primocode 10.11.1 → 10.13.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 +15 -2
- package/lib/catalogo.js +7 -2
- package/lib/estudio/assinaturas/gerativo.json +1 -1
- package/lib/estudio/camadas.js +386 -0
- package/lib/estudio/diretor.js +2 -1
- package/lib/estudio/efeitos-fabrica/camadas-empilhadas.jsx +64 -0
- package/lib/estudio/efeitos-fabrica/terminal-digitando.jsx +58 -0
- package/lib/estudio/efeitos.js +2 -0
- package/lib/estudio/gerativo.js +44 -15
- package/lib/estudio/index.js +110 -2
- package/lib/estudio/kit/gerativo.jsx +15 -0
- package/lib/estudio/kit/index.jsx +2 -1
- package/lib/estudio/kit/rosto.jsx +108 -0
- package/lib/estudio/kit.js +4 -1
- package/lib/estudio/kokoro.js +3 -1
- package/lib/estudio/remotion.js +6 -4
- package/lib/estudio/revisor.js +61 -0
- package/lib/estudio/voicestudio.js +81 -0
- package/lib/video/auto.js +18 -3
- package/package.json +2 -2
- package/skills/editar-meu-video/SKILL.md +6 -0
- package/skills/motion-de-primeira/SKILL.md +33 -51
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v10.
|
|
1
|
+
# PrimoCode v10.13.0
|
|
2
2
|
|
|
3
3
|
Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
|
|
4
4
|
Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
|
|
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
|
|
|
9
9
|
está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
▐▛███▜▌ Primo Code v10.
|
|
12
|
+
▐▛███▜▌ Primo Code v10.13.0
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
|
@@ -17,6 +17,19 @@ está no limite de pedidos, o próximo atende. Você não vê o erro, e não pag
|
|
|
17
17
|
Personalize o mascote (`primo`, `fox`, `cat`) e o nome com que ele te chama —
|
|
18
18
|
veja `/mascot` e `/name`.
|
|
19
19
|
|
|
20
|
+
## O que mudou na 10.13
|
|
21
|
+
|
|
22
|
+
- **Motion pelas referências:** o título enche a tela (rótulo nunca abaixo de 2% da altura), a cena dura de 1 a 2 s, o texto entra com ataque rápido e freio forte, e a frase sai inteira. As saídas que espalhavam letras saíram. O círculo de caneta na palavra-chave ganha o ✓. Sem quadro morto entre as cenas. A conta está em `docs/planos/2026-10-15-motion-pelas-referencias.md`.
|
|
23
|
+
- **Revisão do texto:** antes do render, o PrimoCode avisa texto pequeno demais para o celular e contraste abaixo de 3:1.
|
|
24
|
+
- **Camadas sem legenda dobrada:** a cena escrita pela IA não repete a fala por cima da legenda.
|
|
25
|
+
- **Voz do VoiceStudio:** com o app aberto no Mac, ele narra primeiro. Fechado, a narração segue para o Gemini (`PRIMOCODE_SEM_VOICESTUDIO=1` desliga).
|
|
26
|
+
- **Edição de vídeo:** num vídeo sem pedido falado, a edição não para mais na legenda. O agente corta, dá zoom e cor.
|
|
27
|
+
|
|
28
|
+
## O que mudou na 10.12
|
|
29
|
+
|
|
30
|
+
- **Vídeo em camadas:** você grava só a fala e o PrimoCode monta o resto. O rosto vira cartão, bolinha no canto, some e volta, com mola, e o motion ocupa o espaço que sobra. A legenda vai palavra por palavra, com a palavra forte em serif itálico. O áudio nunca pula. Use `estudio_video acao:"camadas"`: sem batidas, ele propõe pela transcrição.
|
|
31
|
+
- **Fábrica:** camadas 3D empilhadas e terminal com o prompt sendo digitado.
|
|
32
|
+
|
|
20
33
|
## O que mudou na 10.11
|
|
21
34
|
|
|
22
35
|
- **Barulhinhos:** cada palavra, item e ponto que entra no motion faz um som pequeno, numa escada de notas, com tique, clique de mouse, blip, swoosh e sub na revelação. A cena pode pedir o som no quadro certo (`sons: [{t, som}]`).
|
package/lib/catalogo.js
CHANGED
|
@@ -142,14 +142,19 @@ const GRUPOS = [
|
|
|
142
142
|
+ 'Use os tempos que voltam para a duração das cenas — estimar por número de palavras erra em segundos.' },
|
|
143
143
|
|
|
144
144
|
{ tool: 'estudio_video', escreve: true, faz: 'o que se faz com a gravação do usuário: importar, medir, cortar silêncio, transcrever, recortar a pessoa',
|
|
145
|
-
args: 'id. acao: editar | importar | medir | silencio | cortar-silencio | quadro | legendas | recortar-pessoa. '
|
|
145
|
+
args: 'id. acao: editar | camadas | importar | medir | silencio | cortar-silencio | quadro | legendas | recortar-pessoa. '
|
|
146
146
|
+ 'editar: FAZ A EDIÇÃO INTEIRA sozinho — corta o silêncio, transcreve palavra por palavra e monta o plano '
|
|
147
147
|
+ '(legenda animada, ícone na hora da palavra, zoom de corte, pop e whoosh). estilo: viral (padrão) | limpo. '
|
|
148
148
|
+ 'Com gravação do usuário, é o PRIMEIRO passo depois de importar; depois só estudio_render. '
|
|
149
149
|
+ 'arquivo: caminho (em "importar", de fora; nas outras, o relativo dentro do projeto). '
|
|
150
150
|
+ 'cortar-silencio: minimoParaCortar (padrão 0.6s), respiro (padrão 0.18s) — os dois existem para o corte não soar robótico. '
|
|
151
151
|
+ 'legendas: devolve os trechos com tempo, para virarem elemento "legenda". '
|
|
152
|
-
+ 'recortar-pessoa: devolve um vídeo com fundo TRANSPARENTE, para pôr sobre qualquer fundo.'
|
|
152
|
+
+ 'recortar-pessoa: devolve um vídeo com fundo TRANSPARENTE, para pôr sobre qualquer fundo. '
|
|
153
|
+
+ 'camadas: a pessoa SÓ GRAVOU A FALA e o motion vem por cima — a gravação contínua é a narração, o ROSTO muda de forma com mola '
|
|
154
|
+
+ '(cheia | cartao | bolinha | some) e a cena de motion ocupa o espaço que sobra; legenda palavra por palavra (1 a 3), destaque em serif itálica na cor de acento. '
|
|
155
|
+
+ 'Sem batidas, devolve uma PROPOSTA pela transcrição; você decide e chama de novo com '
|
|
156
|
+
+ 'batidas:[{de, ate, rosto, cena?: "componentes/x.jsx" ou nome de efeito de fábrica, destaque?: ["palavra"], canto?: esquerda|direita|centro (bolinha), dados?: {…} para a cena}]. '
|
|
157
|
+
+ 'Alterne as formas, ponha cena onde o rosto encolhe e varie as cenas. rosto: {foco:{x,y} (0 a 1, onde o rosto está na gravação), zoom} quando a pessoa está longe. acento: "#RRGGBB".' },
|
|
153
158
|
|
|
154
159
|
{ tool: 'estudio_render', escreve: true, faz: 'vira arquivo pelo Remotion e abre para o usuário: .mp4 no vídeo, .pdf em slides e arte',
|
|
155
160
|
args: 'id. paginas: true força páginas paradas em vez de vídeo (projeto de formato "slides" ou "arte" já faz isso sozinho). '
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"origem": "o vídeo que o Joel aprovou em 08/10/2026 (teste-e2e-sono, o gerativo do PR #113, 30,3 s), medido",
|
|
5
5
|
"descricao": "Batida curta e seca, pouco texto por linha, numa identidade só (2 chãos, 1 acento, 2 fontes): o fundo troca na virada de seção, a primeira por corte seco. A palavra gigante entra em ~1 de cada 3 batidas. O som é desenhado da semente e fica mais raro que no fluido.",
|
|
6
6
|
"faixas": {
|
|
7
|
-
"batida_mediana_s": { "min":
|
|
7
|
+
"batida_mediana_s": { "min": 1, "max": 3, "medido": 3.18, "revisto": "15/10/2026: as referências do Joel (\"motion devem sair assim\") cortam a cada 1 a 2 s; a faixa saiu de 2–4,5 para 1–3 s.", "diz": "cada batida dura de 1 a 3 s, o normal é ~2 s" },
|
|
8
8
|
"palavras_por_frase_max": { "max": 5, "medido": 3, "diz": "no máximo 3 palavras por linha" },
|
|
9
9
|
"palavras_por_frase_mediana": { "max": 3, "medido": 2, "diz": "o normal é 1 a 2 palavras por linha" },
|
|
10
10
|
"cortes_secos": { "min": 1, "medido": 5, "diz": "o fundo troca por corte seco (5 das 9 trocas)" },
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* camadas.js — a pessoa só gravou a fala; o Estúdio monta o bolo por cima.
|
|
3
|
+
*
|
|
4
|
+
* "Quero uma edição nesse nível" — Joel (14/10/2026), com um reel em que o
|
|
5
|
+
* vídeo de quem fala é contínuo, mas o ROSTO muda de forma: tela cheia,
|
|
6
|
+
* cartão com borda que brilha, bolinha no canto, some — e o motion toma o
|
|
7
|
+
* espaço que sobra.
|
|
8
|
+
*
|
|
9
|
+
* ── QUEM DECIDE ──────────────────────────────────────────────────────────
|
|
10
|
+
* A IA. Ela lê a transcrição e passa as BATIDAS: de quando a quando, a forma
|
|
11
|
+
* do rosto, a cena de motion que ocupa o espaço livre e as palavras que
|
|
12
|
+
* ganham destaque na legenda. Este módulo não escolhe nada disso — ele mede
|
|
13
|
+
* (onde fica a caixa do rosto, onde sobra espaço, onde a legenda não cobre o
|
|
14
|
+
* rosto) e monta o plano. Sem batidas, `propor` devolve uma SUGESTÃO pela
|
|
15
|
+
* transcrição, que a IA aceita ou muda.
|
|
16
|
+
*
|
|
17
|
+
* ── O QUE SAI ────────────────────────────────────────────────────────────
|
|
18
|
+
* Uma cena por batida, coladas. Em cada uma, de trás para a frente:
|
|
19
|
+
* 1. o palco (kit/rosto.jsx): fundo escuro, brilho que respira e grão;
|
|
20
|
+
* 2. a cena de motion (componente), num grupo do tamanho do espaço livre;
|
|
21
|
+
* 3. o rosto (kit/rosto.jsx): a gravação, que vai da forma da batida
|
|
22
|
+
* anterior para a desta com mola e antecipação. O vídeo nunca reinicia:
|
|
23
|
+
* cada cena começa a gravação em `deOrigem` = o começo da batida, e o
|
|
24
|
+
* áudio da pessoa é a narração do vídeo inteiro;
|
|
25
|
+
* 4. a legenda palavra por palavra (1 a 3), a palavra de destaque na serifa
|
|
26
|
+
* em itálico e na cor de acento, numa altura que foge do rosto.
|
|
27
|
+
* E o som: swoosh quando o rosto muda de forma, um toque quando a cena entra
|
|
28
|
+
* e o micro baixinho nas palavras de destaque (sem-narracao.sonorizarPlano,
|
|
29
|
+
* com voz).
|
|
30
|
+
*
|
|
31
|
+
* Função pura: entra batida e palavra, sai plano. Quem mexe em arquivo é a
|
|
32
|
+
* ferramenta (estudio_video acao:"camadas", em index.js).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const edicao = require('./edicao.js');
|
|
38
|
+
const sem = require('./sem-narracao.js');
|
|
39
|
+
|
|
40
|
+
const FORMAS = ['cheia', 'cartao', 'bolinha', 'some'];
|
|
41
|
+
// O nome que a IA usar por outro caminho ("cartão", "avatar") vale a mesma forma.
|
|
42
|
+
const APELIDOS = {
|
|
43
|
+
cheia: 'cheia', 'tela-cheia': 'cheia', tela: 'cheia', full: 'cheia', inteira: 'cheia',
|
|
44
|
+
cartao: 'cartao', 'cartão': 'cartao', card: 'cartao', janela: 'cartao',
|
|
45
|
+
bolinha: 'bolinha', bola: 'bolinha', avatar: 'bolinha', circulo: 'bolinha', 'círculo': 'bolinha',
|
|
46
|
+
some: 'some', sem: 'some', oculto: 'some', nenhum: 'some', fora: 'some',
|
|
47
|
+
};
|
|
48
|
+
const ACENTO = '#8B7CF6';
|
|
49
|
+
const r3 = (x) => Math.round(x * 1000) / 1000;
|
|
50
|
+
|
|
51
|
+
/** A forma do rosto pelo nome que a IA deu, ou null. */
|
|
52
|
+
function formaDe(nome) {
|
|
53
|
+
return APELIDOS[String(nome || '').trim().toLowerCase()] || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A palavra sem acento, sem pontuação e em minúsculas — para casar o destaque. */
|
|
57
|
+
function limpa(texto) {
|
|
58
|
+
return String(texto || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const corValida = (c) => (/^#[0-9a-f]{6}$/i.test(String(c || '')) ? String(c) : null);
|
|
62
|
+
|
|
63
|
+
/* ── a geometria ──────────────────────────────────────────────────────────
|
|
64
|
+
Tudo em pixels do quadro. Cada caixa: x, y, w, h, raio, anel (px da borda
|
|
65
|
+
que brilha), opacidade e escala. O kit só interpola entre duas caixas. */
|
|
66
|
+
|
|
67
|
+
/** Onde o rosto fica nesta forma. `canto`: esquerda | direita | centro (a bolinha). */
|
|
68
|
+
function caixaDe(forma, { largura: W = 1080, altura: H = 1920, canto = 'esquerda' } = {}) {
|
|
69
|
+
const deitado = W > H;
|
|
70
|
+
const M = Math.round(Math.min(W, H) * 0.06);
|
|
71
|
+
if (forma === 'cartao') {
|
|
72
|
+
if (deitado) {
|
|
73
|
+
const w = Math.round(W * 0.34), h = Math.round(H * 0.7);
|
|
74
|
+
return { x: W - M - w, y: Math.round((H - h) / 2), w, h, raio: Math.round(H * 0.045), anel: 3, opacidade: 1, escala: 1 };
|
|
75
|
+
}
|
|
76
|
+
const w = Math.round(W * 0.66), h = Math.round(H * 0.40);
|
|
77
|
+
return { x: Math.round((W - w) / 2), y: Math.round(H * 0.50), w, h, raio: Math.round(W * 0.06), anel: 3, opacidade: 1, escala: 1 };
|
|
78
|
+
}
|
|
79
|
+
if (forma === 'bolinha' || forma === 'some') {
|
|
80
|
+
const d = Math.round(deitado ? H * 0.26 : W * 0.26);
|
|
81
|
+
const lado = canto === 'direita' || (deitado && canto !== 'esquerda' && canto !== 'centro') ? 'direita' : canto;
|
|
82
|
+
const x = lado === 'direita' ? W - M - d : lado === 'centro' ? Math.round((W - d) / 2) : M;
|
|
83
|
+
const y = Math.round(deitado ? M : H * 0.055);
|
|
84
|
+
return { x, y, w: d, h: d, raio: Math.round(d / 2), anel: 6, opacidade: forma === 'some' ? 0 : 1, escala: forma === 'some' ? 0.35 : 1 };
|
|
85
|
+
}
|
|
86
|
+
return { x: 0, y: 0, w: W, h: H, raio: 0, anel: 0, opacidade: 1, escala: 1 };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** O espaço que sobra para o motion nesta forma: {x, y, w, h}. */
|
|
90
|
+
function areaLivre(forma, { largura: W = 1080, altura: H = 1920, canto = 'esquerda' } = {}) {
|
|
91
|
+
const M = Math.round(Math.min(W, H) * 0.06);
|
|
92
|
+
if (W > H) {
|
|
93
|
+
const c = caixaDe('cartao', { largura: W, altura: H });
|
|
94
|
+
if (forma === 'cartao') return { x: M, y: M, w: c.x - 2 * M, h: Math.round(H * 0.68) };
|
|
95
|
+
if (forma === 'cheia') return { x: M, y: M, w: W - 2 * M, h: Math.round(H * 0.2) };
|
|
96
|
+
if (forma === 'some') return { x: M, y: M, w: W - 2 * M, h: Math.round(H * 0.68) };
|
|
97
|
+
// A bolinha no canto de cima: o motion fica ao lado dela.
|
|
98
|
+
const b = caixaDe('bolinha', { largura: W, altura: H, canto });
|
|
99
|
+
const x = b.x > W / 2 ? M : b.x + b.w + M;
|
|
100
|
+
return { x, y: M, w: (b.x > W / 2 ? b.x - M : W - M) - x, h: Math.round(H * 0.68) };
|
|
101
|
+
}
|
|
102
|
+
if (forma === 'cartao') return { x: M, y: Math.round(H * 0.06), w: W - 2 * M, h: Math.round(H * 0.42) };
|
|
103
|
+
// Tela cheia: só uma faixa no alto (o letreiro "parte 1"), acima da testa.
|
|
104
|
+
if (forma === 'cheia') return { x: M, y: Math.round(H * 0.04), w: W - 2 * M, h: Math.round(H * 0.12) };
|
|
105
|
+
if (forma === 'bolinha') {
|
|
106
|
+
const b = caixaDe('bolinha', { largura: W, altura: H, canto });
|
|
107
|
+
const y = b.y + b.h + Math.round(H * 0.025);
|
|
108
|
+
return { x: M, y, w: W - 2 * M, h: Math.round(H * 0.72) - y };
|
|
109
|
+
}
|
|
110
|
+
return { x: M, y: Math.round(H * 0.08), w: W - 2 * M, h: Math.round(H * 0.62) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Onde mora o rosto dentro da caixa (a parte de cima dela: testa ao queixo). */
|
|
114
|
+
function regiaoDoRosto(forma, op = {}) {
|
|
115
|
+
const H = op.altura || 1920;
|
|
116
|
+
if (forma === 'some') return null;
|
|
117
|
+
if (forma === 'cheia') return { y: Math.round(H * 0.12), h: Math.round(H * 0.52) };
|
|
118
|
+
const c = caixaDe(forma, op);
|
|
119
|
+
if (forma === 'bolinha') return { y: c.y, h: c.h };
|
|
120
|
+
return { y: c.y, h: Math.round(c.h * 0.7) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A legenda: {x, y} do centro e a meia altura da faixa dela — fora do rosto. */
|
|
124
|
+
function lugarDaLegenda(forma, { largura: W = 1080, altura: H = 1920, canto = 'esquerda' } = {}) {
|
|
125
|
+
const meia = Math.round(H * 0.04);
|
|
126
|
+
if (W > H) {
|
|
127
|
+
if (forma === 'cartao') {
|
|
128
|
+
const a = areaLivre('cartao', { largura: W, altura: H });
|
|
129
|
+
return { x: Math.round(a.x + a.w / 2), y: Math.round(H * 0.86), meia };
|
|
130
|
+
}
|
|
131
|
+
return { x: Math.round(W / 2), y: Math.round(H * 0.84), meia };
|
|
132
|
+
}
|
|
133
|
+
if (forma === 'cheia') return { x: Math.round(W / 2), y: Math.round(H * 0.78), meia };
|
|
134
|
+
// No cartão, por cima do peito (o rosto fica nos 70% de cima), como na referência.
|
|
135
|
+
if (forma === 'cartao') {
|
|
136
|
+
const c = caixaDe('cartao', { largura: W, altura: H });
|
|
137
|
+
return { x: Math.round(W / 2), y: Math.round(c.y + c.h * 0.86), meia };
|
|
138
|
+
}
|
|
139
|
+
return { x: Math.round(W / 2), y: Math.round(H * 0.8), meia };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ── a proposta (sem batidas) ─────────────────────────────────────────── */
|
|
143
|
+
|
|
144
|
+
const RODIZIO = ['cartao', 'bolinha', 'some', 'cartao', 'cheia', 'bolinha', 'some', 'cheia'];
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Uma SUGESTÃO de batidas pela transcrição: uma por frase (juntando as
|
|
148
|
+
* curtas até ~2,2 s e cortando as longas em ~3,6 s, o ritmo da referência), a primeira com o rosto cheio (o gancho é a pessoa), as
|
|
149
|
+
* outras num rodízio que não repete a forma vizinha. A cena fica em branco:
|
|
150
|
+
* quem escreve o motion de cada ideia é a IA. O destaque sugerido é a palavra
|
|
151
|
+
* que mais carrega a frase (edicao.peso) — sugestão, não decisão.
|
|
152
|
+
*/
|
|
153
|
+
function propor(palavras, duracao, { minimo = 2.2, teto = 3.6 } = {}) {
|
|
154
|
+
const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
|
|
155
|
+
if (!lista.length) return [];
|
|
156
|
+
const juntos = [];
|
|
157
|
+
for (const g of edicao.frases(lista, { teto })) {
|
|
158
|
+
const ult = juntos[juntos.length - 1];
|
|
159
|
+
if (ult && ult[ult.length - 1].ate - ult[0].de < minimo) ult.push(...g);
|
|
160
|
+
else juntos.push([...g]);
|
|
161
|
+
}
|
|
162
|
+
const total = Math.max(Number(duracao) || 0, lista[lista.length - 1].ate);
|
|
163
|
+
let r = 0;
|
|
164
|
+
return juntos.map((g, i) => {
|
|
165
|
+
const de = i === 0 ? 0 : (juntos[i - 1][juntos[i - 1].length - 1].ate + g[0].de) / 2;
|
|
166
|
+
const ate = i === juntos.length - 1 ? total : (g[g.length - 1].ate + juntos[i + 1][0].de) / 2;
|
|
167
|
+
const rosto = i === 0 ? 'cheia' : RODIZIO[r++ % RODIZIO.length];
|
|
168
|
+
const forte = g.reduce((m, p) => (edicao.peso(p.texto) > (m ? edicao.peso(m.texto) : 0) ? p : m), null);
|
|
169
|
+
return {
|
|
170
|
+
de: r3(de), ate: r3(ate), rosto,
|
|
171
|
+
texto: g.map((p) => p.texto).join(' '),
|
|
172
|
+
destaque: forte ? [String(forte.texto).replace(/[^\p{L}\p{N}-]/gu, '')] : [],
|
|
173
|
+
cena: null,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ── conferir e montar ────────────────────────────────────────────────── */
|
|
179
|
+
|
|
180
|
+
/** O que está errado nas batidas (lista vazia = boas). */
|
|
181
|
+
function conferir(batidas, duracao) {
|
|
182
|
+
const problemas = [];
|
|
183
|
+
if (!Array.isArray(batidas) || !batidas.length) return ['"batidas" é uma lista com pelo menos uma batida {de, ate, rosto}.'];
|
|
184
|
+
batidas.forEach((b, i) => {
|
|
185
|
+
const n = `batidas[${i}]`;
|
|
186
|
+
if (!b || typeof b !== 'object') { problemas.push(`${n} não é um objeto.`); return; }
|
|
187
|
+
if (!Number.isFinite(Number(b.de)) || !Number.isFinite(Number(b.ate))) problemas.push(`${n}: "de" e "ate" são segundos.`);
|
|
188
|
+
else if (Number(b.ate) <= Number(b.de)) problemas.push(`${n}: "ate" (${b.ate}) tem de vir depois de "de" (${b.de}).`);
|
|
189
|
+
if (!formaDe(b.rosto)) problemas.push(`${n}: rosto "${b.rosto}" não existe — use ${FORMAS.join(', ')}.`);
|
|
190
|
+
if (b.cena != null && typeof b.cena !== 'string') problemas.push(`${n}: "cena" é o caminho do componente (ex.: componentes/x.jsx) ou o nome de um efeito de fábrica.`);
|
|
191
|
+
if (b.destaque != null && !Array.isArray(b.destaque) && typeof b.destaque !== 'string') problemas.push(`${n}: "destaque" é uma lista de palavras.`);
|
|
192
|
+
});
|
|
193
|
+
const ordem = batidas.filter((b) => b && Number.isFinite(Number(b.de))).slice().sort((a, b) => a.de - b.de);
|
|
194
|
+
for (let i = 1; i < ordem.length; i++) {
|
|
195
|
+
if (Number(ordem[i].de) < Number(ordem[i - 1].ate) - 0.05) problemas.push(`as batidas ${ordem[i - 1].de}–${ordem[i - 1].ate} e ${ordem[i].de}–${ordem[i].ate} se sobrepõem.`);
|
|
196
|
+
}
|
|
197
|
+
if (duracao && ordem.length && Number(ordem[0].de) >= duracao) problemas.push(`a primeira batida começa depois do fim da gravação (${duracao}s).`);
|
|
198
|
+
return problemas;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** As batidas no tempo do quadro: em ordem, coladas (o buraco vira da anterior), de 0 ao fim. */
|
|
202
|
+
function normalizar(batidas, duracao, fps = 30) {
|
|
203
|
+
const q = (x) => Math.round(x * fps) / fps;
|
|
204
|
+
const lista = batidas.map((b) => ({ ...b, rosto: formaDe(b.rosto), de: Number(b.de), ate: Number(b.ate) }))
|
|
205
|
+
.sort((a, b) => a.de - b.de)
|
|
206
|
+
.filter((b) => !duracao || b.de < duracao);
|
|
207
|
+
const fim = q(duracao || lista[lista.length - 1].ate);
|
|
208
|
+
return lista.map((b, i) => ({
|
|
209
|
+
...b,
|
|
210
|
+
de: i === 0 ? 0 : q(b.de),
|
|
211
|
+
ate: i === lista.length - 1 ? fim : q(lista[i + 1].de),
|
|
212
|
+
})).filter((b) => b.ate - b.de >= 1 / fps);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const listaDe = (x) => (Array.isArray(x) ? x : typeof x === 'string' ? [x] : [])
|
|
216
|
+
.flatMap((s) => String(s).split(/\s+/)).map(limpa).filter(Boolean);
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* O plano do vídeo em camadas.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} op
|
|
222
|
+
* @param {string} op.src a gravação, relativa à pasta do projeto (o áudio dela é a narração)
|
|
223
|
+
* @param {number} op.duracao segundos
|
|
224
|
+
* @param {Array} op.palavras [{de, ate, texto}] no tempo da gravação
|
|
225
|
+
* @param {Array} op.batidas [{de, ate, rosto, cena?, destaque?, canto?, dados?, foco?, zoom?}] — da IA
|
|
226
|
+
* @param {number} op.largura do vídeo final (e da gravação, se `origem` não vier)
|
|
227
|
+
* @param {object} op.origem {largura, altura} da gravação
|
|
228
|
+
* @param {object} op.rosto {foco:{x,y} (0 a 1, onde está o rosto na gravação), zoom}
|
|
229
|
+
* @param {string} op.acento a cor da borda, do brilho e da palavra de destaque
|
|
230
|
+
* @param {object} op.sons nome → arquivo (sons-leves.preparar)
|
|
231
|
+
*/
|
|
232
|
+
function montar({ src, duracao, palavras = [], batidas, largura = 1080, altura = 1920, fps = 30, origem = null, rosto = {}, acento = ACENTO, sons = {} } = {}) {
|
|
233
|
+
if (!src) return { ok: false, error: 'falta a gravação (src).' };
|
|
234
|
+
const problemas = conferir(batidas, duracao);
|
|
235
|
+
if (problemas.length) return { ok: false, error: 'as batidas têm problema: ' + problemas.join(' '), problemas };
|
|
236
|
+
const AC = corValida(acento) || ACENTO;
|
|
237
|
+
const W = largura, H = altura;
|
|
238
|
+
const orig = { largura: (origem && origem.largura) || W, altura: (origem && origem.altura) || H };
|
|
239
|
+
const lista = normalizar(batidas, duracao, fps);
|
|
240
|
+
const falas = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
|
|
241
|
+
const focoDe = (b) => {
|
|
242
|
+
const f = (b && b.foco) || rosto.foco || {};
|
|
243
|
+
const n = (v, d) => (Number.isFinite(Number(v)) ? Math.max(0, Math.min(1, Number(v))) : d);
|
|
244
|
+
return { fx: n(f.x, 0.5), fy: n(f.y, 0.4) };
|
|
245
|
+
};
|
|
246
|
+
const zoomDe = (b) => Math.max(1, Math.min(4, Number((b && b.zoom) ?? rosto.zoom) || 1));
|
|
247
|
+
|
|
248
|
+
const cenas = [];
|
|
249
|
+
const cues = [];
|
|
250
|
+
const micros = [];
|
|
251
|
+
const contagem = { cheia: 0, cartao: 0, bolinha: 0, some: 0 };
|
|
252
|
+
let comCena = 0;
|
|
253
|
+
let girarEntrada = 0;
|
|
254
|
+
const ENTRADA = ['pop', 'mouse', 'digital'];
|
|
255
|
+
|
|
256
|
+
lista.forEach((b, i) => {
|
|
257
|
+
const dur = r3(b.ate - b.de);
|
|
258
|
+
const op = { largura: W, altura: H, canto: b.canto || 'esquerda' };
|
|
259
|
+
const ant = i > 0 ? lista[i - 1] : null;
|
|
260
|
+
const opAnt = ant ? { largura: W, altura: H, canto: ant.canto || 'esquerda' } : op;
|
|
261
|
+
contagem[b.rosto]++;
|
|
262
|
+
|
|
263
|
+
/* As duas caixas: de onde o rosto vem e para onde vai. "Some" encolhe
|
|
264
|
+
no lugar de onde saiu (e volta a crescer de onde vai aparecer). */
|
|
265
|
+
const caixa = (forma, o, bat) => ({ ...caixaDe(forma, o), ...focoDe(bat), zoom: zoomDe(bat) });
|
|
266
|
+
let agora = caixa(b.rosto, op, b);
|
|
267
|
+
let antes = ant ? caixa(ant.rosto, opAnt, ant) : agora;
|
|
268
|
+
const sumir = (c) => ({ ...c, opacidade: 0, escala: 0.35, raio: Math.round(Math.min(c.w, c.h) / 2) });
|
|
269
|
+
if (b.rosto === 'some' && ant) agora = sumir(antes);
|
|
270
|
+
if (ant && ant.rosto === 'some') antes = sumir(agora);
|
|
271
|
+
const mudou = !!ant && ant.rosto !== b.rosto;
|
|
272
|
+
|
|
273
|
+
const elementos = [{ tipo: 'kit', peca: 'palco', acento: AC, semente: i }];
|
|
274
|
+
|
|
275
|
+
/* A cena anterior SAI, não some no corte: enquanto o rosto cresce, ela
|
|
276
|
+
continua no tempo dela (o componente com "de" negativo segue de onde
|
|
277
|
+
parou), encolhe e esmaece. Sem isso, o quadro da troca fica vazio. */
|
|
278
|
+
const grupoAnt = ant && mudou && cenas[i - 1].elementos.find((e) => e.tipo === 'grupo' && !e.saindo);
|
|
279
|
+
if (grupoAnt) {
|
|
280
|
+
const andou = r3(cenas[i - 1].duracao - grupoAnt.de);
|
|
281
|
+
elementos.push({
|
|
282
|
+
...grupoAnt, de: 0, ate: 0.4, saindo: true,
|
|
283
|
+
anima: { opacidade: [{ t: 0, v: 1 }, { t: 0.36, v: 0 }], escala: [{ t: 0, v: 1 }, { t: 0.4, v: 0.94, e: 'suave' }] },
|
|
284
|
+
origem: 'center center',
|
|
285
|
+
elementos: grupoAnt.elementos.map((e) => ({ ...e, de: -andou })),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// A cena de motion entra quando o rosto já está saindo do caminho.
|
|
290
|
+
if (b.cena) {
|
|
291
|
+
comCena++;
|
|
292
|
+
const area = areaLivre(b.rosto, op);
|
|
293
|
+
const entra = mudou ? 0.18 : 0;
|
|
294
|
+
const ws = falas.filter((p) => p.de >= b.de && p.de < b.ate);
|
|
295
|
+
elementos.push({
|
|
296
|
+
tipo: 'grupo', x: area.x, y: area.y, largura: area.w, altura: area.h, de: entra,
|
|
297
|
+
anima: { opacidade: [{ t: 0, v: 0 }, { t: 0.22, v: 1 }] },
|
|
298
|
+
elementos: [{
|
|
299
|
+
tipo: 'componente', src: b.cena,
|
|
300
|
+
dados: {
|
|
301
|
+
largura: area.w, altura: area.h, duracao: r3(dur - entra),
|
|
302
|
+
/* 15/10: a cena escrevia a fala, e a legenda do PrimoCode vinha
|
|
303
|
+
por cima ("provou / provou o bolo"). A fala é para SINCRONIA. */
|
|
304
|
+
legendaNoQuadro: true,
|
|
305
|
+
regra: 'a legenda da fala já está no quadro: não escreva a fala; use as palavras só para o tempo, e desenhe a ideia',
|
|
306
|
+
fala: ws.map((p) => p.texto).join(' '),
|
|
307
|
+
palavras: ws.map((p) => ({ texto: p.texto, t: r3(p.de - b.de - entra) })),
|
|
308
|
+
destaque: (Array.isArray(b.destaque) ? b.destaque : b.destaque ? [b.destaque] : []),
|
|
309
|
+
cores: { fundo: 'transparent', texto: '#ffffff', acento: AC, suave: '#ffffff99' },
|
|
310
|
+
fontes: { display: 'Instrument Serif', texto: 'Inter', mono: 'JetBrains Mono' },
|
|
311
|
+
// As chaves "fonte…" são as que o fontesCSS do remotion.js carrega.
|
|
312
|
+
fonteTitulo: 'Instrument Serif', fonteTexto: 'Inter', fonteMono: 'JetBrains Mono',
|
|
313
|
+
...(b.dados && typeof b.dados === 'object' ? b.dados : {}),
|
|
314
|
+
},
|
|
315
|
+
}],
|
|
316
|
+
});
|
|
317
|
+
cues.push({ t: r3(b.de + entra + 0.06), som: ENTRADA[girarEntrada++ % ENTRADA.length], volume: 0.3 });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
elementos.push({
|
|
321
|
+
tipo: 'kit', peca: 'rosto', src, deOrigem: r3(b.de), volume: 1,
|
|
322
|
+
origem: orig, antes, agora, mudou, acento: AC, forma: b.rosto,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// A legenda: 1 a 3 palavras por vez, o destaque na serifa e na cor de acento.
|
|
326
|
+
const fortes = new Set(listaDe(b.destaque));
|
|
327
|
+
const ws = falas.filter((p) => p.de >= b.de && p.de < b.ate);
|
|
328
|
+
const local = (t) => r3(Math.max(0, Math.min(dur, t - b.de)));
|
|
329
|
+
const blocos = edicao.blocos(ws, 3);
|
|
330
|
+
const fala = blocos.map((bl, j) => {
|
|
331
|
+
const fim = bl[bl.length - 1].ate + 0.6;
|
|
332
|
+
const prox = blocos[j + 1] ? blocos[j + 1][0].de - 0.04 : Infinity;
|
|
333
|
+
return {
|
|
334
|
+
de: local(bl[0].de - 0.04), ate: local(Math.min(fim, prox)),
|
|
335
|
+
texto: bl.map((p) => p.texto).join(' '),
|
|
336
|
+
palavras: bl.map((p) => {
|
|
337
|
+
const forte = fortes.has(limpa(p.texto));
|
|
338
|
+
if (forte) micros.push(r3(p.de));
|
|
339
|
+
return { de: local(p.de), ate: local(p.ate), texto: String(p.texto).replace(/[.,;:!?…]+$/, ''), ...(forte ? { forte: true } : {}) };
|
|
340
|
+
}),
|
|
341
|
+
};
|
|
342
|
+
}).filter((f) => f.ate > f.de);
|
|
343
|
+
if (fala.length) {
|
|
344
|
+
const lugar = lugarDaLegenda(b.rosto, op);
|
|
345
|
+
elementos.push({
|
|
346
|
+
// Largura explícita: ancorada no meio, sem ela a caixa só teria a metade direita do quadro e quebraria a linha.
|
|
347
|
+
tipo: 'legenda', x: lugar.x, y: lugar.y, ancora: 'centro', largura: Math.round((W > H ? 0.6 : 0.9) * W), fala,
|
|
348
|
+
estilo: {
|
|
349
|
+
fonte: 'Inter', peso: 800, tamanho: Math.round(Math.min(W, H) * 0.064),
|
|
350
|
+
cor: '#ffffff', destaque: '#ffffff', corForte: clarear(AC), fonteForte: 'Instrument Serif',
|
|
351
|
+
largura: '100%', contorno: '0px transparent',
|
|
352
|
+
sombra: '0 4px 26px rgba(0,0,0,.65), 0 1px 2px rgba(0,0,0,.5)',
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// O swoosh chega um tico antes da forma nova (o ar vem antes da imagem).
|
|
358
|
+
if (mudou) cues.push({ t: r3(Math.max(0, b.de - 0.1)), som: 'swoosh', volume: 0.34 });
|
|
359
|
+
|
|
360
|
+
cenas.push({ duracao: dur, fundo: { tipo: 'cor', valor: '#07060c' }, elementos, transicao: { tipo: 'corte' } });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const r = { plano: { formato: { largura: W, altura: H, fps }, fundoGeral: '#07060c', cenas, audio: {} } };
|
|
364
|
+
const total = cenas.reduce((n, c) => n + c.duracao, 0);
|
|
365
|
+
const nSons = sem.sonorizarPlano(r, { comVoz: true, sons, cues, tempos: [{ de: 0, ate: total, palavras: micros.map((t) => ({ de: t })) }] });
|
|
366
|
+
if (!r.plano.audio.efeitos || !r.plano.audio.efeitos.length) delete r.plano.audio;
|
|
367
|
+
return {
|
|
368
|
+
ok: true,
|
|
369
|
+
plano: r.plano,
|
|
370
|
+
batidas: lista.map((b) => ({ de: b.de, ate: b.ate, rosto: b.rosto, cena: b.cena || null })),
|
|
371
|
+
resumo: {
|
|
372
|
+
batidas: lista.length, formas: contagem, cenas: comCena,
|
|
373
|
+
trocasDeForma: lista.filter((b, i) => i && lista[i - 1].rosto !== b.rosto).length,
|
|
374
|
+
palavras: falas.length, destaques: micros.length, sons: nSons, duracao: r3(total),
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** O acento mais claro, para a palavra de destaque ler sobre o fundo escuro. */
|
|
380
|
+
function clarear(hex, k = 0.28) {
|
|
381
|
+
const c = corValida(hex) || ACENTO;
|
|
382
|
+
const n = (i) => Math.round(parseInt(c.slice(i, i + 2), 16) + (255 - parseInt(c.slice(i, i + 2), 16)) * k);
|
|
383
|
+
return '#' + [1, 3, 5].map((i) => n(i).toString(16).padStart(2, '0')).join('');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
module.exports = { FORMAS, formaDe, caixaDe, areaLivre, regiaoDoRosto, lugarDaLegenda, propor, conferir, normalizar, montar, limpa, clarear, ACENTO };
|
package/lib/estudio/diretor.js
CHANGED
|
@@ -110,7 +110,8 @@ function vozPara({ tom = 'neutro', idioma = 'pt-BR', genero } = {}) {
|
|
|
110
110
|
/** Quantas cenas cabem, e de que tamanho, num vídeo desta duração. */
|
|
111
111
|
function ritmoPara(segundos) {
|
|
112
112
|
const s = Math.max(5, Number(segundos) || 30);
|
|
113
|
-
|
|
113
|
+
// (15/10) As referências cortam a cada 1 a 2 s; o PrimoCode pedia 2,6 a 5 s e saía lento.
|
|
114
|
+
const media = s <= 20 ? 1.8 : s <= 45 ? 2.2 : s <= 90 ? 2.6 : 3;
|
|
114
115
|
return { cenas: Math.max(3, Math.round(s / media)), duracaoMedia: media,
|
|
115
116
|
palavrasPorCena: Math.round(media * 2.6) }; // ~155 palavras/minuto de narração
|
|
116
117
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Camadas 3D empilhadas: placas arredondadas caem uma a uma numa pilha isométrica, com mola, e flutuam.
|
|
2
|
+
// el.dados (ou el): camadas [{cor, rotulo}] (ou números de 3 a 6), titulo, cores {fundo, texto, acento}, fontes {display, texto}, largura, altura, atraso (s).
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { useVideoConfig } from 'remotion';
|
|
5
|
+
|
|
6
|
+
const PADRAO = ['#F5B82E', '#3B82F6', '#8B7CF6', '#C4B5FD', '#F4F4F5'];
|
|
7
|
+
|
|
8
|
+
function mola(s) {
|
|
9
|
+
if (s <= 0) return 0;
|
|
10
|
+
const w = 2 * Math.PI * 1.9, z = 0.55, wd = w * Math.sqrt(1 - z * z);
|
|
11
|
+
return 1 - Math.exp(-z * w * s) * (Math.cos(wd * s) + (z * w / wd) * Math.sin(wd * s));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function CamadasEmpilhadas({ el, t }) {
|
|
15
|
+
const d = el.dados || el;
|
|
16
|
+
const v = useVideoConfig();
|
|
17
|
+
const W = d.largura || v.width, H = d.altura || v.height;
|
|
18
|
+
const c = d.cores || {};
|
|
19
|
+
const f = d.fontes || {};
|
|
20
|
+
const u = t - (d.atraso || 0);
|
|
21
|
+
const lista = Array.isArray(d.camadas) && d.camadas.length
|
|
22
|
+
? d.camadas.slice(0, 6).map((x, i) => (typeof x === 'object' ? x : { cor: PADRAO[i % PADRAO.length] }))
|
|
23
|
+
: PADRAO.slice(0, Number(d.camadas) || 4).map((cor) => ({ cor }));
|
|
24
|
+
const n = lista.length;
|
|
25
|
+
const titulo = d.titulo ? 1 : 0;
|
|
26
|
+
const lado = Math.min(W * 0.56, (H * (titulo ? 0.66 : 0.82)) / 1.25);
|
|
27
|
+
const passo = lado * 0.13;
|
|
28
|
+
const respira = Math.sin(u * 1.6) * 6;
|
|
29
|
+
return (
|
|
30
|
+
<div style={{ position: 'absolute', inset: 0, width: W, height: H, background: c.fundo || 'transparent', overflow: 'visible' }}>
|
|
31
|
+
<div style={{ position: 'absolute', left: '50%', top: titulo ? '58%' : '52%', width: 0, height: 0, perspective: 1600 }}>
|
|
32
|
+
{/* a sombra da pilha, no chão */}
|
|
33
|
+
<div style={{ position: 'absolute', left: -lado * 0.6, top: lado * 0.38, width: lado * 1.2, height: lado * 0.3, borderRadius: '50%',
|
|
34
|
+
background: 'radial-gradient(closest-side, rgba(0,0,0,.55), transparent)', filter: 'blur(8px)', opacity: Math.min(1, u / 0.6) }} />
|
|
35
|
+
<div style={{ position: 'absolute', transformStyle: 'preserve-3d', transform: `rotateX(58deg) rotateZ(-40deg) translateZ(${respira}px)` }}>
|
|
36
|
+
{lista.map((cam, i) => {
|
|
37
|
+
const k = mola(u - 0.12 - i * 0.16);
|
|
38
|
+
const z = i * passo + (1 - k) * lado * 0.9;
|
|
39
|
+
const op = Math.min(1, Math.max(0, (u - 0.12 - i * 0.16) / 0.12));
|
|
40
|
+
const cor = cam.cor || PADRAO[i % PADRAO.length];
|
|
41
|
+
return (
|
|
42
|
+
<div key={i} style={{
|
|
43
|
+
position: 'absolute', left: -lado / 2, top: -lado / 2, width: lado, height: lado, borderRadius: lado * 0.12,
|
|
44
|
+
transform: `translateZ(${z}px)`, opacity: op,
|
|
45
|
+
background: `linear-gradient(135deg, ${cor}, ${cor}cc)`,
|
|
46
|
+
boxShadow: `0 0 0 2px #ffffff30 inset, 0 ${passo * 0.6}px ${passo * 1.4}px rgba(0,0,0,.35)`,
|
|
47
|
+
}}>
|
|
48
|
+
{cam.rotulo ? (
|
|
49
|
+
<div style={{ position: 'absolute', right: lado * 0.08, bottom: lado * 0.06, color: '#0b0b12', fontFamily: `"${f.texto || 'Inter'}", sans-serif`,
|
|
50
|
+
fontWeight: 800, fontSize: lado * 0.08, letterSpacing: '0.04em', textTransform: 'uppercase' }}>{cam.rotulo}</div>
|
|
51
|
+
) : null}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
})}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
{titulo ? (
|
|
58
|
+
<div style={{ position: 'absolute', top: 0, zIndex: 2, width: '100%', textAlign: 'center', color: c.texto || '#fff',
|
|
59
|
+
fontFamily: `"${f.display || 'Instrument Serif'}", serif`, fontStyle: 'italic', fontSize: Math.min(W * 0.09, H * 0.12),
|
|
60
|
+
opacity: Math.min(1, Math.max(0, u / 0.3)), transform: `translateY(${(1 - Math.min(1, Math.max(0, u / 0.3))) * 16}px)` }}>{d.titulo}</div>
|
|
61
|
+
) : null}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Terminal com o prompt sendo digitado: a janela entra com mola, cada linha é digitada letra a letra e o cursor pisca.
|
|
2
|
+
// el.dados (ou el): linhas [texto] (ou titulo/fala), rotulo (barra da janela), velocidade (letras/s), cores {fundo, texto, acento}, fontes {mono}, largura, altura, atraso (s).
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { useVideoConfig } from 'remotion';
|
|
5
|
+
|
|
6
|
+
function mola(s) {
|
|
7
|
+
if (s <= 0) return 0;
|
|
8
|
+
const w = 2 * Math.PI * 1.7, z = 0.6, wd = w * Math.sqrt(1 - z * z);
|
|
9
|
+
return 1 - Math.exp(-z * w * s) * (Math.cos(wd * s) + (z * w / wd) * Math.sin(wd * s));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function TerminalDigitando({ el, t }) {
|
|
13
|
+
const d = el.dados || el;
|
|
14
|
+
const v = useVideoConfig();
|
|
15
|
+
const W = d.largura || v.width, H = d.altura || v.height;
|
|
16
|
+
const c = d.cores || {};
|
|
17
|
+
const f = d.fontes || {};
|
|
18
|
+
const u = t - (d.atraso || 0);
|
|
19
|
+
// O prompt "›" é da janela: a linha que já vem com "> " ou "$ " saía com ele dobrado ("› > faz um site", 15/10).
|
|
20
|
+
const linhas = (Array.isArray(d.linhas) && d.linhas.length ? d.linhas : [d.titulo || d.fala || 'faz o vídeo pra mim']).map((x) => String(x).replace(/^\s*[>›$#]\s+/, '')).slice(0, 6);
|
|
21
|
+
const vel = d.velocidade || 26;
|
|
22
|
+
const k = mola(u);
|
|
23
|
+
const largura = Math.min(W * 0.96, H * 1.5);
|
|
24
|
+
const tam = Math.max(14, Math.min(largura * 0.058, (H * 0.6) / (linhas.length * 1.8 + 2)));
|
|
25
|
+
// Cada linha começa quando a anterior terminou de ser digitada (com um respiro).
|
|
26
|
+
let relogio = 0.35;
|
|
27
|
+
const tempos = linhas.map((l) => { const ini = relogio; relogio += l.length / vel + 0.25; return ini; });
|
|
28
|
+
const ac = c.acento || '#8B7CF6';
|
|
29
|
+
return (
|
|
30
|
+
<div style={{ position: 'absolute', inset: 0, width: W, height: H, background: c.fundo || 'transparent', display: 'grid', placeItems: 'center' }}>
|
|
31
|
+
<div style={{
|
|
32
|
+
width: largura, borderRadius: tam * 0.8, overflow: 'hidden', background: 'rgba(13,12,22,.92)',
|
|
33
|
+
border: '1px solid rgba(255,255,255,.12)', boxShadow: `0 30px 80px rgba(0,0,0,.55), 0 0 60px ${ac}22`,
|
|
34
|
+
transform: `translateY(${(1 - k) * 40}px) scale(${0.92 + 0.08 * k})`, opacity: Math.min(1, Math.max(0, u / 0.2)),
|
|
35
|
+
}}>
|
|
36
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: tam * 0.4, padding: `${tam * 0.55}px ${tam * 0.8}px`, borderBottom: '1px solid rgba(255,255,255,.08)' }}>
|
|
37
|
+
{['#ff5f57', '#febc2e', '#28c840'].map((cor) => <div key={cor} style={{ width: tam * 0.55, height: tam * 0.55, borderRadius: '50%', background: cor }} />)}
|
|
38
|
+
<div style={{ marginLeft: tam * 0.5, color: '#ffffff88', fontFamily: `"${f.mono || 'JetBrains Mono'}", monospace`, fontSize: tam * 0.62 }}>{d.rotulo || 'claude code'}</div>
|
|
39
|
+
</div>
|
|
40
|
+
<div style={{ padding: `${tam * 0.8}px ${tam}px ${tam * 1.1}px`, fontFamily: `"${f.mono || 'JetBrains Mono'}", monospace`, fontSize: tam, lineHeight: 1.6, color: c.texto || '#fff' }}>
|
|
41
|
+
{linhas.map((l, i) => {
|
|
42
|
+
const n = Math.max(0, Math.min(l.length, Math.floor((u - tempos[i]) * vel)));
|
|
43
|
+
if (u < tempos[i]) return null;
|
|
44
|
+
const digitando = n < l.length || i === linhas.length - 1;
|
|
45
|
+
const pisca = Math.floor(u * 2.2) % 2 === 0;
|
|
46
|
+
return (
|
|
47
|
+
<div key={i} style={{ whiteSpace: 'pre-wrap' }}>
|
|
48
|
+
<span style={{ color: ac, marginRight: tam * 0.5 }}>›</span>
|
|
49
|
+
{l.slice(0, n)}
|
|
50
|
+
{digitando ? <span style={{ display: 'inline-block', width: tam * 0.55, height: tam * 1.1, verticalAlign: 'text-bottom', marginLeft: 2, background: ac, opacity: n < l.length || pisca ? 1 : 0 }} /> : null}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
})}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
package/lib/estudio/efeitos.js
CHANGED
|
@@ -45,6 +45,8 @@ const FABRICA = [
|
|
|
45
45
|
{ nome: 'risco-multilinha', descricao: 'texto riscado com quebra de linha: um traço por linha, em sequência, e o texto esmaece (o "não" do contraste)', tags: ['risco', 'contraste'] },
|
|
46
46
|
{ nome: 'mola-com-peso', descricao: 'entrada com física: antecipa, cai com peso, amassa no pouso e assenta com rebote (texto ou imagem)', tags: ['fisica', 'entrada'] },
|
|
47
47
|
{ nome: 'raios-de-luz', descricao: 'feixes de luz giram atrás do título, o acento pulsa e partículas sobem na luz', tags: ['luz', 'brilho', 'titulo'] },
|
|
48
|
+
{ nome: 'camadas-empilhadas', descricao: 'placas 3D arredondadas caem uma a uma numa pilha isométrica, com mola, e flutuam (dados.camadas: [{cor, rotulo}], titulo)', tags: ['3d', 'camadas', 'pilha'] },
|
|
49
|
+
{ nome: 'terminal-digitando', descricao: 'janela de terminal entra com mola e o prompt é digitado letra a letra, com o cursor piscando (dados.linhas, rotulo)', tags: ['terminal', 'codigo', 'prompt'] },
|
|
48
50
|
];
|
|
49
51
|
const deFabrica = (nome) => FABRICA.find((x) => x.nome === nome && fs.existsSync(path.join(fabricaPasta, x.nome + '.jsx')));
|
|
50
52
|
|