@mrkt_frwd/reel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/critic.mjs ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Frame critic — judge a recording from what it actually shows.
3
+ *
4
+ * The plan for this phase said "point design-eval's still-image rubric at the sampled
5
+ * frames". Reading that rubric closely, most of it does not transfer: its axes are
6
+ * `subjectFirst`, `framing`, `geometryCredibility` and `materials`, which are
7
+ * product-photography measures. A frame of someone filling in a form has no subject on a
8
+ * background and no material response, so those axes would score noise and the noise
9
+ * would carry a calibrated-looking number. What does transfer is the *shape* of that
10
+ * rubric — measured axes, blockers separated from warnings, a skip status that
11
+ * renormalises rather than scoring a default — and `imageStats`, whose raw luminance and
12
+ * occupancy measures are subject-agnostic.
13
+ *
14
+ * So the axes here are about what a recording can get wrong, and every one is measured
15
+ * from frames rather than asserted about the script:
16
+ *
17
+ * deadFrames a shot rendered nothing — blank, or a uniform wash
18
+ * frozenMotion consecutive frames identical while the shot claims movement. This is
19
+ * the one that catches a WebGL scene that never animated, which is
20
+ * exactly the failure a screenshot review cannot see.
21
+ * continuity a jump inside a shot, where the picture changes so much between two
22
+ * frames that the eye reads a cut rather than a move
23
+ * pacing a shot too short for anyone to read what it shows
24
+ * ending the clip lands mid-motion instead of on a held final state
25
+ *
26
+ * No model call. Everything here runs from pixels and the capture timeline, so it works
27
+ * in an environment with no egress and gives the same answer twice.
28
+ */
29
+ import fs from 'fs';
30
+ import path from 'path';
31
+
32
+ import { frameGrids, gridDelta, gridSpread } from './frame-grid.mjs';
33
+
34
+ /** Below this luminance spread a frame carries no legible content. */
35
+ const DEAD_STD_LUM = 3.0;
36
+ /** Mean absolute difference under this reads as an identical frame. */
37
+ const FROZEN_DELTA = 0.6;
38
+ /** Above this, consecutive frames *might* read as a cut rather than a move. */
39
+ const JUMP_DELTA = 34;
40
+ /** ...but only if the change also stands this many times clear of its neighbours. */
41
+ const JUMP_ISOLATION = 3.5;
42
+ /** Changes this close to either end of a shot belong to the cut, not to the shot. */
43
+ const BOUNDARY_SEC = 0.4;
44
+ /** A shot shorter than this cannot be read, whatever it contains. */
45
+ const MIN_SHOT_SEC = 1.2;
46
+ /** The tail that should be settled so a loop ends well. */
47
+ const ENDING_WINDOW_SEC = 0.6;
48
+
49
+ const clamp = (n, lo = 0, hi = 100) => Math.max(lo, Math.min(hi, n));
50
+ const axis = (score, status, notes, evidence = {}) => ({
51
+ score: Math.round(clamp(score)),
52
+ status,
53
+ notes,
54
+ evidence,
55
+ });
56
+
57
+ /**
58
+ * @param {object} opts
59
+ * @param {string[]} opts.frames analysis frames, in order
60
+ * @param {number} opts.fps rate the analysis frames were extracted at
61
+ * @param {{id:string,intent:string,startSec:number,endSec:number}[]} opts.timeline
62
+ * @param {number} opts.durationSec
63
+ */
64
+ export function critique({ frames, fps, timeline = [], durationSec }) {
65
+ const axes = {};
66
+ const blockers = [];
67
+ const findings = [];
68
+
69
+ if (!frames || frames.length < 2) {
70
+ return {
71
+ axes: { deadFrames: axis(0, 'fail', 'No frames to judge') },
72
+ blockers: ['NO_FRAMES'],
73
+ findings: [],
74
+ score: 0,
75
+ pass: false,
76
+ };
77
+ }
78
+
79
+ const { ok, grids, error } = frameGrids(frames);
80
+
81
+ // Without a pixel backend every measure below is null and the axes would fall back to
82
+ // constants — a score that measured nothing. Refuse rather than reassure.
83
+ if (!ok || grids.some((g) => !g)) {
84
+ return {
85
+ axes: {},
86
+ blockers: ['MEASUREMENT_UNAVAILABLE'],
87
+ findings: [{
88
+ kind: 'measurement',
89
+ detail: error || 'One or more frames could not be read',
90
+ }],
91
+ score: null,
92
+ pass: false,
93
+ };
94
+ }
95
+
96
+ const stats = frames.map((file, i) => ({
97
+ file, i, t: i / fps, grid: grids[i], spread: gridSpread(grids[i]),
98
+ }));
99
+
100
+ const shotAt = (t) => timeline.find((s) => t >= s.startSec && t < s.endSec) || null;
101
+
102
+ // ── dead frames ────────────────────────────────────────────────────────────
103
+ const dead = stats.filter((f) => (f.spread ?? 0) < DEAD_STD_LUM);
104
+ for (const f of dead) {
105
+ findings.push({
106
+ kind: 'dead-frame',
107
+ atSec: Number(f.t.toFixed(2)),
108
+ shot: shotAt(f.t)?.id ?? null,
109
+ detail: `frame carries almost no contrast (spread ${(f.spread ?? 0).toFixed(1)}) — nothing is legible here`,
110
+ });
111
+ }
112
+ axes.deadFrames = dead.length
113
+ ? axis(100 - (dead.length / stats.length) * 300, 'fail', `${dead.length} blank or near-blank frame(s)`, { count: dead.length })
114
+ : axis(100, 'pass', 'every frame carries content');
115
+ if (dead.length / stats.length > 0.1) blockers.push('DEAD_FRAMES');
116
+
117
+ // ── frozen motion ──────────────────────────────────────────────────────────
118
+ // Judged per shot, and only against shots that claim movement. A `hold` is supposed to
119
+ // be still, so flagging stillness everywhere would bury the one case that matters.
120
+ const deltas = [];
121
+ for (let i = 1; i < stats.length; i++) {
122
+ deltas.push({ t: stats[i].t, d: gridDelta(stats[i - 1].grid, stats[i].grid), shot: shotAt(stats[i].t) });
123
+ }
124
+
125
+ const movingShots = new Map();
126
+ for (const d of deltas) {
127
+ if (!d.shot) continue;
128
+ const entry = movingShots.get(d.shot.id) || { shot: d.shot, deltas: [] };
129
+ entry.deltas.push(d.d ?? 0);
130
+ movingShots.set(d.shot.id, entry);
131
+ }
132
+
133
+ let frozen = 0;
134
+ for (const { shot, deltas: ds } of movingShots.values()) {
135
+ const moved = ds.filter((d) => d > FROZEN_DELTA).length;
136
+ const share = ds.length ? moved / ds.length : 0;
137
+ // A shot whose frames never change at all, for more than a second, is not a beat —
138
+ // it is something that failed to render or failed to animate.
139
+ if (share < 0.05 && shot.endSec - shot.startSec > 1.5) {
140
+ frozen++;
141
+ findings.push({
142
+ kind: 'frozen-shot',
143
+ atSec: Number(shot.startSec.toFixed(2)),
144
+ shot: shot.id,
145
+ detail: `nothing moves for ${(shot.endSec - shot.startSec).toFixed(1)}s — intent was "${shot.intent}"`,
146
+ });
147
+ }
148
+ }
149
+ axes.frozenMotion = frozen
150
+ ? axis(100 - frozen * 45, 'fail', `${frozen} shot(s) render a still picture`, { count: frozen })
151
+ : axis(100, 'pass', 'every shot shows movement');
152
+ if (frozen) blockers.push('FROZEN_SHOT');
153
+
154
+ // ── continuity ─────────────────────────────────────────────────────────────
155
+ // A jump at a shot boundary is a cut and is intended; a jump inside one is not.
156
+ //
157
+ // Absolute change is the wrong test on its own, and the first version of this got it
158
+ // wrong: scrolling the landing page across the boundary between its dark hero and the
159
+ // cream section below produces enormous frame-to-frame deltas, and every one was
160
+ // reported as a cut. That is a fast scroll doing exactly what it was asked to do.
161
+ //
162
+ // What distinguishes a discontinuity is that it is *isolated*: one large change sitting
163
+ // among small ones. Sustained motion makes its neighbours large too. So a delta is only
164
+ // a jump if it also stands well clear of the local median around it.
165
+ const localMedian = (i, radius = 3) => {
166
+ const near = [];
167
+ for (let j = Math.max(0, i - radius); j <= Math.min(deltas.length - 1, i + radius); j++) {
168
+ if (j !== i && deltas[j].d != null) near.push(deltas[j].d);
169
+ }
170
+ if (!near.length) return null;
171
+ near.sort((a, b) => a - b);
172
+ return near[Math.floor(near.length / 2)];
173
+ };
174
+
175
+ const jumps = deltas.filter((d, i) => {
176
+ if (d.d == null || d.d < JUMP_DELTA) return false;
177
+ if (!d.shot) return false;
178
+ // Both ends of a shot are legitimate cuts. Guarding only the start still reported the
179
+ // navigation into the next shot, because the analysis strip is coarser than the
180
+ // boundary and one delta straddles it — the change belongs to the goto, not to the
181
+ // shot it happens to be filed under.
182
+ if (d.t - d.shot.startSec <= BOUNDARY_SEC) return false;
183
+ if (d.shot.endSec - d.t <= BOUNDARY_SEC) return false;
184
+ const med = localMedian(i);
185
+ // No neighbours to compare against — do not guess.
186
+ if (med == null) return false;
187
+ return d.d > Math.max(JUMP_DELTA, med * JUMP_ISOLATION);
188
+ });
189
+ for (const j of jumps) {
190
+ findings.push({
191
+ kind: 'jump-cut',
192
+ atSec: Number(j.t.toFixed(2)),
193
+ shot: j.shot.id,
194
+ detail: `picture changes abruptly mid-shot (delta ${j.d.toFixed(0)}) — reads as a cut, not a move`,
195
+ });
196
+ }
197
+ axes.continuity = jumps.length
198
+ ? axis(100 - jumps.length * 12, 'warn', `${jumps.length} abrupt change(s) inside a shot`, { count: jumps.length })
199
+ : axis(100, 'pass', 'motion is continuous within every shot');
200
+
201
+ // ── pacing ─────────────────────────────────────────────────────────────────
202
+ const rushed = timeline.filter((s) => s.endSec - s.startSec < MIN_SHOT_SEC);
203
+ for (const s of rushed) {
204
+ findings.push({
205
+ kind: 'rushed-shot',
206
+ atSec: Number(s.startSec.toFixed(2)),
207
+ shot: s.id,
208
+ detail: `${(s.endSec - s.startSec).toFixed(2)}s is too short to read — under the ${MIN_SHOT_SEC}s floor`,
209
+ });
210
+ }
211
+ axes.pacing = timeline.length
212
+ ? (rushed.length
213
+ ? axis(100 - (rushed.length / timeline.length) * 100, 'warn', `${rushed.length} shot(s) too short to read`, { count: rushed.length })
214
+ : axis(100, 'pass', 'every shot holds long enough to read'))
215
+ : axis(50, 'skip', 'no timeline supplied');
216
+
217
+ // ── ending ─────────────────────────────────────────────────────────────────
218
+ // The craft rule in the recorder skill: land on a strong final state so a loop ends
219
+ // well. Measured as stillness across the tail.
220
+ const tail = deltas.filter((d) => d.t > (durationSec ?? stats[stats.length - 1].t) - ENDING_WINDOW_SEC);
221
+ const tailMoving = tail.filter((d) => (d.d ?? 0) > JUMP_DELTA / 3).length;
222
+ if (tail.length && tailMoving / tail.length > 0.5) {
223
+ findings.push({
224
+ kind: 'unsettled-ending',
225
+ atSec: Number((durationSec ?? 0).toFixed(2)),
226
+ shot: timeline[timeline.length - 1]?.id ?? null,
227
+ detail: 'the clip ends mid-motion — a loop will cut on a moving frame',
228
+ });
229
+ }
230
+ axes.ending = tail.length
231
+ ? (tailMoving / tail.length > 0.5
232
+ ? axis(45, 'warn', 'ends mid-motion rather than on a held state')
233
+ : axis(100, 'pass', 'settles before the end'))
234
+ : axis(50, 'skip', 'clip too short to judge its ending');
235
+
236
+ const score = weighted(axes);
237
+ return {
238
+ axes,
239
+ blockers,
240
+ findings: findings.sort((a, b) => a.atSec - b.atSec),
241
+ score,
242
+ pass: blockers.length === 0 && score != null && score >= 70,
243
+ };
244
+ }
245
+
246
+ export const CRITIC_WEIGHTS = {
247
+ deadFrames: 0.3,
248
+ frozenMotion: 0.3,
249
+ continuity: 0.15,
250
+ pacing: 0.15,
251
+ ending: 0.1,
252
+ };
253
+
254
+ /** Skipped axes renormalise rather than scoring a default — the design-eval convention. */
255
+ function weighted(axes) {
256
+ let sum = 0;
257
+ let wsum = 0;
258
+ for (const [k, w] of Object.entries(CRITIC_WEIGHTS)) {
259
+ if (axes[k] && axes[k].status !== 'skip') {
260
+ sum += axes[k].score * w;
261
+ wsum += w;
262
+ }
263
+ }
264
+ return wsum ? Math.round(sum / wsum) : null;
265
+ }
266
+
267
+ export function writeCritique(dir, result, meta = {}) {
268
+ const file = path.join(dir, 'critique.json');
269
+ fs.writeFileSync(file, JSON.stringify({ ...meta, ...result }, null, 2));
270
+ return file;
271
+ }
package/src/cursor.mjs ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Synthetic cursor overlay.
3
+ *
4
+ * Playwright's mouse is real as far as the page is concerned, but the OS pointer is not
5
+ * part of what the compositor paints — so a screencast of a click shows the effect and
6
+ * never the cause. For a SaaS-style walkthrough that is fatal: the viewer sees fields
7
+ * filling themselves. This draws a pointer that the recording can see, plus a ripple on
8
+ * click so the moment of interaction reads at speed.
9
+ *
10
+ * Installed with `addInitScript`, so it survives every navigation in a multi-page story
11
+ * without the script needing to re-inject it per page.
12
+ *
13
+ * The overlay is `pointer-events:none` and sits above everything, so it cannot alter what
14
+ * it is documenting. It is also marked `data-recording-chrome`, which the frame critic
15
+ * uses to tell studio UI apart from recorder furniture.
16
+ */
17
+
18
+ export const CURSOR_INIT = `(() => {
19
+ if (window.__rec) return;
20
+ const SIZE = 22;
21
+ let el = null, ripple = null;
22
+
23
+ function ensure() {
24
+ if (el && document.body.contains(el)) return;
25
+ el = document.createElement('div');
26
+ el.setAttribute('data-recording-chrome', 'cursor');
27
+ el.style.cssText = [
28
+ 'position:fixed','left:0','top:0','width:' + SIZE + 'px','height:' + SIZE + 'px',
29
+ 'pointer-events:none','z-index:2147483647','will-change:transform',
30
+ 'transform:translate3d(-100px,-100px,0)','opacity:0','transition:opacity .18s ease',
31
+ ].join(';');
32
+ el.innerHTML =
33
+ '<svg width="' + SIZE + '" height="' + SIZE + '" viewBox="0 0 22 22" fill="none">' +
34
+ '<path d="M4 2.5 L4 17 L8 13.4 L10.6 19 L13.4 17.7 L10.8 12.3 L16 12 Z" ' +
35
+ 'fill="#ffffff" stroke="rgba(0,0,0,.55)" stroke-width="1.1" stroke-linejoin="round"/></svg>';
36
+
37
+ ripple = document.createElement('div');
38
+ ripple.setAttribute('data-recording-chrome', 'ripple');
39
+ ripple.style.cssText = [
40
+ 'position:fixed','left:0','top:0','width:34px','height:34px','margin:-17px 0 0 -17px',
41
+ 'border-radius:50%','pointer-events:none','z-index:2147483646','opacity:0',
42
+ 'border:2px solid rgba(255,255,255,.85)','will-change:transform,opacity',
43
+ ].join(';');
44
+
45
+ document.body.appendChild(el);
46
+ document.body.appendChild(ripple);
47
+ }
48
+
49
+ window.__rec = {
50
+ /** Place the pointer. Called once per animation step, so no CSS transition on transform. */
51
+ cursor(x, y) {
52
+ ensure();
53
+ el.style.opacity = '1';
54
+ el.style.transform = 'translate3d(' + x + 'px,' + y + 'px,0)';
55
+ window.__rec._x = x; window.__rec._y = y;
56
+ },
57
+ hide() { if (el) el.style.opacity = '0'; },
58
+ /** A one-shot ring at the current point. Deliberately short — it must not trail. */
59
+ ripple(x, y) {
60
+ ensure();
61
+ const px = x == null ? window.__rec._x : x;
62
+ const py = y == null ? window.__rec._y : y;
63
+ ripple.style.transition = 'none';
64
+ ripple.style.transform = 'translate3d(' + px + 'px,' + py + 'px,0) scale(.35)';
65
+ ripple.style.opacity = '.9';
66
+ requestAnimationFrame(() => {
67
+ ripple.style.transition = 'transform .42s cubic-bezier(.2,.7,.3,1), opacity .42s ease';
68
+ ripple.style.transform = 'translate3d(' + px + 'px,' + py + 'px,0) scale(1.25)';
69
+ ripple.style.opacity = '0';
70
+ });
71
+ },
72
+ _x: 0, _y: 0,
73
+ };
74
+
75
+ // The drawn cursor follows the real pointer by listening, rather than being positioned
76
+ // by the driver on every step. Each driver-side call is an IPC round trip, and doing
77
+ // one per animation frame was costing more time than the frame itself — it held capture
78
+ // to about 20fps during motion. Listening keeps the two in sync by construction.
79
+ addEventListener('pointermove', (e) => {
80
+ if (e.isTrusted === false && !e.__rec) return;
81
+ window.__rec.cursor(e.clientX, e.clientY);
82
+ }, { capture: true, passive: true });
83
+
84
+ /** Eased scroll driven entirely in-page, so the driver waits instead of stepping. */
85
+ window.__rec.scrollTo = (target, ms) => new Promise((resolve) => {
86
+ const from = window.scrollY;
87
+ const delta = target - from;
88
+ if (!ms || Math.abs(delta) < 1) { window.scrollTo(0, target); return resolve(); }
89
+ const t0 = performance.now();
90
+ const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
91
+ (function step(now) {
92
+ const t = Math.min(1, (now - t0) / ms);
93
+ window.scrollTo(0, from + delta * ease(t));
94
+ if (t < 1) requestAnimationFrame(step);
95
+ else resolve();
96
+ })(t0);
97
+ });
98
+
99
+ if (document.body) ensure();
100
+ else document.addEventListener('DOMContentLoaded', ensure, { once: true });
101
+ })();`;
102
+
103
+ /**
104
+ * Hides native carets and scrollbars, which read as artefacts in a finished clip, and
105
+ * neutralises smooth-scroll so the recorder owns scroll timing rather than the page.
106
+ */
107
+ export const POLISH_INIT = `(() => {
108
+ const style = document.createElement('style');
109
+ style.setAttribute('data-recording-chrome', 'polish');
110
+ style.textContent = [
111
+ 'html{scroll-behavior:auto !important}',
112
+ '::-webkit-scrollbar{width:0 !important;height:0 !important}',
113
+ 'html{scrollbar-width:none !important}',
114
+ ].join('');
115
+ const add = () => document.documentElement.appendChild(style);
116
+ if (document.documentElement) add();
117
+ else document.addEventListener('DOMContentLoaded', add, { once: true });
118
+ })();`;
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Edit CLI — cut a short piece from a recorded master.
4
+ *
5
+ * npm run record:edit -- recordings/studio-tour/social.edit.json
6
+ * npm run record:edit -- <edit.json> --check # resolve and report, encode nothing
7
+ *
8
+ * The master is the source of truth. A social cut is a reading of it, not a second
9
+ * recording, so the two cannot drift apart.
10
+ */
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { chromium } from 'playwright-core';
14
+
15
+ import { launch } from './runner.mjs';
16
+ import { validateEdit, resolve, budgetReport, loadCapture, ASPECTS } from './edit.mjs';
17
+ import { renderCaptions } from './caption.mjs';
18
+ import { renderMotionSequence } from './motion.mjs';
19
+ import { cutEdit, probeDuration } from './assemble.mjs';
20
+
21
+ const argv = process.argv.slice(2);
22
+ const has = (name) => argv.includes(`--${name}`);
23
+ const editPath = argv.find((a) => !a.startsWith('--') && a.endsWith('.json'));
24
+
25
+ if (!editPath || !fs.existsSync(editPath)) {
26
+ console.error('usage: npm run record:edit -- <path/to/edit.json> [--check]');
27
+ process.exit(2);
28
+ }
29
+
30
+ const root = process.cwd();
31
+ const edit = JSON.parse(fs.readFileSync(editPath, 'utf8'));
32
+
33
+ console.log(`\n EDIT ${edit.name}\n ────────────────────────────────────`);
34
+
35
+ const errors = validateEdit(edit);
36
+ if (errors.length) {
37
+ console.error(`\n ✗ ${errors.length} problem(s) in ${editPath}\n`);
38
+ for (const e of errors) console.error(` ${e}`);
39
+ console.error('');
40
+ process.exit(1);
41
+ }
42
+
43
+ let capture;
44
+ try {
45
+ capture = loadCapture(root, edit.source);
46
+ } catch (e) {
47
+ console.error(`\n ✗ ${e.message}\n`);
48
+ process.exit(1);
49
+ }
50
+
51
+ const { segments, problems, totalSec, overlapSec, sourceDurationSec } = resolve(edit, capture);
52
+ if (problems.length) {
53
+ console.error(`\n ✗ ${problems.length} segment(s) do not resolve against ${edit.source}\n`);
54
+ for (const p of problems) console.error(` ${p}`);
55
+ console.error('');
56
+ process.exit(1);
57
+ }
58
+
59
+ console.log(` ✓ ${segments.length} segment(s) resolve against ${edit.source} (${sourceDurationSec.toFixed(1)}s master)`);
60
+ for (const s of segments) {
61
+ const speed = s.speed !== 1 ? ` @${s.speed}x` : '';
62
+ const cap = s.caption ? ` "${s.caption.slice(0, 42)}${s.caption.length > 42 ? '…' : ''}"` : '';
63
+ // The transition sits on its own line between the two shots it joins, which is where it
64
+ // happens. Inline, it read as a property of the segment and pushed the columns out of
65
+ // alignment for every other row.
66
+ if (s.transition && s.transition.type !== 'cut') {
67
+ console.log(` ⌄ ${s.transition.type} ${s.transition.durationSec}s`);
68
+ }
69
+ console.log(` ${(s.shot || 'abs').padEnd(9)} ${s.start.toFixed(2)}-${s.end.toFixed(2)}s → ${s.outSec.toFixed(2)}s${speed}${cap}`);
70
+ }
71
+ console.log(
72
+ ` ✓ ${totalSec.toFixed(1)}s total`
73
+ + (overlapSec > 0 ? ` (${overlapSec.toFixed(1)}s absorbed by transitions)` : '')
74
+ );
75
+
76
+ // Narrowing a landscape master to a vertical frame is lossy in a way that no focusX can
77
+ // rescue, and the loss is invisible until the render is watched. Report it in the units
78
+ // that matter — how much of the frame survives — and name the alternative, which is to
79
+ // record the story at that viewport instead. These pages are responsive; a vertical take
80
+ // reflows and keeps the whole design in frame.
81
+ const shot = ASPECTS[edit.aspect || '16:9'];
82
+ const src = capture.viewport;
83
+ if (src?.width && shot) {
84
+ const srcRatio = src.width / src.height;
85
+ const outRatio = shot.w / shot.h;
86
+ if (outRatio < srcRatio - 0.02 && segments.some((s) => s.fit !== 'contain')) {
87
+ const kept = ((src.height * outRatio) / src.width) * 100;
88
+ const level = kept < 60 ? '!' : '·';
89
+ console.log(
90
+ ` ${level} cropping ${src.width}x${src.height} to ${edit.aspect} keeps ${kept.toFixed(0)}% of the width`
91
+ );
92
+ if (kept < 60) {
93
+ console.log(` a native take keeps all of it: npm run record -- recordings/${edit.source}/script.json --viewport ${shot.w}x${shot.h}`);
94
+ console.log(` then point this edit at "source": "${edit.source}-vertical"`);
95
+ }
96
+ }
97
+ }
98
+
99
+ const budget = budgetReport(totalSec, edit.budgetSec);
100
+ if (budget && !budget.ok) {
101
+ // Reported, not enforced. Silently retiming to hit a number changes the pacing the
102
+ // director chose, and pacing is the whole point of a short format.
103
+ console.warn(` ! ${budget.totalSec.toFixed(1)}s is over the ${budget.budgetSec}s budget — ${budget.hint}`);
104
+ }
105
+
106
+ // An audio bed is named in the edit but lives on disk, so it is checked here rather than
107
+ // in validation. Failing at the graph stage would mean discovering it after the captions
108
+ // have been rendered in a browser.
109
+ let audio = null;
110
+ if (edit.audio?.file) {
111
+ const file = path.isAbsolute(edit.audio.file) ? edit.audio.file : path.join(root, edit.audio.file);
112
+ if (!fs.existsSync(file)) {
113
+ console.error(`\n ✗ audio.file not found: ${edit.audio.file}\n`);
114
+ process.exit(1);
115
+ }
116
+ audio = { ...edit.audio, file };
117
+ const bed = path.basename(file);
118
+ console.log(` ✓ audio bed ${bed} at ${audio.gainDb ?? -18}dB${audio.loop ? ', looped' : ''}`);
119
+ }
120
+
121
+ if (has('check')) {
122
+ console.log('\n OK check — nothing was encoded\n');
123
+ process.exit(0);
124
+ }
125
+
126
+ const aspect = edit.aspect || '16:9';
127
+ const size = ASPECTS[aspect];
128
+ const fps = edit.fps || capture.fps || 30;
129
+ const outDir = path.join(root, 'recordings', edit.source, 'out');
130
+ const master = path.join(root, capture.outputs?.master || path.join('recordings', edit.source, 'out', `${edit.source}.mp4`));
131
+
132
+ if (!fs.existsSync(master)) {
133
+ console.error(`\n ✗ master not found at ${path.relative(root, master)} — re-run the recording\n`);
134
+ process.exit(1);
135
+ }
136
+
137
+ // ── captions ──────────────────────────────────────────────────────────────────
138
+ const capDir = path.join(root, 'recordings', edit.source, '.captions');
139
+ const captions = segments
140
+ .filter((s) => s.caption)
141
+ .map((s) => ({ key: `cap-${s.index}`, text: s.caption, kicker: s.kicker }));
142
+
143
+ // ── motion overlays ───────────────────────────────────────────────────────────
144
+ // Rendered before the stills, because a segment that animates does not want one. Each is
145
+ // a PNG sequence at the edit's frame rate, produced by stepping the animation in Node and
146
+ // screenshotting Chromium once per frame — the same determinism the capture mode relies on.
147
+ const motionDir = path.join(root, 'recordings', edit.source, '.motion');
148
+ fs.rmSync(motionDir, { recursive: true, force: true });
149
+ let motionFrames = 0;
150
+ for (const s of segments) {
151
+ if (!s.motion) continue;
152
+ const spec = {
153
+ text: s.caption,
154
+ kicker: s.kicker,
155
+ durationSec: s.captionForSec != null ? Math.min(s.captionForSec, s.outSec) : s.outSec,
156
+ ...s.motion,
157
+ };
158
+ const seq = await renderMotionSequence(spec, {
159
+ chromium, launch, dir: path.join(motionDir, `seg-${s.index}`),
160
+ width: size.w, height: size.h, aspect, fps, root,
161
+ });
162
+ s.motionSeq = seq;
163
+ motionFrames += seq.count;
164
+ }
165
+ if (motionFrames) {
166
+ console.log(` ✓ ${motionFrames} animated overlay frame(s) across ${segments.filter((s) => s.motion).length} segment(s)`);
167
+ }
168
+
169
+ const stillCaptions = captions.filter((c) => !segments.find((s) => `cap-${s.index}` === c.key)?.motion);
170
+
171
+ let rendered = new Map();
172
+ if (stillCaptions.length) {
173
+ rendered = await renderCaptions(stillCaptions, {
174
+ chromium, launch, dir: capDir, width: size.w, height: size.h, aspect, root,
175
+ });
176
+ console.log(` ✓ ${rendered.size} still caption(s) rendered in the studio typeface`);
177
+ }
178
+ for (const s of segments) {
179
+ if (s.caption && !s.motion) s.captionPng = rendered.get(`cap-${s.index}`);
180
+ }
181
+
182
+ // ── cut ───────────────────────────────────────────────────────────────────────
183
+ const out = path.join(outDir, `${edit.name}.mp4`);
184
+ try {
185
+ cutEdit({
186
+ input: master,
187
+ out,
188
+ segments,
189
+ size,
190
+ focusX: edit.focusX ?? 0.5,
191
+ fps,
192
+ crf: edit.crf ?? 19,
193
+ audio,
194
+ });
195
+ } finally {
196
+ fs.rmSync(capDir, { recursive: true, force: true });
197
+ fs.rmSync(motionDir, { recursive: true, force: true });
198
+ }
199
+
200
+ const actual = probeDuration(out);
201
+ console.log(` ✓ ${path.relative(root, out)} ${size.w}x${size.h} ${actual ? actual.toFixed(1) + 's' : ''} (${(fs.statSync(out).size / 1e6).toFixed(1)} MB)`);
202
+
203
+ // The filtergraph is long and one wrong label silently drops a segment, so the result is
204
+ // checked against what the edit asked for rather than assumed.
205
+ if (actual && Math.abs(actual - totalSec) > 0.5) {
206
+ console.error(`\n ✗ output is ${actual.toFixed(2)}s but the edit asks for ${totalSec.toFixed(2)}s — a segment did not make it\n`);
207
+ process.exit(1);
208
+ }
209
+
210
+ console.log('\n OK\n');
211
+ process.exit(0);