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.
- package/README.md +4 -4
- package/bin/primocode.js +25 -28
- package/lib/act.js +5 -2
- package/lib/catalogo.js +7 -4
- package/lib/config.js +10 -2
- package/lib/estudio/assets.js +86 -6
- package/lib/estudio/batida.js +184 -0
- package/lib/estudio/biblioteca.js +214 -0
- package/lib/estudio/cinema.js +37 -17
- package/lib/estudio/composicao.js +2 -1
- package/lib/estudio/decisao.js +106 -0
- package/lib/estudio/edicao.js +36 -17
- package/lib/estudio/estilo.js +35 -8
- package/lib/estudio/fluido.js +352 -0
- package/lib/estudio/gerativo.js +21 -3
- package/lib/estudio/hibrido.js +16 -12
- package/lib/estudio/impacto.js +66 -31
- package/lib/estudio/index.js +245 -42
- package/lib/estudio/kit/fluido.jsx +614 -0
- package/lib/estudio/kit/gerativo.jsx +15 -5
- package/lib/estudio/kit/index.jsx +2 -1
- package/lib/estudio/kit/tres.jsx +88 -21
- package/lib/estudio/kit.js +3 -1
- package/lib/estudio/kokoro.js +111 -0
- package/lib/estudio/lancamento.js +3 -4
- package/lib/estudio/legibilidade.js +20 -0
- package/lib/estudio/referencias.js +36 -25
- package/lib/estudio/remotion.js +53 -6
- package/lib/estudio/revisor.js +117 -0
- package/lib/estudio/variacao3d.js +46 -0
- package/lib/estudio/variedade.js +140 -8
- package/lib/portao-skill.js +150 -0
- package/lib/skills.js +15 -1
- package/lib/tools.js +31 -5
- package/lib/video/filters.js +6 -1
- package/lib/video/ops.js +4 -3
- package/lib/video/timeline.js +1 -1
- package/lib/video/zoom.js +275 -0
- package/package.json +2 -2
- package/skills/escrever-em-portugues/SKILL.md +3 -0
- package/skills/interface-revisada/SKILL.md +7 -1
- package/skills/motion-de-primeira/SKILL.md +3 -5
- package/skills/projeto-no-github/SKILL.md +6 -0
- package/skills/publicar-na-internet/SKILL.md +3 -0
- package/skills/site-com-motion/SKILL.md +2 -0
- 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 };
|
package/lib/estudio/cinema.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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.
|
|
190
|
-
const h = fit.tamanho * 1.
|
|
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
|
-
|
|
197
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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:
|
|
306
|
-
anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.25, v: 1 }], escala: [{ t: 0, v: 0.92 }, { t:
|
|
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:
|
|
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
|
-
|
|
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 };
|
package/lib/estudio/edicao.js
CHANGED
|
@@ -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 &&
|
|
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
|
-
|
|
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
|
|
406
|
-
if (e.sons && sons.whoosh && i
|
|
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
|
-
|
|
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,
|
|
494
|
+
module.exports = { montarEdicao, ALTERNATIVAS, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, rotaDeApoio, DEPOIS_DA_PALAVRA, ESTILOS, ICONES, FOTO };
|