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.
@@ -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. Quem diz no pedido não é perguntado:
45
- "só motion", "sem referência", "sem foto" → sem
46
- "só do site x.com", "usa apenas anthropic.com e …" → fechado (o escopo)
47
- "web aberta", "escopo aberto", "pesquisa à vontade" → aberto
48
- O resto volta com `explicito: false` e o terminal pergunta antes de
49
- começar: "eu posso pedir um vídeo mas estar me referindo ao motion"
50
- (Joel, 02/10) — chutar o aberto fazia vídeo com foto e b-roll quando a
51
- pessoa queria só motion. */
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(/[̀-ͯ]/g, '');
54
- if (/so (o )?motion|motion puro|sem refer|sem (foto|imagem|imagens|video de banco|pesquisa)|nao (pesquis|busqu)|so anima/.test(t)) return { modo: 'sem', escopo: '', explicito: true };
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 (/escopo aberto|web aberta|pesquis\w* (a vontade|na web|na internet)|busc\w* (imagens|fotos|videos) na (web|internet)|com (imagens|fotos) da (web|internet)/.test(t)) {
60
- return { modo: 'aberto', escopo: '', explicito: true };
61
- }
62
- return { modo: 'aberto', escopo: '', explicito: false };
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({ modo, escopo } = ATUAL) {
67
- if (modo === 'sem') {
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 e deixe '
70
- + 'o motion gerativo fazer o trabalho (roteiro.estilo "gerativo": a tipografia anima sozinha, sem template).';
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
- + 'e dê em cada parte do roteiro um "busca" em inglês com o que mostrar de fundo — gente, lugar, tela, objeto de verdade.';
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 };
@@ -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
- function Transicao({ tipo, progresso, children }) {
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 };