CtrlK
BlogDocsLog inGet started
Tessl Logo

andrebrov/broll-sourcing

Drop in one talking-head video and get it finished — transcribe it, search the web for supporting b-roll (article screenshots, memes, images, video clips), present the asset list for approval, then hand off to reel-builder to normalize audio, burn subtitles, cut the b-roll in, and export for Instagram and LinkedIn. Use when the user drops a video and wants illustrations/screenshots/memes added, asks to "edit this like CapCut", wants b-roll found for them, or wants a talking-head take prepped for Instagram/LinkedIn. Sourcing and approval only — all rendering belongs to the reel-builder skill.

74

Quality

93%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

transitions.pyscripts/

#!/usr/bin/env python3
"""Put a transition hit on every cut where the picture changes source.

render_reel.py concatenates segments with `-c copy` — hard cuts, no
transitions. This is a post-process on the graded master, run BEFORE captions
are burned.

WHY NOT xfade. A crossfade consumes time: each one shortens the picture by its
duration, and a talking-head reel's audio is a continuous VO laid over the
whole timeline (--vo-overlay), so the first dissolve desyncs everything after
it. Every style here is DURATION-PRESERVING — the effect is keyed to a time
window with ffmpeg's `enable`, so not one frame moves and verify_cut.py still
passes. If you truly want dissolves, they belong in the cut plan with segment
durations padded to pay for them, not here.

Styles (all ~3 frames at 30fps by default):
  flash  dip to white — the default; reads as energy, survives IG's recompress
  dip    dip to black — heavier, for a topic change
  whip   horizontal smear — mimics a whip pan into a cutaway

Cuts are read from the plan: only where the SOURCE CLIP CHANGES. A word trim
inside one take is meant to be invisible, and a flash there announces the edit.

Usage:
  transitions.py work/master.mp4 plan/cut_plan.json --out work/master_fx.mp4
  transitions.py work/master.mp4 plan/cut_plan.json --style whip --duration 0.13
  transitions.py work/master.mp4 --at 3.4 8.1 --out work/master_fx.mp4
  transitions.py --selftest
"""
import argparse, json, subprocess, sys
from pathlib import Path

STYLES = {
    "flash": "eq=brightness=0.6:contrast=1.15",
    "dip":   "eq=brightness=-0.9",
    "whip":  "avgblur=sizeX=48:sizeY=1",
}


def source_change_cuts(segs):
    """Times where the picture changes clip. Same-clip joins stay invisible."""
    cuts, t = [], 0.0
    for a, b in zip(segs, segs[1:]):
        t += float(a["duration"])
        if a.get("clip") != b.get("clip"):
            cuts.append(round(t, 3))
    return cuts


def filter_expr(cuts, style, duration, total=None):
    """One timeline-enabled filter covering every cut. '' if nothing to do."""
    half = duration / 2.0
    windows = []
    for c in cuts:
        lo = max(0.0, c - half)
        hi = c + half if total is None else min(total, c + half)
        if hi > lo:
            # commas escaped: this lands inside a -vf filtergraph
            windows.append(f"between(t\\,{lo:.3f}\\,{hi:.3f})")
    if not windows:
        return ""
    return f"{STYLES[style]}:enable='{'+'.join(windows)}'"


def selftest():
    segs = [{"clip": "a.mp4", "duration": 2.0}, {"clip": "a.mp4", "duration": 1.0},
            {"clip": "b.mp4", "duration": 3.0}, {"clip": "a.mp4", "duration": 1.5}]
    # 2.0 is a same-clip join (invisible); 3.0 and 6.0 change source.
    assert source_change_cuts(segs) == [3.0, 6.0], source_change_cuts(segs)
    assert source_change_cuts([{"clip": "a.mp4", "duration": 5.0}]) == []

    f = filter_expr([3.0], "flash", 0.1)
    assert f == "eq=brightness=0.6:contrast=1.15:enable='between(t\\,2.950\\,3.050)'", f
    # A cut at 0 clamps to the frame start rather than going negative.
    assert "between(t\\,0.000\\," in filter_expr([0.02], "dip", 0.1)
    # Clamped to total, and a window past the end is dropped entirely.
    assert filter_expr([9.99], "whip", 0.1, total=10.0).endswith("9.940\\,10.000)'")
    # A hit entirely past the end is dropped rather than emitting an
    # inverted window, which ffmpeg accepts and silently never fires.
    assert filter_expr([10.5], "whip", 0.1, total=10.0) == ""
    assert filter_expr([], "flash", 0.1) == ""
    # Multiple windows OR together with '+' — verified against ffmpeg, but
    # nothing here would catch a regression in the join itself.
    assert filter_expr([1.0, 2.0, 3.0], "flash", 0.1).count("between") == 3
    print("ok")


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("video", nargs="?")
    ap.add_argument("plan", nargs="?", help="cut_plan.json; omit if using --at")
    ap.add_argument("--out")
    ap.add_argument("--style", choices=sorted(STYLES), default="flash")
    ap.add_argument("--duration", type=float, default=0.10,
                    help="Seconds per hit (default 0.10 = 3 frames at 30fps)")
    ap.add_argument("--at", nargs="*", type=float, default=None,
                    help="Explicit cut times, overriding the plan")
    ap.add_argument("--crf", type=int, default=16, help="Match the master's CRF")
    ap.add_argument("--selftest", action="store_true")
    a = ap.parse_args()

    if a.selftest:
        return selftest()
    if not a.video or not a.out:
        ap.error("need a video and --out (or --selftest)")

    if a.duration <= 0:
        ap.error("--duration must be positive")
    if a.at is not None and not a.at:
        ap.error("--at needs at least one time")
    if a.at is not None:
        cuts = a.at
    elif a.plan:
        plan = json.loads(Path(a.plan).read_text())
        cuts = source_change_cuts(plan["segments"])
    else:
        ap.error("pass a cut_plan.json or --at")

    total = float(subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
         "-of", "csv=p=0", a.video], capture_output=True, text=True
    ).stdout.strip() or 0) or None

    vf = filter_expr(cuts, a.style, a.duration, total)
    if not vf:
        sys.exit("No source-change cuts found — nothing to do. "
                 "Use --at to place hits by hand.")

    print(f"{len(cuts)} {a.style} hits at: "
          f"{', '.join(f'{c:.2f}s' for c in cuts)}", file=sys.stderr)
    out = Path(a.out)
    out.parent.mkdir(parents=True, exist_ok=True)
    tmp = out.with_name(out.stem + ".tmp" + out.suffix)
    # +faststart matches render_reel.py; without it this pass moves the moov
    # atom to the end of the file and kills progressive playback on the web.
    subprocess.run(["ffmpeg", "-y", "-i", a.video, "-vf", vf,
                    "-c:v", "libx264", "-preset", "medium", "-crf", str(a.crf),
                    "-pix_fmt", "yuv420p", "-movflags", "+faststart",
                    "-c:a", "copy", str(tmp)], check=True)
    tmp.rename(out)
    print(f"wrote {out}", file=sys.stderr)


if __name__ == "__main__":
    main()

SKILL.md

tile.json