primocode 8.42.2 → 9.1.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.
Files changed (104) hide show
  1. package/README.md +41 -37
  2. package/bin/primocode.js +598 -360
  3. package/lib/acervo.js +8 -1
  4. package/lib/act.js +205 -114
  5. package/lib/api.js +35 -4
  6. package/lib/boas-vindas.js +42 -195
  7. package/lib/catalogo.js +100 -61
  8. package/lib/config.js +1 -1
  9. package/lib/conta.js +3 -24
  10. package/lib/diff.js +161 -0
  11. package/lib/diretor.js +37 -47
  12. package/lib/entrada.js +576 -0
  13. package/lib/especialistas.js +2 -2
  14. package/lib/estudio/assets.js +343 -0
  15. package/lib/estudio/identidade.js +560 -0
  16. package/lib/estudio/index.js +565 -0
  17. package/lib/estudio/plano.js +327 -0
  18. package/lib/estudio/projeto.js +246 -0
  19. package/lib/estudio/remotion.js +644 -0
  20. package/lib/estudio/slides.js +190 -0
  21. package/lib/estudio/video.js +296 -0
  22. package/lib/fala.js +49 -6
  23. package/lib/marca.js +4 -1
  24. package/lib/markdown.js +376 -0
  25. package/lib/marketplace.js +189 -0
  26. package/lib/pasta.js +15 -4
  27. package/lib/permissao.js +137 -0
  28. package/lib/registro.js +229 -0
  29. package/lib/skills.js +329 -0
  30. package/lib/tools.js +106 -187
  31. package/lib/ui.js +7 -3
  32. package/lib/voz.js +239 -0
  33. package/package.json +12 -3
  34. package/skills/acessibilidade/SKILL.md +28 -0
  35. package/skills/api-que-nao-quebra/SKILL.md +27 -0
  36. package/skills/depurar-de-verdade/SKILL.md +29 -0
  37. package/skills/editar-meu-video/SKILL.md +59 -0
  38. package/skills/escrever-em-portugues/SKILL.md +28 -0
  39. package/skills/git-limpo/SKILL.md +28 -0
  40. package/skills/motion-de-primeira/SKILL.md +62 -0
  41. package/skills/pagina-que-converte/SKILL.md +28 -0
  42. package/skills/performance-web/SKILL.md +28 -0
  43. package/skills/revisao-antes-de-entregar/SKILL.md +27 -0
  44. package/skills/slides-que-prendem/SKILL.md +40 -0
  45. package/studio/edge.py +16 -66
  46. package/studio/narrar.py +274 -0
  47. package/studio/pessoa.py +199 -0
  48. package/voz/primo_voz/__init__.py +0 -0
  49. package/voz/primo_voz/__main__.py +383 -0
  50. package/voz/primo_voz/escuta.py +235 -0
  51. package/voz/primo_voz/orbe.py +216 -0
  52. package/lib/audio-livre.js +0 -177
  53. package/lib/chaves.js +0 -149
  54. package/lib/claude-engine.js +0 -631
  55. package/lib/codex-engine.js +0 -208
  56. package/lib/remotion.js +0 -663
  57. package/lib/studio.js +0 -960
  58. package/studio/PRIMOCODE.md +0 -58
  59. package/studio/README.md +0 -154
  60. package/studio/__pycache__/edge.cpython-311.pyc +0 -0
  61. package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
  62. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  63. package/studio/arquivos/corte-teste-de-camera-fb823.json +0 -57
  64. package/studio/ferramentas/00-protocolo.md +0 -125
  65. package/studio/ferramentas/01-modelos.md +0 -270
  66. package/studio/ferramentas/README.md +0 -32
  67. package/studio/ferramentas/corte.md +0 -919
  68. package/studio/ferramentas/grade.md +0 -92
  69. package/studio/ferramentas/prisma.md +0 -107
  70. package/studio/ferramentas/prosa.md +0 -86
  71. package/studio/ferramentas/tela.md +0 -93
  72. package/studio/ferramentas/traco.md +0 -79
  73. package/studio/server.py +0 -2254
  74. package/studio/studio.log +0 -57
  75. package/studio/voz.py +0 -388
  76. package/studio/web/analise.html +0 -52
  77. package/studio/web/core.css +0 -1167
  78. package/studio/web/fontes/bebas.woff2 +0 -0
  79. package/studio/web/fontes/grotesk-700.woff2 +0 -0
  80. package/studio/web/fontes/inter-400.woff2 +0 -0
  81. package/studio/web/fontes/inter-700.woff2 +0 -0
  82. package/studio/web/fontes/jetbrains-400.woff2 +0 -0
  83. package/studio/web/fontes/playfair-700.woff2 +0 -0
  84. package/studio/web/fontes.css +0 -23
  85. package/studio/web/home.html +0 -217
  86. package/studio/web/js/analise.js +0 -510
  87. package/studio/web/js/audio.js +0 -607
  88. package/studio/web/js/editor.js +0 -1802
  89. package/studio/web/js/exportar.js +0 -222
  90. package/studio/web/js/ferramentas.js +0 -108
  91. package/studio/web/js/icones.js +0 -120
  92. package/studio/web/js/midia.js +0 -157
  93. package/studio/web/js/narracao.js +0 -325
  94. package/studio/web/js/player.js +0 -903
  95. package/studio/web/js/render-grade.js +0 -243
  96. package/studio/web/js/render-prosa.js +0 -104
  97. package/studio/web/js/render.js +0 -1852
  98. package/studio/web/js/sfx.js +0 -233
  99. package/studio/web/js/video.js +0 -464
  100. package/studio/web/js/voz-neural.js +0 -152
  101. package/studio/web/render.css +0 -1352
  102. package/studio/web/tf.html +0 -15
  103. package/studio/web/tool.html +0 -78
  104. package/studio/web/view.html +0 -27
@@ -0,0 +1,565 @@
1
+ /**
2
+ * estudio/index.js — as ferramentas do Studio, do jeito que o agente as vê.
3
+ *
4
+ * ── O QUE ESTE ARQUIVO SUBSTITUI ─────────────────────────────────────────
5
+ * `lib/studio.js`, 887 linhas cuja maior parte era ligar, manter de pé e
6
+ * conversar por HTTP com um servidor Python que desenhava páginas — e mais o
7
+ * cuidado de abrir uma aba no navegador da pessoa no momento certo, e de não
8
+ * abrir três.
9
+ *
10
+ * Nada disso existe mais. Cada peça é uma PASTA, cada render é o Remotion, e
11
+ * a única coisa que abre no fim é o vídeo pronto.
12
+ *
13
+ * ── AS SETE FERRAMENTAS, E POR QUE SÃO SETE ──────────────────────────────
14
+ * estudio_projeto abrir, achar, listar, lembrar
15
+ * estudio_plano escrever ou ajustar o que o vídeo é
16
+ * estudio_midia procurar e baixar imagem, som, ícone, fonte
17
+ * estudio_narrar a voz, com os tempos medidos
18
+ * estudio_video o que se faz com a gravação: medir, cortar, legendar
19
+ * estudio_render virar arquivo
20
+ * estudio_site ler a identidade de uma marca a partir do link
21
+ *
22
+ * A tentação era uma ferramenta por verbo (criar cena, mover elemento, trocar
23
+ * cor). Isso multiplicaria o catálogo por dez e, pior, faria o modelo montar
24
+ * o vídeo por ajustes — que é exatamente como se produz vídeo genérico. O
25
+ * plano inteiro de uma vez obriga a decidir o vídeo inteiro de uma vez.
26
+ */
27
+
28
+ 'use strict';
29
+
30
+ const fs = require('fs');
31
+ const os = require('os');
32
+ const path = require('path');
33
+ const { spawn } = require('child_process');
34
+
35
+ const projeto = require('./projeto.js');
36
+ const acervo = require('../acervo.js');
37
+ const plano = require('./plano.js');
38
+ const remotion = require('./remotion.js');
39
+ const assets = require('./assets.js');
40
+ const video = require('./video.js');
41
+ const identidade = require('./identidade.js');
42
+ const slides = require('./slides.js');
43
+
44
+ const PY = path.join(__dirname, '..', '..', 'studio');
45
+
46
+ /** Qual python desta máquina. O mesmo problema do Studio antigo, mesma cura. */
47
+ function python() {
48
+ const { execSync } = require('child_process');
49
+ const nomes = process.platform === 'win32'
50
+ ? ['python', 'py', 'python3']
51
+ : ['python3', 'python', '/opt/homebrew/bin/python3', '/usr/local/bin/python3', '/usr/bin/python3'];
52
+ for (const n of nomes) {
53
+ try {
54
+ const v = execSync(`${n} --version`, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 }).toString();
55
+ if (/Python 3/.test(v)) return n;
56
+ } catch { /* próximo */ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function rodarPython(script, args, { timeout = 20 * 60000 } = {}) {
62
+ return new Promise((resolve) => {
63
+ const py = python();
64
+ if (!py) {
65
+ return resolve({ ok: false, error: 'não achei o Python 3 nesta máquina.',
66
+ comoResolver: 'instale o Python 3 (python.org) e tente de novo' });
67
+ }
68
+ const p = spawn(py, [path.join(PY, script), ...args]);
69
+ let saida = '', erro = '';
70
+ p.stdout.on('data', (d) => { saida += d.toString(); });
71
+ p.stderr.on('data', (d) => { erro += d.toString(); });
72
+ const relogio = setTimeout(() => { try { p.kill(); } catch {} }, timeout);
73
+ p.on('close', () => {
74
+ clearTimeout(relogio);
75
+ try { resolve(JSON.parse(saida.trim().split('\n').pop())); }
76
+ catch { resolve({ ok: false, error: (erro || saida).slice(-400).trim() || 'o script não respondeu' }); }
77
+ });
78
+ p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, error: e.message }); });
79
+ });
80
+ }
81
+
82
+ // ── estudio_projeto ──────────────────────────────────────────────────────
83
+
84
+ async function ferramentaProjeto(args) {
85
+ const acao = String(args.acao || 'listar').toLowerCase();
86
+
87
+ if (acao === 'criar' || acao === 'novo') {
88
+ const r = projeto.criar({
89
+ titulo: args.titulo, pedido: args.pedido || args.titulo, formato: args.formato || 'video',
90
+ });
91
+ return { ...r, comoUsar: 'agora escreva o plano com estudio_plano, usando este id.' };
92
+ }
93
+
94
+ if (acao === 'abrir' || acao === 'ler') {
95
+ const p = projeto.ler(args.id);
96
+ if (!p) return { ok: false, error: `não achei o projeto "${args.id}". Use acao:"procurar".` };
97
+ const pl = projeto.lerPlano(args.id);
98
+ return {
99
+ ok: true, projeto: p,
100
+ // O caderno é o que faz "mexe naquela parte" funcionar semanas
101
+ // depois: sem ele, reabrir diz o que existe e nunca por quê.
102
+ caderno: projeto.caderno(args.id, { ultimas: 25 }),
103
+ plano: pl ? plano.resumo(pl) : 'ainda não há plano',
104
+ versoes: projeto.versoes(args.id),
105
+ arquivos: listarArquivos(p.pasta),
106
+ };
107
+ }
108
+
109
+ if (acao === 'procurar' || acao === 'achar') {
110
+ const achados = projeto.procurar(args.texto || args.titulo || '');
111
+ if (!achados.length) return { ok: true, achados: [], aviso: 'nenhum projeto parecido.' };
112
+ return {
113
+ ok: true,
114
+ achados: achados.map((p) => ({ id: p.id, titulo: p.titulo, criado: p.criado.slice(0, 10), pedido: p.pedido.slice(0, 120) })),
115
+ };
116
+ }
117
+
118
+ if (acao === 'anotar') {
119
+ projeto.anotar(args.id, args.tipo || 'nota', args.texto || '');
120
+ return { ok: true };
121
+ }
122
+
123
+ const todos = projeto.listar({ limite: Number(args.limite) || 20 });
124
+ return {
125
+ ok: true,
126
+ projetos: todos.map((p) => ({ id: p.id, titulo: p.titulo, criado: p.criado.slice(0, 10), renders: (p.renders || []).length })),
127
+ };
128
+ }
129
+
130
+ function listarArquivos(pasta) {
131
+ const saida = {};
132
+ for (const sub of ['entrada', 'midia', 'saida']) {
133
+ try {
134
+ saida[sub] = fs.readdirSync(path.join(pasta, sub))
135
+ .filter((f) => !f.endsWith('.licenca.json')).slice(0, 40);
136
+ } catch { saida[sub] = []; }
137
+ }
138
+ return saida;
139
+ }
140
+
141
+ // ── estudio_plano ────────────────────────────────────────────────────────
142
+
143
+ async function ferramentaPlano(args) {
144
+ const p = projeto.ler(args.id);
145
+ if (!p) return { ok: false, error: `não achei o projeto "${args.id}".` };
146
+
147
+ if (args.voltarPara) return projeto.voltarPara(args.id, args.voltarPara);
148
+
149
+ if (!args.plano && !args.cenas) {
150
+ const atual = projeto.lerPlano(args.id);
151
+ if (!atual) return { ok: false, error: 'este projeto ainda não tem plano — mande um.' };
152
+ return { ok: true, plano: atual, resumo: plano.resumo(atual) };
153
+ }
154
+
155
+ // Um plano inteiro, ou só as cenas (que é o caso comum).
156
+ let novo = args.plano || { formato: args.formato, cenas: args.cenas, audio: args.audio };
157
+
158
+ /* Ajuste de UMA cena, sem reescrever o resto.
159
+ *
160
+ * É o caminho de "não gostei da parte do cartão": reescrever o plano
161
+ * inteiro para mexer numa cena é como o vídeo antigo mudava de cara
162
+ * sozinho a cada correção. */
163
+ if (args.cena != null) {
164
+ const atual = projeto.lerPlano(args.id);
165
+ if (!atual) return { ok: false, error: 'não há plano para ajustar.' };
166
+ const i = Number(args.cena) - 1;
167
+ if (!(i >= 0 && i < atual.cenas.length)) {
168
+ return { ok: false, error: `a cena ${args.cena} não existe (são ${atual.cenas.length}).` };
169
+ }
170
+ atual.cenas[i] = args.plano || args.novaCena || atual.cenas[i];
171
+ novo = atual;
172
+ }
173
+
174
+ const conferida = plano.conferir(novo, { pasta: p.pasta });
175
+ if (!conferida.ok) {
176
+ return {
177
+ ok: false,
178
+ error: 'o plano tem problemas que quebrariam o render — conserte e mande de novo.',
179
+ problemas: conferida.problemas.slice(0, 12),
180
+ };
181
+ }
182
+
183
+ projeto.salvarPlano(args.id, novo, args.motivo || '');
184
+ projeto.salvar(args.id, { duracao: conferida.duracao });
185
+ return {
186
+ ok: true, duracao: conferida.duracao, resumo: plano.resumo(novo),
187
+ proximo: 'estudio_render quando estiver pronto.',
188
+ };
189
+ }
190
+
191
+ // ── estudio_midia ────────────────────────────────────────────────────────
192
+
193
+ async function ferramentaMidia(args) {
194
+ const acao = String(args.acao || 'imagem').toLowerCase();
195
+ const p = args.id ? projeto.ler(args.id) : null;
196
+ if (args.id && !p) return { ok: false, error: `não achei o projeto "${args.id}".` };
197
+
198
+ if (acao === 'fontes') {
199
+ if (args.conferir) return assets.conferirFonte(args.conferir);
200
+ return { ok: true, fontes: assets.FONTES,
201
+ nota: 'qualquer família do Google Fonts funciona — esta lista é para não repetir sempre as mesmas.' };
202
+ }
203
+
204
+ /* Recortar o fundo. O pedido pede "buscar PNGs na internet, remover fundo
205
+ e inserir" — e sem isto o agente colava a foto com o retângulo branco
206
+ dela por cima do fundo da cena, que é o cara-de-recorte que denuncia
207
+ vídeo feito às pressas. O recorte real é modelo de segmentação (rembg);
208
+ sem ele na máquina, sobra o corte por cor, que só serve em fundo
209
+ chapado — e a resposta diz qual dos dois foi usado. */
210
+ if (acao === 'semfundo' || acao === 'sem-fundo' || acao === 'recortar') {
211
+ const alvo = args.arquivo || args.src;
212
+ if (!alvo) return { ok: false, error: 'diga o arquivo (arquivo) da imagem a recortar.' };
213
+ const cheio = p ? path.resolve(p.pasta, alvo) : path.resolve(alvo);
214
+ const r = await acervo.tirarFundo(cheio, { cor: args.cor, tolerancia: args.tolerancia });
215
+ if (!r.ok) return r;
216
+ const relativo = p ? path.relative(p.pasta, r.arquivo).replace(/\\/g, '/') : r.arquivo;
217
+ if (p) projeto.anotar(p.id, 'midia', `recortou o fundo de ${alvo} (${r.metodo})`);
218
+ return { ok: true, src: relativo, metodo: r.metodo, aviso: r.aviso || undefined };
219
+ }
220
+
221
+ if (acao === 'logo') {
222
+ if (!p) return { ok: false, error: 'para baixar a logo é preciso dizer em qual projeto (id).' };
223
+ const nome = String(args.marca || args.nome || args.termo || '').trim();
224
+ if (!nome) return { ok: false, error: 'diga a marca (marca).' };
225
+ let html = '';
226
+ let site = String(args.site || args.url || '').trim();
227
+ if (!site) {
228
+ const achado = await identidade.acharSite(nome);
229
+ if (achado.ok) site = achado.url;
230
+ }
231
+ if (site) {
232
+ if (!/^https?:\/\//i.test(site)) site = 'https://' + site;
233
+ html = (await require('../referencia.js').pegar(site, { timeout: 15000 })) || '';
234
+ }
235
+ const r = await identidade.pegarLogo(html, site || 'https://exemplo.invalido', { pasta: p.pasta, nome });
236
+ if (r.ok) projeto.anotar(p.id, 'midia', `logo de ${nome}: ${r.src} (${r.achadaEm})`);
237
+ return r;
238
+ }
239
+
240
+ if (acao === 'icones' || acao === 'icone') {
241
+ if (args.nome) {
242
+ const r = await assets.icone(args.nome, { pasta: p ? p.pasta : null });
243
+ return r.ok ? { ok: true, nome: args.nome, baixado: true } : r;
244
+ }
245
+ return assets.procurarIcones(args.termo || args.busca || '');
246
+ }
247
+
248
+ if (acao === 'som' || acao === 'audio' || acao === 'trilha' || acao === 'efeito') {
249
+ const busca = await assets.procurarSom(args.termo || '', {
250
+ quantas: Number(args.quantas) || 8,
251
+ duracaoMin: acao === 'trilha' ? 30 : 0,
252
+ duracaoMax: acao === 'efeito' ? 6 : 0,
253
+ });
254
+ if (!busca.ok || !args.baixar) return busca;
255
+ return baixarEscolhido(busca.sons, args, p);
256
+ }
257
+
258
+ const busca = await assets.procurarImagens(args.termo || '', {
259
+ quantas: Number(args.quantas) || 8,
260
+ transparente: !!args.transparente,
261
+ });
262
+ if (!busca.ok || !args.baixar) return busca;
263
+ return baixarEscolhido(busca.imagens, args, p);
264
+ }
265
+
266
+ async function baixarEscolhido(lista, args, p) {
267
+ if (!p) return { ok: false, error: 'para baixar é preciso dizer em qual projeto (id).' };
268
+ const i = Number(args.baixar) === true ? 0 : Math.max(0, Number(args.baixar) - 1 || 0);
269
+ const item = lista[i];
270
+ if (!item) return { ok: false, error: `não há o item ${args.baixar} nessa busca.` };
271
+ const r = await assets.baixar(item, { pasta: p.pasta, nome: args.nome });
272
+ if (r.ok) {
273
+ projeto.anotar(p.id, 'midia', `baixou "${item.titulo}" (${item.licenca || 'sem licença declarada'})`);
274
+ }
275
+ return { ...r, creditos: item.creditos || null,
276
+ comoUsar: r.ok ? `no plano: "src": "${r.relativo}"` : undefined };
277
+ }
278
+
279
+ // ── estudio_narrar ───────────────────────────────────────────────────────
280
+
281
+ async function ferramentaNarrar(args) {
282
+ if (args.vozes) return rodarPython('narrar.py', ['vozes'], { timeout: 30000 });
283
+
284
+ const p = projeto.ler(args.id);
285
+ if (!p) return { ok: false, error: `não achei o projeto "${args.id}".` };
286
+ const texto = String(args.texto || '').trim();
287
+ if (!texto) return { ok: false, error: 'não há texto para narrar.' };
288
+
289
+ const saida = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
290
+ fs.mkdirSync(path.dirname(saida), { recursive: true });
291
+
292
+ const r = await rodarPython('narrar.py', [
293
+ 'falar', '--texto', texto, '--saida', saida,
294
+ '--voz', String(args.voz || 'antonio'),
295
+ '--estilo', String(args.estilo || 'normal'),
296
+ ...(args.ritmo ? ['--ritmo', String(args.ritmo)] : []),
297
+ ...(args.tom ? ['--tom', String(args.tom)] : []),
298
+ ], { timeout: 5 * 60000 });
299
+
300
+ if (!r.ok) return r;
301
+ const relativo = path.relative(p.pasta, saida).replace(/\\/g, '/');
302
+ projeto.anotar(p.id, 'narracao', `${r.segundos}s na voz ${args.voz || 'antonio'}`);
303
+ return {
304
+ ...r, relativo,
305
+ comoUsar: `no plano: "audio": {"narracao": "${relativo}"}. `
306
+ + 'Os tempos de "falas" são MEDIDOS — use-os para a duração de cada cena e para as legendas, '
307
+ + 'em vez de estimar por número de palavras.',
308
+ };
309
+ }
310
+
311
+ // ── estudio_video ────────────────────────────────────────────────────────
312
+
313
+ async function ferramentaVideo(args, ctx = {}) {
314
+ const acao = String(args.acao || 'medir').toLowerCase();
315
+ const p = projeto.ler(args.id);
316
+ if (!p) return { ok: false, error: `não achei o projeto "${args.id}".` };
317
+
318
+ const pronto = video.conferir();
319
+ if (!pronto.ok) return pronto;
320
+
321
+ const dentro = (rel) => {
322
+ const cheio = path.resolve(p.pasta, String(rel || ''));
323
+ return cheio.startsWith(path.resolve(p.pasta)) ? cheio : null;
324
+ };
325
+
326
+ if (acao === 'importar') {
327
+ // O vídeo da pessoa entra na pasta do projeto. Trabalhar a partir de
328
+ // onde ele está é o caminho para o agente escrever em cima do
329
+ // original — e o original dela não tem cópia.
330
+ const de = path.resolve(String(args.arquivo || '').replace(/^~(?=$|[/\\])/, os.homedir()));
331
+ if (!fs.existsSync(de)) return { ok: false, error: `não achei "${args.arquivo}".` };
332
+ const para = path.join(p.pasta, 'entrada', path.basename(de));
333
+ fs.copyFileSync(de, para);
334
+ const m = await video.medir(para);
335
+ projeto.anotar(p.id, 'entrada', `importou ${path.basename(de)} (${m.ok ? m.segundos + 's' : '?'})`);
336
+ return { ok: true, arquivo: path.relative(p.pasta, para).replace(/\\/g, '/'), medida: m };
337
+ }
338
+
339
+ const alvo = dentro(args.arquivo);
340
+ if (!alvo || !fs.existsSync(alvo)) {
341
+ return { ok: false, error: `"${args.arquivo}" não está na pasta do projeto. Use acao:"importar" antes.` };
342
+ }
343
+
344
+ if (acao === 'medir') return video.medir(alvo);
345
+
346
+ if (acao === 'silencio' || acao === 'pausas') {
347
+ const s = await video.silencios(alvo, { limiarDb: Number(args.limiarDb) || -40 });
348
+ if (!s.ok) return s;
349
+ const m = await video.medir(alvo);
350
+ return {
351
+ ok: true, silencios: s.silencios,
352
+ ficariam: video.trechosQueFicam(m.segundos, s.silencios, args),
353
+ };
354
+ }
355
+
356
+ if (acao === 'cortar-silencio' || acao === 'cortar') {
357
+ const saida = path.join(p.pasta, 'midia', `cortado-${Date.now().toString(36)}.mp4`);
358
+ const r = await video.cortarSilencio(alvo, saida, args);
359
+ if (r.ok && !r.semCorte) {
360
+ projeto.anotar(p.id, 'corte', `${r.antes}s → ${r.depois}s, ${r.cortes} cortes`);
361
+ r.relativo = path.relative(p.pasta, saida).replace(/\\/g, '/');
362
+ }
363
+ return r;
364
+ }
365
+
366
+ if (acao === 'quadro') {
367
+ const saida = path.join(p.pasta, 'midia', `quadro-${Math.round(Number(args.segundo) || 0)}s.png`);
368
+ const r = await video.quadro(alvo, saida, Number(args.segundo) || 0);
369
+ if (r.ok) r.relativo = path.relative(p.pasta, saida).replace(/\\/g, '/');
370
+ return r;
371
+ }
372
+
373
+ if (acao === 'legendas' || acao === 'transcrever') {
374
+ return transcrever(p, alvo, ctx);
375
+ }
376
+
377
+ if (acao === 'recortar-pessoa' || acao === 'pessoa') {
378
+ const saida = path.join(p.pasta, 'midia', `pessoa-${Date.now().toString(36)}.webm`);
379
+ const r = await rodarPython('pessoa.py', [
380
+ 'recortar', '--entrada', alvo, '--saida', saida,
381
+ ...(args.suavizar != null ? ['--suavizar', String(args.suavizar)] : []),
382
+ ...(args.encolher != null ? ['--encolher', String(args.encolher)] : []),
383
+ ], { timeout: 40 * 60000 });
384
+ if (r.ok) {
385
+ r.relativo = path.relative(p.pasta, saida).replace(/\\/g, '/');
386
+ r.comoUsar = 'é um vídeo com fundo TRANSPARENTE. Ponha-o como elemento de vídeo numa cena '
387
+ + 'que tenha a imagem de fundo atrás — não precisa juntar os dois num arquivo.';
388
+ projeto.anotar(p.id, 'pessoa', `recortou ${r.quadros} quadros`);
389
+ }
390
+ return r;
391
+ }
392
+
393
+ return { ok: false, error: `não conheço a ação "${acao}".` };
394
+ }
395
+
396
+ /**
397
+ * A transcrição com tempo — é ela que dá legenda sincronizada.
398
+ *
399
+ * Quem tem a chave é o servidor, e é assim de propósito (a API vem com o
400
+ * produto). O áudio vai pequeno: mono, 16 kHz, 32 kbps.
401
+ */
402
+ async function transcrever(p, alvo, ctx) {
403
+ const mp3 = path.join(p.pasta, 'midia', `audio-${Date.now().toString(36)}.mp3`);
404
+ const ex = await video.extrairAudio(alvo, mp3);
405
+ if (!ex.ok) return ex;
406
+
407
+ if (!ctx.api || !ctx.server) {
408
+ return { ok: false, error: 'a transcrição precisa do servidor, e ele não foi passado para a ferramenta.' };
409
+ }
410
+ if (ex.bytes > 20 * 1024 * 1024) {
411
+ return { ok: false, error: `o áudio ficou com ${(ex.bytes / 1048576).toFixed(1)} MB, acima do teto de 20 MB. `
412
+ + 'Transcreva em pedaços: recorte o vídeo antes.' };
413
+ }
414
+
415
+ try {
416
+ const r = await ctx.api.requestTranscricao(ctx.server, {
417
+ token: ctx.token,
418
+ audio: fs.readFileSync(mp3).toString('base64'),
419
+ nome: path.basename(mp3),
420
+ idioma: 'pt',
421
+ });
422
+ try { fs.unlinkSync(mp3); } catch { /* fica, não faz mal */ }
423
+ if (!r || r.ok === false) return { ok: false, error: (r && r.erro) || 'a transcrição falhou.' };
424
+ projeto.anotar(p.id, 'transcricao', `${(r.trechos || []).length} trechos`);
425
+ return {
426
+ ok: true, texto: r.texto, trechos: r.trechos,
427
+ comoUsar: 'cada trecho vira uma fala da legenda: {"de":…, "ate":…, "texto":…}. '
428
+ + 'O estilo da legenda (fonte, cor, peso, posição, animação) é escolha sua — não há padrão.',
429
+ };
430
+ } catch (e) {
431
+ return { ok: false, error: `a transcrição falhou: ${e.message}` };
432
+ }
433
+ }
434
+
435
+ // ── estudio_render ───────────────────────────────────────────────────────
436
+
437
+ async function ferramentaRender(args, ctx = {}) {
438
+ const p = projeto.ler(args.id);
439
+ if (!p) return { ok: false, error: `não achei o projeto "${args.id}".` };
440
+ const pl = projeto.lerPlano(args.id);
441
+ if (!pl) return { ok: false, error: 'não há plano neste projeto.' };
442
+
443
+ const conferida = plano.conferir(pl, { pasta: p.pasta });
444
+ if (!conferida.ok) {
445
+ return { ok: false, error: 'o plano tem problemas — conserte antes de renderizar.',
446
+ problemas: conferida.problemas.slice(0, 12) };
447
+ }
448
+
449
+ // Os ícones vêm ANTES do render: um render que busca ícone na internet
450
+ // falha no meio, depois de vinte minutos.
451
+ const { mapa, faltaram } = await assets.iconesDoPlano(pl, p.pasta);
452
+
453
+ const montado = remotion.montar({ pasta: p.pasta, titulo: p.titulo, plano: pl, icones: mapa });
454
+
455
+ if (args.somenteProjeto) {
456
+ return { ok: true, projeto: montado.raiz, faltaram,
457
+ aviso: 'o projeto Remotion está escrito, mas nada foi renderizado.' };
458
+ }
459
+
460
+ /* SLIDES E ARTE são o mesmo plano, parados no tempo: cada cena vira uma
461
+ página. O Studio antigo tinha seis ferramentas com seis formatos de
462
+ documento; cinco delas eram "coisas posicionadas num retângulo", que é
463
+ o que uma cena já é. */
464
+ const parado = p.formato === 'slides' || p.formato === 'arte' || args.paginas;
465
+ if (parado) {
466
+ const pasta = path.join(p.pasta, 'saida', 'paginas');
467
+ const r = await slides.renderizarPaginas({
468
+ raiz: montado.raiz, destino: pasta, plano: pl, aoFalar: ctx.aoFalar || (() => {}),
469
+ });
470
+ if (!r.ok) return { ...r, faltaram };
471
+
472
+ const pdf = path.join(p.pasta, 'saida', `${projeto.apelidar(p.titulo)}.pdf`);
473
+ const junto = slides.montarPDF(r.paginas, pdf);
474
+ if (!junto.ok) return { ...junto, imagens: r.paginas };
475
+
476
+ projeto.salvar(args.id, { renders: [...(p.renders || []), { quando: new Date().toISOString(), arquivo: pdf, bytes: junto.bytes }] });
477
+ projeto.anotar(args.id, 'render', `${junto.paginas} páginas em PDF`);
478
+ if (ctx.abrir) { try { ctx.abrir(pdf); } catch { /* o caminho está na resposta */ } }
479
+ return {
480
+ ok: true, arquivo: pdf, paginas: junto.paginas, bytes: junto.bytes,
481
+ imagens: r.paginas,
482
+ faltaram: faltaram.length ? faltaram : undefined,
483
+ aviso: 'ABERTO para o usuário ver. Chame finish dizendo o que foi feito.',
484
+ };
485
+ }
486
+
487
+ const saida = path.join(p.pasta, 'saida', `${projeto.apelidar(p.titulo)}.mp4`);
488
+ const falar = ctx.aoFalar || (() => {});
489
+ const r = await remotion.renderizar({
490
+ raiz: montado.raiz, saida,
491
+ minutos: Number(args.minutos) || 30,
492
+ aoFalar: falar,
493
+ });
494
+ if (!r.ok) return { ...r, faltaram };
495
+
496
+ const bytes = fs.existsSync(saida) ? fs.statSync(saida).size : 0;
497
+ projeto.salvar(args.id, { renders: [...(p.renders || []), { quando: new Date().toISOString(), arquivo: saida, bytes }] });
498
+ projeto.anotar(args.id, 'render', `${(bytes / 1048576).toFixed(1)} MB, ${conferida.duracao}s`);
499
+
500
+ // O vídeo pronto abre. É a única coisa que abre — a interface morreu, e
501
+ // o que a pessoa pediu foi o vídeo.
502
+ if (ctx.abrir) { try { ctx.abrir(saida); } catch { /* o caminho está na resposta */ } }
503
+
504
+ return {
505
+ ok: true, arquivo: saida, bytes, segundos: conferida.duracao,
506
+ faltaram: faltaram.length ? faltaram : undefined,
507
+ aviso: 'ABERTO para o usuário assistir. Chame finish dizendo o que foi feito.',
508
+ };
509
+ }
510
+
511
+ // ── estudio_site ─────────────────────────────────────────────────────────
512
+
513
+ async function ferramentaSite(args) {
514
+ let alvo = String(args.url || args.site || '').trim();
515
+
516
+ /* Só o NOME da empresa também serve: "faz um vídeo para a Paris Group"
517
+ não traz link nenhum, e sem este caminho o agente inventaria as cores
518
+ da marca — erro que aparece pronto no vídeo entregue. */
519
+ if (!alvo && (args.marca || args.nome || args.empresa)) {
520
+ const achado = await identidade.acharSite(args.marca || args.nome || args.empresa);
521
+ if (!achado.ok) return achado;
522
+ alvo = achado.url;
523
+ }
524
+ if (!alvo) return { ok: false, error: 'mande a url do site, ou o nome da empresa em "marca".' };
525
+ // "tailwindcss.com" é endereço para gente e não é URL para o `new URL`.
526
+ // Sem isto, o domínio sem esquema voltava "não é um endereço" — e quem
527
+ // digita o domínio pelado é justamente quem já sabe qual é o site.
528
+ if (!/^https?:\/\//i.test(alvo)) alvo = 'https://' + alvo;
529
+
530
+ const r = await identidade.analisar(alvo);
531
+ if (r.ok) r.achadoPor = args.url || args.site ? 'url dada' : 'nome da empresa';
532
+
533
+ /* A LOGO. Cor e fonte saem do CSS; a logo não — ela mora num <img> do
534
+ cabeçalho, num apple-touch-icon ou no og:image. Sem baixar aqui, o
535
+ agente montava a peça de marca com o nome em texto e o usuário
536
+ perguntava por que a logo dele não apareceu.
537
+
538
+ Só baixa quando há projeto: arquivo de mídia solto no disco, fora de
539
+ uma pasta de projeto, é lixo que ninguém recolhe. */
540
+ const html = r.html;
541
+ delete r.html; // o HTML cru não vai para o modelo
542
+ if (r.ok && args.id && args.logo !== false) {
543
+ const p = projeto.ler(args.id);
544
+ if (p) {
545
+ /* O nome que o USUÁRIO disse vem primeiro. `r.marca` sai do
546
+ <title>, e título de página é frase: o nubank.com.br devolve
547
+ "Nu | About us", de onde sobra "about" — que não identifica
548
+ marca nenhuma e faz o desempate premiar a imagem errada. */
549
+ const daMarca = args.marca || args.nome || args.empresa || r.marca || r.titulo || 'marca';
550
+ r.logo = await identidade.pegarLogo(html, r.url, { pasta: p.pasta, nome: daMarca });
551
+ if (r.logo.ok) projeto.anotar(args.id, 'midia', `logo de ${r.marca || r.url}: ${r.logo.src} (${r.logo.achadaEm})`);
552
+ }
553
+ }
554
+
555
+ if (r.ok && args.id) {
556
+ projeto.anotar(args.id, 'referencia', `leu ${r.url}: ${(r.cores.marca[0] || {}).cor || 'sem cor'} · ${(r.fontes.declaradas[0] || {}).fonte || 'sem fonte'}`);
557
+ }
558
+ return r;
559
+ }
560
+
561
+ module.exports = {
562
+ ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
563
+ ferramentaVideo, ferramentaRender, ferramentaSite,
564
+ projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython,
565
+ };