primocode 9.10.0 → 10.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +30 -6
  2. package/bin/primocode.js +169 -342
  3. package/lib/act.js +360 -39
  4. package/lib/browser.js +105 -4
  5. package/lib/casar.js +108 -0
  6. package/lib/catalogo.js +6 -2
  7. package/lib/claude-engine.js +38 -17
  8. package/lib/codex-engine.js +27 -9
  9. package/lib/config.js +10 -3
  10. package/lib/cursor.js +10 -1
  11. package/lib/entrada.js +77 -11
  12. package/lib/estudio/cinema.js +27 -9
  13. package/lib/estudio/diretor.js +21 -1
  14. package/lib/estudio/ende.js +16 -5
  15. package/lib/estudio/estilo.js +66 -0
  16. package/lib/estudio/gerativo.js +1453 -0
  17. package/lib/estudio/glifos.js +215 -0
  18. package/lib/estudio/hibrido.js +165 -0
  19. package/lib/estudio/index.js +284 -53
  20. package/lib/estudio/kit/base.jsx +285 -0
  21. package/lib/estudio/kit/gerativo.jsx +980 -0
  22. package/lib/estudio/kit/index.jsx +13 -0
  23. package/lib/estudio/kit/marca.jsx +741 -0
  24. package/lib/estudio/kit/tres.jsx +308 -0
  25. package/lib/estudio/kit/vox.jsx +357 -0
  26. package/lib/estudio/kit.js +78 -0
  27. package/lib/estudio/lancamento.js +441 -0
  28. package/lib/estudio/motion.js +2 -5
  29. package/lib/estudio/plano.js +28 -3
  30. package/lib/estudio/referencias.js +105 -0
  31. package/lib/estudio/remotion.js +139 -51
  32. package/lib/estudio/ritmo.js +77 -0
  33. package/lib/estudio/sons-leves.js +38 -1
  34. package/lib/estudio/variedade.js +105 -0
  35. package/lib/estudio/vox.js +414 -0
  36. package/lib/fala.js +1 -94
  37. package/lib/mcp.js +156 -0
  38. package/lib/personas.js +67 -0
  39. package/lib/pipeline.js +55 -56
  40. package/lib/repeticao.js +3 -1
  41. package/lib/skills.js +44 -2
  42. package/lib/snapshot.js +111 -0
  43. package/lib/tools.js +100 -22
  44. package/lib/ui.js +50 -8
  45. package/lib/video/analise.js +89 -0
  46. package/lib/video/auto.js +329 -0
  47. package/lib/video/camadas.js +207 -0
  48. package/lib/video/cli.js +74 -0
  49. package/lib/video/delta.js +90 -0
  50. package/lib/video/edits.js +379 -0
  51. package/lib/video/filters.js +280 -0
  52. package/lib/video/fx3d/Camadas.jsx +64 -0
  53. package/lib/video/fx3d/Objetos.jsx +128 -0
  54. package/lib/video/fx3d/Root.jsx +18 -0
  55. package/lib/video/fx3d/modelos.jsx +304 -0
  56. package/lib/video/fx3d.js +108 -0
  57. package/lib/video/gerados.js +119 -0
  58. package/lib/video/objetos.js +111 -0
  59. package/lib/video/ops-fx.js +453 -0
  60. package/lib/video/ops.js +347 -0
  61. package/lib/video/paralelo.js +50 -0
  62. package/lib/video/pedido.js +191 -0
  63. package/lib/video/projeto.js +133 -0
  64. package/lib/video/rastrear.js +95 -0
  65. package/lib/video/rastro.js +276 -0
  66. package/lib/video/render.js +274 -0
  67. package/lib/video/texto.js +144 -0
  68. package/lib/video/timeline.js +318 -0
  69. package/lib/video/transcript.js +124 -0
  70. package/lib/voz.js +75 -223
  71. package/package.json +2 -3
  72. package/skills/motion-de-primeira/SKILL.md +5 -5
  73. package/studio/narrar.py +10 -7
  74. package/studio/rastrear.py +399 -0
  75. package/studio/texto.py +208 -0
  76. package/voz/primo_voz/__main__.py +96 -236
  77. package/voz/primo_voz/transcrever.py +31 -1
  78. package/app/ansi.js +0 -55
  79. package/app/app.css +0 -500
  80. package/app/app.js +0 -1017
  81. package/app/icone.svg +0 -6
  82. package/app/index.html +0 -220
  83. package/app/primo-marca.js +0 -192
  84. package/lib/app.js +0 -615
  85. package/lib/icone.js +0 -263
  86. package/lib/instalar-app.js +0 -231
  87. package/lib/painel.js +0 -291
  88. package/voz/primo_voz/desperta.py +0 -195
  89. package/voz/primo_voz/orbe.py +0 -406
@@ -0,0 +1,1453 @@
1
+ /**
2
+ * gerativo.js — o motion GERATIVO: sem template, a tipografia é o filme.
3
+ *
4
+ * "O motion que ele faz é tudo igual — só muda a informação num template
5
+ * pronto. Não pode ter template. Motion é animação contínua, que
6
+ * transforma as coisas, o texto: mexe a própria FONTE." — Joel (29–30/09)
7
+ *
8
+ * Os estilos de cena por parte (motion.js, vox.js, lancamento.js) ligam cada
9
+ * tipo de parte a um leiaute fixo: a lista é sempre a mesma lista. Aqui não.
10
+ * O roteiro e os tempos da voz viram uma PARTITURA: uma linha do tempo só,
11
+ * dividida em BATIDAS. Cada batida tem:
12
+ *
13
+ * as palavras que a voz diz nela, cada uma com o seu instante (o áudio
14
+ * comanda o quadro — regra 01 do guia VOX);
15
+ * um papel por palavra (tese, apoio, função) — a tese é a mais forte;
16
+ * uma COMPOSIÇÃO sorteada entre as que o CONTEÚDO permite (palavra
17
+ * gigante, pilha à esquerda, divisão, contador, cascata, parede de texto,
18
+ * citação marcada, marca que se constrói, palavras faladas, produto 3D) —
19
+ * nunca a mesma duas batidas seguidas;
20
+ * um COMPORTAMENTO por linha, do vocabulário que os eixos da fonte
21
+ * permitem (carimbo, máscara, onda, decodificar, peso 100→900 no eixo
22
+ * wght, largura no wdth, tracking, desfoque, split-flap, pontos, contorno);
23
+ * a forma de ACENTO — um traço só que atravessa o vídeo inteiro e vira
24
+ * sublinhado, anel, risco, barra, marcador, marca-texto, ponto final;
25
+ * a SAÍDA, que transforma em vez de cortar: as letras viram pontos e voam
26
+ * para o acento, a fonte afina e evapora, a câmera atravessa a palavra, o
27
+ * acento cresce e vira o fundo da próxima.
28
+ *
29
+ * O sorteio é DETERMINÍSTICO: a semente é o hash do roteiro. O mesmo roteiro
30
+ * dá a mesma partitura; dois roteiros diferentes, filmes diferentes. As
31
+ * regras do guia são restrições do sorteio: hierarquia, 30–40% de respiro,
32
+ * um acento em no máximo 3 pontos, até 6 elementos, uma ação dominante por
33
+ * vez, stagger de 3–6 quadros, burst de 16–18, traço de 10–16, leitura de
34
+ * pelo menos 1 s, mola SNAPPY no elemento e bezier longa na câmera.
35
+ *
36
+ * Quem desenha é kit/gerativo.jsx: UM componente que lê a partitura quadro
37
+ * a quadro. Puro: tempos, narração, trilha e sons entram; o plano sai.
38
+ */
39
+
40
+ 'use strict';
41
+
42
+ const { misturarCor, ehCor } = require('./morfar.js');
43
+ const { volumesDaTrilha } = require('./ritmo.js');
44
+ const { falaDe } = require('./composicao.js');
45
+
46
+ const FPS = 30;
47
+ const QUADRO = 1 / FPS;
48
+ const r3 = (n) => Math.round(n * 1000) / 1000;
49
+ const r1 = (n) => Math.round(n * 10) / 10;
50
+
51
+ /* As regras do guia MOTION VOX STYLE, em números. Os testes leem daqui. */
52
+ const REGRAS = {
53
+ SNAPPY: { damping: 26, stiffness: 240, mass: 0.65 },
54
+ stagger: [3, 6], // quadros entre elementos
55
+ burst: [16, 18], // quadros da entrada de um elemento
56
+ traco: [10, 16], // quadros do traço de markup
57
+ leitura: 1.0, // segundos parado antes de sair
58
+ elementos: 6, // por quadro
59
+ acentos: 3, // pontos de cor por batida
60
+ respiro: [0.3, 0.4], // fração do quadro que é fundo puro
61
+ };
62
+
63
+ // ── o acaso com semente ─────────────────────────────────────────────────
64
+ function hash(txt) {
65
+ let h = 0x811c9dc5;
66
+ const s = String(txt);
67
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
68
+ return h >>> 0;
69
+ }
70
+ /** mulberry32: o mesmo número de partida dá sempre a mesma sequência. */
71
+ function sorteador(semente) {
72
+ let a = semente >>> 0;
73
+ const prox = () => {
74
+ a = (a + 0x6d2b79f5) >>> 0;
75
+ let t = a;
76
+ t = Math.imul(t ^ (t >>> 15), t | 1);
77
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
78
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
79
+ };
80
+ const s = {
81
+ prox,
82
+ entre: (a0, b0) => a0 + (b0 - a0) * prox(),
83
+ um: (lista) => lista[Math.floor(prox() * lista.length) % lista.length],
84
+ chance: (p) => prox() < p,
85
+ /** Sorteio com peso: {nome: peso}. Peso 0 não sai. */
86
+ pesado: (pesos) => {
87
+ const itens = Object.entries(pesos).filter(([, p]) => p > 0);
88
+ if (!itens.length) return null;
89
+ const total = itens.reduce((n, [, p]) => n + p, 0);
90
+ let x = prox() * total;
91
+ for (const [nome, p] of itens) { if ((x -= p) <= 0) return nome; }
92
+ return itens[itens.length - 1][0];
93
+ },
94
+ };
95
+ return s;
96
+ }
97
+
98
+ // ── as fontes variáveis (Google Fonts, eixos conferidos em 30/09) ─────────
99
+ /* largura: o quanto a fonte é mais larga que uma grotesca comum (para a
100
+ estimativa; o componente mede de verdade e encolhe o que não couber). */
101
+ const VARIAVEIS = {
102
+ 'Roboto Flex': { eixos: { wght: [100, 1000], wdth: [25, 151], opsz: [8, 144], slnt: [-10, 0], GRAD: [-200, 150] }, largura: 0.98 },
103
+ 'Fraunces': { eixos: { wght: [100, 900], opsz: [9, 144], SOFT: [0, 100], WONK: [0, 1] }, largura: 1.0, serifa: true },
104
+ 'Inter Tight': { eixos: { wght: [100, 900] }, largura: 0.93 },
105
+ 'Inter': { eixos: { wght: [100, 900], opsz: [14, 32] }, largura: 1.0 },
106
+ 'Unbounded': { eixos: { wght: [200, 900] }, largura: 1.3 },
107
+ 'Newsreader': { eixos: { wght: [200, 800], opsz: [6, 72] }, largura: 0.88, serifa: true },
108
+ 'DM Sans': { eixos: { wght: [100, 1000], opsz: [9, 40] }, largura: 1.0 },
109
+ 'Plus Jakarta Sans': { eixos: { wght: [200, 800] }, largura: 1.04 },
110
+ 'Bricolage Grotesque': { eixos: { wght: [200, 800], wdth: [75, 100], opsz: [12, 96] }, largura: 0.98 },
111
+ 'Anybody': { eixos: { wght: [100, 900], wdth: [50, 150] }, largura: 1.0 },
112
+ 'Archivo': { eixos: { wght: [100, 900], wdth: [62, 125] }, largura: 1.0 },
113
+ 'Mona Sans': { eixos: { wght: [200, 900], wdth: [75, 125] }, largura: 1.0 },
114
+ 'IBM Plex Sans': { eixos: { wght: [100, 700], wdth: [85, 100] }, largura: 0.98 },
115
+ 'Space Grotesk': { eixos: { wght: [300, 700] }, largura: 1.02 },
116
+ 'Big Shoulders': { eixos: { wght: [100, 900], opsz: [10, 72] }, largura: 0.72 },
117
+ 'Geist': { eixos: { wght: [100, 900] }, largura: 0.98 },
118
+ 'Manrope': { eixos: { wght: [200, 800] }, largura: 1.02 },
119
+ 'Sora': { eixos: { wght: [100, 800] }, largura: 1.06 },
120
+ 'Outfit': { eixos: { wght: [100, 900] }, largura: 0.98 },
121
+ 'Montserrat': { eixos: { wght: [100, 900] }, largura: 1.12 },
122
+ 'JetBrains Mono': { eixos: { wght: [100, 800] }, largura: 1.1, mono: true },
123
+ // A biblioteca anti-genérica (30/09): faces com caráter, conferidas no Google Fonts.
124
+ 'Bodoni Moda': { eixos: { wght: [400, 900], opsz: [6, 96] }, largura: 0.96, serifa: true },
125
+ 'Playfair Display': { eixos: { wght: [400, 900] }, largura: 0.98, serifa: true },
126
+ 'Familjen Grotesk': { eixos: { wght: [400, 700] }, largura: 0.96 },
127
+ 'Schibsted Grotesk': { eixos: { wght: [400, 900] }, largura: 0.98 },
128
+ 'Hanken Grotesk': { eixos: { wght: [100, 900] }, largura: 0.97 },
129
+ 'Syne': { eixos: { wght: [400, 800] }, largura: 1.1 },
130
+ 'Martian Mono': { eixos: { wdth: [75, 112.5], wght: [100, 800] }, largura: 1.15, mono: true },
131
+ // As estáticas: sem eixo para animar, servem de texto, citação e rótulo.
132
+ 'Instrument Serif': { eixos: {}, css: 'ital@0;1', largura: 0.86, serifa: true },
133
+ 'Space Mono': { eixos: {}, css: 'wght@400;700', largura: 1.1, mono: true },
134
+ 'Fragment Mono': { eixos: {}, css: '', largura: 1.1, mono: true },
135
+ };
136
+
137
+ /* AS FONTES DE IA: o que todo vídeo feito por IA usa. Nunca são o padrão do
138
+ gerativo; se a marca manda uma delas como título, ela vira o texto e o
139
+ título ganha uma face com caráter. */
140
+ const BANIDAS = ['Inter', 'Inter Tight', 'Roboto', 'Roboto Flex', 'Poppins', 'Montserrat', 'Open Sans', 'DM Sans', 'Plus Jakarta Sans',
141
+ 'Unbounded', 'Lato', 'Arial', 'Helvetica', 'Nunito', 'Raleway', 'Space Grotesk', 'Manrope', 'Outfit', 'Geist', 'Sora'];
142
+ /* AS CORES DE IA: o violeta de SaaS (#6366f1 e vizinhos) e o neon sobre preto. */
143
+ function corGenerica(hex) {
144
+ const n = parseInt(String(hex).slice(1), 16), r = (n >> 16 & 255) / 255, g = (n >> 8 & 255) / 255, b = (n & 255) / 255;
145
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
146
+ if (!d) return false;
147
+ let h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
148
+ h = (h * 60 + 360) % 360;
149
+ const sat = d / (1 - Math.abs(mx + mn - 1) || 1);
150
+ if (h >= 238 && h <= 300 && sat > 0.35) return true; // violeta, roxo, índigo de SaaS
151
+ if (sat > 0.85 && mx > 0.9 && ((h >= 170 && h <= 200) || (h >= 295 && h <= 330))) return true; // ciano e magenta neon
152
+ return false;
153
+ }
154
+
155
+ /* OS HUMORES: do assunto do roteiro saem a dupla de fontes, a paleta, o
156
+ gênero da trilha e a família de sons. Cada humor tem várias opções; a
157
+ semente escolhe — dois vídeos do mesmo assunto não saem iguais. */
158
+ const HUMORES = {
159
+ editorial: {
160
+ palavras: 'historia por que porque explica cancelamento atenas guerra livro politica cultura democracia voto lei grego roma imperio arte jornal',
161
+ displays: ['Fraunces', 'Bodoni Moda', 'Playfair Display'], textos: ['Schibsted Grotesk', 'Familjen Grotesk'], monos: ['Martian Mono', 'Space Mono'],
162
+ serifas: ['Instrument Serif', 'Fraunces'], cineticas: ['Anybody', 'Archivo'],
163
+ paletas: [['#EFE8DA', '#1A1714', '#7A1F1F'], ['#F3EEE3', '#14161F', '#1F3FBF'], ['#E9E3D3', '#211E1A', '#D9481C']],
164
+ generos: ['jazz', 'acoustic', 'classical'], som: 'papel',
165
+ },
166
+ tecnologia: {
167
+ palavras: 'ia ai app software codigo modelo modelos dados claude sonnet gpt algoritmo digital tecnologia plataforma api chip computador agente agentes agentic rapido barato versao lancamento',
168
+ displays: ['Bricolage Grotesque', 'Mona Sans', 'Schibsted Grotesk', 'Syne'], textos: ['Familjen Grotesk', 'Hanken Grotesk'], monos: ['Martian Mono', 'Fragment Mono'],
169
+ serifas: ['Instrument Serif', 'Fraunces'], cineticas: ['Mona Sans', 'Anybody', 'Archivo'],
170
+ paletas: [['#1B1D1A', '#E9E6DD', '#C6F432'], ['#10161F', '#F1ECE0', '#FF5F1F'], ['#2F3A26', '#EDE6D3', '#FF6F59'], ['#F0EDE6', '#141414', '#FF4A1C']],
171
+ generos: ['electronica', 'lofi-chill-beats', 'funk'], som: 'analogico',
172
+ },
173
+ energia: {
174
+ palavras: 'som audio fone fones headset musica bateria esporte treino velocidade energia jogo game festa barulho silencio ouca ouvir escute vento corrida',
175
+ displays: ['Anybody', 'Big Shoulders', 'Syne', 'Archivo'], textos: ['Hanken Grotesk', 'Familjen Grotesk'], monos: ['Space Mono', 'Martian Mono'],
176
+ serifas: ['Bodoni Moda', 'Instrument Serif'], cineticas: ['Anybody', 'Archivo'],
177
+ paletas: [['#121110', '#F2EEE6', '#FF4F00'], ['#1E1A16', '#F4EBDD', '#E8C547'], ['#0F1E3D', '#F3EDE1', '#FF7A4D']],
178
+ generos: ['funk', 'dance-club', 'electronica'], som: 'digital',
179
+ },
180
+ natureza: {
181
+ palavras: 'ceu azul sol luz natureza planta plantas agua mar terra fisica ciencia universo estrela estrelas clima ar cor cores olhos violeta vermelho por do',
182
+ displays: ['Fraunces', 'Bricolage Grotesque', 'Bodoni Moda'], textos: ['Hanken Grotesk', 'Schibsted Grotesk'], monos: ['Martian Mono', 'Space Mono'],
183
+ serifas: ['Instrument Serif', 'Fraunces'], cineticas: ['Anybody', 'Mona Sans'],
184
+ paletas: [['#F2EDE2', '#13233F', '#2546D9'], ['#0F2A2A', '#EDE7DA', '#F2A541'], ['#E7E2D2', '#22301F', '#C8553D']],
185
+ generos: ['ambient', 'acoustic', 'cinematic'], som: 'organico',
186
+ },
187
+ luxo: {
188
+ palavras: 'luxo moda perfume joia premium elegante vinho couro ouro atelier',
189
+ displays: ['Bodoni Moda', 'Playfair Display', 'Fraunces'], textos: ['Hanken Grotesk', 'Familjen Grotesk'], monos: ['Fragment Mono', 'Martian Mono'],
190
+ serifas: ['Bodoni Moda', 'Instrument Serif'], cineticas: ['Anybody', 'Mona Sans'],
191
+ paletas: [['#2A0E12', '#F0E6D6', '#C9A24A'], ['#F1EBE1', '#1B1A17', '#8C1C13']],
192
+ generos: ['jazz', 'soul-rnb', 'classical'], som: 'papel',
193
+ },
194
+ };
195
+ /* A cor que o próprio assunto diz ("por que o céu é AZUL") vira o acento. */
196
+ const CORES_DITAS = { azul: '#2546D9', vermelho: '#C8291E', verde: '#2F7D4F', laranja: '#FF5F1F', amarelo: '#E8B92F', dourado: '#C9A24A', rosa: '#E86A8A', coral: '#FF6F59' };
197
+
198
+ /* A palavra que pede ícone → o ícone 3D (kit/tres.jsx ICONES). */
199
+ const ICONE_DE = {
200
+ bateria: 'bateria', carga: 'bateria', tomada: 'bateria', horas: 'relogio', hora: 'relogio', minutos: 'relogio', tempo: 'relogio', espera: 'relogio', dia: 'relogio',
201
+ rapido: 'raio', velocidade: 'raio', energia: 'raio', agil: 'raio', instantaneo: 'raio', conversa: 'balao', conversar: 'balao', chat: 'balao', pergunta: 'balao',
202
+ seguro: 'escudo', seguranca: 'escudo', protege: 'escudo', protecao: 'escudo', confianca: 'escudo', coracao: 'coracao', amor: 'coracao', saude: 'coracao', pessoas: 'coracao',
203
+ melhor: 'estrela', estrela: 'estrela', estrelas: 'estrela', qualidade: 'estrela', sol: 'sol', luz: 'sol', video: 'play', assista: 'play', experimente: 'play',
204
+ engrenagem: 'engrenagem', ajuste: 'engrenagem', agentes: 'engrenagem', agente: 'engrenagem', trabalho: 'engrenagem', senha: 'cadeado', privacidade: 'cadeado', bloqueio: 'cadeado',
205
+ grafico: 'barras', cresce: 'barras', crescimento: 'barras', dados: 'barras', barato: 'barras', conta: 'barras', fone: 'fone', headset: 'fone', almofadas: 'fone',
206
+ som: 'onda', audio: 'onda', microfone: 'onda', vento: 'onda', escute: 'onda', ouca: 'onda', barulho: 'onda', celular: 'celular', app: 'celular', aparelho: 'celular', aparelhos: 'celular',
207
+ notebook: 'celular', pronto: 'check', feito: 'check', certo: 'check', proximo: 'seta', passo: 'seta', adiante: 'seta', ricocheteia: 'seta',
208
+ };
209
+
210
+ /** O humor do roteiro: as palavras dele contra as de cada humor (empate: a semente). */
211
+ function humorDe(roteiro, s) {
212
+ const txt = new Set(tokens((roteiro.partes || []).map((p) => [p.titulo, p.texto, p.fala, p.sub, ...(p.itens || [])].filter(Boolean).join(' ')).join(' ')
213
+ + ' ' + ((roteiro.marca && roteiro.marca.nome) || '') + ' ' + (roteiro.pedido || '')));
214
+ const nota = {};
215
+ for (const [h, d] of Object.entries(HUMORES)) nota[h] = d.palavras.split(' ').filter((w) => txt.has(w)).length + s.prox() * 0.5;
216
+ return Object.entries(nota).sort((a, b) => b[1] - a[1])[0][0];
217
+ }
218
+ /* As que têm largura (wdth): é nelas que a palavra estica. */
219
+ const CINETICAS = ['Roboto Flex', 'Anybody', 'Archivo', 'Mona Sans'];
220
+
221
+ /** A linha do css2 do Google para os eixos: minúsculos em ordem, depois os maiúsculos. */
222
+ function especEixos(familia) {
223
+ const v = VARIAVEIS[familia];
224
+ if (!v) return null;
225
+ if (v.css != null) return v.css;
226
+ if (!Object.keys(v.eixos).length) return '';
227
+ const tags = Object.keys(v.eixos).sort((a, b) => {
228
+ const A = a === a.toUpperCase(), B = b === b.toUpperCase();
229
+ if (A !== B) return A ? 1 : -1;
230
+ return a < b ? -1 : 1;
231
+ });
232
+ return tags.join(',') + '@' + tags.map((t) => v.eixos[t].join('..')).join(',');
233
+ }
234
+
235
+ const eixoDe = (familia, tag) => (VARIAVEIS[familia] && VARIAVEIS[familia].eixos[tag]) || null;
236
+ const prende = (v, [a, b]) => Math.max(a, Math.min(b, v));
237
+
238
+ // ── o texto ─────────────────────────────────────────────────────────────
239
+ const norm = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
240
+ const tokens = (t) => norm(t).split(/[^a-z0-9%]+/).filter(Boolean);
241
+ const FUNCAO = new Set(('o a os as um uma uns umas de do da dos das em no na nos nas por pra pro para com sem e ou que se '
242
+ + 'ao aos à às é ser seu sua seus suas te lhe me meu minha isso isto esse essa este esta ele ela eles elas voce nao mais '
243
+ + 'the a an of to in on for and or with is are it this that your you our by at as from be').split(' '));
244
+ const funcao = (w) => FUNCAO.has(tokens(w)[0] || '');
245
+ /* As que não podem terminar linha: artigo, preposição, conjunção. */
246
+ const FRACAS = new Set('o a os as um uma de do da dos das em no na nos nas por pra pro para com sem e ou que se ao aos à às the an of to in on for and or with by at from'.split(' '));
247
+ const fraca = (w) => FRACAS.has(tokens(w)[0] || '');
248
+
249
+ /* A largura do texto em "em", pela forma de cada letra. É uma ESTIMATIVA:
250
+ serve para quebrar linha e escolher tamanho; quem garante que cabe é o
251
+ componente, que mede no navegador e encolhe. */
252
+ function emDe(ch) {
253
+ if (ch === ' ') return 0.27;
254
+ if (/[iIl1.,:;'!|j·]/.test(ch)) return 0.3;
255
+ if (/[mwMW]/.test(ch)) return 0.86;
256
+ if (/[A-ZÀ-Ý]/.test(ch)) return 0.68;
257
+ if (/[0-9]/.test(ch)) return 0.58;
258
+ if (/[%&@#]/.test(ch)) return 0.8;
259
+ return 0.54;
260
+ }
261
+ function larguraEm(texto, { fonte = 'Roboto Flex', wght = 400, wdth = 100, tracking = 0, maiusc = false } = {}) {
262
+ const v = VARIAVEIS[fonte] || { largura: 1 };
263
+ const t = maiusc ? String(texto).toUpperCase() : String(texto);
264
+ let em = 0;
265
+ for (const ch of t) em += (v.mono ? 0.6 : emDe(ch)) + tracking;
266
+ const peso = 1 + ((wght - 400) / 500) * 0.1;
267
+ const larg = 0.3 + 0.7 * (wdth / 100);
268
+ return em * v.largura * peso * larg;
269
+ }
270
+ /** O maior tamanho (px) em que o texto cabe na largura, com teto. */
271
+ function tamanhoQueCabe(texto, largura, max, op) {
272
+ const em = larguraEm(texto, op) || 1;
273
+ return Math.max(12, Math.min(max, Math.floor((largura * 0.94) / em)));
274
+ }
275
+ /* A QUEBRA DE LINHA de diretor de arte, não de editor de texto: todas as
276
+ partições da frase em até `n` linhas, e ganha a que tem linhas parelhas,
277
+ quebra depois da pontuação e NUNCA termina linha em "da", "o", "que" —
278
+ "Barulho da / rua" é o erro que denuncia máquina. */
279
+ function particoes(ws, n) {
280
+ const out = [];
281
+ const rec = (i, acc) => {
282
+ if (i === ws.length) { out.push(acc); return; }
283
+ if (acc.length >= n) return;
284
+ for (let j = i + 1; j <= ws.length; j++) rec(j, [...acc, ws.slice(i, j).join(' ')]);
285
+ };
286
+ if (ws.length <= 14) rec(0, []);
287
+ return out;
288
+ }
289
+ function custoQuebra(linhas, larguras) {
290
+ let c = 0;
291
+ const max = Math.max(...larguras);
292
+ larguras.forEach((w, i) => { if (i < larguras.length - 1) c += Math.pow(1 - w / max, 2) * 2; });
293
+ linhas.forEach((l, i) => {
294
+ if (i === linhas.length - 1) { if (larguras[i] < max * 0.25 && linhas.length > 1) c += 1.2; return; }
295
+ const ult = l.split(' ').pop();
296
+ if (fraca(ult) || /^\d+$/.test(ult)) c += 6;
297
+ if (/[,.;:!?—–-]$/.test(ult)) c -= 0.8;
298
+ });
299
+ return c + (linhas.length - 1) * 0.35;
300
+ }
301
+ /** Quebra em linhas que caibam (a melhor partição; gulosa se a frase é longa). */
302
+ function quebrar(texto, largura, tamanho, op, n = 99) {
303
+ const ws = String(texto).split(/\s+/).filter(Boolean);
304
+ const cabe = (l) => larguraEm(l, op) * tamanho <= largura * 0.94;
305
+ let melhor = null, custo = Infinity;
306
+ for (const ls of particoes(ws, n)) {
307
+ if (!ls.every(cabe)) continue;
308
+ const c = custoQuebra(ls, ls.map((l) => larguraEm(l, op)));
309
+ if (c < custo) { custo = c; melhor = ls; }
310
+ }
311
+ if (melhor) return melhor;
312
+ const linhas = [];
313
+ let atual = '';
314
+ for (const w of ws) {
315
+ const tenta = atual ? atual + ' ' + w : w;
316
+ if (!atual || cabe(tenta)) atual = tenta;
317
+ else { linhas.push(atual); atual = w; }
318
+ }
319
+ if (atual) linhas.push(atual);
320
+ return linhas;
321
+ }
322
+ /** O maior tamanho em que o texto cabe em até `n` linhas — sem quebra feia, se der. */
323
+ function caberEmLinhas(texto, largura, n, max, op, min = 14) {
324
+ let reserva = null;
325
+ for (let t = Math.round(max); t >= min; t -= Math.max(1, Math.round(t * 0.04))) {
326
+ const ls = quebrar(texto, largura, t, op, n);
327
+ if (ls.length > n || !ls.every((l) => larguraEm(l, op) * t <= largura * 0.96)) continue;
328
+ const feia = ls.slice(0, -1).some((l) => fraca(l.split(' ').pop()));
329
+ if (!feia) return { tamanho: t, linhas: ls };
330
+ if (!reserva) reserva = { tamanho: t, linhas: ls };
331
+ if (t < max * 0.5) break;
332
+ }
333
+ return reserva || { tamanho: min, linhas: quebrar(texto, largura, min, op, n) };
334
+ }
335
+
336
+ const numeroDe = (v) => {
337
+ const m = String(v == null ? '' : v).match(/^(\D*?)(\d[\d.,]*)(.*)$/);
338
+ if (!m) return null;
339
+ const bruto = m[2];
340
+ const milhar = /\.\d{3}(\D|$)/.test(bruto) ? '.' : (/,\d{3}(\D|$)/.test(bruto) && !/\./.test(bruto) ? ',' : '');
341
+ const limpo = milhar === '.' ? bruto.replace(/\./g, '').replace(',', '.') : milhar === ',' ? bruto.replace(/,/g, '') : bruto.replace(',', '.');
342
+ const n = Number(limpo);
343
+ if (!Number.isFinite(n)) return null;
344
+ const casas = (/[.,](\d+)$/.exec(milhar ? '' : bruto) || [, ''])[1].length;
345
+ return { antes: m[1], n, depois: m[3], casas, milhar, virgula: /,/.test(bruto) && !milhar };
346
+ };
347
+
348
+ // ── o conteúdo de cada parte (o que aparece, não o leiaute) ────────────
349
+ const itensDe = (p) => [...(p.itens || []), ...(p.barras || []).map((b) => `${b.rotulo || ''} ${b.valor != null ? b.valor : ''}`.trim()),
350
+ ...(p.cores || []).map((c) => (typeof c === 'string' ? c : `${c.nome || ''} ${c.hex || ''}`.trim()))]
351
+ .map((i) => (typeof i === 'string' ? i : i.texto || i.titulo || '')).filter(Boolean);
352
+
353
+ function conteudoDe(p, marca = {}) {
354
+ const titulo = p.titulo || p.texto || '';
355
+ const itens = itensDe(p);
356
+ if (p.tipo === 'numero' && numeroDe(p.valor)) return { tipo: 'numero', valor: String(p.valor), rotulo: p.rotulo || titulo, sub: p.sub || '' };
357
+ if (itens.length >= 2) return { tipo: 'lista', titulo: p.tipo === 'cores' ? titulo : titulo, itens: itens.slice(0, 5), numerada: p.tipo === 'passos' };
358
+ if (p.tipo === 'contraste' && (p.nao || p.sim)) return { tipo: 'contraste', nao: p.nao || '', sim: p.sim || titulo };
359
+ if (p.tipo === 'citacao') return { tipo: 'citacao', texto: titulo, autor: p.autor || '' };
360
+ const nome = marca.nome || '';
361
+ if (p.tipo === 'logo' || (p.tipo === 'abertura' && titulo && norm(titulo).replace(/\s/g, '') === norm(nome).replace(/\s/g, '')))
362
+ return { tipo: 'marca', nome: titulo || nome, sub: p.sub || '' };
363
+ if (p.tipo === 'cta') return { tipo: 'cta', titulo, link: p.link || '' };
364
+ if (p.tipo === 'produto') return { tipo: 'produto', titulo, sub: p.texto && p.texto !== titulo ? p.texto : (p.sub || ''), selo: p.selo || '' };
365
+ return { tipo: 'titulo', titulo, sub: p.sub || '', selo: p.selo || '' };
366
+ }
367
+ const textoDoConteudo = (c) => [c.titulo, c.sub, c.valor, c.rotulo, c.nao, c.sim, c.texto, c.autor, c.nome, c.link, c.selo, ...(c.itens || [])].filter(Boolean).join(' ');
368
+
369
+ // ── as composições e o que cada conteúdo aceita ─────────────────────────
370
+ const COMPOSICOES = ['gigante', 'pilha', 'divisao', 'contador', 'cascata', 'parede', 'citacao', 'marca', 'palavras', 'produto'];
371
+ const COMPORTAMENTOS = ['carimbo', 'mascara', 'onda', 'decodifica', 'peso', 'largura', 'tracking', 'desfoque', 'flap', 'pontos', 'contorno', 'monta'];
372
+ const SAIDAS = ['pontos', 'evapora', 'atravessa', 'desliza', 'comprime', 'explode', 'chicote', 'inunda', 'corte'];
373
+ const ACENTOS = ['ponto', 'sublinha', 'anel', 'risco', 'barra', 'bala', 'marca', 'moldura', 'seta'];
374
+
375
+ function pesosDe(c, { curto, palavras, primeira, ultima, tem3d, pergunta, exclama, numeroNoTitulo }) {
376
+ switch (c.tipo) {
377
+ case 'numero': return { contador: 5, gigante: 2, pilha: 1.2 };
378
+ case 'lista': return { cascata: 4, pilha: c.itens.length <= 3 && c.itens.every((i) => i.length <= 26) ? 1.6 : 0, rajada: c.itens.length <= 4 ? 2.4 : 0 };
379
+ case 'contraste': return { divisao: 5, duelo: 2.6 };
380
+ case 'citacao': return { citacao: 5, pilha: 1 };
381
+ case 'marca': return { marca: primeira || ultima ? 4.5 : 3, gigante: 2.4, parede: curto ? 2 : 0 };
382
+ case 'cta': return { pilha: 3, gigante: curto ? 2.6 : 0.6, parede: c.titulo.length <= 16 ? 1.6 : 0, marca: 1, produto: tem3d ? 3.5 : 0 };
383
+ case 'produto': return tem3d ? { produto: 9, pilha: 0.5 } : { pilha: 3, gigante: curto ? 2.5 : 0.5, parede: c.titulo.length <= 12 ? 1.2 : 0 };
384
+ case 'falado': return { palavras: 4, gigante: palavras <= 3 ? 2.6 : 0, pilha: palavras <= 9 ? 1.2 : 0 };
385
+ default: return {
386
+ gigante: (curto ? 4.2 : 0.6) + (primeira ? 1 : 0) + (exclama ? 1 : 0) + (numeroNoTitulo ? 1 : 0),
387
+ pilha: 3 + (pergunta ? 1 : 0),
388
+ parede: c.titulo.length <= 14 ? 1.4 + (primeira ? 1 : 0) : 0,
389
+ palavras: 0,
390
+ };
391
+ }
392
+ }
393
+
394
+ // ── o tempo: casar o que aparece com o que a voz diz ────────────────────
395
+ /** As palavras de cada parte, com tempo. Sem voz, 0,34 s por palavra. */
396
+ function temposDasPartes(partes, tempos) {
397
+ const temVoz = tempos.length === partes.length && tempos.every((t) => t && t.ate > t.de);
398
+ if (temVoz) {
399
+ return tempos.map((t, i) => {
400
+ let ws = (t.palavras || []).filter((w) => w && w.texto != null && w.de != null).map((w) => ({ texto: String(w.texto), de: Number(w.de), ate: Number(w.ate != null ? w.ate : w.de + 0.25) }));
401
+ if (!ws.length) {
402
+ const falas = String(falaDe(partes[i])).split(/\s+/).filter(Boolean);
403
+ const passo = (t.ate - t.de) / Math.max(1, falas.length);
404
+ ws = falas.map((w, j) => ({ texto: w, de: t.de + j * passo, ate: t.de + (j + 0.8) * passo }));
405
+ }
406
+ return { de: t.de, ate: t.ate, palavras: ws };
407
+ });
408
+ }
409
+ let t = 0.35;
410
+ return partes.map((p) => {
411
+ const falas = String(falaDe(p) || '').split(/\s+/).filter(Boolean);
412
+ const ws = falas.map((w) => { const q = { texto: w, de: r3(t), ate: r3(t + 0.28) }; t += 0.34; return q; });
413
+ const de = ws.length ? ws[0].de : t;
414
+ const ate = ws.length ? ws[ws.length - 1].ate : t + 1.4;
415
+ t = ate + 0.45;
416
+ return { de, ate, palavras: ws };
417
+ });
418
+ }
419
+
420
+ /** Para cada texto (em ordem), o instante da voz em que ele é dito — ou null. */
421
+ function casar(textos, palavras, { de = 0, cursor0 = 0 } = {}) {
422
+ const ws = palavras.map((w) => ({ ...w, k: tokens(w.texto)[0] || '' }));
423
+ let cursor = cursor0;
424
+ return textos.map((txt) => {
425
+ const tk = tokens(txt);
426
+ const alvo = tk.find((w) => w.length >= 3 && !FUNCAO.has(w)) || tk[0];
427
+ if (!alvo) return null;
428
+ for (let j = cursor; j < ws.length; j++) {
429
+ if (ws[j].de < de - 0.05) continue;
430
+ if (ws[j].k === alvo || (alvo.length >= 5 && ws[j].k.startsWith(alvo.slice(0, 5)))) { cursor = j + 1; return ws[j].de; }
431
+ }
432
+ return null;
433
+ });
434
+ }
435
+
436
+ /* As frases da fala: as palavras com tempo repartidas pelo ponto final. */
437
+ function frasesDe(fala, palavras) {
438
+ const frases = String(fala || '').split(/(?<=[.!?…])\s+/).map((f) => f.trim()).filter(Boolean);
439
+ const out = [];
440
+ let j = 0;
441
+ for (const f of frases) {
442
+ const n = f.split(/\s+/).filter(Boolean).length;
443
+ const ws = palavras.slice(j, j + n);
444
+ j += n;
445
+ if (ws.length) out.push({ texto: f, palavras: ws.map((w, k) => ({ ...w, texto: f.split(/\s+/).filter(Boolean)[k] || w.texto })) });
446
+ }
447
+ return out;
448
+ }
449
+
450
+ // ── as cores ────────────────────────────────────────────────────────────
451
+ const luz = (h) => { const n = parseInt(String(h).slice(1), 16); return ((n >> 16 & 255) * 0.299 + (n >> 8 & 255) * 0.587 + (n & 255) * 0.114) / 255; };
452
+ const contraste = (a, b) => { const la = luz(a) + 0.05, lb = luz(b) + 0.05; return la > lb ? la / lb : lb / la; };
453
+ const PALETAS = HUMORES.tecnologia.paletas;
454
+
455
+ function paletaDe(marca, s, humor = null, roteiro = {}) {
456
+ const c = marca.cores || {};
457
+ const op = humor ? HUMORES[humor].paletas : PALETAS;
458
+ const [f0, t0, a0] = op[Math.floor(s.prox() * op.length) % op.length];
459
+ let base = { fundo: f0, tinta: t0, acento: a0 };
460
+ // A cor que o assunto diz manda no acento (se não sumir no fundo).
461
+ const ditas = tokens((roteiro.partes || []).map((p) => [p.titulo, p.destaque].join(' ')).join(' ')).filter((w) => CORES_DITAS[w]);
462
+ if (ditas.length && contraste(CORES_DITAS[ditas[0]], f0) >= 2.2) base = { ...base, acento: CORES_DITAS[ditas[0]] };
463
+ const fundo = ehCor(c.fundo) ? c.fundo : base.fundo;
464
+ let tinta = ehCor(c.tinta) ? c.tinta : (ehCor(c.fundo) ? (luz(fundo) < 0.5 ? '#F4F1EA' : '#121212') : base.tinta);
465
+ if (contraste(fundo, tinta) < 4) tinta = luz(fundo) < 0.5 ? '#F4F1EA' : '#121212';
466
+ const acento = ehCor(c.destaque) ? c.destaque : (marca.gradiente && ehCor(marca.gradiente[1]) && !corGenerica(marca.gradiente[1]) ? marca.gradiente[1] : base.acento);
467
+ return { fundo, tinta, acento };
468
+ }
469
+ /* OS CAMPOS DE COR (30/09, "olha a diferença"): o reel de referência troca o
470
+ fundo inteiro a cada batida — preto, laranja, creme, azul elétrico, lima —
471
+ cor chapada, saturada, contraste alto. Cada humor tem o seu sistema de 5
472
+ (0 = a tinta escura, 1 = a cor-assinatura, 2 = o papel, 3 e 4 = as ousadas);
473
+ a marca entra no lugar das dela. Nada de bege lavado o vídeo inteiro. */
474
+ const CAMPOS = {
475
+ tecnologia: [['#0D0D0D', '#FF5B2E', '#EFEAE0', '#2B3BFF', '#DFFF3A'], ['#111111', '#C6F432', '#F1ECE0', '#FF4A1C', '#1F3CFF']],
476
+ editorial: [['#14110F', '#B3261E', '#F1EADB', '#1F4E3D', '#E9B44C'], ['#121417', '#1F3FBF', '#F3EEE3', '#D9481C', '#E8C547']],
477
+ energia: [['#0B0B0B', '#FF4F00', '#F2EEE6', '#1D3BD1', '#C9F23A'], ['#101010', '#E8C547', '#F4EBDD', '#E5322D', '#2A57FF']],
478
+ natureza: [['#0F2A2A', '#F2A541', '#F2EDE2', '#2546D9', '#E4573D'], ['#13233F', '#2546D9', '#F2EDE2', '#F2A541', '#C8553D']],
479
+ luxo: [['#120D0B', '#C9A24A', '#F0E6D6', '#5B1A1F', '#2F4F4F']],
480
+ };
481
+ function camposDe(marca, humor, s, dita = null) {
482
+ const c = marca.cores || {};
483
+ let k = [...s.um(CAMPOS[humor] || CAMPOS.tecnologia)];
484
+ // A cor que o assunto diz vira a assinatura (se não for a de outro papel).
485
+ if (dita && !ehCor(c.destaque)) { const j = k.indexOf(dita); if (j > 0) k[j] = k[1]; k[1] = dita; }
486
+ // A marca: o fundo dela, o destaque dela e a tinta dela tomam os lugares de mesmo papel.
487
+ const escuro = (x) => luz(x) < 0.35, claro = (x) => luz(x) > 0.7;
488
+ for (const cor of [c.fundo, c.destaque, c.tinta].filter(ehCor)) {
489
+ const lugar = escuro(cor) ? 0 : claro(cor) ? 2 : 1;
490
+ if (!k.includes(cor)) k[lugar] = cor;
491
+ }
492
+ if (ehCor(c.destaque) && k[1] !== c.destaque) k[1] = c.destaque;
493
+ return k;
494
+ }
495
+ /** As cores de uma batida pelo campo dela: texto no contraste máximo, acento noutra cor do sistema. */
496
+ function coresDoCampo(campos, i, s) {
497
+ const fundo = campos[i];
498
+ const tintas = [campos[0], campos[2]].sort((a, b) => contraste(b, fundo) - contraste(a, fundo));
499
+ const texto = tintas[0];
500
+ const outras = campos.filter((c, j) => j !== i && c !== texto && contraste(c, fundo) >= 2.2);
501
+ const acento = outras.length ? (outras.includes(campos[1]) && i !== 1 ? campos[1] : s.um(outras)) : texto;
502
+ return { fundo, texto, fraco: misturarCor(texto, fundo, 0.45), acento, contorno: misturarCor(texto, fundo, 0.6) };
503
+ }
504
+
505
+ /** As cores de uma batida pelo fundo dela: base (a marca) ou acento (o fundo vira a cor). */
506
+ function coresDaBatida(P, fundo) {
507
+ if (fundo === 'acento') {
508
+ const texto = contraste(P.acento, P.fundo) >= contraste(P.acento, P.tinta) ? P.fundo : P.tinta;
509
+ return { fundo: P.acento, texto, fraco: misturarCor(texto, P.acento, 0.45), acento: texto, contorno: misturarCor(texto, P.acento, 0.6) };
510
+ }
511
+ return { fundo: P.fundo, texto: P.tinta, fraco: misturarCor(P.tinta, P.fundo, 0.58), acento: P.acento, contorno: misturarCor(P.tinta, P.fundo, 0.72) };
512
+ }
513
+
514
+ // ── o som de cada vídeo: uma FAMÍLIA, não um pacote ─────────────────────
515
+ /* "Os efeitos sonoros estão muito repetitivos" e "todo mundo usa os mesmos
516
+ sons" (30/09). Cada vídeo sintetiza a própria família no ffmpeg — 13
517
+ variações em 6 gestos (passagem, golpe, nasce, tique, revela, brilho) —
518
+ com afinação, duração, envelope e filtro sorteados da semente, no timbre
519
+ do humor: papel (foley de papel e madeira), analógico (pluck e fita),
520
+ digital (bitcrush), orgânico (madeira e sopro). Receita = argumentos do
521
+ ffmpeg; sons-leves.js gera os arquivos. */
522
+ const NOTAS = [220, 246.94, 261.63, 293.66, 329.63, 349.23, 392, 440];
523
+ function sonsDoVideo(roteiro = {}) {
524
+ const semente = hash('som:' + JSON.stringify((roteiro.partes || []).map((p) => [p.titulo, p.fala])) + ((roteiro.marca && roteiro.marca.nome) || ''));
525
+ const s = sorteador(semente);
526
+ const timbre = HUMORES[humorDe(roteiro, sorteador(hash('humor')))] ? HUMORES[humorDe(roteiro, s)].som : 'analogico';
527
+ const raiz = s.um(NOTAS);
528
+ const escala = [1, 1.189, 1.335, 1.498, 1.782, 2]; // pentatônica menor sobre a raiz
529
+ const nota = () => Math.round(raiz * s.um(escala) * (s.chance(0.3) ? 2 : 1));
530
+ const f = (x) => Math.round(x * 1000) / 1000;
531
+ const crush = timbre === 'digital' ? ',acrusher=bits=' + Math.round(s.entre(5, 9)) + ':mode=log:aa=1' : '';
532
+ const receitas = {};
533
+ const varre = (nome) => {
534
+ const d = f(s.entre(0.32, 0.7)), at = f(d * s.entre(0.45, 0.7)), fc = Math.round(s.entre(timbre === 'papel' ? 500 : 800, timbre === 'papel' ? 1600 : 2600));
535
+ const cor = timbre === 'papel' ? s.um(['brown', 'pink']) : timbre === 'digital' ? 'white' : 'pink';
536
+ receitas[nome] = ['-f', 'lavfi', '-i', `anoisesrc=d=${d}:c=${cor}:a=0.9:s=${Math.round(s.entre(1, 9999))}`, '-af',
537
+ `bandpass=f=${fc}:width_type=o:w=${f(s.entre(1.2, 2.6))},afade=t=in:st=0:d=${at}:curve=exp,afade=t=out:st=${at}:d=${f(d - at)}:curve=qsin,volume=${f(s.entre(1.6, 2.4))}${crush}`];
538
+ };
539
+ const fita = (nome) => { // a fita que para: o tom cai até zero
540
+ const f0 = nota() * 2, d = f(s.entre(0.35, 0.6));
541
+ receitas[nome] = ['-f', 'lavfi', '-i', `aevalsrc='0.5*sin(2*PI*${f0}*(t-t*t/(2*${d})))*(1-t/${d})':s=44100:d=${d}`, '-af', `lowpass=f=${Math.round(s.entre(1800, 4000))}${crush}`];
542
+ };
543
+ const golpe = (nome) => {
544
+ const f0 = Math.round(s.entre(timbre === 'papel' ? 85 : 60, timbre === 'papel' ? 120 : 80)), f1 = Math.round(f0 * s.entre(0.4, 0.6)), d = f(s.entre(0.5, 1.0));
545
+ const ruido = f(timbre === 'papel' ? s.entre(0.4, 0.7) : s.entre(0.15, 0.4));
546
+ receitas[nome] = ['-f', 'lavfi', '-i', `aevalsrc='0.9*sin(2*PI*(${f0}*t-(${f0 - f1})*t*t/(2*${d})))*exp(-${f(s.entre(4, 8))}*t)+${ruido}*(random(0)*2-1)*exp(-${Math.round(s.entre(30, 60))}*t)':s=44100:d=${d}`,
547
+ '-af', `lowpass=f=${Math.round(s.entre(1400, 3200))},volume=1.4${crush}`];
548
+ };
549
+ const nasce = (nome) => {
550
+ if (timbre === 'papel' || timbre === 'organico') { // madeira: tom curto e seco, com o estalo do toque
551
+ const fq = Math.round(s.entre(380, 900));
552
+ receitas[nome] = ['-f', 'lavfi', '-i', `aevalsrc='0.7*sin(2*PI*${fq}*t)*exp(-${Math.round(s.entre(45, 80))}*t)+0.25*(random(0)*2-1)*exp(-${Math.round(s.entre(120, 220))}*t)':s=44100:d=0.16`,
553
+ '-af', `bandpass=f=${fq}:width_type=o:w=2.5,volume=2.2`];
554
+ } else { // pluck afinado na raiz do vídeo
555
+ const fq = nota(), k = Math.round(s.entre(14, 26));
556
+ receitas[nome] = ['-f', 'lavfi', '-i', `aevalsrc='0.55*exp(-${k}*t)*(sin(2*PI*${fq}*t)+0.45*sin(2*PI*${fq * 2}*t)*exp(-${k}*t)+0.2*sin(2*PI*${fq * 3}*t))':s=44100:d=0.32`,
557
+ '-af', `afade=t=out:st=0.26:d=0.06${crush}`];
558
+ }
559
+ };
560
+ const tique = (nome) => {
561
+ if (timbre === 'papel' || timbre === 'organico') {
562
+ const fc = Math.round(s.entre(2500, 6500));
563
+ receitas[nome] = ['-f', 'lavfi', '-i', `anoisesrc=d=0.035:c=white:a=0.8:s=${Math.round(s.entre(1, 9999))}`, '-af', `bandpass=f=${fc}:width_type=o:w=1.5,afade=t=out:st=0.005:d=0.03,volume=2.5`];
564
+ } else {
565
+ const fq = Math.round(s.entre(1800, 3600));
566
+ receitas[nome] = ['-f', 'lavfi', '-i', `aevalsrc='0.45*sin(2*PI*${fq}*t)*exp(-${Math.round(s.entre(70, 140))}*t)':s=44100:d=0.07`, ...(crush ? ['-af', crush.slice(1)] : [])];
567
+ }
568
+ };
569
+ const revela = (nome) => {
570
+ const f0 = nota() / 2, d = f(s.entre(0.9, 1.4));
571
+ receitas[nome] = ['-f', 'lavfi', '-i', `aevalsrc='(0.3*sin(2*PI*(${f0}+${Math.round(s.entre(300, 800))}*t*t)*t)+${timbre === 'papel' ? 0.32 : 0.18}*(random(0)*2-1))*pow(t/${d},2)':s=44100:d=${d}`,
572
+ '-af', `highpass=f=${Math.round(s.entre(180, 420))},afade=t=out:st=${f(d - 0.05)}:d=0.05,volume=1.3${crush}`];
573
+ };
574
+ const brilho = (nome) => {
575
+ const a = nota() * 2, b = Math.round(a * s.um([1.25, 1.335, 1.5]));
576
+ receitas[nome] = ['-f', 'lavfi', '-i', `aevalsrc='0.3*sin(2*PI*${a}*t)*exp(-${f(s.entre(2.5, 4))}*t)+0.22*sin(2*PI*${b}*t)*exp(-${f(s.entre(3, 5))}*t)':s=44100:d=1.3`,
577
+ ...(timbre === 'papel' ? ['-af', 'lowpass=f=2400'] : [])];
578
+ };
579
+ varre('passagem0'); varre('passagem1'); if (timbre === 'papel') varre('passagem2'); else fita('passagem2');
580
+ golpe('golpe0'); golpe('golpe1');
581
+ nasce('nasce0'); nasce('nasce1'); nasce('nasce2');
582
+ tique('tique0'); tique('tique1');
583
+ revela('revela0'); brilho('brilho0'); brilho('brilho1');
584
+ return { timbre, raiz, semente, receitas };
585
+ }
586
+ /** O gênero da trilha pelo humor do roteiro, e a semente para sortear a faixa. */
587
+ function musicaDe(roteiro = {}) {
588
+ const semente = hash('musica:' + JSON.stringify((roteiro.partes || []).map((p) => [p.titulo, p.fala])));
589
+ const s = sorteador(semente);
590
+ const h = humorDe(roteiro, s);
591
+ return { humor: h, genero: s.um(HUMORES[h].generos), semente };
592
+ }
593
+ /* ── O SOM DESENHADO (30/09, terceira volta): "os efeitos repetem e são os
594
+ genéricos que toda IA usa". Então:
595
+ densidade um evento por batida, no máximo (mais dois risers nas grandes
596
+ revelações); batida sem som também existe — a música carrega;
597
+ nunca repete cada evento é um arquivo seu, com parâmetros sorteados
598
+ (altura, duração, envelope, filtro, pan, cauda de eco);
599
+ da própria trilha o riser é um trecho da música AO CONTRÁRIO crescendo,
600
+ o golpe é a música parando como fita + um sub que cai, a
601
+ passagem é uma fatia filtrada dela, o chicote é um gaguejo de
602
+ um pedaço; o ar da câmera é ruído que atravessa o estéreo.
603
+ Sem trilha, os mesmos gestos saem sintetizados, também um por evento.
604
+ As receitas usam "{pasta}" no lugar da pasta do projeto (sons-leves.js). */
605
+ function desenharSom(P, { trilha = null, sons = {} } = {}) {
606
+ const sh = sorteador((P.semente ^ 0x2c1b3c6d) >>> 0);
607
+ const DUR = P.duracao;
608
+ const f = (x) => Math.round(x * 1000) / 1000;
609
+ const musica = trilha ? '{pasta}/' + trilha : null;
610
+ const receitas = {}, efeitos = [];
611
+ const pan = () => { const x = sh.entre(-0.7, 0.7); return [f(Math.min(1, 1 - x)), f(Math.min(1, 1 + x))]; };
612
+ const panF = () => { const [l, r] = pan(); return `pan=stereo|c0=${l}*c0|c1=${r}*c1`; };
613
+ const eco = () => `aecho=0.8:${f(sh.entre(0.4, 0.75))}:${Math.round(sh.entre(30, 90))}|${Math.round(sh.entre(100, 220))}:${f(sh.entre(0.15, 0.35))}|${f(sh.entre(0.08, 0.2))}`;
614
+ const ss = () => f(sh.entre(4, Math.max(6, DUR + 20))); // de onde da trilha sai o pedaço
615
+ const sint = sonsDoVideo({ partes: P.batidas.map((b) => ({ titulo: b.comp, fala: String(b.de) })) }).receitas;
616
+ const receita = (gesto) => {
617
+ if (!musica) { // sem trilha: a síntese, um arquivo por evento (a semente muda a cada um)
618
+ const base = { revela: 'revela0', golpe: sh.um(['golpe0', 'golpe1']), fatia: sh.um(['nasce0', 'nasce1', 'nasce2']), gaguejo: sh.um(['passagem0', 'passagem1']), ar: sh.um(['passagem1', 'passagem2']) }[gesto];
619
+ const args = [...sint[base]];
620
+ const i = args.indexOf('-af');
621
+ const extra = `asetrate=44100*${f(sh.entre(0.8, 1.25))},aresample=44100,${eco()},${panF()}`;
622
+ if (i >= 0) args[i + 1] += ',' + extra; else args.push('-af', extra);
623
+ return args;
624
+ }
625
+ if (gesto === 'revela') {
626
+ const d = f(sh.entre(0.9, 1.5));
627
+ return ['-ss', String(ss()), '-t', String(d), '-i', musica, '-af',
628
+ `areverse,highpass=f=${Math.round(sh.entre(150, 500))},afade=t=in:st=0:d=${f(d * 0.92)}:curve=exp,${eco()},volume=${f(sh.entre(4, 6))},${panF()}`];
629
+ }
630
+ if (gesto === 'golpe') {
631
+ const d = f(sh.entre(0.55, 0.95)), f0 = Math.round(sh.entre(55, 80)), f1 = Math.round(f0 * sh.entre(0.35, 0.55));
632
+ return ['-ss', String(ss()), '-t', String(d), '-i', musica, '-f', 'lavfi', '-i',
633
+ `aevalsrc='0.9*sin(2*PI*(${f0}*t-${f((f0 - f1) / (2 * d))}*t*t))*exp(-${f(sh.entre(3.5, 6))}*t)':s=44100:d=${d}`,
634
+ '-filter_complex', `[0:a]aformat=channel_layouts=stereo,asetrate=44100*${f(sh.entre(0.45, 0.7))},aresample=44100,lowpass=f=${Math.round(sh.entre(900, 2400))},afade=t=out:st=0:d=${d}:curve=exp[m];`
635
+ + `[1:a]aformat=channel_layouts=stereo[s];[m][s]amix=inputs=2:weights=${f(sh.entre(0.8, 1.3))} ${f(sh.entre(0.7, 1.1))}:normalize=0,volume=${f(sh.entre(1.4, 2))}`];
636
+ }
637
+ if (gesto === 'gaguejo') {
638
+ const g = f(sh.entre(0.06, 0.13)), n = Math.round(sh.entre(3, 6));
639
+ return ['-ss', String(ss()), '-t', String(g), '-i', musica, '-af',
640
+ `aloop=loop=${n}:size=${Math.round(g * 44100)},highpass=f=${Math.round(sh.entre(200, 700))},afade=t=out:st=${f(g * (n + 1) * 0.55)}:d=${f(g * (n + 1) * 0.45)},volume=${f(sh.entre(1.6, 2.4))},${panF()}`];
641
+ }
642
+ if (gesto === 'fatia') {
643
+ const d = f(sh.entre(0.16, 0.38)), fc = Math.round(sh.entre(300, 3200));
644
+ return ['-ss', String(ss()), '-t', String(d), '-i', musica, '-af',
645
+ `bandpass=f=${fc}:width_type=o:w=${f(sh.entre(0.8, 2))},afade=t=in:st=0:d=0.006,afade=t=out:st=${f(d * 0.35)}:d=${f(d * 0.65)},${eco()},volume=${f(sh.entre(5, 8))},${panF()}`];
646
+ }
647
+ // ar: o ruído que atravessa o estéreo, como a câmera passando
648
+ const d = f(sh.entre(0.45, 0.8)), ida = sh.chance(0.5);
649
+ return ['-f', 'lavfi', '-i', `anoisesrc=d=${d}:c=${sh.um(['pink', 'brown'])}:a=0.8:s=${Math.round(sh.entre(1, 99999))}`, '-af',
650
+ `bandpass=f=${Math.round(sh.entre(500, 1800))}:width_type=o:w=${f(sh.entre(1.4, 2.6))},afade=t=in:st=0:d=${f(d * 0.55)}:curve=exp,afade=t=out:st=${f(d * 0.55)}:d=${f(d * 0.45)},`
651
+ + `aformat=channel_layouts=stereo,aeval=exprs='val(0)*${ida ? '(1-t/' + d + ')' : '(t/' + d + ')'}|val(1)*${ida ? '(t/' + d + ')' : '(1-t/' + d + ')'}':channel_layout=stereo,volume=${f(sh.entre(4, 6))}`];
652
+ };
653
+ const evento = (t, gesto, volume) => {
654
+ if (t < 0 || t > DUR - 0.1) return;
655
+ if (efeitos.some((e) => Math.abs(e.t - t) < 0.25)) return;
656
+ const nome = 'e' + String(efeitos.length).padStart(2, '0') + '-' + gesto;
657
+ receitas[nome] = receita(gesto);
658
+ efeitos.push({ t: f(Math.max(0, t + (sh.prox() - 0.5) * 0.02)), nome, gesto, volume: f(volume * (0.85 + sh.prox() * 0.3)) });
659
+ };
660
+ const bs = P.batidas;
661
+ // Os dois risers: antes da primeira grande virada de fundo e antes do fecho.
662
+ const virada = bs.find((b, i) => i > 0 && b.entrada === 'inunda');
663
+ if (virada) evento(virada.de - 1.1, 'revela', 0.7);
664
+ if (bs.length > 2) evento(bs[bs.length - 1].de - 1.2, 'revela', 0.7);
665
+ // O golpe é raro (um a cada três batidas, nunca dois seguidos): golpe em toda tese vira bate-estaca.
666
+ let desdeGolpe = 9;
667
+ const tetoGolpes = Math.max(1, Math.round(bs.length / 3));
668
+ let golpes = 0;
669
+ bs.forEach((b, i) => {
670
+ const g = (b.golpes || [])[0];
671
+ const forte = g && g.forca >= 1 && desdeGolpe >= 2 && golpes < tetoGolpes;
672
+ desdeGolpe++;
673
+ if (forte) { desdeGolpe = 0; golpes++; }
674
+ if (i === 0) { if (forte) evento(g.t, 'golpe', 0.75); return; }
675
+ if (!forte && sh.chance(0.18)) return; // a batida que respira só com a música
676
+ if (forte) return evento(g.t, 'golpe', 0.8);
677
+ const tr = b.entrada;
678
+ if (tr === 'corte') return evento(b.de, 'golpe', 0.75);
679
+ if (tr === 'chicote' || tr === 'explode') return evento(b.de - 0.2, 'gaguejo', 0.7);
680
+ if (tr === 'atravessa' || tr === 'inunda') return evento(b.de - 0.4, 'ar', 0.75);
681
+ return evento(b.de - 0.08, 'fatia', 0.75);
682
+ });
683
+ efeitos.sort((a, b) => a.t - b.t);
684
+ // O src é o nome que sons-leves.prepararReceitas dá (semente + nome): um arquivo por evento.
685
+ const sufixo = (P.semente >>> 0).toString(36);
686
+ const fim = efeitos.map((e) => ({ t: e.t, src: (sons.__pasta || 'midia') + `/sfx-g${sufixo}-${e.nome}.mp3`, volume: e.volume }));
687
+ return { efeitos: fim, receitas: { semente: P.semente, receitas } };
688
+ }
689
+
690
+ /* Os nomes antigos (sons-leves.js) valem como reserva de cada gesto. */
691
+ const LEGADO = { passagem: ['whoosh', 'sopro'], golpe: ['impacto', 'toque'], nasce: ['pop', 'bolha'], tique: ['clique'], revela: ['riser', 'subida'], brilho: ['brilho'] };
692
+
693
+ // ── a montagem ──────────────────────────────────────────────────────────
694
+ /**
695
+ * @param {object} op
696
+ * @param {object} op.roteiro { marca, partes, modelo3d?, semente? }
697
+ * @param {Array} op.tempos [{de, ate, palavras:[{texto,de,ate}]}] por parte
698
+ * @returns {{ok, plano, partitura, resumo}}
699
+ */
700
+ function montarGerativo({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null }) {
701
+ const partes = ((roteiro && roteiro.partes) || []).filter((p) => p && typeof p === 'object');
702
+ if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
703
+ const partitura = partituraDe({ roteiro: { ...roteiro, partes }, largura, altura, tempos });
704
+ const DUR = partitura.duracao;
705
+
706
+ // ── o som: efeitos nas transformações, a música abaixa sob a voz ─────
707
+ /* O SOM (desenharSom): um evento por batida, no máximo — e cada um é um
708
+ arquivo PRÓPRIO, feito da trilha do vídeo (ou sintetizado, sem trilha). */
709
+ const somDesenhado = desenharSom(partitura, { trilha, sons });
710
+ const efeitos = somDesenhado.efeitos;
711
+ // A música vem cheia nos respiros e abaixa só sob a voz (a faixa chega normalizada a -18 LUFS).
712
+ const volumes = volumesDaTrilha(tempos.length === partes.length ? tempos : [], DUR, { alto: 0.95, baixo: 0.38 });
713
+
714
+ const P = partitura.paleta;
715
+ const fontesVariaveis = {};
716
+ for (const f of Object.values(partitura.fontes)) { const e = especEixos(f); if (e != null) fontesVariaveis[f] = e; }
717
+ const plano = {
718
+ formato: { largura, altura, fps: FPS },
719
+ fundoGeral: P.fundo,
720
+ fontesVariaveis,
721
+ cenas: [{
722
+ duracao: DUR,
723
+ fundo: P.fundo,
724
+ elementos: [{
725
+ tipo: 'kit', peca: 'gerativo', partitura,
726
+ // As chaves "fonte*" são o que o fontes.css procura (remotion.js).
727
+ fonte: partitura.fontes.display, fonteTexto: partitura.fontes.texto, fonteMono: partitura.fontes.mono,
728
+ fonteCinetica: partitura.fontes.cinetica, fonteSerifa: partitura.fontes.serifa,
729
+ }],
730
+ }],
731
+ audio: {
732
+ ...(narracao ? { narracao } : {}),
733
+ ...(trilha ? { trilha: { src: trilha, volumes, loop: true } } : {}),
734
+ efeitos,
735
+ },
736
+ };
737
+ return {
738
+ ok: true, plano, partitura, somReceitas: somDesenhado.receitas,
739
+ resumo: { cenas: 1, batidas: partitura.batidas.length, segundos: DUR, fps: FPS, composicoes: partitura.batidas.map((b) => b.comp), semente: partitura.semente },
740
+ };
741
+ }
742
+
743
+ /** A partitura, sem áudio nem plano — é o que os testes olham. */
744
+ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [] }) {
745
+ const partes = roteiro.partes || [];
746
+ const marca = roteiro.marca || {};
747
+ const W = largura, H = altura, deitado = W >= H;
748
+ const semente = roteiro.semente != null ? hash('semente:' + roteiro.semente)
749
+ : hash(JSON.stringify([partes.map((p) => [p.tipo, p.titulo, p.texto, p.fala, p.valor, p.itens, p.nao, p.sim, p.destaque]), marca.nome || '']));
750
+ const s = sorteador(semente);
751
+ const humor = humorDe(roteiro, s);
752
+ const H0 = HUMORES[humor];
753
+ const P = paletaDe(marca, s, humor, roteiro);
754
+
755
+ /* ── as fontes: NADA de cara de IA. A da marca vale se tiver caráter (e
756
+ eixos para animar); a genérica da marca vira texto, e o título ganha
757
+ uma face da biblioteca do humor. ─────────────────────────────────── */
758
+ const pedida = (marca.fontes && marca.fontes.titulo) || '';
759
+ const pedidaTexto = (marca.fontes && marca.fontes.texto) || '';
760
+ const boa = (f) => f && VARIAVEIS[f] && !BANIDAS.includes(f) && VARIAVEIS[f].eixos.wght;
761
+ const display = boa(pedida) ? pedida : s.um(H0.displays);
762
+ const cinetica = eixoDe(display, 'wdth') && eixoDe(display, 'wdth')[0] <= 75 ? display : s.um(H0.cineticas);
763
+ const texto = pedidaTexto ? pedidaTexto : (pedida && BANIDAS.includes(pedida) ? pedida : s.um(H0.textos));
764
+ const serifa = VARIAVEIS[display].serifa ? display : s.um(H0.serifas);
765
+ const fontes = { display, cinetica, texto, mono: s.um(H0.monos), serifa };
766
+
767
+ // ── o quadro: margens e respiro ─────────────────────────────────────
768
+ // A margem conta com a câmera: ela empurra até ~8% e desliza, e nada pode sair do quadro.
769
+ const M = Math.round(W * (deitado ? 0.085 : 0.095));
770
+ const larguraUtil = W - 2 * M;
771
+ const base = Math.min(W, H) / 1080;
772
+ const tamRotulo = Math.round((deitado ? 24 : 30) * base);
773
+ const tem3d = !!roteiro.modelo3d;
774
+ const ws = temposDasPartes(partes, tempos);
775
+
776
+ // ── os limites de cada parte na linha do tempo ────────────────────────
777
+ const inicio = [];
778
+ partes.forEach((_, i) => inicio.push(i === 0 ? 0 : r3(Math.max(inicio[i - 1] + 1.4, ws[i].de - 0.12))));
779
+ const ultimaFala = ws[ws.length - 1];
780
+ const duracao = r1(Math.max(inicio[inicio.length - 1] + 2.0, (ultimaFala.ate || 0) + 1.15));
781
+ const fimParte = partes.map((_, i) => (i < partes.length - 1 ? inicio[i + 1] : duracao));
782
+
783
+ // ── as batidas: de cada parte, 1 a 3 ─────────────────────────────────
784
+ const brutas = [];
785
+ partes.forEach((p, i) => {
786
+ const c = conteudoDe(p, marca);
787
+ const palavrasP = ws[i].palavras;
788
+ const t0 = inicio[i], t1 = fimParte[i];
789
+ let de = t0;
790
+ /* A FALA que não é o que aparece ("Feche os olhos. Agora escute." antes
791
+ de "VÓRTEX H1") vira uma batida própria, palavra por palavra — quando
792
+ há tempo para as duas e a primeira frase não diz o conteúdo. */
793
+ const frases = frasesDe(falaDe(p), palavrasP);
794
+ const chave = tokens(textoDoConteudo(c)).filter((w) => w.length >= 3 && !FUNCAO.has(w));
795
+ if (frases.length >= 2 && t1 - t0 >= 2.6 && c.tipo !== 'lista') {
796
+ const f0 = frases[0];
797
+ const diz = tokens(f0.texto);
798
+ const cobre = chave.filter((w) => diz.some((d) => d === w || (w.length >= 5 && d.startsWith(w.slice(0, 5))))).length / Math.max(1, chave.length);
799
+ const ultimaW = f0.palavras[f0.palavras.length - 1];
800
+ const fimF = r3(Math.max(frases[1].palavras[0].de - 0.1, ultimaW.de + REGRAS.leitura + 0.1));
801
+ if (cobre < 0.5 && fimF - de >= 1.3 && t1 - fimF >= 1.9 && f0.palavras.length <= 9 && s.chance(0.9)) {
802
+ brutas.push({ parte: i, de, ate: fimF, conteudo: { tipo: 'falado', texto: f0.texto, palavras: f0.palavras, destaque: p.destaque || '' } });
803
+ de = fimF;
804
+ }
805
+ }
806
+ brutas.push({ parte: i, de, ate: t1, conteudo: { ...c, destaque: p.destaque || '' }, palavras: palavrasP });
807
+ });
808
+
809
+ // ── a composição de cada batida: sorteada entre as que cabem ─────────
810
+ const usadas = {};
811
+ const memoria = {};
812
+ const batidas = [];
813
+ let anterior = null;
814
+ const recentes = [];
815
+ const escolher = (pesos, { evita = [] } = {}) => {
816
+ const w = {};
817
+ for (const [nome, p] of Object.entries(pesos)) {
818
+ const comp = nome === 'rajada' ? 'gigante' : nome === 'duelo' ? 'pilha' : nome;
819
+ let q = p;
820
+ if (comp === anterior || evita.includes(comp)) q = 0;
821
+ if (recentes.slice(-3).includes(comp)) q *= 0.35;
822
+ q *= Math.pow(0.72, usadas[comp] || 0);
823
+ if (comp === 'parede' && (usadas.parede || 0) >= 1) q *= 0.25;
824
+ w[nome] = q;
825
+ }
826
+ return s.pesado(w) || s.pesado({ pilha: anterior === 'pilha' ? 0 : 1, gigante: anterior === 'gigante' ? 0 : 1, palavras: anterior === 'palavras' ? 0 : 1 });
827
+ };
828
+ const registra = (b) => { usadas[b.comp] = (usadas[b.comp] || 0) + 1; recentes.push(b.comp); anterior = b.comp; batidas.push(b); };
829
+
830
+ brutas.forEach((br, bi) => {
831
+ const c = br.conteudo;
832
+ const tituloC = c.titulo || c.nome || c.texto || '';
833
+ const feat = {
834
+ curto: tituloC.length <= 22 && tituloC.split(/\s+/).length <= 4,
835
+ palavras: (c.palavras || []).length, primeira: bi === 0, ultima: bi === brutas.length - 1, tem3d,
836
+ pergunta: /\?/.test(tituloC), exclama: /!/.test(tituloC), numeroNoTitulo: /\d/.test(tituloC),
837
+ };
838
+ let nome = escolher(pesosDe(c, feat));
839
+ const ctx = { s, W, H, M, deitado, base, larguraUtil, tamRotulo, fontes, P, palavras: br.palavras || c.palavras || [], parte: br.parte, marca, roteiro, memoria };
840
+ if (nome === 'rajada' && br.ate - br.de < 1.6 * c.itens.length) nome = 'cascata';
841
+ if (nome === 'rajada') {
842
+ // Cada item vira a sua batida; o título da lista fica como rótulo.
843
+ const n = c.itens.length;
844
+ const quandoItem = casar(c.itens, ctx.palavras, { de: br.de });
845
+ let de = br.de;
846
+ c.itens.forEach((it, j) => {
847
+ // Cada item fica no ar o bastante para ser lido: 1 s depois da última linha dele.
848
+ const MIN = 1.6;
849
+ const prox = j < n - 1 ? (quandoItem[j + 1] != null ? Math.max(de + MIN, quandoItem[j + 1] - 0.1) : de + (br.ate - de) / (n - j)) : br.ate;
850
+ const ate = j < n - 1 ? r3(Math.min(prox, br.ate - MIN * (n - j - 1))) : br.ate;
851
+ const comp = (anterior === 'gigante') ? 'pilha' : 'gigante';
852
+ const b = compor(comp, { parte: br.parte, de: r3(de), ate, conteudo: { tipo: 'titulo', titulo: (c.numerada ? String(j + 1).padStart(2, '0') + ' ' : '') + it, sub: '', selo: c.titulo, destaque: c.destaque } }, ctx);
853
+ registra(b);
854
+ de = ate;
855
+ });
856
+ return;
857
+ }
858
+ if (nome === 'duelo') {
859
+ const meio = casar([c.sim], ctx.palavras, { de: br.de + 1.2 })[0];
860
+ const corte = r3(Math.min(br.ate - 1.4, Math.max(br.de + 1.3, meio != null ? meio - 0.1 : (br.de + br.ate) / 2)));
861
+ const cNao = anterior !== 'pilha' ? 'pilha' : 'gigante';
862
+ registra(compor(cNao, { parte: br.parte, de: br.de, ate: corte, conteudo: { tipo: 'titulo', titulo: c.nao, sub: '', riscado: true, destaque: '' } }, ctx));
863
+ registra(compor(cNao === 'pilha' ? 'gigante' : 'pilha', { parte: br.parte, de: corte, ate: br.ate, conteudo: { tipo: 'titulo', titulo: c.sim, sub: '', destaque: c.destaque } }, ctx));
864
+ return;
865
+ }
866
+ registra(compor(nome, br, ctx));
867
+ });
868
+
869
+ /* ── os campos: o fundo inteiro troca de cor a cada batida (quase sempre),
870
+ nunca igual ao anterior nem ao de duas antes; a fala e o conteúdo da
871
+ mesma parte às vezes dividem o campo. O produto 3D pede o campo escuro. */
872
+ const ditaCor = tokens(partes.map((p) => [p.titulo, p.destaque].join(' ')).join(' ')).map((w) => CORES_DITAS[w]).find(Boolean) || null;
873
+ const campos = camposDe(marca, humor, s, ditaCor);
874
+ batidas.forEach((b, i) => {
875
+ if (i === 0) { b.campo = 0; return; }
876
+ const a = batidas[i - 1];
877
+ if (a.parte === b.parte && s.chance(0.4)) { b.campo = a.campo; return; }
878
+ if (b.produto3d) { b.campo = a.campo === 0 ? 3 : 0; return; }
879
+ const evita = [a.campo, i > 1 ? batidas[i - 2].campo : -1];
880
+ const op = campos.map((_, j) => j).filter((j) => !evita.includes(j));
881
+ b.campo = s.um(op);
882
+ });
883
+ for (const b of batidas) { b.fundo = 'campo' + b.campo; b.cores = coresDoCampo(campos, b.campo, s); }
884
+
885
+ /* ── os ÍCONES 3D: onde a parte pediria um ícone (lista, número,
886
+ recurso), um objeto de geometria no material do vídeo. A palavra
887
+ decide qual; nunca o mesmo duas vezes, e no máximo em ~45% das batidas
888
+ — ícone em toda batida vira papel de parede de IA. ───────────────── */
889
+ const material = s.um(['argila', 'verniz', 'metal']);
890
+ const iconesUsados = new Set();
891
+ const tetoIcones = Math.ceil(batidas.length * 0.45);
892
+ for (const b of batidas) {
893
+ if (iconesUsados.size >= tetoIcones) break;
894
+ if (b.produto3d || ['parede', 'marca', 'citacao', 'divisao'].includes(b.comp)) continue;
895
+ const ditas = tokens(b.linhas.map((l) => l.texto).join(' ') + ' ' + ((partes[b.parte] && partes[b.parte].fala) || ''));
896
+ const ic = ditas.map((w) => ICONE_DE[w]).find((x) => x && !iconesUsados.has(x));
897
+ if (!ic) continue;
898
+ iconesUsados.add(ic);
899
+ const prim = b.linhas[0];
900
+ b.icone3d = { icone: ic, lado: deitado ? 'direita' : 'acima', tamanho: Math.round(deitado ? 0.4 * H : 0.38 * W), material,
901
+ cor: b.cores.texto, cor2: b.cores.acento, luz: b.cores.acento, t: r3(Math.min(b.ate - 1, (prim ? prim.t : b.de) + 0.15)) };
902
+ b.acentos += 1;
903
+ if (b.acentos > REGRAS.acentos) { b.icone3d.cor2 = misturarCor(b.cores.texto, b.cores.fundo, 0.3); b.acentos -= 1; }
904
+ }
905
+
906
+ /* ── o HERÓI GRÁFICO: toda batida tem uma ideia visual grande além do
907
+ texto, como no reel de referência — a explosão radial que nasce de um
908
+ ponto, o polígono que vira estrela e quadrado com partículas em órbita,
909
+ o campo de truchet com uma onda de cor, a esfera e o toro de pontos em
910
+ 3D, os anéis de órbita, a grade que deforma, os rastros de velocidade.
911
+ Procedurais, da semente, na paleta da batida; nunca o mesmo seguido. ── */
912
+ const OBJETOS = ['explosao', 'poligono', 'esfera', 'toro'], CAMPOS_H = ['truchet', 'aneis', 'grade', 'rastro'];
913
+ const usosH = {};
914
+ let ultimoH = null;
915
+ const recentesH = [];
916
+ batidas.forEach((b, i) => {
917
+ const ocupado = b.icone3d || b.produto3d;
918
+ let op = ocupado || ['gigante', 'parede', 'divisao'].includes(b.comp) ? CAMPOS_H : [...OBJETOS, ...CAMPOS_H];
919
+ if (b.comp === 'parede') op = ['aneis', 'rastro', 'grade'];
920
+ const w = {};
921
+ for (const h of op) {
922
+ let q = OBJETOS.includes(h) ? 2.2 : 1;
923
+ if (recentesH.slice(-3).includes(h)) q = 0;
924
+ if (h === 'truchet' && (usosH.truchet || 0) >= 1) q = 0;
925
+ if ((h === 'esfera' || h === 'toro') && (usosH[h] || 0) >= 1) q = 0;
926
+ q *= Math.pow(0.5, usosH[h] || 0);
927
+ w[h] = q;
928
+ }
929
+ // Tudo recente: o menos recente de todos os de campo.
930
+ const reserva = () => CAMPOS_H.filter((x) => !recentesH.slice(-3).includes(x))[0] || CAMPOS_H[0];
931
+ let h = i === 0 && b.comp === 'marca' ? 'explosao' : s.pesado(w) || reserva();
932
+ usosH[h] = (usosH[h] || 0) + 1;
933
+ ultimoH = h;
934
+ recentesH.push(h);
935
+ const comCor = b.acentos < REGRAS.acentos;
936
+ if (comCor) b.acentos += 1;
937
+ b.heroi = { tipo: h, modo: OBJETOS.includes(h) ? 'objeto' : 'campo', semente: hash(b.id + h) % 9973,
938
+ cor: comCor ? b.cores.acento : misturarCor(b.cores.texto, b.cores.fundo, 0.25), cor2: b.cores.texto };
939
+ });
940
+
941
+ // ── as passagens: como uma batida vira a outra ──────────────────────
942
+ let ultimaSaida = null, desdeCorte = 9;
943
+ const saidasRecentes = [];
944
+ batidas.forEach((b, i) => {
945
+ if (i === 0) { b.entrada = 'semente'; return; }
946
+ const a = batidas[i - 1];
947
+ let tr;
948
+ // O campo troca por corte seco, por cortina ou pela inundação do acento — independente do gesto do texto.
949
+ b.troca = a.campo === b.campo ? null : s.pesado({ corte: 5, cortina: 2.5, inunda: 1.5 });
950
+ if (b.troca === 'inunda' && s.chance(0.5)) tr = 'inunda';
951
+ else {
952
+ const tese = b.linhas.find((l) => l.papel === 'tese' || l.papel === 'numero');
953
+ /* As passagens EXTRAVAGANTES pesam mais ("motion é vida", 30/09): a
954
+ letra explode e remonta a próxima palavra, a câmera chicoteia,
955
+ atravessa a palavra. As calmas ficam de respiro. */
956
+ const w = { explode: 3, chicote: 2.8, atravessa: 2.4, pontos: 2.2, evapora: 0.8, desliza: 0.3, comprime: eixoDe(fontes.display, 'wdth') ? 1.2 : 0.7,
957
+ corte: desdeCorte >= 3 && tese && tese.comportamento === 'carimbo' ? 1.8 : 0 };
958
+ if (a.parte === b.parte) { w.atravessa *= 0.5; w.pontos *= 1.4; }
959
+ for (const x of saidasRecentes.slice(-3)) w[x] = 0;
960
+ tr = s.pesado(w);
961
+ }
962
+ desdeCorte = tr === 'corte' ? 0 : desdeCorte + 1;
963
+ ultimaSaida = tr;
964
+ saidasRecentes.push(tr);
965
+ b.entrada = tr;
966
+ a.saida = tr;
967
+ // O que explode REMONTA: as letras da próxima tese chegam dos estilhaços.
968
+ if (tr === 'explode') b.linhas.filter((l) => l.papel === 'tese' && !l.marca && !l.riscado).forEach((l) => { l.comportamento = 'monta'; });
969
+ });
970
+ batidas[batidas.length - 1].saida = 'fim';
971
+
972
+ /* O gesto da tese não volta antes de três batidas — conferido DEPOIS das
973
+ passagens (o "monta" que a explosão pede também conta): se repetir, a
974
+ batida ganha outro gesto; se o repetido é o monta, a passagem muda. */
975
+ const recG = [];
976
+ const gestoLivre = (fonte) => ['peso', 'carimbo', 'largura', 'contorno', 'onda', 'pontos', 'mascara', 'desfoque']
977
+ .filter((g) => !recG.slice(-3).includes(g) && (g !== 'largura' || eixoDe(fonte, 'wdth')) && (g !== 'peso' || eixoDe(fonte, 'wght')));
978
+ batidas.forEach((b, i) => {
979
+ const teses = b.linhas.filter((l) => (l.papel === 'tese' || l.papel === 'numero') && !l.marca);
980
+ if (!teses.length) return;
981
+ let g = teses[0].comportamento;
982
+ // O contador é sempre "peso" (o número engorda contando): quem vem antes dele cede o peso.
983
+ const contadorPerto = !b.contador && batidas.slice(i + 1, i + 4).some((x) => x.contador);
984
+ if (recG.slice(-3).includes(g) || (contadorPerto && g === 'peso')) {
985
+ if (contadorPerto) recG.push('peso');
986
+ if (g === 'monta' && i > 0) {
987
+ const alt = ['pontos', 'atravessa', 'evapora', 'comprime', 'desliza'].find((x) => !saidasRecentes.slice(Math.max(0, i - 4), i - 1).includes(x)) || 'pontos';
988
+ b.entrada = alt; batidas[i - 1].saida = alt;
989
+ }
990
+ const livres = gestoLivre(teses[0].fonte);
991
+ const novo = livres.length ? s.um(livres) : g;
992
+ teses.filter((l) => l.comportamento === g).forEach((l) => { l.comportamento = novo; });
993
+ if (b.contador) teses.forEach((l) => { if (l.id === b.contador.linha) l.comportamento = 'peso'; });
994
+ g = teses[0].comportamento;
995
+ if (contadorPerto) recG.pop();
996
+ }
997
+ recG.push(g);
998
+ });
999
+
1000
+ // ── a vida no repouso: a fonte respira enquanto a gente lê ───────────
1001
+ for (const b of batidas) {
1002
+ const tese = b.linhas.find((l) => l.papel === 'tese' || l.papel === 'numero');
1003
+ const f = tese ? tese.fonte : fontes.display;
1004
+ const op = ['onda', 'deriva', ...(eixoDe(f, 'wght') ? ['respira', 'respira'] : []), ...(eixoDe(f, 'wdth') ? ['estica'] : [])];
1005
+ b.vida = b.comp === 'contador' ? 'respira' : s.um(op);
1006
+ }
1007
+
1008
+ /* ── os GOLPES: a tese bate — escala que soca, tela que treme, impacto
1009
+ no som. Um por batida no máximo, onde a palavra mais forte assenta. */
1010
+ for (const b of batidas) {
1011
+ const t = b.linhas.find((l) => (l.papel === 'tese' || l.papel === 'numero') && !l.riscado);
1012
+ b.golpes = [];
1013
+ if (!t) continue;
1014
+ const pt = t.palavras.find((p) => p.papel === 'tese') || t.palavras[0];
1015
+ const quando = b.contador ? b.contador.t + b.contador.dur : (pt.t || t.t) + (t.comportamento === 'monta' ? 0.32 : t.comportamento === 'carimbo' ? 0.03 : 0.2);
1016
+ if (quando < b.ate - 0.3) b.golpes.push({ t: r3(quando), linha: t.id, forca: b.comp === 'gigante' || b.comp === 'contador' || b.comp === 'parede' ? 1 : 0.6 });
1017
+ }
1018
+
1019
+ // ── a câmera: nunca para; acelera na passagem ────────────────────────
1020
+ const camera = [{ t: 0, x: 0, y: 0, zoom: 1.12, rot: 0, e: 'camera' }];
1021
+ let lado = 1;
1022
+ batidas.forEach((b, i) => {
1023
+ // A câmera tem inércia: o sentido do movimento quase sempre continua (nada de virar a cada batida).
1024
+ if (s.chance(0.2)) lado = -lado;
1025
+ const z0 = r3(1 + s.entre(0, 0.02)), z1 = r3(z0 + s.entre(0.05, 0.09));
1026
+ const x0 = r1(lado * s.entre(0.004, 0.014) * W), x1 = r1(-lado * s.entre(0.01, 0.022) * W);
1027
+ const y0 = r1(s.entre(-0.006, 0.006) * H), y1 = r1(s.entre(-0.01, 0.01) * H);
1028
+ const g0 = r3(s.entre(-0.6, 0.6)), g1 = r3(g0 + lado * s.entre(0.5, 1.1));
1029
+ const assenta = Math.min(b.de + 0.55, (b.de + b.ate) / 2);
1030
+ if (b.entrada === 'corte') camera.push({ t: r3(b.de), x: x0, y: y0, zoom: z0 + 0.04, rot: g0, e: 'degrau' });
1031
+ camera.push({ t: r3(assenta), x: x0, y: y0, zoom: z0, rot: g0, e: i === 0 ? 'camera' : 'editorial' });
1032
+ const fimCalmo = r3(Math.max(assenta + 0.1, b.ate - 0.32));
1033
+ camera.push({ t: fimCalmo, x: x1, y: y1, zoom: z1, rot: g1, e: 'camera' });
1034
+ if (i < batidas.length - 1) {
1035
+ const prox = batidas[i + 1].entrada;
1036
+ const empurra = prox === 'atravessa' ? 0.4 : prox === 'explode' ? 0.16 : prox === 'inunda' ? 0.14 : prox === 'corte' ? 0.03 : 0.1;
1037
+ // O chicote: a câmera sai rasgando de lado, e a próxima chega do outro.
1038
+ const xs = prox === 'chicote' ? -lado * 0.22 * W : x1 * 2.2;
1039
+ camera.push({ t: r3(b.ate), x: r1(xs), y: r1(y1 * 2), zoom: r3(z1 + empurra), rot: r3(g1 + lado * (prox === 'corte' ? 0.3 : prox === 'chicote' ? 2.6 : 2)), e: 'acelera' });
1040
+ } else camera.push({ t: r3(b.ate), x: 0, y: 0, zoom: r3(z1 + 0.03), rot: 0, e: 'camera' });
1041
+ });
1042
+
1043
+ // ── o produto em 3D: mora nas batidas que o hospedam ─────────────────
1044
+ const produtos = batidas.filter((b) => b.produto3d).map((b) => ({ ...b.produto3d, de: b.de, ate: b.ate, modelo: roteiro.modelo3d,
1045
+ cor: roteiro.cor3d || '#1b1c20', corAcento: P.acento }));
1046
+
1047
+ // ── o fundo vivo ────────────────────────────────────────────────────
1048
+ const fundo = { textura: s.um(['grade', 'pontos', 'linhas', 'grade']), passo: Math.round(s.um([72, 96, 120]) * base), grao: luz(P.fundo) < 0.5 ? 0.07 : 0.05,
1049
+ brilho: { x: s.entre(0.25, 0.75), y: s.entre(0.2, 0.5) },
1050
+ // Partículas em três profundidades: o espaço respira mesmo no repouso.
1051
+ particulas: Array.from({ length: 34 }, (_, i) => ({ x: r1(s.prox() * W), y: r1(s.prox() * H), z: [0.25, 0.5, 0.85][i % 3], r: r1((1.2 + s.prox() * 2.4) * base), v: r1(s.entre(6, 22) * base) })) };
1052
+
1053
+ return {
1054
+ versao: 1, semente, fps: FPS, humor,
1055
+ musica: { genero: s.um(H0.generos), generos: H0.generos }, largura: W, altura: H, duracao, regras: REGRAS,
1056
+ paleta: { ...P, fundo: campos[0], tinta: campos[2], acento: campos[1] }, campos, fontes, eixos: Object.fromEntries(Object.values(fontes).map((f) => [f, (VARIAVEIS[f] || {}).eixos || { wght: [400, 700] }])),
1057
+ margem: M, fundo, batidas, camera, produtos,
1058
+ hud: { marca: String((marca.nome || roteiro.titulo || 'PrimoCode')).toUpperCase().slice(0, 24), ano: '2026' },
1059
+ };
1060
+ }
1061
+
1062
+ // ── as composições: de conteúdo + contexto, uma batida ─────────────────
1063
+ function compor(comp, br, ctx) {
1064
+ const { s, W, H, M, deitado, base, larguraUtil, tamRotulo, fontes } = ctx;
1065
+ const c = br.conteudo;
1066
+ const palavras = ctx.palavras || [];
1067
+ const b = { id: 'b' + Math.round(br.de * 1000), parte: br.parte, de: r3(br.de), ate: r3(br.ate), comp, origem: c.tipo === 'falado' ? 'falado' : 'conteudo', linhas: [], acento: [] };
1068
+ const eixosDisplay = VARIAVEIS[fontes.display].eixos;
1069
+ const wMax = eixosDisplay.wght[1];
1070
+ const wTese = Math.min(wMax, s.um([800, 900, 900, 1000]));
1071
+ const destaqueTk = tokens(c.destaque || '');
1072
+ const ehTese = (w) => destaqueTk.length && tokens(w).some((k) => destaqueTk.includes(k));
1073
+ let id = 0;
1074
+ const linha = (texto, papel, o = {}) => {
1075
+ const l = {
1076
+ id: b.id + '-' + (id++), texto: String(texto), papel, fonte: o.fonte || fontes.display, tamanho: Math.round(o.tamanho),
1077
+ eixos: o.eixos || { wght: papel === 'tese' || papel === 'numero' ? wTese : papel === 'rotulo' ? 500 : 420 },
1078
+ cor: o.cor || (papel === 'rotulo' || papel === 'fraco' ? 'fraco' : 'texto'), tracking: o.tracking || 0, maiusc: !!o.maiusc,
1079
+ comportamento: o.comportamento || null, riscado: !!o.riscado, envelhece: null,
1080
+ };
1081
+ l.palavras = l.texto.split(/\s+/).filter(Boolean).map((w) => ({ texto: w, papel: ehTese(w) ? 'tese' : funcao(w) ? 'funcao' : 'normal' }));
1082
+ b.linhas.push(l);
1083
+ return l;
1084
+ };
1085
+ // Os pesos DENTRO da frase: artigo leve, verbo regular, tese pesada e maior.
1086
+ const misturarPesos = (l, { maiorTese = 1.12, corTese = true } = {}) => {
1087
+ const [wmin, wmax] = (VARIAVEIS[l.fonte] && VARIAVEIS[l.fonte].eixos.wght) || [300, 800];
1088
+ const temTese = l.palavras.some((p) => p.papel === 'tese');
1089
+ for (const p of l.palavras) {
1090
+ if (p.papel === 'tese') { p.wght = Math.min(wmax, wTese); p.escala = maiorTese; if (corTese) p.cor = 'acento'; }
1091
+ else if (p.papel === 'funcao') p.wght = prende(temTese ? 300 : 380, [wmin, wmax]);
1092
+ else p.wght = prende(temTese ? 520 : l.eixos.wght, [wmin, wmax]);
1093
+ }
1094
+ };
1095
+ const vert = (opcoes) => s.um(opcoes);
1096
+ const tese = (l) => { l.papel = 'tese'; return l; };
1097
+
1098
+ switch (comp) {
1099
+ case 'gigante': {
1100
+ const txt = c.titulo || c.nome || c.sim || c.valor || c.texto || '';
1101
+ const maiusc = txt.length <= 16 && s.chance(0.65);
1102
+ const fonte = s.chance(0.5) ? fontes.cinetica : fontes.display;
1103
+ const eixos = { wght: Math.min(VARIAVEIS[fonte].eixos.wght[1], wTese) };
1104
+ const op = { fonte, wght: eixos.wght, maiusc };
1105
+ const nMax = deitado ? 2 : Math.min(4, txt.split(/\s+/).length);
1106
+ const alvo = deitado ? larguraUtil * 0.96 : larguraUtil;
1107
+ const fit = caberEmLinhas(txt, alvo, nMax, (deitado ? 0.3 : 0.2) * H, op);
1108
+ const alinha = deitado && s.chance(0.35) ? 'esq' : 'centro';
1109
+ b.bloco = { x: alinha === 'esq' ? M : W / 2, y: H * vert(deitado ? [0.5, 0.52, 0.47] : [0.47, 0.5, 0.55]), alinha, vert: 'meio', largura: alvo, gap: 0.04 };
1110
+ if (c.selo) linha(c.selo, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.16, maiusc: true, eixos: { wght: 500 } });
1111
+ for (const t of fit.linhas) {
1112
+ const l = linha(t, 'tese', { fonte, tamanho: fit.tamanho, eixos, maiusc, riscado: c.riscado, cor: c.riscado ? 'fraco' : 'texto' });
1113
+ if (!destaqueTk.length) l.palavras.forEach((p) => { p.papel = 'normal'; });
1114
+ }
1115
+ const ultima = b.linhas[b.linhas.length - 1];
1116
+ if (c.riscado) b.acento.push({ forma: 'risco', alvo: { linha: ultima.id } });
1117
+ else if (/[.!]$/.test(txt) || s.chance(0.4)) b.acento.push({ forma: 'ponto', alvo: { linha: ultima.id, fim: true } });
1118
+ else {
1119
+ const lt = b.linhas.find((l) => l.papel === 'tese' && l.palavras.some((p) => p.papel === 'tese')) || ultima;
1120
+ const pi = Math.max(0, lt.palavras.findIndex((p) => p.papel === 'tese'));
1121
+ b.acento.push({ forma: 'sublinha', alvo: { linha: lt.id, palavra: lt.palavras.some((p) => p.papel === 'tese') ? pi : null } });
1122
+ }
1123
+ if (s.chance(0.5)) b.linhas.filter((l) => l.papel === 'tese').forEach((l) => l.palavras.forEach((p) => { if (p.papel === 'tese') p.cor = 'acento'; }));
1124
+ break;
1125
+ }
1126
+ case 'pilha': case 'produto': {
1127
+ const noProduto = comp === 'produto';
1128
+ const lg = noProduto ? (deitado ? W * 0.42 : larguraUtil) : (deitado ? W * s.entre(0.62, 0.74) : larguraUtil);
1129
+ const principal = c.tipo === 'numero' ? c.valor : (c.titulo || c.nome || c.nao || c.sim || c.texto || '');
1130
+ const nL = 3;
1131
+ const op = { fonte: fontes.display, wght: wTese };
1132
+ const fit = caberEmLinhas(principal, lg, nL, (deitado ? 0.15 : 0.085) * H * (noProduto ? 0.85 : 1), op);
1133
+ const y = noProduto ? (deitado ? H * 0.52 : H * 0.72) : H * vert(deitado ? [0.42, 0.5, 0.58] : [0.44, 0.52, 0.6]);
1134
+ b.bloco = { x: M, y, alinha: 'esq', vert: 'meio', largura: lg, gap: 0.16 };
1135
+ const rot = c.selo || (c.tipo === 'cta' ? '' : '');
1136
+ if (rot) linha(rot, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.16, maiusc: true, eixos: { wght: 500 }, cor: noProduto ? 'acento' : 'fraco' });
1137
+ for (const t of fit.linhas) {
1138
+ const l = linha(t, 'tese', { tamanho: fit.tamanho, riscado: c.riscado, cor: c.riscado ? 'fraco' : 'texto' });
1139
+ if (!c.riscado) misturarPesos(l, { corTese: !noProduto });
1140
+ }
1141
+ if (c.tipo === 'numero') {
1142
+ const t0 = b.linhas.find((l) => l.papel === 'tese');
1143
+ t0.papel = 'numero';
1144
+ b.contador = { linha: t0.id, ...numeroDe(c.valor), dur: 0.9 };
1145
+ if (c.rotulo) linha(c.rotulo, 'apoio', { tamanho: Math.max(30 * base, fit.tamanho * 0.34), eixos: { wght: 600 } });
1146
+ }
1147
+ const apoio = c.sub || c.link || '';
1148
+ if (apoio) {
1149
+ const fa = caberEmLinhas(apoio, lg, 2, Math.max(26 * base, fit.tamanho * 0.36), { fonte: fontes.texto, wght: 400 });
1150
+ fa.linhas.forEach((t) => linha(t, 'apoio', { fonte: fontes.texto, tamanho: fa.tamanho, eixos: { wght: 400 }, cor: c.link ? 'acento' : 'fraco' }));
1151
+ }
1152
+ const teses = b.linhas.filter((l) => l.papel === 'tese' || l.papel === 'numero');
1153
+ if (c.riscado) b.acento.push({ forma: 'risco', alvo: { linha: teses[teses.length - 1].id } });
1154
+ else {
1155
+ const comTese = teses.find((l) => l.palavras.some((p) => p.papel === 'tese'));
1156
+ const forma = s.pesado({ barra: 2, sublinha: comTese ? 2.5 : 0.6, anel: comTese && comTese.palavras.find((p) => p.papel === 'tese').texto.length <= 9 ? 1.6 : 0 });
1157
+ if (forma === 'barra') b.acento.push({ forma: 'barra', alvo: { bloco: true } });
1158
+ else {
1159
+ const lt = comTese || teses[teses.length - 1];
1160
+ b.acento.push({ forma, alvo: { linha: lt.id, palavra: comTese ? lt.palavras.findIndex((p) => p.papel === 'tese') : null } });
1161
+ }
1162
+ }
1163
+ // O ThreeCanvas só aceita pixel inteiro; ele ocupa a altura toda, para o chão não cortar.
1164
+ if (noProduto) b.produto3d = deitado ? { x: Math.round(W * 0.42), y: 0, largura: Math.round(W * 0.58), altura: H, escala: 0.95 } : { x: 0, y: -Math.round(H * 0.2), largura: W, altura: H, escala: 0.62 };
1165
+ break;
1166
+ }
1167
+ case 'palavras': {
1168
+ const txt = c.texto || c.titulo || '';
1169
+ const alinha = s.chance(0.55) ? 'esq' : 'centro';
1170
+ const lg = deitado ? W * 0.8 : larguraUtil;
1171
+ const op = { fonte: fontes.display, wght: 600 };
1172
+ const fit = caberEmLinhas(txt, lg, deitado ? 3 : 4, (deitado ? 0.12 : 0.075) * H, op);
1173
+ b.bloco = { x: alinha === 'esq' ? M : W / 2, y: H * vert([0.48, 0.52]), alinha, vert: 'meio', largura: lg, gap: 0.1 };
1174
+ // Sem destaque, a tese é a palavra mais longa que não é função.
1175
+ if (!destaqueTk.length) {
1176
+ const cand = txt.split(/\s+/).filter((w) => !funcao(w)).sort((a, b2) => tokens(b2).join('').length - tokens(a).join('').length)[0];
1177
+ if (cand) destaqueTk.push(...tokens(cand));
1178
+ }
1179
+ for (const t of fit.linhas) { const l = linha(t, 'apoio', { tamanho: fit.tamanho, eixos: { wght: 560 } }); misturarPesos(l, { maiorTese: 1.14 }); }
1180
+ const lt = b.linhas.find((l) => l.palavras.some((p) => p.papel === 'tese'));
1181
+ if (lt) { tese(lt); b.acento.push({ forma: s.chance(0.6) ? 'sublinha' : 'anel', alvo: { linha: lt.id, palavra: lt.palavras.findIndex((p) => p.papel === 'tese') } }); }
1182
+ else b.acento.push({ forma: 'ponto', alvo: { linha: b.linhas[b.linhas.length - 1].id, fim: true } });
1183
+ break;
1184
+ }
1185
+ case 'contador': {
1186
+ const n = numeroDe(c.valor);
1187
+ const fonte = s.chance(0.6) ? fontes.cinetica : fontes.display;
1188
+ const eixos = { wght: Math.min(VARIAVEIS[fonte].eixos.wght[1], wTese) };
1189
+ const alinha = 'centro';
1190
+ const lg = deitado ? W * 0.62 : larguraUtil;
1191
+ const tam = tamanhoQueCabe(c.valor, lg * 0.82, (deitado ? 0.36 : 0.17) * H, { fonte, wght: eixos.wght });
1192
+ b.bloco = { x: alinha === 'esq' ? M : W / 2, y: H * vert(deitado ? [0.48, 0.5] : [0.46, 0.5]), alinha, vert: 'meio', largura: lg, gap: 0.08 };
1193
+ const l = linha(c.valor, 'numero', { fonte, tamanho: tam, eixos });
1194
+ const tRot = Math.max(30 * base, Math.min(tam * 0.22, 64 * base));
1195
+ if (c.rotulo) linha(c.rotulo, 'apoio', { tamanho: tRot, eixos: { wght: 600 }, fonte: fontes.display });
1196
+ if (c.sub) linha(c.sub, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.08, eixos: { wght: 400 } });
1197
+ b.contador = { linha: l.id, ...n, dur: 0.9 };
1198
+ b.acento.push({ forma: c.valor.length <= 4 ? 'anel' : 'sublinha', alvo: { linha: l.id } });
1199
+ break;
1200
+ }
1201
+ case 'cascata': {
1202
+ const lg = deitado ? W * 0.72 : larguraUtil - 40 * base;
1203
+ const itens = c.itens.slice(0, 5);
1204
+ // Fonte larga (Unbounded) aperta a lista: os itens vão na fonte de texto.
1205
+ const fonte = VARIAVEIS[fontes.display].largura > 1.1 ? fontes.texto : fontes.display;
1206
+ const op = { fonte, wght: 560 };
1207
+ const maxT = (deitado ? 0.09 : 0.042) * H;
1208
+ const nL = itens.length <= 3 ? 2 : 1;
1209
+ const tam = Math.min(...itens.map((it) => caberEmLinhas((c.numerada ? '00 ' : '') + it, lg, nL, maxT, op).tamanho));
1210
+ b.bloco = { x: M + 30 * base, y: H * vert(deitado ? [0.5, 0.52] : [0.5, 0.54]), alinha: 'esq', vert: 'meio', largura: lg, gap: 0.42 };
1211
+ if (c.titulo) linha(c.titulo, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.16, maiusc: true, eixos: { wght: 500 } });
1212
+ itens.forEach((it, j) => {
1213
+ const txt = (c.numerada ? String(j + 1).padStart(2, '0') + ' ' : '') + it;
1214
+ quebrar(txt, lg, tam, op, nL).forEach((pedaco, q) => {
1215
+ const l = linha(pedaco, 'item', { tamanho: tam, fonte, eixos: { wght: 560 } });
1216
+ if (q > 0) l.continua = true;
1217
+ if (c.numerada && q === 0) l.palavras[0].papel = 'indice';
1218
+ l.palavras.forEach((p) => { if (p.papel === 'tese') p.papel = 'normal'; });
1219
+ });
1220
+ });
1221
+ b.acento.push({ forma: 'bala', alvo: { linha: b.linhas.find((l) => l.papel === 'item').id }, segue: true });
1222
+ break;
1223
+ }
1224
+ case 'divisao': {
1225
+ const lado = deitado ? (larguraUtil - M * 0.6) / 2 : larguraUtil;
1226
+ const op = { fonte: fontes.display, wght: wTese };
1227
+ const fN = caberEmLinhas(c.nao, lado, deitado ? 3 : 2, (deitado ? 0.1 : 0.065) * H, { ...op, wght: 400 });
1228
+ const fS = caberEmLinhas(c.sim, lado, deitado ? 3 : 3, (deitado ? 0.12 : 0.08) * H, op);
1229
+ if (deitado) {
1230
+ b.bloco = { x: M, y: H * 0.5, alinha: 'esq', vert: 'meio', largura: lado, gap: 0.1, colunas: [M, M + lado + M * 0.6] };
1231
+ } else {
1232
+ b.bloco = { x: M, y: H * 0.5, alinha: 'esq', vert: 'meio', largura: lado, gap: 0.12, blocos: 2 };
1233
+ }
1234
+ fN.linhas.forEach((t) => { const l = linha(t, 'fraco', { tamanho: fN.tamanho, eixos: { wght: 380 }, cor: 'fraco' }); l.coluna = 0; l.riscado = true; });
1235
+ fS.linhas.forEach((t) => { const l = linha(t, 'tese', { tamanho: fS.tamanho }); l.coluna = 1; misturarPesos(l); });
1236
+ const nao = b.linhas.filter((l) => l.coluna === 0), sim = b.linhas.filter((l) => l.coluna === 1);
1237
+ b.acento.push({ forma: 'risco', alvo: { linha: nao[Math.floor((nao.length - 1) / 2)].id } });
1238
+ const lt = sim.find((l) => l.palavras.some((p) => p.papel === 'tese')) || sim[sim.length - 1];
1239
+ b.acento.push({ forma: 'sublinha', alvo: { linha: lt.id, palavra: lt.palavras.some((p) => p.papel === 'tese') ? lt.palavras.findIndex((p) => p.papel === 'tese') : null } });
1240
+ break;
1241
+ }
1242
+ case 'citacao': {
1243
+ const fonte = fontes.serifa;
1244
+ const lg = deitado ? W * 0.78 : larguraUtil;
1245
+ const q = String(c.texto || '').replace(/^["“]+|["”]+$/g, '');
1246
+ const fit = caberEmLinhas(q, lg, deitado ? 4 : 5, (deitado ? 0.12 : 0.065) * H, { fonte, wght: 420 });
1247
+ b.bloco = { x: M, y: H * vert([0.48, 0.52]), alinha: 'esq', vert: 'meio', largura: lg, gap: 0.16 };
1248
+ linha('“', 'aspas', { fonte, tamanho: fit.tamanho * 1.6, eixos: { wght: 800 }, cor: 'acento' });
1249
+ fit.linhas.forEach((t) => { const l = linha(t, 'apoio', { fonte, tamanho: fit.tamanho, eixos: { wght: 420, ...(VARIAVEIS[fonte].eixos.SOFT ? { SOFT: 100, WONK: 1 } : {}) } }); l.palavras.forEach((p) => { if (p.papel === 'tese') { p.wght = 700; } }); });
1250
+ if (c.autor) linha('— ' + c.autor, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.06, eixos: { wght: 400 } });
1251
+ const lt = b.linhas.find((l) => l.palavras.some((p) => p.papel === 'tese'));
1252
+ if (lt) { tese(lt); b.acento.push({ forma: 'marca', alvo: { linha: lt.id, palavra: lt.palavras.findIndex((p) => p.papel === 'tese'), ate: lt.palavras.map((p) => p.papel).lastIndexOf('tese') } }); }
1253
+ else b.acento.push({ forma: 'barra', alvo: { bloco: true } });
1254
+ break;
1255
+ }
1256
+ case 'marca': {
1257
+ const nome = c.nome || c.titulo || (ctx.marca.nome || '');
1258
+ const fonte = fontes.display;
1259
+ const lg = deitado ? W * 0.7 : larguraUtil * 0.9;
1260
+ const tam = tamanhoQueCabe(nome, lg, (deitado ? 0.22 : 0.13) * H, { fonte, wght: 800 });
1261
+ b.bloco = { x: W / 2 + tam * 0.3, y: H * 0.5, alinha: 'centro', vert: 'meio', largura: lg, gap: 0.2 };
1262
+ linha(nome, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(800, VARIAVEIS[fonte].eixos.wght[1]) }, comportamento: 'contorno' }).marca = true;
1263
+ if (c.sub || c.link) linha(c.sub || c.link, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.2, maiusc: true, eixos: { wght: 400 } });
1264
+ b.acento.push({ forma: 'ponto', alvo: { linha: b.linhas[0].id, antes: true } });
1265
+ break;
1266
+ }
1267
+ case 'parede': {
1268
+ const txt = c.titulo || c.nome || '';
1269
+ const fonte = fontes.cinetica;
1270
+ const maiusc = true;
1271
+ const tam = tamanhoQueCabe(txt, larguraUtil, (deitado ? 0.2 : 0.1) * H, { fonte, wght: 900, maiusc });
1272
+ b.bloco = { x: W / 2, y: H * 0.5, alinha: 'centro', vert: 'meio', largura: larguraUtil, gap: 0.1 };
1273
+ linha(txt, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(900, VARIAVEIS[fonte].eixos.wght[1]) }, maiusc });
1274
+ b.textura = { texto: (maiusc ? txt.toUpperCase() : txt), tamanho: tam, fonte, linhas: Math.ceil(H / (tam * 1.02)) + 3, velocidade: Math.round(s.entre(40, 90) * base) };
1275
+ b.acento.push({ forma: s.chance(0.5) ? 'moldura' : 'sublinha', alvo: { linha: b.linhas[0].id } });
1276
+ break;
1277
+ }
1278
+ default: throw new Error('composição desconhecida: ' + comp);
1279
+ }
1280
+
1281
+ // ── o comportamento de cada linha, do que os eixos permitem ─────────
1282
+ /* Um GESTO por grupo: as linhas da mesma frase entram do mesmo jeito (a
1283
+ frase é uma ação só); rótulo, apoio e itens têm o gesto deles. */
1284
+ const gestos = {};
1285
+ for (const l of b.linhas) {
1286
+ if (l.comportamento) continue;
1287
+ const grupo = l.papel === 'numero' ? 'tese' : l.papel;
1288
+ if (gestos[grupo] && l.fonte === gestos[grupo].fonte) { l.comportamento = gestos[grupo].c; continue; }
1289
+ const eixosF = VARIAVEIS[l.fonte] ? VARIAVEIS[l.fonte].eixos : {};
1290
+ const temW = eixosF.wght && eixosF.wght[1] - eixosF.wght[0] >= 400;
1291
+ const temL = !!eixosF.wdth && eixosF.wdth[0] <= 80;
1292
+ let op;
1293
+ /* Sem embaralhar e sem split-flap: leem como defeito ("natural, não
1294
+ estranho", 30/09). Os gestos ficam físicos: peso, largura, máscara. */
1295
+ if (l.papel === 'tese' || l.papel === 'numero') op = { carimbo: 2, peso: temW ? 2.6 : 0, largura: temL ? 2.4 : 0, contorno: 1.3, onda: 1.1, pontos: 1.2, mascara: 1.2, desfoque: 1 };
1296
+ else if (l.papel === 'rotulo') op = { tracking: 1.5, mascara: 2, desfoque: 1.2 };
1297
+ else if (l.papel === 'aspas') op = { carimbo: 1 };
1298
+ else op = { mascara: 2, desfoque: 2.2, tracking: 1.2, onda: 1, peso: temW ? 1.2 : 0 };
1299
+ l.comportamento = s.pesado(op);
1300
+ gestos[grupo] = { c: l.comportamento, fonte: l.fonte };
1301
+ }
1302
+ // A MESMA tese não entra do mesmo jeito da batida anterior (não é template).
1303
+ const tese0 = b.linhas.find((l) => l.papel === 'tese' || l.papel === 'numero');
1304
+ // Nenhum gesto da tese volta antes de três batidas.
1305
+ const mem = ctx.memoria || {};
1306
+ mem.gestos = mem.gestos || [];
1307
+ if (tese0 && !tese0.marca && mem.gestos.slice(-3).includes(tese0.comportamento)) {
1308
+ const livres = ['peso', 'carimbo', 'largura', 'contorno', 'onda', 'pontos', 'mascara', 'desfoque'].filter((g) => !mem.gestos.slice(-3).includes(g)
1309
+ && (g !== 'largura' || (VARIAVEIS[tese0.fonte] && VARIAVEIS[tese0.fonte].eixos.wdth)) && (g !== 'peso' || (VARIAVEIS[tese0.fonte] && VARIAVEIS[tese0.fonte].eixos.wght)));
1310
+ const novo = livres.length ? s.um(livres) : tese0.comportamento;
1311
+ const velho = tese0.comportamento;
1312
+ b.linhas.filter((l) => (l.papel === 'tese' || l.papel === 'numero') && l.comportamento === velho && !l.marca).forEach((l) => { l.comportamento = novo; });
1313
+ }
1314
+ if (tese0) mem.gestos.push(tese0.comportamento);
1315
+ if (b.contador) b.linhas.find((l) => l.id === b.contador.linha).comportamento = 'peso';
1316
+
1317
+ /* ENCHER: a estimativa de largura erra para fonte larga ou estreita; o
1318
+ componente mede e cresce o grupo principal até a largura do bloco,
1319
+ com o teto vertical daqui (o respiro de 30–40% continua garantido). */
1320
+ // A palavra-herói de ponta a ponta (o CLAUDE do reel): teto alto, a largura manda.
1321
+ const tetoV = { gigante: deitado ? 0.44 : 0.24, marca: deitado ? 0.3 : 0.16, parede: deitado ? 0.26 : 0.13, contador: deitado ? 0.52 : 0.22,
1322
+ pilha: deitado ? 0.22 : 0.12, produto: deitado ? 0.14 : 0.08, palavras: deitado ? 0.12 : 0.08, cascata: deitado ? 0.1 : 0.05, divisao: deitado ? 0.13 : 0.085, citacao: deitado ? 0.12 : 0.07 }[b.comp] || 0.1;
1323
+ // A SANGRIA (a palavra de ponta a ponta) é um momento, não o padrão: até duas por vídeo, só em palavra curta.
1324
+ const memS = ctx.memoria || {};
1325
+ if (['gigante', 'parede'].includes(b.comp) && (memS.cheios || 0) < 2 && b.linhas.filter((l) => l.papel === 'tese').length <= 2
1326
+ && b.linhas.every((l) => l.texto.length <= 12) && s.chance(0.6)) { b.cheio = true; memS.cheios = (memS.cheios || 0) + 1; }
1327
+ const enche = (l) => ['tese', 'numero', 'item'].includes(l.papel) || (b.comp === 'citacao' && l.papel === 'apoio') || (b.comp === 'palavras' && l.papel === 'apoio');
1328
+ // O bloco inteiro cabe na área segura: o teto por linha divide a altura útil (~72%) pelo número de linhas que enchem.
1329
+ const nEnche = Math.max(1, ...[0, 1].map((col) => b.linhas.filter((l) => enche(l) && (l.coluna == null || l.coluna === col)).length));
1330
+ for (const l of b.linhas) {
1331
+ if (!enche(l)) continue;
1332
+ l.encher = l.papel === 'item' ? 'item' : 'principal';
1333
+ // O respiro do guia: fora dos (no máximo dois) momentos de sangria, o bloco usa ~55% da altura e a tese não encosta em nada.
1334
+ l.max = Math.round(b.cheio ? Math.min(tetoV * H, (H * 0.66) / (nEnche * 1.05)) : Math.min(tetoV * H * 0.72, (H * 0.5) / (nEnche * 1.05)));
1335
+ }
1336
+ // ── o tempo de cada linha e palavra: quando a voz diz ────────────────
1337
+ tempoDaBatida(b, palavras, ctx);
1338
+ // ── o acento: quando cada estado dele começa ─────────────────────────
1339
+ acentoNoTempo(b, ctx);
1340
+ // A linha que continua o item é o mesmo elemento (o item quebrou em duas).
1341
+ b.elementos = b.linhas.filter((l) => !l.continua).length + (b.textura ? 1 : 0) + (b.produto3d ? 1 : 0);
1342
+ b.acentos = contarAcentos(b);
1343
+ return b;
1344
+ }
1345
+
1346
+ function tempoDaBatida(b, palavras, ctx) {
1347
+ const Q = QUADRO;
1348
+ const fim = b.ate - REGRAS.leitura; // a última entrada deixa 1 s de leitura
1349
+ const ordem = b.linhas.filter((l) => l.papel !== 'aspas');
1350
+ const quando = casar(ordem.map((l) => l.texto), palavras, { de: b.de - 0.05 });
1351
+ // Lista: cada item no instante em que é dito; o resto, em cascata depois do anterior.
1352
+ let t = b.de + 0.12;
1353
+ /* O passo entre o que NÃO é dito: a cascata dá tempo de ler cada item
1354
+ (até 0,7 s); o resto, o stagger do guia (3–6 quadros). */
1355
+ const naoDitos = quando.filter((q) => q == null).length;
1356
+ const janela = Math.max(0, fim - b.de - 0.3);
1357
+ const passo = b.comp === 'cascata' && naoDitos > 1
1358
+ ? Math.max(5 * Q, Math.min(0.7, janela / Math.max(1, ordem.length - 1)))
1359
+ : ctx.s.um([3, 4, 5, 6]) * Q;
1360
+ ordem.forEach((l, i) => {
1361
+ if (l.continua && i) { l.t = r3(t + 2 * Q); t = l.t; return; }
1362
+ const falado = quando[i];
1363
+ l.t = r3(Math.max(i === 0 ? b.de + 0.1 : t + REGRAS.stagger[0] * Q, falado != null ? falado : (i === 0 ? b.de + 0.12 : t + passo)));
1364
+ t = l.t;
1365
+ });
1366
+ /* Quadro morto não: a primeira coisa da batida entra em até 0,35 s, mesmo
1367
+ que a voz ainda não a tenha dito (o resto segue a voz). */
1368
+ if (ordem[0] && ordem[0].t > b.de + 0.35) ordem[0].t = r3(b.de + 0.35);
1369
+ for (let i = 1; i < ordem.length; i++) if (ordem[i].t < ordem[i - 1].t + REGRAS.stagger[0] * Q) ordem[i].t = r3(ordem[i - 1].t + REGRAS.stagger[0] * Q);
1370
+ // Nada entra tarde demais para ser lido: de trás para frente, com o stagger mínimo.
1371
+ for (let i = ordem.length - 1; i >= 0; i--) {
1372
+ const teto = i === ordem.length - 1 ? fim : ordem[i + 1].t - (ordem[i + 1].continua ? 2 : REGRAS.stagger[0]) * Q;
1373
+ if (ordem[i].t > teto) ordem[i].t = r3(teto);
1374
+ }
1375
+ for (let i = 0; i < ordem.length; i++) {
1376
+ const piso = i === 0 ? b.de + 0.03 : ordem[i - 1].t + (ordem[i].continua ? 2 : REGRAS.stagger[0]) * Q;
1377
+ if (ordem[i].t < piso) ordem[i].t = r3(piso);
1378
+ }
1379
+ const aspas = b.linhas.find((l) => l.papel === 'aspas');
1380
+ if (aspas) aspas.t = r3(Math.max(b.de + 0.03, ordem[0].t - 4 * Q));
1381
+ /* A FALA, palavra por palavra: na batida falada, a k-ésima palavra na tela
1382
+ é a k-ésima da frase — o tempo dela é o da voz, exato. */
1383
+ if (b.origem === 'falado') {
1384
+ let k = 0;
1385
+ for (const l of b.linhas) {
1386
+ for (const p of l.palavras) { const w = palavras[k++]; if (w) p.t = r3(Math.min(w.de, Math.max(b.de + 0.03, fim))); }
1387
+ l.t = l.palavras[0].t;
1388
+ }
1389
+ for (let i = 1; i < ordem.length; i++) if (ordem[i].t < ordem[i - 1].t + REGRAS.stagger[0] * Q) ordem[i].t = r3(ordem[i - 1].t + REGRAS.stagger[0] * Q);
1390
+ for (const l of b.linhas) l.palavras.forEach((p, j) => { if (j === 0 || p.t < l.t) p.t = Math.max(p.t, l.t); });
1391
+ }
1392
+ // As palavras: a fala faladas na hora; as outras seguem a linha.
1393
+ for (const l of b.linhas) {
1394
+ if (b.origem === 'falado') continue;
1395
+ const qs = b.origem === 'falado' || b.comp === 'palavras' ? casar(l.palavras.map((p) => p.texto), palavras, { de: l.t - 0.05 }) : l.palavras.map(() => null);
1396
+ let tw = l.t;
1397
+ l.palavras.forEach((p, j) => {
1398
+ const alvo = qs[j] != null ? qs[j] : (j === 0 ? l.t : tw + 2 * Q);
1399
+ p.t = r3(Math.min(Math.max(j === 0 ? l.t : tw + 2 * Q, alvo), Math.max(l.t, fim)));
1400
+ tw = p.t;
1401
+ });
1402
+ }
1403
+ // A cascata envelhece: o item anterior perde a cor quando o próximo entra.
1404
+ const itens = b.linhas.filter((l) => l.papel === 'item');
1405
+ const cabecas = itens.filter((l) => !l.continua);
1406
+ itens.forEach((l) => { const prox = cabecas.find((h) => h.t > l.t + 0.001 && itens.indexOf(h) > itens.indexOf(l)); if (prox) l.envelhece = prox.t; });
1407
+ if (b.contador) b.contador.t = b.linhas.find((l) => l.id === b.contador.linha).t;
1408
+ }
1409
+
1410
+ function acentoNoTempo(b, ctx) {
1411
+ const Q = QUADRO;
1412
+ const [ta, tb] = REGRAS.traco;
1413
+ const dur = () => r3(ctx.s.um([ta, 12, 14, tb]) * Q);
1414
+ const alvoT = (a) => {
1415
+ const l = a.alvo && a.alvo.linha ? b.linhas.find((x) => x.id === a.alvo.linha) : b.linhas.find((x) => x.papel === 'tese') || b.linhas[0];
1416
+ return l;
1417
+ };
1418
+ const fim = b.ate - 0.5;
1419
+ if (b.acento[0] && b.acento[0].segue) {
1420
+ // O marcador anda com a lista: um estado por item.
1421
+ const itens = b.linhas.filter((l) => l.papel === 'item' && !l.continua);
1422
+ const a0 = b.acento[0];
1423
+ b.acento = itens.map((l) => ({ forma: 'bala', alvo: { linha: l.id }, t: r3(Math.max(b.de + 0.05, l.t - 2 * Q)), dur: r3(8 * Q) }));
1424
+ void a0;
1425
+ return;
1426
+ }
1427
+ let prev = b.de;
1428
+ b.acento.forEach((a, i) => {
1429
+ const l = alvoT(a);
1430
+ const base = l ? l.t + (a.forma === 'ponto' && a.alvo.antes ? -0.05 : REGRAS.burst[0] * Q * 0.75) : b.de + 0.2;
1431
+ a.t = r3(Math.min(fim, Math.max(prev + (i ? 0.25 : 0.05), base)));
1432
+ a.dur = dur();
1433
+ prev = a.t + a.dur;
1434
+ });
1435
+ }
1436
+
1437
+ function contarAcentos(b) {
1438
+ const formas = b.acento.length ? 1 : 0; // o traço é UM, que anda
1439
+ const linhasAcento = b.linhas.filter((l) => l.cor === 'acento').length;
1440
+ const palavrasAcento = b.linhas.reduce((n, l) => n + (l.cor === 'acento' ? 0 : l.palavras.filter((p) => p.cor === 'acento').length ? 1 : 0), 0);
1441
+ let n = formas + linhasAcento + palavrasAcento;
1442
+ // Passou de três: a cor sai primeiro das palavras, depois das linhas de apoio.
1443
+ if (n > REGRAS.acentos) {
1444
+ for (const l of b.linhas) { if (n <= REGRAS.acentos) break; if (l.cor !== 'acento' && l.palavras.some((p) => p.cor === 'acento')) { l.palavras.forEach((p) => { if (p.cor === 'acento') p.cor = null; }); n--; } }
1445
+ for (const l of b.linhas) { if (n <= REGRAS.acentos) break; if (l.cor === 'acento' && l.papel !== 'aspas') { l.cor = 'fraco'; n--; } }
1446
+ }
1447
+ return n;
1448
+ }
1449
+
1450
+ module.exports = {
1451
+ montarGerativo, partituraDe, sonsDoVideo, musicaDe, desenharSom, humorDe, corGenerica, BANIDAS, HUMORES, REGRAS, VARIAVEIS, COMPOSICOES, COMPORTAMENTOS, SAIDAS, ACENTOS, FPS,
1452
+ especEixos, sorteador, hash, larguraEm, conteudoDe, casar,
1453
+ };