@amg555/frameforge-mcp 2.4.10 → 2.7.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/.claude-plugin/marketplace.json +22 -4
- package/CHANGELOG.md +174 -0
- package/CLAUDE.md +192 -9
- package/README.md +47 -6
- package/output/harness_test_1794418727139/frames/f000000.jpg +1 -0
- 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/src/cli/doctor.js
CHANGED
|
@@ -126,7 +126,27 @@ if (hasPlaywright && hasChromium) {
|
|
|
126
126
|
' (Path 1 via create_video_from_script does not need Playwright)');
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
// 6.
|
|
129
|
+
// 6. Media Downloader (yt-dlp)
|
|
130
|
+
const { detectYtDlp } = require('../utils/media-downloader');
|
|
131
|
+
if (detectYtDlp()) {
|
|
132
|
+
const ytdlpVer = spawnSync('yt-dlp', ['--version'], { encoding: 'utf8', timeout: 3000, shell: process.platform === 'win32' });
|
|
133
|
+
pass('Media Downloader (yt-dlp)', `available (${(ytdlpVer.stdout || '').trim()})`);
|
|
134
|
+
} else {
|
|
135
|
+
warn('Media Downloader (yt-dlp)', 'yt-dlp not found in PATH (optional for downloading web clips/audio)',
|
|
136
|
+
'Run "pip install yt-dlp" or download binary from https://github.com/yt-dlp/yt-dlp');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 7. Speech Transcription (Whisper)
|
|
140
|
+
const { detectWhisper } = require('../utils/transcribe');
|
|
141
|
+
const whisperStatus = detectWhisper();
|
|
142
|
+
if (whisperStatus.available) {
|
|
143
|
+
pass('Speech Transcription (Whisper)', `engine: ${whisperStatus.engine} (word-level timestamps & pause detection ready)`);
|
|
144
|
+
} else {
|
|
145
|
+
warn('Speech Transcription (Whisper)', 'Local whisper engine not detected (FFmpeg pause detection fallback active)',
|
|
146
|
+
'Run "pip install openai-whisper" for local word-level transcription or set OPENAI_API_KEY in .env');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 8. System Font (for video subtitles/captions)
|
|
130
150
|
const FONT_CANDIDATES = [
|
|
131
151
|
'C:\\Windows\\Fonts\\arialbd.ttf',
|
|
132
152
|
'C:\\Windows\\Fonts\\calibrib.ttf',
|
|
@@ -188,7 +208,21 @@ if (hasLLMKey) {
|
|
|
188
208
|
);
|
|
189
209
|
}
|
|
190
210
|
|
|
191
|
-
//
|
|
211
|
+
// 10. Upstream Frontier Skills & Repo Sync
|
|
212
|
+
const { getUpstreamSummary } = require('../skills/sync-upstream');
|
|
213
|
+
const upstream = getUpstreamSummary();
|
|
214
|
+
if (upstream.hasUpdates) {
|
|
215
|
+
pass('Upstream Skills', `${upstream.repoCount} frontier repos tracked (${upstream.pendingUpdates.length} updates applied)`);
|
|
216
|
+
for (const u of upstream.pendingUpdates) {
|
|
217
|
+
console.log(` ${COLORS.cyan}↳ [${u.category}] ${u.repo} (${u.userImpact})${COLORS.reset}`);
|
|
218
|
+
}
|
|
219
|
+
} else if (upstream.lastSync) {
|
|
220
|
+
pass('Upstream Skills', `${upstream.repoCount} frontier repos synced (HyperFrames, OneTake, LemoOpuscar, Remotion, Earshot, ClaudeAnimationBase)`);
|
|
221
|
+
} else {
|
|
222
|
+
warn('Upstream Skills', 'Not yet synchronized with upstream frontier repos', 'Run "frameforge sync" to check and apply latest animation and editing techniques');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 11. Output directory check
|
|
192
226
|
const baseDir = process.env.N8N_FILES_DIR || path.join(process.cwd(), '.frameforge-files');
|
|
193
227
|
try {
|
|
194
228
|
fs.mkdirSync(baseDir, { recursive: true });
|
package/src/cli/make.js
CHANGED
|
@@ -83,16 +83,107 @@ const LANGUAGE_VOICES = {
|
|
|
83
83
|
'id': 'id-ID-ArdiNeural', // Indonesian
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
-
// ──
|
|
86
|
+
// ── Subcommand Dispatcher ─────────────────────────────────────────────────────
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
const subCmd = (process.argv[2] || '').toLowerCase();
|
|
89
|
+
|
|
90
|
+
// 1. Dispatch to code-driven animation harness
|
|
91
|
+
if (subCmd === 'harness') {
|
|
90
92
|
const harnessBin = path.resolve(__dirname, '../harness/bin/harness.mjs');
|
|
91
93
|
const { spawnSync } = require('child_process');
|
|
92
94
|
const res = spawnSync(process.execPath, [harnessBin, ...process.argv.slice(3)], { stdio: 'inherit' });
|
|
93
95
|
process.exit(res.status ?? 0);
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
// 2. Dispatch to media downloader (yt-dlp)
|
|
99
|
+
if (subCmd === 'download' || subCmd === 'ingest') {
|
|
100
|
+
const url = process.argv[3];
|
|
101
|
+
if (!url || url.startsWith('-')) {
|
|
102
|
+
console.error('Usage: frameforge download <url> [--audio] [--subtitles] [--out-dir <dir>] [--filename <name>]');
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
const isAudio = process.argv.includes('--audio') || process.argv.includes('-a');
|
|
106
|
+
const sub = process.argv.includes('--subtitles') || process.argv.includes('--subs');
|
|
107
|
+
let outDir, filename;
|
|
108
|
+
for (let i = 4; i < process.argv.length; i++) {
|
|
109
|
+
if (process.argv[i] === '--out-dir' || process.argv[i] === '-o') outDir = process.argv[++i];
|
|
110
|
+
if (process.argv[i] === '--filename' || process.argv[i] === '-f') filename = process.argv[++i];
|
|
111
|
+
}
|
|
112
|
+
const { downloadMediaWithYtDlp, detectYtDlp } = require('../utils/media-downloader');
|
|
113
|
+
if (!detectYtDlp()) {
|
|
114
|
+
console.error('✖ yt-dlp is not installed. Install with: pip install yt-dlp');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
console.log(`⬇️ Downloading with yt-dlp: ${url}`);
|
|
119
|
+
const res = downloadMediaWithYtDlp(url, { format: isAudio ? 'audio' : 'video', subtitles: sub, outputDir: outDir, filename });
|
|
120
|
+
console.log(`✔ Downloaded successfully to: ${res.localPath}`);
|
|
121
|
+
if (res.subtitlePath) console.log(`✔ Subtitles saved to: ${res.subtitlePath}`);
|
|
122
|
+
if (res.duration) console.log(` Duration: ${res.duration.toFixed(1)}s (${res.width || 0}x${res.height || 0})`);
|
|
123
|
+
process.exit(0);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(`✖ Download failed: ${err.message}`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 3. Dispatch to speech transcription (Whisper)
|
|
131
|
+
if (subCmd === 'transcribe') {
|
|
132
|
+
const mediaPath = process.argv[3];
|
|
133
|
+
if (!mediaPath || mediaPath.startsWith('-')) {
|
|
134
|
+
console.error('Usage: frameforge transcribe <mediaPath> [--model <model>] [--language <lang>] [--format <ndjson|srt|json|all>] [--out <path>]');
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
let model = 'base', language, format = 'all', outputPath;
|
|
138
|
+
for (let i = 4; i < process.argv.length; i++) {
|
|
139
|
+
if (process.argv[i] === '--model' || process.argv[i] === '-m') model = process.argv[++i];
|
|
140
|
+
if (process.argv[i] === '--language' || process.argv[i] === '-l') language = process.argv[++i];
|
|
141
|
+
if (process.argv[i] === '--format' || process.argv[i] === '-f') format = process.argv[++i];
|
|
142
|
+
if (process.argv[i] === '--out' || process.argv[i] === '-o') outputPath = process.argv[++i];
|
|
143
|
+
}
|
|
144
|
+
const { transcribeAudio, detectWhisper } = require('../utils/transcribe');
|
|
145
|
+
const engine = detectWhisper();
|
|
146
|
+
console.log(`🎙️ Transcribing with Whisper (${engine.engine}, model: ${model}): ${mediaPath}`);
|
|
147
|
+
transcribeAudio(mediaPath, { model, language, format, outputPath })
|
|
148
|
+
.then(res => {
|
|
149
|
+
console.log(`✔ Transcription complete (${res.duration.toFixed(1)}s, ${res.words.length} words, ${res.segments.length} segments, ${res.pauses.length} pauses)`);
|
|
150
|
+
if (res.files) {
|
|
151
|
+
for (const [fmt, p] of Object.entries(res.files)) {
|
|
152
|
+
console.log(` ↳ [${fmt.toUpperCase()}] ${p}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
process.exit(0);
|
|
156
|
+
})
|
|
157
|
+
.catch(err => {
|
|
158
|
+
console.error(`✖ Transcription failed: ${err.message}`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 4. Dispatch to upstream skills synchronizer
|
|
164
|
+
if (subCmd === 'sync' || subCmd === 'sync-skills' || subCmd === 'update') {
|
|
165
|
+
const isSilent = process.argv.includes('--silent');
|
|
166
|
+
const { syncSkills } = require('../skills/sync-upstream');
|
|
167
|
+
syncSkills({ silent: isSilent })
|
|
168
|
+
.then(() => process.exit(0))
|
|
169
|
+
.catch(err => {
|
|
170
|
+
console.error(`✖ Upstream sync failed: ${err.message}`);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 5. Dispatch to doctor
|
|
176
|
+
if (subCmd === 'doctor') {
|
|
177
|
+
require('./doctor');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 6. Dispatch to setup
|
|
182
|
+
if (subCmd === 'setup') {
|
|
183
|
+
require('./setup-keys');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
96
187
|
function parseArgs(argv) {
|
|
97
188
|
const args = {
|
|
98
189
|
mode: 'full-auto',
|
|
@@ -151,7 +242,17 @@ function parseArgs(argv) {
|
|
|
151
242
|
|
|
152
243
|
function printHelp() {
|
|
153
244
|
console.log(`
|
|
154
|
-
FrameForge
|
|
245
|
+
FrameForge — Unified AI Video Production Engine
|
|
246
|
+
|
|
247
|
+
SUBCOMMANDS:
|
|
248
|
+
download <url> Download video/audio from web (YouTube, Twitter, Vimeo, etc.) via yt-dlp
|
|
249
|
+
Options: [--audio] [--subtitles] [--out-dir <dir>] [--filename <name>]
|
|
250
|
+
transcribe <file> Transcribe speech to word-level timestamps & subtitles via Whisper
|
|
251
|
+
Options: [--model <size>] [--language <code>] [--format srt|ndjson|json|all]
|
|
252
|
+
sync Synchronize skills from 9 upstream frontier repositories
|
|
253
|
+
doctor Pre-flight environment diagnostics (FFmpeg, Whisper, yt-dlp, Playwright)
|
|
254
|
+
setup Configure API keys (~/.frameforge/config.json)
|
|
255
|
+
harness Run HTML code-driven animation test harness (Playwright + Chromium)
|
|
155
256
|
|
|
156
257
|
MODES:
|
|
157
258
|
full-auto (default) Research → Script → AI Images → TTS → Render → Publish
|
|
@@ -674,8 +775,11 @@ function summarise(startTime) {
|
|
|
674
775
|
console.log(`\n⏱️ Total time: ${mins > 0 ? `${mins}m ` : ''}${secs}s\n`);
|
|
675
776
|
}
|
|
676
777
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
778
|
+
const KNOWN_SUBCMDS = ['harness', 'download', 'ingest', 'transcribe', 'sync', 'sync-skills', 'update', 'doctor', 'setup'];
|
|
779
|
+
if (!KNOWN_SUBCMDS.includes(subCmd)) {
|
|
780
|
+
main().catch(err => {
|
|
781
|
+
console.error(`\n❌ Fatal error: ${err.message}`);
|
|
782
|
+
if (process.env.DEBUG_PROVIDERS) console.error(err.stack);
|
|
783
|
+
process.exit(1);
|
|
784
|
+
});
|
|
785
|
+
}
|
|
@@ -162,9 +162,20 @@ async function cmdSheet() {
|
|
|
162
162
|
const page = await openPageWithVirtualClock({ browser, htmlPath: HTML_FILE });
|
|
163
163
|
const outPath = flags.out ? path.resolve(CWD, flags.out) : path.join(CHECK_DIR, 'sheet.jpg');
|
|
164
164
|
|
|
165
|
-
let times
|
|
165
|
+
let times;
|
|
166
166
|
if (flags.times) {
|
|
167
167
|
times = flags.times.split(',').map(Number).filter(n => !isNaN(n));
|
|
168
|
+
} else {
|
|
169
|
+
// Auto-derive timestamps from scene duration so long films get full coverage.
|
|
170
|
+
// A 60s film at the old [0.1, 0.5, 1.0, 1.5, 2.0] default covers only the first 3%.
|
|
171
|
+
const duration = await page.evaluate(() =>
|
|
172
|
+
window.DURATION || window.REEL?.duration || 5
|
|
173
|
+
);
|
|
174
|
+
const step = Math.max(0.5, duration / 20);
|
|
175
|
+
const count = Math.min(20, Math.ceil(duration / step));
|
|
176
|
+
times = Array.from({ length: count }, (_, i) =>
|
|
177
|
+
parseFloat(((i + 1) * step).toFixed(2))
|
|
178
|
+
);
|
|
168
179
|
}
|
|
169
180
|
|
|
170
181
|
console.log(`🖼 Generating Contact Sheet (${times.length} frames) → ${outPath}`);
|
|
@@ -307,12 +318,12 @@ async function cmdPixels() {
|
|
|
307
318
|
}
|
|
308
319
|
|
|
309
320
|
async function cmdQuality() {
|
|
310
|
-
console.log(`🎯 Running Closed-Loop Visual QA Gate (1–10 Scoring) on: ${
|
|
321
|
+
console.log(`🎯 Running Closed-Loop Visual QA Gate (1–10 Scoring) on: ${HTML_FILE}`);
|
|
311
322
|
const { browser } = await launchHarnessBrowser();
|
|
312
323
|
const sheetTarget = path.join(CHECK_DIR, 'sheet.jpg');
|
|
313
324
|
const res = await verifySceneQuality({
|
|
314
325
|
browser,
|
|
315
|
-
htmlPath:
|
|
326
|
+
htmlPath: HTML_FILE,
|
|
316
327
|
sheetPath: sheetTarget,
|
|
317
328
|
});
|
|
318
329
|
await browser.close();
|
|
@@ -333,10 +344,32 @@ async function cmdQuality() {
|
|
|
333
344
|
}
|
|
334
345
|
}
|
|
335
346
|
|
|
347
|
+
async function cmdLook() {
|
|
348
|
+
console.log(`🔍 Running Stage & Asset Diagnostic Linter: ${HTML_FILE}`);
|
|
349
|
+
const lookCandidates = [
|
|
350
|
+
path.join(CWD, 'scripts', 'look.mjs'),
|
|
351
|
+
path.resolve(__dirname, '../../../plugins/video-pipeline/skills/video-pipeline/assets/project/scripts/look.mjs'),
|
|
352
|
+
path.join(__dirname, 'look.mjs'),
|
|
353
|
+
];
|
|
354
|
+
const lookScript = lookCandidates.find(p => fs.existsSync(p));
|
|
355
|
+
if (lookScript) {
|
|
356
|
+
const lookArgs = [lookScript];
|
|
357
|
+
if (flags.strict) lookArgs.push('--strict');
|
|
358
|
+
const res = spawnSync('node', lookArgs, { stdio: 'inherit', cwd: CWD });
|
|
359
|
+
if (res.status !== 0) {
|
|
360
|
+
console.error('❌ Look diagnostics failed. Resolve asset or stage errors before rendering.');
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
console.log(' ⚠️ look.mjs not found in project scripts, skipping DOM linter.');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
336
368
|
async function cmdAll() {
|
|
337
|
-
console.log('🚀 Running Complete Production Pipeline: Storyboard → Verify → Quality → Frames → Encode
|
|
369
|
+
console.log('🚀 Running Complete Production Pipeline: Doctor → Storyboard → Look → Sheet → Verify → Quality → Frames → Encode');
|
|
338
370
|
await cmdDoctor();
|
|
339
371
|
await cmdStoryboard();
|
|
372
|
+
await cmdLook();
|
|
340
373
|
await cmdSheet();
|
|
341
374
|
await cmdVerify();
|
|
342
375
|
await cmdQuality();
|
|
@@ -345,10 +378,98 @@ async function cmdAll() {
|
|
|
345
378
|
console.log('\n✨ Production complete! Output ready at out/film.mp4');
|
|
346
379
|
}
|
|
347
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Golden stills regression — save or check.
|
|
383
|
+
*
|
|
384
|
+
* harness golden --save Capture 4-keyframe storyboard and write hashes to
|
|
385
|
+
* out/check/golden.json. Run after a video passes QC.
|
|
386
|
+
* harness golden --check Re-capture the same 4 keyframes and compare hashes.
|
|
387
|
+
* Fails if any frame hash changed (refactor broke determinism).
|
|
388
|
+
* harness golden --out <path> Custom path for golden.json (default: out/check/golden.json)
|
|
389
|
+
*/
|
|
390
|
+
async function cmdGolden() {
|
|
391
|
+
const goldenPath = flags.out
|
|
392
|
+
? path.resolve(CWD, flags.out)
|
|
393
|
+
: path.join(CHECK_DIR, 'golden.json');
|
|
394
|
+
|
|
395
|
+
const { browser } = await launchHarnessBrowser({
|
|
396
|
+
softGl: flags['soft-gl'],
|
|
397
|
+
chromePath: flags.chrome,
|
|
398
|
+
});
|
|
399
|
+
const page = await openPageWithVirtualClock({ browser, htmlPath: HTML_FILE });
|
|
400
|
+
|
|
401
|
+
// Resolve duration
|
|
402
|
+
const duration = await page.evaluate(() =>
|
|
403
|
+
window.DURATION || window.REEL?.duration || 5
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
// 4 canonical keyframes (0%, 33%, 66%, 99%)
|
|
407
|
+
const goldenTimes = [
|
|
408
|
+
parseFloat((duration * 0.01).toFixed(3)),
|
|
409
|
+
parseFloat((duration * 0.33).toFixed(3)),
|
|
410
|
+
parseFloat((duration * 0.66).toFixed(3)),
|
|
411
|
+
parseFloat((duration * 0.99).toFixed(3)),
|
|
412
|
+
];
|
|
413
|
+
|
|
414
|
+
async function captureHashes() {
|
|
415
|
+
const hashes = {};
|
|
416
|
+
for (const t of goldenTimes) {
|
|
417
|
+
const buf = await captureFrame(page, t, { type: 'image/png' });
|
|
418
|
+
const { createHash } = await import('crypto');
|
|
419
|
+
hashes[String(t)] = createHash('sha256').update(buf).digest('hex').slice(0, 16);
|
|
420
|
+
}
|
|
421
|
+
return hashes;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (flags.save) {
|
|
425
|
+
console.log(`🟡 Golden: capturing ${goldenTimes.length} keyframes → ${goldenPath}`);
|
|
426
|
+
const hashes = await captureHashes();
|
|
427
|
+
fs.writeFileSync(goldenPath, JSON.stringify({ htmlFile: HTML_FILE, duration, times: goldenTimes, hashes }, null, 2), 'utf8');
|
|
428
|
+
await browser.close();
|
|
429
|
+
console.log(` ✅ Golden baseline saved: ${goldenPath}`);
|
|
430
|
+
for (const [t, h] of Object.entries(hashes)) {
|
|
431
|
+
console.log(` t=${t}s hash=${h}`);
|
|
432
|
+
}
|
|
433
|
+
} else if (flags.check) {
|
|
434
|
+
if (!fs.existsSync(goldenPath)) {
|
|
435
|
+
console.error(`❌ No golden baseline found at ${goldenPath}. Run: harness golden --save first.`);
|
|
436
|
+
process.exit(1);
|
|
437
|
+
}
|
|
438
|
+
const baseline = JSON.parse(fs.readFileSync(goldenPath, 'utf8'));
|
|
439
|
+
console.log(`🔎 Golden: comparing against baseline (${baseline.times.length} frames) → ${goldenPath}`);
|
|
440
|
+
const current = await captureHashes();
|
|
441
|
+
await browser.close();
|
|
442
|
+
|
|
443
|
+
let passed = true;
|
|
444
|
+
for (const t of baseline.times) {
|
|
445
|
+
const key = String(t);
|
|
446
|
+
const expected = baseline.hashes[key];
|
|
447
|
+
const actual = current[key];
|
|
448
|
+
if (expected !== actual) {
|
|
449
|
+
passed = false;
|
|
450
|
+
console.error(` ❌ REGRESSION at t=${t}s expected=${expected} got=${actual}`);
|
|
451
|
+
} else {
|
|
452
|
+
console.log(` ✅ t=${t}s hash=${actual} (matches)`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!passed) {
|
|
457
|
+
console.error('\n❌ GOLDEN CHECK FAILED — refactor broke determinism. Investigate edit.js changes.');
|
|
458
|
+
process.exit(1);
|
|
459
|
+
} else {
|
|
460
|
+
console.log('\n✅ GOLDEN CHECK PASSED — all keyframe hashes match baseline.');
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
await browser.close();
|
|
464
|
+
console.log('Usage: harness golden --save (capture baseline)\n harness golden --check (verify against baseline)');
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
348
468
|
try {
|
|
349
469
|
switch (command) {
|
|
350
470
|
case 'doctor': await cmdDoctor(); break;
|
|
351
471
|
case 'storyboard': await cmdStoryboard(); break;
|
|
472
|
+
case 'look': await cmdLook(); break;
|
|
352
473
|
case 'verify': await cmdVerify(); break;
|
|
353
474
|
case 'quality': await cmdQuality(); break;
|
|
354
475
|
case 'sheet': await cmdSheet(); break;
|
|
@@ -357,9 +478,10 @@ try {
|
|
|
357
478
|
case 'frames': await cmdFrames(); break;
|
|
358
479
|
case 'encode': await cmdEncode(); break;
|
|
359
480
|
case 'pixels': await cmdPixels(); break;
|
|
481
|
+
case 'golden': await cmdGolden(); break;
|
|
360
482
|
case 'all': await cmdAll(); break;
|
|
361
483
|
default:
|
|
362
|
-
console.log(`Usage: harness <doctor|storyboard|verify|quality|sheet|strip|crop|frames|encode|pixels|all> [options]`);
|
|
484
|
+
console.log(`Usage: harness <doctor|storyboard|look|verify|quality|sheet|strip|crop|frames|encode|pixels|golden|all> [options]`);
|
|
363
485
|
break;
|
|
364
486
|
}
|
|
365
487
|
} catch (err) {
|