primocode 10.0.9 → 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.
@@ -35,8 +35,10 @@ const { spawn } = require('child_process');
35
35
  const projeto = require('./projeto.js');
36
36
  const acervo = require('./acervo.js');
37
37
  const hibrido = require('./hibrido.js');
38
+ const biblioteca = require('./biblioteca.js');
38
39
  const referencias = require('./referencias.js');
39
40
  const variedade = require('./variedade.js');
41
+ const variacao3d = require('./variacao3d.js');
40
42
  const plano = require('./plano.js');
41
43
  const remotion = require('./remotion.js');
42
44
  const assets = require('./assets.js');
@@ -50,7 +52,9 @@ const impacto = require('./impacto.js');
50
52
  const cinema = require('./cinema.js');
51
53
  const motion = require('./motion.js');
52
54
  const gerativo = require('./gerativo.js');
55
+ const fluido = require('./fluido.js');
53
56
  const vox = require('./vox.js');
57
+ const kokoro = require('./kokoro.js');
54
58
  const lancamento = require('./lancamento.js');
55
59
  const glifos = require('./glifos.js');
56
60
  const { escolherEstilo, LEVES } = require('./estilo.js');
@@ -153,9 +157,11 @@ function semProjeto(id) {
153
157
  };
154
158
  }
155
159
 
156
- async function ferramentaProjeto(args) {
160
+ async function ferramentaProjeto(args, ctx = {}) {
157
161
  const acao = String(args.acao || 'listar').toLowerCase();
158
162
 
163
+ if (acao === 'editar' || acao === 'editor' || acao === 'studio') return editarNoStudio(args, ctx);
164
+
159
165
  if (acao === 'criar' || acao === 'novo') {
160
166
  const r = projeto.criar({
161
167
  titulo: args.titulo, pedido: args.pedido || args.titulo, formato: args.formato || 'video',
@@ -199,6 +205,28 @@ async function ferramentaProjeto(args) {
199
205
  };
200
206
  }
201
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
+
202
230
  function listarArquivos(pasta) {
203
231
  const saida = {};
204
232
  for (const sub of ['entrada', 'midia', 'saida']) {
@@ -317,6 +345,8 @@ async function ferramentaPlano(args) {
317
345
  try { original = JSON.parse(typeof args.roteiro === 'string' ? args.roteiro : JSON.stringify(args.roteiro)); } catch { original = args.roteiro; }
318
346
  const r = await prepararRoteiro(p, args);
319
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`);
320
350
  let conferida = plano.conferir(r.plano, { pasta: p.pasta });
321
351
  /* A rede de segurança: o elemento que não passa sai, e o vídeo segue.
322
352
  Um defeito num ícone não pode custar o vídeo inteiro. */
@@ -384,7 +414,7 @@ async function ferramentaMidia(args) {
384
414
 
385
415
  if (acao === 'fontes') {
386
416
  if (args.conferir) return assets.conferirFonte(args.conferir);
387
- return { ok: true, fontes: assets.FONTES,
417
+ return { ok: true, fontes: assets.FONTES, biblioteca: biblioteca.cardapio(),
388
418
  nota: 'qualquer família do Google Fonts funciona — esta lista é para não repetir sempre as mesmas.' };
389
419
  }
390
420
 
@@ -682,27 +712,54 @@ async function ferramentaVideo(args, ctx = {}) {
682
712
  }
683
713
 
684
714
  /* A mídia de apoio de um termo: vídeo do Pexels quando pedido e houver,
685
- senão a primeira foto que baixar. Devolve {src, tipo} ou null. */
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). */
686
718
  async function baixarApoio(p, termo, { orientacao = null, video: querVideo = false } = {}) {
687
719
  if (querVideo) {
688
720
  try {
689
- const b = await assets.procurarVideos(termo, { quantas: 4, orientacao });
690
- for (const item of (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 3)) {
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)) {
691
726
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
692
- if (d.ok) return { src: d.relativo, tipo: 'video' };
727
+ if (d.ok) { variedade.marcar('broll', item.url); return { src: d.relativo, tipo: 'video' }; }
693
728
  }
694
729
  } catch { /* cai para a foto */ }
695
730
  }
696
731
  try {
697
- const b = await assets.procurarImagens(termo, { quantas: 4, orientacao });
698
- for (const item of (b.ok ? b.imagens : [])) {
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)) {
699
737
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
700
- if (d.ok) return { src: d.relativo, tipo: 'imagem' };
738
+ if (d.ok) { variedade.marcar('broll', item.url); return { src: d.relativo, tipo: 'imagem' }; }
701
739
  }
702
740
  } catch { /* sem apoio */ }
703
741
  return null;
704
742
  }
705
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
+
706
763
  /* ── A VOZ DO GEMINI ─────────────────────────────────────────────────────
707
764
  * O roteiro inteiro numa chamada só (o plano grátis tem poucos pedidos por
708
765
  * minuto), e as partes achadas depois na transcrição do próprio áudio: a
@@ -867,11 +924,23 @@ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
867
924
  const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
868
925
  const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
869
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() });
870
929
  const v = await narrarComGemini(lista, mp3, { voz, direcao });
871
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 */ } }
872
931
  return v;
873
932
  }
874
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
+
875
944
  /* A MÚSICA TEM LETRA? O acervo não diz se a faixa é instrumental, então ela
876
945
  é ouvida: 25 s do meio vão para a transcrição, e mais de 6 palavras é voz
877
946
  cantada. Sem servidor para ouvir, a faixa passa (melhor música que nenhuma). */
@@ -903,7 +972,7 @@ async function sonsReais(p, video) {
903
972
  for (const termo of variedade.termosSfx(nome)) {
904
973
  if (sons[nome] !== sint[nome]) break;
905
974
  try {
906
- const b = await assets.procurarSom(termo, { quantas: 10 });
975
+ const b = await assets.procurarSom(termo, { quantas: 10, livre: true });
907
976
  for (const item of variedade.ordenar(b.ok ? b.sons : [], 'sfx').slice(0, 3)) {
908
977
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
909
978
  if (d.ok) { sons[nome] = d.relativo; variedade.marcar('sfx', item.url); break; }
@@ -939,7 +1008,9 @@ async function prepararRoteiro(p, args) {
939
1008
  IMPACTO. O que o agente pediu vale mais que tudo. */
940
1009
  const pedidoTxt = `${p.pedido || ''} ${args.pedido || ''}`;
941
1010
  const ref = referencias.atual();
942
- let estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo });
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' });
943
1014
  /* "Sem referências, só motion": o híbrido vive de b-roll de fora, então
944
1015
  cai para o motion — sem nada baixado. */
945
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);
@@ -953,10 +1024,31 @@ async function prepararRoteiro(p, args) {
953
1024
  if (/\b3\s?d\b|tridimensional/i.test(pedidoTxt) && (!args.estilo || estilo === 'gerativo' || estilo === 'lancamento')) {
954
1025
  // O 3D mora numa batida do gerativo; o lançamento antigo, só pelo nome.
955
1026
  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';
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 });
958
1042
  }
959
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
+ }
960
1052
  // Os estilos de motion usam os efeitos leves e a trilha do ende.app; os de corte, os cheios.
961
1053
  const ehMotion = LEVES.has(estilo);
962
1054
  const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
@@ -1044,6 +1136,8 @@ async function prepararRoteiro(p, args) {
1044
1136
  }
1045
1137
  } catch { /* fica o alinhamento guardado */ }
1046
1138
  }
1139
+ } else if (kokoro.querKokoro(args.voz || roteiro.voz)) {
1140
+ v = { ok: false, error: 'kokoro pedido' };
1047
1141
  } else {
1048
1142
  v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
1049
1143
  direcao: 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
@@ -1056,7 +1150,7 @@ async function prepararRoteiro(p, args) {
1056
1150
  const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
1057
1151
  // Ritmo NORMAL: o "citação" (-10%, respiro longo) esticou o vídeo para 125 s.
1058
1152
  fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
1059
- v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'antonio')], { timeout: 10 * 60000 });
1153
+ v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'antonio', { soEdge });
1060
1154
  }
1061
1155
  if (!v.ok) return v;
1062
1156
  /* A DURAÇÃO que o roteiro pede ("60–75 s"): se a voz passa do que cabe,
@@ -1171,20 +1265,27 @@ async function prepararRoteiro(p, args) {
1171
1265
  const proxy = `${slug}.leve.mp4`;
1172
1266
  if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo });
1173
1267
  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)) {
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)) {
1176
1274
  const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
1177
1275
  const l = d.ok ? await leve(d.relativo, proxy) : null;
1178
- if (l) out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo });
1276
+ if (l) { out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
1179
1277
  }
1180
1278
  } catch { /* segue com a foto */ }
1181
1279
  const foto = jaTem.find((f) => f.startsWith(slug + '-') && /\.(jpe?g|png|webp)$/i.test(f));
1182
1280
  if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo });
1183
1281
  else try {
1184
- const b = await assets.procurarImagens(termo, { quantas: 3, orientacao });
1185
- for (const item of (b.ok ? b.imagens : []).slice(0, 1)) {
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)) {
1186
1287
  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 });
1288
+ if (d.ok) { out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
1188
1289
  }
1189
1290
  } catch { /* sem */ }
1190
1291
  return out;
@@ -1241,7 +1342,7 @@ async function prepararRoteiro(p, args) {
1241
1342
  const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
1242
1343
  sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes, ritmoLeitura: hib ? 0.55 : 1 });
1243
1344
  if (r.ok && hib) {
1244
- hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura });
1345
+ hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura, hud: roteiro.hud === true, particulas: roteiro.particulas === true });
1245
1346
  r.resumo.estilo = 'hibrido';
1246
1347
  }
1247
1348
  if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${hib ? 'hibrido' : 'cinema'}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
@@ -1263,18 +1364,23 @@ async function prepararRoteiro(p, args) {
1263
1364
  ? 'Leia em português do Brasil como locutor de reels de tecnologia: RÁPIDO e contínuo, com voz cheia, quente e encorpada, '
1264
1365
  + 'bem perto do microfone, muita energia e sorriso na voz. Ataque firme e ênfase curta e forte nas palavras de impacto; '
1265
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.'
1266
1370
  : estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
1267
1371
  + 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
1268
1372
  // "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
1269
1373
  : 'Leia em português do Brasil como narrador de reels de tecnologia: vivo, confiante e rápido, sorrindo na voz, '
1270
1374
  + 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.' });
1271
- if (!v.ok) v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
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) });
1272
1377
  if (!v.ok) return v;
1273
1378
  /* O RITMO DA VOZ. Mesmo com a direção, a voz sai no passo de leitura, e
1274
1379
  o vídeo — que segue a voz palavra por palavra — sai lento junto. O
1275
1380
  áudio acelera 1,12× (1,3× ficou corrido demais, 29/09; atempo não muda o tom) e cada tempo de palavra
1276
1381
  acompanha; `roteiro.ritmoVoz` ajusta (1 desliga). */
1277
- const ritmo = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz || args.ritmoVoz) || 1.12));
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)));
1278
1384
  if (ritmo > 1.01) {
1279
1385
  const rap = mp3.replace(/\.mp3$/, '-r.mp3');
1280
1386
  const c = await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${ritmo.toFixed(3)}`, rap], { timeout: 120000 });
@@ -1405,6 +1511,11 @@ async function prepararRoteiro(p, args) {
1405
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}`);
1406
1512
  return r;
1407
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
+ }
1408
1519
  if (estilo === 'motion' && modo === 'video') {
1409
1520
  const r = motion.montarMotion({ roteiro, largura, altura, tempos, narracao, sons, trilha, imagens });
1410
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`);