승인된 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
@@ -0,0 +1,10 @@
# 합성 렌더러 작업 경계
이 코드는 해당 제작물의 승인된 Blender MP4와 앵커를 기존 Remotion + FFmpeg로 합성한다.
- 현재 포인터·선행 버전·완료 상태·실제 사용자 승인·원본/프로필/입력 SHA를 확인한다. r1 고정이나 파일 존재만으로 유효성을 인정하지 않는다.
- 11의 실제 카메라 투영 앵커, 12의 실제 무음 MP4, 06 원 WAV, 04 선택 제목과 07 자막 원문·시간을 유지한다. 옛 2D 변형을 새 Blender 합성에 호출하지 않는다.
- package.json/잠금 파일·전역 설정·원 미디어·01–12·이전 revision을 변경하지 않는다. project.json은 총괄만 작성한다. 다른 작업자의 변경을 되돌리지 않는다.
- 매 실행은 현재 13 revision 아래 새 attempt를 사용하고 실패 파일을 보존한다. 입력·코드 해시와 실제 출력 측정·전체 디코드를 기록한다. 이전 파일 존재를 성공이나 캐시 재사용 근거로 삼지 않는다.
- 위험한 동작 변경은 의미 있는 실패 재현부터 작성한다. npm.cmd run check와 실제 최종 출력 검증을 수행한다. 기술 검사·정지 표본·청취·전체 시청·사용자 final 승인을 분리한다.
- 소스·작은 JSON/MD만 지정 경로로 Git에 추가한다. ignored renderer 전체 폴더를 강제 추가하지 않는다. 미디어·의존성·캐시·번들은 커밋하지 않는다.
@@ -1,23 +1,22 @@
# 이 제작물의 실행 구성
# 이 제작물의 합성 실행 구성
사용자가 선택한 Remotion + FFmpeg 실행 경로다. 승인된 07/08/11 r1을 읽어 컷별 움직임을 만들고, 12의 실제 검증된 MP4와 승인된 통합 WAV·제목·자막으로 합성한다. 다른 제작물에 바로 사용할 수 있는 범용 엔진으로 간주하지 않는다.
사용자가 선택한 기존 Remotion + FFmpeg로 승인된 Blender 컷을 합성한다. `project.json`의 현재 포인터와 topic/story/scenes 승인 버전, 선행 완료 상태를 읽는다. 01–07 r1은 그대로 유지하고 승인된 08·09 r3, 정식 10–12 r2와 실제 앵커를 사용한다. 다른 소재의 장면을 자동 제작하는 범용 엔진이 아니다.
PowerShell에서 이 폴더를 작업 디렉터리로 사용한다.
PowerShell에서 이 폴더를 작업 디렉터리로 사용한다. 기존 로컬 의존성을 사용하며 재설치하지 않는다.
```powershell
npm.cmd ci --cache .cache/npm
npm.cmd run check
npm.cmd run render -- preview
npm.cmd run render -- motion
npm.cmd run render -- preview c05 c09 c16 c24 c27 c31
npm.cmd run render -- compose
```
- Remotion 4.0.534, React 19.3.0과 잠금 파일을 사용한다. FFmpeg/ffprobe와 Chrome Headless Shell은 로컬 node_modules에 준비된다. 전역 패키지·사용자 설정을 변경하지 않는다.
- 입력 PNG를 public에 그대로 복사한다. 50fps·1080×1920·5528프레임으로 승인된 20ms 단위 컷 경계를 보존한다. 분자·결합 도해의 강조와 물방울 몸체의 국소 변형을 제어한다.
- motion은 12-motion/r1의 실제 마스터와 31개 컷 MP4, 실측 probe.json을 만든다. compose는 담당자가 12 결과를 확인해 artifact.json에 verified=true로 기록한 뒤 실행한다.
- compose는 기존 컷 영상에 한국어 제목·도해 주석·승인된 38개 자막을 합성하고, 승인된 전체 WAV를 속도·음량·길이 변경 없이 AAC로 한 번 인코딩한다. MP4와 별도 SRT를 만든다.
- 렌더러는 단계 artifact.json이나 project.json, 사용자 승인을 작성하지 않는다. 측정값·파일 생성만으로 실제 화면 검수·청취 성공을 주장하지 않는다.
- 기존 출력이 있으면 다시 사용하므로, 중단·실패한 파일은 담당자가 확인한 뒤 별도 시도 파일로 보존하고 재실행한다. 잘못된 파일을 완료 결과로 등록하지 않는다.
- check는 입력·승인·시간·연속 경로의 의미 있는 실패 사례와 물방울 영역 검출을 확인한다. 실제 영상 검수는 단계별 review.md와 검사 이미지에 기록한다.
- Remotion 4.0.534, React 19.3.0 및 기존 잠금 파일을 유지한다. 로컬 node_modules의 FFmpeg/ffprobe와 기존 Chrome Headless Shell을 사용한다. 전역 환경·설정은 변경하지 않는다.
- check는 실제 SHA-256·PNG 디코드·MP4/WAV 실측, 31컷·62끝점·38자막·5528프레임과 승인·버전·미검증·시간·연결 오류 거부를 확인한다. 실제 청취·정상 속도 전체 시청을 대신하지 않는다.
- compose는 12의 실제 무음 MP4를 OffthreadVideo로 각 컷의 로컬 프레임에서 재생하고, 전체 50fps·1080×1920·110560ms를 유지한다. 11의 실제 카메라 투영 앵커와 움직임 키로 주석을 배치한다. 옛 PNG의 색 영역 검출이나 2D 변형은 이 합성에 호출하지 않는다.
- 04의 선택 제목과 07의 38자막 원문·구간을 그대로 사용한다. 승인된 통합 WAV 전체를 속도·gain·trim·새 BGM 없이 한 번 AAC 인코딩한다. 별도 SRT는 원문·시간 그대로다.
- 새 출력은 현재 13 revision의 `render-attempts/<입력 식별자>-<새 시도>/` 아래에 만든다. 원본 SHA·프로필·입력 파일 해시·버전·합성 코드·패키지 바이트를 식별에 넣고, 존재하는 옛 파일을 재사용하거나 덮어쓰지 않는다. 실패·부분 시도도 보존한다.
- preview는 같은 13 revision의 `previews/` 아래에 실제 MP4를 사용하는 합성 표본을 만든다. 정식 영상의 디코드 검수가 아니다. 새 Blender 원 컷은 `tools/blender`로 제작하며 이 렌더러의 motion 명령으로 생성하지 않는다. 옛 2D Motion 구현은 이전 결과의 참고로 보존한다.
- 최종 MP4의 전체 AV 디코드·스트림·5528프레임·110560ms, 모든 컷 시작/중간/끝·38자막 중간·주석 전환의 실제 디코드 PNG를 기록한다. 기술 검증과 모바일 표본 관찰, 정상 속도 전체 재생·청취, 사용자 final 승인은 구분한다.
- 실행기는 artifact.json·project.json·사용자 승인을 작성하지 않는다. 담당자가 실제 MP4/SRT와 근거를 13/14에 제출하고 총괄만 공유 상태를 갱신한다.
실제 의존성·캐시·번들·복사된 public 미디어는 로컬 .gitignore로 제외한다. 입력 원본, 단계 결과, 승인 기록과 실제 출력 미디어는 프로젝트 제작 폴더에 보존한다.
의존성·번들·public 복사본·캐시·실제 미디어는 Git에 추가하지 않는다. 이전 r1의 결과·사용자 피드백과 원 WAV·스타일은 보존한다. 플랫폼 공통 안전영역을 보장하지 않으며 실제 업로드 화면 검수는 별도다.
@@ -1,6 +1,7 @@
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {AbsoluteFill, Composition, OffthreadVideo, Sequence, continueRender,
delayRender, getInputProps, registerRoot, staticFile, useCurrentFrame} from 'remotion';
import {blenderLabels} from './scene-plan.mjs';
const clamp = (n) => Math.max(0, Math.min(1, n));
const ease = (n) => n * n * (3 - 2 * n);
@@ -83,16 +84,22 @@ function Motion({clip}) {
}
function TextLayer({data}) {
const ms = useCurrentFrame() * 20;
const frame = useCurrentFrame(), ms = frame * 20;
const caption = data.captions.find((c) => ms >= c.start_ms && ms < c.end_ms);
const labels = data.labels.filter((l) => ms >= l.start && ms < l.end);
const clip = data.clips.find((c) => ms >= c.start_ms && ms < c.end_ms);
const blender = data.visual_engine === 'Blender';
const labels = blender ? blenderLabels(clip, frame) : data.labels.filter((l) => ms >= l.start && ms < l.end);
return <AbsoluteFill style={{fontFamily: 'Malgun Gothic, sans-serif', color: '#17314c'}}>
{ms < 7920 && <div style={{position: 'absolute', top: 35, left: 85, right: 85,
{ms < 7920 && <div style={{position: 'absolute', top: 240, left: 85, right: 85,
fontSize: 64, lineHeight: 1.1, fontWeight: 800, textAlign: 'center'}}>
물방울은 왜<br/>동그랗게 뭉칠까?
{data.title}
</div>}
{labels.map((l, i) => <div key={i} style={{position: 'absolute', left: l.x / 945 * 1080,
top: l.y / 1680 * 1920, transform: 'translate(-50%, -50%)', fontSize: (l.size ?? 36) / 945 * 1080,
{blender && <svg width="1080" height="1920" style={{position: 'absolute', inset: 0}}>
{labels.filter((l) => l.leader).map((l, i) => <line key={i} x1={l.x} y1={l.y + 20}
x2={l.target[0]} y2={l.target[1]} stroke="#17314c" strokeWidth="3"/>)}
</svg>}
{labels.map((l, i) => <div key={i} style={{position: 'absolute', left: blender ? l.x : l.x / 945 * 1080,
top: blender ? l.y : l.y / 1680 * 1920, transform: 'translate(-50%, -50%)', fontSize: blender ? l.size : (l.size ?? 36) / 945 * 1080,
fontWeight: 700, whiteSpace: 'nowrap', background: l.text.startsWith('δ') ? 'transparent' : '#fffdf2ee',
padding: l.text.startsWith('δ') ? 0 : '9px 16px', borderRadius: 12}}>{l.text}</div>)}
{caption && <div style={{position: 'absolute', left: 80, right: 80, bottom: 140,
@@ -106,7 +113,7 @@ function TextLayer({data}) {
function Video({data, composed = false}) {
return <AbsoluteFill style={{background: '#fffdf2'}}>
{data.clips.map((clip) => <Sequence key={clip.id} from={clip.start_ms / 20} durationInFrames={clip.frames}>
{composed ? <OffthreadVideo src={staticFile(`${clip.id}.mp4`)} muted style={{width: '100%', height: '100%'}}/>
{composed ? <OffthreadVideo src={staticFile(clip.video ?? `${clip.id}.mp4`)} muted style={{width: '100%', height: '100%'}}/>
: <Motion clip={clip}/>}
</Sequence>)}
{composed && <TextLayer data={data}/>}
@@ -1,26 +1,46 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import {artifact, prepareData, projectDir, read, validateInputs} from './data.mjs';
import {loadInputs, prepareData, validateInputs, measured, absolute} from './data.mjs';
const project = read(path.join(projectDir, 'project.json'));
const cuts = artifact('07-cuts').payload.cuts;
const images = artifact('11-images').payload.result.frames;
const scenes = artifact('08-scenes').payload;
validateInputs(project, cuts, images, scenes);
const data = prepareData();
const inputs = loadInputs();
validateInputs(inputs.project, inputs.context, inputs.anchors);
const data = prepareData(inputs);
assert.equal(data.visual_engine, 'Blender');
assert.equal(data.clips.filter((c) => c.video_path && c.anchors).length, 31);
assert.equal(data.clips.reduce((n, c) => n + c.frames, 0), 5528);
for (const c of data.clips.filter((c) => c.boxes)) for (const box of c.boxes) {
assert(box[1] >= 660 && box[2] < 430, `${c.id}: guide pixels confused with droplet body`);
const cases = [
['scenes 미승인', (p) => {p.reviews.find((r) => r.review_id === 'scenes').decision = 'pending';}],
['옛 승인', (p) => {p.reviews.find((r) => r.review_id === 'scenes').artifact_refs[1].revision = 2;}],
['stale 결과', (p) => {p.stages.find((s) => s.stage_id === '12-motion').status = 'stale';}],
['다른 현재 경로', (p) => {p.stages.find((s) => s.stage_id === '12-motion').artifact_path = 'foreign.json';}],
['다른 선행 버전', (p, c) => {c['12-motion'].inputs[2].revision = 1;}],
['옛 엔진', (p) => {p.settings.visual_engine = 'Image';}],
['누락 컷', (p, c) => {c['12-motion'].payload.result.clips.pop();}],
['미검증 컷', (p, c) => {c['12-motion'].payload.result.clips[0].media.verified = false;}],
['다른 시각 버전', (p, c) => {c['12-motion'].payload.result.clips[0].media.path = 'projects/water-hydrogen-bond-surface-tension/stages/12-motion/r1/clip.mp4';}],
['누락 끝점', (p, c) => {c['11-images'].payload.result.frames.pop();}],
['프로필 혼입', (p, c) => {c['12-motion'].payload.request.render_profile.samples = 32;}],
['원본 혼입', (p, c, a) => {a.source_sha256 = '0'.repeat(64);}],
['시간 격자 오류', (p, c) => {c['07-cuts'].payload.cuts[1].start_ms += 1;}],
['연속 경계 불일치', (p, c) => {c['11-images'].payload.result.frames.find((f) => f.frame_id === 'c02-start').media.path = c['11-images'].payload.result.frames[2].media.path + 'wrong.png';}],
['다른 Scene 앵커', (p, c, a) => {a.cuts[0].scene_name = 'WrongScene';}],
['움직임 키 누락', (p, c, a) => {a.cuts.find((a) => a.motion.some((m) => m.animated)).motion.find((m) => m.animated).keys.pop();}],
['옛 대표 원본', (p, c) => {c['08-scenes'].payload.preview.scene_file.path = 'projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r1/old.blend';}],
['미검증 대표 화면', (p, c) => {c['08-scenes'].payload.preview.samples[0].media.verified = false;}],
];
for (const [name, mutate] of cases) {
const {project, context, anchors} = structuredClone(inputs);
mutate(project, context, anchors);
assert.throws(() => validateInputs(project, context, anchors), undefined, name);
}
for (const mutate of [
(p, c, i) => {p.reviews.find((r) => r.review_id === 'scenes').decision = 'pending';},
(p, c, i) => {i.pop();},
(p, c, i) => {i[0].media.verified = false;},
(p, c, i) => {c[1].start_ms += 20;},
(p, c, i) => {i.find((f) => f.frame_id === 'c02-start').media.path = 'wrong.png';},
assert.throws(() => measured(absolute(data.clips[1].video_path), data.clips[0].end_ms), /실제 시간 불일치/);
assert.throws(() => measured(absolute('projects/water-hydrogen-bond-surface-tension/missing.mp4'), 20));
for (const [name, mutate] of [
['없는 통합 WAV', (i) => {i.context['06-tts'].payload.result.narration.path = 'projects/water-hydrogen-bond-surface-tension/media/06-tts/r1/missing.wav';}],
['미검증 단위 WAV', (i) => {i.context['06-tts'].payload.result.files[0].media.verified = false;}],
['실측 WAV 시간 오류', (i) => {i.context['06-tts'].payload.result.files[0].media.duration_ms += 20;}],
['실제 원본 SHA 오류', (i) => {i.context['11-images'].payload.result.scene_file.sha256 = '0'.repeat(64); i.anchors.source_sha256 = '0'.repeat(64);}],
]) {
const [p, c, i] = structuredClone([project, cuts, images]);
mutate(p, c, i);
assert.throws(() => validateInputs(p, c, i, scenes));
const changed = structuredClone(inputs); mutate(changed);
assert.throws(() => prepareData(changed), undefined, name);
}
console.log('Actual inputs and failure checks passed: missing approval/frame, unverified media, timing gap and discontinuity.');
console.log(`실제 Blender 입력과 ${cases.length + 6}개 거부 사례 통과: 31컷·62끝점·38자막·5528프레임`);
@@ -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};
}
@@ -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});}
@@ -52,3 +52,86 @@ export const labels = [
{start: 101980 + 700, end: 104560, text: '서로 끌림', x: 704, y: 600},
{start: 104560 + 600, end: 107360, text: '표면 넓힘에 저항', x: 472, y: 1080},
];
// 새 합성 좌표는 Blender의 실제 카메라 투영(1080×1920)과 논리 프레임 키다.
export function anchorAt(cut, id, frame) {
const motion = cut.motion.find((m) => m.anchor_id === id);
if (!motion) throw new Error(`${cut.cut_id}: 없는 실제 앵커 ${id}`);
const keys = motion.keys;
const after = keys.findIndex((k) => k[0] > frame);
const a = after < 0 ? keys.at(-1) : keys[Math.max(0, after - 1)];
const b = after < 0 ? a : keys[after];
const t = a[0] === b[0] ? 0 : Math.max(0, Math.min(1, (frame - a[0]) / (b[0] - a[0])));
return {x: a[1] + (b[1] - a[1]) * t, y: a[2] + (b[2] - a[2]) * t,
target: [a[3] + (b[3] - a[3]) * t, a[4] + (b[4] - a[4]) * t]};
}
export function blenderLabels(clip, frame) {
const ms = frame * 20, result = [];
const at = (id, text, {dx = 0, dy = 0, size = 42, leader = false} = {}) => {
const a = anchorAt(clip.anchors, id, frame);
// 연결선은 mesh 위쪽 가장자리까지만 닿아 원자를 가리지 않는다.
result.push({...a, target: [a.target[0], a.y + 32], x: a.x + dx, y: a.y + dy, text, size, leader});
};
const heading = (text, y = 450, size = 42) => result.push({text, x: 540, y, size});
switch (clip.scene_name) {
case 'OpeningDroplet':
if (ms >= 4380) heading('이제 물속 분자로 확대', 560, 40);
break;
case 'SingleMolecule':
if (ms < 11920) {
heading('산소 1개 · 수소 2개', 500);
at('single_O_delta_minus', '산소', {dy: -15});
at('single_H_delta_plus', '수소'); at('single_H2_delta_plus', '수소');
} else {
heading('부분 전하 · 분자 전체는 중성', 500, 38);
at('single_O_delta_minus', 'δ−', {dy: -12, size: 58});
if (ms >= 14780) {
at('single_H_delta_plus', 'δ+', {size: 56}); at('single_H2_delta_plus', 'δ+', {size: 56});
}
}
break;
case 'BondPair':
at('pair_H_delta_plus', 'δ+', {size: 52}); at('pair_O_delta_minus', 'δ−', {size: 54});
if (ms >= 24380) heading('수소결합', 530, 48);
if (ms >= 28480) heading('실선: 분자 안 · 점선: 분자 사이', 620, 36);
break;
case 'LiquidNetwork':
heading(ms < 45120 ? '연결이 생겼다 끊어져요' : '물끼리 함께 뭉쳐요', 500, 44);
break;
case 'Interface':
heading('물 표면의 개념도', 460, 38);
if (ms >= 52080 && ms < 56120) at('water_interior', '물 안쪽', {dx: -240, dy: 450, size: 38});
if (ms >= 56120) {
at('air_region', '공기 쪽', {dy: -80, size: 40});
at('water_surface', '표면', {dx: 300, dy: -35, size: 38, leader: true});
}
if (ms >= 61000) heading('공기 쪽에 물 분자 이웃이 적음', 560, 36);
break;
case 'DropletStretch':
at('same_volume_drop', '같은 물의 양 · 개념도', {dy: -140, size: 36});
if (ms >= 67440 && ms < 70820) heading('표면을 넓히려면 에너지가 필요', 1380, 38);
if (ms >= 80200) heading('표면장력', 1400, 48);
break;
case 'EqualVolumeComparison':
heading('같은 물의 양 · 개념도', 500, 40);
at('equal_volume_left', '늘어난 모양', {dy: -70, size: 38});
at('equal_volume_right', '둥근 모양', {dy: -70, size: 38});
if (ms >= 84160) at('equal_volume_right', '겉면이 가장 작음', {dy: 490, size: 36});
break;
case 'ShapeConditions':
heading('크기·바닥 조건이 다른 예시', 470, 38);
at('small_free_condition', '작은 물방울', {dy: -110, size: 36});
at('small_free_condition', '외부 영향이 작을 때', {dy: 510, size: 32});
if (ms >= 93420) at('large_contact_condition', '큰 물방울 · 바닥 접촉', {dy: -120, size: 32});
if (ms >= 96160) at('contact_floor', '중력·바닥의 영향', {dy: 400, size: 34});
break;
case 'AnswerDroplet':
// 확대도를 처음 보여주는 프레임부터 명시한다. 공기 중 실제 분자 배치가 아니다.
at('answer_inset_O', '확대개념도', {dy: -95, size: 40});
if (ms >= 102680 && ms < 104560) at('answer_inset_O', '서로 끌림', {dy: 170, size: 36});
if (ms >= 105160 && ms < 107360) heading('표면 넓힘에 저항', 1390, 40);
break;
default: throw new Error(`미지원 실제 Scene ${clip.scene_name}`);
}
return result;
}
@@ -0,0 +1,2 @@
render-attempts/
previews/
@@ -20,6 +20,10 @@
"stage_id": "07-cuts",
"revision": 1
},
{
"stage_id": "11-images",
"revision": 2
},
{
"stage_id": "12-motion",
"revision": 2
@@ -27,11 +31,437 @@
],
"payload": {
"request": {
"timeline": [],
"narration_path": "",
"captions": [],
"title": ""
"timeline": [
{
"cut_id": "c01",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/f0fb77b44b3a7e0f-ff638f566c58/clip.mp4",
"start_ms": 0,
"end_ms": 4380
},
{
"cut_id": "c02",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/fe41e9c1c4904295-226129fb3aae/clip.mp4",
"start_ms": 4380,
"end_ms": 7920
},
{
"cut_id": "c03",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/252c4d4827f07900-062240cdd127/clip.mp4",
"start_ms": 7920,
"end_ms": 11920
},
{
"cut_id": "c04",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/3b097e55a9067a3d-88be52721626/clip.mp4",
"start_ms": 11920,
"end_ms": 14780
},
{
"cut_id": "c05",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/00a79a4042b7aa67-cbbd262fba23/clip.mp4",
"start_ms": 14780,
"end_ms": 18080
},
{
"cut_id": "c06",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/350ae6aed2bc06e0-83e884eeb30e/clip.mp4",
"start_ms": 18080,
"end_ms": 20340
},
{
"cut_id": "c07",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/f89fd3b5663c4f93-b0e80fa17c3a/clip.mp4",
"start_ms": 20340,
"end_ms": 24380
},
{
"cut_id": "c08",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/8a143b259afd678d-ff53e01947d9/clip.mp4",
"start_ms": 24380,
"end_ms": 28480
},
{
"cut_id": "c09",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/c5f863130bf54d86-99ecdd8c5b89/clip.mp4",
"start_ms": 28480,
"end_ms": 32720
},
{
"cut_id": "c10",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/31cefa5ad87204fd-de1e57c80c46/clip.mp4",
"start_ms": 32720,
"end_ms": 35560
},
{
"cut_id": "c11",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/962ede97c96eb35d-c66d07f83cf5/clip.mp4",
"start_ms": 35560,
"end_ms": 40360
},
{
"cut_id": "c12",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/12d62510bbd4ad00-f93131809a2d/clip.mp4",
"start_ms": 40360,
"end_ms": 45120
},
{
"cut_id": "c13",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/9d662faa9dd42767-87fe9d242858/clip.mp4",
"start_ms": 45120,
"end_ms": 49360
},
{
"cut_id": "c14",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/64e94571b9b8e28c-9cbd2e7a4324/clip.mp4",
"start_ms": 49360,
"end_ms": 52080
},
{
"cut_id": "c15",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/fb66ebe6435f6afa-906d837beca1/clip.mp4",
"start_ms": 52080,
"end_ms": 56120
},
{
"cut_id": "c16",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/9aeebb0967d39f44-bef15dce6126/clip.mp4",
"start_ms": 56120,
"end_ms": 61000
},
{
"cut_id": "c17",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/54d5f46a632a9695-ee9ec262318b/clip.mp4",
"start_ms": 61000,
"end_ms": 63440
},
{
"cut_id": "c18",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/d17c810836884739-389283a9c2fa/clip.mp4",
"start_ms": 63440,
"end_ms": 67440
},
{
"cut_id": "c19",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/d0d9e6a51128af61-c22fe8c8a04d/clip.mp4",
"start_ms": 67440,
"end_ms": 70820
},
{
"cut_id": "c20",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/74d4e59c54be4564-f2850c103ea7/clip.mp4",
"start_ms": 70820,
"end_ms": 73120
},
{
"cut_id": "c21",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/ed6625d7d0fbb445-2ef6d66052af/clip.mp4",
"start_ms": 73120,
"end_ms": 77040
},
{
"cut_id": "c22",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/a4a62fd0d88b0899-d8fd3685254d/clip.mp4",
"start_ms": 77040,
"end_ms": 81520
},
{
"cut_id": "c23",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/b51248951a76a664-ddf9f89c29c9/clip.mp4",
"start_ms": 81520,
"end_ms": 82960
},
{
"cut_id": "c24",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/3aee06d7644dd6d1-880195cf81ad/clip.mp4",
"start_ms": 82960,
"end_ms": 87380
},
{
"cut_id": "c25",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/82376fb41de0e0f4-f407ebbc225f/clip.mp4",
"start_ms": 87380,
"end_ms": 91520
},
{
"cut_id": "c26",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/8ef05230d2035e52-30a666b43b90/clip.mp4",
"start_ms": 91520,
"end_ms": 96160
},
{
"cut_id": "c27",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/d666eb5f1fcd316a-fdb7aee7aab7/clip.mp4",
"start_ms": 96160,
"end_ms": 97760
},
{
"cut_id": "c28",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/30cfd4ebd99f655e-e837add0d29e/clip.mp4",
"start_ms": 97760,
"end_ms": 101980
},
{
"cut_id": "c29",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/120ae7727066d6ac-4784e9f0c12b/clip.mp4",
"start_ms": 101980,
"end_ms": 104560
},
{
"cut_id": "c30",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/4267f4ad755c8d75-5a4cfec4ec07/clip.mp4",
"start_ms": 104560,
"end_ms": 107360
},
{
"cut_id": "c31",
"video_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/render-attempts/4c2c0967c96e947c-ca2c859c571e/clip.mp4",
"start_ms": 107360,
"end_ms": 110560
}
],
"narration_path": "projects/water-hydrogen-bond-surface-tension/media/06-tts/r1/narration.wav",
"captions": [
{
"id": "cap01",
"start_ms": 0,
"end_ms": 3060,
"text": "작은 물방울은 왜\n동그랗게 뭉칠까요?"
},
{
"id": "cap02",
"start_ms": 4380,
"end_ms": 7900,
"text": "비밀은 물속의\n작은 분자들에 있습니다."
},
{
"id": "cap03",
"start_ms": 7920,
"end_ms": 10160,
"text": "물 분자는 산소 하나와\n수소 둘로"
},
{
"id": "cap04",
"start_ms": 10160,
"end_ms": 11480,
"text": "이루어져 있어요."
},
{
"id": "cap05",
"start_ms": 11920,
"end_ms": 14500,
"text": "산소 쪽은 살짝 마이너스,"
},
{
"id": "cap06",
"start_ms": 14780,
"end_ms": 18040,
"text": "수소 쪽은 살짝 플러스\n성질을 띱니다."
},
{
"id": "cap07",
"start_ms": 18080,
"end_ms": 20340,
"text": "그래서 한 분자의\n수소 쪽과"
},
{
"id": "cap08",
"start_ms": 20340,
"end_ms": 23280,
"text": "옆 분자의 산소 쪽이\n서로 끌어당깁니다."
},
{
"id": "cap09",
"start_ms": 24380,
"end_ms": 27300,
"text": "이 분자 사이의 끌림을\n수소결합이라고 해요."
},
{
"id": "cap10",
"start_ms": 28480,
"end_ms": 30060,
"text": "한 물 분자 안의"
},
{
"id": "cap11",
"start_ms": 30060,
"end_ms": 32620,
"text": "산소와 수소를 묶는\n결합과는 다릅니다."
},
{
"id": "cap12",
"start_ms": 32720,
"end_ms": 35560,
"text": "수소결합은 분자들을\n영원히 묶는"
},
{
"id": "cap13",
"start_ms": 35560,
"end_ms": 37780,
"text": "끈은 아닙니다."
},
{
"id": "cap14",
"start_ms": 37780,
"end_ms": 40360,
"text": "물속에서는\n연결이 생겼다"
},
{
"id": "cap15",
"start_ms": 40360,
"end_ms": 42940,
"text": "끊어지기를 반복해요."
},
{
"id": "cap16",
"start_ms": 42940,
"end_ms": 46060,
"text": "그래도 많은 분자가\n서로 끌어당기니"
},
{
"id": "cap17",
"start_ms": 46060,
"end_ms": 49340,
"text": "물은 함께 뭉치려는\n성질을 보입니다."
},
{
"id": "cap18",
"start_ms": 49360,
"end_ms": 52080,
"text": "이제 물의 표면을 볼까요?"
},
{
"id": "cap19",
"start_ms": 52080,
"end_ms": 57120,
"text": "물 안쪽은 다른 물 분자들에\n둘러싸여 있지만,"
},
{
"id": "cap20",
"start_ms": 57120,
"end_ms": 61000,
"text": "공기와 만나는 표면은\n공기 쪽에"
},
{
"id": "cap21",
"start_ms": 61000,
"end_ms": 63420,
"text": "물 분자 이웃이 적습니다."
},
{
"id": "cap22",
"start_ms": 63440,
"end_ms": 67440,
"text": "이런 표면을\n더 넓게 만들려면"
},
{
"id": "cap23",
"start_ms": 67440,
"end_ms": 69840,
"text": "에너지를 보태야 합니다."
},
{
"id": "cap24",
"start_ms": 70820,
"end_ms": 73120,
"text": "그래서 물은\n표면의 넓이를"
},
{
"id": "cap25",
"start_ms": 73120,
"end_ms": 75420,
"text": "줄이는 쪽으로\n모양을 바꾸려 해요."
},
{
"id": "cap26",
"start_ms": 77040,
"end_ms": 79320,
"text": "표면이 늘어나는 데\n저항하는"
},
{
"id": "cap27",
"start_ms": 79320,
"end_ms": 81360,
"text": "이 성질이\n표면장력입니다."
},
{
"id": "cap28",
"start_ms": 81520,
"end_ms": 82960,
"text": "같은 양의 물이라면,"
},
{
"id": "cap29",
"start_ms": 82960,
"end_ms": 86760,
"text": "공 모양이 겉면을\n가장 작게 만들 수 있어요."
},
{
"id": "cap30",
"start_ms": 87380,
"end_ms": 89620,
"text": "중력이나 바닥의\n영향을 적게 받는"
},
{
"id": "cap31",
"start_ms": 89620,
"end_ms": 92740,
"text": "작은 물방울이\n둥글어지는 이유입니다."
},
{
"id": "cap32",
"start_ms": 93420,
"end_ms": 96160,
"text": "큰 물방울이나\n바닥에 닿은 물방울은"
},
{
"id": "cap33",
"start_ms": 96160,
"end_ms": 97740,
"text": "모양이 달라질 수 있어요."
},
{
"id": "cap34",
"start_ms": 97760,
"end_ms": 99820,
"text": "물방울을 감싼\n진짜 껍질이"
},
{
"id": "cap35",
"start_ms": 99820,
"end_ms": 101980,
"text": "있는 건 아닙니다."
},
{
"id": "cap36",
"start_ms": 101980,
"end_ms": 104560,
"text": "수소결합으로 서로\n끌어당기는 분자들과,"
},
{
"id": "cap37",
"start_ms": 104560,
"end_ms": 107360,
"text": "표면을 넓히기\n어려운 성질이"
},
{
"id": "cap38",
"start_ms": 107360,
"end_ms": 110540,
"text": "작은 물방울의 둥근 모양을\n만드는 거예요."
}
],
"title": "물방울은 왜 동그랗게 뭉칠까?"
},
"result": null
"result": {
"video": {
"path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r2/render-attempts/1d0ec28d2096c02c-01b90b61-4a1/surface-tension-ko.mp4",
"kind": "video",
"verified": true,
"duration_ms": 110560,
"width": 1080,
"height": 1920
},
"captions_path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r2/render-attempts/1d0ec28d2096c02c-01b90b61-4a1/surface-tension-ko.srt"
}
}
}
@@ -0,0 +1,5 @@
# 채택하지 않은 첫 합성 시도
`render-attempts/58dd1435f22ef0de-4aff9094-818`의 실제 MP4/SRT와 검사 표본을 보존한다. 실제 c02 중간 프레임에서 ‘물속 분자 보기’의 선이 다음 장면용 빈 확대 위치로 끝나는 배치 오류를 발견했다. 표면 Scene 주석 선은 이미 실제 mesh 위 가장자리까지 닿게 조정되어 원자를 가리지 않는다.
첫 MP4는 정상 인코딩·전체 AV 디코드가 가능하지만 새 완성본으로 채택하지 않는다. c02는 다음 분자 설명으로 확대한다는 화면 안내로 바꾸고 빈 위치의 연결선을 제거했다. 수정 소스 해시를 포함한 새 render-attempt에서 전체 합성을 다시 실행한다. 기존 시도의 성공 여부를 새 결과나 사용자 승인으로 재사용하지 않는다.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,183 @@
{
"schema_version": "1.1",
"project_id": "water-hydrogen-bond-surface-tension",
"brief": {
"topic": "물의 수소결합에 의한 표면장력에 대한 설명",
"language": "한국어"
},
"settings": {
"aspect_ratio": "9:16",
"max_duration_ms": 180000,
"max_cut_duration_ms": 5000,
"style_reference": "references/1.png",
"tts_engine": "Qwen3-TTS",
"conda_env": "qwen3-tts",
"visual_engine": "Blender"
},
"stages": [
{
"stage_id": "01-topic",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/01-topic/r1/artifact.json",
"issues": []
},
{
"stage_id": "02-research",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/02-research/r1/artifact.json",
"issues": []
},
{
"stage_id": "03-script",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/03-script/r1/artifact.json",
"issues": []
},
{
"stage_id": "04-title",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/04-title/r1/artifact.json",
"issues": []
},
{
"stage_id": "05-tts-split",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/05-tts-split/r1/artifact.json",
"issues": []
},
{
"stage_id": "06-tts",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/06-tts/r1/artifact.json",
"issues": []
},
{
"stage_id": "07-cuts",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/07-cuts/r1/artifact.json",
"issues": []
},
{
"stage_id": "08-scenes",
"status": "complete",
"revision": 3,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3/artifact.json",
"issues": []
},
{
"stage_id": "09-entities",
"status": "complete",
"revision": 3,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/09-entities/r3/artifact.json",
"issues": []
},
{
"stage_id": "10-references",
"status": "complete",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/10-references/r2/artifact.json",
"issues": []
},
{
"stage_id": "11-images",
"status": "complete",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/11-images/r2/artifact.json",
"issues": []
},
{
"stage_id": "12-motion",
"status": "complete",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/artifact.json",
"issues": []
},
{
"stage_id": "13-compose",
"status": "draft",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r2/artifact.json",
"issues": []
},
{
"stage_id": "14-review",
"status": "draft",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/artifact.json",
"issues": []
}
],
"reviews": [
{
"review_id": "topic",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "01-topic",
"revision": 1
}
],
"feedback": "추천안으로 진행합니다"
},
{
"review_id": "story",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "02-research",
"revision": 1
},
{
"stage_id": "03-script",
"revision": 1
},
{
"stage_id": "04-title",
"revision": 1
}
],
"feedback": "확정합니다."
},
{
"review_id": "scenes",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "07-cuts",
"revision": 1
},
{
"stage_id": "08-scenes",
"revision": 3
},
{
"stage_id": "09-entities",
"revision": 3
}
],
"feedback": "수정본을 승인합니다"
},
{
"review_id": "final",
"decision": "changes_requested",
"artifact_refs": [
{
"stage_id": "13-compose",
"revision": 1
},
{
"stage_id": "14-review",
"revision": 1
}
],
"feedback": "내용은 좋았지만 이미지의 퀄리티가 좋지 않았습니다. GPT-Image-2.5 Sunburst 대신에 blender로 3D 모델을 만들고 이것으로 영상을 만들수 있을까요?"
}
]
}
@@ -0,0 +1,21 @@
# 새 3D 완성본 합성 검토
- 제작: water-hydrogen-bond-surface-tension / 한국어.
- 대상: final 검토용 13-compose r2. 공유 상태 변경은 총괄에 awaiting_review를 제안한다.
- 실제 [MP4](render-attempts/1d0ec28d2096c02c-01b90b61-4a1/surface-tension-ko.mp4)와 [38구간 SRT](render-attempts/1d0ec28d2096c02c-01b90b61-4a1/surface-tension-ko.srt), [요청·결과](artifact.json), [입력·실측 근거](provenance.json).
- 제목: 물방울은 왜 동그랗게 뭉칠까?
- 사용자의 실제 scenes 승인: 07 r1·08 r3·09 r3, 응답 “수정본을 승인합니다”. 새 final 승인은 아직 없다. 이전 r1의 이미지 품질 변경 요청은 보존한다.
03/04/06/07 r1과 기존 원 WAV를 유지하고 정식 12 r2의 실제 무음 MP4 31개를 합성했다. 합성 주석은 11 r2의 실제 1080 카메라 투영 앵커와 움직임 키를 사용한다. 직접 사용한 11 r2도 inputs에 기록한다. 옛 PNG 색 영역 검출·2D 변형은 호출하지 않는다. 패키지·잠금 파일, 원 .blend·PNG·MP4·WAV와 01–12는 변경하지 않았다.
실제 완성 MP4는 H.264/AAC, 1080×1920·50fps·5528프레임·110560ms(1분 50.56초), 6,117,533바이트다. SHA-256은 `c36bfe927acffde38d60d5068cfc1acc22784bc4d4fdd30ca015bb141f919ce6`이다. 31컷은 0부터 연속이며 최장 4880ms다. SRT의 38원문·줄바꿈·순서·시간은 07과 정확히 같다. 마지막 자막 끝 110540ms와 기존 20ms 공백도 유지했다.
`npm.cmd run render -- compose`는 두 전체 시도 모두 종료 0이었다. [첫 시도](render-attempts/58dd1435f22ef0de-4aff9094-818/surface-tension-ko.mp4)의 c02에서 다음 Scene용 빈 확대 위치에 선이 끝나는 문제를 실제 최종 프레임으로 발견했다. 연결선을 제거하고 화면 안내로 바꾼 새 전체 시도를 채택했다. [불채택 사유](first-attempt-not-adopted.md)와 이전 실물은 보존한다. 기존 파일 존재로 재사용하거나 r1을 덮어쓰지 않았다.
원 WAV는 PCM16/mono/24000Hz·2653440샘플이다. 8발화의 PCM과 통합 PCM이 바이트 동일하고 원 9WAV 해시도 유지됐다. 속도·gain·trim·새 BGM 없이 WAV 전체를 한 번 AAC 인코딩했다. 실제 음성·영상 시작은 0ms, 각각 110560ms다. 전체 AV 디코드와 원 PCM/AAC 같은 위치 신호 대조를 통과했다. 상관계수 0.9999665011969985, SNR 41.68737842978274dB이며 끝의 768 AAC 패딩 샘플은 원 길이와 구별한다. 이 대조는 청취가 아니다.
[14 검수](../../14-review/r2/review.md)의 실제 최종 프레임 163개(31컷 시작/중간/끝 93, 38자막 중간, 16주석 전환의 직전/해당 32)를 360px 모바일 검사 판 29개로 직접 봤다. O의 δ−·두 H의 δ+, 실선과 점선, Interface의 실제 물 영역 포함·주석 가림, 같은 양의 비교와 별도 크기/바닥 조건, 마지막 상단 “확대개념도”를 확인했다. 자막의 글자·대비·줄바꿈과 주요 물체 분리도 확인했다. 고정 플랫폼 안전영역 보증으로 해석하지 않는다.
[제작 지침](../../../../../docs/short-video-guide.md)의 질문→분자 원인→표면 에너지→조건을 붙인 답 구조와 자막/도해 주석의 역할을 적용했다. 새 반전·위기·대안 실패는 소재 근거가 없어 넣지 않았다. 정상 속도 전체 연속 시청·청취, 플랫폼 앱 UI 겹침은 현재 세션에서 확인하지 못했다. 실제 파일은 준비됐으며 14의 미검수 영역을 not_checked/incomplete로 남기고 사용자 final 검토에 제출한다. 이는 확인된 결함 때문에 재생성을 요구하는 판정이 아니다.
실제 입력 바이트·버전·원본·프로필과 렌더러 SHA는 provenance에 기록했다. [렌더 당시 project 증거](render-time-project.json)의 해시를 유지하며 현재 brief/settings/01–12 포인터·상태·topic/story/scenes 승인과 대조한다. 총괄의 정상적인 13/14 상태·final 검토 갱신은 원 입력 변경과 구분한다. 실행기는 project.json·승인 기록을 작성하지 않는다.
@@ -0,0 +1 @@
inspection/
@@ -4,6 +4,42 @@
"stage_id": "14-review",
"revision": 2,
"inputs": [
{
"stage_id": "02-research",
"revision": 1
},
{
"stage_id": "03-script",
"revision": 1
},
{
"stage_id": "04-title",
"revision": 1
},
{
"stage_id": "06-tts",
"revision": 1
},
{
"stage_id": "07-cuts",
"revision": 1
},
{
"stage_id": "08-scenes",
"revision": 3
},
{
"stage_id": "09-entities",
"revision": 3
},
{
"stage_id": "11-images",
"revision": 2
},
{
"stage_id": "12-motion",
"revision": 2
},
{
"stage_id": "13-compose",
"revision": 2
@@ -13,36 +49,43 @@
"checks": [
{
"area": "story",
"verdict": "not_checked",
"evidence": ""
"verdict": "pass",
"evidence": "실제 최종 MP4의 38자막 중간 표본·SRT와 03 b1-question–b8-answer, 04 선택 제목, 02의 verified c1–c9를 대조했다. 0–7920ms의 질문, 7920–49360ms의 부분 전하·분자 사이 끌림·동적 연결, 49360–81520ms의 표면 이웃과 확장의 에너지, 81520–97760ms의 같은 물의 양과 외부 조건, 97760–110560ms의 껍질 오해 해소와 답을 유지했다. 새 수치·막·MD·항상 완전한 구 주장은 추가하지 않았다. 승인 원문과 실제 표본의 내용 대응 판정이며 전체 시청·청취 완료를 뜻하지 않는다."
},
{
"area": "visual",
"verdict": "not_checked",
"evidence": ""
"evidence": "실제 새 최종 MP4에서 31컷의 시작·중간·끝 93프레임, 38자막 중간, 16주석 전환 직전/해당 프레임 32개를 추출했다. inspection/cuts-01–16·captions-01–07·annotations-01–06을 360px 너비로 직접 확인했다. c03–c05의 온전한 O 하나/H 둘과 실제 O의 δ−·두 H의 δ+, c06–c13의 실선/점선·연결 변화, c14–c17의 물 영역 안 원자·결합과 공기/표면 구분, c18–c24의 같은 물 양 비교, c25–c27의 별도 크기·바닥 조건, c28–c31의 상단 확대개념도를 확인했다. 첫 시도의 c02 공중 연결선을 제거한 새 전체 시도에서 수정과 Interface 주석 선의 원자 가림 방지를 재확인했다. 표본에 남은 구조·가림 오류는 발견하지 않았으나 정상 속도 전체 연속 재생의 자연스러움은 확인하지 못했다."
},
{
"area": "audio",
"verdict": "not_checked",
"evidence": ""
"evidence": "사용자의 기존 내레이션 WAV 괜찮습니다 응답은 06 원 WAV의 청취 승인으로 보존한다. 최종 MP4는 원 WAV 전체를 속도·gain·trim·새 BGM 없이 한 번 AAC 인코딩했다. technical-check.json: 원 8발화 PCM과 통합 PCM 동일, 24000Hz mono/2653440샘플/110560ms, 전체 AV 디코드 성공. 같은 위치 원 PCM/AAC 상관계수 0.9999665011969985, SNR 41.68737842978274dB다. 디코드 끝의 768샘플 AAC 패딩을 원 길이와 구별했다. 신호 대조는 청취가 아니다. 현재 세션 오디오 입력 미지원으로 최종 MP4 발음·음량·누락/중복을 직접 듣지 못했다."
},
{
"area": "captions",
"verdict": "not_checked",
"evidence": ""
"evidence": "실제 SRT의 38번호·한국어 원문·줄바꿈·시작/끝 시간이 07 captions 및 13 request와 정확히 같음을 verify_final.py로 확인했다. 실제 cap01–cap38 중간 프레임을 360px 모바일 검사 판으로 직접 보며 52px 원 글자·대비·줄바꿈·주석/물체 가림을 확인했다. 32주석 전환 표본도 확인했고 잘림이나 주요 도해 가림을 발견하지 않았다. 원 음성과 정상 속도로 함께 재생하는 읽기·청취 동기화 및 플랫폼 앱 UI 겹침은 확인하지 못했으므로 영역 전체 통과는 보류한다."
},
{
"area": "timing",
"verdict": "not_checked",
"evidence": ""
"verdict": "pass",
"evidence": "실제 최종 MP4 H.264 1080×1920=9:16/50fps/5528프레임, 영상·AAC 음성 시작 0ms/각 110560ms, 전체 AV 디코드 성공. 실제 12r2의 31무음 MP4 프레임·시간과 07r1/13 timeline이 일치하고 0부터 연속·합계 110560ms·최장 4880ms로 모두 5000ms 이하 및 전체 180000ms 이하다. 원 WAV는 2653440샘플/24000Hz=110560ms다. 자막 범위 0–110540ms와 끝의 20ms 공백을 그대로 유지했다. 속도·시간을 바꿔 불일치를 감추지 않았다."
},
{
"area": "consistency",
"verdict": "not_checked",
"evidence": ""
"verdict": "pass",
"evidence": "실제 최종 MP4의 모든 31컷 첫/중간/마지막 93표본을 cuts-01–16에서 직접 대조했다. 승인된 08r3의 continuous 22쌍은 같은 Scene·카메라·물체 위치/방향을 이어가고 말에 맞는 자막/주석만 전환한다. cut 8쌍 c02→c03/c05→c06/c09→c10/c13→c14/c17→c18/c22→c23/c24→c25/c27→c28은 설명 규모·표면·물방울·비교 조건·결말의 실제 다른 Scene으로 전환한다. 09r3/11r2의 산호색 O·미색 H·남색 내부 실선·금색 분자 사이 점선·청색 물·크림 배경을 유지했다. 판정은 실제 연결 경계와 고정 특징 표본 대조 범위에 한한다."
}
],
"findings": [
{
"severity": "blocking",
"description": "최종 MP4 전체 0–110560ms의 정상 속도 연속 시청·청취가 아직 확인되지 않았다. 실제 기술·신호·모바일 정지 표본은 청취·전체 재생 통과를 대신하지 않는다. 사용자 final 검토에서 움직임 자연스러움·발음/음량·누락/중복·자막 동기화를 확인해야 한다. 이는 현재 결함을 확인해 재생성을 요구하는 판정이 아니다. 증상이 있으면 실제 시간/컷을 근거로 12/13, 원 음성 문제이면 06의 새 버전으로 돌아간다.",
"stage_id": "13-compose",
"cut_id": null
}
],
"findings": [],
"recommendation": "incomplete"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
"""실제 최종 MP4에서 추출한 표본을 360px 모바일 너비로 나란히 보여준다."""
from pathlib import Path
import json
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[5]
PROJECT = ROOT/'projects/water-hydrogen-bond-surface-tension'
a=json.loads((PROJECT/'stages/13-compose/r2/artifact.json').read_text(encoding='utf-8'))
video=ROOT/a['payload']['result']['video']['path']
p=json.loads((video.parent/'probe.json').read_text(encoding='utf-8'))
out=Path(__file__).parent/'inspection';out.mkdir(exist_ok=True)
font=ImageFont.truetype('C:/Windows/Fonts/malgun.ttf',18)
for category,prefix in [('cuts','c'),('captions','caption-'),('annotations','annotation-')]:
samples=[s for s in p['samples'] if s['id'].startswith(prefix) and (category != 'cuts' or s['id'][1:3].isdigit())]
assert len(samples) == {'cuts':93,'captions':38,'annotations':32}[category], category
for group in range((len(samples)+5)//6):
rows=samples[group*6:group*6+6]
sheet=Image.new('RGB',(1080,1320),'white');draw=ImageDraw.Draw(sheet)
for index,sample in enumerate(rows):
x=index%3*360;y=index//3*660
with Image.open(ROOT/sample['path']) as image:
sheet.paste(image.convert('RGB').resize((360,640),Image.Resampling.LANCZOS),(x,y+20))
draw.text((x+8,y),sample['id']+' F'+str(sample['frame']),font=font,fill='black')
sheet.save(out/f'{category}-{group+1:02d}.png')
print(category,len(samples),'실제 프레임 →',(len(samples)+5)//6,'모바일 검사 판')
@@ -0,0 +1,22 @@
# 새 3D 완성본의 여섯 영역 검수
대상은 13-compose r2의 실제 새 MP4/SRT와 [14 JSON](artifact.json)이다. [완성본과 합성 근거](../../13-compose/r2/review.md), [실측·신호 검사](technical-check.json), [입력·실측 해시](../../13-compose/r2/provenance.json)를 연결한다. 검수 추천은 incomplete이며 총괄에 13/14 awaiting_review를 제안한다. 사용자 final 승인을 기록하거나 제작 완료를 선언하지 않는다.
| 영역 | 판정 | 확인 근거와 남은 범위 |
| --- | --- | --- |
| story | pass | 02 verified c1–c9·03 b1–b8·04 선택 제목과 실제 38자막 표본/SRT의 질문부터 조건을 붙인 답까지 대응. 새 수치·막·MD·항상 완전한 구 주장 없음. 내용 대응 판정이며 전체 시청·청취가 아님 |
| visual | not_checked | 실제 최종 MP4의 163프레임과 360px 모바일 검사 판 직접 관찰. δ−/두 δ+·원자 수·실선/점선·물 영역 포함·같은 양/별도 조건·확대개념도·가림을 확인. 정상 속도 전체 움직임 자연스러움은 미확인 |
| audio | not_checked | 전체 AV 디코드·원 8단위/통합 PCM 동일·원 WAV/AAC 상관계수 0.9999665011969985, SNR 41.68737842978274dB. 신호 대조는 청취가 아님. 세션 오디오 입력 미지원으로 최종 발음·음량·누락/중복 미청취 |
| captions | not_checked | 38 SRT 원문·줄바꿈·시간 정확 동일, 실제 38중간/32주석 전환의 대비·글자·가림 확인. 정상 속도 청취 동기화·플랫폼 앱 UI 미확인 |
| timing | pass | 1080×1920·50fps·5528F, 영상/AAC 각 0–110560ms. 31컷 합계 동일·최장 4880ms. WAV 2653440샘플. SRT 0–110540ms 및 마지막 20ms 공백 보존 |
| consistency | pass | 모든 컷 시작/중간/끝 93표본에서 승인 08 r3의 continuous 22쌍/cut 8쌍, 같은 Scene 카메라·위치/방향·09 r3/11 r2 고정 특징 대조. 실제 경계·고정 특징 표본 범위의 판정 |
모바일 검사 판은 inspection/cuts-01–16(93프레임), captions-01–07(38프레임), annotations-01–06(32프레임)이다. 각 화면 너비는 360px이며 PNG 원본은 실제 최종 MP4에서 추출한 540×960 표본이다. 소스 PNG나 합성 요청 표본을 최종 검수로 대체하지 않았다. 검토용 파생 판을 분류하는 접두사 오류는 개수 assertion의 RED→GREEN으로 수정했고 원 MP4/SRT에는 영향이 없다. 초기 파생 중복 판도 삭제하지 않았다.
실제 최종 표본에서 발견한 첫 c02의 공중 연결선은 13에서 제거한 새 전체 시도로 수정했고, c16 주석 선은 실제 mesh 위 가장자리까지 닿아 원자를 가리지 않는다. 새 시도의 실제 31컷·38자막·주석 전환 표본에서 남은 잘림·주요 물체 가림·구조 오류는 발견하지 않았다. 마지막 상단 분자도는 처음 표시하는 97760ms부터 “확대개념도”가 붙어 공기 중 실제 분자로 설명하지 않는다. Interface 절개는 물의 고체 막이라고 설명하지 않는다.
필수 최종 확인의 남은 범위는 전체 0–110560ms 정상 속도 연속 시청·청취다. findings의 blocking 항목은 이 검수 부재를 뜻하며 현재 결함 때문에 전체 재생성을 요구하는 판정이 아니다. 사용자는 실제 완성본에서 움직임 자연스러움·발음/음량·누락/중복·자막 동기화를 확인할 수 있다. 증상이 있으면 시간/컷을 근거로 12/13, 원 음성이 문제면 06의 새 버전으로 돌아간다. 기존 “내레이션 WAV 괜찮습니다.” 응답을 최종 MP4 청취 승인으로 바꾸지 않는다.
[제작 지침](../../../../../docs/short-video-guide.md)의 도입 약속·원리·조건·결말 회수, 실제 시간과 자막/도해 구분을 적용했다. 소재에 없는 위기·반전·대안 실패는 넣지 않았다. 고정 플랫폼 안전영역이나 모든 프레임의 품질·시청 유지율을 보장하지 않는다. 기존 r1 완성본·이미지 품질 변경 피드백과 r2/r3 장면 이력은 보존한다.
실제 명령: 렌더러 폴더의 `npm.cmd run check`(24거부 사례·종료 0), `npm.cmd run render -- compose`(새 실제 전체 시도·종료 0). 저장소 루트의 `C:/Users/baram/anaconda3/python.exe -B -X utf8 projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/verify_final.py`(종료 0), 같은 Python의 `mobile_views.py`(최종 93/38/32분류·종료 0). 기존 `contracts/check.py`(10개·종료 0)와 `tools/blender/test_runner.py`(17개·종료 0)도 유지했다. 설치·Blender 재렌더·전역 설정 변경은 실행하지 않았다.
@@ -0,0 +1,30 @@
{
"result": "GREEN",
"video": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r2/render-attempts/1d0ec28d2096c02c-01b90b61-4a1/surface-tension-ko.mp4",
"video_sha256": "c36bfe927acffde38d60d5068cfc1acc22784bc4d4fdd30ca015bb141f919ce6",
"frames": 5528,
"duration_ms": 110560,
"width": 1080,
"height": 1920,
"fps": 50,
"cuts": 31,
"captions": 38,
"full_av_decode": true,
"srt_exact_original": true,
"input_sha256_unchanged": true,
"renderer_sha256_unchanged": true,
"render_time_project_sha256": "e1f82e03e4af6832d8e7185eece36b9eb6e7beb763cac17e716618c7806ce780",
"current_prior_inputs_and_approvals_equal_snapshot": true,
"wav_samples": 2653440,
"decoded_aac_samples": 2654208,
"aac_extra_padding_samples": 768,
"unit_pcm_equals_original_wav": true,
"aac_original_correlation_zero_lag": 0.9999665011969985,
"aac_original_snr_db": 41.68737842978274,
"decoded_audio_path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r2/render-attempts/1d0ec28d2096c02c-01b90b61-4a1/inspection/decoded-narration-20fa510a3f85.wav",
"decoded_audio_sha256": "43b03bf4818275a7dd17f40e036e345c2e0f57f60beddc0bdcb9c0f326a324f6",
"final_decoded_samples": 163,
"normal_speed_full_playback": "not_checked",
"listening": "not_checked",
"note": "AAC 대조는 원 샘플 길이만 비교하고 원 WAV/합성 속도는 변경하지 않았다. 신호·표본은 청취·전체 재생 검수를 대신하지 않는다."
}
@@ -0,0 +1,113 @@
"""현재 완성본의 실측·원문·입력 해시 확인. 청취나 정상 속도 시청을 주장하지 않는다."""
from pathlib import Path
import hashlib
import json
import re
import subprocess
import wave
import uuid
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parents[5]
PROJECT = ROOT / 'projects/water-hydrogen-bond-surface-tension'
read = lambda p: json.loads(p.read_text(encoding='utf-8'))
sha = lambda p: hashlib.sha256(p.read_bytes()).hexdigest()
project = read(PROJECT / 'project.json')
def artifact(stage):
pointer = next(s for s in project['stages'] if s['stage_id'] == stage)
return read(ROOT / pointer['artifact_path'])
a = artifact('13-compose')
cut = artifact('07-cuts')['payload']
sound = artifact('06-tts')['payload']['result']
motion = artifact('12-motion')['payload']['result']['clips']
titles = artifact('04-title')['payload']
assert a['schema_version'] == '1.1' and a['revision'] == 2
assert a['payload']['request']['captions'] == cut['captions']
assert a['payload']['request']['title'] == next(t['text'] for t in titles['candidates'] if t['id'] == titles['selected_id'])
assert a['payload']['request']['narration_path'] == sound['narration']['path']
assert a['payload']['request']['timeline'] == [dict(cut_id=c['id'], video_path=next(m['media']['path'] for m in motion if m['cut_id']==c['id']), start_ms=c['start_ms'], end_ms=c['end_ms']) for c in cut['cuts']]
video = ROOT / a['payload']['result']['video']['path']
out = video.parent
inputs = read(out / 'inputs.json')
snapshot_path = PROJECT/'stages/13-compose/r2/render-time-project.json'
snapshot = read(snapshot_path)
for key in ['schema_version', 'project_id', 'brief', 'settings']:
assert project[key] == snapshot[key], f'현재 제작 조건 변경: {key}'
prior = lambda p: [s for s in p['stages'] if int(s['stage_id'][:2]) <= 12]
approvals = lambda p: [r for r in p['reviews'] if r['review_id'] != 'final']
assert prior(project) == prior(snapshot), '렌더 후 선행 현재 포인터/상태 변경'
assert approvals(project) == approvals(snapshot), '렌더 후 topic/story/scenes 승인 변경'
for stage in ['13-compose', '14-review']:
current = next(s for s in project['stages'] if s['stage_id'] == stage)
original_pointer = next(s for s in snapshot['stages'] if s['stage_id'] == stage)
assert (current['revision'],current['artifact_path']) == (original_pointer['revision'],original_pointer['artifact_path'])
assert current['status'] in ['draft','awaiting_review','complete'], f'{stage}: 유효하지 않은 현재 상태'
for i in inputs['inputs']:
file = snapshot_path if i['path'] == str((PROJECT/'project.json').relative_to(ROOT)).replace(chr(92),'/') else ROOT/i['path']
assert sha(file) == i['sha256'], i['path']
for i in inputs['renderer']:
assert sha(ROOT / i['path']) == i['sha256'], i['path']
result = read(out / 'result.json')
assert sha(video) == result['video_sha256']
ffdir = PROJECT / 'renderer/node_modules/@remotion/compositor-win32-x64-msvc'
ffmpeg, ffprobe = ffdir / 'ffmpeg.exe', ffdir / 'ffprobe.exe'
def run(args):
subprocess.run([str(ffmpeg), '-hide_banner', '-loglevel', 'error', '-n', *map(str,args)], check=True)
probe = json.loads(subprocess.check_output([str(ffprobe), '-v', 'error', '-show_streams', '-show_format', '-of', 'json', str(video)]))
v = next(s for s in probe['streams'] if s['codec_type']=='video')
au = next(s for s in probe['streams'] if s['codec_type']=='audio')
assert (v['width'],v['height'],v['r_frame_rate'],int(v['nb_frames'])) == (1080,1920,'50/1',5528)
assert round(float(v['duration'])*1000) == round(float(au['duration'])*1000) == 110560
assert float(v['start_time']) == float(au['start_time']) == 0
assert au['codec_name']=='aac' and au['sample_rate']=='24000' and au['channels']==1
run(['-i',video,'-map','0:v:0','-map','0:a:0','-c:v','rawvideo','-c:a','pcm_s16le','-f','null','-'])
srt = ROOT / a['payload']['result']['captions_path']
assert sha(srt) == result['captions_sha256']
def ms(text):
h,m,s,f=map(int,re.split('[:,]',text));return ((h*60+m)*60+s)*1000+f
blocks=srt.read_text(encoding='utf-8').strip().split('\n\n')
assert len(blocks)==38
for index,(block,c) in enumerate(zip(blocks,cut['captions']),1):
seq,times,text=block.split('\n',2)
start,end=times.split(' --> ')
assert int(seq)==index and (ms(start),ms(end),text)==(c['start_ms'],c['end_ms'],c['text'])
original = ROOT / sound['narration']['path']
with wave.open(str(original),'rb') as w:
assert (w.getframerate(),w.getnchannels(),w.getsampwidth(),w.getnframes()) == (24000,1,2,2653440)
original_pcm=w.readframes(w.getnframes())
x=np.frombuffer(original_pcm,dtype='<i2').astype(np.float64)
unit_pcm=[]
for unit in sound['files']:
with wave.open(str(ROOT/unit['media']['path']),'rb') as w:
assert (w.getframerate(),w.getnchannels(),w.getsampwidth()) == (24000,1,2)
unit_pcm.append(w.readframes(w.getnframes()))
assert b''.join(unit_pcm)==original_pcm, '원 발화 PCM/통합 WAV 불일치'
decoded = out / f'inspection/decoded-narration-{uuid.uuid4().hex[:12]}.wav'
run(['-i',video,'-map','0:a:0','-c:a','pcm_s16le',decoded])
with wave.open(str(decoded),'rb') as w:
y=np.frombuffer(w.readframes(w.getnframes()),dtype='<i2').astype(np.float64)
assert len(y)>=len(x)
y0=y[:len(x)]
corr=float(np.corrcoef(x,y0)[0,1])
snr=float(10*np.log10(np.sum(x*x)/np.sum((x-y0)**2)))
assert corr>0.99, 'AAC 신호/정렬 불일치'
samples=read(out / 'probe.json')['samples']
assert len(samples)==163
for sample in samples:
assert sha(ROOT/sample['path'])==sample['sha256']
with Image.open(ROOT/sample['path']) as image:
assert image.format == 'PNG' and image.size == (540, 960)
image.load()
report={'result':'GREEN','video':str(video.relative_to(ROOT)).replace(chr(92),'/'),'video_sha256':sha(video),
'frames':5528,'duration_ms':110560,'width':1080,'height':1920,'fps':50,'cuts':31,'captions':38,
'full_av_decode':True,'srt_exact_original':True,'input_sha256_unchanged':True,'renderer_sha256_unchanged':True,
'render_time_project_sha256':sha(snapshot_path),'current_prior_inputs_and_approvals_equal_snapshot':True,
'wav_samples':len(x),'decoded_aac_samples':len(y),'aac_extra_padding_samples':len(y)-len(x),
'unit_pcm_equals_original_wav':True,
'aac_original_correlation_zero_lag':corr,'aac_original_snr_db':snr,
'decoded_audio_path':str(decoded.relative_to(ROOT)).replace(chr(92),'/'),'decoded_audio_sha256':sha(decoded),
'final_decoded_samples':len(samples),'normal_speed_full_playback':'not_checked','listening':'not_checked',
'note':'AAC 대조는 원 샘플 길이만 비교하고 원 WAV/합성 속도는 변경하지 않았다. 신호·표본은 청취·전체 재생 검수를 대신하지 않는다.'}
(PROJECT/'stages/14-review/r2/technical-check.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(json.dumps(report,ensure_ascii=False))