From fafaebb051907a848a9406f9da19669c81a83a3b Mon Sep 17 00:00:00 2001 From: Elvis Claros Castro Date: Sat, 26 Sep 2026 20:50:41 -0300 Subject: Translate code, comments and logs to English; English README; configurable paths and env vars Identifiers, docstrings, comments and console messages are now in English. Narration, subtitles and on-screen text stay in Spanish (they are the video content). The Blender <-> Godot physics protocol uses English keys and body prefixes chosen to keep the original creation order, so cached simulations and renders stay bit-identical. The old Spanish environment variable names are still accepted. --- .gitignore | 2 +- LICENSE | 21 ++ README.es.md | 284 ++++++++++++++++++++++ README.md | 317 ++++++++++++++----------- blender/base.py | 595 ++++++++++++++++++++++++----------------------- blender/bloques.py | 306 ++++++++++++------------ blender/bolapeluda.py | 274 +++++++++++----------- blender/caos.py | 228 +++++++++--------- blender/diferencial.py | 388 +++++++++++++++--------------- blender/domino.py | 462 ++++++++++++++++++------------------ blender/galton.py | 398 +++++++++++++++---------------- brand/README.es.md | 52 +++++ brand/README.md | 70 +++--- brand/make_logo.py | 246 ++++++++++---------- godot/sim.gd | 281 +++++++++++----------- scenes/overlay.py | 20 +- scenes/tiktok.py | 36 +-- scenes/v10_buffon.py | 92 ++++---- scenes/v11_collatz.py | 98 ++++---- scenes/v12_simpson.py | 144 ++++++------ scenes/v13_cuerda.py | 118 +++++----- scenes/v14_gabriel.py | 82 +++---- scenes/v15_mobius.py | 138 +++++------ scenes/v16_bayes.py | 154 ++++++------ scenes/v17_cicloide.py | 188 +++++++-------- scenes/v18_dados.py | 174 +++++++------- scenes/v1_montyhall.py | 44 ++-- scenes/v2_nueves.py | 74 +++--- scenes/v3_cumple.py | 52 ++--- scenes/v4_luna.py | 64 ++--- scenes/v5_japones.py | 62 ++--- scenes/v6_benford.py | 66 +++--- scenes/v7_hilbert.py | 56 ++--- scenes/v8_reuleaux.py | 108 ++++----- scenes/v9_regla72.py | 68 +++--- scripts/build_blender.py | 32 +-- scripts/build_video.py | 4 +- scripts/gen_audio.py | 39 ++-- scripts/music.py | 40 ++-- scripts/narration.py | 4 +- scripts/numspell.py | 18 +- scripts/regen.py | 6 +- scripts/repick.py | 4 +- scripts/verify.py | 2 +- scripts/verify_final.py | 32 +-- 45 files changed, 3183 insertions(+), 2760 deletions(-) create mode 100644 LICENSE create mode 100644 README.es.md create mode 100644 brand/README.es.md diff --git a/.gitignore b/.gitignore index 7da0c69..31e6267 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ /media/ /tmp/ /audio/ -/godot/trabajo/ +/godot/work/ /godot/.godot/ __pycache__/ *.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..acdf183 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Elvis Claros Castro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.es.md b/README.es.md new file mode 100644 index 0000000..d5c8837 --- /dev/null +++ b/README.es.md @@ -0,0 +1,284 @@ +# 24 videos verticales para TikTok — 100% generados localmente + +[English](README.md) + +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. `MODE=sim blender -b -P blender/.py` arma la escena y exporta a glTF solo + los objetos físicos. El prefijo del nombre dice qué es cada uno para Godot + (`static_` malla estática, `box_` caja estática, `gate_` estática que se saca + con un evento, `ball_` esfera rígida, `block_` caja rígida). Después corre + `godot --headless --fixed-fps 240 -- godot/work/.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/work/.bin`). +3. `blender -b -P blender/.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 `"scale"` 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 Backdrop # 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 +/ruta/a/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 `refresh(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.** `studio_world()` 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 | +|---|---| +| `bevel_gear` | 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) | +| `full_wheel` | Neumático con tacos y canales + llanta de aleación con radios + disco de freno | +| `ring_sector` | 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 | +| `flat_ring`, `screws` | Bridas y coronas de bulones | + +Los PNG intermedios quedan en `render//` (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. + +`SAMPLES=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). `EVERY=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/_timeline.json` con el +tiempo real de cada bloque y el audio se coloca en esas marcas exactas. + +## Variables de entorno + +| Variable | Para qué | +|---|---| +| `PROJECT` | raíz del proyecto (por defecto, la carpeta del repositorio) | +| `MODE=sim` | en las escenas con Godot: recalcula la física en lugar de renderizar | +| `SAMPLES`, `PCT` | muestras de EEVEE y porcentaje de resolución | +| `FROM_FRAME`, `TO_FRAME`, `EVERY`, `RESUME` | qué frames renderizar | +| `QWENTTS_DIR`, `VOICE_REF` | qwentts.cpp y la voz de referencia para la locución | +| `PIANO_DIR` | muestras de piano de keysound | + +Los nombres anteriores (`PROY`, `MODO`, `MUESTRAS`, `DESDE`, `HASTA`, `SALTO`, +`SEGUIR`, `PRUEBA`) se siguen aceptando. + +## Voz + +El repositorio no trae la voz clonada. `VOICE_REF` es el prefijo de sus tres +archivos (`.spk`, `.rvq` y `.txt`), que se sacan de una grabación con +`qwen-codec --talker`. + +## Qué no está en git + +Todo lo que se genera: `render/` (PNG intermedios), `media/` (salida de Manim), +`audio/` (locución), `out/` (videos finales y timelines), `tmp/` y +`godot/work/` (simulaciones). Se regeneran con los pasos de arriba. + +## 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. + +## Licencia + +MIT diff --git a/README.md b/README.md index bcced52..31cb19d 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,49 @@ -# 24 videos verticales para TikTok — 100% generados localmente +# 24 vertical TikTok videos — generated 100% locally -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. +[Español](README.es.md) -## Los videos (`out/`) +Everything runs offline: **Manim** for 2D animation, **Blender** (EEVEE, headless) +for the 3D scenes, **Godot** (Jolt physics engine, headless) for rigid-body +physics, **qwentts.cpp** (a cloned Rioplatense voice) for the narration and the +`keysound` piano samples for the music. -| Archivo | Tema | Duración | Gancho | +The videos are in Spanish: narration, subtitles and on-screen text are Spanish on +purpose. The code is in English. + +## The videos (`out/`) + +| File | Topic | Length | Hook | |---|---|---|---| -| `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.mp4` | The Monty Hall problem | 39.5 s | sparks debate in the comments | +| `nueves.mp4` | 0,999… = 1 | 38.8 s | sparks debate in the comments | +| `cumple.mp4` | The birthday paradox | 38.6 s | surprising fact | +| `luna.mp4` | Folding paper to the Moon | 38.4 s | surprising fact | +| `japones.mp4` | Multiplying with lines | 38.8 s | useful, gets saved and shared | +| `benford.mp4` | Benford's law (spotting fraud) | 42.8 s | surprising fact + useful | +| `hilbert.mp4` | The infinite hotel | 37.1 s | sparks debate in the comments | +| `reuleaux.mp4` | Why manhole covers are round | 36.0 s | satisfying to watch | +| `regla72.mp4` | The rule of 72 | 35.5 s | useful, gets saved | +| `buffon.mp4` | Getting π by dropping needles | 36.7 s | mathematical beauty | +| `collatz.mp4` | The Collatz conjecture (3n+1) | 46.2 s | unsolved mystery | +| `simpson.mp4` | Simpson's paradox | 53.0 s | debate + useful | +| `cuerda.mp4` | A rope around the Earth | 45.4 s | surprising fact | +| `gabriel.mp4` | Gabriel's horn | 40.5 s | mathematical beauty | +| `mobius.mp4` | Möbius strip | 46.3 s | try it at home | +| `bayes.mp4` | False positive (medical test) | 46.9 s | debate + useful | +| `cicloide.mp4` | Brachistochrone: the fastest curve | 47.6 s | satisfying to watch | +| `dados.mp4` | Non-transitive dice | 50.6 s | sparks debate in the comments | +| `bolapeluda.mp4` | Hairy ball theorem (3D) | 41.9 s | satisfying to watch | +| `caos.mp4` | Three double pendulums and 1 mm (3D) | 40.6 s | hypnotic, watched to the end | +| `diferencial.mp4` | How a differential works (3D) | 44.1 s | useful, gets saved and shared | +| `galton.mp4` | Galton board: 500 balls draw the bell curve (Blender + Godot) | 45.7 s | satisfying + question in the comments | +| `bloques.mp4` | Harmonic stack: blocks overhanging the table (Blender + Godot) | 48.0 s | try it at home + surprising fact | +| `domino.mp4` | Dominoes ×1.5: from 5 mm to the Aconcagua (Blender + Godot) | 66.7 s | exponential + question in the comments (over 1 min) | + +Format: 1080×1920, 30 fps, H.264 + AAC 48 kHz stereo, **−13.8 to −15.3 LUFS** +measured (TikTok's target is −14, so it neither lowers nor raises them), true +peak below −1 dBTP. Burned-in subtitles: 80 % watch without sound. + +## Suggested post texts (in Spanish, as published) **montyhall** — ¿Te quedás o cambiás? El 87% se equivoca 🚪 `#matematica #montyhall #datocurioso #probabilidad #argentina` @@ -113,140 +117,181 @@ Subtítulos quemados: el 80 % mira sin audio. **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: +Post one a day at the same time, and rotate the three kinds: -- **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, +- **debate** (comments, which push reach the most): montyhall, nueves, hilbert, + simpson, cuerda, dados +- **useful** (saved and shared): japones, regla72, benford, bayes, diferencial, bloques +- **spectacle** (watched to the end): 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. +The 3D ones (`bolapeluda`, `caos`, `diferencial`, `galton`, `bloques`, `domino`) +are made in Blender: they break the visual monotony of Manim without changing the +channel style, because the UI layer (chip, bar and subtitles) is the same. -`mobius` es el único con llamado a la acción físico ("hacelo con una hoja"): -esos suelen traer videos-respuesta, que valen doble. +`mobius` is the only one with a physical call to action ("do it with a sheet of +paper"): those tend to bring response videos, which count double. -### Blender + Godot (`galton`, `bloques`) +### Blender + Godot (`galton`, `bloques`, `domino`) -Blender diseña y Godot calcula la física: +Blender designs and Godot computes the physics: -1. `MODO=sim blender -b -P blender/.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/.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/.bin`). -3. `blender -b -P blender/.py` lee esas poses y renderiza con los materiales - de verdad (madera procedural, latón, acero). +1. `MODE=sim blender -b -P blender/.py` builds the scene and exports only + the physical objects to glTF. The name prefix tells Godot what each one is + (`static_` static mesh, `box_` static box, `gate_` static and removed by an + event, `ball_` rigid sphere, `block_` rigid box). Then it runs + `godot --headless --fixed-fps 240 -- godot/work/.json`. +2. `godot/sim.gd` loads the glb at runtime, creates the bodies, applies the + script events (release, remove the gate) at the exact second of the timeline + and records each body's pose at 30 fps in Blender coordinates + (`godot/work/.bin`). +3. `blender -b -P blender/.py` reads those poses back and renders with the + real materials (procedural wood, brass, steel). -Nada del movimiento está animado a mano. Algunos números que salen de la simulación: +None of the motion is animated by hand. Some numbers that come out of the +simulation: -- **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. +- **galton**: bins `[0 1 7 29 70 104 96 83 65 35 10 0 0]` (standard deviation of + 1.82 bins; the ideal binomial for 12 rows gives 1.73). The middle column holds + **96**: that is the answer to the closing question. +- **bloques**: the stack of 5 with harmonic shifts ×0.95 holds with the top block + entirely in the air (8.5 % of a block). The tower of 16 does not move until one + more block is put on its top left end: 10 of 17 fall. -Dos ajustes que hicieron falta en Godot: +Two tweaks Godot needed: -- **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. +- **galton**: linear damping of 6/s, acting as friction against the glass. + Without it the balls pick up too much sideways speed and the distribution + comes out flat. +- **bloques**: physics at 1920 Hz and the world ×5 (`"hz"` and `"scale"` in the + config). Jolt tolerates 2 cm of penetration and at 240 Hz the tower of 16 sank + and toppled on its own. -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). +The prefixes also set the order Godot creates the bodies in (Blender exports +alphabetically), and in a chaotic scene that order changes the result: keep new +prefixes in the same alphabetical order (`ball_ < box_ < gate_ < static_`). -## Cómo regenerar +On an Optimus laptop EEVEE uses the Intel GPU unless started with +`__NV_PRIME_RENDER_OFFLOAD=1 __GLX_VENDOR_LIBRARY_NAME=nvidia`, which is 3.6 times +faster (1.5 s/frame versus 5.5 on galton). + +## Regenerating ```bash -conda run -n manim python scripts/gen_audio.py montyhall # voz + verificación +conda run -n manim python scripts/gen_audio.py montyhall # voice + check 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 +conda run -n manim python scripts/build_video.py montyhall # music + mix + mux +conda run -n manim python scripts/verify_final.py montyhall # QA on the final MP4 ``` -`DRAFT=1` antes del render baja a 540×960 para iterar rápido. +`DRAFT=1` before rendering drops to 540×960 to iterate fast. -## Cómo está armado +## Layout ``` -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 +scripts/narration.py scripts: (text that is spoken, on-screen subtitle) +scripts/gen_audio.py synthesizes each sentence and checks it by transcribing with Whisper +scripts/numspell.py turns "99,9%" into "noventa y nueve coma nueve por ciento" + so the transcription can be compared with the script +scripts/regen.py regenerates one sentence trying 5 seeds +scripts/repick.py picks the best take already synthesized again +scripts/verify.py QA: transcribes every WAV and flags doubtful ones +scripts/verify_final.py final QA: extracts the audio of the mixed MP4 and transcribes it, + which proves the voice is intelligible over the piano +scripts/music.py piano bed (original progression) + ducking under the voice +scripts/build_video.py builds the track following the timeline and muxes it onto the video +scenes/tiktok.py base: 9:16, subtitles, progress bar, "beats" +scenes/v*.py one scene per video (Manim) +scenes/overlay.py UI layer for the 3D videos: the same bar, chip and subtitles, + rendered with alpha +blender/base.py base of the 3D scenes: palette, camera, primitives, timing +blender/*.py one scene per 3D video +godot/sim.gd generic rigid-body simulator fed by Blender +scripts/build_blender.py composites background + 3D + overlay + audio in one pass +brand/ channel logo and avatar (make_logo.py) ``` -### Los videos 3D +### The 3D videos -Se renderizan headless con Blender 5.2 y EEVEE: +Rendered headless with Blender 5.2 and EEVEE: ```bash -conda run -n manim manim -s --disable_caching -o fondo scenes/overlay.py Fondo # trama de puntos +conda run -n manim manim -s --disable_caching -o fondo scenes/overlay.py Backdrop # dot pattern 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 + --disable_caching -o caos scenes/overlay.py Overlay # UI layer + timeline +/path/to/blender -b --factory-startup --python blender/caos.py # PNG sequence with alpha +conda run -n manim python scripts/build_blender.py caos # compositing + audio ``` -Dos decisiones que se pagaron solas: +Decisions that paid for themselves: -- **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. +- **No keyframes.** The render walks the frames one by one and calls + `refresh(f)` before each one, which places everything. That way physics is + computed exactly on every frame, skipped frames can be rendered to check the + framing, and there is no fighting with Blender 5.x's layered actions API. +- **The background is not in the 3D scene.** Blender renders with + `film_transparent` and the dotted background is composited underneath with + ffmpeg, so it is pixel for pixel the same as in the 18 Manim videos. +- **Metal needs something to reflect.** `studio_world()` puts a horizon gradient + in the world: it is not visible (the film is transparent) but it is the only + thing that separates steel from grey plastic. Without it, a metallic material + looks flat. -`blender/base.py` genera las piezas mecánicas por código: +`blender/base.py` generates the mechanical parts in code: -| Función | Qué hace | +| Function | What it does | |---|---| -| `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 | +| `bevel_gear` | Bevel gear with a cone half-angle and spiral teeth. Two mesh at 90° when their γ add up to 90: 45+45 for the differential ones, 72+18 for ring gear and pinion (and there the tooth ratio is fixed by sin72/sin18 = 3.07, which is why they are 43 and 14) | +| `full_wheel` | Tire with lugs and grooves + alloy rim with spokes + brake disc | +| `ring_sector` | Curved wall: two of these and two flanges make a differential housing with windows, which is what lets you see the gears inside | +| `flat_ring`, `screws` | Flanges and bolt circles | + +The intermediate PNGs stay in `render//` (about 4 GB for three videos). They +allow recompositing with other audio or subtitles without rendering again, which +takes 30 to 55 minutes per video; if they are not needed, delete them. + +`SAMPLES=8` lowers the EEVEE samples (16 by default, 64 stock: that is 10 s/frame +on a GTX 1050 and 1.6 s/frame with 8). `EVERY=55` renders one frame out of every +55 to build a contact sheet without waiting for the whole render. + +Sync is not estimated: the scene writes `out/_timeline.json` with the real +time of each block and the audio is placed at those exact marks. + +## Environment variables + +| Variable | What for | +|---|---| +| `PROJECT` | project root (defaults to the repository directory) | +| `MODE=sim` | in the Godot scenes: recompute the physics instead of rendering | +| `SAMPLES`, `PCT` | EEVEE samples and resolution percentage | +| `FROM_FRAME`, `TO_FRAME`, `EVERY`, `RESUME` | which frames to render | +| `QWENTTS_DIR`, `VOICE_REF` | qwentts.cpp and the reference voice for the narration | +| `PIANO_DIR` | keysound piano samples | + +The older Spanish names (`PROY`, `MODO`, `MUESTRAS`, `DESDE`, `HASTA`, `SALTO`, +`SEGUIR`, `PRUEBA`) are still accepted. + +## Voice + +The repository does not include the cloned voice. `VOICE_REF` is the path prefix +of its three files (`.spk`, `.rvq` and `.txt`), extracted from a recording with +`qwen-codec --talker`. + +## What is not in git -Los PNG intermedios quedan en `render//` (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. +Everything generated: `render/` (intermediate PNGs), `media/` (Manim output), +`audio/` (narration), `out/` (final videos and timelines), `tmp/` and +`godot/work/` (simulations). They are regenerated with the steps above. -`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. +## About the music -La sincronía no se estima: la escena escribe `out/_timeline.json` con el -tiempo real de cada bloque y el audio se coloca en esas marcas exactas. +The progressions (Am–F–C–G, Em–C–G–D, Am–Em–Dm–E, Cm–G#–A#–Gm, A–E–F#m–D, etc.) are +original and played with loose piano samples, so the audio is yours and Content +ID will not flag it. If you prefer a trending TikTok sound, upload anyway and add +it from the app: the voice is already mixed at −14 LUFS and stays intelligible +over it. -## Sobre la música +## License -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. +MIT diff --git a/blender/base.py b/blender/base.py index 385eb0c..74b23be 100644 --- a/blender/base.py +++ b/blender/base.py @@ -1,29 +1,36 @@ # -*- coding: utf-8 -*- -"""Base comun para las escenas 3D del canal (Blender 5.2, EEVEE, headless). +"""Common base for the channel's 3D scenes (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. +Key idea: no keyframes are used. The render walks the frames one by one and +calls a refresh(f) function before each one that places everything. That way +physics and geometry are computed exactly on every frame and there is no +fighting with the layered actions API of Blender 5.x. -El tiempo sale de out/_timeline.json, el mismo archivo que usa -build_video.py para colocar el audio: la sincronia es por construccion. +Timing comes from out/_timeline.json, the same file build_video.py uses +to place the audio: sync holds by construction. """ import json, math, os, sys import bpy -ROOT = os.environ.get("PROY", "/tmp/Firefox/videos-t") + +def env(name, old=None, default=None): + """Environment variable, also accepting its older Spanish name.""" + return os.environ.get(name, os.environ.get(old, default) if old else default) + + +# project root: defaults to the directory above this file +ROOT = env("PROJECT", "PROY", os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) FPS = 30 W, H = 1080, 1920 -# --- paleta del canal (sRGB) -------------------------------------------------- +# --- channel palette (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.""" + """sRGB hex -> linear, which is what Blender expects in materials.""" def c(v): v /= 255.0 return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 @@ -31,73 +38,73 @@ def srgb(h): 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.""" +def scene_setup(samples=None): + """Clean vertical scene, EEVEE, transparent background and no tonemapping.""" 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.eevee.taa_render_samples = int(samples or env("SAMPLES", "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.film_transparent = True # the dotted background is composited separately 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. + # 'Standard' keeps colors exactly as written; AgX would wash them out and the + # channel would end up with a different palette from the Manim videos. 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 + world_obj = bpy.data.worlds.new("mundo") + world_obj.use_nodes = True + world_obj.node_tree.nodes["Background"].inputs[0].default_value = (*srgb("fondo"), 1) + world_obj.node_tree.nodes["Background"].inputs[1].default_value = 0.45 + sc.world = world_obj return sc -def poner(nodo, nombre, valor): - """setattr/socket tolerante: avisa en vez de romper si cambio el nombre.""" +def put(node, obj_name, valor): + """Tolerant setattr/socket: warns instead of breaking if a name changed.""" try: - if nombre in nodo.inputs: - nodo.inputs[nombre].default_value = valor + if obj_name in node.inputs: + node.inputs[obj_name].default_value = valor return True except Exception: pass - print(f" aviso: no existe la entrada '{nombre}'") + print(f" warning: input '{obj_name}' does not exist") return False -def material(nombre, color, rug=0.35, metal=0.0, emis=0.0, alpha=1.0): - m = bpy.data.materials.new(nombre) +def material(obj_name, color, rough=0.35, metal=0.0, emit=0.0, alpha=1.0): + m = bpy.data.materials.new(obj_name) 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) + put(b, "Base Color", col) + put(b, "Roughness", rough) + put(b, "Metallic", metal) + if emit: + put(b, "Emission Color", col) + put(b, "Emission Strength", emit) if alpha < 1.0: - poner(b, "Alpha", alpha) + put(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) +def make_object(obj_name, mesh_obj, mat=None, collection_obj=None): + ob = bpy.data.objects.new(obj_name, mesh_obj) + (collection_obj 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) +def mesh_from(obj_name, verts, faces, suave=True): + me = bpy.data.meshes.new(obj_name) me.from_pydata(verts, [], faces) me.update() if suave: @@ -106,11 +113,11 @@ def malla_de(nombre, verts, faces, suave=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') +def curve_poly(obj_name, splines, thickness_px=0.01, radios=None, mat=None): + """A curve with several POLY splines; optional per-point radii.""" + cu = bpy.data.curves.new(obj_name, 'CURVE') cu.dimensions = '3D' - cu.bevel_depth = grosor + cu.bevel_depth = thickness_px cu.bevel_resolution = 1 cu.use_fill_caps = True for k, pts in enumerate(splines): @@ -120,11 +127,11 @@ def curva_poly(nombre, splines, grosor=0.01, radios=None, mat=None): 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) + return make_object(obj_name, cu, mat) -def rehacer_curva(ob, splines, radios=None): - """Reescribe los puntos de una curva ya creada (misma cantidad de puntos).""" +def rebuild_curve(ob, splines, radios=None): + """Rewrites the points of an existing curve (same number of points).""" 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) @@ -132,54 +139,54 @@ def rehacer_curva(ob, splines, radios=None): sp.points[i].radius = radios[k][i] -def camara(loc, mira=(0, 0, 0), lente=50): +def camera_obj(loc, sight=(0, 0, 0), lens=50): cd = bpy.data.cameras.new("cam") - cd.lens = lente + cd.lens = lens cam = bpy.data.objects.new("cam", cd) bpy.context.collection.objects.link(cam) bpy.context.scene.camera = cam - apuntar(cam, loc, mira) + aim_at(cam, loc, sight) return cam -def apuntar(ob, loc, mira): - """Coloca ob en loc mirando a 'mira' (convencion de camara: -Z adelante).""" +def aim_at(ob, loc, sight): + """Places ob at loc looking at 'sight' (camera convention: -Z forward).""" from mathutils import Vector ob.location = loc - d = Vector(mira) - Vector(loc) + d = Vector(sight) - 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 +def light_obj(obj_name, kind_m, loc, energy_val, color="blanco", size_u=2.0, sight=None): + ld = bpy.data.lights.new(obj_name, kind_m) + ld.energy = energy_val 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) + if kind_m == 'AREA': + ld.size = size_u + if kind_m == 'POINT': + ld.shadow_soft_size = size_u + ob = bpy.data.objects.new(obj_name, ld) bpy.context.collection.objects.link(ob) - if mira is not None: - apuntar(ob, loc, mira) + if sight is not None: + aim_at(ob, loc, sight) else: ob.location = loc return ob -# --- tiempo ------------------------------------------------------------------- -class Tiempo: - """Traduce el timeline del canal a frames.""" +# --- timing ------------------------------------------------------------------- +class Timeline: + """Turns the channel timeline into frames.""" - def __init__(self, nombre): - with open(os.path.join(ROOT, "out", f"{nombre}_timeline.json")) as f: + def __init__(self, obj_name): + with open(os.path.join(ROOT, "out", f"{obj_name}_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.""" + def span(self, i): + """(first frame, last frame) of beat i, 1-based inclusive.""" b = self.beats[i] a = int(round(b["start"] * FPS)) + 1 z = int(round((b["start"] + b["dur"]) * FPS)) @@ -189,8 +196,8 @@ class Tiempo: 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) + """Progress 0..1 within beat i (outside the beat: 0 before, 1 after).""" + a, z = self.span(i) if f <= a: return 0.0 if f >= z: @@ -203,111 +210,111 @@ def suave(x): return x * x * (3 - 2 * x) -def mezcla(a, b, x): +def mix_m(a, b, x): return a + (b - a) * suave(x) # --- render ------------------------------------------------------------------- -def render_secuencia(nombre, tiempo, actualizar, desde=None, hasta=None): +def render_sequence(obj_name, elapsed_t, refresh, since=None, until=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) + folder = os.path.join(ROOT, "render", obj_name) + os.makedirs(folder, exist_ok=True) + a = int(since or env("FROM_FRAME", "DESDE", 1)) + z = int(until or env("TO_FRAME", "HASTA", elapsed_t.n_frames)) + jump = env("EVERY", "SALTO") # for tests: 1 out of every N + frames = range(a, z + 1, int(jump) if jump else 1) import time t0 = time.time() - hechos = 0 + done = 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): + target = os.path.join(folder, f"f{f:04d}.png") + if env("RESUME", "SEGUIR") and os.path.exists(target): continue sc.frame_set(f) - actualizar(f) - sc.render.filepath = destino + refresh(f) + sc.render.filepath = target bpy.ops.render.render(write_still=True) - hechos += 1 - if hechos % 25 == 0: + done += 1 + if done % 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) + print(f" [{obj_name}] frame {f}/{z} {d / done:.2f}s/frame " + f"faltan {(len(frames) - done) * d / done / 60:.1f} min", flush=True) + print(f"[{obj_name}] {done} frames in {(time.time() - t0) / 60:.1f} min", flush=True) -# --- texto 3D ----------------------------------------------------------------- -FUENTE_RUTA = "/usr/share/fonts/TTF/Roboto-Bold.ttf" -_fuente = None +# --- 3D text ----------------------------------------------------------------- +FONT_PATH = "/usr/share/fonts/TTF/Roboto-Bold.ttf" +_font = 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) +def txt_m(body_obj, size_u=0.18, color="blanco", align='CENTER', emit=1.8, plano_xz=True): + """Flat emissive text on the XZ plane (for cameras looking along -Y).""" + global _font + if _font is None: + _font = bpy.data.fonts.load(FONT_PATH) cu = bpy.data.curves.new("txt", 'FONT') - cu.body = cuerpo - cu.font = _fuente - cu.size = tam + cu.body = body_obj + cu.font = _font + cu.size = size_u cu.align_x = align cu.align_y = 'CENTER' - ob = objeto("txt", cu, material(f"m_txt_{color}", color, emis=emis, rug=0.6)) + ob = make_object("txt", cu, material(f"m_txt_{color}", color, emit=emit, rough=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.""" +def cylinder(obj_name, radio, largo, mat=None, sides=20): + """Cylinder centred on the origin, +Z axis, to be repositioned per frame.""" verts, faces = [], [] - for i in range(lados): - a = 2 * math.pi * i / lados + for i in range(sides): + a = 2 * math.pi * i / sides 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 + for i in range(sides): + j = (i + 1) % sides 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]: + lid_a = [2 * i for i in range(sides)][::-1] + lid_b = [2 * i + 1 for i in range(sides)] + faces += [tuple(lid_a), tuple(lid_b)] + me = mesh_from(obj_name, verts, faces, suave=False) + for p in me.polygons[:sides]: p.use_smooth = True - return objeto(nombre, me, mat) + return make_object(obj_name, me, mat) -def esfera(nombre, radio, mat=None, seg=24, anillos=14): +def sphere(obj_name, radio, mat=None, seg_m=24, rings=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 + for i in range(1, rings): + phi = math.pi * i / rings + for j in range(seg_m): + th = 2 * math.pi * j / seg_m 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.""" + north = len(verts); verts.append((0, 0, radio)) + south = len(verts); verts.append((0, 0, -radio)) + for i in range(rings - 2): + for j in range(seg_m): + a = i * seg_m + j + b = i * seg_m + (j + 1) % seg_m + faces.append((a, b, b + seg_m, a + seg_m)) + for j in range(seg_m): + faces.append((north, (j + 1) % seg_m, j)) + faces.append((south, (rings - 2) * seg_m + j, (rings - 2) * seg_m + (j + 1) % seg_m)) + return make_object(obj_name, mesh_from(obj_name, verts, faces), mat) + + +def orient(ob, since, until): + """Places a cylinder created with cylinder() between two points.""" from mathutils import Vector - a, b = Vector(desde), Vector(hasta) + a, b = Vector(since), Vector(until) ob.location = (a + b) / 2 d = b - a ob.rotation_euler = d.to_track_quat('Z', 'Y').to_euler() -def toro(nombre, R, r, mat=None, u=64, v=24): +def torus(obj_name, R, r, mat=None, u=64, v=24): verts, faces = [], [] for i in range(u): a = 2 * math.pi * i / u @@ -321,38 +328,38 @@ def toro(nombre, R, r, mat=None, u=64, v=24): for j in range(v): j2 = (j + 1) % v faces.append((i * v + j, i2 * v + j, i2 * v + j2, i * v + j2)) - return objeto(nombre, malla_de(nombre, verts, faces), mat) + return make_object(obj_name, mesh_from(obj_name, verts, faces), mat) -HOLGURA = 0.55 # 0.5 = sin juego; un poco mas deja luz entre flancos +SLACK = 0.55 # 0.5 = no play; a bit more leaves light between flanks -def engranaje_conico(nombre, eje, N=18, gamma=45.0, d_i=0.50, d_o=0.70, - alto=0.055, mat=None, nd=10, por_diente=10, fase=0.0, - espiral=0.0, espesor=0.10, hueco=0.0): - """Engranaje conico con el apice en el origen y eje 'eje'. +def bevel_gear(obj_name, axis_obj, N=18, gamma=45.0, d_i=0.50, d_o=0.70, + alto=0.055, mat=None, nd=10, per_tooth=10, phase=0.0, + spiral=0.0, thickness=0.10, gap_m=0.0): + """Bevel gear with its apex at the origin and axis 'axis_obj'. - gamma = semiangulo del cono de paso. Dos engranajes engranan a 90 grados - cuando sus gammas suman 90: 45+45 para los del diferencial, - 72+18 para corona y pinon. - espiral = torsion del diente a lo largo de la generatriz (conico espiral, - que es lo que se usa de verdad; en recto se ve de juguete). - espesor = cuanto se extruye el cuerpo hacia atras, para que sea un solido - y no una cascara. + gamma = half-angle of the pitch cone. Two gears mesh at 90 degrees + when their gammas add up to 90: 45+45 for the differential + ones, 72+18 for ring gear and pinion. + spiral = twist of the tooth along the generatrix (spiral bevel, which + is what is really used; straight teeth look like a toy). + thickness = how far the body is extruded backwards, so it is a solid and + not a shell. """ from mathutils import Vector - a = Vector(eje).normalized() + a = Vector(axis_obj).normalized() u = Vector((0, 0, 1)) if abs(a.z) < 0.9 else Vector((1, 0, 0)) u = (u - a * u.dot(a)).normalized() v = a.cross(u) cg, sg = math.cos(math.radians(gamma)), math.sin(math.radians(gamma)) - nphi = N * por_diente + nphi = N * per_tooth verts, faces = [], [] - def perfil(x): + def profile(x): return max(0.0, min(1.0, (math.cos(x) + 0.30) / 0.60)) - def punto(d, phi, h): + def point(d, phi, h): rad = u * math.cos(phi) + v * math.sin(phi) base = (a * cg + rad * sg) * d nrm = rad * cg - a * sg @@ -363,79 +370,79 @@ def engranaje_conico(nombre, eje, N=18, gamma=45.0, d_i=0.50, d_o=0.70, d = d_i + (d_o - d_i) * s_ for j in range(nphi): phi = 2 * math.pi * j / nphi - # el diente arranca bajo en el extremo interior y crece hacia afuera - # el diente se apaga en el borde interior: si no, el cierre del - # cuerpo queda como un abanico de aletas - # el diente va mitad por encima y mitad por debajo del cono de paso: - # asi el de enfrente entra en el hueco en vez de atravesar el cuerpo - h = alto * (perfil(N * phi + fase + espiral * s_) - HOLGURA) * (s_ ** 0.8) - p = punto(d, phi, h) + # the tooth starts low at the inner end and grows outwards + # the tooth fades at the inner edge: otherwise the body closure + # looks like a fan of fins + # the tooth sits half above and half below the pitch cone: + # that way the opposite gear goes into the gap instead of through the body + h = alto * (profile(N * phi + phase + spiral * s_) - SLACK) * (s_ ** 0.8) + p = point(d, phi, h) verts.append((p.x, p.y, p.z)) for i in range(nd): for j in range(nphi): j2 = (j + 1) % nphi faces.append((i * nphi + j, i * nphi + j2, (i + 1) * nphi + j2, (i + 1) * nphi + j)) - # cuerpo: se extruye el borde exterior hacia atras y se cierra con el fondo + # body: the outer edge is extruded backwards and closed with the bottom