primocode 9.8.0-beta.9 → 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 +2 -2
- 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/package.json +2 -2
- package/studio/narrar.py +111 -10
- package/studio/rostos.py +52 -0
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
|
```
|
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
|
};
|