primocode 9.10.0 → 10.0.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.
Files changed (60) hide show
  1. package/README.md +30 -6
  2. package/bin/primocode.js +100 -341
  3. package/lib/act.js +245 -23
  4. package/lib/browser.js +17 -0
  5. package/lib/casar.js +108 -0
  6. package/lib/catalogo.js +6 -2
  7. package/lib/config.js +10 -3
  8. package/lib/cursor.js +10 -1
  9. package/lib/entrada.js +4 -2
  10. package/lib/estudio/diretor.js +9 -1
  11. package/lib/estudio/estilo.js +51 -0
  12. package/lib/estudio/glifos.js +215 -0
  13. package/lib/estudio/index.js +41 -18
  14. package/lib/estudio/kit/base.jsx +285 -0
  15. package/lib/estudio/kit/index.jsx +11 -0
  16. package/lib/estudio/kit/marca.jsx +741 -0
  17. package/lib/estudio/kit/vox.jsx +357 -0
  18. package/lib/estudio/kit.js +72 -0
  19. package/lib/estudio/lancamento.js +413 -0
  20. package/lib/estudio/motion.js +2 -5
  21. package/lib/estudio/plano.js +28 -3
  22. package/lib/estudio/remotion.js +87 -43
  23. package/lib/estudio/ritmo.js +77 -0
  24. package/lib/estudio/vox.js +385 -0
  25. package/lib/fala.js +1 -94
  26. package/lib/pipeline.js +55 -56
  27. package/lib/repeticao.js +3 -1
  28. package/lib/skills.js +8 -2
  29. package/lib/snapshot.js +111 -0
  30. package/lib/tools.js +79 -20
  31. package/lib/ui.js +13 -6
  32. package/lib/video/analise.js +89 -0
  33. package/lib/video/cli.js +74 -0
  34. package/lib/video/delta.js +90 -0
  35. package/lib/video/edits.js +379 -0
  36. package/lib/video/filters.js +247 -0
  37. package/lib/video/ops.js +322 -0
  38. package/lib/video/projeto.js +133 -0
  39. package/lib/video/render.js +221 -0
  40. package/lib/video/texto.js +142 -0
  41. package/lib/video/timeline.js +310 -0
  42. package/lib/video/transcript.js +124 -0
  43. package/lib/voz.js +75 -223
  44. package/package.json +2 -3
  45. package/skills/motion-de-primeira/SKILL.md +3 -4
  46. package/studio/texto.py +208 -0
  47. package/voz/primo_voz/__main__.py +96 -236
  48. package/voz/primo_voz/transcrever.py +31 -1
  49. package/app/ansi.js +0 -55
  50. package/app/app.css +0 -500
  51. package/app/app.js +0 -1017
  52. package/app/icone.svg +0 -6
  53. package/app/index.html +0 -220
  54. package/app/primo-marca.js +0 -192
  55. package/lib/app.js +0 -615
  56. package/lib/icone.js +0 -263
  57. package/lib/instalar-app.js +0 -231
  58. package/lib/painel.js +0 -291
  59. package/voz/primo_voz/desperta.py +0 -195
  60. package/voz/primo_voz/orbe.py +0 -406
@@ -0,0 +1,133 @@
1
+ /**
2
+ * projeto.js — onde a edição mora: `<pasta atual>/video/<id>/`.
3
+ *
4
+ * timeline.json o projeto (lib/video/timeline.js)
5
+ * history/ uma foto da timeline ANTES de cada edição (o undo)
6
+ * log.jsonl o caderno: que operação, quando, com que resultado
7
+ * midia/ transcrição e silêncio medidos (cache por mídia)
8
+ * cache/ os pngs do texto e os quadros do inspect
9
+ * saida/ o mp4 exportado e a folha do inspect
10
+ *
11
+ * A pasta fica NA PASTA DO TRABALHO, e não em ~/.primocode como os projetos
12
+ * do Estúdio: a gravação é da pessoa, está ali, e o vídeo editado tem que
13
+ * aparecer ao lado dela. O id segue a convenção do Estúdio (data na frente,
14
+ * apelido depois), para "o de semana passada" ser achado pela ordem.
15
+ *
16
+ * ── POR QUE O UNDO É FOTO E NÃO OPERAÇÃO INVERSA ─────────────────────────
17
+ * Desfazer um ripple de quarenta clipes com a conta inversa é reescrever o
18
+ * ripple ao contrário, com os mesmos defeitos. A foto do json custa alguns
19
+ * kB e desfaz qualquer coisa, inclusive o que for criado amanhã. É o que
20
+ * deixa o agente editar sem pedir licença a cada passo.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const T = require('./timeline.js');
28
+ const { apelidar } = require('../estudio/projeto.js');
29
+
30
+ const MAX_HISTORICO = 100;
31
+
32
+ function raiz(cwd = process.cwd()) {
33
+ return process.env.PRIMOCODE_VIDEO_DIR || path.join(cwd, 'video');
34
+ }
35
+
36
+ function escreverAtomico(arq, texto) {
37
+ const tmp = `${arq}.${process.pid}.tmp`;
38
+ fs.writeFileSync(tmp, texto);
39
+ fs.renameSync(tmp, arq);
40
+ }
41
+
42
+ function criar({ nome = 'video', width, height, fps, cwd } = {}) {
43
+ const base = raiz(cwd);
44
+ const dia = new Date().toISOString().slice(0, 10);
45
+ const semente = `${dia}-${apelidar(nome)}`;
46
+ let id = semente;
47
+ for (let n = 2; fs.existsSync(path.join(base, id)); n++) id = `${semente}-${n}`;
48
+ const dir = path.join(base, id);
49
+ for (const sub of ['history', 'midia', 'cache', 'saida']) fs.mkdirSync(path.join(dir, sub), { recursive: true });
50
+ const tl = T.novo({ nome: apelidar(nome), width, height, fps });
51
+ const p = { id, dir };
52
+ salvar(p, tl);
53
+ fs.writeFileSync(path.join(base, '.atual'), id);
54
+ return { p, tl };
55
+ }
56
+
57
+ function atual(cwd) {
58
+ try { return fs.readFileSync(path.join(raiz(cwd), '.atual'), 'utf8').trim() || null; } catch { return null; }
59
+ }
60
+
61
+ function abrir(id, cwd) {
62
+ const base = raiz(cwd);
63
+ const alvo = id || atual(cwd);
64
+ if (!alvo) return null;
65
+ let dir = path.join(base, alvo);
66
+ if (!fs.existsSync(path.join(dir, 'timeline.json'))) {
67
+ // Aceita o começo do id ("2026-09-29-aula") como no clipe.
68
+ const casa = listar(cwd).filter((x) => x.id.startsWith(alvo) || x.id.endsWith(alvo));
69
+ if (casa.length !== 1) return null;
70
+ dir = path.join(base, casa[0].id);
71
+ }
72
+ const tl = JSON.parse(fs.readFileSync(path.join(dir, 'timeline.json'), 'utf8'));
73
+ return { p: { id: path.basename(dir), dir }, tl };
74
+ }
75
+
76
+ function usar(id, cwd) { fs.writeFileSync(path.join(raiz(cwd), '.atual'), id); }
77
+
78
+ function listar(cwd) {
79
+ const base = raiz(cwd);
80
+ if (!fs.existsSync(base)) return [];
81
+ return fs.readdirSync(base).filter((n) => fs.existsSync(path.join(base, n, 'timeline.json'))).sort().map((id) => {
82
+ let tl = null;
83
+ try { tl = JSON.parse(fs.readFileSync(path.join(base, id, 'timeline.json'), 'utf8')); } catch {}
84
+ return { id, s: tl ? T.r2(T.duracao(tl) / tl.fps) : null, clips: tl ? T.todosOsClipes(tl).length : null };
85
+ });
86
+ }
87
+
88
+ function salvar(p, tl) {
89
+ escreverAtomico(path.join(p.dir, 'timeline.json'), JSON.stringify(tl));
90
+ }
91
+
92
+ function historico(p) {
93
+ const dir = path.join(p.dir, 'history');
94
+ if (!fs.existsSync(dir)) return [];
95
+ return fs.readdirSync(dir).filter((n) => /^\d+\.json$/.test(n)).sort();
96
+ }
97
+
98
+ /** Guarda a foto de ANTES da edição. */
99
+ function fotografar(p, antes, op) {
100
+ const lista = historico(p);
101
+ const n = lista.length ? Number(lista[lista.length - 1].slice(0, -5)) + 1 : 1;
102
+ fs.mkdirSync(path.join(p.dir, 'history'), { recursive: true });
103
+ fs.writeFileSync(path.join(p.dir, 'history', `${String(n).padStart(6, '0')}.json`), JSON.stringify({ op, tl: antes }));
104
+ for (const velho of lista.slice(0, Math.max(0, lista.length + 1 - MAX_HISTORICO))) {
105
+ try { fs.unlinkSync(path.join(p.dir, 'history', velho)); } catch {}
106
+ }
107
+ }
108
+
109
+ /** Volta `passos` edições. Devolve {tl, desfeitas:[ops]} ou erro. */
110
+ function desfazer(p, passos = 1) {
111
+ const lista = historico(p);
112
+ if (!lista.length) throw T.erro('não há nada para desfazer');
113
+ // "abc" daria NaN, e slice(-NaN) é o histórico INTEIRO.
114
+ const n = Math.max(1, Math.min(lista.length, Math.round(Number(passos)) || 1));
115
+ const alvo = lista.slice(-n);
116
+ const desfeitas = [];
117
+ let tl = null;
118
+ for (const arq of [...alvo].reverse()) {
119
+ const foto = JSON.parse(fs.readFileSync(path.join(p.dir, 'history', arq), 'utf8'));
120
+ desfeitas.push(foto.op);
121
+ tl = foto.tl;
122
+ fs.unlinkSync(path.join(p.dir, 'history', arq));
123
+ }
124
+ return { tl, desfeitas, restam: lista.length - n };
125
+ }
126
+
127
+ function anotar(p, op, resumo) {
128
+ try {
129
+ fs.appendFileSync(path.join(p.dir, 'log.jsonl'), JSON.stringify({ q: new Date().toISOString(), op, ...resumo }) + '\n');
130
+ } catch { /* o caderno não pode derrubar a edição */ }
131
+ }
132
+
133
+ module.exports = { raiz, criar, abrir, atual, usar, listar, salvar, fotografar, desfazer, historico, anotar, MAX_HISTORICO };
@@ -0,0 +1,221 @@
1
+ /**
2
+ * render.js — onde o editor encosta no mundo: ffmpeg, Pillow e disco.
3
+ *
4
+ * Tudo que decide mora nos módulos puros (filters, texto, analise). Aqui só
5
+ * se mede, desenha e roda. O ffmpeg é achado pelo mesmo lib/estudio/video.js
6
+ * do Estúdio — um lugar só sabe onde o ffmpeg está e como instalar.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { spawn, execFileSync } = require('child_process');
14
+ const T = require('./timeline.js');
15
+ const F = require('./filters.js');
16
+ const TX = require('./texto.js');
17
+ const video = require('../estudio/video.js');
18
+ const { rodarPython } = require('./transcript.js');
19
+
20
+ const TEXTO_PY = path.join(__dirname, '..', '..', 'studio', 'texto.py');
21
+ const IMAGEM = /\.(png|jpe?g|webp|bmp|gif|tiff?)$/i;
22
+
23
+ // ── o que o ffmpeg desta máquina sabe ────────────────────────────────────
24
+
25
+ let _versao = null;
26
+ function versaoMaior() {
27
+ if (_versao !== null) return _versao;
28
+ try {
29
+ const v = execFileSync(video.ffmpeg(), ['-version'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString();
30
+ const m = v.match(/ffmpeg version n?(\d+)\./);
31
+ _versao = m ? Number(m[1]) : 99; // build do git ("N-12345") é recente
32
+ } catch { _versao = 99; }
33
+ return _versao;
34
+ }
35
+
36
+ let _codec = null;
37
+ function codecDeVideo() {
38
+ if (_codec) return _codec;
39
+ let lista = '';
40
+ try { lista = execFileSync(video.ffmpeg(), ['-hide_banner', '-encoders'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString(); } catch {}
41
+ for (const c of ['libx264', 'libopenh264', 'h264_videotoolbox', 'h264_mf', 'mpeg4']) {
42
+ if (new RegExp(`\\s${c}\\s`).test(lista)) { _codec = c; return c; }
43
+ }
44
+ return (_codec = 'mpeg4');
45
+ }
46
+
47
+ function argsDoCodec(codec, crf) {
48
+ if (codec === 'libx264') return ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', String(crf)];
49
+ if (codec === 'h264_videotoolbox') return ['-c:v', codec, '-b:v', '8M'];
50
+ return ['-c:v', codec, '-b:v', '8M'];
51
+ }
52
+
53
+ function conferir() { return video.conferir(); }
54
+
55
+ // ── importar ─────────────────────────────────────────────────────────────
56
+
57
+ async function medirAsset(tl, arquivo) {
58
+ const abs = path.resolve(arquivo);
59
+ if (!fs.existsSync(abs)) throw T.erro(`arquivo não existe: ${abs}`);
60
+ const m = await video.medir(abs);
61
+ if (!m.ok) throw T.erro(m.error);
62
+ const imagem = IMAGEM.test(abs);
63
+ const kind = imagem ? 'image' : m.largura ? 'video' : 'audio';
64
+ return {
65
+ id: T.novoAssetId(tl), path: abs, nome: path.basename(abs), kind,
66
+ seconds: imagem ? 0 : T.r2(m.segundos), width: m.largura || 0, height: m.altura || 0,
67
+ fps: m.fps || 0, hasAudio: !!m.temAudio,
68
+ };
69
+ }
70
+
71
+ // ── o texto em png ───────────────────────────────────────────────────────
72
+
73
+ let _pillow = null;
74
+ function python() { return require('../voz.js').python(); }
75
+
76
+ async function temPillow() {
77
+ if (_pillow !== null) return _pillow;
78
+ const py = python();
79
+ _pillow = py ? !!(await rodarPython(py, [TEXTO_PY, 'conferir'], { cwd: path.dirname(TEXTO_PY), timeout: 30000 })).ok : false;
80
+ return _pillow;
81
+ }
82
+
83
+ /** As camadas de texto da janela: {índiceDaFaixa: arquivo .ffconcat}. */
84
+ async function camadasDeTexto(pasta, tl, { from, to, escala }) {
85
+ const faixas = tl.tracks.map((t, i) => ({ t, i })).filter(({ t }) => t.type === 'text' && t.clips.length && !t.hidden);
86
+ if (!faixas.length) return { camadas: {}, avisos: [] };
87
+ if (!(await temPillow())) {
88
+ return { camadas: {}, avisos: ['os textos NÃO foram desenhados: falta o Pillow (python3 -m pip install pillow)'] };
89
+ }
90
+ const W = T.par(tl.width * escala);
91
+ const H = T.par(tl.height * escala);
92
+ const dir = path.join(pasta, 'cache', 'texto', `${W}x${H}`);
93
+ fs.mkdirSync(dir, { recursive: true });
94
+ const vazio = path.join(dir, 'vazio.png');
95
+ const arquivoDe = (chave) => (chave === 'vazio' ? vazio : path.join(dir, `${chave}.png`));
96
+ const estados = [];
97
+ const camadas = {};
98
+ for (const { t, i } of faixas) {
99
+ const { segmentos, desenhos } = TX.estadosDaFaixa(tl, t, { from, to, escala });
100
+ if (!Object.keys(desenhos).length) continue;
101
+ for (const [chave, items] of Object.entries(desenhos)) {
102
+ if (!fs.existsSync(arquivoDe(chave)) && !estados.some((e) => e.chave === chave)) estados.push({ chave, file: arquivoDe(chave), items });
103
+ }
104
+ const arq = path.join(dir, `faixa${i}-${from}-${to}.ffconcat`);
105
+ fs.writeFileSync(arq, TX.ffconcat(segmentos, tl.fps, arquivoDe));
106
+ camadas[i] = arq;
107
+ }
108
+ if (estados.length || !fs.existsSync(vazio)) {
109
+ const job = path.join(dir, `job-${process.pid}.json`);
110
+ fs.writeFileSync(job, JSON.stringify({ width: W, height: H, estados: estados.map(({ file, items }) => ({ file, items })), vazio }));
111
+ const r = await rodarPython(python(), [TEXTO_PY, 'estados', job], { cwd: path.dirname(TEXTO_PY) });
112
+ try { fs.unlinkSync(job); } catch {}
113
+ if (!r.ok) return { camadas: {}, avisos: [`os textos não foram desenhados: ${r.error}`] };
114
+ }
115
+ return { camadas, avisos: [], estados: estados.length };
116
+ }
117
+
118
+ // ── rodar o ffmpeg ───────────────────────────────────────────────────────
119
+
120
+ async function rodarGrafo(pasta, g, saida) {
121
+ const arq = path.join(pasta, 'cache', `grafo-${process.pid}.txt`);
122
+ fs.mkdirSync(path.dirname(arq), { recursive: true });
123
+ fs.writeFileSync(arq, g.grafo);
124
+ // O grafo vai por arquivo: com cinquenta clipes ele passa do teto de
125
+ // linha de comando do Windows (32 mil caracteres).
126
+ const flag = versaoMaior() >= 7 ? ['-/filter_complex', arq] : ['-filter_complex_script', arq];
127
+ const args = ['-hide_banner', '-loglevel', 'error', '-y', ...g.inputs.flat(), ...flag, ...saida];
128
+ const r = await video.rodar(video.ffmpeg(), args, { timeout: 60 * 60000 });
129
+ if (r.ok) { try { fs.unlinkSync(arq); } catch {} }
130
+ return { ...r, grafo: arq };
131
+ }
132
+
133
+ async function exportar(pasta, tl, { out, from = 0, to, preview = false, crf = 20 } = {}) {
134
+ const c = conferir();
135
+ if (!c.ok) return c;
136
+ const escala = preview ? 0.5 : 1;
137
+ const ate = to === undefined ? T.duracao(tl) : Number(to);
138
+ const tx = await camadasDeTexto(pasta, tl, { from, to: ate, escala });
139
+ const g = F.montarGrafo(tl, { from, to: ate, camadasDeTexto: tx.camadas, escala });
140
+ const destino = path.resolve(out || path.join(pasta, 'saida', `${tl.nome || 'video'}${preview ? '-preview' : ''}.mp4`));
141
+ fs.mkdirSync(path.dirname(destino), { recursive: true });
142
+ const inicio = Date.now();
143
+ const r = await rodarGrafo(pasta, g, [
144
+ '-map', g.saidaV, '-map', g.saidaA, '-t', F.num(g.segundos), '-r', String(tl.fps),
145
+ ...argsDoCodec(codecDeVideo(), crf), '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', destino,
146
+ ]);
147
+ if (!r.ok) return { ok: false, error: 'o ffmpeg falhou', detalhe: r.erro.slice(-600), grafo: r.grafo };
148
+ return { ok: true, file: destino, s: T.r2(g.segundos), size: `${g.W}x${g.H}`, mb: T.r2(fs.statSync(destino).size / 1048576),
149
+ tookS: Math.round((Date.now() - inicio) / 100) / 10, ...(tx.avisos.length ? { avisos: tx.avisos } : {}) };
150
+ }
151
+
152
+ /** Os quadros pedidos, com a grade 0–1 e o número do quadro, numa folha só. */
153
+ async function inspecionar(pasta, tl, { frames, escala = 0.5, out } = {}) {
154
+ const c = conferir();
155
+ if (!c.ok) return c;
156
+ const dur = T.duracao(tl);
157
+ const lista = [...new Set(frames.map((f) => Math.max(0, Math.min(dur - 1, Math.round(Number(f))))))].slice(0, 12);
158
+ const dir = path.join(pasta, 'cache', 'inspect');
159
+ fs.mkdirSync(dir, { recursive: true });
160
+ const pngs = [];
161
+ const avisos = new Set();
162
+ for (const f of lista) {
163
+ const tx = await camadasDeTexto(pasta, tl, { from: f, to: f + 1, escala });
164
+ tx.avisos.forEach((a) => avisos.add(a));
165
+ const g = F.montarGrafo(tl, { from: f, to: f + 1, camadasDeTexto: tx.camadas, escala, audio: false });
166
+ const png = path.join(dir, `f${f}.png`);
167
+ const r = await rodarGrafo(pasta, g, ['-map', g.saidaV, '-frames:v', '1', '-update', '1', png]);
168
+ if (!r.ok) return { ok: false, error: `o quadro ${f} falhou`, detalhe: r.erro.slice(-500), grafo: r.grafo };
169
+ pngs.push({ png, frame: f, tempo: f / tl.fps });
170
+ }
171
+ const destino = path.resolve(out || path.join(pasta, 'saida', 'inspect.png'));
172
+ fs.mkdirSync(path.dirname(destino), { recursive: true });
173
+ if (await temPillow()) {
174
+ const job = path.join(dir, 'grade.json');
175
+ fs.writeFileSync(job, JSON.stringify({ frames: pngs, out: destino, altura: lista.length > 1 ? 640 : 960 }));
176
+ const r = await rodarPython(python(), [TEXTO_PY, 'grade', job], { cwd: path.dirname(TEXTO_PY) });
177
+ if (r.ok) return { ok: true, png: destino, frames: lista, ...(avisos.size ? { avisos: [...avisos] } : {}) };
178
+ avisos.add(`sem grade: ${r.error}`);
179
+ }
180
+ return { ok: true, pngs: pngs.map((p) => p.png), frames: lista, avisos: [...avisos, 'sem Pillow: quadros sem grade'] };
181
+ }
182
+
183
+ // ── medir áudio ──────────────────────────────────────────────────────────
184
+
185
+ async function silenciosDe(pasta, asset, { threshold = -35, minSilence = 0.5 } = {}) {
186
+ const cache = path.join(pasta, 'midia', `silencio-${asset.id}-${threshold}-${minSilence}.json`);
187
+ if (fs.existsSync(cache)) { try { return JSON.parse(fs.readFileSync(cache, 'utf8')); } catch {} }
188
+ const r = await video.rodar(video.ffmpeg(), ['-hide_banner', '-i', asset.path, '-vn',
189
+ '-af', `silencedetect=noise=${threshold}dB:d=${minSilence}`, '-f', 'null', '-']);
190
+ if (!r.ok) throw T.erro(`o silencedetect falhou: ${r.erro.slice(-200)}`);
191
+ const lista = [];
192
+ let inicio = null;
193
+ for (const linha of r.erro.split('\n')) {
194
+ const i = linha.match(/silence_start:\s*(-?[\d.]+)/);
195
+ if (i) { inicio = Math.max(0, Number(i[1])); continue; }
196
+ const f = linha.match(/silence_end:\s*([\d.]+)/);
197
+ if (f && inicio !== null) { lista.push({ de: inicio, ate: Number(f[1]) }); inicio = null; }
198
+ }
199
+ // Silêncio que vai até o fim do arquivo não tem "silence_end": fecha no fim.
200
+ if (inicio !== null && inicio < asset.seconds) lista.push({ de: inicio, ate: asset.seconds });
201
+ fs.mkdirSync(path.dirname(cache), { recursive: true });
202
+ fs.writeFileSync(cache, JSON.stringify(lista));
203
+ return lista;
204
+ }
205
+
206
+ /** As amostras cruas (mono, Float32) — para achar batida. */
207
+ function amostras(arquivo, taxa = 22050) {
208
+ return new Promise((resolve, reject) => {
209
+ const p = spawn(video.ffmpeg(), ['-hide_banner', '-loglevel', 'error', '-i', arquivo, '-vn', '-ac', '1', '-ar', String(taxa), '-f', 'f32le', '-']);
210
+ const partes = [];
211
+ p.stdout.on('data', (d) => partes.push(d));
212
+ p.on('error', reject);
213
+ p.on('close', (code) => {
214
+ if (code !== 0) return reject(T.erro('não consegui ler o áudio'));
215
+ const b = Buffer.concat(partes);
216
+ resolve(new Float32Array(b.buffer, b.byteOffset, Math.floor(b.length / 4)));
217
+ });
218
+ });
219
+ }
220
+
221
+ module.exports = { conferir, medirAsset, temPillow, camadasDeTexto, exportar, inspecionar, silenciosDe, amostras, codecDeVideo, versaoMaior };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * texto.js — o que cada faixa de texto mostra em cada quadro.
3
+ *
4
+ * O ffmpeg que vem no Homebrew não tem mais drawtext nem libass (conferido
5
+ * em 29/09/2026, ffmpeg 9.0.1): contar com eles é contar com um vídeo sem
6
+ * legenda na máquina de metade das pessoas. Então o texto é desenhado FORA
7
+ * do ffmpeg — em PNG transparente, pelo studio/texto.py (Pillow) — e entra
8
+ * como uma camada por faixa.
9
+ *
10
+ * A animação (popIn, typewriter, wordReveal, highlightPop) é decidida AQUI,
11
+ * quadro a quadro, como dado: "neste quadro a palavra 3 está amarela e 18%
12
+ * maior". Quadros com o mesmo estado viram UM png com duração; uma legenda de
13
+ * três palavras com destaque vira uns quinze pngs, não noventa. O Python só
14
+ * desenha o que recebe — e por isso a animação é testável sem Pillow.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const crypto = require('crypto');
20
+ const T = require('./timeline.js');
21
+
22
+ const ESTILO_PADRAO = {
23
+ font: 'bold', size: 0.048, color: '#FFFFFF', stroke: '#000000', strokeWidth: 0.14,
24
+ highlight: '#FFD400', bg: null, upper: false, align: 'center',
25
+ };
26
+
27
+ const r3 = (n) => Math.round(n * 1000) / 1000;
28
+
29
+ /** As palavras com tempo (quadros relativos ao clipe). Sem tempo: divide igual. */
30
+ function palavrasDo(c) {
31
+ const ws = (c.text.words || []).filter((w) => w && w.t);
32
+ if (ws.length) return ws;
33
+ const partes = String(c.text.content || '').split(/\s+/).filter(Boolean);
34
+ const passo = c.durationFrames / Math.max(1, partes.length);
35
+ return partes.map((t, i) => ({ t, f: Math.round(i * passo), e: Math.round((i + 1) * passo) }));
36
+ }
37
+
38
+ /** O estado de UM clipe de texto no quadro local `lf` — ou null (não aparece). */
39
+ function estado(tl, c, lf) {
40
+ const st = { ...ESTILO_PADRAO, ...(c.text.style || {}) };
41
+ const d = c.durationFrames;
42
+ const anim = c.text.animation || 'none';
43
+ const up = (s) => (st.upper ? String(s).toUpperCase() : String(s));
44
+ let scale = 1;
45
+ let alpha = c.opacity;
46
+ let words = palavrasDo(c).map((w) => ({ t: up(w.t) }));
47
+ const tempos = palavrasDo(c);
48
+
49
+ const entrada = (quadros) => Math.min(1, (lf + 1) / quadros);
50
+ if (anim === 'fade') alpha *= Math.min(entrada(6), (d - lf) / 6);
51
+ if (anim === 'popIn' || anim === 'highlightPop') {
52
+ if (lf < 3) scale = 0.6 + 0.55 * ((lf + 1) / 3);
53
+ else if (lf < 6) scale = 1.15 - 0.05 * (lf - 2);
54
+ alpha *= entrada(2);
55
+ }
56
+ if (anim === 'typewriter') {
57
+ const total = words.reduce((s, w) => s + w.t.length, 0);
58
+ const porLetra = Math.max(0.5, Math.min(2, (0.7 * d) / Math.max(1, total)));
59
+ let sobra = Math.floor((lf + 1) / porLetra);
60
+ words = words.map((w) => {
61
+ const mostra = Math.max(0, Math.min(w.t.length, sobra));
62
+ sobra -= w.t.length;
63
+ return mostra === w.t.length ? w : { ...w, show: w.t.slice(0, mostra) };
64
+ });
65
+ }
66
+ if (anim === 'wordReveal') {
67
+ words = words.map((w, i) => {
68
+ const k = lf - tempos[i].f;
69
+ if (k < 0) return { ...w, a: 0 };
70
+ return k < 3 ? { ...w, s: r3(1.25 - 0.08 * k) } : w;
71
+ });
72
+ }
73
+ if (anim === 'highlightPop') {
74
+ let ativa = -1;
75
+ tempos.forEach((w, i) => { if (lf >= w.f) ativa = i; });
76
+ if (ativa >= 0 && lf < tempos[ativa].e + 6) {
77
+ const k = lf - tempos[ativa].f;
78
+ words[ativa] = { ...words[ativa], c: st.highlight, s: r3(k <= 2 ? 1 + 0.1 * (k + 1) : 1.22) };
79
+ }
80
+ }
81
+ if (c.fadeIn && lf < c.fadeIn) alpha *= (lf + 1) / c.fadeIn;
82
+ if (c.fadeOut && lf >= d - c.fadeOut) alpha *= (d - lf) / c.fadeOut;
83
+ if (alpha <= 0.01) return null;
84
+
85
+ const W = tl.width;
86
+ const H = tl.height;
87
+ const item = {
88
+ cx: r3(c.transform.cx * W), cy: r3(c.transform.cy * H), w: Math.round(c.transform.w * W),
89
+ size: Math.round(st.size * H), font: st.font, color: st.color, align: st.align,
90
+ stroke: st.stroke, sw: Math.round(st.strokeWidth * st.size * H), bg: st.bg,
91
+ scale: r3(scale), alpha: Math.round(alpha * 100) / 100, words,
92
+ };
93
+ if (c.transform.rotation) item.rot = c.transform.rotation;
94
+ return item;
95
+ }
96
+
97
+ /**
98
+ * Os estados de uma faixa na janela [from, to): segmentos consecutivos com o
99
+ * mesmo desenho viram um só. Devolve {segmentos:[{dur, chave}], desenhos}.
100
+ */
101
+ function estadosDaFaixa(tl, faixa, { from = 0, to, escala = 1 } = {}) {
102
+ const ate = to === undefined ? T.duracao(tl) : to;
103
+ const clips = faixa.clips.filter((c) => !c.hidden && T.fim(c) > from && c.startFrame < ate);
104
+ const escalar = (it) => (escala === 1 ? it : {
105
+ ...it, cx: r3(it.cx * escala), cy: r3(it.cy * escala), w: Math.round(it.w * escala),
106
+ size: Math.max(4, Math.round(it.size * escala)), sw: Math.round(it.sw * escala),
107
+ });
108
+ const segmentos = [];
109
+ const desenhos = {};
110
+ let k = 0;
111
+ for (let f = from; f < ate; f++) {
112
+ while (k < clips.length && T.fim(clips[k]) <= f) k++;
113
+ const itens = [];
114
+ for (let j = k; j < clips.length && clips[j].startFrame <= f; j++) {
115
+ const c = clips[j];
116
+ if (f >= T.fim(c)) continue;
117
+ const it = estado(tl, c, f - c.startFrame);
118
+ if (it) itens.push(escalar(it));
119
+ }
120
+ const json = JSON.stringify(itens);
121
+ const chave = itens.length ? crypto.createHash('sha1').update(json).digest('hex').slice(0, 14) : 'vazio';
122
+ if (itens.length) desenhos[chave] = itens;
123
+ const ult = segmentos[segmentos.length - 1];
124
+ if (ult && ult.chave === chave) ult.dur++;
125
+ else segmentos.push({ dur: 1, chave });
126
+ }
127
+ return { segmentos, desenhos };
128
+ }
129
+
130
+ /** O .ffconcat da faixa: cada estado com a duração dele. */
131
+ function ffconcat(segmentos, fps, arquivoDe) {
132
+ const linhas = ['ffconcat version 1.0'];
133
+ for (const s of segmentos) {
134
+ linhas.push(`file '${arquivoDe(s.chave).replace(/'/g, "'\\''")}'`);
135
+ linhas.push(`duration ${(s.dur / fps).toFixed(6)}`);
136
+ }
137
+ // O concat ignora a duração do último arquivo sem uma linha a mais dele.
138
+ if (segmentos.length) linhas.push(`file '${arquivoDe(segmentos[segmentos.length - 1].chave).replace(/'/g, "'\\''")}'`);
139
+ return linhas.join('\n') + '\n';
140
+ }
141
+
142
+ module.exports = { ESTILO_PADRAO, palavrasDo, estado, estadosDaFaixa, ffconcat };