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.
- package/README.md +4 -4
- package/bin/primocode.js +25 -28
- package/lib/act.js +5 -2
- package/lib/catalogo.js +7 -4
- package/lib/config.js +10 -2
- package/lib/estudio/assets.js +86 -6
- package/lib/estudio/batida.js +184 -0
- package/lib/estudio/biblioteca.js +214 -0
- package/lib/estudio/cinema.js +37 -17
- package/lib/estudio/composicao.js +2 -1
- package/lib/estudio/decisao.js +106 -0
- package/lib/estudio/edicao.js +36 -17
- package/lib/estudio/estilo.js +35 -8
- package/lib/estudio/fluido.js +352 -0
- package/lib/estudio/gerativo.js +21 -3
- package/lib/estudio/hibrido.js +16 -12
- package/lib/estudio/impacto.js +66 -31
- package/lib/estudio/index.js +245 -42
- package/lib/estudio/kit/fluido.jsx +614 -0
- package/lib/estudio/kit/gerativo.jsx +15 -5
- package/lib/estudio/kit/index.jsx +2 -1
- package/lib/estudio/kit/tres.jsx +88 -21
- package/lib/estudio/kit.js +3 -1
- package/lib/estudio/kokoro.js +111 -0
- package/lib/estudio/lancamento.js +3 -4
- package/lib/estudio/legibilidade.js +20 -0
- package/lib/estudio/referencias.js +36 -25
- package/lib/estudio/remotion.js +53 -6
- package/lib/estudio/revisor.js +117 -0
- package/lib/estudio/variacao3d.js +46 -0
- package/lib/estudio/variedade.js +140 -8
- package/lib/portao-skill.js +150 -0
- package/lib/skills.js +15 -1
- package/lib/tools.js +31 -5
- package/lib/video/filters.js +6 -1
- package/lib/video/ops.js +4 -3
- package/lib/video/timeline.js +1 -1
- package/lib/video/zoom.js +275 -0
- package/package.json +2 -2
- package/skills/escrever-em-portugues/SKILL.md +3 -0
- package/skills/interface-revisada/SKILL.md +7 -1
- package/skills/motion-de-primeira/SKILL.md +3 -5
- package/skills/projeto-no-github/SKILL.md +6 -0
- package/skills/publicar-na-internet/SKILL.md +3 -0
- package/skills/site-com-motion/SKILL.md +2 -0
- package/studio/voz_kokoro.py +98 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v10.0
|
|
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
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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 =
|
|
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
|
-
+ '
|
|
93
|
-
+ '
|
|
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
|
|
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,
|
package/lib/estudio/assets.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|