Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

recap: pre-encode subtitle-track to webm so frames present continuously

Concat-demuxer + image2 produces a sparse PTS stream (one frame per
file= entry, with timing as a "duration before next frame" gap). When
that's piped into ffmpeg's overlay filter as input #2, the overlay sees
gaps and truncates the output stream — the previous build encoded only
~157s of a 281s timeline.

Fix: subtitle-track.mjs now does a 1-3 second pre-encode pass that
converts the concat-demuxer timeline into a proper 30 fps libvpx-vp9
WebM with yuva420p alpha (~hundreds of KB). The main compose just adds
that webm as -i input #2; build-filter overlays it once. Cache via
hash on (subs[*] timing + total duration); skip the encode when cached.

This is the previously projected fix for the 6-hour compose bottleneck
on oven, now actually working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+75 -26
+4 -2
recap/bin/build-filter.mjs
··· 29 29 lines.push(`[a2]showwaves=s=1080x96:colors=0xff70d0|0x70f0e0:mode=cline:rate=30,format=rgba,colorchannelmixer=aa=0.55[wave]`); 30 30 lines.push(`[bg][wave]overlay=x=0:y=${WAVE_Y}:format=auto[bg2]`); 31 31 lines.push(`[bg2]drawbox=x=0:y=1912:w='iw*t/${TOTAL}':h=8:color=0xff69b4:t=fill[v0]`); 32 - // Single subtitle-track overlay (was a 135-deep movie= chain). 33 - lines.push(`[2:v]format=rgba,fps=30,scale=1080:1920[subs]`); 32 + // Single subtitle-track overlay. Input #2 is a pre-encoded webm with 33 + // alpha (vp9 / yuva420p) produced by subtitle-track.mjs — see comments 34 + // there. Format the alpha track to match the slide pipeline and overlay. 35 + lines.push(`[2:v]fps=30,format=rgba,scale=1080:1920[subs]`); 34 36 lines.push(`[v0][subs]overlay=x=0:y=0:format=auto:shortest=0[final]`); 35 37 36 38 process.stdout.write(lines.join(";\n") + "\n");
+3 -3
recap/bin/compose.fish
··· 16 16 set -l TOTAL (cat $OUT/duration.txt) 17 17 set -l AUDIO $OUT/recap.mp3 18 18 set -l WALTZ $OUT/waltz.mp3 19 - set -l SUBTRACK $OUT/subtitle-track.txt 19 + set -l SUBTRACK $OUT/subtitle-track.webm 20 20 set -l VIDEO $OUT/recap.mp4 21 21 set -l FILTER $OUT/filter.txt 22 22 ··· 50 50 ffmpeg -hide_banner -y \ 51 51 -f concat -safe 0 -i $OUT/concat.txt \ 52 52 -i $AUDIO \ 53 - -f concat -safe 0 -i $SUBTRACK \ 53 + -i $SUBTRACK \ 54 54 -stream_loop -1 -i $WALTZ \ 55 55 -filter_complex_script $FILTER \ 56 56 -map "[final]" -map "[mix]" \ ··· 63 63 ffmpeg -hide_banner -y \ 64 64 -f concat -safe 0 -i $OUT/concat.txt \ 65 65 -i $AUDIO \ 66 - -f concat -safe 0 -i $SUBTRACK \ 66 + -i $SUBTRACK \ 67 67 -filter_complex_script $FILTER \ 68 68 -map "[final]" -map "[a1]" \ 69 69 -c:v libx264 -preset ultrafast -crf 22 -pix_fmt yuv420p \
+68 -21
recap/bin/subtitle-track.mjs
··· 1 1 #!/usr/bin/env node 2 - // subtitle-track.mjs — emit a concat-demuxer file (`out/subtitle-track.txt`) 3 - // that sequences subtitle PNGs with explicit durations, so the main compose 4 - // can include subtitles via a single `-f concat -i subtitle-track.txt` 5 - // input + one overlay filter — instead of a 135-deep `movie=...` chain. 2 + // subtitle-track.mjs — emit a proper subtitle-track video (constant 30 fps, 3 + // transparent alpha) so the main compose can include subtitles via a single 4 + // `-i subtitle-track.webm` input + one overlay filter — instead of the old 5 + // 135-deep `movie=...` chain. 6 6 // 7 - // Reads `out/subs.json` (timing + per-chunk PNG paths) and `out/subs/blank.png` 8 - // (a fully-transparent 1080×1920 PNG produced by subtitles.mjs). 7 + // Two-step process: 8 + // 1. Build a concat-demuxer text file (`out/subtitle-track.txt`) listing 9 + // subtitle PNGs and blank-gap PNGs with their durations. 10 + // 2. Run ffmpeg to convert that timeline into a 30 fps alpha WebM 11 + // (`out/subtitle-track.webm`). Without this pre-encode, the concat 12 + // demuxer feeds the main compose as a sparse PTS stream (one frame 13 + // per `file` entry), which ffmpeg's `overlay` interprets weirdly and 14 + // truncates the output. The pre-encoded webm presents continuous 15 + // 30 fps frames just like the slides input. 9 16 // 10 - // The output is a plain concat-demuxer text file. ffmpeg picks it up at 11 - // frame rate via: 12 - // -f concat -safe 0 -i out/subtitle-track.txt 13 - // The `duration` directive is honored on each entry. The very last `file` 14 - // must be repeated (concat-demuxer quirk) so the final entry's duration 15 - // applies. 17 + // Output webm is small (a few hundred KB at most — mostly transparent 18 + // frames + tiny pill images). Encode is fast (1-3 seconds). 16 19 // 17 20 // Usage: node bin/subtitle-track.mjs 18 21 19 - import { readFileSync, writeFileSync } from "node:fs"; 22 + import { execFileSync } from "node:child_process"; 23 + import { readFileSync, writeFileSync, existsSync } from "node:fs"; 20 24 import { resolve, dirname } from "node:path"; 21 25 import { fileURLToPath } from "node:url"; 26 + import { createHash } from "node:crypto"; 22 27 23 28 const HERE = dirname(fileURLToPath(import.meta.url)); 24 29 const ROOT = resolve(HERE, ".."); 25 30 const subsPath = `${ROOT}/out/subs.json`; 26 31 const blankPath = `${ROOT}/out/subs/blank.png`; 27 - const outPath = `${ROOT}/out/subtitle-track.txt`; 32 + const listPath = `${ROOT}/out/subtitle-track.txt`; 33 + const webmPath = `${ROOT}/out/subtitle-track.webm`; 34 + const hashFile = `${webmPath}.hash`; 28 35 const durPath = `${ROOT}/out/duration.txt`; 36 + const force = process.argv.includes("--force"); 29 37 30 38 const subs = JSON.parse(readFileSync(subsPath, "utf8")); 31 39 if (!subs.length) { 32 40 console.error(`✗ no subtitle chunks in ${subsPath}`); 41 + process.exit(1); 42 + } 43 + if (!existsSync(blankPath)) { 44 + console.error(`✗ missing ${blankPath} — re-run bin/subtitles.mjs`); 33 45 process.exit(1); 34 46 } 35 47 36 - // Total video duration (so the track ends at the right moment). If 37 - // duration.txt isn't around yet, fall back to the last subtitle endSec 38 - // (slides.mjs will have set duration.txt before this runs in the pipeline). 39 48 let total; 40 49 try { total = parseFloat(readFileSync(durPath, "utf8")); } 41 50 catch { total = subs[subs.length - 1].endSec; } 42 51 52 + // ── 1. write the concat-demuxer timeline ─────────────────────────────── 43 53 const lines = []; 44 54 let cursor = 0; 45 55 for (const s of subs) { 46 56 if (s.startSec > cursor + 0.001) { 47 - // Gap before this chunk: blank. 48 57 lines.push(`file '${blankPath}'`); 49 58 lines.push(`duration ${(s.startSec - cursor).toFixed(3)}`); 50 59 } ··· 52 61 lines.push(`duration ${(s.endSec - s.startSec).toFixed(3)}`); 53 62 cursor = s.endSec; 54 63 } 55 - // Trailing blank to fill the rest of the timeline. 56 64 if (total > cursor + 0.001) { 57 65 lines.push(`file '${blankPath}'`); 58 66 lines.push(`duration ${(total - cursor).toFixed(3)}`); ··· 62 70 const lastFileLine = [...lines].reverse().find((l) => l.startsWith("file ")); 63 71 lines.push(lastFileLine); 64 72 65 - writeFileSync(outPath, lines.join("\n") + "\n"); 66 - console.log(`✓ ${outPath} · ${subs.length} chunks · total ${total.toFixed(2)}s`); 73 + writeFileSync(listPath, lines.join("\n") + "\n"); 74 + 75 + // ── 2. cache check + encode ───────────────────────────────────────────── 76 + const inputHash = createHash("sha256") 77 + .update(JSON.stringify(subs.map((s) => ({ f: s.file, a: s.startSec, b: s.endSec })))) 78 + .update(`total=${total.toFixed(3)}`) 79 + .digest("hex") 80 + .slice(0, 16); 81 + 82 + if (!force && existsSync(webmPath) && existsSync(hashFile)) { 83 + const cached = readFileSync(hashFile, "utf8").trim(); 84 + if (cached === inputHash) { 85 + const size = (readFileSync(webmPath).length / 1024).toFixed(0); 86 + console.log(`✓ ${webmPath} cached (${size} KB · hash ${inputHash}) — skipping ffmpeg`); 87 + console.log(`✓ ${listPath} · ${subs.length} chunks · total ${total.toFixed(2)}s`); 88 + process.exit(0); 89 + } 90 + } 91 + 92 + // libvpx-vp9 with yuva420p preserves the alpha channel through to the 93 + // main compose's overlay filter. -t pins the output to the cut's total 94 + // length so any concat-demuxer rounding doesn't bleed past the end. 95 + console.log(`→ encoding subtitle track · ${subs.length} chunks · ${total.toFixed(2)}s`); 96 + execFileSync("ffmpeg", [ 97 + "-hide_banner", "-y", "-loglevel", "error", 98 + "-f", "concat", "-safe", "0", "-i", listPath, 99 + "-vf", "fps=30,format=yuva420p", 100 + "-c:v", "libvpx-vp9", 101 + "-pix_fmt", "yuva420p", 102 + "-b:v", "2M", 103 + "-deadline", "realtime", 104 + "-cpu-used", "8", 105 + "-auto-alt-ref", "0", 106 + "-t", String(total), 107 + webmPath, 108 + ], { stdio: ["ignore", "ignore", "inherit"] }); 109 + 110 + writeFileSync(hashFile, inputHash + "\n"); 111 + const size = (readFileSync(webmPath).length / 1024).toFixed(0); 112 + console.log(`✓ ${webmPath} (${size} KB · hash ${inputHash})`); 113 + console.log(`✓ ${listPath} · ${subs.length} chunks · total ${total.toFixed(2)}s`);