# La skill «torculo»: el motor fuera del pliego

> El canon, la pluma y las láminas, las materias WGSL, las tomas y la capa de sentido, para usar el motor sin escribir un pliego.

URL: https://torculo.joseluissaorin.com/referencia/skill-torculo · Revisado: 2026-10-16 · English: https://torculo.joseluissaorin.com/en/reference.md

Fuente: skills/torculo/SKILL.md (generada al construir la web).


Tórculo es la prensa del grabador: la plancha se trabaja una vez y se estampa
tantas veces como haga falta. Aquí la plancha es una escena, un dibujo o una
materia, y las estampas son un vídeo, una web, una app o un juego.

Todo vive en `~/Developer/torculo` (repo git propio):

| Pieza | Dónde | Qué es |
|---|---|---|
| Canon | `canon/` | La única fuente de verdad: paleta, azar, oficio, foley |
| Motor | `manimgx/` (rama `seams`) | Fork de ManimGX con lo de la casa |
| Pluma | `pluma/` | El trazo a mano, igual en Rust, Python y wasm; formato `.lamina` |
| Materias | `materias/` | Papel, acuarela espectral, pincel, risografía, tejido, transiciones (WGSL) |
| Sentido | `sentido/` | EmbeddingGemma 2 en local: elegir, comprobar, comparar |
| Turno | `tools/pesado` | Para no saturar el Mac (obligatorio) |
| Plan del fork | `FORK.md` | Dónde engancha cada cosa, con fichero y línea |

## 0. La regla de la carga (antes que nada)

El Mac de José Luis tiene 14 núcleos y no puede volver a saturarse (llegó a una
carga de 268 con varios agentes compilando a la vez). **Todo trabajo pesado
pasa por el turno:**

```sh
~/Developer/torculo/tools/pesado cargo build --release
~/Developer/torculo/tools/pesado uv run manimgx render escena.py
~/Developer/torculo/tools/pesado uv run pytest -n 4
```

Como mucho dos trabajos pesados a la vez en todo el Mac, espera a carga < 14, nice 15,
`CARGO_BUILD_JOBS=4`, `MESA_JOBS=2`. Al lanzar subagentes, decírselo
explícitamente a cada uno. No tocar procesos que no sean nuestros.

## 1. El canon

`canon/canon.toml` → `python3 canon/genera.py` → `canon/gen/canon.{py,ts,rs,wgsl,css}`.
Nunca se edita `gen/` a mano; `python3 canon/prueba.py` comprueba que el azar da
lo mismo en Python, TypeScript y Rust.

- **Papel: la crema de Scholaris**, `#F5F0E8` (hundido `#EDE6D6`, hoja `#FAF7F0`,
  noche `#1A0F0A`). Elegida por José Luis el 13-10-2026: no usar `#F2EEE4` ni
  `#F6F0E2` en nada nuevo.
- **Tinta: café ferrogálico** `#2C1810` (segunda `#5C3D2E`, lápiz `#9A8E80`).
- **Escritorio** (los tres de la Bauhaus): minio `#C1453B`, azurita `#2B4C7E`,
  amarillo `#E8A838`, oro `#D6A03A`.
- **Tambores risográficos** (solo contenido, nunca cromo): ultramar, bermellón,
  naranja, ámbar, verde, rosa, violeta, humo.
- **Azar:** semilla = FNV-1a de 32 bits sobre **UTF-8**; serie = Mulberry32;
  ruido sin estado = `hash32`. Ojo: el `azar.ts` antiguo de Scholaris usaba
  UTF-16, así que un nombre con tilde o eñe tiembla distinto que antes.
- **Oficio** (Lasseter hecho números): 220 palabras/min + 1 s de asiento para
  leer, 8 fotogramas mínimos por movimiento, 3 piezas moviéndose a la vez como
  mucho, 0,6 s de pausa al final del beat, anticipación 0,2, hervor a 8 fps con
  3 poses, lo dibujado «en dos».

Desde Python: `sys.path.insert(0, "~/Developer/torculo/canon/gen"); import canon`.

## 2. El motor (fork de ManimGX)

Venv: `~/Developer/torculo/manimgx/.venv` (Python 3.14). La API de ManimGX es la
de Manim CE (`import manimgx as m`, `class X(m.Scene)`), pero **no sus
tripas**: lee `docs/content/user-guide/coming-from-manim-ce.md` del repo.
La guía de ManimGX para agentes es `https://manimgx.academa.ai/llms.txt` (en el
repo no hay copia; su cartilla vive en `tests/docs/`); el bucle es
`manimgx inspect escena.py` → mirar el storyboard → `manimgx render escena.py`.

Lo que añade Tórculo (todo apagado por defecto: sin usarlo, el motor dibuja
exactamente como el original):

### Película y papel: la cadena de postproceso

```python
import manimgx as m
m.config.post = [m.Papel(), m.Bobina("truca")]   # el baño de la casa, dentro del motor
m.config.compositing = "linear"                  # opcional: componer en luz lineal
```

- `m.Bobina("truca")`: la bobina con la que se revela *Mujeres al borde*
  (grano, halación, vaivén, polvo, parpadeo…), leída como la lee
  `filmlook-metal`. Acepta un JSON de ajustes del laboratorio, `lut=`/`lut_in=`
  (.cube) y cualquier ajuste por nombre encima.
- `m.Papel()`: la primera etapa del baño (ganancia de punto, luz cálida, placa de
  papel multiplicada).
- `m.Pass("mi.wgsl", **params)`: cualquier pasada WGSL con la convención de
  las materias (abajo). Las de `materias/shaders/` entran tal cual.
- El grano vive también en las pausas (los fotogramas retenidos se rehacen).
- **La bobina se revela sobre su papel** (`papel=`, por defecto la crema del
  canon). La ganancia de `truca` (0,17) levanta un papel crema por encima del
  blanco: en `filmlook-metal` el #F5F0E8 sale (255, 255, 255) y sin grano.
  Aquí la exposición se mide desde el papel y el hombro lo deja sobre sí
  mismo: la tinta se levanta igual, el papel queda crema (medido: #F5F0E8 →
  (246, 242, 233) con la bobina sola, (243, 235, 226) con `Papel()` delante) y
  el grano se mueve sobre él. `m.Bobina("truca", papel=None)` revela exactamente
  como el laboratorio.
- Comparado con `filmlook-metal` (sin papel): mismo grano y halación; el
  original sangra algo más el color en los bordes y da un amarillo un poco más
  cálido.

### Materiales, mezclas, pluma y hervor

```python
circulo = m.Circle(radius=2, fill_opacity=1, color="#2B3BC7")
circulo.set_material("riso", desregistro=1.5)    # también "papel", "acuarela"
circulo.set_blend("multiplicar")                 # dos tintas que se multiplican
# set_material con un objeto Material (3D) sigue funcionando como en ManimGX

t = m.Trazo(puntos, anchos, tiempos=llegadas, grano="lapiz")  # grosor variable, puntas afiladas
t = m.Trazo.de_pluma(trazo_de_torculo_pluma)                  # la pluma de la casa, tal cual
self.play(m.Create(t))                            # se dibuja al ritmo real de la mano

viva = m.Hervor(dibujo, poses=3, fps=8, desfase=True)   # la línea que vive al reposar
```

- `m.register_material(nombre, wgsl, parametros)`: material propio; el WGSL
  define `fn materia(m: Materia) -> vec4<f32>` (ver el docstring de
  `manimgx/saorin/registro.py`: `m.pixel`, `m.local`, `m.cobertura`,
  `cobertura_en`, ruido de la casa `azar`, `ruido`, `fbm`, `fibras`, `diente`).
- Coste medido: todo encendido (materiales, pluma, multiplicar, hervor) añade
  unos 1,5 ms por fotograma a 1080p.
- `gpu=True` en `Hervor` hace el temblor en el aplanado de la GPU.
- La semilla del grano, del desregistro y del hervor de una pieza es la del
  canon (FNV-1a sobre UTF-8) de su nombre: `set_material("riso", pieza="c1_09/rosa")`;
  sin nombre, de su clase y su forma. Nunca de un contador: la misma escena
  rodada dos veces da los mismos bytes.

### Voz, foley y errores

- `m.sidecar("pliego.mesa")` (o el `.tiempos.json`, o la carpeta): la voz real
  de un pliego como voz de `self.say`, sincronizada por palabra. **Sin voz no se
  paga:** la voz por defecto da error en vez de llamar a un servicio de pago.
- Errores estrictos por defecto: LaTeX mal formado (`\frac{a}{`, `x^{2`,
  `\left(` sin cerrar) y avisos de Typst fallan en la línea de la escena.
  `--no-strict` los convierte en avisos.

### Salida

```sh
manimgx render escena.py --codec hevc        # HEVC Main10 10 bits por VideoToolbox
manimgx render escena.py --codec prores      # ProRes 422 HQ (.mov)
manimgx render escena.py --codec prores4444  # con alfa, desmultiplicada
manimgx render escena.py --take              # la toma: para la web y la biblioteca Rust
```

`h264` (x264) es el de siempre; los binarios con x264 son GPL, los de
VideoToolbox no. El HEVC entra directo en `filmlook-metal` si hiciera falta.

### Web, apps y juegos

- **Web:** `<manimgx-film src="Escena.take" autoplay>` (paquete `browser/`,
  `film.ts`): reproduce una toma grabada sin Pyodide, con WebGPU (Chrome, Edge,
  Safari 26; Firefox no).
- **Rust:** crate `rust/render` (`manimgx-render`): `Replay::read(&bytes)`,
  `replay.render(i)` → RGBA; `Gpu::from_device(...)` para dibujar en el
  dispositivo de otra app (Tauri, el laboratorio de película).
- **Todavía no:** interactividad (clics hacia la escena) y reproductor iOS/Android.
  Plan en `FORK.md` §6 y §8.

## 3. La pluma única y las láminas

`pluma/` es el puerto fiel de `pluma.ts`/`boceto.ts` de la skill
`dibujos-a-mano`: el mismo dibujo da el mismo SVG carácter a carácter y el mismo
polígono (±1e-14) en Rust, Python (`torculo_pluma`, venv en `pluma/python/.venv`)
y wasm.

Una **lámina** (`.lamina`, especificación en `pluma/LAMINA.md`) lleva atlas de
color, **atlas de tiempo** (cuándo pasó la pluma por cada píxel), 3 poses de
hervor y la geometría vectorial (ancho por punto, longitud de arco, llegada).
Se revela con una línea de shader: `clamp((progreso*1.03 - t)*60, 0, 1)`.

- Exportar desde la skill: `scripts/lamina.ts` de `dibujos-a-mano`.
- Ver: `pluma/visor/index.html` (WebGL2, sin dependencias).
- En el motor: `m.Trazo.de_pluma(...)` dibuja la geometría como trazos nativos.
- Una lámina grande pesa en GPU: `--escala 1` para dibujos de más de 600 de ancho.

## 4. Las materias (WGSL)

```sh
cd ~/Developer/torculo/materias
~/Developer/torculo/tools/pesado cargo build --release
./target/release/materia aplica -c "pincel > difuminado" -e foto.png -s pintura.png
./target/release/materia aplica -c "risografia(tambor1=rosa, tambor2=ultramar)" -e clip.mp4 -s riso.mp4
./target/release/materia transicion -t pasa_pagina -a A.png -b B.png -s paso.mp4 --dura 1.4
```

| Materia | Para qué | ms/fotograma 1080p |
|---|---|---|
| `papel` | Once papeles en unidades físicas (khadi, washi, verjurado, cuaderno…) | 3–15 |
| `acuarela` | Simulación espectral: mezcla sustractiva real, floraciones, bordes, mareas; `escribe` a pincel con tipografías Hershey | 7–19 |
| `pincel` | Foto o vídeo de archivo convertido en pintura, borde barbado | 5,5 |
| `difuminado` | Ruido anclado (pantalla, temporal, pegado al papel) | 0,5 |
| `risografia` | Separación en 2–3 tambores, trama, desregistro, multiplicar | 3–4 |
| `tejido` | Texto en punto de media que se teje punto a punto | hasta 15 |
| transiciones | Las 14 de `.mesa`: funde a negro/papel, pasa página, tijera, iris, quemado… | 0,3–2 |

Convención de pasada (la misma que lee `m.Pass`): entrada `rgba16float` lineal
premultiplicada, sampler, uniformes `{resolucion, fotograma, tiempo, semilla,
params[4]}` y salida; `#import` comparte módulos y el canon. El tiempo es el de
la escena, nunca el reloj: el render es determinista.

Límites conocidos: el pincel «nada» en planos con mucho movimiento; el tejido
no tiene aún hebras sueltas entre letras; pasa página dobla en cilindro, no en
cono; el interior de la acuarela es algo uniforme. **La licencia de
jelly-painting (origen del pincel) no consta: no publicar el pincel sin
confirmarla.**

## 5. La capa de sentido (EmbeddingGemma 2, local)

```sh
# no está en el PATH: el ejecutable vive en el venv (README.md tiene las medidas)
SENTIDO=~/Developer/torculo/sentido/.venv/bin/sentido
cd <proyecto>                                    # las rutas, relativas al proyecto
$SENTIDO voz pliego.mesa -o /tmp/voz.json        # ¿cada toma dice el texto de su beat?
$SENTIDO qc master.mp4 pliego.mesa               # ¿se ve lo que se dice? (informe HTML)
$SENTIDO diff v6.mesa v7.mesa                    # qué cambió de sentido
$SENTIDO indexa sentido/bestiario                # índice de láminas y piezas (README §1)
$SENTIDO elige «la celosía, mirar sin ser mirada» --evita «reja»
$SENTIDO resuelve pliego.mesa                    # congela pon: lámina ≈ «…» en .mesa.lock
$SENTIDO ritmo master.mp4                        # cortes y tira de ritmo
```

- Todo lo de `sentido` es pesado (carga el modelo): por `tools/pesado`.
- **`sentido voz` antes de cada tirada.** Encontró seis tomas de *Mujeres* que
  Dropbox había pisado (misma duración, otro corte de la grabación).
- `qc` sirve para saber dónde mirar, no para juzgar: lee mal el texto pequeño.
- `elige` acierta a la primera el 93 % con descripciones; con intenciones muy
  abstractas falla. Las elecciones se congelan en `<pliego>.mesa.lock`: el
  render nunca depende de lo que diga el modelo ese día.
- Motor MLX bf16. Nunca float16 (da NaN). Los pesos están en
  `~/.cache/spdf-models`, no en la caché de Hugging Face.
- **Nunca para transcribir:** eso es siempre Whisper local (skill `transcribir`).
- **El metraje real** (`sentido/metraje.py`): `sentido metraje indexa
  <carpeta>` (fotogramas a 1 fps, audio en ventanas de 3 s y frases de la
  transcripción de lo que `mesa ingesta` dejó en `.metraje/`), `sentido
  metraje busca <carpetas…> --consultas q.json` (por planos, centrado, modo
  `plano` o `frase`) y `sentido metraje ancla` (qué recorte de un fotograma
  es «la silla»). Lo llama `mesa ingesta|monta`; la lengua está en la skill
  `mesa` (EL METRAJE REAL) y en `SPEC-METRAJE.md`.

## 6. `.mesa` sobre Tórculo

El pliego no cambia: `mesa toca --motor gx` y `mesa animatica --motor gx`
ruedan el mismo pliego, con el mismo sidecar, sobre el motor nuevo (detalles en
la skill `mesa`). Medido con *La terraza no existe* entera: **463 s de reloj
frente a unas 3 h 45 min con Cairo**.

Lo que el backend toma del motor (`manim_saorin/gx/enganches.py`):

- `--bano` (o `baño truca` en el preámbulo) → `config.post = [Papel(),
  Bobina("truca", papel=canon)]` dentro del render; el baño de ffmpeg ya no.
- `--codec hevc` → HEVC Main10 por VideoToolbox (también `prores`).
- `--sonido` → la voz real la pone el motor con `m.sidecar` en el instante de
  cada beat; la mezcla suma foley, clips y suelo a esa pista.
- La `Tinta` hierve en el shader (`m.Hervor(gpu=True)`) y `trazo.pulso` es un
  `m.Trazo` nativo. Cada enganche se apaga con `MESA_GX_SIN_<NOMBRE>=1`.

Medido el 14-10-2026, capítulo de los beats c1 de *Mujeres* (113,7 s, qh, baño,
HEVC y voz): 81,6 s de render y mezcla; sincronía de la voz medida con Whisper,
mediana 0,01 s. Lo que aún delega o no porta (régimen de canción, materiales
por pieza, algunos verbos) está en `manim_saorin/gx/NOPORTA.md`.


### El 3D y Blender (SPEC-MESA-2 §3.17)

Un pliego con `ESCENARIO 3D` rueda un decorado en Blender (en segundo
plano, por capas: fondo, lo de delante, contornos) con la misma cámara del
operador, y Tórculo lo compone: la pluma (`m.Trazo`) dibuja los contornos
con hervor y las fichas son láminas plantadas en el suelo. Ojo: el ancho de
un `m.Trazo` en 3D no se encoge con la distancia (es del plano del cuadro).
`ESCENARIO 3D · en ManimGX` usa `MeshMobject` con `Material` y la luz física
del motor. Detalles en la skill `mesa` (EL ESCENARIO 3D).

## 7. Trampas

- Lanzar trabajo pesado sin `pesado` (ver §0).
- Renders y tomas dentro de Dropbox: generan copias en conflicto. Escribir fuera
  y copiar el resultado.
- `frames=`/píxeles del motor vienen **premultiplicados**: desmultiplicar antes
  de guardar PNG con alfa.
- Las tomas del fork tienen su propio rango de versión (desde `0x8000_0001`):
  el reproductor de ManimGX original no las lee.
- El determinismo es por máquina: mismo GPU y mismo motor, mismos bytes; entre
  GPU distintas, no se garantiza.
- La paleta antigua (`#F2EEE4`) sigue en código viejo de manim-saorín y
  zine-ui: lo nuevo usa siempre el canon.

## 8. La emulsión física de la película (`emulsion/`, `canon/peliculas/`)

El modelo físico de la película, a la altura de la emulsión de Osmo y con la **gelatina
Saorín** como llave de la casa. Léelo entero en `~/Developer/torculo/emulsion/README.md`.

```python
import manimgx as m
m.config.post = [m.Papel(), m.Pelicula("vision3-500t", copia="2383", formato="16")]
m.config.post = [m.Papel(), m.Pelicula("portra-400", copia="escaneo", gelatina="Saorín")]
```

```sh
cd ~/Developer/torculo/emulsion
python3 -m torculo_emulsion lista                    # 50 stocks, copias y looks
~/Developer/torculo/tools/pesado python3 -m torculo_emulsion revela vision3-500t \
    -e rodaje.mov -s copia.mov --entrada auto --gelatina Saorín --codec prores
python3 -m torculo_emulsion cube vision3-500t --entrada logc3 -o 5219.cube   # Resolve
python3 -m torculo_emulsion dctl vision3-500t --entrada s-log3 -o 5219.dctl  # con grano
python3 -m torculo_emulsion web                      # http://localhost:8765, WebGPU local
```

- **Stocks** (`canon/peliculas/<id>.toml`, cada curva con su hoja y su página): VISION3
  500T/250D/200T/50D, VISION2 500T/250D/200T/50D, EXR 50D/200T/500T, ETERNA 250D/500,
  REALA 500D, EKTACHROME 100D 7294, Double-X 5222, Tri-X 7266; copias 2383, 2393 y
  ETERNA-CP 3523XD; Portra 160/400/800, Ektar 100, Gold 200, UltraMax 400, Ektachrome
  E100, Tri-X 400, T-MAX 100/400/P3200, Velvia 50/100, Provia 100F, Superia X-TRA 400,
  Pro 400H, Fujicolor C200, Acros 100 II, HP5 Plus, FP4 Plus, Delta 100/400/3200, Pan F
  Plus, XP2 Super; CineStill 800T y 50D (VISION3 sin antihalo). Looks por categoría en
  `canon/peliculas/looks.toml`.
- **La gelatina manda:** `gelatina="Saorín"` (sobre la película: lo neutro entra en la
  .cube exactamente como entraba el vídeo; la película pone color de capas, latitud, grano
  y halación) o `gelatina_modo="delante"` (filtro delante del objetivo).
- **Un mismo grafo, tres anfitriones:** `grafo.rs` del fork (pasada de clase `grafo`),
  `materia revela` del crate `materias` y `web/grafo.js`. La ficha (JSON) trae el WGSL,
  el grafo y los números; `materias/shaders/emulsion.wgsl` es la única fuente del shader.
- **Medidas** (M4 Max, 4K, cadena entera): unos 17 ms por fotograma en la GPU (57-60 fps); en el motor, 4K con ProRes a 44 fps de punta a punta. La GPU
  cuadra con la referencia de Python a menos de 1 nivel de 8 bits.
- **Trampas:** en un dibujo, `m.Pelicula` no recupera altas luces (`altas=0`: el papel no
  es una lámpara) y mide la exposición desde el papel del canon (`papel=`); en metraje
  SDR sí las recupera (`altas`, por defecto 2), y por eso brilla la halación de los faros.
  Los log esperan el código de cámara sin estirar (`materia revela --codigos 1`, lo pone
  solo la CLI de Python). El motor necesita `TORCULO` apuntando al Tórculo con
  `emulsion/`.
