B-roll overlay
Lays silent cutaway clips over a finished base video: the base audio runs
untouched underneath, the picture cuts away and returns, and the final duration
equals the base duration exactly. That is the whole contract — overlay, never
concatenation. A previous run concatenated: 24s for a 15s base, voice dead 12s.
Assembly mechanics live entirely in scripts/broll_overlay.py; this file is
judgment only — when to enter, where the windows go, what the user sees first.
Entry condition
Two things must already exist. This skill never generates the base — if there
isn't one yet, say so and stop.
A finished, approved base video with exactly one audio stream. Approved
means the user has watched it and signed off — b-roll is a polish pass over a
locked cut. The script stream-copies one voice track under the picture, so a
two-stream base is rejected: ffmpeg -i base -map 0:v -map 0:a:0 -c copy.
Its transcript, with timings. One call to POST /v1/transcripts with
the base's jobId (or its assetId if it was uploaded). It returns text,
word-level words[] and segments[] — timings in SECONDS, which is what
the EDL below takes — plus an srt you can ignore here. Price it with the
transcript arm of POST /v1/estimates and quote that number, never one from
this file. The meter is per minute of source, rounded up from a one-minute
minimum, and a repeat of the same source is free, so asking twice costs
once. No local install.
(The captions endpoint still cannot supply this — it burns subtitles into
a new MP4 and returns no text or timings, verified 2026-08-04 and re-checked
when transcripts shipped. That finding is correct and is kept here so nobody
re-discovers it and files a bug against captions.)
Offline fallback: a local whisper pass (whisper-cli or openai-whisper)
still works and is the path when there is no API key or no network. Note
whisper reports timings in milliseconds where the API reports seconds, and
whisper-cli with no model downloaded returns an EMPTY transcript rather than
an error — which reads exactly like a bad render. See README for the model
download.
Placement is read from the transcript, not eyeballed.
REST key required. A Novoads MCP connector is not a substitute. If
NOVOADS_API_KEY is missing or still the placeholder, stop before any
generation work and tell the user: "Before continuing, create an API key at
https://novoads.ai/dashboard/settings?tab=api and paste it into .env."
That holds even when mcp__novoads__* tools are connected and authenticated in
the session. Never call mcp__novoads__* tools from this repo's workflows: they
are a different surface with different behavior, including the units they quote
costs in. Repo installs verify with ./scripts/check-novoads-env.sh; a solo
install checks NOVOADS_API_KEY in the environment.
Pack version. Every /v1 response carries X-Novoads-Pack-Version; mention a newer pack at https://github.com/novoads/agent-skills only when that header names a version NEWER than this file's metadata.packVersion — equal or older is nothing to say, and it is never a reason to stop.
Workflow
- Read the transcript. Note what is said and when — the words drive every
decision below.
- Generate the b-roll clips, only after base approval. One clip per moment
you intend to cover, each rendered silent (
audioEnabled: false). Overlay
audio is ignored by the script regardless, and generating before approval
spends credits on a cut that may still change.
Price the set live first — POST /v1/estimates this session, the total
shown against the user's balance, an explicit yes. Four cutaways is four
charges, and a remembered price is not a price.
Cast the base actor in every cutaway that shows a person: pass the same
referenceAssetIds the base used — Seedance re-casts every render, and a
stranger's hands or skin breaks the ad (observed live 2026-08-04: Black base
actor, five white-cast cutaways). With no identity ref, keep people out of
frame entirely — objects and environments, not "face out of frame".
- Propose an EDL. JSON with
base, output, and overlays (file,
start, end, covers). covers is mandatory and quotes the spoken line
the window illustrates — a window you can't quote is one you're guessing at.
- Show the EDL to the user and wait for approval. Rendering before the user
has seen the plan is precisely the failure this step prevents.
- Render:
python3 scripts/broll_overlay.py edl.json
- Report the script's verification output verbatim — the measured durations,
the audio check and the window count, not a paraphrase. Then offer 2–3 cut
variations (different windows, fewer cutaways, a tighter opener) to react to.
Placement judgment
Default cadence, measured 2026-08-04 frame by frame from the reference edit this
pack reproduces — ~15.5s, 10–11 shots, a cut every ~1.4s:
- Alternate A-B-A-B: the face returns between every cutaway. Never two
overlay windows back to back; the talking head is the spine.
- 4–6 windows per 15s, ~1–1.5s each, ~40–45% coverage. Short and frequent.
This retires our older "fewer, longer windows" rule — taste, reversed by
measurement; our own two renders ran 2 windows of 2.5s/2.0s, ~30% coverage.
- The b-roll may travel; the base never does. The reference visits 4
distinct b-roll settings across 2–3 rooms; the talking head never moves.
- Never cover the opening hook beat. The first beat is a face making a
claim; cutting away there spends the retention the hook just bought.
- End on the person, not on b-roll. The closing beat is the verdict, and it
only lands if the viewer is looking at whoever delivers it.
- Put product shots where the product is being spoken about. B-roll that
illustrates the current sentence reads as evidence; anything else is filler.
Default shot plan. The reference's five cutaways tell one arc: problem →
stress → product macro → dose/usage → relief — 2 product shots to 3 emotional
beats. Adapt the imagery to the script, keep the ratio.
Evidence: one measured edit (n=1) — a default, not a law; depart deliberately and
say why (evals.md OV3/OV6). Every run prints its own cadence against this
envelope; --stats prints it without rendering.
Hard rule: assembly always goes through the script
Never hand-write overlay or concat ffmpeg for this task. broll_overlay.py owns
validation, rendering and verification, and it fails loudly on exactly what
fails silently by hand: duration drift, re-encoded audio, overlapping or
zero-frame windows, a clip shorter than its window, a rotated phone base
composited at the wrong geometry, a window that quietly composited nothing.
Improvised ffmpeg is how the founding failure happened.
It needs ffmpeg 7.1 or newer. The script scales each overlay against the base
branch with scale=w=rw:h=rh, and those reference constants arrived in 7.1. On an
older build — Ubuntu 24.04's apt ships 6.1 — the graph does not parse and the
error names rw, not a version. Check with ffmpeg -version before blaming the
EDL; on Debian/Ubuntu the fix is a static build, not apt install ffmpeg.
Escape hatch: if a request genuinely doesn't fit the EDL model — a speed
ramp, a picture-in-picture inset, audio that actually needs editing — say so and
ask the user how to proceed. Do not improvise around the script.
Other modes
python3 scripts/broll_overlay.py edl.json --dry-run — validate the EDL and
print the plan, rendering nothing. Cheap way to check window geometry.
python3 scripts/broll_overlay.py edl.json --stats — window count, lengths,
coverage, base-return gaps, each marked against the envelope. Never an error.
python3 scripts/broll_overlay.py --verify-only FINAL --base BASE [--edl edl.json]
— re-check any output against its base, including one from an old session.
Pass --edl whenever you have it: without it the check is duration +
audio only, which a plain copy of the base also passes; with it each window's
midpoint frame is compared against the base, so a window that composited
nothing fails. A run without --edl says so out loud.
Exit codes: 2 validation, 3 render, 4 verification. A nonzero exit is a
real failure — surface it, don't retry blindly. Renders go to a hidden temp file
beside the output, renamed only after verification passes: a failed run never
leaves a half-written or unverified file at the output path.
Evals
evals.md defines the six scenarios this skill is held to (OV1–OV6).
scripts/test_broll_overlay.py implements the mechanical ones against synthetic
fixtures — run it after touching the script.
1---2name: broll-overlay3description: Overlays silent b-roll cutaway clips onto a finished base video — the base audio keeps running underneath while the picture cuts away and returns, and the final duration always equals the base duration. Use when the user asks to add b-roll, add cutaways, or overlay clips on a video: "add b-roll to this", "cut away to a product shot here", "overlay these clips on my UGC ad". Entered from an approved base video plus its transcript, which comes from POST /v1/transcripts and needs no local install; it never generates the base itself and it never extends a video.4---56# B-roll overlay78Lays silent cutaway clips **over** a finished base video: the base audio runs9untouched underneath, the picture cuts away and returns, and the final duration10equals the base duration exactly. That is the whole contract — overlay, never11concatenation. A previous run concatenated: 24s for a 15s base, voice dead 12s.1213Assembly mechanics live entirely in `scripts/broll_overlay.py`; this file is14judgment only — when to enter, where the windows go, what the user sees first.1516## Entry condition1718Two things must already exist. This skill never generates the base — if there19isn't one yet, say so and stop.20211. **A finished, approved base video with exactly one audio stream.** Approved22 means the user has watched it and signed off — b-roll is a polish pass over a23 locked cut. The script stream-copies one voice track under the picture, so a24 two-stream base is rejected: `ffmpeg -i base -map 0:v -map 0:a:0 -c copy`.252. **Its transcript, with timings.** One call to **`POST /v1/transcripts`** with26 the base's `jobId` (or its `assetId` if it was uploaded). It returns `text`,27 word-level `words[]` and `segments[]` — **timings in SECONDS**, which is what28 the EDL below takes — plus an `srt` you can ignore here. **Price it with the29 transcript arm of `POST /v1/estimates` and quote that number**, never one from30 this file. The meter is per minute of source, rounded up from a one-minute31 minimum, and **a repeat of the same source is free**, so asking twice costs32 once. No local install.3334 *(The **captions** endpoint still cannot supply this — it burns subtitles into35 a new MP4 and returns no text or timings, verified 2026-08-04 and re-checked36 when transcripts shipped. That finding is correct and is kept here so nobody37 re-discovers it and files a bug against captions.)*3839 **Offline fallback:** a local whisper pass (`whisper-cli` or `openai-whisper`)40 still works and is the path when there is no API key or no network. Note41 whisper reports timings in **milliseconds** where the API reports seconds, and42 `whisper-cli` with no model downloaded returns an EMPTY transcript rather than43 an error — which reads exactly like a bad render. See README for the model44 download.4546 Placement is read from the transcript, not eyeballed.4748> **REST key required. A Novoads MCP connector is not a substitute.** If49> `NOVOADS_API_KEY` is missing or still the placeholder, stop before any50> generation work and tell the user: "Before continuing, create an API key at51> <https://novoads.ai/dashboard/settings?tab=api> and paste it into `.env`."52> That holds even when `mcp__novoads__*` tools are connected and authenticated in53> the session. Never call `mcp__novoads__*` tools from this repo's workflows: they54> are a different surface with different behavior, including the units they quote55> costs in. Repo installs verify with `./scripts/check-novoads-env.sh`; a solo56> install checks `NOVOADS_API_KEY` in the environment.5758**Pack version.** Every `/v1` response carries `X-Novoads-Pack-Version`; mention a newer pack at <https://github.com/novoads/agent-skills> only when that header names a version NEWER than this file's `metadata.packVersion` — equal or older is nothing to say, and it is never a reason to stop.5960## Workflow61621. **Read the transcript.** Note what is said and when — the words drive every63 decision below.642. **Generate the b-roll clips, only after base approval.** One clip per moment65 you intend to cover, each rendered silent (`audioEnabled: false`). Overlay66 audio is ignored by the script regardless, and generating before approval67 spends credits on a cut that may still change.68 **Price the set live first — `POST /v1/estimates` this session, the total69 shown against the user's balance, an explicit yes.** Four cutaways is four70 charges, and a remembered price is not a price.71 **Cast the base actor in every cutaway that shows a person:** pass the same72 `referenceAssetIds` the base used — Seedance re-casts every render, and a73 stranger's hands or skin breaks the ad (observed live 2026-08-04: Black base74 actor, five white-cast cutaways). With no identity ref, keep people out of75 frame entirely — objects and environments, not "face out of frame".763. **Propose an EDL.** JSON with `base`, `output`, and `overlays` (`file`,77 `start`, `end`, `covers`). `covers` is mandatory and **quotes the spoken line78 the window illustrates** — a window you can't quote is one you're guessing at.794. **Show the EDL to the user and wait for approval.** Rendering before the user80 has seen the plan is precisely the failure this step prevents.815. **Render:** `python3 scripts/broll_overlay.py edl.json`826. **Report the script's verification output verbatim** — the measured durations,83 the audio check and the window count, not a paraphrase. Then offer 2–3 cut84 variations (different windows, fewer cutaways, a tighter opener) to react to.8586## Placement judgment8788Default cadence, measured 2026-08-04 frame by frame from the reference edit this89pack reproduces — ~15.5s, 10–11 shots, a cut every ~1.4s:9091- **Alternate A-B-A-B: the face returns between every cutaway.** Never two92 overlay windows back to back; the talking head is the spine.93- **4–6 windows per 15s, ~1–1.5s each, ~40–45% coverage.** Short and frequent.94 This retires our older "fewer, longer windows" rule — taste, reversed by95 measurement; our own two renders ran 2 windows of 2.5s/2.0s, ~30% coverage.96- **The b-roll may travel; the base never does.** The reference visits 497 distinct b-roll settings across 2–3 rooms; the talking head never moves.98- **Never cover the opening hook beat.** The first beat is a face making a99 claim; cutting away there spends the retention the hook just bought.100- **End on the person, not on b-roll.** The closing beat is the verdict, and it101 only lands if the viewer is looking at whoever delivers it.102- **Put product shots where the product is being spoken about.** B-roll that103 illustrates the current sentence reads as evidence; anything else is filler.104105**Default shot plan.** The reference's five cutaways tell one arc: problem →106stress → product macro → dose/usage → relief — 2 product shots to 3 emotional107beats. Adapt the imagery to the script, keep the ratio.108109Evidence: one measured edit (n=1) — a default, not a law; depart deliberately and110say why (evals.md OV3/OV6). Every run prints its own cadence against this111envelope; `--stats` prints it without rendering.112113## Hard rule: assembly always goes through the script114115Never hand-write overlay or concat ffmpeg for this task. `broll_overlay.py` owns116validation, rendering and verification, and it fails loudly on exactly what117fails silently by hand: duration drift, re-encoded audio, overlapping or118zero-frame windows, a clip shorter than its window, a rotated phone base119composited at the wrong geometry, a window that quietly composited nothing.120Improvised ffmpeg is how the founding failure happened.121122**It needs ffmpeg 7.1 or newer.** The script scales each overlay against the base123branch with `scale=w=rw:h=rh`, and those reference constants arrived in 7.1. On an124older build — Ubuntu 24.04's apt ships 6.1 — the graph does not parse and the125error names `rw`, not a version. Check with `ffmpeg -version` before blaming the126EDL; on Debian/Ubuntu the fix is a static build, not `apt install ffmpeg`.127128**Escape hatch:** if a request genuinely doesn't fit the EDL model — a speed129ramp, a picture-in-picture inset, audio that actually needs editing — say so and130ask the user how to proceed. Do not improvise around the script.131132## Other modes133134- `python3 scripts/broll_overlay.py edl.json --dry-run` — validate the EDL and135 print the plan, rendering nothing. Cheap way to check window geometry.136- `python3 scripts/broll_overlay.py edl.json --stats` — window count, lengths,137 coverage, base-return gaps, each marked against the envelope. Never an error.138- `python3 scripts/broll_overlay.py --verify-only FINAL --base BASE [--edl edl.json]`139 — re-check any output against its base, including one from an old session.140 **Pass `--edl` whenever you have it:** without it the check is duration +141 audio only, which a plain copy of the base also passes; with it each window's142 midpoint frame is compared against the base, so a window that composited143 nothing fails. A run without `--edl` says so out loud.144145Exit codes: `2` validation, `3` render, `4` verification. A nonzero exit is a146real failure — surface it, don't retry blindly. Renders go to a hidden temp file147beside the output, renamed only after verification passes: a failed run never148leaves a half-written or unverified file at the output path.149150## Evals151152`evals.md` defines the six scenarios this skill is held to (OV1–OV6).153`scripts/test_broll_overlay.py` implements the mechanical ones against synthetic154fixtures — run it after touching the script.