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
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
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-builderSKILLS 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.
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.
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-headThis 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 yesCaptions are effectively mandatory: both target platforms autoplay muted.
Move the recording in yourself — init creates an empty raw/, and every
later step reads a fixed filename:
mv "/path/to/IMG_4471.MOV" raw/take.mp4Copy 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.
mlx_whisper raw/take.mp4 --word-timestamps True --output-format json \
--output-dir work/captionsWord-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.
python3 $SKILLS/plan_broll.py work/captions/take.json --out plan/broll.jsonGives 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.
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:
| Kind | Find it | Fetch it |
|---|---|---|
| Article / headline | WebSearch | Chrome navigate + dismiss the cookie wall, then python3 $SKILLS/capture_window.py shot --window Chrome --out raw/broll/NN_slug.png |
| Meme / image | WebSearch | curl -L -o raw/broll/NN_slug.jpg "URL" |
| Video clip | WebSearch | yt-dlp --download-sections "*1:10-1:25" -o raw/broll/NN_slug.mp4 "URL" |
| Nothing good online | — | headline 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 verticalScreenshots: 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.
Stop for approval when the reel is about any of these:
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:
| # | at | what the take says | asset | source |
|---|
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.
From here reel-builder owns it, starting at its Step 4:
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)
clean_audio.py then tighten_vo.py — the audio normalize pass
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 emptyIn 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.
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:
Gate 1 (plan + 540p preview) → render_reel.py --grade clean → Gate 2
Transitions (below) — on the graded master, before captions
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.mp4Export 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 themThis 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).
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.
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.5exports/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 18reframe_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.
Say so rather than approximating, and name the tool that can:
| want | local reality | what actually does it |
|---|---|---|
| Remove room echo / reverb | clean_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 camera | Nothing. ffmpeg has no gaze model. | Descript Eye Contact, NVIDIA Broadcast (RTX only), Sieve |
| Generate music | audiocraft 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 page | Headless 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 |
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 URLTell 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.
raw/, never into work/ — work/ is cleared between sessions.