primocode 10.1.0 → 10.3.0-beta.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/act.js +5 -0
- package/lib/browser.js +1 -1
- package/lib/catalogo.js +17 -8
- package/lib/estudio/assets.js +75 -1
- package/lib/estudio/assinatura.js +108 -0
- package/lib/estudio/assinaturas/fluido.json +15 -0
- package/lib/estudio/assinaturas/gerativo.json +15 -0
- package/lib/estudio/batida.js +184 -0
- package/lib/estudio/biblioteca.js +52 -12
- package/lib/estudio/decisao.js +106 -0
- package/lib/estudio/diretor.js +70 -4
- package/lib/estudio/edicao.js +34 -16
- package/lib/estudio/efeitos.js +95 -0
- package/lib/estudio/estilo.js +17 -6
- package/lib/estudio/falantes.js +97 -0
- package/lib/estudio/fluido.js +110 -16
- package/lib/estudio/gerativo.js +309 -85
- package/lib/estudio/hibrido.js +1 -1
- package/lib/estudio/index.js +252 -45
- package/lib/estudio/kit/fluido.jsx +130 -9
- package/lib/estudio/kit/gerativo.jsx +90 -21
- package/lib/estudio/kit/marca.jsx +58 -1
- package/lib/estudio/kit/tres.jsx +4 -1
- package/lib/estudio/kit.js +2 -0
- package/lib/estudio/plano.js +5 -1
- package/lib/estudio/print-site.js +152 -0
- package/lib/estudio/referencias.js +47 -25
- package/lib/estudio/remotion.js +74 -3
- package/lib/estudio/revisor.js +117 -0
- package/lib/estudio/variedade.js +83 -21
- 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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* print-site.js — o print DE VERDADE de um site, para virar cena 3D.
|
|
3
|
+
*
|
|
4
|
+
* "Se eu mandar um site e pedir, dependendo do modelo, ela pode tirar print
|
|
5
|
+
* do site, transformar em 3D e pôr no Motion." (Joel, 09/10/2026)
|
|
6
|
+
*
|
|
7
|
+
* Até aqui, quando o vídeo era SOBRE um site, o estúdio desenhava uma janela
|
|
8
|
+
* de mentira (a peça `site` do kit: manchete, botão e um bloco de cor). Quem
|
|
9
|
+
* conhece o site olhava e não reconhecia — era um site genérico com o nome
|
|
10
|
+
* da marca. O navegador escondido já existia (browser.js, o revisor), mas
|
|
11
|
+
* só servia para conferir página; o `browser_screenshot` tirava print da
|
|
12
|
+
* aba visível da pessoa, do tamanho da janela dela, e só se o agente
|
|
13
|
+
* lembrasse de chamar.
|
|
14
|
+
*
|
|
15
|
+
* Aqui o print sai do navegador ESCONDIDO, com a tela de um computador
|
|
16
|
+
* (1440 de largura) ou de um celular (390, densidade 2), da página inteira
|
|
17
|
+
* (até um teto, para a imagem não virar 40 MB) ou só da primeira dobra. A
|
|
18
|
+
* imagem vai para midia/site/ do projeto e volta com a proporção medida,
|
|
19
|
+
* que é o que a peça `printsite` do kit precisa para rolar a página.
|
|
20
|
+
*
|
|
21
|
+
* Não decide se o vídeo usa o print. Isso é o modelo, pelo cardápio do
|
|
22
|
+
* estudio_midia: o pedido traz um site e o vídeo é sobre ele → vale; o vídeo
|
|
23
|
+
* só cita a marca → não precisa.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
const TELAS = {
|
|
32
|
+
computador: { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false },
|
|
33
|
+
celular: { width: 390, height: 844, deviceScaleFactor: 2, mobile: true },
|
|
34
|
+
};
|
|
35
|
+
// Página mais longa que isto é cortada: um print de 1440×20000 passa de 10 MB
|
|
36
|
+
// e o vídeo nunca mostra mais que umas três telas rolando.
|
|
37
|
+
const ALTURA_MAX = 6000;
|
|
38
|
+
|
|
39
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
40
|
+
|
|
41
|
+
/** A URL que o navegador pode abrir — só http(s). null = não é site. */
|
|
42
|
+
function normalizarUrl(url) {
|
|
43
|
+
let u = String(url || '').trim();
|
|
44
|
+
if (!u) return null;
|
|
45
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(u)) u = 'https://' + u;
|
|
46
|
+
try {
|
|
47
|
+
const p = new URL(u);
|
|
48
|
+
return /^https?:$/.test(p.protocol) && p.hostname ? p.toString() : null;
|
|
49
|
+
} catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Antes do print, a página desce até o fim e volta: foto com loading="lazy"
|
|
53
|
+
só carrega quando chega perto da tela, e sem isto o print saía com metade
|
|
54
|
+
das imagens em branco. */
|
|
55
|
+
const ROLAR_TUDO = `(async () => {
|
|
56
|
+
const passo = innerHeight * 0.8, fim = Math.min(document.documentElement.scrollHeight, ${ALTURA_MAX});
|
|
57
|
+
for (let y = 0; y < fim; y += passo) { scrollTo(0, y); await new Promise((r) => setTimeout(r, 120)); }
|
|
58
|
+
scrollTo(0, 0); await new Promise((r) => setTimeout(r, 300));
|
|
59
|
+
return true;
|
|
60
|
+
})()`;
|
|
61
|
+
const MEDIR = `JSON.stringify({ altura: Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0), titulo: document.title || '' })`;
|
|
62
|
+
|
|
63
|
+
async function valor(sessao, expression, awaitPromise = false) {
|
|
64
|
+
const r = await sessao.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise }, 30000).catch(() => null);
|
|
65
|
+
return r && r.result ? r.result.value : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Tira o print do site e grava na pasta do projeto.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} url
|
|
72
|
+
* @param {object} op { pasta, celular, inteira (padrão true), alturaMax, navegador, assentar }
|
|
73
|
+
* @returns {Promise<{ok, src, url, titulo, tela, largura, altura, proporcao, cortada, elemento} | {ok:false, error}>}
|
|
74
|
+
*/
|
|
75
|
+
async function capturarSite(url, op = {}) {
|
|
76
|
+
const alvo = normalizarUrl(url);
|
|
77
|
+
if (!alvo) return { ok: false, error: `"${url || ''}" não é o endereço de um site (http ou https).` };
|
|
78
|
+
if (!op.pasta) return { ok: false, error: 'o print vai para a pasta do projeto — diga o projeto (id).' };
|
|
79
|
+
const navegador = op.navegador || require('../browser.js');
|
|
80
|
+
const tela = op.celular ? 'celular' : 'computador';
|
|
81
|
+
const metrica = TELAS[tela];
|
|
82
|
+
// No celular a página é três vezes mais comprida: o teto é menor.
|
|
83
|
+
const teto = Math.max(metrica.height, Number(op.alturaMax) || (metrica.mobile ? 4000 : ALTURA_MAX));
|
|
84
|
+
|
|
85
|
+
let aba;
|
|
86
|
+
try {
|
|
87
|
+
/* A aba nasce em branco e só depois navega: a tela (largura, celular)
|
|
88
|
+
tem de valer ANTES do site carregar, senão o site responsivo já
|
|
89
|
+
escolheu o layout de computador e o "print do celular" sai largo. */
|
|
90
|
+
aba = await navegador.abaEscondida('about:blank', { assentar: 0 });
|
|
91
|
+
const s = aba.sessao;
|
|
92
|
+
await s.send('Emulation.setDeviceMetricsOverride', metrica);
|
|
93
|
+
if (metrica.mobile) await s.send('Emulation.setTouchEmulationEnabled', { enabled: true }).catch(() => {});
|
|
94
|
+
await s.send('Page.navigate', { url: alvo });
|
|
95
|
+
const prazo = Date.now() + (op.prazo || 20000);
|
|
96
|
+
while (Date.now() < prazo) {
|
|
97
|
+
if (await valor(s, 'document.readyState') === 'complete') break;
|
|
98
|
+
await sleep(200);
|
|
99
|
+
}
|
|
100
|
+
await sleep(op.assentar == null ? 1500 : op.assentar);
|
|
101
|
+
if (op.inteira !== false) await valor(s, ROLAR_TUDO, true);
|
|
102
|
+
|
|
103
|
+
let medida = {};
|
|
104
|
+
try { medida = JSON.parse(await valor(s, MEDIR) || '{}'); } catch { /* segue com a tela */ }
|
|
105
|
+
const alturaPagina = Math.max(metrica.height, Number(medida.altura) || metrica.height);
|
|
106
|
+
const altura = op.inteira === false ? metrica.height : Math.min(alturaPagina, teto);
|
|
107
|
+
|
|
108
|
+
const r = await s.send('Page.captureScreenshot', {
|
|
109
|
+
format: 'jpeg', quality: 88, captureBeyondViewport: true,
|
|
110
|
+
clip: { x: 0, y: 0, width: metrica.width, height: altura, scale: 1 },
|
|
111
|
+
}, 60000);
|
|
112
|
+
if (!r || !r.data) return { ok: false, error: 'o navegador devolveu um print vazio.' };
|
|
113
|
+
|
|
114
|
+
const host = new URL(alvo).hostname.replace(/^www\./, '').replace(/[^a-z0-9.-]/gi, '-');
|
|
115
|
+
const src = `midia/site/${host}-${tela}${op.inteira === false ? '-dobra' : ''}.jpg`;
|
|
116
|
+
const cheio = path.join(op.pasta, src);
|
|
117
|
+
fs.mkdirSync(path.dirname(cheio), { recursive: true });
|
|
118
|
+
fs.writeFileSync(cheio, Buffer.from(r.data, 'base64'));
|
|
119
|
+
|
|
120
|
+
const proporcao = Math.round((altura / metrica.width) * 1000) / 1000;
|
|
121
|
+
const feito = {
|
|
122
|
+
ok: true, src, url: alvo, titulo: medida.titulo || '', tela,
|
|
123
|
+
largura: metrica.width, altura, proporcao,
|
|
124
|
+
cortada: op.inteira !== false && alturaPagina > teto,
|
|
125
|
+
};
|
|
126
|
+
feito.elemento = elementoDoPrint(feito);
|
|
127
|
+
feito.nota = 'O elemento é uma sugestão: ajuste x, y, largura e o tempo ao seu plano. '
|
|
128
|
+
+ 'rolar: true desce a página devagar; inclinacao muda o ângulo 3D.';
|
|
129
|
+
return feito;
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return { ok: false, error: `não consegui tirar o print de ${alvo}: ${e.message}` };
|
|
132
|
+
} finally {
|
|
133
|
+
if (aba) { try { await aba.fechar(); } catch { /* a aba já fechou */ } }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* O elemento de plano que mostra o print: a janela (ou o celular) em 3D.
|
|
139
|
+
* Num quadro 1920×1080 por padrão — o modelo ajusta ao formato dele.
|
|
140
|
+
*/
|
|
141
|
+
function elementoDoPrint(print, { quadroW = 1920, quadroH = 1080, t = 0 } = {}) {
|
|
142
|
+
const celular = print.tela === 'celular';
|
|
143
|
+
const largura = celular ? Math.round(quadroH * 0.42) : Math.round(quadroW * 0.66);
|
|
144
|
+
const altura = celular ? Math.round(largura * 2.05) : Math.round(largura * 0.64);
|
|
145
|
+
return {
|
|
146
|
+
tipo: 'kit', peca: 'printsite', src: print.src, proporcao: print.proporcao,
|
|
147
|
+
celular, url: print.url, t, rolar: print.proporcao > (altura / largura) * 1.2,
|
|
148
|
+
x: Math.round((quadroW - largura) / 2), y: Math.round((quadroH - altura) / 2), largura, altura,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { capturarSite, elementoDoPrint, normalizarUrl, TELAS, ALTURA_MAX };
|
|
@@ -18,14 +18,17 @@
|
|
|
18
18
|
|
|
19
19
|
'use strict';
|
|
20
20
|
|
|
21
|
-
let ATUAL = { modo: null, escopo: '' };
|
|
21
|
+
let ATUAL = { modo: null, escopo: '', explicito: false };
|
|
22
22
|
|
|
23
|
-
function definir(modo, escopo = '') {
|
|
24
|
-
ATUAL = { modo: ['aberto', 'fechado', 'sem'].includes(modo) ? modo : null, escopo: String(escopo || '').trim() };
|
|
23
|
+
function definir(modo, escopo = '', explicito = true) {
|
|
24
|
+
ATUAL = { modo: ['aberto', 'fechado', 'sem'].includes(modo) ? modo : null, escopo: String(escopo || '').trim(), explicito: !!explicito };
|
|
25
25
|
return ATUAL;
|
|
26
26
|
}
|
|
27
27
|
function atual() { return ATUAL; }
|
|
28
|
-
function limpar() { ATUAL = { modo: null, escopo: '' }; }
|
|
28
|
+
function limpar() { ATUAL = { modo: null, escopo: '', explicito: false }; }
|
|
29
|
+
|
|
30
|
+
/** Sem foto nem vídeo de fora: o "sem" e também ninguém ter dito nada (o padrão, 07/10). */
|
|
31
|
+
function semImagem({ modo } = ATUAL) { return modo !== 'aberto' && modo !== 'fechado'; }
|
|
29
32
|
|
|
30
33
|
/** Os termos do escopo fechado: "site:anthropic.com, claude ai, sonnet" → lista. */
|
|
31
34
|
function termos(escopo = ATUAL.escopo) {
|
|
@@ -41,33 +44,51 @@ function entender(resposta) {
|
|
|
41
44
|
return null;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
/* O MODO DEDUZIDO DO PEDIDO.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
/* O MODO DEDUZIDO DO PEDIDO. O padrão é SEM IMAGEM: "por padrão, NÃO
|
|
48
|
+
usar imagens. Somente quando o usuário pedir explicitamente" (Joel, 07/10).
|
|
49
|
+
O menu 1/2/3 do terminal saiu; quem quer imagem diz no pedido:
|
|
50
|
+
"adicione imagens de apoio", "quero imagens durante o vídeo",
|
|
51
|
+
"use imagens reais", "web aberta" → aberto
|
|
52
|
+
"só do site x.com", "usa apenas anthropic.com e …" → fechado (o escopo)
|
|
53
|
+
"só motion", "sem referência", "sem foto" → sem (explícito)
|
|
54
|
+
o resto → sem (o padrão) */
|
|
55
|
+
const PEDE_IMAGEM = /(adicion|coloc|use|usa|usar|quero|bota|pon|insir|inclu|com)\w*\s+(\w+\s+){0,3}(imagens?|fotos?|b-?roll|videos? de (apoio|banco)|cenas reais)\b|(imagens?|fotos?) (de apoio|reais|durante|para ilustrar|pra ilustrar|da (web|internet))|escopo aberto|web aberta|pesquis\w* (a vontade|na web|na internet)|busc\w* (imagens|fotos|videos) na (web|internet)/;
|
|
56
|
+
const NEGA_IMAGEM = /so (o )?motion|motion puro|sem refer|sem (foto|imagem|imagens|video de banco|pesquisa)|nao (pesquis|busqu)|so anima|(nao|nada de) (quero |use |usar |coloc\w* )?(imagens?|fotos?)/;
|
|
57
|
+
|
|
52
58
|
function inferir(texto) {
|
|
53
|
-
const t = String(texto || '').toLowerCase().normalize('NFD').replace(/[
|
|
54
|
-
if (
|
|
59
|
+
const t = String(texto || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
|
60
|
+
if (NEGA_IMAGEM.test(t)) return { modo: 'sem', escopo: '', explicito: true };
|
|
55
61
|
const dominios = String(texto || '').match(/\b[a-z0-9-]+(\.[a-z0-9-]+)+\.?[a-z]{2,}(\/[^\s,]*)?/gi) || [];
|
|
56
62
|
if (/\b(so|apenas|somente|unicamente)\b[^.]{0,30}\b(site|sites|link|links|fonte|fontes|pagina|de|do|da|em)\b/.test(t) && dominios.length) {
|
|
57
63
|
return { modo: 'fechado', escopo: dominios.join(', '), explicito: true };
|
|
58
64
|
}
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
if (PEDE_IMAGEM.test(t)) return { modo: 'aberto', escopo: '', explicito: true };
|
|
66
|
+
return { modo: 'sem', escopo: '', explicito: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* O QUE A PESSOA DISSE dos vídeos (assinatura.js): as últimas notas da
|
|
70
|
+
assinatura do fluido vão junto da instrução, para o agente ler ANTES de
|
|
71
|
+
escrever o roteiro — o feedback de um vídeo vale para o próximo. */
|
|
72
|
+
function notasDaAssinatura() {
|
|
73
|
+
try {
|
|
74
|
+
const notas = require('./assinatura.js').notasRecentes('fluido', 6);
|
|
75
|
+
return notas.length ? '\n[O QUE A PESSOA DISSE DOS ÚLTIMOS VÍDEOS — assinatura fluido] ' + notas.map((n) => `(${n.quando}) ${n.texto}`).join(' · ') : '';
|
|
76
|
+
} catch { return ''; }
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
/** A instrução para o agente, conforme o modo. */
|
|
66
|
-
function instrucao({
|
|
67
|
-
|
|
80
|
+
function instrucao(atual = ATUAL) { const i = instrucaoDoModo(atual); return i ? i + notasDaAssinatura() : ''; }
|
|
81
|
+
function instrucaoDoModo({ modo, escopo, explicito } = ATUAL) {
|
|
82
|
+
// O vídeo sem imagem é motion: o agente escolhe o estilo de motion que serve ao roteiro, não um fixo.
|
|
83
|
+
const motion = 'O vídeo é MOTION: tipografia, formas, ícones, animação e som. Escolha em roteiro.estilo o motion que serve a este '
|
|
84
|
+
+ 'roteiro (gerativo, fluido, impacto, vox, marca, lancamento) ou deixe vazio para o rodízio — não repita sempre o mesmo.';
|
|
85
|
+
if (modo === 'sem' && explicito) {
|
|
68
86
|
return '[REFERÊNCIAS: NENHUMA] A pessoa quer SÓ MOTION: não pesquise (nada de pesquisar_referencia nem estudio_site), '
|
|
69
|
-
+ 'não busque foto nem vídeo, não use estudio_midia de banco. Escreva o roteiro com o que o pedido já diz
|
|
70
|
-
|
|
87
|
+
+ 'não busque foto nem vídeo, não use estudio_midia de banco. Escreva o roteiro com o que o pedido já diz. ' + motion;
|
|
88
|
+
}
|
|
89
|
+
if (modo === 'sem') {
|
|
90
|
+
return '[SEM IMAGEM — o padrão] A pessoa não pediu imagem: não busque foto nem vídeo, não use estudio_midia de banco '
|
|
91
|
+
+ 'nem "busca" nas partes. Pesquisar FATOS em texto (pesquisar_referencia) pode, se o assunto pedir. ' + motion;
|
|
71
92
|
}
|
|
72
93
|
if (modo === 'fechado') {
|
|
73
94
|
const t = termos(escopo);
|
|
@@ -77,8 +98,9 @@ function instrucao({ modo, escopo } = ATUAL) {
|
|
|
77
98
|
+ `No roteiro, ponha roteiro.busca = ${JSON.stringify(t)} para o b-roll.`;
|
|
78
99
|
}
|
|
79
100
|
if (modo === 'aberto') {
|
|
80
|
-
return '[REFERÊNCIAS: ESCOPO ABERTO] Pesquise o assunto à vontade (pesquisar_referencia; estudio_site se for de uma marca) '
|
|
81
|
-
+ '
|
|
101
|
+
return '[REFERÊNCIAS: ESCOPO ABERTO — a pessoa pediu imagens] Pesquise o assunto à vontade (pesquisar_referencia; estudio_site se for de uma marca). '
|
|
102
|
+
+ 'A imagem é um elemento da narrativa, não o vídeo inteiro: dê "busca" (em inglês: gente, lugar, tela, objeto de verdade) '
|
|
103
|
+
+ 'SÓ nas partes em que uma imagem real mostra o que o texto não mostra. Não troque de imagem a cada frase; o resto é motion.';
|
|
82
104
|
}
|
|
83
105
|
return '';
|
|
84
106
|
}
|
|
@@ -102,4 +124,4 @@ function filtrar(r, { modo, escopo } = ATUAL) {
|
|
|
102
124
|
return { ...r, fontes: refs.length, referencias: refs, ...(fora ? { foraDoEscopo: fora } : {}) };
|
|
103
125
|
}
|
|
104
126
|
|
|
105
|
-
module.exports = { definir, atual, limpar, termos, entender, inferir, instrucao, dominios, filtrar };
|
|
127
|
+
module.exports = { definir, atual, limpar, semImagem, termos, entender, inferir, instrucao, dominios, filtrar };
|
package/lib/estudio/remotion.js
CHANGED
|
@@ -103,6 +103,7 @@ import {
|
|
|
103
103
|
} from 'remotion';
|
|
104
104
|
import { morfar, ehCor, misturarCor } from './morfar';
|
|
105
105
|
import { KIT, CURVAS_KIT, Filtros } from './kit';
|
|
106
|
+
import { COMPONENTES } from './componentes';
|
|
106
107
|
|
|
107
108
|
/* ── SUAVIZAÇÃO ──────────────────────────────────────────────────────────
|
|
108
109
|
* Cada curva é uma função de 0..1 em 0..1. "suave" é o padrão porque
|
|
@@ -227,7 +228,7 @@ function Elemento({ el, t, dados }) {
|
|
|
227
228
|
/* A PEÇA DO KIT desenha nas coordenadas da cena (x, y dela são o lugar do
|
|
228
229
|
assunto, não o canto de uma caixa): a caixa dela é o quadro inteiro, e o
|
|
229
230
|
"anima" do elemento move o conjunto. */
|
|
230
|
-
const base = el.tipo === 'kit'
|
|
231
|
+
const base = el.tipo === 'kit' || el.tipo === 'componente'
|
|
231
232
|
? { position: 'absolute', left: 0, top: 0, width: '100%', height: '100%' }
|
|
232
233
|
: posicao(el);
|
|
233
234
|
const move = transformDe(a, {});
|
|
@@ -383,6 +384,20 @@ function Elemento({ el, t, dados }) {
|
|
|
383
384
|
);
|
|
384
385
|
}
|
|
385
386
|
|
|
387
|
+
/* O COMPONENTE que a IA escreveu (ou reusou do catálogo, efeitos.js).
|
|
388
|
+
Antes o plano o aceitava e o Cena não sabia desenhar: o efeito sumia
|
|
389
|
+
do vídeo sem erro. Agora montar() copia o arquivo para src/componentes/
|
|
390
|
+
e ele recebe {el, t, fps}, no quadro inteiro, como a peça do kit. */
|
|
391
|
+
case 'componente': {
|
|
392
|
+
const Comp = COMPONENTES[el.src];
|
|
393
|
+
if (!Comp) return null;
|
|
394
|
+
return (
|
|
395
|
+
<div style={{ ...estilo, transformOrigin: el.origem || ((el.x || 0) + 'px ' + (el.y || 0) + 'px') }}>
|
|
396
|
+
<Comp el={el} t={tLocal} fps={dados.fps} />
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
386
401
|
case 'grupo':
|
|
387
402
|
return (
|
|
388
403
|
<div style={{
|
|
@@ -611,9 +626,41 @@ import icones from './icones.json';
|
|
|
611
626
|
/* A transição vive em quem SAI e em quem ENTRA ao mesmo tempo: as duas cenas
|
|
612
627
|
* ficam no ar durante o cruzamento. Fazer só a que entra aparecer por cima dá
|
|
613
628
|
* o "piscar" de fundo que denuncia corte mal feito. */
|
|
614
|
-
|
|
629
|
+
/* AS TRANSIÇÕES EM 3D (09/10: "quero mais animações, mais efeitos, e efeitos
|
|
630
|
+
* mais 3D dependendo do vídeo"). As de antes giravam só a cena que entra, sem
|
|
631
|
+
* perspectiva: o "giro" era um achatamento, não um giro. Estas quatro mexem
|
|
632
|
+
* nas DUAS cenas (a que sai também recebe o progresso, em "saida") e ficam
|
|
633
|
+
* num palco com perspectiva:
|
|
634
|
+
* virar a cena vira como um cartão, e o verso é a próxima
|
|
635
|
+
* cubo as duas são faces de um cubo que gira
|
|
636
|
+
* profundidade a que sai passa pela câmera; a nova vem do fundo, desfocada
|
|
637
|
+
* inclinar a nova levanta deitada, como uma folha que se ergue */
|
|
638
|
+
const TRANS_3D = { virar: 1, cubo: 1, profundidade: 1, inclinar: 1 };
|
|
639
|
+
function estilo3d(tipo, p, saindo) {
|
|
640
|
+
const k = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
|
|
641
|
+
if (tipo === 'virar') return { transform: 'rotateY(' + (saindo ? 180 * k : -180 + 180 * k) + 'deg)', backfaceVisibility: 'hidden' };
|
|
642
|
+
if (tipo === 'cubo') return saindo
|
|
643
|
+
? { transformOrigin: '100% 50%', transform: 'translateX(' + -100 * k + '%) rotateY(' + -90 * k + 'deg)' }
|
|
644
|
+
: { transformOrigin: '0% 50%', transform: 'translateX(' + 100 * (1 - k) + '%) rotateY(' + 90 * (1 - k) + 'deg)' };
|
|
645
|
+
if (tipo === 'profundidade') return saindo
|
|
646
|
+
? { transform: 'translateZ(' + 900 * k + 'px)', opacity: 1 - k }
|
|
647
|
+
: { transform: 'translateZ(' + -1400 * (1 - k) + 'px)', filter: 'blur(' + (1 - k) * 16 + 'px)', opacity: Math.min(1, k * 2.5) };
|
|
648
|
+
if (tipo === 'inclinar') return saindo
|
|
649
|
+
? { transform: 'translateY(' + -12 * k + '%) scale(' + (1 - 0.08 * k) + ')', filter: 'brightness(' + (1 - 0.5 * k) + ')' }
|
|
650
|
+
: { transformOrigin: '50% 100%', transform: 'rotateX(' + 72 * (1 - k) + 'deg)', opacity: Math.min(1, k * 3) };
|
|
651
|
+
return {};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function Transicao({ tipo, progresso, saida, children }) {
|
|
615
655
|
const p = progresso;
|
|
616
656
|
const { width: W, height: H } = useVideoConfig();
|
|
657
|
+
// A cena que sai, quando a próxima entra por uma transição 3D, mexe também.
|
|
658
|
+
if (saida && TRANS_3D[saida.tipo] && saida.p > 0 && !(TRANS_3D[tipo] && p < 1)) {
|
|
659
|
+
return <AbsoluteFill style={{ perspective: 1800 }}><AbsoluteFill style={estilo3d(saida.tipo, saida.p, true)}>{children}</AbsoluteFill></AbsoluteFill>;
|
|
660
|
+
}
|
|
661
|
+
if (TRANS_3D[tipo] && p < 1) {
|
|
662
|
+
return <AbsoluteFill style={{ perspective: 1800 }}><AbsoluteFill style={estilo3d(tipo, p, false)}>{children}</AbsoluteFill></AbsoluteFill>;
|
|
663
|
+
}
|
|
617
664
|
let estilo = {};
|
|
618
665
|
if (tipo === 'esmaecer') estilo = { opacity: p };
|
|
619
666
|
else if (tipo === 'deslizar') estilo = { transform: 'translateX(' + (1 - p) * 100 + '%)' };
|
|
@@ -683,9 +730,12 @@ export const Video = () => {
|
|
|
683
730
|
const progresso = cruz > 0 && local < cruz && i > 0
|
|
684
731
|
? interpolate(local, [0, cruz], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' })
|
|
685
732
|
: 1;
|
|
733
|
+
// O progresso de quem ENTRA depois desta: as transições 3D mexem nas duas.
|
|
734
|
+
const prox = pedacos[i + 1];
|
|
735
|
+
const saida = prox && prox.cruz > 0 ? { tipo: prox.tipo, p: interpolate(frame - prox.de, [0, prox.cruz], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) } : null;
|
|
686
736
|
return (
|
|
687
737
|
<Sequence key={i} from={de} durationInFrames={dur} name={'cena ' + (i + 1)}>
|
|
688
|
-
<Transicao tipo={i === 0 ? 'corte' : tipo} progresso={progresso}>
|
|
738
|
+
<Transicao tipo={i === 0 ? 'corte' : tipo} progresso={progresso} saida={saida}>
|
|
689
739
|
<Cena cena={cena} dados={dados} />
|
|
690
740
|
</Transicao>
|
|
691
741
|
</Sequence>
|
|
@@ -719,6 +769,8 @@ export const Video = () => {
|
|
|
719
769
|
return vs[vs.length - 1].v; }
|
|
720
770
|
: (plano.audio.trilha.volume ?? 0.18)}
|
|
721
771
|
loop={plano.audio.trilha.loop !== false}
|
|
772
|
+
/* O início da faixa que casa as batidas com os cortes (batida.js). */
|
|
773
|
+
startFrom={Math.round((plano.audio.trilha.inicio || 0) * fps)}
|
|
722
774
|
/>
|
|
723
775
|
)}
|
|
724
776
|
{/* Os TRECHOS: pedaços de áudio no tempo certo — a narração partida entre
|
|
@@ -812,6 +864,24 @@ para este projeto, não para o PrimoCode.
|
|
|
812
864
|
`;
|
|
813
865
|
}
|
|
814
866
|
|
|
867
|
+
/* Os componentes do plano viram import de verdade: o webpack do Remotion não
|
|
868
|
+
carrega arquivo por nome em tempo de render, então cada `src` é copiado
|
|
869
|
+
para src/componentes/ e entra no mapa COMPONENTES pelo mesmo nome. */
|
|
870
|
+
function escreverComponentes(pasta, src, plano) {
|
|
871
|
+
const srcs = new Set();
|
|
872
|
+
const ver = (els) => { for (const el of els || []) { if (el && el.tipo === 'componente' && el.src) srcs.add(el.src); if (el) ver(el.elementos); } };
|
|
873
|
+
for (const c of plano.cenas || []) ver(c.elementos);
|
|
874
|
+
const linhas = [], mapa = [];
|
|
875
|
+
[...srcs].forEach((s, i) => {
|
|
876
|
+
const de = path.resolve(pasta, s);
|
|
877
|
+
if (!de.startsWith(path.resolve(pasta) + path.sep) || !fs.existsSync(de)) return;
|
|
878
|
+
escrever(path.join(src, 'componentes', 'c' + i + path.extname(de)), fs.readFileSync(de, 'utf8'));
|
|
879
|
+
linhas.push(`import * as M${i} from './componentes/c${i}';`);
|
|
880
|
+
mapa.push(` ${JSON.stringify(s)}: M${i}.default || Object.values(M${i}).find((v) => typeof v === 'function'),`);
|
|
881
|
+
});
|
|
882
|
+
escrever(path.join(src, 'componentes.js'), `${linhas.join('\n')}\nexport const COMPONENTES = {\n${mapa.join('\n')}\n};\n`);
|
|
883
|
+
}
|
|
884
|
+
|
|
815
885
|
/**
|
|
816
886
|
* Escreve o projeto Remotion dentro da pasta do projeto do Studio.
|
|
817
887
|
*
|
|
@@ -849,6 +919,7 @@ function montar({ pasta, titulo, plano, icones = {} }) {
|
|
|
849
919
|
// O kit de motion (kit.js): as peças que o elemento {"tipo":"kit"} monta.
|
|
850
920
|
for (const f of require('./kit.js').arquivos()) escrever(path.join(src, f.nome), f.conteudo);
|
|
851
921
|
escrever(path.join(src, 'plano.json'), JSON.stringify(plano, null, 2));
|
|
922
|
+
escreverComponentes(pasta, src, plano);
|
|
852
923
|
escrever(path.join(src, 'icones.json'), JSON.stringify(icones, null, 2));
|
|
853
924
|
escrever(path.join(src, 'fontes.css'), fontesCSS(plano));
|
|
854
925
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* revisor.js — a revisão do plano antes do render: o olho de editor que
|
|
3
|
+
* procura receita, excesso e pressa, e conserta o que dá sem perguntar.
|
|
4
|
+
*
|
|
5
|
+
* "Antes de finalizar, procure padrões repetitivos, escolhas previsíveis,
|
|
6
|
+
* templates rígidos, excesso de efeitos, excesso de imagens, cortes
|
|
7
|
+
* artificiais, problemas de ritmo." (Joel, 07/10)
|
|
8
|
+
*
|
|
9
|
+
* plano.js confere se o plano RODA; este confere se ele está BOM. O que tem
|
|
10
|
+
* conserto seguro é consertado aqui (e dito); o que pede decisão vira aviso,
|
|
11
|
+
* que vai para o agente e para as notas do projeto:
|
|
12
|
+
* conserta a mesma transição 3+ vezes seguidas → a do meio vira corte seco
|
|
13
|
+
* conserta o MESMO efeito sonoro colado nele (< 0,3 s) → fica um (camadas de sons diferentes são desenho, ficam)
|
|
14
|
+
* conserta câmera mexendo em 4 cenas seguidas → a mais fraca para (respiro)
|
|
15
|
+
* avisa texto que não dá tempo de ler (0,3 s/palavra, mínimo 0,8 s)
|
|
16
|
+
* avisa a mesma sequência de elementos em 4+ cenas seguidas (cara de template)
|
|
17
|
+
* avisa imagem sem a pessoa pedir, ou imagem em mais da metade das cenas
|
|
18
|
+
* conserta efeito sonoro demais (mais de 1 a cada 1,5 s) → ficam os mais altos
|
|
19
|
+
* Puro: o plano entra, o plano consertado e a lista saem.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
25
|
+
|
|
26
|
+
const tipoDaTransicao = (c) => (c && c.transicao ? (c.transicao.tipo || c.transicao) : 'corte');
|
|
27
|
+
const mexe = (cam) => {
|
|
28
|
+
if (!cam) return false;
|
|
29
|
+
return Object.values(cam).some((ks) => Array.isArray(ks) && ks.length > 1 && ks.some((k) => Math.abs(Number(k.v) - Number(ks[0].v)) > 0.005));
|
|
30
|
+
};
|
|
31
|
+
const forcaDaCamera = (cam) => Math.max(0, ...Object.values(cam || {}).filter(Array.isArray)
|
|
32
|
+
.map((ks) => Math.max(...ks.map((k) => Number(k.v) || 0)) - Math.min(...ks.map((k) => Number(k.v) || 0))));
|
|
33
|
+
const palavras = (t) => String(t || '').split(/\s+/).filter(Boolean).length;
|
|
34
|
+
// Imagem ou vídeo DE FORA: o vídeo da própria gravação (deOrigem) e o recorte da pessoa não contam.
|
|
35
|
+
const imagemDeFora = (el) => el && (el.tipo === 'imagem' || (el.tipo === 'video' && el.deOrigem == null && !/pessoa-/.test(el.src || '')));
|
|
36
|
+
|
|
37
|
+
function revisar(plano, { semImagem = false } = {}) {
|
|
38
|
+
const consertos = [], avisos = [];
|
|
39
|
+
const cenas = (plano && plano.cenas) || [];
|
|
40
|
+
if (!cenas.length) return { plano, consertos, avisos };
|
|
41
|
+
|
|
42
|
+
// 1. A mesma transição (que não seja corte seco) 3+ vezes seguidas: receita.
|
|
43
|
+
for (let i = 2; i < cenas.length; i++) {
|
|
44
|
+
const t = tipoDaTransicao(cenas[i]);
|
|
45
|
+
if (t !== 'corte' && t === tipoDaTransicao(cenas[i - 1]) && t === tipoDaTransicao(cenas[i - 2])) {
|
|
46
|
+
cenas[i - 1].transicao = { tipo: 'corte' };
|
|
47
|
+
consertos.push(`cena ${i}: "${t}" 3 vezes seguidas — a do meio virou corte seco`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2. Câmera mexendo em 4 cenas seguidas: a de movimento mais fraco para.
|
|
52
|
+
for (let i = 3; i < cenas.length; i++) {
|
|
53
|
+
const janela = [i - 3, i - 2, i - 1, i];
|
|
54
|
+
if (janela.every((k) => mexe(cenas[k].camera))) {
|
|
55
|
+
const k = janela.filter((j) => j > 0 && !cenas[j].impacto) // a abertura (o gancho) e o golpe não param
|
|
56
|
+
|
|
57
|
+
// Empate: a mais à frente — parar a primeira da janela fazia a janela seguinte pedir outra, e paravam todas (08/10).
|
|
58
|
+
.sort((a, b) => forcaDaCamera(cenas[a].camera) - forcaDaCamera(cenas[b].camera) || b - a)[0];
|
|
59
|
+
if (k == null) continue;
|
|
60
|
+
const cam = cenas[k].camera;
|
|
61
|
+
for (const prop of Object.keys(cam)) if (Array.isArray(cam[prop]) && cam[prop].length) cam[prop] = [{ t: 0, v: cam[prop][0].v }, { t: 1, v: cam[prop][0].v }];
|
|
62
|
+
consertos.push(`cena ${k + 1}: 4 cenas seguidas com a câmera mexendo — esta respira`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. Efeitos sonoros: colados um no outro, fica o mais alto; e a densidade.
|
|
67
|
+
const efeitos = ((plano.audio && plano.audio.efeitos) || []).slice().sort((a, b) => (a.t || 0) - (b.t || 0));
|
|
68
|
+
if (efeitos.length) {
|
|
69
|
+
const fica = [];
|
|
70
|
+
for (const e of efeitos) {
|
|
71
|
+
const ult = fica[fica.length - 1];
|
|
72
|
+
if (ult && e.src === ult.src && (e.t || 0) - (ult.t || 0) < 0.3) {
|
|
73
|
+
if ((e.volume ?? 1) > (ult.volume ?? 1)) fica[fica.length - 1] = e;
|
|
74
|
+
consertos.push(`efeito em ${r2(e.t || 0)}s colado no de ${r2(ult.t || 0)}s — ficou um`);
|
|
75
|
+
} else fica.push(e);
|
|
76
|
+
}
|
|
77
|
+
const total = cenas.reduce((n, c) => n + (Number(c.duracao) || 0), 0);
|
|
78
|
+
/* Som demais vira ruído: acima de 1 a cada 1,5 s, ficam os mais altos
|
|
79
|
+
(o desenho de som marca o mais importante com volume), um a cada 0,7 s. */
|
|
80
|
+
let final = fica;
|
|
81
|
+
if (total > 0 && fica.length / total > 1 / 1.5) {
|
|
82
|
+
const teto = Math.max(1, Math.floor(total / 1.5));
|
|
83
|
+
const guardados = [];
|
|
84
|
+
for (const e of fica.slice().sort((a, b) => (b.volume ?? 1) - (a.volume ?? 1))) {
|
|
85
|
+
if (guardados.length >= teto) break;
|
|
86
|
+
if (guardados.every((g) => Math.abs((g.t || 0) - (e.t || 0)) >= 0.7)) guardados.push(e);
|
|
87
|
+
}
|
|
88
|
+
final = guardados.sort((a, b) => (a.t || 0) - (b.t || 0));
|
|
89
|
+
consertos.push(`${fica.length} efeitos sonoros em ${r2(total)}s eram ruído — ficaram os ${final.length} mais fortes`);
|
|
90
|
+
}
|
|
91
|
+
plano.audio.efeitos = final;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 4. Texto que não dá tempo de ler.
|
|
95
|
+
cenas.forEach((c, i) => {
|
|
96
|
+
const n = (c.elementos || []).filter((e) => e && e.tipo === 'texto').reduce((s, e) => s + palavras(e.texto), 0);
|
|
97
|
+
const precisa = Math.max(0.8, 0.3 * n);
|
|
98
|
+
if (n && Number(c.duracao) < precisa) avisos.push(`cena ${i + 1}: ${n} palavras em ${c.duracao}s (precisa de ${r2(precisa)}s para ler)`);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// 5. A mesma sequência de elementos em 4+ cenas seguidas: cara de template.
|
|
102
|
+
const assinatura = (c) => (c.elementos || []).map((e) => e && (e.tipo + (e.peca ? ':' + e.peca : ''))).join(',');
|
|
103
|
+
for (let i = 0, j = 1; j <= cenas.length; j++) {
|
|
104
|
+
if (j < cenas.length && assinatura(cenas[j]) === assinatura(cenas[i])) continue;
|
|
105
|
+
if (j - i >= 4 && assinatura(cenas[i])) avisos.push(`cenas ${i + 1} a ${j}: a mesma montagem (${assinatura(cenas[i])}) ${j - i} vezes seguidas — varie a composição`);
|
|
106
|
+
i = j;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 6. Imagem: sem pedido, nenhuma; com pedido, não em toda cena.
|
|
110
|
+
const comImagem = cenas.filter((c) => (c.elementos || []).some(imagemDeFora) || (c.fundo && (c.fundo.tipo === 'imagem' || c.fundo.tipo === 'video'))).length;
|
|
111
|
+
if (semImagem && comImagem) avisos.push(`${comImagem} cena(s) com imagem de fora, mas a pessoa não pediu imagem`);
|
|
112
|
+
else if (cenas.length >= 4 && comImagem / cenas.length > 0.5) avisos.push(`imagem em ${comImagem} de ${cenas.length} cenas — a imagem é um momento, não o vídeo inteiro`);
|
|
113
|
+
|
|
114
|
+
return { plano, consertos, avisos };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { revisar };
|