primocode 9.8.0-beta.11 → 9.8.0-beta.13

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.
@@ -42,7 +42,7 @@ const ICONES = [
42
42
  [['inteligencia', 'ia', 'robo', 'maquina', 'automac'], 'robot'],
43
43
  [['cerebro', 'pens', 'raciocin', 'aprend', 'mente'], 'brain'],
44
44
  [['ideia', 'criativ', 'inova'], 'light-bulb'],
45
- [['dinheiro', 'grana', 'reais', 'lucro', 'vend', 'fatur', 'ganh', 'pagar', 'preco'], 'money-bag'],
45
+ [['dinheiro', 'grana', 'reais', 'lucro', 'vend', 'fatur', 'ganh', 'pagar', 'preco', 'financ', 'custo', 'econom', 'receita'], 'money-bag'],
46
46
  [['tempo', 'minuto', 'segundo', 'rapid', 'hora'], 'stopwatch'],
47
47
  [['cresc', 'result', 'aument', 'melhor'], 'chart-increasing'],
48
48
  [['cair', 'caiu', 'perd', 'queda', 'pior'], 'chart-decreasing'],
@@ -70,9 +70,9 @@ const ICONES = [
70
70
  [['padr', 'peca', 'encaix'], 'puzzle-piece'],
71
71
  [['decid', 'decis', 'escolh', 'duvida', 'pergunta'], 'thinking-face'],
72
72
  [['exemplo', 'procur', 'busca', 'pesquis', 'analis'], 'magnifying-glass-tilted-left'],
73
- [['assistente', 'convers', 'chat', 'mensag', 'responde'], 'speech-balloon'],
73
+ [['assistente', 'convers', 'chat', 'mensag', 'responde', 'atend', 'suporte', 'whatsapp'], 'speech-balloon'],
74
74
  [['recomend', 'filme', 'serie', 'netflix', 'video'], 'clapper-board'],
75
- [['tecnolog', 'algoritm', 'sistema', 'ferrament'], 'gear'],
75
+ [['tecnolog', 'algoritm', 'sistema', 'ferrament', 'operac', 'process', 'automat'], 'gear'],
76
76
  [['cotidian', 'rotina', 'semana', 'calendario', 'agenda'], 'calendar'],
77
77
  [['conhecimento', 'formad', 'diploma', 'sabe'], 'graduation-cap'],
78
78
  [['sucesso', 'venc', 'ganhador', 'premio', 'campeao'], 'trophy'],
@@ -85,6 +85,23 @@ const ICONES = [
85
85
  [['surpre', 'chocad', 'absurd'], 'exploding-head'],
86
86
  ];
87
87
 
88
+ /* O termo de busca da IMAGEM DE APOIO, pelo emoji que a palavra ganhou. Em
89
+ inglês de propósito: os acervos abertos respondem muito melhor assim. */
90
+ const FOTO = {
91
+ robot: 'humanoid robot', brain: 'human brain', 'light-bulb': 'light bulb idea', 'money-bag': 'money cash',
92
+ stopwatch: 'clock time', 'chart-increasing': 'business growth', 'chart-decreasing': 'stock market fall',
93
+ 'bar-chart': 'data analytics', rocket: 'rocket launch', fire: 'fire flames', 'mobile-phone': 'smartphone hand',
94
+ laptop: 'laptop programming', 'globe-showing-americas': 'earth from space', eyes: 'eye closeup',
95
+ 'speaker-high-volume': 'sound waves', memo: 'writing notebook', 'musical-notes': 'music concert',
96
+ 'dog-face': 'puppy', 'cat-face': 'cat', 'red-heart': 'heart love', 'busts-in-silhouette': 'people crowd',
97
+ house: 'modern house', automobile: 'car road', books: 'library books', warning: 'warning sign',
98
+ package: 'objects on table', 'puzzle-piece': 'puzzle pieces', 'thinking-face': 'person thinking',
99
+ 'magnifying-glass-tilted-left': 'magnifying glass', 'speech-balloon': 'smart speaker', 'clapper-board': 'watching tv',
100
+ gear: 'circuit board', calendar: 'city street people', 'graduation-cap': 'students classroom', trophy: 'trophy',
101
+ 'flexed-biceps': 'strength', handshake: 'handshake', 'shopping-cart': 'shopping', briefcase: 'office work',
102
+ locked: 'cyber security', 'party-popper': 'party celebration', 'exploding-head': 'surprised face',
103
+ };
104
+
88
105
  function limpa(palavra) {
89
106
  return String(palavra || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
90
107
  .toLowerCase().replace(/[^a-z0-9]/g, '');
@@ -110,6 +127,33 @@ function peso(palavra) {
110
127
  return w.length + (iconeDe(palavra) ? 6 : 0);
111
128
  }
112
129
 
130
+ /** O termo de busca da imagem de apoio para esta palavra, ou null. */
131
+ function termoDeApoio(palavra) {
132
+ const ic = iconeDe(palavra);
133
+ return ic ? FOTO[ic.split(':')[1]] || null : null;
134
+ }
135
+
136
+ // Em quais cenas entra imagem de apoio: uma a cada três, a partir da segunda.
137
+ const temApoio = (i) => i % 3 === 2;
138
+
139
+ function forteDe(g) {
140
+ let forte = null;
141
+ for (const p of g) if (peso(p.texto) > (forte ? peso(forte.texto) : 0)) forte = p;
142
+ return forte;
143
+ }
144
+
145
+ /** Os termos das imagens de apoio que o plano vai pedir — para baixar antes. */
146
+ function termosDeApoio(palavras) {
147
+ const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
148
+ const termos = [];
149
+ frases(lista).forEach((g, i) => {
150
+ const f = forteDe(g);
151
+ const t = f && temApoio(i) ? termoDeApoio(f.texto) : null;
152
+ if (t && !termos.includes(t)) termos.push(t);
153
+ });
154
+ return termos;
155
+ }
156
+
113
157
  const fimDeFrase = (texto) => /[.!?…]$/.test(String(texto || '').trim());
114
158
  const r2 = (n) => Math.round(n * 100) / 100;
115
159
 
@@ -164,8 +208,8 @@ function camera(i, dur) {
164
208
  }
165
209
 
166
210
  const ESTILOS = {
167
- viral: { icones: true, letreiro: true, sons: true, barra: true, destaque: '#FFD400', forte: '#00E0A4' },
168
- limpo: { icones: false, letreiro: false, sons: false, barra: false, destaque: '#FFFFFF', forte: '#FFD400' },
211
+ viral: { icones: true, letreiro: true, sons: true, barra: true, apoio: true, destaque: '#FFD400', forte: '#00E0A4' },
212
+ limpo: { icones: false, letreiro: false, sons: false, barra: false, apoio: true, destaque: '#FFFFFF', forte: '#FFD400' },
169
213
  };
170
214
 
171
215
  /**
@@ -181,7 +225,7 @@ const ESTILOS = {
181
225
  * @param {object} op.sons { pop, whoosh } — caminhos na pasta do projeto, opcionais
182
226
  * @param {Set} op.iconesOk os nomes de ícone que existem de verdade (sem isso, todos valem)
183
227
  */
184
- function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null }) {
228
+ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null, imagens = {}, trilha = null }) {
185
229
  const e = ESTILOS[estilo] || ESTILOS.viral;
186
230
  const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
187
231
  if (!lista.length) return { ok: false, error: 'a gravação não tem fala transcrita para editar.' };
@@ -202,16 +246,35 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
202
246
  const local = (t) => r2(Math.max(0, t - ini));
203
247
 
204
248
  // A palavra forte da frase: a que mais carrega a ideia.
205
- let forte = null;
206
- for (const p of g) if (peso(p.texto) > (forte ? peso(forte.texto) : 0)) forte = p;
249
+ const forte = forteDe(g);
207
250
 
208
251
  const elementos = [{
209
252
  tipo: 'video', src, x: 0, y: 0, largura: '100%', altura: '100%',
210
253
  deOrigem: r2(ini), ateOrigem: r2(ini + dur), volume: 1,
211
254
  }];
212
255
 
256
+ /* A IMAGEM DE APOIO: na hora da palavra forte, a tela vira a imagem do
257
+ que está sendo dito, com um zoom lento, e a legenda continua por
258
+ cima. É o "b-roll" das edições de videomaker. */
259
+ const apoio = forte && e.apoio && temApoio(i) ? imagens[termoDeApoio(forte.texto)] : null;
260
+ if (apoio) {
261
+ const de = local(forte.de - 0.1);
262
+ const vida = Math.min(2.2, dur - de);
263
+ if (vida > 0.8) {
264
+ elementos.push({
265
+ tipo: 'imagem', src: apoio, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
266
+ de, ate: r2(de + vida),
267
+ anima: {
268
+ escala: [{ t: 0, v: 1.12 }, { t: vida, v: 1.0, e: 'suave' }],
269
+ opacidade: [{ t: 0, v: 0 }, { t: 0.12, v: 1 }, { t: vida - 0.15, v: 1 }, { t: vida, v: 0 }],
270
+ },
271
+ });
272
+ if (e.sons && sons.whoosh) efeitos.push({ t: r2(Math.max(0, ini + de - 0.1)), src: sons.whoosh, volume: 0.55 });
273
+ }
274
+ }
275
+
213
276
  // O ícone salta na hora da palavra; sem ícone, o letreiro da ideia.
214
- const icone = forte && e.icones ? iconeDe(forte.texto) : null;
277
+ const icone = forte && e.icones && !apoio ? iconeDe(forte.texto) : null;
215
278
  const temIcone = icone && (!iconesOk || iconesOk.has(icone));
216
279
  const x = lado++ % 2 === 0 ? '78%' : '22%';
217
280
  if (temIcone) {
@@ -229,7 +292,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
229
292
  opacidade: [{ t: vida - 0.2, v: 1 }, { t: vida, v: 0 }],
230
293
  },
231
294
  });
232
- if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.45 });
295
+ if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.8 });
233
296
  }
234
297
  } else if (forte && e.letreiro && i % 2 === 0 && peso(forte.texto) >= 7) {
235
298
  const de = local(forte.de);
@@ -245,12 +308,12 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
245
308
  rotacao: [{ t: 0, v: -6 }, { t: 0.3, v: -3, e: 'saida' }],
246
309
  },
247
310
  });
248
- if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.35 });
311
+ if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.7 });
249
312
  }
250
313
  }
251
314
 
252
315
  // A legenda palavra por palavra.
253
- const fala = blocos(g, deitado ? 4 : 3).map((b, j, todos) => ({
316
+ const fala = blocos(g, 3).map((b, j, todos) => ({
254
317
  de: local(b[0].de - 0.04),
255
318
  ate: j < todos.length - 1 ? local(todos[j + 1][0].de - 0.04) : r2(dur),
256
319
  texto: b.map((p) => p.texto).join(' '),
@@ -260,10 +323,10 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
260
323
  })),
261
324
  }));
262
325
  elementos.push({
263
- tipo: 'legenda', x: '50%', y: deitado ? '80%' : '70%', ancora: 'centro', fala,
326
+ tipo: 'legenda', x: '50%', y: deitado ? '76%' : '68%', ancora: 'centro', fala,
264
327
  estilo: {
265
328
  fonte: 'Montserrat', peso: 900, tamanho: Math.round(base * (deitado ? 0.078 : 0.072)),
266
- destaque: e.destaque, corForte: e.forte, caixa: 'uppercase',
329
+ destaque: e.destaque, corForte: e.forte, caixa: 'uppercase', largura: '92%',
267
330
  },
268
331
  });
269
332
 
@@ -277,7 +340,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
277
340
  }
278
341
 
279
342
  // O whoosh entra no punch-in: é o corte que ele anuncia.
280
- if (e.sons && sons.whoosh && i > 0 && i % 3 === 1) efeitos.push({ t: r2(Math.max(0, ini - 0.12)), src: sons.whoosh, volume: 0.3 });
343
+ if (e.sons && sons.whoosh && i > 0 && i % 3 === 1) efeitos.push({ t: r2(Math.max(0, ini - 0.12)), src: sons.whoosh, volume: 0.6 });
281
344
 
282
345
  cenas.push({ duracao: dur, camera: camera(i, dur), elementos, transicao: { tipo: 'corte' } });
283
346
  });
@@ -288,11 +351,18 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
288
351
  formato: { largura, altura, fps: 30 },
289
352
  fundoGeral: '#000',
290
353
  cenas,
291
- ...(efeitos.length ? { audio: { efeitos } } : {}),
354
+ ...(efeitos.length || trilha ? { audio: {
355
+ ...(efeitos.length ? { efeitos } : {}),
356
+ // A música fica BAIXA: quem fala para a câmera fala o vídeo
357
+ // inteiro, e a trilha é cama, não disputa.
358
+ ...(trilha ? { trilha: { src: trilha, volume: 0.07, loop: true } } : {}),
359
+ } } : {}),
292
360
  },
293
361
  resumo: {
294
362
  cenas: cenas.length,
295
363
  icones: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'icone').length, 0),
364
+ imagens: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'imagem').length, 0),
365
+ musica: Boolean(trilha),
296
366
  letreiros: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'texto').length, 0),
297
367
  efeitos: efeitos.length,
298
368
  },
@@ -328,4 +398,4 @@ async function prepararSons(pasta, video) {
328
398
  return sons;
329
399
  }
330
400
 
331
- module.exports = { montarEdicao, prepararSons, iconeDe, peso, frases, blocos, ESTILOS, ICONES };
401
+ module.exports = { montarEdicao, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, ESTILOS, ICONES, FOTO };
@@ -41,6 +41,7 @@ const video = require('./video.js');
41
41
  const identidade = require('./identidade.js');
42
42
  const slides = require('./slides.js');
43
43
  const edicao = require('./edicao.js');
44
+ const composicao = require('./composicao.js');
44
45
 
45
46
  const PY = path.join(__dirname, '..', '..', 'studio');
46
47
 
@@ -172,12 +173,31 @@ async function ferramentaPlano(args) {
172
173
 
173
174
  if (args.voltarPara) return projeto.voltarPara(args.id, args.voltarPara);
174
175
 
175
- if (!args.plano && !args.cenas) {
176
+ if (!args.plano && !args.cenas && !args.roteiro) {
176
177
  const atual = projeto.lerPlano(args.id);
177
178
  if (!atual) return { ok: false, error: 'este projeto ainda não tem plano — mande um.' };
178
179
  return { ok: true, plano: atual, resumo: plano.resumo(atual) };
179
180
  }
180
181
 
182
+ /* O ROTEIRO: o modelo diz o que cada parte é e o que ela diz, e o código
183
+ faz o resto — layout, voz com entonação, ícones, imagens, música e
184
+ sons (lib/estudio/composicao.js). É o caminho para qualquer modelo
185
+ entregar motion e slides que prestam. */
186
+ if (args.roteiro) {
187
+ const r = await prepararRoteiro(p, args);
188
+ if (!r.ok) return r;
189
+ const conferida = plano.conferir(r.plano, { pasta: p.pasta });
190
+ if (!conferida.ok) {
191
+ return { ok: false, error: 'o roteiro virou um plano com problemas (defeito do PrimoCode, não seu).', problemas: conferida.problemas.slice(0, 12) };
192
+ }
193
+ projeto.salvarPlano(args.id, r.plano, args.motivo || 'montado do roteiro');
194
+ projeto.salvar(args.id, { duracao: conferida.duracao });
195
+ return {
196
+ ok: true, duracao: conferida.duracao, ...r.resumo, resumo: plano.resumo(r.plano),
197
+ proximo: 'estudio_render com este id. Para ajustar, mande o roteiro de novo (é rápido) — não edite as cenas à mão.',
198
+ };
199
+ }
200
+
181
201
  // Um plano inteiro, ou só as cenas (que é o caso comum).
182
202
  let novo = args.plano || { formato: args.formato, cenas: args.cenas, audio: args.audio };
183
203
 
@@ -413,6 +433,15 @@ async function ferramentaVideo(args, ctx = {}) {
413
433
  const c = await video.cortarSilencio(alvo, saida, args);
414
434
  if (c.ok && !c.semCorte) { fonte = saida; cortes = c.cortes || 0; }
415
435
  }
436
+ /* A VOZ NO VOLUME CERTO. A gravação de celular sai baixa (medido em
437
+ 25/09: -37,6 dB de média no vídeo pronto, e "está sem som"). O
438
+ loudnorm leva a fala a -15 LUFS, o volume de vídeo de internet; o
439
+ vídeo em si não é recodificado. */
440
+ const normal = path.join(p.pasta, 'midia', `voz-normal-${Date.now().toString(36)}.mp4`);
441
+ const ln = await video.rodar(video.ffmpeg(), ['-y', '-i', fonte, '-c:v', 'copy',
442
+ '-af', 'loudnorm=I=-15:TP=-1.5:LRA=11', '-c:a', 'aac', '-b:a', '192k', normal], { timeout: 10 * 60000 });
443
+ if (ln.ok) fonte = normal;
444
+
416
445
  const m = await video.medir(fonte);
417
446
  if (!m.ok) return m;
418
447
 
@@ -430,10 +459,36 @@ async function ferramentaVideo(args, ctx = {}) {
430
459
  }
431
460
  const sons = await edicao.prepararSons(p.pasta, video);
432
461
 
462
+ // As imagens de apoio: uma por termo, a primeira que baixar.
463
+ const imagens = {};
464
+ if ((args.estilo || 'viral') !== 'sem-apoio') {
465
+ for (const termo of edicao.termosDeApoio(palavras).slice(0, 8)) {
466
+ try {
467
+ const b = await assets.procurarImagens(termo, { quantas: 4 });
468
+ for (const item of (b.ok ? b.imagens : [])) {
469
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
470
+ if (d.ok) { imagens[termo] = d.relativo; break; }
471
+ }
472
+ } catch { /* segue sem esta */ }
473
+ }
474
+ }
475
+
476
+ // A música de fundo: uma trilha longa, de licença aberta.
477
+ let trilha = null;
478
+ if (args.musica !== false) {
479
+ try {
480
+ const b = await assets.procurarSom(args.trilha || 'upbeat corporate background', { quantas: 6, duracaoMin: 45 });
481
+ for (const item of (b.ok ? b.sons : [])) {
482
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
483
+ if (d.ok) { trilha = d.relativo; break; }
484
+ }
485
+ } catch { /* segue sem música */ }
486
+ }
487
+
433
488
  const montado = edicao.montarEdicao({
434
489
  src: path.relative(p.pasta, fonte).replace(/\\/g, '/'),
435
490
  duracao: m.segundos, palavras, largura: m.largura, altura: m.altura,
436
- estilo: args.estilo || 'viral', sons, iconesOk,
491
+ estilo: args.estilo || 'viral', sons, iconesOk, imagens, trilha,
437
492
  });
438
493
  if (!montado.ok) return montado;
439
494
  const salvo = await ferramentaPlano({ id: args.id, plano: montado.plano, motivo: `edição automática (${args.estilo || 'viral'})` });
@@ -465,6 +520,56 @@ async function ferramentaVideo(args, ctx = {}) {
465
520
  return { ok: false, error: `não conheço a ação "${acao}".` };
466
521
  }
467
522
 
523
+ const PROPORCOES = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350] };
524
+
525
+ /** O roteiro vira plano: baixa o que falta, narra, e chama o compositor. */
526
+ async function prepararRoteiro(p, args) {
527
+ const roteiro = typeof args.roteiro === 'string' ? JSON.parse(args.roteiro) : args.roteiro;
528
+ if (Array.isArray(roteiro)) return prepararRoteiro(p, { ...args, roteiro: { partes: roteiro } });
529
+ const partes = roteiro.partes || [];
530
+ const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
531
+ const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
532
+
533
+ const iconesOk = new Set();
534
+ for (const nome of composicao.iconesDoRoteiro(roteiro)) {
535
+ try { const r = await assets.icone(nome); if (r && r.ok) iconesOk.add(nome); } catch { /* sem */ }
536
+ }
537
+ const imagens = {};
538
+ for (const [i, parte] of partes.entries()) {
539
+ if (parte.tipo !== 'imagem' || parte.src || !parte.busca) continue;
540
+ try {
541
+ const b = await assets.procurarImagens(parte.busca, { quantas: 4 });
542
+ for (const item of (b.ok ? b.imagens : [])) {
543
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'cena-' + (i + 1) });
544
+ if (d.ok) { imagens[i] = d.relativo; break; }
545
+ }
546
+ } catch { /* a cena sai sem a foto, com o título */ }
547
+ }
548
+
549
+ let tempos = [], narracao = null, sons = {}, trilha = null;
550
+ if (modo === 'video') {
551
+ const lista = partes.map((pt, i) => ({ texto: composicao.falaDe(pt), registro: composicao.registroDe(pt, i, partes.length) }));
552
+ const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
553
+ fs.mkdirSync(path.dirname(arq), { recursive: true });
554
+ fs.writeFileSync(arq, JSON.stringify(lista));
555
+ const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
556
+ const v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
557
+ if (!v.ok) return v;
558
+ tempos = v.partes;
559
+ narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
560
+ projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
561
+ sons = await edicao.prepararSons(p.pasta, video);
562
+ try {
563
+ const b = await assets.procurarSom(roteiro.musica || args.trilha || 'inspiring corporate background', { quantas: 6, duracaoMin: 45 });
564
+ for (const item of (b.ok ? b.sons : [])) {
565
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
566
+ if (d.ok) { trilha = d.relativo; break; }
567
+ }
568
+ } catch { /* sem música */ }
569
+ }
570
+ return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk });
571
+ }
572
+
468
573
  /* Sem o tempo de cada palavra, cada trecho é repartido pelas palavras dele,
469
574
  proporcional ao tamanho de cada uma. */
470
575
  function repartirTrechos(trechos) {
@@ -165,7 +165,10 @@ function conferirElemento(caminho, el, pasta, problemas, duracao) {
165
165
  }
166
166
  }
167
167
  conferirCor(`${caminho}.cor`, el.cor, problemas);
168
- conferirCor(`${caminho}.fundo`, el.fundo, problemas);
168
+ // O fundo do elemento é cor, ou o mesmo objeto do fundo da cena (um
169
+ // gradiente sobre a foto, por exemplo).
170
+ if (el.fundo && typeof el.fundo === 'object') conferirFundo(`${caminho}.fundo`, el.fundo, pasta, problemas);
171
+ else conferirCor(`${caminho}.fundo`, el.fundo, problemas);
169
172
  conferirAnima(`${caminho}.anima`, el.anima, problemas);
170
173
 
171
174
  // `de` e `ate` recortam o elemento no tempo DA CENA. Um elemento que
@@ -234,7 +234,39 @@ function Elemento({ el, t, dados }) {
234
234
  }
235
235
 
236
236
  switch (el.tipo) {
237
- case 'texto':
237
+ case 'texto': {
238
+ /* O NÚMERO QUE CONTA: "contar": {ate: 9, dur: 1.2} faz o texto subir
239
+ de 0 até o valor, com a curva de saída. Casas decimais e o que vem
240
+ antes/depois do número (R$, %, mil) ficam como estão. */
241
+ let conteudo = el.texto;
242
+ if (el.contar) {
243
+ const alvo = Number(el.contar.ate) || 0;
244
+ const dur = el.contar.dur || 1.2;
245
+ const k = CURVAS.saida(Math.min(1, Math.max(0, tLocal / dur)));
246
+ const casas = el.contar.casas || 0;
247
+ const v = (alvo * k).toLocaleString('pt-BR', { minimumFractionDigits: casas, maximumFractionDigits: casas });
248
+ conteudo = (el.contar.antes || '') + v + (el.contar.depois || '');
249
+ }
250
+ /* O TEXTO QUE SE REVELA: "revelar": [t0, t1, …] é o instante (no tempo
251
+ do elemento) em que cada palavra entra — o da narração. A palavra
252
+ sobe e aparece; as que ainda não foram ditas esperam invisíveis, e o
253
+ bloco não muda de tamanho quando elas entram. */
254
+ if (Array.isArray(el.revelar) && typeof el.texto === 'string') {
255
+ const palavras = el.texto.split(' ');
256
+ conteudo = palavras.map((w, i) => {
257
+ const t0 = el.revelar[Math.min(i, el.revelar.length - 1)] ?? 0;
258
+ const k = Math.min(1, Math.max(0, (tLocal - t0) / 0.28));
259
+ const e = CURVAS.saida(k);
260
+ // O espaço fica FORA do span: é ali que a linha pode quebrar.
261
+ return (
262
+ <React.Fragment key={i}>
263
+ <span style={{ display: 'inline-block', opacity: e, transform: 'translateY(' + (1 - e) * 0.35 + 'em)',
264
+ color: el.destaques && el.destaques.includes(i) ? (el.corDestaque || el.cor) : undefined }}>{w}</span>
265
+ {i < palavras.length - 1 ? ' ' : null}
266
+ </React.Fragment>
267
+ );
268
+ });
269
+ }
238
270
  return (
239
271
  <div style={{
240
272
  ...estilo,
@@ -250,8 +282,13 @@ function Elemento({ el, t, dados }) {
250
282
  background: el.gradienteTexto || undefined,
251
283
  WebkitBackgroundClip: el.gradienteTexto ? 'text' : undefined,
252
284
  WebkitTextFillColor: el.gradienteTexto ? 'transparent' : undefined,
253
- }}>{el.texto}</div>
285
+ fontStyle: el.italico ? 'italic' : undefined,
286
+ textDecoration: el.riscado ? 'line-through' : undefined,
287
+ textDecorationThickness: el.riscado ? '0.08em' : undefined,
288
+ wordBreak: 'normal', overflowWrap: 'break-word',
289
+ }}>{conteudo}</div>
254
290
  );
291
+ }
255
292
 
256
293
  case 'forma': {
257
294
  const forma = el.forma || 'retangulo';
@@ -260,7 +297,7 @@ function Elemento({ el, t, dados }) {
260
297
  ...estilo,
261
298
  width: medida(el.largura, '200px'),
262
299
  height: forma === 'linha' ? medida(el.espessura, '4px') : medida(el.altura, '200px'),
263
- background: el.fundo || el.cor || '#fff',
300
+ ...(el.fundo && typeof el.fundo === 'object' ? fundoCSS(el.fundo) : { background: el.fundo || el.cor || '#fff' }),
264
301
  borderRadius: forma === 'circulo' ? '50%' : medida(a.raio ?? el.raio, '0px'),
265
302
  border: el.contorno || undefined,
266
303
  }} />
@@ -375,10 +412,11 @@ function Elemento({ el, t, dados }) {
375
412
  }
376
413
  }
377
414
 
378
- export function Cena({ cena, dados }) {
415
+ export function Cena({ cena, dados, tFixo }) {
379
416
  const frame = useCurrentFrame();
380
417
  const { fps } = useVideoConfig();
381
- const t = frame / fps;
418
+ // A página parada é a cena num instante só: tFixo manda no tempo.
419
+ const t = tFixo != null ? tFixo : frame / fps;
382
420
 
383
421
  const cam = animado(cena.camera, t);
384
422
  const camTransform = [
@@ -439,6 +477,21 @@ function Transicao({ tipo, progresso, children }) {
439
477
  return <AbsoluteFill style={estilo}>{children}</AbsoluteFill>;
440
478
  }
441
479
 
480
+ /* AS PÁGINAS: um quadro por cena, cada uma congelada no meio dela. Os
481
+ * slides saem num render só — antes era um "remotion still" por página, e
482
+ * cada um empacotava o projeto inteiro de novo (onze páginas, onze
483
+ * empacotamentos: é a demora de 25 minutos medida em 25/09). */
484
+ export const Paginas = () => {
485
+ const { fps } = useVideoConfig();
486
+ const frame = useCurrentFrame();
487
+ const cena = plano.cenas[Math.min(frame, plano.cenas.length - 1)];
488
+ return (
489
+ <AbsoluteFill style={{ backgroundColor: plano.fundoGeral || '#000' }}>
490
+ <Cena cena={cena} dados={{ fps, icones }} tFixo={cena.duracao / 2} />
491
+ </AbsoluteFill>
492
+ );
493
+ };
494
+
442
495
  export const Video = () => {
443
496
  const { fps } = useVideoConfig();
444
497
  const frame = useCurrentFrame();
@@ -478,6 +531,7 @@ export const Video = () => {
478
531
  <Audio
479
532
  src={staticFile(plano.audio.trilha.src || plano.audio.trilha)}
480
533
  volume={plano.audio.trilha.volume ?? 0.18}
534
+ loop={plano.audio.trilha.loop !== false}
481
535
  />
482
536
  )}
483
537
  {(plano.audio && plano.audio.efeitos ? plano.audio.efeitos : []).map((e, i) => (
@@ -494,7 +548,7 @@ function rootJsx(plano) {
494
548
  const f = plano.formato || {};
495
549
  return `import React from 'react';
496
550
  import { Composition } from 'remotion';
497
- import { Video } from './Video';
551
+ import { Video, Paginas } from './Video';
498
552
  import plano from './plano.json';
499
553
  import './fontes.css';
500
554
 
@@ -505,14 +559,24 @@ const quadros = Math.max(1, Math.round(
505
559
  ));
506
560
 
507
561
  export const RemotionRoot = () => (
508
- <Composition
509
- id="Video"
510
- component={Video}
511
- durationInFrames={quadros}
512
- fps={fps}
513
- width={${Number(f.largura) || 1920}}
514
- height={${Number(f.altura) || 1080}}
515
- />
562
+ <>
563
+ <Composition
564
+ id="Video"
565
+ component={Video}
566
+ durationInFrames={quadros}
567
+ fps={fps}
568
+ width={${Number(f.largura) || 1920}}
569
+ height={${Number(f.altura) || 1080}}
570
+ />
571
+ <Composition
572
+ id="Paginas"
573
+ component={Paginas}
574
+ durationInFrames={plano.cenas.length}
575
+ fps={fps}
576
+ width={${Number(f.largura) || 1920}}
577
+ height={${Number(f.altura) || 1080}}
578
+ />
579
+ </>
516
580
  );
517
581
  `;
518
582
  }
@@ -48,41 +48,45 @@ const { spawn } = require('child_process');
48
48
  function renderizarPaginas({ raiz, destino, plano, aoFalar = () => {} }) {
49
49
  return new Promise((resolve) => {
50
50
  fs.mkdirSync(destino, { recursive: true });
51
- const fps = Number((plano.formato || {}).fps) || 30;
52
-
53
- // Onde começa cada cena, em quadros — a mesma conta do Video.jsx.
54
- const inicios = [];
55
- let cursor = 0;
56
- for (const cena of plano.cenas || []) {
57
- const dur = Math.max(1, Math.round(cena.duracao * fps));
58
- const t = cena.transicao || {};
59
- const cruz = (t.tipo || t) === 'corte' ? 0 : Math.round((t.duracao ?? 0.4) * fps);
60
- inicios.push({ de: cursor, dur });
61
- cursor += dur - cruz;
62
- }
51
+ for (const f of fs.readdirSync(destino)) if (/\.(jpe?g|png)$/i.test(f)) fs.rmSync(path.join(destino, f));
52
+ const n = (plano.cenas || []).length;
53
+ const shell = process.platform === 'win32';
54
+ const cita = (a) => (shell && /\s/.test(a) ? `"${a}"` : a);
55
+
56
+ /* Projeto novo não tem o Remotion instalado: o caminho de slides
57
+ rodava o npx direto e morria em "could not determine executable". */
58
+ const passos = fs.existsSync(path.join(raiz, 'node_modules')) ? [] : [['npm', ['install', '--no-audit', '--no-fund']]];
59
+ // Todas as páginas num render só: a composição "Paginas" tem um quadro por cena.
60
+ /* O Remotion recusa pasta de sequência com ponto no caminho ("cannot
61
+ have an extension") — e ~/.primocode tem. A sequência sai numa pasta
62
+ temporária sem ponto e as páginas são movidas depois. */
63
+ const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'primo-paginas-'));
64
+ passos.push(['npx', ['remotion', 'render', 'Paginas', tmp, '--sequence',
65
+ '--image-format=jpeg', '--jpeg-quality=95', '--log=warn']]);
63
66
 
64
- const paginas = [];
65
67
  const correr = (i) => {
66
- if (i >= inicios.length) return resolve({ ok: true, paginas });
67
- const { de, dur } = inicios[i];
68
- const quadro = de + Math.floor(dur / 2);
69
- const arquivo = path.join(destino, `pagina-${String(i + 1).padStart(3, '0')}.jpeg`);
70
- aoFalar(`página ${i + 1} de ${inicios.length}`);
71
-
72
- const p = spawn('npx', [
73
- 'remotion', 'still', 'Video', arquivo,
74
- '--frame=' + quadro, '--image-format=jpeg', '--quality=95', '--log=warn',
75
- ], { cwd: raiz, shell: process.platform === 'win32' });
76
-
68
+ if (i >= passos.length) {
69
+ // Pelo NÚMERO: em ordem alfabética, element-10 vem antes de element-2.
70
+ const num = (f) => Number((f.match(/(\d+)\.jpe?g$/i) || [])[1] || 0);
71
+ const saidas = fs.readdirSync(tmp).filter((f) => /\.jpe?g$/i.test(f)).sort((a, b) => num(a) - num(b));
72
+ const paginas = saidas.map((f, k) => {
73
+ const final = path.join(destino, `pagina-${String(k + 1).padStart(3, '0')}.jpeg`);
74
+ fs.copyFileSync(path.join(tmp, f), final);
75
+ return final;
76
+ });
77
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* fica no temporário */ }
78
+ if (paginas.length !== n) return resolve({ ok: false, error: `saíram ${paginas.length} páginas de ${n}.` });
79
+ return resolve({ ok: true, paginas });
80
+ }
81
+ const [cmd, args] = passos[i];
82
+ aoFalar(cmd === 'npm' ? 'instalando o Remotion nesta pasta (uma vez só)' : `desenhando ${n} páginas`);
83
+ const p = spawn(cmd, args.map(cita), { cwd: raiz, shell });
77
84
  let erro = '';
78
- p.stderr.on('data', (d) => { erro += d.toString().slice(0, 1000); });
79
- const relogio = setTimeout(() => { try { p.kill(); } catch {} }, 5 * 60000);
85
+ p.stderr.on('data', (d) => { erro += d.toString().slice(0, 2000); });
86
+ const relogio = setTimeout(() => { try { p.kill(); } catch {} }, 15 * 60000);
80
87
  p.on('close', (code) => {
81
88
  clearTimeout(relogio);
82
- if (code !== 0) {
83
- return resolve({ ok: false, error: `a página ${i + 1} falhou: ${erro.slice(-400).trim()}` });
84
- }
85
- paginas.push(arquivo);
89
+ if (code !== 0) return resolve({ ok: false, error: `${cmd} saiu com ${code}: ${erro.slice(-400).trim()}` });
86
90
  correr(i + 1);
87
91
  });
88
92
  p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, error: e.message }); });