primocode 10.1.0 → 10.2.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.
@@ -26,6 +26,8 @@
26
26
 
27
27
  'use strict';
28
28
 
29
+ const decisao = require('./decisao.js');
30
+
29
31
  // ── as palavras que não carregam ideia ───────────────────────────────────
30
32
  const VAZIAS = new Set((
31
33
  'a o as os um uma uns umas de do da dos das no na nos nas em por para pra pro com sem que se e '
@@ -88,6 +90,25 @@ const ICONES = [
88
90
  [['surpre', 'chocad', 'absurd'], 'exploding-head'],
89
91
  ];
90
92
 
93
+ /* AS OUTRAS FORMAS DE DIZER O MESMO (07/10): "não quero que o sistema fique
94
+ repetindo os mesmos ícones". Cada conceito tinha UM emoji — todo vídeo de
95
+ dinheiro saía com o mesmo saco de dinheiro. O ícone automático continua
96
+ saindo daqui pelo primeiro nome (o de ICONES); a passada de variedade do
97
+ vídeo (index.js, variarIcones) troca pelo que os últimos vídeos menos
98
+ usaram, o mesmo conceito com o mesmo ícone dentro do vídeo. Nomes
99
+ conferidos no fluent-emoji-flat do Iconify em 07/10. */
100
+ const ALTERNATIVAS = {
101
+ robot: ['mechanical-arm', 'desktop-computer'], brain: ['thought-balloon', 'light-bulb'], 'light-bulb': ['sparkles', 'glowing-star', 'gem-stone'],
102
+ 'money-bag': ['dollar-banknote', 'coin', 'money-with-wings', 'credit-card'], stopwatch: ['hourglass-not-done', 'alarm-clock', 'mantelpiece-clock', 'watch'],
103
+ 'chart-increasing': ['seedling', 'mountain', 'chequered-flag'], 'bar-chart': ['clipboard', 'hundred-points'], rocket: ['high-voltage', 'airplane'],
104
+ fire: ['collision', 'high-voltage', 'dizzy'], 'check-mark-button': ['check-mark', 'thumbs-up', 'hundred-points'], 'cross-mark': ['stop-sign', 'no-entry'],
105
+ laptop: ['desktop-computer', 'keyboard'], 'globe-showing-americas': ['globe-with-meridians', 'satellite-antenna'], 'speaker-high-volume': ['microphone', 'headphone', 'megaphone'],
106
+ memo: ['pencil', 'open-book', 'clipboard'], books: ['open-book', 'pencil'], 'speech-balloon': ['speaking-head', 'thought-balloon', 'megaphone'],
107
+ gear: ['hammer-and-wrench', 'toolbox', 'link'], calendar: ['spiral-calendar'], trophy: ['sports-medal', 'crown', 'glowing-star'],
108
+ 'flexed-biceps': ['high-voltage'], handshake: ['people-hugging', 'link'], locked: ['shield', 'key'], briefcase: ['bank', 'classical-building'],
109
+ 'party-popper': ['confetti-ball', 'sparkles'], 'red-heart': ['sparkling-heart'], 'shopping-cart': ['receipt', 'credit-card'], 'clapper-board': ['video-camera', 'camera'],
110
+ };
111
+
91
112
  /* O termo de busca da IMAGEM DE APOIO, pelo emoji que a palavra ganhou. Em
92
113
  inglês de propósito: os acervos abertos respondem muito melhor assim. */
93
114
  const FOTO = {
@@ -225,14 +246,6 @@ function blocos(palavras, maximo) {
225
246
  return out;
226
247
  }
227
248
 
228
- // O zoom de cada cena, em rodízio: parado, punch-in, empurrando devagar.
229
- function camera(i, dur) {
230
- const tipo = i % 3;
231
- if (tipo === 0) return { zoom: [{ t: 0, v: 1 }, { t: dur, v: 1.04, e: 'linear' }] };
232
- if (tipo === 1) return { zoom: [{ t: 0, v: 1.18 }, { t: dur, v: 1.21, e: 'linear' }] };
233
- return { zoom: [{ t: 0, v: 1.07 }, { t: dur, v: 1.13, e: 'suave' }] };
234
- }
235
-
236
249
  const ESTILOS = {
237
250
  viral: { icones: true, letreiro: true, sons: true, barra: true, apoio: true, recorte: true, fundoRecorte: ['#1B1F3B', '#3A1C71'], destaque: '#FFD400', forte: '#00E0A4' },
238
251
  limpo: { icones: false, letreiro: false, sons: false, barra: false, apoio: true, recorte: false, fundoRecorte: ['#111111', '#333333'], destaque: '#FFFFFF', forte: '#FFD400' },
@@ -251,8 +264,6 @@ const ESTILOS = {
251
264
  * @param {object} op.sons { pop, whoosh } — caminhos na pasta do projeto, opcionais
252
265
  * @param {Set} op.iconesOk os nomes de ícone que existem de verdade (sem isso, todos valem)
253
266
  */
254
- // Em quais cenas a pessoa é recortada, com a palavra gigante atrás dela.
255
- const temRecorte = (i) => i % 4 === 2;
256
267
 
257
268
  function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null, imagens = {}, trilha = null, recortes = {}, marca = null }) {
258
269
  const e = ESTILOS[estilo] || ESTILOS.viral;
@@ -263,6 +274,10 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
263
274
  const base = Math.min(largura, altura);
264
275
  const grupos = frases(lista);
265
276
  const total = duracao || lista[lista.length - 1].ate + 0.3;
277
+ /* O QUE CADA FRASE PEDE (decisao.js): movimento, recorte e whoosh pela
278
+ intensidade dela, não pelo rodízio i % 3 — e depois do golpe, respiro. */
279
+ const decisoes = decisao.decidir(grupos, { peso });
280
+ let ladoQuadro = 0;
266
281
  const cenas = [];
267
282
  const efeitos = [];
268
283
  const infos = []; // onde cada cena começa e a palavra forte dela — para o recorte
@@ -283,12 +298,13 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
283
298
  deOrigem: r2(ini), ateOrigem: r2(ini + dur), volume: 1,
284
299
  }];
285
300
 
286
- infos.push({ ini: r2(ini), dur, forte: forte ? { de: local(forte.de), texto: forte.texto, peso: peso(forte.texto) } : null });
301
+ infos.push({ ini: r2(ini), dur, forte: forte ? { de: local(forte.de), texto: forte.texto, peso: peso(forte.texto) } : null,
302
+ recorte: decisoes[i].recorte, intensidade: decisoes[i].intensidade });
287
303
 
288
304
  /* A PALAVRA ATRÁS DA PESSOA: o fundo do quarto some, entra a cor da
289
305
  marca, a palavra forte aparece enorme e a pessoa, recortada, fica
290
306
  na frente dela. É o efeito que mais denuncia edição de videomaker. */
291
- const rec = e.recorte && temRecorte(i) ? recortes[i] : null;
307
+ const rec = e.recorte && decisoes[i].recorte ? recortes[i] : null;
292
308
  if (rec && forte) {
293
309
  const vida = r2(rec.ate - rec.de);
294
310
  const some = [{ t: 0, v: 0 }, { t: 0.1, v: 1 }, { t: vida - 0.12, v: 1 }, { t: vida, v: 0 }];
@@ -403,10 +419,12 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
403
419
  });
404
420
  }
405
421
 
406
- // O whoosh entra no punch-in: é o corte que ele anuncia.
407
- 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 });
422
+ // O whoosh entra no soco: é o corte que ele anuncia (e só nele).
423
+ if (e.sons && sons.whoosh && decisoes[i].whoosh) efeitos.push({ t: r2(Math.max(0, ini - 0.12)), src: sons.whoosh, volume: 0.6 });
408
424
 
409
- cenas.push({ duracao: dur, camera: camera(i, dur), elementos, transicao: { tipo: 'corte' } });
425
+ const d = decisoes[i];
426
+ if (d.movimento === 'reenquadra') ladoQuadro = 1 - ladoQuadro;
427
+ cenas.push({ duracao: dur, camera: decisao.camera(d.movimento, dur, d.intensidade, ladoQuadro, base), elementos, transicao: { tipo: 'corte' }, impacto: d.movimento === 'soco' || undefined });
410
428
  });
411
429
 
412
430
  return {
@@ -473,4 +491,4 @@ async function prepararSons(pasta, video) {
473
491
  return sons;
474
492
  }
475
493
 
476
- module.exports = { montarEdicao, temRecorte, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, rotaDeApoio, DEPOIS_DA_PALAVRA, ESTILOS, ICONES, FOTO };
494
+ module.exports = { montarEdicao, ALTERNATIVAS, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, rotaDeApoio, DEPOIS_DA_PALAVRA, ESTILOS, ICONES, FOTO };
@@ -51,13 +51,23 @@ const normal = (e) => {
51
51
  /* O RODÍZIO: quando ninguém escolheu ("vários estilos... que não sejam padrão
52
52
  da IA", Joel 05/10), o padrão não é sempre o híbrido — gira entre os que
53
53
  servem a qualquer roteiro, sem repetir os últimos (memória 'estilo'). */
54
- const RODIZIO = ['hibrido', 'cinema', 'gerativo', 'impacto', 'fluido'];
54
+ /* Com imagem pedida (08/10): o padrão é o FLUIDO MISTO — motion com a
55
+ imagem em poucos momentos, cada uma entrando de um jeito. O híbrido e o
56
+ cinema ("10 mil imagens de banco e a mesma animação") ficam pelo nome. */
57
+ const RODIZIO = ['fluido'];
58
+ /* Sem imagem (o padrão desde 07/10), o rodízio fica nos dois motions que
59
+ nasceram das referências boas (motions-exemplos/motion-god): o fluido e o
60
+ gerativo. O impacto (emoji 3D em cada batida, cara de anúncio) saiu do
61
+ rodízio em 08/10 — "horríveis, nada a ver com as referências" — e fica
62
+ para quem o pede pelo nome (o agente ou o roteiro). */
63
+ const RODIZIO_SEM_IMAGEM = ['fluido', 'gerativo'];
55
64
 
56
- function estiloDaVez(aleatorio = Math.random) {
57
- return variedade.sortear(RODIZIO, 'estilo', { aleatorio }) || 'hibrido';
65
+ function estiloDaVez(aleatorio = Math.random, semImagem = false) {
66
+ const lista = semImagem ? RODIZIO_SEM_IMAGEM : RODIZIO;
67
+ return variedade.sortear(lista, 'estilo', { aleatorio }) || lista[0];
58
68
  }
59
69
 
60
- function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = false, aleatorio } = {}) {
70
+ function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = false, semImagem = false, aleatorio } = {}) {
61
71
  const pedidoAgente = normal(agente);
62
72
  if (pedidoAgente) return pedidoAgente;
63
73
  const doRoteiro = normal(roteiro);
@@ -73,10 +83,11 @@ function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = fa
73
83
  /* Anúncio, reels e o resto: o HÍBRIDO. "Um mix da versão antiga (o reels
74
84
  da imersão, no cinema) e da última (o kit) — absurdo, que nem parece
75
85
  que foi IA" (29/09). Com `girar`, o padrão roda (RODIZIO). */
76
- return girar ? estiloDaVez(aleatorio) : 'hibrido';
86
+ if (girar) return estiloDaVez(aleatorio, semImagem);
87
+ return semImagem ? 'gerativo' : 'hibrido';
77
88
  }
78
89
 
79
90
  /** Os estilos que usam os efeitos leves e a trilha do ende.app (os de motion, não os de corte). */
80
91
  const LEVES = new Set(['gerativo', 'motion', 'vox', 'marca', 'lancamento', 'fluido']);
81
92
 
82
- module.exports = { escolherEstilo, estiloDaVez, ESTILOS, LEVES, PEDE_MOTION, PEDE_FLUIDO, RODIZIO };
93
+ module.exports = { escolherEstilo, estiloDaVez, ESTILOS, LEVES, PEDE_MOTION, PEDE_FLUIDO, RODIZIO, RODIZIO_SEM_IMAGEM };
@@ -38,11 +38,18 @@ const ANTES = 0.12; // a batida troca isto antes da primeir
38
38
  NO MÁXIMO dois acentos (o da palavra e o brilho difuso). */
39
39
  const PALETAS = {
40
40
  noite: { escuro: true, fundo: ['#020615', '#061a5c', '#1240c9'], tinta: '#eef2ff', suave: '#9fb0d9', acentos: ['#8fb8ff', '#2f6bff'] },
41
+ /* As CHAPADAS das referências boas (08/10): fundo de uma cor só, sem o
42
+ gradiente — o laranja da "criatividade", o branco com azul, o preto puro
43
+ e o cobalto. O "sempre o mesmo azul" era vício. */
44
+ laranja: { escuro: false, fundo: ['#f2621f', '#f06a28', '#ec7a3c'], tinta: '#1a0d05', suave: '#5a2a10', acentos: ['#fff4ea', '#ffd2b0'] },
45
+ branco: { escuro: false, fundo: ['#ffffff', '#f6f7fa', '#eceff6'], tinta: '#0b0d12', suave: '#6b7180', acentos: ['#1f4bff', '#9fb3ff'] },
46
+ preto: { escuro: true, fundo: ['#000000', '#050505', '#121212'], tinta: '#f5f5f5', suave: '#8a8a8a', acentos: ['#ff7a3d', '#ff9a66'] },
47
+ cobalto: { escuro: true, fundo: ['#1238e6', '#1640f0', '#2350ff'], tinta: '#ffffff', suave: '#c8d4ff', acentos: ['#ffe14d', '#ffffff'] },
41
48
  breu: { escuro: true, fundo: ['#030304', '#0b0a12', '#2a2156'], tinta: '#f4f4f6', suave: '#8d8a9c', acentos: ['#b4a6ff', '#5b45ff'] },
42
49
  lavanda: { escuro: false, fundo: ['#f5f4ff', '#e4e1ff', '#b9b2ff'], tinta: '#17153a', suave: '#6e6a92', acentos: ['#4a3ce0', '#8f86ff'] },
43
50
  creme: { escuro: false, fundo: ['#fbf8f2', '#f2e8d8', '#e7c9a5'], tinta: '#1e1a15', suave: '#857a6c', acentos: ['#c2502a', '#f0a36b'] },
44
51
  };
45
- const ESCURAS = ['noite', 'breu'], CLARAS = ['lavanda', 'creme'];
52
+ const ESCURAS = ['noite', 'breu', 'preto', 'cobalto'], CLARAS = ['lavanda', 'creme', 'laranja', 'branco'];
46
53
 
47
54
  const hash = (s) => { let h = 2166136261; for (const c of String(s)) h = Math.imul(h ^ c.charCodeAt(0), 16777619); return h >>> 0; };
48
55
 
@@ -51,9 +58,9 @@ function paletaDe(roteiro, semente) {
51
58
  const pedida = String(roteiro.paleta || '').toLowerCase();
52
59
  const tema = String(roteiro.tema || '').toLowerCase();
53
60
  const nome = PALETAS[pedida] ? pedida
54
- : /claro|light/.test(tema) ? CLARAS[semente % 2]
55
- : /escuro|dark/.test(tema) ? ESCURAS[semente % 2]
56
- : Object.keys(PALETAS)[semente % 4];
61
+ : /claro|light/.test(tema) ? CLARAS[semente % CLARAS.length]
62
+ : /escuro|dark/.test(tema) ? ESCURAS[semente % ESCURAS.length]
63
+ : Object.keys(PALETAS)[semente % Object.keys(PALETAS).length];
57
64
  const p = { nome, ...PALETAS[nome], acentos: [...PALETAS[nome].acentos] };
58
65
  // A cor da marca vira o acento da palavra (e só ela: a paleta não cresce).
59
66
  const dest = roteiro.marca && roteiro.marca.cores && roteiro.marca.cores.destaque;
@@ -106,7 +113,7 @@ const TIPO = { logo: 'abertura', imagem: 'titulo', produto: 'titulo', grafico: '
106
113
  * A partitura: as batidas, cada uma com as frases (e quando cada uma entra),
107
114
  * a forma do portador e a transição. Sem áudio nem plano — é o que os testes olham.
108
115
  */
109
- function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [] }) {
116
+ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], imagens = {} }) {
110
117
  const partes = (roteiro.partes || []).filter((p) => p && typeof p === 'object');
111
118
  const semente = Number.isFinite(roteiro.semente) ? roteiro.semente : hash(JSON.stringify(partes.map((p) => [p.tipo, p.titulo, p.valor])));
112
119
  const paleta = paletaDe(roteiro, semente);
@@ -116,9 +123,15 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [] }) {
116
123
  const link = (partes.find((p) => p.tipo === 'cta') || {}).link || '';
117
124
  const cmd = /^(npm|npx|pnpm|yarn|pip|brew|curl|git|bun)\b/.test(link) ? link : String(marca.nome || 'app').toLowerCase().replace(/\s+/g, '-');
118
125
  let comCartao = 0;
126
+ /* O cartão de produto só quando o vídeo É de produto: tem marca, um
127
+ comando de instalar ou um link. Um vídeo sobre sono saía com um
128
+ terminal "$ app" ao lado do título (teste de 08/10). `p.ui` explícito vale sempre. */
129
+ const deProduto = !!(marca.nome || link || /\b(app|software|plataforma|ferramenta|produto|sistema|saas|api|cli)\b/i.test(String(roteiro.pedido || '')));
119
130
 
120
131
  const batidas = partes.map((p, i) => {
121
- const tipo = TIPO[p.tipo] || (FORMA[p.tipo] ? p.tipo : 'titulo');
132
+ /* Fecho sem marca não monta a "marca de bolinhas" (vício, 08/10): vira
133
+ uma frase — e, curta, a gigante do fim. */
134
+ const tipo = p.tipo === 'cta' && !marca.nome ? 'titulo' : TIPO[p.tipo] || (FORMA[p.tipo] ? p.tipo : 'titulo');
122
135
  const titulo = p.titulo || p.texto || '';
123
136
  const fr = [];
124
137
  const poe = (papel, texto, o = {}) => { if (String(texto || '').trim()) fr.push({ id: `${i}-${fr.length}`, papel, palavras: frase(texto, o) }); };
@@ -126,7 +139,8 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [] }) {
126
139
  /* O CARTÃO DE PRODUTO que flutua ao lado (terminal, chat, painel), feito
127
140
  de forma e texto — e o texto vai para o outro lado. `ui: false` tira. */
128
141
  const ui = p.ui === false ? null : typeof p.ui === 'string' ? p.ui
129
- : tipo === 'titulo' || tipo === 'abertura' ? ['terminal', 'chat'][comCartao % 2] : tipo === 'numero' ? 'painel' : null;
142
+ : !deProduto ? null
143
+ : tipo === 'titulo' || tipo === 'abertura' ? ['terminal', 'chat'][comCartao % 2] : tipo === 'numero' ? 'painel' : null;
130
144
  if (ui) {
131
145
  b.lado = comCartao++ % 2 ? 'dir' : 'esq';
132
146
  b.ui = ui === 'terminal' ? { tipo: ui, linhas: ['$ ' + cmd, '✓ ' + caixaNormal(p.sub || titulo)] }
@@ -165,6 +179,53 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [] }) {
165
179
  return b;
166
180
  });
167
181
 
182
+ /* ── A PALAVRA GIGANTE e a VIRADA (08/10): nas referências boas, o
183
+ momento forte é uma frase curta que ENCHE a tela, e o fundo vira
184
+ (o ponto cresce até cobrir tudo de cor). Sem isso o vídeo era a mesma
185
+ pílula pequena no mesmo azul por 36 s — "horríveis". Regras de editor:
186
+ só frase de até 4 palavras, abertura ou com destaque (a palavra-tese)
187
+ primeiro; gigante em até metade das batidas; a virada só nas que têm
188
+ destaque, 1 em 3 e nunca seguida. `p.gigante` explícito manda. */
189
+ const curta = (b) => { const f = b.frases.find((x) => x.papel === 'principal'); return f && f.palavras.length <= 4 && b.frases.length <= 2; };
190
+ const candidatas = batidas.map((b, i) => ({ b, i, p: partes[i] }))
191
+ .filter(({ b, p }) => p.gigante !== false && (b.tipo === 'titulo' || b.tipo === 'abertura') && !b.ui && !b.imagem && curta(b))
192
+ .sort((x, y) => (y.p.gigante === true) - (x.p.gigante === true) || !!y.p.destaque - !!x.p.destaque || (x.b.tipo === 'abertura' ? -1 : 0) || x.i - y.i);
193
+ /* ── AS IMAGENS, quando a pessoa pediu (08/10: "deve ser misto com imagens
194
+ e motion, e com variadas animações"; o híbrido era "10 mil imagens de
195
+ banco e a mesma animação"). A imagem é UM momento: só nas batidas que
196
+ têm imagem baixada, no máximo 1 em 3, nunca duas seguidas. E cada uma
197
+ entra de um JEITO diferente, sem repetir no vídeo:
198
+ janela a foto abre da esquerda e cobre a tela, o texto por cima
199
+ circulo o ponto cresce em círculo com a foto dentro
200
+ cartao a foto num cartão que gira em 3D, o texto acima
201
+ faixa uma faixa da foto abre na altura, o texto abaixo
202
+ vazado o título gigante com a foto por DENTRO das letras */
203
+ const JEITOS = ['janela', 'circulo', 'cartao', 'faixa', 'vazado'];
204
+ const ordemJeitos = JEITOS.map((j, k) => ({ j, o: hash(j + semente) })).sort((a, b) => a.o - b.o).map((x) => x.j);
205
+ const tetoImagem = Math.max(1, Math.floor(batidas.length / 3));
206
+ let nImagem = 0;
207
+ for (const b of batidas) {
208
+ const img = imagens[b.i];
209
+ if (!img || !img.src || b.tipo === 'cta' || nImagem >= tetoImagem) continue;
210
+ if (batidas[b.i - 1] && batidas[b.i - 1].imagem) continue;
211
+ const pr = b.frases.find((f) => f.papel === 'principal');
212
+ let jeito = ordemJeitos[nImagem % ordemJeitos.length];
213
+ if (jeito === 'vazado' && !(pr && pr.palavras.length <= 3)) jeito = ordemJeitos[(nImagem + 1) % ordemJeitos.length] === 'vazado' ? 'janela' : ordemJeitos[(nImagem + 1) % ordemJeitos.length];
214
+ b.imagem = { src: img.src, jeito };
215
+ b.ui = null; b.lado = 'centro';
216
+ nImagem++;
217
+ }
218
+
219
+ // A gigante pode seguir outra (as referências fazem isso); a VIRADA é o golpe: 1 em 3, nunca seguida.
220
+ const teto = Math.max(1, Math.ceil(batidas.length / 2)), tetoVirada = Math.max(1, Math.floor(batidas.length / 3));
221
+ for (const { b, i, p } of candidatas) {
222
+ if (batidas.filter((x) => x.gigante).length >= teto && p.gigante !== true) break;
223
+ b.gigante = true;
224
+ const viradas = batidas.filter((x) => x.virada).length;
225
+ const vizinha = (batidas[i - 1] && batidas[i - 1].virada) || (batidas[i + 1] && batidas[i + 1].virada);
226
+ if (i > 0 && viradas < tetoVirada && !vizinha && (p.destaque || p.gigante === true)) b.virada = true;
227
+ }
228
+
168
229
  /* ── o tempo: a VOZ manda. A batida troca na fronteira da frase (um
169
230
  pouco antes da primeira palavra da parte) e cada palavra na tela
170
231
  aparece no instante em que é dita (os tempos por palavra da narração).
@@ -185,13 +246,16 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [] }) {
185
246
  cada palavra (a frase da tela pode vir em outra ordem que a falada)
186
247
  e fica com o casamento que acha mais palavras. */
187
248
  const casa = (k, d) => d === k || (k.length >= 4 && d.length >= 4 && d.slice(0, 5) === k.slice(0, 5));
249
+ /* Cada frase procura as suas palavras na fala INTEIRA da batida: com
250
+ um cursor só, o título que casava com o fim da fala deixava o
251
+ subtítulo sem achar as dele (entrava 0,45 s atrasado, 08/10). */
188
252
  let ts = f.palavras.map(() => null), fim = cursor;
189
253
  for (let ini = 0; ini < f.palavras.length; ini++) {
190
- let c = cursor;
254
+ let c = 0;
191
255
  const tt = f.palavras.map((w, j) => {
192
256
  const k = limpa(w.t);
193
257
  if (j < ini || !k) return null;
194
- for (let q = c; q < Math.min(ditas.length, c + 12); q++) if (casa(k, ditas[q].k)) { c = q + 1; return ditas[q].de; }
258
+ for (let q = c; q < Math.min(ditas.length, c + 40); q++) if (casa(k, ditas[q].k)) { c = q + 1; return ditas[q].de; }
195
259
  return null;
196
260
  });
197
261
  if (tt.filter((x) => x != null).length > ts.filter((x) => x != null).length) { ts = tt; fim = c; }
@@ -201,7 +265,11 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [] }) {
201
265
  const ach = ts.findIndex((x) => x != null);
202
266
  /* O que a voz não diz entra cedo o bastante para ser lido antes de a
203
267
  frase acabar (e nunca antes da batida). */
204
- let t = ach >= 0 ? ts[ach] - ach * STAGGER
268
+ /* O que a voz DIZ entra quando ela diz — nunca antes. Antes de 08/10
269
+ a frase entrava no começo da batida se a voz demorasse: "ontem à
270
+ noite" aparecia 2,8 s antes de ser dito, e o Joel viu "atrasos
271
+ entre a narração e o vídeo". */
272
+ let t = ach >= 0 && ts[ach] != null ? ts[ach] - ach * STAGGER
205
273
  : tp ? Math.max(b.de + 0.25, Math.min(livre, fimVoz - leitura(f.palavras.length) - 0.05)) : livre;
206
274
  f.palavras.forEach((w, j) => {
207
275
  const falada = ts[j];
@@ -226,6 +294,7 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [] }) {
226
294
 
227
295
  return {
228
296
  versao: 2, semente, fps: FPS, atraso, sincronia, largura, altura, duracao: ult.ate, paleta,
297
+ marcador: roteiro.marcador || ['brilho', 'numero', 'traco', 'ponto', 'nenhum'][hash('marcador' + semente) % 5],
229
298
  fontes: { texto: 'Inter', serifa: 'Instrument Serif', mono: 'JetBrains Mono' },
230
299
  logo: marca.logo || null,
231
300
  batidas,
@@ -238,10 +307,10 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [] }) {
238
307
  * @param {Array} op.tempos [{de, ate, palavras}] por parte (da narração)
239
308
  * @param {object} op.sons os efeitos leves (sons-leves.js)
240
309
  */
241
- function montarFluido({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null }) {
310
+ function montarFluido({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, imagens = {} }) {
242
311
  const partes = ((roteiro && roteiro.partes) || []).filter((p) => p && typeof p === 'object');
243
312
  if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
244
- const partitura = partituraDe({ roteiro: { ...roteiro, partes }, largura, altura, tempos });
313
+ const partitura = partituraDe({ roteiro: { ...roteiro, partes }, largura, altura, tempos, imagens });
245
314
  const DUR = partitura.duracao;
246
315
  /* O SOM segue o movimento (sons-leves.js, tudo sintetizado): whoosh em
247
316
  cada transformação, pop quando pílula ou cartão assenta, clique na roda e
@@ -277,7 +346,7 @@ function montarFluido({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
277
346
  efeitos,
278
347
  },
279
348
  };
280
- return { ok: true, plano, partitura, resumo: { cenas: 1, batidas: partitura.batidas.length, segundos: DUR, fps: FPS, paleta: P.nome, atraso: partitura.atraso, sincronia: partitura.sincronia } };
349
+ return { ok: true, plano, partitura, resumo: { cenas: 1, batidas: partitura.batidas.length, imagens: partitura.batidas.filter((b) => b.imagem).map((b) => b.imagem.jeito), segundos: DUR, fps: FPS, paleta: P.nome, atraso: partitura.atraso, sincronia: partitura.sincronia } };
281
350
  }
282
351
 
283
352
  module.exports = { montarFluido, partituraDe, caixaNormal, PALETAS, POR_PALAVRA, MINIMO };
@@ -43,6 +43,8 @@ const plano = require('./plano.js');
43
43
  const remotion = require('./remotion.js');
44
44
  const assets = require('./assets.js');
45
45
  const video = require('./video.js');
46
+ const batida = require('./batida.js');
47
+ const revisor = require('./revisor.js');
46
48
  const identidade = require('./identidade.js');
47
49
  const slides = require('./slides.js');
48
50
  const edicao = require('./edicao.js');
@@ -666,7 +668,7 @@ async function ferramentaVideo(args, ctx = {}) {
666
668
  if ((args.estilo || 'viral') === 'viral' && args.recorte !== false) {
667
669
  const recortes = {};
668
670
  for (const [i, info] of montado.infos.entries()) {
669
- if (!edicao.temRecorte(i) || !info.forte || info.forte.peso < 9 || Object.keys(recortes).length >= 3) continue;
671
+ if (!info.recorte || !info.forte || info.forte.peso < 9 || Object.keys(recortes).length >= 3) continue;
670
672
  const de = Math.max(0, info.forte.de - 0.15);
671
673
  const ate = Math.min(info.dur, de + 2.2);
672
674
  if (ate - de < 1) continue;
@@ -715,6 +717,18 @@ async function ferramentaVideo(args, ctx = {}) {
715
717
  senão uma foto. Devolve {src, tipo} ou null. O clipe sai sorteado entre
716
718
  os que os últimos vídeos não usaram (variedade.js), não o primeiro da
717
719
  busca — "o b-roll veio igual nos dois vídeos" (Joel, 05/10). */
720
+ /** A foto editorial (Wikimedia, museu) que os últimos vídeos não usaram; o crédito fica no caderno. */
721
+ async function baixarEditorial(p, termo) {
722
+ try {
723
+ const b = await assets.procurarEditorial(termo, { quantas: 8 });
724
+ for (const item of variedade.ordenar(b.imagens, 'broll').slice(0, 4)) {
725
+ const d = await assets.baixar(item, { pasta: p.pasta, nome: 'foto-' + termo });
726
+ if (d.ok) { variedade.marcar('broll', item.url); projeto.anotar(p.id, 'creditos', item.creditos); return { src: d.relativo, tipo: 'imagem' }; }
727
+ }
728
+ } catch { /* sem foto */ }
729
+ return null;
730
+ }
731
+
718
732
  async function baixarApoio(p, termo, { orientacao = null, video: querVideo = false } = {}) {
719
733
  if (querVideo) {
720
734
  try {
@@ -749,11 +763,26 @@ async function variarIcones(plano, roteiro) {
749
763
  const explicitos = [];
750
764
  try { JSON.stringify(roteiro || {}, (k, v) => { if (k === 'icone' && typeof v === 'string') explicitos.push(v); return v; }); } catch { /* sem */ }
751
765
  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 */ }
766
+ for (const nome of variedade.iconesTrocaveis(plano, explicitos)) {
767
+ /* Primeiro O ÍCONE: o conceito escolhe entre as formas dele
768
+ (edicao.ALTERNATIVAS) pelos pontos da variedade — o que os últimos
769
+ vídeos menos usaram. Depois A COLEÇÃO. */
770
+ const base = nome.split(':')[1];
771
+ let escolhido = base;
772
+ const alts = edicao.ALTERNATIVAS[base];
773
+ if (alts) {
774
+ const ordem = variedade.ordenar([base, ...alts].map((url) => ({ url })), 'icone-nome').map((x) => x.url);
775
+ for (const n of ordem) {
776
+ if (n === base) { escolhido = base; break; }
777
+ try { const r = await assets.icone('fluent-3d:' + n); if (r && r.ok) { escolhido = n; break; } } catch { /* tenta o próximo */ }
778
+ }
779
+ variedade.marcar('icone-nome', escolhido);
756
780
  }
781
+ let novo = 'fluent-3d:' + escolhido;
782
+ if (colecao !== 'fluent-3d') {
783
+ try { const r = await assets.icone(colecao + ':' + escolhido); if (r && r.ok) novo = colecao + ':' + escolhido; } catch { /* fica o 3D */ }
784
+ }
785
+ if (novo !== nome) troca[nome] = novo;
757
786
  }
758
787
  const n = variedade.trocarIcones(plano, troca);
759
788
  if (n || colecao === 'fluent-3d') variedade.marcar('icones', colecao);
@@ -1008,16 +1037,13 @@ async function prepararRoteiro(p, args) {
1008
1037
  IMPACTO. O que o agente pediu vale mais que tudo. */
1009
1038
  const pedidoTxt = `${p.pedido || ''} ${args.pedido || ''}`;
1010
1039
  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;
1040
+ /* Sem imagem é o padrão (07/10): sem o pedido dizer, nada de foto nem
1041
+ vídeo de fora. Ninguém escolheu o estilo? Ele gira (estilo.js) — sem
1042
+ imagem, só entre os que não vivem de foto. O híbrido e o cinema, pedidos
1043
+ pelo nome sem imagem, caem para o gerativo (sem nada baixado). */
1044
+ const semImagem = referencias.semImagem(ref);
1045
+ let estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo, girar: true, semImagem });
1046
+ if (semImagem && (estilo === 'hibrido' || estilo === 'cinema')) estilo = 'gerativo';
1021
1047
  /* "O vídeo deve ter um 3D do fone" (30/09): pedido de 3D vira o gerativo
1022
1048
  com o produto modelado girando numa batida (kit/tres.jsx) — a menos que
1023
1049
  o agente tenha escolhido outro estilo pelo nome. */
@@ -1046,8 +1072,8 @@ async function prepararRoteiro(p, args) {
1046
1072
  todos iguais" (02/10). A marca manda; depois o roteiro.visual da IA;
1047
1073
  sem nada, o sorteio que pula o que os últimos vídeos usaram. */
1048
1074
  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(', '));
1075
+ const v = biblioteca.vestir(roteiro, { estilo });
1076
+ if (v.mudou) projeto.anotar(p.id, 'visual', [v.titulo && `fonte ${v.titulo} + ${v.texto} (tom ${v.tom})`, v.paleta && `paleta ${v.paleta}`].filter(Boolean).join(', '));
1051
1077
  }
1052
1078
  // Os estilos de motion usam os efeitos leves e a trilha do ende.app; os de corte, os cheios.
1053
1079
  const ehMotion = LEVES.has(estilo);
@@ -1079,13 +1105,13 @@ async function prepararRoteiro(p, args) {
1079
1105
  const videosMarca = [...(acervo.videos || [])];
1080
1106
  const imagens = {}, fundos = {};
1081
1107
  for (const [i, parte] of partes.entries()) {
1082
- if (parte.tipo !== 'imagem' || parte.src) continue;
1108
+ if (semImagem || parte.tipo !== 'imagem' || parte.src) continue;
1083
1109
  const texto = `${parte.titulo || ''} ${parte.sub || ''} ${parte.fala || ''} ${parte.busca || ''}`;
1084
1110
  if (modo === 'video' && videosMarca.length && i % 2 === 1) { imagens[i] = { src: videosMarca.shift().src, tipo: 'video' }; continue; }
1085
1111
  const m = melhorFoto(texto) || (parte.busca ? await baixarApoio(p, parte.busca, { orientacao: largura >= altura ? 'landscape' : 'portrait', video: modo === 'video' }) : null);
1086
1112
  if (m) imagens[i] = m;
1087
1113
  }
1088
- if (acervo.fotos.length) {
1114
+ if (acervo.fotos.length && !semImagem) {
1089
1115
  const comFundo = ['abertura', 'titulo', 'contraste', 'citacao', 'cta', 'passos'];
1090
1116
  let alterna = 0;
1091
1117
  for (const [i, parte] of partes.entries()) {
@@ -1408,9 +1434,8 @@ async function prepararRoteiro(p, args) {
1408
1434
  funk para o fone, eletrônica para IA…), sorteia a faixa e não
1409
1435
  repete as últimas usadas — nada de "inspiring corporate". */
1410
1436
  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 = []; }
1437
+ // A memória da trilha é a da variedade ('trilha'): antes era um arquivo à parte (trilhas-usadas.json).
1438
+ const usadas = variedade.usadas('trilha');
1414
1439
  const mus = estilo === 'gerativo' ? gerativo.musicaDe(roteiro) : null;
1415
1440
  /* A trilha do gerativo vem do Jamendo (Openverse), pelo GÊNERO do
1416
1441
  assunto, instrumental, sorteada e sem repetir as últimas — a
@@ -1421,9 +1446,10 @@ async function prepararRoteiro(p, args) {
1421
1446
  classical: 'piano classical instrumental', 'soul-rnb': 'soul instrumental' };
1422
1447
  try {
1423
1448
  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 || ''));
1449
+ // Pelos pontos da variedade: a recente e a muito usada vão para o fim, não somem.
1450
+ const opcoes = variedade.ordenar((b.ok ? b.sons : []).filter((x) => !/vocal|voice|feat|ft\.|remix/i.test(x.titulo || '')), 'trilha');
1425
1451
  for (let k = 0; k < opcoes.length && !m; k++) {
1426
- const item = opcoes[(mus.semente + k) % opcoes.length];
1452
+ const item = opcoes[k];
1427
1453
  const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha-' + (roteiro.genero || mus.genero) });
1428
1454
  if (!d.ok) continue;
1429
1455
  if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
@@ -1432,12 +1458,12 @@ async function prepararRoteiro(p, args) {
1432
1458
  } catch { /* cai na ende.app */ }
1433
1459
  }
1434
1460
  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 */ }
1461
+ variedade.marcar('trilha', m.id);
1436
1462
  projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero} (Jamendo)`);
1437
1463
  } else if (mus) {
1438
1464
  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
1465
  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 */ }
1466
+ if (id) variedade.marcar('trilha', id);
1441
1467
  if (m) projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero}`);
1442
1468
  } else m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
1443
1469
  if (m) { trilha = path.relative(p.pasta, m.src).replace(/\\/g, '/'); projeto.anotar(p.id, 'trilha', m.credito); }
@@ -1512,8 +1538,48 @@ async function prepararRoteiro(p, args) {
1512
1538
  return r;
1513
1539
  }
1514
1540
  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`);
1541
+ /* A CARA de cada vídeo do fluido, por PROJETO (ajustar uma frase não
1542
+ troca): a semente (marcador, jeito das imagens) e a paleta, que a
1543
+ memória de variedade não repete do vídeo anterior (08/10: "sempre o
1544
+ mesmo azul"). A paleta ou o tema pedidos mandam. */
1545
+ if (roteiro.semente == null) {
1546
+ roteiro.semente = p.semente != null ? p.semente : Math.floor(Math.random() * 1e9);
1547
+ if (p.semente == null) projeto.salvar(p.id, { semente: roteiro.semente });
1548
+ }
1549
+ /* O modelo também vicia: pedia "noite" em todo vídeo (era a primeira da
1550
+ lista). A paleta dele vale, menos quando é uma das 2 últimas usadas —
1551
+ aí vem a menos usada do mesmo tema (escura ou clara). */
1552
+ if (!p.paletaFluido) {
1553
+ const P = fluido.PALETAS, nomes = Object.keys(P);
1554
+ const pedida = P[String(roteiro.paleta || '').toLowerCase()] ? String(roteiro.paleta).toLowerCase() : null;
1555
+ const recentes = variedade.usadas('paleta-fluido').slice(0, 2);
1556
+ const escuro = pedida ? P[pedida].escuro : /escuro|dark/i.test(roteiro.tema || '') ? true : /claro|light/i.test(roteiro.tema || '') ? false : null;
1557
+ let escolha = pedida && !recentes.includes(pedida) ? pedida : null;
1558
+ if (!escolha) escolha = variedade.sortear(nomes.filter((n) => escuro == null || P[n].escuro === escuro), 'paleta-fluido');
1559
+ else variedade.marcar('paleta-fluido', escolha);
1560
+ if (pedida && escolha !== pedida) projeto.anotar(p.id, 'visual', `paleta ${pedida} pedida, mas foi a de um dos últimos vídeos: ${escolha}`);
1561
+ projeto.salvar(p.id, { paletaFluido: escolha });
1562
+ p.paletaFluido = escolha;
1563
+ }
1564
+ roteiro.paleta = p.paletaFluido;
1565
+ /* As IMAGENS do fluido misto, só com imagem pedida: as partes com
1566
+ "busca", no máximo 1 em 3 e espalhadas (fluido.js decide o jeito). */
1567
+ const fotosFluido = {};
1568
+ if (!semImagem) {
1569
+ const comBusca = partes.map((pt, k) => (pt.busca || pt.src ? k : -1)).filter((k) => k >= 0 && (partes[k].tipo !== 'cta'));
1570
+ const teto = Math.max(1, Math.floor(partes.length / 3));
1571
+ const escolhidas = [];
1572
+ for (const k of comBusca) if (escolhidas.length < teto && !escolhidas.some((j) => Math.abs(j - k) < 2)) escolhidas.push(k);
1573
+ for (const k of escolhidas) {
1574
+ const pt = partes[k];
1575
+ // O acervo EDITORIAL (assets.procurarEditorial), não o banco de stock: sem foto que sirva, a parte fica sem imagem.
1576
+ const m = pt.src ? { src: pt.src } : await baixarEditorial(p, pt.busca);
1577
+ if (m && m.src) fotosFluido[k] = { src: m.src };
1578
+ }
1579
+ if (Object.keys(fotosFluido).length) projeto.anotar(p.id, 'midia', `fluido misto: imagem nas partes ${Object.keys(fotosFluido).map((k) => Number(k) + 1).join(', ')}`);
1580
+ }
1581
+ const r = fluido.montarFluido({ imagens: fotosFluido, roteiro: { ...roteiro, pedido: roteiro.pedido || pedidoTxt }, largura, altura, tempos, narracao, sons, trilha });
1582
+ if (r.ok) projeto.anotar(p.id, 'plano', `estilo fluido: ${r.resumo.batidas} batidas${r.resumo.imagens.length ? ` (imagens: ${r.resumo.imagens.join(', ')})` : ''}, 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
1583
  return r;
1518
1584
  }
1519
1585
  if (estilo === 'motion' && modo === 'video') {
@@ -1600,10 +1666,34 @@ async function ferramentaRender(args, ctx = {}) {
1600
1666
  problemas: conferida.problemas.slice(0, 12) };
1601
1667
  }
1602
1668
 
1669
+ /* A REVISÃO (revisor.js): o plano roda; agora, ele está bom? Conserta a
1670
+ receita (transição repetida, câmera sem respiro, som duplicado) e
1671
+ devolve o resto como aviso para quem fez o plano. */
1672
+ const revisao = revisor.revisar(pl, { semImagem: referencias.semImagem() });
1673
+ if (revisao.consertos.length) projeto.anotar(args.id, 'revisao', revisao.consertos.join('; '));
1674
+ if (revisao.avisos.length) projeto.anotar(args.id, 'revisao', 'avisos: ' + revisao.avisos.join('; '));
1675
+
1603
1676
  // Os ícones vêm ANTES do render: um render que busca ícone na internet
1604
1677
  // falha no meio, depois de vinte minutos.
1605
1678
  const { mapa, faltaram } = await assets.iconesDoPlano(pl, p.pasta);
1606
1679
 
1680
+ /* A MÚSICA NO CORTE (batida.js): a faixa começa no ponto em que as
1681
+ batidas caem nos cortes do plano, e o corte de impacto num acento. Só
1682
+ quando ninguém disse o início (audio.trilha.inicio). */
1683
+ const tr = pl.audio && pl.audio.trilha;
1684
+ if (tr && !(typeof tr === 'object' && tr.inicio != null) && !(p.formato === 'slides' || p.formato === 'arte' || args.paginas)) {
1685
+ try {
1686
+ const src = path.join(p.pasta, tr.src || tr);
1687
+ const analise = await batida.analisar(src, { ffmpeg: video.ffmpeg() || 'ffmpeg' });
1688
+ if (analise && analise.batidas.length) {
1689
+ const duracao = (pl.cenas || []).reduce((n, c) => n + (Number(c.duracao) || 0), 0);
1690
+ const r = batida.melhorInicio(batida.cortesDoPlano(pl), analise, { maximo: Math.min(20, analise.segundos - duracao - 0.5) });
1691
+ pl.audio.trilha = { ...(typeof tr === 'object' ? tr : { src: tr }), inicio: r.inicio };
1692
+ projeto.anotar(args.id, 'trilha', `${analise.bpm} BPM; a faixa entra em ${r.inicio}s e ${r.alinhados} de ${r.total} cortes caem na batida`);
1693
+ }
1694
+ } catch { /* sem análise, a faixa entra do começo */ }
1695
+ }
1696
+
1607
1697
  const montado = remotion.montar({ pasta: p.pasta, titulo: p.titulo, plano: pl, icones: mapa });
1608
1698
 
1609
1699
  if (args.somenteProjeto) {
@@ -1669,7 +1759,9 @@ async function ferramentaRender(args, ctx = {}) {
1669
1759
  return {
1670
1760
  ok: true, arquivo: saida, bytes, segundos: conferida.duracao,
1671
1761
  faltaram: faltaram.length ? faltaram : undefined,
1672
- aviso: 'ABERTO para o usuário assistir. Chame finish dizendo o que foi feito.',
1762
+ revisao: revisao.consertos.length || revisao.avisos.length ? { consertado: revisao.consertos, avisos: revisao.avisos } : undefined,
1763
+ aviso: 'ABERTO para o usuário assistir. Chame finish dizendo o que foi feito'
1764
+ + (revisao.avisos.length ? ' — e cite os avisos da revisão que valem um novo plano.' : '.'),
1673
1765
  };
1674
1766
  }
1675
1767
 
@@ -1803,7 +1895,7 @@ async function ferramentaSite(args) {
1803
1895
  return r;
1804
1896
  }
1805
1897
 
1806
- module.exports = {
1898
+ module.exports = { variarIcones,
1807
1899
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
1808
1900
  ferramentaVideo, ferramentaRender, ferramentaSite,
1809
1901
  projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro,