@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/cli.mjs ADDED
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Recording CLI.
4
+ *
5
+ * npm run record -- recordings/<name>/script.json
6
+ * npm run record:dry -- recordings/<name>/script.json
7
+ * npm run record:exact -- recordings/<name>/script.json
8
+ * npm run record -- <script> --live https://joeasare.com --formats mp4,gif,vertical
9
+ *
10
+ * Two capture modes:
11
+ *
12
+ * realtime Frames arrive as the compositor paints them, carrying real timestamps,
13
+ * and are resampled to a constant rate at assembly. Fast to run, but the
14
+ * result is bound to machine speed — measured 20-23fps for DOM pages and
15
+ * about 10fps for Three.js under SwiftShader.
16
+ *
17
+ * deterministic Page time is replaced by a virtual clock the recorder advances one frame
18
+ * at a time, screenshotting each. Slow to run and completely indifferent to
19
+ * how slow: a frame costing 900ms of real work is still 1/fps of video, and
20
+ * two runs of one script produce the same frames.
21
+ */
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import { chromium } from 'playwright-core';
25
+
26
+ import { record, launch } from './runner.mjs';
27
+ import { validate, dryRun, parseViewport } from './schema.mjs';
28
+ import { encodeMaster, encodeVertical, encodeGif, sampleFrames, extractFrames, probeDuration } from './assemble.mjs';
29
+ import { critique, writeCritique } from './critic.mjs';
30
+ import { startServer } from './server.mjs';
31
+
32
+ const argv = process.argv.slice(2);
33
+ const flag = (name, fallback = null) => {
34
+ const i = argv.indexOf(`--${name}`);
35
+ return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fallback;
36
+ };
37
+ const has = (name) => argv.includes(`--${name}`);
38
+
39
+ const scriptPath = argv.find((a) => !a.startsWith('--') && a.endsWith('.json'));
40
+ if (!scriptPath) {
41
+ console.error('usage: npm run record -- <path/to/script.json> [--dry-run] [--deterministic] [--live <origin>] [--formats mp4,gif,vertical] [--keep-frames] [--gate]');
42
+ process.exit(2);
43
+ }
44
+ if (!fs.existsSync(scriptPath)) {
45
+ console.error(`no such script: ${scriptPath}`);
46
+ process.exit(2);
47
+ }
48
+
49
+ const script = JSON.parse(fs.readFileSync(scriptPath, 'utf8'));
50
+ const root = process.cwd();
51
+
52
+ console.log(`\n RECORD ${script.name}\n ────────────────────────────────────`);
53
+
54
+ // ── gate: the script has to be well formed before a browser is worth opening ──
55
+ const errors = validate(script);
56
+ if (errors.length) {
57
+ console.error(`\n ✗ ${errors.length} problem(s) in ${scriptPath}\n`);
58
+ for (const e of errors) console.error(` ${e}`);
59
+ console.error('');
60
+ process.exit(1);
61
+ }
62
+ console.log(' ✓ script is well formed');
63
+
64
+ /**
65
+ * A vertical take is a *recording* at a vertical viewport, not a crop of a landscape one.
66
+ *
67
+ * Cropping a 1440x900 master to 9:16 keeps 35% of its width, so a two-column layout loses
68
+ * a column and the framing argument moves to picking which third to sacrifice. These
69
+ * templates are responsive, so the answer is to give the page a vertical window and let it
70
+ * reflow. The crop path stays for deriving a quick short from an existing master, but it
71
+ * is the fallback now, not the method.
72
+ *
73
+ * npm run record -- <script> --viewport 540x960@2
74
+ *
75
+ * CSS pixels at a scale factor, and the two are not interchangeable: a layout switches on
76
+ * CSS width, so 1080x1920 is still a desktop and the page keeps its two-column form. The
77
+ * form above is a phone's CSS width rendered at 2x, which reflows *and* delivers a
78
+ * 1080x1920 frame.
79
+ *
80
+ * The take lands in its own directory, because it is a different recording of the same
81
+ * story: a different capture, a different timeline, its own critique. Sharing a directory
82
+ * would mean the second take silently overwriting the first's capture.json, and every edit
83
+ * pointing at that source would change meaning without being touched.
84
+ */
85
+ const viewportFlag = flag('viewport');
86
+ let outputName = script.name;
87
+ if (viewportFlag) {
88
+ const v = parseViewport(viewportFlag);
89
+ if (v.error) {
90
+ console.error(`\n ✗ ${v.error}\n`);
91
+ process.exit(2);
92
+ }
93
+ script.viewport = { width: v.width, height: v.height };
94
+ script.deviceScaleFactor = v.scale;
95
+ outputName = `${script.name}-${v.suffix}`;
96
+ console.log(
97
+ ` ✓ recording at ${v.width}x${v.height} CSS px @${v.scale}x → ${v.outWidth}x${v.outHeight} `
98
+ + `→ recordings/${outputName}`
99
+ );
100
+
101
+ // The distinction that decides whether a vertical take is worth anything. A responsive
102
+ // layout switches on *CSS* width, and 1080 CSS px is still a desktop: recording the
103
+ // brief form at 1080x1920 kept the two-column desktop layout and left the lower third
104
+ // of the frame empty — a worse result than the crop it was meant to replace. Recording
105
+ // at a phone's CSS width and scaling up gets the mobile layout at full resolution.
106
+ // Found by looking at a sample frame, which is the only way this is ever visible.
107
+ if (v.desktopBreakpoint) {
108
+ console.warn(` ! ${v.width} CSS px wide is a desktop breakpoint — the page will not reflow to its mobile layout.`);
109
+ console.warn(` For a phone layout at the same pixel size: --viewport ${v.width / 2}x${v.height / 2}@2`);
110
+ }
111
+ }
112
+
113
+ const outDir = path.join(root, 'recordings', outputName);
114
+ const liveOrigin = flag('live');
115
+
116
+ // ── dry run: every selector resolves, nothing is captured ─────────────────────
117
+ if (has('dry-run')) {
118
+ let server = null;
119
+ let baseUrl = liveOrigin;
120
+ if (!baseUrl) {
121
+ server = await startServer(root);
122
+ baseUrl = server.base;
123
+ }
124
+ const browser = await launch(chromium);
125
+ const page = await browser.newPage({ viewport: script.viewport || { width: 1440, height: 900 } });
126
+ try {
127
+ const missing = await dryRun(script, { page, baseUrl });
128
+ if (missing.length) {
129
+ console.error(`\n ✗ ${missing.length} selector(s) resolve to nothing\n`);
130
+ for (const m of missing) console.error(` shot ${m.shot} · ${m.type} · ${m.selector}`);
131
+ console.error('');
132
+ process.exit(1);
133
+ }
134
+ console.log(' ✓ every selector resolves');
135
+ console.log('\n OK dry run — nothing was recorded\n');
136
+ } finally {
137
+ await browser.close().catch(() => {});
138
+ if (server) await server.close();
139
+ }
140
+ process.exit(0);
141
+ }
142
+
143
+ // ── capture ───────────────────────────────────────────────────────────────────
144
+ const result = await record(script, {
145
+ chromium,
146
+ outDir,
147
+ root,
148
+ baseUrl: liveOrigin,
149
+ capture: has('deterministic') ? 'deterministic' : undefined,
150
+ log: (m) => console.log(` ${m}`),
151
+ });
152
+
153
+ const { stats, timeline, consoleErrors, assetErrors, framesDir, deterministic } = result;
154
+
155
+ // A missing font is not a missing image. The page still renders, every frame looks
156
+ // plausible, and the take is quietly in the wrong typeface — which is exactly how the
157
+ // studio tour got recorded in Georgia. Say it before the encode, not after.
158
+ const missingFonts = (assetErrors || []).filter((a) => a.kind === 'font' || a.kind === 'stylesheet');
159
+ if (missingFonts.length) {
160
+ console.warn(`\n ! ${missingFonts.length} font/stylesheet request(s) failed — this take is in fallback type:`);
161
+ for (const a of [...new Map(missingFonts.map((a) => [a.url, a])).values()].slice(0, 5)) {
162
+ console.warn(` ${a.detail} ${a.url}`);
163
+ }
164
+ console.warn(' The frames will look fine. They will not be the studio\'s typefaces.\n');
165
+ }
166
+ const fpsTarget = script.fps || 30;
167
+ if (deterministic) {
168
+ console.log(
169
+ ` ✓ captured ${stats.count} frames · ${stats.durationSec.toFixed(1)}s · `
170
+ + `exactly ${fpsTarget} fps (${stats.realMsPerFrame}ms of real work per frame)`
171
+ );
172
+ } else {
173
+ console.log(
174
+ ` ✓ captured ${stats.count} frames · ${stats.durationSec.toFixed(1)}s · `
175
+ + `${stats.motionFps.toFixed(1)} fps while moving (${stats.heldFrames} held)`
176
+ );
177
+ }
178
+ if (stats.dropped) console.warn(` ! ${stats.dropped} frame(s) failed to write`);
179
+
180
+ // The screencast delivers what the compositor paints, and headless Chromium paces that
181
+ // well below 30fps here — measured 20-23fps during motion on this container, with no
182
+ // launch flag that lifts it. Encoding to a higher constant rate then duplicates frames
183
+ // unevenly, which reads as judder. Say so rather than shipping a number that implies
184
+ // smoothness the capture never had.
185
+ if (!deterministic && stats.motionFps > 0 && stats.motionFps < fpsTarget * 0.8) {
186
+ console.warn(
187
+ ` ! captured ${stats.motionFps.toFixed(1)} fps during motion but encoding at ${fpsTarget} — `
188
+ + `frames will be duplicated. Set "fps": ${Math.max(20, Math.round(stats.motionFps / 2) * 2)}, `
189
+ + 'or re-run with --deterministic for exact frame timing.'
190
+ );
191
+ }
192
+ if (stats.count < 2) {
193
+ console.error('\n ✗ nothing was captured — the page never painted\n');
194
+ process.exit(1);
195
+ }
196
+
197
+ // ── assemble ──────────────────────────────────────────────────────────────────
198
+ const fps = script.fps || 30;
199
+ const formats = (flag('formats') || (script.output?.formats || ['mp4']).join(',')).split(',').map((s) => s.trim());
200
+ const manifest = result.writeManifest(path.join(framesDir, 'frames.txt'));
201
+ const master = path.join(outDir, 'out', `${outputName}.mp4`);
202
+
203
+ encodeMaster({ manifest, out: master, fps, crf: script.output?.crf ?? 18 });
204
+ console.log(` ✓ ${path.relative(root, master)} (${(fs.statSync(master).size / 1e6).toFixed(1)} MB)`);
205
+
206
+ if (formats.includes('vertical')) {
207
+ const { width, height } = result.viewport;
208
+ if (width / height <= 9 / 16 + 0.02) {
209
+ // Already vertical. Cropping it to 9:16 is a re-encode that changes nothing, and
210
+ // shipping both leaves two files where one is a slightly worse copy of the other.
211
+ console.log(' · skipping the vertical derivative — this take is already vertical');
212
+ } else {
213
+ const vertical = path.join(outDir, 'out', `${outputName}-vertical.mp4`);
214
+ encodeVertical({ input: master, out: vertical, focusX: script.output?.focusX ?? 0.5, fps });
215
+ console.log(` ✓ ${path.relative(root, vertical)}`);
216
+ // Say what it costs, at the moment it is paid. A derived vertical from a landscape
217
+ // take throws most of the frame away; recording the story at a vertical viewport
218
+ // keeps all of it, because these pages are responsive.
219
+ const kept = ((height * 9 / 16) / width * 100).toFixed(0);
220
+ console.log(` keeps ${kept}% of the width — re-run with --viewport 1080x1920 for a native vertical take`);
221
+ }
222
+ }
223
+ if (formats.includes('gif')) {
224
+ const gif = path.join(outDir, 'out', `${outputName}.gif`);
225
+ encodeGif({ input: master, out: gif, width: script.output?.gifWidth ?? 720 });
226
+ console.log(` ✓ ${path.relative(root, gif)} (${(fs.statSync(gif).size / 1e6).toFixed(1)} MB)`);
227
+ }
228
+
229
+ // A few stills for a human to glance at; the critic reads a denser strip below.
230
+ const samples = sampleFrames({
231
+ input: master,
232
+ dir: path.join(outDir, 'samples'),
233
+ count: script.sampleCount ?? 6,
234
+ duration: stats.durationSec,
235
+ });
236
+ console.log(` ✓ ${samples.length} sample frame(s) for critique`);
237
+
238
+ // ── critique ──────────────────────────────────────────────────────────────────
239
+ // Judged from the encoded master, so the verdict is about what a viewer will see rather
240
+ // than about an intermediate nobody watches.
241
+ const ANALYSIS_FPS = 4;
242
+ const analysisDir = path.join(outDir, '.analysis');
243
+ let verdict = null;
244
+ try {
245
+ const analysisFrames = extractFrames({ input: master, dir: analysisDir, fps: ANALYSIS_FPS });
246
+ verdict = critique({
247
+ frames: analysisFrames,
248
+ fps: ANALYSIS_FPS,
249
+ timeline,
250
+ durationSec: probeDuration(master),
251
+ });
252
+ writeCritique(outDir, verdict, { name: outputName, judgedAt: new Date().toISOString() });
253
+
254
+ if (verdict.blockers?.includes('MEASUREMENT_UNAVAILABLE')) {
255
+ // Not a bad recording — an unjudged one. Printing it as a failure next to a video
256
+ // that encoded fine reads as though the take were broken.
257
+ console.warn(' ! critique skipped — no pixel backend to compare frames with');
258
+ console.warn(' python3 -m pip install --user pillow numpy');
259
+ console.warn(' (macOS may need --break-system-packages, or use a venv)');
260
+ } else if (verdict.blockers?.length) {
261
+ console.error(` ✗ critique: ${verdict.blockers.join(', ')}`);
262
+ } else {
263
+ console.log(` ✓ critique ${verdict.score}/100 — ${verdict.findings.length} finding(s)`);
264
+ }
265
+ for (const f of verdict.findings.slice(0, 6)) {
266
+ if (f.atSec == null) {
267
+ // Not every finding is anchored to a moment — a measurement problem is about the
268
+ // whole run. Formatting one as `undefineds - ` was pure noise.
269
+ console.log(` ${f.kind}: ${f.detail}`);
270
+ continue;
271
+ }
272
+ console.log(` ${String(f.atSec).padStart(6)}s ${f.shot || '-'} ${f.kind}: ${f.detail}`);
273
+ }
274
+ } catch (e) {
275
+ console.warn(` ! critique unavailable: ${e.message}`);
276
+ } finally {
277
+ fs.rmSync(analysisDir, { recursive: true, force: true });
278
+ }
279
+
280
+ fs.writeFileSync(
281
+ path.join(outDir, 'capture.json'),
282
+ JSON.stringify({
283
+ name: outputName,
284
+ recordedAt: new Date().toISOString(),
285
+ viewport: result.viewport,
286
+ fps,
287
+ frames: stats.count,
288
+ capture: deterministic ? 'deterministic' : 'realtime',
289
+ capturedFps: Number(stats.fps.toFixed(2)),
290
+ capturedMotionFps: Number(stats.motionFps.toFixed(2)),
291
+ ...(deterministic ? { realMsPerFrame: stats.realMsPerFrame } : {}),
292
+ durationSec: probeDuration(master),
293
+ timeline,
294
+ consoleErrors,
295
+ assetErrors,
296
+ outputs: { master: path.relative(root, master), samples: samples.map((s) => path.relative(root, s)) },
297
+ critique: verdict ? { score: verdict.score, pass: verdict.pass, findings: verdict.findings.length } : null,
298
+ }, null, 2)
299
+ );
300
+
301
+ if (consoleErrors.length) {
302
+ console.warn(`\n ! ${consoleErrors.length} console error(s) during capture:`);
303
+ for (const e of [...new Set(consoleErrors)].slice(0, 5)) console.warn(` ${e}`);
304
+ }
305
+
306
+ if (!has('keep-frames')) {
307
+ fs.rmSync(framesDir, { recursive: true, force: true });
308
+ }
309
+
310
+ console.log(`\n OK ${path.relative(root, path.join(outDir, 'capture.json'))}\n`);
311
+
312
+ // --gate makes the critique decide the exit code, for a loop that should stop on a bad
313
+ // take rather than quietly producing one.
314
+ if (has('gate') && verdict && !verdict.pass) {
315
+ console.error(` ✗ critique did not pass (${verdict.score ?? 'unscored'}/100)\n`);
316
+ process.exit(1);
317
+ }
318
+ process.exit(0);
package/src/clock.mjs ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Deterministic clock.
3
+ *
4
+ * Phase 1 captured whatever the compositor happened to paint, which tied the result to
5
+ * machine speed: 20-23fps for DOM pages and about 10fps for Three.js under SwiftShader.
6
+ * This replaces the page's sense of time entirely. The recorder advances a virtual clock
7
+ * by exactly one frame, runs the callbacks that became due, screenshots, and repeats — so
8
+ * a frame that takes 900ms of real work still becomes 1/24s of video, and two runs of the
9
+ * same script produce the same frames.
10
+ *
11
+ * Why a JavaScript shim rather than CDP `Emulation.setVirtualTimePolicy`: virtual time
12
+ * freezes the whole renderer, so any page awaiting a real network response hangs until
13
+ * the policy is nursed through pending fetches. A shim only replaces the *timing* APIs.
14
+ * Network, decoding and layout continue on real time, so a page that fetches during a
15
+ * recording simply works, and a `setTimeout` poll waiting on that fetch still fires
16
+ * because the recorder keeps advancing.
17
+ *
18
+ * Installed with `addInitScript`, before any page script runs — a Three.js app that
19
+ * captured `performance.now` at module scope would otherwise keep the real one.
20
+ *
21
+ * ── What this cannot virtualise ──────────────────────────────────────────────────
22
+ * · `<video>` and `<audio>` playback, which run on the media clock. Recorded live.
23
+ * · CSS transitions and animations are *sampled*, not stepped: they are paused and
24
+ * their currentTime is set from the virtual clock each frame. That is exact for
25
+ * time-based animations and wrong for anything driven by transitionend timing.
26
+ * · Code reading `new Date()` rather than `Date.now()` — the constructor is left
27
+ * alone deliberately, since replacing it breaks date maths in ways that are far
28
+ * harder to see than a slightly wrong wall clock.
29
+ * · Worker and iframe timers, which have their own global scopes.
30
+ */
31
+
32
+ export const CLOCK_INIT = `(() => {
33
+ if (window.__clock) return;
34
+
35
+ const realNow = performance.now.bind(performance);
36
+ const realDateNow = Date.now.bind(Date);
37
+ const origin = realDateNow();
38
+
39
+ let now = 0;
40
+ let seq = 0;
41
+ const rafs = new Map();
42
+ const timers = new Map();
43
+
44
+ // Anything already scheduled by the time we install is left to the real clock; nothing
45
+ // should be, because this runs before page scripts.
46
+ const realSetTimeout = window.setTimeout.bind(window);
47
+
48
+ window.__clock = {
49
+ installed: true,
50
+ get now() { return now; },
51
+ /** Frames the recorder has asked for; exposed for diagnostics. */
52
+ ticks: 0,
53
+
54
+ /**
55
+ * Advance virtual time by dt milliseconds and run everything that became due.
56
+ * Timers fire in due order, then animation callbacks receive the new timestamp,
57
+ * matching the order a real frame would use.
58
+ */
59
+ tick(dt) {
60
+ now += dt;
61
+ this.ticks++;
62
+
63
+ // Timers can schedule timers. Bound the drain so a setTimeout(fn, 0) loop cannot
64
+ // spin forever inside a single frame — it yields to the next frame instead.
65
+ for (let guard = 0; guard < 1000; guard++) {
66
+ let earliest = null;
67
+ for (const t of timers.values()) {
68
+ if (t.due <= now && (!earliest || t.due < earliest.due || (t.due === earliest.due && t.id < earliest.id))) {
69
+ earliest = t;
70
+ }
71
+ }
72
+ if (!earliest) break;
73
+ if (earliest.interval == null) timers.delete(earliest.id);
74
+ else earliest.due = now + Math.max(1, earliest.interval);
75
+ try { earliest.fn(...(earliest.args || [])); } catch (e) { /* page's problem, not ours */ }
76
+ }
77
+
78
+ // rAF callbacks registered during this flush belong to the *next* frame, which is
79
+ // how the real one behaves — otherwise a self-rescheduling render loop would run
80
+ // unbounded inside one tick.
81
+ const due = [...rafs.entries()];
82
+ rafs.clear();
83
+ for (const [, cb] of due) {
84
+ try { cb(now); } catch (e) { /* ditto */ }
85
+ }
86
+
87
+ // CSS animations and transitions are sampled rather than stepped.
88
+ if (document.getAnimations) {
89
+ for (const anim of document.getAnimations()) {
90
+ try {
91
+ if (anim.playState === 'running') anim.pause();
92
+ anim.currentTime = now;
93
+ } catch (e) { /* not all animations accept a seek */ }
94
+ }
95
+ }
96
+ },
97
+
98
+ /** True once the page has asked for at least one frame — tells us a render loop exists. */
99
+ get pending() { return rafs.size; },
100
+ get timerCount() { return timers.size; },
101
+ };
102
+
103
+ performance.now = () => now;
104
+ Date.now = () => origin + now;
105
+
106
+ window.requestAnimationFrame = (cb) => { const id = ++seq; rafs.set(id, cb); return id; };
107
+ window.cancelAnimationFrame = (id) => { rafs.delete(id); };
108
+
109
+ window.setTimeout = (fn, delay, ...args) => {
110
+ if (typeof fn !== 'function') return realSetTimeout(fn, delay);
111
+ const id = ++seq;
112
+ timers.set(id, { id, fn, args, due: now + Math.max(0, delay || 0), interval: null });
113
+ return id;
114
+ };
115
+ window.setInterval = (fn, delay, ...args) => {
116
+ if (typeof fn !== 'function') return 0;
117
+ const id = ++seq;
118
+ const every = Math.max(1, delay || 1);
119
+ timers.set(id, { id, fn, args, due: now + every, interval: every });
120
+ return id;
121
+ };
122
+ window.clearTimeout = (id) => { timers.delete(id); };
123
+ window.clearInterval = (id) => { timers.delete(id); };
124
+
125
+ // Kept so the recorder can measure how much real work a frame cost, which is the only
126
+ // honest way to report that a scene is heavy once the output no longer shows it.
127
+ window.__clock.realNow = realNow;
128
+ })();`;
129
+
130
+ /** Milliseconds of virtual time per frame at a given rate. */
131
+ export const frameMs = (fps) => 1000 / fps;