Skip to content

Latest commit

 

History

History
789 lines (657 loc) · 37.8 KB

File metadata and controls

789 lines (657 loc) · 37.8 KB

vlogcut — Usage Guide

vlogcut turns vlog editing into a text-editing task. You never scrub a timeline: the tool transcribes your takes with word-level timestamps, you (or a local LLM) decide which sentences in which order tell the story, and the renderer derives every cut from that decision.

The mental model

Two artifacts live in your project directory and drive everything:

File What it is Who writes it
words.json Every spoken word with precise start/end times plus safe-cut annotations. The single source of truth. ingest (never edit by hand)
edit.md The edit decision list / change-plan: one sentence per line, in output order. ingest generates it; curate/plan refine it; you review it

render is a pure function of these two files (plus style configs). It never re-analyzes audio, so re-rendering after any edit.md tweak is fast and reproducible.

Workflow

0. Setup (once per machine)

python3 vlogcut.py setup            # doctor + consent-gated installer
python3 vlogcut.py setup --check    # read-only report, changes nothing

Checks ffmpeg (including whether the build has the libass-backed subtitles filter captions need), whisper-cli, the whisper model (and which one), and the optional pieces (afconvert, swiftc/Vision for framing, plan backends), offers the missing installs, and writes the resolved paths to ~/.config/vlogcut/config.yaml. Tool paths can be overridden per project (whisper_cli:, whisper_model:, ffmpeg:, ffprobe: in vlogcut.yaml) or via $VLOGCUT_* environment variables — see the README for the full resolution order.

1. Ingest

python3 vlogcut.py ingest takes/*.mov

Extracts audio (afconvert on macOS; the identical conversion runs through ffmpeg where it is absent), transcribes (whisper-cli, Metal-accelerated, word-level DTW timestamps), refines word boundaries against the actual audio energy, detects the speaker's face on sampled frames (macOS Vision), and writes words.json + edit.md.

  • Accepts .mov/.mp4/.m4a/.aiff/.wav; multiple takes in one call.
  • Ingest is incremental. Results are cached per source file under .vlogcut-cache/ in the project dir (see Caching & timings): re-running ingest resolves unchanged files from the cache in well under a second and only transcribes new or changed footage. Every file reports its cache hit/miss. --force-transcribe ignores the cache and re-runs everything (refreshing it).
  • A hand-tuned edit.md is never clobbered. When the existing edit.md looks like a plan (it opens with a # plan: heading, or contains x -marks, {zoom}/{wide} markers, or ~~strikes~~), ingest leaves it untouched and writes the regenerated document to edit.generated.md instead, with a note. A virgin (ingest-generated) edit.md is overwritten as before. words.json is always rewritten — it is ingest-owned.
  • Add --curate to auto-mark false starts in the same run.
  • All analysis happens here and is persisted in words.json — render never re-opens the media. Ingest prints the face-detection hit rate per take (face in 42/42 frames (100%)); a take with too few hits simply gets no base framing (see below) and everything else still works.
  • Ingest also flags delivery flaws the transcript cannot show (see Delivery quality) and annotates the affected edit.md lines.
  • If the project has a corrections.yaml (see Transcription corrections), its fixes are re-applied at the end of every ingest — mangled proper names stay fixed across re-ingests.

Noisy locations work. The silence threshold that drives word-boundary refinement separates speech from non-speech, not just from the deepest noise floor: on high-ambience footage (outdoor wind/street, e.g. a DJI mic) the ambience band is measured from the file's own sustained pauses and the threshold is raised above it, so pauses stay visible to refinement and get tightened at render like any studio take. Quiet recordings keep the exact noise-floor-derived threshold they always had.

2. Curate — mechanical cleanup

python3 vlogcut.py curate [--dry-run]

Detects false starts and restarts ("So the first thing. → So the first thing you need is…") and marks the abandoned attempt. Deliberately conservative: rhetorical repeats and non-adjacent similarities are left alone.

Marks are never deletions — a cut line becomes:

x [00:07 take1.mov] So the first thing. <!-- restart: retaken at 00:08 -->

Unmark (remove the x prefix) to restore it. render skips x -marked lines.

3. Plan — derive the story (optional but recommended)

python3 vlogcut.py plan [--dry-run] [--backend ollama|claude] [--model NAME]

The narrative stage: a language model reads the full line inventory, derives the intended message, and produces a change-plan — which lines to keep, in what order (across takes), which to drop with reasons, and which kept lines deserve a zoom punch-in. Hard constraint: it can only select and reorder lines you actually spoke; it cannot invent, rewrite, or merge text. Output is validated (every line accounted for, no invented IDs) and written back as edit.md:

# plan: <one-line message summary>
[00:00 take2.mov] So I saw that tweet ...      ← kept, in story order
[00:27 take2.mov] Because everybody can literally do it. {zoom}
...
x [01:17 take2.mov] I have no idea what to do with this. <!-- plan: ... -->
  • Default backend is claude (the claude CLI with model opus), which produces the sharpest redundancy cuts; --backend ollama (default model qwen3.6:35b-a3b) is the explicit opt-in for a fully local model.
  • Lines you already x-marked are respected, never resurrected.
  • Always review the plan before rendering — it's a proposal, not a verdict.

Zoom assignment is editorial, not mechanical: the model punches in where the content punches (core claims, punchlines, pivots/reveals, confessional beats), stays wide for setup and flow, targets roughly 15–30% of kept lines, never zooms the very first line, and never marks two adjacent kept lines (adjacent zoomed shots show no framing change at their shared cut). The assignment is validated — zooms must be kept lines, no invented or duplicate IDs; an adjacency violation gets one explained retry and then degrades safely (the later pick of each adjacent pair is dropped with a warning, the plan itself survives). Any {zoom}/{wide} marker you placed in edit.md is locked: the model sees it as [zoom locked]/[wide locked] context and can never override it.

When a plan assigns punch-ins it prints a reminder that markers-only mode needs zoom_every: 0 — and if the project has no vlogcut.yaml yet, it writes one with exactly that (an existing vlogcut.yaml is never modified; a nonzero zoom_every there just earns a warning that the cadence will stack on top of the markers).

4. Review edit.md — the one manual step

Open it in any editor. You can:

  • Delete a line (cut entirely) or move it (output follows file order, across takes)
  • Mark/unmark with a leading x (soft delete, keeps the line visible)
  • Strike a single word with ~~word~~ — the scalpel (see Manual word surgery)
  • Add #-prefixed comment lines to structure the file (ignored by render)
  • Leave trailing <!-- comments --> on lines (stripped automatically)

Do not edit the [mm:ss filename] prefix or the sentence text itself — render matches lines back to words.json by both. (~~strikes~~, {zoom}/{wide} markers and <!-- comments --> are markup, not text: they are stripped before matching.)

5. Render

python3 vlogcut.py render -o final.mp4 [--preview] [--dry-run] [--no-captions] [--no-overlays] [--no-framing]

Derives the cut list (drops marked/deleted lines, removes filler words, tightens pauses > 0.4s to a natural 0.15s beat — both per-project tunables, see Pause tightening — pads and energy-snaps every cut edge so words are never clipped and removed audio never leaks), then renders in a single ffmpeg pass: H.264/AAC, 30fps, sources normalized, base-framed and concatenated in edit.md order, 10ms audio fades at every splice, captions burned in. Short tightened pauses render as speed ramps — sped-up pause video instead of a jump cut (see Speed-ramp pauses; --no-ramp disables).

  • --preview renders a fast draft for the iteration loop: the video is scaled to 540px width (aspect preserved) and encoded ultrafast/CRF 28 — on the reference project roughly half the full render time and an eighth of the file size. Everything is still visible (captions, overlays, zoom, framing, ramps — the filter graph is identical up to one final scale), and the cut list and the entire audio path are byte-identical to the full render (verified: the preview's and the full render's AAC streams hash the same), so what you hear in a preview is exactly the deliverable's audio. A reminder is printed so a preview can't be mistaken for the final.
  • --dry-run prints the per-line cut decisions and final segment list — read it to understand exactly what will be cut and why. It annotates every ramped pause (ramp x4.2 (0.63s->0.15s)) and closes with the visible-cut count (Visible cuts: 22 of 57 transitions (58 segments, 35 pauses ramped)). It also prints a repeated-word cluster warning for every cut or ~~strike~~ whose boundary falls between close repeats of the same word (see Audit below — whisper's word timestamps are least reliable exactly there).
  • The generated .ass subtitle file is kept next to the output for inspection.
  • iPhone footage with data/preview tracks is handled (explicit stream selection).

6. Audit — re-listen to every splice

python3 vlogcut.py audit [-o audio.wav] [--joins-only] [--full]

The editorial loop is text-based, but several real defects are audible only: a cut or strike inside a repeated-word stumble cluster ("it's not even it's not very it's not always…") lands at the wrong sample position because whisper's per-word timestamps are least reliable across repeats (the DTW alignment is ambiguous — any "it's" can align to any "it's"), leaving doubled words, residue of removed words, chopped words, or overcuts that no transcript diff can show. audit finds them by actually listening to the cut:

  1. Computes the cut list exactly as render does (same code path), and prints the static repeated-word cluster warnings.
  2. Renders the audio only, through the identical audio filter graph the real render uses (same trims, same splice fades) — a few seconds where the full render takes minutes. -o audio.wav keeps the rendered audio for listening.
  3. Checks every splice point — line-to-line joins AND the cuts inside a line (filler/strike removals, tightened pauses) — in four layers:
    • an energy chop check: audio still at full speech level right up to the cut with silence after it means a word was chopped mid-flight (deterministic, no transcription involved);
    • a ±1.6 s whisper window per splice, batched into a few passes and compared against the words the cut list says should be there;
    • a solo re-check of every flagged window (a batched pass can be poisoned by a whisper decoder loop; a lone window cannot);
    • tight ±0.45 s probes: whisper with context auto-corrects splice defects (a doubled word can transcribe perfectly clean at ±1.6 s), so each splice is also probed with almost no context, at three slightly shifted positions — a doubled word or an alien word that replicates across shifts is real, a one-off transcription guess is not.
  4. Prints an ordered report and always exits 0 — it is advisory:
audit: 56 joins (49 cached, 7 listened)
AUDIT: 56 joins checked (49 cached, 7 listened), 6 suspect

SUSPECT at 02:26.8 (join):
    [00:02 take2.mov] So, in the AI era, the durable asset
    [00:06 take2.mov] is not going to be the product, but ...
    expected: So, in the AI era, the durable asset is not going to be the product,
    heard:    I era the durable asset asset is not going to be
    probe:    for asset asset is
    reason: splice probe heard 'asset' twice in a row (doubled audio at the cut)

Per suspect it shows the output time, the edit.md line(s) on both sides of the splice, the expected vs heard words (heard from the context window, probe from the tight probe when it contributed), and the reason(s): doubled audio, residue/garble (audio of removed words leaking through), overcut (an expected word missing), or word chopped mid-flight.

  • Audit is incremental. Every join heard CLEAN is remembered in .vlogcut-cache/audit.json under a fingerprint of everything its audio depends on — the source ids, boundary times, and kept-word spans of the segments inside its listen window, plus the audio-chain parameters — and skipped on the next run: after an edit, only the joins the edit actually touched (plus any joins it created) are re-listened, which is what turns the re-audit after a one-line tweak from minutes into seconds. The fingerprint deliberately ignores output time, so an edit upstream shifts later joins without invalidating them; moving a boundary, adding a ~~strike~~, or changing pause config near a join always invalidates it. Suspects are never cached — a reported suspect is re-listened on every run until it is actually fixed. --full re-listens everything (and refreshes the cache).
  • --joins-only checks only line-to-line joins and skips the cuts inside lines — roughly halves the (listened) runtime.
  • The comparison is calibrated to tolerate normal whisper variance (article/copula swaps, respellings, split/merged words, hesitation noises, window-edge truncation effects), so a clean render reports 0 suspects — every reported suspect deserves a listen.
  • Typical runtime: a few seconds for the audio render plus whisper passes for every listened join (dominated by the tight probes; three probes per splice, four concurrent whisper processes) — minutes for a full first audit of a 3-minute cut, seconds for the incremental re-audit after a small edit.
  • Fixing a suspect is the normal editorial loop: strike the leaking word, drop or re-take the line, or add a corrections.yaml/framing fix — then re-run audit until it is quiet.

7. Inspect — look at the audio

python3 vlogcut.py inspect SOURCE START END [-o out.png]

The visual companion to audit: where audit listens to the rendered cut, inspect draws one source-time range of the raw take — the waveform (ffmpeg showwavespic, ~1800×560) overlaid with everything words.json believes about it:

  • every word span as a box with its label (labels alternate between a top and a bottom row so dense speech stays readable);
  • the persisted safe boundaries: cut as a red line, on as an orange line (see Delivery quality);
  • the delivery flags as colored word boxes (mumble magenta, trail yellow);
  • unlabeled-voice bands (red, over the wave): audio energy above the file's speech threshold outside every word span — i.e. speech the transcript never showed. This is the payload: when audit reports residue ("asset asset"), the inspect image shows the untranscribed restart-mumble the transcript hid, and exactly where the safe boundaries around it sit.

SOURCE is the take's path, basename, or any unique substring of it (take2); START/END are source-time seconds. The default output is inspect_<source>_<start>-<end>.png in the project dir. It also prints the words, flags, boundaries, and detected voice bands as text. Needs a full ffmpeg build (drawtext), like captions.

python3 vlogcut.py inspect take2 3.2 7.6
  'asset' 4.18-4.50  [mumble, cut=4.66]
  'is' 6.63-6.92  [cut=6.84, on=6.63]
  UNLABELED VOICE 4.82-5.92 (1.10s)
  ...

Delivery quality

Curate, plan, and you all judge transcript text — but a line can read perfectly and still be delivered badly, and no text-based editor can see that. Ingest measures the audio for two such flaws and persists them per word in words.json:

flag what it means how it is found
trail the word trails off: a stretched, mumbled function word that fades out ("…might be aaa~") — it sounds unfinished, and often like a different word the word is longer than a word of its length plausibly can be, real silence follows it (so the measured duration is the word's own), and the second half of its voiced span is measurably quieter than the first
mumble the gap after the word hides untranscribed speech — a restart-mumble, a swallowed word — that the cut has to remove the "silent" gap contains sustained voiced frames (the same evidence that already earns a safe cut point)

A voiced gap gets both of its boundaries persisted, because one safe point cannot protect both sides when the stray speech sits mid-gap or late:

  • the earlier word's cut — the widest silent dip in the gap; a segment ending there never extends past it;
  • the later word's on — the safe onset: the midpoint of the last silent dip between the gap's voiced region and the next word (falling back to just past the last voiced frame when the decay tapers straight into the onset). A segment resuming at that word never starts before it, so the lead-in pad cannot scoop the mumble's decaying tail into the resume (the classic audible symptom: a doubled word at the join, "…the durable asset — asset is not…"). A stray burst can even straddle the next word's recorded onset (whisper's DTW start slices into an untranscribed word); the analysis looks a word-start's silent lead-in (60 ms) past the recorded onset, so such a burst is still detected and the on dip may deliberately sit just after the recorded start.

Both fields live per word in words.json; files ingested before on existed render exactly as they always did. vlogcut inspect draws both (red/orange lines) together with the untranscribed speech they fence off.

Both are rare by design (on the reference footage: 0.7% and 1.7% of words — a noisy flag would be worse than none).

The affected edit.md lines carry an informational comment:

[00:00 take1.mov] ... I think the solution might be a <!-- delivery: trails off; unclear speech cut here -->
  • The comment is derived from words.json, never round-tripped as text: curate and plan re-derive it for every line they rewrite, so it can never go stale or stack up.
  • plan sees the same flags as [delivery: trails off] / [delivery: unclear cut] tags in its line inventory, and is told to prefer dropping a weak-delivery line when the story survives without it (a redundant setup clause) — but to keep it when it carries content the message needs, because the flaw is repaired downstream.

The repair (render). When a kept span's last word is flagged trail — i.e. a cut, a jump, or a tightened pause lands right on the trailing-off word — render chops the mumble tail: the word ends trail_clamp seconds after its start, keeping its intelligible onset and dropping the fade.

# vlogcut.yaml
trail_clamp: 0.22     # default; 0 keeps the tail exactly as recorded

The clamp only ever shortens a segment, so it cannot break any of render's cut discipline (it never reaches into a neighbor or past a persisted safe cut point), and it never keeps less than 0.12s of the word. A project with no flagged words renders exactly the same cut list with the clamp enabled.

render --dry-run reports every repair:

[00:00 take1.mov] I thought a little more about this and I think the solution might be a
    trail clamp: 'a' 4.60-5.05 (0.45s) kept to 4.82 (0.22s)
    keep   0.480 -   4.820  (4.340s)

Manual word surgery

When a single word ruins an otherwise good line — a stray "a", a doubled "the", a word the delivery mangled — strike it in edit.md:

[00:00 take1.mov] ... I think the solution might be ~~a~~
[00:12 take1.mov] So the the ~~the~~ tools are a commodity.

A struck word is cut exactly like a filler word: gone from the audio, gone from the captions, with the same padding, clamping, and pause tightening around the hole.

  • Positional: striking one of several identical words in a line hits exactly that one.
  • The struck word stays part of the line's span, so the line still matches words.json — you are not editing the transcript, you are marking up a cut.
  • curate and plan round-trip strikes verbatim on the lines they keep.
  • A strike may cover several words: ~~a little~~.

Transcription corrections

Whisper mangles proper names and jargon ("Sutskever" → "or"), and patching words.json by hand dies on the next re-ingest. Declare the fix once in an optional corrections.yaml in the project dir (a list of flat entries, like overlays.yaml):

- source: take2.mov        # basename of the take
  near: 16            # seconds: the word at/nearest this time is the target
  from: "or"          # the word's current (mangled) text — a safety check
  to: "Sutskever"     # the replacement
  • Applied automatically at the end of every ingest (after boundary refinement and cut annotation), so the fix survives re-ingest forever.
  • Text-only. A correction never touches a word's timing or its cut/trail/mumble annotations, so cuts, pauses, and overlays are unaffected. Captions read words.json and inherit the fix automatically.
  • The from check is tolerant of case and surrounding punctuation ("or," still matches from: or and keeps its comma around the replacement) — but a real mismatch warns and skips the entry rather than guessing.

To fix an existing project without re-transcribing, run:

python3 vlogcut.py correct

It applies corrections.yaml to words.json and rewrites the matching edit.md sentence lines' text in place — curate x marks, {zoom}/{wide} markers, <!-- comments -->, and ~~strikes~~ are all preserved. Because edit.md may be a hand-crafted plan, correct is careful: if any sentence line no longer matches a words.json sentence (or a correction would change the sentence grouping itself, e.g. by adding terminal punctuation), it warns and leaves edit.md untouched, updating only words.json.

Captions

Word-timed karaoke captions, generated from the same timestamps as the cuts — they cannot drift. Style lives in an optional captions.yaml in the project dir (flat key: value, # comments):

# defaults shown
font: Arial Black
size: 96              # in a 1080-wide reference space, scales with resolution
color: white          # base (upcoming) word color
highlight: cyan       # active word color; named or raw ASS "&HAABBGGRR"
outline_color: black
outline: 4
bold: true
uppercase: true
position: bottom      # bottom | center
margin_v: 260         # higher number = higher on screen (reference space)
max_words: 3          # words per caption group

Caption groups are bounded by width as well as word count: the renderer estimates each event's rendered width (calibrated for the default Arial Black uppercase style) and breaks the group before a word that would push it past ~92% of the usable frame width — captions never wrap and never run off the frame edge. A single word too wide on its own (captions never break inside a word) is instead shrunk just enough to fit via an inline font-size override.

Recipes:

  • Orange accent, higher placement: highlight: "&H0000A5FF", margin_v: 560
  • One word at a time, no accent: max_words: 1, highlight: white

Named colors: white, yellow, green, red, cyan, black. Any other color as raw ASS hex &HAABBGGRR (note: BGR byte order — orange FFA500 becomes &H0000A5FF).

Re-rendering with a new style needs no re-ingest — just run render again.

Base framing

Phone-on-a-bench footage puts the head wherever the phone happened to point — usually too low, with dead space above it. Base framing fixes that per take, automatically.

At ingest, the speaker's face is detected on frames sampled every ~2.5s (macOS Vision, on-device, via packaged vlogcut/facedetect.swift — compiled once into a cached binary) and the median face box is persisted in words.json:

{"source": "take2.mov", "words": [...],
 "framing": {"face": {"cx": 0.506, "cy": 0.439, "w": 0.378, "h": 0.213},
             "samples": 42, "hits": 42}}

At render, that box is solved into the smallest crop-in that puts the face-box center at (0.5, head_y). Two rules make this safe:

  • It only ever crops IN (zoom ≥ 1.0, never out), and only as far as the placement needs.
  • It normalizes head POSITION, never head SIZE. A deliberately close take stays close — it is not zoomed out to match the others, and it is not blown up beyond the crop its repositioning costs.

A zoom punch-in composes with it (effective zoom = base × punch) and crops around the base-framed center, so a punch tightens on the framed head instead of drifting back to the middle of the frame.

# vlogcut.yaml — defaults shown
framing_enabled: true
head_y: 0.40           # target height of the FACE-BOX CENTER
framing_max_zoom: 1.35 # hard clamp on the base crop-in

head_y targets the center of the face box (brow to chin) — not the top of the head, which sits roughly half a box-height above it. The default 0.40 was calibrated on real 9:16 footage: it lands the eye line at ≈0.34, i.e. on the upper-third line (the talking-head standard), and it is the tightest target that still keeps a close-up take's hair inside the frame. Tighter values (0.30–0.35) look cramped on medium shots and visibly clip the top of the head on close ones.

Per-take overrides go in an optional framing.yaml (a list, like overlays.yaml):

- source: take3.mov       # basename of the take
  head_y: 0.45            # park this take's head lower than the default
- source: take1.mov
  zoom: 1.0               # force a base zoom (1.0 = full frame, untouched)
- source: take2.mov
  skip: true              # leave this take's framing completely alone

Unknown basenames warn. --dry-run prints the solved base zoom and window center per source, plus a residual when the target could not be reached within the zoom cap.

Framing degrades to a no-op — with a warning, never a crash — when swiftc or Vision is unavailable, when a take has no detectable face, or when fewer than 40% of its sampled frames yield one. Takes ingested before this feature existed have no framing key and render exactly as they always did. --no-framing (or framing_enabled: false) turns it off entirely.

Pause tightening

Every inter-word gap longer than pause_max seconds is tightened down to a natural pause_keep-second beat — the core "cut the dead air" move. Both are per-project tunables in the optional flat vlogcut.yaml:

# vlogcut.yaml — defaults shown
pause_max: 0.4     # gaps longer than this are tightened (0.1–5.0)
pause_keep: 0.15   # the kept natural beat (0.05–1.0, must be < pause_max)

Raise pause_max for a slower, breathing cut (fewer pauses touched); lower it for a denser one. It governs both in-line pauses (a kept line splits at every gap longer than pause_max) and recorded pauses between lines: a line-join pause long enough that ingest split the sentence on it (> 0.8s) plays whole when pause_max covers it — values above 0.8 are meaningful exactly there, since an in-line gap can never exceed the sentence-split threshold. Shorter join gaps (punctuation-split sentence rhythm) always keep the calibrated pause_keep beat, as do gaps ingest flagged as hiding untranscribed speech. pause_keep is also where the cut pads come from: every segment keeps a lead-in/tail pad around its words, the two pads always sum to pause_keep (so a tightened pause keeps exactly that much real audio), and they split it 2:1 before:after (word starts are transcribed systematically late; refined ends already extend past the voiced edge) — at the defaults, 0.10s before and 0.05s after. Speed-ramp factors follow it too: a ramp compresses the removed gap into exactly the pause_keep beat (factor (gap + pause_keep) / pause_keep, see below). Out-of-range values warn and fall back to the defaults; a pause_keep that is not strictly below pause_max resets both. --dry-run's tighten pause 0.63s -> 0.15s notes show the configured values. Sentence grouping at ingest and the <!-- delivery --> annotations are not affected — only the cut is.

Speed-ramp pauses

Tightening every pause used to mean a hard video cut every few words — the image "jumps like crazy" on a pause-heavy take even though the audio flows. Speed ramps fix that: where the two sides of a tightened pause are continuous in the source (same take, nothing removed between them but the pause itself), render keeps the pause's video and speeds it through the kept beat instead of cutting. Motion runs continuously — you see the speaker breathe/blink at 3–7× for a sixth of a second instead of teleporting.

Mechanics (all invariants, not tunables — durations shown for the default pause_keep: 0.15, see Pause tightening):

  • The two cut pads around a tightened pause already keep exactly pause_keep (0.15s) of real pause audio. The ramp piece takes over exactly that 0.15s of the video timeline: the neighbors cede their pads' worth of video and the whole source pause is setpts-compressed into it. Output duration and the entire audio chain are bit-for-bit unchanged — a ramp is video-only.
  • Speed factor = (removed gap + pause_keep) / pause_keep, hard-capped at ×8.
  • A ramp is not a visible cut: the zoom/framing level holds across it, it never counts toward the zoom shot cadence, and a {zoom}/{wide} marker split at the same pause wins (the intentional jump cut stays a jump cut, unramped).
  • Never ramped: pauses longer than ramp_max_gap, pauses whose gap video contains removed speech (a cut filler or ~~strike~~ — you'd see sped-up talking), and cross-take joins.
# vlogcut.yaml — defaults shown
ramp_enabled: true    # false = hard-cut every tightened pause, as before
ramp_max_gap: 1.0     # longest removed source gap a ramp may cover (s)

--no-ramp disables ramps for one render. --dry-run annotates every ramped pause with its factor (ramp x4.2 (0.63s->0.15s)) and reports the visible-cut total — on a typical talking-head take, ramps turn ~60% of the splices invisible.

Zoom punch-ins

Subtle zoom cuts for engagement: by default every 2nd visible shot is punched in 1.08×, and the zoom level only ever changes at a cut the viewer already sees (source-continuous segments always share one framing). Captions are unaffected — they burn in after the zoom at fixed size/position. The punch multiplies on top of the base framing (above) and keeps it.

Configure via an optional flat vlogcut.yaml in the project dir:

zoom_enabled: true    # false = never zoom
zoom_factor: 1.08     # punch-in strength (clamped by zoom_max: 1.15)
zoom_every: 2         # punch in on every Nth visible shot; 0 = markers only

Manual control per line in edit.md — a trailing marker forces the whole shot containing that line:

[00:24 take1.mov] So the only things that you can really compete with is your original thoughts. {zoom}
[00:30 take1.mov] And things that you come up in your own mind ... {wide}

Markers survive curate and plan rewrites and never appear in captions. plan also assigns {zoom} markers itself (editorially motivated punch-ins — see the Plan section); markers you placed by hand are locked and never overridden by it. For marker-driven zooms to be the only zooms, set zoom_every: 0 (markers-only mode) — plan writes such a vlogcut.yaml for you when none exists.

Mid-shot marker splits — the classic vlog emphasis jump. When a marked line starts inside a continuous shot (its speech directly continues the previous line — nothing removed between them, so the take flows on with no framing change), the shot is split at the marked line's first kept word: the framing changes exactly on the beat as an intentional jump cut. The splice keeps every millisecond of audio (the two segments share the boundary instant; the usual 10 ms audio fades make it inaudible), a tightened pause at the boundary stays tightened, and --dry-run labels such segments [marker split]. A {wide} marker inside a zoomed shot splits symmetrically back to 1.0. Only markers split shots — the automatic cadence still only changes levels at cuts that already exist, and a split never shifts the cadence of other shots.

Picture overlays

Fade still images (a tweet screenshot, a photo, a map) over the final cut via an optional overlays.yaml in the project dir — a list of flat entries:

- anchor: "that tweet"     # spoken phrase: overlay appears when it is said
  img: assets/tweet.png
  duration: 5              # seconds; clamped to the video end
  position: top            # top | center | bottom
  width: 0.92              # fraction of the output width
- at: "01:23"              # alternative: output-time anchor (mm:ss)
  img: assets/map.png
  • Anchor by phrase, not timestamp: the phrase is matched (case/punctuation-insensitive) against the kept words in output order — the same word timings that drive cuts and captions — so the overlay lands on the words wherever the edit moves them, and survives re-cutting. occurrence: 2 targets the second time the phrase is spoken. An anchor whose words were cut away (or never spoken) warns and skips that overlay.
  • Overlays are centered horizontally, fade in/out over 0.3 s, and are drawn on top of the video but under the captions (position bottom sits above the caption zone). Zoom punch-ins never affect them.
  • --no-overlays renders without them; a missing image file is a clear error before ffmpeg starts.

Caching & timings

The iteration loop — tweak edit.md → preview → audit — should cost seconds, not minutes. Three mechanisms make it so, all rooted in a per-project .vlogcut-cache/ directory (safe to delete at any time; every entry silently recomputes on the next run):

Two-layer ingest cache.

  • Layer 1 — immutable, expensive (transcripts/<fingerprint>.json): the raw whisper JSON per source, keyed by a cheap file fingerprint (size + mtime + hashes of the first/last 64 KB — full-file hashing of gigabyte takes would cost more than it saves). A fingerprint hit skips audio extraction + whisper entirely.

  • Layer 2 — cheap, derivable (analysis/<key>.json): the refined words with every ingest annotation (boundaries, safe cut/on points, trail/mumble flags) plus the source's framing entry, keyed by (layer-1 fingerprint, the code's ANALYSIS_VERSION, the corrections.yaml content hash). Changing the analysis code or corrections.yaml recomputes this layer in seconds — from the cached transcript, never by re-transcribing.

    On the reference project (one 790 MB 2.6-minute take): cold ingest 27 s, fully-warm re-ingest 0.1 s, corrections-change re-ingest 14 s (no whisper), --force-transcribe 28 s.

Incremental audit (audit.json): see the Audit section — clean-join verdicts are remembered per join fingerprint, suspects never are.

Stage timings. Every command ends with a one-line wall-clock summary of its stages on stderr, e.g.

ingest: extract 0.6s · whisper 11.8s (cache: 0 hits, 1 miss) · analysis 0.1s · framing 14.7s · total 27.2s
audit: render 0.5s · screen 10.3s (0 cached, 15 listened) · probes 37.1s · total 47.9s

and appends the same data as one JSON line (command, per-stage seconds, notes, total, timestamp) to timings.log in the project dir — grep it to see where your loop's time actually goes. Both the cache and the log are fail-soft: a read-only project dir loses the speedup and the log line, never the command.

Practical notes & quirks

  • Whisper rarely transcribes "um"/"uh" in real speech, so the filler blocklist mostly fires on clearly articulated fillers; untranscribed ones become pauses and get tightened instead. Both paths remove them.
  • Your hesitations are preserved by design. A trailing-off "a…" or a mid-sentence restart that whisper merged into one line stays in the video — the tool cuts what the transcript identifies, it doesn't rewrite delivery.
  • Suspected audio artifact? Run vlogcut audit — it re-listens to every splice with exactly the probes that work: narrow-window whisper re-transcription (full-video transcription smooths over small residues, and even a ±1.6s window auto-corrects a doubled word — the sub-second probes catch it) plus a 10ms RMS energy profile at the cut edges. Then vlogcut inspect SOURCE START END on the suspect's source range shows you why — the waveform with word spans, safe boundaries, and any untranscribed speech drawn on.
  • render --dry-run is the fastest way to answer "why was this cut?"
  • Keep takes under ~10 min each; transcription time scales with length.

Testing

python3 tests/run_checks.py

Runs the full check suite (cut-boundary invariants — including the two-sided cut/on voiced-gap boundaries, curation rules, plan validation, caption timing/grouping, the base-framing solver and its composition with zoom, speed-ramp selection and its graph/zoom-hold invariants, the inspect time→pixel mapping and unlabeled-voice detection, config parsing — including pause_max/pause_keep, the ingest-cache fingerprints and layer keys, plan-file protection, --preview graph equality, the audit join fingerprints, and the stage-timing format). Synthetic fixtures with known ground truth live in testdata/takes/.