primocode 9.8.0-beta.11 → 9.8.0-beta.13
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/act.js +12 -8
- package/lib/catalogo.js +5 -1
- package/lib/claude-engine.js +25 -3
- package/lib/estudio/composicao.js +404 -0
- package/lib/estudio/edicao.js +87 -17
- package/lib/estudio/index.js +107 -2
- package/lib/estudio/plano.js +4 -1
- package/lib/estudio/remotion.js +78 -14
- package/lib/estudio/slides.js +34 -30
- package/lib/local/cerebro.json +28 -4
- package/lib/local/motor-cli.js +1 -1
- package/package.json +2 -2
- package/studio/narrar.py +111 -10
package/lib/estudio/edicao.js
CHANGED
|
@@ -42,7 +42,7 @@ const ICONES = [
|
|
|
42
42
|
[['inteligencia', 'ia', 'robo', 'maquina', 'automac'], 'robot'],
|
|
43
43
|
[['cerebro', 'pens', 'raciocin', 'aprend', 'mente'], 'brain'],
|
|
44
44
|
[['ideia', 'criativ', 'inova'], 'light-bulb'],
|
|
45
|
-
[['dinheiro', 'grana', 'reais', 'lucro', 'vend', 'fatur', 'ganh', 'pagar', 'preco'], 'money-bag'],
|
|
45
|
+
[['dinheiro', 'grana', 'reais', 'lucro', 'vend', 'fatur', 'ganh', 'pagar', 'preco', 'financ', 'custo', 'econom', 'receita'], 'money-bag'],
|
|
46
46
|
[['tempo', 'minuto', 'segundo', 'rapid', 'hora'], 'stopwatch'],
|
|
47
47
|
[['cresc', 'result', 'aument', 'melhor'], 'chart-increasing'],
|
|
48
48
|
[['cair', 'caiu', 'perd', 'queda', 'pior'], 'chart-decreasing'],
|
|
@@ -70,9 +70,9 @@ const ICONES = [
|
|
|
70
70
|
[['padr', 'peca', 'encaix'], 'puzzle-piece'],
|
|
71
71
|
[['decid', 'decis', 'escolh', 'duvida', 'pergunta'], 'thinking-face'],
|
|
72
72
|
[['exemplo', 'procur', 'busca', 'pesquis', 'analis'], 'magnifying-glass-tilted-left'],
|
|
73
|
-
[['assistente', 'convers', 'chat', 'mensag', 'responde'], 'speech-balloon'],
|
|
73
|
+
[['assistente', 'convers', 'chat', 'mensag', 'responde', 'atend', 'suporte', 'whatsapp'], 'speech-balloon'],
|
|
74
74
|
[['recomend', 'filme', 'serie', 'netflix', 'video'], 'clapper-board'],
|
|
75
|
-
[['tecnolog', 'algoritm', 'sistema', 'ferrament'], 'gear'],
|
|
75
|
+
[['tecnolog', 'algoritm', 'sistema', 'ferrament', 'operac', 'process', 'automat'], 'gear'],
|
|
76
76
|
[['cotidian', 'rotina', 'semana', 'calendario', 'agenda'], 'calendar'],
|
|
77
77
|
[['conhecimento', 'formad', 'diploma', 'sabe'], 'graduation-cap'],
|
|
78
78
|
[['sucesso', 'venc', 'ganhador', 'premio', 'campeao'], 'trophy'],
|
|
@@ -85,6 +85,23 @@ const ICONES = [
|
|
|
85
85
|
[['surpre', 'chocad', 'absurd'], 'exploding-head'],
|
|
86
86
|
];
|
|
87
87
|
|
|
88
|
+
/* O termo de busca da IMAGEM DE APOIO, pelo emoji que a palavra ganhou. Em
|
|
89
|
+
inglês de propósito: os acervos abertos respondem muito melhor assim. */
|
|
90
|
+
const FOTO = {
|
|
91
|
+
robot: 'humanoid robot', brain: 'human brain', 'light-bulb': 'light bulb idea', 'money-bag': 'money cash',
|
|
92
|
+
stopwatch: 'clock time', 'chart-increasing': 'business growth', 'chart-decreasing': 'stock market fall',
|
|
93
|
+
'bar-chart': 'data analytics', rocket: 'rocket launch', fire: 'fire flames', 'mobile-phone': 'smartphone hand',
|
|
94
|
+
laptop: 'laptop programming', 'globe-showing-americas': 'earth from space', eyes: 'eye closeup',
|
|
95
|
+
'speaker-high-volume': 'sound waves', memo: 'writing notebook', 'musical-notes': 'music concert',
|
|
96
|
+
'dog-face': 'puppy', 'cat-face': 'cat', 'red-heart': 'heart love', 'busts-in-silhouette': 'people crowd',
|
|
97
|
+
house: 'modern house', automobile: 'car road', books: 'library books', warning: 'warning sign',
|
|
98
|
+
package: 'objects on table', 'puzzle-piece': 'puzzle pieces', 'thinking-face': 'person thinking',
|
|
99
|
+
'magnifying-glass-tilted-left': 'magnifying glass', 'speech-balloon': 'smart speaker', 'clapper-board': 'watching tv',
|
|
100
|
+
gear: 'circuit board', calendar: 'city street people', 'graduation-cap': 'students classroom', trophy: 'trophy',
|
|
101
|
+
'flexed-biceps': 'strength', handshake: 'handshake', 'shopping-cart': 'shopping', briefcase: 'office work',
|
|
102
|
+
locked: 'cyber security', 'party-popper': 'party celebration', 'exploding-head': 'surprised face',
|
|
103
|
+
};
|
|
104
|
+
|
|
88
105
|
function limpa(palavra) {
|
|
89
106
|
return String(palavra || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
90
107
|
.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
@@ -110,6 +127,33 @@ function peso(palavra) {
|
|
|
110
127
|
return w.length + (iconeDe(palavra) ? 6 : 0);
|
|
111
128
|
}
|
|
112
129
|
|
|
130
|
+
/** O termo de busca da imagem de apoio para esta palavra, ou null. */
|
|
131
|
+
function termoDeApoio(palavra) {
|
|
132
|
+
const ic = iconeDe(palavra);
|
|
133
|
+
return ic ? FOTO[ic.split(':')[1]] || null : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Em quais cenas entra imagem de apoio: uma a cada três, a partir da segunda.
|
|
137
|
+
const temApoio = (i) => i % 3 === 2;
|
|
138
|
+
|
|
139
|
+
function forteDe(g) {
|
|
140
|
+
let forte = null;
|
|
141
|
+
for (const p of g) if (peso(p.texto) > (forte ? peso(forte.texto) : 0)) forte = p;
|
|
142
|
+
return forte;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Os termos das imagens de apoio que o plano vai pedir — para baixar antes. */
|
|
146
|
+
function termosDeApoio(palavras) {
|
|
147
|
+
const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
|
|
148
|
+
const termos = [];
|
|
149
|
+
frases(lista).forEach((g, i) => {
|
|
150
|
+
const f = forteDe(g);
|
|
151
|
+
const t = f && temApoio(i) ? termoDeApoio(f.texto) : null;
|
|
152
|
+
if (t && !termos.includes(t)) termos.push(t);
|
|
153
|
+
});
|
|
154
|
+
return termos;
|
|
155
|
+
}
|
|
156
|
+
|
|
113
157
|
const fimDeFrase = (texto) => /[.!?…]$/.test(String(texto || '').trim());
|
|
114
158
|
const r2 = (n) => Math.round(n * 100) / 100;
|
|
115
159
|
|
|
@@ -164,8 +208,8 @@ function camera(i, dur) {
|
|
|
164
208
|
}
|
|
165
209
|
|
|
166
210
|
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' },
|
|
211
|
+
viral: { icones: true, letreiro: true, sons: true, barra: true, apoio: true, destaque: '#FFD400', forte: '#00E0A4' },
|
|
212
|
+
limpo: { icones: false, letreiro: false, sons: false, barra: false, apoio: true, destaque: '#FFFFFF', forte: '#FFD400' },
|
|
169
213
|
};
|
|
170
214
|
|
|
171
215
|
/**
|
|
@@ -181,7 +225,7 @@ const ESTILOS = {
|
|
|
181
225
|
* @param {object} op.sons { pop, whoosh } — caminhos na pasta do projeto, opcionais
|
|
182
226
|
* @param {Set} op.iconesOk os nomes de ícone que existem de verdade (sem isso, todos valem)
|
|
183
227
|
*/
|
|
184
|
-
function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null }) {
|
|
228
|
+
function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, estilo = 'viral', sons = {}, iconesOk = null, imagens = {}, trilha = null }) {
|
|
185
229
|
const e = ESTILOS[estilo] || ESTILOS.viral;
|
|
186
230
|
const lista = (palavras || []).filter((p) => p && p.texto && p.ate > p.de).sort((a, b) => a.de - b.de);
|
|
187
231
|
if (!lista.length) return { ok: false, error: 'a gravação não tem fala transcrita para editar.' };
|
|
@@ -202,16 +246,35 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
|
|
|
202
246
|
const local = (t) => r2(Math.max(0, t - ini));
|
|
203
247
|
|
|
204
248
|
// A palavra forte da frase: a que mais carrega a ideia.
|
|
205
|
-
|
|
206
|
-
for (const p of g) if (peso(p.texto) > (forte ? peso(forte.texto) : 0)) forte = p;
|
|
249
|
+
const forte = forteDe(g);
|
|
207
250
|
|
|
208
251
|
const elementos = [{
|
|
209
252
|
tipo: 'video', src, x: 0, y: 0, largura: '100%', altura: '100%',
|
|
210
253
|
deOrigem: r2(ini), ateOrigem: r2(ini + dur), volume: 1,
|
|
211
254
|
}];
|
|
212
255
|
|
|
256
|
+
/* A IMAGEM DE APOIO: na hora da palavra forte, a tela vira a imagem do
|
|
257
|
+
que está sendo dito, com um zoom lento, e a legenda continua por
|
|
258
|
+
cima. É o "b-roll" das edições de videomaker. */
|
|
259
|
+
const apoio = forte && e.apoio && temApoio(i) ? imagens[termoDeApoio(forte.texto)] : null;
|
|
260
|
+
if (apoio) {
|
|
261
|
+
const de = local(forte.de - 0.1);
|
|
262
|
+
const vida = Math.min(2.2, dur - de);
|
|
263
|
+
if (vida > 0.8) {
|
|
264
|
+
elementos.push({
|
|
265
|
+
tipo: 'imagem', src: apoio, x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
|
|
266
|
+
de, ate: r2(de + vida),
|
|
267
|
+
anima: {
|
|
268
|
+
escala: [{ t: 0, v: 1.12 }, { t: vida, v: 1.0, e: 'suave' }],
|
|
269
|
+
opacidade: [{ t: 0, v: 0 }, { t: 0.12, v: 1 }, { t: vida - 0.15, v: 1 }, { t: vida, v: 0 }],
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
if (e.sons && sons.whoosh) efeitos.push({ t: r2(Math.max(0, ini + de - 0.1)), src: sons.whoosh, volume: 0.55 });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
213
276
|
// O ícone salta na hora da palavra; sem ícone, o letreiro da ideia.
|
|
214
|
-
const icone = forte && e.icones ? iconeDe(forte.texto) : null;
|
|
277
|
+
const icone = forte && e.icones && !apoio ? iconeDe(forte.texto) : null;
|
|
215
278
|
const temIcone = icone && (!iconesOk || iconesOk.has(icone));
|
|
216
279
|
const x = lado++ % 2 === 0 ? '78%' : '22%';
|
|
217
280
|
if (temIcone) {
|
|
@@ -229,7 +292,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
|
|
|
229
292
|
opacidade: [{ t: vida - 0.2, v: 1 }, { t: vida, v: 0 }],
|
|
230
293
|
},
|
|
231
294
|
});
|
|
232
|
-
if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.
|
|
295
|
+
if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.8 });
|
|
233
296
|
}
|
|
234
297
|
} else if (forte && e.letreiro && i % 2 === 0 && peso(forte.texto) >= 7) {
|
|
235
298
|
const de = local(forte.de);
|
|
@@ -245,12 +308,12 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
|
|
|
245
308
|
rotacao: [{ t: 0, v: -6 }, { t: 0.3, v: -3, e: 'saida' }],
|
|
246
309
|
},
|
|
247
310
|
});
|
|
248
|
-
if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.
|
|
311
|
+
if (e.sons && sons.pop) efeitos.push({ t: r2(ini + de), src: sons.pop, volume: 0.7 });
|
|
249
312
|
}
|
|
250
313
|
}
|
|
251
314
|
|
|
252
315
|
// A legenda palavra por palavra.
|
|
253
|
-
const fala = blocos(g,
|
|
316
|
+
const fala = blocos(g, 3).map((b, j, todos) => ({
|
|
254
317
|
de: local(b[0].de - 0.04),
|
|
255
318
|
ate: j < todos.length - 1 ? local(todos[j + 1][0].de - 0.04) : r2(dur),
|
|
256
319
|
texto: b.map((p) => p.texto).join(' '),
|
|
@@ -260,10 +323,10 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
|
|
|
260
323
|
})),
|
|
261
324
|
}));
|
|
262
325
|
elementos.push({
|
|
263
|
-
tipo: 'legenda', x: '50%', y: deitado ? '
|
|
326
|
+
tipo: 'legenda', x: '50%', y: deitado ? '76%' : '68%', ancora: 'centro', fala,
|
|
264
327
|
estilo: {
|
|
265
328
|
fonte: 'Montserrat', peso: 900, tamanho: Math.round(base * (deitado ? 0.078 : 0.072)),
|
|
266
|
-
destaque: e.destaque, corForte: e.forte, caixa: 'uppercase',
|
|
329
|
+
destaque: e.destaque, corForte: e.forte, caixa: 'uppercase', largura: '92%',
|
|
267
330
|
},
|
|
268
331
|
});
|
|
269
332
|
|
|
@@ -277,7 +340,7 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
|
|
|
277
340
|
}
|
|
278
341
|
|
|
279
342
|
// 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.
|
|
343
|
+
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.6 });
|
|
281
344
|
|
|
282
345
|
cenas.push({ duracao: dur, camera: camera(i, dur), elementos, transicao: { tipo: 'corte' } });
|
|
283
346
|
});
|
|
@@ -288,11 +351,18 @@ function montarEdicao({ src, duracao, palavras, largura = 1080, altura = 1920, e
|
|
|
288
351
|
formato: { largura, altura, fps: 30 },
|
|
289
352
|
fundoGeral: '#000',
|
|
290
353
|
cenas,
|
|
291
|
-
...(efeitos.length ? { audio: {
|
|
354
|
+
...(efeitos.length || trilha ? { audio: {
|
|
355
|
+
...(efeitos.length ? { efeitos } : {}),
|
|
356
|
+
// A música fica BAIXA: quem fala para a câmera fala o vídeo
|
|
357
|
+
// inteiro, e a trilha é cama, não disputa.
|
|
358
|
+
...(trilha ? { trilha: { src: trilha, volume: 0.07, loop: true } } : {}),
|
|
359
|
+
} } : {}),
|
|
292
360
|
},
|
|
293
361
|
resumo: {
|
|
294
362
|
cenas: cenas.length,
|
|
295
363
|
icones: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'icone').length, 0),
|
|
364
|
+
imagens: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'imagem').length, 0),
|
|
365
|
+
musica: Boolean(trilha),
|
|
296
366
|
letreiros: cenas.reduce((n, c) => n + c.elementos.filter((x) => x.tipo === 'texto').length, 0),
|
|
297
367
|
efeitos: efeitos.length,
|
|
298
368
|
},
|
|
@@ -328,4 +398,4 @@ async function prepararSons(pasta, video) {
|
|
|
328
398
|
return sons;
|
|
329
399
|
}
|
|
330
400
|
|
|
331
|
-
module.exports = { montarEdicao, prepararSons, iconeDe, peso, frases, blocos, ESTILOS, ICONES };
|
|
401
|
+
module.exports = { montarEdicao, prepararSons, iconeDe, peso, frases, blocos, termosDeApoio, termoDeApoio, ESTILOS, ICONES, FOTO };
|
package/lib/estudio/index.js
CHANGED
|
@@ -41,6 +41,7 @@ const video = require('./video.js');
|
|
|
41
41
|
const identidade = require('./identidade.js');
|
|
42
42
|
const slides = require('./slides.js');
|
|
43
43
|
const edicao = require('./edicao.js');
|
|
44
|
+
const composicao = require('./composicao.js');
|
|
44
45
|
|
|
45
46
|
const PY = path.join(__dirname, '..', '..', 'studio');
|
|
46
47
|
|
|
@@ -172,12 +173,31 @@ async function ferramentaPlano(args) {
|
|
|
172
173
|
|
|
173
174
|
if (args.voltarPara) return projeto.voltarPara(args.id, args.voltarPara);
|
|
174
175
|
|
|
175
|
-
if (!args.plano && !args.cenas) {
|
|
176
|
+
if (!args.plano && !args.cenas && !args.roteiro) {
|
|
176
177
|
const atual = projeto.lerPlano(args.id);
|
|
177
178
|
if (!atual) return { ok: false, error: 'este projeto ainda não tem plano — mande um.' };
|
|
178
179
|
return { ok: true, plano: atual, resumo: plano.resumo(atual) };
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
/* O ROTEIRO: o modelo diz o que cada parte é e o que ela diz, e o código
|
|
183
|
+
faz o resto — layout, voz com entonação, ícones, imagens, música e
|
|
184
|
+
sons (lib/estudio/composicao.js). É o caminho para qualquer modelo
|
|
185
|
+
entregar motion e slides que prestam. */
|
|
186
|
+
if (args.roteiro) {
|
|
187
|
+
const r = await prepararRoteiro(p, args);
|
|
188
|
+
if (!r.ok) return r;
|
|
189
|
+
const conferida = plano.conferir(r.plano, { pasta: p.pasta });
|
|
190
|
+
if (!conferida.ok) {
|
|
191
|
+
return { ok: false, error: 'o roteiro virou um plano com problemas (defeito do PrimoCode, não seu).', problemas: conferida.problemas.slice(0, 12) };
|
|
192
|
+
}
|
|
193
|
+
projeto.salvarPlano(args.id, r.plano, args.motivo || 'montado do roteiro');
|
|
194
|
+
projeto.salvar(args.id, { duracao: conferida.duracao });
|
|
195
|
+
return {
|
|
196
|
+
ok: true, duracao: conferida.duracao, ...r.resumo, resumo: plano.resumo(r.plano),
|
|
197
|
+
proximo: 'estudio_render com este id. Para ajustar, mande o roteiro de novo (é rápido) — não edite as cenas à mão.',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
181
201
|
// Um plano inteiro, ou só as cenas (que é o caso comum).
|
|
182
202
|
let novo = args.plano || { formato: args.formato, cenas: args.cenas, audio: args.audio };
|
|
183
203
|
|
|
@@ -413,6 +433,15 @@ async function ferramentaVideo(args, ctx = {}) {
|
|
|
413
433
|
const c = await video.cortarSilencio(alvo, saida, args);
|
|
414
434
|
if (c.ok && !c.semCorte) { fonte = saida; cortes = c.cortes || 0; }
|
|
415
435
|
}
|
|
436
|
+
/* A VOZ NO VOLUME CERTO. A gravação de celular sai baixa (medido em
|
|
437
|
+
25/09: -37,6 dB de média no vídeo pronto, e "está sem som"). O
|
|
438
|
+
loudnorm leva a fala a -15 LUFS, o volume de vídeo de internet; o
|
|
439
|
+
vídeo em si não é recodificado. */
|
|
440
|
+
const normal = path.join(p.pasta, 'midia', `voz-normal-${Date.now().toString(36)}.mp4`);
|
|
441
|
+
const ln = await video.rodar(video.ffmpeg(), ['-y', '-i', fonte, '-c:v', 'copy',
|
|
442
|
+
'-af', 'loudnorm=I=-15:TP=-1.5:LRA=11', '-c:a', 'aac', '-b:a', '192k', normal], { timeout: 10 * 60000 });
|
|
443
|
+
if (ln.ok) fonte = normal;
|
|
444
|
+
|
|
416
445
|
const m = await video.medir(fonte);
|
|
417
446
|
if (!m.ok) return m;
|
|
418
447
|
|
|
@@ -430,10 +459,36 @@ async function ferramentaVideo(args, ctx = {}) {
|
|
|
430
459
|
}
|
|
431
460
|
const sons = await edicao.prepararSons(p.pasta, video);
|
|
432
461
|
|
|
462
|
+
// As imagens de apoio: uma por termo, a primeira que baixar.
|
|
463
|
+
const imagens = {};
|
|
464
|
+
if ((args.estilo || 'viral') !== 'sem-apoio') {
|
|
465
|
+
for (const termo of edicao.termosDeApoio(palavras).slice(0, 8)) {
|
|
466
|
+
try {
|
|
467
|
+
const b = await assets.procurarImagens(termo, { quantas: 4 });
|
|
468
|
+
for (const item of (b.ok ? b.imagens : [])) {
|
|
469
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
|
|
470
|
+
if (d.ok) { imagens[termo] = d.relativo; break; }
|
|
471
|
+
}
|
|
472
|
+
} catch { /* segue sem esta */ }
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// A música de fundo: uma trilha longa, de licença aberta.
|
|
477
|
+
let trilha = null;
|
|
478
|
+
if (args.musica !== false) {
|
|
479
|
+
try {
|
|
480
|
+
const b = await assets.procurarSom(args.trilha || 'upbeat corporate background', { quantas: 6, duracaoMin: 45 });
|
|
481
|
+
for (const item of (b.ok ? b.sons : [])) {
|
|
482
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
|
|
483
|
+
if (d.ok) { trilha = d.relativo; break; }
|
|
484
|
+
}
|
|
485
|
+
} catch { /* segue sem música */ }
|
|
486
|
+
}
|
|
487
|
+
|
|
433
488
|
const montado = edicao.montarEdicao({
|
|
434
489
|
src: path.relative(p.pasta, fonte).replace(/\\/g, '/'),
|
|
435
490
|
duracao: m.segundos, palavras, largura: m.largura, altura: m.altura,
|
|
436
|
-
estilo: args.estilo || 'viral', sons, iconesOk,
|
|
491
|
+
estilo: args.estilo || 'viral', sons, iconesOk, imagens, trilha,
|
|
437
492
|
});
|
|
438
493
|
if (!montado.ok) return montado;
|
|
439
494
|
const salvo = await ferramentaPlano({ id: args.id, plano: montado.plano, motivo: `edição automática (${args.estilo || 'viral'})` });
|
|
@@ -465,6 +520,56 @@ async function ferramentaVideo(args, ctx = {}) {
|
|
|
465
520
|
return { ok: false, error: `não conheço a ação "${acao}".` };
|
|
466
521
|
}
|
|
467
522
|
|
|
523
|
+
const PROPORCOES = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350] };
|
|
524
|
+
|
|
525
|
+
/** O roteiro vira plano: baixa o que falta, narra, e chama o compositor. */
|
|
526
|
+
async function prepararRoteiro(p, args) {
|
|
527
|
+
const roteiro = typeof args.roteiro === 'string' ? JSON.parse(args.roteiro) : args.roteiro;
|
|
528
|
+
if (Array.isArray(roteiro)) return prepararRoteiro(p, { ...args, roteiro: { partes: roteiro } });
|
|
529
|
+
const partes = roteiro.partes || [];
|
|
530
|
+
const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
|
|
531
|
+
const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
|
|
532
|
+
|
|
533
|
+
const iconesOk = new Set();
|
|
534
|
+
for (const nome of composicao.iconesDoRoteiro(roteiro)) {
|
|
535
|
+
try { const r = await assets.icone(nome); if (r && r.ok) iconesOk.add(nome); } catch { /* sem */ }
|
|
536
|
+
}
|
|
537
|
+
const imagens = {};
|
|
538
|
+
for (const [i, parte] of partes.entries()) {
|
|
539
|
+
if (parte.tipo !== 'imagem' || parte.src || !parte.busca) continue;
|
|
540
|
+
try {
|
|
541
|
+
const b = await assets.procurarImagens(parte.busca, { quantas: 4 });
|
|
542
|
+
for (const item of (b.ok ? b.imagens : [])) {
|
|
543
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'cena-' + (i + 1) });
|
|
544
|
+
if (d.ok) { imagens[i] = d.relativo; break; }
|
|
545
|
+
}
|
|
546
|
+
} catch { /* a cena sai sem a foto, com o título */ }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
let tempos = [], narracao = null, sons = {}, trilha = null;
|
|
550
|
+
if (modo === 'video') {
|
|
551
|
+
const lista = partes.map((pt, i) => ({ texto: composicao.falaDe(pt), registro: composicao.registroDe(pt, i, partes.length) }));
|
|
552
|
+
const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
|
|
553
|
+
fs.mkdirSync(path.dirname(arq), { recursive: true });
|
|
554
|
+
fs.writeFileSync(arq, JSON.stringify(lista));
|
|
555
|
+
const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
|
|
556
|
+
const v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
|
|
557
|
+
if (!v.ok) return v;
|
|
558
|
+
tempos = v.partes;
|
|
559
|
+
narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
|
|
560
|
+
projeto.anotar(p.id, 'narracao', `${v.segundos}s em ${partes.length} partes, voz ${v.voz}`);
|
|
561
|
+
sons = await edicao.prepararSons(p.pasta, video);
|
|
562
|
+
try {
|
|
563
|
+
const b = await assets.procurarSom(roteiro.musica || args.trilha || 'inspiring corporate background', { quantas: 6, duracaoMin: 45 });
|
|
564
|
+
for (const item of (b.ok ? b.sons : [])) {
|
|
565
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha' });
|
|
566
|
+
if (d.ok) { trilha = d.relativo; break; }
|
|
567
|
+
}
|
|
568
|
+
} catch { /* sem música */ }
|
|
569
|
+
}
|
|
570
|
+
return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk });
|
|
571
|
+
}
|
|
572
|
+
|
|
468
573
|
/* Sem o tempo de cada palavra, cada trecho é repartido pelas palavras dele,
|
|
469
574
|
proporcional ao tamanho de cada uma. */
|
|
470
575
|
function repartirTrechos(trechos) {
|
package/lib/estudio/plano.js
CHANGED
|
@@ -165,7 +165,10 @@ function conferirElemento(caminho, el, pasta, problemas, duracao) {
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
conferirCor(`${caminho}.cor`, el.cor, problemas);
|
|
168
|
-
|
|
168
|
+
// O fundo do elemento é cor, ou o mesmo objeto do fundo da cena (um
|
|
169
|
+
// gradiente sobre a foto, por exemplo).
|
|
170
|
+
if (el.fundo && typeof el.fundo === 'object') conferirFundo(`${caminho}.fundo`, el.fundo, pasta, problemas);
|
|
171
|
+
else conferirCor(`${caminho}.fundo`, el.fundo, problemas);
|
|
169
172
|
conferirAnima(`${caminho}.anima`, el.anima, problemas);
|
|
170
173
|
|
|
171
174
|
// `de` e `ate` recortam o elemento no tempo DA CENA. Um elemento que
|
package/lib/estudio/remotion.js
CHANGED
|
@@ -234,7 +234,39 @@ function Elemento({ el, t, dados }) {
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
switch (el.tipo) {
|
|
237
|
-
case 'texto':
|
|
237
|
+
case 'texto': {
|
|
238
|
+
/* O NÚMERO QUE CONTA: "contar": {ate: 9, dur: 1.2} faz o texto subir
|
|
239
|
+
de 0 até o valor, com a curva de saída. Casas decimais e o que vem
|
|
240
|
+
antes/depois do número (R$, %, mil) ficam como estão. */
|
|
241
|
+
let conteudo = el.texto;
|
|
242
|
+
if (el.contar) {
|
|
243
|
+
const alvo = Number(el.contar.ate) || 0;
|
|
244
|
+
const dur = el.contar.dur || 1.2;
|
|
245
|
+
const k = CURVAS.saida(Math.min(1, Math.max(0, tLocal / dur)));
|
|
246
|
+
const casas = el.contar.casas || 0;
|
|
247
|
+
const v = (alvo * k).toLocaleString('pt-BR', { minimumFractionDigits: casas, maximumFractionDigits: casas });
|
|
248
|
+
conteudo = (el.contar.antes || '') + v + (el.contar.depois || '');
|
|
249
|
+
}
|
|
250
|
+
/* O TEXTO QUE SE REVELA: "revelar": [t0, t1, …] é o instante (no tempo
|
|
251
|
+
do elemento) em que cada palavra entra — o da narração. A palavra
|
|
252
|
+
sobe e aparece; as que ainda não foram ditas esperam invisíveis, e o
|
|
253
|
+
bloco não muda de tamanho quando elas entram. */
|
|
254
|
+
if (Array.isArray(el.revelar) && typeof el.texto === 'string') {
|
|
255
|
+
const palavras = el.texto.split(' ');
|
|
256
|
+
conteudo = palavras.map((w, i) => {
|
|
257
|
+
const t0 = el.revelar[Math.min(i, el.revelar.length - 1)] ?? 0;
|
|
258
|
+
const k = Math.min(1, Math.max(0, (tLocal - t0) / 0.28));
|
|
259
|
+
const e = CURVAS.saida(k);
|
|
260
|
+
// O espaço fica FORA do span: é ali que a linha pode quebrar.
|
|
261
|
+
return (
|
|
262
|
+
<React.Fragment key={i}>
|
|
263
|
+
<span style={{ display: 'inline-block', opacity: e, transform: 'translateY(' + (1 - e) * 0.35 + 'em)',
|
|
264
|
+
color: el.destaques && el.destaques.includes(i) ? (el.corDestaque || el.cor) : undefined }}>{w}</span>
|
|
265
|
+
{i < palavras.length - 1 ? ' ' : null}
|
|
266
|
+
</React.Fragment>
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
238
270
|
return (
|
|
239
271
|
<div style={{
|
|
240
272
|
...estilo,
|
|
@@ -250,8 +282,13 @@ function Elemento({ el, t, dados }) {
|
|
|
250
282
|
background: el.gradienteTexto || undefined,
|
|
251
283
|
WebkitBackgroundClip: el.gradienteTexto ? 'text' : undefined,
|
|
252
284
|
WebkitTextFillColor: el.gradienteTexto ? 'transparent' : undefined,
|
|
253
|
-
|
|
285
|
+
fontStyle: el.italico ? 'italic' : undefined,
|
|
286
|
+
textDecoration: el.riscado ? 'line-through' : undefined,
|
|
287
|
+
textDecorationThickness: el.riscado ? '0.08em' : undefined,
|
|
288
|
+
wordBreak: 'normal', overflowWrap: 'break-word',
|
|
289
|
+
}}>{conteudo}</div>
|
|
254
290
|
);
|
|
291
|
+
}
|
|
255
292
|
|
|
256
293
|
case 'forma': {
|
|
257
294
|
const forma = el.forma || 'retangulo';
|
|
@@ -260,7 +297,7 @@ function Elemento({ el, t, dados }) {
|
|
|
260
297
|
...estilo,
|
|
261
298
|
width: medida(el.largura, '200px'),
|
|
262
299
|
height: forma === 'linha' ? medida(el.espessura, '4px') : medida(el.altura, '200px'),
|
|
263
|
-
background: el.fundo || el.cor || '#fff',
|
|
300
|
+
...(el.fundo && typeof el.fundo === 'object' ? fundoCSS(el.fundo) : { background: el.fundo || el.cor || '#fff' }),
|
|
264
301
|
borderRadius: forma === 'circulo' ? '50%' : medida(a.raio ?? el.raio, '0px'),
|
|
265
302
|
border: el.contorno || undefined,
|
|
266
303
|
}} />
|
|
@@ -375,10 +412,11 @@ function Elemento({ el, t, dados }) {
|
|
|
375
412
|
}
|
|
376
413
|
}
|
|
377
414
|
|
|
378
|
-
export function Cena({ cena, dados }) {
|
|
415
|
+
export function Cena({ cena, dados, tFixo }) {
|
|
379
416
|
const frame = useCurrentFrame();
|
|
380
417
|
const { fps } = useVideoConfig();
|
|
381
|
-
|
|
418
|
+
// A página parada é a cena num instante só: tFixo manda no tempo.
|
|
419
|
+
const t = tFixo != null ? tFixo : frame / fps;
|
|
382
420
|
|
|
383
421
|
const cam = animado(cena.camera, t);
|
|
384
422
|
const camTransform = [
|
|
@@ -439,6 +477,21 @@ function Transicao({ tipo, progresso, children }) {
|
|
|
439
477
|
return <AbsoluteFill style={estilo}>{children}</AbsoluteFill>;
|
|
440
478
|
}
|
|
441
479
|
|
|
480
|
+
/* AS PÁGINAS: um quadro por cena, cada uma congelada no meio dela. Os
|
|
481
|
+
* slides saem num render só — antes era um "remotion still" por página, e
|
|
482
|
+
* cada um empacotava o projeto inteiro de novo (onze páginas, onze
|
|
483
|
+
* empacotamentos: é a demora de 25 minutos medida em 25/09). */
|
|
484
|
+
export const Paginas = () => {
|
|
485
|
+
const { fps } = useVideoConfig();
|
|
486
|
+
const frame = useCurrentFrame();
|
|
487
|
+
const cena = plano.cenas[Math.min(frame, plano.cenas.length - 1)];
|
|
488
|
+
return (
|
|
489
|
+
<AbsoluteFill style={{ backgroundColor: plano.fundoGeral || '#000' }}>
|
|
490
|
+
<Cena cena={cena} dados={{ fps, icones }} tFixo={cena.duracao / 2} />
|
|
491
|
+
</AbsoluteFill>
|
|
492
|
+
);
|
|
493
|
+
};
|
|
494
|
+
|
|
442
495
|
export const Video = () => {
|
|
443
496
|
const { fps } = useVideoConfig();
|
|
444
497
|
const frame = useCurrentFrame();
|
|
@@ -478,6 +531,7 @@ export const Video = () => {
|
|
|
478
531
|
<Audio
|
|
479
532
|
src={staticFile(plano.audio.trilha.src || plano.audio.trilha)}
|
|
480
533
|
volume={plano.audio.trilha.volume ?? 0.18}
|
|
534
|
+
loop={plano.audio.trilha.loop !== false}
|
|
481
535
|
/>
|
|
482
536
|
)}
|
|
483
537
|
{(plano.audio && plano.audio.efeitos ? plano.audio.efeitos : []).map((e, i) => (
|
|
@@ -494,7 +548,7 @@ function rootJsx(plano) {
|
|
|
494
548
|
const f = plano.formato || {};
|
|
495
549
|
return `import React from 'react';
|
|
496
550
|
import { Composition } from 'remotion';
|
|
497
|
-
import { Video } from './Video';
|
|
551
|
+
import { Video, Paginas } from './Video';
|
|
498
552
|
import plano from './plano.json';
|
|
499
553
|
import './fontes.css';
|
|
500
554
|
|
|
@@ -505,14 +559,24 @@ const quadros = Math.max(1, Math.round(
|
|
|
505
559
|
));
|
|
506
560
|
|
|
507
561
|
export const RemotionRoot = () => (
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
562
|
+
<>
|
|
563
|
+
<Composition
|
|
564
|
+
id="Video"
|
|
565
|
+
component={Video}
|
|
566
|
+
durationInFrames={quadros}
|
|
567
|
+
fps={fps}
|
|
568
|
+
width={${Number(f.largura) || 1920}}
|
|
569
|
+
height={${Number(f.altura) || 1080}}
|
|
570
|
+
/>
|
|
571
|
+
<Composition
|
|
572
|
+
id="Paginas"
|
|
573
|
+
component={Paginas}
|
|
574
|
+
durationInFrames={plano.cenas.length}
|
|
575
|
+
fps={fps}
|
|
576
|
+
width={${Number(f.largura) || 1920}}
|
|
577
|
+
height={${Number(f.altura) || 1080}}
|
|
578
|
+
/>
|
|
579
|
+
</>
|
|
516
580
|
);
|
|
517
581
|
`;
|
|
518
582
|
}
|
package/lib/estudio/slides.js
CHANGED
|
@@ -48,41 +48,45 @@ const { spawn } = require('child_process');
|
|
|
48
48
|
function renderizarPaginas({ raiz, destino, plano, aoFalar = () => {} }) {
|
|
49
49
|
return new Promise((resolve) => {
|
|
50
50
|
fs.mkdirSync(destino, { recursive: true });
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
51
|
+
for (const f of fs.readdirSync(destino)) if (/\.(jpe?g|png)$/i.test(f)) fs.rmSync(path.join(destino, f));
|
|
52
|
+
const n = (plano.cenas || []).length;
|
|
53
|
+
const shell = process.platform === 'win32';
|
|
54
|
+
const cita = (a) => (shell && /\s/.test(a) ? `"${a}"` : a);
|
|
55
|
+
|
|
56
|
+
/* Projeto novo não tem o Remotion instalado: o caminho de slides
|
|
57
|
+
rodava o npx direto e morria em "could not determine executable". */
|
|
58
|
+
const passos = fs.existsSync(path.join(raiz, 'node_modules')) ? [] : [['npm', ['install', '--no-audit', '--no-fund']]];
|
|
59
|
+
// Todas as páginas num render só: a composição "Paginas" tem um quadro por cena.
|
|
60
|
+
/* O Remotion recusa pasta de sequência com ponto no caminho ("cannot
|
|
61
|
+
have an extension") — e ~/.primocode tem. A sequência sai numa pasta
|
|
62
|
+
temporária sem ponto e as páginas são movidas depois. */
|
|
63
|
+
const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'primo-paginas-'));
|
|
64
|
+
passos.push(['npx', ['remotion', 'render', 'Paginas', tmp, '--sequence',
|
|
65
|
+
'--image-format=jpeg', '--jpeg-quality=95', '--log=warn']]);
|
|
63
66
|
|
|
64
|
-
const paginas = [];
|
|
65
67
|
const correr = (i) => {
|
|
66
|
-
if (i >=
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
if (i >= passos.length) {
|
|
69
|
+
// Pelo NÚMERO: em ordem alfabética, element-10 vem antes de element-2.
|
|
70
|
+
const num = (f) => Number((f.match(/(\d+)\.jpe?g$/i) || [])[1] || 0);
|
|
71
|
+
const saidas = fs.readdirSync(tmp).filter((f) => /\.jpe?g$/i.test(f)).sort((a, b) => num(a) - num(b));
|
|
72
|
+
const paginas = saidas.map((f, k) => {
|
|
73
|
+
const final = path.join(destino, `pagina-${String(k + 1).padStart(3, '0')}.jpeg`);
|
|
74
|
+
fs.copyFileSync(path.join(tmp, f), final);
|
|
75
|
+
return final;
|
|
76
|
+
});
|
|
77
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* fica no temporário */ }
|
|
78
|
+
if (paginas.length !== n) return resolve({ ok: false, error: `saíram ${paginas.length} páginas de ${n}.` });
|
|
79
|
+
return resolve({ ok: true, paginas });
|
|
80
|
+
}
|
|
81
|
+
const [cmd, args] = passos[i];
|
|
82
|
+
aoFalar(cmd === 'npm' ? 'instalando o Remotion nesta pasta (uma vez só)' : `desenhando ${n} páginas`);
|
|
83
|
+
const p = spawn(cmd, args.map(cita), { cwd: raiz, shell });
|
|
77
84
|
let erro = '';
|
|
78
|
-
p.stderr.on('data', (d) => { erro += d.toString().slice(0,
|
|
79
|
-
const relogio = setTimeout(() => { try { p.kill(); } catch {} },
|
|
85
|
+
p.stderr.on('data', (d) => { erro += d.toString().slice(0, 2000); });
|
|
86
|
+
const relogio = setTimeout(() => { try { p.kill(); } catch {} }, 15 * 60000);
|
|
80
87
|
p.on('close', (code) => {
|
|
81
88
|
clearTimeout(relogio);
|
|
82
|
-
if (code !== 0) {
|
|
83
|
-
return resolve({ ok: false, error: `a página ${i + 1} falhou: ${erro.slice(-400).trim()}` });
|
|
84
|
-
}
|
|
85
|
-
paginas.push(arquivo);
|
|
89
|
+
if (code !== 0) return resolve({ ok: false, error: `${cmd} saiu com ${code}: ${erro.slice(-400).trim()}` });
|
|
86
90
|
correr(i + 1);
|
|
87
91
|
});
|
|
88
92
|
p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, error: e.message }); });
|