primocode 9.10.0 → 10.0.9

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 (89) hide show
  1. package/README.md +30 -6
  2. package/bin/primocode.js +169 -342
  3. package/lib/act.js +360 -39
  4. package/lib/browser.js +105 -4
  5. package/lib/casar.js +108 -0
  6. package/lib/catalogo.js +6 -2
  7. package/lib/claude-engine.js +38 -17
  8. package/lib/codex-engine.js +27 -9
  9. package/lib/config.js +10 -3
  10. package/lib/cursor.js +10 -1
  11. package/lib/entrada.js +77 -11
  12. package/lib/estudio/cinema.js +27 -9
  13. package/lib/estudio/diretor.js +21 -1
  14. package/lib/estudio/ende.js +16 -5
  15. package/lib/estudio/estilo.js +66 -0
  16. package/lib/estudio/gerativo.js +1453 -0
  17. package/lib/estudio/glifos.js +215 -0
  18. package/lib/estudio/hibrido.js +165 -0
  19. package/lib/estudio/index.js +284 -53
  20. package/lib/estudio/kit/base.jsx +285 -0
  21. package/lib/estudio/kit/gerativo.jsx +980 -0
  22. package/lib/estudio/kit/index.jsx +13 -0
  23. package/lib/estudio/kit/marca.jsx +741 -0
  24. package/lib/estudio/kit/tres.jsx +308 -0
  25. package/lib/estudio/kit/vox.jsx +357 -0
  26. package/lib/estudio/kit.js +78 -0
  27. package/lib/estudio/lancamento.js +441 -0
  28. package/lib/estudio/motion.js +2 -5
  29. package/lib/estudio/plano.js +28 -3
  30. package/lib/estudio/referencias.js +105 -0
  31. package/lib/estudio/remotion.js +139 -51
  32. package/lib/estudio/ritmo.js +77 -0
  33. package/lib/estudio/sons-leves.js +38 -1
  34. package/lib/estudio/variedade.js +105 -0
  35. package/lib/estudio/vox.js +414 -0
  36. package/lib/fala.js +1 -94
  37. package/lib/mcp.js +156 -0
  38. package/lib/personas.js +67 -0
  39. package/lib/pipeline.js +55 -56
  40. package/lib/repeticao.js +3 -1
  41. package/lib/skills.js +44 -2
  42. package/lib/snapshot.js +111 -0
  43. package/lib/tools.js +100 -22
  44. package/lib/ui.js +50 -8
  45. package/lib/video/analise.js +89 -0
  46. package/lib/video/auto.js +329 -0
  47. package/lib/video/camadas.js +207 -0
  48. package/lib/video/cli.js +74 -0
  49. package/lib/video/delta.js +90 -0
  50. package/lib/video/edits.js +379 -0
  51. package/lib/video/filters.js +280 -0
  52. package/lib/video/fx3d/Camadas.jsx +64 -0
  53. package/lib/video/fx3d/Objetos.jsx +128 -0
  54. package/lib/video/fx3d/Root.jsx +18 -0
  55. package/lib/video/fx3d/modelos.jsx +304 -0
  56. package/lib/video/fx3d.js +108 -0
  57. package/lib/video/gerados.js +119 -0
  58. package/lib/video/objetos.js +111 -0
  59. package/lib/video/ops-fx.js +453 -0
  60. package/lib/video/ops.js +347 -0
  61. package/lib/video/paralelo.js +50 -0
  62. package/lib/video/pedido.js +191 -0
  63. package/lib/video/projeto.js +133 -0
  64. package/lib/video/rastrear.js +95 -0
  65. package/lib/video/rastro.js +276 -0
  66. package/lib/video/render.js +274 -0
  67. package/lib/video/texto.js +144 -0
  68. package/lib/video/timeline.js +318 -0
  69. package/lib/video/transcript.js +124 -0
  70. package/lib/voz.js +75 -223
  71. package/package.json +2 -3
  72. package/skills/motion-de-primeira/SKILL.md +5 -5
  73. package/studio/narrar.py +10 -7
  74. package/studio/rastrear.py +399 -0
  75. package/studio/texto.py +208 -0
  76. package/voz/primo_voz/__main__.py +96 -236
  77. package/voz/primo_voz/transcrever.py +31 -1
  78. package/app/ansi.js +0 -55
  79. package/app/app.css +0 -500
  80. package/app/app.js +0 -1017
  81. package/app/icone.svg +0 -6
  82. package/app/index.html +0 -220
  83. package/app/primo-marca.js +0 -192
  84. package/lib/app.js +0 -615
  85. package/lib/icone.js +0 -263
  86. package/lib/instalar-app.js +0 -231
  87. package/lib/painel.js +0 -291
  88. package/voz/primo_voz/desperta.py +0 -195
  89. package/voz/primo_voz/orbe.py +0 -406
@@ -0,0 +1,90 @@
1
+ /**
2
+ * delta.js — toda edição devolve O QUE MUDOU, não o projeto inteiro.
3
+ *
4
+ * Um corte de silêncio num vídeo de três minutos mexe em quarenta clipes.
5
+ * Devolver a linha do tempo inteira a cada passo enche o contexto do modelo
6
+ * em cinco edições; devolver "sumiram estes, estes andaram 45 quadros para a
7
+ * esquerda" cabe numa linha.
8
+ *
9
+ * O delta sai da COMPARAÇÃO do antes com o depois, e não de cada operação
10
+ * anotando o que fez. É de propósito: uma operação que esquece de anotar um
11
+ * clipe deslocado mentiria para o modelo, e ele planejaria o passo seguinte
12
+ * em cima de um quadro errado. Comparando, o delta é certo por construção.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const T = require('./timeline.js');
18
+
19
+ const MAX_LISTA = 12;
20
+
21
+ function indexar(tl) {
22
+ const m = new Map();
23
+ tl.tracks.forEach((t, i) => t.clips.forEach((c) => m.set(c.id, { c, t: i, tid: t.id || i })));
24
+ return m;
25
+ }
26
+
27
+ // O clipe sem posição: se só a posição mudou, é deslocamento, não mudança.
28
+ function semPosicao(c) {
29
+ const { startFrame, ...resto } = c;
30
+ return JSON.stringify(resto);
31
+ }
32
+
33
+ function diff(antes, depois, extra = {}) {
34
+ const a = indexar(antes);
35
+ const d = indexar(depois);
36
+ const added = [];
37
+ const removed = [];
38
+ const changed = [];
39
+ const deslocados = new Map(); // por quanto andou → ids
40
+
41
+ for (const [id, x] of d) {
42
+ const y = a.get(id);
43
+ if (!y) { added.push(T.compacto(depois, x.c, x.t)); continue; }
44
+ const mesmaCoisa = semPosicao(y.c) === semPosicao(x.c);
45
+ if (mesmaCoisa && y.tid === x.tid && y.c.startFrame === x.c.startFrame) continue;
46
+ if (mesmaCoisa && y.tid === x.tid) {
47
+ const by = x.c.startFrame - y.c.startFrame;
48
+ if (!deslocados.has(by)) deslocados.set(by, []);
49
+ deslocados.get(by).push(id);
50
+ continue;
51
+ }
52
+ changed.push(camposMudados(antes, depois, y, x));
53
+ }
54
+ for (const id of a.keys()) if (!d.has(id)) removed.push(id);
55
+
56
+ const out = { ok: true };
57
+ // Cinquenta legendas novas não cabem num delta: as primeiras e a conta.
58
+ if (added.length) out.added = added.slice(0, MAX_LISTA);
59
+ if (added.length > MAX_LISTA) out.addedMore = added.length - MAX_LISTA;
60
+ if (removed.length) out.removed = removed;
61
+ if (changed.length) out.changed = changed.slice(0, MAX_LISTA);
62
+ if (changed.length > MAX_LISTA) out.changedMore = changed.length - MAX_LISTA;
63
+ if (deslocados.size) out.shifted = [...deslocados].map(([by, ids]) => ({ by, ids }));
64
+ if (antes.tracks.map((t) => t.id).join() !== depois.tracks.map((t) => t.id).join()) out.tracks = depois.tracks.map((t) => t.type);
65
+ const d0 = T.duracao(antes);
66
+ const d1 = T.duracao(depois);
67
+ out.dur = d1;
68
+ if (d0 !== d1) out.durWas = d0;
69
+ if (antes.markers.length !== depois.markers.length) out.markers = depois.markers.length;
70
+ const assetsNovos = Object.keys(depois.assets).filter((k) => !antes.assets[k]);
71
+ if (assetsNovos.length) out.assets = assetsNovos.map((k) => ({ id: k, nome: depois.assets[k].nome, s: T.r2(depois.assets[k].seconds || 0) }));
72
+ if (!added.length && !removed.length && !changed.length && !deslocados.size && !assetsNovos.length && out.markers === undefined) out.nada = true;
73
+ return { ...out, ...extra };
74
+ }
75
+
76
+ /** Só as chaves da vista compacta que mudaram (e o id). */
77
+ function camposMudados(antes, depois, y, x) {
78
+ const va = T.compacto(antes, y.c, y.t);
79
+ const vd = T.compacto(depois, x.c, x.t);
80
+ const o = { id: x.c.id };
81
+ for (const k of new Set([...Object.keys(va), ...Object.keys(vd)])) {
82
+ if (k === 'id') continue;
83
+ if (JSON.stringify(va[k]) !== JSON.stringify(vd[k])) o[k] = vd[k] === undefined ? null : vd[k];
84
+ }
85
+ // Mudou algo que a vista compacta não mostra (estilo do texto, keyframe…).
86
+ if (Object.keys(o).length === 1) o.props = true;
87
+ return o;
88
+ }
89
+
90
+ module.exports = { diff };
@@ -0,0 +1,379 @@
1
+ /**
2
+ * edits.js — as edições, como funções puras sobre o json da linha do tempo.
3
+ *
4
+ * Nada aqui toca disco nem ffmpeg: entra timeline, sai timeline mexida (ou
5
+ * um erro dizendo por quê). É isso que deixa o ripple ser testado em
6
+ * milissegundos, e é isso que deixa a edição ser atômica — quem salva é o
7
+ * ops.js, e só se a função inteira terminou sem erro.
8
+ *
9
+ * ── O RIPPLE ─────────────────────────────────────────────────────────────
10
+ * Apagar um trecho [a, b) da linha do tempo faz, em cada clipe das faixas
11
+ * afetadas, uma de cinco coisas: nada (está antes), andar b−a para a
12
+ * esquerda (está depois), sumir (está dentro), perder a ponta (cruza uma
13
+ * borda) ou virar dois (o trecho está no meio dele). Com vários trechos, a
14
+ * conta vai do ÚLTIMO para o primeiro: assim apagar um trecho nunca muda a
15
+ * posição dos que ainda faltam apagar.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const T = require('./timeline.js');
21
+ const { erro } = T;
22
+
23
+ // ── adicionar ────────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Um clipe a partir do que o modelo manda. Mídia: `asset` (id), `from`/`to`
27
+ * em SEGUNDOS da origem ou `dur` em quadros. Posição: `at` em quadros
28
+ * (padrão: o fim da faixa).
29
+ */
30
+ function montarClipe(tl, spec) {
31
+ const tipo = spec.type || (spec.asset ? (T.acharAsset(tl, spec.asset).kind) : null);
32
+ if (!tipo) throw erro('clipe sem "asset" nem "type"');
33
+ const base = { ...spec, type: tipo };
34
+ if (spec.asset) {
35
+ const a = T.acharAsset(tl, spec.asset);
36
+ base.asset = a.id;
37
+ if (tipo !== 'image') {
38
+ const de = Number(spec.from || 0);
39
+ const speed = Number(spec.speed || 1);
40
+ base.trimStartFrame = T.quadros(tl, de);
41
+ let dur = spec.dur !== undefined ? Number(spec.dur) : null;
42
+ if (dur === null) {
43
+ const ate = spec.to !== undefined ? Number(spec.to) : a.seconds;
44
+ if (!(ate > de)) throw erro(`"to" (${ate}) precisa ser maior que "from" (${de})`);
45
+ dur = Math.round(T.quadros(tl, ate - de) / speed);
46
+ }
47
+ base.durationFrames = dur;
48
+ } else {
49
+ base.durationFrames = spec.dur !== undefined ? Number(spec.dur) : tl.fps * 3;
50
+ }
51
+ } else {
52
+ base.durationFrames = spec.dur !== undefined ? Number(spec.dur) : tl.fps * 2;
53
+ }
54
+ delete base.id;
55
+ return T.clipe(tl, base);
56
+ }
57
+
58
+ /* Sem faixa pedida e com posição: a primeira faixa do tipo onde o clipe
59
+ CABE sem sobrepor (a da legenda fica de fora). Não coube, faixa nova. */
60
+ function faixaLivre(tl, c, at) {
61
+ const tipo = c.type === 'image' ? 'video' : c.type;
62
+ const livre = tl.tracks.findIndex((t) => t.type === tipo && t.role !== 'captions'
63
+ && !t.clips.some((x) => x.startFrame < at + c.durationFrames && T.fim(x) > at));
64
+ return livre >= 0 ? livre : 'new';
65
+ }
66
+
67
+ function adicionar(tl, spec) {
68
+ const c = montarClipe(tl, spec);
69
+ const pedida = spec.track === undefined && spec.at !== undefined ? faixaLivre(tl, c, Math.round(Number(spec.at))) : spec.track;
70
+ const ti = T.faixaPara(tl, c.type, pedida);
71
+ const faixa = tl.tracks[ti];
72
+ c.startFrame = spec.at !== undefined ? Math.round(Number(spec.at))
73
+ : faixa.clips.reduce((m, x) => Math.max(m, T.fim(x)), 0);
74
+ faixa.clips.push(c);
75
+ T.ordenar(tl);
76
+ return c;
77
+ }
78
+
79
+ // ── dividir e cortar ─────────────────────────────────────────────────────
80
+
81
+ /** A parte [ps, pe) (quadros da linha do tempo) de um clipe, como clipe novo. */
82
+ function pedaco(tl, c, ps, pe, id) {
83
+ const o = ps - c.startFrame;
84
+ const p = T.clone(c);
85
+ p.id = id;
86
+ p.startFrame = ps;
87
+ p.durationFrames = pe - ps;
88
+ if (c.type !== 'image' && c.type !== 'text') p.trimStartFrame = c.trimStartFrame + Math.round(o * (c.speed || 1));
89
+ if (o > 0) p.fadeIn = 0;
90
+ if (pe < T.fim(c)) p.fadeOut = 0;
91
+ for (const prop of Object.keys(p.keyframes || {})) {
92
+ p.keyframes[prop] = p.keyframes[prop].map((k) => ({ ...k, frame: k.frame - o }));
93
+ }
94
+ if (p.text) {
95
+ p.text.words = (p.text.words || []).filter((w) => w.e > o && w.f < pe - c.startFrame)
96
+ .map((w) => ({ ...w, f: Math.max(0, w.f - o), e: Math.min(pe - ps, w.e - o) }));
97
+ }
98
+ return p;
99
+ }
100
+
101
+ /** Divide no quadro `at` da linha do tempo. Devolve [esquerda, direita]. */
102
+ function dividir(tl, ref, at) {
103
+ const { clip: c, track } = T.achar(tl, ref);
104
+ const f = Math.round(Number(at));
105
+ if (!(f > c.startFrame && f < T.fim(c))) throw erro(`o quadro ${f} não está dentro de ${c.id} (${c.startFrame}-${T.fim(c)})`);
106
+ const esq = pedaco(tl, c, c.startFrame, f, c.id);
107
+ const dir = pedaco(tl, c, f, T.fim(c), T.novoId(tl));
108
+ const faixa = tl.tracks[track];
109
+ faixa.clips.splice(faixa.clips.indexOf(c), 1, esq, dir);
110
+ return [esq, dir];
111
+ }
112
+
113
+ /** O que sobra de um clipe depois de apagar [a, b), já na posição nova. */
114
+ function cortar(tl, c, a, b) {
115
+ const s = c.startFrame;
116
+ const e = T.fim(c);
117
+ const tam = b - a;
118
+ if (e <= a) return [c];
119
+ if (s >= b) { c.startFrame -= tam; return [c]; }
120
+ if (s >= a && e <= b) return [];
121
+ const partes = [];
122
+ if (s < a) partes.push(pedaco(tl, c, s, a, c.id));
123
+ if (e > b) {
124
+ const dir = pedaco(tl, c, b, e, partes.length ? T.novoId(tl) : c.id);
125
+ dir.startFrame = b - tam;
126
+ partes.push(dir);
127
+ }
128
+ // Texto não vira dois: a legenda que cruza o corte só encolhe.
129
+ if (partes.length === 2 && c.type === 'text') {
130
+ const [esq, dir] = partes;
131
+ esq.durationFrames += dir.durationFrames;
132
+ esq.text.words = esq.text.words.concat(dir.text.words.map((w) => ({ ...w, f: w.f + (a - s), e: w.e + (a - s) })));
133
+ esq.fadeOut = c.fadeOut;
134
+ return [esq];
135
+ }
136
+ return partes;
137
+ }
138
+
139
+ /** Junta trechos que se tocam ou se cruzam, em ordem. */
140
+ function juntar(ranges) {
141
+ const lista = ranges.map(([a, b]) => [Math.round(a), Math.round(b)]).filter(([a, b]) => b > a && b > 0)
142
+ .map(([a, b]) => [Math.max(0, a), b]).sort((x, y) => x[0] - y[0]);
143
+ const out = [];
144
+ for (const r of lista) {
145
+ const ult = out[out.length - 1];
146
+ if (ult && r[0] <= ult[1]) ult[1] = Math.max(ult[1], r[1]);
147
+ else out.push([...r]);
148
+ }
149
+ return out;
150
+ }
151
+
152
+ /**
153
+ * Apaga os trechos e fecha o buraco. `tracks`: quais faixas (índices);
154
+ * padrão todas — o que mantém legenda e música no lugar da fala.
155
+ */
156
+ function apagarTrechos(tl, ranges, { tracks } = {}) {
157
+ const lista = juntar(ranges);
158
+ const alvo = tracks ? new Set(tracks.map(Number)) : null;
159
+ for (const [a, b] of [...lista].reverse()) {
160
+ tl.tracks.forEach((t, i) => {
161
+ if (alvo && !alvo.has(i)) return;
162
+ t.clips = t.clips.flatMap((c) => cortar(tl, c, a, b));
163
+ });
164
+ }
165
+ T.ordenar(tl);
166
+ return { ranges: lista, removedFrames: lista.reduce((s, [a, b]) => s + (b - a), 0) };
167
+ }
168
+
169
+ /** Abre um buraco de `tam` quadros em `at` (o insert com ripple). */
170
+ function abrirEspaco(tl, at, tam, { tracks } = {}) {
171
+ const alvo = tracks ? new Set(tracks.map(Number)) : null;
172
+ tl.tracks.forEach((t, i) => {
173
+ if (alvo && !alvo.has(i)) return;
174
+ for (const c of [...t.clips]) {
175
+ if (c.startFrame < at && T.fim(c) > at && c.type !== 'text') dividir(tl, c.id, at);
176
+ }
177
+ for (const c of t.clips) {
178
+ if (c.startFrame >= at) c.startFrame += tam;
179
+ else if (T.fim(c) > at) c.durationFrames += tam; // texto que cruza: estica
180
+ }
181
+ });
182
+ T.ordenar(tl);
183
+ }
184
+
185
+ function inserir(tl, spec) {
186
+ const at = Math.round(Number(spec.at || 0));
187
+ const c = montarClipe(tl, spec);
188
+ abrirEspaco(tl, at, c.durationFrames, { tracks: spec.rippleTracks });
189
+ return adicionar(tl, { ...spec, at, dur: c.durationFrames, from: spec.from, asset: spec.asset });
190
+ }
191
+
192
+ function mover(tl, ref, { to, track }) {
193
+ const { clip: c, track: de } = T.achar(tl, ref);
194
+ if (to !== undefined) c.startFrame = Math.max(0, Math.round(Number(to)));
195
+ if (track !== undefined && Number(track) !== de) {
196
+ const para = T.faixaPara(tl, c.type, track === 'new' ? 'new' : Number(track));
197
+ const origem = tl.tracks.find((t) => t.clips.includes(c));
198
+ origem.clips.splice(origem.clips.indexOf(c), 1);
199
+ tl.tracks[para].clips.push(c);
200
+ }
201
+ T.ordenar(tl);
202
+ return c;
203
+ }
204
+
205
+ function remover(tl, refs, { ripple = false } = {}) {
206
+ const alvos = refs.map((r) => T.achar(tl, r));
207
+ const trechos = alvos.map(({ clip }) => [clip.startFrame, T.fim(clip)]);
208
+ for (const { clip } of alvos) {
209
+ const t = tl.tracks.find((x) => x.clips.includes(clip));
210
+ t.clips.splice(t.clips.indexOf(clip), 1);
211
+ }
212
+ if (ripple) {
213
+ // Só fecha o buraco onde NENHUMA faixa tem mais nada no trecho.
214
+ const livres = trechos.filter(([a, b]) => !T.todosOsClipes(tl).some(({ clip }) => clip.startFrame < b && T.fim(clip) > a));
215
+ if (livres.length) apagarTrechos(tl, livres);
216
+ }
217
+ limparFaixasVazias(tl);
218
+ }
219
+
220
+ function limparFaixasVazias(tl) {
221
+ tl.tracks = tl.tracks.filter((t) => t.clips.length || t.fixa);
222
+ }
223
+
224
+ // ── palavras → trechos ───────────────────────────────────────────────────
225
+
226
+ /**
227
+ * Os trechos (quadros) das palavras pedidas. `mapa` é a transcrição já na
228
+ * linha do tempo: [{i, f, e}]. Palavras vizinhas apagadas juntas levam a
229
+ * pausa entre elas; palavra solta leva só ela.
230
+ */
231
+ function trechosDasPalavras(mapa, indices) {
232
+ const quer = new Set(indices.map(Number));
233
+ const achadas = mapa.filter((w) => quer.has(w.i)).sort((a, b) => a.f - b.f);
234
+ const faltou = [...quer].filter((i) => !mapa.some((w) => w.i === i));
235
+ const ranges = [];
236
+ let atual = null;
237
+ for (const w of achadas) {
238
+ if (atual && w.i === atual.ultimo + 1 && w.f - atual.b <= 30) { atual.b = Math.max(atual.b, w.e); atual.ultimo = w.i; continue; }
239
+ if (atual) ranges.push([atual.a, atual.b]);
240
+ atual = { a: w.f, b: w.e, ultimo: w.i };
241
+ }
242
+ if (atual) ranges.push([atual.a, atual.b]);
243
+ return { ranges, faltou };
244
+ }
245
+
246
+ // ── propriedades e keyframes ─────────────────────────────────────────────
247
+
248
+ function propriedades(tl, refs, props) {
249
+ const out = [];
250
+ for (const r of refs) {
251
+ const { clip } = T.achar(tl, r);
252
+ if (props.speed !== undefined && clip.type !== 'text' && clip.type !== 'image') {
253
+ // Mudar a velocidade mantém o trecho da origem: a duração é que muda.
254
+ const origem = T.consumo(clip);
255
+ clip.speed = Number(props.speed);
256
+ clip.durationFrames = Math.max(1, Math.round(origem / clip.speed));
257
+ }
258
+ const { speed, ...resto } = props;
259
+ T.aplicarProps(clip, resto);
260
+ if (props.dur !== undefined) clip.durationFrames = Math.round(Number(props.dur));
261
+ out.push(clip.id);
262
+ }
263
+ T.ordenar(tl);
264
+ return out;
265
+ }
266
+
267
+ /** Keyframes em quadro DA LINHA DO TEMPO; guardados relativos ao clipe. */
268
+ function keyframes(tl, ref, prop, keys) {
269
+ const { clip } = T.achar(tl, ref);
270
+ if (!T.PROPS_ANIMAVEIS.includes(prop)) throw erro(`"${prop}" não anima (use ${T.PROPS_ANIMAVEIS.join('/')})`);
271
+ const lista = (keys || []).map((k) => {
272
+ const interp = k.interp || 'ease';
273
+ if (!T.INTERP.includes(interp)) throw erro(`interp "${interp}" (use ${T.INTERP.join('/')})`);
274
+ return { frame: Math.round(Number(k.frame)) - clip.startFrame, value: Number(k.value), interp };
275
+ }).sort((a, b) => a.frame - b.frame);
276
+ if (lista.length) clip.keyframes[prop] = lista;
277
+ else delete clip.keyframes[prop];
278
+ return clip;
279
+ }
280
+
281
+ // ── layouts ──────────────────────────────────────────────────────────────
282
+
283
+ const CANTOS = { pip_tl: [0, 0], pip_tr: [1, 0], pip_bl: [0, 1], pip_br: [1, 1] };
284
+ const LAYOUTS = ['full', 'side_by_side', 'top_bottom', 'grid2x2', 'grid3x3', ...Object.keys(CANTOS)];
285
+
286
+ function aspecto(tl, c) {
287
+ const a = c.asset && tl.assets[c.asset];
288
+ return a && a.width && a.height ? a.width / a.height : 16 / 9;
289
+ }
290
+
291
+ function layout(tl, nome, refs, { size = 0.34, margin = 0.04 } = {}) {
292
+ if (!LAYOUTS.includes(nome)) throw erro(`layout "${nome}" (use ${LAYOUTS.join('/')})`);
293
+ const alvos = refs.map((r) => T.achar(tl, r));
294
+ const caixa = (x, [cx, cy, w, h]) => { x.clip.transform = { ...x.clip.transform, cx, cy, w, h }; x.clip.fit = x.clip.fit === 'contain' ? 'contain' : 'cover'; };
295
+ const avisos = [];
296
+ if (nome === 'full') alvos.forEach((x) => caixa(x, [0.5, 0.5, 1, 1]));
297
+ if (nome === 'side_by_side') alvos.slice(0, 2).forEach((x, i) => caixa(x, [0.25 + i * 0.5, 0.5, 0.5, 1]));
298
+ if (nome === 'top_bottom') alvos.slice(0, 2).forEach((x, i) => caixa(x, [0.5, 0.25 + i * 0.5, 1, 0.5]));
299
+ if (nome.startsWith('grid')) {
300
+ const n = nome === 'grid2x2' ? 2 : 3;
301
+ alvos.slice(0, n * n).forEach((x, i) => caixa(x, [(i % n + 0.5) / n, (Math.floor(i / n) + 0.5) / n, 1 / n, 1 / n]));
302
+ }
303
+ if (CANTOS[nome]) {
304
+ if (alvos.length < 2) throw erro('pip precisa de dois clipes: [principal, janelinha]');
305
+ caixa(alvos[0], [0.5, 0.5, 1, 1]);
306
+ const [qx, qy] = CANTOS[nome];
307
+ const w = size;
308
+ const h = (w * tl.width) / (aspecto(tl, alvos[1].clip) * tl.height);
309
+ const mx = margin;
310
+ const my = (margin * tl.width) / tl.height;
311
+ caixa(alvos[1], [qx ? 1 - mx - w / 2 : mx + w / 2, qy ? 1 - my - h / 2 : my + h / 2, w, h]);
312
+ if (alvos[1].track > alvos[0].track) avisos.push(`a janelinha ${alvos[1].clip.id} está numa faixa ABAIXO de ${alvos[0].clip.id} e fica escondida: move {"id":"${alvos[1].clip.id}","track":"new"}`);
313
+ }
314
+ return avisos;
315
+ }
316
+
317
+ // ── textos e legendas ────────────────────────────────────────────────────
318
+
319
+ function textos(tl, lista) {
320
+ return lista.map((t) => {
321
+ if (!t.content && !(t.words || []).length) throw erro('texto sem "content"');
322
+ const at = Math.round(Number(t.at || 0));
323
+ return adicionar(tl, {
324
+ type: 'text', track: t.track, at, dur: t.dur !== undefined ? t.dur : tl.fps * 2,
325
+ content: t.content, style: t.style, animation: t.animation || 'popIn',
326
+ transform: { cx: t.cx !== undefined ? t.cx : 0.5, cy: t.cy !== undefined ? t.cy : 0.5, w: t.w || 0.9, h: t.h || 0.2 },
327
+ fadeIn: t.fadeIn, fadeOut: t.fadeOut, words: t.words,
328
+ });
329
+ });
330
+ }
331
+
332
+ const fimDeFrase = (s) => /[.!?…,;:]$/.test(String(s || '').trim());
333
+
334
+ /**
335
+ * As legendas a partir da transcrição JÁ na linha do tempo (`mapa`, depois
336
+ * dos cortes). Quebra em `maxWords`, em pontuação e em pausa; cada legenda
337
+ * guarda o tempo de cada palavra, para a animação acender a palavra dita.
338
+ */
339
+ function legendas(tl, mapa, { maxWords = 3, animation = 'highlightPop', style = {}, cy = 0.72, w = 0.86, maxGap = 12 } = {}) {
340
+ // Refazer a legenda substitui a anterior, em vez de empilhar duas.
341
+ for (const t of tl.tracks) t.clips = t.clips.filter((c) => !c.caption);
342
+ limparFaixasVazias(tl);
343
+ const ws = [...mapa].sort((a, b) => a.f - b.f);
344
+ const grupos = [];
345
+ let g = [];
346
+ ws.forEach((p, k) => {
347
+ g.push(p);
348
+ const prox = ws[k + 1];
349
+ if (!prox || g.length >= maxWords || fimDeFrase(p.texto) || prox.f - p.e > maxGap) { grupos.push(g); g = []; }
350
+ });
351
+ const faixa = tl.tracks.findIndex((t) => t.type === 'text' && t.role === 'captions');
352
+ let ti = faixa;
353
+ if (ti < 0) { ti = T.faixaPara(tl, 'text', 'new'); tl.tracks[ti].role = 'captions'; }
354
+ const criados = [];
355
+ grupos.forEach((grupo, k) => {
356
+ const at = grupo[0].f;
357
+ const prox = grupos[k + 1];
358
+ // Pausa curta até a próxima: a legenda fica até ela entrar (sem piscar).
359
+ const fala = grupo[grupo.length - 1].e;
360
+ let ate = fala + 4;
361
+ if (prox) ate = prox[0].f - fala <= maxGap ? prox[0].f : Math.min(ate, prox[0].f);
362
+ const dur = Math.max(1, ate - at);
363
+ const c = T.clipe(tl, {
364
+ type: 'text', startFrame: at, durationFrames: dur, content: grupo.map((p) => p.texto).join(' '),
365
+ style, animation, transform: { cx: 0.5, cy, w, h: 0.16 },
366
+ words: grupo.map((p) => ({ t: p.texto, f: Math.max(0, p.f - at), e: Math.min(dur, p.e - at) })),
367
+ });
368
+ c.caption = true;
369
+ tl.tracks[ti].clips.push(c);
370
+ criados.push(c);
371
+ });
372
+ T.ordenar(tl);
373
+ return criados;
374
+ }
375
+
376
+ module.exports = {
377
+ LAYOUTS, montarClipe, adicionar, dividir, cortar, juntar, apagarTrechos, abrirEspaco, inserir,
378
+ mover, remover, trechosDasPalavras, propriedades, keyframes, layout, textos, legendas, limparFaixasVazias,
379
+ };