@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/LICENSE +21 -0
- package/README.md +17 -0
- package/docs/getting-started.md +280 -0
- package/package.json +24 -0
- package/src/actions.mjs +215 -0
- package/src/assemble.mjs +362 -0
- package/src/caption.mjs +115 -0
- package/src/capture.mjs +143 -0
- package/src/cli.mjs +318 -0
- package/src/clock.mjs +131 -0
- package/src/critic.mjs +271 -0
- package/src/cursor.mjs +118 -0
- package/src/edit-cli.mjs +211 -0
- package/src/edit.mjs +303 -0
- package/src/frame-grid.mjs +80 -0
- package/src/index.mjs +19 -0
- package/src/motion.mjs +229 -0
- package/src/runner.mjs +205 -0
- package/src/schema.mjs +157 -0
- package/src/server.mjs +109 -0
- package/src/timeline.mjs +120 -0
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;
|