Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122#!/usr/bin/env node// subtitle-track.mjs — emit `out/subs.ass` (Advanced SubStation Alpha)// from `out/subs.json`. The main compose then renders subtitles via// ffmpeg's `subtitles=` filter (libass) — single filter, sub-second// overhead, no alpha-codec gymnastics, frame-perfect timing.//// Why ASS over the pre-rendered PNG track:// - libvpx-vp9 silently dropped alpha on the oven (pix_fmt=yuv420p// instead of yuva420p), so the overlaid track came through opaque// black and covered the slides.// - Even with alpha working, the concat-demuxer + fps filter pattern// was sparse-PTS-prone and truncated the output stream.// - ASS bypasses both problems: ffmpeg renders text directly into the// video stream at composite time using the libass subtitle library.//// Pill styling — translucent dark background + cream text + magenta// border — replicates the look of the previous PNG pill via ASS box// drawing primitives (BorderStyle=4 = opaque box).//// Usage: node bin/subtitle-track.mjs [audience-name]
import { readFileSync, writeFileSync, existsSync } from "node:fs";import { resolve, dirname } from "node:path";import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));const ROOT = resolve(HERE, "..");const audienceName = process.argv[2] || "fia";const subsPath = `${ROOT}/out/subs.json`;const assPath = `${ROOT}/out/subs.ass`;const layoutsPath = `${ROOT}/out/layouts.json`;const segmentsPath = `${ROOT}/out/segments.json`;
// Per-chunk chapter color from layouts.json + segments.json — overrides// the libass outline color per Dialogue line so the pill border follows// the slide color story instead of being fixed magenta.const layoutsForColors = existsSync(layoutsPath) ? JSON.parse(readFileSync(layoutsPath, "utf8")) : {};const segmentsForColors = existsSync(segmentsPath) ? JSON.parse(readFileSync(segmentsPath, "utf8")) : [];function chapterColorAt(secs) { for (const seg of segmentsForColors) { if (secs >= seg.startSec && secs < seg.endSec) { const layout = layoutsForColors[seg.name]; if (layout && layout.color && layout.color.rgb) { const [r, g, b] = layout.color.rgb; return `&H${b.toString(16).padStart(2,"0")}${g.toString(16).padStart(2,"0")}${r.toString(16).padStart(2,"0")}&`; } return null; } } return null;}
const subs = JSON.parse(readFileSync(subsPath, "utf8"));if (!subs.length) { console.error(`✗ no subtitle chunks in ${subsPath}`); process.exit(1);}
// ── ASS time format: H:MM:SS.cc ────────────────────────────────────────function assTime(t) { const h = Math.floor(t / 3600); const m = Math.floor((t % 3600) / 60); const s = (t % 60).toFixed(2); return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(5, "0")}`;}
// ASS color = &HAABBGGRR (alpha + BGR, hex). For opaque colors use AA=00.// Style maps:// PrimaryColour → text fill// OutlineColour → text outline// BackColour → translucent pill background (BorderStyle=4 enables a box)// BorderStyle=1 → outline + shadow only (no box)// BorderStyle=4 → opaque box behind the text (our pill)// Outline → border thickness in pixels// Shadow → drop shadow distance// Alignment → 1..9 numpad: 2 = bottom-center, 5 = middle-center,// 8 = top-center// MarginV → vertical margin from the corresponding edge//// We want a magenta border (3px) around a translucent dark-purple pill// with cream-yellow text, anchored at the bottom of a 1080×1920 frame.// Bottom anchor (Alignment=2) + MarginV=215 puts the pill base around// y=1700 of 1920 (matches the prior PNG pill's y=1690 area).const PRIMARY = "&H00C5F7FC"; // cream #FCF7C5const OUTLINE = "&H00B469FF"; // magenta outline #FF69B4const BACK = "&H80200810"; // dark purple translucent ~50% (#100820 + alpha 80)
const lines = [ "[Script Info]", "ScriptType: v4.00+", "PlayResX: 1080", "PlayResY: 1920", "WrapStyle: 0", "ScaledBorderAndShadow: yes", "", "[V4+ Styles]", "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding", `Style: Default,YWFTProcessing,64,${PRIMARY},&H00FFFFFF,${OUTLINE},${BACK},1,0,0,0,100,100,-1,0,4,3,0,2,60,60,215,1`, "", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",];
for (const c of subs) { // Sanitize: ASS uses \N for newlines and treats { ... } as override blocks const text = (c.text || "") .replace(/[\r\n]+/g, " ") .replace(/\{/g, "(") .replace(/\}/g, ")") .trim(); if (!text) continue; // Per-segment chapter color override on the outline (\3c). Falls back // to the Default style's outline (magenta) when the chunk lands // outside any segment or before the layout step ran. const chapHex = chapterColorAt(c.startSec); const colorOverride = chapHex ? `{\\3c${chapHex}}` : ""; lines.push(`Dialogue: 0,${assTime(c.startSec)},${assTime(c.endSec)},Default,,0,0,0,,${colorOverride}${text}`);}
writeFileSync(assPath, lines.join("\n") + "\n");console.log(`✓ ${assPath} · ${subs.length} cues`);