@amg555/frameforge-mcp 2.3.2 → 2.3.3

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.
Files changed (48) hide show
  1. package/.mcp.json +2 -4
  2. package/CHANGELOG.md +1 -1
  3. package/CLAUDE.md +104 -26
  4. package/README.md +16 -12
  5. package/SECURITY.md +1 -3
  6. package/package.json +7 -3
  7. package/plugins/video-pipeline/.claude-plugin/plugin.json +19 -0
  8. package/plugins/video-pipeline/skills/html-renderer/SKILL.md +292 -0
  9. package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +308 -0
  10. package/plugins/video-pipeline/skills/video-pipeline/assets/project/ANIMATION_GUIDE.md +257 -0
  11. package/plugins/video-pipeline/skills/video-pipeline/assets/project/STORYBOARD.md +77 -0
  12. package/plugins/video-pipeline/skills/video-pipeline/assets/project/index.html +80 -0
  13. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/diagram-author.md +43 -0
  14. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +56 -0
  15. package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +42 -0
  16. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.css +140 -0
  17. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +870 -0
  18. package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +138 -0
  19. package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +167 -0
  20. package/plugins/video-pipeline/skills/video-pipeline/references/providers.md +163 -0
  21. package/scripts/db-migrate.js +112 -0
  22. package/scripts/deep-validate.js +326 -0
  23. package/scripts/generate-initial-assets.js +342 -0
  24. package/scripts/html-renderer/new-project.mjs +408 -0
  25. package/scripts/html-renderer/transcribe.py +294 -0
  26. package/src/asset-library/README.md +2 -2
  27. package/src/asset-library/asset-library.js +1 -1
  28. package/src/cli/make.js +52 -71
  29. package/src/cli/setup-keys.js +15 -17
  30. package/src/fact-checker/review-gate.js +5 -1
  31. package/src/harness/bin/harness.mjs +324 -0
  32. package/src/harness/lib/browser.mjs +137 -0
  33. package/src/harness/lib/capture.mjs +161 -0
  34. package/src/harness/lib/encode.mjs +89 -0
  35. package/src/harness/lib/jobs.mjs +135 -0
  36. package/src/harness/lib/pixels.mjs +137 -0
  37. package/src/harness/lib/sheets.mjs +164 -0
  38. package/src/harness/lib/verify-purity.mjs +119 -0
  39. package/src/harness/test/fixtures/broken-scene.html +27 -0
  40. package/src/harness/test/fixtures/reference-scene.html +23 -0
  41. package/src/harness/test/fixtures/sample-script.json +18 -0
  42. package/src/mcp-server/server.js +285 -144
  43. package/src/pipeline/telemetry.js +1 -0
  44. package/src/utils/log-redaction.js +5 -3
  45. package/src/video-renderer/renderer.js +12 -4
  46. package/src/worker/server-render-free.js +2 -3
  47. package/src/workflows/assets.js +20 -43
  48. package/src/workflows/ingest.js +5 -5
@@ -0,0 +1,89 @@
1
+ /**
2
+ * encode.mjs — FFmpeg Argv Builders & Final Master Video Encoder
3
+ *
4
+ * Enforces broadcast-standard BT.709 color metadata, explicit -t duration (avoiding
5
+ * the -shortest 0% CPU hang trap), and accurate multi-track audio muxing.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { spawnSync, spawn } from 'child_process';
11
+
12
+ /**
13
+ * Build FFmpeg argv for encoding a directory of image frames into an MP4.
14
+ */
15
+ export function buildFramesEncodeArgs({
16
+ framesPattern,
17
+ duration,
18
+ fps = 30,
19
+ crf = 17,
20
+ preset = 'slow',
21
+ audioPath = null,
22
+ outPath,
23
+ }) {
24
+ const args = [
25
+ '-y',
26
+ '-framerate', String(fps),
27
+ '-pattern_type', 'sequence',
28
+ '-i', framesPattern,
29
+ ];
30
+
31
+ if (audioPath && fs.existsSync(audioPath)) {
32
+ args.push(
33
+ '-i', audioPath,
34
+ '-c:a', 'aac',
35
+ '-b:a', '192k',
36
+ '-ar', '48000',
37
+ '-ac', '2',
38
+ '-map', '0:v:0',
39
+ '-map', '1:a:0'
40
+ );
41
+ } else {
42
+ args.push('-an');
43
+ }
44
+
45
+ args.push(
46
+ '-c:v', 'libx264',
47
+ '-preset', preset,
48
+ '-crf', String(crf),
49
+ '-profile:v', 'high',
50
+ '-level', '4.1',
51
+ '-pix_fmt', 'yuv420p',
52
+ // BT.709 Color Space Tags (Prevents player color shift)
53
+ '-color_range', 'tv',
54
+ '-colorspace', 'bt709',
55
+ '-color_primaries', 'bt709',
56
+ '-color_trc', 'bt709',
57
+ '-x264-params', 'colorprim=bt709:transfer=bt709:colormatrix=bt709',
58
+ '-movflags', '+faststart',
59
+ // Explicit duration prevents -shortest hang trap
60
+ '-t', String(duration),
61
+ outPath
62
+ );
63
+
64
+ return args;
65
+ }
66
+
67
+ /**
68
+ * Execute FFmpeg encode from frame sequence to MP4 master.
69
+ */
70
+ export async function encodeFramesToMp4(opts) {
71
+ const args = buildFramesEncodeArgs(opts);
72
+ const res = spawnSync('ffmpeg', args, {
73
+ maxBuffer: 1024 * 1024 * 64,
74
+ encoding: 'utf8',
75
+ });
76
+
77
+ if (res.status !== 0) {
78
+ throw new Error(`FFmpeg master encode failed:\n${res.stderr || res.stdout}`);
79
+ }
80
+
81
+ const stat = fs.statSync(opts.outPath);
82
+ return {
83
+ outPath: opts.outPath,
84
+ sizeBytes: stat.size,
85
+ duration: opts.duration,
86
+ fps: opts.fps,
87
+ };
88
+ }
89
+
@@ -0,0 +1,135 @@
1
+ /**
2
+ * jobs.mjs — Resumable, Atomic Frame Queue & Worker Pool
3
+ *
4
+ * Implements atomic writes (.tmp + renameSync) and corruption checks (<1000 bytes)
5
+ * so interrupted renders can resume without re-rendering valid frames or trusting truncated ones.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { openPageWithVirtualClock, captureFrame } from './capture.mjs';
11
+
12
+ /**
13
+ * Render a frame range across a pool of parallel Playwright pages.
14
+ *
15
+ * @param {object} opts
16
+ * @param {object} opts.browser Playwright Browser instance
17
+ * @param {string} opts.htmlPath Path to index.html or studio.html
18
+ * @param {string} opts.framesDir Directory where frame files land
19
+ * @param {number} opts.first First frame index (0-based)
20
+ * @param {number} opts.last Last frame index (inclusive)
21
+ * @param {number} opts.fps Frames per second
22
+ * @param {number} [opts.workers=4] Number of parallel worker pages
23
+ * @param {number} [opts.width=1920] Frame width
24
+ * @param {number} [opts.height=1080] Frame height
25
+ * @param {string} [opts.format='jpg'] 'jpg' or 'png'
26
+ * @param {number} [opts.quality=0.94] JPEG quality
27
+ * @param {function} [opts.onProgress] (done, total, frameIndex) => void
28
+ * @returns {Promise<{ total: number, rendered: number, skipped: number, frames: string[] }>}
29
+ */
30
+ export async function renderFrameJobs({
31
+ browser,
32
+ htmlPath,
33
+ framesDir,
34
+ first = 0,
35
+ last,
36
+ fps = 30,
37
+ workers = 4,
38
+ width = 1920,
39
+ height = 1080,
40
+ format = 'jpg',
41
+ quality = 0.94,
42
+ onProgress = null,
43
+ }) {
44
+ fs.mkdirSync(framesDir, { recursive: true });
45
+
46
+ const ext = format === 'png' ? 'png' : 'jpg';
47
+ const mimeType = format === 'png' ? 'image/png' : 'image/jpeg';
48
+
49
+ const todo = [];
50
+ let skipped = 0;
51
+
52
+ for (let i = first; i <= last; i++) {
53
+ const filename = `f${String(i).padStart(6, '0')}.${ext}`;
54
+ const targetFile = path.join(framesDir, filename);
55
+
56
+ let existsAndValid = false;
57
+ try {
58
+ if (fs.existsSync(targetFile)) {
59
+ const stat = fs.statSync(targetFile);
60
+ if (stat.size >= 1000) {
61
+ existsAndValid = true;
62
+ }
63
+ }
64
+ } catch (_) {}
65
+
66
+ if (existsAndValid) {
67
+ skipped++;
68
+ } else {
69
+ todo.push(i);
70
+ }
71
+ }
72
+
73
+ const totalFrames = last - first + 1;
74
+ if (todo.length === 0) {
75
+ return {
76
+ total: totalFrames,
77
+ rendered: 0,
78
+ skipped,
79
+ frames: fs.readdirSync(framesDir).filter(f => f.startsWith('f') && f.endsWith(`.${ext}`)).map(f => path.join(framesDir, f)),
80
+ };
81
+ }
82
+
83
+ const workerCount = Math.min(workers, todo.length);
84
+ const pages = await Promise.all(
85
+ Array.from({ length: workerCount }, () =>
86
+ openPageWithVirtualClock(browser, htmlPath, { width, height })
87
+ )
88
+ );
89
+
90
+ let nextIdx = 0;
91
+ let doneCount = skipped;
92
+
93
+ await Promise.all(
94
+ pages.map(async (page) => {
95
+ while (true) {
96
+ let frameIdx;
97
+ if (nextIdx < todo.length) {
98
+ frameIdx = todo[nextIdx++];
99
+ } else {
100
+ break;
101
+ }
102
+
103
+ const t = frameIdx / fps;
104
+ const filename = `f${String(frameIdx).padStart(6, '0')}.${ext}`;
105
+ const targetFile = path.join(framesDir, filename);
106
+ const tmpFile = `${targetFile}.tmp`;
107
+
108
+ const buf = await captureFrame(page, t, { type: mimeType, quality });
109
+
110
+ // Atomic write: write to temp file then rename
111
+ fs.writeFileSync(tmpFile, buf);
112
+ fs.renameSync(tmpFile, targetFile);
113
+
114
+ doneCount++;
115
+ if (onProgress) {
116
+ onProgress(doneCount, totalFrames, frameIdx);
117
+ }
118
+ }
119
+ await page.close();
120
+ })
121
+ );
122
+
123
+ const allFrames = fs.readdirSync(framesDir)
124
+ .filter(f => f.startsWith('f') && f.endsWith(`.${ext}`))
125
+ .sort()
126
+ .map(f => path.join(framesDir, f));
127
+
128
+ return {
129
+ total: totalFrames,
130
+ rendered: todo.length,
131
+ skipped,
132
+ frames: allFrames,
133
+ };
134
+ }
135
+
@@ -0,0 +1,137 @@
1
+ /**
2
+ * pixels.mjs — Frame Content & Color Inspector
3
+ *
4
+ * For inspecting visual content when the model cannot view image media directly.
5
+ * Catches blank frames, blown flashes, and quadrant-confined content.
6
+ */
7
+
8
+ import { spawnSync } from 'child_process';
9
+ import fs from 'fs';
10
+
11
+ /**
12
+ * Analyze an RGB24 buffer.
13
+ * @param {Buffer|Uint8Array} buf RGB raw bytes (length = width * height * 3)
14
+ * @param {number} width
15
+ * @param {number} height
16
+ * @returns {{
17
+ * meanColor: [number, number, number],
18
+ * brightPixelShare: number,
19
+ * bbox: [number, number, number, number]|null,
20
+ * isBlank: boolean,
21
+ * isBlown: boolean,
22
+ * isCornerConfined: boolean,
23
+ * flags: string[]
24
+ * }}
25
+ */
26
+ export function analyzeRgbBuffer(buf, width, height) {
27
+ const totalPixels = width * height;
28
+ if (!buf || buf.length < totalPixels * 3) {
29
+ throw new Error(`Buffer size ${buf?.length} too small for ${width}x${height} RGB24`);
30
+ }
31
+
32
+ let rSum = 0;
33
+ let gSum = 0;
34
+ let bSum = 0;
35
+ let brightCount = 0;
36
+
37
+ let minX = width;
38
+ let minY = height;
39
+ let maxX = -1;
40
+ let maxY = -1;
41
+
42
+ for (let i = 0; i < totalPixels; i++) {
43
+ const idx = i * 3;
44
+ const r = buf[idx];
45
+ const g = buf[idx + 1];
46
+ const b = buf[idx + 2];
47
+
48
+ rSum += r;
49
+ gSum += g;
50
+ bSum += b;
51
+
52
+ // Perceived luminance (ITU-R BT.709)
53
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
54
+
55
+ if (lum > 200) {
56
+ brightCount++;
57
+ }
58
+
59
+ if (lum > 30) {
60
+ const x = i % width;
61
+ const y = Math.floor(i / width);
62
+ if (x < minX) minX = x;
63
+ if (x > maxX) maxX = x;
64
+ if (y < minY) minY = y;
65
+ if (y > maxY) maxY = y;
66
+ }
67
+ }
68
+
69
+ const meanR = Math.round(rSum / totalPixels);
70
+ const meanG = Math.round(gSum / totalPixels);
71
+ const meanB = Math.round(bSum / totalPixels);
72
+ const brightPixelShare = brightCount / totalPixels;
73
+
74
+ const hasContent = maxX >= 0 && maxY >= 0;
75
+ const bbox = hasContent ? [minX, minY, maxX, maxY] : null;
76
+
77
+ const meanLum = 0.2126 * meanR + 0.7152 * meanG + 0.0722 * meanB;
78
+ const isBlank = !hasContent || (meanLum < 5 && brightPixelShare < 0.001);
79
+ const isBlown = brightPixelShare > 0.92;
80
+
81
+ let isCornerConfined = false;
82
+ if (hasContent && !isBlank && !isBlown) {
83
+ const halfW = width * 0.55;
84
+ const halfH = height * 0.55;
85
+ const inTopLeft = maxX < halfW && maxY < halfH;
86
+ const inTopRight = minX > width - halfW && maxY < halfH;
87
+ const inBottomLeft = maxX < halfW && minY > height - halfH;
88
+ const inBottomRight = minX > width - halfW && minY > height - halfH;
89
+ isCornerConfined = inTopLeft || inTopRight || inBottomLeft || inBottomRight;
90
+ }
91
+
92
+ const flags = [];
93
+ if (isBlank) flags.push('blank_frame');
94
+ if (isBlown) flags.push('blown_frame');
95
+ if (isCornerConfined) flags.push('corner_confined');
96
+
97
+ return {
98
+ meanColor: [meanR, meanG, meanB],
99
+ brightPixelShare: parseFloat(brightPixelShare.toFixed(4)),
100
+ bbox,
101
+ isBlank,
102
+ isBlown,
103
+ isCornerConfined,
104
+ flags,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Analyze an image file on disk using FFmpeg rawvideo pipe.
110
+ * @param {string} filePath
111
+ * @param {number} [width=1920]
112
+ * @param {number} [height=1080]
113
+ */
114
+ export function analyzeImageFile(filePath, width = 1920, height = 1080) {
115
+ if (!fs.existsSync(filePath)) {
116
+ throw new Error(`File not found: ${filePath}`);
117
+ }
118
+
119
+ const res = spawnSync('ffmpeg', [
120
+ '-v', 'error',
121
+ '-i', filePath,
122
+ '-s', `${width}x${height}`,
123
+ '-f', 'rawvideo',
124
+ '-pix_fmt', 'rgb24',
125
+ '-'
126
+ ], {
127
+ maxBuffer: width * height * 3 + 1024,
128
+ encoding: 'buffer'
129
+ });
130
+
131
+ if (res.status !== 0 || !res.stdout) {
132
+ throw new Error(`FFmpeg pixel extraction failed: ${res.stderr?.toString() || 'Unknown error'}`);
133
+ }
134
+
135
+ return analyzeRgbBuffer(res.stdout, width, height);
136
+ }
137
+
@@ -0,0 +1,164 @@
1
+ /**
2
+ * sheets.mjs — Visual Review Artifact Compositor
3
+ *
4
+ * Generates contact sheets, motion strips, full-res crops, and 4-keyframe storyboards
5
+ * for human/agent review before full encode.
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { spawnSync } from 'child_process';
11
+ import { captureFrame } from './capture.mjs';
12
+
13
+ function pad6(n) {
14
+ return String(n).padStart(6, '0');
15
+ }
16
+
17
+ /**
18
+ * Render contact sheet of given timestamps.
19
+ */
20
+ export async function renderSheet({
21
+ page,
22
+ times,
23
+ cols = 6,
24
+ thumbWidth = 320,
25
+ aspectRatio = 16 / 9,
26
+ outPath,
27
+ }) {
28
+ const thumbHeight = Math.round(thumbWidth / aspectRatio);
29
+ const rows = Math.ceil(times.length / cols);
30
+
31
+ const tmpDir = path.join(path.dirname(outPath), `_sheet_tmp_${Date.now()}`);
32
+ fs.mkdirSync(tmpDir, { recursive: true });
33
+
34
+ for (let i = 0; i < times.length; i++) {
35
+ const buf = await captureFrame(page, times[i]);
36
+ fs.writeFileSync(path.join(tmpDir, `f_${pad6(i)}.png`), buf);
37
+ }
38
+
39
+ const res = spawnSync('ffmpeg', [
40
+ '-y',
41
+ '-i', path.join(tmpDir, 'f_%06d.png'),
42
+ '-vf', `scale=${thumbWidth}:${thumbHeight},tile=${cols}x${rows}`,
43
+ '-q:v', '3',
44
+ outPath,
45
+ ]);
46
+
47
+ fs.rmSync(tmpDir, { recursive: true, force: true });
48
+
49
+ if (res.status !== 0) {
50
+ throw new Error(`Sheet generation failed: ${res.stderr?.toString()}`);
51
+ }
52
+
53
+ return outPath;
54
+ }
55
+
56
+ /**
57
+ * Render motion strip: every frame across a time range [start, end].
58
+ */
59
+ export async function renderStrip({
60
+ page,
61
+ start,
62
+ end,
63
+ fps = 30,
64
+ thumbWidth = 320,
65
+ aspectRatio = 16 / 9,
66
+ outPath,
67
+ }) {
68
+ const thumbHeight = Math.round(thumbWidth / aspectRatio);
69
+ const startF = Math.round(start * fps);
70
+ const endF = Math.round(end * fps);
71
+ const count = Math.max(1, endF - startF + 1);
72
+
73
+ const tmpDir = path.join(path.dirname(outPath), `_strip_tmp_${Date.now()}`);
74
+ fs.mkdirSync(tmpDir, { recursive: true });
75
+
76
+ for (let i = 0; i < count; i++) {
77
+ const t = (startF + i) / fps;
78
+ const buf = await captureFrame(page, t);
79
+ fs.writeFileSync(path.join(tmpDir, `f_${pad6(i)}.png`), buf);
80
+ }
81
+
82
+ const res = spawnSync('ffmpeg', [
83
+ '-y',
84
+ '-i', path.join(tmpDir, 'f_%06d.png'),
85
+ '-vf', `scale=${thumbWidth}:${thumbHeight},tile=${count}x1`,
86
+ '-q:v', '3',
87
+ outPath,
88
+ ]);
89
+
90
+ fs.rmSync(tmpDir, { recursive: true, force: true });
91
+
92
+ if (res.status !== 0) {
93
+ throw new Error(`Strip generation failed: ${res.stderr?.toString()}`);
94
+ }
95
+
96
+ return outPath;
97
+ }
98
+
99
+ /**
100
+ * Render full-res crop region or ratio framing at time t.
101
+ */
102
+ export async function renderCrop({
103
+ page,
104
+ time,
105
+ crop = null, // [x, y, w, h] or ratio string '9:16'
106
+ outPath,
107
+ }) {
108
+ const buf = await captureFrame(page, time);
109
+ const rawPath = `${outPath}.raw.png`;
110
+ fs.writeFileSync(rawPath, buf);
111
+
112
+ let vfFilter;
113
+ if (Array.isArray(crop) && crop.length === 4) {
114
+ const [x, y, w, h] = crop;
115
+ vfFilter = `crop=${w}:${h}:${x}:${y}`;
116
+ } else if (crop === '9:16') {
117
+ vfFilter = 'crop=ih*9/16:ih:(iw-ow)/2:0';
118
+ } else {
119
+ vfFilter = 'null';
120
+ }
121
+
122
+ const res = spawnSync('ffmpeg', [
123
+ '-y',
124
+ '-i', rawPath,
125
+ '-vf', vfFilter,
126
+ outPath,
127
+ ]);
128
+
129
+ try { fs.unlinkSync(rawPath); } catch (_) {}
130
+
131
+ if (res.status !== 0) {
132
+ throw new Error(`Crop generation failed: ${res.stderr?.toString()}`);
133
+ }
134
+
135
+ return outPath;
136
+ }
137
+
138
+ /**
139
+ * Render standard 4-keyframe storyboard (0%, 33%, 66%, 100%) in 2x2 grid.
140
+ */
141
+ export async function renderStoryboard({
142
+ page,
143
+ duration,
144
+ thumbWidth = 480,
145
+ aspectRatio = 16 / 9,
146
+ outPath,
147
+ }) {
148
+ const times = [
149
+ 0,
150
+ parseFloat((duration * 0.33).toFixed(2)),
151
+ parseFloat((duration * 0.66).toFixed(2)),
152
+ Math.max(0, parseFloat((duration - 0.05).toFixed(2))),
153
+ ];
154
+
155
+ return renderSheet({
156
+ page,
157
+ times,
158
+ cols: 2,
159
+ thumbWidth,
160
+ aspectRatio,
161
+ outPath,
162
+ });
163
+ }
164
+
@@ -0,0 +1,119 @@
1
+ /**
2
+ * verify-purity.mjs — 3-Point History Leak & Purity Verification Gate
3
+ *
4
+ * Enforces the core invariant: Every frame must be a pure function of t.
5
+ * Tests 3 points per timestamp:
6
+ * 1. Hash at t (a)
7
+ * 2. Immediate repeat at t (b)
8
+ * 3. Wander off to (t + duration/2) % duration
9
+ * 4. Cold jump back to t (c)
10
+ * Condition: a === b && a === c.
11
+ *
12
+ * If a !== b, frame varies on repeated calls (unseeded randomness / unconstrained timers).
13
+ * If a !== c, history leaked into the draw path (retained state, incremental physics).
14
+ */
15
+
16
+ import { createHash } from 'crypto';
17
+ import { openPageWithVirtualClock, captureFrame } from './capture.mjs';
18
+
19
+ /**
20
+ * Verify frame purity across a set of sampled timestamps.
21
+ *
22
+ * @param {object} opts
23
+ * @param {object} opts.browser Playwright browser instance
24
+ * @param {string} opts.htmlPath Path to HTML file
25
+ * @param {number} [opts.duration] Scene duration (seconds, default reads window.DURATION)
26
+ * @param {number[]} [opts.times] Specific times to test (defaults to 9 sampled points)
27
+ * @param {number} [opts.width=640] Test render width (lower resolution is faster for hash comparison)
28
+ * @param {number} [opts.height=360] Test render height
29
+ * @returns {Promise<{
30
+ * passed: boolean,
31
+ * timesTested: number,
32
+ * results: Array<{ time: number, a: string, b: string, c: string, ok: boolean, failure?: string }>,
33
+ * framesSha256?: string
34
+ * }>}
35
+ */
36
+ export async function verifyScenePurity({
37
+ browser,
38
+ htmlPath,
39
+ duration = null,
40
+ times = null,
41
+ width = null,
42
+ height = null,
43
+ }) {
44
+ // Initial page probe for dimensions
45
+ const probe = await openPageWithVirtualClock(browser, htmlPath, { width: 1080, height: 1080 });
46
+ const dims = await probe.evaluate(() => {
47
+ const c = document.querySelector('canvas');
48
+ return {
49
+ w: c?.width || window.REEL?.width || 1080,
50
+ h: c?.height || window.REEL?.height || 1080,
51
+ dur: window.DURATION || window.REEL?.duration || 2.0,
52
+ };
53
+ });
54
+ await probe.close();
55
+
56
+ const resolvedWidth = width || dims.w;
57
+ const resolvedHeight = height || dims.h;
58
+ const resolvedDuration = duration || dims.dur;
59
+
60
+ const page = await openPageWithVirtualClock(browser, htmlPath, {
61
+ width: resolvedWidth,
62
+ height: resolvedHeight,
63
+ });
64
+
65
+ const sampleTimes = times || [
66
+ 0.05 * resolvedDuration,
67
+ 0.15 * resolvedDuration,
68
+ 0.28 * resolvedDuration,
69
+ 0.40 * resolvedDuration,
70
+ 0.50 * resolvedDuration,
71
+ 0.62 * resolvedDuration,
72
+ 0.75 * resolvedDuration,
73
+ 0.88 * resolvedDuration,
74
+ 0.95 * resolvedDuration,
75
+ ].map(t => parseFloat(t.toFixed(3)));
76
+
77
+ async function getFrameHash(t) {
78
+ const buf = await captureFrame(page, t, { type: 'image/png' });
79
+ return createHash('sha256').update(buf).digest('hex').slice(0, 16);
80
+ }
81
+
82
+ const results = [];
83
+ let allPassed = true;
84
+ const hashDigest = createHash('sha256');
85
+
86
+ for (const t of sampleTimes) {
87
+ // 1. Initial render at t
88
+ const a = await getFrameHash(t);
89
+
90
+ // 2. Immediate repeat at t
91
+ const b = await getFrameHash(t);
92
+
93
+ // 3. Wander off to opposite half of timeline
94
+ const wanderTime = parseFloat(((t + resolvedDuration / 2) % resolvedDuration).toFixed(3));
95
+ await getFrameHash(wanderTime);
96
+
97
+ // 4. Cold jump back to t
98
+ const c = await getFrameHash(t);
99
+
100
+ const ok = (a === b && a === c);
101
+ if (!ok) allPassed = false;
102
+
103
+ let failure = null;
104
+ if (a !== b) failure = 'Non-deterministic: repeat call at same t gave different frame';
105
+ else if (a !== c) failure = 'History leak: frame changed after rendering other timestamps';
106
+
107
+ results.push({ time: t, a, b, c, ok, failure });
108
+ hashDigest.update(a);
109
+ }
110
+
111
+ await page.close();
112
+
113
+ return {
114
+ passed: allPassed,
115
+ timesTested: sampleTimes.length,
116
+ results,
117
+ framesSha256: hashDigest.digest('hex').slice(0, 16),
118
+ };
119
+ }
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <style>body { margin: 0; background: #0c1116; }</style>
6
+ </head>
7
+ <body>
8
+ <canvas id="frame" width="1080" height="1080"></canvas>
9
+ <script>
10
+ window.DURATION = 2;
11
+ const ctx = document.querySelector("#frame").getContext("2d");
12
+ let historyAccumulator = 0;
13
+
14
+ // Broken: relies on rAF loop and unseeded Math.random + accumulated state
15
+ window.seek = async (t) => {
16
+ historyAccumulator += Math.random() * 50; // History leak & randomness!
17
+ ctx.fillStyle = "#0c1116";
18
+ ctx.fillRect(0, 0, 1080, 1080);
19
+ ctx.fillStyle = "#ff5555";
20
+ ctx.beginPath();
21
+ ctx.arc(120 + 400 * t + historyAccumulator, 540, 80, 0, 2 * Math.PI);
22
+ ctx.fill();
23
+ };
24
+ </script>
25
+ </body>
26
+ </html>
27
+
@@ -0,0 +1,23 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <style>body { margin: 0; background: #0c1116; overflow: hidden; }</style>
6
+ </head>
7
+ <body>
8
+ <canvas id="frame" width="1080" height="1080"></canvas>
9
+ <script>
10
+ window.DURATION = 2;
11
+ const ctx = document.querySelector("#frame").getContext("2d");
12
+ window.seek = async (t) => {
13
+ ctx.fillStyle = "#0c1116";
14
+ ctx.fillRect(0, 0, 1080, 1080);
15
+ ctx.fillStyle = "#5be2a1";
16
+ ctx.beginPath();
17
+ ctx.arc(120 + 400 * t, 540, 80, 0, 2 * Math.PI);
18
+ ctx.fill();
19
+ };
20
+ </script>
21
+ </body>
22
+ </html>
23
+