primocode 10.0.9 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -4
  2. package/bin/primocode.js +25 -28
  3. package/lib/act.js +5 -2
  4. package/lib/catalogo.js +7 -4
  5. package/lib/config.js +10 -2
  6. package/lib/estudio/assets.js +86 -6
  7. package/lib/estudio/batida.js +184 -0
  8. package/lib/estudio/biblioteca.js +214 -0
  9. package/lib/estudio/cinema.js +37 -17
  10. package/lib/estudio/composicao.js +2 -1
  11. package/lib/estudio/decisao.js +106 -0
  12. package/lib/estudio/edicao.js +36 -17
  13. package/lib/estudio/estilo.js +35 -8
  14. package/lib/estudio/fluido.js +352 -0
  15. package/lib/estudio/gerativo.js +21 -3
  16. package/lib/estudio/hibrido.js +16 -12
  17. package/lib/estudio/impacto.js +66 -31
  18. package/lib/estudio/index.js +245 -42
  19. package/lib/estudio/kit/fluido.jsx +614 -0
  20. package/lib/estudio/kit/gerativo.jsx +15 -5
  21. package/lib/estudio/kit/index.jsx +2 -1
  22. package/lib/estudio/kit/tres.jsx +88 -21
  23. package/lib/estudio/kit.js +3 -1
  24. package/lib/estudio/kokoro.js +111 -0
  25. package/lib/estudio/lancamento.js +3 -4
  26. package/lib/estudio/legibilidade.js +20 -0
  27. package/lib/estudio/referencias.js +36 -25
  28. package/lib/estudio/remotion.js +53 -6
  29. package/lib/estudio/revisor.js +117 -0
  30. package/lib/estudio/variacao3d.js +46 -0
  31. package/lib/estudio/variedade.js +140 -8
  32. package/lib/portao-skill.js +150 -0
  33. package/lib/skills.js +15 -1
  34. package/lib/tools.js +31 -5
  35. package/lib/video/filters.js +6 -1
  36. package/lib/video/ops.js +4 -3
  37. package/lib/video/timeline.js +1 -1
  38. package/lib/video/zoom.js +275 -0
  39. package/package.json +2 -2
  40. package/skills/escrever-em-portugues/SKILL.md +3 -0
  41. package/skills/interface-revisada/SKILL.md +7 -1
  42. package/skills/motion-de-primeira/SKILL.md +3 -5
  43. package/skills/projeto-no-github/SKILL.md +6 -0
  44. package/skills/publicar-na-internet/SKILL.md +3 -0
  45. package/skills/site-com-motion/SKILL.md +2 -0
  46. package/studio/voz_kokoro.py +98 -0
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v10.0.9
1
+ # PrimoCode v10.2.0
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.0.9
12
+ ▐▛███▜▌ Primo Code v10.2.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -352,7 +352,7 @@ primocode -c # continua a última conversa salva (--co
352
352
 
353
353
  ```json
354
354
  {
355
- "server": "https://primocode-server.onrender.com",
355
+ "server": "https://primocode-server-production.up.railway.app",
356
356
  "model": "main",
357
357
  "token": null
358
358
  }
@@ -361,7 +361,7 @@ primocode -c # continua a última conversa salva (--co
361
361
  Ou por variável de ambiente — elas têm prioridade:
362
362
 
363
363
  ```bash
364
- export PRIMOCODE_SERVER="https://primocode-server.onrender.com"
364
+ export PRIMOCODE_SERVER="https://primocode-server-production.up.railway.app"
365
365
  export PRIMOCODE_MODEL="main"
366
366
  export PRIMOCODE_TOKEN="..." # só se o servidor exigir
367
367
  ```
package/bin/primocode.js CHANGED
@@ -36,6 +36,7 @@ const skills = require('../lib/skills');
36
36
  const checkpoint = require('../lib/checkpoint');
37
37
  const comandosLib = require('../lib/comandos');
38
38
  const marketplace = require('../lib/marketplace');
39
+ const portaoSkill = require('../lib/portao-skill');
39
40
  const vozModo = require('../lib/voz');
40
41
  const conta = require('../lib/conta');
41
42
  const nuvem = require('../lib/nuvem');
@@ -1003,10 +1004,26 @@ async function instalarSkill() {
1003
1004
  console.log('');
1004
1005
  const caminho = String(await ask(' ' + c.brand('❯ ') + c.white('Caminho do SKILL.md: '))).trim();
1005
1006
  if (!caminho) return;
1006
- const r = skills.instalarDe(caminho);
1007
+ const r = await instalarComPortao(caminho);
1007
1008
  console.log(r.ok ? c.ok(` Skill instalada: ${r.nome}`) : c.err(` ${r.error}`));
1008
1009
  }
1009
1010
 
1011
+ /* Instala passando pelo portão (lib/portao-skill.js). Achou algo: mostra cada
1012
+ achado com arquivo:linha e faz a mesma pergunta do que não tem volta
1013
+ (autorizarControle) — Enter sozinho é NÃO. */
1014
+ async function instalarComPortao(caminho) {
1015
+ const r = skills.instalarDe(caminho);
1016
+ if (!r.bloqueada) return r;
1017
+ console.log('');
1018
+ console.log(' ' + c.err(r.error));
1019
+ for (const linha of portaoSkill.descrever(r.achados).slice(0, 20)) console.log(' ' + c.warn(linha));
1020
+ if (r.achados.length > 20) console.log(c.dim(` … e mais ${r.achados.length - 20}`));
1021
+ console.log(' ' + c.dim('Leia cada linha: o portão acha padrão, não intenção.'));
1022
+ const resposta = await autorizarControle(`instalar a skill de ${caminho} mesmo assim`, { tipo: 'perigo' });
1023
+ if (resposta !== 'agora' && resposta !== 'sessao') return { ok: false, error: 'Skill não instalada.' };
1024
+ return skills.instalarDe(caminho, { forcar: true });
1025
+ }
1026
+
1010
1027
  async function sendPrompt(userText) {
1011
1028
  /* O portão do plano. Sem ele o bloqueio da abertura seria só um texto na
1012
1029
  tela: a pessoa leria "você não tem acesso" e mandaria a primeira coisa
@@ -1071,37 +1088,17 @@ async function enviarPedido(userText) {
1071
1088
  // O Chrome só aparece na tela quando o pedido é de navegador (lib/browser.js, definirVisivel).
1072
1089
  require('../lib/browser.js').definirVisivel(querNavegador(userText));
1073
1090
 
1074
- /* AS REFERÊNCIAS DO VÍDEO, decididas pela pessoa antes de começar
1075
- (lib/estudio/referencias.js): web aberta, só o escopo que ela der,
1076
- ou nenhuma — só motion. No pedido único não há quem responda, e
1077
- vale o aberto. */
1091
+ /* AS REFERÊNCIAS DO VÍDEO (lib/estudio/referencias.js) saem do próprio
1092
+ pedido. O padrão é SEM IMAGEM, e o menu 1/2/3 saiu: "por padrão, NÃO
1093
+ usar imagens; somente quando o usuário pedir explicitamente" (07/10). */
1078
1094
  const referencias = require('../lib/estudio/referencias.js');
1079
1095
  referencias.limpar();
1080
1096
  const producao = require('../lib/estudio/diretor.js').precisaDeProducao(userText);
1081
1097
  if (producao.producao && producao.formato === 'video') {
1082
- /* O modo sai do próprio pedido quando ele diz ("só motion", "só do
1083
- site x.com", "web aberta"). Quando NÃO diz, pergunta antes de
1084
- começar — "vídeo" às vezes quer dizer só o motion (02/10). No
1085
- pedido único não há quem responda, e vale o aberto. */
1086
1098
  const r = referencias.inferir(userText);
1087
- if (!r.explicito && process.stdin.isTTY && !state.semTerminal) {
1088
- console.log('');
1089
- console.log(' ' + c.brand('▸') + ' ' + c.bold(c.white('Que vídeo você quer?')));
1090
- console.log(' ' + c.dim('1') + c.muted(' só motion — animação pura, nenhuma foto nem vídeo de fora'));
1091
- console.log(' ' + c.dim('2') + c.muted(' motion + web aberta — ele pesquisa e busca imagem e vídeo à vontade'));
1092
- console.log(' ' + c.dim('3') + c.muted(' motion + escopo fechado — só os sites e termos que você disser'));
1093
- const dito = String(await ask(' ' + c.brand('❯ ') + c.muted('1, 2 ou 3 '))).trim();
1094
- // Aqui 1 é o motion (a ordem da pergunta), não o 1 = aberto do entender().
1095
- const modo = { 1: 'sem', 2: 'aberto', 3: 'fechado' }[(/^\s*([123])\b/.exec(dito) || [])[1]] || referencias.entender(dito) || 'aberto';
1096
- r.modo = modo;
1097
- r.escopo = modo === 'fechado'
1098
- ? String(await ask(' ' + c.brand('❯ ') + c.muted('quais sites ou termos? (separados por vírgula) '))).trim()
1099
- : '';
1100
- if (modo === 'fechado' && !r.escopo) r.modo = 'aberto';
1101
- }
1102
- referencias.definir(r.modo, r.escopo);
1103
- console.log(c.dim(` ⎿ referências: ${{ aberto: 'web aberta', fechado: 'só ' + r.escopo, sem: 'nenhuma, só motion' }[r.modo]}`)
1104
- + (r.modo === 'aberto' ? c.dim(' · diga "só motion" ou "só do site x.com" para mudar') : ''));
1099
+ referencias.definir(r.modo, r.escopo, r.explicito);
1100
+ console.log(c.dim(` ⎿ imagens: ${{ aberto: 'sim, da web', fechado: 'só de ' + r.escopo, sem: r.explicito ? 'nenhuma, só motion' : 'nenhuma (o padrão)' }[r.modo]}`)
1101
+ + (r.modo === 'sem' && !r.explicito ? c.dim(' · diga "adicione imagens de apoio" para usar') : ''));
1105
1102
  }
1106
1103
 
1107
1104
  // O resumo da rodada anterior é feito em segundo plano logo depois
@@ -2218,7 +2215,7 @@ async function handleSlash(text) {
2218
2215
  console.log(c.dim(' Ou abra o ') + c.brand('/marketplace') + c.dim(' e escolha "New Skill" para escrever uma.'));
2219
2216
  break;
2220
2217
  }
2221
- const r = skills.instalarDe(rest);
2218
+ const r = await instalarComPortao(rest);
2222
2219
  if (!r.ok) { console.log(c.err(` ${r.error}`)); break; }
2223
2220
  console.log(c.ok(` Skill instalada: ${r.nome}`) + c.muted(` (${shortPath(r.caminho)})`));
2224
2221
  console.log(c.dim(' Já está ligada. /skills mostra todas.'));
package/lib/act.js CHANGED
@@ -849,10 +849,13 @@ async function runAct(userPrompt, opts) {
849
849
  * um diretor dentro do diretor é uma volta a mais para o mesmo lugar. */
850
850
  if (!subagent && profundidade === 0) {
851
851
  const diretor = require('./estudio/diretor.js');
852
+ // Vídeo que o Estúdio fez se edita no Remotion Studio, não no editor por linha do tempo.
853
+ const EDITAR_DO_ESTUDIO = 'SE ELA QUER EDITAR UM VÍDEO QUE O ESTÚDIO FEZ (não uma gravação dela): '
854
+ + 'estudio_projeto acao:"editar" com o id do projeto abre o Remotion Studio no navegador; diga a pasta a ela e pare.\n';
852
855
  const producao = diretor.precisaDeProducao(pedido);
853
856
  if (diretor.precisaDeEdicao(pedido)) {
854
857
  // A gravação da pessoa: o editor por linha do tempo (lib/video/), pelo run_shell.
855
- messages.push({ role: 'system', content: 'PARA EDITAR A GRAVAÇÃO DA PESSOA use o editor por linha do tempo via run_shell: '
858
+ 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: '
856
859
  + 'SE A PESSOA SÓ DISSE "edite esse vídeo" (sem dizer o quê), O VÍDEO É O PEDIDO: `primocode video import --json \'{"path":"<arquivo>"}\'`, '
857
860
  + 'depois `primocode video auto --json \'{}\'` (lê o que ela FALA na gravação e aplica cada efeito no momento em que diz), inspect, export e abra o mp4. '
858
861
  + 'Senão: '
@@ -887,7 +890,7 @@ async function runAct(userPrompt, opts) {
887
890
  resposta é o modelo. Então isto é o caminho SE for produzir, e não
888
891
  mais uma ordem de produzir. */
889
892
  messages.push({ role: 'system', content:
890
- 'VOCÊ DECIDE o que entregar, pelo que a pessoa pediu: se ela quer só o roteiro, a ideia, um texto ou uma resposta, ESCREVA isso e pare — não produza vídeo. '
893
+ EDITAR_DO_ESTUDIO + 'VOCÊ DECIDE o que entregar, pelo que a pessoa pediu: se ela quer só o roteiro, a ideia, um texto ou uma resposta, ESCREVA isso e pare — não produza vídeo. '
891
894
  + `SE ela quer a peça pronta (formato "${producao.formato === 'apresentacao' ? 'slides' : 'video'}"), faça em três passos: `
892
895
  + `estudio_projeto acao:"criar" com formato "${producao.formato === 'apresentacao' ? 'slides' : 'video'}" → `
893
896
  + 'estudio_plano com "roteiro" (partes com tipo e fala; o PrimoCode faz layout, voz, ícones, música e sons) → '
package/lib/catalogo.js CHANGED
@@ -77,7 +77,9 @@ const GRUPOS = [
77
77
  resumo: 'vídeo, motion, slides e arte — a IA faz, e no fim abre o resultado pronto',
78
78
  itens: [
79
79
  { tool: 'estudio_projeto', escreve: true, faz: 'abre, acha e lista os projetos do estúdio — cada peça é uma pasta que não some',
80
- args: 'acao: criar | abrir | procurar | listar | anotar. '
80
+ args: 'acao: criar | abrir | procurar | listar | anotar | editar. '
81
+ + 'editar: id (sem id, o mais novo) — quando o usuário quer EDITAR um vídeo que o Estúdio fez, '
82
+ + 'abre o projeto no Remotion Studio (o editor, no navegador) e devolve a pasta. '
81
83
  + 'criar: titulo, pedido, formato (video|slides|arte). '
82
84
  + 'abrir: id — devolve o plano, o CADERNO (o que foi decidido e por quê) e os arquivos. '
83
85
  + 'procurar: texto — acha pelo que o usuário lembra ("aquele do PicPay"), inclusive por detalhe do meio do processo. '
@@ -88,9 +90,10 @@ const GRUPOS = [
88
90
  + 'partes:[{tipo, fala, …}]} — o PrimoCode faz o layout, a voz com entonação, os ícones, a música e os sons. '
89
91
  + 'Tipos: abertura{titulo,sub} titulo{titulo,sub} numero{valor,rotulo,sub} lista{titulo,itens} passos{titulo,itens} '
90
92
  + 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link} '
91
- + 'logo{titulo} cores{cores:[{nome,hex}]} produto{titulo,texto,destaque,selo,barras}. Em qualquer parte: destaque (a palavra-tese), fonte (a fonte citada). '
92
- + 'estilo: hibrido (PADRÃO: foto e vídeo reais que não param + o motion por cima; dê busca:"termo em inglês" por parte para o b-roll) | gerativo (= motion: sem template — a tipografia anima nos eixos da fonte numa linha do tempo contínua, composição, fonte, cor e som sorteados do roteiro; é o do modo sem referências e o de quem pede motion/animação; com modelo3d o produto 3D entra numa batida) | classico (o motion 3D antigo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
93
- + 'marca (brand reveal: brilho, letras em vetor, marca cromada, padrão) | lancamento (produto: cartões, painel, calendário) | impacto | cinema. '
93
+ + 'logo{titulo} cores{cores:[{nome,hex}]} produto{titulo,texto,destaque,selo,barras}. Em qualquer parte: destaque (a palavra-tese), fonte (a fonte citada). O texto da TELA sai da FALA: o título usa palavras que a fala diz, cedo na frase (a tela entra quando a voz diz; palavra que a voz não diz deixa a tela esperando). '
94
+ + 'visual:{titulo,texto,paleta} (hibrido, cinema, impacto): fonte e cor do cardápio (estudio_midia acao:fontes); sem visual, o PrimoCode sorteia sem repetir os últimos vídeos. '
95
+ + 'estilo (SEM IMAGEM é o padrão: escolha o motion que serve a ESTE roteiro, ou deixe vazio e ele gira entre gerativo, fluido e impacto): hibrido (SÓ quando a pessoa pediu imagem: foto e vídeo reais + o motion por cima; dê busca:"termo em inglês" só nas partes em que a imagem agrega) | gerativo (= motion: sem template — a tipografia anima nos eixos da fonte numa linha do tempo contínua, composição, fonte, cor e som sorteados do roteiro; com modelo3d o produto 3D entra numa batida) | classico (o motion 3D antigo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
96
+ + 'marca (brand reveal: brilho, letras em vetor, marca cromada, padrão) | lancamento (produto: cartões, painel, calendário) | fluido (minimalista: uma ideia por batida, a forma de uma vira a próxima, sem corte; paleta noite|breu|preto|cobalto (escuras) ou lavanda|creme|laranja|branco (claras) — escolha pelo assunto e NÃO repita a do vídeo anterior; vazio, o PrimoCode escolhe) | impacto | cinema. '
94
97
  + 'marca e lancamento aceitam visual:orbe|vetor|estrela|esfera|grade|site|padrao|cromo|cartoes|painel|cores numa parte. marca.nome dá o nome. '
95
98
  + 'voz (thalita|antonio|francisca). Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
96
99
  + 'cena (opcional): o número de UMA cena para trocar só ela, sem reescrever o resto. '
package/lib/config.js CHANGED
@@ -273,6 +273,14 @@ function podarMemoriaGeral() {
273
273
  } catch {}
274
274
  }
275
275
 
276
+ /* O servidor saiu do Render pro Railway da Paris Group (05/10). Quem gravou o
277
+ endereço antigo no config.json vai pro novo: o Render vai ser desligado. */
278
+ const SERVIDOR = 'https://primocode-server-production.up.railway.app';
279
+ const SERVIDOR_ANTIGO = /^https?:\/\/primocode-server\.onrender\.com\/?$/;
280
+ function servidor(escolhido) {
281
+ return !escolhido || SERVIDOR_ANTIGO.test(String(escolhido).trim()) ? SERVIDOR : escolhido;
282
+ }
283
+
276
284
  function loadConfig() {
277
285
  ensureStructure();
278
286
  let file = {};
@@ -280,7 +288,7 @@ function loadConfig() {
280
288
  if (fs.existsSync(CONFIG_FILE)) file = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
281
289
  } catch { file = {}; }
282
290
  return {
283
- server: process.env.PRIMOCODE_SERVER || file.server || 'https://primocode-server.onrender.com',
291
+ server: servidor(process.env.PRIMOCODE_SERVER || file.server),
284
292
  model: process.env.PRIMOCODE_MODEL || file.model || 'main',
285
293
  // Esforço, estilo Claude Code (/effort). É o que o usuário escolhe; o
286
294
  // modelo é derivado dele. Guardado para durar entre sessões.
@@ -363,7 +371,7 @@ function esquecerProjeto() {
363
371
  module.exports = {
364
372
  tokensDoAssunto, GENERICAS,
365
373
  lembrarProjeto, projetoLembrado, esquecerProjeto, PROJETO_ATIVO_FILE,
366
- loadConfig, saveConfig, appendHistory,
374
+ loadConfig, saveConfig, appendHistory, servidor, SERVIDOR,
367
375
  CONFIG_DIR, CONFIG_FILE, HISTORY_FILE, ROOT_DIR, GENERAL_MEMORY,
368
376
  ensureStructure, slugify, stripAccents,
369
377
  createProjectDir, getProjectDir, listProjects, findSimilarProject,
@@ -258,6 +258,80 @@ async function pexelsDoServidor(tipo, termo, { quantas, orientacao } = {}) {
258
258
  } catch { return null; }
259
259
  }
260
260
 
261
+ /* ── O ACERVO EDITORIAL (08/10): "as imagens de banco são horríveis… desde
262
+ que não seja genérica, tipo pessoa sorrindo olhando pro nada" (Joel). Foto
263
+ de verdade, de arquivo e de museu, em vez de banco de stock:
264
+ 1. Wikimedia Commons — documental (o despertador antigo, a ressonância de
265
+ 1982, a cidade à noite). Só CC0, domínio público e CC BY (sem SA: o
266
+ SA obrigaria o vídeo a ter a mesma licença), e só foto grande;
267
+ 2. Art Institute of Chicago — o acervo em domínio público (pintura,
268
+ gravura, fotografia antiga), pela imagem IIIF.
269
+ O que tem cara de stock ou de assunto pesado sai pelo título. Sem nada
270
+ que sirva, volta vazio: imagem genérica é pior que nenhuma. */
271
+ const GENERICA = /smil|happy|laugh|business ?(wo)?man|teamwork|handshake|thumbs|stock photo|isolated on white|portrait of (a|an) (young|happy)|selfie|model pos/i;
272
+ const PESADA = /homeless|corpse|dead body|cad[aá]ver|war victim|wounded|blood|nude|naked|execution|lynch|torture|mendig/i;
273
+ const LIVRE = (lic) => /^(cc0|public domain|pd|cc by \d|cc by$|cc-by-\d|attribution)/i.test(String(lic || '').trim()) && !/sa|nc|nd/i.test(String(lic || '').replace(/^public domain/i, ''));
274
+
275
+ function limparHtml(t) { return String(t || '').replace(/<[^>]+>/g, '').replace(/\s*(title|label)\s+QS:.*$/i, '').replace(/\s+/g, ' ').trim(); }
276
+
277
+ async function procurarWikimedia(termo, { quantas = 8, minimo = 1200 } = {}) {
278
+ const url = 'https://commons.wikimedia.org/w/api.php?action=query&format=json&generator=search&gsrnamespace=6'
279
+ + `&gsrsearch=${encodeURIComponent(termo + ' filetype:bitmap')}&gsrlimit=30&prop=imageinfo`
280
+ + '&iiprop=url|size|extmetadata&iiurlwidth=1600&iiextmetadatafilter=LicenseShortName|Artist|ImageDescription|ObjectName';
281
+ const r = await pegar(url, { json: true });
282
+ const paginas = Object.values((r.query && r.query.pages) || {}).sort((a, b) => (a.index || 0) - (b.index || 0));
283
+ const out = [];
284
+ for (const pg of paginas) {
285
+ const ii = (pg.imageinfo || [])[0];
286
+ if (!ii || ii.width < minimo || ii.height < minimo * 0.5) continue;
287
+ const md = ii.extmetadata || {};
288
+ const lic = (md.LicenseShortName || {}).value || '';
289
+ const titulo = limparHtml((md.ObjectName || {}).value || pg.title.replace(/^File:/, '').replace(/\.[a-z]+$/i, ''));
290
+ const desc = limparHtml((md.ImageDescription || {}).value || '');
291
+ if (!LIVRE(lic) || GENERICA.test(titulo + ' ' + desc) || PESADA.test(titulo + ' ' + desc)) continue;
292
+ const autor = (limparHtml((md.Artist || {}).value || '').split(/\s+(Author info|Reusing images|Own work)\b/i)[0].slice(0, 80)) || 'Wikimedia Commons';
293
+ out.push({ url: ii.thumburl || ii.url, titulo, autor, licenca: lic, fonte: 'wikimedia', largura: ii.thumbwidth || ii.width, altura: ii.thumbheight || ii.height,
294
+ creditos: `${titulo} — ${autor} (${lic}, Wikimedia Commons)`, pagina: ii.descriptionurl });
295
+ if (out.length >= quantas) break;
296
+ }
297
+ return out;
298
+ }
299
+
300
+ async function procurarArtic(termo, { quantas = 6 } = {}) {
301
+ const url = `https://api.artic.edu/api/v1/artworks/search?q=${encodeURIComponent(termo)}`
302
+ + '&query%5Bterm%5D%5Bis_public_domain%5D=true&limit=20&fields=id,title,image_id,artist_title,date_display,thumbnail';
303
+ const r = await pegar(url, { json: true });
304
+ return (r.data || []).filter((a) => a.image_id && !PESADA.test(a.title || '')).slice(0, quantas).map((a) => ({
305
+ url: `https://www.artic.edu/iiif/2/${a.image_id}/full/1686,/0/default.jpg`, titulo: a.title, autor: a.artist_title || 'Art Institute of Chicago',
306
+ licenca: 'CC0 (domínio público)', fonte: 'artic', largura: (a.thumbnail || {}).width, altura: (a.thumbnail || {}).height,
307
+ creditos: `${a.title}${a.date_display ? ', ' + a.date_display : ''} — ${a.artist_title || 'autor desconhecido'} (Art Institute of Chicago, CC0)`,
308
+ }));
309
+ }
310
+
311
+ /* A BUSCA DE ARQUIVO é curta: o roteiro escreve busca de banco de stock
312
+ ("tired person at office desk"), e um arquivo de verdade não acha nada
313
+ com isso. Tenta a busca inteira, depois só as palavras de conteúdo, depois
314
+ os pares delas do fim para o começo ("alarm clock on nightstand" → "alarm
315
+ clock"). Pessoa genérica sai da busca: o arquivo responde com a coisa. */
316
+ const VAZIAS_BUSCA = new Set('a an the of on in at with and or to for from by into over under near during while person people man woman someone guy girl young old tired happy sad soft light glowing macro close up closeup shot view cinematic photo image background beautiful modern'.split(' '));
317
+ function buscasMenores(termo) {
318
+ const ws = String(termo || '').toLowerCase().replace(/[^a-z0-9\s-]/g, ' ').split(/\s+/).filter(Boolean);
319
+ const conteudo = ws.filter((w) => !VAZIAS_BUSCA.has(w));
320
+ const pares = [];
321
+ for (let k = conteudo.length - 2; k >= 0; k--) pares.push(conteudo.slice(k, k + 2).join(' '));
322
+ return [...new Set([ws.join(' '), conteudo.join(' '), ...pares, ...conteudo.slice().reverse()].filter((x) => x && x.length > 2))];
323
+ }
324
+
325
+ /** A foto editorial (Wikimedia), da busca inteira à mais curta. Nunca o banco de stock. */
326
+ async function procurarEditorial(termo, { quantas = 8 } = {}) {
327
+ for (const q of buscasMenores(termo)) {
328
+ let imagens = [];
329
+ try { imagens = await procurarWikimedia(q, { quantas }); } catch { continue; }
330
+ if (imagens.length >= 2) return { ok: true, imagens, busca: q };
331
+ }
332
+ return { ok: false, imagens: [] };
333
+ }
334
+
261
335
  /** Procura vídeo de apoio (b-roll). Só Pexels: o Openverse não tem vídeo. */
262
336
  async function procurarVideos(termo, { quantas = 5, orientacao = null } = {}) {
263
337
  const videos = await pexelsDoServidor('videos', termo, { quantas, orientacao });
@@ -329,12 +403,18 @@ async function procurarMusica(termo, { quantas = 8, duracaoMin = 45 } = {}) {
329
403
  return procurarSom(termo, { quantas, duracaoMin });
330
404
  }
331
405
 
332
- async function procurarSom(termo, { quantas = 8, duracaoMin = 0, duracaoMax = 0 } = {}) {
406
+ /* `livre`: só domínio público (CC0 e PDM). O efeito sonoro entra no vídeo sem
407
+ crédito, então não pode ser CC-BY, que o "commercial" deixa passar. */
408
+ function urlSom(termo, { quantas = 8, duracaoMin = 0, livre = false } = {}) {
409
+ let url = `https://api.openverse.org/v1/audio/?q=${encodeURIComponent(termo)}`
410
+ + `&page_size=${quantas}` + (livre ? '&license=cc0,pdm' : '&license_type=commercial');
411
+ if (duracaoMin && duracaoMin < 30) url += '&length=short';
412
+ return url;
413
+ }
414
+
415
+ async function procurarSom(termo, { quantas = 8, duracaoMin = 0, duracaoMax = 0, livre = false } = {}) {
333
416
  try {
334
- let url = `https://api.openverse.org/v1/audio/?q=${encodeURIComponent(termo)}`
335
- + `&page_size=${quantas}&license_type=commercial`;
336
- if (duracaoMin && duracaoMin < 30) url += '&length=short';
337
- const r = await pegar(url, { json: true });
417
+ const r = await pegar(urlSom(termo, { quantas, duracaoMin, livre }), { json: true });
338
418
  const sons = (r.results || [])
339
419
  .filter((x) => !duracaoMax || !x.duration || x.duration / 1000 <= duracaoMax)
340
420
  .filter((x) => !duracaoMin || !x.duration || x.duration / 1000 >= duracaoMin)
@@ -412,6 +492,6 @@ module.exports = {
412
492
  extensaoPorConteudo,
413
493
  pegar, guardar,
414
494
  icone, procurarIcones, iconesDoPlano, COLECOES, procurarVideos, procurarMusica,
415
- procurarImagens, procurarSom, baixar,
495
+ procurarImagens, procurarEditorial, procurarWikimedia, procurarArtic, buscasMenores, procurarSom, urlSom, baixar,
416
496
  FONTES, conferirFonte,
417
497
  };
@@ -0,0 +1,184 @@
1
+ /**
2
+ * batida.js — a música manda no corte: acha a batida da trilha e escolhe de
3
+ * onde a faixa começa para as batidas caírem em cima dos cortes do vídeo.
4
+ *
5
+ * "A música também deve influenciar a edição: cortes, animações,
6
+ * transições e momentos de impacto devem poder ser sincronizados com
7
+ * ela." (Joel, 07/10)
8
+ *
9
+ * Antes, "cortar no beat" queria dizer cortar 0,08 s antes da primeira
10
+ * palavra da parte (ritmo.js): a voz mandava, e a música tocava por baixo
11
+ * sem saber dos cortes. Mexer no corte quebraria a sincronia com a voz,
12
+ * então aqui quem se move é a MÚSICA — o que um editor faz ao deslizar a
13
+ * trilha na linha do tempo:
14
+ * 1. analisar: o ffmpeg decodifica a faixa; a energia vira um envelope de
15
+ * ataques; a autocorrelação acha o andamento (60–180 BPM) e a fase;
16
+ * os picos mais fortes viram os ACENTOS (a virada, o drop);
17
+ * 2. alinhar: dos cortes do plano (o corte de impacto pesa mais), procura
18
+ * o início da faixa em que mais cortes caem numa batida e o maior
19
+ * impacto cai num acento. O plano ganha audio.trilha.inicio.
20
+ * Puro onde dá: analisarPCM e melhorInicio não tocam o disco.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('fs');
26
+ const { spawn } = require('child_process');
27
+
28
+ const TAXA = 22050;
29
+ const SALTO = 512; // ~23 ms por quadro do envelope
30
+ const r3 = (n) => Math.round(n * 1000) / 1000;
31
+
32
+ /** O envelope de ataques: quanto a energia SOBE de um quadro para o outro. */
33
+ function envelope(amostras) {
34
+ const n = Math.floor(amostras.length / SALTO);
35
+ const energia = new Float64Array(n);
36
+ for (let i = 0; i < n; i++) {
37
+ let s = 0;
38
+ for (let k = i * SALTO; k < (i + 1) * SALTO; k++) s += amostras[k] * amostras[k];
39
+ energia[i] = Math.log1p(1000 * s / SALTO);
40
+ }
41
+ const ataque = new Float64Array(n);
42
+ for (let i = 1; i < n; i++) ataque[i] = Math.max(0, energia[i] - energia[i - 1]);
43
+ // Tira a média móvel (1 s): sobra o que se destaca do que está ao redor.
44
+ const janela = Math.round(TAXA / SALTO);
45
+ const out = new Float64Array(n);
46
+ let soma = 0;
47
+ for (let i = 0; i < n; i++) {
48
+ soma += ataque[i];
49
+ if (i >= janela) soma -= ataque[i - janela];
50
+ out[i] = Math.max(0, ataque[i] - soma / Math.min(i + 1, janela));
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * A análise de uma faixa já decodificada (mono, `taxa` Hz).
57
+ * @returns {{bpm:number, periodo:number, batidas:number[], acentos:number[], confianca:number, segundos:number}}
58
+ */
59
+ function analisarPCM(amostras, taxa = TAXA) {
60
+ const seg = amostras.length / taxa;
61
+ const env = envelope(amostras);
62
+ const qps = taxa / SALTO; // quadros do envelope por segundo
63
+ const vazio = { bpm: 0, periodo: 0, batidas: [], acentos: [], confianca: 0, segundos: r3(seg) };
64
+ if (env.length < qps * 4) return vazio;
65
+
66
+ // O andamento: o atraso (em quadros) que mais se parece consigo mesmo, de 60 a 180 BPM.
67
+ const media = env.reduce((a, b) => a + b, 0) / env.length;
68
+ let melhorLag = 0, melhor = 0, total = 0;
69
+ const lagMin = Math.floor(qps * 60 / 180), lagMax = Math.ceil(qps * 60 / 60);
70
+ const auto = (lag) => { let s = 0; for (let i = lag; i < env.length; i++) s += (env[i] - media) * (env[i - lag] - media); return s / (env.length - lag); };
71
+ for (let lag = lagMin; lag <= lagMax; lag++) {
72
+ const s = auto(lag);
73
+ // Leve preferência pelo andamento de dança (~120): o dobro e a metade empatam muito.
74
+ const bpm = 60 * qps / lag;
75
+ const peso = Math.exp(-0.5 * Math.pow(Math.log2(bpm / 120) / 1.0, 2));
76
+ if (s * peso > melhor) { melhor = s * peso; melhorLag = lag; }
77
+ total += Math.max(0, s);
78
+ }
79
+ if (!melhorLag || melhor <= 0) return vazio;
80
+
81
+ /* O período em fração de quadro: o atraso inteiro (23 ms de grão) acumula
82
+ erro batida após batida. A parábola nos vizinhos acha o meio; e o
83
+ período que mais soma ataque numa grade longa decide o resto. */
84
+ const [y0, y1, y2] = [auto(melhorLag - 1), auto(melhorLag), auto(melhorLag + 1)];
85
+ let lag = melhorLag + (y0 - 2 * y1 + y2 !== 0 ? 0.5 * (y0 - y2) / (y0 - 2 * y1 + y2) : 0);
86
+ const somaGrade = (L, f) => { let s = 0; for (let x = f; x < env.length; x += L) s += env[Math.round(x)] || 0; return s; };
87
+ let fase = 0, maisForte = -1;
88
+ for (let L = lag - 0.3; L <= lag + 0.3; L += 0.02) {
89
+ for (let f = 0; f < L; f += 0.5) {
90
+ const s = somaGrade(L, f);
91
+ if (s > maisForte) { maisForte = s; fase = f; lag = L; }
92
+ }
93
+ }
94
+ const periodo = lag / qps;
95
+ // Cada batida se prende ao ataque mais forte a até 2 quadros dela (o músico não é metrônomo).
96
+ const batidas = [];
97
+ for (let x = fase; x < env.length; x += lag) {
98
+ const c = Math.round(x);
99
+ let i = c;
100
+ for (let k = Math.max(0, c - 2); k <= Math.min(env.length - 1, c + 2); k++) if (env[k] > env[i]) i = k;
101
+ batidas.push(r3(i / qps));
102
+ }
103
+
104
+ // Os acentos: os ataques mais fortes (o 5% de cima), a pelo menos 2 s um do outro.
105
+ const ordem = [...env.keys()].sort((a, b) => env[b] - env[a]);
106
+ const acentos = [];
107
+ for (const i of ordem.slice(0, Math.max(4, Math.floor(env.length * 0.05)))) {
108
+ const t = i / qps;
109
+ if (acentos.every((a) => Math.abs(a - t) >= 2)) acentos.push(t);
110
+ if (acentos.length >= 12) break;
111
+ }
112
+ /* A confiança: a fração das batidas que caem num ataque de verdade (acima
113
+ da mediana do envelope). Grade que não casa com a música fica perto de 0,5. */
114
+ const mediana = [...env].sort((x, y) => x - y)[Math.floor(env.length / 2)];
115
+ const naBatida = batidas.filter((t) => { const c = Math.round(t * qps); return Math.max(env[c - 1] || 0, env[c] || 0, env[c + 1] || 0) > mediana; }).length;
116
+ const confianca = batidas.length ? naBatida / batidas.length : 0;
117
+ return { bpm: Math.round(60 / periodo), periodo: r3(periodo), batidas, acentos: acentos.sort((a, b) => a - b).map(r3), confianca: r3(confianca), segundos: r3(seg) };
118
+ }
119
+
120
+ /** Decodifica a faixa (até `maximo` s) e analisa. Guarda ao lado: <faixa>.batidas.json. */
121
+ function analisar(arquivo, { maximo = 150, ffmpeg = 'ffmpeg' } = {}) {
122
+ const cache = arquivo + '.batidas.json';
123
+ try { return Promise.resolve(JSON.parse(fs.readFileSync(cache, 'utf8'))); } catch { /* analisa */ }
124
+ return new Promise((resolve) => {
125
+ const p = spawn(ffmpeg, ['-v', 'error', '-t', String(maximo), '-i', arquivo, '-ac', '1', '-ar', String(TAXA), '-f', 'f32le', '-']);
126
+ const pedacos = [];
127
+ p.stdout.on('data', (d) => pedacos.push(d));
128
+ p.on('error', () => resolve(null));
129
+ p.on('close', (code) => {
130
+ if (code !== 0) return resolve(null);
131
+ const buf = Buffer.concat(pedacos);
132
+ const amostras = new Float32Array(buf.buffer, buf.byteOffset, Math.floor(buf.length / 4));
133
+ const a = analisarPCM(amostras, TAXA);
134
+ try { fs.writeFileSync(cache, JSON.stringify(a)); } catch { /* sem cache */ }
135
+ resolve(a);
136
+ });
137
+ });
138
+ }
139
+
140
+ const CONFIANCA_MINIMA = 0.7; // abaixo disso a batida é chute: a faixa entra do começo
141
+
142
+ const TRANSICOES_FORTES = new Set(['soco', 'flash', 'chicote', 'zoom', 'giro']);
143
+
144
+ /** Os cortes do plano no tempo do vídeo: [{t, peso}]. Corte de impacto pesa 3; o maior impacto vira `impacto`. */
145
+ function cortesDoPlano(plano) {
146
+ const cortes = [];
147
+ let t = 0;
148
+ for (const [i, cena] of ((plano && plano.cenas) || []).entries()) {
149
+ if (i > 0) {
150
+ const tr = cena.transicao && (cena.transicao.tipo || cena.transicao);
151
+ cortes.push({ t: r3(t), peso: cena.impacto || TRANSICOES_FORTES.has(tr) ? 3 : 1 });
152
+ }
153
+ t += Number(cena.duracao) || 0;
154
+ }
155
+ return cortes;
156
+ }
157
+
158
+ /**
159
+ * De onde a faixa começa (em s) para o máximo de cortes cair numa batida.
160
+ * Procura em passos de 10 ms, de 0 a `maximo`; empate fica com o início mais
161
+ * cedo. O corte mais pesado ganha bônus se cair num acento.
162
+ */
163
+ function melhorInicio(cortes, analise, { maximo = 0, tolerancia = 0.05 } = {}) {
164
+ const bats = (analise && analise.batidas) || [];
165
+ if (!cortes.length || bats.length < 4 || (analise.confianca != null && analise.confianca < CONFIANCA_MINIMA)) return { inicio: 0, alinhados: 0, total: cortes.length };
166
+ const perto = (lista, t) => {
167
+ let lo = 0, hi = lista.length - 1;
168
+ while (lo < hi) { const m = (lo + hi) >> 1; if (lista[m] < t) lo = m + 1; else hi = m; }
169
+ return Math.min(Math.abs(lista[lo] - t), lo > 0 ? Math.abs(lista[lo - 1] - t) : Infinity);
170
+ };
171
+ const maior = cortes.reduce((a, b) => (b.peso > a.peso ? b : a), cortes[0]);
172
+ const acentos = (analise.acentos || []);
173
+ let melhor = { inicio: 0, pontos: -1 };
174
+ for (let o = 0; o <= Math.max(0, maximo) + 1e-9; o += 0.01) {
175
+ let pontos = 0;
176
+ for (const c of cortes) pontos += c.peso * Math.exp(-0.5 * Math.pow(perto(bats, c.t + o) / tolerancia, 2));
177
+ if (maior.peso > 1 && acentos.length && perto(acentos, maior.t + o) < 0.1) pontos += 2;
178
+ if (pontos > melhor.pontos + 1e-6) melhor = { inicio: r3(o), pontos };
179
+ }
180
+ const alinhados = cortes.filter((c) => perto(bats, c.t + melhor.inicio) <= tolerancia).length;
181
+ return { inicio: melhor.inicio, alinhados, total: cortes.length };
182
+ }
183
+
184
+ module.exports = { analisarPCM, analisar, cortesDoPlano, melhorInicio, TAXA, CONFIANCA_MINIMA };