물 영상의 검증된 3D 대표 시제품과 새 장면 설계 작성
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# 물 소재 3D 시제품 작성 경계
|
||||
|
||||
- 이 폴더는 물 영상의 세 대표 장면을 재현하는 bpy 코드다. 범용 실행기는 `tools/blender/run.py`를 사용하고 수정하지 않는다.
|
||||
- 원본·렌더는 자기 `08-scenes/r2` 아래에 둔다. 이전 결과·WAV·사용자 스타일 원본·project.json은 변경하지 않는다.
|
||||
- 실제 O1H2·V자·두 종류 결합·동적 이웃·닫힌 mesh 부피와 `--disable-autoexec` 평가를 검사한다. 물리 시뮬레이션으로 주장하지 않는다.
|
||||
- draft 품질 조정은 별도 렌더 시도를 보존한다. 제출 후 결과 변경은 새 revision으로 진행하며 새 scenes 승인 전 정식 10–12를 실행하지 않는다.
|
||||
@@ -0,0 +1,18 @@
|
||||
# 물 영상의 제한된 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 뒤다.
|
||||
|
||||
```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 초안을 작성하므로 제출한 r2에서 재실행하지 않는다. `render_preview.py`는 현재 초안에 실제 증거만 추가한다. 완료된 결과는 새 버전에서 작업한다.
|
||||
|
||||
`test_model.py`는 없는 파일·Scene 누락·H 원자 누락·부피 driver 오류의 실제 RED와 원본 GREEN을 확인한다. 부피는 시작·중간 세 곳·전체 컷 끝을 실제 mesh로 평가한다. 실제 렌더는 기존 실행기의 source hash·Scene·profile·frame 검사, PNG/MP4 probe 및 전체 디코드를 거친다. 큰 원본·렌더·실패 시도는 해당 r2의 좁은 `.gitignore`로 보존하고 작은 검증 JSON에 복사할 파일·해시를 기록한다.
|
||||
@@ -0,0 +1,217 @@
|
||||
"""물 소재의 세 대표 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/r2/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)
|
||||
# 옆면/뒤쪽의 청색 체적만 보이는 잘라낸 설명 모형. 경계 위 고체 막 없음.
|
||||
bpy.ops.mesh.primitive_cube_add(size=1, location=(0,1.7,-1.15))
|
||||
body = bpy.context.object
|
||||
body.name = 'WaterCutawayBody'
|
||||
body.scale = (5,1.5,3)
|
||||
body.data.materials.append(GHOST)
|
||||
body['role'] = 'cutaway_water'
|
||||
bevel=body.modifiers.new('SoftCutawayEdges','BEVEL')
|
||||
bevel.width=.055
|
||||
bevel.segments=3
|
||||
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,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/r2'
|
||||
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}/r2'
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
body = 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=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. 3D 체적을 옆면·뒤쪽만 남긴 잘라낸 설명 모형, 카메라 (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/r2/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',2)])
|
||||
@@ -0,0 +1,65 @@
|
||||
"""기존 실행기로 세 대표 컷의 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/r2')
|
||||
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)
|
||||
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:
|
||||
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','2','--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,29 @@
|
||||
"""없는 원본·잘못된 Scene·원자 수·부피를 실제 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/r2'
|
||||
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):
|
||||
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)
|
||||
records.append(dict(command=command,exit_code=result.returncode,expected_exit_code=expected,reason=result.stderr.strip().splitlines()[-1:] if result.stderr.strip() else []))
|
||||
|
||||
|
||||
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'")]:
|
||||
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)
|
||||
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(SOURCE),'--out',str(BASE/'model-check.json')],0)
|
||||
(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('4 negative cases RED, real model GREEN')
|
||||
@@ -0,0 +1,75 @@
|
||||
"""컷·연결·새 버전 미디어와 실제 실행 증거를 검사한다."""
|
||||
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/r2/artifact.json')
|
||||
b=read(PROJECT/'stages/09-entities/r2/artifact.json')
|
||||
schema=read(ROOT/'contracts/artifact.schema.json')
|
||||
jsonschema.validate(a,schema)
|
||||
jsonschema.validate(b,schema)
|
||||
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']
|
||||
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/r2') 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/r2/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/r2/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,112 @@
|
||||
"""Blender 내부에서 실제 원본·구조·평가 부피를 검사한다."""
|
||||
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
|
||||
|
||||
|
||||
def verify(source):
|
||||
assert source.is_file(), "원본 파일 없음"
|
||||
bpy.ops.wm.open_mainfile(filepath=str(source))
|
||||
expected = {"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
|
||||
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 {"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"])
|
||||
Reference in New Issue
Block a user