primocode 9.8.0-beta.2 → 9.8.0-beta.21

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 (74) hide show
  1. package/README.md +33 -6
  2. package/app/ansi.js +55 -0
  3. package/app/app.css +482 -281
  4. package/app/app.js +888 -315
  5. package/app/index.html +176 -101
  6. package/app/primo-marca.js +58 -1
  7. package/bin/primocode.js +697 -118
  8. package/comandos/explicar.md +14 -0
  9. package/comandos/publicar.md +10 -0
  10. package/comandos/revisar.md +13 -0
  11. package/lib/act.js +130 -26
  12. package/lib/api.js +8 -1
  13. package/lib/app.js +317 -74
  14. package/lib/browser.js +79 -3
  15. package/lib/catalogo.js +11 -3
  16. package/lib/checkpoint-git.js +218 -0
  17. package/lib/checkpoint.js +249 -0
  18. package/lib/claude-engine.js +25 -3
  19. package/lib/comandos-extra.js +481 -0
  20. package/lib/comandos.js +104 -0
  21. package/lib/config.js +21 -4
  22. package/lib/estudio/acervo-site.js +157 -0
  23. package/lib/estudio/assets.js +77 -3
  24. package/lib/estudio/cinema.js +354 -0
  25. package/lib/estudio/composicao.js +470 -0
  26. package/lib/estudio/edicao.js +450 -0
  27. package/lib/estudio/impacto.js +263 -0
  28. package/lib/estudio/index.js +769 -5
  29. package/lib/estudio/plano.js +5 -2
  30. package/lib/estudio/remotion.js +228 -23
  31. package/lib/estudio/slides.js +34 -30
  32. package/lib/fala.js +95 -1
  33. package/lib/local/cerebro.json +361 -180
  34. package/lib/local/entrada.js +18 -8
  35. package/lib/local/motor-cli.js +1 -1
  36. package/lib/mencoes.js +98 -0
  37. package/lib/offline.js +168 -0
  38. package/lib/painel.js +291 -0
  39. package/lib/perigo.js +256 -0
  40. package/lib/pre-leitura.js +118 -0
  41. package/lib/repeticao.js +4 -2
  42. package/lib/skills.js +6 -1
  43. package/lib/tempos.js +62 -0
  44. package/lib/ui.js +171 -17
  45. package/package.json +3 -2
  46. package/skills/apresentacao-pptx/SKILL.md +52 -0
  47. package/skills/cartaz-de-arte/LICENSE.txt +202 -0
  48. package/skills/cartaz-de-arte/SKILL.md +47 -0
  49. package/skills/criar-skill/LICENSE.txt +202 -0
  50. package/skills/criar-skill/SKILL.md +49 -0
  51. package/skills/depurar-de-verdade/LICENSE.txt +21 -0
  52. package/skills/depurar-de-verdade/SKILL.md +14 -5
  53. package/skills/design-de-verdade/LICENSE.txt +177 -0
  54. package/skills/design-de-verdade/SKILL.md +42 -0
  55. package/skills/documento-word/SKILL.md +57 -0
  56. package/skills/interface-revisada/SKILL.md +47 -0
  57. package/skills/me-ensina/LICENSE.txt +21 -0
  58. package/skills/me-ensina/SKILL.md +27 -0
  59. package/skills/me-entrevista/LICENSE.txt +21 -0
  60. package/skills/me-entrevista/SKILL.md +27 -0
  61. package/skills/motion-de-primeira/SKILL.md +1 -1
  62. package/skills/pdf-bonito/SKILL.md +48 -0
  63. package/skills/planilha-excel/SKILL.md +47 -0
  64. package/skills/react-sem-dor/SKILL.md +48 -0
  65. package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
  66. package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
  67. package/skills/site-com-motion/SKILL.md +39 -0
  68. package/skills/temas-prontos/LICENSE.txt +202 -0
  69. package/skills/temas-prontos/SKILL.md +50 -0
  70. package/skills/testar-o-site/LICENSE.txt +202 -0
  71. package/skills/testar-o-site/SKILL.md +46 -0
  72. package/skills/video-no-remotion/SKILL.md +48 -0
  73. package/studio/narrar.py +111 -10
  74. package/studio/rostos.py +52 -0
package/lib/config.js CHANGED
@@ -129,15 +129,25 @@ function listProjects() {
129
129
  } catch { return []; }
130
130
  }
131
131
 
132
+ /* Palavras que dizem o TIPO da coisa, não QUAL coisa. "landing da barbearia"
133
+ e "landing-nova" (de outra empresa) dividiam "landing", e o pedido novo caía
134
+ dentro do projeto antigo — medido em 24/09: a landing da Navalha de Ouro foi
135
+ escrita em landing-nova/. Só o assunto conta como parecido. */
136
+ const GENERICAS = new Set(['landing', 'lading', 'page', 'pagina', 'site', 'app', 'aplicativo', 'projeto',
137
+ 'novo', 'nova', 'sistema', 'web', 'html', 'teste', 'versao', 'final', 'the', 'para', 'pra']);
138
+ function tokensDoAssunto(nome) {
139
+ return slugify(nome).split('-').filter((t) => t.length > 2 && !GENERICAS.has(t) && !/^v?\d+$/.test(t));
140
+ }
141
+
132
142
  // Projeto parecido com o que o usuário pediu? Compara os pedaços do slug.
133
143
  // "jogo-de-carro" vs "jogo-carro-2d" -> compartilham jogo+carro -> 0.5
134
144
  function findSimilarProject(projectName, minScore = 0.4) {
135
- const wanted = new Set(slugify(projectName).split('-').filter((t) => t.length > 2));
145
+ const wanted = new Set(tokensDoAssunto(projectName));
136
146
  if (!wanted.size) return null;
137
147
 
138
148
  let best = null;
139
149
  for (const existing of listProjects()) {
140
- const tokens = new Set(existing.split('-').filter((t) => t.length > 2));
150
+ const tokens = new Set(tokensDoAssunto(existing));
141
151
  if (!tokens.size) continue;
142
152
  let shared = 0;
143
153
  for (const t of wanted) if (tokens.has(t)) shared++;
@@ -285,9 +295,15 @@ function loadConfig() {
285
295
  currentProject: file.currentProject || null,
286
296
  userName: file.userName || null,
287
297
  mascot: file.mascot || 'primo',
298
+ // O rostinho do mascote depois da resposta. Ligado por padrão; quem acha
299
+ // que é enfeite demais desliga com /mascot reacoes off.
300
+ reacoes: file.reacoes !== false,
288
301
  theme: file.theme || 'primo',
289
302
  // Modo escolhido dura entre sessões: quem liga o auto quer o auto amanhã.
290
303
  mode: process.env.PRIMOCODE_MODE || file.mode || 'auto',
304
+ // O /permissoes: quando ele pergunta antes de agir. Dura entre sessões
305
+ // pelo mesmo motivo do modo — quem escolheu "perguntar" quer amanhã também.
306
+ permissoes: file.permissoes || 'auto',
291
307
  rootDir: ROOT_DIR,
292
308
  _file: file,
293
309
  };
@@ -297,8 +313,8 @@ function saveConfig(partial) {
297
313
  ensureDir(CONFIG_DIR);
298
314
  const cur = (() => { try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; } })();
299
315
  // Só persiste o que faz sentido guardar — nada de campos derivados.
300
- const { server, model, effort, token, autoApply, currentProject, userName, mascot, theme, mode } = { ...cur, ...partial };
301
- const next = { server, model, effort, token, autoApply, currentProject, userName, mascot, theme, mode };
316
+ const { server, model, effort, token, autoApply, currentProject, userName, mascot, reacoes, theme, mode, permissoes } = { ...cur, ...partial };
317
+ const next = { server, model, effort, token, autoApply, currentProject, userName, mascot, reacoes, theme, mode, permissoes };
302
318
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), 'utf8');
303
319
  return next;
304
320
  }
@@ -338,6 +354,7 @@ function esquecerProjeto() {
338
354
  }
339
355
 
340
356
  module.exports = {
357
+ tokensDoAssunto, GENERICAS,
341
358
  lembrarProjeto, projetoLembrado, esquecerProjeto, PROJETO_ATIVO_FILE,
342
359
  loadConfig, saveConfig, appendHistory,
343
360
  CONFIG_DIR, CONFIG_FILE, HISTORY_FILE, ROOT_DIR, GENERAL_MEMORY,
@@ -0,0 +1,157 @@
1
+ /**
2
+ * acervo-site.js — as fotos, os vídeos e os fatos da PRÓPRIA marca, tirados
3
+ * do site dela.
4
+ *
5
+ * "Eu pedi um vídeo pra Paris Group pra anunciar a imersão: ele poderia
6
+ * puxar imagens das imersões, vídeos, fotos, etc. e colocar no decorrer do
7
+ * vídeo." · "Os roteiros são nada a ver." (25/09/2026)
8
+ *
9
+ * O roteiro saía inventado ("sem data, preço nem local", anotou o próprio
10
+ * modelo) e as imagens eram de banco. O site da marca tem as duas coisas: a
11
+ * página da imersão diz "6 a 8 out · Chapecó/SC · 20 cadeiras", e a galeria
12
+ * tem as fotos da sala. Aqui se lê a página inicial e as páginas internas
13
+ * que combinam com o pedido, e sai:
14
+ *
15
+ * - fotos: as maiores que o site oferece (o maior "w" do srcset), sem logo,
16
+ * ícone, SVG nem miniatura;
17
+ * - vídeos: os .mp4 que a página toca (depoimento, bastidor);
18
+ * - fatos: o texto de cada página, limpo — a matéria-prima do roteiro.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const { URL } = require('url');
24
+
25
+ const PALAVRAS_BOAS = ['galeria', 'imers', 'evento', 'sobre', 'about', 'portfolio', 'cases', 'case', 'turma', 'curso', 'produto', 'servic', 'equipe', 'time'];
26
+ const RUIM = /(logo|icon|favicon|sprite|avatar|emoji|badge|\/brand\/|placeholder|blank|pixel|\.svg(\?|$)|-sm\.|\.sm\.|thumb)/i;
27
+
28
+ function limpar(t) { return String(t || '').replace(/\s+/g, ' ').trim(); }
29
+
30
+ /** O texto da página, sem script, estilo, menu nem rodapé repetido. */
31
+ function textoDaPagina(html) {
32
+ let t = String(html || '')
33
+ .replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<noscript[\s\S]*?<\/noscript>/gi, ' ')
34
+ .replace(/<(nav|footer|header)[\s\S]*?<\/\1>/gi, ' ')
35
+ .replace(/<br\s*\/?>|<\/(p|h\d|li|div|section)>/gi, '. ')
36
+ .replace(/<[^>]+>/g, ' ');
37
+ t = t.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&[a-z]+;/g, ' ');
38
+ return limpar(t).replace(/(\.\s*){2,}/g, '. ').slice(0, 2200);
39
+ }
40
+
41
+ /** A versão maior de cada imagem: no srcset, o maior "w" até 2000. */
42
+ function maiorDoSrcset(srcset) {
43
+ let melhor = null, largura = 0;
44
+ for (const parte of String(srcset).split(',')) {
45
+ const [u, w] = parte.trim().split(/\s+/);
46
+ const n = parseInt(w, 10) || 1;
47
+ if (u && n <= 2000 && n >= largura) { melhor = u; largura = n; }
48
+ }
49
+ return melhor;
50
+ }
51
+
52
+ // A mesma foto em tamanhos diferentes (x.sm.webp, x.md.webp, x.lg.webp) é UMA foto.
53
+ const chaveDe = (u) => u.replace(/[?#].*$/, '').replace(/\.(sm|md|lg|xl|\d{2,4}w?)(?=\.[a-z]+$)/i, '').replace(/-\d{3,4}(?=\.[a-z]+$)/, '');
54
+
55
+ function colherMidia(html, base) {
56
+ const abs = (u) => { try { return new URL(String(u).replace(/\\\//g, '/'), base).toString(); } catch { return null; } };
57
+ const fotos = new Map(), videos = new Map();
58
+ const pesoDe = (u) => (/\.lg\.|1800w|1920|2000/.test(u) ? 3 : /\.md\.|1280/.test(u) ? 2 : 1);
59
+ const poe = (mapa, u, alt = '') => {
60
+ const a = abs(u);
61
+ if (!a || RUIM.test(a) || a.startsWith('data:')) return;
62
+ const k = chaveDe(a);
63
+ const atual = mapa.get(k);
64
+ // Sem alt, o nome do arquivo descreve a foto ("palestrante-plateia-mesas").
65
+ const doNome = decodeURIComponent(a.split('/').pop().replace(/\.[a-z0-9]+$/i, '').replace(/\.(sm|md|lg)$/i, ''))
66
+ .replace(/^\d+[-_]?/, '').replace(/[-_]+/g, ' ').replace(/\b(dsc|img|hero)\b\s*\d*/gi, '').trim();
67
+ if (!atual || pesoDe(a) > pesoDe(atual.url)) mapa.set(k, { url: a, alt: limpar(alt).slice(0, 120) || (atual && atual.alt) || doNome });
68
+ };
69
+ let m;
70
+ const reImg = /<img\b[^>]*>/gi;
71
+ while ((m = reImg.exec(html))) {
72
+ const tag = m[0];
73
+ const alt = (tag.match(/\balt=["']([^"']*)/i) || [])[1] || '';
74
+ const srcset = (tag.match(/\b(?:srcset|data-srcset)=["']([^"']+)/i) || [])[1];
75
+ const src = (srcset && maiorDoSrcset(srcset)) || (tag.match(/\b(?:data-src|src)=["']([^"']+)/i) || [])[1];
76
+ if (src && /\.(jpe?g|png|webp|avif)(\?|$)/i.test(src)) poe(fotos, src, alt);
77
+ }
78
+ // Sites em Next/React guardam a galeria num JSON dentro da página: o
79
+ // caminho da mídia aparece como texto, não como <img>.
80
+ const reSolto = /["'(]((?:https?:)?\/[^"'()\s]+?\.(?:jpe?g|png|webp|avif|mp4|webm))(?:\?[^"'()\s]*)?["')]/gi;
81
+ while ((m = reSolto.exec(html))) {
82
+ const u = m[1];
83
+ if (/\.(mp4|webm)$/i.test(u)) poe(videos, u);
84
+ else poe(fotos, u);
85
+ }
86
+ const og = (html.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)/i) || [])[1];
87
+ if (og) poe(fotos, og, 'capa do site');
88
+ return { fotos: [...fotos.values()], videos: [...videos.values()] };
89
+ }
90
+
91
+ /** As páginas internas que valem a leitura para ESTE pedido. */
92
+ function paginasRelevantes(html, base, pedido, max = 5) {
93
+ const host = new URL(base).hostname;
94
+ const termos = String(pedido || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
95
+ .split(/[^a-z0-9]+/).filter((w) => w.length >= 4);
96
+ const vistos = new Set();
97
+ const lista = [];
98
+ const re = /href=["']([^"'#]+)["']/gi;
99
+ let m;
100
+ while ((m = re.exec(html))) {
101
+ let u;
102
+ try { u = new URL(m[1], base); } catch { continue; }
103
+ if (u.hostname !== host || /\.(css|js|png|jpe?g|svg|webp|pdf|ico|xml|json)$/i.test(u.pathname)) continue;
104
+ const caminho = u.pathname.replace(/\/$/, '');
105
+ if (!caminho || vistos.has(caminho) || /privacidade|privacy|termos|terms|login|blog\/|cdn-cgi/.test(caminho)) continue;
106
+ vistos.add(caminho);
107
+ const c = caminho.toLowerCase();
108
+ const pontos = termos.filter((t) => c.includes(t.slice(0, 6))).length * 3
109
+ + PALAVRAS_BOAS.filter((w) => c.includes(w)).length;
110
+ if (pontos > 0) lista.push({ url: u.toString(), pontos });
111
+ }
112
+ return lista.sort((a, b) => b.pontos - a.pontos).slice(0, max).map((x) => x.url);
113
+ }
114
+
115
+ /**
116
+ * Lê o site e baixa o acervo para a pasta do projeto.
117
+ *
118
+ * @param {object} op
119
+ * @param {string} op.url
120
+ * @param {string} op.pedido o que o usuário pediu (decide quais páginas ler)
121
+ * @param {string} op.pasta a pasta do projeto
122
+ * @param {function} op.buscar (url) → {corpo} — o leitor de página do identidade.js
123
+ * @param {function} op.baixar (item, {pasta, nome, sub}) → {ok, relativo} — o do assets.js
124
+ */
125
+ async function colher({ url, pedido = '', pasta, buscar, baixar, maxFotos = 16, maxVideos = 4, htmlInicial = null }) {
126
+ const paginas = [];
127
+ const inicial = htmlInicial || (await buscar(url)).corpo;
128
+ paginas.push({ url, html: inicial });
129
+ for (const u of paginasRelevantes(inicial, url, pedido)) {
130
+ try { paginas.push({ url: u, html: (await buscar(u)).corpo }); } catch { /* página que não abriu fica de fora */ }
131
+ }
132
+
133
+ const fatos = paginas.map((p) => ({ pagina: p.url, texto: textoDaPagina(p.html) })).filter((f) => f.texto.length > 80);
134
+ const fotosVistas = new Map(), videosVistos = new Map();
135
+ // As páginas internas primeiro: a galeria tem mais foto de verdade que a home.
136
+ for (const p of [...paginas.slice(1), paginas[0]]) {
137
+ const { fotos, videos } = colherMidia(p.html, p.url);
138
+ for (const f of fotos) if (!fotosVistas.has(chaveDe(f.url))) fotosVistas.set(chaveDe(f.url), { ...f, pagina: p.url });
139
+ for (const v of videos) if (!videosVistos.has(chaveDe(v.url))) videosVistos.set(chaveDe(v.url), { ...v, pagina: p.url });
140
+ }
141
+
142
+ const fotos = [], videos = [];
143
+ for (const f of fotosVistas.values()) {
144
+ if (fotos.length >= maxFotos) break;
145
+ const d = await baixar({ url: f.url, titulo: f.alt || 'foto da marca', licenca: 'da própria marca', fonte: 'site' }, { pasta, nome: 'marca-foto', sub: 'midia/marca' });
146
+ // Menos de 25 KB é ícone ou miniatura, mesmo sem cara de ícone no nome.
147
+ if (d.ok && d.bytes > 25000) fotos.push({ src: d.relativo, alt: f.alt, pagina: f.pagina });
148
+ }
149
+ for (const v of videosVistos.values()) {
150
+ if (videos.length >= maxVideos) break;
151
+ const d = await baixar({ url: v.url, titulo: 'vídeo da marca', licenca: 'da própria marca', fonte: 'site' }, { pasta, nome: 'marca-video', sub: 'midia/marca' });
152
+ if (d.ok && d.bytes > 50000) videos.push({ src: d.relativo, pagina: v.pagina });
153
+ }
154
+ return { ok: true, paginas: paginas.map((p) => p.url), fatos, fotos, videos };
155
+ }
156
+
157
+ module.exports = { colher, colherMidia, paginasRelevantes, textoDaPagina, maiorDoSrcset };
@@ -120,7 +120,41 @@ const COLECOES = [
120
120
  * O SVG vem com `currentColor` no traço para a cor ser decidida na cena — um
121
121
  * ícone com cor cravada no arquivo obriga a baixar de novo para mudar de cor.
122
122
  */
123
+ /* ── EMOJI 3D ─────────────────────────────────────────────────────────
124
+ * "Os emojis, quero emojis da Apple." (25/09/2026) Os da Apple têm direito
125
+ * autoral e não podem ir num vídeo publicado; os Fluent 3D da Microsoft têm
126
+ * licença MIT e o mesmo visual brilhante e com volume. "fluent-3d:robot" é o
127
+ * PNG do repositório oficial (pelo jsDelivr), embutido como <img> — o resto
128
+ * do render trata igual a um ícone SVG. Sem o 3D, cai no emoji plano. */
129
+ const cache3d = new Map();
130
+ async function emoji3d(nome, { pasta = null } = {}) {
131
+ if (cache3d.has(nome)) return cache3d.get(nome);
132
+ const palavras = nome.split('-');
133
+ const pastas = [
134
+ palavras.join(' ').replace(/^./, (c) => c.toUpperCase()),
135
+ palavras.map((w) => w[0].toUpperCase() + w.slice(1)).join(' '),
136
+ ];
137
+ for (const pastaGit of pastas) {
138
+ const url = `https://cdn.jsdelivr.net/gh/microsoft/fluentui-emoji@main/assets/${encodeURIComponent(pastaGit)}/3D/${palavras.join('_')}_3d.png`;
139
+ try {
140
+ const png = await pegar(url, { binario: true });
141
+ if (png && png.length > 1000 && png[0] === 0x89) {
142
+ if (pasta) guardar(path.join(pasta, 'midia', 'icones', `fluent-3d-${nome}.png`), png, { licenca: 'MIT (Microsoft Fluent UI Emoji)', origem: url });
143
+ const r = { ok: true, nome: 'fluent-3d:' + nome,
144
+ svg: `<img src="data:image/png;base64,${png.toString('base64')}" style="width:100%;height:100%;object-fit:contain;display:block" />` };
145
+ cache3d.set(nome, r);
146
+ return r;
147
+ }
148
+ } catch { /* a próxima grafia */ }
149
+ }
150
+ const plano = await icone('fluent-emoji-flat:' + nome, { pasta });
151
+ const r = plano.ok ? { ...plano, nome: 'fluent-3d:' + nome } : plano;
152
+ cache3d.set(nome, r);
153
+ return r;
154
+ }
155
+
123
156
  async function icone(nome, { pasta = null } = {}) {
157
+ if (/^fluent-3d:/i.test(String(nome))) return emoji3d(String(nome).split(':')[1], { pasta });
124
158
  const m = String(nome || '').match(/^([a-z0-9-]+):([a-z0-9-]+)$/i);
125
159
  if (!m) return { ok: false, error: 'o ícone é "coleção:nome", como "lucide:zap"' };
126
160
  const url = `https://api.iconify.design/${m[1]}/${m[2]}.svg?color=currentColor`;
@@ -213,7 +247,28 @@ function extensaoPorConteudo(bytes) {
213
247
  *
214
248
  * @returns {{ok, imagens: Array<{url, titulo, autor, licenca, creditos, fonte}>}}
215
249
  */
216
- async function procurarImagens(termo, { quantas = 8, transparente = false } = {}) {
250
+ /* O Pexels pelo SERVIDOR: a chave é dele, e ninguém precisa ter uma. Sem
251
+ servidor (ou sem a chave lá), devolve null e a busca segue no Openverse. */
252
+ async function pexelsDoServidor(tipo, termo, { quantas, orientacao } = {}) {
253
+ try {
254
+ const cfg = require('../config').loadConfig();
255
+ const r = await require('../api').getMidia(cfg.server, cfg.token, tipo, { q: termo, n: quantas, orientacao });
256
+ const lista = r && r.ok ? r[tipo] : null;
257
+ return lista && lista.length ? lista : null;
258
+ } catch { return null; }
259
+ }
260
+
261
+ /** Procura vídeo de apoio (b-roll). Só Pexels: o Openverse não tem vídeo. */
262
+ async function procurarVideos(termo, { quantas = 5, orientacao = null } = {}) {
263
+ const videos = await pexelsDoServidor('videos', termo, { quantas, orientacao });
264
+ return videos ? { ok: true, videos } : { ok: false, error: 'sem acervo de vídeo agora (o servidor está sem a chave do Pexels).', videos: [] };
265
+ }
266
+
267
+ async function procurarImagens(termo, { quantas = 8, transparente = false, orientacao = null } = {}) {
268
+ if (!transparente && !process.env.PEXELS_API_KEY) {
269
+ const imagens = await pexelsDoServidor('imagens', termo, { quantas, orientacao });
270
+ if (imagens) return { ok: true, imagens };
271
+ }
217
272
  const chave = process.env.PEXELS_API_KEY;
218
273
  if (chave && !transparente) {
219
274
  try {
@@ -256,14 +311,33 @@ async function procurarImagens(termo, { quantas = 8, transparente = false } = {}
256
311
  * `duracaoMin` existe para separar as duas coisas sem o usuário dizer: um
257
312
  * "whoosh" tem menos de dois segundos, uma trilha tem mais de trinta.
258
313
  */
314
+ /* A MÚSICA vem da Jamendo (pelo Openverse): música de verdade, moderna, de
315
+ licença aberta. O Openverse geral devolvia concerto antigo, vento e trilha
316
+ de terror para "cinematic" (medido em 25/09). Licença "nd" (sem obra
317
+ derivada) fica de fora da música: pôr a faixa num vídeo editado é derivar. */
318
+ async function procurarMusica(termo, { quantas = 8, duracaoMin = 45 } = {}) {
319
+ try {
320
+ const r = await pegar(`https://api.openverse.org/v1/audio/?q=${encodeURIComponent(termo)}&source=jamendo`
321
+ + `&page_size=${Math.max(quantas * 2, 10)}&license_type=commercial`, { json: true });
322
+ const sons = (r.results || [])
323
+ .filter((x) => !/nd/.test(x.license || '') && (!duracaoMin || (x.duration || 0) / 1000 >= duracaoMin))
324
+ .slice(0, quantas)
325
+ .map((x) => ({ url: x.url, titulo: x.title || termo, autor: x.creator, licenca: x.license, creditos: x.attribution,
326
+ fonte: 'jamendo', segundos: x.duration ? Math.round(x.duration / 1000) : null }));
327
+ if (sons.length) return { ok: true, sons };
328
+ } catch { /* cai no Openverse geral */ }
329
+ return procurarSom(termo, { quantas, duracaoMin });
330
+ }
331
+
259
332
  async function procurarSom(termo, { quantas = 8, duracaoMin = 0, duracaoMax = 0 } = {}) {
260
333
  try {
261
334
  let url = `https://api.openverse.org/v1/audio/?q=${encodeURIComponent(termo)}`
262
335
  + `&page_size=${quantas}&license_type=commercial`;
263
- if (duracaoMin) url += `&length=${duracaoMin < 30 ? 'short' : 'medium'}`;
336
+ if (duracaoMin && duracaoMin < 30) url += '&length=short';
264
337
  const r = await pegar(url, { json: true });
265
338
  const sons = (r.results || [])
266
339
  .filter((x) => !duracaoMax || !x.duration || x.duration / 1000 <= duracaoMax)
340
+ .filter((x) => !duracaoMin || !x.duration || x.duration / 1000 >= duracaoMin)
267
341
  .map((x) => ({
268
342
  url: x.url, titulo: x.title || termo, autor: x.creator,
269
343
  licenca: x.license, creditos: x.attribution, fonte: 'openverse',
@@ -337,7 +411,7 @@ async function conferirFonte(familia) {
337
411
  module.exports = {
338
412
  extensaoPorConteudo,
339
413
  pegar, guardar,
340
- icone, procurarIcones, iconesDoPlano, COLECOES,
414
+ icone, procurarIcones, iconesDoPlano, COLECOES, procurarVideos, procurarMusica,
341
415
  procurarImagens, procurarSom, baixar,
342
416
  FONTES, conferirFonte,
343
417
  };