// 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 `
Pull Request tldraw / tldraw · #${manifest.pr}

${highlighted}

${slide.subtitle ? `

${esc(slide.subtitle)}

` : ''}
${slide.date ? `${esc(slide.date)}` : ''} Walkthrough
` } function renderSegment({ slide, start, duration, i }) { return `

${esc(slide.title || '')}

` } function renderCode({ slide, start, duration, i }) { const lines = (slide.code || '').split('\n') const focus = slide.focus || [{ line: 0, at: 0 }] const codeLines = lines .map( (l, n) => `
${String(n + 1).padStart(2, ' ')}${highlightLine(l)}
` ) .join('') const focusJson = JSON.stringify(focus) return `
${esc(slide.language || 'ts')} ${esc(slide.filename || '')} ${esc(slide.title || '')}
${codeLines}
` } function renderDiffLines(diff) { const lines = diff.split('\n') return lines .map((l) => { let cls = 'dl' let mark = '' if (l.startsWith('@@')) { cls += ' dl-hunk' mark = '⋯' } else if (l.startsWith('+++') || l.startsWith('---')) { cls += ' dl-meta' } else if (l.startsWith('+')) { cls += ' dl-add' mark = '+' } else if (l.startsWith('-')) { cls += ' dl-del' mark = '−' } else { mark = ' ' } const body = l.startsWith('+') || l.startsWith('-') ? l.slice(1) : l return `
${esc(mark)}${highlightLine(body)}
` }) .join('') } function renderDiff({ slide, start, duration, i }) { return `
${esc(slide.language || 'ts')} ${esc(slide.filename || '')} ${esc(slide.title || '')}
${renderDiffLines(slide.diff || '')}
` } function renderText({ slide, start, duration, i }) { return `
Summary tldraw / tldraw · #${manifest.pr}

${esc(slide.title || '')}

${slide.subtitle ? `

${esc(slide.subtitle)}

` : ''}
` } function renderList({ slide, start, duration, i }) { const items = (slide.items || []) .map( (it, n) => `
  • ${n + 1}.${esc(it)}
  • ` ) .join('') return `

    ${esc(slide.title || '')}

      ${items}
    ` } function renderImage({ slide, start, duration, i }) { return `
    ` } function renderOutro({ start, duration, i }) { return `
    ${TLDRAW_WORDMARK}
    PR Walkthrough · #${manifest.pr}
    ` } const RENDERERS = { intro: renderIntro, segment: renderSegment, code: renderCode, diff: renderDiff, text: renderText, list: renderList, image: renderImage, outro: renderOutro, } const slidesHtml = timed .map((t) => { const r = RENDERERS[t.slide.type] if (!r) throw new Error(`Unknown slide type: ${t.slide.type}`) return r(t) }) .join('') // --- Audio elements ---------------------------------------------------------- const audioHtml = timed .filter(({ slide }) => slide.audio) .map( ({ slide, start, i }) => `` ) .join('\n') // --- Captions --------------------------------------------------------------- const allCaptions = [] for (const { slide, start } of timed) { if (!slide.audio) continue const caps = makeCaptions(slide.audio, start) allCaptions.push(...caps) } const CAPTION_GAP = 0.002 const captionsHtml = allCaptions .map((c, k) => { const dur = Math.max(0.05, c.duration - CAPTION_GAP) return `
    ${esc(c.text)}
    ` }) .join('\n') // --- Timeline JS ------------------------------------------------------------- const timelineJs = [] for (const { slide, start, duration, i } of timed) { const fadeIn = 0.4 const fadeOut = 0.4 if (i === 0) { // First frame should show the full title card immediately. timelineJs.push(`tl.set("#slide-${i}", { opacity: 1 }, ${start});`) } else { timelineJs.push( `tl.fromTo("#slide-${i}", { opacity: 0 }, { opacity: 1, duration: ${fadeIn}, ease: "power2.out" }, ${start});` ) } timelineJs.push( `tl.to("#slide-${i}", { opacity: 0, duration: ${fadeOut}, ease: "power2.in" }, ${start + duration - fadeOut});` ) timelineJs.push(`tl.set("#slide-${i}", { opacity: 0 }, ${start + duration});`) if ((slide.type === 'code' || slide.type === 'diff') && slide.focus && slide.focus.length) { const lineHeight = 36 const focus = slide.focus const targets = focus.map((f) => ({ t: start + (f.at || 0) * duration, y: -Math.max(0, f.line - 4) * lineHeight, })) timelineJs.push(`tl.set("#code-scroll-${i}", { y: ${targets[0].y} }, ${start});`) for (let k = 1; k < targets.length; k++) { const prev = targets[k - 1] const cur = targets[k] const dur = Math.max(0.5, cur.t - prev.t) timelineJs.push( `tl.to("#code-scroll-${i}", { y: ${cur.y}, duration: ${dur}, ease: "power1.inOut" }, ${prev.t});` ) } } } // --- Final HTML -------------------------------------------------------------- const html = `
    ${slidesHtml}
    ${captionsHtml}
    ${audioHtml}
    ` fs.writeFileSync(path.join(__dirname, 'index.html'), html) console.log(`Wrote ${path.relative(process.cwd(), path.join(__dirname, 'index.html'))}`) console.log(` ${timed.length} slides, ${totalDuration.toFixed(2)}s total`) console.log( ` ${timed.filter((t) => t.slide.audio).length} audio tracks, ${allCaptions.length} captions` )