primocode 10.8.0 → 10.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v10.8.0
1
+ # PrimoCode v10.9.1
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v10.8.0
12
+ ▐▛███▜▌ Primo Code v10.9.1
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/bin/primocode.js CHANGED
@@ -253,16 +253,20 @@ async function comandoApi(rest) {
253
253
 
254
254
  const apelido = { claude: 'claude-code', gpt: 'chatgpt', openai: 'chatgpt' }[arg.toLowerCase()];
255
255
  let id = apelido || ids.find((k) => k === arg.toLowerCase() || P[k].nome.toLowerCase().startsWith(arg.toLowerCase()) && arg);
256
+ // A lista numerada, a pergunta e a resposta somem depois da escolha: fica só o resultado (lib/entrada.js, marcar).
257
+ let marca = null;
258
+ const tirarLista = () => { if (marca && rl && rl.voltarPara) rl.voltarPara(marca); marca = null; };
256
259
  if (!id) {
260
+ if (rl && rl.marcar) marca = rl.marcar();
257
261
  console.log('');
258
262
  console.log(' ' + c.bold('Com qual IA você quer rodar?') + c.muted(' a sua chave ou a sua assinatura, sem o limite do servidor'));
259
263
  if (atual) console.log(' ' + c.muted('agora: ') + c.white(atual.cli ? atual.nome : `${atual.nome} · ${atual.modelo} · chave ${provedorLocal.mascarar(atual.chave)}`));
260
264
  ids.forEach((k, i) => console.log(` ${c.brand(String(i + 1))} ${c.white(P[k].nome.padEnd(24))}${c.dim(P[k].dica)}`));
261
265
  console.log(` ${c.brand('0')} ${c.white('Servidor do PrimoCode'.padEnd(24))}${c.dim('sem chave, com o limite do plano')}`);
262
266
  const escolha = String(await ask(` ${c.brand('?')} Número: `)).trim();
263
- if (escolha === '0') return comandoApi('off');
267
+ if (escolha === '0') { tirarLista(); return comandoApi('off'); }
264
268
  id = ids[Number(escolha) - 1];
265
- if (!id) { console.log(c.muted(' Nada mudou.')); return; }
269
+ if (!id) { tirarLista(); console.log(c.muted(' Nada mudou.')); return; }
266
270
  }
267
271
 
268
272
  const p = P[id];
@@ -270,22 +274,25 @@ async function comandoApi(rest) {
270
274
  // e logada. Quem pensa é ela; quem executa é o PrimoCode.
271
275
  if (p.cli) {
272
276
  if (!require('../lib/local/motor-cli').instalado(id)) {
277
+ tirarLista();
273
278
  console.log(c.warn(` O ${p.nome} não está instalado nesta máquina.`) + c.muted(` Veja ${p.onde}`));
274
279
  return;
275
280
  }
276
281
  const salvo = provedorLocal.salvar({ provedor: id, modelo: p.modelos[0] });
282
+ tirarLista();
277
283
  console.log(c.ok(` Pronto: ${salvo.nome}`) + c.muted(' · usa a sua assinatura, sem chave'));
278
284
  console.log(c.dim(` O ${p.nome} decide, o PrimoCode executa. /api off volta ao servidor`));
279
285
  return;
280
286
  }
281
287
  console.log(c.muted(` Pegue a chave em `) + c.white(p.onde));
282
288
  const chave = String(await askSecret(` ${c.brand('?')} Cole a chave do ${p.nome} (não aparece na tela): `)).trim();
283
- if (!chave) { console.log(c.muted(' Nada mudou.')); return; }
289
+ if (!chave) { tirarLista(); console.log(c.muted(' Nada mudou.')); return; }
284
290
  const sp = new Spinner('conferindo a chave').start();
285
291
  const r = await provedorLocal.conferir(id, chave);
286
292
  sp.stop();
287
- if (!r.ok) { console.log(c.warn(` ${r.error}`)); return; }
293
+ if (!r.ok) { tirarLista(); console.log(c.warn(` ${r.error}`)); return; }
288
294
  const salvo = provedorLocal.salvar({ provedor: id, chave, modelo: r.modelo });
295
+ tirarLista();
289
296
  console.log(c.ok(` Pronto: ${salvo.nome} · ${salvo.modelo}`) + c.muted(` · chave ${provedorLocal.mascarar(chave)}`));
290
297
  console.log(c.dim(' Agora os pedidos vão direto daqui, com a sua cota. /api modelo <nome> troca o modelo · /api off volta ao servidor'));
291
298
  }
@@ -1279,6 +1286,8 @@ async function enviarPedido(userText) {
1279
1286
  // ferramentas desktop_* no servidor (ver DICA_COMPUTADOR).
1280
1287
  instrucao: [i === 0 && querComputador(userText) ? vozModo.DICA_COMPUTADOR : '',
1281
1288
  i === 0 ? require('../lib/estudio/referencias.js').instrucao() : '',
1289
+ // As imagens do pedido (arrastadas ou coladas): guardadas para o Estúdio importar, e ditas ao agente.
1290
+ i === 0 ? (() => { const R = require('../lib/estudio/referencias.js'); return R.instrucaoDasImagens(R.guardarImagens(R.imagensNoTexto(userText))); })() : '',
1282
1291
  i === 0 ? plan.notaOutraPasta || '' : ''].filter(Boolean).join('\n\n'),
1283
1292
  });
1284
1293
 
package/lib/act.js CHANGED
@@ -684,8 +684,20 @@ const RITMO_AGENTE = '[ritmo]\n'
684
684
 
685
685
  /* `pedido` é opcional: com ele, o arquivo citado no pedido é o primeiro a
686
686
  entrar já lido (ver lib/pre-leitura.js). */
687
+ /** O agente quer o editor de gravação (`primocode video`) num pedido que não é editar gravação? Puro. */
688
+ function videoForaDeEdicao(nome, args, ehEdicao) {
689
+ return nome === 'run_shell' && !ehEdicao && /^\s*(?:cd\s+\S+\s*&&\s*)?(?:npx\s+)?primocode\s+video\b/.test(String((args || {}).command || ''));
690
+ }
691
+
687
692
  function buildMemoryContext(projectDir, projectName, pedido = '', { memoriaDaPasta = true } = {}) {
688
693
  const parts = [];
694
+ /* NO SERVIDOR O CONTEXTO É CURTO (13/10: "continua lendo e listando
695
+ coisas desnecessárias"). O plano grátis guarda ~1.900 tokens de
696
+ conversa; 3.400 de contexto fixo empurravam para fora o que o modelo
697
+ acabou de ler, e ele lia de novo. Com provedor próprio (/api) nada muda. */
698
+ let proprio = false;
699
+ try { proprio = !!require('./local/provedor.js').atual(); } catch { /* sem provedor próprio */ }
700
+ const curto = (t) => (proprio || t.length <= 1500 ? t : '…' + t.slice(-1500));
689
701
  // As instruções vêm primeiro: são regra de quem manda no projeto, e a
690
702
  // memória é o que o agente anotou. Entram aqui (e não no bin) para valer
691
703
  // igual no terminal, no modo voz e em cada passo de um plano.
@@ -697,14 +709,14 @@ function buildMemoryContext(projectDir, projectName, pedido = '', { memoriaDaPas
697
709
  bin), ela não vem de novo. */
698
710
  if (memoriaDaPasta) {
699
711
  const daPasta = require('./agents-md.js').ler(projectDir);
700
- if (daPasta) parts.push(`[AGENTS.md — memória desta pasta: de onde a conversa parou]\n${daPasta}`);
712
+ if (daPasta) parts.push(`[AGENTS.md — memória desta pasta: de onde a conversa parou]\n${curto(daPasta)}`);
701
713
  }
702
714
  const geral = cfg.readGeneralMemory();
703
- if (geral.trim()) parts.push(`[memory-geral.md]\n${geral}`);
715
+ if (geral.trim()) parts.push(`[memory-geral.md]\n${curto(geral)}`);
704
716
  const projeto = cfg.readProjectMemory(projectDir);
705
- if (projeto.trim()) parts.push(`[${projectName || 'projeto'}/memory.md]\n${projeto}`);
717
+ if (projeto.trim()) parts.push(`[${projectName || 'projeto'}/memory.md]\n${curto(projeto)}`);
706
718
  const arquivos = arquivosDoProjeto(projectDir);
707
- const mapa = mapaDoProjeto(projectDir, 80, arquivos);
719
+ const mapa = mapaDoProjeto(projectDir, proprio ? 80 : 30, arquivos);
708
720
  if (mapa) parts.push(`[${projectName || 'projeto'}/arquivos]\n${mapa}\n\n${preLeitura.JUNTAR_LEITURAS}`);
709
721
  const lidos = preLeitura.bloco(preLeitura.escolher(projectDir, arquivos, pedido));
710
722
  if (lidos) parts.push(lidos);
@@ -860,6 +872,10 @@ async function runAct(userPrompt, opts) {
860
872
  * lib/skills.js, que também explica por que mandar todas em todo pedido
861
873
  * não é uma opção num plano limitado por token. */
862
874
  let pedido = userPrompt;
875
+ /* Editar a GRAVAÇÃO da pessoa? Decide pelo que ELA escreveu (userPrompt),
876
+ não pelo `pedido` já com skills e instruções na frente: uma skill com
877
+ "editar… o vídeo" no corpo bastava para virar edição. */
878
+ const ehEdicaoDaGravacao = require('./estudio/diretor.js').precisaDeEdicao(userPrompt);
863
879
  if (!subagent) {
864
880
  const comSkills = skills.cardapio(userPrompt);
865
881
  pedido = comSkills.texto;
@@ -914,8 +930,8 @@ async function runAct(userPrompt, opts) {
914
930
  // Vídeo que o Estúdio fez se edita no Remotion Studio, não no editor por linha do tempo.
915
931
  const EDITAR_DO_ESTUDIO = 'SE ELA QUER EDITAR UM VÍDEO QUE O ESTÚDIO FEZ (não uma gravação dela): '
916
932
  + 'estudio_projeto acao:"editar" com o id do projeto abre o Remotion Studio no navegador; diga a pasta a ela e pare.\n';
917
- const producao = diretor.precisaDeProducao(pedido);
918
- if (diretor.precisaDeEdicao(pedido)) {
933
+ const producao = diretor.precisaDeProducao(userPrompt);
934
+ if (ehEdicaoDaGravacao) {
919
935
  // A gravação da pessoa: o editor por linha do tempo (lib/video/), pelo run_shell.
920
936
  messages.push({ role: 'system', content: EDITAR_DO_ESTUDIO + 'PARA EDITAR A GRAVAÇÃO DA PESSOA use o editor por linha do tempo via run_shell: '
921
937
  + 'SE A PESSOA SÓ DISSE "edite esse vídeo" (sem dizer o quê), O VÍDEO É O PEDIDO: `primocode video import --json \'{"path":"<arquivo>"}\'`, '
@@ -1474,6 +1490,19 @@ async function runAct(userPrompt, opts) {
1474
1490
  /* Só conta o que passou do freio de repetição: a chamada idêntica
1475
1491
  já é barrada lá (e a rodada parada desiste sozinha). Aqui entra
1476
1492
  o que escapa dele — a fatia sobreposta, a volta sem nada novo. */
1493
+ /* VÍDEO SE FAZ NO ESTÚDIO (13/10). Pedida uma animação 3D, o modelo
1494
+ do servidor rodou `primocode video do` e `primocode video help`
1495
+ pelo shell cinco vezes até o freio parar — o editor por linha do
1496
+ tempo é para a GRAVAÇÃO da pessoa, e as ferramentas do Estúdio
1497
+ estavam ali. Fora de edição, a chamada volta com o caminho certo. */
1498
+ if (videoForaDeEdicao(tc.name, tc.arguments, ehEdicaoDaGravacao)) {
1499
+ anotar(tc, 'recusada', 'primocode video fora de edição de gravação');
1500
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify({ ok: false,
1501
+ error: '`primocode video` é só para editar uma GRAVAÇÃO da pessoa. Para criar vídeo, animação ou motion: '
1502
+ + 'estudio_projeto acao:"criar" → estudio_plano com o roteiro → estudio_render.' }) });
1503
+ continue;
1504
+ }
1505
+
1477
1506
  motivoTravou = motivoTravou || travado.observar(circulo, tc.name, tc.arguments, pastaAtual);
1478
1507
 
1479
1508
  if (criarPasta && catalogo.criaProjeto(tc.name, tc.arguments)) garantirPasta();
@@ -1955,5 +1984,5 @@ async function rodarPipeline(args, ctx) {
1955
1984
  });
1956
1985
  }
1957
1986
 
1958
- module.exports = { runAct, entregaDoAgente, lembreteDeParalelo, buildMemoryContext, leituraPeloShell, historicoEnxuto, interromper, mandarDuranteATarefa, trabalhando, sobrasDaCaixa, entregarRecados, compactar, motivoDaParada, pareceParadaNoMeio, ehTetoDeToken, ehTransitorio, esperaDoErro,
1987
+ module.exports = { runAct, videoForaDeEdicao, entregaDoAgente, lembreteDeParalelo, buildMemoryContext, leituraPeloShell, historicoEnxuto, interromper, mandarDuranteATarefa, trabalhando, sobrasDaCaixa, entregarRecados, compactar, motivoDaParada, pareceParadaNoMeio, ehTetoDeToken, ehTransitorio, esperaDoErro,
1959
1988
  anotacaoDoQueJaFoi, encolherParaContinuar, MAX_ITERATIONS };
package/lib/entrada.js CHANGED
@@ -37,6 +37,9 @@
37
37
 
38
38
  'use strict';
39
39
 
40
+ const fs = require('fs');
41
+ const os = require('os');
42
+ const path = require('path');
40
43
  const readline = require('readline');
41
44
  const { EventEmitter } = require('events');
42
45
  const { c, visibleWidth, truncate } = require('./ui.js');
@@ -46,7 +49,51 @@ const stripAnsiLocal = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
46
49
  // ── O desenho ────────────────────────────────────────────────────────────
47
50
 
48
51
  /** A etiqueta de colagem em destaque (fininha, na cor da marca), sem mudar a largura. */
49
- const RE_ETIQUETA = /\[Colado #\d+ · [^\]]+\]/g;
52
+ const RE_ETIQUETA = /\[(?:Colado|Imagem) #\d+[^\]]*\]/g;
53
+
54
+ /* A IMAGEM ARRASTADA (Joel, 13/10: "#imagem1 #imagem2, igual fica aqui").
55
+ Arrastar arquivo para o terminal é uma colagem do CAMINHO, e cada terminal
56
+ escreve de um jeito: o do macOS escapa o espaço e põe um no fim
57
+ ("Captura\ de\ Tela.png "), o iTerm põe entre aspas, outros mandam
58
+ file://. Devolve os caminhos quando TODOS são imagens que existem; senão
59
+ null, e a colagem segue como texto. */
60
+ const RE_IMAGEM = /\.(png|jpe?g|gif|webp|heic|bmp|tiff?|svg)$/i;
61
+ function imagensArrastadas(texto) {
62
+ const t = String(texto || '').trim();
63
+ if (!t || t.includes('\n')) return null;
64
+ const pedacos = [];
65
+ let cur = '', aspas = null;
66
+ for (let i = 0; i < t.length; i++) {
67
+ const ch = t[i];
68
+ if (aspas) { if (ch === aspas) aspas = null; else cur += ch; continue; }
69
+ if (ch === "'" || ch === '"') { aspas = ch; continue; }
70
+ if (ch === '\\' && i + 1 < t.length) { cur += t[++i]; continue; }
71
+ if (/\s/.test(ch)) { if (cur) pedacos.push(cur); cur = ''; continue; }
72
+ cur += ch;
73
+ }
74
+ if (aspas) return null;
75
+ if (cur) pedacos.push(cur);
76
+ const arquivos = pedacos.map((p) => (p.startsWith('file://') ? decodeURI(p.slice(7)) : p.replace(/^~(?=\/)/, os.homedir())));
77
+ const existe = (f) => { try { return fs.statSync(f).isFile(); } catch { return false; } };
78
+ if (!arquivos.length || !arquivos.every((f) => path.isAbsolute(f) && RE_IMAGEM.test(f) && existe(f))) return null;
79
+ return arquivos;
80
+ }
81
+
82
+ /* O QUE A ETIQUETA GUARDAVA, embaixo do pedido já enviado: o nome da imagem,
83
+ ou as primeiras linhas do texto colado e quanto falta. Na caixa a etiqueta
84
+ poupa espaço; depois de mandar, quem rola a conversa quer ver o que foi. */
85
+ function guardadosNaTela(guardados, largura) {
86
+ const out = [];
87
+ for (const [et, conteudo] of guardados || []) {
88
+ if (et.startsWith('[Imagem')) { out.push(`⎿ ${et} ${path.basename(conteudo)}`); continue; }
89
+ const linhas = String(conteudo).split('\n');
90
+ const mostra = linhas.slice(0, 3).map((l) => truncate(l, Math.max(10, largura - et.length - 6)));
91
+ out.push(`⎿ ${et} ${mostra[0]}`);
92
+ for (const l of mostra.slice(1)) out.push(` ${' '.repeat(et.length)} ${l}`);
93
+ if (linhas.length > 3) out.push(` ${' '.repeat(et.length)} … +${linhas.length - 3} linhas`);
94
+ }
95
+ return out;
96
+ }
50
97
  function pintarEtiquetas(s) { return String(s).replace(RE_ETIQUETA, (m) => c.brand(m) + '\x1b[38;5;254m'); }
51
98
 
52
99
  /* As cores do painel, no cubo de 256 cores. O fundo é um degrau acima do
@@ -248,12 +295,14 @@ function montar(e) {
248
295
  }
249
296
 
250
297
  /** O pedido que foi mandado, como fica no histórico da tela: um painel. */
251
- function pedidoNaTela(texto, colunas) {
298
+ function pedidoNaTela(texto, colunas, guardados = []) {
252
299
  const largura = Math.max(24, Math.min(colunas || 80, 200)) - 1;
253
300
  const barra = `${COR.barra}┃${COR.reset}`;
254
301
  const linhas = quebrar(texto, Math.max(8, largura - PREFIXO))
255
302
  .map((l) => painel(barra + ' ' + pintarEtiquetas(c.white(l.texto)), largura));
256
- return [painel(barra, largura), ...linhas, painel(barra, largura)].join('\n');
303
+ const extras = guardadosNaTela(guardados, largura - PREFIXO)
304
+ .map((l) => painel(barra + ' ' + c.dim(truncate(l, largura - PREFIXO)), largura));
305
+ return [painel(barra, largura), ...linhas, ...extras, painel(barra, largura)].join('\n');
257
306
  }
258
307
 
259
308
  // ── A caixa viva ─────────────────────────────────────────────────────────
@@ -403,6 +452,21 @@ class Entrada extends EventEmitter {
403
452
  if (this.ativo && !this.fechado) this._desenhar();
404
453
  }
405
454
 
455
+ /* UMA ESCOLHA QUE SOME depois de respondida (o /api, 13/10: "só apareça
456
+ pra qual modelo trocou, aquela lista enorme os números somem").
457
+ marcar() antes de listar, voltarPara(marca) depois: a lista, a pergunta
458
+ e a resposta saem da conversa, e fica só o que vier depois. Só na tela
459
+ cheia; no modo linha o terminal já rolou e o texto fica. */
460
+ marcar() { return this.cheia ? { n: this.conversa.length, ultima: this.conversa[this.conversa.length - 1] } : null; }
461
+ voltarPara(marca) {
462
+ if (!marca || !this.cheia || this.conversa.length < marca.n) return;
463
+ this.conversa.length = marca.n;
464
+ if (marca.n) this.conversa[marca.n - 1] = marca.ultima;
465
+ this.rolagem = 0;
466
+ this._quadroAnterior = null;
467
+ this._pintar();
468
+ }
469
+
406
470
  /** Um comando com tela própria desenha aqui (na tela cheia). null fecha. */
407
471
  painel(texto) {
408
472
  this.painelTexto = texto == null ? null : String(texto).replace(/\n+$/, '');
@@ -566,7 +630,7 @@ class Entrada extends EventEmitter {
566
630
  this._mudou();
567
631
  }
568
632
 
569
- limpar() { this.line = ''; this.cursor = 0; this.menu = null; this.colados = new Map(); this._mudou(); }
633
+ limpar() { this.line = ''; this.cursor = 0; this.menu = null; this.colados = new Map(); this.nImagem = 0; this._mudou(); }
570
634
 
571
635
  /* COLAR VIRA ETIQUETA, como no Claude Code: texto grande colado (várias
572
636
  linhas, ou mais de 300 caracteres) entra na caixa como
@@ -576,10 +640,22 @@ class Entrada extends EventEmitter {
576
640
  colar(texto) {
577
641
  const t = String(texto || '');
578
642
  if (!t) return;
643
+ if (!this.colados) this.colados = new Map();
644
+ /* Imagem arrastada: uma etiqueta por arquivo, e no Enter volta o caminho.
645
+ O contador não reaproveita número: apagar a #1 não faz a próxima repetir a #2. */
646
+ const imagens = imagensArrastadas(t);
647
+ if (imagens) {
648
+ const ets = imagens.map((f) => {
649
+ this.nImagem = (this.nImagem || 0) + 1;
650
+ const et = `[Imagem #${this.nImagem}]`;
651
+ this.colados.set(et, f);
652
+ return et;
653
+ });
654
+ return this.write(ets.join(' ') + (/\s$/.test(t) ? ' ' : ''));
655
+ }
579
656
  const linhas = t.split('\n').length;
580
657
  if (linhas < 3 && t.length <= 300) return this.write(t);
581
- if (!this.colados) this.colados = new Map();
582
- const n = this.colados.size + 1;
658
+ const n = [...this.colados.keys()].filter((k) => k.startsWith('[Colado')).length + 1;
583
659
  const etiqueta = `[Colado #${n} · ${linhas > 1 ? `${linhas} linhas` : `${t.length} caracteres`}]`;
584
660
  this.colados.set(etiqueta, t);
585
661
  this.write(etiqueta);
@@ -890,7 +966,9 @@ class Entrada extends EventEmitter {
890
966
  if (this.menu) return this._escolherDoMenu();
891
967
  const visto = this.line;
892
968
  const texto = this.expandir(visto);
969
+ const guardados = [...(this.colados || [])].filter(([et]) => visto.includes(et));
893
970
  this.colados = new Map();
971
+ this.nImagem = 0;
894
972
  const oculto = this.oculto;
895
973
  this.oculto = false;
896
974
  this.line = '';
@@ -921,8 +999,8 @@ class Entrada extends EventEmitter {
921
999
  const temAlgo = this.conversa.some((l) => /\S/.test(l));
922
1000
  const ultima = this.conversa[this.conversa.length - 1] || '';
923
1001
  const antes = !temAlgo ? '' : /\S/.test(ultima) ? '\n\n' : '\n';
924
- tela.acrescentar(this.conversa, antes + pedidoNaTela(visto, mainW - 1) + '\n\n');
925
- } else this.escreverCru(pedidoNaTela(visto, process.stdout.columns || 80) + '\n');
1002
+ tela.acrescentar(this.conversa, antes + pedidoNaTela(visto, mainW - 1, guardados) + '\n\n');
1003
+ } else this.escreverCru(pedidoNaTela(visto, process.stdout.columns || 80, guardados) + '\n');
926
1004
  if (this.historico[this.historico.length - 1] !== texto) this.historico.push(texto);
927
1005
  }
928
1006
  this._desenhar();
@@ -932,4 +1010,4 @@ class Entrada extends EventEmitter {
932
1010
 
933
1011
  function criar(op) { return new Entrada(op).iniciar(); }
934
1012
 
935
- module.exports = { criar, Entrada, montar, quebrar, encher, pintarEtiquetas, painel, pedidoNaTela, PREFIXO, COR };
1013
+ module.exports = { criar, Entrada, montar, quebrar, encher, pintarEtiquetas, painel, pedidoNaTela, imagensArrastadas, PREFIXO, COR };
@@ -76,7 +76,29 @@ function estiloDaVez(aleatorio = Math.random, semImagem = false) {
76
76
  return variedade.sortear(lista, 'estilo', { aleatorio }) || lista[0];
77
77
  }
78
78
 
79
+ /* O ESTILO DA CASA (13/10, sobre o primocode-ele-constroi.mp4: "esse vídeo tá
80
+ top! quero os vídeos nesse estilo, todos os vídeos que ele gerar"). Todo
81
+ vídeo é o GERATIVO — tipografia grande e reta, as cores da marca, Inter e
82
+ JetBrains Mono. O estilo que o agente ou o roteiro escolhem não vale mais:
83
+ o rodízio dava um vídeo de cada jeito. Muda só quando a PESSOA pede pelo
84
+ nome, ou pede a animação 3D com personagens (o personagem). */
85
+ // Nome de estilo inequívoco, ou "estilo X". "Vídeo de lançamento" e "cinema" contam o assunto, não o estilo.
86
+ const PEDE_PELO_NOME = /\b(fluid[oa]|vox|explainer|h[ií]brid[oa]|cl[aá]ssico|brand reveal|estilo (?:de )?(?:marca|impacto|cinema|lan[çc]amento|minimalista|fluido|vox))\b/i;
87
+ const CASA = true;
88
+
79
89
  function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = false, semImagem = false, aleatorio } = {}) {
90
+ if (CASA) {
91
+ // O personagem 3D é outro produto (bonecos conversando), não um jeito do motion: esse o agente e o roteiro ainda pedem.
92
+ if (PEDE_PERSONAGEM.test(pedido) || normal(agente) === 'personagem' || normal(roteiro) === 'personagem') return 'personagem';
93
+ const nomeado = (String(pedido).match(PEDE_PELO_NOME) || [])[1];
94
+ if (nomeado) {
95
+ const x = nomeado.toLowerCase().replace(/^estilo (?:de )?/, '').replace(/^fluida$/, 'fluido').replace(/^h[ií]brid[oa]$/, 'hibrido')
96
+ .replace(/^brand reveal$/, 'marca').replace(/^minimalista$/, 'fluido').replace(/^explainer$/, 'vox').replace(/^cl[aá]ssico$/, 'classico').replace(/^lan[çc]amento$/, 'lancamento');
97
+ const n = normal(x);
98
+ if (n) return n;
99
+ }
100
+ return 'gerativo';
101
+ }
80
102
  const pedidoAgente = normal(agente);
81
103
  if (pedidoAgente) return pedidoAgente;
82
104
  const doRoteiro = normal(roteiro);
@@ -101,4 +123,4 @@ function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = fa
101
123
  /** Os estilos que usam os efeitos leves e a trilha do ende.app (os de motion, não os de corte). */
102
124
  const LEVES = new Set(['gerativo', 'motion', 'vox', 'marca', 'lancamento', 'fluido', 'personagem']);
103
125
 
104
- module.exports = { escolherEstilo, estiloDaVez, ESTILOS, LEVES, PEDE_MOTION, PEDE_FLUIDO, RODIZIO, RODIZIO_SEM_IMAGEM, PEDE_PERSONAGEM };
126
+ module.exports = { escolherEstilo, estiloDaVez, ESTILOS, LEVES, PEDE_MOTION, PEDE_FLUIDO, RODIZIO, RODIZIO_SEM_IMAGEM, PEDE_PERSONAGEM, PEDE_PELO_NOME };
@@ -238,7 +238,7 @@ function acertosDeHumor(roteiro) {
238
238
  lê roteiro.escolhasGerativo, e a marca continua mandando por cima. */
239
239
  function escolherVisual(roteiro = {}, { aleatorio = Math.random } = {}) {
240
240
  const variedade = require('./variedade.js');
241
- const marca = roteiro.marca || {};
241
+ const marca = require('./marca.js').marcaEmObjeto(roteiro.marca);
242
242
  const n = acertosDeHumor(roteiro);
243
243
  const max = Math.max(...Object.values(n));
244
244
  const humor = variedade.sortear(Object.keys(HUMORES), 'humor-gerativo', { aleatorio, adequacao: (h) => (n[h] + 1) / (max + 1) });
@@ -637,7 +637,11 @@ function camposDe(marca, humor, s, dita = null, fixa = null) {
637
637
  if (dita && !ehCor(c.destaque)) { const j = k.indexOf(dita); if (j > 0) k[j] = k[1]; k[1] = dita; }
638
638
  // A marca: o fundo dela, o destaque dela e a tinta dela tomam os lugares de mesmo papel.
639
639
  const escuro = (x) => luz(x) < 0.35, claro = (x) => luz(x) > 0.7;
640
- for (const cor of [c.fundo, c.destaque, c.tinta].filter(ehCor)) {
640
+ /* Marca de fundo escuro sem tinta declarada: o claro é o branco frio, não
641
+ o creme do sorteio (o motion do PrimoCode, preto e ciano, ganhava fundo
642
+ #F1EADB na lista e no fim — 13/10). */
643
+ const tinta = ehCor(c.tinta) ? c.tinta : (ehCor(c.fundo) && luz(c.fundo) < 0.2 ? '#F5F5F7' : null);
644
+ for (const cor of [c.fundo, c.destaque, tinta].filter(ehCor)) {
641
645
  const lugar = escuro(cor) ? 0 : claro(cor) ? 2 : 1;
642
646
  if (!k.includes(cor)) k[lugar] = cor;
643
647
  }
@@ -941,7 +945,7 @@ function montarGerativo({ roteiro, largura = 1920, altura = 1080, tempos = [], n
941
945
  /** A partitura, sem áudio nem plano — é o que os testes olham. */
942
946
  function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icones = {} }) {
943
947
  const partes = roteiro.partes || [];
944
- const marca = roteiro.marca || {};
948
+ const marca = require('./marca.js').marcaEmObjeto(roteiro.marca);
945
949
  const W = largura, H = altura, deitado = W >= H;
946
950
  const semente = roteiro.semente != null ? hash('semente:' + roteiro.semente)
947
951
  : hash(JSON.stringify([partes.map((p) => [p.tipo, p.titulo, p.texto, p.fala, p.valor, p.itens, p.nao, p.sim, p.destaque]), marca.nome || '']));
@@ -959,8 +963,14 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
959
963
  eixos para animar); a genérica da marca vira texto, e o título ganha
960
964
  uma face da biblioteca do humor. ─────────────────────────────────── */
961
965
  const pedida = (marca.fontes && marca.fontes.titulo) || '';
962
- const pedidaTexto = (marca.fontes && marca.fontes.texto) || '';
963
- const boa = (f) => f && VARIAVEIS[f] && !BANIDAS.includes(f) && VARIAVEIS[f].eixos.wght;
966
+ /* O texto da marca; sem ele, a mono da marca (o site do PrimoCode é a
967
+ Instrument Serif nos títulos e a JetBrains Mono no resto, 13/10). */
968
+ const monoMarca = (marca.fontes && marca.fontes.mono) || '';
969
+ const pedidaTexto = (marca.fontes && marca.fontes.texto) || (VARIAVEIS[monoMarca] ? monoMarca : '');
970
+ /* A da marca vale se a biblioteca carrega (e não é cara de IA), mesmo sem
971
+ eixo de peso: sem eixo ela só não anima o peso. Exigir o wght trocava a
972
+ Instrument Serif do site por uma Bodoni sorteada (13/10). */
973
+ const boa = (f) => f && VARIAVEIS[f] && !BANIDAS.includes(f);
964
974
  const ou = (salva, sorteada) => salva || sorteada;
965
975
  const display = boa(pedida) ? pedida : ou(E.display, s.um(H0.displays));
966
976
  const cinetica = eixoDe(display, 'wdth') && eixoDe(display, 'wdth')[0] <= 75 ? display : s.um(H0.cineticas);
@@ -1129,7 +1139,12 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1129
1139
  const mx = Math.max(r, g, bb), mn = Math.min(r, g, bb), d = mx - mn; if (!d) return 0;
1130
1140
  const hh = mx === r ? ((g - bb) / d) % 6 : mx === g ? (bb - r) / d + 2 : (r - g) / d + 4; return (hh * 60 + 360) % 360; };
1131
1141
  const longe = (x) => { const d = Math.abs(matiz(x) - matiz(ident.acento)); return Math.min(d, 360 - d); };
1132
- const ousada = [campos[3], campos[4]].filter(ehCor).sort((x, y) => longe(y) - longe(x))[0];
1142
+ /* Com marca, a ousada é a de APOIO da marca, ou nenhuma: o amarelo do
1143
+ sistema sorteado entrava de fundo no motion preto e ciano do PrimoCode (13/10). */
1144
+ const mc = marca.cores || {};
1145
+ const daMarca = ehCor(mc.fundo) || ehCor(mc.destaque);
1146
+ const ousada = daMarca ? (ehCor(mc.apoio) && mc.apoio !== ident.acento ? mc.apoio : null)
1147
+ : [campos[3], campos[4]].filter(ehCor).sort((x, y) => longe(y) - longe(x))[0];
1133
1148
  const fortes = [ident.acento, ousada].filter((x, i, a) => ehCor(x) && a.indexOf(x) === i);
1134
1149
  const nCor = batidas.length >= 6 ? Math.max(2, Math.round(batidas.length / 3.5)) : batidas.length >= 4 ? 1 : 0;
1135
1150
  const candCor = batidas.map((b, i) => i).filter((i) => i > 0 && !['marca', 'produto'].includes(batidas[i].comp));
@@ -1453,6 +1468,36 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1453
1468
  fundo.ambiente = AMBIENTES.includes(roteiro.ambiente) ? roteiro.ambiente : E.ambiente || 'limpo';
1454
1469
  fundo.acento = ACENTOS_JEITO.includes(roteiro.acento) ? roteiro.acento : E.acento || 'traco';
1455
1470
 
1471
+ /* SEM LETRA TORTA (13/10: "os motions saem muito tortos"). A letra que
1472
+ vira outra antes de assentar (decodifica, flap), a que chega voando e
1473
+ girando (monta), a que entra inclinada (onda) e a palavra que explode em
1474
+ letras rodando deixavam o "SGERE", o "a o" solto e o quadro vazio de
1475
+ letras fantasmas. Os sorteios acontecem igual (a semente não muda o
1476
+ resto); no fim, cada gesto vira o seu par limpo. */
1477
+ /* O substituto é um limpo que não aparece nas 3 batidas de cada lado: a
1478
+ passagem e o gesto da tese não voltam antes de 3 batidas. */
1479
+ const TORTOS = ['decodifica', 'flap', 'onda', 'monta'];
1480
+ const GESTOS_LIMPOS = ['mascara', 'desfoque', 'tracking', 'pontos', 'contorno', 'carimbo'];
1481
+ const SAIDAS_LIMPAS = ['evapora', 'desliza', 'comprime', 'pontos', 'atravessa'];
1482
+ const vizinhas = (i) => batidas.filter((_, j) => j !== i && Math.abs(j - i) <= 3);
1483
+ const principal = (b) => (b.linhas || []).find((l) => (l.papel === 'tese' || l.papel === 'numero') && !l.marca);
1484
+ const comGesto = batidas.filter(principal);
1485
+ batidas.forEach((b, i) => {
1486
+ if (b.saida === 'explode' || b.saida === 'chicote') {
1487
+ const perto = vizinhas(i).map((x) => x.saida);
1488
+ b.saida = SAIDAS_LIMPAS.find((x) => !perto.includes(x)) || 'corte';
1489
+ if (batidas[i + 1]) batidas[i + 1].entrada = b.saida;
1490
+ }
1491
+ const tortas = (b.linhas || []).filter((l) => TORTOS.includes(l.comportamento));
1492
+ if (tortas.length) {
1493
+ // A janela conta só as batidas com linha principal (tese ou número, fora a marca), como quem assiste vê.
1494
+ const k = comGesto.indexOf(b);
1495
+ const perto = k < 0 ? [] : comGesto.filter((_, j) => j !== k && Math.abs(j - k) <= 3).map((x) => principal(x).comportamento);
1496
+ const limpo = GESTOS_LIMPOS.find((x) => !perto.includes(x)) || 'mascara';
1497
+ for (const l of tortas) l.comportamento = limpo;
1498
+ }
1499
+ });
1500
+
1456
1501
  return {
1457
1502
  versao: 1, semente, fps: FPS, humor,
1458
1503
  musica: { genero: ou(E.genero, s.um(H0.generos)), generos: H0.generos }, largura: W, altura: H, duracao, regras: REGRAS,
@@ -1480,14 +1525,16 @@ function molduraDe(roteiro, E, { W, H, deitado, marca, secoes }) {
1480
1525
  };
1481
1526
  }
1482
1527
 
1528
+ /* O eixo de peso da fonte; a estática (a Instrument Serif da marca) é só 400. */
1529
+ const pesoDe = (fonte) => (VARIAVEIS[fonte] && VARIAVEIS[fonte].eixos.wght) || [400, 400];
1530
+
1483
1531
  // ── as composições: de conteúdo + contexto, uma batida ─────────────────
1484
1532
  function compor(comp, br, ctx) {
1485
1533
  const { s, W, H, M, deitado, base, larguraUtil, tamRotulo, fontes } = ctx;
1486
1534
  const c = br.conteudo;
1487
1535
  const palavras = ctx.palavras || [];
1488
1536
  const b = { id: 'b' + Math.round(br.de * 1000), parte: br.parte, de: r3(br.de), ate: r3(br.ate), comp, origem: c.tipo === 'falado' ? 'falado' : 'conteudo', linhas: [], acento: [] };
1489
- const eixosDisplay = VARIAVEIS[fontes.display].eixos;
1490
- const wMax = eixosDisplay.wght[1];
1537
+ const wMax = pesoDe(fontes.display)[1];
1491
1538
  const wTese = Math.min(wMax, s.um([800, 900, 900, 1000]));
1492
1539
  const destaqueTk = tokens(c.destaque || '');
1493
1540
  const ehTese = (w) => destaqueTk.length && tokens(w).some((k) => destaqueTk.includes(k));
@@ -1516,13 +1563,21 @@ function compor(comp, br, ctx) {
1516
1563
  };
1517
1564
  const vert = (opcoes) => s.um(opcoes);
1518
1565
  const tese = (l) => { l.papel = 'tese'; return l; };
1566
+ /* O ponto do acento ENTRA NO LUGAR do ponto final: com os dois, saía
1567
+ "Uma ideia.." (13/10). */
1568
+ const pontoFinal = (l) => {
1569
+ l.texto = l.texto.replace(/\.$/, '');
1570
+ const u = l.palavras[l.palavras.length - 1];
1571
+ if (u) u.texto = u.texto.replace(/\.$/, '');
1572
+ b.acento.push({ forma: 'ponto', alvo: { linha: l.id, fim: true } });
1573
+ };
1519
1574
 
1520
1575
  switch (comp) {
1521
1576
  case 'gigante': {
1522
1577
  const txt = c.titulo || c.nome || c.sim || c.valor || c.texto || '';
1523
1578
  const maiusc = txt.length <= 16 && s.chance(0.65);
1524
1579
  const fonte = s.chance(0.5) ? fontes.cinetica : fontes.display;
1525
- const eixos = { wght: Math.min(VARIAVEIS[fonte].eixos.wght[1], wTese) };
1580
+ const eixos = { wght: Math.min(pesoDe(fonte)[1], wTese) };
1526
1581
  const op = { fonte, wght: eixos.wght, maiusc };
1527
1582
  // A frase de 3+ palavras pode ir em 3 linhas no 16:9: cresce na altura em vez de encolher na largura.
1528
1583
  const nMax = deitado ? Math.min(3, Math.max(2, txt.split(/\s+/).length)) : Math.min(4, txt.split(/\s+/).length);
@@ -1543,7 +1598,7 @@ function compor(comp, br, ctx) {
1543
1598
  plano inteiro (12/10, o estilo padrão quebrava). Sem linha, sem acento. */
1544
1599
  if (!ultima) break;
1545
1600
  if (c.riscado) b.acento.push({ forma: 'risco', alvo: { linha: ultima.id } });
1546
- else if (/[.!]$/.test(txt) || s.chance(0.4)) b.acento.push({ forma: 'ponto', alvo: { linha: ultima.id, fim: true } });
1601
+ else if (/[.!]$/.test(txt) || s.chance(0.4)) pontoFinal(ultima);
1547
1602
  else {
1548
1603
  const lt = b.linhas.find((l) => l.papel === 'tese' && l.palavras.some((p) => p.papel === 'tese')) || ultima;
1549
1604
  const pi = Math.max(0, lt.palavras.findIndex((p) => p.papel === 'tese'));
@@ -1608,13 +1663,13 @@ function compor(comp, br, ctx) {
1608
1663
  for (const t of fit.linhas) { const l = linha(t, 'apoio', { tamanho: fit.tamanho, eixos: { wght: 560 } }); misturarPesos(l, { maiorTese: 1.14 }); }
1609
1664
  const lt = b.linhas.find((l) => l.palavras.some((p) => p.papel === 'tese'));
1610
1665
  if (lt) { tese(lt); b.acento.push({ forma: s.chance(0.6) ? 'sublinha' : 'anel', alvo: { linha: lt.id, palavra: lt.palavras.findIndex((p) => p.papel === 'tese') } }); }
1611
- else b.acento.push({ forma: 'ponto', alvo: { linha: b.linhas[b.linhas.length - 1].id, fim: true } });
1666
+ else pontoFinal(b.linhas[b.linhas.length - 1]);
1612
1667
  break;
1613
1668
  }
1614
1669
  case 'contador': {
1615
1670
  const n = numeroDe(c.valor);
1616
1671
  const fonte = s.chance(0.6) ? fontes.cinetica : fontes.display;
1617
- const eixos = { wght: Math.min(VARIAVEIS[fonte].eixos.wght[1], wTese) };
1672
+ const eixos = { wght: Math.min(pesoDe(fonte)[1], wTese) };
1618
1673
  const alinha = 'centro';
1619
1674
  const lg = deitado ? W * 0.62 : larguraUtil;
1620
1675
  const tam = tamanhoQueCabe(c.valor, lg * 0.82, (deitado ? 0.36 : 0.17) * H, { fonte, wght: eixos.wght });
@@ -1653,8 +1708,11 @@ function compor(comp, br, ctx) {
1653
1708
  case 'divisao': {
1654
1709
  const lado = deitado ? (larguraUtil - M * 0.6) / 2 : larguraUtil;
1655
1710
  const op = { fonte: fontes.display, wght: wTese };
1656
- const fN = caberEmLinhas(c.nao, lado, deitado ? 3 : 2, (deitado ? 0.13 : 0.08) * H, { ...op, wght: 400 });
1657
- const fS = caberEmLinhas(c.sim, lado, deitado ? 3 : 3, (deitado ? 0.17 : 0.1) * H, op);
1711
+ /* Teto mais alto (13/10): com 13% e 17% da altura, "sugere / constrói"
1712
+ ficava miúdo no canto, metade do tamanho do gigante ao lado. A
1713
+ largura da metade é que segura a frase longa. */
1714
+ const fN = caberEmLinhas(c.nao, lado, deitado ? 3 : 2, (deitado ? 0.2 : 0.11) * H, { ...op, wght: 400 });
1715
+ const fS = caberEmLinhas(c.sim, lado, deitado ? 3 : 3, (deitado ? 0.26 : 0.14) * H, op);
1658
1716
  if (deitado) {
1659
1717
  b.bloco = { x: M, y: H * 0.5, alinha: 'esq', vert: 'meio', largura: lado, gap: 0.1, colunas: [M, M + lado + M * 0.6] };
1660
1718
  } else {
@@ -1688,7 +1746,7 @@ function compor(comp, br, ctx) {
1688
1746
  const lg = deitado ? W * 0.7 : larguraUtil * 0.9;
1689
1747
  const tam = tamanhoQueCabe(nome, lg, (deitado ? 0.3 : 0.16) * H, { fonte, wght: 800 });
1690
1748
  b.bloco = { x: W / 2 + tam * 0.3, y: H * 0.5, alinha: 'centro', vert: 'meio', largura: lg, gap: 0.2 };
1691
- linha(nome, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(800, VARIAVEIS[fonte].eixos.wght[1]) }, comportamento: 'contorno' }).marca = true;
1749
+ linha(nome, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(800, pesoDe(fonte)[1]) }, comportamento: 'contorno' }).marca = true;
1692
1750
  if (c.sub || c.link) linha(c.sub || c.link, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.2, maiusc: true, eixos: { wght: 400 } });
1693
1751
  b.acento.push({ forma: 'ponto', alvo: { linha: b.linhas[0].id, antes: true } });
1694
1752
  break;
@@ -1699,7 +1757,7 @@ function compor(comp, br, ctx) {
1699
1757
  const maiusc = true;
1700
1758
  const tam = tamanhoQueCabe(txt, larguraUtil, (deitado ? 0.26 : 0.13) * H, { fonte, wght: 900, maiusc });
1701
1759
  b.bloco = { x: W / 2, y: H * 0.5, alinha: 'centro', vert: 'meio', largura: larguraUtil, gap: 0.1 };
1702
- linha(txt, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(900, VARIAVEIS[fonte].eixos.wght[1]) }, maiusc });
1760
+ linha(txt, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(900, pesoDe(fonte)[1]) }, maiusc });
1703
1761
  b.textura = { texto: (maiusc ? txt.toUpperCase() : txt), tamanho: tam, fonte, linhas: Math.ceil(H / (tam * 1.02)) + 3, velocidade: Math.round(s.entre(40, 90) * base) };
1704
1762
  // Moldura em volta da palavra é vício de "cara de IA" (a caixa na palavra-chave); sai em 08/10.
1705
1763
  b.acento.push({ forma: s.chance(0.5) ? 'ponto' : 'sublinha', alvo: { linha: b.linhas[0].id } });
@@ -47,6 +47,7 @@ const assets = require('./assets.js');
47
47
  const video = require('./video.js');
48
48
  const batida = require('./batida.js');
49
49
  const revisor = require('./revisor.js');
50
+ const marcaLib = require('./marca.js');
50
51
  const conferencia = require('./conferencia.js');
51
52
  const margem = require('./margem.js');
52
53
  const assinatura = require('./assinatura.js');
@@ -510,6 +511,15 @@ async function ferramentaMidia(args) {
510
511
 
511
512
  /* O ELEMENTO do site, tirado pelo código (print-site.js): a lista das peças
512
513
  (logo, botão, cartão, título…) e uma peça sozinha, com o fundo transparente. */
514
+ /* A REFERÊNCIA VISUAL que a pessoa manda (13/10: "não usa as referências
515
+ que mandei" — era o print da landing, arrastado no pedido). O agente não
516
+ enxerga imagem; aqui ela entra no projeto, as cores saem pelo ffmpeg e,
517
+ se o projeto ainda não tem marca, viram a marca. Com site, o site manda. */
518
+ if (acao === 'referencia' || acao === 'referência') {
519
+ if (!p) return semProjeto(args.id);
520
+ return importarReferencia(p, String(args.arquivo || args.caminho || args.src || ''));
521
+ }
522
+
513
523
  if (acao === 'elementos') return printSite.elementosDoSite(args.url || args.site || args.termo, { celular: !!args.celular });
514
524
  if (acao === 'elemento') {
515
525
  if (!p) return { ok: false, error: 'para tirar o elemento é preciso dizer em qual projeto (id).' };
@@ -975,13 +985,19 @@ function vazamentoNoComeco(lista, palavras, direcao = '') {
975
985
  const roteiro = norm(lista.slice(0, 2).map((x) => x.texto).join(' ')).split(' ').filter(Boolean);
976
986
  if (roteiro.length < 2 || !palavras.length) return 0;
977
987
  const pares = new Set(roteiro.slice(0, -1).map((w, i) => w + ' ' + roteiro[i + 1]));
978
- const ditas = palavras.slice(0, 14).map((w) => norm(w.texto));
988
+ /* 40 palavras, não 14 (13/10): a direção do personagem tem ~20 palavras, e
989
+ o roteiro só começava depois da janela — o corte desistia e o vídeo saía
990
+ com "Natural and close. Never like an announcer." na legenda. */
991
+ const ditas = palavras.slice(0, 40).map((w) => norm(w.texto));
979
992
  let i = 0;
980
993
  while (i < ditas.length - 1 && !pares.has(ditas[i] + ' ' + ditas[i + 1])) i++;
981
994
  if (i === 0 || i >= ditas.length - 1) return 0;
982
995
  const daDirecao = new Set(norm(direcao).split(' ').filter(Boolean));
983
996
  const antes = ditas.slice(0, i).join(' ').split(' ').filter(Boolean);
984
- const vazou = !direcao || antes.filter((w) => daDirecao.has(w)).length / antes.length >= 0.6;
997
+ /* A transcrição entorta a direção ("Read aloud in Brazilian Portuguese" vira
998
+ "Lê a alta em Brasília e Português"): basta 35% das palavras, ou 3, serem dela. */
999
+ const daDir = antes.filter((w) => daDirecao.has(w)).length;
1000
+ const vazou = !direcao || daDir / antes.length >= 0.35 || daDir >= 3;
985
1001
  return vazou ? Math.max(0, palavras[i].de - 0.08) : 0;
986
1002
  }
987
1003
 
@@ -1131,7 +1147,7 @@ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
1131
1147
  /* 'v3' (11/10): as narrações guardadas com a direção antiga (a que tinha
1132
1148
  "RÁPIDO" e "contínuas") ficam para trás — reaproveitar uma delas trazia o
1133
1149
  "Rápido e contínuo." de volta mesmo com a direção nova. */
1134
- const chave = require('crypto').createHash('sha1').update(JSON.stringify(['v3', lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
1150
+ const chave = require('crypto').createHash('sha1').update(JSON.stringify(['v4', lista.map((x) => x.texto), voz || '', direcao || ''])).digest('hex').slice(0, 16);
1135
1151
  const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
1136
1152
  const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
1137
1153
  if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
@@ -1248,6 +1264,32 @@ function conferirAssinatura(p, r, nome) {
1248
1264
  projeto.anotar(p.id, 'assinatura', fora.length ? `fora da assinatura ${nome}: ${fora.join('; ')}` : `seguiu a assinatura ${nome}`);
1249
1265
  }
1250
1266
 
1267
+ /* A REFERÊNCIA VISUAL que a pessoa manda (13/10: "não usa as referências
1268
+ que mandei" — era o print da landing, arrastado no pedido). O agente não
1269
+ enxerga imagem; aqui ela entra no projeto, as cores saem pelo ffmpeg e, se o
1270
+ projeto ainda não tem marca, viram a marca. Com site, o site manda. */
1271
+ function importarReferencia(p, arquivo) {
1272
+ const orig = String(arquivo || '').trim().replace(/^file:\/\//, '');
1273
+ if (!orig || !fs.existsSync(orig)) return { ok: false, error: 'mande em "arquivo" o caminho da imagem de referência.' };
1274
+ const nome = path.basename(orig).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^\w.-]+/g, '-');
1275
+ const destino = path.join(p.pasta, 'midia', 'referencias', nome);
1276
+ fs.mkdirSync(path.dirname(destino), { recursive: true });
1277
+ fs.copyFileSync(orig, destino);
1278
+ const rel = path.relative(p.pasta, destino).replace(/\\/g, '/');
1279
+ const cores = marcaLib.coresDaImagem(destino);
1280
+ const temMarca = !!(p.marca && Object.keys(marcaLib.marcaEmObjeto(p.marca).cores || {}).length);
1281
+ const salvar = { referencias: [...(p.referencias || []).filter((x) => x.src !== rel), { src: rel, cores, de: orig }] };
1282
+ if (!temMarca && cores.length) salvar.marca = { ...(p.marca || {}), ...marcaLib.marcaEmObjeto({ cores }) };
1283
+ projeto.salvar(p.id, salvar);
1284
+ projeto.anotar(p.id, 'referencia', `imagem ${rel}: ${cores.join(' ') || 'sem cor'}${temMarca ? '' : ' (virou a marca)'}`);
1285
+ return {
1286
+ ok: true, src: rel, cores, viraMarca: !temMarca && cores.length > 0,
1287
+ comoUsar: (temMarca ? 'O projeto já tem a marca do site: ela manda nas cores; a referência guia o JEITO (composição, peso, o que aparece). '
1288
+ : 'Estas cores viraram a marca do projeto. ')
1289
+ + 'Se a imagem é print do site ou do produto, o vídeo mostra a tela de verdade (estudio_midia acao:"print" e a peça printsite).',
1290
+ };
1291
+ }
1292
+
1251
1293
  async function prepararRoteiro(p, args) {
1252
1294
  const roteiro = typeof args.roteiro === 'string' ? JSON.parse(args.roteiro) : args.roteiro;
1253
1295
  if (Array.isArray(roteiro)) return prepararRoteiro(p, { ...args, roteiro: { partes: roteiro } });
@@ -1258,8 +1300,16 @@ async function prepararRoteiro(p, args) {
1258
1300
  for (const pt of roteiro.partes || []) {
1259
1301
  if (pt.src && !existe(pt.src)) { if (!pt.busca && /^https?:/i.test(pt.src)) pt.busca = pt.titulo || ''; delete pt.src; }
1260
1302
  }
1303
+ // As imagens do pedido (o terminal guardou em referencias.imagens) entram sozinhas, uma vez por projeto.
1304
+ const jaTem = new Set((p.referencias || []).map((x) => x.de));
1305
+ const novas = referencias.imagens().filter((f) => !jaTem.has(f));
1306
+ for (const f of novas) { try { importarReferencia(p, f); } catch (e) { projeto.anotar(p.id, 'referencia', `não importou ${path.basename(f)}: ${e.message}`); } }
1307
+ if (novas.length) p = projeto.ler(p.id) || p;
1261
1308
  if (roteiro.marca) {
1262
- roteiro.marca = { ...(p.marca || {}), ...roteiro.marca };
1309
+ /* A do roteiro por cima da do site, CAMPO A CAMPO: antes o "cores" em
1310
+ lista do agente apagava o {fundo, destaque} lido do site (13/10). */
1311
+ const site = marcaLib.marcaEmObjeto(p.marca), dele = marcaLib.marcaEmObjeto(roteiro.marca);
1312
+ roteiro.marca = { ...site, ...dele, cores: { ...(site.cores || {}), ...(dele.cores || {}) }, fontes: { ...(site.fontes || {}), ...(dele.fontes || {}) } };
1263
1313
  if (roteiro.marca.logo && !existe(roteiro.marca.logo)) roteiro.marca.logo = (p.marca && existe(p.marca.logo)) ? p.marca.logo : undefined;
1264
1314
  } else if (p.marca) roteiro.marca = p.marca;
1265
1315
  const partes = roteiro.partes || [];
@@ -1277,6 +1327,12 @@ async function prepararRoteiro(p, args) {
1277
1327
  const semImagem = referencias.semImagem(ref);
1278
1328
  let estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo, girar: true, semImagem });
1279
1329
  if (semImagem && (estilo === 'hibrido' || estilo === 'cinema')) estilo = 'gerativo';
1330
+ /* AS FONTES DA CASA (13/10, o primocode-ele-constroi.mp4 que o Joel aprovou):
1331
+ no gerativo, título em Inter e texto em JetBrains Mono, com as CORES da
1332
+ marca. Só muda se o pedido falar de fonte. */
1333
+ if (estilo === 'gerativo' && !/\bfontes?\b|tipografia|\bserifa/i.test(pedidoTxt)) {
1334
+ roteiro.marca = { ...(roteiro.marca || {}), fontes: { titulo: 'Inter', texto: 'JetBrains Mono' } };
1335
+ }
1280
1336
  /* "O vídeo deve ter um 3D do fone" (30/09): pedido de 3D vira o gerativo
1281
1337
  com o produto modelado girando numa batida (kit/tres.jsx) — a menos que
1282
1338
  o agente tenha escolhido outro estilo pelo nome. */
@@ -1848,6 +1904,9 @@ async function prepararRoteiro(p, args) {
1848
1904
  } catch { /* fica sem */ }
1849
1905
  continue;
1850
1906
  }
1907
+ // O ícone do próprio site (estudio_site guardou os <svg> na marca).
1908
+ const doSite = h.match(/^site:(\d+)$/i);
1909
+ if (doSite) { const x = ((roteiro.marca || {}).iconesSite || [])[Number(doSite[1]) - 1]; if (x && x.svg) icones[h] = x.svg; continue; }
1851
1910
  if (!/^[a-z0-9-]+:[a-z0-9-]+$/i.test(h) || icones[h]) continue;
1852
1911
  try { const ic = await assets.icone(h, { pasta: p.pasta }); if (ic && ic.ok) icones[h] = ic.svg; else projeto.anotar(p.id, 'icones', `herói ${h} não baixou: ${(ic && ic.error) || '?'}`); } catch { /* fica sem */ }
1853
1912
  }
@@ -2235,6 +2294,9 @@ async function ferramentaSite(args) {
2235
2294
  }
2236
2295
  }
2237
2296
  } catch { /* sem gradiente */ }
2297
+ /* Os ícones do próprio site (os <svg> dos cartões): viram "site:N" no roteiro. */
2298
+ const icSite = marcaLib.iconesDoSite(html);
2299
+ if (icSite.length) { marca.iconesSite = icSite; r.icones = icSite.map((x, i) => `site:${i + 1}${x.nome ? ' (' + x.nome + ')' : ''}`); }
2238
2300
  projeto.salvar(args.id, { acervo: { fotos: a.fotos, videos: a.videos, fatos: a.fatos, site: r.url }, marca });
2239
2301
  projeto.anotar(args.id, 'midia', `acervo do site: ${a.fotos.length} fotos, ${a.videos.length} vídeos, ${a.paginas.length} páginas lidas`);
2240
2302
  let orcamento = 4200;
@@ -2248,7 +2310,8 @@ async function ferramentaSite(args) {
2248
2310
  r.comoUsar = 'Escreva o roteiro com os FATOS acima (datas, local, números, frases da marca) — nada '
2249
2311
  + 'inventado. As fotos e os vídeos reais da marca entram SOZINHOS no vídeo: não precisa pôr '
2250
2312
  + '"busca" nas partes de imagem (use tipo "imagem" com só o título). Use as cores, a fonte e '
2251
- + 'a logo em roteiro.marca.';
2313
+ + 'a logo em roteiro.marca (objeto: {cores: {fundo, destaque, tinta}, fontes: {titulo, texto}}).'
2314
+ + (r.icones ? ' Os ícones do site estão em "icones": ponha icone:"site:N" na parte em que o assunto dele aparece.' : '');
2252
2315
  } catch (e) {
2253
2316
  r.acervo = { erro: e.message };
2254
2317
  }
@@ -430,8 +430,8 @@ function letra({ t, fps, l, p, j, ch, i, idx, n, b, cx, cores, eixosF, alvoSaida
430
430
  const centroLinha = cx ? cx.x + cx.w / 2 : 0;
431
431
  switch (c) {
432
432
  case 'carimbo': {
433
- const r0 = (acaso(semente) - 0.5) * 8;
434
- tr.push('rotate(' + (r0 * (1 - m)).toFixed(2) + 'deg)', 'scale(' + (1 + 0.32 * (1 - m)).toFixed(4) + ')');
433
+ // Sem inclinação: a letra carimbada torta lia como defeito (13/10). Fica o impacto da escala.
434
+ tr.push('scale(' + (1 + 0.32 * (1 - m)).toFixed(4) + ')');
435
435
  const f = tl * fps;
436
436
  blur = f < 2 ? 1.4 : Math.max(0, 0.7 * (1 - (f - 2) / 7));
437
437
  break;
@@ -917,7 +917,11 @@ export function Gerativo({ el, t, fps }) {
917
917
  leve a cada meio segundo, como um bumbo. */
918
918
  // A respiração lenta do mundo (sem bumbo: o pulso batendo lia como tranco).
919
919
  sz += 0.008 * Math.sin((t * Math.PI * 2) / 3.4);
920
- const cam = { x: cam0.x + sx, y: cam0.y + sy, zoom: cam0.zoom * (1 + sz), rot: cam0.rot + sr };
920
+ /* A câmera NÃO GIRA (13/10, "os motions saem muito tortos"): o giro das
921
+ chaves (cam0.rot) e o da deriva (sr) inclinavam o mundo inteiro, e todo
922
+ texto saía com a linha de base caída. Ficam a deriva, o zoom e o tranco. */
923
+ void sr;
924
+ const cam = { x: cam0.x + sx, y: cam0.y + sy, zoom: cam0.zoom * (1 + sz), rot: 0 };
921
925
  const ac0 = acentoEm(t, estados);
922
926
  // O acento também vive: treme de leve e respira na espessura.
923
927
  const ac = { ...ac0, pts: ac0.pts.map((q, i) => [q[0] + Math.sin(t * 3.1 + i * 0.5) * 1.6 * base, q[1] + Math.cos(t * 2.7 + i * 0.37) * 1.6 * base]), larg: ac0.larg * (1 + 0.08 * Math.sin(t * 4.2)) };
@@ -929,7 +933,8 @@ export function Gerativo({ el, t, fps }) {
929
933
  const origem = tela(ac.cx, ac.cy);
930
934
  /* O ESPAÇO 3D: o mundo tem perspectiva, a câmera também inclina (rotateX/Y)
931
935
  e as camadas estão em profundidades diferentes — o texto não é adesivo. */
932
- const rx = Math.cos(t * 0.27) * 1.4, ry = Math.sin(t * 0.31) * 2 + cam.rot * 0.6;
936
+ // A perspectiva fica, mas quase parada: 1,4° e 2° deixavam a frase em trapézio.
937
+ const rx = Math.cos(t * 0.27) * 0.35, ry = Math.sin(t * 0.31) * 0.5;
933
938
  const mundo = 'translate(' + W / 2 + 'px,' + H / 2 + 'px) rotate(' + cam.rot.toFixed(3) + 'deg) rotateX(' + rx.toFixed(3) + 'deg) rotateY(' + ry.toFixed(3) + 'deg) scale(' + cam.zoom.toFixed(4) + ') translate(' + (-W / 2 - cam.x).toFixed(1) + 'px,' + (-H / 2 - cam.y).toFixed(1) + 'px)';
934
939
  // No ar: a batida atual, a anterior enquanto sai.
935
940
  const noAr = bs.filter((x, i) => i === bi || (i === bi - 1 && t < x.ate + 0.14 && x.saida !== 'corte'));
@@ -280,4 +280,119 @@ async function marcaDaEmpresa(args, { projectDir, studio }) {
280
280
  };
281
281
  }
282
282
 
283
- module.exports = { marcaDaEmpresa, candidatasALogo, coresDe, fontesDe, metas, atributo };
283
+ /* A MARCA EM LISTA VIRA OBJETO (13/10). O agente escreve a marca do jeito
284
+ que leu: "cores": ["#22d3ee", "#0a0b0b", …], "fontes": ["Instrument
285
+ Serif", "JetBrains Mono"]. O Estúdio lê {fundo, destaque, tinta} e
286
+ {titulo, texto, mono}. Sem a conversão, `cores.fundo` não existia, o
287
+ gerativo achava que não havia marca e sorteava o humor: o motion do
288
+ PrimoCode saiu vermelho e creme em Bodoni, com o site preto e ciano.
289
+ cores a primeira escura é o fundo, a primeira viva o destaque (a
290
+ segunda, o apoio), a primeira clara a tinta;
291
+ fontes a mono vai para mono; das outras, a 1ª é título e a 2ª texto.
292
+ O que já é objeto passa intacto. */
293
+ function marcaEmObjeto(m) {
294
+ if (!m || typeof m !== 'object') return {};
295
+ const out = { ...m };
296
+ /* Objeto com OUTROS nomes ({primaria, neutras: […]}, visto em 13/10): sem
297
+ fundo/destaque/tinta, todas as cores dele viram a lista e são lidas por
298
+ luz e saturação, como a lista do agente. */
299
+ const papeis = ['fundo', 'destaque', 'tinta', 'apoio'];
300
+ if (m.cores && typeof m.cores === 'object' && !Array.isArray(m.cores) && !papeis.some((k) => m.cores[k])) {
301
+ const todas = [];
302
+ const juntar = (v) => { if (Array.isArray(v)) v.forEach(juntar); else if (v && typeof v === 'object') Object.values(v).forEach(juntar); else if (typeof v === 'string') todas.push(v); };
303
+ juntar(m.cores);
304
+ m = { ...m, cores: todas };
305
+ }
306
+ if (Array.isArray(m.cores)) {
307
+ const rgb = (h) => { const n = parseInt(h.slice(1), 16); return [n >> 16, (n >> 8) & 255, n & 255].map((v) => v / 255); };
308
+ const luz = (h) => { const [r, g, b] = rgb(h); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
309
+ const sat = (h) => { const v = rgb(h); return Math.max(...v) - Math.min(...v); };
310
+ const cs = m.cores.map((x) => String((x && x.cor) || x || '').trim()).filter((x) => /^#[0-9a-f]{6}$/i.test(x));
311
+ const vivas = cs.filter((x) => sat(x) > 0.25);
312
+ const fundo = cs.find((x) => luz(x) < 0.12 && sat(x) <= 0.25);
313
+ const tinta = cs.find((x) => luz(x) > 0.8 && sat(x) <= 0.25);
314
+ out.cores = { ...(fundo ? { fundo } : {}), ...(vivas[0] ? { destaque: vivas[0] } : {}), ...(vivas[1] ? { apoio: vivas[1] } : {}), ...(tinta ? { tinta } : {}) };
315
+ }
316
+ if (Array.isArray(m.fontes)) {
317
+ const fs = m.fontes.map((f) => String((f && f.nome) || f || '').trim()).filter(Boolean);
318
+ const mono = fs.find((f) => /mono|code|consol/i.test(f));
319
+ const resto = fs.filter((f) => f !== mono);
320
+ out.fontes = { ...(resto[0] ? { titulo: resto[0] } : {}), ...(resto[1] ? { texto: resto[1] } : {}), ...(mono ? { mono } : {}) };
321
+ }
322
+ return out;
323
+ }
324
+
325
+ /* OS ÍCONES DO SITE (13/10: "não usa os ícones do site"). Os ícones de uma
326
+ landing quase sempre são <svg> inline (o desenho de traço ao lado de cada
327
+ cartão), e não arquivos. Aqui saem os pequenos — com viewBox, sem imagem
328
+ nem script dentro, até 4 KB — com um nome: o aria-label, o <title>, ou o
329
+ título (h2/h3/h4) logo depois dele no HTML. No roteiro viram "site:N". */
330
+ function iconesDoSite(html, max = 12) {
331
+ const out = [], vistos = new Set();
332
+ const re = /<svg\b[^>]*>[\s\S]*?<\/svg>/gi;
333
+ let m;
334
+ while ((m = re.exec(String(html || ''))) && out.length < max) {
335
+ const svg = m[0];
336
+ if (svg.length > 4000 || /<(image|script|foreignObject)\b/i.test(svg) || !/viewBox=/i.test(svg)) continue;
337
+ const abre = svg.slice(0, svg.indexOf('>') + 1);
338
+ const lado = Number((abre.match(/\b(?:width|height)=["']?(\d+)/i) || [])[1] || 0);
339
+ if (lado > 120) continue;
340
+ const corpo = svg.replace(/\s+/g, ' ');
341
+ if (vistos.has(corpo)) continue;
342
+ vistos.add(corpo);
343
+ const depois = String(html).slice(re.lastIndex, re.lastIndex + 500);
344
+ const nome = (abre.match(/aria-label=["']([^"']+)/i) || [])[1]
345
+ || (svg.match(/<title>([^<]+)<\/title>/i) || [])[1]
346
+ || ((depois.match(/<h[2-4][^>]*>([\s\S]*?)<\/h[2-4]>/i) || [])[1] || '').replace(/<[^>]+>/g, '').trim()
347
+ || '';
348
+ out.push({ nome: nome.slice(0, 40), svg });
349
+ }
350
+ return out;
351
+ }
352
+
353
+ /* AS CORES DE UMA IMAGEM (a referência que a pessoa manda: o print do site,
354
+ um frame, um anúncio de que gostou). O ffmpeg reduz a imagem a 160 px e
355
+ devolve os pixels; as cores parecidas viram uma, e saem as 6 que mais
356
+ aparecem, da mais comum à menos. Sem ffmpeg, lista vazia. */
357
+ function coresDaImagem(arquivo, max = 6) {
358
+ const { spawnSync } = require('child_process');
359
+ const r = spawnSync('ffmpeg', ['-v', 'error', '-i', arquivo, '-vf', 'scale=160:-1', '-frames:v', '1', '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'], { maxBuffer: 1 << 22 });
360
+ if (r.status !== 0 || !r.stdout || !r.stdout.length) return [];
361
+ const conta = new Map();
362
+ for (let i = 0; i + 2 < r.stdout.length; i += 3) {
363
+ const k = ((r.stdout[i] >> 3) << 10) | ((r.stdout[i + 1] >> 3) << 5) | (r.stdout[i + 2] >> 3);
364
+ conta.set(k, (conta.get(k) || 0) + 1);
365
+ }
366
+ const hex = (k) => '#' + [(k >> 10) & 31, (k >> 5) & 31, k & 31].map((v) => (v * 8 + 4).toString(16).padStart(2, '0')).join('');
367
+ const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
368
+ const longe = (a, b) => { const x = rgb(a), y = rgb(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); };
369
+ // As 4 mais comuns, e as 2 mais VIVAS que aparecem de verdade: a cor da
370
+ // marca costuma ocupar pouca área (o ciano de um título, o botão), e sem
371
+ // isto o print dava só fundo e cinza.
372
+ const total = r.stdout.length / 3;
373
+ const sat = (h) => { const v = rgb(h); return (Math.max(...v) - Math.min(...v)) / 255; };
374
+ const lista = [...conta].sort((a, b) => b[1] - a[1]).map(([k, n]) => ({ h: hex(k), n }));
375
+ const out = [];
376
+ const por = (x) => { if (out.length < max && out.every((o) => longe(o, x.h) > 48)) out.push(x.h); };
377
+ for (const x of lista) { if (out.length >= Math.max(1, max - 2)) break; por(x); }
378
+ /* As vivas se espalham em dezenas de tons de 1 ou 2 pixels (a borda da
379
+ letra mistura com o fundo): contam por MATIZ, em faixas de 30°, e de
380
+ cada faixa vale o pixel mais vivo — o miolo da letra, não a borda. */
381
+ const faixas = new Map();
382
+ for (let i = 0; i + 2 < r.stdout.length; i += 3) {
383
+ const v = [r.stdout[i], r.stdout[i + 1], r.stdout[i + 2]], mx = Math.max(...v), mn = Math.min(...v), d = mx - mn;
384
+ if (d / 255 < 0.35 || mx < 60) continue;
385
+ const hh = mx === v[0] ? ((v[1] - v[2]) / d) % 6 : mx === v[1] ? (v[2] - v[0]) / d + 2 : (v[0] - v[1]) / d + 4;
386
+ const f = Math.floor(((hh * 60 + 360) % 360) / 30);
387
+ const x = faixas.get(f) || { n: 0, melhor: null, nota: -1 };
388
+ x.n++;
389
+ const nota = d * mx;
390
+ if (nota > x.nota) { x.nota = nota; x.melhor = '#' + v.map((c) => c.toString(16).padStart(2, '0')).join(''); }
391
+ faixas.set(f, x);
392
+ }
393
+ [...faixas.values()].filter((x) => x.n >= Math.max(8, total * 0.0005)).sort((a, b) => b.n - a.n).slice(0, 2).forEach((x) => por({ h: x.melhor }));
394
+ void sat; void lista;
395
+ return out;
396
+ }
397
+
398
+ module.exports = { marcaDaEmpresa, candidatasALogo, coresDe, fontesDe, metas, atributo, marcaEmObjeto, iconesDoSite, coresDaImagem };
@@ -128,28 +128,54 @@ function generoDoApresentador(roteiro = {}) {
128
128
  const CABELOS_F = ['longo']; // o coque e o cacheado, de frente, liam como homem (12/10); só quando pedidos
129
129
  const CABELOS_M = ['curto', 'topete', 'careca', 'cacheado'];
130
130
 
131
+ /* A COR PELO NOME (13/10). O agente escreveu pele: "morena", corCabelo:
132
+ "castanho escuro", roupa: "moletom cinza" — e o three.js, sem entender o
133
+ nome, pinta de BRANCO: o humano saiu todo branco, pele, cabelo e roupa.
134
+ Código de cor passa direto; nome em português vira o código pela tabela
135
+ (o nome mais longo primeiro: "castanho escuro" antes de "castanho"); o que
136
+ não se reconhece cai no sorteio de sempre. */
137
+ const CORES_POR_NOME = {
138
+ pele: [['muito clara', '#f6d7bd'], ['clara', '#f2c9a6'], ['branca', '#f2c9a6'], ['bronzeada', '#c08660'], ['parda', '#c98d66'],
139
+ ['morena clara', '#c98d66'], ['morena escura', '#8a5a3c'], ['morena', '#a8704c'], ['oliva', '#c4a07a'], ['negra', '#6b4229'],
140
+ ['preta', '#6b4229'], ['escura', '#6b4229'], ['media', '#c98d66']],
141
+ cabelo: [['castanho escuro', '#3b2618'], ['castanho claro', '#8a5a34'], ['castanho', '#6b4423'], ['preto', '#1d1612'], ['loiro', '#d8c39a'],
142
+ ['ruivo', '#a4471f'], ['grisalho', '#8d8d93'], ['branco', '#e8e6e1'], ['cinza', '#8d8d93']],
143
+ cor: [['azul marinho', '#1f2a5c'], ['azul claro', '#6fa8ff'], ['cinza escuro', '#3a3d45'], ['cinza claro', '#c4c6cc'], ['cinza', '#8a8d96'],
144
+ ['preto', '#24262d'], ['preta', '#24262d'], ['branco', '#e8e2d6'], ['branca', '#e8e2d6'], ['azul', '#2f4cdd'], ['verde', '#1f8a5b'],
145
+ ['vermelho', '#c2452f'], ['vermelha', '#c2452f'], ['amarelo', '#f1b434'], ['amarela', '#f1b434'], ['laranja', '#d9532b'],
146
+ ['roxo', '#7b5cff'], ['roxa', '#7b5cff'], ['rosa', '#e07aa0'], ['bege', '#d8c4a4'], ['marrom', '#6b4423'], ['vinho', '#7a1f2b'], ['dourado', '#c9a24a']],
147
+ };
148
+ function corEmHex(valor, tabela, sorteada) {
149
+ const v = String(valor == null ? '' : valor).trim();
150
+ if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(v)) return v;
151
+ const t = ' ' + v.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z ]+/g, ' ') + ' ';
152
+ for (const [nomeCor, hex] of CORES_POR_NOME[tabela] || []) if (t.includes(' ' + nomeCor + ' ')) return hex;
153
+ return sorteada;
154
+ }
155
+
131
156
  function aparencia(nome, pedido = {}) {
132
157
  const k = (s) => hashDe(nome + ':' + s);
133
158
  const tipo = tipoDe(nome, pedido);
134
159
  const rotulo = String(pedido.rotulo || pedido.nome || nome || '').trim().slice(0, 24);
135
160
  if (tipo === 'animal') {
136
161
  const especie = especieDe(pedido.especie || '') || especieDe(nome) || 'cachorro';
137
- return { nome, rotulo, tipo, especie, ...(pedido.cor ? { cor: pedido.cor } : {}), ...(pedido.cor2 ? { cor2: pedido.cor2 } : {}) };
162
+ const cor = corEmHex(pedido.cor, 'cor', null), cor2 = corEmHex(pedido.cor2, 'cor', null);
163
+ return { nome, rotulo, tipo, especie, ...(cor ? { cor } : {}), ...(cor2 ? { cor2 } : {}) };
138
164
  }
139
165
  if (tipo === 'coisa') {
140
166
  const termo = String(pedido.icone || pedido.coisa || nome);
141
167
  return { nome, rotulo, tipo, icone: termo, ...(pedido.src ? { src: pedido.src } : {}) };
142
168
  }
143
169
  if (tipo === 'robo') {
144
- return { nome, rotulo, tipo, cor: pedido.cor || pegar(ROBOS, k('cor')), olhos: pedido.olhos || null, ...(pedido.bigode ? { bigode: true } : {}) };
170
+ return { nome, rotulo, tipo, cor: corEmHex(pedido.cor, 'cor', pegar(ROBOS, k('cor'))), olhos: pedido.olhos || null, ...(pedido.bigode ? { bigode: true } : {}) };
145
171
  }
146
172
  return {
147
173
  nome, rotulo, tipo,
148
- pele: pedido.pele || pegar(PELES, k('pele')),
174
+ pele: corEmHex(pedido.pele, 'pele', pegar(PELES, k('pele'))),
149
175
  cabelo: CABELOS.includes(pedido.cabelo) ? pedido.cabelo
150
176
  : pegar(pedido.genero === 'f' ? CABELOS_F : pedido.genero === 'm' ? CABELOS_M : CABELOS, k('cabelo')),
151
- corCabelo: pedido.corCabelo || pegar(CORES_CABELO, k('corCabelo')),
152
- roupa: pedido.roupa || pegar(ROUPAS, k('roupa')),
177
+ corCabelo: corEmHex(pedido.corCabelo, 'cabelo', pegar(CORES_CABELO, k('corCabelo'))),
178
+ roupa: corEmHex(pedido.roupa, 'cor', pegar(ROUPAS, k('roupa'))),
153
179
  oculos: pedido.oculos != null ? !!pedido.oculos : k('oculos') < 0.3,
154
180
  barba: !!pedido.barba && pedido.genero !== 'f', // só quando pedida: sorteada, ficava estranha em quem tem cabelo longo
155
181
  bigode: !!pedido.bigode && pedido.genero !== 'f',
@@ -80,8 +80,12 @@ function notasDaAssinatura() {
80
80
  function instrucao(atual = ATUAL) { const i = instrucaoDoModo(atual); return i ? i + notasDaAssinatura() : ''; }
81
81
  function instrucaoDoModo({ modo, escopo, explicito } = ATUAL) {
82
82
  // O vídeo sem imagem é motion: o agente escolhe o estilo de motion que serve ao roteiro, não um fixo.
83
- const motion = 'O vídeo é MOTION: tipografia, formas, ícones, animação e som. Escolha em roteiro.estilo o motion que serve a este '
84
- + 'roteiro (gerativo, fluido, impacto, vox, marca, lancamento) ou deixe vazio para o rodízio — não repita sempre o mesmo.';
83
+ const motion = 'O vídeo é MOTION: tipografia, formas, ícones, animação e som, no ESTILO DA CASA (o gerativo, com as cores da '
84
+ + 'marca): não ponha roteiro.estilo, a menos que a pessoa tenha pedido outro estilo pelo nome. '
85
+ /* O ROTEIRO (13/10: "o roteiro que monta é horrível" — eram os títulos da landing em fila). */
86
+ + 'O ROTEIRO é uma história de 15 a 25 s: gancho (a situação de quem assiste) → o que o produto FAZ → UMA prova concreta '
87
+ + 'dos fatos do site → a ação (nome, comando, link). Nunca os títulos das seções do site em sequência. Cada fala tem de 5 a '
88
+ + '12 palavras e puxa a próxima; o titulo da parte é a palavra-chave (1 a 4 palavras), não a fala repetida.';
85
89
  if (modo === 'sem' && explicito) {
86
90
  return '[REFERÊNCIAS: NENHUMA] A pessoa quer SÓ MOTION: não pesquise (nada de pesquisar_referencia nem estudio_site), '
87
91
  + 'não busque foto nem vídeo, não use estudio_midia de banco. Escreva o roteiro com o que o pedido já diz. ' + motion;
@@ -124,4 +128,42 @@ function filtrar(r, { modo, escopo } = ATUAL) {
124
128
  return { ...r, fontes: refs.length, referencias: refs, ...(fora ? { foraDoEscopo: fora } : {}) };
125
129
  }
126
130
 
127
- module.exports = { definir, atual, limpar, semImagem, termos, entender, inferir, instrucao, dominios, filtrar };
131
+ /* AS IMAGENS DO PEDIDO (13/10: "não usa as referências que mandei" — era o
132
+ print da landing, arrastado no pedido). O modelo do servidor não enxerga
133
+ imagem e nem pode chamar a importação (a lista de ações dele é fechada),
134
+ então o terminal guarda aqui os caminhos de imagem que EXISTEM no texto do
135
+ pedido, e o Estúdio importa sozinho no próximo roteiro (index.js). */
136
+ let IMAGENS = [];
137
+ const RE_EXT_IMG = /\.(png|jpe?g|gif|webp|heic|bmp)\b/gi;
138
+ function imagensNoTexto(texto) {
139
+ const fs = require('fs'), os = require('os');
140
+ const t = String(texto || '');
141
+ const achadas = [];
142
+ let m;
143
+ while ((m = RE_EXT_IMG.exec(t))) {
144
+ const fim = m.index + m[0].length;
145
+ // Do fim da extensão para trás: o começo é uma "/" (ou "~/", "file://") no início ou depois de espaço/aspas.
146
+ let melhor = null;
147
+ for (let i = m.index; i >= 0; i--) {
148
+ if (t[i] !== '/' && !(t[i] === '~' && t[i + 1] === '/')) continue;
149
+ const antes = i === 0 ? ' ' : t[i - 1];
150
+ if (!/[\s'"(:]/.test(antes) && !(t.slice(Math.max(0, i - 7), i) === 'file://')) continue;
151
+ const bruto = t.slice(i, fim).replace(/\\(.)/g, '$1').replace(/^~(?=\/)/, os.homedir());
152
+ try { if (fs.statSync(bruto).isFile()) melhor = bruto; } catch { /* não é este começo */ }
153
+ }
154
+ if (melhor && !achadas.includes(melhor)) achadas.push(melhor);
155
+ }
156
+ return achadas.slice(0, 6);
157
+ }
158
+ function guardarImagens(lista) { IMAGENS = Array.isArray(lista) ? lista.slice(0, 6) : []; return IMAGENS; }
159
+ function imagens() { return IMAGENS; }
160
+ function instrucaoDasImagens(lista = IMAGENS) {
161
+ if (!lista.length) return '';
162
+ return `[REFERÊNCIA VISUAL] A pessoa mandou ${lista.length} imagem(ns): ${lista.map((f) => require('path').basename(f)).join(', ')}. `
163
+ + 'Elas SÃO a referência do pedido, mesmo em "só motion". Se o pedido é vídeo, o Estúdio já guarda cada uma no projeto e tira as '
164
+ + 'cores dela (com site, as cores do site mandam). Escreva o roteiro seguindo o que a referência mostra e diga no fim, '
165
+ + 'em uma linha, o que dela o vídeo usou.';
166
+ }
167
+
168
+ module.exports = { definir, atual, limpar, semImagem, termos, entender, inferir, instrucao, dominios, filtrar,
169
+ imagensNoTexto, guardarImagens, imagens, instrucaoDasImagens };
@@ -32,7 +32,8 @@
32
32
  const fs = require('fs');
33
33
  const path = require('path');
34
34
 
35
- const ORCAMENTO_SERVIDOR = 6 * 1024;
35
+ // 2,5 KB no servidor (13/10): 6 KB de arquivo pré-lido não cabiam no orçamento do plano grátis e empurravam a conversa para fora.
36
+ const ORCAMENTO_SERVIDOR = 2.5 * 1024;
36
37
  const ORCAMENTO_LOCAL = 40 * 1024;
37
38
  const MAIOR_ARQUIVO = 20 * 1024;
38
39
 
package/lib/tools.js CHANGED
@@ -630,8 +630,15 @@ async function despachar(name, args, ctx) {
630
630
  return editFile(args, projectDir);
631
631
  case 'read_file':
632
632
  return readFile(args, projectDir);
633
- case 'list_dir':
634
- return { ok: true, tree: treeDir(resolverParaLer(args.path, projectDir), args.depth || 2) };
633
+ case 'list_dir': {
634
+ /* No máximo 60 linhas (13/10: "continua listando coisas desnecessárias").
635
+ Pasta grande listada inteira enche a conversa e não diz onde está o que importa. */
636
+ const arvore = String(treeDir(resolverParaLer(args.path, projectDir), args.depth || 2));
637
+ const ls = arvore.split('\n');
638
+ if (ls.length <= 60) return { ok: true, tree: arvore };
639
+ return { ok: true, tree: ls.slice(0, 60).join('\n'), cortado: ls.length - 60,
640
+ dica: 'Lista grande, cortei. Não liste de novo: use glob com o padrão do que procura (ex.: "src/**/*.css") ou grep pelo nome.' };
641
+ }
635
642
  /* `pasta` (opcional): procurar numa pasta que o usuário citou, sem
636
643
  trocar a de trabalho. Os caminhos voltam relativos a ELA, e a
637
644
  resposta diz qual é. */
package/lib/travado.js CHANGED
@@ -15,6 +15,10 @@
15
15
  * vezes sem escrita no meio? → travado
16
16
  * 3. VOLTAS_SEM_NOVIDADE voltas seguidas sem escrever nada e sem nenhuma
17
17
  * chamada que ainda não tinha sido feita? → travado
18
+ * 4. LIMITE_OLHAR leituras, listagens e buscas DIFERENTES seguidas, sem
19
+ * nenhuma ação no meio (escrever, rodar, usar o Estúdio)? → travado
20
+ * (13/10: "continua lendo e listando coisas desnecessárias". As regras
21
+ * 1 a 3 só pegam repetição: ler 20 arquivos diferentes nunca travava.)
18
22
  * Na primeira vez, um empurrão curto (o que está repetindo, o que fazer). Se
19
23
  * travar de novo depois do empurrão, PARA e diz por quê.
20
24
  *
@@ -28,10 +32,14 @@ const path = require('path');
28
32
  const LIMITE_RELEITURA = 3;
29
33
  const LIMITE_MESMA_CHAMADA = 4;
30
34
  const VOLTAS_SEM_NOVIDADE = 6;
35
+ const LIMITE_OLHAR = 8;
36
+ const OLHAR = new Set(['read_file', 'list_dir', 'glob', 'grep']);
37
+ // O shell que só olha: listar, achar, procurar, mostrar arquivo.
38
+ const RE_SHELL_OLHA = /^(?:cd\s+\S+\s*&&\s*)?(?:ls|find|grep|rg|cat|head|tail|sed\s+-n|tree|wc|nl|less|more|stat|file|du)\b/;
31
39
  const ESCRITAS = new Set(['write_file', 'edit_file', 'multi_edit']);
32
40
 
33
41
  function novo() {
34
- return { leituras: new Map(), chamadas: new Map(), vistas: new Set(), novidade: false, voltasSemNovidade: 0, avisos: 0 };
42
+ return { leituras: new Map(), chamadas: new Map(), vistas: new Set(), novidade: false, voltasSemNovidade: 0, avisos: 0, olhou: [], limiteOlhar: LIMITE_OLHAR };
35
43
  }
36
44
 
37
45
  /* A faixa que a leitura cobre. Sem offset nem limit, o read_file devolve o
@@ -89,6 +97,19 @@ function observar(estado, nome, args, pasta) {
89
97
  estado.novidade = true;
90
98
  }
91
99
  if (!estado.vistas.has(chave)) { estado.vistas.add(chave); estado.novidade = true; }
100
+ // Regra 4: o que só olha soma; qualquer ação zera.
101
+ const soOlha = OLHAR.has(nome) || (nome === 'run_shell' && RE_SHELL_OLHA.test(String(a.command || '').trim()));
102
+ if (!soOlha) { estado.olhou = []; estado.limiteOlhar = LIMITE_OLHAR; }
103
+ else {
104
+ const alvo = a.path || a.pattern || a.query || a.command || '.';
105
+ const curto = pasta && a.path ? path.relative(pasta, caminhoDe(a.path, pasta)) || '.' : String(alvo).slice(0, 40);
106
+ estado.olhou.push(`${nome === 'run_shell' ? '$ ' : ''}${curto}`);
107
+ if (estado.olhou.length >= estado.limiteOlhar) {
108
+ // A segunda vez vem 4 olhadas depois: o empurrão dá espaço para o último passo, não para mais um passeio.
109
+ estado.limiteOlhar = estado.olhou.length + 4;
110
+ return `olhou ${estado.olhou.length} coisas seguidas sem fazer nada (${estado.olhou.slice(-6).join(', ')})`;
111
+ }
112
+ }
92
113
  const leitura = nome === 'read_file' ? a : nome === 'run_shell' ? leituraDoShell(a.command) : null;
93
114
  if (leitura && leitura.path) {
94
115
  const caminho = caminhoDe(leitura.path, pasta);
@@ -138,4 +159,4 @@ function decidir(estado, motivo) {
138
159
  };
139
160
  }
140
161
 
141
- module.exports = { LIMITE_RELEITURA, LIMITE_MESMA_CHAMADA, VOLTAS_SEM_NOVIDADE, LEITURA_PADRAO, novo, faixa, leituraDoShell, observar, fimDaVolta, decidir };
162
+ module.exports = { LIMITE_RELEITURA, LIMITE_MESMA_CHAMADA, VOLTAS_SEM_NOVIDADE, LIMITE_OLHAR, LEITURA_PADRAO, novo, faixa, leituraDoShell, observar, fimDaVolta, decidir };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.8.0",
3
+ "version": "10.9.1",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {