primocode 9.8.0-beta.14 → 9.8.0-beta.15

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 v9.8.0-beta.14
1
+ # PrimoCode v9.8.0-beta.15
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 v9.8.0-beta.14
12
+ ▐▛███▜▌ Primo Code v9.8.0-beta.15
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -32,7 +32,10 @@ const VAZIAS = new Set((
32
32
  + 'ou mas como mais menos muito muita muitos muitas isso isto esse essa este esta aquele aquela ele ela '
33
33
  + 'eles elas eu voce voces nos meu minha seu sua nosso nossa tem ter ser foi era sao esta estao vai vou '
34
34
  + 'ja ai aqui la entao tambem so quando onde porque pois ate sobre depois antes cada todo toda todos '
35
- + 'todas qual quais quem nao sim ne ta tipo coisa coisas gente fazer faz feito pode podem vamos hoje'
35
+ + 'todas qual quais quem nao sim ne ta tipo coisa coisas gente fazer faz feito pode podem vamos hoje '
36
+ // Verbos de ligação e de fala: não são a ideia da frase.
37
+ + 'mostra mostrar ajuda ajudar deixa deixar usar usa usando sabe saber quer querer precisa precisar '
38
+ + 'acho olha vem fica ficar falar fala dizer diz ver vai ir estou estava sendo tendo existe chamado'
36
39
  ).split(' '));
37
40
 
38
41
  /* A palavra (sem acento, minúscula, começo dela) → o emoji colorido. Casa
@@ -208,8 +211,8 @@ function camera(i, dur) {
208
211
  }
209
212
 
210
213
  const ESTILOS = {
211
- viral: { icones: true, letreiro: true, sons: true, barra: true, apoio: true, destaque: '#FFD400', forte: '#00E0A4' },
212
- limpo: { icones: false, letreiro: false, sons: false, barra: false, apoio: true, destaque: '#FFFFFF', forte: '#FFD400' },
214
+ viral: { icones: true, letreiro: true, sons: true, barra: true, apoio: true, recorte: true, fundoRecorte: ['#1B1F3B', '#3A1C71'], destaque: '#FFD400', forte: '#00E0A4' },
215
+ limpo: { icones: false, letreiro: false, sons: false, barra: false, apoio: true, recorte: false, fundoRecorte: ['#111111', '#333333'], destaque: '#FFFFFF', forte: '#FFD400' },
213
216
  };
214
217
 
215
218
  /**
@@ -225,7 +228,10 @@ const ESTILOS = {
225
228
  * @param {object} op.sons { pop, whoosh } — caminhos na pasta do projeto, opcionais
226
229
  * @param {Set} op.iconesOk os nomes de ícone que existem de verdade (sem isso, todos valem)
227
230
  */
228
- function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null, imagens = {}, trilha = null }) {
231
+ // Em quais cenas a pessoa é recortada, com a palavra gigante atrás dela.
232
+ const temRecorte = (i) => i % 4 === 2;
233
+
234
+ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null, imagens = {}, trilha = null, recortes = {} }) {
229
235
  const e = ESTILOS[estilo] || ESTILOS.viral;
230
236
  const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
231
237
  if (!lista.length) return { ok: false, error: 'a gravação não tem fala transcrita para editar.' };
@@ -236,6 +242,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
236
242
  const total = duracao || lista[lista.length - 1].ate + 0.3;
237
243
  const cenas = [];
238
244
  const efeitos = [];
245
+ const infos = []; // onde cada cena começa e a palavra forte dela — para o recorte
239
246
  let lado = 0;
240
247
 
241
248
  grupos.forEach((g, i) => {
@@ -253,10 +260,37 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
253
260
  deOrigem: r2(ini), ateOrigem: r2(ini + dur), volume: 1,
254
261
  }];
255
262
 
263
+ infos.push({ ini: r2(ini), dur, forte: forte ? { de: local(forte.de), texto: forte.texto, peso: peso(forte.texto) } : null });
264
+
265
+ /* A PALAVRA ATRÁS DA PESSOA: o fundo do quarto some, entra a cor da
266
+ marca, a palavra forte aparece enorme e a pessoa, recortada, fica
267
+ na frente dela. É o efeito que mais denuncia edição de videomaker. */
268
+ const rec = e.recorte && temRecorte(i) ? recortes[i] : null;
269
+ if (rec && forte) {
270
+ const vida = r2(rec.ate - rec.de);
271
+ const some = [{ t: 0, v: 0 }, { t: 0.1, v: 1 }, { t: vida - 0.12, v: 1 }, { t: vida, v: 0 }];
272
+ elementos.push({
273
+ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', de: rec.de, ate: rec.ate,
274
+ fundo: { tipo: 'gradiente', cores: [e.fundoRecorte[0], e.fundoRecorte[1]], angulo: 160 }, anima: { opacidade: some },
275
+ });
276
+ const palavra = limpaPontuacao(forte.texto).toUpperCase();
277
+ const fit = Math.round(Math.min(altura * 0.42, (largura * 0.92) / Math.max(3, palavra.length * 0.46)));
278
+ elementos.push({
279
+ tipo: 'texto', texto: palavra, fonte: 'Anton', peso: 400, tamanho: fit, cor: e.forte,
280
+ x: '50%', y: deitado ? '38%' : '34%', ancora: 'centro', de: rec.de, ate: rec.ate,
281
+ anima: { escala: [{ t: 0, v: 0.6 }, { t: 0.22, v: 1.06, e: 'saida' }, { t: 0.4, v: 1, e: 'suave' }, { t: vida, v: 1.05 }], opacidade: some },
282
+ });
283
+ elementos.push({
284
+ tipo: 'video', src: rec.src, volume: 0, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
285
+ de: rec.de, ate: rec.ate, anima: { opacidade: some },
286
+ });
287
+ if (e.sons && sons.whoosh) efeitos.push({ t: r2(Math.max(0, ini + rec.de - 0.1)), src: sons.whoosh, volume: 0.55 });
288
+ }
289
+
256
290
  /* A IMAGEM DE APOIO: na hora da palavra forte, a tela vira a imagem do
257
291
  que está sendo dito, com um zoom lento, e a legenda continua por
258
292
  cima. É o "b-roll" das edições de videomaker. */
259
- const apoio = forte && e.apoio && temApoio(i) ? imagens[termoDeApoio(forte.texto)] : null;
293
+ const apoio = forte && e.apoio && temApoio(i) && !rec ? imagens[termoDeApoio(forte.texto)] : null;
260
294
  if (apoio) {
261
295
  const de = local(forte.de - 0.1);
262
296
  const vida = Math.min(2.2, dur - de);
@@ -278,7 +312,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
278
312
  }
279
313
 
280
314
  // O ícone salta na hora da palavra; sem ícone, o letreiro da ideia.
281
- const icone = forte && e.icones && !apoio ? iconeDe(forte.texto) : null;
315
+ const icone = forte && e.icones && !apoio && !rec ? iconeDe(forte.texto) : null;
282
316
  const temIcone = icone && (!iconesOk || iconesOk.has(icone));
283
317
  const x = lado++ % 2 === 0 ? '78%' : '22%';
284
318
  if (temIcone) {
@@ -298,7 +332,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
298
332
  });
299
333
  if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.8 });
300
334
  }
301
- } else if (forte && e.letreiro && i % 2 === 0 && peso(forte.texto) >= 7) {
335
+ } else if (forte && e.letreiro && !rec && i % 2 === 0 && peso(forte.texto) >= 7) {
302
336
  const de = local(forte.de);
303
337
  const vida = Math.min(1.6, dur - de);
304
338
  if (vida > 0.5) {
@@ -362,8 +396,10 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
362
396
  ...(trilha ? { trilha: { src: trilha, volume: 0.07, loop: true } } : {}),
363
397
  } } : {}),
364
398
  },
399
+ infos,
365
400
  resumo: {
366
401
  cenas: cenas.length,
402
+ recortes: Object.keys(recortes).length,
367
403
  icones: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'icone').length, 0),
368
404
  apoios: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'imagem' || (x.tipo === 'video' && x.volume === 0)).length, 0),
369
405
  musica: Boolean(trilha),
@@ -402,4 +438,4 @@ async function prepararSons(pasta, video) {
402
438
  return sons;
403
439
  }
404
440
 
405
- module.exports = { montarEdicao, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, ESTILOS, ICONES, FOTO };
441
+ module.exports = { montarEdicao, temRecorte, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, ESTILOS, ICONES, FOTO };
@@ -81,6 +81,37 @@ function rodarPython(script, args, { timeout = 20 * 60000 } = {}) {
81
81
  });
82
82
  }
83
83
 
84
+ /* O RECORTE DA PESSOA precisa do MediaPipe, e o MediaPipe só existe até o
85
+ Python 3.12 — no Python 3.14 do Homebrew ele nem instala (medido em
86
+ 25/09: o recorte nunca tinha rodado nesta máquina). Com o `uv`, o script
87
+ roda num Python 3.12 isolado, baixado uma vez; sem ele, tenta o Python da
88
+ máquina, como antes. */
89
+ function acharUv() {
90
+ const { execFileSync } = require('child_process');
91
+ for (const c of ['uv', path.join(os.homedir(), '.local', 'bin', 'uv'), '/opt/homebrew/bin/uv', '/usr/local/bin/uv']) {
92
+ try { execFileSync(c, ['--version'], { stdio: 'ignore', timeout: 5000 }); return c; } catch { /* próximo */ }
93
+ }
94
+ return null;
95
+ }
96
+ function rodarPessoa(args, { timeout = 40 * 60000 } = {}) {
97
+ const uv = acharUv();
98
+ if (!uv) return rodarPython('pessoa.py', args, { timeout });
99
+ return new Promise((resolve) => {
100
+ const p = spawn(uv, ['run', '--quiet', '--python', '3.12', '--with', 'mediapipe<0.10.22',
101
+ '--with', 'opencv-python', '--with', 'numpy', 'python', path.join(PY, 'pessoa.py'), ...args]);
102
+ let saida = '', erro = '';
103
+ p.stdout.on('data', (d) => { saida += d.toString(); });
104
+ p.stderr.on('data', (d) => { erro += d.toString(); });
105
+ const relogio = setTimeout(() => { try { p.kill(); } catch {} }, timeout);
106
+ p.on('close', () => {
107
+ clearTimeout(relogio);
108
+ try { resolve(JSON.parse(saida.trim().split('\n').pop())); }
109
+ catch { resolve({ ok: false, error: (erro || saida).slice(-400).trim() || 'o recorte não respondeu' }); }
110
+ });
111
+ p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, error: e.message }); });
112
+ });
113
+ }
114
+
84
115
  // ── estudio_projeto ──────────────────────────────────────────────────────
85
116
 
86
117
  /**
@@ -481,12 +512,36 @@ async function ferramentaVideo(args, ctx = {}) {
481
512
  } catch { /* segue sem música */ }
482
513
  }
483
514
 
484
- const montado = edicao.montarEdicao({
515
+ const base = {
485
516
  src: path.relative(p.pasta, fonte).replace(/\\/g, '/'),
486
517
  duracao: m.segundos, palavras, largura: m.largura, altura: m.altura,
487
518
  estilo: args.estilo || 'viral', sons, iconesOk, imagens, trilha,
488
- });
519
+ };
520
+ let montado = edicao.montarEdicao(base);
489
521
  if (!montado.ok) return montado;
522
+
523
+ /* A segunda passada: nas frases escolhidas, a pessoa recortada na
524
+ frente da palavra gigante. Só os trechos que entram são recortados
525
+ (2 s cada), não o vídeo inteiro — recortar 52 s levaria minutos. */
526
+ if ((args.estilo || 'viral') === 'viral' && args.recorte !== false) {
527
+ const recortes = {};
528
+ for (const [i, info] of montado.infos.entries()) {
529
+ if (!edicao.temRecorte(i) || !info.forte || info.forte.peso < 9 || Object.keys(recortes).length >= 3) continue;
530
+ const de = Math.max(0, info.forte.de - 0.15);
531
+ const ate = Math.min(info.dur, de + 2.2);
532
+ if (ate - de < 1) continue;
533
+ const seg = path.join(p.pasta, 'midia', `seg-${i + 1}-${Date.now().toString(36)}.mp4`);
534
+ const corte = await video.rodar(video.ffmpeg(), ['-y', '-ss', String(info.ini + de), '-t', String(ate - de), '-i', fonte,
535
+ '-vf', `scale=${Math.min(1280, m.largura)}:-2`, '-an', '-c:v', 'libx264', '-preset', 'veryfast', seg], { timeout: 120000 });
536
+ if (!corte.ok) continue;
537
+ const saida = path.join(p.pasta, 'midia', `pessoa-${i + 1}-${Date.now().toString(36)}.webm`);
538
+ const r = await rodarPessoa(['recortar', '--entrada', seg, '--saida', saida], { timeout: 10 * 60000 });
539
+ try { fs.unlinkSync(seg); } catch { /* fica */ }
540
+ if (!r.ok) break; // sem MediaPipe, nenhum recorte vai dar: não tenta os outros
541
+ recortes[i] = { src: path.relative(p.pasta, saida).replace(/\\/g, '/'), de: Math.round(de * 100) / 100, ate: Math.round(ate * 100) / 100 };
542
+ }
543
+ if (Object.keys(recortes).length) montado = edicao.montarEdicao({ ...base, recortes });
544
+ }
490
545
  const salvo = await ferramentaPlano({ id: args.id, plano: montado.plano, motivo: `edição automática (${args.estilo || 'viral'})` });
491
546
  if (!salvo.ok) return salvo;
492
547
  projeto.anotar(p.id, 'edicao', `${montado.resumo.cenas} cenas, ${montado.resumo.icones} ícones, ${cortes} cortes de silêncio`);
@@ -499,7 +554,7 @@ async function ferramentaVideo(args, ctx = {}) {
499
554
 
500
555
  if (acao === 'recortar-pessoa' || acao === 'pessoa') {
501
556
  const saida = path.join(p.pasta, 'midia', `pessoa-${Date.now().toString(36)}.webm`);
502
- const r = await rodarPython('pessoa.py', [
557
+ const r = await rodarPessoa([
503
558
  'recortar', '--entrada', alvo, '--saida', saida,
504
559
  ...(args.suavizar != null ? ['--suavizar', String(args.suavizar)] : []),
505
560
  ...(args.encolher != null ? ['--encolher', String(args.encolher)] : []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.8.0-beta.14",
3
+ "version": "9.8.0-beta.15",
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": {