primocode 9.8.0-beta.10 → 9.8.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/lib/catalogo.js +4 -1
- package/lib/estudio/edicao.js +331 -0
- package/lib/estudio/index.js +65 -1
- package/lib/estudio/remotion.js +66 -3
- package/lib/local/cerebro.json +334 -185
- package/lib/local/entrada.js +18 -8
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v9.8.0-beta.
|
|
1
|
+
# PrimoCode v9.8.0-beta.11
|
|
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 v9.8.0-beta.
|
|
12
|
+
▐▛███▜▌ Primo Code v9.8.0-beta.11
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/lib/catalogo.js
CHANGED
|
@@ -104,7 +104,10 @@ const GRUPOS = [
|
|
|
104
104
|
+ 'Use os tempos que voltam para a duração das cenas — estimar por número de palavras erra em segundos.' },
|
|
105
105
|
|
|
106
106
|
{ 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',
|
|
107
|
-
args: 'id. acao: importar | medir | silencio | cortar-silencio | quadro | legendas | recortar-pessoa. '
|
|
107
|
+
args: 'id. acao: editar | importar | medir | silencio | cortar-silencio | quadro | legendas | recortar-pessoa. '
|
|
108
|
+
+ 'editar: FAZ A EDIÇÃO INTEIRA sozinho — corta o silêncio, transcreve palavra por palavra e monta o plano '
|
|
109
|
+
+ '(legenda animada, ícone na hora da palavra, zoom de corte, pop e whoosh). estilo: viral (padrão) | limpo. '
|
|
110
|
+
+ 'Com gravação do usuário, é o PRIMEIRO passo depois de importar; depois só estudio_render. '
|
|
108
111
|
+ 'arquivo: caminho (em "importar", de fora; nas outras, o relativo dentro do projeto). '
|
|
109
112
|
+ 'cortar-silencio: minimoParaCortar (padrão 0.6s), respiro (padrão 0.18s) — os dois existem para o corte não soar robótico. '
|
|
110
113
|
+ 'legendas: devolve os trechos com tempo, para virarem elemento "legenda". '
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edicao.js — a gravação da pessoa vira um vídeo editado, SEM o modelo
|
|
3
|
+
* escrever cena por cena.
|
|
4
|
+
*
|
|
5
|
+
* "Deixe iguais aqueles vídeos que as videomakers editam: aquelas legendas
|
|
6
|
+
* maravilhosas que fazem parte do vídeo, alguns ícones aparecendo, cortes
|
|
7
|
+
* naturais, mais animações… Quero que até uma batata faça vídeos assim."
|
|
8
|
+
* (25/09/2026)
|
|
9
|
+
*
|
|
10
|
+
* Com o Claude o vídeo saiu ótimo porque o modelo escreveu, à mão, cada
|
|
11
|
+
* legenda, cada palavra em destaque e cada zoom. Um modelo grátis não faz
|
|
12
|
+
* isso. Então a edição mora AQUI, em código: com as palavras e o tempo de
|
|
13
|
+
* cada uma, `montarEdicao` devolve o plano inteiro —
|
|
14
|
+
*
|
|
15
|
+
* - uma cena por frase, com o corte seco entre elas e o zoom alternando
|
|
16
|
+
* (o "punch-in" que dá ritmo a quem fala para a câmera);
|
|
17
|
+
* - a legenda palavra por palavra, com a palavra dita em destaque e a
|
|
18
|
+
* palavra forte da frase na cor dela;
|
|
19
|
+
* - o ícone (emoji colorido) que salta na hora em que a palavra é dita;
|
|
20
|
+
* - o letreiro grande da ideia, nas frases sem ícone;
|
|
21
|
+
* - o pop e o whoosh nos momentos certos, e a barra de progresso.
|
|
22
|
+
*
|
|
23
|
+
* É uma função pura: entra palavra, sai plano. O modelo só escolhe o estilo,
|
|
24
|
+
* e pode ajustar qualquer cena depois com estudio_plano.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
// ── as palavras que não carregam ideia ───────────────────────────────────
|
|
30
|
+
const VAZIAS = new Set((
|
|
31
|
+
'a o as os um uma uns umas de do da dos das no na nos nas em por para pra pro com sem que se e '
|
|
32
|
+
+ 'ou mas como mais menos muito muita muitos muitas isso isto esse essa este esta aquele aquela ele ela '
|
|
33
|
+
+ 'eles elas eu voce voces nos meu minha seu sua nosso nossa tem ter ser foi era sao esta estao vai vou '
|
|
34
|
+
+ 'ja ai aqui la entao tambem so quando onde porque pois ate sobre depois antes cada todo toda todos '
|
|
35
|
+
+ 'todas qual quais quem nao sim ne ta tipo coisa coisas gente fazer faz feito pode podem vamos hoje'
|
|
36
|
+
).split(' '));
|
|
37
|
+
|
|
38
|
+
/* A palavra (sem acento, minúscula, começo dela) → o emoji colorido. Casa
|
|
39
|
+
pelo começo para "aprender", "aprende" e "aprendizado" caírem no mesmo. Os
|
|
40
|
+
nomes foram conferidos na coleção fluent-emoji-flat do Iconify (25/09). */
|
|
41
|
+
const ICONES = [
|
|
42
|
+
[['inteligencia', 'ia', 'robo', 'maquina', 'automac'], 'robot'],
|
|
43
|
+
[['cerebro', 'pens', 'raciocin', 'aprend', 'mente'], 'brain'],
|
|
44
|
+
[['ideia', 'criativ', 'inova'], 'light-bulb'],
|
|
45
|
+
[['dinheiro', 'grana', 'reais', 'lucro', 'vend', 'fatur', 'ganh', 'pagar', 'preco'], 'money-bag'],
|
|
46
|
+
[['tempo', 'minuto', 'segundo', 'rapid', 'hora'], 'stopwatch'],
|
|
47
|
+
[['cresc', 'result', 'aument', 'melhor'], 'chart-increasing'],
|
|
48
|
+
[['cair', 'caiu', 'perd', 'queda', 'pior'], 'chart-decreasing'],
|
|
49
|
+
[['dados', 'numero', 'grafico', 'estatist', 'parametro'], 'bar-chart'],
|
|
50
|
+
[['foguete', 'lanc', 'decol', 'comec'], 'rocket'],
|
|
51
|
+
[['incrivel', 'fogo', 'bomba'], 'fire'],
|
|
52
|
+
[['certo', 'funcion', 'corret', 'pronto'], 'check-mark-button'],
|
|
53
|
+
[['errado', 'erro', 'falha', 'proibid'], 'cross-mark'],
|
|
54
|
+
[['celular', 'telefone', 'aplicativo', 'app'], 'mobile-phone'],
|
|
55
|
+
[['computador', 'codigo', 'program', 'software', 'site'], 'laptop'],
|
|
56
|
+
[['internet', 'mundo', 'global'], 'globe-showing-americas'],
|
|
57
|
+
[['olh', 'ver', 'imagem', 'imagens', 'visual', 'reconhec'], 'eyes'],
|
|
58
|
+
[['voz', 'som', 'sons', 'audio', 'ouv'], 'speaker-high-volume'],
|
|
59
|
+
[['texto', 'escrev', 'palavra', 'lista'], 'memo'],
|
|
60
|
+
[['musica', 'cancao'], 'musical-notes'],
|
|
61
|
+
[['cachorro', 'cao'], 'dog-face'],
|
|
62
|
+
[['gato'], 'cat-face'],
|
|
63
|
+
[['coracao', 'amor', 'ama', 'paixao'], 'red-heart'],
|
|
64
|
+
[['pessoa', 'cliente', 'usuario', 'equipe', 'publico'], 'busts-in-silhouette'],
|
|
65
|
+
[['casa', 'lar'], 'house'],
|
|
66
|
+
[['carro', 'dirig'], 'automobile'],
|
|
67
|
+
[['livro', 'estud', 'escola', 'ensin', 'aula', 'curso'], 'books'],
|
|
68
|
+
[['atencao', 'cuidado', 'perigo', 'risco'], 'warning'],
|
|
69
|
+
[['objeto', 'produto', 'pacote', 'entrega'], 'package'],
|
|
70
|
+
[['padr', 'peca', 'encaix'], 'puzzle-piece'],
|
|
71
|
+
[['decid', 'decis', 'escolh', 'duvida', 'pergunta'], 'thinking-face'],
|
|
72
|
+
[['exemplo', 'procur', 'busca', 'pesquis', 'analis'], 'magnifying-glass-tilted-left'],
|
|
73
|
+
[['assistente', 'convers', 'chat', 'mensag', 'responde'], 'speech-balloon'],
|
|
74
|
+
[['recomend', 'filme', 'serie', 'netflix', 'video'], 'clapper-board'],
|
|
75
|
+
[['tecnolog', 'algoritm', 'sistema', 'ferrament'], 'gear'],
|
|
76
|
+
[['cotidian', 'rotina', 'semana', 'calendario', 'agenda'], 'calendar'],
|
|
77
|
+
[['conhecimento', 'formad', 'diploma', 'sabe'], 'graduation-cap'],
|
|
78
|
+
[['sucesso', 'venc', 'ganhador', 'premio', 'campeao'], 'trophy'],
|
|
79
|
+
[['forca', 'forte', 'poder', 'energia'], 'flexed-biceps'],
|
|
80
|
+
[['acordo', 'parceri', 'junt', 'ajud'], 'handshake'],
|
|
81
|
+
[['compra', 'loja', 'carrinho'], 'shopping-cart'],
|
|
82
|
+
[['trabalh', 'empresa', 'negocio', 'emprego'], 'briefcase'],
|
|
83
|
+
[['seguranc', 'senha', 'proteg', 'privad'], 'locked'],
|
|
84
|
+
[['festa', 'comemor', 'parabens'], 'party-popper'],
|
|
85
|
+
[['surpre', 'chocad', 'absurd'], 'exploding-head'],
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
function limpa(palavra) {
|
|
89
|
+
return String(palavra || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
90
|
+
.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** O emoji desta palavra, ou null. Palavra de 2 letras só casa inteira ("ia"). */
|
|
94
|
+
function iconeDe(palavra) {
|
|
95
|
+
const w = limpa(palavra);
|
|
96
|
+
if (!w || VAZIAS.has(w)) return null;
|
|
97
|
+
for (const [raizes, nome] of ICONES) {
|
|
98
|
+
for (const r of raizes) {
|
|
99
|
+
if (r.length <= 3 ? w === r : w.startsWith(r)) return 'fluent-emoji-flat:' + nome;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Quanto a palavra carrega a ideia da frase. 0 = nada. */
|
|
106
|
+
function peso(palavra) {
|
|
107
|
+
const w = limpa(palavra);
|
|
108
|
+
if (!w || VAZIAS.has(w)) return 0;
|
|
109
|
+
if (/^\d+$/.test(w)) return 20;
|
|
110
|
+
return w.length + (iconeDe(palavra) ? 6 : 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const fimDeFrase = (texto) => /[.!?…]$/.test(String(texto || '').trim());
|
|
114
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
115
|
+
|
|
116
|
+
/* As frases: corta no ponto final, na pausa longa ou quando a cena passa do
|
|
117
|
+
teto. Frase curta demais gruda na próxima — corte a cada meio segundo é
|
|
118
|
+
epilepsia, não ritmo. */
|
|
119
|
+
function frases(palavras, { teto = 5.5, pausa = 0.5, minimo = 1.4 } = {}) {
|
|
120
|
+
const grupos = [];
|
|
121
|
+
let atual = [];
|
|
122
|
+
palavras.forEach((p, i) => {
|
|
123
|
+
atual.push(p);
|
|
124
|
+
const prox = palavras[i + 1];
|
|
125
|
+
const dur = p.ate - atual[0].de;
|
|
126
|
+
if (!prox || fimDeFrase(p.texto) || prox.de - p.ate >= pausa || dur >= teto) {
|
|
127
|
+
grupos.push(atual);
|
|
128
|
+
atual = [];
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
const juntos = [];
|
|
132
|
+
for (const g of grupos) {
|
|
133
|
+
const ult = juntos[juntos.length - 1];
|
|
134
|
+
const durG = g[g.length - 1].ate - g[0].de;
|
|
135
|
+
if (ult && (durG < minimo || ult[ult.length - 1].ate - ult[0].de < minimo)
|
|
136
|
+
&& g[g.length - 1].ate - ult[0].de <= teto + 1.5) ult.push(...g);
|
|
137
|
+
else juntos.push([...g]);
|
|
138
|
+
}
|
|
139
|
+
return juntos;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Os pedaços da legenda: poucas palavras por vez (quem assiste no celular lê
|
|
143
|
+
em blocos curtos), quebrando na pontuação e na pausa. */
|
|
144
|
+
function blocos(palavras, maximo) {
|
|
145
|
+
const out = [];
|
|
146
|
+
let atual = [];
|
|
147
|
+
palavras.forEach((p, i) => {
|
|
148
|
+
atual.push(p);
|
|
149
|
+
const prox = palavras[i + 1];
|
|
150
|
+
if (!prox || atual.length >= maximo || /[,.!?;:…]$/.test(p.texto) || prox.de - p.ate > 0.3) {
|
|
151
|
+
out.push(atual);
|
|
152
|
+
atual = [];
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// O zoom de cada cena, em rodízio: parado, punch-in, empurrando devagar.
|
|
159
|
+
function camera(i, dur) {
|
|
160
|
+
const tipo = i % 3;
|
|
161
|
+
if (tipo === 0) return { zoom: [{ t: 0, v: 1 }, { t: dur, v: 1.04, e: 'linear' }] };
|
|
162
|
+
if (tipo === 1) return { zoom: [{ t: 0, v: 1.18 }, { t: dur, v: 1.21, e: 'linear' }] };
|
|
163
|
+
return { zoom: [{ t: 0, v: 1.07 }, { t: dur, v: 1.13, e: 'suave' }] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const ESTILOS = {
|
|
167
|
+
viral: { icones: true, letreiro: true, sons: true, barra: true, destaque: '#FFD400', forte: '#00E0A4' },
|
|
168
|
+
limpo: { icones: false, letreiro: false, sons: false, barra: false, destaque: '#FFFFFF', forte: '#FFD400' },
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* O plano do vídeo editado.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} op
|
|
175
|
+
* @param {string} op.src o vídeo (já sem os silêncios), relativo à pasta do projeto
|
|
176
|
+
* @param {number} op.duracao a duração dele, em segundos
|
|
177
|
+
* @param {Array} op.palavras [{de, ate, texto}] no tempo DESTE vídeo
|
|
178
|
+
* @param {number} op.largura do vídeo de origem
|
|
179
|
+
* @param {number} op.altura
|
|
180
|
+
* @param {string} op.estilo 'viral' (padrão) | 'limpo'
|
|
181
|
+
* @param {object} op.sons { pop, whoosh } — caminhos na pasta do projeto, opcionais
|
|
182
|
+
* @param {Set} op.iconesOk os nomes de ícone que existem de verdade (sem isso, todos valem)
|
|
183
|
+
*/
|
|
184
|
+
function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null }) {
|
|
185
|
+
const e = ESTILOS[estilo] || ESTILOS.viral;
|
|
186
|
+
const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
|
|
187
|
+
if (!lista.length) return { ok: false, error: 'a gravação não tem fala transcrita para editar.' };
|
|
188
|
+
|
|
189
|
+
const deitado = largura > altura;
|
|
190
|
+
const base = Math.min(largura, altura);
|
|
191
|
+
const grupos = frases(lista);
|
|
192
|
+
const total = duracao || lista[lista.length - 1].ate + 0.3;
|
|
193
|
+
const cenas = [];
|
|
194
|
+
const efeitos = [];
|
|
195
|
+
let lado = 0;
|
|
196
|
+
|
|
197
|
+
grupos.forEach((g, i) => {
|
|
198
|
+
const ini = i === 0 ? 0 : grupos[i - 1][grupos[i - 1].length - 1].ate
|
|
199
|
+
+ (g[0].de - grupos[i - 1][grupos[i - 1].length - 1].ate) / 2;
|
|
200
|
+
const fim = i === grupos.length - 1 ? total : g[g.length - 1].ate + (grupos[i + 1][0].de - g[g.length - 1].ate) / 2;
|
|
201
|
+
const dur = r2(Math.max(0.4, fim - ini));
|
|
202
|
+
const local = (t) => r2(Math.max(0, t - ini));
|
|
203
|
+
|
|
204
|
+
// A palavra forte da frase: a que mais carrega a ideia.
|
|
205
|
+
let forte = null;
|
|
206
|
+
for (const p of g) if (peso(p.texto) > (forte ? peso(forte.texto) : 0)) forte = p;
|
|
207
|
+
|
|
208
|
+
const elementos = [{
|
|
209
|
+
tipo: 'video', src, x: 0, y: 0, largura: '100%', altura: '100%',
|
|
210
|
+
deOrigem: r2(ini), ateOrigem: r2(ini + dur), volume: 1,
|
|
211
|
+
}];
|
|
212
|
+
|
|
213
|
+
// O ícone salta na hora da palavra; sem ícone, o letreiro da ideia.
|
|
214
|
+
const icone = forte && e.icones ? iconeDe(forte.texto) : null;
|
|
215
|
+
const temIcone = icone && (!iconesOk || iconesOk.has(icone));
|
|
216
|
+
const x = lado++ % 2 === 0 ? '78%' : '22%';
|
|
217
|
+
if (temIcone) {
|
|
218
|
+
const de = local(forte.de - 0.05);
|
|
219
|
+
const vida = Math.min(1.8, dur - de);
|
|
220
|
+
if (vida > 0.5) {
|
|
221
|
+
elementos.push({
|
|
222
|
+
tipo: 'icone', nome: icone, tamanho: Math.round(base * (deitado ? 0.2 : 0.24)),
|
|
223
|
+
x, y: deitado ? '30%' : '26%', ancora: 'centro', de, ate: r2(de + vida),
|
|
224
|
+
sombra: undefined,
|
|
225
|
+
anima: {
|
|
226
|
+
escala: [{ t: 0, v: 0 }, { t: 0.2, v: 1.18, e: 'saida' }, { t: 0.34, v: 1, e: 'suave' }],
|
|
227
|
+
rotacao: [{ t: 0, v: -14 }, { t: 0.34, v: 0, e: 'saida' }, { t: vida, v: 4, e: 'suave' }],
|
|
228
|
+
y: [{ t: 0.34, v: 0 }, { t: vida, v: -14, e: 'suave' }],
|
|
229
|
+
opacidade: [{ t: vida - 0.2, v: 1 }, { t: vida, v: 0 }],
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.45 });
|
|
233
|
+
}
|
|
234
|
+
} else if (forte && e.letreiro && i % 2 === 0 && peso(forte.texto) >= 7) {
|
|
235
|
+
const de = local(forte.de);
|
|
236
|
+
const vida = Math.min(1.6, dur - de);
|
|
237
|
+
if (vida > 0.5) {
|
|
238
|
+
elementos.push({
|
|
239
|
+
tipo: 'texto', texto: limpaPontuacao(forte.texto).toUpperCase(), fonte: 'Anton',
|
|
240
|
+
tamanho: Math.round(base * (deitado ? 0.11 : 0.1)), cor: e.forte, peso: 400,
|
|
241
|
+
x, y: deitado ? '28%' : '24%', ancora: 'centro', de, ate: r2(de + vida),
|
|
242
|
+
anima: {
|
|
243
|
+
escala: [{ t: 0, v: 0.4 }, { t: 0.16, v: 1.12, e: 'saida' }, { t: 0.3, v: 1, e: 'suave' }],
|
|
244
|
+
opacidade: [{ t: 0, v: 0 }, { t: 0.1, v: 1 }, { t: vida - 0.18, v: 1 }, { t: vida, v: 0 }],
|
|
245
|
+
rotacao: [{ t: 0, v: -6 }, { t: 0.3, v: -3, e: 'saida' }],
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.35 });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A legenda palavra por palavra.
|
|
253
|
+
const fala = blocos(g, deitado ? 4 : 3).map((b, j, todos) => ({
|
|
254
|
+
de: local(b[0].de - 0.04),
|
|
255
|
+
ate: j < todos.length - 1 ? local(todos[j + 1][0].de - 0.04) : r2(dur),
|
|
256
|
+
texto: b.map((p) => p.texto).join(' '),
|
|
257
|
+
palavras: b.map((p) => ({
|
|
258
|
+
de: local(p.de), ate: local(p.ate), texto: limpaPontuacao(p.texto),
|
|
259
|
+
...(p === forte ? { forte: true } : {}),
|
|
260
|
+
})),
|
|
261
|
+
}));
|
|
262
|
+
elementos.push({
|
|
263
|
+
tipo: 'legenda', x: '50%', y: deitado ? '80%' : '70%', ancora: 'centro', fala,
|
|
264
|
+
estilo: {
|
|
265
|
+
fonte: 'Montserrat', peso: 900, tamanho: Math.round(base * (deitado ? 0.078 : 0.072)),
|
|
266
|
+
destaque: e.destaque, corForte: e.forte, caixa: 'uppercase',
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if (e.barra) {
|
|
271
|
+
elementos.push({
|
|
272
|
+
tipo: 'forma', forma: 'retangulo', x: 0, y: 0, altura: Math.max(6, Math.round(base * 0.008)),
|
|
273
|
+
largura: '100%', cor: e.destaque,
|
|
274
|
+
anima: { escalaX: [{ t: 0, v: r2(ini / total) }, { t: dur, v: r2((ini + dur) / total), e: 'linear' }] },
|
|
275
|
+
origem: 'left',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// O whoosh entra no punch-in: é o corte que ele anuncia.
|
|
280
|
+
if (e.sons && sons.whoosh && i > 0 && i % 3 === 1) efeitos.push({ t: r2(Math.max(0, ini - 0.12)), src: sons.whoosh, volume: 0.3 });
|
|
281
|
+
|
|
282
|
+
cenas.push({ duracao: dur, camera: camera(i, dur), elementos, transicao: { tipo: 'corte' } });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
ok: true,
|
|
287
|
+
plano: {
|
|
288
|
+
formato: { largura, altura, fps: 30 },
|
|
289
|
+
fundoGeral: '#000',
|
|
290
|
+
cenas,
|
|
291
|
+
...(efeitos.length ? { audio: { efeitos } } : {}),
|
|
292
|
+
},
|
|
293
|
+
resumo: {
|
|
294
|
+
cenas: cenas.length,
|
|
295
|
+
icones: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'icone').length, 0),
|
|
296
|
+
letreiros: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'texto').length, 0),
|
|
297
|
+
efeitos: efeitos.length,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function limpaPontuacao(t) {
|
|
303
|
+
return String(t || '').replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}%]+$/gu, '');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* Os dois sons, sintetizados pelo ffmpeg: nada baixado, nada com licença de
|
|
307
|
+
ninguém. O pop é um tom que cai rápido; o whoosh, um ruído que sobe e some. */
|
|
308
|
+
const SONS = {
|
|
309
|
+
pop: ['-f', 'lavfi', '-i', "aevalsrc='0.9*sin(2*PI*(1500-7000*t)*t)*exp(-38*t)':s=44100:d=0.14"],
|
|
310
|
+
whoosh: ['-f', 'lavfi', '-i', 'anoisesrc=d=0.55:c=pink:a=0.6',
|
|
311
|
+
'-af', 'highpass=f=500,lowpass=f=5000,afade=t=in:st=0:d=0.3:curve=exp,afade=t=out:st=0.3:d=0.25,volume=0.9'],
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
async function prepararSons(pasta, video) {
|
|
315
|
+
const fs = require('fs');
|
|
316
|
+
const path = require('path');
|
|
317
|
+
const sons = {};
|
|
318
|
+
for (const [nome, args] of Object.entries(SONS)) {
|
|
319
|
+
const rel = `midia/sfx-${nome}.mp3`;
|
|
320
|
+
const cheio = path.join(pasta, rel);
|
|
321
|
+
if (!fs.existsSync(cheio)) {
|
|
322
|
+
fs.mkdirSync(path.dirname(cheio), { recursive: true });
|
|
323
|
+
const r = await video.rodar(video.ffmpeg(), ['-y', ...args, '-q:a', '4', cheio], { timeout: 30000 });
|
|
324
|
+
if (!r.ok) continue;
|
|
325
|
+
}
|
|
326
|
+
sons[nome] = rel;
|
|
327
|
+
}
|
|
328
|
+
return sons;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = { montarEdicao, prepararSons, iconeDe, peso, frases, blocos, ESTILOS, ICONES };
|
package/lib/estudio/index.js
CHANGED
|
@@ -40,6 +40,7 @@ const assets = require('./assets.js');
|
|
|
40
40
|
const video = require('./video.js');
|
|
41
41
|
const identidade = require('./identidade.js');
|
|
42
42
|
const slides = require('./slides.js');
|
|
43
|
+
const edicao = require('./edicao.js');
|
|
43
44
|
|
|
44
45
|
const PY = path.join(__dirname, '..', '..', 'studio');
|
|
45
46
|
|
|
@@ -399,6 +400,52 @@ async function ferramentaVideo(args, ctx = {}) {
|
|
|
399
400
|
return transcrever(p, alvo, ctx);
|
|
400
401
|
}
|
|
401
402
|
|
|
403
|
+
/* A EDIÇÃO INTEIRA, em código (lib/estudio/edicao.js): corta o silêncio,
|
|
404
|
+
transcreve palavra por palavra e monta o plano — legenda animada,
|
|
405
|
+
ícones na hora da palavra, zoom de corte, pop e whoosh. É o caminho
|
|
406
|
+
para qualquer modelo entregar vídeo de videomaker: ele só chama isto e
|
|
407
|
+
renderiza. "Até uma batata faz vídeos assim." (25/09/2026) */
|
|
408
|
+
if (acao === 'editar' || acao === 'edicao' || acao === 'auto') {
|
|
409
|
+
let fonte = alvo;
|
|
410
|
+
let cortes = 0;
|
|
411
|
+
if (args.cortarSilencio !== false) {
|
|
412
|
+
const saida = path.join(p.pasta, 'midia', `cortado-${Date.now().toString(36)}.mp4`);
|
|
413
|
+
const c = await video.cortarSilencio(alvo, saida, args);
|
|
414
|
+
if (c.ok && !c.semCorte) { fonte = saida; cortes = c.cortes || 0; }
|
|
415
|
+
}
|
|
416
|
+
const m = await video.medir(fonte);
|
|
417
|
+
if (!m.ok) return m;
|
|
418
|
+
|
|
419
|
+
const tr = await transcrever(p, fonte, ctx);
|
|
420
|
+
if (!tr.ok) return tr;
|
|
421
|
+
let palavras = tr.palavras || [];
|
|
422
|
+
// Servidor sem o tempo por palavra: reparte cada trecho pelas palavras
|
|
423
|
+
// dele. Sai menos preciso, mas a legenda continua animada.
|
|
424
|
+
if (!palavras.length) palavras = repartirTrechos(tr.trechos || []);
|
|
425
|
+
|
|
426
|
+
const candidatos = [...new Set(palavras.map((w) => edicao.iconeDe(w.texto)).filter(Boolean))];
|
|
427
|
+
const iconesOk = new Set();
|
|
428
|
+
for (const nome of candidatos) {
|
|
429
|
+
try { const r = await assets.icone(nome); if (r && (r.ok !== false)) iconesOk.add(nome); } catch { /* fica sem */ }
|
|
430
|
+
}
|
|
431
|
+
const sons = await edicao.prepararSons(p.pasta, video);
|
|
432
|
+
|
|
433
|
+
const montado = edicao.montarEdicao({
|
|
434
|
+
src: path.relative(p.pasta, fonte).replace(/\\/g, '/'),
|
|
435
|
+
duracao: m.segundos, palavras, largura: m.largura, altura: m.altura,
|
|
436
|
+
estilo: args.estilo || 'viral', sons, iconesOk,
|
|
437
|
+
});
|
|
438
|
+
if (!montado.ok) return montado;
|
|
439
|
+
const salvo = await ferramentaPlano({ id: args.id, plano: montado.plano, motivo: `edição automática (${args.estilo || 'viral'})` });
|
|
440
|
+
if (!salvo.ok) return salvo;
|
|
441
|
+
projeto.anotar(p.id, 'edicao', `${montado.resumo.cenas} cenas, ${montado.resumo.icones} ícones, ${cortes} cortes de silêncio`);
|
|
442
|
+
return {
|
|
443
|
+
ok: true, ...montado.resumo, cortesDeSilencio: cortes, duracao: salvo.duracao,
|
|
444
|
+
proximo: 'estudio_render com este id. Antes, se quiser, ajuste uma cena com estudio_plano cena:N '
|
|
445
|
+
+ '(ex.: trocar um ícone, pôr uma imagem de apoio atrás).',
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
402
449
|
if (acao === 'recortar-pessoa' || acao === 'pessoa') {
|
|
403
450
|
const saida = path.join(p.pasta, 'midia', `pessoa-${Date.now().toString(36)}.webm`);
|
|
404
451
|
const r = await rodarPython('pessoa.py', [
|
|
@@ -418,6 +465,23 @@ async function ferramentaVideo(args, ctx = {}) {
|
|
|
418
465
|
return { ok: false, error: `não conheço a ação "${acao}".` };
|
|
419
466
|
}
|
|
420
467
|
|
|
468
|
+
/* Sem o tempo de cada palavra, cada trecho é repartido pelas palavras dele,
|
|
469
|
+
proporcional ao tamanho de cada uma. */
|
|
470
|
+
function repartirTrechos(trechos) {
|
|
471
|
+
const out = [];
|
|
472
|
+
for (const t of trechos) {
|
|
473
|
+
const ws = String(t.texto || '').split(/\s+/).filter(Boolean);
|
|
474
|
+
const letras = ws.reduce((n, w) => n + w.length, 0) || 1;
|
|
475
|
+
let cur = t.de;
|
|
476
|
+
for (const w of ws) {
|
|
477
|
+
const d = (t.ate - t.de) * (w.length / letras);
|
|
478
|
+
out.push({ de: cur, ate: cur + d, texto: w });
|
|
479
|
+
cur += d;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return out;
|
|
483
|
+
}
|
|
484
|
+
|
|
421
485
|
/**
|
|
422
486
|
* A transcrição com tempo — é ela que dá legenda sincronizada.
|
|
423
487
|
*
|
|
@@ -448,7 +512,7 @@ async function transcrever(p, alvo, ctx) {
|
|
|
448
512
|
if (!r || r.ok === false) return { ok: false, error: (r && r.erro) || 'a transcrição falhou.' };
|
|
449
513
|
projeto.anotar(p.id, 'transcricao', `${(r.trechos || []).length} trechos`);
|
|
450
514
|
return {
|
|
451
|
-
ok: true, texto: r.texto, trechos: r.trechos,
|
|
515
|
+
ok: true, texto: r.texto, trechos: r.trechos, palavras: r.palavras || [],
|
|
452
516
|
comoUsar: 'cada trecho vira uma fala da legenda: {"de":…, "ate":…, "texto":…}. '
|
|
453
517
|
+ 'O estilo da legenda (fonte, cor, peso, posição, animação) é escolha sua — não há padrão.',
|
|
454
518
|
};
|
package/lib/estudio/remotion.js
CHANGED
|
@@ -212,6 +212,7 @@ function Elemento({ el, t, dados }) {
|
|
|
212
212
|
transform: [base.transform, move].filter(Boolean).join(' '),
|
|
213
213
|
filter: filtroDe(a) || undefined,
|
|
214
214
|
mixBlendMode: el.mistura || undefined,
|
|
215
|
+
transformOrigin: el.origem || undefined,
|
|
215
216
|
};
|
|
216
217
|
|
|
217
218
|
if (el.vidro) {
|
|
@@ -294,7 +295,7 @@ function Elemento({ el, t, dados }) {
|
|
|
294
295
|
// O SVG foi baixado para a pasta do projeto (estudio/assets.js): render
|
|
295
296
|
// que busca ícone na internet falha no meio, depois de tudo pronto.
|
|
296
297
|
return (
|
|
297
|
-
<div style={{ ...estilo, width: medida(el.tamanho, '96px'), height: medida(el.tamanho, '96px'), color: el.cor || '#fff' }}
|
|
298
|
+
<div style={{ ...estilo, width: medida(el.tamanho, '96px'), height: medida(el.tamanho, '96px'), fontSize: medida(el.tamanho, '96px'), lineHeight: 0, color: el.cor || '#fff' }}
|
|
298
299
|
dangerouslySetInnerHTML={{ __html: dados.icones[el.nome] || '' }} />
|
|
299
300
|
);
|
|
300
301
|
|
|
@@ -302,6 +303,55 @@ function Elemento({ el, t, dados }) {
|
|
|
302
303
|
const fala = (el.fala || []).find((f) => t >= f.de && t <= f.ate);
|
|
303
304
|
if (!fala) return null;
|
|
304
305
|
const e = el.estilo || {};
|
|
306
|
+
/* LEGENDA DE VIDEOMAKER: com o tempo de cada palavra, elas entram uma a
|
|
307
|
+
uma, no instante em que são ditas, com um salto curto. A que está
|
|
308
|
+
sendo dita ganha a cor de destaque; a palavra forte (a ideia da
|
|
309
|
+
frase) ganha a cor dela e fica maior. Sem "palavras", a frase entra
|
|
310
|
+
inteira, como antes. */
|
|
311
|
+
if (Array.isArray(fala.palavras) && fala.palavras.length) {
|
|
312
|
+
const destaque = e.destaque || '#FFD400';
|
|
313
|
+
const forte = e.corForte || '#00E0A4';
|
|
314
|
+
const soUma = e.modo === 'palavra';
|
|
315
|
+
const visiveis = soUma
|
|
316
|
+
? fala.palavras.filter((p, i, l) => t >= p.de && (i === l.length - 1 || t < l[i + 1].de)).slice(-1)
|
|
317
|
+
: fala.palavras.filter((p) => t >= p.de - 0.02);
|
|
318
|
+
return (
|
|
319
|
+
<div style={{
|
|
320
|
+
...estilo,
|
|
321
|
+
display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'baseline',
|
|
322
|
+
gap: '0 0.34em', maxWidth: medida(e.largura, '86%'),
|
|
323
|
+
fontFamily: e.fonte ? '"' + e.fonte + '", system-ui, sans-serif' : 'system-ui, sans-serif',
|
|
324
|
+
fontSize: medida(e.tamanho, '64px'),
|
|
325
|
+
fontWeight: e.peso || 900,
|
|
326
|
+
textTransform: e.caixa || undefined,
|
|
327
|
+
lineHeight: 1.1,
|
|
328
|
+
}}>
|
|
329
|
+
{visiveis.map((p, i) => {
|
|
330
|
+
const idade = t - p.de;
|
|
331
|
+
const entra = Math.min(1, Math.max(0, idade / 0.14));
|
|
332
|
+
const salto = entra < 1 ? 0.7 + 0.42 * CURVAS.saida(entra) : 1.12 - 0.12 * Math.min(1, (idade - 0.14) / 0.12);
|
|
333
|
+
const falando = t >= p.de && t <= p.ate + 0.05;
|
|
334
|
+
const cor = p.forte ? forte : (falando ? destaque : (e.cor || '#fff'));
|
|
335
|
+
return (
|
|
336
|
+
<span key={i} style={{
|
|
337
|
+
display: 'inline-block',
|
|
338
|
+
// A palavra forte é MAIOR na fonte, não na escala: escala não
|
|
339
|
+
// abre espaço, e ela cresceria por cima da vizinha.
|
|
340
|
+
fontSize: p.forte ? '1.22em' : undefined,
|
|
341
|
+
transform: 'scale(' + salto + ') translateY(' + (1 - entra) * 18 + 'px)',
|
|
342
|
+
opacity: entra,
|
|
343
|
+
color: cor,
|
|
344
|
+
textShadow: e.sombra || '0 6px 22px rgba(0,0,0,.6), 0 2px 0 rgba(0,0,0,.35)',
|
|
345
|
+
WebkitTextStroke: e.contorno || '2px rgba(0,0,0,.55)',
|
|
346
|
+
paintOrder: 'stroke fill',
|
|
347
|
+
background: falando && e.fundoAtivo ? e.fundoAtivo : undefined,
|
|
348
|
+
borderRadius: '0.18em', padding: falando && e.fundoAtivo ? '0 0.12em' : undefined,
|
|
349
|
+
}}>{p.texto}{p.emoji ? ' ' + p.emoji : ''}</span>
|
|
350
|
+
);
|
|
351
|
+
})}
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
305
355
|
return (
|
|
306
356
|
<div style={{
|
|
307
357
|
...estilo,
|
|
@@ -366,7 +416,7 @@ export function Cena({ cena, dados }) {
|
|
|
366
416
|
</AbsoluteFill>
|
|
367
417
|
);
|
|
368
418
|
}
|
|
369
|
-
|
|
419
|
+
`;
|
|
370
420
|
|
|
371
421
|
const RAIZ_JSX = String.raw`import React from 'react';
|
|
372
422
|
import { AbsoluteFill, Audio, Sequence, staticFile, useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
|
|
@@ -438,7 +488,7 @@ export const Video = () => {
|
|
|
438
488
|
</AbsoluteFill>
|
|
439
489
|
);
|
|
440
490
|
};
|
|
441
|
-
|
|
491
|
+
`;
|
|
442
492
|
|
|
443
493
|
function rootJsx(plano) {
|
|
444
494
|
const f = plano.formato || {};
|
|
@@ -513,12 +563,25 @@ para este projeto, não para o PrimoCode.
|
|
|
513
563
|
* @param {object} op.plano
|
|
514
564
|
* @param {object} op.icones { "lucide:zap": "<svg…>" } já baixados
|
|
515
565
|
*/
|
|
566
|
+
const TSCONFIG = JSON.stringify({
|
|
567
|
+
compilerOptions: {
|
|
568
|
+
target: 'ES2018', module: 'commonjs', jsx: 'react-jsx', strict: false, noEmit: true,
|
|
569
|
+
lib: ['es2015', 'dom'], esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true,
|
|
570
|
+
},
|
|
571
|
+
exclude: ['remotion.config.ts'],
|
|
572
|
+
}, null, 2) + '\n';
|
|
573
|
+
|
|
516
574
|
function montar({ pasta, titulo, plano, icones = {} }) {
|
|
517
575
|
const raiz = path.join(pasta, 'remotion');
|
|
518
576
|
const src = path.join(raiz, 'src');
|
|
519
577
|
|
|
520
578
|
escrever(path.join(raiz, 'package.json'), pkgJson(titulo || 'video'));
|
|
521
579
|
escrever(path.join(raiz, 'remotion.config.ts'), REMOTION_CONFIG);
|
|
580
|
+
/* O remotion.config.ts é TypeScript, e o Remotion 4 recusa renderizar sem
|
|
581
|
+
um tsconfig.json na raiz ("Could not find a tsconfig.json"). Medido em
|
|
582
|
+
25/09: o vídeo que saiu bom só saiu porque o modelo criou este arquivo à
|
|
583
|
+
mão depois do erro — um modelo grátis travaria ali. */
|
|
584
|
+
escrever(path.join(raiz, 'tsconfig.json'), TSCONFIG);
|
|
522
585
|
escrever(path.join(raiz, 'README.md'), readme(titulo || 'Vídeo', plano));
|
|
523
586
|
escrever(path.join(src, 'index.js'), INDEX_JS);
|
|
524
587
|
escrever(path.join(src, 'Root.jsx'), rootJsx(plano));
|