Video edit (screen / OBS / talk recording)
Script kit: scripts/ in this skill (prep.sh, transcript_txt.py, ocr_scan.py,
cam_flags.py, df_enhance.py, build_edl.py, render.py, verify_output.py,
chapters.py, overview.py, job.example.json, selftest.sh). Run them from a copy in
<workdir>/scripts/ so a job never depends on the skill dir. scripts/selftest.sh proves
an install end to end in a couple of minutes.
Inputs
<src>: the recording, usually newest~/Desktop/*.mov(OBS namingYYYY-MM-DD HH-MM-SS.mov). If several recent recordings exist, confirm which one BEFORE starting the long pipeline - editing the wrong video wastes an hour.<target_minutes>: ask if not given; typical 20-30 from a 45-90 min source.<workdir>: fresh scratch dir, e.g./tmp/<session>/edit.- Toolchain:
ffmpeg6+,python3,mlx_whisper, DeepFilterNet3, Pillow/numpy/soundfile, Apple Vision (pyobjc-framework-Vision/-Quartz).
Steps
- Recon, never touch the source.
ffprobe <src>(resolution, fps, audio). Work only from copies/extracts in<workdir>. - Prep.
prep.sh "<src>" $W:orig48.wav,mono16.wav,frames/(1 per 2 s),camall/(webcam crop at 1 fps, geometry auto-derived from the resolution),sheet_overview.jpg,silences_raw.txt(diagnostic only), andtranscript.json(mlx_whisper large-v3-turbo, word timestamps, ~4 min). Whisper keeps running in the background after prep returns - startdf_enhance.py orig48.wav df_enhanced.wav(DeepFilterNet3, ~6 min CPU) NOW, it is independent. - Read the whole transcript (3 chunks via
transcript_txt.py ... --from/--to). Write keep ranges (source seconds) intojob.json -> keep. Silence trimming is minor (~2 of 47 min); the real minutes come from content decisions. Whisper self-informs: a phrase repeated 10+ times or empty-segment runs = dead air, cut it. - Visual-static pass. "Nothing happening" is often visually static, NOT silent. Frame-difference the 0.5 fps frames: <1% pixels changed over a ~10 s window = inactive; merge runs split by <=1 s blips; require >=4 s to cut; 0.5 s handles. Blinking cursors/spinners fake activity - mask or raise the threshold; check intermediate frames for change-and-revert bursts. Typing is noise, not silence: gates use RMS on the enhanced track (
pause.loud_db), never rawsilencedetect. - Opening. Cut everything before the first real content. If the start is slow/boring, start on the most interesting demo; optionally cold-open with 1-2 exciting clips then a speed-up of the intro. Never open on wallpaper/black - a choppy first 30 s reads as trash.
- Leak scan (runs while you read).
ocr_scan.py scanafterrunonframes/: IPv4/v6,*.ts.net, emails, API keys, ssh material. Dense 2 fps pass on every terminal/config-heavy range (ocr_scan.py dense src.mov dense 600:60 ...). Static text ->boxes(solid fill); scrolling terminal output -> blur the whole pane (blurwindow +blur_regions, in source pixels) for the window - per-line boxes miss text between samples.127.0.0.1/localhostare fine. Log every window injob.json. - Privacy cuts (webcam).
cam_flags.py(hand-near-mouth via Vision) +cam_flags.py --sheet S:DURzoomed 2 fps sheets of each flagged cluster. Cut the moments the user would not publish: hand at the face during a pause, leaving the desk, another person in frame, full-screen face segments. Ordinary thinking poses and small gestures are fine. NEVER cut mid-sentence for a gesture - snap to the nearest sentence boundary and note what was lost. - User timestamp cuts are literal. "cut 0000 - 01:12" -> put it in
job.cuts(orkeep), apply exactly, then re-check pacing around the seam. - Audio master. Sanity: enhanced-vs-original correlation > 0.95 (
df_enhance.pyprints it). The defaultaudio_chainis highpass 75, cuts/boosts around 220 Hz / 3.2 kHz / 9 kHz, de-esser, 3:1 compressor; notch any persistent fan tone by adding anequalizerterm;render.pydoes the two-pass loudnorm to -16 LUFS / -1.5 dBTP. The untouched mic stays as audio track 2. - EDL + render.
build_edl.py->segments.json(pauses >0.7 s inside keeps shortened to 0.4 s; snapped to the probed frame rate; <0.3 s dropped).render.py: grouped parallelh264_videotoolboxencodes, concat-c copy, mux both tracks. It is restartable per group - if a group fails, fix and rerun, unchanged groups are skipped. Deliver 1080p unless asked otherwise (4K doubles render time for nothing). - Verify before delivering.
verify_output.pyexits non-zero unless frame count matches the EDL, both audio tracks match the video duration, and OCR of the output at 4 fps across every redaction window finds zero leaks. Also pull output frames at each privacy cut and at 0:00-1:00 and LOOK at them. Use--fullfor a whole-file leak sweep when the material is sensitive. - Re-transcribe the new cut. Transcript, subtitles and chapters must match the OUTPUT timeline, not the source - re-whisper the cut (or
chapters.py, which remaps through the EDL) before writing SRT/chapters. Never ship SRT times taken from the source transcript. - Metadata + deliver. Without being asked twice: chapter timestamps (
chapters.py, >=3, first at 0:00), 2-3 title options, ~30 tags under 500 chars (+2 tag/description variants if asked), description, SRT per language (~42 chars/line; multilingual on request), all as plain text. Thumbnail via youtube-pack: read the user's last 10 thumbnails for style; default white/creme background + black text, no face unless asked, logos/text big and uncluttered. Then move<output>.mp4(and SRTs/metadata) to~/Downloadsandopenthe file / open the folder in default app.
Outputs
<Topic> - YYYY-MM-DD - edit.mp4(1080p, 2 audio tracks) in~/Downloads, opened in the default app.<workdir>:job.json,segments.json,group_bounds.json,vf_<n>.txt, redaction/privacy logs.~/Downloads: SRTs,metadata.txt(titles, description, tags, chapters).- Optional on request: topical sub-videos split from the same source, each with its own chapters.
Composes with
whisper/DFN3 (in-kit) -> video-edit (verified mp4 in ~/Downloads) -> youtube-pack (thumbnail.png) -> browser upload to YouTube Studio configured like the user's other videos.
Rules
- Never modify the source recording.
- Target length is a target; if content won't shrink without gutting the talk, deliver slightly over and name the next two cuts.
- Cut whole tangents before trimming more pauses; keep hardware talk, live demos, honest caveats, the recap.
- Always end with: move to ~/Downloads + open.
- Report faithfully: you cannot hear the result - say so and point at track 2.
- Separately recorded audio (Discord/Craig) usually does not exist; check Desktop/Downloads/iCloud/Voice Memos, if absent use the OBS mic and say so.
- When asked to free space: delete old unaccepted drafts/variants, keep the source and the final.
- Multi-person podcasts: find the RIGHT audio tracks (usually 2 of many), map audio to the correct speaker, alternate video with the person talking, clean transitions.
- Never claim the edit is done because the render finished; only
verify_output.py+ eyeballed frames settle it.
Verify
verify_output.pyprints PASS (frame count, two audio tracks, no leaks in windows) and exits 0.- Eyeballed frames at 0:00-1:00 and every privacy cut (
verify_output.py --sheet 0:60). - SRT re-transcription spot-check against the cut.
ffprobethe delivered file: duration, resolution, fps, 2 audio streams.
Known failures
- Wrong video edited -> confirm newest/topic match before the pipeline.
- Subtitles/chapters on the source timeline -> re-transcribe after cutting (step 12).
- Hostname visible in output -> scrolling text between OCR samples; blur whole pane, widen the window, then output-side OCR.
- Silencedetect cuts typing/video playback -> gate on enhanced-audio RMS (
pause.loud_db), not silence alone. - Choppy black opening -> verify output 0:00-1:00 frames, cold-open on real content.
cp -ioverwrite prompt / sandbox TCC blocks~/Downloads-> use/bin/cpwith sandbox disabled.- ffmpeg filter dies ("Cannot allocate memory") past ~100
between()terms -> fewerrender_groups. - Blinking cursor counts as activity in frame differencing -> mask/tune threshold, check intermediate frames.
render.pyframe mismatch -> it refuses to concat; readrender_<n>.log(-ssbefore a non-keyframe, broken decoder).- Whisper still running when you read
transcript.json->transcript_txt.pyreports empty segments; wait forwhisper.logto finish.
Examples
- 2026-09-02 talk, 47 min -> 26 min: 32 keep ranges, 2 privacy cuts (9:52-9:58, 26:08-26:14), 2 blur windows + 1 box for a
*.ts.nethostname, render ~2 h wall clock, leak caught only by output-side OCR. - 2026-09-24 teaching OBS session: user rejected first cut ("background noise, typing, beginning highly boring") -> removed typing noise, picked a better opening, re-did transcripts, then literal head cut
0:00-1:12, leak scan, white/creme thumbnail with bigger logos, 2 metadata variants, moved to Downloads. - Podcast (2026-09-18): 1h40m video + 5 candidate audio files -> identified the 2 right per-speaker tracks, mapped voice to video, 1080p, trim head, SRT + ES/AR/HI translations, chapter timestamps, thumbnail with both speakers, text on top, no black background.