primocode 10.4.4 → 10.5.0

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.
@@ -66,6 +66,7 @@ const kokoro = require('./kokoro.js');
66
66
  const lancamento = require('./lancamento.js');
67
67
  const glifos = require('./glifos.js');
68
68
  const personagem = require('./personagem.js');
69
+ const colecao = require('./colecao.js');
69
70
  const { escolherEstilo, LEVES } = require('./estilo.js');
70
71
  const sonsLeves = require('./sons-leves.js');
71
72
  const ende = require('./ende.js');
@@ -518,6 +519,23 @@ async function ferramentaMidia(args) {
518
519
  return r;
519
520
  }
520
521
 
522
+ /* A BIBLIOTECA (colecao.js, 11/10): o cardápio inteiro, o ícone 3D e a
523
+ animação pronta. Procurar não usa rede (o índice mora no pacote); com
524
+ id, baixa para o projeto e devolve o elemento pronto. */
525
+ if (acao === 'biblioteca' || acao === 'cardapio' || acao === 'cardápio') return colecao.cardapio();
526
+ if (acao === 'icones3d' || acao === 'icone3d' || acao === 'animacoes' || acao === 'animacao' || acao === 'animação' || acao === 'animações') {
527
+ const ehAnim = /^anima/.test(acao);
528
+ if (args.id_item || /^(fluent3d|noto):/.test(String(args.item || args.baixar || ''))) {
529
+ const pj = p ? projeto.ler(p.id) : null;
530
+ const fmt = (pj && pj.formato) || {};
531
+ const r = await colecao.baixar(args.id_item || args.item || args.baixar, { pasta: p ? p.pasta : null, largura: fmt.largura || 1920, altura: fmt.altura || 1080 });
532
+ if (r.ok && p) projeto.anotar(p.id, 'midia', `biblioteca: ${r.src} (${r.licenca})`);
533
+ return r;
534
+ }
535
+ return ehAnim ? colecao.procurarAnimacoes(args.termo || args.busca || '', { quantas: Number(args.quantas) || 10 })
536
+ : colecao.procurarIcones3d(args.termo || args.busca || '', { quantas: Number(args.quantas) || 10 });
537
+ }
538
+
521
539
  if (acao === 'icones' || acao === 'icone') {
522
540
  if (args.nome) {
523
541
  const r = await assets.icone(args.nome, { pasta: p ? p.pasta : null });
@@ -896,6 +914,32 @@ function alinharPartes(lista, palavras, total) {
896
914
  });
897
915
  }
898
916
 
917
+ /**
918
+ * Onde o roteiro COMEÇA na fala transcrita, em segundos — 0 se começa logo.
919
+ *
920
+ * Dois jeitos de o Gemini vazar a direção, os dois vistos no vídeo do Joel
921
+ * (10/10): falar "Rápido e contínuo." ANTES do roteiro, e falar NO LUGAR da
922
+ * primeira frase ("Rápido e contínuo. Você pergunta…", sem o "IA é pra tirar
923
+ * dúvida"). Então: acha o primeiro par de palavras seguidas que existe no
924
+ * roteiro (não só o par do começo), e corta o que veio antes — mas só se o que
925
+ * veio antes for, na maior parte, palavra da DIREÇÃO. Fala do roteiro que o
926
+ * transcritor errou não é vazamento e não sai.
927
+ */
928
+ function vazamentoNoComeco(lista, palavras, direcao = '') {
929
+ const norm = (t) => String(t || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, ' ').trim();
930
+ const roteiro = norm(lista.slice(0, 2).map((x) => x.texto).join(' ')).split(' ').filter(Boolean);
931
+ if (roteiro.length < 2 || !palavras.length) return 0;
932
+ const pares = new Set(roteiro.slice(0, -1).map((w, i) => w + ' ' + roteiro[i + 1]));
933
+ const ditas = palavras.slice(0, 14).map((w) => norm(w.texto));
934
+ let i = 0;
935
+ while (i < ditas.length - 1 && !pares.has(ditas[i] + ' ' + ditas[i + 1])) i++;
936
+ if (i === 0 || i >= ditas.length - 1) return 0;
937
+ const daDirecao = new Set(norm(direcao).split(' ').filter(Boolean));
938
+ const antes = ditas.slice(0, i).join(' ').split(' ').filter(Boolean);
939
+ const vazou = !direcao || antes.filter((w) => daDirecao.has(w)).length / antes.length >= 0.6;
940
+ return vazou ? Math.max(0, palavras[i].de - 0.08) : 0;
941
+ }
942
+
899
943
  async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
900
944
  try {
901
945
  const cfg = require('../config').loadConfig();
@@ -931,9 +975,25 @@ async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
931
975
  const chars = lista.reduce((n, x) => n + x.texto.length, 0);
932
976
  if (med.segundos < chars / 22) return { ok: false, error: `a voz veio cortada (${med.segundos.toFixed(1)} s para ${chars} caracteres)` };
933
977
  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) || [];
978
+ let palavras = (tr && tr.palavras) || [];
935
979
  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) };
980
+ let segundos = med.segundos;
981
+ /* A DIREÇÃO LIDA EM VOZ ALTA. O servidor manda a direção ("leia RÁPIDO e
982
+ contínuo…") e o texto no mesmo pedido, e o Gemini às vezes fala o
983
+ começo dela: o vídeo do Joel abria com "Rápido e contínuo." antes do
984
+ roteiro (10/10). Se o roteiro só começa depois de algumas palavras,
985
+ o que vem antes é vazamento — e sai do áudio. */
986
+ const corte = vazamentoNoComeco(lista, palavras, direcao);
987
+ if (corte > 0.15) {
988
+ const cortado = mp3.replace(/\.mp3$/, '-c.mp3');
989
+ const k = await video.rodar(video.ffmpeg(), ['-y', '-ss', corte.toFixed(3), '-i', mp3, '-c:a', 'libmp3lame', '-q:a', '3', cortado], { timeout: 120000 });
990
+ if (k.ok && fs.existsSync(cortado)) {
991
+ fs.renameSync(cortado, mp3);
992
+ 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) }));
993
+ segundos = Math.max(0, segundos - corte);
994
+ }
995
+ }
996
+ return { ok: true, voz: `gemini ${r.voz}`, segundos, partes: alinharPartes(lista, palavras, segundos), ...(corte > 0.15 ? { cortouDirecao: corte } : {}) };
937
997
  } catch (e) {
938
998
  return { ok: false, error: e.message };
939
999
  }
@@ -1009,9 +1069,24 @@ async function prepararDepoimento(p, c) {
1009
1069
  origem: { src: c.src, de: c.de, ate: c.ate, frase: c.frase }, ...(c.fraseDita ? { frase: c.fraseDita } : {}) };
1010
1070
  }
1011
1071
 
1072
+ /* O tom de cada estilo, em UMA frase de instrução, em inglês e terminando em
1073
+ dois-pontos, que é a forma que o Gemini TTS entende como instrução e não
1074
+ como fala. Se ainda vazar, o corte de vazamentoNoComeco tira (as palavras
1075
+ ditas antes do roteiro são quase todas daqui). */
1076
+ const DIRECAO_DA_VOZ = {
1077
+ gerativo: 'Read aloud in Brazilian Portuguese as a tech reels narrator: full, warm voice close to the mic, energetic and smiling, no long pauses:',
1078
+ fluido: 'Read aloud in Brazilian Portuguese as a premium product video narrator: confident and close, clean sentences that flow into each other, no ad tone:',
1079
+ personagem: 'Read aloud in Brazilian Portuguese as someone really talking on a podcast: natural and close, never like an announcer:',
1080
+ impacto: 'Read aloud in Brazilian Portuguese as an energetic ad announcer: full voice, strong emphasis on the impact words:',
1081
+ padrao: 'Read aloud in Brazilian Portuguese as a lively tech reels narrator: confident, smiling, emphasis on the words that matter:',
1082
+ };
1083
+
1012
1084
  /* A narração com cache (a mesma fala, na mesma direção, não é gerada duas vezes). */
1013
1085
  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);
1086
+ /* 'v3' (11/10): as narrações guardadas com a direção antiga (a que tinha
1087
+ "RÁPIDO" e "contínuas") ficam para trás — reaproveitar uma delas trazia o
1088
+ "Rápido e contínuo." de volta mesmo com a direção nova. */
1089
+ const chave = require('crypto').createHash('sha1').update(JSON.stringify(['v3', lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
1015
1090
  const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
1016
1091
  const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
1017
1092
  if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
@@ -1493,24 +1568,14 @@ async function prepararRoteiro(p, args) {
1493
1568
  const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
1494
1569
  // A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
1495
1570
  // Com cache: refazer o vídeo (outro estilo, outra proporção) não gasta a cota da voz.
1496
- const direcao = estilo === 'gerativo'
1497
- /* O gerativo pede voz com CORPO ("narração encorpada, extravagante",
1498
- 30/09): cheia, quente, perto do microfone — e o ritmo segue 1,12×. */
1499
- /* (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, '
1501
- + 'bem perto do microfone, muita energia e sorriso na voz. Ataque firme e ênfase curta e forte nas palavras de impacto; '
1502
- + 'quase sem pausa entre as frases. Nunca arraste, nunca leia devagar nem solene.'
1503
- // O fluido é o vídeo de produto: voz limpa e segura, e rápida ("mais rápido", 06/10).
1504
- : estilo === 'fluido' ? 'Leia em português do Brasil como narrador de vídeo de produto premium: seguro, próximo e RÁPIDO, '
1505
- + 'frases limpas e contínuas, quase sem pausa entre elas. Nunca arraste, nunca leia devagar; sem tom de anúncio.'
1506
- // O personagem CONVERSA: voz de gente falando com gente, não de locutor.
1507
- : estilo === 'personagem' ? 'Fale em português do Brasil como alguém conversando de verdade num podcast: natural, próximo, '
1508
- + 'com as pausas e a entonação de quem está pensando junto. Sem voz de locutor, sem leitura.'
1509
- : estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
1510
- + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
1511
- // "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
1512
- : 'Leia em português do Brasil como narrador de reels de tecnologia: vivo, confiante e rápido, sorrindo na voz, '
1513
- + 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.';
1571
+ /* A DIREÇÃO DA VOZ. O servidor manda a direção e o texto no MESMO pedido,
1572
+ e o Gemini às vezes lê o começo dela em voz alta: todo vídeo do fluido
1573
+ abria com "Rápido e contínuo." (11/10, "em tudo ela coloca isso, pode
1574
+ ser um negócio de padaria"). Então a direção é curta, descreve só o
1575
+ TOM, e não tem palavra solta que soe como a primeira frase de um
1576
+ roteiro ("rápido", "contínuo", caixa alta). A velocidade não vem
1577
+ daqui: vem do atempo (ritmoVoz) depois da voz pronta. */
1578
+ const direcao = DIRECAO_DA_VOZ[estilo] || DIRECAO_DA_VOZ.padrao;
1514
1579
  /* Uma voz por falante (falantes.js). Com uma voz só, um grupo só: é a
1515
1580
  narração de sempre, com o mesmo cache. */
1516
1581
  const vozesL = falantes.vozesDasPartes(partes, roteiro, args.voz || roteiro.voz);
@@ -1656,6 +1721,15 @@ async function prepararRoteiro(p, args) {
1656
1721
  const icones = {};
1657
1722
  for (const pt of partes) {
1658
1723
  const h = typeof (pt.heroi != null ? pt.heroi : pt.icone) === 'string' ? String(pt.heroi != null ? pt.heroi : pt.icone).trim() : '';
1724
+ if (/^(fluent3d|noto):/i.test(h) && !icones[h]) {
1725
+ // O herói da biblioteca: o PNG 3D ou o Lottie, baixado para o projeto.
1726
+ try {
1727
+ const r = await colecao.baixar(h, { pasta: p.pasta, largura, altura });
1728
+ if (r.ok) icones[h] = /^noto:/i.test(h) ? { lottie: r.src } : { png: r.src };
1729
+ else projeto.anotar(p.id, 'icones', `herói ${h} não baixou: ${r.error || '?'}`);
1730
+ } catch { /* fica sem */ }
1731
+ continue;
1732
+ }
1659
1733
  if (!/^[a-z0-9-]+:[a-z0-9-]+$/i.test(h) || icones[h]) continue;
1660
1734
  try { const ic = await assets.icone(h, { pasta: p.pasta }); if (ic && ic.ok) icones[h] = ic.svg; else projeto.anotar(p.id, 'icones', `herói ${h} não baixou: ${(ic && ic.error) || '?'}`); } catch { /* fica sem */ }
1661
1735
  }
@@ -2042,7 +2116,8 @@ async function ferramentaSite(args) {
2042
2116
  return r;
2043
2117
  }
2044
2118
 
2045
- module.exports = { variarIcones,
2119
+ module.exports = {
2120
+ vazamentoNoComeco, variarIcones,
2046
2121
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
2047
2122
  ferramentaVideo, ferramentaRender, ferramentaSite,
2048
2123
  projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro, roteiroValido, juntarVozes,
@@ -0,0 +1,41 @@
1
+ /* kit/animacao.jsx — a animação pronta (Lottie) da biblioteca, quadro a quadro.
2
+ *
3
+ * {"tipo":"kit","peca":"animacao","src":"midia/animacoes/1f525.json","x":…,"y":…,"largura":…,"altura":…,
4
+ * "loop":true,"velocidade":1}
5
+ *
6
+ * O Lottie é vetor: sai nítido em qualquer tamanho. Ele NÃO toca sozinho
7
+ * (o relógio do navegador faria cada render sair diferente): a cada quadro o
8
+ * tempo do elemento vira o quadro da animação (goToAndStop). Os arquivos vêm
9
+ * de colecao.js (o Noto Emoji animado do Google, CC BY 4.0). */
10
+ import React, { useEffect, useRef, useState } from 'react';
11
+ import { staticFile, delayRender, continueRender } from 'remotion';
12
+ import lottie from 'lottie-web';
13
+
14
+ export function Animacao({ el, t }) {
15
+ const caixa = useRef(null);
16
+ const [anim, setAnim] = useState(null);
17
+ const [espera] = useState(() => delayRender('animação ' + el.src));
18
+ useEffect(() => {
19
+ let vivo = true;
20
+ let a = null;
21
+ fetch(/^https?:/.test(el.src) ? el.src : staticFile(el.src))
22
+ .then((r) => r.json())
23
+ .then((dados) => {
24
+ if (!vivo || !caixa.current) return;
25
+ a = lottie.loadAnimation({ container: caixa.current, renderer: 'svg', loop: false, autoplay: false, animationData: dados,
26
+ rendererSettings: { preserveAspectRatio: 'xMidYMid meet' } });
27
+ a.addEventListener('DOMLoaded', () => { setAnim(a); continueRender(espera); });
28
+ })
29
+ .catch(() => continueRender(espera));
30
+ return () => { vivo = false; if (a) a.destroy(); };
31
+ }, [el.src]);
32
+ if (anim) {
33
+ const total = Math.max(1, anim.totalFrames - 1);
34
+ const q = Math.max(0, t) * (anim.frameRate || 30) * (el.velocidade || 1);
35
+ anim.goToAndStop(el.loop === false ? Math.min(q, total) : q % total, true);
36
+ }
37
+ const w = el.largura || 300, h = el.altura || w;
38
+ return <div ref={caixa} style={{ position: 'absolute', left: el.x || 0, top: el.y || 0, width: w, height: h }} />;
39
+ }
40
+
41
+ export const ANIMACAO = { animacao: Animacao };
@@ -12,9 +12,10 @@
12
12
  * quadro as fontes carregam e cada linha é MEDIDA no navegador (delayRender):
13
13
  * o que não cabe encolhe, e o acento sabe onde fica cada palavra. */
14
14
  import React, { useState, useEffect, useMemo } from 'react';
15
- import { delayRender, continueRender } from 'remotion';
15
+ import { delayRender, continueRender, Img, staticFile } from 'remotion';
16
+ import { Animacao } from './animacao';
16
17
  import { SNAPPY, CURVAS_KIT, acaso, suave, alfa, mistura } from './base';
17
- import { Produto3D, Icone3D } from './tres';
18
+ import { Produto3D, Icone3D, Heroi3D } from './tres';
18
19
 
19
20
  const ed = CURVAS_KIT.editorial, curvaCam = CURVAS_KIT.camera, arrasto = CURVAS_KIT.arrasto, acelera = CURVAS_KIT.acelera;
20
21
  const c01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
@@ -193,7 +194,9 @@ function leiaute(P, med) {
193
194
  if (!l.encher || !med[l.id]) continue;
194
195
  const k = l.encher + (l.coluna != null ? l.coluna : '');
195
196
  // 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);
197
+ /* (10/10) A tese gigante, a parede e a marca ENCHEM o bloco (97%): com fonte
198
+ larga, os 74% de antes paravam "CLAUDE CODE" em 9% da altura. O resto, 80%. */
199
+ 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
200
  grupo[k] = grupo[k] == null ? s : Math.min(grupo[k], s);
198
201
  }
199
202
  const tam = (l) => {
@@ -950,9 +953,24 @@ export function Gerativo({ el, t, fps }) {
950
953
  e do texto — e calmo: desenhado por cima, brigava com os dois (05/10). */}
951
954
  {noAr.filter((x) => x.heroi && x.heroi.modo === 'campo' && L[x.id]).map((x) => {
952
955
  const bl = L[x.id].bloco, some = 1 - c01((t - x.ate) / 0.14);
956
+ let campo3d = null;
957
+ if (x.heroi.tres) {
958
+ /* As colunas NUNCA encostam no texto: só existem numa faixa abaixo
959
+ (ou acima, de ponta-cabeça) do bloco MEDIDO, com folga. O campo mora
960
+ mais fundo que o texto (translateZ -200), então na tela ele encolhe
961
+ para o centro mais que o texto: a folga medida aqui só cresce lá. */
962
+ const folga = Math.max(x.vao || 0, 0.07 * H);
963
+ const ds = DUR_SAIDA[x.saida] || 0;
964
+ const saiDe = x.ate - ds - 0.15;
965
+ if (H - (bl.y1 + folga) >= 0.34 * H) campo3d = { op: 0.7, faixa: { y0: bl.y1 + folga, y1: H }, saiDe };
966
+ else if (bl.y - folga >= 0.34 * H) campo3d = { op: 0.7, faixa: { y0: 0, y1: bl.y - folga }, teto: true, saiDe };
967
+ if (!campo3d) return null; // sem faixa livre: sem colunas
968
+ }
953
969
  return (
954
970
  <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 }} />
971
+ {x.heroi.tres
972
+ ? <Heroi3D h={x.heroi} t={t} de={x.de} ate={x.ate} W={W} H={H} slot={campo3d} />
973
+ : <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
974
  </div>
957
975
  );
958
976
  })}
@@ -998,10 +1016,31 @@ export function Gerativo({ el, t, fps }) {
998
1016
  // O lugar do herói: o vazio do bloco (como o ícone); sem vazio, atrás do texto e mais fraco.
999
1017
  const bl = lay.bloco, M = P.margem;
1000
1018
  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);
1019
+ const tres = x.heroi.modo === 'objeto3d';
1020
+ // O 3D é GRANDE ("um 3d bem presente"): o raio pede o vazio inteiro.
1021
+ const R = Math.min(W, H) * (tres ? (W >= H ? 0.26 : 0.22) : (W >= H ? 0.2 : 0.17));
1002
1022
  let slot = null;
1003
1023
  // A área segura (margem.js), com o vão do bloco; partitura antiga: a margem do mundo.
1004
1024
  const S = x.seguro || { x0: M, y0: M * 1.4, x1: W - M, y1: H - M * 1.4 }, V = x.vao || 0;
1025
+ if (tres) {
1026
+ /* O 3D NUNCA cruza o texto: mora no maior retângulo livre ao lado do
1027
+ bloco MEDIDO (com o vão e uma folga), dentro da área segura; o raio
1028
+ é o círculo que cabe nele, e o Heroi3D garante que a projeção inteira
1029
+ (anel, tubo, dolly, perspectiva) fica dentro desse raio. Sem
1030
+ retângulo grande o bastante: sem 3D nesta batida. */
1031
+ const folga = Math.max(V, 0.035 * Math.min(W, H));
1032
+ const livres = [
1033
+ { x0: bl.x1 + folga, y0: S.y0, x1: S.x1, y1: S.y1 },
1034
+ { x0: S.x0, y0: S.y0, x1: bl.x - folga, y1: S.y1 },
1035
+ { x0: S.x0, y0: S.y0, x1: S.x1, y1: bl.y - folga },
1036
+ { x0: S.x0, y0: bl.y1 + folga, x1: S.x1, y1: S.y1 },
1037
+ ].map((q) => ({ ...q, r: Math.min(q.x1 - q.x0, q.y1 - q.y0) / 2 })).sort((a, c) => c.r - a.r);
1038
+ const q = livres[0];
1039
+ if (!q || q.r < Math.min(W, H) * 0.12) return null;
1040
+ const ds = DUR_SAIDA[x.saida] || 0;
1041
+ 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 };
1042
+ return <Heroi3D h={x.heroi} t={t} de={x.de} ate={x.ate} W={W} H={H} slot={slot} />;
1043
+ }
1005
1044
  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
1045
  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
1046
  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 };
@@ -1037,6 +1076,25 @@ export function Gerativo({ el, t, fps }) {
1037
1076
  const [px, py] = achou;
1038
1077
  /* O ÍCONE PLANO (09/10): o do Iconify que o roteiro pediu (o pão, o QR
1039
1078
  code), na cor do acento da identidade — entra em mola e flutua de leve. */
1079
+ /* O ÍCONE DA BIBLIOTECA (11/10): o 3D renderizado do Fluent (png) ou a
1080
+ animação pronta do Noto (lottie). Entra em mola, gira de leve no
1081
+ eixo Y e flutua, com a sombra no chão que dá peso de objeto. */
1082
+ if (ic.png || ic.lottie) {
1083
+ const k = MOLA(t - ic.t), dt = t - ic.t, sai = c01((t - (x.ate - 0.25)) / 0.25);
1084
+ const fl = Math.sin(dt * 1.4) * 0.025 * tam, giro = Math.sin(dt * 0.9) * 10;
1085
+ return (
1086
+ <div style={{ position: 'absolute', left: Math.round(px), top: Math.round(py), width: tam, height: tam, opacity: c01(k * 1.4) * (1 - sai),
1087
+ transform: 'translateZ(140px) scale(' + (0.5 + 0.5 * k).toFixed(4) + ')', transformOrigin: '50% 70%' }}>
1088
+ <div style={{ position: 'absolute', left: '18%', right: '18%', bottom: '-4%', height: '9%', borderRadius: '50%', background: 'rgba(0,0,0,0.28)',
1089
+ filter: 'blur(' + (tam * 0.03).toFixed(1) + 'px)', transform: 'scale(' + (1 - fl / tam * 2).toFixed(3) + ')' }} />
1090
+ <div style={{ position: 'absolute', inset: 0, transform: 'translateY(' + fl.toFixed(1) + 'px) perspective(800px) rotateY(' + giro.toFixed(2) + 'deg) rotate(' + ((1 - k) * -10).toFixed(2) + 'deg)' }}>
1091
+ {ic.png
1092
+ ? <Img src={staticFile(ic.png)} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
1093
+ : <Animacao el={{ src: ic.lottie, x: 0, y: 0, largura: tam, altura: tam, loop: true }} t={Math.max(0, dt)} />}
1094
+ </div>
1095
+ </div>
1096
+ );
1097
+ }
1040
1098
  if (ic.svg) {
1041
1099
  const k = MOLA(t - ic.t), fl = Math.sin((t - ic.t) * 1.3) * 0.012 * tam, sai = c01((t - (x.ate - 0.25)) / 0.25);
1042
1100
  return <div style={{ position: 'absolute', left: Math.round(px), top: Math.round(py + fl), width: tam, height: tam, color: ic.cor, opacity: c01(k * 1.4) * (1 - sai),
@@ -10,6 +10,8 @@ import { TRES } from './tres';
10
10
  import { GERATIVO } from './gerativo';
11
11
  import { FLUIDO } from './fluido';
12
12
  import { PERSONAGEM } from './personagem';
13
+ import { VOXEL } from './voxel';
14
+ import { ANIMACAO } from './animacao';
13
15
 
14
- export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, ...FLUIDO, ...PERSONAGEM, papel: Papel };
16
+ export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, ...FLUIDO, ...PERSONAGEM, ...VOXEL, ...ANIMACAO, papel: Papel };
15
17
  export { Filtros, Acabamento, Hud, CURVAS_KIT, bolhaCaminho } from './base';
@@ -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 };