/* * Build a vertical /learn video from a script file. * * node scripts/learn-film.mjs docs/learn-films/.json * * The five existing platform videos were cut by an ad-hoc pipeline that was * never committed, so the first time the UI changed nobody could re-shoot them. * This is that pipeline, written down. `scripts/screenshots.mjs` exists for the * same reason and this file deliberately reuses its two hard-won lessons: 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 — which is how * the previous set was found to be wrong. * * Typography is composed in the browser, not in ffmpeg: the product's typeface * is Manrope Variable and ffmpeg's drawtext would have to fall back to DejaVu, * which is visibly not the same product. So Playwright renders a transparent * chrome layer per shot and ffmpeg only moves pixels. * * Requires: a running API + web (see 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 the /learn scripts are written to; see docs/learn-scripts.md. */ 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 FRAME_W = 1080; const FRAME_H = 1920; /* The app is captured at 1120 wide and lands at 1080, so it is very nearly 1:1 * and the product's own type sizes stay legible on a phone. Any narrower and the * sidebar drops to the mobile tab bar (NAV_BREAKPOINT is 1024). */ const SHOT_W = 1120; const SHOT_H = 1560; const APP_W = FRAME_W; const APP_H = Math.round((SHOT_H * FRAME_W) / SHOT_W); const APP_Y = 250; const FPS = 30; /** Every anchor a script may name, and how to frame it. */ /* * `focus` is `full` (the whole viewport) or `column` (the content column only). * * A vertical video is watched on a phone, and the whole 1120px viewport scaled * into 1080 leaves the product's own 12px labels at about 11px on the finished * frame — technically visible, actually unreadable. So a detail shot drops the * sidebar and keeps the content column, which is about 1.4x closer. * * The crop is driven by the column's WIDTH rather than by the height of the * thing being talked about, which was the first attempt: a crop sized to the * target element is narrower than the column, so it sliced the cards either * side of centre and the frame read as broken rather than as close. Width * first, height derived from the aspect, vertical position centred on the * target — that way nothing is ever cut off horizontally. */ const ANCHORS = { hero: { route: '/motion', scroll: 'top', focus: 'full' }, stats: { route: '/motion', scroll: 'top', focus: 'column' }, 'stage-rail': { route: '/motion', text: 'The motion, stage by stage', focus: 'column' }, kinds: { route: '/motion', text: 'Library cover, by kind', focus: 'column' }, promotions: { route: '/motion', text: 'What got easier', focus: 'column' }, 'open-engagements': { route: '/motion', text: 'Open engagements', focus: 'column' }, 'library-list': { route: '/motion/library', scroll: 'top', focus: 'full' }, 'template-head': { route: '/motion/library/PLAYBOOK', scroll: 'top', focus: 'column' }, 'template-use': { route: '/motion/library/PLAYBOOK', text: 'Use this template', focus: 'column' }, 'template-body': { route: '/motion/library/PLAYBOOK', text: 'The document', focus: 'column' }, 'eng-list': { route: '/motion/engagements', scroll: 'top', focus: 'full' }, 'eng-head': { route: '/motion/engagements/POC', scroll: 'top', focus: 'column' }, 'eng-stages': { route: '/motion/engagements/POC', text: 'Where the work sits', focus: 'column' }, 'eng-qualification': { route: '/motion/engagements/POC', text: 'Qualification', focus: 'column' }, 'eng-artefacts': { route: '/motion/engagements/POC', text: 'Artefacts', focus: 'column' }, }; 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()); /* The service answers with raw WAV bytes, not JSON. A JSON.parse here is the * kind of mistake that 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 shotDurations(shots, total) { const words = shots.map((shot) => shot.line.trim().split(/\s+/).length); const sum = words.reduce((a, b) => a + b, 0); const raw = words.map((n) => (n / sum) * total); /* Rounded to whole frames, with the remainder pushed onto the last shot so * the video and the narration end on the same frame rather than drifting. */ const frames = raw.map((seconds) => Math.max(FPS, Math.round(seconds * FPS))); const target = Math.round(total * FPS); frames[frames.length - 1] += target - frames.reduce((a, b) => a + b, 0); return frames; } const CHROME_HTML = (opts) => `
${opts.eyebrow}
${opts.caption}
PIG · Motion
primeintellectgrowth.com
`; const THEME = { dark: { bg: '#0a0a0b', fg: '#fafafa', rule: '#26262b', accent: '#22c55e' }, light: { bg: '#ffffff', fg: '#0a0a0b', rule: '#e6e6ea', accent: '#16a34a' }, }; 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((shot) => shot.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 = shotDurations(film.shots, duration); // 2 ---------------------------------------------------------- the capture 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 poc = engagements.engagements.find((e) => e.stage === 'poc') ?? engagements.engagements[0]; const playbook = templates.templates.find((t) => t.kind === 'playbook') ?? templates.templates[0]; const browser = await chromium.launch({ channel: 'chrome' }); const contexts = {}; for (const theme of ['dark', 'light']) { const context = await browser.newContext({ viewport: { width: SHOT_W, height: SHOT_H }, /* 3x, because a detail shot crops to about half the viewport and is then * scaled back up to 1080 wide; at 2x that is an upscale and it shows. */ deviceScaleFactor: 3, 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 chromePage = await browser.newPage({ viewport: { width: FRAME_W, height: FRAME_H } }); const segments = []; for (const [index, shot] of film.shots.entries()) { const anchor = ANCHORS[shot.anchor]; if (!anchor) throw new Error(`unknown anchor "${shot.anchor}" in ${film.slug}`); const route = anchor.route.replace('PLAYBOOK', playbook.id).replace('POC', poc.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 rendered = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); if (rendered !== shot.theme) throw new Error(`shot ${index} rendered ${rendered}, wanted ${shot.theme}`); if (anchor.text) { await page.evaluate((text) => { const node = [...document.querySelectorAll('h1,h2,h3')].find( (el) => el.textContent?.trim() === text, ); if (!node) throw new Error(`no heading "${text}"`); const card = node.closest('section,[class*="card"],div'); (card ?? node).scrollIntoView({ block: 'center' }); window.scrollBy(0, -60); }, anchor.text); } else { await page.evaluate(() => window.scrollTo(0, 0)); } await page.waitForTimeout(500); const appPng = join(work, `app-${index}.png`); const clip = anchor.focus === 'full' ? null : await page.evaluate( ({ text, shotW, shotH }) => { const column = document.querySelector('main'); if (!column) return null; const rect = column.getBoundingClientRect(); const width = Math.min(shotW, Math.max(560, rect.width)); const height = Math.min(shotH, width * (shotH / shotW)); const heading = text ? [...document.querySelectorAll('h1,h2,h3')].find((el) => el.textContent?.trim() === text) : null; const box = (heading?.closest('section,[class*="card"]') ?? heading)?.getBoundingClientRect(); const centreY = box ? box.top + box.height / 2 : height / 2; return { x: Math.round(Math.max(0, Math.min(shotW - width, rect.left))), y: Math.round(Math.max(0, Math.min(shotH - height, centreY - height / 2))), width: Math.round(width), height: Math.round(height), }; }, { text: anchor.text ?? null, shotW: SHOT_W, shotH: SHOT_H }, ); await page.screenshot(clip ? { path: appPng, clip } : { path: appPng }); await page.close(); const palette = THEME[shot.theme]; await chromePage.setContent( CHROME_HTML({ ...palette, font, eyebrow: index === 0 ? 'Prime Intellect Growth' : 'Motion', caption: shot.caption, progress: (index + 1) / film.shots.length, }), { waitUntil: 'load' }, ); await chromePage.waitForTimeout(250); const chromePng = join(work, `chrome-${index}.png`); await chromePage.screenshot({ path: chromePng, omitBackground: true }); // 3 ------------------------------------------------ one shot -> one clip const n = frames[index]; const zoom = shot.zoom === 'out' ? `'if(eq(on,0),1.14,max(1.0,zoom-${(0.14 / n).toFixed(6)}))'` : shot.zoom === 'hold' ? `'1.03'` : `'if(eq(on,0),1.0,min(1.12,zoom+${(0.12 / n).toFixed(6)}))'`; const segment = join(work, `seg-${index}.mp4`); await run('ffmpeg', [ '-v', 'error', '-y', '-loop', '1', '-framerate', String(FPS), '-t', String(n / FPS), '-i', appPng, '-i', chromePng, '-filter_complex', `[0:v]zoompan=z=${zoom}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=${n}:s=${APP_W}x${APP_H}:fps=${FPS},` + `pad=${FRAME_W}:${FRAME_H}:0:${APP_Y}:color=${palette.bg}[base];` + `[base][1:v]overlay=0:0:format=auto,format=yuv420p[v]`, '-map', '[v]', '-frames:v', String(n), /* CRF 22, not 18. The frame is a mostly-static UI with a slow zoom, so 18 * spent 3.7 Mbps on 32 seconds — a 15 MB download for a video whose whole * point is that somebody opens it on a phone between meetings. 22 holds * the product's 12px labels and lands under 6 MB. */ '-c:v', 'libx264', '-preset', 'slow', '-crf', '22', segment, ]); segments.push(segment); console.log(` shot ${index + 1}/${film.shots.length} ${shot.anchor} ${shot.theme} ${(n / FPS).toFixed(2)}s`); } 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', /* The bed sits far under the voice and fades at both ends. Loudness is * normalised so clips do not jump against each other on the Learn page. */ `[2:a]volume=0.085,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', '192k', '-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`); console.log(` ${named}`); console.log(` ${poster}`); await writeFile( join(work, 'result.json'), JSON.stringify( { slug: film.slug, title: film.title, summary: film.summary, hash, durationSeconds: Math.round(finalDuration), words, file: named, poster }, null, 2, ), ); } await main();