승인된 Blender 컷의 실제 합성과 최종 검수 근거 추가

This commit is contained in:
NINI
2026-10-10 04:11:08 +09:00
parent 78fcd49a54
commit ff28b7b47c
26 changed files with 6955 additions and 220 deletions
+15
View File
@@ -83,3 +83,18 @@ if ($LASTEXITCODE -ne 0) { throw '계약 회귀 검사 실패' }
```
검사는 기존 1.0 예제·실제 제작 JSON, 새 1.1 빈 양식, 잘못된 버전·엔진·추가 키·검증 원본의 해시/버전 누락·프로필·시간을 다룬다. 의미 사례는 .blend의 media 위장, ID 중복·미존재 참조·Scene 불일치·9:16·루트 상대 경로·result=null 완료 금지를 다룬다. 전체 원본·미디어·승인·화면의 실제 검증은 각 단계의 실행 경로와 검토 근거로 확인한다. [1.1 연결 예제](../examples/blender-contract/README.md)는 실제 파일이 없는 요청 예제다.
## 승인된 물 영상 Blender 컷의 실제 합성
기존 Remotion + FFmpeg 설치와 패키지/잠금 파일을 유지한다. [제작 렌더러 안내](../projects/water-hydrogen-bond-surface-tension/renderer/README.md)의 현재 입력·실제 파일 검사를 먼저 실행한다. 저장소 루트의 PowerShell에서:
```powershell
npm.cmd --prefix projects/water-hydrogen-bond-surface-tension/renderer run check
npm.cmd --prefix projects/water-hydrogen-bond-surface-tension/renderer run render -- compose
& 'C:/Users/baram/anaconda3/python.exe' -B -X utf8 projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/verify_final.py
& 'C:/Users/baram/anaconda3/python.exe' -B -X utf8 projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/mobile_views.py
```
새 compose는 현재 13 revision의 새 render-attempts 폴더에 실제 MP4/SRT와 입력/실측/최종 프레임 표본을 만들며 기존 파일을 재사용하거나 r1을 덮어쓰지 않는다. 실행기는 artifact/project·사용자 승인을 쓰지 않는다. 담당자가 실제 받은 결과 경로를 13에 연결한 뒤 verify_final을 실행한다. 현재 제출한 r2는 [13 검토](../projects/water-hydrogen-bond-surface-tension/stages/13-compose/r2/review.md)와 [14 검수](../projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/review.md)에 있다.
이 제작의 verify_final은 제출한 r2 결과·렌더 당시 project 증거·현재 유효 선행 입력과 실제 파일을 검사한다. 총괄의 13/14 상태·final 기록 갱신은 렌더 시점 project 바이트와 구별하지만 원 WAV·컷·선행 artifact·합성 코드/패키지 SHA 변경은 거부한다. 새 합성 시도는 새 렌더 시점 project 증거와 결과를 담당자가 연결해야 한다. 기술 검사·모바일 표본·신호 대조를 정상 속도 전체 재생·청취나 사용자 final 승인으로 처리하지 않는다. 미검수 영역은 not_checked/incomplete로 제출하고 실제 완성본은 final 검토에 제공한다.
+10 -3
View File
@@ -36,12 +36,19 @@
- 남은 확인: 현재 세션은 오디오 입력을 지원하지 않으며 정상 속도 전체 시청·청취를 수행하지 못했다. 원 WAV에 대한 기존 사용자 청취 확인은 보존하되 최종 MP4의 청취 확인으로 바꾸지 않는다. 파일·신호·정지 표본 검사로 미검수 영역을 pass 처리하지 않았다.
- 영향: 실제 13 출력과 14 보고는 사용자 final 검토에 제출할 수 있다. 13/14는 awaiting_review이며 14 추천은 incomplete다. 영상 파일 부재나 실행 경로 부재로 기다리는 상태가 아니다. 시청 중 실제 증상이 확인되면 시간/컷과 원인 단계로 돌아간다.
- 이후 사용자 피드백: 내용은 수용했으나 이미지 품질 변경을 요청해 Blender 3D로 전환했다. 현재 13/14 r1은 stale, 기존 final은 changes_requested다. 기존 결과·미청취 기록은 보존하며, 새 08·09 r2와 실제 3D 대표 샘플의 scenes 검토를 기다린다. 새 완성본의 재생·청취 확인은 전체 제작 후 다시 진행한다.
- 이후 사용자 피드백: 내용은 수용했으나 이미지 품질 변경을 요청해 Blender 3D로 전환했다. 13/14 r1은 stale이고 기존 final의 changes_requested 피드백을 보존한다. 이후 수정한 07 r1·08 r3·09 r3가 실제 승인되어 정식 10–12 r2와 새 13/14 r2로 진행했다. 새 완성본은 실제 MP4/SRT가 있으며 아래 최종 재생·청취 대기 기록을 따른다.
## 물 분자와 표시 체적의 포함 관계 — 해소, r3 장면 검토 대기
## 물 분자와 표시 체적의 포함 관계 — 해소, r3 장면 승인
- 대상: 08-scenes r2의 c16 Interface. [실제 사용자 피드백](../../projects/water-hydrogen-bond-surface-tension/history/scenes-r2-feedback.md)을 보존했다.
- 확인: PNG에서 하단 분자가 물 영역 실루엣 밖에 보인다. 작성 코드의 WaterCutawayBody 범위는 y=0.95–2.45, z=-2.65–0.35인데 분자는 앞쪽 y에 배치돼 있고 하단 원자 일부도 z 범위를 벗어난다. 설명용 절개라는 문구로 표시 체적과 분자의 실제 포함 관계 오류를 정당화할 수 없다.
- 영향: r2 시각 품질 통과 판단을 취소하고 08·09 r3를 작성한다. 기존 대본·WAV·r2 결과를 보존하며 전체 제작은 시작하지 않는다.
- 수정·검증: 실제 물 영역과 절개 화면을 맞추고, 분자 중심이 아닌 평가된 전체 원자·결합 mesh를 Interface의 모든 프레임에서 검사한다. 기존 원본 RED와 수정 GREEN, 실제 새 PNG·MP4·모바일 화면으로 확인한다.
- 해소: r3의 실제 앞면·윗면 열린 절개 안에 분자·결합을 배치했다. 전체 705프레임·평가 정점 11,747,388개가 물 체적과 실제 네 표시 면의 투영 영역 안에 포함된다. 기존 r2와 중심을 유지한 원자 정점 오류는 정확한 포함 assertion으로 종료 1, 수정 원본은 종료 0이다. 실제 새 c16 MP4 전체 디코드와 시작·중간·마지막·모바일 화면에서도 돌출이 없음을 확인했다. 이전 r2 실물 75개와 전환 전 기준 36개 파일은 SHA-256 동일하다. [새 검토](../../projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3/review.md)는 사용자 scenes 승인을 기다리며 전체 제작 완료로 처리하지 않는다.
- 해소: r3의 실제 앞면·윗면 열린 절개 안에 분자·결합을 배치했다. 전체 705프레임·평가 정점 11,747,388개가 물 체적과 실제 네 표시 면의 투영 영역 안에 포함된다. 기존 r2와 중심을 유지한 원자 정점 오류는 정확한 포함 assertion으로 종료 1, 수정 원본은 종료 0이다. 실제 새 c16 MP4 전체 디코드와 시작·중간·마지막·모바일 화면에서도 돌출이 없음을 확인했다. 이전 r2 실물 75개와 전환 전 기준 36개 파일은 SHA-256 동일하다. [새 검토](../../projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3/review.md)는 이후 실제 “수정본을 승인합니다” 응답으로 승인됐고 정식 10–12 r2를 제작했다. 최종 사용자 승인과는 구분한다.
## 새 3D 완성본의 전체 재생·청취 — final 검토 대기
- 대상: [13 r2 실제 MP4/SRT](../../projects/water-hydrogen-bond-surface-tension/stages/13-compose/r2/review.md)와 [14 r2 여섯 영역 보고](../../projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/review.md).
- 완료한 확인: 실제 1080×1920·50fps·5528F·110560ms, 31컷/38 SRT 원문·시간, 전체 AV 디코드·원 WAV/AAC 신호·원 입력 SHA 및 실제 최종 163프레임/모바일 표본 관찰. c02의 공중 연결선은 별도 전체 시도로 수정했고 첫 시도는 보존한다.
- 남은 범위: 현재 세션 오디오 입력 미지원과 정상 속도 전체 연속 재생 미확인으로 visual/audio/captions 전체 판정은 not_checked, 추천은 incomplete다. 원 WAV의 기존 사용자 수용을 새 MP4의 청취 승인으로 바꾸지 않는다.
- 영향: 실제 파일·검토 근거가 준비돼 13/14 awaiting_review를 제안하며 사용자 final 검토를 기다린다. 확인된 결함으로 재생성을 요구하거나 파일 부재로 실행을 멈춘 상태가 아니다. 시청에서 증상이 있으면 실제 시간/컷과 원인 단계의 새 버전으로 돌아간다.
+11 -8
View File
@@ -15,15 +15,18 @@ JSON·TOML·스킬 형식과 링크·단계 참조를 실제 명령으로 확인
Codex CLI의 존재·버전은 확인했지만 새 세션에서 사용자 지정 역할·스킬의 자동 탐색은 아직 확인하지 않았다. 물 제작의 기존 06 r1에는 사용자 수용 응답과 실제 Qwen3-TTS WAV·실측 구간이 있으며, 기존 13 r1에는 실제 합성 MP4/SRT가 있다. 이 기존 결과의 전체 재생·청취 검수는 완료로 기록하지 않는다.
Blender 5.2.2에서 작은 검사 씬의 EEVEE/GPU와 Cycles/CPU 렌더·PNG/MP4 전체 디코드를 확인했다. EEVEE의 50fps 실행과 Cycles/OPTIX GPU 정지 렌더도 확인했다. 검사 씬은 물 영상의 품질 승인 근거가 아니다. 실행기의 14개 회귀 검사와 10개 계약 검사는 통과했으며, 미승인 제작·누락 파일·잘못된 참조·이전 시각 버전 혼입을 거부한다.
Blender 5.2.2에서 작은 검사 씬의 EEVEE/GPU와 Cycles/CPU 렌더·PNG/MP4 전체 디코드를 확인했다. EEVEE의 50fps 실행과 Cycles/OPTIX GPU 정지 렌더도 확인했다. 검사 씬은 물 영상의 품질 승인 근거가 아니다. 실행기의 17개 회귀 검사와 10개 계약 검사는 통과했으며, 미승인 제작·누락 파일·잘못된 참조·이전 시각 버전 혼입을 거부한다.
물 제작은 01–07 r1과 승인된 WAV를 유지한다. 사용자가 지적한 r2의 분자·물 영역 포함 관계 오류를 앞선 시각 검수가 놓쳤으며 [r2 피드백](../../projects/water-hydrogen-bond-surface-tension/history/scenes-r2-feedback.md)을 보존했다. 새 08·09 r3에서 실제 영역과 전체 원자·결합을 수정하고 705프레임 전체 mesh 정점 및 실제 표시 면의 투영 영역 검사를 통과했다. 실제 새 대표 PNG·MP4 각각 3개와 직접 관찰 근거를 [r3 장면 검토](../../projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3/review.md)에 제출한다. 현재 r3는 awaiting_review, scenes 결정은 pending이고 기존 10–14 r1은 stale이다. [전환 전 진행 기록](../../projects/water-hydrogen-bond-surface-tension/history/pre-blender-project.json)과 r2 결과·미디어도 보존한다.
물 제작은 01–07 r1과 승인된 WAV를 유지한다. 사용자가 지적한 r2의 분자·물 영역 포함 관계 오류를 앞선 시각 검수가 놓쳤으며 [r2 피드백](../../projects/water-hydrogen-bond-surface-tension/history/scenes-r2-feedback.md)을 보존했다. 새 08·09 r3에서 실제 영역과 전체 원자·결합을 수정하고 705프레임 전체 mesh 정점 및 실제 표시 면의 투영 영역 검사를 통과했다. 실제 새 대표 PNG·MP4 각각 3개와 직접 관찰 근거를 [r3 장면 검토](../../projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3/review.md)에 제출한다. 사용자가 07 r1·08 r3·09 r3에 “수정본을 승인합니다”라고 응답해 scenes가 approved이며 정식 10–12 r2를 제작했다. 기존 10–14 r1은 stale로 보존한다. [전환 전 진행 기록](../../projects/water-hydrogen-bond-surface-tension/history/pre-blender-project.json)과 r2 결과·미디어도 보존한다.
## 다음 구현
## 새 3D 완성본과 남은 검토
1. 준비된 물 영상의 새 3D 장면 설계와 대표 PNG·짧은 MP4에 대해 실제 scenes 검토를 받는다.
2. 해당 승인 후 기준 모델·62개 끝점 ID·31개 실제 컷 영상을 새 버전으로 제작한다.
3. 기존 Remotion + FFmpeg로 새 컷과 기존 음성·38개 자막을 합성하고 실제 여섯 영역 검수와 final 검토를 진행한다.
4. 다른 소재의 장면 제작은 이 범용 실행 경로를 사용하되 소재별 모델과 근거를 별도로 작성한다.
승인된 10 r2의 기준 모델, 11 r2의 62끝점 ID·실제 앵커, 12 r2의 31무음 MP4로 새 13 r2 MP4/SRT를 만들었다. 원 대본·제목·WAV·38자막은 유지했다. 실제 완성본은 1080×1920·50fps·5528프레임·110560ms이며 전체 AV 디코드·원 WAV/AAC 신호·모든 컷과 자막/주석 전환의 실제 최종 163표본을 확인했다. 합성 입력 검사의 24개 거부 사례도 통과한다.
대표 영상의 실제 디코드 화면 비교에서 비활성 Scene의 애니메이션이 정지하는 문제를 발견했고, 렌더 전에 선택 Scene을 활성화하도록 수정했다. 초기 활성 Scene이 Idle인 같은 원본에서 두 장면의 PNG·MP4 실제 RGB 움직임 검사가 실패→통과로 바뀌었으며 독립 검토도 통과했다. 물 대표 렌더는 수정된 실행기로 재제작하고 실제 움직임을 확인했다. 전체 컷 제작·새 완성본 검수는 scenes 승인 후의 후속 작업이다.
[새 합성 검토](../../projects/water-hydrogen-bond-surface-tension/stages/13-compose/r2/review.md)와 [여섯 영역 보고](../../projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/review.md)를 사용자 final 검토에 제출한다. 정상 속도 전체 시청·청취는 현재 세션에서 확인하지 못해 visual/audio/captions는 not_checked, 추천은 incomplete다. 13/14는 awaiting_review를 제안하며 새 final 승인이나 제작 완료를 기록하지 않았다. 영상 실행·파일 부재로 멈춘 상태는 아니다.
1. 실제 새 MP4의 정상 속도 전체 재생·청취와 사용자 final 검토를 마친다.
2. 증상이 발견되면 실제 시간/컷과 원인 단계의 새 버전으로 수정한다.
3. 다른 소재는 같은 실행 경로를 사용하되 별도 근거·모델·장면을 작성한다.
대표 영상의 실제 디코드 화면 비교에서 비활성 Scene의 애니메이션이 정지하는 문제를 발견했고, 렌더 전에 선택 Scene을 활성화하도록 수정했다. 초기 활성 Scene이 Idle인 같은 원본에서 두 장면의 PNG·MP4 실제 RGB 움직임 검사가 실패→통과로 바뀌었으며 독립 검토도 통과했다. 물 대표 렌더는 수정된 실행기로 재제작하고 실제 움직임을 확인했다. 이후 실제 r3 scenes 승인 뒤 정식 10–12 r2와 새 13/14 r2로 진행했다. 정상 속도 전체 재생·청취와 새 final 승인은 남아 있다.
+23 -2
View File
@@ -20,7 +20,7 @@ JSON 34개 중 데이터 양식·예제 30개는 해당 스키마로 검증했
## Blender 전환 진행 중 검사
아래는 초기 기반 검사 이후 실제 실행한 추가 검사다. 기존 물 제작의 WAV와 r1 결과를 보존하고 새 3D 대표 렌더를 준비하는 중이며, 정식 전체 제작·새 완성본의 검사 기록은 아직 없다.
아래는 초기 기반 검사 이후 실제 실행한 추가 검사다. 기존 물 제작의 WAV와 r1 결과를 보존하고 새 3D 대표 렌더를 준비하는 중이며, 당시 정식 전체 제작·새 완성본 검사는 아직 없었다. 이후 실제 기록은 아래 새 완성본 절에 추가한다.
| 실제 실행 | 종료 코드 | 확인한 내용 |
| --- | --- | --- |
@@ -65,4 +65,25 @@ JSON 34개 중 데이터 양식·예제 30개는 해당 스키마로 검증했
| 실제 새 MP4 3개 probe·전체 디코드·프레임 추출 | 0 | 50fps·1080×1920·202/244/200프레임·4040/4880/4000ms. 실제 9프레임·모바일 화면에서 돌출 없음과 움직임 직접 확인 |
| 기존 작업 루트의 실물·이력 SHA-256 대조 | 0 | 새 실물 25개 동일, 이전 r2 파일 75개·전환 전 기준 36개 변경 없음 |
최소 모델 축 여백은 0.3246/0.4540/0.1150, 투영 윤곽 여백은 47.94px다. 설명 모형·카메라의 검사 수치이며 과학적 측정값이나 최종 자막·플랫폼 안전영역 보증이 아니다. 사용자 scenes refs는 07 r1·08 r3·09 r3이고 결정은 pending이다.
최소 모델 축 여백은 0.3246/0.4540/0.1150, 투영 윤곽 여백은 47.94px다. 설명 모형·카메라의 검사 수치이며 과학적 측정값이나 최종 자막·플랫폼 안전영역 보증이 아니다. 당시 사용자 scenes refs는 07 r1·08 r3·09 r3이고 결정은 pending이었다. 이후 실제 “수정본을 승인합니다” 응답으로 approved가 되어 정식 전체 제작을 진행했다.
## 승인된 Blender 전체 컷과 새 완성본
2026-10-10 확인. 07 r1·08 r3·09 r3의 실제 사용자 scenes 승인 이후 정식 10/11/12 r2를 사용했다. 원 대본·제목·WAV·38자막·스타일과 r1 피드백은 보존했다. 설치·Blender 모델/원 컷 재렌더·전역 설정 변경은 합성 작업에서 실행하지 않았다.
| 실제 실행 | 종료 코드 | 확인한 내용 |
| --- | --- | --- |
| 렌더러 폴더의 첫 `npm.cmd run check` | 1 (RED) | r1 고정 승인 검사에서 실제 승인 r3를 거부하는 `3 !== 1` 재현 |
| 같은 폴더의 최종 `npm.cmd run check` | 0 | 실제 원본/PNG/MP4/WAV·현재 포인터/승인·SHA·시간과 24거부 사례. 31컷·62끝점·38자막·5528F |
| `npm.cmd run render -- preview c01 c05 c09 c16 c19 c24 c27 c31` | 0 | 실제 새 MP4/앵커를 사용한 합성 화면 24표본. 최종 검수와 구분 |
| `npm.cmd run render -- compose` 두 전체 시도 | 각각 0 | 실제 MP4/SRT 생성. 첫 c02의 빈 확대 위치 연결선을 최종 디코드 화면에서 발견·수정해 새 시도로 전체 재출력 |
| `C:/Users/baram/anaconda3/python.exe -B -X utf8 projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/verify_final.py` | 0 | 실제 최종 H.264/AAC·1080×1920·50fps·5528F·0–110560ms, 전체 AV 디코드, 원 8단위/통합 PCM 동일, SRT 38원문/시간 정확 동일·입력/렌더러 SHA 유지 |
| 같은 Python의 `projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/mobile_views.py` | 1→0 | cut 접두사가 caption까지 포함하는 파생 판 분류 오류 RED→GREEN. 실제 최종 93컷/38자막/32주석 전환 표본을 360px 검사 판 16/7/6개로 확인 |
| `C:/Users/baram/anaconda3/python.exe -B -X utf8 tools/blender/test_runner.py` | 0 | 기존 실행기 회귀 17개 |
| `C:/Users/baram/anaconda3/python.exe -B -X utf8 contracts/check.py` | 0 | 계약 회귀 10개 |
채택 MP4는 6,117,533바이트, SHA-256 `c36bfe927acffde38d60d5068cfc1acc22784bc4d4fdd30ca015bb141f919ce6`이다. [13 실제 경로·입력·실측](../../projects/water-hydrogen-bond-surface-tension/stages/13-compose/r2/provenance.json)과 [14 기술 검사](../../projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/technical-check.json)를 따른다. 원 WAV 2653440샘플과 디코드 AAC의 원 길이 구간 상관계수 0.9999665011969985, SNR 41.68737842978274dB다. AAC 끝의 패딩 768샘플은 원 발화 길이와 구별하며 원 WAV·속도·gain·trim은 바꾸지 않았다.
실제 최종 163프레임·29모바일 검사 판에서 전하 대상·원자/결합·물 영역 포함·같은 양/별도 비교 조건·확대개념도·글자/가림과 22 continuous/8 cut 연결을 직접 확인했다. 소스 프레임 검사를 최종 영상 검사로 대체하지 않았다. 정상 속도 전체 재생·청취 및 플랫폼 앱 UI는 미확인이므로 [14 보고](../../projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/review.md)의 해당 영역은 not_checked, 추천은 incomplete다. 새 final 승인 전에는 제작 완료가 아니다.
렌더 당시 project 바이트는 별도 render-time-project.json에 보존했다. 이 증거 SHA와 현재 brief/settings/01–12 포인터·상태 및 topic/story/scenes 승인을 엄격히 대조한다. 총괄의 같은 13/14 포인터에 대한 정상 상태 변경과 final 검토 갱신을 입력 원본 변경과 구별한다. 기존/실패 시도·캐시·이전 r1을 삭제하지 않았다.
@@ -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"
}
}
}
@@ -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
@@ -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"
}
}
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))