223 lines
15 KiB
JavaScript
223 lines
15 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import assert from 'node:assert/strict';
|
|
import {fileURLToPath} from 'node:url';
|
|
import {createHash} from 'node:crypto';
|
|
import {execFileSync} from 'node:child_process';
|
|
import {PNG} from 'pngjs';
|
|
|
|
export const rendererDir = path.dirname(fileURLToPath(import.meta.url));
|
|
export const projectDir = path.dirname(rendererDir);
|
|
export const repoDir = path.resolve(projectDir, '../..');
|
|
export const fps = 50;
|
|
export const read = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
export const artifact = (stage, project = read(path.join(projectDir, 'project.json'))) => {
|
|
const pointer = project.stages.find((s) => s.stage_id === stage);
|
|
assert(pointer?.artifact_path, `${stage}: 현재 결과 없음`);
|
|
const expected = `projects/${project.project_id}/stages/${stage}/r${pointer.revision}/artifact.json`;
|
|
assert.equal(pointer.artifact_path, expected, `${stage}: 현재 경로/버전 불일치`);
|
|
return read(absolute(pointer.artifact_path));
|
|
};
|
|
export const absolute = (relative) => path.join(repoDir, relative);
|
|
export const relative = (file) => path.relative(repoDir, file).split(path.sep).join('/');
|
|
export const write = (file, value) => {
|
|
fs.mkdirSync(path.dirname(file), {recursive: true});
|
|
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
|
|
};
|
|
|
|
export const sha256 = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
export const binaries = path.join(rendererDir, 'node_modules/@remotion/compositor-win32-x64-msvc');
|
|
export const probe = (file) => JSON.parse(execFileSync(path.join(binaries, 'ffprobe.exe'),
|
|
['-v', 'error', '-show_streams', '-show_format', '-of', 'json', file], {encoding: 'utf8'}));
|
|
export function measured(file, expectedMs, audio = false) {
|
|
const p = probe(file), s = p.streams.find((s) => s.codec_type === (audio ? 'audio' : 'video'));
|
|
assert(s, `${file}: 스트림 없음`);
|
|
assert.equal(Math.round(Number(s.duration) * 1000), expectedMs, `${file}: 실제 시간 불일치`);
|
|
if (audio) {
|
|
assert.equal(s.codec_name, 'pcm_s16le'); assert.equal(s.sample_rate, '24000'); assert.equal(s.channels, 1);
|
|
assert.equal(Number(s.duration_ts), expectedMs * 24, `${file}: WAV 샘플 수 불일치`);
|
|
} else {
|
|
assert.equal(s.width, 1080); assert.equal(s.height, 1920); assert.equal(s.r_frame_rate, '50/1');
|
|
assert.equal(s.avg_frame_rate, '50/1'); assert.equal(Number(s.nb_frames), expectedMs / 20);
|
|
}
|
|
return {path: relative(file), duration_ms: expectedMs, width: s.width ?? null,
|
|
height: s.height ?? null, frames: audio ? null : Number(s.nb_frames), codec: s.codec_name};
|
|
}
|
|
export function loadInputs() {
|
|
const project = read(path.join(projectDir, 'project.json'));
|
|
const context = Object.fromEntries(project.stages.filter((s) => Number(s.stage_id.slice(0, 2)) <= 12)
|
|
.map((s) => [s.stage_id, artifact(s.stage_id, project)]));
|
|
const anchorsPath = path.join(path.dirname(absolute(project.stages.find((s) => s.stage_id === '11-images').artifact_path)), 'anchors.json');
|
|
return {project, context, anchors: read(anchorsPath), anchorsPath};
|
|
}
|
|
export function validateInputs(project, context, anchors) {
|
|
assert.equal(project.schema_version, '1.1'); assert.equal(project.settings.visual_engine, 'Blender');
|
|
const pointer = (id) => project.stages.find((s) => s.stage_id === id);
|
|
const payload = (id) => context[id].payload;
|
|
for (const [id, a] of Object.entries(context)) {
|
|
assert.equal(pointer(id)?.status, 'complete', `${id}: 선행 결과 미완료/stale`);
|
|
assert.equal(a.stage_id, id); assert.equal(a.project_id, project.project_id);
|
|
assert.equal(a.revision, pointer(id).revision, `${id}: 다른 버전`);
|
|
assert.equal(pointer(id).artifact_path, `projects/${project.project_id}/stages/${id}/r${a.revision}/artifact.json`);
|
|
for (const ref of a.inputs) assert.equal(ref.revision, pointer(ref.stage_id)?.revision, `${id}: 오래된 선행 참조`);
|
|
}
|
|
for (const [id, refs] of [['topic', ['01-topic']], ['story', ['02-research', '03-script', '04-title']],
|
|
['scenes', ['07-cuts', '08-scenes', '09-entities']]]) {
|
|
const review = project.reviews.find((r) => r.review_id === id);
|
|
assert.equal(review?.decision, 'approved', `${id}: 실제 승인 필요`);
|
|
assert(review.feedback.trim(), `${id}: 사용자 승인 응답 없음`);
|
|
assert.deepEqual(review.artifact_refs, refs.map((stage_id) => ({stage_id, revision: pointer(stage_id).revision})), `${id}: 승인 버전 불일치`);
|
|
}
|
|
for (const id of ['08-scenes', '09-entities', '10-references', '11-images', '12-motion']) assert.equal(context[id].schema_version, '1.1');
|
|
const source = payload('11-images').result?.scene_file;
|
|
assert(source?.verified && /^[a-f0-9]{64}$/.test(source.sha256) && source.blender_version, '검증 원본 필요');
|
|
const root = (id) => `projects/${project.project_id}/stages/${id}/r${pointer(id).revision}/`;
|
|
const inside = (value, id) => assert(typeof value === 'string' && value.startsWith(root(id)) && !value.includes('..') && !value.includes('\\'), `${id}: 다른 버전/외부 경로`);
|
|
inside(source.path, '11-images');
|
|
assert.equal(payload('11-images').request.scene_path, source.path);
|
|
assert.equal(payload('12-motion').request.scene_path, source.path);
|
|
const profile = payload('11-images').request.render_profile;
|
|
assert.deepEqual(profile, payload('12-motion').request.render_profile, '원본/프로필 혼입');
|
|
assert.deepEqual(profile, payload('10-references').request.render_profile, '기준/애니메이션 프로필 혼입');
|
|
assert.equal(profile.width, 1080); assert.equal(profile.height, 1920); assert.equal(profile.fps, fps);
|
|
const preview = payload('08-scenes').preview;
|
|
assert(preview?.scene_file.verified); inside(preview.scene_file.path, '08-scenes');
|
|
for (const sample of preview.samples) {assert(sample.media.verified); inside(sample.media.path, '08-scenes');}
|
|
inside(payload('10-references').result.scene_file.path, '10-references');
|
|
const output = pointer('13-compose');
|
|
assert(output && ['draft', 'awaiting_review', 'complete'].includes(output.status), '새 합성 버전 준비 필요');
|
|
assert.equal(output.artifact_path, `projects/${project.project_id}/stages/13-compose/r${output.revision}/artifact.json`);
|
|
assert.equal(anchors.source_path, source.path); assert.equal(anchors.source_sha256, source.sha256);
|
|
assert.equal(anchors.width, profile.width); assert.equal(anchors.height, profile.height);
|
|
assert.equal(anchors.coordinate_system, 'top_left_pixel'); assert.equal(anchors.logical_to_blender, 'logical_frame + 1');
|
|
const cuts = payload('07-cuts').cuts, images = payload('11-images').result?.frames ?? [], clips = payload('12-motion').result?.clips ?? [];
|
|
assert.equal(cuts.length, 31); assert.equal(images.length, cuts.length * 2); assert.equal(clips.length, cuts.length);
|
|
for (const [rows, key] of [[cuts, 'id'], [images, 'frame_id'], [clips, 'cut_id'], [anchors.cuts, 'cut_id']]) assert.equal(new Set(rows.map((r) => r[key])).size, rows.length);
|
|
assert.equal(anchors.cuts.length, cuts.length);
|
|
let end = 0;
|
|
for (const cut of cuts) {
|
|
assert.equal(cut.start_ms, end, `${cut.id} gap/overlap`);
|
|
const duration = cut.end_ms - cut.start_ms;
|
|
assert(duration > 0 && duration <= project.settings.max_cut_duration_ms);
|
|
assert.equal(cut.start_ms % 20, 0); assert.equal(cut.end_ms % 20, 0);
|
|
const request = payload('12-motion').request.clips.find((r) => r.cut_id === cut.id);
|
|
const clip = clips.find((r) => r.cut_id === cut.id), anchor = anchors.cuts.find((r) => r.cut_id === cut.id);
|
|
assert(request && clip && anchor, `${cut.id}: 누락 컷/앵커`);
|
|
assert.equal(request.start_frame, cut.start_ms / 20); assert.equal(request.end_frame, cut.end_ms / 20);
|
|
assert.equal(request.duration_ms, duration); assert.equal(anchor.start_frame, request.start_frame);
|
|
assert.equal(anchor.end_frame, request.end_frame); assert.equal(anchor.scene_name, request.scene_name);
|
|
assert(clip.media.verified && clip.media.kind === 'video'); inside(clip.media.path, '12-motion');
|
|
assert.equal(clip.media.duration_ms, duration); assert.equal(clip.media.width, 1080); assert.equal(clip.media.height, 1920);
|
|
for (const position of ['start', 'end']) {
|
|
const image = images.find((f) => f.frame_id === `${cut.id}-${position}`);
|
|
const r = payload('11-images').request.frames.find((f) => f.id === image?.frame_id);
|
|
assert(image?.media.verified && image.media.kind === 'image', `${cut.id}-${position} unverified`);
|
|
inside(image.media.path, '11-images'); assert.equal(image.media.width, 1080); assert.equal(image.media.height, 1920);
|
|
assert.equal(r.scene_name, request.scene_name); assert.equal(r.frame_index, cut[`${position}_ms`] / 20);
|
|
}
|
|
for (const motion of anchor.motion) {
|
|
assert(motion.keys.length > 0);
|
|
if (motion.animated) {
|
|
assert.equal(motion.keys[0][0], request.start_frame); assert.equal(motion.keys.at(-1)[0], request.end_frame);
|
|
assert(motion.keys.every((k, i) => i === 0 || k[0] > motion.keys[i - 1][0]), `${cut.id}: 앵커 시간 순서 오류`);
|
|
}
|
|
assert(motion.keys.every((k) => k.length === 5 && k.every(Number.isFinite)), `${cut.id}: 앵커 키 오류`);
|
|
}
|
|
end = cut.end_ms;
|
|
}
|
|
assert.equal(end, payload('07-cuts').narration_duration_ms); assert(end <= project.settings.max_duration_ms);
|
|
assert.equal(payload('06-tts').result.narration.duration_ms, end); assert.equal(payload('07-cuts').captions.length, 38);
|
|
let captionEnd = 0;
|
|
for (const caption of payload('07-cuts').captions) {
|
|
assert(caption.text.trim() && caption.start_ms >= captionEnd && caption.end_ms > caption.start_ms && caption.end_ms <= end);
|
|
captionEnd = caption.end_ms;
|
|
}
|
|
for (const c of payload('08-scenes').connections.filter((c) => c.mode === 'continuous')) {
|
|
const get = (id) => images.find((i) => i.frame_id === id).media.path;
|
|
assert.equal(get(`${c.from_cut_id}-end`), get(`${c.to_cut_id}-start`));
|
|
}
|
|
}
|
|
|
|
export function prepareData(inputs = loadInputs()) {
|
|
const {project, context, anchors, anchorsPath} = inputs;
|
|
validateInputs(project, context, anchors);
|
|
const inputFiles = new Map();
|
|
const add = (file, expected) => {
|
|
const hash = sha256(file);
|
|
if (expected) assert.equal(hash, expected, `${relative(file)}: 실제 SHA 불일치`);
|
|
inputFiles.set(relative(file), hash);
|
|
return hash;
|
|
};
|
|
add(path.join(projectDir, 'project.json'));
|
|
const preview = context['08-scenes'].payload.preview;
|
|
add(absolute(preview.scene_file.path), preview.scene_file.sha256);
|
|
for (const sample of preview.samples) add(absolute(sample.media.path));
|
|
add(absolute(project.settings.style_reference));
|
|
const manifests = {};
|
|
for (const [id, a] of Object.entries(context)) {
|
|
const file = absolute(project.stages.find((s) => s.stage_id === id).artifact_path);
|
|
add(file);
|
|
if (Number(id.slice(0, 2)) >= 10) {
|
|
const manifestPath = path.join(path.dirname(file), 'manifest.json');
|
|
const manifest = read(manifestPath); add(manifestPath);
|
|
assert.equal(manifest.result, 'GREEN'); assert.equal(manifest.stage_id, id);
|
|
manifests[id] = new Map(manifest.actual_assets.map((m) => [m.path, m.sha256]));
|
|
assert.equal(sha256(file), manifests[id].get(relative(file)), `${id}: 단계 manifest 불일치`);
|
|
assert.deepEqual(manifest.render_profile, a.payload.request.render_profile);
|
|
}
|
|
}
|
|
const checked = (value, id) => {
|
|
const expected = manifests[id].get(value);
|
|
assert(expected, `${id}: manifest에서 누락된 입력 ${value}`);
|
|
return add(absolute(value), expected);
|
|
};
|
|
checked(relative(anchorsPath), '11-images');
|
|
for (const id of ['10-references', '11-images']) {
|
|
const source = context[id].payload.result.scene_file;
|
|
assert(source.verified && source.blender_version && /^[a-f0-9]{64}$/.test(source.sha256));
|
|
assert.equal(checked(source.path, id), source.sha256);
|
|
}
|
|
for (const frame of context['11-images'].payload.result.frames) {
|
|
if (inputFiles.has(frame.media.path)) continue;
|
|
checked(frame.media.path, '11-images');
|
|
const png = PNG.sync.read(fs.readFileSync(absolute(frame.media.path)), {checkCRC: true});
|
|
assert.equal(png.width, 1080); assert.equal(png.height, 1920);
|
|
}
|
|
const sound = context['06-tts'].payload.result;
|
|
for (const media of [...sound.files.map((f) => f.media), sound.narration]) {
|
|
assert(media.verified && media.kind === 'audio');
|
|
assert(media.path.startsWith(`projects/${project.project_id}/media/06-tts/r${context['06-tts'].revision}/`) && !media.path.includes('..'));
|
|
add(absolute(media.path)); measured(absolute(media.path), media.duration_ms, true);
|
|
}
|
|
const profile = context['11-images'].payload.request.render_profile;
|
|
const source = context['11-images'].payload.result.scene_file;
|
|
const publicDir = path.join(rendererDir, 'public'); fs.mkdirSync(publicDir, {recursive: true});
|
|
const clips = context['07-cuts'].payload.cuts.map((cut) => {
|
|
const media = context['12-motion'].payload.result.clips.find((c) => c.cut_id === cut.id).media;
|
|
const request = context['12-motion'].payload.request.clips.find((c) => c.cut_id === cut.id);
|
|
const hash = checked(media.path, '12-motion');
|
|
const evidencePath = path.join(path.dirname(absolute(media.path)), 'evidence.json');
|
|
checked(relative(evidencePath), '12-motion');
|
|
const evidence = read(evidencePath);
|
|
assert.deepEqual(evidence.scene_file, source, `${cut.id}: 원본 혼입`);
|
|
assert.deepEqual(evidence.render_profile, profile, `${cut.id}: 프로필 혼입`);
|
|
assert.equal(evidence.scene_name, request.scene_name);
|
|
assert.deepEqual(evidence.media, media);
|
|
assert.deepEqual(evidence.logical_frames, Array.from({length: request.end_frame - request.start_frame}, (_, i) => request.start_frame + i));
|
|
measured(absolute(media.path), cut.end_ms - cut.start_ms);
|
|
assert.equal(probe(absolute(media.path)).streams.length, 1, `${cut.id}: 무음 원 컷 필요`);
|
|
const video = `${cut.id}-${hash.slice(0, 16)}.mp4`;
|
|
fs.copyFileSync(absolute(media.path), path.join(publicDir, video));
|
|
return {...cut, frames: request.end_frame - request.start_frame, scene_name: request.scene_name,
|
|
video, video_path: media.path, anchors: anchors.cuts.find((a) => a.cut_id === cut.id)};
|
|
});
|
|
const titles = context['04-title'].payload;
|
|
const title = titles.candidates.find((t) => t.id === titles.selected_id)?.text;
|
|
assert(title?.trim(), '선택 제목 필요');
|
|
return {visual_engine: 'Blender', clips, captions: context['07-cuts'].payload.captions, title, fps,
|
|
duration_ms: sound.narration.duration_ms, narration: sound.narration, render_profile: profile,
|
|
source, input_files: [...inputFiles].map(([path, sha256]) => ({path, sha256})),
|
|
input_versions: Object.values(context).map(({stage_id, revision}) => ({stage_id, revision})),
|
|
output_revision: project.stages.find((s) => s.stage_id === '13-compose').revision};
|
|
}
|