primocode 9.8.0-beta.2 → 9.8.0-beta.20

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.
Files changed (73) hide show
  1. package/README.md +33 -6
  2. package/app/ansi.js +55 -0
  3. package/app/app.css +482 -281
  4. package/app/app.js +888 -315
  5. package/app/index.html +176 -101
  6. package/app/primo-marca.js +58 -1
  7. package/bin/primocode.js +697 -118
  8. package/comandos/explicar.md +14 -0
  9. package/comandos/publicar.md +10 -0
  10. package/comandos/revisar.md +13 -0
  11. package/lib/act.js +130 -26
  12. package/lib/api.js +8 -1
  13. package/lib/app.js +317 -74
  14. package/lib/browser.js +79 -3
  15. package/lib/catalogo.js +11 -3
  16. package/lib/checkpoint-git.js +218 -0
  17. package/lib/checkpoint.js +249 -0
  18. package/lib/claude-engine.js +25 -3
  19. package/lib/comandos-extra.js +481 -0
  20. package/lib/comandos.js +104 -0
  21. package/lib/config.js +21 -4
  22. package/lib/estudio/acervo-site.js +157 -0
  23. package/lib/estudio/assets.js +77 -3
  24. package/lib/estudio/cinema.js +277 -0
  25. package/lib/estudio/composicao.js +470 -0
  26. package/lib/estudio/edicao.js +450 -0
  27. package/lib/estudio/impacto.js +263 -0
  28. package/lib/estudio/index.js +674 -5
  29. package/lib/estudio/plano.js +5 -2
  30. package/lib/estudio/remotion.js +228 -23
  31. package/lib/estudio/slides.js +34 -30
  32. package/lib/fala.js +95 -1
  33. package/lib/local/cerebro.json +361 -180
  34. package/lib/local/entrada.js +18 -8
  35. package/lib/local/motor-cli.js +1 -1
  36. package/lib/mencoes.js +98 -0
  37. package/lib/offline.js +168 -0
  38. package/lib/painel.js +291 -0
  39. package/lib/perigo.js +256 -0
  40. package/lib/pre-leitura.js +118 -0
  41. package/lib/repeticao.js +4 -2
  42. package/lib/skills.js +6 -1
  43. package/lib/tempos.js +62 -0
  44. package/lib/ui.js +171 -17
  45. package/package.json +3 -2
  46. package/skills/apresentacao-pptx/SKILL.md +52 -0
  47. package/skills/cartaz-de-arte/LICENSE.txt +202 -0
  48. package/skills/cartaz-de-arte/SKILL.md +47 -0
  49. package/skills/criar-skill/LICENSE.txt +202 -0
  50. package/skills/criar-skill/SKILL.md +49 -0
  51. package/skills/depurar-de-verdade/LICENSE.txt +21 -0
  52. package/skills/depurar-de-verdade/SKILL.md +14 -5
  53. package/skills/design-de-verdade/LICENSE.txt +177 -0
  54. package/skills/design-de-verdade/SKILL.md +42 -0
  55. package/skills/documento-word/SKILL.md +57 -0
  56. package/skills/interface-revisada/SKILL.md +47 -0
  57. package/skills/me-ensina/LICENSE.txt +21 -0
  58. package/skills/me-ensina/SKILL.md +27 -0
  59. package/skills/me-entrevista/LICENSE.txt +21 -0
  60. package/skills/me-entrevista/SKILL.md +27 -0
  61. package/skills/motion-de-primeira/SKILL.md +1 -1
  62. package/skills/pdf-bonito/SKILL.md +48 -0
  63. package/skills/planilha-excel/SKILL.md +47 -0
  64. package/skills/react-sem-dor/SKILL.md +48 -0
  65. package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
  66. package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
  67. package/skills/site-com-motion/SKILL.md +39 -0
  68. package/skills/temas-prontos/LICENSE.txt +202 -0
  69. package/skills/temas-prontos/SKILL.md +50 -0
  70. package/skills/testar-o-site/LICENSE.txt +202 -0
  71. package/skills/testar-o-site/SKILL.md +46 -0
  72. package/skills/video-no-remotion/SKILL.md +48 -0
  73. package/studio/narrar.py +111 -10
package/lib/config.js CHANGED
@@ -129,15 +129,25 @@ function listProjects() {
129
129
  } catch { return []; }
130
130
  }
131
131
 
132
+ /* Palavras que dizem o TIPO da coisa, não QUAL coisa. "landing da barbearia"
133
+ e "landing-nova" (de outra empresa) dividiam "landing", e o pedido novo caía
134
+ dentro do projeto antigo — medido em 24/09: a landing da Navalha de Ouro foi
135
+ escrita em landing-nova/. Só o assunto conta como parecido. */
136
+ const GENERICAS = new Set(['landing', 'lading', 'page', 'pagina', 'site', 'app', 'aplicativo', 'projeto',
137
+ 'novo', 'nova', 'sistema', 'web', 'html', 'teste', 'versao', 'final', 'the', 'para', 'pra']);
138
+ function tokensDoAssunto(nome) {
139
+ return slugify(nome).split('-').filter((t) => t.length > 2 && !GENERICAS.has(t) && !/^v?\d+$/.test(t));
140
+ }
141
+
132
142
  // Projeto parecido com o que o usuário pediu? Compara os pedaços do slug.
133
143
  // "jogo-de-carro" vs "jogo-carro-2d" -> compartilham jogo+carro -> 0.5
134
144
  function findSimilarProject(projectName, minScore = 0.4) {
135
- const wanted = new Set(slugify(projectName).split('-').filter((t) => t.length > 2));
145
+ const wanted = new Set(tokensDoAssunto(projectName));
136
146
  if (!wanted.size) return null;
137
147
 
138
148
  let best = null;
139
149
  for (const existing of listProjects()) {
140
- const tokens = new Set(existing.split('-').filter((t) => t.length > 2));
150
+ const tokens = new Set(tokensDoAssunto(existing));
141
151
  if (!tokens.size) continue;
142
152
  let shared = 0;
143
153
  for (const t of wanted) if (tokens.has(t)) shared++;
@@ -285,9 +295,15 @@ function loadConfig() {
285
295
  currentProject: file.currentProject || null,
286
296
  userName: file.userName || null,
287
297
  mascot: file.mascot || 'primo',
298
+ // O rostinho do mascote depois da resposta. Ligado por padrão; quem acha
299
+ // que é enfeite demais desliga com /mascot reacoes off.
300
+ reacoes: file.reacoes !== false,
288
301
  theme: file.theme || 'primo',
289
302
  // Modo escolhido dura entre sessões: quem liga o auto quer o auto amanhã.
290
303
  mode: process.env.PRIMOCODE_MODE || file.mode || 'auto',
304
+ // O /permissoes: quando ele pergunta antes de agir. Dura entre sessões
305
+ // pelo mesmo motivo do modo — quem escolheu "perguntar" quer amanhã também.
306
+ permissoes: file.permissoes || 'auto',
291
307
  rootDir: ROOT_DIR,
292
308
  _file: file,
293
309
  };
@@ -297,8 +313,8 @@ function saveConfig(partial) {
297
313
  ensureDir(CONFIG_DIR);
298
314
  const cur = (() => { try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; } })();
299
315
  // Só persiste o que faz sentido guardar — nada de campos derivados.
300
- const { server, model, effort, token, autoApply, currentProject, userName, mascot, theme, mode } = { ...cur, ...partial };
301
- const next = { server, model, effort, token, autoApply, currentProject, userName, mascot, theme, mode };
316
+ const { server, model, effort, token, autoApply, currentProject, userName, mascot, reacoes, theme, mode, permissoes } = { ...cur, ...partial };
317
+ const next = { server, model, effort, token, autoApply, currentProject, userName, mascot, reacoes, theme, mode, permissoes };
302
318
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), 'utf8');
303
319
  return next;
304
320
  }
@@ -338,6 +354,7 @@ function esquecerProjeto() {
338
354
  }
339
355
 
340
356
  module.exports = {
357
+ tokensDoAssunto, GENERICAS,
341
358
  lembrarProjeto, projetoLembrado, esquecerProjeto, PROJETO_ATIVO_FILE,
342
359
  loadConfig, saveConfig, appendHistory,
343
360
  CONFIG_DIR, CONFIG_FILE, HISTORY_FILE, ROOT_DIR, GENERAL_MEMORY,
@@ -0,0 +1,157 @@
1
+ /**
2
+ * acervo-site.js — as fotos, os vídeos e os fatos da PRÓPRIA marca, tirados
3
+ * do site dela.
4
+ *
5
+ * "Eu pedi um vídeo pra Paris Group pra anunciar a imersão: ele poderia
6
+ * puxar imagens das imersões, vídeos, fotos, etc. e colocar no decorrer do
7
+ * vídeo." · "Os roteiros são nada a ver." (25/09/2026)
8
+ *
9
+ * O roteiro saía inventado ("sem data, preço nem local", anotou o próprio
10
+ * modelo) e as imagens eram de banco. O site da marca tem as duas coisas: a
11
+ * página da imersão diz "6 a 8 out · Chapecó/SC · 20 cadeiras", e a galeria
12
+ * tem as fotos da sala. Aqui se lê a página inicial e as páginas internas
13
+ * que combinam com o pedido, e sai:
14
+ *
15
+ * - fotos: as maiores que o site oferece (o maior "w" do srcset), sem logo,
16
+ * ícone, SVG nem miniatura;
17
+ * - vídeos: os .mp4 que a página toca (depoimento, bastidor);
18
+ * - fatos: o texto de cada página, limpo — a matéria-prima do roteiro.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const { URL } = require('url');
24
+
25
+ const PALAVRAS_BOAS = ['galeria', 'imers', 'evento', 'sobre', 'about', 'portfolio', 'cases', 'case', 'turma', 'curso', 'produto', 'servic', 'equipe', 'time'];
26
+ const RUIM = /(logo|icon|favicon|sprite|avatar|emoji|badge|\/brand\/|placeholder|blank|pixel|\.svg(\?|$)|-sm\.|\.sm\.|thumb)/i;
27
+
28
+ function limpar(t) { return String(t || '').replace(/\s+/g, ' ').trim(); }
29
+
30
+ /** O texto da página, sem script, estilo, menu nem rodapé repetido. */
31
+ function textoDaPagina(html) {
32
+ let t = String(html || '')
33
+ .replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<noscript[\s\S]*?<\/noscript>/gi, ' ')
34
+ .replace(/<(nav|footer|header)[\s\S]*?<\/\1>/gi, ' ')
35
+ .replace(/<br\s*\/?>|<\/(p|h\d|li|div|section)>/gi, '. ')
36
+ .replace(/<[^>]+>/g, ' ');
37
+ t = t.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&[a-z]+;/g, ' ');
38
+ return limpar(t).replace(/(\.\s*){2,}/g, '. ').slice(0, 2200);
39
+ }
40
+
41
+ /** A versão maior de cada imagem: no srcset, o maior "w" até 2000. */
42
+ function maiorDoSrcset(srcset) {
43
+ let melhor = null, largura = 0;
44
+ for (const parte of String(srcset).split(',')) {
45
+ const [u, w] = parte.trim().split(/\s+/);
46
+ const n = parseInt(w, 10) || 1;
47
+ if (u && n <= 2000 && n >= largura) { melhor = u; largura = n; }
48
+ }
49
+ return melhor;
50
+ }
51
+
52
+ // A mesma foto em tamanhos diferentes (x.sm.webp, x.md.webp, x.lg.webp) é UMA foto.
53
+ const chaveDe = (u) => u.replace(/[?#].*$/, '').replace(/\.(sm|md|lg|xl|\d{2,4}w?)(?=\.[a-z]+$)/i, '').replace(/-\d{3,4}(?=\.[a-z]+$)/, '');
54
+
55
+ function colherMidia(html, base) {
56
+ const abs = (u) => { try { return new URL(String(u).replace(/\\\//g, '/'), base).toString(); } catch { return null; } };
57
+ const fotos = new Map(), videos = new Map();
58
+ const pesoDe = (u) => (/\.lg\.|1800w|1920|2000/.test(u) ? 3 : /\.md\.|1280/.test(u) ? 2 : 1);
59
+ const poe = (mapa, u, alt = '') => {
60
+ const a = abs(u);
61
+ if (!a || RUIM.test(a) || a.startsWith('data:')) return;
62
+ const k = chaveDe(a);
63
+ const atual = mapa.get(k);
64
+ // Sem alt, o nome do arquivo descreve a foto ("palestrante-plateia-mesas").
65
+ const doNome = decodeURIComponent(a.split('/').pop().replace(/\.[a-z0-9]+$/i, '').replace(/\.(sm|md|lg)$/i, ''))
66
+ .replace(/^\d+[-_]?/, '').replace(/[-_]+/g, ' ').replace(/\b(dsc|img|hero)\b\s*\d*/gi, '').trim();
67
+ if (!atual || pesoDe(a) > pesoDe(atual.url)) mapa.set(k, { url: a, alt: limpar(alt).slice(0, 120) || (atual && atual.alt) || doNome });
68
+ };
69
+ let m;
70
+ const reImg = /<img\b[^>]*>/gi;
71
+ while ((m = reImg.exec(html))) {
72
+ const tag = m[0];
73
+ const alt = (tag.match(/\balt=["']([^"']*)/i) || [])[1] || '';
74
+ const srcset = (tag.match(/\b(?:srcset|data-srcset)=["']([^"']+)/i) || [])[1];
75
+ const src = (srcset && maiorDoSrcset(srcset)) || (tag.match(/\b(?:data-src|src)=["']([^"']+)/i) || [])[1];
76
+ if (src && /\.(jpe?g|png|webp|avif)(\?|$)/i.test(src)) poe(fotos, src, alt);
77
+ }
78
+ // Sites em Next/React guardam a galeria num JSON dentro da página: o
79
+ // caminho da mídia aparece como texto, não como <img>.
80
+ const reSolto = /["'(]((?:https?:)?\/[^"'()\s]+?\.(?:jpe?g|png|webp|avif|mp4|webm))(?:\?[^"'()\s]*)?["')]/gi;
81
+ while ((m = reSolto.exec(html))) {
82
+ const u = m[1];
83
+ if (/\.(mp4|webm)$/i.test(u)) poe(videos, u);
84
+ else poe(fotos, u);
85
+ }
86
+ const og = (html.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)/i) || [])[1];
87
+ if (og) poe(fotos, og, 'capa do site');
88
+ return { fotos: [...fotos.values()], videos: [...videos.values()] };
89
+ }
90
+
91
+ /** As páginas internas que valem a leitura para ESTE pedido. */
92
+ function paginasRelevantes(html, base, pedido, max = 5) {
93
+ const host = new URL(base).hostname;
94
+ const termos = String(pedido || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
95
+ .split(/[^a-z0-9]+/).filter((w) => w.length >= 4);
96
+ const vistos = new Set();
97
+ const lista = [];
98
+ const re = /href=["']([^"'#]+)["']/gi;
99
+ let m;
100
+ while ((m = re.exec(html))) {
101
+ let u;
102
+ try { u = new URL(m[1], base); } catch { continue; }
103
+ if (u.hostname !== host || /\.(css|js|png|jpe?g|svg|webp|pdf|ico|xml|json)$/i.test(u.pathname)) continue;
104
+ const caminho = u.pathname.replace(/\/$/, '');
105
+ if (!caminho || vistos.has(caminho) || /privacidade|privacy|termos|terms|login|blog\/|cdn-cgi/.test(caminho)) continue;
106
+ vistos.add(caminho);
107
+ const c = caminho.toLowerCase();
108
+ const pontos = termos.filter((t) => c.includes(t.slice(0, 6))).length * 3
109
+ + PALAVRAS_BOAS.filter((w) => c.includes(w)).length;
110
+ if (pontos > 0) lista.push({ url: u.toString(), pontos });
111
+ }
112
+ return lista.sort((a, b) => b.pontos - a.pontos).slice(0, max).map((x) => x.url);
113
+ }
114
+
115
+ /**
116
+ * Lê o site e baixa o acervo para a pasta do projeto.
117
+ *
118
+ * @param {object} op
119
+ * @param {string} op.url
120
+ * @param {string} op.pedido o que o usuário pediu (decide quais páginas ler)
121
+ * @param {string} op.pasta a pasta do projeto
122
+ * @param {function} op.buscar (url) → {corpo} — o leitor de página do identidade.js
123
+ * @param {function} op.baixar (item, {pasta, nome, sub}) → {ok, relativo} — o do assets.js
124
+ */
125
+ async function colher({ url, pedido = '', pasta, buscar, baixar, maxFotos = 16, maxVideos = 4, htmlInicial = null }) {
126
+ const paginas = [];
127
+ const inicial = htmlInicial || (await buscar(url)).corpo;
128
+ paginas.push({ url, html: inicial });
129
+ for (const u of paginasRelevantes(inicial, url, pedido)) {
130
+ try { paginas.push({ url: u, html: (await buscar(u)).corpo }); } catch { /* página que não abriu fica de fora */ }
131
+ }
132
+
133
+ const fatos = paginas.map((p) => ({ pagina: p.url, texto: textoDaPagina(p.html) })).filter((f) => f.texto.length > 80);
134
+ const fotosVistas = new Map(), videosVistos = new Map();
135
+ // As páginas internas primeiro: a galeria tem mais foto de verdade que a home.
136
+ for (const p of [...paginas.slice(1), paginas[0]]) {
137
+ const { fotos, videos } = colherMidia(p.html, p.url);
138
+ for (const f of fotos) if (!fotosVistas.has(chaveDe(f.url))) fotosVistas.set(chaveDe(f.url), { ...f, pagina: p.url });
139
+ for (const v of videos) if (!videosVistos.has(chaveDe(v.url))) videosVistos.set(chaveDe(v.url), { ...v, pagina: p.url });
140
+ }
141
+
142
+ const fotos = [], videos = [];
143
+ for (const f of fotosVistas.values()) {
144
+ if (fotos.length >= maxFotos) break;
145
+ const d = await baixar({ url: f.url, titulo: f.alt || 'foto da marca', licenca: 'da própria marca', fonte: 'site' }, { pasta, nome: 'marca-foto', sub: 'midia/marca' });
146
+ // Menos de 25 KB é ícone ou miniatura, mesmo sem cara de ícone no nome.
147
+ if (d.ok && d.bytes > 25000) fotos.push({ src: d.relativo, alt: f.alt, pagina: f.pagina });
148
+ }
149
+ for (const v of videosVistos.values()) {
150
+ if (videos.length >= maxVideos) break;
151
+ const d = await baixar({ url: v.url, titulo: 'vídeo da marca', licenca: 'da própria marca', fonte: 'site' }, { pasta, nome: 'marca-video', sub: 'midia/marca' });
152
+ if (d.ok && d.bytes > 50000) videos.push({ src: d.relativo, pagina: v.pagina });
153
+ }
154
+ return { ok: true, paginas: paginas.map((p) => p.url), fatos, fotos, videos };
155
+ }
156
+
157
+ module.exports = { colher, colherMidia, paginasRelevantes, textoDaPagina, maiorDoSrcset };
@@ -120,7 +120,41 @@ const COLECOES = [
120
120
  * O SVG vem com `currentColor` no traço para a cor ser decidida na cena — um
121
121
  * ícone com cor cravada no arquivo obriga a baixar de novo para mudar de cor.
122
122
  */
123
+ /* ── EMOJI 3D ─────────────────────────────────────────────────────────
124
+ * "Os emojis, quero emojis da Apple." (25/09/2026) Os da Apple têm direito
125
+ * autoral e não podem ir num vídeo publicado; os Fluent 3D da Microsoft têm
126
+ * licença MIT e o mesmo visual brilhante e com volume. "fluent-3d:robot" é o
127
+ * PNG do repositório oficial (pelo jsDelivr), embutido como <img> — o resto
128
+ * do render trata igual a um ícone SVG. Sem o 3D, cai no emoji plano. */
129
+ const cache3d = new Map();
130
+ async function emoji3d(nome, { pasta = null } = {}) {
131
+ if (cache3d.has(nome)) return cache3d.get(nome);
132
+ const palavras = nome.split('-');
133
+ const pastas = [
134
+ palavras.join(' ').replace(/^./, (c) => c.toUpperCase()),
135
+ palavras.map((w) => w[0].toUpperCase() + w.slice(1)).join(' '),
136
+ ];
137
+ for (const pastaGit of pastas) {
138
+ const url = `https://cdn.jsdelivr.net/gh/microsoft/fluentui-emoji@main/assets/${encodeURIComponent(pastaGit)}/3D/${palavras.join('_')}_3d.png`;
139
+ try {
140
+ const png = await pegar(url, { binario: true });
141
+ if (png && png.length > 1000 && png[0] === 0x89) {
142
+ if (pasta) guardar(path.join(pasta, 'midia', 'icones', `fluent-3d-${nome}.png`), png, { licenca: 'MIT (Microsoft Fluent UI Emoji)', origem: url });
143
+ const r = { ok: true, nome: 'fluent-3d:' + nome,
144
+ svg: `<img src="data:image/png;base64,${png.toString('base64')}" style="width:100%;height:100%;object-fit:contain;display:block" />` };
145
+ cache3d.set(nome, r);
146
+ return r;
147
+ }
148
+ } catch { /* a próxima grafia */ }
149
+ }
150
+ const plano = await icone('fluent-emoji-flat:' + nome, { pasta });
151
+ const r = plano.ok ? { ...plano, nome: 'fluent-3d:' + nome } : plano;
152
+ cache3d.set(nome, r);
153
+ return r;
154
+ }
155
+
123
156
  async function icone(nome, { pasta = null } = {}) {
157
+ if (/^fluent-3d:/i.test(String(nome))) return emoji3d(String(nome).split(':')[1], { pasta });
124
158
  const m = String(nome || '').match(/^([a-z0-9-]+):([a-z0-9-]+)$/i);
125
159
  if (!m) return { ok: false, error: 'o ícone é "coleção:nome", como "lucide:zap"' };
126
160
  const url = `https://api.iconify.design/${m[1]}/${m[2]}.svg?color=currentColor`;
@@ -213,7 +247,28 @@ function extensaoPorConteudo(bytes) {
213
247
  *
214
248
  * @returns {{ok, imagens: Array<{url, titulo, autor, licenca, creditos, fonte}>}}
215
249
  */
216
- async function procurarImagens(termo, { quantas = 8, transparente = false } = {}) {
250
+ /* O Pexels pelo SERVIDOR: a chave é dele, e ninguém precisa ter uma. Sem
251
+ servidor (ou sem a chave lá), devolve null e a busca segue no Openverse. */
252
+ async function pexelsDoServidor(tipo, termo, { quantas, orientacao } = {}) {
253
+ try {
254
+ const cfg = require('../config').loadConfig();
255
+ const r = await require('../api').getMidia(cfg.server, cfg.token, tipo, { q: termo, n: quantas, orientacao });
256
+ const lista = r && r.ok ? r[tipo] : null;
257
+ return lista && lista.length ? lista : null;
258
+ } catch { return null; }
259
+ }
260
+
261
+ /** Procura vídeo de apoio (b-roll). Só Pexels: o Openverse não tem vídeo. */
262
+ async function procurarVideos(termo, { quantas = 5, orientacao = null } = {}) {
263
+ const videos = await pexelsDoServidor('videos', termo, { quantas, orientacao });
264
+ return videos ? { ok: true, videos } : { ok: false, error: 'sem acervo de vídeo agora (o servidor está sem a chave do Pexels).', videos: [] };
265
+ }
266
+
267
+ async function procurarImagens(termo, { quantas = 8, transparente = false, orientacao = null } = {}) {
268
+ if (!transparente && !process.env.PEXELS_API_KEY) {
269
+ const imagens = await pexelsDoServidor('imagens', termo, { quantas, orientacao });
270
+ if (imagens) return { ok: true, imagens };
271
+ }
217
272
  const chave = process.env.PEXELS_API_KEY;
218
273
  if (chave && !transparente) {
219
274
  try {
@@ -256,14 +311,33 @@ async function procurarImagens(termo, { quantas = 8, transparente = false } = {}
256
311
  * `duracaoMin` existe para separar as duas coisas sem o usuário dizer: um
257
312
  * "whoosh" tem menos de dois segundos, uma trilha tem mais de trinta.
258
313
  */
314
+ /* A MÚSICA vem da Jamendo (pelo Openverse): música de verdade, moderna, de
315
+ licença aberta. O Openverse geral devolvia concerto antigo, vento e trilha
316
+ de terror para "cinematic" (medido em 25/09). Licença "nd" (sem obra
317
+ derivada) fica de fora da música: pôr a faixa num vídeo editado é derivar. */
318
+ async function procurarMusica(termo, { quantas = 8, duracaoMin = 45 } = {}) {
319
+ try {
320
+ const r = await pegar(`https://api.openverse.org/v1/audio/?q=${encodeURIComponent(termo)}&source=jamendo`
321
+ + `&page_size=${Math.max(quantas * 2, 10)}&license_type=commercial`, { json: true });
322
+ const sons = (r.results || [])
323
+ .filter((x) => !/nd/.test(x.license || '') && (!duracaoMin || (x.duration || 0) / 1000 >= duracaoMin))
324
+ .slice(0, quantas)
325
+ .map((x) => ({ url: x.url, titulo: x.title || termo, autor: x.creator, licenca: x.license, creditos: x.attribution,
326
+ fonte: 'jamendo', segundos: x.duration ? Math.round(x.duration / 1000) : null }));
327
+ if (sons.length) return { ok: true, sons };
328
+ } catch { /* cai no Openverse geral */ }
329
+ return procurarSom(termo, { quantas, duracaoMin });
330
+ }
331
+
259
332
  async function procurarSom(termo, { quantas = 8, duracaoMin = 0, duracaoMax = 0 } = {}) {
260
333
  try {
261
334
  let url = `https://api.openverse.org/v1/audio/?q=${encodeURIComponent(termo)}`
262
335
  + `&page_size=${quantas}&license_type=commercial`;
263
- if (duracaoMin) url += `&length=${duracaoMin < 30 ? 'short' : 'medium'}`;
336
+ if (duracaoMin && duracaoMin < 30) url += '&length=short';
264
337
  const r = await pegar(url, { json: true });
265
338
  const sons = (r.results || [])
266
339
  .filter((x) => !duracaoMax || !x.duration || x.duration / 1000 <= duracaoMax)
340
+ .filter((x) => !duracaoMin || !x.duration || x.duration / 1000 >= duracaoMin)
267
341
  .map((x) => ({
268
342
  url: x.url, titulo: x.title || termo, autor: x.creator,
269
343
  licenca: x.license, creditos: x.attribution, fonte: 'openverse',
@@ -337,7 +411,7 @@ async function conferirFonte(familia) {
337
411
  module.exports = {
338
412
  extensaoPorConteudo,
339
413
  pegar, guardar,
340
- icone, procurarIcones, iconesDoPlano, COLECOES,
414
+ icone, procurarIcones, iconesDoPlano, COLECOES, procurarVideos, procurarMusica,
341
415
  procurarImagens, procurarSom, baixar,
342
416
  FONTES, conferirFonte,
343
417
  };
@@ -0,0 +1,277 @@
1
+ /**
2
+ * cinema.js — o estilo cinematográfico, premium e humano.
3
+ *
4
+ * "Os efeitos sonoros estão genéricos e sem impacto, a paleta da Paris não
5
+ * é laranja, as cenas têm muito texto e não dá de ler antes de trocar, e
6
+ * os textos não têm nada a ver com o roteiro." — e veio o roteiro dele:
7
+ * "cinematográfico, premium, moderno e humano… poucas palavras por tela…
8
+ * fotos nunca estáticas… NÃO usar excesso de efeitos… a abertura não
9
+ * mostra o logo." (25/09/2026)
10
+ *
11
+ * O contrário do impacto: o texto entra com calma e FICA enquanto é falado
12
+ * (a voz lê o que está na tela), as fotos se movem sempre (zoom, pan,
13
+ * lateral), o preto é quase absoluto, o destaque é o gradiente da marca, a
14
+ * música cresce e silencia antes do clímax, e a marca só aparece no fim.
15
+ *
16
+ * Tipos de parte (todos com "fala" opcional; sem ela, a voz lê os textos):
17
+ * frase {linhas} preto, linha fina, texto
18
+ * montagem {linhas, ritmo} fotos rápidas + texto por cima
19
+ * numeros {antes, itens:["3 EDIÇÕES"]} os números contando
20
+ * escada {itens, final} "UMA SALA." "20 CADEIRAS." …
21
+ * dia {num, titulo, linhas} 01 PREPARAR + as três linhas
22
+ * depoimento {clipes:[{src,de,ate,frase,nome}]} o aluno falando
23
+ * climax {linhas} montagem acelerada, preto, silêncio
24
+ * final {linhas} a logo e a chamada
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ const { caber } = require('./composicao.js');
30
+
31
+ const r2 = (n) => Math.round(n * 100) / 100;
32
+ const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
33
+
34
+ /** Quanto tempo um texto precisa ficar na tela para ser lido. */
35
+ const leitura = (t) => 0.55 + 0.22 * String(t || '').split(/\s+/).filter(Boolean).length;
36
+
37
+ /** A fala desta parte (o que a voz diz): a "fala", ou os textos da tela. */
38
+ function falaDe(p) {
39
+ if (p.fala) return p.fala;
40
+ if (p.tipo === 'depoimento') return '';
41
+ const partes = [p.antes, ...(p.linhas || []), ...(p.itens || []), p.num ? `Dia ${p.num}.` : '', p.titulo, p.final];
42
+ return partes.filter(Boolean).map((x) => String(x).replace(/[·→]/g, ',')).join(' ');
43
+ }
44
+
45
+ /* Quando cada texto começa, pelo tempo das palavras da voz: procura a
46
+ primeira palavra do texto a partir de onde parou. */
47
+ function quando(textos, palavras, dur, t0 = 0.25) {
48
+ const ws = palavras.map((w) => ({ ...w, k: tokens(w.texto)[0] || '' }));
49
+ let cursor = 0;
50
+ return textos.map((txt, i) => {
51
+ const alvo = tokens(txt).find((w) => w.length >= 3) || tokens(txt)[0];
52
+ let achado = -1;
53
+ for (let j = cursor; j < ws.length; j++) if (ws[j].k === alvo || (alvo && ws[j].k.startsWith(alvo.slice(0, 5)))) { achado = j; break; }
54
+ if (achado >= 0) { cursor = achado + 1; return r2(ws[achado].de); }
55
+ return r2(t0 + (dur - t0) * (i / Math.max(1, textos.length)));
56
+ });
57
+ }
58
+
59
+ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null, fotos = [] }) {
60
+ const partes = (roteiro && roteiro.partes) || [];
61
+ if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
62
+ const marca = roteiro.marca || {};
63
+ const grad = marca.gradiente && marca.gradiente.length >= 2 ? marca.gradiente : ['#2E81ED', '#9567DB'];
64
+ const PRETO = (marca.cores && marca.cores.fundo) || '#07070A';
65
+ const BRANCO = '#F5F5F7';
66
+ const DEST = grad[1];
67
+ const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Inter', texto: (marca.fontes && marca.fontes.texto) || 'Inter' };
68
+ const W = largura, H = altura, deitado = W >= H;
69
+ const M = Math.round(Math.min(W, H) * 0.08), W0 = W - 2 * M;
70
+ const base = Math.min(W, H);
71
+ const cenas = [], trechos = [], efeitos = [], volumes = [];
72
+ let t = 0, fotoI = 0;
73
+ const foto = () => fotos[fotoI++ % Math.max(1, fotos.length)];
74
+ const som = (tt, nome, volume = 0.6) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, tt)), src: sons[nome], volume }); };
75
+ const vol = (tt, v) => volumes.push({ t: r2(tt), v });
76
+
77
+ // Os movimentos da foto, em rodízio: nunca parada.
78
+ const movs = [
79
+ (d) => ({ escala: [{ t: 0, v: 1.02 }, { t: d, v: 1.16, e: 'linear' }] }),
80
+ (d) => ({ escala: [{ t: 0, v: 1.18 }, { t: d, v: 1.06, e: 'linear' }] }),
81
+ (d) => ({ escala: [{ t: 0, v: 1.14 }, { t: d, v: 1.14 }], x: [{ t: 0, v: -W * 0.04 }, { t: d, v: W * 0.04, e: 'linear' }] }),
82
+ (d) => ({ escala: [{ t: 0, v: 1.14 }, { t: d, v: 1.14 }], x: [{ t: 0, v: W * 0.04 }, { t: d, v: -W * 0.04, e: 'linear' }] }),
83
+ ];
84
+ let mov = 0;
85
+ const pFoto = (els, f, de, ate, { veu = 0.5, entrada = 'corte' } = {}) => {
86
+ if (!f) return;
87
+ const d = r2(ate - de), v = f.tipo === 'video';
88
+ els.push({ tipo: v ? 'video' : 'imagem', src: f.src, ...(v ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: 'center 30%', de: r2(de), ate: r2(ate),
89
+ anima: { ...movs[mov++ % movs.length](d), ...(entrada === 'suave' ? { opacidade: [{ t: 0, v: 0 }, { t: 0.4, v: 1 }] } : {}) } });
90
+ if (veu > 0) els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', de: r2(de), ate: r2(ate),
91
+ fundo: { tipo: 'gradiente', cores: [`rgba(7,7,10,${veu * 0.7})`, `rgba(7,7,10,${veu})`, `rgba(7,7,10,${Math.min(0.95, veu + 0.25)})`], angulo: 180 } });
92
+ };
93
+ // O texto: entra palavra por palavra no tempo da voz e fica; as palavras marcadas no gradiente.
94
+ const pTexto = (els, texto, de, ate, { y = null, max = deitado ? H * 0.11 : W * 0.13, linhas = 3, peso = 700, caixa = true, marcar = [], cor = BRANCO, pal = [], alinhar = 'center', x = M, larg = W0 } = {}) => {
95
+ const txt = caixa ? String(texto).toUpperCase() : String(texto);
96
+ const fit = caber(txt, { largura: larg * 0.86, linhas, max, fonte: F.titulo });
97
+ const h = fit.tamanho * 1.08 * fit.linhas;
98
+ const ws = txt.split(/\s+/);
99
+ /* Centralizado, a linha entra inteira: palavra por palavra, as que ainda
100
+ não foram ditas ocupam espaço invisível e o começo da frase aparece
101
+ torto, puxado para a esquerda. Alinhado à esquerda, palavra por palavra. */
102
+ const rev = ws.map((_, k) => (alinhar === 'center' ? 0 : r2(Math.max(0, (pal[k] != null ? pal[k] : de + 0.08 * k) - de))));
103
+ const idx = ws.map((w, k) => (marcar.some((m) => tokens(w)[0] && tokens(m).includes(tokens(w)[0])) ? k : -1)).filter((k) => k >= 0);
104
+ els.push({ tipo: 'texto', texto: txt, fonte: F.titulo, peso, tamanho: fit.tamanho, cor, entrelinha: 1.08, espacamento: caixa ? 1 : 0,
105
+ x: Math.round(x), y: Math.round(y == null ? (H - h) / 2 : y), largura: Math.round(larg), alinhamento: alinhar, de: r2(de), ate: r2(ate),
106
+ revelar: rev, ...(idx.length ? { destaques: idx, corDestaque: DEST } : {}),
107
+ anima: { opacidade: [{ t: 0, v: 1 }, { t: Math.max(0.01, ate - de - 0.25), v: 1 }, { t: ate - de, v: 0 }], y: [{ t: 0, v: 10 }, { t: ate - de, v: -6, e: 'linear' }] } });
108
+ return h;
109
+ };
110
+ const linhaFina = (els, y, de, larg = W * 0.18) => els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round((W - larg) / 2), y: Math.round(y), largura: Math.round(larg), altura: 2,
111
+ fundo: { tipo: 'gradiente', cores: [grad[0], grad[1]], angulo: 90 }, origem: 'center', de: r2(de), anima: { escalaX: [{ t: 0, v: 0 }, { t: 0.8, v: 1, e: 'saida' }] } });
112
+ const pontos = (els, de) => { for (let k = 0; k < 3; k++) els.push({ tipo: 'forma', forma: 'circulo', largura: 6, altura: 6, cor: k === 1 ? DEST : 'rgba(255,255,255,0.4)', x: Math.round(W / 2 - 18 + k * 15), y: Math.round(H - M * 1.2), de: r2(de), anima: { opacidade: [{ t: 0.2 * k, v: 0 }, { t: 0.2 * k + 0.3, v: 1 }] } }); };
113
+
114
+ let narrI = 0;
115
+ partes.forEach((p, i) => {
116
+ const els = [];
117
+ const cena = { fundo: { tipo: 'cor', valor: PRETO }, elementos: els, transicao: { tipo: 'corte', duracao: 0 } };
118
+ const narrada = p.tipo !== 'depoimento';
119
+ const tp = narrada ? (tempos[narrI++] || null) : null;
120
+ const off = 0.35; // a voz entra um instante depois do corte
121
+ const falaDur = tp ? Math.min(tp.ate + 0.6, tempos[narrI] ? tempos[narrI].de : tp.ate + 0.6) - tp.de : 0;
122
+ const rel = (w) => r2(off + w - (tp ? tp.de : 0)); // tempo da voz → tempo da cena
123
+ const pals = tp ? tp.palavras || [] : [];
124
+ let dur;
125
+
126
+ /* O fim da fala: a transcrição marca o fim da última palavra um pouco
127
+ antes do fim de verdade, e cortar ali comia a última sílaba ("parece
128
+ que a narração não terminou", 25/09). O trecho vai até perto do
129
+ começo da próxima fala, com folga. */
130
+ const proxFala = tempos[narrI] ? tempos[narrI].de : null;
131
+ const fimFala = tp ? r2(Math.min(proxFala != null ? proxFala - 0.02 : tp.ate + 0.6, tp.ate + 0.6)) : 0;
132
+ if (tp && narracao) trechos.push({ src: narracao, t: r2(t + off), de: r2(tp.de), ate: fimFala, volume: 1 });
133
+
134
+ const textosDaParte = p.tipo === 'escada' ? [...(p.itens || []), p.final].filter(Boolean)
135
+ : p.tipo === 'numeros' ? [p.antes, ...(p.itens || [])].filter(Boolean)
136
+ : p.tipo === 'dia' ? [p.num, p.titulo, ...(p.linhas || [])].filter(Boolean)
137
+ : (p.linhas || []);
138
+ const somaLeitura = textosDaParte.reduce((n, x) => n + leitura(x), 0);
139
+ // A cena dura a fala (com um respiro) — e nunca menos que o tempo de ler.
140
+ dur = r2(Math.max(falaDur + off + 0.45, somaLeitura * 0.8, 2));
141
+ const inicios = quando(textosDaParte, pals.map((w) => ({ ...w, de: rel(w.de) })), dur, off);
142
+ /* Cada texto garante o SEU tempo de leitura e empurra o seguinte: sincronia
143
+ com a voz é bom, ler é obrigatório. Se a leitura pede mais que a fala,
144
+ a cena cresce (medido: "EDIÇÕES" ficava meio segundo na tela). */
145
+ for (let k = 1; k < inicios.length; k++) inicios[k] = r2(Math.max(inicios[k], inicios[k - 1] + leitura(textosDaParte[k - 1])));
146
+ if (inicios.length) dur = r2(Math.max(dur, inicios[inicios.length - 1] + leitura(textosDaParte[inicios.length - 1]) + 0.3));
147
+ const fimDe = (k) => (k < inicios.length - 1 ? inicios[k + 1] : dur);
148
+ const palDo = (txt) => { const ws = String(txt).split(/\s+/); const a = pals.findIndex((w) => tokens(w.texto)[0] === tokens(ws[0])[0]); return a < 0 ? [] : ws.map((_, k) => (pals[a + k] ? rel(pals[a + k].de) : null)); };
149
+
150
+ if (p.tipo === 'frase') {
151
+ if (i === 0) { som(t + 0.1, 'teclado', 0.5); vol(t, 0.12); }
152
+ linhaFina(els, H * 0.5 - base * 0.12, 0.2);
153
+ textosDaParte.forEach((x, k) => pTexto(els, x, inicios[k], fimDe(k), { caixa: false, peso: 600, max: deitado ? H * 0.1 : W * 0.12, linhas: deitado ? 3 : 4, marcar: p.marcar || [], pal: palDo(x) }));
154
+ } else if (p.tipo === 'montagem' || p.tipo === 'climax') {
155
+ const rap = p.tipo === 'climax' ? 0.38 : (p.ritmo || 0.75);
156
+ const fimFotos = p.tipo === 'climax' && textosDaParte.length ? Math.max(1.8, inicios[0] - 0.4) : dur;
157
+ for (let a = 0; a < fimFotos; a += rap) pFoto(els, foto(), a, Math.min(fimFotos, a + rap), { veu: p.tipo === 'climax' ? 0.15 : 0.5 });
158
+ if (p.tipo === 'climax') {
159
+ vol(t, 0.5); vol(t + fimFotos - 0.3, 0.75); vol(t + fimFotos, 0.0); vol(t + dur - 0.2, 0.0);
160
+ som(t + fimFotos, 'impacto', 0.7);
161
+ } else { vol(t, 0.28); som(t, 'whoosh', 0.35); }
162
+ textosDaParte.forEach((x, k) => pTexto(els, x, Math.max(inicios[k], p.tipo === 'climax' ? fimFotos : 0), fimDe(k), { peso: 800, marcar: p.marcar || [], pal: palDo(x) }));
163
+ } else if (p.tipo === 'numeros') {
164
+ vol(t, 0.3);
165
+ for (let a = 0; a < dur; a += 1.2) pFoto(els, foto(), a, Math.min(dur, a + 1.2), { veu: 0.62 });
166
+ textosDaParte.forEach((x, k) => {
167
+ const m = String(x).match(/^(\d+)\s+(.*)$/);
168
+ if (k === 0 && p.antes) { pTexto(els, x, inicios[k], fimDe(k), { max: base * 0.08, peso: 700, pal: palDo(x) }); return; }
169
+ if (!m) { pTexto(els, x, inicios[k], fimDe(k), { pal: palDo(x) }); return; }
170
+ const tam = Math.round(deitado ? H * 0.24 : W * 0.3);
171
+ els.push({ tipo: 'texto', texto: m[1], fonte: F.titulo, peso: 800, tamanho: tam, cor: BRANCO, x: M, y: Math.round(H * 0.5 - tam * 0.75), largura: W0, alinhamento: 'center',
172
+ de: inicios[k], ate: fimDe(k), contar: { ate: Number(m[1]), dur: 0.9 }, gradienteTexto: undefined,
173
+ anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.25, v: 1 }], escala: [{ t: 0, v: 0.92 }, { t: 0.6, v: 1, e: 'saida' }] } });
174
+ pTexto(els, m[2], inicios[k] + 0.3, fimDe(k), { y: H * 0.5 + tam * 0.3, max: base * 0.085, peso: 700, cor: DEST });
175
+ });
176
+ } else if (p.tipo === 'escada') {
177
+ vol(t, 0.22);
178
+ if (sons.sala) trechos.push({ src: sons.sala, t: r2(t), de: 0, ate: dur, volume: 0.25 });
179
+ // As fotos passam devagar numa janela, à direita (ou em cima, no vertical).
180
+ // No vertical a janela desce da zona do topo do Instagram e fica maior.
181
+ const jan = deitado ? { x: W * 0.56, y: M, l: W * 0.44 - M, a: H - 2 * M } : { x: M, y: H * 0.1, l: W0, a: H * 0.42 };
182
+ const itens = p.itens || [];
183
+ const hItem = Math.round(deitado ? H * 0.1 : W * 0.12);
184
+ const fimItens = p.final ? inicios[itens.length] : dur;
185
+ for (let a = 0; a < fimItens; a += 1.3) {
186
+ const f = foto();
187
+ if (f) els.push({ tipo: f.tipo === 'video' ? 'video' : 'imagem', src: f.src, ...(f.tipo === 'video' ? { volume: 0 } : {}), x: Math.round(jan.x), y: Math.round(jan.y), largura: Math.round(jan.l), altura: Math.round(jan.a), ajuste: 'cover', foco: 'center 30%', raio: 18, de: r2(a), ate: r2(Math.min(fimItens, a + 1.3)),
188
+ anima: { ...movs[mov++ % movs.length](1.3), opacidade: [{ t: 0, v: 0 }, { t: 0.3, v: 1 }] } });
189
+ }
190
+ itens.forEach((x, k) => {
191
+ const yy = deitado ? H * 0.5 - (itens.length * hItem * 1.25) / 2 + k * hItem * 1.25 : H * 0.56 + k * hItem * 1.25;
192
+ pTexto(els, x, inicios[k], fimItens, { y: yy, max: hItem, linhas: 1, alinhar: 'left', larg: deitado ? W * 0.48 : W0, peso: 800, pal: palDo(x) });
193
+ });
194
+ if (p.final) pTexto(els, p.final, fimItens, dur, { marcar: p.marcar || [], pal: palDo(p.final) });
195
+ } else if (p.tipo === 'dia') {
196
+ vol(t, 0.32);
197
+ pFoto(els, foto(), 0, dur, { veu: 0.66, entrada: 'suave' });
198
+ som(t, 'whoosh', 0.3);
199
+ const tamN = Math.round(deitado ? H * 0.34 : W * 0.42);
200
+ els.push({ tipo: 'texto', texto: String(p.num), fonte: F.titulo, peso: 800, tamanho: tamN, cor: 'rgba(149,103,219,0.12)', contorno: `${Math.max(3, Math.round(base * 0.004))}px ${DEST}`,
201
+ x: M, y: Math.round(deitado ? H * 0.12 : H * 0.14), largura: W0, alinhamento: deitado ? 'left' : 'center',
202
+ anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.6, v: 1 }], x: [{ t: 0, v: -20 }, { t: dur, v: 20, e: 'linear' }] } });
203
+ const yT = deitado ? H * 0.5 : H * 0.42;
204
+ const hT = pTexto(els, p.titulo, inicios[1] || 0.4, dur, { y: yT, max: deitado ? H * 0.11 : W * 0.13, linhas: 1, alinhar: deitado ? 'left' : 'center', larg: W0, peso: 800, pal: palDo(p.titulo) });
205
+ (p.linhas || []).forEach((x, k) => pTexto(els, x, inicios[2 + k] || 0.8 + k * 0.6, dur, { y: yT + hT + H * 0.03 + k * base * (deitado ? 0.085 : 0.1), max: base * (deitado ? 0.06 : 0.07), linhas: 1, caixa: false, peso: 500, cor: 'rgba(245,245,247,0.8)',
206
+ alinhar: deitado ? 'left' : 'center', larg: W0, pal: palDo(x) }));
207
+ } else if (p.tipo === 'depoimento') {
208
+ /* O aluno falando, com a voz dele; a música quase some. CADA depoimento
209
+ é uma cena: o vídeo conta o tempo do início da cena, e com os três
210
+ na mesma cena o segundo e o terceiro já tinham passado do fim (preto). */
211
+ vol(t, 0.06);
212
+ const clipes = p.clipes || [];
213
+ clipes.forEach((c, n) => {
214
+ const d = r2(c.ate - c.de + 0.3);
215
+ const ce = { fundo: { tipo: 'cor', valor: PRETO }, elementos: [], transicao: { tipo: n === 0 ? 'esmaecer' : 'corte', duracao: n === 0 ? 0.4 : 0 }, duracao: d };
216
+ const el2 = ce.elementos;
217
+ if (deitado) {
218
+ const wV = Math.round(H * 0.88 * 9 / 16), xV = Math.round(W * 0.08), xT = xV + wV + Math.round(W * 0.05);
219
+ el2.push({ tipo: 'video', src: c.src, volume: 0, deOrigem: c.de, ateOrigem: c.ate + 0.3, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', anima: { desfoque: [{ t: 0, v: 40 }], brilho: [{ t: 0, v: 0.4 }] } });
220
+ el2.push({ tipo: 'video', src: c.src, volume: 1, deOrigem: c.de, ateOrigem: c.ate + 0.3, x: xV, y: Math.round(H * 0.06), largura: wV, altura: Math.round(H * 0.88), ajuste: 'cover', raio: 20, anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.2, v: 1 }] } });
221
+ pTexto(el2, `“${c.frase}”`, 0.3, d, { x: xT, larg: W - xT - M, alinhar: 'left', caixa: false, peso: 600, max: H * 0.075, linhas: 5 });
222
+ if (c.nome) el2.push({ tipo: 'texto', texto: c.nome.toUpperCase(), fonte: F.texto, peso: 700, tamanho: Math.round(H * 0.03), cor: DEST, espacamento: 3, x: xT, y: Math.round(H * 0.8), de: 0.5 });
223
+ } else {
224
+ el2.push({ tipo: 'video', src: c.src, volume: 1, deOrigem: c.de, ateOrigem: c.ate + 0.3, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', foco: 'center 30%' });
225
+ el2.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', fundo: { tipo: 'gradiente', cores: ['rgba(7,7,10,0)', 'rgba(7,7,10,0.35)', 'rgba(7,7,10,0.9)'], angulo: 180 } });
226
+ // Tudo acima de 75% da altura: embaixo disso o Instagram põe a legenda e os botões.
227
+ pTexto(el2, `“${c.frase}”`, 0.3, d, { y: H * 0.54, caixa: false, peso: 600, max: W * 0.08, linhas: 4 });
228
+ if (c.nome) el2.push({ tipo: 'texto', texto: c.nome.toUpperCase(), fonte: F.texto, peso: 700, tamanho: Math.round(W * 0.035), cor: DEST, espacamento: 3, x: M, y: Math.round(H * 0.72), largura: W0, alinhamento: 'center', de: 0.5 });
229
+ }
230
+ if (n < clipes.length - 1) { cenas.push(ce); t += d; } else { Object.assign(cena, ce); dur = d; }
231
+ });
232
+ if (!clipes.length) dur = 1;
233
+ } else if (p.tipo === 'final') {
234
+ // A marca, enfim: a logo aparece devagar, as linhas em sequência, a logo some.
235
+ vol(t, 0.55); vol(t + dur - 1.8, 0.45); vol(t + dur, 0);
236
+ som(t + 0.05, 'impacto', 0.55);
237
+ const R = Math.round(Math.max(W, H) * 0.7);
238
+ els.push({ tipo: 'forma', forma: 'circulo', largura: R, altura: R, x: Math.round(W / 2 - R / 2), y: Math.round(H * 0.42 - R / 2), cor: grad[1], opacidade: 0.18, anima: { desfoque: [{ t: 0, v: Math.round(R * 0.25) }], escala: [{ t: 0, v: 0.8 }, { t: dur, v: 1.15, e: 'suave' }] } });
239
+ if (marca.logo) {
240
+ const lh = Math.round(base * (deitado ? 0.13 : 0.16));
241
+ els.push({ tipo: 'imagem', src: marca.logo, x: Math.round(W / 2 - lh * 1.75), y: Math.round(H * (deitado ? 0.2 : 0.3)), largura: Math.round(lh * 3.5), altura: lh, ajuste: 'contain',
242
+ anima: { opacidade: [{ t: 0, v: 0 }, { t: 1.0, v: 1 }, { t: dur - 1.2, v: 1 }, { t: dur, v: 0 }], escala: [{ t: 0, v: 0.94 }, { t: dur, v: 1.02, e: 'linear' }] } });
243
+ }
244
+ const y0 = H * (deitado ? 0.44 : 0.42);
245
+ textosDaParte.forEach((x, k) => pTexto(els, x, inicios[k], k < textosDaParte.length - 1 ? fimDe(k) : dur, { y: y0 + (k % 2) * 0, max: k === textosDaParte.length - 1 ? base * (deitado ? 0.1 : 0.12) : base * (deitado ? 0.08 : 0.1), peso: k === textosDaParte.length - 1 ? 800 : 600,
246
+ marcar: p.marcar || [], cor: k === textosDaParte.length - 1 ? DEST : BRANCO, pal: palDo(x) }));
247
+ pontos(els, 0.3);
248
+ cena.transicao = { tipo: 'esmaecer', duracao: 0.6 };
249
+ }
250
+
251
+ // Entre as partes, um esmaecer curto: corte seco em tudo parecia fala interrompida.
252
+ if (i > 0 && cena.transicao.tipo === 'corte' && !['montagem', 'climax'].includes(p.tipo)) cena.transicao = { tipo: 'esmaecer', duracao: 0.35 };
253
+ cena.duracao = dur;
254
+ cenas.push(cena);
255
+ t += dur;
256
+ });
257
+
258
+ // Os cruzamentos são da cena que entra: a anterior fica no ar até ela entrar.
259
+ let acum = 0;
260
+ cenas.forEach((c, k) => {
261
+ const prox = cenas[k + 1];
262
+ c.duracao = r2(c.duracao + (prox && prox.transicao.tipo !== 'corte' ? prox.transicao.duracao : 0));
263
+ acum += c.duracao;
264
+ });
265
+ volumes.sort((a, b) => a.t - b.t);
266
+ return {
267
+ ok: true,
268
+ plano: {
269
+ formato: { largura: W, altura: H, fps: 30 }, fundoGeral: PRETO, cenas,
270
+ audio: { ...(trilha ? { trilha: { src: trilha, volumes: volumes.length ? volumes : [{ t: 0, v: 0.3 }], loop: true } } : {}), trechos, efeitos },
271
+ acabamento: { grao: 0.05, vinheta: 0.5, grade: 0.035, passoGrade: Math.round(base / 9) },
272
+ },
273
+ resumo: { cenas: cenas.length, estilo: 'cinema', segundos: r2(t) },
274
+ };
275
+ }
276
+
277
+ module.exports = { montarCinema, falaDe, quando, leitura };