primocode 8.40.0 → 8.41.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 (37) hide show
  1. package/README.md +28 -5
  2. package/bin/primocode.js +90 -0
  3. package/lib/acervo.js +391 -0
  4. package/lib/act.js +71 -0
  5. package/lib/audio-livre.js +6 -1
  6. package/lib/boas-vindas.js +80 -21
  7. package/lib/catalogo.js +24 -0
  8. package/lib/conta.js +24 -3
  9. package/lib/diretor.js +306 -0
  10. package/lib/tools.js +43 -0
  11. package/lib/ui.js +3 -2
  12. package/package.json +2 -2
  13. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  14. package/studio/web/js/render.js +32 -13
  15. package/studio/__pycache__/edge.cpython-311.pyc +0 -0
  16. package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
  17. package/studio/arquivos/corte-estatico-corte-a5bab.json +0 -150
  18. package/studio/arquivos/corte-founders-ai-teste-do-player-2de1d.json +0 -65
  19. package/studio/arquivos/corte-legado-teste.json +0 -1
  20. package/studio/arquivos/corte-legado2.json +0 -1
  21. package/studio/arquivos/corte-minimo-2a829.json +0 -24
  22. package/studio/arquivos/corte-ok-00a3b.json +0 -18
  23. package/studio/arquivos/corte-ok-1270e.json +0 -22
  24. package/studio/arquivos/corte-sweep-corte-cfb29.json +0 -147
  25. package/studio/arquivos/corte-visual-completo-78f50.json +0 -35
  26. package/studio/arquivos/grade-sweep-grade-0c2f1.json +0 -119
  27. package/studio/arquivos/prisma-estatico-prisma-d8740.json +0 -182
  28. package/studio/arquivos/prisma-sweep-prisma-4c58c.json +0 -166
  29. package/studio/arquivos/prisma-y-b0785.json +0 -52
  30. package/studio/arquivos/prosa-sweep-prosa-f29cf.json +0 -80
  31. package/studio/arquivos/tela-estatico-tela-c7daf.json +0 -90
  32. package/studio/arquivos/tela-motion-3ecaa.json +0 -135
  33. package/studio/arquivos/tela-motion-d6150.json +0 -135
  34. package/studio/arquivos/tela-sweep-tela-0d2bf.json +0 -84
  35. package/studio/arquivos/traco-estatico-traco-25872.json +0 -113
  36. package/studio/arquivos/traco-sweep-traco-0bcf4.json +0 -102
  37. package/studio/studio.log +0 -28
package/README.md CHANGED
@@ -1,15 +1,17 @@
1
- # PrimoCode v8.1.1
1
+ # PrimoCode v8.41.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** —
5
5
  e lembra do que fez entre uma sessão e outra.
6
6
 
7
- **Grátis, sem chave de API sua e sem teto de gasto.** Os modelos são os grátis do
8
- OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um modelo
9
- está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
7
+ **Sem chave de API sua.** O modelo vem hospedado: você entra com a conta do
8
+ Conecta Primo AI (plano **Premium** ou **Super**) e usa — nada de console de
9
+ provedor, nada de teto de gasto para acompanhar. Quem preferir a própria chave
10
+ do Claude ou do GPT escolhe isso na abertura, com as setinhas, e passa a usar a
11
+ assinatura dele.
10
12
 
11
13
  ```
12
- ▐▛███▜▌ Primo Code v8.1.1
14
+ ▐▛███▜▌ Primo Code v8.41.0
13
15
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
16
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
17
  ```
@@ -31,6 +33,27 @@ veja `/mascot` e `/name`.
31
33
 
32
34
  Em uma frase: **é o poder de um agente de código no terminal, em português, controlando o seu computador de verdade — sem você precisar configurar chave de API nenhuma.**
33
35
 
36
+ ## Vídeo e apresentação: o diretor entra antes da produção
37
+
38
+ Quando o pedido é de vídeo, reels, teaser ou apresentação narrada, o PrimoCode
39
+ não sai montando cena: ele chama primeiro o **diretor** — uma IA que só
40
+ estrutura. Ela devolve o roteiro cena a cena com a **narração escrita para ser
41
+ falada**, a duração, a transição, o movimento de cada cena, a voz (perfil e
42
+ instrução de leitura) e a lista de imagens, logos e música a buscar. Só depois
43
+ disso a produção começa, e ela segue a pauta em vez de inventar.
44
+
45
+ A mídia é de verdade: foto com licença aberta (Openverse), logo em SVG da marca
46
+ (Wikimedia Commons) e, com chave própria, a busca de imagens do Google pela API
47
+ oficial. Música e efeito sonoro vêm do `buscar_audio`, que baixa e já traz o
48
+ crédito; a identidade de uma empresa — logo, cor, paleta, fontes e frase — o
49
+ `marca_da_empresa` lê do site dela. Tudo o que desce guarda a licença ao lado do
50
+ arquivo, e os créditos saem prontos para a cena final. Recorte de fundo com
51
+ `rembg`, quando instalado.
52
+
53
+ O motor não é gosto: vídeo curto sai pelo estúdio, em segundos, sem instalar
54
+ nada; vídeo longo, canal alfa, ProRes ou fps alto viram projeto **Remotion** na
55
+ sua pasta — e a resposta sempre diz por que escolheu.
56
+
34
57
  ## Instalação
35
58
 
36
59
  **macOS / Linux / WSL** — um comando, e ele resolve tudo (inclusive instalar o Node, se faltar):
package/bin/primocode.js CHANGED
@@ -228,6 +228,69 @@ function sliderEffort(atual) {
228
228
  });
229
229
  }
230
230
 
231
+ /* ── A LISTA COM AS SETINHAS ─────────────────────────────────────────────
232
+ *
233
+ * "O usuário seleciona usando as setinhas, igual você."
234
+ *
235
+ * ↑↓ anda, Enter confirma, Esc fica com o primeiro item. O número também
236
+ * funciona — quem já sabe qual quer não precisa navegar até lá.
237
+ *
238
+ * Devolve `undefined` quando não há terminal de verdade: aí quem chamou cai
239
+ * na lista numerada, que funciona em pipe e em CI. `null` é outra coisa —
240
+ * é Esc, que É uma resposta ("tanto faz, a primeira").
241
+ */
242
+ function menuSetas({ titulo, ajuda, lista }) {
243
+ return new Promise((resolve) => {
244
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return resolve(undefined);
245
+ let idx = 0;
246
+ const linhas = () => {
247
+ const fora = [''];
248
+ fora.push(c.bold(c.brand(' ' + titulo)));
249
+ if (ajuda) fora.push(c.muted(' ' + ajuda));
250
+ fora.push('');
251
+ lista.forEach((e, i) => {
252
+ const aqui = i === idx;
253
+ fora.push((aqui ? c.brand(' ❯ ') : ' ') + (aqui ? c.bold(c.white(e.titulo)) : c.muted(e.titulo)));
254
+ fora.push(' ' + c.dim(e.detalhe || ''));
255
+ });
256
+ fora.push('');
257
+ return fora;
258
+ };
259
+ const ALTURA = linhas().length;
260
+
261
+ const pintar = (primeira) => {
262
+ if (!primeira) process.stdout.write(`\x1b[${ALTURA}A\r`);
263
+ process.stdout.write(linhas().map((l) => '\x1b[2K' + l).join('\n') + '\n');
264
+ };
265
+
266
+ const encerrar = (valor) => {
267
+ process.stdin.removeListener('keypress', aoTeclar);
268
+ sliderAtivo = false;
269
+ try { process.stdout.write('\x1b[?25h'); } catch {}
270
+ resolve(valor);
271
+ };
272
+ const aoTeclar = (ch, key) => {
273
+ if (!key) return;
274
+ if (key.name === 'down' || key.name === 'j' || (key.name === 'tab' && !key.shift)) {
275
+ idx = (idx + 1) % lista.length; return pintar(false);
276
+ }
277
+ if (key.name === 'up' || key.name === 'k' || (key.name === 'tab' && key.shift)) {
278
+ idx = (idx - 1 + lista.length) % lista.length; return pintar(false);
279
+ }
280
+ if (/^[1-9]$/.test(ch || '') && Number(ch) <= lista.length) {
281
+ idx = Number(ch) - 1; return pintar(false);
282
+ }
283
+ if (key.name === 'return' || key.name === 'enter') return encerrar(lista[idx].id);
284
+ if (key.name === 'escape' || (key.ctrl && key.name === 'c')) return encerrar(null);
285
+ };
286
+
287
+ sliderAtivo = true; // enquanto está no ar, o resto do teclado se cala
288
+ try { process.stdout.write('\x1b[?25l'); } catch {}
289
+ pintar(true);
290
+ process.stdin.on('keypress', aoTeclar);
291
+ });
292
+ }
293
+
231
294
  function aplicarEffort(nome) {
232
295
  const nivel = effort.nivelDe(nome);
233
296
  state.config.effort = nivel.nome;
@@ -1652,6 +1715,10 @@ async function main() {
1652
1715
  }
1653
1716
 
1654
1717
  rl.on('line', (line) => {
1718
+ // A lista de setinhas (e o slider do /effort) são donos do teclado
1719
+ // enquanto estão no ar: o Enter que confirma a escolha não é uma
1720
+ // linha para a fila, e sem isto ele vira um pedido em branco.
1721
+ if (sliderAtivo) return;
1655
1722
  if (pendingAsk) { const resolve = pendingAsk; pendingAsk = null; resolve(line); return; }
1656
1723
  // Abertura correndo: guarda em vez de mandar ao agente.
1657
1724
  if (segurando) { adiantadas.push(line); return; }
@@ -1687,6 +1754,29 @@ async function main() {
1687
1754
  interativo: !!(process.stdin.isTTY && process.stdout.isTTY),
1688
1755
  conectado: () => conta.atual(),
1689
1756
  conectar: () => handleSlash('/connect').then(() => conta.atual()),
1757
+ /* A ESCOLHA, COM AS SETINHAS — E O QUE FOI DIGITADO ANTES DELA.
1758
+ *
1759
+ * A lista só aparece depois de o plano voltar do servidor, e quem
1760
+ * colou "3⏎1⏎/version" já respondeu antes de ver. Esse texto está em
1761
+ * `adiantadas`; se a lista ignorasse, a resposta se perderia e o
1762
+ * menu ficaria esperando um teclado que já falou. Então: primeiro o
1763
+ * que foi adiantado (se casar com algum item), depois as setinhas. O
1764
+ * que não casa fica na fila — é comando, não resposta. */
1765
+ selecionar: ({ titulo, ajuda, lista }) => {
1766
+ while (adiantadas.length) {
1767
+ const dito = String(adiantadas[0] || '').trim();
1768
+ if (!dito) { adiantadas.shift(); continue; }
1769
+ const qual = boasVindas.escolher(dito, lista);
1770
+ if (!qual) break; // isso é comando, não escolha
1771
+ adiantadas.shift();
1772
+ const item = lista.find((e) => e.id === qual);
1773
+ console.log('');
1774
+ console.log(c.bold(c.brand(' ' + titulo)));
1775
+ console.log(c.brand(' ❯ ') + c.white(item ? item.titulo : qual));
1776
+ return Promise.resolve(qual);
1777
+ }
1778
+ return menuSetas({ titulo, ajuda, lista });
1779
+ },
1690
1780
  sessao: () => conta.sessao(),
1691
1781
  aplicar: (cmd) => handleSlash(cmd),
1692
1782
  }).then((r) => {
package/lib/acervo.js ADDED
@@ -0,0 +1,391 @@
1
+ /**
2
+ * acervo.js — de onde vêm as imagens, os logos e a música.
3
+ *
4
+ * "Pesquise e use imagens e logos do Google, baixe imagens, corte fundo, use
5
+ * músicas da internet."
6
+ *
7
+ * ── POR QUE ISTO NÃO É "BAIXAR DO GOOGLE" E PRONTO ───────────────────────
8
+ * Porque o Google Imagens não é uma API: raspar a página dele é contra os
9
+ * termos, quebra sem aviso e devolve a MINIATURA, que num vídeo 1920×1080
10
+ * aparece como um borrão. O que existe de verdade são três caminhos, e este
11
+ * módulo usa os três, nesta ordem:
12
+ *
13
+ * 1. OPENVERSE — busca de imagem E de áudio com licença aberta, sem chave.
14
+ * É o padrão: funciona na máquina de todo mundo, no primeiro minuto.
15
+ * 2. WIKIMEDIA COMMONS — é onde MORAM os logos das marcas, em SVG e PNG
16
+ * grandes. Uma busca de logo que não passa por aqui devolve fan art.
17
+ * 3. GOOGLE CUSTOM SEARCH — a busca do Google DE VERDADE, pela API oficial,
18
+ * quando a pessoa tem chave (`PRIMOCODE_GOOGLE_KEY` + `PRIMOCODE_GOOGLE_CX`).
19
+ * Cobre o que os dois primeiros não têm.
20
+ *
21
+ * ── A LICENÇA ANDA JUNTO DO ARQUIVO ──────────────────────────────────────
22
+ * Todo download grava um `.json` ao lado com fonte, autor e licença. Não é
23
+ * burocracia: um vídeo de cliente com uma foto "CC BY" sem crédito é um
24
+ * problema jurídico do cliente, e descobrir isso depois de publicar é tarde.
25
+ * `creditos()` monta a lista pronta para a cena final ou a descrição.
26
+ *
27
+ * ── O QUE NÃO ENTRA AQUI ─────────────────────────────────────────────────
28
+ * Nada é baixado para dentro do pacote npm nem fica de dependência. O acervo
29
+ * mora em `~/.primocode/acervo`, com cache por URL: a mesma foto pedida duas
30
+ * vezes desce uma vez só.
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ const fs = require('fs');
36
+ const os = require('os');
37
+ const path = require('path');
38
+ const https = require('https');
39
+ const http = require('http');
40
+ const { spawn, spawnSync } = require('child_process');
41
+
42
+ const PASTA = () => path.join(os.homedir(), '.primocode', 'acervo');
43
+ const TETO_BYTES = 25 * 1024 * 1024; // 25 MB: foto grande cabe, filme não
44
+ const TIPOS_OK = /^(image\/(jpeg|png|webp|gif|svg\+xml)|audio\/(mpeg|mp4|ogg|wav|flac)|video\/mp4)/;
45
+
46
+ // ── a rede, sem dependência ──────────────────────────────────────────────
47
+
48
+ function buscarJSON(url, { timeout = 15000, cabecalhos = {} } = {}) {
49
+ return new Promise((resolve, reject) => {
50
+ const req = https.get(url, {
51
+ timeout,
52
+ headers: { 'User-Agent': agente(), Accept: 'application/json', ...cabecalhos },
53
+ }, (res) => {
54
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
55
+ res.resume();
56
+ return resolve(buscarJSON(new URL(res.headers.location, url).href, { timeout, cabecalhos }));
57
+ }
58
+ let txt = '';
59
+ res.setEncoding('utf8');
60
+ res.on('data', (d) => { txt += d; });
61
+ res.on('end', () => {
62
+ if (res.statusCode >= 400) return reject(new Error(`${res.statusCode} em ${new URL(url).host}`));
63
+ try { resolve(JSON.parse(txt)); } catch { reject(new Error('resposta que não é JSON')); }
64
+ });
65
+ });
66
+ req.on('timeout', () => req.destroy(new Error('o servidor demorou demais')));
67
+ req.on('error', reject);
68
+ });
69
+ }
70
+
71
+ /* O User-Agent é EXIGIDO pela Wikimedia — sem ele a API responde 403, e o
72
+ erro não diz isso. Vai com o nome do programa e a versão, que é o que a
73
+ política deles pede. */
74
+ function agente() {
75
+ let v = '?';
76
+ try { v = require('../package.json').version; } catch { /* sem package: tanto faz */ }
77
+ return `PrimoCode/${v} (https://github.com/parisgroup-ai/primocode)`;
78
+ }
79
+
80
+ // ── as buscas ────────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Imagens com licença aberta, do Openverse. Sem chave, sem cadastro.
84
+ * `licenca: 'comercial'` (padrão) só traz o que pode ir em vídeo de cliente.
85
+ */
86
+ async function openverseImagens(consulta, { quantos = 8, licenca = 'comercial', orientacao } = {}) {
87
+ const q = new URLSearchParams({ q: consulta, page_size: String(Math.min(20, quantos * 2)) });
88
+ if (licenca === 'comercial') q.set('license_type', 'commercial');
89
+ if (orientacao) q.set('aspect_ratio', orientacao === 'retrato' ? 'tall'
90
+ : orientacao === 'quadrado' ? 'square' : 'wide');
91
+ const r = await buscarJSON('https://api.openverse.org/v1/images/?' + q.toString());
92
+ return (r.results || []).slice(0, quantos).map((i) => ({
93
+ fonte: 'openverse', titulo: i.title || consulta, url: i.url, pagina: i.foreign_landing_url,
94
+ autor: i.creator || null, licenca: [i.license, i.license_version].filter(Boolean).join(' ').toUpperCase(),
95
+ largura: i.width || null, altura: i.height || null, tipo: i.filetype || null,
96
+ }));
97
+ }
98
+
99
+ /** Música e efeitos, do mesmo Openverse — é ele quem indexa ccMixter e Jamendo. */
100
+ async function openverseAudio(consulta, { quantos = 6, licenca = 'comercial', segundosMin = 20 } = {}) {
101
+ const q = new URLSearchParams({ q: consulta, page_size: String(Math.min(20, quantos * 3)) });
102
+ if (licenca === 'comercial') q.set('license_type', 'commercial');
103
+ const r = await buscarJSON('https://api.openverse.org/v1/audio/?' + q.toString());
104
+ return (r.results || [])
105
+ .filter((a) => !segundosMin || !a.duration || a.duration / 1000 >= segundosMin)
106
+ .slice(0, quantos)
107
+ .map((a) => ({
108
+ fonte: 'openverse', titulo: a.title || consulta,
109
+ url: a.url || (a.alt_files && a.alt_files[0] && a.alt_files[0].url),
110
+ pagina: a.foreign_landing_url, autor: a.creator || null,
111
+ licenca: [a.license, a.license_version].filter(Boolean).join(' ').toUpperCase(),
112
+ segundos: a.duration ? Math.round(a.duration / 1000) : null,
113
+ genero: (a.genres || []).join(', ') || null,
114
+ }))
115
+ .filter((a) => a.url);
116
+ }
117
+
118
+ /**
119
+ * Logos, no lugar onde eles moram de verdade: o Wikimedia Commons.
120
+ *
121
+ * A busca vai atrás de "<marca> logo" no espaço de arquivos e devolve a URL
122
+ * do ORIGINAL — normalmente SVG, que é o que se quer num vídeo: escala sem
123
+ * borrar. Procurar logo numa busca de fotos devolve fan art e camiseta.
124
+ */
125
+ async function wikimediaLogos(marca, { quantos = 6 } = {}) {
126
+ const q = new URLSearchParams({
127
+ action: 'query', format: 'json', origin: '*', generator: 'search',
128
+ gsrsearch: `${marca} logo`, gsrnamespace: '6', gsrlimit: String(Math.min(20, quantos * 2)),
129
+ prop: 'imageinfo', iiprop: 'url|size|extmetadata', iiurlwidth: '1024',
130
+ });
131
+ const r = await buscarJSON('https://commons.wikimedia.org/w/api.php?' + q.toString());
132
+ const paginas = r && r.query && r.query.pages ? Object.values(r.query.pages) : [];
133
+ return paginas
134
+ .map((p) => {
135
+ const info = (p.imageinfo || [])[0] || {};
136
+ const meta = info.extmetadata || {};
137
+ return {
138
+ fonte: 'wikimedia', titulo: p.title || marca,
139
+ url: info.url, pagina: info.descriptionurl,
140
+ autor: limpar(meta.Artist && meta.Artist.value) || null,
141
+ licenca: (meta.LicenseShortName && meta.LicenseShortName.value) || 'ver a página',
142
+ largura: info.width || null, altura: info.height || null,
143
+ vetor: /\.svg$/i.test(info.url || ''),
144
+ };
145
+ })
146
+ .filter((i) => i.url && /\.(svg|png|jpe?g|webp)$/i.test(i.url))
147
+ .sort((a, b) => Number(b.vetor) - Number(a.vetor)) // SVG primeiro
148
+ .slice(0, quantos);
149
+ }
150
+
151
+ function limpar(html) {
152
+ return html ? String(html).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() : null;
153
+ }
154
+
155
+ /**
156
+ * A busca do Google DE VERDADE — pela API oficial, com a chave da pessoa.
157
+ * Sem chave, devolve lista vazia e quem chamou segue com os outros caminhos:
158
+ * não é erro, é um caminho que esta máquina não tem.
159
+ */
160
+ async function googleImagens(consulta, { quantos = 8, chave, cx, tipo } = {}) {
161
+ const key = chave || process.env.PRIMOCODE_GOOGLE_KEY;
162
+ const eng = cx || process.env.PRIMOCODE_GOOGLE_CX;
163
+ if (!key || !eng) return [];
164
+ const q = new URLSearchParams({
165
+ key, cx: eng, q: consulta, searchType: 'image', num: String(Math.min(10, quantos)),
166
+ safe: 'active', imgSize: 'xlarge',
167
+ });
168
+ if (tipo === 'logo') { q.set('imgType', 'clipart'); q.set('imgSize', 'large'); }
169
+ const r = await buscarJSON('https://www.googleapis.com/customsearch/v1?' + q.toString());
170
+ return (r.items || []).map((i) => ({
171
+ fonte: 'google', titulo: i.title, url: i.link,
172
+ pagina: (i.image && i.image.contextLink) || i.link,
173
+ autor: i.displayLink || null, licenca: 'confira na página de origem',
174
+ largura: i.image && i.image.width, altura: i.image && i.image.height,
175
+ }));
176
+ }
177
+
178
+ /**
179
+ * A busca que o agente chama: tenta os caminhos na ordem certa para o que
180
+ * está sendo procurado, e junta.
181
+ *
182
+ * Logo pede Wikimedia primeiro; foto pede Openverse. O Google entra sempre
183
+ * que houver chave — é ele que cobre o que falta.
184
+ */
185
+ async function procurar(consulta, { tipo = 'imagem', quantos = 8, licenca = 'comercial', orientacao } = {}) {
186
+ const achados = [];
187
+ const tentar = async (fn) => {
188
+ try { achados.push(...(await fn())); } catch (e) { falhas.push(e.message); }
189
+ };
190
+ const falhas = [];
191
+
192
+ if (tipo === 'musica' || tipo === 'audio') {
193
+ await tentar(() => openverseAudio(consulta, { quantos, licenca }));
194
+ } else if (tipo === 'logo') {
195
+ await tentar(() => wikimediaLogos(consulta, { quantos }));
196
+ await tentar(() => googleImagens(consulta + ' logo', { quantos, tipo: 'logo' }));
197
+ if (!achados.length) await tentar(() => openverseImagens(consulta + ' logo', { quantos, licenca }));
198
+ } else {
199
+ await tentar(() => openverseImagens(consulta, { quantos, licenca, orientacao }));
200
+ await tentar(() => googleImagens(consulta, { quantos }));
201
+ }
202
+
203
+ const vistos = new Set();
204
+ const lista = achados.filter((a) => a.url && !vistos.has(a.url) && vistos.add(a.url));
205
+ return { ok: lista.length > 0, tipo, consulta, achados: lista.slice(0, quantos),
206
+ falhas: lista.length ? [] : falhas };
207
+ }
208
+
209
+ // ── baixar ───────────────────────────────────────────────────────────────
210
+
211
+ function nomeDe(url, titulo) {
212
+ let ext = '';
213
+ try { ext = path.extname(new URL(url).pathname).slice(0, 6); } catch { /* url estranha */ }
214
+ const base = String(titulo || 'arquivo').toLowerCase()
215
+ .normalize('NFD').replace(/[̀-ͯ]/g, '')
216
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'arquivo';
217
+ const marca = hash(url);
218
+ return `${base}-${marca}${ext || ''}`;
219
+ }
220
+
221
+ function hash(texto) {
222
+ // Um identificador curto e estável por URL. Não é segurança — é cache.
223
+ let h = 0;
224
+ for (let i = 0; i < texto.length; i++) { h = (h * 31 + texto.charCodeAt(i)) | 0; }
225
+ return Math.abs(h).toString(36).slice(0, 7);
226
+ }
227
+
228
+ /**
229
+ * Baixa e guarda, com a licença ao lado. Mesma URL duas vezes: baixa uma.
230
+ *
231
+ * Recusa o que não é mídia (uma página de erro em HTML chega com 200 e
232
+ * viraria um "jpg" que não abre) e o que passa do teto.
233
+ */
234
+ function baixar(url, { titulo, meta = {}, pasta = PASTA(), teto = TETO_BYTES } = {}) {
235
+ return new Promise((resolve) => {
236
+ let destino;
237
+ try { destino = path.join(pasta, nomeDe(url, titulo)); }
238
+ catch { return resolve({ ok: false, error: 'url inválida' }); }
239
+
240
+ if (fs.existsSync(destino) && fs.statSync(destino).size > 0) {
241
+ return resolve({ ok: true, arquivo: destino, cache: true, ...lerFicha(destino) });
242
+ }
243
+ fs.mkdirSync(pasta, { recursive: true });
244
+
245
+ const puxar = (alvo, saltos = 0) => {
246
+ let u;
247
+ try { u = new URL(alvo); } catch { return resolve({ ok: false, error: 'url inválida' }); }
248
+ const lib = u.protocol === 'https:' ? https : http;
249
+ const req = lib.get(u, { timeout: 30000, headers: { 'User-Agent': agente() } }, (res) => {
250
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
251
+ res.resume();
252
+ if (saltos > 4) return resolve({ ok: false, error: 'redirecionamento sem fim' });
253
+ return puxar(new URL(res.headers.location, alvo).href, saltos + 1);
254
+ }
255
+ if (res.statusCode !== 200) {
256
+ res.resume();
257
+ return resolve({ ok: false, error: `o servidor respondeu ${res.statusCode}` });
258
+ }
259
+ const tipo = String(res.headers['content-type'] || '').split(';')[0];
260
+ if (tipo && !TIPOS_OK.test(tipo)) {
261
+ res.resume();
262
+ return resolve({ ok: false, error: `isso não é mídia (${tipo})` });
263
+ }
264
+ const tamanho = Number(res.headers['content-length'] || 0);
265
+ if (tamanho && tamanho > teto) {
266
+ res.resume();
267
+ return resolve({ ok: false, error: `arquivo grande demais (${Math.round(tamanho / 1e6)} MB)` });
268
+ }
269
+ let escritos = 0;
270
+ const saida = fs.createWriteStream(destino);
271
+ res.on('data', (p) => {
272
+ escritos += p.length;
273
+ if (escritos > teto) {
274
+ req.destroy();
275
+ saida.destroy();
276
+ try { fs.rmSync(destino, { force: true }); } catch {}
277
+ resolve({ ok: false, error: 'arquivo grande demais' });
278
+ }
279
+ });
280
+ res.pipe(saida);
281
+ saida.on('finish', () => {
282
+ const ficha = { url, titulo: titulo || null, baixado: new Date().toISOString(), ...meta };
283
+ try { fs.writeFileSync(destino + '.json', JSON.stringify(ficha, null, 2), 'utf8'); } catch {}
284
+ resolve({ ok: true, arquivo: destino, bytes: escritos, cache: false, ficha });
285
+ });
286
+ saida.on('error', (e) => resolve({ ok: false, error: e.message }));
287
+ });
288
+ req.on('timeout', () => req.destroy(new Error('demorou demais')));
289
+ req.on('error', (e) => resolve({ ok: false, error: e.message }));
290
+ };
291
+ puxar(url);
292
+ });
293
+ }
294
+
295
+ function lerFicha(arquivo) {
296
+ try { return { ficha: JSON.parse(fs.readFileSync(arquivo + '.json', 'utf8')) }; }
297
+ catch { return { ficha: null }; }
298
+ }
299
+
300
+ /** A lista de créditos do que foi usado — pronta para a cena final. */
301
+ function creditos(arquivos = null, pasta = PASTA()) {
302
+ let fichas = [];
303
+ try {
304
+ const alvos = arquivos || fs.readdirSync(pasta)
305
+ .filter((f) => f.endsWith('.json'))
306
+ .map((f) => path.join(pasta, f.replace(/\.json$/, '')));
307
+ fichas = alvos.map((a) => lerFicha(a).ficha).filter(Boolean);
308
+ } catch { /* sem acervo ainda */ }
309
+ return fichas.map((f) => ({
310
+ titulo: f.titulo, autor: f.autor || null, licenca: f.licenca || null,
311
+ fonte: f.fonte || null, pagina: f.pagina || f.url,
312
+ linha: [f.titulo, f.autor && `por ${f.autor}`, f.licenca, f.fonte]
313
+ .filter(Boolean).join(' · '),
314
+ }));
315
+ }
316
+
317
+ // ── tirar o fundo ────────────────────────────────────────────────────────
318
+
319
+ /**
320
+ * Qual ferramenta desta máquina sabe recortar. A decisão fica separada do
321
+ * processo para poder ser testada sem recortar nada.
322
+ */
323
+ function comoTirarFundo({ temRembg, temBackgroundRemover, temPython, temFfmpeg } = {}) {
324
+ if (temRembg) return 'rembg';
325
+ if (temBackgroundRemover) return 'backgroundremover';
326
+ if (temPython) return 'python-rembg';
327
+ if (temFfmpeg) return 'ffmpeg-chroma'; // só resolve fundo de cor chapada
328
+ return null;
329
+ }
330
+
331
+ function existe(cmd) {
332
+ try { return spawnSync(cmd, ['--help'], { stdio: 'ignore', timeout: 8000 }).status != null; }
333
+ catch { return false; }
334
+ }
335
+
336
+ /**
337
+ * Recorta o fundo, com o que houver instalado.
338
+ *
339
+ * O caminho bom é o `rembg` (modelo U²-Net), que resolve pessoa, produto e
340
+ * logo com sombra. O `ffmpeg -colorkey` é o último recurso e só serve para
341
+ * fundo de UMA cor chapada — dizer que ele "tira o fundo" de uma foto
342
+ * qualquer seria mentira, e por isso o resultado vem com `metodo` na
343
+ * resposta: quem chamou mostra o que foi feito.
344
+ */
345
+ async function tirarFundo(arquivo, { destino, cor = '0x00FF00', tolerancia = 0.3 } = {}) {
346
+ if (!fs.existsSync(arquivo)) return { ok: false, error: 'esse arquivo não existe' };
347
+ const saida = destino || arquivo.replace(/\.[^.]+$/, '') + '-sem-fundo.png';
348
+ const metodo = comoTirarFundo({
349
+ temRembg: existe('rembg'),
350
+ temBackgroundRemover: existe('backgroundremover'),
351
+ // `import rembg` só sai 0 quando o pacote ESTÁ lá — é a pergunta
352
+ // certa: ter python não é ter recortador.
353
+ temPython: (() => {
354
+ try { return spawnSync('python3', ['-c', 'import rembg'], { stdio: 'ignore', timeout: 20000 }).status === 0; }
355
+ catch { return false; }
356
+ })(),
357
+ temFfmpeg: existe('ffmpeg'),
358
+ });
359
+ if (!metodo) {
360
+ return { ok: false, error: 'nada nesta máquina sabe recortar fundo',
361
+ comoResolver: 'instale com: pip install rembg (ou pipx install rembg)' };
362
+ }
363
+
364
+ const rodar = (cmd, args) => new Promise((ok) => {
365
+ const p = spawn(cmd, args, { stdio: 'ignore' });
366
+ p.on('error', () => ok(false));
367
+ p.on('close', (cod) => ok(cod === 0));
368
+ });
369
+
370
+ let deu = false;
371
+ if (metodo === 'rembg') deu = await rodar('rembg', ['i', arquivo, saida]);
372
+ else if (metodo === 'backgroundremover') deu = await rodar('backgroundremover', ['-i', arquivo, '-o', saida]);
373
+ else if (metodo === 'python-rembg') {
374
+ deu = await rodar('python3', ['-c',
375
+ 'import sys;from rembg import remove;open(sys.argv[2],"wb").write(remove(open(sys.argv[1],"rb").read()))',
376
+ arquivo, saida]);
377
+ } else {
378
+ deu = await rodar('ffmpeg', ['-y', '-i', arquivo, '-vf',
379
+ `colorkey=${cor}:${tolerancia}:0.1`, '-c:a', 'copy', saida]);
380
+ }
381
+
382
+ if (!deu || !fs.existsSync(saida)) return { ok: false, error: `o recorte falhou (${metodo})`, metodo };
383
+ return { ok: true, arquivo: saida, metodo,
384
+ aviso: metodo === 'ffmpeg-chroma'
385
+ ? 'recorte por cor: só funciona bem em fundo de uma cor só' : null };
386
+ }
387
+
388
+ module.exports = {
389
+ procurar, openverseImagens, openverseAudio, wikimediaLogos, googleImagens,
390
+ baixar, creditos, tirarFundo, comoTirarFundo, nomeDe, PASTA,
391
+ };
package/lib/act.js CHANGED
@@ -760,6 +760,22 @@ async function runAct(userPrompt, opts) {
760
760
  continue;
761
761
  }
762
762
 
763
+ /* O DIRETOR mora aqui pelo mesmo motivo do pipeline: ele roda
764
+ um AGENTE, e quem sabe rodar agente é este arquivo. É a peça
765
+ que o dono pediu — "a IA identifica que precisa criar vídeo,
766
+ ativa o agente, esse agente faz a estrutura e manda pra IA, e
767
+ depois ela só faz o vídeo". */
768
+ if (tc.name === 'planejar_producao') {
769
+ algoRodou = true;
770
+ fala.acao(tc.name, tc.arguments, profundidade);
771
+ const r = await rodarDiretor(tc.arguments || {}, {
772
+ server, model, token, projectDir: pastaAtual, projectName, profundidade, motor,
773
+ });
774
+ logResult(tc.name, r, profundidade);
775
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(r) });
776
+ continue;
777
+ }
778
+
763
779
  // Repetiu a mesma chamada? Não executa de novo. Rodar outra vez
764
780
  // gastaria a cota para devolver exatamente o mesmo texto, e é
765
781
  // esse texto que convence o modelo a repetir mais uma vez. Sem
@@ -994,5 +1010,60 @@ async function rodarPipeline(args, ctx) {
994
1010
  });
995
1011
  }
996
1012
 
1013
+ /* ══ O DIRETOR ═════════════════════════════════════════════════════════════
1014
+ *
1015
+ * "Tenha uma IA específica pra roteiro que monta tudo — narração, qual voz,
1016
+ * roteiro. Ela estrutura tudo antes de mandar pra IA."
1017
+ *
1018
+ * As regras, o prompt e a conferência do plano moram em `diretor.js`. Aqui é
1019
+ * a costura: uma conversa NOVA para o diretor (ele não vê a do usuário nem a
1020
+ * do produtor — é isso que faz o roteiro parar de virar legenda do slide) e a
1021
+ * pauta de volta, que é o que o produtor recebe no lugar do pedido cru.
1022
+ */
1023
+ async function rodarDiretor(args, ctx) {
1024
+ const { server, model, token, projectDir, projectName, profundidade, motor } = ctx;
1025
+ const diretor = require('./diretor.js');
1026
+
1027
+ const correr = async (prompt, rotulo) => {
1028
+ if (PARAR) return '';
1029
+ fala.agente('Diretor', rotulo, profundidade);
1030
+ const r = await runAct(prompt, {
1031
+ server, model, token, projectDir, projectName,
1032
+ history: [], profundidade: profundidade + 1, subagent: true, motor,
1033
+ });
1034
+ return (r && r.assistantMessage && r.assistantMessage.content) || '';
1035
+ };
1036
+
1037
+ const r = await diretor.planejar({
1038
+ pedido: args.pedido || args.prompt || '',
1039
+ segundos: Number(args.segundos) || 30,
1040
+ formato: args.formato === 'apresentacao' ? 'apresentacao' : 'video',
1041
+ contexto: args.contexto || '',
1042
+ correr,
1043
+ mostrar: (linha) => console.log(fala.resultado(profundidade) + c.muted(linha)),
1044
+ });
1045
+ if (!r.ok) return r;
1046
+
1047
+ return {
1048
+ ok: true,
1049
+ motor: r.plano.motor,
1050
+ voz: r.plano.voz,
1051
+ midia: r.plano.midia,
1052
+ trilha: r.plano.trilha,
1053
+ pauta: diretor.pauta(r.plano),
1054
+ plano: r.plano,
1055
+ ressalvas: r.defeitos,
1056
+ aviso: 'A ESTRUTURA ESTÁ PRONTA. Agora só PRODUZA: busque a mídia listada com '
1057
+ + 'midia_procurar + midia_baixar (use o "src" que volta em fundo.imagem e em src); '
1058
+ + 'a TRILHA vem do buscar_audio (já baixa e credita) e a identidade de uma empresa, do marca_da_empresa. '
1059
+ + `e monte com studio_criar (${r.plano.formato === 'apresentacao' ? 'prisma' : 'corte'}) `
1060
+ + 'seguindo a pauta cena a cena. Não invente cena, não troque a ordem, não reescreva a narração. '
1061
+ + (r.plano.motor.motor === 'remotion'
1062
+ ? 'Depois de criar, gere o projeto Remotion com remotion_projeto: '
1063
+ + r.plano.motor.motivo + '.'
1064
+ : 'O motor é o estúdio: ' + r.plano.motor.motivo + '.'),
1065
+ };
1066
+ }
1067
+
997
1068
  module.exports = { runAct, interromper, compactar, motivoDaParada, ehTetoDeToken, ehTransitorio, esperaDoErro,
998
1069
  anotacaoDoQueJaFoi, encolherParaContinuar, pareceParadaNoMeio, MAX_ITERATIONS, MAX_EMPURROES };
@@ -64,7 +64,12 @@ async function procurar({ consulta, tipo = 'musica', dur = 0, comercial = true,
64
64
  if (!bruto) return { ok: false, error: 'o Openverse não respondeu (rede?)' };
65
65
  let j;
66
66
  try { j = JSON.parse(bruto); } catch { return { ok: false, error: 'resposta ilegível do Openverse' }; }
67
- const itens = (j.results || []).filter((f) => f.url && LIVRES.has(String(f.license || '').toLowerCase()) || !comercial);
67
+ // `f.url &&` PRECISA valer sempre: sem o parêntese, `&&` liga mais forte
68
+ // que `||` e `comercial: false` deixava passar item sem url (o download
69
+ // falhava três vezes e a tool dizia "não consegui baixar") E, pior, faixa
70
+ // NC dentro de peça comercial — o que a lista LIVRES existe para impedir.
71
+ const itens = (j.results || []).filter((f) => f.url
72
+ && (LIVRES.has(String(f.license || '').toLowerCase()) || !comercial));
68
73
  const alvoMs = tipo === 'efeito' ? 2500 : (dur ? dur * 1000 : 90000);
69
74
  const notas = itens.map((f) => {
70
75
  let n = 0;