Files
pig/scripts/learn-film.mjs
karti 8a6601dfce
CI / verify (push) Successful in 7m14s
CI / publish (push) Has been skipped
Re-cut the Motion films in landscape, and hold the camera still
THE FORMAT. The first pair were vertical. That was wrong for this product: PIG
is a desktop application, and reframing it 9:16 either shrinks it past reading
or crops away the sidebar and half the table. These are 1920x1080, like the five
already on the page, and the app is filmed at a real 1920 desktop viewport —
the previous renderer set the viewport to 960 and captured at 2x, which produced
a 1920px image of the COLLAPSED layout, because the viewport is what the product
responds to and the scale factor is only how much detail there is to zoom into.

THE CAMERA. It now holds still, and eases in on the thing the narration is
naming, holds, and eases back out — the way Cap zooms toward a click. The first
cut drifted a slow zoom across every shot, which reads as restless rather than
as emphasis and is the thing people mean when they call a screen recording
annoying. Six of fourteen shots move in the customer film; the rest are locked
off. The curve is a smoothstep either side of a hold, written as a function of
the frame number rather than accumulated through zoompan's own `zoom` variable,
which refers to the previous frame and drifts off its endpoints.

Zoom targets are chosen as the SMALLEST element already in frame, not the last
match in document order. That was wrong in a way only a long page shows: the
Halcyon engagement prints the same phrases twice — once in the score note and
again inside the artefact bodies below — so "kappa 0.54" resolved to a paragraph
two and a half thousand pixels below the fold that no amount of scrolling would
reveal, because an ancestor clips it. Preferring what is on screen keeps the
zoom inside the frame the shot chose; preferring the smallest match makes naming
a figure land on the figure rather than on the card around it.

THE SCRIPTS were rewritten from nothing through a judge panel — three
independent angles each, scored on hook, concreteness, ear, tension and ending,
then synthesised and adversarially checked against the running product.

The second film is the one that was asked for: one customer, end to end. Halcyon
Research, scored 52.8 and scope down on the day of first discovery because
nothing had been measured, through legal and scoping and a budget path, to 82.8
and build once the verifier, the baseline and the environment landed — and the
two dimensions that fell inside that same twenty-five point rise. It closes on
the week-two dev split reading under its trajectory, because the honest thing
about that record is that the gate might not be met and it says so before anyone
asks. It runs 68 seconds; a feature tour is 30 and a story is not.
2026-08-19 02:59:29 -07:00

366 lines
17 KiB
JavaScript

/*
* Build a /learn video from a script file.
*
* node scripts/learn-film.mjs docs/learn-films/<slug>.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) => `<!doctype html><html><head><meta charset="utf-8"><style>
@font-face{font-family:'Manrope';src:url('file://${o.font}') format('woff2');font-weight:200 800;font-display:block}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:${W}px;height:${H}px;background:transparent}
body{font-family:'Manrope',system-ui,sans-serif;-webkit-font-smoothing:antialiased}
.pill{position:absolute;left:56px;bottom:66px;display:flex;align-items:center;gap:16px;
padding:16px 26px;border-radius:999px;background:${o.pill};border:1px solid ${o.rule};
backdrop-filter:blur(12px);box-shadow:0 10px 40px rgba(0,0,0,.28)}
.dot{width:11px;height:11px;border-radius:50%;background:${o.accent};flex:none}
.text{font-size:29px;font-weight:600;letter-spacing:-.01em;color:${o.fg};white-space:nowrap}
.track{position:absolute;left:0;right:0;bottom:0;height:5px;background:${o.rule}}
.fill{height:5px;background:${o.accent};width:${(o.progress * 100).toFixed(2)}%}
</style></head><body>
<div class="pill"><span class="dot"></span><span class="text">${o.caption}</span></div>
<div class="track"><div class="fill"></div></div>
</body></html>`;
async function main() {
const scriptPath = process.argv[2];
if (!scriptPath) throw new Error('usage: learn-film.mjs <script.json>');
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();