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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sagadeck",
3
- "version": "1.11.6",
3
+ "version": "1.11.7",
4
4
  "description": "Gerador de apresentações: YAML -> HTML animado + PowerPoint editável, com temas e figuras geradas na hora.",
5
5
  "type": "module",
6
6
  "bin": {
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
- const ctx = { baseDir: spec._dir || process.cwd(), theme, spec, warnings: [], mapTiles: inferLayout(s) === "map" ? tilesForDeck(loadMapConfig(mapaFile())) : null };
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 `data:image/${ext};base64,${fs.readFileSync(f).toString("base64")}`;
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);
@@ -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 { spawn } from "node:child_process";
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
- if (process.platform === "win32") spawn("explorer.exe", [dir], { detached: true, stdio: "ignore" }).unref();
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
+ }
@@ -418,12 +418,7 @@
418
418
  mainRenders++;
419
419
  let data;
420
420
  try {
421
- const res = await fetch("api/render-slide", {
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
- fetch("api/variety", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ spec: state.deck }) })
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
- while (!mainRenders && thumbActive < 3 && thumbQueue.length) {
1995
- const idx = thumbQueue.shift();
1996
- const slide = state.deck.slides[idx];
1997
- if (!slide) continue;
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
- fetch("api/render-slide", {
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(idx, slide, data.html);
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
- document.fonts?.ready.then(() => { run(); report(); }); // a fonte do tema pode chegar depois e mudar as medidas
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
+ }
@@ -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
- if (process.platform === "win32") spawn("explorer.exe", [`/select,${file}`], { detached: true, stdio: "ignore" }).unref();
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
- if (pathname === "/api/render-slide" && req.method === "POST") {
797
- const { slide, index, spec } = await readJSON(req);
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") || "";
@@ -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 = `data:${mime};base64,${fs.readFileSync(file).toString('base64')}`;
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=`data:image/${ext==='jpg'?'jpeg':ext};base64,${fs.readFileSync(file).toString('base64')}`;}
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