"""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 = '''