From 09890292fea75aa4cfb20f0f5b87198db6d832b1 Mon Sep 17 00:00:00 2001 From: Thiago Date: Fri, 11 Sep 2026 00:46:49 -0300 Subject: [PATCH 1/3] =?UTF-8?q?docs(hyperframes):=20agregar=20lecciones=20?= =?UTF-8?q?medidas=20y=20cuatro=20herramientas=20de=20verificaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Producir ocho piezas contra un corpus de 22 referencias de motion graphics dejó un conjunto de hallazgos que contradicen o completan lo que el skill ya decía. Todo lo que se afirma acá está medido, no estimado. Nuevo: references/lecciones-medidas.md · Las trampas del motor de captura por seek: immediateRender muerde en las dos direcciones (con false el elemento muestra su estado FINAL desde el cuadro 0), los fundidos que terminan en un límite de clip necesitan un tl.set duro, no hay obturador así que todo el motion blur es autoreado, y dos tweens sobre la misma propiedad se pisan en silencio. · La escala real de un travelling: medido sobre una referencia, el objeto pasa del 13 % al 88 % del ancho del cuadro. Un empuje de 1.0 a 1.15 no es una cámara. Umbral de percepción: 1 px de desplazamiento aparente por cuadro. · Por qué una cámara necesita textura (grano) para verse, y por qué el modo de fusión importa: overlay sobre negro devuelve negro. · Cómo se ilumina un objeto en la oscuridad: lo define su borde, no su relleno. · Ninguna medición de texto es válida antes de document.fonts.ready — offsetWidth devuelve el ancho de la tipografía de respaldo, medido 23 % menor. Alternativas por porcentaje y transformada que no dependen de ninguna métrica. · Siete layouts de composición leídos de las referencias, con el único caso en que corresponde centrar. · Ritmo (variación 3×), tiempo de lectura (17 caracteres por segundo), y las equivalencias de curva entre la literatura y GSAP: cubic out es power2.out, no power3 — la numeración de GSAP induce a este error. · Audio: el sonido va en el pico de la animación, y loudnorm controla el pico de muestra mientras el encoder AAC reconstruye picos entre muestras. Nuevo: scripts/lab/ — cuatro herramientas que sostienen esas afirmaciones · fluidez.sh mide cuántos cuadros no cambian respecto del anterior · sfx.sh sintetiza aire/click/sub/cama/riser con el pico en posición conocida · master.sh deja el audio en -16 LUFS verificando el pico real y corrigiéndose · getfont.mjs baja cualquier Google Font en woff2 y arma el @font-face Correcciones a contenido existente · references/typography.md prohibía tipografías sin advertir que el motor sólo embebe 18 familias. Siguiendo la lista tal como estaba se podía elegir una que no está embebida, y el render caía a la de respaldo en silencio. · references/motion-principles.md proponía animar letterSpacing como variación de entrada, y el propio lint del motor lo rechaza: reflowea el texto y se clava a píxeles enteros, así que tiembla bajo la captura por seek. Verificado: npm run check (695 archivos, 0 errores) y npm run sync:skills sin discrepancias entre .claude/ y .agents/. Co-Authored-By: Claude Opus 5 --- .agents/skills/hyperframes/SKILL.md | 7 + .../references/lecciones-medidas.md | 276 ++++++++++++++++++ .../references/motion-principles.md | 9 +- .../hyperframes/references/typography.md | 9 + .claude/skills/hyperframes/SKILL.md | 7 + .../references/lecciones-medidas.md | 276 ++++++++++++++++++ .../references/motion-principles.md | 9 +- .../hyperframes/references/typography.md | 9 + scripts/lab/fluidez.sh | 18 ++ scripts/lab/getfont.mjs | 56 ++++ scripts/lab/master.sh | 43 +++ scripts/lab/sfx.sh | 46 +++ 12 files changed, 763 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/hyperframes/references/lecciones-medidas.md create mode 100644 .claude/skills/hyperframes/references/lecciones-medidas.md create mode 100755 scripts/lab/fluidez.sh create mode 100644 scripts/lab/getfont.mjs create mode 100755 scripts/lab/master.sh create mode 100755 scripts/lab/sfx.sh diff --git a/.agents/skills/hyperframes/SKILL.md b/.agents/skills/hyperframes/SKILL.md index 5584493f..0cce054a 100644 --- a/.agents/skills/hyperframes/SKILL.md +++ b/.agents/skills/hyperframes/SKILL.md @@ -327,6 +327,13 @@ Skip on small edits (fixing a color, adjusting one duration). Run on new composi ## References (loaded on demand) +- **[references/lecciones-medidas.md](references/lecciones-medidas.md)** — ⭐ **Leer primero.** + Lo aprendido produciendo contra las 22 referencias del board, medido en vez de estimado: + las trampas del motor de captura por seek, la escala real de un travelling, cómo se + ilumina un objeto, por qué no se puede medir texto antes de `document.fonts.ready`, + los siete layouts de composición, el ritmo, el audio y el método de verificación. + **Donde contradiga al resto del skill, gana: está medido.** + - **[references/captions.md](references/captions.md)** — Captions, subtitles, lyrics, karaoke synced to audio. Tone-adaptive style detection, per-word styling, text overflow prevention, caption exit guarantees, word grouping. Read when adding any text synced to audio timing. - **[references/tts.md](references/tts.md)** — Text-to-speech with Kokoro-82M. Voice selection, speed tuning, TTS+captions workflow. Read when generating narration or voiceover. - **[references/audio-reactive.md](references/audio-reactive.md)** — Audio-reactive animation: map frequency bands and amplitude to GSAP properties. Read when visuals should respond to music, voice, or sound. diff --git a/.agents/skills/hyperframes/references/lecciones-medidas.md b/.agents/skills/hyperframes/references/lecciones-medidas.md new file mode 100644 index 00000000..5f1412bb --- /dev/null +++ b/.agents/skills/hyperframes/references/lecciones-medidas.md @@ -0,0 +1,276 @@ +# Lecciones medidas + +Lo que se aprendió produciendo ocho piezas contra 22 referencias reales +— 22 piezas de motion graphics de referencia, recolectadas aparte; el repo no las incluye, midiendo cada afirmación en vez de estimarla. +**Cuando algo de acá contradiga al resto del skill, gana esto: está medido.** + +--- + +## 1 · El motor captura por seek, y eso rompe cosas que parecen obvias + +### `immediateRender` muerde en las dos direcciones +| | Qué muestra el elemento ANTES de que arranque su tween | +| --- | --- | +| `from()` / `fromTo()` con el default (`true`) | su estado **inicial**, desde el cuadro 0 | +| `fromTo(..., {immediateRender:false})` | su estado **FINAL**, desde el cuadro 0 | + +Con `immediateRender:false` el elemento **tiene que nacer invisible** (`opacity:0` +en el CSS o en el `gsap.set` de carga). No es algo para recordar en cada tween: +va una vez a la hoja de estilos. + +*Costó tres apariciones del mismo bug: tarjetas ya puestas en el cuadro 0, un +puntero visible desde el principio, un botón que se veía antes de existir.* + +### Un fundido que termina en el límite de un clip necesita un `tl.set` duro +El lint lo llama `gsap_exit_missing_hard_kill`. Al saltar de cuadro, el motor +puede caer después del fundido y dejar visibilidad obsoleta. + +### No hay obturador: **todo el motion blur es autoreado** +No existe estela natural. Regla: *lo que se mueve rápido se desenfoca en la +dirección en que se mueve, y recupera el foco al frenar.* A 60 fps no es opcional. + +### Dos tweens sobre la misma propiedad: el que termina después gana +Cuando algo "no obedece", **buscar el otro tween que lo está pisando** antes de +tocar el valor. El lint avisa (`overlapping_gsap_tweens`) y hay que hacerle caso. + +--- + +## 2 · La cámara + +**Medido sobre la referencia albus**, cuánto del ancho del cuadro ocupa el objeto: + +| tiempo | ancho | +| --- | --- | +| 0.0 s | **13 %** | +| 1.3 s | 40 % | +| 2.3 s | 62 % | +| 3.8 s | **88 %** | + +**Es un travelling de 6.8× y ocupa el acto entero.** Un empuje de `1.0 → 1.15` +no es una cámara: es una imagen fija. Y el crecimiento va **cargado adelante** +(`power1.out`), no lineal. + +🔑 **El error de fondo es construir el objeto a escala web.** Con una cápsula de +760 px en un cuadro de 1920, por más que se empuje nunca llena el cuadro. Se +construye el objeto **grande** (1560 px) y se lo arranca al 13 % de escala. + +### Umbral de percepción +Una cámara necesita **≥1 px de desplazamiento aparente por cuadro** para que el +ojo la registre, y 2-3 px para que se sienta viva. Un giro de 12° en 5 s son +0.04° por cuadro: invisible. + +### Y no alcanza con moverse: hace falta TEXTURA contra la cual verlo +Un degradado radial desplazado se ve idéntico a sí mismo. La solución es la del +cine: **grano**. Una capa de `feTurbulence` a pantalla completa, corrida ~1.4 px +por cuadro, cambia todos los píxeles del cuadro. Hace tres cosas a la vez: +da referencia visual al movimiento, **rompe el banding de H.264** en degradados +oscuros, y los negros dejan de parecer un vacío digital. + +🚨 **`mix-blend-mode: overlay` sobre negro devuelve negro.** Sobre fondo oscuro va +`screen` (aditivo); sobre fondo claro va `multiply`. Con el modo equivocado el +grano está puesto y es literalmente invisible — el archivo pesa lo mismo. + +--- + +## 3 · La luz define el objeto, no el relleno + +En un cuadro oscuro **el objeto lo define su borde**. La receta medida contra la +referencia: +- contorno **continuo** de 5-7 px casi blanco +- **tres** `drop-shadow` encadenados (14 px, 44 px, 96 px) — una sola sombra da un + borde prolijo; tres dan un objeto que emite luz +- un halo grande y desenfocado detrás: es lo que lo despega del negro +- un **segundo anillo concéntrico** exterior, más tenue +- interior **más oscuro** que el borde: el filo tiene que ganarle al relleno + +**Un objeto fino no se agranda: se agranda su LUZ.** Un halo de 1720×960 hace que +un cuadro deje de leer como vacío sin tocar el objeto. + +### El bokeh son círculos, no un lavado +Discos desenfocados a ~20 % de opacidad, cada uno moviéndose a su ritmo. Un +degradado radial difuso no da profundidad de campo: da niebla. + +### En registro claro, el BLANCO es el material principal +El color es un acento que se insinúa en un borde. Cuatro manchas saturadas +cubriendo el cuadro no son un registro claro: son un papel de caramelo. + +--- + +## 4 · Medir texto: la trampa más cara + +🚨 **Ninguna medición de texto es válida antes de `document.fonts.ready`.** +Al ejecutarse el script la fuente embebida todavía no cargó, así que +`offsetWidth` devuelve el ancho de la **tipografía de respaldo**. Medido: **23 % +menos** del ancho real. Una caja calculada con ese número recorta la frase **para +siempre**, por más que la timeline esté perfecta. + +**La solución no es un factor de corrección: es no medir.** +- recorte de tipeo → `clip-path: inset(… X% …)` animado de 100 % a 0 % +- recentrado → `xPercent` de 0 a −50 (porcentaje del propio ancho) +- cursor → un **riel** del ancho del texto que se traslada `xPercent` 0→100: + llega exacto al final de la frase sin saber cuánto mide + +Lo que sí necesita medir (titulares estáticos) se encaja en +`document.fonts.ready` — toca `font-size`, nunca la timeline: + +```js +function encajar(sel, caja) { + document.querySelectorAll(sel).forEach(el => { + let px = parseFloat(getComputedStyle(el).fontSize); + const piso = px * 0.45; // fusible: si hay que bajar más, el + while (el.scrollWidth > caja && px > piso) { // problema es la medición + px -= 1; el.style.fontSize = px + 'px'; + } + }); +} +encajar('.titular', CAJA); +if (document.fonts) document.fonts.ready.then(() => encajar('.titular', CAJA)); +``` + +⚠️ **`scrollWidth` sobre un bloque de ancho 100 % mide el BLOQUE, no el texto.** +El texto a medir va en un `inline-block`. Sin eso el encaje achica la tipografía +hasta el mínimo y el titular sale del tamaño de una nota al pie. + +⚠️ **Si el texto se escala después de encajarlo, la caja debe descontar esa +escala** (`CAJA / 1.07` para una deriva de 1.07). + +### Y un límite del propio motor +**Solo hay 18 tipografías embebidas**, y el propio skill prohíbe la mayoría. +Las usables sin traer archivos: **Montserrat · Oswald · League Gothic · +Archivo Black · Space Mono · IBM Plex Mono · JetBrains Mono · Source Code Pro**. +Cualquier otra necesita un `@font-face` real con su `.woff2` — y las reglas hay +que **pegarlas inline**: un `@import` a un `.css` externo el compilador no lo ve. +`scripts/lab/getfont.mjs` baja cualquier Google Font y arma el bloque. + +--- + +## 5 · Composición del texto + +> **El texto casi nunca es lo más grande del cuadro. El OBJETO lo es.** +> El texto es una etiqueta que nombra lo que estás viendo. + +Siete layouts leídos de las referencias: + +| # | Layout | Cuándo | +| --- | --- | --- | +| 1 | etiqueta chica arriba, objeto abajo | el objeto es el argumento — el más común | +| 2 | titular anclado a un margen, sangrando | frases largas | +| 3 | cuadro partido: objeto de un lado, texto del otro | comparaciones | +| 4 | texto tapado por el objeto | da profundidad y cuesta cero | +| 5 | una palabra enorme cortada por el borde | remates | +| 6 | una forma parte el cuadro; el texto vive en una mitad | cambios de sección | +| 7 | **centrado y SOLO** | placas de título puras, sin nada más en el cuadro | + +🚨 **El 7 es el único que permite centrar, y exige que no haya nada más.** +Texto centrado con un objeto detrás no está en la lista porque **ninguna +referencia lo usa**. + +🚨 **Y no alcanza con "no centrar":** si todos los bloques comparten el mismo +anclaje —todos a la izquierda— es la misma plantilla en otro eje. Lo que se varía +es el anclaje **entre bloques y entre actos**. + +**Márgenes:** 58 px en 1080 no es un margen, es estar contra la pared (5 %). +Piso **122 px** (11 %). Sangrar el objeto sí; **cortar una palabra a la mitad, no** +— eso lee a desborde, no a recorte. + +**Jerarquía adentro del bloque:** una palabra manda, notoriamente más grande y en +el peso más pesado; las secundarias finas y **en gris**, nunca en el mismo color. +Un salto de cuerpo de 2.7× es lo que lo hace leer como jerarquía y no como lista. + +--- + +## 6 · Ritmo + +- **Variación de plano (máx ÷ mediana) ≈ 3×.** El ritmo parejo es la causa + medida de que una pieza salga lenta. El hook corta rápido, la prueba respira, + el remate vuelve a cortar. +- **`expo.out` sobre duraciones largas es una trampa:** recorre el 80 % de la + distancia en el primer 20 % del tiempo y después se arrastra. Para llegadas + fluidas, **`power2.out` sobre 0.7-0.8 s**. +- **Equivalencias de curva** (los tutoriales usan nombres de Qt; GSAP numera + distinto y es un error fácil): + +| Tutorial / Qt | GSAP | +| --- | --- | +| cubic out · `OutCubic` | **`power2.out`** (no `power3`) | +| quad ease · `InOutQuad` | `power1.inOut` | +| circ ease · `InOutCirc` | `circ.inOut` | +| `OutExpo` — para **un número que aparece** | `expo.out` | + +- **El corte cae en el ARRANQUE de un gesto, no en el medio.** Medido sobre 109 + cortes: movimiento antes del corte 0.84 (bajo el azar), después 1.55. Para un + impacto, el corte va **justo antes**, para que caiga entero en el plano nuevo. +- **Punch-in:** 10 fotogramas a 30 fps = **0.333 s**. El zoom no es el plano: es + el acento adentro del plano. +- **Pre-lap:** el acto siguiente empieza a entrar antes de que el anterior + termine. Es el J-cut hecho imagen, y es lo que hace que los actos dejen de + leerse como piezas pegadas. + +### Tiempo de lectura +**17 caracteres por segundo, contados DESPUÉS de que el texto está completo.** +23 caracteres necesitan 1.35 s de permanencia. + +⚠️ Y dar tiempo de lectura **sube los cuadros muertos**: un texto que se queda es +un cuadro que no cambia. La salida no es acortar el hold sino **sostenerlo con la +cámara** — el texto sigue acercándose mientras se lee. + +--- + +## 7 · Audio + +- **El sonido va en el PICO de la animación, no en el corte.** Y el pico no está + al principio del archivo: el whoosh mediano de biblioteca lo tiene a 200 ms del + inicio, así que hay que adelantar el arranque por ese offset. Sintetizar los + sonidos resuelve el problema de raíz: el pico queda donde uno lo pone + (`scripts/lab/sfx.sh`). +- **Dos o tres golpes en toda la pieza, nunca uno por corte.** +- **J-cut: 2.00 s de adelanto.** El sonido del acto siguiente entra dos segundos + antes que su imagen. +- **El limitador no existe dentro de HyperFrames:** va en un pase de masterizado + que **copia el video** (`-c:v copy`) y sólo re-encodea el audio. +- **−14 LUFS es para contenido denso.** Una pieza con tres acentos sobre silencio + necesita **−16**, o el normalizador comprime tanto que los golpes se comen todo. +- 🚨 **`loudnorm` controla el pico de MUESTRA; el encoder AAC reconstruye picos + ENTRE muestras y se pasa** (~1.6 dB, y depende del contenido). Hace falta un + `alimiter` después, **verificando** el resultado: `scripts/lab/master.sh` baja el + límite solo hasta cumplir. + +--- + +## 8 · Método + +### Medir la fluidez, no opinarla +`scripts/lab/fluidez.sh` compara cada cuadro con el anterior y dibuja el perfil. +**Objetivo: menos del 10 % de cuadros quietos.** El movimiento medio hay que +leerlo contra la familia de la pieza — una pieza minimalista sobre negro nunca va +a marcar como una pila de paneles iluminados. Calibración: las referencias del +board miden **3.06 de mediana**; las dos minimalistas miden 0.16 y 0.17. + +### Mirar a resolución completa, no en miniaturas +La hoja de contactos sirve para el arco. Los defectos —un elemento fantasma, un +recorte, un choque— sólo aparecen en un cuadro a resolución completa. + +### ⭐ El A/B pareado por timestamp +Extraer **el mismo instante** de la referencia y de la propia pieza y apilarlos. +Es lo que más rápido delata una copia: encuentra en un minuto diferencias de +escala, de luz y de tiempo que una hoja suelta no muestra nunca. + +### Copiar antes que inventar +Copiar una referencia 1:1 enseña más rápido que diseñar de cero, porque obliga a +notar decisiones que uno nunca se habría animado a tomar — un travelling de 6.8×, +un borde de 7 px, un color que sube hasta invertir el texto. + +--- + +## 9 · Errores propios que cuestan renders + +- **`open(p,'w')` trunca antes de escribir.** Una escritura que falla deja el + archivo vacío. Escribir a `.tmp` y `os.replace()`. +- **Nunca silenciar `stderr` en una herramienta.** Un `2>/dev/null` hizo que + `master.sh` escribiera un archivo de 0 bytes sin avisar. +- **zsh se come `:l` después de una variable** (`$LIM:level` → `0.9evel`): es su + modificador de minúsculas. Usar `${LIM}:level`. +- **Verificar que el elemento existe** (`grep -c`) antes de culpar a la animación. + Un `
` que nunca se insertó se ve exactamente igual que un tween roto. +- **Borrar un elemento sin borrar su JS mata la timeline entera.** Síntoma: + archivo minúsculo, render lentísimo, todos los cuadros idénticos. diff --git a/.agents/skills/hyperframes/references/motion-principles.md b/.agents/skills/hyperframes/references/motion-principles.md index f02a3bd5..d57dd1f7 100644 --- a/.agents/skills/hyperframes/references/motion-principles.md +++ b/.agents/skills/hyperframes/references/motion-principles.md @@ -2,11 +2,18 @@ ## Guardrails +> Ver también [lecciones-medidas.md](lecciones-medidas.md): escala real de un +> travelling, umbral de percepción de la cámara, variación de ritmo 3×, tiempo de +> lectura y equivalencias de curva (cubic out = `power2.out`, no `power3`). + You know these rules but you violate them. Stop. - **Don't use the same ease on every tween.** You default to `power2.out` on everything. Vary eases like you vary font weights — no more than 2 independent tweens with the same ease in a scene. - **Don't use the same speed on everything.** You default to 0.4-0.5s for everything. The slowest scene should be 3× slower than the fastest. Vary duration deliberately. -- **Don't enter everything from the same direction.** You default to `y: 30, opacity: 0` on every element. Vary: from left, from right, from scale, opacity-only, letter-spacing. +- **Don't enter everything from the same direction.** You default to `y: 30, opacity: 0` on every element. Vary: from left, from right, from scale, opacity-only. + 🚨 **No animar `letterSpacing`:** reflowea el texto y se clava a píxeles enteros, + así que tiembla bajo la captura por seek — el lint lo rechaza. Para el efecto de + letras que se juntan, partir en spans y animar la `x` de cada glifo. - **Don't use the same stagger on every scene.** Each scene needs its own rhythm. - **Don't use ambient zoom on every scene.** Pick different ambient motion per scene: slow pan, subtle rotation, scale push, color shift, or nothing. Stillness after motion is powerful. - **Don't start at t=0.** Offset the first animation 0.1-0.3s. Zero-delay feels like a jump cut. diff --git a/.agents/skills/hyperframes/references/typography.md b/.agents/skills/hyperframes/references/typography.md index 2a2ee52c..ac64ce64 100644 --- a/.agents/skills/hyperframes/references/typography.md +++ b/.agents/skills/hyperframes/references/typography.md @@ -2,6 +2,15 @@ The compiler embeds supported fonts — just write `font-family` in CSS. +> 🚨 **Antes de elegir: el motor sólo embebe 18 familias.** De las que esta lista +> NO prohíbe, las embebidas son: **Montserrat · Oswald · League Gothic · +> Archivo Black · Space Mono · IBM Plex Mono · JetBrains Mono · Source Code Pro**. +> Cualquier otra necesita un `@font-face` real con su `.woff2`, **pegado inline** +> (un `@import` a un `.css` externo el compilador no lo ve). +> `scripts/lab/getfont.mjs` baja cualquier Google Font y lo arma. +> Y **ninguna medición de texto es válida antes de `document.fonts.ready`** — +> ver [lecciones-medidas.md](lecciones-medidas.md) §4. + ## Banned Training-data defaults that every LLM reaches for. These produce monoculture across compositions. diff --git a/.claude/skills/hyperframes/SKILL.md b/.claude/skills/hyperframes/SKILL.md index 5584493f..0cce054a 100644 --- a/.claude/skills/hyperframes/SKILL.md +++ b/.claude/skills/hyperframes/SKILL.md @@ -327,6 +327,13 @@ Skip on small edits (fixing a color, adjusting one duration). Run on new composi ## References (loaded on demand) +- **[references/lecciones-medidas.md](references/lecciones-medidas.md)** — ⭐ **Leer primero.** + Lo aprendido produciendo contra las 22 referencias del board, medido en vez de estimado: + las trampas del motor de captura por seek, la escala real de un travelling, cómo se + ilumina un objeto, por qué no se puede medir texto antes de `document.fonts.ready`, + los siete layouts de composición, el ritmo, el audio y el método de verificación. + **Donde contradiga al resto del skill, gana: está medido.** + - **[references/captions.md](references/captions.md)** — Captions, subtitles, lyrics, karaoke synced to audio. Tone-adaptive style detection, per-word styling, text overflow prevention, caption exit guarantees, word grouping. Read when adding any text synced to audio timing. - **[references/tts.md](references/tts.md)** — Text-to-speech with Kokoro-82M. Voice selection, speed tuning, TTS+captions workflow. Read when generating narration or voiceover. - **[references/audio-reactive.md](references/audio-reactive.md)** — Audio-reactive animation: map frequency bands and amplitude to GSAP properties. Read when visuals should respond to music, voice, or sound. diff --git a/.claude/skills/hyperframes/references/lecciones-medidas.md b/.claude/skills/hyperframes/references/lecciones-medidas.md new file mode 100644 index 00000000..5f1412bb --- /dev/null +++ b/.claude/skills/hyperframes/references/lecciones-medidas.md @@ -0,0 +1,276 @@ +# Lecciones medidas + +Lo que se aprendió produciendo ocho piezas contra 22 referencias reales +— 22 piezas de motion graphics de referencia, recolectadas aparte; el repo no las incluye, midiendo cada afirmación en vez de estimarla. +**Cuando algo de acá contradiga al resto del skill, gana esto: está medido.** + +--- + +## 1 · El motor captura por seek, y eso rompe cosas que parecen obvias + +### `immediateRender` muerde en las dos direcciones +| | Qué muestra el elemento ANTES de que arranque su tween | +| --- | --- | +| `from()` / `fromTo()` con el default (`true`) | su estado **inicial**, desde el cuadro 0 | +| `fromTo(..., {immediateRender:false})` | su estado **FINAL**, desde el cuadro 0 | + +Con `immediateRender:false` el elemento **tiene que nacer invisible** (`opacity:0` +en el CSS o en el `gsap.set` de carga). No es algo para recordar en cada tween: +va una vez a la hoja de estilos. + +*Costó tres apariciones del mismo bug: tarjetas ya puestas en el cuadro 0, un +puntero visible desde el principio, un botón que se veía antes de existir.* + +### Un fundido que termina en el límite de un clip necesita un `tl.set` duro +El lint lo llama `gsap_exit_missing_hard_kill`. Al saltar de cuadro, el motor +puede caer después del fundido y dejar visibilidad obsoleta. + +### No hay obturador: **todo el motion blur es autoreado** +No existe estela natural. Regla: *lo que se mueve rápido se desenfoca en la +dirección en que se mueve, y recupera el foco al frenar.* A 60 fps no es opcional. + +### Dos tweens sobre la misma propiedad: el que termina después gana +Cuando algo "no obedece", **buscar el otro tween que lo está pisando** antes de +tocar el valor. El lint avisa (`overlapping_gsap_tweens`) y hay que hacerle caso. + +--- + +## 2 · La cámara + +**Medido sobre la referencia albus**, cuánto del ancho del cuadro ocupa el objeto: + +| tiempo | ancho | +| --- | --- | +| 0.0 s | **13 %** | +| 1.3 s | 40 % | +| 2.3 s | 62 % | +| 3.8 s | **88 %** | + +**Es un travelling de 6.8× y ocupa el acto entero.** Un empuje de `1.0 → 1.15` +no es una cámara: es una imagen fija. Y el crecimiento va **cargado adelante** +(`power1.out`), no lineal. + +🔑 **El error de fondo es construir el objeto a escala web.** Con una cápsula de +760 px en un cuadro de 1920, por más que se empuje nunca llena el cuadro. Se +construye el objeto **grande** (1560 px) y se lo arranca al 13 % de escala. + +### Umbral de percepción +Una cámara necesita **≥1 px de desplazamiento aparente por cuadro** para que el +ojo la registre, y 2-3 px para que se sienta viva. Un giro de 12° en 5 s son +0.04° por cuadro: invisible. + +### Y no alcanza con moverse: hace falta TEXTURA contra la cual verlo +Un degradado radial desplazado se ve idéntico a sí mismo. La solución es la del +cine: **grano**. Una capa de `feTurbulence` a pantalla completa, corrida ~1.4 px +por cuadro, cambia todos los píxeles del cuadro. Hace tres cosas a la vez: +da referencia visual al movimiento, **rompe el banding de H.264** en degradados +oscuros, y los negros dejan de parecer un vacío digital. + +🚨 **`mix-blend-mode: overlay` sobre negro devuelve negro.** Sobre fondo oscuro va +`screen` (aditivo); sobre fondo claro va `multiply`. Con el modo equivocado el +grano está puesto y es literalmente invisible — el archivo pesa lo mismo. + +--- + +## 3 · La luz define el objeto, no el relleno + +En un cuadro oscuro **el objeto lo define su borde**. La receta medida contra la +referencia: +- contorno **continuo** de 5-7 px casi blanco +- **tres** `drop-shadow` encadenados (14 px, 44 px, 96 px) — una sola sombra da un + borde prolijo; tres dan un objeto que emite luz +- un halo grande y desenfocado detrás: es lo que lo despega del negro +- un **segundo anillo concéntrico** exterior, más tenue +- interior **más oscuro** que el borde: el filo tiene que ganarle al relleno + +**Un objeto fino no se agranda: se agranda su LUZ.** Un halo de 1720×960 hace que +un cuadro deje de leer como vacío sin tocar el objeto. + +### El bokeh son círculos, no un lavado +Discos desenfocados a ~20 % de opacidad, cada uno moviéndose a su ritmo. Un +degradado radial difuso no da profundidad de campo: da niebla. + +### En registro claro, el BLANCO es el material principal +El color es un acento que se insinúa en un borde. Cuatro manchas saturadas +cubriendo el cuadro no son un registro claro: son un papel de caramelo. + +--- + +## 4 · Medir texto: la trampa más cara + +🚨 **Ninguna medición de texto es válida antes de `document.fonts.ready`.** +Al ejecutarse el script la fuente embebida todavía no cargó, así que +`offsetWidth` devuelve el ancho de la **tipografía de respaldo**. Medido: **23 % +menos** del ancho real. Una caja calculada con ese número recorta la frase **para +siempre**, por más que la timeline esté perfecta. + +**La solución no es un factor de corrección: es no medir.** +- recorte de tipeo → `clip-path: inset(… X% …)` animado de 100 % a 0 % +- recentrado → `xPercent` de 0 a −50 (porcentaje del propio ancho) +- cursor → un **riel** del ancho del texto que se traslada `xPercent` 0→100: + llega exacto al final de la frase sin saber cuánto mide + +Lo que sí necesita medir (titulares estáticos) se encaja en +`document.fonts.ready` — toca `font-size`, nunca la timeline: + +```js +function encajar(sel, caja) { + document.querySelectorAll(sel).forEach(el => { + let px = parseFloat(getComputedStyle(el).fontSize); + const piso = px * 0.45; // fusible: si hay que bajar más, el + while (el.scrollWidth > caja && px > piso) { // problema es la medición + px -= 1; el.style.fontSize = px + 'px'; + } + }); +} +encajar('.titular', CAJA); +if (document.fonts) document.fonts.ready.then(() => encajar('.titular', CAJA)); +``` + +⚠️ **`scrollWidth` sobre un bloque de ancho 100 % mide el BLOQUE, no el texto.** +El texto a medir va en un `inline-block`. Sin eso el encaje achica la tipografía +hasta el mínimo y el titular sale del tamaño de una nota al pie. + +⚠️ **Si el texto se escala después de encajarlo, la caja debe descontar esa +escala** (`CAJA / 1.07` para una deriva de 1.07). + +### Y un límite del propio motor +**Solo hay 18 tipografías embebidas**, y el propio skill prohíbe la mayoría. +Las usables sin traer archivos: **Montserrat · Oswald · League Gothic · +Archivo Black · Space Mono · IBM Plex Mono · JetBrains Mono · Source Code Pro**. +Cualquier otra necesita un `@font-face` real con su `.woff2` — y las reglas hay +que **pegarlas inline**: un `@import` a un `.css` externo el compilador no lo ve. +`scripts/lab/getfont.mjs` baja cualquier Google Font y arma el bloque. + +--- + +## 5 · Composición del texto + +> **El texto casi nunca es lo más grande del cuadro. El OBJETO lo es.** +> El texto es una etiqueta que nombra lo que estás viendo. + +Siete layouts leídos de las referencias: + +| # | Layout | Cuándo | +| --- | --- | --- | +| 1 | etiqueta chica arriba, objeto abajo | el objeto es el argumento — el más común | +| 2 | titular anclado a un margen, sangrando | frases largas | +| 3 | cuadro partido: objeto de un lado, texto del otro | comparaciones | +| 4 | texto tapado por el objeto | da profundidad y cuesta cero | +| 5 | una palabra enorme cortada por el borde | remates | +| 6 | una forma parte el cuadro; el texto vive en una mitad | cambios de sección | +| 7 | **centrado y SOLO** | placas de título puras, sin nada más en el cuadro | + +🚨 **El 7 es el único que permite centrar, y exige que no haya nada más.** +Texto centrado con un objeto detrás no está en la lista porque **ninguna +referencia lo usa**. + +🚨 **Y no alcanza con "no centrar":** si todos los bloques comparten el mismo +anclaje —todos a la izquierda— es la misma plantilla en otro eje. Lo que se varía +es el anclaje **entre bloques y entre actos**. + +**Márgenes:** 58 px en 1080 no es un margen, es estar contra la pared (5 %). +Piso **122 px** (11 %). Sangrar el objeto sí; **cortar una palabra a la mitad, no** +— eso lee a desborde, no a recorte. + +**Jerarquía adentro del bloque:** una palabra manda, notoriamente más grande y en +el peso más pesado; las secundarias finas y **en gris**, nunca en el mismo color. +Un salto de cuerpo de 2.7× es lo que lo hace leer como jerarquía y no como lista. + +--- + +## 6 · Ritmo + +- **Variación de plano (máx ÷ mediana) ≈ 3×.** El ritmo parejo es la causa + medida de que una pieza salga lenta. El hook corta rápido, la prueba respira, + el remate vuelve a cortar. +- **`expo.out` sobre duraciones largas es una trampa:** recorre el 80 % de la + distancia en el primer 20 % del tiempo y después se arrastra. Para llegadas + fluidas, **`power2.out` sobre 0.7-0.8 s**. +- **Equivalencias de curva** (los tutoriales usan nombres de Qt; GSAP numera + distinto y es un error fácil): + +| Tutorial / Qt | GSAP | +| --- | --- | +| cubic out · `OutCubic` | **`power2.out`** (no `power3`) | +| quad ease · `InOutQuad` | `power1.inOut` | +| circ ease · `InOutCirc` | `circ.inOut` | +| `OutExpo` — para **un número que aparece** | `expo.out` | + +- **El corte cae en el ARRANQUE de un gesto, no en el medio.** Medido sobre 109 + cortes: movimiento antes del corte 0.84 (bajo el azar), después 1.55. Para un + impacto, el corte va **justo antes**, para que caiga entero en el plano nuevo. +- **Punch-in:** 10 fotogramas a 30 fps = **0.333 s**. El zoom no es el plano: es + el acento adentro del plano. +- **Pre-lap:** el acto siguiente empieza a entrar antes de que el anterior + termine. Es el J-cut hecho imagen, y es lo que hace que los actos dejen de + leerse como piezas pegadas. + +### Tiempo de lectura +**17 caracteres por segundo, contados DESPUÉS de que el texto está completo.** +23 caracteres necesitan 1.35 s de permanencia. + +⚠️ Y dar tiempo de lectura **sube los cuadros muertos**: un texto que se queda es +un cuadro que no cambia. La salida no es acortar el hold sino **sostenerlo con la +cámara** — el texto sigue acercándose mientras se lee. + +--- + +## 7 · Audio + +- **El sonido va en el PICO de la animación, no en el corte.** Y el pico no está + al principio del archivo: el whoosh mediano de biblioteca lo tiene a 200 ms del + inicio, así que hay que adelantar el arranque por ese offset. Sintetizar los + sonidos resuelve el problema de raíz: el pico queda donde uno lo pone + (`scripts/lab/sfx.sh`). +- **Dos o tres golpes en toda la pieza, nunca uno por corte.** +- **J-cut: 2.00 s de adelanto.** El sonido del acto siguiente entra dos segundos + antes que su imagen. +- **El limitador no existe dentro de HyperFrames:** va en un pase de masterizado + que **copia el video** (`-c:v copy`) y sólo re-encodea el audio. +- **−14 LUFS es para contenido denso.** Una pieza con tres acentos sobre silencio + necesita **−16**, o el normalizador comprime tanto que los golpes se comen todo. +- 🚨 **`loudnorm` controla el pico de MUESTRA; el encoder AAC reconstruye picos + ENTRE muestras y se pasa** (~1.6 dB, y depende del contenido). Hace falta un + `alimiter` después, **verificando** el resultado: `scripts/lab/master.sh` baja el + límite solo hasta cumplir. + +--- + +## 8 · Método + +### Medir la fluidez, no opinarla +`scripts/lab/fluidez.sh` compara cada cuadro con el anterior y dibuja el perfil. +**Objetivo: menos del 10 % de cuadros quietos.** El movimiento medio hay que +leerlo contra la familia de la pieza — una pieza minimalista sobre negro nunca va +a marcar como una pila de paneles iluminados. Calibración: las referencias del +board miden **3.06 de mediana**; las dos minimalistas miden 0.16 y 0.17. + +### Mirar a resolución completa, no en miniaturas +La hoja de contactos sirve para el arco. Los defectos —un elemento fantasma, un +recorte, un choque— sólo aparecen en un cuadro a resolución completa. + +### ⭐ El A/B pareado por timestamp +Extraer **el mismo instante** de la referencia y de la propia pieza y apilarlos. +Es lo que más rápido delata una copia: encuentra en un minuto diferencias de +escala, de luz y de tiempo que una hoja suelta no muestra nunca. + +### Copiar antes que inventar +Copiar una referencia 1:1 enseña más rápido que diseñar de cero, porque obliga a +notar decisiones que uno nunca se habría animado a tomar — un travelling de 6.8×, +un borde de 7 px, un color que sube hasta invertir el texto. + +--- + +## 9 · Errores propios que cuestan renders + +- **`open(p,'w')` trunca antes de escribir.** Una escritura que falla deja el + archivo vacío. Escribir a `.tmp` y `os.replace()`. +- **Nunca silenciar `stderr` en una herramienta.** Un `2>/dev/null` hizo que + `master.sh` escribiera un archivo de 0 bytes sin avisar. +- **zsh se come `:l` después de una variable** (`$LIM:level` → `0.9evel`): es su + modificador de minúsculas. Usar `${LIM}:level`. +- **Verificar que el elemento existe** (`grep -c`) antes de culpar a la animación. + Un `
` que nunca se insertó se ve exactamente igual que un tween roto. +- **Borrar un elemento sin borrar su JS mata la timeline entera.** Síntoma: + archivo minúsculo, render lentísimo, todos los cuadros idénticos. diff --git a/.claude/skills/hyperframes/references/motion-principles.md b/.claude/skills/hyperframes/references/motion-principles.md index f02a3bd5..d57dd1f7 100644 --- a/.claude/skills/hyperframes/references/motion-principles.md +++ b/.claude/skills/hyperframes/references/motion-principles.md @@ -2,11 +2,18 @@ ## Guardrails +> Ver también [lecciones-medidas.md](lecciones-medidas.md): escala real de un +> travelling, umbral de percepción de la cámara, variación de ritmo 3×, tiempo de +> lectura y equivalencias de curva (cubic out = `power2.out`, no `power3`). + You know these rules but you violate them. Stop. - **Don't use the same ease on every tween.** You default to `power2.out` on everything. Vary eases like you vary font weights — no more than 2 independent tweens with the same ease in a scene. - **Don't use the same speed on everything.** You default to 0.4-0.5s for everything. The slowest scene should be 3× slower than the fastest. Vary duration deliberately. -- **Don't enter everything from the same direction.** You default to `y: 30, opacity: 0` on every element. Vary: from left, from right, from scale, opacity-only, letter-spacing. +- **Don't enter everything from the same direction.** You default to `y: 30, opacity: 0` on every element. Vary: from left, from right, from scale, opacity-only. + 🚨 **No animar `letterSpacing`:** reflowea el texto y se clava a píxeles enteros, + así que tiembla bajo la captura por seek — el lint lo rechaza. Para el efecto de + letras que se juntan, partir en spans y animar la `x` de cada glifo. - **Don't use the same stagger on every scene.** Each scene needs its own rhythm. - **Don't use ambient zoom on every scene.** Pick different ambient motion per scene: slow pan, subtle rotation, scale push, color shift, or nothing. Stillness after motion is powerful. - **Don't start at t=0.** Offset the first animation 0.1-0.3s. Zero-delay feels like a jump cut. diff --git a/.claude/skills/hyperframes/references/typography.md b/.claude/skills/hyperframes/references/typography.md index 2a2ee52c..ac64ce64 100644 --- a/.claude/skills/hyperframes/references/typography.md +++ b/.claude/skills/hyperframes/references/typography.md @@ -2,6 +2,15 @@ The compiler embeds supported fonts — just write `font-family` in CSS. +> 🚨 **Antes de elegir: el motor sólo embebe 18 familias.** De las que esta lista +> NO prohíbe, las embebidas son: **Montserrat · Oswald · League Gothic · +> Archivo Black · Space Mono · IBM Plex Mono · JetBrains Mono · Source Code Pro**. +> Cualquier otra necesita un `@font-face` real con su `.woff2`, **pegado inline** +> (un `@import` a un `.css` externo el compilador no lo ve). +> `scripts/lab/getfont.mjs` baja cualquier Google Font y lo arma. +> Y **ninguna medición de texto es válida antes de `document.fonts.ready`** — +> ver [lecciones-medidas.md](lecciones-medidas.md) §4. + ## Banned Training-data defaults that every LLM reaches for. These produce monoculture across compositions. diff --git a/scripts/lab/fluidez.sh b/scripts/lab/fluidez.sh new file mode 100755 index 00000000..92337fd3 --- /dev/null +++ b/scripts/lab/fluidez.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# fluidez.sh — mide cuánto cambia cada cuadro respecto del anterior. +# El motor captura por seek: si un tramo no cambia, el ojo lo lee como "lento". +for f in "$@"; do + ffmpeg -v error -i "$f" -vf "tblend=all_mode=difference,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-" \ + -f null - 2>/dev/null | grep -o 'YAVG=[0-9.]*' | cut -d= -f2 > /tmp/_fl.txt + python3 - "$f" <<'PY' +import sys +vals=[float(x) for x in open('/tmp/_fl.txt') if x.strip()] +if not vals: print("sin datos"); raise SystemExit +n=len(vals); dead=sum(1 for v in vals if v<0.35) +print(f"{sys.argv[1]}") +print(f" {n} cuadros · movimiento medio {sum(vals)/n:.2f} · quietos {dead/n*100:.0f}%") +step=max(1,n//24) +print(' '+''.join('█' if (m:=sum(vals[i:i+step])/len(vals[i:i+step]))>2 else '▓' if m>1 else '▒' if m>.35 else '·' + for i in range(0,n,step))) +PY +done diff --git a/scripts/lab/getfont.mjs b/scripts/lab/getfont.mjs new file mode 100644 index 00000000..6371f741 --- /dev/null +++ b/scripts/lab/getfont.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +// getfont.mjs — download Google Font woff2 files into a project and print @font-face CSS. +// Usage: node getfont.mjs "Instrument Sans" 400,700 [italic] +// HyperFrames only bundles 18 families; anything else needs a real @font-face + local file. +import { mkdir, writeFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +const [dir, family, weightsArg = '400', italic] = process.argv.slice(2); +if (!dir || !family) { + console.error('usage: node getfont.mjs "Family Name" [weights] [italic]'); + process.exit(1); +} +const weights = weightsArg.split(',').map(w => w.trim()).filter(Boolean); +const ital = italic === 'italic'; + +// woff2 requires a modern UA; Google serves ttf to unknown agents. +const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'; +const spec = ital + ? `${family}:ital,wght@${weights.map(w => `1,${w}`).join(';')}` + : `${family}:wght@${weights.join(';')}`; +const cssUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(spec)}&display=swap`; + +const res = await fetch(cssUrl, { headers: { 'User-Agent': UA } }); +if (!res.ok) { console.error(`Google Fonts said ${res.status} for "${family}" — check the exact family name.`); process.exit(1); } +const css = await res.text(); + +// Keep only the latin block; other subsets bloat the project for no visible gain. +const blocks = [...css.matchAll(/\/\*\s*([\w-\[\]]+)\s*\*\/\s*@font-face\s*\{([^}]+)\}/g)] + .filter(m => m[1] === 'latin' || m[1] === 'latin-ext'); +if (!blocks.length) { console.error('No latin @font-face blocks returned. Is the family/weight combination valid?'); process.exit(1); } + +const slug = family.toLowerCase().replace(/\s+/g, '-'); +const fontsDir = join(dir, 'assets', 'fonts'); +await mkdir(fontsDir, { recursive: true }); + +const out = []; +for (const [, subset, body] of blocks) { + const url = body.match(/url\((https:[^)]+\.woff2)\)/)?.[1]; + const weight = body.match(/font-weight:\s*([^;]+);/)?.[1].trim() ?? '400'; + const style = body.match(/font-style:\s*([^;]+);/)?.[1].trim() ?? 'normal'; + const unicode = body.match(/unicode-range:\s*([^;]+);/)?.[1].trim(); + if (!url) continue; + + const name = `${slug}-${weight.replace(/\s+/g, '')}-${style}-${subset}.woff2`; + const bin = await fetch(url, { headers: { 'User-Agent': UA } }); + await writeFile(join(fontsDir, name), Buffer.from(await bin.arrayBuffer())); + + out.push(`@font-face{font-family:'${family}';font-style:${style};font-weight:${weight};font-display:block;` + + `src:url('assets/fonts/${name}') format('woff2');` + + (unicode ? `unicode-range:${unicode};` : '') + `}`); + console.error(` ✓ ${name} (${(bin.headers.get('content-length') ?? '?')} bytes)`); +} + +console.error(`\nPaste into