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
@@ -63,6 +63,93 @@ var Exportar = (function () {
63
63
  return Promise.all(tarefas);
64
64
  }
65
65
 
66
+ /* Copia para o clone o estado ATUAL de cada animação.
67
+ *
68
+ * Sem isto o vídeo exportado perde todo o movimento, e o erro é silencioso.
69
+ * O XMLSerializer copia a MARCAÇÃO, não o instante: o SVG resultante mostra
70
+ * o elemento no estado final da animação, sempre. Foi medido — aos 0,3 s de
71
+ * um vídeo, o título já aparecia no lugar final, com a entrada inteira
72
+ * perdida. O arquivo ficava tecnicamente perfeito e visualmente morto.
73
+ *
74
+ * A leitura vem do getComputedStyle do elemento VIVO, que reflete onde a
75
+ * animação está agora, e entra como estilo inline no clone. Depois as
76
+ * animações são desligadas no clone: o estado já está escrito, e deixá-las
77
+ * ligadas faria o SVG recomeçar do zero por cima.
78
+ *
79
+ * Só três propriedades são copiadas — são as que as animações do estúdio
80
+ * mexem (entrada, transição, chaves, Ken Burns, cubo 3D). Copiar o estilo
81
+ * computado inteiro seria dezenas de vezes mais caro por quadro, e o resto
82
+ * já vem das folhas embutidas no próprio SVG. */
83
+ function assarAnimacoes(original, clone) {
84
+ var deLa = original.querySelectorAll("*");
85
+ var daCa = clone.querySelectorAll("*");
86
+ var n = Math.min(deLa.length, daCa.length);
87
+ for (var i = 0; i < n; i++) {
88
+ var cs = window.getComputedStyle(deLa[i]);
89
+ var alvo = daCa[i].style;
90
+ if (cs.transform && cs.transform !== "none") alvo.transform = cs.transform;
91
+ if (cs.opacity !== "" && cs.opacity !== "1") alvo.opacity = cs.opacity;
92
+ if (cs.filter && cs.filter !== "none") alvo.filter = cs.filter;
93
+ alvo.animation = "none";
94
+ }
95
+ // A raiz também: a TRANSIÇÃO de cena (zoom, deslizar, persiana) anima o
96
+ // transform dela. Quem chama zera esse transform para desfazer a escala de
97
+ // pré-visualização — e sem copiar de volta o valor animado, toda transição
98
+ // sumia do arquivo exportado.
99
+ var raiz = window.getComputedStyle(original);
100
+ if (raiz.transform && raiz.transform !== "none") clone.style.transform = raiz.transform;
101
+ if (raiz.opacity !== "" && raiz.opacity !== "1") clone.style.opacity = raiz.opacity;
102
+ if (raiz.filter && raiz.filter !== "none") clone.style.filter = raiz.filter;
103
+ clone.style.animation = "none";
104
+ }
105
+
106
+ /* Uma cena do DOM → uma <img> pronta para desenhar no canvas.
107
+ *
108
+ * Separado de `imagem()` porque o vídeo precisa disto CENTENAS de vezes, e
109
+ * com a folha de CSS já buscada: refazer o fetch do core.css e do render.css
110
+ * a cada quadro seria o gasto dominante.
111
+ *
112
+ * `congelar` põe as animações no estado final — é o que a exportação de PNG
113
+ * quer. O vídeo passa false, porque o que ele quer é o retrato do instante. */
114
+ /* `mascara` recebe o clone antes de virar SVG e pode esconder camadas. É como
115
+ a exportação de vídeo tira o fundo da cena que tem gravação do usuário: o
116
+ quadro do <video> é pintado à parte, no canvas, e o fundo do tema por cima
117
+ dele apagaria justamente a imagem que se quer mostrar. */
118
+ function retrato(cenaEl, L, A, folha, congelar, mascara) {
119
+ var clone = cenaEl.cloneNode(true);
120
+ clone.style.transform = "none";
121
+ clone.style.position = "static";
122
+ clone.style.display = "block";
123
+ clone.style.width = L + "px";
124
+ clone.style.height = A + "px";
125
+ if (congelar) {
126
+ clone.classList.remove("animar");
127
+ Array.prototype.forEach.call(clone.querySelectorAll("*"), function (n) {
128
+ n.style.animation = "none";
129
+ n.style.opacity = n.style.opacity === "0" ? "1" : n.style.opacity;
130
+ });
131
+ } else {
132
+ assarAnimacoes(cenaEl, clone);
133
+ }
134
+ if (typeof mascara === "function") mascara(clone);
135
+ var caixa = document.createElement("div");
136
+ caixa.appendChild(clone);
137
+ return embutirImagens(caixa).then(function () {
138
+ var html = new XMLSerializer().serializeToString(clone);
139
+ var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + L + '" height="' + A + '">' +
140
+ "<foreignObject width='100%' height='100%'>" +
141
+ '<div xmlns="http://www.w3.org/1999/xhtml">' +
142
+ "<style>" + folha.replace(/<\/style>/gi, "") + "</style>" +
143
+ html + "</div></foreignObject></svg>";
144
+ return new Promise(function (ok, falha) {
145
+ var img = new Image();
146
+ img.onload = function () { ok(img); };
147
+ img.onerror = function () { falha(new Error("não consegui montar a imagem")); };
148
+ img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
149
+ });
150
+ });
151
+ }
152
+
66
153
  function imagem(cenaEl, op) {
67
154
  op = op || {};
68
155
  var L = op.largura || cenaEl.offsetWidth || 1920;
@@ -131,5 +218,5 @@ var Exportar = (function () {
131
218
  });
132
219
  }
133
220
 
134
- return { imagem: imagem };
221
+ return { imagem: imagem, retrato: retrato, css: css, assarAnimacoes: assarAnimacoes };
135
222
  })();
@@ -11,7 +11,7 @@
11
11
 
12
12
  var arq = null, doc = {}, cenas = [], atual = 0;
13
13
  var tocando = false, quadro = null, t0 = 0, decorrido = 0;
14
- var trilha = null, gravador = null, pedacos = [], fluxoTela = null;
14
+ var trilha = null, gravador = null, pedacos = [];
15
15
  var ehVideo = false, transporte = null;
16
16
 
17
17
  /* ------------------------------------------------------------- avisos -- */
@@ -30,12 +30,32 @@
30
30
  return String(Math.floor(s / 60)).padStart(2, "0") + ":" + String(s % 60).padStart(2, "0");
31
31
  }
32
32
 
33
+ /* Quanto dura o maior trecho de vídeo do usuário nesta cena.
34
+ Cortar as pausas de uma gravação vira uma fila de cenas, cada uma com um
35
+ pedaço do MESMO arquivo. Se cada uma dessas cenas durasse os 4 segundos
36
+ do padrão, o corte não seria corte nenhum: o trecho de 1,2s ficaria
37
+ congelado 2,8s e o de 9s sairia pela metade. O trecho é que manda. */
38
+ function duracaoClipe(c) {
39
+ var maior = 0;
40
+ (c.elementos || []).forEach(function (e) {
41
+ if (!e || e.tipo !== "clipe") return;
42
+ var de = Number(e.de) || 0, ate = Number(e.ate);
43
+ if (isFinite(ate) && ate > de) maior = Math.max(maior, ate - de);
44
+ });
45
+ return maior;
46
+ }
47
+
33
48
  function duracaoDe(i) {
34
49
  /* "duracao": "3s" (texto) fazia acc virar concatenação e o relógio dava
35
50
  vídeo de 00:00. Número são ou o padrão — nunca corrente de texto. */
36
51
  var c = cenas[i] || {};
37
52
  var d = Number(c.duracao);
38
- d = (isFinite(d) && d > 0) ? Math.min(60, d) : 4;
53
+ /* O padrão de 4s vale para cena de texto. Onde há gravação, o padrão é o
54
+ trecho — e o teto sobe junto, porque 60s é guarda contra erro de
55
+ digitação e um pedaço de vídeo medido não é erro de digitação. */
56
+ var clipe = duracaoClipe(c);
57
+ if (!(isFinite(d) && d > 0)) d = clipe > 0 ? Math.min(600, clipe) : 4;
58
+ else d = Math.min(clipe > 0 ? 600 : 60, d);
39
59
  /* A narração pré-renderizada tem duração conhecida (o servidor mediu o
40
60
  WAV e gravou `narracaoDur` na cena). A cena dura o que for MAIOR:
41
61
  o que o autor pediu ou a fala inteira + um respiro. É isto que torna
@@ -140,6 +160,7 @@
140
160
  if (!cenas.length) return;
141
161
  var anterior = atual;
142
162
  atual = Math.max(0, Math.min(cenas.length - 1, n));
163
+ if (anterior !== atual) pararClipes(anterior); // o áudio da cena que saiu vai junto
143
164
  Array.prototype.forEach.call(palco.children, function (no, i) {
144
165
  no.style.display = i === atual ? "block" : "none";
145
166
  if (i === atual) {
@@ -163,6 +184,7 @@
163
184
  narrar();
164
185
  pintarApresentador();
165
186
  if (!semRelogio) decorrido = inicioDe(atual);
187
+ sincronizarClipes();
166
188
  pintar();
167
189
  if (location.hash.indexOf("#imprimir") !== 0) {
168
190
  history.replaceState(null, "", "#/" + (atual + 1));
@@ -172,6 +194,79 @@
172
194
  function proxima() { if (atual < cenas.length - 1) ir(atual + 1); else if (ehVideo) fim(); }
173
195
  function anterior() { ir(atual - 1); }
174
196
 
197
+ /* ── clipes: a gravação do usuário andando junto com a cena ─────────────
198
+ Um `clipe` é um trecho do arquivo — `de` e `ate` em segundos. Quem manda
199
+ no tempo é o relógio do player, não o do <video>: senão cada cena com
200
+ gravação puxaria a linha do tempo para o lado dela e a trilha, a narração
201
+ e a exportação sairiam cada uma num compasso.
202
+
203
+ Mas corrigir `currentTime` a cada quadro deixa a imagem tremida e corta o
204
+ áudio. Então o <video> toca sozinho, que é o que ele faz bem, e só é
205
+ puxado de volta quando a diferença passa de um quinto de segundo — que é
206
+ mais que o ouvido perdoa numa fala. */
207
+ var DERIVA = 0.2;
208
+
209
+ function clipesDe(i) {
210
+ var no = palco.children[i];
211
+ return no ? no.querySelectorAll("video") : [];
212
+ }
213
+
214
+ function trechoDe(v) {
215
+ var de = parseFloat(v.dataset.de), ate = parseFloat(v.dataset.ate);
216
+ return { de: isFinite(de) ? de : 0, ate: isFinite(ate) ? ate : Infinity };
217
+ }
218
+
219
+ /* A voz do usuário tem de entrar no ARQUIVO, não só sair na caixa de som.
220
+ O MP4 leva as faixas de áudio que estão no grafo da trilha e mais nada —
221
+ um <video> tocando por fora sai pelo alto-falante e some do arquivo, que
222
+ é o pior dos mundos: parece certo na tela e chega mudo em quem recebe.
223
+
224
+ Entra pela `entradaVoz`, a mesma porta da narração: fica fora do volume
225
+ da trilha e fora do duck. Quem abaixa é a música; a fala, nunca.
226
+ `createMediaElementSource` só aceita ser chamado uma vez por elemento —
227
+ daí a marca no próprio nó. */
228
+ function ligarAoGrafo(v) {
229
+ if (v._ligado || !trilha || !trilha.ctx || !trilha.entradaVoz) return;
230
+ v._ligado = true;
231
+ if (v.muted) return; // clipe mudo não tem o que ligar
232
+ try {
233
+ trilha.ctx.createMediaElementSource(v).connect(trilha.entradaVoz);
234
+ if (trilha.duck) trilha.duck(true); // a música cede enquanto ele fala
235
+ } catch (_) { /* já conectado, ou sem Web Audio: toca direto mesmo */ }
236
+ }
237
+
238
+ function tocarClipe(v) {
239
+ ligarAoGrafo(v);
240
+ if (trilha && trilha.ctx && trilha.ctx.state === "suspended") trilha.ctx.resume();
241
+ var p = v.play();
242
+ if (p && p.catch) p.catch(function () {}); // autoplay barrado: fica o quadro
243
+ }
244
+
245
+ function pararClipes(i) {
246
+ Array.prototype.forEach.call(clipesDe(i), function (v) { try { v.pause(); } catch (e) {} });
247
+ }
248
+
249
+ /* Chamado a cada quadro do laço, e depois de qualquer salto no tempo. */
250
+ function sincronizarClipes() {
251
+ var vs = clipesDe(atual);
252
+ if (!vs.length) return;
253
+ var dentro = Math.max(0, decorrido - inicioDe(atual));
254
+ Array.prototype.forEach.call(vs, function (v) {
255
+ if (v.readyState < 1) return; // ainda sem metadados: nada a buscar
256
+ var t = trechoDe(v);
257
+ var alvo = t.de + dentro;
258
+ /* Passou do fim do trecho: segura o último quadro em vez de invadir o
259
+ que vem depois no arquivo — invadir é justamente a pausa que se
260
+ queria cortar. */
261
+ if (alvo >= t.ate) { if (!v.paused) v.pause(); return; }
262
+ if (Math.abs(v.currentTime - alvo) > DERIVA) {
263
+ try { v.currentTime = alvo; } catch (e) {}
264
+ }
265
+ if (tocando && v.paused) tocarClipe(v);
266
+ else if (!tocando && !v.paused) v.pause();
267
+ });
268
+ }
269
+
175
270
  /* números que contam do zero até o valor ------------------------------- */
176
271
 
177
272
  function contar(cena) {
@@ -200,6 +295,7 @@
200
295
  tocando = true;
201
296
  t0 = performance.now() - decorrido * 1000;
202
297
  if (trilha) { trilha.tocar(); }
298
+ sincronizarClipes();
203
299
  laco();
204
300
  atualizarBotao();
205
301
  }
@@ -207,6 +303,7 @@
207
303
  function pausar() {
208
304
  tocando = false;
209
305
  cancelAnimationFrame(quadro);
306
+ pararClipes(atual);
210
307
  if (trilha) trilha.parar();
211
308
  if (typeof Narracao !== "undefined") Narracao.parar();
212
309
  if (vozGravada) { vozGravada.pause(); vozGravada = null; }
@@ -252,6 +349,7 @@
252
349
  }
253
350
  if (decorrido >= total()) return fim();
254
351
  if (alvo !== atual) ir(alvo, true);
352
+ sincronizarClipes();
255
353
  pintar();
256
354
  quadro = requestAnimationFrame(laco);
257
355
  }
@@ -267,9 +365,32 @@
267
365
  ir(alvo, true);
268
366
  t0 = performance.now() - decorrido * 1000;
269
367
  if (trilha && trilha.elemento) trilha.elemento.currentTime = (doc.trilha.inicio || 0) + decorrido;
368
+ sincronizarClipes();
270
369
  pintar();
271
370
  }
272
371
 
372
+ /* Antes de exportar, os clipes precisam ter chegado — `drawImage` de um
373
+ <video> sem quadro decodificado desenha nada, e o resultado seria a
374
+ gravação do usuário faltando do vídeo dele. Espera com prazo: um arquivo
375
+ que não carrega não pode travar a exportação da peça inteira. */
376
+ function clipesProntos(prazo) {
377
+ var todos = [];
378
+ Array.prototype.forEach.call(palco.querySelectorAll("video"), function (v) {
379
+ if (v.readyState >= 2) return;
380
+ todos.push(new Promise(function (ok) {
381
+ var pronto = function () { v.removeEventListener("loadeddata", pronto); ok(); };
382
+ v.addEventListener("loadeddata", pronto);
383
+ v.addEventListener("error", pronto);
384
+ try { v.load(); } catch (e) {}
385
+ }));
386
+ });
387
+ if (!todos.length) return Promise.resolve();
388
+ return Promise.race([
389
+ Promise.all(todos),
390
+ new Promise(function (ok) { setTimeout(ok, prazo || 15000); }),
391
+ ]);
392
+ }
393
+
273
394
  /* ---------------------------------------------------------- narração -- */
274
395
 
275
396
  var vozGravada = null;
@@ -545,57 +666,62 @@ function escolherFormato() {
545
666
 
546
667
  function gravaMp4() { return /mp4/.test(escolherFormato()); }
547
668
 
669
+ /* Baixar o vídeo = DESENHAR o vídeo, não gravar a tela.
670
+ *
671
+ * Aqui havia getDisplayMedia: pedia permissão, o usuário escolhia uma
672
+ * janela, e o navegador salvava o que estivesse aparecendo. O ponteiro do
673
+ * mouse entrava no vídeo, notificação entrava no vídeo, e a resolução era
674
+ * a da janela em vez da do projeto. Quem vai VENDER a ferramenta não pede
675
+ * ao cliente para compartilhar a tela para exportar um arquivo dele.
676
+ *
677
+ * Agora o player TOCA e o video.js fotografa a cena que está no palco,
678
+ * quadro a quadro, para um canvas do tamanho exato do projeto. O áudio é
679
+ * o mesmo grafo de sempre — trilha, narração e efeitos, já mixados com o
680
+ * ducking — então o arquivo sai idêntico ao que se ouve aqui.
681
+ */
548
682
  function iniciarGravacao() {
549
- if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
550
- return aviso("este navegador não grava a tela", 3000);
683
+ var b = document.getElementById("tp-gravar");
684
+ if (!b || b.dataset.exportando) return;
685
+ if (typeof VideoExport === "undefined" || !VideoExport.disponivel()) {
686
+ return aviso("este navegador não sabe gerar vídeo", 3200);
551
687
  }
552
- // audio:true deixa o usuário marcar "compartilhar áudio da aba" — é assim
553
- // que a narração falada entra na gravação
554
- // preferCurrentTab tira o passo de escolher a janela: o Chrome já vem
555
- // com ESTA aba selecionada, então baixar vira um clique e um "permitir"
556
- // em vez de um clique, uma escolha e um "permitir".
557
- navigator.mediaDevices.getDisplayMedia({
558
- video: { frameRate: 30 }, audio: true,
559
- preferCurrentTab: true, selfBrowserSurface: "include",
560
- }).catch(function () {
561
- // Navegador que não conhece preferCurrentTab recusa o pedido inteiro:
562
- // repete sem as opções novas em vez de falhar.
563
- return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true });
564
- }).then(function (fluxo) {
565
- fluxoTela = fluxo;
566
- var faixas = fluxo.getVideoTracks();
567
- fluxo.getAudioTracks().forEach(function (f) { faixas.push(f); });
568
- if (trilha && trilha.fluxo) {
569
- trilha.fluxo.getAudioTracks().forEach(function (f) { faixas.push(f); });
570
- }
571
- var misto = new MediaStream(faixas);
572
- pedacos = [];
573
- // MP4 primeiro, e testando os codecs NOMEADOS antes do genérico.
574
- //
575
- // Testar só "video/mp4" perdia MP4 em navegador que existe: o Chrome
576
- // responde false para o nome solto e true para
577
- // "video/mp4;codecs=avc1.42E01E,mp4a.40.2". Quem pediu MP4 recebia
578
- // webm à toa. A lista vai do mais compatível (H.264 + AAC, que toca em
579
- // tudo) para o menos, e só cai no webm quando não há MP4 nenhum.
580
- var tipo = escolherFormato();
581
- gravador = new MediaRecorder(misto, { mimeType: tipo, videoBitsPerSecond: 8000000 });
582
- gravador.ondataavailable = function (ev) { if (ev.data.size) pedacos.push(ev.data); };
583
- gravador.onstop = baixarGravacao;
584
- fluxo.getVideoTracks()[0].onended = pararGravacao;
585
-
586
- gravador.start();
587
- document.getElementById("tp-gravar").classList.add("gravando");
588
- document.getElementById("tp-gravar").textContent = "Parar";
589
- buscar(0);
590
- tocar();
591
- aviso("gravando — o vídeo para sozinho no fim", 3000);
592
- }).catch(function () { aviso("gravação cancelada", 1500); });
688
+
689
+ b.dataset.exportando = "1";
690
+ b.classList.add("gravando");
691
+ aviso("montando o vídeo — não feche esta aba", 3000);
692
+
693
+ // O player é a fonte: ele sabe encadear cenas, narrar no tempo certo e
694
+ // tocar os efeitos. O exportador só pergunta o que está na tela.
695
+ var fonte = {
696
+ duracao: total(),
697
+ // A gravação do usuário precisa estar decodificada antes do primeiro
698
+ // quadro; sem isso ela sairia do arquivo final, e só nele.
699
+ preparar: function () { return clipesProntos(); },
700
+ comecar: function () { buscar(0); tocar(); },
701
+ parar: function () { pausar(); },
702
+ no: function () { return palco.children[atual] || null; },
703
+ };
704
+
705
+ VideoExport.baixar(arq, {
706
+ nome: arq.titulo,
707
+ fonte: fonte,
708
+ fluxoAudio: trilha && trilha.fluxo,
709
+ aoAndar: function (f) { b.textContent = Math.round(f * 100) + "%"; },
710
+ }).then(function (r) {
711
+ aviso(r.mp4 ? "vídeo salvo em .mp4" : "salvo em .webm (este navegador não faz mp4)", 3400);
712
+ }).catch(function (e) {
713
+ aviso("não consegui gerar: " + e.message, 4200);
714
+ }).then(function () {
715
+ delete b.dataset.exportando;
716
+ b.classList.remove("gravando");
717
+ b.textContent = "Baixar ▾";
718
+ });
593
719
  }
594
720
 
721
+
595
722
  function pararGravacao() {
596
723
  if (gravador && gravador.state === "recording") gravador.stop();
597
724
  if (gravadorAudio && gravadorAudio.state === "recording") gravadorAudio.stop();
598
- if (fluxoTela) fluxoTela.getTracks().forEach(function (f) { f.stop(); });
599
725
  var b = document.getElementById("tp-gravar");
600
726
  if (b) { b.classList.remove("gravando"); b.textContent = "Baixar ▾"; }
601
727
  }
@@ -11,6 +11,11 @@ var PAPEIS = {
11
11
  apoio: { tamanho: 30, peso: 400, altura: 1.45, cor: "var(--c-fraco)" },
12
12
  corpo: { tamanho: 25, peso: 400, altura: 1.5, classe: "corpo" },
13
13
  legenda: { tamanho: 18, peso: 400, altura: 1.45, cor: "var(--c-fraco)", classe: "corpo" },
14
+ /* Legenda de FALA, para vídeo — outra coisa de `legenda`, que é a nota de
15
+ rodapé de um slide. 18px sobre 1080p é ilegível no celular, e legenda de
16
+ vídeo é lida em movimento, muitas vezes sem som. Grande, com peso, e com
17
+ chapa escura atrás para funcionar sobre qualquer imagem. */
18
+ fala: { tamanho: 52, peso: 640, altura: 1.25, classe: "fala" },
14
19
  };
15
20
 
16
21
  var LAYOUTS = ["capa", "titulo", "secao", "duas-colunas", "tres-colunas",
@@ -157,7 +162,7 @@ function svgGrafico(e, l, a) {
157
162
 
158
163
  var TIPOS_CONHECIDOS = { texto: 1, lista: 1, cartoes: 1, numeros: 1, tabela: 1, codigo: 1,
159
164
  citacao: 1, forma: 1, imagem: 1, icone: 1, grafico: 1, no: 1, conector: 1,
160
- objeto3d: 1 };
165
+ objeto3d: 1, clipe: 1 };
161
166
 
162
167
  /* ── chaves de animação ──────────────────────────────────────────────────
163
168
  Transforma a lista de instantes de um elemento numa regra @keyframes de
@@ -611,6 +616,43 @@ function renderElemento(e, ctx) {
611
616
  break;
612
617
  }
613
618
 
619
+ /* ── clipe: um pedaço de vídeo do próprio usuário ────────────────────
620
+ "Eu gravo o vídeo, e aí ele vai editar: cortar as pausas demoradas,
621
+ legendar, colocar efeito, transição, uma música que combine."
622
+
623
+ Nada disso era possível — o estúdio não tinha como pôr o vídeo de
624
+ alguém dentro de uma cena. Um `clipe` é um trecho: o arquivo, de onde
625
+ começa e onde termina. Cortar as pausas vira uma lista de clipes do
626
+ MESMO arquivo, com os pedaços mudos fora.
627
+
628
+ `de` e `ate` são segundos no arquivo original. `mudo` deixa só a
629
+ imagem (para quando a trilha manda na cena), e `volume` mistura a voz
630
+ do vídeo com o resto. */
631
+ case "clipe": {
632
+ var vid = document.createElement("video");
633
+ vid.src = e.src || "";
634
+ vid.preload = "auto";
635
+ vid.playsInline = true;
636
+ vid.muted = !!e.mudo;
637
+ if (e.volume != null) vid.volume = Math.max(0, Math.min(1, e.volume));
638
+ vid.style.width = "100%";
639
+ vid.style.height = "100%";
640
+ vid.style.objectFit = e.ajuste === "conter" ? "contain" : "cover";
641
+ if (e.posicao) vid.style.objectPosition = e.posicao;
642
+ if (e.filtro) vid.style.filter = e.filtro === "cinza" ? "grayscale(1)"
643
+ : e.filtro === "sepia" ? "sepia(.7)"
644
+ : e.filtro === "contraste" ? "contrast(1.35) saturate(1.15)"
645
+ : e.filtro === "desbotado" ? "saturate(.55) brightness(1.08)"
646
+ : e.filtro;
647
+ // O trecho fica gravado no nó: quem toca (player) e quem exporta
648
+ // (video.js) leem daqui em vez de reabrir o documento.
649
+ if (typeof e.de === "number") vid.dataset.de = e.de;
650
+ if (typeof e.ate === "number") vid.dataset.ate = e.ate;
651
+ no.style.borderRadius = _px(e.raio != null ? e.raio : 0);
652
+ no.appendChild(vid);
653
+ break;
654
+ }
655
+
614
656
  case "icone": {
615
657
  var t = e.tamanho || 90;
616
658
  no.style.width = _px(e.l || t);
@@ -831,6 +873,25 @@ function renderCena(cena, doc, op) {
831
873
  var elementos = (cena.elementos || []).slice();
832
874
  var margem = Math.round(W * 0.068);
833
875
 
876
+ /* A gravação do usuário é o CHÃO da cena, não um bloco no meio do texto.
877
+ Entrando pelo fluxo, ela virava uma caixinha de 190px espremida embaixo
878
+ do título — e no vídeo exportado sobrava uma tarja de imagem cercada de
879
+ fundo. Quem grava o próprio rosto falando quer a imagem ocupando o
880
+ quadro, com a legenda POR CIMA.
881
+
882
+ Um clipe com `x`/`y` continua sendo elemento posicionado: aí a pessoa
883
+ pediu a caixinha de propósito (canto da tela, quadro dentro do quadro). */
884
+ var deChao = [];
885
+ var falas = [];
886
+ elementos = elementos.filter(function (e) {
887
+ if (!e || e.x != null || e.y != null) return true;
888
+ if (e.tipo === "clipe") { deChao.push(e); return false; }
889
+ // Legenda de fala mora no terço de baixo, como toda legenda de vídeo.
890
+ // Pelo fluxo ela iria para o meio da tela, em cima do rosto de quem fala.
891
+ if (e.tipo === "texto" && e.papel === "fala") { falas.push(e); return false; }
892
+ return true;
893
+ });
894
+
834
895
  var fluxo = document.createElement("div");
835
896
  fluxo.className = "fluxo";
836
897
  fluxo.setAttribute("data-layout", layout);
@@ -856,6 +917,43 @@ function renderCena(cena, doc, op) {
856
917
  })(),
857
918
  };
858
919
 
920
+ deChao.forEach(function (e) {
921
+ ctx.i++;
922
+ var el = renderElemento(e, ctx);
923
+ el.style.position = "absolute";
924
+ /* `inset: 0` sozinho não estica: renderElemento já deixou uma `height`
925
+ inline, e com altura declarada o `bottom` é ignorado (a regra do
926
+ over-constrained do CSS). A camada saía com 60px de altura — uma tarja
927
+ de imagem no topo, com fundo do tema no resto. Largura e altura ditas
928
+ na cara não têm essa conversa. */
929
+ el.style.left = "0"; el.style.top = "0"; el.style.right = ""; el.style.bottom = "";
930
+ el.style.width = "100%"; el.style.height = "100%";
931
+ el.style.zIndex = "0";
932
+ // Sem animação de entrada por padrão: deslizar o quadro inteiro da
933
+ // gravação não é efeito, é defeito. Quem pedir `anim` continua tendo.
934
+ if (!e.anim) { el.removeAttribute("data-an"); el.style.animation = "none"; }
935
+ no.insertBefore(el, fluxo); // atrás do texto, na frente do fundo
936
+ });
937
+ if (deChao.length) {
938
+ fluxo.style.position = "relative";
939
+ fluxo.style.zIndex = "1";
940
+ }
941
+
942
+ if (falas.length) {
943
+ var faixa = document.createElement("div");
944
+ faixa.className = "faixa-fala";
945
+ faixa.style.position = "absolute";
946
+ faixa.style.left = "0"; faixa.style.right = "0";
947
+ faixa.style.bottom = Math.round(H * 0.085) + "px";
948
+ faixa.style.zIndex = "2"; // acima do texto e da gravação
949
+ faixa.style.padding = "0 " + Math.round(W * 0.08) + "px";
950
+ falas.forEach(function (e) {
951
+ ctx.i++;
952
+ faixa.appendChild(renderElemento(e, ctx));
953
+ });
954
+ no.appendChild(faixa);
955
+ }
956
+
859
957
  if (layout === "livre") {
860
958
  fluxo.style.padding = "0";
861
959
  if (cena.auto) posicionarAuto(elementos, W, H, cena.auto);