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
package/lib/estudio/index.js
CHANGED
|
@@ -34,6 +34,11 @@ const { spawn } = require('child_process');
|
|
|
34
34
|
|
|
35
35
|
const projeto = require('./projeto.js');
|
|
36
36
|
const acervo = require('./acervo.js');
|
|
37
|
+
const hibrido = require('./hibrido.js');
|
|
38
|
+
const biblioteca = require('./biblioteca.js');
|
|
39
|
+
const referencias = require('./referencias.js');
|
|
40
|
+
const variedade = require('./variedade.js');
|
|
41
|
+
const variacao3d = require('./variacao3d.js');
|
|
37
42
|
const plano = require('./plano.js');
|
|
38
43
|
const remotion = require('./remotion.js');
|
|
39
44
|
const assets = require('./assets.js');
|
|
@@ -46,7 +51,10 @@ const acervoSite = require('./acervo-site.js');
|
|
|
46
51
|
const impacto = require('./impacto.js');
|
|
47
52
|
const cinema = require('./cinema.js');
|
|
48
53
|
const motion = require('./motion.js');
|
|
54
|
+
const gerativo = require('./gerativo.js');
|
|
55
|
+
const fluido = require('./fluido.js');
|
|
49
56
|
const vox = require('./vox.js');
|
|
57
|
+
const kokoro = require('./kokoro.js');
|
|
50
58
|
const lancamento = require('./lancamento.js');
|
|
51
59
|
const glifos = require('./glifos.js');
|
|
52
60
|
const { escolherEstilo, LEVES } = require('./estilo.js');
|
|
@@ -149,9 +157,11 @@ function semProjeto(id) {
|
|
|
149
157
|
};
|
|
150
158
|
}
|
|
151
159
|
|
|
152
|
-
async function ferramentaProjeto(args) {
|
|
160
|
+
async function ferramentaProjeto(args, ctx = {}) {
|
|
153
161
|
const acao = String(args.acao || 'listar').toLowerCase();
|
|
154
162
|
|
|
163
|
+
if (acao === 'editar' || acao === 'editor' || acao === 'studio') return editarNoStudio(args, ctx);
|
|
164
|
+
|
|
155
165
|
if (acao === 'criar' || acao === 'novo') {
|
|
156
166
|
const r = projeto.criar({
|
|
157
167
|
titulo: args.titulo, pedido: args.pedido || args.titulo, formato: args.formato || 'video',
|
|
@@ -195,6 +205,28 @@ async function ferramentaProjeto(args) {
|
|
|
195
205
|
};
|
|
196
206
|
}
|
|
197
207
|
|
|
208
|
+
/* EDITAR um vídeo que o Estúdio fez: abre o projeto Remotion dele no
|
|
209
|
+
Remotion Studio. Sem id, é o projeto mais novo. */
|
|
210
|
+
async function editarNoStudio(args, ctx = {}) {
|
|
211
|
+
const p = args.id ? projeto.ler(args.id) : projeto.listar({ limite: 1 })[0];
|
|
212
|
+
if (!p) return semProjeto(args.id);
|
|
213
|
+
const raiz = path.join(p.pasta, 'remotion');
|
|
214
|
+
if (!fs.existsSync(path.join(raiz, 'package.json'))) {
|
|
215
|
+
// Projeto que ainda não virou Remotion: escreve agora, sem renderizar.
|
|
216
|
+
const m = await ferramentaRender({ id: p.id, somenteProjeto: true }, ctx);
|
|
217
|
+
if (!m.ok) return m;
|
|
218
|
+
}
|
|
219
|
+
const r = remotion.abrirStudio({ raiz, spawnar: ctx.spawnar });
|
|
220
|
+
if (!r.ok) return { ...r, pasta: raiz };
|
|
221
|
+
projeto.anotar(p.id, 'editor', 'abriu no Remotion Studio');
|
|
222
|
+
return {
|
|
223
|
+
ok: true, id: p.id, pasta: raiz, comando: r.comando,
|
|
224
|
+
aviso: 'O Remotion Studio está ABRINDO no navegador (geralmente http://localhost:3000). '
|
|
225
|
+
+ `Diga ao usuário onde está o projeto: ${raiz}. Lá ele mexe e renderiza pelo botão Render do Studio. `
|
|
226
|
+
+ 'Cuidado: estudio_render reescreve a pasta src/ a partir do plano e apaga o que ele mudou à mão.',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
198
230
|
function listarArquivos(pasta) {
|
|
199
231
|
const saida = {};
|
|
200
232
|
for (const sub of ['entrada', 'midia', 'saida']) {
|
|
@@ -306,8 +338,15 @@ async function ferramentaPlano(args) {
|
|
|
306
338
|
sons (lib/estudio/composicao.js). É o caminho para qualquer modelo
|
|
307
339
|
entregar motion e slides que prestam. */
|
|
308
340
|
if (args.roteiro) {
|
|
341
|
+
/* O roteiro como a pessoa (ou o modelo) mandou: o híbrido traduz as
|
|
342
|
+
partes para o cinema no caminho, e o que fica guardado tem de ser o
|
|
343
|
+
original — senão o próximo ajuste parte do traduzido. */
|
|
344
|
+
let original = null;
|
|
345
|
+
try { original = JSON.parse(typeof args.roteiro === 'string' ? args.roteiro : JSON.stringify(args.roteiro)); } catch { original = args.roteiro; }
|
|
309
346
|
const r = await prepararRoteiro(p, args);
|
|
310
347
|
if (!r.ok) return r;
|
|
348
|
+
const ic = await variarIcones(r.plano, original);
|
|
349
|
+
if (ic.trocados) projeto.anotar(p.id, 'icones', `coleção ${ic.colecao}: ${ic.trocados} ícones`);
|
|
311
350
|
let conferida = plano.conferir(r.plano, { pasta: p.pasta });
|
|
312
351
|
/* A rede de segurança: o elemento que não passa sai, e o vídeo segue.
|
|
313
352
|
Um defeito num ícone não pode custar o vídeo inteiro. */
|
|
@@ -322,7 +361,7 @@ async function ferramentaPlano(args) {
|
|
|
322
361
|
}
|
|
323
362
|
projeto.salvarPlano(args.id, r.plano, args.motivo || 'montado do roteiro');
|
|
324
363
|
// O roteiro fica guardado: ajustar uma frase é mandar o roteiro de novo, não reescrever tudo.
|
|
325
|
-
try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(args.roteiro, null, 2)); } catch { /* segue */ }
|
|
364
|
+
try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(original || args.roteiro, null, 2)); } catch { /* segue */ }
|
|
326
365
|
projeto.salvar(args.id, { duracao: conferida.duracao });
|
|
327
366
|
return {
|
|
328
367
|
ok: true, duracao: conferida.duracao, ...r.resumo, resumo: plano.resumo(r.plano),
|
|
@@ -375,7 +414,7 @@ async function ferramentaMidia(args) {
|
|
|
375
414
|
|
|
376
415
|
if (acao === 'fontes') {
|
|
377
416
|
if (args.conferir) return assets.conferirFonte(args.conferir);
|
|
378
|
-
return { ok: true, fontes: assets.FONTES,
|
|
417
|
+
return { ok: true, fontes: assets.FONTES, biblioteca: biblioteca.cardapio(),
|
|
379
418
|
nota: 'qualquer família do Google Fonts funciona — esta lista é para não repetir sempre as mesmas.' };
|
|
380
419
|
}
|
|
381
420
|
|
|
@@ -673,27 +712,54 @@ async function ferramentaVideo(args, ctx = {}) {
|
|
|
673
712
|
}
|
|
674
713
|
|
|
675
714
|
/* A mídia de apoio de um termo: vídeo do Pexels quando pedido e houver,
|
|
676
|
-
senão
|
|
715
|
+
senão uma foto. Devolve {src, tipo} ou null. O clipe sai sorteado entre
|
|
716
|
+
os que os últimos vídeos não usaram (variedade.js), não o primeiro da
|
|
717
|
+
busca — "o b-roll veio igual nos dois vídeos" (Joel, 05/10). */
|
|
677
718
|
async function baixarApoio(p, termo, { orientacao = null, video: querVideo = false } = {}) {
|
|
678
719
|
if (querVideo) {
|
|
679
720
|
try {
|
|
680
|
-
const
|
|
681
|
-
|
|
721
|
+
const itens = await variedade.buscarSemRepetir(async (t) => {
|
|
722
|
+
const b = await assets.procurarVideos(t, { quantas: 8, orientacao });
|
|
723
|
+
return (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 3);
|
|
724
|
+
}, termo);
|
|
725
|
+
for (const item of itens.slice(0, 4)) {
|
|
682
726
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
|
|
683
|
-
if (d.ok) return { src: d.relativo, tipo: 'video' };
|
|
727
|
+
if (d.ok) { variedade.marcar('broll', item.url); return { src: d.relativo, tipo: 'video' }; }
|
|
684
728
|
}
|
|
685
729
|
} catch { /* cai para a foto */ }
|
|
686
730
|
}
|
|
687
731
|
try {
|
|
688
|
-
const
|
|
689
|
-
|
|
732
|
+
const itens = await variedade.buscarSemRepetir(async (t) => {
|
|
733
|
+
const b = await assets.procurarImagens(t, { quantas: 8, orientacao });
|
|
734
|
+
return b.ok ? b.imagens : [];
|
|
735
|
+
}, termo);
|
|
736
|
+
for (const item of itens.slice(0, 4)) {
|
|
690
737
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
|
|
691
|
-
if (d.ok) return { src: d.relativo, tipo: 'imagem' };
|
|
738
|
+
if (d.ok) { variedade.marcar('broll', item.url); return { src: d.relativo, tipo: 'imagem' }; }
|
|
692
739
|
}
|
|
693
740
|
} catch { /* sem apoio */ }
|
|
694
741
|
return null;
|
|
695
742
|
}
|
|
696
743
|
|
|
744
|
+
/* Os ícones automáticos trocam de coleção a cada vídeo (Joel, 05/10: "várias
|
|
745
|
+
opções de ícones... que não sejam padrão da IA"). O ícone que o roteiro
|
|
746
|
+
pediu pelo nome fica; o que não existe na coleção sorteada fica no 3D. */
|
|
747
|
+
async function variarIcones(plano, roteiro) {
|
|
748
|
+
const colecao = variedade.colecaoIcones();
|
|
749
|
+
const explicitos = [];
|
|
750
|
+
try { JSON.stringify(roteiro || {}, (k, v) => { if (k === 'icone' && typeof v === 'string') explicitos.push(v); return v; }); } catch { /* sem */ }
|
|
751
|
+
const troca = {};
|
|
752
|
+
if (colecao !== 'fluent-3d') {
|
|
753
|
+
for (const nome of variedade.iconesTrocaveis(plano, explicitos)) {
|
|
754
|
+
const novo = colecao + ':' + nome.split(':')[1];
|
|
755
|
+
try { const r = await assets.icone(novo); if (r && r.ok) troca[nome] = novo; } catch { /* fica o 3D */ }
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
const n = variedade.trocarIcones(plano, troca);
|
|
759
|
+
if (n || colecao === 'fluent-3d') variedade.marcar('icones', colecao);
|
|
760
|
+
return { colecao, trocados: n };
|
|
761
|
+
}
|
|
762
|
+
|
|
697
763
|
/* ── A VOZ DO GEMINI ─────────────────────────────────────────────────────
|
|
698
764
|
* O roteiro inteiro numa chamada só (o plano grátis tem poucos pedidos por
|
|
699
765
|
* minuto), e as partes achadas depois na transcrição do próprio áudio: a
|
|
@@ -858,11 +924,23 @@ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
|
|
|
858
924
|
const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
|
|
859
925
|
const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
|
|
860
926
|
if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
|
|
927
|
+
// A voz local pedida (voz: 'kokoro'): direto nela, sem gastar a cota do Gemini.
|
|
928
|
+
if (kokoro.querKokoro(voz)) return kokoro.narrar(lista, mp3, { voz, uv: acharUv() });
|
|
861
929
|
const v = await narrarComGemini(lista, mp3, { voz, direcao });
|
|
862
930
|
if (v.ok) { try { fs.mkdirSync(dir, { recursive: true }); fs.copyFileSync(mp3, cm); fs.writeFileSync(cj, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes })); } catch { /* sem cache */ } }
|
|
863
931
|
return v;
|
|
864
932
|
}
|
|
865
933
|
|
|
934
|
+
/* A VOZ DE RESERVA, quando o Gemini falha: o Kokoro (local, sem cota) e
|
|
935
|
+
depois o Edge. A falha de uma passa para a próxima, nunca derruba. */
|
|
936
|
+
function narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge = false, semKokoro = false } = {}) {
|
|
937
|
+
const passos = kokoro.ordem(voz, process.env, { soEdge }).filter((n) => n !== 'gemini' && !(semKokoro && n === 'kokoro'));
|
|
938
|
+
return kokoro.corrente(passos, {
|
|
939
|
+
kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
|
|
940
|
+
edge: () => rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(kokoro.querKokoro(voz, {}) ? vozEdge : voz || vozEdge)], { timeout: 10 * 60000 }),
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
866
944
|
/* A MÚSICA TEM LETRA? O acervo não diz se a faixa é instrumental, então ela
|
|
867
945
|
é ouvida: 25 s do meio vão para a transcrição, e mais de 6 palavras é voz
|
|
868
946
|
cantada. Sem servidor para ouvir, a faixa passa (melhor música que nenhuma). */
|
|
@@ -887,21 +965,18 @@ async function temLetra(arquivo) {
|
|
|
887
965
|
(25/09/2026) */
|
|
888
966
|
async function sonsReais(p, video) {
|
|
889
967
|
const sint = await edicao.prepararSons(p.pasta, video);
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
whoosh: ['cinematic whoosh', 'swoosh transition'],
|
|
893
|
-
impacto: ['cinematic boom impact', 'deep cinematic impact', 'trailer hit'],
|
|
894
|
-
sala: ['crowd murmur indoor', 'office ambience', 'people talking background'],
|
|
895
|
-
};
|
|
968
|
+
/* Termo sorteado e resultado sorteado, sem repetir os últimos: o primeiro
|
|
969
|
+
resultado do termo fixo era o mesmo whoosh em todo vídeo (02/10). */
|
|
896
970
|
const sons = { ...sint };
|
|
897
|
-
for (const [
|
|
898
|
-
for (const termo of
|
|
971
|
+
for (const nome of ['teclado', 'whoosh', 'impacto', 'sala']) {
|
|
972
|
+
for (const termo of variedade.termosSfx(nome)) {
|
|
973
|
+
if (sons[nome] !== sint[nome]) break;
|
|
899
974
|
try {
|
|
900
|
-
const b = await assets.procurarSom(termo, { quantas:
|
|
901
|
-
const item
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
975
|
+
const b = await assets.procurarSom(termo, { quantas: 10, livre: true });
|
|
976
|
+
for (const item of variedade.ordenar(b.ok ? b.sons : [], 'sfx').slice(0, 3)) {
|
|
977
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
|
|
978
|
+
if (d.ok) { sons[nome] = d.relativo; variedade.marcar('sfx', item.url); break; }
|
|
979
|
+
}
|
|
905
980
|
} catch { /* o próximo termo */ }
|
|
906
981
|
}
|
|
907
982
|
}
|
|
@@ -932,7 +1007,48 @@ async function prepararRoteiro(p, args) {
|
|
|
932
1007
|
lançamento de produto vira LANCAMENTO, anúncio e reels vão para o
|
|
933
1008
|
IMPACTO. O que o agente pediu vale mais que tudo. */
|
|
934
1009
|
const pedidoTxt = `${p.pedido || ''} ${args.pedido || ''}`;
|
|
935
|
-
const
|
|
1010
|
+
const ref = referencias.atual();
|
|
1011
|
+
/* Ninguém escolheu? O padrão gira (estilo.js, RODIZIO) — menos no "sem
|
|
1012
|
+
referências", que só tem o motion e segue a regra de baixo. */
|
|
1013
|
+
let estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo, girar: ref.modo !== 'sem' });
|
|
1014
|
+
/* "Sem referências, só motion": o híbrido vive de b-roll de fora, então
|
|
1015
|
+
cai para o motion — sem nada baixado. */
|
|
1016
|
+
const produtoNoPedido = /produto|an[uú]nci|lan[çc]a|headset|fone|celular|t[eê]nis|rel[oó]gio|garrafa|lata|app\b/i.test(pedidoTxt);
|
|
1017
|
+
/* E o motion de agora é o GERATIVO: sem template, produto ou não (o
|
|
1018
|
+
produto em 3D entra numa batida dele, logo abaixo). */
|
|
1019
|
+
if (ref.modo === 'sem' && estilo === 'hibrido') estilo = 'gerativo';
|
|
1020
|
+
void produtoNoPedido;
|
|
1021
|
+
/* "O vídeo deve ter um 3D do fone" (30/09): pedido de 3D vira o gerativo
|
|
1022
|
+
com o produto modelado girando numa batida (kit/tres.jsx) — a menos que
|
|
1023
|
+
o agente tenha escolhido outro estilo pelo nome. */
|
|
1024
|
+
if (/\b3\s?d\b|tridimensional/i.test(pedidoTxt) && (!args.estilo || estilo === 'gerativo' || estilo === 'lancamento')) {
|
|
1025
|
+
// O 3D mora numa batida do gerativo; o lançamento antigo, só pelo nome.
|
|
1026
|
+
if (!args.estilo && estilo !== 'lancamento') estilo = 'gerativo';
|
|
1027
|
+
if (!roteiro.modelo3d) roteiro.modelo3d = variacao3d.modeloDoPedido(pedidoTxt);
|
|
1028
|
+
}
|
|
1029
|
+
// O 3D de cada vídeo: acabamento, câmera, luz e ritmo sorteados sem repetir os últimos.
|
|
1030
|
+
/* Por PROJETO, não por chamada: mandar o roteiro de novo para ajustar uma
|
|
1031
|
+
frase não pode sortear outro 3D nem outro vídeo. Projeto novo, sorteio novo. */
|
|
1032
|
+
if (roteiro.modelo3d && !roteiro.variacao3d) {
|
|
1033
|
+
roteiro.variacao3d = p.variacao3d || variacao3d.escolherVariacao3d();
|
|
1034
|
+
if (!p.variacao3d) projeto.salvar(p.id, { variacao3d: roteiro.variacao3d });
|
|
1035
|
+
}
|
|
1036
|
+
/* A SEMENTE do gerativo saía do texto do roteiro: o mesmo roteiro dava o
|
|
1037
|
+
mesmo vídeo, batida por batida (05/10). Sem semente do roteiro, cada
|
|
1038
|
+
projeto sorteia a sua. */
|
|
1039
|
+
if (estilo === 'gerativo' && roteiro.semente == null) {
|
|
1040
|
+
roteiro.semente = p.semente != null ? p.semente : Math.floor(Math.random() * 1e9);
|
|
1041
|
+
if (p.semente == null) projeto.salvar(p.id, { semente: roteiro.semente });
|
|
1042
|
+
}
|
|
1043
|
+
if (ref.modo === 'fechado' && !roteiro.busca && referencias.termos().length) roteiro.busca = referencias.termos();
|
|
1044
|
+
/* A BIBLIOTECA (biblioteca.js): sem fonte e cor da marca, o híbrido, o
|
|
1045
|
+
cinema e o impacto caíam sempre no Inter e no azul e roxo — "os vídeos
|
|
1046
|
+
todos iguais" (02/10). A marca manda; depois o roteiro.visual da IA;
|
|
1047
|
+
sem nada, o sorteio que pula o que os últimos vídeos usaram. */
|
|
1048
|
+
if (['hibrido', 'cinema', 'impacto'].includes(estilo)) {
|
|
1049
|
+
const v = biblioteca.vestir(roteiro);
|
|
1050
|
+
if (v.mudou) projeto.anotar(p.id, 'visual', [v.titulo && `fonte ${v.titulo} + ${v.texto}`, v.paleta && `paleta ${v.paleta}`].filter(Boolean).join(', '));
|
|
1051
|
+
}
|
|
936
1052
|
// Os estilos de motion usam os efeitos leves e a trilha do ende.app; os de corte, os cheios.
|
|
937
1053
|
const ehMotion = LEVES.has(estilo);
|
|
938
1054
|
const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
|
|
@@ -981,7 +1097,15 @@ async function prepararRoteiro(p, args) {
|
|
|
981
1097
|
}
|
|
982
1098
|
|
|
983
1099
|
let tempos = [], narracao = null, sons = {}, trilha = null;
|
|
984
|
-
|
|
1100
|
+
/* O HÍBRIDO (hibrido.js) é o cinema com o kit por cima: o roteiro de
|
|
1101
|
+
qualquer estilo vira os tipos do cinema aqui, antes da voz — a voz lê
|
|
1102
|
+
o que o cinema vai mostrar. */
|
|
1103
|
+
let hib = null;
|
|
1104
|
+
if (modo === 'video' && estilo === 'hibrido') {
|
|
1105
|
+
hib = hibrido.traduzir(roteiro);
|
|
1106
|
+
partes.splice(0, partes.length, ...hib.roteiro.partes);
|
|
1107
|
+
}
|
|
1108
|
+
if (modo === 'video' && (estilo === 'cinema' || estilo === 'hibrido')) {
|
|
985
1109
|
// Só as partes com voz vão para a narração; o depoimento tem a voz do aluno.
|
|
986
1110
|
// Só as partes com voz: o depoimento tem a voz do aluno, e a parte muda não tem voz.
|
|
987
1111
|
// A parte com voz forte é narrada à parte: se entrasse aqui também, seria falada duas vezes (e muda uma delas).
|
|
@@ -1012,6 +1136,8 @@ async function prepararRoteiro(p, args) {
|
|
|
1012
1136
|
}
|
|
1013
1137
|
} catch { /* fica o alinhamento guardado */ }
|
|
1014
1138
|
}
|
|
1139
|
+
} else if (kokoro.querKokoro(args.voz || roteiro.voz)) {
|
|
1140
|
+
v = { ok: false, error: 'kokoro pedido' };
|
|
1015
1141
|
} else {
|
|
1016
1142
|
v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
|
|
1017
1143
|
direcao: 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
|
|
@@ -1024,7 +1150,7 @@ async function prepararRoteiro(p, args) {
|
|
|
1024
1150
|
const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
|
|
1025
1151
|
// Ritmo NORMAL: o "citação" (-10%, respiro longo) esticou o vídeo para 125 s.
|
|
1026
1152
|
fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
|
|
1027
|
-
v = await
|
|
1153
|
+
v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'antonio', { soEdge });
|
|
1028
1154
|
}
|
|
1029
1155
|
if (!v.ok) return v;
|
|
1030
1156
|
/* A DURAÇÃO que o roteiro pede ("60–75 s"): se a voz passa do que cabe,
|
|
@@ -1051,20 +1177,39 @@ async function prepararRoteiro(p, args) {
|
|
|
1051
1177
|
}
|
|
1052
1178
|
}
|
|
1053
1179
|
}
|
|
1180
|
+
/* No HÍBRIDO a voz anda um pouco mais rápido por padrão (1,12×, como no
|
|
1181
|
+
vox): o trailer premium do cinema lido pelo Gemini sai no passo de
|
|
1182
|
+
leitura, e o vídeo segue a voz. `roteiro.ritmoVoz` ajusta. */
|
|
1183
|
+
if (hib && ritmoVoz <= 1.01) {
|
|
1184
|
+
const kh = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz) || 1.12));
|
|
1185
|
+
const rapido = mp3.replace(/\.mp3$/, '-h.mp3');
|
|
1186
|
+
const c = kh > 1.01 ? await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${kh.toFixed(3)}`, rapido], { timeout: 120000 }) : { ok: false };
|
|
1187
|
+
if (process.env.PRIMOCODE_TRACO === '1') process.stderr.write(`[traço] voz do híbrido ${kh}× ok=${c.ok} existe=${fs.existsSync(rapido)} ${String(c.erro || '').slice(-120)}\n`);
|
|
1188
|
+
if (c.ok && fs.existsSync(rapido)) {
|
|
1189
|
+
fs.renameSync(rapido, mp3);
|
|
1190
|
+
ritmoVoz = kh;
|
|
1191
|
+
const div = (x) => Math.round((x / kh) * 1000) / 1000;
|
|
1192
|
+
v.partes = v.partes.map((pt) => ({ de: div(pt.de), ate: div(pt.ate), palavras: (pt.palavras || []).map((w) => ({ ...w, de: div(w.de), ate: div(w.ate) })) }));
|
|
1193
|
+
v.segundos = div(v.segundos);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1054
1196
|
projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${lista.length} partes, voz ${v.voz}`);
|
|
1055
1197
|
const sonsC = await sonsReais(p, video);
|
|
1056
1198
|
let trilhaC = null;
|
|
1057
1199
|
// Vários termos e durações: vídeo sem música foi o que saiu na primeira vez (25/09).
|
|
1058
1200
|
// E só instrumental: "essa música daria pra deixar só o instrumental?" — a faixa é OUVIDA.
|
|
1059
|
-
|
|
1060
|
-
|
|
1201
|
+
// O termo sai do clima do roteiro (variedade.js), não de uma lista fixa de "cinematic inspiring".
|
|
1202
|
+
for (const termo of [...variedade.termosTrilha(roteiro, 'cinema'), 'instrumental']) {
|
|
1203
|
+
if (trilhaC) break;
|
|
1061
1204
|
try {
|
|
1062
|
-
const b = await assets.procurarMusica(termo, { quantas:
|
|
1063
|
-
for (const item of (b.ok ? b.sons : [])) {
|
|
1205
|
+
const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
|
|
1206
|
+
for (const item of variedade.ordenar(b.ok ? b.sons : [], 'trilha').filter((x) => !/vocal|voice|feat|ft\.|remix/i.test(x.titulo || '')).slice(0, 4)) {
|
|
1064
1207
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
|
|
1065
1208
|
if (!d.ok) continue;
|
|
1066
1209
|
if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
|
|
1067
|
-
trilhaC = d.relativo;
|
|
1210
|
+
trilhaC = d.relativo; variedade.marcar('trilha', item.url);
|
|
1211
|
+
projeto.anotar(p.id, 'trilha', `"${termo}": ${item.titulo} — ${item.autor || 'Jamendo'} (${item.licenca || 'CC'})`);
|
|
1212
|
+
break;
|
|
1068
1213
|
}
|
|
1069
1214
|
} catch { /* o próximo termo */ }
|
|
1070
1215
|
}
|
|
@@ -1085,7 +1230,76 @@ async function prepararRoteiro(p, args) {
|
|
|
1085
1230
|
const MINI = /poster|face \d+|\bthumb/i;
|
|
1086
1231
|
const fotosC = acervo.fotos.filter((f) => !MINI.test(f.alt || ''))
|
|
1087
1232
|
.map((f) => ({ src: f.src, tipo: 'imagem', cat: TELA.test(f.alt || '') ? 'tela' : 'pessoas', alt: f.alt }));
|
|
1088
|
-
|
|
1233
|
+
/* O B-ROLL do híbrido: o cinema vive de foto que troca por baixo do
|
|
1234
|
+
texto, e o acervo da marca raramente passa de meia dúzia. Vídeo de
|
|
1235
|
+
banco primeiro (o fundo que se move sozinho), foto como reserva —
|
|
1236
|
+
em paralelo, porque são oito buscas. */
|
|
1237
|
+
/* Foto de compartilhamento (og:image) e logo trazem o NOME da marca em
|
|
1238
|
+
letra gigante: como fundo, viravam um "THRO" cortado atrás do texto. */
|
|
1239
|
+
/* E arte de banner (2400×1260 e afins): é o logo da marca desenhado, não
|
|
1240
|
+
uma cena — cortada no vídeo em pé, vira um "✳ Cl" gigante. */
|
|
1241
|
+
const banner = (alt) => { const m = /(\d{3,4})x(\d{3,4})/.exec(alt || ''); return m && Number(m[1]) / Number(m[2]) > 1.7 && altura > largura; };
|
|
1242
|
+
if (hib) for (let q = fotosC.length - 1; q >= 0; q--) if (/open graph|og[:_ -]?image|\blogo|banner|wordmark/i.test(fotosC[q].alt || '') || banner(fotosC[q].alt)) fotosC.splice(q, 1);
|
|
1243
|
+
if (hib && fotosC.length < 14) {
|
|
1244
|
+
const orientacao = largura >= altura ? 'landscape' : 'portrait';
|
|
1245
|
+
const pastaMidia = path.join(p.pasta, 'midia');
|
|
1246
|
+
const jaTem = (() => { try { return fs.readdirSync(pastaMidia); } catch { return []; } })();
|
|
1247
|
+
const cat = (termo) => (/screen|code|data|server|dashboard/i.test(termo) ? 'tela' : 'pessoas');
|
|
1248
|
+
/* A versão LEVE do b-roll: o Pexels entrega 1080×1920 inteiro (até 30
|
|
1249
|
+
MB e 20 s), e o render parava num quadro esperando vários vídeos
|
|
1250
|
+
assim ao mesmo tempo (medido: timeout de 120 s no quadro 184). Cada
|
|
1251
|
+
um vira 8 s, 720 de largura, sem áudio — e fica guardado, para o
|
|
1252
|
+
próximo render não baixar de novo. */
|
|
1253
|
+
const leve = async (rel, destino) => {
|
|
1254
|
+
const alvo = path.join(pastaMidia, destino);
|
|
1255
|
+
const escala = largura >= altura ? 'scale=1280:-2' : 'scale=720:-2';
|
|
1256
|
+
const c = await video.rodar(video.ffmpeg(), ['-y', '-i', path.join(p.pasta, rel), '-t', '8', '-vf', escala + ',fps=30', '-an',
|
|
1257
|
+
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '24', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', alvo], { timeout: 180000 });
|
|
1258
|
+
if (!c.ok || !fs.existsSync(alvo)) return null;
|
|
1259
|
+
try { fs.unlinkSync(path.join(p.pasta, rel)); } catch { /* fica o original */ }
|
|
1260
|
+
return 'midia/' + destino;
|
|
1261
|
+
};
|
|
1262
|
+
const achados = await Promise.all(hibrido.termosDeBusca(roteiro, hib.extras).map(async (termo) => {
|
|
1263
|
+
const out = [];
|
|
1264
|
+
const slug = 'broll-' + String(termo).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
1265
|
+
const proxy = `${slug}.leve.mp4`;
|
|
1266
|
+
if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo });
|
|
1267
|
+
else try {
|
|
1268
|
+
// Sorteado entre os não usados, com o termo variado (variedade.js, 05/10).
|
|
1269
|
+
const itens = await variedade.buscarSemRepetir(async (t) => {
|
|
1270
|
+
const b = await assets.procurarVideos(t, { quantas: 8, orientacao });
|
|
1271
|
+
return (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 4);
|
|
1272
|
+
}, termo);
|
|
1273
|
+
for (const item of itens.slice(0, 1)) {
|
|
1274
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
|
|
1275
|
+
const l = d.ok ? await leve(d.relativo, proxy) : null;
|
|
1276
|
+
if (l) { out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
|
|
1277
|
+
}
|
|
1278
|
+
} catch { /* segue com a foto */ }
|
|
1279
|
+
const foto = jaTem.find((f) => f.startsWith(slug + '-') && /\.(jpe?g|png|webp)$/i.test(f));
|
|
1280
|
+
if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo });
|
|
1281
|
+
else try {
|
|
1282
|
+
const itens = await variedade.buscarSemRepetir(async (t) => {
|
|
1283
|
+
const b = await assets.procurarImagens(t, { quantas: 8, orientacao });
|
|
1284
|
+
return b.ok ? b.imagens : [];
|
|
1285
|
+
}, termo);
|
|
1286
|
+
for (const item of itens.slice(0, 1)) {
|
|
1287
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
|
|
1288
|
+
if (d.ok) { out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
|
|
1289
|
+
}
|
|
1290
|
+
} catch { /* sem */ }
|
|
1291
|
+
return out;
|
|
1292
|
+
}));
|
|
1293
|
+
const novos = achados.flat();
|
|
1294
|
+
// Intercalados com as da marca: a foto da marca não pode ficar toda no começo.
|
|
1295
|
+
const marcaF = fotosC.splice(0);
|
|
1296
|
+
for (let q = 0; q < Math.max(marcaF.length, novos.length); q++) {
|
|
1297
|
+
if (novos[q]) fotosC.push(novos[q]);
|
|
1298
|
+
if (marcaF[q]) fotosC.push(marcaF[q]);
|
|
1299
|
+
}
|
|
1300
|
+
projeto.anotar(p.id, 'midia', `b-roll do híbrido: ${novos.filter((f) => f.tipo === 'video').length} vídeos e ${novos.filter((f) => f.tipo === 'imagem').length} fotos`);
|
|
1301
|
+
}
|
|
1302
|
+
const rostos = await acharRostos(p, fotosC.filter((f) => f.tipo !== 'video').map((f) => f.src));
|
|
1089
1303
|
for (const f of fotosC) if (rostos[f.src]) f.rosto = rostos[f.src];
|
|
1090
1304
|
// Os depoimentos: cortados na palavra, com a voz no volume certo e fade.
|
|
1091
1305
|
for (const pt of partes) {
|
|
@@ -1126,8 +1340,12 @@ async function prepararRoteiro(p, args) {
|
|
|
1126
1340
|
iNarr++;
|
|
1127
1341
|
}
|
|
1128
1342
|
const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
|
|
1129
|
-
sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes });
|
|
1130
|
-
if (r.ok
|
|
1343
|
+
sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes, ritmoLeitura: hib ? 0.55 : 1 });
|
|
1344
|
+
if (r.ok && hib) {
|
|
1345
|
+
hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura, hud: roteiro.hud === true, particulas: roteiro.particulas === true });
|
|
1346
|
+
r.resumo.estilo = 'hibrido';
|
|
1347
|
+
}
|
|
1348
|
+
if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${hib ? 'hibrido' : 'cinema'}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
|
|
1131
1349
|
return r;
|
|
1132
1350
|
}
|
|
1133
1351
|
if (modo === 'video') {
|
|
@@ -1137,31 +1355,105 @@ async function prepararRoteiro(p, args) {
|
|
|
1137
1355
|
fs.writeFileSync(arq, JSON.stringify(lista));
|
|
1138
1356
|
const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
|
|
1139
1357
|
// A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1358
|
+
// Com cache: refazer o vídeo (outro estilo, outra proporção) não gasta a cota da voz.
|
|
1359
|
+
let v = await narrarComCache(lista, mp3, { voz: args.voz || roteiro.voz,
|
|
1360
|
+
direcao: estilo === 'gerativo'
|
|
1361
|
+
/* O gerativo pede voz com CORPO ("narração encorpada, extravagante",
|
|
1362
|
+
30/09): cheia, quente, perto do microfone — e o ritmo segue 1,12×. */
|
|
1363
|
+
/* (A primeira versão pedia "trailer" e "ênfase sustentada": a voz saiu 25% mais lenta, medido em 30/09.) */
|
|
1364
|
+
? 'Leia em português do Brasil como locutor de reels de tecnologia: RÁPIDO e contínuo, com voz cheia, quente e encorpada, '
|
|
1365
|
+
+ 'bem perto do microfone, muita energia e sorriso na voz. Ataque firme e ênfase curta e forte nas palavras de impacto; '
|
|
1366
|
+
+ 'quase sem pausa entre as frases. Nunca arraste, nunca leia devagar nem solene.'
|
|
1367
|
+
// O fluido é o vídeo de produto: voz limpa e segura, e rápida ("mais rápido", 06/10).
|
|
1368
|
+
: estilo === 'fluido' ? 'Leia em português do Brasil como narrador de vídeo de produto premium: seguro, próximo e RÁPIDO, '
|
|
1369
|
+
+ 'frases limpas e contínuas, quase sem pausa entre elas. Nunca arraste, nunca leia devagar; sem tom de anúncio.'
|
|
1370
|
+
: estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
|
|
1371
|
+
+ 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
|
|
1372
|
+
// "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
|
|
1373
|
+
: 'Leia em português do Brasil como narrador de reels de tecnologia: vivo, confiante e rápido, sorrindo na voz, '
|
|
1374
|
+
+ 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.' });
|
|
1375
|
+
// O Kokoro pedido já foi tentado no narrarComCache: a reserva não repete.
|
|
1376
|
+
if (!v.ok) v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'thalita', { semKokoro: kokoro.querKokoro(args.voz || roteiro.voz) });
|
|
1144
1377
|
if (!v.ok) return v;
|
|
1378
|
+
/* O RITMO DA VOZ. Mesmo com a direção, a voz sai no passo de leitura, e
|
|
1379
|
+
o vídeo — que segue a voz palavra por palavra — sai lento junto. O
|
|
1380
|
+
áudio acelera 1,12× (1,3× ficou corrido demais, 29/09; atempo não muda o tom) e cada tempo de palavra
|
|
1381
|
+
acompanha; `roteiro.ritmoVoz` ajusta (1 desliga). */
|
|
1382
|
+
// O fluido corre mais ("mais rápido", 06/10): 1,22× por padrão.
|
|
1383
|
+
const ritmo = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz || args.ritmoVoz) || (estilo === 'fluido' ? 1.22 : 1.12)));
|
|
1384
|
+
if (ritmo > 1.01) {
|
|
1385
|
+
const rap = mp3.replace(/\.mp3$/, '-r.mp3');
|
|
1386
|
+
const c = await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${ritmo.toFixed(3)}`, rap], { timeout: 120000 });
|
|
1387
|
+
if (c.ok && fs.existsSync(rap)) {
|
|
1388
|
+
fs.renameSync(rap, mp3);
|
|
1389
|
+
const esc = (x) => (typeof x === 'number' ? Math.round((x / ritmo) * 1000) / 1000 : x);
|
|
1390
|
+
v.segundos = esc(v.segundos);
|
|
1391
|
+
v.partes = (v.partes || []).map((pt) => ({ ...pt, de: esc(pt.de), ate: esc(pt.ate),
|
|
1392
|
+
palavras: (pt.palavras || []).map((w) => ({ ...w, de: esc(w.de), ate: esc(w.ate) })) }));
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1145
1395
|
tempos = v.partes;
|
|
1146
1396
|
narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
|
|
1147
1397
|
projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
|
|
1148
1398
|
// O motion usa os efeitos leves; os estilos de corte, os cheios.
|
|
1149
|
-
sons
|
|
1399
|
+
// O gerativo tem a família de sons DELE (sintetizada da semente); os outros motion, os leves.
|
|
1400
|
+
// O gerativo desenha os sons DEPOIS da partitura (um arquivo por evento, da própria trilha).
|
|
1401
|
+
sons = estilo === 'gerativo' ? {}
|
|
1402
|
+
: ehMotion ? sonsLeves.preparar(p.pasta) : await edicao.prepararSons(p.pasta, video);
|
|
1150
1403
|
// A trilha do motion: ende.app (CC BY 4.0, sem Content ID), clima tecnologia.
|
|
1151
1404
|
if (ehMotion) {
|
|
1152
1405
|
try {
|
|
1153
1406
|
const segundos = tempos.length ? tempos[tempos.length - 1].ate + 2 : 20;
|
|
1154
|
-
|
|
1407
|
+
/* O gerativo escolhe o GÊNERO pelo assunto (jazz para o explainer,
|
|
1408
|
+
funk para o fone, eletrônica para IA…), sorteia a faixa e não
|
|
1409
|
+
repete as últimas usadas — nada de "inspiring corporate". */
|
|
1410
|
+
let m = null;
|
|
1411
|
+
const usadasArq = path.join(os.homedir(), '.primocode', 'studio', 'trilhas-usadas.json');
|
|
1412
|
+
let usadas = [];
|
|
1413
|
+
try { usadas = JSON.parse(fs.readFileSync(usadasArq, 'utf8')); } catch { usadas = []; }
|
|
1414
|
+
const mus = estilo === 'gerativo' ? gerativo.musicaDe(roteiro) : null;
|
|
1415
|
+
/* A trilha do gerativo vem do Jamendo (Openverse), pelo GÊNERO do
|
|
1416
|
+
assunto, instrumental, sorteada e sem repetir as últimas — a
|
|
1417
|
+
ende.app fica de reserva ("parar de cair sempre na ende", 30/09). */
|
|
1418
|
+
if (mus) {
|
|
1419
|
+
const BUSCA = { jazz: 'jazz instrumental', 'lofi-chill-beats': 'lofi hip hop instrumental', electronica: 'electronic instrumental', funk: 'funk instrumental',
|
|
1420
|
+
'dance-club': 'house instrumental', ambient: 'ambient instrumental', acoustic: 'acoustic guitar instrumental', cinematic: 'cinematic instrumental',
|
|
1421
|
+
classical: 'piano classical instrumental', 'soul-rnb': 'soul instrumental' };
|
|
1422
|
+
try {
|
|
1423
|
+
const b = await assets.procurarMusica(BUSCA[roteiro.genero || mus.genero] || ((roteiro.genero || mus.genero) + ' instrumental'), { quantas: 8, duracaoMin: Math.max(45, segundos + 10) });
|
|
1424
|
+
const opcoes = (b.ok ? b.sons : []).filter((x) => x.url && !usadas.includes(x.url) && !/vocal|voice|feat|ft\.|remix/i.test(x.titulo || ''));
|
|
1425
|
+
for (let k = 0; k < opcoes.length && !m; k++) {
|
|
1426
|
+
const item = opcoes[(mus.semente + k) % opcoes.length];
|
|
1427
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha-' + (roteiro.genero || mus.genero) });
|
|
1428
|
+
if (!d.ok) continue;
|
|
1429
|
+
if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
|
|
1430
|
+
m = { src: path.join(p.pasta, d.relativo), credito: `Música: ${item.titulo} — ${item.autor || 'Jamendo'} (${item.licenca || 'CC'}, Jamendo)`, id: item.url, segundos: item.segundos };
|
|
1431
|
+
}
|
|
1432
|
+
} catch { /* cai na ende.app */ }
|
|
1433
|
+
}
|
|
1434
|
+
if (mus && m) {
|
|
1435
|
+
try { fs.writeFileSync(usadasArq, JSON.stringify([m.id, ...usadas.filter((x) => x !== m.id)].slice(0, 16))); } catch { /* sem memória */ }
|
|
1436
|
+
projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero} (Jamendo)`);
|
|
1437
|
+
} else if (mus) {
|
|
1438
|
+
m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'energetico', segundos, genero: roteiro.genero || mus.genero, semente: mus.semente, evitar: usadas });
|
|
1439
|
+
const id = m && Number((/ende-(\d+)\.mp3$/.exec(m.src) || [])[1]);
|
|
1440
|
+
if (id) try { fs.writeFileSync(usadasArq, JSON.stringify([id, ...usadas.filter((x) => x !== id)].slice(0, 16))); } catch { /* sem memória */ }
|
|
1441
|
+
if (m) projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero}`);
|
|
1442
|
+
} else m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
|
|
1155
1443
|
if (m) { trilha = path.relative(p.pasta, m.src).replace(/\\/g, '/'); projeto.anotar(p.id, 'trilha', m.credito); }
|
|
1156
1444
|
} catch { /* cai na busca de sempre */ }
|
|
1157
1445
|
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1446
|
+
// Termo pelo clima e faixa sorteada sem repetir — o "inspiring corporate" fixo era a mesma música em todo vídeo.
|
|
1447
|
+
for (const termo of [args.trilha, ...variedade.termosTrilha(roteiro, estilo)].filter(Boolean)) {
|
|
1448
|
+
if (trilha) break;
|
|
1449
|
+
try {
|
|
1450
|
+
const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
|
|
1451
|
+
for (const item of variedade.ordenar(b.ok ? b.sons : [], 'trilha').slice(0, 3)) {
|
|
1452
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
|
|
1453
|
+
if (d.ok) { trilha = d.relativo; variedade.marcar('trilha', item.url); projeto.anotar(p.id, 'trilha', `"${termo}": ${item.titulo}`); break; }
|
|
1454
|
+
}
|
|
1455
|
+
} catch { /* o próximo termo */ }
|
|
1456
|
+
}
|
|
1165
1457
|
}
|
|
1166
1458
|
if (estilo === 'impacto' && modo === 'video') {
|
|
1167
1459
|
// Os emojis das palavras FALADAS também (a batida mostra a fala).
|
|
@@ -1197,6 +1489,33 @@ async function prepararRoteiro(p, args) {
|
|
|
1197
1489
|
if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${estilo}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s${precisa ? (g ? ', vetor com os pontos da fonte' : ', vetor sem os pontos (sem a fonte)') : ''}`);
|
|
1198
1490
|
return r;
|
|
1199
1491
|
}
|
|
1492
|
+
if (estilo === 'gerativo' && modo === 'video') {
|
|
1493
|
+
/* A música a -18 LUFS (a voz sai a -14): os volumes do plano partem
|
|
1494
|
+
daí — cheia no respiro, uns 9 dB abaixo quando a voz fala. */
|
|
1495
|
+
if (trilha && !/-n18\.mp3$/.test(trilha)) {
|
|
1496
|
+
const norm = path.join(p.pasta, 'midia', path.basename(trilha).replace(/\.[a-z0-9]+$/i, '') + '-n18.mp3');
|
|
1497
|
+
/* A introdução da faixa fica de fora (ela começa morna): corta de 12% em
|
|
1498
|
+
diante, com 0,4 s de entrada, no tamanho do vídeo. */
|
|
1499
|
+
let ini = 0;
|
|
1500
|
+
try { const md = await video.medir(path.join(p.pasta, trilha)); const tot = md.segundos || 0; const quer = (tempos.length ? tempos[tempos.length - 1].ate : 30) + 6; if (tot > quer + 8) ini = Math.min(tot * 0.12, tot - quer - 2); } catch { /* do começo */ }
|
|
1501
|
+
const c = fs.existsSync(norm) ? { ok: true } : await video.rodar(video.ffmpeg(), ['-y', ...(ini > 1 ? ['-ss', ini.toFixed(2)] : []), '-i', path.join(p.pasta, trilha),
|
|
1502
|
+
'-af', 'afade=t=in:st=0:d=0.4,loudnorm=I=-18:TP=-2:LRA=11', '-ar', '44100', norm], { timeout: 5 * 60000 });
|
|
1503
|
+
if (c.ok && fs.existsSync(norm)) trilha = path.relative(p.pasta, norm).replace(/\\/g, '/');
|
|
1504
|
+
}
|
|
1505
|
+
const r = gerativo.montarGerativo({ roteiro, largura, altura, tempos, narracao, sons, trilha });
|
|
1506
|
+
if (r.ok) {
|
|
1507
|
+
const feitos = sonsLeves.prepararReceitas(p.pasta, r.somReceitas);
|
|
1508
|
+
// Som que o ffmpeg não conseguiu fazer sai do plano (o vídeo não espera um arquivo que não existe).
|
|
1509
|
+
r.plano.audio.efeitos = r.plano.audio.efeitos.filter((e) => Object.values(feitos).includes(e.src));
|
|
1510
|
+
}
|
|
1511
|
+
if (r.ok) projeto.anotar(p.id, 'plano', `estilo gerativo: ${r.resumo.batidas} batidas (${r.resumo.composicoes.join(' → ')}), ${r.resumo.segundos}s, semente ${r.resumo.semente}`);
|
|
1512
|
+
return r;
|
|
1513
|
+
}
|
|
1514
|
+
if (estilo === 'fluido' && modo === 'video') {
|
|
1515
|
+
const r = fluido.montarFluido({ roteiro, largura, altura, tempos, narracao, sons, trilha });
|
|
1516
|
+
if (r.ok) projeto.anotar(p.id, 'plano', `estilo fluido: ${r.resumo.batidas} batidas, paleta ${r.resumo.paleta}, ${r.resumo.segundos}s, batida atrasada no máximo ${r.resumo.atraso}s, palavra atrás da voz no máximo ${r.resumo.sincronia}s`);
|
|
1517
|
+
return r;
|
|
1518
|
+
}
|
|
1200
1519
|
if (estilo === 'motion' && modo === 'video') {
|
|
1201
1520
|
const r = motion.montarMotion({ roteiro, largura, altura, tempos, narracao, sons, trilha, imagens });
|
|
1202
1521
|
if (r.ok) projeto.anotar(p.id, 'plano', `estilo motion: ${r.resumo.segundos}s contínuos, ${r.resumo.partes} partes, ${r.resumo.fps} fps`);
|