/* * Build a /learn video from a script file. * * node scripts/learn-film.mjs docs/learn-films/.json * * The five original platform videos were cut by an ad-hoc process that was * never committed, so the first time the UI moved nobody could re-shoot them. * This is that pipeline, written down. It reuses the two hard-won lessons from * `scripts/screenshots.mjs`: the appearance preference is stored SERVER-side * and adopted after hydration, so seeding localStorage is not enough and the * profile response has to be rewritten; and per-device layout state * (`pig.sidebarOpen`, `pig.piggyDockOpen`) has to be forced or the framing is * whatever a human last left behind. * * ORDER IS LOAD-BEARING. The narration is rendered FIRST and its measured * duration drives every shot length. A shot list timed by guess leaves the * narrator talking over a frozen frame, or gets cut mid-sentence. * * THE CAMERA. Landscape, and still by default. An earlier version drifted a * slow zoom across every shot, which reads as restless rather than as emphasis * — the whole frame moving all the time is the thing people mean when they call * a screen recording "annoying". This one holds still, and where a line points * at one thing it eases IN on that element, holds, and eases back OUT, the way * Cap zooms toward a click. The easing is a smoothstep either side of a hold, * so there is no visible start or stop. * * Typography is composed in the browser, not in ffmpeg: the product's face is * Manrope Variable and drawtext would fall back to DejaVu, which reads as a * different company. Playwright renders a transparent caption layer per shot * and ffmpeg only moves pixels. * * Requires: a running API + web (PIG_WEB_URL), Chatterbox reachable, ffmpeg. */ import { chromium } from 'playwright'; import { execFile } from 'node:child_process'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { promisify } from 'node:util'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const run = promisify(execFile); const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(HERE, '..'); const WEB = process.env.PIG_WEB_URL ?? 'http://localhost:5179'; const TTS = process.env.PIG_TTS_URL ?? 'http://100.127.247.67:8095'; const VOICE = process.env.PIG_TTS_VOICE ?? 'karti'; /* 1.20x is the rate docs/learn-scripts.md is written to. */ const TEMPO = Number(process.env.PIG_TTS_TEMPO ?? 1.2); const MUSIC = process.env.PIG_FILM_MUSIC ?? join(REPO, 'apps/web/public/audio/pig-ambient.mp3'); const W = 1920; const H = 1080; const SCALE = 2; const FPS = 30; /* The zoom envelope, as fractions of the shot: hold, ease in, hold, ease out. */ const ZOOM_IN_START = 0.16; const ZOOM_IN_END = 0.42; const ZOOM_OUT_START = 0.76; const ZOOM_OUT_END = 0.98; const THEME = { dark: { bg: '#0a0a0b', fg: '#fafafa', pill: 'rgba(18,18,20,.92)', rule: 'rgba(255,255,255,.14)', accent: '#22c55e' }, light: { bg: '#ffffff', fg: '#0a0a0b', pill: 'rgba(255,255,255,.94)', rule: 'rgba(0,0,0,.12)', accent: '#16a34a' }, }; async function ffprobeDuration(path) { const { stdout } = await run('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path]); return Number(stdout.trim()); } async function narrate(text, out) { const response = await fetch(`${TTS}/generate`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, voice: VOICE }), }); if (!response.ok) throw new Error(`Chatterbox ${response.status}: ${await response.text()}`); const wav = Buffer.from(await response.arrayBuffer()); /* Raw WAV bytes, not JSON. A JSON.parse here reads as a TTS outage. */ if (wav.subarray(0, 4).toString() !== 'RIFF') { throw new Error(`Chatterbox did not return WAV: ${wav.subarray(0, 120).toString()}`); } await writeFile(out, wav); return ffprobeDuration(out); } /** Shot lengths are proportional to the words spoken over them. */ function shotFrames(shots, total) { const words = shots.map((shot) => shot.line.trim().split(/\s+/).length); const sum = words.reduce((a, b) => a + b, 0); const frames = words.map((n) => Math.max(FPS, Math.round(((n / sum) * total) * FPS))); const target = Math.round(total * FPS); frames[frames.length - 1] += target - frames.reduce((a, b) => a + b, 0); return frames; } /** * A smoothstep ramp between two fractions of the shot, as an ffmpeg expression. * * Written out rather than accumulated with zoompan's own `zoom` variable, which * refers to the PREVIOUS frame and drifts: expressing the curve purely as a * function of `on` means the zoom is the same on every re-render and lands * exactly on 1.0 at both ends. */ function ramp(from, to, n) { const u = `clip((on/${n - 1}-${from})/${(to - from).toFixed(4)},0,1)`; return `(pow(${u},2)*(3-2*${u}))`; } function zoomExpression(scale, n) { const rise = ramp(ZOOM_IN_START, ZOOM_IN_END, n); const fall = ramp(ZOOM_OUT_START, ZOOM_OUT_END, n); return `1+${(scale - 1).toFixed(4)}*(${rise}-${fall})`; } const CAPTION_HTML = (o) => `
${o.caption}
`; async function main() { const scriptPath = process.argv[2]; if (!scriptPath) throw new Error('usage: learn-film.mjs '); const film = JSON.parse(await readFile(scriptPath, 'utf8')); const work = join(REPO, '.film', film.slug); await rm(work, { recursive: true, force: true }); await mkdir(work, { recursive: true }); const font = join( REPO, 'node_modules/.pnpm/@fontsource-variable+manrope@5.3.0/node_modules/@fontsource-variable/manrope/files/manrope-latin-wght-normal.woff2', ); // 1 -------------------------------------------------------- the narration const narration = film.shots.map((s) => s.line.trim()).join(' '); const words = narration.split(/\s+/).length; const rawVoice = join(work, 'voice-raw.wav'); const spoken = await narrate(narration, rawVoice); const voice = join(work, 'voice.wav'); await run('ffmpeg', ['-v', 'error', '-y', '-i', rawVoice, '-filter:a', `atempo=${TEMPO}`, voice]); const duration = await ffprobeDuration(voice); console.log(` narration ${words} words -> ${spoken.toFixed(2)}s at 1.0x -> ${duration.toFixed(2)}s at ${TEMPO}x`); const frames = shotFrames(film.shots, duration); // 2 ----------------------------------------------------------- the routes const engagements = await fetch(`${WEB}/api/motion/engagements`).then((r) => r.json()); const templates = await fetch(`${WEB}/api/motion/templates`).then((r) => r.json()); const byStage = (stage) => engagements.engagements.find((e) => e.stage === stage); const alias = { /* By slug, not by kind: two shipped templates are `playbook` kind, and * which one `find` returned depended on list order. */ PLAYBOOK: templates.templates.find((t) => t.slug === 'strategic-deployment-playbook')?.id, SCORECARD: templates.templates.find((t) => t.slug === 'trainability-qualification')?.id, HALCYON: byStage('poc')?.id, NORTHWIND: byStage('expansion')?.id, }; for (const [name, id] of Object.entries(alias)) { if (!id) throw new Error(`route alias ${name} resolved to nothing — is the demo book seeded?`); } const browser = await chromium.launch({ channel: 'chrome' }); const contexts = {}; for (const theme of ['dark', 'light']) { const context = await browser.newContext({ /* * A real 1920 desktop viewport, captured at 2x and delivered at 1920. * Setting the viewport to W/SCALE was wrong in a way that looked right: * it produced a 1920px image, but the PAGE believed it was 960 wide, so * the sidebar collapsed and the app filmed its compact layout. The * viewport is what the product responds to; the scale factor is only how * much detail there is to zoom into. */ viewport: { width: W, height: H }, deviceScaleFactor: SCALE, colorScheme: theme, }); await context.addInitScript(` localStorage.setItem('pig.themeMode', '${theme}'); localStorage.setItem('pig.sidebarOpen', 'true'); localStorage.setItem('pig.piggyDockOpen', 'false'); localStorage.setItem('pig.audio.enabled', 'false'); `); await context.route('**/api/me/profile', async (route) => { const response = await route.fetch(); const body = await response.json().catch(() => ({})); await route.fulfill({ json: { ...body, themeMode: theme } }); }); contexts[theme] = context; } const captionPage = await browser.newPage({ viewport: { width: W, height: H } }); const segments = []; for (const [index, shot] of film.shots.entries()) { let route = shot.route; for (const [name, id] of Object.entries(alias)) route = route.replace(name, id); const page = await contexts[shot.theme].newPage(); await page.goto(`${WEB}${route}`, { waitUntil: 'networkidle' }); /* Lazy route chunks sit behind a Suspense boundary; a screenshot taken * before the fallback resolves photographs the spinner. */ await page.waitForTimeout(1400); const painted = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); if (painted !== shot.theme) throw new Error(`shot ${index} painted ${painted}, wanted ${shot.theme}`); if (shot.scrollTo) { const ok = await page.evaluate((text) => { const node = [...document.querySelectorAll('h1,h2,h3')].find((el) => el.textContent?.trim() === text); if (!node) return false; (node.closest('section,[class*="card"]') ?? node).scrollIntoView({ block: 'center' }); return true; }, shot.scrollTo); if (!ok) throw new Error(`shot ${index}: no heading "${shot.scrollTo}" on ${route}`); await page.waitForTimeout(600); } /* The zoom target, in captured pixels. Smallest element containing the * text, so naming "82.8%" lands on the figure rather than on the card. */ let focus = null; if (shot.zoom) { /* * Target selection is "smallest element already in frame", not "last * match in document order", which is what this did first and got wrong in * a way that only shows up on a long page. The Halcyon engagement prints * the same phrases twice — once in the score note and again inside the * artefact bodies further down — so "kappa 0.54" resolved to a paragraph * two and a half thousand pixels below the fold, and no amount of * scrolling brought it into view because an ancestor clips it. * * Preferring what is already on screen keeps the zoom in the frame the * `scrollTo` chose; preferring the smallest match makes naming a figure * land on the figure rather than on the card around it. Only if nothing * matches in frame does it scroll, and then it re-measures. */ /* Inlined in both evaluates rather than injected as a string: the page * is the only place this can run, and `new Function` is one CSP away from * failing in an environment that is not the dev server. */ const locate = ({ text, scale, move }) => { const matches = [...document.querySelectorAll('body *')].filter( (el) => el.textContent?.includes(text) && el.getBoundingClientRect().width > 0, ); if (!matches.length) return { found: false }; const area = (el) => { const r = el.getBoundingClientRect(); return r.width * r.height; }; const inFrame = matches.filter((el) => { const r = el.getBoundingClientRect(); return r.bottom > 0 && r.top < window.innerHeight; }); const el = (inFrame.length ? inFrame : matches).sort((a, b) => area(a) - area(b))[0]; const r = el.getBoundingClientRect(); if (r.bottom < 0 || r.top > window.innerHeight) { if (move) { el.scrollIntoView({ block: 'center' }); return { found: true, offScreen: true }; } return { found: true, offScreen: true }; } return { found: true, offScreen: false, cx: (r.left + r.width / 2) * scale, cy: (r.top + r.height / 2) * scale, }; }; const first = await page.evaluate(locate, { text: shot.zoom.on, scale: SCALE, move: true }); if (!first.found) throw new Error(`shot ${index}: zoom target "${shot.zoom.on}" is not on ${route}`); if (first.offScreen) await page.waitForTimeout(500); focus = first.offScreen ? await page.evaluate(locate, { text: shot.zoom.on, scale: SCALE, move: false }) : first; if (focus.offScreen) focus = null; if (!focus) { throw new Error(`shot ${index}: zoom target "${shot.zoom.on}" would not come into view on ${route}`); } } const framePng = join(work, `frame-${index}.png`); await page.screenshot({ path: framePng }); await page.close(); const palette = THEME[shot.theme]; await captionPage.setContent( CAPTION_HTML({ ...palette, font, caption: shot.caption, progress: (index + 1) / film.shots.length }), { waitUntil: 'load' }, ); await captionPage.waitForTimeout(250); const captionPng = join(work, `caption-${index}.png`); await captionPage.screenshot({ path: captionPng, omitBackground: true }); // 3 ---------------------------------------------- one shot -> one clip const n = frames[index]; const camera = focus ? `zoompan=z='${zoomExpression(shot.zoom.scale ?? 1.6, n)}':` + `x='max(0,min(iw-iw/zoom,${focus.cx.toFixed(1)}-(iw/zoom)/2))':` + `y='max(0,min(ih-ih/zoom,${focus.cy.toFixed(1)}-(ih/zoom)/2))':` + `d=${n}:s=${W}x${H}:fps=${FPS}` : `scale=${W}:${H}`; const segment = join(work, `seg-${index}.mp4`); await run('ffmpeg', [ '-v', 'error', '-y', '-loop', '1', '-framerate', String(FPS), '-t', String(n / FPS), '-i', framePng, '-i', captionPng, '-filter_complex', `[0:v]${camera},format=yuv420p[base];[base][1:v]overlay=0:0:format=auto,format=yuv420p[v]`, '-map', '[v]', '-frames:v', String(n), /* CRF 23 on a still frame with an occasional slow zoom. The originals * ship at about 420 kbps for 1440x810; this lands in the same class. */ '-c:v', 'libx264', '-preset', 'slow', '-crf', '23', segment, ]); segments.push(segment); console.log( ` ${String(index + 1).padStart(2)}/${film.shots.length} ${shot.theme.padEnd(5)} ` + `${(n / FPS).toFixed(2)}s ${focus ? `zoom x${shot.zoom.scale ?? 1.6} on "${shot.zoom.on}"` : 'still'}`, ); } await browser.close(); // 4 ------------------------------------------------ cut, score, normalise const list = join(work, 'segments.txt'); await writeFile(list, segments.map((s) => `file '${s}'`).join('\n')); const silent = join(work, 'silent.mp4'); await run('ffmpeg', ['-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', silent]); const out = join(work, 'film.mp4'); await run('ffmpeg', [ '-v', 'error', '-y', '-i', silent, '-i', voice, '-stream_loop', '-1', '-i', MUSIC, '-filter_complex', `[2:a]volume=0.075,afade=t=in:st=0:d=1.2,afade=t=out:st=${(duration - 1.6).toFixed(2)}:d=1.6[bed];` + `[1:a]adelay=250|250,apad[vox];` + `[vox][bed]amix=inputs=2:duration=first:dropout_transition=0,` + `atrim=0:${duration.toFixed(3)},loudnorm=I=-16:TP=-1.5:LRA=11[a]`, '-map', '0:v', '-map', '[a]', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', '-shortest', out, ]); const finalDuration = await ffprobeDuration(out); const hash = createHash('sha256').update(await readFile(out)).digest('hex').slice(0, 8); const named = join(work, `${film.slug}.${hash}.mp4`); await run('cp', [out, named]); const poster = join(work, `${film.slug}.${hash}.jpg`); await run('ffmpeg', ['-v', 'error', '-y', '-ss', '1.2', '-i', named, '-frames:v', '1', '-q:v', '3', poster]); console.log(`\n ${film.slug}: ${finalDuration.toFixed(2)}s, ${words} words, ${W}x${H}`); console.log(` ${named}\n ${poster}`); } await main();