@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/runner.mjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recording runner — owns the browser for the length of a story.
|
|
3
|
+
*
|
|
4
|
+
* The context is created once and reused across every shot, which is the whole reason a
|
|
5
|
+
* multi-page narrative works: cookies, localStorage, sessionStorage and any in-page state
|
|
6
|
+
* survive navigation, so shot 4 can show an account page that shot 2 signed into. A
|
|
7
|
+
* recorder that opens a fresh page per shot can only ever produce disconnected clips.
|
|
8
|
+
*
|
|
9
|
+
* Launch flags are inherited from `tools/runtime-audit.mjs`, which is the configuration
|
|
10
|
+
* already proven to render this repo's WebGL templates headlessly under SwiftShader.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
|
|
15
|
+
import { ACTIONS, resetJitter } from './actions.mjs';
|
|
16
|
+
import { CURSOR_INIT, POLISH_INIT } from './cursor.mjs';
|
|
17
|
+
import { CLOCK_INIT } from './clock.mjs';
|
|
18
|
+
import { ScreencastRecorder, writeManifest } from './capture.mjs';
|
|
19
|
+
import { RealtimeDriver, DeterministicDriver } from './timeline.mjs';
|
|
20
|
+
import { startServer } from './server.mjs';
|
|
21
|
+
|
|
22
|
+
const LAUNCH_ARGS = [
|
|
23
|
+
'--use-gl=angle',
|
|
24
|
+
'--use-angle=swiftshader',
|
|
25
|
+
'--no-sandbox',
|
|
26
|
+
'--ignore-gpu-blocklist',
|
|
27
|
+
'--force-color-profile=srgb',
|
|
28
|
+
// Screencast quality: without this Chromium throttles the compositor when the window
|
|
29
|
+
// is not focused, which in headless means always.
|
|
30
|
+
'--disable-backgrounding-occluded-windows',
|
|
31
|
+
'--disable-renderer-backgrounding',
|
|
32
|
+
'--disable-background-timer-throttling',
|
|
33
|
+
'--hide-scrollbars',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Playwright's bundled browser revision and the one baked into a container image drift
|
|
38
|
+
* apart, and the resulting error names a path that was never going to exist. Prefer an
|
|
39
|
+
* explicit binary when one is present.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveExecutable() {
|
|
42
|
+
if (process.env.CHROMIUM_PATH) return process.env.CHROMIUM_PATH;
|
|
43
|
+
const root = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers';
|
|
44
|
+
if (!fs.existsSync(root)) return undefined;
|
|
45
|
+
const candidates = fs
|
|
46
|
+
.readdirSync(root)
|
|
47
|
+
.filter((d) => d.startsWith('chromium-'))
|
|
48
|
+
.sort()
|
|
49
|
+
.reverse()
|
|
50
|
+
.map((d) => path.join(root, d, 'chrome-linux', 'chrome'));
|
|
51
|
+
return candidates.find((c) => fs.existsSync(c));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function launch(chromium, { headless = true } = {}) {
|
|
55
|
+
const executablePath = resolveExecutable();
|
|
56
|
+
return chromium.launch({ headless, args: LAUNCH_ARGS, ...(executablePath ? { executablePath } : {}) });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run a script end to end.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} script parsed script.json
|
|
63
|
+
* @param {object} opts
|
|
64
|
+
* @param {import('playwright-core').BrowserType} opts.chromium
|
|
65
|
+
* @param {string} opts.outDir where frames and video land
|
|
66
|
+
* @param {string} [opts.baseUrl] skip the local server and record a deployed origin
|
|
67
|
+
* @param {string} [opts.root] document root for the local server
|
|
68
|
+
* @param {(msg: string) => void} [opts.log]
|
|
69
|
+
*/
|
|
70
|
+
export async function record(script, opts) {
|
|
71
|
+
const log = opts.log || (() => {});
|
|
72
|
+
const viewport = script.viewport || { width: 1440, height: 900 };
|
|
73
|
+
const framesDir = path.join(opts.outDir, 'frames');
|
|
74
|
+
|
|
75
|
+
let server = null;
|
|
76
|
+
let baseUrl = opts.baseUrl;
|
|
77
|
+
if (!baseUrl) {
|
|
78
|
+
server = await startServer(opts.root || process.cwd());
|
|
79
|
+
baseUrl = server.base;
|
|
80
|
+
log(`serving ${opts.root || process.cwd()} at ${baseUrl}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const browser = await launch(opts.chromium);
|
|
84
|
+
const context = await browser.newContext({
|
|
85
|
+
viewport,
|
|
86
|
+
deviceScaleFactor: script.deviceScaleFactor || 1,
|
|
87
|
+
reducedMotion: 'no-preference',
|
|
88
|
+
colorScheme: script.colorScheme || 'dark',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Before any page script runs, on every navigation. The clock goes first: a Three.js
|
|
92
|
+
// app that captured performance.now at module scope would otherwise keep the real one.
|
|
93
|
+
const deterministic = (script.capture || opts.capture) === 'deterministic';
|
|
94
|
+
if (deterministic) await context.addInitScript(CLOCK_INIT);
|
|
95
|
+
await context.addInitScript(POLISH_INIT);
|
|
96
|
+
await context.addInitScript(CURSOR_INIT);
|
|
97
|
+
|
|
98
|
+
const page = await context.newPage();
|
|
99
|
+
const consoleErrors = [];
|
|
100
|
+
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 200)); });
|
|
101
|
+
page.on('pageerror', (e) => consoleErrors.push(`pageerror: ${e.message.slice(0, 200)}`));
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Assets that did not arrive, named.
|
|
105
|
+
*
|
|
106
|
+
* The console reports these as "Failed to load resource: 404" with no URL, which is
|
|
107
|
+
* unactionable — two sessions logged six of them and chased none, because there was
|
|
108
|
+
* nothing to chase. Recording the URL turns a number into a defect.
|
|
109
|
+
*
|
|
110
|
+
* A font matters more than the count suggests: a page whose typeface never arrived
|
|
111
|
+
* renders in its fallback stack and looks entirely plausible doing it, frame by frame.
|
|
112
|
+
* The studio recorded its whole tour in Georgia that way. So fonts and stylesheets are
|
|
113
|
+
* flagged separately from an image nobody will miss.
|
|
114
|
+
*/
|
|
115
|
+
const assetErrors = [];
|
|
116
|
+
const noteAsset = (url, detail) => {
|
|
117
|
+
if (assetErrors.length > 60) return;
|
|
118
|
+
const kind = /\.(woff2?|ttf|otf)(\?|$)/i.test(url) ? 'font'
|
|
119
|
+
: /\.css(\?|$)|fonts\.googleapis/i.test(url) ? 'stylesheet'
|
|
120
|
+
: 'asset';
|
|
121
|
+
assetErrors.push({ kind, url: url.slice(0, 300), detail });
|
|
122
|
+
};
|
|
123
|
+
page.on('response', (r) => { if (r.status() >= 400) noteAsset(r.url(), String(r.status())); });
|
|
124
|
+
page.on('requestfailed', (r) => noteAsset(r.url(), r.failure()?.errorText || 'request failed'));
|
|
125
|
+
|
|
126
|
+
fs.rmSync(framesDir, { recursive: true, force: true });
|
|
127
|
+
const fps = script.fps || 30;
|
|
128
|
+
|
|
129
|
+
const driver = deterministic
|
|
130
|
+
? new DeterministicDriver(page, {
|
|
131
|
+
fps,
|
|
132
|
+
dir: framesDir,
|
|
133
|
+
quality: script.quality ?? 90,
|
|
134
|
+
format: script.frameFormat || 'jpeg',
|
|
135
|
+
})
|
|
136
|
+
: new RealtimeDriver(page, { fps });
|
|
137
|
+
|
|
138
|
+
// Realtime observes what the compositor paints; deterministic captures each frame it
|
|
139
|
+
// has just computed, so only the former needs a screencast attached.
|
|
140
|
+
const recorder = deterministic
|
|
141
|
+
? null
|
|
142
|
+
: new ScreencastRecorder(page, { dir: framesDir, quality: script.quality ?? 80 });
|
|
143
|
+
|
|
144
|
+
// Same script, same keystroke cadence — see resetJitter in actions.mjs.
|
|
145
|
+
resetJitter();
|
|
146
|
+
|
|
147
|
+
const ctx = { page, context, baseUrl, log, driver };
|
|
148
|
+
const timeline = [];
|
|
149
|
+
let stats = null;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
// The first goto happens before capture starts so the initial paint and font swap
|
|
153
|
+
// are not the opening frames of the video.
|
|
154
|
+
const first = script.shots[0]?.actions?.[0];
|
|
155
|
+
if (first?.type === 'goto') {
|
|
156
|
+
await ACTIONS.goto(ctx, first);
|
|
157
|
+
await page.waitForTimeout(script.settleMs ?? 600);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (recorder) await recorder.start();
|
|
161
|
+
else driver.startCapture();
|
|
162
|
+
const t0 = Date.now();
|
|
163
|
+
|
|
164
|
+
const elapsed = () => (deterministic ? driver.virtualMs / 1000 : (Date.now() - t0) / 1000);
|
|
165
|
+
|
|
166
|
+
for (const shot of script.shots) {
|
|
167
|
+
const shotStart = elapsed();
|
|
168
|
+
log(` shot ${shot.id} — ${shot.intent}`);
|
|
169
|
+
for (const [i, action] of shot.actions.entries()) {
|
|
170
|
+
if (shot === script.shots[0] && i === 0 && first?.type === 'goto') continue;
|
|
171
|
+
const fn = ACTIONS[action.type];
|
|
172
|
+
if (!fn) throw new Error(`unknown action "${action.type}" in shot ${shot.id}`);
|
|
173
|
+
await fn(ctx, action);
|
|
174
|
+
}
|
|
175
|
+
timeline.push({
|
|
176
|
+
id: shot.id,
|
|
177
|
+
intent: shot.intent,
|
|
178
|
+
startSec: Number(shotStart.toFixed(3)),
|
|
179
|
+
endSec: Number(elapsed().toFixed(3)),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
if (recorder) {
|
|
184
|
+
stats = await recorder.stop();
|
|
185
|
+
} else {
|
|
186
|
+
driver.stopCapture();
|
|
187
|
+
stats = driver.summary();
|
|
188
|
+
}
|
|
189
|
+
await context.close().catch(() => {});
|
|
190
|
+
await browser.close().catch(() => {});
|
|
191
|
+
if (server) await server.close();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const frames = recorder ? recorder.frames : driver.frames;
|
|
195
|
+
return {
|
|
196
|
+
stats,
|
|
197
|
+
timeline,
|
|
198
|
+
consoleErrors,
|
|
199
|
+
assetErrors,
|
|
200
|
+
framesDir,
|
|
201
|
+
viewport,
|
|
202
|
+
deterministic,
|
|
203
|
+
writeManifest: (file) => writeManifest(frames, file),
|
|
204
|
+
};
|
|
205
|
+
}
|
package/src/schema.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Script validation and the dry run.
|
|
3
|
+
*
|
|
4
|
+
* This is the handoff gate between the director agent (who writes `script.json`) and the
|
|
5
|
+
* interaction developer (who makes it actually run). The failure this exists to catch is
|
|
6
|
+
* the one this repo keeps finding in other guises: a reference that *looks* wired and is
|
|
7
|
+
* not. A selector that matches nothing does not throw at author time — it throws four
|
|
8
|
+
* minutes into a capture, after the browser has been driven halfway through a story.
|
|
9
|
+
*
|
|
10
|
+
* `validate` is static and free. `dryRun` opens the pages and asserts every selector the
|
|
11
|
+
* script names actually resolves, without recording anything.
|
|
12
|
+
*/
|
|
13
|
+
import { ACTION_TYPES } from './actions.mjs';
|
|
14
|
+
|
|
15
|
+
const REQUIRED_BY_TYPE = {
|
|
16
|
+
goto: ['url'],
|
|
17
|
+
waitFor: [],
|
|
18
|
+
hold: [],
|
|
19
|
+
moveTo: ['x', 'y'],
|
|
20
|
+
hover: ['selector'],
|
|
21
|
+
click: ['selector'],
|
|
22
|
+
type: ['selector', 'text'],
|
|
23
|
+
press: ['key'],
|
|
24
|
+
scroll: ['to'],
|
|
25
|
+
drag: [],
|
|
26
|
+
eval: ['fn'],
|
|
27
|
+
inject: [],
|
|
28
|
+
route: ['url'],
|
|
29
|
+
setViewport: ['width', 'height'],
|
|
30
|
+
cursor: [],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} script parsed script.json
|
|
35
|
+
* @returns {string[]} problems, empty when the script is well formed
|
|
36
|
+
*/
|
|
37
|
+
export function validate(script) {
|
|
38
|
+
const errors = [];
|
|
39
|
+
if (!script || typeof script !== 'object') return ['script is not an object'];
|
|
40
|
+
if (!script.name || !/^[a-z0-9][a-z0-9-]*$/.test(script.name)) {
|
|
41
|
+
errors.push('name must be a lowercase slug — it becomes the output directory');
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(script.shots) || script.shots.length === 0) {
|
|
44
|
+
errors.push('script has no shots');
|
|
45
|
+
return errors;
|
|
46
|
+
}
|
|
47
|
+
if (script.fps != null && (script.fps < 1 || script.fps > 60)) {
|
|
48
|
+
errors.push(`fps ${script.fps} is outside 1-60`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ids = new Set();
|
|
52
|
+
script.shots.forEach((shot, si) => {
|
|
53
|
+
const where = `shot ${si}${shot.id ? ` (${shot.id})` : ''}`;
|
|
54
|
+
if (!shot.id) errors.push(`${where}: missing id`);
|
|
55
|
+
else if (ids.has(shot.id)) errors.push(`${where}: duplicate id`);
|
|
56
|
+
ids.add(shot.id);
|
|
57
|
+
// The intent line is what the critic judges the frames against. Without it a
|
|
58
|
+
// critique can only be about taste.
|
|
59
|
+
if (!shot.intent) errors.push(`${where}: missing intent — the critic has nothing to judge against`);
|
|
60
|
+
if (!Array.isArray(shot.actions) || shot.actions.length === 0) {
|
|
61
|
+
errors.push(`${where}: no actions`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
shot.actions.forEach((a, ai) => {
|
|
65
|
+
const at = `${where} action ${ai}`;
|
|
66
|
+
if (!a || !a.type) return errors.push(`${at}: missing type`);
|
|
67
|
+
if (!ACTION_TYPES.includes(a.type)) {
|
|
68
|
+
return errors.push(`${at}: unknown action "${a.type}" — known: ${ACTION_TYPES.join(', ')}`);
|
|
69
|
+
}
|
|
70
|
+
for (const key of REQUIRED_BY_TYPE[a.type] || []) {
|
|
71
|
+
if (a[key] === undefined) errors.push(`${at}: "${a.type}" requires "${key}"`);
|
|
72
|
+
}
|
|
73
|
+
if (a.type === 'drag' && !a.selector && (a.x === undefined || a.y === undefined)) {
|
|
74
|
+
errors.push(`${at}: drag needs a selector or an x/y start point`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// A story that never navigates is a screenshot with extra steps.
|
|
80
|
+
const hasGoto = script.shots.some((s) => (s.actions || []).some((a) => a.type === 'goto'));
|
|
81
|
+
if (!hasGoto) errors.push('no shot navigates anywhere — every script needs at least one goto');
|
|
82
|
+
|
|
83
|
+
return errors;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Selectors a shot depends on, in the order the script will need them. */
|
|
87
|
+
export function selectorsFor(shot) {
|
|
88
|
+
return (shot.actions || [])
|
|
89
|
+
.filter((a) => a.selector && a.type !== 'waitFor')
|
|
90
|
+
.map((a) => ({ selector: a.selector, type: a.type }));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Walk the script for real, resolving every selector, without capturing. Navigations and
|
|
95
|
+
* waits run; interactions do not. Returns the list of unresolved references.
|
|
96
|
+
*
|
|
97
|
+
* @returns {Promise<{ selector: string, shot: string, type: string }[]>}
|
|
98
|
+
*/
|
|
99
|
+
export async function dryRun(script, { page, baseUrl }) {
|
|
100
|
+
const missing = [];
|
|
101
|
+
for (const shot of script.shots) {
|
|
102
|
+
for (const a of shot.actions) {
|
|
103
|
+
if (a.type === 'goto') {
|
|
104
|
+
const url = /^https?:/i.test(a.url) ? a.url : baseUrl + (a.url.startsWith('/') ? a.url : `/${a.url}`);
|
|
105
|
+
await page.goto(url, { waitUntil: 'load', timeout: a.timeout || 30000 });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (a.type === 'waitFor' && a.selector) {
|
|
109
|
+
await page.locator(a.selector).first()
|
|
110
|
+
.waitFor({ state: a.state || 'visible', timeout: a.timeout || 15000 })
|
|
111
|
+
.catch(() => missing.push({ selector: a.selector, shot: shot.id, type: 'waitFor' }));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (a.type === 'setViewport') {
|
|
115
|
+
await page.setViewportSize({ width: a.width, height: a.height });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!a.selector) continue;
|
|
119
|
+
const count = await page.locator(a.selector).count().catch(() => 0);
|
|
120
|
+
if (count === 0) missing.push({ selector: a.selector, shot: shot.id, type: a.type });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return missing;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `--viewport WIDTHxHEIGHT[@SCALE]` → the take's viewport, scale factor and variant name.
|
|
128
|
+
*
|
|
129
|
+
* Separated from the CLI so the parsing and the naming can be asserted without launching
|
|
130
|
+
* a browser. Returns `{ error }` rather than throwing, because the caller reports it and
|
|
131
|
+
* exits with a usage code.
|
|
132
|
+
*/
|
|
133
|
+
export function parseViewport(value) {
|
|
134
|
+
const m = /^(\d{2,4})x(\d{2,4})(?:@(\d(?:\.\d)?))?$/.exec(String(value ?? '').trim());
|
|
135
|
+
if (!m) return { error: `--viewport wants WIDTHxHEIGHT[@SCALE], e.g. 540x960@2 — got "${value}"` };
|
|
136
|
+
|
|
137
|
+
const width = Number(m[1]);
|
|
138
|
+
const height = Number(m[2]);
|
|
139
|
+
const scale = m[3] ? Number(m[3]) : 1;
|
|
140
|
+
if (scale < 1 || scale > 3) return { error: `scale ${scale} is outside 1-3` };
|
|
141
|
+
|
|
142
|
+
const ratio = width / height;
|
|
143
|
+
const suffix =
|
|
144
|
+
Math.abs(ratio - 9 / 16) < 0.02 ? 'vertical'
|
|
145
|
+
: Math.abs(ratio - 1) < 0.02 ? 'square'
|
|
146
|
+
: Math.abs(ratio - 16 / 9) < 0.02 ? 'wide'
|
|
147
|
+
: `${width}x${height}`;
|
|
148
|
+
|
|
149
|
+
// A responsive layout switches on CSS width, not on output pixels. 1080 CSS px is still
|
|
150
|
+
// a desktop: the first vertical take recorded that way kept its two-column layout and
|
|
151
|
+
// left the bottom third of the frame empty — worse than the crop it was meant to
|
|
152
|
+
// replace. Flagged rather than corrected, because a wide vertical take is legitimate
|
|
153
|
+
// when the subject is a 3D stage that does not reflow at all.
|
|
154
|
+
const desktopBreakpoint = width >= 900 && ratio < 1;
|
|
155
|
+
|
|
156
|
+
return { width, height, scale, suffix, desktopBreakpoint, outWidth: width * scale, outHeight: height * scale };
|
|
157
|
+
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static file server for recordings.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately separate from the one inside `tools/runtime-audit.mjs`, which is not
|
|
5
|
+
* importable — that file has no exports and runs its audit on import. Unifying the two
|
|
6
|
+
* means editing the file CI depends on most, so it is a change of its own rather than a
|
|
7
|
+
* passenger on this one. Recording also needs behaviour the audit does not: it serves a
|
|
8
|
+
* chosen document root and honours `cleanUrls` the way `vercel.json` declares it, so a
|
|
9
|
+
* script can say `/library` and get `library.html` exactly as production would.
|
|
10
|
+
*/
|
|
11
|
+
import http from 'http';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
|
|
15
|
+
const MIME = {
|
|
16
|
+
'.html': 'text/html; charset=utf-8',
|
|
17
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
18
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
19
|
+
'.css': 'text/css; charset=utf-8',
|
|
20
|
+
'.json': 'application/json; charset=utf-8',
|
|
21
|
+
'.webp': 'image/webp',
|
|
22
|
+
'.png': 'image/png',
|
|
23
|
+
'.jpg': 'image/jpeg',
|
|
24
|
+
'.jpeg': 'image/jpeg',
|
|
25
|
+
'.gif': 'image/gif',
|
|
26
|
+
'.svg': 'image/svg+xml',
|
|
27
|
+
'.mp4': 'video/mp4',
|
|
28
|
+
'.webm': 'video/webm',
|
|
29
|
+
'.glb': 'model/gltf-binary',
|
|
30
|
+
'.woff': 'font/woff',
|
|
31
|
+
'.woff2': 'font/woff2',
|
|
32
|
+
'.map': 'application/json',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Commerce endpoints answer with a signed-out stub. A recording of the editor should
|
|
37
|
+
* show the real gated state rather than a network error, and no recording should ever
|
|
38
|
+
* depend on live Stripe.
|
|
39
|
+
*/
|
|
40
|
+
function mockApi(pathname, res) {
|
|
41
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
42
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
43
|
+
if (/\/api\/me$/.test(pathname)) {
|
|
44
|
+
res.writeHead(200);
|
|
45
|
+
res.end(JSON.stringify({ authenticated: false, pro: false, plan: null, templates: {}, mock: true }));
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (/\/api\/export-allowed$/.test(pathname)) {
|
|
49
|
+
res.writeHead(200);
|
|
50
|
+
res.end(JSON.stringify({ allowed: false, reason: 'signin', mock: true }));
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (pathname.startsWith('/api/')) {
|
|
54
|
+
res.writeHead(200);
|
|
55
|
+
res.end(JSON.stringify({ ok: true, mock: true }));
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} root document root
|
|
63
|
+
* @returns {Promise<{ base: string, close: () => Promise<void> }>}
|
|
64
|
+
*/
|
|
65
|
+
export async function startServer(root) {
|
|
66
|
+
const server = http.createServer((req, res) => {
|
|
67
|
+
let pathname;
|
|
68
|
+
try {
|
|
69
|
+
pathname = decodeURIComponent(new URL(req.url, 'http://127.0.0.1').pathname);
|
|
70
|
+
} catch {
|
|
71
|
+
res.writeHead(400);
|
|
72
|
+
return res.end('bad request');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (mockApi(pathname, res)) return;
|
|
76
|
+
|
|
77
|
+
let file = path.join(root, pathname);
|
|
78
|
+
// cleanUrls: /library resolves to library.html, matching vercel.json.
|
|
79
|
+
if (!path.extname(file)) {
|
|
80
|
+
if (fs.existsSync(`${file}.html`)) file = `${file}.html`;
|
|
81
|
+
else if (fs.existsSync(path.join(file, 'index.html'))) file = path.join(file, 'index.html');
|
|
82
|
+
}
|
|
83
|
+
if (pathname === '/') file = path.join(root, 'index.html');
|
|
84
|
+
|
|
85
|
+
// Never serve outside the document root, whatever the request path claims.
|
|
86
|
+
const resolved = path.resolve(file);
|
|
87
|
+
if (!resolved.startsWith(path.resolve(root))) {
|
|
88
|
+
res.writeHead(403);
|
|
89
|
+
return res.end('forbidden');
|
|
90
|
+
}
|
|
91
|
+
if (!fs.existsSync(resolved) || fs.statSync(resolved).isDirectory()) {
|
|
92
|
+
res.writeHead(404);
|
|
93
|
+
return res.end('not found');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
res.setHeader('Content-Type', MIME[path.extname(resolved)] || 'application/octet-stream');
|
|
97
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
98
|
+
res.writeHead(200);
|
|
99
|
+
res.end(fs.readFileSync(resolved));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
103
|
+
const { port } = server.address();
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
base: `http://127.0.0.1:${port}`,
|
|
107
|
+
close: () => new Promise((resolve) => server.close(resolve)),
|
|
108
|
+
};
|
|
109
|
+
}
|
package/src/timeline.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two ways time can pass during a recording.
|
|
3
|
+
*
|
|
4
|
+
* Actions do not know which they are running under. They ask the driver to wait, or to
|
|
5
|
+
* animate a value over a duration, and the driver decides whether that means sleeping and
|
|
6
|
+
* hoping the compositor keeps up, or advancing a virtual clock one frame at a time and
|
|
7
|
+
* taking a screenshot per frame. Keeping that decision in one place is what let phase 2
|
|
8
|
+
* land without rewriting the verb vocabulary.
|
|
9
|
+
*
|
|
10
|
+
* realtime — phase 1. Fast to run, output bound to machine speed.
|
|
11
|
+
* deterministic — phase 2. Slow to run, output independent of machine speed and
|
|
12
|
+
* identical across runs.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
|
|
17
|
+
const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
|
18
|
+
|
|
19
|
+
/** Wall-clock pacing. Frames are whatever the screencast delivers alongside. */
|
|
20
|
+
export class RealtimeDriver {
|
|
21
|
+
constructor(page, { fps = 30 } = {}) {
|
|
22
|
+
this.page = page;
|
|
23
|
+
this.frameMs = 1000 / fps;
|
|
24
|
+
this.deterministic = false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async wait(ms) {
|
|
28
|
+
if (ms > 0) await this.page.waitForTimeout(ms);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run `onStep(t)` across `ms`, where t is eased 0..1. Sleeps only the remainder of each
|
|
33
|
+
* step, so the animation takes the duration it says regardless of driver round trips —
|
|
34
|
+
* a fixed sleep per step made every move overrun.
|
|
35
|
+
*/
|
|
36
|
+
async animate(ms, onStep) {
|
|
37
|
+
const steps = Math.max(1, Math.round(ms / this.frameMs));
|
|
38
|
+
const t0 = Date.now();
|
|
39
|
+
for (let i = 1; i <= steps; i++) {
|
|
40
|
+
await onStep(ease(i / steps), i / steps);
|
|
41
|
+
const behind = t0 + (i / steps) * ms - Date.now();
|
|
42
|
+
if (behind > 0) await this.page.waitForTimeout(behind);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Virtual pacing. Every frame is exactly `frameMs` of page time, whatever it cost to
|
|
49
|
+
* produce, and every frame is captured explicitly rather than observed.
|
|
50
|
+
*/
|
|
51
|
+
export class DeterministicDriver {
|
|
52
|
+
constructor(page, { fps = 30, dir, quality = 90, format = 'jpeg' } = {}) {
|
|
53
|
+
this.page = page;
|
|
54
|
+
this.fps = fps;
|
|
55
|
+
this.frameMs = 1000 / fps;
|
|
56
|
+
this.dir = dir;
|
|
57
|
+
this.quality = quality;
|
|
58
|
+
this.format = format;
|
|
59
|
+
this.deterministic = true;
|
|
60
|
+
this.frames = [];
|
|
61
|
+
this.capturing = false;
|
|
62
|
+
this.virtualMs = 0;
|
|
63
|
+
this.realMsInFrames = 0;
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
startCapture() { this.capturing = true; }
|
|
68
|
+
stopCapture() { this.capturing = false; }
|
|
69
|
+
|
|
70
|
+
/** Advance one frame of page time, then record what it painted. */
|
|
71
|
+
async tick() {
|
|
72
|
+
const realStart = Date.now();
|
|
73
|
+
await this.page.evaluate((dt) => window.__clock && window.__clock.tick(dt), this.frameMs);
|
|
74
|
+
this.virtualMs += this.frameMs;
|
|
75
|
+
|
|
76
|
+
if (this.capturing) {
|
|
77
|
+
const index = this.frames.length;
|
|
78
|
+
const ext = this.format === 'png' ? 'png' : 'jpg';
|
|
79
|
+
const file = path.join(this.dir, `f${String(index).padStart(6, '0')}.${ext}`);
|
|
80
|
+
// A screenshot forces a paint, which is what makes the frame we just computed
|
|
81
|
+
// actually exist. This is also the slow part, and deliberately so — wall-clock cost
|
|
82
|
+
// stops mattering once the output no longer depends on it.
|
|
83
|
+
const buf = await this.page.screenshot(
|
|
84
|
+
this.format === 'png' ? { type: 'png' } : { type: 'jpeg', quality: this.quality }
|
|
85
|
+
);
|
|
86
|
+
fs.writeFileSync(file, buf);
|
|
87
|
+
this.frames.push({ file, t: this.virtualMs / 1000 });
|
|
88
|
+
}
|
|
89
|
+
this.realMsInFrames += Date.now() - realStart;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async wait(ms) {
|
|
93
|
+
const frames = Math.max(0, Math.round(ms / this.frameMs));
|
|
94
|
+
for (let i = 0; i < frames; i++) await this.tick();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async animate(ms, onStep) {
|
|
98
|
+
const steps = Math.max(1, Math.round(ms / this.frameMs));
|
|
99
|
+
for (let i = 1; i <= steps; i++) {
|
|
100
|
+
await onStep(ease(i / steps), i / steps);
|
|
101
|
+
await this.tick();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
summary() {
|
|
106
|
+
return {
|
|
107
|
+
count: this.frames.length,
|
|
108
|
+
dropped: 0,
|
|
109
|
+
durationSec: this.frames.length / this.fps,
|
|
110
|
+
fps: this.fps,
|
|
111
|
+
motionFps: this.fps,
|
|
112
|
+
heldFrames: 0,
|
|
113
|
+
deterministic: true,
|
|
114
|
+
// How long the machine actually took per frame. Once output is decoupled from
|
|
115
|
+
// speed this is the only place heaviness is still visible, and it is worth
|
|
116
|
+
// surfacing rather than hiding — it is what says a scene is expensive.
|
|
117
|
+
realMsPerFrame: this.frames.length ? Number((this.realMsInFrames / this.frames.length).toFixed(1)) : 0,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|