@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.
- package/.mcp.json +2 -4
- package/CHANGELOG.md +1 -1
- package/CLAUDE.md +104 -26
- package/README.md +16 -12
- package/SECURITY.md +1 -3
- package/package.json +7 -3
- package/plugins/video-pipeline/.claude-plugin/plugin.json +19 -0
- package/plugins/video-pipeline/skills/html-renderer/SKILL.md +292 -0
- package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +308 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/ANIMATION_GUIDE.md +257 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/STORYBOARD.md +77 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/index.html +80 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/diagram-author.md +43 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +56 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +42 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.css +140 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +870 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +138 -0
- package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +167 -0
- package/plugins/video-pipeline/skills/video-pipeline/references/providers.md +163 -0
- package/scripts/db-migrate.js +112 -0
- package/scripts/deep-validate.js +326 -0
- package/scripts/generate-initial-assets.js +342 -0
- package/scripts/html-renderer/new-project.mjs +408 -0
- package/scripts/html-renderer/transcribe.py +294 -0
- package/src/asset-library/README.md +2 -2
- package/src/asset-library/asset-library.js +1 -1
- package/src/cli/make.js +52 -71
- package/src/cli/setup-keys.js +15 -17
- package/src/fact-checker/review-gate.js +5 -1
- package/src/harness/bin/harness.mjs +324 -0
- package/src/harness/lib/browser.mjs +137 -0
- package/src/harness/lib/capture.mjs +161 -0
- package/src/harness/lib/encode.mjs +89 -0
- package/src/harness/lib/jobs.mjs +135 -0
- package/src/harness/lib/pixels.mjs +137 -0
- package/src/harness/lib/sheets.mjs +164 -0
- package/src/harness/lib/verify-purity.mjs +119 -0
- package/src/harness/test/fixtures/broken-scene.html +27 -0
- package/src/harness/test/fixtures/reference-scene.html +23 -0
- package/src/harness/test/fixtures/sample-script.json +18 -0
- package/src/mcp-server/server.js +285 -144
- package/src/pipeline/telemetry.js +1 -0
- package/src/utils/log-redaction.js +5 -3
- package/src/video-renderer/renderer.js +12 -4
- package/src/worker/server-render-free.js +2 -3
- package/src/workflows/assets.js +20 -43
- 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
|
+
|