412 lines
26 KiB
Python
412 lines
26 KiB
Python
"""저장소 안의 실제 Blender 원본과 승인된 요청을 검사·렌더한다."""
|
||
import argparse
|
||
from fractions import Fraction
|
||
import hashlib
|
||
import json
|
||
from pathlib import Path
|
||
import subprocess
|
||
import sys
|
||
import uuid
|
||
import wave
|
||
|
||
from jsonschema import Draft202012Validator
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(ROOT))
|
||
from contracts.check import semantic_errors
|
||
|
||
NEEDED = {
|
||
"08-scenes": {"03-script", "07-cuts"},
|
||
"10-references": {"02-research", "08-scenes", "09-entities"},
|
||
"11-images": {"02-research", "03-script", "07-cuts", "08-scenes", "09-entities", "10-references"},
|
||
"12-motion": {"07-cuts", "08-scenes", "11-images"},
|
||
}
|
||
|
||
|
||
def require(condition, message):
|
||
if not condition:
|
||
raise ValueError(message)
|
||
|
||
|
||
def read(path):
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
|
||
|
||
def relative(root, value):
|
||
path = Path(value)
|
||
require(not path.is_absolute() and ":" not in value and ".." not in path.parts,
|
||
"저장소 상대 경로가 필요합니다: " + value)
|
||
result = (root / path).resolve()
|
||
require(result.is_relative_to(root.resolve()), "저장소 밖의 경로입니다")
|
||
return result
|
||
|
||
|
||
def digest(path):
|
||
with path.open("rb") as source:
|
||
return hashlib.file_digest(source, "sha256").hexdigest()
|
||
|
||
|
||
def check_source(root, metadata):
|
||
path = relative(root, metadata["path"])
|
||
require(path.is_file() and path.suffix.lower() == ".blend", "실제 .blend 원본이 필요합니다")
|
||
actual = digest(path)
|
||
if metadata["verified"]:
|
||
require(actual == metadata["sha256"], "원본 SHA-256이 바뀌었습니다")
|
||
return path, actual
|
||
|
||
|
||
def prepare(root, job):
|
||
"""쓰기 전에 현재 포인터·선행 참조·승인·시간과 요청 항목을 검사한다."""
|
||
validate = {name: Draft202012Validator(read(root / "contracts" / (name + ".schema.json")))
|
||
for name in ("project", "artifact")}
|
||
project_path = relative(root, job["project"])
|
||
project = read(project_path)
|
||
validate["project"].validate(project)
|
||
require(project["schema_version"] == "1.1" and project["settings"].get("visual_engine") == "Blender",
|
||
"새 Blender 실행 대상 진행 기록은 1.1·visual_engine=Blender여야 합니다")
|
||
pointers = {stage["stage_id"]: stage for stage in project["stages"]}
|
||
context = {}
|
||
loading = set()
|
||
|
||
def load(stage, complete=True):
|
||
if stage in context:
|
||
return context[stage]
|
||
pointer = pointers[stage]
|
||
require(pointer["artifact_path"] and pointer["revision"], "현재 선행 결과가 없습니다: " + stage)
|
||
require(pointer["status"] == "complete" if complete else pointer["status"] in
|
||
("draft", "awaiting_input", "awaiting_review", "complete"), "현재 단계 상태가 유효하지 않습니다: " + stage)
|
||
path = relative(root, pointer["artifact_path"])
|
||
expected = project_path.parent / "stages" / stage / ("r" + str(pointer["revision"])) / "artifact.json"
|
||
require(path == expected.resolve(), "현재 결과 포인터 경로가 불일치합니다: " + stage)
|
||
data = read(path)
|
||
validate["artifact"].validate(data)
|
||
require((data["project_id"], data["stage_id"], data["revision"]) ==
|
||
(project["project_id"], stage, pointer["revision"]), "결과의 제작물·단계·버전이 불일치합니다")
|
||
require(stage not in loading, "선행 참조가 순환합니다")
|
||
loading.add(stage)
|
||
ids = [ref["stage_id"] for ref in data["inputs"]]
|
||
require(len(ids) == len(set(ids)), "선행 단계 참조가 중복되었습니다")
|
||
require(NEEDED.get(stage, set()) <= set(ids), "필수 선행 참조가 빠졌습니다: " + stage)
|
||
for ref in data["inputs"]:
|
||
require(ref["revision"] == pointers[ref["stage_id"]]["revision"], "오래된 선행 버전입니다")
|
||
load(ref["stage_id"])
|
||
loading.remove(stage)
|
||
# preview 초안의 미검증 결과는 렌더 뒤 검사한다. 승인된 결과는 완료 의미 검사를 한다.
|
||
if complete and data["schema_version"] == "1.1":
|
||
require(not semantic_errors(data, context, complete=True), "완료 선행 결과의 의미 조건이 유효하지 않습니다: " + stage)
|
||
context[stage] = data
|
||
return data
|
||
|
||
data = read(relative(root, job["artifact"]))
|
||
stage = data["stage_id"]
|
||
require(stage in NEEDED and data["schema_version"] == "1.1", "렌더 대상은 1.1의 08 또는 10–12입니다")
|
||
require(relative(root, job["artifact"]) == relative(root, pointers[stage]["artifact_path"]), "현재 결과가 아닌 요청입니다")
|
||
artifact = load(stage, complete=False)
|
||
require(job["output_revision"] == artifact["revision"], "출력 버전과 현재 요청 버전이 다릅니다")
|
||
mode = job["mode"]
|
||
require((mode == "preview" and stage == "08-scenes") or
|
||
(mode == "production" and stage in ("10-references", "11-images", "12-motion")), "대표 렌더와 정식 제작 단계를 구분해야 합니다")
|
||
|
||
for review_id, stages in (("topic", {"01-topic"}), ("story", {"02-research", "03-script", "04-title"}),
|
||
("scenes", {"07-cuts", "08-scenes", "09-entities"})):
|
||
if review_id == "scenes" and mode == "preview":
|
||
continue
|
||
review = next(r for r in project["reviews"] if r["review_id"] == review_id)
|
||
require(review["decision"] == "approved" and review["feedback"].strip(), "실제 현재 사용자 승인이 필요합니다: " + review_id)
|
||
refs = {r["stage_id"]: r["revision"] for r in review["artifact_refs"]}
|
||
require(stages <= refs.keys() and len(refs) == len(review["artifact_refs"]), "승인 참조가 누락·중복되었습니다")
|
||
for approved_stage, revision in refs.items():
|
||
require(revision == pointers[approved_stage]["revision"], "승인은 이전 버전에 대한 것입니다")
|
||
load(approved_stage)
|
||
|
||
payload = artifact["payload"]
|
||
request = payload["preview"] if mode == "preview" else payload["request"]
|
||
require(request is not None, "대표 원본과 RenderProfile을 먼저 작성해야 합니다")
|
||
profile = request["render_profile"]
|
||
require(profile["engine"] != "BLENDER_EEVEE" or profile["seed"] == 0,
|
||
"이 EEVEE 경로는 기본 seed=0만 지원합니다. 임의 seed API가 확인되지 않았습니다")
|
||
require(profile["width"] * 16 == profile["height"] * 9 and profile["width"] % 2 == 0
|
||
and profile["height"] % 2 == 0, "H.264 출력을 위한 짝수 크기 9:16 화면이 필요합니다")
|
||
metadata = request["scene_file"] if mode == "preview" else dict(path=job["source"], verified=False, sha256=None, blender_version=None)
|
||
if stage == "11-images":
|
||
require(job["source"] == request["scene_path"], "11 원본 요청 경로가 불일치합니다")
|
||
if payload["result"]:
|
||
metadata = payload["result"]["scene_file"]
|
||
if stage == "12-motion":
|
||
previous = context["11-images"]["payload"]
|
||
metadata = previous["result"]["scene_file"]
|
||
require(metadata["verified"] and request["render_profile"] == previous["request"]["render_profile"], "11 검증 원본·동일 프로필이 필요합니다")
|
||
require(request["scene_path"] == metadata["path"], "11 원본 참조가 다릅니다")
|
||
require(job["source"] == metadata["path"], "원본 메타데이터 경로가 다릅니다")
|
||
source, source_hash = check_source(root, metadata)
|
||
require(source.is_relative_to(project_path.parent), "다른 제작물의 원본입니다")
|
||
if mode == "preview" or stage in ("10-references", "11-images"):
|
||
require(source.is_relative_to(relative(root, job["artifact"]).parent), "대표/기준 원본은 자기 새 버전 아래에 있어야 합니다")
|
||
|
||
cuts_data = load("07-cuts")["payload"]
|
||
cuts = {cut["id"]: cut for cut in cuts_data["cuts"]}
|
||
require(len(cuts) == len(cuts_data["cuts"]) and cuts, "실측 컷이 없거나 중복되었습니다")
|
||
previous_end = 0
|
||
for cut in cuts.values():
|
||
require(cut["start_ms"] == previous_end and 0 < cut["end_ms"] - cut["start_ms"] <= 5000,
|
||
"실측 컷은 겹침·빈 구간 없이 5초 이내여야 합니다")
|
||
require(all(cut[k] * profile["fps"] % 1000 == 0 for k in ("start_ms", "end_ms")), "실측 컷이 프레임 격자에 맞지 않습니다")
|
||
previous_end = cut["end_ms"]
|
||
tts = load("06-tts")["payload"]
|
||
narration = tts["result"]["narration"]
|
||
require(narration["verified"] and previous_end == cuts_data["narration_duration_ms"] <= 180000, "실측 통합 음성·컷 전체 길이가 다릅니다")
|
||
def audio_measure(media):
|
||
path = relative(root, media["path"])
|
||
require(media["verified"] and media["kind"] == "audio" and path.is_file()
|
||
and path.is_relative_to(project_path.parent), "실제 검증 단위/통합 WAV가 필요합니다")
|
||
with wave.open(str(path), "rb") as wav:
|
||
require(Fraction(wav.getnframes() * 1000, wav.getframerate()) == media["duration_ms"], "실제 WAV 길이가 다릅니다")
|
||
require(len(wav.readframes(wav.getnframes())) == wav.getnframes() * wav.getnchannels() * wav.getsampwidth(), "WAV PCM 데이터가 잘렸습니다")
|
||
return wav.getnchannels(), wav.getsampwidth(), wav.getframerate()
|
||
audio_format = audio_measure(narration)
|
||
files, ranges = tts["result"]["files"], tts["result"]["unit_ranges"]
|
||
ids = [x["id"] for x in tts["request"]["units"]]
|
||
require(ids and len(ids) == len(set(ids)) and ids == [x["unit_id"] for x in files] == [x["unit_id"] for x in ranges],
|
||
"06 단위 요청·실제 WAV·통합 구간 참조가 다릅니다")
|
||
end_ms = 0
|
||
for item, unit_range in zip(files, ranges):
|
||
require(audio_measure(item["media"]) == audio_format, "단위/통합 WAV 형식이 다릅니다")
|
||
require(unit_range["start_ms"] == end_ms and unit_range["end_ms"] - end_ms == item["media"]["duration_ms"] > 0,
|
||
"06 단위 실측 구간이 다릅니다")
|
||
end_ms = unit_range["end_ms"]
|
||
require(end_ms == previous_end == narration["duration_ms"], "실제 단위/통합 WAV 전체 길이가 다릅니다")
|
||
require(all(set(c["unit_ids"]) <= set(ids) for c in cuts.values()), "07 발화 단위 참조가 다릅니다")
|
||
scenes_payload = artifact["payload"] if mode == "preview" else context["08-scenes"]["payload"]
|
||
scene_cuts = [s["cut_id"] for s in scenes_payload["scenes"]]
|
||
require(len(scene_cuts) == len(cuts) and set(scene_cuts) == cuts.keys(), "08 장면의 컷이 누락·중복되었습니다")
|
||
ordered = list(cuts)
|
||
require([(c["from_cut_id"], c["to_cut_id"]) for c in scenes_payload["connections"]] ==
|
||
list(zip(ordered, ordered[1:])), "08 연결의 인접 컷 참조가 다릅니다")
|
||
if "09-entities" in context:
|
||
entities = context["09-entities"]["payload"]
|
||
appearances = entities["appearances"]
|
||
require(len(appearances) == len(cuts) and {a["cut_id"] for a in appearances} == cuts.keys(), "09 컷별 등장 참조가 누락·중복되었습니다")
|
||
for kind in ("character", "background"):
|
||
ids = [item["id"] for item in entities[kind + "s"]]
|
||
require(len(ids) == len(set(ids)) and all(set(a[kind + "_ids"]) <= set(ids) for a in appearances), "09 등장 요소 ID 참조가 다릅니다")
|
||
|
||
frame, start, end = job.get("frame"), job.get("start"), job.get("end")
|
||
require((frame is not None and start is None and end is None) or
|
||
(frame is None and start is not None and end is not None and 0 <= start < end), "이미지 한 프레임 또는 영상 [start,end) 범위를 지정해야 합니다")
|
||
require(frame is not None or (end - start) * 1000 <= 5000 * profile["fps"], "대표/컷 영상은 5초 이내입니다")
|
||
require(frame is not None or (end - start) * 1000 % profile["fps"] == 0, "영상 길이는 정수 밀리초 격자여야 합니다")
|
||
frames = [frame] if frame is not None else list(range(start, end))
|
||
require(frames and 0 <= frames[0] and (len(frames) * 1000 <= 5000 * profile["fps"] or frame is not None), "대표/컷 영상은 5초 이내입니다")
|
||
require(frames[-1] <= previous_end * profile["fps"] // 1000, "통합 음성 밖의 프레임입니다")
|
||
if mode == "preview":
|
||
selected = job.get("cut_id", [])
|
||
require(0 < len(selected) <= 3 and len(selected) == len(set(selected)) and set(selected) <= cuts.keys(), "대표 컷 1–3개를 지정해야 합니다")
|
||
require(all(any(cuts[c]["start_ms"] * profile["fps"] // 1000 <= f <
|
||
cuts[c]["end_ms"] * profile["fps"] // 1000 + (1 if frame is not None else 0)
|
||
for c in selected) for f in frames), "대표 프레임이 지정 컷 범위 밖입니다")
|
||
elif stage == "11-images":
|
||
matches = [f for f in request["frames"] if f["id"] == job.get("frame_id")]
|
||
require(len(matches) == 1 and frame is not None and (job["scene"], frame) ==
|
||
(matches[0]["scene_name"], matches[0]["frame_index"]), "11 논리 프레임·Scene 참조가 다릅니다")
|
||
cut = cuts[matches[0]["cut_id"]]
|
||
require(frame == cut[matches[0]["position"] + "_ms"] * profile["fps"] // 1000, "11 끝점과 실측 컷이 다릅니다")
|
||
require(not semantic_errors(artifact, context), "11 요청의 참조가 유효하지 않습니다")
|
||
elif stage == "12-motion":
|
||
matches = [c for c in request["clips"] if c["cut_id"] in job.get("cut_id", [])]
|
||
require(len(job.get("cut_id", [])) == len(matches) == 1 and frame is None,
|
||
"12 요청의 컷 한 개를 지정해야 합니다")
|
||
require((job["scene"], start, end) == (matches[0]["scene_name"], matches[0]["start_frame"], matches[0]["end_frame"]), "12 Scene·프레임 범위 참조가 다릅니다")
|
||
require(not semantic_errors(artifact, context), "12 요청의 참조가 유효하지 않습니다")
|
||
else:
|
||
require(frame is not None and any(x["id"] == job.get("reference_id") for x in request["items"]), "10 기준 ID와 이미지 프레임이 필요합니다")
|
||
require(not semantic_errors(artifact, context), "10 요청의 등장 요소 참조가 유효하지 않습니다")
|
||
if stage in ("11-images", "12-motion"):
|
||
endpoints = payload if stage == "11-images" else context["11-images"]["payload"]
|
||
frames_by_position = {(x["cut_id"], x["position"]): x for x in endpoints["request"]["frames"]}
|
||
for (cut_id, position), target in frames_by_position.items():
|
||
require(target["frame_index"] == cuts[cut_id][position + "_ms"] * profile["fps"] // 1000,
|
||
"11 전체 끝점과 실측 컷이 다릅니다")
|
||
media_by_id = {x["frame_id"]: x["media"] for x in (endpoints["result"] or {}).get("frames", [])}
|
||
for connection in context["08-scenes"]["payload"]["connections"]:
|
||
left = frames_by_position[(connection["from_cut_id"], "end")]
|
||
right = frames_by_position[(connection["to_cut_id"], "start")]
|
||
if connection["mode"] == "cut":
|
||
require(left["scene_name"] != right["scene_name"], "cut 경계는 서로 다른 Scene 이름이 필요합니다")
|
||
continue
|
||
require((left["scene_name"], left["frame_index"]) == (right["scene_name"], right["frame_index"]),
|
||
"continuous 경계의 Scene/논리 프레임이 다릅니다")
|
||
if media_by_id:
|
||
require(media_by_id[left["id"]]["path"] == media_by_id[right["id"]]["path"],
|
||
"continuous 끝점은 같은 실제 PNG 경로를 공유해야 합니다")
|
||
objects = list(job.get("require_object", []))
|
||
source_inputs, media_inputs = [], []
|
||
if mode == "production":
|
||
for input_stage in ("08-scenes", "10-references", "11-images"):
|
||
if input_stage not in context or input_stage == stage:
|
||
continue
|
||
prior = context[input_stage]["payload"]
|
||
result = prior["preview"] if input_stage == "08-scenes" else prior["result"]
|
||
require(result and result["scene_file"]["verified"], "실제 검증 선행 .blend가 필요합니다")
|
||
input_directory = relative(root, pointers[input_stage]["artifact_path"]).parent
|
||
require(relative(root, result["scene_file"]["path"]).is_relative_to(input_directory), "이전 버전/다른 단계의 선행 .blend입니다")
|
||
check_source(root, result["scene_file"])
|
||
source_inputs.append(result["scene_file"])
|
||
key = {"08-scenes": "samples", "10-references": "items", "11-images": "frames"}[input_stage]
|
||
input_profile = prior["preview"]["render_profile"] if input_stage == "08-scenes" else prior["request"]["render_profile"]
|
||
for item in result[key]:
|
||
media = item["media"]
|
||
require(media["verified"] and relative(root, media["path"]).is_file(), "실제 검증 선행 PNG/MP4가 필요합니다")
|
||
require(relative(root, media["path"]).is_relative_to(input_directory), "이전 버전/다른 단계의 선행 미디어입니다")
|
||
media_inputs.append(dict(media=media, profile=input_profile))
|
||
if stage in ("11-images", "12-motion") and "10-references" in context:
|
||
endpoint_request = payload["request"] if stage == "11-images" else context["11-images"]["payload"]["request"]
|
||
selected_frames = [x for x in endpoint_request["frames"] if
|
||
(x["id"] == job.get("frame_id") if stage == "11-images" else x["cut_id"] in job["cut_id"])]
|
||
selected_ids = {ref for frame in selected_frames for ref in frame["reference_ids"]}
|
||
refs = context["10-references"]["payload"]
|
||
model_ids = {x["id"] for x in refs["request"]["items"] if x["kind"] != "style"}
|
||
objects += [x["object_name"] for x in refs["result"]["items"] if x["reference_id"] in selected_ids & model_ids]
|
||
require(objects, "검사할 실제 오브젝트/컬렉션 이름이 필요합니다")
|
||
return dict(job, profile=profile, frames=frames, source_hash=source_hash,
|
||
metadata=metadata, require_object=sorted(set(objects)),
|
||
source_inputs=source_inputs, media_inputs=media_inputs)
|
||
|
||
|
||
def identity(job, source_hash):
|
||
value = dict(source=source_hash, profile=job["profile"], scene=job["scene"], frames=job["frames"],
|
||
renderer=digest(Path(__file__).with_name("inside.py")))
|
||
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
|
||
|
||
|
||
def execute(command, logfile=None):
|
||
result = subprocess.run([str(x) for x in command], capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||
if logfile:
|
||
logfile.write_text(result.stdout + result.stderr, encoding="utf-8")
|
||
require(result.returncode == 0, "실행 실패 (종료코드 " + str(result.returncode) + "): " + (result.stderr or result.stdout)[-1500:])
|
||
return result.stdout
|
||
|
||
|
||
def measure(path, profile, count, ffmpeg, ffprobe):
|
||
value = json.loads(execute([ffprobe, "-v", "error", "-count_frames", "-show_streams", "-show_format", "-of", "json", path]))
|
||
videos = [s for s in value["streams"] if s["codec_type"] == "video"]
|
||
require(len(videos) == 1 and len(value["streams"]) == 1, "무음 영상 트랙 하나가 필요합니다")
|
||
stream = videos[0]
|
||
require((stream["width"], stream["height"]) == (profile["width"], profile["height"]), "실제 출력 해상도가 다릅니다")
|
||
kind = "image" if path.suffix == ".png" else "video"
|
||
require(stream["codec_name"] == ("png" if kind == "image" else "h264"), "실제 출력 코덱이 다릅니다")
|
||
require(int(stream["nb_read_frames"]) == count, "실제 디코드 프레임 수가 다릅니다")
|
||
duration = None
|
||
if kind == "video":
|
||
require(Fraction(stream["avg_frame_rate"]) == profile["fps"], "실제 영상 fps가 다릅니다")
|
||
expected = Fraction(count * 1000, profile["fps"])
|
||
require(expected.denominator == 1 and abs(Fraction(stream["duration"]) * 1000 - expected) <= 1,
|
||
"실제 영상 길이가 다릅니다")
|
||
duration = int(expected)
|
||
execute([ffmpeg, "-v", "error", "-xerror", "-i", path, "-map", "0:v:0", "-c:v", "rawvideo", "-f", "null", "-"])
|
||
return dict(path=path.relative_to(ROOT).as_posix(), kind=kind, verified=True,
|
||
duration_ms=duration, width=stream["width"], height=stream["height"])
|
||
|
||
|
||
def blender_call(blender, source, job, folder, operation):
|
||
jobpath = folder / (operation + "-job.json")
|
||
resultpath = folder / (operation + "-result.json")
|
||
value = dict(job, operation=operation, result=str(resultpath), directory=str(folder))
|
||
jobpath.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
|
||
execute([blender, "--background", "--factory-startup", "--disable-autoexec", source,
|
||
"--python-exit-code", "1", "--python", Path(__file__).with_name("inside.py"), "--", jobpath], folder / (operation + ".log"))
|
||
require(resultpath.is_file(), "Blender 검사 결과가 없습니다")
|
||
return read(resultpath)
|
||
|
||
|
||
def render(root, job, blender, ffmpeg, ffprobe):
|
||
job = prepare(root, job)
|
||
source = relative(root, job["source"])
|
||
artifact = relative(root, job["artifact"])
|
||
key = identity(job, job["source_hash"])
|
||
folder = artifact.parent / "render-attempts" / (key[:16] + "-" + uuid.uuid4().hex[:12])
|
||
folder.mkdir(parents=True, exist_ok=False)
|
||
try:
|
||
for index, metadata in enumerate(job["source_inputs"]):
|
||
prior_source, prior_hash = check_source(root, metadata)
|
||
prior_folder = folder / ("input-source-" + str(index))
|
||
prior_folder.mkdir()
|
||
prior_report = blender_call(blender, prior_source,
|
||
dict(scene=None, require_object=[]), prior_folder, "inspect")
|
||
require(prior_report["blender_version"] == metadata["blender_version"]
|
||
and digest(prior_source) == prior_hash, "선행 .blend 버전·해시가 다릅니다")
|
||
for item in job["media_inputs"]:
|
||
media, profile = item["media"], item["profile"]
|
||
count = 1 if media["kind"] == "image" else Fraction(media["duration_ms"] * profile["fps"], 1000)
|
||
require(isinstance(count, int) or count.denominator == 1, "선행 영상이 프레임 격자와 다릅니다")
|
||
actual = measure(relative(root, media["path"]), profile, int(count), ffmpeg, ffprobe)
|
||
require(actual == media, "선행 미디어의 실제 메타데이터가 기록과 다릅니다")
|
||
report = blender_call(blender, source, job, folder, "inspect")
|
||
if job["metadata"]["verified"]:
|
||
require(report["blender_version"] == job["metadata"]["blender_version"], "검증 원본의 Blender 버전이 다릅니다")
|
||
require(digest(source) == job["source_hash"], "검사 중 원본이 바뀌었습니다")
|
||
rendered = blender_call(blender, source, job, folder, "render")
|
||
require(rendered["render_profile"] == job["profile"], "실제 적용 프로필이 요청과 다릅니다")
|
||
require(digest(source) == job["source_hash"], "렌더 중 원본이 바뀌었습니다")
|
||
# 같은 시각을 다른 Scene으로 평가할 때도 원본·Scene·프로필·프레임이 서로 분리된다.
|
||
paths = [folder / ("frame-" + str(f).zfill(8) + ".png") for f in job["frames"]]
|
||
pngs = [measure(path, job["profile"], 1, ffmpeg, ffprobe) for path in paths]
|
||
if job.get("frame") is None:
|
||
output = folder / "clip.mp4"
|
||
execute([ffmpeg, "-v", "error", "-n", "-framerate", job["profile"]["fps"], "-start_number", job["start"],
|
||
"-i", folder / "frame-%08d.png", "-frames:v", len(paths), "-an", "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p", "-movflags", "+faststart", output], folder / "encode.log")
|
||
media = measure(output, job["profile"], len(paths), ffmpeg, ffprobe)
|
||
else:
|
||
media = pngs[0]
|
||
evidence = dict(scene_file=dict(path=job["source"], verified=True, sha256=job["source_hash"], blender_version=report["blender_version"]),
|
||
render_profile=rendered["render_profile"], scene_name=job["scene"], logical_frames=job["frames"],
|
||
source_inspection=report, render_inspection=rendered,
|
||
media=media, identity=key, visual_review="not_checked")
|
||
(folder / "evidence.json").write_text(json.dumps(evidence, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
return evidence
|
||
except Exception as error:
|
||
(folder / "failure.json").write_text(json.dumps(dict(error=str(error), identity=key), ensure_ascii=False), encoding="utf-8")
|
||
raise
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="실제 Blender 원본 검사와 제한 렌더. 제작 상태는 변경하지 않습니다.")
|
||
parser.add_argument("operation", choices=("inspect", "render"))
|
||
parser.add_argument("--blender", required=True)
|
||
parser.add_argument("--source", required=True)
|
||
parser.add_argument("--scene", required=True)
|
||
parser.add_argument("--require-object", action="append", default=[])
|
||
parser.add_argument("--mode", choices=("preview", "production"))
|
||
parser.add_argument("--project")
|
||
parser.add_argument("--artifact")
|
||
parser.add_argument("--output-revision", type=int)
|
||
parser.add_argument("--frame", type=int)
|
||
parser.add_argument("--start", type=int)
|
||
parser.add_argument("--end", type=int)
|
||
parser.add_argument("--cut-id", action="append", default=[])
|
||
parser.add_argument("--frame-id")
|
||
parser.add_argument("--reference-id")
|
||
parser.add_argument("--ffmpeg")
|
||
parser.add_argument("--ffprobe")
|
||
args = parser.parse_args()
|
||
job = vars(args)
|
||
try:
|
||
if args.operation == "inspect":
|
||
source, source_hash = check_source(ROOT, dict(path=args.source, verified=False))
|
||
folder = ROOT / "tools/blender/.cache" / ("inspect-" + uuid.uuid4().hex)
|
||
folder.mkdir(parents=True)
|
||
result = blender_call(args.blender, source, job, folder, "inspect")
|
||
require(digest(source) == source_hash, "검사 중 원본이 바뀌었습니다")
|
||
result["scene_file"] = dict(path=args.source, verified=True, sha256=source_hash, blender_version=result["blender_version"])
|
||
else:
|
||
require(all((args.mode, args.project, args.artifact, args.output_revision, args.ffmpeg, args.ffprobe)), "렌더의 내부 실행 인자가 빠졌습니다")
|
||
result = render(ROOT, job, args.blender, args.ffmpeg, args.ffprobe)
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
except Exception as error:
|
||
print("검사/렌더 실패: " + str(error), file=sys.stderr)
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|