승인된 Blender 컷의 실제 합성과 최종 검수 근거 추가
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user