primocode 10.2.0 → 10.3.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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v10.2.0
1
+ # PrimoCode v10.3.0
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.2.0
12
+ ▐▛███▜▌ Primo Code v10.3.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/lib/act.js CHANGED
@@ -895,6 +895,11 @@ async function runAct(userPrompt, opts) {
895
895
  + `estudio_projeto acao:"criar" com formato "${producao.formato === 'apresentacao' ? 'slides' : 'video'}" → `
896
896
  + 'estudio_plano com "roteiro" (partes com tipo e fala; o PrimoCode faz layout, voz, ícones, música e sons) → '
897
897
  + '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. '
898
+ /* "todo vídeo começa igual" (09/10): o cardápio de estruturas e o
899
+ que os últimos vídeos usaram. O modelo escolhe; nada aqui força. */
900
+ + 'ANTES de escrever as partes, escolha a estrutura do roteiro e diga qual em roteiro.estrutura. '
901
+ + 'SE a pessoa pediu duas vozes, dois narradores ou diálogo: roteiro.vozes {nome: voz} e falante em cada parte.\n'
902
+ + diretor.cardapioEstruturas() + '\n'
898
903
  + 'NÃO escreva cenas, elementos nem posições à mão. '
899
904
  + 'Se estudio_plano não aceitar "roteiro" (servidor antigo), siga com as ferramentas que você tem. '
900
905
  + 'Nunca chame uma ferramenta que não está na sua lista — a chamada é recusada e a tarefa se perde.' });
package/lib/browser.js CHANGED
@@ -1296,5 +1296,5 @@ module.exports = {
1296
1296
  scriptDaSeta, verSeta, findChrome,
1297
1297
  open, click, clickText, clickSelector, type, typeInSelector,
1298
1298
  key, screenshot, evaluate, waitFor, wait, scroll, close,
1299
- inspecionarEscondido, rodarEscondido, abrirEscondido, fecharRevisor, subirRevisor, aquecerRevisor,
1299
+ inspecionarEscondido, rodarEscondido, abrirEscondido, abaEscondida, fecharRevisor, subirRevisor, aquecerRevisor,
1300
1300
  };
package/lib/catalogo.js CHANGED
@@ -77,7 +77,8 @@ const GRUPOS = [
77
77
  resumo: 'vídeo, motion, slides e arte — a IA faz, e no fim abre o resultado pronto',
78
78
  itens: [
79
79
  { tool: 'estudio_projeto', escreve: true, faz: 'abre, acha e lista os projetos do estúdio — cada peça é uma pasta que não some',
80
- args: 'acao: criar | abrir | procurar | listar | anotar | editar. '
80
+ args: 'acao: criar | abrir | procurar | listar | anotar | editar | assinatura. '
81
+ + 'assinatura: quando o usuário diz o que achou de um vídeo ("adorei a virada", "texto demais"), grave com texto (e o id) — o próximo vídeo parte disso; sem texto, lê as faixas e as notas. '
81
82
  + 'editar: id (sem id, o mais novo) — quando o usuário quer EDITAR um vídeo que o Estúdio fez, '
82
83
  + 'abre o projeto no Remotion Studio (o editor, no navegador) e devolve a pasta. '
83
84
  + 'criar: titulo, pedido, formato (video|slides|arte). '
@@ -87,24 +88,32 @@ const GRUPOS = [
87
88
 
88
89
  { tool: 'estudio_plano', escreve: true, faz: 'escreve ou ajusta o que o vídeo É — cenas, elementos, animação, câmera, áudio',
89
90
  args: 'id. PREFIRA roteiro: {marca:{cores:{fundo,destaque,tinta},fontes:{titulo,texto},logo}, proporcao, '
90
- + 'partes:[{tipo, fala, …}]} — o PrimoCode faz o layout, a voz com entonação, os ícones, a música e os sons. '
91
+ + 'partes:[{tipo, fala, …}], estrutura} — estrutura é a que VOCÊ escolheu pelo pedido (historia|problema-solucao|lista|pergunta-resposta|dialogo|demonstracao|mito-verdade|dado-surpresa, ou outra com nome); o PrimoCode faz o layout, a voz com entonação, os ícones, a música e os sons. '
91
92
  + 'Tipos: abertura{titulo,sub} titulo{titulo,sub} numero{valor,rotulo,sub} lista{titulo,itens} passos{titulo,itens} '
92
93
  + 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link} '
93
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). '
94
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. '
95
- + '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; com modelo3d o produto 3D entra numa batida) | classico (o motion 3D antigo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
96
- + '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) | impacto | cinema. '
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) | '
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. '
97
98
  + 'marca e lancamento aceitam visual:orbe|vetor|estrela|esfera|grade|site|padrao|cromo|cartoes|painel|cores numa parte. marca.nome dá o nome. '
98
- + 'voz (thalita|antonio|francisca). Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
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. '
99
100
  + 'cena (opcional): o número de UMA cena para trocar só ela, sem reescrever o resto. '
100
101
  + 'motivo (opcional): fica no caderno. voltarPara (opcional): nome de uma versão. '
101
102
  + 'Sem plano nem cenas, LÊ o plano atual. '
102
103
  + 'Cada elemento é {tipo: texto|forma|imagem|video|icone|legenda|componente|kit} com x, y, cor, tamanho e '
103
104
  + '"anima": {propriedade: [{t,v,e}]} — quadros-chave em segundos. A cena tem "camera": {zoom,x,y,rotacao} e '
104
- + '"perspectiva" para 3D. NÃO existe layout pronto nem tema — cada propriedade do vídeo é escolha sua, a cada vídeo.' },
105
+ + '"perspectiva" para 3D. Cena.transicao {tipo,duracao}: corte|esmaecer|deslizar|zoom|giro|mascara|morph|chicote|soco|flash|liquido, '
106
+ + 'e as 3D virar|cubo|profundidade|inclinar (mexem nas duas cenas) — use 3D se o vídeo pede volume (produto, site, tecnologia); em editorial plano, prefira as planas. '
107
+ + 'NÃO existe layout pronto nem tema — cada propriedade do vídeo é escolha sua, a cada vídeo.' },
105
108
 
106
109
  { tool: 'estudio_midia', escreve: true, faz: 'procura e baixa ícone, imagem, música e efeito sonoro da internet, e lista as fontes',
107
- args: 'acao: imagem | icones | som | trilha | efeito | fontes. termo. id (para baixar). '
110
+ args: 'acao: imagem | icones | som | trilha | efeito | fontes | print. termo. id (para baixar). '
111
+ + 'print: url — tira o print REAL do site e devolve o src e um elemento {tipo:"kit", peca:"printsite"} pronto '
112
+ + '(o site de verdade numa janela 3D que rola a página; celular: true para a tela do celular; inteira: false só a primeira dobra). '
113
+ + 'Use SE o pedido trouxer um site e o vídeo for SOBRE ele (lançamento, tour, case); vídeo que só cita a marca não precisa. '
114
+ + 'efeitos: lista os componentes guardados (os menos usados primeiro). usar-efeito {nome}: copia para o projeto e devolve o elemento — use se um deles servir a ESTE roteiro. '
115
+ + 'guardar-efeito {arquivo, nome, descricao}: guarda um componente que você escreveu e ficou bom, para os próximos vídeos '
116
+ + '(o componente: export default de uma função React que recebe {el, t, fps}, num arquivo só, importando só react, remotion, @remotion/*, three). '
108
117
  + 'baixar: o número do item da busca (1 é o primeiro). transparente: true para PNG recortado. '
109
118
  + 'icones: sem "nome", procura; com "nome" ("lucide:zap"), baixa. '
110
119
  + 'Tudo é BAIXADO para a pasta do projeto — o plano nunca aponta para a internet.' },
@@ -0,0 +1,108 @@
1
+ /**
2
+ * assinatura.js — o estilo como um ARQUIVO com nome e números, que o vídeo
3
+ * novo é conferido contra e que guarda o que a pessoa disse dos vídeos.
4
+ *
5
+ * Do desmontado do Forseth (rookies-lab, 07/10): o estilo vira um arquivo
6
+ * nomeado, e o feedback da pessoa fica gravado nele. O que faltava lá: o
7
+ * estilo era prosa, sem número — ninguém conferia se o vídeo seguiu.
8
+ *
9
+ * A assinatura de fábrica mora em lib/estudio/assinaturas/<nome>.json: as
10
+ * faixas medidas do vídeo que o Joel aprovou (08/10). A da pessoa mora em
11
+ * ~/.primocode/studio/assinaturas/<nome>.json e guarda só o que é dela: as
12
+ * faixas que ela ajustou e as notas ("adorei a virada", "texto demais").
13
+ * Lidas juntas: a dela por cima da de fábrica.
14
+ *
15
+ * medir(partitura, plano) os números do vídeo (os mesmos da assinatura)
16
+ * conferir(nome, medidas) o que saiu da faixa, em uma linha cada
17
+ * anotar(nome, texto) o feedback da pessoa, com a data
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const fs = require('fs');
23
+ const os = require('os');
24
+ const path = require('path');
25
+
26
+ const FABRICA = path.join(__dirname, 'assinaturas');
27
+ const pastaDaPessoa = () => path.join(os.homedir(), '.primocode', 'studio', 'assinaturas');
28
+ const r2 = (n) => Math.round(n * 100) / 100;
29
+
30
+ function lerJson(arq) { try { return JSON.parse(fs.readFileSync(arq, 'utf8')); } catch { return null; } }
31
+
32
+ /** A assinatura pelo nome: a de fábrica com a da pessoa por cima. null se não existe nenhuma. */
33
+ function ler(nome) {
34
+ const n = String(nome || '').toLowerCase().replace(/[^a-z0-9-]/g, '');
35
+ if (!n) return null;
36
+ const base = lerJson(path.join(FABRICA, n + '.json'));
37
+ const dela = lerJson(path.join(pastaDaPessoa(), n + '.json'));
38
+ if (!base && !dela) return null;
39
+ const a = { nome: n, faixas: {}, notas: [], ...(base || {}) };
40
+ if (dela) {
41
+ for (const [k, v] of Object.entries(dela.faixas || {})) a.faixas[k] = { ...(a.faixas[k] || {}), ...v };
42
+ a.notas = [...(a.notas || []), ...(dela.notas || [])];
43
+ }
44
+ return a;
45
+ }
46
+
47
+ const mediana = (xs) => { const s = [...xs].sort((a, b) => a - b); return s.length ? (s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2) : 0; };
48
+
49
+ /* Uma "frase" na tela: no fluido, b.frases[].palavras; no gerativo, cada
50
+ linha (b.linhas[], com .palavras ou só .texto). */
51
+ const frasesDe = (b) => [...(b.frases || []), ...(b.linhas || [])]
52
+ .map((f) => (Array.isArray(f.palavras) ? f.palavras.length : String(f.texto || '').split(/\s+/).filter(Boolean).length));
53
+
54
+ /** Os números de um vídeo (fluido ou gerativo), da partitura e do plano. */
55
+ function medir(partitura, plano) {
56
+ const B = (partitura && partitura.batidas) || [];
57
+ const dur = Number(partitura && partitura.duracao) || 0;
58
+ if (!B.length || !dur) return null;
59
+ const palavras = B.flatMap(frasesDe).filter((n) => n > 0);
60
+ const efeitos = ((plano && plano.audio && plano.audio.efeitos) || []).length;
61
+ return {
62
+ batida_mediana_s: r2(mediana(B.map((b) => b.ate - b.de))),
63
+ palavras_por_frase_max: Math.max(0, ...palavras),
64
+ palavras_por_frase_mediana: mediana(palavras),
65
+ // Corte seco: a transição do fluido, ou o fundo do gerativo que troca por corte.
66
+ cortes_secos: B.filter((b, i) => i > 0 && (b.transicao === 'corte' || b.troca === 'corte')).length,
67
+ fracao_gigante: r2(B.filter((b) => b.gigante || b.comp === 'gigante').length / B.length),
68
+ // Virada de fundo: a marcada no fluido, ou o fundo do gerativo que troca (corte, cortina ou inundação).
69
+ fracao_virada: r2(B.filter((b, i) => b.virada || (i > 0 && b.troca)).length / B.length),
70
+ efeitos_por_minuto: r2(efeitos / dur * 60),
71
+ };
72
+ }
73
+
74
+ /** O que saiu da faixa: [{faixa, valor, min, max, diz}]. Vazio = o vídeo seguiu a assinatura. */
75
+ function conferir(nome, medidas) {
76
+ const a = ler(nome);
77
+ if (!a || !medidas) return { assinatura: null, fora: [] };
78
+ const fora = [];
79
+ for (const [k, f] of Object.entries(a.faixas || {})) {
80
+ const v = medidas[k];
81
+ if (v == null) continue;
82
+ if ((f.min != null && v < f.min) || (f.max != null && v > f.max)) fora.push({ faixa: k, valor: v, min: f.min, max: f.max, diz: f.diz });
83
+ }
84
+ return { assinatura: a.nome, fora };
85
+ }
86
+
87
+ /** Uma linha por faixa estourada, para o agente e o caderno. */
88
+ function descrever(fora) {
89
+ return fora.map((x) => `${x.faixa} = ${x.valor} (a assinatura pede ${x.min != null ? x.min : '…'} a ${x.max != null ? x.max : '…'}: ${x.diz || ''})`);
90
+ }
91
+
92
+ /** O feedback da pessoa fica na assinatura dela, com a data. Devolve quantas notas ela tem. */
93
+ function anotar(nome, texto, quando = new Date()) {
94
+ const n = String(nome || '').toLowerCase().replace(/[^a-z0-9-]/g, '');
95
+ const t = String(texto || '').trim();
96
+ if (!n || !t) return { ok: false, error: 'a assinatura precisa de nome e de texto.' };
97
+ const arq = path.join(pastaDaPessoa(), n + '.json');
98
+ const dela = lerJson(arq) || { nome: n, faixas: {}, notas: [] };
99
+ dela.notas = [...(dela.notas || []), { quando: quando.toISOString().slice(0, 10), texto: t }].slice(-40);
100
+ try { fs.mkdirSync(pastaDaPessoa(), { recursive: true }); fs.writeFileSync(arq, JSON.stringify(dela, null, 2)); }
101
+ catch (e) { return { ok: false, error: e.message }; }
102
+ return { ok: true, assinatura: n, notas: dela.notas.length };
103
+ }
104
+
105
+ /** As últimas notas, para ir na frente do pedido (o agente lê antes de fazer o próximo vídeo). */
106
+ function notasRecentes(nome, n = 6) { const a = ler(nome); return a ? (a.notas || []).slice(-n) : []; }
107
+
108
+ module.exports = { ler, medir, conferir, descrever, anotar, notasRecentes, FABRICA };
@@ -0,0 +1,15 @@
1
+ {
2
+ "nome": "fluido",
3
+ "estilo": "fluido",
4
+ "origem": "o vídeo que o Joel aprovou em 08/10/2026 (produtividade-comeca-na-noite-anterior, 35,7 s), medido",
5
+ "descricao": "Uma ideia por batida, pouco texto, nada de corte seco: a forma de uma batida vira a da próxima. Contraste de escala (palavra gigante) e a virada de fundo nos momentos fortes. O som marca o movimento sem virar ruído.",
6
+ "faixas": {
7
+ "batida_mediana_s": { "min": 2.5, "max": 5.5, "medido": 4.0, "diz": "cada batida dura de 3 a 4,5 s" },
8
+ "palavras_por_frase_max": { "max": 9, "medido": 7, "diz": "no máximo 7 palavras por frase" },
9
+ "palavras_por_frase_mediana": { "max": 4, "medido": 2, "diz": "o normal é 2 palavras por frase" },
10
+ "cortes_secos": { "max": 0, "medido": 0, "diz": "zero cortes secos, tudo morfa" },
11
+ "fracao_gigante": { "min": 0.25, "max": 0.65, "medido": 0.56, "diz": "palavra gigante em perto da metade das batidas" },
12
+ "fracao_virada": { "min": 0.1, "max": 0.4, "medido": 0.33, "diz": "virada de fundo em ~1 de cada 3 batidas" },
13
+ "efeitos_por_minuto": { "min": 12, "max": 40, "medido": 26.9, "diz": "uns 27 efeitos sonoros por minuto" }
14
+ }
15
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "nome": "gerativo",
3
+ "estilo": "gerativo",
4
+ "origem": "o vídeo que o Joel aprovou em 08/10/2026 (teste-e2e-sono, o gerativo do PR #113, 30,3 s), medido",
5
+ "descricao": "Batida curta e seca, pouco texto por linha, numa identidade só (2 chãos, 1 acento, 2 fontes): o fundo troca na virada de seção, a primeira por corte seco. A palavra gigante entra em ~1 de cada 3 batidas. O som é desenhado da semente e fica mais raro que no fluido.",
6
+ "faixas": {
7
+ "batida_mediana_s": { "min": 2, "max": 4.5, "medido": 3.18, "diz": "cada batida dura de 1,7 a 4,5 s, o normal é ~3 s" },
8
+ "palavras_por_frase_max": { "max": 5, "medido": 3, "diz": "no máximo 3 palavras por linha" },
9
+ "palavras_por_frase_mediana": { "max": 3, "medido": 2, "diz": "o normal é 1 a 2 palavras por linha" },
10
+ "cortes_secos": { "min": 1, "medido": 5, "diz": "o fundo troca por corte seco (5 das 9 trocas)" },
11
+ "fracao_gigante": { "min": 0.15, "max": 0.5, "medido": 0.3, "diz": "palavra gigante em ~1 de cada 3 batidas" },
12
+ "fracao_virada": { "min": 0, "max": 0.45, "medido": 0.8, "revisto": "09/10/2026: o Joel reprovou o fundo trocando toda batida no vídeo do Pix na padaria (\"do nada preto, do nada branco\"). A faixa saiu de 0,5–1 para 0–0,45; o medido continua o do vídeo de 08/10, que agora fica fora SÓ nesta faixa.", "diz": "o fundo troca só na virada de seção, entre os 2 chãos da identidade (uns 2 a cada 10 batidas)" },
13
+ "efeitos_por_minuto": { "min": 6, "max": 30, "medido": 15.84, "diz": "uns 16 efeitos sonoros por minuto" }
14
+ }
15
+ }
@@ -115,9 +115,62 @@ function ritmoPara(segundos) {
115
115
  palavrasPorCena: Math.round(media * 2.6) }; // ~155 palavras/minuto de narração
116
116
  }
117
117
 
118
+ // ── 2b. a estrutura: um cardápio, e a memória do que já saiu ─────────────
119
+
120
+ /* "o roteiro vicia: todo vídeo começa igual, não escolhe outra estrutura"
121
+ (Joel, 09/10). A regra 3 do diretor tinha UM arco só — prende em 2
122
+ segundos, entrega, pede ação — e o produtor nem isso via: todo roteiro
123
+ saía com a mesma abertura de impacto e o mesmo miolo de lista. Aqui não
124
+ tem regex escolhendo pelo pedido: o modelo recebe o CARDÁPIO (cada
125
+ estrutura com o "quando serve" e o jeito de abrir) e o que os últimos
126
+ vídeos já usaram. Quem escolhe é ele; o código só lembra. */
127
+ const ESTRUTURAS = [
128
+ { id: 'historia', nome: 'história', quando: 'tem uma pessoa, um caso real ou um antes e depois', abre: 'uma cena concreta: alguém, um lugar, um momento ("Às 6h, a Ana abriu o caixa e…")' },
129
+ { id: 'problema-solucao', nome: 'problema → solução', quando: 'o assunto resolve uma dor clara', abre: 'a dor dita como quem sente ("Você perde duas horas por semana com…")' },
130
+ { id: 'lista', nome: 'lista', quando: 'são dicas, razões ou passos que se sustentam sozinhos', abre: 'a promessa com o número ("3 erros que…")' },
131
+ { id: 'pergunta-resposta', nome: 'pergunta → resposta', quando: 'o público já tem uma dúvida', abre: 'a pergunta, direta, sem a resposta ainda' },
132
+ { id: 'dialogo', nome: 'diálogo', quando: 'o pedido fala em duas pessoas, duas vozes, conversa ou entrevista', abre: 'a fala de um, e o outro responde' },
133
+ { id: 'demonstracao', nome: 'demonstração', quando: 'o valor está em VER funcionando', abre: 'o resultado pronto primeiro; depois, como se chega lá' },
134
+ { id: 'mito-verdade', nome: 'contraste (mito × verdade)', quando: 'existe uma crença comum que está errada', abre: 'o mito, dito como se fosse verdade' },
135
+ { id: 'dado-surpresa', nome: 'dado que surpreende', quando: 'há um número que muda o jeito de ver o assunto', abre: 'o número sozinho, e a frase que diz por que ele importa' },
136
+ ];
137
+
138
+ const vari = () => require('./variedade.js'); // tardio: a memória mora em ~/.primocode/studio
139
+ const idEstrutura = (x) => String(x || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
140
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
141
+
142
+ /** As últimas estruturas e aberturas que saíram em vídeo, mais nova primeiro. */
143
+ function recentes(n = 3) {
144
+ return { estruturas: vari().usadas('estrutura').slice(0, n), aberturas: vari().usadas('abertura').slice(0, n) };
145
+ }
146
+
147
+ /** O cardápio em texto, para o prompt — com o que foi usado por último. */
148
+ function cardapioEstruturas({ usadas = recentes() } = {}) {
149
+ const linhas = ESTRUTURAS.map((e) => `- ${e.id} (${e.nome}): se ${e.quando}. Abre com ${e.abre}.`);
150
+ if (usadas.estruturas.length) linhas.push(`Usadas recentemente: ${usadas.estruturas.join(', ')} — prefira outra, a menos que o pedido peça essa.`);
151
+ if (usadas.aberturas.length) linhas.push(`Os últimos vídeos começaram assim: ${usadas.aberturas.map((a) => `"${a}"`).join(' · ')} — não comece do mesmo jeito.`);
152
+ linhas.push('Nenhuma serve? Invente a estrutura e dê um nome a ela.');
153
+ return 'CARDÁPIO DE ESTRUTURAS (você escolhe pelo pedido):\n' + linhas.join('\n');
154
+ }
155
+
156
+ /**
157
+ * Depois que o roteiro virou plano: guarda a estrutura que o modelo escolheu
158
+ * e como o vídeo abriu. Aceita o roteiro (partes/fala) ou o plano do diretor
159
+ * (cenas/narracao). Não julga a escolha — só lembra dela.
160
+ */
161
+ function registrarEstrutura(r) {
162
+ const x = r && typeof r === 'object' ? r : {};
163
+ const estrutura = idEstrutura(x.estrutura) || null;
164
+ const p0 = (x.partes || x.cenas || [])[0] || {};
165
+ const abertura = String(p0.fala || p0.narracao || p0.titulo || '').split(/\s+/).filter(Boolean).slice(0, 8).join(' ') || null;
166
+ if (estrutura) vari().marcar('estrutura', estrutura);
167
+ if (abertura) vari().marcar('abertura', abertura);
168
+ return { estrutura, abertura };
169
+ }
170
+
118
171
  // ── 3. o plano ───────────────────────────────────────────────────────────
119
172
 
120
- const CAMPOS_CENA = ['titulo', 'narracao', 'duracao', 'aTela', 'fundo', 'transicao',
173
+ const CAMPOS_CENA = ['titulo', 'falante', 'narracao', 'duracao', 'aTela', 'fundo', 'transicao',
121
174
  'elementos', 'midia', 'camera', 'chaves', 'notas'];
122
175
 
123
176
  /**
@@ -170,6 +223,7 @@ function normalizarPlano(bruto, { segundos } = {}) {
170
223
  tom: p.tom || 'neutro',
171
224
  publico: p.publico || null,
172
225
  promessa: p.promessa || null, // o que a pessoa leva do vídeo
226
+ estrutura: idEstrutura(p.estrutura) || null,
173
227
  segundos: total,
174
228
  voz: vozPara({ tom: p.tom, idioma: p.idioma || 'pt-BR',
175
229
  genero: p.voz && p.voz.genero }),
@@ -181,6 +235,9 @@ function normalizarPlano(bruto, { segundos } = {}) {
181
235
  midia: limpas.flatMap((c, i) => c.midia.map((m) => ({ cena: i + 1, ...m }))),
182
236
  creditos: !!p.creditos,
183
237
  };
238
+ // Duas vozes (falantes.js): o mapa e quem fala cada cena, quando o diretor escreveu diálogo.
239
+ const { vozes } = require('./falantes.js').normalizar({ vozes: p.vozes, partes: limpas });
240
+ if (vozes) plano.vozes = vozes;
184
241
  return { plano, defeitos, ok: defeitos.length === 0 };
185
242
  }
186
243
 
@@ -197,6 +254,7 @@ Devolva SÓ um bloco \`\`\`json com este formato, sem texto antes nem depois:
197
254
 
198
255
  {
199
256
  "titulo": "…",
257
+ "estrutura": "o id que você escolheu no cardápio abaixo (ou o nome da que inventou)",
200
258
  "formato": "${formato}",
201
259
  "proporcao": "16:9 | 9:16 | 1:1 | 4:5",
202
260
  "visual": "a direção de arte em uma ou duas frases: paleta (diga as cores em hex), que ar tem (limpo, denso, luminoso, sujo), que tipografia pede. NÃO existe lista de temas — descreva o que este vídeo tem de próprio.",
@@ -204,9 +262,11 @@ Devolva SÓ um bloco \`\`\`json com este formato, sem texto antes nem depois:
204
262
  "publico": "para quem é",
205
263
  "promessa": "o que a pessoa leva ao terminar de ver",
206
264
  "trilha": { "busca": "termo de busca da música", "humor": "…", "volume": 0.18 },
265
+ "vozes": { "nome de quem fala": "antonio | thalita | francisca" },
207
266
  "cenas": [
208
267
  {
209
268
  "titulo": "o que aparece grande na tela",
269
+ "falante": "quem fala esta cena (um nome de vozes) — só quando houver mais de uma voz",
210
270
  "narracao": "a frase FALADA, escrita para ser lida em voz alta",
211
271
  "duracao": 3.5,
212
272
  "aTela": "o que se vê nesta cena, descrito como você descreveria para um motion designer: onde está cada coisa, que tamanho tem, o que domina o quadro",
@@ -223,13 +283,16 @@ Devolva SÓ um bloco \`\`\`json com este formato, sem texto antes nem depois:
223
283
  REGRAS QUE SEPARAM UM VÍDEO BOM DE UM GENÉRICO:
224
284
  1. NARRAÇÃO É FALA, NÃO LEGENDA. "Vantagens" não é narração. Escreva a frase que a pessoa ouve.
225
285
  2. O QUE SE OUVE E O QUE SE VÊ NÃO SE REPETEM. A tela mostra a prova (número, imagem, logo); a voz conta o que ela significa.
226
- 3. ARCO. Primeira cena prende em 2 segundos, o miolo entrega, a última pede uma ação. Sem "Introdução / Desenvolvimento / Conclusão".
286
+ 3. ESTRUTURA. Escolha no cardápio do fim a que serve a ESTE pedido; a primeira cena abre do jeito dela e prende em 2 segundos, a última pede uma ação. Sem "Introdução / Desenvolvimento / Conclusão".
227
287
  4. NÚMERO É CONCRETO. Nada de "muitos clientes": ou tem o número, ou a frase muda.
228
288
  5. RITMO VARIADO. Cenas de durações diferentes e transições diferentes. Tudo em fade parece slide.
229
289
  5b. NADA DE GAVETA. Não existe tema pronto, layout pronto nem fundo pronto: é você que decide a cor, a tipografia e a composição DESTE vídeo. Dois vídeos seus sobre assuntos diferentes não podem sair parecidos.
230
290
  6. MÍDIA COM PROPÓSITO. Só peça imagem ou logo quando ela PROVA algo. Diga o termo de busca de verdade ("painel solar em telhado residencial", não "imagem legal").
231
291
  7. MOVIMENTO. Em cada cena diga em "chaves" o movimento principal — é ele que tira a cara de apresentação parada.
232
- 8. CÂMERA COM PARCIMÔNIA. Movimento de quadro em UMA cena a cada duas ou três ("camera": "nenhuma" nas outras): o que dá ritmo é alternar cena parada e cena que se move. Em cena com foto de fundo, prefira "aproximar" ou "empurrar".`;
292
+ 8. CÂMERA COM PARCIMÔNIA. Movimento de quadro em UMA cena a cada duas ou três ("camera": "nenhuma" nas outras): o que dá ritmo é alternar cena parada e cena que se move. Em cena com foto de fundo, prefira "aproximar" ou "empurrar".
293
+ 9. UMA VOZ OU DUAS. Se o pedido fala em dois narradores, duas vozes, diálogo, conversa ou entrevista, escreva como DIÁLOGO: dê nome a cada um em "vozes" (com vozes diferentes) e diga em cada cena quem fala ("falante"). Com uma voz só, deixe "vozes" e "falante" de fora.
294
+
295
+ ${cardapioEstruturas()}`;
233
296
  };
234
297
 
235
298
  const REVISAO = (defeitos) => `O plano voltou com defeitos de FORMA. Corrija SÓ o que está listado e devolva o JSON inteiro de novo, no mesmo formato:
@@ -289,9 +352,11 @@ function pauta(plano) {
289
352
  linhas.push(`PLANO DE PRODUÇÃO — ${p.titulo}`);
290
353
  linhas.push(`formato ${p.formato} · ${p.proporcao} · ${p.segundos}s · tom ${p.tom}`);
291
354
  if (p.visual) linhas.push(`visual: ${p.visual}`);
355
+ if (p.estrutura) linhas.push(`estrutura: ${p.estrutura} (mande em roteiro.estrutura)`);
292
356
  if (p.promessa) linhas.push(`promessa: ${p.promessa}`);
293
357
  if (p.publico) linhas.push(`público: ${p.publico}`);
294
358
  linhas.push(`voz: ${p.voz && p.voz.perfil} · ${p.voz && p.voz.instrucao} · ${p.voz && p.voz.idioma}`);
359
+ if (p.vozes) linhas.push(`vozes: ${Object.entries(p.vozes).map(([k, v]) => `${k} = ${v}`).join(', ')} (mande em roteiro.vozes, e falante em cada parte)`);
295
360
  if (p.trilha) linhas.push(`trilha: ${p.trilha.busca || p.trilha.arquivo} (volume ${p.trilha.volume})`);
296
361
  linhas.push('');
297
362
  (p.cenas || []).forEach((c, i) => {
@@ -299,7 +364,7 @@ function pauta(plano) {
299
364
  if (c.titulo) linhas.push(` na tela: ${c.titulo}`);
300
365
  if (c.aTela) linhas.push(` composição: ${c.aTela}`);
301
366
  if (c.fundo) linhas.push(` fundo: ${typeof c.fundo === 'string' ? c.fundo : JSON.stringify(c.fundo)}`);
302
- linhas.push(` narração: ${c.narracao}`);
367
+ linhas.push(` narração${c.falante ? ` (${c.falante})` : ''}: ${c.narracao}`);
303
368
  if (c.camera && c.camera !== 'nenhuma') linhas.push(` câmera: ${c.camera}`);
304
369
  if (c.chaves) linhas.push(` movimento: ${c.chaves}`);
305
370
  (c.midia || []).forEach((m) => linhas.push(
@@ -314,6 +379,7 @@ function pauta(plano) {
314
379
 
315
380
  module.exports = {
316
381
  precisaDeProducao, precisaDeEdicao, vozPara, ritmoPara,
382
+ ESTRUTURAS, recentes, cardapioEstruturas, registrarEstrutura,
317
383
  normalizarPlano, extrairJSON, planejar, pauta,
318
384
  DIRETOR, REVISAO,
319
385
  };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * efeitos.js — o efeito que a IA escreveu num vídeo fica para os próximos.
3
+ *
4
+ * "Quero mais animações, mais efeitos, e que possa criar tudo isso
5
+ * também." (Joel, 09/10/2026)
6
+ *
7
+ * O `componente` (plano.js) é a válvula: quando nenhuma primitiva alcança, a
8
+ * IA escreve um arquivo React na pasta do projeto. Só que ele morria com o
9
+ * projeto — o efeito bonito do vídeo de terça não existia para o de quarta,
10
+ * e a IA reescrevia do zero (ou pior, nem lembrava que tinha feito).
11
+ *
12
+ * Aqui o componente que ficou bom é GUARDADO em ~/.primocode/studio/efeitos/
13
+ * com nome e descrição, e volta como cardápio: os menos usados primeiro,
14
+ * pela mesma memória de variedade das trilhas e dos ícones (variedade.js,
15
+ * tipo 'efeito'). Guardar e reusar são escolhas do modelo; nada entra no
16
+ * vídeo sozinho.
17
+ *
18
+ * Antes de guardar, a fonte é conferida: o efeito vai rodar em OUTROS
19
+ * projetos, então import relativo (o arquivo vizinho não vai junto),
20
+ * Math.random/Date.now (cada render sai diferente) e timer (quadro
21
+ * repetido) são recusados com o motivo.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+ const variedade = require('./variedade.js');
30
+
31
+ const pasta = () => path.join(os.homedir(), '.primocode', 'studio', 'efeitos');
32
+ const catalogoArq = () => path.join(pasta(), 'catalogo.json');
33
+ const PACOTES = /^(react|react\/jsx-runtime|remotion|@remotion\/[a-z-]+|three|three\/examples\/jsm\/.+|@react-three\/fiber)$/;
34
+ const TETO = 150 * 1024;
35
+
36
+ function ler() {
37
+ try { const c = JSON.parse(fs.readFileSync(catalogoArq(), 'utf8')); return Array.isArray(c) ? c : []; } catch { return []; }
38
+ }
39
+
40
+ /** O que impede este código de rodar em outro projeto. [] = pode guardar. */
41
+ function conferirFonte(codigo) {
42
+ const falta = [];
43
+ const sem = String(codigo || '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
44
+ if (!/export\s+(default|function|const)/.test(sem)) falta.push('o arquivo não exporta o componente (export default function …)');
45
+ for (const m of sem.matchAll(/(?:from\s+|import\s*\(\s*|require\s*\(\s*)['"]([^'"]+)['"]/g)) {
46
+ if (!PACOTES.test(m[1])) falta.push(`import "${m[1]}": só react, remotion, @remotion/*, three e @react-three/fiber — o arquivo vai sozinho para outro projeto`);
47
+ }
48
+ if (/Math\.random|Date\.now/.test(sem)) falta.push('Math.random/Date.now: cada render sairia diferente — use random("semente") do Remotion');
49
+ if (/setTimeout|setInterval|requestAnimationFrame/.test(sem)) falta.push('timer não existe no render quadro a quadro — anime pelo quadro (useCurrentFrame ou a prop t)');
50
+ return falta;
51
+ }
52
+
53
+ /** Guarda o componente do projeto no catálogo, com nome e descrição. */
54
+ function guardar({ pasta: proj, arquivo, nome, descricao, tags = [] } = {}) {
55
+ const slug = String(nome || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
56
+ if (slug.length < 2) return { ok: false, error: 'dê um nome ao efeito (nome), como "vidro-quebrando".' };
57
+ if (!String(descricao || '').trim()) return { ok: false, error: 'diga o que o efeito faz (descricao) — é o que você vai ler no cardápio depois.' };
58
+ if (!proj || !arquivo) return { ok: false, error: 'diga o projeto (id) e o arquivo do componente (arquivo).' };
59
+ const cheio = path.resolve(proj, arquivo);
60
+ if (!cheio.startsWith(path.resolve(proj) + path.sep) || !/\.(jsx|js|tsx)$/.test(cheio)) return { ok: false, error: 'o arquivo é um .jsx/.js dentro da pasta do projeto.' };
61
+ if (!fs.existsSync(cheio)) return { ok: false, error: `o arquivo "${arquivo}" não existe no projeto.` };
62
+ const codigo = fs.readFileSync(cheio, 'utf8');
63
+ if (codigo.length > TETO) return { ok: false, error: 'o componente passa de 150 KB — guarde a parte que é o efeito.' };
64
+ const falta = conferirFonte(codigo);
65
+ if (falta.length) return { ok: false, error: 'não guardei: ' + falta.join('; ') };
66
+ fs.mkdirSync(pasta(), { recursive: true });
67
+ fs.writeFileSync(path.join(pasta(), slug + '.jsx'), codigo);
68
+ const item = { nome: slug, descricao: String(descricao).trim().slice(0, 300), tags: [].concat(tags || []).map(String).slice(0, 8), guardado: new Date().toISOString().slice(0, 10) };
69
+ fs.writeFileSync(catalogoArq(), JSON.stringify([item, ...ler().filter((x) => x.nome !== slug)], null, 2));
70
+ return { ok: true, ...item };
71
+ }
72
+
73
+ /** O cardápio: os guardados, os menos usados primeiro (memória da variedade). */
74
+ function cardapio({ aleatorio = Math.random } = {}) {
75
+ const itens = ler().filter((x) => fs.existsSync(path.join(pasta(), x.nome + '.jsx')));
76
+ return variedade.pontuar(itens, 'efeito', { chave: (x) => x.nome })
77
+ .map((p) => ({ ...p, pontos: p.pontos + aleatorio() * 0.05 }))
78
+ .sort((a, b) => b.pontos - a.pontos)
79
+ .map((p) => ({ nome: p.opcao.nome, descricao: p.opcao.descricao, tags: p.opcao.tags }));
80
+ }
81
+
82
+ /** Copia o efeito para o projeto e devolve o elemento pronto. Marca o uso. */
83
+ function reusar({ pasta: proj, nome } = {}) {
84
+ const item = ler().find((x) => x.nome === nome);
85
+ const de = item && path.join(pasta(), item.nome + '.jsx');
86
+ if (!item || !fs.existsSync(de)) return { ok: false, error: `não há efeito "${nome}" guardado — veja o cardápio (acao: efeitos).` };
87
+ if (!proj) return { ok: false, error: 'diga o projeto (id).' };
88
+ const src = `efeitos/${item.nome}.jsx`;
89
+ fs.mkdirSync(path.join(proj, 'efeitos'), { recursive: true });
90
+ fs.copyFileSync(de, path.join(proj, src));
91
+ variedade.marcar('efeito', item.nome);
92
+ return { ok: true, src, descricao: item.descricao, elemento: { tipo: 'componente', src, x: 0, y: 0 } };
93
+ }
94
+
95
+ module.exports = { guardar, cardapio, reusar, conferirFonte, pasta };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * falantes.js — quem fala cada parte do roteiro.
3
+ *
4
+ * "fiz um teste de um vídeo com duas narrações e ele fez de uma narração
5
+ * só, uma voz só" (Joel, 09/10)
6
+ *
7
+ * O roteiro tinha UMA voz (roteiro.voz) e nenhum lugar para dizer quem fala o
8
+ * quê: pedir diálogo ou dois narradores não tinha como chegar na narração. O
9
+ * contrato agora tem dois campos, os dois opcionais:
10
+ * roteiro.vozes = { "ana": "thalita", "bruno": "antonio" }
11
+ * parte.falante = "ana"
12
+ * Sem eles, nada muda: roteiro.voz segue valendo para tudo. Quem decide se o
13
+ * vídeo é diálogo é o modelo, pelo pedido; aqui só se lê o que ele escreveu.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ // As vozes do Studio (thalita | antonio | francisca). Para quem ficou sem voz no mapa.
19
+ const PAR = ['antonio', 'thalita', 'francisca'];
20
+
21
+ const nomeFalante = (x) => (typeof x === 'string' ? x.trim().toLowerCase().slice(0, 40) : '');
22
+
23
+ /** Limpa vozes e falantes do roteiro (o que não for texto sai). Devolve o mesmo objeto. */
24
+ function normalizar(roteiro) {
25
+ const r = roteiro && typeof roteiro === 'object' ? roteiro : {};
26
+ if (r.vozes && typeof r.vozes === 'object' && !Array.isArray(r.vozes)) {
27
+ const limpo = {};
28
+ for (const [k, v] of Object.entries(r.vozes)) if (nomeFalante(k) && typeof v === 'string' && v.trim()) limpo[nomeFalante(k)] = v.trim();
29
+ if (Object.keys(limpo).length) r.vozes = limpo; else delete r.vozes;
30
+ } else delete r.vozes;
31
+ for (const pt of Array.isArray(r.partes) ? r.partes : []) {
32
+ if (!pt || typeof pt !== 'object' || !('falante' in pt)) continue;
33
+ if (nomeFalante(pt.falante)) pt.falante = nomeFalante(pt.falante); else delete pt.falante;
34
+ }
35
+ return r;
36
+ }
37
+
38
+ /**
39
+ * A voz de cada parte, na ordem. Com falante e voz no mapa, a do mapa; com
40
+ * falante FORA do mapa (o modelo deu nome e esqueceu a voz), a próxima do par
41
+ * que ainda não fala — dois falantes nunca saem com a mesma voz por esquecimento;
42
+ * sem falante, a `padrao` (roteiro.voz).
43
+ */
44
+ function vozesDasPartes(partes, roteiro = {}, padrao = '') {
45
+ const mapa = { ...((roteiro && roteiro.vozes) || {}) };
46
+ const ocupadas = new Set(Object.values(mapa).map((v) => String(v).toLowerCase()));
47
+ if (padrao) ocupadas.add(String(padrao).toLowerCase());
48
+ return (partes || []).map((pt) => {
49
+ const f = nomeFalante(pt && pt.falante);
50
+ if (!f) return padrao || '';
51
+ if (!mapa[f]) {
52
+ mapa[f] = PAR.find((v) => !ocupadas.has(v)) || PAR[Object.keys(mapa).length % PAR.length];
53
+ ocupadas.add(mapa[f]);
54
+ }
55
+ return mapa[f];
56
+ });
57
+ }
58
+
59
+ /**
60
+ * A narração por falante: as falas seguidas da mesma voz viram um grupo, cada
61
+ * grupo é narrado na sua voz (narrarUm — a mesma corrente Gemini → Kokoro →
62
+ * Edge de sempre), e os mp3 são juntados na ordem (juntar). Os tempos de cada
63
+ * parte andam pela duração dos grupos de antes: o vídeo segue a voz certa.
64
+ * Uma voz só: um grupo só, e é exatamente a narração de antes.
65
+ *
66
+ * @param {Array<{texto:string, voz?:string}>} lista
67
+ * @param {(lista, mp3, voz) => Promise<{ok, segundos, partes, voz}>} op.narrarUm
68
+ * @param {(arquivos, mp3) => Promise<{ok, duracoes?:number[]}>} op.juntar
69
+ */
70
+ async function narrarPorFalante(lista, mp3, { narrarUm, juntar }) {
71
+ const grupos = [];
72
+ for (const { voz = '', ...x } of lista) {
73
+ const g = grupos[grupos.length - 1];
74
+ if (g && g.voz === voz) g.itens.push(x); else grupos.push({ voz, itens: [x] });
75
+ }
76
+ if (grupos.length <= 1) return narrarUm(grupos.length ? grupos[0].itens : [], mp3, grupos.length ? grupos[0].voz : '');
77
+ const feitos = [];
78
+ for (const [i, g] of grupos.entries()) {
79
+ const arq = mp3.replace(/\.mp3$/, '') + `-falante${i + 1}.mp3`;
80
+ const v = await narrarUm(g.itens, arq, g.voz);
81
+ if (!v || !v.ok) return { ok: false, error: `a voz ${g.voz || 'padrão'} falhou: ${(v && v.error) || 'sem resposta'}` };
82
+ feitos.push({ arq, v });
83
+ }
84
+ const j = await juntar(feitos.map((x) => x.arq), mp3);
85
+ if (!j || !j.ok) return { ok: false, error: (j && j.error) || 'não juntei as vozes' };
86
+ let antes = 0;
87
+ const partes = [];
88
+ feitos.forEach(({ v }, i) => {
89
+ const mais = (t) => Math.round((t + antes) * 1000) / 1000;
90
+ for (const pt of v.partes || []) partes.push({ ...pt, de: mais(pt.de), ate: mais(pt.ate), palavras: (pt.palavras || []).map((w) => ({ ...w, de: mais(w.de), ate: mais(w.ate) })) });
91
+ // A duração MEDIDA do arquivo quando quem juntou mediu; senão a que a voz disse.
92
+ antes += Number((j.duracoes || [])[i]) || Number(v.segundos) || 0;
93
+ });
94
+ return { ok: true, voz: [...new Set(feitos.map((x) => x.v.voz).filter(Boolean))].join(' + '), segundos: Math.round(antes * 1000) / 1000, partes, falantes: grupos.length };
95
+ }
96
+
97
+ module.exports = { normalizar, vozesDasPartes, narrarPorFalante, PAR };