Volver al índice

docs/render-parasite-preview.py

"""Render the same ParasiteRig mesh and poses used by Minecraft.

Java is the single source of geometry, pivots and animation. This script only
projects its boxes, reads the shipped atlas and presents an offline preview.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import io
import json
import math
import subprocess
import tempfile
import shutil
import time
from functools import lru_cache
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter

# Paths and presentation settings.
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "docs"
RIG = ROOT / "src/main/java/ar/com/companeros/horror/client/ParasiteRig.java"
MODEL = RIG.with_name("ParasiteModel.java")
ASSETS = ROOT / "src/main/resources/assets/companeros/textures/entity"
JAVA_HOME = Path("C:/Program Files/Eclipse Adoptium/jdk-17.0.20.8-hotspot")
FFMPEG = Path("C:/Users/toto83571/AppData/Local/CodexTools/ffmpeg/ffmpeg-9.0-full_build-shared/bin/ffmpeg.exe")
FONT_HOME = Path("C:/Windows/Fonts")
CLASS_NAME = "ar.com.companeros.horror.client.ParasiteRig"
FPS = 20
SEQUENCES = ("idle", "lore", "chase", "chase4", "drag", "crawl", "crawl2", "crawl3", "crawl4", "fold", "turn", "climb", "attack", "friendly")
LABELS = {"idle": "ACECHO", "chase": "PERSECUCIÓN", "crawl": "PASO ESTRECHO", "fold": "PLEGARSE", "turn": "GIRAR", "climb": "TREPAR", "lore": "EL ENCUENTRO", "attack": "ATAQUE", "friendly": "MODO SENSIBLE"}
RIG_MODES = {"idle": "stalk", "crawl": "compact"}
LABELS.update({"crawl2":"PASO 1×2", "crawl3":"PASO 1×3", "crawl4":"PASO 1×4"})
LABELS.update({"chase4": "PERSECUCIÓN EN CUATRO", "drag": "ARRASTRE"})

# Reutilizar medios exige que sus muestras Java y texturas sean idénticas.
def digest(value):
    return hashlib.sha256(value).hexdigest()

def sample_digest(samples):
    return digest(json.dumps(samples, sort_keys=True, separators=(",", ":")).encode("utf-8"))

def source_snapshot():
    return {"rig_sha256": digest(RIG.read_bytes()), "model_sha256": digest(MODEL.read_bytes()),
            "renderer_sha256": digest(Path(__file__).read_bytes()),
            "textures": {str(path): digest(path.read_bytes()) for path in
                         (ASSETS / "parasite.png", ASSETS / "parasite_friendly.png", ASSETS / "parasite_initial.png") if path.exists()}}

def duration(mode):
    return 17 if mode == "lore" else 7 if mode == "idle" else 2 if mode == "friendly" else 4 if mode == "fold" else 3

@lru_cache(maxsize=3)
def texture_pixels(name):
    return np.asarray(Image.open(ASSETS / name).convert("RGBA"))

@lru_cache(maxsize=6)
def pixel_coordinates(width, height):
    # Compartir la grilla evita cientos de asignaciones por cada fotograma.
    return np.indices((height, width), dtype=np.float32) + np.float32(.5)

# Java exports exact samples; no animation is recreated in Python.
def compile_rig():
    classes = ROOT / "build/parasite-preview-classes"
    classes.mkdir(parents=True, exist_ok=True)
    subprocess.run([str(JAVA_HOME / "bin/javac.exe"), "-encoding", "UTF-8", "-d", str(classes), str(RIG)], check=True, capture_output=True, timeout=45)
    return classes

def rig_sample(classes, mode, stage, seconds, appearance=0):
    result = subprocess.run([str(JAVA_HOME / "bin/java.exe"), "-cp", str(classes), CLASS_NAME, RIG_MODES.get(mode, mode), str(stage), f"{seconds:.5f}", "1", "20", str(appearance)], check=True, capture_output=True, text=True, encoding="utf-8", timeout=25)
    sample = json.loads(result.stdout)
    sample["_stage"] = stage
    return sample

def rig_sequence(classes, mode, stage, duration):
    count = round(duration * FPS)
    result = subprocess.run([str(JAVA_HOME / "bin/java.exe"), "-cp", str(classes), CLASS_NAME, RIG_MODES.get(mode, mode), str(stage), "0", str(count), str(FPS)], check=True, capture_output=True, text=True, encoding="utf-8", timeout=120)
    samples = json.loads(result.stdout)
    if isinstance(samples, list) and len(samples) == count:
        for sample in samples: sample["_stage"] = stage
        return samples
    # A rig that only supports single samples still yields the same animation.
    return [rig_sample(classes, mode, stage, i / FPS) for i in range(count)]

# ModelPart transforms: translation, rotation Z/Y/X, then scale.
def matrix(position, rotation, scale):
    result = np.eye(4, dtype=np.float64)
    result[:3, 3] = position
    for axis in (2, 1, 0):
        c, s = math.cos(rotation[axis]), math.sin(rotation[axis])
        rot = np.eye(4)
        if axis == 0: rot[1, 1], rot[1, 2], rot[2, 1], rot[2, 2] = c, -s, s, c
        elif axis == 1: rot[0, 0], rot[0, 2], rot[2, 0], rot[2, 2] = c, s, -s, c
        else: rot[0, 0], rot[0, 1], rot[1, 0], rot[1, 1] = c, -s, s, c
        result = result @ rot
    return result @ np.diag([*scale, 1.0])

def mesh(sample, selected=None):
    bones = {bone["name"]: bone for bone in sample["bones"]}
    transforms, visibility = {}, {}
    def resolve(name):
        if name in transforms: return transforms[name]
        bone = bones[name]
        pose = sample["pose"].get(name, {})
        own = matrix(pose.get("position", bone["pivot"]), pose.get("rotation", [0, 0, 0]), pose.get("scale", [1, 1, 1]))
        parent = bone.get("parent")
        parent_exists = bool(parent and parent in bones)
        transforms[name] = resolve(parent) @ own if parent_exists else own
        visibility[name] = pose.get("visible", True) and (visibility[parent] if parent_exists else True)
        return transforms[name]
    boxes = []
    for name, bone in bones.items():
        transform = resolve(name)
        if visibility[name] and (selected is None or name in selected):
            for box in bone.get("boxes", []):
                boxes.append((box["bounds"], box["uv"], transform, box.get("mirror", False)))
    return boxes

def cube_faces(box):
    (x, y, z, width, height, depth), (u, v), transform, mirrored = box
    x2, y2, z2 = x + width, y + height, z + depth
    if mirrored: x, x2 = x2, x
    vertices = np.array([(x, y, z, 1), (x2, y, z, 1), (x2, y2, z, 1), (x, y2, z, 1),
                         (x, y, z2, 1), (x2, y, z2, 1), (x2, y2, z2, 1), (x, y2, z2, 1)], dtype=float)
    vertices = (transform @ vertices.T).T[:, :3]
    # Exactly the ModelPart.Cube face rectangles and Polygon UV ordering.
    faces = [([5, 4, 0, 1], (u + depth, v, u + depth + width, v + depth)),
             ([2, 3, 7, 6], (u + depth + width, v + depth, u + depth + width * 2, v)),
             ([0, 4, 7, 3], (u, v + depth, u + depth, v + depth + height)),
             ([1, 0, 3, 2], (u + depth, v + depth, u + depth + width, v + depth + height)),
             ([5, 1, 2, 6], (u + depth + width, v + depth, u + depth + width + depth, v + depth + height)),
             ([4, 5, 6, 7], (u + depth + width + depth, v + depth, u + depth + width + depth + width, v + depth + height))]
    for indices, (u1, v1, u2, v2) in faces:
        world = vertices[indices]
        uv = np.array([(u2, v1), (u1, v1), (u1, v2), (u2, v2)], dtype=float)
        if mirrored: world, uv = world[::-1], uv[::-1]
        yield world, uv

def camera_matrix(yaw=23, pitch=8):
    yaw, pitch = math.radians(yaw), math.radians(pitch)
    return np.array([[math.cos(yaw), 0, math.sin(yaw)],
                     [math.sin(pitch) * math.sin(yaw), math.cos(pitch), -math.sin(pitch) * math.cos(yaw)],
                     [math.cos(pitch) * math.sin(yaw), -math.sin(pitch), -math.cos(pitch) * math.cos(yaw)]])

def framing(samples, width, height, yaw=23, pitch=8, selected=None, reference=False):
    cam = camera_matrix(yaw, pitch)
    all_points = []
    for sample in samples:
        for box in mesh(sample, selected):
            for points, _ in cube_faces(box): all_points.extend((cam @ points.T).T[:, :2])
    if reference:
        all_points.extend((cam @ point)[:2] for point in (np.array([-25,24,-2]), np.array([-17,-4.8,2])))
    bounds = np.array(all_points)
    minimum, maximum = bounds.min(axis=0), bounds.max(axis=0)
    scale = float(np.min(np.array([width * .84, height * .76]) / np.maximum(maximum - minimum, .01)))
    offset = np.array([width / 2, height * .51]) - (minimum + maximum) / 2 * scale
    return cam, scale, offset

# Depth-buffered, nearest-neighbor texture rasterizer.
def render(sample, size=(720, 660), frame=None, friendly=False):
    width, height = size
    cam, scale, offset = frame or framing([sample], width, height)
    texture_name = "parasite_friendly.png" if friendly else "parasite_initial.png" if sample.get("_stage") == 0 else "parasite.png"
    texture = texture_pixels(texture_name)
    pixels = np.zeros((height, width, 4), dtype=np.uint8)
    zbuffer = np.full((height, width), -np.inf, dtype=np.float32)
    coordinates = pixel_coordinates(width, height)
    for box in mesh(sample):
        for vertices, uv in cube_faces(box):
            face_normal = np.cross(vertices[1] - vertices[0], vertices[2] - vertices[0])
            length = np.linalg.norm(face_normal)
            if length < .000001: continue
            face_normal /= length
            if (cam @ face_normal)[2] <= .00001: continue
            illumination = max(0, np.dot(face_normal, np.array([-.35, -.68, -.64])))
            shade = .66 + .34 * illumination
            projected = (cam @ vertices.T).T
            projected[:, :2] = projected[:, :2] * scale + offset
            projected = projected.astype(np.float32)
            uv = uv.astype(np.float32)
            for indices in ((0, 1, 2), (0, 2, 3)):
                a, b, c = projected[list(indices)]
                ta, tb, tc = uv[list(indices)]
                denominator = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1])
                if abs(denominator) < 1e-8: continue
                left = max(0, int(np.floor(min(a[0], b[0], c[0]))))
                right = min(width - 1, int(np.ceil(max(a[0], b[0], c[0]))))
                top = max(0, int(np.floor(min(a[1], b[1], c[1]))))
                bottom = min(height - 1, int(np.ceil(max(a[1], b[1], c[1]))))
                if right < left or bottom < top: continue
                sy = coordinates[0, top:bottom + 1, left:right + 1]
                sx = coordinates[1, top:bottom + 1, left:right + 1]
                wa = ((b[1] - c[1]) * (sx - c[0]) + (c[0] - b[0]) * (sy - c[1])) / denominator
                wb = ((c[1] - a[1]) * (sx - c[0]) + (a[0] - c[0]) * (sy - c[1])) / denominator
                wc = 1 - wa - wb
                depth = wa * a[2] + wb * b[2] + wc * c[2]
                zpart = zbuffer[top:bottom + 1, left:right + 1]
                mask = (np.minimum(np.minimum(wa, wb), wc) >= -1e-7) & (depth > zpart)
                tx = np.clip((wa * ta[0] + wb * tb[0] + wc * tc[0]).astype(np.int32), 0, texture.shape[1] - 1)
                ty = np.clip((wa * ta[1] + wb * tb[1] + wc * tc[1]).astype(np.int32), 0, texture.shape[0] - 1)
                colors = texture[ty, tx].copy()
                mask &= colors[:, :, 3] > 0
                colors[:, :, :3] = (colors[:, :, :3].astype(np.float32) * np.float32(shade)).astype(np.uint8)
                pixels[top:bottom + 1, left:right + 1][mask] = colors[mask]
                zpart[mask] = depth[mask]
    return Image.fromarray(pixels, "RGBA")

# Background is presentation only; model pixels use the game's atlas and poses.
@lru_cache(maxsize=32)
def font(size, bold=False):
    return ImageFont.truetype(str(FONT_HOME / ("segoeuib.ttf" if bold else "segoeui.ttf")), size)

@lru_cache(maxsize=8)
def cavern(size, friendly=False):
    width, height = size
    yy, xx = np.mgrid[:height, :width]
    distance = ((xx - width * .53) / width) ** 2 + ((yy - height * .42) / height) ** 2
    radial = np.maximum(0, 1 - distance * 2.8)
    colors = np.stack((20 + radial * 17, 28 + radial * 30, 31 + radial * 32), axis=-1) if friendly else np.stack((10 + radial * 18, 12 + radial * 21, 17 + radial * 29), axis=-1)
    image = Image.fromarray(colors.astype(np.uint8), "RGB").convert("RGBA")
    draw = ImageDraw.Draw(image)
    floor = int(height * .77)
    draw.polygon([(0, height), (0, floor), (width * .45, height * .58), (width, floor), (width, height)], fill=(25, 37, 40, 255) if friendly else (17, 20, 25, 255))
    for side in (0, 1):
        for row in range(5):
            y1, y2 = row * floor / 5, (row + 1) * floor / 5
            spread = (floor - y1) * .25
            polygon = [(0, y1), (width * .12 + spread, y1 + floor * .08), (width * .12 + spread * .88, y2 + floor * .08), (0, y2)]
            if side: polygon = [(width - x, y) for x, y in polygon]
            shade = 15 + row % 2 * 5
            draw.polygon(polygon, fill=(shade, shade + 2, shade + 7), outline=(27, 31, 39))
    shadow = Image.new("RGBA", size)
    ImageDraw.Draw(shadow).ellipse((width * .22, height * .77, width * .79, height * .94), fill=(0, 0, 0, 160))
    image.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(width * .025)))
    return image

def scene(sample, size, frame=None, friendly=False, label=None):
    image = cavern(size, friendly).copy()
    image.alpha_composite(render(sample, size, frame, friendly))
    if label:
        ImageDraw.Draw(image).text((size[0] * .05, size[1] * .055), label, fill=(157, 176, 174) if friendly else (182, 150, 138), font=font(max(14, int(size[0] * .03)), True))
    return image.convert("RGB")

# La referencia humana comparte cámara y unidades con el cuerpo exportado.
def scale_scene(sample, size=(1440, 1000)):
    cam, scale, offset = framing([sample], *size, reference=True)
    frame = (cam, scale, offset)
    image = scene(sample, size, frame, label="HUÉSPED")
    draw = ImageDraw.Draw(image)
    blocks = [(-24.6, 13.2, -1.8, 3.6, 10.8, 3.6), (-21, 13.2, -1.8, 3.6, 10.8, 3.6),
              (-24.6, 2.4, -1.8, 7.2, 10.8, 3.6), (-24.6, -4.8, -3.6, 7.2, 7.2, 7.2),
              (-27, 2.4, -1.8, 2.4, 10.8, 3.6), (-17.4, 2.4, -1.8, 2.4, 10.8, 3.6)]
    for x,y,z,w,h,d in blocks:
        box = ([x,y,z,w,h,d], [0,0], np.eye(4), False)
        for points, _ in cube_faces(box):
            projected = (cam @ points.T).T[:, :2] * scale + offset
            draw.polygon([tuple(point) for point in projected], fill=(58,67,66), outline=(100,111,103))
    feet = (cam @ np.array([-21,24,0]))[:2] * scale + offset
    draw.text((feet[0]-58,feet[1]+24), "JUGADOR · 1,8 BLOQUES", font=font(15), fill=(148,158,149))
    vertices = np.concatenate([points for box in mesh(sample) for points,_ in cube_faces(box)])
    height = (vertices[:,1].max()-vertices[:,1].min()) / 16
    draw.text((size[0]-300,size[1]-65), f"HUÉSPED · {height:.1f} BLOQUES".replace('.',','),font=font(18,True),fill=(191,171,163))
    return image

def write_video(frames, destination):
    frames = iter(frames)
    first = next(frames)
    width, height = first.size
    # Cada render escribe su propio temporal: un fallo conserva el último video completo.
    with tempfile.TemporaryDirectory(prefix="render-video-", dir=destination.parent) as directory:
        temporary = Path(directory) / "clip.mp4"
        process = subprocess.Popen([str(FFMPEG), "-y", "-loglevel", "error", "-f", "rawvideo", "-pixel_format", "rgb24", "-video_size", f"{width}x{height}", "-framerate", str(FPS), "-i", "pipe:0", "-an", "-c:v", "libx264", "-threads", "1", "-preset", "veryfast", "-crf", "19", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(temporary)], stdin=subprocess.PIPE)
        try:
            process.stdin.write(first.tobytes())
            for frame in frames: process.stdin.write(frame.tobytes())
            process.stdin.close()
            if process.wait(timeout=60) != 0: raise RuntimeError("FFmpeg did not finish the preview video")
        finally:
            if process.poll() is None:
                process.kill()
                process.wait(timeout=10)
            if not process.stdin.closed:
                try: process.stdin.close()
                except BrokenPipeError: pass
        # La duración sola no detecta un MP4 corrupto; comprobar todos los cuadros.
        subprocess.run([str(FFMPEG), "-xerror", "-v", "error", "-threads", "1", "-i", str(temporary),
                        "-f", "null", "-"], check=True, capture_output=True, timeout=45)
        temporary.replace(destination)

def join_videos(paths, destination):
    with tempfile.TemporaryDirectory(prefix="parasite-preview-", dir=ROOT / "build") as temporary:
        listing = Path(temporary) / "clips.txt"
        listing.write_text("\n".join("file '" + str(path).replace("\\", "/") + "'" for path in paths), encoding="utf-8")
        subprocess.run([str(FFMPEG), "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", str(listing), "-c", "copy", "-movflags", "+faststart", str(destination)], check=True)

# OBJ files are physical 3D meshes, with the shipped UV atlas, not concept art.
def export_obj(sample, destination, friendly=False):
    destination.parent.mkdir(exist_ok=True)
    material = "parasite_friendly" if friendly else "parasite_initial" if sample.get("_stage") == 0 else "parasite"
    lines = ["# Raps: geometry and pose exported directly from ParasiteRig", "mtllib " + material + ".mtl", "o Raps_Parasite", "usemtl " + material]
    vertex_index = 1
    for box in mesh(sample):
        for points, uv in cube_faces(box):
            points = np.array([(p[0] / 16, (24 - p[1]) / 16, -p[2] / 16) for p in points])
            normal = np.cross(points[1] - points[0], points[2] - points[0])
            length = np.linalg.norm(normal)
            normal = normal / length if length else np.array([0, 1, 0])
            lines.extend("v {:.7f} {:.7f} {:.7f}".format(*point) for point in points)
            lines.extend("vt {:.7f} {:.7f}".format(point[0] / 128, 1 - point[1] / 128) for point in uv)
            lines.extend("vn {:.7f} {:.7f} {:.7f}".format(*normal) for _ in range(4))
            lines.append("f " + " ".join(f"{i}/{i}/{i}" for i in range(vertex_index, vertex_index + 4)))
            vertex_index += 4
    destination.write_text("\n".join(lines) + "\n", encoding="utf-8")
    (destination.parent / (material + ".mtl")).write_text(f"newmtl {material}\nKa 0.15 0.15 0.15\nKd 1 1 1\nKs 0 0 0\nd 1\nillum 1\nmap_Kd {material}.png\n", encoding="utf-8")
    shutil.copy2(ASSETS / (material + ".png"), destination.parent / (material + ".png"))

def comparison(samples):
    sheet = Image.new("RGB", (1680, 1040), (10, 13, 18))
    draw = ImageDraw.Draw(sheet)
    draw.text((42, 26), "EVOLUCIÓN DEL HUÉSPED", font=font(32, True), fill=(191, 171, 163))
    shared = framing(samples[:5], 520, 440)
    for index, sample in enumerate(samples):
        position = (30 + (index % 3) * 550, 85 + (index // 3) * 475)
        image = scene(sample, (520, 440), shared if index < 5 else None, index == 5, f"FASE {index}" if index < 5 else "MODO SENSIBLE")
        sheet.paste(image, position)
    sheet.save(OUT / "parasito-evolucion.png", optimize=True)

def refresh_phase_zero(classes):
    stages = [rig_sample(classes, "idle", stage, .6) for stage in range(5)]
    friendly = rig_sample(classes, "friendly", 4, 1.2)
    image = scene(stages[0], (720, 660), label="FASE 0")
    image.save(OUT / "parasito-fase-0.png", optimize=True)
    comparison(stages + [friendly])
    export_obj(stages[0], OUT / "modelos-3d/parasito-huesped-inicial.obj")
    for mode in ("chase", "climb"):
        sample = rig_sample(classes, mode, 4, 1.2)
        scene(sample, (720, 660), label=LABELS[mode]).save(OUT / f"parasito-{mode}-pose.png", optimize=True)
    html_path = OUT / "parasito-modelos.html"
    html = html_path.read_text(encoding="utf-8")
    prefix, separator, rest = html.partition("const images=")
    image_json, boundary, suffix = rest.partition(",modes=")
    if not separator or not boundary: raise ValueError("The preview page does not contain its image catalog")
    images = json.loads(image_json)
    images["0"] = base64.b64encode((OUT / "parasito-fase-0.png").read_bytes()).decode()
    html_path.write_text(prefix + separator + json.dumps(images) + boundary + suffix, encoding="utf-8")
    path = OUT / "parasito-modelos-origen.json"
    provenance = json.loads(path.read_text(encoding="utf-8"))
    provenance["rig_sha256"] = hashlib.sha256(RIG.read_bytes()).hexdigest()
    provenance["model_sha256"] = hashlib.sha256(MODEL.read_bytes()).hexdigest()
    initial = ASSETS / "parasite_initial.png"
    provenance["textures"][str(initial)] = hashlib.sha256(initial.read_bytes()).hexdigest()
    initial_mesh = "modelos-3d/parasito-huesped-inicial.obj"
    if initial_mesh not in provenance["outputs"]: provenance["outputs"].append(initial_mesh)
    path.write_text(json.dumps(provenance, ensure_ascii=False, indent=2), encoding="utf-8")
    print("Phase zero, comparison, HTML and source hashes refreshed.")

def contact_sheet(classes, mode, size):
    """Seis poses exportadas directamente por Java, sin esperar el video."""
    seconds = 7 if mode == "lore" else duration(mode)
    times = [i * (seconds - 1 / FPS) / 5 for i in range(6)]
    samples = [rig_sample(classes, mode, 4, second) for second in times]
    framing_data = framing(samples, *size)
    sheet = Image.new("RGB", (size[0] * 3, size[1] * 2))
    for index, sample in enumerate(samples):
        frame = scene(sample, size, framing_data, mode == "friendly", f"{LABELS[mode]} · {times[index]:.2f}s")
        sheet.paste(frame, ((index % 3) * size[0], (index // 3) * size[1]))
    path = OUT / f"parasito-contact-{mode}.png"
    sheet.save(path, optimize=True)
    print("Contactos exportados:", path, flush=True)

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--still-only", "--stills-only", action="store_true")
    parser.add_argument("--refresh-phase-zero", action="store_true")
    parser.add_argument("--modes", help="Modos separados por comas; por defecto, todos")
    parser.add_argument("--contact-sheet", action="store_true", help="Exportar seis poses por modo, sin videos")
    parser.add_argument("--no-stills", action="store_true", help="Conservar imágenes y OBJ existentes")
    parser.add_argument("--force-redraw", action="store_true", help="Ignorar la caché de muestras idénticas")
    parser.add_argument("--size", default="480x440", help="Resolución de video; conserva 20 FPS")
    args = parser.parse_args()
    modes = tuple(args.modes.split(",")) if args.modes else SEQUENCES
    if not modes or any(mode not in SEQUENCES for mode in modes):
        parser.error("Modos válidos: " + ",".join(SEQUENCES))
    try:
        size = tuple(int(part) for part in args.size.lower().split("x"))
        if len(size) != 2 or any(value < 160 or value > 1920 or value % 2 for value in size): raise ValueError()
    except ValueError:
        parser.error("--size debe usar ancho x alto pares entre 160 y 1920")
    snapshot = source_snapshot()
    provenance_path = OUT / "parasito-modelos-origen.json"
    old_provenance = json.loads(provenance_path.read_text(encoding="utf-8")) if provenance_path.exists() else {}
    video_records = old_provenance.get("videos", {})
    classes = compile_rig()
    if args.contact_sheet:
        for mode in modes: contact_sheet(classes, mode, size)
        return
    if args.refresh_phase_zero:
        refresh_phase_zero(classes)
        return
    OUT.mkdir(exist_ok=True)
    if not args.no_stills:
        samples = {mode: rig_sample(classes, mode, 4, 1.2) for mode in SEQUENCES}
        hero = scale_scene(rig_sample(classes, "idle", 4, .6))
        hero.save(OUT / "parasito-modelos.png", optimize=True)
        for name, style in (("obsidian", 1), ("iron", 2), ("diamond", 3), ("copper", 4)):
            sample = rig_sample(classes, "idle", 4, .6, style | style << 3 | style << 6)
            scene(sample, (720, 660), label=name.upper()).save(OUT / f"parasito-prefab-{name}.png", optimize=True)
            export_obj(sample, OUT / "modelos-3d" / f"parasito-prefab-{name}.obj")
        face_names = {"head", "mouth", "socket_l", "socket_r", "eye_l", "eye_r", "jaw", "jaw_cavity", "upper_teeth", "lower_teeth"}
        face_frame = framing([samples["idle"]], 720, 720, yaw=8, pitch=0, selected=face_names)
        scene(samples["idle"], (720, 720), face_frame).save(OUT / "parasito-rostro.png", optimize=True)
        encoded = {}
        stages = []
        for stage in range(5):
            sample = rig_sample(classes, "idle", stage, .6)
            stages.append(sample)
            image = scene(sample, (720, 660), label=f"FASE {stage}")
            image.save(OUT / f"parasito-fase-{stage}.png", optimize=True)
            stream = io.BytesIO(); image.save(stream, format="PNG")
            encoded[str(stage)] = base64.b64encode(stream.getvalue()).decode()
        friendly_image = scene(samples["friendly"], (720, 660), friendly=True, label="MODO SENSIBLE")
        friendly_image.save(OUT / "parasito-sensible.png", optimize=True)
        comparison(stages + [samples["friendly"]])
        stream = io.BytesIO(); friendly_image.save(stream, format="PNG")
        encoded["friendly"] = base64.b64encode(stream.getvalue()).decode()
        obj_samples = {"parasito-humanoide": samples["idle"], "parasito-encuentro": rig_sample(classes, "lore", 4, 3.25), "parasito-aracnido": rig_sample(classes, "crawl", 4, 1.2), "parasito-sensible": samples["friendly"], "parasito-huesped-inicial": stages[0]}
        for name, sample in obj_samples.items():
            export_obj(sample, OUT / "modelos-3d" / (name + ".obj"), name.endswith("sensible"))
            (OUT / "modelos-3d" / (name + ".json")).write_text(json.dumps(sample, ensure_ascii=False), encoding="utf-8")
    if not args.still_only:
        clips = []
        for mode in modes:
            # La narración dura 17 segundos; el bucle muestra el giro y su regreso completos.
            seconds = duration(mode)
            sequence = rig_sequence(classes, mode, 4, seconds)
            fingerprint = {"samples_sha256": sample_digest(sequence), "fps": FPS, "size": list(size),
                           "renderer_sha256": snapshot["renderer_sha256"], "textures": snapshot["textures"]}
            previous = video_records.get(mode, {})
            clip = OUT / f"parasito-{mode}.mp4"
            reusable = (not args.force_redraw and previous.get("fingerprint") == fingerprint
                        and previous.get("outputs") and all((OUT / name).is_file() and
                        digest((OUT / name).read_bytes()) == value for name, value in previous["outputs"].items()))
            if reusable:
                print("Video sin cambios:", mode, flush=True)
                continue
            framing_data = framing(sequence, *size)
            small = []
            started = time.monotonic()
            def frames():
                for index, sample in enumerate(sequence):
                    frame = scene(sample, size, framing_data, mode == "friendly", LABELS[mode])
                    if mode in ("chase", "lore") and index < 9 * FPS:
                        small.append(frame.resize((436, 400), Image.Resampling.LANCZOS))
                    yield frame
                    if (index + 1) % FPS == 0:
                        print(f"{mode}: {index + 1}/{len(sequence)} fotogramas · {time.monotonic() - started:.1f}s", flush=True)
            write_video(frames(), clip)
            print("Video exportado:", mode, flush=True)
            if mode == "lore":
                long_clip = OUT / "parasito-encuentro-completo.mp4"
                shutil.copy2(clip, long_clip)
                temporary_clip = OUT / "parasito-lore-corto.mp4"
                subprocess.run([str(FFMPEG), "-y", "-loglevel", "error", "-i", str(long_clip), "-frames:v", str(9 * FPS), "-an", "-c:v", "libx264", "-threads", "1", "-preset", "veryfast", "-crf", "19", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(temporary_clip)], check=True)
                temporary_clip.replace(clip)
                own_voice = OUT / "parasito-voz.mp3"
                if own_voice.exists():
                    subprocess.run([str(FFMPEG), "-y", "-loglevel", "error", "-i", str(long_clip), "-i", str(own_voice), "-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", str(OUT / "parasito-encuentro-voz.mp4")], check=True)
            if mode != "friendly": clips.append(clip)
            if mode in ("chase", "lore"):
                # GIF usa unidades de 10 ms; distribuir el redondeo conserva el FPS del video.
                durations = [(round((i + 1) * 100 / FPS) - round(i * 100 / FPS)) * 10 for i in range(len(small))]
                small[0].save(OUT / f"parasito-{mode}.gif", save_all=True, append_images=small[1:], duration=durations, loop=0, optimize=True)
            outputs = [clip]
            if mode in ("chase", "lore"): outputs.append(OUT / f"parasito-{mode}.gif")
            if mode == "lore": outputs += [OUT / "parasito-encuentro-completo.mp4", OUT / "parasito-encuentro-voz.mp4"]
            video_records[mode] = {"fingerprint": fingerprint, "sources": snapshot,
                                  "outputs": {str(path.relative_to(OUT)): digest(path.read_bytes()) for path in outputs if path.exists()}}
            # Persistir después de cada clip: una interrupción no obliga a repetir los anteriores.
            old_provenance["videos"] = video_records
            provenance_path.write_text(json.dumps(old_provenance, ensure_ascii=False, indent=2), encoding="utf-8")
        clips = [OUT / f"parasito-{mode}.mp4" for mode in SEQUENCES if mode != "friendly"]
        if all(path.is_file() for path in clips): join_videos(clips, OUT / "parasito-animaciones.mp4")
    html = '''<!doctype html><html lang="es"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Raps · Huésped</title>
<style>body{margin:0;background:#090c11;color:#e7dfdb;font:16px system-ui;min-height:100vh}main{max-width:920px;margin:auto;padding:24px}h1{font-size:32px;font-weight:600;letter-spacing:.08em}nav{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0}button{color:inherit;background:#20242c;border:1px solid #474349;padding:10px 16px;border-radius:6px;cursor:pointer}button[aria-pressed=true]{background:#663b35;border-color:#b27867}img,video{width:100%;max-height:75vh;object-fit:contain;background:#0d1118;border-radius:12px}label{display:block;margin:18px 0}</style>
<main><h1>HUÉSPED</h1><nav id="animation"></nav><video id="motion" controls autoplay muted loop playsinline></video><label id="voice">Voz del huésped<br><audio controls preload="none" src="parasito-voz.mp3"></audio></label><nav id="stages"></nav><label><input id="safe" type="checkbox"> Modo sensible</label><img id="figure" alt="Evolución del huésped"></main>
<script>const images=IMAGES,modes={idle:'Acecho',lore:'Encuentro',chase:'Persecución',climb:'Trepar',friendly:'Modo sensible'};let stage=4,mode='idle',safe=document.querySelector('#safe'),video=document.querySelector('#motion');
Object.entries(modes).forEach(([key,label])=>{let b=document.createElement('button');b.textContent=label;b.onclick=()=>{mode=key;update()};document.querySelector('#animation').append(b)});
for(let i=0;i<5;i++){let b=document.createElement('button');b.textContent='Fase '+i;b.onclick=()=>{stage=i;updateImage()};document.querySelector('#stages').append(b)}
function updateImage(){document.querySelector('#figure').src='data:image/png;base64,'+images[safe.checked||mode==='friendly'?'friendly':stage];[...document.querySelector('#stages').children].forEach((b,i)=>b.setAttribute('aria-pressed',i===stage))}
function update(){let current=safe.checked?'friendly':mode;video.src='parasito-'+current+'.mp4';let audio=document.querySelector('audio');document.querySelector('#voice').hidden=current==='friendly';if(current==='friendly'){audio.pause();audio.currentTime=0}[...document.querySelector('#animation').children].forEach((b,i)=>b.setAttribute('aria-pressed',Object.keys(modes)[i]===current));updateImage()}safe.onchange=update;update();</script></html>'''
    if not args.no_stills:
        (OUT / "parasito-modelos.html").write_text(html.replace("IMAGES", json.dumps(encoded)), encoding="utf-8")
    provenance = {"rig": str(RIG), "rig_sha256": hashlib.sha256(RIG.read_bytes()).hexdigest(), "model": str(MODEL), "model_sha256": hashlib.sha256(MODEL.read_bytes()).hexdigest(), "geometry_and_animation": "Java ParasiteRig CLI, shared with Minecraft model", "projection": "orthographic", "fps": FPS, "hero_pose": {"mode": "idle", "stage": 4, "seconds": .6}, "textures": {str(path): hashlib.sha256(path.read_bytes()).hexdigest() for path in (ASSETS / "parasite.png", ASSETS / "parasite_friendly.png", ASSETS / "parasite_initial.png") if path.exists()}, "outputs": ["parasito-modelos.png", "parasito-rostro.png", "parasito-evolucion.png", "parasito-modelos.html", "parasito-animaciones.mp4", "parasito-encuentro-voz.mp4", "modelos-3d/parasito-humanoide.obj", "modelos-3d/parasito-aracnido.obj", "modelos-3d/parasito-encuentro.obj", "modelos-3d/parasito-sensible.obj"]}
    provenance.update(snapshot)
    provenance["videos"] = video_records
    provenance["stills_sources"] = old_provenance.get("stills_sources", {}) if args.no_stills else snapshot
    provenance["selected_modes"] = list(modes)
    provenance["media_hash_scope"] = "Each video records its own Java samples, source hashes and output hashes; unrendered videos retain their previous records."
    provenance_path.write_text(json.dumps(provenance, ensure_ascii=False, indent=2), encoding="utf-8")
    print(OUT / "parasito-modelos.png")
    print(OUT / "parasito-animaciones.mp4")

if __name__ == "__main__": main()