Volver al índice
docs/validate-parasite-assets.py
"""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()