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 +2 -2
- package/bin/primocode.js +6 -26
- package/lib/catalogo.js +3 -3
- package/lib/estudio/assets.js +75 -1
- package/lib/estudio/batida.js +184 -0
- package/lib/estudio/biblioteca.js +52 -12
- package/lib/estudio/decisao.js +106 -0
- package/lib/estudio/edicao.js +34 -16
- package/lib/estudio/estilo.js +17 -6
- package/lib/estudio/fluido.js +82 -13
- package/lib/estudio/index.js +122 -30
- package/lib/estudio/kit/fluido.jsx +130 -9
- package/lib/estudio/referencias.js +36 -25
- package/lib/estudio/remotion.js +2 -0
- package/lib/estudio/revisor.js +117 -0
- package/lib/estudio/variedade.js +71 -17
- 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/motion-de-primeira/SKILL.md +3 -5
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v10.0
|
|
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
|
|
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
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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 (
|
|
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
|
|
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. '
|
package/lib/estudio/assets.js
CHANGED
|
@@ -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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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)
|
|
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) ||
|
|
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 };
|