"""Verificar medios exportados, OBJ portables y poses del rig Java.""" import hashlib import importlib.util import json import math import subprocess import argparse from pathlib import Path from PIL import Image HERE = Path(__file__).resolve().parent spec = importlib.util.spec_from_file_location("parasite_preview", HERE / "render-parasite-preview.py") preview = importlib.util.module_from_spec(spec) spec.loader.exec_module(preview) # Mallas: índices válidos y materiales relativos junto al OBJ. def inspect_obj(path): vertices, coordinates, normals, faces = [], [], [], [] material_file = None for line in path.read_text(encoding="utf-8").splitlines(): fields = line.split() if not fields: continue if fields[0] == "v": vertices.append([float(n) for n in fields[1:]]) elif fields[0] == "vt": coordinates.append([float(n) for n in fields[1:]]) elif fields[0] == "vn": normals.append([float(n) for n in fields[1:]]) elif fields[0] == "f": faces.append(fields[1:]) elif fields[0] == "mtllib": material_file = path.parent / fields[1] assert vertices and faces, path assert all(math.isfinite(n) for values in vertices + coordinates + normals for n in values), path for face in faces: assert len(face) == 4, path for corner in face: vertex, texture, normal = (int(n) for n in corner.split("/")) assert 1 <= vertex <= len(vertices) and 1 <= texture <= len(coordinates) and 1 <= normal <= len(normals), path assert material_file and material_file.exists(), path for line in material_file.read_text(encoding="utf-8").splitlines(): if line.startswith("map_Kd "): atlas = path.parent / line.removeprefix("map_Kd ") assert atlas.exists() and Image.open(atlas).size == (128, 128), atlas return {"vertices": len(vertices), "faces": len(faces), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} # Videos y GIF: comprobar duración, FPS y silencio de la variante sensible. def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--modes", help="Auditar solo estos videos, separados por comas") arguments = parser.parse_args() result = {"videos": {}, "gifs": {}, "objects": {}, "native_poses": {}, "source_hashes": {}} durations = {"idle": 7, "chase": 3, "chase4": 3, "drag": 3, "crawl": 3, "crawl2": 3, "crawl3": 3, "crawl4": 3, "fold": 4, "turn": 3, "climb": 3, "attack": 3, "lore": 9, "friendly": 2, "encuentro-voz": 17} if arguments.modes: selected = arguments.modes.split(",") if any(mode not in durations for mode in selected): parser.error("Modos desconocidos") durations = {mode: durations[mode] for mode in selected} for mode, duration in durations.items(): output = subprocess.run([str(preview.FFMPEG.with_name("ffprobe.exe")), "-v", "error", "-show_entries", "format=duration:stream=codec_type,width,height,r_frame_rate,channels", "-of", "json", str(HERE / f"parasito-{mode}.mp4")], check=True, capture_output=True, text=True) video = json.loads(output.stdout) assert abs(float(video["format"]["duration"]) - duration) < .09, mode assert video["streams"][0]["r_frame_rate"] == f"{preview.FPS}/1", mode if mode == "friendly": assert len(video["streams"]) == 1, mode if mode == "encuentro-voz": assert any(s.get("codec_type") == "audio" and s.get("channels") == 1 for s in video["streams"]) result["videos"][mode] = video if "friendly" in durations: frames = subprocess.run([str(preview.FFMPEG), "-v", "error", "-i", str(HERE / "parasito-friendly.mp4"), "-map", "0:v", "-frames:v", "40", "-f", "framemd5", "pipe:1"], check=True, capture_output=True, text=True, timeout=30) distinct = {line.rsplit(",", 1)[-1].strip() for line in frames.stdout.splitlines() if line and not line.startswith("#")} assert len(distinct) > 8, "La variante sensible debe mostrar movimiento real" result["videos"]["friendly"]["distinct_frames"] = len(distinct) for mode, duration in (("lore", 9000), ("chase", 3000)): if mode not in durations: continue image = Image.open(HERE / f"parasito-{mode}.gif") elapsed = sum(image.seek(i) or image.info.get("duration", 0) for i in range(image.n_frames)) assert image.n_frames == duration * preview.FPS // 1000 and elapsed == duration, mode result["gifs"][mode] = {"frames": image.n_frames, "duration_ms": elapsed} classes = preview.ROOT / "build/parasite-preview-classes" if not arguments.modes: for path in (HERE / "modelos-3d").glob("*.obj"): result["objects"][path.name] = inspect_obj(path) assert len(result["objects"]) == 9 # Comprobar el exportado con el Java original y las huellas de fuentes/texturas. for name, mode, seconds in (("parasito-humanoide", "idle", 1.2), ("parasito-aracnido", "crawl", 1.2), ("parasito-encuentro", "lore", 3.25), ("parasito-sensible", "friendly", 1.2)): exported = json.loads((HERE / "modelos-3d" / (name + ".json")).read_text(encoding="utf-8")) current = preview.rig_sample(classes, mode, 4, seconds) assert exported["bones"] == current["bones"] and exported["pose"] == current["pose"], name result["native_poses"][name] = "equal" provenance = json.loads((HERE / "parasito-modelos-origen.json").read_text(encoding="utf-8")) if not arguments.modes: for filename, key in ((preview.RIG, "rig_sha256"), (preview.MODEL, "model_sha256")): digest = hashlib.sha256(filename.read_bytes()).hexdigest() assert digest == provenance[key], filename result["source_hashes"][filename.name] = digest for filename, digest in provenance["textures"].items(): assert hashlib.sha256(Path(filename).read_bytes()).hexdigest() == digest assert provenance.get("stills_sources") == preview.source_snapshot(), "Imágenes/OBJ requieren render de la versión actual" # Un render parcial nunca certifica videos viejos con la huella de Java nuevo. # Comparar muestras completas permite reutilizar un clip que realmente no cambió. result["video_provenance"] = {} for mode in durations: native_mode = "lore" if mode == "encuentro-voz" else mode record = provenance.get("videos", {}).get(native_mode) assert record, f"{mode}: falta procedencia individual; regenerar este video" sequence = preview.rig_sequence(classes, native_mode, 4, preview.duration(native_mode)) assert record["fingerprint"]["samples_sha256"] == preview.sample_digest(sequence), f"{mode}: poses obsoletas" assert record["fingerprint"]["fps"] == preview.FPS, mode assert record["fingerprint"]["renderer_sha256"] == preview.digest(Path(preview.__file__).read_bytes()), f"{mode}: renderer distinto" assert record["fingerprint"]["textures"] == preview.source_snapshot()["textures"], f"{mode}: texturas obsoletas" for filename, expected in record["outputs"].items(): assert preview.digest((HERE / filename).read_bytes()) == expected, f"{mode}: archivo modificado {filename}" result["video_provenance"][mode] = "current native samples and file hashes verified" if not arguments.modes: # Demostración 1×3: mismos movimientos Java, corredor visible y exportado separado. demo_path = HERE / "parasito-crawl3-paredes-origen.json" demo = json.loads(demo_path.read_text(encoding="utf-8")) assert demo["mode"] == "crawl3" and demo["stage"] == 4 assert demo["fps"] == preview.FPS and demo["duration_seconds"] == 12 and demo["frame_count"] == 240 for filename, expected in demo["source_hashes"].items(): assert preview.digest((preview.ROOT / filename).read_bytes()) == expected, filename for filename, expected in demo["output_hashes"].items(): assert preview.digest((HERE / filename).read_bytes()) == expected, filename native = preview.rig_sequence(classes, "crawl3", 4, 12) assert preview.sample_digest(native) == demo["native_samples_sha256"], "La demostración debe usar las poses actuales del mod" probe = subprocess.run([str(preview.FFMPEG.with_name("ffprobe.exe")), "-v", "error", "-show_entries", "format=duration:stream=codec_type,width,height,r_frame_rate,nb_frames", "-of", "json", str(HERE / "parasito-crawl3-paredes.mp4")], check=True, capture_output=True, text=True, timeout=15) media = json.loads(probe.stdout) assert abs(float(media["format"]["duration"]) - 12) < .01 assert len(media["streams"]) == 1 and media["streams"][0]["r_frame_rate"] == "20/1" assert int(media["streams"][0]["nb_frames"]) == 240 assert max(item["maximum_gap_blocks"] for item in demo["wall_contacts"].values()) < .025 result["corridor_demo"] = {"video": media, "native_samples": "equal", "wall_contacts": demo["wall_contacts"], "source_and_output_hashes": "verified"} # Escalada: verificar apoyos de pared, distancia vertical y exportado nativo. demo = json.loads((HERE / "parasito-climb-pared-origen.json").read_text(encoding="utf-8")) assert demo["mode"] == "climb" and demo["stage"] == 4 assert demo["fps"] == preview.FPS and demo["duration_seconds"] == 6 and demo["frame_count"] == 120 assert demo["wall_motion_model_units_per_second"] == 10 for filename, expected in demo["source_hashes"].items(): assert preview.digest((preview.ROOT / filename).read_bytes()) == expected, filename for filename, expected in demo["output_hashes"].items(): assert preview.digest((HERE / filename).read_bytes()) == expected, filename native = preview.rig_sequence(classes, "climb", 4, 6) assert preview.sample_digest(native) == demo["native_samples_sha256"], "La escalada debe usar las poses actuales del mod" spec = importlib.util.spec_from_file_location("climb_demo_audit", HERE / "render-climbing-demo.py") climb = importlib.util.module_from_spec(spec) spec.loader.exec_module(climb) contacts = climb.support_measurements(native) assert contacts == demo["wall_contacts"], "Los apoyos deben medirse sobre los mismos fotogramas" assert contacts["maximum_support_gap_blocks"] < .002 and contacts["maximum_planted_drift_blocks"] < .002 probe = subprocess.run([str(preview.FFMPEG.with_name("ffprobe.exe")), "-v", "error", "-show_entries", "format=duration:stream=codec_type,width,height,r_frame_rate,nb_frames", "-of", "json", str(HERE / "parasito-climb-pared.mp4")], check=True, capture_output=True, text=True, timeout=15) media = json.loads(probe.stdout) assert abs(float(media["format"]["duration"]) - 6) < .01 assert len(media["streams"]) == 1 and media["streams"][0]["r_frame_rate"] == "20/1" assert int(media["streams"][0]["nb_frames"]) == 120 result["climbing_demo"] = {"video": media, "native_samples": "equal", "wall_contacts": contacts, "source_and_output_hashes": "verified"} result["scope"] = "selected videos only" if arguments.modes else "all media, meshes and native poses, plus visible corridor and climbing demos" (HERE / "parasito-preview-validacion.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") print(f"PASS: {len(durations)} videos nativos con procedencia individual verificada; alcance: {result['scope']}.") if __name__ == "__main__": main()