primocode 9.8.0-beta.11 → 9.8.0-beta.13

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 v9.8.0-beta.11
1
+ # PrimoCode v9.8.0-beta.13
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 v9.8.0-beta.11
12
+ ▐▛███▜▌ Primo Code v9.8.0-beta.13
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/lib/act.js CHANGED
@@ -692,15 +692,19 @@ async function runAct(userPrompt, opts) {
692
692
 
693
693
  Um modelo não chama tool fora da lista por conta própria. Só
694
694
  chamou porque este parágrafo mandou. */
695
+ /* O caminho do ROTEIRO (lib/estudio/composicao.js). Antes esta
696
+ ordem mandava chamar o diretor e depois compor "cena a cena,
697
+ com as primitivas" — o modelo escrevia posição em pixel e o
698
+ vídeo saía quebrado, além de custar três ou quatro rodadas a
699
+ mais de modelo (medido em 25/09). */
695
700
  messages.push({ role: 'system', content:
696
- 'ESTE PEDIDO É DE PRODUÇÃO AUDIOVISUAL. Se `planejar_producao` estiver na sua lista de '
697
- + `ferramentas, chame-o ANTES de criar qualquer peça (formato "${producao.formato}"): `
698
- + 'ele devolve a pauta — arco, narração falada, voz, direção de arte e a mídia a buscar — '
699
- + 'e é ela que você produz, cena a cena, compondo com as primitivas. '
700
- + 'Se ele NÃO estiver na sua lista, não o chame e não procure equivalente: siga com as '
701
- + 'ferramentas que você tem e escreva o roteiro antes das cenas, não dentro do JSON delas. '
702
- + 'Nunca chame uma ferramenta que não está na sua lista — a chamada é recusada e a tarefa '
703
- + 'se perde.' });
701
+ `ESTE PEDIDO É DE PRODUÇÃO (formato "${producao.formato === 'apresentacao' ? 'slides' : 'video'}"). Faça em três passos: `
702
+ + `estudio_projeto acao:"criar" com formato "${producao.formato === 'apresentacao' ? 'slides' : 'video'}" → `
703
+ + 'estudio_plano com "roteiro" (partes com tipo e fala; o PrimoCode faz layout, voz, ícones, música e sons) → '
704
+ + 'estudio_render. Se a peça for de uma marca de verdade, estudio_site antes, e use as cores e a logo em roteiro.marca. '
705
+ + 'NÃO escreva cenas, elementos nem posições à mão. '
706
+ + 'Se estudio_plano não aceitar "roteiro" (servidor antigo), siga com as ferramentas que você tem. '
707
+ + 'Nunca chame uma ferramenta que não está na sua lista — a chamada é recusada e a tarefa se perde.' });
704
708
  }
705
709
  }
706
710
 
package/lib/catalogo.js CHANGED
@@ -84,7 +84,11 @@ const GRUPOS = [
84
84
  + 'SEMPRE procure antes de criar quando o pedido falar de algo que já existe.' },
85
85
 
86
86
  { tool: 'estudio_plano', escreve: true, faz: 'escreve ou ajusta o que o vídeo É — cenas, elementos, animação, câmera, áudio',
87
- args: 'id. plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
87
+ args: 'id. PREFIRA roteiro: {marca:{cores:{fundo,destaque,tinta},fontes:{titulo,texto},logo}, proporcao, '
88
+ + 'partes:[{tipo, fala, …}]} — o PrimoCode faz o layout, a voz com entonação, os ícones, a música e os sons. '
89
+ + 'Tipos: abertura{titulo,sub} titulo{titulo,sub} numero{valor,rotulo,sub} lista{titulo,itens} passos{titulo,itens} '
90
+ + 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link}. '
91
+ + 'voz (thalita|antonio|francisca). Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
88
92
  + 'cena (opcional): o número de UMA cena para trocar só ela, sem reescrever o resto. '
89
93
  + 'motivo (opcional): fica no caderno. voltarPara (opcional): nome de uma versão. '
90
94
  + 'Sem plano nem cenas, LÊ o plano atual. '
@@ -61,6 +61,28 @@ function argsDoModelo() {
61
61
  return MODELO_CLAUDE ? ['--model', MODELO_CLAUDE] : [];
62
62
  }
63
63
 
64
+ /* O Claude Code ENXUTO, como motor. Sem isto cada passo do agente abria o
65
+ * Claude Code da pessoa inteiro: hooks, plugins, skills, CLAUDE.md dela e as
66
+ * ferramentas nativas — 36 mil tokens e ~6 s por chamada, e a resposta
67
+ * chegava com recado de hook pessoal ("context-sync pendente"). Medido em
68
+ * 25/09: com estes argumentos, a mesma chamada leva 473 tokens e ~3,5 s. Um
69
+ * pedido de 100 passos pagava aquilo 100 vezes — é a demora de 20 minutos.
70
+ *
71
+ * Nada disto tira o que o motor precisa: o protocolo vai inteiro no prompt
72
+ * (corpoDoProtocolo), e as ferramentas que valem são as do PrimoCode, não as
73
+ * do Claude Code. */
74
+ const ARGS_ENXUTOS = [
75
+ '--setting-sources', 'project',
76
+ '--strict-mcp-config',
77
+ '--disable-slash-commands',
78
+ '--tools', '',
79
+ '--no-session-persistence',
80
+ '--system-prompt', 'Você é o motor de decisão do PrimoCode. Siga à risca o protocolo que vem na mensagem e responda só com o bloco pedido.',
81
+ ];
82
+ function argsDoMotor() {
83
+ return [...ARGS_ENXUTOS, ...argsDoModelo()];
84
+ }
85
+
64
86
  /**
65
87
  * Acha o ÚLTIMO bloco marcado no texto (pode vir ruído de terminal — prompt
66
88
  * ANSI, texto solto — antes e depois) e devolve o JSON de dentro dele.
@@ -567,7 +589,7 @@ async function requestActViaHeadless(payload, onEvent) {
567
589
  '-p',
568
590
  '--output-format', 'json',
569
591
  '--dangerously-skip-permissions',
570
- ...argsDoModelo(),
592
+ ...argsDoMotor(),
571
593
  ], { encoding: 'utf-8', timeout: 180000, input: prompt, maxBuffer: 32 * 1024 * 1024,
572
594
  env: process.env, ...SEM_APARECER });
573
595
  const r = JSON.parse(saida);
@@ -577,7 +599,7 @@ async function requestActViaHeadless(payload, onEvent) {
577
599
  const pedirCorrecao = (aviso) => {
578
600
  const s = execFileSync('claude', [
579
601
  '-p', '--output-format', 'json', '--dangerously-skip-permissions',
580
- ...argsDoModelo(),
602
+ ...argsDoMotor(),
581
603
  ], {
582
604
  encoding: 'utf-8', timeout: 120000, maxBuffer: 32 * 1024 * 1024,
583
605
  env: process.env,
@@ -622,7 +644,7 @@ module.exports = {
622
644
  // o que veio torto. Reimplementar aquilo no outro motor seria manter dois
623
645
  // protocolos que divergem na primeira correção de um deles.
624
646
  linhaDoHistorico, nomesDasTools, promptDoProtocolo, promptDeCorrecao,
625
- MODELO_CLAUDE, argsDoModelo,
647
+ MODELO_CLAUDE, argsDoModelo, argsDoMotor,
626
648
  SKILL_DIR, SKILL_PATH, garantirSkill,
627
649
  temClaudeCode, temOrca, orcaAlcancavel, escolherCaminho,
628
650
  garantirEngine, requestAct, emitirDecisao,
@@ -0,0 +1,404 @@
1
+ /**
2
+ * composicao.js — o roteiro vira cena, e quem faz o layout é o CÓDIGO.
3
+ *
4
+ * "Os motion estão bem quebrados, a voz está sem entonação, não tem música,
5
+ * efeitos sonoros, ícones… ele sobrepõe as coisas." · "Os slides entregou
6
+ * uma merda." (25/09/2026)
7
+ *
8
+ * O modelo escrevia a posição de cada texto em pixel, à mão, e errava: texto
9
+ * cortado na borda, letra minúscula, meia tela vazia, nada de som. Aqui o
10
+ * modelo escreve só o ROTEIRO — o que cada parte diz e de que tipo ela é — e
11
+ * o código monta a cena:
12
+ *
13
+ * - a letra é MEDIDA para caber na caixa (nunca sai da tela, nunca some de
14
+ * tão pequena), e tudo respeita a margem de segurança;
15
+ * - cada tipo de parte tem a composição dele (abertura, número que conta,
16
+ * lista com ícones, contraste, citação, imagem, gráfico, passos, chamada),
17
+ * e a variação vem da marca e da posição no roteiro;
18
+ * - no vídeo, cada parte dura o que a voz dela dura, e o som (whoosh na
19
+ * transição, pop no item, música de cama) entra sozinho;
20
+ * - no slide, é a MESMA cena, parada.
21
+ *
22
+ * Função pura: roteiro + tempos da voz entram, plano sai.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const { iconeDe } = require('./edicao.js');
28
+
29
+ const TIPOS = ['abertura', 'titulo', 'numero', 'lista', 'contraste', 'citacao', 'imagem', 'grafico', 'passos', 'cta'];
30
+
31
+ // ── cor ──────────────────────────────────────────────────────────────────
32
+ function rgb(hex) {
33
+ const h = String(hex || '').replace('#', '');
34
+ const n = h.length === 3 ? h.split('').map((c) => c + c).join('') : h.padEnd(6, '0').slice(0, 6);
35
+ return [0, 2, 4].map((i) => parseInt(n.slice(i, i + 2), 16) || 0);
36
+ }
37
+ const hex = (c) => '#' + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
38
+ const misturar = (a, b, k) => hex(rgb(a).map((v, i) => v + (rgb(b)[i] - v) * k));
39
+ const luz = (c) => { const [r, g, b] = rgb(c); return (0.299 * r + 0.587 * g + 0.114 * b) / 255; };
40
+ const alfa = (c, a) => c + Math.round(a * 255).toString(16).padStart(2, '0');
41
+
42
+ function paleta(marca = {}) {
43
+ const c = marca.cores || {};
44
+ const fundo = c.fundo || '#0E0F14';
45
+ const escuro = luz(fundo) < 0.5;
46
+ const tinta = c.tinta || (escuro ? '#F4F2EC' : '#15161A');
47
+ const destaque = c.destaque || '#FF6A3D';
48
+ return {
49
+ fundo, tinta, destaque, escuro,
50
+ fundo2: misturar(fundo, tinta, escuro ? 0.07 : 0.05),
51
+ apoio: c.apoio || misturar(destaque, tinta, 0.35),
52
+ suave: alfa(tinta, 0.68),
53
+ // Sobre o destaque, a tinta que dá contraste.
54
+ sobreDestaque: luz(destaque) > 0.6 ? '#15161A' : '#FFFFFF',
55
+ };
56
+ }
57
+
58
+ /* ── a letra que cabe ─────────────────────────────────────────────────────
59
+ * Largura média do caractere em "em" por família. Não é exata — é o
60
+ * bastante para escolher o maior tamanho que cabe em N linhas, com folga. */
61
+ const LARGURA = { condensada: 0.43, display: 0.56, texto: 0.52, serifa: 0.5 };
62
+ const FAMILIA_TIPO = (f) => (/condensed|narrow|anton|oswald|bebas|teko|league gothic/i.test(f) ? 'condensada'
63
+ : /serif|fraunces|playfair|garamond|lora|newsreader|spectral|baskerville|dm serif/i.test(f) ? 'serifa'
64
+ : /black|bricolage|archivo|sora|grotesk|syne|unbounded/i.test(f) ? 'display' : 'texto');
65
+
66
+ function caber(texto, { largura, linhas = 2, max = 200, min = 18, fonte = 'Inter' }) {
67
+ const cw = LARGURA[FAMILIA_TIPO(fonte)];
68
+ const palavras = String(texto || '').split(/\s+/).filter(Boolean);
69
+ for (let t = Math.round(max); t >= min; t -= 2) {
70
+ // Quebra de verdade, palavra por palavra: é o que o navegador fará.
71
+ let n = 1, linha = 0;
72
+ let cabe = true;
73
+ for (const w of palavras) {
74
+ const lw = (w.length + 1) * cw * t;
75
+ if (lw > largura * 1.02) { cabe = false; break; } // uma palavra sozinha não cabe
76
+ if (linha + lw > largura) { n++; linha = lw; } else linha += lw;
77
+ }
78
+ if (cabe && n <= linhas) return { tamanho: t, linhas: n };
79
+ }
80
+ return { tamanho: min, linhas };
81
+ }
82
+
83
+ // ── as partes do roteiro ─────────────────────────────────────────────────
84
+ const r2 = (n) => Math.round(n * 100) / 100;
85
+ const TRANSICOES = ['deslizar', 'zoom', 'mascara', 'esmaecer', 'morph'];
86
+
87
+ function textoDe(p) {
88
+ return [p.titulo, p.texto, p.valor, p.rotulo, p.sub, p.nao, p.sim, ...(p.itens || []).map((i) => (typeof i === 'string' ? i : i.texto))]
89
+ .filter(Boolean).join(' ');
90
+ }
91
+
92
+ /** O que a voz diz nesta parte. Sem "fala", a voz lê o que está na tela. */
93
+ function falaDe(p) {
94
+ if (p.fala) return String(p.fala);
95
+ if (p.tipo === 'numero') return `${p.valor} ${p.rotulo || ''}.`;
96
+ return textoDe(p);
97
+ }
98
+
99
+ /** O registro da voz para esta parte (ver REGISTRO em studio/narrar.py). */
100
+ function registroDe(p, i, n) {
101
+ if (i === 0) return 'gancho';
102
+ if (p.tipo === 'cta' || i === n - 1) return 'cta';
103
+ return { numero: 'numero', contraste: 'contraste', lista: 'lista', citacao: 'citacao', passos: 'lista' }[p.tipo] || 'normal';
104
+ }
105
+
106
+ function iconesDoRoteiro(roteiro) {
107
+ const nomes = new Set();
108
+ for (const p of roteiro.partes || []) {
109
+ for (const it of p.itens || []) {
110
+ const t = typeof it === 'string' ? it : it.texto;
111
+ const ic = (typeof it === 'object' && it.icone) || iconeDoTexto(t);
112
+ if (ic) nomes.add(ic);
113
+ }
114
+ }
115
+ return [...nomes];
116
+ }
117
+
118
+ function iconeDoTexto(t) {
119
+ for (const w of String(t || '').split(/\s+/)) { const ic = iconeDe(w); if (ic) return ic; }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * O plano.
125
+ *
126
+ * @param {object} op
127
+ * @param {object} op.roteiro { partes: [{tipo, …}], marca: {cores, fontes, logo} }
128
+ * @param {string} op.modo 'video' | 'slides'
129
+ * @param {Array} op.tempos no vídeo: [{de, ate, palavras}] da voz de cada parte
130
+ * @param {string} op.narracao o mp3 da voz (vídeo)
131
+ * @param {object} op.sons { pop, whoosh }
132
+ * @param {string} op.trilha a música de cama (vídeo)
133
+ * @param {object} op.imagens { indiceDaParte: src }
134
+ * @param {Set} op.iconesOk os ícones que existem
135
+ */
136
+ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080, tempos = [], narracao = null,
137
+ sons = {}, trilha = null, imagens = {}, iconesOk = null }) {
138
+ const partes = (roteiro && roteiro.partes) || [];
139
+ if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
140
+ const ruins = partes.map((p, i) => (TIPOS.includes(p.tipo) ? null : `parte ${i + 1}: tipo "${p.tipo}" não existe (use ${TIPOS.join(', ')})`)).filter(Boolean);
141
+ if (ruins.length) return { ok: false, error: 'o roteiro tem partes de tipo desconhecido.', problemas: ruins };
142
+
143
+ const marca = roteiro.marca || {};
144
+ const cor = paleta(marca);
145
+ const F = {
146
+ titulo: (marca.fontes && marca.fontes.titulo) || 'Bricolage Grotesque',
147
+ texto: (marca.fontes && marca.fontes.texto) || 'Inter',
148
+ serifa: (marca.fontes && marca.fontes.serifa) || 'Fraunces',
149
+ };
150
+ const W = largura, H = altura;
151
+ const M = Math.round(Math.min(W, H) * 0.08); // a margem de segurança
152
+ const vid = modo === 'video';
153
+ const deitado = W >= H;
154
+ const cenas = [];
155
+ const efeitos = [];
156
+
157
+ // No slide tudo já entrou quando o quadro do meio é tirado (1,5 s).
158
+ const atraso = (t) => (vid ? t : Math.min(t, 0.9) * 0.6);
159
+
160
+ partes.forEach((p, i) => {
161
+ const tp = tempos[i] || {};
162
+ const proxDe = tempos[i + 1] ? tempos[i + 1].de : null;
163
+ const base = vid ? Math.max(1.6, (proxDe != null ? proxDe : (tp.ate || 0) + 0.9) - (tp.de || 0)) : 3;
164
+ const trans = !vid || i === 0 ? 'corte' : TRANSICOES[i % TRANSICOES.length];
165
+ const cruz = trans === 'corte' ? 0 : 0.35;
166
+ const dur = r2(base + cruz);
167
+ const els = [];
168
+ const variante = i % 2;
169
+
170
+ // ── o fundo: cor, profundidade e movimento, nunca chapado e morto ──
171
+ let fundo = { tipo: 'cor', valor: variante ? cor.fundo2 : cor.fundo };
172
+ let tinta = cor.tinta, destaque = cor.destaque, suave = cor.suave;
173
+ if (p.tipo === 'numero') { fundo = { tipo: 'cor', valor: cor.destaque }; tinta = cor.sobreDestaque; destaque = cor.sobreDestaque; suave = alfa(cor.sobreDestaque, 0.75); }
174
+ if (p.tipo !== 'imagem') {
175
+ const R = Math.round(Math.max(W, H) * 0.62);
176
+ const canto = [[W - R * 0.55, -R * 0.45], [-R * 0.45, H - R * 0.5], [W - R * 0.4, H - R * 0.55], [-R * 0.5, -R * 0.4]][i % 4];
177
+ els.push({
178
+ tipo: 'forma', forma: 'circulo', largura: R, altura: R, x: Math.round(canto[0]), y: Math.round(canto[1]),
179
+ cor: p.tipo === 'numero' ? cor.sobreDestaque : cor.destaque, opacidade: p.tipo === 'numero' ? 0.12 : (cor.escuro ? 0.22 : 0.16),
180
+ anima: {
181
+ desfoque: [{ t: 0, v: Math.round(R * 0.22) }],
182
+ x: [{ t: 0, v: 0 }, { t: dur, v: variante ? 60 : -60, e: 'suave' }],
183
+ y: [{ t: 0, v: 0 }, { t: dur, v: variante ? -40 : 40, e: 'suave' }],
184
+ },
185
+ });
186
+ }
187
+
188
+ const W0 = W - 2 * M;
189
+ const titulo = (texto, { y, larg = W0, linhas = 2, max = H * 0.12, x = M, alinhar = 'left', de = 0.15, cor: c = tinta, destaqueUltima = true }) => {
190
+ const fit = caber(texto, { largura: larg, linhas, max, fonte: F.titulo });
191
+ const ws = String(texto).split(/\s+/);
192
+ els.push({
193
+ tipo: 'texto', texto, fonte: F.titulo, peso: 800, tamanho: fit.tamanho, cor: c, entrelinha: 1.04,
194
+ x, y: Math.round(y), largura: Math.round(larg), alinhamento: alinhar,
195
+ revelar: ws.map((_, k) => r2(atraso(de + k * 0.07))),
196
+ ...(destaqueUltima && ws.length > 2 ? { destaques: [ws.length - 1], corDestaque: destaque } : {}),
197
+ });
198
+ return fit.tamanho * 1.04 * fit.linhas;
199
+ };
200
+ const linhaFina = (texto, { y, larg = W0, x = M, max = H * 0.04, de = 0.5, alinhar = 'left', c = suave, linhas = 2 }) => {
201
+ const fit = caber(texto, { largura: larg, linhas, max, fonte: F.texto });
202
+ els.push({
203
+ tipo: 'texto', texto, fonte: F.texto, peso: 500, tamanho: fit.tamanho, cor: c, entrelinha: 1.3,
204
+ x, y: Math.round(y), largura: Math.round(larg), alinhamento: alinhar,
205
+ anima: { opacidade: [{ t: atraso(de), v: 0 }, { t: atraso(de) + 0.4, v: 1 }], y: [{ t: atraso(de), v: 18 }, { t: atraso(de) + 0.5, v: 0, e: 'saida' }] },
206
+ });
207
+ return fit.tamanho * 1.3 * fit.linhas;
208
+ };
209
+ const traco = (x, y, c = destaque, de = 0.05) => els.push({
210
+ tipo: 'forma', forma: 'retangulo', x, y: Math.round(y), largura: Math.round(W * 0.06), altura: Math.max(6, Math.round(H * 0.008)),
211
+ cor: c, origem: 'left', anima: { escalaX: [{ t: atraso(de), v: 0 }, { t: atraso(de) + 0.5, v: 1, e: 'saida' }] },
212
+ });
213
+ const logo = (x, y, alt) => { if (marca.logo) els.push({ tipo: 'imagem', src: marca.logo, x, y, altura: alt, largura: Math.round(alt * 4), ajuste: 'contain', foco: 'left center', anima: { opacidade: [{ t: atraso(0.3), v: 0 }, { t: atraso(0.3) + 0.4, v: 1 }] } }); };
214
+
215
+ switch (p.tipo) {
216
+ case 'abertura':
217
+ case 'titulo': {
218
+ const centro = p.tipo === 'titulo' && variante === 1;
219
+ const larg = centro ? W0 * 0.86 : W0 * (deitado ? 0.78 : 1);
220
+ const fit = caber(p.titulo || '', { largura: larg, linhas: 3, max: H * (p.tipo === 'abertura' ? 0.15 : 0.12), fonte: F.titulo });
221
+ const hT = fit.tamanho * 1.04 * fit.linhas;
222
+ const hS = p.sub ? H * 0.09 : 0;
223
+ let y = (H - hT - hS) / 2;
224
+ const x = centro ? (W - larg) / 2 : M;
225
+ if (p.tipo === 'abertura') logo(M, M, Math.round(H * 0.05));
226
+ if (!centro) traco(M, y - H * 0.05);
227
+ titulo(p.titulo || '', { y, larg, linhas: 3, max: fit.tamanho, x, alinhar: centro ? 'center' : 'left' });
228
+ if (p.sub) linhaFina(p.sub, { y: y + hT + H * 0.03, larg, x, alinhar: centro ? 'center' : 'left', de: 0.6 });
229
+ break;
230
+ }
231
+ case 'numero': {
232
+ const valor = String(p.valor == null ? '' : p.valor);
233
+ const m = valor.match(/^([^\d-]*)(-?[\d.,]+)(.*)$/);
234
+ const larg = deitado ? W0 * 0.5 : W0;
235
+ const fit = caber(valor, { largura: larg, linhas: 1, max: H * (deitado ? 0.5 : 0.3), fonte: F.titulo });
236
+ const yN = deitado ? (H - fit.tamanho) / 2 - H * 0.03 : H * 0.22;
237
+ const num = Number(m ? m[2].replace(/\./g, '').replace(',', '.') : NaN);
238
+ els.push({
239
+ tipo: 'texto', texto: valor, fonte: F.titulo, peso: 800, tamanho: fit.tamanho, cor: tinta, entrelinha: 1,
240
+ x: M, y: Math.round(yN), largura: Math.round(larg + M),
241
+ ...(m && !Number.isNaN(num) ? { contar: { ate: num, dur: vid ? 1.3 : 0.6, antes: m[1], depois: m[3], casas: (m[2].split(',')[1] || '').length } } : {}),
242
+ anima: { escala: [{ t: 0, v: 0.86 }, { t: atraso(0.5), v: 1, e: 'saida' }], opacidade: [{ t: 0, v: 0 }, { t: 0.15, v: 1 }] },
243
+ });
244
+ const xR = deitado ? M + larg + W * 0.04 : M;
245
+ const lR = deitado ? W - xR - M : W0;
246
+ const yR = deitado ? H * 0.38 : yN + fit.tamanho + H * 0.04;
247
+ const hR = titulo(p.rotulo || '', { y: yR, larg: lR, linhas: 3, max: H * 0.075, x: xR, de: 0.4, destaqueUltima: false });
248
+ if (p.sub) linhaFina(p.sub, { y: yR + hR + H * 0.03, larg: lR, x: xR, de: 0.8 });
249
+ break;
250
+ }
251
+ case 'lista':
252
+ case 'passos': {
253
+ const itens = (p.itens || []).slice(0, 5).map((it) => (typeof it === 'string' ? { texto: it } : it));
254
+ const n = itens.length || 1;
255
+ const colunas = deitado && n <= 4 ? n : 1;
256
+ const gap = W * 0.025;
257
+ const wCard = colunas > 1 ? (W0 - gap * (colunas - 1)) / colunas : W0;
258
+ /* O bloco inteiro (título + itens) fica no meio da altura: com
259
+ o título no topo e os itens logo abaixo, sobrava meia tela
260
+ vazia embaixo. */
261
+ const fT = p.titulo ? caber(p.titulo, { largura: W0, linhas: 2, max: H * 0.1, fonte: F.titulo }) : { tamanho: 0, linhas: 0 };
262
+ const hTit = fT.tamanho * 1.04 * fT.linhas;
263
+ const hCard0 = colunas > 1 ? (p.tipo === 'passos' ? H * 0.26 : H * 0.44) : H * 0.14;
264
+ const hItens = colunas > 1 ? hCard0 : n * hCard0 + (n - 1) * gap;
265
+ const bloco = hTit + (hTit ? H * 0.07 : 0) + hItens;
266
+ const yTit = Math.max(M, (H - bloco) / 2);
267
+ if (p.titulo) titulo(p.titulo, { y: yTit, linhas: 2, max: fT.tamanho });
268
+ const topo = yTit + hTit + (hTit ? H * 0.07 : 0);
269
+ const hCard = Math.min(hCard0, colunas > 1 ? H - topo - M : (H - topo - M - gap * (n - 1)) / n);
270
+ const dur0 = vid ? Math.max(1, (tp.ate || dur) - (tp.de || 0)) : 1;
271
+ itens.forEach((it, k) => {
272
+ const cx = colunas > 1 ? M + k * (wCard + gap) : M;
273
+ const cy = colunas > 1 ? topo : topo + k * (hCard + gap);
274
+ // O item entra quando a voz chega nele (ou em cascata, no slide).
275
+ const quando = atraso(vid ? 0.5 + (dur0 * 0.85 - 0.5) * (k / n) : 0.3 + k * 0.12);
276
+ const entra = { opacidade: [{ t: quando, v: 0 }, { t: quando + 0.3, v: 1 }], y: [{ t: quando, v: 40 }, { t: quando + 0.45, v: 0, e: 'saida' }] };
277
+ if (p.tipo === 'lista') {
278
+ els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round(cx), y: Math.round(cy), largura: Math.round(wCard), altura: Math.round(hCard), cor: alfa(tinta, cor.escuro ? 0.07 : 0.06), raio: Math.round(H * 0.025), anima: entra });
279
+ } else if (k < n - 1 && colunas > 1) {
280
+ els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round(cx + H * 0.05), y: Math.round(cy + H * 0.05), largura: Math.round(wCard + gap), altura: 4, cor: alfa(destaque, 0.5), origem: 'left', anima: { escalaX: [{ t: quando, v: 0 }, { t: quando + 0.6, v: 1, e: 'suave' }] } });
281
+ }
282
+ const tamI = Math.round(Math.min(hCard * (colunas > 1 ? 0.28 : 0.5), H * 0.09));
283
+ const ic = it.icone || iconeDoTexto(it.texto);
284
+ const pad = Math.round(Math.min(wCard, hCard) * 0.14);
285
+ if (p.tipo === 'passos') {
286
+ els.push({ tipo: 'forma', forma: 'circulo', x: Math.round(cx + pad * 0.3), y: Math.round(cy + pad * 0.3), largura: tamI, altura: tamI, cor: destaque, anima: { ...entra, escala: [{ t: quando, v: 0 }, { t: quando + 0.35, v: 1, e: 'elastico' }] } });
287
+ els.push({ tipo: 'texto', texto: String(k + 1), fonte: F.titulo, peso: 800, tamanho: Math.round(tamI * 0.5), cor: cor.sobreDestaque, x: Math.round(cx + pad * 0.3 + tamI / 2), y: Math.round(cy + pad * 0.3 + tamI / 2), ancora: 'centro', anima: entra });
288
+ } else if (ic && (!iconesOk || iconesOk.has(ic))) {
289
+ els.push({ tipo: 'icone', nome: ic, tamanho: tamI, x: Math.round(cx + pad), y: Math.round(cy + (colunas > 1 ? pad : (hCard - tamI) / 2)), anima: { ...entra, escala: [{ t: quando, v: 0 }, { t: quando + 0.3, v: 1.12, e: 'saida' }, { t: quando + 0.45, v: 1 }] } });
290
+ }
291
+ if (vid && sons.pop) efeitos.push({ t: r2((tp.de || 0) + quando), src: sons.pop, volume: 0.5 });
292
+ const tx = colunas > 1 ? cx + pad : cx + pad + tamI + pad;
293
+ const ty = colunas > 1 ? cy + pad + tamI + pad * 0.8 : cy + hCard / 2;
294
+ const lw = colunas > 1 ? wCard - 2 * pad : wCard - (tamI + 3 * pad);
295
+ const fit = caber(it.texto, { largura: lw, linhas: colunas > 1 ? 4 : 2, max: Math.min(H * 0.05, hCard * 0.3), fonte: F.texto });
296
+ els.push({
297
+ tipo: 'texto', texto: it.texto, fonte: F.texto, peso: 650, tamanho: fit.tamanho, cor: tinta, entrelinha: 1.2,
298
+ x: Math.round(tx), y: Math.round(colunas > 1 ? ty : ty - fit.tamanho * 1.2 * fit.linhas / 2), largura: Math.round(lw), anima: entra,
299
+ });
300
+ });
301
+ break;
302
+ }
303
+ case 'contraste': {
304
+ const larg = W0 * 0.9;
305
+ const f1 = caber(p.nao || '', { largura: larg, linhas: 2, max: H * 0.07, fonte: F.texto });
306
+ const f2 = caber(p.sim || '', { largura: larg, linhas: 3, max: H * 0.13, fonte: F.titulo });
307
+ const hTot = f1.tamanho * 1.2 * f1.linhas + H * 0.05 + f2.tamanho * 1.04 * f2.linhas;
308
+ const y1 = (H - hTot) / 2;
309
+ const meio = vid ? Math.max(0.8, ((tp.ate || dur) - (tp.de || 0)) * 0.45) : 0.5;
310
+ els.push({ tipo: 'texto', texto: p.nao || '', fonte: F.texto, peso: 600, tamanho: f1.tamanho, cor: alfa(tinta, 0.5), x: M, y: Math.round(y1), largura: Math.round(larg), entrelinha: 1.2, anima: { opacidade: [{ t: 0.1, v: 0 }, { t: 0.4, v: 1 }] } });
311
+ els.push({ tipo: 'forma', forma: 'retangulo', x: M, y: Math.round(y1 + f1.tamanho * 0.62), largura: Math.round(Math.min(larg, (p.nao || '').length * f1.tamanho * 0.52)), altura: Math.max(5, Math.round(f1.tamanho * 0.09)), cor: destaque, origem: 'left', anima: { escalaX: [{ t: atraso(meio - 0.3), v: 0 }, { t: atraso(meio), v: 1, e: 'saida' }] } });
312
+ titulo(p.sim || '', { y: y1 + f1.tamanho * 1.2 * f1.linhas + H * 0.05, larg, linhas: 3, max: f2.tamanho, de: atraso(meio) });
313
+ if (vid && sons.pop) efeitos.push({ t: r2((tp.de || 0) + meio), src: sons.pop, volume: 0.5 });
314
+ break;
315
+ }
316
+ case 'citacao': {
317
+ const larg = W0 * 0.84;
318
+ const fit = caber(p.texto || '', { largura: larg, linhas: 4, max: H * 0.085, fonte: F.serifa });
319
+ const hT = fit.tamanho * 1.18 * fit.linhas;
320
+ const y = (H - hT) / 2 + H * 0.04;
321
+ els.push({ tipo: 'texto', texto: '“', fonte: F.serifa, peso: 700, tamanho: Math.round(H * 0.3), cor: destaque, x: M, y: Math.round(y - H * 0.24), anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.3, v: 1 }], escala: [{ t: 0, v: 0.7 }, { t: 0.5, v: 1, e: 'saida' }] } });
322
+ const ws = String(p.texto || '').split(/\s+/);
323
+ els.push({ tipo: 'texto', texto: p.texto || '', fonte: F.serifa, peso: 500, italico: true, tamanho: fit.tamanho, cor: tinta, entrelinha: 1.18, x: Math.round(M + W * 0.04), y: Math.round(y), largura: Math.round(larg), revelar: ws.map((_, k) => r2(atraso(0.2 + k * 0.09))) });
324
+ if (p.autor) linhaFina('— ' + p.autor, { y: y + hT + H * 0.04, x: Math.round(M + W * 0.04), larg, de: 0.9, c: destaque });
325
+ break;
326
+ }
327
+ case 'imagem': {
328
+ const src = imagens[i] || p.src;
329
+ if (src) {
330
+ els.push({ tipo: 'imagem', src, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', anima: { escala: [{ t: 0, v: 1.12 }, { t: dur, v: 1, e: 'suave' }] } });
331
+ els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', fundo: { tipo: 'gradiente', cores: ['rgba(0,0,0,0)', 'rgba(0,0,0,0.25)', 'rgba(0,0,0,0.85)'], angulo: 180 } });
332
+ }
333
+ const larg = W0 * 0.8;
334
+ const fit = caber(p.titulo || '', { largura: larg, linhas: 2, max: H * 0.1, fonte: F.titulo });
335
+ const hT = fit.tamanho * 1.04 * fit.linhas;
336
+ const yT = H - M - hT - (p.sub ? H * 0.08 : 0);
337
+ if (p.titulo) titulo(p.titulo, { y: yT, larg, max: fit.tamanho, cor: '#FFFFFF', de: 0.3 });
338
+ if (p.sub) linhaFina(p.sub, { y: yT + hT + H * 0.02, larg, c: 'rgba(255,255,255,0.8)', de: 0.7 });
339
+ break;
340
+ }
341
+ case 'grafico': {
342
+ const barras = (p.barras || []).slice(0, 7);
343
+ const hT = p.titulo ? titulo(p.titulo, { y: M * 1.1, linhas: 2, max: H * 0.08 }) : 0;
344
+ const topo = M * 1.1 + hT + H * 0.12;
345
+ const baseY = H - M - H * 0.08;
346
+ const hMax = baseY - topo;
347
+ const maior = Math.max(1, ...barras.map((b) => Number(b.valor) || 0));
348
+ const gap = W0 * 0.03;
349
+ const wB = (W0 - gap * (barras.length - 1)) / Math.max(1, barras.length);
350
+ barras.forEach((b, k) => {
351
+ const h = Math.max(6, hMax * (Number(b.valor) || 0) / maior);
352
+ const x = M + k * (wB + gap);
353
+ const q = atraso(0.4 + k * 0.12);
354
+ els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round(x), y: Math.round(baseY - h), largura: Math.round(wB), altura: Math.round(h), cor: k === barras.length - 1 ? destaque : alfa(tinta, 0.25), raio: Math.round(wB * 0.08), origem: 'bottom', anima: { escalaY: [{ t: q, v: 0 }, { t: q + 0.6, v: 1, e: 'saida' }] } });
355
+ const fv = Math.round(Math.min(H * 0.05, wB * 0.35));
356
+ els.push({ tipo: 'texto', texto: String(b.valor), fonte: F.titulo, peso: 800, tamanho: fv, cor: tinta, x: Math.round(x + wB / 2), y: Math.round(baseY - h - fv * 1.4), ancora: 'centro-x', contar: { ate: Number(b.valor) || 0, dur: 0.8 }, anima: { opacidade: [{ t: q, v: 0 }, { t: q + 0.3, v: 1 }] } });
357
+ const fr = caber(b.rotulo || '', { largura: wB, linhas: 2, max: H * 0.03, fonte: F.texto });
358
+ els.push({ tipo: 'texto', texto: b.rotulo || '', fonte: F.texto, peso: 500, tamanho: fr.tamanho, cor: suave, x: Math.round(x), y: Math.round(baseY + H * 0.02), largura: Math.round(wB), alinhamento: 'center' });
359
+ });
360
+ break;
361
+ }
362
+ case 'cta': {
363
+ const larg = W0 * 0.84;
364
+ const fit = caber(p.titulo || '', { largura: larg, linhas: 3, max: H * 0.12, fonte: F.titulo });
365
+ const hT = fit.tamanho * 1.04 * fit.linhas;
366
+ const temLink = Boolean(p.link || p.sub);
367
+ const y = (H - hT - (temLink ? H * 0.16 : 0)) / 2;
368
+ titulo(p.titulo || '', { y, larg, max: fit.tamanho, x: (W - larg) / 2, alinhar: 'center' });
369
+ if (temLink) {
370
+ const txt = p.link || p.sub;
371
+ const fl = caber(txt, { largura: larg * 0.7, linhas: 1, max: H * 0.045, fonte: F.texto });
372
+ const wP = Math.min(larg, txt.length * fl.tamanho * 0.55 + fl.tamanho * 2.4);
373
+ const hP = fl.tamanho * 2.2;
374
+ const yP = y + hT + H * 0.06;
375
+ const q = atraso(0.8);
376
+ const entra = { opacidade: [{ t: q, v: 0 }, { t: q + 0.3, v: 1 }], escala: [{ t: q, v: 0.8 }, { t: q + 0.4, v: 1, e: 'elastico' }] };
377
+ els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round((W - wP) / 2), y: Math.round(yP), largura: Math.round(wP), altura: Math.round(hP), cor: destaque, raio: Math.round(hP / 2), anima: entra });
378
+ els.push({ tipo: 'texto', texto: txt, fonte: F.texto, peso: 700, tamanho: fl.tamanho, cor: cor.sobreDestaque, x: Math.round(W / 2), y: Math.round(yP + hP / 2), ancora: 'centro', anima: entra });
379
+ if (vid && sons.pop) efeitos.push({ t: r2((tp.de || 0) + q), src: sons.pop, volume: 0.55 });
380
+ }
381
+ logo(Math.round(W / 2 - H * 0.1), Math.round(H - M - H * 0.05), Math.round(H * 0.05));
382
+ break;
383
+ }
384
+ default: break;
385
+ }
386
+
387
+ const cena = { duracao: dur, fundo, elementos: els, transicao: { tipo: trans, duracao: cruz } };
388
+ if (vid) cena.camera = { zoom: variante ? [{ t: 0, v: 1.04 }, { t: dur, v: 1, e: 'suave' }] : [{ t: 0, v: 1 }, { t: dur, v: 1.04, e: 'suave' }] };
389
+ cenas.push(cena);
390
+ if (vid && i > 0 && sons.whoosh) efeitos.push({ t: r2(Math.max(0, (tp.de || 0) - 0.15)), src: sons.whoosh, volume: 0.4 });
391
+ });
392
+
393
+ const audio = {};
394
+ if (vid && narracao) audio.narracao = narracao;
395
+ if (vid && trilha) audio.trilha = { src: trilha, volume: 0.12, loop: true };
396
+ if (vid && efeitos.length) audio.efeitos = efeitos;
397
+ return {
398
+ ok: true,
399
+ plano: { formato: { largura: W, altura: H, fps: 30 }, fundoGeral: cor.fundo, cenas, ...(Object.keys(audio).length ? { audio } : {}) },
400
+ resumo: { cenas: cenas.length, efeitos: efeitos.length, musica: Boolean(audio.trilha), voz: Boolean(audio.narracao) },
401
+ };
402
+ }
403
+
404
+ module.exports = { comporRoteiro, caber, paleta, falaDe, registroDe, iconesDoRoteiro, TIPOS };