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.
This commit is contained in:
@@ -1,63 +1,79 @@
|
|||||||
{
|
{
|
||||||
"slug": "motion-the-repeatable-motion",
|
"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.",
|
"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": [
|
"shots": [
|
||||||
{
|
{
|
||||||
"anchor": "hero",
|
"route": "/motion",
|
||||||
|
"scrollTo": null,
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"line": "Hey team, check out the motion feature on our platform,",
|
"line": "Hey team, check out the motion feature on our platform, because the ledger was never the hard part.",
|
||||||
"caption": "Motion Has Shipped",
|
"caption": "The hard part",
|
||||||
"zoom": "in"
|
"zoom": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"anchor": "stats",
|
"route": "/motion",
|
||||||
|
"scrollTo": null,
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"line": "PIG knew the ledger, what we bought and what we sold. This is the practice.",
|
"line": "PIG knew every deal we closed. It never knew how we closed it.",
|
||||||
"caption": "What PIG Missed",
|
"caption": "Numbers, not practice",
|
||||||
"zoom": "out"
|
"zoom": { "on": "Promotions", "scale": 1.5 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"anchor": "library-list",
|
"route": "/motion",
|
||||||
|
"scrollTo": "The motion, stage by stage",
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"line": "Motion is the library of what we know how to do. Twelve templates.",
|
"line": "Eight stages, seven live engagements, thirteen templates waiting. Legal sits second, because paperwork gates the work.",
|
||||||
"caption": "Current Versions Only",
|
"caption": "Eight stages, legal second",
|
||||||
"zoom": "in"
|
"zoom": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"anchor": "template-head",
|
"route": "/motion",
|
||||||
|
"scrollTo": "Library cover, by kind",
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"line": "Each names its kind, its stage, its version and usage count.",
|
"line": "Nine kinds of conversation, not one of them empty. Nobody improvises this on a call.",
|
||||||
"caption": "One Template, Four Facts",
|
"caption": "Nine kinds, none empty",
|
||||||
"zoom": "hold"
|
"zoom": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"anchor": "template-use",
|
"route": "/motion/library",
|
||||||
|
"scrollTo": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"line": "Use it: it instantiates into a live engagement as a working artefact.",
|
"line": "Filter by kind, stage or visibility. Thirteen shared, nothing stranded in a private draft.",
|
||||||
"caption": "From Library To Deal",
|
"caption": "The library",
|
||||||
"zoom": "in"
|
"zoom": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"anchor": "template-body",
|
"route": "/motion/library/PLAYBOOK",
|
||||||
|
"scrollTo": "The stages",
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"line": "A used template cannot be edited in place; live work must not shift underneath it.",
|
"line": "This one is the spine. Every stage names its artefact, its exit test, its failure mode.",
|
||||||
"caption": "Versions, Never Edits",
|
"caption": "The spine",
|
||||||
"zoom": "hold"
|
"zoom": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"anchor": "stage-rail",
|
"route": "/motion/library/PLAYBOOK",
|
||||||
|
"scrollTo": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"line": "Bound to the eight pipeline stages every real deal walks through.",
|
"line": "Used once, so PIG closes it to edits. You cut version two instead.",
|
||||||
"caption": "Eight Stages Covered",
|
"caption": "Closed to edits",
|
||||||
"zoom": "out"
|
"zoom": { "on": "closed to edits", "scale": 1.6 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"anchor": "promotions",
|
"route": "/motion",
|
||||||
|
"scrollTo": "What got easier",
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"line": "The artefact that closed the deal returns as version two. The next deal starts ahead. thanks for watching!",
|
"line": "Northwind's fleet expansion closed. Its case study came back as version two, carrying the numbers.",
|
||||||
"caption": "The Loop Closes",
|
"caption": "Promoted back",
|
||||||
"zoom": "in"
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -149,30 +149,35 @@ export const HOSTED_LEARN_MANIFEST: readonly HostedLearnEntry[] = [
|
|||||||
sortOrder: 5,
|
sortOrder: 5,
|
||||||
},
|
},
|
||||||
/*
|
/*
|
||||||
* Six and seven are the first vertical (9:16) films in the set. The five
|
* Six and seven are landscape, like the five above, and cut by
|
||||||
* above are landscape captures of the desktop app; these are cut for a phone,
|
* `scripts/learn-film.mjs`. A vertical pair was tried first and rejected:
|
||||||
* because the way this material actually gets watched is somebody opening the
|
* the product is a desktop application, and reframing it 9:16 either shrinks
|
||||||
* link on the way to a meeting. `scripts/learn-film.mjs` renders them and is
|
* it past reading or crops away the sidebar and half the table. The camera
|
||||||
* the pipeline the first five never had.
|
* 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',
|
slug: 'motion-the-repeatable-motion',
|
||||||
title: 'Motion: the repeatable go-to-market',
|
title: 'Motion: the part of a deal PIG never knew',
|
||||||
summary:
|
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',
|
track: 'platform',
|
||||||
visibility: 'code',
|
visibility: 'code',
|
||||||
durationSeconds: 32,
|
durationSeconds: 47,
|
||||||
sortOrder: 6,
|
sortOrder: 6,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'the-sales-motion',
|
slug: 'one-customer-end-to-end',
|
||||||
title: 'How a deal moves through Motion',
|
title: 'Halcyon Research, and a gate it might miss',
|
||||||
summary:
|
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',
|
track: 'platform',
|
||||||
visibility: 'code',
|
visibility: 'code',
|
||||||
durationSeconds: 34,
|
durationSeconds: 68,
|
||||||
sortOrder: 7,
|
sortOrder: 7,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+206
-197
@@ -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/<slug>.json
|
* node scripts/learn-film.mjs docs/learn-films/<slug>.json
|
||||||
*
|
*
|
||||||
* The five existing platform videos were cut by an ad-hoc pipeline that was
|
* The five original platform videos were cut by an ad-hoc process that was
|
||||||
* never committed, so the first time the UI changed nobody could re-shoot them.
|
* never committed, so the first time the UI moved nobody could re-shoot them.
|
||||||
* This is that pipeline, written down. `scripts/screenshots.mjs` exists for the
|
* This is that pipeline, written down. It reuses the two hard-won lessons from
|
||||||
* same reason and this file deliberately reuses its two hard-won lessons: the
|
* `scripts/screenshots.mjs`: the appearance preference is stored SERVER-side
|
||||||
* appearance preference is stored SERVER-side and adopted after hydration, so
|
* and adopted after hydration, so seeding localStorage is not enough and the
|
||||||
* seeding localStorage is not enough and the profile response has to be
|
* profile response has to be rewritten; and per-device layout state
|
||||||
* rewritten; and per-device layout state (`pig.sidebarOpen`, `pig.piggyDockOpen`)
|
* (`pig.sidebarOpen`, `pig.piggyDockOpen`) has to be forced or the framing is
|
||||||
* has to be forced or the framing is whatever a human last left behind.
|
* whatever a human last left behind.
|
||||||
*
|
*
|
||||||
* ORDER IS LOAD-BEARING. The narration is rendered FIRST and its measured
|
* 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
|
* 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
|
* narrator talking over a frozen frame, or gets cut mid-sentence.
|
||||||
* the previous set was found to be wrong.
|
|
||||||
*
|
*
|
||||||
* Typography is composed in the browser, not in ffmpeg: the product's typeface
|
* THE CAMERA. Landscape, and still by default. An earlier version drifted a
|
||||||
* is Manrope Variable and ffmpeg's drawtext would have to fall back to DejaVu,
|
* slow zoom across every shot, which reads as restless rather than as emphasis
|
||||||
* which is visibly not the same product. So Playwright renders a transparent
|
* — the whole frame moving all the time is the thing people mean when they call
|
||||||
* chrome layer per shot and ffmpeg only moves pixels.
|
* 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 { chromium } from 'playwright';
|
||||||
import { execFile } from 'node:child_process';
|
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 WEB = process.env.PIG_WEB_URL ?? 'http://localhost:5179';
|
||||||
const TTS = process.env.PIG_TTS_URL ?? 'http://100.127.247.67:8095';
|
const TTS = process.env.PIG_TTS_URL ?? 'http://100.127.247.67:8095';
|
||||||
const VOICE = process.env.PIG_TTS_VOICE ?? 'karti';
|
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 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 MUSIC = process.env.PIG_FILM_MUSIC ?? join(REPO, 'apps/web/public/audio/pig-ambient.mp3');
|
||||||
|
|
||||||
const FRAME_W = 1080;
|
const W = 1920;
|
||||||
const FRAME_H = 1920;
|
const H = 1080;
|
||||||
/* The app is captured at 1120 wide and lands at 1080, so it is very nearly 1:1
|
const SCALE = 2;
|
||||||
* 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;
|
const FPS = 30;
|
||||||
|
|
||||||
/** Every anchor a script may name, and how to frame it. */
|
/* The zoom envelope, as fractions of the shot: hold, ease in, hold, ease out. */
|
||||||
/*
|
const ZOOM_IN_START = 0.16;
|
||||||
* `focus` is `full` (the whole viewport) or `column` (the content column only).
|
const ZOOM_IN_END = 0.42;
|
||||||
*
|
const ZOOM_OUT_START = 0.76;
|
||||||
* A vertical video is watched on a phone, and the whole 1120px viewport scaled
|
const ZOOM_OUT_END = 0.98;
|
||||||
* 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
|
const THEME = {
|
||||||
* sidebar and keeps the content column, which is about 1.4x closer.
|
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' },
|
||||||
* 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) {
|
async function ffprobeDuration(path) {
|
||||||
const { stdout } = await run('ffprobe', [
|
const { stdout } = await run('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path]);
|
||||||
'-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path,
|
|
||||||
]);
|
|
||||||
return Number(stdout.trim());
|
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()}`);
|
if (!response.ok) throw new Error(`Chatterbox ${response.status}: ${await response.text()}`);
|
||||||
const wav = Buffer.from(await response.arrayBuffer());
|
const wav = Buffer.from(await response.arrayBuffer());
|
||||||
/* The service answers with raw WAV bytes, not JSON. A JSON.parse here is the
|
/* Raw WAV bytes, not JSON. A JSON.parse here reads as a TTS outage. */
|
||||||
* kind of mistake that reads as a TTS outage. */
|
|
||||||
if (wav.subarray(0, 4).toString() !== 'RIFF') {
|
if (wav.subarray(0, 4).toString() !== 'RIFF') {
|
||||||
throw new Error(`Chatterbox did not return WAV: ${wav.subarray(0, 120).toString()}`);
|
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. */
|
/** 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 words = shots.map((shot) => shot.line.trim().split(/\s+/).length);
|
||||||
const sum = words.reduce((a, b) => a + b, 0);
|
const sum = words.reduce((a, b) => a + b, 0);
|
||||||
const raw = words.map((n) => (n / sum) * total);
|
const frames = words.map((n) => Math.max(FPS, Math.round(((n / sum) * total) * FPS)));
|
||||||
/* 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);
|
const target = Math.round(total * FPS);
|
||||||
frames[frames.length - 1] += target - frames.reduce((a, b) => a + b, 0);
|
frames[frames.length - 1] += target - frames.reduce((a, b) => a + b, 0);
|
||||||
return frames;
|
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}
|
* 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}
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
html,body{width:${FRAME_W}px;height:${FRAME_H}px;background:transparent}
|
html,body{width:${W}px;height:${H}px;background:transparent}
|
||||||
body{font-family:'Manrope',system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
body{font-family:'Manrope',system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
||||||
.band{position:absolute;left:0;right:0;background:${opts.bg}}
|
.pill{position:absolute;left:56px;bottom:66px;display:flex;align-items:center;gap:16px;
|
||||||
.top{top:0;height:${APP_Y}px;display:flex;flex-direction:column;justify-content:center;padding:0 64px;gap:14px}
|
padding:16px 26px;border-radius:999px;background:${o.pill};border:1px solid ${o.rule};
|
||||||
.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}
|
backdrop-filter:blur(12px);box-shadow:0 10px 40px rgba(0,0,0,.28)}
|
||||||
.eyebrow{font-size:26px;font-weight:700;letter-spacing:.22em;text-transform:uppercase;color:${opts.accent}}
|
.dot{width:11px;height:11px;border-radius:50%;background:${o.accent};flex:none}
|
||||||
.caption{font-size:58px;font-weight:700;letter-spacing:-.02em;line-height:1.08;color:${opts.fg}}
|
.text{font-size:29px;font-weight:600;letter-spacing:-.01em;color:${o.fg};white-space:nowrap}
|
||||||
.mark{display:flex;align-items:center;gap:14px;font-size:28px;font-weight:700;color:${opts.fg};opacity:.85}
|
.track{position:absolute;left:0;right:0;bottom:0;height:5px;background:${o.rule}}
|
||||||
.dot{width:12px;height:12px;border-radius:50%;background:${opts.accent}}
|
.fill{height:5px;background:${o.accent};width:${(o.progress * 100).toFixed(2)}%}
|
||||||
.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>
|
</style></head><body>
|
||||||
<div class="band top"><div class="eyebrow">${opts.eyebrow}</div><div class="caption">${opts.caption}</div></div>
|
<div class="pill"><span class="dot"></span><span class="text">${o.caption}</span></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>
|
<div class="track"><div class="fill"></div></div>
|
||||||
</body></html>`;
|
</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() {
|
async function main() {
|
||||||
const scriptPath = process.argv[2];
|
const scriptPath = process.argv[2];
|
||||||
if (!scriptPath) throw new Error('usage: learn-film.mjs <script.json>');
|
if (!scriptPath) throw new Error('usage: learn-film.mjs <script.json>');
|
||||||
@@ -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',
|
'node_modules/.pnpm/@fontsource-variable+manrope@5.3.0/node_modules/@fontsource-variable/manrope/files/manrope-latin-wght-normal.woff2',
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1 ------------------------------------------------------- the narration
|
// 1 -------------------------------------------------------- the narration
|
||||||
const narration = film.shots.map((shot) => shot.line.trim()).join(' ');
|
const narration = film.shots.map((s) => s.line.trim()).join(' ');
|
||||||
const words = narration.split(/\s+/).length;
|
const words = narration.split(/\s+/).length;
|
||||||
const rawVoice = join(work, 'voice-raw.wav');
|
const rawVoice = join(work, 'voice-raw.wav');
|
||||||
const spoken = await narrate(narration, rawVoice);
|
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]);
|
await run('ffmpeg', ['-v', 'error', '-y', '-i', rawVoice, '-filter:a', `atempo=${TEMPO}`, voice]);
|
||||||
const duration = await ffprobeDuration(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`);
|
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 routes
|
||||||
|
|
||||||
// 2 ---------------------------------------------------------- the capture
|
|
||||||
const engagements = await fetch(`${WEB}/api/motion/engagements`).then((r) => r.json());
|
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 templates = await fetch(`${WEB}/api/motion/templates`).then((r) => r.json());
|
||||||
const poc = engagements.engagements.find((e) => e.stage === 'poc') ?? engagements.engagements[0];
|
const byStage = (stage) => engagements.engagements.find((e) => e.stage === stage);
|
||||||
const playbook = templates.templates.find((t) => t.kind === 'playbook') ?? templates.templates[0];
|
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 browser = await chromium.launch({ channel: 'chrome' });
|
||||||
const contexts = {};
|
const contexts = {};
|
||||||
for (const theme of ['dark', 'light']) {
|
for (const theme of ['dark', 'light']) {
|
||||||
const context = await browser.newContext({
|
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
|
* A real 1920 desktop viewport, captured at 2x and delivered at 1920.
|
||||||
* scaled back up to 1080 wide; at 2x that is an upscale and it shows. */
|
* Setting the viewport to W/SCALE was wrong in a way that looked right:
|
||||||
deviceScaleFactor: 3,
|
* 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,
|
colorScheme: theme,
|
||||||
});
|
});
|
||||||
await context.addInitScript(`
|
await context.addInitScript(`
|
||||||
@@ -208,106 +202,137 @@ async function main() {
|
|||||||
});
|
});
|
||||||
contexts[theme] = context;
|
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 = [];
|
const segments = [];
|
||||||
for (const [index, shot] of film.shots.entries()) {
|
for (const [index, shot] of film.shots.entries()) {
|
||||||
const anchor = ANCHORS[shot.anchor];
|
let route = shot.route;
|
||||||
if (!anchor) throw new Error(`unknown anchor "${shot.anchor}" in ${film.slug}`);
|
for (const [name, id] of Object.entries(alias)) route = route.replace(name, id);
|
||||||
const route = anchor.route.replace('PLAYBOOK', playbook.id).replace('POC', poc.id);
|
|
||||||
|
|
||||||
const page = await contexts[shot.theme].newPage();
|
const page = await contexts[shot.theme].newPage();
|
||||||
await page.goto(`${WEB}${route}`, { waitUntil: 'networkidle' });
|
await page.goto(`${WEB}${route}`, { waitUntil: 'networkidle' });
|
||||||
/* Lazy route chunks sit behind a Suspense boundary; a screenshot taken
|
/* Lazy route chunks sit behind a Suspense boundary; a screenshot taken
|
||||||
* before the fallback resolves photographs the spinner. */
|
* before the fallback resolves photographs the spinner. */
|
||||||
await page.waitForTimeout(1400);
|
await page.waitForTimeout(1400);
|
||||||
const rendered = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
const painted = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
if (rendered !== shot.theme) throw new Error(`shot ${index} rendered ${rendered}, wanted ${shot.theme}`);
|
if (painted !== shot.theme) throw new Error(`shot ${index} painted ${painted}, 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`);
|
if (shot.scrollTo) {
|
||||||
const clip = anchor.focus === 'full' ? null : await page.evaluate(
|
const ok = await page.evaluate((text) => {
|
||||||
({ text, shotW, shotH }) => {
|
const node = [...document.querySelectorAll('h1,h2,h3')].find((el) => el.textContent?.trim() === text);
|
||||||
const column = document.querySelector('main');
|
if (!node) return false;
|
||||||
if (!column) return null;
|
(node.closest('section,[class*="card"]') ?? node).scrollIntoView({ block: 'center' });
|
||||||
const rect = column.getBoundingClientRect();
|
return true;
|
||||||
const width = Math.min(shotW, Math.max(560, rect.width));
|
}, shot.scrollTo);
|
||||||
const height = Math.min(shotH, width * (shotH / shotW));
|
if (!ok) throw new Error(`shot ${index}: no heading "${shot.scrollTo}" on ${route}`);
|
||||||
const heading = text
|
await page.waitForTimeout(600);
|
||||||
? [...document.querySelectorAll('h1,h2,h3')].find((el) => el.textContent?.trim() === text)
|
}
|
||||||
: null;
|
|
||||||
const box = (heading?.closest('section,[class*="card"]') ?? heading)?.getBoundingClientRect();
|
/* The zoom target, in captured pixels. Smallest element containing the
|
||||||
const centreY = box ? box.top + box.height / 2 : height / 2;
|
* text, so naming "82.8%" lands on the figure rather than on the card. */
|
||||||
return {
|
let focus = null;
|
||||||
x: Math.round(Math.max(0, Math.min(shotW - width, rect.left))),
|
if (shot.zoom) {
|
||||||
y: Math.round(Math.max(0, Math.min(shotH - height, centreY - height / 2))),
|
/*
|
||||||
width: Math.round(width),
|
* Target selection is "smallest element already in frame", not "last
|
||||||
height: Math.round(height),
|
* 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) => {
|
||||||
{ text: anchor.text ?? null, shotW: SHOT_W, shotH: SHOT_H },
|
const r = el.getBoundingClientRect();
|
||||||
);
|
return r.bottom > 0 && r.top < window.innerHeight;
|
||||||
await page.screenshot(clip ? { path: appPng, clip } : { path: appPng });
|
});
|
||||||
|
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();
|
await page.close();
|
||||||
|
|
||||||
const palette = THEME[shot.theme];
|
const palette = THEME[shot.theme];
|
||||||
await chromePage.setContent(
|
await captionPage.setContent(
|
||||||
CHROME_HTML({
|
CAPTION_HTML({ ...palette, font, caption: shot.caption, progress: (index + 1) / film.shots.length }),
|
||||||
...palette,
|
|
||||||
font,
|
|
||||||
eyebrow: index === 0 ? 'Prime Intellect Growth' : 'Motion',
|
|
||||||
caption: shot.caption,
|
|
||||||
progress: (index + 1) / film.shots.length,
|
|
||||||
}),
|
|
||||||
{ waitUntil: 'load' },
|
{ waitUntil: 'load' },
|
||||||
);
|
);
|
||||||
await chromePage.waitForTimeout(250);
|
await captionPage.waitForTimeout(250);
|
||||||
const chromePng = join(work, `chrome-${index}.png`);
|
const captionPng = join(work, `caption-${index}.png`);
|
||||||
await chromePage.screenshot({ path: chromePng, omitBackground: true });
|
await captionPage.screenshot({ path: captionPng, omitBackground: true });
|
||||||
|
|
||||||
// 3 ------------------------------------------------ one shot -> one clip
|
// 3 ---------------------------------------------- one shot -> one clip
|
||||||
const n = frames[index];
|
const n = frames[index];
|
||||||
const zoom =
|
const camera = focus
|
||||||
shot.zoom === 'out'
|
? `zoompan=z='${zoomExpression(shot.zoom.scale ?? 1.6, n)}':` +
|
||||||
? `'if(eq(on,0),1.14,max(1.0,zoom-${(0.14 / n).toFixed(6)}))'`
|
`x='max(0,min(iw-iw/zoom,${focus.cx.toFixed(1)}-(iw/zoom)/2))':` +
|
||||||
: shot.zoom === 'hold'
|
`y='max(0,min(ih-ih/zoom,${focus.cy.toFixed(1)}-(ih/zoom)/2))':` +
|
||||||
? `'1.03'`
|
`d=${n}:s=${W}x${H}:fps=${FPS}`
|
||||||
: `'if(eq(on,0),1.0,min(1.12,zoom+${(0.12 / n).toFixed(6)}))'`;
|
: `scale=${W}:${H}`;
|
||||||
|
|
||||||
const segment = join(work, `seg-${index}.mp4`);
|
const segment = join(work, `seg-${index}.mp4`);
|
||||||
await run('ffmpeg', [
|
await run('ffmpeg', [
|
||||||
'-v', 'error', '-y',
|
'-v', 'error', '-y',
|
||||||
'-loop', '1', '-framerate', String(FPS), '-t', String(n / FPS), '-i', appPng,
|
'-loop', '1', '-framerate', String(FPS), '-t', String(n / FPS), '-i', framePng,
|
||||||
'-i', chromePng,
|
'-i', captionPng,
|
||||||
'-filter_complex',
|
'-filter_complex', `[0:v]${camera},format=yuv420p[base];[base][1:v]overlay=0:0:format=auto,format=yuv420p[v]`,
|
||||||
`[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),
|
'-map', '[v]', '-frames:v', String(n),
|
||||||
/* CRF 22, not 18. The frame is a mostly-static UI with a slow zoom, so 18
|
/* CRF 23 on a still frame with an occasional slow zoom. The originals
|
||||||
* spent 3.7 Mbps on 32 seconds — a 15 MB download for a video whose whole
|
* ship at about 420 kbps for 1440x810; this lands in the same class. */
|
||||||
* point is that somebody opens it on a phone between meetings. 22 holds
|
'-c:v', 'libx264', '-preset', 'slow', '-crf', '23', segment,
|
||||||
* the product's 12px labels and lands under 6 MB. */
|
|
||||||
'-c:v', 'libx264', '-preset', 'slow', '-crf', '22', segment,
|
|
||||||
]);
|
]);
|
||||||
segments.push(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();
|
await browser.close();
|
||||||
|
|
||||||
// 4 ------------------------------------------------- cut, score, normalise
|
// 4 ------------------------------------------------ cut, score, normalise
|
||||||
const list = join(work, 'segments.txt');
|
const list = join(work, 'segments.txt');
|
||||||
await writeFile(list, segments.map((s) => `file '${s}'`).join('\n'));
|
await writeFile(list, segments.map((s) => `file '${s}'`).join('\n'));
|
||||||
const silent = join(work, 'silent.mp4');
|
const silent = join(work, 'silent.mp4');
|
||||||
@@ -316,19 +341,14 @@ async function main() {
|
|||||||
const out = join(work, 'film.mp4');
|
const out = join(work, 'film.mp4');
|
||||||
await run('ffmpeg', [
|
await run('ffmpeg', [
|
||||||
'-v', 'error', '-y',
|
'-v', 'error', '-y',
|
||||||
'-i', silent,
|
'-i', silent, '-i', voice, '-stream_loop', '-1', '-i', MUSIC,
|
||||||
'-i', voice,
|
|
||||||
'-stream_loop', '-1', '-i', MUSIC,
|
|
||||||
'-filter_complex',
|
'-filter_complex',
|
||||||
/* The bed sits far under the voice and fades at both ends. Loudness is
|
`[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];` +
|
||||||
* 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];` +
|
`[1:a]adelay=250|250,apad[vox];` +
|
||||||
`[vox][bed]amix=inputs=2:duration=first:dropout_transition=0,` +
|
`[vox][bed]amix=inputs=2:duration=first:dropout_transition=0,` +
|
||||||
`atrim=0:${duration.toFixed(3)},loudnorm=I=-16:TP=-1.5:LRA=11[a]`,
|
`atrim=0:${duration.toFixed(3)},loudnorm=I=-16:TP=-1.5:LRA=11[a]`,
|
||||||
'-map', '0:v', '-map', '[a]',
|
'-map', '0:v', '-map', '[a]',
|
||||||
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k',
|
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', '-shortest', out,
|
||||||
'-movflags', '+faststart', '-shortest', out,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const finalDuration = await ffprobeDuration(out);
|
const finalDuration = await ffprobeDuration(out);
|
||||||
@@ -336,21 +356,10 @@ async function main() {
|
|||||||
const named = join(work, `${film.slug}.${hash}.mp4`);
|
const named = join(work, `${film.slug}.${hash}.mp4`);
|
||||||
await run('cp', [out, named]);
|
await run('cp', [out, named]);
|
||||||
const poster = join(work, `${film.slug}.${hash}.jpg`);
|
const poster = join(work, `${film.slug}.${hash}.jpg`);
|
||||||
await run('ffmpeg', [
|
await run('ffmpeg', ['-v', 'error', '-y', '-ss', '1.2', '-i', named, '-frames:v', '1', '-q:v', '3', poster]);
|
||||||
'-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(`\n ${film.slug}: ${finalDuration.toFixed(2)}s, ${words} words, ${W}x${H}`);
|
||||||
console.log(` ${named}`);
|
console.log(` ${named}\n ${poster}`);
|
||||||
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();
|
await main();
|
||||||
|
|||||||
Reference in New Issue
Block a user