primocode 9.10.0 → 10.0.9

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 (89) hide show
  1. package/README.md +30 -6
  2. package/bin/primocode.js +169 -342
  3. package/lib/act.js +360 -39
  4. package/lib/browser.js +105 -4
  5. package/lib/casar.js +108 -0
  6. package/lib/catalogo.js +6 -2
  7. package/lib/claude-engine.js +38 -17
  8. package/lib/codex-engine.js +27 -9
  9. package/lib/config.js +10 -3
  10. package/lib/cursor.js +10 -1
  11. package/lib/entrada.js +77 -11
  12. package/lib/estudio/cinema.js +27 -9
  13. package/lib/estudio/diretor.js +21 -1
  14. package/lib/estudio/ende.js +16 -5
  15. package/lib/estudio/estilo.js +66 -0
  16. package/lib/estudio/gerativo.js +1453 -0
  17. package/lib/estudio/glifos.js +215 -0
  18. package/lib/estudio/hibrido.js +165 -0
  19. package/lib/estudio/index.js +284 -53
  20. package/lib/estudio/kit/base.jsx +285 -0
  21. package/lib/estudio/kit/gerativo.jsx +980 -0
  22. package/lib/estudio/kit/index.jsx +13 -0
  23. package/lib/estudio/kit/marca.jsx +741 -0
  24. package/lib/estudio/kit/tres.jsx +308 -0
  25. package/lib/estudio/kit/vox.jsx +357 -0
  26. package/lib/estudio/kit.js +78 -0
  27. package/lib/estudio/lancamento.js +441 -0
  28. package/lib/estudio/motion.js +2 -5
  29. package/lib/estudio/plano.js +28 -3
  30. package/lib/estudio/referencias.js +105 -0
  31. package/lib/estudio/remotion.js +139 -51
  32. package/lib/estudio/ritmo.js +77 -0
  33. package/lib/estudio/sons-leves.js +38 -1
  34. package/lib/estudio/variedade.js +105 -0
  35. package/lib/estudio/vox.js +414 -0
  36. package/lib/fala.js +1 -94
  37. package/lib/mcp.js +156 -0
  38. package/lib/personas.js +67 -0
  39. package/lib/pipeline.js +55 -56
  40. package/lib/repeticao.js +3 -1
  41. package/lib/skills.js +44 -2
  42. package/lib/snapshot.js +111 -0
  43. package/lib/tools.js +100 -22
  44. package/lib/ui.js +50 -8
  45. package/lib/video/analise.js +89 -0
  46. package/lib/video/auto.js +329 -0
  47. package/lib/video/camadas.js +207 -0
  48. package/lib/video/cli.js +74 -0
  49. package/lib/video/delta.js +90 -0
  50. package/lib/video/edits.js +379 -0
  51. package/lib/video/filters.js +280 -0
  52. package/lib/video/fx3d/Camadas.jsx +64 -0
  53. package/lib/video/fx3d/Objetos.jsx +128 -0
  54. package/lib/video/fx3d/Root.jsx +18 -0
  55. package/lib/video/fx3d/modelos.jsx +304 -0
  56. package/lib/video/fx3d.js +108 -0
  57. package/lib/video/gerados.js +119 -0
  58. package/lib/video/objetos.js +111 -0
  59. package/lib/video/ops-fx.js +453 -0
  60. package/lib/video/ops.js +347 -0
  61. package/lib/video/paralelo.js +50 -0
  62. package/lib/video/pedido.js +191 -0
  63. package/lib/video/projeto.js +133 -0
  64. package/lib/video/rastrear.js +95 -0
  65. package/lib/video/rastro.js +276 -0
  66. package/lib/video/render.js +274 -0
  67. package/lib/video/texto.js +144 -0
  68. package/lib/video/timeline.js +318 -0
  69. package/lib/video/transcript.js +124 -0
  70. package/lib/voz.js +75 -223
  71. package/package.json +2 -3
  72. package/skills/motion-de-primeira/SKILL.md +5 -5
  73. package/studio/narrar.py +10 -7
  74. package/studio/rastrear.py +399 -0
  75. package/studio/texto.py +208 -0
  76. package/voz/primo_voz/__main__.py +96 -236
  77. package/voz/primo_voz/transcrever.py +31 -1
  78. package/app/ansi.js +0 -55
  79. package/app/app.css +0 -500
  80. package/app/app.js +0 -1017
  81. package/app/icone.svg +0 -6
  82. package/app/index.html +0 -220
  83. package/app/primo-marca.js +0 -192
  84. package/lib/app.js +0 -615
  85. package/lib/icone.js +0 -263
  86. package/lib/instalar-app.js +0 -231
  87. package/lib/painel.js +0 -291
  88. package/voz/primo_voz/desperta.py +0 -195
  89. package/voz/primo_voz/orbe.py +0 -406
package/lib/tools.js CHANGED
@@ -61,6 +61,8 @@ async function clicarPeloCursor(x, y, { botao = 'left', duplo = false, rotulo }
61
61
  () => (duplo ? desktop.doubleClick(nx, ny) : desktop.click(nx, ny, botao)));
62
62
  }
63
63
  const referencia = require('./referencia');
64
+ const snapshot = require('./snapshot');
65
+ const casar = require('./casar');
64
66
  const estudio = require('./estudio');
65
67
  const cfg = require('./config');
66
68
 
@@ -85,7 +87,8 @@ function resolveInProject(rawPath, projectDir) {
85
87
  }
86
88
  const target = path.resolve(base, rawPath || '.');
87
89
  const rel = path.relative(base, target);
88
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
90
+ // `..` como SEGMENTO, não como começo de nome: "..rascunho" é legítimo.
91
+ if (rel === '..' || rel.startsWith('..' + path.sep) || rel.startsWith('../') || path.isAbsolute(rel)) {
89
92
  throw new Error(`Caminho fora da pasta de trabalho: ${rawPath}`);
90
93
  }
91
94
  return target;
@@ -100,7 +103,10 @@ function treeDir(dir, maxDepth = 2, currentDepth = 0, prefix = '') {
100
103
  for (const item of items) {
101
104
  if (item.startsWith('.') && item !== '.env') continue;
102
105
  if (IGNORAR.has(item)) continue;
103
- (fs.statSync(path.join(dir, item)).isDirectory() ? dirs : files).push(item);
106
+ // Link quebrado não derruba a listagem inteira: entra como arquivo.
107
+ let ehPasta = false;
108
+ try { ehPasta = fs.statSync(path.join(dir, item)).isDirectory(); } catch { ehPasta = false; }
109
+ (ehPasta ? dirs : files).push(item);
104
110
  }
105
111
  dirs.sort(); files.sort();
106
112
  const all = [...dirs, ...files];
@@ -109,7 +115,7 @@ function treeDir(dir, maxDepth = 2, currentDepth = 0, prefix = '') {
109
115
  const isLast = i === all.length - 1;
110
116
  result += prefix + (isLast ? '└── ' : '├── ') + item + '\n';
111
117
  const full = path.join(dir, item);
112
- if (fs.statSync(full).isDirectory() && currentDepth < maxDepth) {
118
+ if (dirs.includes(item) && currentDepth < maxDepth) {
113
119
  result += treeDir(full, maxDepth, currentDepth + 1, prefix + (isLast ? ' ' : '│ '));
114
120
  }
115
121
  }
@@ -262,7 +268,19 @@ async function runInNewTerminal(command, cwd) {
262
268
  como ~/Documents/Meus Projetos virava dois argumentos e o cd falhava
263
269
  calado: o terminal abria na pasta errada e o comando rodava no lugar
264
270
  errado, que é pior que não abrir. */
265
- cmd = `osascript -e 'tell application "Terminal" to do script "cd ${pastaAlvo.replace(/ /g, '\\\\ ')} && ${command}"'`;
271
+ /* Sem shell no meio: o script vai como UM argumento do osascript
272
+ (execFile), e dentro dele a string do AppleScript é escapada (\\ e
273
+ \"). Antes o comando entrava cru entre aspas duplas dentro de aspas
274
+ simples — `node server.js --name="x"` quebrava o AppleScript. A
275
+ pasta vai entre aspas simples para o shell do Terminal (a aspa
276
+ simples está barrada acima). */
277
+ const doTerminal = `cd '${pastaAlvo}' && ${command}`;
278
+ const script = `tell application "Terminal" to do script "${doTerminal.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
279
+ return new Promise((resolve) => {
280
+ execFile('osascript', ['-e', script], { timeout: 60000 }, (erro, _o, stderr) => {
281
+ resolve(erro ? { ok: false, error: String(stderr || erro.message).trim() || 'falhou' } : { ok: true, opener: 'osascript' });
282
+ });
283
+ });
266
284
  } else if (p === 'win32') {
267
285
  // Aspas em volta do caminho: "C:\Users\Joel Silva\..." é comum, e sem
268
286
  // elas o cmd corta no espaço. A aspa dupla já está barrada acima, então
@@ -273,7 +291,8 @@ async function runInNewTerminal(command, cwd) {
273
291
  o caminho não precisa entrar na string de shell nenhuma. É a solução
274
292
  mais segura das três: o que não é interpolado não pode ser mal
275
293
  escapado. */
276
- cmd = `x-terminal-emulator -e "bash -c '${command}; exec bash'"`;
294
+ // A aspa simples do comando fecharia o `bash -c '…'`: vira '\''.
295
+ cmd = `x-terminal-emulator -e "bash -c '${String(command).replace(/'/g, "'\\''")}; exec bash'"`;
277
296
  pastaDoProcesso = pastaAlvo;
278
297
  }
279
298
  const r = await runShell(cmd, 60000, pastaDoProcesso);
@@ -315,7 +334,19 @@ function editFile({ path: rawPath, old_string, new_string, replace_all }, projec
315
334
  const original = fs.readFileSync(filePath, 'utf-8');
316
335
  const ocorrencias = original.split(old_string).length - 1;
317
336
 
337
+ // Não bateu letra por letra: o casador frouxo (lib/casar.js) acha o
338
+ // trecho com o espaço, a indentação ou o número de linha errados — desde
339
+ // que seja UM lugar só. Poupa a volta "não achei → relê → edita de novo".
340
+ let casado = null;
318
341
  if (ocorrencias === 0) {
342
+ const r = casar.achar(original, old_string);
343
+ if (r.erro === 'varios') {
344
+ return { ok: false, error: `Trecho aparece ${r.vezes} vezes (${r.como}). Inclua mais linhas em volta para ficar único.` };
345
+ }
346
+ if (!r.erro) casado = r;
347
+ }
348
+
349
+ if (ocorrencias === 0 && !casado) {
319
350
  // Erro seco faz o modelo chutar de novo. Devolvendo o que EXISTE no
320
351
  // arquivo, ele acerta na segunda tentativa.
321
352
  return {
@@ -329,15 +360,18 @@ function editFile({ path: rawPath, old_string, new_string, replace_all }, projec
329
360
  return { ok: false, error: `Trecho aparece ${ocorrencias} vezes. Inclua mais linhas em volta para ficar único, ou use replace_all.` };
330
361
  }
331
362
 
332
- const atualizado = replace_all
333
- ? original.split(old_string).join(new_string ?? '')
334
- : original.replace(old_string, new_string ?? '');
363
+ const atualizado = casado
364
+ ? original.slice(0, casado.inicio) + (new_string ?? '') + original.slice(casado.fim)
365
+ : replace_all
366
+ ? original.split(old_string).join(new_string ?? '')
367
+ : original.replace(old_string, () => new_string ?? '');
335
368
 
336
369
  fs.writeFileSync(filePath, atualizado, 'utf-8');
337
370
  return {
338
371
  ok: true,
339
372
  path: path.relative(projectDir, filePath).replace(/\\/g, '/'),
340
- substituicoes: replace_all ? ocorrencias : 1,
373
+ substituicoes: replace_all && !casado ? ocorrencias : 1,
374
+ casouPor: casado ? casado.como : undefined,
341
375
  linhasAntes: original.split('\n').length,
342
376
  linhasDepois: atualizado.split('\n').length,
343
377
  // As duas versões, para a TELA desenhar o diff. O `_` na frente marca
@@ -368,6 +402,13 @@ function readFile({ path: rawPath, offset, limit }, projectDir) {
368
402
  };
369
403
  }
370
404
 
405
+ /* Arquivo pequeno vem INTEIRO, mesmo pedido aos pedaços: o modelo lia um
406
+ app.js de 393 linhas de 50 em 50, cada pedaço uma ida ao servidor
407
+ (teste da loja, 01/10). Inteiro, uma vez, custa menos. */
408
+ if (todas.length <= 400 && bruto.length <= 40000) {
409
+ return { ok: true, content: todas.map((l, i) => `${i + 1}\t${l}`).join('\n'), linhas: todas.length, bytes: bruto.length,
410
+ inteiro: true, dica: 'arquivo pequeno: veio inteiro, com o número das linhas — não precisa ler de novo' };
411
+ }
371
412
  const inicio = Math.max(0, (offset || 1) - 1);
372
413
  const fim = Math.min(todas.length, inicio + (limit || 200));
373
414
  const recorte = todas.slice(inicio, fim).map((l, i) => `${inicio + i + 1}\t${l}`).join('\n');
@@ -391,12 +432,21 @@ function multiEdit({ path: rawPath, edits }, projectDir) {
391
432
  if (old_string === new_string) return { ok: false, error: `edit ${i + 1}: old_string e new_string são iguais` };
392
433
  const ocorrencias = conteudo.split(old_string).length - 1;
393
434
  if (ocorrencias === 0) {
394
- return { ok: false, error: `edit ${i + 1}: trecho não encontrado`, trechosParecidos: trechosParecidos(conteudo, old_string) };
435
+ const r = casar.achar(conteudo, old_string);
436
+ if (r.erro === 'varios') {
437
+ return { ok: false, error: `edit ${i + 1}: trecho aparece ${r.vezes}x (${r.como}) — inclua mais contexto para torná-lo único` };
438
+ }
439
+ if (r.erro) {
440
+ return { ok: false, error: `edit ${i + 1}: trecho não encontrado`, trechosParecidos: trechosParecidos(conteudo, old_string) };
441
+ }
442
+ conteudo = conteudo.slice(0, r.inicio) + (new_string ?? '') + conteudo.slice(r.fim);
443
+ substituicoes++;
444
+ continue;
395
445
  }
396
446
  if (ocorrencias > 1 && !replace_all) {
397
447
  return { ok: false, error: `edit ${i + 1}: trecho aparece ${ocorrencias}x — use replace_all ou inclua mais contexto para torná-lo único` };
398
448
  }
399
- conteudo = replace_all ? conteudo.split(old_string).join(new_string ?? '') : conteudo.replace(old_string, new_string ?? '');
449
+ conteudo = replace_all ? conteudo.split(old_string).join(new_string ?? '') : conteudo.replace(old_string, () => new_string ?? '');
400
450
  substituicoes++;
401
451
  }
402
452
 
@@ -517,6 +567,14 @@ async function despachar(name, args, ctx) {
517
567
  const alvo = /^https?:\/\//i.test(args.target || '')
518
568
  ? args.target
519
569
  : resolveInProject(args.target, projectDir);
570
+ /* Site da web abre À VISTA no Chrome controlado (o cursor vai na
571
+ barra e digita). Arquivo do projeto continua no navegador padrão. */
572
+ if (/^https?:/i.test(alvo) && process.env.PRIMOCODE_BROWSER_HEADLESS !== '1' && require('./cursor').disponivel()) {
573
+ try {
574
+ const r = await browser.abrirVisivel(alvo);
575
+ if (r && r.ok !== false) return { ok: true, opener: 'chrome à vista', url: r.url, pagina: await browser.retrato(40), comoUsar: snapshot.COMO_USAR };
576
+ } catch { /* cai no navegador padrão */ }
577
+ }
520
578
  return openInBrowser(alvo);
521
579
  }
522
580
  case 'multi_edit':
@@ -527,11 +585,14 @@ async function despachar(name, args, ctx) {
527
585
  // Pesquisa de referência: abre documentação e exemplos sobre o
528
586
  // pedido e devolve o TEXTO para o modelo aprender antes de
529
587
  // escrever. Aparece no terminal, com as fontes — nada oculto.
530
- case 'pesquisar_referencia':
531
- return referencia.pesquisar({
588
+ case 'pesquisar_referencia': {
589
+ const r = await referencia.pesquisar({
532
590
  consulta: args.consulta || args.query || '',
533
591
  paginas: Math.max(1, Math.min(5, Number(args.paginas) || 3)),
534
592
  });
593
+ // Escopo fechado ("só do site x.com"): o buscador devolve o mundo; aqui só passa o site.
594
+ return require('./estudio/referencias.js').filtrar(r);
595
+ }
535
596
 
536
597
  // memory-geral.md fica FORA da pasta do projeto — write_file/edit_file
537
598
  // não alcançam por design (resolveInProject bloqueia ../ de propósito).
@@ -544,24 +605,41 @@ async function despachar(name, args, ctx) {
544
605
  }
545
606
 
546
607
  // ── Navegador controlado (CDP) — o agente age numa página real ──
608
+ /* O navegador fala em REFS (lib/snapshot.js): abrir e clicar já
609
+ devolvem o retrato da página com "@e3 [button] Entrar", e o
610
+ selector aceita "@e3". Uma leitura a menos por passo, e nenhum
611
+ seletor CSS chutado. */
547
612
  case 'browser_open': {
548
613
  const alvo = /^https?:\/\//i.test(args.url || '')
549
614
  ? args.url
550
615
  : 'file://' + resolveInProject(args.url, projectDir);
551
- return browser.open(alvo);
616
+ // Endereço da web abre À VISTA: o cursor vai na barra e digita (browser.abrirVisivel).
617
+ const r = /^https?:/i.test(alvo) ? await browser.abrirVisivel(alvo) : await browser.open(alvo);
618
+ return r && r.ok !== false ? { ...r, pagina: await browser.retrato(40), comoUsar: snapshot.COMO_USAR } : r;
619
+ }
620
+ case 'browser_click': {
621
+ let r;
622
+ if (args.selector || snapshot.ehRef(args.text)) r = await browser.clickSelector(snapshot.seletorDaRef(args.selector || args.text));
623
+ else if (args.text) r = await browser.clickText(args.text);
624
+ else if (args.x != null && args.y != null) r = await browser.click(args.x, args.y);
625
+ else return { ok: false, error: 'browser_click precisa de selector (ou a ref "@e3"), text ou x/y' };
626
+ // O clique quase sempre muda a página: o retrato novo vai junto.
627
+ await new Promise((ok) => setTimeout(ok, 500));
628
+ return r && r.ok !== false ? { ...r, pagina: await browser.retrato(40) } : r;
552
629
  }
553
- case 'browser_click':
554
- if (args.selector) return browser.clickSelector(args.selector);
555
- if (args.text) return browser.clickText(args.text);
556
- if (args.x != null && args.y != null) return browser.click(args.x, args.y);
557
- return { ok: false, error: 'browser_click precisa de selector, text ou x/y' };
558
630
  case 'browser_type':
559
- if (args.selector) return browser.typeInSelector(args.selector, args.text || '', args.clear !== false);
631
+ if (args.selector) return browser.typeInSelector(snapshot.seletorDaRef(args.selector), args.text || '', args.clear !== false);
560
632
  return browser.type(args.text || '');
561
633
  case 'browser_key':
562
634
  return browser.key(args.key);
563
635
  case 'browser_read': {
564
- const sel = args.selector ? `document.querySelector(${JSON.stringify(args.selector)})` : 'document.body';
636
+ if (!args.selector) {
637
+ const pagina = await browser.retrato(80);
638
+ const texto = await browser.evaluate('(function(){return document.body?document.body.innerText.slice(0,2500):null;})()');
639
+ if (!pagina && texto == null) return { ok: false, error: 'nada na página — ela pode não ter carregado' };
640
+ return { ok: true, pagina, text: texto || '', comoUsar: snapshot.COMO_USAR };
641
+ }
642
+ const sel = `document.querySelector(${JSON.stringify(snapshot.seletorDaRef(args.selector))})`;
565
643
  const texto = await browser.evaluate(`(function(){const e=${sel};return e?e.innerText.slice(0,6000):null;})()`);
566
644
  return texto == null ? { ok: false, error: 'nada na página (ou seletor não encontrado)' } : { ok: true, text: texto };
567
645
  }
@@ -573,7 +651,7 @@ async function despachar(name, args, ctx) {
573
651
  case 'browser_scroll':
574
652
  return browser.scroll(args.dy != null ? args.dy : 600);
575
653
  case 'browser_wait':
576
- return browser.waitFor(args.selector, args.timeout_ms || 15000);
654
+ return browser.waitFor(snapshot.seletorDaRef(args.selector), args.timeout_ms || 15000);
577
655
  case 'browser_eval':
578
656
  return { ok: true, value: await browser.evaluate(args.js || 'null') };
579
657
 
package/lib/ui.js CHANGED
@@ -310,11 +310,11 @@ const TIPS = [
310
310
  ];
311
311
 
312
312
  const NEWS = [
313
- 'Vídeo com DIRETOR: roteiro, narração e voz decididos antes de produzir',
314
- 'Ele busca e baixa imagem, logo e música — com a licença junto do arquivo',
315
- '/voice abre o Primo Code Voice · /marketplace instala skills',
316
- 'Do "high" para cima ele PESQUISA referências antes de escrever',
317
- 'Esc Esc a qualquer momento para o agente no meio do trabalho',
313
+ 'A IA decide o caminho: skills e equipe num cardápio, ela escolhe o que usa',
314
+ 'Até 8 agentes em paralelo, cada um volta com uma entrega — e passa a vez',
315
+ 'Edite falando no vídeo: primocode video auto aplica o que você diz, na hora',
316
+ 'Colou texto grande? Vira etiqueta [Colado #1 · N linhas] na caixa',
317
+ 'Escopo fechado de verdade: "só do site x.com" filtra a pesquisa',
318
318
  ];
319
319
 
320
320
  // ── Cabeçalho ────────────────────────────────────────────────────────────
@@ -413,29 +413,70 @@ class Spinner {
413
413
  this.text = this.textos[0];
414
414
  this.frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
415
415
  this.i = 0; this.timer = null; this.desde = 0; this.largura = 0;
416
+ this.detalhe = ''; // o que está chegando agora (act.js atualiza)
416
417
  }
417
418
  start() {
418
419
  // Fora de um terminal (pipe, CI) a animação só sujaria a saída.
419
420
  if (!process.stdout.isTTY) return this;
420
421
  this.desde = Date.now();
422
+ /* Com a caixa de digitar no ar, o spinner desenha DENTRO dela (ver
423
+ mostrarSpinner em lib/entrada.js): escrever "\r" por cima apagava
424
+ a caixa e a barra de status enquanto ele girava. */
425
+ const caixa = Spinner.caixa;
426
+ if (caixa && caixa.mostrarSpinner && caixa.desenhado !== undefined) {
427
+ this.naCaixa = caixa;
428
+ this.timer = setInterval(() => {
429
+ if (this.textos.length > 1) this.text = this.textos[Math.floor((Date.now() - this.desde) / 4000) % this.textos.length];
430
+ const seg = Math.floor((Date.now() - this.desde) / 1000);
431
+ const extra = (seg >= 3 ? ` · ${seg}s` : '') + (this.detalhe ? ` · ${this.detalhe}` : '');
432
+ caixa.mostrarSpinner(`${c.muted(this.frames[this.i])} ${c.muted(this.text)}${c.dim(extra)}`);
433
+ this.i = (this.i + 1) % this.frames.length;
434
+ }, 120);
435
+ return this;
436
+ }
421
437
  this.timer = setInterval(() => {
422
438
  // Com mais de uma frase, troca a cada 4s. A frase nova pode ser mais
423
439
  // curta que a anterior: o resto da linha velha ficaria na tela, então
424
440
  // a linha é sempre preenchida até a maior largura já escrita.
425
441
  if (this.textos.length > 1) this.text = this.textos[Math.floor((Date.now() - this.desde) / 4000) % this.textos.length];
426
- const cru = ` ${this.frames[this.i]} ${this.text}`;
442
+ /* O relógio e o que está chegando. "Parece travado" era isto: a
443
+ mesma frase girando por um minuto inteiro enquanto o modelo
444
+ escrevia um arquivo grande. Com os segundos contando (e o que
445
+ já chegou, quando há), quem olha sabe que está andando. */
446
+ const seg = Math.floor((Date.now() - this.desde) / 1000);
447
+ const extra = (seg >= 3 ? ` · ${seg}s` : '') + (this.detalhe ? ` · ${this.detalhe}` : '');
448
+ const cru = ` ${this.frames[this.i]} ${this.text}${extra}`;
427
449
  const falta = Math.max(0, this.largura - cru.length);
428
450
  this.largura = Math.max(this.largura, cru.length);
429
- process.stdout.write(`\r ${c.muted(this.frames[this.i])} ${c.muted(this.text)}${' '.repeat(falta)}`);
451
+ process.stdout.write(`\r ${c.muted(this.frames[this.i])} ${c.muted(this.text)}${c.dim(extra)}${' '.repeat(falta)}`);
430
452
  this.i = (this.i+1) % this.frames.length;
431
453
  }, 80);
432
454
  return this;
433
455
  }
434
456
  stop() {
457
+ if (this.naCaixa) {
458
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
459
+ this.naCaixa.mostrarSpinner('');
460
+ this.naCaixa = null;
461
+ return;
462
+ }
435
463
  if (this.timer) { clearInterval(this.timer); this.timer = null; process.stdout.write(`\r${' '.repeat(Math.max(80, this.largura + 4))}\r`); }
436
464
  }
437
465
  }
438
466
 
467
+ /* O TÍTULO DO TERMINAL: é por ele que o Orca (e qualquer terminal com abas)
468
+ sabe que este painel é um agente e se está trabalhando. O Orca lê o glifo
469
+ de spinner em braile como "trabalhando" e o nome como identidade — então
470
+ "⠋ PrimoCode — tarefa" enquanto trabalha, "PrimoCode" parado. Vai pelo
471
+ canal cru da caixa: pelo stdout embrulhado, um escape sem quebra de linha
472
+ deixaria a caixa apagada (o mesmo defeito do spinner). */
473
+ function titulo(texto) {
474
+ if (!process.stdout.isTTY || process.env.PRIMOCODE_SEM_TITULO === '1') return;
475
+ const limpo = String(texto || '').replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 80);
476
+ const escrever = Spinner.caixa && Spinner.caixa.escreverCru ? Spinner.caixa.escreverCru : (s) => process.stdout.write(s);
477
+ try { escrever(`\x1b]0;${limpo}\x07`); } catch { /* terminal fechado */ }
478
+ }
479
+
439
480
  // O rótulo de cada tool na tela — o que a pessoa lê no lugar do nome interno.
440
481
  const ROTULOS = {
441
482
  read_file:'Read', write_file:'Write', edit_file:'Edit', multi_edit:'MultiEdit',
@@ -609,4 +650,5 @@ function friendlyError(raw) {
609
650
  }
610
651
 
611
652
 
612
- module.exports = { c, mascot, MASCOTES, resolverMascote, mascoteInfo, galeriaMascotes, reacao, welcomeBox, Spinner, PENSANDO, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
653
+ module.exports = {
654
+ titulo, c, mascot, MASCOTES, resolverMascote, mascoteInfo, galeriaMascotes, reacao, welcomeBox, Spinner, PENSANDO, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * analise.js — silêncio e batida, como conta pura sobre números medidos.
3
+ *
4
+ * O ffmpeg mede (onde o áudio fica abaixo do limiar; as amostras cruas), e
5
+ * as decisões ficam aqui, testáveis sem áudio nenhum:
6
+ *
7
+ * · o SILÊNCIO vira trecho da linha do tempo com `padding` de respiro em
8
+ * cada ponta — cortar no exato instante em que a voz volta é o que faz
9
+ * corte automático soar como máquina (lib/estudio/video.js conta a mesma
10
+ * história). O silêncio no começo e no fim do arquivo sai inteiro;
11
+ * · a BATIDA é o pico de subida de energia (onset), com limiar que se
12
+ * adapta ao volume da música em volta. Não é um detector de tempo de
13
+ * estúdio; é o bastante para cortar no ritmo.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const T = require('./timeline.js');
19
+
20
+ /**
21
+ * Os trechos (quadros da linha do tempo) a apagar, dados os silêncios de
22
+ * cada mídia em segundos da origem: {assetId: [{de, ate}]}.
23
+ */
24
+ function trechosDeSilencio(tl, clipes, silencios, { padding = 0.15, minimo = 2 } = {}) {
25
+ const ranges = [];
26
+ for (const c of clipes) {
27
+ const lista = silencios[c.asset] || [];
28
+ const dur = (tl.assets[c.asset] || {}).seconds || Infinity;
29
+ const { de, ate } = T.janelaOrigem(tl, c);
30
+ for (const s of lista) {
31
+ const a = s.de <= 0.05 ? s.de : s.de + padding;
32
+ const b = s.ate >= dur - 0.05 ? s.ate : s.ate - padding;
33
+ const ia = Math.max(a, de);
34
+ const ib = Math.min(b, ate);
35
+ if (ib <= ia) continue;
36
+ const fa = T.origemParaQuadro(tl, c, ia);
37
+ const fb = T.origemParaQuadro(tl, c, ib);
38
+ if (fb - fa >= minimo) ranges.push([fa, fb]);
39
+ }
40
+ }
41
+ return ranges;
42
+ }
43
+
44
+ /** Amostras (Float32, mono) → {bpm, beats: [segundos]}. */
45
+ function batidas(amostras, taxa, { sensibilidade = 1.4, minimoEntre = 0.22 } = {}) {
46
+ const hop = Math.max(64, Math.round(taxa * 0.0116)); // ~11,6 ms
47
+ const janela = hop * 2;
48
+ const energia = [];
49
+ for (let i = 0; i + janela <= amostras.length; i += hop) {
50
+ let s = 0;
51
+ for (let j = i; j < i + janela; j++) s += amostras[j] * amostras[j];
52
+ energia.push(Math.log(1e-9 + s / janela));
53
+ }
54
+ const onset = energia.map((e, i) => (i ? Math.max(0, e - energia[i - 1]) : 0));
55
+ const porSeg = taxa / hop;
56
+ const raio = Math.round(porSeg * 0.5);
57
+ const beats = [];
58
+ let ultimo = -Infinity;
59
+ for (let i = 1; i < onset.length - 1; i++) {
60
+ const a = Math.max(0, i - raio);
61
+ const b = Math.min(onset.length, i + raio);
62
+ let soma = 0;
63
+ let soma2 = 0;
64
+ for (let j = a; j < b; j++) { soma += onset[j]; soma2 += onset[j] * onset[j]; }
65
+ const n = b - a;
66
+ const media = soma / n;
67
+ const dp = Math.sqrt(Math.max(0, soma2 / n - media * media));
68
+ const limiar = media + sensibilidade * dp;
69
+ let pico = onset[i] > limiar && onset[i] > 0.3;
70
+ for (let j = Math.max(0, i - 3); pico && j <= Math.min(onset.length - 1, i + 3); j++) if (onset[j] > onset[i]) pico = false;
71
+ const t = (i * hop) / taxa;
72
+ if (pico && t - ultimo >= minimoEntre) { beats.push(Math.round(t * 1000) / 1000); ultimo = t; }
73
+ }
74
+ return { bpm: bpmDe(beats), beats };
75
+ }
76
+
77
+ function bpmDe(beats) {
78
+ if (beats.length < 4) return null;
79
+ const iv = [];
80
+ for (let i = 1; i < beats.length; i++) iv.push(beats[i] - beats[i - 1]);
81
+ iv.sort((a, b) => a - b);
82
+ let med = iv[Math.floor(iv.length / 2)];
83
+ let bpm = 60 / med;
84
+ while (bpm < 70) bpm *= 2;
85
+ while (bpm > 180) bpm /= 2;
86
+ return Math.round(bpm);
87
+ }
88
+
89
+ module.exports = { trechosDeSilencio, batidas, bpmDe };