primocode 10.11.0 → 10.12.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 CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v10.10.1
1
+ # PrimoCode v10.12.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.10.1
12
+ ▐▛███▜▌ Primo Code v10.12.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.12
21
+
22
+ - **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.
23
+ - **Fábrica:** camadas 3D empilhadas e terminal com o prompt sendo digitado.
24
+
25
+ ## O que mudou na 10.11
26
+
27
+ - **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}]`).
28
+ - **Vídeo sem narração:** `semNarracao: true` (ou "sem voz" no pedido) gera só motion, trilha e efeitos, e a trilha não abaixa.
29
+ - **Efeitos de fábrica:** peça do site em 3D com impacto, brilho que varre, cor que inunda, risco em várias linhas, entrada com física e raios de luz. Quando nenhuma cena é escrita, o plano devolve o cardápio de efeitos.
30
+ - **3D com peso:** o boneco antecipa, respira, pula amassando e anda com o quadril.
31
+ - **Risco:** texto riscado que quebra em várias linhas ganha um traço por linha.
32
+
20
33
  ## O que mudou na 10.4
21
34
 
22
35
  - **A tela do OpenCode.** O PrimoCode abre em tela cheia: a conversa rola em
package/lib/catalogo.js CHANGED
@@ -94,7 +94,7 @@ const GRUPOS = [
94
94
  + 'logo{titulo} cores{cores:[{nome,hex}]} produto{titulo,texto,destaque,selo,barras}. Em qualquer parte: destaque (a palavra-tese), fonte (a fonte citada). O texto da TELA sai da FALA: o título usa palavras que a fala diz, cedo na frase (a tela entra quando a voz diz; palavra que a voz não diz deixa a tela esperando). '
95
95
  + 'visual:{titulo,texto,paleta} (hibrido, cinema, impacto): fonte e cor do cardápio (estudio_midia acao:fontes); sem visual, o PrimoCode sorteia sem repetir os últimos vídeos. '
96
96
  + 'componente:"componentes/x.jsx" numa parte = a cena que VOCÊ escreveu em React — é o caminho do motion (skill Motion de primeira): uma ideia desenhada por fala; a voz, a trilha, os sons e o fundo seguem do Estúdio; cena que não compila volta em cenasQuebradas. '
97
- + 'semNarracao:true no roteiro (ou pedido "sem narração") = vídeo só com motion, trilha e efeitos sonoros: sem estudio_narrar, cada parte dura o seu "duracao" (s) ou o tempo de ler a tela, e um som leve entra em cada corte e entrada. '
97
+ + 'semNarracao:true no roteiro (ou pedido "sem narração") = vídeo só com motion, trilha e efeitos sonoros: sem estudio_narrar, cada parte dura o seu "duracao" (s) ou o tempo de ler a tela, e um som leve entra em cada corte e entrada. Toda parte aceita sons:[{t, som}] (t na parte; pop, mouse, tique, digital, swoosh, sub, ponto1..ponto5) para o barulhinho cair no quadro em que a coisa acontece. '
98
98
  + 'estilo (deixe VAZIO: o gerativo, com as suas cenas em componente e o estiloVisual que você montou; outro só se a pessoa pedir pelo nome): hibrido (SÓ quando a pessoa pediu imagem: foto e vídeo reais + o motion por cima; dê busca:"termo em inglês" só nas partes em que a imagem agrega — a COISA que a frase nomeia, 2 a 4 palavras, não a frase inteira; rota:"tipografia" em número/citação, "produto" quando a frase fala do produto; ancora:"palavra" — o clipe troca 0,2 s depois dela) | gerativo (= motion: sem template — a tipografia anima nos eixos da fonte numa linha do tempo contínua, composição, fonte, cor e som — estiloVisual {humor, paleta, display, texto, mono, genero, ambiente, acento, material} é o estilo que VOCÊ monta pelo assunto (cardápio: estudio_midia acao:estilos; vazio, o PrimoCode sorteia); modelo3d fone|celular|notebook|relogio|garrafa|lata|caixa: o objeto gira em 3D numa batida — ponha quando o vídeo é de um produto ou objeto físico; ideia abstrata, história ou explicação: deixe vazio; o vídeo tem UMA identidade (2 chãos, 1 acento, 2 fontes) e o fundo só troca na virada de seção — se o roteiro tem atos, dê secao:"nome curto" nas partes, senão o PrimoCode divide; moldura cantos|completa: o quadro fixo do reel (cantos, o assunto no alto, "01 · seção", o tempo) — SÓ quando a pessoa pedir; vazio = sem moldura (ela entrega que o vídeo foi gerado); heroi em cada parte: o desenho da batida além do texto, tirado do CONTEÚDO — um ícone 3D RENDERIZADO da biblioteca ("fluent3d:hot beverage", "fluent3d:money bag"; procure com estudio_midia acao:icones3d — é o que mais parece 3D de verdade), uma animação pronta ("noto:1f525"; acao:animacoes), um ícone do Iconify do objeto que a fala cita ("mdi:bread-slice", "mdi:qrcode"; acao:icones), um 3D do kit (raio|relogio|check|balao|escudo|coracao|estrela|sol|play|engrenagem|cadeado|barras|fone|onda|celular|seta|bateria) ou "nenhum" quando a tipografia basta; forma abstrata (explosao|poligono|esfera|toro|truchet|aneis|grade|rastro) só em assunto abstrato ou de tecnologia — numa conversa de padaria, nunca; ambiente limpo|luz|bokeh e acento traco|viagem (o ponto que viaja entre as batidas): vazio, o PrimoCode escolhe com memória e o bokeh quase não sai fora de tecnologia) | classico (o motion 3D antigo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
99
99
  + 'marca (brand reveal: brilho, letras em vetor, marca cromada, padrão) | lancamento (produto: cartões, painel, calendário) | fluido (minimalista: uma ideia por batida, a forma de uma vira a próxima, sem corte; paleta noite|breu|preto|cobalto (escuras) ou lavanda|creme|laranja|branco (claras) — escolha pelo assunto e NÃO repita a do vídeo anterior; vazio, o PrimoCode escolhe; fontesFluido aprovado|editorial|revista|carater|tecnico|classico, vazio gira) | impacto | cinema | personagem (a ANIMAÇÃO 3D de verdade, o vídeo GRAVADO em 3D no visual de blocos/voxel de render de cinema — é a escolha para animação 3D, personagem, bicho, boneco, avatar, mascote, podcast, entrevista ou alguém FALANDO em 3D; cada parte é UMA fala {falante, texto, emocao?, destaque?, acao?, cenario?}, sem tipo). NADA É FIXO, monte o que a pessoa pediu: cena:"texto livre do lugar" (praia, cidade, floresta, parque, fazenda, neve, deserto, espaço, fundo do mar, escritório, sala, cozinha, escola, palco… + hora: "à noite", "ao pôr do sol") ou cena:{lugar, hora, elementos:["palmeira","barraca","violão",…]} — elemento fora do catálogo vira ícone 3D em blocos; parte.cenario muda o lugar a partir daquela fala. Até 4 falantes; personagens:{"nome":{tipo:pessoa|robo|animal|coisa, especie (animal: cachorro|gato|cavalo|vaca|porco|ovelha|raposa|lobo|urso|panda|coelho|leao|tigre|elefante|girafa|tartaruga|galinha|pato|passaro|papagaio|coruja|pinguim|peixe|tubarao|golfinho|dinossauro|sapo|macaco), icone (coisa: "pizza", "café", "polvo" — qualquer objeto/bicho que fala), pessoa: pele, cabelo:curto|cacheado|longo|topete|coque|careca, corCabelo, roupa, oculos, barba; robo: cor}} — sem tipo, o nome decide (nome de bicho vira bicho, de IA vira o mascote laranja, de objeto vira coisa); figurantes:["cachorro","pessoa"] (quem está na cena sem falar); BICHO NÃO FALA: a fala de um bicho sai da narração e ele fica na cena — só fala se a pessoa pedir ("cachorro falante") ou personagens.x.fala:true; a voz de cada falante sai do GÊNERO do nome (personagens.x.genero:f|m manda), uma voz por personagem do começo ao fim; bigode:true e cabeca:"cubo" (o rosto de pixel antigo) em personagens; sem lugar no pedido, o set é a cobertura à noite (janela da cidade, cartões de interface na frente). acao em cada parte: acenar|apontar|pular|dancar|comemorar|pensar|andar|correr. emocao feliz|serio|surpreso|pensativo|animado|curioso; destaque:"texto curto"; paineis:["título",…] só no set de telas (o padrão quando o pedido não diz lugar); visual:"liso" só se pedirem o boneco liso antigo). '
100
100
  + 'marca e lancamento aceitam visual:orbe|vetor|esfera|grade|site|padrao|cromo|cartoes|painel|cores numa parte. marca.nome dá o nome. '
@@ -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). '
@@ -0,0 +1,382 @@
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
+ fala: ws.map((p) => p.texto).join(' '),
303
+ palavras: ws.map((p) => ({ texto: p.texto, t: r3(p.de - b.de - entra) })),
304
+ destaque: (Array.isArray(b.destaque) ? b.destaque : b.destaque ? [b.destaque] : []),
305
+ cores: { fundo: 'transparent', texto: '#ffffff', acento: AC, suave: '#ffffff99' },
306
+ fontes: { display: 'Instrument Serif', texto: 'Inter', mono: 'JetBrains Mono' },
307
+ // As chaves "fonte…" são as que o fontesCSS do remotion.js carrega.
308
+ fonteTitulo: 'Instrument Serif', fonteTexto: 'Inter', fonteMono: 'JetBrains Mono',
309
+ ...(b.dados && typeof b.dados === 'object' ? b.dados : {}),
310
+ },
311
+ }],
312
+ });
313
+ cues.push({ t: r3(b.de + entra + 0.06), som: ENTRADA[girarEntrada++ % ENTRADA.length], volume: 0.3 });
314
+ }
315
+
316
+ elementos.push({
317
+ tipo: 'kit', peca: 'rosto', src, deOrigem: r3(b.de), volume: 1,
318
+ origem: orig, antes, agora, mudou, acento: AC, forma: b.rosto,
319
+ });
320
+
321
+ // A legenda: 1 a 3 palavras por vez, o destaque na serifa e na cor de acento.
322
+ const fortes = new Set(listaDe(b.destaque));
323
+ const ws = falas.filter((p) => p.de >= b.de && p.de < b.ate);
324
+ const local = (t) => r3(Math.max(0, Math.min(dur, t - b.de)));
325
+ const blocos = edicao.blocos(ws, 3);
326
+ const fala = blocos.map((bl, j) => {
327
+ const fim = bl[bl.length - 1].ate + 0.6;
328
+ const prox = blocos[j + 1] ? blocos[j + 1][0].de - 0.04 : Infinity;
329
+ return {
330
+ de: local(bl[0].de - 0.04), ate: local(Math.min(fim, prox)),
331
+ texto: bl.map((p) => p.texto).join(' '),
332
+ palavras: bl.map((p) => {
333
+ const forte = fortes.has(limpa(p.texto));
334
+ if (forte) micros.push(r3(p.de));
335
+ return { de: local(p.de), ate: local(p.ate), texto: String(p.texto).replace(/[.,;:!?…]+$/, ''), ...(forte ? { forte: true } : {}) };
336
+ }),
337
+ };
338
+ }).filter((f) => f.ate > f.de);
339
+ if (fala.length) {
340
+ const lugar = lugarDaLegenda(b.rosto, op);
341
+ elementos.push({
342
+ // Largura explícita: ancorada no meio, sem ela a caixa só teria a metade direita do quadro e quebraria a linha.
343
+ tipo: 'legenda', x: lugar.x, y: lugar.y, ancora: 'centro', largura: Math.round((W > H ? 0.6 : 0.9) * W), fala,
344
+ estilo: {
345
+ fonte: 'Inter', peso: 800, tamanho: Math.round(Math.min(W, H) * 0.064),
346
+ cor: '#ffffff', destaque: '#ffffff', corForte: clarear(AC), fonteForte: 'Instrument Serif',
347
+ largura: '100%', contorno: '0px transparent',
348
+ sombra: '0 4px 26px rgba(0,0,0,.65), 0 1px 2px rgba(0,0,0,.5)',
349
+ },
350
+ });
351
+ }
352
+
353
+ // O swoosh chega um tico antes da forma nova (o ar vem antes da imagem).
354
+ if (mudou) cues.push({ t: r3(Math.max(0, b.de - 0.1)), som: 'swoosh', volume: 0.34 });
355
+
356
+ cenas.push({ duracao: dur, fundo: { tipo: 'cor', valor: '#07060c' }, elementos, transicao: { tipo: 'corte' } });
357
+ });
358
+
359
+ const r = { plano: { formato: { largura: W, altura: H, fps }, fundoGeral: '#07060c', cenas, audio: {} } };
360
+ const total = cenas.reduce((n, c) => n + c.duracao, 0);
361
+ const nSons = sem.sonorizarPlano(r, { comVoz: true, sons, cues, tempos: [{ de: 0, ate: total, palavras: micros.map((t) => ({ de: t })) }] });
362
+ if (!r.plano.audio.efeitos || !r.plano.audio.efeitos.length) delete r.plano.audio;
363
+ return {
364
+ ok: true,
365
+ plano: r.plano,
366
+ batidas: lista.map((b) => ({ de: b.de, ate: b.ate, rosto: b.rosto, cena: b.cena || null })),
367
+ resumo: {
368
+ batidas: lista.length, formas: contagem, cenas: comCena,
369
+ trocasDeForma: lista.filter((b, i) => i && lista[i - 1].rosto !== b.rosto).length,
370
+ palavras: falas.length, destaques: micros.length, sons: nSons, duracao: r3(total),
371
+ },
372
+ };
373
+ }
374
+
375
+ /** O acento mais claro, para a palavra de destaque ler sobre o fundo escuro. */
376
+ function clarear(hex, k = 0.28) {
377
+ const c = corValida(hex) || ACENTO;
378
+ const n = (i) => Math.round(parseInt(c.slice(i, i + 2), 16) + (255 - parseInt(c.slice(i, i + 2), 16)) * k);
379
+ return '#' + [1, 3, 5].map((i) => n(i).toString(16).padStart(2, '0')).join('');
380
+ }
381
+
382
+ module.exports = { FORMAS, formaDe, caixaDe, areaLivre, regiaoDoRosto, lugarDaLegenda, propor, conferir, normalizar, montar, limpa, clarear, ACENTO };
@@ -17,6 +17,8 @@ const fs = require('fs');
17
17
  const os = require('os');
18
18
  const path = require('path');
19
19
 
20
+ const EASING = ['linear', 'ease', 'quad', 'cubic', 'poly', 'sin', 'circle', 'exp', 'elastic', 'back', 'bounce', 'bezier', 'in', 'out', 'inOut', 'step0', 'step1'];
21
+ const TROCA = { expo: 'exp', sine: 'sin', circ: 'circle', cubicBezier: 'bezier', easeIn: 'in', easeOut: 'out', easeInOut: 'inOut', quart: 'poly' };
20
22
  const PERMITIDOS = /^(react|remotion|three|@remotion\/[\w-]+|\.{1,2}\/.+)$/;
21
23
 
22
24
  let esbuild;
@@ -33,6 +35,10 @@ function conferirCena(arquivo, { compilar = compilador() } = {}) {
33
35
  if (!/export\s+default\b/.test(codigo)) return { ok: false, erro: 'falta o export default da função da cena ({el, t, fps}).' };
34
36
  const fora = [...codigo.matchAll(/\bfrom\s+['"]([^'"]+)['"]|\brequire\(\s*['"]([^'"]+)['"]\s*\)/g)].map((m) => m[1] || m[2]).filter((x) => !PERMITIDOS.test(x));
35
37
  if (fora.length) return { ok: false, erro: `import fora do permitido: ${[...new Set(fora)].join(', ')} (só react, remotion, @remotion/*, three).` };
38
+ /* Easing que não existe compila, mas mata o render inteiro (14/10, a
39
+ padaria: "Easing.expo" — easing is not a function, 3 renders perdidos). */
40
+ const easingErrado = [...new Set([...codigo.matchAll(/\bEasing\.(\w+)/g)].map((m) => m[1]))].filter((n) => !EASING.includes(n));
41
+ if (easingErrado.length) return { ok: false, erro: `Easing.${easingErrado[0]} não existe no Remotion${TROCA[easingErrado[0]] ? ` — use Easing.${TROCA[easingErrado[0]]}` : ''} (existem: ${EASING.join(', ')}).` };
36
42
  if (!compilar) return { ok: true, semCompilador: true };
37
43
  try {
38
44
  compilar.transformSync(codigo, { loader: /\.tsx$/i.test(arquivo) ? 'tsx' : 'jsx', jsx: 'automatic', logLevel: 'silent' });
@@ -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,57 @@
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
+ const linhas = (Array.isArray(d.linhas) && d.linhas.length ? d.linhas : [d.titulo || d.fala || 'faz o vídeo pra mim']).map(String).slice(0, 6);
20
+ const vel = d.velocidade || 26;
21
+ const k = mola(u);
22
+ const largura = Math.min(W * 0.96, H * 1.5);
23
+ const tam = Math.max(14, Math.min(largura * 0.058, (H * 0.6) / (linhas.length * 1.8 + 2)));
24
+ // Cada linha começa quando a anterior terminou de ser digitada (com um respiro).
25
+ let relogio = 0.35;
26
+ const tempos = linhas.map((l) => { const ini = relogio; relogio += l.length / vel + 0.25; return ini; });
27
+ const ac = c.acento || '#8B7CF6';
28
+ return (
29
+ <div style={{ position: 'absolute', inset: 0, width: W, height: H, background: c.fundo || 'transparent', display: 'grid', placeItems: 'center' }}>
30
+ <div style={{
31
+ width: largura, borderRadius: tam * 0.8, overflow: 'hidden', background: 'rgba(13,12,22,.92)',
32
+ border: '1px solid rgba(255,255,255,.12)', boxShadow: `0 30px 80px rgba(0,0,0,.55), 0 0 60px ${ac}22`,
33
+ transform: `translateY(${(1 - k) * 40}px) scale(${0.92 + 0.08 * k})`, opacity: Math.min(1, Math.max(0, u / 0.2)),
34
+ }}>
35
+ <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)' }}>
36
+ {['#ff5f57', '#febc2e', '#28c840'].map((cor) => <div key={cor} style={{ width: tam * 0.55, height: tam * 0.55, borderRadius: '50%', background: cor }} />)}
37
+ <div style={{ marginLeft: tam * 0.5, color: '#ffffff88', fontFamily: `"${f.mono || 'JetBrains Mono'}", monospace`, fontSize: tam * 0.62 }}>{d.rotulo || 'claude code'}</div>
38
+ </div>
39
+ <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' }}>
40
+ {linhas.map((l, i) => {
41
+ const n = Math.max(0, Math.min(l.length, Math.floor((u - tempos[i]) * vel)));
42
+ if (u < tempos[i]) return null;
43
+ const digitando = n < l.length || i === linhas.length - 1;
44
+ const pisca = Math.floor(u * 2.2) % 2 === 0;
45
+ return (
46
+ <div key={i} style={{ whiteSpace: 'pre-wrap' }}>
47
+ <span style={{ color: ac, marginRight: tam * 0.5 }}>›</span>
48
+ {l.slice(0, n)}
49
+ {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}
50
+ </div>
51
+ );
52
+ })}
53
+ </div>
54
+ </div>
55
+ </div>
56
+ );
57
+ }
@@ -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
 
@@ -77,6 +77,7 @@ const colecao = require('./colecao.js');
77
77
  const { escolherEstilo, LEVES } = require('./estilo.js');
78
78
  const sonsLeves = require('./sons-leves.js');
79
79
  const semNarracao = require('./sem-narracao.js');
80
+ const camadas = require('./camadas.js');
80
81
  const ende = require('./ende.js');
81
82
  const producao = require('./producao.js');
82
83
 
@@ -409,9 +410,13 @@ async function ferramentaPlano(args) {
409
410
  /* SEM NARRAÇÃO: o som sobe de papel — um efeito leve em cada corte e em
410
411
  cada entrada marcante, variando (sem-narracao.js, sons-leves.js). */
411
412
  if (args.__semNarracao) {
412
- const n = semNarracao.sonorizarPlano(r, { tempos: args.__semNarracao.tempos, sons: sonsLeves.preparar(p.pasta) });
413
+ const n = semNarracao.sonorizarPlano(r, { tempos: args.__semNarracao.tempos, cues: args.__semNarracao.cues, sons: sonsLeves.preparar(p.pasta) });
413
414
  if (r.resumo) r.resumo.semNarracao = true;
414
- projeto.anotar(p.id, 'plano', `sem narração: ${n} efeitos leves nos cortes e entradas`);
415
+ projeto.anotar(p.id, 'plano', `sem narração: ${n} efeitos leves nos cortes, entradas e micros`);
416
+ } else if (args.__barulhinhos && r.plano) {
417
+ // Com voz: os micros e os sons pedidos pela cena, baixinho por baixo da fala.
418
+ const n = semNarracao.sonorizarPlano(r, { tempos: args.__barulhinhos.tempos, cues: args.__barulhinhos.cues, sons: sonsLeves.preparar(p.pasta), comVoz: true });
419
+ if (n) projeto.anotar(p.id, 'plano', `barulhinhos: ${n} micros por baixo da fala`);
415
420
  }
416
421
  const ic = await variarIcones(r.plano, original);
417
422
  if (ic.trocados) projeto.anotar(p.id, 'icones', `coleção ${ic.colecao}: ${ic.trocados} ícones`);
@@ -439,9 +444,18 @@ async function ferramentaPlano(args) {
439
444
  projeto.salvar(args.id, { estrutura: usada.estrutura || '-' });
440
445
  }
441
446
  const quebradas = args.__cenasQuebradas || [];
447
+ /* NENHUMA CENA ESCRITA (14/10, a padaria da 10.11.0 saiu toda no
448
+ gerativo): a regra morava só na skill, e regra escrita não é regra
449
+ obedecida. O plano volta com o cardápio na mão — a IA ainda decide. */
450
+ const pts = Array.isArray(original) ? original : (original && original.partes) || [];
451
+ const nenhuma = pts.length > 1 && !pts.some((pt) => pt && pt.componente) && !quebradas.length;
442
452
  return {
443
453
  ok: true, duracao: conferida.duracao, ...r.resumo, resumo: plano.resumo(r.plano),
444
454
  ...(quebradas.length ? { cenasQuebradas: quebradas } : {}),
455
+ ...(nenhuma ? { nenhumaCenaEscrita: {
456
+ aviso: 'Todas as partes caíram no gerativo automático. Para motion de verdade, escreva ao menos uma cena (componente), partindo de um destes efeitos (estudio_midia usar-efeito) se servir, e mande o roteiro de novo. Cada parte pode pedir som: sons:[{t, som}] (pop, mouse, tique, digital, swoosh, sub, ponto1..ponto5). Se o automático é o que você quer, siga para o render.',
457
+ efeitos: efeitos.cardapio().slice(0, 6),
458
+ } } : {}),
445
459
  proximo: quebradas.length
446
460
  ? `${quebradas.length} cena(s) sua(s) não compilaram e ficaram no gerativo: conserte o arquivo (o erro está em cenasQuebradas) e mande o roteiro de novo antes do render.`
447
461
  : 'estudio_render com este id. Para ajustar, mande o roteiro de novo (é rápido) — não edite as cenas à mão.',
@@ -849,6 +863,80 @@ async function ferramentaVideo(args, ctx = {}) {
849
863
  };
850
864
  }
851
865
 
866
+ /* O VÍDEO EM CAMADAS (camadas.js, 14/10: "quero uma edição nesse nível"):
867
+ a pessoa só gravou a fala; a IA passa as batidas (forma do rosto, cena
868
+ de motion, destaque) e o Estúdio monta o bolo por cima. Sem batidas,
869
+ devolve uma proposta pela transcrição — a IA aceita ou muda. */
870
+ if (acao === 'camadas') {
871
+ const m = await video.medir(alvo);
872
+ if (!m.ok) return m;
873
+ // A transcrição fica guardada: a proposta e a montagem não pagam duas vezes.
874
+ const guardada = path.join(p.pasta, 'midia', `transcricao-${path.basename(alvo).replace(/\.[^.]+$/, '')}.json`);
875
+ let palavras = Array.isArray(args.palavras) && args.palavras.length ? args.palavras : null;
876
+ if (palavras) { try { fs.writeFileSync(guardada, JSON.stringify(palavras)); } catch { /* segue sem guardar */ } }
877
+ if (!palavras && fs.existsSync(guardada)) { try { palavras = JSON.parse(fs.readFileSync(guardada, 'utf8')); } catch { palavras = null; } }
878
+ if (!palavras) {
879
+ const tr = await transcrever(p, alvo, ctx);
880
+ if (!tr.ok) return tr;
881
+ palavras = (tr.palavras && tr.palavras.length) ? tr.palavras : repartirTrechos(tr.trechos || []);
882
+ try { fs.writeFileSync(guardada, JSON.stringify(palavras)); } catch { /* segue sem guardar */ }
883
+ }
884
+ const batidas = Array.isArray(args.batidas) && args.batidas.length ? args.batidas : null;
885
+ if (!batidas) {
886
+ return {
887
+ ok: true, proposta: camadas.propor(palavras, m.segundos), duracao: m.segundos,
888
+ comoUsar: 'é uma SUGESTÃO pela transcrição. Decida você: mude as formas, junte ou separe batidas, '
889
+ + 'escreva a cena de motion de cada ideia (componentes/x.jsx, recebe {el, t, fps}; el.dados traz largura/altura do espaço livre, '
890
+ + 'palavras com t, destaque, cores e fontes) ou use um efeito de fábrica pelo nome (estudio_midia acao:"efeitos"), '
891
+ + 'e chame de novo com batidas:[{de, ate, rosto, cena?, destaque?, canto?, dados?}].',
892
+ };
893
+ }
894
+ // A cena: arquivo da pasta (conferido antes do render) ou efeito de fábrica pelo nome.
895
+ const problemas = [];
896
+ const prontas = batidas.map((b, i) => {
897
+ if (!b || !b.cena) return b;
898
+ const cena = String(b.cena).trim();
899
+ if (/[\\/]|\.(jsx|tsx|js)$/i.test(cena)) {
900
+ const cheio = dentro(cena);
901
+ if (!cheio || !fs.existsSync(cheio)) { problemas.push(`batidas[${i}]: a cena "${cena}" não está na pasta do projeto.`); return { ...b, cena: null }; }
902
+ const conf = conferirCena(cheio);
903
+ if (!conf.ok) { problemas.push(`batidas[${i}]: ${cena} — ${conf.erro}`); return { ...b, cena: null }; }
904
+ return { ...b, cena };
905
+ }
906
+ const r = efeitos.reusar({ pasta: p.pasta, nome: cena });
907
+ if (!r.ok) { problemas.push(`batidas[${i}]: ${r.error}`); return { ...b, cena: null }; }
908
+ return { ...b, cena: r.src };
909
+ });
910
+ if (problemas.length) return { ok: false, error: 'as cenas têm problema — conserte e mande de novo.', problemas };
911
+
912
+ /* A voz no volume de vídeo de internet (-15 LUFS), como na edição
913
+ automática; o vídeo em si não é recodificado. */
914
+ let fonte = alvo;
915
+ if (args.normalizar !== false) {
916
+ const normal = path.join(p.pasta, 'midia', `voz-normal-${path.basename(alvo).replace(/\.[^.]+$/, '')}.mp4`);
917
+ if (!fs.existsSync(normal)) {
918
+ await video.rodar(video.ffmpeg(), ['-y', '-i', alvo, '-c:v', 'copy',
919
+ '-af', 'loudnorm=I=-15:TP=-1.5:LRA=11', '-c:a', 'aac', '-b:a', '192k', normal], { timeout: 10 * 60000 });
920
+ }
921
+ if (fs.existsSync(normal)) fonte = normal;
922
+ }
923
+ const deitado = m.largura > m.altura;
924
+ const montado = camadas.montar({
925
+ src: path.relative(p.pasta, fonte).replace(/\\/g, '/'), duracao: m.segundos, palavras, batidas: prontas,
926
+ largura: deitado ? 1920 : 1080, altura: deitado ? 1080 : 1920, fps: 30,
927
+ origem: { largura: m.largura, altura: m.altura }, rosto: args.rosto || {}, acento: args.acento,
928
+ sons: sonsLeves.preparar(p.pasta),
929
+ });
930
+ if (!montado.ok) return montado;
931
+ const salvo = await ferramentaPlano({ id: args.id, plano: montado.plano, motivo: `vídeo em camadas (${montado.resumo.batidas} batidas)` });
932
+ if (!salvo.ok) return salvo;
933
+ projeto.anotar(p.id, 'edicao', `camadas: ${montado.resumo.batidas} batidas, ${montado.resumo.cenas} cenas de motion, ${montado.resumo.trocasDeForma} trocas de forma`);
934
+ return {
935
+ ok: true, ...montado.resumo, batidas: montado.batidas, duracao: salvo.duracao,
936
+ proximo: 'estudio_render com este id. Para mudar uma batida, chame camadas de novo com a lista inteira.',
937
+ };
938
+ }
939
+
852
940
  if (acao === 'recortar-pessoa' || acao === 'pessoa') {
853
941
  const saida = path.join(p.pasta, 'midia', `pessoa-${Date.now().toString(36)}.webm`);
854
942
  const r = await rodarPessoa([
@@ -1792,7 +1880,7 @@ async function prepararRoteiro(p, args) {
1792
1880
  texto da tela pede), e as palavras são repartidas na parte. Os sons
1793
1881
  leves entram depois do plano, em cada corte e entrada (sem-narracao.js). */
1794
1882
  tempos = semNarracao.tempos(partes, { falaDe: composicao.falaDe });
1795
- args.__semNarracao = { tempos };
1883
+ args.__semNarracao = { tempos, cues: semNarracao.cuesDe(partes, tempos) };
1796
1884
  const total = tempos.length ? tempos[tempos.length - 1].ate : 0;
1797
1885
  projeto.anotar(p.id, 'narracao', `sem narração: ${Math.round(total * 10) / 10}s em ${partes.length} partes, só motion e efeitos sonoros`);
1798
1886
  sons = estilo === 'gerativo' ? {} : ehMotion ? sonsLeves.preparar(p.pasta) : await edicao.prepararSons(p.pasta, video);
@@ -1870,6 +1958,7 @@ async function prepararRoteiro(p, args) {
1870
1958
  }
1871
1959
  }
1872
1960
  tempos = v.partes;
1961
+ args.__barulhinhos = { tempos, cues: semNarracao.cuesDe(partes, tempos) };
1873
1962
  narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
1874
1963
  projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
1875
1964
  // O motion usa os efeitos leves; os estilos de corte, os cheios.
@@ -12,6 +12,7 @@ import { FLUIDO } from './fluido';
12
12
  import { PERSONAGEM } from './personagem';
13
13
  import { VOXEL } from './voxel';
14
14
  import { ANIMACAO } from './animacao';
15
+ import { ROSTO } from './rosto';
15
16
 
16
- export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, ...FLUIDO, ...PERSONAGEM, ...VOXEL, ...ANIMACAO, papel: Papel };
17
+ export const KIT = { ...VOX, ...MARCA, ...TRES, ...GERATIVO, ...FLUIDO, ...PERSONAGEM, ...VOXEL, ...ANIMACAO, ...ROSTO, papel: Papel };
17
18
  export { Filtros, Acabamento, Hud, CURVAS_KIT, bolhaCaminho } from './base';
@@ -0,0 +1,108 @@
1
+ /* kit/rosto.jsx — o rosto de quem fala, que muda de forma (camadas.js).
2
+ *
3
+ * "o vídeo da pessoa é contínuo, mas o ROSTO muda de forma: tela cheia,
4
+ * cartão com borda que brilha, bolinha no canto, some" (Joel, 14/10)
5
+ *
6
+ * Duas peças:
7
+ * - palco: o fundo escuro com brilho que respira e grão (o que aparece
8
+ * quando o rosto não ocupa a tela);
9
+ * - rosto: a gravação dentro de uma caixa que vai de `antes` para `agora`
10
+ * com mola (kit/natural.js): recua um tico (antecipação), vai, passa do
11
+ * ponto e assenta. A gravação começa em `deOrigem` e NÃO reinicia: cada
12
+ * cena pega do ponto em que a anterior parou, e o áudio dela é a fala do
13
+ * vídeo. Quando "some", o vídeo continua tocando, só invisível.
14
+ * A geometria (onde fica cada caixa) vem pronta do camadas.js; aqui só se
15
+ * interpola. Nada de sorteio: o mesmo quadro sai igual sempre. */
16
+ import React from 'react';
17
+ import { AbsoluteFill, Video, staticFile, useVideoConfig } from 'remotion';
18
+ import { mola, ruido } from './natural';
19
+
20
+ const lerp = (a, b, k) => a + (b - a) * k;
21
+ const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
22
+ const CAMPOS = ['x', 'y', 'w', 'h', 'raio', 'anel', 'zoom', 'fx', 'fy'];
23
+
24
+ /** A caixa no instante t (s) da cena. */
25
+ export function caixaNoTempo(el, t) {
26
+ const B = el.agora || el.antes || {};
27
+ const A = el.antes || B;
28
+ if (!el.mudou) return { ...B };
29
+ const s = t - 0.05;
30
+ // A mola com peso: um pouco de sobra, e assenta em ~0,6 s.
31
+ const k = mola(s, { freq: 1.55, amort: 0.62 });
32
+ const kc = clamp(k, 0, 1);
33
+ // A antecipação: antes de ir, a caixa encolhe um tico (o "respiro" do gesto).
34
+ const prep = s > 0 && s < 0.14 ? Math.sin(Math.PI * (s / 0.14)) * 0.035 : 0;
35
+ const o = {};
36
+ for (const c of CAMPOS) o[c] = lerp(A[c] ?? B[c] ?? 0, B[c] ?? A[c] ?? 0, k);
37
+ o.raio = Math.max(0, o.raio);
38
+ o.anel = Math.max(0, o.anel);
39
+ o.w = Math.max(1, o.w); o.h = Math.max(1, o.h);
40
+ o.opacidade = lerp(A.opacidade ?? 1, B.opacidade ?? 1, kc);
41
+ o.escala = Math.max(0, lerp(A.escala ?? 1, B.escala ?? 1, k)) * (1 - prep);
42
+ return o;
43
+ }
44
+
45
+ export function Rosto({ el, t, fps }) {
46
+ const c = caixaNoTempo(el, t);
47
+ const ac = el.acento || '#8B7CF6';
48
+ const origem = el.origem || {};
49
+ const sw = origem.largura || 1080, sh = origem.altura || 1920;
50
+ // A gravação cobre a caixa (cover) com o zoom, e o rosto (fx, fy) vai para o centro, acima do meio (o queixo longe da legenda).
51
+ const s = Math.max(c.w / sw, c.h / sh) * Math.max(1, c.zoom || 1);
52
+ const vw = sw * s, vh = sh * s;
53
+ const left = clamp(c.w * 0.5 - (c.fx ?? 0.5) * vw, c.w - vw, 0);
54
+ const top = clamp(c.h * 0.38 - (c.fy ?? 0.4) * vh, c.h - vh, 0);
55
+ // A bolinha e o cartão flutuam de leve: parado de todo parece colado.
56
+ const flutua = el.forma === 'bolinha' || el.forma === 'cartao' ? ruido(t * 0.35, 11) * 4 : 0;
57
+ const anel = c.anel;
58
+ const brilho = anel > 0
59
+ ? `0 0 ${Math.round(18 + anel * 5)}px ${ac}88, 0 0 ${Math.round(60 + anel * 10)}px ${ac}33, 0 26px 70px rgba(0,0,0,.6)`
60
+ : 'none';
61
+ return (
62
+ <div style={{
63
+ position: 'absolute', left: c.x, top: c.y + flutua, width: c.w, height: c.h,
64
+ transform: `scale(${c.escala})`, transformOrigin: '50% 50%', opacity: c.opacidade,
65
+ borderRadius: c.raio, padding: anel,
66
+ // A borda que brilha: um degradê do acento ao branco, por trás do vídeo.
67
+ background: anel > 0 ? `linear-gradient(150deg, #ffffffcc, ${ac} 38%, ${ac}aa 70%, #ffffff88)` : 'transparent',
68
+ boxShadow: brilho, boxSizing: 'border-box',
69
+ }}>
70
+ <div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden', borderRadius: Math.max(0, c.raio - anel), background: '#000' }}>
71
+ <Video
72
+ src={staticFile(el.src)}
73
+ startFrom={el.deOrigem ? Math.round(el.deOrigem * fps) : undefined}
74
+ volume={el.volume ?? 1}
75
+ style={{ position: 'absolute', left, top, width: vw, height: vh, maxWidth: 'none' }}
76
+ />
77
+ </div>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ /** O palco: preto azulado, dois brilhos que derivam devagar, vinheta e grão. */
83
+ export function Palco({ el, t }) {
84
+ const { width: W, height: H } = useVideoConfig();
85
+ const ac = el.acento || '#8B7CF6';
86
+ const dx = ruido(t * 0.12, 3 + (el.semente || 0)) * 7;
87
+ const dy = ruido(t * 0.1, 7 + (el.semente || 0)) * 5;
88
+ const quadro = Math.floor(t * 24);
89
+ const id = 'grao-' + (el.semente || 0);
90
+ return (
91
+ <AbsoluteFill style={{ background: '#07060c', overflow: 'hidden' }}>
92
+ <div style={{ position: 'absolute', inset: 0, background:
93
+ `radial-gradient(55% 38% at ${50 + dx}% ${58 + dy}%, ${ac}40, transparent 72%),`
94
+ + `radial-gradient(45% 30% at ${16 - dx}% ${16 - dy}%, #4f46e526, transparent 70%),`
95
+ + `radial-gradient(70% 45% at ${80 + dy}% ${96}%, #1d4ed838, transparent 72%)` }} />
96
+ <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(120% 80% at 50% 45%, transparent 55%, rgba(0,0,0,.55) 100%)' }} />
97
+ <svg width={W} height={H} style={{ position: 'absolute', inset: 0, opacity: el.grao ?? 0.13, mixBlendMode: 'screen' }}>
98
+ <filter id={id}>
99
+ <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed={quadro % 61} stitchTiles="stitch" />
100
+ <feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1.1 -0.45" />
101
+ </filter>
102
+ <rect width="100%" height="100%" filter={`url(#${id})`} />
103
+ </svg>
104
+ </AbsoluteFill>
105
+ );
106
+ }
107
+
108
+ export const ROSTO = { rosto: Rosto, palco: Palco };
@@ -65,9 +65,12 @@ const PECAS = {
65
65
  voxel: { precisa: ['partitura'] },
66
66
  // A animação pronta da biblioteca (kit/animacao.jsx, colecao.js): o Lottie tocado quadro a quadro.
67
67
  animacao: { precisa: ['src'] },
68
+ // O vídeo em camadas (kit/rosto.jsx, camadas.js): a gravação que muda de forma, e o palco escuro atrás dela.
69
+ rosto: { precisa: ['src'] },
70
+ palco: {},
68
71
  };
69
72
 
70
- const ARQUIVOS = ['base.jsx', 'vox.jsx', 'marca.jsx', 'tres.jsx', 'gerativo.jsx', 'fluido.jsx', 'personagem.jsx', 'mundo.jsx', 'voxel.jsx', 'animacao.jsx', 'natural.js', 'index.jsx'];
73
+ const ARQUIVOS = ['base.jsx', 'vox.jsx', 'marca.jsx', 'tres.jsx', 'gerativo.jsx', 'fluido.jsx', 'personagem.jsx', 'mundo.jsx', 'voxel.jsx', 'animacao.jsx', 'rosto.jsx', 'natural.js', 'index.jsx'];
71
74
 
72
75
  /** Os arquivos do kit, para o projeto Remotion: [{nome: 'kit/base.jsx', conteudo}]. */
73
76
  function arquivos() {
@@ -67,7 +67,10 @@ function revisar(plano, { semImagem = false } = {}) {
67
67
  const efeitos = ((plano.audio && plano.audio.efeitos) || []).slice().sort((a, b) => (a.t || 0) - (b.t || 0));
68
68
  if (efeitos.length) {
69
69
  const fica = [];
70
- for (const e of efeitos) {
70
+ /* O micro (o tique, a escada de notas) não conta para o teto: é a
71
+ textura das referências, 2 a 3 por segundo, baixinho (14/10). */
72
+ const micros = efeitos.filter((e) => e.micro);
73
+ for (const e of efeitos.filter((x) => !x.micro)) {
71
74
  const ult = fica[fica.length - 1];
72
75
  if (ult && e.src === ult.src && (e.t || 0) - (ult.t || 0) < 0.3) {
73
76
  if ((e.volume ?? 1) > (ult.volume ?? 1)) fica[fica.length - 1] = e;
@@ -88,7 +91,7 @@ function revisar(plano, { semImagem = false } = {}) {
88
91
  final = guardados.sort((a, b) => (a.t || 0) - (b.t || 0));
89
92
  consertos.push(`${fica.length} efeitos sonoros em ${r2(total)}s eram ruído — ficaram os ${final.length} mais fortes`);
90
93
  }
91
- plano.audio.efeitos = final;
94
+ plano.audio.efeitos = [...final, ...micros].sort((a, b) => (a.t || 0) - (b.t || 0));
92
95
  }
93
96
 
94
97
  // 4. Texto que não dá tempo de ler.
@@ -65,12 +65,14 @@ function tempos(partes, { falaDe = textoDaTela } = {}) {
65
65
 
66
66
  /* Os momentos que pedem som: os CORTES (começo de parte, de batida, de cena)
67
67
  e as ENTRADAS marcantes (o ícone, o item, a cena que a IA escreveu). */
68
- function momentos({ tempos: tps = [], plano = null, partitura = null } = {}) {
69
- const cortes = [], entradas = [];
68
+ function momentos({ tempos: tps = [], plano = null, partitura = null, cues = [] } = {}) {
69
+ const cortes = [], entradas = [], micros = [], revelas = [];
70
70
  const perto = (lista, t, d = 0.3) => lista.some((x) => Math.abs(x - t) < d);
71
71
  const corte = (t) => { if (t > 0.3 && !perto(cortes, t)) cortes.push(r3(t)); };
72
72
  const entrada = (t) => { if (t != null && t >= 0 && !perto(entradas, t, 0.35) && !perto(cortes, t, 0.2)) entradas.push(r3(t)); };
73
73
  for (const t of tps.slice(1)) corte(t.de);
74
+ // A cena escrita pela IA não tem batida: as palavras repartidas na parte são os micros dela.
75
+ for (const tp of tps) for (const pw of tp.palavras || []) if (pw && pw.de != null) micros.push(r3(pw.de));
74
76
  const bs = (partitura && partitura.batidas) || [];
75
77
  for (const b of bs) corte(b.de);
76
78
  // Vídeo de várias cenas (os outros estilos): o começo de cada cena é um corte.
@@ -80,25 +82,35 @@ function momentos({ tempos: tps = [], plano = null, partitura = null } = {}) {
80
82
  for (const b of bs) {
81
83
  if (b.icone3d && b.icone3d.t != null) entrada(b.icone3d.t);
82
84
  for (const l of b.linhas || []) if ((l.papel === 'item' || l.papel === 'numero') && l.t != null) entrada(l.t);
83
- if (b.comp === 'marca') entrada(b.de + 0.35);
85
+ if (b.comp === 'marca') { entrada(b.de + 0.35); revelas.push(r3(b.de + 0.3)); }
86
+ /* O MICRO (14/10, as referências): cada linha e cada palavra que entra
87
+ faz um som pequeno — é isso que dá a sensação de "tudo tem som". */
88
+ for (const l of b.linhas || []) {
89
+ if (l.t != null) micros.push(r3(l.t));
90
+ for (const pw of l.palavras || []) if (pw && pw.t != null) micros.push(r3(pw.t));
91
+ }
84
92
  }
85
93
  for (const c of (plano && plano.cenas) || []) for (const el of c.elementos || []) if (el && el.tipo === 'componente' && el.de != null) entrada(el.de + 0.08);
86
- return { cortes: cortes.sort((a, b) => a - b), entradas: entradas.sort((a, b) => a - b) };
94
+ const ocupado = [...cortes, ...entradas];
95
+ const mic = [...new Set(micros)].sort((a, b) => a - b).filter((t, i, l) => t > 0.05 && !ocupado.some((x) => Math.abs(x - t) < 0.1) && (i === 0 || t - l[i - 1] >= 0.09));
96
+ return { cortes: cortes.sort((a, b) => a - b), entradas: entradas.sort((a, b) => a - b), micros: mic, revelas, cues: (cues || []).filter((c) => c && c.t != null) };
87
97
  }
88
98
 
89
99
  // O corte passa com ar; a entrada chega com um toque pequeno. Ordem de rodízio.
90
- const DO_CORTE = ['whoosh', 'sopro', 'toque', 'riser'];
91
- const DA_ENTRADA = ['pop', 'bolha', 'clique', 'brilho'];
100
+ const DO_CORTE = ['swoosh', 'whoosh', 'sopro', 'toque', 'riser'];
101
+ const DA_ENTRADA = ['pop', 'mouse', 'bolha', 'digital', 'clique', 'brilho'];
102
+ // O micro sobe numa escada (ponto1→ponto5) enquanto as coisas entram em sequência, e recomeça na pausa.
103
+ const ESCADA = ['ponto1', 'ponto2', 'ponto3', 'ponto4', 'ponto5'];
92
104
  // O whoosh e o sopro antecipam o corte (o ar chega antes da imagem); o riser sobe até ele.
93
- const ANTES = { whoosh: 0.18, sopro: 0.3, riser: 1.0, toque: 0 };
94
- const VOLUME = { whoosh: 0.5, sopro: 0.6, toque: 0.6, riser: 0.4, pop: 0.42, bolha: 0.45, clique: 0.4, brilho: 0.32 };
105
+ const ANTES = { whoosh: 0.18, swoosh: 0.12, sopro: 0.3, riser: 1.0, toque: 0 };
106
+ const VOLUME = { swoosh: 0.45, mouse: 0.4, digital: 0.3, tique: 0.3, sub: 0.7, whoosh: 0.5, sopro: 0.6, toque: 0.6, riser: 0.4, pop: 0.42, bolha: 0.45, clique: 0.4, brilho: 0.32 };
95
107
 
96
108
  /**
97
109
  * Soma aos efeitos do plano um som leve em cada corte e em cada entrada.
98
110
  * `sons` é o mapa nome → arquivo do sons-leves.preparar. Não encosta em som
99
111
  * que já existe (0,12 s) e nunca repete o mesmo som duas vezes seguidas.
100
112
  */
101
- function sonorizar(efeitos, { cortes = [], entradas = [], sons = {}, duracao = Infinity } = {}) {
113
+ function sonorizar(efeitos, { cortes = [], entradas = [], micros = [], revelas = [], cues = [], sons = {}, duracao = Infinity, volMicro = 0.24 } = {}) {
102
114
  const fora = (efeitos || []).slice();
103
115
  const eventos = [...cortes.map((t) => ({ t, tipo: 'corte' })), ...entradas.map((t) => ({ t, tipo: 'entrada' }))].sort((a, b) => a.t - b.t);
104
116
  const giro = { corte: 0, entrada: 0 };
@@ -121,20 +133,57 @@ function sonorizar(efeitos, { cortes = [], entradas = [], sons = {}, duracao = I
121
133
  novos.push({ t, src: sons[nome], volume: VOLUME[nome] || 0.4, som: nome });
122
134
  ultimo = nome;
123
135
  }
136
+ // A revelação (a marca no fim) ganha o sub por baixo do som da entrada.
137
+ for (const t of revelas) if (sons.sub && t < duracao - 0.1) novos.push({ t: r3(t), src: sons.sub, volume: VOLUME.sub, som: 'sub' });
138
+ // O som que a cena pediu (parte.sons: a IA sabe quando a bolinha chega).
139
+ for (const c of cues) {
140
+ const nome = sons[c.som] ? c.som : (sons.pop ? 'pop' : null);
141
+ if (nome && c.t < duracao - 0.05) novos.push({ t: r3(c.t), src: sons[nome], volume: Number(c.volume) || VOLUME[nome] || 0.35, som: nome, micro: true });
142
+ }
143
+ // Os micros: a escada sobe em sequência e recomeça depois de 0,6 s de pausa; um tique a cada tanto quebra a repetição.
144
+ let degrau = -1, antes = -9;
145
+ for (const t of micros) {
146
+ if (t > duracao - 0.05) continue;
147
+ degrau = t - antes > 0.6 ? 0 : Math.min(ESCADA.length - 1, degrau + 1);
148
+ antes = t;
149
+ const nome = degrau === ESCADA.length - 1 && sons.tique && micros.length > 8 && Math.round(t * 10) % 3 === 0 ? 'tique' : ESCADA[degrau];
150
+ if (!sons[nome] || novos.some((e) => Math.abs(e.t - t) < 0.08)) continue;
151
+ novos.push({ t, src: sons[nome], volume: volMicro, som: nome, micro: true });
152
+ }
124
153
  return { efeitos: [...fora, ...novos.map(({ som, ...e }) => e)].sort((a, b) => a.t - b.t), novos };
125
154
  }
126
155
 
127
156
  /** O plano pronto ganha os barulhinhos (o plano é mudado no lugar). Devolve quantos entraram. */
128
- function sonorizarPlano(r, { tempos: tps = [], sons = {} } = {}) {
157
+ function sonorizarPlano(r, { tempos: tps = [], sons = {}, cues = [], comVoz = false } = {}) {
129
158
  if (!r || !r.plano) return 0;
130
159
  const plano = r.plano;
131
160
  plano.audio = plano.audio || {};
132
161
  const duracao = (plano.cenas || []).reduce((n, c) => n + (Number(c.duracao) || 0), 0) || Infinity;
133
- const m = momentos({ tempos: tps, plano, partitura: r.partitura });
134
- const s = sonorizar(plano.audio.efeitos || [], { ...m, sons, duracao });
162
+ const m = momentos({ tempos: tps, plano, partitura: r.partitura, cues });
163
+ /* Com voz, os cortes e entradas já têm som (gerativo.js): entram só os
164
+ micros e os pedidos pela cena, baixinho, para não brigar com a fala. */
165
+ const s = comVoz
166
+ ? sonorizar(plano.audio.efeitos || [], { micros: m.micros, cues: m.cues, sons, duracao, volMicro: 0.14 })
167
+ : sonorizar(plano.audio.efeitos || [], { ...m, sons, duracao });
135
168
  plano.audio.efeitos = s.efeitos;
169
+ if (comVoz) return s.novos.length;
136
170
  delete plano.audio.narracao;
171
+ /* Sem voz não há o que abrir espaço: a trilha não abaixa entre os cortes
172
+ (o ducking era para a fala, e deixava o vídeo em -17 LUFS). */
173
+ const tr = plano.audio.trilha;
174
+ if (tr && Array.isArray(tr.volumes)) tr.volumes = tr.volumes.map((v) => (v.t > 0.3 && v.v < 0.85 ? { ...v, v: 0.85 } : v));
137
175
  return s.novos.length;
138
176
  }
139
177
 
140
- module.exports = { ativo, textoDaTela, duracaoDe, tempos, momentos, sonorizar, sonorizarPlano, PEDIDO };
178
+ /** parte.sons [{t, som, volume}] (t na parte) → no tempo do vídeo. */
179
+ function cuesDe(partes = [], tps = []) {
180
+ const out = [];
181
+ (partes || []).forEach((pt, i) => {
182
+ const de = tps[i] && tps[i].de;
183
+ if (de == null || !pt || !Array.isArray(pt.sons)) return;
184
+ for (const c of pt.sons) if (c && Number.isFinite(Number(c.t))) out.push({ t: r3(de + Number(c.t)), som: String(c.som || 'pop'), volume: c.volume });
185
+ });
186
+ return out;
187
+ }
188
+
189
+ module.exports = { cuesDe, ativo, textoDaTela, duracaoDe, tempos, momentos, sonorizar, sonorizarPlano, PEDIDO };
@@ -40,6 +40,21 @@ const EFEITOS = {
40
40
  impacto: ['-f', 'lavfi', '-i', "aevalsrc='0.95*sin(2*PI*(62-26*t)*t)*exp(-4.5*t)+0.45*(random(0)*2-1)*exp(-38*t)':s=44100:d=1.1", '-af', 'lowpass=f=2600,volume=1.4'],
41
41
  riser: ['-f', 'lavfi', '-i', "aevalsrc='(0.32*sin(2*PI*(170+620*t*t)*t)+0.22*(random(0)*2-1))*pow(t/1.1,2)':s=44100:d=1.1", '-af', 'highpass=f=260,afade=t=out:st=1.04:d=0.06,volume=1.3'],
42
42
  pop: ['-f', 'lavfi', '-i', "aevalsrc='0.8*sin(2*PI*(980-640*t)*t)*exp(-28*t)*(1-exp(-700*t))':s=44100:d=0.18"],
43
+ /* OS BARULHINHOS DAS REFERÊNCIAS (14/10, "eu quero meus barulhinhos", com o
44
+ motion1.mp4: 29 sons em 15 s, cada bolinha e cada ponto que aparece faz
45
+ um). Pequenos e secos: o tique da interface, o clique do mouse, o blip
46
+ digital, o swoosh que desce, o sub da revelação e a escada de notas. */
47
+ tique: ['-f', 'lavfi', '-i', "aevalsrc='0.45*sin(2*PI*3200*t)*exp(-140*t)+0.12*(random(0)*2-1)*exp(-220*t)':s=44100:d=0.05", '-af', 'highpass=f=900'],
48
+ mouse: ['-f', 'lavfi', '-i', "aevalsrc='0.6*(random(0)*2-1)*exp(-260*t)+0.45*(random(0)*2-1)*exp(-260*(t-0.07))*gte(t,0.07)':s=44100:d=0.14", '-af', 'bandpass=f=2600:width_type=o:w=1.6,volume=1.6'],
49
+ digital: ['-f', 'lavfi', '-i', "aevalsrc='0.28*sgn(sin(2*PI*(1500+900*gte(t,0.03))*t))*exp(-38*t)':s=44100:d=0.08", '-af', 'lowpass=f=5200'],
50
+ swoosh: ['-f', 'lavfi', '-i', 'anoisesrc=d=0.55:c=white:a=0.8', '-af',
51
+ 'bandpass=f=2600:width_type=o:w=3,afade=t=in:st=0:d=0.12:curve=exp,afade=t=out:st=0.12:d=0.43:curve=exp,volume=1.6'],
52
+ sub: ['-f', 'lavfi', '-i', "aevalsrc='0.9*sin(2*PI*(55-20*t)*t)*exp(-3*t)*(1-exp(-300*t))':s=44100:d=1.2", '-af', 'lowpass=f=220,volume=1.5'],
53
+ ponto1: ['-f', 'lavfi', '-i', "aevalsrc='0.55*sin(2*PI*784.0*t)*exp(-34*t)*(1-exp(-900*t))+0.15*sin(2*PI*1568.0*t)*exp(-50*t)':s=44100:d=0.16"],
54
+ ponto2: ['-f', 'lavfi', '-i', "aevalsrc='0.55*sin(2*PI*880.0*t)*exp(-34*t)*(1-exp(-900*t))+0.15*sin(2*PI*1760.0*t)*exp(-50*t)':s=44100:d=0.16"],
55
+ ponto3: ['-f', 'lavfi', '-i', "aevalsrc='0.55*sin(2*PI*987.8*t)*exp(-34*t)*(1-exp(-900*t))+0.15*sin(2*PI*1975.6*t)*exp(-50*t)':s=44100:d=0.16"],
56
+ ponto4: ['-f', 'lavfi', '-i', "aevalsrc='0.55*sin(2*PI*1174.7*t)*exp(-34*t)*(1-exp(-900*t))+0.15*sin(2*PI*2349.4*t)*exp(-50*t)':s=44100:d=0.16"],
57
+ ponto5: ['-f', 'lavfi', '-i', "aevalsrc='0.55*sin(2*PI*1318.5*t)*exp(-34*t)*(1-exp(-900*t))+0.15*sin(2*PI*2637.0*t)*exp(-50*t)':s=44100:d=0.16"],
43
58
  };
44
59
 
45
60
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.11.0",
3
+ "version": "10.12.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/conta.test.mjs && node test/planos-tela.test.js && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/gerativo-identidade.test.js && node test/gerativo-moldura.test.js && node test/gerativo-heroi.test.js && node test/gerativo-ambiente.test.js && node test/gerativo-variedade.test.js && node test/hibrido.test.js && node test/batida.test.js && node test/estudio-acervo.test.js && node test/estudio-diretor.test.js && node test/estudio-marca.test.js && node test/estudio-novo-projeto.test.js && node test/estudio-plano.test.js && node test/estudio-slides.test.js && node test/estudio-video.test.js && node test/revisor.test.js && node test/conferencia.test.js && node test/cor.test.js && node test/assinatura.test.js && node test/biblioteca.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/rolar.test.js && node test/orquestra.test.js && node test/shell-limpo.test.js && node test/editar-remotion.test.js && node test/variacao3d.test.js && node test/portao-skill.test.js && node test/clones.test.js && node test/kokoro.test.js && node test/fluido.test.js && node test/zoom.test.js && node test/personagem.test.js && node test/tela.test.js && node test/margem.test.js && node test/travado.test.js && node test/leitura.test.js && node test/producao-provedores.test.js && node test/broll-frase.test.js && node test/vazamento.test.js && node test/colecao.test.js && node test/mundo.test.js && node test/mcp-procurar.test.js && node test/selecao.test.js && node test/nome-curto.test.js && node test/agents-md.test.js && node test/outra-pasta.test.js && node test/estudio-sem-id.test.js && node test/estudio-objeto-do-site.test.js && node test/cenarios.test.js && node test/movimentos.test.js && node test/gerativo-so-fala.test.js && node test/estilos.test.js && node test/proporcao.test.js && node test/cena.test.js && node test/sem-narracao.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/conta.test.mjs && node test/planos-tela.test.js && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/gerativo-identidade.test.js && node test/gerativo-moldura.test.js && node test/gerativo-heroi.test.js && node test/gerativo-ambiente.test.js && node test/gerativo-variedade.test.js && node test/hibrido.test.js && node test/batida.test.js && node test/estudio-acervo.test.js && node test/estudio-diretor.test.js && node test/estudio-marca.test.js && node test/estudio-novo-projeto.test.js && node test/estudio-plano.test.js && node test/estudio-slides.test.js && node test/estudio-video.test.js && node test/revisor.test.js && node test/conferencia.test.js && node test/cor.test.js && node test/assinatura.test.js && node test/biblioteca.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/rolar.test.js && node test/orquestra.test.js && node test/shell-limpo.test.js && node test/editar-remotion.test.js && node test/variacao3d.test.js && node test/portao-skill.test.js && node test/clones.test.js && node test/kokoro.test.js && node test/fluido.test.js && node test/zoom.test.js && node test/personagem.test.js && node test/tela.test.js && node test/margem.test.js && node test/travado.test.js && node test/leitura.test.js && node test/producao-provedores.test.js && node test/broll-frase.test.js && node test/vazamento.test.js && node test/colecao.test.js && node test/mundo.test.js && node test/mcp-procurar.test.js && node test/selecao.test.js && node test/nome-curto.test.js && node test/agents-md.test.js && node test/outra-pasta.test.js && node test/estudio-sem-id.test.js && node test/estudio-objeto-do-site.test.js && node test/cenarios.test.js && node test/movimentos.test.js && node test/gerativo-so-fala.test.js && node test/estilos.test.js && node test/proporcao.test.js && node test/cena.test.js && node test/sem-narracao.test.js && node test/camadas.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -45,6 +45,12 @@ por 1 a 3 segundos. Depois volta. É isso que dá ritmo a vídeo falado.
45
45
  Busque a imagem pelo que ela DISSE naquele instante (você tem os tempos da
46
46
  transcrição). Volte sempre antes de a frase acabar.
47
47
 
48
+ ## Vídeo em camadas
49
+
50
+ Pediu "edição nesse nível", motion por cima da fala, rosto que vira cartão ou
51
+ bolinha: `acao:"camadas"`. Você decide as batidas; a descrição da ferramenta
52
+ diz o formato.
53
+
48
54
  ## Troca de fundo
49
55
 
50
56
  `acao:"recortar-pessoa"` devolve a pessoa com fundo transparente. Ponha uma