승인된 Blender 컷의 실제 합성과 최종 검수 근거 추가

This commit is contained in:
NINI
2026-10-10 04:11:08 +09:00
parent 78fcd49a54
commit ff28b7b47c
26 changed files with 6955 additions and 220 deletions
@@ -2,15 +2,22 @@ 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';
import {plans, labels} from './scene-plan.mjs';
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) => read(path.join(projectDir, `stages/${stage}/r1/artifact.json`));
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) => {
@@ -18,100 +25,198 @@ export const write = (file, value) => {
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
};
export function validateInputs(project, cuts, images, scenes) {
for (const id of ['story', 'scenes']) {
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} approval required`);
for (const ref of review.artifact_refs) assert.equal(ref.revision, 1);
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 ['07-cuts', '08-scenes', '11-images']) {
assert.equal(project.stages.find((s) => s.stage_id === id)?.status, 'complete', `${id} incomplete`);
}
assert.equal(cuts.length, 31);
assert.equal(new Set(cuts.map((c) => c.id)).size, cuts.length);
assert.equal(images.length, 62);
assert.equal(new Set(images.map((i) => i.frame_id)).size, images.length);
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 <= 5000);
assert.equal(duration % 20, 0, '50fps requires exact frame times');
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}`);
assert(image?.media.verified, `${cut.id}-${position} unverified`);
assert.equal(image.media.width * 16, image.media.height * 9);
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, 110560);
assert(end <= project.settings.max_duration_ms);
for (const c of scenes.connections.filter((c) => c.mode === 'continuous')) {
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`));
}
}
function bodyBox(png) {
// Anti-aliased navy magnification guides can resemble cyan; use the largest
// connected filled region rather than the union of every matching pixel.
const width = 540, height = 410, mask = new Uint8Array(width * height);
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
const i = ((y + 620) * png.width + x + 220) * 4;
const [r, g, b] = png.data.subarray(i, i + 3);
mask[y * width + x] = r < 190 && g > 155 && b > 180 && g > r + 15 ? 1 : 0;
}
const queue = new Int32Array(mask.length); let largest = 0, bounds;
for (let seed = 0; seed < mask.length; seed++) {
if (!mask[seed]) continue;
let head = 0, tail = 1, minX = width, minY = height, maxX = 0, maxY = 0;
queue[0] = seed; mask[seed] = 0;
while (head < tail) {
const cell = queue[head++], x = cell % width, y = Math.floor(cell / width);
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
for (const next of [x ? cell - 1 : -1, x + 1 < width ? cell + 1 : -1,
y ? cell - width : -1, y + 1 < height ? cell + width : -1]) {
if (next >= 0 && mask[next]) {mask[next] = 0; queue[tail++] = next;}
}
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);
}
if (tail > largest) {largest = tail; bounds = [minX + 215, minY + 615, maxX - minX + 11, maxY - minY + 11];}
}
assert(largest > 10000, 'macro droplet body not found');
return bounds;
}
export function prepareData() {
const project = read(path.join(projectDir, 'project.json'));
const cutStage = artifact('07-cuts').payload;
const imageStage = artifact('11-images').payload;
const scenes = artifact('08-scenes').payload;
const images = imageStage.result?.frames ?? [];
validateInputs(project, cutStage.cuts, images, scenes);
const publicDir = path.join(rendererDir, 'public');
fs.mkdirSync(publicDir, {recursive: true});
const copied = new Map();
function asset(media) {
if (!copied.has(media.path)) {
const name = `asset-${copied.size}.png`;
const bytes = fs.readFileSync(absolute(media.path));
const png = PNG.sync.read(bytes, {checkCRC: true});
assert.equal(png.width, media.width); assert.equal(png.height, media.height);
fs.writeFileSync(path.join(publicDir, name), bytes);
copied.set(media.path, {name, png});
}
return copied.get(media.path);
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);
}
const clips = cutStage.cuts.map((c) => {
const a = asset(images.find((f) => f.frame_id === `${c.id}-start`).media);
const b = asset(images.find((f) => f.frame_id === `${c.id}-end`).media);
const plan = plans[c.id];
const boxes = plan.kind === 'deform' ? [bodyBox(a.png), bodyBox(b.png)] : null;
return {...c, frames: (c.end_ms - c.start_ms) / 20, a: a.name, b: b.name, plan,
boxes, background: Array.from(a.png.data.subarray(0, 3))};
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 = artifact('04-title').payload;
const title = titles.candidates.find((t) => t.id === titles.selected_id).text;
return {clips, captions: cutStage.captions, labels, title, fps,
duration_ms: cutStage.narration_duration_ms ?? clips.at(-1).end_ms};
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};
}