primocode 10.0.9 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -4
  2. package/bin/primocode.js +25 -28
  3. package/lib/act.js +5 -2
  4. package/lib/catalogo.js +7 -4
  5. package/lib/config.js +10 -2
  6. package/lib/estudio/assets.js +86 -6
  7. package/lib/estudio/batida.js +184 -0
  8. package/lib/estudio/biblioteca.js +214 -0
  9. package/lib/estudio/cinema.js +37 -17
  10. package/lib/estudio/composicao.js +2 -1
  11. package/lib/estudio/decisao.js +106 -0
  12. package/lib/estudio/edicao.js +36 -17
  13. package/lib/estudio/estilo.js +35 -8
  14. package/lib/estudio/fluido.js +352 -0
  15. package/lib/estudio/gerativo.js +21 -3
  16. package/lib/estudio/hibrido.js +16 -12
  17. package/lib/estudio/impacto.js +66 -31
  18. package/lib/estudio/index.js +245 -42
  19. package/lib/estudio/kit/fluido.jsx +614 -0
  20. package/lib/estudio/kit/gerativo.jsx +15 -5
  21. package/lib/estudio/kit/index.jsx +2 -1
  22. package/lib/estudio/kit/tres.jsx +88 -21
  23. package/lib/estudio/kit.js +3 -1
  24. package/lib/estudio/kokoro.js +111 -0
  25. package/lib/estudio/lancamento.js +3 -4
  26. package/lib/estudio/legibilidade.js +20 -0
  27. package/lib/estudio/referencias.js +36 -25
  28. package/lib/estudio/remotion.js +53 -6
  29. package/lib/estudio/revisor.js +117 -0
  30. package/lib/estudio/variacao3d.js +46 -0
  31. package/lib/estudio/variedade.js +140 -8
  32. package/lib/portao-skill.js +150 -0
  33. package/lib/skills.js +15 -1
  34. package/lib/tools.js +31 -5
  35. package/lib/video/filters.js +6 -1
  36. package/lib/video/ops.js +4 -3
  37. package/lib/video/timeline.js +1 -1
  38. package/lib/video/zoom.js +275 -0
  39. package/package.json +2 -2
  40. package/skills/escrever-em-portugues/SKILL.md +3 -0
  41. package/skills/interface-revisada/SKILL.md +7 -1
  42. package/skills/motion-de-primeira/SKILL.md +3 -5
  43. package/skills/projeto-no-github/SKILL.md +6 -0
  44. package/skills/publicar-na-internet/SKILL.md +3 -0
  45. package/skills/site-com-motion/SKILL.md +2 -0
  46. package/studio/voz_kokoro.py +98 -0
@@ -0,0 +1,214 @@
1
+ /**
2
+ * biblioteca.js — o cardápio de fonte e cor dos estilos de corte (híbrido,
3
+ * cinema, impacto), com memória do que os últimos vídeos usaram.
4
+ *
5
+ * "por falta de variedade a gente tá fazendo os vídeos todos iguais" (Joel, 02/10)
6
+ *
7
+ * Sem fonte e cor da marca, o cinema caía sempre no Inter e no gradiente
8
+ * azul e roxo (#2E81ED → #9567DB), e o híbrido é o padrão: todo vídeo saía
9
+ * com a mesma cara. Aqui a escolha segue esta ordem:
10
+ * 1. a marca (fontes e cores dela) — nunca é trocada;
11
+ * 2. o que a IA escolheu do cardápio (roteiro.visual);
12
+ * 3. um sorteio que pula o que os últimos vídeos já usaram.
13
+ *
14
+ * As 128 famílias foram conferidas no Google Fonts em 02/10, com o peso mais
15
+ * grosso de cada uma (o título do cinema é caixa alta em 800).
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+ const path = require('path');
23
+
24
+ // ── as fontes: "Nome:peso máximo", por tipo ──────────────────────────────
25
+ const TIPOS = {
26
+ condensada: 'Anton:400, Antonio:700, Archivo Narrow:700, Barlow Condensed:900, Bebas Neue:400, Big Shoulders Display:900, '
27
+ + 'Encode Sans Condensed:900, Fjalla One:400, Khand:700, League Gothic:400, Mohave:700, Oswald:700, Pathway Gothic One:400, '
28
+ + 'Saira Condensed:900, Six Caps:400, Smooch Sans:900, Sofia Sans Condensed:900, Staatliches:400, Teko:700',
29
+ display: 'Alfa Slab One:400, Anybody:900, Archivo Black:400, Archivo:900, Black Ops One:400, Bricolage Grotesque:800, '
30
+ + 'Chakra Petch:700, Chivo:900, Darker Grotesque:900, Epilogue:900, Exo 2:900, Familjen Grotesk:700, Funnel Display:800, '
31
+ + 'Gabarito:900, Lexend:900, Mona Sans:900, Passion One:900, Red Hat Display:900, Righteous:400, Rubik:900, Russo One:400, '
32
+ + 'Schibsted Grotesk:900, Shrikhand:400, Urbanist:900, Wix Madefor Display:800',
33
+ larga: 'Audiowide:400, Bagel Fat One:400, Bowlby One:400, Bungee Shade:400, Bungee:400, Dela Gothic One:400, Krona One:400, '
34
+ + 'Lexend Zetta:900, Michroma:400, Monoton:400, Orbitron:900, Press Start 2P:400, Rubik Mono One:400, Silkscreen:700, Syne:800, Ultra:400',
35
+ texto: 'Afacad:700, Albert Sans:900, Barlow:900, Be Vietnam Pro:900, Figtree:900, Funnel Sans:800, Golos Text:900, '
36
+ + 'Hanken Grotesk:900, Host Grotesk:800, Instrument Sans:700, Karla:800, Onest:900, Public Sans:900, Reddit Sans:900, '
37
+ + 'Rethink Sans:800, Sofia Sans:900, Work Sans:900',
38
+ serifa: 'Abril Fatface:400, Arvo:700, Bitter:900, Bodoni Moda:900, Caprasimo:400, Cormorant Garamond:700, Crimson Pro:900, '
39
+ + 'DM Serif Display:400, EB Garamond:800, Fraunces:900, Gloock:400, Instrument Serif:400, Libre Baskerville:700, '
40
+ + 'Libre Bodoni:700, Libre Caslon Display:400, Literata:900, Lora:700, Merriweather:900, Newsreader:800, Playfair Display:900, '
41
+ + 'Prata:400, Roboto Slab:900, Rozha One:400, Source Serif 4:900, Spectral:800, Yeseva One:400, Young Serif:400, Zilla Slab:700',
42
+ manuscrita: 'Caveat:700, Gloria Hallelujah:400, Kalam:700, Lobster:400, Marck Script:400, Nanum Pen Script:400, Pacifico:400, '
43
+ + 'Permanent Marker:400, Rock Salt:400, Satisfy:400, Shadows Into Light:400, Yellowtail:400',
44
+ mono: 'Azeret Mono:900, DM Mono:400, Fragment Mono:400, Geist Mono:900, IBM Plex Mono:700, JetBrains Mono:800, '
45
+ + 'Major Mono Display:400, Martian Mono:800, Red Hat Mono:700, Space Mono:700, VT323:400',
46
+ };
47
+ const FONTES = Object.entries(TIPOS).flatMap(([tipo, lista]) => lista.split(', ').map((x) => {
48
+ const [nome, peso] = x.split(':');
49
+ return { nome, tipo, peso: Number(peso) };
50
+ }));
51
+
52
+ /* A largura média do caractere, em "em", por tipo — o caber() de
53
+ composicao.js usa para escolher o tamanho. A larga é a que estoura a borda. */
54
+ const LARGURA = { condensada: 0.46, display: 0.6, larga: 0.74, texto: 0.58, serifa: 0.55, manuscrita: 0.52, mono: 0.62 };
55
+ const larguraDe = (fonte) => { const f = acharFonte(fonte); return f ? LARGURA[f.tipo] : null; };
56
+
57
+ /* O título é caixa alta grossa: fonte de texto fina demais não serve. O
58
+ texto (etiquetas, nomes) é qualquer uma que se lê pequena. */
59
+ const TITULO = FONTES.filter((f) => ['condensada', 'display', 'larga'].includes(f.tipo) || (f.tipo === 'serifa' && f.peso >= 800)
60
+ || (f.tipo === 'texto' && f.peso >= 900));
61
+ const TEXTO = FONTES.filter((f) => ['texto', 'mono'].includes(f.tipo) || (f.tipo === 'serifa' && f.peso >= 700) || (f.tipo === 'display' && f.peso >= 800));
62
+
63
+ // ── as paletas: fundo escuro e as duas cores do gradiente ────────────────
64
+ /* O cinema é escuro (o texto é branco), então todo fundo é escuro. Nada do
65
+ violeta de SaaS nem do neon: é o que denuncia vídeo feito por IA. */
66
+ const BASES = [
67
+ ['brasa', '#120B08', '#FF5A1F', '#FFB23F'], ['ferrugem', '#15100D', '#C8553D', '#F2A65A'], ['mata', '#0B1410', '#2BB673', '#D8F05A'],
68
+ ['oceano', '#071219', '#1E88E5', '#4FD1C5'], ['tijolo', '#170C0B', '#E2583E', '#F6D6AD'], ['limao', '#0E100A', '#C6F432', '#F4F1E8'],
69
+ ['cobre', '#120E0C', '#D9822B', '#F3C98B'], ['sangue', '#100607', '#E5383B', '#FF9F1C'], ['musgo', '#0F120C', '#8DB255', '#E9C46A'],
70
+ ['gelo', '#0A0F14', '#8EC5FF', '#F1F7FF'], ['sol-de-inverno', '#0D0D10', '#FFC93C', '#FF6B35'], ['cereja', '#14080B', '#FF4D6D', '#FFC2D1'],
71
+ ['jade', '#06120F', '#00A878', '#9BE564'], ['areia', '#13110D', '#E9C46A', '#F4A261'], ['safira', '#070B16', '#2F6BFF', '#7FB2FF'],
72
+ ['tangerina', '#110A06', '#FF7A00', '#FFD166'], ['pistache', '#0C110D', '#93C572', '#F0EAD6'], ['vinho', '#13070C', '#D1495B', '#FCB9B2'],
73
+ ['petroleo', '#061214', '#0F9D9A', '#F2C14E'], ['ouro-velho', '#0F0D08', '#C9A227', '#F5E6B3'], ['coral', '#120A0A', '#FF6F59', '#FFD6A5'],
74
+ ['asfalto', '#0E0F10', '#FF3B30', '#FFFFFF'], ['menta', '#08110E', '#3DDC97', '#E8FFF4'], ['lava', '#0F0705', '#FF3D00', '#FFB300'],
75
+ ['ceu', '#08101A', '#56C3E8', '#F7F7F7'], ['terracota', '#140D0A', '#CB6843', '#EBD4B4'], ['abacate', '#0D100A', '#A3B18A', '#DAD7CD'],
76
+ ['neve-e-fogo', '#0B0B0E', '#FFFFFF', '#FF4F00'], ['framboesa', '#12070D', '#E0245E', '#FFB3C6'], ['cacau', '#120D0A', '#A47148', '#F2D0A9'],
77
+ ['esmeralda', '#04110B', '#10B981', '#FDE68A'], ['aco', '#0C0E11', '#9DB4C0', '#E0E1DD'], ['acafrao', '#110D05', '#F4B400', '#FF6D00'],
78
+ ['lagoa', '#051214', '#48CAE4', '#CAF0F8'], ['papoula', '#110808', '#F94144', '#F9C74F'], ['oliva', '#0F0F09', '#B5A642', '#F1E9C9'],
79
+ ['ardosia', '#0D1014', '#FF8C42', '#E8EEF2'], ['tinta-e-rosa', '#0E0B0C', '#FF8FAB', '#FFE5EC'], ['hortela-e-ouro', '#08120F', '#52B788', '#F6C453'],
80
+ ['noite-azul', '#060A12', '#3A86FF', '#FFBE0B'], ['cinzas', '#101010', '#D9D9D9', '#FF5F1F'], ['pessego', '#120C0A', '#FFAD87', '#FFE3D3'],
81
+ ['floresta', '#07100A', '#40916C', '#B7E4C7'], ['rubi', '#10060A', '#EF233C', '#EDF2F4'], ['mostarda', '#100E06', '#E1AD01', '#F6F1D1'],
82
+ ['turquesa', '#061111', '#2EC4B6', '#FFBF69'], ['caramelo', '#120C07', '#DDA15E', '#FEFAE0'], ['grafite-limao', '#0F1011', '#D4FF3A', '#F2F2F2'],
83
+ ['alvorada', '#0D0A0C', '#FF9E00', '#FF4D6D'], ['tempestade', '#0A0D10', '#6C9BD2', '#F2E8CF'],
84
+ ];
85
+
86
+ function rgb(hex) { const n = parseInt(String(hex).slice(1), 16); return [n >> 16 & 255, n >> 8 & 255, n & 255]; }
87
+ const hex = (c) => '#' + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('').toUpperCase();
88
+ const misturar = (a, b, k) => hex(rgb(a).map((v, i) => v + (rgb(b)[i] - v) * k));
89
+ const luz = (c) => { const [r, g, b] = rgb(c); return (0.299 * r + 0.587 * g + 0.114 * b) / 255; };
90
+
91
+ /* Cada base vira quatro: como veio, o gradiente invertido, o fundo tingido
92
+ da cor principal e a monocromática (a cor e ela mais clara). */
93
+ const PALETAS = BASES.flatMap(([nome, fundo, a, b]) => [
94
+ { nome, base: nome, fundo, grad: [a, b] },
95
+ { nome: `${nome}-invertida`, base: nome, fundo, grad: [b, a] },
96
+ { nome: `${nome}-tingida`, base: nome, fundo: misturar(fundo, a, 0.1), grad: [a, b] },
97
+ { nome: `${nome}-mono`, base: nome, fundo, grad: [a, misturar(a, '#FFFFFF', 0.45)] },
98
+ ]);
99
+
100
+ // ── a memória: o que os últimos vídeos já usaram ─────────────────────────
101
+ const ARQ = path.join(os.homedir(), '.primocode', 'studio', 'biblioteca-usada.json');
102
+ const LEMBRA = { titulo: 15, texto: 8, paleta: 20 }; // paleta conta pela base: "brasa-mono" depois de "brasa" é repetir
103
+
104
+ function lerMemoria(arq = ARQ) {
105
+ try { const m = JSON.parse(fs.readFileSync(arq, 'utf8')); return { titulo: [], texto: [], paleta: [], ...m }; }
106
+ catch { return { titulo: [], texto: [], paleta: [] }; }
107
+ }
108
+ function gravarMemoria(m, arq = ARQ) {
109
+ try { fs.mkdirSync(path.dirname(arq), { recursive: true }); fs.writeFileSync(arq, JSON.stringify(m)); } catch { /* sem memória, só sorteio */ }
110
+ }
111
+
112
+ const norm = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
113
+ function acharFonte(nome) { const n = norm(nome); return FONTES.find((f) => norm(f.nome) === n) || null; }
114
+ function acharPaleta(nome) { const n = norm(nome).replace(/\s+/g, '-'); return PALETAS.find((p) => p.nome === n) || null; }
115
+
116
+ /* O CASAMENTO DA FONTE COM O ROTEIRO (07/10): "a IA deve escolher fontes e
117
+ combinações de acordo com assunto, tom, público e ritmo" (Joel). Antes eram
118
+ dois sorteios independentes. Agora:
119
+ - o CLIMA do roteiro (o mesmo que escolhe a trilha, variedade.climaDe) diz
120
+ quanto cada tipo de título serve (0 a 1);
121
+ - o tipo do título diz que tipo de texto faz par com ele (contraste, não
122
+ duas fontes que brigam);
123
+ - a memória penaliza o recente pela distância, não corta: o que serve ao
124
+ tom ganha do que só é novo (os pontos de variedade.js). */
125
+ const TOM_DO_TITULO = {
126
+ tecnologia: { display: 1, larga: 0.9, condensada: 0.6, texto: 0.7, serifa: 0.2 },
127
+ energetico: { condensada: 1, display: 0.9, larga: 0.6, texto: 0.5, serifa: 0.2 },
128
+ epico: { condensada: 1, serifa: 0.8, display: 0.6, larga: 0.5, texto: 0.3 },
129
+ calmo: { serifa: 1, texto: 0.9, display: 0.5, condensada: 0.3, larga: 0.2 },
130
+ alegre: { display: 1, larga: 0.8, texto: 0.6, condensada: 0.4, serifa: 0.4 },
131
+ emocional: { serifa: 1, texto: 0.7, display: 0.5, condensada: 0.4, larga: 0.2 },
132
+ misterio: { condensada: 0.9, serifa: 0.9, larga: 0.6, display: 0.5, texto: 0.4 },
133
+ };
134
+ // O par do texto, pelo tipo do título: o contraste que funciona.
135
+ const PAR_DO_TEXTO = {
136
+ condensada: { texto: 1, mono: 0.8, serifa: 0.6, display: 0.4 },
137
+ display: { texto: 1, serifa: 0.7, mono: 0.6, display: 0.2 },
138
+ larga: { mono: 1, texto: 0.9, serifa: 0.4, display: 0.2 },
139
+ serifa: { texto: 1, mono: 0.7, display: 0.5, serifa: 0.3 },
140
+ texto: { serifa: 1, mono: 0.9, texto: 0.3, display: 0.5 },
141
+ };
142
+
143
+ /** O tom do roteiro para a fonte: o clima (variedade.climaDe). Neutro serve tudo igual. */
144
+ function tomDe(roteiro, estilo = '') { return require('./variedade.js').climaDe(roteiro || {}, estilo); }
145
+
146
+ /* A escolha por pontos: 2 × adequação − recência (1 para o último usado,
147
+ metade a cada quarto da memória). Entre os quase empatados, sorteia. */
148
+ function escolher(lista, recentes, chave, sorte, adequa = () => 1, lembra = 10) {
149
+ const meia = Math.max(1, lembra / 4);
150
+ const pts = lista.map((x) => {
151
+ const pos = recentes.indexOf(chave(x));
152
+ return { x, p: 2 * adequa(x) - (pos < 0 ? 0 : Math.pow(0.5, pos / meia)) };
153
+ });
154
+ const melhor = Math.max(...pts.map((q) => q.p));
155
+ const quase = pts.filter((q) => q.p >= melhor - 0.05).map((q) => q.x);
156
+ return quase[Math.floor(sorte() * quase.length) % quase.length];
157
+ }
158
+
159
+ /**
160
+ * Escolhe fonte e paleta para o roteiro e as põe em roteiro.marca, sem
161
+ * trocar nada que a marca já tem. Devolve o que escolheu e de onde veio.
162
+ * memoria: null não lê nem grava o disco (os testes); sorte: () => [0,1).
163
+ */
164
+ function vestir(roteiro, { memoria = ARQ, sorte = Math.random, estilo = '' } = {}) {
165
+ if (!roteiro || typeof roteiro !== 'object') return { mudou: false };
166
+ const marca = roteiro.marca || {};
167
+ const cores = marca.cores || {};
168
+ const visual = roteiro.visual || {};
169
+ const mem = memoria ? lerMemoria(memoria) : { titulo: [], texto: [], paleta: [] };
170
+ const escolha = { mudou: false };
171
+
172
+ if (!(marca.fontes && marca.fontes.titulo)) {
173
+ // A IA escolheu pelo nome: vale qualquer família do Google Fonts, mesmo fora do cardápio.
174
+ const tom = TOM_DO_TITULO[tomDe(roteiro, estilo)] || null;
175
+ const titulo = visual.titulo ? String(visual.titulo)
176
+ : escolher(TITULO, mem.titulo, (f) => f.nome, sorte, (f) => (tom ? tom[f.tipo] ?? 0.3 : 1), LEMBRA.titulo).nome;
177
+ const tipoTitulo = (acharFonte(titulo) || {}).tipo;
178
+ const par = PAR_DO_TEXTO[tipoTitulo] || null;
179
+ const textoLista = TEXTO.filter((f) => f.nome !== titulo);
180
+ const texto = visual.texto ? String(visual.texto)
181
+ : escolher(textoLista, mem.texto, (f) => f.nome, sorte, (f) => (par ? par[f.tipo] ?? 0.2 : 1), LEMBRA.texto).nome;
182
+ marca.fontes = { ...(marca.fontes || {}), titulo, texto };
183
+ mem.titulo = [titulo, ...mem.titulo.filter((x) => x !== titulo)].slice(0, LEMBRA.titulo);
184
+ mem.texto = [texto, ...mem.texto.filter((x) => x !== texto)].slice(0, LEMBRA.texto);
185
+ Object.assign(escolha, { mudou: true, titulo, texto, fonteDaIA: !!visual.titulo, tom: tomDe(roteiro, estilo) });
186
+ }
187
+ // A cor da marca manda: com destaque ou gradiente declarado, a paleta não entra.
188
+ if (!(marca.gradiente && marca.gradiente.length >= 2) && !cores.destaque) {
189
+ const p = acharPaleta(visual.paleta) || escolher(PALETAS, mem.paleta, (x) => x.base, sorte, () => 1, LEMBRA.paleta);
190
+ marca.gradiente = [...p.grad];
191
+ marca.cores = { ...cores, fundo: cores.fundo || p.fundo, destaque: p.grad[1], apoio: p.grad[0] };
192
+ mem.paleta = [p.base, ...mem.paleta.filter((x) => x !== p.base)].slice(0, LEMBRA.paleta);
193
+ Object.assign(escolha, { mudou: true, paleta: p.nome, paletaDaIA: !!acharPaleta(visual.paleta) });
194
+ }
195
+ if (escolha.mudou) {
196
+ roteiro.marca = marca;
197
+ if (memoria) gravarMemoria(mem, memoria);
198
+ }
199
+ return escolha;
200
+ }
201
+
202
+ /** O cardápio para a IA: nomes por tipo, e as paletas pela base. */
203
+ function cardapio() {
204
+ const porTipo = {};
205
+ for (const f of FONTES) (porTipo[f.tipo] = porTipo[f.tipo] || []).push(f.nome);
206
+ return {
207
+ fontes: porTipo,
208
+ paletas: BASES.map(([nome, fundo, a, b]) => `${nome} ${fundo} ${a} ${b}`),
209
+ variacoes: 'cada paleta também vem -invertida, -tingida e -mono (ex.: "brasa-mono")',
210
+ uso: 'roteiro.visual = {titulo, texto, paleta}. Sem visual, o PrimoCode sorteia e não repete os últimos vídeos.',
211
+ };
212
+ }
213
+
214
+ module.exports = { vestir, cardapio, tomDe, TOM_DO_TITULO, PAR_DO_TEXTO, larguraDe, acharFonte, acharPaleta, FONTES, PALETAS, BASES, TITULO, TEXTO, luz, ARQ };
@@ -27,6 +27,7 @@
27
27
  'use strict';
28
28
 
29
29
  const { caber } = require('./composicao.js');
30
+ const { minimoLeitura, sobra } = require('./legibilidade.js');
30
31
 
31
32
  const r2 = (n) => Math.round(n * 100) / 100;
32
33
  const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
@@ -34,6 +35,7 @@ const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').t
34
35
  /** Quanto tempo um texto precisa ficar na tela para ser lido. Nunca menos que
35
36
  0,3 s por palavra: a regra de legibilidade do /brag (Arthur, 28/09). */
36
37
  const nPalavras = (t) => String(t || '').split(/\s+/).filter(Boolean).length;
38
+ const SAIDA_MIN = 0.1, ENTRA_NUM = 0.4; // a saída mais curta; o número contando antes de ser lido
37
39
  const leituraCheia = (t) => Math.max(0.55 + 0.22 * nPalavras(t), 0.3 * nPalavras(t));
38
40
 
39
41
  /** A fala desta parte (o que a voz diz): a "fala", ou os textos da tela. */
@@ -113,7 +115,7 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
113
115
  const PRETO = fundoMarca && luz(fundoMarca) < 0.25 ? fundoMarca : '#07070A';
114
116
  const BRANCO = '#F5F5F7';
115
117
  const DEST = grad[1];
116
- const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Inter', texto: (marca.fontes && marca.fontes.texto) || 'Inter' };
118
+ const F = { titulo: (marca.fontes && marca.fontes.titulo) || 'Inter', texto: (marca.fontes && marca.fontes.texto) || 'Inter', destaque: 'Instrument Serif' };
117
119
  const W = largura, H = altura, deitado = W >= H;
118
120
  const M = Math.round(Math.min(W, H) * 0.08), W0 = W - 2 * M;
119
121
  const base = Math.min(W, H);
@@ -180,26 +182,36 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
180
182
  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 } });
181
183
  };
182
184
  // O texto: entra palavra por palavra no tempo da voz e fica; as palavras marcadas no gradiente.
183
- 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, bater = false } = {}) => {
185
+ /* SEM CARA DE IA (05/10): o texto sai como a pessoa escreveu (nada de caixa
186
+ alta forçada), um pouco menor e com mais respiro — e a palavra marcada é
187
+ UM acento, na serifa em itálico. Caixa alta só com "caixa: true". */
188
+ const pTexto = (els, texto, de, ate, { y = null, max = deitado ? H * 0.11 : W * 0.13, linhas = 3, peso = 700, caixa = false, marcar = [], cor = BRANCO, pal = [], alinhar = 'center', x = M, larg = W0, bater = false } = {}) => {
189
+ max *= 0.85; peso = Math.min(peso, 700);
184
190
  // Frase longa (a narração na tela) vai em letra normal: em caixa alta ela encolhe e cansa.
185
- if (caixa && String(texto).split(/\s+/).length > 7) { caixa = false; peso = Math.min(peso, 700); }
191
+ if (caixa && String(texto).split(/\s+/).length > 7) caixa = false;
186
192
  // Frase longa ganha linhas (no vertical, até 5), para a letra não encolher.
187
193
  if (String(texto).split(/\s+/).length > 7) linhas = Math.max(linhas, deitado ? 4 : 5);
188
194
  const txt = caixa ? String(texto).toUpperCase() : String(texto);
189
- const fit = caber(txt, { largura: larg * 0.86, linhas, max, fonte: F.titulo });
190
- const h = fit.tamanho * 1.08 * fit.linhas;
195
+ const fit = caber(txt, { largura: larg * 0.78, linhas, max, fonte: F.titulo });
196
+ const h = fit.tamanho * 1.14 * fit.linhas;
191
197
  const ws = txt.split(/\s+/);
192
198
  /* Centralizado, a linha entra inteira: palavra por palavra, as que ainda
193
199
  não foram ditas ocupam espaço invisível e o começo da frase aparece
194
200
  torto, puxado para a esquerda. Alinhado à esquerda, palavra por palavra. */
195
201
  const rev = ws.map((_, k) => (alinhar === 'center' ? 0 : r2(Math.max(0, (pal[k] != null ? pal[k] : de + 0.08 * k) - de))));
196
- const idx = ws.map((w, k) => (marcar.some((m) => tokens(w)[0] && tokens(m).includes(tokens(w)[0])) ? k : -1)).filter((k) => k >= 0);
197
- els.push({ tipo: 'texto', texto: txt, fonte: F.titulo, peso, tamanho: fit.tamanho, cor, entrelinha: 1.08, espacamento: caixa ? 1 : 0,
202
+ // Uma palavra só, e só em frase de mais de uma palavra (a palavra sozinha não tem com quem contrastar).
203
+ const idx = ws.length < 2 ? [] : ws.map((w, k) => (marcar.some((m) => tokens(w)[0] && tokens(m).includes(tokens(w)[0])) ? k : -1)).filter((k) => k >= 0).slice(0, 1);
204
+ /* Entra rápido, FICA parado o tempo de ler, e só então sai (a lei do
205
+ /brag). Cena curta encolhe a entrada e a saída, nunca a leitura. */
206
+ const vida = ate - de, ler = minimoLeitura(txt);
207
+ const ent = bater ? sobra(vida - SAIDA_MIN, ler, 0.08, 0.22) : 0, fe = ent / 0.22;
208
+ const saida = r2(sobra(vida - ent, ler, SAIDA_MIN, 0.25));
209
+ els.push({ tipo: 'texto', texto: txt, fonte: F.titulo, peso, tamanho: fit.tamanho, cor, entrelinha: 1.14, espacamento: caixa ? 1 : -0.5,
198
210
  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),
199
- revelar: rev, ...(idx.length ? { destaques: idx, corDestaque: DEST } : {}),
200
- 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' }],
211
+ revelar: rev, ...(idx.length ? { destaques: idx, corDestaque: DEST, fonteDestaque: F.destaque } : {}),
212
+ anima: { opacidade: [{ t: 0, v: 1 }, { t: r2(Math.max(0.01, vida - saida)), v: 1 }, { t: r2(vida), v: 0 }], y: [{ t: 0, v: 10 }, { t: ate - de, v: -6, e: 'linear' }],
201
213
  // O texto que BATE no fundo: entra grande e assenta de uma vez.
202
- ...(bater ? { escala: [{ t: 0, v: 1.35 }, { t: 0.12, v: 0.97, e: 'saida' }, { t: 0.22, v: 1 }] } : {}) } });
214
+ ...(bater ? { escala: [{ t: 0, v: 1.35 }, { t: r2(0.12 * fe), v: 0.97, e: 'saida' }, { t: r2(ent), v: 1 }] } : {}) } });
203
215
  return h;
204
216
  };
205
217
  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,
@@ -242,9 +254,14 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
242
254
  com a voz é bom, ler é obrigatório. Se a leitura pede mais que a fala,
243
255
  a cena cresce (medido: "EDIÇÕES" ficava meio segundo na tela). */
244
256
  // Texto curto (um número, "3 DIAS.") se lê de relance: não espera a leitura cheia e não atrasa a voz.
245
- const minimo = (x) => Math.max(0.3 * nPalavras(x), Math.min(leitura(x), nPalavras(x) <= 3 ? 0.75 : 1.6));
246
- for (let k = 1; k < inicios.length; k++) inicios[k] = r2(Math.max(inicios[k], inicios[k - 1] + minimo(textosDaParte[k - 1])));
247
- if (inicios.length) dur = r2(Math.max(dur, inicios[inicios.length - 1] + leitura(textosDaParte[inicios.length - 1]) + 0.3));
257
+ /* E nunca menos que a lei do /brag (0,8 s parado) mais a saída mínima; o
258
+ número ainda conta antes de ser lido (ENTRA_NUM). */
259
+ const minimo = (x) => Math.max(minimoLeitura(x) + SAIDA_MIN, Math.min(leitura(x), nPalavras(x) <= 3 ? 0.75 : 1.6)) + (p.tipo === 'numeros' ? ENTRA_NUM : 0);
260
+ const espacar = (passo = 0) => {
261
+ for (let k = 1; k < inicios.length; k++) inicios[k] = r2(Math.max(inicios[k], inicios[k - 1] + Math.max(passo, minimo(textosDaParte[k - 1]))));
262
+ if (inicios.length) dur = r2(Math.max(dur, inicios[inicios.length - 1] + Math.max(leitura(textosDaParte[inicios.length - 1]), minimo(textosDaParte[inicios.length - 1])) + 0.3));
263
+ };
264
+ espacar();
248
265
  const fimDe = (k) => (k < inicios.length - 1 ? inicios[k + 1] : dur);
249
266
  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)); };
250
267
 
@@ -256,11 +273,13 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
256
273
  const rap = p.tipo === 'climax' ? 0.38 : (p.ritmo || 0.75);
257
274
  let fimFotos = p.tipo === 'climax' && textosDaParte.length ? Math.max(1.8, inicios[0] - 0.4) : dur;
258
275
  const fz = p.tipo === 'climax' ? fortes[i] : null;
276
+ // A virada começa quando as fotos acabam: o texto não perde a leitura para a montagem.
277
+ if (p.tipo === 'climax' && !fz && inicios.length) { inicios[0] = r2(Math.max(inicios[0], fimFotos)); espacar(1.2); }
259
278
  if (fz) {
260
279
  /* A voz forte entra DEPOIS da montagem, junto com o texto que bate. */
261
280
  fimFotos = 1.4;
262
281
  const ini2 = quando(textosDaParte, (fz.palavras || []).map((w) => ({ ...w, de: w.de + fimFotos })), fz.segundos, fimFotos);
263
- ini2.forEach((v0, k) => { inicios[k] = k === 0 ? fimFotos : Math.max(v0, inicios[k - 1] + 1.2); });
282
+ ini2.forEach((v0, k) => { inicios[k] = k === 0 ? fimFotos : Math.max(v0, inicios[k - 1] + Math.max(1.2, minimo(textosDaParte[k - 1]))); });
264
283
  dur = r2(Math.max(fimFotos + fz.segundos + 0.5, inicios[inicios.length - 1] + 1.6));
265
284
  const tr = trechos[trechos.length - 1];
266
285
  if (tr && tr.src === fz.src) tr.t = r2(t + fimFotos - 0.05);
@@ -301,9 +320,10 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
301
320
  const larguraNum = String(mv[1] + m[1] + mv[3]).length * 0.8; // serifa larga: 0,62 ainda quebrava
302
321
  const tam = Math.round(Math.min(deitado ? H * 0.24 : W * 0.3, (W0 * 0.96) / Math.max(1, larguraNum)));
303
322
  const casas = /[.,]/.test(m[1]) ? 1 : 0;
323
+ const entN = r2(sobra(fimDe(k) - inicios[k], minimoLeitura(m[1]), 0.3, 0.6));
304
324
  els.push({ tipo: 'texto', texto: mv[1] + m[1] + mv[3], fonte: F.titulo, peso: 800, tamanho: tam, cor: BRANCO, x: M, y: Math.round(H * 0.5 - tam * 0.75), largura: W0, alinhamento: 'center',
305
- de: inicios[k], ate: fimDe(k), contar: { ate: Number(m[1].replace(',', '.')), dur: 0.6, casas, antes: mv[1].toUpperCase(), depois: mv[3] }, gradienteTexto: undefined,
306
- anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.25, v: 1 }], escala: [{ t: 0, v: 0.92 }, { t: 0.6, v: 1, e: 'saida' }] } });
325
+ de: inicios[k], ate: fimDe(k), contar: { ate: Number(m[1].replace(',', '.')), dur: entN, casas, antes: mv[1].toUpperCase(), depois: mv[3] }, gradienteTexto: undefined,
326
+ anima: { opacidade: [{ t: 0, v: 0 }, { t: Math.min(0.25, entN), v: 1 }], escala: [{ t: 0, v: 0.92 }, { t: entN, v: 1, e: 'saida' }] } });
307
327
  pTexto(els, m[2], inicios[k] + 0.1, fimDe(k), { y: H * 0.5 + tam * 0.3, max: base * 0.085, peso: 700, cor: DEST });
308
328
  });
309
329
  } else if (p.tipo === 'escada') {
@@ -330,7 +350,7 @@ function montarCinema({ roteiro, largura = 1920, altura = 1080, tempos = [], nar
330
350
  pFoto(els, foto(), 0, dur, { veu: 0.66, entrada: 'suave' });
331
351
  som(t, 'whoosh', 0.3);
332
352
  const tamN = Math.round(deitado ? H * 0.34 : W * 0.42);
333
- 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}`,
353
+ els.push({ tipo: 'texto', texto: String(p.num), fonte: F.titulo, peso: 800, tamanho: tamN, cor: `${DEST}1F`, contorno: `${Math.max(3, Math.round(base * 0.004))}px ${DEST}`,
334
354
  x: M, y: Math.round(deitado ? H * 0.12 : H * 0.14), largura: W0, alinhamento: deitado ? 'left' : 'center',
335
355
  anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.6, v: 1 }], x: [{ t: 0, v: -20 }, { t: dur, v: 20, e: 'linear' }] } });
336
356
  const yT = deitado ? H * 0.5 : H * 0.42;
@@ -71,7 +71,8 @@ const FAMILIA_TIPO = (f) => (/condensed|narrow|anton|oswald|bebas|teko|league go
71
71
  : /black|bricolage|archivo|sora|grotesk|syne|unbounded/i.test(f) ? 'display' : 'texto');
72
72
 
73
73
  function caber(texto, { largura, linhas = 2, max = 200, min = 18, fonte = 'Inter' }) {
74
- const cw = LARGURA[FAMILIA_TIPO(fonte)];
74
+ // A fonte da biblioteca sabe a própria largura; a de fora, pelo nome.
75
+ const cw = require('./biblioteca.js').larguraDe(fonte) || LARGURA[FAMILIA_TIPO(fonte)];
75
76
  const palavras = String(texto || '').split(/\s+/).filter(Boolean);
76
77
  for (let t = Math.round(max); t >= min; t -= 2) {
77
78
  // Quebra de verdade, palavra por palavra: é o que o navegador fará.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * decisao.js — o que cada frase da edição pede: movimento, recorte, som — ou
3
+ * nada, e a cena respira.
4
+ *
5
+ * "Não quero simplesmente adicionar efeitos. Quero melhorar a decisão de
6
+ * edição. Nem todo momento precisa de movimento, transição ou efeito. Às
7
+ * vezes a melhor decisão é deixar a cena respirar." (Joel, 07/10)
8
+ *
9
+ * Antes a edição era um rodízio: zoom por `i % 3`, a pessoa recortada em
10
+ * `i % 4 === 2`, whoosh em `i % 3 === 1`. A frase mais forte do vídeo podia
11
+ * cair num "parado", e uma frase de ligação ganhava o soco. Aqui:
12
+ * 1. INTENSIDADE (0 a 1) de cada frase: a palavra mais forte, número,
13
+ * exclamação e pergunta, frase curta de efeito, a pausa antes dela (o
14
+ * silêncio prepara o golpe), o gancho do começo e o fecho;
15
+ * 2. MOVIMENTO pela intensidade, com três regras de editor: o golpe é raro
16
+ * (no máximo ~1 em 3 frases), depois do golpe vem um respiro, e o mesmo
17
+ * movimento não se repete em seguida;
18
+ * 3. o RECORTE (a pessoa na frente da palavra gigante) vai para as frases
19
+ * mais fortes, longe uma da outra; o WHOOSH só anuncia o soco.
20
+ * Puro: frases entram, decisões saem. `aleatorio` decide os empates.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const r2 = (n) => Math.round(n * 100) / 100;
26
+ const limpa = (t) => String(t || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9%]/g, '');
27
+
28
+ /* Os movimentos de câmera (zoom em quadros-chave, no tempo da cena):
29
+ parado nada — a cena respira
30
+ empurra aproxima devagar (tensão que cresce)
31
+ soco entra já fechado e segura (o golpe)
32
+ recua abre devagar (alívio, conclusão)
33
+ reenquadra fecha num ponto fora do centro (mudança de assunto) */
34
+ const MOVIMENTOS = {
35
+ parado: () => ({ zoom: [{ t: 0, v: 1.0 }, { t: 1, v: 1.0 }] }),
36
+ empurra: (d, k) => ({ zoom: [{ t: 0, v: 1.0 }, { t: d, v: r2(1.04 + 0.06 * k), e: 'linear' }] }),
37
+ soco: (d, k) => ({ zoom: [{ t: 0, v: r2(1.14 + 0.08 * k) }, { t: d, v: r2(1.17 + 0.08 * k), e: 'linear' }] }),
38
+ recua: (d, k) => ({ zoom: [{ t: 0, v: r2(1.1 + 0.05 * k) }, { t: d, v: 1.02, e: 'suave' }] }),
39
+ // O x é em px: 3,5% do lado menor cabe na sobra de um zoom de 1,12.
40
+ reenquadra: (d, k, lado, base = 1080) => ({ zoom: [{ t: 0, v: r2(1.12 + 0.04 * k) }, { t: d, v: r2(1.15 + 0.04 * k), e: 'suave' }],
41
+ x: [{ t: 0, v: Math.round((lado ? 1 : -1) * base * 0.03) }, { t: d, v: Math.round((lado ? 1 : -1) * base * 0.035), e: 'suave' }] }),
42
+ };
43
+
44
+ /** A intensidade de cada frase (lista de palavras {de, ate, texto}), de 0 a 1. */
45
+ function intensidades(grupos, peso = (w) => limpa(w).length) {
46
+ const n = grupos.length;
47
+ const brutos = grupos.map((g, i) => {
48
+ const texto = g.map((p) => p.texto).join(' ');
49
+ const forte = Math.max(0, ...g.map((p) => peso(p.texto)));
50
+ let s = Math.min(1, forte / 14) * 0.45;
51
+ if (/\d/.test(texto)) s += 0.2;
52
+ if (/!/.test(texto)) s += 0.2;
53
+ if (/\?/.test(texto)) s += 0.1;
54
+ if (g.length <= 4) s += 0.15; // frase curta é frase de efeito
55
+ const pausa = i > 0 ? g[0].de - grupos[i - 1][grupos[i - 1].length - 1].ate : 0;
56
+ if (pausa > 0.45) s += Math.min(0.2, pausa * 0.25); // o silêncio prepara o golpe
57
+ if (i === 0) s += 0.15; // o gancho
58
+ if (i === n - 1) s += 0.1; // o fecho
59
+ return s;
60
+ });
61
+ const max = Math.max(...brutos, 1e-9), min = Math.min(...brutos);
62
+ return brutos.map((s) => r2(max - min < 1e-6 ? 0.5 : (s - min) / (max - min)));
63
+ }
64
+
65
+ /**
66
+ * As decisões da edição: uma por frase.
67
+ * @returns {Array<{intensidade:number, movimento:string, recorte:boolean, whoosh:boolean}>}
68
+ */
69
+ function decidir(grupos, { peso, aleatorio = Math.random, maxRecortes = 3 } = {}) {
70
+ const ints = intensidades(grupos, peso);
71
+ const n = ints.length;
72
+ // O golpe é raro: só as frases do terço de cima (pelo menos 1, no máximo n/3).
73
+ const ordem = [...ints.keys()].sort((a, b) => ints[b] - ints[a]);
74
+ const golpes = new Set(ordem.slice(0, Math.max(1, Math.floor(n / 3))).filter((i) => ints[i] >= 0.6 || i === ordem[0]));
75
+ const out = [];
76
+ for (let i = 0; i < n; i++) {
77
+ const ant = out[i - 1];
78
+ let mov;
79
+ if (golpes.has(i) && !(ant && ant.movimento === 'soco')) mov = 'soco';
80
+ else if (ant && ant.movimento === 'soco') mov = 'parado'; // depois do golpe, respira
81
+ else if (i === n - 1) mov = ints[i] > 0.4 ? 'recua' : 'parado';
82
+ else {
83
+ // O resto pelo que a frase pede, sem repetir o anterior.
84
+ const pede = ints[i] > 0.45 ? ['reenquadra', 'empurra'] : ints[i] > 0.2 ? ['empurra', 'parado', 'reenquadra'] : ['parado', 'empurra'];
85
+ const opcoes = pede.filter((m) => !ant || m !== ant.movimento);
86
+ mov = opcoes[Math.floor(aleatorio() * Math.min(2, opcoes.length)) % opcoes.length];
87
+ }
88
+ out.push({ intensidade: ints[i], movimento: mov, recorte: false, whoosh: mov === 'soco' && i > 0 });
89
+ }
90
+ // O recorte: as frases mais fortes, com pelo menos 3 frases entre um e outro.
91
+ const escolhidos = [];
92
+ for (const i of ordem) {
93
+ if (escolhidos.length >= maxRecortes || ints[i] < 0.5) break;
94
+ if (i === 0 || escolhidos.some((j) => Math.abs(j - i) < 3)) continue;
95
+ escolhidos.push(i);
96
+ out[i].recorte = true;
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /** A câmera da cena para o movimento decidido. `lado` alterna o reenquadramento. */
102
+ function camera(movimento, dur, intensidade = 0.5, lado = 0, base = 1080) {
103
+ return (MOVIMENTOS[movimento] || MOVIMENTOS.parado)(dur, Math.max(0, Math.min(1, intensidade)), lado, base);
104
+ }
105
+
106
+ module.exports = { intensidades, decidir, camera, MOVIMENTOS };
@@ -26,6 +26,8 @@
26
26
 
27
27
  'use strict';
28
28
 
29
+ const decisao = require('./decisao.js');
30
+
29
31
  // ── as palavras que não carregam ideia ───────────────────────────────────
30
32
  const VAZIAS = new Set((
31
33
  'a o as os um uma uns umas de do da dos das no na nos nas em por para pra pro com sem que se e '
@@ -88,6 +90,25 @@ const ICONES = [
88
90
  [['surpre', 'chocad', 'absurd'], 'exploding-head'],
89
91
  ];
90
92
 
93
+ /* AS OUTRAS FORMAS DE DIZER O MESMO (07/10): "não quero que o sistema fique
94
+ repetindo os mesmos ícones". Cada conceito tinha UM emoji — todo vídeo de
95
+ dinheiro saía com o mesmo saco de dinheiro. O ícone automático continua
96
+ saindo daqui pelo primeiro nome (o de ICONES); a passada de variedade do
97
+ vídeo (index.js, variarIcones) troca pelo que os últimos vídeos menos
98
+ usaram, o mesmo conceito com o mesmo ícone dentro do vídeo. Nomes
99
+ conferidos no fluent-emoji-flat do Iconify em 07/10. */
100
+ const ALTERNATIVAS = {
101
+ robot: ['mechanical-arm', 'desktop-computer'], brain: ['thought-balloon', 'light-bulb'], 'light-bulb': ['sparkles', 'glowing-star', 'gem-stone'],
102
+ 'money-bag': ['dollar-banknote', 'coin', 'money-with-wings', 'credit-card'], stopwatch: ['hourglass-not-done', 'alarm-clock', 'mantelpiece-clock', 'watch'],
103
+ 'chart-increasing': ['seedling', 'mountain', 'chequered-flag'], 'bar-chart': ['clipboard', 'hundred-points'], rocket: ['high-voltage', 'airplane'],
104
+ fire: ['collision', 'high-voltage', 'dizzy'], 'check-mark-button': ['check-mark', 'thumbs-up', 'hundred-points'], 'cross-mark': ['stop-sign', 'no-entry'],
105
+ laptop: ['desktop-computer', 'keyboard'], 'globe-showing-americas': ['globe-with-meridians', 'satellite-antenna'], 'speaker-high-volume': ['microphone', 'headphone', 'megaphone'],
106
+ memo: ['pencil', 'open-book', 'clipboard'], books: ['open-book', 'pencil'], 'speech-balloon': ['speaking-head', 'thought-balloon', 'megaphone'],
107
+ gear: ['hammer-and-wrench', 'toolbox', 'link'], calendar: ['spiral-calendar'], trophy: ['sports-medal', 'crown', 'glowing-star'],
108
+ 'flexed-biceps': ['high-voltage'], handshake: ['people-hugging', 'link'], locked: ['shield', 'key'], briefcase: ['bank', 'classical-building'],
109
+ 'party-popper': ['confetti-ball', 'sparkles'], 'red-heart': ['sparkling-heart'], 'shopping-cart': ['receipt', 'credit-card'], 'clapper-board': ['video-camera', 'camera'],
110
+ };
111
+
91
112
  /* O termo de busca da IMAGEM DE APOIO, pelo emoji que a palavra ganhou. Em
92
113
  inglês de propósito: os acervos abertos respondem muito melhor assim. */
93
114
  const FOTO = {
@@ -225,14 +246,6 @@ function blocos(palavras, maximo) {
225
246
  return out;
226
247
  }
227
248
 
228
- // O zoom de cada cena, em rodízio: parado, punch-in, empurrando devagar.
229
- function camera(i, dur) {
230
- const tipo = i % 3;
231
- if (tipo === 0) return { zoom: [{ t: 0, v: 1 }, { t: dur, v: 1.04, e: 'linear' }] };
232
- if (tipo === 1) return { zoom: [{ t: 0, v: 1.18 }, { t: dur, v: 1.21, e: 'linear' }] };
233
- return { zoom: [{ t: 0, v: 1.07 }, { t: dur, v: 1.13, e: 'suave' }] };
234
- }
235
-
236
249
  const ESTILOS = {
237
250
  viral: { icones: true, letreiro: true, sons: true, barra: true, apoio: true, recorte: true, fundoRecorte: ['#1B1F3B', '#3A1C71'], destaque: '#FFD400', forte: '#00E0A4' },
238
251
  limpo: { icones: false, letreiro: false, sons: false, barra: false, apoio: true, recorte: false, fundoRecorte: ['#111111', '#333333'], destaque: '#FFFFFF', forte: '#FFD400' },
@@ -251,8 +264,6 @@ const ESTILOS = {
251
264
  * @param {object} op.sons { pop, whoosh } — caminhos na pasta do projeto, opcionais
252
265
  * @param {Set} op.iconesOk os nomes de ícone que existem de verdade (sem isso, todos valem)
253
266
  */
254
- // Em quais cenas a pessoa é recortada, com a palavra gigante atrás dela.
255
- const temRecorte = (i) => i % 4 === 2;
256
267
 
257
268
  function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null, imagens = {}, trilha = null, recortes = {}, marca = null }) {
258
269
  const e = ESTILOS[estilo] || ESTILOS.viral;
@@ -263,6 +274,10 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
263
274
  const base = Math.min(largura, altura);
264
275
  const grupos = frases(lista);
265
276
  const total = duracao || lista[lista.length - 1].ate + 0.3;
277
+ /* O QUE CADA FRASE PEDE (decisao.js): movimento, recorte e whoosh pela
278
+ intensidade dela, não pelo rodízio i % 3 — e depois do golpe, respiro. */
279
+ const decisoes = decisao.decidir(grupos, { peso });
280
+ let ladoQuadro = 0;
266
281
  const cenas = [];
267
282
  const efeitos = [];
268
283
  const infos = []; // onde cada cena começa e a palavra forte dela — para o recorte
@@ -283,12 +298,13 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
283
298
  deOrigem: r2(ini), ateOrigem: r2(ini + dur), volume: 1,
284
299
  }];
285
300
 
286
- infos.push({ ini: r2(ini), dur, forte: forte ? { de: local(forte.de), texto: forte.texto, peso: peso(forte.texto) } : null });
301
+ infos.push({ ini: r2(ini), dur, forte: forte ? { de: local(forte.de), texto: forte.texto, peso: peso(forte.texto) } : null,
302
+ recorte: decisoes[i].recorte, intensidade: decisoes[i].intensidade });
287
303
 
288
304
  /* A PALAVRA ATRÁS DA PESSOA: o fundo do quarto some, entra a cor da
289
305
  marca, a palavra forte aparece enorme e a pessoa, recortada, fica
290
306
  na frente dela. É o efeito que mais denuncia edição de videomaker. */
291
- const rec = e.recorte && temRecorte(i) ? recortes[i] : null;
307
+ const rec = e.recorte && decisoes[i].recorte ? recortes[i] : null;
292
308
  if (rec && forte) {
293
309
  const vida = r2(rec.ate - rec.de);
294
310
  const some = [{ t: 0, v: 0 }, { t: 0.1, v: 1 }, { t: vida - 0.12, v: 1 }, { t: vida, v: 0 }];
@@ -389,7 +405,8 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
389
405
  tipo: 'legenda', x: '50%', y: deitado ? '76%' : '68%', ancora: 'centro', fala,
390
406
  estilo: {
391
407
  fonte: 'Montserrat', peso: 900, tamanho: Math.round(base * (deitado ? 0.078 : 0.072)),
392
- destaque: e.destaque, corForte: e.forte, caixa: 'uppercase', largura: '92%',
408
+ // Caixa normal (o que a pessoa disse, como disse) e a palavra forte na serifa em itálico.
409
+ destaque: e.destaque, corForte: e.forte, fonteForte: 'Instrument Serif', largura: '92%',
393
410
  },
394
411
  });
395
412
 
@@ -402,10 +419,12 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
402
419
  });
403
420
  }
404
421
 
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 });
422
+ // O whoosh entra no soco: é o corte que ele anuncia (e só nele).
423
+ if (e.sons && sons.whoosh && decisoes[i].whoosh) efeitos.push({ t: r2(Math.max(0, ini - 0.12)), src: sons.whoosh, volume: 0.6 });
407
424
 
408
- cenas.push({ duracao: dur, camera: camera(i, dur), elementos, transicao: { tipo: 'corte' } });
425
+ const d = decisoes[i];
426
+ if (d.movimento === 'reenquadra') ladoQuadro = 1 - ladoQuadro;
427
+ cenas.push({ duracao: dur, camera: decisao.camera(d.movimento, dur, d.intensidade, ladoQuadro, base), elementos, transicao: { tipo: 'corte' }, impacto: d.movimento === 'soco' || undefined });
409
428
  });
410
429
 
411
430
  return {
@@ -472,4 +491,4 @@ async function prepararSons(pasta, video) {
472
491
  return sons;
473
492
  }
474
493
 
475
- module.exports = { montarEdicao, temRecorte, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, rotaDeApoio, DEPOIS_DA_PALAVRA, ESTILOS, ICONES, FOTO };
494
+ module.exports = { montarEdicao, ALTERNATIVAS, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, rotaDeApoio, DEPOIS_DA_PALAVRA, ESTILOS, ICONES, FOTO };