@amg555/frameforge-mcp 2.4.10 → 2.7.1
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/.claude-plugin/marketplace.json +22 -4
- package/CHANGELOG.md +174 -0
- package/CLAUDE.md +192 -9
- package/README.md +47 -6
- package/package.json +4 -3
- package/plugins/video-pipeline/.claude-plugin/plugin.json +8 -2
- package/plugins/video-pipeline/skills/.upstream-skills.json +143 -0
- package/plugins/video-pipeline/skills/html-renderer/SKILL.md +59 -15
- package/plugins/video-pipeline/skills/html-renderer/references/animation-patterns.md +609 -0
- package/plugins/video-pipeline/skills/html-renderer/references/character-animation.md +388 -0
- package/plugins/video-pipeline/skills/html-renderer/references/collage-broll.md +41 -0
- package/plugins/video-pipeline/skills/html-renderer/references/design.md +217 -0
- package/plugins/video-pipeline/skills/html-renderer/references/directing.md +165 -0
- package/plugins/video-pipeline/skills/html-renderer/references/earshot-audio.md +201 -0
- package/plugins/video-pipeline/skills/html-renderer/references/engine-api.md +480 -0
- package/plugins/video-pipeline/skills/html-renderer/references/film-styles.md +95 -0
- package/plugins/video-pipeline/skills/html-renderer/references/hyperframes-motion.md +208 -0
- package/plugins/video-pipeline/skills/html-renderer/references/onetake-camera.md +59 -0
- package/plugins/video-pipeline/skills/html-renderer/references/procedural-sfx.md +135 -0
- package/plugins/video-pipeline/skills/html-renderer/references/remotion-patterns.md +159 -0
- package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +19 -2
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +15 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +21 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +1293 -20
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +36 -6
- package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +126 -2
- package/scripts/deep-validate.js +52 -2
- package/src/audio-mixer/mixer.js +594 -63
- package/src/cli/doctor.js +36 -2
- package/src/cli/make.js +113 -9
- package/src/harness/bin/harness.mjs +127 -5
- package/src/harness/lib/capture.mjs +215 -192
- package/src/harness/lib/verify-quality.mjs +47 -7
- package/src/mcp-server/server.js +1015 -59
- package/src/skills/sync-upstream.js +290 -0
- package/src/utils/media-downloader.js +162 -0
- package/src/utils/transcribe.js +341 -0
- package/src/video-editor/editor.js +96 -11
- package/src/workflows/ingest.js +10 -0
- package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +0 -464
- package/out/check/purity_report.json +0 -79
- package/out/frames/f000000.jpg +0 -0
- package/out/frames/f000001.jpg +0 -0
- package/out/frames/f000002.jpg +0 -0
- package/out/frames/f000003.jpg +0 -0
- package/out/frames/f000004.jpg +0 -0
- package/out/frames/f000005.jpg +0 -0
- package/out/frames/f000006.jpg +0 -0
- package/out/frames/f000007.jpg +0 -0
- package/out/frames/f000008.jpg +0 -0
- package/out/frames/f000009.jpg +0 -0
- package/out/frames/f000010.jpg +0 -0
- package/out/frames/f000011.jpg +0 -0
- package/out/frames/f000012.jpg +0 -0
- package/out/frames/f000013.jpg +0 -0
- package/out/frames/f000014.jpg +0 -0
- package/out/frames/f000015.jpg +0 -0
- package/out/frames/f000016.jpg +0 -0
- package/out/frames/f000017.jpg +0 -0
- package/out/frames/f000018.jpg +0 -0
- package/out/frames/f000019.jpg +0 -0
- package/out/frames/f000020.jpg +0 -0
- package/out/frames/f000021.jpg +0 -0
- package/out/frames/f000022.jpg +0 -0
- package/out/frames/f000023.jpg +0 -0
- package/out/frames/f000024.jpg +0 -0
- package/out/frames/f000025.jpg +0 -0
- package/out/frames/f000026.jpg +0 -0
- package/out/frames/f000027.jpg +0 -0
- package/out/frames/f000028.jpg +0 -0
- package/out/frames/f000029.jpg +0 -0
- package/out/frames/f000030.jpg +0 -0
- package/out/frames/f000031.jpg +0 -0
- package/out/frames/f000032.jpg +0 -0
- package/out/frames/f000033.jpg +0 -0
- package/out/frames/f000034.jpg +0 -0
- package/out/frames/f000035.jpg +0 -0
- package/out/frames/f000036.jpg +0 -0
- package/out/frames/f000037.jpg +0 -0
- package/out/frames/f000038.jpg +0 -0
- package/out/frames/f000039.jpg +0 -0
- package/out/frames/f000040.jpg +0 -0
- package/out/frames/f000041.jpg +0 -0
- package/out/frames/f000042.jpg +0 -0
- package/out/frames/f000043.jpg +0 -0
- package/out/frames/f000044.jpg +0 -0
- package/out/frames/f000045.jpg +0 -0
- package/out/frames/f000046.jpg +0 -0
- package/out/frames/f000047.jpg +0 -0
- package/out/frames/f000048.jpg +0 -0
- package/out/frames/f000049.jpg +0 -0
- package/out/frames/f000050.jpg +0 -0
- package/out/frames/f000051.jpg +0 -0
- package/out/frames/f000052.jpg +0 -0
- package/out/frames/f000053.jpg +0 -0
- package/out/frames/f000054.jpg +0 -0
- package/out/frames/f000055.jpg +0 -0
- package/out/frames/f000056.jpg +0 -0
- package/out/frames/f000057.jpg +0 -0
- package/out/frames/f000058.jpg +0 -0
- package/out/frames/f000059.jpg +0 -0
- package/src/harness/test/fixtures/broken-scene.html +0 -27
- package/src/harness/test/fixtures/reference-scene.html +0 -23
- package/src/harness/test/fixtures/sample-script.json +0 -18
|
@@ -1,192 +1,215 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* capture.mjs — Virtual Clock Injection & Deterministic Frame Stepper
|
|
3
|
-
*
|
|
4
|
-
* Replaces Date.now, performance.now, requestAnimationFrame, setTimeout/setInterval,
|
|
5
|
-
* and CSS animations with a virtual clock. Supports both canvas/WebGL renderAt(t)
|
|
6
|
-
* and DOM/canvas seek(t).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import fs from 'fs';
|
|
10
|
-
import path from 'path';
|
|
11
|
-
import { pathToFileURL } from 'url';
|
|
12
|
-
|
|
13
|
-
export const VIRTUAL_CLOCK_INJECTION = `
|
|
14
|
-
(function () {
|
|
15
|
-
let _now = 0;
|
|
16
|
-
let _rafQueue = new Map();
|
|
17
|
-
let _rafId = 0;
|
|
18
|
-
let _timerQueue = [];
|
|
19
|
-
let _timerId = 0;
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
window.
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
if (document.getAnimations) {
|
|
121
|
-
document.getAnimations().forEach(a => {
|
|
122
|
-
if (a.playState !== 'paused') a.pause();
|
|
123
|
-
a.currentTime = _now;
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
} catch (_) {}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
});
|
|
192
|
-
|
|
1
|
+
/**
|
|
2
|
+
* capture.mjs — Virtual Clock Injection & Deterministic Frame Stepper
|
|
3
|
+
*
|
|
4
|
+
* Replaces Date.now, performance.now, requestAnimationFrame, setTimeout/setInterval,
|
|
5
|
+
* and CSS animations with a virtual clock. Supports both canvas/WebGL renderAt(t)
|
|
6
|
+
* and DOM/canvas seek(t).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { pathToFileURL } from 'url';
|
|
12
|
+
|
|
13
|
+
export const VIRTUAL_CLOCK_INJECTION = `
|
|
14
|
+
(function () {
|
|
15
|
+
let _now = 0;
|
|
16
|
+
let _rafQueue = new Map();
|
|
17
|
+
let _rafId = 0;
|
|
18
|
+
let _timerQueue = [];
|
|
19
|
+
let _timerId = 0;
|
|
20
|
+
const _realDateNow = Date.now;
|
|
21
|
+
const _realSetTimeout = window.setTimeout;
|
|
22
|
+
const _startDate = _realDateNow();
|
|
23
|
+
const _delayHandles = new Set();
|
|
24
|
+
let _delayId = 0;
|
|
25
|
+
|
|
26
|
+
window.delayRender = (label = 'asset') => {
|
|
27
|
+
const id = ++_delayId;
|
|
28
|
+
_delayHandles.add(id);
|
|
29
|
+
return id;
|
|
30
|
+
};
|
|
31
|
+
window.continueRender = (id) => {
|
|
32
|
+
_delayHandles.delete(id);
|
|
33
|
+
};
|
|
34
|
+
window.cancelRender = (err) => {
|
|
35
|
+
console.error("Render cancelled:", err);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
window.__playwright = true;
|
|
39
|
+
|
|
40
|
+
// 1. Deterministic clocks
|
|
41
|
+
performance.now = () => _now;
|
|
42
|
+
const OriginalDate = window.Date;
|
|
43
|
+
class VirtualDate extends OriginalDate {
|
|
44
|
+
constructor(...args) {
|
|
45
|
+
if (args.length === 0) {
|
|
46
|
+
super(_startDate + _now);
|
|
47
|
+
} else {
|
|
48
|
+
super(...args);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
static now() {
|
|
52
|
+
return Math.floor(_startDate + _now);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
window.Date = VirtualDate;
|
|
56
|
+
Date.now = () => _startDate + _now;
|
|
57
|
+
|
|
58
|
+
// 2. Deterministic requestAnimationFrame
|
|
59
|
+
window.requestAnimationFrame = (cb) => {
|
|
60
|
+
const id = ++_rafId;
|
|
61
|
+
_rafQueue.set(id, cb);
|
|
62
|
+
return id;
|
|
63
|
+
};
|
|
64
|
+
window.cancelAnimationFrame = (id) => {
|
|
65
|
+
_rafQueue.delete(id);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// 3. Virtual timer queue
|
|
69
|
+
window.setTimeout = (fn, delay = 0, ...args) => {
|
|
70
|
+
const id = ++_timerId;
|
|
71
|
+
_timerQueue.push({ id, runAt: _now + Math.max(0, delay), fn, args, repeat: 0 });
|
|
72
|
+
_timerQueue.sort((a, b) => a.runAt - b.runAt);
|
|
73
|
+
return id;
|
|
74
|
+
};
|
|
75
|
+
window.clearTimeout = (id) => {
|
|
76
|
+
const idx = _timerQueue.findIndex(t => t.id === id);
|
|
77
|
+
if (idx !== -1) _timerQueue.splice(idx, 1);
|
|
78
|
+
};
|
|
79
|
+
window.setInterval = (fn, interval = 0, ...args) => {
|
|
80
|
+
const id = ++_timerId;
|
|
81
|
+
const step = Math.max(16, interval);
|
|
82
|
+
_timerQueue.push({ id, runAt: _now + step, fn, args, repeat: step });
|
|
83
|
+
_timerQueue.sort((a, b) => a.runAt - b.runAt);
|
|
84
|
+
return id;
|
|
85
|
+
};
|
|
86
|
+
window.clearInterval = (id) => {
|
|
87
|
+
const idx = _timerQueue.findIndex(t => t.id === id);
|
|
88
|
+
if (idx !== -1) _timerQueue.splice(idx, 1);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// 4. Deterministic Seeded PRNG fallback for unseeded Math.random()
|
|
92
|
+
let _seed = 0x2F6E2B1;
|
|
93
|
+
const resetSeed = (tSec) => {
|
|
94
|
+
_seed = (0x2F6E2B1 ^ Math.imul(Math.floor(tSec * 1000), 0x9E3779B9)) | 0;
|
|
95
|
+
};
|
|
96
|
+
window.Math.random = () => {
|
|
97
|
+
_seed = (_seed + 0x6D2B79F5) | 0;
|
|
98
|
+
let t = Math.imul(_seed ^ (_seed >>> 15), 1 | _seed);
|
|
99
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
100
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// 5. Virtual time advance
|
|
104
|
+
window.__advance = (ms) => {
|
|
105
|
+
_now += ms;
|
|
106
|
+
while (_timerQueue.length > 0 && _timerQueue[0].runAt <= _now) {
|
|
107
|
+
const item = _timerQueue.shift();
|
|
108
|
+
try { item.fn(...item.args); } catch (_) {}
|
|
109
|
+
if (item.repeat > 0) {
|
|
110
|
+
item.runAt = _now + item.repeat;
|
|
111
|
+
_timerQueue.push(item);
|
|
112
|
+
_timerQueue.sort((a, b) => a.runAt - b.runAt);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const currentRafs = Array.from(_rafQueue.values());
|
|
116
|
+
_rafQueue.clear();
|
|
117
|
+
currentRafs.forEach(cb => { try { cb(_now); } catch (_) {} });
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
if (document.getAnimations) {
|
|
121
|
+
document.getAnimations().forEach(a => {
|
|
122
|
+
if (a.playState !== 'paused') a.pause();
|
|
123
|
+
a.currentTime = _now;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
} catch (_) {}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// 6. Absolute virtual seek
|
|
130
|
+
window.__seek = async (t) => {
|
|
131
|
+
_now = t * 1000;
|
|
132
|
+
resetSeed(t);
|
|
133
|
+
|
|
134
|
+
// Pause & seek CSS animations
|
|
135
|
+
try {
|
|
136
|
+
if (document.getAnimations) {
|
|
137
|
+
document.getAnimations().forEach(a => {
|
|
138
|
+
if (a.playState !== 'paused') a.pause();
|
|
139
|
+
a.currentTime = _now;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
} catch (_) {}
|
|
143
|
+
|
|
144
|
+
if (window.renderAt) {
|
|
145
|
+
await window.renderAt(t);
|
|
146
|
+
} else if (window.seek) {
|
|
147
|
+
await window.seek(t);
|
|
148
|
+
}
|
|
149
|
+
while (_timerQueue.length > 0 && _timerQueue[0].runAt <= _now) {
|
|
150
|
+
const item = _timerQueue.shift();
|
|
151
|
+
try { item.fn(...item.args); } catch (_) {}
|
|
152
|
+
}
|
|
153
|
+
if (document.fonts && document.fonts.ready) {
|
|
154
|
+
await document.fonts.ready;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (_delayHandles.size > 0) {
|
|
158
|
+
const startW = _realDateNow();
|
|
159
|
+
while (_delayHandles.size > 0 && _realDateNow() - startW < 10000) {
|
|
160
|
+
await new Promise(r => _realSetTimeout(r, 10));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
})();
|
|
165
|
+
`;
|
|
166
|
+
|
|
167
|
+
export async function openPageWithVirtualClock(browserOrOpts, htmlPathArg, optsArg = {}) {
|
|
168
|
+
let browser, htmlPath, width, height;
|
|
169
|
+
if (browserOrOpts && typeof browserOrOpts === 'object' && browserOrOpts.browser) {
|
|
170
|
+
browser = browserOrOpts.browser;
|
|
171
|
+
htmlPath = browserOrOpts.htmlPath;
|
|
172
|
+
width = browserOrOpts.width || 1920;
|
|
173
|
+
height = browserOrOpts.height || 1080;
|
|
174
|
+
} else {
|
|
175
|
+
browser = browserOrOpts;
|
|
176
|
+
htmlPath = htmlPathArg;
|
|
177
|
+
width = optsArg.width || 1920;
|
|
178
|
+
height = optsArg.height || 1080;
|
|
179
|
+
}
|
|
180
|
+
const page = await browser.newPage();
|
|
181
|
+
await page.addInitScript(VIRTUAL_CLOCK_INJECTION);
|
|
182
|
+
|
|
183
|
+
const fileUrl = pathToFileURL(path.resolve(htmlPath)).href;
|
|
184
|
+
await page.goto(fileUrl, { waitUntil: 'load' });
|
|
185
|
+
|
|
186
|
+
// Await fonts before frame 0
|
|
187
|
+
await page.evaluate(async () => {
|
|
188
|
+
if (document.fonts && document.fonts.ready) {
|
|
189
|
+
await document.fonts.ready;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await page.setViewportSize({ width, height });
|
|
194
|
+
return page;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function captureFrame(page, t, { type = 'image/png', quality = 0.94 } = {}) {
|
|
198
|
+
const dataUrl = await page.evaluate(async ({ time, mime, q }) => {
|
|
199
|
+
await window.__seek(time);
|
|
200
|
+
if (window.renderAt) {
|
|
201
|
+
return await window.renderAt(time, mime, q);
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}, { time: t, mime: type, q: quality });
|
|
205
|
+
|
|
206
|
+
if (typeof dataUrl === 'string' && dataUrl.includes(',')) {
|
|
207
|
+
return Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const screenshotType = type === 'image/jpeg' ? 'jpeg' : 'png';
|
|
211
|
+
return page.screenshot({
|
|
212
|
+
type: screenshotType,
|
|
213
|
+
quality: screenshotType === 'jpeg' ? Math.round(quality * 100) : undefined,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
@@ -204,18 +204,58 @@ export async function verifySceneQuality({
|
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
// Motion continuity delta:
|
|
208
|
-
// A
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
207
|
+
// Motion continuity delta: pixel-luminance MAD between t=30% and t=70% frames.
|
|
208
|
+
// A pure hash comparison misses slow-drift scenes (e.g. barely-visible zoom) that
|
|
209
|
+
// look frozen to a human viewer but differ by ≥1 pixel. We require at least 0.5%
|
|
210
|
+
// of pixels to change by >10 luminance units — this filters lens-flare flickers too.
|
|
211
|
+
const earlyBuf = rawBuffers[1] || rawBuffers[0];
|
|
212
|
+
const lateBuf = rawBuffers[3] || rawBuffers[rawBuffers.length - 1];
|
|
213
|
+
|
|
214
|
+
const hashEarly = createHash('sha256').update(earlyBuf).digest('hex');
|
|
215
|
+
const hashLate = createHash('sha256').update(lateBuf).digest('hex');
|
|
216
|
+
const identicalHash = (hashEarly === hashLate);
|
|
217
|
+
|
|
212
218
|
let motionDelta = 1.0;
|
|
219
|
+
let isStuckFrozen = identicalHash;
|
|
220
|
+
|
|
221
|
+
if (!identicalHash) {
|
|
222
|
+
// Pixel-level MAD using Playwright canvas read (already done above, reuse page)
|
|
223
|
+
const pixelMotion = await page.evaluate(async (times) => {
|
|
224
|
+
const canvas = document.querySelector('canvas') || document.getElementById('stage');
|
|
225
|
+
if (!canvas) return null;
|
|
226
|
+
const ctx = canvas.getContext('2d');
|
|
227
|
+
const w = canvas.width;
|
|
228
|
+
const h = canvas.height;
|
|
229
|
+
|
|
230
|
+
await window.__seek(times[0]);
|
|
231
|
+
const d1 = ctx.getImageData(0, 0, w, h).data;
|
|
232
|
+
|
|
233
|
+
await window.__seek(times[1]);
|
|
234
|
+
const d2 = ctx.getImageData(0, 0, w, h).data;
|
|
235
|
+
|
|
236
|
+
const total = w * h;
|
|
237
|
+
let changedPixels = 0;
|
|
238
|
+
for (let i = 0; i < total; i++) {
|
|
239
|
+
const idx = i * 4;
|
|
240
|
+
const lum1 = 0.2126 * d1[idx] + 0.7152 * d1[idx + 1] + 0.0722 * d1[idx + 2];
|
|
241
|
+
const lum2 = 0.2126 * d2[idx] + 0.7152 * d2[idx + 1] + 0.0722 * d2[idx + 2];
|
|
242
|
+
if (Math.abs(lum1 - lum2) > 10) changedPixels++;
|
|
243
|
+
}
|
|
244
|
+
return changedPixels / total;
|
|
245
|
+
}, [sampleTimes[1] ?? sampleTimes[0], sampleTimes[3] ?? sampleTimes[sampleTimes.length - 1]]);
|
|
246
|
+
|
|
247
|
+
if (pixelMotion !== null) {
|
|
248
|
+
motionDelta = pixelMotion;
|
|
249
|
+
// Require at least 0.5% of pixels to have changed by >10 luminance units
|
|
250
|
+
isStuckFrozen = pixelMotion < 0.005;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
213
253
|
|
|
214
254
|
if (isStuckFrozen) {
|
|
215
|
-
motionDelta = 0.0;
|
|
255
|
+
motionDelta = identicalHash ? 0.0 : motionDelta;
|
|
216
256
|
sampledFrames.forEach(f => {
|
|
217
257
|
f.score = Math.max(1, f.score - 4);
|
|
218
|
-
f.issues.push(
|
|
258
|
+
f.issues.push(`Stuck motion: < 0.5% pixel luminance change between t=30% and t=70% (delta=${(motionDelta * 100).toFixed(2)}%)`);
|
|
219
259
|
});
|
|
220
260
|
}
|
|
221
261
|
|