/* kit/gerativo.jsx — o motion gerativo: UM componente que desenha a
 * partitura inteira (lib/estudio/gerativo.js), quadro a quadro.
 *
 * Não há cena, não há template: há a linha do tempo. Em cada quadro este
 * componente pergunta à partitura que batidas estão no ar, onde está a
 * câmera, em que forma está o acento — e desenha. Tudo é função do tempo:
 * nada de Math.random, Date.now nem transição CSS.
 *
 * A letra é a unidade: cada uma é um <span> com o próprio transform e os
 * próprios EIXOS da fonte variável (font-variation-settings) — o peso que vai
 * de 100 a 900, a largura que estica, o grau que respira. Antes do primeiro
 * quadro as fontes carregam e cada linha é MEDIDA no navegador (delayRender):
 * o que não cabe encolhe, e o acento sabe onde fica cada palavra. */
import React, { useState, useEffect, useMemo } from 'react';
import { delayRender, continueRender, Img, staticFile } from 'remotion';
import { Animacao } from './animacao';
import { SNAPPY, CURVAS_KIT, acaso, suave, alfa, mistura } from './base';
import { Produto3D, Icone3D, Heroi3D } from './tres';

const ed = CURVAS_KIT.editorial, curvaCam = CURVAS_KIT.camera, arrasto = CURVAS_KIT.arrasto, acelera = CURVAS_KIT.acelera;
const c01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
const lerp = (a, b, k) => a + (b - a) * k;
const entradaC = (t) => t * t * t;
const saidaC = (t) => 1 - Math.pow(1 - t, 3);

/* A mola SNAPPY do guia em forma fechada (o spring() do Remotion simula
   quadro a quadro; aqui são milhares de letras por quadro). Amortecimento
   26 com massa 0,65: um pouco acima do crítico — ataque rápido, sem quique. */
const MOLA = (() => {
  const { damping: c, stiffness: k, mass: m } = SNAPPY;
  const w0 = Math.sqrt(k / m), z = c / (2 * Math.sqrt(k * m));
  if (z > 1.0001) {
    const s = Math.sqrt(z * z - 1), r1 = -w0 * (z - s), r2 = -w0 * (z + s);
    const A = r2 / (r1 - r2), B = -r1 / (r1 - r2);
    return (t) => (t <= 0 ? 0 : 1 + A * Math.exp(r1 * t) + B * Math.exp(r2 * t));
  }
  return (t) => (t <= 0 ? 0 : 1 - Math.exp(-w0 * t) * (1 + w0 * t));
})();

const EMBARALHA = 'ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&*+=/<>';
const embaralha = (n) => EMBARALHA[Math.floor(acaso(n) * EMBARALHA.length) % EMBARALHA.length];

// ── os eixos ────────────────────────────────────────────────────────────
function fvs(eixosFonte, valores) {
  const partes = [];
  for (const tag of Object.keys(valores)) {
    const faixa = eixosFonte && eixosFonte[tag];
    if (!faixa) continue;
    const v = Math.max(faixa[0], Math.min(faixa[1], valores[tag]));
    partes.push('"' + tag + '" ' + Math.round(v * 10) / 10);
  }
  return partes.join(', ') || 'normal';
}
const textoDaLinha = (l) => (l.maiusc ? l.texto.toUpperCase() : l.texto);
const palavrasDaLinha = (l) => {
  const ws = textoDaLinha(l).split(/\s+/).filter(Boolean);
  return l.palavras.map((p, j) => ({ ...p, texto: ws[j] != null ? ws[j] : p.texto }));
};
const pesoDaPalavra = (l, p) => (p.wght != null ? p.wght : l.eixos.wght);
const familia = (f) => '"' + f + '", system-ui, sans-serif';

/* A marcação de uma linha: a MESMA na medida e no desenho — é assim que a
   medida vale para o que aparece. */
function Marcacao({ l, eixosFonte, estiloLetra, estiloPalavra, extraLetra }) {
  const ps = palavrasDaLinha(l);
  let k = 0;
  return ps.map((p, j) => {
    const letras = Array.from(p.texto);
    const ep = estiloPalavra ? estiloPalavra(p, j) : {};
    const out = (
      <span key={j} style={{ display: 'inline-block', fontSize: (p.escala || 1) + 'em',
        fontVariationSettings: fvs(eixosFonte, { ...l.eixos, wght: pesoDaPalavra(l, p) }), ...ep }}>
        {letras.map((ch, i) => {
          const idx = k++;
          const e = estiloLetra ? estiloLetra(p, j, ch, i, idx) : null;
          return (
            <span key={i} style={{ display: 'inline-block', position: 'relative', ...(e && e.estilo) }}>
              {e && e.char != null ? e.char : ch}
              {extraLetra ? extraLetra(p, j, i, idx) : null}
            </span>
          );
        })}
      </span>
    );
    return j < ps.length - 1 ? [out, <span key={'s' + j}>{' '}</span>] : out;
  });
}

// ── a medida (uma vez por aba do render) ─────────────────────────────────
function medir(P) {
  const caixa = document.createElement('div');
  caixa.style.cssText = 'position:absolute;left:-99999px;top:0;visibility:hidden;';
  document.body.appendChild(caixa);
  const out = {};
  const linha = (id, l, fonteForcada) => {
    const d = document.createElement('div');
    d.style.cssText = 'position:absolute;white-space:pre;line-height:1;font-size:100px;';
    d.style.fontFamily = familia(fonteForcada || l.fonte);
    d.style.letterSpacing = (l.tracking || 0) + 'em';
    d.style.fontFeatureSettings = '"tnum" 1';
    const eixos = P.eixos[l.fonte] || {};
    const ps = palavrasDaLinha(l);
    ps.forEach((p, j) => {
      const w = document.createElement('span');
      w.style.display = 'inline-block';
      w.style.fontSize = (p.escala || 1) + 'em';
      w.style.fontVariationSettings = fvs(eixos, { ...l.eixos, wght: pesoDaPalavra(l, p) });
      for (const ch of Array.from(p.texto)) {
        const s = document.createElement('span');
        s.style.display = 'inline-block';
        s.textContent = ch;
        w.appendChild(s);
      }
      d.appendChild(w);
      if (j < ps.length - 1) d.appendChild(document.createTextNode(' '));
    });
    caixa.appendChild(d);
    const r0 = d.getBoundingClientRect();
    const palavras = [], letras = [];
    for (const w of d.querySelectorAll(':scope > span')) {
      const r = w.getBoundingClientRect();
      palavras.push({ x: (r.left - r0.left) / 100, w: r.width / 100 });
      for (const s of w.querySelectorAll('span')) { const q = s.getBoundingClientRect(); letras.push({ x: (q.left - r0.left) / 100, w: q.width / 100 }); }
    }
    out[id] = { w: r0.width / 100, h: r0.height / 100, palavras, letras };
  };
  for (const b of P.batidas) {
    for (const l of b.linhas) linha(l.id, l);
    if (b.textura) linha(b.id + '-textura', { ...b.linhas[0], texto: b.textura.texto + '  ·  ', maiusc: false, palavras: (b.textura.texto + ' ·').split(/\s+/).map((t) => ({ texto: t })) });
  }
  document.body.removeChild(caixa);
  return out;
}

function useMedidas(P) {
  const [medidas, setMedidas] = useState(null);
  const [alca] = useState(() => delayRender('gerativo: carregar as fontes e medir as linhas'));
  useEffect(() => {
    let vivo = true;
    const texto = Array.from(new Set(P.batidas.flatMap((b) => b.linhas.map((l) => textoDaLinha(l) + l.texto)).join('') + '0123456789 ·—“”')).join('');
    const fams = Array.from(new Set(Object.values(P.fontes)));
    const carregar = () => Promise.all(fams.flatMap((f) => [100, 400, 900].map((w) => document.fonts.load(w + ' 100px "' + f + '"', texto).catch(() => null))));
    carregar().then(() => document.fonts.ready).then(() => { if (vivo) setMedidas(medir(P)); })
      .catch(() => { if (vivo) setMedidas(medir(P)); });
    return () => { vivo = false; };
  }, []);
  useEffect(() => { if (medidas) continueRender(alca); }, [medidas]);
  return medidas;
}

/* ── a área segura: o bloco MEDIDO cabe em b.seguro ──────────────────────
   b.seguro vem do planner (margem.limitesNaCamera): é onde, no mundo, o
   bloco pode ficar para não encostar na borda da tela com a câmera daquela
   batida. Largo demais: encolhe tudo junto (fonte e posição) em volta do
   centro; fora: anda para dentro. A barra do acento mora à esquerda do bloco
   e o ponto final à direita: contam como parte dele. Partitura antiga (sem
   b.seguro): como antes. */
function encaixar(b, caixas) {
  const seg = b.seguro;
  const cs = Object.values(caixas);
  if (!seg || !cs.length) return;
  const sMax = Math.max(...cs.map((c) => c.s));
  const temBarra = (b.acento || []).some((a) => a.forma === 'barra');
  const x0 = Math.min(...cs.map((c) => c.x)), y0 = Math.min(...cs.map((c) => c.y));
  const x1 = Math.max(...cs.map((c) => c.x + c.w)), y1 = Math.max(...cs.map((c) => c.y + c.h));
  const deriva = b.vida === 'deriva' ? (x1 - x0) * 0.03 : 0;
  const esq = (temBarra ? Math.max(18, (b.vao || 0) * 0.7, (y1 - y0) * 0.08) + 8 : 0) + sMax * 0.04 + deriva, dir = sMax * 0.22 + deriva;
  const k = Math.min(1, (seg.x1 - seg.x0) / (x1 - x0 + esq + dir), (seg.y1 - seg.y0) / (y1 - y0));
  const ox = (x0 + x1) / 2, oy = (y0 + y1) / 2;
  const nx0 = ox + (x0 - ox) * k - esq * k, nx1 = ox + (x1 - ox) * k + dir * k;
  const ny0 = oy + (y0 - oy) * k, ny1 = oy + (y1 - oy) * k;
  const dx = nx0 < seg.x0 ? seg.x0 - nx0 : nx1 > seg.x1 ? seg.x1 - nx1 : 0;
  const dy = ny0 < seg.y0 ? seg.y0 - ny0 : ny1 > seg.y1 ? seg.y1 - ny1 : 0;
  if (k > 0.999 && !dx && !dy) return;
  const X = (x) => ox + (x - ox) * k + dx, Y = (y) => oy + (y - oy) * k + dy;
  for (const c of cs) {
    c.x = X(c.x); c.y = Y(c.y); c.w *= k; c.h *= k; c.s *= k;
    c.palavras = c.palavras.map((p) => ({ x: X(p.x), w: p.w * k }));
    c.letras = c.letras.map((q) => ({ x: X(q.x), w: q.w * k }));
  }
}

// ── o leiaute: da medida às caixas no mundo ─────────────────────────────
function leiaute(P, med) {
  const W = P.largura, H = P.altura;
  const porBatida = {};
  for (const b of P.batidas) {
    const bl = b.bloco;
    const caixas = {};
    /* O tamanho final: o que não cabe encolhe; o grupo que ENCHE cresce até a
       largura do bloco (todas as linhas dele no mesmo tamanho), com teto. */
    const grupo = {};
    for (const l of b.linhas) {
      if (!l.encher || !med[l.id]) continue;
      const k = l.encher + (l.coluna != null ? l.coluna : '');
      // O grid manda: a tese ocupa até ~74% da largura do bloco; só a sangria vai a 97%.
      /* (10/10) A tese gigante, a parede e a marca ENCHEM o bloco (97%): com fonte
         larga, os 74% de antes paravam "CLAUDE CODE" em 9% da altura. O resto, 80%. */
      const s = Math.min(l.max || l.tamanho, (bl.largura * (b.cheio || ['gigante', 'parede', 'marca'].includes(b.comp) ? 0.97 : 0.8)) / med[l.id].w);
      grupo[k] = grupo[k] == null ? s : Math.min(grupo[k], s);
    }
    const tam = (l) => {
      const m = med[l.id];
      if (l.encher && grupo[l.encher + (l.coluna != null ? l.coluna : '')] != null) return grupo[l.encher + (l.coluna != null ? l.coluna : '')];
      let s = l.tamanho;
      if (m && m.w * s > bl.largura) s = (bl.largura / m.w) * 0.985;
      return s;
    };
    const gapEntre = (a, c) => {
      if (c.continua) return 0.1 * tam(a);
      if (a.papel === c.papel) return a.papel === 'item' ? bl.gap * tam(a) : a.papel === 'tese' || a.papel === 'numero' ? 0.02 * tam(a) : 0.14 * tam(a);
      return Math.max(0.32 * Math.min(tam(a), tam(c)), 0.12 * Math.max(tam(a), tam(c)));
    };
    const pilha = (ls, x0, yCentro, alinha) => {
      const vis = ls.filter((l) => l.papel !== 'aspas');
      let total = 0;
      vis.forEach((l, i) => { total += (med[l.id] ? med[l.id].h : 1) * tam(l); if (i) total += gapEntre(vis[i - 1], l); });
      let y = yCentro - (bl.vert === 'meio' ? total / 2 : 0);
      vis.forEach((l, i) => {
        if (i) y += gapEntre(vis[i - 1], l);
        const s = tam(l), m = med[l.id] || { w: 1, h: 1, palavras: [], letras: [] };
        const w = m.w * s, h = m.h * s;
        const x = alinha === 'centro' ? x0 - w / 2 : x0;
        caixas[l.id] = { x, y, w, h, s, palavras: m.palavras.map((p) => ({ x: x + p.x * s, w: p.w * s })), letras: m.letras.map((q) => ({ x: x + q.x * s, w: q.w * s })) };
        y += h;
      });
      return total;
    };
    if (bl.colunas) {
      pilha(b.linhas.filter((l) => l.coluna === 0), bl.colunas[0], bl.y, 'esq');
      pilha(b.linhas.filter((l) => l.coluna === 1), bl.colunas[1], bl.y, 'esq');
    } else if (bl.blocos === 2) {
      const a = b.linhas.filter((l) => l.coluna === 0), c = b.linhas.filter((l) => l.coluna === 1);
      const alt = (ls) => ls.reduce((n, l, i) => n + (med[l.id] ? med[l.id].h : 1) * tam(l) + (i ? gapEntre(ls[i - 1], l) : 0), 0);
      const hA = alt(a), hC = alt(c), vao = 0.9 * tam(a[0] || c[0]);
      const topo = bl.y - (hA + vao + hC) / 2;
      pilha(a, bl.x, topo + hA / 2, 'esq');
      pilha(c, bl.x, topo + hA + vao + hC / 2, 'esq');
    } else pilha(b.linhas, bl.x, bl.y, bl.alinha);
    // As aspas penduradas acima da primeira linha da citação.
    const aspas = b.linhas.find((l) => l.papel === 'aspas');
    if (aspas) {
      const prim = b.linhas.find((l) => l.papel !== 'aspas');
      const cp = caixas[prim.id];
      const s = aspas.tamanho;
      caixas[aspas.id] = { x: cp.x - s * 0.04, y: cp.y - s * 0.62, w: s * 0.5, h: s, s, palavras: [{ x: cp.x, w: s * 0.5 }], letras: [{ x: cp.x, w: s * 0.5 }] };
    }
    encaixar(b, caixas);
    // O bloco inteiro (para a barra e a moldura).
    const cs = Object.values(caixas);
    const bx = { x: Math.min(...cs.map((c) => c.x)), y: Math.min(...cs.map((c) => c.y)), x1: Math.max(...cs.map((c) => c.x + c.w)), y1: Math.max(...cs.map((c) => c.y + c.h)) };
    porBatida[b.id] = { caixas, bloco: bx, textura: b.textura && med[b.id + '-textura'] ? med[b.id + '-textura'].w * b.textura.tamanho : 0 };
  }
  return porBatida;
}

// ── o acento: um traço de N pontos que muda de forma ─────────────────────
const N = 36;
function geometria(a, b, L, P) {
  const lay = L[b.id];
  const base = Math.min(P.largura, P.altura) / 1080;
  const cx = lay.caixas[a.alvo && a.alvo.linha] || null;
  const alvoPalavra = () => {
    if (!cx) return { x: lay.bloco.x, w: lay.bloco.x1 - lay.bloco.x };
    const i = a.alvo.palavra, f = a.alvo.ate != null ? a.alvo.ate : i;
    if (i == null || i < 0 || !cx.palavras[i]) return { x: cx.x, w: cx.w };
    const p0 = cx.palavras[i], p1 = cx.palavras[Math.max(i, Math.min(f, cx.palavras.length - 1))];
    return { x: p0.x, w: p1.x + p1.w - p0.x };
  };
  const pts = [];
  const f = a.forma;
  if (f === 'ponto' || f === 'bala' || f === 'viagem') {
    let x, y, r;
    if (f === 'viagem') { x = a.x; y = a.y; r = a.r; }
    else if (f === 'bala' && cx) { r = Math.max(7 * base, cx.s * 0.13); x = cx.x - r * 2.6; y = cx.y + cx.h * 0.52; }
    else if (cx && a.alvo.antes) { r = cx.s * 0.2; x = cx.x - r * 2.2; y = cx.y + cx.h * 0.5; }
    else if (cx) { r = Math.max(8 * base, cx.s * 0.085); x = cx.x + cx.w + r * 1.6; y = cx.y + cx.h * 0.74; }
    else { r = 14 * base; x = P.largura / 2; y = P.altura / 2; }
    for (let i = 0; i < N; i++) { const q = (i / (N - 1)) * Math.PI * 2; pts.push([x + Math.cos(q) * r * 0.35, y + Math.sin(q) * r * 0.35]); }
    return { pts, larg: r * 1.35, alfa: 1, frente: true, cx: x, cy: y };
  }
  if (!cx && f !== 'barra') return geometria({ forma: 'ponto', alvo: {} }, b, L, P);
  if (f === 'sublinha' || f === 'risco' || f === 'marca') {
    const pw = alvoPalavra();
    const s = cx.s;
    const y = f === 'sublinha' ? cx.y + cx.h * 0.96 : f === 'risco' ? cx.y + cx.h * 0.56 : cx.y + cx.h * 0.6;
    const x0 = pw.x - (f === 'marca' ? s * 0.06 : 0), x1 = pw.x + pw.w + (f === 'marca' ? s * 0.06 : 0);
    for (let i = 0; i < N; i++) { const k = i / (N - 1); pts.push([lerp(x0, x1, k), y + Math.sin(k * 3.1 + 1) * s * 0.012 - (f === 'risco' ? (k - 0.5) * s * 0.08 : k * k * s * 0.02)]); }
    return { pts, larg: f === 'marca' ? s * 0.6 : f === 'risco' ? Math.max(5 * base, s * 0.07) : Math.max(5 * base, s * 0.055), alfa: f === 'marca' ? 0.34 : 1, frente: f !== 'marca', tampa: f === 'marca' ? 'butt' : 'round', cx: (x0 + x1) / 2, cy: y };
  }
  if (f === 'anel') {
    const pw = alvoPalavra();
    const ccx = pw.x + pw.w / 2, ccy = cx.y + cx.h * 0.52;
    const rx = Math.min(pw.w / 2 + cx.s * 0.14, P.largura / 2 - 24 * base), ry = cx.h * 0.6;
    for (let i = 0; i < N; i++) { const q = -2.3 + (i / (N - 1)) * Math.PI * 2 * 1.1; const e = 1 + (i / N) * 0.07; pts.push([ccx + Math.cos(q) * rx * e, ccy + Math.sin(q) * ry * e]); }
    return { pts, larg: Math.max(4 * base, cx.s * 0.03), alfa: 1, frente: true, cx: ccx, cy: ccy };
  }
  if (f === 'barra') {
    // O vão da barra é o respiro do vídeo (margem.js): colada, ela riscava a primeira letra.
    // A deriva da vida abre as letras até 3% da linha para fora: a barra recua junto.
    const x0 = lay.bloco.x - Math.max(18 * base, (b.vao || 0) * 0.7, (lay.bloco.y1 - lay.bloco.y) * 0.08) - (b.vida === 'deriva' ? (lay.bloco.x1 - lay.bloco.x) * 0.03 : 0);
    /* O texto mora na batida, que chega ~80 px mais perto da lente enquanto se
       lê; o traço, não. Sem esta conta, o texto crescia por cima da barra (a
       primeira letra riscada, 10/10): a barra vai para onde o texto É VISTO. */
    const D = Math.round(Math.max(P.largura, P.altura) * 0.85), kz = D / (D - 80);
    const X = (q) => P.largura / 2 + (q - P.largura / 2) * kz, Y = (q) => P.altura / 2 + (q - P.altura / 2) * kz;
    const x = X(x0);
    for (let i = 0; i < N; i++) { const k = i / (N - 1); pts.push([x, Y(lerp(lay.bloco.y + 4, lay.bloco.y1 - 4, k))]); }
    return { pts, larg: Math.max(8 * base, 10 * base), alfa: 1, frente: true, cx: x, cy: (lay.bloco.y + lay.bloco.y1) / 2 };
  }
  if (f === 'moldura') {
    const pad = cx.s * 0.14;
    const x0 = cx.x - pad, y0 = cx.y - pad * 0.5, x1 = cx.x + cx.w + pad, y1 = cx.y + cx.h + pad * 0.5;
    const per = 2 * (x1 - x0) + 2 * (y1 - y0);
    for (let i = 0; i < N; i++) {
      let d = (i / (N - 1)) * per;
      if (d < x1 - x0) { pts.push([x0 + d, y0]); continue; } d -= x1 - x0;
      if (d < y1 - y0) { pts.push([x1, y0 + d]); continue; } d -= y1 - y0;
      if (d < x1 - x0) { pts.push([x1 - d, y1]); continue; } d -= x1 - x0;
      pts.push([x0, y1 - d]);
    }
    return { pts, larg: Math.max(3 * base, cx.s * 0.02), alfa: 1, frente: true, tampa: 'square', cx: (x0 + x1) / 2, cy: (y0 + y1) / 2 };
  }
  return geometria({ forma: 'ponto', alvo: a.alvo }, b, L, P);
}

/* A linha do tempo do acento: a semente, e em cada batida a VIAGEM (vira
   ponto e anda até onde o próximo traço começa) e os estados dela. */
function estadosDoAcento(P, L) {
  const base = Math.min(P.largura, P.altura) / 1080;
  /* O ACENTO EM TRAÇO (09/10, "todo vídeo tem aquela bolinha"): sem a semente
     no meio da tela, sem a viagem em ponto e sem o recolher — cada traço se
     desenha no seu lugar e some com a batida (acentoEm). */
  const semBola = P.fundo && P.fundo.acento === 'traco';
  if (semBola) {
    const out = [];
    P.batidas.forEach((b, bi) => b.acento.forEach((a) => out.push({ t: a.t, dur: a.dur, g: geometria(a, b, L, P), cor: b.cores.acento, batida: bi, forma: a.forma, ate: b.ate })));
    out.sort((a, b) => a.t - b.t);
    out.semBola = true;
    return out;
  }
  const out = [{ t: 0, dur: 0.35, g: (() => { const r = 16 * base; const pts = []; for (let i = 0; i < N; i++) { const q = (i / (N - 1)) * Math.PI * 2; pts.push([P.largura / 2 + Math.cos(q) * r * 0.35, P.altura / 2 + Math.sin(q) * r * 0.35]); } return { pts, larg: r * 1.35, alfa: 1, frente: true, cx: P.largura / 2, cy: P.altura / 2 }; })(), cor: P.batidas[0].cores.acento, batida: 0 }];
  P.batidas.forEach((b, bi) => {
    b.acento.forEach((a, k) => {
      const g = geometria(a, b, L, P);
      if (k === 0 && a.forma !== 'bala' && a.forma !== 'ponto') {
        const r = Math.max(7 * base, g.larg * 0.8);
        const tv = Math.min(b.de + 0.05, a.t - 0.12);
        out.push({ t: tv, dur: Math.max(0.12, Math.min(0.5, a.t - tv)), g: geometria({ forma: 'viagem', x: g.pts[0][0], y: g.pts[0][1], r }, b, L, P), cor: b.cores.acento, batida: bi });
      }
      out.push({ t: a.t, dur: a.dur, g, cor: b.cores.acento, batida: bi, forma: a.forma });
    });
    /* No fim da batida o acento se RECOLHE num ponto: ele ficava na última
       forma até a batida seguinte, e a moldura sobrava sozinha na tela depois
       que a palavra saía (teste de 08/10). */
    const ult = b.acento[b.acento.length - 1];
    if (ult && b.ate != null) {
      const g = geometria(ult, b, L, P);
      const t0 = Math.max(ult.t + (ult.dur || 0), b.ate - 0.3);
      if (t0 < b.ate) out.push({ t: t0, dur: 0.25, g: geometria({ forma: 'viagem', x: g.cx, y: g.cy, r: 7 * base }, b, L, P), cor: b.cores.acento, batida: bi });
    }
  });
  out.sort((a, b) => a.t - b.t);
  return out;
}

function acentoEm(t, estados) {
  if (estados.semBola) {
    // O traço se desenha (os pontos aparecem em ordem) e some no fim da batida; antes do primeiro, nada.
    let k = -1;
    while (k < estados.length - 1 && estados[k + 1].t <= t) k++;
    const cur = estados[k];
    if (!cur) return { pts: [[0, 0], [0, 0]], larg: 0.1, alfa: 0, frente: true, cor: '#000000', cx: 0, cy: 0 };
    const p = arrasto(c01((t - cur.t) / Math.max(0.01, cur.dur)));
    const n = Math.max(2, Math.ceil(cur.g.pts.length * p));
    const some = 1 - c01((t - (cur.ate - 0.22)) / 0.22);
    return { pts: cur.g.pts.slice(0, n), larg: cur.g.larg, alfa: cur.g.alfa * some, frente: cur.g.frente, tampa: cur.g.tampa, cor: cur.cor, cx: cur.g.cx, cy: cur.g.cy };
  }
  let k = 0;
  while (k < estados.length - 1 && estados[k + 1].t <= t) k++;
  const cur = estados[k], prev = estados[Math.max(0, k - 1)];
  const p = k === 0 ? saidaC(c01(t / cur.dur)) : arrasto(c01((t - cur.t) / Math.max(0.01, cur.dur)));
  if (k === 0) {
    const g = cur.g;
    return { pts: g.pts, larg: g.larg * p, alfa: 1, frente: true, cor: cur.cor, cx: g.cx, cy: g.cy };
  }
  const a = prev.g, b = cur.g;
  return {
    pts: b.pts.map((q, i) => [lerp(a.pts[i][0], q[0], p), lerp(a.pts[i][1], q[1], p)]),
    larg: lerp(a.larg, b.larg, p), alfa: lerp(a.alfa, b.alfa, p), frente: p > 0.5 ? b.frente : a.frente, tampa: p > 0.5 ? b.tampa : a.tampa,
    cor: p > 0.5 ? cur.cor : prev.cor, cx: lerp(a.cx, b.cx, p), cy: lerp(a.cy, b.cy, p),
  };
}

// ── a câmera ────────────────────────────────────────────────────────────
function cameraEm(t, ks) {
  if (t <= ks[0].t) return ks[0];
  for (let i = 0; i < ks.length - 1; i++) {
    const a = ks[i], b = ks[i + 1];
    if (t >= a.t && t < b.t) {
      if (b.e === 'degrau' || b.t - a.t < 0.001) return b;
      const u = (t - a.t) / (b.t - a.t);
      const k = b.e === 'acelera' ? acelera(u) : b.e === 'editorial' ? ed(u) : curvaCam(u);
      return { x: lerp(a.x, b.x, k), y: lerp(a.y, b.y, k), zoom: lerp(a.zoom, b.zoom, k), rot: lerp(a.rot, b.rot, k) };
    }
  }
  return ks[ks.length - 1];
}

// ── as saídas: quanto tempo cada uma leva ───────────────────────────────
const DUR_SAIDA = { pontos: 0.5, evapora: 0.42, atravessa: 0.36, desliza: 0.36, comprime: 0.42, explode: 0.46, chicote: 0.3, inunda: 0.24, corte: 0, fim: 0 };

// ── a letra: entrada, vida, saída ───────────────────────────────────────
function letra({ t, fps, l, p, j, ch, i, idx, n, b, cx, cores, eixosF, alvoSaida, frame, folga, centroBloco }) {
  const est = {};
  let char = null;
  const c = l.comportamento;
  const passo = { carimbo: 0, desfoque: 0, tracking: 0, peso: 1.6, largura: 1.2, onda: 1.3, decodifica: 1, flap: 1.5, mascara: 1, pontos: 1.2, contorno: 1.6, monta: 0.8 }[c] || 1;
  const t0 = p.t + (passo * idx) / fps * (l.papel === 'item' || l.papel === 'apoio' || l.papel === 'rotulo' ? 0.6 : 1);
  const tl = t - t0;
  if (tl < 0 && c !== 'decodifica' && c !== 'flap') return { estilo: { opacity: 0 } };
  const m = MOLA(tl);
  const tr = [];
  let op = 1, blur = 0;
  const eixos = { ...l.eixos, wght: pesoDaPalavra(l, p) };
  const wMin = eixosF.wght ? eixosF.wght[0] : eixos.wght;
  const semente = (l.id.length * 31 + idx * 7 + j * 13) % 997;
  const caixaL = cx && cx.letras[idx];
  const centroL = caixaL ? caixaL.x + caixaL.w / 2 : 0;
  const centroLinha = cx ? cx.x + cx.w / 2 : 0;
  switch (c) {
    case 'carimbo': {
      const r0 = (acaso(semente) - 0.5) * 8;
      tr.push('rotate(' + (r0 * (1 - m)).toFixed(2) + 'deg)', 'scale(' + (1 + 0.32 * (1 - m)).toFixed(4) + ')');
      const f = tl * fps;
      blur = f < 2 ? 1.4 : Math.max(0, 0.7 * (1 - (f - 2) / 7));
      break;
    }
    case 'mascara': tr.push('translateY(' + ((1 - m) * 105).toFixed(1) + '%)'); break;
    case 'onda':
      tr.push('translateY(' + ((1 - m) * 0.55 + Math.sin(tl * 15) * 0.08 * (1 - m)).toFixed(3) + 'em)', 'rotate(' + ((1 - m) * 10).toFixed(2) + 'deg)');
      op = c01(tl * fps / 4); break;
    case 'decodifica': {
      if (t < p.t + (idx * 1) / fps - 0.001) return { estilo: { opacity: 0 } };
      const resolve = p.t + 0.26 + (idx % 4) * 0.035 + idx * 0.012;
      if (t < resolve && ch.trim()) { char = embaralha(semente + Math.floor(frame / 2) * 17); est.color = cores.fraco; }
      else tr.push('scale(' + (1 + 0.1 * (1 - MOLA(t - resolve))).toFixed(3) + ')');
      break;
    }
    case 'flap': {
      if (t < p.t + (idx * 1.2) / fps) return { estilo: { opacity: 0 } };
      const nf = 3 + (idx % 3), dur = (nf * 2) / fps, tf = t - (p.t + (idx * 1.2) / fps);
      if (tf < dur && ch.trim()) { char = embaralha(semente + Math.floor(tf * fps / 2) * 29); const fase = (tf * fps / 2) % 1; tr.push('rotateX(' + (fase * 80).toFixed(1) + 'deg)'); est.color = cores.fraco; }
      else tr.push('rotateX(' + ((1 - MOLA(tf - dur)) * 50).toFixed(1) + 'deg)');
      break;
    }
    case 'peso': {
      /* O peso BALANÇA: do fio (wght mínimo) ao mais pesado que a fonte tem,
         e assenta no peso dele — a própria fonte se mexe. */
      const u = c01(tl / 0.8), wMax = eixosF.wght ? eixosF.wght[1] : eixos.wght, alvo = eixos.wght;
      eixos.wght = u < 0.5 ? lerp(wMin, wMax, ed(u / 0.5)) : lerp(wMax, alvo, ed((u - 0.5) / 0.5));
      tr.push('scaleY(' + (0.9 + 0.1 * ed(c01(tl / 0.4))).toFixed(3) + ')');
      op = c01(tl * fps / 3); break;
    }
    case 'largura': {
      // A largura estica até onde o quadro deixa e volta (wdth 25 → pico → normal).
      const u = c01(tl / 0.8), base0 = l.eixos.wdth || 100;
      if (eixosF.wdth) {
        const pico = Math.min(eixosF.wdth[1], base0 + (folga || 0) * 100 * 0.9);
        eixos.wdth = u < 0.5 ? lerp(eixosF.wdth[0], pico, ed(u / 0.5)) : lerp(pico, base0, ed((u - 0.5) / 0.5));
      }
      eixos.wght = lerp(Math.max(wMin, eixos.wght - 450), eixos.wght, ed(u));
      op = c01(tl * fps / 3); break;
    }
    case 'monta': {
      // As letras chegam dos estilhaços da palavra que explodiu e se montam.
      // A letra volta numa curva (arco), assenta com a mola, sem pião.
      const k = MOLA(tl * 0.85);
      const a = acaso(semente + 3) * Math.PI * 2, d = (160 + acaso(semente + 5) * 260) * (1 - k);
      tr.push('translate(' + (Math.cos(a) * d).toFixed(1) + 'px,' + (Math.sin(a) * d - 120 * (1 - k) * k * 4).toFixed(1) + 'px)',
        'rotate(' + ((acaso(semente + 9) - 0.5) * 60 * (1 - k)).toFixed(1) + 'deg)', 'scale(' + (0.6 + 0.4 * k).toFixed(3) + ')');
      blur = (1 - k) * 10; op = c01(tl * fps / 2);
      eixos.wght = lerp(wMin, eixos.wght, k);
      break;
    }
    case 'tracking': tr.push('translateX(' + ((centroLinha - centroL) * (1 - m)).toFixed(1) + 'px)'); op = c01(m * 1.4); break;
    case 'desfoque': blur = (1 - m) * 16; tr.push('translateY(' + ((1 - m) * 0.3).toFixed(3) + 'em)', 'scale(' + (1 + 0.05 * (1 - m)).toFixed(3) + ')'); op = c01(m * 1.2); break;
    case 'pontos': {
      const k = MOLA(tl - 4 / fps);
      tr.push('scale(' + k.toFixed(3) + ')');
      break;
    }
    case 'contorno': {
      // O contorno se desenha, e a tinta enche por dentro.
      const k = c01((tl - 0.3) / 0.4);
      est.WebkitTextStroke = (0.018 * (1 - k) + 0.0001).toFixed(4) + 'em currentColor';
      if (k < 1) est.WebkitTextFillColor = alfaCor(cores, l, p, k);
      op = c01(tl * fps / 3); break;
    }
    default: break;
  }
  // A VIDA no repouso: a fonte continua se mexendo enquanto a gente lê.
  const principal = l.papel === 'tese' || l.papel === 'numero';
  const repouso = principal ? c01((t - (l.t + 0.7)) / 0.6) : 0;
  if (repouso > 0 && !l.riscado) {
    const fase = t * (Math.PI * 2) / 2.8 - idx * 0.45;
    if (b.vida === 'respira') {
      if (eixosF.GRAD) eixos.GRAD = (l.eixos.GRAD || 0) - 200 * (0.5 + 0.5 * Math.sin(fase)) * repouso;
      else eixos.wght = eixos.wght - 280 * (0.5 + 0.5 * Math.sin(fase)) * repouso;
    } else if (b.vida === 'onda') tr.push('translateY(' + (-0.06 * Math.sin(fase) * repouso).toFixed(4) + 'em)');
    else if (b.vida === 'deriva') tr.push('translateX(' + ((centroL - centroLinha) * 0.03 * repouso * c01((t - l.t) / 3)).toFixed(2) + 'px)');
    else if (b.vida === 'estica' && eixosF.wdth) eixos.wdth = (l.eixos.wdth || 100) - 30 * (0.5 + 0.5 * Math.sin(fase)) * repouso;
  }
  // A letra treme de leve no pulso (a cada meio segundo), e nunca fica parada de vez.
  /* (O tremor de letra no pulso saiu: lia como defeito, não como vida.) */
  // A SAÍDA: a letra se transforma no que vem.
  const ds = DUR_SAIDA[b.saida] || 0;
  if (ds > 0) {
    const ordem = idx / Math.max(1, n);
    const te = c01((t - (b.ate - ds) - ordem * ds * 0.35) / (ds * 0.65));
    if (te > 0) {
      if (b.saida === 'pontos') {
        tr.push('scale(' + (1 - saidaC(c01(te / 0.4))).toFixed(3) + ')');
      } else if (b.saida === 'evapora') {
        eixos.wght = lerp(eixos.wght, wMin, ed(te));
        tr.push('translate(' + ((centroL - centroLinha) * 0.25 * te).toFixed(1) + 'px,' + (-0.15 * te).toFixed(3) + 'em)');
        op *= 1 - te; blur += te * 6;
      } else if (b.saida === 'explode') {
        // A palavra EXPLODE: cada letra voa para fora do centro do bloco, girando.
        const lay = cx && cx.letras[idx];
        const ox = lay ? lay.x + lay.w / 2 - centroBloco.x : 0, oy = cx ? cx.y + cx.h / 2 - centroBloco.y : 0;
        // Em arco (a gravidade puxa um pouco para baixo), com giro contido — física, não glitch.
        const a = Math.atan2(oy, ox || 0.001) + (acaso(semente + 11) - 0.5) * 0.7;
        const e2 = entradaC(te), d = (220 + acaso(semente + 13) * 360) * e2;
        tr.push('translate(' + (Math.cos(a) * d).toFixed(1) + 'px,' + (Math.sin(a) * d + 180 * e2 * e2).toFixed(1) + 'px)',
          'rotate(' + ((acaso(semente + 17) - 0.5) * 90 * e2).toFixed(1) + 'deg)', 'scale(' + (1 + 0.25 * e2).toFixed(3) + ')');
        op *= 1 - te * te; blur += te * 9;
        if (eixosF.wght) eixos.wght = lerp(eixos.wght, eixosF.wght[1], te);
      } else if (b.saida === 'comprime') {
        tr.push('translateX(' + ((centroLinha - centroL) * ed(te)).toFixed(1) + 'px)', 'scaleX(' + (1 - 0.9 * te).toFixed(3) + ')');
        if (eixosF.wdth) eixos.wdth = lerp(eixos.wdth || 100, eixosF.wdth[0], te);
        op *= 1 - c01((te - 0.7) / 0.3);
      }
    }
  }
  if (tr.length) est.transform = tr.join(' ');
  if (op < 1) est.opacity = op;
  if (blur > 0.05) est.filter = 'blur(' + blur.toFixed(2) + 'px)';
  est.fontVariationSettings = fvs(eixosF, eixos);
  est.transformOrigin = '50% 60%';
  return { estilo: est, char };
}
function alfaCor(cores, l, p, k) {
  const base = p.cor === 'acento' ? cores.acento : l.cor === 'acento' ? cores.acento : l.cor === 'fraco' ? cores.fraco : cores.texto;
  return alfa(base, k);
}

/* O ponto que nasce ou em que a letra vira (entrada e saída "pontos"). */
function pontoDaLetra({ t, fps, l, p, idx, n, b, cx, cores, alvoSaida }) {
  const tamPonto = 0.17;
  const c = l.comportamento;
  const t0 = p.t + (1.2 * idx) / fps;
  let escala = 0, dx = 0, dy = 0, op = 1;
  if (c === 'pontos' && t >= t0 && t < t0 + 0.5) {
    const tl = t - t0;
    escala = tl < 4 / fps ? saidaC(tl * fps / 4) : 1 - MOLA(tl - 4 / fps);
  }
  const ds = DUR_SAIDA[b.saida] || 0;
  if (b.saida === 'pontos' && ds > 0 && cx && cx.letras[idx]) {
    const ordem = idx / Math.max(1, n);
    const te = c01((t - (b.ate - ds) - ordem * ds * 0.35) / (ds * 0.65));
    if (te > 0) {
      escala = Math.max(escala, saidaC(c01(te / 0.3)));
      if (alvoSaida) {
        const q = cx.letras[idx];
        const k = entradaC(c01((te - 0.3) / 0.7));
        dx = (alvoSaida.x - (q.x + q.w / 2)) * k;
        dy = (alvoSaida.y - (cx.y + cx.h * 0.55)) * k;
        escala *= 1 - 0.5 * k;
        op = 1 - c01((te - 0.85) / 0.15);
      }
    }
  }
  if (escala <= 0.01) return null;
  const cor = p.cor === 'acento' ? cores.acento : cores.texto;
  return (
    <span style={{ position: 'absolute', left: '50%', top: '55%', width: tamPonto + 'em', height: tamPonto + 'em', marginLeft: -tamPonto / 2 + 'em', marginTop: -tamPonto / 2 + 'em',
      borderRadius: '50%', background: cor, opacity: op,
      transform: 'translate(' + dx.toFixed(1) + 'px,' + dy.toFixed(1) + 'px) scale(' + escala.toFixed(3) + ')' }} />
  );
}

// ── uma linha ──────────────────────────────────────────────────────────
function Linha({ l, b, cx, t, fps, P, frame, alvoSaida, centroBloco }) {
  if (!cx) return null;
  const folga = Math.max(0, (P.largura * 0.94) / Math.max(1, cx.w) - 1);
  // O GOLPE: a tese soca (escala que bate e assenta).
  let soco = 1;
  for (const g of b.golpes || []) if (g.linha === l.id && t >= g.t) soco += 0.05 * g.forca * Math.exp(-(t - g.t) * 14);
  const cores = b.cores;
  const eixosF = P.eixos[l.fonte] || {};
  const ps = palavrasDaLinha(l);
  const n = ps.reduce((q, p) => q + Array.from(p.texto).length, 0);
  // A cascata envelhece: o item anterior perde a cor quando o próximo entra.
  const velho = l.envelhece != null ? c01((t - l.envelhece) / (6 / fps)) : 0;
  let corL = l.cor === 'acento' ? cores.acento : l.cor === 'fraco' ? cores.fraco : cores.texto;
  if (velho > 0) corL = mistura(corL, cores.fraco, velho);
  let texto = null;
  // O CONTADOR: o número conta, e o peso cresce com ele.
  const ct = b.contador && b.contador.linha === l.id ? b.contador : null;
  let pesoContador = null;
  if (ct && ct.n != null) {
    const k = ed(c01((t - ct.t) / ct.dur));
    const v = ct.n * k;
    let s = v.toFixed(ct.casas || 0);
    if (ct.milhar) s = s.replace(/\B(?=(\d{3})+(?!\d))/g, ct.milhar);
    if (ct.virgula) s = s.replace('.', ',');
    texto = (ct.antes || '') + s + (ct.depois || '');
    if (l.maiusc) texto = texto.toUpperCase();
    const faixa = eixosF.wght || [300, 900];
    pesoContador = lerp(faixa[0], l.eixos.wght, k);
  }
  const estiloPalavra = (p, j) => {
    const e = {};
    if (p.cor === 'acento' && l.cor !== 'acento') e.color = velho > 0 ? mistura(cores.acento, cores.fraco, velho) : cores.acento;
    if (l.comportamento === 'mascara') e.clipPath = 'inset(-0.25em -0.1em -0.3em -0.1em)';
    return e;
  };
  const conteudo = texto != null ? (
    <span style={{ display: 'inline-block', fontVariationSettings: fvs(eixosF, { ...l.eixos, wght: pesoContador }), fontFeatureSettings: '"tnum" 1',
      opacity: c01((t - ct.t) * fps / 3), transform: 'scaleY(' + (0.9 + 0.1 * ed(c01((t - ct.t) / ct.dur))).toFixed(3) + ')' }}>{texto}</span>
  ) : (
    <Marcacao l={l} eixosFonte={eixosF} estiloPalavra={estiloPalavra}
      estiloLetra={(p, j, ch, i, idx) => letra({ t, fps, l, p, j, ch, i, idx, n, b, cx, cores, eixosF, alvoSaida, frame, folga, centroBloco })}
      extraLetra={(p, j, i, idx) => pontoDaLetra({ t, fps, l, p, idx, n, b, cx, cores, alvoSaida })} />
  );
  // As ALÇAS de vetor da marca que se constrói: cantos e guias enquanto a letra se desenha.
  const alcas = l.marca && t >= l.t && t < l.t + 1.3 ? (() => {
    const k = c01((t - l.t) / 0.25) * (1 - c01((t - l.t - 0.9) / 0.4));
    const lado = Math.max(5, cx.s * 0.05);
    const cor = alfa(cores.texto, 0.75 * k);
    return (
      <>
        {[0.2, 0.78, 1.0].map((f, q) => <div key={'g' + q} style={{ position: 'absolute', left: -cx.s * 0.3, width: cx.w + cx.s * 0.6, top: cx.h * f, height: 1, background: alfa(cores.acento, 0.55 * k) }} />)}
        {cx.letras.map((q, qi) => {
          const k2 = c01((t - l.t - qi * 1.6 / fps) / 0.15) * k;
          return [0, 1].map((lx) => [0.2, 1.0].map((ly) => (
            <div key={qi + '-' + lx + '-' + ly} style={{ position: 'absolute', left: q.x - cx.x + lx * q.w - lado / 2, top: cx.h * ly - lado / 2, width: lado, height: lado,
              border: '1.5px solid ' + cor, background: alfa(cores.fundo, 0.9 * k2), opacity: k2 }} />
          )));
        })}
      </>
    );
  })() : null;
  return (
    <div style={{ position: 'absolute', left: cx.x, top: cx.y, width: cx.w + 2, height: cx.h, whiteSpace: 'pre', lineHeight: 1,
      fontFamily: familia(l.fonte), fontSize: cx.s, letterSpacing: (l.tracking || 0) + 'em', color: corL, perspective: '600px',
      textAlign: 'left', fontFeatureSettings: '"tnum" 1', transform: soco !== 1 ? 'scale(' + soco.toFixed(4) + ')' : undefined, transformOrigin: '50% 55%' }}>
      {alcas}
      {conteudo}
    </div>
  );
}

// ── a parede: a linha repetida vira textura ─────────────────────────────
function Textura({ b, cx, t, P, periodo }) {
  if (!cx || !b.textura || !periodo) return null;
  const tx = b.textura;
  const linhas = [];
  const n = Math.ceil(tx.linhas / 2);
  const reps = Math.ceil((P.largura * 3) / periodo) + 2;
  const unidade = (tx.texto + '  ·  ').repeat(reps);
  const saiu = DUR_SAIDA[b.saida] ? c01((t - (b.ate - DUR_SAIDA[b.saida])) / DUR_SAIDA[b.saida]) : 0;
  const l0 = b.linhas[0];
  for (let k = -n; k <= n; k++) {
    if (k === 0) continue;
    const aparece = c01((t - (l0.t + Math.abs(k) * 2 / P.fps)) / (6 / P.fps));
    if (aparece <= 0) continue;
    const dir = k % 2 ? 1 : -1;
    const x = -((t * tx.velocidade * (1 + Math.abs(k) * 0.08) + Math.abs(k) * periodo * 0.37) % periodo) * dir - periodo * (dir > 0 ? 1 : 0) - P.largura;
    linhas.push(
      <div key={k} style={{ position: 'absolute', left: cx.x + x - cx.x, top: cx.y + k * cx.h * 1.02, whiteSpace: 'pre', lineHeight: 1, fontFamily: familia(tx.fonte),
        fontSize: cx.s, fontVariationSettings: fvs(P.eixos[tx.fonte] || {}, l0.eixos), color: 'rgba(0,0,0,0)',
        WebkitTextStroke: Math.max(1, cx.s * 0.008) + 'px ' + alfa(b.cores.texto, 0.32 * aparece * (1 - saiu)) }}>{unidade}</div>,
    );
  }
  return <>{linhas}</>;
}

// ── o fundo vivo ────────────────────────────────────────────────────────
const GRAO = 'url("data:image/svg+xml;utf8,' + encodeURIComponent("<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0.6 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>") + '")';

function Fundo({ P, b, prevB, t, cam, frame, origem }) {
  const W = P.largura, H = P.altura;
  const f = P.fundo;
  // A inundação: o acento cresce e vira o fundo da próxima batida.
  let corBase = b.cores.fundo, circulo = null;
  /* O CAMPO troca: por inundação (o acento cresce e vira o fundo), por cortina
     (a cor nova varre de um lado) ou por corte seco no quadro da batida. */
  if (prevB && prevB.cores.fundo !== b.cores.fundo && b.troca === 'inunda') {
    const k = entradaC(c01((t - (b.de - 0.2)) / 0.42));
    if (k < 1) {
      corBase = prevB.cores.fundo;
      const R = Math.hypot(W, H) * 1.05 * k;
      circulo = <div style={{ position: 'absolute', left: origem.x - R, top: origem.y - R, width: 2 * R, height: 2 * R, borderRadius: '50%', background: b.cores.fundo }} />;
    }
  } else if (prevB && prevB.cores.fundo !== b.cores.fundo && b.troca === 'cortina') {
    const k = ed(c01((t - (b.de - 0.1)) / 0.34));
    if (k < 1) {
      corBase = prevB.cores.fundo;
      const lado = (b.campo + Math.round(b.de * 10)) % 4;
      const ins = lado === 0 ? `inset(0 ${100 - k * 100}% 0 0)` : lado === 1 ? `inset(0 0 0 ${100 - k * 100}%)` : lado === 2 ? `inset(0 0 ${100 - k * 100}% 0)` : `inset(${100 - k * 100}% 0 0 0)`;
      circulo = <div style={{ position: 'absolute', inset: 0, background: b.cores.fundo, clipPath: ins }} />;
    }
  }
  const claro = (() => { const n = parseInt(corBase.slice(1), 16); return ((n >> 16 & 255) * 0.299 + (n >> 8 & 255) * 0.587 + (n & 255) * 0.114) / 255 > 0.5; })();
  const linhaCor = alfa(b.cores.texto, claro ? 0.07 : 0.075);
  const passo = f.passo * (1 + (cam.zoom - 1) * 0.35);
  const px = (-cam.x * 0.35 + t * 6) % passo, py = (-cam.y * 0.35 + t * 3) % passo;
  const textura = f.textura === 'grade'
    ? { backgroundImage: 'linear-gradient(' + linhaCor + ' 1px, transparent 1px), linear-gradient(90deg, ' + linhaCor + ' 1px, transparent 1px)' }
    : f.textura === 'pontos' ? { backgroundImage: 'radial-gradient(' + alfa(b.cores.texto, 0.16) + ' 1.2px, transparent 1.6px)' }
    : { backgroundImage: 'linear-gradient(' + linhaCor + ' 1px, transparent 1px)' };
  const bx = (f.brilho.x + 0.12 * Math.sin(t * 0.23)) * 100, by = (f.brilho.y + 0.1 * Math.cos(t * 0.19)) * 100;
  return (
    <div style={{ position: 'absolute', inset: 0, background: corBase, overflow: 'hidden' }}>
      {circulo}
      {(f.ambiente || 'bokeh') !== 'limpo' && <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at ' + bx + '% ' + by + '%, ' + alfa(claro ? '#000000' : '#ffffff', 0.05) + ' 0%, transparent 60%)' }} />}
      {(f.particulas || []).map((q, i) => {
        const x = ((q.x - cam.x * q.z * 1.6 + t * q.v * (i % 2 ? 1 : -0.6)) % W + W) % W;
        const y = ((q.y - cam.y * q.z * 1.6 - t * q.v * 0.7) % H + H) % H;
        const sc = 1 + (cam.zoom - 1) * q.z * 3;
        return <div key={'q' + i} style={{ position: 'absolute', left: x, top: y, width: q.r * 2 * sc, height: q.r * 2 * sc, borderRadius: '50%',
          background: i % 5 === 0 ? b.cores.acento : b.cores.texto, opacity: (0.1 + q.z * 0.25), filter: q.z < 0.3 ? 'blur(1.5px)' : undefined }} />;
      })}
      {f.textura && <div style={{ position: 'absolute', inset: 0, ...textura, backgroundSize: passo + 'px ' + passo + 'px', backgroundPosition: px + 'px ' + py + 'px',
        WebkitMaskImage: 'radial-gradient(ellipse at 50% 50%, #000 20%, transparent 78%)', maskImage: 'radial-gradient(ellipse at 50% 50%, #000 20%, transparent 78%)' }} />}
    </div>
  );
}

// ── o HERÓI GRÁFICO: a ideia visual grande de cada batida ───────────────
const TAU = Math.PI * 2;
function formaPoligono(n, estrela, R, cx, cy, giro, N = 60) {
  const v = [];
  const lados = estrela ? n * 2 : n;
  for (let i = 0; i < lados; i++) { const a = giro + (i / lados) * TAU - Math.PI / 2, r = estrela && i % 2 ? R * 0.45 : R; v.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); }
  const pts = [];
  for (let k = 0; k < N; k++) {
    const u = (k / N) * lados, i = Math.floor(u), f = u - i, a = v[i % lados], b2 = v[(i + 1) % lados];
    pts.push([lerp(a[0], b2[0], f), lerp(a[1], b2[1], f)]);
  }
  return pts;
}
const caminhoFechado = (pts) => 'M' + pts.map((q) => q[0].toFixed(1) + ' ' + q[1].toFixed(1)).join('L') + 'Z';

function Heroi({ b, t, P, slot }) {
  const h = b.heroi;
  if (!h) return null;
  const W = P.largura, H = P.altura, base = Math.min(W, H) / 1080;
  const tl = t - b.de, dur = Math.max(0.8, b.ate - b.de);
  const k = ed(c01(tl / 0.7));
  const sem = h.semente;
  const cor = h.cor, cor2 = h.cor2;
  const { cx, cy, R, fraco } = slot;
  const op = slot.op != null ? slot.op : fraco ? 0.32 : 1;
  const els = [];
  if (h.tipo === 'explosao') {
    // A explosão radial: nasce de um ponto, os raios saem em mola e giram devagar; anéis de régua em volta.
    const n = 10 + (sem % 7), giro = tl * 0.25 + (sem % 10) * 0.3;
    for (let i = 0; i < n; i++) {
      const ki = MOLA(tl - 0.12 - i * 0.025), a = giro + (i / n) * TAU, r1 = R * (0.62 + 0.38 * acaso(sem + i)) * ki * (1 + 0.04 * Math.sin(t * 3 + i));
      els.push(<line key={'r' + i} x1={cx + Math.cos(a) * R * 0.14} y1={cy + Math.sin(a) * R * 0.14} x2={cx + Math.cos(a) * r1} y2={cy + Math.sin(a) * r1} stroke={cor} strokeWidth={R * 0.1} strokeLinecap="round" />);
    }
    els.push(<circle key="c" cx={cx} cy={cy} r={R * 0.16 * Math.min(1, tl * 6)} fill={cor} />);
    els.push(<circle key="a1" cx={cx} cy={cy} r={R * 1.25 * k} fill="none" stroke={cor2} strokeOpacity={0.28} strokeWidth={1.5 * base} />);
    els.push(<circle key="a2" cx={cx} cy={cy} r={R * 1.55 * ed(c01((tl - 0.15) / 0.8))} fill="none" stroke={cor2} strokeOpacity={0.22} strokeWidth={1.2 * base} strokeDasharray={`${2 * base} ${9 * base}`} transform={`rotate(${-tl * 12} ${cx} ${cy})`} />);
  } else if (h.tipo === 'poligono') {
    // O polígono que se transforma: triângulo → estrela → quadrado, com partículas em órbita.
    const seq = [[3, false], [5, true], [4, false], [6, true]];
    const u = c01(tl / dur) * 3, i = Math.min(2, Math.floor(u)), f = ed(c01((u - i) * 2.2 - 0.6));
    const giro = tl * 0.35;
    const A = formaPoligono(seq[i][0], seq[i][1], R * 0.62 * k, cx, cy, giro), B2 = formaPoligono(seq[i + 1][0], seq[i + 1][1], R * 0.62 * k, cx, cy, giro);
    const pts = A.map((q, j) => [lerp(q[0], B2[j][0], f), lerp(q[1], B2[j][1], f)]);
    els.push(<path key="g" d={caminhoFechado(formaPoligono(seq[i + 1][0], seq[i + 1][1], R * 0.7 * k, cx, cy, -giro * 1.4))} fill="none" stroke={cor} strokeOpacity={0.55} strokeWidth={1.4 * base} />);
    els.push(<path key="p" d={caminhoFechado(pts)} fill={cor2} />);
    const n = 12;
    for (let j = 0; j < n; j++) {
      const a = (j / n) * TAU + tl * 0.6, r = R * 1.18 * k;
      const x = cx + Math.cos(a) * r, y = cy + Math.sin(a) * r, s2 = R * 0.045;
      els.push(j % 3 ? <circle key={'o' + j} cx={x} cy={y} r={s2} fill={cor2} /> : <path key={'o' + j} d={caminhoFechado(formaPoligono(3, false, s2 * 1.6, x, y, a))} fill={cor} />);
    }
  } else if (h.tipo === 'esfera' || h.tipo === 'toro') {
    // A malha de pontos em 3D: esfera ou toro girando, o ponto mais perto maior e mais forte.
    const pts = [];
    const ry = tl * 0.7 + sem, rx = 0.45 + Math.sin(tl * 0.4) * 0.2;
    const add = (x, y, z, ac) => {
      const x1 = x * Math.cos(ry) + z * Math.sin(ry), z1 = -x * Math.sin(ry) + z * Math.cos(ry);
      const y2 = y * Math.cos(rx) - z1 * Math.sin(rx), z2 = y * Math.sin(rx) + z1 * Math.cos(rx);
      const f = 3.2 / (3.2 + z2);
      pts.push({ x: cx + x1 * R * f * k, y: cy + y2 * R * f * k, z: z2, f, ac });
    };
    if (h.tipo === 'esfera') { for (let i = 1; i < 16; i++) for (let j = 0; j < 26; j++) { const th = (i / 16) * Math.PI, ph = (j / 26) * TAU; add(Math.sin(th) * Math.cos(ph), Math.cos(th), Math.sin(th) * Math.sin(ph), (i * 26 + j) % 11 === 0); } }
    else { for (let i = 0; i < 30; i++) for (let j = 0; j < 14; j++) { const u = (i / 30) * TAU, v = (j / 14) * TAU; add((0.72 + 0.3 * Math.cos(v)) * Math.cos(u), 0.3 * Math.sin(v), (0.72 + 0.3 * Math.cos(v)) * Math.sin(u), (i + j) % 9 === 0); } }
    pts.sort((a, b2) => b2.z - a.z);
    pts.forEach((q, j) => els.push(<circle key={j} cx={q.x} cy={q.y} r={Math.max(0.6, 2.6 * base * q.f)} fill={q.ac ? cor : cor2} opacity={0.25 + 0.75 * c01((1 - q.z) / 2)} />));
  } else if (h.tipo === 'truchet') {
    // O campo de truchet: arcos em azulejo e uma onda de cor que atravessa na diagonal.
    const T = Math.round(Math.min(W, H) / 7.5), cols = Math.ceil(W / T) + 1, rows = Math.ceil(H / T) + 1;
    const onda = (tl * 7) % (cols + rows + 8) - 4;
    let dBase = '', dOnda = '';
    for (let i = 0; i < cols; i++) for (let j = 0; j < rows; j++) {
      if ((i + j) * 0.9 > tl * 22) continue;
      const x = i * T, y = j * T, r = T / 2, o = acaso(sem + i * 31 + j * 17) > 0.5;
      const d = o ? `M${x + r} ${y}A${r} ${r} 0 0 1 ${x} ${y + r}M${x + T} ${y + r}A${r} ${r} 0 0 0 ${x + r} ${y + T}`
        : `M${x + r} ${y}A${r} ${r} 0 0 0 ${x + T} ${y + r}M${x} ${y + r}A${r} ${r} 0 0 1 ${x + r} ${y + T}`;
      if (Math.abs(i + j - onda) < 2.2) dOnda += d; else dBase += d;
    }
    els.push(<path key="b" d={dBase} fill="none" stroke={cor2} strokeOpacity={0.16} strokeWidth={T * 0.16} strokeLinecap="round" />);
    els.push(<path key="o" d={dOnda} fill="none" stroke={cor} strokeOpacity={0.8} strokeWidth={T * 0.16} strokeLinecap="round" />);
  } else if (h.tipo === 'aneis') {
    // Anéis de órbita inclinados, cada um com o seu satélite.
    for (let j = 0; j < 3; j++) {
      const rx2 = R * (1.05 + j * 0.38) * k, ry2 = rx2 * (0.3 + 0.08 * j), ang = (sem % 40) - 20 + j * 28 + tl * (6 + j * 3);
      const a = tl * (0.9 - j * 0.2) + j * 2;
      els.push(<g key={'a' + j} transform={`rotate(${ang.toFixed(2)} ${cx} ${cy})`}>
        <ellipse cx={cx} cy={cy} rx={rx2} ry={ry2} fill="none" stroke={cor2} strokeOpacity={0.3} strokeWidth={1.6 * base} />
        <circle cx={cx + Math.cos(a) * rx2} cy={cy + Math.sin(a) * ry2} r={6 * base} fill={j === 1 ? cor : cor2} />
      </g>);
    }
  } else if (h.tipo === 'grade') {
    // A grade que deforma: uma lente passa e entorta as linhas.
    const G = Math.round(Math.min(W, H) / 12), bx = lerp(-0.2 * W, 1.2 * W, (tl / dur) % 1), by = H * (0.35 + 0.3 * Math.sin(tl * 0.7));
    const warp = (x, y) => { const d2 = ((x - bx) ** 2 + (y - by) ** 2) / (0.09 * W * W); const e = Math.exp(-d2) * 60 * base; return [x + (x - bx) * e / 300, y + (y - by) * e / 300]; };
    let d = '';
    for (let x = 0; x <= W + G; x += G) { d += 'M'; for (let y = 0; y <= H; y += 40) { const q = warp(x, y); d += q[0].toFixed(1) + ' ' + q[1].toFixed(1) + (y + 40 <= H ? 'L' : ''); } }
    for (let y = 0; y <= H + G; y += G) { d += 'M'; for (let x = 0; x <= W; x += 40) { const q = warp(x, y); d += q[0].toFixed(1) + ' ' + q[1].toFixed(1) + (x + 40 <= W ? 'L' : ''); } }
    els.push(<path key="g" d={d} fill="none" stroke={cor2} strokeOpacity={0.14 * k} strokeWidth={1.2 * base} />);
    els.push(<circle key="l" cx={bx} cy={by} r={7 * base} fill={cor} opacity={k} />);
  } else if (h.tipo === 'rastro') {
    // Rastros de velocidade cruzando o quadro.
    for (let j = 0; j < 16; j++) {
      const y = H * acaso(sem + j * 7), len = W * (0.15 + 0.45 * acaso(sem + j * 11)), v = W * (1.2 + acaso(j * 13 + 1) * 1.6);
      const x = ((tl * v + acaso(sem + j * 3) * W * 2) % (W * 2 + len)) - len;
      els.push(<rect key={j} x={x} y={y} width={len} height={(1.5 + 4 * acaso(j * 5 + sem)) * base} rx={2} fill={j % 4 === 0 ? cor : cor2} opacity={(j % 4 === 0 ? 0.7 : 0.18) * k} />);
    }
  }
  return <svg width={W} height={H} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', opacity: op }}>{els}</svg>;
}

/* A MOLDURA (o HUD do motion designer): cantos de enquadramento, o assunto do
   vídeo, a SEÇÃO ("01 · A CONVERSA"), o timecode e a régua do vídeo — fixa do
   começo ao fim, como no reel de referência. Mora dentro da área segura
   (P.hud.area: no 9:16, longe do status e dos botões do celular). 'cantos' é
   a versão mínima: os cantos e a seção. Partitura antiga (sem área): a margem de antes. */
const SECOES = { gigante: 'TESE', pilha: 'ARGUMENTO', divisao: 'CONTRASTE', contador: 'NÚMERO', cascata: 'LISTA', parede: 'TEXTURA', citacao: 'CITAÇÃO', marca: 'MARCA', palavras: 'FALA', produto: 'PRODUTO' };
function HudG({ P, b, bi, t, fps }) {
  const W = P.largura, H = P.altura, h = P.hud, deitado = W >= H;
  const m0 = Math.round(Math.min(W, H) * 0.035);
  const A = h.area || { x: m0, topo: m0, base: m0 };
  const tam = Math.max(11, Math.round(Math.min(W, H) * (deitado ? 0.0125 : 0.018)));
  const c = Math.round(Math.min(W, H) * 0.026);
  const cor = alfa(b.cores.texto, 0.72);
  const f = Math.round(t * fps);
  const tc = [Math.floor(t / 60), Math.floor(t) % 60, f % fps].map((n) => String(n).padStart(2, '0')).join(':');
  const rot = { position: 'absolute', fontFamily: familia(P.fontes.mono), fontSize: tam, letterSpacing: '0.14em', color: cor, whiteSpace: 'nowrap', textTransform: 'uppercase', lineHeight: 1 };
  const L = A.x, T = A.topo, R = W - A.x, B = H - A.base;
  const canto = (x, y, sx, sy) => <div key={x + '-' + y} style={{ position: 'absolute', left: sx > 0 ? x : x - c, top: sy > 0 ? y : y - c, width: c, height: c,
    borderLeft: sx > 0 ? '1.5px solid ' + cor : undefined, borderRight: sx < 0 ? '1.5px solid ' + cor : undefined, borderTop: sy > 0 ? '1.5px solid ' + cor : undefined, borderBottom: sy < 0 ? '1.5px solid ' + cor : undefined }} />;
  // A seção: o número e o nome dela (a partitura antiga não tem seção — vale a composição).
  const k = b.secao != null ? b.secao : bi;
  const nome = (h.secoes || [])[k] || SECOES[b.comp] || String(b.comp).toUpperCase();
  const secao = <div style={{ ...rot, right: W - R + c * 0.6, top: T + c * 0.6 }}>{String(k + 1).padStart(2, '0') + ' · ' + nome}</div>;
  if (h.tipo === 'cantos') return <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>{canto(L, T, 1, 1)}{canto(R, T, -1, 1)}{canto(L, B, 1, -1)}{canto(R, B, -1, -1)}{secao}</div>;
  const n = Math.max(1, (h.secoes || []).length);
  return (
    <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
      {canto(L, T, 1, 1)}{canto(R, T, -1, 1)}{canto(L, B, 1, -1)}{canto(R, B, -1, -1)}
      <div style={{ ...rot, left: L + c * 0.6, top: T + c * 0.6 }}><b>{h.marca}</b>{deitado && <span style={{ opacity: 0.6 }}>{'  ·  ' + h.ano}</span>}</div>
      {secao}
      <div style={{ ...rot, left: L + c * 0.6, bottom: H - B + c * 0.6 }}>{tc}<span style={{ opacity: 0.55 }}>{'   ' + fps + ' FPS'}</span></div>
      <div style={{ ...rot, right: W - R + c * 0.6, bottom: H - B + c * 0.6 }}>{(h.secoes ? String(k + 1).padStart(2, '0') + ' / ' + String(n).padStart(2, '0') : W + '×' + H)}</div>
      <div style={{ position: 'absolute', left: L + c * 0.6, top: B - c * 0.35, height: 1.5, width: (R - L - c * 1.2) * c01(t / P.duracao), background: cor }} />
    </div>
  );
}

// ── o componente ────────────────────────────────────────────────────────
export function Gerativo({ el, t, fps }) {
  const P = el.partitura;
  const med = useMedidas(P);
  const L = useMemo(() => (med ? leiaute(P, med) : null), [med]);
  const estados = useMemo(() => (L ? estadosDoAcento(P, L) : null), [L]);
  const W = P.largura, H = P.altura;
  if (!L) return <div style={{ position: 'absolute', inset: 0, background: P.paleta.fundo }} />;
  const frame = Math.round(t * fps);
  const bs = P.batidas;
  /* O AMBIENTE (09/10): o bokeh e o feixe de luz só quando a identidade pede
     (P.fundo.ambiente). Partitura antiga, sem o campo: como antes (ambiente || 'bokeh'). */
  const ambiente = P.fundo.ambiente || 'bokeh', comLuz = ambiente !== 'limpo';
  let bi = 0;
  while (bi < bs.length - 1 && t >= bs[bi + 1].de) bi++;
  const b = bs[bi];
  const cam0 = cameraEm(t, P.camera);
  /* A câmera NUNCA para: por cima das chaves, uma deriva de mão; e nos
     golpes (a tese que bate, o corte) ela treme e soca. */
  const base = Math.min(W, H) / 1080;
  // A deriva é lenta e longa (inércia); o tremor só no golpe, e dura no máximo 6 quadros.
  let sx = Math.sin(t * 0.43) * 0.006 * W, sy = Math.cos(t * 0.37) * 0.005 * H, sr = Math.sin(t * 0.29) * 0.25, sz = 0;
  for (const g of b.golpes || []) {
    const d = t - g.t;
    if (d < 0 || d > 6 / fps) continue;
    const k = g.forca * (1 - d / (6 / fps));
    sx += Math.sin(d * 70) * 9 * base * k; sy += Math.cos(d * 60) * 6 * base * k; sz += 0.02 * k;
  }
  /* O PULSO: nada assenta de vez — o mundo respira (escala lenta) e bate de
     leve a cada meio segundo, como um bumbo. */
  // A respiração lenta do mundo (sem bumbo: o pulso batendo lia como tranco).
  sz += 0.008 * Math.sin((t * Math.PI * 2) / 3.4);
  const cam = { x: cam0.x + sx, y: cam0.y + sy, zoom: cam0.zoom * (1 + sz), rot: cam0.rot + sr };
  const ac0 = acentoEm(t, estados);
  // O acento também vive: treme de leve e respira na espessura.
  const ac = { ...ac0, pts: ac0.pts.map((q, i) => [q[0] + Math.sin(t * 3.1 + i * 0.5) * 1.6 * base, q[1] + Math.cos(t * 2.7 + i * 0.37) * 1.6 * base]), larg: ac0.larg * (1 + 0.08 * Math.sin(t * 4.2)) };
  // O ponto de onde a inundação nasce: o acento, visto pela câmera.
  const tela = (x, y) => {
    const r = (cam.rot * Math.PI) / 180, dx = x - W / 2 - cam.x, dy = y - H / 2 - cam.y;
    return { x: W / 2 + cam.zoom * (dx * Math.cos(r) - dy * Math.sin(r)), y: H / 2 + cam.zoom * (dx * Math.sin(r) + dy * Math.cos(r)) };
  };
  const origem = tela(ac.cx, ac.cy);
  /* O ESPAÇO 3D: o mundo tem perspectiva, a câmera também inclina (rotateX/Y)
     e as camadas estão em profundidades diferentes — o texto não é adesivo. */
  const rx = Math.cos(t * 0.27) * 1.4, ry = Math.sin(t * 0.31) * 2 + cam.rot * 0.6;
  const mundo = 'translate(' + W / 2 + 'px,' + H / 2 + 'px) rotate(' + cam.rot.toFixed(3) + 'deg) rotateX(' + rx.toFixed(3) + 'deg) rotateY(' + ry.toFixed(3) + 'deg) scale(' + cam.zoom.toFixed(4) + ') translate(' + (-W / 2 - cam.x).toFixed(1) + 'px,' + (-H / 2 - cam.y).toFixed(1) + 'px)';
  // No ar: a batida atual, a anterior enquanto sai.
  const noAr = bs.filter((x, i) => i === bi || (i === bi - 1 && t < x.ate + 0.14 && x.saida !== 'corte'));
  const traco = (
    <svg key="acento" width={W} height={H} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}>
      <path d={suave(ac.pts)} fill="none" stroke={ac.cor} strokeWidth={Math.max(0.1, ac.larg)} strokeLinecap={ac.tampa || 'round'} strokeLinejoin="round" opacity={ac.alfa} />
    </svg>
  );
  // Onde as letras que viram pontos vão parar: a viagem do acento na próxima batida.
  const alvoDe = (x) => {
    const prox = estados.find((e) => e.batida === bs.indexOf(x) + 1);
    return prox ? { x: prox.g.cx, y: prox.g.cy } : null;
  };
  const produtos = (P.produtos || []).filter((q) => t >= q.de - 0.1 && t <= q.ate + 0.35);
  return (
    <div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
      <Fundo P={P} b={b} prevB={bs[bi - 1]} t={t} cam={cam} frame={frame} origem={origem} />
      <div style={{ position: 'absolute', inset: 0, perspective: Math.round(Math.max(W, H) * 0.85) + 'px', perspectiveOrigin: '50% 48%' }}>
      <div style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, transformOrigin: '0 0', transform: mundo, transformStyle: 'preserve-3d' }}>
        {/* O CAMPO (o padrão que cobre o quadro) mora ATRÁS de tudo — do produto 3D
            e do texto — e calmo: desenhado por cima, brigava com os dois (05/10). */}
        {noAr.filter((x) => x.heroi && x.heroi.modo === 'campo' && L[x.id]).map((x) => {
          const bl = L[x.id].bloco, some = 1 - c01((t - x.ate) / 0.14);
          let campo3d = null;
          if (x.heroi.tres) {
            /* As colunas NUNCA encostam no texto: só existem numa faixa abaixo
               (ou acima, de ponta-cabeça) do bloco MEDIDO, com folga. O campo mora
               mais fundo que o texto (translateZ -200), então na tela ele encolhe
               para o centro mais que o texto: a folga medida aqui só cresce lá. */
            const folga = Math.max(x.vao || 0, 0.07 * H);
            const ds = DUR_SAIDA[x.saida] || 0;
            const saiDe = x.ate - ds - 0.15;
            if (H - (bl.y1 + folga) >= 0.34 * H) campo3d = { op: 0.7, faixa: { y0: bl.y1 + folga, y1: H }, saiDe };
            else if (bl.y - folga >= 0.34 * H) campo3d = { op: 0.7, faixa: { y0: 0, y1: bl.y - folga }, teto: true, saiDe };
            if (!campo3d) return null;   // sem faixa livre: sem colunas
          }
          return (
            <div key={'campo' + x.id} style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, transform: 'translateZ(-200px)', opacity: some }}>
              {x.heroi.tres
                ? <Heroi3D h={x.heroi} t={t} de={x.de} ate={x.ate} W={W} H={H} slot={campo3d} />
                : <Heroi b={x} t={t} P={P} slot={{ cx: (bl.x + bl.x1) / 2, cy: (bl.y + bl.y1) / 2, R: Math.min(W, H) * 0.32, op: 0.18 }} />}
            </div>
          );
        })}
        {produtos.map((q, i) => {
          const sai = c01((t - q.ate) / 0.3), entra = ed(c01((t - q.de) / 0.6));
          return (
            <div key={'p' + i} style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: 1 - sai, transform: 'scale(' + (0.94 + 0.06 * entra + 0.1 * sai) + ')', transformOrigin: (q.x + q.largura / 2) + 'px ' + (q.y + q.altura / 2) + 'px' }}>
              <Produto3D el={{ x: q.x, y: q.y, largura: q.largura, altura: q.altura, modelo: q.modelo, cor: q.cor, corAcento: q.corAcento, acabamento: q.acabamento, camera: q.camera, luz: q.luz, ritmo: q.ritmo, t: q.de + 0.05, giro: 0.45, escala: q.escala || 1, semChao: true }} t={t} />
            </div>
          );
        })}
        {!ac.frente && traco}
        {noAr.map((x) => {
          const lay = L[x.id];
          const ds = DUR_SAIDA[x.saida] || 0;
          const te = ds ? c01((t - (x.ate - ds)) / ds) : 0;
          let estilo = {};
          if (x.saida === 'atravessa' && te > 0) {
            estilo = { transform: 'scale(' + (1 + 4.5 * entradaC(te)).toFixed(3) + ')', opacity: 1 - c01((te - 0.45) / 0.55), filter: 'blur(' + (te * 10).toFixed(1) + 'px)' };
          } else if (x.saida === 'chicote' && te > 0) {
            estilo = { transform: 'translateX(' + (-0.25 * W * entradaC(te)).toFixed(1) + 'px) skewX(' + (-6 * te).toFixed(2) + 'deg)', filter: 'blur(' + (te * 12).toFixed(1) + 'px)', opacity: 1 - c01((te - 0.5) / 0.5) };
          } else if (x.saida === 'inunda' && te > 0) estilo = { opacity: 1 - te, transform: 'scale(' + (1 - 0.04 * te) + ')' };
          else if (x.saida === 'corte' && t >= x.ate) return null;
          if (x.entrada === 'chicote' && t < x.de + 0.32) {
            const k = ed(c01((t - x.de) / 0.32));
            estilo = { ...estilo, transform: 'translateX(' + (0.2 * W * (1 - k)).toFixed(1) + 'px) skewX(' + (5 * (1 - k)).toFixed(2) + 'deg)', filter: 'blur(' + ((1 - k) * 10).toFixed(1) + 'px)' };
          }
          if (x.entrada === 'atravessa' && t < x.de + 0.4) {
            const k = ed(c01((t - x.de) / 0.4));
            estilo = { ...estilo, transform: 'scale(' + (0.82 + 0.18 * k).toFixed(4) + ')', filter: 'blur(' + ((1 - k) * 8).toFixed(1) + 'px)' };
          }
          const alvo = x.saida === 'pontos' ? alvoDe(x) : null;
          /* O DOLLY: a batida chega do fundo do espaço, a câmera avança devagar
             nela, e na saída ela vem para cima da lente e passa — atravessamos. */
          const zEntra = x.entrada === 'corte' ? 0 : -340 * (1 - ed(c01((t - x.de) / 0.65)));
          const zSai = ds && x.saida !== 'corte' && x.saida !== 'inunda' ? 560 * entradaC(te) : 0;
          const zB = zEntra + 110 * c01((t - x.de) / Math.max(0.6, x.ate - x.de)) + zSai;
          const tz = 'translateZ(' + zB.toFixed(1) + 'px)';
          return (
            <div key={x.id} style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, transformOrigin: (W / 2) + 'px ' + (H / 2) + 'px', transformStyle: estilo.filter ? undefined : 'preserve-3d',
              ...estilo, transform: [tz, estilo.transform].filter(Boolean).join(' '), opacity: (estilo.opacity != null ? estilo.opacity : 1) * (zSai ? 1 - c01((te - 0.55) / 0.45) : 1) }}>
              {x.heroi && (() => {
                // O lugar do herói: o vazio do bloco (como o ícone); sem vazio, atrás do texto e mais fraco.
                const bl = lay.bloco, M = P.margem;
                if (x.heroi.modo === 'campo') return null;   // desenhado atrás, antes do produto
                const tres = x.heroi.modo === 'objeto3d';
                // O 3D é GRANDE ("um 3d bem presente"): o raio pede o vazio inteiro.
                const R = Math.min(W, H) * (tres ? (W >= H ? 0.26 : 0.22) : (W >= H ? 0.2 : 0.17));
                let slot = null;
                // A área segura (margem.js), com o vão do bloco; partitura antiga: a margem do mundo.
                const S = x.seguro || { x0: M, y0: M * 1.4, x1: W - M, y1: H - M * 1.4 }, V = x.vao || 0;
                if (tres) {
                  /* O 3D NUNCA cruza o texto: mora no maior retângulo livre ao lado do
                     bloco MEDIDO (com o vão e uma folga), dentro da área segura; o raio
                     é o círculo que cabe nele, e o Heroi3D garante que a projeção inteira
                     (anel, tubo, dolly, perspectiva) fica dentro desse raio. Sem
                     retângulo grande o bastante: sem 3D nesta batida. */
                  const folga = Math.max(V, 0.035 * Math.min(W, H));
                  const livres = [
                    { x0: bl.x1 + folga, y0: S.y0, x1: S.x1, y1: S.y1 },
                    { x0: S.x0, y0: S.y0, x1: bl.x - folga, y1: S.y1 },
                    { x0: S.x0, y0: S.y0, x1: S.x1, y1: bl.y - folga },
                    { x0: S.x0, y0: bl.y1 + folga, x1: S.x1, y1: S.y1 },
                  ].map((q) => ({ ...q, r: Math.min(q.x1 - q.x0, q.y1 - q.y0) / 2 })).sort((a, c) => c.r - a.r);
                  const q = livres[0];
                  if (!q || q.r < Math.min(W, H) * 0.12) return null;
                  const ds = DUR_SAIDA[x.saida] || 0;
                  slot = { cx: (q.x0 + q.x1) / 2, cy: (q.y0 + q.y1) / 2, R: Math.min(q.r, Math.min(W, H) * 0.3), saiDe: x.ate - ds - 0.12 };
                  return <Heroi3D h={x.heroi} t={t} de={x.de} ate={x.ate} W={W} H={H} slot={slot} />;
                }
                if (S.x1 - bl.x1 - V >= R * 2.3) slot = { cx: bl.x1 + V + (S.x1 - bl.x1 - V) / 2, cy: (bl.y + bl.y1) / 2, R };
                else if (bl.y - V - S.y0 >= R * 2.2) slot = { cx: W / 2, cy: S.y0 + (bl.y - V - S.y0) / 2, R };
                else if (S.y1 - bl.y1 - V >= R * 2.2) slot = { cx: W / 2, cy: bl.y1 + V + (S.y1 - bl.y1 - V) / 2, R };
                // Sem vazio, não entra (09/10): o anel no meio do texto era enfeite que ninguém pediu.
                else return null;
                return <Heroi b={x} t={t} P={P} slot={slot} />;
              })()}
              <Textura b={x} cx={lay.caixas[x.linhas[0].id]} t={t} P={P} periodo={lay.textura} />
              {x.icone3d && (() => {
                /* O ícone 3D mora no vazio do bloco, no lugar que o projeto escolheu
                   (ic.lados, gerativo.js: direita, esquerda, acima, abaixo ou canto);
                   sem espaço lá, tenta o próximo; sem nenhum, não entra — nunca
                   por cima do texto. Partitura antiga (sem lados): direita ou acima. */
                const ic = x.icone3d, bl = lay.bloco;
                /* A área segura do ícone (margem.js): ele vive mais perto da lente que o
                   texto, então o limite dele é mais apertado; e fica a um vão (4% do lado
                   curto) do bloco. Partitura antiga: a margem do mundo, como antes. */
                const M = P.margem, S = x.seguroIcone || { x0: M, y0: M, x1: W - M, y1: H - M }, V = x.vao || 8;
                const tam = Math.min(ic.tamanho, (S.x1 - S.x0) * 0.4, (S.y1 - S.y0) * 0.6);
                const xTopo = Math.min(S.x1 - tam, Math.max(S.x0, x.bloco.alinha === 'centro' ? (bl.x + bl.x1) / 2 - tam / 2 : bl.x - tam * 0.08));
                const fora = (qx, qy) => qx + tam <= bl.x - V || qx >= bl.x1 + V || qy + tam <= bl.y - V || qy >= bl.y1 + V;
                const LUGAR = {
                  'direita': () => (S.x1 - bl.x1 - V >= tam ? [Math.min(S.x1 - tam, bl.x1 + V + (S.x1 - bl.x1 - V - tam) / 2), Math.min(S.y1 - tam, Math.max(S.y0, (bl.y + bl.y1) / 2 - tam / 2))] : null),
                  'esquerda': () => (bl.x - V - S.x0 >= tam ? [Math.max(S.x0, S.x0 + (bl.x - V - S.x0 - tam) / 2), Math.min(S.y1 - tam, Math.max(S.y0, (bl.y + bl.y1) / 2 - tam / 2))] : null),
                  'acima': () => (bl.y - V - S.y0 >= tam ? [xTopo, Math.max(S.y0, bl.y - V - tam)] : null),
                  'abaixo': () => (S.y1 - bl.y1 - V >= tam ? [xTopo, Math.min(S.y1 - tam, bl.y1 + V)] : null),
                  // O canto livre mais longe do texto: o ícone pequeno, de assinatura.
                  'canto': () => [[S.x1 - tam, S.y0], [S.x1 - tam, S.y1 - tam], [S.x0, S.y0], [S.x0, S.y1 - tam]].find(([qx, qy]) => fora(qx, qy)) || null,
                };
                const ordem = ic.lados || [ic.lado, 'acima'];
                const achou = ordem.map((l) => LUGAR[l] && LUGAR[l]()).find(Boolean);
                if (!achou) return null;
                const [px, py] = achou;
                /* O ÍCONE PLANO (09/10): o do Iconify que o roteiro pediu (o pão, o QR
                   code), na cor do acento da identidade — entra em mola e flutua de leve. */
                /* O ÍCONE DA BIBLIOTECA (11/10): o 3D renderizado do Fluent (png) ou a
                   animação pronta do Noto (lottie). Entra em mola, gira de leve no
                   eixo Y e flutua, com a sombra no chão que dá peso de objeto. */
                if (ic.png || ic.lottie) {
                  const k = MOLA(t - ic.t), dt = t - ic.t, sai = c01((t - (x.ate - 0.25)) / 0.25);
                  const fl = Math.sin(dt * 1.4) * 0.025 * tam, giro = Math.sin(dt * 0.9) * 10;
                  return (
                    <div style={{ position: 'absolute', left: Math.round(px), top: Math.round(py), width: tam, height: tam, opacity: c01(k * 1.4) * (1 - sai),
                      transform: 'translateZ(140px) scale(' + (0.5 + 0.5 * k).toFixed(4) + ')', transformOrigin: '50% 70%' }}>
                      <div style={{ position: 'absolute', left: '18%', right: '18%', bottom: '-4%', height: '9%', borderRadius: '50%', background: 'rgba(0,0,0,0.28)',
                        filter: 'blur(' + (tam * 0.03).toFixed(1) + 'px)', transform: 'scale(' + (1 - fl / tam * 2).toFixed(3) + ')' }} />
                      <div style={{ position: 'absolute', inset: 0, transform: 'translateY(' + fl.toFixed(1) + 'px) perspective(800px) rotateY(' + giro.toFixed(2) + 'deg) rotate(' + ((1 - k) * -10).toFixed(2) + 'deg)' }}>
                        {ic.png
                          ? <Img src={staticFile(ic.png)} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
                          : <Animacao el={{ src: ic.lottie, x: 0, y: 0, largura: tam, altura: tam, loop: true }} t={Math.max(0, dt)} />}
                      </div>
                    </div>
                  );
                }
                if (ic.svg) {
                  const k = MOLA(t - ic.t), fl = Math.sin((t - ic.t) * 1.3) * 0.012 * tam, sai = c01((t - (x.ate - 0.25)) / 0.25);
                  return <div style={{ position: 'absolute', left: Math.round(px), top: Math.round(py + fl), width: tam, height: tam, color: ic.cor, opacity: c01(k * 1.4) * (1 - sai),
                    transform: 'translateZ(120px) scale(' + (0.6 + 0.4 * k).toFixed(4) + ') rotate(' + ((1 - k) * -8).toFixed(2) + 'deg)', transformOrigin: '50% 60%' }}
                    dangerouslySetInnerHTML={{ __html: ic.svg }} />;
                }
                return <div style={{ position: 'absolute', inset: 0, transform: 'translateZ(150px)' }}><Icone3D el={{ ...ic, tamanho: tam, x: Math.round(px), y: Math.round(py) }} t={t} /></div>;
              })()}
              {x.linhas.map((l, li) => {
                const cx = lay.caixas[l.id];
                if (t < l.t - 0.2) return null;
                let eLinha = {};
                if (x.saida === 'desliza') {
                  const tl = c01((t - (x.ate - DUR_SAIDA.desliza) - li * 3 / fps) / (DUR_SAIDA.desliza * 0.7));
                  if (tl > 0) eLinha = { transform: 'translateY(' + (-0.5 * ed(tl) * cx.s).toFixed(1) + 'px)', opacity: 1 - tl, filter: 'blur(' + (tl * 4).toFixed(1) + 'px)' };
                }
                /* Cada linha numa PROFUNDIDADE: o rótulo perto da lente, o apoio
                   atrás, a tese no foco — e a lente desfoca o que não é a tese.
                   A linha entra deitada (rotateX) e balança no eixo Y. */
                const zL = { rotulo: 120, aspas: 170, apoio: -120, fraco: -150, numero: 40, item: -35 * (li % 3) }[l.papel] || 0;
                const deita = (1 - ed(c01((t - l.t) / 0.55))) * 32;
                const balanco = Math.sin(t * 0.42 + li * 0.9) * 1.4;
                const dof = Math.min(1.1, Math.abs(zL) / 140) * 0.9;
                const origemL = (cx.x + cx.w / 2).toFixed(0) + 'px ' + (cx.y + cx.h / 2).toFixed(0) + 'px';
                const t3 = 'translateZ(' + zL + 'px) rotateX(' + deita.toFixed(2) + 'deg) rotateY(' + balanco.toFixed(2) + 'deg)';
                eLinha = { ...eLinha, transform: [t3, eLinha.transform].filter(Boolean).join(' '), transformOrigin: origemL,
                  filter: [dof > 0.05 ? 'blur(' + dof.toFixed(2) + 'px)' : '', eLinha.filter || ''].filter(Boolean).join(' ') || undefined };
                return (
                  <div key={l.id} style={{ position: 'absolute', inset: 0, ...eLinha }}>
                    <Linha l={l} b={x} cx={cx} t={t} fps={fps} P={P} frame={frame} alvoSaida={alvo} centroBloco={{ x: (lay.bloco.x + lay.bloco.x1) / 2, y: (lay.bloco.y + lay.bloco.y1) / 2 }} />
                  </div>
                );
              })}
            </div>
          );
        })}
        {ac.frente && traco}
      </div>
      </div>
      {/* A FRENTE: manchas de luz que passam perto da lente (bokeh) e o feixe
          de luz que varre o quadro quando uma batida chega. */}
      {ambiente === 'bokeh' && [0, 1, 2, 3].map((k) => {
        const per = 5 + acaso(P.semente % 997 + k * 7) * 4, fase = acaso(P.semente % 991 + k * 13) * per;
        const u = ((t + fase) % per) / per;
        const tam = Math.min(W, H) * (0.28 + acaso(k * 17 + 3) * 0.3);
        const x = lerp(-0.35 * W, 1.35 * W, u) - cam.x * 1.8, y = H * (0.15 + acaso(k * 29 + 5) * 0.7) - cam.y * 1.8;
        const cor = k % 2 ? b.cores.acento : b.cores.texto;
        return <div key={'bk' + k} style={{ position: 'absolute', left: x - tam / 2, top: y - tam / 2, width: tam, height: tam, borderRadius: '50%', pointerEvents: 'none',
          background: 'radial-gradient(circle, ' + alfa(cor, k % 2 ? 0.16 : 0.08) + ' 0%, transparent 66%)', opacity: Math.sin(Math.PI * u) }} />;
      })}
      {comLuz && (() => {
        const k = c01((t - b.de) / 0.95);
        if (k <= 0 || k >= 1) return null;
        const x = lerp(-0.7 * W, 1.2 * W, ed(k));
        const claro = parseInt(b.cores.fundo.slice(1, 3), 16) > 160;
        return <div style={{ position: 'absolute', top: -H * 0.2, height: H * 1.4, left: x, width: W * 0.45, pointerEvents: 'none', transform: 'rotate(14deg)',
          background: 'linear-gradient(90deg, transparent, ' + (claro ? 'rgba(255,255,255,0.55)' : 'rgba(255,255,255,0.12)') + ', transparent)', mixBlendMode: claro ? 'soft-light' : 'screen', opacity: Math.sin(Math.PI * k) }} />;
      })()}
      {P.hud && <HudG P={P} b={b} bi={bi} t={t} fps={fps} />}
      {/* O acabamento: grão que muda a cada quadro e vinheta suave. */}
      <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', backgroundImage: GRAO, backgroundSize: '220px 220px',
        backgroundPosition: Math.floor(acaso(frame) * 220) + 'px ' + Math.floor(acaso(frame + 7) * 220) + 'px', opacity: P.fundo.grao * 1.6, mixBlendMode: 'overlay' }} />
      <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', background: 'radial-gradient(ellipse at 50% 45%, transparent 55%, rgba(0,0,0,' + (parseInt(b.cores.fundo.slice(1, 3), 16) > 160 ? 0.06 : 0.18) + ') 100%)' }} />
    </div>
  );
}

export const GERATIVO = { gerativo: Gerativo };
