primocode 10.0.9 → 10.2.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 +4 -4
- package/bin/primocode.js +25 -28
- package/lib/act.js +5 -2
- package/lib/catalogo.js +7 -4
- package/lib/config.js +10 -2
- package/lib/estudio/assets.js +86 -6
- package/lib/estudio/batida.js +184 -0
- package/lib/estudio/biblioteca.js +214 -0
- package/lib/estudio/cinema.js +37 -17
- package/lib/estudio/composicao.js +2 -1
- package/lib/estudio/decisao.js +106 -0
- package/lib/estudio/edicao.js +36 -17
- package/lib/estudio/estilo.js +35 -8
- package/lib/estudio/fluido.js +352 -0
- package/lib/estudio/gerativo.js +21 -3
- package/lib/estudio/hibrido.js +16 -12
- package/lib/estudio/impacto.js +66 -31
- package/lib/estudio/index.js +245 -42
- package/lib/estudio/kit/fluido.jsx +614 -0
- package/lib/estudio/kit/gerativo.jsx +15 -5
- package/lib/estudio/kit/index.jsx +2 -1
- package/lib/estudio/kit/tres.jsx +88 -21
- package/lib/estudio/kit.js +3 -1
- package/lib/estudio/kokoro.js +111 -0
- package/lib/estudio/lancamento.js +3 -4
- package/lib/estudio/legibilidade.js +20 -0
- package/lib/estudio/referencias.js +36 -25
- package/lib/estudio/remotion.js +53 -6
- package/lib/estudio/revisor.js +117 -0
- package/lib/estudio/variacao3d.js +46 -0
- package/lib/estudio/variedade.js +140 -8
- package/lib/portao-skill.js +150 -0
- package/lib/skills.js +15 -1
- package/lib/tools.js +31 -5
- package/lib/video/filters.js +6 -1
- package/lib/video/ops.js +4 -3
- package/lib/video/timeline.js +1 -1
- package/lib/video/zoom.js +275 -0
- package/package.json +2 -2
- package/skills/escrever-em-portugues/SKILL.md +3 -0
- package/skills/interface-revisada/SKILL.md +7 -1
- package/skills/motion-de-primeira/SKILL.md +3 -5
- package/skills/projeto-no-github/SKILL.md +6 -0
- package/skills/publicar-na-internet/SKILL.md +3 -0
- package/skills/site-com-motion/SKILL.md +2 -0
- package/studio/voz_kokoro.py +98 -0
package/lib/estudio/index.js
CHANGED
|
@@ -35,12 +35,16 @@ const { spawn } = require('child_process');
|
|
|
35
35
|
const projeto = require('./projeto.js');
|
|
36
36
|
const acervo = require('./acervo.js');
|
|
37
37
|
const hibrido = require('./hibrido.js');
|
|
38
|
+
const biblioteca = require('./biblioteca.js');
|
|
38
39
|
const referencias = require('./referencias.js');
|
|
39
40
|
const variedade = require('./variedade.js');
|
|
41
|
+
const variacao3d = require('./variacao3d.js');
|
|
40
42
|
const plano = require('./plano.js');
|
|
41
43
|
const remotion = require('./remotion.js');
|
|
42
44
|
const assets = require('./assets.js');
|
|
43
45
|
const video = require('./video.js');
|
|
46
|
+
const batida = require('./batida.js');
|
|
47
|
+
const revisor = require('./revisor.js');
|
|
44
48
|
const identidade = require('./identidade.js');
|
|
45
49
|
const slides = require('./slides.js');
|
|
46
50
|
const edicao = require('./edicao.js');
|
|
@@ -50,7 +54,9 @@ const impacto = require('./impacto.js');
|
|
|
50
54
|
const cinema = require('./cinema.js');
|
|
51
55
|
const motion = require('./motion.js');
|
|
52
56
|
const gerativo = require('./gerativo.js');
|
|
57
|
+
const fluido = require('./fluido.js');
|
|
53
58
|
const vox = require('./vox.js');
|
|
59
|
+
const kokoro = require('./kokoro.js');
|
|
54
60
|
const lancamento = require('./lancamento.js');
|
|
55
61
|
const glifos = require('./glifos.js');
|
|
56
62
|
const { escolherEstilo, LEVES } = require('./estilo.js');
|
|
@@ -153,9 +159,11 @@ function semProjeto(id) {
|
|
|
153
159
|
};
|
|
154
160
|
}
|
|
155
161
|
|
|
156
|
-
async function ferramentaProjeto(args) {
|
|
162
|
+
async function ferramentaProjeto(args, ctx = {}) {
|
|
157
163
|
const acao = String(args.acao || 'listar').toLowerCase();
|
|
158
164
|
|
|
165
|
+
if (acao === 'editar' || acao === 'editor' || acao === 'studio') return editarNoStudio(args, ctx);
|
|
166
|
+
|
|
159
167
|
if (acao === 'criar' || acao === 'novo') {
|
|
160
168
|
const r = projeto.criar({
|
|
161
169
|
titulo: args.titulo, pedido: args.pedido || args.titulo, formato: args.formato || 'video',
|
|
@@ -199,6 +207,28 @@ async function ferramentaProjeto(args) {
|
|
|
199
207
|
};
|
|
200
208
|
}
|
|
201
209
|
|
|
210
|
+
/* EDITAR um vídeo que o Estúdio fez: abre o projeto Remotion dele no
|
|
211
|
+
Remotion Studio. Sem id, é o projeto mais novo. */
|
|
212
|
+
async function editarNoStudio(args, ctx = {}) {
|
|
213
|
+
const p = args.id ? projeto.ler(args.id) : projeto.listar({ limite: 1 })[0];
|
|
214
|
+
if (!p) return semProjeto(args.id);
|
|
215
|
+
const raiz = path.join(p.pasta, 'remotion');
|
|
216
|
+
if (!fs.existsSync(path.join(raiz, 'package.json'))) {
|
|
217
|
+
// Projeto que ainda não virou Remotion: escreve agora, sem renderizar.
|
|
218
|
+
const m = await ferramentaRender({ id: p.id, somenteProjeto: true }, ctx);
|
|
219
|
+
if (!m.ok) return m;
|
|
220
|
+
}
|
|
221
|
+
const r = remotion.abrirStudio({ raiz, spawnar: ctx.spawnar });
|
|
222
|
+
if (!r.ok) return { ...r, pasta: raiz };
|
|
223
|
+
projeto.anotar(p.id, 'editor', 'abriu no Remotion Studio');
|
|
224
|
+
return {
|
|
225
|
+
ok: true, id: p.id, pasta: raiz, comando: r.comando,
|
|
226
|
+
aviso: 'O Remotion Studio está ABRINDO no navegador (geralmente http://localhost:3000). '
|
|
227
|
+
+ `Diga ao usuário onde está o projeto: ${raiz}. Lá ele mexe e renderiza pelo botão Render do Studio. `
|
|
228
|
+
+ 'Cuidado: estudio_render reescreve a pasta src/ a partir do plano e apaga o que ele mudou à mão.',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
202
232
|
function listarArquivos(pasta) {
|
|
203
233
|
const saida = {};
|
|
204
234
|
for (const sub of ['entrada', 'midia', 'saida']) {
|
|
@@ -317,6 +347,8 @@ async function ferramentaPlano(args) {
|
|
|
317
347
|
try { original = JSON.parse(typeof args.roteiro === 'string' ? args.roteiro : JSON.stringify(args.roteiro)); } catch { original = args.roteiro; }
|
|
318
348
|
const r = await prepararRoteiro(p, args);
|
|
319
349
|
if (!r.ok) return r;
|
|
350
|
+
const ic = await variarIcones(r.plano, original);
|
|
351
|
+
if (ic.trocados) projeto.anotar(p.id, 'icones', `coleção ${ic.colecao}: ${ic.trocados} ícones`);
|
|
320
352
|
let conferida = plano.conferir(r.plano, { pasta: p.pasta });
|
|
321
353
|
/* A rede de segurança: o elemento que não passa sai, e o vídeo segue.
|
|
322
354
|
Um defeito num ícone não pode custar o vídeo inteiro. */
|
|
@@ -384,7 +416,7 @@ async function ferramentaMidia(args) {
|
|
|
384
416
|
|
|
385
417
|
if (acao === 'fontes') {
|
|
386
418
|
if (args.conferir) return assets.conferirFonte(args.conferir);
|
|
387
|
-
return { ok: true, fontes: assets.FONTES,
|
|
419
|
+
return { ok: true, fontes: assets.FONTES, biblioteca: biblioteca.cardapio(),
|
|
388
420
|
nota: 'qualquer família do Google Fonts funciona — esta lista é para não repetir sempre as mesmas.' };
|
|
389
421
|
}
|
|
390
422
|
|
|
@@ -636,7 +668,7 @@ async function ferramentaVideo(args, ctx = {}) {
|
|
|
636
668
|
if ((args.estilo || 'viral') === 'viral' && args.recorte !== false) {
|
|
637
669
|
const recortes = {};
|
|
638
670
|
for (const [i, info] of montado.infos.entries()) {
|
|
639
|
-
if (!
|
|
671
|
+
if (!info.recorte || !info.forte || info.forte.peso < 9 || Object.keys(recortes).length >= 3) continue;
|
|
640
672
|
const de = Math.max(0, info.forte.de - 0.15);
|
|
641
673
|
const ate = Math.min(info.dur, de + 2.2);
|
|
642
674
|
if (ate - de < 1) continue;
|
|
@@ -682,27 +714,81 @@ async function ferramentaVideo(args, ctx = {}) {
|
|
|
682
714
|
}
|
|
683
715
|
|
|
684
716
|
/* A mídia de apoio de um termo: vídeo do Pexels quando pedido e houver,
|
|
685
|
-
senão
|
|
717
|
+
senão uma foto. Devolve {src, tipo} ou null. O clipe sai sorteado entre
|
|
718
|
+
os que os últimos vídeos não usaram (variedade.js), não o primeiro da
|
|
719
|
+
busca — "o b-roll veio igual nos dois vídeos" (Joel, 05/10). */
|
|
720
|
+
/** A foto editorial (Wikimedia, museu) que os últimos vídeos não usaram; o crédito fica no caderno. */
|
|
721
|
+
async function baixarEditorial(p, termo) {
|
|
722
|
+
try {
|
|
723
|
+
const b = await assets.procurarEditorial(termo, { quantas: 8 });
|
|
724
|
+
for (const item of variedade.ordenar(b.imagens, 'broll').slice(0, 4)) {
|
|
725
|
+
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'foto-' + termo });
|
|
726
|
+
if (d.ok) { variedade.marcar('broll', item.url); projeto.anotar(p.id, 'creditos', item.creditos); return { src: d.relativo, tipo: 'imagem' }; }
|
|
727
|
+
}
|
|
728
|
+
} catch { /* sem foto */ }
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
|
|
686
732
|
async function baixarApoio(p, termo, { orientacao = null, video: querVideo = false } = {}) {
|
|
687
733
|
if (querVideo) {
|
|
688
734
|
try {
|
|
689
|
-
const
|
|
690
|
-
|
|
735
|
+
const itens = await variedade.buscarSemRepetir(async (t) => {
|
|
736
|
+
const b = await assets.procurarVideos(t, { quantas: 8, orientacao });
|
|
737
|
+
return (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 3);
|
|
738
|
+
}, termo);
|
|
739
|
+
for (const item of itens.slice(0, 4)) {
|
|
691
740
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
|
|
692
|
-
if (d.ok) return { src: d.relativo, tipo: 'video' };
|
|
741
|
+
if (d.ok) { variedade.marcar('broll', item.url); return { src: d.relativo, tipo: 'video' }; }
|
|
693
742
|
}
|
|
694
743
|
} catch { /* cai para a foto */ }
|
|
695
744
|
}
|
|
696
745
|
try {
|
|
697
|
-
const
|
|
698
|
-
|
|
746
|
+
const itens = await variedade.buscarSemRepetir(async (t) => {
|
|
747
|
+
const b = await assets.procurarImagens(t, { quantas: 8, orientacao });
|
|
748
|
+
return b.ok ? b.imagens : [];
|
|
749
|
+
}, termo);
|
|
750
|
+
for (const item of itens.slice(0, 4)) {
|
|
699
751
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'apoio-' + termo });
|
|
700
|
-
if (d.ok) return { src: d.relativo, tipo: 'imagem' };
|
|
752
|
+
if (d.ok) { variedade.marcar('broll', item.url); return { src: d.relativo, tipo: 'imagem' }; }
|
|
701
753
|
}
|
|
702
754
|
} catch { /* sem apoio */ }
|
|
703
755
|
return null;
|
|
704
756
|
}
|
|
705
757
|
|
|
758
|
+
/* Os ícones automáticos trocam de coleção a cada vídeo (Joel, 05/10: "várias
|
|
759
|
+
opções de ícones... que não sejam padrão da IA"). O ícone que o roteiro
|
|
760
|
+
pediu pelo nome fica; o que não existe na coleção sorteada fica no 3D. */
|
|
761
|
+
async function variarIcones(plano, roteiro) {
|
|
762
|
+
const colecao = variedade.colecaoIcones();
|
|
763
|
+
const explicitos = [];
|
|
764
|
+
try { JSON.stringify(roteiro || {}, (k, v) => { if (k === 'icone' && typeof v === 'string') explicitos.push(v); return v; }); } catch { /* sem */ }
|
|
765
|
+
const troca = {};
|
|
766
|
+
for (const nome of variedade.iconesTrocaveis(plano, explicitos)) {
|
|
767
|
+
/* Primeiro O ÍCONE: o conceito escolhe entre as formas dele
|
|
768
|
+
(edicao.ALTERNATIVAS) pelos pontos da variedade — o que os últimos
|
|
769
|
+
vídeos menos usaram. Depois A COLEÇÃO. */
|
|
770
|
+
const base = nome.split(':')[1];
|
|
771
|
+
let escolhido = base;
|
|
772
|
+
const alts = edicao.ALTERNATIVAS[base];
|
|
773
|
+
if (alts) {
|
|
774
|
+
const ordem = variedade.ordenar([base, ...alts].map((url) => ({ url })), 'icone-nome').map((x) => x.url);
|
|
775
|
+
for (const n of ordem) {
|
|
776
|
+
if (n === base) { escolhido = base; break; }
|
|
777
|
+
try { const r = await assets.icone('fluent-3d:' + n); if (r && r.ok) { escolhido = n; break; } } catch { /* tenta o próximo */ }
|
|
778
|
+
}
|
|
779
|
+
variedade.marcar('icone-nome', escolhido);
|
|
780
|
+
}
|
|
781
|
+
let novo = 'fluent-3d:' + escolhido;
|
|
782
|
+
if (colecao !== 'fluent-3d') {
|
|
783
|
+
try { const r = await assets.icone(colecao + ':' + escolhido); if (r && r.ok) novo = colecao + ':' + escolhido; } catch { /* fica o 3D */ }
|
|
784
|
+
}
|
|
785
|
+
if (novo !== nome) troca[nome] = novo;
|
|
786
|
+
}
|
|
787
|
+
const n = variedade.trocarIcones(plano, troca);
|
|
788
|
+
if (n || colecao === 'fluent-3d') variedade.marcar('icones', colecao);
|
|
789
|
+
return { colecao, trocados: n };
|
|
790
|
+
}
|
|
791
|
+
|
|
706
792
|
/* ── A VOZ DO GEMINI ─────────────────────────────────────────────────────
|
|
707
793
|
* O roteiro inteiro numa chamada só (o plano grátis tem poucos pedidos por
|
|
708
794
|
* minuto), e as partes achadas depois na transcrição do próprio áudio: a
|
|
@@ -867,11 +953,23 @@ async function narrarComCache(lista, mp3, { voz, direcao } = {}) {
|
|
|
867
953
|
const dir = path.join(os.homedir(), '.primocode', 'studio', 'cache-voz');
|
|
868
954
|
const cm = path.join(dir, chave + '.mp3'), cj = path.join(dir, chave + '.json');
|
|
869
955
|
if (fs.existsSync(cm) && fs.existsSync(cj)) { fs.copyFileSync(cm, mp3); return { ok: true, ...JSON.parse(fs.readFileSync(cj, 'utf8')) }; }
|
|
956
|
+
// A voz local pedida (voz: 'kokoro'): direto nela, sem gastar a cota do Gemini.
|
|
957
|
+
if (kokoro.querKokoro(voz)) return kokoro.narrar(lista, mp3, { voz, uv: acharUv() });
|
|
870
958
|
const v = await narrarComGemini(lista, mp3, { voz, direcao });
|
|
871
959
|
if (v.ok) { try { fs.mkdirSync(dir, { recursive: true }); fs.copyFileSync(mp3, cm); fs.writeFileSync(cj, JSON.stringify({ voz: v.voz, segundos: v.segundos, partes: v.partes })); } catch { /* sem cache */ } }
|
|
872
960
|
return v;
|
|
873
961
|
}
|
|
874
962
|
|
|
963
|
+
/* A VOZ DE RESERVA, quando o Gemini falha: o Kokoro (local, sem cota) e
|
|
964
|
+
depois o Edge. A falha de uma passa para a próxima, nunca derruba. */
|
|
965
|
+
function narrarReserva(lista, mp3, arq, voz, vozEdge, { soEdge = false, semKokoro = false } = {}) {
|
|
966
|
+
const passos = kokoro.ordem(voz, process.env, { soEdge }).filter((n) => n !== 'gemini' && !(semKokoro && n === 'kokoro'));
|
|
967
|
+
return kokoro.corrente(passos, {
|
|
968
|
+
kokoro: () => kokoro.narrar(lista, mp3, { voz, uv: acharUv() }),
|
|
969
|
+
edge: () => rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(kokoro.querKokoro(voz, {}) ? vozEdge : voz || vozEdge)], { timeout: 10 * 60000 }),
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
875
973
|
/* A MÚSICA TEM LETRA? O acervo não diz se a faixa é instrumental, então ela
|
|
876
974
|
é ouvida: 25 s do meio vão para a transcrição, e mais de 6 palavras é voz
|
|
877
975
|
cantada. Sem servidor para ouvir, a faixa passa (melhor música que nenhuma). */
|
|
@@ -903,7 +1001,7 @@ async function sonsReais(p, video) {
|
|
|
903
1001
|
for (const termo of variedade.termosSfx(nome)) {
|
|
904
1002
|
if (sons[nome] !== sint[nome]) break;
|
|
905
1003
|
try {
|
|
906
|
-
const b = await assets.procurarSom(termo, { quantas: 10 });
|
|
1004
|
+
const b = await assets.procurarSom(termo, { quantas: 10, livre: true });
|
|
907
1005
|
for (const item of variedade.ordenar(b.ok ? b.sons : [], 'sfx').slice(0, 3)) {
|
|
908
1006
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'sfx-real-' + nome });
|
|
909
1007
|
if (d.ok) { sons[nome] = d.relativo; variedade.marcar('sfx', item.url); break; }
|
|
@@ -939,24 +1037,44 @@ async function prepararRoteiro(p, args) {
|
|
|
939
1037
|
IMPACTO. O que o agente pediu vale mais que tudo. */
|
|
940
1038
|
const pedidoTxt = `${p.pedido || ''} ${args.pedido || ''}`;
|
|
941
1039
|
const ref = referencias.atual();
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
if (
|
|
949
|
-
void produtoNoPedido;
|
|
1040
|
+
/* Sem imagem é o padrão (07/10): sem o pedido dizer, nada de foto nem
|
|
1041
|
+
vídeo de fora. Ninguém escolheu o estilo? Ele gira (estilo.js) — sem
|
|
1042
|
+
imagem, só entre os que não vivem de foto. O híbrido e o cinema, pedidos
|
|
1043
|
+
pelo nome sem imagem, caem para o gerativo (sem nada baixado). */
|
|
1044
|
+
const semImagem = referencias.semImagem(ref);
|
|
1045
|
+
let estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo, girar: true, semImagem });
|
|
1046
|
+
if (semImagem && (estilo === 'hibrido' || estilo === 'cinema')) estilo = 'gerativo';
|
|
950
1047
|
/* "O vídeo deve ter um 3D do fone" (30/09): pedido de 3D vira o gerativo
|
|
951
1048
|
com o produto modelado girando numa batida (kit/tres.jsx) — a menos que
|
|
952
1049
|
o agente tenha escolhido outro estilo pelo nome. */
|
|
953
1050
|
if (/\b3\s?d\b|tridimensional/i.test(pedidoTxt) && (!args.estilo || estilo === 'gerativo' || estilo === 'lancamento')) {
|
|
954
1051
|
// O 3D mora numa batida do gerativo; o lançamento antigo, só pelo nome.
|
|
955
1052
|
if (!args.estilo && estilo !== 'lancamento') estilo = 'gerativo';
|
|
956
|
-
if (!roteiro.modelo3d) roteiro.modelo3d =
|
|
957
|
-
|
|
1053
|
+
if (!roteiro.modelo3d) roteiro.modelo3d = variacao3d.modeloDoPedido(pedidoTxt);
|
|
1054
|
+
}
|
|
1055
|
+
// O 3D de cada vídeo: acabamento, câmera, luz e ritmo sorteados sem repetir os últimos.
|
|
1056
|
+
/* Por PROJETO, não por chamada: mandar o roteiro de novo para ajustar uma
|
|
1057
|
+
frase não pode sortear outro 3D nem outro vídeo. Projeto novo, sorteio novo. */
|
|
1058
|
+
if (roteiro.modelo3d && !roteiro.variacao3d) {
|
|
1059
|
+
roteiro.variacao3d = p.variacao3d || variacao3d.escolherVariacao3d();
|
|
1060
|
+
if (!p.variacao3d) projeto.salvar(p.id, { variacao3d: roteiro.variacao3d });
|
|
1061
|
+
}
|
|
1062
|
+
/* A SEMENTE do gerativo saía do texto do roteiro: o mesmo roteiro dava o
|
|
1063
|
+
mesmo vídeo, batida por batida (05/10). Sem semente do roteiro, cada
|
|
1064
|
+
projeto sorteia a sua. */
|
|
1065
|
+
if (estilo === 'gerativo' && roteiro.semente == null) {
|
|
1066
|
+
roteiro.semente = p.semente != null ? p.semente : Math.floor(Math.random() * 1e9);
|
|
1067
|
+
if (p.semente == null) projeto.salvar(p.id, { semente: roteiro.semente });
|
|
958
1068
|
}
|
|
959
1069
|
if (ref.modo === 'fechado' && !roteiro.busca && referencias.termos().length) roteiro.busca = referencias.termos();
|
|
1070
|
+
/* A BIBLIOTECA (biblioteca.js): sem fonte e cor da marca, o híbrido, o
|
|
1071
|
+
cinema e o impacto caíam sempre no Inter e no azul e roxo — "os vídeos
|
|
1072
|
+
todos iguais" (02/10). A marca manda; depois o roteiro.visual da IA;
|
|
1073
|
+
sem nada, o sorteio que pula o que os últimos vídeos usaram. */
|
|
1074
|
+
if (['hibrido', 'cinema', 'impacto'].includes(estilo)) {
|
|
1075
|
+
const v = biblioteca.vestir(roteiro, { estilo });
|
|
1076
|
+
if (v.mudou) projeto.anotar(p.id, 'visual', [v.titulo && `fonte ${v.titulo} + ${v.texto} (tom ${v.tom})`, v.paleta && `paleta ${v.paleta}`].filter(Boolean).join(', '));
|
|
1077
|
+
}
|
|
960
1078
|
// Os estilos de motion usam os efeitos leves e a trilha do ende.app; os de corte, os cheios.
|
|
961
1079
|
const ehMotion = LEVES.has(estilo);
|
|
962
1080
|
const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
|
|
@@ -987,13 +1105,13 @@ async function prepararRoteiro(p, args) {
|
|
|
987
1105
|
const videosMarca = [...(acervo.videos || [])];
|
|
988
1106
|
const imagens = {}, fundos = {};
|
|
989
1107
|
for (const [i, parte] of partes.entries()) {
|
|
990
|
-
if (parte.tipo !== 'imagem' || parte.src) continue;
|
|
1108
|
+
if (semImagem || parte.tipo !== 'imagem' || parte.src) continue;
|
|
991
1109
|
const texto = `${parte.titulo || ''} ${parte.sub || ''} ${parte.fala || ''} ${parte.busca || ''}`;
|
|
992
1110
|
if (modo === 'video' && videosMarca.length && i % 2 === 1) { imagens[i] = { src: videosMarca.shift().src, tipo: 'video' }; continue; }
|
|
993
1111
|
const m = melhorFoto(texto) || (parte.busca ? await baixarApoio(p, parte.busca, { orientacao: largura >= altura ? 'landscape' : 'portrait', video: modo === 'video' }) : null);
|
|
994
1112
|
if (m) imagens[i] = m;
|
|
995
1113
|
}
|
|
996
|
-
if (acervo.fotos.length) {
|
|
1114
|
+
if (acervo.fotos.length && !semImagem) {
|
|
997
1115
|
const comFundo = ['abertura', 'titulo', 'contraste', 'citacao', 'cta', 'passos'];
|
|
998
1116
|
let alterna = 0;
|
|
999
1117
|
for (const [i, parte] of partes.entries()) {
|
|
@@ -1044,6 +1162,8 @@ async function prepararRoteiro(p, args) {
|
|
|
1044
1162
|
}
|
|
1045
1163
|
} catch { /* fica o alinhamento guardado */ }
|
|
1046
1164
|
}
|
|
1165
|
+
} else if (kokoro.querKokoro(args.voz || roteiro.voz)) {
|
|
1166
|
+
v = { ok: false, error: 'kokoro pedido' };
|
|
1047
1167
|
} else {
|
|
1048
1168
|
v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz,
|
|
1049
1169
|
direcao: 'Leia em português do Brasil como narrador de trailer de marca premium: voz grave, confiante e humana, '
|
|
@@ -1056,7 +1176,7 @@ async function prepararRoteiro(p, args) {
|
|
|
1056
1176
|
const arq = path.join(p.pasta, 'midia', `roteiro-${Date.now().toString(36)}.json`);
|
|
1057
1177
|
// Ritmo NORMAL: o "citação" (-10%, respiro longo) esticou o vídeo para 125 s.
|
|
1058
1178
|
fs.writeFileSync(arq, JSON.stringify(lista.map((x) => ({ ...x, registro: 'normal' }))));
|
|
1059
|
-
v = await
|
|
1179
|
+
v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'antonio', { soEdge });
|
|
1060
1180
|
}
|
|
1061
1181
|
if (!v.ok) return v;
|
|
1062
1182
|
/* A DURAÇÃO que o roteiro pede ("60–75 s"): se a voz passa do que cabe,
|
|
@@ -1171,20 +1291,27 @@ async function prepararRoteiro(p, args) {
|
|
|
1171
1291
|
const proxy = `${slug}.leve.mp4`;
|
|
1172
1292
|
if (jaTem.includes(proxy)) out.push({ src: 'midia/' + proxy, tipo: 'video', cat: cat(termo), alt: termo });
|
|
1173
1293
|
else try {
|
|
1174
|
-
|
|
1175
|
-
|
|
1294
|
+
// Sorteado entre os não usados, com o termo variado (variedade.js, 05/10).
|
|
1295
|
+
const itens = await variedade.buscarSemRepetir(async (t) => {
|
|
1296
|
+
const b = await assets.procurarVideos(t, { quantas: 8, orientacao });
|
|
1297
|
+
return (b.ok ? b.videos : []).filter((v) => !v.duracao || v.duracao >= 4);
|
|
1298
|
+
}, termo);
|
|
1299
|
+
for (const item of itens.slice(0, 1)) {
|
|
1176
1300
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
|
|
1177
1301
|
const l = d.ok ? await leve(d.relativo, proxy) : null;
|
|
1178
|
-
if (l) out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo });
|
|
1302
|
+
if (l) { out.push({ src: l, tipo: 'video', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
|
|
1179
1303
|
}
|
|
1180
1304
|
} catch { /* segue com a foto */ }
|
|
1181
1305
|
const foto = jaTem.find((f) => f.startsWith(slug + '-') && /\.(jpe?g|png|webp)$/i.test(f));
|
|
1182
1306
|
if (foto) out.push({ src: 'midia/' + foto, tipo: 'imagem', cat: cat(termo), alt: termo });
|
|
1183
1307
|
else try {
|
|
1184
|
-
const
|
|
1185
|
-
|
|
1308
|
+
const itens = await variedade.buscarSemRepetir(async (t) => {
|
|
1309
|
+
const b = await assets.procurarImagens(t, { quantas: 8, orientacao });
|
|
1310
|
+
return b.ok ? b.imagens : [];
|
|
1311
|
+
}, termo);
|
|
1312
|
+
for (const item of itens.slice(0, 1)) {
|
|
1186
1313
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: slug });
|
|
1187
|
-
if (d.ok) out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo });
|
|
1314
|
+
if (d.ok) { out.push({ src: d.relativo, tipo: 'imagem', cat: cat(termo), alt: termo }); variedade.marcar('broll', item.url); }
|
|
1188
1315
|
}
|
|
1189
1316
|
} catch { /* sem */ }
|
|
1190
1317
|
return out;
|
|
@@ -1241,7 +1368,7 @@ async function prepararRoteiro(p, args) {
|
|
|
1241
1368
|
const r = cinema.montarCinema({ roteiro, largura, altura, tempos: v.partes, narracao: path.relative(p.pasta, mp3).replace(/\\/g, '/'),
|
|
1242
1369
|
sons: sonsC, trilha: trilhaC, fotos: fotosC, fortes, ritmoLeitura: hib ? 0.55 : 1 });
|
|
1243
1370
|
if (r.ok && hib) {
|
|
1244
|
-
hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura });
|
|
1371
|
+
hibrido.enfeitar(r.plano, { extras: hib.extras, partes, marca: roteiro.marca || {}, largura, altura, hud: roteiro.hud === true, particulas: roteiro.particulas === true });
|
|
1245
1372
|
r.resumo.estilo = 'hibrido';
|
|
1246
1373
|
}
|
|
1247
1374
|
if (r.ok) projeto.anotar(p.id, 'plano', `estilo ${hib ? 'hibrido' : 'cinema'}: ${r.resumo.cenas} cenas, ${r.resumo.segundos}s`);
|
|
@@ -1263,18 +1390,23 @@ async function prepararRoteiro(p, args) {
|
|
|
1263
1390
|
? 'Leia em português do Brasil como locutor de reels de tecnologia: RÁPIDO e contínuo, com voz cheia, quente e encorpada, '
|
|
1264
1391
|
+ 'bem perto do microfone, muita energia e sorriso na voz. Ataque firme e ênfase curta e forte nas palavras de impacto; '
|
|
1265
1392
|
+ 'quase sem pausa entre as frases. Nunca arraste, nunca leia devagar nem solene.'
|
|
1393
|
+
// O fluido é o vídeo de produto: voz limpa e segura, e rápida ("mais rápido", 06/10).
|
|
1394
|
+
: estilo === 'fluido' ? 'Leia em português do Brasil como narrador de vídeo de produto premium: seguro, próximo e RÁPIDO, '
|
|
1395
|
+
+ 'frases limpas e contínuas, quase sem pausa entre elas. Nunca arraste, nunca leia devagar; sem tom de anúncio.'
|
|
1266
1396
|
: estilo === 'impacto' ? 'Leia em português do Brasil como locutor de comercial agressivo e vendedor: energia alta, ritmo rápido, '
|
|
1267
1397
|
+ 'voz encorpada, ênfase forte nas palavras de impacto, pausas curtas e secas entre as frases, sorrindo na voz.'
|
|
1268
1398
|
// "A narração tá lenta, deixa mais viva" (29/09): todo estilo fala com energia de reels.
|
|
1269
1399
|
: 'Leia em português do Brasil como narrador de reels de tecnologia: vivo, confiante e rápido, sorrindo na voz, '
|
|
1270
1400
|
+ 'ênfase nas palavras que importam, pausas curtas entre as frases. Nada de leitura lenta ou solene.' });
|
|
1271
|
-
|
|
1401
|
+
// O Kokoro pedido já foi tentado no narrarComCache: a reserva não repete.
|
|
1402
|
+
if (!v.ok) v = await narrarReserva(lista, mp3, arq, args.voz || roteiro.voz, 'thalita', { semKokoro: kokoro.querKokoro(args.voz || roteiro.voz) });
|
|
1272
1403
|
if (!v.ok) return v;
|
|
1273
1404
|
/* O RITMO DA VOZ. Mesmo com a direção, a voz sai no passo de leitura, e
|
|
1274
1405
|
o vídeo — que segue a voz palavra por palavra — sai lento junto. O
|
|
1275
1406
|
áudio acelera 1,12× (1,3× ficou corrido demais, 29/09; atempo não muda o tom) e cada tempo de palavra
|
|
1276
1407
|
acompanha; `roteiro.ritmoVoz` ajusta (1 desliga). */
|
|
1277
|
-
|
|
1408
|
+
// O fluido corre mais ("mais rápido", 06/10): 1,22× por padrão.
|
|
1409
|
+
const ritmo = Math.max(1, Math.min(1.5, Number(roteiro.ritmoVoz || args.ritmoVoz) || (estilo === 'fluido' ? 1.22 : 1.12)));
|
|
1278
1410
|
if (ritmo > 1.01) {
|
|
1279
1411
|
const rap = mp3.replace(/\.mp3$/, '-r.mp3');
|
|
1280
1412
|
const c = await video.rodar(video.ffmpeg(), ['-y', '-i', mp3, '-filter:a', `atempo=${ritmo.toFixed(3)}`, rap], { timeout: 120000 });
|
|
@@ -1302,9 +1434,8 @@ async function prepararRoteiro(p, args) {
|
|
|
1302
1434
|
funk para o fone, eletrônica para IA…), sorteia a faixa e não
|
|
1303
1435
|
repete as últimas usadas — nada de "inspiring corporate". */
|
|
1304
1436
|
let m = null;
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
try { usadas = JSON.parse(fs.readFileSync(usadasArq, 'utf8')); } catch { usadas = []; }
|
|
1437
|
+
// A memória da trilha é a da variedade ('trilha'): antes era um arquivo à parte (trilhas-usadas.json).
|
|
1438
|
+
const usadas = variedade.usadas('trilha');
|
|
1308
1439
|
const mus = estilo === 'gerativo' ? gerativo.musicaDe(roteiro) : null;
|
|
1309
1440
|
/* A trilha do gerativo vem do Jamendo (Openverse), pelo GÊNERO do
|
|
1310
1441
|
assunto, instrumental, sorteada e sem repetir as últimas — a
|
|
@@ -1315,9 +1446,10 @@ async function prepararRoteiro(p, args) {
|
|
|
1315
1446
|
classical: 'piano classical instrumental', 'soul-rnb': 'soul instrumental' };
|
|
1316
1447
|
try {
|
|
1317
1448
|
const b = await assets.procurarMusica(BUSCA[roteiro.genero || mus.genero] || ((roteiro.genero || mus.genero) + ' instrumental'), { quantas: 8, duracaoMin: Math.max(45, segundos + 10) });
|
|
1318
|
-
|
|
1449
|
+
// Pelos pontos da variedade: a recente e a muito usada vão para o fim, não somem.
|
|
1450
|
+
const opcoes = variedade.ordenar((b.ok ? b.sons : []).filter((x) => !/vocal|voice|feat|ft\.|remix/i.test(x.titulo || '')), 'trilha');
|
|
1319
1451
|
for (let k = 0; k < opcoes.length && !m; k++) {
|
|
1320
|
-
const item = opcoes[
|
|
1452
|
+
const item = opcoes[k];
|
|
1321
1453
|
const d = await assets.baixar(item, { pasta: p.pasta, nome: 'trilha-' + (roteiro.genero || mus.genero) });
|
|
1322
1454
|
if (!d.ok) continue;
|
|
1323
1455
|
if (await temLetra(path.join(p.pasta, d.relativo))) { projeto.anotar(p.id, 'midia', `trilha com voz descartada: ${item.titulo}`); continue; }
|
|
@@ -1326,12 +1458,12 @@ async function prepararRoteiro(p, args) {
|
|
|
1326
1458
|
} catch { /* cai na ende.app */ }
|
|
1327
1459
|
}
|
|
1328
1460
|
if (mus && m) {
|
|
1329
|
-
|
|
1461
|
+
variedade.marcar('trilha', m.id);
|
|
1330
1462
|
projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero} (Jamendo)`);
|
|
1331
1463
|
} else if (mus) {
|
|
1332
1464
|
m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'energetico', segundos, genero: roteiro.genero || mus.genero, semente: mus.semente, evitar: usadas });
|
|
1333
1465
|
const id = m && Number((/ende-(\d+)\.mp3$/.exec(m.src) || [])[1]);
|
|
1334
|
-
if (id)
|
|
1466
|
+
if (id) variedade.marcar('trilha', id);
|
|
1335
1467
|
if (m) projeto.anotar(p.id, 'trilha', `gênero ${roteiro.genero || mus.genero}`);
|
|
1336
1468
|
} else m = await ende.provedor({ pasta: path.join(p.pasta, 'midia') }).gerar({ clima: roteiro.clima || 'tecnologia', segundos });
|
|
1337
1469
|
if (m) { trilha = path.relative(p.pasta, m.src).replace(/\\/g, '/'); projeto.anotar(p.id, 'trilha', m.credito); }
|
|
@@ -1405,6 +1537,51 @@ async function prepararRoteiro(p, args) {
|
|
|
1405
1537
|
if (r.ok) projeto.anotar(p.id, 'plano', `estilo gerativo: ${r.resumo.batidas} batidas (${r.resumo.composicoes.join(' → ')}), ${r.resumo.segundos}s, semente ${r.resumo.semente}`);
|
|
1406
1538
|
return r;
|
|
1407
1539
|
}
|
|
1540
|
+
if (estilo === 'fluido' && modo === 'video') {
|
|
1541
|
+
/* A CARA de cada vídeo do fluido, por PROJETO (ajustar uma frase não
|
|
1542
|
+
troca): a semente (marcador, jeito das imagens) e a paleta, que a
|
|
1543
|
+
memória de variedade não repete do vídeo anterior (08/10: "sempre o
|
|
1544
|
+
mesmo azul"). A paleta ou o tema pedidos mandam. */
|
|
1545
|
+
if (roteiro.semente == null) {
|
|
1546
|
+
roteiro.semente = p.semente != null ? p.semente : Math.floor(Math.random() * 1e9);
|
|
1547
|
+
if (p.semente == null) projeto.salvar(p.id, { semente: roteiro.semente });
|
|
1548
|
+
}
|
|
1549
|
+
/* O modelo também vicia: pedia "noite" em todo vídeo (era a primeira da
|
|
1550
|
+
lista). A paleta dele vale, menos quando é uma das 2 últimas usadas —
|
|
1551
|
+
aí vem a menos usada do mesmo tema (escura ou clara). */
|
|
1552
|
+
if (!p.paletaFluido) {
|
|
1553
|
+
const P = fluido.PALETAS, nomes = Object.keys(P);
|
|
1554
|
+
const pedida = P[String(roteiro.paleta || '').toLowerCase()] ? String(roteiro.paleta).toLowerCase() : null;
|
|
1555
|
+
const recentes = variedade.usadas('paleta-fluido').slice(0, 2);
|
|
1556
|
+
const escuro = pedida ? P[pedida].escuro : /escuro|dark/i.test(roteiro.tema || '') ? true : /claro|light/i.test(roteiro.tema || '') ? false : null;
|
|
1557
|
+
let escolha = pedida && !recentes.includes(pedida) ? pedida : null;
|
|
1558
|
+
if (!escolha) escolha = variedade.sortear(nomes.filter((n) => escuro == null || P[n].escuro === escuro), 'paleta-fluido');
|
|
1559
|
+
else variedade.marcar('paleta-fluido', escolha);
|
|
1560
|
+
if (pedida && escolha !== pedida) projeto.anotar(p.id, 'visual', `paleta ${pedida} pedida, mas foi a de um dos últimos vídeos: ${escolha}`);
|
|
1561
|
+
projeto.salvar(p.id, { paletaFluido: escolha });
|
|
1562
|
+
p.paletaFluido = escolha;
|
|
1563
|
+
}
|
|
1564
|
+
roteiro.paleta = p.paletaFluido;
|
|
1565
|
+
/* As IMAGENS do fluido misto, só com imagem pedida: as partes com
|
|
1566
|
+
"busca", no máximo 1 em 3 e espalhadas (fluido.js decide o jeito). */
|
|
1567
|
+
const fotosFluido = {};
|
|
1568
|
+
if (!semImagem) {
|
|
1569
|
+
const comBusca = partes.map((pt, k) => (pt.busca || pt.src ? k : -1)).filter((k) => k >= 0 && (partes[k].tipo !== 'cta'));
|
|
1570
|
+
const teto = Math.max(1, Math.floor(partes.length / 3));
|
|
1571
|
+
const escolhidas = [];
|
|
1572
|
+
for (const k of comBusca) if (escolhidas.length < teto && !escolhidas.some((j) => Math.abs(j - k) < 2)) escolhidas.push(k);
|
|
1573
|
+
for (const k of escolhidas) {
|
|
1574
|
+
const pt = partes[k];
|
|
1575
|
+
// O acervo EDITORIAL (assets.procurarEditorial), não o banco de stock: sem foto que sirva, a parte fica sem imagem.
|
|
1576
|
+
const m = pt.src ? { src: pt.src } : await baixarEditorial(p, pt.busca);
|
|
1577
|
+
if (m && m.src) fotosFluido[k] = { src: m.src };
|
|
1578
|
+
}
|
|
1579
|
+
if (Object.keys(fotosFluido).length) projeto.anotar(p.id, 'midia', `fluido misto: imagem nas partes ${Object.keys(fotosFluido).map((k) => Number(k) + 1).join(', ')}`);
|
|
1580
|
+
}
|
|
1581
|
+
const r = fluido.montarFluido({ imagens: fotosFluido, roteiro: { ...roteiro, pedido: roteiro.pedido || pedidoTxt }, largura, altura, tempos, narracao, sons, trilha });
|
|
1582
|
+
if (r.ok) projeto.anotar(p.id, 'plano', `estilo fluido: ${r.resumo.batidas} batidas${r.resumo.imagens.length ? ` (imagens: ${r.resumo.imagens.join(', ')})` : ''}, paleta ${r.resumo.paleta}, ${r.resumo.segundos}s, batida atrasada no máximo ${r.resumo.atraso}s, palavra atrás da voz no máximo ${r.resumo.sincronia}s`);
|
|
1583
|
+
return r;
|
|
1584
|
+
}
|
|
1408
1585
|
if (estilo === 'motion' && modo === 'video') {
|
|
1409
1586
|
const r = motion.montarMotion({ roteiro, largura, altura, tempos, narracao, sons, trilha, imagens });
|
|
1410
1587
|
if (r.ok) projeto.anotar(p.id, 'plano', `estilo motion: ${r.resumo.segundos}s contínuos, ${r.resumo.partes} partes, ${r.resumo.fps} fps`);
|
|
@@ -1489,10 +1666,34 @@ async function ferramentaRender(args, ctx = {}) {
|
|
|
1489
1666
|
problemas: conferida.problemas.slice(0, 12) };
|
|
1490
1667
|
}
|
|
1491
1668
|
|
|
1669
|
+
/* A REVISÃO (revisor.js): o plano roda; agora, ele está bom? Conserta a
|
|
1670
|
+
receita (transição repetida, câmera sem respiro, som duplicado) e
|
|
1671
|
+
devolve o resto como aviso para quem fez o plano. */
|
|
1672
|
+
const revisao = revisor.revisar(pl, { semImagem: referencias.semImagem() });
|
|
1673
|
+
if (revisao.consertos.length) projeto.anotar(args.id, 'revisao', revisao.consertos.join('; '));
|
|
1674
|
+
if (revisao.avisos.length) projeto.anotar(args.id, 'revisao', 'avisos: ' + revisao.avisos.join('; '));
|
|
1675
|
+
|
|
1492
1676
|
// Os ícones vêm ANTES do render: um render que busca ícone na internet
|
|
1493
1677
|
// falha no meio, depois de vinte minutos.
|
|
1494
1678
|
const { mapa, faltaram } = await assets.iconesDoPlano(pl, p.pasta);
|
|
1495
1679
|
|
|
1680
|
+
/* A MÚSICA NO CORTE (batida.js): a faixa começa no ponto em que as
|
|
1681
|
+
batidas caem nos cortes do plano, e o corte de impacto num acento. Só
|
|
1682
|
+
quando ninguém disse o início (audio.trilha.inicio). */
|
|
1683
|
+
const tr = pl.audio && pl.audio.trilha;
|
|
1684
|
+
if (tr && !(typeof tr === 'object' && tr.inicio != null) && !(p.formato === 'slides' || p.formato === 'arte' || args.paginas)) {
|
|
1685
|
+
try {
|
|
1686
|
+
const src = path.join(p.pasta, tr.src || tr);
|
|
1687
|
+
const analise = await batida.analisar(src, { ffmpeg: video.ffmpeg() || 'ffmpeg' });
|
|
1688
|
+
if (analise && analise.batidas.length) {
|
|
1689
|
+
const duracao = (pl.cenas || []).reduce((n, c) => n + (Number(c.duracao) || 0), 0);
|
|
1690
|
+
const r = batida.melhorInicio(batida.cortesDoPlano(pl), analise, { maximo: Math.min(20, analise.segundos - duracao - 0.5) });
|
|
1691
|
+
pl.audio.trilha = { ...(typeof tr === 'object' ? tr : { src: tr }), inicio: r.inicio };
|
|
1692
|
+
projeto.anotar(args.id, 'trilha', `${analise.bpm} BPM; a faixa entra em ${r.inicio}s e ${r.alinhados} de ${r.total} cortes caem na batida`);
|
|
1693
|
+
}
|
|
1694
|
+
} catch { /* sem análise, a faixa entra do começo */ }
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1496
1697
|
const montado = remotion.montar({ pasta: p.pasta, titulo: p.titulo, plano: pl, icones: mapa });
|
|
1497
1698
|
|
|
1498
1699
|
if (args.somenteProjeto) {
|
|
@@ -1558,7 +1759,9 @@ async function ferramentaRender(args, ctx = {}) {
|
|
|
1558
1759
|
return {
|
|
1559
1760
|
ok: true, arquivo: saida, bytes, segundos: conferida.duracao,
|
|
1560
1761
|
faltaram: faltaram.length ? faltaram : undefined,
|
|
1561
|
-
|
|
1762
|
+
revisao: revisao.consertos.length || revisao.avisos.length ? { consertado: revisao.consertos, avisos: revisao.avisos } : undefined,
|
|
1763
|
+
aviso: 'ABERTO para o usuário assistir. Chame finish dizendo o que foi feito'
|
|
1764
|
+
+ (revisao.avisos.length ? ' — e cite os avisos da revisão que valem um novo plano.' : '.'),
|
|
1562
1765
|
};
|
|
1563
1766
|
}
|
|
1564
1767
|
|
|
@@ -1692,7 +1895,7 @@ async function ferramentaSite(args) {
|
|
|
1692
1895
|
return r;
|
|
1693
1896
|
}
|
|
1694
1897
|
|
|
1695
|
-
module.exports = {
|
|
1898
|
+
module.exports = { variarIcones,
|
|
1696
1899
|
ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
|
|
1697
1900
|
ferramentaVideo, ferramentaRender, ferramentaSite,
|
|
1698
1901
|
projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes, prepararRoteiro,
|