From 28a1861e9ec038771922656d07a4c6e2c0ae81fb Mon Sep 17 00:00:00 2001 From: NINI Date: Sat, 10 Oct 2026 00:25:51 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=20Blender=20Scene?= =?UTF-8?q?=EC=9D=98=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=ED=8F=89=EA=B0=80=EC=99=80=20RGB=20=EC=9B=80=EC=A7=81=EC=9E=84?= =?UTF-8?q?=20=EA=B2=80=EC=82=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/blender/inside.py | 2 ++ tools/blender/smoke.py | 55 ++++++++++++++++++++++++++++++---- tools/blender/smoke_fixture.py | 14 +++++++-- 3 files changed, 63 insertions(+), 8 deletions(-) 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: