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.
- package/README.md +30 -6
- package/bin/primocode.js +100 -341
- package/lib/act.js +245 -23
- package/lib/browser.js +17 -0
- package/lib/casar.js +108 -0
- package/lib/catalogo.js +6 -2
- package/lib/config.js +10 -3
- package/lib/cursor.js +10 -1
- package/lib/entrada.js +4 -2
- package/lib/estudio/diretor.js +9 -1
- package/lib/estudio/estilo.js +51 -0
- package/lib/estudio/glifos.js +215 -0
- package/lib/estudio/index.js +41 -18
- package/lib/estudio/kit/base.jsx +285 -0
- package/lib/estudio/kit/index.jsx +11 -0
- package/lib/estudio/kit/marca.jsx +741 -0
- package/lib/estudio/kit/vox.jsx +357 -0
- package/lib/estudio/kit.js +72 -0
- package/lib/estudio/lancamento.js +413 -0
- package/lib/estudio/motion.js +2 -5
- package/lib/estudio/plano.js +28 -3
- package/lib/estudio/remotion.js +87 -43
- package/lib/estudio/ritmo.js +77 -0
- package/lib/estudio/vox.js +385 -0
- package/lib/fala.js +1 -94
- package/lib/pipeline.js +55 -56
- package/lib/repeticao.js +3 -1
- package/lib/skills.js +8 -2
- package/lib/snapshot.js +111 -0
- package/lib/tools.js +79 -20
- package/lib/ui.js +13 -6
- package/lib/video/analise.js +89 -0
- package/lib/video/cli.js +74 -0
- package/lib/video/delta.js +90 -0
- package/lib/video/edits.js +379 -0
- package/lib/video/filters.js +247 -0
- package/lib/video/ops.js +322 -0
- package/lib/video/projeto.js +133 -0
- package/lib/video/render.js +221 -0
- package/lib/video/texto.js +142 -0
- package/lib/video/timeline.js +310 -0
- package/lib/video/transcript.js +124 -0
- package/lib/voz.js +75 -223
- package/package.json +2 -3
- package/skills/motion-de-primeira/SKILL.md +3 -4
- package/studio/texto.py +208 -0
- package/voz/primo_voz/__main__.py +96 -236
- package/voz/primo_voz/transcrever.py +31 -1
- package/app/ansi.js +0 -55
- package/app/app.css +0 -500
- package/app/app.js +0 -1017
- package/app/icone.svg +0 -6
- package/app/index.html +0 -220
- package/app/primo-marca.js +0 -192
- package/lib/app.js +0 -615
- package/lib/icone.js +0 -263
- package/lib/instalar-app.js +0 -231
- package/lib/painel.js +0 -291
- package/voz/primo_voz/desperta.py +0 -195
- package/voz/primo_voz/orbe.py +0 -406
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timeline.js — o projeto de edição é UM json: faixas, clipes e marcadores.
|
|
3
|
+
*
|
|
4
|
+
* "Quero um editor de vídeo de verdade, que a IA consiga operar."
|
|
5
|
+
*
|
|
6
|
+
* ── AS DUAS UNIDADES, E QUEM CONVERTE ────────────────────────────────────
|
|
7
|
+
* Na linha do tempo tudo é QUADRO inteiro (startFrame, durationFrames); na
|
|
8
|
+
* mídia de origem tudo é SEGUNDO (o "de 3,2 s a 7,9 s" que a transcrição e o
|
|
9
|
+
* silencedetect devolvem). Quem converte é este módulo — o modelo nunca
|
|
10
|
+
* multiplica por fps. Conta de fps feita de cabeça é onde a legenda escorrega
|
|
11
|
+
* meio segundo e ninguém sabe por quê.
|
|
12
|
+
*
|
|
13
|
+
* Por dentro o corte na origem (trimStartFrame) também fica em quadro do
|
|
14
|
+
* projeto: assim a conta do ripple é soma de inteiro, sem arredondamento
|
|
15
|
+
* acumulando a cada edição.
|
|
16
|
+
*
|
|
17
|
+
* ── A FAIXA 0 É A DE CIMA ────────────────────────────────────────────────
|
|
18
|
+
* Como em qualquer editor visto de cima: a faixa 0 cobre as outras. O
|
|
19
|
+
* render desenha da última para a primeira.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
|
|
26
|
+
const TIPOS_FAIXA = ['video', 'audio', 'text'];
|
|
27
|
+
// Que clipe cabe em que faixa. Imagem mora na faixa de vídeo.
|
|
28
|
+
const CABE = { video: ['video', 'image'], audio: ['audio'], text: ['text'] };
|
|
29
|
+
|
|
30
|
+
const TRANSFORM_PADRAO = { cx: 0.5, cy: 0.5, w: 1, h: 1, rotation: 0 };
|
|
31
|
+
const CROP_PADRAO = { l: 0, t: 0, r: 0, b: 0 };
|
|
32
|
+
const PROPS_ANIMAVEIS = ['zoom', 'cx', 'cy', 'rotation', 'opacity', 'volume'];
|
|
33
|
+
const INTERP = ['linear', 'ease', 'hold'];
|
|
34
|
+
|
|
35
|
+
function novo({ nome = 'video', width = 1080, height = 1920, fps = 30 } = {}) {
|
|
36
|
+
return {
|
|
37
|
+
versao: 1, nome, fps: Number(fps), width: par(width), height: par(height),
|
|
38
|
+
assets: {}, tracks: [], markers: [],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// O x264 recusa dimensão ímpar em yuv420p.
|
|
43
|
+
function par(n) { return Math.max(2, Math.round(Number(n) / 2) * 2); }
|
|
44
|
+
|
|
45
|
+
const clone = (o) => JSON.parse(JSON.stringify(o));
|
|
46
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
47
|
+
|
|
48
|
+
// ── unidades ─────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const quadros = (tl, seg) => Math.round(Number(seg) * tl.fps);
|
|
51
|
+
const segundos = (tl, q) => Number(q) / tl.fps;
|
|
52
|
+
|
|
53
|
+
/** Fim (exclusivo) de um clipe na linha do tempo. */
|
|
54
|
+
const fim = (c) => c.startFrame + c.durationFrames;
|
|
55
|
+
|
|
56
|
+
/** Quanto da origem o clipe consome, em quadros do projeto. */
|
|
57
|
+
const consumo = (c) => Math.round(c.durationFrames * (c.speed || 1));
|
|
58
|
+
|
|
59
|
+
/** A janela da origem, em segundos: [de, ate). */
|
|
60
|
+
function janelaOrigem(tl, c) {
|
|
61
|
+
const de = segundos(tl, c.trimStartFrame || 0);
|
|
62
|
+
return { de, ate: de + segundos(tl, consumo(c)) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Segundo da origem → quadro da linha do tempo, para ESTE clipe (ou null). */
|
|
66
|
+
function origemParaQuadro(tl, c, seg) {
|
|
67
|
+
const { de, ate } = janelaOrigem(tl, c);
|
|
68
|
+
if (seg < de || seg > ate) return null;
|
|
69
|
+
return c.startFrame + Math.round(((seg - de) * tl.fps) / (c.speed || 1));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function duracao(tl) {
|
|
73
|
+
let d = 0;
|
|
74
|
+
for (const t of tl.tracks) for (const c of t.clips) d = Math.max(d, fim(c));
|
|
75
|
+
return d;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── ids ──────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function todosOsClipes(tl) {
|
|
81
|
+
const out = [];
|
|
82
|
+
tl.tracks.forEach((t, i) => t.clips.forEach((c) => out.push({ clip: c, track: i })));
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Id curto de 4 caracteres, sem repetir no projeto. */
|
|
87
|
+
function novoId(tl) {
|
|
88
|
+
const usados = new Set(todosOsClipes(tl).map((x) => x.clip.id));
|
|
89
|
+
for (;;) {
|
|
90
|
+
const id = crypto.randomBytes(3).toString('hex').slice(0, 4);
|
|
91
|
+
if (!usados.has(id) && !/^\d+$/.test(id)) return id;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* A faixa tem id estável: criar uma faixa em cima muda o ÍNDICE de todas as
|
|
96
|
+
outras, e sem id o delta diria que todos os clipes "mudaram de faixa". */
|
|
97
|
+
function novoTrackId(tl) {
|
|
98
|
+
let n = tl.tracks.length + 1;
|
|
99
|
+
while (tl.tracks.some((t) => t.id === 't' + n)) n++;
|
|
100
|
+
return 't' + n;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function novoAssetId(tl) {
|
|
104
|
+
let n = Object.keys(tl.assets).length + 1;
|
|
105
|
+
while (tl.assets['a' + n]) n++;
|
|
106
|
+
return 'a' + n;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Acha o clipe pelo id ou por um começo único dele. */
|
|
110
|
+
function achar(tl, ref) {
|
|
111
|
+
const r = String(ref || '').trim();
|
|
112
|
+
if (!r) throw erro('faltou o id do clipe');
|
|
113
|
+
const todos = todosOsClipes(tl);
|
|
114
|
+
const exato = todos.find((x) => x.clip.id === r);
|
|
115
|
+
if (exato) return exato;
|
|
116
|
+
const casam = todos.filter((x) => x.clip.id.startsWith(r));
|
|
117
|
+
if (casam.length === 1) return casam[0];
|
|
118
|
+
if (!casam.length) throw erro(`não há clipe "${r}"`);
|
|
119
|
+
throw erro(`"${r}" é ambíguo: ${casam.map((x) => x.clip.id).join(', ')}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function acharAsset(tl, ref) {
|
|
123
|
+
if (ref && tl.assets[ref]) return tl.assets[ref];
|
|
124
|
+
const lista = Object.values(tl.assets);
|
|
125
|
+
const porNome = lista.find((a) => a.nome === ref || a.path === ref);
|
|
126
|
+
if (porNome) return porNome;
|
|
127
|
+
if (!ref) {
|
|
128
|
+
const falado = lista.find((a) => a.hasAudio);
|
|
129
|
+
if (falado) return falado;
|
|
130
|
+
}
|
|
131
|
+
throw erro(ref ? `não há mídia "${ref}"` : 'o projeto não tem mídia com áudio');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function erro(msg) { const e = new Error(msg); e.doEditor = true; return e; }
|
|
135
|
+
|
|
136
|
+
// ── clipes ───────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/** Um clipe completo a partir do mínimo, com os padrões preenchidos. */
|
|
139
|
+
function clipe(tl, base) {
|
|
140
|
+
const c = {
|
|
141
|
+
id: base.id || novoId(tl),
|
|
142
|
+
type: base.type,
|
|
143
|
+
startFrame: Math.round(base.startFrame || 0),
|
|
144
|
+
durationFrames: Math.round(base.durationFrames),
|
|
145
|
+
trimStartFrame: Math.round(base.trimStartFrame || 0),
|
|
146
|
+
speed: 1, volume: 1, opacity: 1, fadeIn: 0, fadeOut: 0,
|
|
147
|
+
transform: { ...TRANSFORM_PADRAO }, crop: { ...CROP_PADRAO },
|
|
148
|
+
fit: 'cover', keyframes: {}, effects: [],
|
|
149
|
+
};
|
|
150
|
+
if (base.asset) c.asset = base.asset;
|
|
151
|
+
if (base.type === 'text') c.text = { content: '', style: {}, animation: 'none', words: [] };
|
|
152
|
+
return aplicarProps(c, base);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const NUMEROS = ['speed', 'volume', 'opacity', 'fadeIn', 'fadeOut'];
|
|
156
|
+
|
|
157
|
+
/** Mescla propriedades num clipe (o set_clip_properties). */
|
|
158
|
+
function aplicarProps(c, p) {
|
|
159
|
+
for (const k of NUMEROS) if (p[k] !== undefined) c[k] = Number(p[k]);
|
|
160
|
+
if (p.fadeIn !== undefined) c.fadeIn = Math.round(c.fadeIn);
|
|
161
|
+
if (p.fadeOut !== undefined) c.fadeOut = Math.round(c.fadeOut);
|
|
162
|
+
if (p.transform) c.transform = { ...c.transform, ...p.transform };
|
|
163
|
+
for (const k of ['cx', 'cy', 'w', 'h', 'rotation']) if (p[k] !== undefined) c.transform[k] = Number(p[k]);
|
|
164
|
+
if (p.crop) c.crop = { ...c.crop, ...p.crop };
|
|
165
|
+
if (p.fit) c.fit = p.fit;
|
|
166
|
+
if (p.hidden !== undefined) c.hidden = !!p.hidden;
|
|
167
|
+
if (c.type === 'text') {
|
|
168
|
+
if (p.content !== undefined) c.text.content = String(p.content);
|
|
169
|
+
if (p.style) c.text.style = { ...c.text.style, ...p.style };
|
|
170
|
+
if (p.animation) c.text.animation = p.animation;
|
|
171
|
+
if (Array.isArray(p.words)) c.text.words = p.words;
|
|
172
|
+
}
|
|
173
|
+
return c;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── validação ────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
const ANIMACOES = ['none', 'fade', 'popIn', 'typewriter', 'wordReveal', 'highlightPop'];
|
|
179
|
+
|
|
180
|
+
/** Lista os problemas. Vazia = pode salvar. */
|
|
181
|
+
function problemas(tl) {
|
|
182
|
+
const p = [];
|
|
183
|
+
if (!(tl.fps > 0) || !(tl.width > 0) || !(tl.height > 0)) p.push('fps/width/height inválidos');
|
|
184
|
+
tl.tracks.forEach((t, ti) => {
|
|
185
|
+
if (!TIPOS_FAIXA.includes(t.type)) p.push(`faixa ${ti}: tipo "${t.type}"`);
|
|
186
|
+
let ultimo = null;
|
|
187
|
+
for (const c of t.clips) {
|
|
188
|
+
const onde = `clipe ${c.id} (faixa ${ti})`;
|
|
189
|
+
if (!CABE[t.type] || !CABE[t.type].includes(c.type)) p.push(`${onde}: ${c.type} não cabe em faixa ${t.type}`);
|
|
190
|
+
for (const k of ['startFrame', 'durationFrames', 'trimStartFrame']) {
|
|
191
|
+
if (!Number.isInteger(c[k]) || c[k] < 0) p.push(`${onde}: ${k}=${c[k]} precisa ser inteiro >= 0`);
|
|
192
|
+
}
|
|
193
|
+
if (c.durationFrames < 1) p.push(`${onde}: duração zero`);
|
|
194
|
+
if (!(c.speed > 0 && c.speed <= 16)) p.push(`${onde}: speed ${c.speed} fora de (0,16]`);
|
|
195
|
+
if (c.asset && !tl.assets[c.asset]) p.push(`${onde}: mídia ${c.asset} não existe`);
|
|
196
|
+
if (c.asset && tl.assets[c.asset] && tl.assets[c.asset].seconds && c.type !== 'image') {
|
|
197
|
+
const sobra = segundos(tl, (c.trimStartFrame || 0) + consumo(c)) - tl.assets[c.asset].seconds;
|
|
198
|
+
if (sobra > 0.1) p.push(`${onde}: passa ${r2(sobra)}s do fim da mídia`);
|
|
199
|
+
}
|
|
200
|
+
for (const [k, v] of Object.entries(c.transform)) if (!Number.isFinite(v)) p.push(`${onde}: transform.${k} inválido`);
|
|
201
|
+
for (const [prop, keys] of Object.entries(c.keyframes || {})) {
|
|
202
|
+
if (!PROPS_ANIMAVEIS.includes(prop)) p.push(`${onde}: keyframe de "${prop}" não existe (use ${PROPS_ANIMAVEIS.join('/')})`);
|
|
203
|
+
for (const k of keys) if (!Number.isFinite(k.value) || !Number.isInteger(k.frame)) p.push(`${onde}: keyframe inválido`);
|
|
204
|
+
}
|
|
205
|
+
if (c.type === 'text' && !ANIMACOES.includes(c.text.animation)) p.push(`${onde}: animação "${c.text.animation}" (use ${ANIMACOES.join('/')})`);
|
|
206
|
+
if (ultimo && c.startFrame < fim(ultimo)) p.push(`${onde}: sobrepõe ${ultimo.id} na mesma faixa (quadros ${c.startFrame}-${fim(ultimo)})`);
|
|
207
|
+
ultimo = c;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
return p;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function ordenar(tl) {
|
|
214
|
+
for (const t of tl.tracks) t.clips.sort((a, b) => a.startFrame - b.startFrame);
|
|
215
|
+
return tl;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── faixas ───────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A faixa onde um clipe deste tipo entra. `pedida` é o índice que o modelo
|
|
222
|
+
* mandou (ou undefined / "new"). Texto nasce em cima, áudio embaixo.
|
|
223
|
+
*/
|
|
224
|
+
function faixaPara(tl, tipo, pedida) {
|
|
225
|
+
const tipoFaixa = tipo === 'image' ? 'video' : tipo;
|
|
226
|
+
if (pedida !== undefined && pedida !== 'new') {
|
|
227
|
+
const i = Number(pedida);
|
|
228
|
+
if (!tl.tracks[i]) throw erro(`não há faixa ${i} (há ${tl.tracks.length})`);
|
|
229
|
+
if (!CABE[tl.tracks[i].type].includes(tipo)) throw erro(`faixa ${i} é de ${tl.tracks[i].type}, não aceita ${tipo}`);
|
|
230
|
+
return i;
|
|
231
|
+
}
|
|
232
|
+
if (pedida !== 'new') {
|
|
233
|
+
// Texto: a de texto mais de cima. Vídeo: a de vídeo mais de baixo
|
|
234
|
+
// (é a base). Áudio: a de áudio mais de cima.
|
|
235
|
+
const idx = tl.tracks.map((t, i) => (t.type === tipoFaixa ? i : -1)).filter((i) => i >= 0);
|
|
236
|
+
if (idx.length) return tipoFaixa === 'video' ? idx[idx.length - 1] : idx[0];
|
|
237
|
+
}
|
|
238
|
+
const faixa = { id: novoTrackId(tl), type: tipoFaixa, clips: [] };
|
|
239
|
+
if (tipoFaixa === 'text') { tl.tracks.unshift(faixa); return 0; }
|
|
240
|
+
if (tipoFaixa === 'audio') { tl.tracks.push(faixa); return tl.tracks.length - 1; }
|
|
241
|
+
// Vídeo novo entra ACIMA dos vídeos que já existem (é o que se quer num
|
|
242
|
+
// "põe esse b-roll por cima"), mas abaixo dos textos.
|
|
243
|
+
const primeiroVideo = tl.tracks.findIndex((t) => t.type !== 'text');
|
|
244
|
+
const onde = primeiroVideo < 0 ? tl.tracks.length : primeiroVideo;
|
|
245
|
+
tl.tracks.splice(onde, 0, faixa);
|
|
246
|
+
return onde;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── a vista compacta ─────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/** O clipe em poucas letras: só o que difere do padrão. */
|
|
252
|
+
function compacto(tl, c, track) {
|
|
253
|
+
const o = { id: c.id, t: track, type: c.type, at: c.startFrame, dur: c.durationFrames };
|
|
254
|
+
if (c.type === 'video') delete o.type;
|
|
255
|
+
if (c.asset) {
|
|
256
|
+
o.asset = c.asset;
|
|
257
|
+
if (c.type !== 'image') { const j = janelaOrigem(tl, c); o.src = [r2(j.de), r2(j.ate)]; }
|
|
258
|
+
}
|
|
259
|
+
if (c.speed !== 1) o.speed = c.speed;
|
|
260
|
+
if (c.volume !== 1) o.volume = c.volume;
|
|
261
|
+
if (c.opacity !== 1) o.opacity = c.opacity;
|
|
262
|
+
if (c.fadeIn) o.fadeIn = c.fadeIn;
|
|
263
|
+
if (c.fadeOut) o.fadeOut = c.fadeOut;
|
|
264
|
+
if (c.hidden) o.hidden = true;
|
|
265
|
+
const tf = {};
|
|
266
|
+
for (const [k, v] of Object.entries(c.transform)) if (v !== TRANSFORM_PADRAO[k]) tf[k] = Math.round(v * 1000) / 1000;
|
|
267
|
+
if (Object.keys(tf).length) o.xf = tf;
|
|
268
|
+
if (Object.values(c.crop).some((v) => v)) o.crop = c.crop;
|
|
269
|
+
if (c.fit !== 'cover') o.fit = c.fit;
|
|
270
|
+
const kf = Object.keys(c.keyframes || {}).filter((k) => c.keyframes[k].length);
|
|
271
|
+
if (kf.length) o.kf = kf;
|
|
272
|
+
if (c.effects.length) o.fx = c.effects.map((e) => e.type);
|
|
273
|
+
if (c.text) {
|
|
274
|
+
o.text = c.text.content.length > 48 ? c.text.content.slice(0, 45) + '...' : c.text.content;
|
|
275
|
+
if (c.text.animation !== 'none') o.anim = c.text.animation;
|
|
276
|
+
}
|
|
277
|
+
return o;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** get_timeline: o projeto inteiro (ou um trecho) sem nada que seja padrão. */
|
|
281
|
+
function vista(tl, { from = 0, to = Infinity } = {}) {
|
|
282
|
+
const tracks = tl.tracks.map((t, i) => {
|
|
283
|
+
const clips = t.clips.filter((c) => fim(c) > from && c.startFrame < to).map((c) => {
|
|
284
|
+
const o = compacto(tl, c, i);
|
|
285
|
+
delete o.t;
|
|
286
|
+
if (t.type === 'text' && o.type === 'text') delete o.type;
|
|
287
|
+
if (t.type === 'audio' && o.type === 'audio') delete o.type;
|
|
288
|
+
return o;
|
|
289
|
+
});
|
|
290
|
+
const f = { i, type: t.type, n: t.clips.length, clips };
|
|
291
|
+
if (t.muted) f.muted = true;
|
|
292
|
+
return f;
|
|
293
|
+
});
|
|
294
|
+
const assets = {};
|
|
295
|
+
for (const a of Object.values(tl.assets)) {
|
|
296
|
+
assets[a.id] = { nome: a.nome, kind: a.kind, s: r2(a.seconds || 0) };
|
|
297
|
+
if (a.width) assets[a.id].size = `${a.width}x${a.height}`;
|
|
298
|
+
if (!a.hasAudio && a.kind === 'video') assets[a.id].mudo = true;
|
|
299
|
+
}
|
|
300
|
+
const out = { fps: tl.fps, size: `${tl.width}x${tl.height}`, dur: duracao(tl), durS: r2(segundos(tl, duracao(tl))), assets, tracks };
|
|
301
|
+
if (tl.markers.length) out.markers = tl.markers.filter((m) => m.frame >= from && m.frame < to).slice(0, 60);
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = {
|
|
306
|
+
TIPOS_FAIXA, CABE, TRANSFORM_PADRAO, CROP_PADRAO, PROPS_ANIMAVEIS, INTERP, ANIMACOES,
|
|
307
|
+
novo, clone, r2, par, quadros, segundos, fim, consumo, janelaOrigem, origemParaQuadro, duracao,
|
|
308
|
+
todosOsClipes, novoId, novoAssetId, achar, acharAsset, erro,
|
|
309
|
+
clipe, aplicarProps, problemas, ordenar, faixaPara, compacto, vista,
|
|
310
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcript.js — cada palavra dita, com o tempo dela, e onde ela está
|
|
3
|
+
* AGORA na linha do tempo.
|
|
4
|
+
*
|
|
5
|
+
* A transcrição é da MÍDIA (segundos da origem) e fica guardada uma vez por
|
|
6
|
+
* mídia. O que muda a cada edição é o mapa: depois de cortar um silêncio, a
|
|
7
|
+
* palavra 42 continua sendo a 42, mas começa 38 quadros antes. É por isso
|
|
8
|
+
* que o índice da palavra é estável — o modelo pode pedir "apaga as palavras
|
|
9
|
+
* 12 a 15" com a lista que leu três edições atrás.
|
|
10
|
+
*
|
|
11
|
+
* ── DE ONDE VEM ──────────────────────────────────────────────────────────
|
|
12
|
+
* 1. o faster-whisper local (voz/primo_voz/transcrever.py), se instalado:
|
|
13
|
+
* sem rede, sem chave, e o áudio não sai da máquina;
|
|
14
|
+
* 2. o /api/transcrever do servidor do PrimoCode (o mesmo do Estúdio).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { spawn } = require('child_process');
|
|
22
|
+
const T = require('./timeline.js');
|
|
23
|
+
|
|
24
|
+
const VOZ = path.join(__dirname, '..', '..', 'voz');
|
|
25
|
+
|
|
26
|
+
/** O mapa: cada palavra da mídia que aparece na linha do tempo, em quadros. */
|
|
27
|
+
function mapear(tl, assetId, palavras) {
|
|
28
|
+
const out = [];
|
|
29
|
+
tl.tracks.forEach((t, ti) => {
|
|
30
|
+
if (t.type === 'text') return;
|
|
31
|
+
for (const c of t.clips) {
|
|
32
|
+
if (c.asset !== assetId) continue;
|
|
33
|
+
const { de, ate } = T.janelaOrigem(tl, c);
|
|
34
|
+
palavras.forEach((p, i) => {
|
|
35
|
+
const meio = (p.de + p.ate) / 2;
|
|
36
|
+
if (meio < de || meio >= ate) return;
|
|
37
|
+
const f = T.origemParaQuadro(tl, c, Math.max(de, p.de));
|
|
38
|
+
const e = T.origemParaQuadro(tl, c, Math.min(ate, p.ate));
|
|
39
|
+
out.push({ i, texto: p.texto, f, e: Math.max(f + 1, e), clip: c.id, t: ti });
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
// A mesma palavra em dois clipes (clipe duplicado) aparece duas vezes: fica a de cima.
|
|
44
|
+
const vistos = new Set();
|
|
45
|
+
return out.sort((a, b) => a.f - b.f || a.t - b.t).filter((w) => {
|
|
46
|
+
const k = `${w.i}@${w.f}`;
|
|
47
|
+
if (vistos.has(k)) return false;
|
|
48
|
+
vistos.add(k);
|
|
49
|
+
return true;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rodarPython(py, args, { cwd, timeout = 20 * 60000 } = {}) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const p = spawn(py, args, { cwd, env: { ...process.env, PYTHONPATH: cwd, PYTHONWARNINGS: 'ignore' } });
|
|
56
|
+
let out = '';
|
|
57
|
+
let err = '';
|
|
58
|
+
p.stdout.on('data', (d) => { out += d; });
|
|
59
|
+
p.stderr.on('data', (d) => { err += d; });
|
|
60
|
+
const rel = setTimeout(() => { try { p.kill(); } catch {} }, timeout);
|
|
61
|
+
p.on('error', (e) => { clearTimeout(rel); resolve({ ok: false, error: e.message }); });
|
|
62
|
+
p.on('close', () => {
|
|
63
|
+
clearTimeout(rel);
|
|
64
|
+
const linha = out.trim().split('\n').pop() || '';
|
|
65
|
+
try { resolve(JSON.parse(linha)); } catch { resolve({ ok: false, error: (err || out).slice(-300) || 'sem resposta' }); }
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function local(arquivo, idioma) {
|
|
71
|
+
const py = require('../voz.js').python();
|
|
72
|
+
if (!py) return { ok: false, error: 'sem python3' };
|
|
73
|
+
return rodarPython(py, ['-m', 'primo_voz.transcrever', 'palavras', arquivo, idioma], { cwd: VOZ });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function servidor(arquivo, idioma, pastaTmp) {
|
|
77
|
+
const cfg = require('../config.js').loadConfig();
|
|
78
|
+
if (!cfg.server || !cfg.token) return { ok: false, error: 'sem servidor/conta configurados' };
|
|
79
|
+
const video = require('../estudio/video.js');
|
|
80
|
+
const mp3 = path.join(pastaTmp, `audio-${Date.now().toString(36)}.mp3`);
|
|
81
|
+
const ex = await video.extrairAudio(arquivo, mp3);
|
|
82
|
+
if (!ex.ok) return ex;
|
|
83
|
+
try {
|
|
84
|
+
const r = await require('../api.js').requestTranscricao(cfg.server, {
|
|
85
|
+
token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: path.basename(mp3), idioma,
|
|
86
|
+
});
|
|
87
|
+
if (!r || r.ok === false) return { ok: false, error: (r && (r.erro || r.error)) || 'a transcrição falhou' };
|
|
88
|
+
return { ok: true, palavras: r.palavras || [] };
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return { ok: false, error: e.message };
|
|
91
|
+
} finally {
|
|
92
|
+
try { fs.unlinkSync(mp3); } catch {}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A transcrição da mídia (do cache, ou feita agora). */
|
|
97
|
+
async function obter(pasta, asset, { force = false, idioma = 'pt' } = {}) {
|
|
98
|
+
const cache = path.join(pasta, 'midia', `transcricao-${asset.id}.json`);
|
|
99
|
+
if (!force && fs.existsSync(cache)) {
|
|
100
|
+
try { return { ok: true, ...JSON.parse(fs.readFileSync(cache, 'utf8')), cache: true }; } catch { /* refaz */ }
|
|
101
|
+
}
|
|
102
|
+
if (!asset.hasAudio) return { ok: false, error: `a mídia ${asset.id} não tem áudio` };
|
|
103
|
+
const tentativas = [];
|
|
104
|
+
let r = await local(asset.path, idioma);
|
|
105
|
+
let fonte = 'whisper-local';
|
|
106
|
+
if (!r.ok) {
|
|
107
|
+
tentativas.push(`local: ${r.error}`);
|
|
108
|
+
r = await servidor(asset.path, idioma, path.join(pasta, 'midia'));
|
|
109
|
+
fonte = 'servidor';
|
|
110
|
+
}
|
|
111
|
+
if (!r.ok) {
|
|
112
|
+
tentativas.push(`servidor: ${r.error}`);
|
|
113
|
+
return { ok: false, error: 'não consegui transcrever', tentativas,
|
|
114
|
+
comoResolver: 'python3 -m pip install faster-whisper (local), ou entre na conta do PrimoCode' };
|
|
115
|
+
}
|
|
116
|
+
const palavras = (r.palavras || []).filter((p) => p && p.texto && p.ate > p.de)
|
|
117
|
+
.map((p) => ({ de: Number(p.de), ate: Number(p.ate), texto: String(p.texto).trim() }));
|
|
118
|
+
const dados = { asset: asset.id, fonte, idioma, palavras };
|
|
119
|
+
fs.mkdirSync(path.dirname(cache), { recursive: true });
|
|
120
|
+
fs.writeFileSync(cache, JSON.stringify(dados));
|
|
121
|
+
return { ok: true, ...dados };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { mapear, obter, rodarPython, VOZ };
|