Clip discovery

rot clips TARGET finds the strongest short-form windows in a source. TARGET can be a YouTube video, an authorized Twitch clip, a local video file, or a folder of existing footage.

A YouTube video

Install the optional YouTube integration, then download a permitted video and export ranked MP4 segments:

uv sync --extra youtube
uv run rot clips "https://www.youtube.com/watch?v=VIDEO_ID" \
  --method hybrid --duration 30 --count 5 -o clips

A Twitch clip

Register a Twitch application and supply a user access token with channel:manage:clips or editor:manage:clips. Twitch’s official download endpoint only permits the broadcaster or an authorized channel editor to download that channel’s clips.

uv sync --extra twitch
export ROT_TWITCH_CLIENT_ID=...
export ROT_TWITCH_ACCESS_TOKEN=...
uv run rot clips "https://clips.twitch.tv/CLIP_ID" \
  --method hybrid --duration 20 --count 2 -o clips

Landscape is downloaded by default so rot retains the original composition. If Twitch has an official portrait rendition for the clip, select it with --twitch-variant portrait. Requesting a variant that does not exist fails rather than silently changing orientation.

A local file

uv run rot clips ./recording.mp4 --duration 20 --count 4 -o clips

A folder of footage

Point rot at a library and it ranks windows across every video it finds, recursing by default and reporting anything it could not read instead of aborting the scan:

uv run rot clips ./gameplay-archive --duration 15 --count 8 -o clips

Use --no-recursive to stay in the top directory, and --max-per-source to stop one strong video from taking every slot.

from rot import ClipDetectionSettings, FolderClipFinder

finder = FolderClipFinder(
    ClipDetectionSettings(clip_duration=15, clip_count=8, max_per_source=2)
)
result = finder.find("./gameplay-archive", "build/clips")

for candidate in result.candidates:
    print(candidate.source.name, candidate.start, candidate.score)
for skipped in result.skipped:
    print("skipped", skipped.path, skipped.reason)

Analysis results are cached on disk, keyed by the file’s path, modification time, size, and the settings that affect decoding. Re-running a search with a different --count, --duration, or blend weight re-ranks the cached signals without decoding the video again. Pass --no-cache to force a fresh decode. Cache invalidation uses modification time and size rather than file contents, so an edit that preserves both will not be noticed.

Choosing a method

hybrid blends visual scene-change strength, frame-to-frame motion, and short-window RMS audio energy, then rejects heavily overlapping candidates.

Method Use it for
hybrid The default. Mixed content where any of the three signals may carry the moment.
scene Edited montages and silent footage, where hard cuts are the signal.
motion Gameplay and action footage that moves constantly without cutting.
audio Podcasts, interviews, and reactions, where energetic speech matters more than cuts.

Pass --download-only to keep source.mp4 and inspect the suggested ranges without exporting; it applies to YouTube and Twitch targets.

from rot import ClipDetectionSettings, Project, YouTubeClipFinder

finder = YouTubeClipFinder(
    ClipDetectionSettings(method="hybrid", clip_duration=25, clip_count=3)
)
result = finder.find("https://youtu.be/VIDEO_ID", "build/youtube-clips")
project = Project.short_form().background(result.project_clips()[0])

The equivalent official Twitch API is explicit about its credentials:

import os

from rot import ClipDetectionSettings, TwitchClipFinder

finder = TwitchClipFinder(
    ClipDetectionSettings(method="hybrid", clip_duration=20, clip_count=2),
    client_id=os.environ["ROT_TWITCH_CLIENT_ID"],
    access_token=os.environ["ROT_TWITCH_ACCESS_TOKEN"],
)
result = finder.find("https://clips.twitch.tv/CLIP_ID", "build/twitch-clips")

All three signals are extracted in a single FFmpeg pass, with progress reported as it decodes.

How ranking works

Each candidate window [a, b) of duration D gets one score per signal, and the signals are blended. Every constant below is a ClipDetectionSettings field you can tune.

Scene — cut density passed through a saturating curve:

density     = (sum of cut strengths in the window) / D
scene_score = density / (density + scene_half_saturation)

Dividing by D makes the score independent of window length, so a 40-second window no longer outranks a 20-second one purely by containing more cuts. The curve is strictly increasing and approaches 1 without reaching it, so busy windows stay ordered rather than all clamping to the same value. scene_half_saturation defaults to 0.25, which scores 0.5 at one full-strength cut every four seconds — short-form pacing typically runs between one cut every two seconds (0.67) and every eight (0.33), placing the interesting range across the middle of the curve.

Motion — the mean of signalstats YDIF, normalized against motion_reference (default 12.0) and clamped to [0, 1]. A static talking head sits around YDIF 1–3, handheld and gameplay footage around 8–15, and whip pans above 20. The mean is used rather than the peak because a hard cut spikes YDIF, and taking the peak would make this signal largely redundant with the scene score. YDIF is measured at a fixed motion_fps (default 15.0) so the value means the same thing on a 24 fps and a 60 fps source.

Audio — RMS level normalized between audio_floor_db and audio_ceiling_db, then combined:

audio_score = audio_mean_weight * mean + audio_peak_weight * peak   (weights normalized)

Defaults are 0.70 and 0.30. The mean term rewards sustained energy, so continuous speech beats one shout in dead air; the peak term keeps a punchline or impact from being averaged away.

Blendingscene_weight (0.35), motion_weight (0.20), and audio_weight (0.45) are renormalized over the signals actually present, so a video with no audio track falls back to visual-only scoring with rescaled weights instead of silently scoring low. Motion carries the smallest share because YDIF is the noisiest of the three — grain, compression artifacts, and camera shake all inflate it — so it breaks ties and rescues action footage without driving the ranking.

Boundary quality — a window is scored down by up to boundary_penalty (default 0.15) in proportion to how loud the audio is at its two cut points, measured over edge_probe seconds. The penalty is deliberately small: it breaks ties between comparable windows rather than promoting a dull one. Separately, selected clips snap onto a nearby scene cut or audio trough within snap_window seconds so they begin cleanly, preserving clip duration. Set snap=False (or --no-snap) to keep the raw ranked ranges.

Results

ClipSearchResult.candidates holds ranked ClipCandidate objects. Each one carries the source it came from, its time range, the blended score, and the scene_score, motion_score, and audio_score that produced it. as_clip() turns a candidate into a trim-aware Clip without re-encoding, and project_clips() does the same for the whole result.

When fewer clips are found than requested — a short source, or a max_overlap_ratio too strict to fit that many distinct windows — the shortfall is reported in ClipSearchResult.warnings and through the rot logger rather than passing silently.

Exports are named after their source and time range, so re-running a search produces the same filenames and will not clobber unrelated clips that happen to share a stem. Use --overwrite-exports to replace them, --overwrite-downloads to re-fetch source.mp4, or -f for both.

Exports preserve source dimensions but are accurately cut and encoded as H.264, AAC 48 kHz stereo MP4s. A later Project render applies the vertical output contract. Only download and reuse media you have permission to process. yt-dlp handles availability, age gates, regional restrictions, and authentication, which can still prevent a download.