diff --git a/tools/blender/inside.py b/tools/blender/inside.py index 3bc4344..6112531 100644 --- a/tools/blender/inside.py +++ b/tools/blender/inside.py @@ -37,6 +37,8 @@ def main(): frame_start=scene.frame_start, frame_end=scene.frame_end, logical_to_blender="logical_frame + 1", external_links=False) if job["operation"] == "render": + # frame_set과 렌더의 depsgraph가 같은 Scene을 평가하도록 활성 컨텍스트를 맞춘다. + bpy.context.window.scene = scene profile = job["profile"] scene.render.engine = profile["engine"] scene.render.resolution_x = profile["width"] diff --git a/tools/blender/smoke.py b/tools/blender/smoke.py index 32bf4cb..07fb684 100644 --- a/tools/blender/smoke.py +++ b/tools/blender/smoke.py @@ -3,23 +3,54 @@ import argparse import json from pathlib import Path import shutil +import subprocess + +from PIL import Image import run from test_runner import fixture, production_fixture, endpoint_fixture, ROOT from test_runner import write +def pixels(path): + with Image.open(path) as image: + return image.convert("RGB").tobytes() + + +def check_motion(evidence, ffmpeg): + frames = evidence["logical_frames"] + checked = [frames[0], frames[len(frames) // 2], frames[-1]] + media = evidence["media"] + attempt = (ROOT / media["path"]).parent + rendered_samples = [pixels(attempt / ("frame-" + str(f).zfill(8) + ".png")) for f in checked] + # H.264 프레임마다 달라질 수 있는 양자화 흔적을 원본 3D 움직임으로 오인하지 않는다. + run.require(len(set(rendered_samples)) == 3, "디코드 PNG RGB 시작/중간/끝에서 3D 움직임이 없습니다: " + evidence["scene_name"]) + decoded = subprocess.run([ffmpeg, "-v", "error", "-xerror", "-i", str(ROOT / media["path"]), + "-map", "0:v:0", "-pix_fmt", "rgb24", "-c:v", "rawvideo", "-f", "image2pipe", "-"], capture_output=True) + run.require(decoded.returncode == 0, "MP4 RGB 디코드가 실패했습니다: " + decoded.stderr.decode("utf-8", errors="replace")[-1000:]) + size = media["width"] * media["height"] * 3 + run.require(len(decoded.stdout) == size * len(frames), "실제 RGB 디코드 프레임 수가 다릅니다") + samples = [decoded.stdout[index * size:(index + 1) * size] for index in (0, len(frames) // 2, len(frames) - 1)] + run.require(len(set(samples)) == 3, "디코드 MP4 RGB 시작/중간/끝에서 3D 움직임이 없습니다: " + evidence["scene_name"]) + return checked + + def main(): parser = argparse.ArgumentParser(description="설치 정보가 아닌 실제 렌더·디코드 검사") parser.add_argument("--blender", required=True) parser.add_argument("--ffmpeg", required=True) parser.add_argument("--ffprobe", required=True) parser.add_argument("--engine", choices=("CYCLES", "BLENDER_EEVEE"), default="CYCLES") + parser.add_argument("--animation-only", action="store_true", help="50fps·5프레임으로 비활성 Scene의 실제 RGB 움직임만 검사") args = parser.parse_args() folder, project, artifacts, job = fixture() if args.engine == "BLENDER_EEVEE": artifacts["08-scenes"]["payload"]["preview"]["render_profile"].update(engine="BLENDER_EEVEE", device="GPU") write(ROOT / job["artifact"], artifacts["08-scenes"]) + if args.animation_only: + artifacts["08-scenes"]["payload"]["preview"]["render_profile"]["fps"] = 50 + job["end"] = 5 + write(ROOT / job["artifact"], artifacts["08-scenes"]) source = ROOT / job["source"] # fixture의 검사 전 바이트는 생성 원본이 아니다. 실패 흔적은 별도 파일로 보존한다. source.rename(source.with_suffix(".uninspected")) @@ -27,14 +58,26 @@ def main(): Path(__file__).with_name("smoke_fixture.py"), "--", source]) video = run.render(ROOT, job, args.blender, args.ffmpeg, args.ffprobe) require = run.require - require(video["media"]["duration_ms"] == 1000, "검사 영상 길이 불일치") - attempt = (ROOT / video["media"]["path"]).parent - require(run.digest(attempt / "frame-00000000.png") != run.digest(attempt / "frame-00000009.png"), "3D 움직임이 없습니다") - still_job = dict(job, frame=10, start=None, end=None) + require(video["media"]["duration_ms"] == (100 if args.animation_only else 1000), "검사 영상 길이 불일치") + second_video = run.render(ROOT, dict(job, scene="SceneB", require_object=["BallB"]), args.blender, args.ffmpeg, args.ffprobe) + motion_checks, errors = [], [] + for rendered in (video, second_video): + try: + motion_checks.append(check_motion(rendered, args.ffmpeg)) + except ValueError as error: + errors.append(str(error)) + require(not errors, "\n".join(errors)) + first_motion, second_motion = motion_checks + still_job = dict(job, frame=4 if args.animation_only else 10, start=None, end=None) first = run.render(ROOT, still_job, args.blender, args.ffmpeg, args.ffprobe) - second = run.render(ROOT, dict(still_job, scene="SceneB"), args.blender, args.ffmpeg, args.ffprobe) - require(first["identity"] != second["identity"] and run.digest(ROOT / first["media"]["path"]) != run.digest(ROOT / second["media"]["path"]), + second = run.render(ROOT, dict(still_job, scene="SceneB", require_object=["BallB"]), args.blender, args.ffmpeg, args.ffprobe) + require(first["identity"] != second["identity"] and pixels(ROOT / first["media"]["path"]) != pixels(ROOT / second["media"]["path"]), "같은 논리 프레임의 다른 Scene 카메라가 혼입되었습니다") + if args.animation_only: + print(json.dumps(dict(first_video=video["media"], second_video=second_video["media"], + first_rgb_frames=first_motion, second_rgb_frames=second_motion, first_scene=first["media"], second_scene=second["media"], + renderer=video["render_profile"], source=video["scene_file"], result="초기 Idle Scene과 무관한 두 Scene의 디코드 RGB 움직임·5프레임·100ms·같은 시각의 Scene 구별 검사 통과"), ensure_ascii=False, indent=2)) + return for invalid in (dict(still_job, scene="Missing"), dict(still_job, require_object=["Missing"])): try: run.render(ROOT, invalid, args.blender, args.ffmpeg, args.ffprobe) diff --git a/tools/blender/smoke_fixture.py b/tools/blender/smoke_fixture.py index efc7fca..9f66790 100644 --- a/tools/blender/smoke_fixture.py +++ b/tools/blender/smoke_fixture.py @@ -1,4 +1,4 @@ -"""실행 검사 전용 3D 구와 두 카메라를 만든다. 제작 내용과 무관하다.""" +"""실행 검사 전용 두 애니메이션 Scene과 초기 Idle Scene을 만든다.""" import sys import bpy @@ -34,7 +34,10 @@ scene.camera = camera second = bpy.data.scenes.new("SceneB") second.frame_start, second.frame_end = 1, 21 second.world = scene.world -second.collection.objects.link(ball) +second_ball = ball.copy() +second_ball.data = ball.data.copy() +second_ball.name = "BallB" +second.collection.objects.link(second_ball) second.collection.objects.link(light) other = camera.copy() other.data = camera.data.copy() @@ -42,6 +45,13 @@ other.location = (5, -7, 1) other.rotation_euler = (-other.location).to_track_quat("-Z", "Y").to_euler() second.collection.objects.link(other) second.camera = other +idle = bpy.data.scenes.new("Idle") +idle.world = scene.world +idle_camera = camera.copy() +idle_camera.data = camera.data.copy() +idle.collection.objects.link(idle_camera) +idle.camera = idle_camera +bpy.context.window.scene = idle if "--external-image" in sys.argv: bpy.data.images.load(sys.argv[sys.argv.index("--external-image") + 1]).use_fake_user = True if "--short-scene" in sys.argv: