승인된 Blender 컷의 실제 합성과 최종 검수 근거 추가
This commit is contained in:
@@ -1,109 +1,89 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {createHash, randomUUID} from 'node:crypto';
|
||||
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';
|
||||
import {absolute, binaries, measured, prepareData, projectDir, rendererDir, relative, sha256, probe, write} from './data.mjs';
|
||||
|
||||
const mode = process.argv[2] ?? 'preview';
|
||||
if (!['preview', 'motion', 'compose'].includes(mode)) throw new Error('Use preview, motion or compose');
|
||||
if (!['preview', 'compose'].includes(mode)) throw new Error('Blender 원 컷은 tools/blender에서 제작합니다. preview 또는 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 sourceFiles = ['data.mjs', 'render.mjs', 'Scene.jsx', 'scene-plan.mjs', 'package.json', 'package-lock.json'];
|
||||
const identity = {inputs: data.input_files, versions: data.input_versions, source: data.source,
|
||||
profile: data.render_profile, output_revision: data.output_revision,
|
||||
renderer: sourceFiles.map((name) => ({path: relative(path.join(rendererDir, name)), sha256: sha256(path.join(rendererDir, name))}))};
|
||||
const key = createHash('sha256').update(JSON.stringify(identity)).digest('hex');
|
||||
// 매 실행은 새 시도다. 파일 존재를 완료나 재사용 근거로 삼지 않는다.
|
||||
const out = path.join(projectDir, `stages/13-compose/r${data.output_revision}/${mode === 'preview' ? 'previews' : 'render-attempts'}/${key.slice(0, 16)}-${randomUUID().slice(0, 12)}`);
|
||||
fs.mkdirSync(out, {recursive: true});
|
||||
write(path.join(out, 'inputs.json'), {identity: key, ...identity, status: 'requested'});
|
||||
console.log(`출력 시도: ${relative(out)}`);
|
||||
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});
|
||||
const composition = await selectComposition({serveUrl, id: 'Composed', inputProps, puppeteerInstance: browser});
|
||||
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`)});
|
||||
output: path.join(out, `${clip.id}-${position}.png`)});
|
||||
}
|
||||
console.log('Actual Remotion state previews rendered. Visual inspection remains required.');
|
||||
console.log('실제 MP4를 사용한 합성 화면 표본 생성. 최종 인코딩 영상 검수와 구분합니다.');
|
||||
} else {
|
||||
const master = path.join(out, mode === 'motion' ? 'motion-master.mp4' : 'captioned-silent.mp4');
|
||||
if (!fs.existsSync(master)) {
|
||||
const master = path.join(out, 'captioned-silent.mp4');
|
||||
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;}
|
||||
if (percent !== last) {console.log(`합성: ${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;
|
||||
measured(master, data.duration_ms);
|
||||
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]);
|
||||
// 승인된 WAV 전체를 한 번 AAC 인코딩한다. 속도·gain·trim·BGM을 적용하지 않는다.
|
||||
execute(['-i', master, '-i', absolute(data.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`)]);
|
||||
const streams = probe(video).streams;
|
||||
if (streams.length !== 2 || !streams.some((s) => s.codec_type === 'audio' && s.codec_name === 'aac' &&
|
||||
s.sample_rate === '24000' && s.channels === 1 && Math.round(Number(s.duration) * 1000) === data.duration_ms)) {
|
||||
throw new Error('최종 AAC 스트림/시간 불일치');
|
||||
}
|
||||
}
|
||||
execute(['-i', video, '-map', '0:v:0', '-map', '0:a:0', '-c:v', 'rawvideo', '-c:a', 'pcm_s16le', '-f', 'null', '-']);
|
||||
// 실행 중 원 입력이 바뀌었으면 결과를 완료로 기록하지 않는다.
|
||||
for (const input of data.input_files) if (sha256(absolute(input.path)) !== input.sha256) throw new Error(`실행 중 입력 변경: ${input.path}`);
|
||||
const time = (ms) => `${String(Math.floor(ms / 3600000)).padStart(2, '0')}:${String(Math.floor(ms / 60000) % 60).padStart(2, '0')}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')},${String(ms % 1000).padStart(3, '0')}`;
|
||||
const srt = path.join(out, 'surface-tension-ko.srt');
|
||||
fs.writeFileSync(srt, data.captions.map((c, i) => `${i + 1}\n${time(c.start_ms)} --> ${time(c.end_ms)}\n${c.text}\n`).join('\n'), 'utf8');
|
||||
const inspection = path.join(out, 'inspection'); fs.mkdirSync(inspection, {recursive: true});
|
||||
const samples = [];
|
||||
const sample = (id, frame) => {
|
||||
const file = path.join(inspection, `${id}.png`);
|
||||
execute(['-i', video, '-ss', String(frame / 50), '-frames:v', '1', '-vf', 'scale=540:960', file]);
|
||||
samples.push({id, frame, path: relative(file), sha256: sha256(file)});
|
||||
};
|
||||
for (const c of data.clips) for (const [position, offset] of [['start', 0], ['middle', Math.floor(c.frames / 2)], ['end', c.frames - 1]]) {
|
||||
sample(`${c.id}-${position}`, c.start_ms / 20 + offset);
|
||||
}
|
||||
for (const c of data.captions) sample(`caption-${c.id}`, Math.floor((c.start_ms + c.end_ms) / 40));
|
||||
const switches = [11920, 14780, 24380, 28480, 52080, 56120, 61000, 67440, 80200, 84160, 93420, 96160, 97760, 102680, 105160, 107360];
|
||||
for (const ms of switches) for (const offset of [-1, 0]) sample(`annotation-${ms}-${offset === 0 ? 'at' : 'before'}`, ms / 20 + offset);
|
||||
write(path.join(out, 'probe.json'), {video: metadata, streams, full_av_decode: true,
|
||||
narration: {path: data.narration.path, sha256: sha256(absolute(data.narration.path)), samples: data.duration_ms * 24},
|
||||
captions: {path: relative(srt), count: data.captions.length, sha256: sha256(srt)}, samples,
|
||||
note: '실측·전체 디코드·실제 최종 프레임 표본. 정상 속도 전체 재생·청취와 사용자 final 승인은 별도다.'});
|
||||
write(path.join(out, 'result.json'), {identity: key, status: 'technically_verified',
|
||||
video: {path: relative(video), kind: 'video', verified: true, duration_ms: metadata.duration_ms, width: metadata.width, height: metadata.height},
|
||||
video_sha256: sha256(video), captions_path: relative(srt), captions_sha256: sha256(srt)});
|
||||
console.log(`실제 완성본: ${relative(video)} (${metadata.frames}F / ${metadata.duration_ms}ms)`);
|
||||
}
|
||||
} finally {await browser.close({silent: true});}
|
||||
|
||||
Reference in New Issue
Block a user