primocode 8.22.1 → 8.26.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 (41) hide show
  1. package/app/app.css +230 -0
  2. package/app/app.js +273 -0
  3. package/app/icone.svg +6 -0
  4. package/app/index.html +68 -0
  5. package/bin/primocode.js +52 -0
  6. package/lib/app.js +334 -0
  7. package/lib/catalogo.js +2 -0
  8. package/lib/config.js +53 -2
  9. package/lib/studio.js +125 -11
  10. package/lib/tools.js +35 -1
  11. package/package.json +3 -2
  12. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  13. package/studio/arquivos/corte-estatico-corte-a5bab.json +150 -0
  14. package/studio/arquivos/corte-founders-ai-teste-do-player-2de1d.json +65 -0
  15. package/studio/arquivos/corte-legado-teste.json +1 -0
  16. package/studio/arquivos/corte-legado2.json +1 -0
  17. package/studio/arquivos/corte-minimo-2a829.json +24 -0
  18. package/studio/arquivos/corte-ok-00a3b.json +18 -0
  19. package/studio/arquivos/corte-ok-1270e.json +22 -0
  20. package/studio/arquivos/corte-sweep-corte-cfb29.json +147 -0
  21. package/studio/arquivos/corte-visual-completo-78f50.json +35 -0
  22. package/studio/arquivos/grade-sweep-grade-0c2f1.json +119 -0
  23. package/studio/arquivos/prisma-estatico-prisma-d8740.json +182 -0
  24. package/studio/arquivos/prisma-sweep-prisma-4c58c.json +166 -0
  25. package/studio/arquivos/prisma-y-b0785.json +52 -0
  26. package/studio/arquivos/prosa-sweep-prosa-f29cf.json +80 -0
  27. package/studio/arquivos/tela-estatico-tela-c7daf.json +90 -0
  28. package/studio/arquivos/tela-sweep-tela-0d2bf.json +84 -0
  29. package/studio/arquivos/traco-estatico-traco-25872.json +113 -0
  30. package/studio/arquivos/traco-sweep-traco-0bcf4.json +102 -0
  31. package/studio/ferramentas/corte.md +159 -12
  32. package/studio/server.py +264 -8
  33. package/studio/web/analise.html +52 -0
  34. package/studio/web/js/analise.js +202 -0
  35. package/studio/web/js/exportar.js +88 -1
  36. package/studio/web/js/player.js +172 -46
  37. package/studio/web/js/render.js +99 -1
  38. package/studio/web/js/video.js +434 -0
  39. package/studio/web/render.css +27 -0
  40. package/studio/web/tool.html +1 -0
  41. package/studio/web/view.html +2 -0
@@ -0,0 +1,434 @@
1
+ /* ==========================================================================
2
+ video.js — o vídeo vira ARQUIVO. Sem gravar a tela de ninguém.
3
+
4
+ ── O QUE ESTAVA ERRADO ─────────────────────────────────────────────────
5
+ O botão de baixar usava getDisplayMedia: pedia permissão, o usuário
6
+ escolhia uma janela, e o navegador salvava o que estivesse aparecendo.
7
+ Isso é gravação de tela, não exportação. O dono foi direto ao ponto, em
8
+ quatro conversas diferentes:
9
+
10
+ "O mp4 não vai ser uma gravação da tela do usuário, tem nada a ver.
11
+ Tu clica no botão download ele já vai baixar o arquivo."
12
+
13
+ E não é preferência. Gravando a tela: o ponteiro do mouse entra no vídeo,
14
+ uma notificação entra no vídeo, a resolução é a da janela e não a do
15
+ projeto, e quem for VENDER a ferramenta não vai pedir para o cliente
16
+ compartilhar a tela para exportar um arquivo dele mesmo.
17
+
18
+ ── COMO FUNCIONA ───────────────────────────────────────────────────────
19
+ O vídeo é DESENHADO. Um canvas do tamanho exato do projeto é fotografado
20
+ a partir da cena que está tocando, quadro a quadro, e vira faixa de vídeo
21
+ por captureStream. O áudio vem do grafo que o player já monta — trilha,
22
+ narração e efeitos, com o ducking que abaixa a música na voz. O
23
+ MediaRecorder junta os dois num arquivo. Zero permissão.
24
+
25
+ ── POR QUE ELE DIRIGE O PLAYER EM VEZ DE RENDERIZAR SOZINHO ────────────
26
+ Porque o player já sabe tocar: ele encadeia as cenas, dispara a narração
27
+ no tempo certo, aplica ducking e toca os efeitos de transição. Refazer
28
+ isso aqui seria manter duas versões da mesma lógica — e elas divergiriam,
29
+ como já aconteceu neste código com o editor e o renderizador. Aqui o
30
+ exportador só pergunta "qual cena está na tela agora?" e fotografa.
31
+
32
+ ── POR QUE EM TEMPO REAL ───────────────────────────────────────────────
33
+ O ideal seria renderizar sem pressa e carimbar cada quadro com o tempo
34
+ dele (MediaStreamTrackGenerator). Foi testado: o MediaRecorder não produz
35
+ um byte com faixa vinda de gerador — zero em mp4, webm/vp8 e webm/vp9. Só
36
+ o captureStream de canvas grava, e ele é preso ao relógio.
37
+
38
+ O custo aparece na taxa de quadros: rasterizar 1920×1080 leva ~76 ms.
39
+ Mas quase nunca é preciso rasterizar — numa cena de 4 segundos a entrada
40
+ dura menos de um, e o resto do tempo o quadro está PARADO. Este arquivo
41
+ pergunta ao navegador se ainda há animação rodando; enquanto há,
42
+ redesenha; quando todas terminam, para, e o captureStream segue emitindo
43
+ o último quadro na taxa cheia. Movimento suave onde há movimento, imagem
44
+ limpa onde não há.
45
+ ========================================================================== */
46
+
47
+ var VideoExport = (function () {
48
+ "use strict";
49
+
50
+ /* Do mais compatível para o menos.
51
+ *
52
+ * H.264 com AAC é o MP4 que toca em tudo — WhatsApp, Instagram,
53
+ * PowerPoint, TV, editor de vídeo. AV1 dentro de MP4 é MP4 de verdade mas
54
+ * ainda não toca em todo lugar, então vem depois. O webm é o último
55
+ * recurso: abre em navegador e pouco mais.
56
+ *
57
+ * Testar só "video/mp4" não basta: o Chrome responde false para o nome
58
+ * solto e true para a forma com codec. Já custou MP4 a quem pediu MP4. */
59
+ var FORMATOS = [
60
+ "video/mp4;codecs=avc1.42E01E,mp4a.40.2",
61
+ "video/mp4;codecs=avc1.4d002a,mp4a.40.2",
62
+ "video/mp4;codecs=avc1,mp4a.40.2",
63
+ "video/mp4;codecs=avc1",
64
+ "video/mp4",
65
+ "video/webm;codecs=vp9,opus",
66
+ "video/webm;codecs=vp8,opus",
67
+ "video/webm",
68
+ ];
69
+
70
+ function formato() {
71
+ if (!window.MediaRecorder) return null;
72
+ for (var i = 0; i < FORMATOS.length; i++) {
73
+ if (MediaRecorder.isTypeSupported(FORMATOS[i])) return FORMATOS[i];
74
+ }
75
+ return null;
76
+ }
77
+
78
+ function disponivel() {
79
+ return !!(formato() && document.createElement("canvas").captureStream
80
+ && typeof Exportar !== "undefined" && Exportar.retrato);
81
+ }
82
+
83
+ function ehMp4(tipo) { return /mp4/.test(tipo || ""); }
84
+ function extensaoDe(tipo) { return ehMp4(tipo) ? ".mp4" : ".webm"; }
85
+
86
+ /* Ainda há algo se mexendo nesta cena?
87
+ *
88
+ * É a pergunta que decide se vale gastar dezenas de milissegundos
89
+ * redesenhando. Animação infinita (Ken Burns, cubo 3D girando, brilho de
90
+ * gradiente) nunca termina — e aí redesenhar é o certo, porque a imagem
91
+ * MUDA mesmo. */
92
+ function aindaAnima(no) {
93
+ if (!no || !no.getAnimations) return true; // sem a API: redesenha sempre
94
+ var todas;
95
+ try { todas = no.getAnimations({ subtree: true }); }
96
+ catch (e) { return true; }
97
+ for (var i = 0; i < todas.length; i++) {
98
+ if (todas[i].playState === "running") return true;
99
+ }
100
+ return false;
101
+ }
102
+
103
+ function duracaoDaCena(c) {
104
+ var d = c && c.duracao;
105
+ return (typeof d === "number" && isFinite(d) && d > 0) ? d : 4;
106
+ }
107
+
108
+ /* ------------------------------------------------------------ a fonte -- */
109
+
110
+ /* Uma "fonte" diz ao exportador o que fotografar e por quanto tempo.
111
+ *
112
+ * Quando o player está na página, ele é a fonte: sabe tocar, narrar e
113
+ * mixar. Fora dele (no editor, ou num teste), o exportador monta uma fonte
114
+ * própria que percorre as cenas por conta — mais simples, sem narração.
115
+ */
116
+ function fonteDoDocumento(doc, palco) {
117
+ var cenas = doc.cenas || [];
118
+ var nos = cenas.map(function (c) {
119
+ var no = renderCena(c, doc, { animar: true });
120
+ no.style.position = "static";
121
+ no.style.transform = "none";
122
+ no.style.display = "none";
123
+ palco.appendChild(no);
124
+ return no;
125
+ });
126
+ var atual = -1;
127
+ var t0 = 0;
128
+ var total = cenas.reduce(function (t, c) { return t + duracaoDaCena(c); }, 0);
129
+
130
+ function qualCena(t) {
131
+ var acc = 0;
132
+ for (var i = 0; i < cenas.length; i++) {
133
+ acc += duracaoDaCena(cenas[i]);
134
+ if (t < acc) return i;
135
+ }
136
+ return cenas.length - 1;
137
+ }
138
+
139
+ return {
140
+ duracao: total,
141
+ comecar: function () { t0 = performance.now(); },
142
+ parar: function () {},
143
+ no: function () {
144
+ var t = (performance.now() - t0) / 1000;
145
+ var i = qualCena(t);
146
+ if (i !== atual) {
147
+ atual = i;
148
+ nos.forEach(function (n, k) { n.style.display = k === i ? "block" : "none"; });
149
+ // reinicia entrada e transição, como o player faz
150
+ nos[i].classList.remove("animar");
151
+ void nos[i].offsetWidth;
152
+ nos[i].classList.add("animar");
153
+ }
154
+ return nos[i];
155
+ },
156
+ };
157
+ }
158
+
159
+ /* ------------------------------------------------------------ clipes -- */
160
+
161
+ /* Desenha os vídeos do usuário no canvas, ANTES do desenho da cena.
162
+ *
163
+ * O retrato de uma cena é feita por SVG com foreignObject, e foreignObject
164
+ * NÃO rasteriza <video>: o quadro do vídeo sairia em branco, por mais
165
+ * correto que o resto estivesse. Então o vídeo é desenhado direto no
166
+ * canvas — drawImage aceita um <video> e pega o quadro atual — e a cena
167
+ * entra por cima, onde o buraco do vídeo é transparente.
168
+ *
169
+ * A conta de posição vem do layout real: getBoundingClientRect do vídeo
170
+ * contra o da cena, convertido para as coordenadas do projeto. Assim vale
171
+ * para clipe posicionado à mão (x/y) e para clipe que o layout arrumou.
172
+ *
173
+ * "Onde o buraco é transparente" não era verdade e por isso a gravação
174
+ * sumia do arquivo: a cena carrega o fundo do tema, opaco, e ele cobria o
175
+ * vídeo inteiro. Um vídeo do usuário que não aparece no vídeo do usuário —
176
+ * e nada denunciava, porque na tela, no DOM, estava certo.
177
+ *
178
+ * Então numa cena com clipe a ordem é: a cor de chão do tema, o vídeo, e a
179
+ * cena SEM fundo por cima (é o que `semFundo` faz no clone). O texto, a
180
+ * legenda e a marca continuam por cima da imagem, que é onde devem estar.
181
+ */
182
+ function desenharClipes(ctx2d, cenaEl, L, A) {
183
+ var vids = cenaEl.querySelectorAll("video");
184
+ if (!vids.length) return;
185
+ var caixaCena = cenaEl.getBoundingClientRect();
186
+ if (!caixaCena.width || !caixaCena.height) return;
187
+ var escX = L / caixaCena.width;
188
+ var escY = A / caixaCena.height;
189
+
190
+ Array.prototype.forEach.call(vids, function (v) {
191
+ // readyState < 2 é vídeo sem quadro nenhum decodificado ainda: pedir
192
+ // para desenhar levanta exceção em alguns navegadores e mancha o
193
+ // canvas em outros.
194
+ if (v.readyState < 2 || !v.videoWidth) return;
195
+ var r = v.getBoundingClientRect();
196
+ var dx = (r.left - caixaCena.left) * escX;
197
+ var dy = (r.top - caixaCena.top) * escY;
198
+ var dw = r.width * escX;
199
+ var dh = r.height * escY;
200
+ if (dw <= 0 || dh <= 0) return;
201
+
202
+ // "cobrir" recorta o vídeo para encher a caixa sem deformar — o mesmo
203
+ // que o object-fit faz na tela. Sem isto o export esticaria a imagem
204
+ // e ficaria diferente do que a pessoa aprovou no player.
205
+ var conter = v.style.objectFit === "contain";
206
+ var escala = conter
207
+ ? Math.min(dw / v.videoWidth, dh / v.videoHeight)
208
+ : Math.max(dw / v.videoWidth, dh / v.videoHeight);
209
+ var lw = v.videoWidth * escala;
210
+ var lh = v.videoHeight * escala;
211
+ var lx = dx + (dw - lw) / 2;
212
+ var ly = dy + (dh - lh) / 2;
213
+
214
+ ctx2d.save();
215
+ ctx2d.beginPath();
216
+ ctx2d.rect(dx, dy, dw, dh);
217
+ ctx2d.clip();
218
+ // "cinza", "sepia", "contraste": na tela é filter do CSS, e drawImage
219
+ // ignora o CSS do elemento. O canvas tem o mesmo filtro, com a mesma
220
+ // gramática — então é só repassar, e o arquivo sai como o player.
221
+ var f = window.getComputedStyle(v).filter;
222
+ if (f && f !== "none") { try { ctx2d.filter = f; } catch (e) {} }
223
+ try { ctx2d.drawImage(v, lx, ly, lw, lh); } catch (e) { /* quadro ainda não veio */ }
224
+ ctx2d.restore();
225
+ });
226
+ }
227
+
228
+ /* O chão da cena, para o clipe que não ocupa o quadro inteiro. Sem isto,
229
+ a volta do vídeo sairia preta em vez da cor do tema. */
230
+ function corDeChao(no) {
231
+ var c = window.getComputedStyle(no).backgroundColor;
232
+ return (c && c !== "rgba(0, 0, 0, 0)" && c !== "transparent") ? c : "#0b0e14";
233
+ }
234
+
235
+ /* Prepara o CLONE da cena que tem gravação — só o clone, a tela do usuário
236
+ não muda. Duas coisas precisam sair, e as duas escondiam o vídeo:
237
+
238
+ · o fundo da cena, opaco por cima da gravação. `data-fundo` sai junto
239
+ porque os padrões (aurora, raios, ondas) são pseudo-elementos: zerar
240
+ `background` deixaria a camada de cima intacta;
241
+
242
+ · o próprio <video>. foreignObject não decodifica vídeo, mas também não
243
+ deixa o espaço vazio: pinta a caixa do elemento no cinza padrão
244
+ (#333). O quadro saía com a gravação desenhada embaixo e um retângulo
245
+ cinza chapado exatamente em cima dela. */
246
+ function semFundo(clone) {
247
+ clone.removeAttribute("data-fundo");
248
+ clone.style.background = "transparent";
249
+ clone.style.backgroundImage = "none";
250
+ Array.prototype.forEach.call(clone.querySelectorAll("video"), function (v) {
251
+ v.style.visibility = "hidden";
252
+ v.style.background = "transparent";
253
+ // A caixa que segura o vídeo também pinta (é o cinza de espera, útil na
254
+ // tela enquanto o arquivo carrega). No quadro exportado ela é só mais
255
+ // uma tampa em cima da gravação — e foi a última a ser encontrada,
256
+ // porque some junto com o resto do fundo e parece o fundo.
257
+ for (var pai = v.parentNode; pai && pai !== clone; pai = pai.parentNode) {
258
+ pai.style.background = "transparent";
259
+ pai.style.backgroundImage = "none";
260
+ }
261
+ });
262
+ }
263
+
264
+ /* Cena com vídeo nunca fica "parada": o clipe corre mesmo sem animação
265
+ CSS, e pular o redesenho congelaria a imagem do usuário. */
266
+ function temClipe(no) {
267
+ return !!(no && no.querySelector && no.querySelector("video"));
268
+ }
269
+
270
+ /* --------------------------------------------------------------- gerar -- */
271
+
272
+ /* Monta o arquivo e devolve { blob, bytes, formato, mp4, segundos }.
273
+ *
274
+ * `op.fonte` — quem diz o que fotografar (o player, normalmente).
275
+ * `op.fluxoAudio` — MediaStream já mixado. Sem ele o vídeo sai mudo.
276
+ * `op.aoAndar(fracao)` — progresso, para a interface mostrar.
277
+ */
278
+ function gerar(arq, op) {
279
+ op = op || {};
280
+ var doc = (arq && arq.doc) || {};
281
+ var cenas = doc.cenas || [];
282
+ if (!cenas.length) return Promise.reject(new Error("este arquivo não tem cenas"));
283
+
284
+ var tipo = formato();
285
+ if (!tipo) return Promise.reject(new Error("este navegador não sabe gerar vídeo"));
286
+
287
+ var L = doc.largura || 1920;
288
+ var A = doc.altura || 1080;
289
+ var FPS = op.fps || 30;
290
+ var andar = op.aoAndar || function () {};
291
+
292
+ var tela = document.createElement("canvas");
293
+ tela.width = L; tela.height = A;
294
+ var ctx2d = tela.getContext("2d", { alpha: false });
295
+ ctx2d.fillStyle = "#000";
296
+ ctx2d.fillRect(0, 0, L, A);
297
+
298
+ // Palco próprio só quando o exportador tem de montar as cenas sozinho.
299
+ // Fica DENTRO do documento (fora dele não há layout nem animação) mas
300
+ // fora da vista — e não com display:none, que também mataria o layout.
301
+ var palco = null;
302
+ var fonte = op.fonte;
303
+ if (!fonte) {
304
+ palco = document.createElement("div");
305
+ palco.setAttribute("aria-hidden", "true");
306
+ palco.style.cssText = "position:fixed;left:-99999px;top:0;pointer-events:none;"
307
+ + "width:" + L + "px;height:" + A + "px;overflow:hidden";
308
+ document.body.appendChild(palco);
309
+ fonte = fonteDoDocumento(doc, palco);
310
+ }
311
+
312
+ var fluxo = tela.captureStream(FPS);
313
+ if (op.fluxoAudio) {
314
+ op.fluxoAudio.getAudioTracks().forEach(function (f) { fluxo.addTrack(f); });
315
+ }
316
+
317
+ var pedacos = [];
318
+ var rec = new MediaRecorder(fluxo, {
319
+ mimeType: tipo,
320
+ videoBitsPerSecond: op.bitrate || (L * A > 1000000 ? 8000000 : 4000000),
321
+ });
322
+ rec.ondataavailable = function (ev) { if (ev.data && ev.data.size) pedacos.push(ev.data); };
323
+
324
+ var totalMs = (op.duracao || fonte.duracao || 0) * 1000;
325
+ var cancelado = false;
326
+ var folhaCss = null;
327
+
328
+ function limpar() {
329
+ try { if (fonte.parar) fonte.parar(); } catch (e) {}
330
+ if (palco) { try { palco.remove(); } catch (e) {} }
331
+ fluxo.getVideoTracks().forEach(function (t) { try { t.stop(); } catch (e) {} });
332
+ }
333
+
334
+ var promessa = Exportar.css().then(function (folha) {
335
+ folhaCss = folha;
336
+ // A fonte pode ter o que carregar antes do primeiro quadro — a gravação
337
+ // do usuário, que sem quadro decodificado desenharia em branco.
338
+ return fonte.preparar ? fonte.preparar() : null;
339
+ }).then(function () {
340
+ rec.start(1000);
341
+ if (fonte.comecar) fonte.comecar();
342
+
343
+ return new Promise(function (pronto) {
344
+ var t0 = performance.now();
345
+ var primeiro = true;
346
+
347
+ function volta() {
348
+ if (cancelado) return pronto();
349
+ var t = performance.now() - t0;
350
+ if (t >= totalMs) return pronto();
351
+ andar(Math.min(0.995, t / totalMs));
352
+
353
+ var no = fonte.no();
354
+ if (!no) return setTimeout(volta, 1000 / FPS);
355
+
356
+ // Quadro idêntico ao anterior não precisa ser refeito: o
357
+ // captureStream repete o último sozinho, na taxa cheia.
358
+ if (!primeiro && !aindaAnima(no) && !temClipe(no)) {
359
+ return setTimeout(volta, 1000 / FPS);
360
+ }
361
+ primeiro = false;
362
+
363
+ var comVideo = temClipe(no);
364
+ Exportar.retrato(no, L, A, folhaCss, false, comVideo ? semFundo : null)
365
+ .then(function (img) {
366
+ if (cancelado) return pronto();
367
+ if (comVideo) {
368
+ // chão do tema → gravação do usuário → cena sem fundo
369
+ ctx2d.fillStyle = corDeChao(no);
370
+ ctx2d.fillRect(0, 0, L, A);
371
+ desenharClipes(ctx2d, no, L, A);
372
+ }
373
+ ctx2d.drawImage(img, 0, 0, L, A);
374
+ volta();
375
+ }).catch(function () {
376
+ // Um quadro que não rasterizou não derruba o vídeo: segue, e o
377
+ // quadro anterior continua na tela.
378
+ setTimeout(volta, 1000 / FPS);
379
+ });
380
+ }
381
+ volta();
382
+ });
383
+ }).then(function () {
384
+ return new Promise(function (ok) {
385
+ // Um instante a mais antes de parar: o codificador precisa fechar o
386
+ // último trecho, e parar em cima do fim corta o final do vídeo.
387
+ setTimeout(function () {
388
+ rec.onstop = function () { ok(); };
389
+ if (rec.state !== "inactive") rec.stop(); else ok();
390
+ }, 400);
391
+ });
392
+ }).then(function () {
393
+ limpar();
394
+ var blob = new Blob(pedacos, { type: tipo.split(";")[0] });
395
+ if (!blob.size) throw new Error("o navegador não produziu vídeo");
396
+ andar(1);
397
+ return {
398
+ ok: true, blob: blob, bytes: blob.size,
399
+ formato: extensaoDe(tipo), tipo: tipo, mp4: ehMp4(tipo),
400
+ segundos: totalMs / 1000,
401
+ };
402
+ }).catch(function (e) {
403
+ limpar();
404
+ throw e;
405
+ });
406
+
407
+ promessa.cancelar = function () { cancelado = true; };
408
+ return promessa;
409
+ }
410
+
411
+ /* Gera e entrega o arquivo. É isto que o botão chama. */
412
+ function baixar(arq, op) {
413
+ op = op || {};
414
+ return gerar(arq, op).then(function (r) {
415
+ var url = URL.createObjectURL(r.blob);
416
+ var a = document.createElement("a");
417
+ a.href = url;
418
+ a.download = (op.nome || arq.titulo || "video")
419
+ .replace(/[^\w\-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() + r.formato;
420
+ document.body.appendChild(a);
421
+ a.click();
422
+ a.remove();
423
+ setTimeout(function () { URL.revokeObjectURL(url); }, 8000);
424
+ return r;
425
+ });
426
+ }
427
+
428
+ return {
429
+ disponivel: disponivel, formato: formato, gerar: gerar, baixar: baixar,
430
+ FORMATOS: FORMATOS, _aindaAnima: aindaAnima,
431
+ };
432
+ })();
433
+
434
+ if (typeof module !== "undefined" && module.exports) module.exports = VideoExport;
@@ -214,6 +214,33 @@
214
214
  }
215
215
  .cena .fluxo > .el { flex: 0 0 auto; max-width: 100%; }
216
216
 
217
+ /* --------------------------------------------------- legenda de fala ----
218
+ Legenda de vídeo é lida em movimento e, muitas vezes, sem som — e por cima
219
+ de uma imagem que muda a cada quadro. Cor de tema não resolve: sobre um céu
220
+ claro, texto claro some; sobre uma sombra, texto escuro some.
221
+
222
+ Então ela não confia no fundo. Branco puro, peso alto, chapa escura atrás e
223
+ um contorno de sombra que segura a leitura mesmo quando a chapa cai em cima
224
+ de algo claro. É a mesma decisão de toda legenda de streaming. */
225
+ .cena .faixa-fala {
226
+ display: flex;
227
+ flex-direction: column;
228
+ align-items: center;
229
+ gap: 10px;
230
+ text-align: center;
231
+ pointer-events: none;
232
+ }
233
+ .cena .el.txt.fala {
234
+ display: inline-block;
235
+ max-width: 86%;
236
+ color: #fff;
237
+ background: rgba(6, 8, 12, .72);
238
+ padding: .32em .7em;
239
+ border-radius: .28em;
240
+ text-shadow: 0 2px 10px rgba(0, 0, 0, .85), 0 0 2px rgba(0, 0, 0, .9);
241
+ text-wrap: balance;
242
+ }
243
+
217
244
  .cena .fluxo[data-layout="capa"],
218
245
  .cena .fluxo[data-layout="centro"],
219
246
  .cena .fluxo[data-layout="citacao"] { align-items: center; text-align: center; }
@@ -72,6 +72,7 @@
72
72
  <script src="/js/narracao.js"></script>
73
73
  <script src="/js/midia.js"></script>
74
74
  <script src="/js/exportar.js"></script>
75
+ <script src="/js/video.js"></script>
75
76
  <script src="/js/editor.js"></script>
76
77
  </body>
77
78
  </html>
@@ -20,6 +20,8 @@
20
20
  <script src="/js/sfx.js"></script>
21
21
  <script src="/js/voz-neural.js"></script>
22
22
  <script src="/js/narracao.js"></script>
23
+ <script src="/js/exportar.js"></script>
24
+ <script src="/js/video.js"></script>
23
25
  <script src="/js/player.js"></script>
24
26
  </body>
25
27
  </html>