정식 3D 제작 완료 기록과 기존 합성 구성 준비

This commit is contained in:
NINI
2026-10-10 03:29:40 +09:00
parent cefdc0f73b
commit 78fcd49a54
13 changed files with 3670 additions and 44 deletions
@@ -0,0 +1,213 @@
{
"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": "draft",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/10-references/r2/artifact.json",
"issues": [
{
"code": "BLENDER_PRODUCTION_IN_PROGRESS",
"message": "승인된 08·09 r3로 정식 기준 모델·끝점·전체 컷 영상을 제작합니다.",
"blocking": true
}
]
},
{
"stage_id": "11-images",
"status": "draft",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/11-images/r2/artifact.json",
"issues": [
{
"code": "BLENDER_PRODUCTION_IN_PROGRESS",
"message": "승인된 08·09 r3로 정식 기준 모델·끝점·전체 컷 영상을 제작합니다.",
"blocking": true
}
]
},
{
"stage_id": "12-motion",
"status": "draft",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/artifact.json",
"issues": [
{
"code": "BLENDER_PRODUCTION_IN_PROGRESS",
"message": "승인된 08·09 r3로 정식 기준 모델·끝점·전체 컷 영상을 제작합니다.",
"blocking": true
}
]
},
{
"stage_id": "13-compose",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
},
{
"stage_id": "14-review",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/14-review/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
}
],
"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 모델을 만들고 이것으로 영상을 만들수 있을까요?"
}
]
}
@@ -80,68 +80,38 @@
}, },
{ {
"stage_id": "10-references", "stage_id": "10-references",
"status": "draft", "status": "complete",
"revision": 2, "revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/10-references/r2/artifact.json", "artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/10-references/r2/artifact.json",
"issues": [ "issues": []
{
"code": "BLENDER_PRODUCTION_IN_PROGRESS",
"message": "승인된 08·09 r3로 정식 기준 모델·끝점·전체 컷 영상을 제작합니다.",
"blocking": true
}
]
}, },
{ {
"stage_id": "11-images", "stage_id": "11-images",
"status": "draft", "status": "complete",
"revision": 2, "revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/11-images/r2/artifact.json", "artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/11-images/r2/artifact.json",
"issues": [ "issues": []
{
"code": "BLENDER_PRODUCTION_IN_PROGRESS",
"message": "승인된 08·09 r3로 정식 기준 모델·끝점·전체 컷 영상을 제작합니다.",
"blocking": true
}
]
}, },
{ {
"stage_id": "12-motion", "stage_id": "12-motion",
"status": "draft", "status": "complete",
"revision": 2, "revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/artifact.json", "artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/artifact.json",
"issues": [ "issues": []
{
"code": "BLENDER_PRODUCTION_IN_PROGRESS",
"message": "승인된 08·09 r3로 정식 기준 모델·끝점·전체 컷 영상을 제작합니다.",
"blocking": true
}
]
}, },
{ {
"stage_id": "13-compose", "stage_id": "13-compose",
"status": "stale", "status": "draft",
"revision": 1, "revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r1/artifact.json", "artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r2/artifact.json",
"issues": [ "issues": []
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
}, },
{ {
"stage_id": "14-review", "stage_id": "14-review",
"status": "stale", "status": "draft",
"revision": 1, "revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/14-review/r1/artifact.json", "artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/artifact.json",
"issues": [ "issues": []
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
} }
], ],
"reviews": [ "reviews": [
@@ -0,0 +1,4 @@
node_modules/
.cache/
public/
bundle/
@@ -0,0 +1,23 @@
# 이 제작물의 실행 구성
사용자가 선택한 Remotion + FFmpeg 실행 경로다. 승인된 07/08/11 r1을 읽어 컷별 움직임을 만들고, 12의 실제 검증된 MP4와 승인된 통합 WAV·제목·자막으로 합성한다. 다른 제작물에 바로 사용할 수 있는 범용 엔진으로 간주하지 않는다.
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 -- 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와 검사 이미지에 기록한다.
실제 의존성·캐시·번들·복사된 public 미디어는 로컬 .gitignore로 제외한다. 입력 원본, 단계 결과, 승인 기록과 실제 출력 미디어는 프로젝트 제작 폴더에 보존한다.
@@ -0,0 +1,125 @@
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {AbsoluteFill, Composition, OffthreadVideo, Sequence, continueRender,
delayRender, getInputProps, registerRoot, staticFile, useCurrentFrame} from 'remotion';
const clamp = (n) => Math.max(0, Math.min(1, n));
const ease = (n) => n * n * (3 - 2 * n);
const ochre = (p, i) => p[i] > p[i + 1] + 8 && p[i + 1] > 150 && p[i + 1] > p[i + 2] + 12;
const loaded = new Map();
function loadImage(name) {
if (!loaded.has(name)) loaded.set(name, new Promise((resolve, reject) => {
const im = new Image(); im.onload = () => resolve(im); im.onerror = reject;
im.src = staticFile(name);
}));
return loaded.get(name);
}
function Motion({clip}) {
const frame = useCurrentFrame();
const ref = useRef(null);
const [pair, setPair] = useState(null);
const [handle] = useState(() => delayRender('load actual start/end PNG'));
useEffect(() => {
Promise.all([loadImage(clip.a), loadImage(clip.b)]).then(([a, b]) => {
const c = document.createElement('canvas'); c.width = 945; c.height = 1680;
const ctx = c.getContext('2d', {willReadFrequently: true});
ctx.drawImage(a, 0, 0); const pa = ctx.getImageData(0, 0, 945, 1680);
ctx.clearRect(0, 0, 945, 1680); ctx.drawImage(b, 0, 0);
const pb = ctx.getImageData(0, 0, 945, 1680);
const pixels = [];
for (let i = 0; i < pa.data.length; i += 4) {
if (ochre(pa.data, i) || ochre(pb.data, i)) pixels.push(i);
}
setPair({a, b, pa, pb, pixels}); continueRender(handle);
}).catch((e) => {throw e;});
}, [clip.a, clip.b, handle]);
useLayoutEffect(() => {
if (!pair || !ref.current) return;
const ctx = ref.current.getContext('2d');
const p = ease(clamp((frame * 20 - (clip.plan.delayMs ?? 0)) /
(clip.frames * 20 - (clip.plan.delayMs ?? 0)) * 1.35));
ctx.clearRect(0, 0, 945, 1680);
if (p >= 1 || frame === clip.frames - 1) {ctx.drawImage(pair.b, 0, 0); return;}
ctx.drawImage(pair.a, 0, 0);
if (!p || clip.plan.kind === 'hold') return;
let annotationPixels;
if (clip.plan.kind === 'annotation' || clip.plan.kind === 'deform') {
// Only ochre explanatory pixels change. All source atoms/internal bonds remain intact.
const out = new ImageData(new Uint8ClampedArray(pair.pa.data), 945, 1680);
for (const i of pair.pixels) {
const coordinate = clip.plan.direction === 'x' ? (i / 4 % 945) / 945 :
clip.plan.direction === 'y' ? Math.floor(i / 4 / 945) / 1680 : 0;
const t = clamp(p * (clip.plan.direction ? 1.4 : 1) - coordinate * 0.4);
for (let k = 0; k < 3; k++) out.data[i + k] =
pair.pa.data[i + k] * (1 - t) + pair.pb.data[i + k] * t;
}
ctx.putImageData(out, 0, 0);
annotationPixels = out.data;
}
if (clip.plan.kind === 'deform') {
const [a, b] = clip.boxes;
const box = a.map((v, i) => v + (b[i] - v) * p);
ctx.fillStyle = `rgb(${clip.background.join(',')})`;
// Clear all of the original contour, including non-elliptical antialiasing.
ctx.fillRect(a[0] - 3, a[1] - 3, a[2] + 6, a[3] + 6);
ctx.save(); ctx.beginPath(); ctx.ellipse(box[0] + box[2] / 2, box[1] + box[3] / 2,
box[2] / 2, box[3] / 2, 0, 0, Math.PI * 2); ctx.clip();
ctx.drawImage(pair.a, ...a, ...box);
ctx.globalAlpha = p; ctx.drawImage(pair.b, ...b, ...box); ctx.restore();
// Preserve external energy/arrow pixels if their callout edge overlaps the body bounding box.
for (const i of pair.pixels) {
ctx.fillStyle = `rgb(${annotationPixels[i]},${annotationPixels[i + 1]},${annotationPixels[i + 2]})`;
ctx.fillRect(i / 4 % 945, Math.floor(i / 4 / 945), 1, 1);
}
}
if (clip.plan.regions?.length) {
ctx.save(); ctx.beginPath();
for (const r of clip.plan.regions) ctx.rect(...r);
ctx.clip();
ctx.globalAlpha = p; ctx.drawImage(pair.b, 0, 0); ctx.restore();
}
}, [frame, pair, clip]);
return <canvas ref={ref} width={945} height={1680} style={{width: '100%', height: '100%'}}/>;
}
function TextLayer({data}) {
const ms = useCurrentFrame() * 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);
return <AbsoluteFill style={{fontFamily: 'Malgun Gothic, sans-serif', color: '#17314c'}}>
{ms < 7920 && <div style={{position: 'absolute', top: 35, left: 85, right: 85,
fontSize: 64, lineHeight: 1.1, fontWeight: 800, textAlign: 'center'}}>
물방울은 왜<br/>동그랗게 뭉칠까?
</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,
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,
minHeight: 160, borderRadius: 24, padding: '25px 28px', boxSizing: 'border-box',
background: '#17314cef', color: '#fffdf2', fontSize: 52, fontWeight: 700,
lineHeight: 1.4, whiteSpace: 'pre-line', textAlign: 'center', display: 'flex',
alignItems: 'center', justifyContent: 'center'}}>{caption.text}</div>}
</AbsoluteFill>;
}
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%'}}/>
: <Motion clip={clip}/>}
</Sequence>)}
{composed && <TextLayer data={data}/>}
</AbsoluteFill>;
}
function Root() {
const {data} = getInputProps();
return <>
<Composition id="Motion" component={Video} width={1080} height={1920} fps={50}
durationInFrames={5528} defaultProps={{data}}/>
<Composition id="Composed" component={Video} width={1080} height={1920} fps={50}
durationInFrames={5528} defaultProps={{data, composed: true}}/>
</>;
}
registerRoot(Root);
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import {artifact, prepareData, projectDir, read, validateInputs} 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();
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`);
}
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';},
]) {
const [p, c, i] = structuredClone([project, cuts, images]);
mutate(p, c, i);
assert.throws(() => validateInputs(p, c, i, scenes));
}
console.log('Actual inputs and failure checks passed: missing approval/frame, unverified media, timing gap and discontinuity.');
@@ -0,0 +1,117 @@
import fs from 'node:fs';
import path from 'node:path';
import assert from 'node:assert/strict';
import {fileURLToPath} from 'node:url';
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 absolute = (relative) => path.join(repoDir, relative);
export const relative = (file) => path.relative(repoDir, file).split(path.sep).join('/');
export const write = (file, value) => {
fs.mkdirSync(path.dirname(file), {recursive: true});
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
};
export function validateInputs(project, cuts, images, scenes) {
for (const id of ['story', 'scenes']) {
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);
}
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);
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');
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);
}
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')) {
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;}
}
}
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 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))};
});
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};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
{
"name": "surface-tension-renderer",
"private": true,
"type": "module",
"scripts": {
"render": "node render.mjs",
"check": "node check.mjs"
},
"dependencies": {
"@remotion/bundler": "4.0.534",
"@remotion/renderer": "4.0.534",
"pngjs": "7.0.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"remotion": "4.0.534"
}
}
@@ -0,0 +1,109 @@
import fs from 'node:fs';
import path from 'node:path';
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';
const mode = process.argv[2] ?? 'preview';
if (!['preview', 'motion', 'compose'].includes(mode)) throw new Error('Use preview, motion or 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 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});
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`)});
}
console.log('Actual Remotion state previews rendered. Visual inspection remains required.');
} else {
const master = path.join(out, mode === 'motion' ? 'motion-master.mp4' : 'captioned-silent.mp4');
if (!fs.existsSync(master)) {
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;}
}});
}
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;
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]);
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`)]);
}
}
}
} finally {await browser.close({silent: true});}
@@ -0,0 +1,54 @@
// Coordinates use the verified 945 x 1680 source PNG canvas.
// Each region changes only an explanatory element; the camera stays fixed.
export const plans = {
c01: {kind: 'patch', regions: [[420, 160, 480, 610]]},
c02: {kind: 'patch', regions: [[540, 200, 340, 340]]},
c03: {kind: 'annotation'}, c04: {kind: 'annotation'},
c05: {kind: 'hold'}, c06: {kind: 'annotation'},
c07: {kind: 'annotation', direction: 'x'}, c08: {kind: 'hold'},
c09: {kind: 'annotation'}, c10: {kind: 'annotation'},
c11: {kind: 'annotation', direction: 'y'},
c12: {kind: 'annotation', direction: 'x'}, c13: {kind: 'hold'},
c14: {kind: 'annotation'},
c15: {kind: 'annotation', direction: 'y'}, c16: {kind: 'annotation'},
c17: {kind: 'hold'}, c18: {kind: 'deform'},
c19: {kind: 'annotation'}, c20: {kind: 'deform'}, c21: {kind: 'deform'},
c22: {kind: 'annotation'}, c23: {kind: 'hold'}, c24: {kind: 'annotation'},
c25: {kind: 'hold'},
c26: {kind: 'patch', regions: [[400, 1030, 495, 225]], delayMs: 1900},
c27: {kind: 'annotation'},
c28: {kind: 'patch', regions: [[420, 160, 480, 610]]},
c29: {kind: 'annotation', direction: 'x'},
c30: {kind: 'deform'},
c31: {kind: 'deform', regions: [[510, 150, 395, 425], [430, 395, 140, 260], [515, 500, 230, 170]]},
};
// Spoken-term timing comes from the approved ASR-aligned 07/08 stages.
export const labels = [
{start: 4380, end: 7920, text: '확대 개념도', x: 704, y: 600},
{start: 7920, end: 11920, text: '산소 1개 · 수소 2개', x: 472, y: 430},
{start: 11920, end: 18080, text: '부분 전하 · 분자 전체는 중성', x: 472, y: 450},
{start: 11920, end: 18080, text: 'δ−', x: 472, y: 710, size: 56},
{start: 14780, end: 18080, text: 'δ+', x: 263, y: 920, size: 52},
{start: 14780, end: 18080, text: 'δ+', x: 677, y: 920, size: 52},
{start: 24380 + 1800, end: 32720, text: '수소결합', x: 510, y: 575},
{start: 28480, end: 32720, text: '분자 안', x: 177, y: 475},
{start: 28480, end: 32720, text: '분자 사이', x: 530, y: 985},
{start: 45120, end: 49360, text: '물끼리 뭉치는 성질', x: 472, y: 1220},
{start: 52080, end: 56120, text: '물 안쪽', x: 472, y: 1085},
{start: 56120, end: 63440, text: '공기 쪽', x: 472, y: 375},
{start: 56120, end: 63440, text: '표면', x: 820, y: 435},
{start: 61000, end: 63440, text: '물 분자 이웃이 적음', x: 472, y: 250},
{start: 67440 + 700, end: 70820, text: '에너지', x: 203, y: 665, size: 32},
{start: 67440, end: 70820, text: '에너지를 보태야 함', x: 472, y: 1080},
{start: 63440, end: 81520, text: '같은 물의 양 · 입체 물방울의 개념도', x: 472, y: 1190, size: 28},
{start: 63440 + 16760, end: 81520, text: '표면장력', x: 472, y: 570},
{start: 81520, end: 87380, text: '같은 물의 양 · 개념도', x: 472, y: 115},
{start: 82960 + 1200, end: 87380, text: '겉면이 가장 작음', x: 670, y: 940},
{start: 87380, end: 97760, text: '외부 영향이 작을 때', x: 225, y: 955, size: 30},
{start: 93420, end: 97760, text: '큰 물방울 · 바닥 접촉 예시', x: 641, y: 955, size: 29},
{start: 96160, end: 97760, text: '중력·바닥의 영향', x: 641, y: 1340, size: 30},
{start: 97760 + 700, end: 107360, text: '확대 개념도', x: 704, y: 130, size: 30},
{start: 101980 + 700, end: 104560, text: '서로 끌림', x: 704, y: 600},
{start: 104560 + 600, end: 107360, text: '표면 넓힘에 저항', x: 472, y: 1080},
];
@@ -0,0 +1,37 @@
{
"schema_version": "1.1",
"project_id": "water-hydrogen-bond-surface-tension",
"stage_id": "13-compose",
"revision": 2,
"inputs": [
{
"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": "12-motion",
"revision": 2
}
],
"payload": {
"request": {
"timeline": [],
"narration_path": "",
"captions": [],
"title": ""
},
"result": null
}
}
@@ -0,0 +1,48 @@
{
"schema_version": "1.1",
"project_id": "water-hydrogen-bond-surface-tension",
"stage_id": "14-review",
"revision": 2,
"inputs": [
{
"stage_id": "13-compose",
"revision": 2
}
],
"payload": {
"checks": [
{
"area": "story",
"verdict": "not_checked",
"evidence": ""
},
{
"area": "visual",
"verdict": "not_checked",
"evidence": ""
},
{
"area": "audio",
"verdict": "not_checked",
"evidence": ""
},
{
"area": "captions",
"verdict": "not_checked",
"evidence": ""
},
{
"area": "timing",
"verdict": "not_checked",
"evidence": ""
},
{
"area": "consistency",
"verdict": "not_checked",
"evidence": ""
}
],
"findings": [],
"recommendation": "incomplete"
}
}