Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

recap: switch subtitles from pre-encoded webm to .ass + libass filter

The pre-encoded subtitle webm path had two compounding bugs on the
oven build: libvpx-vp9 silently dropped alpha (encoded as yuv420p
instead of yuva420p, so every frame came through as opaque black and
hid the slides), and the concat-demuxer→fps filter pattern produced
sparse PTS that truncated the main encode at ~half length.

Switching to ffmpeg's `subtitles=` filter (libass) sidesteps both:
- Subtitles render directly into the video stream at composite time —
no separate alpha-codec encode required.
- Single filter, sub-second overhead instead of 5+ minutes of webm
pre-encode.
- Frame-perfect cue timing from the .ass Dialogue lines.
- Pill styling replicated via BorderStyle=4 (opaque box) +
PrimaryColour cream + OutlineColour magenta + translucent BackColour.

bin/subtitle-track.mjs now emits subs.ass instead of subtitle-track.webm.
build-filter.mjs adds a single `subtitles=...:fontsdir=...` filter on
the slide chain. compose.fish drops the subtitle-track input; waltz
moves from input #3 back to input #2.

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

+87 -103
+10 -8
recap/bin/build-filter.mjs
··· 25 25 26 26 const lines = []; 27 27 // NOTE: do NOT add an `fps=30` filter to inputs that come from a concat 28 - // demuxer with `duration` directives (slides + subtitle webm here). The 29 - // fps filter mis-handles the sparse PTS that the demuxer emits and 30 - // truncates the output stream at ~half length. ffmpeg honors the 31 - // duration directives natively when the encoder consumes them directly. 28 + // demuxer with `duration` directives (slides input #0). The fps filter 29 + // mis-handles the sparse PTS the demuxer emits and truncates the output 30 + // stream. ffmpeg honors the duration directives natively at the encoder. 32 31 lines.push(`[0:v]format=yuv420p,scale=1080:1920,setsar=1[bg]`); 33 32 lines.push(`[1:a]apad=whole_dur=${TOTAL},asplit=2[a1][a2]`); 34 33 lines.push(`[a2]showwaves=s=1080x96:colors=0xff70d0|0x70f0e0:mode=cline:rate=30,format=rgba,colorchannelmixer=aa=0.55[wave]`); 35 34 lines.push(`[bg][wave]overlay=x=0:y=${WAVE_Y}:format=auto[bg2]`); 36 35 lines.push(`[bg2]drawbox=x=0:y=1912:w='iw*t/${TOTAL}':h=8:color=0xff69b4:t=fill[v0]`); 37 - // Single subtitle-track overlay. Input #2 is a pre-encoded 30 fps webm 38 - // with alpha (vp9 / yuva420p) produced by subtitle-track.mjs. 39 - lines.push(`[2:v]scale=1080:1920,format=rgba[subs]`); 40 - lines.push(`[v0][subs]overlay=x=0:y=0:format=auto:shortest=0[final]`); 36 + // Subtitles via libass — single filter pass, no pre-encode, no alpha 37 + // codec dance. fontsdir picks up the YWFT face shipped in the repo so 38 + // the .ass `Style: YWFTProcessing` resolves. The .ass path is escaped 39 + // for ffmpeg's filter parser (`:` and `\` need it inside subtitles=...). 40 + const ASS = `${ROOT}/out/subs.ass`.replace(/:/g, "\\:").replace(/\\/g, "/"); 41 + const FONTSDIR = `${REPO}/system/public/type/webfonts`.replace(/:/g, "\\:"); 42 + lines.push(`[v0]subtitles='${ASS}':fontsdir='${FONTSDIR}'[final]`); 41 43 42 44 process.stdout.write(lines.join(";\n") + "\n");
+6 -6
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.webm 19 + set -l SUBSASS $OUT/subs.ass 20 20 set -l VIDEO $OUT/recap.mp4 21 21 set -l FILTER $OUT/filter.txt 22 22 ··· 28 28 echo "✗ missing $OUT/subs.json — run bin/subtitles.mjs first" 29 29 exit 1 30 30 end 31 - if not test -f $SUBTRACK 32 - echo "✗ missing $SUBTRACK — run bin/subtitle-track.mjs first" 31 + if not test -f $SUBSASS 32 + echo "✗ missing $SUBSASS — run bin/subtitle-track.mjs first" 33 33 exit 1 34 34 end 35 35 ··· 46 46 if test -f $WALTZ 47 47 echo " + bed: $WALTZ (waltz)" 48 48 # printf — fish parses $TOTAL[bed] as a slice index; %s sidesteps that. 49 - printf ';[3:a]volume=0.42,atrim=duration=%s[bed];[a1][bed]amix=inputs=2:duration=first:dropout_transition=0:weights=1.0 0.55[mix]\n' "$TOTAL" >> $FILTER 49 + # Slides=0, narration=1, waltz=2 (subtitles are baked in via the libass 50 + # filter inside the filter graph — no extra input). 51 + printf ';[2:a]volume=0.42,atrim=duration=%s[bed];[a1][bed]amix=inputs=2:duration=first:dropout_transition=0:weights=1.0 0.55[mix]\n' "$TOTAL" >> $FILTER 50 52 ffmpeg -hide_banner -y \ 51 53 -f concat -safe 0 -i $OUT/concat.txt \ 52 54 -i $AUDIO \ 53 - -i $SUBTRACK \ 54 55 -stream_loop -1 -i $WALTZ \ 55 56 -filter_complex_script $FILTER \ 56 57 -map "[final]" -map "[mix]" \ ··· 63 64 ffmpeg -hide_banner -y \ 64 65 -f concat -safe 0 -i $OUT/concat.txt \ 65 66 -i $AUDIO \ 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 \
+71 -89
recap/bin/subtitle-track.mjs
··· 1 1 #!/usr/bin/env node 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. 2 + // subtitle-track.mjs — emit `out/subs.ass` (Advanced SubStation Alpha) 3 + // from `out/subs.json`. The main compose then renders subtitles via 4 + // ffmpeg's `subtitles=` filter (libass) — single filter, sub-second 5 + // overhead, no alpha-codec gymnastics, frame-perfect timing. 6 6 // 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. 7 + // Why ASS over the pre-rendered PNG track: 8 + // - libvpx-vp9 silently dropped alpha on the oven (pix_fmt=yuv420p 9 + // instead of yuva420p), so the overlaid track came through opaque 10 + // black and covered the slides. 11 + // - Even with alpha working, the concat-demuxer + fps filter pattern 12 + // was sparse-PTS-prone and truncated the output stream. 13 + // - ASS bypasses both problems: ffmpeg renders text directly into the 14 + // video stream at composite time using the libass subtitle library. 16 15 // 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 + // Pill styling — translucent dark background + cream text + magenta 17 + // border — replicates the look of the previous PNG pill via ASS box 18 + // drawing primitives (BorderStyle=4 = opaque box). 19 19 // 20 - // Usage: node bin/subtitle-track.mjs 20 + // Usage: node bin/subtitle-track.mjs [audience-name] 21 21 22 - import { execFileSync } from "node:child_process"; 23 - import { readFileSync, writeFileSync, existsSync } from "node:fs"; 22 + import { readFileSync, writeFileSync } from "node:fs"; 24 23 import { resolve, dirname } from "node:path"; 25 24 import { fileURLToPath } from "node:url"; 26 - import { createHash } from "node:crypto"; 27 25 28 26 const HERE = dirname(fileURLToPath(import.meta.url)); 29 27 const ROOT = resolve(HERE, ".."); 28 + const audienceName = process.argv[2] || "fia"; 30 29 const subsPath = `${ROOT}/out/subs.json`; 31 - const blankPath = `${ROOT}/out/subs/blank.png`; 32 - const listPath = `${ROOT}/out/subtitle-track.txt`; 33 - const webmPath = `${ROOT}/out/subtitle-track.webm`; 34 - const hashFile = `${webmPath}.hash`; 35 - const durPath = `${ROOT}/out/duration.txt`; 36 - const force = process.argv.includes("--force"); 30 + const assPath = `${ROOT}/out/subs.ass`; 37 31 38 32 const subs = JSON.parse(readFileSync(subsPath, "utf8")); 39 33 if (!subs.length) { 40 34 console.error(`✗ no subtitle chunks in ${subsPath}`); 41 35 process.exit(1); 42 36 } 43 - if (!existsSync(blankPath)) { 44 - console.error(`✗ missing ${blankPath} — re-run bin/subtitles.mjs`); 45 - process.exit(1); 46 - } 47 37 48 - let total; 49 - try { total = parseFloat(readFileSync(durPath, "utf8")); } 50 - catch { total = subs[subs.length - 1].endSec; } 51 - 52 - // ── 1. write the concat-demuxer timeline ─────────────────────────────── 53 - const lines = []; 54 - let cursor = 0; 55 - for (const s of subs) { 56 - if (s.startSec > cursor + 0.001) { 57 - lines.push(`file '${blankPath}'`); 58 - lines.push(`duration ${(s.startSec - cursor).toFixed(3)}`); 59 - } 60 - lines.push(`file '${s.file}'`); 61 - lines.push(`duration ${(s.endSec - s.startSec).toFixed(3)}`); 62 - cursor = s.endSec; 63 - } 64 - if (total > cursor + 0.001) { 65 - lines.push(`file '${blankPath}'`); 66 - lines.push(`duration ${(total - cursor).toFixed(3)}`); 38 + // ── ASS time format: H:MM:SS.cc ──────────────────────────────────────── 39 + function assTime(t) { 40 + const h = Math.floor(t / 3600); 41 + const m = Math.floor((t % 3600) / 60); 42 + const s = (t % 60).toFixed(2); 43 + return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(5, "0")}`; 67 44 } 68 - // Concat demuxer requires the last `file` line repeated for its duration 69 - // to apply (https://trac.ffmpeg.org/wiki/Slideshow). 70 - const lastFileLine = [...lines].reverse().find((l) => l.startsWith("file ")); 71 - lines.push(lastFileLine); 72 45 73 - writeFileSync(listPath, lines.join("\n") + "\n"); 46 + // ASS color = &HAABBGGRR (alpha + BGR, hex). For opaque colors use AA=00. 47 + // Style maps: 48 + // PrimaryColour → text fill 49 + // OutlineColour → text outline 50 + // BackColour → translucent pill background (BorderStyle=4 enables a box) 51 + // BorderStyle=1 → outline + shadow only (no box) 52 + // BorderStyle=4 → opaque box behind the text (our pill) 53 + // Outline → border thickness in pixels 54 + // Shadow → drop shadow distance 55 + // Alignment → 1..9 numpad: 2 = bottom-center, 5 = middle-center, 56 + // 8 = top-center 57 + // MarginV → vertical margin from the corresponding edge 58 + // 59 + // We want a magenta border (3px) around a translucent dark-purple pill 60 + // with cream-yellow text, anchored at the bottom of a 1080×1920 frame. 61 + // Bottom anchor (Alignment=2) + MarginV=215 puts the pill base around 62 + // y=1700 of 1920 (matches the prior PNG pill's y=1690 area). 63 + const PRIMARY = "&H00C5F7FC"; // cream #FCF7C5 64 + const OUTLINE = "&H00B469FF"; // magenta outline #FF69B4 65 + const BACK = "&H80200810"; // dark purple translucent ~50% (#100820 + alpha 80) 74 66 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); 67 + const lines = [ 68 + "[Script Info]", 69 + "ScriptType: v4.00+", 70 + "PlayResX: 1080", 71 + "PlayResY: 1920", 72 + "WrapStyle: 0", 73 + "ScaledBorderAndShadow: yes", 74 + "", 75 + "[V4+ Styles]", 76 + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding", 77 + `Style: Default,YWFTProcessing,64,${PRIMARY},&H00FFFFFF,${OUTLINE},${BACK},1,0,0,0,100,100,-1,0,4,3,0,2,60,60,215,1`, 78 + "", 79 + "[Events]", 80 + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text", 81 + ]; 81 82 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 - } 83 + for (const c of subs) { 84 + // Sanitize: ASS uses \N for newlines and treats { ... } as override blocks 85 + const text = (c.text || "") 86 + .replace(/[\r\n]+/g, " ") 87 + .replace(/\{/g, "(") 88 + .replace(/\}/g, ")") 89 + .trim(); 90 + if (!text) continue; 91 + lines.push(`Dialogue: 0,${assTime(c.startSec)},${assTime(c.endSec)},Default,,0,0,0,,${text}`); 90 92 } 91 93 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`); 94 + writeFileSync(assPath, lines.join("\n") + "\n"); 95 + console.log(`✓ ${assPath} · ${subs.length} cues`);