primocode 10.0.0 → 10.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/bin/primocode.js +88 -3
- package/lib/act.js +122 -20
- package/lib/browser.js +89 -5
- package/lib/catalogo.js +6 -3
- package/lib/claude-engine.js +38 -17
- package/lib/codex-engine.js +27 -9
- package/lib/config.js +10 -2
- package/lib/entrada.js +73 -9
- package/lib/estudio/assets.js +12 -6
- package/lib/estudio/biblioteca.js +174 -0
- package/lib/estudio/cinema.js +63 -25
- package/lib/estudio/composicao.js +2 -1
- package/lib/estudio/diretor.js +13 -1
- package/lib/estudio/edicao.js +2 -1
- package/lib/estudio/ende.js +16 -5
- package/lib/estudio/estilo.js +41 -10
- package/lib/estudio/fluido.js +283 -0
- package/lib/estudio/gerativo.js +1471 -0
- package/lib/estudio/hibrido.js +169 -0
- package/lib/estudio/impacto.js +66 -31
- package/lib/estudio/index.js +366 -47
- package/lib/estudio/kit/fluido.jsx +493 -0
- package/lib/estudio/kit/gerativo.jsx +990 -0
- package/lib/estudio/kit/index.jsx +4 -1
- package/lib/estudio/kit/tres.jsx +375 -0
- package/lib/estudio/kit.js +9 -1
- package/lib/estudio/kokoro.js +111 -0
- package/lib/estudio/lancamento.js +27 -0
- package/lib/estudio/legibilidade.js +20 -0
- package/lib/estudio/referencias.js +105 -0
- package/lib/estudio/remotion.js +103 -14
- package/lib/estudio/ritmo.js +2 -2
- package/lib/estudio/sons-leves.js +38 -1
- package/lib/estudio/variacao3d.js +46 -0
- package/lib/estudio/variedade.js +183 -0
- package/lib/estudio/vox.js +37 -8
- package/lib/mcp.js +156 -0
- package/lib/personas.js +67 -0
- package/lib/portao-skill.js +150 -0
- package/lib/skills.js +51 -1
- package/lib/tools.js +53 -8
- package/lib/ui.js +41 -6
- package/lib/video/auto.js +329 -0
- package/lib/video/camadas.js +207 -0
- package/lib/video/cli.js +1 -1
- package/lib/video/filters.js +39 -6
- package/lib/video/fx3d/Camadas.jsx +64 -0
- package/lib/video/fx3d/Objetos.jsx +128 -0
- package/lib/video/fx3d/Root.jsx +18 -0
- package/lib/video/fx3d/modelos.jsx +304 -0
- package/lib/video/fx3d.js +108 -0
- package/lib/video/gerados.js +119 -0
- package/lib/video/objetos.js +111 -0
- package/lib/video/ops-fx.js +453 -0
- package/lib/video/ops.js +30 -5
- package/lib/video/paralelo.js +50 -0
- package/lib/video/pedido.js +191 -0
- package/lib/video/rastrear.js +95 -0
- package/lib/video/rastro.js +276 -0
- package/lib/video/render.js +67 -14
- package/lib/video/texto.js +6 -4
- package/lib/video/timeline.js +9 -1
- package/package.json +2 -2
- package/skills/escrever-em-portugues/SKILL.md +3 -0
- package/skills/interface-revisada/SKILL.md +7 -1
- package/skills/motion-de-primeira/SKILL.md +5 -4
- package/skills/projeto-no-github/SKILL.md +6 -0
- package/skills/publicar-na-internet/SKILL.md +3 -0
- package/skills/site-com-motion/SKILL.md +2 -0
- package/studio/narrar.py +10 -7
- package/studio/rastrear.py +399 -0
- package/studio/voz_kokoro.py +98 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* referencias.js — de onde o vídeo tira as referências, decidido pela
|
|
3
|
+
* PESSOA antes de começar.
|
|
4
|
+
*
|
|
5
|
+
* "A IA deve pedir: pegar referências da web com escopo aberto, pegar
|
|
6
|
+
* referências da web com escopo que você define (escopo fechado), ou sem
|
|
7
|
+
* referências — só motion." (29/09)
|
|
8
|
+
*
|
|
9
|
+
* Três modos:
|
|
10
|
+
* aberto pesquisa, site da marca e b-roll de banco à vontade (o híbrido)
|
|
11
|
+
* fechado só o que a pessoa disse: os sites e termos do escopo, nada além
|
|
12
|
+
* sem nenhuma foto nem vídeo de fora: motion puro (o kit), sem b-roll
|
|
13
|
+
*
|
|
14
|
+
* A escolha vale para o pedido (o terminal pergunta a cada vídeo novo) e é
|
|
15
|
+
* lida em dois lugares: a instrução que vai para o agente e o Estúdio, que
|
|
16
|
+
* não baixa b-roll no "sem" e só busca o escopo no "fechado".
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
let ATUAL = { modo: null, escopo: '' };
|
|
22
|
+
|
|
23
|
+
function definir(modo, escopo = '') {
|
|
24
|
+
ATUAL = { modo: ['aberto', 'fechado', 'sem'].includes(modo) ? modo : null, escopo: String(escopo || '').trim() };
|
|
25
|
+
return ATUAL;
|
|
26
|
+
}
|
|
27
|
+
function atual() { return ATUAL; }
|
|
28
|
+
function limpar() { ATUAL = { modo: null, escopo: '' }; }
|
|
29
|
+
|
|
30
|
+
/** Os termos do escopo fechado: "site:anthropic.com, claude ai, sonnet" → lista. */
|
|
31
|
+
function termos(escopo = ATUAL.escopo) {
|
|
32
|
+
return String(escopo || '').split(/[,;\n]+/).map((x) => x.trim()).filter(Boolean).slice(0, 8);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A resposta da pergunta ("1", "2", "aberto", "só motion"…) → modo. */
|
|
36
|
+
function entender(resposta) {
|
|
37
|
+
const r = String(resposta || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').trim();
|
|
38
|
+
if (/^1\b|aberto|livre|qualquer/.test(r)) return 'aberto';
|
|
39
|
+
if (/^2\b|fechad|escopo|defino|so (d|n)(e|o)ss/.test(r)) return 'fechado';
|
|
40
|
+
if (/^3\b|sem ref|nenhum|so motion|motion|sem foto|sem imagem/.test(r)) return 'sem';
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* O MODO DEDUZIDO DO PEDIDO. Quem diz no pedido não é perguntado:
|
|
45
|
+
"só motion", "sem referência", "sem foto" → sem
|
|
46
|
+
"só do site x.com", "usa apenas anthropic.com e …" → fechado (o escopo)
|
|
47
|
+
"web aberta", "escopo aberto", "pesquisa à vontade" → aberto
|
|
48
|
+
O resto volta com `explicito: false` e o terminal pergunta antes de
|
|
49
|
+
começar: "eu posso pedir um vídeo mas estar me referindo ao motion"
|
|
50
|
+
(Joel, 02/10) — chutar o aberto fazia vídeo com foto e b-roll quando a
|
|
51
|
+
pessoa queria só motion. */
|
|
52
|
+
function inferir(texto) {
|
|
53
|
+
const t = String(texto || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
|
54
|
+
if (/so (o )?motion|motion puro|sem refer|sem (foto|imagem|imagens|video de banco|pesquisa)|nao (pesquis|busqu)|so anima/.test(t)) return { modo: 'sem', escopo: '', explicito: true };
|
|
55
|
+
const dominios = String(texto || '').match(/\b[a-z0-9-]+(\.[a-z0-9-]+)+\.?[a-z]{2,}(\/[^\s,]*)?/gi) || [];
|
|
56
|
+
if (/\b(so|apenas|somente|unicamente)\b[^.]{0,30}\b(site|sites|link|links|fonte|fontes|pagina|de|do|da|em)\b/.test(t) && dominios.length) {
|
|
57
|
+
return { modo: 'fechado', escopo: dominios.join(', '), explicito: true };
|
|
58
|
+
}
|
|
59
|
+
if (/escopo aberto|web aberta|pesquis\w* (a vontade|na web|na internet)|busc\w* (imagens|fotos|videos) na (web|internet)|com (imagens|fotos) da (web|internet)/.test(t)) {
|
|
60
|
+
return { modo: 'aberto', escopo: '', explicito: true };
|
|
61
|
+
}
|
|
62
|
+
return { modo: 'aberto', escopo: '', explicito: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A instrução para o agente, conforme o modo. */
|
|
66
|
+
function instrucao({ modo, escopo } = ATUAL) {
|
|
67
|
+
if (modo === 'sem') {
|
|
68
|
+
return '[REFERÊNCIAS: NENHUMA] A pessoa quer SÓ MOTION: não pesquise (nada de pesquisar_referencia nem estudio_site), '
|
|
69
|
+
+ 'não busque foto nem vídeo, não use estudio_midia de banco. Escreva o roteiro com o que o pedido já diz e deixe '
|
|
70
|
+
+ 'o motion gerativo fazer o trabalho (roteiro.estilo "gerativo": a tipografia anima sozinha, sem template).';
|
|
71
|
+
}
|
|
72
|
+
if (modo === 'fechado') {
|
|
73
|
+
const t = termos(escopo);
|
|
74
|
+
return `[REFERÊNCIAS: ESCOPO FECHADO] Use SÓ estas fontes e termos: ${t.join(' · ') || escopo}. `
|
|
75
|
+
+ 'Pesquise apenas dentro delas (em pesquisar_referencia, cite o site ou o termo no próprio texto da busca). '
|
|
76
|
+
+ 'Nada de fato, foto ou vídeo de fora desse escopo; se faltar algo, diga no fim em uma linha. '
|
|
77
|
+
+ `No roteiro, ponha roteiro.busca = ${JSON.stringify(t)} para o b-roll.`;
|
|
78
|
+
}
|
|
79
|
+
if (modo === 'aberto') {
|
|
80
|
+
return '[REFERÊNCIAS: ESCOPO ABERTO] Pesquise o assunto à vontade (pesquisar_referencia; estudio_site se for de uma marca) '
|
|
81
|
+
+ 'e dê em cada parte do roteiro um "busca" em inglês com o que mostrar de fundo — gente, lugar, tela, objeto de verdade.';
|
|
82
|
+
}
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Os domínios do escopo fechado: "só do site anthropic.com" → ['anthropic.com']. */
|
|
87
|
+
function dominios(escopo = ATUAL.escopo) {
|
|
88
|
+
return termos(escopo).map((t) => t.replace(/^site:/, '').replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase())
|
|
89
|
+
.filter((t) => /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(t));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** O resultado da pesquisa sem o que está fora do escopo fechado (subdomínios valem: www-cdn.anthropic.com). */
|
|
93
|
+
function filtrar(r, { modo, escopo } = ATUAL) {
|
|
94
|
+
if (modo !== 'fechado' || !r || !Array.isArray(r.referencias)) return r;
|
|
95
|
+
const ds = dominios(escopo);
|
|
96
|
+
if (!ds.length) return r;
|
|
97
|
+
const dentro = (u) => { try { const h = new URL(u).hostname.toLowerCase(); return ds.some((d) => h === d || h.endsWith('.' + d)); } catch { return false; } };
|
|
98
|
+
const refs = r.referencias.filter((x) => dentro(x.url));
|
|
99
|
+
const fora = r.referencias.length - refs.length;
|
|
100
|
+
if (!refs.length) return { ok: false, consulta: r.consulta, error: `nada desta busca é de ${ds.join(', ')} (escopo fechado)`,
|
|
101
|
+
comoCorrigir: `Abra o site direto: browser_open https://${ds[0]} e browser_read. Não use fonte de fora.` };
|
|
102
|
+
return { ...r, fontes: refs.length, referencias: refs, ...(fora ? { foraDoEscopo: fora } : {}) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { definir, atual, limpar, termos, entender, inferir, instrucao, dominios, filtrar };
|
package/lib/estudio/remotion.js
CHANGED
|
@@ -74,6 +74,10 @@ function pkgJson(nome) {
|
|
|
74
74
|
remotion: VERSAO_REMOTION,
|
|
75
75
|
react: '19.0.0',
|
|
76
76
|
'react-dom': '19.0.0',
|
|
77
|
+
// O produto em 3D (kit/tres.jsx). Instalado uma vez na base compartilhada.
|
|
78
|
+
'@remotion/three': VERSAO_REMOTION,
|
|
79
|
+
three: '0.186.1',
|
|
80
|
+
'@react-three/fiber': '9.8.1',
|
|
77
81
|
},
|
|
78
82
|
}, null, 2) + '\n';
|
|
79
83
|
}
|
|
@@ -275,14 +279,20 @@ function Elemento({ el, t, dados }) {
|
|
|
275
279
|
if (Array.isArray(el.revelar) && typeof el.texto === 'string') {
|
|
276
280
|
const palavras = el.texto.split(' ');
|
|
277
281
|
conteudo = palavras.map((w, i) => {
|
|
278
|
-
const
|
|
279
|
-
|
|
282
|
+
const marcada = !!(el.destaques && el.destaques.includes(i));
|
|
283
|
+
/* A PALAVRA DE DESTAQUE: com "fonteDestaque", o acento é a serifa em
|
|
284
|
+
itálico (na cor do texto), que chega um instante depois das outras.
|
|
285
|
+
Um acento só — nada de caixa de cor atrás (a "cara de IA", 05/10). */
|
|
286
|
+
const serifa = marcada && el.fonteDestaque;
|
|
287
|
+
const t0 = (el.revelar[Math.min(i, el.revelar.length - 1)] ?? 0) + (serifa ? 0.12 : 0);
|
|
288
|
+
const k = Math.min(1, Math.max(0, (tLocal - t0) / (serifa ? 0.45 : 0.28)));
|
|
280
289
|
const e = CURVAS.saida(k);
|
|
281
290
|
// O espaço fica FORA do span: é ali que a linha pode quebrar.
|
|
282
291
|
return (
|
|
283
292
|
<React.Fragment key={i}>
|
|
284
293
|
<span style={{ display: 'inline-block', opacity: e, transform: 'translateY(' + (1 - e) * 0.35 + 'em)',
|
|
285
|
-
color:
|
|
294
|
+
color: marcada && !serifa ? (el.corDestaque || el.cor) : undefined,
|
|
295
|
+
...(serifa ? { fontFamily: '"' + el.fonteDestaque + '", Georgia, serif', fontStyle: 'italic', fontWeight: 400, fontSize: '1.1em', letterSpacing: 0 } : {}) }}>{w}</span>
|
|
286
296
|
{i < palavras.length - 1 ? ' ' : null}
|
|
287
297
|
</React.Fragment>
|
|
288
298
|
);
|
|
@@ -461,12 +471,14 @@ function Elemento({ el, t, dados }) {
|
|
|
461
471
|
display: 'inline-block',
|
|
462
472
|
// A palavra forte é MAIOR na fonte, não na escala: escala não
|
|
463
473
|
// abre espaço, e ela cresceria por cima da vizinha.
|
|
464
|
-
fontSize: p.forte ? '1.22em' : undefined,
|
|
474
|
+
fontSize: p.forte ? (e.fonteForte ? '1.1em' : '1.22em') : undefined,
|
|
475
|
+
// Com "fonteForte", a palavra forte é a serifa em itálico, não o letreiro maior.
|
|
476
|
+
...(p.forte && e.fonteForte ? { fontFamily: '"' + e.fonteForte + '", Georgia, serif', fontStyle: 'italic', fontWeight: 400, textTransform: 'none' } : {}),
|
|
465
477
|
transform: 'scale(' + salto + ') translateY(' + (1 - entra) * 18 + 'px)',
|
|
466
478
|
opacity: entra,
|
|
467
479
|
color: cor,
|
|
468
480
|
textShadow: e.sombra || '0 6px 22px rgba(0,0,0,.6), 0 2px 0 rgba(0,0,0,.35)',
|
|
469
|
-
WebkitTextStroke: e.contorno || '2px rgba(0,0,0,.55)',
|
|
481
|
+
WebkitTextStroke: p.forte && e.fonteForte ? undefined : (e.contorno || '2px rgba(0,0,0,.55)'),
|
|
470
482
|
paintOrder: 'stroke fill',
|
|
471
483
|
background: falando && e.fundoAtivo ? e.fundoAtivo : undefined,
|
|
472
484
|
borderRadius: '0.18em', padding: falando && e.fundoAtivo ? '0 0.12em' : undefined,
|
|
@@ -877,9 +889,15 @@ function fontesCSS(plano) {
|
|
|
877
889
|
for (const cena of plano.cenas || []) andar(cena.elementos || []);
|
|
878
890
|
andar(plano.hud);
|
|
879
891
|
if (!usadas.size) return '/* nenhuma fonte externa neste vídeo */\n';
|
|
892
|
+
/* A FONTE VARIÁVEL: o motion gerativo anima os eixos (wght, wdth, GRAD…)
|
|
893
|
+
e precisa do arquivo variável inteiro, pedido pela faixa de cada eixo
|
|
894
|
+
("Roboto+Flex:opsz,slnt,wdth,wght,GRAD@8..144,…"). As outras seguem
|
|
895
|
+
com os pesos fixos. */
|
|
896
|
+
const variaveis = plano.fontesVariaveis || {};
|
|
880
897
|
const familias = [...usadas]
|
|
881
898
|
// O Google devolve o que a família tem e ignora o resto: pedir todos os pesos e o itálico não quebra.
|
|
882
|
-
.map((f) => 'family=' + encodeURIComponent(f).replace(/%20/g, '+')
|
|
899
|
+
.map((f) => 'family=' + encodeURIComponent(f).replace(/%20/g, '+')
|
|
900
|
+
+ (variaveis[f] ? ':' + variaveis[f] : ':ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,600'))
|
|
883
901
|
.join('&');
|
|
884
902
|
return `@import url('https://fonts.googleapis.com/css2?${familias}&display=block');\n`;
|
|
885
903
|
}
|
|
@@ -904,15 +922,47 @@ function citarWin(a) {
|
|
|
904
922
|
return '"' + t.replace(/"/g, '""') + '"';
|
|
905
923
|
}
|
|
906
924
|
|
|
925
|
+
/* O REMOTION COMPARTILHADO. Cada projeto rodava o próprio `npm install`
|
|
926
|
+
(minutos, e ~300 MB por vídeo) — e um 404 passageiro do registro no meio
|
|
927
|
+
dele derrubava o vídeo inteiro (medido em 30/09: source-map-js 1.2.2).
|
|
928
|
+
Agora a instalação mora UMA vez em ~/.primocode/studio/remotion-base, com
|
|
929
|
+
as mesmas dependências, e o projeto aponta para ela. Dependência diferente
|
|
930
|
+
(versão nova do Remotion) reinstala a base. */
|
|
931
|
+
const os = require('os');
|
|
932
|
+
function baseCompartilhada(raiz) {
|
|
933
|
+
const base = path.join(os.homedir(), '.primocode', 'studio', 'remotion-base');
|
|
934
|
+
let deps = '';
|
|
935
|
+
try { const pj = JSON.parse(fs.readFileSync(path.join(raiz, 'package.json'), 'utf8')); deps = JSON.stringify({ d: pj.dependencies || {}, v: pj.devDependencies || {} }); } catch { return null; }
|
|
936
|
+
return { base, deps, pronta: (() => {
|
|
937
|
+
try { return fs.existsSync(path.join(base, 'node_modules', '.bin')) && fs.readFileSync(path.join(base, 'deps.json'), 'utf8') === deps; } catch { return false; }
|
|
938
|
+
})() };
|
|
939
|
+
}
|
|
940
|
+
|
|
907
941
|
function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
|
|
908
942
|
return new Promise((resolve) => {
|
|
909
|
-
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
943
|
+
let temNodeModules = fs.existsSync(path.join(raiz, 'node_modules'));
|
|
944
|
+
const comp = temNodeModules || process.platform === 'win32' ? null : baseCompartilhada(raiz);
|
|
945
|
+
if (comp && comp.pronta) {
|
|
946
|
+
try { fs.symlinkSync(path.join(comp.base, 'node_modules'), path.join(raiz, 'node_modules'), 'dir'); temNodeModules = true; } catch { /* instala aqui mesmo */ }
|
|
947
|
+
}
|
|
948
|
+
const passos = [];
|
|
949
|
+
if (!temNodeModules && comp) {
|
|
950
|
+
// Instala na base (com uma segunda tentativa: o registro às vezes devolve 404 por instantes) e liga.
|
|
951
|
+
try { fs.mkdirSync(comp.base, { recursive: true }); fs.copyFileSync(path.join(raiz, 'package.json'), path.join(comp.base, 'package.json')); } catch { /* segue */ }
|
|
952
|
+
passos.push(['npm', ['install', '--no-audit', '--no-fund'], { cwd: comp.base, tentativas: 2, depois: () => {
|
|
953
|
+
fs.writeFileSync(path.join(comp.base, 'deps.json'), comp.deps);
|
|
954
|
+
try { fs.symlinkSync(path.join(comp.base, 'node_modules'), path.join(raiz, 'node_modules'), 'dir'); } catch { /* já existe */ }
|
|
955
|
+
} }]);
|
|
956
|
+
} else if (!temNodeModules) passos.push(['npm', ['install', '--no-audit', '--no-fund'], { tentativas: 2 }]);
|
|
957
|
+
// Vídeo de b-roll pesado (Pexels HD) levava mais de 30 s para o primeiro quadro.
|
|
958
|
+
/* VÁRIOS TRABALHADORES no render: o Remotion usa metade dos núcleos por
|
|
959
|
+
padrão; aqui vão quase todos (um fica para o terminal respirar). */
|
|
960
|
+
const nucleos = Math.max(2, (require('os').cpus() || []).length - 1);
|
|
961
|
+
passos.push(['npx', ['remotion', 'render', 'Video', saida, '--log=warn', '--timeout=120000', `--concurrency=${nucleos}`]]);
|
|
962
|
+
|
|
963
|
+
const correr = (i, tentativa = 1) => {
|
|
914
964
|
if (i >= passos.length) return resolve({ ok: true, arquivo: saida });
|
|
915
|
-
const [cmd, args] = passos[i];
|
|
965
|
+
const [cmd, args, op = {}] = passos[i];
|
|
916
966
|
aoFalar(i === 0 && !temNodeModules
|
|
917
967
|
? 'instalando o Remotion nesta pasta (uma vez só, alguns minutos)'
|
|
918
968
|
: 'renderizando quadro a quadro');
|
|
@@ -926,7 +976,7 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
|
|
|
926
976
|
CVE-2024-27980 o Node recusa spawnar npm.cmd/npx.cmd sem shell.
|
|
927
977
|
Então as aspas entram na mão, só onde o shell existe. */
|
|
928
978
|
const citados = process.platform === 'win32' ? args.map(citarWin) : args;
|
|
929
|
-
const p = spawn(cmd, citados, { cwd: raiz, shell: process.platform === 'win32' });
|
|
979
|
+
const p = spawn(cmd, citados, { cwd: op.cwd || raiz, shell: process.platform === 'win32' });
|
|
930
980
|
let erro = '';
|
|
931
981
|
p.stderr.on('data', (d) => { erro += d.toString().slice(0, 2000); });
|
|
932
982
|
p.stdout.on('data', (d) => {
|
|
@@ -937,8 +987,10 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
|
|
|
937
987
|
p.on('close', (code) => {
|
|
938
988
|
clearTimeout(relogio);
|
|
939
989
|
if (code !== 0) {
|
|
990
|
+
if (tentativa < (op.tentativas || 1)) { aoFalar('o npm tropeçou — tentando de novo'); return setTimeout(() => correr(i, tentativa + 1), 3000); }
|
|
940
991
|
return resolve({ ok: false, error: `${cmd} saiu com ${code}: ${erro.slice(-600).trim()}` });
|
|
941
992
|
}
|
|
993
|
+
try { if (op.depois) op.depois(); } catch { /* segue */ }
|
|
942
994
|
correr(i + 1);
|
|
943
995
|
});
|
|
944
996
|
p.on('error', (e) => {
|
|
@@ -961,4 +1013,41 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
|
|
|
961
1013
|
});
|
|
962
1014
|
}
|
|
963
1015
|
|
|
964
|
-
|
|
1016
|
+
/* ── ABRIR PARA EDITAR ────────────────────────────────────────────────
|
|
1017
|
+
"Quando eu falar pra editar, edita no editor de vídeo open source." O
|
|
1018
|
+
projeto já é Remotion: editar é abrir o Remotion Studio na pasta dele. Ele
|
|
1019
|
+
sobe solto (o PrimoCode não espera) e abre o navegador sozinho. */
|
|
1020
|
+
function comandoStudio(raiz) {
|
|
1021
|
+
return { cmd: 'npx', args: ['remotion', 'studio'], cwd: raiz };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function abrirStudio({ raiz, spawnar = spawn } = {}) {
|
|
1025
|
+
if (!raiz || !fs.existsSync(path.join(raiz, 'package.json'))) {
|
|
1026
|
+
return { ok: false, error: 'não achei o projeto Remotion nesta pasta.' };
|
|
1027
|
+
}
|
|
1028
|
+
// O Studio precisa do Remotion instalado. A base compartilhada já vem do primeiro render.
|
|
1029
|
+
if (!fs.existsSync(path.join(raiz, 'node_modules'))) {
|
|
1030
|
+
const comp = process.platform === 'win32' ? null : baseCompartilhada(raiz);
|
|
1031
|
+
if (comp && comp.pronta) {
|
|
1032
|
+
try { fs.symlinkSync(path.join(comp.base, 'node_modules'), path.join(raiz, 'node_modules'), 'dir'); } catch { /* segue */ }
|
|
1033
|
+
}
|
|
1034
|
+
if (!fs.existsSync(path.join(raiz, 'node_modules'))) {
|
|
1035
|
+
return { ok: false, error: 'o Remotion ainda não está instalado para este projeto.',
|
|
1036
|
+
comoResolver: 'renderize uma vez com estudio_render (instala o Remotion), depois abra o editor.' };
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
const c = comandoStudio(raiz);
|
|
1040
|
+
const win = process.platform === 'win32';
|
|
1041
|
+
try {
|
|
1042
|
+
const p = spawnar(c.cmd, win ? c.args.map(citarWin) : c.args,
|
|
1043
|
+
{ cwd: c.cwd, detached: true, stdio: 'ignore', shell: win });
|
|
1044
|
+
if (p && p.on) p.on('error', () => { /* sem npx: o caminho vai na resposta */ });
|
|
1045
|
+
if (p && p.unref) p.unref();
|
|
1046
|
+
} catch (e) {
|
|
1047
|
+
return { ok: false, error: e.message };
|
|
1048
|
+
}
|
|
1049
|
+
return { ok: true, raiz, comando: `cd "${raiz}" && npx remotion studio` };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
module.exports = { montar, renderizar, fontesCSS, VERSAO_REMOTION, CENA_JSX, pkgJson, baseCompartilhada, TSCONFIG,
|
|
1053
|
+
comandoStudio, abrirStudio };
|
package/lib/estudio/ritmo.js
CHANGED
|
@@ -25,7 +25,7 @@ const r2 = (n) => Math.round(n * 100) / 100;
|
|
|
25
25
|
*
|
|
26
26
|
* @returns {{ini:number[], dur:number[], total:number, temVoz:boolean}}
|
|
27
27
|
*/
|
|
28
|
-
function tempoDasPartes(partes, tempos = [], { semVoz = () => 2.
|
|
28
|
+
function tempoDasPartes(partes, tempos = [], { semVoz = () => 2.0, antes = 0.08, cauda = 0.6, minimo = 0.9 } = {}) {
|
|
29
29
|
const temVoz = tempos.length === partes.length && tempos.every((t) => t && t.ate > t.de);
|
|
30
30
|
const ini = [];
|
|
31
31
|
if (temVoz) partes.forEach((_, i) => ini.push(i === 0 ? 0 : r2(Math.max(ini[i - 1] + minimo, tempos[i].de - antes))));
|
|
@@ -60,7 +60,7 @@ function palavrasNaCena(tempo, inicio) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/** O volume da trilha no tempo: cheia no respiro, baixa quando a voz fala, some no fim. */
|
|
63
|
-
function volumesDaTrilha(tempos, total, { alto = 0.
|
|
63
|
+
function volumesDaTrilha(tempos, total, { alto = 0.4, baixo = 0.2 } = {}) {
|
|
64
64
|
const k = (t, v) => ({ t: r2(t), v });
|
|
65
65
|
const vs = [k(0, 0.05), k(0.4, alto)];
|
|
66
66
|
// Um quadro-chave de cada vez, sempre depois do anterior: fala colada na outra não volta no tempo.
|
|
@@ -32,6 +32,14 @@ const EFEITOS = {
|
|
|
32
32
|
subida: ['-f', 'lavfi', '-i', "aevalsrc='0.28*sin(2*PI*(180+260*t*t)*t)*(t/1.4)':s=44100:d=1.4", '-af', 'afade=t=out:st=1.2:d=0.2'],
|
|
33
33
|
// Toque grave e macio: o que chega e assenta.
|
|
34
34
|
toque: ['-f', 'lavfi', '-i', "aevalsrc='0.7*sin(2*PI*(110-50*t)*t)*exp(-7*t)':s=44100:d=0.6", '-af', 'lowpass=f=900'],
|
|
35
|
+
/* Os CHEIOS do motion gerativo ("motion é vida: efeito sonoro NA ação",
|
|
36
|
+
30/09): o whoosh da passagem, o impacto da tese, o riser antes da
|
|
37
|
+
revelação e o pop da letra que nasce. Ainda sintetizados, ainda limpos. */
|
|
38
|
+
whoosh: ['-f', 'lavfi', '-i', 'anoisesrc=d=0.75:c=pink:a=0.9', '-af',
|
|
39
|
+
'bandpass=f=1100:width_type=o:w=2.2,afade=t=in:st=0:d=0.42:curve=exp,afade=t=out:st=0.42:d=0.33:curve=qsin,volume=2.2'],
|
|
40
|
+
impacto: ['-f', 'lavfi', '-i', "aevalsrc='0.95*sin(2*PI*(62-26*t)*t)*exp(-4.5*t)+0.45*(random(0)*2-1)*exp(-38*t)':s=44100:d=1.1", '-af', 'lowpass=f=2600,volume=1.4'],
|
|
41
|
+
riser: ['-f', 'lavfi', '-i', "aevalsrc='(0.32*sin(2*PI*(170+620*t*t)*t)+0.22*(random(0)*2-1))*pow(t/1.1,2)':s=44100:d=1.1", '-af', 'highpass=f=260,afade=t=out:st=1.04:d=0.06,volume=1.3'],
|
|
42
|
+
pop: ['-f', 'lavfi', '-i', "aevalsrc='0.8*sin(2*PI*(980-640*t)*t)*exp(-28*t)*(1-exp(-700*t))':s=44100:d=0.18"],
|
|
35
43
|
};
|
|
36
44
|
|
|
37
45
|
/**
|
|
@@ -59,4 +67,33 @@ function preparar(pasta, { rodar = null } = {}) {
|
|
|
59
67
|
return sons;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
|
-
|
|
70
|
+
/**
|
|
71
|
+
* A família de sons de UM vídeo (gerativo.js sonsDoVideo): cada receita vira
|
|
72
|
+
* midia/sfx-g<semente>-<nome>.mp3. O que já existe não é refeito.
|
|
73
|
+
*/
|
|
74
|
+
function prepararReceitas(pasta, { receitas = {}, semente = 0 } = {}, { rodar = null } = {}) {
|
|
75
|
+
const exec = rodar || ((args) => {
|
|
76
|
+
try { require('child_process').execFileSync('ffmpeg', args, { stdio: 'ignore', timeout: 30000 }); return true; }
|
|
77
|
+
catch { return false; }
|
|
78
|
+
});
|
|
79
|
+
const sons = {};
|
|
80
|
+
const sufixo = (semente >>> 0).toString(36);
|
|
81
|
+
for (const [nome, args] of Object.entries(receitas)) {
|
|
82
|
+
const rel = `midia/sfx-g${sufixo}-${nome}.mp3`;
|
|
83
|
+
const cheio = path.join(pasta, rel);
|
|
84
|
+
if (!fs.existsSync(cheio)) {
|
|
85
|
+
fs.mkdirSync(path.dirname(cheio), { recursive: true });
|
|
86
|
+
// O limitador no fim: o golpe sintetizado passava de 0 dB e estalava.
|
|
87
|
+
// "{pasta}" é a pasta do projeto: o som feito da própria trilha lê o arquivo dela.
|
|
88
|
+
const a = args.map((x) => String(x).split('{pasta}').join(pasta));
|
|
89
|
+
const fc = a.indexOf('-filter_complex'), i = a.indexOf('-af');
|
|
90
|
+
if (fc >= 0) a[fc + 1] += ',alimiter=limit=0.8:level=false';
|
|
91
|
+
else if (i >= 0) a[i + 1] += ',alimiter=limit=0.8:level=false'; else a.push('-af', 'alimiter=limit=0.8:level=false');
|
|
92
|
+
if (!exec(['-y', '-loglevel', 'error', ...a, '-q:a', '3', cheio])) continue;
|
|
93
|
+
}
|
|
94
|
+
sons[nome] = rel;
|
|
95
|
+
}
|
|
96
|
+
return sons;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { EFEITOS, preparar, prepararReceitas };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* variacao3d.js — o produto em 3D (kit/tres.jsx) diferente a cada vídeo.
|
|
3
|
+
*
|
|
4
|
+
* "várias opções de elementos 3D... vários estilos... que não sejam padrão
|
|
5
|
+
* da IA" (Joel, 05/10)
|
|
6
|
+
*
|
|
7
|
+
* Antes, todo fone saía igual: o mesmo material, a mesma câmera, a mesma luz
|
|
8
|
+
* e o mesmo giro. Agora cada vídeo sorteia o acabamento, o enquadramento, a
|
|
9
|
+
* luz e o ritmo — sem repetir os últimos (memória '3d' do variedade.js). Os
|
|
10
|
+
* nomes daqui são as chaves que o tres.jsx desenha.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const variedade = require('./variedade.js');
|
|
16
|
+
|
|
17
|
+
const VARIACOES = {
|
|
18
|
+
acabamento: ['verniz', 'metal', 'fosco', 'perola', 'acetinado'],
|
|
19
|
+
camera: ['frente', 'alto', 'baixo', 'perfil', 'perto'],
|
|
20
|
+
luz: ['estudio', 'recorte', 'suave', 'lateral'],
|
|
21
|
+
ritmo: ['lento', 'medio', 'rapido', 'pendulo'],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Um 3D por vídeo: cada faceta sorteada sem repetir as últimas. */
|
|
25
|
+
function escolherVariacao3d(aleatorio = Math.random) {
|
|
26
|
+
const v = {};
|
|
27
|
+
for (const [faceta, opcoes] of Object.entries(VARIACOES)) v[faceta] = variedade.sortear(opcoes, '3d', { prefixo: faceta + ':', aleatorio });
|
|
28
|
+
return v;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Qual modelo o pedido descreve. Sem pista, a caixa — um produto genérico que ainda gira. */
|
|
32
|
+
const MODELOS3D = [
|
|
33
|
+
['fone', /fone|headset|headphone|auricular/i],
|
|
34
|
+
['celular', /celular|smartphone|iphone|phone|telefone/i],
|
|
35
|
+
['notebook', /notebook|laptop|computador|macbook/i],
|
|
36
|
+
['relogio', /rel[oó]gio|smartwatch|\bwatch\b/i],
|
|
37
|
+
['garrafa', /garrafa|squeeze|bottle|garrafinha|vinho|perfume/i],
|
|
38
|
+
['lata', /lata|bebida|refri|cerveja|energ[eé]tico/i],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function modeloDoPedido(texto) {
|
|
42
|
+
const achado = MODELOS3D.find(([, re]) => re.test(String(texto || '')));
|
|
43
|
+
return achado ? achado[0] : 'caixa';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { VARIACOES, MODELOS3D, escolherVariacao3d, modeloDoPedido };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* variedade.js — a trilha e os efeitos dos vídeos de corte (híbrido, cinema,
|
|
3
|
+
* impacto) sem repetir de um vídeo para o outro.
|
|
4
|
+
*
|
|
5
|
+
* "todos os vídeos é sempre as mesmas músicas, os mesmos efeitos sonoros,
|
|
6
|
+
* sempre o mesmo padrão de vídeo" (Joel, 02/10)
|
|
7
|
+
*
|
|
8
|
+
* A causa era a mesma nos dois: um termo fixo na busca ("inspiring
|
|
9
|
+
* corporate", "cinematic whoosh") e o PRIMEIRO resultado baixado. A busca
|
|
10
|
+
* devolve a mesma lista na mesma ordem, então todo vídeo saía com a mesma
|
|
11
|
+
* faixa e o mesmo whoosh. Aqui:
|
|
12
|
+
* 1. o termo sai de um cardápio pelo clima do roteiro, em ordem sorteada;
|
|
13
|
+
* 2. dos resultados, sorteia — e pula o que os últimos vídeos já usaram;
|
|
14
|
+
* 3. o que foi usado entra na memória (~/.primocode/studio/<tipo>-usadas.json).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const PASTA = path.join(os.homedir(), '.primocode', 'studio');
|
|
24
|
+
const MEMORIA = { trilha: 16, sfx: 40, broll: 80, icones: 4, estilo: 8, '3d': 24 };
|
|
25
|
+
|
|
26
|
+
function arquivo(tipo) { return path.join(PASTA, `${tipo}-usadas.json`); }
|
|
27
|
+
|
|
28
|
+
function usadas(tipo) {
|
|
29
|
+
try { const l = JSON.parse(fs.readFileSync(arquivo(tipo), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function marcar(tipo, id) {
|
|
33
|
+
if (!id) return;
|
|
34
|
+
try {
|
|
35
|
+
fs.mkdirSync(PASTA, { recursive: true });
|
|
36
|
+
const l = [id, ...usadas(tipo).filter((x) => x !== id)].slice(0, MEMORIA[tipo] || 20);
|
|
37
|
+
fs.writeFileSync(arquivo(tipo), JSON.stringify(l));
|
|
38
|
+
} catch { /* sem memória: só perde a garantia de não repetir */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function embaralhar(lista, aleatorio = Math.random) {
|
|
42
|
+
const a = [...lista];
|
|
43
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
44
|
+
const j = Math.floor(aleatorio() * (i + 1));
|
|
45
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
46
|
+
}
|
|
47
|
+
return a;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Os itens da busca na ordem de tentar: os nunca usados primeiro (sorteados), os usados no fim. */
|
|
51
|
+
function ordenar(itens, tipo, aleatorio = Math.random) {
|
|
52
|
+
const ja = new Set(usadas(tipo));
|
|
53
|
+
const validos = (itens || []).filter((x) => x && x.url);
|
|
54
|
+
return [...embaralhar(validos.filter((x) => !ja.has(x.url)), aleatorio), ...validos.filter((x) => ja.has(x.url))];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Uma das opções, sem repetir as últimas: as nunca usadas primeiro (sorteadas),
|
|
59
|
+
* depois a usada há mais tempo. Marca a escolhida na memória do tipo. Com
|
|
60
|
+
* `prefixo`, várias listas dividem o mesmo arquivo ('acabamento:fosco').
|
|
61
|
+
*/
|
|
62
|
+
function sortear(opcoes, tipo, { prefixo = '', aleatorio = Math.random } = {}) {
|
|
63
|
+
if (!opcoes || !opcoes.length) return null;
|
|
64
|
+
const ja = usadas(tipo);
|
|
65
|
+
const idade = (o) => ja.indexOf(prefixo + o); // 0 = a última usada
|
|
66
|
+
const novas = embaralhar(opcoes.filter((o) => idade(o) < 0), aleatorio);
|
|
67
|
+
const velhas = opcoes.filter((o) => idade(o) >= 0).sort((a, b) => idade(b) - idade(a));
|
|
68
|
+
const escolha = [...novas, ...velhas][0];
|
|
69
|
+
marcar(tipo, prefixo + escolha);
|
|
70
|
+
return escolha;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── a trilha: termos por clima ──────────────────────────────────────────
|
|
74
|
+
const CLIMAS = {
|
|
75
|
+
tecnologia: ['electronic instrumental', 'synthwave instrumental', 'future bass instrumental', 'downtempo electronic', 'minimal techno instrumental', 'chillwave instrumental'],
|
|
76
|
+
energetico: ['funk instrumental', 'rock instrumental', 'drum and bass instrumental', 'breakbeat instrumental', 'upbeat indie instrumental', 'house instrumental'],
|
|
77
|
+
epico: ['epic orchestral', 'trailer instrumental', 'cinematic drums', 'epic hybrid instrumental', 'dramatic strings'],
|
|
78
|
+
calmo: ['lofi hip hop instrumental', 'ambient instrumental', 'acoustic guitar instrumental', 'piano instrumental', 'jazz instrumental'],
|
|
79
|
+
alegre: ['ukulele instrumental', 'indie pop instrumental', 'happy acoustic instrumental', 'latin instrumental', 'disco instrumental'],
|
|
80
|
+
emocional: ['piano emotional instrumental', 'post rock instrumental', 'cinematic strings', 'neo classical instrumental'],
|
|
81
|
+
misterio: ['dark ambient instrumental', 'trip hop instrumental', 'suspense instrumental', 'noir jazz instrumental'],
|
|
82
|
+
neutro: ['indie instrumental', 'soul instrumental', 'jazz hop instrumental', 'world instrumental', 'electronic instrumental', 'acoustic instrumental'],
|
|
83
|
+
};
|
|
84
|
+
const SINONIMOS = {
|
|
85
|
+
tecnologia: /tec|tech|ia\b|ai\b|software|app|digital|futur/, energetico: /energ|agit|rapid|forte|esport|academ/,
|
|
86
|
+
epico: /epic|epico|grandi|trailer|impact/, calmo: /calm|tranq|suave|relax|chill/, alegre: /aleg|feliz|divert|leve/,
|
|
87
|
+
emocional: /emoc|emoti|inspir|sentim|historia/, misterio: /mist|sombr|dark|suspen|tens/,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function climaDe(roteiro = {}, estilo = '') {
|
|
91
|
+
const t = String([roteiro.clima, roteiro.genero, roteiro.tom].filter(Boolean).join(' ')).toLowerCase()
|
|
92
|
+
.normalize('NFD').replace(/[̀-ͯ]/g, '');
|
|
93
|
+
for (const [clima, re] of Object.entries(SINONIMOS)) if (re.test(t)) return clima;
|
|
94
|
+
return estilo === 'impacto' ? 'energetico' : 'neutro';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Os termos para buscar a trilha, em ordem de tentar. O pedido explícito
|
|
99
|
+
* (roteiro.musica) vem primeiro; depois o clima, sorteado; e por último um
|
|
100
|
+
* clima vizinho, para nunca sair sem música.
|
|
101
|
+
*/
|
|
102
|
+
function termosTrilha(roteiro = {}, estilo = '', aleatorio = Math.random) {
|
|
103
|
+
const clima = climaDe(roteiro, estilo);
|
|
104
|
+
const reserva = clima === 'neutro' ? 'calmo' : 'neutro';
|
|
105
|
+
return [...new Set([roteiro.musica, ...embaralhar(CLIMAS[clima], aleatorio).slice(0, 3),
|
|
106
|
+
...embaralhar(CLIMAS[reserva], aleatorio).slice(0, 2)].filter(Boolean))];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── os efeitos: várias formas de pedir o mesmo som ───────────────────────
|
|
110
|
+
const SFX = {
|
|
111
|
+
teclado: ['keyboard typing', 'typing keyboard', 'mechanical keyboard', 'laptop typing', 'typewriter keys'],
|
|
112
|
+
whoosh: ['cinematic whoosh', 'swoosh transition', 'air whoosh', 'fast swipe', 'woosh pass by', 'riser whoosh'],
|
|
113
|
+
impacto: ['cinematic boom impact', 'deep cinematic impact', 'trailer hit', 'bass drop hit', 'punch hit', 'sub boom'],
|
|
114
|
+
sala: ['crowd murmur indoor', 'office ambience', 'people talking background', 'cafe ambience', 'room tone people'],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function termosSfx(nome, aleatorio = Math.random) {
|
|
118
|
+
return embaralhar(SFX[nome] || [nome], aleatorio).slice(0, 3);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── o b-roll: o mesmo pedido, de jeitos diferentes ───────────────────────
|
|
122
|
+
/* "não ficar só nas mesmas fontes, mesmos estilos" (Joel, 05/10): o mesmo
|
|
123
|
+
roteiro renderizado duas vezes trocou fonte, cor e música, mas o b-roll
|
|
124
|
+
veio IDÊNTICO — as mesmas mãos no teclado. O termo era o mesmo e o
|
|
125
|
+
primeiro resultado do Pexels também. Agora o termo ganha um jeito de
|
|
126
|
+
filmar sorteado, e o clipe já usado vai para o fim da fila. */
|
|
127
|
+
const JEITOS = ['cinematic', 'slow motion', 'close up', 'natural light', 'detail shot', 'wide shot', 'handheld'];
|
|
128
|
+
|
|
129
|
+
function termosBroll(termo, aleatorio = Math.random) {
|
|
130
|
+
const t = String(termo || '').replace(/\s+/g, ' ').trim();
|
|
131
|
+
if (!t) return [];
|
|
132
|
+
const jeitos = embaralhar(JEITOS.filter((j) => !t.toLowerCase().includes(j)), aleatorio).slice(0, 2);
|
|
133
|
+
return embaralhar([t, ...jeitos.map((j) => `${t} ${j}`)], aleatorio);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Busca com as variações do termo e devolve os itens na ordem de tentar:
|
|
138
|
+
* para no primeiro termo que trouxer algo nunca usado. `buscar(termo)`
|
|
139
|
+
* devolve a lista de itens ({url, …}) — é a busca de verdade, ou um stub.
|
|
140
|
+
*/
|
|
141
|
+
async function buscarSemRepetir(buscar, termo, tipo = 'broll', aleatorio = Math.random) {
|
|
142
|
+
const ja = new Set(usadas(tipo));
|
|
143
|
+
let reserva = [];
|
|
144
|
+
for (const t of termosBroll(termo, aleatorio)) {
|
|
145
|
+
let itens = [];
|
|
146
|
+
try { itens = ordenar(await buscar(t), tipo, aleatorio); } catch { continue; }
|
|
147
|
+
if (itens.length && !ja.has(itens[0].url)) return itens;
|
|
148
|
+
if (!reserva.length) reserva = itens;
|
|
149
|
+
}
|
|
150
|
+
return reserva;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── os ícones: a coleção muda de um vídeo para o outro ──────────────────
|
|
154
|
+
/* O ícone automático (edicao.iconeDe) é sempre "fluent-3d:<emoji>". Estas
|
|
155
|
+
coleções do Iconify usam os mesmos nomes de emoji, então dá para trocar a
|
|
156
|
+
coleção inteira do vídeo; o que não existir na coleção nova fica no 3D. */
|
|
157
|
+
const FAMILIAS_ICONE = ['fluent-3d', 'fluent-emoji-flat', 'fluent-emoji', 'noto', 'twemoji', 'openmoji'];
|
|
158
|
+
|
|
159
|
+
function colecaoIcones(aleatorio = Math.random) {
|
|
160
|
+
return ordenar(FAMILIAS_ICONE.map((url) => ({ url })), 'icones', aleatorio)[0].url;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cadaIcone(plano, fn) {
|
|
164
|
+
for (const cena of (plano && plano.cenas) || []) for (const el of cena.elementos || []) if (el && el.tipo === 'icone' && el.nome) fn(el);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Os ícones automáticos do plano — os que o roteiro pediu pelo nome ficam como estão. */
|
|
168
|
+
function iconesTrocaveis(plano, explicitos = []) {
|
|
169
|
+
const fixos = new Set(explicitos);
|
|
170
|
+
const nomes = new Set();
|
|
171
|
+
cadaIcone(plano, (el) => { if (/^fluent-3d:/.test(el.nome) && !fixos.has(el.nome)) nomes.add(el.nome); });
|
|
172
|
+
return [...nomes];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Troca no plano cada nome que estiver em `troca` ({velho: novo}). Devolve quantos trocou. */
|
|
176
|
+
function trocarIcones(plano, troca = {}) {
|
|
177
|
+
let n = 0;
|
|
178
|
+
cadaIcone(plano, (el) => { if (troca[el.nome]) { el.nome = troca[el.nome]; n++; } });
|
|
179
|
+
return n;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { usadas, marcar, embaralhar, ordenar, sortear, climaDe, termosTrilha, termosSfx, termosBroll, buscarSemRepetir,
|
|
183
|
+
colecaoIcones, iconesTrocaveis, trocarIcones, CLIMAS, SFX, JEITOS, FAMILIAS_ICONE };
|