diff --git a/docs/learn-films/motion-the-repeatable-motion.json b/docs/learn-films/motion-the-repeatable-motion.json index fc89878..80d288e 100644 --- a/docs/learn-films/motion-the-repeatable-motion.json +++ b/docs/learn-films/motion-the-repeatable-motion.json @@ -1,63 +1,79 @@ { "slug": "motion-the-repeatable-motion", - "title": "Motion: The Repeatable Go-To-Market", - "summary": "A tour of Motion — the library of what we know how to do, bound to the stages of a real deal, getting better every time we run it.", + "title": "Motion: the part of a deal PIG never knew", + "summary": "PIG tracked the money on every deal and none of the practice — Motion is the library of what we know how to do, and it gets a version better every time a deal closes.", "shots": [ { - "anchor": "hero", + "route": "/motion", + "scrollTo": null, "theme": "dark", - "line": "Hey team, check out the motion feature on our platform,", - "caption": "Motion Has Shipped", - "zoom": "in" + "line": "Hey team, check out the motion feature on our platform, because the ledger was never the hard part.", + "caption": "The hard part", + "zoom": null }, { - "anchor": "stats", + "route": "/motion", + "scrollTo": null, "theme": "dark", - "line": "PIG knew the ledger, what we bought and what we sold. This is the practice.", - "caption": "What PIG Missed", - "zoom": "out" + "line": "PIG knew every deal we closed. It never knew how we closed it.", + "caption": "Numbers, not practice", + "zoom": { "on": "Promotions", "scale": 1.5 } }, { - "anchor": "library-list", + "route": "/motion", + "scrollTo": "The motion, stage by stage", "theme": "dark", - "line": "Motion is the library of what we know how to do. Twelve templates.", - "caption": "Current Versions Only", - "zoom": "in" + "line": "Eight stages, seven live engagements, thirteen templates waiting. Legal sits second, because paperwork gates the work.", + "caption": "Eight stages, legal second", + "zoom": null }, { - "anchor": "template-head", + "route": "/motion", + "scrollTo": "Library cover, by kind", "theme": "dark", - "line": "Each names its kind, its stage, its version and usage count.", - "caption": "One Template, Four Facts", - "zoom": "hold" + "line": "Nine kinds of conversation, not one of them empty. Nobody improvises this on a call.", + "caption": "Nine kinds, none empty", + "zoom": null }, { - "anchor": "template-use", + "route": "/motion/library", + "scrollTo": null, "theme": "light", - "line": "Use it: it instantiates into a live engagement as a working artefact.", - "caption": "From Library To Deal", - "zoom": "in" + "line": "Filter by kind, stage or visibility. Thirteen shared, nothing stranded in a private draft.", + "caption": "The library", + "zoom": null }, { - "anchor": "template-body", + "route": "/motion/library/PLAYBOOK", + "scrollTo": "The stages", "theme": "light", - "line": "A used template cannot be edited in place; live work must not shift underneath it.", - "caption": "Versions, Never Edits", - "zoom": "hold" + "line": "This one is the spine. Every stage names its artefact, its exit test, its failure mode.", + "caption": "The spine", + "zoom": null }, { - "anchor": "stage-rail", + "route": "/motion/library/PLAYBOOK", + "scrollTo": null, "theme": "light", - "line": "Bound to the eight pipeline stages every real deal walks through.", - "caption": "Eight Stages Covered", - "zoom": "out" + "line": "Used once, so PIG closes it to edits. You cut version two instead.", + "caption": "Closed to edits", + "zoom": { "on": "closed to edits", "scale": 1.6 } }, { - "anchor": "promotions", + "route": "/motion", + "scrollTo": "What got easier", "theme": "light", - "line": "The artefact that closed the deal returns as version two. The next deal starts ahead. thanks for watching!", - "caption": "The Loop Closes", - "zoom": "in" + "line": "Northwind's fleet expansion closed. Its case study came back as version two, carrying the numbers.", + "caption": "Promoted back", + "zoom": { "on": "recovery-action policy", "scale": 1.5 } + }, + { + "route": "/motion", + "scrollTo": null, + "theme": "light", + "line": "The library is not what we wrote. It is what we have done. Thanks for watching!", + "caption": "What we have done", + "zoom": null } ] -} \ No newline at end of file +} diff --git a/docs/learn-films/one-customer-end-to-end.json b/docs/learn-films/one-customer-end-to-end.json new file mode 100644 index 0000000..c3bfc49 --- /dev/null +++ b/docs/learn-films/one-customer-end-to-end.json @@ -0,0 +1,137 @@ +{ + "slug": "one-customer-end-to-end", + "title": "Halcyon Research, and a gate it might miss", + "summary": "One engagement in order: a 52.8% scope down, the artefacts that closed the gaps, and a score that rose 25.5 points while two dimensions fell.", + "shots": [ + { + "route": "/motion/engagements/HALCYON", + "scrollTo": "Where the work sits", + "theme": "dark", + "line": "Halcyon is three weeks into a paid pilot that might not survive the twenty-eighth of August.", + "caption": "A pilot under pressure", + "zoom": null + }, + { + "route": "/motion/engagements/HALCYON", + "scrollTo": "Qualification", + "theme": "dark", + "line": "It started worse. Fifty-two point eight percent, band scope down, scored on discovery day.", + "caption": "The first honest read", + "zoom": { + "on": "52.8%", + "scale": 2.2 + } + }, + { + "route": "/motion/engagements/HALCYON", + "scrollTo": "Qualification", + "theme": "dark", + "line": "The task repeats and notes trace. No baseline, no rubric, no budget line.", + "caption": "Nothing was measured", + "zoom": null + }, + { + "route": "/motion/engagements/HALCYON", + "scrollTo": "Artefacts", + "theme": "dark", + "line": "It closed them one at a time. Rights in legal, architecture in scoping.", + "caption": "Closed one stage at a time", + "zoom": null + }, + { + "route": "/motion/engagements/HALCYON", + "scrollTo": "Qualification", + "theme": "dark", + "line": "Rescore, fifty-seven point three. Only what scoping touched moved. The forcing function fell.", + "caption": "Rescored at 57.3%", + "zoom": null + }, + { + "route": "/motion/engagements/HALCYON", + "scrollTo": "Artefacts", + "theme": "light", + "line": "Then a budget path named the FY27 line and an October committee date.", + "caption": "A budget path appears", + "zoom": { + "on": "Halcyon Research: budget path to the 8 October committee", + "scale": 1.5 + } + }, + { + "route": "/motion/engagements/HALCYON", + "scrollTo": "Qualification", + "theme": "light", + "line": "Then measurement. Verifier at kappa point seven one, against a point seven three ceiling.", + "caption": "Measurement lands", + "zoom": null + }, + { + "route": "/motion/engagements/HALCYON", + "scrollTo": "Qualification", + "theme": "light", + "line": "Baseline nought point six four, environment up in week one. Eighty-two point eight percent, build.", + "caption": "82.8%, Build", + "zoom": null + }, + { + "route": "/motion/engagements/HALCYON", + "scrollTo": "Qualification", + "theme": "light", + "line": "Twenty-five and a half points in one step, and two dimensions fell inside.", + "caption": "Two things got worse", + "zoom": { + "on": "+25.5%", + "scale": 1.8 + } + }, + { + "route": "/motion/engagements/HALCYON", + "scrollTo": "Qualification", + "theme": "light", + "line": "Task definability, because Halcyon's adjudicators agree at kappa point five four on oncology.", + "caption": "Adjudicators disagree", + "zoom": { + "on": "kappa 0.54", + "scale": 1.9 + } + }, + { + "route": "/motion/engagements/HALCYON", + "scrollTo": "Qualification", + "theme": "light", + "line": "The sponsor fell too. Beaumont has missed two of three calls before the ask.", + "caption": "Sponsor going quiet", + "zoom": { + "on": "Beaumont has missed", + "scale": 1.9 + } + }, + { + "route": "/motion/engagements/HALCYON", + "scrollTo": "Qualification", + "theme": "light", + "line": "Week two read nought point six six, under a point six nine trajectory.", + "caption": "The gate is live", + "zoom": { + "on": "week-two dev split", + "scale": 1.9 + } + }, + { + "route": "/motion/engagements/HALCYON", + "scrollTo": null, + "theme": "light", + "line": "It might not make it, and the record said so before anyone asked.", + "caption": "The record said so first", + "zoom": null + }, + { + "route": "/motion/engagements/HALCYON", + "scrollTo": null, + "theme": "light", + "line": "Not spun, not chased down. Written down when it happened. Thanks for watching!", + "caption": "Written when it happened", + "zoom": null + } + ] +} \ No newline at end of file diff --git a/docs/learn-films/the-sales-motion.json b/docs/learn-films/the-sales-motion.json deleted file mode 100644 index 3c4ff77..0000000 --- a/docs/learn-films/the-sales-motion.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "slug": "the-sales-motion", - "title": "How a deal moves through Motion", - "summary": "The practitioner's path: open an engagement on a live deal, score it, pull the template for your stage, fill it with the customer's facts, and watch the score move both ways.", - "shots": [ - { - "anchor": "eng-list", - "theme": "dark", - "line": "Right, you've got a live deal that needs to move.", - "caption": "Seven Open, One Won", - "zoom": "in" - }, - { - "anchor": "eng-head", - "theme": "dark", - "line": "You open an engagement on the deal you're already working.", - "caption": "Bound To A Deal", - "zoom": "hold" - }, - { - "anchor": "eng-qualification", - "theme": "dark", - "line": "Score it against the trainability framework: twelve weighted dimensions, the band names an action.", - "caption": "Decline To Build", - "zoom": "in" - }, - { - "anchor": "library-list", - "theme": "dark", - "line": "Pick the template for the stage the deal is standing in.", - "caption": "Pick By Stage", - "zoom": "out" - }, - { - "anchor": "eng-stages", - "theme": "light", - "line": "Instantiate, and the artefact lands on the engagement under its stage.", - "caption": "It Lands In Place", - "zoom": "hold" - }, - { - "anchor": "eng-artefacts", - "theme": "light", - "line": "Fill it in with the customer's own numbers, draft to final.", - "caption": "Draft, Review, Final", - "zoom": "in" - }, - { - "anchor": "eng-qualification", - "theme": "light", - "line": "Rescore. Halcyon went fifty-two point eight to fifty-seven point three to eighty-two point eight.", - "caption": "Up Thirty Points", - "zoom": "in" - }, - { - "anchor": "eng-head", - "theme": "light", - "line": "Verifier measured, baseline measured, budget got a committee date.", - "caption": "What Actually Moved", - "zoom": "hold" - }, - { - "anchor": "eng-qualification", - "theme": "light", - "line": "Two dimensions fell there too, so it isn't a ratchet. thanks for watching!", - "caption": "Sponsor And Definability", - "zoom": "out" - } - ] -} \ No newline at end of file diff --git a/packages/db/src/seed/demo/learn.ts b/packages/db/src/seed/demo/learn.ts index d329e8e..0d52321 100644 --- a/packages/db/src/seed/demo/learn.ts +++ b/packages/db/src/seed/demo/learn.ts @@ -149,30 +149,35 @@ export const HOSTED_LEARN_MANIFEST: readonly HostedLearnEntry[] = [ sortOrder: 5, }, /* - * Six and seven are the first vertical (9:16) films in the set. The five - * above are landscape captures of the desktop app; these are cut for a phone, - * because the way this material actually gets watched is somebody opening the - * link on the way to a meeting. `scripts/learn-film.mjs` renders them and is - * the pipeline the first five never had. + * Six and seven are landscape, like the five above, and cut by + * `scripts/learn-film.mjs`. A vertical pair was tried first and rejected: + * the product is a desktop application, and reframing it 9:16 either shrinks + * it past reading or crops away the sidebar and half the table. The camera + * holds still and eases in on the figure being named, rather than drifting + * across every shot, which is what made the first cut tiring to watch. + * + * Seven is longer than the rest on purpose — it walks one customer from the + * first discovery to a kill gate that may not be met, and that is a story + * rather than a feature tour. */ { slug: 'motion-the-repeatable-motion', - title: 'Motion: the repeatable go-to-market', + title: 'Motion: the part of a deal PIG never knew', summary: - 'A tour of Motion — the library of what we know how to do, bound to the stages of a real deal, getting better every time we run it.', + 'PIG knew every deal we closed. It never knew how we closed it. The library of what we know how to do, bound to the stages of a real deal.', track: 'platform', visibility: 'code', - durationSeconds: 32, + durationSeconds: 47, sortOrder: 6, }, { - slug: 'the-sales-motion', - title: 'How a deal moves through Motion', + slug: 'one-customer-end-to-end', + title: 'Halcyon Research, and a gate it might miss', summary: - "The practitioner's path: open an engagement on a live deal, score it, pull the template for your stage, fill it with the customer's facts, and watch the score move both ways.", + 'One customer end to end: scored 52.8 and scope down on discovery day, 82.8 and build once measurement landed, and two dimensions that fell on the way up.', track: 'platform', visibility: 'code', - durationSeconds: 34, + durationSeconds: 68, sortOrder: 7, }, ]; diff --git a/scripts/learn-film.mjs b/scripts/learn-film.mjs index 0ef3cad..9c51846 100644 --- a/scripts/learn-film.mjs +++ b/scripts/learn-film.mjs @@ -1,28 +1,35 @@ /* - * Build a vertical /learn video from a script file. + * Build a /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. + * 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 — which is how - * the previous set was found to be wrong. + * narrator talking over a frozen frame, or gets cut mid-sentence. * - * 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. + * 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. * - * Requires: a running API + web (see PIG_WEB_URL), Chatterbox reachable, ffmpeg. + * 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'; @@ -39,60 +46,28 @@ 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. */ +/* 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 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 W = 1920; +const H = 1080; +const SCALE = 2; 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' }, +/* 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, - ]); + const { stdout } = await run('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path]); return Number(stdout.trim()); } @@ -104,8 +79,7 @@ async function narrate(text, out) { }); 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. */ + /* 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()}`); } @@ -114,46 +88,51 @@ async function narrate(text, out) { } /** Shot lengths are proportional to the words spoken over them. */ -function shotDurations(shots, total) { +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 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 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; } -const CHROME_HTML = (opts) => ` -
${opts.eyebrow}
${opts.caption}
-
-
-
PIG · Motion
primeintellectgrowth.com
+
${o.caption}
`; -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 '); @@ -167,8 +146,8 @@ async function main() { '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(' '); + // 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); @@ -176,23 +155,38 @@ async function main() { 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); - const frames = shotDurations(film.shots, duration); - - // 2 ---------------------------------------------------------- the capture + // 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 poc = engagements.engagements.find((e) => e.stage === 'poc') ?? engagements.engagements[0]; - const playbook = templates.templates.find((t) => t.kind === 'playbook') ?? templates.templates[0]; + 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({ - 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, + /* + * 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(` @@ -208,106 +202,137 @@ async function main() { }); contexts[theme] = context; } - const chromePage = await browser.newPage({ viewport: { width: FRAME_W, height: FRAME_H } }); + const captionPage = await browser.newPage({ viewport: { width: W, height: 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); + 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 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 painted = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); + if (painted !== shot.theme) throw new Error(`shot ${index} painted ${painted}, wanted ${shot.theme}`); - 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), + 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; }; - }, - { text: anchor.text ?? null, shotW: SHOT_W, shotH: SHOT_H }, - ); - await page.screenshot(clip ? { path: appPng, clip } : { path: appPng }); + 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 chromePage.setContent( - CHROME_HTML({ - ...palette, - font, - eyebrow: index === 0 ? 'Prime Intellect Growth' : 'Motion', - caption: shot.caption, - progress: (index + 1) / film.shots.length, - }), + await captionPage.setContent( + CAPTION_HTML({ ...palette, font, 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 }); + await captionPage.waitForTimeout(250); + const captionPng = join(work, `caption-${index}.png`); + await captionPage.screenshot({ path: captionPng, omitBackground: true }); - // 3 ------------------------------------------------ one shot -> one clip + // 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 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', 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]`, + '-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 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, + /* 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(` shot ${index + 1}/${film.shots.length} ${shot.anchor} ${shot.theme} ${(n / FPS).toFixed(2)}s`); + 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 + // 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'); @@ -316,19 +341,14 @@ async function main() { const out = join(work, 'film.mp4'); await run('ffmpeg', [ '-v', 'error', '-y', - '-i', silent, - '-i', voice, - '-stream_loop', '-1', '-i', MUSIC, + '-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];` + + `[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', '192k', - '-movflags', '+faststart', '-shortest', out, + '-c:v', 'copy', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', '-shortest', out, ]); const finalDuration = await ffprobeDuration(out); @@ -336,21 +356,10 @@ async function main() { 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, - ]); + 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, - ), - ); + console.log(`\n ${film.slug}: ${finalDuration.toFixed(2)}s, ${words} words, ${W}x${H}`); + console.log(` ${named}\n ${poster}`); } await main();