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.
- package/app/app.css +230 -0
- package/app/app.js +273 -0
- package/app/icone.svg +6 -0
- package/app/index.html +68 -0
- package/bin/primocode.js +52 -0
- package/lib/app.js +334 -0
- package/lib/catalogo.js +2 -0
- package/lib/config.js +53 -2
- package/lib/studio.js +125 -11
- package/lib/tools.js +35 -1
- package/package.json +3 -2
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/arquivos/corte-estatico-corte-a5bab.json +150 -0
- package/studio/arquivos/corte-founders-ai-teste-do-player-2de1d.json +65 -0
- package/studio/arquivos/corte-legado-teste.json +1 -0
- package/studio/arquivos/corte-legado2.json +1 -0
- package/studio/arquivos/corte-minimo-2a829.json +24 -0
- package/studio/arquivos/corte-ok-00a3b.json +18 -0
- package/studio/arquivos/corte-ok-1270e.json +22 -0
- package/studio/arquivos/corte-sweep-corte-cfb29.json +147 -0
- package/studio/arquivos/corte-visual-completo-78f50.json +35 -0
- package/studio/arquivos/grade-sweep-grade-0c2f1.json +119 -0
- package/studio/arquivos/prisma-estatico-prisma-d8740.json +182 -0
- package/studio/arquivos/prisma-sweep-prisma-4c58c.json +166 -0
- package/studio/arquivos/prisma-y-b0785.json +52 -0
- package/studio/arquivos/prosa-sweep-prosa-f29cf.json +80 -0
- package/studio/arquivos/tela-estatico-tela-c7daf.json +90 -0
- package/studio/arquivos/tela-sweep-tela-0d2bf.json +84 -0
- package/studio/arquivos/traco-estatico-traco-25872.json +113 -0
- package/studio/arquivos/traco-sweep-traco-0bcf4.json +102 -0
- package/studio/ferramentas/corte.md +159 -12
- package/studio/server.py +264 -8
- package/studio/web/analise.html +52 -0
- package/studio/web/js/analise.js +202 -0
- package/studio/web/js/exportar.js +88 -1
- package/studio/web/js/player.js +172 -46
- package/studio/web/js/render.js +99 -1
- package/studio/web/js/video.js +434 -0
- package/studio/web/render.css +27 -0
- package/studio/web/tool.html +1 -0
- 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
|
})();
|
package/studio/web/js/player.js
CHANGED
|
@@ -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 = []
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
}
|
package/studio/web/js/render.js
CHANGED
|
@@ -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);
|