primocode 10.3.0 → 10.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * margem.js — a área segura e o respiro: o que o vídeo NÃO deixa a IA fazer.
3
+ *
4
+ * "quero que os motions tenham uma barra que limita a IA de colocar itens
5
+ * perto das bordas ou deixar eles muito juntos" — Joel, 10/10, com os
6
+ * quadros do claude-ai-motion: "pensam" encostado na borda esquerda, o
7
+ * "claude.ai" cortado, o "{ }" comido na direita.
8
+ *
9
+ * Não é dica no prompt (o modelo esquece); é conta, antes do render:
10
+ *
11
+ * área segura nada visível a menos de 6% da largura e 7% da altura da
12
+ * borda (o "title safe" da TV). O que passa é empurrado para
13
+ * dentro; o texto largo demais ENCOLHE até caber.
14
+ * respiro dois elementos no ar ao mesmo tempo ficam a pelo menos 4%
15
+ * do lado curto um do outro; colados, o de baixo (o que veio
16
+ * depois na lista) desce, ou sobe se não couber.
17
+ * fundo livre o que é fundo — padrão, textura, partículas, a forma
18
+ * gigante, a imagem de ponta a ponta, o que vem marcado com
19
+ * fundo/decorativo/sangria — não entra na conta.
20
+ *
21
+ * A conta vale para a posição de REPOUSO (x, y somados ao último quadro-chave
22
+ * de anima.x/anima.y): a entrada que vem de fora da tela continua livre.
23
+ *
24
+ * Os estilos que desenham com câmera (o gerativo) não têm x, y no plano: o
25
+ * planner deles chama `limitesNaCamera`, que devolve a área segura no MUNDO
26
+ * já descontado o zoom, a deriva e a profundidade de cada batida.
27
+ *
28
+ * Puro: o plano entra, o plano guardado e a lista do que mudou saem.
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ /* As regras, em fração do quadro. Os testes leem daqui. */
34
+ const MARGEM = { x: 0.06, y: 0.07, vao: 0.04 };
35
+
36
+ const r1 = (n) => Math.round(n * 10) / 10;
37
+
38
+ /** A área segura do quadro, em pixels. */
39
+ function areaSegura(W, H, m = MARGEM) {
40
+ return { x0: Math.round(W * m.x), y0: Math.round(H * m.y), x1: Math.round(W * (1 - m.x)), y1: Math.round(H * (1 - m.y)) };
41
+ }
42
+
43
+ // ── o que é fundo ──────────────────────────────────────────────────────
44
+ /* As peças do kit que são o QUADRO inteiro (textura, padrão, o gerativo que
45
+ tem a própria conta) ou enfeite de fundo. */
46
+ const PECAS_FUNDO = new Set(['textura', 'padrao', 'particulas', 'papel', 'grade', 'orbe', 'cromo', 'esfera', 'estrela',
47
+ 'gerativo', 'fluido', 'personagem', 'site']);
48
+
49
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
50
+
51
+ function ehFundo(el, W, H) {
52
+ if (!el || typeof el !== 'object') return true;
53
+ if (el.fundo === true || el.decorativo || el.sangria || el.livre) return true;
54
+ if (el.tipo === 'kit') return PECAS_FUNDO.has(el.peca) || num(el.x) == null || num(el.y) == null;
55
+ // A forma é desenho (o protagonista, a partícula, o cursor): quem decide onde ela vive é o estilo.
56
+ if (el.tipo === 'forma' || el.tipo === 'legenda' || el.tipo === 'componente') return true;
57
+ if (num(el.x) == null || num(el.y) == null) return true;
58
+ const w = medidaPx(el.largura, W), h = medidaPx(el.altura, H);
59
+ // A imagem (ou o grupo) de ponta a ponta é fundo: a borda dela É a borda do quadro.
60
+ if ((w != null && w >= W * 0.9) || (h != null && h >= H * 0.9)) return true;
61
+ const op = num(el.opacidade);
62
+ if (op != null && op <= 0.35) return true;
63
+ return false;
64
+ }
65
+
66
+ function medidaPx(v, total) {
67
+ if (typeof v === 'number') return v;
68
+ const m = String(v == null ? '' : v).match(/^(-?[\d.]+)%$/);
69
+ return m ? (Number(m[1]) / 100) * total : null;
70
+ }
71
+
72
+ // ── a caixa de cada elemento, em repouso ───────────────────────────────
73
+ const ultimo = (ks) => (Array.isArray(ks) && ks.length ? Number(ks[ks.length - 1].v) || 0 : 0);
74
+
75
+ /* A largura de um texto sem medir: 0,56 em por caractere é a média das fontes
76
+ do Estúdio (a mesma conta de composicao.caber). Erra para mais na fonte
77
+ estreita — que é o lado seguro. */
78
+ const EM = 0.56;
79
+ function textoDe(el) {
80
+ if (typeof el.texto === 'string') return el.texto;
81
+ if (Array.isArray(el.palavras)) return el.palavras.map((p) => (typeof p === 'string' ? p : p.texto || '')).join(' ');
82
+ if (Array.isArray(el.linhas)) return el.linhas.map((p) => (typeof p === 'string' ? p : p.texto || '')).join('\n');
83
+ return el.termo || el.titulo || '';
84
+ }
85
+
86
+ /** {x, y, w, h} do elemento parado no lugar dele — ou null quando não dá para saber. */
87
+ function caixaDe(el, W, H) {
88
+ const tam = num(el.tamanho) || (el.tipo === 'texto' || el.tipo === 'kit' ? 64 : null);
89
+ let w = medidaPx(el.largura, W), h = medidaPx(el.altura, H);
90
+ const txt = textoDe(el);
91
+ if (txt && tam) {
92
+ const linhas = String(txt).split('\n');
93
+ const maior = Math.max(...linhas.map((l) => l.length));
94
+ const corrido = maior * tam * EM;
95
+ const larg = w != null ? Math.min(w, corrido) : corrido;
96
+ const quebras = w != null ? linhas.reduce((n, l) => n + Math.max(1, Math.ceil((l.length * tam * EM) / Math.max(1, w))), 0) : linhas.length;
97
+ w = w != null && corrido > w ? w : larg;
98
+ if (h == null) h = quebras * tam * (num(el.entrelinha) || 1.1);
99
+ }
100
+ if (el.tipo === 'icone' && w == null) w = h = num(el.tamanho) || 96;
101
+ if (w == null || h == null) return null;
102
+ const ancora = el.ancora || (el.tipo === 'kit' && (el.alinhamento === 'centro' || el.centro) ? 'centro-x' : 'canto');
103
+ // O texto centrado sem caixa cresce para os dois lados do x.
104
+ let x = el.x + ultimo(el.anima && el.anima.x), y = el.y + ultimo(el.anima && el.anima.y);
105
+ const esc = el.anima && el.anima.escala ? ultimo(el.anima.escala) || 1 : 1;
106
+ w *= esc; h *= esc;
107
+ if (ancora === 'centro' || ancora === 'centro-x') x -= w / 2;
108
+ if (ancora === 'centro' || ancora === 'centro-y') y -= h / 2;
109
+ return { x, y, w, h };
110
+ }
111
+
112
+ const noAr = (el, duracao) => [num(el.de) ?? 0, num(el.ate) ?? duracao];
113
+ const cruzam = (a, b) => a[0] < b[1] && b[0] < a[1];
114
+
115
+ /**
116
+ * Guarda UMA cena: encolhe, empurra para dentro e separa.
117
+ * @returns {string[]} o que mudou, em português, para as notas do projeto
118
+ */
119
+ function guardarCena(cena, W, H, { indice = 0, m = MARGEM } = {}) {
120
+ const area = areaSegura(W, H, m);
121
+ const vao = Math.round(Math.min(W, H) * m.vao);
122
+ const lw = area.x1 - area.x0, lh = area.y1 - area.y0;
123
+ const mudou = [];
124
+ const vivos = [];
125
+ (cena.elementos || []).forEach((el, j) => {
126
+ if (ehFundo(el, W, H)) return;
127
+ let cx = caixaDe(el, W, H);
128
+ if (!cx) return;
129
+ const nome = `cena ${indice + 1}, ${el.tipo === 'kit' ? el.peca : el.tipo} ${j + 1}`;
130
+ // 1. Largo ou alto demais: o texto encolhe a fonte; a caixa encolhe a medida.
131
+ const k = Math.min(1, lw / cx.w, lh / cx.h);
132
+ if (k < 0.999) {
133
+ if (num(el.tamanho)) el.tamanho = Math.max(12, Math.floor(el.tamanho * k));
134
+ const wl = medidaPx(el.largura, W), hl = medidaPx(el.altura, H);
135
+ if (wl != null) el.largura = Math.floor(Math.min(wl, lw));
136
+ if (hl != null && !num(el.tamanho)) el.altura = Math.floor(hl * k);
137
+ if (wl != null && hl != null && !num(el.tamanho) && wl > lw) el.altura = Math.floor(hl * (lw / wl));
138
+ cx = caixaDe(el, W, H) || cx;
139
+ mudou.push(`${nome}: grande demais para a área segura — encolheu ${Math.round((1 - k) * 100)}%`);
140
+ }
141
+ // 2. Para dentro da área segura.
142
+ const dx = cx.x < area.x0 ? area.x0 - cx.x : cx.x + cx.w > area.x1 ? area.x1 - (cx.x + cx.w) : 0;
143
+ const dy = cx.y < area.y0 ? area.y0 - cx.y : cx.y + cx.h > area.y1 ? area.y1 - (cx.y + cx.h) : 0;
144
+ if (Math.abs(dx) >= 1 || Math.abs(dy) >= 1) {
145
+ el.x = r1(el.x + dx); el.y = r1(el.y + dy);
146
+ cx = { ...cx, x: cx.x + dx, y: cx.y + dy };
147
+ mudou.push(`${nome}: encostava na borda — entrou ${Math.round(Math.hypot(dx, dy))} px`);
148
+ }
149
+ vivos.push({ el, cx, nome, quando: noAr(el, Number(cena.duracao) || 0) });
150
+ });
151
+
152
+ // 3. O respiro: quem veio depois cede o lugar, de cima para baixo.
153
+ for (let i = 1; i < vivos.length; i++) {
154
+ const b = vivos[i];
155
+ for (let a = 0; a < i; a++) {
156
+ const A = vivos[a];
157
+ if (!cruzam(A.quando, b.quando)) continue;
158
+ const sx = Math.min(A.cx.x + A.cx.w, b.cx.x + b.cx.w) - Math.max(A.cx.x, b.cx.x) + vao;
159
+ const sy = Math.min(A.cx.y + A.cx.h, b.cx.y + b.cx.h) - Math.max(A.cx.y, b.cx.y) + vao;
160
+ if (sx <= 0 || sy <= 0) continue;
161
+ // Desce para baixo de A; sem lugar embaixo, sobe para cima dele; sem os dois, vai para o lado.
162
+ const desce = A.cx.y + A.cx.h + vao - b.cx.y, sobe = A.cx.y - vao - (b.cx.y + b.cx.h);
163
+ const dir = A.cx.x + A.cx.w + vao - b.cx.x, esq = A.cx.x - vao - (b.cx.x + b.cx.w);
164
+ const opcoes = [[0, desce], [0, sobe], [dir, 0], [esq, 0]]
165
+ .filter(([ox, oy]) => b.cx.x + ox >= area.x0 - 0.5 && b.cx.x + b.cx.w + ox <= area.x1 + 0.5 && b.cx.y + oy >= area.y0 - 0.5 && b.cx.y + b.cx.h + oy <= area.y1 + 0.5);
166
+ if (!opcoes.length) { mudou.push(`${b.nome}: colado em outro e sem lugar livre — ficou (revise a cena)`); continue; }
167
+ const [ox, oy] = opcoes.sort((p, q) => Math.hypot(...p) - Math.hypot(...q))[0];
168
+ b.el.x = r1(b.el.x + ox); b.el.y = r1(b.el.y + oy);
169
+ b.cx = { ...b.cx, x: b.cx.x + ox, y: b.cx.y + oy };
170
+ mudou.push(`${b.nome}: colado em outro — afastou ${Math.round(Math.hypot(ox, oy))} px`);
171
+ }
172
+ }
173
+ return mudou;
174
+ }
175
+
176
+ /** O plano inteiro. Muda o plano no lugar e devolve {plano, consertos}. */
177
+ function guardarPlano(plano, op = {}) {
178
+ const W = (plano && plano.formato && plano.formato.largura) || 1920;
179
+ const H = (plano && plano.formato && plano.formato.altura) || 1080;
180
+ const consertos = [];
181
+ ((plano && plano.cenas) || []).forEach((c, i) => { consertos.push(...guardarCena(c, W, H, { ...op, indice: i })); });
182
+ return { plano, consertos };
183
+ }
184
+
185
+ // ── a área segura vista por uma câmera que se mexe ─────────────────────
186
+ function camEm(ks, t) {
187
+ if (!ks.length) return { x: 0, y: 0, zoom: 1, rot: 0 };
188
+ if (t <= ks[0].t) return ks[0];
189
+ for (let i = 0; i < ks.length - 1; i++) {
190
+ const a = ks[i], b = ks[i + 1];
191
+ if (t >= a.t && t < b.t) {
192
+ if (b.e === 'degrau' || b.t - a.t < 0.001) return b;
193
+ const u = (t - a.t) / (b.t - a.t);
194
+ return { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u, zoom: a.zoom + (b.zoom - a.zoom) * u, rot: a.rot + (b.rot - a.rot) * u };
195
+ }
196
+ }
197
+ return ks[ks.length - 1];
198
+ }
199
+
200
+ /**
201
+ * Onde, no MUNDO, um bloco pode ficar para que, na TELA, ele não saia da área
202
+ * segura enquanto está parado — com a câmera daquele trecho.
203
+ *
204
+ * A tela vê o mundo assim (kit/gerativo.jsx): centro + zoom × perspectiva ×
205
+ * (ponto − centro − câmera). Então o limite da tela, desfeito, é o limite do
206
+ * mundo. Somam-se a deriva de mão (0,6% × 0,5%), a respiração (0,8%), o giro
207
+ * da câmera e o rotateY do mundo (2% a mais de escala, de folga).
208
+ *
209
+ * @param {object} op
210
+ * @param {number} op.W, op.H o quadro
211
+ * @param {Array} op.camera [{t, x, y, zoom, rot, e}]
212
+ * @param {number} op.de, op.ate o trecho em que o bloco está PARADO
213
+ * @param {number} op.z a profundidade do bloco (px para a lente)
214
+ * @param {number} op.D a distância da perspectiva (px)
215
+ */
216
+ function limitesNaCamera({ W, H, camera = [], de, ate, z = 0, D = Math.round(Math.max(W, H) * 0.85), m = MARGEM }) {
217
+ const mx = W * m.x, my = H * m.y;
218
+ const amostras = [de, ate, ...camera.filter((k) => k.t > de && k.t < ate).map((k) => k.t)];
219
+ const persp = D / Math.max(1, D - z);
220
+ let x0 = -Infinity, x1 = Infinity, y0 = -Infinity, y1 = Infinity;
221
+ for (const t of amostras) {
222
+ const c = camEm(camera, t);
223
+ const s = (c.zoom || 1) * 1.008 * persp * 1.02;
224
+ const giro = Math.abs(Math.sin(((c.rot || 0) * Math.PI) / 180));
225
+ // O giro tira do lugar o canto do bloco, não o do quadro: meia altura do bloco basta (¼ do quadro).
226
+ const folgaX = W * 0.006 + (H / 4) * giro, folgaY = H * 0.005 + (W / 4) * giro;
227
+ x0 = Math.max(x0, W / 2 + c.x - (W / 2 - mx) / s + folgaX);
228
+ x1 = Math.min(x1, W / 2 + c.x + (W / 2 - mx) / s - folgaX);
229
+ y0 = Math.max(y0, H / 2 + c.y - (H / 2 - my) / s + folgaY);
230
+ y1 = Math.min(y1, H / 2 + c.y + (H / 2 - my) / s - folgaY);
231
+ }
232
+ return { x0: r1(x0), x1: r1(x1), y0: r1(y0), y1: r1(y1) };
233
+ }
234
+
235
+ module.exports = { MARGEM, areaSegura, ehFundo, caixaDe, guardarCena, guardarPlano, limitesNaCamera, PECAS_FUNDO };
@@ -0,0 +1,232 @@
1
+ /**
2
+ * personagem.js — o vídeo GRAVADO em 3D: personagens que falam, com câmera de filme.
3
+ *
4
+ * "uma animação em 3D do Claude falando com o usuário sobre por que ele gasta
5
+ * token demais ... como se fosse gravado mesmo, só que tudo em 3D"
6
+ * (Joel, 10/10)
7
+ *
8
+ * Até aqui o 3D do Estúdio era objeto girando numa batida (kit/tres.jsx). Aqui
9
+ * o vídeo inteiro é uma cena: um set (podcast, escritório, estúdio), um ou dois
10
+ * personagens modelados de geometria (gente ou robô), a boca que segue a voz
11
+ * letra por letra, piscada, sobrancelha, cabeça que vira para quem fala, gesto
12
+ * na palavra forte, e a câmera que corta como num programa gravado — plano
13
+ * aberto, ombro, close, detalhe — com o leve balanço de câmera na mão.
14
+ *
15
+ * Toda decisão (quem fala quando, que plano em cada trecho, a aparência de
16
+ * cada um, o destaque na tela) é tomada AQUI, no node, e testada; o
17
+ * kit/personagem.jsx só desenha o que a partitura manda.
18
+ *
19
+ * O contrato do roteiro (tudo opcional, além de partes[].texto):
20
+ * parte.falante = "claude" (falantes.js: uma voz por falante)
21
+ * parte.emocao = feliz|serio|surpreso|pensativo|animado|curioso
22
+ * parte.destaque = "70% menos tokens" (o cartão que aparece na fala)
23
+ * roteiro.personagens = { "claude": { tipo: "robo", cor: "#..." },
24
+ * "joel": { tipo: "pessoa", pele, cabelo, corCabelo, roupa } }
25
+ * roteiro.cenario = podcast|escritorio|estudio|noite
26
+ */
27
+
28
+ 'use strict';
29
+
30
+ const { volumesDaTrilha } = require('./ritmo.js');
31
+
32
+ const FPS = 30;
33
+ const r2 = (x) => Math.round(x * 100) / 100;
34
+
35
+ // Nome que é de máquina vira robô, se o roteiro não disse o tipo.
36
+ const EH_ROBO = /\b(ia|ai|assistente|rob[oô]|bot|claude|gpt|chatgpt|gemini|primo|primocode|copilot|agente|maquina|máquina|computador)\b/i;
37
+
38
+ const PELES = ['#f2c9a6', '#e3b08a', '#c98d66', '#9a6342', '#6b4229'];
39
+ const CABELOS = ['curto', 'cacheado', 'longo', 'topete', 'coque', 'careca'];
40
+ const CORES_CABELO = ['#1d1612', '#3b2618', '#6b4423', '#b5895a', '#d8c39a', '#2b2b30'];
41
+ const ROUPAS = ['#2f4cdd', '#1f8a5b', '#e0e0e6', '#d0452f', '#24262d', '#f1b434', '#7b5cff'];
42
+ const ROBOS = ['#eef0f4', '#f4e9dc', '#dfe8f3', '#2a2d35'];
43
+
44
+ /* Os sets. Cada um: parede, segunda parede (painéis), chão, acento (neon e
45
+ luz de recorte), a mesa e o que mais há no fundo. Joel gosta de verde e azul. */
46
+ const CENARIOS = {
47
+ podcast: { parede: '#16202e', parede2: '#1d2a3c', chao: '#2a2522', acento: '#3ddc97', luz: '#ffd9a8', mesa: true, microfones: true, neon: 'NO AR' },
48
+ estudio: { parede: '#0f1d2a', parede2: '#132838', chao: '#1b1d22', acento: '#2fa4ff', luz: '#e6f1ff', mesa: true, microfones: false, neon: '' },
49
+ escritorio: { parede: '#d9d2c5', parede2: '#cbc2b2', chao: '#8b6a4a', acento: '#1f8a5b', luz: '#fff1dc', mesa: true, microfones: false, neon: '' },
50
+ noite: { parede: '#120f1f', parede2: '#1a1530', chao: '#141218', acento: '#7b5cff', luz: '#c8b8ff', mesa: true, microfones: true, neon: 'ON' },
51
+ };
52
+
53
+ /** Um número de 0 a 1 a partir de um texto: a mesma pessoa sai sempre igual. */
54
+ function hashDe(txt) {
55
+ let h = 2166136261;
56
+ for (const c of String(txt)) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); }
57
+ return (h >>> 0) / 4294967296;
58
+ }
59
+ const pegar = (lista, k) => lista[Math.floor(k * lista.length) % lista.length];
60
+
61
+ function rng(semente) {
62
+ let s = (Number(semente) >>> 0) || 1;
63
+ return () => { s = (Math.imul(s ^ (s >>> 15), 2246822507) + 0x9e3779b9) >>> 0; return s / 4294967296; };
64
+ }
65
+
66
+ /** A aparência de quem fala: o que o roteiro disse vale; o resto sai do nome. */
67
+ function aparencia(nome, pedido = {}) {
68
+ const k = (s) => hashDe(nome + ':' + s);
69
+ const tipo = pedido.tipo === 'robo' || pedido.tipo === 'pessoa' ? pedido.tipo : (EH_ROBO.test(nome) ? 'robo' : 'pessoa');
70
+ const rotulo = String(pedido.rotulo || pedido.nome || nome || '').trim().slice(0, 24);
71
+ if (tipo === 'robo') {
72
+ return { nome, rotulo, tipo, cor: pedido.cor || pegar(ROBOS, k('cor')), olhos: pedido.olhos || null };
73
+ }
74
+ return {
75
+ nome, rotulo, tipo,
76
+ pele: pedido.pele || pegar(PELES, k('pele')),
77
+ cabelo: CABELOS.includes(pedido.cabelo) ? pedido.cabelo : pegar(CABELOS, k('cabelo')),
78
+ corCabelo: pedido.corCabelo || pegar(CORES_CABELO, k('corCabelo')),
79
+ roupa: pedido.roupa || pegar(ROUPAS, k('roupa')),
80
+ oculos: pedido.oculos != null ? !!pedido.oculos : k('oculos') < 0.3,
81
+ barba: !!pedido.barba, // só quando pedida: sorteada, ficava estranha em quem tem cabelo longo
82
+ };
83
+ }
84
+
85
+ const EMOCOES = ['neutro', 'feliz', 'serio', 'surpreso', 'pensativo', 'animado', 'curioso'];
86
+ function emocaoDe(pt, texto) {
87
+ const e = String(pt.emocao || '').toLowerCase();
88
+ if (EMOCOES.includes(e)) return e;
89
+ if (/\?\s*$/.test(texto)) return 'curioso';
90
+ if (/!\s*$/.test(texto)) return 'animado';
91
+ return 'neutro';
92
+ }
93
+
94
+ /** O cartão da fala: o que o roteiro deu, ou o primeiro número que a fala diz. */
95
+ function destaqueDe(pt, texto) {
96
+ if (typeof pt.destaque === 'string' && pt.destaque.trim()) return pt.destaque.trim().slice(0, 40);
97
+ if (pt.destaque === false) return null;
98
+ const m = /(R\$\s?\d[\d.,]*|\d+[\d.,]*\s?(%|x|mil|milh[õo]es|tokens?|vezes|segundos|minutos|horas))/i.exec(texto);
99
+ return m ? m[1].trim() : null;
100
+ }
101
+
102
+ const falaDe = (pt) => String(pt.texto || pt.fala || pt.narracao || pt.titulo || '').trim();
103
+
104
+ /* Sem os tempos da voz, cada fala ganha o tempo de leitura (2,6 palavras/s)
105
+ e as palavras se repartem pelo tamanho. */
106
+ function temposEstimados(partes) {
107
+ let t = 0.4;
108
+ return partes.map((pt) => {
109
+ const ws = falaDe(pt).split(/\s+/).filter(Boolean);
110
+ const dur = Math.max(1.2, ws.length / 2.6);
111
+ const letras = ws.reduce((n, w) => n + w.length, 0) || 1;
112
+ let a = t;
113
+ const palavras = ws.map((w) => { const d = (w.length / letras) * dur; const p = { texto: w, de: a, ate: a + d }; a += d; return p; });
114
+ const r = { de: t, ate: t + dur, palavras };
115
+ t += dur + 0.35;
116
+ return r;
117
+ });
118
+ }
119
+
120
+ /* OS PLANOS. A gramática de programa gravado: abre no aberto, corta no
121
+ começo de cada fala (um tico antes da voz), alterna ombro e close na troca
122
+ de quem fala, volta ao aberto de tempos em tempos, e fala longa ganha um
123
+ segundo ângulo no meio, numa fronteira de palavra. */
124
+ function planosDe(falas, { dois, duracao, aleatorio }) {
125
+ const planos = [];
126
+ const MOV = ['entra', 'fixo', 'lateral', 'entra', 'sai'];
127
+ let ultimoTipo = null;
128
+ const por = (de, tipo, alvo) => {
129
+ if (planos.length && de - planos[planos.length - 1].de < 0.9) { // corte colado no anterior: troca o anterior
130
+ const p = planos[planos.length - 1]; p.tipo = tipo; p.alvo = alvo; return;
131
+ }
132
+ planos.push({ de: r2(Math.max(0, de)), tipo, alvo, mov: MOV[Math.floor(aleatorio() * MOV.length)], lado: aleatorio() < 0.5 ? -1 : 1 });
133
+ ultimoTipo = tipo;
134
+ };
135
+ por(0, dois ? 'aberto' : 'medio', 0);
136
+ falas.forEach((f, i) => {
137
+ const anterior = falas[i - 1];
138
+ const troca = !anterior || anterior.quem !== f.quem;
139
+ let tipo;
140
+ if (i === 0) tipo = dois ? 'aberto' : 'medio';
141
+ else if (i % 4 === 0) tipo = dois ? 'aberto' : 'largo';
142
+ else if (dois && troca) tipo = ultimoTipo === 'ombro' ? 'close' : 'ombro';
143
+ else tipo = ultimoTipo === 'close' ? 'medio' : 'close';
144
+ if (i > 0) por(f.de - 0.1, tipo, f.quem);
145
+ else if (planos[0].tipo !== tipo) planos[0].tipo = tipo;
146
+ // Fala longa: um segundo ângulo no meio, colado numa palavra.
147
+ if (f.ate - f.de > 5.5 && f.palavras.length > 6) {
148
+ const meio = (f.de + f.ate) / 2;
149
+ const w = f.palavras.reduce((b, x) => (Math.abs(x.de - meio) < Math.abs(b.de - meio) ? x : b), f.palavras[0]);
150
+ por(w.de - 0.05, tipo === 'close' ? 'detalhe' : 'close', f.quem);
151
+ }
152
+ });
153
+ for (let k = 0; k < planos.length; k++) planos[k].ate = r2(k + 1 < planos.length ? planos[k + 1].de : duracao);
154
+ return planos;
155
+ }
156
+
157
+ /** A partitura inteira: o que o kit/personagem.jsx desenha, quadro a quadro. */
158
+ function partituraDe({ roteiro = {}, largura = 1920, altura = 1080, tempos = [] }) {
159
+ const partes = (roteiro.partes || []).filter((p) => p && typeof p === 'object' && falaDe(p));
160
+ const ts = tempos.length === partes.length ? tempos : temposEstimados(partes);
161
+ const semente = roteiro.semente != null ? roteiro.semente : Math.floor(hashDe(partes.map(falaDe).join('|')) * 1e9);
162
+ const aleatorio = rng(semente);
163
+
164
+ // O elenco, na ordem em que fala. Sem falante, um apresentador só.
165
+ const nomes = [];
166
+ for (const pt of partes) {
167
+ const f = typeof pt.falante === 'string' && pt.falante.trim() ? pt.falante.trim().toLowerCase() : 'apresentador';
168
+ if (!nomes.includes(f)) nomes.push(f);
169
+ }
170
+ const pedidos = roteiro.personagens && typeof roteiro.personagens === 'object' ? roteiro.personagens : {};
171
+ const pedidoDe = (n) => pedidos[n] || pedidos[Object.keys(pedidos).find((k) => k.toLowerCase() === n)] || {};
172
+ // No set cabem dois; o terceiro em diante divide o lugar (raro num diálogo).
173
+ const elenco = nomes.slice(0, 2).map((n, i) => ({ ...aparencia(n, pedidoDe(n)), lado: nomes.length > 1 ? (i === 0 ? -1 : 1) : 0 }));
174
+ const quemDe = (pt) => {
175
+ const f = typeof pt.falante === 'string' && pt.falante.trim() ? pt.falante.trim().toLowerCase() : 'apresentador';
176
+ return nomes.indexOf(f) % Math.max(1, elenco.length);
177
+ };
178
+
179
+ const falas = partes.map((pt, i) => {
180
+ const t = ts[i] || {};
181
+ const texto = falaDe(pt);
182
+ const palavras = (t.palavras && t.palavras.length ? t.palavras : temposEstimados([pt])[0].palavras.map((w) => ({ ...w, de: w.de - 0.4 + (t.de || 0), ate: w.ate - 0.4 + (t.de || 0) })))
183
+ .map((w) => ({ t: String(w.texto || w.word || ''), de: r2(w.de), ate: r2(w.ate) }));
184
+ return {
185
+ quem: quemDe(pt), de: r2(t.de != null ? t.de : (palavras[0] || {}).de || 0), ate: r2(t.ate != null ? t.ate : (palavras[palavras.length - 1] || {}).ate || 0),
186
+ texto, palavras, emocao: emocaoDe(pt, texto), destaque: destaqueDe(pt, texto),
187
+ };
188
+ });
189
+ const duracao = r2((falas.length ? falas[falas.length - 1].ate : 3) + 1.2);
190
+
191
+ const nomeCen = CENARIOS[String(roteiro.cenario || '').toLowerCase()] ? String(roteiro.cenario).toLowerCase()
192
+ : pegar(Object.keys(CENARIOS), aleatorio());
193
+ const cenario = { nome: nomeCen, ...CENARIOS[nomeCen] };
194
+ if (roteiro.acento && /^#[0-9a-f]{6}$/i.test(roteiro.acento)) cenario.acento = roteiro.acento;
195
+
196
+ const planos = planosDe(falas, { dois: elenco.length > 1, duracao, aleatorio });
197
+ return {
198
+ versao: 1, fps: FPS, largura, altura, duracao, semente, cenario, elenco, falas, planos,
199
+ legenda: roteiro.legenda !== false,
200
+ titulo: typeof roteiro.titulo === 'string' ? roteiro.titulo.slice(0, 60) : '',
201
+ filme: { grao: roteiro.grao !== false, vinheta: true },
202
+ };
203
+ }
204
+
205
+ function montarPersonagem({ roteiro, largura = 1920, altura = 1080, tempos = [], narracao = null, sons = {}, trilha = null }) {
206
+ const partes = ((roteiro && roteiro.partes) || []).filter((p) => p && typeof p === 'object');
207
+ if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
208
+ const partitura = partituraDe({ roteiro: { ...roteiro, partes }, largura, altura, tempos });
209
+ if (!partitura.falas.length) return { ok: false, error: 'o roteiro veio sem fala: o personagem precisa de texto em cada parte.' };
210
+ const DUR = partitura.duracao;
211
+ const efeitos = [];
212
+ const som = (t, nome, volume) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, t)), src: sons[nome], volume }); };
213
+ for (const f of partitura.falas) if (f.destaque) som(f.de + 0.3, 'pop', 0.16);
214
+ // A música bem baixa: é conversa, a voz manda.
215
+ const volumes = volumesDaTrilha(tempos.length === partes.length ? tempos : [], DUR, { alto: 0.32, baixo: 0.09 });
216
+ const plano = {
217
+ formato: { largura, altura, fps: FPS },
218
+ fundoGeral: partitura.cenario.parede,
219
+ cenas: [{ duracao: DUR, fundo: partitura.cenario.parede, elementos: [{ tipo: 'kit', peca: 'personagem', partitura, x: 0, y: 0, largura, altura }] }],
220
+ audio: {
221
+ ...(narracao ? { narracao } : {}),
222
+ ...(trilha ? { trilha: { src: trilha, volumes, loop: true } } : {}),
223
+ efeitos,
224
+ },
225
+ };
226
+ return {
227
+ ok: true, plano, partitura,
228
+ resumo: { cenas: 1, segundos: DUR, fps: FPS, cenario: partitura.cenario.nome, elenco: partitura.elenco.map((e) => `${e.rotulo} (${e.tipo})`), planos: partitura.planos.length, falas: partitura.falas.length },
229
+ };
230
+ }
231
+
232
+ module.exports = { montarPersonagem, partituraDe, planosDe, aparencia, destaqueDe, emocaoDe, CENARIOS, EH_ROBO, FPS };
@@ -149,4 +149,193 @@ function elementoDoPrint(print, { quadroW = 1920, quadroH = 1080, t = 0 } = {})
149
149
  };
150
150
  }
151
151
 
152
- module.exports = { capturarSite, elementoDoPrint, normalizarUrl, TELAS, ALTURA_MAX };
152
+
153
+ /* ── O ELEMENTO DO SITE, TIRADO PELO CÓDIGO ───────────────────────────────
154
+ *
155
+ * "tirar uma coisa, recortar o fundo, deixar só um elemento do site, ou ele
156
+ * extrair por código — ele lê o código do site, pega os elementos e põe
157
+ * no vídeo" (Joel, 10/10)
158
+ *
159
+ * O print inteiro mostra a página; o vídeo muitas vezes quer UMA peça dela: o
160
+ * botão de assinar, o cartão de preço, a logo, o título. Aqui o navegador
161
+ * escondido lê o DOM e devolve as peças que dá para tirar (com o seletor de
162
+ * cada uma), e depois tira a escolhida SOZINHA, com o fundo transparente:
163
+ * tudo na página fica invisível menos ela (visibility, que não mexe no
164
+ * layout), e o print sai em PNG com alfa, no dobro da resolução. Junto vem o
165
+ * código dela (o HTML e o estilo calculado: fonte, cor, raio, sombra), para o
166
+ * modelo poder também REDESENHAR a peça como componente, se preferir. */
167
+
168
+ // O seletor único de um elemento, curto e legível (id > classes > posição).
169
+ const SELETOR_JS = `function __sel(el) {
170
+ if (el.id && /^[a-z][\\w-]*$/i.test(el.id) && document.querySelectorAll('#' + el.id).length === 1) return '#' + el.id;
171
+ const partes = []; let e = el;
172
+ while (e && e.nodeType === 1 && e !== document.body && partes.length < 7) {
173
+ let s = e.tagName.toLowerCase();
174
+ const cls = [...e.classList].filter((c) => /^[a-z][\\w-]{1,30}$/i.test(c)).slice(0, 2);
175
+ if (cls.length) s += '.' + cls.join('.');
176
+ const pai = e.parentElement;
177
+ if (pai) { const irm = [...pai.children].filter((x) => x.tagName === e.tagName); if (irm.length > 1) s += ':nth-of-type(' + (irm.indexOf(e) + 1) + ')'; }
178
+ partes.unshift(s);
179
+ try { if (document.querySelectorAll(partes.join(' > ')).length === 1) return partes.join(' > '); } catch (_) {}
180
+ e = pai;
181
+ }
182
+ return partes.join(' > ');
183
+ }`;
184
+
185
+ const LISTAR = `(() => {
186
+ ${SELETOR_JS}
187
+ const vis = (el) => { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el);
188
+ return r.width > 20 && r.height > 12 && cs.visibility !== 'hidden' && cs.display !== 'none' && +cs.opacity > 0.05; };
189
+ const txt = (el) => String(el.innerText || el.getAttribute('alt') || el.getAttribute('aria-label') || '').replace(/\\s+/g, ' ').trim().slice(0, 70);
190
+ const out = [], vistos = new Set();
191
+ const por = (el, tipo) => {
192
+ if (!el || vistos.has(el) || !vis(el)) return;
193
+ vistos.add(el);
194
+ const r = el.getBoundingClientRect();
195
+ out.push({ tipo, seletor: __sel(el), texto: txt(el), largura: Math.round(r.width), altura: Math.round(r.height), y: Math.round(r.top + scrollY) });
196
+ };
197
+ const logo = document.querySelector('header img[alt*="logo" i], header [class*="logo" i], img[alt*="logo" i], [class*="logo" i] img, [class*="logo" i] svg, header svg, header img');
198
+ por(logo, 'logo');
199
+ por(document.querySelector('nav') || document.querySelector('header'), 'menu');
200
+ const h1 = document.querySelector('h1');
201
+ por(h1, 'titulo');
202
+ if (h1) { let e = h1.parentElement; while (e && e !== document.body && e.getBoundingClientRect().height < innerHeight * 0.45) e = e.parentElement; if (e && e !== document.body) por(e, 'hero'); }
203
+ const botoes = [...document.querySelectorAll('a, button, [role=button]')].filter((b) => {
204
+ const cs = getComputedStyle(b), r = b.getBoundingClientRect();
205
+ const temCor = cs.backgroundColor && !/rgba\\(0, 0, 0, 0\\)|transparent/.test(cs.backgroundColor);
206
+ return txt(b) && r.width >= 70 && r.height >= 28 && r.height < 120 && (temCor || parseFloat(cs.borderWidth) > 0) && r.top + scrollY < innerHeight * 3;
207
+ });
208
+ botoes.slice(0, 5).forEach((b) => por(b, 'botao'));
209
+ // Cartões: três ou mais irmãos com a mesma classe e tamanho de cartão.
210
+ const grupos = new Map();
211
+ for (const el of document.querySelectorAll('body *')) {
212
+ if (!el.className || typeof el.className !== 'string' || !el.parentElement) continue;
213
+ const k = el.parentElement.tagName + '|' + el.className;
214
+ const r = el.getBoundingClientRect();
215
+ if (r.width < 140 || r.width > 700 || r.height < 100 || r.height > 900) continue;
216
+ if (!grupos.has(k)) grupos.set(k, []);
217
+ grupos.get(k).push(el);
218
+ }
219
+ [...grupos.values()].filter((g) => g.length >= 3).slice(0, 4).forEach((g) => { por(g[0], 'cartao'); });
220
+ [...document.querySelectorAll('img')].filter((i) => (i.naturalWidth || 0) >= 300).slice(0, 4).forEach((i) => por(i, 'imagem'));
221
+ por(document.querySelector('form'), 'formulario');
222
+ por(document.querySelector('[class*="pric" i], [id*="pric" i], [class*="preco" i], [class*="plano" i]'), 'preco');
223
+ por(document.querySelector('footer'), 'rodape');
224
+ return JSON.stringify(out.slice(0, 25));
225
+ })()`;
226
+
227
+ /* Acha o alvo (pelo seletor ou pelo texto), isola e devolve a caixa e o código. */
228
+ const ISOLAR = (seletor, texto, transparente) => `(async () => {
229
+ let alvo = null;
230
+ try { alvo = ${JSON.stringify(seletor || '')} ? document.querySelector(${JSON.stringify(seletor || '')}) : null; } catch (_) {}
231
+ const quer = ${JSON.stringify(String(texto || '').toLowerCase())};
232
+ if (!alvo && quer) {
233
+ const cand = [...document.querySelectorAll('a, button, h1, h2, h3, h4, p, li, span, div, section, article, img, figure')]
234
+ .filter((e) => { const t = String(e.innerText || e.getAttribute('alt') || '').toLowerCase(); const r = e.getBoundingClientRect(); return t.includes(quer) && r.width > 10 && r.height > 8; });
235
+ // O MENOR que contém o texto: o botão, e não a seção inteira em volta dele.
236
+ cand.sort((a, b) => { const ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect(); return ra.width * ra.height - rb.width * rb.height; });
237
+ alvo = cand[0] || null;
238
+ }
239
+ if (!alvo) return JSON.stringify({ erro: 'não achei esse elemento na página' });
240
+ alvo.scrollIntoView({ block: 'center' });
241
+ await new Promise((r) => setTimeout(r, 500));
242
+ if (${transparente ? 'true' : 'false'}) {
243
+ alvo.setAttribute('data-primo-alvo', '1');
244
+ const st = document.createElement('style');
245
+ st.textContent = 'html, body { background: transparent !important; } body * { visibility: hidden !important; } '
246
+ + '[data-primo-alvo], [data-primo-alvo] * { visibility: visible !important; }';
247
+ document.head.appendChild(st);
248
+ await new Promise((r) => setTimeout(r, 150));
249
+ }
250
+ const r = alvo.getBoundingClientRect();
251
+ const cs = getComputedStyle(alvo);
252
+ const html = alvo.outerHTML.replace(/<script[\\s\\S]*?<\\/script>/gi, '').replace(/\\s+/g, ' ').slice(0, 3000);
253
+ return JSON.stringify({ x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height, html, texto: String(alvo.innerText || '').trim().slice(0, 200),
254
+ estilo: { fonte: cs.fontFamily, peso: cs.fontWeight, tamanho: cs.fontSize, cor: cs.color, fundo: cs.backgroundColor, raio: cs.borderRadius, sombra: cs.boxShadow !== 'none' ? cs.boxShadow : '', borda: cs.border } });
255
+ })()`;
256
+
257
+ async function abrir(navegador, alvo, metrica, op = {}) {
258
+ const aba = await navegador.abaEscondida('about:blank', { assentar: 0 });
259
+ const s = aba.sessao;
260
+ await s.send('Emulation.setDeviceMetricsOverride', metrica);
261
+ if (metrica.mobile) await s.send('Emulation.setTouchEmulationEnabled', { enabled: true }).catch(() => {});
262
+ await s.send('Page.navigate', { url: alvo });
263
+ const prazo = Date.now() + (op.prazo || 20000);
264
+ while (Date.now() < prazo) {
265
+ if (await valor(s, 'document.readyState') === 'complete') break;
266
+ await sleep(200);
267
+ }
268
+ await sleep(op.assentar == null ? 1500 : op.assentar);
269
+ return aba;
270
+ }
271
+
272
+ /**
273
+ * As peças que dá para tirar do site: logo, menu, título, hero, botões,
274
+ * cartões, imagens, formulário, preço, rodapé — cada uma com o seletor.
275
+ */
276
+ async function elementosDoSite(url, op = {}) {
277
+ const alvo = normalizarUrl(url);
278
+ if (!alvo) return { ok: false, error: `"${url || ''}" não é o endereço de um site (http ou https).` };
279
+ const navegador = op.navegador || require('../browser.js');
280
+ let aba;
281
+ try {
282
+ aba = await abrir(navegador, alvo, TELAS[op.celular ? 'celular' : 'computador'], op);
283
+ let lista = [];
284
+ try { lista = JSON.parse(await valor(aba.sessao, LISTAR) || '[]'); } catch { /* vazio */ }
285
+ return { ok: true, url: alvo, elementos: lista,
286
+ proximo: 'estudio_midia acao:elemento com url e seletor (ou texto: "o que está escrito nele") tira a peça sozinha, com fundo transparente.' };
287
+ } catch (e) {
288
+ return { ok: false, error: `não consegui ler ${alvo}: ${e.message}` };
289
+ } finally {
290
+ if (aba) { try { await aba.fechar(); } catch { /* já fechou */ } }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Tira UM elemento do site, sozinho: PNG transparente no dobro da resolução,
296
+ * mais o código dele (html e estilo calculado).
297
+ *
298
+ * @param {object} op { pasta, seletor, texto, celular, transparente (padrão true), margem }
299
+ */
300
+ async function capturarElemento(url, op = {}) {
301
+ const alvo = normalizarUrl(url);
302
+ if (!alvo) return { ok: false, error: `"${url || ''}" não é o endereço de um site (http ou https).` };
303
+ if (!op.pasta) return { ok: false, error: 'o elemento vai para a pasta do projeto — diga o projeto (id).' };
304
+ if (!op.seletor && !op.texto) return { ok: false, error: 'diga qual elemento: seletor (de acao:elementos) ou texto (o que está escrito nele).' };
305
+ const navegador = op.navegador || require('../browser.js');
306
+ const tela = op.celular ? 'celular' : 'computador';
307
+ const metrica = { ...TELAS[tela], deviceScaleFactor: 2 };
308
+ const transparente = op.transparente !== false;
309
+ let aba;
310
+ try {
311
+ aba = await abrir(navegador, alvo, metrica, op);
312
+ const s = aba.sessao;
313
+ let info = {};
314
+ try { info = JSON.parse(await valor(s, ISOLAR(op.seletor, op.texto, transparente), true) || '{}'); } catch { /* sem */ }
315
+ if (info.erro || !info.w) return { ok: false, error: info.erro || 'o elemento não tem tamanho na tela (escondido?)' };
316
+ if (transparente) await s.send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } }).catch(() => {});
317
+ const m = op.margem == null ? 8 : Number(op.margem) || 0;
318
+ const clip = { x: Math.max(0, info.x - m), y: Math.max(0, info.y - m), width: info.w + 2 * m, height: Math.min(info.h + 2 * m, 4000), scale: 1 };
319
+ const r = await s.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true, clip }, 60000);
320
+ if (!r || !r.data) return { ok: false, error: 'o navegador devolveu um print vazio.' };
321
+ const host = new URL(alvo).hostname.replace(/^www\./, '').replace(/[^a-z0-9.-]/gi, '-');
322
+ const nome = String(op.seletor || op.texto).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 30) || 'elemento';
323
+ const src = `midia/site/${host}-${nome}.png`;
324
+ const cheio = path.join(op.pasta, src);
325
+ fs.mkdirSync(path.dirname(cheio), { recursive: true });
326
+ fs.writeFileSync(cheio, Buffer.from(r.data, 'base64'));
327
+ const largura = Math.round(clip.width), altura = Math.round(clip.height);
328
+ return {
329
+ ok: true, src, url: alvo, largura, altura, proporcao: Math.round((altura / largura) * 1000) / 1000, transparente,
330
+ texto: info.texto, codigo: { html: info.html, estilo: info.estilo },
331
+ elemento: { tipo: 'imagem', src, ajuste: 'contain', x: 0, y: 0, largura, altura },
332
+ nota: 'O PNG tem o dobro da resolução: escale à vontade. O código (html e estilo) serve para redesenhar a peça como componente, se preferir animá-la por dentro.',
333
+ };
334
+ } catch (e) {
335
+ return { ok: false, error: `não consegui tirar o elemento de ${alvo}: ${e.message}` };
336
+ } finally {
337
+ if (aba) { try { await aba.fechar(); } catch { /* já fechou */ } }
338
+ }
339
+ }
340
+
341
+ module.exports = { capturarSite, elementoDoPrint, elementosDoSite, capturarElemento, normalizarUrl, TELAS, ALTURA_MAX, LISTAR, ISOLAR };