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.
- package/README.md +33 -6
- package/app/ansi.js +55 -0
- package/app/app.css +482 -281
- package/app/app.js +888 -315
- package/app/index.html +176 -101
- package/app/primo-marca.js +58 -1
- package/bin/primocode.js +697 -118
- package/comandos/explicar.md +14 -0
- package/comandos/publicar.md +10 -0
- package/comandos/revisar.md +13 -0
- package/lib/act.js +130 -26
- package/lib/api.js +8 -1
- package/lib/app.js +317 -74
- package/lib/browser.js +79 -3
- package/lib/catalogo.js +11 -3
- package/lib/checkpoint-git.js +218 -0
- package/lib/checkpoint.js +249 -0
- package/lib/claude-engine.js +25 -3
- package/lib/comandos-extra.js +481 -0
- package/lib/comandos.js +104 -0
- package/lib/config.js +21 -4
- package/lib/estudio/acervo-site.js +157 -0
- package/lib/estudio/assets.js +77 -3
- package/lib/estudio/cinema.js +277 -0
- package/lib/estudio/composicao.js +470 -0
- package/lib/estudio/edicao.js +450 -0
- package/lib/estudio/impacto.js +263 -0
- package/lib/estudio/index.js +674 -5
- package/lib/estudio/plano.js +5 -2
- package/lib/estudio/remotion.js +228 -23
- package/lib/estudio/slides.js +34 -30
- package/lib/fala.js +95 -1
- package/lib/local/cerebro.json +361 -180
- package/lib/local/entrada.js +18 -8
- package/lib/local/motor-cli.js +1 -1
- package/lib/mencoes.js +98 -0
- package/lib/offline.js +168 -0
- package/lib/painel.js +291 -0
- package/lib/perigo.js +256 -0
- package/lib/pre-leitura.js +118 -0
- package/lib/repeticao.js +4 -2
- package/lib/skills.js +6 -1
- package/lib/tempos.js +62 -0
- package/lib/ui.js +171 -17
- package/package.json +3 -2
- package/skills/apresentacao-pptx/SKILL.md +52 -0
- package/skills/cartaz-de-arte/LICENSE.txt +202 -0
- package/skills/cartaz-de-arte/SKILL.md +47 -0
- package/skills/criar-skill/LICENSE.txt +202 -0
- package/skills/criar-skill/SKILL.md +49 -0
- package/skills/depurar-de-verdade/LICENSE.txt +21 -0
- package/skills/depurar-de-verdade/SKILL.md +14 -5
- package/skills/design-de-verdade/LICENSE.txt +177 -0
- package/skills/design-de-verdade/SKILL.md +42 -0
- package/skills/documento-word/SKILL.md +57 -0
- package/skills/interface-revisada/SKILL.md +47 -0
- package/skills/me-ensina/LICENSE.txt +21 -0
- package/skills/me-ensina/SKILL.md +27 -0
- package/skills/me-entrevista/LICENSE.txt +21 -0
- package/skills/me-entrevista/SKILL.md +27 -0
- package/skills/motion-de-primeira/SKILL.md +1 -1
- package/skills/pdf-bonito/SKILL.md +48 -0
- package/skills/planilha-excel/SKILL.md +47 -0
- package/skills/react-sem-dor/SKILL.md +48 -0
- package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
- package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
- package/skills/site-com-motion/SKILL.md +39 -0
- package/skills/temas-prontos/LICENSE.txt +202 -0
- package/skills/temas-prontos/SKILL.md +50 -0
- package/skills/testar-o-site/LICENSE.txt +202 -0
- package/skills/testar-o-site/SKILL.md +46 -0
- package/skills/video-no-remotion/SKILL.md +48 -0
- 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(
|
|
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
|
|
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(/ /g, ' ').replace(/&/g, '&').replace(/"/g, '"').replace(/'/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 };
|
package/lib/estudio/assets.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 };
|