Guide

PySceneDetect and ffmpeg scdet find the same hard cuts in a studio stream. Split each clip into shots at those cuts so a 9:16 crop never pans across one, or let clipsdk do it at $0.03 per source minute.

Scene cut detection with 2 detectors, and a fresh 9:16 crop at every cut

A vertical reframe follows a face with a smooth camera path, and that path has to restart at every cut or it slides across the edit.

GuidesUpdated

Or one API call

$0.03per source minute

  • Every clip included, up to 1080p60 and 20 Mb/s
  • $1 free to start, 33 minutes of video
  • Prepaid credit, no subscription
See pricing

Cut detectors on the same stream

Run on the full NASA in Silicon Valley Live episode, about 10 minutes at 1280x720.

Cut detectors on the same stream
DetectorSettingCuts foundRuns in
PySceneDetect AdaptiveDetectorDefaults: adaptive_threshold 3.0, min_scene_len 15 frames61Python, decodes with OpenCV
ffmpeg scdetthreshold=8 on a 640 px copy61, all within 0.5 s of an AdaptiveDetector cutAny ffmpeg filter graph
clipsdk reframe: "auto"scdet at 8, plus a check for slow fadesClip 2 renders with the same 8 cutsOne API call

Find the cuts and split a clip into shots

Four steps: get the stream, run PySceneDetect, run ffmpeg scdet, then split one clip at the cuts.

  1. Get the stream

    The NASA in Silicon Valley Live episode is a studio show that cuts between close-ups and two-shots of three people, so it has plenty of hard cuts.

    terminal
    curl -o stream.mp4 https://api.clipsdk.com/f/lib/demo/silicon-valley-live-stream.mp4
    pip install "scenedetect==0.7.2" "opencv-python-headless==5.0.0.93"
    
  2. Find cuts with PySceneDetect

    AdaptiveDetector compares each frame's change with the change around it, which can cut down on false cuts from fast camera moves. detect() returns a start and end per scene, so every scene start after the first is a cut.

    adaptive.py
    import json
    from scenedetect import detect, AdaptiveDetector
    
    scenes = detect("stream.mp4", AdaptiveDetector())
    cuts = [start.seconds for start, _ in scenes[1:]]
    json.dump(cuts, open("adaptive.json", "w"))
    
    print(len(cuts), "cuts")
    print([round(t, 2) for t in cuts[:8]])
    
    # $ python adaptive.py
    # 61 cuts
    # [15.07, 32.47, 38.33, 40.77, 45.67, 62.87, 69.23, 74.37]
    
  3. Find cuts with ffmpeg scdet

    scdet scores the change between frames from 0 to 100 and tags a frame as a cut when the score passes the threshold. The ffmpeg docs call 8 to 14 good values; we use 8 on a 640 px copy, the setting the clipsdk engine uses.

    terminal
    ffmpeg -i stream.mp4 -an -vf "scale=640:-2:flags=area,scdet=threshold=8,metadata=mode=print:key=lavfi.scd.time:file=cuts.txt" -f null -
    grep -c pts_time cuts.txt
    grep -o 'pts_time:[0-9.]*' cuts.txt | head -8
    
    # output:
    # 61
    # pts_time:15.066667
    # pts_time:32.466667
    # pts_time:38.333333
    # pts_time:40.766667
    # pts_time:45.666667
    # pts_time:62.866667
    # pts_time:69.233333
    # pts_time:74.366667
    
  4. Split one clip into shots

    The API ranked the stretch from 36 s to 92 s as its second clip, so we split that range. A cut less than 0.5 s after the last one is folded into the shot before, so a flash or a quick insert does not become a shot of its own.

    shots.py
    import json
    import re
    
    adaptive = json.load(open("adaptive.json"))
    scdet = [float(t) for t in re.findall(r"pts_time:([\d.]+)", open("cuts.txt").read())]
    agree = [t for t in scdet if any(abs(t - a) < 0.5 for a in adaptive)]
    print(len(adaptive), "adaptive cuts,", len(scdet), "scdet cuts,", len(agree), "found by both")
    
    # Split one clip at every cut, and fold slivers under 0.5 s into the shot before
    CLIP_START, CLIP_END, MIN_SHOT_S = 35.95, 91.56, 0.5
    edges = [CLIP_START]
    for t in scdet:
        if CLIP_START + MIN_SHOT_S <= t <= CLIP_END - MIN_SHOT_S and t - edges[-1] >= MIN_SHOT_S:
            edges.append(t)
    edges.append(CLIP_END)
    
    shots = list(zip(edges, edges[1:]))
    print(len(shots), "shots")
    for start, end in shots:
        print(f"{start:6.2f} {end:6.2f}")
    
    # $ python shots.py
    # 61 adaptive cuts, 61 scdet cuts, 61 found by both
    # 9 shots
    #  35.95  38.33
    #  38.33  40.77
    #  40.77  45.67
    #  45.67  62.87
    #  62.87  69.23
    #  69.23  74.37
    #  74.37  85.10
    #  85.10  88.43
    #  88.43  91.56
    

Or send one request

Send the stream with aspect: "9:16" and reframe: "auto". The engine runs scdet at threshold 8 on the same 640 px decode it uses for faces, drops cuts closer than 0.5 s to the last one, splits each clip into shots and restarts the crop in each.

It returned 3 clips from the 10-minute stream. Clip 2 is the range we split by hand above, and the rendered clip holds the same 8 cuts. At $0.03 per source minute, billed per second, the stream costs about $0.30. See pricing.

POST/v1/clipsTry it
curl https://api.clipsdk.com/v1/clips \
  -H "Authorization: Bearer $CLIPSDK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "video_url": "https://api.clipsdk.com/f/lib/demo/silicon-valley-live-stream.mp4",
    "clips": 3,
    "aspect": "9:16",
    "reframe": "auto"
  }'
Clip 2, 56 seconds in 9 shots: the crop restarts at each cut.clip-2.mp4clip-2.srtclip-2.vtt

Why a reframe has to restart at every cut

A 9:16 crop that follows a face is smoothed so it does not shake. Smooth it across a hard cut and the crop eases from where the face was in the last shot to where it is in the next, a pan the camera never made. Viewers read it as a mistake.

So the camera path starts fresh in each shot and snaps to the new face on the first frame. The clipsdk engine splits each clip into shots at scene cuts and never pans across one; a cut or a speaker switch is always a hard cut in the output.

Hard cuts are the easy part

On this stream both detectors found 61 cuts and agreed on every one, because a studio show cuts cleanly between cameras. Fades and dissolves are harder: they spread the change over many frames, so no single frame pair may pass the threshold.

PySceneDetect's adaptive detector helps with camera moves, and its ThresholdDetector handles fades to black. The clipsdk engine adds its own check, looking for a big change of a coarse 4x4 colour grid across about a second, and only splits there when both shots last at least 1 s.

ffmpeg's select filter also has a scene score from 0 to 1. Its docs use select='gt(scene,0.4)' to build a mosaic of scene thumbnails and call 0.3 to 0.5 a sane range. That is handy for thumbnails, but scdet gives you the cut times directly.

Questions, answered

What threshold should I use for ffmpeg scdet?

The ffmpeg docs call 8 to 14 good values and default to 10. The clipsdk engine uses 8 on a 640 px copy, which found the same cuts as PySceneDetect on the studio stream above.

Is PySceneDetect better than ffmpeg for cut detection?

On this studio stream they agreed on all 61 cuts. PySceneDetect gives you several detectors and a Python list of scenes; scdet fits into an ffmpeg graph you already run, such as the decode that feeds your face detector.

Why does my vertical crop slide at a cut?

The camera path is smoothed across the cut, so it eases from the old face position to the new one. Split the path at every cut and snap to the face at the start of each shot.

Does the clipsdk API return the cut times?

No. Each clip comes back rendered, with its crop already split at the cuts. The result lists clips, scores, times, transcripts and files.

Start free

Your back catalog is full of shorts

Your first $1 is free: 33 minutes of video, every clip included up to 1080p60 and 20 Mb/s.

Two studio microphones on a desk in a dark room lit by a red glow