primocode 10.1.0 → 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 CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v10.0.9
1
+ # PrimoCode v10.2.0
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v10.0.9
12
+ ▐▛███▜▌ Primo Code v10.2.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/bin/primocode.js CHANGED
@@ -1088,37 +1088,17 @@ async function enviarPedido(userText) {
1088
1088
  // O Chrome só aparece na tela quando o pedido é de navegador (lib/browser.js, definirVisivel).
1089
1089
  require('../lib/browser.js').definirVisivel(querNavegador(userText));
1090
1090
 
1091
- /* AS REFERÊNCIAS DO VÍDEO, decididas pela pessoa antes de começar
1092
- (lib/estudio/referencias.js): web aberta, só o escopo que ela der,
1093
- ou nenhuma — só motion. No pedido único não há quem responda, e
1094
- vale o aberto. */
1091
+ /* AS REFERÊNCIAS DO VÍDEO (lib/estudio/referencias.js) saem do próprio
1092
+ pedido. O padrão é SEM IMAGEM, e o menu 1/2/3 saiu: "por padrão, NÃO
1093
+ usar imagens; somente quando o usuário pedir explicitamente" (07/10). */
1095
1094
  const referencias = require('../lib/estudio/referencias.js');
1096
1095
  referencias.limpar();
1097
1096
  const producao = require('../lib/estudio/diretor.js').precisaDeProducao(userText);
1098
1097
  if (producao.producao && producao.formato === 'video') {
1099
- /* O modo sai do próprio pedido quando ele diz ("só motion", "só do
1100
- site x.com", "web aberta"). Quando NÃO diz, pergunta antes de
1101
- começar — "vídeo" às vezes quer dizer só o motion (02/10). No
1102
- pedido único não há quem responda, e vale o aberto. */
1103
1098
  const r = referencias.inferir(userText);
1104
- if (!r.explicito && process.stdin.isTTY && !state.semTerminal) {
1105
- console.log('');
1106
- console.log(' ' + c.brand('▸') + ' ' + c.bold(c.white('Que vídeo você quer?')));
1107
- console.log(' ' + c.dim('1') + c.muted(' só motion — animação pura, nenhuma foto nem vídeo de fora'));
1108
- console.log(' ' + c.dim('2') + c.muted(' motion + web aberta — ele pesquisa e busca imagem e vídeo à vontade'));
1109
- console.log(' ' + c.dim('3') + c.muted(' motion + escopo fechado — só os sites e termos que você disser'));
1110
- const dito = String(await ask(' ' + c.brand('❯ ') + c.muted('1, 2 ou 3 '))).trim();
1111
- // Aqui 1 é o motion (a ordem da pergunta), não o 1 = aberto do entender().
1112
- const modo = { 1: 'sem', 2: 'aberto', 3: 'fechado' }[(/^\s*([123])\b/.exec(dito) || [])[1]] || referencias.entender(dito) || 'aberto';
1113
- r.modo = modo;
1114
- r.escopo = modo === 'fechado'
1115
- ? String(await ask(' ' + c.brand('❯ ') + c.muted('quais sites ou termos? (separados por vírgula) '))).trim()
1116
- : '';
1117
- if (modo === 'fechado' && !r.escopo) r.modo = 'aberto';
1118
- }
1119
- referencias.definir(r.modo, r.escopo);
1120
- console.log(c.dim(` ⎿ referências: ${{ aberto: 'web aberta', fechado: 'só ' + r.escopo, sem: 'nenhuma, só motion' }[r.modo]}`)
1121
- + (r.modo === 'aberto' ? c.dim(' · diga "só motion" ou "só do site x.com" para mudar') : ''));
1099
+ referencias.definir(r.modo, r.escopo, r.explicito);
1100
+ console.log(c.dim(` ⎿ imagens: ${{ aberto: 'sim, da web', fechado: 'só de ' + r.escopo, sem: r.explicito ? 'nenhuma, só motion' : 'nenhuma (o padrão)' }[r.modo]}`)
1101
+ + (r.modo === 'sem' && !r.explicito ? c.dim(' · diga "adicione imagens de apoio" para usar') : ''));
1122
1102
  }
1123
1103
 
1124
1104
  // O resumo da rodada anterior é feito em segundo plano logo depois
package/lib/catalogo.js CHANGED
@@ -90,10 +90,10 @@ const GRUPOS = [
90
90
  + 'partes:[{tipo, fala, …}]} — o PrimoCode faz o layout, a voz com entonação, os ícones, a música e os sons. '
91
91
  + 'Tipos: abertura{titulo,sub} titulo{titulo,sub} numero{valor,rotulo,sub} lista{titulo,itens} passos{titulo,itens} '
92
92
  + 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link} '
93
- + 'logo{titulo} cores{cores:[{nome,hex}]} produto{titulo,texto,destaque,selo,barras}. Em qualquer parte: destaque (a palavra-tese), fonte (a fonte citada). '
93
+ + 'logo{titulo} cores{cores:[{nome,hex}]} produto{titulo,texto,destaque,selo,barras}. Em qualquer parte: destaque (a palavra-tese), fonte (a fonte citada). O texto da TELA sai da FALA: o título usa palavras que a fala diz, cedo na frase (a tela entra quando a voz diz; palavra que a voz não diz deixa a tela esperando). '
94
94
  + 'visual:{titulo,texto,paleta} (hibrido, cinema, impacto): fonte e cor do cardápio (estudio_midia acao:fontes); sem visual, o PrimoCode sorteia sem repetir os últimos vídeos. '
95
- + 'estilo: hibrido (PADRÃO: foto e vídeo reais que não param + o motion por cima; dê busca:"termo em inglês" por parte para o b-roll) | gerativo (= motion: sem template — a tipografia anima nos eixos da fonte numa linha do tempo contínua, composição, fonte, cor e som sorteados do roteiro; é o do modo sem referências e o de quem pede motion/animação; com modelo3d o produto 3D entra numa batida) | classico (o motion 3D antigo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
96
- + 'marca (brand reveal: brilho, letras em vetor, marca cromada, padrão) | lancamento (produto: cartões, painel, calendário) | fluido (minimalista: uma ideia por batida, a forma de uma vira a próxima, sem corte; paleta noite|breu|lavanda|creme ou tema claro|escuro no roteiro) | impacto | cinema. '
95
+ + 'estilo (SEM IMAGEM é o padrão: escolha o motion que serve a ESTE roteiro, ou deixe vazio e ele gira entre gerativo, fluido e impacto): hibrido (SÓ quando a pessoa pediu imagem: foto e vídeo reais + o motion por cima; dê busca:"termo em inglês" só nas partes em que a imagem agrega) | gerativo (= motion: sem template — a tipografia anima nos eixos da fonte numa linha do tempo contínua, composição, fonte, cor e som sorteados do roteiro; com modelo3d o produto 3D entra numa batida) | classico (o motion 3D antigo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
96
+ + 'marca (brand reveal: brilho, letras em vetor, marca cromada, padrão) | lancamento (produto: cartões, painel, calendário) | fluido (minimalista: uma ideia por batida, a forma de uma vira a próxima, sem corte; paleta noite|breu|preto|cobalto (escuras) ou lavanda|creme|laranja|branco (claras) — escolha pelo assunto e NÃO repita a do vídeo anterior; vazio, o PrimoCode escolhe) | impacto | cinema. '
97
97
  + 'marca e lancamento aceitam visual:orbe|vetor|estrela|esfera|grade|site|padrao|cromo|cartoes|painel|cores numa parte. marca.nome dá o nome. '
98
98
  + 'voz (thalita|antonio|francisca). Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
99
99
  + 'cena (opcional): o número de UMA cena para trocar só ela, sem reescrever o resto. '
@@ -258,6 +258,80 @@ async function pexelsDoServidor(tipo, termo, { quantas, orientacao } = {}) {
258
258
  } catch { return null; }
259
259
  }
260
260
 
261
+ /* ── O ACERVO EDITORIAL (08/10): "as imagens de banco são horríveis… desde
262
+ que não seja genérica, tipo pessoa sorrindo olhando pro nada" (Joel). Foto
263
+ de verdade, de arquivo e de museu, em vez de banco de stock:
264
+ 1. Wikimedia Commons — documental (o despertador antigo, a ressonância de
265
+ 1982, a cidade à noite). Só CC0, domínio público e CC BY (sem SA: o
266
+ SA obrigaria o vídeo a ter a mesma licença), e só foto grande;
267
+ 2. Art Institute of Chicago — o acervo em domínio público (pintura,
268
+ gravura, fotografia antiga), pela imagem IIIF.
269
+ O que tem cara de stock ou de assunto pesado sai pelo título. Sem nada
270
+ que sirva, volta vazio: imagem genérica é pior que nenhuma. */
271
+ const GENERICA = /smil|happy|laugh|business ?(wo)?man|teamwork|handshake|thumbs|stock photo|isolated on white|portrait of (a|an) (young|happy)|selfie|model pos/i;
272
+ const PESADA = /homeless|corpse|dead body|cad[aá]ver|war victim|wounded|blood|nude|naked|execution|lynch|torture|mendig/i;
273
+ const LIVRE = (lic) => /^(cc0|public domain|pd|cc by \d|cc by$|cc-by-\d|attribution)/i.test(String(lic || '').trim()) && !/sa|nc|nd/i.test(String(lic || '').replace(/^public domain/i, ''));
274
+
275
+ function limparHtml(t) { return String(t || '').replace(/<[^>]+>/g, '').replace(/\s*(title|label)\s+QS:.*$/i, '').replace(/\s+/g, ' ').trim(); }
276
+
277
+ async function procurarWikimedia(termo, { quantas = 8, minimo = 1200 } = {}) {
278
+ const url = 'https://commons.wikimedia.org/w/api.php?action=query&format=json&generator=search&gsrnamespace=6'
279
+ + `&gsrsearch=${encodeURIComponent(termo + ' filetype:bitmap')}&gsrlimit=30&prop=imageinfo`
280
+ + '&iiprop=url|size|extmetadata&iiurlwidth=1600&iiextmetadatafilter=LicenseShortName|Artist|ImageDescription|ObjectName';
281
+ const r = await pegar(url, { json: true });
282
+ const paginas = Object.values((r.query && r.query.pages) || {}).sort((a, b) => (a.index || 0) - (b.index || 0));
283
+ const out = [];
284
+ for (const pg of paginas) {
285
+ const ii = (pg.imageinfo || [])[0];
286
+ if (!ii || ii.width < minimo || ii.height < minimo * 0.5) continue;
287
+ const md = ii.extmetadata || {};
288
+ const lic = (md.LicenseShortName || {}).value || '';
289
+ const titulo = limparHtml((md.ObjectName || {}).value || pg.title.replace(/^File:/, '').replace(/\.[a-z]+$/i, ''));
290
+ const desc = limparHtml((md.ImageDescription || {}).value || '');
291
+ if (!LIVRE(lic) || GENERICA.test(titulo + ' ' + desc) || PESADA.test(titulo + ' ' + desc)) continue;
292
+ const autor = (limparHtml((md.Artist || {}).value || '').split(/\s+(Author info|Reusing images|Own work)\b/i)[0].slice(0, 80)) || 'Wikimedia Commons';
293
+ out.push({ url: ii.thumburl || ii.url, titulo, autor, licenca: lic, fonte: 'wikimedia', largura: ii.thumbwidth || ii.width, altura: ii.thumbheight || ii.height,
294
+ creditos: `${titulo} — ${autor} (${lic}, Wikimedia Commons)`, pagina: ii.descriptionurl });
295
+ if (out.length >= quantas) break;
296
+ }
297
+ return out;
298
+ }
299
+
300
+ async function procurarArtic(termo, { quantas = 6 } = {}) {
301
+ const url = `https://api.artic.edu/api/v1/artworks/search?q=${encodeURIComponent(termo)}`
302
+ + '&query%5Bterm%5D%5Bis_public_domain%5D=true&limit=20&fields=id,title,image_id,artist_title,date_display,thumbnail';
303
+ const r = await pegar(url, { json: true });
304
+ return (r.data || []).filter((a) => a.image_id && !PESADA.test(a.title || '')).slice(0, quantas).map((a) => ({
305
+ url: `https://www.artic.edu/iiif/2/${a.image_id}/full/1686,/0/default.jpg`, titulo: a.title, autor: a.artist_title || 'Art Institute of Chicago',
306
+ licenca: 'CC0 (domínio público)', fonte: 'artic', largura: (a.thumbnail || {}).width, altura: (a.thumbnail || {}).height,
307
+ creditos: `${a.title}${a.date_display ? ', ' + a.date_display : ''} — ${a.artist_title || 'autor desconhecido'} (Art Institute of Chicago, CC0)`,
308
+ }));
309
+ }
310
+
311
+ /* A BUSCA DE ARQUIVO é curta: o roteiro escreve busca de banco de stock
312
+ ("tired person at office desk"), e um arquivo de verdade não acha nada
313
+ com isso. Tenta a busca inteira, depois só as palavras de conteúdo, depois
314
+ os pares delas do fim para o começo ("alarm clock on nightstand" → "alarm
315
+ clock"). Pessoa genérica sai da busca: o arquivo responde com a coisa. */
316
+ const VAZIAS_BUSCA = new Set('a an the of on in at with and or to for from by into over under near during while person people man woman someone guy girl young old tired happy sad soft light glowing macro close up closeup shot view cinematic photo image background beautiful modern'.split(' '));
317
+ function buscasMenores(termo) {
318
+ const ws = String(termo || '').toLowerCase().replace(/[^a-z0-9\s-]/g, ' ').split(/\s+/).filter(Boolean);
319
+ const conteudo = ws.filter((w) => !VAZIAS_BUSCA.has(w));
320
+ const pares = [];
321
+ for (let k = conteudo.length - 2; k >= 0; k--) pares.push(conteudo.slice(k, k + 2).join(' '));
322
+ return [...new Set([ws.join(' '), conteudo.join(' '), ...pares, ...conteudo.slice().reverse()].filter((x) => x && x.length > 2))];
323
+ }
324
+
325
+ /** A foto editorial (Wikimedia), da busca inteira à mais curta. Nunca o banco de stock. */
326
+ async function procurarEditorial(termo, { quantas = 8 } = {}) {
327
+ for (const q of buscasMenores(termo)) {
328
+ let imagens = [];
329
+ try { imagens = await procurarWikimedia(q, { quantas }); } catch { continue; }
330
+ if (imagens.length >= 2) return { ok: true, imagens, busca: q };
331
+ }
332
+ return { ok: false, imagens: [] };
333
+ }
334
+
261
335
  /** Procura vídeo de apoio (b-roll). Só Pexels: o Openverse não tem vídeo. */
262
336
  async function procurarVideos(termo, { quantas = 5, orientacao = null } = {}) {
263
337
  const videos = await pexelsDoServidor('videos', termo, { quantas, orientacao });
@@ -418,6 +492,6 @@ module.exports = {
418
492
  extensaoPorConteudo,
419
493
  pegar, guardar,
420
494
  icone, procurarIcones, iconesDoPlano, COLECOES, procurarVideos, procurarMusica,
421
- procurarImagens, procurarSom, urlSom, baixar,
495
+ procurarImagens, procurarEditorial, procurarWikimedia, procurarArtic, buscasMenores, procurarSom, urlSom, baixar,
422
496
  FONTES, conferirFonte,
423
497
  };
@@ -0,0 +1,184 @@
1
+ /**
2
+ * batida.js — a música manda no corte: acha a batida da trilha e escolhe de
3
+ * onde a faixa começa para as batidas caírem em cima dos cortes do vídeo.
4
+ *
5
+ * "A música também deve influenciar a edição: cortes, animações,
6
+ * transições e momentos de impacto devem poder ser sincronizados com
7
+ * ela." (Joel, 07/10)
8
+ *
9
+ * Antes, "cortar no beat" queria dizer cortar 0,08 s antes da primeira
10
+ * palavra da parte (ritmo.js): a voz mandava, e a música tocava por baixo
11
+ * sem saber dos cortes. Mexer no corte quebraria a sincronia com a voz,
12
+ * então aqui quem se move é a MÚSICA — o que um editor faz ao deslizar a
13
+ * trilha na linha do tempo:
14
+ * 1. analisar: o ffmpeg decodifica a faixa; a energia vira um envelope de
15
+ * ataques; a autocorrelação acha o andamento (60–180 BPM) e a fase;
16
+ * os picos mais fortes viram os ACENTOS (a virada, o drop);
17
+ * 2. alinhar: dos cortes do plano (o corte de impacto pesa mais), procura
18
+ * o início da faixa em que mais cortes caem numa batida e o maior
19
+ * impacto cai num acento. O plano ganha audio.trilha.inicio.
20
+ * Puro onde dá: analisarPCM e melhorInicio não tocam o disco.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('fs');
26
+ const { spawn } = require('child_process');
27
+
28
+ const TAXA = 22050;
29
+ const SALTO = 512; // ~23 ms por quadro do envelope
30
+ const r3 = (n) => Math.round(n * 1000) / 1000;
31
+
32
+ /** O envelope de ataques: quanto a energia SOBE de um quadro para o outro. */
33
+ function envelope(amostras) {
34
+ const n = Math.floor(amostras.length / SALTO);
35
+ const energia = new Float64Array(n);
36
+ for (let i = 0; i < n; i++) {
37
+ let s = 0;
38
+ for (let k = i * SALTO; k < (i + 1) * SALTO; k++) s += amostras[k] * amostras[k];
39
+ energia[i] = Math.log1p(1000 * s / SALTO);
40
+ }
41
+ const ataque = new Float64Array(n);
42
+ for (let i = 1; i < n; i++) ataque[i] = Math.max(0, energia[i] - energia[i - 1]);
43
+ // Tira a média móvel (1 s): sobra o que se destaca do que está ao redor.
44
+ const janela = Math.round(TAXA / SALTO);
45
+ const out = new Float64Array(n);
46
+ let soma = 0;
47
+ for (let i = 0; i < n; i++) {
48
+ soma += ataque[i];
49
+ if (i >= janela) soma -= ataque[i - janela];
50
+ out[i] = Math.max(0, ataque[i] - soma / Math.min(i + 1, janela));
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * A análise de uma faixa já decodificada (mono, `taxa` Hz).
57
+ * @returns {{bpm:number, periodo:number, batidas:number[], acentos:number[], confianca:number, segundos:number}}
58
+ */
59
+ function analisarPCM(amostras, taxa = TAXA) {
60
+ const seg = amostras.length / taxa;
61
+ const env = envelope(amostras);
62
+ const qps = taxa / SALTO; // quadros do envelope por segundo
63
+ const vazio = { bpm: 0, periodo: 0, batidas: [], acentos: [], confianca: 0, segundos: r3(seg) };
64
+ if (env.length < qps * 4) return vazio;
65
+
66
+ // O andamento: o atraso (em quadros) que mais se parece consigo mesmo, de 60 a 180 BPM.
67
+ const media = env.reduce((a, b) => a + b, 0) / env.length;
68
+ let melhorLag = 0, melhor = 0, total = 0;
69
+ const lagMin = Math.floor(qps * 60 / 180), lagMax = Math.ceil(qps * 60 / 60);
70
+ const auto = (lag) => { let s = 0; for (let i = lag; i < env.length; i++) s += (env[i] - media) * (env[i - lag] - media); return s / (env.length - lag); };
71
+ for (let lag = lagMin; lag <= lagMax; lag++) {
72
+ const s = auto(lag);
73
+ // Leve preferência pelo andamento de dança (~120): o dobro e a metade empatam muito.
74
+ const bpm = 60 * qps / lag;
75
+ const peso = Math.exp(-0.5 * Math.pow(Math.log2(bpm / 120) / 1.0, 2));
76
+ if (s * peso > melhor) { melhor = s * peso; melhorLag = lag; }
77
+ total += Math.max(0, s);
78
+ }
79
+ if (!melhorLag || melhor <= 0) return vazio;
80
+
81
+ /* O período em fração de quadro: o atraso inteiro (23 ms de grão) acumula
82
+ erro batida após batida. A parábola nos vizinhos acha o meio; e o
83
+ período que mais soma ataque numa grade longa decide o resto. */
84
+ const [y0, y1, y2] = [auto(melhorLag - 1), auto(melhorLag), auto(melhorLag + 1)];
85
+ let lag = melhorLag + (y0 - 2 * y1 + y2 !== 0 ? 0.5 * (y0 - y2) / (y0 - 2 * y1 + y2) : 0);
86
+ const somaGrade = (L, f) => { let s = 0; for (let x = f; x < env.length; x += L) s += env[Math.round(x)] || 0; return s; };
87
+ let fase = 0, maisForte = -1;
88
+ for (let L = lag - 0.3; L <= lag + 0.3; L += 0.02) {
89
+ for (let f = 0; f < L; f += 0.5) {
90
+ const s = somaGrade(L, f);
91
+ if (s > maisForte) { maisForte = s; fase = f; lag = L; }
92
+ }
93
+ }
94
+ const periodo = lag / qps;
95
+ // Cada batida se prende ao ataque mais forte a até 2 quadros dela (o músico não é metrônomo).
96
+ const batidas = [];
97
+ for (let x = fase; x < env.length; x += lag) {
98
+ const c = Math.round(x);
99
+ let i = c;
100
+ for (let k = Math.max(0, c - 2); k <= Math.min(env.length - 1, c + 2); k++) if (env[k] > env[i]) i = k;
101
+ batidas.push(r3(i / qps));
102
+ }
103
+
104
+ // Os acentos: os ataques mais fortes (o 5% de cima), a pelo menos 2 s um do outro.
105
+ const ordem = [...env.keys()].sort((a, b) => env[b] - env[a]);
106
+ const acentos = [];
107
+ for (const i of ordem.slice(0, Math.max(4, Math.floor(env.length * 0.05)))) {
108
+ const t = i / qps;
109
+ if (acentos.every((a) => Math.abs(a - t) >= 2)) acentos.push(t);
110
+ if (acentos.length >= 12) break;
111
+ }
112
+ /* A confiança: a fração das batidas que caem num ataque de verdade (acima
113
+ da mediana do envelope). Grade que não casa com a música fica perto de 0,5. */
114
+ const mediana = [...env].sort((x, y) => x - y)[Math.floor(env.length / 2)];
115
+ const naBatida = batidas.filter((t) => { const c = Math.round(t * qps); return Math.max(env[c - 1] || 0, env[c] || 0, env[c + 1] || 0) > mediana; }).length;
116
+ const confianca = batidas.length ? naBatida / batidas.length : 0;
117
+ return { bpm: Math.round(60 / periodo), periodo: r3(periodo), batidas, acentos: acentos.sort((a, b) => a - b).map(r3), confianca: r3(confianca), segundos: r3(seg) };
118
+ }
119
+
120
+ /** Decodifica a faixa (até `maximo` s) e analisa. Guarda ao lado: <faixa>.batidas.json. */
121
+ function analisar(arquivo, { maximo = 150, ffmpeg = 'ffmpeg' } = {}) {
122
+ const cache = arquivo + '.batidas.json';
123
+ try { return Promise.resolve(JSON.parse(fs.readFileSync(cache, 'utf8'))); } catch { /* analisa */ }
124
+ return new Promise((resolve) => {
125
+ const p = spawn(ffmpeg, ['-v', 'error', '-t', String(maximo), '-i', arquivo, '-ac', '1', '-ar', String(TAXA), '-f', 'f32le', '-']);
126
+ const pedacos = [];
127
+ p.stdout.on('data', (d) => pedacos.push(d));
128
+ p.on('error', () => resolve(null));
129
+ p.on('close', (code) => {
130
+ if (code !== 0) return resolve(null);
131
+ const buf = Buffer.concat(pedacos);
132
+ const amostras = new Float32Array(buf.buffer, buf.byteOffset, Math.floor(buf.length / 4));
133
+ const a = analisarPCM(amostras, TAXA);
134
+ try { fs.writeFileSync(cache, JSON.stringify(a)); } catch { /* sem cache */ }
135
+ resolve(a);
136
+ });
137
+ });
138
+ }
139
+
140
+ const CONFIANCA_MINIMA = 0.7; // abaixo disso a batida é chute: a faixa entra do começo
141
+
142
+ const TRANSICOES_FORTES = new Set(['soco', 'flash', 'chicote', 'zoom', 'giro']);
143
+
144
+ /** Os cortes do plano no tempo do vídeo: [{t, peso}]. Corte de impacto pesa 3; o maior impacto vira `impacto`. */
145
+ function cortesDoPlano(plano) {
146
+ const cortes = [];
147
+ let t = 0;
148
+ for (const [i, cena] of ((plano && plano.cenas) || []).entries()) {
149
+ if (i > 0) {
150
+ const tr = cena.transicao && (cena.transicao.tipo || cena.transicao);
151
+ cortes.push({ t: r3(t), peso: cena.impacto || TRANSICOES_FORTES.has(tr) ? 3 : 1 });
152
+ }
153
+ t += Number(cena.duracao) || 0;
154
+ }
155
+ return cortes;
156
+ }
157
+
158
+ /**
159
+ * De onde a faixa começa (em s) para o máximo de cortes cair numa batida.
160
+ * Procura em passos de 10 ms, de 0 a `maximo`; empate fica com o início mais
161
+ * cedo. O corte mais pesado ganha bônus se cair num acento.
162
+ */
163
+ function melhorInicio(cortes, analise, { maximo = 0, tolerancia = 0.05 } = {}) {
164
+ const bats = (analise && analise.batidas) || [];
165
+ if (!cortes.length || bats.length < 4 || (analise.confianca != null && analise.confianca < CONFIANCA_MINIMA)) return { inicio: 0, alinhados: 0, total: cortes.length };
166
+ const perto = (lista, t) => {
167
+ let lo = 0, hi = lista.length - 1;
168
+ while (lo < hi) { const m = (lo + hi) >> 1; if (lista[m] < t) lo = m + 1; else hi = m; }
169
+ return Math.min(Math.abs(lista[lo] - t), lo > 0 ? Math.abs(lista[lo - 1] - t) : Infinity);
170
+ };
171
+ const maior = cortes.reduce((a, b) => (b.peso > a.peso ? b : a), cortes[0]);
172
+ const acentos = (analise.acentos || []);
173
+ let melhor = { inicio: 0, pontos: -1 };
174
+ for (let o = 0; o <= Math.max(0, maximo) + 1e-9; o += 0.01) {
175
+ let pontos = 0;
176
+ for (const c of cortes) pontos += c.peso * Math.exp(-0.5 * Math.pow(perto(bats, c.t + o) / tolerancia, 2));
177
+ if (maior.peso > 1 && acentos.length && perto(acentos, maior.t + o) < 0.1) pontos += 2;
178
+ if (pontos > melhor.pontos + 1e-6) melhor = { inicio: r3(o), pontos };
179
+ }
180
+ const alinhados = cortes.filter((c) => perto(bats, c.t + melhor.inicio) <= tolerancia).length;
181
+ return { inicio: melhor.inicio, alinhados, total: cortes.length };
182
+ }
183
+
184
+ module.exports = { analisarPCM, analisar, cortesDoPlano, melhorInicio, TAXA, CONFIANCA_MINIMA };
@@ -113,12 +113,47 @@ const norm = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toL
113
113
  function acharFonte(nome) { const n = norm(nome); return FONTES.find((f) => norm(f.nome) === n) || null; }
114
114
  function acharPaleta(nome) { const n = norm(nome).replace(/\s+/g, '-'); return PALETAS.find((p) => p.nome === n) || null; }
115
115
 
116
- /* O sorteio que pula os recentes. Se a lista inteira já foi usada, vale
117
- qualquer uma — nunca fica sem. */
118
- function sortear(lista, recentes, chave, sorte) {
119
- const livres = lista.filter((x) => !recentes.includes(chave(x)));
120
- const de = livres.length ? livres : lista;
121
- return de[Math.floor(sorte() * de.length) % de.length];
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];
122
157
  }
123
158
 
124
159
  /**
@@ -126,7 +161,7 @@ function sortear(lista, recentes, chave, sorte) {
126
161
  * trocar nada que a marca já tem. Devolve o que escolheu e de onde veio.
127
162
  * memoria: null não lê nem grava o disco (os testes); sorte: () => [0,1).
128
163
  */
129
- function vestir(roteiro, { memoria = ARQ, sorte = Math.random } = {}) {
164
+ function vestir(roteiro, { memoria = ARQ, sorte = Math.random, estilo = '' } = {}) {
130
165
  if (!roteiro || typeof roteiro !== 'object') return { mudou: false };
131
166
  const marca = roteiro.marca || {};
132
167
  const cores = marca.cores || {};
@@ -136,17 +171,22 @@ function vestir(roteiro, { memoria = ARQ, sorte = Math.random } = {}) {
136
171
 
137
172
  if (!(marca.fontes && marca.fontes.titulo)) {
138
173
  // A IA escolheu pelo nome: vale qualquer família do Google Fonts, mesmo fora do cardápio.
139
- const titulo = visual.titulo ? String(visual.titulo) : sortear(TITULO, mem.titulo, (f) => f.nome, sorte).nome;
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;
140
179
  const textoLista = TEXTO.filter((f) => f.nome !== titulo);
141
- const texto = visual.texto ? String(visual.texto) : sortear(textoLista, mem.texto, (f) => f.nome, sorte).nome;
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;
142
182
  marca.fontes = { ...(marca.fontes || {}), titulo, texto };
143
183
  mem.titulo = [titulo, ...mem.titulo.filter((x) => x !== titulo)].slice(0, LEMBRA.titulo);
144
184
  mem.texto = [texto, ...mem.texto.filter((x) => x !== texto)].slice(0, LEMBRA.texto);
145
- Object.assign(escolha, { mudou: true, titulo, texto, fonteDaIA: !!visual.titulo });
185
+ Object.assign(escolha, { mudou: true, titulo, texto, fonteDaIA: !!visual.titulo, tom: tomDe(roteiro, estilo) });
146
186
  }
147
187
  // A cor da marca manda: com destaque ou gradiente declarado, a paleta não entra.
148
188
  if (!(marca.gradiente && marca.gradiente.length >= 2) && !cores.destaque) {
149
- const p = acharPaleta(visual.paleta) || sortear(PALETAS, mem.paleta, (x) => x.base, sorte);
189
+ const p = acharPaleta(visual.paleta) || escolher(PALETAS, mem.paleta, (x) => x.base, sorte, () => 1, LEMBRA.paleta);
150
190
  marca.gradiente = [...p.grad];
151
191
  marca.cores = { ...cores, fundo: cores.fundo || p.fundo, destaque: p.grad[1], apoio: p.grad[0] };
152
192
  mem.paleta = [p.base, ...mem.paleta.filter((x) => x !== p.base)].slice(0, LEMBRA.paleta);
@@ -171,4 +211,4 @@ function cardapio() {
171
211
  };
172
212
  }
173
213
 
174
- module.exports = { vestir, cardapio, larguraDe, acharFonte, acharPaleta, FONTES, PALETAS, BASES, TITULO, TEXTO, luz, ARQ };
214
+ module.exports = { vestir, cardapio, tomDe, TOM_DO_TITULO, PAR_DO_TEXTO, larguraDe, acharFonte, acharPaleta, FONTES, PALETAS, BASES, TITULO, TEXTO, luz, ARQ };
@@ -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 };