// build.mjs — Generate index.html for a PR walkthrough video from a manifest
// JSON file. Reads slide definitions and emits one HTML composition with
// timed clips + a single GSAP timeline driving slide transitions, code-focus
// pans, and yellow-on-black captions sourced from whisper word-level
// transcripts of each audio file.
//
// Usage:
// node build.mjs
//
// Expects:
// - audio-NN.wav files alongside the manifest (referenced by slide.audio)
// - copies of those files in ./assets/audio-NN.wav (done by render.sh)
// - whisper transcripts in ./transcripts/audio-NN.json (done by render.sh)
//
// Output: ./index.html (the hyperframes composition)
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
// --- Args --------------------------------------------------------------------
const manifestPath = process.argv[2]
if (!manifestPath) {
console.error('Usage: node build.mjs ')
process.exit(1)
}
const manifestAbs = path.resolve(manifestPath)
if (!fs.existsSync(manifestAbs)) {
console.error(`Manifest not found: ${manifestAbs}`)
process.exit(1)
}
const manifest = JSON.parse(fs.readFileSync(manifestAbs, 'utf8'))
// --- Whisper transcripts -----------------------------------------------------
const TRANSCRIPTS_DIR = path.join(__dirname, 'transcripts')
const transcripts = new Map()
if (fs.existsSync(TRANSCRIPTS_DIR)) {
for (const f of fs.readdirSync(TRANSCRIPTS_DIR)) {
if (!f.endsWith('.json')) continue
const audioName = f.replace(/\.json$/, '.wav')
transcripts.set(audioName, JSON.parse(fs.readFileSync(path.join(TRANSCRIPTS_DIR, f), 'utf8')))
}
}
// Group whisper words into caption-sized chunks. Break early on long pauses
// (sentence boundaries) or when reaching the max word count.
function chunkTranscript(words, { maxWords = 7, gapThreshold = 0.45 } = {}) {
const chunks = []
let current = []
for (const w of words) {
if (current.length === 0) {
current.push(w)
continue
}
const prev = current[current.length - 1]
const gap = w.start - prev.end
if (gap > gapThreshold || current.length >= maxWords) {
chunks.push(current)
current = [w]
} else {
current.push(w)
}
}
if (current.length) chunks.push(current)
return chunks.map((group) => ({
text: group.map((g) => g.text).join(' '),
start: group[0].start,
end: group[group.length - 1].end,
}))
}
function makeCaptions(audioFile, audioStart) {
const words = transcripts.get(audioFile)
if (!words) return []
const chunks = chunkTranscript(words)
// Whisper capitalizes the first word of each transcript naturally and
// leaves mid-sentence words lowercase — so we use the text as-is rather
// than uppercasing each chunk (which would treat every chunk as a new
// sentence).
return chunks.map((c) => ({
text: c.text,
start: audioStart + c.start,
duration: Math.max(0.4, c.end - c.start),
}))
}
// --- Cumulative timing -------------------------------------------------------
let cursor = 0
const timed = manifest.slides.map((slide, i) => {
const start = cursor
const duration = slide.durationInSeconds
cursor += duration
return { slide, start, duration, i }
})
const totalDuration = cursor
// --- HTML escape -------------------------------------------------------------
function esc(s) {
return String(s).replace(/&/g, '&').replace(//g, '>')
}
// --- Light syntax highlighting (keywords / strings / comments / etc) ---------
const KEYWORDS = new Set([
'abstract',
'as',
'async',
'await',
'boolean',
'break',
'case',
'catch',
'class',
'const',
'constructor',
'continue',
'default',
'delete',
'do',
'else',
'enum',
'export',
'extends',
'false',
'finally',
'for',
'from',
'function',
'get',
'if',
'implements',
'import',
'in',
'instanceof',
'interface',
'is',
'let',
'new',
'null',
'number',
'of',
'override',
'private',
'protected',
'public',
'readonly',
'return',
'set',
'static',
'string',
'super',
'switch',
'this',
'throw',
'true',
'try',
'type',
'typeof',
'undefined',
'void',
'while',
'yield',
'any',
'never',
'unknown',
])
function highlightLine(line) {
const re =
/(\/\/.*$)|(\/\*[\s\S]*?\*\/)|('(?:\\.|[^'\\])*')|("(?:\\.|[^"\\])*")|(`(?:\\.|[^`\\])*`)|(\b\d+(?:\.\d+)?\b)|(\b[A-Za-z_$][\w$]*\b)|(@\w+)/g
let out = ''
let last = 0
for (const m of line.matchAll(re)) {
out += esc(line.slice(last, m.index))
const [tok, comment, block, sq, dq, bt, num, ident, decorator] = m
if (comment || block) out += `${esc(tok)}`
else if (sq || dq || bt) out += `${esc(tok)}`
else if (num) out += `${esc(tok)}`
else if (decorator) out += `${esc(tok)}`
else if (ident) {
if (KEYWORDS.has(ident)) out += `${esc(ident)}`
else if (/^[A-Z]/.test(ident)) out += `${esc(ident)}`
else out += esc(ident)
}
last = m.index + tok.length
}
out += esc(line.slice(last))
return out || ' '
}
// --- Brand SVGs --------------------------------------------------------------
const TLDRAW_WORDMARK = ``
const TLDRAW_MARK = ``
// --- Slide renderers ---------------------------------------------------------
function slideAttrs({ start, duration, i }, extra = '') {
// Slide 0 starts visible at frame 0; the .slide CSS baseline is opacity:0
// so without this override the captured first frame would be blank.
const initialStyle = i === 0 ? ` style="opacity: 1"` : ''
return `class="clip slide" data-start="${start}" data-duration="${duration}" data-track-index="2" id="slide-${i}"${initialStyle} ${extra}`
}
function renderIntro({ slide, start, duration, i }) {
// Pick a single word from the title to highlight with the brand mark
// background. Defaults to the last word if no special marker is provided.
const title = slide.title || `PR #${manifest.pr}`
// Strip a trailing "#NNNN" if present — it's redundant with the eyebrow.
const cleanTitle = title.replace(/\s*#\d+\s*$/, '').trim()
const words = cleanTitle.split(/\s+/)
const highlightIndex = Math.max(0, words.length - 2)
const highlighted = words
.map((w, n) => (n === highlightIndex ? `${esc(w)}` : esc(w)))
.join(' ')
return `