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