정식 3D 제작 완료 기록과 기존 합성 구성 준비
This commit is contained in:
+213
@@ -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",
|
||||
"status": "draft",
|
||||
"status": "complete",
|
||||
"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
|
||||
}
|
||||
]
|
||||
"issues": []
|
||||
},
|
||||
{
|
||||
"stage_id": "11-images",
|
||||
"status": "draft",
|
||||
"status": "complete",
|
||||
"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
|
||||
}
|
||||
]
|
||||
"issues": []
|
||||
},
|
||||
{
|
||||
"stage_id": "12-motion",
|
||||
"status": "draft",
|
||||
"status": "complete",
|
||||
"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
|
||||
}
|
||||
]
|
||||
"issues": []
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
"status": "draft",
|
||||
"revision": 2,
|
||||
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r2/artifact.json",
|
||||
"issues": []
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
"status": "draft",
|
||||
"revision": 2,
|
||||
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/artifact.json",
|
||||
"issues": []
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user