승인된 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;
}