primocode 9.8.0-beta.13 → 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.13
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.13
12
+ ▐▛███▜▌ Primo Code v9.8.0-beta.15
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/lib/api.js CHANGED
@@ -268,6 +268,13 @@ function getConectores(serverUrl, token) {
268
268
  return pedirGet(serverUrl, '/api/conectores', token);
269
269
  }
270
270
 
271
+ /* Foto e vídeo de apoio do Pexels, pela chave do servidor (ninguém precisa
272
+ ter uma). tipo: "imagens" | "videos". */
273
+ function getMidia(serverUrl, token, tipo, { q, n, orientacao } = {}) {
274
+ const qs = new URLSearchParams({ q: q || '', n: String(n || 6), ...(orientacao ? { orientacao } : {}) });
275
+ return pedirGet(serverUrl, `/api/midia/${tipo}?${qs}`, token);
276
+ }
277
+
271
278
  function getHealth(serverUrl, token) {
272
279
  return pedirGet(serverUrl, '/health', token);
273
280
  }
@@ -300,4 +307,4 @@ function pedirGet(serverUrl, rota, token) {
300
307
  });
301
308
  }
302
309
 
303
- module.exports = { request, requestStream, requestAct, requestChat, requestResumo, requestRefino, requestPlano, requestTranscricao, getHealth, getConectores };
310
+ module.exports = { request, requestStream, requestAct, requestChat, requestResumo, requestRefino, requestPlano, requestTranscricao, getHealth, getConectores, getMidia };
@@ -213,7 +213,28 @@ function extensaoPorConteudo(bytes) {
213
213
  *
214
214
  * @returns {{ok, imagens: Array<{url, titulo, autor, licenca, creditos, fonte}>}}
215
215
  */
216
- async function procurarImagens(termo, { quantas = 8, transparente = false } = {}) {
216
+ /* O Pexels pelo SERVIDOR: a chave é dele, e ninguém precisa ter uma. Sem
217
+ servidor (ou sem a chave lá), devolve null e a busca segue no Openverse. */
218
+ async function pexelsDoServidor(tipo, termo, { quantas, orientacao } = {}) {
219
+ try {
220
+ const cfg = require('../config').loadConfig();
221
+ const r = await require('../api').getMidia(cfg.server, cfg.token, tipo, { q: termo, n: quantas, orientacao });
222
+ const lista = r && r.ok ? r[tipo] : null;
223
+ return lista && lista.length ? lista : null;
224
+ } catch { return null; }
225
+ }
226
+
227
+ /** Procura vídeo de apoio (b-roll). Só Pexels: o Openverse não tem vídeo. */
228
+ async function procurarVideos(termo, { quantas = 5, orientacao = null } = {}) {
229
+ const videos = await pexelsDoServidor('videos', termo, { quantas, orientacao });
230
+ return videos ? { ok: true, videos } : { ok: false, error: 'sem acervo de vídeo agora (o servidor está sem a chave do Pexels).', videos: [] };
231
+ }
232
+
233
+ async function procurarImagens(termo, { quantas = 8, transparente = false, orientacao = null } = {}) {
234
+ if (!transparente && !process.env.PEXELS_API_KEY) {
235
+ const imagens = await pexelsDoServidor('imagens', termo, { quantas, orientacao });
236
+ if (imagens) return { ok: true, imagens };
237
+ }
217
238
  const chave = process.env.PEXELS_API_KEY;
218
239
  if (chave && !transparente) {
219
240
  try {
@@ -337,7 +358,7 @@ async function conferirFonte(familia) {
337
358
  module.exports = {
338
359
  extensaoPorConteudo,
339
360
  pegar, guardar,
340
- icone, procurarIcones, iconesDoPlano, COLECOES,
361
+ icone, procurarIcones, iconesDoPlano, COLECOES, procurarVideos,
341
362
  procurarImagens, procurarSom, baixar,
342
363
  FONTES, conferirFonte,
343
364
  };
@@ -325,9 +325,11 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
325
325
  break;
326
326
  }
327
327
  case 'imagem': {
328
- const src = imagens[i] || p.src;
328
+ const m = imagens[i] || p.src;
329
+ const src = m && typeof m === 'object' ? m.src : m;
329
330
  if (src) {
330
- els.push({ tipo: 'imagem', src, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', anima: { escala: [{ t: 0, v: 1.12 }, { t: dur, v: 1, e: 'suave' }] } });
331
+ const ehVideo = m && typeof m === 'object' && m.tipo === 'video';
332
+ els.push({ tipo: ehVideo ? 'video' : 'imagem', src, ...(ehVideo ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', anima: { escala: [{ t: 0, v: 1.12 }, { t: dur, v: 1, e: 'suave' }] } });
331
333
  els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', fundo: { tipo: 'gradiente', cores: ['rgba(0,0,0,0)', 'rgba(0,0,0,0.25)', 'rgba(0,0,0,0.85)'], angulo: 180 } });
332
334
  }
333
335
  const larg = W0 * 0.8;
@@ -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
@@ -133,8 +136,8 @@ function termoDeApoio(palavra) {
133
136
  return ic ? FOTO[ic.split(':')[1]] || null : null;
134
137
  }
135
138
 
136
- // Em quais cenas entra imagem de apoio: uma a cada três, a partir da segunda.
137
- const temApoio = (i) => i % 3 === 2;
139
+ // Em quais cenas entra a mídia de apoio: uma a cada duas, a partir da segunda.
140
+ const temApoio = (i) => i % 2 === 1;
138
141
 
139
142
  function forteDe(g) {
140
143
  let forte = null;
@@ -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,16 +260,47 @@ 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);
263
297
  if (vida > 0.8) {
298
+ // O apoio é foto ou vídeo (o Pexels dá os dois; vídeo é melhor).
299
+ const ehVideo = typeof apoio === 'object' && apoio.tipo === 'video';
264
300
  elementos.push({
265
- tipo: 'imagem', src: apoio, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
301
+ tipo: ehVideo ? 'video' : 'imagem', src: typeof apoio === 'object' ? apoio.src : apoio,
302
+ ...(ehVideo ? { volume: 0 } : {}),
303
+ x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
266
304
  de, ate: r2(de + vida),
267
305
  anima: {
268
306
  escala: [{ t: 0, v: 1.12 }, { t: vida, v: 1.0, e: 'suave' }],
@@ -274,7 +312,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
274
312
  }
275
313
 
276
314
  // O ícone salta na hora da palavra; sem ícone, o letreiro da ideia.
277
- const icone = forte && e.icones && !apoio ? iconeDe(forte.texto) : null;
315
+ const icone = forte && e.icones && !apoio && !rec ? iconeDe(forte.texto) : null;
278
316
  const temIcone = icone && (!iconesOk || iconesOk.has(icone));
279
317
  const x = lado++ % 2 === 0 ? '78%' : '22%';
280
318
  if (temIcone) {
@@ -294,7 +332,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
294
332
  });
295
333
  if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.8 });
296
334
  }
297
- } 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) {
298
336
  const de = local(forte.de);
299
337
  const vida = Math.min(1.6, dur - de);
300
338
  if (vida > 0.5) {
@@ -358,10 +396,12 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
358
396
  ...(trilha ? { trilha: { src: trilha, volume: 0.07, loop: true } } : {}),
359
397
  } } : {}),
360
398
  },
399
+ infos,
361
400
  resumo: {
362
401
  cenas: cenas.length,
402
+ recortes: Object.keys(recortes).length,
363
403
  icones: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'icone').length, 0),
364
- imagens: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'imagem').length, 0),
404
+ apoios: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'imagem' || (x.tipo === 'video' && x.volume === 0)).length, 0),
365
405
  musica: Boolean(trilha),
366
406
  letreiros: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'texto').length, 0),
367
407
  efeitos: efeitos.length,
@@ -398,4 +438,4 @@ async function prepararSons(pasta, video) {
398
438
  return sons;
399
439
  }
400
440
 
401
- 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
  /**
@@ -462,14 +493,10 @@ async function ferramentaVideo(args, ctx = {}) {
462
493
  // As imagens de apoio: uma por termo, a primeira que baixar.
463
494
  const imagens = {};
464
495
  if ((args.estilo || 'viral') !== 'sem-apoio') {
496
+ const orientacao = m.largura >= m.altura ? 'landscape' : 'portrait';
465
497
  for (const termo of edicao.termosDeApoio(palavras).slice(0, 8)) {
466
- try {
467
- const b = await assets.procurarImagens(termo, { quantas: 4 });
468
- for (const item of (b.ok ? b.imagens : [])) {
469
- const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
470
- if (d.ok) { imagens[termo] = d.relativo; break; }
471
- }
472
- } catch { /* segue sem esta */ }
498
+ imagens[termo] = await baixarApoio(p, termo, { orientacao, video: true });
499
+ if (!imagens[termo]) delete imagens[termo];
473
500
  }
474
501
  }
475
502
 
@@ -485,12 +512,36 @@ async function ferramentaVideo(args, ctx = {}) {
485
512
  } catch { /* segue sem música */ }
486
513
  }
487
514
 
488
- const montado = edicao.montarEdicao({
515
+ const base = {
489
516
  src: path.relative(p.pasta, fonte).replace(/\\/g, '/'),
490
517
  duracao: m.segundos, palavras, largura: m.largura, altura: m.altura,
491
518
  estilo: args.estilo || 'viral', sons, iconesOk, imagens, trilha,
492
- });
519
+ };
520
+ let montado = edicao.montarEdicao(base);
493
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
+ }
494
545
  const salvo = await ferramentaPlano({ id: args.id, plano: montado.plano, motivo: `edição automática (${args.estilo || 'viral'})` });
495
546
  if (!salvo.ok) return salvo;
496
547
  projeto.anotar(p.id, 'edicao', `${montado.resumo.cenas} cenas, ${montado.resumo.icones} ícones, ${cortes} cortes de silêncio`);
@@ -503,7 +554,7 @@ async function ferramentaVideo(args, ctx = {}) {
503
554
 
504
555
  if (acao === 'recortar-pessoa' || acao === 'pessoa') {
505
556
  const saida = path.join(p.pasta, 'midia', `pessoa-${Date.now().toString(36)}.webm`);
506
- const r = await rodarPython('pessoa.py', [
557
+ const r = await rodarPessoa([
507
558
  'recortar', '--entrada', alvo, '--saida', saida,
508
559
  ...(args.suavizar != null ? ['--suavizar', String(args.suavizar)] : []),
509
560
  ...(args.encolher != null ? ['--encolher', String(args.encolher)] : []),
@@ -520,6 +571,28 @@ async function ferramentaVideo(args, ctx = {}) {
520
571
  return { ok: false, error: `não conheço a ação "${acao}".` };
521
572
  }
522
573
 
574
+ /* A mídia de apoio de um termo: vídeo do Pexels quando pedido e houver,
575
+ senão a primeira foto que baixar. Devolve {src, tipo} ou null. */
576
+ async function baixarApoio(p, termo, { orientacao = null, video: querVideo = false } = {}) {
577
+ if (querVideo) {
578
+ try {
579
+ const b = await assets.procurarVideos(termo, { quantas: 4, orientacao });
580
+ for (const item of (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 3)) {
581
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
582
+ if (d.ok) return { src: d.relativo, tipo: 'video' };
583
+ }
584
+ } catch { /* cai para a foto */ }
585
+ }
586
+ try {
587
+ const b = await assets.procurarImagens(termo, { quantas: 4, orientacao });
588
+ for (const item of (b.ok ? b.imagens : [])) {
589
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
590
+ if (d.ok) return { src: d.relativo, tipo: 'imagem' };
591
+ }
592
+ } catch { /* sem apoio */ }
593
+ return null;
594
+ }
595
+
523
596
  const PROPORCOES = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350] };
524
597
 
525
598
  /** O roteiro vira plano: baixa o que falta, narra, e chama o compositor. */
@@ -537,13 +610,8 @@ async function prepararRoteiro(p, args) {
537
610
  const imagens = {};
538
611
  for (const [i, parte] of partes.entries()) {
539
612
  if (parte.tipo !== 'imagem' || parte.src || !parte.busca) continue;
540
- try {
541
- const b = await assets.procurarImagens(parte.busca, { quantas: 4 });
542
- for (const item of (b.ok ? b.imagens : [])) {
543
- const d = await assets.baixar(item, { pasta: p.pasta, nome: 'cena-' + (i + 1) });
544
- if (d.ok) { imagens[i] = d.relativo; break; }
545
- }
546
- } catch { /* a cena sai sem a foto, com o título */ }
613
+ const m = await baixarApoio(p, parte.busca, { orientacao: largura >= altura ? 'landscape' : 'portrait', video: modo === 'video' });
614
+ if (m) imagens[i] = m;
547
615
  }
548
616
 
549
617
  let tempos = [], narracao = null, sons = {}, trilha = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.8.0-beta.13",
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": {