primocode 9.8.0-beta.10 → 9.8.0-beta.12

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.10
1
+ # PrimoCode v9.8.0-beta.12
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.10
12
+ ▐▛███▜▌ Primo Code v9.8.0-beta.12
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/lib/catalogo.js CHANGED
@@ -104,7 +104,10 @@ const GRUPOS = [
104
104
  + 'Use os tempos que voltam para a duração das cenas — estimar por número de palavras erra em segundos.' },
105
105
 
106
106
  { tool: 'estudio_video', escreve: true, faz: 'o que se faz com a gravação do usuário: importar, medir, cortar silêncio, transcrever, recortar a pessoa',
107
- args: 'id. acao: importar | medir | silencio | cortar-silencio | quadro | legendas | recortar-pessoa. '
107
+ args: 'id. acao: editar | importar | medir | silencio | cortar-silencio | quadro | legendas | recortar-pessoa. '
108
+ + 'editar: FAZ A EDIÇÃO INTEIRA sozinho — corta o silêncio, transcreve palavra por palavra e monta o plano '
109
+ + '(legenda animada, ícone na hora da palavra, zoom de corte, pop e whoosh). estilo: viral (padrão) | limpo. '
110
+ + 'Com gravação do usuário, é o PRIMEIRO passo depois de importar; depois só estudio_render. '
108
111
  + 'arquivo: caminho (em "importar", de fora; nas outras, o relativo dentro do projeto). '
109
112
  + 'cortar-silencio: minimoParaCortar (padrão 0.6s), respiro (padrão 0.18s) — os dois existem para o corte não soar robótico. '
110
113
  + 'legendas: devolve os trechos com tempo, para virarem elemento "legenda". '
@@ -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,401 @@
1
+ /**
2
+ * edicao.js — a gravação da pessoa vira um vídeo editado, SEM o modelo
3
+ * escrever cena por cena.
4
+ *
5
+ * "Deixe iguais aqueles vídeos que as videomakers editam: aquelas legendas
6
+ * maravilhosas que fazem parte do vídeo, alguns ícones aparecendo, cortes
7
+ * naturais, mais animações… Quero que até uma batata faça vídeos assim."
8
+ * (25/09/2026)
9
+ *
10
+ * Com o Claude o vídeo saiu ótimo porque o modelo escreveu, à mão, cada
11
+ * legenda, cada palavra em destaque e cada zoom. Um modelo grátis não faz
12
+ * isso. Então a edição mora AQUI, em código: com as palavras e o tempo de
13
+ * cada uma, `montarEdicao` devolve o plano inteiro —
14
+ *
15
+ * - uma cena por frase, com o corte seco entre elas e o zoom alternando
16
+ * (o "punch-in" que dá ritmo a quem fala para a câmera);
17
+ * - a legenda palavra por palavra, com a palavra dita em destaque e a
18
+ * palavra forte da frase na cor dela;
19
+ * - o ícone (emoji colorido) que salta na hora em que a palavra é dita;
20
+ * - o letreiro grande da ideia, nas frases sem ícone;
21
+ * - o pop e o whoosh nos momentos certos, e a barra de progresso.
22
+ *
23
+ * É uma função pura: entra palavra, sai plano. O modelo só escolhe o estilo,
24
+ * e pode ajustar qualquer cena depois com estudio_plano.
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ // ── as palavras que não carregam ideia ───────────────────────────────────
30
+ const VAZIAS = new Set((
31
+ 'a o as os um uma uns umas de do da dos das no na nos nas em por para pra pro com sem que se e '
32
+ + 'ou mas como mais menos muito muita muitos muitas isso isto esse essa este esta aquele aquela ele ela '
33
+ + 'eles elas eu voce voces nos meu minha seu sua nosso nossa tem ter ser foi era sao esta estao vai vou '
34
+ + 'ja ai aqui la entao tambem so quando onde porque pois ate sobre depois antes cada todo toda todos '
35
+ + 'todas qual quais quem nao sim ne ta tipo coisa coisas gente fazer faz feito pode podem vamos hoje'
36
+ ).split(' '));
37
+
38
+ /* A palavra (sem acento, minúscula, começo dela) → o emoji colorido. Casa
39
+ pelo começo para "aprender", "aprende" e "aprendizado" caírem no mesmo. Os
40
+ nomes foram conferidos na coleção fluent-emoji-flat do Iconify (25/09). */
41
+ const ICONES = [
42
+ [['inteligencia', 'ia', 'robo', 'maquina', 'automac'], 'robot'],
43
+ [['cerebro', 'pens', 'raciocin', 'aprend', 'mente'], 'brain'],
44
+ [['ideia', 'criativ', 'inova'], 'light-bulb'],
45
+ [['dinheiro', 'grana', 'reais', 'lucro', 'vend', 'fatur', 'ganh', 'pagar', 'preco'], 'money-bag'],
46
+ [['tempo', 'minuto', 'segundo', 'rapid', 'hora'], 'stopwatch'],
47
+ [['cresc', 'result', 'aument', 'melhor'], 'chart-increasing'],
48
+ [['cair', 'caiu', 'perd', 'queda', 'pior'], 'chart-decreasing'],
49
+ [['dados', 'numero', 'grafico', 'estatist', 'parametro'], 'bar-chart'],
50
+ [['foguete', 'lanc', 'decol', 'comec'], 'rocket'],
51
+ [['incrivel', 'fogo', 'bomba'], 'fire'],
52
+ [['certo', 'funcion', 'corret', 'pronto'], 'check-mark-button'],
53
+ [['errado', 'erro', 'falha', 'proibid'], 'cross-mark'],
54
+ [['celular', 'telefone', 'aplicativo', 'app'], 'mobile-phone'],
55
+ [['computador', 'codigo', 'program', 'software', 'site'], 'laptop'],
56
+ [['internet', 'mundo', 'global'], 'globe-showing-americas'],
57
+ [['olh', 'ver', 'imagem', 'imagens', 'visual', 'reconhec'], 'eyes'],
58
+ [['voz', 'som', 'sons', 'audio', 'ouv'], 'speaker-high-volume'],
59
+ [['texto', 'escrev', 'palavra', 'lista'], 'memo'],
60
+ [['musica', 'cancao'], 'musical-notes'],
61
+ [['cachorro', 'cao'], 'dog-face'],
62
+ [['gato'], 'cat-face'],
63
+ [['coracao', 'amor', 'ama', 'paixao'], 'red-heart'],
64
+ [['pessoa', 'cliente', 'usuario', 'equipe', 'publico'], 'busts-in-silhouette'],
65
+ [['casa', 'lar'], 'house'],
66
+ [['carro', 'dirig'], 'automobile'],
67
+ [['livro', 'estud', 'escola', 'ensin', 'aula', 'curso'], 'books'],
68
+ [['atencao', 'cuidado', 'perigo', 'risco'], 'warning'],
69
+ [['objeto', 'produto', 'pacote', 'entrega'], 'package'],
70
+ [['padr', 'peca', 'encaix'], 'puzzle-piece'],
71
+ [['decid', 'decis', 'escolh', 'duvida', 'pergunta'], 'thinking-face'],
72
+ [['exemplo', 'procur', 'busca', 'pesquis', 'analis'], 'magnifying-glass-tilted-left'],
73
+ [['assistente', 'convers', 'chat', 'mensag', 'responde'], 'speech-balloon'],
74
+ [['recomend', 'filme', 'serie', 'netflix', 'video'], 'clapper-board'],
75
+ [['tecnolog', 'algoritm', 'sistema', 'ferrament'], 'gear'],
76
+ [['cotidian', 'rotina', 'semana', 'calendario', 'agenda'], 'calendar'],
77
+ [['conhecimento', 'formad', 'diploma', 'sabe'], 'graduation-cap'],
78
+ [['sucesso', 'venc', 'ganhador', 'premio', 'campeao'], 'trophy'],
79
+ [['forca', 'forte', 'poder', 'energia'], 'flexed-biceps'],
80
+ [['acordo', 'parceri', 'junt', 'ajud'], 'handshake'],
81
+ [['compra', 'loja', 'carrinho'], 'shopping-cart'],
82
+ [['trabalh', 'empresa', 'negocio', 'emprego'], 'briefcase'],
83
+ [['seguranc', 'senha', 'proteg', 'privad'], 'locked'],
84
+ [['festa', 'comemor', 'parabens'], 'party-popper'],
85
+ [['surpre', 'chocad', 'absurd'], 'exploding-head'],
86
+ ];
87
+
88
+ /* O termo de busca da IMAGEM DE APOIO, pelo emoji que a palavra ganhou. Em
89
+ inglês de propósito: os acervos abertos respondem muito melhor assim. */
90
+ const FOTO = {
91
+ robot: 'humanoid robot', brain: 'human brain', 'light-bulb': 'light bulb idea', 'money-bag': 'money cash',
92
+ stopwatch: 'clock time', 'chart-increasing': 'business growth', 'chart-decreasing': 'stock market fall',
93
+ 'bar-chart': 'data analytics', rocket: 'rocket launch', fire: 'fire flames', 'mobile-phone': 'smartphone hand',
94
+ laptop: 'laptop programming', 'globe-showing-americas': 'earth from space', eyes: 'eye closeup',
95
+ 'speaker-high-volume': 'sound waves', memo: 'writing notebook', 'musical-notes': 'music concert',
96
+ 'dog-face': 'puppy', 'cat-face': 'cat', 'red-heart': 'heart love', 'busts-in-silhouette': 'people crowd',
97
+ house: 'modern house', automobile: 'car road', books: 'library books', warning: 'warning sign',
98
+ package: 'objects on table', 'puzzle-piece': 'puzzle pieces', 'thinking-face': 'person thinking',
99
+ 'magnifying-glass-tilted-left': 'magnifying glass', 'speech-balloon': 'smart speaker', 'clapper-board': 'watching tv',
100
+ gear: 'circuit board', calendar: 'city street people', 'graduation-cap': 'students classroom', trophy: 'trophy',
101
+ 'flexed-biceps': 'strength', handshake: 'handshake', 'shopping-cart': 'shopping', briefcase: 'office work',
102
+ locked: 'cyber security', 'party-popper': 'party celebration', 'exploding-head': 'surprised face',
103
+ };
104
+
105
+ function limpa(palavra) {
106
+ return String(palavra || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
107
+ .toLowerCase().replace(/[^a-z0-9]/g, '');
108
+ }
109
+
110
+ /** O emoji desta palavra, ou null. Palavra de 2 letras só casa inteira ("ia"). */
111
+ function iconeDe(palavra) {
112
+ const w = limpa(palavra);
113
+ if (!w || VAZIAS.has(w)) return null;
114
+ for (const [raizes, nome] of ICONES) {
115
+ for (const r of raizes) {
116
+ if (r.length <= 3 ? w === r : w.startsWith(r)) return 'fluent-emoji-flat:' + nome;
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+
122
+ /** Quanto a palavra carrega a ideia da frase. 0 = nada. */
123
+ function peso(palavra) {
124
+ const w = limpa(palavra);
125
+ if (!w || VAZIAS.has(w)) return 0;
126
+ if (/^\d+$/.test(w)) return 20;
127
+ return w.length + (iconeDe(palavra) ? 6 : 0);
128
+ }
129
+
130
+ /** O termo de busca da imagem de apoio para esta palavra, ou null. */
131
+ function termoDeApoio(palavra) {
132
+ const ic = iconeDe(palavra);
133
+ return ic ? FOTO[ic.split(':')[1]] || null : null;
134
+ }
135
+
136
+ // Em quais cenas entra imagem de apoio: uma a cada três, a partir da segunda.
137
+ const temApoio = (i) => i % 3 === 2;
138
+
139
+ function forteDe(g) {
140
+ let forte = null;
141
+ for (const p of g) if (peso(p.texto) > (forte ? peso(forte.texto) : 0)) forte = p;
142
+ return forte;
143
+ }
144
+
145
+ /** Os termos das imagens de apoio que o plano vai pedir — para baixar antes. */
146
+ function termosDeApoio(palavras) {
147
+ const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
148
+ const termos = [];
149
+ frases(lista).forEach((g, i) => {
150
+ const f = forteDe(g);
151
+ const t = f && temApoio(i) ? termoDeApoio(f.texto) : null;
152
+ if (t && !termos.includes(t)) termos.push(t);
153
+ });
154
+ return termos;
155
+ }
156
+
157
+ const fimDeFrase = (texto) => /[.!?…]$/.test(String(texto || '').trim());
158
+ const r2 = (n) => Math.round(n * 100) / 100;
159
+
160
+ /* As frases: corta no ponto final, na pausa longa ou quando a cena passa do
161
+ teto. Frase curta demais gruda na próxima — corte a cada meio segundo é
162
+ epilepsia, não ritmo. */
163
+ function frases(palavras, { teto = 5.5, pausa = 0.5, minimo = 1.4 } = {}) {
164
+ const grupos = [];
165
+ let atual = [];
166
+ palavras.forEach((p, i) => {
167
+ atual.push(p);
168
+ const prox = palavras[i + 1];
169
+ const dur = p.ate - atual[0].de;
170
+ if (!prox || fimDeFrase(p.texto) || prox.de - p.ate >= pausa || dur >= teto) {
171
+ grupos.push(atual);
172
+ atual = [];
173
+ }
174
+ });
175
+ const juntos = [];
176
+ for (const g of grupos) {
177
+ const ult = juntos[juntos.length - 1];
178
+ const durG = g[g.length - 1].ate - g[0].de;
179
+ if (ult && (durG < minimo || ult[ult.length - 1].ate - ult[0].de < minimo)
180
+ && g[g.length - 1].ate - ult[0].de <= teto + 1.5) ult.push(...g);
181
+ else juntos.push([...g]);
182
+ }
183
+ return juntos;
184
+ }
185
+
186
+ /* Os pedaços da legenda: poucas palavras por vez (quem assiste no celular lê
187
+ em blocos curtos), quebrando na pontuação e na pausa. */
188
+ function blocos(palavras, maximo) {
189
+ const out = [];
190
+ let atual = [];
191
+ palavras.forEach((p, i) => {
192
+ atual.push(p);
193
+ const prox = palavras[i + 1];
194
+ if (!prox || atual.length >= maximo || /[,.!?;:…]$/.test(p.texto) || prox.de - p.ate > 0.3) {
195
+ out.push(atual);
196
+ atual = [];
197
+ }
198
+ });
199
+ return out;
200
+ }
201
+
202
+ // O zoom de cada cena, em rodízio: parado, punch-in, empurrando devagar.
203
+ function camera(i, dur) {
204
+ const tipo = i % 3;
205
+ if (tipo === 0) return { zoom: [{ t: 0, v: 1 }, { t: dur, v: 1.04, e: 'linear' }] };
206
+ if (tipo === 1) return { zoom: [{ t: 0, v: 1.18 }, { t: dur, v: 1.21, e: 'linear' }] };
207
+ return { zoom: [{ t: 0, v: 1.07 }, { t: dur, v: 1.13, e: 'suave' }] };
208
+ }
209
+
210
+ const ESTILOS = {
211
+ viral: { icones: true, letreiro: true, sons: true, barra: true, apoio: true, destaque: '#FFD400', forte: '#00E0A4' },
212
+ limpo: { icones: false, letreiro: false, sons: false, barra: false, apoio: true, destaque: '#FFFFFF', forte: '#FFD400' },
213
+ };
214
+
215
+ /**
216
+ * O plano do vídeo editado.
217
+ *
218
+ * @param {object} op
219
+ * @param {string} op.src o vídeo (já sem os silêncios), relativo à pasta do projeto
220
+ * @param {number} op.duracao a duração dele, em segundos
221
+ * @param {Array} op.palavras [{de, ate, texto}] no tempo DESTE vídeo
222
+ * @param {number} op.largura do vídeo de origem
223
+ * @param {number} op.altura
224
+ * @param {string} op.estilo 'viral' (padrão) | 'limpo'
225
+ * @param {object} op.sons { pop, whoosh } — caminhos na pasta do projeto, opcionais
226
+ * @param {Set} op.iconesOk os nomes de ícone que existem de verdade (sem isso, todos valem)
227
+ */
228
+ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null, imagens = {}, trilha = null }) {
229
+ const e = ESTILOS[estilo] || ESTILOS.viral;
230
+ const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
231
+ if (!lista.length) return { ok: false, error: 'a gravação não tem fala transcrita para editar.' };
232
+
233
+ const deitado = largura > altura;
234
+ const base = Math.min(largura, altura);
235
+ const grupos = frases(lista);
236
+ const total = duracao || lista[lista.length - 1].ate + 0.3;
237
+ const cenas = [];
238
+ const efeitos = [];
239
+ let lado = 0;
240
+
241
+ grupos.forEach((g, i) => {
242
+ const ini = i === 0 ? 0 : grupos[i - 1][grupos[i - 1].length - 1].ate
243
+ + (g[0].de - grupos[i - 1][grupos[i - 1].length - 1].ate) / 2;
244
+ const fim = i === grupos.length - 1 ? total : g[g.length - 1].ate + (grupos[i + 1][0].de - g[g.length - 1].ate) / 2;
245
+ const dur = r2(Math.max(0.4, fim - ini));
246
+ const local = (t) => r2(Math.max(0, t - ini));
247
+
248
+ // A palavra forte da frase: a que mais carrega a ideia.
249
+ const forte = forteDe(g);
250
+
251
+ const elementos = [{
252
+ tipo: 'video', src, x: 0, y: 0, largura: '100%', altura: '100%',
253
+ deOrigem: r2(ini), ateOrigem: r2(ini + dur), volume: 1,
254
+ }];
255
+
256
+ /* A IMAGEM DE APOIO: na hora da palavra forte, a tela vira a imagem do
257
+ que está sendo dito, com um zoom lento, e a legenda continua por
258
+ cima. É o "b-roll" das edições de videomaker. */
259
+ const apoio = forte && e.apoio && temApoio(i) ? imagens[termoDeApoio(forte.texto)] : null;
260
+ if (apoio) {
261
+ const de = local(forte.de - 0.1);
262
+ const vida = Math.min(2.2, dur - de);
263
+ if (vida > 0.8) {
264
+ elementos.push({
265
+ tipo: 'imagem', src: apoio, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
266
+ de, ate: r2(de + vida),
267
+ anima: {
268
+ escala: [{ t: 0, v: 1.12 }, { t: vida, v: 1.0, e: 'suave' }],
269
+ opacidade: [{ t: 0, v: 0 }, { t: 0.12, v: 1 }, { t: vida - 0.15, v: 1 }, { t: vida, v: 0 }],
270
+ },
271
+ });
272
+ if (e.sons && sons.whoosh) efeitos.push({ t: r2(Math.max(0, ini + de - 0.1)), src: sons.whoosh, volume: 0.55 });
273
+ }
274
+ }
275
+
276
+ // O ícone salta na hora da palavra; sem ícone, o letreiro da ideia.
277
+ const icone = forte && e.icones && !apoio ? iconeDe(forte.texto) : null;
278
+ const temIcone = icone && (!iconesOk || iconesOk.has(icone));
279
+ const x = lado++ % 2 === 0 ? '78%' : '22%';
280
+ if (temIcone) {
281
+ const de = local(forte.de - 0.05);
282
+ const vida = Math.min(1.8, dur - de);
283
+ if (vida > 0.5) {
284
+ elementos.push({
285
+ tipo: 'icone', nome: icone, tamanho: Math.round(base * (deitado ? 0.2 : 0.24)),
286
+ x, y: deitado ? '30%' : '26%', ancora: 'centro', de, ate: r2(de + vida),
287
+ sombra: undefined,
288
+ anima: {
289
+ escala: [{ t: 0, v: 0 }, { t: 0.2, v: 1.18, e: 'saida' }, { t: 0.34, v: 1, e: 'suave' }],
290
+ rotacao: [{ t: 0, v: -14 }, { t: 0.34, v: 0, e: 'saida' }, { t: vida, v: 4, e: 'suave' }],
291
+ y: [{ t: 0.34, v: 0 }, { t: vida, v: -14, e: 'suave' }],
292
+ opacidade: [{ t: vida - 0.2, v: 1 }, { t: vida, v: 0 }],
293
+ },
294
+ });
295
+ if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.8 });
296
+ }
297
+ } else if (forte && e.letreiro && i % 2 === 0 && peso(forte.texto) >= 7) {
298
+ const de = local(forte.de);
299
+ const vida = Math.min(1.6, dur - de);
300
+ if (vida > 0.5) {
301
+ elementos.push({
302
+ tipo: 'texto', texto: limpaPontuacao(forte.texto).toUpperCase(), fonte: 'Anton',
303
+ tamanho: Math.round(base * (deitado ? 0.11 : 0.1)), cor: e.forte, peso: 400,
304
+ x, y: deitado ? '28%' : '24%', ancora: 'centro', de, ate: r2(de + vida),
305
+ anima: {
306
+ escala: [{ t: 0, v: 0.4 }, { t: 0.16, v: 1.12, e: 'saida' }, { t: 0.3, v: 1, e: 'suave' }],
307
+ opacidade: [{ t: 0, v: 0 }, { t: 0.1, v: 1 }, { t: vida - 0.18, v: 1 }, { t: vida, v: 0 }],
308
+ rotacao: [{ t: 0, v: -6 }, { t: 0.3, v: -3, e: 'saida' }],
309
+ },
310
+ });
311
+ if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.7 });
312
+ }
313
+ }
314
+
315
+ // A legenda palavra por palavra.
316
+ const fala = blocos(g, 3).map((b, j, todos) => ({
317
+ de: local(b[0].de - 0.04),
318
+ ate: j < todos.length - 1 ? local(todos[j + 1][0].de - 0.04) : r2(dur),
319
+ texto: b.map((p) => p.texto).join(' '),
320
+ palavras: b.map((p) => ({
321
+ de: local(p.de), ate: local(p.ate), texto: limpaPontuacao(p.texto),
322
+ ...(p === forte ? { forte: true } : {}),
323
+ })),
324
+ }));
325
+ elementos.push({
326
+ tipo: 'legenda', x: '50%', y: deitado ? '76%' : '68%', ancora: 'centro', fala,
327
+ estilo: {
328
+ fonte: 'Montserrat', peso: 900, tamanho: Math.round(base * (deitado ? 0.078 : 0.072)),
329
+ destaque: e.destaque, corForte: e.forte, caixa: 'uppercase', largura: '92%',
330
+ },
331
+ });
332
+
333
+ if (e.barra) {
334
+ elementos.push({
335
+ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, altura: Math.max(6, Math.round(base * 0.008)),
336
+ largura: '100%', cor: e.destaque,
337
+ anima: { escalaX: [{ t: 0, v: r2(ini / total) }, { t: dur, v: r2((ini + dur) / total), e: 'linear' }] },
338
+ origem: 'left',
339
+ });
340
+ }
341
+
342
+ // O whoosh entra no punch-in: é o corte que ele anuncia.
343
+ if (e.sons && sons.whoosh && i > 0 && i % 3 === 1) efeitos.push({ t: r2(Math.max(0, ini - 0.12)), src: sons.whoosh, volume: 0.6 });
344
+
345
+ cenas.push({ duracao: dur, camera: camera(i, dur), elementos, transicao: { tipo: 'corte' } });
346
+ });
347
+
348
+ return {
349
+ ok: true,
350
+ plano: {
351
+ formato: { largura, altura, fps: 30 },
352
+ fundoGeral: '#000',
353
+ cenas,
354
+ ...(efeitos.length || trilha ? { audio: {
355
+ ...(efeitos.length ? { efeitos } : {}),
356
+ // A música fica BAIXA: quem fala para a câmera fala o vídeo
357
+ // inteiro, e a trilha é cama, não disputa.
358
+ ...(trilha ? { trilha: { src: trilha, volume: 0.07, loop: true } } : {}),
359
+ } } : {}),
360
+ },
361
+ resumo: {
362
+ cenas: cenas.length,
363
+ icones: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'icone').length, 0),
364
+ imagens: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'imagem').length, 0),
365
+ musica: Boolean(trilha),
366
+ letreiros: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'texto').length, 0),
367
+ efeitos: efeitos.length,
368
+ },
369
+ };
370
+ }
371
+
372
+ function limpaPontuacao(t) {
373
+ return String(t || '').replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}%]+$/gu, '');
374
+ }
375
+
376
+ /* Os dois sons, sintetizados pelo ffmpeg: nada baixado, nada com licença de
377
+ ninguém. O pop é um tom que cai rápido; o whoosh, um ruído que sobe e some. */
378
+ const SONS = {
379
+ pop: ['-f', 'lavfi', '-i', "aevalsrc='0.9*sin(2*PI*(1500-7000*t)*t)*exp(-38*t)':s=44100:d=0.14"],
380
+ whoosh: ['-f', 'lavfi', '-i', 'anoisesrc=d=0.55:c=pink:a=0.6',
381
+ '-af', 'highpass=f=500,lowpass=f=5000,afade=t=in:st=0:d=0.3:curve=exp,afade=t=out:st=0.3:d=0.25,volume=0.9'],
382
+ };
383
+
384
+ async function prepararSons(pasta, video) {
385
+ const fs = require('fs');
386
+ const path = require('path');
387
+ const sons = {};
388
+ for (const [nome, args] of Object.entries(SONS)) {
389
+ const rel = `midia/sfx-${nome}.mp3`;
390
+ const cheio = path.join(pasta, rel);
391
+ if (!fs.existsSync(cheio)) {
392
+ fs.mkdirSync(path.dirname(cheio), { recursive: true });
393
+ const r = await video.rodar(video.ffmpeg(), ['-y', ...args, '-q:a', '4', cheio], { timeout: 30000 });
394
+ if (!r.ok) continue;
395
+ }
396
+ sons[nome] = rel;
397
+ }
398
+ return sons;
399
+ }
400
+
401
+ module.exports = { montarEdicao, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, ESTILOS, ICONES, FOTO };
@@ -40,6 +40,7 @@ const assets = require('./assets.js');
40
40
  const video = require('./video.js');
41
41
  const identidade = require('./identidade.js');
42
42
  const slides = require('./slides.js');
43
+ const edicao = require('./edicao.js');
43
44
 
44
45
  const PY = path.join(__dirname, '..', '..', 'studio');
45
46
 
@@ -399,6 +400,87 @@ async function ferramentaVideo(args, ctx = {}) {
399
400
  return transcrever(p, alvo, ctx);
400
401
  }
401
402
 
403
+ /* A EDIÇÃO INTEIRA, em código (lib/estudio/edicao.js): corta o silêncio,
404
+ transcreve palavra por palavra e monta o plano — legenda animada,
405
+ ícones na hora da palavra, zoom de corte, pop e whoosh. É o caminho
406
+ para qualquer modelo entregar vídeo de videomaker: ele só chama isto e
407
+ renderiza. "Até uma batata faz vídeos assim." (25/09/2026) */
408
+ if (acao === 'editar' || acao === 'edicao' || acao === 'auto') {
409
+ let fonte = alvo;
410
+ let cortes = 0;
411
+ if (args.cortarSilencio !== false) {
412
+ const saida = path.join(p.pasta, 'midia', `cortado-${Date.now().toString(36)}.mp4`);
413
+ const c = await video.cortarSilencio(alvo, saida, args);
414
+ if (c.ok && !c.semCorte) { fonte = saida; cortes = c.cortes || 0; }
415
+ }
416
+ /* A VOZ NO VOLUME CERTO. A gravação de celular sai baixa (medido em
417
+ 25/09: -37,6 dB de média no vídeo pronto, e "está sem som"). O
418
+ loudnorm leva a fala a -15 LUFS, o volume de vídeo de internet; o
419
+ vídeo em si não é recodificado. */
420
+ const normal = path.join(p.pasta, 'midia', `voz-normal-${Date.now().toString(36)}.mp4`);
421
+ const ln = await video.rodar(video.ffmpeg(), ['-y', '-i', fonte, '-c:v', 'copy',
422
+ '-af', 'loudnorm=I=-15:TP=-1.5:LRA=11', '-c:a', 'aac', '-b:a', '192k', normal], { timeout: 10 * 60000 });
423
+ if (ln.ok) fonte = normal;
424
+
425
+ const m = await video.medir(fonte);
426
+ if (!m.ok) return m;
427
+
428
+ const tr = await transcrever(p, fonte, ctx);
429
+ if (!tr.ok) return tr;
430
+ let palavras = tr.palavras || [];
431
+ // Servidor sem o tempo por palavra: reparte cada trecho pelas palavras
432
+ // dele. Sai menos preciso, mas a legenda continua animada.
433
+ if (!palavras.length) palavras = repartirTrechos(tr.trechos || []);
434
+
435
+ const candidatos = [...new Set(palavras.map((w) => edicao.iconeDe(w.texto)).filter(Boolean))];
436
+ const iconesOk = new Set();
437
+ for (const nome of candidatos) {
438
+ try { const r = await assets.icone(nome); if (r && (r.ok !== false)) iconesOk.add(nome); } catch { /* fica sem */ }
439
+ }
440
+ const sons = await edicao.prepararSons(p.pasta, video);
441
+
442
+ // As imagens de apoio: uma por termo, a primeira que baixar.
443
+ const imagens = {};
444
+ if ((args.estilo || 'viral') !== 'sem-apoio') {
445
+ for (const termo of edicao.termosDeApoio(palavras).slice(0, 8)) {
446
+ try {
447
+ const b = await assets.procurarImagens(termo, { quantas: 4 });
448
+ for (const item of (b.ok ? b.imagens : [])) {
449
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
450
+ if (d.ok) { imagens[termo] = d.relativo; break; }
451
+ }
452
+ } catch { /* segue sem esta */ }
453
+ }
454
+ }
455
+
456
+ // A música de fundo: uma trilha longa, de licença aberta.
457
+ let trilha = null;
458
+ if (args.musica !== false) {
459
+ try {
460
+ const b = await assets.procurarSom(args.trilha || 'upbeat corporate background', { quantas: 6, duracaoMin: 45 });
461
+ for (const item of (b.ok ? b.sons : [])) {
462
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
463
+ if (d.ok) { trilha = d.relativo; break; }
464
+ }
465
+ } catch { /* segue sem música */ }
466
+ }
467
+
468
+ const montado = edicao.montarEdicao({
469
+ src: path.relative(p.pasta, fonte).replace(/\\/g, '/'),
470
+ duracao: m.segundos, palavras, largura: m.largura, altura: m.altura,
471
+ estilo: args.estilo || 'viral', sons, iconesOk, imagens, trilha,
472
+ });
473
+ if (!montado.ok) return montado;
474
+ const salvo = await ferramentaPlano({ id: args.id, plano: montado.plano, motivo: `edição automática (${args.estilo || 'viral'})` });
475
+ if (!salvo.ok) return salvo;
476
+ projeto.anotar(p.id, 'edicao', `${montado.resumo.cenas} cenas, ${montado.resumo.icones} ícones, ${cortes} cortes de silêncio`);
477
+ return {
478
+ ok: true, ...montado.resumo, cortesDeSilencio: cortes, duracao: salvo.duracao,
479
+ proximo: 'estudio_render com este id. Antes, se quiser, ajuste uma cena com estudio_plano cena:N '
480
+ + '(ex.: trocar um ícone, pôr uma imagem de apoio atrás).',
481
+ };
482
+ }
483
+
402
484
  if (acao === 'recortar-pessoa' || acao === 'pessoa') {
403
485
  const saida = path.join(p.pasta, 'midia', `pessoa-${Date.now().toString(36)}.webm`);
404
486
  const r = await rodarPython('pessoa.py', [
@@ -418,6 +500,23 @@ async function ferramentaVideo(args, ctx = {}) {
418
500
  return { ok: false, error: `não conheço a ação "${acao}".` };
419
501
  }
420
502
 
503
+ /* Sem o tempo de cada palavra, cada trecho é repartido pelas palavras dele,
504
+ proporcional ao tamanho de cada uma. */
505
+ function repartirTrechos(trechos) {
506
+ const out = [];
507
+ for (const t of trechos) {
508
+ const ws = String(t.texto || '').split(/\s+/).filter(Boolean);
509
+ const letras = ws.reduce((n, w) => n + w.length, 0) || 1;
510
+ let cur = t.de;
511
+ for (const w of ws) {
512
+ const d = (t.ate - t.de) * (w.length / letras);
513
+ out.push({ de: cur, ate: cur + d, texto: w });
514
+ cur += d;
515
+ }
516
+ }
517
+ return out;
518
+ }
519
+
421
520
  /**
422
521
  * A transcrição com tempo — é ela que dá legenda sincronizada.
423
522
  *
@@ -448,7 +547,7 @@ async function transcrever(p, alvo, ctx) {
448
547
  if (!r || r.ok === false) return { ok: false, error: (r && r.erro) || 'a transcrição falhou.' };
449
548
  projeto.anotar(p.id, 'transcricao', `${(r.trechos || []).length} trechos`);
450
549
  return {
451
- ok: true, texto: r.texto, trechos: r.trechos,
550
+ ok: true, texto: r.texto, trechos: r.trechos, palavras: r.palavras || [],
452
551
  comoUsar: 'cada trecho vira uma fala da legenda: {"de":…, "ate":…, "texto":…}. '
453
552
  + 'O estilo da legenda (fonte, cor, peso, posição, animação) é escolha sua — não há padrão.',
454
553
  };