primocode 10.1.0 → 10.3.0-beta.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/bin/primocode.js +6 -26
- package/lib/act.js +5 -0
- package/lib/browser.js +1 -1
- package/lib/catalogo.js +17 -8
- package/lib/estudio/assets.js +75 -1
- package/lib/estudio/assinatura.js +108 -0
- package/lib/estudio/assinaturas/fluido.json +15 -0
- package/lib/estudio/assinaturas/gerativo.json +15 -0
- package/lib/estudio/batida.js +184 -0
- package/lib/estudio/biblioteca.js +52 -12
- package/lib/estudio/decisao.js +106 -0
- package/lib/estudio/diretor.js +70 -4
- package/lib/estudio/edicao.js +34 -16
- package/lib/estudio/efeitos.js +95 -0
- package/lib/estudio/estilo.js +17 -6
- package/lib/estudio/falantes.js +97 -0
- package/lib/estudio/fluido.js +110 -16
- package/lib/estudio/gerativo.js +309 -85
- package/lib/estudio/hibrido.js +1 -1
- package/lib/estudio/index.js +252 -45
- package/lib/estudio/kit/fluido.jsx +130 -9
- package/lib/estudio/kit/gerativo.jsx +90 -21
- package/lib/estudio/kit/marca.jsx +58 -1
- package/lib/estudio/kit/tres.jsx +4 -1
- package/lib/estudio/kit.js +2 -0
- package/lib/estudio/plano.js +5 -1
- package/lib/estudio/print-site.js +152 -0
- package/lib/estudio/referencias.js +47 -25
- package/lib/estudio/remotion.js +74 -3
- package/lib/estudio/revisor.js +117 -0
- package/lib/estudio/variedade.js +83 -21
- 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/motion-de-primeira/SKILL.md +3 -5
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v10.0.
|
|
1
|
+
# PrimoCode v10.3.0-beta.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.3.0-beta.0
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/bin/primocode.js
CHANGED
|
@@ -1088,37 +1088,17 @@ async function enviarPedido(userText) {
|
|
|
1088
1088
|
// O Chrome só aparece na tela quando o pedido é de navegador (lib/browser.js, definirVisivel).
|
|
1089
1089
|
require('../lib/browser.js').definirVisivel(querNavegador(userText));
|
|
1090
1090
|
|
|
1091
|
-
/* AS REFERÊNCIAS DO VÍDEO
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
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). */
|
|
1095
1094
|
const referencias = require('../lib/estudio/referencias.js');
|
|
1096
1095
|
referencias.limpar();
|
|
1097
1096
|
const producao = require('../lib/estudio/diretor.js').precisaDeProducao(userText);
|
|
1098
1097
|
if (producao.producao && producao.formato === 'video') {
|
|
1099
|
-
/* O modo sai do próprio pedido quando ele diz ("só motion", "só do
|
|
1100
|
-
site x.com", "web aberta"). Quando NÃO diz, pergunta antes de
|
|
1101
|
-
começar — "vídeo" às vezes quer dizer só o motion (02/10). No
|
|
1102
|
-
pedido único não há quem responda, e vale o aberto. */
|
|
1103
1098
|
const r = referencias.inferir(userText);
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
console.log(' ' + c.dim('1') + c.muted(' só motion — animação pura, nenhuma foto nem vídeo de fora'));
|
|
1108
|
-
console.log(' ' + c.dim('2') + c.muted(' motion + web aberta — ele pesquisa e busca imagem e vídeo à vontade'));
|
|
1109
|
-
console.log(' ' + c.dim('3') + c.muted(' motion + escopo fechado — só os sites e termos que você disser'));
|
|
1110
|
-
const dito = String(await ask(' ' + c.brand('❯ ') + c.muted('1, 2 ou 3 '))).trim();
|
|
1111
|
-
// Aqui 1 é o motion (a ordem da pergunta), não o 1 = aberto do entender().
|
|
1112
|
-
const modo = { 1: 'sem', 2: 'aberto', 3: 'fechado' }[(/^\s*([123])\b/.exec(dito) || [])[1]] || referencias.entender(dito) || 'aberto';
|
|
1113
|
-
r.modo = modo;
|
|
1114
|
-
r.escopo = modo === 'fechado'
|
|
1115
|
-
? String(await ask(' ' + c.brand('❯ ') + c.muted('quais sites ou termos? (separados por vírgula) '))).trim()
|
|
1116
|
-
: '';
|
|
1117
|
-
if (modo === 'fechado' && !r.escopo) r.modo = 'aberto';
|
|
1118
|
-
}
|
|
1119
|
-
referencias.definir(r.modo, r.escopo);
|
|
1120
|
-
console.log(c.dim(` ⎿ referências: ${{ aberto: 'web aberta', fechado: 'só ' + r.escopo, sem: 'nenhuma, só motion' }[r.modo]}`)
|
|
1121
|
-
+ (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') : ''));
|
|
1122
1102
|
}
|
|
1123
1103
|
|
|
1124
1104
|
// O resumo da rodada anterior é feito em segundo plano logo depois
|
package/lib/act.js
CHANGED
|
@@ -895,6 +895,11 @@ async function runAct(userPrompt, opts) {
|
|
|
895
895
|
+ `estudio_projeto acao:"criar" com formato "${producao.formato === 'apresentacao' ? 'slides' : 'video'}" → `
|
|
896
896
|
+ 'estudio_plano com "roteiro" (partes com tipo e fala; o PrimoCode faz layout, voz, ícones, música e sons) → '
|
|
897
897
|
+ '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. '
|
|
898
|
+
/* "todo vídeo começa igual" (09/10): o cardápio de estruturas e o
|
|
899
|
+
que os últimos vídeos usaram. O modelo escolhe; nada aqui força. */
|
|
900
|
+
+ 'ANTES de escrever as partes, escolha a estrutura do roteiro e diga qual em roteiro.estrutura. '
|
|
901
|
+
+ 'SE a pessoa pediu duas vozes, dois narradores ou diálogo: roteiro.vozes {nome: voz} e falante em cada parte.\n'
|
|
902
|
+
+ diretor.cardapioEstruturas() + '\n'
|
|
898
903
|
+ 'NÃO escreva cenas, elementos nem posições à mão. '
|
|
899
904
|
+ 'Se estudio_plano não aceitar "roteiro" (servidor antigo), siga com as ferramentas que você tem. '
|
|
900
905
|
+ 'Nunca chame uma ferramenta que não está na sua lista — a chamada é recusada e a tarefa se perde.' });
|
package/lib/browser.js
CHANGED
|
@@ -1296,5 +1296,5 @@ module.exports = {
|
|
|
1296
1296
|
scriptDaSeta, verSeta, findChrome,
|
|
1297
1297
|
open, click, clickText, clickSelector, type, typeInSelector,
|
|
1298
1298
|
key, screenshot, evaluate, waitFor, wait, scroll, close,
|
|
1299
|
-
inspecionarEscondido, rodarEscondido, abrirEscondido, fecharRevisor, subirRevisor, aquecerRevisor,
|
|
1299
|
+
inspecionarEscondido, rodarEscondido, abrirEscondido, abaEscondida, fecharRevisor, subirRevisor, aquecerRevisor,
|
|
1300
1300
|
};
|
package/lib/catalogo.js
CHANGED
|
@@ -77,7 +77,8 @@ 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 | editar. '
|
|
80
|
+
args: 'acao: criar | abrir | procurar | listar | anotar | editar | assinatura. '
|
|
81
|
+
+ 'assinatura: quando o usuário diz o que achou de um vídeo ("adorei a virada", "texto demais"), grave com texto (e o id) — o próximo vídeo parte disso; sem texto, lê as faixas e as notas. '
|
|
81
82
|
+ 'editar: id (sem id, o mais novo) — quando o usuário quer EDITAR um vídeo que o Estúdio fez, '
|
|
82
83
|
+ 'abre o projeto no Remotion Studio (o editor, no navegador) e devolve a pasta. '
|
|
83
84
|
+ 'criar: titulo, pedido, formato (video|slides|arte). '
|
|
@@ -87,24 +88,32 @@ const GRUPOS = [
|
|
|
87
88
|
|
|
88
89
|
{ tool: 'estudio_plano', escreve: true, faz: 'escreve ou ajusta o que o vídeo É — cenas, elementos, animação, câmera, áudio',
|
|
89
90
|
args: 'id. PREFIRA roteiro: {marca:{cores:{fundo,destaque,tinta},fontes:{titulo,texto},logo}, proporcao, '
|
|
90
|
-
+ 'partes:[{tipo, fala, …}]} — o PrimoCode faz o layout, a voz com entonação, os ícones, a música e os sons. '
|
|
91
|
+
+ 'partes:[{tipo, fala, …}], estrutura} — estrutura é a que VOCÊ escolheu pelo pedido (historia|problema-solucao|lista|pergunta-resposta|dialogo|demonstracao|mito-verdade|dado-surpresa, ou outra com nome); o PrimoCode faz o layout, a voz com entonação, os ícones, a música e os sons. '
|
|
91
92
|
+ 'Tipos: abertura{titulo,sub} titulo{titulo,sub} numero{valor,rotulo,sub} lista{titulo,itens} passos{titulo,itens} '
|
|
92
93
|
+ 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link} '
|
|
93
|
-
+ 'logo{titulo} cores{cores:[{nome,hex}]} produto{titulo,texto,destaque,selo,barras}. Em qualquer parte: destaque (a palavra-tese), fonte (a fonte citada). '
|
|
94
|
+
+ '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
95
|
+ '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: hibrido (
|
|
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|lavanda|creme
|
|
96
|
+
+ '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; modelo3d fone|celular|notebook|relogio|garrafa|lata|caixa: o objeto gira em 3D numa batida — ponha quando o vídeo é de um produto ou objeto físico; ideia abstrata, história ou explicação: deixe vazio; o vídeo tem UMA identidade (2 chãos, 1 acento, 2 fontes) e o fundo só troca na virada de seção — se o roteiro tem atos, dê secao:"nome curto" nas partes, senão o PrimoCode divide; moldura cantos|completa|false: o quadro fixo do reel (cantos, o assunto no alto, "01 · seção", o tempo) — ponha só se o vídeo pede cara de peça de design; vazio, o PrimoCode alterna com memória e às vezes não põe; heroi em cada parte: o desenho da batida além do texto, tirado do CONTEÚDO — um ícone do Iconify do objeto que a fala cita ("mdi:bread-slice", "mdi:qrcode"; procure com estudio_midia acao:icones), um 3D do kit (raio|relogio|check|balao|escudo|coracao|estrela|sol|play|engrenagem|cadeado|barras|fone|onda|celular|seta|bateria) ou "nenhum" quando a tipografia basta; forma abstrata (explosao|poligono|esfera|toro|truchet|aneis|grade|rastro) só em assunto abstrato ou de tecnologia — numa conversa de padaria, nunca; ambiente limpo|luz|bokeh e acento traco|viagem (o ponto que viaja entre as batidas): vazio, o PrimoCode escolhe com memória e o bokeh quase não sai fora de tecnologia) | classico (o motion 3D antigo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
|
|
97
|
+
+ '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; fontesFluido aprovado|editorial|revista|carater|tecnico|classico, vazio gira) | impacto | cinema. '
|
|
97
98
|
+ 'marca e lancamento aceitam visual:orbe|vetor|estrela|esfera|grade|site|padrao|cromo|cartoes|painel|cores numa parte. marca.nome dá o nome. '
|
|
98
|
-
+ 'voz (thalita|antonio|francisca). Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
|
|
99
|
+
+ 'voz (thalita|antonio|francisca). DUAS VOZES (o pedido fala em dois narradores, diálogo, conversa, entrevista): roteiro.vozes {"nome":"antonio","outro":"thalita"} e falante:"nome" em cada parte — cada um sai na sua voz. Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
|
|
99
100
|
+ 'cena (opcional): o número de UMA cena para trocar só ela, sem reescrever o resto. '
|
|
100
101
|
+ 'motivo (opcional): fica no caderno. voltarPara (opcional): nome de uma versão. '
|
|
101
102
|
+ 'Sem plano nem cenas, LÊ o plano atual. '
|
|
102
103
|
+ 'Cada elemento é {tipo: texto|forma|imagem|video|icone|legenda|componente|kit} com x, y, cor, tamanho e '
|
|
103
104
|
+ '"anima": {propriedade: [{t,v,e}]} — quadros-chave em segundos. A cena tem "camera": {zoom,x,y,rotacao} e '
|
|
104
|
-
+ '"perspectiva" para 3D.
|
|
105
|
+
+ '"perspectiva" para 3D. Cena.transicao {tipo,duracao}: corte|esmaecer|deslizar|zoom|giro|mascara|morph|chicote|soco|flash|liquido, '
|
|
106
|
+
+ 'e as 3D virar|cubo|profundidade|inclinar (mexem nas duas cenas) — use 3D se o vídeo pede volume (produto, site, tecnologia); em editorial plano, prefira as planas. '
|
|
107
|
+
+ 'NÃO existe layout pronto nem tema — cada propriedade do vídeo é escolha sua, a cada vídeo.' },
|
|
105
108
|
|
|
106
109
|
{ tool: 'estudio_midia', escreve: true, faz: 'procura e baixa ícone, imagem, música e efeito sonoro da internet, e lista as fontes',
|
|
107
|
-
args: 'acao: imagem | icones | som | trilha | efeito | fontes. termo. id (para baixar). '
|
|
110
|
+
args: 'acao: imagem | icones | som | trilha | efeito | fontes | print. termo. id (para baixar). '
|
|
111
|
+
+ 'print: url — tira o print REAL do site e devolve o src e um elemento {tipo:"kit", peca:"printsite"} pronto '
|
|
112
|
+
+ '(o site de verdade numa janela 3D que rola a página; celular: true para a tela do celular; inteira: false só a primeira dobra). '
|
|
113
|
+
+ 'Use SE o pedido trouxer um site e o vídeo for SOBRE ele (lançamento, tour, case); vídeo que só cita a marca não precisa. '
|
|
114
|
+
+ 'efeitos: lista os componentes guardados (os menos usados primeiro). usar-efeito {nome}: copia para o projeto e devolve o elemento — use se um deles servir a ESTE roteiro. '
|
|
115
|
+
+ 'guardar-efeito {arquivo, nome, descricao}: guarda um componente que você escreveu e ficou bom, para os próximos vídeos '
|
|
116
|
+
+ '(o componente: export default de uma função React que recebe {el, t, fps}, num arquivo só, importando só react, remotion, @remotion/*, three). '
|
|
108
117
|
+ 'baixar: o número do item da busca (1 é o primeiro). transparente: true para PNG recortado. '
|
|
109
118
|
+ 'icones: sem "nome", procura; com "nome" ("lucide:zap"), baixa. '
|
|
110
119
|
+ 'Tudo é BAIXADO para a pasta do projeto — o plano nunca aponta para a internet.' },
|
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 });
|
|
@@ -418,6 +492,6 @@ module.exports = {
|
|
|
418
492
|
extensaoPorConteudo,
|
|
419
493
|
pegar, guardar,
|
|
420
494
|
icone, procurarIcones, iconesDoPlano, COLECOES, procurarVideos, procurarMusica,
|
|
421
|
-
procurarImagens, procurarSom, urlSom, baixar,
|
|
495
|
+
procurarImagens, procurarEditorial, procurarWikimedia, procurarArtic, buscasMenores, procurarSom, urlSom, baixar,
|
|
422
496
|
FONTES, conferirFonte,
|
|
423
497
|
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assinatura.js — o estilo como um ARQUIVO com nome e números, que o vídeo
|
|
3
|
+
* novo é conferido contra e que guarda o que a pessoa disse dos vídeos.
|
|
4
|
+
*
|
|
5
|
+
* Do desmontado do Forseth (rookies-lab, 07/10): o estilo vira um arquivo
|
|
6
|
+
* nomeado, e o feedback da pessoa fica gravado nele. O que faltava lá: o
|
|
7
|
+
* estilo era prosa, sem número — ninguém conferia se o vídeo seguiu.
|
|
8
|
+
*
|
|
9
|
+
* A assinatura de fábrica mora em lib/estudio/assinaturas/<nome>.json: as
|
|
10
|
+
* faixas medidas do vídeo que o Joel aprovou (08/10). A da pessoa mora em
|
|
11
|
+
* ~/.primocode/studio/assinaturas/<nome>.json e guarda só o que é dela: as
|
|
12
|
+
* faixas que ela ajustou e as notas ("adorei a virada", "texto demais").
|
|
13
|
+
* Lidas juntas: a dela por cima da de fábrica.
|
|
14
|
+
*
|
|
15
|
+
* medir(partitura, plano) os números do vídeo (os mesmos da assinatura)
|
|
16
|
+
* conferir(nome, medidas) o que saiu da faixa, em uma linha cada
|
|
17
|
+
* anotar(nome, texto) o feedback da pessoa, com a data
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const FABRICA = path.join(__dirname, 'assinaturas');
|
|
27
|
+
const pastaDaPessoa = () => path.join(os.homedir(), '.primocode', 'studio', 'assinaturas');
|
|
28
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
29
|
+
|
|
30
|
+
function lerJson(arq) { try { return JSON.parse(fs.readFileSync(arq, 'utf8')); } catch { return null; } }
|
|
31
|
+
|
|
32
|
+
/** A assinatura pelo nome: a de fábrica com a da pessoa por cima. null se não existe nenhuma. */
|
|
33
|
+
function ler(nome) {
|
|
34
|
+
const n = String(nome || '').toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
35
|
+
if (!n) return null;
|
|
36
|
+
const base = lerJson(path.join(FABRICA, n + '.json'));
|
|
37
|
+
const dela = lerJson(path.join(pastaDaPessoa(), n + '.json'));
|
|
38
|
+
if (!base && !dela) return null;
|
|
39
|
+
const a = { nome: n, faixas: {}, notas: [], ...(base || {}) };
|
|
40
|
+
if (dela) {
|
|
41
|
+
for (const [k, v] of Object.entries(dela.faixas || {})) a.faixas[k] = { ...(a.faixas[k] || {}), ...v };
|
|
42
|
+
a.notas = [...(a.notas || []), ...(dela.notas || [])];
|
|
43
|
+
}
|
|
44
|
+
return a;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const mediana = (xs) => { const s = [...xs].sort((a, b) => a - b); return s.length ? (s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2) : 0; };
|
|
48
|
+
|
|
49
|
+
/* Uma "frase" na tela: no fluido, b.frases[].palavras; no gerativo, cada
|
|
50
|
+
linha (b.linhas[], com .palavras ou só .texto). */
|
|
51
|
+
const frasesDe = (b) => [...(b.frases || []), ...(b.linhas || [])]
|
|
52
|
+
.map((f) => (Array.isArray(f.palavras) ? f.palavras.length : String(f.texto || '').split(/\s+/).filter(Boolean).length));
|
|
53
|
+
|
|
54
|
+
/** Os números de um vídeo (fluido ou gerativo), da partitura e do plano. */
|
|
55
|
+
function medir(partitura, plano) {
|
|
56
|
+
const B = (partitura && partitura.batidas) || [];
|
|
57
|
+
const dur = Number(partitura && partitura.duracao) || 0;
|
|
58
|
+
if (!B.length || !dur) return null;
|
|
59
|
+
const palavras = B.flatMap(frasesDe).filter((n) => n > 0);
|
|
60
|
+
const efeitos = ((plano && plano.audio && plano.audio.efeitos) || []).length;
|
|
61
|
+
return {
|
|
62
|
+
batida_mediana_s: r2(mediana(B.map((b) => b.ate - b.de))),
|
|
63
|
+
palavras_por_frase_max: Math.max(0, ...palavras),
|
|
64
|
+
palavras_por_frase_mediana: mediana(palavras),
|
|
65
|
+
// Corte seco: a transição do fluido, ou o fundo do gerativo que troca por corte.
|
|
66
|
+
cortes_secos: B.filter((b, i) => i > 0 && (b.transicao === 'corte' || b.troca === 'corte')).length,
|
|
67
|
+
fracao_gigante: r2(B.filter((b) => b.gigante || b.comp === 'gigante').length / B.length),
|
|
68
|
+
// Virada de fundo: a marcada no fluido, ou o fundo do gerativo que troca (corte, cortina ou inundação).
|
|
69
|
+
fracao_virada: r2(B.filter((b, i) => b.virada || (i > 0 && b.troca)).length / B.length),
|
|
70
|
+
efeitos_por_minuto: r2(efeitos / dur * 60),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** O que saiu da faixa: [{faixa, valor, min, max, diz}]. Vazio = o vídeo seguiu a assinatura. */
|
|
75
|
+
function conferir(nome, medidas) {
|
|
76
|
+
const a = ler(nome);
|
|
77
|
+
if (!a || !medidas) return { assinatura: null, fora: [] };
|
|
78
|
+
const fora = [];
|
|
79
|
+
for (const [k, f] of Object.entries(a.faixas || {})) {
|
|
80
|
+
const v = medidas[k];
|
|
81
|
+
if (v == null) continue;
|
|
82
|
+
if ((f.min != null && v < f.min) || (f.max != null && v > f.max)) fora.push({ faixa: k, valor: v, min: f.min, max: f.max, diz: f.diz });
|
|
83
|
+
}
|
|
84
|
+
return { assinatura: a.nome, fora };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Uma linha por faixa estourada, para o agente e o caderno. */
|
|
88
|
+
function descrever(fora) {
|
|
89
|
+
return fora.map((x) => `${x.faixa} = ${x.valor} (a assinatura pede ${x.min != null ? x.min : '…'} a ${x.max != null ? x.max : '…'}: ${x.diz || ''})`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** O feedback da pessoa fica na assinatura dela, com a data. Devolve quantas notas ela tem. */
|
|
93
|
+
function anotar(nome, texto, quando = new Date()) {
|
|
94
|
+
const n = String(nome || '').toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
95
|
+
const t = String(texto || '').trim();
|
|
96
|
+
if (!n || !t) return { ok: false, error: 'a assinatura precisa de nome e de texto.' };
|
|
97
|
+
const arq = path.join(pastaDaPessoa(), n + '.json');
|
|
98
|
+
const dela = lerJson(arq) || { nome: n, faixas: {}, notas: [] };
|
|
99
|
+
dela.notas = [...(dela.notas || []), { quando: quando.toISOString().slice(0, 10), texto: t }].slice(-40);
|
|
100
|
+
try { fs.mkdirSync(pastaDaPessoa(), { recursive: true }); fs.writeFileSync(arq, JSON.stringify(dela, null, 2)); }
|
|
101
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
102
|
+
return { ok: true, assinatura: n, notas: dela.notas.length };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** As últimas notas, para ir na frente do pedido (o agente lê antes de fazer o próximo vídeo). */
|
|
106
|
+
function notasRecentes(nome, n = 6) { const a = ler(nome); return a ? (a.notas || []).slice(-n) : []; }
|
|
107
|
+
|
|
108
|
+
module.exports = { ler, medir, conferir, descrever, anotar, notasRecentes, FABRICA };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nome": "fluido",
|
|
3
|
+
"estilo": "fluido",
|
|
4
|
+
"origem": "o vídeo que o Joel aprovou em 08/10/2026 (produtividade-comeca-na-noite-anterior, 35,7 s), medido",
|
|
5
|
+
"descricao": "Uma ideia por batida, pouco texto, nada de corte seco: a forma de uma batida vira a da próxima. Contraste de escala (palavra gigante) e a virada de fundo nos momentos fortes. O som marca o movimento sem virar ruído.",
|
|
6
|
+
"faixas": {
|
|
7
|
+
"batida_mediana_s": { "min": 2.5, "max": 5.5, "medido": 4.0, "diz": "cada batida dura de 3 a 4,5 s" },
|
|
8
|
+
"palavras_por_frase_max": { "max": 9, "medido": 7, "diz": "no máximo 7 palavras por frase" },
|
|
9
|
+
"palavras_por_frase_mediana": { "max": 4, "medido": 2, "diz": "o normal é 2 palavras por frase" },
|
|
10
|
+
"cortes_secos": { "max": 0, "medido": 0, "diz": "zero cortes secos, tudo morfa" },
|
|
11
|
+
"fracao_gigante": { "min": 0.25, "max": 0.65, "medido": 0.56, "diz": "palavra gigante em perto da metade das batidas" },
|
|
12
|
+
"fracao_virada": { "min": 0.1, "max": 0.4, "medido": 0.33, "diz": "virada de fundo em ~1 de cada 3 batidas" },
|
|
13
|
+
"efeitos_por_minuto": { "min": 12, "max": 40, "medido": 26.9, "diz": "uns 27 efeitos sonoros por minuto" }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nome": "gerativo",
|
|
3
|
+
"estilo": "gerativo",
|
|
4
|
+
"origem": "o vídeo que o Joel aprovou em 08/10/2026 (teste-e2e-sono, o gerativo do PR #113, 30,3 s), medido",
|
|
5
|
+
"descricao": "Batida curta e seca, pouco texto por linha, numa identidade só (2 chãos, 1 acento, 2 fontes): o fundo troca na virada de seção, a primeira por corte seco. A palavra gigante entra em ~1 de cada 3 batidas. O som é desenhado da semente e fica mais raro que no fluido.",
|
|
6
|
+
"faixas": {
|
|
7
|
+
"batida_mediana_s": { "min": 2, "max": 4.5, "medido": 3.18, "diz": "cada batida dura de 1,7 a 4,5 s, o normal é ~3 s" },
|
|
8
|
+
"palavras_por_frase_max": { "max": 5, "medido": 3, "diz": "no máximo 3 palavras por linha" },
|
|
9
|
+
"palavras_por_frase_mediana": { "max": 3, "medido": 2, "diz": "o normal é 1 a 2 palavras por linha" },
|
|
10
|
+
"cortes_secos": { "min": 1, "medido": 5, "diz": "o fundo troca por corte seco (5 das 9 trocas)" },
|
|
11
|
+
"fracao_gigante": { "min": 0.15, "max": 0.5, "medido": 0.3, "diz": "palavra gigante em ~1 de cada 3 batidas" },
|
|
12
|
+
"fracao_virada": { "min": 0, "max": 0.45, "medido": 0.8, "revisto": "09/10/2026: o Joel reprovou o fundo trocando toda batida no vídeo do Pix na padaria (\"do nada preto, do nada branco\"). A faixa saiu de 0,5–1 para 0–0,45; o medido continua o do vídeo de 08/10, que agora fica fora SÓ nesta faixa.", "diz": "o fundo troca só na virada de seção, entre os 2 chãos da identidade (uns 2 a cada 10 batidas)" },
|
|
13
|
+
"efeitos_por_minuto": { "min": 6, "max": 30, "medido": 15.84, "diz": "uns 16 efeitos sonoros por minuto" }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -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 };
|