물 영상 정식 3D 기준·끝점·31개 컷 제작

This commit is contained in:
NINI
2026-10-10 03:25:24 +09:00
parent 5ee2b2e972
commit f8f12f963d
40 changed files with 18669 additions and 21 deletions
@@ -0,0 +1 @@
/__pycache__/
@@ -1,6 +1,6 @@
# 물 소재 3D 시제품 작성 경계
# 물 소재 3D 제작 경계
- 이 폴더는 물 영상의 세 대표 장면을 재현하는 bpy 코드다. 범용 실행기는 `tools/blender/run.py`를 사용하고 수정하지 않는다.
- 원본·렌더는 자기 `08-scenes/r3` 아래에 둔다. 이전 결과·WAV·사용자 스타일 원본·project.json은 변경하지 않는다.
- 이 폴더는 물 영상의 대표 장면과 승인된 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를 실행하지 않는다.
- draft 품질 조정은 별도 렌더 시도를 보존한다. 제출 후 결과 변경은 새 revision으로 진행하며 새 scenes 승인 전 정식 10–12를 실행하지 않는다. 승인 후에도 총괄의 실제 10/11 완료 기록에 맞춰 다음 렌더를 진행한다.
@@ -1,8 +1,10 @@
# 물 영상의 제한된 3D 시제품
# 물 영상의 3D 제작
## 제한된 시제품 기록
`build_preview.py`는 BondPair·Interface·DropletStretch 세 Scene만 만든다. 불규칙한 물 분자, 실제 연결 쌍 변화, 내부/표면 강조, 같은 부피의 3D 변형을 구현한 설명 모형이다. MD·유체 시뮬레이션은 아니다. 31컷 전체 모델·최종 영상은 scenes 승인 뒤 별도 제작한다.
저장소 루트에서 기존 설치를 사용한다. Blender 논리 F는 실제 F+1, MP4는 `[start,end)`다. 대표 영상은 c07 4040ms/202프레임, c16 4880ms/244프레임, c18 4000ms/200프레임의 무음 영상이다. 끝 PNG는 exclusive 경계이며 MP4 마지막 프레임보다 20ms 뒤다.
승인·제출된 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
@@ -16,3 +18,14 @@
작성 순서는 새 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,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,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))
@@ -19,6 +19,7 @@ def containment(scene):
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에서 경계를 얻고 중심·별도 메타데이터만으로 판정하지 않는다.
@@ -26,6 +27,7 @@ def containment(scene):
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])
@@ -57,6 +59,19 @@ def containment(scene):
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)
@@ -68,13 +83,13 @@ def containment(scene):
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'])
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):
def verify(source, expected=None):
assert source.is_file(), "원본 파일 없음"
bpy.ops.wm.open_mainfile(filepath=str(source))
expected = {"BondPair": (905, 1637), "Interface": (2469, 3173), "DropletStretch": (3173, 4077)}
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, "외부 자산"
@@ -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'])