sagadeck 1.11.6 → 1.11.7
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/docs/ANDAMENTO.md +73 -0
- package/package.json +1 -1
- package/src/build.js +2 -1
- package/src/elements.js +9 -1
- package/src/studio/api-routes.js +2 -2
- package/src/studio/compress.js +88 -0
- package/src/studio/public/app.js +18 -20
- package/src/studio/public/index.html +1 -0
- package/src/studio/public/render-client.js +33 -0
- package/src/studio/render-routes.js +87 -0
- package/src/studio/reveal.js +12 -0
- package/src/studio/server.js +7 -10
- package/src/video-player.js +3 -2
package/docs/ANDAMENTO.md
CHANGED
|
@@ -8,6 +8,79 @@ começar. Trabalho em curso vai para o GitHub (commit + push) a cada etapa, nunc
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
+
## 1.11.7: navegação leve pela rede — 24/10/2026 (branch `claude/navegacao-leve`)
|
|
12
|
+
|
|
13
|
+
**A máquina toda lenta era culpa da suíte de testes.** Medido no Windows dela: 19 processos `explorer.exe`, todos
|
|
14
|
+
janelas "Temp - File Explorer", ~6 GB de memória e ~22 mil s de CPU cada (juntas, uns 3 núcleos ocupados o tempo todo,
|
|
15
|
+
vigiando a Temp, onde os testes criam e apagam milhares de arquivos). Origem: o teste das identidades
|
|
16
|
+
(`studio.test.js`) clica em Configurar, o servidor abria o Explorador de verdade com `/select,` no arquivo da pasta
|
|
17
|
+
temporária do teste; a pasta era apagada e a janela recuava para a Temp e ficava. Uma janela por rodada da suíte,
|
|
18
|
+
de 22/10 a 24/10. Corrigido: todo `explorer.exe` passa por `src/studio/reveal.js`, que não abre nada com
|
|
19
|
+
`SAGADECK_TEST_ISOLATED` (test/isolate.js); guarda em `guards.test.js` (nenhum outro arquivo de `src/` chama
|
|
20
|
+
`explorer.exe`). As 19 janelas foram fechadas (memória em uso: 20,3 GB → 15,7 GB; CPU em repouso < 1%).
|
|
21
|
+
|
|
22
|
+
**Troca de slide no editor (perfil de CPU na palestra de 39 slides dela, Studio local): 430 ms → ~45 ms.**
|
|
23
|
+
- `fitSlideText` ajustava o slide duas vezes por troca: na hora e de novo no `document.fonts.ready`, que já carregado
|
|
24
|
+
resolve na hora. Agora só repete se as fontes ainda estão chegando.
|
|
25
|
+
- `putThumb` recolocava o mesmo HTML na miniatura do slide atual a cada troca e media tudo de novo
|
|
26
|
+
(`runThumbFits`, ~35 ms). Agora guarda o que cada miniatura mostra (`thumbShown`, WeakMap) e não refaz o igual.
|
|
27
|
+
Teste em `studio-navigation.test.js` (a miniatura não é refeita; falha sem a correção).
|
|
28
|
+
- Medido e descartado: `content-visibility: auto` nas miniaturas não ajudou (a página tem ~4.300 elementos).
|
|
29
|
+
|
|
30
|
+
Pedido: "ficou super lento pra navegar nos slides". No Studio local (127.0.0.1) a troca de slide leva 20–80 ms e a
|
|
31
|
+
apresentação não perde quadro; o peso estava na rede, e pelo portal ninguém comprimia nada (nginx com `gzip on` mas sem
|
|
32
|
+
`gzip_types` nem `gzip_proxied`). Medido no servidor e no deck de teste:
|
|
33
|
+
- Cada troca de slide: `/api/render-slide` subia o deck inteiro e descia ~171 kB, dos quais 158 kB o CSS base (igual
|
|
34
|
+
em todo clique); `/api/variety` subia o deck inteiro de novo. Cada miniatura, um pedido com o deck inteiro.
|
|
35
|
+
- Abrir o editor: ~5,4 MB; abrir de novo: ~1,3 MB (`katex.css` com as fontes embutidas, 924 kB, sem cabeçalho de
|
|
36
|
+
cache).
|
|
37
|
+
|
|
38
|
+
Feito:
|
|
39
|
+
- `src/studio/render-routes.js` (sai do `server.js`): `render-slide` devolve `baseHash`/`themeHash` e só manda o CSS
|
|
40
|
+
quando o hash informado é outro; `render-slides` desenha até 24 slides num pedido. No editor,
|
|
41
|
+
`public/render-client.js` guarda o CSS e devolve a resposta preenchida (o resto do `app.js` não muda);
|
|
42
|
+
`pumpThumbs` pede em lotes de 6. A variedade sobe só `purpose` e `layout`/`tone` de cada slide.
|
|
43
|
+
- `src/studio/compress.js`: gzip para texto a partir de 1 kB quando o navegador aceita (nunca `x-ndjson` nem
|
|
44
|
+
`event-stream`, que chegam aos poucos); ETag nos arquivos da página fora de `/api/`, com 304 sem corpo e
|
|
45
|
+
`Cache-Control: no-cache` quando não há prazo próprio (versão nova chega na hora). Repassa o `drain` do gzip (sem
|
|
46
|
+
isso, arquivo servido por `pipe` travava: o teste pegou).
|
|
47
|
+
- Resultado (deck de teste, bytes na rede): trocar 10 vezes, 1.708 kB → 24 kB; abrir, 5,4 MB → 2,1 MB; abrir de novo,
|
|
48
|
+
1,3 MB → 55 kB; apresentação, 257 kB → 64 kB (304 sem mudança).
|
|
49
|
+
- Testes: `test/compress.test.js` (gzip, pequeno/sem aceite/HEAD/imagem, `pipe`, erro com diagnóstico, streams não
|
|
50
|
+
seguradas, ETag/304) e `test/studio-navigation.test.js` (no editor: CSS base uma vez, troca leve, miniaturas em lote,
|
|
51
|
+
variedade pequena, `app.js` em gzip, zero erros JS; falha com o editor antigo).
|
|
52
|
+
- **O maior peso eram as fotos**: `imageSrc` (e o vídeo, `video-player.js`) embutiam o arquivo em base64 no HTML de
|
|
53
|
+
cada slide. Um slide de modelo com foto: `render-slide` de 5.652 kB, o lote de 6 miniaturas de 2.840 kB, a cada
|
|
54
|
+
clique. Isso também fazia o desenho do slide da tela chegar depois do lote de miniaturas, e o formulário ficava no
|
|
55
|
+
slide anterior (o teste "coleções" da CI pegou). Agora `fileSrc` (elements.js) usa `ctx.assetURL` quando há:
|
|
56
|
+
`render-routes.js` passa `_assetURL`, e a foto vira `api/deck-asset?v=<data+tamanho>&p=<caminho>` (o caminho por
|
|
57
|
+
último: o player reconhece o vídeo pela terminação), com `Cache-Control: immutable`. Mesmo slide: 5 kB. A
|
|
58
|
+
apresentação, as exportações e as fotos para a IA continuam embutindo (o HTML vale sozinho).
|
|
59
|
+
- **Segurança (multiusuário)**: o render usava o `_dir` mandado pelo navegador para ler as imagens; dava para ler a
|
|
60
|
+
biblioteca de outra pessoa. Agora a pasta é a do deck aberto no servidor, e `/api/deck-asset` só entrega
|
|
61
|
+
imagem/vídeo dentro da biblioteca da pessoa, da pasta do deck e das pastas de modelos do sagadeck. Teste:
|
|
62
|
+
`studio-navigation.test.js` (foto por endereço que aparece, miniatura sem `data:`, 404 fora das pastas e para
|
|
63
|
+
arquivo que não é imagem, `_dir` do pedido ignorado; falha sem a correção).
|
|
64
|
+
- Com as fotos descendo pelas mesmas conexões, apareceram duas corridas (a CI pegou): o fim da transformação dizia
|
|
65
|
+
"A transformação terminou" antes de recarregar o deck (agora diz depois, com as miniaturas novas na tela), e as
|
|
66
|
+
fotos das miniaturas fora da tela disputavam a conexão (agora `loading="lazy"` no `render-slides`). O teste do
|
|
67
|
+
"trocar imagem" (`editor-tools.test.js`) conferia que o `src` não tinha "a.png", e "nova.png" tem: passa a exigir o
|
|
68
|
+
caminho da nova.
|
|
69
|
+
- Os testes da biblioteca (`library-ui.test.js`) estouram o tempo nesta máquina também na main (11 falhas, mesmo
|
|
70
|
+
código): carga da máquina, não regressão; na CI passam. A capa da vitrine (`gallery-cover`) leva ~15 s na primeira
|
|
71
|
+
vez (foto pelo Chrome) e segura o "rede parada" dos testes.
|
|
72
|
+
- Sobra, se ainda pesar: ~70 pedidos ao abrir o editor (cada um uma ida e volta, mesmo com 304); juntar os scripts
|
|
73
|
+
num só reduziria a espera em conexão lenta.
|
|
74
|
+
|
|
75
|
+
## Imagens da IA fora do repositório — 24/10/2026 (branch `claude/imagens-fora-do-repo`)
|
|
76
|
+
|
|
77
|
+
Dois testes ao vivo pedem imagem sem pasta de deck, e `materializeImages` grava em `process.cwd()`: rodando a bateria
|
|
78
|
+
do repositório, as fotos iam para `<repo>/imagens/`, e um `git add -A` levou 6 PNGs (~1,5 MB cada) e 2 imagens da web
|
|
79
|
+
para os commits das versões 1.11.2 e 1.11.3 (o pacote do npm não leva `imagens/`, não foi afetado). Saíram do git;
|
|
80
|
+
`/imagens/` foi para o `.gitignore`; `ai-live.test.js` roda num temporário (`process.chdir`). Guarda em
|
|
81
|
+
`guards.test.js` (nada de `imagens/` no git, o `.gitignore` e o temporário da bateria). O histórico antigo continua com
|
|
82
|
+
os arquivos (reescrever a main não compensa).
|
|
83
|
+
|
|
11
84
|
## 1.11.6: rodada de uso real — 23/10/2026 (branch `claude/parada-e-fotos`)
|
|
12
85
|
|
|
13
86
|
Quatro pedidos reais no sagadeck-dev (conta de teste `verificacao`, tópico "Rodada de uso real"), pelo caminho da
|
package/package.json
CHANGED
package/src/build.js
CHANGED
|
@@ -351,7 +351,8 @@ export function renderSlide(raw, i = 0, spec = {}) {
|
|
|
351
351
|
const deckTheme = resolveTheme(spec.theme, spec.palette, identityOf(spec));
|
|
352
352
|
const s = { ...(spec.defaults || {}), ...raw };
|
|
353
353
|
const theme = slideTheme(s, deckTheme, spec);
|
|
354
|
-
|
|
354
|
+
// _assetURL (só o editor do Studio, render-routes.js): imagens e vídeos por endereço, não embutidos no HTML
|
|
355
|
+
const ctx = { baseDir: spec._dir || process.cwd(), theme, spec, warnings: [], assetURL: typeof spec._assetURL === "function" ? spec._assetURL : null, mapTiles: inferLayout(s) === "map" ? tilesForDeck(loadMapConfig(mapaFile())) : null };
|
|
355
356
|
const layout = inferLayout(s);
|
|
356
357
|
const fn = LAYOUTS[layout];
|
|
357
358
|
if (!fn) throw new Error(`Slide ${i + 1}: layout "${layout}" não existe.`);
|
package/src/elements.js
CHANGED
|
@@ -213,7 +213,15 @@ export function imageSrc(p, ctx) {
|
|
|
213
213
|
return null;
|
|
214
214
|
}
|
|
215
215
|
const ext = path.extname(f).slice(1).toLowerCase().replace("jpg", "jpeg").replace("svg", "svg+xml");
|
|
216
|
-
return `
|
|
216
|
+
return fileSrc(f, `image/${ext}`, ctx);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Arquivo do deck dentro do HTML: embutido (data:), para o HTML exportado valer sozinho; no editor do Studio, o
|
|
220
|
+
// endereço do arquivo (ctx.assetURL, render-routes.js), que o navegador baixa uma vez e guarda. Embutida, a mesma foto
|
|
221
|
+
// descia em cada troca de slide e em cada miniatura (megabytes por clique pela internet).
|
|
222
|
+
export function fileSrc(f, mime, ctx = {}) {
|
|
223
|
+
if (ctx.assetURL) return ctx.assetURL(f);
|
|
224
|
+
return `data:${mime};base64,${fs.readFileSync(f).toString("base64")}`;
|
|
217
225
|
}
|
|
218
226
|
|
|
219
227
|
const isFigure = (el) => el && (el.icon || el.picto || el.diagram || el.chart || el.ufmap || el.svg || el.image || el.qr || el.points);
|
package/src/studio/api-routes.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import crypto from "node:crypto";
|
|
7
|
-
import {
|
|
7
|
+
import { reveal } from "./reveal.js";
|
|
8
8
|
import YAML from "yaml";
|
|
9
9
|
import { writeRecording, readRecordings, mimeOf } from "../api-client.js";
|
|
10
10
|
|
|
@@ -115,7 +115,7 @@ export async function apiRoutes({ req, res, pathname, url, W, apiEnv, rtSessions
|
|
|
115
115
|
if (pathname === "/api/http/vars/folder") {
|
|
116
116
|
const dir = path.dirname(apiEnv.file);
|
|
117
117
|
fs.mkdirSync(dir, { recursive: true });
|
|
118
|
-
|
|
118
|
+
reveal(dir); // nunca nos testes (reveal.js)
|
|
119
119
|
return reply(200, { dir });
|
|
120
120
|
}
|
|
121
121
|
if (pathname === "/api/http/send") return reply(200, await apiEnv.send({ ...(body.request || {}), file: fileOf(body) }));
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Respostas leves pela rede. No servidor, pelo portal, nada saía comprimido (o nginx só comprime o que ele mesmo gera)
|
|
2
|
+
// e nada ficava no cache do navegador: cada troca de slide baixava ~170 kB, e cada abertura do editor ~1,3 MB de
|
|
3
|
+
// novo (só o katex.css, com as fontes da matemática embutidas, tem 924 kB). Aqui, valendo em qualquer lugar (servidor,
|
|
4
|
+
// rede do banco, máquina da pessoa) sem depender do proxy:
|
|
5
|
+
// - gzip para texto (HTML, CSS, JS, JSON, SVG, XML) a partir de 1 kB, quando o navegador aceita; nunca o que chega aos
|
|
6
|
+
// poucos (respostas ao vivo da IA em x-ndjson, eventos em event-stream), que o gzip seguraria até juntar um bloco;
|
|
7
|
+
// - ETag nos arquivos da página (fora de /api/) entregues de uma vez: na visita seguinte o navegador pergunta se mudou
|
|
8
|
+
// e recebe 304, sem corpo. Sem prazo de cache próprio, vai "no-cache" (sempre pergunta): versão nova do sagadeck
|
|
9
|
+
// chega na hora, sem código velho preso no navegador.
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import zlib from "node:zlib";
|
|
12
|
+
|
|
13
|
+
const COMPRESSIBLE = /^(text\/(html|css|plain|javascript|xml)|application\/(json|javascript|xml)|image\/svg\+xml)\b/i;
|
|
14
|
+
const STREAMING = /event-stream|ndjson/i;
|
|
15
|
+
export const MIN_BYTES = 1024;
|
|
16
|
+
|
|
17
|
+
export function installCompression(req, res, { min = MIN_BYTES } = {}) {
|
|
18
|
+
if (req.method === "HEAD") return;
|
|
19
|
+
const gzipOk = /\bgzip\b/i.test(String(req.headers["accept-encoding"] || ""));
|
|
20
|
+
const conditional = req.method === "GET" && !String(req.url || "").startsWith("/api/");
|
|
21
|
+
if (!gzipOk && !conditional) return;
|
|
22
|
+
const writeHead = res.writeHead, write = res.write, end = res.end;
|
|
23
|
+
let pending = null; // writeHead adiado: o primeiro pedaço decide se comprime (e, inteiro, se mudou)
|
|
24
|
+
let gz = null, decided = false, notModified = false;
|
|
25
|
+
|
|
26
|
+
const compressibleNow = () => {
|
|
27
|
+
const type = String(res.getHeader("content-type") || "");
|
|
28
|
+
return COMPRESSIBLE.test(type) && !STREAMING.test(type) && !res.getHeader("content-encoding") && ![204, 304].includes(res.statusCode);
|
|
29
|
+
};
|
|
30
|
+
// decide e manda os cabeçalhos de verdade; body: a resposta inteira (end de uma vez) ou null (vem aos pedaços)
|
|
31
|
+
const flush = (body) => {
|
|
32
|
+
if (decided) return;
|
|
33
|
+
decided = true;
|
|
34
|
+
const reason = pending?.reason;
|
|
35
|
+
pending = null;
|
|
36
|
+
if (conditional && body && res.statusCode === 200 && !res.getHeader("etag")) {
|
|
37
|
+
const etag = `"${crypto.createHash("sha1").update(body).digest("base64url").slice(0, 22)}"`;
|
|
38
|
+
res.setHeader("ETag", etag);
|
|
39
|
+
if (!res.getHeader("cache-control")) res.setHeader("Cache-Control", "no-cache");
|
|
40
|
+
if (String(req.headers["if-none-match"] || "").split(/\s*,\s*/).includes(etag)) {
|
|
41
|
+
notModified = true;
|
|
42
|
+
res.statusCode = 304;
|
|
43
|
+
for (const h of ["content-length", "content-type", "content-encoding"]) res.removeHeader(h);
|
|
44
|
+
return writeHead.call(res, 304);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (gzipOk && compressibleNow() && (body == null || body.length >= min)) {
|
|
48
|
+
gz = zlib.createGzip({ level: 6 });
|
|
49
|
+
res.setHeader("Content-Encoding", "gzip");
|
|
50
|
+
res.removeHeader("Content-Length");
|
|
51
|
+
const vary = String(res.getHeader("vary") || "");
|
|
52
|
+
if (!/accept-encoding/i.test(vary)) res.setHeader("Vary", vary ? `${vary}, Accept-Encoding` : "Accept-Encoding");
|
|
53
|
+
gz.on("data", (c) => write.call(res, c));
|
|
54
|
+
gz.on("end", () => end.call(res));
|
|
55
|
+
// quem escreve com pipe (arquivo) para quando o write devolve false e espera o drain da resposta: é o gzip que drena
|
|
56
|
+
gz.on("drain", () => res.emit("drain"));
|
|
57
|
+
}
|
|
58
|
+
reason === undefined ? writeHead.call(res, res.statusCode) : writeHead.call(res, res.statusCode, reason);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
res.writeHead = function (code, a, b) {
|
|
62
|
+
const reason = typeof a === "string" ? a : undefined;
|
|
63
|
+
const headers = typeof a === "string" ? b : a;
|
|
64
|
+
res.statusCode = code;
|
|
65
|
+
if (Array.isArray(headers)) for (let i = 0; i + 1 < headers.length; i += 2) res.setHeader(headers[i], headers[i + 1]);
|
|
66
|
+
else if (headers) for (const [k, v] of Object.entries(headers)) if (v !== undefined) res.setHeader(k, v);
|
|
67
|
+
if (decided) return reason === undefined ? writeHead.call(res, code) : writeHead.call(res, code, reason);
|
|
68
|
+
// o que chega aos poucos (ou não é texto nem arquivo da página) sai na hora, como antes
|
|
69
|
+
const type = String(res.getHeader("content-type") || "");
|
|
70
|
+
if (STREAMING.test(type) || (!compressibleNow() && !conditional)) { decided = true; return reason === undefined ? writeHead.call(res, code) : writeHead.call(res, code, reason); }
|
|
71
|
+
pending = { reason };
|
|
72
|
+
return res;
|
|
73
|
+
};
|
|
74
|
+
res.write = function (chunk, enc, cb) {
|
|
75
|
+
if (!decided) flush(null);
|
|
76
|
+
if (notModified) return true;
|
|
77
|
+
return gz ? gz.write(chunk, enc, cb) : write.call(res, chunk, enc, cb);
|
|
78
|
+
};
|
|
79
|
+
res.end = function (chunk, enc, cb) {
|
|
80
|
+
if (typeof chunk === "function") { cb = chunk; chunk = undefined; enc = undefined; } else if (typeof enc === "function") { cb = enc; enc = undefined; }
|
|
81
|
+
if (!decided) flush(chunk == null ? Buffer.alloc(0) : Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8"));
|
|
82
|
+
if (notModified) return end.call(res, cb);
|
|
83
|
+
if (!gz) return end.call(res, chunk, enc, cb);
|
|
84
|
+
if (cb) res.once("finish", cb);
|
|
85
|
+
if (chunk != null) gz.end(chunk, enc); else gz.end();
|
|
86
|
+
return res;
|
|
87
|
+
};
|
|
88
|
+
}
|
package/src/studio/public/app.js
CHANGED
|
@@ -418,12 +418,7 @@
|
|
|
418
418
|
mainRenders++;
|
|
419
419
|
let data;
|
|
420
420
|
try {
|
|
421
|
-
|
|
422
|
-
method: "POST",
|
|
423
|
-
headers: { "Content-Type": "application/json" },
|
|
424
|
-
body: JSON.stringify({ slide, index: idx, spec: state.deck }),
|
|
425
|
-
});
|
|
426
|
-
data = await res.json();
|
|
421
|
+
data = await window.SagaRender.slide({ slide, index: idx, spec: state.deck }); // sem rebaixar o CSS (render-client.js)
|
|
427
422
|
} finally { mainRenders--; setTimeout(pumpThumbs); }
|
|
428
423
|
if (seq !== renderSeq) return; // já pediram um render mais novo
|
|
429
424
|
|
|
@@ -1174,7 +1169,8 @@
|
|
|
1174
1169
|
dom.storyArcDot.setAttribute("cy", currentPt.y);
|
|
1175
1170
|
|
|
1176
1171
|
// variedade medida no servidor (src/ai/variety.js — a mesma régua da geração com IA)
|
|
1177
|
-
|
|
1172
|
+
// só o que a régua usa (layout, tom, propósito): subia o deck inteiro a cada troca de slide
|
|
1173
|
+
fetch("api/variety", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ spec: { purpose: state.deck.purpose, slides: (state.deck.slides || []).map((s) => ({ layout: s.layout, tone: s.tone })) } }) })
|
|
1178
1174
|
.then((r) => r.json()).then((v) => {
|
|
1179
1175
|
const ok = v.ok;
|
|
1180
1176
|
dom.arcStatusBadge.className = `arc-badge ${ok ? "ok" : "warn"}`;
|
|
@@ -1857,6 +1853,7 @@
|
|
|
1857
1853
|
// Miniaturas = o próprio slide renderizado, em escala. Renderizadas sob demanda (quando aparecem
|
|
1858
1854
|
// na lista), com cache por conteúdo; o slide aberto atualiza a sua a cada edição (putThumb).
|
|
1859
1855
|
const thumbCache = new Map(); // chave (tema + índice + slide) -> html
|
|
1856
|
+
const thumbShown = new WeakMap(); // tela da miniatura -> html que ela mostra (putThumb não refaz o mesmo)
|
|
1860
1857
|
// tudo do deck que muda o desenho de um slide (tema, destaque, cabeçalho/rodapé e o que eles mostram)
|
|
1861
1858
|
const deckLook = () => {
|
|
1862
1859
|
const d = state.deck || {};
|
|
@@ -1901,6 +1898,7 @@
|
|
|
1901
1898
|
const cached = thumbCache.get(thumbKey(idx, slide));
|
|
1902
1899
|
if (cached) {
|
|
1903
1900
|
screen.innerHTML = `<div class="thumb-render">${cached}</div>`;
|
|
1901
|
+
thumbShown.set(screen, cached);
|
|
1904
1902
|
queueThumbFit(screen); // em fatias: reconstruir a lista não ajusta as 200 de uma vez
|
|
1905
1903
|
} else {
|
|
1906
1904
|
const fb = document.createElement("div");
|
|
@@ -1991,20 +1989,16 @@
|
|
|
1991
1989
|
}
|
|
1992
1990
|
|
|
1993
1991
|
function pumpThumbs() {
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
const
|
|
1997
|
-
if (
|
|
1992
|
+
// em lotes de até 6, com o deck subindo uma vez por lote (render-client.js); o CSS que a página já tem não volta
|
|
1993
|
+
while (!mainRenders && thumbActive < 2 && thumbQueue.length) {
|
|
1994
|
+
const deck = state.deck, batch = [];
|
|
1995
|
+
while (thumbQueue.length && batch.length < 6) { const idx = thumbQueue.shift(); if (deck.slides[idx]) batch.push(idx); }
|
|
1996
|
+
if (!batch.length) continue;
|
|
1998
1997
|
thumbActive++;
|
|
1999
|
-
|
|
2000
|
-
method: "POST",
|
|
2001
|
-
headers: { "Content-Type": "application/json" },
|
|
2002
|
-
body: JSON.stringify({ slide, index: idx, spec: state.deck }),
|
|
2003
|
-
})
|
|
2004
|
-
.then((r) => r.json())
|
|
1998
|
+
window.SagaRender.slides({ indices: batch, spec: deck })
|
|
2005
1999
|
.then((data) => {
|
|
2006
2000
|
ensureSlideStyles(data.baseCSS, data.themeCSS);
|
|
2007
|
-
putThumb(
|
|
2001
|
+
for (const r of data.slides) if (r.html != null && deck.slides[r.index]) putThumb(r.index, deck.slides[r.index], r.html);
|
|
2008
2002
|
})
|
|
2009
2003
|
.catch(() => {})
|
|
2010
2004
|
.finally(() => { thumbActive--; pumpThumbs(); });
|
|
@@ -2036,6 +2030,9 @@
|
|
|
2036
2030
|
thumbCache.set(thumbKey(idx, slide), html);
|
|
2037
2031
|
const screen = dom.thumbnailsList.querySelector(`.thumb-card[data-idx="${idx}"] .thumb-screen`);
|
|
2038
2032
|
if (!screen) return;
|
|
2033
|
+
// mesmo HTML já na tela: nada a refazer (cada troca de slide recolocava a miniatura dele e media tudo de novo)
|
|
2034
|
+
if (thumbShown.get(screen) === html) return;
|
|
2035
|
+
thumbShown.set(screen, html);
|
|
2039
2036
|
screen.innerHTML = `<div class="thumb-render">${html}</div>`;
|
|
2040
2037
|
queueThumbFit(screen);
|
|
2041
2038
|
// miniatura de diagrama também é desenhada, mas quando o navegador estiver livre: o desenho do Mermaid é pesado e
|
|
@@ -3066,7 +3063,8 @@
|
|
|
3066
3063
|
};
|
|
3067
3064
|
run();
|
|
3068
3065
|
report();
|
|
3069
|
-
|
|
3066
|
+
// a fonte do tema pode chegar depois e mudar as medidas; já carregada, medir de novo era o dobro do trabalho por troca
|
|
3067
|
+
if (document.fonts && document.fonts.status !== "loaded") document.fonts.ready.then(() => { run(); report(); });
|
|
3070
3068
|
}
|
|
3071
3069
|
|
|
3072
3070
|
// ==========================================================================
|
|
@@ -3299,8 +3297,8 @@
|
|
|
3299
3297
|
if (run) { work.update({ type: "progress", ...(run.progress || {}), phase: run.progress?.phase || "transform", mode: run.mode, text: run.progress?.text || "Transformando…" }); setTimeout(tick, 3000); return; }
|
|
3300
3298
|
work.done();
|
|
3301
3299
|
const job = st.jobs?.[0];
|
|
3300
|
+
await loadDeck(); // o aviso vem com o deck novo já na tela (antes, aparecia e as miniaturas ainda eram as velhas)
|
|
3302
3301
|
appendChatMessage("ai", job?.status === "parcial" ? "A transformação parou antes do fim; o que ficou pronto está guardado e pedir de novo continua de onde parou." : "A transformação terminou.");
|
|
3303
|
-
await loadDeck();
|
|
3304
3302
|
};
|
|
3305
3303
|
tick();
|
|
3306
3304
|
}
|
|
@@ -862,6 +862,7 @@
|
|
|
862
862
|
<button id="chat-fab" class="chat-fab hidden" type="button" title="Voltar para o chat" aria-label="Voltar para o chat"><i class="ic" data-ic="sparkles"></i><span class="chat-fab-dot" aria-hidden="true"></span></button>
|
|
863
863
|
|
|
864
864
|
<script src="fit.js"></script>
|
|
865
|
+
<script src="render-client.js"></script>
|
|
865
866
|
<script src="ui-icons.js"></script>
|
|
866
867
|
<script src="update-notice.js"></script>
|
|
867
868
|
<script src="ai-settings.js"></script>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Pedidos de desenho do editor (o slide da tela e as miniaturas) sem baixar de novo o CSS que a página já tem.
|
|
2
|
+
// O servidor (render-routes.js) devolve os hashes do CSS base e do CSS dos temas e só manda o texto quando o hash que
|
|
3
|
+
// o editor informou é outro. Aqui o CSS fica guardado e volta preenchido na resposta: quem chama recebe baseCSS e
|
|
4
|
+
// themeCSS como antes. Antes, cada troca de slide baixava ~170 kB (o CSS base inteiro), e cada miniatura era um pedido
|
|
5
|
+
// com o deck inteiro: pela internet, navegar nos slides ficava lento.
|
|
6
|
+
(function () {
|
|
7
|
+
"use strict";
|
|
8
|
+
const css = { base: null, theme: null, baseHash: null, themeHash: null };
|
|
9
|
+
|
|
10
|
+
function absorb(data) {
|
|
11
|
+
if (typeof data.baseCSS === "string") { css.base = data.baseCSS; css.baseHash = data.baseHash || null; }
|
|
12
|
+
if (typeof data.themeCSS === "string") { css.theme = data.themeCSS; css.themeHash = data.themeHash || null; }
|
|
13
|
+
return { ...data, baseCSS: css.base ?? "", themeCSS: css.theme ?? "" };
|
|
14
|
+
}
|
|
15
|
+
async function post(url, body) {
|
|
16
|
+
const res = await fetch(url, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "Content-Type": "application/json" },
|
|
19
|
+
body: JSON.stringify({ ...body, baseHash: css.base != null ? css.baseHash : undefined, themeHash: css.theme != null ? css.themeHash : undefined }),
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
22
|
+
return absorb(await res.json());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
window.SagaRender = {
|
|
26
|
+
// o slide da tela: { slide, index, spec } -> resposta do render-slide, com baseCSS/themeCSS preenchidos
|
|
27
|
+
slide: (body) => post("api/render-slide", body),
|
|
28
|
+
// miniaturas em lote: { indices, spec } -> { slides: [{ index, html }], baseCSS, themeCSS }
|
|
29
|
+
slides: (body) => post("api/render-slides", body),
|
|
30
|
+
// para os testes: o que está guardado
|
|
31
|
+
_cache: css,
|
|
32
|
+
};
|
|
33
|
+
})();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// O slide desenhado para o editor (/api/render-slide), as miniaturas em lote (/api/render-slides) e os arquivos do
|
|
2
|
+
// deck que elas mostram (/api/deck-asset). Pela internet (servidor, portal), navegar nos slides ficava lento:
|
|
3
|
+
// - o CSS base (~160 kB) e o dos temas, iguais em todo clique, desciam de novo a cada troca de slide. Agora o editor
|
|
4
|
+
// diz a versão que já tem (baseHash/themeHash) e só recebe o que mudou;
|
|
5
|
+
// - cada miniatura era um pedido, com o deck inteiro;
|
|
6
|
+
// - as fotos e os vídeos iam embutidos no HTML (base64): um slide com foto passava de 5 MB, em cada clique e em cada
|
|
7
|
+
// miniatura. Agora vão por endereço, com a data do arquivo no endereço: o navegador baixa uma vez e guarda.
|
|
8
|
+
// A pasta das imagens é a que o servidor conhece (a do deck aberto), nunca a que vem no pedido: no multiusuário, um
|
|
9
|
+
// `_dir` mandado pelo navegador lia arquivos da biblioteca de outra pessoa.
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { renderSlide } from "../build.js";
|
|
14
|
+
|
|
15
|
+
const hashes = new Map(); // texto do CSS -> hash (o CSS base é o mesmo enquanto o processo vive)
|
|
16
|
+
function hashOf(css) {
|
|
17
|
+
let h = hashes.get(css);
|
|
18
|
+
if (!h) {
|
|
19
|
+
h = crypto.createHash("sha1").update(css).digest("hex").slice(0, 16);
|
|
20
|
+
if (hashes.size > 50) hashes.clear();
|
|
21
|
+
hashes.set(css, h);
|
|
22
|
+
}
|
|
23
|
+
return h;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// a resposta sem o CSS que o editor já tem; sempre com os hashes, para ele pedir sem o CSS da próxima vez
|
|
27
|
+
export function withoutKnownCSS(rendered, { baseHash, themeHash } = {}) {
|
|
28
|
+
const { baseCSS = "", themeCSS = "", ...rest } = rendered;
|
|
29
|
+
const bh = hashOf(baseCSS), th = hashOf(themeCSS);
|
|
30
|
+
return { ...rest, baseHash: bh, themeHash: th, ...(baseHash === bh ? {} : { baseCSS }), ...(themeHash === th ? {} : { themeCSS }) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const MAX_BATCH = 24;
|
|
34
|
+
const ASSET_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", svg: "image/svg+xml", mp4: "video/mp4", webm: "video/webm", ogg: "video/ogg" };
|
|
35
|
+
|
|
36
|
+
// pastas de onde o editor pode mostrar arquivos: a biblioteca da pessoa, a pasta do deck aberto e as do sagadeck
|
|
37
|
+
function assetRoots(W, extra) {
|
|
38
|
+
return [W.library?.root, W.spec?._dir, W.file && path.dirname(W.file), ...extra].filter(Boolean).map((d) => path.resolve(d));
|
|
39
|
+
}
|
|
40
|
+
const inside = (f, dir) => f === dir || f.startsWith(dir + path.sep);
|
|
41
|
+
|
|
42
|
+
// endereço de um arquivo do deck (o caminho por último: o player reconhece o vídeo pela terminação)
|
|
43
|
+
export function assetURL(file) {
|
|
44
|
+
const st = fs.statSync(file, { throwIfNoEntry: false });
|
|
45
|
+
return `api/deck-asset?v=${st ? Math.round(st.mtimeMs).toString(36) + st.size.toString(36) : 0}&p=${encodeURIComponent(path.resolve(file))}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// o deck como o servidor conhece: a pasta das imagens é a do deck aberto, e os arquivos vão por endereço
|
|
49
|
+
function serverDeck(spec, W) {
|
|
50
|
+
const deck = { ...(spec || W.spec) };
|
|
51
|
+
if (W.spec) deck._dir = W.spec._dir; else delete deck._dir;
|
|
52
|
+
deck._assetURL = assetURL;
|
|
53
|
+
return deck;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function renderRoutes({ req, res, pathname, url, W, readJSON, roots = [] }) {
|
|
57
|
+
const json = (obj) => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); return true; };
|
|
58
|
+
if (pathname === "/api/render-slide" && req.method === "POST") {
|
|
59
|
+
const { slide, index, spec, baseHash, themeHash } = await readJSON(req);
|
|
60
|
+
return json(withoutKnownCSS(renderSlide(slide, index ?? 0, serverDeck(spec, W)), { baseHash, themeHash }));
|
|
61
|
+
}
|
|
62
|
+
if (pathname === "/api/render-slides" && req.method === "POST") {
|
|
63
|
+
const { indices, spec, baseHash, themeHash } = await readJSON(req);
|
|
64
|
+
const deck = serverDeck(spec, W);
|
|
65
|
+
const slides = [];
|
|
66
|
+
let first = null;
|
|
67
|
+
for (const i of [].concat(indices || []).slice(0, MAX_BATCH)) {
|
|
68
|
+
const s = deck.slides?.[i];
|
|
69
|
+
if (!s) continue;
|
|
70
|
+
// foto de miniatura só desce quando a miniatura aparece (as de fora da tela disputavam a conexão com o resto)
|
|
71
|
+
try { const r = renderSlide(s, i, deck); first ||= r; slides.push({ index: i, html: r.html.replace(/<img (?![^>]*\bloading=)/g, '<img loading="lazy" decoding="async" ') }); } catch (e) { slides.push({ index: i, error: e.message }); }
|
|
72
|
+
}
|
|
73
|
+
const css = first ? withoutKnownCSS(first, { baseHash, themeHash }) : {};
|
|
74
|
+
return json({ baseHash: css.baseHash, themeHash: css.themeHash, baseCSS: css.baseCSS, themeCSS: css.themeCSS, slides });
|
|
75
|
+
}
|
|
76
|
+
if (pathname === "/api/deck-asset" && (req.method === "GET" || req.method === "HEAD")) {
|
|
77
|
+
const file = path.resolve(String(url.searchParams.get("p") || ""));
|
|
78
|
+
const type = ASSET_TYPES[path.extname(file).slice(1).toLowerCase()];
|
|
79
|
+
const st = type && assetRoots(W, roots).some((d) => inside(file, d)) ? fs.statSync(file, { throwIfNoEntry: false }) : null;
|
|
80
|
+
if (!st?.isFile()) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Arquivo não encontrado." })); return true; }
|
|
81
|
+
// a data e o tamanho estão no endereço (v=): mudou o arquivo, muda o endereço; o mesmo endereço nunca muda
|
|
82
|
+
res.writeHead(200, { "Content-Type": type, "Content-Length": st.size, "Cache-Control": "private, max-age=31536000, immutable" });
|
|
83
|
+
if (req.method === "HEAD") res.end(); else fs.createReadStream(file).pipe(res);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Abre a pasta (ou mostra o arquivo selecionado) no Explorador do Windows: "abrir a pasta" das variáveis, "Configurar"
|
|
2
|
+
// das identidades. Nos testes (SAGADECK_TEST_ISOLATED, test/isolate.js), nunca: o teste das identidades clicava em
|
|
3
|
+
// Configurar e abria uma janela de verdade na pasta temporária do teste; a pasta era apagada, a janela voltava para a
|
|
4
|
+
// Temp e ficava aberta. Em dois dias eram 19 janelas, ~6 GB de memória e muita CPU vigiando a Temp (onde os testes
|
|
5
|
+
// criam e apagam milhares de arquivos): a máquina toda ficava lenta. Todo explorer.exe do Studio passa por aqui.
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
export function reveal(target, { select = false, platform = process.platform, env = process.env } = {}) {
|
|
9
|
+
if (platform !== "win32" || env.SAGADECK_TEST_ISOLATED === "1") return false;
|
|
10
|
+
spawn("explorer.exe", [select ? `/select,${target}` : target], { detached: true, stdio: "ignore" }).unref();
|
|
11
|
+
return true;
|
|
12
|
+
}
|
package/src/studio/server.js
CHANGED
|
@@ -2,6 +2,9 @@ import { lookAt, readBody, readJSON } from "./request-helpers.js";
|
|
|
2
2
|
import { workspaceResolver, scopePreviewHTML, staleTab } from "./workspaces.js";
|
|
3
3
|
import { cancellable, respond } from "./ai-response.js";
|
|
4
4
|
import {chatErrorResult,installErrorResponses} from './errors.js';
|
|
5
|
+
import { installCompression } from "./compress.js";
|
|
6
|
+
import { renderRoutes } from "./render-routes.js";
|
|
7
|
+
import { reveal } from "./reveal.js";
|
|
5
8
|
// sagadeck Studio · Servidor HTTP local para o editor visual PowerPoint + Chat Lateral IA
|
|
6
9
|
import { slideCopyRoutes } from "./slide-copy-routes.js";
|
|
7
10
|
import { publicScript } from "./public-files.js";
|
|
@@ -18,7 +21,6 @@ import crypto from "node:crypto";
|
|
|
18
21
|
import os from "node:os";
|
|
19
22
|
import path from "node:path";
|
|
20
23
|
import { fileURLToPath } from "node:url";
|
|
21
|
-
import { spawn } from "node:child_process";
|
|
22
24
|
import YAML from "yaml";
|
|
23
25
|
import { buildHTML, renderSlide, loadSpec, inferLayout, setFitDefaults } from "../build.js";
|
|
24
26
|
import { loadPreferences, savePreferences, preferencesFile, PREF_SCHEMA } from "../preferences.js";
|
|
@@ -401,6 +403,7 @@ export function createStudioServer(deckPath = null, opts = {}) {
|
|
|
401
403
|
res.setHeader("Content-Security-Policy", "frame-ancestors 'self'");
|
|
402
404
|
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
403
405
|
|
|
406
|
+
installCompression(req, res); // gzip quando o navegador aceita (compress.js); por dentro do ajuste das respostas de erro
|
|
404
407
|
installErrorResponses(res);
|
|
405
408
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
406
409
|
const pathname = url.pathname;
|
|
@@ -591,7 +594,7 @@ export function createStudioServer(deckPath = null, opts = {}) {
|
|
|
591
594
|
if (blocked) { res.writeHead(blocked.code, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Configurar identidades só no Studio desta máquina." })); return; }
|
|
592
595
|
const { ensureIdentitiesFile } = await import("../identity.js");
|
|
593
596
|
const file = ensureIdentitiesFile();
|
|
594
|
-
|
|
597
|
+
reveal(file, { select: true }); // nunca nos testes (reveal.js)
|
|
595
598
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
596
599
|
res.end(JSON.stringify({ file }));
|
|
597
600
|
return;
|
|
@@ -793,14 +796,8 @@ export function createStudioServer(deckPath = null, opts = {}) {
|
|
|
793
796
|
return;
|
|
794
797
|
}
|
|
795
798
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
const deckSpec = spec || W.spec;
|
|
799
|
-
const rendered = renderSlide(slide, index ?? 0, deckSpec);
|
|
800
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
801
|
-
res.end(JSON.stringify(rendered));
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
799
|
+
// o slide do editor e as miniaturas em lote, sem reenviar o CSS que o editor já tem (render-routes.js)
|
|
800
|
+
if (["/api/render-slide", "/api/render-slides", "/api/deck-asset"].includes(pathname) && await renderRoutes({ req, res, pathname, url, W, readJSON, roots: [MODEL_ASSETS_DIR, ...TEMPLATE_DIRS] })) return;
|
|
804
801
|
|
|
805
802
|
if (pathname === "/api/icons" && req.method === "GET") {
|
|
806
803
|
const q = url.searchParams.get("q") || "";
|
package/src/video-player.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { esc } from './markup.js';
|
|
4
|
+
import { fileSrc } from './elements.js';
|
|
4
5
|
import {iconSVG} from './figures/icons.js';
|
|
5
6
|
|
|
6
7
|
export function youtubeId(value) {
|
|
@@ -20,14 +21,14 @@ export function videoPlayer(e, ctx = {}) {
|
|
|
20
21
|
const file = path.resolve(ctx.baseDir || process.cwd(), url);
|
|
21
22
|
if (fs.existsSync(file)) {
|
|
22
23
|
const mime = /\.mp4$/i.test(file) ? 'video/mp4' : /\.webm$/i.test(file) ? 'video/webm' : 'video/ogg';
|
|
23
|
-
url =
|
|
24
|
+
url = fileSrc(file, mime, ctx); // no editor, o endereço do arquivo (elements.js: fileSrc)
|
|
24
25
|
} else { ctx.warnings?.push(`Vídeo não encontrado: ${url}`); return `<div class="fig-pending">${label}: arquivo não encontrado</div>`; }
|
|
25
26
|
}
|
|
26
27
|
if (/\.(mp4|webm|ogg)(?:\?|$)|^data:video\//i.test(url)) {
|
|
27
28
|
let poster=e.poster;
|
|
28
29
|
if(poster&&!/^(https?:|data:)/i.test(poster)){
|
|
29
30
|
const file=path.resolve(ctx.baseDir||process.cwd(),poster);
|
|
30
|
-
if(fs.existsSync(file)){const ext=path.extname(file).slice(1).toLowerCase();poster
|
|
31
|
+
if(fs.existsSync(file)){const ext=path.extname(file).slice(1).toLowerCase();poster=fileSrc(file,`image/${ext==='jpg'?'jpeg':ext}`,ctx);}
|
|
31
32
|
else ctx.warnings?.push(`Poster não encontrado: ${poster}`);
|
|
32
33
|
}
|
|
33
34
|
// loop silencioso (fundo vivo, detalhe animado): sem controles, repete sozinho
|