"""저장소 안의 실제 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 left["id"] in media_by_id and right["id"] in 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())