primocode 9.8.0-beta.9 → 9.9.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.
@@ -0,0 +1,475 @@
1
+ /**
2
+ * edicao.js — a gravação da pessoa vira um vídeo editado, SEM o modelo
3
+ * escrever cena por cena.
4
+ *
5
+ * "Deixe iguais aqueles vídeos que as videomakers editam: aquelas legendas
6
+ * maravilhosas que fazem parte do vídeo, alguns ícones aparecendo, cortes
7
+ * naturais, mais animações… Quero que até uma batata faça vídeos assim."
8
+ * (25/09/2026)
9
+ *
10
+ * Com o Claude o vídeo saiu ótimo porque o modelo escreveu, à mão, cada
11
+ * legenda, cada palavra em destaque e cada zoom. Um modelo grátis não faz
12
+ * isso. Então a edição mora AQUI, em código: com as palavras e o tempo de
13
+ * cada uma, `montarEdicao` devolve o plano inteiro —
14
+ *
15
+ * - uma cena por frase, com o corte seco entre elas e o zoom alternando
16
+ * (o "punch-in" que dá ritmo a quem fala para a câmera);
17
+ * - a legenda palavra por palavra, com a palavra dita em destaque e a
18
+ * palavra forte da frase na cor dela;
19
+ * - o ícone (emoji colorido) que salta na hora em que a palavra é dita;
20
+ * - o letreiro grande da ideia, nas frases sem ícone;
21
+ * - o pop e o whoosh nos momentos certos, e a barra de progresso.
22
+ *
23
+ * É uma função pura: entra palavra, sai plano. O modelo só escolhe o estilo,
24
+ * e pode ajustar qualquer cena depois com estudio_plano.
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ // ── as palavras que não carregam ideia ───────────────────────────────────
30
+ const VAZIAS = new Set((
31
+ '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 '
32
+ + 'ou mas como mais menos muito muita muitos muitas isso isto esse essa este esta aquele aquela ele ela '
33
+ + 'eles elas eu voce voces nos meu minha seu sua nosso nossa tem ter ser foi era sao esta estao vai vou '
34
+ + 'ja ai aqui la entao tambem so quando onde porque pois ate sobre depois antes cada todo toda todos '
35
+ + 'todas qual quais quem nao sim ne ta tipo coisa coisas gente fazer faz feito pode podem vamos hoje '
36
+ // Verbos de ligação e de fala: não são a ideia da frase.
37
+ + 'mostra mostrar ajuda ajudar deixa deixar usar usa usando sabe saber quer querer precisa precisar '
38
+ + 'acho olha vem fica ficar falar fala dizer diz ver vai ir estou estava sendo tendo existe chamado'
39
+ ).split(' '));
40
+
41
+ /* A palavra (sem acento, minúscula, começo dela) → o emoji colorido. Casa
42
+ pelo começo para "aprender", "aprende" e "aprendizado" caírem no mesmo. Os
43
+ nomes foram conferidos na coleção fluent-emoji-flat do Iconify (25/09). */
44
+ const ICONES = [
45
+ [['inteligencia', 'ia', 'robo', 'maquina', 'automac'], 'robot'],
46
+ [['cerebro', 'pens', 'raciocin', 'aprend', 'mente'], 'brain'],
47
+ [['ideia', 'criativ', 'inova'], 'light-bulb'],
48
+ [['dinheiro', 'grana', 'reais', 'lucro', 'vend', 'fatur', 'ganh', 'pagar', 'preco', 'financ', 'custo', 'econom', 'receita'], 'money-bag'],
49
+ [['tempo', 'minuto', 'segundo', 'rapid', 'hora'], 'stopwatch'],
50
+ [['cresc', 'result', 'aument', 'melhor'], 'chart-increasing'],
51
+ [['cair', 'caiu', 'perd', 'queda', 'pior'], 'chart-decreasing'],
52
+ [['dados', 'numero', 'grafico', 'estatist', 'parametro'], 'bar-chart'],
53
+ [['foguete', 'lanc', 'decol', 'comec'], 'rocket'],
54
+ [['incrivel', 'fogo', 'bomba'], 'fire'],
55
+ [['certo', 'funcion', 'corret', 'pronto'], 'check-mark-button'],
56
+ [['errado', 'erro', 'falha', 'proibid'], 'cross-mark'],
57
+ [['celular', 'telefone', 'aplicativo', 'app'], 'mobile-phone'],
58
+ [['computador', 'codigo', 'program', 'software', 'site'], 'laptop'],
59
+ [['internet', 'mundo', 'global'], 'globe-showing-americas'],
60
+ [['olh', 'ver', 'imagem', 'imagens', 'visual', 'reconhec'], 'eyes'],
61
+ [['voz', 'som', 'sons', 'audio', 'ouv'], 'speaker-high-volume'],
62
+ [['texto', 'escrev', 'palavra', 'lista'], 'memo'],
63
+ [['musica', 'cancao'], 'musical-notes'],
64
+ [['cachorro', 'cao'], 'dog-face'],
65
+ [['gato'], 'cat-face'],
66
+ [['coracao', 'amor', 'ama', 'paixao'], 'red-heart'],
67
+ [['pessoa', 'cliente', 'usuario', 'equipe', 'publico'], 'busts-in-silhouette'],
68
+ [['casa', 'lar'], 'house'],
69
+ [['carro', 'dirig'], 'automobile'],
70
+ [['livro', 'estud', 'escola', 'ensin', 'aula', 'curso'], 'books'],
71
+ [['atencao', 'cuidado', 'perigo', 'risco'], 'warning'],
72
+ [['objeto', 'produto', 'pacote', 'entrega'], 'package'],
73
+ [['padr', 'peca', 'encaix'], 'puzzle-piece'],
74
+ [['decid', 'decis', 'escolh', 'duvida', 'pergunta'], 'thinking-face'],
75
+ [['exemplo', 'procur', 'busca', 'pesquis', 'analis'], 'magnifying-glass-tilted-left'],
76
+ [['assistente', 'convers', 'chat', 'mensag', 'responde', 'atend', 'suporte', 'whatsapp'], 'speech-balloon'],
77
+ [['recomend', 'filme', 'serie', 'netflix', 'video'], 'clapper-board'],
78
+ [['tecnolog', 'algoritm', 'sistema', 'ferrament', 'operac', 'process', 'automat'], 'gear'],
79
+ [['cotidian', 'rotina', 'semana', 'calendario', 'agenda'], 'calendar'],
80
+ [['conhecimento', 'formad', 'diploma', 'sabe'], 'graduation-cap'],
81
+ [['sucesso', 'venc', 'ganhador', 'premio', 'campeao'], 'trophy'],
82
+ [['forca', 'forte', 'poder', 'energia'], 'flexed-biceps'],
83
+ [['acordo', 'parceri', 'junt', 'ajud'], 'handshake'],
84
+ [['compra', 'loja', 'carrinho'], 'shopping-cart'],
85
+ [['trabalh', 'empresa', 'negocio', 'emprego'], 'briefcase'],
86
+ [['seguranc', 'senha', 'proteg', 'privad'], 'locked'],
87
+ [['festa', 'comemor', 'parabens'], 'party-popper'],
88
+ [['surpre', 'chocad', 'absurd'], 'exploding-head'],
89
+ ];
90
+
91
+ /* O termo de busca da IMAGEM DE APOIO, pelo emoji que a palavra ganhou. Em
92
+ inglês de propósito: os acervos abertos respondem muito melhor assim. */
93
+ const FOTO = {
94
+ robot: 'humanoid robot', brain: 'human brain', 'light-bulb': 'light bulb idea', 'money-bag': 'money cash',
95
+ stopwatch: 'clock time', 'chart-increasing': 'business growth', 'chart-decreasing': 'stock market fall',
96
+ 'bar-chart': 'data analytics', rocket: 'rocket launch', fire: 'fire flames', 'mobile-phone': 'smartphone hand',
97
+ laptop: 'laptop programming', 'globe-showing-americas': 'earth from space', eyes: 'eye closeup',
98
+ 'speaker-high-volume': 'sound waves', memo: 'writing notebook', 'musical-notes': 'music concert',
99
+ 'dog-face': 'puppy', 'cat-face': 'cat', 'red-heart': 'heart love', 'busts-in-silhouette': 'people crowd',
100
+ house: 'modern house', automobile: 'car road', books: 'library books', warning: 'warning sign',
101
+ package: 'objects on table', 'puzzle-piece': 'puzzle pieces', 'thinking-face': 'person thinking',
102
+ 'magnifying-glass-tilted-left': 'magnifying glass', 'speech-balloon': 'smart speaker', 'clapper-board': 'watching tv',
103
+ gear: 'circuit board', calendar: 'city street people', 'graduation-cap': 'students classroom', trophy: 'trophy',
104
+ 'flexed-biceps': 'strength', handshake: 'handshake', 'shopping-cart': 'shopping', briefcase: 'office work',
105
+ locked: 'cyber security', 'party-popper': 'party celebration', 'exploding-head': 'surprised face',
106
+ };
107
+
108
+ function limpa(palavra) {
109
+ return String(palavra || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
110
+ .toLowerCase().replace(/[^a-z0-9]/g, '');
111
+ }
112
+
113
+ /** O emoji desta palavra, ou null. Palavra de 2 letras só casa inteira ("ia"). */
114
+ function iconeDe(palavra) {
115
+ const w = limpa(palavra);
116
+ if (!w || VAZIAS.has(w)) return null;
117
+ for (const [raizes, nome] of ICONES) {
118
+ for (const r of raizes) {
119
+ if (r.length <= 3 ? w === r : w.startsWith(r)) return 'fluent-3d:' + nome;
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /** Quanto a palavra carrega a ideia da frase. 0 = nada. */
126
+ function peso(palavra) {
127
+ const w = limpa(palavra);
128
+ if (!w || VAZIAS.has(w)) return 0;
129
+ if (/^\d+$/.test(w)) return 20;
130
+ return w.length + (iconeDe(palavra) ? 6 : 0);
131
+ }
132
+
133
+ /** O termo de busca da imagem de apoio para esta palavra, ou null. */
134
+ function termoDeApoio(palavra) {
135
+ const ic = iconeDe(palavra);
136
+ return ic ? FOTO[ic.split(':')[1]] || null : null;
137
+ }
138
+
139
+ // Em quais cenas entra a mídia de apoio: uma a cada duas, a partir da segunda.
140
+ const temApoio = (i) => i % 2 === 1;
141
+
142
+ function forteDe(g) {
143
+ let forte = null;
144
+ for (const p of g) if (peso(p.texto) > (forte ? peso(forte.texto) : 0)) forte = p;
145
+ return forte;
146
+ }
147
+
148
+ /* O B-ROLL EM QUATRO ROTAS (Arthur, do b-roll-finder, 28/09): a frase diz qual
149
+ imagem pede, na ordem abaixo. A primeira que casa decide.
150
+ marca a frase diz o nome da marca → a logo / o site da marca
151
+ numero a frase tem número → sem foto: o número já é a imagem
152
+ concreto uma palavra tem foto no acervo → a foto de estoque dela
153
+ pessoa ideia abstrata → fica no rosto de quem fala
154
+ `palavra` é onde o corte se ancora: ele entra 0,2 s DEPOIS dela. */
155
+ const DEPOIS_DA_PALAVRA = 0.2;
156
+
157
+ function rotaDeApoio(g, { marca = null } = {}) {
158
+ const ws = g || [];
159
+ const nomes = (Array.isArray(marca) ? marca : [marca]).filter(Boolean).map((m) => String(m).split(/\s+/).map(limpa));
160
+ for (const nome of nomes) {
161
+ const k = ws.findIndex((_, i) => nome.every((n, j) => ws[i + j] && limpa(ws[i + j].texto) === n));
162
+ if (k >= 0) return { rota: 'marca', termo: null, palavra: ws[k] };
163
+ }
164
+ const num = ws.find((p) => /\d/.test(p.texto));
165
+ if (num) return { rota: 'numero', termo: null, palavra: num };
166
+ let conc = null;
167
+ for (const p of ws) if (termoDeApoio(p.texto) && (!conc || peso(p.texto) > peso(conc.texto))) conc = p;
168
+ if (conc) return { rota: 'concreto', termo: termoDeApoio(conc.texto), palavra: conc };
169
+ return { rota: 'pessoa', termo: null, palavra: null };
170
+ }
171
+
172
+ /** Os termos das imagens de apoio que o plano vai pedir — para baixar antes. */
173
+ function termosDeApoio(palavras, opcoes) {
174
+ const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
175
+ const termos = [];
176
+ frases(lista).forEach((g, i) => {
177
+ const t = temApoio(i) ? rotaDeApoio(g, opcoes).termo : null;
178
+ if (t && !termos.includes(t)) termos.push(t);
179
+ });
180
+ return termos;
181
+ }
182
+
183
+ const fimDeFrase = (texto) => /[.!?…]$/.test(String(texto || '').trim());
184
+ const r2 = (n) => Math.round(n * 100) / 100;
185
+
186
+ /* As frases: corta no ponto final, na pausa longa ou quando a cena passa do
187
+ teto. Frase curta demais gruda na próxima — corte a cada meio segundo é
188
+ epilepsia, não ritmo. */
189
+ function frases(palavras, { teto = 5.5, pausa = 0.5, minimo = 1.4 } = {}) {
190
+ const grupos = [];
191
+ let atual = [];
192
+ palavras.forEach((p, i) => {
193
+ atual.push(p);
194
+ const prox = palavras[i + 1];
195
+ const dur = p.ate - atual[0].de;
196
+ if (!prox || fimDeFrase(p.texto) || prox.de - p.ate >= pausa || dur >= teto) {
197
+ grupos.push(atual);
198
+ atual = [];
199
+ }
200
+ });
201
+ const juntos = [];
202
+ for (const g of grupos) {
203
+ const ult = juntos[juntos.length - 1];
204
+ const durG = g[g.length - 1].ate - g[0].de;
205
+ if (ult && (durG < minimo || ult[ult.length - 1].ate - ult[0].de < minimo)
206
+ && g[g.length - 1].ate - ult[0].de <= teto + 1.5) ult.push(...g);
207
+ else juntos.push([...g]);
208
+ }
209
+ return juntos;
210
+ }
211
+
212
+ /* Os pedaços da legenda: poucas palavras por vez (quem assiste no celular lê
213
+ em blocos curtos), quebrando na pontuação e na pausa. */
214
+ function blocos(palavras, maximo) {
215
+ const out = [];
216
+ let atual = [];
217
+ palavras.forEach((p, i) => {
218
+ atual.push(p);
219
+ const prox = palavras[i + 1];
220
+ if (!prox || atual.length >= maximo || /[,.!?;:…]$/.test(p.texto) || prox.de - p.ate > 0.3) {
221
+ out.push(atual);
222
+ atual = [];
223
+ }
224
+ });
225
+ return out;
226
+ }
227
+
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
+ const ESTILOS = {
237
+ viral: { icones: true, letreiro: true, sons: true, barra: true, apoio: true, recorte: true, fundoRecorte: ['#1B1F3B', '#3A1C71'], destaque: '#FFD400', forte: '#00E0A4' },
238
+ limpo: { icones: false, letreiro: false, sons: false, barra: false, apoio: true, recorte: false, fundoRecorte: ['#111111', '#333333'], destaque: '#FFFFFF', forte: '#FFD400' },
239
+ };
240
+
241
+ /**
242
+ * O plano do vídeo editado.
243
+ *
244
+ * @param {object} op
245
+ * @param {string} op.src o vídeo (já sem os silêncios), relativo à pasta do projeto
246
+ * @param {number} op.duracao a duração dele, em segundos
247
+ * @param {Array} op.palavras [{de, ate, texto}] no tempo DESTE vídeo
248
+ * @param {number} op.largura do vídeo de origem
249
+ * @param {number} op.altura
250
+ * @param {string} op.estilo 'viral' (padrão) | 'limpo'
251
+ * @param {object} op.sons { pop, whoosh } — caminhos na pasta do projeto, opcionais
252
+ * @param {Set} op.iconesOk os nomes de ícone que existem de verdade (sem isso, todos valem)
253
+ */
254
+ // Em quais cenas a pessoa é recortada, com a palavra gigante atrás dela.
255
+ const temRecorte = (i) => i % 4 === 2;
256
+
257
+ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null, imagens = {}, trilha = null, recortes = {}, marca = null }) {
258
+ const e = ESTILOS[estilo] || ESTILOS.viral;
259
+ const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
260
+ if (!lista.length) return { ok: false, error: 'a gravação não tem fala transcrita para editar.' };
261
+
262
+ const deitado = largura > altura;
263
+ const base = Math.min(largura, altura);
264
+ const grupos = frases(lista);
265
+ const total = duracao || lista[lista.length - 1].ate + 0.3;
266
+ const cenas = [];
267
+ const efeitos = [];
268
+ const infos = []; // onde cada cena começa e a palavra forte dela — para o recorte
269
+ let lado = 0;
270
+
271
+ grupos.forEach((g, i) => {
272
+ const ini = i === 0 ? 0 : grupos[i - 1][grupos[i - 1].length - 1].ate
273
+ + (g[0].de - grupos[i - 1][grupos[i - 1].length - 1].ate) / 2;
274
+ const fim = i === grupos.length - 1 ? total : g[g.length - 1].ate + (grupos[i + 1][0].de - g[g.length - 1].ate) / 2;
275
+ const dur = r2(Math.max(0.4, fim - ini));
276
+ const local = (t) => r2(Math.max(0, t - ini));
277
+
278
+ // A palavra forte da frase: a que mais carrega a ideia.
279
+ const forte = forteDe(g);
280
+
281
+ const elementos = [{
282
+ tipo: 'video', src, x: 0, y: 0, largura: '100%', altura: '100%',
283
+ deOrigem: r2(ini), ateOrigem: r2(ini + dur), volume: 1,
284
+ }];
285
+
286
+ infos.push({ ini: r2(ini), dur, forte: forte ? { de: local(forte.de), texto: forte.texto, peso: peso(forte.texto) } : null });
287
+
288
+ /* A PALAVRA ATRÁS DA PESSOA: o fundo do quarto some, entra a cor da
289
+ marca, a palavra forte aparece enorme e a pessoa, recortada, fica
290
+ na frente dela. É o efeito que mais denuncia edição de videomaker. */
291
+ const rec = e.recorte && temRecorte(i) ? recortes[i] : null;
292
+ if (rec && forte) {
293
+ const vida = r2(rec.ate - rec.de);
294
+ const some = [{ t: 0, v: 0 }, { t: 0.1, v: 1 }, { t: vida - 0.12, v: 1 }, { t: vida, v: 0 }];
295
+ elementos.push({
296
+ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', de: rec.de, ate: rec.ate,
297
+ fundo: { tipo: 'gradiente', cores: [e.fundoRecorte[0], e.fundoRecorte[1]], angulo: 160 }, anima: { opacidade: some },
298
+ });
299
+ const palavra = limpaPontuacao(forte.texto).toUpperCase();
300
+ const fit = Math.round(Math.min(altura * 0.42, (largura * 0.92) / Math.max(3, palavra.length * 0.46)));
301
+ elementos.push({
302
+ tipo: 'texto', texto: palavra, fonte: 'Anton', peso: 400, tamanho: fit, cor: e.forte,
303
+ x: '50%', y: deitado ? '38%' : '34%', ancora: 'centro', de: rec.de, ate: rec.ate,
304
+ anima: { escala: [{ t: 0, v: 0.6 }, { t: 0.22, v: 1.06, e: 'saida' }, { t: 0.4, v: 1, e: 'suave' }, { t: vida, v: 1.05 }], opacidade: some },
305
+ });
306
+ elementos.push({
307
+ tipo: 'video', src: rec.src, volume: 0, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
308
+ de: rec.de, ate: rec.ate, anima: { opacidade: some },
309
+ });
310
+ if (e.sons && sons.whoosh) efeitos.push({ t: r2(Math.max(0, ini + rec.de - 0.1)), src: sons.whoosh, volume: 0.55 });
311
+ }
312
+
313
+ /* A IMAGEM DE APOIO: na hora da palavra forte, a tela vira a imagem do
314
+ que está sendo dito, com um zoom lento, e a legenda continua por
315
+ cima. É o "b-roll" das edições de videomaker. */
316
+ const r = e.apoio && temApoio(i) && !rec ? rotaDeApoio(g, { marca }) : null;
317
+ const apoio = !r ? null : r.rota === 'concreto' ? imagens[r.termo] : r.rota === 'marca' ? imagens.marca : null;
318
+ if (apoio) {
319
+ // O corte entra DEPOIS da palavra, nunca antes: quem ouve "cachorro" vê o cachorro.
320
+ const de = local(r.palavra.de + DEPOIS_DA_PALAVRA);
321
+ const vida = Math.min(2.2, dur - de);
322
+ if (vida > 0.8) {
323
+ // O apoio é foto ou vídeo (o Pexels dá os dois; vídeo é melhor).
324
+ const ehVideo = typeof apoio === 'object' && apoio.tipo === 'video';
325
+ elementos.push({
326
+ tipo: ehVideo ? 'video' : 'imagem', src: typeof apoio === 'object' ? apoio.src : apoio,
327
+ ...(ehVideo ? { volume: 0 } : {}),
328
+ x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
329
+ de, ate: r2(de + vida),
330
+ anima: {
331
+ escala: [{ t: 0, v: 1.12 }, { t: vida, v: 1.0, e: 'suave' }],
332
+ opacidade: [{ t: 0, v: 0 }, { t: 0.12, v: 1 }, { t: vida - 0.15, v: 1 }, { t: vida, v: 0 }],
333
+ },
334
+ });
335
+ if (e.sons && sons.whoosh) efeitos.push({ t: r2(Math.max(0, ini + de - 0.1)), src: sons.whoosh, volume: 0.55 });
336
+ }
337
+ }
338
+
339
+ // O ícone salta na hora da palavra; sem ícone, o letreiro da ideia.
340
+ const icone = forte && e.icones && !apoio && !rec ? iconeDe(forte.texto) : null;
341
+ const temIcone = icone && (!iconesOk || iconesOk.has(icone));
342
+ const x = lado++ % 2 === 0 ? '78%' : '22%';
343
+ if (temIcone) {
344
+ const de = local(forte.de - 0.05);
345
+ const vida = Math.min(1.8, dur - de);
346
+ if (vida > 0.5) {
347
+ elementos.push({
348
+ tipo: 'icone', nome: icone, tamanho: Math.round(base * (deitado ? 0.2 : 0.24)),
349
+ x, y: deitado ? '30%' : '26%', ancora: 'centro', de, ate: r2(de + vida),
350
+ sombra: undefined,
351
+ anima: {
352
+ escala: [{ t: 0, v: 0 }, { t: 0.2, v: 1.18, e: 'saida' }, { t: 0.34, v: 1, e: 'suave' }],
353
+ rotacao: [{ t: 0, v: -14 }, { t: 0.34, v: 0, e: 'saida' }, { t: vida, v: 4, e: 'suave' }],
354
+ y: [{ t: 0.34, v: 0 }, { t: vida, v: -14, e: 'suave' }],
355
+ opacidade: [{ t: vida - 0.2, v: 1 }, { t: vida, v: 0 }],
356
+ },
357
+ });
358
+ if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.8 });
359
+ }
360
+ } else if (forte && e.letreiro && !rec && i % 2 === 0 && peso(forte.texto) >= 7) {
361
+ const de = local(forte.de);
362
+ const vida = Math.min(1.6, dur - de);
363
+ if (vida > 0.5) {
364
+ elementos.push({
365
+ tipo: 'texto', texto: limpaPontuacao(forte.texto).toUpperCase(), fonte: 'Anton',
366
+ tamanho: Math.round(base * (deitado ? 0.11 : 0.1)), cor: e.forte, peso: 400,
367
+ x, y: deitado ? '28%' : '24%', ancora: 'centro', de, ate: r2(de + vida),
368
+ anima: {
369
+ escala: [{ t: 0, v: 0.4 }, { t: 0.16, v: 1.12, e: 'saida' }, { t: 0.3, v: 1, e: 'suave' }],
370
+ opacidade: [{ t: 0, v: 0 }, { t: 0.1, v: 1 }, { t: vida - 0.18, v: 1 }, { t: vida, v: 0 }],
371
+ rotacao: [{ t: 0, v: -6 }, { t: 0.3, v: -3, e: 'saida' }],
372
+ },
373
+ });
374
+ if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.7 });
375
+ }
376
+ }
377
+
378
+ // A legenda palavra por palavra.
379
+ const fala = blocos(g, 3).map((b, j, todos) => ({
380
+ de: local(b[0].de - 0.04),
381
+ ate: j < todos.length - 1 ? local(todos[j + 1][0].de - 0.04) : r2(dur),
382
+ texto: b.map((p) => p.texto).join(' '),
383
+ palavras: b.map((p) => ({
384
+ de: local(p.de), ate: local(p.ate), texto: limpaPontuacao(p.texto),
385
+ ...(p === forte ? { forte: true } : {}),
386
+ })),
387
+ }));
388
+ elementos.push({
389
+ tipo: 'legenda', x: '50%', y: deitado ? '76%' : '68%', ancora: 'centro', fala,
390
+ estilo: {
391
+ fonte: 'Montserrat', peso: 900, tamanho: Math.round(base * (deitado ? 0.078 : 0.072)),
392
+ destaque: e.destaque, corForte: e.forte, caixa: 'uppercase', largura: '92%',
393
+ },
394
+ });
395
+
396
+ if (e.barra) {
397
+ elementos.push({
398
+ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, altura: Math.max(6, Math.round(base * 0.008)),
399
+ largura: '100%', cor: e.destaque,
400
+ anima: { escalaX: [{ t: 0, v: r2(ini / total) }, { t: dur, v: r2((ini + dur) / total), e: 'linear' }] },
401
+ origem: 'left',
402
+ });
403
+ }
404
+
405
+ // O whoosh entra no punch-in: é o corte que ele anuncia.
406
+ 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 });
407
+
408
+ cenas.push({ duracao: dur, camera: camera(i, dur), elementos, transicao: { tipo: 'corte' } });
409
+ });
410
+
411
+ return {
412
+ ok: true,
413
+ plano: {
414
+ formato: { largura, altura, fps: 30 },
415
+ fundoGeral: '#000',
416
+ cenas,
417
+ ...(efeitos.length || trilha ? { audio: {
418
+ ...(efeitos.length ? { efeitos } : {}),
419
+ // A música fica BAIXA: quem fala para a câmera fala o vídeo
420
+ // inteiro, e a trilha é cama, não disputa.
421
+ ...(trilha ? { trilha: { src: trilha, volume: 0.07, loop: true } } : {}),
422
+ } } : {}),
423
+ },
424
+ infos,
425
+ resumo: {
426
+ cenas: cenas.length,
427
+ recortes: Object.keys(recortes).length,
428
+ icones: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'icone').length, 0),
429
+ apoios: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'imagem' || (x.tipo === 'video' && x.volume === 0)).length, 0),
430
+ musica: Boolean(trilha),
431
+ letreiros: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'texto').length, 0),
432
+ efeitos: efeitos.length,
433
+ },
434
+ };
435
+ }
436
+
437
+ function limpaPontuacao(t) {
438
+ return String(t || '').replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}%]+$/gu, '');
439
+ }
440
+
441
+ /* Os dois sons, sintetizados pelo ffmpeg: nada baixado, nada com licença de
442
+ ninguém. O pop é um tom que cai rápido; o whoosh, um ruído que sobe e some. */
443
+ const SONS = {
444
+ pop: ['-f', 'lavfi', '-i', "aevalsrc='0.9*sin(2*PI*(1500-7000*t)*t)*exp(-38*t)':s=44100:d=0.14"],
445
+ whoosh: ['-f', 'lavfi', '-i', 'anoisesrc=d=0.55:c=pink:a=0.6',
446
+ '-af', 'highpass=f=500,lowpass=f=5000,afade=t=in:st=0:d=0.3:curve=exp,afade=t=out:st=0.3:d=0.25,volume=0.9'],
447
+ // O impacto: um grave que cai de 60 para 30 Hz, com o estalo do ataque.
448
+ impacto: ['-f', 'lavfi', '-i', "aevalsrc='0.95*sin(2*PI*(62-30*t)*t)*exp(-3.6*t)+0.3*(random(0)*2-1)*exp(-45*t)':s=44100:d=1.3",
449
+ '-af', 'lowpass=f=2500,volume=1.4'],
450
+ // O riser: sobe dois segundos e corta — anuncia o que vem.
451
+ riser: ['-f', 'lavfi', '-i', "aevalsrc='0.4*sin(2*PI*(160+700*t*t)*t)*(t/2)+0.3*(random(0)*2-1)*pow(t/2,2)':s=44100:d=2",
452
+ '-af', 'highpass=f=120,volume=1.2'],
453
+ // O estalo do texto batendo na tela.
454
+ estalo: ['-f', 'lavfi', '-i', "aevalsrc='(random(0)*2-1)*exp(-38*t)*0.9':s=44100:d=0.22",
455
+ '-af', 'highpass=f=900,lowpass=f=9000'],
456
+ };
457
+
458
+ async function prepararSons(pasta, video) {
459
+ const fs = require('fs');
460
+ const path = require('path');
461
+ const sons = {};
462
+ for (const [nome, args] of Object.entries(SONS)) {
463
+ const rel = `midia/sfx-${nome}.mp3`;
464
+ const cheio = path.join(pasta, rel);
465
+ if (!fs.existsSync(cheio)) {
466
+ fs.mkdirSync(path.dirname(cheio), { recursive: true });
467
+ const r = await video.rodar(video.ffmpeg(), ['-y', ...args, '-q:a', '4', cheio], { timeout: 30000 });
468
+ if (!r.ok) continue;
469
+ }
470
+ sons[nome] = rel;
471
+ }
472
+ return sons;
473
+ }
474
+
475
+ module.exports = { montarEdicao, temRecorte, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, rotaDeApoio, DEPOIS_DA_PALAVRA, ESTILOS, ICONES, FOTO };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * ende.js — trilha da ende.app: CC BY 4.0, uso comercial, sem Content ID.
3
+ *
4
+ * "Usar músicas CC BY 4.0 sem Content ID, da ende.app." — Arthur, do
5
+ * /brag (28/09/2026)
6
+ *
7
+ * Por que esta fonte: música do Openverse com licença comercial às vezes está
8
+ * registrada no Content ID, e o vídeo do cliente leva reivindicação no
9
+ * YouTube. A página da ende.app diz: "Important: NO Content ID. [...]
10
+ * Everything is under CC BY 4.0. [...] Commercial use is allowed as well."
11
+ * O crédito é opcional ("Attribution is nice but no longer required"), mas
12
+ * gravamos mesmo assim: custa uma linha e protege o cliente.
13
+ *
14
+ * ── SEM API ──────────────────────────────────────────────────────────────
15
+ * A ende.app não tem API, e a busca por texto (?q=) não filtra: devolve
16
+ * sempre a mesma lista. Então lemos os cartões da listagem, que trazem
17
+ * `musicPlayer(id, 'mp3', duração)`, o título, a página e as tags. A escolha é
18
+ * pelas tags e pela duração. `robots.txt` só fecha /user-api/ e /challenge.
19
+ *
20
+ * Se a página mudar, `lerLista` devolve [] e o provedor devolve null, e a
21
+ * corrente do producao.js cai no próximo provedor (o Openverse). A rede e o
22
+ * disco são injetados: o teste roda com um recorte do HTML real.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ const LISTA = 'https://ende.app/en/search'; // 25 cartões; a /en mostra 11
31
+ const CREDITO = 'Música: Sascha Ende (ende.app), CC BY 4.0';
32
+
33
+ // O clima do roteiro (pt) → palavras que aparecem nas tags da ende.app (en).
34
+ const CLIMAS = {
35
+ emocional: ['emotional', 'acoustic', 'atmospheric', 'piano', 'cinematic', 'ambient', 'calm', 'strings'],
36
+ cinema: ['cinematic', 'epic', 'orchestral', 'atmospheric', 'emotional', 'strings'],
37
+ energetico: ['energetic', 'upbeat', 'beat', 'drums', 'rock', 'arcade', 'breakcore', 'epic'],
38
+ viral: ['upbeat', 'beat', 'energetic', 'pop', 'electronic'],
39
+ impacto: ['epic', 'drums', 'trailer', 'energetic', 'beat'],
40
+ neutro: ['background', 'corporate', 'chilled', 'calm', 'ambient'],
41
+ };
42
+
43
+ const limpar = (s) => String(s || '').replace(/<[^>]*>/g, '').replace(/&amp;/g, '&').replace(/&#0?39;/g, "'").replace(/\s+/g, ' ').trim();
44
+
45
+ /** Os cartões de música da listagem. Página diferente do esperado: []. */
46
+ function lerLista(html) {
47
+ const texto = String(html || '');
48
+ const cortes = [...texto.matchAll(/musicPlayer\(\s*(\d+)\s*,\s*'([^']+\.mp3)'\s*,\s*([\d.]+)/g)];
49
+ return cortes.map((m, k) => {
50
+ const fim = k + 1 < cortes.length ? cortes[k + 1].index : texto.length;
51
+ const cartao = texto.slice(m.index, fim);
52
+ const link = cartao.match(/<a href="([^"]+)" class="ap-title-link">([\s\S]*?)<\/a>/);
53
+ const tags = [...cartao.matchAll(/class="ap-subline-tag">([^<]+)</g)].map((t) => limpar(t[1]).toLowerCase());
54
+ return {
55
+ id: Number(m[1]), mp3: m[2], segundos: Number(m[3]),
56
+ titulo: link ? limpar(link[2]) : null, pagina: link ? link[1] : null, tags,
57
+ };
58
+ }).filter((f) => f.titulo && f.segundos > 0);
59
+ }
60
+
61
+ /** A faixa que cobre o vídeo e mais combina com o clima. Nenhuma cobre: null. */
62
+ function escolher(faixas, { clima = 'neutro', segundos = 0 } = {}) {
63
+ const palavras = CLIMAS[clima] || CLIMAS.neutro;
64
+ const nota = (f) => f.tags.reduce((n, t) => n + palavras.filter((p) => t.includes(p)).length, 0);
65
+ const cabem = (faixas || []).filter((f) => f.segundos >= segundos);
66
+ if (!cabem.length) return null;
67
+ return cabem.reduce((a, b) => (nota(b) > nota(a) ? b : a));
68
+ }
69
+
70
+ async function buscarPadrao(url) {
71
+ const r = await fetch(url, { headers: { 'User-Agent': 'PrimoCode-Estudio' } });
72
+ if (!r.ok) throw new Error(`ende.app respondeu ${r.status}`);
73
+ return r.text();
74
+ }
75
+
76
+ /** Baixa na pasta de mídia e grava o .json da licença ao lado, como o acervo faz. */
77
+ function baixarEm(pasta) {
78
+ return async (url, nome, meta) => {
79
+ const r = await fetch(url);
80
+ if (!r.ok) throw new Error(`download da ende.app respondeu ${r.status}`);
81
+ const destino = path.join(pasta, nome);
82
+ fs.mkdirSync(pasta, { recursive: true });
83
+ fs.writeFileSync(destino, Buffer.from(await r.arrayBuffer()));
84
+ fs.writeFileSync(destino + '.json', JSON.stringify(meta, null, 2));
85
+ return destino;
86
+ };
87
+ }
88
+
89
+ /** O provedor de `musica` para o producao.js. */
90
+ function provedor({ buscar = buscarPadrao, baixar, pasta = 'midia' } = {}) {
91
+ const salvar = baixar || baixarEm(pasta);
92
+ return {
93
+ nome: 'ende', tipo: 'musica',
94
+ async gerar({ clima, segundos = 0 } = {}) {
95
+ const faixa = escolher(lerLista(await buscar(LISTA)), { clima, segundos });
96
+ if (!faixa) return null;
97
+ const meta = {
98
+ fonte: faixa.pagina, titulo: faixa.titulo, autor: 'Sascha Ende',
99
+ licenca: 'CC BY 4.0', contentId: false, credito: CREDITO,
100
+ };
101
+ const src = await salvar(faixa.mp3, `ende-${faixa.id}.mp3`, meta);
102
+ return { src, segundos: faixa.segundos, ...meta };
103
+ },
104
+ };
105
+ }
106
+
107
+ module.exports = { lerLista, escolher, provedor, CLIMAS, CREDITO };