primocode 10.13.0 → 10.14.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 +66 -12
- package/bin/primocode.js +148 -8
- package/lib/act.js +48 -1
- package/lib/catalogo.js +12 -1
- package/lib/config.js +1 -0
- package/lib/estudio/cota.js +150 -0
- package/lib/estudio/dados/projetos/clube-ad5.json +105 -0
- package/lib/estudio/dados/projetos/clube-ae-sem-memoria.json +107 -0
- package/lib/estudio/dados/projetos/clube-agent.json +120 -0
- package/lib/estudio/dados/projetos/clube-antes-e-agora.json +86 -0
- package/lib/estudio/dados/projetos/clube-apresentando-clube.json +110 -0
- package/lib/estudio/dados/projetos/clube-bluevisa.json +106 -0
- package/lib/estudio/dados/projetos/clube-caught-in-the-fire.json +127 -0
- package/lib/estudio/dados/projetos/clube-chat-cliente-satisfeito.json +98 -0
- package/lib/estudio/dados/projetos/clube-chatgpt-anuncios.json +120 -0
- package/lib/estudio/dados/projetos/clube-claude-dj.json +140 -0
- package/lib/estudio/dados/projetos/clube-claude-mcp.json +142 -0
- package/lib/estudio/dados/projetos/clube-cleanui.json +106 -0
- package/lib/estudio/dados/projetos/clube-clinica-take-unico.json +128 -0
- package/lib/estudio/dados/projetos/clube-clinica.json +104 -0
- package/lib/estudio/dados/projetos/clube-clube-opener.json +125 -0
- package/lib/estudio/dados/projetos/clube-clube.json +108 -0
- package/lib/estudio/dados/projetos/clube-codigo.json +101 -0
- package/lib/estudio/dados/projetos/clube-distilbook.json +104 -0
- package/lib/estudio/dados/projetos/clube-drive-upload.json +115 -0
- package/lib/estudio/dados/projetos/clube-dub.json +119 -0
- package/lib/estudio/dados/projetos/clube-enxovaly.json +105 -0
- package/lib/estudio/dados/projetos/clube-fecha7.json +118 -0
- package/lib/estudio/dados/projetos/clube-filatov.json +128 -0
- package/lib/estudio/dados/projetos/clube-frame.json +108 -0
- package/lib/estudio/dados/projetos/clube-glass.json +118 -0
- package/lib/estudio/dados/projetos/clube-letra-em-apps.json +84 -0
- package/lib/estudio/dados/projetos/clube-letra-globo-3d.json +111 -0
- package/lib/estudio/dados/projetos/clube-letra-nos-apps.json +146 -0
- package/lib/estudio/dados/projetos/clube-letra-seis-mundos.json +100 -0
- package/lib/estudio/dados/projetos/clube-lula-3.json +146 -0
- package/lib/estudio/dados/projetos/clube-morph.json +119 -0
- package/lib/estudio/dados/projetos/clube-motion-designer.json +115 -0
- package/lib/estudio/dados/projetos/clube-motion-pacote.json +112 -0
- package/lib/estudio/dados/projetos/clube-movimento.json +103 -0
- package/lib/estudio/dados/projetos/clube-not-like-us.json +117 -0
- package/lib/estudio/dados/projetos/clube-pinterest-inspiracao.json +86 -0
- package/lib/estudio/dados/projetos/clube-pixel-e-tinta.json +108 -0
- package/lib/estudio/dados/projetos/clube-ponte.json +118 -0
- package/lib/estudio/dados/projetos/clube-processo-edit-tonal.json +126 -0
- package/lib/estudio/dados/projetos/clube-processo-motion-100-ia.json +109 -0
- package/lib/estudio/dados/projetos/clube-promo.json +114 -0
- package/lib/estudio/dados/projetos/clube-proposito.json +115 -0
- package/lib/estudio/dados/projetos/clube-rankhog.json +128 -0
- package/lib/estudio/dados/projetos/clube-recursao.json +116 -0
- package/lib/estudio/dados/projetos/clube-revelar.json +118 -0
- package/lib/estudio/dados/projetos/clube-robo.json +125 -0
- package/lib/estudio/dados/projetos/clube-shelby-ep2.json +144 -0
- package/lib/estudio/dados/projetos/clube-shelby.json +97 -0
- package/lib/estudio/dados/projetos/clube-solar-eclipse.json +96 -0
- package/lib/estudio/dados/projetos/clube-soldier.json +128 -0
- package/lib/estudio/dados/projetos/clube-som-de-cena.json +108 -0
- package/lib/estudio/dados/projetos/clube-spotify-candidatura.json +112 -0
- package/lib/estudio/dados/projetos/clube-tda-app.json +118 -0
- package/lib/estudio/dados/projetos/clube-tda-ia.json +75 -0
- package/lib/estudio/dados/projetos/clube-telegram-chat.json +118 -0
- package/lib/estudio/dados/projetos/clube-telegram-ilha.json +106 -0
- package/lib/estudio/dados/projetos/clube-tennessee-nike.json +132 -0
- package/lib/estudio/dados/projetos/clube-tudo-se-move.json +112 -0
- package/lib/estudio/dados/projetos/clube-uiad.json +107 -0
- package/lib/estudio/dados/projetos/clube-viralgrow.json +108 -0
- package/lib/estudio/dados/projetos/clube-whose-side.json +140 -0
- package/lib/estudio/dados/projetos/clube-youtube-inscrever.json +108 -0
- package/lib/estudio/dados/projetos/elementos-do-site-em-movimento.json +49 -0
- package/lib/estudio/dados/projetos/explainer-narrado.json +51 -0
- package/lib/estudio/dados/projetos/lancamento-de-produto.json +51 -0
- package/lib/estudio/dados/projetos/motion-so-com-sons.json +50 -0
- package/lib/estudio/dados/projetos/titulo-em-after-effects.json +51 -0
- package/lib/estudio/dados/projetos/tour-de-saas.json +50 -0
- package/lib/estudio/dados/projetos-ae/antes-e-agora/ae/cenas.jsx +733 -0
- package/lib/estudio/dados/projetos-ae/antes-e-agora/assets/fazer_assets.py +88 -0
- package/lib/estudio/dados/projetos-ae/letra-em-apps/ae/cenas.jsx +402 -0
- package/lib/estudio/dados/projetos-ae/letra-em-apps/assets/preparar_assets.py +17 -0
- package/lib/estudio/dados/projetos-ae/letra-em-apps/assets/svg_para_ae.py +25 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/BRIEF-AGENTES.md +31 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/README.md +53 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ROTEIRO-CENAS.md +69 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/A-agenda.jsx +148 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/B-docs.jsx +174 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/C-alttab.jsx +113 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/D-spotify.jsx +118 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/E-notas.jsx +146 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/F-jornal1.jsx +144 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/G-google.jsx +153 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/H-mapa.jsx +176 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/I-imagens.jsx +132 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/J-jornal2.jsx +191 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/K-maps.jsx +184 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/camera-movimentos.jsx +48 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/camera.jsx +57 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/card-spotify.jsx +98 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/digitar.jsx +38 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/jornal-comum.jsx +196 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/letra.jsx +23 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/letra_automatica.py +83 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/montagem.jsx +74 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/w-agenda/render_html.py +13 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/w-agenda/util.jsx +162 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/w-midia-comum.jsx +114 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/ae/wg-util.jsx +360 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/assets/F-jornal1/render_html.py +13 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/assets/render_w-midia.py +26 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/assets/w-midia-ferramentas/inclin.py +30 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/assets/w-midia-ferramentas/medir_d.py +36 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/assets/w-midia-ferramentas/medir_e.py +27 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/ajustar_movimentos.py +61 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/ancorar.py +74 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/assar.py +47 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/camera3d_jornal.py +172 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/comparar.py +32 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/dados_ae_jornal.py +46 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/folha_contato.py +28 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/homografia_jornal.py +74 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/integrar.py +44 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/medir_quadro_a_quadro.py +32 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/mesa_2d.py +64 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/plot_cenas.py +11 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/retificar_quadros.py +38 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/w-agenda/assar_ab.py +52 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/w-agenda/medir_ab.py +59 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/wg_ajustar_h.py +111 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/wg_corrige_cam.py +17 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/wg_ecc_cadeia.py +80 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/wg_finalizar_cam.py +65 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/camera/wg_mosaico.py +61 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/guia-do-metodo/tutorial-lyric-edit-interfaces/01-gramatica-e-roteiro.md +55 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/guia-do-metodo/tutorial-lyric-edit-interfaces/02-camera.md +66 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/guia-do-metodo/tutorial-lyric-edit-interfaces/03-montagem-no-ae.md +50 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/guia-do-metodo/tutorial-lyric-edit-interfaces/04-render-e-conferencia.md +43 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/guia-do-metodo/tutorial-lyric-edit-interfaces/05-abertura-celular-mergulho.md +43 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/guia-do-metodo/tutorial-lyric-edit-interfaces.md +42 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/transicao/atraso_imagem.py +31 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/transicao/fundo.py +56 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/transicao/juntar.py +45 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/transicao/montar.py +177 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/transicao/rastreio.py +74 -0
- package/lib/estudio/dados/projetos-ae/letra-nos-apps/transicao/sincronia.py +18 -0
- package/lib/estudio/dados/projetos-ae/pinterest-inspiracao/ae/cenas.jsx +530 -0
- package/lib/estudio/dados/projetos-ae/pinterest-inspiracao/assets/fazer_assets.py +107 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/assets/pessoas/figurinha.py +46 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/BRIEF-A.md +83 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/audio.py +179 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/confere.py +20 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/dados/tempo-parte1.js +1 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/dados/tempo-parte2.js +1 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/gerar_emojis.py +11 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/img/filmes/_indice.js +122 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/js/base.js +380 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/js/main.js +86 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/js/p1a.js +169 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/js/p1b.js +455 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/js/p1c.js +389 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/js/p2.js +379 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/comp-a/video.py +62 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/take-unico/README.md +65 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/take-unico/oraculo_take.py +137 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/take-unico/render_take.py +388 -0
- package/lib/estudio/dados/projetos-ae/processo-motion-100-ia/take-unico/take.js +235 -0
- package/lib/estudio/editor.js +285 -0
- package/lib/estudio/effectcraft.js +152 -0
- package/lib/estudio/exportar.js +181 -0
- package/lib/estudio/index.js +90 -2
- package/lib/estudio/kit-ae/base.jsx +1146 -0
- package/lib/estudio/kit-ae/cenas.jsx +395 -0
- package/lib/estudio/kit-ae/primitivas.jsx +911 -0
- package/lib/estudio/maquina-diretor.js +393 -0
- package/lib/estudio/maquina-elementos.js +134 -0
- package/lib/estudio/maquina-layout.js +198 -0
- package/lib/estudio/maquina-som.js +478 -0
- package/lib/estudio/maquina-trilha.js +160 -0
- package/lib/estudio/maquina-voz.js +429 -0
- package/lib/estudio/maquina.js +516 -0
- package/lib/estudio/projetos.js +60 -0
- package/lib/headless.js +71 -0
- package/lib/hooks.js +72 -0
- package/lib/local/cerebro.json +398 -12
- package/lib/local/entrada.js +46 -2
- package/lib/marca-kit.js +309 -0
- package/lib/mcp.js +286 -6
- package/lib/perigo.js +31 -3
- package/lib/planos.js +47 -19
- package/lib/preparar.js +111 -0
- package/lib/retomar.js +55 -0
- package/lib/sandbox.js +197 -0
- package/lib/skills.js +3 -1
- package/lib/tools.js +13 -4
- package/lib/ui.js +1 -1
- package/package.json +4 -2
- package/skills/after-effects/SKILL.md +52 -0
- package/skills/maquina-de-motion/SKILL.md +84 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* maquina-som.js — o som de verdade do motion: efeitos CC0 reais, escolhidos POR AÇÃO e por assunto.
|
|
3
|
+
*
|
|
4
|
+
* Os efeitos sintetizados (sons-leves.js) soavam genéricos. Aqui a biblioteca vem do Openverse
|
|
5
|
+
* (freesound, licença cc0/pdm), guardada em ~/.primocode/studio/sfx/<categoria>/<id>.mp3 com a licença
|
|
6
|
+
* ao lado (<id>.licenca.json). O kit JSX escreve o EVENTO ("sino", "notif", "troca"…); o Node decide
|
|
7
|
+
* QUAL arquivo toca: nunca o mesmo em sequência, nunca o mesmo numa janela de 1,5 s, e o assunto muda
|
|
8
|
+
* a preferência (padaria: forno e sino; clínica: notificação e toque; app: teclas e clique).
|
|
9
|
+
*
|
|
10
|
+
* prepararBiblioteca({assunto, acoes, pasta, rede, minPorCategoria}) → índice {categoria:[{arquivo,...}]}
|
|
11
|
+
* escolherSons(eventos, {assunto, indice, semente}) → [{t, arquivo|null, tipo, ganho, acao, rate}]
|
|
12
|
+
* encaixarNaBatida(sons, batidas, {tolerancia}) → sons com o t puxado para a batida
|
|
13
|
+
* mixar({pasta, mudo, saida, sons, trilha, voz, duracao}) → {ok, video, lufs, eventos, reservas, ...}
|
|
14
|
+
*
|
|
15
|
+
* Sem rede e sem cache, `arquivo` vem null e o mixar usa o som leve sintetizado (reserva).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { spawnSync } = require('child_process');
|
|
24
|
+
|
|
25
|
+
const PASTA_PADRAO = () => path.join(os.homedir(), '.primocode', 'studio', 'sfx');
|
|
26
|
+
const API = 'https://api.openverse.org/v1/audio/';
|
|
27
|
+
|
|
28
|
+
/** Categoria → consultas (em inglês, é como o freesound indexa) e duração máxima do arquivo final. */
|
|
29
|
+
const CATEGORIAS = {
|
|
30
|
+
whoosh: { q: ['whoosh', 'whoosh transition', 'air whoosh'], max: 0.8 },
|
|
31
|
+
swoosh: { q: ['swoosh', 'swish', 'fast swipe'], max: 0.7 },
|
|
32
|
+
pop: { q: ['pop', 'bubble pop', 'cartoon pop'], max: 0.6 },
|
|
33
|
+
click: { q: ['mouse click', 'ui click', 'button click'], max: 0.4 },
|
|
34
|
+
tap: { q: ['tap', 'finger tap', 'touch screen tap'], max: 0.5 },
|
|
35
|
+
typing: { q: ['keyboard typing', 'typing keys', 'typewriter key'], max: 1.6 },
|
|
36
|
+
notification: { q: ['notification', 'message alert ding', 'phone notification'], max: 1.6 },
|
|
37
|
+
riser: { q: ['riser', 'build up riser', 'sweep rise'], max: 3.5 },
|
|
38
|
+
impact: { q: ['impact', 'boom hit', 'cinematic hit'], max: 1.8 },
|
|
39
|
+
bell: { q: ['bell ding', 'chime', 'service bell'], max: 2.0 },
|
|
40
|
+
cash: { q: ['cash register', 'coin', 'money cha-ching', 'coin drop', 'coins jingle', 'ka-ching'], max: 1.8 },
|
|
41
|
+
paper: { q: ['paper rustle', 'paper flip', 'page turn'], max: 1.2 },
|
|
42
|
+
glass: { q: ['glass clink', 'glass ping', 'glass tap'], max: 1.4 },
|
|
43
|
+
oven: { q: ['oven timer', 'oven ding timer', 'kitchen timer', 'microwave beep', 'timer bell ring', 'oven door'], max: 2.0 },
|
|
44
|
+
// o banco de MOTION: o que dá peso a corte, troca e pouso (Kenney CC0 + Freesound CC0)
|
|
45
|
+
swish: { q: ['swish', 'fast swish', 'cloth swish'], max: 0.6 },
|
|
46
|
+
transicao: { q: ['transition swoosh', 'whoosh transition fast', 'swoosh impact transition'], max: 1.4 },
|
|
47
|
+
downlifter: { q: ['downlifter', 'reverse down sweep', 'power down', 'falling sweep', 'descending whoosh'], max: 2.2 },
|
|
48
|
+
hit: { q: ['hit', 'punch hit', 'cinematic hit short'], max: 1.2 },
|
|
49
|
+
glitch: { q: ['glitch', 'digital glitch', 'data glitch'], max: 0.9 },
|
|
50
|
+
subdrop: { q: ['sub drop', 'bass drop impact', 'low boom', 'sub bass hit', 'deep impact boom'], max: 2.2 },
|
|
51
|
+
shimmer: { q: ['shimmer', 'magic sparkle', 'glitter chime'], max: 1.8 },
|
|
52
|
+
suave: { q: ['soft thud', 'gentle impact soft', 'soft hit pillow'], max: 0.9 },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Ação (o `tipo` do evento SOM do kit) → categorias, da preferida para a reserva. */
|
|
56
|
+
const ACOES = {
|
|
57
|
+
// SFX de motion são ACENTOS, não bombas: whoosh curto, swish, pop, click, tick, shimmer. Sem sub-drop, sem impacto pesado, sem riser.
|
|
58
|
+
troca: ['swish', 'whoosh', 'swoosh'], whoosh: ['whoosh', 'swish'], swoosh: ['swoosh', 'swish', 'whoosh'], sopro: ['swish'], swish: ['swish', 'swoosh'],
|
|
59
|
+
hit: ['suave'], glitch: ['glitch'], shimmer: ['shimmer', 'glass', 'bell'], desce: ['downlifter'],
|
|
60
|
+
pop: ['pop', 'tap'], bolha: ['pop'], ponto1: ['pop', 'tap'], ponto2: ['pop', 'tap'], ponto3: ['pop', 'tap'], ponto4: ['pop', 'tap'], ponto5: ['pop', 'tap'],
|
|
61
|
+
clique: ['click', 'tap'], mouse: ['click'], tap: ['tap', 'click'], toque: ['tap', 'pop'],
|
|
62
|
+
tique: ['click', 'typing'], tick: ['click', 'typing'], digital: ['click', 'typing'], digita: ['typing', 'click'],
|
|
63
|
+
notif: ['notification', 'bell'], sobe: ['swish'], subida: ['swish'],
|
|
64
|
+
impacto: ['suave'], sub: ['suave'],
|
|
65
|
+
brilho: ['shimmer', 'bell', 'glass'], sino: ['bell', 'glass'], caixa: ['cash', 'bell'], papel: ['paper'], vidro: ['glass', 'bell'],
|
|
66
|
+
};
|
|
67
|
+
/** Arquivos que não entram mais (pesados, de motor, laser, explosão, impacto de metal/soco): o Joel achou "explosivo". */
|
|
68
|
+
const BLOQUEADOS = /^k-(space|engine|thruster|door|laser|explosion|lowfrequency|lowdown|impactmetal|impactplate|impactpunch|impactmining|impacttin|impactwood|impactbell|forcefield-00[34]|powerup|phaseup)/;
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
/** O assunto muda a preferência: regex → categorias a garantir na biblioteca e ações que preferem outras categorias. */
|
|
72
|
+
const TEMAS = [
|
|
73
|
+
{ nome: 'padaria', re: /p[aã]o|padaria|forno|confeit|bolo|caf[eé]|cozinha|restaurante|doce|pizza/i, extras: ['oven', 'bell', 'paper'], prefere: { sino: ['bell', 'oven'], brilho: ['bell'], notif: ['bell', 'oven'], pop: ['pop'], toque: ['bell', 'tap'] } },
|
|
74
|
+
{ nome: 'clinica', re: /cl[ií]nica|est[eé]tica|beleza|sal[aã]o|spa|pele|sa[uú]de|m[eé]dic|dent|cliente|agenda/i, extras: ['notification', 'glass', 'bell'], prefere: { notif: ['notification'], brilho: ['glass', 'bell'], sino: ['glass', 'bell'], toque: ['tap', 'notification'], vidro: ['glass'] } },
|
|
75
|
+
{ nome: 'financas', re: /finan|dinheiro|invest|banco|cr[eé]dito|gasto|or[cç]amento|cart[aã]o|poupan|moeda|cash/i, extras: ['cash', 'notification', 'click', 'typing'], prefere: { caixa: ['cash'], brilho: ['cash', 'bell'], sino: ['cash', 'bell'], notif: ['notification'], tique: ['click'], digita: ['typing'] } },
|
|
76
|
+
{ nome: 'software', re: /app\b|saas|software|c[oó]digo|program|tecnolog|\bia\b|intelig|primocode|sistema|plataforma|dados|site|digital|startup/i, extras: ['typing', 'click', 'riser'], prefere: { digita: ['typing'], clique: ['click'], tique: ['click', 'typing'], sobe: ['riser'], brilho: ['glass', 'bell'], sino: ['notification', 'bell'] } },
|
|
77
|
+
];
|
|
78
|
+
const temaDe = (assunto) => TEMAS.find((t) => t.re.test(String(assunto || ''))) || null;
|
|
79
|
+
|
|
80
|
+
// ── utilidades ───────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function ff(args, { timeout = 60000 } = {}) {
|
|
83
|
+
const r = spawnSync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error', ...args], { encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024 });
|
|
84
|
+
return { ok: r.status === 0, saida: ((r.stdout || '') + (r.stderr || '')).trim() };
|
|
85
|
+
}
|
|
86
|
+
function duracaoDe(arq) {
|
|
87
|
+
const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', arq], { encoding: 'utf8' });
|
|
88
|
+
const d = parseFloat(String(r.stdout || '').trim());
|
|
89
|
+
return isNaN(d) ? null : d;
|
|
90
|
+
}
|
|
91
|
+
function picoDe(arq) {
|
|
92
|
+
const r = spawnSync('ffmpeg', ['-hide_banner', '-nostats', '-i', arq, '-af', 'volumedetect', '-f', 'null', '-'], { encoding: 'utf8' });
|
|
93
|
+
const m = String(r.stderr || '').match(/max_volume:\s*(-?[\d.]+) dB/);
|
|
94
|
+
return m ? Number(m[1]) : null;
|
|
95
|
+
}
|
|
96
|
+
/** O LUFS integrado do arquivo (ebur128), ou null (silêncio total). */
|
|
97
|
+
function medirLufs(arq) {
|
|
98
|
+
const r = spawnSync('ffmpeg', ['-hide_banner', '-nostats', '-i', arq, '-vn', '-af', 'ebur128=peak=true', '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
|
|
99
|
+
const s = String(r.stderr || '');
|
|
100
|
+
const m = s.slice(s.lastIndexOf('Summary:')).match(/I:\s+(-?\d+(?:\.\d+)?)\s+LUFS/);
|
|
101
|
+
return m ? Number(m[1]) : null;
|
|
102
|
+
}
|
|
103
|
+
function mulberry(a) {
|
|
104
|
+
let s = (a >>> 0) || 1;
|
|
105
|
+
return () => { s |= 0; s = (s + 0x6D2B79F5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── a biblioteca ─────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/** Lê o que há em disco: {categoria:[{arquivo, titulo, autor, licenca, segundos, id}]}. A pasta é a verdade; o indice.json é cópia. */
|
|
111
|
+
function lerIndice(pasta = PASTA_PADRAO()) {
|
|
112
|
+
const indice = {};
|
|
113
|
+
if (!fs.existsSync(pasta)) return indice;
|
|
114
|
+
for (const cat of fs.readdirSync(pasta, { withFileTypes: true })) {
|
|
115
|
+
if (!cat.isDirectory() || cat.name.startsWith('_')) continue;
|
|
116
|
+
const dir = path.join(pasta, cat.name);
|
|
117
|
+
for (const f of fs.readdirSync(dir).sort()) {
|
|
118
|
+
if (!f.endsWith('.mp3')) continue;
|
|
119
|
+
let ficha = {};
|
|
120
|
+
try { ficha = JSON.parse(fs.readFileSync(path.join(dir, f.replace(/\.mp3$/, '.licenca.json')), 'utf8')); } catch { /* sem ficha */ }
|
|
121
|
+
(indice[cat.name] = indice[cat.name] || []).push({ id: f.replace(/\.mp3$/, ''), arquivo: path.join(dir, f), titulo: ficha.titulo || f, autor: ficha.autor || null, licenca: ficha.licenca || null, segundos: ficha.segundos || duracaoDe(path.join(dir, f)) });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return indice;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function pedirJson(url, timeout = 20000) {
|
|
128
|
+
const r = await fetch(url, { headers: { 'user-agent': 'primocode-estudio' }, signal: AbortSignal.timeout(timeout) });
|
|
129
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
130
|
+
return r.json();
|
|
131
|
+
}
|
|
132
|
+
async function baixarBytes(url, timeout = 30000) {
|
|
133
|
+
const r = await fetch(url, { headers: { 'user-agent': 'primocode-estudio' }, redirect: 'follow', signal: AbortSignal.timeout(timeout) });
|
|
134
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
135
|
+
return Buffer.from(await r.arrayBuffer());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Corta o silêncio do começo, limita a duração, põe fade-out e leva o pico a −3 dB (mono, 48 kHz).
|
|
140
|
+
* Devolve {ok, segundos} ou {ok:false} quando o arquivo é mudo ou curto demais.
|
|
141
|
+
*/
|
|
142
|
+
function tratar(entrada, saida, max) {
|
|
143
|
+
const tmp = saida + '.tmp.wav';
|
|
144
|
+
const a = ff(['-i', entrada, '-ac', '1', '-ar', '48000', '-af', `silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.02,atrim=0:${max}`, tmp]);
|
|
145
|
+
if (!a.ok) return { ok: false, error: a.saida.slice(-200) };
|
|
146
|
+
try {
|
|
147
|
+
const dur = duracaoDe(tmp), pico = picoDe(tmp);
|
|
148
|
+
if (!dur || dur < 0.05 || pico == null || pico < -50) return { ok: false, error: 'mudo ou curto demais' };
|
|
149
|
+
const fade = Math.min(0.25, Math.max(0.04, dur * 0.2));
|
|
150
|
+
const b = ff(['-i', tmp, '-af', `volume=${(-3 - pico).toFixed(2)}dB,afade=t=out:st=${Math.max(0, dur - fade).toFixed(3)}:d=${fade.toFixed(3)},alimiter=limit=0.708:level=false`, '-ar', '48000', '-ac', '1', '-q:a', '2', saida]);
|
|
151
|
+
return b.ok ? { ok: true, segundos: Math.round(dur * 100) / 100 } : { ok: false, error: b.saida.slice(-200) };
|
|
152
|
+
} finally { fs.rmSync(tmp, { force: true }); }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
// ── Kenney.nl (CC0): o banco de motion de verdade ─────────────────────────
|
|
157
|
+
|
|
158
|
+
const KENNEY = ['impact-sounds', 'interface-sounds', 'ui-audio', 'sci-fi-sounds', 'digital-audio'];
|
|
159
|
+
/** Nome do arquivo → categoria (a primeira regra que casa; cada arquivo vai para UMA categoria só). */
|
|
160
|
+
const KENNEY_REGRAS = [
|
|
161
|
+
['bell', /impactBell/], ['glass', /impactGlass_heavy/], ['shimmer', /^glass_|impactGlass_(light|medium)/],
|
|
162
|
+
['subdrop', /lowFrequency_explosion|^lowDown|spaceEngineLow/], ['downlifter', /phaserDown|^highDown|spaceTrash|^powerDown/],
|
|
163
|
+
['riser', /phaserUp|powerUp|^highUp|spaceEngineSmall|thrusterFire/],
|
|
164
|
+
['glitch', /^glitch_|^scratch_|computerNoise|^lowRandom|zapThreeTone|zapTwoTone/],
|
|
165
|
+
['suave', /impactSoft_(medium|heavy)|impactGeneric_light|impactPlank_medium/],
|
|
166
|
+
['hit', /impactPunch|impactPlate_(heavy|medium)|impactGeneric|impactSoft_heavy|impactMetal_heavy|impactTin|impactWood_heavy|impactMining/],
|
|
167
|
+
['impact', /explosionCrunch|^impactMetal_\d|impactMetal_medium|impactPlank/],
|
|
168
|
+
['whoosh', /forceField/],
|
|
169
|
+
['swish', /^scroll_|^maximize_|^minimize_|^phaseJump/],
|
|
170
|
+
['transicao', /laserLarge|^zap\d|^zap[A-Z]|^back_|^open_|^close_|laserRetro/],
|
|
171
|
+
['notification', /^confirmation_|^question_|^threeTone|^bong|^lowThreeTone/],
|
|
172
|
+
['click', /^click_|^click\d|mouseclick|mouserelease|^switch_|^switch\d|^toggle_/],
|
|
173
|
+
['pop', /^pepSound|^twoTone|^tone1|^pluck/],
|
|
174
|
+
['tap', /^select_|^drop_/],
|
|
175
|
+
['typing', /^tick_|^rollover/],
|
|
176
|
+
];
|
|
177
|
+
const KENNEY_MAX_POR_CAT = 18;
|
|
178
|
+
|
|
179
|
+
function listarArquivos(dir, acc = []) {
|
|
180
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
181
|
+
const p = path.join(dir, e.name);
|
|
182
|
+
if (e.isDirectory()) listarArquivos(p, acc); else if (/\.(ogg|wav|mp3)$/i.test(e.name)) acc.push(p);
|
|
183
|
+
}
|
|
184
|
+
return acc;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Baixa os 5 pacotes do Kenney sob demanda (uma vez), separa por categoria e trata cada arquivo. Sem rede, não faz nada. */
|
|
188
|
+
async function baixarKenney(pasta, { rede = true } = {}) {
|
|
189
|
+
const marca = path.join(pasta, '_kenney', 'pronto.json');
|
|
190
|
+
if (fs.existsSync(marca) || !rede) return { ok: fs.existsSync(marca) };
|
|
191
|
+
const bruto = path.join(pasta, '_kenney');
|
|
192
|
+
fs.mkdirSync(bruto, { recursive: true });
|
|
193
|
+
const porCat = {};
|
|
194
|
+
for (const pacote of KENNEY) {
|
|
195
|
+
try {
|
|
196
|
+
const pagina = `https://kenney.nl/assets/${pacote}`;
|
|
197
|
+
const html = await (await fetch(pagina, { headers: { 'user-agent': 'primocode-estudio' }, signal: AbortSignal.timeout(20000) })).text();
|
|
198
|
+
const zip = (html.match(/https:\/\/kenney\.nl\/media\/pages\/assets\/[^"']*\.zip/) || [])[0];
|
|
199
|
+
if (!zip) continue;
|
|
200
|
+
const dir = path.join(bruto, pacote);
|
|
201
|
+
if (!fs.existsSync(dir)) {
|
|
202
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
203
|
+
const z = path.join(bruto, pacote + '.zip');
|
|
204
|
+
fs.writeFileSync(z, await baixarBytes(zip, 120000));
|
|
205
|
+
spawnSync('unzip', ['-oq', z, '-d', dir]);
|
|
206
|
+
fs.rmSync(z, { force: true });
|
|
207
|
+
}
|
|
208
|
+
for (const arq of listarArquivos(dir)) {
|
|
209
|
+
const nome = path.basename(arq).replace(/\.[a-z0-9]+$/i, '');
|
|
210
|
+
if (/^preview$/i.test(nome)) continue;
|
|
211
|
+
const regra = KENNEY_REGRAS.find(([, re]) => re.test(nome));
|
|
212
|
+
if (regra) (porCat[regra[0]] = porCat[regra[0]] || []).push({ arq, nome, pacote, pagina });
|
|
213
|
+
}
|
|
214
|
+
} catch { /* um pacote que falha não derruba os outros */ }
|
|
215
|
+
}
|
|
216
|
+
let feitos = 0;
|
|
217
|
+
for (const [cat, lista] of Object.entries(porCat)) {
|
|
218
|
+
const def = CATEGORIAS[cat]; if (!def) continue;
|
|
219
|
+
const passo = Math.max(1, lista.length / KENNEY_MAX_POR_CAT);
|
|
220
|
+
const escolhidos = []; for (let x = 0; x < lista.length && escolhidos.length < KENNEY_MAX_POR_CAT; x += passo) escolhidos.push(lista[Math.floor(x)]);
|
|
221
|
+
const dirCat = path.join(pasta, cat); fs.mkdirSync(dirCat, { recursive: true });
|
|
222
|
+
for (const it of escolhidos) {
|
|
223
|
+
const id = 'k-' + it.nome.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 30);
|
|
224
|
+
const destino = path.join(dirCat, id + '.mp3');
|
|
225
|
+
if (fs.existsSync(destino)) continue;
|
|
226
|
+
const t = tratar(it.arq, destino, def.max);
|
|
227
|
+
if (!t.ok) { fs.rmSync(destino, { force: true }); continue; }
|
|
228
|
+
feitos++;
|
|
229
|
+
fs.writeFileSync(path.join(dirCat, id + '.licenca.json'), JSON.stringify({ titulo: it.nome, autor: 'Kenney (kenney.nl)', licenca: 'cc0', licencaUrl: 'https://creativecommons.org/publicdomain/zero/1.0/', origem: it.pagina, fonte: 'kenney', categoria: cat, segundos: t.segundos, quando: new Date().toISOString() }, null, 2));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
fs.writeFileSync(marca, JSON.stringify({ feitos, quando: new Date().toISOString() }));
|
|
233
|
+
return { ok: true, feitos };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function encherCategoria(cat, pasta, minimo, rede) {
|
|
237
|
+
const def = CATEGORIAS[cat];
|
|
238
|
+
const dir = path.join(pasta, cat);
|
|
239
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
240
|
+
const tem = () => fs.readdirSync(dir).filter((f) => f.endsWith('.mp3')).length;
|
|
241
|
+
if (tem() >= minimo || !rede) return;
|
|
242
|
+
const vistos = new Set(fs.readdirSync(dir).filter((f) => f.endsWith('.licenca.json')).map((f) => { try { return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')).origemId; } catch { return null; } }));
|
|
243
|
+
for (const consulta of def.q) {
|
|
244
|
+
if (tem() >= minimo) break;
|
|
245
|
+
let r;
|
|
246
|
+
try { r = await pedirJson(`${API}?q=${encodeURIComponent(consulta)}&license=cc0,pdm&page_size=20`); } catch { continue; }
|
|
247
|
+
const lista = (r.results || []).filter((a) => /^(cc0|pdm)$/i.test(a.license || '') && a.url && (!a.duration || a.duration / 1000 <= 4 + 0.001) && !vistos.has(a.id));
|
|
248
|
+
for (const a of lista) {
|
|
249
|
+
if (tem() >= minimo) break;
|
|
250
|
+
vistos.add(a.id);
|
|
251
|
+
const id = String(a.id).slice(0, 8);
|
|
252
|
+
const destino = path.join(dir, id + '.mp3');
|
|
253
|
+
if (fs.existsSync(destino)) continue;
|
|
254
|
+
const bruto = path.join(dir, id + '.bruto');
|
|
255
|
+
try {
|
|
256
|
+
fs.writeFileSync(bruto, await baixarBytes(a.url));
|
|
257
|
+
const t = tratar(bruto, destino, def.max);
|
|
258
|
+
if (!t.ok) { fs.rmSync(destino, { force: true }); continue; }
|
|
259
|
+
fs.writeFileSync(path.join(dir, id + '.licenca.json'), JSON.stringify({
|
|
260
|
+
titulo: a.title, autor: a.creator || null, licenca: String(a.license).toLowerCase(), licencaUrl: a.license_url || null,
|
|
261
|
+
origem: a.foreign_landing_url || a.url, url: a.url, origemId: a.id, fonte: a.source || a.provider || 'openverse', consulta, categoria: cat,
|
|
262
|
+
segundos: t.segundos, quando: new Date().toISOString(),
|
|
263
|
+
}, null, 2));
|
|
264
|
+
} catch { /* falha de um arquivo nunca derruba */ } finally { fs.rmSync(bruto, { force: true }); }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Garante a biblioteca em disco. `acoes` (opcional) limita às categorias dessas ações; senão vão todas
|
|
271
|
+
* as básicas + as extras do tema do assunto. Cache: o que já existe não é baixado. Sem rede: devolve o que há.
|
|
272
|
+
*/
|
|
273
|
+
async function prepararBiblioteca({ assunto = '', acoes = null, pasta = PASTA_PADRAO(), rede = true, minPorCategoria = 10 } = {}) {
|
|
274
|
+
const tema = temaDe(assunto);
|
|
275
|
+
let cats;
|
|
276
|
+
if (acoes && acoes.length) cats = new Set(acoes.flatMap((a) => ACOES[a] || []));
|
|
277
|
+
else cats = new Set(Object.keys(CATEGORIAS).filter((c) => c !== 'oven'));
|
|
278
|
+
if (tema) tema.extras.forEach((c) => cats.add(c));
|
|
279
|
+
fs.mkdirSync(pasta, { recursive: true });
|
|
280
|
+
try { await baixarKenney(pasta, { rede }); } catch { /* sem Kenney: segue com o Openverse */ }
|
|
281
|
+
const lista = [...cats].filter((c) => CATEGORIAS[c]);
|
|
282
|
+
// 3 categorias por vez: rápido sem estourar a API
|
|
283
|
+
for (let i = 0; i < lista.length; i += 3) await Promise.all(lista.slice(i, i + 3).map((c) => encherCategoria(c, pasta, minPorCategoria, rede).catch(() => null)));
|
|
284
|
+
const indice = lerIndice(pasta);
|
|
285
|
+
try { fs.writeFileSync(path.join(pasta, 'indice.json'), JSON.stringify(indice, null, 2)); } catch { /* só cópia */ }
|
|
286
|
+
return indice;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── a escolha ────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
const JANELA_REPETICAO = 1.5;
|
|
292
|
+
const COM_VARIACAO = new Set(['pop', 'click', 'tap', 'typing', 'bell', 'glass', 'paper']);
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Cada evento {t, tipo, ganho} vira {t, arquivo, tipo, ganho, acao, rate}. `arquivo` null = sem som real
|
|
296
|
+
* (o mixar usa o leve do `tipo`). Determinístico pela semente.
|
|
297
|
+
*/
|
|
298
|
+
function escolherSons(eventos, { assunto = '', indice = null, semente = 1 } = {}) {
|
|
299
|
+
indice = indice || lerIndice();
|
|
300
|
+
const tema = temaDe(assunto);
|
|
301
|
+
const rnd = mulberry(semente);
|
|
302
|
+
const ultimoPorAcao = {};
|
|
303
|
+
const usados = new Set();
|
|
304
|
+
const recentes = []; // [{arquivo, t}]
|
|
305
|
+
const ordenados = [...(eventos || [])].filter((e) => e && isFinite(e.t)).sort((a, b) => a.t - b.t);
|
|
306
|
+
return ordenados.map((e) => {
|
|
307
|
+
const acao = String(e.tipo);
|
|
308
|
+
const padrao = ACOES[acao] || [];
|
|
309
|
+
const pref = (tema && tema.prefere[acao]) || null;
|
|
310
|
+
// a preferência do tema vem primeiro, mas a ordem entre elas varia (não vira sempre o mesmo)
|
|
311
|
+
let cats = pref ? [...pref, ...padrao.filter((c) => !pref.includes(c))] : padrao.slice();
|
|
312
|
+
if (pref && pref.length > 1 && rnd() < 0.5) cats = [cats[1], cats[0], ...cats.slice(2)];
|
|
313
|
+
let arquivo = null, cat = null;
|
|
314
|
+
// nunca o mesmo arquivo duas vezes no vídeo: o que já tocou sai de todos os pools
|
|
315
|
+
for (const c of cats) {
|
|
316
|
+
const opcoes = (indice[c] || []).filter((x) => fs.existsSync(x.arquivo) && !usados.has(x.arquivo) && !BLOQUEADOS.test(path.basename(x.arquivo)));
|
|
317
|
+
if (!opcoes.length) continue;
|
|
318
|
+
arquivo = opcoes[Math.floor(rnd() * opcoes.length)].arquivo; cat = c; break;
|
|
319
|
+
}
|
|
320
|
+
// biblioteca curta demais para o vídeo: reaproveita o que tocou há MAIS tempo (nunca em sequência, nunca em menos de 1,5 s)
|
|
321
|
+
if (!arquivo) {
|
|
322
|
+
for (const c of cats) {
|
|
323
|
+
const opcoes = (indice[c] || []).filter((x) => fs.existsSync(x.arquivo) && !BLOQUEADOS.test(path.basename(x.arquivo)) && x.arquivo !== ultimoPorAcao[acao] && !recentes.some((r) => r.arquivo === x.arquivo && e.t - r.t < JANELA_REPETICAO));
|
|
324
|
+
if (!opcoes.length) continue;
|
|
325
|
+
const quando = (a) => { const u = [...recentes].reverse().find((r) => r.arquivo === a); return u ? u.t : -1; };
|
|
326
|
+
opcoes.sort((a, b) => quando(a.arquivo) - quando(b.arquivo));
|
|
327
|
+
arquivo = opcoes[0].arquivo; cat = c; break;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (arquivo) { ultimoPorAcao[acao] = arquivo; recentes.push({ arquivo, t: e.t }); usados.add(arquivo); }
|
|
331
|
+
const db = (rnd() * 3 - 1.5);
|
|
332
|
+
const ganho = (e.ganho == null ? 1 : e.ganho) * Math.pow(10, db / 20);
|
|
333
|
+
const rate = arquivo && cat && COM_VARIACAO.has(cat) ? Math.round((0.97 + rnd() * 0.08) * 1000) / 1000 : 1;
|
|
334
|
+
return { t: e.t, arquivo, tipo: acao, ganho: Math.round(ganho * 1000) / 1000, acao: cat || acao, rate };
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const DE_CORTE = new Set(['troca', 'swoosh', 'whoosh', 'impacto', 'sub', 'sobe', 'subida']);
|
|
339
|
+
/** Puxa os sons de corte/troca/impacto para a batida mais próxima (até `tolerancia` s). `batidas`: array ou {batidas}. */
|
|
340
|
+
function encaixarNaBatida(sons, batidas, { tolerancia = 0.09 } = {}) {
|
|
341
|
+
const bs = (Array.isArray(batidas) ? batidas : (batidas && batidas.batidas) || []).filter((x) => isFinite(x));
|
|
342
|
+
if (!bs.length) return sons;
|
|
343
|
+
return sons.map((s) => {
|
|
344
|
+
if (!DE_CORTE.has(s.tipo)) return s;
|
|
345
|
+
// o riser termina na batida (não começa nela): empurra o fim para a batida, via t + duração típica ~1,1 s
|
|
346
|
+
const alvo = s.tipo === 'sobe' || s.tipo === 'subida' ? s.t + 1.1 : s.t;
|
|
347
|
+
let melhor = null;
|
|
348
|
+
for (const b of bs) if (melhor == null || Math.abs(b - alvo) < Math.abs(melhor - alvo)) melhor = b;
|
|
349
|
+
if (melhor == null || Math.abs(melhor - alvo) > tolerancia) return s;
|
|
350
|
+
const novo = s.tipo === 'sobe' || s.tipo === 'subida' ? melhor - 1.1 : melhor;
|
|
351
|
+
return { ...s, t: Math.max(0, Math.round(novo * 1000) / 1000), encaixado: true };
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── a mixagem ────────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
const LUFS_ALVO = -14;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* SFX nos tempos + trilha (15 dB abaixo da voz) + voz, com ducking, e a normalização em −14 LUFS.
|
|
361
|
+
* Sem voz, a trilha fica ~10 dB abaixo do barramento de SFX. Devolve {ok, video, lufs, eventos, reservas, mix}.
|
|
362
|
+
*/
|
|
363
|
+
function mixar({ pasta, mudo, saida, sons = [], trilha = null, trilhaInicio = 0, voz = null, duracao, ffmpeg, vol = {} } = {}) { // vol: {trilha, sfx} em dB, somados aos ganhos medidos
|
|
364
|
+
if (!pasta || !mudo || !saida || !duracao) return { ok: false, error: 'faltou pasta, vídeo mudo, saída ou duração' };
|
|
365
|
+
const D = Number(duracao).toFixed(2);
|
|
366
|
+
const temVoz = !!(voz && fs.existsSync(voz)), temTrilha = !!(trilha && fs.existsSync(trilha));
|
|
367
|
+
const trab = path.join(pasta, 'mix-tmp'); fs.mkdirSync(trab, { recursive: true });
|
|
368
|
+
const limpar = () => fs.rmSync(trab, { recursive: true, force: true });
|
|
369
|
+
|
|
370
|
+
// 1) resolve os arquivos (real ou reserva leve)
|
|
371
|
+
let leves = null, reservas = 0;
|
|
372
|
+
const eventos = [];
|
|
373
|
+
for (const s of sons.slice(0, 120)) {
|
|
374
|
+
let arq = s.arquivo && fs.existsSync(s.arquivo) ? s.arquivo : null;
|
|
375
|
+
if (!arq) {
|
|
376
|
+
leves = leves || require('./sons-leves.js').preparar(pasta);
|
|
377
|
+
if (leves[s.tipo]) { arq = path.join(pasta, leves[s.tipo]); reservas++; }
|
|
378
|
+
}
|
|
379
|
+
if (arq) eventos.push({ ...s, arq });
|
|
380
|
+
}
|
|
381
|
+
if (!eventos.length && !temVoz && !temTrilha) { limpar(); return { ok: false, semAudio: true, video: mudo }; }
|
|
382
|
+
|
|
383
|
+
// 2) barramento de SFX
|
|
384
|
+
let sfx = null;
|
|
385
|
+
if (eventos.length) {
|
|
386
|
+
const ent = [], fil = [], nomes = [];
|
|
387
|
+
eventos.forEach((e, i) => {
|
|
388
|
+
ent.push('-i', e.arq);
|
|
389
|
+
const ms = Math.max(0, Math.round(e.t * 1000));
|
|
390
|
+
const rate = e.rate && e.rate !== 1 ? `,asetrate=${Math.round(48000 * e.rate)},aresample=48000` : '';
|
|
391
|
+
// acento, não bomba: graves cortados abaixo de 120 Hz, ataque suavizado (fade-in 8 ms) e pico em −18 dBFS (o arquivo vem a −3)
|
|
392
|
+
const gdb = -15 + 20 * Math.log10(Math.max(0.05, Math.min(1, e.ganho == null ? 1 : e.ganho)));
|
|
393
|
+
fil.push(`[${i}:a]aresample=48000,aformat=channel_layouts=stereo${rate},highpass=f=120,afade=t=in:st=0:d=0.008,volume=${gdb.toFixed(2)}dB,adelay=${ms}|${ms}[s${i}]`);
|
|
394
|
+
nomes.push(`[s${i}]`);
|
|
395
|
+
});
|
|
396
|
+
fil.push(`${nomes.join('')}amix=inputs=${nomes.length}:normalize=0:duration=longest,apad=whole_dur=${D},atrim=0:${D}[o]`);
|
|
397
|
+
sfx = path.join(trab, 'sfx.wav');
|
|
398
|
+
const r = ff([...ent, '-filter_complex', fil.join(';'), '-map', '[o]', '-ar', '48000', '-ac', '2', sfx], { timeout: 240000 });
|
|
399
|
+
if (!r.ok) { limpar(); return { ok: false, error: 'a mixagem de efeitos falhou: ' + r.saida.slice(-400), video: mudo }; }
|
|
400
|
+
}
|
|
401
|
+
// 3) voz e trilha em wav (para medir)
|
|
402
|
+
let vozW = null, triW = null;
|
|
403
|
+
if (temVoz) {
|
|
404
|
+
vozW = path.join(trab, 'voz.wav');
|
|
405
|
+
ff(['-i', voz, '-af', `aresample=48000,aformat=channel_layouts=stereo,apad=whole_dur=${D},atrim=0:${D}`, '-ar', '48000', vozW]);
|
|
406
|
+
}
|
|
407
|
+
if (temTrilha) {
|
|
408
|
+
triW = path.join(trab, 'trilha.wav');
|
|
409
|
+
ff(['-ss', String(Math.max(0, trilhaInicio || 0)), '-stream_loop', '-1', '-i', trilha, '-af', `aresample=48000,aformat=channel_layouts=stereo,atrim=0:${D},asetpts=PTS-STARTPTS,afade=t=in:st=0:d=0.8,afade=t=out:st=${Math.max(0, duracao - 1.4).toFixed(2)}:d=1.4`, '-ar', '48000', triW], { timeout: 120000 });
|
|
410
|
+
}
|
|
411
|
+
// 4) ganhos pela medição: com voz, a trilha fica 15 dB abaixo dela e os efeitos 10 dB abaixo; sem voz, a trilha vai a −22 LUFS
|
|
412
|
+
// (picos ~ −10 dBFS) e os efeitos, com pico em −18, ficam ~8–10 dB abaixo dela
|
|
413
|
+
const Lv = vozW ? medirLufs(vozW) : null, Lm = triW ? medirLufs(triW) : null, Ls = sfx ? medirLufs(sfx) : null;
|
|
414
|
+
let gTrilha = 0, difVozTrilha = null;
|
|
415
|
+
if (triW && Lm != null) {
|
|
416
|
+
const alvo = Lv != null ? Lv - 15 : -22;
|
|
417
|
+
gTrilha = alvo - Lm + (vol.trilha || 0); difVozTrilha = Lv != null ? Math.round((Lv - (Lm + gTrilha)) * 10) / 10 : null;
|
|
418
|
+
}
|
|
419
|
+
const gSfx = (vozW && Lv != null && Ls != null ? Math.min(0, (Lv - 10) - Ls) : 0) + (vol.sfx || 0); // só abaixa; nunca sobe acima do pico de −18 dBFS
|
|
420
|
+
const picoSfx = sfx ? picoDe(sfx) : null;
|
|
421
|
+
|
|
422
|
+
// 5) o barramento final, com ducking pela voz
|
|
423
|
+
const ent = [], fil = [], bus = [];
|
|
424
|
+
let n = 0;
|
|
425
|
+
const entrada = (arq) => { ent.push('-i', arq); return n++; };
|
|
426
|
+
if (vozW) {
|
|
427
|
+
const iv = entrada(vozW);
|
|
428
|
+
fil.push(`[${iv}:a]asplit=3[v1][v2][v3]`);
|
|
429
|
+
bus.push('[v1]');
|
|
430
|
+
if (triW) {
|
|
431
|
+
const it = entrada(triW);
|
|
432
|
+
fil.push(`[${it}:a]volume=${gTrilha.toFixed(2)}dB[mus]`, `[mus][v2]sidechaincompress=threshold=0.02:ratio=9:attack=15:release=450:makeup=1[musd]`);
|
|
433
|
+
bus.push('[musd]');
|
|
434
|
+
} else fil.push('[v2]anullsink');
|
|
435
|
+
if (sfx) {
|
|
436
|
+
const is = entrada(sfx);
|
|
437
|
+
fil.push(`[${is}:a]volume=${gSfx.toFixed(2)}dB[sf]`, `[sf][v3]sidechaincompress=threshold=0.04:ratio=2.5:attack=10:release=250:makeup=1[sfd]`);
|
|
438
|
+
bus.push('[sfd]');
|
|
439
|
+
} else fil.push('[v3]anullsink');
|
|
440
|
+
} else {
|
|
441
|
+
if (triW) { const it = entrada(triW); fil.push(`[${it}:a]volume=${gTrilha.toFixed(2)}dB[mus]`); bus.push('[mus]'); }
|
|
442
|
+
if (sfx) { const is = entrada(sfx); fil.push(`[${is}:a]anull[sf]`); bus.push('[sf]'); }
|
|
443
|
+
}
|
|
444
|
+
fil.push(`${bus.join('')}amix=inputs=${bus.length}:normalize=0:duration=longest,alimiter=limit=0.95:level=false:attack=5:release=80,atrim=0:${D}[mix]`);
|
|
445
|
+
const bruto = path.join(trab, 'mix-bruto.wav');
|
|
446
|
+
const m1 = ff([...ent, '-filter_complex', fil.join(';'), '-map', '[mix]', '-ar', '48000', '-ac', '2', bruto], { timeout: 240000 });
|
|
447
|
+
if (!m1.ok) { limpar(); return { ok: false, error: 'a mixagem falhou: ' + m1.saida.slice(-400), video: mudo }; }
|
|
448
|
+
|
|
449
|
+
// 6) loudnorm em 2 passadas → −14 LUFS
|
|
450
|
+
const alvo = `I=${LUFS_ALVO}:TP=-1.5:LRA=11`;
|
|
451
|
+
const p1 = spawnSync('ffmpeg', ['-hide_banner', '-nostats', '-i', bruto, '-af', `loudnorm=${alvo}:print_format=json`, '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
452
|
+
let af = `loudnorm=${alvo}`;
|
|
453
|
+
const js = String(p1.stderr || '').match(/\{[^{}]*"input_i"[^{}]*\}/);
|
|
454
|
+
if (js) {
|
|
455
|
+
try {
|
|
456
|
+
const d = JSON.parse(js[0]);
|
|
457
|
+
if (isFinite(d.input_i) && isFinite(d.input_thresh)) af = `loudnorm=${alvo}:measured_I=${d.input_i}:measured_TP=${d.input_tp}:measured_LRA=${d.input_lra}:measured_thresh=${d.input_thresh}:offset=${d.target_offset}:linear=true`;
|
|
458
|
+
} catch { /* passada única */ }
|
|
459
|
+
}
|
|
460
|
+
const fim = ff(['-i', mudo, '-i', bruto, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', af, '-ar', '48000', '-c:a', 'aac', '-b:a', '192k', '-shortest', saida], { timeout: 240000 });
|
|
461
|
+
if (fim.ok && fs.existsSync(saida)) {
|
|
462
|
+
// o loudnorm linear fica abaixo do alvo quando os picos limitam: um ganho a mais, com limiter, fecha a conta
|
|
463
|
+
const l1 = medirLufs(saida);
|
|
464
|
+
if (l1 != null && isFinite(l1) && Math.abs(l1 - LUFS_ALVO) > 0.4) {
|
|
465
|
+
const ganho = (LUFS_ALVO - l1).toFixed(2);
|
|
466
|
+
const f2 = ff(['-i', mudo, '-i', bruto, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', `${af},volume=${ganho}dB,alimiter=limit=0.89:level=false`, '-ar', '48000', '-c:a', 'aac', '-b:a', '192k', '-shortest', saida + '.2.mp4'], { timeout: 240000 });
|
|
467
|
+
if (f2.ok) { const l2 = medirLufs(saida + '.2.mp4'); if (l2 != null && Math.abs(l2 - LUFS_ALVO) < Math.abs(l1 - LUFS_ALVO)) fs.renameSync(saida + '.2.mp4', saida); else fs.rmSync(saida + '.2.mp4', { force: true }); }
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
limpar();
|
|
471
|
+
if (!fim.ok || !fs.existsSync(saida)) return { ok: false, error: 'a normalização falhou: ' + fim.saida.slice(-400), video: mudo };
|
|
472
|
+
return {
|
|
473
|
+
ok: true, video: saida, lufs: medirLufs(saida), eventos: eventos.length, reservas,
|
|
474
|
+
mix: { lufsVoz: Lv, lufsTrilha: Lm, ganhoTrilha: Math.round(gTrilha * 10) / 10, vozMenosTrilha: difVozTrilha, ganhoSfx: Math.round(gSfx * 10) / 10, sfxPicoDbfs: picoSfx == null ? null : Math.round((picoSfx + gSfx) * 10) / 10, sfxLufs: Ls == null ? null : Math.round((Ls + gSfx) * 10) / 10, trilhaLufsNaMix: Lm == null ? null : Math.round((Lm + gTrilha) * 10) / 10 },
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
module.exports = { BLOQUEADOS, KENNEY, KENNEY_REGRAS, baixarKenney, CATEGORIAS, ACOES, TEMAS, temaDe, prepararBiblioteca, lerIndice, escolherSons, encaixarNaBatida, mixar, medirLufs, tratar, PASTA_PADRAO };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* maquina-trilha.js — a trilha do motion: energia alta ou nada.
|
|
3
|
+
*
|
|
4
|
+
* Regras (Joel, 18/10: "a trilha de fundo é horrível"):
|
|
5
|
+
* · LISTA NEGRA permanente: as faixas já usadas pela máquina (inclusive Jesse Keller e Josh Woodward) nunca mais voltam.
|
|
6
|
+
* Mora no código (PROIBIDAS) e em ~/.primocode/studio/trilhas-proibidas.json (a pessoa acrescenta; as duas somam).
|
|
7
|
+
* · ENERGIA ALTA: BPM ≥ 110, batida confiável (batida.js) e volume de música de verdade (LUFS ≥ −17); gênero pelo assunto
|
|
8
|
+
* (phonk, trap, electro, house, techno…). Várias candidatas são baixadas e medidas; ganha a de maior nota.
|
|
9
|
+
* · CORTE NO DROP: o início da faixa é escolhido (batida.melhorInicio) para o maior acento cair no corte mais pesado.
|
|
10
|
+
* · `trilha:` aceita um ARQUIVO da pessoa (mp3/wav/m4a): ele vence a busca (a lista negra não vale para o que ela escolheu).
|
|
11
|
+
* · Sem faixa boa → sem música, só sound design.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { spawnSync } = require('child_process');
|
|
20
|
+
|
|
21
|
+
const ARQUIVO_PROIBIDAS = () => process.env.PRIMOCODE_TRILHAS_PROIBIDAS || path.join(os.homedir(), '.primocode', 'studio', 'trilhas-proibidas.json');
|
|
22
|
+
|
|
23
|
+
/** Usadas até 18/10/2026 (lidas das fichas .licenca.json dos projetos e dos vídeos de prova) + as que o Joel apontou. */
|
|
24
|
+
const PROIBIDAS = [
|
|
25
|
+
{ autor: 'Jesse Keller', motivo: 'o Joel apontou: horrível' },
|
|
26
|
+
{ autor: 'Josh Woodward', titulo: 'Motionless Land', motivo: 'o Joel apontou: horrível' },
|
|
27
|
+
{ autor: 'Soundrider', titulo: 'Corporate', motivo: 'usada nos vídeos v1' },
|
|
28
|
+
{ autor: 'Tymphony', motivo: 'usada nos vídeos v1' },
|
|
29
|
+
{ autor: 'Tri-Tachyon', motivo: 'usada nos vídeos v1' },
|
|
30
|
+
{ autor: 'Professor Kliq', motivo: 'usada nos vídeos v1' },
|
|
31
|
+
{ autor: 'Adrian Sanchez', motivo: 'usada nos vídeos v1' },
|
|
32
|
+
{ autor: 'Arhur Yul', motivo: 'usada nos vídeos v1' },
|
|
33
|
+
{ autor: 'Hasenchat', motivo: 'usada nos vídeos v1' },
|
|
34
|
+
{ autor: 'Pakii', motivo: 'usada nos vídeos v1' },
|
|
35
|
+
{ autor: 'Alittlebitdrunkguy', motivo: 'usada nos vídeos v1' },
|
|
36
|
+
{ autor: 'Jahzzar', motivo: 'usada nos vídeos v1' },
|
|
37
|
+
{ autor: 'Fragments Of Winter', motivo: 'usada nos vídeos v1' },
|
|
38
|
+
{ trackid: '1670486' }, { trackid: '1670484' }, { trackid: '764950' }, { trackid: '341547' }, { trackid: '925452' }, { trackid: '432009' },
|
|
39
|
+
{ trackid: '1501961' }, { trackid: '1137475' }, { trackid: '992957' }, { trackid: '509068' }, { trackid: '417168' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** Garante o arquivo da lista negra (cria com as entradas de fábrica) e devolve a lista somada (código + arquivo). */
|
|
43
|
+
function lerProibidas(arq = ARQUIVO_PROIBIDAS()) {
|
|
44
|
+
let doArquivo = [];
|
|
45
|
+
try { doArquivo = JSON.parse(fs.readFileSync(arq, 'utf8')); } catch { /* ainda não existe */ }
|
|
46
|
+
if (!Array.isArray(doArquivo)) doArquivo = [];
|
|
47
|
+
if (!fs.existsSync(arq)) { try { fs.mkdirSync(path.dirname(arq), { recursive: true }); fs.writeFileSync(arq, JSON.stringify(PROIBIDAS, null, 2)); } catch { /* sem disco */ } }
|
|
48
|
+
return [...PROIBIDAS, ...doArquivo];
|
|
49
|
+
}
|
|
50
|
+
const norm = (s) => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
|
51
|
+
/** A faixa (título, autor, url) está na lista negra? */
|
|
52
|
+
function proibida(item, lista = lerProibidas()) {
|
|
53
|
+
const t = norm(item.titulo || item.title), a = norm(item.autor || item.creator), u = String(item.url || '');
|
|
54
|
+
return lista.some((p) => {
|
|
55
|
+
if (p.trackid && new RegExp('trackid=' + p.trackid + '(?!\\d)').test(u)) return true;
|
|
56
|
+
if (p.url && p.url === u) return true;
|
|
57
|
+
if (p.autor && a.includes(norm(p.autor)) && (!p.titulo || t.includes(norm(p.titulo)))) return true;
|
|
58
|
+
if (!p.autor && p.titulo && t.includes(norm(p.titulo))) return true;
|
|
59
|
+
return false;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Acrescenta uma faixa à lista negra permanente. */
|
|
63
|
+
function proibir(item, motivo = 'recusada') {
|
|
64
|
+
const arq = ARQUIVO_PROIBIDAS();
|
|
65
|
+
let l = []; try { l = JSON.parse(fs.readFileSync(arq, 'utf8')); } catch { /* nova */ }
|
|
66
|
+
l.push({ autor: item.autor || undefined, titulo: item.titulo || undefined, url: item.url || undefined, motivo, quando: new Date().toISOString() });
|
|
67
|
+
fs.mkdirSync(path.dirname(arq), { recursive: true });
|
|
68
|
+
fs.writeFileSync(arq, JSON.stringify(l, null, 2));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Os gêneros que a busca tenta, por tipo de assunto (a chave vem de maquina-diretor.achar). */
|
|
72
|
+
const GENEROS = {
|
|
73
|
+
software: ['electro house', 'techno', 'phonk', 'synthwave'],
|
|
74
|
+
financas: ['trap beat', 'phonk', 'electro hip hop', 'house'],
|
|
75
|
+
padaria: ['tropical house', 'funk groove', 'electro swing', 'house'],
|
|
76
|
+
clinica: ['deep house', 'electro pop', 'chillhop upbeat', 'house'],
|
|
77
|
+
geral: ['electro house', 'trap', 'phonk', 'house'],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const LIMITE = { bpmMin: 110, confianca: 0.7, lufsMin: -17 };
|
|
81
|
+
|
|
82
|
+
function ff(args, opts = {}) {
|
|
83
|
+
const r = spawnSync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error', ...args], { encoding: 'utf8', timeout: opts.timeout || 120000 });
|
|
84
|
+
return r.status === 0;
|
|
85
|
+
}
|
|
86
|
+
function lufsDe(arq, segundos = 60) {
|
|
87
|
+
const r = spawnSync('ffmpeg', ['-hide_banner', '-nostats', '-t', String(segundos), '-i', arq, '-vn', '-af', 'ebur128=peak=true', '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
|
|
88
|
+
const s = String(r.stderr || '');
|
|
89
|
+
const m = s.slice(s.lastIndexOf('Summary:')).match(/I:\s+(-?\d+(?:\.\d+)?)\s+LUFS/);
|
|
90
|
+
return m ? Number(m[1]) : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A nota de uma faixa analisada (0 se reprovada): andamento entre 120 e 150 vale mais, batida firme, acentos de drop e
|
|
95
|
+
* volume de música moderna. `a` = {bpm, confianca, acentos, segundos, lufs}.
|
|
96
|
+
*/
|
|
97
|
+
function nota(a, limite = LIMITE) {
|
|
98
|
+
if (!a || !a.bpm || a.bpm < limite.bpmMin || a.confianca < limite.confianca) return 0;
|
|
99
|
+
if (a.lufs != null && a.lufs < limite.lufsMin) return 0;
|
|
100
|
+
const bpm = Math.max(0, 1 - Math.abs(a.bpm - 135) / 60);
|
|
101
|
+
const loud = a.lufs == null ? 0.5 : Math.min(1, (a.lufs + 17) / 6);
|
|
102
|
+
return Math.round((0.3 * bpm + 0.3 * a.confianca + 0.2 * Math.min(1, (a.acentos || []).length / 6) + 0.2 * loud) * 1000) / 1000;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Escolhe a trilha. `arquivo` (da pessoa) vence. `deps`: {procurar(termo), baixar(item, pasta), analisar(arq), lufs(arq)} (testes).
|
|
107
|
+
* Devolve {arquivo, titulo, creditos, bpm, batidas, acentos, confianca, lufs, nota, pessoa} ou {semMusica: true, motivo}.
|
|
108
|
+
*/
|
|
109
|
+
async function escolher({ chave = 'geral', busca = null, arquivo = null, pasta, rede = true, deps = {}, maximo = 8 } = {}) {
|
|
110
|
+
const batida = require('./batida.js');
|
|
111
|
+
const analisar = deps.analisar || ((arq) => batida.analisar(arq));
|
|
112
|
+
const medirLufs = deps.lufs || ((arq) => lufsDe(arq));
|
|
113
|
+
if (arquivo && fs.existsSync(arquivo)) {
|
|
114
|
+
const a = await analisar(arquivo).catch(() => null);
|
|
115
|
+
return { arquivo, titulo: path.basename(arquivo), creditos: null, bpm: a && a.bpm, batidas: a && a.batidas, acentos: a && a.acentos, confianca: a && a.confianca, lufs: medirLufs(arquivo), pessoa: true, nota: 1 };
|
|
116
|
+
}
|
|
117
|
+
if (!rede && !deps.procurar) return { semMusica: true, motivo: 'sem rede e sem arquivo' };
|
|
118
|
+
const assets = deps.procurar ? null : require('./assets.js');
|
|
119
|
+
const procurar = deps.procurar || ((t) => assets.procurarMusica(t, { quantas: 10, duracaoMin: 25 }));
|
|
120
|
+
const baixar = deps.baixar || ((item, p) => assets.baixar(item, { pasta: p, nome: 'trilha-' + String(item.titulo || 'x').slice(0, 20), sub: path.join('midia', 'trilhas') }));
|
|
121
|
+
const lista = lerProibidas();
|
|
122
|
+
const termos = [busca, ...(GENEROS[chave] || GENEROS.geral)].filter(Boolean).slice(0, 4);
|
|
123
|
+
const vistos = new Set();
|
|
124
|
+
const analisadas = [];
|
|
125
|
+
const recusadas = [];
|
|
126
|
+
for (const termo of termos) {
|
|
127
|
+
if (analisadas.length >= maximo) break;
|
|
128
|
+
let r; try { r = await procurar(termo); } catch { continue; }
|
|
129
|
+
for (const item of ((r && r.sons) || [])) {
|
|
130
|
+
if (analisadas.length >= maximo) break;
|
|
131
|
+
if (!item.url || vistos.has(item.url)) continue;
|
|
132
|
+
vistos.add(item.url);
|
|
133
|
+
if (proibida(item, lista)) { recusadas.push(item.titulo); continue; }
|
|
134
|
+
let b; try { b = await baixar(item, pasta); } catch { continue; }
|
|
135
|
+
if (!b || !b.ok) continue;
|
|
136
|
+
const a = await analisar(b.arquivo).catch(() => null);
|
|
137
|
+
if (!a) continue;
|
|
138
|
+
const lufs = medirLufs(b.arquivo);
|
|
139
|
+
const faixa = { arquivo: b.arquivo, titulo: item.titulo, autor: item.autor, creditos: b.creditos || item.creditos || null, genero: termo, bpm: a.bpm, batidas: a.batidas, acentos: a.acentos, confianca: a.confianca, segundos: a.segundos, lufs };
|
|
140
|
+
faixa.nota = nota(faixa);
|
|
141
|
+
analisadas.push(faixa);
|
|
142
|
+
}
|
|
143
|
+
// uma boa já basta para o primeiro gênero: não gasta rede à toa
|
|
144
|
+
if (analisadas.some((f) => f.nota >= 0.75)) break;
|
|
145
|
+
}
|
|
146
|
+
const boas = analisadas.filter((f) => f.nota > 0).sort((a, b) => b.nota - a.nota);
|
|
147
|
+
if (!boas.length) return { semMusica: true, motivo: `nenhuma das ${analisadas.length} faixas analisadas tem energia (BPM ≥ ${LIMITE.bpmMin}, batida firme, LUFS ≥ ${LIMITE.lufsMin})`, analisadas: analisadas.map((f) => ({ titulo: f.titulo, bpm: f.bpm, confianca: f.confianca, lufs: f.lufs })), recusadas };
|
|
148
|
+
return { ...boas[0], analisadas: analisadas.length, recusadas };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* O início da faixa (s) que põe o drop no corte mais pesado: `cortes` = [{t, peso}] do vídeo. Devolve {inicio, alinhados, total}.
|
|
153
|
+
*/
|
|
154
|
+
function inicioNoDrop(cortes, faixa, duracaoDoVideo) {
|
|
155
|
+
const batida = require('./batida.js');
|
|
156
|
+
const maximo = Math.max(0, (faixa.segundos || 0) - duracaoDoVideo - 1);
|
|
157
|
+
return batida.melhorInicio(cortes, { batidas: faixa.batidas, acentos: faixa.acentos, confianca: faixa.confianca }, { maximo, tolerancia: 0.06 });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { PROIBIDAS, GENEROS, LIMITE, lerProibidas, proibida, proibir, nota, escolher, inicioNoDrop, ARQUIVO_PROIBIDAS };
|