primocode 10.5.0 → 10.7.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 +117 -24
- package/lib/act.js +141 -9
- package/lib/agents-md.js +140 -0
- package/lib/auditoria.js +122 -0
- package/lib/browser.js +48 -1
- package/lib/catalogo.js +29 -6
- package/lib/comandos-extra.js +2 -0
- package/lib/config.js +32 -4
- package/lib/entrada.js +33 -11
- package/lib/estudio/cenarios.js +91 -0
- package/lib/estudio/colecao.js +35 -1
- package/lib/estudio/diretor.js +1 -0
- package/lib/estudio/falantes.js +30 -6
- package/lib/estudio/hibrido.js +144 -8
- package/lib/estudio/index.js +196 -63
- package/lib/estudio/kit/mundo.jsx +1060 -0
- package/lib/estudio/kit/voxel.jsx +315 -70
- package/lib/estudio/kit.js +1 -1
- package/lib/estudio/kokoro.js +18 -4
- package/lib/estudio/margem.js +1 -1
- package/lib/estudio/movimentos.js +212 -0
- package/lib/estudio/mundo.js +328 -0
- package/lib/estudio/personagem.js +164 -11
- package/lib/estudio/plano.js +6 -0
- package/lib/estudio/producao.js +107 -17
- package/lib/estudio/projeto.js +77 -22
- package/lib/leitura.js +176 -0
- package/lib/local/cerebro.json +180 -3
- package/lib/local/entrada.js +61 -4
- package/lib/mcp.js +296 -12
- package/lib/memoria-conversa.js +32 -3
- package/lib/mencoes.js +5 -1
- package/lib/nome-curto.js +126 -0
- package/lib/nome-projeto.js +5 -2
- package/lib/outra-pasta.js +161 -0
- package/lib/perigo.js +34 -2
- package/lib/referencia.js +40 -11
- package/lib/repeticao.js +20 -1
- package/lib/tela.js +1 -1
- package/lib/tools.js +76 -6
- package/lib/travado.js +141 -0
- package/lib/ui.js +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cenarios.js — os cenários que a IA criou e a pessoa deixou guardar.
|
|
3
|
+
*
|
|
4
|
+
* "a IA vai criar os próprios cenários, além de ter os próprios cenários
|
|
5
|
+
* prontos, e pede pro usuário se pode salvar pra usar na próxima vez."
|
|
6
|
+
* (Joel, 12/10)
|
|
7
|
+
*
|
|
8
|
+
* Os lugares prontos moram no mundo.js (praia, cidade, cobertura…). A IA já
|
|
9
|
+
* podia mudar o céu, o chão, a luz e os elementos de UMA cena; o que se perdia
|
|
10
|
+
* era o resultado: o próximo vídeo começava do zero. Aqui o cenário inventado
|
|
11
|
+
* ganha nome e fica em ~/.primocode/cenarios.json — só com o "sim" da pessoa
|
|
12
|
+
* (a ferramenta recusa sem `autorizado: true`) — e vira um lugar como os
|
|
13
|
+
* outros: `lugar: "<nome>"` na cena o traz de volta.
|
|
14
|
+
*
|
|
15
|
+
* Só se guarda o que o mundo.js sabe desenhar: o lugar base, cores #rrggbb,
|
|
16
|
+
* a hora e até 7 elementos. Nada de caminho de arquivo nem código.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
|
|
25
|
+
const arquivo = () => path.join(os.homedir(), '.primocode', 'cenarios.json');
|
|
26
|
+
const ehCor = (c) => typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c);
|
|
27
|
+
const TIPOS_DE_CHAO = ['areia', 'asfalto', 'grama', 'neve', 'lua', 'madeira', 'ladrilho'];
|
|
28
|
+
const HORAS = ['noite', 'por_do_sol', 'amanhecer'];
|
|
29
|
+
|
|
30
|
+
/** "Loja da Ana à noite" → "loja_da_ana_a_noite" (o mesmo jeito dos lugares do mundo.js). */
|
|
31
|
+
function chave(nome) {
|
|
32
|
+
return String(nome || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 40);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function lerTudo() {
|
|
37
|
+
try { const j = JSON.parse(fs.readFileSync(arquivo(), 'utf8')); return j && typeof j === 'object' ? j : {}; } catch { return {}; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Só o que o mundo sabe desenhar; o resto cai fora em silêncio. */
|
|
41
|
+
function limpar(c, bases) {
|
|
42
|
+
const o = c && typeof c === 'object' ? c : {};
|
|
43
|
+
const base = chave(o.base || o.lugar);
|
|
44
|
+
const out = { base: bases.includes(base) ? base : 'cobertura' };
|
|
45
|
+
if (typeof o.descricao === 'string') out.descricao = o.descricao.slice(0, 200);
|
|
46
|
+
if (Array.isArray(o.ceu) && o.ceu.length === 2 && o.ceu.every(ehCor)) out.ceu = o.ceu;
|
|
47
|
+
for (const k of ['parede', 'luz', 'acento']) if (ehCor(o[k])) out[k] = o[k];
|
|
48
|
+
if (o.chao && typeof o.chao === 'object') {
|
|
49
|
+
const ch = {};
|
|
50
|
+
if (TIPOS_DE_CHAO.includes(o.chao.tipo)) ch.tipo = o.chao.tipo;
|
|
51
|
+
if (ehCor(o.chao.cor)) ch.cor = o.chao.cor;
|
|
52
|
+
if (ehCor(o.chao.cor2)) ch.cor2 = o.chao.cor2;
|
|
53
|
+
if (Object.keys(ch).length) out.chao = ch;
|
|
54
|
+
} else if (ehCor(o.chao)) out.chao = { cor: o.chao, cor2: o.chao };
|
|
55
|
+
if (HORAS.includes(o.hora)) out.hora = o.hora.replace(/_/g, ' ');
|
|
56
|
+
if (Array.isArray(o.elementos)) {
|
|
57
|
+
const els = o.elementos.map((e) => (typeof e === 'string' ? e : e && e.nome) || '').map((s) => String(s).slice(0, 40)).filter(Boolean).slice(0, 7);
|
|
58
|
+
if (els.length) out.elementos = els;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Guarda o cenário. `autorizado` é a resposta da pessoa: sem ela, nada é escrito.
|
|
65
|
+
* @param {{nome:string, cenario:object, autorizado:boolean, bases:string[]}} op
|
|
66
|
+
*/
|
|
67
|
+
function salvar({ nome, cenario, autorizado, bases = [] } = {}) {
|
|
68
|
+
if (autorizado !== true) {
|
|
69
|
+
return { ok: false, error: 'falta o sim da pessoa.', comoResolver: 'pergunte "posso salvar este cenário pra usar na próxima vez?" e só chame de novo com autorizado:true se ela disser que sim.' };
|
|
70
|
+
}
|
|
71
|
+
const k = chave(nome);
|
|
72
|
+
if (!k) return { ok: false, error: 'dê um nome ao cenário (ex: "loja da Ana à noite").' };
|
|
73
|
+
if (bases.includes(k)) return { ok: false, error: `"${k}" já é um lugar pronto; escolha outro nome.` };
|
|
74
|
+
const tudo = lerTudo();
|
|
75
|
+
const novo = { ...limpar(cenario, bases), nome: String(nome).slice(0, 60), salvo: new Date().toISOString() };
|
|
76
|
+
tudo[k] = novo;
|
|
77
|
+
fs.mkdirSync(path.dirname(arquivo()), { recursive: true });
|
|
78
|
+
fs.writeFileSync(arquivo(), JSON.stringify(tudo, null, 2));
|
|
79
|
+
return { ok: true, chave: k, cenario: novo, comoUsar: `na próxima cena, lugar:"${k}" traz este cenário de volta.` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ler(nome) {
|
|
83
|
+
const k = chave(nome);
|
|
84
|
+
return k ? lerTudo()[k] || null : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function listar() {
|
|
88
|
+
return Object.entries(lerTudo()).map(([k, c]) => ({ chave: k, nome: c.nome, base: c.base, descricao: c.descricao || '' }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { salvar, ler, listar, limpar, chave, arquivo };
|
package/lib/estudio/colecao.js
CHANGED
|
@@ -55,6 +55,27 @@ const PT_EN = {
|
|
|
55
55
|
surpreso: 'surprised', cerebro: 'brain', 'cérebro': 'brain', olho: 'eye', mao: 'hand', 'mão': 'hand', joinha: 'thumbs up',
|
|
56
56
|
palmas: 'clapping', aplauso: 'clapping', calendario: 'calendar', 'calendário': 'calendar', saco: 'money bag', banco: 'bank',
|
|
57
57
|
cartao: 'credit card', 'cartão': 'credit card', loja: 'shopping', compras: 'shopping cart', voto: 'ballot box', ferramenta: 'hammer',
|
|
58
|
+
// Comida, objeto e veículo: o que vira personagem ou peça da cena 3D (mundo.js).
|
|
59
|
+
hamburguer: 'hamburger', 'hambúrguer': 'hamburger', batata: 'potato', 'batata frita': 'french fries', sorvete: 'ice cream', bolo: 'birthday cake',
|
|
60
|
+
chocolate: 'chocolate bar', maca: 'red apple', 'maçã': 'red apple', uva: 'grapes', morango: 'strawberry', abacaxi: 'pineapple', melancia: 'watermelon',
|
|
61
|
+
cenoura: 'carrot', ovo: 'egg', queijo: 'cheese wedge', leite: 'glass of milk', cerveja: 'beer mug', vinho: 'wine glass', suco: 'beverage box',
|
|
62
|
+
refrigerante: 'cup with straw', sanduiche: 'sandwich', 'sanduíche': 'sandwich', sushi: 'sushi', 'cachorro-quente': 'hot dog', pipoca: 'popcorn',
|
|
63
|
+
rosquinha: 'doughnut', donut: 'doughnut', biscoito: 'cookie', abacate: 'avocado', coco: 'coconut', taco: 'taco', croissant: 'croissant',
|
|
64
|
+
barraca: 'tent', fogueira: 'fire', palmeira: 'palm tree', violao: 'guitar', 'violão': 'guitar', guitarra: 'guitar', bola: 'soccer ball',
|
|
65
|
+
bicicleta: 'bicycle', moto: 'motorcycle', aviao: 'airplane', 'avião': 'airplane', trem: 'locomotive', onibus: 'bus', 'ônibus': 'bus',
|
|
66
|
+
navio: 'ship', helicoptero: 'helicopter', 'helicóptero': 'helicopter', lapis: 'pencil', 'lápis': 'pencil', caneta: 'pen', mochila: 'backpack',
|
|
67
|
+
chapeu: 'top hat', 'chapéu': 'top hat', oculos: 'glasses', 'óculos': 'glasses', televisao: 'television', 'televisão': 'television', tv: 'television',
|
|
68
|
+
microfone: 'microphone', fone: 'headphone', 'guarda-chuva': 'umbrella', cogumelo: 'mushroom', semaforo: 'vertical traffic light',
|
|
69
|
+
'semáforo': 'vertical traffic light', ampulheta: 'hourglass', lua: 'crescent moon', nuvem: 'cloud', arco_iris: 'rainbow', 'arco-íris': 'rainbow',
|
|
70
|
+
flor: 'blossom', rosa: 'rose', girassol: 'sunflower', 'cacto': 'cactus', vulcao: 'volcano', 'vulcão': 'volcano', castelo: 'castle',
|
|
71
|
+
fantasma: 'ghost', alien: 'alien', 'alienígena': 'alien', et: 'alien', unicornio: 'unicorn', 'unicórnio': 'unicorn', dragao: 'dragon', 'dragão': 'dragon',
|
|
72
|
+
// Bichos que o kit/mundo.jsx não monta de blocos: vêm do ícone 3D (a lula fica de fora: é o presidente).
|
|
73
|
+
polvo: 'octopus', baleia: 'whale', caranguejo: 'crab', borboleta: 'butterfly', abelha: 'honeybee', cobra: 'snake', jacare: 'crocodile',
|
|
74
|
+
'jacaré': 'crocodile', crocodilo: 'crocodile', formiga: 'ant', aranha: 'spider', camelo: 'camel', zebra: 'zebra', hipopotamo: 'hippopotamus',
|
|
75
|
+
'hipopótamo': 'hippopotamus', rinoceronte: 'rhinoceros', canguru: 'kangaroo', coala: 'koala', preguica: 'sloth', 'preguiça': 'sloth',
|
|
76
|
+
joaninha: 'lady beetle', caracol: 'snail', minhoca: 'worm', morcego: 'bat', esquilo: 'chipmunk', rato: 'mouse', hamster: 'hamster',
|
|
77
|
+
lagosta: 'lobster', camarao: 'shrimp', 'camarão': 'shrimp', flamingo: 'flamingo', pavao: 'peacock', 'pavão': 'peacock', cisne: 'swan',
|
|
78
|
+
aguia: 'eagle', 'águia': 'eagle', peru: 'turkey', lhama: 'llama', gorila: 'gorilla', orangotango: 'orangutan', bufalo: 'water buffalo',
|
|
58
79
|
engrenagem: 'gear', ciencia: 'test tube', 'ciência': 'test tube', saude: 'hospital', 'saúde': 'hospital', remedio: 'pill', 'remédio': 'pill',
|
|
59
80
|
};
|
|
60
81
|
|
|
@@ -177,9 +198,22 @@ function cardapio() {
|
|
|
177
198
|
personagens3d: 'o estilo personagem (voxel): bonecos de blocos e o mascote, com a câmera de cinema — estudio_roteiro estilo:"personagem"',
|
|
178
199
|
efeitosSonoros: { sintetizados: sons, internet: 'acao:efeito termo:"whoosh" (Openverse, licença aberta, até 6 s)' },
|
|
179
200
|
musicas: { internet: 'acao:trilha termo:"lofi" (Openverse, 30 s ou mais)', automatica: 'sem trilha pedida, o PrimoCode escolhe uma sem repetir as últimas' },
|
|
201
|
+
movimentos: { quantos: Object.keys(require('./movimentos.js').CATALOGO).length, acao: 'movimentos', exemplo: 'termo:"quique" → movimento:"quicar-de-baixo"' },
|
|
180
202
|
fontes: { quantas: fontes || 128, acao: 'fontes', nota: 'Google Fonts, por tipo (condensada, display, serifada, manuscrita…)' },
|
|
181
203
|
imagens: 'acao:imagem termo:"…" (Openverse e Wikimedia; Google com chave) — só quando a pessoa pediu imagem',
|
|
182
204
|
};
|
|
183
205
|
}
|
|
184
206
|
|
|
185
|
-
|
|
207
|
+
/** O ícone 3D que É a coisa (nota de palavra inteira), ou null: "pizza" acha, "joel" não. */
|
|
208
|
+
function melhorIcone3d(termo, piso = 80) {
|
|
209
|
+
const ts = termos(termo);
|
|
210
|
+
if (!ts.length) return null;
|
|
211
|
+
let melhor = null;
|
|
212
|
+
for (const [nome, , kw = ''] of fluent()) {
|
|
213
|
+
const n = Math.max(nota(nome, ts), nota(kw, ts) * 0.8);
|
|
214
|
+
if (n >= piso && (!melhor || n > melhor.n || (n === melhor.n && nome.length < melhor.nome.length))) melhor = { nome, n };
|
|
215
|
+
}
|
|
216
|
+
return melhor ? 'fluent3d:' + melhor.nome : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { melhorIcone3d, procurarIcones3d, procurarAnimacoes, baixar, cardapio, termos, PT_EN };
|
package/lib/estudio/diretor.js
CHANGED
|
@@ -288,6 +288,7 @@ REGRAS QUE SEPARAM UM VÍDEO BOM DE UM GENÉRICO:
|
|
|
288
288
|
5. RITMO VARIADO. Cenas de durações diferentes e transições diferentes. Tudo em fade parece slide.
|
|
289
289
|
5b. NADA DE GAVETA. Não existe tema pronto, layout pronto nem fundo pronto: é você que decide a cor, a tipografia e a composição DESTE vídeo. Dois vídeos seus sobre assuntos diferentes não podem sair parecidos.
|
|
290
290
|
6. MÍDIA COM PROPÓSITO. Só peça imagem ou logo quando ela PROVA algo. Diga o termo de busca de verdade ("painel solar em telhado residencial", não "imagem legal").
|
|
291
|
+
6b. CADA FRASE PEDE UMA ROTA DE IMAGEM, nesta ordem: número ou citação → nenhuma (a tipografia é a imagem); fala do produto → a tela ou a logo dele; coisa concreta → o vídeo DELA, com a busca no substantivo ("espresso cup", não a frase inteira); ideia abstrata → textura que se move. A imagem entra logo DEPOIS da palavra que a nomeia, nunca antes.
|
|
291
292
|
7. MOVIMENTO. Em cada cena diga em "chaves" o movimento principal — é ele que tira a cara de apresentação parada.
|
|
292
293
|
8. CÂMERA COM PARCIMÔNIA. Movimento de quadro em UMA cena a cada duas ou três ("camera": "nenhuma" nas outras): o que dá ritmo é alternar cena parada e cena que se move. Em cena com foto de fundo, prefira "aproximar" ou "empurrar".
|
|
293
294
|
9. UMA VOZ OU DUAS. Se o pedido fala em dois narradores, duas vozes, diálogo, conversa ou entrevista, escreva como DIÁLOGO: dê nome a cada um em "vozes" (com vozes diferentes) e diga em cada cena quem fala ("falante"). Com uma voz só, deixe "vozes" e "falante" de fora.
|
package/lib/estudio/falantes.js
CHANGED
|
@@ -35,22 +35,46 @@ function normalizar(roteiro) {
|
|
|
35
35
|
return r;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/* A VOZ PELO GÊNERO (11/10, "os personagens falam com vozes diferentes em
|
|
39
|
+
cada cena"): o par antigo dava a primeira voz livre a quem falasse
|
|
40
|
+
primeiro — a Ana saía com a voz do Antonio. Agora o NOME decide o gênero e
|
|
41
|
+
cada falante ganha uma voz do Gemini só dele, do começo ao fim do vídeo.
|
|
42
|
+
O que a IA disse (roteiro.vozes, personagens.x.genero) manda. */
|
|
43
|
+
const MASCULINAS = ['Charon', 'Puck', 'Orus', 'Fenrir'];
|
|
44
|
+
const FEMININAS = ['Kore', 'Aoede', 'Leda', 'Zephyr'];
|
|
45
|
+
const ROBO = /\b(ia|ai|assistente|robo|robô|bot|claude|gpt|chatgpt|gemini|primo|primocode|copilot|agente|mascote)\b/i;
|
|
46
|
+
const NOMES_FEMININOS = /^(beatriz|raquel|isabel|carmen|ester|ines|inês|alice|ruth|nicole|lis|luz|rose|ingrid|kelly|jennifer|michelle|rachel|karen|manu|gabi|bel|mel|vitoria|vitória|yasmin|jasmin|carol|isis|ísis|iris|íris|liz|mirian|miriam|ellen|helen|lourdes|mercedes|dolores|socorro|aline|daiane|tais|taís|thais|thaís|lilian|vivian|suellen|jaqueline|simone|rosangela|rosângela|irene|lucimar|nair|jussara|joice|joyce|cris|dani|fer|ju|lu|bia|mãe|mae|vó|vo|avó|avo|tia|dona|senhora|professora|menina|garota|mulher|rainha|princesa|moça|moca)$/;
|
|
47
|
+
const NOMES_MASCULINOS_EM_A = /^(luca|lucca|joshua|nicola|andrea|elias|matias|tobias|jonas|lucas|thomas|tomas|tomás|bruna?x|zuma)$/;
|
|
48
|
+
function generoDe(nome, pedido = {}) {
|
|
49
|
+
const g = String((pedido && pedido.genero) || '').toLowerCase();
|
|
50
|
+
if (/^(f|fem|feminin|mulher)/.test(g)) return 'f';
|
|
51
|
+
if (/^(m|masc|homem)/.test(g)) return 'm';
|
|
52
|
+
const n = String(nome || '').trim().toLowerCase().split(/\s+/)[0];
|
|
53
|
+
if (ROBO.test(n)) return 'robo';
|
|
54
|
+
if (NOMES_FEMININOS.test(n)) return 'f';
|
|
55
|
+
if (NOMES_MASCULINOS_EM_A.test(n)) return 'm';
|
|
56
|
+
return /a$/.test(n) ? 'f' : 'm';
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
/**
|
|
39
60
|
* A voz de cada parte, na ordem. Com falante e voz no mapa, a do mapa; com
|
|
40
|
-
* falante FORA do mapa
|
|
41
|
-
*
|
|
42
|
-
* sem falante, a `padrao` (roteiro.voz).
|
|
61
|
+
* falante FORA do mapa, uma voz do Gemini do gênero do nome que ainda não
|
|
62
|
+
* fala neste vídeo; sem falante, a `padrao` (roteiro.voz).
|
|
43
63
|
*/
|
|
44
64
|
function vozesDasPartes(partes, roteiro = {}, padrao = '') {
|
|
45
65
|
const mapa = { ...((roteiro && roteiro.vozes) || {}) };
|
|
46
66
|
const ocupadas = new Set(Object.values(mapa).map((v) => String(v).toLowerCase()));
|
|
47
67
|
if (padrao) ocupadas.add(String(padrao).toLowerCase());
|
|
68
|
+
const pedidos = (roteiro && roteiro.personagens) || {};
|
|
69
|
+
const livre = (lista, k) => lista.find((v) => !ocupadas.has(v.toLowerCase())) || lista[k % lista.length];
|
|
48
70
|
return (partes || []).map((pt) => {
|
|
49
71
|
const f = nomeFalante(pt && pt.falante);
|
|
50
72
|
if (!f) return padrao || '';
|
|
51
73
|
if (!mapa[f]) {
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
const g = generoDe(f, pedidos[f] || pedidos[Object.keys(pedidos).find((k) => k.toLowerCase() === f)] || {});
|
|
75
|
+
// O mascote tem a voz mais leve das masculinas (Puck), se estiver livre.
|
|
76
|
+
mapa[f] = g === 'f' ? livre(FEMININAS, Object.keys(mapa).length) : g === 'robo' && !ocupadas.has('puck') ? 'Puck' : livre(MASCULINAS, Object.keys(mapa).length);
|
|
77
|
+
ocupadas.add(mapa[f].toLowerCase());
|
|
54
78
|
}
|
|
55
79
|
return mapa[f];
|
|
56
80
|
});
|
|
@@ -94,4 +118,4 @@ async function narrarPorFalante(lista, mp3, { narrarUm, juntar }) {
|
|
|
94
118
|
return { ok: true, voz: [...new Set(feitos.map((x) => x.v.voz).filter(Boolean))].join(' + '), segundos: Math.round(antes * 1000) / 1000, partes, falantes: grupos.length };
|
|
95
119
|
}
|
|
96
120
|
|
|
97
|
-
module.exports = { normalizar, vozesDasPartes, narrarPorFalante, PAR };
|
|
121
|
+
module.exports = { generoDe, MASCULINAS, FEMININAS, normalizar, vozesDasPartes, narrarPorFalante, PAR };
|
package/lib/estudio/hibrido.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* traduzir(roteiro) qualquer roteiro (vox, motion, composição) vira os
|
|
22
22
|
* tipos do cinema; o que o cinema não usa (a fonte, o
|
|
23
23
|
* destaque) fica guardado por parte.
|
|
24
|
+
* rotaDaParte(parte, …) qual imagem a frase pede (4 rotas) e em que palavra
|
|
25
|
+
* o corte se ancora (b-roll-finder, Arthur 17/09).
|
|
24
26
|
* termosDeBusca(roteiro) o que procurar de b-roll para cada parte.
|
|
25
27
|
* enfeitar(plano, …) o kit por cima do plano que o cinema montou.
|
|
26
28
|
*/
|
|
@@ -36,6 +38,65 @@ const limpo = (x) => String(x || '').replace(/\s+/g, ' ').trim();
|
|
|
36
38
|
const BUSCA_PADRAO = ['technology abstract light', 'people working on laptop', 'hands typing keyboard close up',
|
|
37
39
|
'data center servers', 'city night timelapse', 'team meeting office', 'developer code screen', 'futuristic digital network'];
|
|
38
40
|
|
|
41
|
+
/* AS 4 ROTAS DO B-ROLL (do b-roll-finder): a frase diz qual imagem pede, e
|
|
42
|
+
a primeira regra que casa decide. O roteiro manda: `rota` e `busca` na parte.
|
|
43
|
+
tipografia número, gráfico, citação → sem imagem: o texto é a imagem
|
|
44
|
+
produto a frase fala da marca/produto → a tela/foto da marca
|
|
45
|
+
literal uma coisa concreta ("café") → o vídeo de banco DELA
|
|
46
|
+
abstrato ideia que teria de ser desenhada → textura que se move
|
|
47
|
+
O termo sai da PALAVRA-ÂNCORA, não da frase inteira; e o clipe troca 0,2 s
|
|
48
|
+
DEPOIS dela — "atrasado parece intenção, adiantado parece erro". */
|
|
49
|
+
const ROTAS = ['tipografia', 'produto', 'literal', 'abstrato'];
|
|
50
|
+
const ATRASO_ANCORA = require('./edicao.js').DEPOIS_DA_PALAVRA;
|
|
51
|
+
const PRODUTO = /^(app|aplicativ|ferrament|painel|dashboard|plataform|site|terminal|cli|sistema|interface|software|agente)/;
|
|
52
|
+
/* As COISAS que se filmam: substantivo concreto → o termo do banco, em inglês.
|
|
53
|
+
O ícone da edição (edicao.iconeDe) casa verbo também ("começa" → foguete),
|
|
54
|
+
bom para emoji, ruim para b-roll literal; aqui só entra o que tem cara. */
|
|
55
|
+
const COISAS = [
|
|
56
|
+
[['cafe'], 'coffee cup morning'], [['cachorro', 'cao'], 'dog'], [['gato'], 'cat'], [['casa', 'lar'], 'modern house'],
|
|
57
|
+
[['carro'], 'car driving road'], [['cidade', 'rua'], 'city street people'], [['mar', 'praia', 'oceano'], 'ocean waves'],
|
|
58
|
+
[['escritorio', 'reuniao'], 'team meeting office'], [['mao', 'maos'], 'hands close up'], [['teclado', 'digit'], 'hands typing keyboard close up'],
|
|
59
|
+
[['computador', 'notebook', 'laptop'], 'laptop on desk'], [['codigo', 'programa'], 'developer code screen'], [['celular', 'telefone'], 'smartphone in hand'],
|
|
60
|
+
[['livro', 'aula', 'escola'], 'library books'], [['musica'], 'music concert'], [['pessoa', 'equipe', 'cliente', 'gente'], 'people working together'],
|
|
61
|
+
[['servidor', 'nuvem'], 'data center servers'], [['dinheiro'], 'money cash'], [['relogio'], 'clock ticking'],
|
|
62
|
+
];
|
|
63
|
+
const coisaDe = (limpo) => { for (const [raizes, termo] of COISAS) if (raizes.some((r) => (r.length <= 3 ? limpo === r : limpo.startsWith(r)))) return termo; return null; };
|
|
64
|
+
const ABSTRATO = ['abstract light texture', 'ink in water slow motion', 'bokeh lights dark background', 'smoke texture dark'];
|
|
65
|
+
|
|
66
|
+
/** A rota e a âncora de uma parte. `palavras`: [{texto, de, ate}] da voz (ou só o texto). */
|
|
67
|
+
function rotaDaParte(parte, palavras, { marca = null, i = 0 } = {}) {
|
|
68
|
+
const p = parte || {};
|
|
69
|
+
const ed = require('./edicao.js');
|
|
70
|
+
const ws = (palavras && palavras.length ? palavras : String(p.fala || p.texto || p.titulo || '').split(/\s+/).filter(Boolean).map((texto) => ({ texto })));
|
|
71
|
+
const limpa = (x) => String(x || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
72
|
+
const achar = (alvo) => { const a = limpa(String(alvo || '').split(/\s+/)[0]); return a ? ws.find((w) => limpa(w.texto) === a) || null : null; };
|
|
73
|
+
// A palavra que carrega a frase: a mais longa que não é vazia ("confiança", não "um").
|
|
74
|
+
const forte = () => ws.reduce((f, w) => (ed.peso(w.texto) && limpa(w.texto).length > (f ? limpa(f.texto).length : 0) ? w : f), null);
|
|
75
|
+
const tipo = p.tipoOriginal || p.tipo || '';
|
|
76
|
+
let r;
|
|
77
|
+
if (ROTAS.includes(p.rota)) r = { rota: p.rota, palavra: null };
|
|
78
|
+
else if (['numero', 'grafico', 'citacao', 'numeros'].includes(tipo)) r = { rota: 'tipografia', palavra: null };
|
|
79
|
+
else {
|
|
80
|
+
const e = ed.rotaDeApoio(ws, { marca });
|
|
81
|
+
const prod = ws.find((w) => ed.peso(w.texto) && PRODUTO.test(limpa(w.texto)));
|
|
82
|
+
const coisa = ws.find((w) => ed.peso(w.texto) && coisaDe(limpa(w.texto)));
|
|
83
|
+
if (e.rota === 'marca' || prod) r = { rota: 'produto', palavra: e.rota === 'marca' ? e.palavra : prod, termo: 'software interface on laptop screen' };
|
|
84
|
+
else if (e.rota === 'numero') r = { rota: 'tipografia', palavra: e.palavra };
|
|
85
|
+
else if (coisa) r = { rota: 'literal', palavra: coisa, termo: coisaDe(limpa(coisa.texto)) };
|
|
86
|
+
else r = { rota: p.busca ? 'literal' : 'abstrato', palavra: forte(), termo: ABSTRATO[i % ABSTRATO.length] };
|
|
87
|
+
}
|
|
88
|
+
// A âncora: a do roteiro, a palavra-tese, ou a que a regra achou.
|
|
89
|
+
r.palavra = achar(p.ancora) || r.palavra || achar(p.destaque) || forte();
|
|
90
|
+
if (r.rota === 'tipografia') r.termo = null;
|
|
91
|
+
else if (p.busca) r.termo = String(p.busca);
|
|
92
|
+
return r;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Quando o clipe troca, no tempo da voz: 0,2 s depois da âncora. null sem tempo. */
|
|
96
|
+
function tempoDaAncora(palavra) {
|
|
97
|
+
return palavra && Number.isFinite(palavra.de) ? r2(palavra.de + ATRASO_ANCORA) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
39
100
|
/** O roteiro de qualquer estilo → o roteiro do cinema. Devolve {roteiro, extras}. */
|
|
40
101
|
function traduzir(roteiro) {
|
|
41
102
|
const partes = (roteiro && roteiro.partes) || [];
|
|
@@ -71,12 +132,16 @@ function traduzir(roteiro) {
|
|
|
71
132
|
else if (tipo === 'cta' || tipo === 'logo' || tipo === 'fim') q = { tipo: 'final', linhas: [p.titulo || p.texto, p.link].filter(Boolean).map(limpo), ...base };
|
|
72
133
|
else q = { tipo: 'montagem', linhas: [p.titulo || p.texto || p.fala].filter(Boolean).map(limpo), ...base };
|
|
73
134
|
saida.push(q);
|
|
74
|
-
extras.push({ tipoOriginal: tipo, fonte: tipo === 'abertura' && p.sub ? p.sub : (p.fonte || (tipo === 'citacao' ? p.autor : '')), autor: p.autor || '', busca: p.busca || ''
|
|
135
|
+
extras.push({ tipoOriginal: tipo, fonte: tipo === 'abertura' && p.sub ? p.sub : (p.fonte || (tipo === 'citacao' ? p.autor : '')), autor: p.autor || '', busca: p.busca || '',
|
|
136
|
+
...(p.rota ? { rota: p.rota } : {}), ...(p.ancora ? { ancora: p.ancora } : {}) });
|
|
75
137
|
});
|
|
76
138
|
return { roteiro: { ...roteiro, partes: saida, estilo: 'hibrido' }, extras };
|
|
77
139
|
}
|
|
78
140
|
|
|
79
|
-
|
|
141
|
+
/* A parte com o que o roteiro original disse dela (o tipo, a busca, a rota). */
|
|
142
|
+
const comExtras = (p, ex) => ({ ...p, ...(ex ? { tipoOriginal: ex.tipoOriginal, busca: ex.busca || p.busca, rota: ex.rota || p.rota, ancora: ex.ancora || p.ancora } : {}) });
|
|
143
|
+
|
|
144
|
+
/** O que procurar de b-roll para cada parte: o termo da rota dela, a busca do roteiro, ou o padrão. */
|
|
80
145
|
function termosDeBusca(roteiro, extras = []) {
|
|
81
146
|
const global = [].concat(roteiro.busca || []).filter(Boolean);
|
|
82
147
|
// Escopo fechado: só os termos da pessoa, nunca o padrão genérico.
|
|
@@ -84,11 +149,20 @@ function termosDeBusca(roteiro, extras = []) {
|
|
|
84
149
|
const lista = global.length ? global : fechado ? [] : BUSCA_PADRAO;
|
|
85
150
|
const termos = new Set();
|
|
86
151
|
(roteiro.partes || []).forEach((p, i) => {
|
|
87
|
-
const
|
|
88
|
-
|
|
152
|
+
const q = comExtras(p, extras[i]);
|
|
153
|
+
const r = rotaDaParte(q, null, { marca: roteiro.marca && roteiro.marca.nome, i });
|
|
154
|
+
/* Escopo fechado: só a busca que o roteiro deu à parte. O termo da rota sai
|
|
155
|
+
do dicionário (textura, tela de app, e o literal: "cachorro" → "dog") e
|
|
156
|
+
furava o escopo da pessoa (revisão do #99). */
|
|
157
|
+
if (r.termo && (!fechado || q.busca)) termos.add(r.termo);
|
|
89
158
|
});
|
|
90
|
-
|
|
91
|
-
|
|
159
|
+
/* O corte em 8 guarda lugar para a busca global do roteiro (no fechado, é o
|
|
160
|
+
escopo da pessoa): as frases vinham antes e a empurravam para fora
|
|
161
|
+
(revisão do #99). O padrão genérico é só enchimento, esse pode cair. */
|
|
162
|
+
const fixos = global.slice(0, 8);
|
|
163
|
+
const frases = [...termos].filter((t) => !fixos.includes(t)).slice(0, 8 - fixos.length);
|
|
164
|
+
const resto = global.length ? [] : lista.filter((t) => !termos.has(t));
|
|
165
|
+
return [...frases, ...fixos, ...resto].slice(0, 8);
|
|
92
166
|
}
|
|
93
167
|
|
|
94
168
|
/* A câmera de cada cena do cinema: empurra e desliza, alternando o lado. A
|
|
@@ -109,7 +183,7 @@ function camera(d, i, deitado, forte) {
|
|
|
109
183
|
/* SEM CARA DE IA (05/10): o HUD (cantos, timecode, fps, rótulos nas bordas)
|
|
110
184
|
e as partículas eram exatamente a lista do "ruim" que o Joel juntou. Agora
|
|
111
185
|
só entram quando o roteiro pede: roteiro.hud === true, roteiro.particulas === true. */
|
|
112
|
-
function enfeitar(plano, { extras = [], partes = [], marca = {}, largura, altura, hud = false, particulas = false } = {}) {
|
|
186
|
+
function enfeitar(plano, { extras = [], partes = [], marca = {}, largura, altura, hud = false, particulas = false, tempos = null, fortes = {}, fotos = [] } = {}) {
|
|
113
187
|
if (!plano || !Array.isArray(plano.cenas)) return plano;
|
|
114
188
|
const W = largura || (plano.formato && plano.formato.largura) || 1920;
|
|
115
189
|
const H = altura || (plano.formato && plano.formato.altura) || 1080;
|
|
@@ -126,6 +200,7 @@ function enfeitar(plano, { extras = [], partes = [], marca = {}, largura, altura
|
|
|
126
200
|
const n = p.tipo === 'depoimento' ? Math.max(1, (p.clipes || []).length) : 1;
|
|
127
201
|
for (let q = 0; q < n; q++) parteDaCena.push(i);
|
|
128
202
|
});
|
|
203
|
+
if (tempos) ancorar(plano, { extras, partes, marca, tempos, fortes, fotos, parteDaCena });
|
|
129
204
|
plano.cenas.forEach((cena, c) => {
|
|
130
205
|
const iParte = parteDaCena[c] != null ? parteDaCena[c] : c;
|
|
131
206
|
const ex = extras[iParte] || {};
|
|
@@ -166,4 +241,65 @@ function enfeitar(plano, { extras = [], partes = [], marca = {}, largura, altura
|
|
|
166
241
|
return plano;
|
|
167
242
|
}
|
|
168
243
|
|
|
169
|
-
|
|
244
|
+
/* O B-ROLL NA PALAVRA: a cena de montagem (ou de frase) troca para o clipe da
|
|
245
|
+
rota dela 0,2 s depois da âncora — antes, segue a montagem do cinema. Rota
|
|
246
|
+
tipografia tira as fotos: o número já é a imagem. O tempo da cena é o do
|
|
247
|
+
cinema: a voz entra 0,2 s depois do corte (off). */
|
|
248
|
+
function ancorar(plano, { extras, partes, marca, tempos, fortes, fotos, parteDaCena }) {
|
|
249
|
+
const cinema = require('./cinema.js');
|
|
250
|
+
const off = 0.2;
|
|
251
|
+
let narrI = 0;
|
|
252
|
+
const tempoDe = partes.map((p, i) => (p.tipo !== 'depoimento' && cinema.falaDe(p) !== '' && !(p.falaForte && fortes[i]) ? tempos[narrI++] || null : null));
|
|
253
|
+
const ehFoto = (e) => (e.tipo === 'imagem' || e.tipo === 'video' || (e.tipo === 'forma' && e.fundo && e.fundo.tipo === 'gradiente')) && e.largura === '100%';
|
|
254
|
+
const plan = [], vez = [];
|
|
255
|
+
plano.cenas.forEach((cena, c) => {
|
|
256
|
+
const i = parteDaCena[c] != null ? parteDaCena[c] : c;
|
|
257
|
+
const p = partes[i], tp = tempoDe[i];
|
|
258
|
+
if (!p || !['montagem', 'frase', 'numeros'].includes(p.tipo) || (parteDaCena.indexOf(i) !== c)) return;
|
|
259
|
+
const r = rotaDaParte(comExtras(p, extras[i]), tp && tp.palavras, { marca: marca.nome, i });
|
|
260
|
+
plan.push({ parte: i, rota: r.rota, ancora: r.palavra ? r.palavra.texto : null, termo: r.termo || null });
|
|
261
|
+
/* Produto: a tela DA MARCA (o print do acervo) antes do vídeo de banco. Antes
|
|
262
|
+
a regra perguntava se o alt não era termo de busca, mas o conjunto tinha o
|
|
263
|
+
alt de todas as fotos: nunca casava, e o painel ficava de fora (revisão do #99). */
|
|
264
|
+
const f = r.rota === 'tipografia' ? null : r.rota === 'produto' ? fotos.find((x) => x.cat === 'tela' && !x.banco) || fotos.find((x) => x.alt === r.termo)
|
|
265
|
+
: fotos.find((x) => x.alt === r.termo && x.tipo === 'video') || fotos.find((x) => x.alt === r.termo);
|
|
266
|
+
const ta = tp ? tempoDaAncora(r.palavra) : null;
|
|
267
|
+
const de = ta == null ? null : r2(off + ta - tp.de);
|
|
268
|
+
vez.push({ cena, r, f: f && de != null && (cena.duracao || 2) - de >= 0.8 ? f : null, de }); // sem 0,8 s para ver, fica a montagem
|
|
269
|
+
});
|
|
270
|
+
/* O clipe de uma frase não aparece ANTES da palavra dela (nem na montagem
|
|
271
|
+
de outra cena): quem vê o café antes de ouvir "café" sente o erro. */
|
|
272
|
+
const reservado = new Set(vez.filter((x) => x.f).map((x) => x.f.src));
|
|
273
|
+
const livres = fotos.filter((x) => !reservado.has(x.src));
|
|
274
|
+
let n = 0;
|
|
275
|
+
for (const { cena, r, f, de } of vez) {
|
|
276
|
+
let els = cena.elementos || [];
|
|
277
|
+
if (r.rota === 'tipografia') { cena.elementos = els.filter((e) => !ehFoto(e)); continue; }
|
|
278
|
+
for (const e of els) if ((e.tipo === 'imagem' || e.tipo === 'video') && reservado.has(e.src) && livres.length) {
|
|
279
|
+
const g = livres[n++ % livres.length];
|
|
280
|
+
Object.assign(e, { tipo: g.tipo === 'video' ? 'video' : 'imagem', src: g.src, ...(g.tipo === 'video' ? { volume: 0 } : {}) });
|
|
281
|
+
}
|
|
282
|
+
if (!f) continue;
|
|
283
|
+
const d = cena.duracao || 2;
|
|
284
|
+
els = els.filter((e) => !(ehFoto(e) && e.de >= de));
|
|
285
|
+
for (const e of els) if (ehFoto(e) && (e.ate == null || e.ate > de)) e.ate = de;
|
|
286
|
+
// Um resto de foto de um instante vira um piscar: a foto de antes cobre o buraco.
|
|
287
|
+
for (const e of els.filter((x) => ehFoto(x) && x.ate - (x.de || 0) < 0.25)) {
|
|
288
|
+
for (const x of els) if (x !== e && ehFoto(x) && x.ate === e.de && x.tipo === e.tipo) x.ate = e.ate;
|
|
289
|
+
els = els.filter((x) => x !== e);
|
|
290
|
+
}
|
|
291
|
+
const v = f.tipo === 'video';
|
|
292
|
+
const vida = r2(d - de);
|
|
293
|
+
const novo = [{ tipo: v ? 'video' : 'imagem', src: f.src, ...(v ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover', de, ate: r2(d), broll: r.rota,
|
|
294
|
+
anima: { escala: [{ t: 0, v: 1.12 }, { t: vida, v: 1.02, e: 'linear' }] } },
|
|
295
|
+
{ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%', de, ate: r2(d),
|
|
296
|
+
fundo: { tipo: 'gradiente', cores: ['rgba(7,7,10,0.35)', 'rgba(7,7,10,0.5)', 'rgba(7,7,10,0.75)'], angulo: 180 } }];
|
|
297
|
+
const k = els.findIndex((e) => e.tipo === 'texto' || e.tipo === 'kit');
|
|
298
|
+
els.splice(k < 0 ? els.length : k, 0, ...novo);
|
|
299
|
+
cena.elementos = els;
|
|
300
|
+
}
|
|
301
|
+
plano.broll = plan;
|
|
302
|
+
return plan;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = { traduzir, termosDeBusca, enfeitar, rotaDaParte, tempoDaAncora, ancorar, ROTAS, ATRASO_ANCORA, BUSCA_PADRAO };
|