Cut two vertical Motion films, and write down the pipeline that makes them
THE PIPELINE. The five existing 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 — which is the same failure `scripts/screenshots.mjs` was written to stop. `scripts/learn-film.mjs` is that pipeline, and it reuses both of the screenshot script's hard-won lessons: the appearance preference is stored server-side and adopted after hydration, so the theme has to be forced by rewriting the profile response rather than by seeding localStorage; and per-device layout state has to be pinned 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, because a shot list timed by guess leaves the narrator talking over a frozen frame. Shot durations are then proportional to the words spoken over them, so the cut lands on the sentence. Typography is composed in the browser rather than in ffmpeg. The product's face is Manrope Variable and drawtext would have fallen back to DejaVu, which reads as a different company. Playwright renders a transparent chrome layer per shot and ffmpeg only moves pixels. Detail shots crop to the content column rather than the whole viewport. The first attempt cropped a box around the element being talked about, which 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 from the aspect. CRF is 22, not 18. The frame is a static UI with a slow zoom, and 18 spent 3.7 Mbps — a 15 MB download for a video whose whole point is that somebody opens it on a phone between meetings. THE STAGE RAIL BUG, which the films found. Every stage label was losing its last letters — "QUALIFICATIO", "PROCUREMEN" — with no ellipsis to show for it. Three attempts to fix that by widening the card did nothing, because the card was never the thing being measured: the LI holding it had `min-w-0` and no `shrink-0`, so it took its flex share of 88px while the card inside stayed 160, and every card was overpainted by the next one. The DOM reported no overflow the whole time, because there wasn't any — the clipping was one level up. `shrink-0` moves to the LI, where it belongs, and the rail scrolls as it was always meant to. The labels also wrap rather than truncate now: they are a fixed vocabulary of eight words we control, and losing a letter is worse than taking a line. THE FILMS. Two 9:16 clips for the team, narrated in Karti's cloned voice through Chatterbox at 1.20x and scored with the platform's own ambient bed, ducked and loudness-normalised so they do not jump against the five already on the page. Both open dark and switch to light at the midpoint. Shot lists live beside the narration in `docs/learn-films/`, because a script and its shot list timed against each other are one object and splitting them is how they drift.
This commit is contained in:
@@ -24,3 +24,7 @@ backups/
|
||||
# deployment mounts from the host — a release artefact, not source.
|
||||
media/
|
||||
/media
|
||||
|
||||
# Render working directory for scripts/learn-film.mjs. The finished mp4 and its
|
||||
# poster are copied into the media directory, which is itself untracked.
|
||||
.film/
|
||||
|
||||
@@ -56,7 +56,17 @@ export function StageRail({
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<span className="truncate text-xs font-medium uppercase tracking-wide text-muted">
|
||||
{/*
|
||||
* Wraps rather than truncates. The stage names are a fixed
|
||||
* vocabulary of eight words we control, and truncation was
|
||||
* silently rendering "QUALIFICATIO" and "PROCUREMEN" — losing a
|
||||
* letter is worse than taking a second line. Two attempts to fix
|
||||
* this by widening the card (8.5rem, then 10, then 11) each
|
||||
* looked right in a DOM measurement and still clipped on the
|
||||
* rendered frame, which is the argument for not depending on a
|
||||
* magic width at all.
|
||||
*/}
|
||||
<span className="text-xs font-medium uppercase leading-tight tracking-wide text-muted">
|
||||
{DEMAND_STAGE_LABELS[stage]}
|
||||
</span>
|
||||
<span className={cn('nums text-2xl font-semibold leading-none', count === 0 && 'text-muted')}>
|
||||
@@ -74,12 +84,22 @@ export function StageRail({
|
||||
);
|
||||
|
||||
const shape = cn(
|
||||
'tap flex w-[8.5rem] shrink-0 flex-col justify-between gap-2 rounded-xl border p-3 text-left',
|
||||
// 10rem, not 8.5, so the common labels sit on one line; the two
|
||||
// longest wrap to two rather than losing a letter.
|
||||
'tap flex w-40 shrink-0 flex-col justify-between gap-2 rounded-xl border p-3 text-left',
|
||||
active ? 'border-brand bg-surface-2' : 'border-border bg-surface',
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={stage} className="flex min-w-0">
|
||||
/*
|
||||
* `shrink-0` belongs on the LI, not only on the card inside it.
|
||||
* Without it the list item shrank to its flex share — 88px against
|
||||
* the card's 160 — and every card was overpainted by the next one,
|
||||
* so the labels lost their last letters with no ellipsis to show
|
||||
* for it. Three attempts to fix that by widening the card did
|
||||
* nothing, because the card was never the thing being measured.
|
||||
*/
|
||||
<li key={stage} className="flex shrink-0">
|
||||
{onSelect ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"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.",
|
||||
"shots": [
|
||||
{
|
||||
"anchor": "hero",
|
||||
"theme": "dark",
|
||||
"line": "Hey team, check out the motion feature on our platform,",
|
||||
"caption": "Motion Has Shipped",
|
||||
"zoom": "in"
|
||||
},
|
||||
{
|
||||
"anchor": "stats",
|
||||
"theme": "dark",
|
||||
"line": "PIG knew the ledger, what we bought and what we sold. This is the practice.",
|
||||
"caption": "What PIG Missed",
|
||||
"zoom": "out"
|
||||
},
|
||||
{
|
||||
"anchor": "library-list",
|
||||
"theme": "dark",
|
||||
"line": "Motion is the library of what we know how to do. Twelve templates.",
|
||||
"caption": "Current Versions Only",
|
||||
"zoom": "in"
|
||||
},
|
||||
{
|
||||
"anchor": "template-head",
|
||||
"theme": "dark",
|
||||
"line": "Each names its kind, its stage, its version and usage count.",
|
||||
"caption": "One Template, Four Facts",
|
||||
"zoom": "hold"
|
||||
},
|
||||
{
|
||||
"anchor": "template-use",
|
||||
"theme": "light",
|
||||
"line": "Use it: it instantiates into a live engagement as a working artefact.",
|
||||
"caption": "From Library To Deal",
|
||||
"zoom": "in"
|
||||
},
|
||||
{
|
||||
"anchor": "template-body",
|
||||
"theme": "light",
|
||||
"line": "A used template cannot be edited in place; live work must not shift underneath it.",
|
||||
"caption": "Versions, Never Edits",
|
||||
"zoom": "hold"
|
||||
},
|
||||
{
|
||||
"anchor": "stage-rail",
|
||||
"theme": "light",
|
||||
"line": "Bound to the eight pipeline stages every real deal walks through.",
|
||||
"caption": "Eight Stages Covered",
|
||||
"zoom": "out"
|
||||
},
|
||||
{
|
||||
"anchor": "promotions",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -239,6 +239,33 @@ export const HOSTED_LEARN_MANIFEST: readonly HostedLearnEntry[] = [
|
||||
durationSeconds: 28,
|
||||
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.
|
||||
*/
|
||||
{
|
||||
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.',
|
||||
track: 'platform',
|
||||
visibility: 'code',
|
||||
durationSeconds: 32,
|
||||
sortOrder: 6,
|
||||
},
|
||||
{
|
||||
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.",
|
||||
track: 'platform',
|
||||
visibility: 'code',
|
||||
durationSeconds: 34,
|
||||
sortOrder: 7,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* Build a vertical /learn video from a script file.
|
||||
*
|
||||
* node scripts/learn-film.mjs docs/learn-films/<slug>.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) => `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
@font-face{font-family:'Manrope';src:url('file://${opts.font}') format('woff2');font-weight:200 800;font-display:block}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{width:${FRAME_W}px;height:${FRAME_H}px;background:transparent}
|
||||
body{font-family:'Manrope',system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
||||
.band{position:absolute;left:0;right:0;background:${opts.bg}}
|
||||
.top{top:0;height:${APP_Y}px;display:flex;flex-direction:column;justify-content:center;padding:0 64px;gap:14px}
|
||||
.bottom{top:${APP_Y + APP_H}px;height:${FRAME_H - APP_Y - APP_H}px;display:flex;align-items:center;justify-content:space-between;padding:0 64px}
|
||||
.eyebrow{font-size:26px;font-weight:700;letter-spacing:.22em;text-transform:uppercase;color:${opts.accent}}
|
||||
.caption{font-size:58px;font-weight:700;letter-spacing:-.02em;line-height:1.08;color:${opts.fg}}
|
||||
.mark{display:flex;align-items:center;gap:14px;font-size:28px;font-weight:700;color:${opts.fg};opacity:.85}
|
||||
.dot{width:12px;height:12px;border-radius:50%;background:${opts.accent}}
|
||||
.track{position:absolute;left:64px;right:64px;top:${FRAME_H - 26}px;height:6px;border-radius:3px;background:${opts.rule}}
|
||||
.fill{height:6px;border-radius:3px;background:${opts.accent};width:${Math.round(opts.progress * 100)}%}
|
||||
.hair{position:absolute;left:0;right:0;height:1px;background:${opts.rule}}
|
||||
</style></head><body>
|
||||
<div class="band top"><div class="eyebrow">${opts.eyebrow}</div><div class="caption">${opts.caption}</div></div>
|
||||
<div class="hair" style="top:${APP_Y - 1}px"></div>
|
||||
<div class="hair" style="top:${APP_Y + APP_H}px"></div>
|
||||
<div class="band bottom"><div class="mark"><span class="dot"></span>PIG · Motion</div><div class="mark" style="opacity:.5">primeintellectgrowth.com</div></div>
|
||||
<div class="track"><div class="fill"></div></div>
|
||||
</body></html>`;
|
||||
|
||||
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 <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((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();
|
||||
Reference in New Issue
Block a user