diff options
55 files changed, 7734 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7da0c69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Generated output: renders, audio, final videos and scratch space +/render/ +/out/ +/media/ +/tmp/ +/audio/ +/godot/trabajo/ +/godot/.godot/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..bcced52 --- /dev/null +++ b/README.md @@ -0,0 +1,252 @@ +# 24 videos verticales para TikTok — 100% generados localmente + +Todo corre offline: **Manim** para la animación 2D, **Blender** (EEVEE, headless) para +las escenas 3D, **Godot** (motor de física Jolt, headless) para la física de +rígidos, **qwentts.cpp** (voz clonada rioplatense) para la locución y las +muestras de piano de `keysound` para la música. + +## Los videos (`out/`) + +| Archivo | Tema | Duración | Gancho | +|---|---|---|---| +| `montyhall.mp4` | Problema de Monty Hall | 39,5 s | genera debate en comentarios | +| `nueves.mp4` | 0,999… = 1 | 38,8 s | genera debate en comentarios | +| `cumple.mp4` | Paradoja del cumpleaños | 38,6 s | dato que sorprende | +| `luna.mp4` | Doblar un papel hasta la Luna | 38,4 s | dato que sorprende | +| `japones.mp4` | Multiplicar con líneas | 38,8 s | útil, se guarda y se comparte | +| `benford.mp4` | Ley de Benford (detectar fraude) | 42,8 s | dato que sorprende + útil | +| `hilbert.mp4` | El hotel infinito | 37,1 s | genera debate en comentarios | +| `reuleaux.mp4` | Por qué las tapas son redondas | 36,0 s | satisfactorio de mirar | +| `regla72.mp4` | La regla del 72 | 35,5 s | útil, se guarda | +| `buffon.mp4` | Sacar π tirando agujas | 36,7 s | belleza matemática | +| `collatz.mp4` | Conjetura de Collatz (3n+1) | 46,2 s | misterio sin resolver | +| `simpson.mp4` | Paradoja de Simpson | 53,0 s | genera debate + útil | +| `cuerda.mp4` | Una cuerda alrededor de la Tierra | 45,4 s | dato que sorprende | +| `gabriel.mp4` | El cuerno de Gabriel | 40,5 s | belleza matemática | +| `mobius.mp4` | Cinta de Möbius | 46,3 s | para hacer en casa | +| `bayes.mp4` | Falso positivo (test médico) | 46,9 s | genera debate + útil | +| `cicloide.mp4` | Braquistócrona: la curva más rápida | 47,6 s | satisfactorio de mirar | +| `dados.mp4` | Dados no transitivos | 50,6 s | genera debate en comentarios | +| `bolapeluda.mp4` | Teorema de la bola peluda (3D) | 41,9 s | satisfactorio de mirar | +| `caos.mp4` | Tres péndulos dobles y 1 mm (3D) | 40,6 s | hipnótico, se mira entero | +| `diferencial.mp4` | Cómo funciona el diferencial (3D) | 44,1 s | útil, se guarda y se comparte | +| `galton.mp4` | Tablero de Galton: 500 bolitas forman la campana (Blender + Godot) | 45,7 s | satisfactorio + pregunta en comentarios | +| `bloques.mp4` | Pila armónica: bloques que sobresalen de la mesa (Blender + Godot) | 48,0 s | para hacer en casa + dato que sorprende | +| `domino.mp4` | Dominó ×1,5: de 5 mm al Aconcagua (Blender + Godot) | 66,7 s | exponencial + pregunta en comentarios (más de 1 min) | + +Formato: 1080×1920, 30 fps, H.264 + AAC 48 kHz estéreo, **−13,8 a −15,3 LUFS** +medidos (el objetivo de TikTok es −14, así no te lo bajan ni te lo suben), +pico real por debajo de −1 dBTP. +Subtítulos quemados: el 80 % mira sin audio. + +## Textos sugeridos para publicar + +**montyhall** — ¿Te quedás o cambiás? El 87% se equivoca 🚪 +`#matematica #montyhall #datocurioso #probabilidad #argentina` + +**nueves** — 0,999… es EXACTAMENTE 1. Peleame en los comentarios 🧮 +`#matematica #mindblown #datocurioso #numeros #viral` + +**cumple** — Con 23 personas ya hay 50% de que dos cumplan el mismo día 🎂 +`#paradoja #datocurioso #matematica #probabilidad #sabiasque` + +**luna** — Doblá un papel 42 veces y llegás a la Luna 🌙 +`#exponencial #datocurioso #ciencia #mindblown #sabiasque` + +**japones** — Así multiplican en Japón, sin tablas ✏️ +`#trucosmatematicos #japon #escuela #tarea #aprendeentiktok` + +**benford** — Los números inventados se notan. Así agarran fraudes 🕵️ +`#benford #fraude #estadistica #datocurioso #sabiasque` + +**hilbert** — Un hotel lleno con infinitas habitaciones… y entran infinitos más 🏨 +`#infinito #matematica #paradoja #mindblown #hilbert` + +**reuleaux** — Por qué las tapas de alcantarilla son redondas 🛞 +`#datocurioso #geometria #ingenieria #sabiasque #reuleaux` + +**regla72** — La cuenta que te dice cuándo se duplica tu plata 💸 +`#finanzaspersonales #inversiones #interescompuesto #inflacion #argentina` + +**buffon** — Tiro agujas al piso y sale π. Sin ningún círculo 🎲 +`#pi #matematica #azar #datocurioso #mindblown` + +**collatz** — Elegí cualquier número. Nadie pudo demostrar por qué siempre cae al 1 🌀 +`#collatz #matematica #misterio #datocurioso #mindblown` + +**simpson** — Gana en los dos grupos y pierde en el total. Así te miente un promedio 📊 +`#estadistica #paradoja #datos #sabiasque #matematica` + +**cuerda** — Le agrego 1 metro a una cuerda que rodea la Tierra. ¿Cuánto se levanta? 🌍 +`#datocurioso #matematica #mindblown #sabiasque #ciencia` + +**gabriel** — Lo podés llenar de pintura, pero no lo podés pintar 🎺 +`#infinito #matematica #paradoja #calculo #mindblown` + +**mobius** — Cortá esta cinta al medio y NO van a salir dos ✂️📄 +`#mobius #topologia #experimento #hacelovos #datocurioso` + +**bayes** — El test acierta el 99% y te dio positivo. La chance de estar enfermo es 9% 🧪 +`#probabilidad #estadistica #salud #sabiasque #bayes` + +**cicloide** — La recta es el camino más corto… y pierde la carrera 🎢 +`#fisica #matematica #satisfying #mindblown #cicloide` + +**dados** — Elegí el dado que quieras. Elijo después y te gano igual 🎲 +`#probabilidad #dados #juegos #datocurioso #matematica` + +**bolapeluda** — No podés peinar una esfera sin dejar un remolino. Es un teorema 🌀 +`#topologia #matematica #3d #datocurioso #mindblown` + +**caos** — Tres péndulos idénticos salvo 1 milímetro. A los 5 segundos no se parecen en nada 🎲 +`#caos #fisica #simulacion #mindblown #efectomariposa` + +**diferencial** — Por qué tu auto puede doblar sin que una rueda patine ⚙️ +`#mecanica #autos #ingenieria #sabiasque #comofunciona` + +**galton** — 500 bolitas, cada una al azar… y todas juntas dibujan una campana 🔔 +`#probabilidad #estadistica #galton #satisfying #matematica` + +**bloques** — Apilé bloques hasta que el de arriba quedó en el aire. ¿Hasta dónde llegás vos? 📚 +`#fisica #experimento #matematica #paraHacerEnCasa #sabiasque` + +**domino** — Una ficha de 5 mm puede tirar un edificio. ¿En qué ficha se cae tu casa? 🏠 +`#domino #fisica #exponencial #satisfying #sabiasque` + +Subí uno por día, mismo horario, y alterná los tres tipos: + +- **debate** (comentarios, que es lo que más empuja el alcance): montyhall, nueves, + hilbert, simpson, cuerda, dados +- **útil** (se guarda y se comparte): japones, regla72, benford, bayes, diferencial, bloques +- **espectáculo** (se mira hasta el final): reuleaux, buffon, mobius, gabriel, collatz, + luna, cumple, cicloide, bolapeluda, caos, galton, domino + +Los tres últimos (`bolapeluda`, `caos`, `diferencial`) están hechos en Blender: sirven +para cortar la monotonía visual de Manim sin cambiar el estilo del canal, porque la +capa de interfaz (chip, barra y subtítulos) es la misma. + +`mobius` es el único con llamado a la acción físico ("hacelo con una hoja"): +esos suelen traer videos-respuesta, que valen doble. + +### Blender + Godot (`galton`, `bloques`) + +Blender diseña y Godot calcula la física: + +1. `MODO=sim blender -b -P blender/<nombre>.py` arma la escena y exporta a glTF solo + los objetos físicos. El prefijo del nombre dice qué es cada uno para Godot + (`fijo_` malla estática, `caja_` caja estática, `compuerta_` estática que se saca + con un evento, `bola_` esfera rígida, `bloque_` caja rígida). Después corre + `godot --headless --fixed-fps 240 -- godot/trabajo/<nombre>.json`. +2. `godot/sim.gd` carga el glb en tiempo de ejecución, crea los cuerpos, aplica los + eventos del guion (soltar, sacar la compuerta) en el segundo exacto del timeline + y graba la pose de cada cuerpo a 30 fps en coordenadas de Blender + (`godot/trabajo/<nombre>.bin`). +3. `blender -b -P blender/<nombre>.py` lee esas poses y renderiza con los materiales + de verdad (madera procedural, latón, acero). + +Nada del movimiento está animado a mano. Algunos números que salen de la simulación: + +- **galton**: casilleros `[0 1 7 29 70 104 96 83 65 35 10 0 0]` (desvío de 1,82 + casilleros; el binomial ideal de 12 filas da 1,73). La columna del medio tiene + **96**: es la respuesta a la pregunta del final. +- **bloques**: la pila de 5 con corrimientos armónicos ×0,95 se sostiene con el de + arriba entero en el aire (8,5 % de bloque). La torre de 16 no se mueve hasta que + se le apoya un bloque más en la punta izquierda de arriba: se caen 10 de 17. + +Dos ajustes que hicieron falta en Godot: + +- **galton**: una amortiguación lineal de 6/s, que hace de roce contra el vidrio. + Sin ella las bolitas toman demasiada velocidad lateral y el reparto sale plano. +- **bloques**: física a 1920 Hz y mundo ×5 (`"hz"` y `"escala"` en el config). Jolt + tolera 2 cm de penetración y a 240 Hz la torre de 16 se hundía y se volcaba sola. + +Ojo: con la notebook Optimus, EEVEE usa la Intel salvo que se lance con +`__NV_PRIME_RENDER_OFFLOAD=1 __GLX_VENDOR_LIBRARY_NAME=nvidia`, y así es 3,6 veces +más rápido (1,5 s/frame contra 5,5 en galton). + +## Cómo regenerar + +```bash +conda run -n manim python scripts/gen_audio.py montyhall # voz + verificación +conda run -n manim manim render -v ERROR --disable_caching \ + --format=mp4 -o montyhall scenes/v1_montyhall.py MontyHall +conda run -n manim python scripts/build_video.py montyhall # música + mezcla + mux +conda run -n manim python scripts/verify_final.py montyhall # QA sobre el MP4 final +``` + +`DRAFT=1` antes del render baja a 540×960 para iterar rápido. + +## Cómo está armado + +``` +scripts/narration.py guiones: (texto que se locuta, subtítulo en pantalla) +scripts/gen_audio.py sintetiza cada frase y la verifica transcribiéndola con Whisper +scripts/numspell.py pasa "99,9%" a "noventa y nueve coma nueve por ciento" + para poder comparar la transcripción contra el guion +scripts/regen.py regenera una frase probando 5 semillas +scripts/repick.py reelige la mejor toma ya sintetizada +scripts/verify.py QA: transcribe todos los WAV y marca los dudosos +scripts/verify_final.py QA final: saca el audio del MP4 ya mezclado y lo transcribe, + así se prueba que la voz se entiende por encima del piano +scripts/music.py cama de piano (progresión propia) + ducking bajo la voz +scripts/build_video.py arma la pista siguiendo el timeline y la pega al video +scenes/tiktok.py base: 9:16, subtítulos, barra de progreso, "beats" +scenes/v*.py una escena por video (Manim) +scenes/overlay.py capa de interfaz para los videos 3D: la misma barra, el mismo + chip y los mismos subtítulos, renderizados con alfa +blender/base.py base de las escenas 3D: paleta, cámara, primitivas, tiempo +blender/*.py una escena por video 3D +scripts/build_blender.py compone fondo + 3D + overlay + audio en una sola pasada +``` + +### Los videos 3D + +Se renderizan headless con Blender 5.2 y EEVEE: + +```bash +conda run -n manim manim -s --disable_caching -o fondo scenes/overlay.py Fondo # trama de puntos +NAME=caos CHIP=Caos CHIPCOL=rosa conda run -n manim manim --transparent \ + --disable_caching -o caos scenes/overlay.py Overlay # capa + timeline +blender -b --factory-startup --python blender/caos.py # secuencia PNG con alfa +conda run -n manim python scripts/build_blender.py caos # composicion + audio +``` + +Dos decisiones que se pagaron solas: + +- **Sin keyframes.** El render recorre los frames uno por uno y antes de cada uno + llama a `actualizar(f)`, que coloca todo. Así la física se calcula exacta en cada + cuadro, se pueden renderizar frames salteados para revisar el encuadre, y no hay + que pelear con la API de acciones por capas de Blender 5.x. +- **El fondo no está en la escena 3D.** Blender renderiza con `film_transparent` y el + fondo punteado se compone abajo con ffmpeg, así es pixel por pixel el mismo que el + de los 18 videos de Manim. +- **El metal necesita algo que reflejar.** `mundo_estudio()` pone un gradiente de + horizonte en el mundo: no se ve (el film es transparente) pero es lo único que + separa el acero del plástico gris. Sin eso, un material metálico queda plano. + +`blender/base.py` genera las piezas mecánicas por código: + +| Función | Qué hace | +|---|---| +| `engranaje_conico` | Engranaje cónico con semiángulo de cono y dientes en espiral. Dos engranan a 90° cuando sus γ suman 90: 45+45 para los del diferencial, 72+18 para corona y piñón (y ahí la relación de dientes queda fijada por sin72/sin18 = 3,07, que es por qué son 43 y 14) | +| `rueda_completa` | Neumático con tacos y canales + llanta de aleación con radios + disco de freno | +| `sector_anillo` | Pared curva: con dos de estas y dos bridas sale una caja de diferencial con ventanas, que es lo que deja ver los engranajes de adentro | +| `anillo_plano`, `tornillos` | Bridas y coronas de bulones | + +Los PNG intermedios quedan en `render/<nombre>/` (unos 4 GB los tres). Sirven para +recomponer con otro audio o subtítulos sin volver a renderizar, que son entre 30 y +55 minutos por video; si no hacen falta, se borran. + +`MUESTRAS=8` baja las muestras de EEVEE (16 por defecto, 64 de fábrica: eso son +10 s/frame en una GTX 1050 y 1,6 s/frame con 8). `SALTO=55` renderiza un frame de +cada 55 para armar la hoja de contacto sin esperar el render entero. + +La sincronía no se estima: la escena escribe `out/<nombre>_timeline.json` con el +tiempo real de cada bloque y el audio se coloca en esas marcas exactas. + +## Sobre la música + +Las progresiones (Am–F–C–G, Em–C–G–D, Am–Em–Dm–E, Cm–G#–A#–Gm, A–E–F#m–D, etc.) son propias y están tocadas con muestras +de piano sueltas, así que el audio es tuyo y no te lo va a marcar el Content ID. +Si preferís un audio en tendencia de TikTok, subilo igual y agregalo desde la app: +la voz ya está mezclada a −14 LUFS y se sigue entendiendo por encima. diff --git a/blender/base.py b/blender/base.py new file mode 100644 index 0000000..385eb0c --- /dev/null +++ b/blender/base.py @@ -0,0 +1,659 @@ +# -*- coding: utf-8 -*- +"""Base comun para las escenas 3D del canal (Blender 5.2, EEVEE, headless). + +Idea central: no se usan keyframes. El render recorre los frames uno por uno y +antes de cada uno llama a una funcion actualizar(f) que coloca todo. Asi la +fisica y la geometria se calculan exactas en cada cuadro y no hay que pelear +con la API de acciones por capas de Blender 5.x. + +El tiempo sale de out/<nombre>_timeline.json, el mismo archivo que usa +build_video.py para colocar el audio: la sincronia es por construccion. +""" +import json, math, os, sys +import bpy + +ROOT = os.environ.get("PROY", "/tmp/Firefox/videos-t") +FPS = 30 +W, H = 1080, 1920 + +# --- paleta del canal (sRGB) -------------------------------------------------- +HEX = {"ambar": "FFD166", "verde": "06D6A0", "rosa": "EF476F", "celeste": "4CC9F0", + "gris": "8D99AE", "fondo": "0B0F1A", "azul": "1D3B6E", "riel": "1B2233", + "blanco": "FFFFFF", "punto": "243050"} + + +def srgb(h): + """Hex sRGB -> lineal, que es lo que espera Blender en los materiales.""" + def c(v): + v /= 255.0 + return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 + h = HEX.get(h, h).lstrip("#") + return tuple(c(int(h[i:i + 2], 16)) for i in (0, 2, 4)) + + +def escena(muestras=None): + """Escena limpia, vertical, EEVEE, fondo transparente y color sin tonemap.""" + bpy.ops.wm.read_factory_settings(use_empty=True) + sc = bpy.context.scene + sc.render.engine = 'BLENDER_EEVEE' + sc.eevee.taa_render_samples = int(muestras or os.environ.get("MUESTRAS", 16)) + sc.render.resolution_x, sc.render.resolution_y = W, H + sc.render.resolution_percentage = int(os.environ.get("PCT", 100)) + sc.render.fps = FPS + sc.render.film_transparent = True # el fondo punteado se compone aparte + sc.render.image_settings.file_format = 'PNG' + sc.render.image_settings.color_mode = 'RGBA' + sc.render.image_settings.compression = 25 + # 'Standard' deja los colores tal cual se escriben; AgX los lavaria y el + # canal quedaria con otra paleta que los videos de Manim. + for prop, val in (("view_transform", 'Standard'), ("look", 'None')): + try: + setattr(sc.view_settings, prop, val) + except Exception as e: + print(f" aviso: view_settings.{prop} -> {e}") + mundo = bpy.data.worlds.new("mundo") + mundo.use_nodes = True + mundo.node_tree.nodes["Background"].inputs[0].default_value = (*srgb("fondo"), 1) + mundo.node_tree.nodes["Background"].inputs[1].default_value = 0.45 + sc.world = mundo + return sc + + +def poner(nodo, nombre, valor): + """setattr/socket tolerante: avisa en vez de romper si cambio el nombre.""" + try: + if nombre in nodo.inputs: + nodo.inputs[nombre].default_value = valor + return True + except Exception: + pass + print(f" aviso: no existe la entrada '{nombre}'") + return False + + +def material(nombre, color, rug=0.35, metal=0.0, emis=0.0, alpha=1.0): + m = bpy.data.materials.new(nombre) + m.use_nodes = True + b = m.node_tree.nodes.get("Principled BSDF") + col = (*(color if isinstance(color, tuple) else srgb(color)), 1.0) + poner(b, "Base Color", col) + poner(b, "Roughness", rug) + poner(b, "Metallic", metal) + if emis: + poner(b, "Emission Color", col) + poner(b, "Emission Strength", emis) + if alpha < 1.0: + poner(b, "Alpha", alpha) + m.blend_method = 'BLEND' if hasattr(m, "blend_method") else m.blend_method + return m + + +def objeto(nombre, malla, mat=None, coleccion=None): + ob = bpy.data.objects.new(nombre, malla) + (coleccion or bpy.context.collection).objects.link(ob) + if mat is not None: + ob.data.materials.append(mat) + return ob + + +def malla_de(nombre, verts, faces, suave=True): + me = bpy.data.meshes.new(nombre) + me.from_pydata(verts, [], faces) + me.update() + if suave: + for p in me.polygons: + p.use_smooth = True + return me + + +def curva_poly(nombre, splines, grosor=0.01, radios=None, mat=None): + """Una curva con varias splines POLY; radios opcional por punto.""" + cu = bpy.data.curves.new(nombre, 'CURVE') + cu.dimensions = '3D' + cu.bevel_depth = grosor + cu.bevel_resolution = 1 + cu.use_fill_caps = True + for k, pts in enumerate(splines): + sp = cu.splines.new('POLY') + sp.points.add(len(pts) - 1) + for i, p in enumerate(pts): + sp.points[i].co = (p[0], p[1], p[2], 1.0) + if radios is not None: + sp.points[i].radius = radios[k][i] + return objeto(nombre, cu, mat) + + +def rehacer_curva(ob, splines, radios=None): + """Reescribe los puntos de una curva ya creada (misma cantidad de puntos).""" + for sp, pts, k in zip(ob.data.splines, splines, range(len(splines))): + for i, p in enumerate(pts): + sp.points[i].co = (p[0], p[1], p[2], 1.0) + if radios is not None: + sp.points[i].radius = radios[k][i] + + +def camara(loc, mira=(0, 0, 0), lente=50): + cd = bpy.data.cameras.new("cam") + cd.lens = lente + cam = bpy.data.objects.new("cam", cd) + bpy.context.collection.objects.link(cam) + bpy.context.scene.camera = cam + apuntar(cam, loc, mira) + return cam + + +def apuntar(ob, loc, mira): + """Coloca ob en loc mirando a 'mira' (convencion de camara: -Z adelante).""" + from mathutils import Vector + ob.location = loc + d = Vector(mira) - Vector(loc) + ob.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler() + + +def luz(nombre, tipo, loc, energia, color="blanco", tam=2.0, mira=None): + ld = bpy.data.lights.new(nombre, tipo) + ld.energy = energia + ld.color = srgb(color) + if tipo == 'AREA': + ld.size = tam + if tipo == 'POINT': + ld.shadow_soft_size = tam + ob = bpy.data.objects.new(nombre, ld) + bpy.context.collection.objects.link(ob) + if mira is not None: + apuntar(ob, loc, mira) + else: + ob.location = loc + return ob + + +# --- tiempo ------------------------------------------------------------------- +class Tiempo: + """Traduce el timeline del canal a frames.""" + + def __init__(self, nombre): + with open(os.path.join(ROOT, "out", f"{nombre}_timeline.json")) as f: + self.tl = json.load(f) + self.beats = self.tl["beats"] + self.total = self.tl["total"] + self.n_frames = int(round(self.total * FPS)) + + def rango(self, i): + """(frame inicial, frame final) del beat i, 1-based inclusive.""" + b = self.beats[i] + a = int(round(b["start"] * FPS)) + 1 + z = int(round((b["start"] + b["dur"]) * FPS)) + return a, z + + def t(self, f): + return (f - 1) / FPS + + def p(self, f, i): + """Progreso 0..1 dentro del beat i (fuera del beat: 0 antes, 1 despues).""" + a, z = self.rango(i) + if f <= a: + return 0.0 + if f >= z: + return 1.0 + return (f - a) / max(1, z - a) + + +def suave(x): + x = max(0.0, min(1.0, x)) + return x * x * (3 - 2 * x) + + +def mezcla(a, b, x): + return a + (b - a) * suave(x) + + +# --- render ------------------------------------------------------------------- +def render_secuencia(nombre, tiempo, actualizar, desde=None, hasta=None): + sc = bpy.context.scene + carpeta = os.path.join(ROOT, "render", nombre) + os.makedirs(carpeta, exist_ok=True) + a = int(desde or os.environ.get("DESDE", 1)) + z = int(hasta or os.environ.get("HASTA", tiempo.n_frames)) + salto = os.environ.get("SALTO") # para pruebas: 1 de cada N + frames = range(a, z + 1, int(salto) if salto else 1) + import time + t0 = time.time() + hechos = 0 + for f in frames: + destino = os.path.join(carpeta, f"f{f:04d}.png") + if os.environ.get("SEGUIR") and os.path.exists(destino): + continue + sc.frame_set(f) + actualizar(f) + sc.render.filepath = destino + bpy.ops.render.render(write_still=True) + hechos += 1 + if hechos % 25 == 0: + d = time.time() - t0 + print(f" [{nombre}] frame {f}/{z} {d / hechos:.2f}s/frame " + f"faltan {(len(frames) - hechos) * d / hechos / 60:.1f} min", flush=True) + print(f"[{nombre}] {hechos} frames en {(time.time() - t0) / 60:.1f} min", flush=True) + + +# --- texto 3D ----------------------------------------------------------------- +FUENTE_RUTA = "/usr/share/fonts/TTF/Roboto-Bold.ttf" +_fuente = None + + +def texto(cuerpo, tam=0.18, color="blanco", align='CENTER', emis=1.8, plano_xz=True): + """Texto plano, emisivo, en el plano XZ (para camaras que miran por -Y).""" + global _fuente + if _fuente is None: + _fuente = bpy.data.fonts.load(FUENTE_RUTA) + cu = bpy.data.curves.new("txt", 'FONT') + cu.body = cuerpo + cu.font = _fuente + cu.size = tam + cu.align_x = align + cu.align_y = 'CENTER' + ob = objeto("txt", cu, material(f"m_txt_{color}", color, emis=emis, rug=0.6)) + if plano_xz: + ob.rotation_euler = (math.pi / 2, 0, 0) + return ob + + +def cilindro(nombre, radio, largo, mat=None, lados=20): + """Cilindro centrado en el origen, eje +Z, para reubicar por frame.""" + verts, faces = [], [] + for i in range(lados): + a = 2 * math.pi * i / lados + verts.append((radio * math.cos(a), radio * math.sin(a), -largo / 2)) + verts.append((radio * math.cos(a), radio * math.sin(a), largo / 2)) + for i in range(lados): + j = (i + 1) % lados + faces.append((2 * i, 2 * j, 2 * j + 1, 2 * i + 1)) + tapa_a = [2 * i for i in range(lados)][::-1] + tapa_b = [2 * i + 1 for i in range(lados)] + faces += [tuple(tapa_a), tuple(tapa_b)] + me = malla_de(nombre, verts, faces, suave=False) + for p in me.polygons[:lados]: + p.use_smooth = True + return objeto(nombre, me, mat) + + +def esfera(nombre, radio, mat=None, seg=24, anillos=14): + verts, faces = [], [] + for i in range(1, anillos): + phi = math.pi * i / anillos + for j in range(seg): + th = 2 * math.pi * j / seg + verts.append((radio * math.sin(phi) * math.cos(th), + radio * math.sin(phi) * math.sin(th), + radio * math.cos(phi))) + norte = len(verts); verts.append((0, 0, radio)) + sur = len(verts); verts.append((0, 0, -radio)) + for i in range(anillos - 2): + for j in range(seg): + a = i * seg + j + b = i * seg + (j + 1) % seg + faces.append((a, b, b + seg, a + seg)) + for j in range(seg): + faces.append((norte, (j + 1) % seg, j)) + faces.append((sur, (anillos - 2) * seg + j, (anillos - 2) * seg + (j + 1) % seg)) + return objeto(nombre, malla_de(nombre, verts, faces), mat) + + +def orientar(ob, desde, hasta): + """Coloca un cilindro creado con cilindro() entre dos puntos.""" + from mathutils import Vector + a, b = Vector(desde), Vector(hasta) + ob.location = (a + b) / 2 + d = b - a + ob.rotation_euler = d.to_track_quat('Z', 'Y').to_euler() |