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
name:
broll-sourcing
description:
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.

B-Roll Sourcing

Owns the one thing reel-builder deliberately refuses to do: deciding what to illustrate, and going and getting it. Everything downstream of the asset list is reel-builder's — do not reimplement normalize, render, captions, or export here.

video ─▶ 1.INIT ─▶ 2.TRANSCRIBE ─▶ 3.SLOTS ─▶ 4.SEARCH + FETCH ─▶ ⏸ GATE 0 ─▶ hand off to reel-builder

SKILLS below is the reel-builder scripts dir:

Resolve both tool paths from the repo root, BEFORE you cd — every command below runs inside the dated folder, where a repo-relative path resolves to nothing:

SKILLS="$(cd .claude/skills/tessl__reel-builder/scripts && pwd)"
BROLL="$(cd .claude/skills/broll-sourcing/scripts && pwd)"

Going through the .claude/skills/tessl__reel-builder symlink rather than .tessl/plugins/... also survives the vendored plugin moving on the next tessl install.

Where things go

One dated folder per day, one slug per reel inside it — cd there first and every path below is relative to it:

20260831/bfcm-claude-code/
  brief.md  research.json  script.md      (reel-script's, if it ran)
  raw/ work/ plan/ preview/ exports/      (reel-builder's)

YYYYMMDD, no dashes. The slug level stays even on a one-reel day.

Step 1 — Preflight, then init

Gate on the dependency check before scaffolding anything — it exits non-zero on a missing required tool, and finding out at Step 2 means a project folder already exists for a run that cannot proceed:

python3 $SKILLS/check_deps.py --mode talking-head

This flow additionally needs, beyond what that checks: mlx-whisper (no transcript, no slots and no captions), ImageMagick (gen_captions.py burn renders the caption cards with it), and pyobjc-framework-Vision (the LinkedIn crop). Install what is missing and re-run until clean. Name any optional tool that is absent and what it costs before touching media.

Read reel-builder's SKILL.md and follow its Steps 1–3 (init, probe, contact sheets). One video dropped in means talking-head; still ask the other three questions — aspect, resolution, captions — never infer them.

python3 $SKILLS/pipeline.py init --mode talking-head --target-duration 75 \
    --aspect 9:16 --resolution 1080 --captions yes

Captions are effectively mandatory: both target platforms autoplay muted.

Move the recording in yourselfinit creates an empty raw/, and every later step reads a fixed filename:

mv "/path/to/IMG_4471.MOV" raw/take.mp4

Copy the single file; do not apply reel-builder's symlink rule here. That rule is for gigabyte shoots, and symlinking raw/ at the user's source folder means Step 4 writes fetched b-roll into it.

Trim to the intended take before transcribing. plan_broll.py computes its face-only guards from the transcript's runtime, so a 4-minute recording reserves the first 36 seconds for a reel that will be 90. Extract the real take first (ffmpeg -ss START -to END -i raw/take.mp4 -c copy work/extracts/take.mp4) and transcribe that.

Step 2 — Transcribe

mlx_whisper raw/take.mp4 --word-timestamps True --output-format json \
    --output-dir work/captions

Word-level timestamps are required — plan_broll.py and gen_captions.py both take this exact file. No mlx-whisper? Say so and stop; without a transcript there is nothing to search about, and guessing from the filename is how you end up illustrating a claim the take never makes.

Step 3 — Find the slots

python3 $SKILLS/plan_broll.py work/captions/take.json --out plan/broll.json

Gives coverable sentences with at / until / duration / says, plus a budget: b-roll caps at 40% of runtime, and the opening 15% / closing 10% stay on the face (hook and CTA both need a person). prompt is empty — filling it is Step 4's job, and budget_check counts only filled slots against the ceiling.

plan_broll.py exits non-zero with "No slot survives the guards" on a short take, or one with no sentence ≥1.5s clear of the guards. That is a real answer, not a crash: ship it face-only. Do not lower --min-beat to manufacture slots — a sub-second cutaway is a flicker.

Not every slot wants a picture. Most sentences don't. A slot left empty is the normal outcome; over-filling is the failure mode, and it reads as a slideshow with a voice over it.

Step 4 — Search the web, fetch the asset

Read script.md and research.json first. If reel-script ran, every fact already carries a url, a shoot kind, and a note saying what to capture.

Join through script.md, not through text similarity. A fact's claim is a research-time paraphrase ("BFCM traffic is nothing like a normal day"); a slot's says is Whisper transcribing an improvised delivery ("Last year Shopify merchants did fourteen point six billion") — those match on no string measure. script.md tags every beat with its fact id (### STAKES — 0:05–0:22 · [f1]), so: beat → [fN] → find that beat's words in the transcript → that is the slot.

Diff the transcript against script.md before you fetch anything. Report which [fN] beats actually survived the take. A beat that was improvised away must not get its screenshot — illustrating a claim that was never said is worse than leaving the slot empty. Searching again is wasted work and risks illustrating a claim with a different source than the one the script was written against.

Only search for slots research.json does not cover (or when it does not exist). Read the slot's says; where a specific, checkable claim lands — a company, a number, a launch, a named opinion — search for something that backs it. Pick the cheapest kind that does the job:

KindFind itFetch it
Article / headlineWebSearchChrome navigate + dismiss the cookie wall, then python3 $SKILLS/capture_window.py shot --window Chrome --out raw/broll/NN_slug.png
Meme / imageWebSearchcurl -L -o raw/broll/NN_slug.jpg "URL"
Video clipWebSearchyt-dlp --download-sections "*1:10-1:25" -o raw/broll/NN_slug.mp4 "URL"
Nothing good onlineheadline card via make_cards.py (see reel-builder)

The Chrome screenshot tool returns an image into the conversation — it does not write a file. capture_window.py is what puts a PNG on disk (it captures one window by id, so no menu bar or cursor). It needs Screen Recording permission for the terminal.

Pad every screenshot to the target aspect before normalizing — do not let it crop. normalize_clips.py crops a still to fill the frame, and kb: in then pushes ~1.1x further in: two compounding crops. A 2.5:1 article screenshot lost the first two words of its headline before the zoom even started. Fit the image inside ~84% of the frame and pad the rest, so the Ken Burns move has room to travel without reaching content:

magick shot.png -resize 1610x900 -background "#0F1D2B" -gravity center \
    -extent 1920x1080 work/broll_pad/shot.png     # 16:9; use 1080x1350 / 1080x1920 for vertical

Screenshots: full browser window is fine — do not crop. normalize_clips.py crops the still to 9:16 and pans across it, and a frame already cropped to aspect leaves it no pixels to move through. Dismiss cookie banners and paywall modals before shooting; scroll the headline and dek into frame.

Video clips: keep each under the slot's duration. Downloaded clips are ordinary footage from here on — they get probed and normalized like anything in raw/.

Rights. Prefer sources you may reuse, credit the publication on screen for any article shot, and tell the user once per project that memes and clips lifted from search results carry the uploader's rights, not theirs.

Write each choice back into the slot, keeping prompt filled so the budget check sees it:

Slots live in plan/broll.json under the images array — edit them in place; a new top-level object breaks the budget command above. Carry the fact id and the publication across, and reuse research.json's field names (shoot, publication) rather than inventing synonyms:

{"name": "still_03", "at": 18.4, "until": 21.4, "duration": 3.0,
 "says": "Garmin bought TrainingPeaks in July",
 "prompt": "TechCrunch headline on the Garmin/TrainingPeaks acquisition",
 "fact": "f3", "shoot": "article", "url": "https://...",
 "file": "raw/broll/03_garmin.png", "publication": "TechCrunch"}

plan/broll.json is natively a gen_images.py spec and this flow repurposes it. gen_images.py is not part of this flow — running it against a filled file would generate AI images over the real assets you just fetched.

⏸ Gate 0 — approve the asset list (conditionally)

Stop for approval when the reel is about any of these:

  • live litigation or a regulator's complaint — what sits on screen beside a claim decides whether it reads as allegation or as established fact, and that is the author's legal exposure, not yours
  • a named company's alleged wrongdoing
  • an identifiable person's numbers, screenshots or handle
  • anything where a wrong asset would be a correction rather than a re-render

Otherwise do not stop. Fetch, render, and present the finished cut. The gate exists because fetching costs two minutes and rendering three formats costs fifteen — but a gate the author approves unchanged every time is pure friction, and this pipeline runs two to three reels a week. If an asset is wrong in the finished cut, swapping one file and re-running finish_reel.py is a single command.

Nothing gets edited before this. Present one table:

#atwhat the take saysassetsource

Show the images (Read the PNGs/JPEGs) — a headline that screenshotted as a cookie wall is invisible in a table. Report the filled-vs-ceiling budget. plan_broll.py prints offered seconds at generation time and its budget_check() is a library function main() never calls, so compute the filled number here:

python3 -c "import json; d=json.load(open('plan/broll.json')); \
u=sum(s['duration'] for s in d['images'] if s.get('prompt','').strip()); \
print(f\"{u:.1f}s filled / {d['budget']['ceiling_seconds']}s ceiling\")"

Then ask: approve / swap one / drop one / paste your own URL. Loop until they say go. Record the settled list in project.md.

Step 5 — Hand off

From here reel-builder owns it, starting at its Step 4:

  1. normalize_clips.py — mezzanine for the take and every fetched asset; stills become Ken Burns clips (set kb: in for a screenshot, so it pushes into the headline rather than panning off it)

  2. clean_audio.py then tighten_vo.py — the audio normalize pass

  3. Author plan/cut_plan.json by hand per references/talking-head.md; face in-points keep their true source timing or lip-sync breaks. B-roll over ~1s needs --vo-overlay

    Never take cut boundaries from Whisper segment edges. They sit flush against speech, and render_reel.py applies a 30ms audio fade at every cut to prevent pops — so a boundary 20ms from a word puts that fade on the word and the take sounds like it is swallowing syllables. Place every boundary at the midpoint of the silence between words, with ~120ms of margin, and assert that none falls inside a word:

    clipped = [w for w in words if w["start"] < t < w["end"]]   # must be empty

    In one take, three of twelve boundaries cut mid-word and most of the rest sat within 40ms. Fixing it cost 3.4s of trailing silence and nothing else.

  4. verify_cut.py after any audio-driven edit — and believe it. It re-transcribes the cut and catches words eaten at a join, which check_cuts.py cannot see. Two things about reading it honestly:

    • Run it on the music-free VO, never the mixed master. A music bed degrades ASR enough to drop the score ~13 points with nothing else changed, which makes a real failure indistinguishable from noise.
    • Do not argue with a FAIL because the word counts match. Substitutions can preserve the count while a word is gone. Check the boundaries structurally instead (below); that is the ground truth.
  5. Gate 1 (plan + 540p preview) → render_reel.py --grade clean → Gate 2

  6. Transitions (below) — on the graded master, before captions

  7. Re-transcribe the finished picture, then caption it. talking-head.md:387 is explicit: caption against the final cut, not the raw take — tighten_vo.py and the cut plan both move every timestamp after the first edit, so Step 2's words file is offset by the accumulated trim:

    mlx_whisper work/master_fx.mp4 --word-timestamps True \
        --output-format json --output-dir work/captions      # -> master_fx.json
    python3 $SKILLS/gen_captions.py review work/captions/master_fx.json \
        --out work/captions/phrases.json
    python3 $SKILLS/gen_captions.py burn work/captions/phrases.json \
        --video work/master_fx.mp4 --out work/master_final.mp4 --crf <master CRF>

    Review the transcript before burning — Whisper mishears names and numbers.

The chain, and the file the exports must read:

work/master.mp4 ──transitions──▶ work/master_fx.mp4 ──burn──▶ work/master_final.mp4

Export work/master_final.mp4. Exporting master.mp4 ships the reel with no captions and no transitions, and nothing errors — the files just look wrong.

Captions are free when the take is synthetic. If the words were written rather than spoken, do not correct ASR — force-align the known script text onto the ASR timings: the script supplies every word, Whisper supplies only the clock. Zero corrections instead of twenty-one, on the same script.

for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, S, A).get_opcodes():
    # equal  -> script word takes the ASR word's timing
    # replace/delete -> script words split the ASR span evenly
    # insert -> ASR heard words the script lacks; drop them

This only works when the text is exact, so it is available on the avatar-take path and never on a recorded one — a real take improvises, and alignment fights every departure.

Pass --font explicitly. ImageMagick on macOS has no Helvetica-Bold, so gen_captions.py burn dies with unable to read font. Use the system font: --font /System/Library/Fonts/SFNS.ttf --font-italic /System/Library/Fonts/SFNSItalic.ttf.

Correct the transcript before burning — this is load-bearing, not optional. Whisper mishears money, product names and technical terms exactly where a reel puts them on screen. Observed in one take: $1,095→"$1,99", "Black Friday is"→"The criteria is", "meta ads account"→"meta-ass account", "Code counts, model interprets"→"Caut counts model interpreted". Diff the ASR text against whatever clean transcript exists (Riverside, Zoom, the script) and fix it word by word. Two further traps: Whisper splits numbers into separate tokens ($5 + .1 million), and the phrase grouper then breaks them across two caption cards — glue number-unit pairs into one token before review.

Match --crf at every hop. transitions.py and gen_captions.py burn both default to 16; if the master was rendered at --crf 12, the default silently throws that away (talking-head.md:383 measures one case at 80 Mbps down to 44).

Transitions

render_reel.py concatenates with -c copy, so every join is a hard cut. Add hits on the graded master, before captions are burned:

python3 $BROLL/transitions.py work/master.mp4 plan/cut_plan.json \
    --out work/master_fx.mp4 --crf <same as the master>

Skip this step unless the reel wants it. It is a full extra encode generation, and a talking-head take with three cutaways does not need flash hits. When skipped, master.mp4 feeds the caption burn directly.

Styles: flash (dip to white, default), dip (to black), whip (horizontal smear). Default 0.10s — three frames at 30fps.

Karaoke captions (the dominant 2026 short-form style — word-by-word active highlight) come free from gen_captions.py's per-word emphasis field: expand each phrase into one event per word, each carrying the full phrase text with emphasis: [i] on the active word. Two traps, both hit in practice: word timings can overlap after the expansion (clamp each event's end to the next event's start, then drop any that collapse), and the expansion multiplies render cost by the average words-per-phrase.

It only hits cuts where the source clip changes, so cutaways to a screenshot get a transition and word trims inside the take stay invisible. A flash on a same-clip join announces the edit you were trying to hide.

Every style is duration-preserving — the effect is keyed to a time window with ffmpeg's enable, so no frame moves and verify_cut.py still passes. That is the whole reason there is no crossfade here: xfade consumes its own duration, and the VO laid over the timeline by --vo-overlay would desync from the first dissolve onward. If you genuinely want dissolves, pad the segment durations in the cut plan to pay for them — don't reach for xfade on a finished master.

Restraint is the point: hits on every cutaway is already a lot. Use --at to place them by hand when the plan's cuts aren't where you want the energy.

Step 6 — Export for Instagram + LinkedIn

Gate on loudness before you call anything final. Nothing upstream normalises: normalize_clips.py is geometry only and render_reel.py leaves a VO-spine plan at source level. A quiet recording therefore reaches the export untouched — one measured take arrived at -42.4 LUFS, about 28 dB under target, and shipped silent because only some paths applied loudnorm. Measure every export, and reject anything outside roughly -16..-13:

ffmpeg -hide_banner -nostats -i exports/FILE.mp4 -af ebur128 -f null - 2>&1 | grep -E "^\s+I:"

Apply -af loudnorm=I=-14:TP=-1.5:LRA=11 on any export that misses.

python3 $SKILLS/export_variants.py work/master_final.mp4 --outdir exports/ --thumb-at 1.5

exports/ig_reels.mp4 is the Instagram deliverable and uploads to LinkedIn as-is. But LinkedIn crops 9:16 in-feed, so a 4:5 cut wins more feed height.

Fork the LinkedIn cut BEFORE the caption burn, not after. Cropping a already-captioned master and re-burning stamps a second caption track over the first. At 4:5 the default 0.72 caption position clips, so burn this branch at 0.62 unconditionally rather than checking and re-doing it.

The crop offset belongs to the crop width, not the output width. Passing the output width (1080) for a 608-wide crop put the window at 384–992 while the face sat at 925 — hard against the edge, and cut. It hit both vertical formats; only 9:16 was obvious. finish_reel.py parses the width from the crop template so the two cannot disagree, and asserts the face lands within 2% of the window centre. Note the test that matters is distance from centre, not membership: the buggy window still contained the face centre, 67px from its edge, with 200px of face hanging past it.

Never hardcode the crop offset. A centre crop (y=285) beheads anyone framed with normal headroom, which is most talking-head takes. Let Apple Vision place it — this is CapCut's auto-reframe:

CROP=$(python3 $BROLL/reframe_offset.py work/master_fx.mp4 --height 1350)

ffmpeg -i work/master_fx.mp4 -vf "$CROP" \
    -c:v libx264 -crf 18 -pix_fmt yuv420p -c:a copy work/linkedin_cut.mp4

python3 $SKILLS/gen_captions.py burn work/captions/phrases.json \
    --video work/linkedin_cut.mp4 --out exports/linkedin_feed.mp4 \
    --position 0.62 --crf 18

reframe_offset.py reports how many faces it found on stderr; zero means it fell back to a centre crop, so eyeball that one. Needs pyobjc-framework-Vision — without it, pick the offset by eye from a frame grab rather than guessing.

Then offer copy per platform: Instagram wants the hook in the first line and hashtags at the end; LinkedIn wants two or three sentences of context above the fold and no hashtag wall.

What this pipeline cannot do locally

Say so rather than approximating, and name the tool that can:

wantlocal realitywhat actually does it
Remove room echo / reverbclean_audio.py falls back to ffmpeg afftdn, which is denoise, not dereverb. DeepFilterNet is also broken against torchaudio ≥2.1 (imports the removed torchaudio.backend).Descript Studio Sound
Make the speaker's gaze meet the cameraNothing. ffmpeg has no gaze model.Descript Eye Contact, NVIDIA Broadcast (RTX only), Sieve
Generate musicaudiocraft needs xformers, which has no Apple Silicon wheels — dead end. Use HuggingFace transformers MusicGen instead, which has no such dependency.transformers MusicgenForConditionalGeneration
Screenshot an auth-walled pageHeadless Chrome has no session: x.com 403s, LinkedIn covers its own headline with a sign-in modal. capture_window.py needs Screen Recording permission for the terminal.grant the permission, or build an attributed quote card

Descript API (when a token is available)

Base https://descriptapi.com/v1, Authorization: Bearer <token>. Read the token from the environment — never write it into a file, a plan, or a skill.

POST /jobs/import/project_media   {project_name, add_media:{name:{content_type,file_size}}}
PUT  <upload_url>                 raw bytes, Content-Type: application/octet-stream
POST /jobs/agent                  {project_id, prompt}   # natural language: "apply Studio Sound and Eye Contact"
GET  /jobs/{job_id}               poll until job_state == "stopped"
POST /jobs/publish                {project_id, resolution} -> download URL

Tell the agent explicitly not to cut, trim, reorder or caption — it will otherwise re-edit the take out from under a cut plan built against the original timeline. Needs a paid plan (Creator and up); usage draws AI credits.

Principles

  • Search about what the take says, never about the filename or the topic in the abstract.
  • An empty slot beats a weak asset. The ceiling is a limit, not a target.
  • Fetch into raw/, never into work/work/ is cleared between sessions.
  • One gate here (the asset list). The other two are reel-builder's; all three are mandatory.
Workspace
andrebrov
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
andrebrov/broll-sourcing badge