primocode 10.4.4 → 10.4.7

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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v10.4.4
1
+ # PrimoCode v10.4.7
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v10.4.4
12
+ ▐▛███▜▌ Primo Code v10.4.7
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/lib/catalogo.js CHANGED
@@ -93,7 +93,7 @@ const GRUPOS = [
93
93
  + 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link} '
94
94
  + 'logo{titulo} cores{cores:[{nome,hex}]} produto{titulo,texto,destaque,selo,barras}. Em qualquer parte: destaque (a palavra-tese), fonte (a fonte citada). O texto da TELA sai da FALA: o título usa palavras que a fala diz, cedo na frase (a tela entra quando a voz diz; palavra que a voz não diz deixa a tela esperando). '
95
95
  + 'visual:{titulo,texto,paleta} (hibrido, cinema, impacto): fonte e cor do cardápio (estudio_midia acao:fontes); sem visual, o PrimoCode sorteia sem repetir os últimos vídeos. '
96
- + 'estilo (SEM IMAGEM é o padrão: escolha o motion que serve a ESTE roteiro, ou deixe vazio e ele gira entre gerativo, fluido e impacto): hibrido (SÓ quando a pessoa pediu imagem: foto e vídeo reais + o motion por cima; dê busca:"termo em inglês" só nas partes em que a imagem agrega) | gerativo (= motion: sem template — a tipografia anima nos eixos da fonte numa linha do tempo contínua, composição, fonte, cor e som sorteados do roteiro; modelo3d fone|celular|notebook|relogio|garrafa|lata|caixa: o objeto gira em 3D numa batida — ponha quando o vídeo é de um produto ou objeto físico; ideia abstrata, história ou explicação: deixe vazio; o vídeo tem UMA identidade (2 chãos, 1 acento, 2 fontes) e o fundo só troca na virada de seção — se o roteiro tem atos, dê secao:"nome curto" nas partes, senão o PrimoCode divide; moldura cantos|completa|false: o quadro fixo do reel (cantos, o assunto no alto, "01 · seção", o tempo) — ponha só se o vídeo pede cara de peça de design; vazio, o PrimoCode alterna com memória e às vezes não põe; heroi em cada parte: o desenho da batida além do texto, tirado do CONTEÚDO — um ícone do Iconify do objeto que a fala cita ("mdi:bread-slice", "mdi:qrcode"; procure com estudio_midia acao:icones), um 3D do kit (raio|relogio|check|balao|escudo|coracao|estrela|sol|play|engrenagem|cadeado|barras|fone|onda|celular|seta|bateria) ou "nenhum" quando a tipografia basta; forma abstrata (explosao|poligono|esfera|toro|truchet|aneis|grade|rastro) só em assunto abstrato ou de tecnologia — numa conversa de padaria, nunca; ambiente limpo|luz|bokeh e acento traco|viagem (o ponto que viaja entre as batidas): vazio, o PrimoCode escolhe com memória e o bokeh quase não sai fora de tecnologia) | classico (o motion 3D antigo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
96
+ + 'estilo (SEM IMAGEM é o padrão: escolha o motion que serve a ESTE roteiro, ou deixe vazio e ele gira entre gerativo, fluido e impacto): hibrido (SÓ quando a pessoa pediu imagem: foto e vídeo reais + o motion por cima; dê busca:"termo em inglês" só nas partes em que a imagem agrega) | gerativo (= motion: sem template — a tipografia anima nos eixos da fonte numa linha do tempo contínua, composição, fonte, cor e som sorteados do roteiro; modelo3d fone|celular|notebook|relogio|garrafa|lata|caixa: o objeto gira em 3D numa batida — ponha quando o vídeo é de um produto ou objeto físico; ideia abstrata, história ou explicação: deixe vazio; o vídeo tem UMA identidade (2 chãos, 1 acento, 2 fontes) e o fundo só troca na virada de seção — se o roteiro tem atos, dê secao:"nome curto" nas partes, senão o PrimoCode divide; moldura cantos|completa: o quadro fixo do reel (cantos, o assunto no alto, "01 · seção", o tempo) — SÓ quando a pessoa pedir; vazio = sem moldura (ela entrega que o vídeo foi gerado); heroi em cada parte: o desenho da batida além do texto, tirado do CONTEÚDO — um ícone do Iconify do objeto que a fala cita ("mdi:bread-slice", "mdi:qrcode"; procure com estudio_midia acao:icones), um 3D do kit (raio|relogio|check|balao|escudo|coracao|estrela|sol|play|engrenagem|cadeado|barras|fone|onda|celular|seta|bateria) ou "nenhum" quando a tipografia basta; forma abstrata (explosao|poligono|esfera|toro|truchet|aneis|grade|rastro) só em assunto abstrato ou de tecnologia — numa conversa de padaria, nunca; ambiente limpo|luz|bokeh e acento traco|viagem (o ponto que viaja entre as batidas): vazio, o PrimoCode escolhe com memória e o bokeh quase não sai fora de tecnologia) | classico (o motion 3D antigo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
97
97
  + 'marca (brand reveal: brilho, letras em vetor, marca cromada, padrão) | lancamento (produto: cartões, painel, calendário) | fluido (minimalista: uma ideia por batida, a forma de uma vira a próxima, sem corte; paleta noite|breu|preto|cobalto (escuras) ou lavanda|creme|laranja|branco (claras) — escolha pelo assunto e NÃO repita a do vídeo anterior; vazio, o PrimoCode escolhe; fontesFluido aprovado|editorial|revista|carater|tecnico|classico, vazio gira) | impacto | cinema | personagem (o vídeo GRAVADO em 3D — cada parte é UMA fala {falante, texto, emocao?, destaque?}, sem tipo: personagens modelados — gente ou robô — conversando num set de podcast/estúdio/escritório, a boca segue a voz, gestos, e a câmera corta entre aberto, ombro e close; use quando o pedido fala em personagem, boneco, avatar, podcast, entrevista, ou alguém FALANDO em 3D. Com dois falantes (falante em cada parte) eles conversam; personagens:{"nome":{tipo:robo|pessoa, pele, cabelo:curto|cacheado|longo|topete|coque|careca, corCabelo, roupa, oculos, barba, rotulo}} — nome de IA vira robô sozinho; emocao feliz|serio|surpreso|pensativo|animado|curioso e destaque:"texto curto" (o cartão na tela) em cada parte; cenario podcast|estudio|escritorio|noite). '
98
98
  + 'marca e lancamento aceitam visual:orbe|vetor|esfera|grade|site|padrao|cromo|cartoes|painel|cores numa parte. marca.nome dá o nome. '
99
99
  + 'voz (thalita|antonio|francisca). DUAS VOZES (o pedido fala em dois narradores, diálogo, conversa, entrevista): roteiro.vozes {"nome":"antonio","outro":"thalita"} e falante:"nome" em cada parte — cada um sai na sua voz. Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
@@ -141,7 +141,16 @@ const CONFIANCA_MINIMA = 0.7; // abaixo disso a batida é chute: a faixa entra
141
141
 
142
142
  const TRANSICOES_FORTES = new Set(['soco', 'flash', 'chicote', 'zoom', 'giro']);
143
143
 
144
- /** Os cortes do plano no tempo do vídeo: [{t, peso}]. Corte de impacto pesa 3; o maior impacto vira `impacto`. */
144
+ /**
145
+ * Os cortes do plano no tempo do vídeo: [{t, peso}]. Corte de impacto pesa 3; o maior impacto vira `impacto`.
146
+ *
147
+ * O GERATIVO É UMA CENA SÓ: as batidas dele moram dentro da partitura do kit
148
+ * (cena.elementos[].partitura.batidas). Contando só as cenas, o vídeo dele
149
+ * tinha 0 cortes, e o log dizia "0 de 0 cortes caem na batida" (10/10). Cada
150
+ * batida (menos a primeira) é um corte; a troca de campo (fundo de cor) e a
151
+ * tese gigante pesam 3. O corte leva a referência da batida para ser
152
+ * ENCOSTADO na batida da música depois (melhorInicio).
153
+ */
145
154
  function cortesDoPlano(plano) {
146
155
  const cortes = [];
147
156
  let t = 0;
@@ -150,35 +159,111 @@ function cortesDoPlano(plano) {
150
159
  const tr = cena.transicao && (cena.transicao.tipo || cena.transicao);
151
160
  cortes.push({ t: r3(t), peso: cena.impacto || TRANSICOES_FORTES.has(tr) ? 3 : 1 });
152
161
  }
162
+ for (const el of cena.elementos || []) {
163
+ const bs = el && el.partitura && Array.isArray(el.partitura.batidas) ? el.partitura.batidas : null;
164
+ if (!bs) continue;
165
+ bs.forEach((b, j) => {
166
+ if (j === 0) return;
167
+ const forte = b.troca || b.comp === 'gigante' || b.campoCor;
168
+ // A palavra que a batida mostra: a entrada da primeira linha (sem linha, a batida mesmo).
169
+ const ts = (b.linhas || []).map((l) => l.t).filter((x) => typeof x === 'number');
170
+ const palavra = ts.length ? Math.min(...ts) : b.de;
171
+ cortes.push({ t: r3(t + b.de), peso: forte ? 3 : 1, ref: { bs, j, base: t, palavra } });
172
+ });
173
+ }
153
174
  t += Number(cena.duracao) || 0;
154
175
  }
155
- return cortes;
176
+ return cortes.sort((a, b) => a.t - b.t);
177
+ }
178
+
179
+ /** A batida mais perto de `t` numa lista ordenada: {bt, d}. */
180
+ function maisPerto(lista, t) {
181
+ let lo = 0, hi = lista.length - 1;
182
+ while (lo < hi) { const m = (lo + hi) >> 1; if (lista[m] < t) lo = m + 1; else hi = m; }
183
+ const a = lista[lo], b = lo > 0 ? lista[lo - 1] : Infinity;
184
+ return Math.abs(a - t) <= Math.abs(b - t) ? { bt: a, d: Math.abs(a - t) } : { bt: b, d: Math.abs(b - t) };
185
+ }
186
+
187
+ /* Quanto a troca de tela pode se afastar da PALAVRA que ela mostra: a fala
188
+ continua onde estava, e a troca adianta ou atrasa a palavra em no máximo
189
+ 150 ms — abaixo do que o olho percebe como fora de sincronia. A janela é
190
+ medida da palavra, não do corte de antes (que já vinha ~120 ms adiantado). */
191
+ const EMPURRAO = 0.15;
192
+ /** A janela [de, até] em que o corte pode cair, no tempo do vídeo. */
193
+ const janelaDo = (c) => (c.ref ? [c.ref.base + c.ref.palavra - EMPURRAO, c.ref.base + c.ref.palavra + EMPURRAO] : [c.t, c.t]);
194
+ /** A batida da música (no tempo do vídeo) dentro da janela, a mais perto do corte; ou null. */
195
+ function batidaNaJanela(bats, c, o) {
196
+ const [a, z] = janelaDo(c);
197
+ let lo = 0, hi = bats.length;
198
+ while (lo < hi) { const m = (lo + hi) >> 1; if (bats[m] - o < a) lo = m + 1; else hi = m; }
199
+ let melhor = null;
200
+ for (let i = lo; i < bats.length && bats[i] - o <= z; i++) {
201
+ const v = bats[i] - o;
202
+ if (!melhor || Math.abs(v - c.t) < Math.abs(melhor - c.t)) melhor = v;
203
+ }
204
+ return melhor;
156
205
  }
206
+ const MIN_BATIDA = 0.6; // nenhuma batida fica mais curta que isto depois do empurrão
157
207
 
158
208
  /**
159
209
  * De onde a faixa começa (em s) para o máximo de cortes cair numa batida.
160
210
  * Procura em passos de 10 ms, de 0 a `maximo`; empate fica com o início mais
161
211
  * cedo. O corte mais pesado ganha bônus se cair num acento.
212
+ *
213
+ * Os cortes do gerativo (com `ref`) contam como "na batida" até EMPURRAO de
214
+ * distância, porque depois da escolha eles são ENCOSTADOS nela: a batida do
215
+ * vídeo anda (b.de e o ate da anterior), e o golpe da tese também. O plano é
216
+ * mudado no lugar — é ele que vai para o render.
162
217
  */
163
218
  function melhorInicio(cortes, analise, { maximo = 0, tolerancia = 0.05 } = {}) {
164
219
  const bats = (analise && analise.batidas) || [];
165
220
  if (!cortes.length || bats.length < 4 || (analise.confianca != null && analise.confianca < CONFIANCA_MINIMA)) return { inicio: 0, alinhados: 0, total: cortes.length };
166
- const perto = (lista, t) => {
167
- let lo = 0, hi = lista.length - 1;
168
- while (lo < hi) { const m = (lo + hi) >> 1; if (lista[m] < t) lo = m + 1; else hi = m; }
169
- return Math.min(Math.abs(lista[lo] - t), lo > 0 ? Math.abs(lista[lo - 1] - t) : Infinity);
170
- };
221
+ const perto = (lista, t) => maisPerto(lista, t).d;
171
222
  const maior = cortes.reduce((a, b) => (b.peso > a.peso ? b : a), cortes[0]);
172
223
  const acentos = (analise.acentos || []);
173
224
  let melhor = { inicio: 0, pontos: -1 };
174
225
  for (let o = 0; o <= Math.max(0, maximo) + 1e-9; o += 0.01) {
175
226
  let pontos = 0;
176
- for (const c of cortes) pontos += c.peso * Math.exp(-0.5 * Math.pow(perto(bats, c.t + o) / tolerancia, 2));
227
+ for (const c of cortes) {
228
+ if (c.ref) {
229
+ const v = batidaNaJanela(bats, c, o);
230
+ if (v != null) pontos += c.peso * (1 - 0.3 * Math.min(1, Math.abs(v - c.t) / (2 * EMPURRAO)));
231
+ } else pontos += c.peso * Math.exp(-0.5 * Math.pow(perto(bats, c.t + o) / tolerancia, 2));
232
+ }
177
233
  if (maior.peso > 1 && acentos.length && perto(acentos, maior.t + o) < 0.1) pontos += 2;
178
234
  if (pontos > melhor.pontos + 1e-6) melhor = { inicio: r3(o), pontos };
179
235
  }
180
- const alinhados = cortes.filter((c) => perto(bats, c.t + melhor.inicio) <= tolerancia).length;
181
- return { inicio: melhor.inicio, alinhados, total: cortes.length };
236
+ const o = melhor.inicio;
237
+ // O empurrão: cada corte do gerativo vai para a batida mais perto, se couber.
238
+ let empurrados = 0;
239
+ for (const c of cortes) {
240
+ if (!c.ref) continue;
241
+ const { bs, j, base } = c.ref;
242
+ const v = batidaNaJanela(bats, c, o);
243
+ if (v == null || Math.abs(v - c.t) <= 1e-3) continue;
244
+ const delta = v - c.t;
245
+ const b = bs[j], a = bs[j - 1];
246
+ const novo = b.de + delta;
247
+ if (novo - a.de < MIN_BATIDA || b.ate - novo < MIN_BATIDA) continue;
248
+ const colado = Math.abs(a.ate - b.de) < 0.02;
249
+ b.de = r3(novo);
250
+ if (colado) a.ate = b.de;
251
+ c.t = r3(base + b.de);
252
+ empurrados++;
253
+ }
254
+ // O golpe (a tese que bate) também cai na batida, sem sair da batida dele.
255
+ const vistos = new Set();
256
+ for (const c of cortes) {
257
+ if (!c.ref || vistos.has(c.ref.bs)) continue;
258
+ vistos.add(c.ref.bs);
259
+ for (const b of c.ref.bs) for (const g of b.golpes || []) {
260
+ const { bt, d } = maisPerto(bats, c.ref.base + g.t + o);
261
+ const alvo = bt - o - c.ref.base;
262
+ if (d <= EMPURRAO && alvo > b.de + 0.05 && alvo < b.ate - 0.1) g.t = r3(alvo);
263
+ }
264
+ }
265
+ const alinhados = cortes.filter((c) => perto(bats, c.t + o) <= tolerancia).length;
266
+ return { inicio: o, alinhados, total: cortes.length, empurrados };
182
267
  }
183
268
 
184
269
  module.exports = { analisarPCM, analisar, cortesDoPlano, melhorInicio, TAXA, CONFIANCA_MINIMA };
@@ -265,7 +265,7 @@ function escolherVisual(roteiro = {}, { aleatorio = Math.random } = {}) {
265
265
  roteiro É do assunto (uma palavra só não basta). UMA forma por vídeo —
266
266
  é identidade, não sorteio por batida — ou 'nenhum', que a memória também gira. */
267
267
  const forca = (ABSTRATO_DO_HUMOR[humor] || 0) * Math.min(1, n[humor] / 3);
268
- const h1 = forca > 0 ? variedade.sortear([...HEROIS_OBJETO, ...HEROIS_CAMPO, 'nenhum'], 'heroi-gerativo', { aleatorio, adequacao: (h) => (h === 'nenhum' ? 1 - forca : forca * fitH(h)) }) : 'nenhum';
268
+ const h1 = forca > 0 ? variedade.sortear([...HEROIS_3D, ...HEROIS_OBJETO, ...HEROIS_CAMPO, 'nenhum'], 'heroi-gerativo', { aleatorio, adequacao: (h) => (h === 'nenhum' ? 1 - forca : HEROIS_3D.includes(h) ? forca * 1.3 : forca * fitH(h)) }) : 'nenhum';
269
269
  out.herois = { favoritos: h1 === 'nenhum' ? [] : [h1] };
270
270
  // 'produto' e 'marca' quem decide é o conteúdo (o 3D pedido, a marca), não o gosto do projeto.
271
271
  const favC = [];
@@ -274,7 +274,10 @@ function escolherVisual(roteiro = {}, { aleatorio = Math.random } = {}) {
274
274
  /* A MOLDURA (09/10): o quadro fixo do reel de referência — cantos, o assunto
275
275
  no alto, a seção, o tempo embaixo — é identidade, mas em todo vídeo vira
276
276
  vício. Sai do cardápio com memória: 'sem' pesa um pouco mais. */
277
- out.moldura = variedade.sortear(MOLDURAS, 'moldura-gerativo', { aleatorio, adequacao: { sem: 1, cantos: 0.95, completa: 0.95 } });
277
+ /* (10/10) "Essa borda entrega que foi IA que fez" (Joel): os cantos, o
278
+ "01 · seção" e o tempo embaixo são a assinatura de vídeo gerado. Saiu
279
+ do sorteio: só entra quando o roteiro PEDE (moldura: cantos|completa). */
280
+ out.moldura = 'sem';
278
281
  // O bokeh combina com luz de tela (tecnologia, energia, natureza); numa padaria, quase nunca.
279
282
  const bokeh = ['tecnologia', 'energia', 'natureza'].includes(humor) ? 0.6 : 0.2;
280
283
  out.ambiente = variedade.sortear(AMBIENTES, 'ambiente-gerativo', { aleatorio, adequacao: { limpo: 1, luz: 0.85, bokeh } });
@@ -298,6 +301,18 @@ function escolherVisual(roteiro = {}, { aleatorio = Math.random } = {}) {
298
301
  O assunto decide quanto 3D: tecnologia e energia puxam os objetos (esfera,
299
302
  toro, polígono, explosão); editorial e luxo, os campos. */
300
303
  const HEROIS_OBJETO = ['explosao', 'poligono', 'esfera', 'toro'], HEROIS_CAMPO = ['truchet', 'aneis', 'grade', 'rastro'];
304
+ /* O 3D DE VERDADE (10/10, "quero um 3d bem presente", com o reel de
305
+ referência): three.js, perspectiva, luz — grande na tela, não um enfeite.
306
+ globo3d: a esfera de pontos e malha girando em profundidade; fita3d: o nó
307
+ que se torce, com material e luz de recorte; colunas3d: o campo de colunas
308
+ que a câmera sobrevoa, atrás do texto. (kit/tres.jsx, Heroi3D) */
309
+ const HEROIS_3D = ['globo3d', 'fita3d', 'colunas3d'];
310
+ const CAMPOS_3D = ['colunas3d'];
311
+ /* A FAMÍLIA de cada herói: dois heróis da mesma família seguidos são o
312
+ "mesmo desenho" para quem assiste (o anel de pontos com a forma no meio
313
+ saiu em 4 batidas seguidas no vídeo do Joel, 10/10). */
314
+ const FAMILIA_HEROI = { explosao: 'raio', poligono: 'orbita', aneis: 'orbita', esfera: 'malha', toro: 'malha', globo3d: 'malha',
315
+ fita3d: 'fita', colunas3d: 'campo3d', truchet: 'azulejo', grade: 'grade', rastro: 'rastro' };
301
316
  const OBJETO3D_DO_HUMOR = { tecnologia: 1, energia: 1, natureza: 0.8, luxo: 0.6, editorial: 0.4 };
302
317
  /* Quanto o assunto aceita forma abstrata (o resto: a tipografia e o ícone do conteúdo). */
303
318
  const ABSTRATO_DO_HUMOR = { tecnologia: 1, energia: 0.7, natureza: 0.5, luxo: 0, editorial: 0 };
@@ -327,7 +342,7 @@ function escolhasValidas(e) {
327
342
  texto: todas('textos').includes(e.texto) ? e.texto : null,
328
343
  mono: todas('monos').includes(e.mono) ? e.mono : null,
329
344
  genero: todas('generos').includes(e.genero) ? e.genero : null,
330
- herois: (e.herois && Array.isArray(e.herois.favoritos) ? e.herois.favoritos : []).filter((h) => HEROIS_OBJETO.includes(h) || HEROIS_CAMPO.includes(h)),
345
+ herois: (e.herois && Array.isArray(e.herois.favoritos) ? e.herois.favoritos : []).filter((h) => HEROIS_OBJETO.includes(h) || HEROIS_CAMPO.includes(h) || HEROIS_3D.includes(h)),
331
346
  composicoes: (e.composicoes && Array.isArray(e.composicoes.favoritas) ? e.composicoes.favoritas : []).filter((c) => COMPOSICOES.includes(c)),
332
347
  icones: e.icones && ARRANJOS_ICONE.includes(e.icones.arranjo) ? {
333
348
  arranjo: e.icones.arranjo,
@@ -570,6 +585,9 @@ function frasesDe(fala, palavras) {
570
585
 
571
586
  // ── as cores ────────────────────────────────────────────────────────────
572
587
  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; };
588
+ /** A luminância relativa do WCAG (com o gama) e o contraste por ela. */
589
+ const lumWcag = (h) => { const n = parseInt(String(h).slice(1), 16); const c = [n >> 16 & 255, n >> 8 & 255, n & 255].map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; };
590
+ const contrasteWcag = (a, b) => { const la = lumWcag(a) + 0.05, lb = lumWcag(b) + 0.05; return la > lb ? la / lb : lb / la; };
573
591
  const contraste = (a, b) => { const la = luz(a) + 0.05, lb = luz(b) + 0.05; return la > lb ? la / lb : lb / la; };
574
592
  const PALETAS = HUMORES.tecnologia.paletas;
575
593
 
@@ -999,6 +1017,8 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
999
1017
  const comp = nome === 'rajada' ? 'gigante' : nome === 'duelo' ? 'pilha' : nome;
1000
1018
  let q = p;
1001
1019
  if (comp === anterior || evita.includes(comp)) q = 0;
1020
+ // No máximo 2 vezes no vídeo (10/10: "cascata" saiu 3 vezes no vídeo do Joel).
1021
+ if ((usadas[comp] || 0) >= 2) q = 0;
1002
1022
  if (recentes.slice(-3).includes(comp)) q *= 0.35;
1003
1023
  q *= Math.pow(0.72, usadas[comp] || 0);
1004
1024
  if (comp === 'parede' && (usadas.parede || 0) >= 1) q *= 0.25;
@@ -1033,6 +1053,8 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1033
1053
  if (bi === alvo3d && !usadas.produto) nome = 'produto';
1034
1054
  const ctx = { s, W, H, M, deitado, base, larguraUtil, tamRotulo, fontes, P, palavras: br.palavras || c.palavras || [], parte: br.parte, marca, roteiro, memoria };
1035
1055
  if (nome === 'rajada' && br.ate - br.de < 1.6 * c.itens.length) nome = 'cascata';
1056
+ // A cascata que veio da rajada também respeita o teto de 2 (e nunca segue outra cascata).
1057
+ if (nome === 'cascata' && ((usadas.cascata || 0) >= 2 || anterior === 'cascata')) nome = anterior === 'pilha' ? 'palavras' : 'pilha';
1036
1058
  if (nome === 'rajada') {
1037
1059
  // Cada item vira a sua batida; o título da lista fica como rótulo.
1038
1060
  const n = c.itens.length;
@@ -1075,6 +1097,45 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1075
1097
  batidas.forEach((b) => { b.secao = secoes.idx[b.parte] || 0; b.campo = b.secao % 2 === 0 ? comeca : 2 - comeca; });
1076
1098
  for (const b of batidas) { b.fundo = 'campo' + b.campo; b.cores = coresDoCampo(campos, b.campo, s, ident); }
1077
1099
 
1100
+ /* ── OS CAMPOS DE COR CHAPADA (10/10) ────────────────────────────────
1101
+ "O vídeo fica bege e grafite do começo ao fim" — o reel de referência
1102
+ troca o quadro INTEIRO para laranja, azul elétrico, lima. Os campos 3 e
1103
+ 4 tinham saído em 09/10 porque entravam a esmo ("cores nada a ver uma
1104
+ com a outra"); voltam com regra: só DUAS cores fortes — o acento do
1105
+ vídeo e a ousada do mesmo sistema (campos[3]) —, em ~1 de cada 3,5
1106
+ batidas (pelo menos 2 num vídeo de 6+), nunca duas seguidas, nunca a
1107
+ primeira nem a marca. Tinta de contraste alto; o corte é seco (o fundo
1108
+ troca no quadro da batida, que o batida.js encosta na música). */
1109
+ // A segunda cor é a ousada do sistema MAIS LONGE no matiz do acento: duas laranjas lado a lado
1110
+ // (o terracota e o #FF4A1C, render real de 10/10) não fazem o contraste do reel (laranja × azul).
1111
+ const matiz = (h) => { const n = parseInt(String(h).slice(1), 16); const r = (n >> 16 & 255) / 255, g = (n >> 8 & 255) / 255, bb = (n & 255) / 255;
1112
+ const mx = Math.max(r, g, bb), mn = Math.min(r, g, bb), d = mx - mn; if (!d) return 0;
1113
+ const hh = mx === r ? ((g - bb) / d) % 6 : mx === g ? (bb - r) / d + 2 : (r - g) / d + 4; return (hh * 60 + 360) % 360; };
1114
+ const longe = (x) => { const d = Math.abs(matiz(x) - matiz(ident.acento)); return Math.min(d, 360 - d); };
1115
+ const ousada = [campos[3], campos[4]].filter(ehCor).sort((x, y) => longe(y) - longe(x))[0];
1116
+ const fortes = [ident.acento, ousada].filter((x, i, a) => ehCor(x) && a.indexOf(x) === i);
1117
+ const nCor = batidas.length >= 6 ? Math.max(2, Math.round(batidas.length / 3.5)) : batidas.length >= 4 ? 1 : 0;
1118
+ const candCor = batidas.map((b, i) => i).filter((i) => i > 0 && !['marca', 'produto'].includes(batidas[i].comp));
1119
+ const pesoCor = { gigante: 3, palavras: 2.5, contador: 2.5, pilha: 2, parede: 1.5, divisao: 1, cascata: 0.8, citacao: 0.8 };
1120
+ const comCor = [];
1121
+ for (const i of candCor.sort((x, y) => (pesoCor[batidas[y].comp] || 1) - (pesoCor[batidas[x].comp] || 1) || x - y)) {
1122
+ if (comCor.length >= nCor || !fortes.length) break;
1123
+ if (comCor.some((j) => Math.abs(j - i) < 2)) continue;
1124
+ comCor.push(i);
1125
+ }
1126
+ comCor.sort((x, y) => x - y).forEach((i, k) => {
1127
+ const b = batidas[i];
1128
+ const fundo = fortes[k % fortes.length], outra = fortes[(k + 1) % fortes.length];
1129
+ /* A tinta pelo contraste WCAG (luminância relativa, com o gama): a `luz`
1130
+ daqui acha o azul elétrico "médio" e punha texto PRETO nele — o olho
1131
+ lê muito melhor o branco. */
1132
+ const tinta = [...ident.chaos, '#0B0B0B', '#FAFAF7'].sort((x, y) => contrasteWcag(y, fundo) - contrasteWcag(x, fundo))[0];
1133
+ const acento = outra && outra !== fundo && contrasteWcag(outra, fundo) >= 2.6 ? outra : tinta;
1134
+ b.cores = { fundo, texto: tinta, fraco: misturarCor(tinta, fundo, 0.32), acento, contorno: misturarCor(tinta, fundo, 0.5) };
1135
+ b.campoCor = true;
1136
+ b.fundo = 'cor';
1137
+ });
1138
+
1078
1139
  /* ── O HERÓI DE CADA BATIDA VEM DO CONTEÚDO (09/10): "é uma conversa sobre
1079
1140
  pão e ele vai naquela bolinha" (Joel). Quem diz é o roteiro: parte.heroi
1080
1141
  (ou parte.icone) — um ícone do Iconify ("mdi:bread-slice", o desenho
@@ -1084,7 +1145,7 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1084
1145
  e a forma abstrata só no assunto abstrato, a escolhida do projeto, em
1085
1146
  até 1 de cada 3 batidas. Nada serve: a tipografia carrega a batida. ── */
1086
1147
  const pedidoDe = partes.map((p) => { const h = p.heroi != null ? p.heroi : p.icone; return typeof h === 'string' && h.trim() ? h.trim() : null; });
1087
- const ABSTRATOS = [...HEROIS_OBJETO, ...HEROIS_CAMPO];
1148
+ const ABSTRATOS = [...HEROIS_OBJETO, ...HEROIS_CAMPO, ...HEROIS_3D];
1088
1149
  const ICONES3D = new Set(Object.values(ICONE_DE));
1089
1150
  const materialSorteado = s.um(['argila', 'verniz', 'metal']);
1090
1151
  // O arranjo do projeto (escolherVisual); sem ele, o de antes: um lado só, ícones seguidos.
@@ -1135,24 +1196,100 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1135
1196
  ultimoIcone = bi;
1136
1197
  }
1137
1198
 
1138
- // A forma abstrata: a que o modelo pediu, ou a do projeto no assunto que aceita.
1199
+ /* ── A FORMA ABSTRATA, BATIDA A BATIDA (10/10) ───────────────────────
1200
+ Era UMA forma por vídeo, repetida a cada duas batidas: o anel com a
1201
+ forma no meio saiu em 4 batidas seguidas e "as animações são muito
1202
+ iguais" (Joel). Agora cada batida que recebe forma tira a sua de um
1203
+ cardápio, com três regras:
1204
+ 1. nunca a mesma FAMÍLIA (FAMILIA_HEROI) em duas batidas seguidas;
1205
+ 2. cada forma no máximo 2 vezes no vídeo (uma repetição);
1206
+ 3. no assunto que aceita (tecnologia, energia, natureza), pelo menos
1207
+ 1 de cada 3 batidas é 3D DE VERDADE (HEROIS_3D) — grande, com
1208
+ perspectiva e luz, não o enfeite pequeno de antes.
1209
+ A forma pedida pelo roteiro (parte.heroi) continua valendo onde pediu. */
1139
1210
  const OBJETOS = HEROIS_OBJETO;
1140
- const favorito = (ABSTRATO_DO_HUMOR[humor] || 0) > 0 ? E.herois[0] : null;
1141
- const tetoH = Math.ceil(batidas.length / 3);
1142
- let nH = 0, ultimoH = -9;
1211
+ /* O assunto aceita forma abstrata quando o humor é de tecnologia, energia
1212
+ ou natureza E o roteiro É do assunto (3 palavras do humor, como na
1213
+ escolha do projeto) — a padaria que fala de Pix não vira reel de tech. */
1214
+ const acertos = acertosDeHumor(roteiro)[humor] || 0;
1215
+ // E o projeto escolheu ter forma (escolherVisual, E.herois): sem essa escolha, o herói vem só do conteúdo.
1216
+ const aceita = (E.herois || []).length > 0 && (ABSTRATO_DO_HUMOR[humor] || 0) >= 0.5 && acertos >= 3;
1217
+ const nB = batidas.length;
1218
+ const usoH = {}, usoFam = {};
1219
+ let famAnterior = null, ultimoH = -9;
1220
+ // A parede de texto e a marca já cobrem o quadro: forma ali é ruído.
1221
+ const livreParaHeroi = (b) => !b.icone3d && !b.produto3d && !pedidoDe[b.parte] && b.comp !== 'marca' && b.comp !== 'parede';
1222
+ const elegiveis = aceita ? batidas.map((b, i) => (livreParaHeroi(b) ? i : -1)).filter((i) => i >= 0) : [];
1223
+ // Quantas batidas ganham forma: metade do vídeo (as outras ficam com a tipografia e o ícone).
1224
+ const quantas = Math.min(elegiveis.length, Math.ceil(nB / 2));
1225
+ const escolhidas = new Set();
1226
+ for (let k = 0; k < quantas; k++) escolhidas.add(elegiveis[Math.floor((k * elegiveis.length) / quantas)]);
1227
+ const meta3d = aceita ? Math.min(quantas, Math.ceil(nB / 3)) : 0;
1228
+ let n3d = 0, feitas = 0;
1229
+ const favoritos = new Set(E.herois || []);
1230
+ const sortearHeroi = (b, i, so3d) => {
1231
+ const lista = so3d ? HEROIS_3D : [...HEROIS_3D, ...HEROIS_OBJETO, 'truchet', 'aneis', 'rastro'];
1232
+ const w = {};
1233
+ const pode = (h) => w[h] > 0;
1234
+ for (const h of lista.concat([...favoritos].filter((f) => !lista.includes(f) && (!so3d || HEROIS_3D.includes(f))))) {
1235
+ if ((usoH[h] || 0) >= 2) continue;
1236
+ const fam = FAMILIA_HEROI[h];
1237
+ if (fam === famAnterior && i - ultimoH <= 2) continue;
1238
+ // Cada família uma vez no vídeo: a esfera plana depois do globo 3D lia como repetição.
1239
+ if ((usoFam[fam] || 0) >= 1) continue;
1240
+ // As formas planas que pedem o vazio ao lado não cabem na batida sem vazio.
1241
+ if (OBJETOS.includes(h) && ['gigante', 'parede', 'divisao'].includes(b.comp)) continue;
1242
+ // O campo 3D e a parede de texto brigam: as duas cobrem o quadro.
1243
+ if ((CAMPOS_3D.includes(h) || HEROIS_CAMPO.includes(h)) && b.comp === 'parede') continue;
1244
+ // O 3D que mora AO LADO do texto (globo, fita) precisa de um lado livre: a tese gigante,
1245
+ // o contraste e a citação ocupam a largura — ali ele não teria onde ficar sem cruzar a letra.
1246
+ if (HEROIS_3D.includes(h) && !CAMPOS_3D.includes(h) && ['gigante', 'divisao', 'citacao', 'contador'].includes(b.comp)) continue;
1247
+ let p = HEROIS_3D.includes(h) ? 1.6 : 1;
1248
+ if (favoritos.has(h)) p *= 2;
1249
+ p *= Math.pow(0.4, usoH[h] || 0);
1250
+ w[h] = p;
1251
+ }
1252
+ // A forma que o projeto escolheu é a identidade: entra na primeira batida em que cabe.
1253
+ const fav = [...favoritos].find((f) => !usoH[f] && pode(f));
1254
+ if (fav) return fav;
1255
+ // A batida pede 3D e a escolhida é plana: vem a versão 3D da mesma família (o toro vira o globo).
1256
+ if (so3d) {
1257
+ const irma = [...favoritos].map((f) => HEROIS_3D.find((h) => FAMILIA_HEROI[h] === FAMILIA_HEROI[f])).find((h) => h && pode(h));
1258
+ if (irma) return irma;
1259
+ }
1260
+ return s.pesado(w);
1261
+ };
1143
1262
  batidas.forEach((b, i) => {
1144
1263
  let h = b.heroiPedido || null;
1145
- if (!h && favorito && !b.icone3d && !b.produto3d && !pedidoDe[b.parte] && nH < tetoH && i - ultimoH >= 2
1146
- && !(OBJETOS.includes(favorito) && ['gigante', 'parede', 'divisao'].includes(b.comp))) h = favorito;
1147
1264
  delete b.heroiPedido;
1265
+ if (!h && escolhidas.has(i)) {
1266
+ const faltam3d = meta3d - n3d, restam = quantas - feitas;
1267
+ h = sortearHeroi(b, i, faltam3d > 0 && faltam3d >= restam) || sortearHeroi(b, i, false);
1268
+ feitas++;
1269
+ }
1148
1270
  if (!h) return;
1149
- nH++; ultimoH = i;
1271
+ usoH[h] = (usoH[h] || 0) + 1;
1272
+ usoFam[FAMILIA_HEROI[h] || h] = (usoFam[FAMILIA_HEROI[h] || h] || 0) + 1;
1273
+ famAnterior = FAMILIA_HEROI[h] || h; ultimoH = i;
1274
+ const tres = HEROIS_3D.includes(h);
1275
+ if (tres) n3d++;
1150
1276
  const comCor = b.acentos < REGRAS.acentos;
1151
1277
  if (comCor) b.acentos += 1;
1152
- b.heroi = { tipo: h, modo: OBJETOS.includes(h) ? 'objeto' : 'campo', semente: hash(b.id + h) % 9973,
1153
- cor: comCor ? b.cores.acento : misturarCor(b.cores.texto, b.cores.fundo, 0.25), cor2: b.cores.texto };
1278
+ b.heroi = { tipo: h, modo: CAMPOS_3D.includes(h) ? 'campo' : tres ? 'objeto3d' : OBJETOS.includes(h) ? 'objeto' : 'campo',
1279
+ tres, semente: hash(b.id + h) % 9973,
1280
+ cor: comCor ? b.cores.acento : misturarCor(b.cores.texto, b.cores.fundo, 0.25), cor2: b.cores.texto, fundo: b.cores.fundo };
1154
1281
  });
1155
1282
 
1283
+ /* O 3D COMO ASSUNTO (10/10, "uma coisa gigante por tela"): na batida com
1284
+ globo ou fita, o texto vai para a metade esquerda (no 9:16, para o alto)
1285
+ e a outra metade fica inteira para o 3D — que o kit faz do tamanho do
1286
+ vão (até ~60% da altura), sem nunca cruzar a letra. */
1287
+ for (const b of batidas) {
1288
+ if (!b.heroi || b.heroi.modo !== 'objeto3d' || !b.bloco || b.bloco.colunas || b.bloco.blocos) continue;
1289
+ if (deitado) b.bloco = { ...b.bloco, x: M, alinha: 'esq', largura: Math.min(b.bloco.largura, W * 0.46) };
1290
+ else b.bloco = { ...b.bloco, y: H * 0.3, largura: Math.min(b.bloco.largura, larguraUtil) };
1291
+ }
1292
+
1156
1293
  // ── as passagens: como uma batida vira a outra ──────────────────────
1157
1294
  let ultimaSaida = null, desdeCorte = 9;
1158
1295
  const saidasRecentes = [];
@@ -1361,9 +1498,12 @@ function compor(comp, br, ctx) {
1361
1498
  const fonte = s.chance(0.5) ? fontes.cinetica : fontes.display;
1362
1499
  const eixos = { wght: Math.min(VARIAVEIS[fonte].eixos.wght[1], wTese) };
1363
1500
  const op = { fonte, wght: eixos.wght, maiusc };
1364
- const nMax = deitado ? 2 : Math.min(4, txt.split(/\s+/).length);
1501
+ // A frase de 3+ palavras pode ir em 3 linhas no 16:9: cresce na altura em vez de encolher na largura.
1502
+ const nMax = deitado ? Math.min(3, Math.max(2, txt.split(/\s+/).length)) : Math.min(4, txt.split(/\s+/).length);
1365
1503
  const alvo = deitado ? larguraUtil * 0.96 : larguraUtil;
1366
- const fit = caberEmLinhas(txt, alvo, nMax, (deitado ? 0.3 : 0.2) * H, op);
1504
+ /* UMA COISA GIGANTE POR TELA (10/10, comparado ao reel de referência: "o
1505
+ texto pequeno no meio do bege"). A tese gigante enche a largura. */
1506
+ const fit = caberEmLinhas(txt, alvo, nMax, (deitado ? 0.42 : 0.26) * H, op);
1367
1507
  const alinha = deitado && s.chance(0.35) ? 'esq' : 'centro';
1368
1508
  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 };
1369
1509
  if (c.selo) linha(c.selo, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.16, maiusc: true, eixos: { wght: 500 } });
@@ -1384,11 +1524,11 @@ function compor(comp, br, ctx) {
1384
1524
  }
1385
1525
  case 'pilha': case 'produto': {
1386
1526
  const noProduto = comp === 'produto';
1387
- const lg = noProduto ? (deitado ? W * 0.42 : larguraUtil) : (deitado ? W * s.entre(0.62, 0.74) : larguraUtil);
1527
+ const lg = noProduto ? (deitado ? W * 0.42 : larguraUtil) : (deitado ? W * s.entre(0.74, 0.84) : larguraUtil);
1388
1528
  const principal = c.tipo === 'numero' ? c.valor : (c.titulo || c.nome || c.nao || c.sim || c.texto || '');
1389
1529
  const nL = 3;
1390
1530
  const op = { fonte: fontes.display, wght: wTese };
1391
- const fit = caberEmLinhas(principal, lg, nL, (deitado ? 0.15 : 0.085) * H * (noProduto ? 0.85 : 1), op);
1531
+ const fit = caberEmLinhas(principal, lg, nL, (deitado ? 0.23 : 0.115) * H * (noProduto ? 0.85 : 1), op);
1392
1532
  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]);
1393
1533
  b.bloco = { x: M, y, alinha: 'esq', vert: 'meio', largura: lg, gap: 0.16 };
1394
1534
  const rot = c.selo || (c.tipo === 'cta' ? '' : '');
@@ -1426,9 +1566,9 @@ function compor(comp, br, ctx) {
1426
1566
  case 'palavras': {
1427
1567
  const txt = c.texto || c.titulo || '';
1428
1568
  const alinha = s.chance(0.55) ? 'esq' : 'centro';
1429
- const lg = deitado ? W * 0.8 : larguraUtil;
1569
+ const lg = deitado ? W * 0.86 : larguraUtil;
1430
1570
  const op = { fonte: fontes.display, wght: 600 };
1431
- const fit = caberEmLinhas(txt, lg, deitado ? 3 : 4, (deitado ? 0.12 : 0.075) * H, op);
1571
+ const fit = caberEmLinhas(txt, lg, deitado ? 3 : 4, (deitado ? 0.18 : 0.1) * H, op);
1432
1572
  b.bloco = { x: alinha === 'esq' ? M : W / 2, y: H * vert([0.48, 0.52]), alinha, vert: 'meio', largura: lg, gap: 0.1 };
1433
1573
  // Sem destaque, a tese é a palavra mais longa que não é função.
1434
1574
  if (!destaqueTk.length) {
@@ -1463,7 +1603,7 @@ function compor(comp, br, ctx) {
1463
1603
  // Fonte larga (Unbounded) aperta a lista: os itens vão na fonte de texto.
1464
1604
  const fonte = VARIAVEIS[fontes.display].largura > 1.1 ? fontes.texto : fontes.display;
1465
1605
  const op = { fonte, wght: 560 };
1466
- const maxT = (deitado ? 0.09 : 0.042) * H;
1606
+ const maxT = (deitado ? 0.12 : 0.056) * H;
1467
1607
  const nL = itens.length <= 3 ? 2 : 1;
1468
1608
  const tam = Math.min(...itens.map((it) => caberEmLinhas((c.numerada ? '00 ' : '') + it, lg, nL, maxT, op).tamanho));
1469
1609
  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 };
@@ -1483,8 +1623,8 @@ function compor(comp, br, ctx) {
1483
1623
  case 'divisao': {
1484
1624
  const lado = deitado ? (larguraUtil - M * 0.6) / 2 : larguraUtil;
1485
1625
  const op = { fonte: fontes.display, wght: wTese };
1486
- const fN = caberEmLinhas(c.nao, lado, deitado ? 3 : 2, (deitado ? 0.1 : 0.065) * H, { ...op, wght: 400 });
1487
- const fS = caberEmLinhas(c.sim, lado, deitado ? 3 : 3, (deitado ? 0.12 : 0.08) * H, op);
1626
+ const fN = caberEmLinhas(c.nao, lado, deitado ? 3 : 2, (deitado ? 0.13 : 0.08) * H, { ...op, wght: 400 });
1627
+ const fS = caberEmLinhas(c.sim, lado, deitado ? 3 : 3, (deitado ? 0.17 : 0.1) * H, op);
1488
1628
  if (deitado) {
1489
1629
  b.bloco = { x: M, y: H * 0.5, alinha: 'esq', vert: 'meio', largura: lado, gap: 0.1, colunas: [M, M + lado + M * 0.6] };
1490
1630
  } else {
@@ -1502,7 +1642,7 @@ function compor(comp, br, ctx) {
1502
1642
  const fonte = fontes.serifa;
1503
1643
  const lg = deitado ? W * 0.78 : larguraUtil;
1504
1644
  const q = String(c.texto || '').replace(/^["“]+|["”]+$/g, '');
1505
- const fit = caberEmLinhas(q, lg, deitado ? 4 : 5, (deitado ? 0.12 : 0.065) * H, { fonte, wght: 420 });
1645
+ const fit = caberEmLinhas(q, lg, deitado ? 4 : 5, (deitado ? 0.15 : 0.08) * H, { fonte, wght: 420 });
1506
1646
  b.bloco = { x: M, y: H * vert([0.48, 0.52]), alinha: 'esq', vert: 'meio', largura: lg, gap: 0.16 };
1507
1647
  linha('“', 'aspas', { fonte, tamanho: fit.tamanho * 1.6, eixos: { wght: 800 }, cor: 'acento' });
1508
1648
  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; } }); });
@@ -1516,7 +1656,7 @@ function compor(comp, br, ctx) {
1516
1656
  const nome = c.nome || c.titulo || (ctx.marca.nome || '');
1517
1657
  const fonte = fontes.display;
1518
1658
  const lg = deitado ? W * 0.7 : larguraUtil * 0.9;
1519
- const tam = tamanhoQueCabe(nome, lg, (deitado ? 0.22 : 0.13) * H, { fonte, wght: 800 });
1659
+ const tam = tamanhoQueCabe(nome, lg, (deitado ? 0.3 : 0.16) * H, { fonte, wght: 800 });
1520
1660
  b.bloco = { x: W / 2 + tam * 0.3, y: H * 0.5, alinha: 'centro', vert: 'meio', largura: lg, gap: 0.2 };
1521
1661
  linha(nome, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(800, VARIAVEIS[fonte].eixos.wght[1]) }, comportamento: 'contorno' }).marca = true;
1522
1662
  if (c.sub || c.link) linha(c.sub || c.link, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.2, maiusc: true, eixos: { wght: 400 } });
@@ -1527,7 +1667,7 @@ function compor(comp, br, ctx) {
1527
1667
  const txt = c.titulo || c.nome || '';
1528
1668
  const fonte = fontes.cinetica;
1529
1669
  const maiusc = true;
1530
- const tam = tamanhoQueCabe(txt, larguraUtil, (deitado ? 0.2 : 0.1) * H, { fonte, wght: 900, maiusc });
1670
+ const tam = tamanhoQueCabe(txt, larguraUtil, (deitado ? 0.26 : 0.13) * H, { fonte, wght: 900, maiusc });
1531
1671
  b.bloco = { x: W / 2, y: H * 0.5, alinha: 'centro', vert: 'meio', largura: larguraUtil, gap: 0.1 };
1532
1672
  linha(txt, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(900, VARIAVEIS[fonte].eixos.wght[1]) }, maiusc });
1533
1673
  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) };
@@ -1578,8 +1718,12 @@ function compor(comp, br, ctx) {
1578
1718
  componente mede e cresce o grupo principal até a largura do bloco,
1579
1719
  com o teto vertical daqui (o respiro de 30–40% continua garantido). */
1580
1720
  // A palavra-herói de ponta a ponta (o CLAUDE do reel): teto alto, a largura manda.
1581
- 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,
1582
- 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;
1721
+ /* (10/10) "Uma coisa gigante por tela": os tetos subiram — a frase curta
1722
+ chega a ~14–18% da altura em altura de maiúscula, a lista a ~9%. Era o
1723
+ teto daqui (0,12 × 0,72 da altura) que deixava "Entregue a próxima
1724
+ tarefa" pequena no meio do bege, não o tamanho que a composição pedia. */
1725
+ const tetoV = { gigante: deitado ? 0.5 : 0.28, marca: deitado ? 0.34 : 0.18, parede: deitado ? 0.3 : 0.15, contador: deitado ? 0.55 : 0.25,
1726
+ pilha: deitado ? 0.3 : 0.15, produto: deitado ? 0.16 : 0.09, palavras: deitado ? 0.22 : 0.12, cascata: deitado ? 0.14 : 0.07, divisao: deitado ? 0.18 : 0.1, citacao: deitado ? 0.16 : 0.09 }[b.comp] || 0.12;
1583
1727
  // A SANGRIA (a palavra de ponta a ponta) é um momento, não o padrão: até duas por vídeo, só em palavra curta.
1584
1728
  const memS = ctx.memoria || {};
1585
1729
  if (['gigante', 'parede'].includes(b.comp) && (memS.cheios || 0) < 2 && b.linhas.filter((l) => l.papel === 'tese').length <= 2
@@ -1591,7 +1735,7 @@ function compor(comp, br, ctx) {
1591
1735
  if (!enche(l)) continue;
1592
1736
  l.encher = l.papel === 'item' ? 'item' : 'principal';
1593
1737
  // 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.
1594
- 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)));
1738
+ l.max = Math.round(b.cheio ? Math.min(tetoV * H, (H * 0.7) / (nEnche * 1.05)) : Math.min(tetoV * H * 0.9, (H * 0.62) / (nEnche * 1.05)));
1595
1739
  }
1596
1740
  // ── o tempo de cada linha e palavra: quando a voz diz ────────────────
1597
1741
  tempoDaBatida(b, palavras, ctx);
@@ -1709,5 +1853,5 @@ function contarAcentos(b) {
1709
1853
 
1710
1854
  module.exports = {
1711
1855
  montarGerativo, partituraDe, sonsDoVideo, musicaDe, desenharSom, humorDe, escolherVisual, MOLDURAS, AMBIENTES, ACENTOS_JEITO, ARRANJOS_ICONE, RITMOS_ICONE, MATERIAIS_ICONE, corGenerica, BANIDAS, HUMORES, REGRAS, VARIAVEIS, COMPOSICOES, COMPORTAMENTOS, SAIDAS, ACENTOS, FPS,
1712
- especEixos, sorteador, hash, larguraEm, conteudoDe, casar, HEROIS_OBJETO, HEROIS_CAMPO,
1856
+ especEixos, sorteador, hash, larguraEm, conteudoDe, casar, HEROIS_OBJETO, HEROIS_CAMPO, HEROIS_3D, FAMILIA_HEROI,
1713
1857
  };
@@ -896,6 +896,32 @@ function alinharPartes(lista, palavras, total) {
896
896
  });
897
897
  }
898
898
 
899
+ /**
900
+ * Onde o roteiro COMEÇA na fala transcrita, em segundos — 0 se começa logo.
901
+ *
902
+ * Dois jeitos de o Gemini vazar a direção, os dois vistos no vídeo do Joel
903
+ * (10/10): falar "Rápido e contínuo." ANTES do roteiro, e falar NO LUGAR da
904
+ * primeira frase ("Rápido e contínuo. Você pergunta…", sem o "IA é pra tirar
905
+ * dúvida"). Então: acha o primeiro par de palavras seguidas que existe no
906
+ * roteiro (não só o par do começo), e corta o que veio antes — mas só se o que
907
+ * veio antes for, na maior parte, palavra da DIREÇÃO. Fala do roteiro que o
908
+ * transcritor errou não é vazamento e não sai.
909
+ */
910
+ function vazamentoNoComeco(lista, palavras, direcao = '') {
911
+ const norm = (t) => String(t || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, ' ').trim();
912
+ const roteiro = norm(lista.slice(0, 2).map((x) => x.texto).join(' ')).split(' ').filter(Boolean);
913
+ if (roteiro.length < 2 || !palavras.length) return 0;
914
+ const pares = new Set(roteiro.slice(0, -1).map((w, i) => w + ' ' + roteiro[i + 1]));
915
+ const ditas = palavras.slice(0, 14).map((w) => norm(w.texto));
916
+ let i = 0;
917
+ while (i < ditas.length - 1 && !pares.has(ditas[i] + ' ' + ditas[i + 1])) i++;
918
+ if (i === 0 || i >= ditas.length - 1) return 0;
919
+ const daDirecao = new Set(norm(direcao).split(' ').filter(Boolean));
920
+ const antes = ditas.slice(0, i).join(' ').split(' ').filter(Boolean);
921
+ const vazou = !direcao || antes.filter((w) => daDirecao.has(w)).length / antes.length >= 0.6;
922
+ return vazou ? Math.max(0, palavras[i].de - 0.08) : 0;
923
+ }
924
+
899
925
  async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
900
926
  try {
901
927
  const cfg = require('../config').loadConfig();
@@ -931,9 +957,25 @@ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
931
957
  const chars = lista.reduce((n, x) => n + x.texto.length, 0);
932
958
  if (med.segundos < chars / 22) return { ok: false, error: `a voz veio cortada (${med.segundos.toFixed(1)} s para ${chars} caracteres)` };
933
959
  const tr = await api.requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
934
- const palavras = (tr && tr.palavras) || [];
960
+ let palavras = (tr && tr.palavras) || [];
935
961
  if (!palavras.length) return { ok: false, error: 'sem a transcrição da voz para achar as partes' };
936
- return { ok: true, voz: `gemini ${r.voz}`, segundos: med.segundos, partes: alinharPartes(lista, palavras, med.segundos) };
962
+ let segundos = med.segundos;
963
+ /* A DIREÇÃO LIDA EM VOZ ALTA. O servidor manda a direção ("leia RÁPIDO e
964
+ contínuo…") e o texto no mesmo pedido, e o Gemini às vezes fala o
965
+ começo dela: o vídeo do Joel abria com "Rápido e contínuo." antes do
966
+ roteiro (10/10). Se o roteiro só começa depois de algumas palavras,
967
+ o que vem antes é vazamento — e sai do áudio. */
968
+ const corte = vazamentoNoComeco(lista, palavras, direcao);
969
+ if (corte > 0.15) {
970
+ const cortado = mp3.replace(/\.mp3$/, '-c.mp3');
971
+ const k = await video.rodar(video.ffmpeg(), ['-y', '-ss', corte.toFixed(3), '-i', mp3, '-c:a', 'libmp3lame', '-q:a', '3', cortado], { timeout: 120000 });
972
+ if (k.ok && fs.existsSync(cortado)) {
973
+ fs.renameSync(cortado, mp3);
974
+ palavras = palavras.filter((w) => w.de >= corte - 0.02).map((w) => ({ ...w, de: Math.max(0, w.de - corte), ate: Math.max(0, w.ate - corte) }));
975
+ segundos = Math.max(0, segundos - corte);
976
+ }
977
+ }
978
+ return { ok: true, voz: `gemini ${r.voz}`, segundos, partes: alinharPartes(lista, palavras, segundos), ...(corte > 0.15 ? { cortouDirecao: corte } : {}) };
937
979
  } catch (e) {
938
980
  return { ok: false, error: e.message };
939
981
  }
@@ -1011,7 +1053,10 @@ async function prepararDepoimento(p, c) {
1011
1053
 
1012
1054
  /* A narração com cache (a mesma fala, na mesma direção, não é gerada duas vezes). */
1013
1055
  async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
1014
- const chave = require('crypto').createHash('sha1').update(JSON.stringify([lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
1056
+ /* 'v2' (10/10): as narrações guardadas antes do corte da direção lida em
1057
+ voz alta ("Rápido e contínuo.") ficam para trás — reaproveitar uma delas
1058
+ trazia o vazamento de volta mesmo com o conserto. */
1059
+ const chave = require('crypto').createHash('sha1').update(JSON.stringify(['v2', lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
1015
1060
  const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
1016
1061
  const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
1017
1062
  if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
@@ -1497,7 +1542,9 @@ async function prepararRoteiro(p, args) {
1497
1542
  /* O gerativo pede voz com CORPO ("narração encorpada, extravagante",
1498
1543
  30/09): cheia, quente, perto do microfone — e o ritmo segue 1,12×. */
1499
1544
  /* (A primeira versão pedia "trailer" e "ênfase sustentada": a voz saiu 25% mais lenta, medido em 30/09.) */
1500
- ? 'Leia em português do Brasil como locutor de reels de tecnologia: RÁPIDO e contínuo, com voz cheia, quente e encorpada, '
1545
+ /* Sem a ênfase em caixa alta logo no começo ("rápido" gritado): era
1546
+ exatamente o trecho que o Gemini lia em voz alta (10/10). */
1547
+ ? 'Leia em português do Brasil como locutor de reels de tecnologia, num ritmo rápido e sem pausas, com voz cheia, quente e encorpada, '
1501
1548
  + 'bem perto do microfone, muita energia e sorriso na voz. Ataque firme e ênfase curta e forte nas palavras de impacto; '
1502
1549
  + 'quase sem pausa entre as frases. Nunca arraste, nunca leia devagar nem solene.'
1503
1550
  // O fluido é o vídeo de produto: voz limpa e segura, e rápida ("mais rápido", 06/10).
@@ -2042,7 +2089,8 @@ async function ferramentaSite(args) {
2042
2089
  return r;
2043
2090
  }
2044
2091
 
2045
- module.exports = { variarIcones,
2092
+ module.exports = {
2093
+ vazamentoNoComeco, variarIcones,
2046
2094
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
2047
2095
  ferramentaVideo, ferramentaRender, ferramentaSite,
2048
2096
  projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro, roteiroValido, juntarVozes,
@@ -14,7 +14,7 @@
14
14
  import React, { useState, useEffect, useMemo } from 'react';
15
15
  import { delayRender, continueRender } from 'remotion';
16
16
  import { SNAPPY, CURVAS_KIT, acaso, suave, alfa, mistura } from './base';
17
- import { Produto3D, Icone3D } from './tres';
17
+ import { Produto3D, Icone3D, Heroi3D } from './tres';
18
18
 
19
19
  const ed = CURVAS_KIT.editorial, curvaCam = CURVAS_KIT.camera, arrasto = CURVAS_KIT.arrasto, acelera = CURVAS_KIT.acelera;
20
20
  const c01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
@@ -193,7 +193,9 @@ function leiaute(P, med) {
193
193
  if (!l.encher || !med[l.id]) continue;
194
194
  const k = l.encher + (l.coluna != null ? l.coluna : '');
195
195
  // O grid manda: a tese ocupa até ~74% da largura do bloco; só a sangria vai a 97%.
196
- const s = Math.min(l.max || l.tamanho, (bl.largura * (b.cheio ? 0.97 : 0.74)) / med[l.id].w);
196
+ /* (10/10) A tese gigante, a parede e a marca ENCHEM o bloco (97%): com fonte
197
+ larga, os 74% de antes paravam "CLAUDE CODE" em 9% da altura. O resto, 80%. */
198
+ const s = Math.min(l.max || l.tamanho, (bl.largura * (b.cheio || ['gigante', 'parede', 'marca'].includes(b.comp) ? 0.97 : 0.8)) / med[l.id].w);
197
199
  grupo[k] = grupo[k] == null ? s : Math.min(grupo[k], s);
198
200
  }
199
201
  const tam = (l) => {
@@ -950,9 +952,24 @@ export function Gerativo({ el, t, fps }) {
950
952
  e do texto — e calmo: desenhado por cima, brigava com os dois (05/10). */}
951
953
  {noAr.filter((x) => x.heroi && x.heroi.modo === 'campo' && L[x.id]).map((x) => {
952
954
  const bl = L[x.id].bloco, some = 1 - c01((t - x.ate) / 0.14);
955
+ let campo3d = null;
956
+ if (x.heroi.tres) {
957
+ /* As colunas NUNCA encostam no texto: só existem numa faixa abaixo
958
+ (ou acima, de ponta-cabeça) do bloco MEDIDO, com folga. O campo mora
959
+ mais fundo que o texto (translateZ -200), então na tela ele encolhe
960
+ para o centro mais que o texto: a folga medida aqui só cresce lá. */
961
+ const folga = Math.max(x.vao || 0, 0.07 * H);
962
+ const ds = DUR_SAIDA[x.saida] || 0;
963
+ const saiDe = x.ate - ds - 0.15;
964
+ if (H - (bl.y1 + folga) >= 0.34 * H) campo3d = { op: 0.7, faixa: { y0: bl.y1 + folga, y1: H }, saiDe };
965
+ else if (bl.y - folga >= 0.34 * H) campo3d = { op: 0.7, faixa: { y0: 0, y1: bl.y - folga }, teto: true, saiDe };
966
+ if (!campo3d) return null; // sem faixa livre: sem colunas
967
+ }
953
968
  return (
954
969
  <div key={'campo' + x.id} style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, transform: 'translateZ(-200px)', opacity: some }}>
955
- <Heroi b={x} t={t} P={P} slot={{ cx: (bl.x + bl.x1) / 2, cy: (bl.y + bl.y1) / 2, R: Math.min(W, H) * 0.32, op: 0.18 }} />
970
+ {x.heroi.tres
971
+ ? <Heroi3D h={x.heroi} t={t} de={x.de} ate={x.ate} W={W} H={H} slot={campo3d} />
972
+ : <Heroi b={x} t={t} P={P} slot={{ cx: (bl.x + bl.x1) / 2, cy: (bl.y + bl.y1) / 2, R: Math.min(W, H) * 0.32, op: 0.18 }} />}
956
973
  </div>
957
974
  );
958
975
  })}
@@ -998,10 +1015,31 @@ export function Gerativo({ el, t, fps }) {
998
1015
  // O lugar do herói: o vazio do bloco (como o ícone); sem vazio, atrás do texto e mais fraco.
999
1016
  const bl = lay.bloco, M = P.margem;
1000
1017
  if (x.heroi.modo === 'campo') return null; // desenhado atrás, antes do produto
1001
- const R = Math.min(W, H) * (W >= H ? 0.2 : 0.17);
1018
+ const tres = x.heroi.modo === 'objeto3d';
1019
+ // O 3D é GRANDE ("um 3d bem presente"): o raio pede o vazio inteiro.
1020
+ const R = Math.min(W, H) * (tres ? (W >= H ? 0.26 : 0.22) : (W >= H ? 0.2 : 0.17));
1002
1021
  let slot = null;
1003
1022
  // A área segura (margem.js), com o vão do bloco; partitura antiga: a margem do mundo.
1004
1023
  const S = x.seguro || { x0: M, y0: M * 1.4, x1: W - M, y1: H - M * 1.4 }, V = x.vao || 0;
1024
+ if (tres) {
1025
+ /* O 3D NUNCA cruza o texto: mora no maior retângulo livre ao lado do
1026
+ bloco MEDIDO (com o vão e uma folga), dentro da área segura; o raio
1027
+ é o círculo que cabe nele, e o Heroi3D garante que a projeção inteira
1028
+ (anel, tubo, dolly, perspectiva) fica dentro desse raio. Sem
1029
+ retângulo grande o bastante: sem 3D nesta batida. */
1030
+ const folga = Math.max(V, 0.035 * Math.min(W, H));
1031
+ const livres = [
1032
+ { x0: bl.x1 + folga, y0: S.y0, x1: S.x1, y1: S.y1 },
1033
+ { x0: S.x0, y0: S.y0, x1: bl.x - folga, y1: S.y1 },
1034
+ { x0: S.x0, y0: S.y0, x1: S.x1, y1: bl.y - folga },
1035
+ { x0: S.x0, y0: bl.y1 + folga, x1: S.x1, y1: S.y1 },
1036
+ ].map((q) => ({ ...q, r: Math.min(q.x1 - q.x0, q.y1 - q.y0) / 2 })).sort((a, c) => c.r - a.r);
1037
+ const q = livres[0];
1038
+ if (!q || q.r < Math.min(W, H) * 0.12) return null;
1039
+ const ds = DUR_SAIDA[x.saida] || 0;
1040
+ slot = { cx: (q.x0 + q.x1) / 2, cy: (q.y0 + q.y1) / 2, R: Math.min(q.r, Math.min(W, H) * 0.3), saiDe: x.ate - ds - 0.12 };
1041
+ return <Heroi3D h={x.heroi} t={t} de={x.de} ate={x.ate} W={W} H={H} slot={slot} />;
1042
+ }
1005
1043
  if (S.x1 - bl.x1 - V >= R * 2.3) slot = { cx: bl.x1 + V + (S.x1 - bl.x1 - V) / 2, cy: (bl.y + bl.y1) / 2, R };
1006
1044
  else if (bl.y - V - S.y0 >= R * 2.2) slot = { cx: W / 2, cy: S.y0 + (bl.y - V - S.y0) / 2, R };
1007
1045
  else if (S.y1 - bl.y1 - V >= R * 2.2) slot = { cx: W / 2, cy: bl.y1 + V + (S.y1 - bl.y1 - V) / 2, R };
@@ -375,4 +375,160 @@ export function Icone3D({ el, t }) {
375
375
  );
376
376
  }
377
377
 
378
+
379
+ /* ── OS HERÓIS EM 3D DE VERDADE (10/10) ───────────────────────────────────
380
+ * "As animações são muito iguais, quero um 3d bem presente" (Joel, com um
381
+ * reel de motion de referência). O herói abstrato era SVG — pontos girando
382
+ * num plano. Estes três são three.js: perspectiva, profundidade, luz e
383
+ * câmera que anda. Grandes: o raio vem do vazio do bloco (gerativo.jsx).
384
+ *
385
+ * globo3d a esfera de pontos e malha, girando inclinada, com um anel
386
+ * de órbita; os pontos de perto maiores (perspectiva real);
387
+ * fita3d o nó que se torce, em material com verniz e a luz de
388
+ * recorte na cor de acento;
389
+ * colunas3d o campo de colunas que sobe em onda, sobrevoado pela câmera
390
+ * e sumindo na névoa da cor do fundo — fica ATRÁS do texto.
391
+ *
392
+ * Canvas do quadro inteiro, transparente: o objeto é posto no lugar do
393
+ * vazio convertendo pixel → mundo (câmera fixa, fov 30, a 12 unidades).
394
+ */
395
+ const FOV3D = 30, DIST3D = 12;
396
+ const VIS3D = 2 * DIST3D * Math.tan((FOV3D / 2) * Math.PI / 180);
397
+ const mola3d = (x) => { const k = Math.max(0, Math.min(1, x)); return 1 - Math.pow(1 - k, 3) * Math.cos(k * 4.2); };
398
+
399
+ function CameraFixa({ pos, alvo, teto }) {
400
+ const { camera } = useThree();
401
+ // De ponta-cabeça: o campo pende do alto (o texto mora embaixo).
402
+ camera.up.set(0, teto ? -1 : 1, 0);
403
+ camera.position.set(pos[0], pos[1], pos[2]);
404
+ camera.near = 0.1; camera.far = 80;
405
+ camera.lookAt(alvo[0], alvo[1], alvo[2]);
406
+ camera.updateProjectionMatrix();
407
+ return null;
408
+ }
409
+
410
+ function Globo({ r, cor, cor2, sem, tl }) {
411
+ const geo = useMemo(() => {
412
+ const N = 1100, pos = new Float32Array(N * 3), col = new Float32Array(N * 3);
413
+ const c1 = new THREE.Color(cor2), c2 = new THREE.Color(cor);
414
+ const ouro = Math.PI * (3 - Math.sqrt(5));
415
+ for (let i = 0; i < N; i++) {
416
+ const y = 1 - (i / (N - 1)) * 2, rr = Math.sqrt(1 - y * y), a = i * ouro;
417
+ pos.set([Math.cos(a) * rr * r, y * r, Math.sin(a) * rr * r], i * 3);
418
+ const c = (i * 7 + sem) % 6 === 0 ? c2 : c1;
419
+ col.set([c.r, c.g, c.b], i * 3);
420
+ }
421
+ const g = new THREE.BufferGeometry();
422
+ g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
423
+ g.setAttribute('color', new THREE.BufferAttribute(col, 3));
424
+ return g;
425
+ }, [r, cor, cor2, sem]);
426
+ return (
427
+ <group rotation={[0.38 + Math.sin(tl * 0.5) * 0.08, tl * 0.55 + sem * 0.01, 0.12]}>
428
+ <points geometry={geo}><pointsMaterial size={r * 0.06} vertexColors sizeAttenuation /></points>
429
+ <mesh><sphereGeometry args={[r * 0.99, 32, 20]} /><meshBasicMaterial color={cor2} wireframe transparent opacity={0.22} /></mesh>
430
+ <mesh rotation={[1.2, 0.3, tl * 0.4]}><torusGeometry args={[r * 1.14, r * 0.008, 8, 160]} /><meshBasicMaterial color={cor} transparent opacity={0.85} /></mesh>
431
+ <mesh position={[Math.cos(tl * 1.3) * r * 1.14, 0, Math.sin(tl * 1.3) * r * 1.14]} rotation={[1.2, 0.3, 0]}>
432
+ <sphereGeometry args={[r * 0.05, 16, 12]} /><meshBasicMaterial color={cor} />
433
+ </mesh>
434
+ </group>
435
+ );
436
+ }
437
+
438
+ function Fita({ r, cor, cor2, sem, tl }) {
439
+ return (
440
+ <group rotation={[0.5 + tl * 0.35, tl * 0.6 + sem * 0.02, 0.2]}>
441
+ <mesh>
442
+ <torusKnotGeometry args={[r * 0.52, r * 0.13, 280, 32, 2 + (sem % 2), 3]} />
443
+ <meshPhysicalMaterial color={cor2} roughness={0.28} metalness={0.35} clearcoat={0.9} clearcoatRoughness={0.2} />
444
+ </mesh>
445
+ <mesh scale={[1.02, 1.02, 1.02]}>
446
+ <torusKnotGeometry args={[r * 0.52, r * 0.13, 140, 12, 2 + (sem % 2), 3]} />
447
+ <meshBasicMaterial color={cor} wireframe transparent opacity={0.22} />
448
+ </mesh>
449
+ </group>
450
+ );
451
+ }
452
+
453
+ function Colunas({ cor, cor2, fundo, sem, tl }) {
454
+ const n = 15, passo = 0.9;
455
+ const base = new THREE.Color(fundo).lerp(new THREE.Color(cor2), 0.22).getStyle();
456
+ const acesa = new THREE.Color(cor).getStyle();
457
+ const els = [];
458
+ for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) {
459
+ const x = (i - (n - 1) / 2) * passo, z = (j - (n - 1) / 2) * passo - tl * 1.1;
460
+ const d = Math.hypot(i - n / 2, j - n / 2);
461
+ const onda = 0.5 + 0.5 * Math.sin(d * 0.85 - tl * 2.6 + (sem % 7));
462
+ const h = 0.25 + 2.2 * onda * onda;
463
+ const crista = onda > 0.93;
464
+ els.push(
465
+ <mesh key={i + '-' + j} position={[x, h / 2 - 1.6, z]} scale={[passo * 0.78, h, passo * 0.78]}>
466
+ <boxGeometry args={[1, 1, 1]} />
467
+ <meshStandardMaterial color={crista ? acesa : base} roughness={0.55} metalness={0.15}
468
+ {...(crista ? { emissive: acesa, emissiveIntensity: 0.6 } : {})} />
469
+ </mesh>,
470
+ );
471
+ }
472
+ return <group>{els}</group>;
473
+ }
474
+
475
+ /* O quanto cada forma passa do raio r, no pior quadro: o anel e a lua do
476
+ globo (1,14 r + a lua), o nó (o tubo vai a 1,5 × 0,52 r + 0,13 r). */
477
+ const ALCANCE3D = { globo3d: 1.2, fita3d: 0.92 };
478
+
479
+ export function Heroi3D({ h, t, de, ate, W, H, slot }) {
480
+ const tl = Math.max(0, t - de), dur = Math.max(0.8, ate - de);
481
+ const k = Math.min(1.02, mola3d(tl / 0.8));
482
+ // Some ANTES de o texto sair (a saída dele voa pela tela): o herói nunca cruza a letra.
483
+ const saiDe = slot.saiDe != null ? slot.saiDe : ate - 0.25;
484
+ const sai = Math.max(0, Math.min(1, (t - saiDe) / 0.2));
485
+ const sem = h.semente || 0;
486
+ const cor = h.cor || '#ff5a1f', cor2 = h.cor2 || '#eeeeee', fundo = h.fundo || '#111111';
487
+ if (h.tipo === 'colunas3d') {
488
+ // O sobrevoo: a câmera alta, olhando para a frente e para baixo, andando sobre o campo.
489
+ const avanca = tl * 0.25;
490
+ return (
491
+ <div style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: (slot.op != null ? slot.op : 0.85) * Math.min(1, tl / 0.5) * (1 - sai),
492
+ /* A FAIXA: as colunas só existem fora do texto (clip), com a borda esfumada. */
493
+ ...(slot.faixa ? { clipPath: `inset(${slot.faixa.y0}px 0 ${H - slot.faixa.y1}px 0)`,
494
+ WebkitMaskImage: slot.teto ? `linear-gradient(to top, transparent ${H - slot.faixa.y1}px, #000 ${H - slot.faixa.y1 + 0.06 * H}px)` : `linear-gradient(to bottom, transparent ${slot.faixa.y0}px, #000 ${slot.faixa.y0 + 0.06 * H}px)` } : {}) }}>
495
+ <ThreeCanvas width={W} height={H} camera={{ position: [0, 4, 9], fov: 38 }} gl={{ antialias: true, preserveDrawingBuffer: true, alpha: true }}>
496
+ {/* Câmera baixa olhando quase reto: o campo ocupa a metade de baixo e some na névoa — o meio fica para o texto. */}
497
+ <CameraFixa teto={slot.teto} pos={[Math.sin(tl * 0.3) * 1.2, 2.3, 8 - avanca]} alvo={[0, 0.9, -4 - avanca]} />
498
+ <fog attach="fog" args={[fundo, 6, 19]} />
499
+ <ambientLight intensity={0.45} />
500
+ <directionalLight position={[4, 8, 5]} intensity={2.2} />
501
+ <directionalLight position={[-6, 2, -4]} intensity={2.6} color={cor} />
502
+ <Colunas cor={cor} cor2={cor2} fundo={fundo} sem={sem} tl={tl} />
503
+ </ThreeCanvas>
504
+ </div>
505
+ );
506
+ }
507
+ const { cx, cy, R } = slot;
508
+ const wx = ((cx - W / 2) / H) * VIS3D, wy = (-(cy - H / 2) / H) * VIS3D;
509
+ /* R é o TETO do que aparece na tela (o vão livre ao lado do texto). O raio
510
+ no mundo sai dele de trás para a frente: o alcance da forma, a escala
511
+ máxima e a perspectiva do ponto mais perto da lente no fim do dolly. */
512
+ const F = ALCANCE3D[h.tipo] || 1.2, zMax = 0.45;
513
+ let r = (R / H) * VIS3D / (F * 1.02);
514
+ for (let i = 0; i < 3; i++) r = ((R / H) * VIS3D / (F * 1.02)) * ((DIST3D - zMax - F * r) / DIST3D);
515
+ // O dolly: o objeto chega do fundo e vem devagar para a lente durante a batida.
516
+ const z = -3.5 * (1 - Math.min(1, k)) + zMax * Math.min(1, tl / dur);
517
+ const esc = 0.55 + 0.45 * k;
518
+ return (
519
+ <div style={{ position: 'absolute', left: 0, top: 0, width: W, height: H, opacity: (slot.op != null ? slot.op : 1) * Math.min(1, tl / 0.3) * (1 - sai) }}>
520
+ <ThreeCanvas width={W} height={H} camera={{ position: [0, 0, DIST3D], fov: FOV3D }} gl={{ antialias: true, preserveDrawingBuffer: true, alpha: true }}>
521
+ <Ambiente forca={0.9} />
522
+ <ambientLight intensity={0.3} />
523
+ <directionalLight position={[3, 4, 6]} intensity={2.6} />
524
+ <directionalLight position={[-5, -1, -3]} intensity={3.2} color={cor} />
525
+ <pointLight position={[0, 0, 5]} intensity={0.6} />
526
+ <group position={[wx, wy, z]} scale={[esc, esc, esc]}>
527
+ {h.tipo === 'fita3d' ? <Fita r={r} cor={cor} cor2={cor2} sem={sem} tl={tl} /> : <Globo r={r} cor={cor} cor2={cor2} sem={sem} tl={tl} />}
528
+ </group>
529
+ </ThreeCanvas>
530
+ </div>
531
+ );
532
+ }
533
+
378
534
  export const TRES = { produto3d: Produto3D, icone3d: Icone3D };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.4.4",
3
+ "version": "10.4.7",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/gerativo-identidade.test.js && node test/gerativo-moldura.test.js && node test/gerativo-heroi.test.js && node test/gerativo-ambiente.test.js && node test/hibrido.test.js && node test/batida.test.js && node test/estudio-acervo.test.js && node test/estudio-diretor.test.js && node test/estudio-marca.test.js && node test/estudio-novo-projeto.test.js && node test/estudio-plano.test.js && node test/estudio-slides.test.js && node test/estudio-video.test.js && node test/revisor.test.js && node test/assinatura.test.js && node test/biblioteca.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/orquestra.test.js && node test/shell-limpo.test.js && node test/editar-remotion.test.js && node test/variacao3d.test.js && node test/portao-skill.test.js && node test/clones.test.js && node test/kokoro.test.js && node test/fluido.test.js && node test/zoom.test.js && node test/personagem.test.js && node test/tela.test.js && node test/margem.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/gerativo-identidade.test.js && node test/gerativo-moldura.test.js && node test/gerativo-heroi.test.js && node test/gerativo-ambiente.test.js && node test/gerativo-variedade.test.js && node test/hibrido.test.js && node test/batida.test.js && node test/estudio-acervo.test.js && node test/estudio-diretor.test.js && node test/estudio-marca.test.js && node test/estudio-novo-projeto.test.js && node test/estudio-plano.test.js && node test/estudio-slides.test.js && node test/estudio-video.test.js && node test/revisor.test.js && node test/assinatura.test.js && node test/biblioteca.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/orquestra.test.js && node test/shell-limpo.test.js && node test/editar-remotion.test.js && node test/variacao3d.test.js && node test/portao-skill.test.js && node test/clones.test.js && node test/kokoro.test.js && node test/fluido.test.js && node test/zoom.test.js && node test/personagem.test.js && node test/tela.test.js && node test/margem.test.js && node test/vazamento.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"