primocode 8.39.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 (40) 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/remotion.js +245 -45
  11. package/lib/studio.js +54 -2
  12. package/lib/tools.js +46 -0
  13. package/lib/ui.js +3 -2
  14. package/package.json +2 -2
  15. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  16. package/studio/ferramentas/corte.md +23 -28
  17. package/studio/web/js/render.js +32 -13
  18. package/studio/__pycache__/edge.cpython-311.pyc +0 -0
  19. package/studio/__pycache__/elevenlabs.cpython-311.pyc +0 -0
  20. package/studio/arquivos/corte-estatico-corte-a5bab.json +0 -150
  21. package/studio/arquivos/corte-founders-ai-teste-do-player-2de1d.json +0 -65
  22. package/studio/arquivos/corte-legado-teste.json +0 -1
  23. package/studio/arquivos/corte-legado2.json +0 -1
  24. package/studio/arquivos/corte-minimo-2a829.json +0 -24
  25. package/studio/arquivos/corte-ok-00a3b.json +0 -18
  26. package/studio/arquivos/corte-ok-1270e.json +0 -22
  27. package/studio/arquivos/corte-sweep-corte-cfb29.json +0 -147
  28. package/studio/arquivos/corte-visual-completo-78f50.json +0 -35
  29. package/studio/arquivos/grade-sweep-grade-0c2f1.json +0 -119
  30. package/studio/arquivos/prisma-estatico-prisma-d8740.json +0 -182
  31. package/studio/arquivos/prisma-sweep-prisma-4c58c.json +0 -166
  32. package/studio/arquivos/prisma-y-b0785.json +0 -52
  33. package/studio/arquivos/prosa-sweep-prosa-f29cf.json +0 -80
  34. package/studio/arquivos/tela-estatico-tela-c7daf.json +0 -90
  35. package/studio/arquivos/tela-motion-3ecaa.json +0 -135
  36. package/studio/arquivos/tela-motion-d6150.json +0 -135
  37. package/studio/arquivos/tela-sweep-tela-0d2bf.json +0 -84
  38. package/studio/arquivos/traco-estatico-traco-25872.json +0 -113
  39. package/studio/arquivos/traco-sweep-traco-0bcf4.json +0 -102
  40. package/studio/studio.log +0 -28
@@ -16,6 +16,14 @@
16
16
  * pra próxima tela, e aí você seleciona Claude ou GPT — e quando seleciona,
17
17
  * ele dá aquele /gpt ou /claude, e vai funcionar como já funciona hoje."
18
18
  *
19
+ * ── O FLUXO, DO NPM AO PRIMEIRO PEDIDO ───────────────────────────────────
20
+ *
21
+ * npm → `primocode` → login (a página /token do conecta-primo-ai, que é o
22
+ * login normal do app com o token do PrimoCode; o site fala com o terminal
23
+ * sozinho) → "✅ Autenticado, redirecionando…" → "Como quer prosseguir?",
24
+ * com as SETINHAS: API padrão, API própria de outra IA, ou uma IA já
25
+ * configurada. Escolheu, está pronto para usar.
26
+ *
19
27
  * ── A ORDEM É O PRODUTO ──────────────────────────────────────────────────
20
28
  * Conectar → conferir o plano → escolher o motor. Nesta ordem, e não em
21
29
  * outra. A primeira versão disto oferecia o login como UMA OPÇÃO de menu, ao
@@ -43,6 +51,8 @@ const fs = require('fs');
43
51
  const os = require('os');
44
52
  const path = require('path');
45
53
 
54
+ const chavesModulo = require('./chaves.js');
55
+
46
56
  const MARCA = () => path.join(os.homedir(), '.primocode', 'ja-abriu.json');
47
57
 
48
58
  /** Já escolheu o motor alguma vez? A marca guarda a escolha, não o login. */
@@ -67,15 +77,17 @@ function esquecer() {
67
77
 
68
78
  const MOTORES = [
69
79
  { id: 'padrao', titulo: 'Continuar com a API padrão',
70
- detalhe: 'A que já vem pronta, incluída no seu plano.' },
71
- { id: 'propria', titulo: 'Usar a minha conta (Claude, GPT…)',
72
- detalhe: 'O Claude Code ou o Codex que você já usa, na sua assinatura.' },
80
+ detalhe: 'A que já vem pronta, incluída no seu plano. É só começar.' },
81
+ { id: 'propria', titulo: 'Usar a API própria de outra IA',
82
+ detalhe: 'Você cola a sua chave do Claude ou do GPT, e ela é usada nas chamadas.' },
83
+ { id: 'configurada', titulo: 'Usar uma IA já configurada',
84
+ detalhe: 'O Claude Code ou o Codex que já está instalado e logado nesta máquina.' },
73
85
  ];
74
86
 
75
87
  const PROVEDORES = [
76
- { id: 'claude', titulo: 'Claude', comando: '/claude',
88
+ { id: 'claude', titulo: 'Claude', comando: '/claude', chave: 'claude',
77
89
  detalhe: 'Usa o Claude Code da sua conta, no Opus.' },
78
- { id: 'gpt', titulo: 'GPT', comando: '/gpt',
90
+ { id: 'gpt', titulo: 'GPT', comando: '/gpt', chave: 'codex',
79
91
  detalhe: 'Usa o Codex da sua conta, num modelo barato.' },
80
92
  ];
81
93
 
@@ -90,14 +102,15 @@ function escolher(texto, lista) {
90
102
  if (!t) return lista[0].id;
91
103
  const n = Number(t);
92
104
  if (Number.isInteger(n) && n >= 1 && n <= lista.length) return lista[n - 1].id;
93
- for (const e of lista) {
94
- if (t === e.id || t === e.titulo.toLowerCase()) return e.id;
95
- if (e.id.length >= 3 && t.startsWith(e.id.slice(0, 3))) return e.id;
96
- }
105
+ // Primeiro o que bate INTEIRO. O prefixo vem depois, senão "continuar com
106
+ // a API padrão" cairia em "configurada" só por começar com "con".
107
+ for (const e of lista) if (t === e.id || t === e.titulo.toLowerCase()) return e.id;
108
+ for (const e of lista) if (e.id.length >= 3 && t.startsWith(e.id.slice(0, 3))) return e.id;
97
109
  // O jeito como as pessoas falam, e não como a lista se chama.
98
110
  if (lista === MOTORES) {
111
+ if (/j[aá] config|j[aá] instal|j[aá] logad|configurad|instalad|logad/.test(t)) return 'configurada';
99
112
  if (/padr|inclu|mesmo|pronta|voc[eê]s|de voc/.test(t)) return 'padrao';
100
- if (/minha|meu|pr[oó]pri|conta|chave|assinatura/.test(t)) return 'propria';
113
+ if (/minha|meu|pr[oó]pri|conta|chave|assinatura|api de outra/.test(t)) return 'propria';
101
114
  } else {
102
115
  if (/claude|anthropic|opus|sonnet/.test(t)) return 'claude';
103
116
  if (/gpt|openai|codex|chatgpt/.test(t)) return 'gpt';
@@ -105,7 +118,19 @@ function escolher(texto, lista) {
105
118
  return null;
106
119
  }
107
120
 
108
- async function perguntarDaLista({ perguntar, mostrar, cores: c, titulo, ajuda, lista }) {
121
+ /**
122
+ * A escolha na tela. Com terminal de verdade é a LISTA COM AS SETINHAS — ↑↓
123
+ * anda, Enter confirma, como no Claude Code: ninguém precisa saber que o item
124
+ * tem número. `selecionar` vem de quem chamou (o bin, que é dono do teclado);
125
+ * onde ele não existe — teste, pipe, terminal sem raw mode — cai na lista
126
+ * numerada de sempre, que responde ao número, ao nome e ao Enter.
127
+ */
128
+ async function perguntarDaLista({ perguntar, mostrar, cores: c, titulo, ajuda, lista, selecionar }) {
129
+ if (selecionar) {
130
+ const escolhido = await selecionar({ titulo, ajuda, lista });
131
+ if (escolhido && lista.some((e) => e.id === escolhido)) return escolhido;
132
+ if (escolhido === null) return lista[0].id; // Esc: fica com a primeira
133
+ }
109
134
  mostrar('');
110
135
  mostrar(c.bold(c.brand(' ' + titulo)));
111
136
  if (ajuda) mostrar(c.muted(' ' + ajuda));
@@ -142,7 +167,7 @@ async function perguntarDaLista({ perguntar, mostrar, cores: c, titulo, ajuda, l
142
167
  * @param {(cmd:string)=>Promise<any>} op.aplicar executa /claude ou /gpt
143
168
  */
144
169
  async function rodar({ perguntar, mostrar, cores: c, interativo = true,
145
- conectado, conectar, sessao, aplicar } = {}) {
170
+ conectado, conectar, sessao, aplicar, selecionar, chaves = chavesModulo } = {}) {
146
171
 
147
172
  /* Sem terminal de verdade (pipe, script, CI) não se pergunta nada — uma
148
173
  pergunta onde ninguém pode responder trava o processo para sempre, e
@@ -166,6 +191,12 @@ async function rodar({ perguntar, mostrar, cores: c, interativo = true,
166
191
  mostrar(c.muted(' Tente de novo com ') + c.brand('/connect') + c.muted('.'));
167
192
  return { liberado: false, motivo: 'sem-login' };
168
193
  }
194
+ /* A MESMA FRASE DOS DOIS LADOS. A página mostra "✅ Autenticado,
195
+ redirecionando…" e some; quem estava olhando o navegador volta ao
196
+ terminal e precisa ver que o terminal soube — senão parece que a
197
+ aprovação ficou no site. */
198
+ mostrar('');
199
+ mostrar(c.ok(' ✅ Autenticado, redirecionando…'));
169
200
  credencial = conectado ? conectado() : { plano: r.plano };
170
201
  }
171
202
 
@@ -209,8 +240,9 @@ async function rodar({ perguntar, mostrar, cores: c, interativo = true,
209
240
  if (jaEscolheu()) return { liberado: true, motivo: 'ja-escolheu', plano, perguntou: false };
210
241
 
211
242
  const motor = await perguntarDaLista({
212
- perguntar, mostrar, cores: c,
213
- titulo: 'Com qual IA você quer trabalhar?',
243
+ perguntar, mostrar, cores: c, selecionar,
244
+ titulo: 'Como quer prosseguir?',
245
+ ajuda: 'Use as setinhas ↑↓ e Enter para confirmar — ou digite o número.',
214
246
  lista: MOTORES,
215
247
  });
216
248
 
@@ -222,21 +254,48 @@ async function rodar({ perguntar, mostrar, cores: c, interativo = true,
222
254
  return { liberado: true, motor: 'padrao', plano, perguntou: true };
223
255
  }
224
256
 
225
- // ── 4. qual provedor ─────────────────────────────────────────────────
257
+ // ── 4. qual IA ───────────────────────────────────────────────────────
258
+ const propria = motor === 'propria';
226
259
  const qual = await perguntarDaLista({
227
- perguntar, mostrar, cores: c,
228
- titulo: 'Qual conta?',
229
- ajuda: 'Ele usa o programa que você já tem instalado e logado.',
260
+ perguntar, mostrar, cores: c, selecionar,
261
+ titulo: propria ? 'De qual IA é a sua chave?' : 'Qual IA já configurada?',
262
+ ajuda: propria
263
+ ? 'A chave fica só nesta máquina, em ~/.primocode/chaves.json.'
264
+ : 'Ele usa o programa que você já tem instalado e logado.',
230
265
  lista: PROVEDORES,
231
266
  });
232
267
  const escolhido = PROVEDORES.find((p) => p.id === qual) || PROVEDORES[0];
233
268
 
234
- marcar({ motor: escolhido.id, plano });
269
+ /* ── A CHAVE PRÓPRIA ─────────────────────────────────────────────────
270
+ * Só neste caminho se pede chave. Quem escolheu "já configurada" não tem
271
+ * chave para colar — a dele está no programa em que já entrou, e pedir
272
+ * uma ali seria inventar um passo que não existe.
273
+ *
274
+ * Chave recusada (colou a do outro provedor, veio truncada) NÃO trava a
275
+ * abertura: diz o que houve, guarda a escolha do motor e segue. O motor
276
+ * roda com o login que a pessoa já tem, e `/chave` conserta depois. */
277
+ if (propria && perguntar) {
278
+ const p = chaves.PROVEDORES[escolhido.chave] || {};
279
+ mostrar('');
280
+ mostrar(c.muted(` Cole a chave do ${escolhido.titulo}`)
281
+ + c.dim(p.onde ? ` (pegue em ${p.onde})` : '') + c.muted('.'));
282
+ mostrar(c.dim(' Enter em branco pula: dá para configurar depois com ')
283
+ + c.brand('/chave ' + escolhido.chave) + c.dim('.'));
284
+ const colada = String(await perguntar(c.brand(' ❯ ')) || '').trim();
285
+ if (colada) {
286
+ const g = chaves.guardar(escolhido.chave, colada);
287
+ mostrar(g.ok
288
+ ? c.ok(` Chave do ${g.nome} guardada.`) + c.muted(` ${g.disfarce}`)
289
+ : c.err(` ${g.error}`));
290
+ }
291
+ }
292
+
293
+ marcar({ motor: escolhido.id, origem: motor, plano });
235
294
  mostrar('');
236
295
  if (aplicar) await aplicar(escolhido.comando);
237
296
  mostrar('');
238
- return { liberado: true, motor: escolhido.id, comando: escolhido.comando,
239
- plano, perguntou: true };
297
+ return { liberado: true, motor: escolhido.id, origem: motor,
298
+ comando: escolhido.comando, plano, perguntou: true };
240
299
  }
241
300
 
242
301
  module.exports = {
package/lib/catalogo.js CHANGED
@@ -89,6 +89,22 @@ const GRUPOS = [
89
89
  { tool: 'studio_listar', escreve: false, faz: 'lista o que já existe no estúdio', args: null },
90
90
  { tool: 'studio_midia', escreve: false, faz: 'manda imagem, música ou vídeo seu para usar nas peças',
91
91
  args: 'arquivo (opcional): caminho no projeto — sem ele, lista a galeria. nome (opcional).' },
92
+ { tool: 'midia_procurar', escreve: false, faz: 'procura imagem, logo ou música na internet, com licença e crédito',
93
+ args: 'consulta: o que procurar, específico ("painel solar em telhado residencial", não "imagem legal"). '
94
+ + 'tipo (opcional): imagem | logo | musica (padrão imagem). quantos (opcional, padrão 6). '
95
+ + 'Para MÚSICA e EFEITO prefira buscar_audio, que já baixa, manda para o estúdio e devolve o crédito; '
96
+ + 'para a logo de uma EMPRESA prefira marca_da_empresa, que traz também cor, paleta e fontes do site dela. '
97
+ + 'orientacao (opcional): paisagem | retrato | quadrado. '
98
+ + 'Logo vem do Wikimedia Commons (SVG de verdade da marca); foto e música do Openverse (licença aberta); '
99
+ + 'com PRIMOCODE_GOOGLE_KEY e PRIMOCODE_GOOGLE_CX configurados, entra também a busca de imagens do Google. '
100
+ + 'Devolve a lista com url, autor e licença — escolha UMA e chame midia_baixar.' },
101
+ { tool: 'midia_baixar', escreve: false, faz: 'baixa o que foi encontrado e já deixa disponível para as peças',
102
+ args: 'url: a url do resultado de midia_procurar. titulo (opcional). semFundo (opcional): true recorta o fundo da imagem. '
103
+ + 'paraEstudio (opcional, padrão true): também manda para a galeria do estúdio e devolve o "/midia/..." para usar em src e fundo. '
104
+ + 'A licença fica gravada junto do arquivo; midia_creditos monta a lista para a cena final.' },
105
+ { tool: 'midia_sem_fundo', escreve: false, faz: 'recorta o fundo de uma imagem que já está na máquina',
106
+ args: 'arquivo: caminho da imagem. destino (opcional). Usa rembg quando existe; sem ele, só fundo de cor chapada (avisa na resposta).' },
107
+ { tool: 'midia_creditos', escreve: false, faz: 'lista os créditos do que foi baixado, prontos para a cena final ou a descrição', args: null },
92
108
  { tool: 'remotion_projeto', escreve: true, faz: 'transforma uma peça do estúdio em projeto Remotion (React) na pasta do usuário',
93
109
  args: 'id da peça. pasta (opcional): nome da pasta. fps (opcional, padrão 30). renderizar: true só se o npm install já foi feito. '
94
110
  + 'Use SÓ quando o usuário pedir Remotion pelo nome, ou quando precisar de render determinístico, vídeo longo, codec/fps específico ou o projeto em React. '
@@ -164,6 +180,14 @@ const GRUPOS = [
164
180
  args: 'pedido: o que o usuário quer, com as palavras dele. pasta (opcional): onde construir — sem ela, uma pasta nova com o nome do pedido. rodadas (opcional, 1 a 4, padrão 2): quantas voltas de correção. '
165
181
  + 'USE quando pedirem um app, um site, uma página ou uma ferramenta INTEIRA, do zero. NÃO use para mexer no que já existe, para um arquivo só, nem para conserto: são seis a oito chamadas de agente, '
166
182
  + 'e gastá-las para trocar a cor de um botão come o dia do usuário. O revisor ABRE a página num navegador invisível e mede — página em branco e erro de JavaScript reprovam sozinhos, o que ele achar volta para o construtor.' },
183
+ { tool: 'planejar_producao', escreve: false, noLoop: true,
184
+ faz: 'ativa o DIRETOR: roteiro, narração cena a cena, voz, movimento, mídia a buscar e trilha — a estrutura ANTES de produzir',
185
+ args: 'pedido: o que o usuário quer, com as palavras dele. segundos (opcional, padrão 30): duração alvo. '
186
+ + 'formato (opcional): video | apresentacao. contexto (opcional): o que já se sabe (marca, público, dados).\n'
187
+ + 'USE SEMPRE antes de criar vídeo, reels, teaser, animação ou apresentação com narração — inclusive quando o pedido parecer simples. '
188
+ + 'Devolve uma PAUTA: produza exatamente o que ela diz (não invente cena, não troque a ordem, não reescreva a narração), '
189
+ + 'buscando a mídia com midia_procurar + midia_baixar e montando com studio_criar (corte/prisma). '
190
+ + 'NÃO use para ajustar peça existente nem quando o usuário pediu só o roteiro em texto.' },
167
191
  { tool: 'finish', escreve: false, faz: 'encerra dizendo o que fez', noLoop: true,
168
192
  args: 'summary (opcional): uma frase do que foi feito.' },
169
193
  ],
package/lib/conta.js CHANGED
@@ -38,6 +38,26 @@ const CONECTA = process.env.PRIMOCODE_CONTA_SERVER || 'https://conecta-primo-api
38
38
 
39
39
  const ARQUIVO = path.join(os.homedir(), '.primocode', 'conta.json');
40
40
 
41
+ /* ── ONDE O LOGIN ABRE ───────────────────────────────────────────────────
42
+ *
43
+ * "Abre conecta-primo-ai.vercel.app/token do PrimoCode: abre o login normal
44
+ * do app, mas com o token do PrimoCode. Aí o site se comunica com o
45
+ * terminal."
46
+ *
47
+ * A página do token é do SITE, não da API. O servidor devolve a URL dele —
48
+ * que num ambiente antigo pode apontar para outra origem ou para outro
49
+ * caminho — e aqui ela é normalizada para o endereço acima, preservando o que
50
+ * vier na query (é ali que vai o código do pareamento, quando vai).
51
+ * `PRIMOCODE_SITE` troca a origem para rodar contra uma prévia local. */
52
+ const SITE = (process.env.PRIMOCODE_SITE || 'https://conecta-primo-ai.vercel.app').replace(/\/+$/, '');
53
+ const PAGINA_TOKEN = '/token';
54
+
55
+ function paginaDeLogin(urlDoServidor) {
56
+ let query = '';
57
+ try { query = new URL(urlDoServidor).search; } catch { /* sem url utilizável: só a página */ }
58
+ return SITE + PAGINA_TOKEN + query;
59
+ }
60
+
41
61
  // Os planos que liberam tudo. A lista é a MESMA do servidor
42
62
  // (`PLANOS_COM_PRO`), e quem decide de verdade é ele: isto aqui só evita uma
43
63
  // ida à rede para mostrar o selo na abertura.
@@ -201,11 +221,11 @@ async function entrar({ abrir, contar = () => {}, minutos = 5 } = {}) {
201
221
  }
202
222
 
203
223
  let origemDoSite = null;
204
- try { origemDoSite = new URL(pedido.url).origin; } catch { /* url estranha: sem CORS para ela */ }
224
+ try { origemDoSite = new URL(paginaDeLogin(pedido.url)).origin; } catch { /* url estranha: sem CORS para ela */ }
205
225
  const local = await servirPareamento(pedido.codigo, origemDoSite);
206
226
  const copiado = await area.copiar(pedido.codigo);
207
227
 
208
- let url = pedido.url;
228
+ let url = paginaDeLogin(pedido.url);
209
229
  if (local.porta) url += (url.includes('?') ? '&' : '?') + 'porta=' + local.porta;
210
230
 
211
231
  contar({ url, codigo: pedido.codigo, copiado: copiado.ok, porta: local.porta });
@@ -287,4 +307,5 @@ function versao() {
287
307
  try { return require('../package.json').version; } catch { return '?'; }
288
308
  }
289
309
 
290
- module.exports = { entrar, sair, atual, sessao, dispositivos, servirPareamento, ARQUIVO, CONECTA, PLANOS_PRO, PORTA_PAREAMENTO };
310
+ module.exports = { entrar, sair, atual, sessao, dispositivos, servirPareamento, paginaDeLogin,
311
+ ARQUIVO, CONECTA, SITE, PAGINA_TOKEN, PLANOS_PRO, PORTA_PAREAMENTO };
package/lib/diretor.js ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * diretor.js — o agente que ESTRUTURA antes de alguém produzir.
3
+ *
4
+ * "Tenha uma IA específica pra roteiro que monta tudo: narração, qual voz,
5
+ * roteiro, etc. Ela estrutura tudo antes de mandar pra IA. Funciona assim:
6
+ * a IA identifica que precisa criar vídeo, ativa o agente, esse agente faz
7
+ * a estrutura e manda pra IA, e depois ela só faz o vídeo."
8
+ *
9
+ * ── POR QUE UM PAPEL SEPARADO, E NÃO UM PARÁGRAFO NO PROMPT ──────────────
10
+ * É o mesmo motivo do pipeline.js: o modelo que está montando as cenas já
11
+ * gastou a atenção dele em JSON — fonte, cor, duração, layout — e é ali que o
12
+ * roteiro morre. Vira legenda do que aparece na tela ("Vantagens", "Nossos
13
+ * serviços"), sem arco, sem narração escrita, sem decisão de voz. O diretor
14
+ * roda numa conversa PRÓPRIA, sem ver JSON nenhum, e só decide: o que se
15
+ * conta, em que ordem, em quantos segundos, com que voz, com quais imagens e
16
+ * qual música. Depois disso o produtor tem pouco a inventar — e inventar é
17
+ * justamente onde ele erra.
18
+ *
19
+ * ── O PLANO É UM CONTRATO, NÃO UM TEXTO BONITO ───────────────────────────
20
+ * A saída do diretor é JSON com forma conhecida, e este módulo NORMALIZA e
21
+ * CONFERE o que voltou: duração que fecha, narração em toda cena, keyframes
22
+ * onde faz sentido, lista de mídia a buscar. Um plano que não fecha volta ao
23
+ * diretor com o defeito apontado — uma vez. Aceitar "quase certo" aqui custa
24
+ * um vídeo de 40 segundos que dura 12.
25
+ *
26
+ * ── O MOTOR NÃO É GOSTO ──────────────────────────────────────────────────
27
+ * Estúdio ou Remotion: a escolha tem regra (`motorPara`), porque a resposta
28
+ * errada aparece tarde — no vídeo de três minutos que engasgou na gravação do
29
+ * navegador, ou nos cinco minutos de `npm install` para fazer um story de
30
+ * 15 segundos.
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ // ── 1. reconhecer que o pedido é de vídeo ────────────────────────────────
36
+
37
+ const PEDE_VIDEO = /\b(v[ií]deo|reels?|tiktok|short|shorts|motion|anima[cç][aã]o|anima\b|teaser|trailer|comercial|propaganda|v[ií]nheta|vinheta|abertura|capcut|premiere|after\s*effects|aftereffects)\b/i;
38
+ const PEDE_SLIDE = /\b(slide|slides|deck|apresenta[cç][aã]o|pitch|powerpoint|keynote|prisma)\b/i;
39
+ const SO_TEXTO = /\b(roteiro apenas|s[oó] o roteiro|s[oó] o texto|sem v[ií]deo)\b/i;
40
+
41
+ /**
42
+ * O pedido precisa de produção audiovisual? E de que tipo?
43
+ *
44
+ * Isto é o gatilho do agente: o CLI pergunta aqui antes de deixar o produtor
45
+ * começar. Pedir "só o roteiro" NÃO liga a produção — quem pediu texto quer
46
+ * texto, e devolver um mp4 é ignorar a pessoa.
47
+ */
48
+ function precisaDeProducao(texto) {
49
+ const t = String(texto || '');
50
+ if (!t.trim()) return { producao: false };
51
+ if (SO_TEXTO.test(t)) return { producao: false, motivo: 'pediu só o texto' };
52
+ if (PEDE_VIDEO.test(t)) return { producao: true, formato: 'video', motivo: 'o pedido é de vídeo' };
53
+ if (PEDE_SLIDE.test(t)) return { producao: true, formato: 'apresentacao', motivo: 'o pedido é de apresentação' };
54
+ return { producao: false };
55
+ }
56
+
57
+ // ── 2. as decisões que não são do modelo ─────────────────────────────────
58
+
59
+ /**
60
+ * Estúdio ou Remotion.
61
+ *
62
+ * O estúdio exporta em segundos e não instala nada: é o padrão, e para story,
63
+ * reels e teaser é a resposta certa. Remotion custa um `npm install` de
64
+ * minutos e meio giga, e devolve o que o estúdio não tem: quadro a quadro
65
+ * determinístico, vídeo longo sem risco de engasgo, canal alfa e ProRes.
66
+ */
67
+ function motorPara({ segundos = 0, pedido = '', alfa = false, fps = 30, entrega } = {}) {
68
+ const t = String(pedido).toLowerCase();
69
+ if (/remotion/.test(t)) return { motor: 'remotion', motivo: 'foi pedido pelo nome' };
70
+ if (/(est[uú]dio|studio|r[aá]pido|agora|j[aá])\b/.test(t) && segundos <= 60) {
71
+ return { motor: 'estudio', motivo: 'pedido rápido e curto' };
72
+ }
73
+ if (alfa || /alfa|transpar[eê]nci|prores|canal alfa/.test(t)) {
74
+ return { motor: 'remotion', motivo: 'canal alfa e ProRes só existem no Remotion' };
75
+ }
76
+ if (entrega === 'cinema' || fps > 30) {
77
+ return { motor: 'remotion', motivo: 'fps alto pede render determinístico' };
78
+ }
79
+ if (segundos > 90) {
80
+ return { motor: 'remotion', motivo: 'vídeo longo: a gravação do navegador engasga' };
81
+ }
82
+ return { motor: 'estudio', motivo: 'curto: sai em segundos, sem instalar nada' };
83
+ }
84
+
85
+ /**
86
+ * A voz. Tom e idioma decidem; o nome exato quem resolve é o estúdio, que
87
+ * sabe o que está instalado — aqui sai a INTENÇÃO, que é o que o diretor
88
+ * precisa escrever no plano.
89
+ */
90
+ function vozPara({ tom = 'neutro', idioma = 'pt-BR', genero } = {}) {
91
+ const t = String(tom).toLowerCase();
92
+ const perfil = /urgente|energ|hype|an[uú]ncio|venda/.test(t) ? 'energica'
93
+ : /calm|institucional|corporativ|s[eé]ri/.test(t) ? 'firme'
94
+ : /doc|narrativ|hist[oó]ria|documental/.test(t) ? 'grave'
95
+ : /leve|simp[aá]tic|amig|casual/.test(t) ? 'proxima'
96
+ : 'neutra';
97
+ const ritmo = perfil === 'energica' ? 1.08 : perfil === 'grave' ? 0.94 : 1;
98
+ return { perfil, idioma, genero: genero || 'indiferente', ritmo,
99
+ instrucao: {
100
+ energica: 'projeção alta, frases curtas, corte seco entre elas',
101
+ firme: 'ritmo constante, sem exagero, pausa depois do número',
102
+ grave: 'grave e pausada, respira entre as frases',
103
+ proxima: 'como quem conversa, sorriso na voz',
104
+ neutra: 'clara e neutra, sem cor demais',
105
+ }[perfil] };
106
+ }
107
+
108
+ /** Quantas cenas cabem, e de que tamanho, num vídeo desta duração. */
109
+ function ritmoPara(segundos) {
110
+ const s = Math.max(5, Number(segundos) || 30);
111
+ const media = s <= 20 ? 2.6 : s <= 45 ? 3.4 : s <= 90 ? 4.2 : 5;
112
+ return { cenas: Math.max(3, Math.round(s / media)), duracaoMedia: media,
113
+ palavrasPorCena: Math.round(media * 2.6) }; // ~155 palavras/minuto de narração
114
+ }
115
+
116
+ // ── 3. o plano ───────────────────────────────────────────────────────────
117
+
118
+ const CAMPOS_CENA = ['titulo', 'narracao', 'duracao', 'layout', 'fundo', 'transicao',
119
+ 'elementos', 'midia', 'chaves', 'notas'];
120
+
121
+ /**
122
+ * Normaliza o que o diretor devolveu e aponta o que ficou faltando.
123
+ *
124
+ * Não conserta conteúdo — conteúdo é dele. Conserta FORMA: duração que virou
125
+ * string, cena sem transição, o total que não fecha com o pedido. E lista os
126
+ * defeitos que só o diretor resolve (cena muda, narração vazia), para voltar
127
+ * com o apontamento em vez de seguir com o plano torto.
128
+ */
129
+ function normalizarPlano(bruto, { segundos } = {}) {
130
+ const p = bruto && typeof bruto === 'object' ? bruto : {};
131
+ const defeitos = [];
132
+ const cenas = Array.isArray(p.cenas) ? p.cenas : [];
133
+ if (!cenas.length) defeitos.push('o plano veio sem cenas');
134
+
135
+ const TRANSICOES = ['corte', 'fade', 'deslizar', 'subir', 'zoom', 'limpar', 'persiana', 'flash'];
136
+ const limpas = cenas.map((c, i) => {
137
+ const cena = {};
138
+ for (const k of CAMPOS_CENA) if (c && c[k] !== undefined) cena[k] = c[k];
139
+ cena.duracao = Math.max(1.2, Math.min(15, Number(cena.duracao) || 3.5));
140
+ cena.narracao = typeof cena.narracao === 'string' ? cena.narracao.trim() : '';
141
+ if (!cena.narracao) defeitos.push(`cena ${i + 1} sem narração`);
142
+ if (!cena.titulo && !cena.elementos) defeitos.push(`cena ${i + 1} sem nada na tela`);
143
+ // Tudo em fade parece slide, não vídeo — e o diretor esquece disso.
144
+ if (!TRANSICOES.includes(cena.transicao)) cena.transicao = i === 0 ? 'fade' : TRANSICOES[(i % 6) + 1];
145
+ cena.midia = Array.isArray(cena.midia) ? cena.midia.filter((m) => m && m.busca) : [];
146
+ return cena;
147
+ });
148
+
149
+ const total = Math.round(limpas.reduce((s, c) => s + c.duracao, 0) * 10) / 10;
150
+ const alvo = Number(segundos) || Number(p.segundos) || total;
151
+ // 20% de folga: o alvo é intenção, não cronômetro. Fora disso o vídeo
152
+ // entrega outra coisa — 12 segundos onde pediram 40.
153
+ const fecha = !alvo || Math.abs(total - alvo) <= Math.max(3, alvo * 0.2);
154
+ if (!fecha) defeitos.push(`a soma das cenas dá ${total}s e o pedido é ${alvo}s`);
155
+
156
+ const plano = {
157
+ titulo: String(p.titulo || '').trim() || 'Sem título',
158
+ formato: p.formato === 'apresentacao' ? 'apresentacao' : 'video',
159
+ proporcao: ['16:9', '9:16', '1:1', '4:5'].includes(p.proporcao) ? p.proporcao : '16:9',
160
+ tema: p.tema || 'meia-noite',
161
+ tom: p.tom || 'neutro',
162
+ publico: p.publico || null,
163
+ promessa: p.promessa || null, // o que a pessoa leva do vídeo
164
+ segundos: total,
165
+ voz: vozPara({ tom: p.tom, idioma: p.idioma || 'pt-BR',
166
+ genero: p.voz && p.voz.genero }),
167
+ trilha: p.trilha && (p.trilha.busca || p.trilha.arquivo)
168
+ ? { busca: p.trilha.busca || null, arquivo: p.trilha.arquivo || null,
169
+ volume: Number(p.trilha.volume) || 0.18, humor: p.trilha.humor || p.tom || null }
170
+ : null,
171
+ cenas: limpas,
172
+ midia: limpas.flatMap((c, i) => c.midia.map((m) => ({ cena: i + 1, ...m }))),
173
+ creditos: !!p.creditos,
174
+ };
175
+ plano.motor = motorPara({ segundos: total, pedido: p.pedido || '', alfa: !!p.alfa,
176
+ fps: Number(p.fps) || 30 });
177
+ return { plano, defeitos, ok: defeitos.length === 0 };
178
+ }
179
+
180
+ // ── 4. o prompt do diretor ───────────────────────────────────────────────
181
+
182
+ const DIRETOR = (pedido, { segundos = 30, formato = 'video', contexto = '' } = {}) => {
183
+ const r = ritmoPara(segundos);
184
+ return `Você é o DIRETOR de conteúdo. Você NÃO produz nada: você ESTRUTURA, e outra IA produz a partir do que você escrever. Não escreva código, não escreva JSON de cena de ferramenta, não chame ferramenta nenhuma.
185
+
186
+ PEDIDO: ${pedido}
187
+ ${contexto ? `CONTEXTO: ${contexto}\n` : ''}FORMATO: ${formato} · DURAÇÃO ALVO: ${segundos}s · RITMO: ~${r.cenas} cenas de ~${r.duracaoMedia}s (~${r.palavrasPorCena} palavras de narração por cena)
188
+
189
+ Devolva SÓ um bloco \`\`\`json com este formato, sem texto antes nem depois:
190
+
191
+ {
192
+ "titulo": "…",
193
+ "formato": "${formato}",
194
+ "proporcao": "16:9 | 9:16 | 1:1 | 4:5",
195
+ "tema": "meia-noite | claro | papel | neon | brasa | floresta",
196
+ "tom": "como deve soar (institucional, urgente, documental, leve…)",
197
+ "publico": "para quem é",
198
+ "promessa": "o que a pessoa leva ao terminar de ver",
199
+ "trilha": { "busca": "termo de busca da música", "humor": "…", "volume": 0.18 },
200
+ "cenas": [
201
+ {
202
+ "titulo": "o que aparece grande na tela",
203
+ "narracao": "a frase FALADA, escrita para ser lida em voz alta",
204
+ "duracao": 3.5,
205
+ "layout": "capa | titulo | secao | duas-colunas | numeros | citacao | imagem-direita | centro | livre",
206
+ "fundo": "malha | aurora | raios | ondas | nevoa | vinheta, uma cor, ou {\\"imagem\\":\\"<da lista midia>\\"}",
207
+ "transicao": "corte | fade | deslizar | subir | zoom | limpar | persiana | flash",
208
+ "midia": [ { "busca": "o que procurar", "tipo": "imagem | logo", "papel": "fundo | destaque | selo", "semFundo": false } ],
209
+ "chaves": "descreva em uma frase o movimento principal desta cena (o que entra, o que atravessa, o que cresce)",
210
+ "notas": "o que o produtor precisa saber e não está acima"
211
+ }
212
+ ]
213
+ }
214
+
215
+ REGRAS QUE SEPARAM UM VÍDEO BOM DE UM GENÉRICO:
216
+ 1. NARRAÇÃO É FALA, NÃO LEGENDA. "Vantagens" não é narração. Escreva a frase que a pessoa ouve.
217
+ 2. O QUE SE OUVE E O QUE SE VÊ NÃO SE REPETEM. A tela mostra a prova (número, imagem, logo); a voz conta o que ela significa.
218
+ 3. ARCO. Primeira cena prende em 2 segundos, o miolo entrega, a última pede uma ação. Sem "Introdução / Desenvolvimento / Conclusão".
219
+ 4. NÚMERO É CONCRETO. Nada de "muitos clientes": ou tem o número, ou a frase muda.
220
+ 5. RITMO VARIADO. Cenas de durações diferentes e transições diferentes. Tudo em fade parece slide.
221
+ 6. MÍDIA COM PROPÓSITO. Só peça imagem ou logo quando ela PROVA algo. Diga o termo de busca de verdade ("painel solar em telhado residencial", não "imagem legal").
222
+ 7. MOVIMENTO. Em cada cena diga em "chaves" o movimento principal — é ele que tira a cara de apresentação parada.`;
223
+ };
224
+
225
+ const REVISAO = (defeitos) => `O plano voltou com defeitos de FORMA. Corrija SÓ o que está listado e devolva o JSON inteiro de novo, no mesmo formato:
226
+
227
+ ${defeitos.map((d) => '- ' + d).join('\n')}
228
+
229
+ Nada de explicação: só o bloco \`\`\`json.`;
230
+
231
+ /** Tira o JSON do que o modelo escreveu, com ou sem cerca. */
232
+ function extrairJSON(texto) {
233
+ const t = String(texto || '');
234
+ const cerca = t.match(/```(?:json)?\s*([\s\S]*?)```/i);
235
+ const cru = cerca ? cerca[1] : t.slice(t.indexOf('{'), t.lastIndexOf('}') + 1);
236
+ try { return JSON.parse(cru); } catch { /* tenta limpar vírgula sobrando */ }
237
+ try { return JSON.parse(String(cru).replace(/,\s*([}\]])/g, '$1')); } catch { return null; }
238
+ }
239
+
240
+ /**
241
+ * Roda o diretor: uma conversa própria, o plano de volta, conferido, e uma
242
+ * segunda chance quando a forma não fecha.
243
+ *
244
+ * @param {object} op
245
+ * @param {string} op.pedido
246
+ * @param {(prompt:string, papel:string)=>Promise<string>} op.correr uma conversa NOVA
247
+ * @param {number} [op.segundos]
248
+ * @param {(s:string)=>void} [op.mostrar]
249
+ */
250
+ async function planejar({ pedido, correr, segundos = 30, formato = 'video',
251
+ contexto = '', mostrar = () => {} } = {}) {
252
+ if (!pedido) return { ok: false, error: 'Diga o que produzir.' };
253
+ if (typeof correr !== 'function') return { ok: false, error: 'sem quem rode o diretor' };
254
+
255
+ mostrar('diretor — roteiro, narração e voz');
256
+ let bruto = extrairJSON(await correr(DIRETOR(pedido, { segundos, formato, contexto }), 'diretor'));
257
+ if (!bruto) return { ok: false, error: 'o diretor não devolveu um plano legível' };
258
+
259
+ let { plano, defeitos, ok } = normalizarPlano({ ...bruto, pedido }, { segundos });
260
+ if (!ok) {
261
+ mostrar('diretor — corrigindo o plano');
262
+ const segunda = extrairJSON(await correr(REVISAO(defeitos), 'diretor (revisão)'));
263
+ if (segunda) {
264
+ const r2 = normalizarPlano({ ...segunda, pedido }, { segundos });
265
+ // Fica com o melhor dos dois: a segunda volta só vale se corrigiu.
266
+ if (r2.defeitos.length <= defeitos.length) ({ plano, defeitos, ok } = r2);
267
+ }
268
+ }
269
+ return { ok: true, plano, defeitos, aprovado: defeitos.length === 0 };
270
+ }
271
+
272
+ /**
273
+ * O plano vira a PAUTA do produtor — o texto que a IA de produção recebe no
274
+ * lugar do pedido cru. Ela não precisa mais decidir o que contar: só montar.
275
+ */
276
+ function pauta(plano) {
277
+ const p = plano || {};
278
+ const linhas = [];
279
+ linhas.push(`PLANO DE PRODUÇÃO — ${p.titulo}`);
280
+ linhas.push(`formato ${p.formato} · ${p.proporcao} · ${p.segundos}s · tema ${p.tema} · tom ${p.tom}`);
281
+ linhas.push(`motor: ${p.motor && p.motor.motor} (${p.motor && p.motor.motivo})`);
282
+ if (p.promessa) linhas.push(`promessa: ${p.promessa}`);
283
+ if (p.publico) linhas.push(`público: ${p.publico}`);
284
+ linhas.push(`voz: ${p.voz && p.voz.perfil} · ${p.voz && p.voz.instrucao} · ${p.voz && p.voz.idioma}`);
285
+ if (p.trilha) linhas.push(`trilha: ${p.trilha.busca || p.trilha.arquivo} (volume ${p.trilha.volume})`);
286
+ linhas.push('');
287
+ (p.cenas || []).forEach((c, i) => {
288
+ linhas.push(`CENA ${i + 1} — ${c.duracao}s · layout ${c.layout || 'titulo'} · entra em ${c.transicao}`);
289
+ if (c.titulo) linhas.push(` na tela: ${c.titulo}`);
290
+ linhas.push(` narração: ${c.narracao}`);
291
+ if (c.chaves) linhas.push(` movimento: ${c.chaves}`);
292
+ (c.midia || []).forEach((m) => linhas.push(
293
+ ` mídia: ${m.tipo || 'imagem'} "${m.busca}" como ${m.papel || 'destaque'}${m.semFundo ? ' (sem fundo)' : ''}`));
294
+ if (c.notas) linhas.push(` nota: ${c.notas}`);
295
+ linhas.push('');
296
+ });
297
+ linhas.push('Produza EXATAMENTE este plano. As decisões de conteúdo já foram tomadas:');
298
+ linhas.push('não invente cena, não troque a ordem, não reescreva a narração.');
299
+ return linhas.join('\n');
300
+ }
301
+
302
+ module.exports = {
303
+ precisaDeProducao, motorPara, vozPara, ritmoPara,
304
+ normalizarPlano, extrairJSON, planejar, pauta,
305
+ DIRETOR, REVISAO,
306
+ };