// Phase A end-to-end verification: drives the REAL compiled renderMp4Job // (dist-electron/renderWorker.cjs) through ffmpeg and ffprobes the result. // // Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-export.cjs const { app } = require('electron') const path = require('node:path') const os = require('node:os') const fs = require('node:fs') const { execFileSync } = require('node:child_process') const { renderMp4Job } = require('../dist-electron/renderWorker.cjs') const fps = 12 const duration = 2 const scene = { canvas: { width: 320, height: 240, background: '#202030' }, duration, assets: [{ id: 'a1', kind: 'rect', width: 40, height: 40, fill: '#ff4040' }], layers: [ { id: 'l1', assetId: 'a1', visible: true, base: { x: 20, y: 100, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 }, keyframes: [ { time: 0, props: { x: 20 } }, { time: 2, props: { x: 260 } }, ], }, ], } const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast', } async function main() { const outPath = path.join(os.tmpdir(), 'ruwio-verify-export.mp4') fs.rmSync(outPath, { force: true }) const controller = new AbortController() let lastProgress = 0 const startedAt = Date.now() await renderMp4Job({ jobId: 'verify', scene, options, outputPath: outPath, signal: controller.signal, onProgress: (p) => { lastProgress = p }, }) const elapsedMs = Date.now() - startedAt const msPerFrame = elapsedMs / options.totalFrames const notes = [] let pass = true if (!fs.existsSync(outPath)) { pass = false notes.push('output file missing') } else { const sizeKb = (fs.statSync(outPath).size / 1024).toFixed(1) const probe = JSON.parse( execFileSync('ffprobe', [ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=codec_name,width,height,nb_read_frames', '-count_frames', '-of', 'json', outPath, ]).toString(), ) const s = probe.streams[0] console.log(`output: ${outPath} (${sizeKb} KB)`) console.log(`codec=${s.codec_name} ${s.width}x${s.height} frames=${s.nb_read_frames} progress=${lastProgress}`) console.log(`elapsed=${elapsedMs}ms (${msPerFrame.toFixed(1)}ms/frame)`) if (s.codec_name !== 'h264') { pass = false; notes.push(`codec ${s.codec_name} != h264`) } if (s.width !== 320 || s.height !== 240) { pass = false; notes.push(`wrong dims ${s.width}x${s.height}`) } if (Number(s.nb_read_frames) !== options.totalFrames) { pass = false notes.push(`frames ${s.nb_read_frames} != expected ${options.totalFrames}`) } if (lastProgress !== 1) { pass = false; notes.push(`final progress ${lastProgress} != 1`) } const maxMsPerFrame = Number(process.env.RUWIO_VERIFY_MAX_MS_PER_FRAME || 800) if (msPerFrame > maxMsPerFrame) { pass = false notes.push( `render throughput ${msPerFrame.toFixed(1)}ms/frame exceeds ${maxMsPerFrame}ms/frame ` + '(hidden render window may be throttled)', ) } // Decode first & last frame, confirm the rect moved (motion preserved). const f0 = path.join(os.tmpdir(), 'ruwio-f0.png') const f1 = path.join(os.tmpdir(), 'ruwio-flast.png') execFileSync('ffmpeg', ['-y', '-v', 'error', '-i', outPath, '-vf', 'select=eq(n\\,0)', '-frames:v', '1', f0]) execFileSync('ffmpeg', ['-y', '-v', 'error', '-sseof', '-0.1', '-i', outPath, '-frames:v', '1', f1]) const sz0 = fs.statSync(f0).size const sz1 = fs.statSync(f1).size const b0 = fs.readFileSync(f0) const b1 = fs.readFileSync(f1) const same = b0.length === b1.length && b0.equals(b1) console.log(`first frame ${sz0}B, last frame ${sz1}B, identical=${same}`) if (same) { pass = false; notes.push('first and last frame identical (no motion)') } } console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none') console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL') app.exit(pass ? 0 : 1) } // renderMp4Job closes its render window; suppress the default auto-quit so it // doesn't race result printing. app.on('window-all-closed', () => {}) app.whenReady().then(() => main().catch((err) => { console.error('HARNESS ERROR:', err) app.exit(2) }), )