primocode 9.8.0-beta.8 → 9.8.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.
- package/README.md +3 -2
- package/bin/primocode.js +45 -18
- package/lib/act.js +16 -9
- package/lib/api.js +8 -1
- package/lib/catalogo.js +11 -3
- package/lib/claude-engine.js +25 -3
- package/lib/estudio/acervo-site.js +157 -0
- package/lib/estudio/assets.js +77 -3
- package/lib/estudio/cinema.js +399 -0
- package/lib/estudio/composicao.js +470 -0
- package/lib/estudio/edicao.js +450 -0
- package/lib/estudio/impacto.js +263 -0
- package/lib/estudio/index.js +854 -5
- package/lib/estudio/plano.js +5 -2
- package/lib/estudio/remotion.js +228 -23
- package/lib/estudio/slides.js +34 -30
- package/lib/local/cerebro.json +361 -180
- package/lib/local/entrada.js +18 -8
- package/lib/local/motor-cli.js +1 -1
- package/lib/offline.js +168 -0
- package/package.json +2 -3
- package/skills/design-de-verdade/SKILL.md +2 -0
- package/skills/motion-de-primeira/SKILL.md +1 -1
- package/skills/site-com-motion/SKILL.md +39 -0
- package/studio/narrar.py +111 -10
- package/studio/rostos.py +52 -0
- package/kits/acolhedor/index.html +0 -130
- package/kits/acolhedor/script.js +0 -25
- package/kits/acolhedor/style.css +0 -146
- package/kits/editorial/index.html +0 -115
- package/kits/editorial/script.js +0 -19
- package/kits/editorial/style.css +0 -112
- package/kits/landing/index.html +0 -162
- package/kits/landing/script.js +0 -199
- package/kits/landing/style.css +0 -219
- package/kits/premium/index.html +0 -83
- package/kits/premium/script.js +0 -11
- package/kits/premium/style.css +0 -87
- package/kits/vitrine/index.html +0 -108
- package/kits/vitrine/script.js +0 -20
- package/kits/vitrine/style.css +0 -117
- package/lib/kits.js +0 -206
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v9.8.0-beta.
|
|
1
|
+
# PrimoCode v9.8.0-beta.23
|
|
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.
|
|
12
|
+
▐▛███▜▌ Primo Code v9.8.0-beta.23
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
|
@@ -311,6 +311,7 @@ primocode -c # continua a última conversa salva (--co
|
|
|
311
311
|
| `/copiar` | Copia a última resposta para a área de transferência (pbcopy, clip, wl-copy/xclip/xsel) |
|
|
312
312
|
| `/historico [termo]` | Procura nos seus pedidos guardados nesta máquina (`~/.primocode/history.jsonl`), do mais recente. As conversas inteiras da conta ficam no `/chats` |
|
|
313
313
|
| `/status` | Health check do servidor |
|
|
314
|
+
| `/offline` | Temporário: sobe o `primocode-server` no seu computador (a pasta ao lado do CLI, ou `/offline <caminho>`) com as chaves de `~/.primocode/servidor.env` e aponta a sessão pra ele. `/offline off` volta ao servidor de antes |
|
|
314
315
|
| `/doctor` | Confere tudo com ✓/✗: Node, servidor, `/api`, Chrome, Python+PyObjC (cursor, macOS), disco e config. Cada ✗ diz como consertar |
|
|
315
316
|
| `/custo` | Tokens enviados e recebidos e pedidos feitos nesta sessão, por provedor (estimado em caracteres ÷ 4 quando o provedor não manda o número) |
|
|
316
317
|
| `/atalhos` | As teclas: Esc Esc para o agente, `!` roda no shell, Alt+Enter quebra linha… |
|
package/bin/primocode.js
CHANGED
|
@@ -21,7 +21,6 @@ const api = require('../lib/api');
|
|
|
21
21
|
const tools = require('../lib/tools');
|
|
22
22
|
const cfg = require('../lib/config');
|
|
23
23
|
const nomeProjeto = require('../lib/nome-projeto');
|
|
24
|
-
const kits = require('../lib/kits');
|
|
25
24
|
const provedorLocal = require('../lib/local/provedor');
|
|
26
25
|
const { runAct, interromper, ehTetoDeToken, ehTransitorio, esperaDoErro } = require('../lib/act');
|
|
27
26
|
const fala = require('../lib/fala');
|
|
@@ -44,6 +43,7 @@ const nuvem = require('../lib/nuvem');
|
|
|
44
43
|
const compat = require('../lib/compatibilidade');
|
|
45
44
|
const mencoesLib = require('../lib/mencoes');
|
|
46
45
|
const extra = require('../lib/comandos-extra');
|
|
46
|
+
const offline = require('../lib/offline');
|
|
47
47
|
|
|
48
48
|
/* O /custo: toda conversa com o modelo passa pelo lib/api.js, e o contador
|
|
49
49
|
embrulha as funções dele uma vez, aqui. Quem atende é o provedor do /api
|
|
@@ -186,6 +186,7 @@ ${c.bold('Comandos')}
|
|
|
186
186
|
${c.brand('/logout')} desconecta este computador da conta
|
|
187
187
|
${c.brand('/comandos')} seus comandos (/revisar, /explicar…) · /comandos novo <nome>
|
|
188
188
|
${c.brand('/status')} checa o servidor
|
|
189
|
+
${c.brand('/offline')} roda o servidor no seu computador (temporário) · /offline off volta
|
|
189
190
|
${c.brand('/doctor')} confere tudo (servidor, /api, Chrome, disco…) e diz como consertar
|
|
190
191
|
${c.brand('/custo')} tokens e pedidos desta sessão, por provedor
|
|
191
192
|
${c.brand('/atalhos')} as teclas que ajudam
|
|
@@ -251,6 +252,7 @@ const SLASH_COMANDOS = [
|
|
|
251
252
|
['/comandos', 'seus comandos: um .md vira /nome · /comandos novo <nome>'],
|
|
252
253
|
['/api', 'usa a SUA chave (Gemini, ChatGPT, Claude…) direto daqui, sem o limite do servidor'],
|
|
253
254
|
['/status', 'checa o servidor'],
|
|
255
|
+
['/offline', 'roda o servidor aqui no seu computador, com as chaves do Render'],
|
|
254
256
|
['/doctor', 'confere se está tudo certo e diz como consertar'],
|
|
255
257
|
['/custo', 'tokens e pedidos desta sessão, por provedor'],
|
|
256
258
|
['/atalhos', 'as teclas que ajudam'],
|
|
@@ -769,16 +771,6 @@ function batizarDepois(plan, userText) {
|
|
|
769
771
|
.then((nome) => { if (nome) plan.nomeEscolhido = nome; });
|
|
770
772
|
}
|
|
771
773
|
|
|
772
|
-
/* A inspiração visual (lib/kits.js): pedido de landing leva a ficha da
|
|
773
|
-
direção que combina com o negócio. Nada é copiado para a pasta — o agente
|
|
774
|
-
escreve a página dele, inspirado, e não troca texto num molde. */
|
|
775
|
-
async function prepararKit(plan, userText) {
|
|
776
|
-
const kit = kits.escolher(userText, { provedor: provedorLocal.atual() });
|
|
777
|
-
if (!kit) return '';
|
|
778
|
-
console.log(c.dim(` ⎿ inspiração visual: ${kit.titulo}`));
|
|
779
|
-
return kit.instrucao;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
774
|
function createPendingProject(plan) {
|
|
783
775
|
const dir = cfg.createProjectDir(plan.nomeEscolhido || plan.name);
|
|
784
776
|
entrarNoProjeto(dir, 'projeto');
|
|
@@ -1145,7 +1137,6 @@ async function enviarPedido(userText) {
|
|
|
1145
1137
|
tempos.medir('espera do resumo', () => esperarResumo()),
|
|
1146
1138
|
tempos.medir('plano', () => dividirTarefa(prompt)),
|
|
1147
1139
|
]);
|
|
1148
|
-
const instrucaoKit = await prepararKit(plan, userText);
|
|
1149
1140
|
|
|
1150
1141
|
let r = null;
|
|
1151
1142
|
let jaFeito = '';
|
|
@@ -1194,8 +1185,7 @@ async function enviarPedido(userText) {
|
|
|
1194
1185
|
permissoes: state.config.permissoes,
|
|
1195
1186
|
// Pedido de mexer no computador leva a dica que libera as
|
|
1196
1187
|
// ferramentas desktop_* no servidor (ver DICA_COMPUTADOR).
|
|
1197
|
-
instrucao:
|
|
1198
|
-
.filter(Boolean).join('\n\n'),
|
|
1188
|
+
instrucao: i === 0 && querComputador(userText) ? vozModo.DICA_COMPUTADOR : '',
|
|
1199
1189
|
});
|
|
1200
1190
|
|
|
1201
1191
|
r = await rodarPasso();
|
|
@@ -2666,6 +2656,45 @@ async function handleSlash(text) {
|
|
|
2666
2656
|
break;
|
|
2667
2657
|
}
|
|
2668
2658
|
|
|
2659
|
+
/* O servidor aqui, no computador da pessoa, até a sessão acabar
|
|
2660
|
+
(lib/offline.js). "Enquanto não tenho acesso ao Railway, eu testo
|
|
2661
|
+
com o projeto rodando localmente." (24/09/2026) */
|
|
2662
|
+
case 'offline': case 'local': {
|
|
2663
|
+
if (/^(off|sair|desligar|parar)$/i.test(rest)) {
|
|
2664
|
+
if (!state.servidorAntes) { console.log(c.muted(' O /offline não está ligado.')); break; }
|
|
2665
|
+
offline.desligar();
|
|
2666
|
+
state.config.server = state.servidorAntes;
|
|
2667
|
+
state.servidorAntes = null;
|
|
2668
|
+
console.log(c.ok(' Voltei ao servidor de antes: ') + c.dim(state.config.server));
|
|
2669
|
+
break;
|
|
2670
|
+
}
|
|
2671
|
+
if (state.servidorAntes) { console.log(c.muted(' Já está no servidor local: ') + c.dim(state.config.server) + c.muted(' · /offline off volta')); break; }
|
|
2672
|
+
const chaves = offline.prepararEnv();
|
|
2673
|
+
if (chaves.criado || !chaves.temChave) {
|
|
2674
|
+
console.log(c.warn(` ${chaves.criado ? 'Criei' : 'Falta chave em'} ${chaves.arquivo}`));
|
|
2675
|
+
console.log(c.muted(' Cole nele as chaves do Render (primocode-server → Environment), uma por linha:'));
|
|
2676
|
+
console.log(c.dim(' GROQ_API_KEY=… OPENROUTER_API_KEY=… GEMINI_API_KEY=…'));
|
|
2677
|
+
console.log(c.muted(' Depois rode ') + c.brand('/offline') + c.muted(' de novo.'));
|
|
2678
|
+
break;
|
|
2679
|
+
}
|
|
2680
|
+
const dir = offline.acharServidor({ pedido: rest });
|
|
2681
|
+
const sp = new Spinner('subindo o servidor aqui').start();
|
|
2682
|
+
const r = await offline.ligar({ dir, env: chaves.env });
|
|
2683
|
+
sp.stop();
|
|
2684
|
+
if (!r.ok) {
|
|
2685
|
+
console.log(c.err(' ' + r.error));
|
|
2686
|
+
if (r.comoResolver) console.log(c.dim(' → ' + r.comoResolver));
|
|
2687
|
+
break;
|
|
2688
|
+
}
|
|
2689
|
+
state.servidorAntes = state.config.server;
|
|
2690
|
+
state.config.server = r.url;
|
|
2691
|
+
console.log(c.ok(` Servidor ${r.versao || ''} ${r.reaproveitado ? 'que já estava de pé' : 'rodando aqui'}: `) + c.dim(r.url));
|
|
2692
|
+
console.log(c.dim(` ${dir || ''}${dir ? ' · ' : ''}chaves: ${chaves.nomes.join(', ')}${r.reaproveitado ? '' : ' · log em ' + offline.ARQUIVO_LOG}`));
|
|
2693
|
+
if (provedorLocal.atual()) console.log(c.warn(' Atenção: o /api está ligado e fala direto com a IA. Para usar o servidor local, /api off.'));
|
|
2694
|
+
console.log(c.muted(' Vale até fechar o PrimoCode. ') + c.brand('/offline off') + c.muted(' volta antes.'));
|
|
2695
|
+
break;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2669
2698
|
/* Os comandos da pessoa: lista, e explica como fazer o dela. */
|
|
2670
2699
|
case 'comandos': {
|
|
2671
2700
|
const novo = rest.match(/^(novo|new|criar)\s+(\S+)$/i);
|
|
@@ -2867,10 +2896,8 @@ async function main() {
|
|
|
2867
2896
|
// Com -c, o pedido continua a última conversa em vez de chegar sozinho.
|
|
2868
2897
|
if (continuar) await continuarUltima();
|
|
2869
2898
|
/* O MESMO caminho do pedido digitado (sendPrompt). Este modo tinha um
|
|
2870
|
-
runAct próprio, e por isso ficava sem tudo o que vive lá: o
|
|
2871
|
-
|
|
2872
|
-
checkpoint do /desfazer. Medido em 24/09: `primo "crie uma landing…"`
|
|
2873
|
-
não pegou o kit. */
|
|
2899
|
+
runAct próprio, e por isso ficava sem tudo o que vive lá: o plano em
|
|
2900
|
+
passos, a retomada automática, o @arquivo, o checkpoint do /desfazer. */
|
|
2874
2901
|
state.semTerminal = true;
|
|
2875
2902
|
await sendPrompt(promptText);
|
|
2876
2903
|
process.exit(0);
|
package/lib/act.js
CHANGED
|
@@ -692,15 +692,19 @@ async function runAct(userPrompt, opts) {
|
|
|
692
692
|
|
|
693
693
|
Um modelo não chama tool fora da lista por conta própria. Só
|
|
694
694
|
chamou porque este parágrafo mandou. */
|
|
695
|
+
/* O caminho do ROTEIRO (lib/estudio/composicao.js). Antes esta
|
|
696
|
+
ordem mandava chamar o diretor e depois compor "cena a cena,
|
|
697
|
+
com as primitivas" — o modelo escrevia posição em pixel e o
|
|
698
|
+
vídeo saía quebrado, além de custar três ou quatro rodadas a
|
|
699
|
+
mais de modelo (medido em 25/09). */
|
|
695
700
|
messages.push({ role: 'system', content:
|
|
696
|
-
|
|
697
|
-
+ `
|
|
698
|
-
+ '
|
|
699
|
-
+ '
|
|
700
|
-
+ '
|
|
701
|
-
+ '
|
|
702
|
-
+ 'Nunca chame uma ferramenta que não está na sua lista — a chamada é recusada e a tarefa '
|
|
703
|
-
+ 'se perde.' });
|
|
701
|
+
`ESTE PEDIDO É DE PRODUÇÃO (formato "${producao.formato === 'apresentacao' ? 'slides' : 'video'}"). Faça em três passos: `
|
|
702
|
+
+ `estudio_projeto acao:"criar" com formato "${producao.formato === 'apresentacao' ? 'slides' : 'video'}" → `
|
|
703
|
+
+ 'estudio_plano com "roteiro" (partes com tipo e fala; o PrimoCode faz layout, voz, ícones, música e sons) → '
|
|
704
|
+
+ 'estudio_render. Se a peça for de uma marca de verdade, estudio_site antes, e use as cores e a logo em roteiro.marca. '
|
|
705
|
+
+ 'NÃO escreva cenas, elementos nem posições à mão. '
|
|
706
|
+
+ 'Se estudio_plano não aceitar "roteiro" (servidor antigo), siga com as ferramentas que você tem. '
|
|
707
|
+
+ 'Nunca chame uma ferramenta que não está na sua lista — a chamada é recusada e a tarefa se perde.' });
|
|
704
708
|
}
|
|
705
709
|
}
|
|
706
710
|
|
|
@@ -1167,7 +1171,10 @@ async function runAct(userPrompt, opts) {
|
|
|
1167
1171
|
if (checkpoint.MUDAM_ARQUIVO.has(tc.name)) {
|
|
1168
1172
|
try { checkpoint.antesDeMudar(tools.resolveInProject(tc.arguments && tc.arguments.path, pastaAtual), pastaAtual); } catch {}
|
|
1169
1173
|
}
|
|
1170
|
-
|
|
1174
|
+
/* O servidor, o token e o cliente da API vão junto: a legenda do
|
|
1175
|
+
estudio_video transcreve no servidor, e sem eles a ferramenta
|
|
1176
|
+
respondia "a transcrição precisa do servidor". */
|
|
1177
|
+
const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual, server, token, api });
|
|
1171
1178
|
logResult(tc.name, result, profundidade);
|
|
1172
1179
|
repeticao.executou(jaChamado, tc.name, tc.arguments);
|
|
1173
1180
|
if (RELEITURA.has(tc.name) && result && result.ok !== false) {
|
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 };
|
package/lib/catalogo.js
CHANGED
|
@@ -84,7 +84,11 @@ const GRUPOS = [
|
|
|
84
84
|
+ 'SEMPRE procure antes de criar quando o pedido falar de algo que já existe.' },
|
|
85
85
|
|
|
86
86
|
{ tool: 'estudio_plano', escreve: true, faz: 'escreve ou ajusta o que o vídeo É — cenas, elementos, animação, câmera, áudio',
|
|
87
|
-
args: 'id.
|
|
87
|
+
args: 'id. PREFIRA roteiro: {marca:{cores:{fundo,destaque,tinta},fontes:{titulo,texto},logo}, proporcao, '
|
|
88
|
+
+ 'partes:[{tipo, fala, …}]} — o PrimoCode faz o layout, a voz com entonação, os ícones, a música e os sons. '
|
|
89
|
+
+ 'Tipos: abertura{titulo,sub} titulo{titulo,sub} numero{valor,rotulo,sub} lista{titulo,itens} passos{titulo,itens} '
|
|
90
|
+
+ 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link}. '
|
|
91
|
+
+ 'voz (thalita|antonio|francisca). Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
|
|
88
92
|
+ 'cena (opcional): o número de UMA cena para trocar só ela, sem reescrever o resto. '
|
|
89
93
|
+ 'motivo (opcional): fica no caderno. voltarPara (opcional): nome de uma versão. '
|
|
90
94
|
+ 'Sem plano nem cenas, LÊ o plano atual. '
|
|
@@ -104,7 +108,10 @@ const GRUPOS = [
|
|
|
104
108
|
+ 'Use os tempos que voltam para a duração das cenas — estimar por número de palavras erra em segundos.' },
|
|
105
109
|
|
|
106
110
|
{ tool: 'estudio_video', escreve: true, faz: 'o que se faz com a gravação do usuário: importar, medir, cortar silêncio, transcrever, recortar a pessoa',
|
|
107
|
-
args: 'id. acao: importar | medir | silencio | cortar-silencio | quadro | legendas | recortar-pessoa. '
|
|
111
|
+
args: 'id. acao: editar | importar | medir | silencio | cortar-silencio | quadro | legendas | recortar-pessoa. '
|
|
112
|
+
+ 'editar: FAZ A EDIÇÃO INTEIRA sozinho — corta o silêncio, transcreve palavra por palavra e monta o plano '
|
|
113
|
+
+ '(legenda animada, ícone na hora da palavra, zoom de corte, pop e whoosh). estilo: viral (padrão) | limpo. '
|
|
114
|
+
+ 'Com gravação do usuário, é o PRIMEIRO passo depois de importar; depois só estudio_render. '
|
|
108
115
|
+ 'arquivo: caminho (em "importar", de fora; nas outras, o relativo dentro do projeto). '
|
|
109
116
|
+ 'cortar-silencio: minimoParaCortar (padrão 0.6s), respiro (padrão 0.18s) — os dois existem para o corte não soar robótico. '
|
|
110
117
|
+ 'legendas: devolve os trechos com tempo, para virarem elemento "legenda". '
|
|
@@ -119,7 +126,8 @@ const GRUPOS = [
|
|
|
119
126
|
|
|
120
127
|
{ tool: 'estudio_site', escreve: false, faz: 'lê a identidade visual de uma marca: cores, fontes, logo e o que ela diz de si',
|
|
121
128
|
args: 'url — OU marca: o nome da empresa, quando não há link ("faça um vídeo para a Paris Group"). '
|
|
122
|
-
+ 'id (opcional): anota no caderno do projeto
|
|
129
|
+
+ 'id (opcional): anota no caderno do projeto — e com id traz o ACERVO da marca: as fotos e os vídeos '
|
|
130
|
+
+ 'reais do site (que entram sozinhos no vídeo) e os FATOS das páginas (datas, local, números), para o roteiro. '
|
|
123
131
|
+ 'Devolve FATOS medidos, não um roteiro — o roteiro é seu. '
|
|
124
132
|
+ 'Use SEMPRE que o pedido citar uma empresa de verdade: inventar a cor de uma marca que existe '
|
|
125
133
|
+ 'é erro que sai pronto no vídeo entregue. '
|
package/lib/claude-engine.js
CHANGED
|
@@ -61,6 +61,28 @@ function argsDoModelo() {
|
|
|
61
61
|
return MODELO_CLAUDE ? ['--model', MODELO_CLAUDE] : [];
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/* O Claude Code ENXUTO, como motor. Sem isto cada passo do agente abria o
|
|
65
|
+
* Claude Code da pessoa inteiro: hooks, plugins, skills, CLAUDE.md dela e as
|
|
66
|
+
* ferramentas nativas — 36 mil tokens e ~6 s por chamada, e a resposta
|
|
67
|
+
* chegava com recado de hook pessoal ("context-sync pendente"). Medido em
|
|
68
|
+
* 25/09: com estes argumentos, a mesma chamada leva 473 tokens e ~3,5 s. Um
|
|
69
|
+
* pedido de 100 passos pagava aquilo 100 vezes — é a demora de 20 minutos.
|
|
70
|
+
*
|
|
71
|
+
* Nada disto tira o que o motor precisa: o protocolo vai inteiro no prompt
|
|
72
|
+
* (corpoDoProtocolo), e as ferramentas que valem são as do PrimoCode, não as
|
|
73
|
+
* do Claude Code. */
|
|
74
|
+
const ARGS_ENXUTOS = [
|
|
75
|
+
'--setting-sources', 'project',
|
|
76
|
+
'--strict-mcp-config',
|
|
77
|
+
'--disable-slash-commands',
|
|
78
|
+
'--tools', '',
|
|
79
|
+
'--no-session-persistence',
|
|
80
|
+
'--system-prompt', 'Você é o motor de decisão do PrimoCode. Siga à risca o protocolo que vem na mensagem e responda só com o bloco pedido.',
|
|
81
|
+
];
|
|
82
|
+
function argsDoMotor() {
|
|
83
|
+
return [...ARGS_ENXUTOS, ...argsDoModelo()];
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
/**
|
|
65
87
|
* Acha o ÚLTIMO bloco marcado no texto (pode vir ruído de terminal — prompt
|
|
66
88
|
* ANSI, texto solto — antes e depois) e devolve o JSON de dentro dele.
|
|
@@ -567,7 +589,7 @@ async function requestActViaHeadless(payload, onEvent) {
|
|
|
567
589
|
'-p',
|
|
568
590
|
'--output-format', 'json',
|
|
569
591
|
'--dangerously-skip-permissions',
|
|
570
|
-
...
|
|
592
|
+
...argsDoMotor(),
|
|
571
593
|
], { encoding: 'utf-8', timeout: 180000, input: prompt, maxBuffer: 32 * 1024 * 1024,
|
|
572
594
|
env: process.env, ...SEM_APARECER });
|
|
573
595
|
const r = JSON.parse(saida);
|
|
@@ -577,7 +599,7 @@ async function requestActViaHeadless(payload, onEvent) {
|
|
|
577
599
|
const pedirCorrecao = (aviso) => {
|
|
578
600
|
const s = execFileSync('claude', [
|
|
579
601
|
'-p', '--output-format', 'json', '--dangerously-skip-permissions',
|
|
580
|
-
...
|
|
602
|
+
...argsDoMotor(),
|
|
581
603
|
], {
|
|
582
604
|
encoding: 'utf-8', timeout: 120000, maxBuffer: 32 * 1024 * 1024,
|
|
583
605
|
env: process.env,
|
|
@@ -622,7 +644,7 @@ module.exports = {
|
|
|
622
644
|
// o que veio torto. Reimplementar aquilo no outro motor seria manter dois
|
|
623
645
|
// protocolos que divergem na primeira correção de um deles.
|
|
624
646
|
linhaDoHistorico, nomesDasTools, promptDoProtocolo, promptDeCorrecao,
|
|
625
|
-
MODELO_CLAUDE, argsDoModelo,
|
|
647
|
+
MODELO_CLAUDE, argsDoModelo, argsDoMotor,
|
|
626
648
|
SKILL_DIR, SKILL_PATH, garantirSkill,
|
|
627
649
|
temClaudeCode, temOrca, orcaAlcancavel, escolherCaminho,
|
|
628
650
|
garantirEngine, requestAct, emitirDecisao,
|
|
@@ -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(/ /g, ' ').replace(/&/g, '&').replace(/"/g, '"').replace(/'/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 };
|
package/lib/estudio/assets.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
};
|