primocode 9.10.0 → 10.0.9
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 +30 -6
- package/bin/primocode.js +169 -342
- package/lib/act.js +360 -39
- package/lib/browser.js +105 -4
- package/lib/casar.js +108 -0
- package/lib/catalogo.js +6 -2
- package/lib/claude-engine.js +38 -17
- package/lib/codex-engine.js +27 -9
- package/lib/config.js +10 -3
- package/lib/cursor.js +10 -1
- package/lib/entrada.js +77 -11
- package/lib/estudio/cinema.js +27 -9
- package/lib/estudio/diretor.js +21 -1
- package/lib/estudio/ende.js +16 -5
- package/lib/estudio/estilo.js +66 -0
- package/lib/estudio/gerativo.js +1453 -0
- package/lib/estudio/glifos.js +215 -0
- package/lib/estudio/hibrido.js +165 -0
- package/lib/estudio/index.js +284 -53
- package/lib/estudio/kit/base.jsx +285 -0
- package/lib/estudio/kit/gerativo.jsx +980 -0
- package/lib/estudio/kit/index.jsx +13 -0
- package/lib/estudio/kit/marca.jsx +741 -0
- package/lib/estudio/kit/tres.jsx +308 -0
- package/lib/estudio/kit/vox.jsx +357 -0
- package/lib/estudio/kit.js +78 -0
- package/lib/estudio/lancamento.js +441 -0
- package/lib/estudio/motion.js +2 -5
- package/lib/estudio/plano.js +28 -3
- package/lib/estudio/referencias.js +105 -0
- package/lib/estudio/remotion.js +139 -51
- package/lib/estudio/ritmo.js +77 -0
- package/lib/estudio/sons-leves.js +38 -1
- package/lib/estudio/variedade.js +105 -0
- package/lib/estudio/vox.js +414 -0
- package/lib/fala.js +1 -94
- package/lib/mcp.js +156 -0
- package/lib/personas.js +67 -0
- package/lib/pipeline.js +55 -56
- package/lib/repeticao.js +3 -1
- package/lib/skills.js +44 -2
- package/lib/snapshot.js +111 -0
- package/lib/tools.js +100 -22
- package/lib/ui.js +50 -8
- package/lib/video/analise.js +89 -0
- package/lib/video/auto.js +329 -0
- package/lib/video/camadas.js +207 -0
- package/lib/video/cli.js +74 -0
- package/lib/video/delta.js +90 -0
- package/lib/video/edits.js +379 -0
- package/lib/video/filters.js +280 -0
- 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 +347 -0
- package/lib/video/paralelo.js +50 -0
- package/lib/video/pedido.js +191 -0
- package/lib/video/projeto.js +133 -0
- package/lib/video/rastrear.js +95 -0
- package/lib/video/rastro.js +276 -0
- package/lib/video/render.js +274 -0
- package/lib/video/texto.js +144 -0
- package/lib/video/timeline.js +318 -0
- package/lib/video/transcript.js +124 -0
- package/lib/voz.js +75 -223
- package/package.json +2 -3
- package/skills/motion-de-primeira/SKILL.md +5 -5
- package/studio/narrar.py +10 -7
- package/studio/rastrear.py +399 -0
- package/studio/texto.py +208 -0
- package/voz/primo_voz/__main__.py +96 -236
- package/voz/primo_voz/transcrever.py +31 -1
- package/app/ansi.js +0 -55
- package/app/app.css +0 -500
- package/app/app.js +0 -1017
- package/app/icone.svg +0 -6
- package/app/index.html +0 -220
- package/app/primo-marca.js +0 -192
- package/lib/app.js +0 -615
- package/lib/icone.js +0 -263
- package/lib/instalar-app.js +0 -231
- package/lib/painel.js +0 -291
- package/voz/primo_voz/desperta.py +0 -195
- package/voz/primo_voz/orbe.py +0 -406
package/lib/estudio/index.js
CHANGED
|
@@ -34,6 +34,9 @@ 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 referencias = require('./referencias.js');
|
|
39
|
+
const variedade = require('./variedade.js');
|
|
37
40
|
const plano = require('./plano.js');
|
|
38
41
|
const remotion = require('./remotion.js');
|
|
39
42
|
const assets = require('./assets.js');
|
|
@@ -46,6 +49,11 @@ const acervoSite = require('./acervo-site.js');
|
|
|
46
49
|
const impacto = require('./impacto.js');
|
|
47
50
|
const cinema = require('./cinema.js');
|
|
48
51
|
const motion = require('./motion.js');
|
|
52
|
+
const gerativo = require('./gerativo.js');
|
|
53
|
+
const vox = require('./vox.js');
|
|
54
|
+
const lancamento = require('./lancamento.js');
|
|
55
|
+
const glifos = require('./glifos.js');
|
|
56
|
+
const { escolherEstilo, LEVES } = require('./estilo.js');
|
|
49
57
|
const sonsLeves = require('./sons-leves.js');
|
|
50
58
|
const ende = require('./ende.js');
|
|
51
59
|
|
|
@@ -222,7 +230,8 @@ function roteiroValido(r) {
|
|
|
222
230
|
const partes = Array.isArray(x) ? x : x && x.partes;
|
|
223
231
|
if (!Array.isArray(partes) || partes.length < 2) return null;
|
|
224
232
|
const CINEMA = ['frase', 'montagem', 'numeros', 'escada', 'dia', 'depoimento', 'climax', 'final'];
|
|
225
|
-
|
|
233
|
+
const NOVOS = ['logo', 'cores', 'produto']; // vox.js e lancamento.js
|
|
234
|
+
if (!partes.every((pt) => pt && (composicao.TIPOS.includes(pt.tipo) || CINEMA.includes(pt.tipo) || NOVOS.includes(pt.tipo)))) return null;
|
|
226
235
|
const obj = Array.isArray(x) ? { partes } : x;
|
|
227
236
|
return partes.some((pt) => CINEMA.includes(pt.tipo)) && !obj.estilo ? { ...obj, estilo: 'cinema' } : obj;
|
|
228
237
|
}
|
|
@@ -245,7 +254,6 @@ async function garantirAcervo(p) {
|
|
|
245
254
|
/* O ROTEIRISTA do servidor: quando o modelo não manda um roteiro que preste
|
|
246
255
|
(nenhum, ou texto corrido), o pedido e os fatos da marca viram o roteiro
|
|
247
256
|
numa chamada só, com uma tarefa só. */
|
|
248
|
-
const PEDE_MOTION = /\bmotion\b|anima[çc][ãa]o|animad[oa]/i;
|
|
249
257
|
|
|
250
258
|
async function roteiroDoServidor(p, args) {
|
|
251
259
|
const cfg = require('../config').loadConfig();
|
|
@@ -260,7 +268,7 @@ async function roteiroDoServidor(p, args) {
|
|
|
260
268
|
site: acervo.site ? new URL(acervo.site).hostname.replace(/^www\./, '') : '',
|
|
261
269
|
rascunho: typeof args.roteiro === 'string' ? args.roteiro : '',
|
|
262
270
|
// Pedido de motion nunca vira impacto, nem sendo anúncio.
|
|
263
|
-
estilo: args.estilo || (
|
|
271
|
+
estilo: args.estilo || (escolherEstilo({ pedido }) === 'impacto' ? 'impacto' : undefined),
|
|
264
272
|
}, cfg.token);
|
|
265
273
|
if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'o roteirista não respondeu.' };
|
|
266
274
|
return { ok: true, roteiro: { ...r.roteiro, marca: (args.roteiro && args.roteiro.marca) || p.marca || undefined } };
|
|
@@ -302,6 +310,11 @@ async function ferramentaPlano(args) {
|
|
|
302
310
|
sons (lib/estudio/composicao.js). É o caminho para qualquer modelo
|
|
303
311
|
entregar motion e slides que prestam. */
|
|
304
312
|
if (args.roteiro) {
|
|
313
|
+
/* O roteiro como a pessoa (ou o modelo) mandou: o híbrido traduz as
|
|
314
|
+
partes para o cinema no caminho, e o que fica guardado tem de ser o
|
|
315
|
+
original — senão o próximo ajuste parte do traduzido. */
|
|
316
|
+
let original = null;
|
|
317
|
+
try { original = JSON.parse(typeof args.roteiro === 'string' ? args.roteiro : JSON.stringify(args.roteiro)); } catch { original = args.roteiro; }
|
|
305
318
|
const r = await prepararRoteiro(p, args);
|
|
306
319
|
if (!r.ok) return r;
|
|
307
320
|
let conferida = plano.conferir(r.plano, { pasta: p.pasta });
|
|
@@ -318,7 +331,7 @@ async function ferramentaPlano(args) {
|
|
|
318
331
|
}
|
|
319
332
|
projeto.salvarPlano(args.id, r.plano, args.motivo || 'montado do roteiro');
|
|
320
333
|
// O roteiro fica guardado: ajustar uma frase é mandar o roteiro de novo, não reescrever tudo.
|
|
321
|
-
try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(args.roteiro, null, 2)); } catch { /* segue */ }
|
|
334
|
+
try { fs.writeFileSync(path.join(p.pasta, 'roteiro.json'), JSON.stringify(original || args.roteiro, null, 2)); } catch { /* segue */ }
|
|
322
335
|
projeto.salvar(args.id, { duracao: conferida.duracao });
|
|
323
336
|
return {
|
|
324
337
|
ok: true, duracao: conferida.duracao, ...r.resumo, resumo: plano.resumo(r.plano),
|
|
@@ -883,21 +896,18 @@ async function temLetra(arquivo) {
|
|
|
883
896
|
(25/09/2026) */
|
|
884
897
|
async function sonsReais(p, video) {
|
|
885
898
|
const sint = await edicao.prepararSons(p.pasta, video);
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
whoosh: ['cinematic whoosh', 'swoosh transition'],
|
|
889
|
-
impacto: ['cinematic boom impact', 'deep cinematic impact', 'trailer hit'],
|
|
890
|
-
sala: ['crowd murmur indoor', 'office ambience', 'people talking background'],
|
|
891
|
-
};
|
|
899
|
+
/* Termo sorteado e resultado sorteado, sem repetir os últimos: o primeiro
|
|
900
|
+
resultado do termo fixo era o mesmo whoosh em todo vídeo (02/10). */
|
|
892
901
|
const sons = { ...sint };
|
|
893
|
-
for (const [
|
|
894
|
-
for (const termo of
|
|
902
|
+
for (const nome of ['teclado', 'whoosh', 'impacto', 'sala']) {
|
|
903
|
+
for (const termo of variedade.termosSfx(nome)) {
|
|
904
|
+
if (sons[nome] !== sint[nome]) break;
|
|
895
905
|
try {
|
|
896
|
-
const b = await assets.procurarSom(termo, { quantas:
|
|
897
|
-
const item
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
906
|
+
const b = await assets.procurarSom(termo, { quantas: 10 });
|
|
907
|
+
for (const item of variedade.ordenar(b.ok ? b.sons : [], 'sfx').slice(0, 3)) {
|
|
908
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
|
|
909
|
+
if (d.ok) { sons[nome] = d.relativo; variedade.marcar('sfx', item.url); break; }
|
|
910
|
+
}
|
|
901
911
|
} catch { /* o próximo termo */ }
|
|
902
912
|
}
|
|
903
913
|
}
|
|
@@ -922,20 +932,33 @@ async function prepararRoteiro(p, args) {
|
|
|
922
932
|
if (roteiro.marca.logo && !existe(roteiro.marca.logo)) roteiro.marca.logo = (p.marca && existe(p.marca.logo)) ? p.marca.logo : undefined;
|
|
923
933
|
} else if (p.marca) roteiro.marca = p.marca;
|
|
924
934
|
const partes = roteiro.partes || [];
|
|
925
|
-
/* O ESTILO. O padrão é MOTION (motion.js): um plano contínuo
|
|
926
|
-
|
|
927
|
-
(
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
antiga, uma cena por parte, só com estilo: "composicao". */
|
|
935
|
+
/* O ESTILO (estilo.js). O padrão é MOTION (motion.js): um plano contínuo
|
|
936
|
+
em 3D — "isso sim é motion" (Joel, 28/09). Explicação vira VOX
|
|
937
|
+
(editorial, tinta sobre papel), marca vira MARCA (brand reveal),
|
|
938
|
+
lançamento de produto vira LANCAMENTO, anúncio e reels vão para o
|
|
939
|
+
IMPACTO. O que o agente pediu vale mais que tudo. */
|
|
931
940
|
const pedidoTxt = `${p.pedido || ''} ${args.pedido || ''}`;
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
941
|
+
const ref = referencias.atual();
|
|
942
|
+
let estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo });
|
|
943
|
+
/* "Sem referências, só motion": o híbrido vive de b-roll de fora, então
|
|
944
|
+
cai para o motion — sem nada baixado. */
|
|
945
|
+
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);
|
|
946
|
+
/* E o motion de agora é o GERATIVO: sem template, produto ou não (o
|
|
947
|
+
produto em 3D entra numa batida dele, logo abaixo). */
|
|
948
|
+
if (ref.modo === 'sem' && estilo === 'hibrido') estilo = 'gerativo';
|
|
949
|
+
void produtoNoPedido;
|
|
950
|
+
/* "O vídeo deve ter um 3D do fone" (30/09): pedido de 3D vira o gerativo
|
|
951
|
+
com o produto modelado girando numa batida (kit/tres.jsx) — a menos que
|
|
952
|
+
o agente tenha escolhido outro estilo pelo nome. */
|
|
953
|
+
if (/\b3\s?d\b|tridimensional/i.test(pedidoTxt) && (!args.estilo || estilo === 'gerativo' || estilo === 'lancamento')) {
|
|
954
|
+
// O 3D mora numa batida do gerativo; o lançamento antigo, só pelo nome.
|
|
955
|
+
if (!args.estilo && estilo !== 'lancamento') estilo = 'gerativo';
|
|
956
|
+
if (!roteiro.modelo3d) roteiro.modelo3d = /fone|headset|headphone/i.test(pedidoTxt) ? 'fone'
|
|
957
|
+
: /celular|smartphone|phone/i.test(pedidoTxt) ? 'celular' : /lata|bebida|garrafa/i.test(pedidoTxt) ? 'lata' : 'caixa';
|
|
958
|
+
}
|
|
959
|
+
if (ref.modo === 'fechado' && !roteiro.busca && referencias.termos().length) roteiro.busca = referencias.termos();
|
|
960
|
+
// Os estilos de motion usam os efeitos leves e a trilha do ende.app; os de corte, os cheios.
|
|
961
|
+
const ehMotion = LEVES.has(estilo);
|
|
939
962
|
const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
|
|
940
963
|
const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
|
|
941
964
|
|
|
@@ -982,7 +1005,15 @@ async function prepararRoteiro(p, args) {
|
|
|
982
1005
|
}
|
|
983
1006
|
|
|
984
1007
|
let tempos = [], narracao = null, sons = {}, trilha = null;
|
|
985
|
-
|
|
1008
|
+
/* O HÍBRIDO (hibrido.js) é o cinema com o kit por cima: o roteiro de
|
|
1009
|
+
qualquer estilo vira os tipos do cinema aqui, antes da voz — a voz lê
|
|
1010
|
+
o que o cinema vai mostrar. */
|
|
1011
|
+
let hib = null;
|
|
1012
|
+
if (modo === 'video' && estilo === 'hibrido') {
|
|
1013
|
+
hib = hibrido.traduzir(roteiro);
|
|
1014
|
+
partes.splice(0, partes.length, ...hib.roteiro.partes);
|
|
1015
|
+
}
|
|
1016
|
+
if (modo === 'video' && (estilo === 'cinema' || estilo === 'hibrido')) {
|
|
986
1017
|
// Só as partes com voz vão para a narração; o depoimento tem a voz do aluno.
|
|
987
1018
|
// Só as partes com voz: o depoimento tem a voz do aluno, e a parte muda não tem voz.
|
|
988
1019
|
// A parte com voz forte é narrada à parte: se entrasse aqui também, seria falada duas vezes (e muda uma delas).
|
|
@@ -1052,20 +1083,39 @@ async function prepararRoteiro(p, args) {
|
|
|
1052
1083
|
}
|
|
1053
1084
|
}
|
|
1054
1085
|
}
|
|
1086
|
+
/* No HÍBRIDO a voz anda um pouco mais rápido por padrão (1,12×, como no
|
|
1087
|
+
vox): o trailer premium do cinema lido pelo Gemini sai no passo de
|
|
1088
|
+
leitura, e o vídeo segue a voz. `roteiro.ritmoVoz` ajusta. */
|
|
1089
|
+
if (hib && ritmoVoz <= 1.01) {
|
|
1090
|
+
const kh = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz) || 1.12));
|
|
1091
|
+
const rapido = mp3.replace(/\.mp3$/, '-h.mp3');
|
|
1092
|
+
const c = kh > 1.01 ? await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${kh.toFixed(3)}`, rapido], { timeout: 120000 }) : { ok: false };
|
|
1093
|
+
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`);
|
|
1094
|
+
if (c.ok && fs.existsSync(rapido)) {
|
|
1095
|
+
fs.renameSync(rapido, mp3);
|
|
1096
|
+
ritmoVoz = kh;
|
|
1097
|
+
const div = (x) => Math.round((x / kh) * 1000) / 1000;
|
|
1098
|
+
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) })) }));
|
|
1099
|
+
v.segundos = div(v.segundos);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1055
1102
|
projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${lista.length} partes, voz ${v.voz}`);
|
|
1056
1103
|
const sonsC = await sonsReais(p, video);
|
|
1057
1104
|
let trilhaC = null;
|
|
1058
1105
|
// Vários termos e durações: vídeo sem música foi o que saiu na primeira vez (25/09).
|
|
1059
1106
|
// E só instrumental: "essa música daria pra deixar só o instrumental?" — a faixa é OUVIDA.
|
|
1060
|
-
|
|
1061
|
-
|
|
1107
|
+
// O termo sai do clima do roteiro (variedade.js), não de uma lista fixa de "cinematic inspiring".
|
|
1108
|
+
for (const termo of [...variedade.termosTrilha(roteiro, 'cinema'), 'instrumental']) {
|
|
1109
|
+
if (trilhaC) break;
|
|
1062
1110
|
try {
|
|
1063
|
-
const b = await assets.procurarMusica(termo, { quantas:
|
|
1064
|
-
for (const item of (b.ok ? b.sons : [])) {
|
|
1111
|
+
const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
|
|
1112
|
+
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)) {
|
|
1065
1113
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
|
|
1066
1114
|
if (!d.ok) continue;
|
|
1067
1115
|
if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
|
|
1068
|
-
trilhaC = d.relativo;
|
|
1116
|
+
trilhaC = d.relativo; variedade.marcar('trilha', item.url);
|
|
1117
|
+
projeto.anotar(p.id, 'trilha', `"${termo}": ${item.titulo} — ${item.autor || 'Jamendo'} (${item.licenca || 'CC'})`);
|
|
1118
|
+
break;
|
|
1069
1119
|
}
|
|
1070
1120
|
} catch { /* o próximo termo */ }
|
|
1071
1121
|
}
|
|
@@ -1086,7 +1136,69 @@ async function prepararRoteiro(p, args) {
|
|
|
1086
1136
|
const MINI = /poster|face \d+|\bthumb/i;
|
|
1087
1137
|
const fotosC = acervo.fotos.filter((f) => !MINI.test(f.alt || ''))
|
|
1088
1138
|
.map((f) => ({ src: f.src, tipo: 'imagem', cat: TELA.test(f.alt || '') ? 'tela' : 'pessoas', alt: f.alt }));
|
|
1089
|
-
|
|
1139
|
+
/* O B-ROLL do híbrido: o cinema vive de foto que troca por baixo do
|
|
1140
|
+
texto, e o acervo da marca raramente passa de meia dúzia. Vídeo de
|
|
1141
|
+
banco primeiro (o fundo que se move sozinho), foto como reserva —
|
|
1142
|
+
em paralelo, porque são oito buscas. */
|
|
1143
|
+
/* Foto de compartilhamento (og:image) e logo trazem o NOME da marca em
|
|
1144
|
+
letra gigante: como fundo, viravam um "THRO" cortado atrás do texto. */
|
|
1145
|
+
/* E arte de banner (2400×1260 e afins): é o logo da marca desenhado, não
|
|
1146
|
+
uma cena — cortada no vídeo em pé, vira um "✳ Cl" gigante. */
|
|
1147
|
+
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; };
|
|
1148
|
+
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);
|
|
1149
|
+
if (hib && fotosC.length < 14) {
|
|
1150
|
+
const orientacao = largura >= altura ? 'landscape' : 'portrait';
|
|
1151
|
+
const pastaMidia = path.join(p.pasta, 'midia');
|
|
1152
|
+
const jaTem = (() => { try { return fs.readdirSync(pastaMidia); } catch { return []; } })();
|
|
1153
|
+
const cat = (termo) => (/screen|code|data|server|dashboard/i.test(termo) ? 'tela' : 'pessoas');
|
|
1154
|
+
/* A versão LEVE do b-roll: o Pexels entrega 1080×1920 inteiro (até 30
|
|
1155
|
+
MB e 20 s), e o render parava num quadro esperando vários vídeos
|
|
1156
|
+
assim ao mesmo tempo (medido: timeout de 120 s no quadro 184). Cada
|
|
1157
|
+
um vira 8 s, 720 de largura, sem áudio — e fica guardado, para o
|
|
1158
|
+
próximo render não baixar de novo. */
|
|
1159
|
+
const leve = async (rel, destino) => {
|
|
1160
|
+
const alvo = path.join(pastaMidia, destino);
|
|
1161
|
+
const escala = largura >= altura ? 'scale=1280:-2' : 'scale=720:-2';
|
|
1162
|
+
const c = await video.rodar(video.ffmpeg(), ['-y', '-i', path.join(p.pasta, rel), '-t', '8', '-vf', escala + ',fps=30', '-an',
|
|
1163
|
+
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '24', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', alvo], { timeout: 180000 });
|
|
1164
|
+
if (!c.ok || !fs.existsSync(alvo)) return null;
|
|
1165
|
+
try { fs.unlinkSync(path.join(p.pasta, rel)); } catch { /* fica o original */ }
|
|
1166
|
+
return 'midia/' + destino;
|
|
1167
|
+
};
|
|
1168
|
+
const achados = await Promise.all(hibrido.termosDeBusca(roteiro, hib.extras).map(async (termo) => {
|
|
1169
|
+
const out = [];
|
|
1170
|
+
const slug = 'broll-' + String(termo).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
1171
|
+
const proxy = `${slug}.leve.mp4`;
|
|
1172
|
+
if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo });
|
|
1173
|
+
else try {
|
|
1174
|
+
const b = await assets.procurarVideos(termo, { quantas: 3, orientacao });
|
|
1175
|
+
for (const item of (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 4).slice(0, 1)) {
|
|
1176
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
|
|
1177
|
+
const l = d.ok ? await leve(d.relativo, proxy) : null;
|
|
1178
|
+
if (l) out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo });
|
|
1179
|
+
}
|
|
1180
|
+
} catch { /* segue com a foto */ }
|
|
1181
|
+
const foto = jaTem.find((f) => f.startsWith(slug + '-') && /\.(jpe?g|png|webp)$/i.test(f));
|
|
1182
|
+
if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo });
|
|
1183
|
+
else try {
|
|
1184
|
+
const b = await assets.procurarImagens(termo, { quantas: 3, orientacao });
|
|
1185
|
+
for (const item of (b.ok ? b.imagens : []).slice(0, 1)) {
|
|
1186
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
|
|
1187
|
+
if (d.ok) out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo });
|
|
1188
|
+
}
|
|
1189
|
+
} catch { /* sem */ }
|
|
1190
|
+
return out;
|
|
1191
|
+
}));
|
|
1192
|
+
const novos = achados.flat();
|
|
1193
|
+
// Intercalados com as da marca: a foto da marca não pode ficar toda no começo.
|
|
1194
|
+
const marcaF = fotosC.splice(0);
|
|
1195
|
+
for (let q = 0; q < Math.max(marcaF.length, novos.length); q++) {
|
|
1196
|
+
if (novos[q]) fotosC.push(novos[q]);
|
|
1197
|
+
if (marcaF[q]) fotosC.push(marcaF[q]);
|
|
1198
|
+
}
|
|
1199
|
+
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`);
|
|
1200
|
+
}
|
|
1201
|
+
const rostos = await acharRostos(p, fotosC.filter((f) => f.tipo !== 'video').map((f) => f.src));
|
|
1090
1202
|
for (const f of fotosC) if (rostos[f.src]) f.rosto = rostos[f.src];
|
|
1091
1203
|
// Os depoimentos: cortados na palavra, com a voz no volume certo e fade.
|
|
1092
1204
|
for (const pt of partes) {
|
|
@@ -1127,8 +1239,12 @@ async function prepararRoteiro(p, args) {
|
|
|
1127
1239
|
iNarr++;
|
|
1128
1240
|
}
|
|
1129
1241
|
const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
|
|
1130
|
-
sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes });
|
|
1131
|
-
if (r.ok
|
|
1242
|
+
sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes, ritmoLeitura: hib ? 0.55 : 1 });
|
|
1243
|
+
if (r.ok && hib) {
|
|
1244
|
+
hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura });
|
|
1245
|
+
r.resumo.estilo = 'hibrido';
|
|
1246
|
+
}
|
|
1247
|
+
if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${hib ? 'hibrido' : 'cinema'}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
|
|
1132
1248
|
return r;
|
|
1133
1249
|
}
|
|
1134
1250
|
if (modo === 'video') {
|
|
@@ -1138,31 +1254,100 @@ async function prepararRoteiro(p, args) {
|
|
|
1138
1254
|
fs.writeFileSync(arq, JSON.stringify(lista));
|
|
1139
1255
|
const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
|
|
1140
1256
|
// A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1257
|
+
// Com cache: refazer o vídeo (outro estilo, outra proporção) não gasta a cota da voz.
|
|
1258
|
+
let v = await narrarComCache(lista, mp3, { voz: args.voz || roteiro.voz,
|
|
1259
|
+
direcao: estilo === 'gerativo'
|
|
1260
|
+
/* O gerativo pede voz com CORPO ("narração encorpada, extravagante",
|
|
1261
|
+
30/09): cheia, quente, perto do microfone — e o ritmo segue 1,12×. */
|
|
1262
|
+
/* (A primeira versão pedia "trailer" e "ênfase sustentada": a voz saiu 25% mais lenta, medido em 30/09.) */
|
|
1263
|
+
? 'Leia em português do Brasil como locutor de reels de tecnologia: RÁPIDO e contínuo, com voz cheia, quente e encorpada, '
|
|
1264
|
+
+ 'bem perto do microfone, muita energia e sorriso na voz. Ataque firme e ênfase curta e forte nas palavras de impacto; '
|
|
1265
|
+
+ 'quase sem pausa entre as frases. Nunca arraste, nunca leia devagar nem solene.'
|
|
1266
|
+
: estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
|
|
1267
|
+
+ 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
|
|
1268
|
+
// "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
|
|
1269
|
+
: 'Leia em português do Brasil como narrador de reels de tecnologia: vivo, confiante e rápido, sorrindo na voz, '
|
|
1270
|
+
+ 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.' });
|
|
1144
1271
|
if (!v.ok) v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
|
|
1145
1272
|
if (!v.ok) return v;
|
|
1273
|
+
/* O RITMO DA VOZ. Mesmo com a direção, a voz sai no passo de leitura, e
|
|
1274
|
+
o vídeo — que segue a voz palavra por palavra — sai lento junto. O
|
|
1275
|
+
áudio acelera 1,12× (1,3× ficou corrido demais, 29/09; atempo não muda o tom) e cada tempo de palavra
|
|
1276
|
+
acompanha; `roteiro.ritmoVoz` ajusta (1 desliga). */
|
|
1277
|
+
const ritmo = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz || args.ritmoVoz) || 1.12));
|
|
1278
|
+
if (ritmo > 1.01) {
|
|
1279
|
+
const rap = mp3.replace(/\.mp3$/, '-r.mp3');
|
|
1280
|
+
const c = await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${ritmo.toFixed(3)}`, rap], { timeout: 120000 });
|
|
1281
|
+
if (c.ok && fs.existsSync(rap)) {
|
|
1282
|
+
fs.renameSync(rap, mp3);
|
|
1283
|
+
const esc = (x) => (typeof x === 'number' ? Math.round((x / ritmo) * 1000) / 1000 : x);
|
|
1284
|
+
v.segundos = esc(v.segundos);
|
|
1285
|
+
v.partes = (v.partes || []).map((pt) => ({ ...pt, de: esc(pt.de), ate: esc(pt.ate),
|
|
1286
|
+
palavras: (pt.palavras || []).map((w) => ({ ...w, de: esc(w.de), ate: esc(w.ate) })) }));
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1146
1289
|
tempos = v.partes;
|
|
1147
1290
|
narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
|
|
1148
1291
|
projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
|
|
1149
1292
|
// O motion usa os efeitos leves; os estilos de corte, os cheios.
|
|
1150
|
-
sons
|
|
1293
|
+
// O gerativo tem a família de sons DELE (sintetizada da semente); os outros motion, os leves.
|
|
1294
|
+
// O gerativo desenha os sons DEPOIS da partitura (um arquivo por evento, da própria trilha).
|
|
1295
|
+
sons = estilo === 'gerativo' ? {}
|
|
1296
|
+
: ehMotion ? sonsLeves.preparar(p.pasta) : await edicao.prepararSons(p.pasta, video);
|
|
1151
1297
|
// A trilha do motion: ende.app (CC BY 4.0, sem Content ID), clima tecnologia.
|
|
1152
1298
|
if (ehMotion) {
|
|
1153
1299
|
try {
|
|
1154
1300
|
const segundos = tempos.length ? tempos[tempos.length - 1].ate + 2 : 20;
|
|
1155
|
-
|
|
1301
|
+
/* O gerativo escolhe o GÊNERO pelo assunto (jazz para o explainer,
|
|
1302
|
+
funk para o fone, eletrônica para IA…), sorteia a faixa e não
|
|
1303
|
+
repete as últimas usadas — nada de "inspiring corporate". */
|
|
1304
|
+
let m = null;
|
|
1305
|
+
const usadasArq = path.join(os.homedir(), '.primocode', 'studio', 'trilhas-usadas.json');
|
|
1306
|
+
let usadas = [];
|
|
1307
|
+
try { usadas = JSON.parse(fs.readFileSync(usadasArq, 'utf8')); } catch { usadas = []; }
|
|
1308
|
+
const mus = estilo === 'gerativo' ? gerativo.musicaDe(roteiro) : null;
|
|
1309
|
+
/* A trilha do gerativo vem do Jamendo (Openverse), pelo GÊNERO do
|
|
1310
|
+
assunto, instrumental, sorteada e sem repetir as últimas — a
|
|
1311
|
+
ende.app fica de reserva ("parar de cair sempre na ende", 30/09). */
|
|
1312
|
+
if (mus) {
|
|
1313
|
+
const BUSCA = { jazz: 'jazz instrumental', 'lofi-chill-beats': 'lofi hip hop instrumental', electronica: 'electronic instrumental', funk: 'funk instrumental',
|
|
1314
|
+
'dance-club': 'house instrumental', ambient: 'ambient instrumental', acoustic: 'acoustic guitar instrumental', cinematic: 'cinematic instrumental',
|
|
1315
|
+
classical: 'piano classical instrumental', 'soul-rnb': 'soul instrumental' };
|
|
1316
|
+
try {
|
|
1317
|
+
const b = await assets.procurarMusica(BUSCA[roteiro.genero || mus.genero] || ((roteiro.genero || mus.genero) + ' instrumental'), { quantas: 8, duracaoMin: Math.max(45, segundos + 10) });
|
|
1318
|
+
const opcoes = (b.ok ? b.sons : []).filter((x) => x.url && !usadas.includes(x.url) && !/vocal|voice|feat|ft\.|remix/i.test(x.titulo || ''));
|
|
1319
|
+
for (let k = 0; k < opcoes.length && !m; k++) {
|
|
1320
|
+
const item = opcoes[(mus.semente + k) % opcoes.length];
|
|
1321
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha-' + (roteiro.genero || mus.genero) });
|
|
1322
|
+
if (!d.ok) continue;
|
|
1323
|
+
if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
|
|
1324
|
+
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 };
|
|
1325
|
+
}
|
|
1326
|
+
} catch { /* cai na ende.app */ }
|
|
1327
|
+
}
|
|
1328
|
+
if (mus && m) {
|
|
1329
|
+
try { fs.writeFileSync(usadasArq, JSON.stringify([m.id, ...usadas.filter((x) => x !== m.id)].slice(0, 16))); } catch { /* sem memória */ }
|
|
1330
|
+
projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero} (Jamendo)`);
|
|
1331
|
+
} else if (mus) {
|
|
1332
|
+
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 });
|
|
1333
|
+
const id = m && Number((/ende-(\d+)\.mp3$/.exec(m.src) || [])[1]);
|
|
1334
|
+
if (id) try { fs.writeFileSync(usadasArq, JSON.stringify([id, ...usadas.filter((x) => x !== id)].slice(0, 16))); } catch { /* sem memória */ }
|
|
1335
|
+
if (m) projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero}`);
|
|
1336
|
+
} else m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
|
|
1156
1337
|
if (m) { trilha = path.relative(p.pasta, m.src).replace(/\\/g, '/'); projeto.anotar(p.id, 'trilha', m.credito); }
|
|
1157
1338
|
} catch { /* cai na busca de sempre */ }
|
|
1158
1339
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1340
|
+
// Termo pelo clima e faixa sorteada sem repetir — o "inspiring corporate" fixo era a mesma música em todo vídeo.
|
|
1341
|
+
for (const termo of [args.trilha, ...variedade.termosTrilha(roteiro, estilo)].filter(Boolean)) {
|
|
1342
|
+
if (trilha) break;
|
|
1343
|
+
try {
|
|
1344
|
+
const b = await assets.procurarMusica(termo, { quantas: 10, duracaoMin: 45 });
|
|
1345
|
+
for (const item of variedade.ordenar(b.ok ? b.sons : [], 'trilha').slice(0, 3)) {
|
|
1346
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
|
|
1347
|
+
if (d.ok) { trilha = d.relativo; variedade.marcar('trilha', item.url); projeto.anotar(p.id, 'trilha', `"${termo}": ${item.titulo}`); break; }
|
|
1348
|
+
}
|
|
1349
|
+
} catch { /* o próximo termo */ }
|
|
1350
|
+
}
|
|
1166
1351
|
}
|
|
1167
1352
|
if (estilo === 'impacto' && modo === 'video') {
|
|
1168
1353
|
// Os emojis das palavras FALADAS também (a batida mostra a fala).
|
|
@@ -1180,12 +1365,58 @@ async function prepararRoteiro(p, args) {
|
|
|
1180
1365
|
if (r.ok) projeto.anotar(p.id, 'plano', `estilo impacto: ${r.resumo.cenas} batidas`);
|
|
1181
1366
|
return r;
|
|
1182
1367
|
}
|
|
1183
|
-
if (
|
|
1368
|
+
if (estilo === 'vox' && modo === 'video') {
|
|
1369
|
+
const r = vox.montarVox({ roteiro, largura, altura, tempos, narracao, sons, trilha, imagens });
|
|
1370
|
+
if (r.ok) projeto.anotar(p.id, 'plano', `estilo vox: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
|
|
1371
|
+
return r;
|
|
1372
|
+
}
|
|
1373
|
+
if ((estilo === 'marca' || estilo === 'lancamento') && modo === 'video') {
|
|
1374
|
+
/* As letras em vetor (a peça "vetor") pedem os pontos de verdade da
|
|
1375
|
+
fonte: glifos.js lê o .ttf. Sem rede, o vetor desenha o contorno do
|
|
1376
|
+
próprio texto, sem as alças. */
|
|
1377
|
+
const m = roteiro.marca || {};
|
|
1378
|
+
const nome = m.nome || (partes.find((pt) => pt.tipo === 'logo' || pt.tipo === 'abertura') || {}).titulo || '';
|
|
1379
|
+
const precisa = partes.some((pt) => pt.tipo === 'logo' || pt.visual === 'vetor');
|
|
1380
|
+
const g = precisa ? await glifos.palavra((partes.find((pt) => pt.tipo === 'logo') || {}).titulo || nome,
|
|
1381
|
+
{ fonte: (m.fontes && m.fontes.titulo) || (estilo === 'marca' ? 'Plus Jakarta Sans' : 'Inter Tight') }) : null;
|
|
1382
|
+
const r = lancamento.montarLancamento({ roteiro, variante: estilo, largura, altura, tempos, narracao, sons, trilha, imagens, glifos: g });
|
|
1383
|
+
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)') : ''}`);
|
|
1384
|
+
return r;
|
|
1385
|
+
}
|
|
1386
|
+
if (estilo === 'gerativo' && modo === 'video') {
|
|
1387
|
+
/* A música a -18 LUFS (a voz sai a -14): os volumes do plano partem
|
|
1388
|
+
daí — cheia no respiro, uns 9 dB abaixo quando a voz fala. */
|
|
1389
|
+
if (trilha && !/-n18\.mp3$/.test(trilha)) {
|
|
1390
|
+
const norm = path.join(p.pasta, 'midia', path.basename(trilha).replace(/\.[a-z0-9]+$/i, '') + '-n18.mp3');
|
|
1391
|
+
/* A introdução da faixa fica de fora (ela começa morna): corta de 12% em
|
|
1392
|
+
diante, com 0,4 s de entrada, no tamanho do vídeo. */
|
|
1393
|
+
let ini = 0;
|
|
1394
|
+
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 */ }
|
|
1395
|
+
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),
|
|
1396
|
+
'-af', 'afade=t=in:st=0:d=0.4,loudnorm=I=-18:TP=-2:LRA=11', '-ar', '44100', norm], { timeout: 5 * 60000 });
|
|
1397
|
+
if (c.ok && fs.existsSync(norm)) trilha = path.relative(p.pasta, norm).replace(/\\/g, '/');
|
|
1398
|
+
}
|
|
1399
|
+
const r = gerativo.montarGerativo({ roteiro, largura, altura, tempos, narracao, sons, trilha });
|
|
1400
|
+
if (r.ok) {
|
|
1401
|
+
const feitos = sonsLeves.prepararReceitas(p.pasta, r.somReceitas);
|
|
1402
|
+
// Som que o ffmpeg não conseguiu fazer sai do plano (o vídeo não espera um arquivo que não existe).
|
|
1403
|
+
r.plano.audio.efeitos = r.plano.audio.efeitos.filter((e) => Object.values(feitos).includes(e.src));
|
|
1404
|
+
}
|
|
1405
|
+
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}`);
|
|
1406
|
+
return r;
|
|
1407
|
+
}
|
|
1408
|
+
if (estilo === 'motion' && modo === 'video') {
|
|
1184
1409
|
const r = motion.montarMotion({ roteiro, largura, altura, tempos, narracao, sons, trilha, imagens });
|
|
1185
1410
|
if (r.ok) projeto.anotar(p.id, 'plano', `estilo motion: ${r.resumo.segundos}s contínuos, ${r.resumo.partes} partes, ${r.resumo.fps} fps`);
|
|
1186
1411
|
return r;
|
|
1187
1412
|
}
|
|
1188
|
-
|
|
1413
|
+
/* A composição (e os slides) só conhece os tipos antigos: as partes novas
|
|
1414
|
+
viram a mais parecida delas, e o roteiro do vídeo serve à página. */
|
|
1415
|
+
const PARECIDA = { logo: 'abertura', cores: 'lista', produto: 'titulo' };
|
|
1416
|
+
const paraComposicao = { ...roteiro, partes: partes.map((pt) => (PARECIDA[pt.tipo]
|
|
1417
|
+
? { ...pt, tipo: PARECIDA[pt.tipo], itens: pt.itens || (pt.cores || []).map((c) => (typeof c === 'string' ? c : c.nome)) }
|
|
1418
|
+
: pt)) };
|
|
1419
|
+
return composicao.comporRoteiro({ roteiro: paraComposicao, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk, fundos });
|
|
1189
1420
|
}
|
|
1190
1421
|
|
|
1191
1422
|
/* Sem o tempo de cada palavra, cada trecho é repartido pelas palavras dele,
|