Compare commits

...

37 Commits

Author SHA1 Message Date
NINI bc98a3e0d0 3D 완성본 최종 사용자 승인과 검수 한계 기록 2026-10-10 23:53:46 +09:00
NINI 67776b5972 실제 Blender 제작과 최종 검토의 지침 기록 갱신 2026-10-10 04:19:27 +09:00
NINI 26c06994cf 새 3D 완성본을 사용자 최종 검토에 연결 2026-10-10 04:15:07 +09:00
NINI 52160a6c7f 승인된 3D 컷 합성과 실제 검수 결과 통합 2026-10-10 04:14:34 +09:00
NINI ff28b7b47c 승인된 Blender 컷의 실제 합성과 최종 검수 근거 추가 2026-10-10 04:11:08 +09:00
NINI 78fcd49a54 정식 3D 제작 완료 기록과 기존 합성 구성 준비 2026-10-10 03:29:40 +09:00
NINI cefdc0f73b 물 영상 정식 3D 컷 제작 결과 통합 2026-10-10 03:28:32 +09:00
NINI f8f12f963d 물 영상 정식 3D 기준·끝점·31개 컷 제작 2026-10-10 03:25:24 +09:00
NINI d72d17c2c3 Blender 끝점 부분 결과의 후속 렌더 검사 수정 통합 2026-10-10 02:15:32 +09:00
NINI 5ee2b2e972 부분 컷 끝점 결과의 연속 경계 검사 수정 2026-10-10 02:13:09 +09:00
NINI 6b00a8f0f3 수정 3D 장면의 실제 사용자 승인과 정식 제작 준비 기록 2026-10-10 01:45:17 +09:00
NINI 98c5efcef0 물 영역 포함 오류 해소와 새 장면 검토 상태 기록 2026-10-10 01:37:14 +09:00
NINI 2cb192ebcf 물 영역 포함 관계를 수정한 실제 3D 샘플 통합 2026-10-10 01:34:46 +09:00
NINI 3bce356abc r3 검토의 실제 Blender 프레임 관계 바로잡기 2026-10-10 01:33:01 +09:00
NINI 73b52f534e r3 검토 문서 경로와 한국어 띄어쓰기 정리 2026-10-10 01:31:37 +09:00
NINI ee3d18be33 물 분자 전체를 실제 물 영역 안에 포함한 r3 시제품 검증 2026-10-10 01:30:59 +09:00
NINI ced4dcee5d 분자 전체 mesh와 물 영역을 검증하는 재발 방지 기준 기록 2026-10-10 01:27:48 +09:00
NINI d1537ae259 물 분자 포함 관계 검수 누락과 수정 범위 기록 2026-10-10 01:19:17 +09:00
NINI b1d697e320 물 영역 포함 관계 수정 요청과 새 장면 버전 준비 2026-10-10 01:17:37 +09:00
NINI 026a92cccb 검증된 3D 샘플의 장면 검토 대기 상태 기록 2026-10-10 00:48:10 +09:00
NINI 205b750e74 물 영상의 실제 3D 대표 시제품 통합 2026-10-10 00:46:09 +09:00
NINI a97a778f7a 물 영상의 검증된 3D 대표 시제품과 새 장면 설계 작성 2026-10-10 00:42:16 +09:00
NINI 0cbeb37344 docs: capture verified scene context fix and pixel comparison rule 2026-10-10 00:31:06 +09:00
NINI 28a1861e9e 비활성 Blender Scene의 애니메이션 평가와 RGB 움직임 검사 수정 2026-10-10 00:29:43 +09:00
NINI bccd6135e5 fix: integrate decoded scene animation verification 2026-10-10 00:29:41 +09:00
NINI 1bb7bba61c 비활성 Blender Scene의 애니메이션 평가와 RGB 움직임 검사 수정 2026-10-10 00:25:51 +09:00
NINI 7874292a7e docs: record inactive scene render finding and required pixel verification 2026-10-10 00:17:32 +09:00
NINI 1d25107295 fix: isolate fixture approval and identify dependencies by stage id 2026-10-10 00:11:03 +09:00
NINI 04628d2697 fix: isolate fixture approval and identify dependencies by stage id 2026-10-10 00:10:01 +09:00
NINI 8caed117be docs: record verified Blender runtime and current preview boundary 2026-10-09 23:59:19 +09:00
NINI ba035c2888 chore: prepare versioned Blender preview and preserve prior approvals 2026-10-09 23:56:02 +09:00
NINI 8ad501766e feat: integrate verified Blender render runner 2026-10-09 23:55:43 +09:00
NINI 31efdd6681 Blender 원본 검사와 승인 경계를 지키는 로컬 렌더 실행기 추가 2026-10-09 23:54:55 +09:00
NINI 15e84fb7c9 feat: add Blender production contracts and guidelines 2026-10-09 23:25:09 +09:00
NINI 93b9f92f91 fix: enforce Blender preview and continuity limits 2026-10-09 23:24:06 +09:00
NINI d4f1a800aa feat: add versioned Blender production contracts 2026-10-09 23:18:07 +09:00
NINI b2b37a1105 add projects 2026-10-09 23:04:17 +09:00
199 changed files with 45848 additions and 234 deletions
+1 -1
View File
@@ -6,6 +6,6 @@
개별 호출도 topic/story/scenes/final의 사용자 검토를 우회하지 않는다. 담당은 자기 새 버전과 검토를 제출하고 공유 진행·승인은 총괄만 바꾼다. 실제 파일이 없는 외부 결과를 완료로 처리하지 않는다.
필드·입력·승인 지점을 추가하지 않는다. 사례의 반전·위기·시간 배분은 모든 소재의 의무가 아니다. 실제 사용한 구성과 비적용 이유는 기존 review.md에 남긴다.
계약에 없는 필드·입력·승인 지점을 추가하지 않는다. 새 제작은 1.1 Blender 계약, 기존 1.0은 보존·참조한다. scenes 승인 전 제한된 08 대표 샘플만 허용하고 정식 10–12는 승인 후 진행한다. 사례의 반전·위기·시간 배분은 모든 소재의 의무가 아니다. 실제 사용한 구성과 비적용 이유는 기존 review.md에 남긴다.
변경한 스킬의 머리말·이름·미완성 내용과 실제 상대 링크를 확인하고, 선행 결과·승인 부재·외부 파일 부재가 올바르게 보고되는지 검토한다. 불필요한 지원 파일·실행 프로그램·검증 프레임워크를 추가하지 않는다.
+1 -1
View File
@@ -11,7 +11,7 @@ description: 승인된 대본·제목과 실제 컷 영상·통합 음성·자
개별 호출에서도 같은 제작물의 유효한 `03-script`, `04-title`, `06-tts`, `07-cuts`, `12-motion`과 그 선행 관계를 확인한다. 실제 사용한 다섯 결과의 `{stage_id, revision}`을 `inputs`에 기록한다. project.json의 story·scenes가 실제 사용자 승인이며 사용 버전과 연결되는지 확인한다. pending·changes_requested·stale 승인을 우회하지 않고 같은 승인 버전은 다시 묻지 않는다. 누락·미확인 파일·길이 불일치가 있으면 문제 단계·컷을 총괄에 보고하고 요청 준비 완료로 처리하지 않는다.
Qwen3-TTS 통합 WAV와 실측 구간, `gpt-image-2.5-sunburst` 이미지에서 이어진 실제 컷 영상, `references/1.png` 그림체를 유지한다. 이 스킬은 합성 엔진을 선택·구현하거나 실제 모델 API·합성을 실행하지 않고 도구에 중립적인 요청과 받은 결과 확인을 담당한다.
Qwen3-TTS 통합 WAV와 실측 구간, 승인된 Blender Scene에서 렌더링한 실제 컷 영상·교육용 3D 기준을 유지한다. 기존 1.0 결과도 유효한 입력이면 보존·참조한다. 승인된 기존 Remotion + FFmpeg 경로로 합성하고 1.1의 기존 13 구조와 실제 결과를 확인한다. 실행 프로그램 준비·파일 존재와 실제 재생·청취 성공을 구분한다. 원본 references/1.png를 보존하며 도구를 임의로 바꾸지 않는다.
## 요청 연결
+16 -26
View File
@@ -1,42 +1,32 @@
---
name: short-video-images
description: 승인된 짧은 영상의 컷별 시작·끝 이미지 요청서를 작성하고 11 이미지의 실제 PNG와 장면 연속성을 검증할 때 사용한다.
description: 승인된 컷의 Blender Scene·시작/끝 상태와 논리 프레임을 작성하고 11의 실제 .blend·PNG·연속성을 검증한다.
---
# 컷 이미지 요청과 결과 확인
# Blender 컷 끝점 렌더
11-images만 담당한다. 각 컷에 시작과 끝 프레임이 필요하며 요청서만으로 생성 완료를 보고하지 않는다.
## 입력과 경계
## 읽을 자료와 시작 조건
[공통 계약](../../../docs/workflow-contracts.md), [전달 형식](../../../docs/contracts.md), [제작 지침](../../../docs/short-video-guide.md), [결과 스키마](../../../contracts/artifact.schema.json), [검토 양식](../../../templates/review.md)을 읽는다. 새 결과는 1.1이고 기존 1.0 결과를 보존·참조한다. 필수 입력 topic/language, Qwen3-TTS 기존 qwen3-tts 환경, 세로 9:16·최종 180000ms 이하·실측 컷 5000ms 이하를 유지한다.
- [공통 계약](../../../docs/workflow-contracts.md): 입력·선행 버전·경로·승인·실제 미디어 완료 기준.
- [제작 지침](../../../docs/short-video-guide.md): 근거와 숫자, 화면의 역할, 연속 연결, 그림체와 주석.
- [11 양식](../../../templates/stages/11-images.json), [결과 스키마](../../../contracts/artifact.schema.json), [검토 양식](../../../templates/review.md).
- [그림체 기준](../../../references/1.png): 실제 이미지를 열어 가는 윤곽선·단순 형태·정돈된 색면·제한적인 음영을 확인한다.
같은 제작물의 유효한 선행 버전·실제 파일과 topic/story/scenes 승인을 확인하고 사용 버전을 inputs에 기록한다. 필요한 결과·승인이 없거나 stale이면 누락과 돌아갈 단계를 총괄에 보고한다. 같은 유효 승인 버전은 다시 묻지 않는다. 08의 제한된 대표 화면·짧은 움직임만 scenes 승인 전 검토용으로 허용하며 정식 10–12 전체 제작·완료와 구분한다. 새 08·09 승인 후 정식 제작한다.
1. 제작물의 `topic`, `language`를 보존하고 세로 9:16, 전체 180000ms 이내, 각 컷 0ms 초과 5000ms 이하 조건을 확인한다.
2. 같은 제작물의 유효한 02-research, 03-script, 07-cuts, 08-scenes, 09-entities, 10-references를 읽고 실제 사용 버전을 `inputs`에 기록한다. 10의 모든 필요한 기준 PNG와 참조가 검증되어야 한다. 자료가 없거나 stale·부분 결과이면 먼저 해결할 단계와 이유를 총괄에 보고한다.
3. scenes 검토의 실제 사용자 응답과 `artifact_refs`가 사용하는 07·08·09 버전을 승인했는지 확인한다. 10이 이 승인 전에 진행되었다면 유효한 선행 완료로 취급하지 않는다. topic·story 등 앞선 승인도 유효해야 한다. 개별 호출로 선행 결과·승인을 우회하지 않으며 같은 승인 버전에 재승인을 요구하지 않는다.
원본 references/1.png를 보존하고 단순 형태·정돈된 색을 교육용 3D로 해석한다. 단순 배경·부드러운 조명·선명한 형태를 사용하며 평면 복제·캠핑 소재·상표를 강제하지 않는다. 02의 조사와 대본 근거로 모델·재질·방향·상태·수치를 확인하고 사실에 없는 구조를 만들지 않는다. 설명 텍스트는 검증한 합성 텍스트로 배치한다.
## 컷별 요청서 작성
## 컷별 Scene·끝점 요청
`payload.request`의 기존 `model`, `frames`만 사용하며 모델은 `gpt-image-2.5-sunburst`다. 스타일은 10의 기준 결과와 원본 `references/1.png`에 연결한다.
[11 양식](../../../templates/stages/11-images.json)을 사용한다. 유효한 02/03/07/08/09/10과 10의 실제 기준 .blend·PNG를 확인한다. request={engine:"Blender",scene_path,render_profile,frames}. scene_path는 만들 원본의 루트 상대 경로다.
- 각 frame은 유일한 `id`, `cut_id`, `position`, `prompt`, `reference_ids`를 가진다. 07의 모든 컷마다 `position=start`와 `position=end`가 정확히 하나씩 있어야 한다. 추가 컷·중복 위치·누락은 허용하지 않는다.
- `cut_id`는 07·08의 같은 컷을 가리키고, `reference_ids`는 10의 요청 `id`와 결과 `reference_id`가 일치하는 검증된 기준 이미지를 참조한다. 스타일 및 해당 컷의 인물·배경 기준을 포함하고 09의 등장 인물·배경 ID를 프롬프트에 연결한다.
- `prompt`는 03의 관련 블록·주장, 02의 확인한 근거, 08의 해당 start_state/end_state와 camera를 따른다. 인물·배경의 고정 특징을 유지하고 의상·표정·시간 변화는 장면에 근거해 명시한다. 원본 스타일 이미지의 등장인물을 모든 영상의 고정 인물로 삼지 않는다.
- 작동 원리와 단면·지도·주석의 구조·재료·방향·수치를 조사·대본과 대조한다. 근거가 없거나 선행 설계와 충돌하면 임의로 도해하지 말고 수정할 단계·컷을 보고한다. 단순화해도 사실에 중요한 차이를 남긴다.
- 그림체는 첨부의 가는 윤곽선·단순 형태·색면·제한적 음영을 따른다. 참고 Shorts의 입체 렌더링이나 캠핑 소재·상표 복제를 강제하지 않는다. 부정확한 생성 글자·숫자는 검증한 텍스트로 합성하도록 메모한다.
frame={id,cut_id,position,prompt,reference_ids,scene_name,frame_index}. 모든 실제 07 컷마다 start/end가 정확히 하나씩 있고 ID가 유일해야 한다. reference_ids는 10의 검증된 요청/결과 ID, scene_name은 비어 있지 않은 실제 Scene 이름, frame_index는 0 이상 논리 프레임이다. 같은 컷 start/end는 같은 Scene을 사용한다. 조사·대본·08 상태와 카메라·09 고정 특징을 prompt에 연결한다.
## 인접 프레임 연결
1.1 continuous는 동일 Scene의 앞 end와 뒤 start 상태를 맞추고 동일한 실제 PNG 파일을 반드시 공유한다. 위치별 별도 frame ID는 유지한다. 기존 1.0의 선택적 공유 규칙을 소급 변경하지 않는다. cut은 서로 다른 Scene의 같은 프레임 번호를 구별하며 캐시 식별에 Scene 이름을 포함한다. 연결을 임의로 바꾸지 않는다. Blender 실제 번호=논리 프레임+1로 평가한다. 영상은 [start,end) 구간이고 끝점 PNG는 end 자체를 평가한다.
08의 `connections.mode`와 `reason`을 따른다. continuous는 앞 컷 end와 다음 컷 start의 같은 인물·배경·물체 위치·행동 진행·흐름 방향·카메라 상태를 맞춘다. 동일한 이미지를 공유할 수 있으나 각 컷·위치에는 별도 frame ID를 유지하고, 두 결과의 실제 `media.path` 및 공유하는 end/start frame ID 관계를 `review.md`에 명시한다. cut은 위치·시간·시점이 어떻게 바뀌는지 프롬프트와 검토 메모에서 설명한다. 장면 연결을 이미지 단계에서 임의로 변경하지 않는다.
result는 null 또는 {scene_file,frames:[{frame_id,media:image}]}다. 요청마다 실제 PNG가 누락·중복·추가 없이 대응하며 실제 .blend의 Scene·프레임·상태·재질·연결을 확인한다. 원본과 RenderProfile은 후속 12의 기준이다.
## 실제 결과 확인과 전달
## 실제 결과와 전달
실제 제작 시 해당 모델과 세로 출력 지원을 확인한다. 확인하지 않은 API 파라미터를 고정하거나 다른 모델로 무단 대체하지 않는다. 지원이 확인되지 않거나 실제 결과가 없으면 요청서를 보존하고 `result=null`, `awaiting_input`을 총괄에 보고한다.
승인된 저장소 내 Blender 실행 경로를 사용한다. 기존 설치를 무단 변경하거나 다른 도구로 바꾸지 않는다. 설치·요청 준비와 실제 렌더링 성공을 구분한다. .blend는 BlendFile={path,verified,sha256,blender_version}로 기록하고 실제 파일·해시·버전·재열기·Scene/자산을 검증한다. PNG/MP4 media로 위장하지 않는다. RenderProfile의 실제 지원을 확인하며 요청값을 실측 결과로 복사하지 않는다.
- 실제 부분 결과는 `payload.result.frames`에 보존한다. 각 결과는 `frame_id`, `media`이며 frame_id는 요청 frame의 `id`와 일치해야 한다. 모든 요청의 start/end 결과가 누락·중복 없이 검증되기 전 전체 complete로 보고하지 않는다.
- `media`는 기존 `{path, kind, verified, duration_ms, width, height}`를 사용한다. 경로는 저장소 루트 기준이다. 실제 파일 존재·PNG 내용 형식·해상도와 화면을 확인한 경우만 `verified=true`다. `kind=image`, `duration_ms=null`, 크기는 실제 측정값이며 미확인 값은 null이다. 확장자·요청 크기·예제 경로만으로 검증하지 않는다. 실제 세로 9:16, 컷 상태·고정 특징·근거·연속성도 확인한다.
- 자기 단계의 새 `stages/11-images/r<revision>/artifact.json`과 `review.md`만 작성하고 이전 버전을 덮어쓰지 않는다. 메모에 적용 원칙과 결과, 비적용 이유, 실제 모델·지원 확인, 컷·프레임·파일별 검증과 미확인 사항을 적는다. 새 JSON 필드·필수 입력·승인 지점은 만들지 않는다.
- 모든 컷의 두 위치와 결과 검증을 대조해 준비 여부·남은 문제·후속 영향과 수정할 단계를 총괄에 보고한다. `project.json`, 상태·현재 포인터·사용자 승인은 총괄만 변경한다.
media는 기존 여섯 키 {path,kind,verified,duration_ms,width,height}다. PNG는 image·duration_ms=null, MP4는 video·실측 양수 길이이며 실제 형식·해상도·9:16·내용을 확인한다. 확인한 파일만 verified=true이고 미확인 크기·시간은 null이다. 데이터 경로는 루트 상대 경로다. 실제 파일이 없으면 result=null·awaiting_input, 실행/검증 실패는 failed로 보고한다. 부분 결과와 실패 시도를 보존하며 전체 요청 결과가 검증되기 전 complete로 보고하지 않는다.
자기 단계의 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 버전 review.md만 작성하고 이전 결과를 덮어쓰지 않는다. 실제 사용 버전·파일별 검사·명령·종료코드·미확인 영역·적용 원칙과 비적용 이유를 적는다. project.json·상태·현재 포인터·사용자 승인은 총괄만 갱신한다. 필드·열거값은 계약을 따르고 새 필수 입력·승인 지점을 만들지 않는다.
+15 -22
View File
@@ -1,39 +1,32 @@
---
name: short-video-motion
description: 승인된 컷·장면과 실제 시작/끝 이미지로 12-motion 영상 요청을 작성하고 받은 MP4의 길이·상태·연결을 확인한다.
description: 승인된 실제 Blender 원본·Scene·끝점·실측 컷으로 12 렌더 요청을 작성하고 실제 MP4의 길이·상태·연결을 확인한다.
---
# 컷 영상 요청과 결과 확인
# Blender 컷 영상 렌더
## 시작 조건
## 입력과 경계
[공통 계약](../../../docs/workflow-contracts.md)의 저장·전달, 실제 파일과 시간, 승인·수정 규칙과 [제작 지침](../../../docs/short-video-guide.md)의 설명에 필요한 움직임·장면 연결 절을 읽는다. [12 양식](../../../templates/stages/12-motion.json)과 [결과 스키마](../../../contracts/artifact.schema.json)의 `12-motion` 정의를 사용하며 새 필드를 추가하지 않는다.
[공통 계약](../../../docs/workflow-contracts.md), [전달 형식](../../../docs/contracts.md), [제작 지침](../../../docs/short-video-guide.md), [결과 스키마](../../../contracts/artifact.schema.json), [검토 양식](../../../templates/review.md)을 읽는다. 새 결과는 1.1이고 기존 1.0 결과를 보존·참조한다. 필수 입력 topic/language, Qwen3-TTS 기존 qwen3-tts 환경, 세로 9:16·최종 180000ms 이하·실측 컷 5000ms 이하를 유지한다.
개별 호출에서도 같은 제작물의 유효한 `07-cuts`, `08-scenes`, `11-images`와 그 선행 관계를 확인한다. 실제 사용한 세 결과의 `{stage_id, revision}`을 `inputs`에 기록한다. `project.json`에서 story·scenes가 사용 버전과 연결된 실제 사용자 승인인지 확인하며 pending·changes_requested·stale 승인을 우회하지 않는다. 이미 승인된 같은 버전은 재승인을 요구하지 않는다. 자료·승인이 없으면 빠진 항목과 돌아갈 단계를 총괄에 보고한다.
같은 제작물의 유효한 선행 버전·실제 파일과 topic/story/scenes 승인을 확인하고 사용 버전을 inputs에 기록한다. 필요한 결과·승인이 없거나 stale이면 누락과 돌아갈 단계를 총괄에 보고한다. 같은 유효 승인 버전은 다시 묻지 않는다. 08의 제한된 대표 화면·짧은 움직임만 scenes 승인 전 검토용으로 허용하며 정식 10–12 전체 제작·완료와 구분한다. 새 08·09 승인 후 정식 제작한다.
선행 조건인 Qwen3-TTS 통합 WAV의 실측 컷, `gpt-image-2.5-sunburst`의 실제 PNG, `references/1.png` 그림체를 유지한다. 그림체 참고에 캠핑 소재를 강제하지 않는다. 이 스킬은 영상 엔진을 선택·구현하거나 실제 모델 API·영상 생성을 실행하지 않고 도구에 중립적인 요청과 받은 결과 확인을 담당한다.
원본 references/1.png를 보존하고 단순 형태·정돈된 색을 교육용 3D로 해석한다. 단순 배경·부드러운 조명·선명한 형태를 사용하며 평면 복제·캠핑 소재·상표를 강제하지 않는다. 02의 조사와 대본 근거로 모델·재질·방향·상태·수치를 확인하고 사실에 없는 구조를 만들지 않는다. 설명 텍스트는 검증한 합성 텍스트로 배치한다.
## 요청 작성
## 원본과 프레임 범위
`payload.request.clips`는 07의 컷 순서대로 모든 컷을 정확히 한 번 포함한다. 각 항목은 다음 기존 필드만 사용한다.
[12 양식](../../../templates/stages/12-motion.json)을 사용한다. 유효한 07/08/11, 실제 Qwen 통합 WAV의 실측 컷, 11의 검증된 .blend·시작/끝 PNG를 확인한다. request={engine:"Blender",scene_path,render_profile,clips}. scene_path는 11의 실제 원본 경로, render_profile은 11과 동일하다.
- `cut_id`: 07의 컷 ID. 08 장면과 11 프레임의 컷 참조가 같은지 확인한다.
- `start_frame_id`, `end_frame_id`: 11 요청에서 해당 컷의 start/end인 ID. 11 결과의 `frame_id`로 실제 PNG를 찾고 `media.verified=true`와 이미지 내용을 확인한다.
- `duration_ms`: 07의 실측 `end_ms-start_ms`. 양수이고 5000ms 이하이며 글자 수 추정으로 대체하지 않는다.
- `motion_prompt`: 08의 시작→끝 상태, 설명에 필요한 물체·인물 동작, 흐름 방향과 카메라 동작을 전달한다. 별도 camera 필드를 만들지 않는다.
clip={cut_id,start_frame_id,end_frame_id,duration_ms,motion_prompt,scene_name,start_frame,end_frame}. 07 순서의 모든 컷을 정확히 한 번 포함한다. 끝점 ID는 11의 같은 컷·위치이며 Scene 이름·논리 프레임도 일치해야 한다. start_frame/end_frame은 0 이상이며 end_frame>start_frame이다. duration_ms는 실측 end_ms-start_ms이고 0 초과 5000 이하이며 프레임 수×1000/fps와 정확히 같아야 한다. 미작성 null은 실행 준비가 아니다.
물높이 변화·부품 조작 등 원인과 결과를 보여주는 움직임을 우선한다. 줌·흔들림으로 설명을 대신하거나 근거 없는 구조·수치를 더하지 않는다. 시작/끝 이미지와 요청이 같은 변화를 설명해야 한다. continuous 연결은 이전 끝과 다음 시작의 위치·방향·카메라·인물·배경을 맞추고 cut 전환은 승인된 이유를 보존한다.
원인과 결과를 보여주는 실제 물체·상태 변화와 카메라 동작을 motion_prompt에 적고 줌·흔들림으로 설명을 대신하지 않는다. [start_frame,end_frame) 프레임을 렌더링하고 논리 번호+1을 Blender에 전달한다. continuous 경계의 실제 상태를 맞추고 cut의 서로 다른 Scene·캐시를 구별한다. PNG end 자체와 MP4의 마지막 프레임(end-1) 검사를 구분한다.
## 받은 결과 확인
result는 기존 null 또는 {clips:[{cut_id,media:video}]}다. 모든 요청 컷의 실제 MP4·길이·화면·동작·연속성을 검증한다. 요청과 실측이 다르면 12/11/08/07 중 문제 단계와 컷을 보고하고 음성·시간·속도를 임의 변경하지 않는다. 모든 파일이 확인되어야 13 합성에 전달한다.
요청만 준비되면 `payload.result=null`을 유지하고 awaiting_input으로 보고한다. 실제 결과는 `payload.result.clips`의 `{cut_id, media}`로 기록한다. 각 요청 cut_id에 MP4 한 개씩 대응해야 하며 누락·중복·요청 외 ID를 허용하지 않는다. 부분 결과는 보존하되 전체 요청을 받기 전 complete로 보고하지 않는다.
## 실제 결과와 전달
media는 기존 `{path, kind, verified, duration_ms, width, height}`다. path는 저장소 루트 기준 상대 경로, kind는 video다. 실제 파일 존재·MP4 형식·재생 내용·길이·화면 크기를 확인한 뒤에만 verified=true와 실측 메타데이터를 기록한다. 파일 미확인은 verified=false, 확인하지 못한 메타데이터는 null이다. 요청값이나 확장자만으로 실측했다고 쓰지 않는다.
승인된 저장소 내 Blender 실행 경로를 사용한다. 기존 설치를 무단 변경하거나 다른 도구로 바꾸지 않는다. 설치·요청 준비와 실제 렌더링 성공을 구분한다. .blend는 BlendFile={path,verified,sha256,blender_version}로 기록하고 실제 파일·해시·버전·재열기·Scene/자산을 검증한다. PNG/MP4 media로 위장하지 않는다. RenderProfile의 실제 지원을 확인하며 요청값을 실측 결과로 복사하지 않는다.
각 실제 클립 길이를 요청 duration_ms와 비교한다. 다르면 cut_id·요청/실측 길이·수정할 `12-motion`을 보고하며 임의로 컷·음성을 늘리거나 속도를 바꿔 통과시키지 않는다. 실제 시작/끝 상태와 동작·카메라·인접 연결도 확인한다. 프레임 문제는 `11-images`, 장면 설계 문제는 `08-scenes`, 시간 기준 문제는 `07-cuts`로 돌린다. 모든 컷의 실제 파일·길이·상태가 맞아야 합성 전달 준비로 보고한다.
media는 기존 여섯 키 {path,kind,verified,duration_ms,width,height}다. PNG는 image·duration_ms=null, MP4는 video·실측 양수 길이이며 실제 형식·해상도·9:16·내용을 확인한다. 확인한 파일만 verified=true이고 미확인 크기·시간은 null이다. 데이터 경로는 루트 상대 경로다. 실제 파일이 없으면 result=null·awaiting_input, 실행/검증 실패는 failed로 보고한다. 부분 결과와 실패 시도를 보존하며 전체 요청 결과가 검증되기 전 complete로 보고하지 않는다.
## 저장과 보고
담당은 `projects/<project_id>/stages/12-motion/r<revision>/artifact.json`과 같은 버전 `review.md`만 작성한다. 이전 버전을 덮어쓰지 않고 [검토 양식](../../../templates/review.md)에 선행 버전, 요청 대비 실측 결과, 적용한 움직임 원칙·적용하지 않은 구성과 이유, 문제 단계·컷을 적는다.
총괄만 project.json의 상태·포인터·승인을 작성한다. 담당은 준비 여부·미확인 내용·후속 13/14 및 final 검토에 미치는 수정 영향을 보고한다. 사용자 final 승인 전 제작 완료를 선언하지 않는다.
자기 단계의 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 버전 review.md만 작성하고 이전 결과를 덮어쓰지 않는다. 실제 사용 버전·파일별 검사·명령·종료코드·미확인 영역·적용 원칙과 비적용 이유를 적는다. project.json·상태·현재 포인터·사용자 승인은 총괄만 갱신한다. 필드·열거값은 계약을 따르고 새 필수 입력·승인 지점을 만들지 않는다.
+16 -24
View File
@@ -1,38 +1,30 @@
---
name: short-video-references
description: 승인된 짧은 영상 장면과 반복 요소를 바탕으로 10 기준 이미지 요청서를 작성하고 실제 PNG 결과를 검증할 때 사용한다.
description: 승인된 장면과 반복 요소로 Blender 기준 모델·렌더 요청을 작성하고 10의 실제 .blend·PNG 자산과 고정 특징을 확인한다.
---
# 기준 이미지 요청과 결과 확인
# Blender 기준 모델과 이미지
10-references만 담당한다. 요청서 작성은 이미지 생성 성공이나 단계 완료가 아니다.
## 입력과 경계
## 읽을 자료와 시작 조건
[공통 계약](../../../docs/workflow-contracts.md), [전달 형식](../../../docs/contracts.md), [제작 지침](../../../docs/short-video-guide.md), [결과 스키마](../../../contracts/artifact.schema.json), [검토 양식](../../../templates/review.md)을 읽는다. 새 결과는 1.1이고 기존 1.0 결과를 보존·참조한다. 필수 입력 topic/language, Qwen3-TTS 기존 qwen3-tts 환경, 세로 9:16·최종 180000ms 이하·실측 컷 5000ms 이하를 유지한다.
- [공통 계약](../../../docs/workflow-contracts.md): 입력·버전·경로·선행 결과·승인·미디어 완료 기준.
- [제작 지침](../../../docs/short-video-guide.md): 근거와 숫자, 화면의 역할, 깔끔한 그림체와 작동 방식.
- [10 양식](../../../templates/stages/10-references.json), [결과 스키마](../../../contracts/artifact.schema.json), [검토 양식](../../../templates/review.md).
- [그림체 기준](../../../references/1.png): 실제 이미지를 열어 가는 윤곽선·단순 형태·정돈된 색면·제한적인 음영을 확인한다.
같은 제작물의 유효한 선행 버전·실제 파일과 topic/story/scenes 승인을 확인하고 사용 버전을 inputs에 기록한다. 필요한 결과·승인이 없거나 stale이면 누락과 돌아갈 단계를 총괄에 보고한다. 같은 유효 승인 버전은 다시 묻지 않는다. 08의 제한된 대표 화면·짧은 움직임만 scenes 승인 전 검토용으로 허용하며 정식 10–12 전체 제작·완료와 구분한다. 새 08·09 승인 후 정식 제작한다.
1. 제작물의 `topic`, `language`, 현재 결과와 검토 기록을 확인한다. 세로 9:16, 전체 180000ms 이내, 각 컷 0ms 초과 5000ms 이하 조건을 보존한다.
2. 같은 제작물의 유효한 02-research, 08-scenes, 09-entities 결과를 읽고 실제 사용 버전을 `inputs`에 기록한다. 필요한 선행 자료가 없거나 stale이면 총괄에 보완할 단계와 이유를 보고한다.
3. 사용자가 07-cuts·08-scenes·09-entities의 현재 사용 버전을 scenes 검토에서 승인했는지 실제 응답과 `artifact_refs`로 확인한다. 그 전에 10 요청을 작성하거나 생성으로 넘어가지 않는다. topic·story 등 앞선 승인 경계도 유효해야 하며, 개별 호출은 선행 결과나 승인을 우회하지 않는다. 같은 승인 버전은 재승인을 요구하지 않는다.
원본 references/1.png를 보존하고 단순 형태·정돈된 색을 교육용 3D로 해석한다. 단순 배경·부드러운 조명·선명한 형태를 사용하며 평면 복제·캠핑 소재·상표를 강제하지 않는다. 02의 조사와 대본 근거로 모델·재질·방향·상태·수치를 확인하고 사실에 없는 구조를 만들지 않는다. 설명 텍스트는 검증한 합성 텍스트로 배치한다.
## 요청서 작성
## 요청과 자산 확인
`payload.request`의 기존 `model`, `style_reference`, `items`만 사용한다. 모델은 `gpt-image-2.5-sunburst`, 그림체 경로는 `references/1.png`다. 이 경로와 모든 JSON 미디어 경로는 저장소 루트 기준이다.
[10 양식](../../../templates/stages/10-references.json)을 사용한다. 선행 결과는 유효한 02/08/09다. request={engine:"Blender",style_reference:"references/1.png",render_profile,items}이며 RenderProfile은 공통 계약을 따른다.
- 각 item은 유일한 `id`, `kind`, `entity_id`, `prompt`를 가진다. `kind=style`만 `entity_id=null`이며, character/background는 09의 해당 인물/배경 ID를 참조한다. 스타일과 09에서 필요한 모든 인물·배경을 빠짐없이 다룬다.
- 프롬프트에 08의 장면·컷 ID, 09의 인물·배경 ID와 고정 특징을 연결한다. 고정 특징을 유지하고 의상·표정·시간에 따른 변화는 해당 장면에서 허용된 변화로 명시한다. 기준 이미지에 등장한 인물을 모든 영상의 고정 인물로 지정하지 않는다.
- 02의 확인한 주장·출처를 근거로 작동 원리와 단면·지도·주석의 구조·재료·방향·수치를 점검한다. 선행 장면이 근거와 충돌하면 임의로 바꾸지 말고 수정할 단계와 문제를 보고한다.
- 첨부 그림체를 적용하며 참고 Shorts의 입체 렌더링을 강제하지 않는다. 캠핑 소재·인물·상표를 복제하지 않는다. 표현을 단순화해도 사실에 중요한 재료·구조의 차이는 남긴다. 부정확한 생성 글자·숫자는 검증한 텍스트를 합성 단계에 배치하도록 보고한다.
item={id,kind,entity_id,prompt}. ID는 유일하고 kind는 style/character/background/object다. style/object의 entity_id는 null, character/background는 실제 09의 해당 ID다. 스타일과 필요한 인물·배경·물체를 빠짐없이 요청하고 고정 특징·근거·08 상태를 prompt에 연결한다. 예제의 fps·samples·seed를 모든 제작의 고정값으로 취급하지 않는다.
실제 제작 시 해당 모델과 세로 출력 지원을 확인한다. 확인하지 않은 API 파라미터를 고정하거나 다른 모델로 무단 대체하지 않는다. 지원·결과가 확인되지 않으면 요청서와 미확인 내용을 남긴다.
result는 null 또는 {scene_file,items}이며 결과 item={reference_id,object_name,media:image}다. 모든 요청 ID가 누락·중복·추가 없이 대응해야 한다. object_name은 실제 .blend의 오브젝트/컬렉션 이름이다. 모델·재질·고정 특징과 PNG를 함께 확인하고 불일치는 장면/요소/조사 단계로 보고한다.
## 결과 검증과 전달
## 실제 결과와 전달
- 결과가 없으면 `payload.result=null`로 두고 총괄에 `awaiting_input`을 보고한다. 실제 받은 부분 결과는 `result.items`에 보존하되 전체 완료로 보고하지 않는다.
- 각 결과는 `reference_id`, `media`를 사용한다. `reference_id`는 요청 item의 `id`와 일치해야 하며, 중복·누락·알 수 없는 ID가 없어야 한다. 모든 스타일·인물·배경 요청의 실제 결과와 고정 특징을 대조한다.
- `media`는 기존 `{path, kind, verified, duration_ms, width, height}`만 사용한다. 실제 파일 존재·PNG 내용 형식·해상도를 확인하고 화면을 열어 확인한 경우만 `verified=true`로 기록한다. `kind=image`, `duration_ms=null`, width/height는 실제 측정값이다. 미확인 파일은 false, 미확인 크기는 null이며 예제 경로를 실제 결과로 기록하지 않는다. 세로 출력 조건도 실제 해상도로 확인한다.
- 자기 단계의 새 `stages/10-references/r<revision>/artifact.json`과 `review.md`만 작성하고 이전 버전을 덮어쓰지 않는다. 검토 메모에 사용한 원칙·적용 결과·소재에 맞지 않아 적용하지 않은 원칙의 이유, 실제 모델·지원 확인 근거, 파일별 검증 결과와 남은 문제를 적는다. 새로운 JSON 필드·필수 입력·승인 지점은 만들지 않는다.
- 요청 전체와 실제 결과 검증이 충족되면 준비 여부를 총괄에 보고한다. `project.json`, 단계 상태·현재 포인터·사용자 승인은 총괄만 변경한다. 변경이 후속 이미지에 미치는 영향도 함께 보고한다.
승인된 저장소 내 Blender 실행 경로를 사용한다. 기존 설치를 무단 변경하거나 다른 도구로 바꾸지 않는다. 설치·요청 준비와 실제 렌더링 성공을 구분한다. .blend는 BlendFile={path,verified,sha256,blender_version}로 기록하고 실제 파일·해시·버전·재열기·Scene/자산을 검증한다. PNG/MP4 media로 위장하지 않는다. RenderProfile의 실제 지원을 확인하며 요청값을 실측 결과로 복사하지 않는다.
media는 기존 여섯 키 {path,kind,verified,duration_ms,width,height}다. PNG는 image·duration_ms=null, MP4는 video·실측 양수 길이이며 실제 형식·해상도·9:16·내용을 확인한다. 확인한 파일만 verified=true이고 미확인 크기·시간은 null이다. 데이터 경로는 루트 상대 경로다. 실제 파일이 없으면 result=null·awaiting_input, 실행/검증 실패는 failed로 보고한다. 부분 결과와 실패 시도를 보존하며 전체 요청 결과가 검증되기 전 complete로 보고하지 않는다.
자기 단계의 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 버전 review.md만 작성하고 이전 결과를 덮어쓰지 않는다. 실제 사용 버전·파일별 검사·명령·종료코드·미확인 영역·적용 원칙과 비적용 이유를 적는다. project.json·상태·현재 포인터·사용자 승인은 총괄만 갱신한다. 필드·열거값은 계약을 따르고 새 필수 입력·승인 지점을 만들지 않는다.
+1 -1
View File
@@ -11,7 +11,7 @@ description: 13-compose의 실제 완성본을 여섯 영역에서 검수해 14-
개별 호출에서도 같은 제작물의 유효한 `13-compose`와 검수에 직접 사용한 선행 결과를 확인하고 실제 사용한 `{stage_id, revision}`을 `inputs`에 기록한다. 대본·제목·근거, 실측 음성·컷, 장면·인물·배경·이미지·영상은 검수에 필요한 해당 결과와 그 선행 관계를 읽는다. 다른 제작물·오래된 버전·stale 결과를 섞지 않는다.
project.json에서 story·scenes가 사용 버전과 연결된 실제 사용자 승인인지 확인한다. pending·changes_requested·stale 승인을 우회하지 않으며 같은 승인 버전은 재승인을 요구하지 않는다. 선행 조건인 Qwen3-TTS 음성, `gpt-image-2.5-sunburst` 이미지, `references/1.png` 그림체를 유지한다. 이 스킬은 실제 모델 API·영상 생성·합성 실행이나 엔진 선택을 담당하지 않는다.
project.json에서 story·scenes가 사용 버전과 연결된 실제 사용자 승인인지 확인한다. pending·changes_requested·stale 승인을 우회하지 않으며 같은 승인 버전은 재승인을 요구하지 않는다. 선행 조건인 Qwen3-TTS 음성, 승인된 Blender 교육용 3D·실제 컷 영상과 Remotion + FFmpeg 합성 결과를 확인한다. references/1.png 원본은 보존하고 기존 1.0 결과도 유효한 선행 자료로 참조한다. 이 스킬은 실제 최종 영상 검수만 담당하며 도구를 임의로 바꾸지 않는다. 1.1의 14 구조와 여섯 검수 영역을 유지한다.
13의 실제 MP4·SRT를 열고 영상을 끝까지 시청·청취한다. `media.verified=true`만으로 검수자의 관찰을 대신하지 않는다. 실제 최종 영상이 없거나 재생해 볼 수 없으면 recommendation=incomplete이며 확인하지 못한 영역은 not_checked로 남긴다. 빠진 결과·파일·승인과 돌아갈 단계를 총괄에 보고한다. 선행 파일 미확인은 verified=false, 미확인 메타데이터는 null로 취급하며 부분 결과의 완료를 인정하지 않는다.
+3 -1
View File
@@ -23,7 +23,9 @@ description: 짧은 영상의 실제 컷마다 설명할 화면과 시작·끝
4. 07 순서의 각 인접 컷 쌍에 connections를 정확히 하나 작성한다. from_cut_id·to_cut_id는 해당 인접 쌍을 참조하고 mode는 continuous 또는 cut이다. reason에 설명이 이어지거나 위치·시간·시점이 바뀌어야 하는 이유를 적는다.
5. continuous는 앞 컷 end_state와 다음 start_state의 인물·물체 위치, 흐름 방향, 카메라 상태를 맞춘다. cut은 새로운 시점·상태를 이해할 수 있게 제시한다. 일정한 간격만을 이유로 전환하지 않는다.
정확한 필드·타입은 양식·스키마를 따른다. 기존 description·상태·camera·연결 필드에 담고 새 필드를 추가하지 않는다. 고정 그림체는 `references/1.png`를 따르되 이미지의 캠핑 소재를 새 배경으로 강제하지 않는다.
정확한 필드·타입은 양식·스키마를 따른다. 1.1의 description·상태·camera·연결과 preview 필드를 사용한다. 기존 1.0 입력은 보존·참조한다. 원본 `references/1.png`의 단순 형태·정돈된 색을 교육용 3D로 해석하고 단순 배경·부드러운 조명·선명한 형태를 사용한다. 평면 복제·캠핑 소재를 강제하지 않는다.
scenes 승인 전에 제한된 대표 Scene의 실제 .blend·PNG·짧은 MP4를 visual-designer/video-editor의 해당 책임으로 준비해 `payload.preview`에 기록할 수 있다. BlendFile·RenderProfile·sample의 정확한 구조는 공통 계약과 스키마를 따른다. sample ID·실제 컷 ID·파일·해시·버전·실제 렌더 프로필·판독성을 확인한다. 자료가 없으면 preview=null이며 준비 완료로 제시하지 않는다. 이 샘플은 기존 scenes 검토에 포함하는 품질 근거이고 정식 10–12 완료나 추가 승인 지점이 아니다. 새 08·09 승인 전 전체 제작을 하지 않는다.
## 제출과 검토 경계
+3 -3
View File
@@ -35,18 +35,18 @@ description: 주제와 언어로 14단계 짧은 영상 제작을 시작하거
1. 01 후보·선택·범위의 topic 검토를 요청한다. 실제 사용자 승인 전 02를 시작하지 않는다.
2. 02 근거와 03 대본·04 제목의 story 검토를 요청한다. 제목·첫 문장의 약속과 결말, 블록 연결·주장·비유의 한계를 제시한다. 승인 전 05를 시작하지 않는다.
3. 검증된 실제 음성으로 07을 준비하고 08·09를 같은 묶음으로 완성해 scenes 검토를 요청한다. 실측 구간·상태 변화·연결·고정 특징과 주석 근거를 제시한다. 승인 전 10을 시작하지 않는다.
3. 검증된 실제 음성으로 07을 준비하고 08·09를 같은 묶음으로 완성해 scenes 검토를 요청한다. 실측 구간·상태 변화·연결·고정 특징과 주석 근거, 08 preview의 실제 대표 화면·짧은 움직임을 제시한다. 대표 샘플의 교육용 3D 품질을 같은 scenes 검토에서 확인한다. 승인 전에는 이 제한된 샘플만 허용하며 정식 10–12 전체 제작을 시작하지 않는다.
4. 실제 13 완성본과 14 검수 보고의 final 검토를 요청한다. 승인 전 제작 완료를 선언하지 않는다.
검토한 stage_id·revision은 artifact_refs에 고정하고 실제 사용자 응답을 feedback에 기록한다. 응답이 없으면 pending 결정과 awaiting_review 상태로 기다린다. 담당의 선택 제안·검수자의 pass를 사용자 승인으로 만들지 않는다. 변경 요청은 수정할 단계와 새 버전을 배정한다.
## 실제 미디어가 없는 경우
현재 구성은 요청·결과 확인 지침과 양식이며 생성·합성을 실행하는 프로그램은 제공하지 않는다. 06은 기존 qwen3-tts 환경의 Qwen3-TTS, 10–11은 gpt-image-2.5-sunburst로 요청한다. 실제 제작 도구의 모델·화자·언어·출력 지원은 그때 확인하며 전역 환경 재설치·무단 대체를 하지 않는다. 12·13은 특정 영상·합성 엔진을 선택하지 않은 전달 계약이다.
새 양식·결과는 계약 1.1이며 새 프로젝트 settings.visual_engine은 Blender다. 06은 기존 qwen3-tts 환경의 Qwen3-TTS, 10–12는 승인된 저장소 내 Blender 실행 경로, 13은 기존 Remotion + FFmpeg 합성 경로를 사용한다. 기존 1.0 결과·승인·WAV를 보존하고 유효한 선행 버전으로 참조한다. 전역 환경 재설치·무단 도구 대체를 하지 않고 설치 확인과 실제 실행 성공을 구분한다.
result=null 또는 필수 실제 자료 부재는 awaiting_input이다. 다음 담당에게 필수 파일이 준비됐다고 보고하지 않는다. 결과 제출 시 요청 ID·선행 버전·모든 항목·파일 존재·형식·실측 메타데이터를 확인한다. 부분 제출은 보존하지만 전 단계 완료가 아니다. 실패는 원인과 blocking 여부를 issues에 기록하고 failed로 보고한다.
세로 9:16, 최종 180000ms 이내, 각 컷 0ms 초과 5000ms 이하를 확인한다. 그림체는 references/1.png의 윤곽선·단순 형태·색면 기준이며 캠핑 소재·인물·상표를 강제하지 않는다. 07은 실제 통합 음성을 0부터 끝까지 덮고 자막은 별도로 정렬한다. 예상 시간·가상 경로는 실제 확인 근거가 아니다. 음성 길이 초과는 대본 수정과 story 재승인으로 돌린다.
세로 9:16, 최종 180000ms 이내, 각 컷 0ms 초과 5000ms 이하를 확인한다. 원본 references/1.png를 보존하고 단순 형태·정돈된 색을 교육용 3D로 해석한다. 단순 배경·부드러운 조명·선명한 형태를 사용하며 평면 복제와 캠핑 소재·인물·상표를 강제하지 않는다. 07은 실제 통합 음성을 0부터 끝까지 덮고 자막은 별도로 정렬한다. 예상 시간·가상 경로는 실제 확인 근거가 아니다. 음성 길이 초과는 대본 수정과 story 재승인으로 돌린다.
## 변경과 완료
+1 -1
View File
@@ -6,4 +6,4 @@
공유 진행·현재 포인터·승인은 총괄만 작성한다. 다른 담당은 자기 배정 결과·검토를 제출한다. 완료는 실제 파일·실측·필요한 사용자 승인을 확인한 상태이고 요청서·예상·검수자 추천으로 대신하지 않는다.
역할을 바꾸면 TOML 파싱·필수 키·이름·담당 단계와 스킬 참조의 존재를 확인한다. 새 필드·추가 승인·도구 대체를 역할 지침에만 도입하지 않는다. 참조 경로는 형식 확인과 실제 파일 탐색을 구분해 검증한다.
역할을 바꾸면 TOML 파싱·필수 키·이름·담당 단계와 스킬 참조의 존재를 확인한다. 1.1 Blender 계약·08 대표 샘플의 scenes 검토·승인된 실행 경로를 공통 문서와 일치시킨다. 새 필드·추가 승인·도구 대체를 역할 지침에만 도입하지 않는다. 참조 경로는 형식 확인과 실제 파일 탐색을 구분해 검증한다.
+4 -4
View File
@@ -4,13 +4,13 @@ developer_instructions = '''
공통 계약
- 아래 모든 읽을 경로와 결과의 데이터 경로는 저장소 루트 기준이다. 실행 위치나 이 TOML의 위치를 기준으로 해석하지 않는다.
- 먼저 docs/workflow-contracts.md, contracts/project.schema.json, contracts/artifact.schema.json, templates/project.json, templates/review.md를 읽는다. 담당 단계의 templates/stages/<stage_id>.json 구조와 docs/short-video-guide.md의 지정 절을 따른다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 이미지 모델 gpt-image-2.5-sunburst를 유지한다. 그림체만 참고하고 캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. schema_version=1.0과 기존 필드만 사용한다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 새 제작의 시각 엔진 Blender를 유지한다. references/1.png의 단순 형태·정돈된 색을 교육용 3D로 해석하고 단순 배경·부드러운 조명·선명한 형태를 사용한다. 원본을 보존하며 평면 복제·캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. 새 결과는 schema_version=1.1과 해당 계약 필드만 사용한다. 기존 1.0 결과는 그대로 읽고 유효한 선행 결과로 참조할 수 있다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 버전은 1부터 시작한다. 총괄이 배정한 단계·새 버전만 작성하고 이전 버전을 덮어쓰지 않는다. 같은 단계의 같은 버전을 동시에 작성하지 않는다. 수정 영향은 실제 inputs 관계를 따라 판단하고 관계없는 결과는 보존한다.
- 기존 review.md에 적용한 제작 원칙·결과와 소재상 적용하지 않은 구성의 이유를 요약한다. 사실에 없는 위기·반전·대안 실패를 만들지 않는다. 새 필수 JSON 필드·제작 입력·승인 지점을 추가하지 않는다.
- 요청과 실제 결과를 구분한다. result=null 또는 필수 파일·자료 부재는 awaiting_input으로 보고한다. 부분 결과는 보존하고 모두 받기 전에 완료로 보고하지 않는다. 실제 존재·형식·메타데이터를 확인한 media만 verified=true로 기록한다. 예상 시간·예제 경로·확장자를 실측·검증 근거로 쓰지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 실제 API·미디어 실행 프로그램을 새로 구현하거나 영상 생성·합성 엔진을 임의로 선택하지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09와 08 대표 샘플→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 승인된 저장소 내 Blender 실행 경로와 기존 Remotion + FFmpeg 합성 경로를 사용한다. 설치 확인과 실제 렌더링 성공을 구분하고 실행 경로·도구를 임의로 대체하지 않는다.
담당 경계와 보고
- 자신의 담당 단계 결과와 review.md만 작성한다. projects/<project_id>/project.json의 현재 결과 포인터·단계 상태·승인 기록은 총괄만 갱신한다. 담당 밖의 결과·승인·공유 상태를 변경하지 않는다.
+5 -5
View File
@@ -4,13 +4,13 @@ developer_instructions = '''
공통 계약
- 아래 모든 읽을 경로와 결과의 데이터 경로는 저장소 루트 기준이다. 실행 위치나 이 TOML의 위치를 기준으로 해석하지 않는다.
- 먼저 docs/workflow-contracts.md, contracts/project.schema.json, contracts/artifact.schema.json, templates/project.json, templates/review.md를 읽는다. 담당 단계의 templates/stages/<stage_id>.json 구조와 docs/short-video-guide.md의 지정 절을 따른다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 이미지 모델 gpt-image-2.5-sunburst를 유지한다. 그림체만 참고하고 캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. schema_version=1.0과 기존 필드만 사용한다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 새 제작의 시각 엔진 Blender를 유지한다. references/1.png의 단순 형태·정돈된 색을 교육용 3D로 해석하고 단순 배경·부드러운 조명·선명한 형태를 사용한다. 원본을 보존하며 평면 복제·캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. 새 결과는 schema_version=1.1과 해당 계약 필드만 사용한다. 기존 1.0 결과는 그대로 읽고 유효한 선행 결과로 참조할 수 있다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 버전은 1부터 시작한다. 총괄이 배정한 단계·새 버전만 작성하고 이전 버전을 덮어쓰지 않는다. 같은 단계의 같은 버전을 동시에 작성하지 않는다. 수정 영향은 실제 inputs 관계를 따라 판단하고 관계없는 결과는 보존한다.
- 기존 review.md에 적용한 제작 원칙·결과와 소재상 적용하지 않은 구성의 이유를 요약한다. 사실에 없는 위기·반전·대안 실패를 만들지 않는다. 새 필수 JSON 필드·제작 입력·승인 지점을 추가하지 않는다.
- 요청과 실제 결과를 구분한다. result=null 또는 필수 파일·자료 부재는 awaiting_input으로 보고한다. 부분 결과는 보존하고 모두 받기 전에 완료로 보고하지 않는다. 실제 존재·형식·메타데이터를 확인한 media만 verified=true로 기록한다. 예상 시간·예제 경로·확장자를 실측·검증 근거로 쓰지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 실제 API·미디어 실행 프로그램을 새로 구현하거나 영상 생성·합성 엔진을 임의로 선택하지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09와 08 대표 샘플→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 승인된 저장소 내 Blender 실행 경로와 기존 Remotion + FFmpeg 합성 경로를 사용한다. 설치 확인과 실제 렌더링 성공을 구분하고 실행 경로·도구를 임의로 대체하지 않는다.
담당 경계와 보고
- 자신의 담당 단계 결과와 review.md만 작성한다. projects/<project_id>/project.json의 현재 결과 포인터·단계 상태·승인 기록은 총괄만 갱신한다. 담당 밖의 결과·승인·공유 상태를 변경하지 않는다.
@@ -30,5 +30,5 @@ developer_instructions = '''
완료 조건
- 전체 음성을 덮는 컷·자막 시간, 언어·내용·순서·식별자 참조, 모든 컷의 장면·등장 요소·인접 연결을 확인한다. 07·08·09의 scenes 실제 승인이 있어야 완료다.
보류 기준
- 통합 WAV·실측·정렬 확인이 없으면 예상 시간을 확정하지 않고 총괄에게 보고한다. 승인 전에 유효한 07로 08·09를 준비할 수 있으나 10으로 넘어가지 않는다. 180000ms 초과는 대본 수정과 story 재승인으로 보고한다.
- 통합 WAV·실측·정렬 확인이 없으면 예상 시간을 확정하지 않고 총괄에게 보고한다. 승인 전에 유효한 07로 08·09와 제한된 08 대표 .blend·PNG·짧은 MP4를 준비해 같은 scenes 검토에 제시할 수 있다. 교육용 3D 품질·상태 변화·카메라·실제 원본 근거를 확인하고 정식 10–12는 새 08·09 승인 후 시작한다. 180000ms 초과는 대본 수정과 story 재승인으로 보고한다.
'''
+4 -4
View File
@@ -4,13 +4,13 @@ developer_instructions = '''
공통 계약
- 아래 모든 읽을 경로와 결과의 데이터 경로는 저장소 루트 기준이다. 실행 위치나 이 TOML의 위치를 기준으로 해석하지 않는다.
- 먼저 docs/workflow-contracts.md, contracts/project.schema.json, contracts/artifact.schema.json, templates/project.json, templates/review.md를 읽는다. 담당 단계의 templates/stages/<stage_id>.json 구조와 docs/short-video-guide.md의 지정 절을 따른다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 이미지 모델 gpt-image-2.5-sunburst를 유지한다. 그림체만 참고하고 캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. schema_version=1.0과 기존 필드만 사용한다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 새 제작의 시각 엔진 Blender를 유지한다. references/1.png의 단순 형태·정돈된 색을 교육용 3D로 해석하고 단순 배경·부드러운 조명·선명한 형태를 사용한다. 원본을 보존하며 평면 복제·캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. 새 결과는 schema_version=1.1과 해당 계약 필드만 사용한다. 기존 1.0 결과는 그대로 읽고 유효한 선행 결과로 참조할 수 있다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 버전은 1부터 시작한다. 총괄이 배정한 단계·새 버전만 작성하고 이전 버전을 덮어쓰지 않는다. 같은 단계의 같은 버전을 동시에 작성하지 않는다. 수정 영향은 실제 inputs 관계를 따라 판단하고 관계없는 결과는 보존한다.
- 기존 review.md에 적용한 제작 원칙·결과와 소재상 적용하지 않은 구성의 이유를 요약한다. 사실에 없는 위기·반전·대안 실패를 만들지 않는다. 새 필수 JSON 필드·제작 입력·승인 지점을 추가하지 않는다.
- 요청과 실제 결과를 구분한다. result=null 또는 필수 파일·자료 부재는 awaiting_input으로 보고한다. 부분 결과는 보존하고 모두 받기 전에 완료로 보고하지 않는다. 실제 존재·형식·메타데이터를 확인한 media만 verified=true로 기록한다. 예상 시간·예제 경로·확장자를 실측·검증 근거로 쓰지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 실제 API·미디어 실행 프로그램을 새로 구현하거나 영상 생성·합성 엔진을 임의로 선택하지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09와 08 대표 샘플→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 승인된 저장소 내 Blender 실행 경로와 기존 Remotion + FFmpeg 합성 경로를 사용한다. 설치 확인과 실제 렌더링 성공을 구분하고 실행 경로·도구를 임의로 대체하지 않는다.
담당 경계와 보고
- 자신의 담당 단계 결과와 review.md만 작성한다. projects/<project_id>/project.json의 현재 결과 포인터·단계 상태·승인 기록은 총괄만 갱신한다. 담당 밖의 결과·승인·공유 상태를 변경하지 않는다.
+4 -4
View File
@@ -4,13 +4,13 @@ developer_instructions = '''
공통 계약
- 아래 모든 읽을 경로와 결과의 데이터 경로는 저장소 루트 기준이다. 실행 위치나 이 TOML의 위치를 기준으로 해석하지 않는다.
- 먼저 docs/workflow-contracts.md, contracts/project.schema.json, contracts/artifact.schema.json, templates/project.json, templates/review.md를 읽는다. 담당 단계의 templates/stages/<stage_id>.json 구조와 docs/short-video-guide.md의 지정 절을 따른다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 이미지 모델 gpt-image-2.5-sunburst를 유지한다. 그림체만 참고하고 캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. schema_version=1.0과 기존 필드만 사용한다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 새 제작의 시각 엔진 Blender를 유지한다. references/1.png의 단순 형태·정돈된 색을 교육용 3D로 해석하고 단순 배경·부드러운 조명·선명한 형태를 사용한다. 원본을 보존하며 평면 복제·캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. 새 결과는 schema_version=1.1과 해당 계약 필드만 사용한다. 기존 1.0 결과는 그대로 읽고 유효한 선행 결과로 참조할 수 있다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 버전은 1부터 시작한다. 총괄이 배정한 단계·새 버전만 작성하고 이전 버전을 덮어쓰지 않는다. 같은 단계의 같은 버전을 동시에 작성하지 않는다. 수정 영향은 실제 inputs 관계를 따라 판단하고 관계없는 결과는 보존한다.
- 기존 review.md에 적용한 제작 원칙·결과와 소재상 적용하지 않은 구성의 이유를 요약한다. 사실에 없는 위기·반전·대안 실패를 만들지 않는다. 새 필수 JSON 필드·제작 입력·승인 지점을 추가하지 않는다.
- 요청과 실제 결과를 구분한다. result=null 또는 필수 파일·자료 부재는 awaiting_input으로 보고한다. 부분 결과는 보존하고 모두 받기 전에 완료로 보고하지 않는다. 실제 존재·형식·메타데이터를 확인한 media만 verified=true로 기록한다. 예상 시간·예제 경로·확장자를 실측·검증 근거로 쓰지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 실제 API·미디어 실행 프로그램을 새로 구현하거나 영상 생성·합성 엔진을 임의로 선택하지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09와 08 대표 샘플→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 승인된 저장소 내 Blender 실행 경로와 기존 Remotion + FFmpeg 합성 경로를 사용한다. 설치 확인과 실제 렌더링 성공을 구분하고 실행 경로·도구를 임의로 대체하지 않는다.
담당 경계와 보고
- 자신의 담당 단계 결과와 review.md만 작성한다. projects/<project_id>/project.json의 현재 결과 포인터·단계 상태·승인 기록은 총괄만 갱신한다. 담당 밖의 결과·승인·공유 상태를 변경하지 않는다.
+4 -4
View File
@@ -4,13 +4,13 @@ developer_instructions = '''
공통 계약
- 아래 모든 읽을 경로와 결과의 데이터 경로는 저장소 루트 기준이다. 실행 위치나 이 TOML의 위치를 기준으로 해석하지 않는다.
- 먼저 docs/workflow-contracts.md, contracts/project.schema.json, contracts/artifact.schema.json, templates/project.json, templates/review.md를 읽는다. 담당 단계의 templates/stages/<stage_id>.json 구조와 docs/short-video-guide.md의 지정 절을 따른다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 이미지 모델 gpt-image-2.5-sunburst를 유지한다. 그림체만 참고하고 캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. schema_version=1.0과 기존 필드만 사용한다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 새 제작의 시각 엔진 Blender를 유지한다. references/1.png의 단순 형태·정돈된 색을 교육용 3D로 해석하고 단순 배경·부드러운 조명·선명한 형태를 사용한다. 원본을 보존하며 평면 복제·캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. 새 결과는 schema_version=1.1과 해당 계약 필드만 사용한다. 기존 1.0 결과는 그대로 읽고 유효한 선행 결과로 참조할 수 있다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 버전은 1부터 시작한다. 총괄이 배정한 단계·새 버전만 작성하고 이전 버전을 덮어쓰지 않는다. 같은 단계의 같은 버전을 동시에 작성하지 않는다. 수정 영향은 실제 inputs 관계를 따라 판단하고 관계없는 결과는 보존한다.
- 기존 review.md에 적용한 제작 원칙·결과와 소재상 적용하지 않은 구성의 이유를 요약한다. 사실에 없는 위기·반전·대안 실패를 만들지 않는다. 새 필수 JSON 필드·제작 입력·승인 지점을 추가하지 않는다.
- 요청과 실제 결과를 구분한다. result=null 또는 필수 파일·자료 부재는 awaiting_input으로 보고한다. 부분 결과는 보존하고 모두 받기 전에 완료로 보고하지 않는다. 실제 존재·형식·메타데이터를 확인한 media만 verified=true로 기록한다. 예상 시간·예제 경로·확장자를 실측·검증 근거로 쓰지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 실제 API·미디어 실행 프로그램을 새로 구현하거나 영상 생성·합성 엔진을 임의로 선택하지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09와 08 대표 샘플→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 승인된 저장소 내 Blender 실행 경로와 기존 Remotion + FFmpeg 합성 경로를 사용한다. 설치 확인과 실제 렌더링 성공을 구분하고 실행 경로·도구를 임의로 대체하지 않는다.
담당 범위와 읽을 지침
- 전체 01-topic, 02-research, 03-script, 04-title, 05-tts-split, 06-tts, 07-cuts, 08-scenes, 09-entities, 10-references, 11-images, 12-motion, 13-compose, 14-review를 총괄한다.
+6 -6
View File
@@ -4,13 +4,13 @@ developer_instructions = '''
공통 계약
- 아래 모든 읽을 경로와 결과의 데이터 경로는 저장소 루트 기준이다. 실행 위치나 이 TOML의 위치를 기준으로 해석하지 않는다.
- 먼저 docs/workflow-contracts.md, contracts/project.schema.json, contracts/artifact.schema.json, templates/project.json, templates/review.md를 읽는다. 담당 단계의 templates/stages/<stage_id>.json 구조와 docs/short-video-guide.md의 지정 절을 따른다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 이미지 모델 gpt-image-2.5-sunburst를 유지한다. 그림체만 참고하고 캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. schema_version=1.0과 기존 필드만 사용한다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 새 제작의 시각 엔진 Blender를 유지한다. references/1.png의 단순 형태·정돈된 색을 교육용 3D로 해석하고 단순 배경·부드러운 조명·선명한 형태를 사용한다. 원본을 보존하며 평면 복제·캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. 새 결과는 schema_version=1.1과 해당 계약 필드만 사용한다. 기존 1.0 결과는 그대로 읽고 유효한 선행 결과로 참조할 수 있다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 버전은 1부터 시작한다. 총괄이 배정한 단계·새 버전만 작성하고 이전 버전을 덮어쓰지 않는다. 같은 단계의 같은 버전을 동시에 작성하지 않는다. 수정 영향은 실제 inputs 관계를 따라 판단하고 관계없는 결과는 보존한다.
- 기존 review.md에 적용한 제작 원칙·결과와 소재상 적용하지 않은 구성의 이유를 요약한다. 사실에 없는 위기·반전·대안 실패를 만들지 않는다. 새 필수 JSON 필드·제작 입력·승인 지점을 추가하지 않는다.
- 요청과 실제 결과를 구분한다. result=null 또는 필수 파일·자료 부재는 awaiting_input으로 보고한다. 부분 결과는 보존하고 모두 받기 전에 완료로 보고하지 않는다. 실제 존재·형식·메타데이터를 확인한 media만 verified=true로 기록한다. 예상 시간·예제 경로·확장자를 실측·검증 근거로 쓰지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 실제 API·미디어 실행 프로그램을 새로 구현하거나 영상 생성·합성 엔진을 임의로 선택하지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09와 08 대표 샘플→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 승인된 저장소 내 Blender 실행 경로와 기존 Remotion + FFmpeg 합성 경로를 사용한다. 설치 확인과 실제 렌더링 성공을 구분하고 실행 경로·도구를 임의로 대체하지 않는다.
담당 경계와 보고
- 자신의 담당 단계 결과와 review.md만 작성한다. projects/<project_id>/project.json의 현재 결과 포인터·단계 상태·승인 기록은 총괄만 갱신한다. 담당 밖의 결과·승인·공유 상태를 변경하지 않는다.
@@ -18,12 +18,12 @@ developer_instructions = '''
- 진행 불가 시 부분 결과를 보존하고 이유, 누락 자료·실제 파일·승인, 재개에 필요한 조건, 돌아갈 단계와 권장 상태(awaiting_input/awaiting_review/failed)를 총괄에게 보고한다. 실행·검증 실패는 failed, 변경된 선행 결과는 stale 대상과 영향 범위를 제안한다. 없는 근거·파일·승인을 만들어 진행하지 않는다.
담당 범위와 읽을 지침
- 담당 단계는 12-motion, 13-compose다. 단계 스킬 .agents/skills/short-video-motion/SKILL.md와 .agents/skills/short-video-compose/SKILL.md를 읽는다.
- 담당 단계는 12-motion, 13-compose다. scenes 검토용 제한된 08 짧은 대표 영상은 장면 담당의 배정과 08 preview 계약 안에서 지원하며 전체 컷 제작과 구분한다. 단계 스킬 .agents/skills/short-video-motion/SKILL.md와 .agents/skills/short-video-compose/SKILL.md를 읽는다.
- docs/short-video-guide.md의 음성을 기준으로 컷과 연결을 확정한다, 깔끔한 그림체로 작동 방식을 보여준다 중 움직임 부분, 자막과 주석을 읽을 수 있게 배치한다, 최종 영상 검토를 읽는다.
입력
- 12는 유효한 07-cuts·08-scenes·11-images와 실제 시작/끝 PNG, 13은 03-script·04-title·06-tts·07-cuts·12-motion의 지정 버전과 실제 WAV·컷 MP4다.
출력
- templates/stages/12-motion.json에 각 컷의 start_frame_id·end_frame_id·실측 컷 duration_ms·motion_prompt 요청을 작성한다. 설명에 필요한 상태 변화와 연속 기준을 보존한다. 받은 MP4 결과를 cut_id에 연결한다.
- templates/stages/12-motion.json에 각 컷의 start_frame_id·end_frame_id·실측 컷 duration_ms·motion_prompt·실제 scene_name·[start_frame,end_frame) 요청을 작성한다. 11 실제 .blend와 같은 RenderProfile을 사용하며 논리 프레임을 Blender 번호(+1)로 평가한다. 설명에 필요한 상태 변화와 연속 기준을 보존한다. cut의 Scene을 구별하고 캐시 키에 Scene 이름을 포함한다. 받은 MP4 결과를 cut_id에 연결한다.
- templates/stages/13-compose.json에 timeline·통합 narration_path·captions·title 요청을 작성한다. 실제 받은 최종 MP4와 SRT를 result에 연결한다. 데이터 경로는 루트 기준이며 가상 파일을 실제 결과로 쓰지 않는다.
완료 조건
- 12의 모든 컷·프레임 참조·실제 길이·시작/끝 상태가 07·08·11과 맞는다. 13의 컷 순서·타임라인·통합 음성·제목·입력 언어 자막이 일치하며 실제 MP4의 너비·높이·9:16·양수 길이·180000ms 이하와 SRT 존재·내용·정렬을 확인한다.
+4 -4
View File
@@ -4,13 +4,13 @@ developer_instructions = '''
공통 계약
- 아래 모든 읽을 경로와 결과의 데이터 경로는 저장소 루트 기준이다. 실행 위치나 이 TOML의 위치를 기준으로 해석하지 않는다.
- 먼저 docs/workflow-contracts.md, contracts/project.schema.json, contracts/artifact.schema.json, templates/project.json, templates/review.md를 읽는다. 담당 단계의 templates/stages/<stage_id>.json 구조와 docs/short-video-guide.md의 지정 절을 따른다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 이미지 모델 gpt-image-2.5-sunburst를 유지한다. 그림체만 참고하고 캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. schema_version=1.0과 기존 필드만 사용한다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 새 제작의 시각 엔진 Blender를 유지한다. references/1.png의 단순 형태·정돈된 색을 교육용 3D로 해석하고 단순 배경·부드러운 조명·선명한 형태를 사용한다. 원본을 보존하며 평면 복제·캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. 새 결과는 schema_version=1.1과 해당 계약 필드만 사용한다. 기존 1.0 결과는 그대로 읽고 유효한 선행 결과로 참조할 수 있다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 버전은 1부터 시작한다. 총괄이 배정한 단계·새 버전만 작성하고 이전 버전을 덮어쓰지 않는다. 같은 단계의 같은 버전을 동시에 작성하지 않는다. 수정 영향은 실제 inputs 관계를 따라 판단하고 관계없는 결과는 보존한다.
- 기존 review.md에 적용한 제작 원칙·결과와 소재상 적용하지 않은 구성의 이유를 요약한다. 사실에 없는 위기·반전·대안 실패를 만들지 않는다. 새 필수 JSON 필드·제작 입력·승인 지점을 추가하지 않는다.
- 요청과 실제 결과를 구분한다. result=null 또는 필수 파일·자료 부재는 awaiting_input으로 보고한다. 부분 결과는 보존하고 모두 받기 전에 완료로 보고하지 않는다. 실제 존재·형식·메타데이터를 확인한 media만 verified=true로 기록한다. 예상 시간·예제 경로·확장자를 실측·검증 근거로 쓰지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 실제 API·미디어 실행 프로그램을 새로 구현하거나 영상 생성·합성 엔진을 임의로 선택하지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09와 08 대표 샘플→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 승인된 저장소 내 Blender 실행 경로와 기존 Remotion + FFmpeg 합성 경로를 사용한다. 설치 확인과 실제 렌더링 성공을 구분하고 실행 경로·도구를 임의로 대체하지 않는다.
담당 경계와 보고
- 자신의 담당 단계 결과와 review.md만 작성한다. projects/<project_id>/project.json의 현재 결과 포인터·단계 상태·승인 기록은 총괄만 갱신한다. 담당 밖의 결과·승인·공유 상태를 변경하지 않는다.
+8 -8
View File
@@ -4,13 +4,13 @@ developer_instructions = '''
공통 계약
- 아래 모든 읽을 경로와 결과의 데이터 경로는 저장소 루트 기준이다. 실행 위치나 이 TOML의 위치를 기준으로 해석하지 않는다.
- 먼저 docs/workflow-contracts.md, contracts/project.schema.json, contracts/artifact.schema.json, templates/project.json, templates/review.md를 읽는다. 담당 단계의 templates/stages/<stage_id>.json 구조와 docs/short-video-guide.md의 지정 절을 따른다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 이미지 모델 gpt-image-2.5-sunburst를 유지한다. 그림체만 참고하고 캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. schema_version=1.0과 기존 필드만 사용한다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 필수 제작 입력은 topic, language다. 입력 언어로 대본·제목·내레이션·자막을 작성한다. 세로 9:16, 최종 180000ms 이하, 각 컷 0ms 초과 5000ms 이하, 그림체 references/1.png, Qwen3-TTS와 conda 환경 qwen3-tts, 새 제작의 시각 엔진 Blender를 유지한다. references/1.png의 단순 형태·정돈된 색을 교육용 3D로 해석하고 단순 배경·부드러운 조명·선명한 형태를 사용한다. 원본을 보존하며 평면 복제·캠핑 소재·인물·상표를 강제하지 않는다.
- 프로젝트의 현재 포인터·선행 버전·실제 파일·승인을 읽어 유효성을 확인한다. 결과는 projects/<project_id>/stages/<stage_id>/r<revision>/artifact.json과 같은 위치의 review.md에 작성한다. 새 결과는 schema_version=1.1과 해당 계약 필드만 사용한다. 기존 1.0 결과는 그대로 읽고 유효한 선행 결과로 참조할 수 있다. inputs에는 실제 사용한 같은 제작물의 유효한 {stage_id, revision}을 기록한다. 중복·자기 참조를 금지한다.
- 버전은 1부터 시작한다. 총괄이 배정한 단계·새 버전만 작성하고 이전 버전을 덮어쓰지 않는다. 같은 단계의 같은 버전을 동시에 작성하지 않는다. 수정 영향은 실제 inputs 관계를 따라 판단하고 관계없는 결과는 보존한다.
- 기존 review.md에 적용한 제작 원칙·결과와 소재상 적용하지 않은 구성의 이유를 요약한다. 사실에 없는 위기·반전·대안 실패를 만들지 않는다. 새 필수 JSON 필드·제작 입력·승인 지점을 추가하지 않는다.
- 요청과 실제 결과를 구분한다. result=null 또는 필수 파일·자료 부재는 awaiting_input으로 보고한다. 부분 결과는 보존하고 모두 받기 전에 완료로 보고하지 않는다. 실제 존재·형식·메타데이터를 확인한 media만 verified=true로 기록한다. 예상 시간·예제 경로·확장자를 실측·검증 근거로 쓰지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 실제 API·미디어 실행 프로그램을 새로 구현하거나 영상 생성·합성 엔진을 임의로 선택하지 않는다.
- 네 사용자 검토는 topic(01→02), story(02 근거·03/04→05), scenes(07/08/09와 08 대표 샘플→10), final(13/14→제작 완료)이다. 실제 사용자 응답만 승인이다. 담당의 추천이나 응답 없음은 승인이 아니다. 동일한 유효 승인 버전으로 재개하면 재승인을 요구하지 않는다.
- 모델·추론 노력·도구·권한 등 세션 설정은 부모의 설정을 상속한다. 사용자 전체 설정·전역 설정·등록 파일을 변경하지 않는다. 승인된 저장소 내 Blender 실행 경로와 기존 Remotion + FFmpeg 합성 경로를 사용한다. 설치 확인과 실제 렌더링 성공을 구분하고 실행 경로·도구를 임의로 대체하지 않는다.
담당 경계와 보고
- 자신의 담당 단계 결과와 review.md만 작성한다. projects/<project_id>/project.json의 현재 결과 포인터·단계 상태·승인 기록은 총괄만 갱신한다. 담당 밖의 결과·승인·공유 상태를 변경하지 않는다.
@@ -18,15 +18,15 @@ developer_instructions = '''
- 진행 불가 시 부분 결과를 보존하고 이유, 누락 자료·실제 파일·승인, 재개에 필요한 조건, 돌아갈 단계와 권장 상태(awaiting_input/awaiting_review/failed)를 총괄에게 보고한다. 실행·검증 실패는 failed, 변경된 선행 결과는 stale 대상과 영향 범위를 제안한다. 없는 근거·파일·승인을 만들어 진행하지 않는다.
담당 범위와 읽을 지침
- 담당 단계는 10-references, 11-images다. 단계 스킬 .agents/skills/short-video-references/SKILL.md와 .agents/skills/short-video-images/SKILL.md를 읽는다.
- 담당 단계는 10-references, 11-images다. scenes 검토를 위한 제한된 08 대표 모델·PNG도 장면 담당의 배정과 08 preview 계약 안에서 지원하며 정식 10–12 완료로 취급하지 않는다. 단계 스킬 .agents/skills/short-video-references/SKILL.md와 .agents/skills/short-video-images/SKILL.md를 읽는다.
- docs/short-video-guide.md의 근거와 숫자는 작성 전에 정리한다, 설명의 단위를 화면으로 바꾼다, 깔끔한 그림체로 작동 방식을 보여준다, 자막과 주석을 읽을 수 있게 배치한다를 읽는다.
입력
- 10은 유효한 02-research·08-scenes·09-entities와 scenes 승인, 11은 02-research·03-script·07-cuts·08-scenes·09-entities·10-references의 지정 버전과 실제 기준 PNG다. references/1.png를 보고 그림체를 확인한다.
출력
- templates/stages/10-references.json에 gpt-image-2.5-sunburst·references/1.png와 style/character/background 요청을 작성한다. style만 entity_id=null이며 나머지는 유효한 요소 ID를 연결한다. 받은 실제 PNG를 요청 reference_id에 연결한다.
- templates/stages/11-images.json에 각 컷의 start/end 요청과 reference_ids를 작성한다. 받은 실제 PNG를 frame_id에 연결하고 장면 상태·조사 근거·고정 특징·연속성을 확인한다.
- templates/stages/10-references.json에 Blender·references/1.png·RenderProfile과 style/character/background/object 요청을 작성한다. style/object는 entity_id=null이며 나머지는 유효한 09 요소 ID를 연결한다. 실제 .blend·해시·버전과 object_name의 오브젝트/컬렉션, 실제 PNG를 요청 reference_id에 연결한다.
- templates/stages/11-images.json에 각 컷의 start/end·scene_name·논리 frame_index와 reference_ids를 작성하고 같은 컷 두 위치의 Scene을 일치시킨다. 실제 .blend와 RenderProfile을 기록한다. 받은 실제 PNG를 frame_id에 연결하고 장면 상태·조사 근거·고정 특징·연속성을 확인한다.
완료 조건
- 10의 모든 요청 ID·고정 특징과 11의 모든 컷에 start/end가 각각 정확히 하나씩 있으며 실제 PNG의 존재·형식·너비·높이·내용을 확인한다. 세로 9:16 제작에 필요한 출력과 기준 그림체를 검토하고 숫자·글자의 정확성을 확인한다.
보류 기준
- scenes 승인·스타일 기준·사실 근거·기준 이미지·실제 PNG 또는 지정 모델·세로 출력 지원이 없으면 누락을 총괄에게 보고한다. 다른 모델이나 확인하지 않은 API 파라미터로 대체하지 않는다. 부정확한 생성 글자·숫자는 검증한 합성 텍스트 배치 대상으로 보고한다.
- scenes 승인·스타일 기준·사실 근거·기준 이미지·실제 PNG 또는 Blender 실행·세로 출력 지원이 없으면 누락을 총괄에게 보고한다. 다른 엔진이나 확인하지 않은 실행 파라미터로 대체하지 않는다. 부정확한 생성 글자·숫자는 검증한 합성 텍스트 배치 대상으로 보고한다.
'''
+3
View File
@@ -1 +1,4 @@
.dryforge/
.cache/
media/
renderer/
+11 -5
View File
@@ -1,6 +1,6 @@
# 프로젝트 작업 지침
ShortVideoGenerator는 혼자 Codex에서 사용하는 짧은 영상 제작 프로젝트다. 주제와 언어를 제작마다 입력하고 14단계의 역할 지침·스킬·JSON 양식과 사용자 검토를 연결한다. 분야별 특화와 실제 생성·합성 도구 연결은 이후 작업에서 다룬다.
ShortVideoGenerator는 혼자 Codex에서 사용하는 짧은 영상 제작 프로젝트다. 주제와 언어를 제작마다 입력하고 14단계의 역할 지침·스킬·JSON 양식과 사용자 검토를 연결한다. 분야별 장면 제작은 별도 작업으로 구체화하며 Blender 모델·애니메이션과 기존 Remotion + FFmpeg 합성 경로를 연결한다.
## 작업 전에 확인
@@ -12,9 +12,9 @@ ShortVideoGenerator는 혼자 Codex에서 사용하는 짧은 영상 제작 프
## 유지할 제작 기준
- 필수 입력은 주제·언어, 출력은 세로 9:16·최종 3분 이내·음성 기준 각 컷 5초 이내다. 쉬운 표현은 어린이 전용이라는 뜻이 아니다.
- 스타일은 references/1.png의 그림체다. 음성은 Qwen3-TTS의 기존 qwen3-tts 환경, 이미지는 gpt-image-2.5-sunburst다. 사용자 전체 환경·설정을 변경하거나 도구를 무단 대체하지 않는다.
- topic/story/scenes/final의 실제 사용자 승인 없이 다음 경계를 넘지 않는다. 같은 승인 버전으로 재개하면 다시 묻지 않는다.
- 총괄만 project.json을 작성한다. 담당은 자기 새 버전의 결과·검토를 제출하고 이전 결과를 보존한다. 요청·예상·예제와 실제 결과·실측·승인을 구분한다.
- 원본 references/1.png를 보존하고 단순 형태·정돈된 색을 깔끔한 교육용 3D로 해석한다. 단순한 배경·부드러운 조명·선명한 형태를 사용하며 평면 복제를 강제하지 않는다. 음성은 Qwen3-TTS의 기존 qwen3-tts 환경, 새 시각 제작은 Blender다. 사용자 전체 환경·설정을 변경하거나 도구를 무단 대체하지 않는다.
- topic/story/scenes/final의 실제 사용자 승인 없이 다음 경계를 넘지 않는다. 같은 승인 버전으로 재개하면 다시 묻지 않는다. scenes 승인 전에는 08의 제한된 대표 화면·짧은 움직임만 제작해 검토 근거로 사용하며 정식 10–12 완료로 처리하지 않는다. 새 08·09 승인 전 전체 제작을 시작하지 않는다.
- 새 양식·결과는 계약 1.1, 기존 1.0 결과는 보존·참조한다. .blend 원본은 BlendFile로 기록하며 PNG·MP4 media로 위장하지 않는다. 총괄만 project.json을 작성한다. 담당은 자기 새 버전의 결과·검토를 제출하고 이전 결과를 보존한다. 요청·예상·예제와 실제 결과·실측·승인을 구분한다.
## 프로젝트 구조
@@ -38,11 +38,17 @@ ShortVideoGenerator/
│ ├── findings.md → 미해결 문제
│ └── decisions/
│ ├── index.md → 결정 기록 목록
│ └── 0001-local-instructions.md → 로컬 지침·JSON 전달 선택
│ ├── 0001-local-instructions.md → 로컬 지침·JSON 전달 선택
│ └── 0002-local-blender-rendering.md → 모델·실제 애니메이션 선택
├── .codex/agents/AGENTS.md → 8개 역할 설정의 작성 경계
├── .agents/skills/AGENTS.md → 15개 작업 스킬의 작성 경계
├── contracts/AGENTS.md → 스키마의 구조·의미 구분
├── templates/AGENTS.md → 빈 양식·검토 양식의 경계
├── tools/blender/AGENTS.md → Blender 원본 검사·렌더 실행 경계
├── projects/
│ └── water-hydrogen-bond-surface-tension/
│ ├── blender/AGENTS.md → 물 소재 3D 작성 경계
│ └── renderer/AGENTS.md → 실제 컷·음성·자막 합성 경계
├── references/ → 사용자 그림체와 참고 분석
└── examples/ → 실제 실행과 구분한 연결 예제
```
+11 -5
View File
@@ -1,6 +1,6 @@
# 프로젝트 작업 지침
ShortVideoGenerator는 혼자 Codex에서 사용하는 짧은 영상 제작 프로젝트다. 주제와 언어를 제작마다 입력하고 14단계의 역할 지침·스킬·JSON 양식과 사용자 검토를 연결한다. 분야별 특화와 실제 생성·합성 도구 연결은 이후 작업에서 다룬다.
ShortVideoGenerator는 혼자 Codex에서 사용하는 짧은 영상 제작 프로젝트다. 주제와 언어를 제작마다 입력하고 14단계의 역할 지침·스킬·JSON 양식과 사용자 검토를 연결한다. 분야별 장면 제작은 별도 작업으로 구체화하며 Blender 모델·애니메이션과 기존 Remotion + FFmpeg 합성 경로를 연결한다.
## 작업 전에 확인
@@ -12,9 +12,9 @@ ShortVideoGenerator는 혼자 Codex에서 사용하는 짧은 영상 제작 프
## 유지할 제작 기준
- 필수 입력은 주제·언어, 출력은 세로 9:16·최종 3분 이내·음성 기준 각 컷 5초 이내다. 쉬운 표현은 어린이 전용이라는 뜻이 아니다.
- 스타일은 references/1.png의 그림체다. 음성은 Qwen3-TTS의 기존 qwen3-tts 환경, 이미지는 gpt-image-2.5-sunburst다. 사용자 전체 환경·설정을 변경하거나 도구를 무단 대체하지 않는다.
- topic/story/scenes/final의 실제 사용자 승인 없이 다음 경계를 넘지 않는다. 같은 승인 버전으로 재개하면 다시 묻지 않는다.
- 총괄만 project.json을 작성한다. 담당은 자기 새 버전의 결과·검토를 제출하고 이전 결과를 보존한다. 요청·예상·예제와 실제 결과·실측·승인을 구분한다.
- 원본 references/1.png를 보존하고 단순 형태·정돈된 색을 깔끔한 교육용 3D로 해석한다. 단순한 배경·부드러운 조명·선명한 형태를 사용하며 평면 복제를 강제하지 않는다. 음성은 Qwen3-TTS의 기존 qwen3-tts 환경, 새 시각 제작은 Blender다. 사용자 전체 환경·설정을 변경하거나 도구를 무단 대체하지 않는다.
- topic/story/scenes/final의 실제 사용자 승인 없이 다음 경계를 넘지 않는다. 같은 승인 버전으로 재개하면 다시 묻지 않는다. scenes 승인 전에는 08의 제한된 대표 화면·짧은 움직임만 제작해 검토 근거로 사용하며 정식 10–12 완료로 처리하지 않는다. 새 08·09 승인 전 전체 제작을 시작하지 않는다.
- 새 양식·결과는 계약 1.1, 기존 1.0 결과는 보존·참조한다. .blend 원본은 BlendFile로 기록하며 PNG·MP4 media로 위장하지 않는다. 총괄만 project.json을 작성한다. 담당은 자기 새 버전의 결과·검토를 제출하고 이전 결과를 보존한다. 요청·예상·예제와 실제 결과·실측·승인을 구분한다.
## 프로젝트 구조
@@ -38,11 +38,17 @@ ShortVideoGenerator/
│ ├── findings.md → 미해결 문제
│ └── decisions/
│ ├── index.md → 결정 기록 목록
│ └── 0001-local-instructions.md → 로컬 지침·JSON 전달 선택
│ ├── 0001-local-instructions.md → 로컬 지침·JSON 전달 선택
│ └── 0002-local-blender-rendering.md → 모델·실제 애니메이션 선택
├── .codex/agents/AGENTS.md → 8개 역할 설정의 작성 경계
├── .agents/skills/AGENTS.md → 15개 작업 스킬의 작성 경계
├── contracts/AGENTS.md → 스키마의 구조·의미 구분
├── templates/AGENTS.md → 빈 양식·검토 양식의 경계
├── tools/blender/AGENTS.md → Blender 원본 검사·렌더 실행 경계
├── projects/
│ └── water-hydrogen-bond-surface-tension/
│ ├── blender/AGENTS.md → 물 소재 3D 작성 경계
│ └── renderer/AGENTS.md → 실제 컷·음성·자막 합성 경계
├── references/ → 사용자 그림체와 참고 분석
└── examples/ → 실제 실행과 구분한 연결 예제
```
+1 -1
View File
@@ -2,7 +2,7 @@
이 영역은 진행 기록과 14단계 결과의 JSON Schema Draft 2020-12 구조를 관리한다. 실제 파일 측정·모델 추론·사용자 승인 실행을 구현하지 않으며 스킬·양식·예제를 임의로 변경하지 않는다.
필드명·타입·단계 종류·상태 열거값을 보존한다. 결과는 공통 버전·입력 참조와 단계별 payload를 갖는다. 진행 기록에는 14단계와 네 검토가 각각 한 번 필요하다. 외부 결과 부재는 null, 미확인 미디어는 verified=false다.
기존 1.0 정의·단계 종류·상태 열거값을 보존하고 1.1 Blender 계약을 버전 분기로 추가한다. .blend는 BlendFile이며 기존 여섯 키 media의 종류를 늘리지 않는다. 결과는 공통 버전·입력 참조와 단계별 payload를 갖는다. 진행 기록에는 14단계와 네 검토가 각각 한 번 필요하다. 외부 결과 부재는 null, 미확인 미디어는 verified=false다.
구조상 유효한 빈 결과가 완료를 뜻하지 않는다. 사용자 응답·실제 파일·식별자 참조·시간 연속성은 별도 의미 확인이 필요하다. 미검수 또는 blocking 발견이 있는 검수 결과를 pass 구조로 허용하지 않는다.
+850 -9
View File
@@ -2,7 +2,10 @@
"type": "object",
"properties": {
"schema_version": {
"const": "1.0"
"enum": [
"1.0",
"1.1"
]
},
"project_id": {
"type": "string",
@@ -1620,6 +1623,708 @@
}
}
]
},
"BlendFile": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"verified": {
"type": "boolean"
},
"sha256": {
"anyOf": [
{
"type": "null"
},
{
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
]
},
"blender_version": {
"anyOf": [
{
"type": "null"
},
{
"type": "string",
"minLength": 1
}
]
}
},
"required": [
"path",
"verified",
"sha256",
"blender_version"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"verified": {
"const": true
}
}
},
"then": {
"properties": {
"path": {
"type": "string",
"minLength": 1
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"blender_version": {
"type": "string",
"minLength": 1
}
}
}
}
]
},
"RenderProfile": {
"type": "object",
"properties": {
"engine": {
"enum": [
"BLENDER_EEVEE",
"CYCLES"
]
},
"device": {
"enum": [
"CPU",
"GPU"
]
},
"fps": {
"type": "integer",
"minimum": 1
},
"width": {
"type": "integer",
"minimum": 1
},
"height": {
"type": "integer",
"minimum": 1
},
"samples": {
"type": "integer",
"minimum": 1
},
"seed": {
"type": "integer",
"minimum": 0
}
},
"required": [
"engine",
"device",
"fps",
"width",
"height",
"samples",
"seed"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"engine": {
"const": "BLENDER_EEVEE"
}
}
},
"then": {
"properties": {
"device": {
"const": "GPU"
}
}
}
}
]
},
"08-scenes-1.1": {
"type": "object",
"properties": {
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"cut_id": {
"type": "string"
},
"description": {
"type": "string"
},
"start_state": {
"type": "string"
},
"end_state": {
"type": "string"
},
"camera": {
"type": "string"
}
},
"required": [
"cut_id",
"description",
"start_state",
"end_state",
"camera"
],
"additionalProperties": false
}
},
"connections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"from_cut_id": {
"type": "string"
},
"to_cut_id": {
"type": "string"
},
"mode": {
"enum": [
"continuous",
"cut"
]
},
"reason": {
"type": "string"
}
},
"required": [
"from_cut_id",
"to_cut_id",
"mode",
"reason"
],
"additionalProperties": false
}
},
"preview": {
"anyOf": [
{
"type": "null"
},
{
"type": "object",
"properties": {
"scene_file": {
"$ref": "#/$defs/BlendFile"
},
"render_profile": {
"$ref": "#/$defs/RenderProfile"
},
"samples": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"cut_ids": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"minItems": 1,
"uniqueItems": true
},
"media": {
"allOf": [
{
"$ref": "#/$defs/media"
},
{
"properties": {
"kind": {
"enum": [
"image",
"video"
]
}
}
},
{
"if": {
"properties": {
"kind": {
"const": "video"
}
}
},
"then": {
"properties": {
"duration_ms": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 5000
}
}
}
}
]
}
},
"required": [
"id",
"cut_ids",
"media"
],
"additionalProperties": false
}
}
},
"required": [
"scene_file",
"render_profile",
"samples"
],
"additionalProperties": false
}
]
}
},
"required": [
"scenes",
"connections",
"preview"
],
"additionalProperties": false
},
"10-references-1.1": {
"type": "object",
"properties": {
"request": {
"type": "object",
"properties": {
"style_reference": {
"const": "references/1.png"
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"kind": {
"enum": [
"style",
"character",
"background",
"object"
]
},
"entity_id": {
"anyOf": [
{
"type": "null"
},
{
"type": "string"
}
]
},
"prompt": {
"type": "string"
}
},
"required": [
"id",
"kind",
"entity_id",
"prompt"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"kind": {
"enum": [
"style",
"object"
]
}
}
},
"then": {
"properties": {
"entity_id": {
"type": "null"
}
}
},
"else": {
"properties": {
"entity_id": {
"type": "string"
}
}
}
}
]
}
},
"engine": {
"const": "Blender"
},
"render_profile": {
"$ref": "#/$defs/RenderProfile"
}
},
"required": [
"style_reference",
"items",
"engine",
"render_profile"
],
"additionalProperties": false
},
"result": {
"anyOf": [
{
"type": "null"
},
{
"type": "object",
"properties": {
"scene_file": {
"$ref": "#/$defs/BlendFile"
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"reference_id": {
"type": "string"
},
"object_name": {
"type": "string",
"minLength": 1
},
"media": {
"allOf": [
{
"$ref": "#/$defs/media"
},
{
"properties": {
"kind": {
"const": "image"
}
}
}
]
}
},
"required": [
"reference_id",
"object_name",
"media"
],
"additionalProperties": false
}
}
},
"required": [
"scene_file",
"items"
],
"additionalProperties": false
}
]
}
},
"required": [
"request",
"result"
],
"additionalProperties": false
},
"11-images-1.1": {
"type": "object",
"properties": {
"request": {
"type": "object",
"properties": {
"frames": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"cut_id": {
"type": "string"
},
"position": {
"enum": [
"start",
"end"
]
},
"prompt": {
"type": "string"
},
"reference_ids": {
"type": "array",
"items": {
"type": "string"
}
},
"scene_name": {
"type": "string",
"minLength": 1
},
"frame_index": {
"type": "integer",
"minimum": 0
}
},
"required": [
"id",
"cut_id",
"position",
"prompt",
"reference_ids",
"scene_name",
"frame_index"
],
"additionalProperties": false
}
},
"engine": {
"const": "Blender"
},
"scene_path": {
"type": "string"
},
"render_profile": {
"$ref": "#/$defs/RenderProfile"
}
},
"required": [
"frames",
"engine",
"scene_path",
"render_profile"
],
"additionalProperties": false
},
"result": {
"anyOf": [
{
"type": "null"
},
{
"type": "object",
"properties": {
"frames": {
"type": "array",
"items": {
"type": "object",
"properties": {
"frame_id": {
"type": "string"
},
"media": {
"allOf": [
{
"$ref": "#/$defs/media"
},
{
"properties": {
"kind": {
"const": "image"
}
}
}
]
}
},
"required": [
"frame_id",
"media"
],
"additionalProperties": false
}
},
"scene_file": {
"$ref": "#/$defs/BlendFile"
}
},
"required": [
"frames",
"scene_file"
],
"additionalProperties": false
}
]
}
},
"required": [
"request",
"result"
],
"additionalProperties": false
},
"12-motion-1.1": {
"type": "object",
"properties": {
"request": {
"type": "object",
"properties": {
"clips": {
"type": "array",
"items": {
"type": "object",
"properties": {
"cut_id": {
"type": "string"
},
"start_frame_id": {
"type": "string"
},
"end_frame_id": {
"type": "string"
},
"duration_ms": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 5000
},
"motion_prompt": {
"type": "string"
},
"scene_name": {
"type": "string",
"minLength": 1
},
"start_frame": {
"type": "integer",
"minimum": 0
},
"end_frame": {
"type": "integer",
"minimum": 0
}
},
"required": [
"cut_id",
"start_frame_id",
"end_frame_id",
"duration_ms",
"motion_prompt",
"scene_name",
"start_frame",
"end_frame"
],
"additionalProperties": false
}
},
"engine": {
"const": "Blender"
},
"scene_path": {
"type": "string"
},
"render_profile": {
"$ref": "#/$defs/RenderProfile"
}
},
"required": [
"clips",
"engine",
"scene_path",
"render_profile"
],
"additionalProperties": false
},
"result": {
"anyOf": [
{
"type": "null"
},
{
"type": "object",
"properties": {
"clips": {
"type": "array",
"items": {
"type": "object",
"properties": {
"cut_id": {
"type": "string"
},
"media": {
"allOf": [
{
"$ref": "#/$defs/media"
},
{
"properties": {
"kind": {
"const": "video"
}
}
}
]
}
},
"required": [
"cut_id",
"media"
],
"additionalProperties": false
}
}
},
"required": [
"clips"
],
"additionalProperties": false
}
]
}
},
"required": [
"request",
"result"
],
"additionalProperties": false
}
},
"oneOf": [
@@ -1699,9 +2404,43 @@
"const": "08-scenes"
},
"payload": {
"$ref": "#/$defs/08-scenes"
"type": "object"
}
}
},
"allOf": [
{
"if": {
"properties": {
"schema_version": {
"const": "1.0"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/08-scenes"
}
}
}
},
{
"if": {
"properties": {
"schema_version": {
"const": "1.1"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/08-scenes-1.1"
}
}
}
}
]
},
{
"properties": {
@@ -1719,9 +2458,43 @@
"const": "10-references"
},
"payload": {
"$ref": "#/$defs/10-references"
"type": "object"
}
}
},
"allOf": [
{
"if": {
"properties": {
"schema_version": {
"const": "1.0"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/10-references"
}
}
}
},
{
"if": {
"properties": {
"schema_version": {
"const": "1.1"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/10-references-1.1"
}
}
}
}
]
},
{
"properties": {
@@ -1729,9 +2502,43 @@
"const": "11-images"
},
"payload": {
"$ref": "#/$defs/11-images"
"type": "object"
}
}
},
"allOf": [
{
"if": {
"properties": {
"schema_version": {
"const": "1.0"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/11-images"
}
}
}
},
{
"if": {
"properties": {
"schema_version": {
"const": "1.1"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/11-images-1.1"
}
}
}
}
]
},
{
"properties": {
@@ -1739,9 +2546,43 @@
"const": "12-motion"
},
"payload": {
"$ref": "#/$defs/12-motion"
"type": "object"
}
}
},
"allOf": [
{
"if": {
"properties": {
"schema_version": {
"const": "1.0"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/12-motion"
}
}
}
},
{
"if": {
"properties": {
"schema_version": {
"const": "1.1"
}
}
},
"then": {
"properties": {
"payload": {
"$ref": "#/$defs/12-motion-1.1"
}
}
}
}
]
},
{
"properties": {
+349
View File
@@ -0,0 +1,349 @@
"""저장소 계약의 회귀 검사. 실제 렌더링·승인·미디어 검수는 수행하지 않는다."""
from copy import deepcopy
import json
from pathlib import Path
import re
import tomllib
import unittest
from jsonschema import Draft202012Validator
ROOT = Path(__file__).resolve().parents[1]
PROFILE = dict(engine="BLENDER_EEVEE", device="GPU", fps=50, width=1080,
height=1920, samples=32, seed=1)
BLEND = dict(path="examples/blender-contract/preview.blend", verified=False,
sha256=None, blender_version=None)
IMAGE = dict(path="examples/blender-contract/preview.png", kind="image",
verified=False, duration_ms=None, width=None, height=None)
def read(path):
return json.loads((ROOT / path).read_text(encoding="utf-8"))
def artifact(stage, payload):
return dict(schema_version="1.1", project_id="contract-example", stage_id=stage,
revision=1, inputs=[], payload=payload)
def samples():
frames = [dict(id="c1-" + pos, cut_id="c1", position=pos, prompt="상태 설명",
reference_ids=["water"], scene_name="Water", frame_index=index)
for pos, index in [("start", 0), ("end", 200)]]
return {
"08-scenes": artifact("08-scenes", dict(scenes=[], connections=[], preview=None)),
"10-references": artifact("10-references", dict(request=dict(engine="Blender",
style_reference="references/1.png", render_profile=deepcopy(PROFILE), items=[
dict(id="water", kind="object", entity_id=None, prompt="물방울")]), result=None)),
"11-images": artifact("11-images", dict(request=dict(engine="Blender",
scene_path=BLEND["path"], render_profile=deepcopy(PROFILE), frames=frames), result=None)),
"12-motion": artifact("12-motion", dict(request=dict(engine="Blender",
scene_path=BLEND["path"], render_profile=deepcopy(PROFILE), clips=[dict(cut_id="c1",
start_frame_id="c1-start", end_frame_id="c1-end", duration_ms=4000,
motion_prompt="둥글게", scene_name="Water", start_frame=0, end_frame=200)]), result=None)),
}
def semantic_errors(data, context, complete=False):
"""1.1 구조 검사 후 사용. context={stage_id: artifact}; 파일·승인은 별도 확인."""
if data["schema_version"] != "1.1":
return []
errors = []
stage = data["stage_id"]
payload = data["payload"]
def require(condition, message):
if not condition:
errors.append(message)
def indexed(items, key):
ids = [item[key] for item in items]
require(all(ids) and len(ids) == len(set(ids)), "빈 식별자 또는 중복: " + key)
return {item[key]: item for item in items}
def walk(value):
if isinstance(value, dict):
for key, child in value.items():
if key in ("path", "scene_path") and isinstance(child, str) and child:
parts = child.replace("\\", "/").split("/")
require(not child.startswith(("/", "\\")) and ":" not in child
and ".." not in parts, "루트 상대 경로 필요: " + child)
if "kind" in value:
require(not child.lower().endswith(".blend"), ".blend는 media가 아니다")
walk(child)
elif isinstance(value, list):
for child in value:
walk(child)
walk(payload)
request = payload.get("request", {})
preview = payload.get("preview")
profile = request.get("render_profile") or (preview or {}).get("render_profile")
if profile:
require(profile["width"] * 16 == profile["height"] * 9, "RenderProfile은 9:16 필요")
cuts = context.get("07-cuts", {}).get("payload", {}).get("cuts", [])
cut_ids = {cut["id"] for cut in cuts}
if stage == "08-scenes" and complete:
require(preview is not None, "대표 품질 검토 자료 필요")
if stage == "08-scenes" and preview:
indexed(preview["samples"], "id")
require(bool(preview["samples"]), "대표 샘플 필요")
for sample in preview["samples"]:
require(set(sample["cut_ids"]) <= cut_ids, "대표 샘플의 알 수 없는 컷")
require(sample["media"]["verified"], "대표 미디어 미검증")
width, height = sample["media"]["width"], sample["media"]["height"]
require(isinstance(width, int) and isinstance(height, int)
and width * 16 == height * 9, "대표 실제 미디어는 9:16 필요")
require(preview["scene_file"]["verified"], "대표 .blend 미검증")
if stage == "10-references":
indexed(request["items"], "id")
entities = context.get("09-entities", {}).get("payload", {})
for item in request["items"]:
if item["kind"] in ("character", "background"):
bucket = "characters" if item["kind"] == "character" else "backgrounds"
require(item["entity_id"] in {x["id"] for x in entities.get(bucket, [])},
"알 수 없는 entity_id")
if stage == "11-images":
frames = indexed(request["frames"], "id")
refs = context.get("10-references", {}).get("payload", {}).get("request", {}).get("items", [])
reference_ids = {item["id"] for item in refs}
pairs = {}
for frame in frames.values():
require(frame["cut_id"] in cut_ids, "프레임의 알 수 없는 컷")
require(set(frame["reference_ids"]) <= reference_ids, "알 수 없는 reference_id")
pairs.setdefault(frame["cut_id"], []).append(frame)
require(set(pairs) == cut_ids, "컷별 start/end 누락")
for pair in pairs.values():
require(len(pair) == 2 and {x["position"] for x in pair} == {"start", "end"}, "start/end 중복 또는 누락")
require(len({x["scene_name"] for x in pair}) == 1, "같은 컷은 같은 Scene 필요")
if stage == "12-motion":
clips = indexed(request["clips"], "cut_id")
require(set(clips) == cut_ids, "클립 컷 누락 또는 추가")
previous = context.get("11-images", {}).get("payload", {})
result = previous.get("result")
require(bool(result) and result["scene_file"]["verified"], "11 실제 .blend 필요")
if result:
require(request["scene_path"] == result["scene_file"]["path"], "11 원본 경로 불일치")
require(request["render_profile"] == previous.get("request", {}).get("render_profile"), "11 RenderProfile 불일치")
frames = {x["id"]: x for x in previous.get("request", {}).get("frames", [])}
for clip in clips.values():
require(clip["end_frame"] > clip["start_frame"], "프레임 범위는 양수 필요")
for position in ("start", "end"):
frame = frames.get(clip[position + "_frame_id"])
require(bool(frame), "알 수 없는 frame_id")
if frame:
require(frame["cut_id"] == clip["cut_id"] and frame["position"] == position
and frame["scene_name"] == clip["scene_name"]
and frame["frame_index"] == clip[position + "_frame"], "프레임 상태 참조 불일치")
cut = next((x for x in cuts if x["id"] == clip["cut_id"]), None)
if cut:
require(clip["duration_ms"] == cut["end_ms"] - cut["start_ms"], "컷 실측 길이 불일치")
require(clip["duration_ms"] is not None and (clip["end_frame"] - clip["start_frame"]) * 1000
== clip["duration_ms"] * request["render_profile"]["fps"], "프레임 수와 컷 길이 불일치")
if complete and stage in ("10-references", "11-images", "12-motion"):
result = payload["result"]
require(result is not None, "result=null은 완료가 아니다")
if result:
key, requested_key, returned_key = {
"10-references": ("items", "id", "reference_id"),
"11-images": ("frames", "id", "frame_id"),
"12-motion": ("clips", "cut_id", "cut_id"),
}[stage]
returned = indexed(result[key], returned_key)
require(bool(request[key]) and set(returned) == {x[requested_key] for x in request[key]}, "요청/결과 누락 또는 추가")
require(all(x["media"]["verified"] for x in result[key]), "결과 미디어 미검증")
if stage != "12-motion":
require(result["scene_file"]["verified"], "결과 .blend 미검증")
return errors
class ContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.validators = {}
for name in ("project", "artifact"):
schema = read("contracts/" + name + ".schema.json")
Draft202012Validator.check_schema(schema)
cls.validators[name] = Draft202012Validator(schema)
def valid(self, data):
self.validators["artifact" if "stage_id" in data else "project"].validate(data)
def invalid(self, data):
self.assertFalse(self.validators["artifact" if "stage_id" in data else "project"].is_valid(data))
def test_legacy_templates_examples_and_production(self):
count = 0
for folder in ("templates", "examples", "projects"):
for path in (ROOT / folder).rglob("*.json"):
if "node_modules" in path.parts or ".cache" in path.parts:
continue
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict) and "schema_version" in data and "project_id" in data:
self.valid(data)
count += 1
self.assertGreaterEqual(count, 30)
def test_role_skill_and_document_links(self):
agents = list((ROOT / ".codex/agents").glob("*.toml"))
self.assertEqual(len(agents), 8)
for path in agents:
role = tomllib.loads(path.read_text(encoding="utf-8"))
self.assertEqual(set(role), {"name", "description", "developer_instructions"})
self.assertEqual(role["name"], path.stem)
skills = list((ROOT / ".agents/skills").glob("*/SKILL.md"))
self.assertEqual(len(skills), 15)
documents = [ROOT / "AGENTS.md", ROOT / "CLAUDE.md",
*list((ROOT / "docs").glob("*.md")),
ROOT / "examples/blender-contract/README.md", *skills]
for path in documents:
text = path.read_text(encoding="utf-8")
for target in re.findall(r"\]\(([^)]+)\)", text):
if "://" not in target and not target.startswith("#"):
self.assertTrue((path.parent / target.split("#")[0]).resolve().exists(), (path, target))
def test_blender_contracts(self):
project = read("templates/project.json")
project["schema_version"] = "1.1"
project["settings"].pop("image_model", None)
project["settings"]["visual_engine"] = "Blender"
self.valid(project)
for data in samples().values():
self.valid(data)
def test_wrong_version_engine_and_extra_fields(self):
for data in samples().values():
for version in ("1.0", "2.0"):
bad = deepcopy(data)
bad["schema_version"] = version
self.invalid(bad)
if "request" in data["payload"]:
bad = deepcopy(data)
bad["payload"]["request"]["engine"] = "gpt-image-2.5-sunburst"
self.invalid(bad)
bad["payload"]["request"]["engine"] = "Blender"
bad["payload"]["request"]["model"] = "Blender"
self.invalid(bad)
def test_verified_blend_requires_hash_and_version(self):
data = samples()["08-scenes"]
data["payload"]["preview"] = dict(scene_file=deepcopy(BLEND),
render_profile=deepcopy(PROFILE), samples=[dict(id="p1", cut_ids=["c1"], media=deepcopy(IMAGE))])
self.valid(data)
blend = data["payload"]["preview"]["scene_file"]
blend["verified"] = True
self.invalid(data)
blend.update(sha256="a" * 64, blender_version="5.2.2 LTS")
self.valid(data)
for key, value in [("sha256", "A" * 64), ("blender_version", ""), ("path", "")]:
bad = deepcopy(data)
bad["payload"]["preview"]["scene_file"][key] = value
self.invalid(bad)
bad = deepcopy(data)
bad["payload"]["preview"]["samples"][0]["media"]["kind"] = "blend"
self.invalid(bad)
def test_profile_and_duration_constraints(self):
for stage in ("10-references", "11-images", "12-motion"):
profile = read("templates/stages/" + stage + ".json")["payload"]["request"]["render_profile"]
self.assertEqual((profile["samples"], profile["seed"]), (64, 0))
for key, value in [("engine", "UNKNOWN"), ("device", "CPU"), ("fps", 0),
("samples", 0), ("width", 0), ("height", 0), ("seed", -1)]:
bad = samples()["11-images"]
bad["payload"]["request"]["render_profile"][key] = value
self.invalid(bad)
for duration in (0, -1, 5001):
bad = samples()["12-motion"]
bad["payload"]["request"]["clips"][0]["duration_ms"] = duration
self.invalid(bad)
cycles = samples()["11-images"]
cycles["payload"]["request"]["render_profile"].update(engine="CYCLES", device="CPU")
self.valid(cycles)
def test_result_shapes_and_missing_fields(self):
fixtures = samples()
fixtures["10-references"]["payload"]["result"] = dict(scene_file=deepcopy(BLEND),
items=[dict(reference_id="water", object_name="Droplet", media=deepcopy(IMAGE))])
fixtures["11-images"]["payload"]["result"] = dict(scene_file=deepcopy(BLEND),
frames=[dict(frame_id="c1-start", media=deepcopy(IMAGE))])
fixtures["12-motion"]["payload"]["result"] = dict(clips=[dict(cut_id="c1",
media=dict(IMAGE, kind="video", path="examples/blender-contract/c1.mp4"))])
for data in fixtures.values():
self.valid(data)
if "request" in data["payload"]:
for key in data["payload"]["request"]:
bad = deepcopy(data)
del bad["payload"]["request"][key]
self.invalid(bad)
def test_semantic_references_and_completion(self):
data = samples()["11-images"]
context = {"07-cuts": {"payload": {"cuts": [dict(id="c1", start_ms=0, end_ms=4000)]}},
"10-references": samples()["10-references"]}
self.assertEqual(semantic_errors(data, context), [])
self.assertIn("result=null은 완료가 아니다", semantic_errors(data, context, complete=True))
for change in ("duplicate", "reference", "scene", "aspect", "absolute"):
bad = deepcopy(data)
request = bad["payload"]["request"]
if change == "duplicate":
request["frames"][1]["id"] = request["frames"][0]["id"]
elif change == "reference":
request["frames"][0]["reference_ids"] = ["missing"]
elif change == "scene":
request["frames"][1]["scene_name"] = "Other"
elif change == "aspect":
request["render_profile"]["width"] = 1920
else:
request["scene_path"] = "C:/foreign.blend"
self.assertTrue(semantic_errors(bad, context), change)
def test_blend_media_impersonation(self):
data = samples()["08-scenes"]
data["payload"]["preview"] = dict(scene_file=dict(BLEND, verified=True, sha256="a" * 64,
blender_version="5.2.2"), render_profile=deepcopy(PROFILE), samples=[dict(id="p1", cut_ids=["c1"],
media=dict(IMAGE, path="examples/blender-contract/wrong.blend", verified=True, width=1080, height=1920))])
self.valid(data)
self.assertIn(".blend는 media가 아니다", semantic_errors(data,
{"07-cuts": {"payload": {"cuts": [{"id": "c1"}]}}}))
def test_preview_video_duration_and_actual_aspect(self):
data = samples()["08-scenes"]
data["payload"]["preview"] = dict(scene_file=dict(BLEND, verified=True,
sha256="a" * 64, blender_version="5.2.2"), render_profile=deepcopy(PROFILE),
samples=[dict(id="p1", cut_ids=["c1"], media=dict(IMAGE, kind="video",
path="examples/blender-contract/preview.mp4", verified=True,
duration_ms=5000, width=1080, height=1920))])
context = {"07-cuts": {"payload": {"cuts": [{"id": "c1"}]}}}
for duration in (1, 5000):
with self.subTest(valid_duration=duration):
data["payload"]["preview"]["samples"][0]["media"]["duration_ms"] = duration
self.valid(data)
self.assertEqual(semantic_errors(data, context, complete=True), [])
for duration in (0, 5001, 6000):
with self.subTest(invalid_duration=duration):
bad = deepcopy(data)
bad["payload"]["preview"]["samples"][0]["media"]["duration_ms"] = duration
self.invalid(bad)
for width, height in ((1920, 1080), (1080, 1800)):
with self.subTest(invalid_aspect=(width, height)):
bad = deepcopy(data)
bad["payload"]["preview"]["samples"][0]["media"].update(width=width, height=height)
self.valid(bad)
self.assertTrue(semantic_errors(bad, context, complete=True))
for field in ("duration_ms", "width", "height"):
with self.subTest(verified_missing_metadata=field):
bad = deepcopy(data)
bad["payload"]["preview"]["samples"][0]["media"][field] = None
self.invalid(bad)
bad = deepcopy(data)
bad["payload"]["preview"]["samples"][0]["media"].update(verified=False,
duration_ms=None, width=None, height=None)
self.valid(bad)
self.assertTrue(semantic_errors(bad, context, complete=True))
bad = samples()["08-scenes"]
self.valid(bad)
self.assertTrue(semantic_errors(bad, context, complete=True))
if __name__ == "__main__":
unittest.main(verbosity=2)
+112 -36
View File
@@ -2,7 +2,10 @@
"type": "object",
"properties": {
"schema_version": {
"const": "1.0"
"enum": [
"1.0",
"1.1"
]
},
"project_id": {
"type": "string",
@@ -25,40 +28,7 @@
"additionalProperties": false
},
"settings": {
"type": "object",
"properties": {
"aspect_ratio": {
"const": "9:16"
},
"max_duration_ms": {
"const": 180000
},
"max_cut_duration_ms": {
"const": 5000
},
"style_reference": {
"const": "references/1.png"
},
"tts_engine": {
"const": "Qwen3-TTS"
},
"conda_env": {
"const": "qwen3-tts"
},
"image_model": {
"const": "gpt-image-2.5-sunburst"
}
},
"required": [
"aspect_ratio",
"max_duration_ms",
"max_cut_duration_ms",
"style_reference",
"tts_engine",
"conda_env",
"image_model"
],
"additionalProperties": false
"type": "object"
},
"stages": {
"type": "array",
@@ -496,6 +466,112 @@
"blocking"
],
"additionalProperties": false
},
"settings-1.0": {
"type": "object",
"properties": {
"aspect_ratio": {
"const": "9:16"
},
"max_duration_ms": {
"const": 180000
},
"max_cut_duration_ms": {
"const": 5000
},
"style_reference": {
"const": "references/1.png"
},
"tts_engine": {
"const": "Qwen3-TTS"
},
"conda_env": {
"const": "qwen3-tts"
},
"image_model": {
"const": "gpt-image-2.5-sunburst"
}
},
"required": [
"aspect_ratio",
"max_duration_ms",
"max_cut_duration_ms",
"style_reference",
"tts_engine",
"conda_env",
"image_model"
],
"additionalProperties": false
},
"settings-1.1": {
"type": "object",
"properties": {
"aspect_ratio": {
"const": "9:16"
},
"max_duration_ms": {
"const": 180000
},
"max_cut_duration_ms": {
"const": 5000
},
"style_reference": {
"const": "references/1.png"
},
"tts_engine": {
"const": "Qwen3-TTS"
},
"conda_env": {
"const": "qwen3-tts"
},
"visual_engine": {
"const": "Blender"
}
},
"required": [
"aspect_ratio",
"max_duration_ms",
"max_cut_duration_ms",
"style_reference",
"tts_engine",
"conda_env",
"visual_engine"
],
"additionalProperties": false
}
}
},
"allOf": [
{
"if": {
"properties": {
"schema_version": {
"const": "1.0"
}
}
},
"then": {
"properties": {
"settings": {
"$ref": "#/$defs/settings-1.0"
}
}
}
},
{
"if": {
"properties": {
"schema_version": {
"const": "1.1"
}
}
},
"then": {
"properties": {
"settings": {
"$ref": "#/$defs/settings-1.1"
}
}
}
}
]
}
+5 -3
View File
@@ -1,13 +1,15 @@
# 구성과 데이터 흐름
ShortVideoGenerator는 개인용 Codex 작업 지침으로 구성된다. 서비스·데이터베이스·실행 프로그램이 아니라 역할 TOML, 작업별 SKILL.md, JSON 양식·스키마를 함께 사용하는 구조다.
ShortVideoGenerator는 개인용 Codex 작업 지침으로 구성된다. 역할 TOML, 작업별 SKILL.md, JSON 양식·스키마를 함께 사용하고 승인된 로컬 실행 경로에 미디어 제작을 맡긴다. 서비스·데이터베이스는 두지 않는다.
`.codex/agents/`의 8개 역할은 `.agents/skills/`의 담당 작업을 사용한다. 스킬은 `templates/`의 빈 양식과 `contracts/`의 전달 구조를 읽고, 제작별 `projects/<project_id>/`에 결과 버전과 사람용 검토를 남긴다. 총괄만 공유 진행 기록을 관리한다. 참고 자료는 이야기 구성·그림체의 입력이고 다른 제작의 상태·승인을 가져오는 저장소가 아니다.
주제와 언어 요청은 총괄→주제·조사→작가→내레이션→장면→비주얼→영상·편집→검수 순으로 전달된다. 주제, 대본·제목, 장면, 완성본의 사용자 검토가 다음 구간을 연다. 검토를 기다리는 동안 경계를 넘는 새 후속 작업을 실행하지 않는다.
음성 요청은 기존 conda qwen3-tts 환경의 Qwen3-TTS로, 기준·컷 이미지 요청은 gpt-image-2.5-sunburst로 전달하도록 지정했다. 실제 요청 실행 프로그램은 아직 없다. 모델·화자·지원 언어·결과 파일은 실제 제작 환경에서 확인해야 하며 설치 여부가 추론 성공을 뜻하지 않는다.
음성 요청은 기존 conda qwen3-tts 환경의 Qwen3-TTS로, 새 모델·기준·컷 이미지·영상 요청은 Blender로 전달한다. 기존 1.0 이미지 모델 계약과 실제 결과는 보존하고 새 1.1과 함께 읽는다. 모델·화자·지원 언어·결과 파일은 실제 제작 환경에서 확인해야 하며 설치 여부가 추론 성공을 뜻하지 않는다.
영상·합성 경계에는 시작/끝 이미지·움직임·컷 길이 또는 클립·음성·자막의 요청과 실제 결과 파일 정보가 오간다. 엔진·API 파라미터·인증정보는 전달 계약에 고정하지 않는다. 외부 결과가 없으면 진행이 멈추며 예상이나 모의 데이터를 실제 결과로 승격하지 않는다.
영상·합성 경계에는 시작/끝 이미지·움직임·컷 길이 또는 클립·음성·자막의 요청과 실제 결과 파일 정보가 오간다. 1.1은 Blender의 .blend 원본·RenderProfile·Scene 이름·논리 프레임을 명시하고 PNG·MP4 결과를 별도로 기록한다. 합성은 승인된 기존 Remotion + FFmpeg 경로를 재사용한다. 실행파일의 외부 경로·인증정보는 전달 계약에 기록하지 않는다. 외부 결과가 없으면 진행이 멈추며 예상이나 모의 데이터를 실제 결과로 승격하지 않는다.
대표적인 재개 요청은 기존 입력·포인터·승인·파일을 읽고 가장 앞선 미해결 단계에 배정된다. 결과가 갱신되면 실제 inputs 관계로 종속 결과를 다시 확인한다. 제목만 수정한 경우 새 제목은 합성과 검수에 전달되며 기존 음성·이미지는 유지된다.
08의 제한된 대표 화면·짧은 움직임 제작만 scenes 승인 전 품질 검토를 위해 허용한다. 이 자료는 정식 10–12 완료가 아니다. 새 08·09의 scenes 승인 후 전체 모델·끝점·클립을 제작한다. 주제별 모델·장면은 해당 제작의 근거로 작성하며 다른 주제의 3D 자산을 자동 생성한다고 보장하지 않는다.
+1 -1
View File
@@ -12,7 +12,7 @@
장면은 각 컷의 시작·끝·카메라를 정하고 인접 컷의 이어짐 또는 전환 이유를 기록한다. 이어지는 컷은 인물·배경·행동·카메라 상태가 맞아야 한다. 반복 인물·배경의 고정 특징은 유지하고 이야기상 변화는 명시한다. 모든 영상의 고정 인물로 승격하지 않는다.
이미지는 사용자 기준의 그림체를 따르며 캠핑 소재·상표를 의무로 복제하지 않는다. 각 컷에 start/end가 하나씩 필요하다. 같은 실제 이미지를 재사용해도 두 위치의 참조 기록은 남긴다. 영상은 그 사이의 설명에 필요한 움직임을 표현한다.
새 시각 제작은 Blender 교육용 3D이며 사용자 원본의 단순 형태·정돈된 색을 참고한다. 단순 배경·부드러운 조명·선명한 형태를 사용하고 평면 스타일·캠핑 소재·상표를 의무로 복제하지 않는다. 각 컷에 start/end가 하나씩 필요하다. 1.1의 continuous 경계에서는 동일 Scene의 앞 end와 뒤 start가 동일한 실제 PNG 파일을 반드시 공유하며 두 위치의 별도 frame ID를 남긴다. 기존 1.0의 선택적 공유 규칙은 그대로 보존한다. cut 전환은 Scene 이름을 구분하며 같은 프레임 번호를 같은 상태로 취급하지 않는다. 영상은 그 사이의 설명에 필요한 움직임을 표현한다.
결과 파일은 실제 존재·형식·메타데이터를 확인해야 한다. 요청·예상·설명용 예제는 검증 결과가 아니다. 파일 부재·부분 결과는 기다리는 상태이고 전체 완료로 처리할 수 없다. 설치 확인만으로 모델 로딩·추론을 확인했다고 쓰지 않는다.
+17 -6
View File
@@ -4,7 +4,7 @@
## 공통 출력
진행 기록은 schema_version="1.0", project_id, brief={topic,language}, settings, stages, reviews다. stages의 항목은 {stage_id,status,revision,artifact_path,issues}, issues는 {code,message,blocking}다. reviews의 항목은 {review_id,decision,artifact_refs,feedback}이며 artifact_refs는 {stage_id,revision} 배열이다.
진행 기록은 schema_version="1.1"(새 제작) 또는 "1.0"(기존 결과), project_id, brief={topic,language}, settings, stages, reviews다. stages의 항목은 {stage_id,status,revision,artifact_path,issues}, issues는 {code,message,blocking}다. reviews의 항목은 {review_id,decision,artifact_refs,feedback}이며 artifact_refs는 {stage_id,revision} 배열이다.
단계 출력은 {schema_version,project_id,stage_id,revision,inputs,payload}다. revision은 1부터 시작하고 inputs는 실제 사용한 선행 버전 배열이다. project_id는 유일한 소문자 영숫자·하이픈이고, 경로는 저장소 루트 기준 상대 경로다. 이전 결과는 보존하며 review.md는 같은 JSON을 사람이 읽는 요약이다. 공통·단계별 필드 타입은 [진행 스키마](../contracts/project.schema.json)와 [단계 스키마](../contracts/artifact.schema.json), 시작 형태는 [양식](../templates/)에 있다.
@@ -15,16 +15,27 @@
| 단계 | 요청 | 결과 |
|---|---|---|
| 06 | engine, conda_env, language, units | engine, model, voice, files, narration, unit_ranges |
| 10 | model, style_reference, items | items |
| 11 | model, frames | frames |
| 12 | clips | clips |
| 10 (1.1) | engine, style_reference, render_profile, items | scene_file, items |
| 11 (1.1) | engine, scene_path, render_profile, frames | scene_file, frames |
| 12 (1.1) | engine, scene_path, render_profile, clips | clips |
| 13 | timeline, narration_path, captions, title | video, captions_path |
06 units는 {id,block_ids,display_text,spoken_text,pronunciation_notes}, files는 {unit_id,media}, narration은 media, unit_ranges는 {unit_id,start_ms,end_ms}다. 요청 engine은 Qwen3-TTS, conda_env는 qwen3-tts이며 model·voice는 실제 사용값을 결과에 기록한다.
10 요청 items는 {id,kind,entity_id,prompt}, 결과 items는 {reference_id,media}다. kind는 style/character/background이며 스타일만 entity_id=null이다. model은 gpt-image-2.5-sunburst, style_reference는 references/1.png다. 11 요청 frames는 {id,cut_id,position,prompt,reference_ids}, 결과 frames는 {frame_id,media}다. position은 start/end이고 컷마다 둘 다 하나씩 필요하다.
1.0의 기존 이미지 모델 요청·결과 정의는 그대로 읽고 검증한다. 기존 제작·r1 미디어를 변환하거나 덮어쓰지 않는다. 새 1.1 프로젝트의 settings는 기존 aspect_ratio/max_duration_ms/max_cut_duration_ms/style_reference/tts_engine/conda_env 상수를 유지하고 image_model 대신 visual_engine="Blender"를 갖는다. 1.1 프로젝트의 inputs·포인터는 유효한 기존 1.0 결과를 참조할 수 있다.
12 요청 clips는 {cut_id,start_frame_id,end_frame_id,duration_ms,motion_prompt}, 결과 clips는 {cut_id,media}다. 13 timeline은 {cut_id,video_path,start_ms,end_ms}, captions는 {id,start_ms,end_ms,text} 배열이다. video는 media이고 captions_path는 실제 SRT 경로다. 특정 영상·합성 엔진이나 API 파라미터는 계약에 고정하지 않는다.
1.1의 08/10/11/12 외 payload는 1.0과 동일하다. 새 객체의 모든 명시된 키는 필수이고 추가 키를 허용하지 않는다.
- BlendFile={path,verified,sha256,blender_version}. path는 루트 상대 문자열, verified는 불리언, sha256은 null 또는 소문자 64자리 16진수, blender_version은 null 또는 비어 있지 않은 문자열이다. verified=true이면 비어 있지 않은 경로·해시·버전이 모두 필요하다. 실제 파일 해시·재열기·버전을 확인하고 기록한다. .blend를 PNG/MP4 media로 기록하지 않는다.
- RenderProfile={engine,device,fps,width,height,samples,seed}. engine은 BLENDER_EEVEE/CYCLES, device는 CPU/GPU, EEVEE는 GPU다. fps/width/height/samples는 양의 정수, seed는 0 이상 정수다. 의미 검사에서 9:16을 확인한다. 값은 렌더 요청이며 실제 지원·성공을 뜻하지 않는다.
- 08 payload={scenes,connections,preview}. scenes/connections는 기존 구조다. preview는 null 또는 {scene_file:BlendFile,render_profile:RenderProfile,samples}이고 sample={id,cut_ids,media}다. id는 비어 있지 않고 유일하며 cut_ids는 비어 있지 않은 유일한 기존 컷 ID 배열, media는 실제 9:16 PNG 또는 MP4다. 대표 영상의 확인된 duration_ms는 0 초과 5000 이하이며 실제 크기·길이가 미확인/null이거나 verified=false인 미디어는 완료 근거가 아니다. 대표 샘플은 scenes 검토 근거로만 사용한다.
- 10 request={engine:"Blender",style_reference:"references/1.png",render_profile,items}. item={id,kind,entity_id,prompt}이며 kind는 style/character/background/object, style/object는 entity_id=null, character/background는 실제 09의 해당 ID를 참조한다. result는 null 또는 {scene_file:BlendFile,items}; 결과 item={reference_id,object_name,media:image}. reference_id는 요청 ID, object_name은 비어 있지 않은 실제 .blend 오브젝트/컬렉션 이름이다. 기준 PNG와 실제 자산을 함께 대조한다.
- 11 request={engine:"Blender",scene_path,render_profile,frames}. scene_path는 생성할 원본의 루트 상대 경로다. frame={id,cut_id,position,prompt,reference_ids,scene_name,frame_index}; position은 start/end, scene_name은 비어 있지 않은 실제 Scene 이름, frame_index는 0 이상 논리 프레임이다. 같은 컷의 start/end는 같은 Scene을 사용한다. result는 null 또는 {scene_file:BlendFile,frames}; 결과 frame={frame_id,media:image}다.
- 12 request={engine:"Blender",scene_path,render_profile,clips}. scene_path는 11의 검증된 실제 .blend 경로이고 render_profile은 11과 동일하다. clip={cut_id,start_frame_id,end_frame_id,duration_ms,motion_prompt,scene_name,start_frame,end_frame}. scene_name과 끝점 참조는 실제 11의 같은 Scene과 일치하고 start_frame/end_frame은 0 이상이며 end_frame>start_frame이다. duration_ms는 미작성 시 null, 작성 시 0 초과 5000 이하 정수이며 실측 07 구간과 같아야 한다. result는 기존 null 또는 {clips:[{cut_id,media:video}]}다.
13/14의 구조는 바꾸지 않는다. 13 timeline={cut_id,video_path,start_ms,end_ms}, captions=[{id,start_ms,end_ms,text}], video는 media, captions_path는 실제 SRT 경로다. 합성은 기존 Remotion + FFmpeg 경로를 사용하며 실행파일의 외부 절대 경로·인증정보는 JSON에 담지 않는다.
1.1의 continuous는 동일 Scene의 앞 end와 뒤 start가 동일한 실제 PNG 파일을 반드시 공유하며 위치별 frame ID는 각각 유지한다. 기존 1.0의 선택적 공유 규칙은 소급 변경하지 않는다. cut은 서로 다른 Scene의 같은 frame_index를 구별하며 캐시 식별에는 Scene 이름이 필요하다. 프레임 구간은 [start_frame,end_frame)이다. 이번 물 영상은 50fps·5528개 프레임으로 시간 기준을 유지하고 마지막 끝점 PNG는 논리 프레임 5528을 평가한다. Blender의 실제 번호는 논리 프레임+1이다. 타 주제의 fps·길이를 이 값으로 고정하지 않는다.
media는 {path,kind,verified,duration_ms,width,height}다. kind는 audio/image/video, 미확인 값은 null, verified는 불리언이다. 검증된 음성은 WAV·길이·width/height=null, 이미지는 PNG·크기·duration_ms=null, 영상은 MP4·길이·크기가 필요하다. 시간은 ms 정수이며 완료한 길이는 양수다. 결과의 ID는 요청에 존재해야 한다.
+26
View File
@@ -19,3 +19,29 @@ JSON의 경로는 저장소 루트 기준인데 Markdown 링크는 그 파일의
## 새로운 역할이나 스킬이 목록에 보이지 않을 때
TOML·YAML 파싱 성공은 현재 세션이 파일을 탐색했다는 증거가 아니다. 프로젝트 루트에서 새 Codex 세션으로 목록을 확인한다. 임시로 지침 파일을 직접 읽어 담당 작업을 수행할 수 있지만 이를 자동 탐색 검증으로 기록하지 않는다.
## 여러 Blender Scene 중 선택한 장면이 정지 화면으로 렌더될 때
원본의 애니메이션 평가 검사와 영상의 프레임 수가 맞아도, 렌더의 활성 컨텍스트가 다른 Scene이면 선택한 장면이 정지할 수 있다. 프레임을 설정하기 전에 `bpy.context.window.scene`을 렌더할 Scene으로 맞추고 실제 결과를 다시 확인한다. 초기 활성 장면에 움직이는 물체가 없는 검사 원본에서도 두 선택 장면을 각각 검증한다.
PNG 파일 SHA-256은 메타데이터 때문에 달라질 수 있고 H.264 압축 흔적도 픽셀 차이를 만들 수 있다. 기대한 움직임은 원본 렌더 PNG의 디코드 RGB와 실제 MP4의 시작·중간·끝 화면을 함께 비교한다. 파일 해시는 원본·산출물의 식별 근거이며 움직임이나 품질의 증거를 대신하지 않는다. 실행기 수정 뒤에는 새 스크립트 해시를 포함한 별도 attempt로 다시 렌더하고 옛 정지 결과를 채택하지 않는다.
## 분자가 액체의 표시 영역 밖에 놓일 때
분자를 잘 보이게 하려고 불투명한 물 체적 앞에 배치하면, 분자가 실제 물 영역 밖에 떠 있거나 아래·옆 경계를 넘을 수 있다. 절개 설명이라는 문구와 원자 수·결합 종류·영상 디코드 검사는 이 오류를 해결하지 않는다. 분자는 실제 물 영역 안에 두고 앞면·윗면을 열어 내부가 보이는 설명용 절개를 만든다. 절개면을 물의 고체 벽·막으로 설명하지 않는다.
분자 중심만 검사하지 않고 평가된 원자·내부 실선·활성 분자간 점선의 전체 mesh 정점을 모든 애니메이션 프레임과 경계에서 실제 물 영역과 대조한다. 카메라의 투영 윤곽 및 실제 표시 면과도 비교하고 최종 PNG·MP4를 직접 본다. 중심은 내부지만 원자 정점 일부가 밖인 실제 실패 원본을 검사에 포함한다. 제출한 이전 결과는 보존하고 수정은 새 revision에서 진행한다.
## 일부 끝점 렌더 뒤 다음 프레임을 요청하면 실패할 때
11의 결과 배열에 첫 PNG만 등록돼 있어도 배열 자체는 비어 있지 않다. continuous의 양쪽 결과가 모두 있다고 가정하면 아직 없는 끝점 ID를 조회해 다음 렌더를 막는다. 두 frame ID가 실제 등록된 경우에만 동일 파일 여부를 검사하고, 완성 검사는 별도로 전체 62개 ID를 요구한다. 첫 시작 PNG만 존재하는 상태에서 끝점 요청, 공유한 다음 시작, 같은 논리 프레임의 다른 Scene 요청을 차례로 실행해 부분 진행과 장면 분리를 함께 확인한다.
## 로컬 FFmpeg에서 프레임 추출 명령이 실패할 때
Remotion에 포함된 현재 FFmpeg는 select 필터와 rawvideo 출력 muxer를 제공하지 않는다. 일반 FFmpeg 예제를 그대로 쓰면 영상 결함과 관계없이 검사 명령이 실패한다. 단일 프레임 PNG는 입력 뒤에 `-ss`와 `-frames:v 1`을 두고, 전체 디코드는 `-c:v rawvideo -c:a pcm_s16le -f null -`로 확인한다. rawvideo 코덱과 출력 muxer를 구분하고 실제 명령 종료코드와 프레임 수를 확인한다.
## 합성 후 검토 상태만 바꿨는데 입력 해시가 달라질 때
project.json은 렌더 입력과 현재 진행 상태를 함께 담는다. 최종 검토로 넘어가며 13/14 상태나 final 기록만 바꿔도 전체 파일 해시는 달라진다. 렌더 당시 사본은 입력 identity의 SHA와 연결해 보존하고 현재 주제·설정·01–12 포인터/상태·topic/story/scenes 승인은 그 사본과 엄격히 대조한다. 같은 13/14 버전의 정상적인 상태 진행만 원 입력 변경과 구별한다. 원본 미디어·선행 결과·합성 코드의 해시 차이는 이 예외로 허용하지 않는다.
제출한 verify_final.py를 다시 실행하면 새 디코드 WAV와 technical-check.json을 작성하므로 기존 manifest와 해시가 달라질 수 있다. 검사 후 manifest를 확정하고, 이미 제출한 결과의 재검토는 별도 증거 폴더에서 읽기 전용으로 수행한다. 재실행 결과를 제출하려면 새 검사 근거와 manifest를 함께 갱신한다.
+32 -4
View File
@@ -12,7 +12,7 @@ $short-video-workflow
주제·각도 검토를 먼저 마치고 대본·제목, 실측 장면, 실제 완성본 검토 순으로 진행한다. 재개는 같은 스킬에 기존 제작 ID 또는 기록 위치를 전달한다. 전체 스킬 대신 개별 스킬을 불러도 필요한 선행 결과·승인은 유지한다.
요청서를 실행하는 프로그램은 현재 제공하지 않는다. 실제 음성은 기존 qwen3-tts conda 환경에서 생성·확인한 WAV와 통합 단위 구간을 제출하고, 이미지는 지정 모델의 PNG와 요청 ID를 연결해 제출한다. 영상·합성 결과도 MP4·SRT와 실측 정보를 제출한다. 파일 부재·지원하지 않는 언어·시간 불일치는 문제를 보고하고 결과를 기다린다.
새 시각 제작은 승인된 저장소 내 Blender 실행 경로를 사용한다. 실행 구성이 추가되어도 설치·요청 준비와 실제 출력 성공은 구분한다. 실제 음성은 기존 qwen3-tts conda 환경에서 생성·확인한 WAV와 통합 단위 구간을 제출하고, 새 모델·이미지는 Blender .blend 원본(해시·버전)과 실제 PNG·요청 ID·Scene·논리 프레임을 연결해 제출한다. 기존 1.0 결과는 그대로 읽는다. 영상·합성 결과도 MP4·SRT와 실측 정보를 제출한다. 파일 부재·지원하지 않는 언어·시간 불일치는 문제를 보고하고 결과를 기다린다.
## 파일 형식 검사
@@ -31,10 +31,12 @@ for name in ['project','artifact']:
schema=json.loads(Path('contracts/'+name+'.schema.json').read_text(encoding='utf-8'))
Draft202012Validator.check_schema(schema)
validators[name]=Draft202012Validator(schema,format_checker=FormatChecker())
for folder in ['templates','examples']:
for folder in ['templates','examples','projects']:
for path in Path(folder).rglob('*.json'):
if 'node_modules' in path.parts or '.cache' in path.parts:
continue
data=json.loads(path.read_text(encoding='utf-8'))
if path.name=='verification-cases.json':
if not isinstance(data,dict) or 'schema_version' not in data or 'project_id' not in data:
continue
validators['artifact' if 'stage_id' in data else 'project'].validate(data)
agents=list(Path('.codex/agents').glob('*.toml'))
@@ -67,6 +69,32 @@ Get-ChildItem -LiteralPath '.agents/skills' -Directory | ForEach-Object {
## 실제 제작 확인
06의 통합 WAV를 먼저 확인한 뒤 07 시간을 정한다. 글자 수 추정으로 컷·자막을 실측처럼 확정하지 않는다. 08·09 사용자 승인 뒤 기준·컷 이미지로 진행한다. 모든 클립을 받은 뒤 합성 요청을 완성하고 실제 MP4·SRT의 시간·언어·화면을 확인한다.
06의 통합 WAV를 먼저 확인한 뒤 07 시간을 정한다. 글자 수 추정으로 컷·자막을 실측처럼 확정하지 않는다. 08의 실제 대표 화면·짧은 움직임을 기존 scenes 검토에서 07/08/09와 함께 확인한다. 새 08·09 승인 뒤 정식 10 모델·기준 이미지, 11 끝점 PNG, 12 전체 컷 영상으로 진행한다. 모든 클립을 받은 뒤 합성 요청을 완성하고 실제 MP4·SRT의 시간·언어·화면을 확인한다.
파일·메타데이터가 없는 외부 결과는 result=null, 부분 결과는 미완료다. 실제 최종 영상이 없으면 검수 결과는 incomplete이며 사용자 승인 없이는 완료를 선언하지 않는다. 서비스 실행·배포·업로드 명령은 현재 구성에 없다.
## 1.0/1.1 계약 회귀 검사
기존 환경에 jsonschema가 있으면 재설치하지 않고 다음 명령을 실행한다. 위 형식 검사와 함께 사용하며 실제 렌더링·청취·사용자 승인을 검사했다고 해석하지 않는다.
```powershell
python -X utf8 contracts/check.py
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 검토에 제공한다.
+4 -4
View File
@@ -98,11 +98,11 @@ Qwen3-TTS용 분할은 의미·호흡·발음 기준으로 한다. 긴 문장은
## 깔끔한 그림체로 작동 방식을 보여준다
스타일은 [사용자 기준 이미지](../references/1.png)의 단순한 형태, 가는 윤곽선, 정돈된 색면과 제한적인 음영을 따른다. 참고 Shorts의 입체 렌더링이나 캠핑 소재를 이번 영상의 필수 스타일·배경으로 가져오지 않는다.
새 제작은 Blender의 깔끔한 교육용 3D다. [사용자 기준 이미지](../references/1.png)는 보존하고 단순 형태·정돈된 색을 참고한다. 단순 배경, 부드러운 조명, 선명한 분자·물방울 등 주요 형태로 설명하며 평면 복제·캠핑 소재·상표를 강제하지 않는다. scenes 검토에서 실제 대표 화면·짧은 움직임을 먼저 확인하고 승인한 품질 기준으로 전체 제작한다.
gpt-image-2.5-sunburst 요청에는 조사 근거, 대본, 장면 상태, 반복 인물·배경의 고정 특징을 연결한다. 복잡한 메커니즘은 시각 요소를 줄여 설명하되 자연 퇴적물과 콘크리트 시설, 그늘과 냉방처럼 사실에 중요한 차이는 남긴다. 추측한 구조를 사실을 설명하는 도해로 사용하지 않는다.
Blender 모델·장면·애니메이션 요청에는 조사 근거, 대본, 장면 상태, 반복 인물·배경의 고정 특징을 연결한다. 복잡한 메커니즘은 시각 요소를 줄여 설명하되 자연 퇴적물과 콘크리트 시설, 그늘과 냉방처럼 사실에 중요한 차이는 남긴다. 추측한 구조를 사실을 설명하는 도해로 사용하지 않는다.
컷 하나에서는 주요 물체와 변화가 먼저 보이게 한다. 강조 색·화살표·라벨이 서로 다른 대상을 동시에 가리키지 않게 하고, 같은 요소의 색과 기호는 일관되게 사용한다. 생성 이미지의 글자·숫자가 정확하지 않으면 이미지에 억지로 남기지 않고 합성 단계에서 검증한 텍스트로 배치한다.
컷 하나에서는 주요 물체와 변화가 먼저 보이게 한다. 강조 색·화살표·라벨이 서로 다른 대상을 동시에 가리키지 않게 하고, 같은 요소의 색과 기호는 일관되게 사용한다. 설명 글자·숫자는 3D 표면에 억지로 남기지 않고 합성 단계에서 검증한 텍스트로 배치한다.
움직임은 물높이 변화·부품 조작·그림자 이동 등 설명에 필요한 상태 변화를 우선한다. 줌·흔들림·장식적 움직임을 설명의 대체물로 쓰지 않는다. 시작·끝 프레임과 움직임 요청은 같은 상태 변화를 설명해야 한다.
@@ -123,7 +123,7 @@ gpt-image-2.5-sunburst 요청에는 조사 근거, 대본, 장면 상태, 반복
| 03–04 대본·제목 | 이야기 작가 | 질문부터 답까지의 구성, 쉬운 말, 약속과 회수 |
| 05–06 분할·음성 | 내레이션 담당 | 의미·호흡·발음, 요청과 실제 음성의 구분 |
| 07–09 컷·연결·반복 요소 | 장면 감독 | 실제 시간, 화면 역할, 상태 변화와 연속 기준 |
| 10–11 기준·컷 이미지 | 비주얼 담당 | 첨부 그림체, 구조의 정확성, 고정 특징·글자 확인 |
| 10–11 기준·컷 이미지 | 비주얼 담당 | 교육용 3D 모델·.blend 원본, 구조의 정확성, 고정 특징·끝점 확인 |
| 12–13 영상·합성 | 영상·편집 담당 | 설명에 필요한 움직임, 자막·주석, 모바일 판독성 |
| 14 최종 검수 | 최종 검수자 | 도입의 약속부터 완성본까지 근거로 확인 |
| 전체 | 제작 총괄 | 지침 적용 요약과 네 사용자 검토의 연결 |
+1 -1
View File
@@ -10,7 +10,7 @@
실제 완료는 구조 검사 외에 선행 버전·선택 ID·주장 출처·배열 식별자·파일·실측·사용자 승인을 확인해야 한다. request만 있거나 verified=false 미디어를 필수 결과로 쓰는 완료 처리는 오류다. 미검수 영역을 pass로 기록하는 것도 오류다.
공통 설정은 주제·언어 입력, 세로 9:16, 전체 180000ms 이내, 각 컷 0ms 초과 5000ms 이하, 사용자 그림체, Qwen3-TTS 기존 환경과 gpt-image-2.5-sunburst다. 실제 지원 여부는 제작 환경에서 확인하며 확인하지 않은 API 파라미터를 고정하지 않는다.
공통 설정은 주제·언어 입력, 세로 9:16, 전체 180000ms 이내, 각 컷 0ms 초과 5000ms 이하, 사용자 원본의 단순 형태·정돈된 색을 해석한 교육용 3D, Qwen3-TTS 기존 환경과 Blender다. 1.1은 visual_engine=Blender이며 기존 1.0과 지정 이미지 모델 기록은 보존한다. .blend는 media와 구분해 경로·검증 상태·SHA-256·Blender 버전을 기록한다. 실제 지원 여부는 제작 환경에서 확인하며 확인하지 않은 API 파라미터를 고정하지 않는다.
가정·중요한 미확정 의도를 먼저 밝히고 필요한 최소 변경을 한다. 관련 없는 코드·주석·서식·기존 불필요한 코드를 정리하지 않는다. 이번 변경이 새로 불필요하게 만든 항목만 제거한다. 동작을 바꾸는 변경은 해당 성공·실패 조건을 실제로 검증하고, 문서·양식은 형식·참조·예제 흐름을 확인한다.
@@ -6,4 +6,4 @@
서비스·데이터베이스·자동 실행 스케줄러는 현재 사용자 수와 요청 범위에 맞지 않아 선택하지 않았다. Markdown만 전달하면 상태·식별자·선행 버전을 형식 검사하기 어려워 JSON을 함께 사용한다. 전역 설치는 프로젝트 밖의 다른 작업에 지침을 노출하므로 저장소 범위로 한정했다.
이 선택은 외부 생성 결과를 기다릴 때도 기록·검토를 보존하게 한다. 반면 현재 구성만으로 실제 미디어를 생성하거나 병렬 잠금·자동 재시도·업로드를 수행할 수는 없다. 실제 도구 연결은 별도 실행 기능으로 추가하며 사용자 검토와 버전 참조를 계속 보존한다.
이 선택은 외부 생성 결과를 기다릴 때도 기록·검토를 보존하게 한다. 당시 지침·양식만으로 실제 미디어를 생성하거나 병렬 잠금·자동 재시도·업로드를 수행할 수는 없었다. 실제 도구 연결은 별도 실행 기능으로 추가하며 사용자 검토와 버전 참조를 계속 보존한다.
@@ -0,0 +1,21 @@
# 로컬 Blender 모델과 실제 애니메이션
## 선택 배경
사용자는 물 영상의 설명 내용과 내레이션은 수용했지만 생성 이미지의 품질을 개선해 달라고 요청했다. 같은 분자·결합·물방울을 여러 장면에서 유지하면서 실제 상태 변화를 보여줄 필요가 있었다. 기존 2D 합성의 이동·확대만으로는 분자 연결 교체와 일정한 물의 양을 유지하는 변형을 확인하기 어려웠다.
## 결정
새 시각 제작은 저장소의 소재별 Blender 모델·애니메이션으로 만든다. 기존 설치 Blender에서 원본을 열고 PNG와 무음 MP4를 렌더하며, 제목·라벨·자막·승인된 WAV는 기존 Remotion + FFmpeg로 합성한다. 사용자 원본 이미지는 정돈된 형태·색의 참고로 보존하고 평면 스타일 복제를 강제하지 않는다.
전체 렌더 비용을 쓰기 전에 대표 PNG와 짧은 실제 움직임을 기존 scenes 검토에 함께 제출한다. 물 분자가 표시 체적을 벗어난 사용자 피드백도 새 버전의 영역·평가 정점·투영 검사와 대표 출력으로 수정한 뒤 승인받았다. 설치 여부나 기술 검사만으로 품질 승인을 대신하지 않는다.
## 검토한 대안
- GPT 이미지 생성의 반복 수정: 사용자가 지적한 품질 문제를 해결할 보장이 없고 컷 사이 동일 모델·부피·연결의 실제 상태를 검사하기 어렵다.
- 기존 2D 이미지에 움직임만 추가: 합성 구성을 재사용할 수 있지만 3D 원본의 실제 변형과 장면 평가를 제공하지 못한다.
- 외부 영상 생성 서비스 추가: 새 비용·연결 경로가 필요하며 기존 로컬 도구를 사용하려는 승인 범위에 포함되지 않는다.
## 이후 작업에 미치는 영향
원본 .blend의 해시·Scene·프로필과 실제 PNG/MP4를 연결해야 하며 렌더 설정이나 장면 원본이 바뀌면 후속 검증을 다시 수행한다. 기존 대본·음성·시간표와 과거 결과를 그대로 보존할 수 있는 대신, 새로운 소재에는 그 근거에 맞는 모델·장면 코드를 별도로 작성해야 한다. 이 방식은 임의 주제의 모든 모델을 자동 생성하거나 물의 분자동역학·유체 시뮬레이션을 제공하지 않는다.
+1
View File
@@ -1,3 +1,4 @@
# 결정 기록
- [로컬 지침과 JSON 전달](0001-local-instructions.md): 개인용 Codex 사용과 네 사용자 검토에 맞춘 역할·기록 구성.
- [로컬 Blender 모델과 실제 애니메이션](0002-local-blender-rendering.md): 이미지 품질 개선과 모델·움직임 검증을 위한 제작 방식 전환.
+51
View File
@@ -1,3 +1,54 @@
# 미해결 문제
현재 지침·양식 구현 범위에서 확인된 미해결 문제는 없다.
## 실제 제작의 청취 확인 기록 — 해소
- 대상: [water-hydrogen-bond-surface-tension / 06-tts r1 검토](../../projects/water-hydrogen-bond-surface-tension/stages/06-tts/r1/review.md).
- 원인: 현재 Codex 세션은 오디오 입력을 지원하지 않아 실제 WAV의 청취 검수를 수행할 수 없다. 오디오 전달 시 “audio content omitted because you do not support audio input” 결과를 확인했다.
- 완료한 확인: 기존 Qwen3-TTS 모델·한국어·Sohee 지원, 8개 단위 WAV와 110560ms 통합 WAV의 실제 생성, PCM 형식·샘플 기반 단위 구간·파일 통합 동일성.
- 당시 영향과 보류: 청취 결과가 없어 06은 awaiting_input이고 07–09는 보류했다. 파일·파형 검사를 청취 통과로 기록하지 않았다.
- 해소 근거: 사용자가 실제 제공된 통합 WAV에 대해 “내레이션 WAV 괜찮습니다.”라고 확인했다. 사용자 청취 확인으로 06을 complete 처리하고 07–09를 재개한다. Codex 직접 청취로 기록하지 않는다.
- 후속 확인: 07에서 실제 WAV의 단어별 자동 정렬·승인 원문 대응·전체 컷과 별도 자막 구간을 검사했다. 실제 최종 출력의 자막 판독성·타이밍 검수는 13–14에 남아 있다. 승인된 03 원문 및 기존 topic/story 승인은 보존했다. 전역 환경·다른 음성 도구로 대체하지 않았다.
## 임시 ASR 모델 캐시 정리 — 보류, 제작 진행에는 영향 없음
- 대상: projects/water-hydrogen-bond-surface-tension/.cache/asr. 이번 정렬 실행에서 저장한 Whisper 가중치·토크나이저 캐시다. 주 가중치 크기는 1617824864바이트다.
- 시도: 저장소 안의 해당 절대 경로와 일반 디렉터리임을 확인하고, PowerShell의 LiteralPath 삭제 및 파일별·빈 디렉터리별 삭제로 정리를 시도했다.
- 실제 결과: 자동 승인 검토가 삭제 실행을 “blocked by policy”로 거절했다. 더 구체적인 거절 이유는 제공되지 않았다. 삭제는 실행되지 않았고 캐시는 남아 있다. 다른 셸·도구로 삭제를 우회하지 않았다.
- 영향: WAV·정렬 근거·단계 결과·장면 검토에는 영향이 없으며 blocking 문제로 처리하지 않는다. 모델 캐시는 단계 결과·승인·미디어로 참조하지 않는다. 원시 정렬 근거는 별도 alignment.json에 보존했다.
- 보류 이유: 허용된 정리 경로가 없어 해당 임시 캐시를 보존한다. 현재 사용자 전역 환경·설정은 변경하지 않았다.
## 기준 이미지의 정확한 세로 규격 — 해소
- 대상: [water-hydrogen-bond-surface-tension / 10-references r1 검토](../../projects/water-hydrogen-bond-surface-tension/stages/10-references/r1/review.md).
- 실제 실행: 사용자가 “현재 내장 이미지 도구 사용을 허용합니다(모델명 확인 없이)”라고 허용해 기준 이미지 5종을 생성·수정했다. 실제 모델명은 미확인이며 지정 모델 실행 성공으로 기록하지 않는다.
- 원인: 내장 도구에는 크기 지정 인자가 없고 생성 후보 5개 모두 실제 941×1672 PNG로 정확한 9:16에서 미세하게 벗어난다.
- 완료한 확인: 실제 파일·PNG signature·IHDR 크기·화면·그림체·원자 수·결합선 끝점·비교 조건을 확인했다. 부분 결과를 보존하고 규격 미충족으로 verified=false를 기록했다.
- 영향: 10은 awaiting_input, 검증된 기준이 필요한 11–14는 시작하지 않았다. 기존 대본·WAV·컷·사용자 승인에는 영향이 없다.
- 재개 조건: 그림의 원본 픽셀을 유지하고 여백만 추가해 945×1680으로 만드는 로컬 보정의 명시적 허용 또는 정확한 규격의 생성 출력. 이미지 편집의 내장 도구 사용 지침 때문에 로컬 보정 허용 질문을 제출했으며 아직 답변이 없다. 사용자 환경은 변경하지 않았다.
- 해소: 사용자가 해당 보정 요청에 “허용합니다”라고 답했다. 원본을 보존한 새 PNG 5개에 여백만 추가했고, 945×1680·정확한 9:16·원본 영역 픽셀 동일·PNG CRC/디코딩·실제 화면을 확인했다. 10 complete로 처리하고 11을 시작한다. 앞선 보류 기록은 당시 상황이며 현재 보정 허용을 다시 요구하지 않는다.
## 최종 MP4의 전체 재생·청취 확인 — final 검토 대기
- 대상: [water-hydrogen-bond-surface-tension / 14-review r1](../../projects/water-hydrogen-bond-surface-tension/stages/14-review/r1/review.md).
- 실제 출력: 선택한 Remotion + FFmpeg를 제작 폴더 안에 구현해 31개 컷 MP4와 110560ms·1080×1920 최종 MP4·38개 자막 SRT를 생성했다. 실제 길이·프레임 수·전체 AV 디코딩·원 WAV/AAC 신호 대조·모바일 화면 표본·모든 인접 경계를 확인했다.
- 남은 확인: 현재 세션은 오디오 입력을 지원하지 않으며 정상 속도 전체 시청·청취를 수행하지 못했다. 원 WAV에 대한 기존 사용자 청취 확인은 보존하되 최종 MP4의 청취 확인으로 바꾸지 않는다. 파일·신호·정지 표본 검사로 미검수 영역을 pass 처리하지 않았다.
- 영향: 실제 13 출력과 14 보고는 사용자 final 검토에 제출할 수 있다. 13/14는 awaiting_review이며 14 추천은 incomplete다. 영상 파일 부재나 실행 경로 부재로 기다리는 상태가 아니다. 시청 중 실제 증상이 확인되면 시간/컷과 원인 단계로 돌아간다.
- 이후 사용자 피드백: 내용은 수용했으나 이미지 품질 변경을 요청해 Blender 3D로 전환했다. 13/14 r1은 stale이고 기존 final의 changes_requested 피드백을 보존한다. 이후 수정한 07 r1·08 r3·09 r3가 실제 승인되어 정식 10–12 r2와 새 13/14 r2로 진행했다. 새 완성본은 실제 MP4/SRT가 있으며 아래 최종 재생·청취 대기 기록을 따른다.
## 물 분자와 표시 체적의 포함 관계 — 해소, 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)는 이후 실제 “수정본을 승인합니다” 응답으로 승인됐고 정식 10–12 r2를 제작했다. 최종 사용자 승인과는 구분한다.
## 새 3D 완성본의 전체 재생·청취 — 사용자 승인 완료, 직접 검수 미확인
- 대상: [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 r2의 final approved로 기록했다. [승인 근거](../../projects/water-hydrogen-bond-surface-tension/history/final-r2-user-approval.md)와 승인 전 사본을 보존했다. 13은 complete, 14는 직접 전체 재생·청취 근거가 없어 awaiting_input이다. 기존 14의 not_checked/incomplete를 사용자 승인만으로 pass로 바꾸지 않으며 동일 버전의 승인을 다시 요구하지 않는다. 확인된 결함으로 재생성을 요구하거나 파일 부재로 실행을 멈춘 상태가 아니다. 시청에서 증상이 있으면 실제 시간/컷과 원인 단계의 새 버전으로 돌아간다.
+15 -7
View File
@@ -7,18 +7,26 @@
- 진행 기록·단계 결과 스키마 2개, 진행·단계 JSON 양식 15개와 사용자 검토 양식.
- 참고 영상 네 편의 내용·구성 분석을 사용하는 대본·제목·장면·검수 지침.
- 물순환 주제의 연결 예제와 승인·부재·시간·변경을 다루는 검토 사례 15개.
- 기존 1.0 결과를 보존하는 Blender 1.1 계약과 저장소 내 원본 검사·PNG·무음 MP4 렌더 실행기. 실행법은 [Blender 안내](../../tools/blender/README.md)에 있다.
## 확인한 범위
JSON·TOML·스킬 형식과 링크·단계 참조를 실제 명령으로 확인했다. 예제의 컷·자막·프레임 연결과 제목·대본 수정의 종속 관계도 확인했다. 예제의 현재 기록은 topic 승인 전이고 외부 결과·실제 미디어는 없다. 스키마 검사와 예제 검토는 지침·형식의 확인이며, 자동 실행 프로그램의 동작 시험이 아니다.
Codex CLI의 존재·버전은 확인했지만 새 세션에서 사용자 지정 역할·스킬의 자동 탐색은 아직 확인하지 않았다. Qwen3-TTS 환경·패키지의 설치 위치는 확인했으며 모델 로딩·화자·GPU·실제 추론은 미확인이다. 이미지·영상·합성 호출과 실제 영상 청취·검수도 미실행이다.
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 정지 렌더도 확인했다. 검사 씬은 물 영상의 품질 승인 근거가 아니다. 실행기의 17개 회귀 검사와 10개 계약 검사는 통과했으며, 미승인 제작·누락 파일·잘못된 참조·이전 시각 버전 혼입을 거부한다.
1. 기존 Qwen3-TTS 환경의 모델·화자·언어와 실측 구간을 확인하고 실제 요청·결과 연결을 만든다.
2. 지정 이미지 모델의 사용 가능 여부·세로 출력·참조 방식과 실제 PNG 결과 연결을 확인한다.
3. 영상 생성·합성 엔진을 선택하고 컷·음성·자막의 실제 파일·시간 계약에 연결한다.
4. 실제 제작 결과로 전체 과정과 네 검토를 확인한 뒤 분야별 전문 지침을 추가한다.
물 제작은 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 완성본과 남은 검토
승인된 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개 거부 사례도 통과한다.
[새 합성 검토](../../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다. 이후 실제 사용자 “승인합니다” 응답을 [승인 기록](../../projects/water-hydrogen-bond-surface-tension/history/final-r2-user-approval.md)에 보존하고 final을 같은 13/14 r2의 approved로 기록했다. 13은 complete, 14는 직접 전체 재생·청취 근거 부재로 awaiting_input이며 기존 not_checked/incomplete 보고를 보존한다. 사용자 승인과 모든 영역의 기술 검수 완료를 구분한다. 영상 실행·파일 부재로 멈춘 상태는 아니다.
1. 사용자 final 승인은 완료됐다. 추가 전체 재생·청취 근거가 확보되면 14의 새 검수 버전에 기록한다.
2. 증상이 발견되면 실제 시간/컷과 원인 단계의 새 버전으로 수정한다.
3. 다른 소재는 같은 실행 경로를 사용하되 별도 근거·모델·장면을 작성한다.
대표 영상의 실제 디코드 화면 비교에서 비활성 Scene의 애니메이션이 정지하는 문제를 발견했고, 렌더 전에 선택 Scene을 활성화하도록 수정했다. 초기 활성 Scene이 Idle인 같은 원본에서 두 장면의 PNG·MP4 실제 RGB 움직임 검사가 실패→통과로 바뀌었으며 독립 검토도 통과했다. 물 대표 렌더는 수정된 실행기로 재제작하고 실제 움직임을 확인했다. 이후 실제 r3 scenes 승인 뒤 정식 10–12 r2와 새 13/14 r2로 진행했다. 정상 속도 전체 재생·청취의 직접 검수 근거는 미확인이며 사용자 final 승인은 완료됐다.
+91
View File
@@ -17,3 +17,94 @@ JSON 34개 중 데이터 양식·예제 30개는 해당 스키마로 검증했
실제 Codex 세션의 새 역할·스킬 탐색, Qwen3-TTS 모델·화자 로딩과 추론, 지정 이미지 모델의 사용 가능 여부·호출, 영상 생성·합성·청취 검수는 실행하지 않았다. 예제의 외부 result는 null이고 최종 검수는 incomplete다. 서버 구성은 없으므로 서버 구동 검사는 적용되지 않는다.
참고 영상 분석은 공개 메타데이터·한국어 자동 자막·4초 간격의 이미지 표본 117장을 확인한 결과다. 음성 청취, 정확한 컷 경계, 소재 사실의 독립 검증, 시청 유지율은 확인하지 않았다.
## Blender 전환 진행 중 검사
아래는 초기 기반 검사 이후 실제 실행한 추가 검사다. 기존 물 제작의 WAV와 r1 결과를 보존하고 새 3D 대표 렌더를 준비하는 중이며, 당시 정식 전체 제작·새 완성본 검사는 아직 없었다. 이후 실제 기록은 아래 새 완성본 절에 추가한다.
| 실제 실행 | 종료 코드 | 확인한 내용 |
| --- | --- | --- |
| `C:/Users/baram/anaconda3/python.exe -B -X utf8 contracts/check.py` | 0 | 10개 검사: 1.0 호환·1.1 양식·Blender 원본/미디어 구별·참조·대표 영상 길이·문서/역할/스킬 링크 |
| `C:/Users/baram/anaconda3/python.exe -B -X utf8 tools/blender/test_runner.py` | 0 | 13개 검사: 승인·현재 포인터·선행 버전·WAV 실측·누락/미검증 파일·격자·entity 참조·새 엔진·버전 경로·cut/continuous 경계 |
| [실행기 안내](../../tools/blender/README.md)의 `smoke.py --engine BLENDER_EEVEE` 명령 | 0 | 실제 GPU 렌더, 원본 재열기, PNG와 무음 H.264 전체 디코드, 움직임·다중 Scene·10/11/12 요청 실행과 실패 거부 |
| 같은 명령의 `--engine CYCLES` | 0 | 실제 CPU 렌더와 디코드. 별도 Cycles/OPTIX GPU 정지 렌더에서도 RTX 4080 SUPER 장치 확인 |
| 독립 검토의 EEVEE 50fps·5프레임 검사 | 0 | 실제 100ms MP4·PNG 렌더와 전체 디코드. 10fps 검사와 구분 |
| 전환 전 기준 36개 파일의 SHA-256 대조 | 0 | 기존 r1 결과·검토, 렌더러 소스, 원본 스타일과 통합 WAV 변경 없음 |
| 새 작업 사본으로 복사한 WAV 9개의 SHA-256 대조 | 0 | 통합 음성·8개 발화 파일이 원본과 바이트 동일 |
실제 렌더 검사 씬은 사용자 물 영상과 별개인 `.cache`의 검사용 자료다. 검사 승인은 모의 자료이며 실제 사용자 승인으로 기록하지 않는다. Blender 실행·디코드 확인은 화면 품질·청취·최종 승인과 다르다. EEVEE의 임의 seed 설정 API는 확인되지 않아 기본 0만 지원하고 다른 값은 거부한다. Cycles에서는 실제 seed 설정을 적용한다.
추가 물 대표 영상 검사에서 비활성 Scene의 100개 렌더 프레임이 실제로 정지하는 문제를 발견했다. PNG 파일 SHA-256은 메타데이터 때문에 달랐지만, 시작·중간·끝과 여러 중간 프레임의 디코드 RGB 차이는 없었다. 위 smoke의 파일 해시 비교는 이 경우의 움직임 증거로 충분하지 않았다. 선택 Scene의 실제 평가와 디코드 픽셀 비교를 보완하는 중이며, 해당 옛 샘플은 사용자 검토 결과로 채택하지 않는다.
위 문제는 렌더 전에 선택 Scene을 활성화해 수정했다. 실행기 안내의 `smoke.py` 명령에 `--animation-only`를 추가한 EEVEE 검사에서 수정 전 종료 1, 수정 후 종료 0을 확인했다. 초기 Idle인 원본에서 SceneA/B 각각 50fps·5프레임·100ms를 렌더해 PNG와 실제 MP4의 RGB 시작·중간·끝이 모두 달랐다. 원래 실패한 동일 `.blend` 재렌더도 종료 0이고, 내부 실행 코드 해시 변경으로 새 attempt를 사용했다. 독립 검토의 같은 실제 검사, 14개 실행기 회귀와 10개 계약 검사도 각각 종료 0이다. 물 대표 PNG/MP4는 이 수정 뒤 새 attempt로 다시 만들고 있으며 실제 결과 검토가 남아 있다.
## 물 영상 3D 대표 샘플 — scenes 검토 대기
2026-10-10 확인. [08 r2 검토](../../projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r2/review.md)에 실제 명령·파일·한계를 기록했다. [제작 코드 안내](../../projects/water-hydrogen-bond-surface-tension/blender/README.md)의 기존 설치 경로를 사용했다.
| 실제 실행 | 종료 코드 | 확인한 내용 |
| --- | --- | --- |
| `blender/test_model.py` | 0 | 없는 파일·Scene/H 누락·부피 driver 오류 각각 기대 종료 1, 실제 원본 통과. 닫힌 mesh 부피 상대 변화 9.56e-8 |
| `blender/render_preview.py --kind image` 및 `--kind video` | 0 | 실제 PNG·MP4 각각 3개, 같은 원본 SHA·프로필·Scene·범위, 전체 디코드 |
| `blender/inspection_views.py` 및 `blender/verify_design.py` | 0 | 실제 픽셀 변화, 31장면·30연결·38자막·6샘플, 25개 실물 경로·해시 |
| 최종 MP4 3개에 대한 `ffprobe` 및 FFmpeg 전체 디코드·프레임 추출 | 0 | 1080×1920·50fps, 202/244/200프레임·4040/4880/4000ms. 실제 시작/중간/마지막 9프레임과 모바일 화면 직접 확인 |
| 기존 작업 루트로 복사한 실물 25개와 전환 전 기준 36개 SHA-256 대조 | 0 | 원본·대표·증거 동일, 기존 r1·렌더러·스타일·WAV 변경 없음. 전환 전 project.json은 history의 원본으로 대조 |
| 독립 Blender 백그라운드 재열기·스키마·manifest 검사 | 0 | 실제 세 Scene·객체·연결 교체·부피와 전달 구조 확인. scenes pending 유지 |
실제 모델은 세 대표 Scene이며 나머지 31컷의 상태·연결은 설계다. 한국어 라벨·부분전하·자막·내레이션 합성, 정식 10–12 전체 제작과 새 완성본 검수는 후속 작업이다. 기술 검사와 검토자의 pass를 사용자 scenes 승인으로 바꾸지 않았다.
## 사용자 피드백에 따른 물 영역 포함 관계 수정
앞선 r2 품질 검수는 분자와 물 체적의 포함 관계를 놓쳤다. 해당 판단을 취소하고 이전 결과를 보존한 새 r3에서 수정했다. [r3 검토](../../projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3/review.md)에 실제 명령·실패 이유·파일·직접 관찰을 기록했다.
| 실제 실행 | 종료 코드 | 확인한 내용 |
| --- | --- | --- |
| 새 `blender/test_model.py` | 0 | 실패 6사례 각각 기대 종료 1. r2 원본과 원자 중심 불변·mesh 정점 이동 fixture에서 포함 assertion 문구 확인, 수정 원본 GREEN |
| 독립 Blender 평가 정점·표시 면 검사 | 0 | 전체 705프레임·11,747,388정점이 실제 물 체적과 네 면 투영 합집합 안에 포함. 원본 r2 및 정점 오류는 기대 종료 1 |
| r3 `render_preview.py`, `inspection_views.py`, `verify_design.py` | 0 | 같은 새 원본·설정에서 3PNG·3MP4, 31장면·30연결·38자막·25개 실물 해시 |
| 실제 새 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이었다. 이후 실제 “수정본을 승인합니다” 응답으로 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을 삭제하지 않았다.
## 새 완성본 연결 후 저장소 통합 검사
2026-10-10, 실제 수락 MP4/SRT와 제출 자산을 동일 경로·SHA로 저장소 루트에 복사한 뒤 실행했다. 총괄은 이전 final 기록을 보존하고 13/14 awaiting_review 및 r2 final pending을 기록했다.
| 실제 실행 | 종료 코드 | 확인한 내용 |
| --- | --- | --- |
| 기존 Python의 `contracts/check.py` | 0 | 통합 상태에서 계약 회귀 10개 및 로컬 참조 |
| 같은 Python의 `tools/blender/test_runner.py` | 0 | 실행기 전체 회귀 17개 |
| 제작 renderer의 `npm.cmd run check` | 0 | 실제 새 입력과 24개 거부 사례 |
| 같은 Python의 전체 형식·스킬 검사 | 0 | 스키마 적용 결과·이력 64개, 역할 TOML 8개, 스킬 15개 각각 유효, 두 루트 지침 동일 |
| 별도 읽기 전용 최종 실물 검사 | 0 | 실제 영상·SRT·입력·렌더러 SHA, 원 WAV, 이전 기준/대표 파일 111개 보존, 전체 AV 디코드. 정상 속도 시청·청취는 미확인 |
| `git diff --check` | 0 | 통합 변경의 공백 오류 없음 |
이 검사에서 실제 원본·자막·영상은 바뀌지 않았다. 사용자 final 승인은 pending이며 기술 검사 통과를 해당 승인으로 바꾸지 않는다.
## 최종 사용자 승인 기록 후 검사
2026-10-10, 실제 사용자 “승인합니다”와 “최종 승인 작업을 계속하세요” 응답에 따라 [승인 기록](../../projects/water-hydrogen-bond-surface-tension/history/final-r2-user-approval.md)을 저장했다. 승인 직전 project 사본을 보존하고 final을 같은 13/14 r2의 approved로 연결했다. 13은 complete, 14는 직접 전체 시청·청취 근거 부재로 awaiting_input이며 기존 not_checked/incomplete 보고는 변경하지 않았다. 사용자 승인과 기술 검수 완료를 구분한다.
기존 Python의 `contracts/check.py` 10개·`tools/blender/test_runner.py` 17개, 전체 형식 검사(스키마 결과/이력 65개·역할 8개·스킬 15개), renderer의 `npm.cmd run check` 24개 거부 사례, 읽기 전용 최종 실물 검사 및 `git diff --check`를 다시 실행해 각각 종료 코드 0을 확인했다. MP4/SRT·원 WAV·렌더 입력 SHA·이전 결과 111개와 전체 AV 디코드는 유지됐다. 직접 전체 시청·청취 결과를 새로 만들거나 미확인을 pass로 처리하지 않았다. 실행 명령·결과는 `.dryforge/backup/blender-run/final-approval-checks.json`에 보존한다.
+20 -8
View File
@@ -14,7 +14,7 @@
필수 입력은 `topic`, `language`이며 공백만 있거나 빠졌으면 보완한다. 언어 이름·코드를 의미 확인 후 보존한다. 입력 언어로 대본·제목·내레이션·자막을 작성하며 지원되지 않는 언어를 다른 언어로 바꾸지 않는다. 새로운 요청은 저장소에서 유일한 소문자 영숫자·하이픈 `project_id`로 시작한다. 빈 양식의 video-001은 복사 후 실제 식별자로 바꾼다.
공통 설정은 세로 9:16, 최종 180000ms 이내, 각 컷 0ms 초과 5000ms 이하, 스타일 기준 `references/1.png`, TTS `Qwen3-TTS`, conda 환경 `qwen3-tts`, 이미지 모델 `gpt-image-2.5-sunburst`다. 그림체만 참고하고 캠핑 소재·인물·상표를 강제하지 않는다. 쉬운 표현은 대상 시청자를 어린이로 제한하는 규칙이 아니다.
공통 설정은 세로 9:16, 최종 180000ms 이내, 각 컷 0ms 초과 5000ms 이하, 스타일 기준 `references/1.png`, TTS `Qwen3-TTS`, conda 환경 `qwen3-tts`, 새 제작의 시각 엔진 `Blender`다. 단순 배경·부드러운 조명·선명한 형태의 교육용 3D를 사용하며 원본의 단순 형태·정돈된 색을 참고한다. 원본을 보존하고 평면 복제와 캠핑 소재·인물·상표를 강제하지 않는다. 쉬운 표현은 대상 시청자를 어린이로 제한하는 규칙이 아니다.
## 저장과 전달
@@ -28,7 +28,7 @@ projects/<project_id>/
모든 데이터 경로는 저장소 루트 기준 상대 경로다. 현재 작업 폴더나 검토 문서의 위치를 기준으로 해석하지 않는다. 외부 실행파일 경로·키·인증정보를 양식에 기록하지 않는다.
단계 결과는 `schema_version`="1.0", `project_id`, `stage_id`, `revision`, `inputs`, `payload`를 가진다. 버전은 1부터 시작하고 이전 결과를 덮어쓰지 않는다. `inputs`는 실제 사용한 선행 결과의 `{stage_id, revision}` 배열이다. 중복·자기 참조·다른 제작물 참조를 금지한다. 14는 13과 검수에서 직접 사용한 유효 선행 결과를 기록한다.
단계 결과는 `schema_version`="1.1"(새 제작) 또는 "1.0"(기존 결과), `project_id`, `stage_id`, `revision`, `inputs`, `payload`를 가진다. 버전은 1부터 시작하고 이전 결과를 덮어쓰지 않는다. `inputs`는 실제 사용한 선행 결과의 `{stage_id, revision}` 배열이다. 중복·자기 참조·다른 제작물 참조를 금지한다. 14는 13과 검수에서 직접 사용한 유효 선행 결과를 기록한다.
`project.json`의 필드는 `schema_version`, `project_id`, `brief`, `settings`, `stages`, `reviews`다. stages는 아래 14개를 각각 한 번 포함하는 `{stage_id, status, revision, artifact_path, issues}` 배열이다. 결과가 없으면 revision/artifact_path는 null이다. issues는 `{code, message, blocking}` 배열이다. reviews는 네 검토를 각각 한 번 포함하는 `{review_id, decision, artifact_refs, feedback}` 배열이다. artifact_refs는 검토한 `{stage_id, revision}` 배열이고 feedback은 실제 선택·피드백을 기록한 문자열이다.
@@ -47,11 +47,11 @@ projects/<project_id>/
| [05-tts-split](../templates/stages/05-tts-split.json) | 03 | 단위별 block_ids·표시/발화 텍스트·발음 메모. 순서·의미·누락·중복 확인 |
| [06-tts](../templates/stages/06-tts.json) | 05 | 도구·환경·언어·단위 요청. 실제 모델·화자·단위 WAV·통합 WAV·실측 unit_ranges 결과. 전체 파일과 시간 확인 |
| [07-cuts](../templates/stages/07-cuts.json) | 05,06 | 실측 통합 길이·확인 방법·연속 컷·별도 자막. 전체 음성을 덮고 각 컷 5초 이내. scenes 승인 필요 |
| [08-scenes](../templates/stages/08-scenes.json) | 03,07 | 각 컷의 설명·시작/끝 상태·카메라·인접 연결. 누락·중복 없음, 연결 이유·상태 일치. scenes 승인 필요 |
| [08-scenes](../templates/stages/08-scenes.json) | 03,07 | 각 컷의 설명·시작/끝 상태·카메라·인접 연결과 preview(대표 PNG·짧은 MP4·.blend). 누락·중복 없음, 연결 이유·상태 일치. scenes 승인 필요 |
| [09-entities](../templates/stages/09-entities.json) | 03,08 | 인물·배경·고정 특징·컷별 등장 참조. 모든 컷을 정확히 한 번 포함. scenes 승인 필요 |
| [10-references](../templates/stages/10-references.json) | 02,08,09 | 모델·그림체·스타일/인물/배경 요청, 실제 PNG 결과. 모든 요청 ID와 고정 특징을 다룸. 스타일만 entity_id=null |
| [11-images](../templates/stages/11-images.json) | 02,03,07,08,09,10 | 컷별 start/end 요청·기준 ID·실제 PNG. 각 컷의 두 위치가 정확히 하나씩 존재, 장면·근거·연속성 일치 |
| [12-motion](../templates/stages/12-motion.json) | 07,08,11 | 각 컷의 프레임·실제 길이·움직임 요청과 MP4 결과. 모든 컷과 길이·상태 일치 |
| [10-references](../templates/stages/10-references.json) | 02,08,09 | Blender·RenderProfile·스타일/인물/배경/물체 요청, 실제 .blend·PNG 결과. 모든 요청 ID와 실제 오브젝트/컬렉션·고정 특징 확인. style/object만 entity_id=null |
| [11-images](../templates/stages/11-images.json) | 02,03,07,08,09,10 | 컷별 start/end·Scene 이름·논리 프레임·기준 ID 요청, 실제 .blend·PNG. 각 컷의 두 위치가 정확히 하나씩 존재, 장면·근거·연속성 일치 |
| [12-motion](../templates/stages/12-motion.json) | 07,08,11 | 11 실제 .blend·동일 RenderProfile·Scene·프레임 범위·실측 길이·움직임 요청과 MP4 결과. 모든 컷과 길이·상태 일치 |
| [13-compose](../templates/stages/13-compose.json) | 03,04,06,07,12 | timeline·통합 음성·자막·제목 요청, 실제 MP4·SRT 결과. 세로 비율·전체 길이·파일·시간·순서 확인 |
| [14-review](../templates/stages/14-review.json) | 13 및 검수에 사용한 결과 | 6영역 checks·발견·추천. 근거와 수정할 단계·컷. 검수자의 pass와 사용자의 final 승인 구분 |
@@ -67,7 +67,7 @@ media는 `{path, kind, verified, duration_ms, width, height}`다. kind는 audio/
외부 단계의 result=null이면 awaiting_input이다. 부분 결과는 보존하되 요청 항목을 모두 받기 전 complete로 처리하지 않는다. 결과의 unit_id/reference_id/frame_id/cut_id는 요청에 존재해야 한다. 가상 예제 경로는 검증된 실제 파일로 기록하지 않는다.
Qwen3-TTS의 설치 탐색, 모델 로딩, 실제 추론 성공은 다른 확인이다. 기존 환경을 재설치하지 않고 실제 제작 시 모델·화자·언어 지원을 확인한다. 이미지 모델·세로 출력의 지원 여부도 실제 제작에서 확인하며, 확인하지 않은 API 파라미터나 다른 모델을 고정하지 않는다. 영상 생성·합성 엔진은 이번 범위에서 선택하지 않는다.
Qwen3-TTS의 설치 탐색, 모델 로딩, 실제 추론 성공은 다른 확인이다. 기존 환경을 재설치하지 않고 실제 제작 시 모델·화자·언어 지원을 확인한다. Blender 버전·렌더링 지원·실제 원본 재열기·PNG/MP4 출력은 따로 확인한다. 설치 확인만으로 성공을 주장하지 않는다. 합성은 승인된 기존 Remotion + FFmpeg 경로를 쓰며 전역 환경 재설치·무단 도구 대체를 하지 않는다.
06의 통합 음성이 시간 기준이다. unit_ranges는 해당 단위의 실제 통합 음성 구간을 기록하며 순서·전체 포함·겹침을 확인한다. 07 cuts는 0부터 통합 음성 끝까지 빈 구간·중복 없이 이어진다. 각 컷은 start_ms < end_ms, end_ms-start_ms <= 5000이다. 긴 문장을 화면 컷으로 나눌 수 있지만 단어·의미를 누락하거나 순서를 바꾸지 않는다.
@@ -91,7 +91,7 @@ captions는 컷과 다른 단위다. 여러 컷을 걸칠 수 있으며 0 <= sta
|---|---|---|
| topic | 01 각도·범위 | 02 자료 조사 |
| story | 02 근거와 03 대본·04 제목 | 05 TTS 분할 |
| scenes | 07 실제 컷·08 연결·09 인물·배경 | 10 기준 이미지 |
| scenes | 07 실제 컷·08 연결과 실제 대표 PNG/짧은 MP4·09 인물·배경 | 정식 10–12 제작 |
| final | 13 실제 완성본·14 보고 | 제작 완료 선언 |
검토 결정은 pending/approved/changes_requested/stale다. 승인 버전을 artifact_refs로 고정하고 실제 사용자 응답을 feedback에 기록한다. 응답 없음·담당의 추천은 승인이 아니다. 같은 승인 버전으로 재개할 때는 다시 묻지 않는다. 한 검토 묶음 안에서는 준비된 유효 결과로 묶음을 완성할 수 있다. 예를 들어 07 결과로 08·09를 준비한 뒤 scenes 검토를 요청하되, 승인 전에 10으로 넘어가지 않는다.
@@ -115,3 +115,15 @@ pass는 여섯 영역 모두 pass이고 blocking 발견이 없을 때만 가능
스키마는 잘못된 필드·타입·단계·상태, 진행 기록의 14단계/4검토 구성, 다른 도구명, 음성·이미지의 해당 없는 메타데이터, 근거 없는 pass 구조를 검사한다. 주제·언어의 실사용 값, 배열별 식별자·참조의 유효성, 선택 ID, 대본 일치, 파일 실존·언어 지원, 승인 진위, 실제 시간·길이·화면 판독성, 종속 결과의 stale 처리는 담당과 총괄이 별도로 확인한다.
파싱·스키마 검사만으로 Codex의 실제 에이전트·스킬 탐색이나 미디어 생성을 확인했다고 쓰지 않는다. [공식 에이전트 형식](https://learn.chatgpt.com/docs/agent-configuration/subagents#custom-agents)과 [스킬 위치](https://learn.chatgpt.com/docs/build-skills#where-to-save-skills)를 따른다. 실제 호출 환경에서 목록에 보이는지 확인하고, 보이지 않으면 프로젝트 루트에서 새 세션으로 탐색한다.
## Blender 전환과 대표 품질 검토
[단계 스키마](../contracts/artifact.schema.json)의 1.1 필드·타입·열거값을 사용한다. 새 양식은 1.1이며 기존 1.0의 정의·승인·실제 결과를 보존하고 선행 자료로 참조한다. 프로젝트 진행 상태·승인 열거값과 14단계·8역할·15스킬·네 검토는 그대로다.
scene-director가 08에서 대표 Scene의 상태·카메라·해설 목적을 정하고 visual-designer/video-editor의 해당 책임으로 제한된 대표 .blend·PNG·짧은 MP4를 준비한다. 결과는 08 preview와 같은 버전 review.md에 기록한다. 이 예외는 scenes 승인 전에 품질을 검토하는 근거 제작만 허용한다. 정식 10–12를 준비·완료했다고 기록하거나 전체 제작을 진행하지 않는다. 대표 샘플의 실제 파일과 단순 배경·부드러운 조명·선명한 형태·변화 판독성을 07/08/09의 같은 scenes 검토에서 제시한다. 추가 승인 지점은 없다. 샘플이나 장면 변경은 08 새 버전에 기록하고 영향을 받는 09와 승인 묶음을 갱신한다.
새 08·09 승인 후 10은 모델·재질·고정 자산을, 11은 컷별 Scene·끝점 상태와 PNG를, 12는 같은 원본·Scene·RenderProfile에서 [start_frame,end_frame) 구간을 렌더링한다. 같은 컷 start/end는 같은 Scene이며 continuous는 실제 경계 상태가 같아야 한다. cut 경계는 Scene을 구별하고 캐시에 Scene 이름을 포함한다. 12의 프레임 수×1000/fps는 요청·실측 컷 길이와 일치해야 한다. PNG 끝점 평가와 MP4의 마지막 프레임은 서로 다른 개념이므로 마지막 화면 확인 근거를 구분한다.
.blend의 실제 존재·SHA-256·Blender 버전·재열기·오브젝트/컬렉션·Scene 이름 검증은 PNG/MP4 디코딩·해상도·길이·상태 검증과 별개다. 설치 정보만으로 verified=true를 기록하지 않는다. 실패·부분 결과를 보존하고 필요한 단계로 돌아간다. 실제 최종 영상의 전체 재생·청취가 없으면 검수 해당 영역은 not_checked다.
[계약 회귀 검사](../contracts/check.py)는 1.0/1.1 구조와 일부 참조·완료 조건을 검사한다. semantic_errors(data,context,complete=False)는 구조 검사 후 사용하며 context는 실제 사용 버전의 {stage_id:artifact}다. 1.0은 이 함수의 추가 의미 검사 대상이 아니다. 전체 파일·사용자 승인 진위·화면 품질 검증을 수행하지 않는다. 반환한 문제 목록이 비었어도 전체 단계 완료 증거를 대신하지 않는다.
+12
View File
@@ -0,0 +1,12 @@
{
"schema_version": "1.1",
"project_id": "contract-example",
"stage_id": "08-scenes",
"revision": 1,
"inputs": [],
"payload": {
"scenes": [],
"connections": [],
"preview": null
}
}
@@ -0,0 +1,31 @@
{
"schema_version": "1.1",
"project_id": "contract-example",
"stage_id": "10-references",
"revision": 1,
"inputs": [],
"payload": {
"request": {
"engine": "Blender",
"style_reference": "references/1.png",
"render_profile": {
"engine": "BLENDER_EEVEE",
"device": "GPU",
"fps": 50,
"width": 1080,
"height": 1920,
"samples": 32,
"seed": 1
},
"items": [
{
"id": "water",
"kind": "object",
"entity_id": null,
"prompt": "물방울"
}
]
},
"result": null
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"schema_version": "1.1",
"project_id": "contract-example",
"stage_id": "11-images",
"revision": 1,
"inputs": [],
"payload": {
"request": {
"engine": "Blender",
"scene_path": "examples/blender-contract/preview.blend",
"render_profile": {
"engine": "BLENDER_EEVEE",
"device": "GPU",
"fps": 50,
"width": 1080,
"height": 1920,
"samples": 32,
"seed": 1
},
"frames": [
{
"id": "c1-start",
"cut_id": "c1",
"position": "start",
"prompt": "상태 설명",
"reference_ids": [
"water"
],
"scene_name": "Water",
"frame_index": 0
},
{
"id": "c1-end",
"cut_id": "c1",
"position": "end",
"prompt": "상태 설명",
"reference_ids": [
"water"
],
"scene_name": "Water",
"frame_index": 200
}
]
},
"result": null
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"schema_version": "1.1",
"project_id": "contract-example",
"stage_id": "12-motion",
"revision": 1,
"inputs": [],
"payload": {
"request": {
"engine": "Blender",
"scene_path": "examples/blender-contract/preview.blend",
"render_profile": {
"engine": "BLENDER_EEVEE",
"device": "GPU",
"fps": 50,
"width": 1080,
"height": 1920,
"samples": 32,
"seed": 1
},
"clips": [
{
"cut_id": "c1",
"start_frame_id": "c1-start",
"end_frame_id": "c1-end",
"duration_ms": 4000,
"motion_prompt": "둥글게",
"scene_name": "Water",
"start_frame": 0,
"end_frame": 200
}
]
},
"result": null
}
}
+7
View File
@@ -0,0 +1,7 @@
# Blender 1.1 계약 요청 예제
이 폴더의 네 JSON은 구조를 보여주는 요청 예제다. 실제 .blend·PNG·MP4·사용자 승인·완료 결과가 없으며 scene_path는 생성 예정 경로다. 08 preview=null, 10–12 result=null을 유지한다. 이전 [1.0 연결 예제](../water-cycle/README.md)는 변환하지 않는다.
실제 제작에서는 같은 제작물의 07 실측 컷·08 장면·09 요소와 승인 버전을 연결한다. 이 예제의 c1과 0–4000ms·Water Scene·50fps·200 프레임은 설명용 값이며 실제 내레이션 측정이 아니다. 프레임은 [0,200)이고 끝점 PNG는 논리 200을 평가한다. Blender 실제 번호는 논리 프레임+1이다.
[전달 계약](../../docs/contracts.md)과 [공통 완료 기준](../../docs/workflow-contracts.md)을 따른다. contracts/check.py는 예제와 빈 양식·기존 1.0 제작을 구조 검사하고 일부 부정·참조·미완료 사례를 실행한다. 파일 재열기·렌더링·화면·청취·사용자 승인 성공을 증명하지 않는다.
@@ -0,0 +1 @@
/__pycache__/
@@ -0,0 +1,6 @@
# 물 소재 3D 제작 경계
- 이 폴더는 물 영상의 대표 장면과 승인된 9개 정식 장면을 재현하는 bpy 코드다. 범용 실행기는 `tools/blender/run.py`를 사용하고 수정하지 않는다.
- 대표 원본·렌더는 해당 새 08 버전, 정식 원본·렌더는 자기 `10–12/r2` 아래에 둔다. 이전 결과·WAV·사용자 스타일 원본·project.json은 변경하지 않는다.
- 전체 Interface 범위의 평가 mesh 정점·물 경계·화면 투영 포함과 실제 O1H2·V자·두 종류 결합·동적 이웃·닫힌 mesh 부피와 `--disable-autoexec` 평가를 검사한다. 물리 시뮬레이션으로 주장하지 않는다.
- draft 품질 조정은 별도 렌더 시도를 보존한다. 제출 후 결과 변경은 새 revision으로 진행하며 새 scenes 승인 전 정식 10–12를 실행하지 않는다. 승인 후에도 총괄의 실제 10/11 완료 기록에 맞춰 다음 렌더를 진행한다.
@@ -0,0 +1,31 @@
# 물 영상의 3D 제작
## 제한된 시제품 기록
`build_preview.py`는 BondPair·Interface·DropletStretch 세 Scene만 만든다. 불규칙한 물 분자, 실제 연결 쌍 변화, 내부/표면 강조, 같은 부피의 3D 변형을 구현한 설명 모형이다. MD·유체 시뮬레이션은 아니다. 31컷 전체 모델·최종 영상은 scenes 승인 뒤 별도 제작한다.
승인·제출된 08 r3에서 아래 시제품 작성 명령을 다시 실행하지 않는다. `build_preview.py`의 top-level 실행은 원본을 덮어쓰므로 정식 제작에서 import하지 않는다. 저장소 루트에서 기존 설치를 사용한다. Blender 프레임은 논리 프레임+1이며 MP4는 `[start,end)`다. 대표 영상은 c07 4040ms/202프레임, c16 4880ms/244프레임, c18 4000ms/200프레임의 무음 영상이다. 끝 PNG는 exclusive 경계이며 MP4 마지막 프레임보다 20ms 뒤다.
```powershell
& 'C:/Program Files/Blender Foundation/Blender 5.2/blender.exe' --background --disable-autoexec --python-exit-code 1 --python projects/water-hydrogen-bond-surface-tension/blender/build_preview.py
& 'C:/Users/baram/anaconda3/python.exe' -B -X utf8 projects/water-hydrogen-bond-surface-tension/blender/test_model.py
& 'C:/Users/baram/anaconda3/python.exe' -B -X utf8 projects/water-hydrogen-bond-surface-tension/blender/prepare_artifacts.py
& 'C:/Users/baram/anaconda3/python.exe' -B -X utf8 projects/water-hydrogen-bond-surface-tension/blender/render_preview.py --kind image
& 'C:/Users/baram/anaconda3/python.exe' -B -X utf8 projects/water-hydrogen-bond-surface-tension/blender/render_preview.py --kind video
& 'C:/Users/baram/anaconda3/python.exe' -B -X utf8 projects/water-hydrogen-bond-surface-tension/blender/verify_design.py
```
작성 순서는 새 draft 재현용이다. `prepare_artifacts.py`는 대표 미디어가 빈 08/09 초안을 작성하므로 제출한 r3에서 재실행하지 않는다. `render_preview.py`는 현재 초안에 실제 증거만 추가한다. 완료된 결과는 새 버전에서 작업한다.
`test_model.py`는 없는 파일·Scene 누락·H 원자 누락·부피 driver 오류·원자 중심은 유지하고 mesh 정점만 물 밖에 둔 fixture·기존 r2 원본의 실제 RED와 수정 원본 GREEN을 확인한다. 포함 관계 실패 문구도 검사·기록한다. Interface 전체 논리 F2468–3172의 실제 원자·내부 실선·활성 점선 mesh 정점을 실제 물 mesh 경계와 camera 투영 윤곽에 대조한다. 최소 축 여백 0.03을 요구한다. 물 모형은 앞면·윗면이 실제 열린 절개이며 벽·고체 껍질이 아니다. 부피는 시작·중간 세 곳·전체 컷 끝을 실제 mesh로 평가한다. 실제 렌더는 기존 실행기의 source hash·Scene·profile·frame 검사, PNG/MP4 probe 및 전체 디코드를 거친다. 큰 원본·렌더·실패 시도는 해당 r3의 좁은 `.gitignore`로 보존하고 작은 검증 JSON에 복사할 파일·해시를 기록한다.
## 승인 후 정식 10–12 제작
`build_production.py --stage references`는 승인된 08 원본을 읽기만 하여 10의 실제 기준 모델을 만든다. 실제 7개 기준 PNG를 검증하고 총괄이 10 완료를 기록한 뒤 `--stage images`로 10 원본을 열어 11의 9개 Scene 애니메이션 원본을 만든다. 11은 62개 frame ID와 continuous 경계의 공유 PNG를 검증하고 총괄이 11 완료를 기록한 다음 12의 31개 전체 컷을 렌더한다. 정식 원본은 이미 존재하면 덮어쓰지 않는다.
`production_requests.py --stage <단계>` → `production_render.py --stage <단계>` → `production_check.py --stage <단계>`가 실제 요청·GPU 직렬 렌더·계약/해시 대조 경로다. `production_test.py --stage references|images`는 실제 누락/중심 내부·정점 외부 fixture의 RED와 원본 GREEN을 기록한다. `production_views.py --stage 11-images|12-motion`은 실제 끝점과 MP4 디코드의 시작·중간·마지막을 모바일 크기로 비교한다. 정지 유지 구간은 설명 맥락으로 판단한다.
`export_anchors.py`는 11 실제 원본의 1080×1920 camera 좌표·전체 31컷 target/bbox·필요한 매 프레임 라벨 키를 내보낸다. label_pixel은 배치 시작점이며 합성 단계에서 실제 글자·자막·충돌을 확인한다. 기존 2D 색 bbox나 945×1680 좌표를 재사용하지 않는다. 모든 최종 파일·SHA는 각 단계 manifest.json으로 전달한다.
`production_preserve.py`는 승인 실물 목록의 SHA와 고정 커밋의 작업 사본 체크아웃 바이트를 대조한다. 기존 WAV·스타일·01–09 및 이전 r1 결과를 수정하지 않았는지 확인한다.
@@ -0,0 +1,218 @@
"""물 소재의 세 대표 3D 장면만 작성한다. 물리 시뮬레이션이 아니다."""
from pathlib import Path
import hashlib
import math
import shutil
import bpy
from mathutils import Vector
ROOT = Path(__file__).resolve().parents[3]
OUT = ROOT / "projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3/generated-source/preview.blend"
if OUT.exists():
history=OUT.parent/'history'
history.mkdir(exist_ok=True)
snapshot=history/(hashlib.sha256(OUT.read_bytes()).hexdigest()+'.blend')
if not snapshot.exists():
shutil.copy2(OUT,snapshot)
bpy.ops.wm.read_factory_settings(use_empty=True)
def material(name, color, roughness=.48, metallic=0):
mat = bpy.data.materials.new(name)
mat.diffuse_color = (*color, 1)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (*color, 1)
bsdf.inputs["Roughness"].default_value = roughness
bsdf.inputs["Metallic"].default_value = metallic
return mat
O = material("O_Coral", (.74, .16, .11))
H = material("H_Ivory", (.91, .84, .64))
SOLID = material("OH_Navy", (.025, .06, .13))
DASH = material("HO_Gold", (.83, .47, .045), .4)
WATER = material("Water_Blue", (.06, .42, .66), .25, .08)
CREAM = material("Studio_Cream", (.78, .73, .62), .8)
GHOST = material("Water_Concept_Cutaway", (.25, .62, .76), .7)
BACKDROP = bpy.data.materials.new('Cream_Emission_Backdrop')
BACKDROP.use_nodes = True
nodes = BACKDROP.node_tree.nodes
nodes.clear()
emission = nodes.new('ShaderNodeEmission')
emission.inputs['Color'].default_value = (.88,.82,.70,1)
output = nodes.new('ShaderNodeOutputMaterial')
BACKDROP.node_tree.links.new(emission.outputs[0],output.inputs['Surface'])
def point(obj, target):
obj.rotation_euler = (Vector(target) - obj.location).to_track_quat('-Z', 'Y').to_euler()
def scene(name, bounds, camera_position, scale):
s = bpy.context.scene if len(bpy.data.scenes) == 1 and not bpy.context.scene.objects else bpy.data.scenes.new(name)
s.name = name
bpy.context.window.scene = s
s.frame_start, s.frame_end = bounds
s.render.engine = "BLENDER_EEVEE"
s.render.fps = 50
s.render.resolution_x, s.render.resolution_y = 1080, 1920
s.render.resolution_percentage = 100
s.eevee.taa_render_samples = 64
s.view_settings.view_transform = 'Standard'
world = bpy.data.worlds.new(name + "World")
world.use_nodes = True
world.node_tree.nodes['Background'].inputs[0].default_value = (.78, .74, .65, 1)
world.node_tree.nodes['Background'].inputs[1].default_value = .3
s.world = world
cam = bpy.data.objects.new(name+"Camera", bpy.data.cameras.new(name+"Camera"))
s.collection.objects.link(cam)
cam.location = camera_position
point(cam, (0, 0, 0))
cam.data.type = 'ORTHO'
cam.data.ortho_scale = scale
s.camera = cam
bpy.ops.mesh.primitive_plane_add(size=30,location=-cam.location.normalized()*6)
backdrop=bpy.context.object
backdrop.name=name+'CreamBackdrop'
backdrop.rotation_euler=cam.rotation_euler
backdrop.data.materials.append(BACKDROP)
backdrop['role']='background'
for label, location, energy, size in (("Key", (-4,-6,7), 250, 5), ("Fill", (4,-2,3), 150, 4), ("Rim", (0,4,5), 200, 4)):
light = bpy.data.objects.new(name+label, bpy.data.lights.new(name+label, 'AREA'))
light.location = location
light.data.energy, light.data.shape, light.data.size = energy, 'DISK', size
point(light, (0,0,0))
s.collection.objects.link(light)
return s
def sphere(name, position, radius, mat, parent=None, segments=32, rings=16):
bpy.ops.mesh.primitive_uv_sphere_add(segments=segments, ring_count=rings, radius=radius, location=position)
obj = bpy.context.object
obj.name = name
obj.data.materials.append(mat)
for polygon in obj.data.polygons:
polygon.use_smooth = True
obj.parent = parent
return obj
def cylinder(name, a, b, radius, mat, parent=None):
a, b = Vector(a), Vector(b)
bpy.ops.mesh.primitive_cylinder_add(vertices=16, radius=radius, depth=(b-a).length, location=(a+b)/2)
obj = bpy.context.object
obj.name = name
obj.rotation_euler = (b-a).to_track_quat('Z','Y').to_euler()
obj.data.materials.append(mat)
obj.parent = parent
for polygon in obj.data.polygons:
polygon.use_smooth = True
return obj
def molecule(name, location, rotation=0, size=1):
root = bpy.data.objects.new(name, None)
bpy.context.scene.collection.objects.link(root)
root.location = location
root.rotation_euler.z = rotation
root.scale = (size,)*3
root['role'] = 'molecule'
atoms = []
for suffix, pos, radius, mat, element in (("O", (0,0,0), .36,O,'O'), ("H1", (.72,0,.52), .23,H,'H'), ("H2",(-.72,0,.52), .23,H,'H')):
atom = sphere(name+"_"+suffix,pos,radius,mat,root)
atom['role'], atom['element'] = 'atom', element
atoms.append(atom)
for i, h in enumerate(atoms[1:]):
bond = cylinder(name+"_OH"+str(i),(0,0,0),h.location,.065,SOLID,root)
bond['role'] = 'covalent'
return root, atoms
def dash(name, hydrogen, oxygen, frames=None):
# 점선의 끝점은 실제 서로 다른 분자의 H/O 월드 좌표다.
scene = bpy.context.scene
keys = frames or [scene.frame_start]
segments = []
for i in range(6):
obj = cylinder(name+str(i),(0,0,0),(0,0,.1),.045,DASH)
obj['role'], obj['hydrogen'], obj['oxygen'] = 'hydrogen_bond', hydrogen.name, oxygen.name
segments.append(obj)
for frame in keys:
scene.frame_set(frame)
bpy.context.view_layer.update()
a, b = hydrogen.matrix_world.translation, oxygen.matrix_world.translation
direction = b-a
for i, obj in enumerate(segments):
t1, t2 = .18+i*.11, .18+i*.11+.062
obj.location = a+direction*((t1+t2)/2)
obj.rotation_euler = direction.to_track_quat('Z','Y').to_euler()
obj.scale.z = direction.length*(t2-t1)/.1
for path in ('location','rotation_euler','scale'):
obj.keyframe_insert(path,frame=frame)
return segments
pair = scene("BondPair", (905,1637), (0,-12,6), 11.5)
a, aa = molecule("PairA", (-1.8,0,-.3))
b, ba = molecule("PairB", (1.65,.15,.45), .3)
aa[1]['label'] = '한 분자의 Hδ+'
ba[0]['label'] = '이웃 분자의 Oδ−'
for frame, x in ((905,1.65),(1018,1.65),(1118,.85),(1220,.85),(1637,.85)):
b.location.x = x
b.keyframe_insert('location',frame=frame)
dash("Pair_HO_Dash_", aa[1], ba[0], [905,1018,1118,1220,1637])
interface = scene("Interface", (2469,3173), (0,-14,6), 13)
# 실제 물 영역 전체를 표시하되 앞면·윗면을 열어 내부 분자를 보이는 절개 설명 모형.
low, high = (-2.85,-.85,-3.15), (2.85,1.35,.80)
vertices=[(x,y,z) for z in (low[2],high[2]) for y in (low[1],high[1]) for x in (low[0],high[0])]
mesh=bpy.data.meshes.new('WaterCutawayMesh')
mesh.from_pydata(vertices,[],[(0,2,3,1),(2,6,7,3),(0,4,6,2),(1,3,7,5)])
mesh.update()
body=bpy.data.objects.new('WaterCutawayBody',mesh)
interface.collection.objects.link(body)
body.data.materials.append(GHOST)
body['role']='cutaway_water'
body['representation']='앞면·윗면이 열린 물 체적의 설명용 절개'
positions = [(-1.8,-.1,.25),(.4,.2,.18),(1.95,.45,.25),(-2,.3,-1.5),(-.65,.15,-1.15),(.9,.3,-1.6),(2,.35,-1.3),(-1.6,.3,-2.5),(.2,.4,-2.45),(1.6,.1,-2.6)]
mols=[]
for i, pos in enumerate(positions):
root, atoms = molecule('Liquid'+str(i),pos,(-.4+i*.37),.58)
mols.append((root,atoms))
for frame, delta in ((2469,0),(2807,0),(2907,.10*math.sin(i+1)),(3051,.05*math.cos(i)),(3173,0)):
root.location.x = pos[0]+delta
root.keyframe_insert('location',frame=frame)
for i,j in ((0,1),(1,2),(3,4),(3,7),(4,5),(4,7),(5,6),(5,8),(8,9)):
segments = dash('LiquidDash'+str(i)+'_'+str(j)+'_',mols[i][1][1],mols[j][1][0],[2469,2807,2907,3051,3173])
if (i,j) in ((3,4),(3,7)):
for segment in segments:
for frame, active in ((2469, j==4),(2807,j==4),(2907,j==7),(3051,j==7),(3173,j==7)):
segment.scale.x = segment.scale.y = 1 if active else .001
segment.keyframe_insert('scale',frame=frame)
mols[4][0]['label']='물 안쪽'
mols[1][0]['label']='물과 공기의 경계'
for name, center, frames in (("InteriorFocus",positions[4],((2469,.01),(2807,1),(2857,.01))), ("SurfaceFocus",positions[1],((2807,.01),(2907,1),(3173,1)))):
bpy.ops.mesh.primitive_torus_add(major_segments=48, minor_segments=10, location=(center[0],-.32,center[2]), rotation=(math.pi/2,0,0), major_radius=.74, minor_radius=.033)
ring=bpy.context.object
ring.name=name
ring.data.materials.append(DASH)
ring['role']='explanatory_highlight'
for frame, scale in frames:
ring.scale=(scale,)*3
ring.keyframe_insert('scale',frame=frame)
stretch = scene("DropletStretch", (3173,4077), (0,-12,5), 10.5)
drop=sphere('Droplet',(0,0,0),1.3,WATER,segments=128,rings=64)
drop['role']='same_volume_water'
drop['label']='같은 물의 양 · 설명용 모형'
# 단순 driver는 --disable-autoexec 상태에서도 평가된다. 매 프레임 det(scale)=1.
expression="1+0.85*min(max((frame-3173)/200,0),1)"
drop.driver_add('scale',0).driver.expression=expression
for axis in (1,2):
drop.driver_add('scale',axis).driver.expression="("+expression+")**(-0.5)"
OUT.parent.mkdir(parents=True,exist_ok=True)
for s in bpy.data.scenes:
s.frame_set(s.frame_start)
bpy.ops.wm.save_as_mainfile(filepath=str(OUT))
print('PREVIEW_SOURCE',OUT)
@@ -0,0 +1,253 @@
"""승인된 물 소재 자산을 복사해 정식 기준 원본과 애니메이션 원본을 만든다."""
import argparse
import hashlib
import json
import math
from pathlib import Path
import sys
import bpy
from mathutils import Vector
ROOT=Path(__file__).resolve().parents[3]
PROJECT=ROOT/'projects/water-hydrogen-bond-surface-tension'
APPROVED=PROJECT/'stages/08-scenes/r3/generated-source/preview.blend'
APPROVED_HASH='ba0850a2148824cc8416704c03f14e5714ca01a16620a4eb26981e238af78c73'
parser=argparse.ArgumentParser()
parser.add_argument('--stage',choices=['references','images'],required=True)
args=parser.parse_args(sys.argv[sys.argv.index('--')+1:])
def clone(obj,name,scene,parent=None):
result=obj.copy()
if obj.data:
result.data=obj.data.copy()
result.name=name
result.animation_data_clear()
result.parent=parent
scene.collection.objects.link(result)
return result
def studio(name,base,bounds,scale):
scene=bpy.data.scenes.new(name)
scene.world=base.world
scene.render.engine='BLENDER_EEVEE'
scene.render.fps=50
scene.render.resolution_x,scene.render.resolution_y=1080,1920
scene.eevee.taa_render_samples=64
scene.view_settings.view_transform='Standard'
for obj in base.objects:
if obj.type in ('CAMERA','LIGHT') or obj.get('role')=='background':
item=clone(obj,name+obj.type+obj.name,scene)
if item.type=='CAMERA':
scene.camera=item
item.data.ortho_scale=scale
scene.frame_start,scene.frame_end=bounds
return scene
def molecule(scene,name,location,size=1,rotation=0):
template=bpy.data.objects['PairA']
root=clone(template,name,scene)
root.location=location
root.rotation_euler=(0,0,rotation)
root.scale=(size,)*3
for child in template.children:
clone(child,name+child.name[len('PairA'):],scene,root)
return root
def droplet(scene,name,location,scale):
obj=clone(bpy.data.objects['Droplet'],name,scene)
obj.location=location
obj.scale=scale
return obj
def anchor(scene,name,location,label):
obj=bpy.data.objects.new(name,None)
scene.collection.objects.link(obj)
obj.location=location
obj['anchor']=label
return obj
def mark(obj,label):
obj['anchor']=label
def collection(name,objects):
group=bpy.data.collections.new(name)
group.use_fake_user=True
for obj in objects:
group.objects.link(obj)
return group
def animate_network(scene,prefix,start,turn,end):
# 온전한 분자를 움직이고 H···O 원통을 실제 원자 위치로 갱신한다.
roots=[scene.objects[prefix+str(i)] for i in range(10)]
for i,root in enumerate(roots):
position=root.location.copy()
for frame,delta in [(start,0),(turn,.09*math.sin(i+1)),(end,.06*math.cos(i))]:
root.location=position+Vector((delta,0,0))
root.keyframe_insert('location',frame=frame)
template=bpy.data.objects['LiquidDash0_1_0']
pairs=[(0,1),(1,2),(3,4),(3,7),(4,5),(4,6),(4,7),(5,6),(5,8),(8,9)]
for i,j in pairs:
hydrogen,oxygen=scene.objects[prefix+str(i)+'_H1'],scene.objects[prefix+str(j)+'_O']
for segment in range(6):
obj=clone(template,prefix+'Dash'+str(i)+'_'+str(j)+'_'+str(segment),scene)
obj['hydrogen'],obj['oxygen']=hydrogen.name,oxygen.name
for frame in [start,1779,turn,2257,end]:
scene.frame_set(frame)
bpy.context.view_layer.update()
a,b=hydrogen.matrix_world.translation,oxygen.matrix_world.translation
direction=b-a
obj.location=a+direction*(.18+segment*.11+.031)
obj.rotation_euler=direction.to_track_quat('Z','Y').to_euler()
active=not ((i,j)==(3,4) and frame>=turn) and not ((i,j)==(3,7) and frame<turn) and not ((i,j)==(4,5) and frame>=2257) and not ((i,j)==(4,6) and frame<2257)
obj.scale=(1 if active else .001,1 if active else .001,direction.length*.062/.1)
for path in ['location','rotation_euler','scale']:
obj.keyframe_insert(path,frame=frame)
if args.stage=='references':
assert hashlib.sha256(APPROVED.read_bytes()).hexdigest()==APPROVED_HASH, '승인 원본 해시 불일치'
bpy.ops.wm.open_mainfile(filepath=str(APPROVED))
pair=bpy.data.scenes['BondPair']
drop=bpy.data.scenes['DropletStretch']
interface=bpy.data.scenes['Interface']
opening=studio('OpeningDroplet',drop,(1,397),10.5)
obj=droplet(opening,'OpeningWater',(0,0,0),(1,1,1))
mark(obj,'opening_drop')
anchor(opening,'OpeningMoleculeInset',(1.65,0,1.7),'molecule_inset')
single=studio('SingleMolecule',pair,(397,905),9)
molecule(single,'SingleWater',(0,0,-.4),1.8)
for suffix,label in [('O','single_O_delta_minus'),('H1','single_H_delta_plus'),('H2','single_H2_delta_plus')]:
mark(single.objects['SingleWater_'+suffix],label)
network=studio('LiquidNetwork',interface,(1637,2469),13)
for i in range(10):
old=interface.objects['Liquid'+str(i)]
molecule(network,'Network'+str(i),(old.location.x,old.location.y,old.location.z+1.3),.58,old.rotation_euler.z)
mark(network.objects['Network4'],'network_interior')
comparison=studio('EqualVolumeComparison',drop,(4077,4370),12.5)
size=.72
droplet(comparison,'ComparisonLong',(-1.65,0,0),(size*1.65,size/math.sqrt(1.65),size/math.sqrt(1.65)))
droplet(comparison,'ComparisonRound',(1.65,0,0),(size,)*3)
mark(comparison.objects['ComparisonLong'],'equal_volume_left')
mark(comparison.objects['ComparisonRound'],'equal_volume_right')
conditions=studio('ShapeConditions',drop,(4370,4889),12.5)
droplet(conditions,'SmallFreeDrop',(-1.65,0,.2),(.58,)*3)
# 큰 접촉 방울은 같은 부피 비교가 아닌 별도 조건 모형이다.
contact=droplet(conditions,'LargeContactDrop',(1.55,0,-.275),(1.02,1.02,.75/1.3))
mark(conditions.objects['SmallFreeDrop'],'small_free_condition')
mark(contact,'large_contact_condition')
bpy.context.window.scene=conditions
bpy.ops.mesh.primitive_cube_add(size=1,location=(1.55,0,-1.15))
floor=bpy.context.object
floor.name='ContactFloor'
floor.scale=(3.1,2.5,.25)
floor_material=bpy.data.materials.new('ContactFloorCream')
floor_material.diffuse_color=(.78,.73,.62,1)
floor_material.use_nodes=True
floor_material.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.78,.73,.62,1)
floor_material.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8
floor.data.materials.append(floor_material)
floor['role']='condition_floor'
mark(floor,'contact_floor')
answer=studio('AnswerDroplet',drop,(4889,5529),12.5)
droplet(answer,'AnswerWater',(0,0,-.7),(1,)*3)
mark(answer.objects['AnswerWater'],'answer_macro_drop')
for i,position in enumerate([(-1.1,0,1.8),(0,.1,2.05),(1.1,0,1.8)]):
molecule(answer,'AnswerMolecule'+str(i),position,.55,i*.2)
mark(answer.objects['AnswerMolecule0_H1'],'answer_inset_H')
mark(answer.objects['AnswerMolecule1_O'],'answer_inset_O')
mark(answer.objects['AnswerMolecule2'],'answer_inset_extent')
mark(pair.objects['PairA_H1'],'pair_H_delta_plus')
mark(pair.objects['PairB_O'],'pair_O_delta_minus')
mark(interface.objects['Liquid4'],'water_interior')
mark(interface.objects['Liquid1'],'water_surface')
anchor(interface,'InterfaceAirAnchor',(0,0,1.45),'air_region')
anchor(interface,'InterfaceWaterAnchor',(0,0,-2.95),'cutaway_water_region')
mark(drop.objects['Droplet'],'same_volume_drop')
collection('StudioAssets',[o for s in bpy.data.scenes for o in s.objects if o.get('role')=='background'])
collection('StyleAssets',[o for s in bpy.data.scenes for o in s.objects if o.get('role')=='background'])
collection('DropletStudioAssets',[o for s in bpy.data.scenes if s.name in ['OpeningDroplet','DropletStretch','AnswerDroplet'] for o in s.objects if o.get('role')=='background'])
collection('MoleculeStudioAssets',[o for s in bpy.data.scenes if s.name in ['SingleMolecule','BondPair','LiquidNetwork'] for o in s.objects if o.get('role')=='background'])
collection('ComparisonStudioAssets',[o for s in bpy.data.scenes if s.name in ['EqualVolumeComparison','ShapeConditions'] for o in s.objects if o.get('role')=='background'])
collection('MoleculeAssets',[o for o in bpy.data.objects if o.get('role') in ('molecule','atom','covalent','hydrogen_bond')])
collection('DropletAssets',[o for o in bpy.data.objects if o.get('role')=='same_volume_water'])
collection('InterfaceAssets',[interface.objects['WaterCutawayBody']])
# 실제 기준 원본에도 액체 연결 자산을 구성하여 후속 원본이 새 경로만 참조하지 않게 한다.
bpy.context.window.scene=network
animate_network(network,'Network',1637,2019,2469)
bpy.context.window.scene=answer
# 답 회수에는 거시 방울과 공간적으로 분리된 분자 개념도가 공존한다.
for i,j in [(0,1),(1,2)]:
template=bpy.data.objects['LiquidDash0_1_0']
h,o=answer.objects['AnswerMolecule'+str(i)+'_H1'],answer.objects['AnswerMolecule'+str(j)+'_O']
bpy.context.view_layer.update()
a,b=h.matrix_world.translation,o.matrix_world.translation
for n in range(6):
obj=clone(template,'AnswerDash'+str(i)+'_'+str(j)+'_'+str(n),answer)
obj['hydrogen'],obj['oxygen']=h.name,o.name
direction=b-a
obj.location=a+direction*(.18+n*.11+.031)
obj.rotation_euler=direction.to_track_quat('Z','Y').to_euler()
obj.scale=(1,1,direction.length*.062/.1)
for scene in bpy.data.scenes:
scene['approved_source_sha256']=APPROVED_HASH
destination=PROJECT/'stages/10-references/r2/generated-source/references.blend'
else:
artifact=json.loads((PROJECT/'stages/10-references/r2/artifact.json').read_text(encoding='utf-8'))
result=artifact['payload']['result']
source=ROOT/result['scene_file']['path']
assert result['scene_file']['verified'] and hashlib.sha256(source.read_bytes()).hexdigest()==result['scene_file']['sha256']
assert len(result['items'])==7 and all((ROOT/item['media']['path']).is_file() and item['media']['verified'] for item in result['items'])
bpy.ops.wm.open_mainfile(filepath=str(source))
for scene in bpy.data.scenes:
scene['reference_source_sha256']=result['scene_file']['sha256']
drop=bpy.data.objects['Droplet']
expression='1+0.85*min(max((frame-3173)/200,0),1)*(1-min(max((frame-3657)/196,0),1))'
drop.animation_data.drivers[0].driver.expression=expression
for axis in [1,2]:
drop.animation_data.drivers[axis].driver.expression='('+expression+')**(-0.5)'
# 액체 점선의 위치·방향을 프레임마다 실제 H/O 평가 위치에 맞춰 굽는다.
network=bpy.data.scenes['LiquidNetwork']
bpy.context.window.scene=network
segments=[obj for obj in network.objects if obj.get('role')=='hydrogen_bond']
for obj in segments:
obj.animation_data_clear()
for frame in range(1637,2470):
network.frame_set(frame)
bpy.context.view_layer.update()
for obj in segments:
h,o=network.objects[obj['hydrogen']],network.objects[obj['oxygen']]
a,b=h.matrix_world.translation,o.matrix_world.translation
direction=b-a
segment=int(obj.name.rsplit('_',1)[1])
obj.location=a+direction*(.18+segment*.11+.031)
obj.rotation_euler=direction.to_track_quat('Z','Y').to_euler()
pair=(h.parent.name,o.parent.name)
first=min(max((frame-1779)/240,0),1)
second=min(max((frame-2019)/238,0),1)
weight={('Network3','Network4'):1-first,('Network3','Network7'):first,('Network4','Network5'):1-second,('Network4','Network6'):second}.get(pair,1)
obj.scale=(max(weight,.001),max(weight,.001),direction.length*.062/.1)
for path in ['location','rotation_euler','scale']:
obj.keyframe_insert(path,frame=frame)
# 답의 수소결합은 영구 끈이 아니며 연결이 약해졌다가 다시 나타난다.
answer=bpy.data.scenes['AnswerDroplet']
for obj in answer.objects:
if obj.get('role')=='hydrogen_bond':
original=obj.scale.z
for frame,active in [(4889,1),(5100,1),(5160,.001),(5229,1),(5529,1)]:
obj.scale=(active,active,original)
obj.keyframe_insert('scale',frame=frame)
destination=PROJECT/'stages/11-images/r2/generated-source/animation.blend'
for scene in bpy.data.scenes:
bpy.context.window.scene=scene
scene.frame_set(scene.frame_start)
destination.parent.mkdir(parents=True,exist_ok=True)
assert not destination.exists(), '제출 원본 덮어쓰기 금지'
bpy.ops.wm.save_as_mainfile(filepath=str(destination))
print('정식 원본 저장',destination)
@@ -0,0 +1,85 @@
"""1080×1920 실제 카메라 투영으로 T5 주석 위치와 필요한 매 프레임 키를 내보낸다."""
import argparse
import hashlib
import json
from pathlib import Path
import sys
import bpy
import numpy as np
from mathutils import Vector
from bpy_extras.object_utils import world_to_camera_view
ROOT=Path(__file__).resolve().parents[3]
PROJECT=ROOT/'projects/water-hydrogen-bond-surface-tension'
parser=argparse.ArgumentParser()
parser.add_argument('--source',required=True)
parser.add_argument('--out',required=True)
args=parser.parse_args(sys.argv[sys.argv.index('--')+1:])
source=Path(args.source).resolve()
bpy.ops.wm.open_mainfile(filepath=str(source))
cuts=json.loads((PROJECT/'stages/07-cuts/r1/artifact.json').read_text(encoding='utf-8'))['payload']['cuts']
plans=json.loads((PROJECT/'stages/08-scenes/r3/artifact.json').read_text(encoding='utf-8'))['payload']['scenes']
cache={}
def bbox_objects(obj):
return [obj] if obj.type=='MESH' else [child for child in obj.children if child.type=='MESH']
def coordinates(obj,deps):
if obj.name not in cache:
assert not obj.modifiers and not obj.data.shape_keys, '정점 캐시가 불가능한 변형 mesh'
evaluated=obj.evaluated_get(deps)
mesh=evaluated.to_mesh()
cache[obj.name]=np.array([tuple(v.co) for v in mesh.vertices])
evaluated.to_mesh_clear()
matrix=np.array(obj.evaluated_get(deps).matrix_world)
return cache[obj.name] @ matrix[:3,:3].T + matrix[:3,3]
result=[]
for cut,plan in zip(cuts,plans):
scene=bpy.data.scenes[plan['start_state'].split(';')[0].split('=')[1]]
bpy.context.window.scene=scene
assert scene.camera.data.type=='ORTHO'
origin=world_to_camera_view(scene,scene.camera,Vector((0,0,0)))
projection=np.array([tuple(world_to_camera_view(scene,scene.camera,Vector(b))-origin)[:2] for b in [(1,0,0),(0,1,0),(0,0,1)]])
offset=np.array(tuple(origin)[:2])
objects=[obj for obj in scene.objects if obj.get('anchor')]
start,end=cut['start_ms']//20,cut['end_ms']//20
tracks={obj['anchor']:[] for obj in objects}
states=[]
for frame in range(start,end+1):
scene.frame_set(frame+1)
deps=bpy.context.evaluated_depsgraph_get()
labels={}
for obj in objects:
center=np.array(tuple(obj.matrix_world.translation)) @ projection + offset
target=[round(float(center[0]*1080),2),round(float((1-center[1])*1920),2)]
parts=bbox_objects(obj)
box=None
if parts:
vertices=np.vstack([coordinates(part,deps) for part in parts])
projected=vertices @ projection + offset
px=projected[:,0]*1080
py=(1-projected[:,1])*1920
box=[round(float(px.min()),2),round(float(py.min()),2),round(float(px.max()),2),round(float(py.max()),2)]
label=[round((box[0]+box[2])/2,2),round(box[1]-32,2)]
else:
label=target
assert 0<label[0]<1080 and 0<label[1]<1920, '주석 제안점 화면 밖'
labels[obj['anchor']]=dict(object_name=obj.name,target_pixel=target,label_pixel=label,bbox_px=box)
tracks[obj['anchor']].append([frame,*label,*target])
if frame in [start,(start+end)//2,end]:
states.append(dict(logical_frame=frame,blender_frame=frame+1,anchors=labels))
motion=[]
for label,values in tracks.items():
changing=any(v[1:]!=values[0][1:] for v in values[1:])
keys=[]
for index,value in enumerate(values):
if index in [0,len(values)-1] or value[1:]!=values[index-1][1:]:
keys.append(value)
motion.append(dict(anchor_id=label,animated=changing,columns=['logical_frame','label_x','label_y','target_x','target_y'],keys=keys if changing else [values[0],values[-1]]))
result.append(dict(cut_id=cut['id'],scene_name=scene.name,camera_name=scene.camera.name,start_frame=start,end_frame=end,states=states,motion=motion))
value=dict(source_path=source.relative_to(ROOT).as_posix(),source_sha256=hashlib.sha256(source.read_bytes()).hexdigest(),width=1080,height=1920,coordinate_system='top_left_pixel',logical_to_blender='logical_frame + 1',label_note='label_pixel은 실제 mesh 위 32px의 배치 시작점이다. 합성 단계에서 글자 크기·충돌·자막 여백을 실제 합성 화면으로 검수한다. bbox_px와 target_pixel은 실제 카메라 투영이다.',cuts=result)
Path(args.out).write_text(json.dumps(value,ensure_ascii=False,separators=(',',':'))+'\n',encoding='utf-8')
print('실제 카메라 주석 앵커 GREEN',len(result))
@@ -0,0 +1,29 @@
"""실제 영상의 시작·중간·끝 렌더를 모바일 크기로 나란히 보는 검사 보조물."""
import hashlib
import json
from pathlib import Path
from PIL import Image, ImageChops, ImageStat
ROOT=Path(__file__).resolve().parents[3]
BASE=ROOT/'projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3'
artifact=json.loads((BASE/'artifact.json').read_text(encoding='utf-8'))
results=[]
for sample in artifact['payload']['preview']['samples']:
if sample['media']['kind']!='video':
continue
folder=(ROOT/sample['media']['path']).parent
evidence=json.loads((folder/'evidence.json').read_text(encoding='utf-8'))
frames=evidence['logical_frames']
selection=[frames[0],frames[len(frames)//2],frames[-1]]
originals=[Image.open(folder/f'frame-{frame:08}.png').convert('RGB') for frame in selection]
differences=[ImageChops.difference(originals[0],image) for image in originals[1:]]
assert all(diff.getbbox() for diff in differences), '렌더 픽셀이 동일한 정지 클립'
sheet=Image.new('RGB',(1080,640))
for index,image in enumerate(originals):
sheet.paste(image.resize((360,640),Image.Resampling.LANCZOS),(index*360,0))
path=BASE/'generated-source'/f'mobile-{sample["cut_ids"][0]}.png'
sheet.save(path)
results.append(dict(cut_id=sample['cut_ids'][0],logical_frames=selection,pixel_sha256=[hashlib.sha256(image.tobytes()).hexdigest() for image in originals],mean_absolute_pixel_change=[sum(ImageStat.Stat(diff).mean)/3 for diff in differences],mobile_view_path=path.relative_to(ROOT).as_posix(),mobile_view_sha256=hashlib.sha256(path.read_bytes()).hexdigest(),visual_review='not_checked'))
assert len(results)==3, '세 대표 영상이 아직 없음'
(BASE/'motion-check.json').write_text(json.dumps(dict(result='GREEN',source_sha256=artifact['payload']['preview']['scene_file']['sha256'],views=results),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('MOTION_GREEN: 세 영상의 실제 시작/중간/끝 RGB 픽셀 변화')
@@ -0,0 +1,141 @@
"""실측 07을 보존하며 새 08/09의 검토 초안만 작성한다."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
PROJECT = ROOT / 'projects/water-hydrogen-bond-surface-tension'
DESCRIPTIONS = [
'작은 청색 물방울의 둥근 형태를 보여주며 그 모양이 생기는 이유를 질문한다.',
'거시적인 물방울에서 물속 분자 수준의 설명으로 관심을 옮긴다.',
'물 분자가 산소 하나와 수소 둘로 이루어진 온전한 V자 구조임을 보여준다.',
'산소 쪽의 부분 음전하를 강조하며 물 분자 전체가 음이온인 것으로 표현하지 않는다.',
'수소 쪽의 부분 양전하를 산소 쪽과 구별하며 분자 전체의 중성은 유지한다.',
'한 물 분자의 수소와 이웃 물 분자의 산소가 끌림의 양 끝이라는 관계를 보여준다.',
'두 온전한 분자가 가까워지며 분자 사이 H···O 점선 길이가 줄고 내부 O–H 실선은 유지된다.',
'서로 다른 분자의 H···O 사이 끌림에 수소결합이라는 이름을 연결한다.',
'한 분자 내부의 O–H 실선과 분자 사이의 H···O 점선을 동시에 보여 두 결합을 구별한다.',
'불규칙한 액체 분자 집단으로 범위를 넓혀 수소결합이 영구적인 끈은 아님을 설명한다.',
'온전한 분자들이 소폭 움직이며 일부 이웃 사이 연결이 사라지고 새 연결이 생기는 과정을 보여준다.',
'수소결합 연결이 반복해서 바뀌어 액체의 이웃 관계가 고정된 격자가 아님을 보여준다.',
'많은 분자 사이의 끌림이 물끼리 함께 뭉치려는 응집 성질에 기여함을 연결한다.',
'청색 물 체적과 위쪽 공기가 만나는 경계를 절개 설명 모형으로 제시한다.',
'물 안쪽 분자를 강조해 여러 방향에 다른 물 분자 이웃이 있다는 환경을 보여준다.',
'강조 대상을 물 안쪽에서 표면으로 옮겨 공기 방향에는 액체 물 분자 이웃이 적다는 차이를 보여준다.',
'내부와 표면의 불규칙한 분자 배치를 유지해 액체 이웃 환경의 차이를 비교한다.',
'같은 부피의 닫힌 물방울을 길게 변형해 표면을 넓히는 과정을 입체로 보여준다.',
'같은 물의 양으로 길어진 모형을 유지하며 표면을 더 넓히는 데 에너지가 필요함을 설명한다.',
'같은 부피의 물방울이 표면 넓이를 줄이는 쪽으로 모양을 바꾸려는 경향을 연결한다.',
'길어진 물방울이 같은 부피를 유지하며 구형에 가까워지는 계획으로 표면을 줄이는 변화를 보여준다.',
'물 표면이 넓어지는 데 저항하는 성질을 표면장력이라는 이름과 연결한다.',
'길어진 물방울과 둥근 물방울의 모양을 같은 물의 양이라는 공통 조건에서 비교한다.',
'같은 부피일 때 구형이 겉면을 가장 작게 만드는 관계를 두 입체 모형으로 보여준다.',
'외부 영향이 작은 작은 물방울이 구형에 가까워지는 조건을 제시한다.',
'작은 비접촉 방울과 큰 바닥 접촉 방울을 별도 조건 예시로 구별한다.',
'바닥 접촉과 외부 영향으로 실제 물방울의 모양이 달라질 수 있음을 보여준다.',
'작은 청색 물방울로 돌아와 그 주변에 별도 고체 껍질이나 막이 없음을 보여준다.',
'분자 개념도에서 이웃 분자 사이 수소결합을 회수하며 원자와 내부 결합은 유지한다.',
'물방울과 분자 개념도를 함께 유지해 표면을 넓히기 어려운 성질을 답의 원인으로 연결한다.',
'분자 사이 끌림과 표면장력이 외부 영향이 작은 작은 물방울의 둥근 형태를 만든다는 답을 회수한다.'
]
def read(stage):
return json.loads((PROJECT / f'stages/{stage}/r1/artifact.json').read_text(encoding='utf-8'))
def write(stage, payload, inputs):
folder = PROJECT / f'stages/{stage}/r3'
folder.mkdir(parents=True, exist_ok=True)
body = dict(schema_version='1.1',project_id=PROJECT.name,stage_id=stage,revision=3,inputs=[dict(stage_id=s,revision=r) for s,r in inputs],payload=payload)
(folder/'artifact.json').write_text(json.dumps(body,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
if __name__ == '__main__':
cuts = read('07-cuts')['payload']['cuts']
# 읽은 근거는 입력 참조에 포함한다. 선행 결과는 수정하지 않는다.
read('02-research')
read('03-script')
groups = [
(1,2,'OpeningDroplet','bg-droplet','정면보다 약간 위의 3D 전경, 중심 물방울; 다음 분자 확대는 새 Scene에서 제시한다.',[
'작은 청색 물방울이 크림색 공간 중앙에 구형에 가깝게 떠 있고 부드러운 하이라이트가 표면의 입체감을 보여준다.',
'작은 물방울을 유지하고 오른쪽 위에 분자 확대의 위치를 표시하는 합성 주석 앵커를 제시한다.',
'물방울과 확대 위치를 유지한 채 분자 설명으로 넘어갈 준비 상태다.'
]),
(3,5,'SingleMolecule','bg-molecules','3D 분자 근접, 약간 비스듬한 고정 카메라; 산소와 두 수소가 모두 가려지지 않는다.',[
'산호색 O 구 하나·미색 H 구 둘·남색 O–H 원통 두 개의 V자 3D 분자; 주석은 합성용 앵커만 마련한다.',
'온전한 V자 분자가 유지되고 산소 주석 앵커가 강조 위치에 있다.',
'분자 전체 중성을 유지하며 Oδ− 합성 주석 위치만 강조한다.',
'Oδ− 및 Hδ+ 합성 앵커가 모두 마련되며 원자 분리·전체 이온 표시는 없다.'
]),
(6,9,'BondPair','bg-molecules','실제 대표 Scene BondPair. 정면 위쪽 직교 카메라 (0,-12,6), ortho_scale=11.5; 내부 실선과 분자간 점선을 한 화면에 읽는다.',[
'PairA=(-1.8,0,-0.3), PairB=(1.65,0.15,0.45). 두 온전한 V자 분자의 내부 남색 실선과 서로 다른 분자 H···O 금색 분절 원통을 구별한다.',
'Blender F1018에서 PairA=(-1.8,0,-0.3), PairB=(1.65,0.15,0.45); H···O 점선의 양 끝이 실제 H/O 위치에 대응한다.',
'Blender F1220에서 PairB=(0.85,0.15,0.45)로 가까워져 H···O 점선 길이가 줄었다. 내부 O–H 실선 두 개씩은 그대로다.',
'같은 두 V자 분자·같은 카메라·금색 H···O 점선 유지; 수소결합 명칭은 합성 단계에서 별도로 배치한다.',
'같은 두 분자의 남색 내부 O–H 실선과 금색 분자간 H···O 점선이 동시에 보이고 모든 원자는 원래 분자에 남아 있다.'
]),
(10,13,'LiquidNetwork','bg-molecules','불규칙한 3D 액체 분자 집단 근접. 고정 격자 대신 깊이·방향을 달리하고 카메라는 유지한다.',[
'여러 온전한 V자 분자가 불규칙한 위치·방향에 있고 서로 다른 수의 금색 분자간 연결이 보인다.',
'분자 전체가 소폭 이동하는 동안 일부 H···O 점선 연결이 사라질 준비 상태; 내부 실선은 유지된다.',
'첫 H···O 점선 쌍이 사라지고 다른 분자 쌍의 점선이 나타난다. 원자 분리나 화학 반응은 없다.',
'새 이웃과의 H···O 연결이 보이며 분자마다 연결 수·방향이 다르다. 영구 끈·정지 격자는 없다.',
'불규칙한 집단을 유지하며 분자 사이의 응집이 거시적 물의 뭉침에 기여함을 다음 경계 모형으로 연결한다.'
]),
(14,17,'Interface','bg-interface','실제 대표 Scene Interface. 실제 물 경계 x=[-2.85,2.85], y=[-0.85,1.35], z=[-3.15,0.80] 안에 전체 원자·결합을 포함하고 앞면·윗면을 연 절개 설명 모형, 카메라 (0,-14,6), ortho_scale=13. 색 경계에 고체막을 추가하지 않는다.',[
'아래쪽 청색 물 체적과 위쪽 공기 공간이 보이며 실제 청색 물 영역 안에 불규칙한 물 분자가 모두 포함된다. 공기 이름은 추후 합성한다.',
'물-공기 경계를 유지하고 안쪽 Liquid4 주변의 다양한 이웃이 보이도록 근접 강조를 시작한다.',
'Blender F2807에서 Liquid4 안쪽 강조고리 scale=1, Liquid1 표면 강조고리 scale=0.01. 안쪽은 위·아래·옆에 이웃 물 분자가 있다.',
'Blender F3051에서 안쪽 강조는 작아지고 Liquid1 표면 강조고리 scale=1. 표면보다 위 공기 쪽에는 액체 물 이웃을 배치하지 않는다; 별도 막은 없다.',
'안쪽과 표면의 환경 차이가 보이는 불규칙한 분자 배치 유지. 공기는 단순화한 공간이며 진공이나 수증기 부재 주장이 아니다.'
]),
(18,22,'DropletStretch','bg-droplet','실제 대표 Scene DropletStretch. 닫힌 청색 3D mesh, (0,-12,5) 직교 카메라 ortho_scale=10.5. 같은 물의 양·설명용 모형 합성 앵커, 표면층 없음.',[
'Blender F3173의 닫힌 Droplet mesh scale=(1,1,1); 부드러운 청색 구형 물방울. 물의 양은 같은 조건이다.',
'Blender F3373의 Droplet scale=(1.85,1/sqrt(1.85),1/sqrt(1.85)); 같은 부피를 유지하며 좌우로 길어지고 표면이 넓어지는 설명용 변형이다.',
'길어진 같은 부피 물방울 유지; 에너지 필요 주석을 추후 합성한다. 실제 힘·에너지 수치는 표시하지 않는다.',
'같은 부피 물방울이 되돌아갈 준비 상태이며 물 표면의 확장 저항을 설명한다.',
'같은 부피의 닫힌 mesh가 구형에 가깝게 복귀한다. 복귀 애니메이션은 scenes 승인 후 정식 제작한다.',
'구형에 가까운 물방울과 표면 확장 저항 설명이 남는다. 껍질·고체 표면층은 없다.'
]),
(23,24,'EqualVolumeComparison','bg-comparison','3D 비교 전경, 좌우 모형을 같은 카메라·같은 물의 양으로 비교한다. 수치·픽셀면적 비교 없음.',[
'왼쪽 길어진 닫힌 물방울과 오른쪽 구형 물방울을 동일 부피 조건으로 나란히 배치한다.',
'같은 물의 양 주석 앵커를 유지하며 두 형태의 겉면 차이를 보여준다.',
'같은 부피에서 구형의 표면적이 작다는 관계를 청색 형태와 합성 주석으로 강조한다.'
]),
(25,27,'ShapeConditions','bg-comparison','조건 예시 3D 전경, 왼쪽 작은 비접촉 방울과 오른쪽 바닥에 닿은 큰 방울. 앞 비교와 별도 Scene.',[
'작은 비접촉 물방울과 더 큰 바닥 접촉 물방울을 별도의 조건 예시로 배치한다. 이 컷은 동일 부피 비교가 아니다.',
'외부 영향이 작은 작은 물방울이 구형에 가깝고 오른쪽 방울은 바닥과 접촉해 모양이 달라지는 예시다.',
'바닥 접촉 방울의 납작한 형태가 보이며 중력·접촉면 영향의 조건 주석 앵커가 있다.',
'두 조건 예시를 유지한다. 항상 완전한 구라는 단정·접촉각이나 크기 경계값은 없다.'
]),
(28,31,'AnswerDroplet','bg-droplet','도입과 같은 크림색 3D 공간·청색 방울 전경을 새 Scene에서 재제시하고 분자 개념도를 별도 위치에 둔다.',[
'구형에 가까운 청색 물방울과 분리된 분자 개념도 위치가 보인다. 물방울 주변에 별도 껍질은 없다.',
'동일 물방울 표면을 유지하며 추가 막이 없음을 보여준다.',
'별도 분자 개념도에서 온전한 V자 분자 사이 금색 H···O 점선이 변하고 내부 남색 실선은 유지된다.',
'물방울과 분자 개념도를 유지하며 표면을 넓히기 어려운 성질을 합성 주석으로 회수한다.',
'외부 영향이 작은 작은 물방울의 둥근 형태와 수소결합·표면장력의 설명이 함께 남아 도입 질문에 답한다.'
])
]
scenes, mapping = [], {}
for start,end,name,bg,camera,states in groups:
assert len(states)==end-start+2
for n in range(start,end+1):
cut=cuts[n-1]
mapping[cut['id']]=(name,bg)
scenes.append(dict(cut_id=cut['id'],description=DESCRIPTIONS[n-1],start_state=f"Scene={name}; F{cut['start_ms']//20+1}. "+states[n-start],end_state=f"Scene={name}; F{cut['end_ms']//20+1}. "+states[n-start+1],camera=camera))
connections=[]
for left,right in zip(scenes,scenes[1:]):
same=mapping[left['cut_id']][0]==mapping[right['cut_id']][0]
if same:
assert left['end_state']==right['start_state']
connections.append(dict(from_cut_id=left['cut_id'],to_cut_id=right['cut_id'],mode='continuous' if same else 'cut',reason='동일 Scene·논리 경계 프레임·물체·카메라 상태를 공유하며 설명의 다음 변화를 잇는다.' if same else f"설명 범위·조건·시점 전환으로 {mapping[left['cut_id']][0]}에서 별도 {mapping[right['cut_id']][0]} Scene으로 전환한다."))
profile=dict(engine='BLENDER_EEVEE',device='GPU',fps=50,width=1080,height=1920,samples=64,seed=0)
write('08-scenes',dict(scenes=scenes,connections=connections,preview=dict(scene_file=dict(path=f'projects/{PROJECT.name}/stages/08-scenes/r3/generated-source/preview.blend',verified=False,sha256=None,blender_version=None),render_profile=profile,samples=[])),[('02-research',1),('03-script',1),('07-cuts',1)])
common=["references/1.png의 단순 형태·정돈된 색·정보량을 교육용 3D로 해석한다. 크림색 단순 공간·부드러운 조명·선명한 형태를 반복한다.","산호색 O·미색 H·남색 내부 O–H 실선·금색 서로 다른 분자 H···O 점선·청색 물을 일관되게 사용한다.","상하단에는 제목·자막용 빈 공간을 두고 부분전하·한국어 주석은 나중 Remotion에서 검증해 합성한다. 3D에 글자를 굽지 않는다."]
extras={
'bg-droplet':('물방울의 크림색 3D 설명 공간',["청색 물방울은 닫힌 mesh이며 별도 껍질·막을 만들지 않는다.","같은 물의 양 변형은 실제 평가 mesh 부피를 시작·중간·끝에서 검사한다. 물리 시뮬레이션으로 주장하지 않는다."]),
'bg-molecules':('분자의 3D 근접·불규칙 액체 연결 설명 공간',["온전한 물 분자마다 O1H2·V자 형태·내부 O–H 실선 두 개를 유지한다. 각도·거리·힘 수치를 표시하지 않는다.","분자간 H···O 점선은 서로 다른 분자의 원자만 연결한다. 액체는 불규칙한 이웃·변하는 연결이고 모든 분자의 연결 수를 고정하지 않는다."]),
'bg-interface':('물-공기 경계의 3D 체적·절개 설명 공간',["전체 분자·결합이 물 체적 안에 포함되고 앞면·윗면만 열린 절개 설명 모형이며 위 공기는 진공 주장이 아니다. 별도 고체 표면층·전체 아래 가속 화살표가 없다.","안쪽은 다양한 방향의 액체 이웃, 표면은 위쪽 액체 이웃이 적은 환경으로 구별한다. 강조고리는 설명 표식이고 물 껍질이 아니다."]),
'bg-comparison':('같은 부피 비교·조건 예시의 크림색 3D 공간',["동일 부피 비교와 외부 영향 조건 예시는 별도 Scene으로 구별한다.","바닥은 조건 예시에만 등장한다. 중력·접촉각·크기 경계·표면장력 수치를 임의로 표시하지 않는다."])
}
backgrounds=[dict(id=k,description=v[0],fixed_features=common+v[1]) for k,v in extras.items()]
write('09-entities',dict(characters=[],backgrounds=backgrounds,appearances=[dict(cut_id=c['id'],character_ids=[],background_ids=[mapping[c['id']][1]]) for c in cuts]),[('02-research',1),('03-script',1),('07-cuts',1),('08-scenes',3)])
@@ -0,0 +1,104 @@
"""정식 결과의 계약·참조·실측·원본 및 복사 파일 해시를 대조한다."""
import argparse
import hashlib
import json
from pathlib import Path
import sys
import jsonschema
ROOT=Path(__file__).resolve().parents[3]
PROJECT=ROOT/'projects/water-hydrogen-bond-surface-tension'
sys.path.insert(0,str(ROOT/'contracts'))
from check import semantic_errors
parser=argparse.ArgumentParser()
parser.add_argument('--stage',choices=['10-references','11-images','12-motion'],required=True)
args=parser.parse_args()
BASE=PROJECT/f'stages/{args.stage}/r2'
def read(path):
return json.loads(path.read_text(encoding='utf-8'))
artifact=read(BASE/'artifact.json')
context={s['stage_id']:read(PROJECT.parent.parent/s['artifact_path']) for s in read(PROJECT/'project.json')['stages'] if (ROOT/s['artifact_path']).is_file()}
jsonschema.validate(artifact,read(ROOT/'contracts/artifact.schema.json'))
assert not semantic_errors(artifact,context,complete=True),semantic_errors(artifact,context,complete=True)
request,result=artifact['payload']['request'],artifact['payload']['result']
source=result['scene_file'] if args.stage!='12-motion' else context['11-images']['payload']['result']['scene_file']
assert source['verified'] and hashlib.sha256((ROOT/source['path']).read_bytes()).hexdigest()==source['sha256']
if args.stage!='12-motion':
assert (ROOT/source['path']).is_relative_to(BASE)
model=read(BASE/'model-check.json')
assert model['source_sha256']==source['sha256'] and model['water_containment']['frames']==705
assets={source['path']:source['sha256']}
key,id_key={'10-references':('items','reference_id'),'11-images':('frames','frame_id'),'12-motion':('clips','cut_id')}[args.stage]
if args.stage=='10-references':
assert len(result['items'])==7 and len({i['object_name'] for i in result['items']})==7
assert {i['entity_id'] for i in request['items'] if i['kind']=='background'}=={b['id'] for b in context['09-entities']['payload']['backgrounds']}
if args.stage=='11-images':
assert len(result['frames'])==62
by_id={f['frame_id']:f['media'] for f in result['frames']}
frames={f['id']:f for f in request['frames']}
assert len(by_id)==len(frames)==62
anchors=read(BASE/'anchors.json')
assert anchors['source_sha256']==source['sha256'] and (anchors['width'],anchors['height'])==(1080,1920)
assert anchors['coordinate_system']=='top_left_pixel' and len(anchors['cuts'])==31
for cut,anchor in zip(context['07-cuts']['payload']['cuts'],anchors['cuts']):
start,end=cut['start_ms']//20,cut['end_ms']//20
assert (anchor['cut_id'],anchor['start_frame'],anchor['end_frame'])==(cut['id'],start,end)
assert frames[cut['id']+'-start']['frame_index']==start and frames[cut['id']+'-end']['frame_index']==end
assert frames[cut['id']+'-start']['scene_name']==anchor['scene_name']==frames[cut['id']+'-end']['scene_name']
assert anchor['motion'] and all(track['keys'][0][0]==start and track['keys'][-1][0]==end for track in anchor['motion'])
for connection in context['08-scenes']['payload']['connections']:
left,right=connection['from_cut_id']+'-end',connection['to_cut_id']+'-start'
if connection['mode']=='continuous':
assert by_id[left]['path']==by_id[right]['path']
assert (frames[left]['scene_name'],frames[left]['frame_index'])==(frames[right]['scene_name'],frames[right]['frame_index'])
else:
assert frames[left]['scene_name']!=frames[right]['scene_name']
if args.stage=='12-motion':
assert len(result['clips'])==31 and sum(c['end_frame']-c['start_frame'] for c in request['clips'])==5528
assert sum(c['duration_ms'] for c in request['clips'])==110560
if args.stage!='10-references':
visual=read(BASE/'visual-check.json')
assert visual['source_sha256']==source['sha256']
assert all(v['visual_review']=='checked' for v in visual['views']), '실제 화면 직접 관찰 미완료'
assert len(visual['views'])==(9 if args.stage=='11-images' else 31)
for view in visual['views']:
for asset in [view['sheet']]+view.get('decoded_frames',[])+view.get('render_frames',[]):
path=ROOT/asset['path']
assert path.is_relative_to(BASE) and hashlib.sha256(path.read_bytes()).hexdigest()==asset['sha256']
assets[asset['path']]=asset['sha256']
for item in result[key]:
media=item['media']
path=ROOT/media['path']
assert path.is_file() and path.is_relative_to(BASE) and media['verified']
assert (media['width'],media['height'])==(1080,1920)
evidence=read(path.parent/'evidence.json')
assert evidence['scene_file']==source and evidence['render_profile']==request['render_profile'] and evidence['media']==media
if args.stage=='10-references':
assert evidence['logical_frames'] and len(evidence['logical_frames'])==1
elif args.stage=='11-images':
frame=next(f for f in request['frames'] if f['id']==item['frame_id'])
assert evidence['scene_name']==frame['scene_name'] and evidence['logical_frames']==[frame['frame_index']]
else:
clip=next(c for c in request['clips'] if c['cut_id']==item['cut_id'])
assert evidence['scene_name']==clip['scene_name'] and evidence['logical_frames']==list(range(clip['start_frame'],clip['end_frame']))
assert media['duration_ms']==clip['duration_ms'] and 0<media['duration_ms']<=5000
endpoints=context['11-images']['payload']
end=next(f for f in endpoints['request']['frames'] if f['id']==clip['end_frame_id'])
assert end['frame_index']-evidence['logical_frames'][-1]==1
for logical in [clip['start_frame'],(clip['start_frame']+clip['end_frame'])//2,clip['end_frame']-1]:
frame=path.parent/f'frame-{logical:08}.png'
assets[frame.relative_to(ROOT).as_posix()]=hashlib.sha256(frame.read_bytes()).hexdigest()
for file in [path,path.parent/'evidence.json']:
assets[file.relative_to(ROOT).as_posix()]=hashlib.sha256(file.read_bytes()).hexdigest()
for file in BASE.glob('*.json'):
if file.name!='manifest.json':
assets[file.relative_to(ROOT).as_posix()]=hashlib.sha256(file.read_bytes()).hexdigest()
if (BASE/'review.md').is_file():
file=BASE/'review.md'
assets[file.relative_to(ROOT).as_posix()]=hashlib.sha256(file.read_bytes()).hexdigest()
value=dict(result='GREEN',stage_id=args.stage,source_sha256=source['sha256'],requested=len(request[key]),actual=len(result[key]),render_profile=request['render_profile'],actual_assets=[dict(path=path,sha256=sha) for path,sha in sorted(assets.items())])
(BASE/'manifest.json').write_text(json.dumps(value,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('정식 결과 검사 GREEN',args.stage,len(value['actual_assets']))
@@ -0,0 +1,35 @@
"""승인 원본·선행 단계·기존 WAV·스타일의 바이트 보존을 실제 대조한다."""
import hashlib
import json
from pathlib import Path
import subprocess
ROOT=Path(__file__).resolve().parents[3]
PROJECT=ROOT/'projects/water-hydrogen-bond-surface-tension'
BASE=PROJECT/'stages/12-motion/r2'
PINNED='6b00a8f'
records=[]
def check(path,expected,kind):
actual=hashlib.sha256((ROOT/path).read_bytes()).hexdigest()
assert actual==expected, '기존 결과 바이트 변경: '+path
records.append(dict(path=path,sha256=actual,baseline=kind))
approved=json.loads((PROJECT/'stages/08-scenes/r3/design-check.json').read_text(encoding='utf-8'))
for item in approved['actual_assets']:
check(item['path'],item['sha256'],'08 r3 승인 실물 목록')
original=json.loads((PROJECT/'stages/08-scenes/r3/preservation-check.json').read_text(encoding='utf-8'))['baseline_sha256']
for path,sha in original.items():
if path.endswith('.wav') or path.endswith('stages/08-scenes/r2/generated-source/preview.blend'):
check(path,sha,'08 r3 보존 기준')
paths=subprocess.check_output(['git','ls-tree','-r','--name-only',PINNED,'--','references','projects/water-hydrogen-bond-surface-tension/stages'],cwd=ROOT,text=True,encoding='utf-8').splitlines()
for path in paths:
parts=Path(path).parts
if path.startswith('references/') or (len(parts)>4 and (int(parts[3][:2])<=9 or parts[4]=='r1')):
data=subprocess.check_output(['git','cat-file','--filters','--path='+path,PINNED+':'+path],cwd=ROOT)
check(path,hashlib.sha256(data).hexdigest(),PINNED+' 작업 사본 체크아웃 바이트')
value=dict(result='GREEN',pinned_commit=PINNED,unchanged_files=len(records),files=records)
(BASE/'preservation-check.json').write_text(json.dumps(value,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('기존 승인 입력·WAV·스타일 보존 GREEN',len(records))
@@ -0,0 +1,83 @@
"""기존 실행기의 정식 승인·해시·디코드 검사로 요청을 실제 렌더한다."""
import argparse
import json
from pathlib import Path
import subprocess
import sys
import time
sys.path.insert(0,str(Path(__file__).resolve().parent))
from production_requests import REFERENCE_PLAN
ROOT=Path(__file__).resolve().parents[3]
PROJECT=Path('projects/water-hydrogen-bond-surface-tension')
PYTHON='C:/Users/baram/anaconda3/python.exe'
BLENDER='C:/Program Files/Blender Foundation/Blender 5.2/blender.exe'
COMPOSITOR=Path('C:/git/ShortVideoGenerator/projects/water-hydrogen-bond-surface-tension/renderer/node_modules/@remotion/compositor-win32-x64-msvc')
parser=argparse.ArgumentParser()
parser.add_argument('--stage',choices=['10-references','11-images','12-motion'],required=True)
args=parser.parse_args()
BASE=PROJECT/'stages'/args.stage/'r2'
artifact_path=BASE/'artifact.json'
artifact=json.loads((ROOT/artifact_path).read_text(encoding='utf-8'))
records_path=ROOT/BASE/'cli-commands.json'
records=json.loads(records_path.read_text(encoding='utf-8')) if records_path.exists() else []
def execute(command):
started=time.monotonic()
value=subprocess.run(command,cwd=ROOT,capture_output=True,text=True,encoding='utf-8')
records.append(dict(command=command,exit_code=value.returncode,elapsed_seconds=round(time.monotonic()-started,3)))
if value.returncode:
records[-1]['error']=value.stderr[-2500:]+value.stdout[-2500:]
records_path.write_text(json.dumps(records,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
assert value.returncode==0,(value.stdout,value.stderr)
result=json.loads(value.stdout)
if result.get('media'):
records[-1]['evidence_path']=(Path(result['media']['path']).parent/'evidence.json').as_posix()
records[-1]['source_sha256']=result['scene_file']['sha256']
records_path.write_text(json.dumps(records,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
return result
def save():
(ROOT/artifact_path).write_text(json.dumps(artifact,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
common=[PYTHON,'-B','-X','utf8','tools/blender/run.py']
source=(BASE/'generated-source/references.blend').as_posix() if args.stage=='10-references' else artifact['payload']['request']['scene_path']
if args.stage!='12-motion' and not artifact['payload']['result']:
first_scene='BondPair'
inspected=execute(common+['inspect','--source',source,'--scene',first_scene,'--require-object','PairA','--blender',BLENDER])
artifact['payload']['result']=dict(scene_file=inspected['scene_file'],**({'items':[]} if args.stage=='10-references' else {'frames':[]}))
save()
if args.stage=='12-motion' and not artifact['payload']['result']:
artifact['payload']['result']=dict(clips=[])
save()
if args.stage=='10-references':
jobs=[dict(id=id,scene=scene,obj=obj,options=['--reference-id',id,'--frame',str(frame)]) for id,kind,entity,scene,obj,frame in REFERENCE_PLAN]
key,id_key='items','reference_id'
elif args.stage=='11-images':
jobs=[dict(id=f['id'],scene=f['scene_name'],obj='StudioAssets',frame=f['frame_index'],options=['--frame-id',f['id'],'--frame',str(f['frame_index'])]) for f in artifact['payload']['request']['frames']]
key,id_key='frames','frame_id'
else:
jobs=[dict(id=c['cut_id'],scene=c['scene_name'],obj='StudioAssets',options=['--cut-id',c['cut_id'],'--start',str(c['start_frame']),'--end',str(c['end_frame'])]) for c in artifact['payload']['request']['clips']]
key,id_key='clips','cut_id'
for job in jobs:
if any(item[id_key]==job['id'] for item in artifact['payload']['result'][key]):
continue
shared=None
if args.stage=='11-images':
request={f['id']:f for f in artifact['payload']['request']['frames']}
shared=next((item['media'] for item in artifact['payload']['result']['frames'] if (request[item['frame_id']]['scene_name'],request[item['frame_id']]['frame_index'])==(job['scene'],job['frame'])),None)
if shared:
media=shared
else:
command=common+['render','--mode','production','--project',(PROJECT/'project.json').as_posix(),'--artifact',artifact_path.as_posix(),'--output-revision','2','--source',source,'--scene',job['scene'],'--require-object',job['obj'],'--blender',BLENDER,'--ffmpeg',str(COMPOSITOR/'ffmpeg.exe'),'--ffprobe',str(COMPOSITOR/'ffprobe.exe')]+job['options']
evidence=execute(command)
media=evidence['media']
item={id_key:job['id'],'media':media}
if args.stage=='10-references':
item['object_name']=job['obj']
artifact['payload']['result'][key].append(item)
save()
print('실제 결과',args.stage,job['id'],media['path'],flush=True)
print('전체 요청 렌더 완료',args.stage,flush=True)
@@ -0,0 +1,77 @@
"""승인된 31컷에서 정식 요청만 작성한다. 실제 결과는 실행 후 채운다."""
import argparse
import json
from pathlib import Path
ROOT=Path(__file__).resolve().parents[3]
PROJECT=ROOT/'projects/water-hydrogen-bond-surface-tension'
PROFILE=dict(engine='BLENDER_EEVEE',device='GPU',fps=50,width=1080,height=1920,samples=64,seed=0)
REFERENCE_PLAN=[
('style-3d','style',None,'BondPair','StyleAssets',1017),
('background-droplet','background','bg-droplet','OpeningDroplet','DropletStudioAssets',0),
('background-molecules','background','bg-molecules','BondPair','MoleculeStudioAssets',1017),
('background-interface','background','bg-interface','Interface','InterfaceAssets',3050),
('background-comparison','background','bg-comparison','EqualVolumeComparison','ComparisonStudioAssets',4076),
('object-molecule','object',None,'SingleMolecule','MoleculeAssets',396),
('object-droplet','object',None,'OpeningDroplet','DropletAssets',0)]
def read(stage,revision):
return json.loads((PROJECT/f'stages/{stage}/r{revision}/artifact.json').read_text(encoding='utf-8'))
def write(stage,request,inputs):
path=PROJECT/f'stages/{stage}/r2/artifact.json'
existing=json.loads(path.read_text(encoding='utf-8')) if path.exists() else None
assert not existing or not existing['payload']['result'], '완료 또는 부분 실제 결과 덮어쓰기 금지'
value=dict(schema_version='1.1',project_id=PROJECT.name,stage_id=stage,revision=2,inputs=[dict(stage_id=s,revision=r) for s,r in inputs],payload=dict(request=request,result=None))
path.write_text(json.dumps(value,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
if __name__=='__main__':
parser=argparse.ArgumentParser()
parser.add_argument('--stage',choices=['10-references','11-images','12-motion'],required=True)
args=parser.parse_args()
plans=read('08-scenes',3)['payload']['scenes']
if args.stage!='12-motion':
entities=read('09-entities',3)['payload']
read('02-research',1)
if args.stage=='10-references':
backgrounds={b['id']:b for b in entities['backgrounds']}
items=[]
for id,kind,entity,scene,obj,frame in REFERENCE_PLAN:
prompt='승인 08 r3의 '+scene+' 상태와 교육용 3D 고정 특징. 실제 '+obj+' 자산. '
if entity:
prompt+=' '.join(backgrounds[entity]['fixed_features'])
elif kind=='object':
prompt+='O1H2·V자·내부 남색 실선과 금색 분자간 H···O의 구별을 유지한다.' if id=='object-molecule' else '매끈한 청색 닫힌 mesh와 같은 물의 양을 유지하며 별도 껍질이 없다.'
else:
prompt+='references/1.png를 보존하고 단순 형태·정돈된 색·부드러운 조명·크림 배경으로 해석한다. 글자는 합성한다.'
items.append(dict(id=id,kind=kind,entity_id=entity,prompt=prompt))
write(args.stage,dict(style_reference='references/1.png',items=items,engine='Blender',render_profile=PROFILE),[('02-research',1),('08-scenes',3),('09-entities',3)])
elif args.stage=='11-images':
read('03-script',1)
cuts=read('07-cuts',1)['payload']['cuts']
reference=read('10-references',2)
assert reference['payload']['result'] and len(reference['payload']['result']['items'])==7
appearances={p['cut_id']:p for p in entities['appearances']}
mapping={'bg-droplet':'background-droplet','bg-molecules':'background-molecules','bg-interface':'background-interface','bg-comparison':'background-comparison'}
frames=[]
for cut,plan in zip(cuts,plans):
scene=plan['start_state'].split(';')[0].split('=')[1]
refs=['style-3d']+[mapping[b] for b in appearances[cut['id']]['background_ids']]
refs+=['object-molecule'] if scene in ['SingleMolecule','BondPair','LiquidNetwork','Interface'] else ['object-droplet']
if scene=='AnswerDroplet':
refs.append('object-molecule')
for position,key in [('start','start_ms'),('end','end_ms')]:
frames.append(dict(id=cut['id']+'-'+position,cut_id=cut['id'],position=position,prompt=plan['description']+' '+plan[position+'_state']+' '+plan['camera'],reference_ids=refs,scene_name=scene,frame_index=cut[key]//20))
write(args.stage,dict(frames=frames,engine='Blender',render_profile=PROFILE,scene_path='projects/'+PROJECT.name+'/stages/11-images/r2/generated-source/animation.blend'),[('02-research',1),('03-script',1),('07-cuts',1),('08-scenes',3),('09-entities',3),('10-references',2)])
else:
cuts=read('07-cuts',1)['payload']['cuts']
images=read('11-images',2)
assert images['payload']['result'] and len(images['payload']['result']['frames'])==62
frames={f['id']:f for f in images['payload']['request']['frames']}
clips=[]
for cut,plan in zip(cuts,plans):
start,end=frames[cut['id']+'-start'],frames[cut['id']+'-end']
clips.append(dict(cut_id=cut['id'],start_frame_id=start['id'],end_frame_id=end['id'],duration_ms=cut['end_ms']-cut['start_ms'],motion_prompt=plan['description']+' '+plan['start_state']+' → '+plan['end_state']+' '+plan['camera'],scene_name=start['scene_name'],start_frame=start['frame_index'],end_frame=end['frame_index']))
write(args.stage,dict(clips=clips,engine='Blender',render_profile=PROFILE,scene_path=images['payload']['result']['scene_file']['path']),[('07-cuts',1),('08-scenes',3),('11-images',2)])
@@ -0,0 +1,43 @@
"""실제 10/11 원본을 각각 다시 열어 모델·재질·기준 컬렉션 재사용을 대조한다."""
import hashlib
import json
from pathlib import Path
import bpy
ROOT=Path(__file__).resolve().parents[3]
PROJECT=ROOT/'projects/water-hydrogen-bond-surface-tension'
def signature(source):
bpy.ops.wm.open_mainfile(filepath=str(source))
assert not bpy.data.libraries and not bpy.data.images and not bpy.data.fonts and not bpy.data.sounds
meshes={}
for obj in bpy.data.objects:
if obj.type=='MESH':
meshes[obj.name]=dict(vertices=[tuple(v.co) for v in obj.data.vertices],polygons=[tuple(p.vertices) for p in obj.data.polygons],materials=[m.name for m in obj.data.materials])
materials={}
for material in bpy.data.materials:
nodes=[]
for node in material.node_tree.nodes if material.use_nodes else []:
values=[]
for socket in node.inputs:
if hasattr(socket,'default_value'):
value=socket.default_value
if not isinstance(value,(str,int,float,bool)):
value=list(value) if hasattr(value,'__iter__') else str(value)
values.append((socket.name,value))
nodes.append((node.type,values))
materials[material.name]=dict(diffuse=list(material.diffuse_color),nodes=nodes)
return dict(meshes=meshes,materials=materials,collections={c.name:sorted(o.name for o in c.objects) for c in bpy.data.collections},scenes={s.name:dict(objects=sorted(o.name for o in s.objects),camera=s.camera.name,ortho_scale=s.camera.data.ortho_scale) for s in bpy.data.scenes})
reference=PROJECT/'stages/10-references/r2/generated-source/references.blend'
animation=PROJECT/'stages/11-images/r2/generated-source/animation.blend'
baseline=signature(reference)
actual=signature(animation)
assert baseline==actual, '실제 기준 모델·재질·컬렉션·카메라 재사용 불일치'
reference_hash=hashlib.sha256(reference.read_bytes()).hexdigest()
assert all(s['reference_source_sha256']==reference_hash for s in bpy.data.scenes), '10 원본 해시 연결 불일치'
value=dict(result='GREEN',reference_source_sha256=reference_hash,animation_source_sha256=hashlib.sha256(animation.read_bytes()).hexdigest(),actual_reopened_sources=2,mesh_objects=len(actual['meshes']),materials=len(actual['materials']),scenes=len(actual['scenes']),matching_fields=['local_mesh_vertices','polygons','material_slots','material_nodes','collection_members','scene_members','actual_camera_name','orthographic_scale'])
(PROJECT/'stages/11-images/r2/reuse-check.json').write_text(json.dumps(value,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('실제 10→11 기준 재사용 GREEN',value['mesh_objects'])
@@ -0,0 +1,45 @@
"""정식 원본의 실제 누락·포함·부피 실패와 수정 성공을 기록한다."""
import argparse
import json
from pathlib import Path
import subprocess
ROOT=Path(__file__).resolve().parents[3]
PROJECT=ROOT/'projects/water-hydrogen-bond-surface-tension'
BLENDER='C:/Program Files/Blender Foundation/Blender 5.2/blender.exe'
VERIFY=PROJECT/'blender/verify_production.py'
parser=argparse.ArgumentParser()
parser.add_argument('--stage',choices=['references','images'],required=True)
args=parser.parse_args()
BASE=PROJECT/f'stages/{"10-references" if args.stage=="references" else "11-images"}/r2'
SOURCE=BASE/f'generated-source/{"references" if args.stage=="references" else "animation"}.blend'
records=[]
def execute(arguments,expected,reason=None):
command=[BLENDER,'--background','--disable-autoexec','--python-exit-code','1']+arguments
value=subprocess.run(command,cwd=ROOT,capture_output=True,text=True,encoding='utf-8')
records.append(dict(command=command,exit_code=value.returncode,expected_exit_code=expected,matched_reason=reason,assertion_errors=[line for line in value.stderr.splitlines() if line.startswith('AssertionError:')]))
(BASE/'model-test-results.json').write_text(json.dumps(dict(result='RUNNING',executions=records),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
assert value.returncode==expected,(value.stdout,value.stderr)
assert not reason or reason in value.stderr,(reason,value.stderr)
def check(source,expected,reason=None,out=None):
options=['--source',str(source),'--out',str(out or BASE/'generated-source/invalid-result.json')]
if args.stage=='images':
options.append('--animated')
execute(['--python',str(VERIFY),'--']+options,expected,reason)
check(BASE/'generated-source/missing.blend',1,'원본 파일 없음')
check(PROJECT/'stages/08-scenes/r3/generated-source/preview.blend',1,'대표 Scene 누락/혼입')
for name,edit,reason in [
('missing-scene',"bpy.data.scenes.remove(bpy.data.scenes['SingleMolecule'])",'대표 Scene 누락/혼입'),
('outside-mesh',"bpy.data.objects['Liquid9_H1'].data.vertices[0].co.x=10",'물 영역 밖 또는 여백 부족')]:
fixture=BASE/f'generated-source/{name}.blend'
expression=f'import bpy; bpy.ops.wm.open_mainfile(filepath={str(SOURCE)!r}); {edit}; bpy.ops.wm.save_as_mainfile(filepath={str(fixture)!r})'
execute(['--python-expr',expression],0)
check(fixture,1,reason)
check(SOURCE,0,out=BASE/'model-check.json')
(BASE/'model-test-results.json').write_text(json.dumps(dict(result='GREEN',negative_cases=4,positive_cases=1,executions=records),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('정식 원본 RED→GREEN 확인',args.stage)
@@ -0,0 +1,103 @@
"""실제 끝점과 MP4 디코드의 모든 컷 상태를 모바일 비교 화면으로 만든다."""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
from PIL import Image,ImageChops,ImageStat,ImageDraw
ROOT=Path(__file__).resolve().parents[3]
PROJECT=ROOT/'projects/water-hydrogen-bond-surface-tension'
FFMPEG='C:/git/ShortVideoGenerator/projects/water-hydrogen-bond-surface-tension/renderer/node_modules/@remotion/compositor-win32-x64-msvc/ffmpeg.exe'
parser=argparse.ArgumentParser()
parser.add_argument('--stage',choices=['11-images','12-motion'],required=True)
args=parser.parse_args()
BASE=PROJECT/f'stages/{args.stage}/r2'
artifact=json.loads((BASE/'artifact.json').read_text(encoding='utf-8'))['payload']
folder=BASE/'generated-source/inspection'
folder.mkdir(exist_ok=True)
records_path=BASE/'visual-cli-commands.json'
records=json.loads(records_path.read_text(encoding='utf-8')) if records_path.exists() else []
def sheet(paths,labels,out,columns):
rows=(len(paths)+columns-1)//columns
result=Image.new('RGB',(360*columns,664*rows),'white')
draw=ImageDraw.Draw(result)
for index,(path,label) in enumerate(zip(paths,labels)):
x,y=(index%columns)*360,(index//columns)*664
draw.text((x+8,y+5),label,fill='black')
result.paste(Image.open(path).convert('RGB').resize((360,640),Image.Resampling.LANCZOS),(x,y+24))
result.save(out)
return dict(path=out.relative_to(ROOT).as_posix(),sha256=hashlib.sha256(out.read_bytes()).hexdigest())
views=[]
if args.stage=='11-images':
frames={f['id']:f for f in artifact['request']['frames']}
groups={}
seen=set()
for item in artifact['result']['frames']:
frame=frames[item['frame_id']]
if item['media']['path'] in seen:
continue
seen.add(item['media']['path'])
groups.setdefault(frame['scene_name'],[]).append((ROOT/item['media']['path'],str(frame['frame_index'])))
for scene,images in groups.items():
path=folder/(scene+'.png')
asset=sheet([p for p,label in images],['F'+label for p,label in images],path,min(4,len(images)))
views.append(dict(scene_name=scene,unique_frames=len(images),sheet=asset,visual_review='not_checked'))
else:
requests={c['cut_id']:c for c in artifact['request']['clips']}
endpoints=json.loads((PROJECT/'stages/11-images/r2/artifact.json').read_text(encoding='utf-8'))['payload']
endpoint_media={f['frame_id']:ROOT/f['media']['path'] for f in endpoints['result']['frames']}
for item in artifact['result']['clips']:
clip=requests[item['cut_id']]
video=ROOT/item['media']['path']
sha=hashlib.sha256(video.read_bytes()).hexdigest()
count=clip['end_frame']-clip['start_frame']
indices=[0,count//2,count-1]
decoded=[video.parent/f'decoded-review-{index:03}.png' for index in [1,2,3]]
if not all(p.is_file() for p in decoded):
commands=[
[str(Path(FFMPEG).with_name('ffprobe.exe')),'-v','error','-count_frames','-show_streams','-of','json',str(video)],
[FFMPEG,'-hide_banner','-loglevel','error','-xerror','-i',str(video),'-map','0:v:0','-c:v','rawvideo','-f','null','-']]
commands += [[FFMPEG,'-hide_banner','-loglevel','error','-n','-i',str(video),'-ss',str(index/50),'-frames:v','1',str(path)] for index,path in zip(indices,decoded) if not path.is_file()]
for command_index,command in enumerate(commands):
value=subprocess.run(command,capture_output=True,text=True,encoding='utf-8')
record=dict(command=command,exit_code=value.returncode,media_sha256=sha)
if value.returncode:
record['error']=value.stderr
records.append(record)
records_path.write_text(json.dumps(records,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
assert value.returncode==0,value.stderr
if command_index==0:
streams=json.loads(value.stdout)['streams']
assert len(streams)==1 and streams[0]['codec_name']=='h264'
assert int(streams[0]['nb_read_frames'])==count
record['decoded_frames']=count
record['expected_frames']=count
records_path.write_text(json.dumps(records,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
logical=[clip['start_frame']+index for index in indices]
originals=[video.parent/f'frame-{frame:08}.png' for frame in logical]
errors=[sum(ImageStat.Stat(ImageChops.difference(Image.open(a).convert('RGB'),Image.open(b).convert('RGB'))).mean)/3 for a,b in zip(decoded,originals)]
assert max(errors)<5, 'MP4 실제 디코드와 원본 렌더 화면 불일치'
start_png=endpoint_media[clip['start_frame_id']]
end_png=endpoint_media[clip['end_frame_id']]
start_error=sum(ImageStat.Stat(ImageChops.difference(Image.open(start_png).convert('RGB'),Image.open(originals[0]).convert('RGB'))).mean)/3
assert start_error<.1, '11 시작 PNG와 12 실제 시작 상태 불일치'
end_difference=sum(ImageStat.Stat(ImageChops.difference(Image.open(end_png).convert('RGB'),Image.open(originals[-1]).convert('RGB'))).mean)/3
path=folder/(clip['cut_id']+'.png')
labels=[clip['cut_id']+' F'+str(frame) for frame in logical]+['11 end F'+str(clip['end_frame'])]
asset=sheet(decoded+[end_png],labels,path,4)
views.append(dict(cut_id=clip['cut_id'],scene_name=clip['scene_name'],media_sha256=sha,logical_frames=logical,end_png_logical_frame=clip['end_frame'],last_video_frame_delta=1,last_video_time_delta_ms=20,start_endpoint_png_mean_error=start_error,end_boundary_last_render_mean_difference=end_difference,decoded_frames=[dict(path=p.relative_to(ROOT).as_posix(),sha256=hashlib.sha256(p.read_bytes()).hexdigest()) for p in decoded],render_frames=[dict(path=p.relative_to(ROOT).as_posix(),sha256=hashlib.sha256(p.read_bytes()).hexdigest()) for p in originals],mean_decode_render_pixel_error=errors,sheet=asset,visual_review='not_checked'))
previous=json.loads((BASE/'visual-check.json').read_text(encoding='utf-8')) if (BASE/'visual-check.json').exists() else {}
for view in views:
old=next((v for v in previous.get('views',[]) if v.get('sheet')==view['sheet']),None)
if old and old.get('visual_review')=='checked':
view['visual_review']='checked'
view['observation']=old['observation']
source=artifact['result']['scene_file'] if args.stage=='11-images' else json.loads((PROJECT/'stages/11-images/r2/artifact.json').read_text(encoding='utf-8'))['payload']['result']['scene_file']
complete=len(artifact['result']['frames'])==62 if args.stage=='11-images' else len(views)==31
value=dict(result='READY_FOR_VISUAL_REVIEW' if complete else 'PARTIAL',source_sha256=source['sha256'],width=1080,height=1920,panel_width=360,panel_height=640,views=views)
(BASE/'visual-check.json').write_text(json.dumps(value,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('실제 관찰 자료',args.stage,len(views))
@@ -0,0 +1,68 @@
"""기존 실행기로 세 대표 컷의 PNG/MP4를 만든다. 전 컷 제작은 하지 않는다."""
import argparse
import json
from pathlib import Path
import subprocess
from PIL import Image, ImageChops
ROOT = Path(__file__).resolve().parents[3]
BASE = Path('projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3')
BLENDER = 'C:/Program Files/Blender Foundation/Blender 5.2/blender.exe'
PYTHON = 'C:/Users/baram/anaconda3/python.exe'
COMPOSITOR = Path('C:/git/ShortVideoGenerator/projects/water-hydrogen-bond-surface-tension/renderer/node_modules/@remotion/compositor-win32-x64-msvc')
SOURCE = (BASE/'generated-source/preview.blend').as_posix()
ARTIFACT = (BASE/'artifact.json').as_posix()
PROJECT = 'projects/water-hydrogen-bond-surface-tension/project.json'
PLAN = [('c07','BondPair','PairA',1017,1219,1219),('c16','Interface','WaterCutawayBody',2806,3050,3050),('c18','DropletStretch','Droplet',3172,3372,3372)]
def run(args, records):
result=subprocess.run(args,cwd=ROOT,text=True,encoding='utf-8',capture_output=True)
records.append(dict(command=args,exit_code=result.returncode))
(ROOT/BASE/'cli-commands.json').write_text(json.dumps(records,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
if result.returncode:
print(result.stdout,result.stderr,flush=True)
raise RuntimeError('실행 실패; 명령 기록과 기존 시도 보존')
parsed=json.loads(result.stdout)
records[-1]['source_sha256']=parsed['scene_file']['sha256']
records[-1]['scene_name']=parsed.get('scene_name')
if 'media' in parsed:
records[-1]['media']=parsed['media']
records[-1]['evidence_path']=(Path(parsed['media']['path']).parent/'evidence.json').as_posix()
(ROOT/BASE/'cli-commands.json').write_text(json.dumps(records,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('EXIT_0',args[5],parsed.get('scene_name'),flush=True)
return parsed
if __name__=='__main__':
parser=argparse.ArgumentParser()
parser.add_argument('--kind',choices=['image','video'],required=True)
parser.add_argument('--cut',choices=['c07','c16','c18'])
args=parser.parse_args()
record_path=ROOT/BASE/'cli-commands.json'
records=json.loads(record_path.read_text(encoding='utf-8')) if record_path.exists() else []
for record in records:
record.pop('stdout',None)
record.pop('stderr',None)
artifact=json.loads((ROOT/ARTIFACT).read_text(encoding='utf-8'))
for cut,scene,obj,start,end,boundary in PLAN:
if args.cut and args.cut!=cut:
continue
assert not any(s['id']==cut+'-'+args.kind for s in artifact['payload']['preview']['samples']), '이미 반영한 sample을 같은 버전에서 재생성하지 않는다'
common=[PYTHON,'-B','-X','utf8','tools/blender/run.py']
inspect=run(common+['inspect','--blender',BLENDER,'--source',SOURCE,'--scene',scene,'--require-object',obj],records)
artifact['payload']['preview']['scene_file']=inspect['scene_file']
(ROOT/ARTIFACT).write_text(json.dumps(artifact,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
options=['--frame',str(boundary)] if args.kind=='image' else ['--start',str(start),'--end',str(end)]
rendered=run(common+['render','--mode','preview','--project',PROJECT,'--artifact',ARTIFACT,'--output-revision','3','--blender',BLENDER,'--source',SOURCE,'--scene',scene,'--require-object',obj,'--cut-id',cut,'--ffmpeg',str(COMPOSITOR/'ffmpeg.exe'),'--ffprobe',str(COMPOSITOR/'ffprobe.exe')]+options,records)
# 실행기의 실제 증거만 미디어 메타데이터로 사용한다.
evidence=rendered
if args.kind=='video':
frames=[ROOT/Path(evidence['media']['path']).parent/f'frame-{f:08}.png' for f in (start,start+(end-start)//2,end-1)]
pixels=[Image.open(f).convert('RGB') for f in frames]
if not (ImageChops.difference(pixels[0],pixels[1]).getbbox() and ImageChops.difference(pixels[0],pixels[2]).getbbox()):
(ROOT/Path(evidence['media']['path']).parent/'visual-failure.json').write_text(json.dumps(dict(error='실제 렌더 프레임이 정지함; 미디어 반영 금지',logical_frames=[start,start+(end-start)//2,end-1]),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
raise RuntimeError('실제 렌더 프레임이 정지함; 미디어 반영 금지')
artifact['payload']['preview']['samples'].append(dict(id=cut+'-'+args.kind,cut_ids=[cut],media=evidence['media']))
(ROOT/ARTIFACT).write_text(json.dumps(artifact,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('READY',cut,args.kind,evidence['media']['path'],flush=True)
@@ -0,0 +1,32 @@
"""원본 부재·구조·물 영역 포함·부피 오류를 실제 Blender로 거부한다."""
import json
from pathlib import Path
import subprocess
ROOT=Path(__file__).resolve().parents[3]
BASE=ROOT/'projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3'
BLENDER='C:/Program Files/Blender Foundation/Blender 5.2/blender.exe'
SOURCE=BASE/'generated-source/preview.blend'
VERIFY=ROOT/'projects/water-hydrogen-bond-surface-tension/blender/verify_model.py'
records=[]
def execute(args, expected, reason=None):
command=[BLENDER,'--background','--disable-autoexec','--python-exit-code','1']+args
result=subprocess.run(command,cwd=ROOT,capture_output=True,text=True,encoding='utf-8')
assert result.returncode==expected,(command,result.stdout,result.stderr)
if reason:
assert reason in result.stderr, (reason,result.stderr)
records.append(dict(command=command,exit_code=result.returncode,expected_exit_code=expected,matched_reason=reason,assertion_errors=[line for line in result.stderr.splitlines() if line.startswith('AssertionError:')]))
for name,edit in [('invalid-default','bpy.ops.wm.read_factory_settings(use_empty=True)'),('invalid-atoms',"bpy.data.objects.remove(bpy.data.objects['PairA_H2'],do_unlink=True)"),('invalid-volume',"bpy.data.objects['Droplet'].animation_data.drivers[1].driver.expression='1'"),('invalid-containment',"bpy.data.objects['Liquid9_H1'].data.vertices[0].co.x=10")]:
fixture=BASE/'generated-source'/f'{name}.blend'
expression=f'import bpy; bpy.ops.wm.open_mainfile(filepath={str(SOURCE)!r}); {edit}; bpy.ops.wm.save_as_mainfile(filepath={str(fixture)!r})'
execute(['--python-expr',expression],0)
execute(['--python',str(VERIFY),'--','--source',str(fixture),'--out',str(BASE/'generated-source/invalid-result.json')],1,'물 영역 밖 또는 여백 부족' if name=='invalid-containment' else None)
execute(['--python',str(VERIFY),'--','--source',str(BASE/'generated-source/missing.blend'),'--out',str(BASE/'generated-source/invalid-result.json')],1)
execute(['--python',str(VERIFY),'--','--source',str(BASE.parent/'r2/generated-source/preview.blend'),'--out',str(BASE/'generated-source/invalid-result.json')],1,'물 영역 밖 또는 여백 부족')
execute(['--python',str(VERIFY),'--','--source',str(SOURCE),'--out',str(BASE/'model-check.json')],0)
(BASE/'model-test-results.json').write_text(json.dumps(dict(result='GREEN',negative_cases=6,positive_cases=1,executions=records),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('실제 실패 사례 6개 RED, 수정 원본 GREEN')
@@ -0,0 +1,83 @@
"""컷·연결·새 버전 미디어와 실제 실행 증거를 검사한다."""
import hashlib
import json
from pathlib import Path
import jsonschema
ROOT=Path(__file__).resolve().parents[3]
PROJECT=ROOT/'projects/water-hydrogen-bond-surface-tension'
def read(path):
return json.loads(path.read_text(encoding='utf-8'))
if __name__=='__main__':
a=read(PROJECT/'stages/08-scenes/r3/artifact.json')
b=read(PROJECT/'stages/09-entities/r3/artifact.json')
schema=read(ROOT/'contracts/artifact.schema.json')
jsonschema.validate(a,schema)
jsonschema.validate(b,schema)
assert a["revision"]==b["revision"]==3
assert {"stage_id":"08-scenes","revision":3} in b["inputs"]
cuts=read(PROJECT/'stages/07-cuts/r1/artifact.json')['payload']
ids=[c['id'] for c in cuts['cuts']]
assert len(ids)==31 and len(cuts['captions'])==38 and cuts['narration_duration_ms']==110560
assert cuts['cuts'][0]['start_ms']==0 and cuts['cuts'][-1]['end_ms']==110560
assert all(0<c['end_ms']-c['start_ms']<=5000 for c in cuts['cuts'])
assert all(x['end_ms']==y['start_ms'] for x,y in zip(cuts['cuts'],cuts['cuts'][1:]))
scenes=a['payload']['scenes']
connections=a['payload']['connections']
assert [s['cut_id'] for s in scenes]==ids and len(connections)==30
assert [(c['from_cut_id'],c['to_cut_id']) for c in connections]==list(zip(ids,ids[1:]))
for left,right,connection in zip(scenes,scenes[1:],connections):
if connection['mode']=='continuous':
assert left['end_state']==right['start_state']
assert left['camera']==right['camera']
else:
assert left['start_state'].split(';')[0]!=right['start_state'].split(';')[0]
assert b['payload']['characters']==[]
assert [p['cut_id'] for p in b['payload']['appearances']]==ids
bg={g['id'] for g in b['payload']['backgrounds']}
assert all(not p['character_ids'] and set(p['background_ids'])<=bg for p in b['payload']['appearances'])
preview=a['payload']['preview']
source=preview['scene_file']
assert source['verified'] and hashlib.sha256((ROOT/source['path']).read_bytes()).hexdigest()==source['sha256']
assert (ROOT/source['path']).is_relative_to(PROJECT/'stages/08-scenes/r3')
model=read(PROJECT/'stages/08-scenes/r3/model-check.json')
assert model['source_sha256']==source['sha256']
containment=model['water_containment']
assert containment['frames']==705 and containment['logical_frame_start']==2468 and containment['logical_frame_end']==3172
assert min(containment['minimum_axis_margin'])>=.03 and containment['minimum_projected_margin_px']>0
samples=preview['samples']
assert len(samples)==6 and len({s['id'] for s in samples})==6
assets=[dict(path=source['path'],sha256=source['sha256'])]
frame_reviews=[]
for sample in samples:
media=sample['media']
path=ROOT/media['path']
assert path.is_relative_to(PROJECT/'stages/08-scenes/r3') and media['verified']
evidence_path=path.parent/'evidence.json'
evidence=read(evidence_path)
assert evidence['media']==media and evidence['scene_file']==source and evidence['render_profile']==preview['render_profile']
assert (media['width'],media['height'])==(1080,1920)
if media['kind']=='video':
cut=next(c for c in cuts['cuts'] if c['id']==sample['cut_ids'][0])
assert media['duration_ms']==cut['end_ms']-cut['start_ms']
assert evidence['logical_frames']==list(range(cut['start_ms']//20,cut['end_ms']//20))
for frame in (evidence['logical_frames'][0],evidence['logical_frames'][len(evidence['logical_frames'])//2],evidence['logical_frames'][-1]):
f=path.parent/f'frame-{frame:08}.png'
frame_reviews.append(f.relative_to(ROOT).as_posix())
assets.append(dict(path=f.relative_to(ROOT).as_posix(),sha256=hashlib.sha256(f.read_bytes()).hexdigest()))
for f in (path,evidence_path):
assets.append(dict(path=f.relative_to(ROOT).as_posix(),sha256=hashlib.sha256(f.read_bytes()).hexdigest()))
result=dict(result='GREEN',cuts=31,captions=38,connections=30,total_ms=110560,fps=50,total_frames=5528,profile=preview['render_profile'],source_sha256=source['sha256'],samples=6,visual_review_frames=frame_reviews,actual_assets=assets,scenes_approval='pending',formal_10_12='not_started')
motion=read(PROJECT/'stages/08-scenes/r3/motion-check.json')
assert motion['source_sha256']==source['sha256'] and len(motion['views'])==3
for view in motion['views']:
assert all(change>0 for change in view['mean_absolute_pixel_change'])
path=ROOT/view['mobile_view_path']
assert hashlib.sha256(path.read_bytes()).hexdigest()==view['mobile_view_sha256']
assets.append(dict(path=view['mobile_view_path'],sha256=view['mobile_view_sha256']))
(PROJECT/'stages/08-scenes/r3/design-check.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('DESIGN_GREEN: 31 cuts, 30 boundaries, 38 captions, 6 actual samples')
@@ -0,0 +1,190 @@
"""Blender 내부에서 실제 원본·구조·평가 부피를 검사한다."""
import argparse
import hashlib
import json
from pathlib import Path
import sys
import bpy
import bmesh
import numpy as np
from mathutils import Vector
from bpy_extras.object_utils import world_to_camera_view
def containment(scene):
"""전체 프레임의 실제 mesh 정점을 물 영역·화면 윤곽과 대조한다."""
bpy.context.window.scene = scene
body = scene.objects['WaterCutawayBody']
deps = bpy.context.evaluated_depsgraph_get()
evaluated = body.evaluated_get(deps)
mesh = evaluated.to_mesh()
water = np.array([tuple(evaluated.matrix_world @ v.co) for v in mesh.vertices])
faces=[list(face.vertices) for face in mesh.polygons]
evaluated.to_mesh_clear()
low, high = water.min(axis=0), water.max(axis=0)
# 실제 물 표시 mesh에서 경계를 얻고 중심·별도 메타데이터만으로 판정하지 않는다.
origin = world_to_camera_view(scene,scene.camera,Vector((0,0,0)))
projection = np.array([tuple(world_to_camera_view(scene,scene.camera,Vector(b))-origin)[:2] for b in ((1,0,0),(0,1,0),(0,0,1))])
offset = np.array(tuple(origin)[:2])
screen = (water @ projection + offset) * (1080,1920)
face_polygons=[screen[face] for face in faces]
points = sorted(set(map(tuple,screen)))
def cross(a,b,c):
return (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])
lower, upper = [], []
for chain, vertices in ((lower,points),(upper,reversed(points))):
for p in vertices:
while len(chain)>=2 and cross(chain[-2],chain[-1],p)<=0:
chain.pop()
chain.append(p)
hull = np.array(lower[:-1]+upper[:-1])
minimum = np.full(3,np.inf)
pixel_margin = float('inf')
vertex_count = 0
for logical in range(2468,3173):
scene.frame_set(logical+1)
deps = bpy.context.evaluated_depsgraph_get()
for obj in scene.objects:
role = obj.get('role')
if role not in ('atom','covalent','hydrogen_bond') or (role=='hydrogen_bond' and obj.scale.x<=.0011):
continue
evaluated = obj.evaluated_get(deps)
mesh = evaluated.to_mesh()
local = np.array([tuple(v.co) for v in mesh.vertices])
transform = np.array(evaluated.matrix_world)
vertices = local @ transform[:3,:3].T + transform[:3,3]
evaluated.to_mesh_clear()
margin = np.minimum(vertices-low,high-vertices).min(axis=0)
assert margin.min()>=.03, f'물 영역 밖 또는 여백 부족: F{logical} {obj.name}, 축 여백 {margin.tolist()}'
minimum = np.minimum(minimum,margin)
projected = (vertices @ projection + offset) * (1080,1920)
assert (projected.min(axis=0)>0).all() and (projected.max(axis=0)<(1080,1920)).all(), '분자 화면 잘림'
covered=np.zeros(len(projected),dtype=bool)
for polygon in face_polygons:
following=np.roll(polygon,-1,axis=0)
area=sum(a[0]*b[1]-b[0]*a[1] for a,b in zip(polygon,following))
if abs(area)<.001:
continue
inside=np.ones(len(projected),dtype=bool)
for a,b in zip(polygon,following):
edge=b-a
signed=(edge[0]*(projected[:,1]-a[1])-edge[1]*(projected[:,0]-a[0]))*(1 if area>0 else -1)
inside &= signed>=-.001
covered |= inside
assert covered.all(), f'실제 파란 절개 면 투영 밖: F{logical} {obj.name}'
for a,b in zip(hull,np.roll(hull,-1,axis=0)):
edge = b-a
distance = (edge[0]*(projected[:,1]-a[1])-edge[1]*(projected[:,0]-a[0]))/np.linalg.norm(edge)
assert distance.min()>=0, f'파란 물 영역 투영 밖: F{logical} {obj.name}'
pixel_margin = min(pixel_margin,float(distance.min()))
vertex_count += len(vertices)
# 앞면과 윗면은 실제 열린 절개이며 물의 고체 껍질을 만들지 않는다.
assert len(body.data.polygons)==4, '물 절개 면 수 불일치'
for face in body.data.polygons:
points=np.array([tuple(body.matrix_world @ body.data.vertices[i].co) for i in face.vertices])
assert not np.allclose(points[:,1],low[1]) and not np.allclose(points[:,2],high[2]), '앞면·윗면 절개가 닫힘'
return dict(logical_frame_start=2468,logical_frame_end=3172,frames=705,water_bounds=[low.tolist(),high.tolist()],minimum_axis_margin=minimum.tolist(),minimum_projected_margin_px=pixel_margin,evaluated_vertices=vertex_count,mesh_roles=['atom','covalent','active_hydrogen_bond'],open_faces=['front','top'],projection_check='actual_face_union_and_outer_hull')
def verify(source, expected=None):
assert source.is_file(), "원본 파일 없음"
bpy.ops.wm.open_mainfile(filepath=str(source))
expected = expected or {"BondPair": (905, 1637), "Interface": (2469, 3173), "DropletStretch": (3173, 4077)}
assert set(bpy.data.scenes) and set(s.name for s in bpy.data.scenes) == set(expected), "대표 Scene 누락/혼입"
assert not any(d.library for d in bpy.data.objects), "외부 객체"
assert not bpy.data.images and not bpy.data.fonts and not bpy.data.movieclips, "외부 자산"
checks = {}
for name, bounds in expected.items():
scene = bpy.data.scenes[name]
bpy.context.window.scene = scene
assert (scene.frame_start, scene.frame_end) == bounds, "실측 범위를 덮지 못함"
assert scene.camera and scene.render.fps == 50
assert (scene.render.resolution_x, scene.render.resolution_y) == (1080, 1920)
assert scene.render.engine == "BLENDER_EEVEE"
molecules = [o for o in scene.objects if o.get("role") == "molecule"]
for mol in molecules:
atoms = [o for o in mol.children if o.get("role") == "atom"]
assert sorted(o["element"] for o in atoms) == ["H", "H", "O"], "O1H2 위반"
assert all(o.type == "MESH" and len(o.data.vertices) > 20 for o in atoms)
assert all(o.data.materials[0].name==('O_Coral' if o['element']=='O' else 'H_Ivory') for o in atoms), '원자 색 재질 불일치'
oxygen = next(o for o in atoms if o["element"] == "O")
hydrogen = [o for o in atoms if o["element"] == "H"]
assert (hydrogen[0].location - oxygen.location).cross(hydrogen[1].location - oxygen.location).length > .01, "V형태 아님"
bonds = [o for o in mol.children if o.get("role") == "covalent"]
assert len(bonds) == 2 and all(o.type == "MESH" for o in bonds), "내부 실선 두 개 누락"
assert all(o.data.materials[0].name=='OH_Navy' for o in bonds), '내부 실선 재질 불일치'
assert all(min((bond.location - (h.location+oxygen.location)/2).length for h in hydrogen)<.001 for bond in bonds), "실선의 O–H 위치 불일치"
for segment in [o for o in scene.objects if o.get("role") == "hydrogen_bond"]:
h, oxygen = (scene.objects[segment[k]] for k in ("hydrogen", "oxygen"))
assert h["element"] == "H" and oxygen["element"] == "O" and h.parent != oxygen.parent, "H···O 분자간 점선 위반"
assert segment.type == "MESH" and segment.dimensions.length > 0
assert segment.data.materials[0].name=='HO_Gold', '분자간 점선 재질 불일치'
checks[name] = {"molecules": len(molecules), "frame_range": list(bounds)}
assert checks["BondPair"]["molecules"] == 2 and checks["Interface"]["molecules"] >= 8
pair = bpy.data.scenes["BondPair"]
bpy.context.window.scene = pair
pair.frame_set(1018)
before = pair.objects["PairB"].matrix_world.translation.copy()
pair.frame_set(1117)
assert (pair.objects["PairB"].matrix_world.translation - before).length > .1, "실제 3D 이동 없음"
interface = bpy.data.scenes['Interface']
bpy.context.window.scene = interface
water_containment = containment(interface)
connection_sets, neighborhoods = [], []
for frame in (2807,2907,3051):
interface.frame_set(frame)
active = sorted({(o['hydrogen'],o['oxygen']) for o in interface.objects if o.get('role')=='hydrogen_bond' and o.scale.x > .5})
connection_sets.append(active)
for segment in [o for o in interface.objects if o.get('role')=='hydrogen_bond']:
h, oxygen = (interface.objects[segment[k]] for k in ('hydrogen','oxygen'))
a,b = h.matrix_world.translation, oxygen.matrix_world.translation
assert (segment.location-a).cross(b-a).length < .002, '점선이 실제 H···O 선분에서 벗어남'
counts=[]
for name in ('Liquid4','Liquid1'):
center=interface.objects[name].matrix_world.translation
neighbors=[o.matrix_world.translation-center for o in interface.objects if o.get('role')=='molecule' and o.name!=name and (o.matrix_world.translation-center).length<2.2]
counts.append(dict(neighbors=len(neighbors),above=sum(p.z>.7 for p in neighbors)))
assert counts[0]['neighbors']>counts[1]['neighbors'] and counts[0]['above']>0 and counts[1]['above']==0, '내부/표면 이웃 배치 차이 없음'
neighborhoods.append(counts)
assert connection_sets[0] != connection_sets[-1], '실제 활성 연결 쌍이 바뀌지 않음'
scene = bpy.data.scenes["DropletStretch"]
bpy.context.window.scene = scene
droplet = scene.objects["Droplet"]
volumes, scales = [], []
for frame in (3173, 3223, 3273, 3323, 3373):
scene.frame_set(frame)
deps = bpy.context.evaluated_depsgraph_get()
evaluated = droplet.evaluated_get(deps)
mesh = evaluated.to_mesh()
bm = bmesh.new()
bm.from_mesh(mesh)
assert all(e.is_manifold for e in bm.edges), "닫힌 mesh 아님"
bm.transform(evaluated.matrix_world)
volumes.append(abs(bm.calc_volume(signed=True)))
scales.append(list(evaluated.scale))
bm.free()
evaluated.to_mesh_clear()
relative = max(abs(v / volumes[0] - 1) for v in volumes)
assert relative <= .01, "중간 프레임 포함 부피 변화 1% 초과"
assert abs(scales[-1][0] - scales[0][0]) > .5, "변형이 실제 평가되지 않음"
anchors = {}
for name, logical in (("BondPair", 1219), ("Interface", 3050), ("DropletStretch", 3372)):
scene = bpy.data.scenes[name]
bpy.context.window.scene = scene
scene.frame_set(logical + 1)
anchors[name] = {"logical_frame":logical,"labels":{}}
for obj in scene.objects:
if obj.get("label"):
p = world_to_camera_view(scene, scene.camera, obj.matrix_world.translation)
anchors[name]['labels'][obj["label"]] = {"x": round(p.x * 1080, 2), "y": round((1-p.y)*1920, 2), "depth": round(p.z, 2)}
return {"water_containment":water_containment,"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(), "blender_version": bpy.app.version_string, "scenes": checks, "interface_frames_blender":[2807,2907,3051], "interface_active_pairs":connection_sets,"interface_neighborhoods":neighborhoods,"volume_frames_blender": [3173,3223,3273,3323,3373], "volumes": volumes, "relative_volume_change": relative, "evaluated_scales": scales, "label_anchors": anchors, "text_baked": False, "physical_simulation": False}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--source", required=True)
parser.add_argument("--out", required=True)
args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:])
result = verify(Path(args.source).resolve())
Path(args.out).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print("MODEL_GREEN", result["source_sha256"], result["relative_volume_change"])
@@ -0,0 +1,113 @@
"""정식 물 영상의 실제 9개 장면·기준 재사용·부피·주석 위치를 검사한다."""
import argparse
import hashlib
import json
from pathlib import Path
import sys
import bpy
import bmesh
from bpy_extras.object_utils import world_to_camera_view
sys.path.insert(0,str(Path(__file__).resolve().parent))
from verify_model import verify
ROOT=Path(__file__).resolve().parents[3]
PROJECT=ROOT/'projects/water-hydrogen-bond-surface-tension'
CUTS=json.loads((PROJECT/'stages/07-cuts/r1/artifact.json').read_text(encoding='utf-8'))['payload']['cuts']
SCENES=json.loads((PROJECT/'stages/08-scenes/r3/artifact.json').read_text(encoding='utf-8'))['payload']['scenes']
EXPECTED={}
for cut,plan in zip(CUTS,SCENES):
name=plan['start_state'].split(';')[0].split('=')[1]
start,end=cut['start_ms']//20+1,cut['end_ms']//20+1
EXPECTED[name]=(min(EXPECTED.get(name,(start,end))[0],start),end)
def volume(obj):
evaluated=obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
mesh=evaluated.to_mesh()
bm=bmesh.new()
bm.from_mesh(mesh)
assert all(e.is_manifold for e in bm.edges), '닫힌 물방울 mesh 아님'
bm.transform(evaluated.matrix_world)
value=abs(bm.calc_volume(signed=True))
bm.free()
evaluated.to_mesh_clear()
return value
def run(source,animated):
result=verify(source,EXPECTED)
assert bpy.context.scene.get('approved_source_sha256') or bpy.data.scenes['BondPair'].get('approved_source_sha256'), '승인 원본 연결 없음'
assert not bpy.data.libraries and not bpy.data.images and not bpy.data.fonts and not bpy.data.sounds
comparison=bpy.data.scenes['EqualVolumeComparison']
bpy.context.window.scene=comparison
comparison.frame_set(comparison.frame_start)
volumes=[volume(comparison.objects[name]) for name in ['ComparisonLong','ComparisonRound']]
assert abs(volumes[0]/volumes[1]-1)<.01, '비교 두 물방울 부피 불일치'
result['comparison_volumes']=volumes
anchors=[]
for cut,plan in zip(CUTS,SCENES):
scene=bpy.data.scenes[plan['start_state'].split(';')[0].split('=')[1]]
bpy.context.window.scene=scene
start,end=cut['start_ms']//20,cut['end_ms']//20
samples=[]
for logical in sorted(set([start,(start+end)//2,end])):
scene.frame_set(logical+1)
labels={}
for obj in scene.objects:
if obj.get('anchor'):
point=world_to_camera_view(scene,scene.camera,obj.matrix_world.translation)
assert 0<point.x<1 and 0<point.y<1 and point.z>0, '주석 위치 화면 밖'
labels[obj['anchor']]=dict(object_name=obj.name,x=round(point.x*1080,3),y=round((1-point.y)*1920,3),depth=round(point.z,3))
assert labels, '컷 주석 앵커 누락'
samples.append(dict(logical_frame=logical,blender_frame=logical+1,labels=labels))
anchors.append(dict(cut_id=cut['id'],scene_name=scene.name,start_frame=start,end_frame=end,samples=samples))
result['cut_anchors']=anchors
if animated:
assert bpy.data.scenes['BondPair'].get('reference_source_sha256'), '실제 10 원본 연결 없음'
scene=bpy.data.scenes['DropletStretch']
bpy.context.window.scene=scene
values=[]
for logical in range(3172,4077):
scene.frame_set(logical+1)
values.append(volume(scene.objects['Droplet']))
relative=max(abs(v/values[0]-1) for v in values)
assert relative<.01, '복귀 포함 물방울 부피 1% 초과'
scene.frame_set(3853)
assert max(abs(s-1) for s in scene.objects['Droplet'].scale)<.001, 'c21 구형 복귀 없음'
result['full_drop_volume']=dict(logical_start=3172,logical_end=4076,frames=905,relative_change=relative)
network=bpy.data.scenes['LiquidNetwork']
bpy.context.window.scene=network
pair_sets=[]
maximum=0
for frame in range(1637,2470):
network.frame_set(frame)
pairs=set()
for obj in network.objects:
if obj.get('role')!='hydrogen_bond' or obj.scale.x<=.0011:
continue
h,o=network.objects[obj['hydrogen']],network.objects[obj['oxygen']]
assert h.parent!=o.parent and h['element']=='H' and o['element']=='O'
a,b=h.matrix_world.translation,o.matrix_world.translation
error=(obj.location-a).cross(b-a).length
assert error<.003, '액체 점선이 실제 H···O 선분에서 벗어남'
maximum=max(maximum,error)
pairs.add((h.name,o.name))
if frame in [1637,2019,2257,2469]:
pair_sets.append(sorted(pairs))
assert pair_sets[0]!=pair_sets[1] and pair_sets[1]!=pair_sets[2], '액체 연결 반복 교체 없음'
result['network_frames']=833
result['network_active_pairs']=pair_sets
result['network_maximum_line_error']=maximum
result['animated']=animated
result['result']='GREEN'
return result
if __name__=='__main__':
parser=argparse.ArgumentParser()
parser.add_argument('--source',required=True)
parser.add_argument('--out',required=True)
parser.add_argument('--animated',action='store_true')
args=parser.parse_args(sys.argv[sys.argv.index('--')+1:])
result=run(Path(args.source),args.animated)
Path(args.out).write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('정식 원본 검사 GREEN',result['source_sha256'])
@@ -0,0 +1,14 @@
# 3D 완성본 사용자 최종 승인
- 기록일: 2026-10-10.
- 실제 사용자 응답: “승인합니다”. 이후 “최종 승인 작업을 계속하세요”로 기록 마무리를 요청했다.
- 승인 대상: 앞선 최종 검토에서 제공한 13-compose r2 MP4/SRT, 14-review r2 보고서 및 함께 보고한 제작 지침 변경.
- 영상: [surface-tension-ko.mp4](../stages/13-compose/r2/render-attempts/1d0ec28d2096c02c-01b90b61-4a1/surface-tension-ko.mp4).
- 영상 SHA-256: `c36bfe927acffde38d60d5068cfc1acc22784bc4d4fdd30ca015bb141f919ce6`.
- 자막 SHA-256: `88aa5d1c5e435e788285f3a6be394c8e94ea8266cd1b280c6ed32a096b9327d3`.
- 검수 보고: [14-review r2](../stages/14-review/r2/review.md).
- 승인 직전 진행 기록: [보존 사본](pre-final-r2-user-approval-project.json).
project.json의 final을 같은 13/14 r2에 대한 approved로 기록하고 13을 complete로 처리했다. 사용자 응답은 최종 결과 수용이며, 사용자가 실제 전체 재생·청취를 수행했다고 구체적으로 진술한 기록이나 Codex의 직접 관찰 증거는 아니다. 사용자 승인 대기는 해제하지만 14 r2의 visual/audio/captions not_checked·추천 incomplete·기존 finding은 변경하지 않는다. 14 상태는 추가 검수 근거 부재를 나타내는 awaiting_input이며 전체 기술 검수 완료를 선언하지 않는다. 같은 버전의 사용자 승인을 다시 요구하지 않는다.
이전 도구 초기화 실패 당시에는 기록을 저장하지 못했다. 재확인에서 PowerShell·CMD·Node 및 파일 쓰기가 성공한 뒤 이번 응답을 저장했다. 원본 영상·자막·WAV·이전 단계 결과를 재생성하거나 수정하지 않았다.
@@ -0,0 +1,195 @@
{
"schema_version": "1.0",
"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",
"image_model": "gpt-image-2.5-sunburst"
},
"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": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r1/artifact.json",
"issues": []
},
{
"stage_id": "09-entities",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/09-entities/r1/artifact.json",
"issues": []
},
{
"stage_id": "10-references",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/10-references/r1/artifact.json",
"issues": []
},
{
"stage_id": "11-images",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/11-images/r1/artifact.json",
"issues": []
},
{
"stage_id": "12-motion",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r1/artifact.json",
"issues": []
},
{
"stage_id": "13-compose",
"status": "awaiting_review",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r1/artifact.json",
"issues": [
{
"code": "FINAL_PLAYBACK_REVIEW_REQUIRED",
"message": "실제 MP4/SRT 생성·기술 검사·화면 표본 확인 완료. 최종 영상의 정상 속도 전체 재생·청취 확인은 final 검토 대기.",
"blocking": true
}
]
},
{
"stage_id": "14-review",
"status": "awaiting_review",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/14-review/r1/artifact.json",
"issues": [
{
"code": "FINAL_PLAYBACK_REVIEW_REQUIRED",
"message": "근거·미확인 영역을 기록한 검수 보고 준비. 전체 재생·청취 미확인으로 recommendation=incomplete; 사용자 final 검토 대기.",
"blocking": true
}
]
}
],
"reviews": [
{
"review_id": "topic",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "01-topic",
"revision": 1
}
],
"feedback": "추천안으로 진행합니다"
},
{
"review_id": "story",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "02-research",
"revision": 1
},
{
"stage_id": "03-script",
"revision": 1
},
{
"stage_id": "04-title",
"revision": 1
}
],
"feedback": "확정합니다."
},
{
"review_id": "scenes",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "07-cuts",
"revision": 1
},
{
"stage_id": "08-scenes",
"revision": 1
},
{
"stage_id": "09-entities",
"revision": 1
}
],
"feedback": "승인합니다."
},
{
"review_id": "final",
"decision": "pending",
"artifact_refs": [
{
"stage_id": "13-compose",
"revision": 1
},
{
"stage_id": "14-review",
"revision": 1
}
],
"feedback": ""
}
]
}
@@ -0,0 +1,213 @@
{
"schema_version": "1.1",
"project_id": "water-hydrogen-bond-surface-tension",
"brief": {
"topic": "물의 수소결합에 의한 표면장력에 대한 설명",
"language": "한국어"
},
"settings": {
"aspect_ratio": "9:16",
"max_duration_ms": 180000,
"max_cut_duration_ms": 5000,
"style_reference": "references/1.png",
"tts_engine": "Qwen3-TTS",
"conda_env": "qwen3-tts",
"visual_engine": "Blender"
},
"stages": [
{
"stage_id": "01-topic",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/01-topic/r1/artifact.json",
"issues": []
},
{
"stage_id": "02-research",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/02-research/r1/artifact.json",
"issues": []
},
{
"stage_id": "03-script",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/03-script/r1/artifact.json",
"issues": []
},
{
"stage_id": "04-title",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/04-title/r1/artifact.json",
"issues": []
},
{
"stage_id": "05-tts-split",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/05-tts-split/r1/artifact.json",
"issues": []
},
{
"stage_id": "06-tts",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/06-tts/r1/artifact.json",
"issues": []
},
{
"stage_id": "07-cuts",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/07-cuts/r1/artifact.json",
"issues": []
},
{
"stage_id": "08-scenes",
"status": "complete",
"revision": 3,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3/artifact.json",
"issues": []
},
{
"stage_id": "09-entities",
"status": "complete",
"revision": 3,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/09-entities/r3/artifact.json",
"issues": []
},
{
"stage_id": "10-references",
"status": "draft",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/10-references/r2/artifact.json",
"issues": [
{
"code": "BLENDER_PRODUCTION_IN_PROGRESS",
"message": "승인된 08·09 r3로 정식 기준 모델·끝점·전체 컷 영상을 제작합니다.",
"blocking": true
}
]
},
{
"stage_id": "11-images",
"status": "draft",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/11-images/r2/artifact.json",
"issues": [
{
"code": "BLENDER_PRODUCTION_IN_PROGRESS",
"message": "승인된 08·09 r3로 정식 기준 모델·끝점·전체 컷 영상을 제작합니다.",
"blocking": true
}
]
},
{
"stage_id": "12-motion",
"status": "draft",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r2/artifact.json",
"issues": [
{
"code": "BLENDER_PRODUCTION_IN_PROGRESS",
"message": "승인된 08·09 r3로 정식 기준 모델·끝점·전체 컷 영상을 제작합니다.",
"blocking": true
}
]
},
{
"stage_id": "13-compose",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
},
{
"stage_id": "14-review",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/14-review/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
}
],
"reviews": [
{
"review_id": "topic",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "01-topic",
"revision": 1
}
],
"feedback": "추천안으로 진행합니다"
},
{
"review_id": "story",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "02-research",
"revision": 1
},
{
"stage_id": "03-script",
"revision": 1
},
{
"stage_id": "04-title",
"revision": 1
}
],
"feedback": "확정합니다."
},
{
"review_id": "scenes",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "07-cuts",
"revision": 1
},
{
"stage_id": "08-scenes",
"revision": 3
},
{
"stage_id": "09-entities",
"revision": 3
}
],
"feedback": "수정본을 승인합니다"
},
{
"review_id": "final",
"decision": "changes_requested",
"artifact_refs": [
{
"stage_id": "13-compose",
"revision": 1
},
{
"stage_id": "14-review",
"revision": 1
}
],
"feedback": "내용은 좋았지만 이미지의 퀄리티가 좋지 않았습니다. GPT-Image-2.5 Sunburst 대신에 blender로 3D 모델을 만들고 이것으로 영상을 만들수 있을까요?"
}
]
}
@@ -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,195 @@
{
"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": "awaiting_review",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r2/artifact.json",
"issues": [
{
"code": "final_review_pending",
"message": "실제 최종 MP4 전체의 정상 속도 시청·청취와 사용자 final 검토가 필요합니다.",
"blocking": true
}
]
},
{
"stage_id": "14-review",
"status": "awaiting_review",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/artifact.json",
"issues": [
{
"code": "final_review_pending",
"message": "실제 최종 MP4 전체의 정상 속도 시청·청취와 사용자 final 검토가 필요합니다.",
"blocking": true
}
]
}
],
"reviews": [
{
"review_id": "topic",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "01-topic",
"revision": 1
}
],
"feedback": "추천안으로 진행합니다"
},
{
"review_id": "story",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "02-research",
"revision": 1
},
{
"stage_id": "03-script",
"revision": 1
},
{
"stage_id": "04-title",
"revision": 1
}
],
"feedback": "확정합니다."
},
{
"review_id": "scenes",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "07-cuts",
"revision": 1
},
{
"stage_id": "08-scenes",
"revision": 3
},
{
"stage_id": "09-entities",
"revision": 3
}
],
"feedback": "수정본을 승인합니다"
},
{
"review_id": "final",
"decision": "pending",
"artifact_refs": [
{
"stage_id": "13-compose",
"revision": 2
},
{
"stage_id": "14-review",
"revision": 2
}
],
"feedback": ""
}
]
}
@@ -0,0 +1,225 @@
{
"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": "awaiting_review",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r2/artifact.json",
"issues": [
{
"code": "SCENES_APPROVAL_PENDING",
"message": "새 08·09 r2와 검증된 3D 대표 PNG·MP4를 사용자 scenes 검토에 제출합니다.",
"blocking": true
}
]
},
{
"stage_id": "09-entities",
"status": "awaiting_review",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/09-entities/r2/artifact.json",
"issues": [
{
"code": "SCENES_APPROVAL_PENDING",
"message": "새 08·09 r2와 검증된 3D 대표 PNG·MP4를 사용자 scenes 검토에 제출합니다.",
"blocking": true
}
]
},
{
"stage_id": "10-references",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/10-references/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
},
{
"stage_id": "11-images",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/11-images/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
},
{
"stage_id": "12-motion",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
},
{
"stage_id": "13-compose",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
},
{
"stage_id": "14-review",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/14-review/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
}
],
"reviews": [
{
"review_id": "topic",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "01-topic",
"revision": 1
}
],
"feedback": "추천안으로 진행합니다"
},
{
"review_id": "story",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "02-research",
"revision": 1
},
{
"stage_id": "03-script",
"revision": 1
},
{
"stage_id": "04-title",
"revision": 1
}
],
"feedback": "확정합니다."
},
{
"review_id": "scenes",
"decision": "pending",
"artifact_refs": [
{
"stage_id": "07-cuts",
"revision": 1
},
{
"stage_id": "08-scenes",
"revision": 2
},
{
"stage_id": "09-entities",
"revision": 2
}
],
"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,7 @@
# 장면 r2 수정 요청
검토 참조: 07-cuts r1, 08-scenes r2, 09-entities r2.
> 물 안쪽과 표면 비교 샘플을 보면 물분자들이 전체 물 박스를 벗어나 있습니다. 다시 확인해주세요.
실제 PNG에서 하단 분자가 물 영역 밖에 보이고, 원본 좌표에서도 앞쪽 분자와 표시 체적이 일치하지 않는다. 새 r3에서 포함 관계를 수정하고 검토한다. 이전 결과는 보존한다.
@@ -0,0 +1,213 @@
{
"schema_version": "1.1",
"project_id": "water-hydrogen-bond-surface-tension",
"brief": {
"topic": "물의 수소결합에 의한 표면장력에 대한 설명",
"language": "한국어"
},
"settings": {
"aspect_ratio": "9:16",
"max_duration_ms": 180000,
"max_cut_duration_ms": 5000,
"style_reference": "references/1.png",
"tts_engine": "Qwen3-TTS",
"conda_env": "qwen3-tts",
"visual_engine": "Blender"
},
"stages": [
{
"stage_id": "01-topic",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/01-topic/r1/artifact.json",
"issues": []
},
{
"stage_id": "02-research",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/02-research/r1/artifact.json",
"issues": []
},
{
"stage_id": "03-script",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/03-script/r1/artifact.json",
"issues": []
},
{
"stage_id": "04-title",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/04-title/r1/artifact.json",
"issues": []
},
{
"stage_id": "05-tts-split",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/05-tts-split/r1/artifact.json",
"issues": []
},
{
"stage_id": "06-tts",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/06-tts/r1/artifact.json",
"issues": []
},
{
"stage_id": "07-cuts",
"status": "complete",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/07-cuts/r1/artifact.json",
"issues": []
},
{
"stage_id": "08-scenes",
"status": "complete",
"revision": 3,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/08-scenes/r3/artifact.json",
"issues": []
},
{
"stage_id": "09-entities",
"status": "complete",
"revision": 3,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/09-entities/r3/artifact.json",
"issues": []
},
{
"stage_id": "10-references",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/10-references/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
},
{
"stage_id": "11-images",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/11-images/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
},
{
"stage_id": "12-motion",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/12-motion/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
},
{
"stage_id": "13-compose",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
},
{
"stage_id": "14-review",
"status": "stale",
"revision": 1,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/14-review/r1/artifact.json",
"issues": [
{
"code": "VISUAL_ENGINE_CHANGED",
"message": "08·09의 Blender 3D 설계가 새 버전으로 바뀌어 기존 r1 결과는 이력으로 보존합니다.",
"blocking": true
}
]
}
],
"reviews": [
{
"review_id": "topic",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "01-topic",
"revision": 1
}
],
"feedback": "추천안으로 진행합니다"
},
{
"review_id": "story",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "02-research",
"revision": 1
},
{
"stage_id": "03-script",
"revision": 1
},
{
"stage_id": "04-title",
"revision": 1
}
],
"feedback": "확정합니다."
},
{
"review_id": "scenes",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "07-cuts",
"revision": 1
},
{
"stage_id": "08-scenes",
"revision": 3
},
{
"stage_id": "09-entities",
"revision": 3
}
],
"feedback": "수정본을 승인합니다"
},
{
"review_id": "final",
"decision": "changes_requested",
"artifact_refs": [
{
"stage_id": "13-compose",
"revision": 1
},
{
"stage_id": "14-review",
"revision": 1
}
],
"feedback": "내용은 좋았지만 이미지의 퀄리티가 좋지 않았습니다. GPT-Image-2.5 Sunburst 대신에 blender로 3D 모델을 만들고 이것으로 영상을 만들수 있을까요?"
}
]
}
@@ -0,0 +1,21 @@
# 영상 생성·합성 도구 추천 — 2026-10-09
사용자가 “추천하는 도구가 있나요?”라고 물어 현재 공식 문서와 이 제작의 실제 조건을 확인했다. 이후 사용자가 “Remotion + FFmpeg로 저장소 내 구현 진행(추천)”을 선택했다. 아래는 선택 전 적용안이며, 승인에 따라 이 제작의 renderer 폴더 안에 실행 구성을 구현한다. 완료·검증 결과는 단계별 review.md에 기록한다.
## 이번 과학 설명 영상의 추천: Remotion + FFmpeg
이번 장면의 핵심은 원자 O1/H2 구조 유지, H···O 점선의 생성·소멸, 내부/표면 이웃의 강조, 물방울 윤곽의 완만한 변형이다. 이 대상과 관계를 시간에 맞춰 직접 제어하는 모션 그래픽이 적합하다는 제작 판단이다. 생성형 영상의 구조 보존 성능을 직접 측정하거나 보장한 결론은 아니다.
- [Remotion 공식 애니메이션 문서](https://www.remotion.dev/docs/animating-properties): React 기반 화면의 속성을 프레임 시간에 맞춰 애니메이션한다. 검증된 PNG의 그림체·장면 배치를 유지하면서 강조와 연결 변화를 제어하는 용도로 제안한다. 실제 구현 시 도형·레이어와 PNG의 일치 및 각 컷의 실제 시작/끝 화면을 검증해야 한다.
- [Remotion Composition](https://www.remotion.dev/docs/composition): 화면 크기·fps·durationInFrames를 명시한다. 이미 승인된 07의 ms 시간을 유지하고 해당 프레임 격자로 정확히 표현되는 fps를 정한다. 컷·음성 길이를 임의로 바꾸지 않는다.
- [FFmpeg 공식 필터 문서](https://www.ffmpeg.org/ffmpeg-filters.html): 컷 연결·음성 결합·자막/텍스트·최종 인코딩과 실측 검사에 활용할 수 있다. 현재 PATH에서 ffmpeg/ffprobe는 찾지 못했으며 다른 위치의 설치 여부와 지원은 확인되지 않았다.
구체적인 적용안: 12에서 컷별 상태 변화를 제어한 MP4를 만들고, 13에서 승인된 110560ms WAV·38개 자막·제목·발화 시점별 주석을 연결한다. 실제 31컷의 모든 경계가 20ms 배수임을 확인했다. 50fps에서는 각 컷의 길이를 정수 프레임으로 표현하며 전체는 5528프레임이다. 이는 실행 설계의 시간 격자 확인이며 실제 MP4 길이 검증을 대신하지 않는다. Qwen3-TTS와 내장 이미지 도구는 그대로 사용한다. 프로젝트 안에만 실행 구성·필요한 의존성을 두는 방안을 제안하며 사용자 전역 환경을 변경하지 않는다. 단순 줌이나 화면 전체 크로스페이드만으로 원자·연결 변화 설명을 대신하지 않는다. 현재 문서·스킬만으로 이 실행 프로그램이 구현되어 있는 것은 아니다.
## 범용적인 생성형 영상 옵션: Luma Ray 3.2 + FFmpeg
[Luma Agents 공식 API](https://docs.agents.lumalabs.ai/api/resources/generations/methods/create/)는 Ray 3.2의 시작/끝 이미지와 keyframes, 세로 9:16을 문서화한다. 현재 video.duration은 5s/10s이며 새 keyframes는 24fps 격자를 사용한다. 이 제작의 컷은 대부분 5초 미만이므로 API 생성 길이를 승인된 실제 컷 길이와 동일하다고 기록할 수 없다. 두 끝 상태와 정확한 길이를 보존하는 후처리·검증 방식을 먼저 정해야 한다. 해당 서비스의 계정/API 인증과 실제 실행 연결도 필요하다.
다양한 인물·풍경·물체 동작을 생성할 범용 파이프라인에는 후보로 적합하다는 판단이다. 이번 분자 도해에서는 실제 중간 프레임에서 원자 수와 연결의 보존을 확인해야 한다. 설치·외부 업로드·API 호출은 수행하지 않았다. 키 값은 채팅이나 제작 기록에 받지 않는다.
현재 선택은 Remotion + FFmpeg이며 Luma는 미선택 후보다. [12 스킬](../../.agents/skills/short-video-motion/SKILL.md)은 “이 스킬은 영상 엔진을 선택·구현하거나 실제 모델 API·영상 생성을 실행하지” 않는다고 명시한다. 실행 구성의 구현은 이 스킬의 기존 기능으로 주장하지 않고 위 사용자의 명시적 추가 요청에 따라 진행한다. 이미지 제작도 계속한다.
@@ -0,0 +1,189 @@
{
"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": "complete",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/13-compose/r2/artifact.json",
"issues": []
},
{
"stage_id": "14-review",
"status": "awaiting_input",
"revision": 2,
"artifact_path": "projects/water-hydrogen-bond-surface-tension/stages/14-review/r2/artifact.json",
"issues": [
{
"code": "full_playback_review_not_checked",
"message": "사용자 final 승인은 13/14 r2에 기록했다. Codex의 정상 속도 전체 시청·청취 근거는 없으므로 14 r2의 visual/audio/captions not_checked 및 incomplete 판정을 보존한다.",
"blocking": true
}
]
}
],
"reviews": [
{
"review_id": "topic",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "01-topic",
"revision": 1
}
],
"feedback": "추천안으로 진행합니다"
},
{
"review_id": "story",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "02-research",
"revision": 1
},
{
"stage_id": "03-script",
"revision": 1
},
{
"stage_id": "04-title",
"revision": 1
}
],
"feedback": "확정합니다."
},
{
"review_id": "scenes",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "07-cuts",
"revision": 1
},
{
"stage_id": "08-scenes",
"revision": 3
},
{
"stage_id": "09-entities",
"revision": 3
}
],
"feedback": "수정본을 승인합니다"
},
{
"review_id": "final",
"decision": "approved",
"artifact_refs": [
{
"stage_id": "13-compose",
"revision": 2
},
{
"stage_id": "14-review",
"revision": 2
}
],
"feedback": "승인합니다"
}
]
}
@@ -0,0 +1,4 @@
node_modules/
.cache/
public/
bundle/
@@ -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 전체 폴더를 강제 추가하지 않는다. 미디어·의존성·캐시·번들은 커밋하지 않는다.
@@ -0,0 +1,22 @@
# 이 제작물의 합성 실행 구성
사용자가 선택한 기존 Remotion + FFmpeg로 승인된 Blender 컷을 합성한다. `project.json`의 현재 포인터와 topic/story/scenes 승인 버전, 선행 완료 상태를 읽는다. 01–07 r1은 그대로 유지하고 승인된 08·09 r3, 정식 10–12 r2와 실제 앵커를 사용한다. 다른 소재의 장면을 자동 제작하는 범용 엔진이 아니다.
PowerShell에서 이 폴더를 작업 디렉터리로 사용한다. 기존 로컬 의존성을 사용하며 재설치하지 않는다.
```powershell
npm.cmd run check
npm.cmd run render -- preview c05 c09 c16 c24 c27 c31
npm.cmd run render -- compose
```
- 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 복사본·캐시·실제 미디어는 Git에 추가하지 않는다. 이전 r1의 결과·사용자 피드백과 원 WAV·스타일은 보존한다. 플랫폼 공통 안전영역을 보장하지 않으며 실제 업로드 화면 검수는 별도다.
@@ -0,0 +1,132 @@
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);
const ochre = (p, i) => p[i] > p[i + 1] + 8 && p[i + 1] > 150 && p[i + 1] > p[i + 2] + 12;
const loaded = new Map();
function loadImage(name) {
if (!loaded.has(name)) loaded.set(name, new Promise((resolve, reject) => {
const im = new Image(); im.onload = () => resolve(im); im.onerror = reject;
im.src = staticFile(name);
}));
return loaded.get(name);
}
function Motion({clip}) {
const frame = useCurrentFrame();
const ref = useRef(null);
const [pair, setPair] = useState(null);
const [handle] = useState(() => delayRender('load actual start/end PNG'));
useEffect(() => {
Promise.all([loadImage(clip.a), loadImage(clip.b)]).then(([a, b]) => {
const c = document.createElement('canvas'); c.width = 945; c.height = 1680;
const ctx = c.getContext('2d', {willReadFrequently: true});
ctx.drawImage(a, 0, 0); const pa = ctx.getImageData(0, 0, 945, 1680);
ctx.clearRect(0, 0, 945, 1680); ctx.drawImage(b, 0, 0);
const pb = ctx.getImageData(0, 0, 945, 1680);
const pixels = [];
for (let i = 0; i < pa.data.length; i += 4) {
if (ochre(pa.data, i) || ochre(pb.data, i)) pixels.push(i);
}
setPair({a, b, pa, pb, pixels}); continueRender(handle);
}).catch((e) => {throw e;});
}, [clip.a, clip.b, handle]);
useLayoutEffect(() => {
if (!pair || !ref.current) return;
const ctx = ref.current.getContext('2d');
const p = ease(clamp((frame * 20 - (clip.plan.delayMs ?? 0)) /
(clip.frames * 20 - (clip.plan.delayMs ?? 0)) * 1.35));
ctx.clearRect(0, 0, 945, 1680);
if (p >= 1 || frame === clip.frames - 1) {ctx.drawImage(pair.b, 0, 0); return;}
ctx.drawImage(pair.a, 0, 0);
if (!p || clip.plan.kind === 'hold') return;
let annotationPixels;
if (clip.plan.kind === 'annotation' || clip.plan.kind === 'deform') {
// Only ochre explanatory pixels change. All source atoms/internal bonds remain intact.
const out = new ImageData(new Uint8ClampedArray(pair.pa.data), 945, 1680);
for (const i of pair.pixels) {
const coordinate = clip.plan.direction === 'x' ? (i / 4 % 945) / 945 :
clip.plan.direction === 'y' ? Math.floor(i / 4 / 945) / 1680 : 0;
const t = clamp(p * (clip.plan.direction ? 1.4 : 1) - coordinate * 0.4);
for (let k = 0; k < 3; k++) out.data[i + k] =
pair.pa.data[i + k] * (1 - t) + pair.pb.data[i + k] * t;
}
ctx.putImageData(out, 0, 0);
annotationPixels = out.data;
}
if (clip.plan.kind === 'deform') {
const [a, b] = clip.boxes;
const box = a.map((v, i) => v + (b[i] - v) * p);
ctx.fillStyle = `rgb(${clip.background.join(',')})`;
// Clear all of the original contour, including non-elliptical antialiasing.
ctx.fillRect(a[0] - 3, a[1] - 3, a[2] + 6, a[3] + 6);
ctx.save(); ctx.beginPath(); ctx.ellipse(box[0] + box[2] / 2, box[1] + box[3] / 2,
box[2] / 2, box[3] / 2, 0, 0, Math.PI * 2); ctx.clip();
ctx.drawImage(pair.a, ...a, ...box);
ctx.globalAlpha = p; ctx.drawImage(pair.b, ...b, ...box); ctx.restore();
// Preserve external energy/arrow pixels if their callout edge overlaps the body bounding box.
for (const i of pair.pixels) {
ctx.fillStyle = `rgb(${annotationPixels[i]},${annotationPixels[i + 1]},${annotationPixels[i + 2]})`;
ctx.fillRect(i / 4 % 945, Math.floor(i / 4 / 945), 1, 1);
}
}
if (clip.plan.regions?.length) {
ctx.save(); ctx.beginPath();
for (const r of clip.plan.regions) ctx.rect(...r);
ctx.clip();
ctx.globalAlpha = p; ctx.drawImage(pair.b, 0, 0); ctx.restore();
}
}, [frame, pair, clip]);
return <canvas ref={ref} width={945} height={1680} style={{width: '100%', height: '100%'}}/>;
}
function TextLayer({data}) {
const frame = useCurrentFrame(), ms = frame * 20;
const caption = data.captions.find((c) => ms >= c.start_ms && ms < c.end_ms);
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: 240, left: 85, right: 85,
fontSize: 64, lineHeight: 1.1, fontWeight: 800, textAlign: 'center'}}>
{data.title}
</div>}
{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,
minHeight: 160, borderRadius: 24, padding: '25px 28px', boxSizing: 'border-box',
background: '#17314cef', color: '#fffdf2', fontSize: 52, fontWeight: 700,
lineHeight: 1.4, whiteSpace: 'pre-line', textAlign: 'center', display: 'flex',
alignItems: 'center', justifyContent: 'center'}}>{caption.text}</div>}
</AbsoluteFill>;
}
function Video({data, composed = false}) {
return <AbsoluteFill style={{background: '#fffdf2'}}>
{data.clips.map((clip) => <Sequence key={clip.id} from={clip.start_ms / 20} durationInFrames={clip.frames}>
{composed ? <OffthreadVideo src={staticFile(clip.video ?? `${clip.id}.mp4`)} muted style={{width: '100%', height: '100%'}}/>
: <Motion clip={clip}/>}
</Sequence>)}
{composed && <TextLayer data={data}/>}
</AbsoluteFill>;
}
function Root() {
const {data} = getInputProps();
return <>
<Composition id="Motion" component={Video} width={1080} height={1920} fps={50}
durationInFrames={5528} defaultProps={{data}}/>
<Composition id="Composed" component={Video} width={1080} height={1920} fps={50}
durationInFrames={5528} defaultProps={{data, composed: true}}/>
</>;
}
registerRoot(Root);
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import {loadInputs, prepareData, validateInputs, measured, absolute} from './data.mjs';
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);
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);
}
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 changed = structuredClone(inputs); mutate(changed);
assert.throws(() => prepareData(changed), undefined, name);
}
console.log(`실제 Blender 입력과 ${cases.length + 6}개 거부 사례 통과: 31컷·62끝점·38자막·5528프레임`);
@@ -0,0 +1,222 @@
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';
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, 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) => {
fs.mkdirSync(path.dirname(file), {recursive: true});
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
};
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}: 실제 승인 필요`);
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 ['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 <= 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}`);
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, 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`));
}
}
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);
}
}
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);
}
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 = 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};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
{
"name": "surface-tension-renderer",
"private": true,
"type": "module",
"scripts": {
"render": "node render.mjs",
"check": "node check.mjs"
},
"dependencies": {
"@remotion/bundler": "4.0.534",
"@remotion/renderer": "4.0.534",
"pngjs": "7.0.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"remotion": "4.0.534"
}
}
@@ -0,0 +1,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, binaries, measured, prepareData, projectDir, rendererDir, relative, sha256, probe, write} from './data.mjs';
const mode = process.argv[2] ?? 'preview';
if (!['preview', 'compose'].includes(mode)) throw new Error('Blender 원 컷은 tools/blender에서 제작합니다. preview 또는 compose를 사용하세요.');
process.chdir(rendererDir);
const data = prepareData();
const ffmpeg = path.join(binaries, 'ffmpeg.exe');
const execute = (args) => execFileSync(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-n', ...args], {stdio: 'inherit'});
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 inputProps = {data};
const composition = await selectComposition({serveUrl, id: 'Composed', inputProps, puppeteerInstance: browser});
try {
if (mode === 'preview') {
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(out, `${clip.id}-${position}.png`)});
}
console.log('실제 MP4를 사용한 합성 화면 표본 생성. 최종 인코딩 영상 검수와 구분합니다.');
} else {
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(`합성: ${percent}%`); last = percent;}
}});
measured(master, data.duration_ms);
const video = path.join(out, 'surface-tension-ko.mp4');
// 승인된 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 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});}
@@ -0,0 +1,137 @@
// Coordinates use the verified 945 x 1680 source PNG canvas.
// Each region changes only an explanatory element; the camera stays fixed.
export const plans = {
c01: {kind: 'patch', regions: [[420, 160, 480, 610]]},
c02: {kind: 'patch', regions: [[540, 200, 340, 340]]},
c03: {kind: 'annotation'}, c04: {kind: 'annotation'},
c05: {kind: 'hold'}, c06: {kind: 'annotation'},
c07: {kind: 'annotation', direction: 'x'}, c08: {kind: 'hold'},
c09: {kind: 'annotation'}, c10: {kind: 'annotation'},
c11: {kind: 'annotation', direction: 'y'},
c12: {kind: 'annotation', direction: 'x'}, c13: {kind: 'hold'},
c14: {kind: 'annotation'},
c15: {kind: 'annotation', direction: 'y'}, c16: {kind: 'annotation'},
c17: {kind: 'hold'}, c18: {kind: 'deform'},
c19: {kind: 'annotation'}, c20: {kind: 'deform'}, c21: {kind: 'deform'},
c22: {kind: 'annotation'}, c23: {kind: 'hold'}, c24: {kind: 'annotation'},
c25: {kind: 'hold'},
c26: {kind: 'patch', regions: [[400, 1030, 495, 225]], delayMs: 1900},
c27: {kind: 'annotation'},
c28: {kind: 'patch', regions: [[420, 160, 480, 610]]},
c29: {kind: 'annotation', direction: 'x'},
c30: {kind: 'deform'},
c31: {kind: 'deform', regions: [[510, 150, 395, 425], [430, 395, 140, 260], [515, 500, 230, 170]]},
};
// Spoken-term timing comes from the approved ASR-aligned 07/08 stages.
export const labels = [
{start: 4380, end: 7920, text: '확대 개념도', x: 704, y: 600},
{start: 7920, end: 11920, text: '산소 1개 · 수소 2개', x: 472, y: 430},
{start: 11920, end: 18080, text: '부분 전하 · 분자 전체는 중성', x: 472, y: 450},
{start: 11920, end: 18080, text: 'δ−', x: 472, y: 710, size: 56},
{start: 14780, end: 18080, text: 'δ+', x: 263, y: 920, size: 52},
{start: 14780, end: 18080, text: 'δ+', x: 677, y: 920, size: 52},
{start: 24380 + 1800, end: 32720, text: '수소결합', x: 510, y: 575},
{start: 28480, end: 32720, text: '분자 안', x: 177, y: 475},
{start: 28480, end: 32720, text: '분자 사이', x: 530, y: 985},
{start: 45120, end: 49360, text: '물끼리 뭉치는 성질', x: 472, y: 1220},
{start: 52080, end: 56120, text: '물 안쪽', x: 472, y: 1085},
{start: 56120, end: 63440, text: '공기 쪽', x: 472, y: 375},
{start: 56120, end: 63440, text: '표면', x: 820, y: 435},
{start: 61000, end: 63440, text: '물 분자 이웃이 적음', x: 472, y: 250},
{start: 67440 + 700, end: 70820, text: '에너지', x: 203, y: 665, size: 32},
{start: 67440, end: 70820, text: '에너지를 보태야 함', x: 472, y: 1080},
{start: 63440, end: 81520, text: '같은 물의 양 · 입체 물방울의 개념도', x: 472, y: 1190, size: 28},
{start: 63440 + 16760, end: 81520, text: '표면장력', x: 472, y: 570},
{start: 81520, end: 87380, text: '같은 물의 양 · 개념도', x: 472, y: 115},
{start: 82960 + 1200, end: 87380, text: '겉면이 가장 작음', x: 670, y: 940},
{start: 87380, end: 97760, text: '외부 영향이 작을 때', x: 225, y: 955, size: 30},
{start: 93420, end: 97760, text: '큰 물방울 · 바닥 접촉 예시', x: 641, y: 955, size: 29},
{start: 96160, end: 97760, text: '중력·바닥의 영향', x: 641, y: 1340, size: 30},
{start: 97760 + 700, end: 107360, text: '확대 개념도', x: 704, y: 130, size: 30},
{start: 101980 + 700, end: 104560, text: '서로 끌림', x: 704, y: 600},
{start: 104560 + 600, end: 107360, text: '표면 넓힘에 저항', x: 472, y: 1080},
];
// 새 합성 좌표는 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,23 @@
{
"schema_version": "1.0",
"project_id": "water-hydrogen-bond-surface-tension",
"stage_id": "01-topic",
"revision": 1,
"inputs": [],
"payload": {
"candidates": [
{
"id": "droplet-to-molecules",
"angle": "물방울은 왜 동그랗게 뭉칠까?",
"summary": "작은 물방울의 모양을 출발점으로, 물 분자 사이 수소결합과 표면장력의 관계를 설명하는 구성이다. 조사에서 분자 사이 끌림, 표면을 늘리는 데 필요한 에너지, 작은 물방울의 모양을 연결할 수 있는 근거를 확인한다. 물방울 관찰과 분자 도해를 설명 자료로 검토하며, 모든 물방울이 언제나 완전한 구라는 설명은 범위에서 제외한다."
},
{
"id": "paperclip-to-molecules",
"angle": "작은 클립이 물 표면에 머무는 모습을 어떻게 설명할까?",
"summary": "작은 클립을 조심히 놓는 관찰을 도입으로, 물의 표면장력을 분자 사이 수소결합과 연결하는 구성이다. 조사에서 실험 조건과 표면장력의 역할을 확인하고 부력·젖음·물체 모양 등 구별할 조건을 점검한다. 금속은 무엇이든 물 위에 뜬다는 설명이나 실제 고체막이 생긴다는 설명은 사용하지 않는다."
}
],
"selected_id": "droplet-to-molecules",
"selection_reason": "물방울이라는 익숙한 관찰에서 분자 수준의 설명으로 이어갈 수 있어 추천한다. 클립 실험의 추가 조건보다 수소결합과 표면장력이라는 입력 주제에 집중하기 쉽다. 추천 후보를 기록한 것이며 실제 사용자 선택·topic 승인은 아직 받지 않았다."
}
}
@@ -0,0 +1,56 @@
# 주제·이야기 각도 검토
## 검토 대상
- 제작 ID: water-hydrogen-bond-surface-tension
- 입력 주제: 물의 수소결합에 의한 표면장력에 대한 설명
- 언어: 한국어
- 검토 ID와 결과 버전: topic / 01-topic / r1
- JSON 위치: [artifact.json](artifact.json)
- 제출 당시 상태: awaiting_review. 아래 실제 사용자 승인 후 현재 상태는 complete다.
- 제출 당시 selected_id는 담당의 추천이었다. 아래 실제 응답으로 추천 후보의 선택·topic 승인을 확인했다.
- 확인하지 못한 내용: 과학적 설명의 출처·실험 조건·도해의 정확성은 topic 승인 뒤 02에서 조사한다. 음성·이미지·영상과 실측 시간은 아직 없다.
## 추천안
**물방울은 왜 동그랗게 뭉칠까?** — droplet-to-molecules
중심 질문은 “작은 물방울의 모양을 물 분자 사이 수소결합과 표면장력으로 어떻게 설명할까?”다. 익숙한 물방울 관찰에서 분자 도해로 들어갔다가 마지막에 물방울의 모양으로 돌아오는 설명을 제안한다. 이 문구는 이야기 각도이며 최종 제목은 03 대본에 맞춰 04에서 정한다.
조사에서 확인할 설명 경로는 수소결합 → 분자 사이 끌림 → 표면을 늘리는 데 필요한 에너지 → 표면장력과 작은 물방울의 모양이다. 현재는 설명 가설·조사 과제로 제시하며, 검증된 주장이나 대본으로 확정하지 않는다.
보여줄 근거 후보는 작은 물방울 관찰과 물 내부·표면의 분자 관계를 비교하는 도해다. 실제 조건과 자료의 적합성은 조사에서 확인한다.
## 다른 각도
**작은 클립이 물 표면에 머무는 모습을 어떻게 설명할까?** — paperclip-to-molecules
클립 관찰로 호기심을 유도한 뒤 같은 분자 원리를 설명한다. 실험 조건과 부력·젖음 등을 구별해야 해서 도입 설명이 늘어날 수 있다. 수소결합 자체에 집중하기에는 추천안이 더 단순하다.
## 다룰 범위와 조사에서 확인할 것
- 물 분자의 성질과 분자 사이 수소결합을 쉬운 말로 설명하는 데 필요한 근거.
- 물 분자 내부 결합과 분자 사이 수소결합을 도해에서 구별하는 방법.
- 분자 사이 끌림과 표면을 늘리는 에너지 비용을 표면장력에 연결하는 설명의 정확성.
- 작은 물방울의 모양을 설명할 수 있는 조건과 중력·바닥 접촉의 영향.
- 도해와 비유를 실제 크기·고정된 분자 위치·영구적인 연결처럼 오해시키지 않는 방법.
복잡한 수식 유도, 수치 계산, 다른 액체 전체의 비교, 생물의 물 위 보행은 이번 중심 설명에 포함하지 않는다. 실제 고체막·사람의 손·영구적인 끈이 있다는 식의 표현은 사용하지 않는다. 중요한 조건은 자료 조사에 따라 범위를 좁혀 설명한다.
## 제작 지침 적용
[내용·구성 지침](../../../../../docs/short-video-guide.md)의 중심 질문·범위·보여줄 증거 기준을 사용했다. 한 질문을 끝에서 회수하고, 관찰에서 원인으로 이어지는 구성을 추천한다.
사실에 없는 위기·반전·실패한 대안을 넣지 않는다. 이번 소재는 원리 설명이므로 문제 해결의 위기를 억지로 만들지 않는다. 표현은 쉽게 쓰되 시청자를 어린이로 한정하지 않는다.
## 제출 당시의 다음 진행과 남은 결정
제출 당시 01은 후보·추천·범위가 준비됐지만 실제 사용자 topic 승인이 없어 완료가 아니었다. 당시 project.json의 topic 결정은 pending이고 02–14는 pending이었다. 실제 승인과 이후 진행은 아래 기록 및 현재 project.json을 따른다.
사용자는 추천안 승인, 다른 후보 선택 또는 범위 수정 중 하나를 결정한다. 추천안 승인 시 같은 r1을 검토한 실제 응답을 총괄이 기록하고 02 자료 조사로 이어간다. 다른 선택·범위 변경이 있으면 r1을 보존하고 새 버전으로 제출한다.
## 실제 사용자 결정
사용자 응답: “추천안으로 진행합니다”
droplet-to-molecules 후보와 01-topic r1의 범위를 승인했다. 총괄이 project.json의 topic 검토를 approved, 01 상태를 complete로 기록했다. 기존 artifact.json은 제출 당시의 추천 이유를 보존하며 수정하지 않는다. 같은 승인 버전으로 02 자료 조사를 시작한다.
@@ -0,0 +1,130 @@
{
"schema_version": "1.0",
"project_id": "water-hydrogen-bond-surface-tension",
"stage_id": "02-research",
"revision": 1,
"inputs": [
{
"stage_id": "01-topic",
"revision": 1
}
],
"payload": {
"sources": [
{
"id": "s1-acs-polarity",
"title": "Lesson 5.1: Water is a Polar Molecule",
"url": "https://www.acs.org/middleschoolchemistry/lessonplans/chapter5/lesson1.html",
"publisher": "American Chemical Society",
"accessed_at": "2026-10-08"
},
{
"id": "s2-openstax-intermolecular",
"title": "10.1 Intermolecular Forces — Chemistry 2e",
"url": "https://openstax.org/books/chemistry-2e/pages/10-1-intermolecular-forces",
"publisher": "OpenStax, Rice University",
"accessed_at": "2026-10-08"
},
{
"id": "s3-openstax-liquids",
"title": "10.2 Properties of Liquids — Chemistry 2e",
"url": "https://openstax.org/books/chemistry-2e/pages/10-2-properties-of-liquids",
"publisher": "OpenStax, Rice University",
"accessed_at": "2026-10-08"
},
{
"id": "s4-usgs-cohesion",
"title": "Adhesion and Cohesion of Water",
"url": "https://www.usgs.gov/water-science-school/science/adhesion-and-cohesion-water",
"publisher": "U.S. Geological Survey, Water Science School",
"accessed_at": "2026-10-08"
},
{
"id": "s5-openstax-water",
"title": "2.2 Water — Biology 2e",
"url": "https://openstax.org/books/biology-2e/pages/2-2-water",
"publisher": "OpenStax, Rice University",
"accessed_at": "2026-10-08"
}
],
"claims": [
{
"id": "c1-polarity",
"text": "물 분자 H2O는 산소 하나와 수소 둘로 이루어진다. 산소가 공유 전자를 더 강하게 끌어당겨 산소 쪽은 부분 음전하, 수소 쪽은 부분 양전하를 띠며 분자 전체는 중성이다.",
"source_ids": [
"s1-acs-polarity",
"s5-openstax-water"
],
"assessment": "verified"
},
{
"id": "c2-hydrogen-bond",
"text": "물에서 한 분자의 부분 양전하를 띤 수소와 다른 분자의 부분 음전하를 띤 산소 사이의 끌림은 수소결합이다. 한 분자 내부의 O–H 공유결합과 구별한다.",
"source_ids": [
"s2-openstax-intermolecular",
"s1-acs-polarity"
],
"assessment": "verified"
},
{
"id": "c3-dynamic-network",
"text": "액체 물에서는 분자가 움직이며 수소결합이 생기고 끊어지므로 영구적으로 고정된 연결망이 아니다.",
"source_ids": [
"s5-openstax-water"
],
"assessment": "verified"
},
{
"id": "c4-cohesion",
"text": "물 분자 사이 수소결합은 물의 응집, 즉 물끼리 뭉치는 성질에 기여하며 물의 높은 표면장력의 중요한 원인이다. 다른 액체에 표면장력이 없다는 뜻은 아니다.",
"source_ids": [
"s5-openstax-water",
"s3-openstax-liquids"
],
"assessment": "verified"
},
{
"id": "c5-interface-neighbors",
"text": "액체 내부와 달리 물-공기 표면의 분자는 공기 방향에 액체 물 분자 이웃이 적다. 내부와 표면의 분자 환경 차이를 설명하는 정성적 모형이다.",
"source_ids": [
"s3-openstax-liquids"
],
"assessment": "verified"
},
{
"id": "c6-surface-energy",
"text": "액체 표면적을 늘리는 데 에너지가 필요하다. 표면장력은 이 에너지 비용과 표면의 확장에 대한 저항을 표현하며 액체는 표면적을 줄이는 방향의 경향을 보인다.",
"source_ids": [
"s3-openstax-liquids"
],
"assessment": "verified"
},
{
"id": "c7-sphere",
"text": "같은 부피에서 구는 표면적이 가장 작다. 중력·공기저항·다른 표면과의 상호작용 영향이 작으면 작은 물방울은 표면장력으로 구형에 가까운 모양을 갖는다.",
"source_ids": [
"s3-openstax-liquids"
],
"assessment": "verified"
},
{
"id": "c8-shape-conditions",
"text": "실제 물방울의 모양에는 중력과 접촉한 표면과의 상호작용 등이 영향을 준다. 큰 물방울이나 바닥에 닿은 물방울이 언제나 완전한 구인 것은 아니다.",
"source_ids": [
"s3-openstax-liquids",
"s4-usgs-cohesion"
],
"assessment": "verified"
},
{
"id": "c9-no-skin",
"text": "물 표면을 별도의 피부나 고체 껍질로 설명하는 것은 부정확하다. 물방울에 그런 별도 막이 있는 것이 아니다.",
"source_ids": [
"s4-usgs-cohesion"
],
"assessment": "verified"
}
],
"open_questions": []
}
}
@@ -0,0 +1,39 @@
# 조사 근거 검토
- 제작 ID: water-hydrogen-bond-surface-tension
- 결과: 02-research r1 / [artifact.json](artifact.json)
- 선행: 사용자 topic 승인된 [01-topic r1](../../01-topic/r1/artifact.json)
- 상태: 중심 설명의 근거 확인 완료. 별도 사용자 검토를 추가하지 않고 03·04와 story 검토에 포함한다.
- 사용자 story 승인: 아직 없음.
## 확인한 근거
2026-10-08에 아래 본문을 실제로 열어 확인했다. 원문을 복제하지 않고 영상에 필요한 주장만 재서술했다.
| 출처 | 실제 확인한 부분 | 사용하는 주장 |
| --- | --- | --- |
| [ACS: Water is a Polar Molecule](https://www.acs.org/middleschoolchemistry/lessonplans/chapter5/lesson1.html) | Key Concepts, unequal sharing of electrons, Attractions on Different Levels | c1·c2: 부분 전하와 분자 내부/사이 결합 구별 |
| [OpenStax: Intermolecular Forces](https://openstax.org/books/chemistry-2e/pages/10-1-intermolecular-forces) | Hydrogen Bonding, Figure 10.10 설명 | c2: 이웃 분자의 H···O 끌림 |
| [OpenStax: Properties of Liquids](https://openstax.org/books/chemistry-2e/pages/10-2-properties-of-liquids) | 응집·표면 분자·Figure 10.16, 표면적 확장 에너지, 중력·표면 영향 | c4–c8: 응집·표면장력·구형의 조건 |
| [USGS: Adhesion and Cohesion of Water](https://www.usgs.gov/water-science-school/science/adhesion-and-cohesion-water) | Cohesion makes a water drop a drop | c8·c9: 실제 물방울 모양과 별도 피부가 없다는 설명 |
| [OpenStax: Water](https://openstax.org/books/biology-2e/pages/2-2-water) | Water’s Polarity, Water’s States, Cohesive and Adhesive Properties | c1·c3·c4: H2O·전하·변하는 결합·응집 |
출처·주장 ID가 모두 유일하고 verified 9개는 확인한 출처를 참조한다. 중심 질문을 막는 미해결 질문은 없다. 수식·표면장력 수치·특정 크기의 경계값은 이번 대본에 넣지 않는다.
## 설명과 도해의 한계
- “살짝 플러스/마이너스”는 부분 전하를 쉽게 풀어 쓴 표현이다. 물 분자 전체는 중성이며 이온처럼 그리지 않는다.
- 한 분자 안의 O–H 결합과 이웃 분자 사이 H···O 끌림을 다른 선으로 구별한다. 원문 그림을 복제하지 않고 이후 사용자 그림체로 새 도해를 설계한다.
- 표면의 이웃 부족은 정성적 환경 설명이다. 모든 표면 분자가 정확히 같은 수의 결합을 갖거나 고정 격자로 배열된다고 표현하지 않는다.
- 에너지 설명을 사용해 표면장력을 연결한다. 표면 분자가 정지 상태에서도 계속 아래로 가속하거나 모두 응축해 고체막이 생긴다는 도해는 사용하지 않는다.
- 공 모양 비교는 같은 물의 양·부피를 유지한다. 바닥에 닿은 물방울까지 완전한 구로 그리지 않는다.
- 물만 표면장력이 있는 액체라고 말하지 않는다. 수소결합은 물의 응집·높은 표면장력에 기여하는 중요한 원인이다.
USGS의 별도 Surface Tension and Water 페이지와 ACS Surface Tension 수업도 확인했으나 표면에서 결합이 무조건 더 강해져 단단한 층이 생긴다는 식의 단순 설명은 채택하지 않았다. 위 채택 출처의 에너지·응집·모양 조건을 중심으로 쓴다. IUPAC surface work 항목은 본문 접근이 실패해 검증 근거로 등록하지 않았다.
## 제작 지침 적용과 전달
[제작 지침](../../../../../docs/short-video-guide.md)의 근거·숫자·비유 한계 기준을 적용했다. 관찰 → 분자 관계 → 표면의 비용 → 물방울 모양이라는 중심 답에 필요한 근거만 남긴다.
클립·소금쟁이·세제 실험, 다른 액체의 순위·수치, 분자 결합 시간의 정량값은 범위 밖이다. 사실에 없는 반전·위기·실패한 대안은 넣지 않는다. 03의 사실 문장은 위 claim ID와 연결하며, 새로운 인과나 수치를 추가하면 다시 조사한다.
@@ -0,0 +1,85 @@
{
"schema_version": "1.0",
"project_id": "water-hydrogen-bond-surface-tension",
"stage_id": "03-script",
"revision": 1,
"inputs": [
{
"stage_id": "01-topic",
"revision": 1
},
{
"stage_id": "02-research",
"revision": 1
}
],
"payload": {
"blocks": [
{
"id": "b1-question",
"text": "작은 물방울은 왜 동그랗게 뭉칠까요? 비밀은 물속의 작은 분자들에 있습니다.",
"claim_ids": [
"c4-cohesion",
"c7-sphere"
]
},
{
"id": "b2-polarity",
"text": "물 분자는 산소 하나와 수소 둘로 이루어져 있어요. 산소 쪽은 살짝 마이너스, 수소 쪽은 살짝 플러스 성질을 띱니다.",
"claim_ids": [
"c1-polarity"
]
},
{
"id": "b3-hydrogen-bond",
"text": "그래서 한 분자의 수소 쪽과 옆 분자의 산소 쪽이 서로 끌어당깁니다. 이 분자 사이의 끌림을 수소결합이라고 해요. 한 물 분자 안의 산소와 수소를 묶는 결합과는 다릅니다.",
"claim_ids": [
"c2-hydrogen-bond"
]
},
{
"id": "b4-cohesion",
"text": "수소결합은 분자들을 영원히 묶는 끈은 아닙니다. 물속에서는 연결이 생겼다 끊어지기를 반복해요. 그래도 많은 분자가 서로 끌어당기니 물은 함께 뭉치려는 성질을 보입니다.",
"claim_ids": [
"c3-dynamic-network",
"c4-cohesion"
]
},
{
"id": "b5-surface",
"text": "이제 물의 표면을 볼까요? 물 안쪽은 다른 물 분자들에 둘러싸여 있지만, 공기와 만나는 표면은 공기 쪽에 물 분자 이웃이 적습니다.",
"claim_ids": [
"c5-interface-neighbors"
]
},
{
"id": "b6-energy",
"text": "이런 표면을 더 넓게 만들려면 에너지를 보태야 합니다. 그래서 물은 표면의 넓이를 줄이는 쪽으로 모양을 바꾸려 해요. 표면이 늘어나는 데 저항하는 이 성질이 표면장력입니다.",
"claim_ids": [
"c6-surface-energy"
]
},
{
"id": "b7-round",
"text": "같은 양의 물이라면, 공 모양이 겉면을 가장 작게 만들 수 있어요. 중력이나 바닥의 영향을 적게 받는 작은 물방울이 둥글어지는 이유입니다. 큰 물방울이나 바닥에 닿은 물방울은 모양이 달라질 수 있어요.",
"claim_ids": [
"c7-sphere",
"c8-shape-conditions"
]
},
{
"id": "b8-answer",
"text": "물방울을 감싼 진짜 껍질이 있는 건 아닙니다. 수소결합으로 서로 끌어당기는 분자들과, 표면을 넓히기 어려운 성질이 작은 물방울의 둥근 모양을 만드는 거예요.",
"claim_ids": [
"c2-hydrogen-bond",
"c4-cohesion",
"c6-surface-energy",
"c7-sphere",
"c9-no-skin"
]
}
],
"full_text": "작은 물방울은 왜 동그랗게 뭉칠까요? 비밀은 물속의 작은 분자들에 있습니다.\n물 분자는 산소 하나와 수소 둘로 이루어져 있어요. 산소 쪽은 살짝 마이너스, 수소 쪽은 살짝 플러스 성질을 띱니다.\n그래서 한 분자의 수소 쪽과 옆 분자의 산소 쪽이 서로 끌어당깁니다. 이 분자 사이의 끌림을 수소결합이라고 해요. 한 물 분자 안의 산소와 수소를 묶는 결합과는 다릅니다.\n수소결합은 분자들을 영원히 묶는 끈은 아닙니다. 물속에서는 연결이 생겼다 끊어지기를 반복해요. 그래도 많은 분자가 서로 끌어당기니 물은 함께 뭉치려는 성질을 보입니다.\n이제 물의 표면을 볼까요? 물 안쪽은 다른 물 분자들에 둘러싸여 있지만, 공기와 만나는 표면은 공기 쪽에 물 분자 이웃이 적습니다.\n이런 표면을 더 넓게 만들려면 에너지를 보태야 합니다. 그래서 물은 표면의 넓이를 줄이는 쪽으로 모양을 바꾸려 해요. 표면이 늘어나는 데 저항하는 이 성질이 표면장력입니다.\n같은 양의 물이라면, 공 모양이 겉면을 가장 작게 만들 수 있어요. 중력이나 바닥의 영향을 적게 받는 작은 물방울이 둥글어지는 이유입니다. 큰 물방울이나 바닥에 닿은 물방울은 모양이 달라질 수 있어요.\n물방울을 감싼 진짜 껍질이 있는 건 아닙니다. 수소결합으로 서로 끌어당기는 분자들과, 표면을 넓히기 어려운 성질이 작은 물방울의 둥근 모양을 만드는 거예요.",
"estimated_duration_ms": null
}
}
@@ -0,0 +1,63 @@
# 대본과 제목 검토
- 제작 ID: water-hydrogen-bond-surface-tension
- 검토 ID: story
- 결과: [03-script r1 JSON](artifact.json), [04-title r1 JSON](../../04-title/r1/artifact.json)
- 근거: 사용자 topic 승인된 [01 r1](../../01-topic/r1/artifact.json), [02 조사 r1](../../02-research/r1/review.md)
- 제출 당시 상태: awaiting_review
- 제출 당시 사용자 story 승인: 없음. 아래 실제 승인 기록으로 05 진행 조건을 충족했다.
- 추천 제목: **물방울은 왜 동그랗게 뭉칠까?**
## 한국어 내레이션 전체
작은 물방울은 왜 동그랗게 뭉칠까요? 비밀은 물속의 작은 분자들에 있습니다.
물 분자는 산소 하나와 수소 둘로 이루어져 있어요. 산소 쪽은 살짝 마이너스, 수소 쪽은 살짝 플러스 성질을 띱니다.
그래서 한 분자의 수소 쪽과 옆 분자의 산소 쪽이 서로 끌어당깁니다. 이 분자 사이의 끌림을 수소결합이라고 해요. 한 물 분자 안의 산소와 수소를 묶는 결합과는 다릅니다.
수소결합은 분자들을 영원히 묶는 끈은 아닙니다. 물속에서는 연결이 생겼다 끊어지기를 반복해요. 그래도 많은 분자가 서로 끌어당기니 물은 함께 뭉치려는 성질을 보입니다.
이제 물의 표면을 볼까요? 물 안쪽은 다른 물 분자들에 둘러싸여 있지만, 공기와 만나는 표면은 공기 쪽에 물 분자 이웃이 적습니다.
이런 표면을 더 넓게 만들려면 에너지를 보태야 합니다. 그래서 물은 표면의 넓이를 줄이는 쪽으로 모양을 바꾸려 해요. 표면이 늘어나는 데 저항하는 이 성질이 표면장력입니다.
같은 양의 물이라면, 공 모양이 겉면을 가장 작게 만들 수 있어요. 중력이나 바닥의 영향을 적게 받는 작은 물방울이 둥글어지는 이유입니다. 큰 물방울이나 바닥에 닿은 물방울은 모양이 달라질 수 있어요.
물방울을 감싼 진짜 껍질이 있는 건 아닙니다. 수소결합으로 서로 끌어당기는 분자들과, 표면을 넓히기 어려운 성질이 작은 물방울의 둥근 모양을 만드는 거예요.
## 검토 메모
- 중심 질문: 작은 물방울의 둥근 모양을 수소결합과 표면장력으로 어떻게 설명할까?
- 끝에서 전달할 답: 분자 사이 끌림과 표면 확장의 에너지 비용이 작은 물방울의 둥근 모양으로 이어진다.
- 제목·첫 문장의 약속: 작은 물방울의 모양을 이유와 연결한다. b7-round와 b8-answer에서 회수한다.
- 비유와 한계: “살짝 플러스/마이너스”는 부분 전하의 쉬운 표현이다. “끈”·“껍질”은 실제로 있다는 비유가 아니라 영구 연결·고체막 오해를 막는 표현이다. “공 모양”은 같은 부피와 외부 영향이 작은 조건을 전제로 한다.
- 덜어낸 정보: 전기음성도 수치·분자 각도·표면장력 수치·수식·클립 실험·다른 액체 비교. 핵심 원리로 돌아오는 데 필요하지 않아 생략했다.
- 시간: estimated_duration_ms=null. 실제 화자·속도·쉼이 정해지지 않아 신뢰할 길이 추정이 없다. 06 실제 통합 WAV에서 180000ms 이내인지 확인한다. 블록은 이야기 단위이며 5초 컷이 아니다.
| 블록 | 역할과 다음으로 이어지는 이유 | 확인한 주장 |
| --- | --- | --- |
| b1-question | 질문과 약속 → 분자를 볼 이유 | c4·c7 |
| b2-polarity | 부분 전하 → 서로 끌리는 이유 | c1 |
| b3-hydrogen-bond | 이름·결합 구별 → 액체 안에서의 작동 | c2 |
| b4-cohesion | 고정된 끈 오해를 막고 응집 설명 → 표면으로 이동 | c3·c4 |
| b5-surface | 내부와 표면의 환경 차이 → 표면을 늘리는 비용 | c5 |
| b6-energy | 에너지 비용과 표면장력 → 줄어든 면적의 모양 | c6 |
| b7-round | 같은 부피·조건과 모양 → 처음 질문 회수 | c7·c8 |
| b8-answer | 껍질 오해를 막고 원인을 요약 | c2·c4·c6·c7·c9 |
위 짧은 표기 c1–c9는 02의 해당 번호로 시작하는 claim ID를 뜻하며 artifact.json에는 정확한 전체 ID를 기록했다. 출처 본문 위치·확인 방법은 02 review.md에 있다. 대본에 미확인 사실·근거 없는 수치는 넣지 않았다.
## 제작 지침 적용과 결정
[제작 지침](../../../../../docs/short-video-guide.md)의 한 질문, 쉬운 표현, 원인과 결과, 제목·도입·결말 연결을 적용했다. 원리 설명이라 별도의 위기·반전·실패한 대안은 만들지 않는다.
사용자에게 필요한 결정은 02 근거·03 대본·04 제목 r1의 승인 또는 수정이다. 추천 제목은 담당의 제안이며 story 승인이 아니다. 승인 시 총괄이 실제 응답·검토 버전을 기록하고 05로 이어간다. 수정은 해당 단계의 새 버전을 만들고 이전 자료를 보존한다.
## 실제 사용자 승인 기록
- 사용자 응답: “확정합니다.”
- 승인 묶음: 02-research r1, 03-script r1, 04-title r1.
- 확정 대본: 위 전체 내레이션과 artifact.json의 동일한 8개 블록.
- 확정 제목: “물방울은 왜 동그랗게 뭉칠까?” / t1-droplet-question.
- 현재 상태: complete. 총괄이 project.json의 story 결정을 approved로 기록했다. 동일 버전의 재승인은 요구하지 않는다.
@@ -0,0 +1,32 @@
{
"schema_version": "1.0",
"project_id": "water-hydrogen-bond-surface-tension",
"stage_id": "04-title",
"revision": 1,
"inputs": [
{
"stage_id": "03-script",
"revision": 1
}
],
"payload": {
"candidates": [
{
"id": "t1-droplet-question",
"text": "물방울은 왜 동그랗게 뭉칠까?",
"rationale": "b1-question의 질문을 그대로 약속하고 b2–b6의 수소결합·표면장력 설명과 b7-round·b8-answer에서 답한다. 작은 물방울의 조건을 대본에서 밝혀 모든 물방울을 완전한 구로 단정하지 않는다. 추천 제목이며 story 사용자 승인은 아직 없다."
},
{
"id": "t2-molecular-attraction",
"text": "물방울을 둥글게 만드는 분자들의 끌림",
"rationale": "b2-polarity·b3-hydrogen-bond·b4-cohesion에서 분자 사이 끌림을 설명하고 b6-energy·b7-round·b8-answer에서 모양과 연결한다. 원리 설명을 제목에서 먼저 드러낸다."
},
{
"id": "t3-surface-resistance",
"text": "물 표면은 왜 쉽게 늘어나지 않을까?",
"rationale": "b5-surface·b6-energy의 표면 환경과 에너지 비용에 초점을 둔다. b7-round·b8-answer의 물방울 모양을 적용 사례로 회수한다. 수식이나 모든 외력에 대한 무한한 저항을 약속하지 않는다."
}
],
"selected_id": "t1-droplet-question"
}
}
@@ -0,0 +1,37 @@
# 제목 후보 검토
- 제작 ID: water-hydrogen-bond-surface-tension
- 검토 ID: story / 04-title r1
- JSON: [artifact.json](artifact.json)
- 사용한 선행: [03-script r1](../../03-script/r1/artifact.json), [전체 대본·검토 메모](../../03-script/r1/review.md)
- 제출 당시 상태: awaiting_review
- 제출 당시 사용자 제목 선택·story 승인: 없음. 아래 실제 승인으로 selected_id를 확정했다.
## 추천과 후보
1. **물방울은 왜 동그랗게 뭉칠까?** — 추천
b1-question의 질문을 그대로 약속하고 b2–b6의 수소결합·표면장력 설명과 b7-round·b8-answer에서 답한다. 작은 물방울의 조건을 대본에서 밝혀 모든 물방울을 완전한 구로 단정하지 않는다. 제출 당시 추천이며 아래 기록으로 확정했다.
2. **물방울을 둥글게 만드는 분자들의 끌림**
b2-polarity·b3-hydrogen-bond·b4-cohesion에서 분자 사이 끌림을 설명하고 b6-energy·b7-round·b8-answer에서 모양과 연결한다. 원리 설명을 제목에서 먼저 드러낸다.
3. **물 표면은 왜 쉽게 늘어나지 않을까?**
b5-surface·b6-energy의 표면 환경과 에너지 비용에 초점을 둔다. b7-round·b8-answer의 물방울 모양을 적용 사례로 회수한다. 수식이나 모든 외력에 대한 무한한 저항을 약속하지 않는다.
첫 후보는 질문을 짧게 제시하고 대본의 결말에서 답하는 방식이라 추천한다. 제목의 “동그랗게”는 작은 물방울의 조건을 대본에서 설명하며 모든 물방울이 완전한 구라고 단정하지 않는다.
## 검토 연결
추천 제목·첫 문장·결말의 약속은 03 review.md에 함께 기록했다. 새 사실·숫자·위기·다른 주제의 약속을 추가하지 않았다. [제작 지침](../../../../../docs/short-video-guide.md)의 제목·도입·결말 일치 기준을 적용했다.
02 근거와 03·04 해당 버전의 실제 사용자 story 승인 전에는 완료로 처리하거나 05로 넘어가지 않는다. 다른 제목·표현으로 수정할 경우 새 버전을 만들며 제목과 무관한 기존 결과를 무조건 재생성하지 않는다.
## 실제 사용자 승인 기록
- 사용자 응답: “확정합니다.”
- 승인 묶음: 02-research r1, 03-script r1, 04-title r1.
- 확정 제목: “물방울은 왜 동그랗게 뭉칠까?” / t1-droplet-question.
- 현재 상태: complete. project.json의 story 승인에 연결하며 원래 후보와 대본은 보존한다.
@@ -0,0 +1,88 @@
{
"schema_version": "1.0",
"project_id": "water-hydrogen-bond-surface-tension",
"revision": 1,
"stage_id": "05-tts-split",
"inputs": [
{
"stage_id": "03-script",
"revision": 1
}
],
"payload": {
"units": [
{
"id": "u01",
"block_ids": [
"b1-question"
],
"display_text": "작은 물방울은 왜 동그랗게 뭉칠까요? 비밀은 물속의 작은 분자들에 있습니다.",
"spoken_text": "작은 물방울은 왜 동그랗게 뭉칠까요? 비밀은 물속의 작은 분자들에 있습니다.",
"pronunciation_notes": "첫 질문은 호기심이 느껴지게, 두 문장 사이에는 자연스럽게 쉰다."
},
{
"id": "u02",
"block_ids": [
"b2-polarity"
],
"display_text": "물 분자는 산소 하나와 수소 둘로 이루어져 있어요. 산소 쪽은 살짝 마이너스, 수소 쪽은 살짝 플러스 성질을 띱니다.",
"spoken_text": "물 분자는 산소 하나와 수소 둘로 이루어져 있어요. 산소 쪽은 살짝 마이너스, 수소 쪽은 살짝 플러스 성질을 띱니다.",
"pronunciation_notes": "산소 하나·수소 둘을 또렷이 읽고 마이너스와 플러스의 대비를 살린다."
},
{
"id": "u03",
"block_ids": [
"b3-hydrogen-bond"
],
"display_text": "그래서 한 분자의 수소 쪽과 옆 분자의 산소 쪽이 서로 끌어당깁니다. 이 분자 사이의 끌림을 수소결합이라고 해요. 한 물 분자 안의 산소와 수소를 묶는 결합과는 다릅니다.",
"spoken_text": "그래서 한 분자의 수소 쪽과 옆 분자의 산소 쪽이 서로 끌어당깁니다. 이 분자 사이의 끌림을 수소결합이라고 해요. 한 물 분자 안의 산소와 수소를 묶는 결합과는 다릅니다.",
"pronunciation_notes": "수소결합은 한 단어로 읽는다. 분자 사이와 한 분자 안의 차이가 들리도록 문장 사이에서 쉰다."
},
{
"id": "u04",
"block_ids": [
"b4-cohesion"
],
"display_text": "수소결합은 분자들을 영원히 묶는 끈은 아닙니다. 물속에서는 연결이 생겼다 끊어지기를 반복해요. 그래도 많은 분자가 서로 끌어당기니 물은 함께 뭉치려는 성질을 보입니다.",
"spoken_text": "수소결합은 분자들을 영원히 묶는 끈은 아닙니다. 물속에서는 연결이 생겼다 끊어지기를 반복해요. 그래도 많은 분자가 서로 끌어당기니 물은 함께 뭉치려는 성질을 보입니다.",
"pronunciation_notes": "생겼다 끊어지기를 자연스럽게 이어 읽는다. 영원히와 그래도의 대비를 살린다."
},
{
"id": "u05",
"block_ids": [
"b5-surface"
],
"display_text": "이제 물의 표면을 볼까요? 물 안쪽은 다른 물 분자들에 둘러싸여 있지만, 공기와 만나는 표면은 공기 쪽에 물 분자 이웃이 적습니다.",
"spoken_text": "이제 물의 표면을 볼까요? 물 안쪽은 다른 물 분자들에 둘러싸여 있지만, 공기와 만나는 표면은 공기 쪽에 물 분자 이웃이 적습니다.",
"pronunciation_notes": "물 안쪽과 공기 쪽의 대비를 살린다. 물 분자 이웃을 끊어 읽지 않는다."
},
{
"id": "u06",
"block_ids": [
"b6-energy"
],
"display_text": "이런 표면을 더 넓게 만들려면 에너지를 보태야 합니다. 그래서 물은 표면의 넓이를 줄이는 쪽으로 모양을 바꾸려 해요. 표면이 늘어나는 데 저항하는 이 성질이 표면장력입니다.",
"spoken_text": "이런 표면을 더 넓게 만들려면 에너지를 보태야 합니다. 그래서 물은 표면의 넓이를 줄이는 쪽으로 모양을 바꾸려 해요. 표면이 늘어나는 데 저항하는 이 성질이 표면장력입니다.",
"pronunciation_notes": "에너지를 보태야 합니다 뒤에서 쉰다. 표면장력은 한 단어로 또렷이 읽는다."
},
{
"id": "u07",
"block_ids": [
"b7-round"
],
"display_text": "같은 양의 물이라면, 공 모양이 겉면을 가장 작게 만들 수 있어요. 중력이나 바닥의 영향을 적게 받는 작은 물방울이 둥글어지는 이유입니다. 큰 물방울이나 바닥에 닿은 물방울은 모양이 달라질 수 있어요.",
"spoken_text": "같은 양의 물이라면, 공 모양이 겉면을 가장 작게 만들 수 있어요. 중력이나 바닥의 영향을 적게 받는 작은 물방울이 둥글어지는 이유입니다. 큰 물방울이나 바닥에 닿은 물방울은 모양이 달라질 수 있어요.",
"pronunciation_notes": "같은 양·중력·바닥 조건을 또렷하게 읽는다. 세 문장의 쉼을 유지한다."
},
{
"id": "u08",
"block_ids": [
"b8-answer"
],
"display_text": "물방울을 감싼 진짜 껍질이 있는 건 아닙니다. 수소결합으로 서로 끌어당기는 분자들과, 표면을 넓히기 어려운 성질이 작은 물방울의 둥근 모양을 만드는 거예요.",
"spoken_text": "물방울을 감싼 진짜 껍질이 있는 건 아닙니다. 수소결합으로 서로 끌어당기는 분자들과, 표면을 넓히기 어려운 성질이 작은 물방울의 둥근 모양을 만드는 거예요.",
"pronunciation_notes": "진짜 껍질이 있는 건 아닙니다 뒤에서 쉰다. 마지막 문장은 자연스러운 설명 속도로 읽는다."
}
]
}
}
@@ -0,0 +1,12 @@
# TTS 발화 단위 분할 검토
- 제작 ID: water-hydrogen-bond-surface-tension / 05-tts-split r1.
- 입력: story 승인된 [03-script r1](../../03-script/r1/artifact.json). 실제 승인 응답은 “확정합니다.”다.
- 결과: [artifact.json](artifact.json)의 u01–u08. 의미가 이어지는 대본 블록을 각각 한 발화 단위로 두고, 각 단위 안의 문장부호로 호흡을 안내한다.
- 표시 텍스트와 발화 텍스트는 원본 그대로다. 문장을 새로 작성하거나 발음용으로 의미를 바꾸지 않았다. 숫자·약어·외국어 변환은 필요하지 않다.
- 각 단위의 block_ids는 b1-question부터 b8-answer까지 순서대로 한 번씩 대응한다. 전체 표시/발화 텍스트를 줄바꿈으로 이어 원본 full_text와 정확히 일치하는지 검사한다.
- 발음 메모는 수소결합·표면장력의 용어, 전하와 표면 조건의 대비, 문장 사이의 쉼을 다룬다. 실제 발음·호흡은 06 WAV에서 별도로 확인한다.
- 단위 길이·음성 길이는 아직 실측 전이다. 5초에 맞춘 분할이나 재배치는 하지 않았다. 화면 컷과 자막은 실제 통합 WAV를 받은 뒤 정한다.
- 적용 지침: [음성 기준](../../../../../docs/short-video-guide.md#음성을-기준으로-컷과-연결을-확정한다). 원리 설명에 필요하지 않은 인물 대화·위기·반전은 추가하지 않았다.
- 검사 결과: JSON Schema Draft 2020-12로 진행 기록과 01–06의 7개 JSON을 검사해 종료코드 0을 확인했다. 실제 승인·선행 버전·8개 유일 ID·원문 표시/발화 텍스트의 정확한 일치·06 요청 동일성도 통과했다.
- 현재 상태: complete. 추가 사용자 승인은 필요하지 않다.
@@ -0,0 +1,238 @@
{
"schema_version": "1.0",
"project_id": "water-hydrogen-bond-surface-tension",
"revision": 1,
"stage_id": "06-tts",
"inputs": [
{
"stage_id": "05-tts-split",
"revision": 1
}
],
"payload": {
"request": {
"engine": "Qwen3-TTS",
"conda_env": "qwen3-tts",
"language": "한국어",
"units": [
{
"id": "u01",
"block_ids": [
"b1-question"
],
"display_text": "작은 물방울은 왜 동그랗게 뭉칠까요? 비밀은 물속의 작은 분자들에 있습니다.",
"spoken_text": "작은 물방울은 왜 동그랗게 뭉칠까요? 비밀은 물속의 작은 분자들에 있습니다.",
"pronunciation_notes": "첫 질문은 호기심이 느껴지게, 두 문장 사이에는 자연스럽게 쉰다."
},
{
"id": "u02",
"block_ids": [
"b2-polarity"
],
"display_text": "물 분자는 산소 하나와 수소 둘로 이루어져 있어요. 산소 쪽은 살짝 마이너스, 수소 쪽은 살짝 플러스 성질을 띱니다.",
"spoken_text": "물 분자는 산소 하나와 수소 둘로 이루어져 있어요. 산소 쪽은 살짝 마이너스, 수소 쪽은 살짝 플러스 성질을 띱니다.",
"pronunciation_notes": "산소 하나·수소 둘을 또렷이 읽고 마이너스와 플러스의 대비를 살린다."
},
{
"id": "u03",
"block_ids": [
"b3-hydrogen-bond"
],
"display_text": "그래서 한 분자의 수소 쪽과 옆 분자의 산소 쪽이 서로 끌어당깁니다. 이 분자 사이의 끌림을 수소결합이라고 해요. 한 물 분자 안의 산소와 수소를 묶는 결합과는 다릅니다.",
"spoken_text": "그래서 한 분자의 수소 쪽과 옆 분자의 산소 쪽이 서로 끌어당깁니다. 이 분자 사이의 끌림을 수소결합이라고 해요. 한 물 분자 안의 산소와 수소를 묶는 결합과는 다릅니다.",
"pronunciation_notes": "수소결합은 한 단어로 읽는다. 분자 사이와 한 분자 안의 차이가 들리도록 문장 사이에서 쉰다."
},
{
"id": "u04",
"block_ids": [
"b4-cohesion"
],
"display_text": "수소결합은 분자들을 영원히 묶는 끈은 아닙니다. 물속에서는 연결이 생겼다 끊어지기를 반복해요. 그래도 많은 분자가 서로 끌어당기니 물은 함께 뭉치려는 성질을 보입니다.",
"spoken_text": "수소결합은 분자들을 영원히 묶는 끈은 아닙니다. 물속에서는 연결이 생겼다 끊어지기를 반복해요. 그래도 많은 분자가 서로 끌어당기니 물은 함께 뭉치려는 성질을 보입니다.",
"pronunciation_notes": "생겼다 끊어지기를 자연스럽게 이어 읽는다. 영원히와 그래도의 대비를 살린다."
},
{
"id": "u05",
"block_ids": [
"b5-surface"
],
"display_text": "이제 물의 표면을 볼까요? 물 안쪽은 다른 물 분자들에 둘러싸여 있지만, 공기와 만나는 표면은 공기 쪽에 물 분자 이웃이 적습니다.",
"spoken_text": "이제 물의 표면을 볼까요? 물 안쪽은 다른 물 분자들에 둘러싸여 있지만, 공기와 만나는 표면은 공기 쪽에 물 분자 이웃이 적습니다.",
"pronunciation_notes": "물 안쪽과 공기 쪽의 대비를 살린다. 물 분자 이웃을 끊어 읽지 않는다."
},
{
"id": "u06",
"block_ids": [
"b6-energy"
],
"display_text": "이런 표면을 더 넓게 만들려면 에너지를 보태야 합니다. 그래서 물은 표면의 넓이를 줄이는 쪽으로 모양을 바꾸려 해요. 표면이 늘어나는 데 저항하는 이 성질이 표면장력입니다.",
"spoken_text": "이런 표면을 더 넓게 만들려면 에너지를 보태야 합니다. 그래서 물은 표면의 넓이를 줄이는 쪽으로 모양을 바꾸려 해요. 표면이 늘어나는 데 저항하는 이 성질이 표면장력입니다.",
"pronunciation_notes": "에너지를 보태야 합니다 뒤에서 쉰다. 표면장력은 한 단어로 또렷이 읽는다."
},
{
"id": "u07",
"block_ids": [
"b7-round"
],
"display_text": "같은 양의 물이라면, 공 모양이 겉면을 가장 작게 만들 수 있어요. 중력이나 바닥의 영향을 적게 받는 작은 물방울이 둥글어지는 이유입니다. 큰 물방울이나 바닥에 닿은 물방울은 모양이 달라질 수 있어요.",
"spoken_text": "같은 양의 물이라면, 공 모양이 겉면을 가장 작게 만들 수 있어요. 중력이나 바닥의 영향을 적게 받는 작은 물방울이 둥글어지는 이유입니다. 큰 물방울이나 바닥에 닿은 물방울은 모양이 달라질 수 있어요.",
"pronunciation_notes": "같은 양·중력·바닥 조건을 또렷하게 읽는다. 세 문장의 쉼을 유지한다."
},
{
"id": "u08",
"block_ids": [
"b8-answer"
],
"display_text": "물방울을 감싼 진짜 껍질이 있는 건 아닙니다. 수소결합으로 서로 끌어당기는 분자들과, 표면을 넓히기 어려운 성질이 작은 물방울의 둥근 모양을 만드는 거예요.",
"spoken_text": "물방울을 감싼 진짜 껍질이 있는 건 아닙니다. 수소결합으로 서로 끌어당기는 분자들과, 표면을 넓히기 어려운 성질이 작은 물방울의 둥근 모양을 만드는 거예요.",
"pronunciation_notes": "진짜 껍질이 있는 건 아닙니다 뒤에서 쉰다. 마지막 문장은 자연스러운 설명 속도로 읽는다."
}
]
},
"result": {
"engine": "Qwen3-TTS",
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"voice": "Sohee",
"files": [
{
"unit_id": "u01",
"media": {
"path": "projects/water-hydrogen-bond-surface-tension/media/06-tts/r1/u01.wav",
"kind": "audio",
"verified": true,
"duration_ms": 7920,
"width": null,
"height": null
}
},
{
"unit_id": "u02",
"media": {
"path": "projects/water-hydrogen-bond-surface-tension/media/06-tts/r1/u02.wav",
"kind": "audio",
"verified": true,
"duration_ms": 10160,
"width": null,
"height": null
}
},
{
"unit_id": "u03",
"media": {
"path": "projects/water-hydrogen-bond-surface-tension/media/06-tts/r1/u03.wav",
"kind": "audio",
"verified": true,
"duration_ms": 14640,
"width": null,
"height": null
}
},
{
"unit_id": "u04",
"media": {
"path": "projects/water-hydrogen-bond-surface-tension/media/06-tts/r1/u04.wav",
"kind": "audio",
"verified": true,
"duration_ms": 16640,
"width": null,
"height": null
}
},
{
"unit_id": "u05",
"media": {
"path": "projects/water-hydrogen-bond-surface-tension/media/06-tts/r1/u05.wav",
"kind": "audio",
"verified": true,
"duration_ms": 14080,
"width": null,
"height": null
}
},
{
"unit_id": "u06",
"media": {
"path": "projects/water-hydrogen-bond-surface-tension/media/06-tts/r1/u06.wav",
"kind": "audio",
"verified": true,
"duration_ms": 18080,
"width": null,
"height": null
}
},
{
"unit_id": "u07",
"media": {
"path": "projects/water-hydrogen-bond-surface-tension/media/06-tts/r1/u07.wav",
"kind": "audio",
"verified": true,
"duration_ms": 16240,
"width": null,
"height": null
}
},
{
"unit_id": "u08",
"media": {
"path": "projects/water-hydrogen-bond-surface-tension/media/06-tts/r1/u08.wav",
"kind": "audio",
"verified": true,
"duration_ms": 12800,
"width": null,
"height": null
}
}
],
"narration": {
"path": "projects/water-hydrogen-bond-surface-tension/media/06-tts/r1/narration.wav",
"kind": "audio",
"verified": true,
"duration_ms": 110560,
"width": null,
"height": null
},
"unit_ranges": [
{
"unit_id": "u01",
"start_ms": 0,
"end_ms": 7920
},
{
"unit_id": "u02",
"start_ms": 7920,
"end_ms": 18080
},
{
"unit_id": "u03",
"start_ms": 18080,
"end_ms": 32720
},
{
"unit_id": "u04",
"start_ms": 32720,
"end_ms": 49360
},
{
"unit_id": "u05",
"start_ms": 49360,
"end_ms": 63440
},
{
"unit_id": "u06",
"start_ms": 63440,
"end_ms": 81520
},
{
"unit_id": "u07",
"start_ms": 81520,
"end_ms": 97760
},
{
"unit_id": "u08",
"start_ms": 97760,
"end_ms": 110560
}
]
}
}
}
@@ -0,0 +1,63 @@
# Qwen3-TTS 요청과 환경 확인
- 제작 ID: water-hydrogen-bond-surface-tension / 06-tts r1.
- 입력: [05 분할 r1](../../05-tts-split/r1/artifact.json), story 승인된 03-script r1.
- 요청: [artifact.json](artifact.json)의 Qwen3-TTS / qwen3-tts / 한국어 / u01–u08.
- 요청 작성 당시 result=null이었다. 현재 실제 추론·WAV·단위 구간 결과를 artifact.json에 기록했다. 청취 검수와 문장 내부 정렬은 미확인이다.
- 기존 환경 확인: Python 3.12.15, qwen-tts 0.1.1, PyTorch 2.11.0+cu128, CUDA 사용 가능, RTX 4080 SUPER 약 16GB. 이 확인은 음성 생성 성공이 아니다.
- 기존 캐시에 Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice가 있다. 실제 모델 로딩과 한국어·화자 확인 결과는 아래에 기록했다.
- import에서 SoX 부재 및 flash-attn 부재 경고가 나왔다. 패키지 import는 종료코드 0이었다. 기존 PyTorch 방식의 CustomVoice 추론 지원을 확인하며 환경을 재설치하지 않는다.
- 공식 사용 근거: [Qwen3-TTS Python Package Usage](https://github.com/QwenLM/Qwen3-TTS#python-package-usage). 모델에서 실제 지원 목록을 확인한 뒤 Korean 언어와 한국어 화자 Sohee를 사용한다. 요청 language의 “한국어”는 보존한다.
- 적용 지침: [음성 기준](../../../../../docs/short-video-guide.md#음성을-기준으로-컷과-연결을-확정한다). 승인 원문·문장부호·동일 화자를 유지한다. 실제 결과 부재를 예상 시간·가상 WAV로 채우지 않는다.
- 요청 당시 확인 계획: 단위별 실제 WAV, 원래 속도의 통합 WAV, 샘플 기반 구간·길이·무누락·무중복, 실제 청취. 아래 완료한 검사와 미확인 사항을 구분한다. 필수 청취 확인 전 07을 작성하지 않는다.
## 실제 로딩과 추론 진행
- 모델명으로 local_files_only 로딩을 시도했으나 캐시의 refs가 없어 LocalEntryNotFoundError/OSError로 종료코드 1이었다. 스냅샷 본문 파일과 가중치는 존재하며, 기존 스냅샷을 직접 지정해 로딩에 성공했다. 캐시·환경을 수정하거나 모델을 다운로드하지 않았다.
- 로딩한 모델: Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice, 스냅샷 0c0e3051f131929182e2c023b9537f8b1c68adfe. CUDA·bfloat16·PyTorch SDPA로 로딩했고 약 6.39초가 걸렸다. flash-attn은 설치하지 않았다.
- 모델의 실제 지원 목록에서 korean 언어와 sohee 화자를 확인했다. API에서는 language=Korean, speaker=Sohee를 사용했다. 승인된 spoken_text를 그대로 전달하며, 자연스러운 한국어 설명 속도·문장 사이 쉼을 지시했다. 재생 속도 조정은 하지 않는다.
- 기존 패키지의 generate_custom_voice를 일회성 실행해 요청 순서대로 WAV 8개를 생성했다. 추론 실행 종료코드 0과 GENERATED_ALL 출력을 확인했다. 저장 형식은 WAV / PCM_16 / 24000Hz / 모노다.
- 청취 수단 확인: u01 실제 WAV를 오디오 입력으로 전달했으나 도구가 “audio content omitted because you do not support audio input”을 반환했다. 현재 세션에서 실제 청취를 수행할 수 없다. 파일·텍스트·길이 검사를 발음·호흡·누락의 청취 검사로 대신하지 않는다.
- 청취 미확인이 남으면 06을 complete로 처리하지 않고 07–09를 보류한다. 이는 새 사용자 승인 경계가 아니라 06의 필수 실제 검증 자료 부재다. 유효한 story 승인은 유지한다.
## 실제 파일과 구간 검증
- 통합 파일: [narration.wav](../../../media/06-tts/r1/narration.wav).
- 실제 길이: 110560ms / 1분 50.56초 / 2653440 샘플. 180000ms 상한 이내다.
- 요청 u01–u08마다 실제 WAV가 한 개씩 있으며, 요청 순서로 연결했다. 단위와 통합 파일의 RIFF/WAV PCM 헤더, 모노·16bit·24000Hz, 양수 프레임 수, 헤더와 PCM 바이트 수 일치를 검사했다.
- 단위 PCM을 그대로 이어 붙이고 통합 파일을 다시 읽어 전체 PCM 바이트가 정확히 같음을 확인했다. 추가 쉼·자르기·리샘플링·재생 속도 변경은 없다. 파일 통합에서의 누락·중복은 없으며, 실제 발화가 원문을 모두 읽었는지는 청취 미확인이다.
- 구간은 누적 샘플 수 × 1000 / 24000으로 측정했다. 이번 파일은 모든 경계가 정확한 정수 ms이며 0부터 110560ms까지 빈 구간·겹침 없이 덮는다. 문장·단어 내부의 발화 위치를 추정한 정렬 결과는 만들지 않았다.
- 파형 표본은 유한하고 무음 파일이 아니다. 8개 단위에서 디지털 최대 진폭에 닿는 표본은 0개다. 이 검사는 잡음·왜곡·호흡·발음의 실제 청취를 대체하지 않는다.
- 통합·파일·구간 검사 실행 종료코드: 0. media.verified=true는 위 실제 파일·형식·메타데이터 검사이며, 전체 단계 청취 검수 통과를 의미하지 않는다.
| 단위 / 실제 파일 | 통합 시작–끝(ms) | 단위 길이(ms) | 원본 블록 / 확인할 내용 |
| --- | --- | --- | --- |
| [u01.wav](../../../media/06-tts/r1/u01.wav) | 0–7920 | 7920 | b1-question / 물방울 질문 |
| [u02.wav](../../../media/06-tts/r1/u02.wav) | 7920–18080 | 10160 | b2-polarity / 산소·수소, 플러스·마이너스 |
| [u03.wav](../../../media/06-tts/r1/u03.wav) | 18080–32720 | 14640 | b3-hydrogen-bond / 수소결합과 분자 내부 결합의 구별 |
| [u04.wav](../../../media/06-tts/r1/u04.wav) | 32720–49360 | 16640 | b4-cohesion / 생겼다 끊어지는 연결과 응집 |
| [u05.wav](../../../media/06-tts/r1/u05.wav) | 49360–63440 | 14080 | b5-surface / 안쪽과 표면의 이웃 차이 |
| [u06.wav](../../../media/06-tts/r1/u06.wav) | 63440–81520 | 18080 | b6-energy / 에너지 비용과 표면장력 |
| [u07.wav](../../../media/06-tts/r1/u07.wav) | 81520–97760 | 16240 | b7-round / 같은 양·중력·바닥 조건 |
| [u08.wav](../../../media/06-tts/r1/u08.wav) | 97760–110560 | 12800 | b8-answer / 실제 껍질 오해를 막고 답 회수 |
## 보류와 재개 조건
- 제출 당시 상태: awaiting_input. 실제 생성은 성공했고 파일·단위 구간 검사는 완료했으나 필수 청취 결과가 없었다. 당시 총괄은 audio_listening_unavailable 문제를 blocking=true로 기록했다. 아래 사용자 확인으로 해소했다.
- 미확인: 한국어로 정확히 읽었는지, 수소결합·표면장력 등 용어 발음, 자연스러운 호흡과 단위 사이 연결, 원문 발화 누락·중복, 실제 잡음·왜곡 여부. 자동 전사나 청취 결과를 만들지 않았다.
- 재개에 필요한 자료: 이 실제 WAV를 청취할 수 있는 환경 또는 단위 ID·구간·이상 여부를 포함한 실제 청취 검수 결과. [승인된 원문](../../03-script/r1/review.md)과 비교해야 한다. 문제가 있으면 영향을 받는 음성 단위와 06 새 버전을 배정하고 기존 파일은 보존한다.
- 다음 경계: 06의 필수 확인을 충족한 뒤 07–09를 준비해 scenes 검토를 받는다. 기존 topic/story 승인은 유지하며 지금 새로운 승인 경계를 추가하지 않는다. 07–14 결과·이미지는 아직 생성하지 않았다.
## 제출 전 검사
- 최종 검사 종료코드 0: 진행 기록과 01–06의 7개 JSON 스키마, 승인·선행 버전·상태, 8개 요청 단위, 9개 실제 WAV, 110560ms의 샘플 기반 구간과 통합 PCM 동일성, 문서의 31개 로컬 링크를 확인했다.
- git diff --check 종료코드 0. 이번 제작물은 projects/ 아래에 있으며 지침·도구 프로그램·전역 환경 설정을 변경하지 않았다.
- 이 기계적 검사에는 실제 청취가 포함되지 않았다. 당시 청취 미확인 문제와 awaiting_input 상태를 유지했다.
## 사용자 청취 확인과 재개
- 실제 사용자 응답: “내레이션 WAV 괜찮습니다.”
- 확인 대상: 앞서 제공한 06-tts r1의 narration.wav / 110560ms. 사용자 청취 확인을 근거로 이 음성을 사용할 수 있다고 기록한다.
- 이 확인은 사용자의 청취 결과다. Codex가 직접 들었다고 기록하거나 전달받지 않은 세부 발음·잡음 수치를 만들어 넣지 않는다.
- 현재 상태: complete. 기존 파일·실측 구간 검사와 사용자 확인으로 진행 조건을 충족했다. 총괄이 blocking 문제를 해소하고 07–09 준비를 재개했다.
- 단어·문장 내부 정렬은 이 응답만으로 확인되지 않는다. 07에서 실제 WAV의 자동 정렬과 승인 원문의 대응을 별도로 검사한다. 승인된 대본·제목·기존 음성 파일은 변경하지 않는다.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,531 @@
{
"schema_version": "1.0",
"project_id": "water-hydrogen-bond-surface-tension",
"stage_id": "07-cuts",
"revision": 1,
"inputs": [
{
"stage_id": "05-tts-split",
"revision": 1
},
{
"stage_id": "06-tts",
"revision": 1
}
],
"payload": {
"narration_duration_ms": 110560,
"timing_evidence": "06-tts r1의 narration.wav는 WAV/PCM_16/모노/24000Hz, 2653440 샘플=110560ms다. 8개 단위 파일의 PCM과 통합 PCM 동일성, 누적 샘플 기반 unit_ranges를 확인했다. 사용자가 통합 WAV에 대해 '내레이션 WAV 괜찮습니다.'라고 청취 확인했다; Codex 직접 청취는 아니다. 실제 단위 WAV를 Whisper openai/whisper-large-v3-turbo(41f01f3fe87f28c78e2fbf8b568835947dd65ed9)로 한국어 전사하고 단어별 timestamp를 추출했다. alignment.json에 원시 결과를 보존했다. 단위 offset을 더해 통합 시간으로 옮겼고 모든 내부 컷 경계 및 자막 시작/끝은 실제 ASR timestamp에서 선택했다. 자동 전사 텍스트의 띄어쓰기·문장부호·용어·조사 차이는 승인된 03/05 원문과 대조해 자막 원문을 보존했다. 글자 수 시간배분·속도변경·ASR 시간 사이 보간은 하지 않았다. 단어 시간은 자동 정렬 결과이며 프레임 단위 수동 청취 검증은 아니다; 최종 출력에서 자막·주석 정렬과 모바일 판독성을 다시 확인한다.",
"cuts": [
{
"id": "c01",
"start_ms": 0,
"end_ms": 4380,
"unit_ids": [
"u01"
],
"narration_text": "작은 물방울은 왜 동그랗게 뭉칠까요?"
},
{
"id": "c02",
"start_ms": 4380,
"end_ms": 7920,
"unit_ids": [
"u01"
],
"narration_text": "비밀은 물속의 작은 분자들에 있습니다."
},
{
"id": "c03",
"start_ms": 7920,
"end_ms": 11920,
"unit_ids": [
"u02"
],
"narration_text": "물 분자는 산소 하나와 수소 둘로 이루어져 있어요."
},
{
"id": "c04",
"start_ms": 11920,
"end_ms": 14780,
"unit_ids": [
"u02"
],
"narration_text": "산소 쪽은 살짝 마이너스,"
},
{
"id": "c05",
"start_ms": 14780,
"end_ms": 18080,
"unit_ids": [
"u02"
],
"narration_text": "수소 쪽은 살짝 플러스 성질을 띱니다."
},
{
"id": "c06",
"start_ms": 18080,
"end_ms": 20340,
"unit_ids": [
"u03"
],
"narration_text": "그래서 한 분자의 수소 쪽과"
},
{
"id": "c07",
"start_ms": 20340,
"end_ms": 24380,
"unit_ids": [
"u03"
],
"narration_text": "옆 분자의 산소 쪽이 서로 끌어당깁니다."
},
{
"id": "c08",
"start_ms": 24380,
"end_ms": 28480,
"unit_ids": [
"u03"
],
"narration_text": "이 분자 사이의 끌림을 수소결합이라고 해요."
},
{
"id": "c09",
"start_ms": 28480,
"end_ms": 32720,
"unit_ids": [
"u03"
],
"narration_text": "한 물 분자 안의 산소와 수소를 묶는 결합과는 다릅니다."
},
{
"id": "c10",
"start_ms": 32720,
"end_ms": 35560,
"unit_ids": [
"u04"
],
"narration_text": "수소결합은 분자들을 영원히 묶는"
},
{
"id": "c11",
"start_ms": 35560,
"end_ms": 40360,
"unit_ids": [
"u04"
],
"narration_text": "끈은 아닙니다. 물속에서는 연결이 생겼다"
},
{
"id": "c12",
"start_ms": 40360,
"end_ms": 45120,
"unit_ids": [
"u04"
],
"narration_text": "끊어지기를 반복해요. 그래도 많은 분자가 서로"
},
{
"id": "c13",
"start_ms": 45120,
"end_ms": 49360,
"unit_ids": [
"u04"
],
"narration_text": "끌어당기니 물은 함께 뭉치려는 성질을 보입니다."
},
{
"id": "c14",
"start_ms": 49360,
"end_ms": 52080,
"unit_ids": [
"u05"
],
"narration_text": "이제 물의 표면을 볼까요?"
},
{
"id": "c15",
"start_ms": 52080,
"end_ms": 56120,
"unit_ids": [
"u05"
],
"narration_text": "물 안쪽은 다른 물 분자들에 둘러싸여"
},
{
"id": "c16",
"start_ms": 56120,
"end_ms": 61000,
"unit_ids": [
"u05"
],
"narration_text": "있지만, 공기와 만나는 표면은 공기 쪽에"
},
{
"id": "c17",
"start_ms": 61000,
"end_ms": 63440,
"unit_ids": [
"u05"
],
"narration_text": "물 분자 이웃이 적습니다."
},
{
"id": "c18",
"start_ms": 63440,
"end_ms": 67440,
"unit_ids": [
"u06"
],
"narration_text": "이런 표면을 더 넓게 만들려면"
},
{
"id": "c19",
"start_ms": 67440,
"end_ms": 70820,
"unit_ids": [
"u06"
],
"narration_text": "에너지를 보태야 합니다."
},
{
"id": "c20",
"start_ms": 70820,
"end_ms": 73120,
"unit_ids": [
"u06"
],
"narration_text": "그래서 물은 표면의 넓이를"
},
{
"id": "c21",
"start_ms": 73120,
"end_ms": 77040,
"unit_ids": [
"u06"
],
"narration_text": "줄이는 쪽으로 모양을 바꾸려 해요."
},
{
"id": "c22",
"start_ms": 77040,
"end_ms": 81520,
"unit_ids": [
"u06"
],
"narration_text": "표면이 늘어나는 데 저항하는 이 성질이 표면장력입니다."
},
{
"id": "c23",
"start_ms": 81520,
"end_ms": 82960,
"unit_ids": [
"u07"
],
"narration_text": "같은 양의 물이라면,"
},
{
"id": "c24",
"start_ms": 82960,
"end_ms": 87380,
"unit_ids": [
"u07"
],
"narration_text": "공 모양이 겉면을 가장 작게 만들 수 있어요."
},
{
"id": "c25",
"start_ms": 87380,
"end_ms": 91520,
"unit_ids": [
"u07"
],
"narration_text": "중력이나 바닥의 영향을 적게 받는 작은 물방울이 둥글어지는"
},
{
"id": "c26",
"start_ms": 91520,
"end_ms": 96160,
"unit_ids": [
"u07"
],
"narration_text": "이유입니다. 큰 물방울이나 바닥에 닿은 물방울은"
},
{
"id": "c27",
"start_ms": 96160,
"end_ms": 97760,
"unit_ids": [
"u07"
],
"narration_text": "모양이 달라질 수 있어요."
},
{
"id": "c28",
"start_ms": 97760,
"end_ms": 101980,
"unit_ids": [
"u08"
],
"narration_text": "물방울을 감싼 진짜 껍질이 있는 건 아닙니다."
},
{
"id": "c29",
"start_ms": 101980,
"end_ms": 104560,
"unit_ids": [
"u08"
],
"narration_text": "수소결합으로 서로 끌어당기는 분자들과,"
},
{
"id": "c30",
"start_ms": 104560,
"end_ms": 107360,
"unit_ids": [
"u08"
],
"narration_text": "표면을 넓히기 어려운 성질이"
},
{
"id": "c31",
"start_ms": 107360,
"end_ms": 110560,
"unit_ids": [
"u08"
],
"narration_text": "작은 물방울의 둥근 모양을 만드는 거예요."
}
],
"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만드는 거예요."
}
]
}
}
@@ -0,0 +1,126 @@
# 실제 음성 기준 컷·자막 검토
- 제작 ID: water-hydrogen-bond-surface-tension / 07-cuts r1 / scenes 검토 제출.
- 입력: [05 분할 r1](../../05-tts-split/r1/artifact.json), [06 실제 음성 r1](../../06-tts/r1/artifact.json). 유효한 story 승인은 유지한다.
- 결과: [artifact.json](artifact.json), [원시 자동 정렬 근거](alignment.json), [실제 통합 WAV](../../../media/06-tts/r1/narration.wav).
- 31개 컷 / 38개 별도 자막 / 110560ms. 각 컷 1440–4880ms이며 모두 5000ms 이내다. 0에서 음성 끝까지 빈 구간·중복 없이 이어진다.
- 제출 당시 상태: 형식·시간·참조 검사 후 awaiting_review. 아래 사용자 승인으로 complete 처리했다.
- 실제 검사 결과: 진행 기록과 01–09의 10개 JSON 스키마, 승인·선행 버전, 기존 9개 WAV의 PCM 동일성, 31개 컷의 전체 포함·5초 상한, 38개 자막의 원문 일치·무겹침·실제 자동 timestamp 대응을 검사해 종료코드 0을 확인했다. 정렬 원시 결과는 별도 근거 파일이며 단계 artifact 스키마에 필드를 추가하지 않았다.
## 시간 근거와 확인 범위
통합 WAV는 모노 PCM_16 / 24000Hz / 2653440 샘플이다. 06의 단위 PCM과 통합 PCM은 동일하고 단위 구간은 누적 샘플로 측정했다. 사용자는 실제 통합 파일에 “내레이션 WAV 괜찮습니다.”라고 확인했다. Codex 직접 청취라고 기록하지 않는다.
실제 u01–u08 WAV를 기존 환경의 Transformers Whisper로 처리해 단어 timestamp를 추출했다. 모델은 openai/whisper-large-v3-turbo, 리비전 41f01f3fe87f28c78e2fbf8b568835947dd65ed9이며 추론 종료코드는 0이다. [공식 모델 사용 문서](https://huggingface.co/openai/whisper-large-v3-turbo#usage)의 word timestamp 기능을 사용했다. 음성을 새로 만들거나 TTS 도구를 바꾼 작업이 아니다. 패키지 설치·업데이트·전역 설정 변경은 하지 않았다.
alignment.json의 timestamp는 단위 파일 안의 초 단위 자동 정렬 결과다. 각 시작·끝을 ms로 변환하고 06 unit_ranges의 offset_ms를 더했다. 모든 내부 컷 경계와 자막 시작·끝이 실제 추출 timestamp에 존재하는지 검사한다. 단위 마지막 컷은 실제 WAV 끝까지 여백을 포함한다. 글자 수 비례 배분·시간 보간·음성 자르기·속도 변경은 없다.
자동 전사의 다음 차이를 원문과 대조했다. 원시 전사 결과는 근거 파일에 보존하고 captions·narration_text는 승인된 원문을 사용한다. 전사 차이를 근거로 사용자 확인된 음성을 임의 재생성하지 않는다.
| 단위 | 전사와 원문의 대표 차이 | 이번 처리 |
| --- | --- | --- |
| u01 | 물 속의 / 물속의 | 원문의 띄어쓰기 유지 |
| u02 | 수소들로 / 수소 둘로, 띕니다 / 띱니다 | 원문 유지. 수소 둘로는 해당 전사 구간의 시간을 사용 |
| u03 | 수소 결합, 문장부호 생략 | 원문의 수소결합·문장부호 유지 |
| u04 | 건 / 끈은 | 원문의 끈은 유지. 묶는 이후와 아닙니다 이전의 실제 추출 구간에 대응 |
| u05 | 볼까요 뒤 쉼표 / 물음표 | 원문의 질문 유지 |
| u06 | 늘어나는데 / 늘어나는 데, 표면 장력 | 원문 띄어쓰기·용어 유지 |
| u07 | 모양의 / 모양이, 둥그러지는 / 둥글어지는 | 원문의 조사·용어 유지 |
| u08 | 수소 결합, 쉼표 차이 | 원문의 용어·문장부호 유지 |
이 대응은 사용자 청취 확인과 실제 음성에서 추출한 자동 시간에 근거한다. 프레임 단위 수동 청취 검증·ASR의 무오류를 주장하지 않는다. 최종 13·14의 실제 출력에서 자막 위치·타이밍·판독성을 재검수한다.
## 31개 컷
한 문장이 여러 화면 컷에 걸칠 수 있다. 컷 사이 음성은 그대로 연속 재생한다. 화면 연속성이 필요한 구간은 08에서 continuous로 연결하며, 의미 없이 매 컷을 하드 컷하지 않는다.
| 컷 | 통합 시작–끝(ms) | 길이(ms) | 발화 단위 | 해당 발화 원문 |
| --- | --- | --- | --- | --- |
| c01 | 0–4380 | 4380 | u01 | 작은 물방울은 왜 동그랗게 뭉칠까요? |
| c02 | 4380–7920 | 3540 | u01 | 비밀은 물속의 작은 분자들에 있습니다. |
| c03 | 7920–11920 | 4000 | u02 | 물 분자는 산소 하나와 수소 둘로 이루어져 있어요. |
| c04 | 11920–14780 | 2860 | u02 | 산소 쪽은 살짝 마이너스, |
| c05 | 14780–18080 | 3300 | u02 | 수소 쪽은 살짝 플러스 성질을 띱니다. |
| c06 | 18080–20340 | 2260 | u03 | 그래서 한 분자의 수소 쪽과 |
| c07 | 20340–24380 | 4040 | u03 | 옆 분자의 산소 쪽이 서로 끌어당깁니다. |
| c08 | 24380–28480 | 4100 | u03 | 이 분자 사이의 끌림을 수소결합이라고 해요. |
| c09 | 28480–32720 | 4240 | u03 | 한 물 분자 안의 산소와 수소를 묶는 결합과는 다릅니다. |
| c10 | 32720–35560 | 2840 | u04 | 수소결합은 분자들을 영원히 묶는 |
| c11 | 35560–40360 | 4800 | u04 | 끈은 아닙니다. 물속에서는 연결이 생겼다 |
| c12 | 40360–45120 | 4760 | u04 | 끊어지기를 반복해요. 그래도 많은 분자가 서로 |
| c13 | 45120–49360 | 4240 | u04 | 끌어당기니 물은 함께 뭉치려는 성질을 보입니다. |
| c14 | 49360–52080 | 2720 | u05 | 이제 물의 표면을 볼까요? |
| c15 | 52080–56120 | 4040 | u05 | 물 안쪽은 다른 물 분자들에 둘러싸여 |
| c16 | 56120–61000 | 4880 | u05 | 있지만, 공기와 만나는 표면은 공기 쪽에 |
| c17 | 61000–63440 | 2440 | u05 | 물 분자 이웃이 적습니다. |
| c18 | 63440–67440 | 4000 | u06 | 이런 표면을 더 넓게 만들려면 |
| c19 | 67440–70820 | 3380 | u06 | 에너지를 보태야 합니다. |
| c20 | 70820–73120 | 2300 | u06 | 그래서 물은 표면의 넓이를 |
| c21 | 73120–77040 | 3920 | u06 | 줄이는 쪽으로 모양을 바꾸려 해요. |
| c22 | 77040–81520 | 4480 | u06 | 표면이 늘어나는 데 저항하는 이 성질이 표면장력입니다. |
| c23 | 81520–82960 | 1440 | u07 | 같은 양의 물이라면, |
| c24 | 82960–87380 | 4420 | u07 | 공 모양이 겉면을 가장 작게 만들 수 있어요. |
| c25 | 87380–91520 | 4140 | u07 | 중력이나 바닥의 영향을 적게 받는 작은 물방울이 둥글어지는 |
| c26 | 91520–96160 | 4640 | u07 | 이유입니다. 큰 물방울이나 바닥에 닿은 물방울은 |
| c27 | 96160–97760 | 1600 | u07 | 모양이 달라질 수 있어요. |
| c28 | 97760–101980 | 4220 | u08 | 물방울을 감싼 진짜 껍질이 있는 건 아닙니다. |
| c29 | 101980–104560 | 2580 | u08 | 수소결합으로 서로 끌어당기는 분자들과, |
| c30 | 104560–107360 | 2800 | u08 | 표면을 넓히기 어려운 성질이 |
| c31 | 107360–110560 | 3200 | u08 | 작은 물방울의 둥근 모양을 만드는 거예요. |
## 38개 별도 자막
표의 /는 줄바꿈 위치다. captions 원문에는 실제 줄바꿈을 기록했다. 최대 두 줄의 의미 단위로 나누고 컷과 일대일 대응하지 않는다. 말이 없는 쉼에는 필요에 따라 자막을 비운다.
| 자막 | 통합 시작–끝(ms) | 표시 문구 |
| --- | --- | --- |
| cap01 | 0–3060 | 작은 물방울은 왜 / 동그랗게 뭉칠까요? |
| cap02 | 4380–7900 | 비밀은 물속의 / 작은 분자들에 있습니다. |
| cap03 | 7920–10160 | 물 분자는 산소 하나와 / 수소 둘로 |
| cap04 | 10160–11480 | 이루어져 있어요. |
| cap05 | 11920–14500 | 산소 쪽은 살짝 마이너스, |
| cap06 | 14780–18040 | 수소 쪽은 살짝 플러스 / 성질을 띱니다. |
| cap07 | 18080–20340 | 그래서 한 분자의 / 수소 쪽과 |
| cap08 | 20340–23280 | 옆 분자의 산소 쪽이 / 서로 끌어당깁니다. |
| cap09 | 24380–27300 | 이 분자 사이의 끌림을 / 수소결합이라고 해요. |
| cap10 | 28480–30060 | 한 물 분자 안의 |
| cap11 | 30060–32620 | 산소와 수소를 묶는 / 결합과는 다릅니다. |
| cap12 | 32720–35560 | 수소결합은 분자들을 / 영원히 묶는 |
| cap13 | 35560–37780 | 끈은 아닙니다. |
| cap14 | 37780–40360 | 물속에서는 / 연결이 생겼다 |
| cap15 | 40360–42940 | 끊어지기를 반복해요. |
| cap16 | 42940–46060 | 그래도 많은 분자가 / 서로 끌어당기니 |
| cap17 | 46060–49340 | 물은 함께 뭉치려는 / 성질을 보입니다. |
| cap18 | 49360–52080 | 이제 물의 표면을 볼까요? |
| cap19 | 52080–57120 | 물 안쪽은 다른 물 분자들에 / 둘러싸여 있지만, |
| cap20 | 57120–61000 | 공기와 만나는 표면은 / 공기 쪽에 |
| cap21 | 61000–63420 | 물 분자 이웃이 적습니다. |
| cap22 | 63440–67440 | 이런 표면을 / 더 넓게 만들려면 |
| cap23 | 67440–69840 | 에너지를 보태야 합니다. |
| cap24 | 70820–73120 | 그래서 물은 / 표면의 넓이를 |
| cap25 | 73120–75420 | 줄이는 쪽으로 / 모양을 바꾸려 해요. |
| cap26 | 77040–79320 | 표면이 늘어나는 데 / 저항하는 |
| cap27 | 79320–81360 | 이 성질이 / 표면장력입니다. |
| cap28 | 81520–82960 | 같은 양의 물이라면, |
| cap29 | 82960–86760 | 공 모양이 겉면을 / 가장 작게 만들 수 있어요. |
| cap30 | 87380–89620 | 중력이나 바닥의 / 영향을 적게 받는 |
| cap31 | 89620–92740 | 작은 물방울이 / 둥글어지는 이유입니다. |
| cap32 | 93420–96160 | 큰 물방울이나 / 바닥에 닿은 물방울은 |
| cap33 | 96160–97740 | 모양이 달라질 수 있어요. |
| cap34 | 97760–99820 | 물방울을 감싼 / 진짜 껍질이 |
| cap35 | 99820–101980 | 있는 건 아닙니다. |
| cap36 | 101980–104560 | 수소결합으로 서로 / 끌어당기는 분자들과, |
| cap37 | 104560–107360 | 표면을 넓히기 / 어려운 성질이 |
| cap38 | 107360–110540 | 작은 물방울의 둥근 모양을 / 만드는 거예요. |
캡션을 순서대로 이어 공백을 정규화하면 03/05 승인 원문과 정확히 일치한다. 도해 라벨은 문장 자막과 분리하고 음성이 해당 대상을 설명할 때만 나타낸다. 모바일 판독성·플랫폼 버튼 간섭은 아직 실제 출력이 없어 미검수다.
## 적용과 다음 검토
[음성·연결 기준](../../../../../docs/short-video-guide.md#음성을-기준으로-컷과-연결을-확정한다)과 [자막 기준](../../../../../docs/short-video-guide.md#자막과-주석을-읽을-수-있게-배치한다)을 적용했다. TTS 단위를 5초로 바꾸지 않았고 사실에 없는 위기·반전·실험은 추가하지 않았다. [08 장면 검토](../../08-scenes/r1/review.md), [09 반복 요소 검토](../../09-entities/r1/review.md)와 같은 묶음에서 검토한다. 승인 전 10 기준 이미지로 진행하지 않는다.
## 실제 scenes 승인
- 사용자 응답: “승인합니다.”
- 승인 버전: 07-cuts r1, 08-scenes r1, 09-entities r1.
- 현재 상태: complete. 실측 컷·자막 원문·기존 WAV는 변경하지 않았고 총괄이 해당 버전을 approved로 기록했다.
@@ -0,0 +1,423 @@
{
"schema_version": "1.0",
"project_id": "water-hydrogen-bond-surface-tension",
"stage_id": "08-scenes",
"revision": 1,
"inputs": [
{
"stage_id": "03-script",
"revision": 1
},
{
"stage_id": "07-cuts",
"revision": 1
},
{
"stage_id": "02-research",
"revision": 1
}
],
"payload": {
"scenes": [
{
"cut_id": "c01",
"description": "작은 물방울의 둥근 모양을 먼저 보여주고 도입의 질문을 약속한다.",
"start_state": "크림색 화면 중앙에 물청색의 작은 둥근 물방울 하나가 떠 있다. 가는 남색 외곽선과 작은 색면 하이라이트만 있으며 별도 껍질·바닥·상표는 없다.",
"end_state": "물방울은 같은 위치·크기·둥근 모양을 유지한다. 우상단의 작은 확대 개념도 자리로 이어지는 가는 지시선이 나타난다. 실제 막이나 힘을 나타내는 선은 아니다.",
"camera": "고정 세로 전경. 크림색 설명 화면 중앙에 작은 물방울, 우상단에 확대 개념도 자리. 원근 과장·흔들림 없음."
},
{
"cut_id": "c02",
"description": "동일한 물방울에 분자 확대 개념도를 연결해 모양의 이유를 분자에서 찾게 한다.",
"start_state": "물방울은 같은 위치·크기·둥근 모양을 유지한다. 우상단의 작은 확대 개념도 자리로 이어지는 가는 지시선이 나타난다. 실제 막이나 힘을 나타내는 선은 아니다.",
"end_state": "중앙 물방울은 그대로다. 우상단 개념도 안에 산소 산호색 큰 원 하나와 수소 미색 작은 원 둘을 가진 물 분자 도식이 보인다. 합성 주석 '분자 확대 개념도'로 실제 비율·위치와 구별한다.",
"camera": "고정 세로 전경. 크림색 설명 화면 중앙에 작은 물방울, 우상단에 확대 개념도 자리. 원근 과장·흔들림 없음."
},
{
"cut_id": "c03",
"description": "산소 하나와 수소 둘을 같은 분자 안에서 순서대로 강조해 물의 구성을 설명한다.",
"start_state": "중앙에 온전한 물 분자 도식이 있다. 산호색 큰 산소 원 하나와 미색 작은 수소 원 둘을 짧은 남색 실선 두 개가 잇는다. 아직 강조 테두리·전하 주석은 없다.",
"end_state": "같은 물 분자의 산소와 수소 둘에 순서대로 얇은 강조가 들어가 원자 수를 구별할 수 있다. 원자가 떨어지거나 새로 결합하는 화학 반응은 보여주지 않는다.",
"camera": "고정 세로 근접 도해. 크림색 바탕 중앙에 온전한 물 분자 하나. 분자 위치·원자 크기·V자 형태를 세 컷에서 유지."
},
{
"cut_id": "c04",
"description": "산소 쪽의 부분 음전하를 강조한다. 분자 전체를 음이온으로 표시하지 않는다.",
"start_state": "같은 물 분자의 산소와 수소 둘에 순서대로 얇은 강조가 들어가 원자 수를 구별할 수 있다. 원자가 떨어지거나 새로 결합하는 화학 반응은 보여주지 않는다.",
"end_state": "분자 위치와 내부 실선은 그대로다. 산소 원 주변만 옅은 강조가 있고 합성 주석 '살짝 마이너스' 또는 δ−가 산소 쪽에 붙는다.",
"camera": "고정 세로 근접 도해. 크림색 바탕 중앙에 온전한 물 분자 하나. 분자 위치·원자 크기·V자 형태를 세 컷에서 유지."
},
{
"cut_id": "c05",
"description": "수소 쪽의 부분 양전하를 추가해 두 쪽의 성질 차이를 설명한다.",
"start_state": "분자 위치와 내부 실선은 그대로다. 산소 원 주변만 옅은 강조가 있고 합성 주석 '살짝 마이너스' 또는 δ−가 산소 쪽에 붙는다.",
"end_state": "같은 분자에서 산소 쪽 δ−와 수소 두 쪽 δ+의 부분 전하 주석이 보인다. 분자 전체를 감싸는 +/− 기호·이온 테두리는 없다.",
"camera": "고정 세로 근접 도해. 크림색 바탕 중앙에 온전한 물 분자 하나. 분자 위치·원자 크기·V자 형태를 세 컷에서 유지."
},
{
"cut_id": "c06",
"description": "왼쪽 분자의 수소 쪽과 오른쪽 분자의 산소 쪽을 찾아 서로 다른 분자임을 보여준다.",
"start_state": "왼쪽·오른쪽에 온전한 물 분자 도식이 있다. 각 분자는 산호색 O 하나·미색 H 둘·남색 내부 실선을 유지한다. 왼쪽 H와 오른쪽 O가 서로 마주보며 아직 분자 사이 점선은 없다.",
"end_state": "분자들은 같은 자리에 있다. 왼쪽 분자의 마주보는 H 쪽과 오른쪽 분자의 O 쪽만 옅은 강조와 부분 전하 주석으로 짚는다. 두 분자는 합쳐지지 않는다.",
"camera": "고정 세로 중근접 도해. 왼쪽과 오른쪽에 온전한 물 분자 한 개씩, 왼쪽의 수소가 오른쪽의 산소를 향한다. 모든 컷에서 두 분자의 위치·방향을 유지."
},
{
"cut_id": "c07",
"description": "이웃 분자의 수소와 산소 사이 끌림을 양쪽의 짧은 방향 표시와 점선으로 표현한다.",
"start_state": "분자들은 같은 자리에 있다. 왼쪽 분자의 마주보는 H 쪽과 오른쪽 분자의 O 쪽만 옅은 강조와 부분 전하 주석으로 짚는다. 두 분자는 합쳐지지 않는다.",
"end_state": "같은 두 분자의 마주보는 H···O 사이에 황토색 가는 점선과 양쪽에서 서로 향하는 짧은 끌림 표시가 보인다. 각 분자 내부 남색 실선은 변하지 않는다.",
"camera": "고정 세로 중근접 도해. 왼쪽과 오른쪽에 온전한 물 분자 한 개씩, 왼쪽의 수소가 오른쪽의 산소를 향한다. 모든 컷에서 두 분자의 위치·방향을 유지."
},
{
"cut_id": "c08",
"description": "분자 사이 점선을 강조하고 실제 발화에 맞춰 수소결합이라는 이름을 붙인다.",
"start_state": "같은 두 분자의 마주보는 H···O 사이에 황토색 가는 점선과 양쪽에서 서로 향하는 짧은 끌림 표시가 보인다. 각 분자 내부 남색 실선은 변하지 않는다.",
"end_state": "같은 H···O 점선이 강조된다. 06의 u03 상대 8100ms 이후 합성 주석 '수소결합'을 붙인다. 영구적인 굵은 끈·새 산소–수소 실선으로 바꾸지 않는다.",
"camera": "고정 세로 중근접 도해. 왼쪽과 오른쪽에 온전한 물 분자 한 개씩, 왼쪽의 수소가 오른쪽의 산소를 향한다. 모든 컷에서 두 분자의 위치·방향을 유지."
},
{
"cut_id": "c09",
"description": "분자 사이 점선과 한 분자 안의 실선을 비교해 두 결합을 구별한다.",
"start_state": "같은 H···O 점선이 강조된다. 06의 u03 상대 8100ms 이후 합성 주석 '수소결합'을 붙인다. 영구적인 굵은 끈·새 산소–수소 실선으로 바꾸지 않는다.",
"end_state": "두 분자와 H···O 점선은 같은 위치에 남는다. 한 분자 내부 O–H 실선에 별도의 얇은 강조를 더하고 합성 주석 '분자 안'과 '분자 사이'를 서로 다른 선 가까이에 둔다.",
"camera": "고정 세로 중근접 도해. 왼쪽과 오른쪽에 온전한 물 분자 한 개씩, 왼쪽의 수소가 오른쪽의 산소를 향한다. 모든 컷에서 두 분자의 위치·방향을 유지."
},
{
"cut_id": "c10",
"description": "수소결합을 영원히 고정된 끈으로 오해하지 않도록 점선 연결이 바뀌는 물속 개념도를 보여준다.",
"start_state": "소수의 온전한 물 분자가 불규칙한 방향으로 물청색 옅은 색면 안에 놓여 있다. 일부 이웃의 H···O 사이에 황토색 점선이 있으며 고정 격자·모든 분자에 같은 연결 수·둘레 막은 없다.",
"end_state": "분자들의 식별·대체적인 위치는 유지된다. 한 이웃 쌍의 점선이 옅어지며 연결이 고정된 끈이 아님을 드러낸다. 내부 O–H 실선은 그대로다.",
"camera": "고정 세로 중경 개념도. 크림색 바탕의 물청색 옅은 색면 위에 소수의 물 분자를 불규칙하게 배치. 같은 시야·분자 식별·방향을 유지하며 소폭 움직임만 허용."
},
{
"cut_id": "c11",
"description": "기존 분자 사이 연결은 약해지고 다른 이웃 사이 연결이 생기는 상태를 보여준다.",
"start_state": "분자들의 식별·대체적인 위치는 유지된다. 한 이웃 쌍의 점선이 옅어지며 연결이 고정된 끈이 아님을 드러낸다. 내부 O–H 실선은 그대로다.",
"end_state": "옅어진 점선 하나가 사라지고 다른 이웃 H···O 사이에 새 점선이 나타난다. 분자들은 서로 겹치거나 원자 수가 변하지 않는다.",
"camera": "고정 세로 중경 개념도. 크림색 바탕의 물청색 옅은 색면 위에 소수의 물 분자를 불규칙하게 배치. 같은 시야·분자 식별·방향을 유지하며 소폭 움직임만 허용."
},
{
"cut_id": "c12",
"description": "새 연결도 끊어지고 또 다른 연결이 생겨 고정된 격자가 아님을 설명한다.",
"start_state": "옅어진 점선 하나가 사라지고 다른 이웃 H···O 사이에 새 점선이 나타난다. 분자들은 서로 겹치거나 원자 수가 변하지 않는다.",
"end_state": "새 점선 일부도 옅어지고 다른 연결이 나타난다. 이웃들 사이 작은 양방향 끌림 표시를 순차 강조하며 정지 격자·동시 일괄 결합은 만들지 않는다.",
"camera": "고정 세로 중경 개념도. 크림색 바탕의 물청색 옅은 색면 위에 소수의 물 분자를 불규칙하게 배치. 같은 시야·분자 식별·방향을 유지하며 소폭 움직임만 허용."
},
{
"cut_id": "c13",
"description": "연결은 바뀌어도 여러 분자 사이의 끌림이 함께 작용해 물이 뭉치는 성질을 회수한다.",
"start_state": "새 점선 일부도 옅어지고 다른 연결이 나타난다. 이웃들 사이 작은 양방향 끌림 표시를 순차 강조하며 정지 격자·동시 일괄 결합은 만들지 않는다.",
"end_state": "분자들은 같은 물청색 색면 안에서 불규칙한 배치를 유지한다. 여러 H···O 끌림 표시가 보이며 합성 주석 '물끼리 뭉치는 성질'로 응집을 회수한다. 분자 간격을 모두 붕괴시키거나 물 부피를 줄이지 않는다.",
"camera": "고정 세로 중경 개념도. 크림색 바탕의 물청색 옅은 색면 위에 소수의 물 분자를 불규칙하게 배치. 같은 시야·분자 식별·방향을 유지하며 소폭 움직임만 허용."
},
{
"cut_id": "c14",
"description": "물속 설명에서 물과 공기가 만나는 표면으로 시점을 옮긴다.",
"start_state": "위에는 크림색 공기, 아래에는 물청색 액체 물이 있고 경계는 가는 색면 외곽선 하나다. 온전한 물 분자 도식은 물 안에 불규칙하게 놓여 있으며 별도 표면층·고체막·아래 방향 가속 화살표는 없다.",
"end_state": "경계와 모든 분자의 위치는 그대로다. 물 안쪽의 한 분자를 얇은 강조로 선택하고 합성 주석 '물 안쪽'을 붙인다.",
"camera": "고정 세로 단면 개념도. 화면 위쪽은 크림색 공기, 아래쪽은 물청색 액체 물. 수평에 가까운 자유 표면과 내부·표면의 분자를 같은 시야에 둔다."
},
{
"cut_id": "c15",
"description": "물 안쪽의 분자는 주변 여러 방향에 물 분자 이웃이 있음을 보여준다.",
"start_state": "경계와 모든 분자의 위치는 그대로다. 물 안쪽의 한 분자를 얇은 강조로 선택하고 합성 주석 '물 안쪽'을 붙인다.",
"end_state": "같은 내부 분자의 주변 여러 방향에 있는 물 분자 이웃을 차례로 강조한다. 특정 결합 수나 고정 배열을 표시하지 않는다.",
"camera": "고정 세로 단면 개념도. 화면 위쪽은 크림색 공기, 아래쪽은 물청색 액체 물. 수평에 가까운 자유 표면과 내부·표면의 분자를 같은 시야에 둔다."
},
{
"cut_id": "c16",
"description": "공기와 만나는 표면의 분자를 강조해 내부와 다른 환경을 비교한다.",
"start_state": "같은 내부 분자의 주변 여러 방향에 있는 물 분자 이웃을 차례로 강조한다. 특정 결합 수나 고정 배열을 표시하지 않는다.",
"end_state": "내부 강조는 옅어지고 표면 가까이 물 쪽에 있는 한 분자를 강조한다. 위쪽 크림색 영역에 합성 주석 '공기 쪽', 경계 가까이에 '표면'을 붙인다.",
"camera": "고정 세로 단면 개념도. 화면 위쪽은 크림색 공기, 아래쪽은 물청색 액체 물. 수평에 가까운 자유 표면과 내부·표면의 분자를 같은 시야에 둔다."
},
{
"cut_id": "c17",
"description": "공기 쪽에는 액체 물 분자 이웃이 적음을 빈 공간과 아래쪽 이웃의 대비로 설명한다.",
"start_state": "내부 강조는 옅어지고 표면 가까이 물 쪽에 있는 한 분자를 강조한다. 위쪽 크림색 영역에 합성 주석 '공기 쪽', 경계 가까이에 '표면'을 붙인다.",
"end_state": "표면 분자 아래·옆의 물 분자 이웃과 위쪽 공기 영역을 같은 화면에서 대비한다. 합성 주석은 '물 분자 이웃이 적음'이며 공기가 진공이거나 수증기가 전혀 없다는 뜻으로 표시하지 않는다.",
"camera": "고정 세로 단면 개념도. 화면 위쪽은 크림색 공기, 아래쪽은 물청색 액체 물. 수평에 가까운 자유 표면과 내부·표면의 분자를 같은 시야에 둔다."
},
{
"cut_id": "c18",
"description": "물방울의 표면을 넓히는 변형을 보여주며 표면 확장이라는 뜻을 전달한다.",
"start_state": "작은 둥근 물방울 하나가 중앙에 있다. 물청색 색면·가는 남색 외곽선·작은 하이라이트를 유지한다. 별도 껍질·바닥·수치 도표는 없다.",
"end_state": "같은 물방울이 완만하게 길어진 모양이다. 물의 양은 유지하는 개념도이며 실제 두께·부피를 픽셀 면적으로 측정한 그림은 아니다. 양쪽 바깥에 표면을 넓히는 방향의 작은 표시가 있다.",
"camera": "고정 세로 근접 개념도. 크림색 바탕 중앙에 외부 영향이 작은 물방울 하나. 같은 물의 양을 유지하는 입체 물방울의 간단한 평면 표현이며 원·타원 면적 측정 도표가 아니다."
},
{
"cut_id": "c19",
"description": "변형을 유지하는 데 외부에서 에너지를 보태야 함을 개념 표시로 설명한다.",
"start_state": "같은 물방울이 완만하게 길어진 모양이다. 물의 양은 유지하는 개념도이며 실제 두께·부피를 픽셀 면적으로 측정한 그림은 아니다. 양쪽 바깥에 표면을 넓히는 방향의 작은 표시가 있다.",
"end_state": "같은 길어진 물방울과 바깥 방향 표시를 유지한다. 황토색 외부 에너지 표시와 합성 주석 '에너지를 보태야 함'을 더한다. 전하나 실제 에너지 물질을 그리지 않는다.",
"camera": "고정 세로 근접 개념도. 크림색 바탕 중앙에 외부 영향이 작은 물방울 하나. 같은 물의 양을 유지하는 입체 물방울의 간단한 평면 표현이며 원·타원 면적 측정 도표가 아니다."
},
{
"cut_id": "c20",
"description": "같은 물의 양에서 표면을 줄이는 쪽의 경향을 윤곽 변화로 짚는다.",
"start_state": "같은 길어진 물방울과 바깥 방향 표시를 유지한다. 황토색 외부 에너지 표시와 합성 주석 '에너지를 보태야 함'을 더한다. 전하나 실제 에너지 물질을 그리지 않는다.",
"end_state": "같은 위치의 물방울에서 바깥 방향 표시와 에너지 표시가 옅어진다. 길어진 윤곽이 조금 덜 길어지며 표면을 줄이는 경향을 짚는다.",
"camera": "고정 세로 근접 개념도. 크림색 바탕 중앙에 외부 영향이 작은 물방울 하나. 같은 물의 양을 유지하는 입체 물방울의 간단한 평면 표현이며 원·타원 면적 측정 도표가 아니다."
},
{
"cut_id": "c21",
"description": "변형된 물방울이 더 둥근 모양으로 돌아가는 상태를 이어 보여준다.",
"start_state": "같은 위치의 물방울에서 바깥 방향 표시와 에너지 표시가 옅어진다. 길어진 윤곽이 조금 덜 길어지며 표면을 줄이는 경향을 짚는다.",
"end_state": "물방울은 원래처럼 더 둥근 모양이 되었다. 색·양·위치는 유지하고 외부 에너지 표시를 지운다. 무게나 밀도 변화 수치는 없다.",
"camera": "고정 세로 근접 개념도. 크림색 바탕 중앙에 외부 영향이 작은 물방울 하나. 같은 물의 양을 유지하는 입체 물방울의 간단한 평면 표현이며 원·타원 면적 측정 도표가 아니다."
},
{
"cut_id": "c22",
"description": "물의 표면 확장에 대한 저항에 표면장력이라는 이름을 붙인다.",
"start_state": "물방울은 원래처럼 더 둥근 모양이 되었다. 색·양·위치는 유지하고 외부 에너지 표시를 지운다. 무게나 밀도 변화 수치는 없다.",
"end_state": "같은 둥근 물방울의 표면 윤곽만 얇게 강조한다. u06 상대 16760ms 이후 합성 주석 '표면장력'을 붙인다. 새 껍질이나 굵은 테두리를 생성하지 않는다.",
"camera": "고정 세로 근접 개념도. 크림색 바탕 중앙에 외부 영향이 작은 물방울 하나. 같은 물의 양을 유지하는 입체 물방울의 간단한 평면 표현이며 원·타원 면적 측정 도표가 아니다."
},
{
"cut_id": "c23",
"description": "비교 조건인 같은 물의 양을 먼저 제시한다.",
"start_state": "왼쪽에 길어진 물방울, 오른쪽에 둥근 물방울이 같은 물청색·가는 남색 윤곽선으로 보인다. 두 그림은 같은 물의 양을 가정한 입체 개념도이며 픽셀 면적·둘레 비교는 하지 않는다.",
"end_state": "두 물방울은 같은 위치와 모양을 유지한다. 두 그림 위에 공통 합성 주석 '같은 물의 양 · 개념도'를 붙여 비교 조건을 먼저 제시한다.",
"camera": "고정 세로 비교 도해. 위쪽 또는 중앙에 같은 물의 양을 나타내는 두 입체 물방울의 평면 표현을 나란히 배치. 왼쪽은 길어진 모양, 오른쪽은 둥근 모양. 바닥·중력 수치·눈금은 없다."
},
{
"cut_id": "c24",
"description": "같은 부피에서 공 모양의 겉면이 가장 작다는 답을 비교 형태와 강조로 전달한다.",
"start_state": "두 물방울은 같은 위치와 모양을 유지한다. 두 그림 위에 공통 합성 주석 '같은 물의 양 · 개념도'를 붙여 비교 조건을 먼저 제시한다.",
"end_state": "두 물방울의 위치·양 조건은 그대로다. 오른쪽 둥근 물방울의 윤곽을 옅게 강조하고 합성 주석 '겉면이 가장 작음'을 오른쪽에 붙인다. 면적 수치·비율·2차원 둘레 계산은 없다.",
"camera": "고정 세로 비교 도해. 위쪽 또는 중앙에 같은 물의 양을 나타내는 두 입체 물방울의 평면 표현을 나란히 배치. 왼쪽은 길어진 모양, 오른쪽은 둥근 모양. 바닥·중력 수치·눈금은 없다."
},
{
"cut_id": "c25",
"description": "중력이나 접촉의 영향이 작은 작은 물방울의 조건을 밝혀 구형 설명의 범위를 제한한다.",
"start_state": "왼쪽에 작은 둥근 물방울이 바닥에 닿지 않은 채 보인다. 오른쪽에는 예시 자리를 비워 두고 그 아래만 가는 갈색 바닥선을 둔다. 앞의 같은 양 비교 주석은 없다.",
"end_state": "왼쪽의 작은 물방울과 오른쪽 바닥선은 그대로다. 왼쪽에 합성 주석 '외부 영향이 작을 때'를 붙인다. 무중력·영향이 완전히 0이라는 표시나 수치는 없다.",
"camera": "고정 세로 조건 비교. 왼쪽에는 바닥에 닿지 않은 작은 물방울, 오른쪽에는 접촉한 물방울의 예시 자리와 가는 바닥선. 같은 부피 비교와 구별하는 새로운 배치다."
},
{
"cut_id": "c26",
"description": "작은 물방울을 유지하면서 큰 물방울·바닥 접촉의 예시를 새로 보여준다.",
"start_state": "왼쪽의 작은 물방울과 오른쪽 바닥선은 그대로다. 왼쪽에 합성 주석 '외부 영향이 작을 때'를 붙인다. 무중력·영향이 완전히 0이라는 표시나 수치는 없다.",
"end_state": "왼쪽 작은 물방울은 그대로다. u07 상대 11900ms의 '큰' 이후 오른쪽 바닥선 위에 더 크고 납작한 접촉 물방울 예시가 나타난다. 이는 같은 양 비교가 아니라 모양 조건의 예시다.",
"camera": "고정 세로 조건 비교. 왼쪽에는 바닥에 닿지 않은 작은 물방울, 오른쪽에는 접촉한 물방울의 예시 자리와 가는 바닥선. 같은 부피 비교와 구별하는 새로운 배치다."
},
{
"cut_id": "c27",
"description": "바닥에 닿은 물방울의 모양은 달라질 수 있음을 대비하고 모든 물방울이 구라는 오해를 막는다.",
"start_state": "왼쪽 작은 물방울은 그대로다. u07 상대 11900ms의 '큰' 이후 오른쪽 바닥선 위에 더 크고 납작한 접촉 물방울 예시가 나타난다. 이는 같은 양 비교가 아니라 모양 조건의 예시다.",
"end_state": "두 예시는 같은 위치에 유지된다. 오른쪽의 접촉 모양을 강조하고 합성 주석 '중력·바닥의 영향'으로 조건을 짚는다. 접촉각·크기 경계값·표면장력 수치는 넣지 않는다.",
"camera": "고정 세로 조건 비교. 왼쪽에는 바닥에 닿지 않은 작은 물방울, 오른쪽에는 접촉한 물방울의 예시 자리와 가는 바닥선. 같은 부피 비교와 구별하는 새로운 배치다."
},
{
"cut_id": "c28",
"description": "처음 물방울로 돌아와 별도 고체 껍질이 없음을 색면 경계와 분자 개념도로 설명한다.",
"start_state": "중앙에 처음과 같은 물청색 작은 둥근 물방울이 있다. 외곽선은 물과 공기 색면의 경계를 나타내는 가는 선 하나이며 둘레의 별도 띠·층·딱딱한 껍질은 없다. 우상단 개념도 자리는 비어 있다.",
"end_state": "중앙 물방울은 그대로다. 우상단에 분자 확대 개념도가 나타나 온전한 물 분자들을 불규칙하게 보여준다. 합성 주석 '확대 개념도'로 실제 비율·물질층과 구별한다.",
"camera": "고정 세로 전경. 중앙에 처음 질문의 작은 둥근 물방울, 우상단에 분자 확대 개념도. 중앙과 개념도 위치를 끝까지 유지해 질문의 답을 회수한다."
},
{
"cut_id": "c29",
"description": "분자 확대 개념도에서 수소결합에 의한 서로 끌림을 회수한다.",
"start_state": "중앙 물방울은 그대로다. 우상단에 분자 확대 개념도가 나타나 온전한 물 분자들을 불규칙하게 보여준다. 합성 주석 '확대 개념도'로 실제 비율·물질층과 구별한다.",
"end_state": "물방울과 우상단 개념도 위치는 같다. 개념도에서 H···O 점선과 짧은 서로 향하는 표시를 강조하고 합성 주석 '서로 끌림'을 붙인다. 내부 실선은 변하지 않는다.",
"camera": "고정 세로 전경. 중앙에 처음 질문의 작은 둥근 물방울, 우상단에 분자 확대 개념도. 중앙과 개념도 위치를 끝까지 유지해 질문의 답을 회수한다."
},
{
"cut_id": "c30",
"description": "표면을 넓히기 어려운 성질을 같은 물방울의 작은 변형과 연결해 회수한다.",
"start_state": "물방울과 우상단 개념도 위치는 같다. 개념도에서 H···O 점선과 짧은 서로 향하는 표시를 강조하고 합성 주석 '서로 끌림'을 붙인다. 내부 실선은 변하지 않는다.",
"end_state": "같은 물방울의 윤곽이 조금 길어진 모습과 외부에서 넓히려는 작은 방향 표시가 보인다. 개념도는 그대로이고 합성 주석 '표면 넓힘에 저항'을 더한다. 물의 양·그림체·색은 유지한다.",
"camera": "고정 세로 전경. 중앙에 처음 질문의 작은 둥근 물방울, 우상단에 분자 확대 개념도. 중앙과 개념도 위치를 끝까지 유지해 질문의 답을 회수한다."
},
{
"cut_id": "c31",
"description": "작은 물방울의 둥근 모양으로 돌아와 제목과 첫 질문에 답하며 끝낸다.",
"start_state": "같은 물방울의 윤곽이 조금 길어진 모습과 외부에서 넓히려는 작은 방향 표시가 보인다. 개념도는 그대로이고 합성 주석 '표면 넓힘에 저항'을 더한다. 물의 양·그림체·색은 유지한다.",
"end_state": "물방울은 다시 작은 둥근 모양이다. 외부 방향 표시와 긴 주석은 옅어지고 우상단 개념도는 작게 남거나 자연스럽게 사라진다. 중앙 물방울을 가장 먼저 보이게 해 처음 질문의 답으로 마무리한다.",
"camera": "고정 세로 전경. 중앙에 처음 질문의 작은 둥근 물방울, 우상단에 분자 확대 개념도. 중앙과 개념도 위치를 끝까지 유지해 질문의 답을 회수한다."
}
],
"connections": [
{
"from_cut_id": "c01",
"to_cut_id": "c02",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c02",
"to_cut_id": "c03",
"mode": "cut",
"reason": "물방울 전체에서 한 물 분자의 구성으로 설명 규모가 바뀌므로 분자 하나의 근접 도해를 새로 제시한다."
},
{
"from_cut_id": "c03",
"to_cut_id": "c04",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c04",
"to_cut_id": "c05",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c05",
"to_cut_id": "c06",
"mode": "cut",
"reason": "한 분자의 부분 전하에서 두 이웃 분자의 끌림으로 설명 대상 수와 배치가 바뀌므로 두 분자를 함께 보여준다."
},
{
"from_cut_id": "c06",
"to_cut_id": "c07",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c07",
"to_cut_id": "c08",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c08",
"to_cut_id": "c09",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c09",
"to_cut_id": "c10",
"mode": "cut",
"reason": "두 분자의 결합 구별에서 액체 안의 동적인 여러 연결로 설명 범위가 넓어지므로 불규칙한 분자 집단을 새로 제시한다."
},
{
"from_cut_id": "c10",
"to_cut_id": "c11",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c11",
"to_cut_id": "c12",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c12",
"to_cut_id": "c13",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c13",
"to_cut_id": "c14",
"mode": "cut",
"reason": "물속의 응집에서 물-공기 경계의 환경 차이로 설명 위치가 바뀌므로 자유 표면의 단면 개념도를 제시한다."
},
{
"from_cut_id": "c14",
"to_cut_id": "c15",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c15",
"to_cut_id": "c16",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c16",
"to_cut_id": "c17",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c17",
"to_cut_id": "c18",
"mode": "cut",
"reason": "경계의 분자 환경에서 표면 확장의 에너지 비용으로 설명 규모가 바뀌므로 물방울 변형 개념도로 전환한다."
},
{
"from_cut_id": "c18",
"to_cut_id": "c19",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c19",
"to_cut_id": "c20",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c20",
"to_cut_id": "c21",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c21",
"to_cut_id": "c22",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c22",
"to_cut_id": "c23",
"mode": "cut",
"reason": "한 물방울의 표면장력에서 같은 부피의 모양 비교로 비교 기준이 바뀌므로 두 모양을 나란히 제시한다."
},
{
"from_cut_id": "c23",
"to_cut_id": "c24",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c24",
"to_cut_id": "c25",
"mode": "cut",
"reason": "같은 부피 비교가 끝나고 외부 영향과 바닥 접촉의 조건으로 바뀌므로 조건 예시의 새 배치를 제시한다."
},
{
"from_cut_id": "c25",
"to_cut_id": "c26",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c26",
"to_cut_id": "c27",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c27",
"to_cut_id": "c28",
"mode": "cut",
"reason": "조건 예시를 마치고 첫 물방울로 돌아가 답을 회수하므로 도입과 같은 중심 배치를 다시 제시한다."
},
{
"from_cut_id": "c28",
"to_cut_id": "c29",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c29",
"to_cut_id": "c30",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
},
{
"from_cut_id": "c30",
"to_cut_id": "c31",
"mode": "continuous",
"reason": "같은 설명의 대상·위치·방향·카메라를 유지하며 앞 컷의 끝 상태를 다음 시작 상태로 그대로 이어 원인과 결과의 다음 변화를 보여준다."
}
]
}
}
@@ -0,0 +1,93 @@
# 장면 설계 검토안
- 제작 ID: water-hydrogen-bond-surface-tension / 08-scenes r1 / 검토 ID: scenes.
- 입력: story 승인된 [03 대본 r1](../../03-script/r1/review.md), [07 실제 컷·자막 r1](../../07-cuts/r1/review.md), 직접 확인한 [02 조사 r1](../../02-research/r1/review.md).
- 결과: [전체 시작·끝 상태와 카메라 JSON](artifact.json), [09 반복 요소](../../09-entities/r1/review.md).
- 시간 기준: [실제 내레이션](../../../media/06-tts/r1/narration.wav) / 110560ms / 사용자 청취 확인됨.
- 제출 내용: 31개 장면, 30개 인접 연결. 같은 대상·시점을 유지하는 continuous 22개, 설명 규모·관점이 바뀌는 cut 8개.
- 현재는 장면 설계이며 생성 PNG·영상은 아니다. 제출 당시 scenes 승인은 없었고 아래 실제 응답으로 승인됐다.
- 실제 검사 결과: 31개 컷·장면·등장 참조의 순서와 유일성, 30개 인접 연결, continuous 22곳의 end_state→start_state 및 camera 정확한 일치, 8개 cut의 전환 이유, 문서 53개 로컬 링크를 검사해 종료코드 0을 확인했다. 총괄이 07–09 r1을 awaiting_review로 기록했다.
## 전체 흐름과 표현
처음의 작은 둥근 물방울에서 시작해 물 분자의 구성·부분 전하 → 이웃 분자 사이 수소결합 → 바뀌는 연결과 응집 → 물-공기 경계 → 표면 확장 에너지 비용 → 같은 부피의 둥근 모양 → 실제 모양의 조건 → 처음 질문의 답으로 이어진다.
직접 본 [사용자 그림체](../../../../../references/1.png)의 가는 윤곽선·단순 형태·정돈된 색면·제한된 음영을 따른다. 크림색 설명 바탕에 물청색 물방울, 산호색 산소, 미색 수소, 남색 분자 내부 실선, 황토색 분자 사이 점선을 일관되게 사용한다. 인물·캠핑·상표·새 실험은 필요하지 않아 넣지 않는다.
한 컷에서 중심 대상과 변화 하나를 먼저 보이게 한다. 문자·전하 기호·라벨은 이미지에 생성하지 않고 합성 단계의 검증한 한국어 주석으로 배치한다. 자막과 주요 도해를 분리하고, 생성·합성 뒤 모바일 크기와 실제 플랫폼 영역에서 판독성을 확인한다. 모든 플랫폼의 고정 안전영역 수치는 가정하지 않는다.
## 컷별 상태 변화
아래 시간은 실측 WAV와 자동 정렬에 근거한 통합 구간이다. '앞 연결'은 해당 컷으로 들어오는 연결이며, continuous에서는 앞 end_state와 뒤 start_state 및 camera를 동일하게 기록했다. 모든 상세 상태·위치·카메라는 JSON에 있다.
| 컷 / 실제 구간 | 시작 → 끝의 핵심 변화 | 앞 연결 | 조사 주장 / 반복 배경 |
| --- | --- | --- | --- |
| c01 / 0.00–4.38초 | 둥근 물방울 → 확대 개념도 자리 안내 | 도입 | c4-cohesion, c7-sphere / bg-droplet |
| c02 / 4.38–7.92초 | 확대 자리 → 물 분자 도식 등장 | continuous | c4-cohesion, c7-sphere / bg-droplet |
| c03 / 7.92–11.92초 | 온전한 분자 → 산소 하나·수소 둘 강조 | cut | c1-polarity / bg-molecules |
| c04 / 11.92–14.78초 | 원자 구성 → 산소 쪽 부분 음전하 | continuous | c1-polarity / bg-molecules |
| c05 / 14.78–18.08초 | 산소 쪽 → 수소 쪽 부분 양전하 추가 | continuous | c1-polarity / bg-molecules |
| c06 / 18.08–20.34초 | 이웃 분자 둘 → 마주보는 H와 O 강조 | cut | c2-hydrogen-bond / bg-molecules |
| c07 / 20.34–24.38초 | H·O 강조 → 분자 사이 끌림 표시 | continuous | c2-hydrogen-bond / bg-molecules |
| c08 / 24.38–28.48초 | 끌림 표시 → 수소결합 이름 | continuous | c2-hydrogen-bond / bg-molecules |
| c09 / 28.48–32.72초 | 분자 사이 점선 → 분자 내부 실선과 대비 | continuous | c2-hydrogen-bond / bg-molecules |
| c10 / 32.72–35.56초 | 불규칙한 분자 집단 → 한 점선이 옅어짐 | cut | c3-dynamic-network, c4-cohesion / bg-molecules |
| c11 / 35.56–40.36초 | 기존 점선 약화 → 다른 이웃 점선 생성 | continuous | c3-dynamic-network, c4-cohesion / bg-molecules |
| c12 / 40.36–45.12초 | 새 연결 → 연결 교체와 여러 끌림 | continuous | c3-dynamic-network, c4-cohesion / bg-molecules |
| c13 / 45.12–49.36초 | 여러 끌림 → 물끼리 뭉치는 성질 회수 | continuous | c3-dynamic-network, c4-cohesion / bg-molecules |
| c14 / 49.36–52.08초 | 물-공기 단면 → 내부 분자 선택 | cut | c5-interface-neighbors / bg-interface |
| c15 / 52.08–56.12초 | 내부 분자 → 주변 여러 방향의 이웃 강조 | continuous | c5-interface-neighbors / bg-interface |
| c16 / 56.12–61.00초 | 내부 강조 → 표면 분자와 공기 쪽 강조 | continuous | c5-interface-neighbors / bg-interface |
| c17 / 61.00–63.44초 | 표면 강조 → 물 분자 이웃 적음 대비 | continuous | c5-interface-neighbors / bg-interface |
| c18 / 63.44–67.44초 | 작은 둥근 물방울 → 완만한 변형 | cut | c6-surface-energy / bg-droplet |
| c19 / 67.44–70.82초 | 변형 → 외부 에너지 필요 표시 | continuous | c6-surface-energy / bg-droplet |
| c20 / 70.82–73.12초 | 외부 표시 → 표면 줄이는 경향 | continuous | c6-surface-energy / bg-droplet |
| c21 / 73.12–77.04초 | 덜 길어진 모양 → 둥근 모양 | continuous | c6-surface-energy / bg-droplet |
| c22 / 77.04–81.52초 | 둥근 모양 → 표면장력 이름 | continuous | c6-surface-energy / bg-droplet |
| c23 / 81.52–82.96초 | 두 모양 → 같은 물의 양 조건 | cut | c7-sphere, c8-shape-conditions / bg-comparison |
| c24 / 82.96–87.38초 | 같은 양 → 둥근 쪽의 작은 겉면 강조 | continuous | c7-sphere, c8-shape-conditions / bg-comparison |
| c25 / 87.38–91.52초 | 조건 비교 배치 → 작은 외부 영향 조건 | cut | c7-sphere, c8-shape-conditions / bg-comparison |
| c26 / 91.52–96.16초 | 작은 물방울 → 오른쪽 큰 접촉 예시 추가 | continuous | c7-sphere, c8-shape-conditions / bg-comparison |
| c27 / 96.16–97.76초 | 접촉 예시 → 바닥·중력 조건 강조 | continuous | c7-sphere, c8-shape-conditions / bg-comparison |
| c28 / 97.76–101.98초 | 처음 물방울 → 확대 개념도, 별도 껍질 없음 | cut | c2-hydrogen-bond, c4-cohesion, c6-surface-energy, c7-sphere, c9-no-skin / bg-droplet |
| c29 / 101.98–104.56초 | 개념도 → 수소결합·서로 끌림 회수 | continuous | c2-hydrogen-bond, c4-cohesion, c6-surface-energy, c7-sphere, c9-no-skin / bg-droplet |
| c30 / 104.56–107.36초 | 서로 끌림 → 표면 넓힘에 저항 회수 | continuous | c2-hydrogen-bond, c4-cohesion, c6-surface-energy, c7-sphere, c9-no-skin / bg-droplet |
| c31 / 107.36–110.56초 | 작은 변형 → 둥근 물방울로 질문 회수 | continuous | c2-hydrogen-bond, c4-cohesion, c6-surface-energy, c7-sphere, c9-no-skin / bg-droplet |
## 전환이 필요한 8곳
| 인접 컷 | 새 시점이 필요한 이유 |
| --- | --- |
| c02 → c03 | 물방울 전체에서 한 물 분자의 구성으로 설명 규모가 바뀌므로 분자 하나의 근접 도해를 새로 제시한다. |
| c05 → c06 | 한 분자의 부분 전하에서 두 이웃 분자의 끌림으로 설명 대상 수와 배치가 바뀌므로 두 분자를 함께 보여준다. |
| c09 → c10 | 두 분자의 결합 구별에서 액체 안의 동적인 여러 연결로 설명 범위가 넓어지므로 불규칙한 분자 집단을 새로 제시한다. |
| c13 → c14 | 물속의 응집에서 물-공기 경계의 환경 차이로 설명 위치가 바뀌므로 자유 표면의 단면 개념도를 제시한다. |
| c17 → c18 | 경계의 분자 환경에서 표면 확장의 에너지 비용으로 설명 규모가 바뀌므로 물방울 변형 개념도로 전환한다. |
| c22 → c23 | 한 물방울의 표면장력에서 같은 부피의 모양 비교로 비교 기준이 바뀌므로 두 모양을 나란히 제시한다. |
| c24 → c25 | 같은 부피 비교가 끝나고 외부 영향과 바닥 접촉의 조건으로 바뀌므로 조건 예시의 새 배치를 제시한다. |
| c27 → c28 | 조건 예시를 마치고 첫 물방울로 돌아가 답을 회수하므로 도입과 같은 중심 배치를 다시 제시한다. |
나머지 인접 쌍은 같은 대상·위치·방향·카메라를 이어 쓴다. 짧은 컷 c23의 비교 조건은 c24에도 유지되므로 1.44초 후 주석을 지우지 않는다. 문장이 컷을 넘어갈 때 음성을 끊거나 장면을 처음부터 다시 시작하지 않는다.
## 도해와 주석의 근거·한계
- c03–c05 / c1-polarity: 한 분자 안의 산소 하나·수소 둘과 부분 전하를 보여준다. 분자 전체를 이온으로 표시하지 않는다. 각도·전자 수·전하량 수치는 넣지 않는다.
- c06–c09 / c2-hydrogen-bond: 수소결합 점선은 이웃 분자의 H···O 사이에만 둔다. 내부 O–H 실선과 구별하며 두 분자를 합치거나 점선을 영구 끈으로 그리지 않는다. 수소결합 라벨은 u03 상대 8100ms / 통합 26180ms 이후에 나타난다.
- c10–c13 / c3-dynamic-network·c4-cohesion: 연결이 생겼다 끊어지는 정성적 개념도다. 고정 격자·모든 분자의 정확한 결합 수·실제 결합 시간 수치를 만들지 않는다. 응집을 모든 분자가 한 점으로 붕괴하거나 부피가 줄어드는 과정으로 표현하지 않는다.
- c14–c17 / c5-interface-neighbors: 내부와 표면의 액체 물 분자 이웃 환경을 비교한다. 위쪽 공기 단순화는 진공·수증기 완전 부재를 뜻하지 않는다. 고체 표면막·아래 방향으로 계속 가속하는 도해는 넣지 않는다.
- c18–c22 / c6-surface-energy: 같은 양의 입체 물방울이 변형되는 개념도를 평면 그림체로 표현한다. 외부 에너지 표시는 개념 기호이며 새로운 물질·전하를 그린 것이 아니다. 픽셀 면적·둘레를 3차원 표면적·부피로 측정하지 않는다. 표면장력 라벨은 u06 상대 16760ms / 통합 80200ms 이후에 나타난다.
- c23–c24 / c7-sphere: '같은 물의 양 · 개념도' 조건을 먼저 밝힌다. 실제 표면적의 숫자·비율·막대·2차원 둘레 계산을 추가하지 않는다.
- c25–c27 / c8-shape-conditions: 앞의 동일 부피 비교를 끝낸 뒤 조건 예시로 바꾼다. 작은 물방울의 조건은 영향이 작다는 뜻이며 완전히 무중력이라는 단정이 아니다. 큰 접촉 예시는 u07 상대 11900ms / 통합 93420ms의 '큰' 이후에 나타난다. 크기 경계값·접촉각 수치는 없다.
- c28–c31 / c9-no-skin와 결말 주장: 물-공기 색면 경계를 나타내는 얇은 외곽선 하나만 유지한다. 별도 띠·층·딱딱한 껍질은 생성하지 않는다. 확대 개념도는 실제 비율·구조의 사진으로 취급하지 않는다.
각 claim ID의 verified 출처는 02 JSON과 review.md에 있다. 주석의 색·크기·위치는 설명을 위한 선택이며 실측 과학 데이터가 아니다. 새 소재 사실·수치·실험은 추가하지 않았다.
## 사용자에게 필요한 결정
07-cuts r1·08-scenes r1·09-entities r1을 같은 scenes 검토 묶음으로 제시한다. 검토할 내용은 위 상태 변화·실측 구간·연결 이유·같은 색과 형태·과학적 주석의 범위다. 승인 또는 수정할 컷·표현을 알려주면 총괄이 실제 응답을 해당 버전에 기록한다. 승인 전 10 기준 이미지와 11 컷 이미지를 생성하지 않는다. topic/story의 동일 버전 승인은 다시 요구하지 않는다.
## 실제 scenes 승인
- 사용자 응답: “승인합니다.”
- 승인 버전: 07-cuts r1, 08-scenes r1, 09-entities r1.
- 현재 상태: complete. 승인된 31개 상태·30개 연결·기존 버전을 보존하고 10 기준 이미지 요청으로 진행한다. 같은 버전의 재승인은 요구하지 않는다.

Some files were not shown because too many files have changed in this diff Show More