import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; import {bundle} from '@remotion/bundler'; import {openBrowser, renderMedia, renderStill, selectComposition} from '@remotion/renderer'; import {absolute, artifact, prepareData, projectDir, rendererDir, relative, write} from './data.mjs'; const mode = process.argv[2] ?? 'preview'; if (!['preview', 'motion', 'compose'].includes(mode)) throw new Error('Use preview, motion or compose'); process.chdir(rendererDir); const data = prepareData(); const binaries = path.join(rendererDir, 'node_modules/@remotion/compositor-win32-x64-msvc'); const ffmpeg = path.join(binaries, 'ffmpeg.exe'); const ffprobe = path.join(binaries, 'ffprobe.exe'); const execute = (args) => execFileSync(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-n', ...args], {stdio: 'inherit'}); const probe = (file) => JSON.parse(execFileSync(ffprobe, ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', file], {encoding: 'utf8'})); const measured = (file, expectedMs) => { const p = probe(file), v = p.streams.find((s) => s.codec_type === 'video'); const ms = Math.round(Number(v.duration) * 1000); if (ms !== expectedMs || v.width !== 1080 || v.height !== 1920 || v.r_frame_rate !== '50/1') { throw new Error(`${file}: unexpected actual duration/size/fps`); } return {path: relative(file), duration_ms: ms, width: v.width, height: v.height, frames: Number(v.nb_frames), codec: v.codec_name}; }; if (mode === 'compose') { const result = artifact('12-motion').payload.result; if (!result || result.clips.length !== 31 || result.clips.some((c) => !c.media.verified)) { throw new Error('31 visually verified real motion clips required before compose'); } for (const clip of data.clips) { const source = result.clips.find((c) => c.cut_id === clip.id).media.path; measured(absolute(source), clip.end_ms - clip.start_ms); fs.copyFileSync(absolute(source), path.join(rendererDir, `public/${clip.id}.mp4`)); } } const serveUrl = await bundle({entryPoint: path.join(rendererDir, 'Scene.jsx'), outDir: path.join(rendererDir, 'bundle'), publicDir: path.join(rendererDir, 'public')}); const browser = await openBrowser('chrome', {logLevel: 'warn'}); const id = mode === 'compose' ? 'Composed' : 'Motion'; const inputProps = {data}; const composition = await selectComposition({serveUrl, id, inputProps, puppeteerInstance: browser}); const stage = mode === 'compose' ? '13-compose' : '12-motion'; const out = path.join(projectDir, `media/${stage}/r1`); fs.mkdirSync(out, {recursive: true}); try { if (mode === 'preview') { const inspection = path.join(out, 'inspection'); fs.mkdirSync(inspection, {recursive: true}); const focus = process.argv.slice(3); for (const clip of data.clips.filter((c) => !focus.length || focus.includes(c.id))) for (const [position, offset] of [['start', 0], ['middle', Math.floor(clip.frames / 2)], ['end', clip.frames - 1]]) { await renderStill({serveUrl, composition, inputProps, puppeteerInstance: browser, frame: clip.start_ms / 20 + offset, scale: 0.5, imageFormat: 'png', output: path.join(inspection, `${clip.id}-${position}.png`)}); } console.log('Actual Remotion state previews rendered. Visual inspection remains required.'); } else { const master = path.join(out, mode === 'motion' ? 'motion-master.mp4' : 'captioned-silent.mp4'); if (!fs.existsSync(master)) { let last = -1; await renderMedia({serveUrl, composition, inputProps, puppeteerInstance: browser, codec: 'h264', outputLocation: master, crf: 18, x264Preset: 'fast', pixelFormat: 'yuv420p', concurrency: 4, muted: true, overwrite: false, onProgress: ({progress}) => { const percent = Math.floor(progress * 20) * 5; if (percent !== last) {console.log(`${id}: ${percent}%`); last = percent;} }}); } const masterMetadata = measured(master, data.duration_ms); if (mode === 'motion') { const clips = []; for (const c of data.clips) { const file = path.join(out, `${c.id}.mp4`); if (!fs.existsSync(file)) execute(['-ss', String(c.start_ms / 1000), '-i', master, '-an', '-frames:v', String(c.frames), '-c:v', 'libx264', '-crf', '18', '-preset', 'fast', '-threads', '2', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', file]); clips.push({cut_id: c.id, ...measured(file, c.end_ms - c.start_ms)}); if (clips.at(-1).frames !== c.frames) throw new Error(`${c.id} frame count mismatch`); } write(path.join(out, 'probe.json'), {master: masterMetadata, clips, note: 'Technical measurements only. Playback and visual inspection are separate.'}); } else { const narration = artifact('06-tts').payload.result.narration; const video = path.join(out, 'surface-tension-ko.mp4'); // Copy the video and encode the already approved full WAV once, without speed/trim/gain changes. if (!fs.existsSync(video)) execute(['-i', master, '-i', absolute(narration.path), '-map', '0:v:0', '-map', '1:a:0', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', video]); const metadata = measured(video, data.duration_ms); const time = (ms) => { const hours = Math.floor(ms / 3600000), minutes = Math.floor(ms / 60000) % 60; const seconds = Math.floor(ms / 1000) % 60; return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')},${String(ms % 1000).padStart(3, '0')}`; }; fs.writeFileSync(path.join(out, 'surface-tension-ko.srt'), data.captions.map((c, i) => `${i + 1}\n${time(c.start_ms)} --> ${time(c.end_ms)}\n${c.text}\n`).join('\n'), 'utf8'); write(path.join(out, 'probe.json'), {video: metadata, streams: probe(video).streams, note: 'Actual file measurements. Encoded narration listening remains a user review item.'}); const inspection = path.join(out, 'inspection'); fs.mkdirSync(inspection, {recursive: true}); for (const c of data.clips) { // Decode an actual encoded final-video frame, rather than using the request PNG as evidence. execute(['-ss', String((c.start_ms + c.end_ms) / 2000), '-i', video, '-frames:v', '1', '-vf', 'scale=540:960', path.join(inspection, `${c.id}-middle.png`)]); } } } } finally {await browser.close({silent: true});}