sagadeck 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +19 -8
  2. package/SKILL.md +10 -3
  3. package/bin/sagadeck.js +23 -0
  4. package/docs/ANDAMENTO.md +367 -7
  5. package/docs/PLANO-MELHORIAS.md +180 -0
  6. package/docs/REFERENCIA.md +131 -110
  7. package/package.json +33 -2
  8. package/src/ai/commands.js +2 -2
  9. package/src/ai/deck-ai.js +1 -1
  10. package/src/ai/ia-check.js +127 -0
  11. package/src/ai/ia-config.js +2 -2
  12. package/src/ai/llm.js +5 -1
  13. package/src/ai/map-analysis-run.js +1 -1
  14. package/src/ai/quality.js +1 -1
  15. package/src/ai/video-generation.js +70 -25
  16. package/src/build.js +46 -14
  17. package/src/elements.js +39 -1
  18. package/src/export/pptx.js +7 -2
  19. package/src/export/shots.js +7 -2
  20. package/src/fiscal/normalize.js +19 -0
  21. package/src/fs-retry.js +37 -2
  22. package/src/import/office.js +5 -4
  23. package/src/layouts.js +104 -99
  24. package/src/lessons.js +1 -1
  25. package/src/library.js +7 -7
  26. package/src/map-analysis.js +4 -1
  27. package/src/map-config.js +10 -4
  28. package/src/map.js +21 -8
  29. package/src/mcp/server.js +17 -0
  30. package/src/motion.js +20 -6
  31. package/src/purpose.js +1 -1
  32. package/src/runtime/base.css +88 -332
  33. package/src/runtime/fit.js +3 -2
  34. package/src/runtime/fonts/amatic-sc.css +3 -0
  35. package/src/runtime/fonts/bebas-neue.css +2 -0
  36. package/src/runtime/fonts/cabin-sketch.css +2 -0
  37. package/src/runtime/fonts/fraunces.css +3 -0
  38. package/src/runtime/fonts/gelasio.css +3 -0
  39. package/src/runtime/fonts/ibm-plex-mono.css +4 -0
  40. package/src/runtime/fonts/index.json +10 -1
  41. package/src/runtime/fonts/itim.css +2 -0
  42. package/src/runtime/fonts/montserrat.css +2 -0
  43. package/src/runtime/fonts/space-grotesk.css +2 -0
  44. package/src/runtime/map-background.js +91 -0
  45. package/src/runtime/map.js +41 -9
  46. package/src/runtime/runtime.js +81 -100
  47. package/src/runtime/skins/caderno.css +33 -0
  48. package/src/runtime/skins/cartaz.css +27 -0
  49. package/src/runtime/skins/infografico.css +29 -0
  50. package/src/runtime/skins/lousa.css +22 -0
  51. package/src/runtime/skins/musgo.css +42 -0
  52. package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
  53. package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -0
  54. package/src/runtime/vendor/geo-contornos.json +1 -1
  55. package/src/runtime/vendor/maplibre-gl.css +1 -0
  56. package/src/runtime/vendor/maplibre-gl.js +825 -0
  57. package/src/runtime/vendor/maplibre-leaflet.js +234 -0
  58. package/src/runtime/vendor/maplibre-worker.js +10 -0
  59. package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
  60. package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
  61. package/src/studio/command-approval.js +2 -3
  62. package/src/studio/demo-decks.js +4 -2
  63. package/src/studio/export-jobs.js +86 -0
  64. package/src/studio/exporting.js +36 -18
  65. package/src/studio/feature-demos.js +144 -0
  66. package/src/studio/ia-routes.js +9 -0
  67. package/src/studio/layout-samples.js +9 -18
  68. package/src/studio/library-routes.js +0 -9
  69. package/src/studio/project.js +1 -0
  70. package/src/studio/public/ai-settings.js +2 -2
  71. package/src/studio/public/app.js +41 -51
  72. package/src/studio/public/export-status.js +154 -0
  73. package/src/studio/public/index.html +13 -10
  74. package/src/studio/public/library.css +12 -3
  75. package/src/studio/public/library.html +1 -0
  76. package/src/studio/public/library.js +36 -14
  77. package/src/studio/public/map-data.js +7 -3
  78. package/src/studio/public/map-editor.js +625 -236
  79. package/src/studio/public/map-settings.js +3 -1
  80. package/src/studio/public/slide-form.js +18 -15
  81. package/src/studio/public/style.css +54 -9
  82. package/src/studio/public/ui-icons.js +1 -1
  83. package/src/studio/server.js +27 -12
  84. package/src/studio/snapshot.js +8 -4
  85. package/src/studio/template-collections.js +1 -1
  86. package/src/studio/tour.js +89 -7
  87. package/src/themes.js +154 -0
  88. package/templates/cenario-e-ciencia.yaml +1 -21
  89. package/templates/ensaio-api.yaml +3 -3
  90. package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
  91. package/templates/cenario/imagens/cidade.svg +0 -30
  92. package/templates/cenario/imagens/pessoa.svg +0 -15
@@ -0,0 +1,127 @@
1
+ // Diagnóstico da IA configurada, sem tela: cada recurso (texto, visão, imagem, pesquisa, vídeo) com uma chamada de
2
+ // verdade ao provedor, todos ao mesmo tempo, cada resultado avisado assim que chega. Para quem configura e para agentes
3
+ // (Copilot, Claude) saberem em segundos o que funciona, sem abrir o Studio. `sagadeck ia-check`, a ferramenta
4
+ // sagadeck_ia_check do MCP e POST /api/ia/check usam esta mesma função.
5
+ //
6
+ // Vídeo: por padrão só confere a chave e o modelo no catálogo, sem custo; com videoReal: true gera um clipe curto de
7
+ // verdade, baixa e confere que é um MP4 (é o único jeito de provar; nem todo provedor tem catálogo).
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import zlib from "node:zlib";
12
+ import { chat, generateImage, llmConfig, llmConfigured } from "./llm.js";
13
+ import { videoOperation, videoCatalog, videoSetup } from "./video-generation.js";
14
+
15
+ export const CHECKS = { texto: "Texto", visao: "Visão", imagem: "Geração de imagem", pesquisa: "Pesquisa na web", video: "Geração de vídeo" };
16
+ // prazo de cada um (s): um modelo travado não segura os outros
17
+ const PRAZO = { texto: 30, visao: 45, imagem: 120, pesquisa: 60 };
18
+
19
+ // ---- a imagem do teste de visão: duas faixas de cor, desenhada aqui mesmo (PNG mínimo, sem navegador)
20
+ const CORES = { vermelho: [229, 57, 53], azul: [30, 99, 233], verde: [46, 158, 68], amarelo: [245, 197, 24] };
21
+ const SINONIMOS = { vermelho: /vermelh|\bred\b/, azul: /\bazul|\bblue\b/, verde: /\bverde|\bgreen\b/, amarelo: /amarel|\byellow\b/ };
22
+ export function twoColorPNG(left, right, w = 96, h = 48) {
23
+ const row = Buffer.alloc(1 + w * 3);
24
+ for (let x = 0; x < w; x++) row.set(CORES[x < w / 2 ? left : right], 1 + x * 3);
25
+ const chunk = (type, data) => {
26
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
27
+ const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
28
+ const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(body));
29
+ return Buffer.concat([len, body, crc]);
30
+ };
31
+ const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
32
+ return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr),
33
+ chunk("IDAT", zlib.deflateSync(Buffer.concat(Array.from({ length: h }, () => row)))), chunk("IEND", Buffer.alloc(0))]);
34
+ }
35
+ // as duas cores: sorteadas (um palpite certo por acaso é improvável) ou fixas pelo seed (testes)
36
+ export function colorsFor(seed) {
37
+ const nomes = Object.keys(CORES), i = seed == null ? Math.floor(Math.random() * 12) : Number(seed) % 12;
38
+ const a = Math.floor(i / 3), b = (a + 1 + (i % 3)) % 4;
39
+ return [nomes[a], nomes[b]];
40
+ }
41
+ const IMG_MAGIC = [["image/png", "89504e47"], ["image/jpeg", "ffd8ff"], ["image/webp", "52494646"], ["image/gif", "47494638"]];
42
+ const today = (offsetH = -3) => new Date(Date.now() + offsetH * 3600_000).toISOString().slice(0, 10);
43
+ const quick = (cfg, s) => ({ ...cfg, timeoutMs: s * 1000, firstTimeoutMs: s * 1000 });
44
+ const ask = (cfg, model, content, s) => chat([{ role: "user", content }], { cfg: quick(cfg, s), model, think: false, temperature: 0, maxTokens: 80, allowTruncated: true, retries: 0 });
45
+ const said = (t) => `respondeu "${String(t || "").trim().replace(/\s+/g, " ").slice(0, 80)}"`;
46
+
47
+ const RUN = {
48
+ async texto({ cfg }) {
49
+ const r = await ask(cfg, cfg.textModel, "Responda só com a palavra: pronto", PRAZO.texto);
50
+ return /pronto/i.test(r.text) ? { status: "ok", modelo: cfg.textModel, detalhe: said(r.text) } : { status: "falhou", modelo: cfg.textModel, detalhe: `${said(r.text)} (esperado: pronto)` };
51
+ },
52
+ async visao({ cfg, seed }) {
53
+ const [esq, dir] = colorsFor(seed);
54
+ const url = `data:image/png;base64,${twoColorPNG(esq, dir).toString("base64")}`;
55
+ const r = await ask(cfg, cfg.visionModel, [{ type: "text", text: "A imagem tem duas faixas verticais de cor. Quais são as cores, da esquerda para a direita? Responda só as duas cores, em português, separadas por vírgula." }, { type: "image_url", image_url: { url } }], PRAZO.visao);
56
+ const modelo = r.visionRouted || cfg.visionModel;
57
+ if (r.imagesDropped) return { status: "falhou", modelo, detalhe: "o modelo não aceitou a imagem: respondeu sem vê-la. Escolha em Configurar IA um modelo de visão que aceite imagem." };
58
+ const t = String(r.text || "").toLowerCase(), m = (c) => t.search(SINONIMOS[c]);
59
+ const ok = m(esq) >= 0 && m(dir) >= 0 && m(esq) < m(dir);
60
+ return { status: ok ? "ok" : "falhou", modelo, detalhe: `${said(r.text)}${ok ? "" : ` (a imagem era ${esq}, ${dir})`}` };
61
+ },
62
+ async imagem({ cfg, outDir }) {
63
+ const img = await Promise.race([generateImage("a single red circle centered on a plain white background, flat, minimal", { cfg: quick(cfg, PRAZO.imagem) }),
64
+ new Promise((_, no) => setTimeout(() => no(new Error(`passou de ${PRAZO.imagem} s sem devolver a imagem`)), PRAZO.imagem * 1000).unref())]);
65
+ const hex = img.data.subarray(0, 4).toString("hex"), tipo = IMG_MAGIC.find(([, m]) => hex.startsWith(m))?.[0];
66
+ if (!tipo || img.data.length < 60) return { status: "falhou", modelo: cfg.imageModel, detalhe: `o que voltou não é uma imagem (${img.data.length} bytes)` };
67
+ const arquivo = path.join(outDir(), `imagem${tipo === "image/jpeg" ? ".jpg" : "." + tipo.split("/")[1]}`);
68
+ fs.writeFileSync(arquivo, img.data);
69
+ return { status: "ok", modelo: cfg.imageModel, detalhe: `${tipo}, ${Math.round(img.data.length / 1024)} KB`, arquivo };
70
+ },
71
+ async pesquisa({ cfg }) {
72
+ if (!cfg.searchModel) return { status: "nao_configurado", detalhe: "sem modelo de pesquisa (opcional: sem ele, a pesquisa usa os buscadores diretos)" };
73
+ const r = await ask(cfg, cfg.searchModel, "Pesquise na web: qual é a data de hoje no horário de Brasília? Responda só no formato AAAA-MM-DD.", PRAZO.pesquisa);
74
+ const d = String(r.text || "").match(/(\d{4})-(\d{2})-(\d{2})/)?.[0];
75
+ const dias = d ? Math.abs(Date.parse(d) - Date.parse(today())) / 86400_000 : Infinity;
76
+ return dias <= 1 ? { status: "ok", modelo: cfg.searchModel, detalhe: said(r.text) }
77
+ : { status: "falhou", modelo: cfg.searchModel, detalhe: `${said(r.text)}; hoje é ${today()} (não parece ter pesquisado)` };
78
+ },
79
+ // o mesmo caminho do produto (videoSetup): o modelo de vídeo do Configurar IA, com a chave e o adaptador de lá, ou as
80
+ // variáveis SAGADECK_VIDEO_KEY / OPENROUTER_API_KEY para quem já usava assim
81
+ async video({ cfg, env, fetcher, videoReal, videoTimeoutS, videoPollMs, outDir }) {
82
+ const setup = videoSetup({ env, cfg }), modelo = setup.model, de = ` (via ${setup.origem})`;
83
+ if (!setup.key && !setup.adaptador) return { status: "nao_configurado", modelo, detalhe: "ponha um modelo de vídeo em Configurar IA (usa a chave de lá) ou defina SAGADECK_VIDEO_KEY / OPENROUTER_API_KEY" };
84
+ const net = { setup, ...(fetcher !== fetch ? { fetcher } : {}) };
85
+ if (!videoReal) {
86
+ const cat = await videoCatalog(net);
87
+ if (!cat.ok) return { status: "sem_prova", modelo, detalhe: `${cat.motivo}; só o teste real prova: --video-real${de}` };
88
+ const meta = cat.models.find((m) => m.id === modelo || m.canonical_slug === modelo);
89
+ return meta ? { status: "sem_prova", modelo, detalhe: `o modelo está no catálogo, mas a chave e a geração só se provam gerando: --video-real (é cobrado)${de}` }
90
+ : { status: "falhou", modelo, detalhe: `o modelo não está no catálogo do provedor (${cat.models.length} modelos)` };
91
+ }
92
+ const cwd = outDir();
93
+ const sub = await videoOperation({ action: "submit", model: modelo, prompt: "a small red ball rolling slowly across a plain white table", duration: "min", resolution: "min", out: "video.mp4" }, { cwd, ...net });
94
+ const limite = Date.now() + videoTimeoutS * 1000;
95
+ let st = { status: sub.status };
96
+ while (!["completed", "failed", "error", "cancelled"].includes(st.status)) {
97
+ if (Date.now() > limite) return { status: "falhou", modelo, detalhe: `o clipe não ficou pronto em ${videoTimeoutS} s (job ${sub.id}, último estado: ${st.status || "?"})` };
98
+ await new Promise((r) => setTimeout(r, videoPollMs));
99
+ st = await videoOperation({ action: "status", id: sub.id }, { cwd, ...net });
100
+ }
101
+ if (st.status !== "completed") return { status: "falhou", modelo, detalhe: `o provedor encerrou com ${st.status}${st.error ? `: ${typeof st.error === "string" ? st.error : JSON.stringify(st.error)}` : ""}` };
102
+ const dl = await videoOperation({ action: "download", id: sub.id }, { cwd, ...net });
103
+ return { status: "ok", modelo, detalhe: `MP4 de ${Math.round(dl.bytes / 1024)} KB${dl.cost != null ? `, custou $${Number(dl.cost).toFixed(2)}` : ""}${de}`, arquivo: path.join(cwd, dl.video) };
104
+ },
105
+ };
106
+
107
+ const NEEDS_LLM = new Set(["texto", "visao", "imagem", "pesquisa"]);
108
+ // only: lista de ids (padrão: todos); rapido: sem imagem e vídeo (os lentos e cobrados); videoReal: gera um clipe
109
+ export async function iaCheck({ only, rapido = false, videoReal = false, videoTimeoutS = 600, videoPollMs = 5000, cfg = llmConfig(), env = process.env, fetcher = fetch, seed, outDir, onResult = () => {} } = {}) {
110
+ const ids = (only?.length ? only : Object.keys(CHECKS)).filter((id) => CHECKS[id] && !(rapido && (id === "imagem" || id === "video")));
111
+ let dir = outDir;
112
+ const out = () => (dir ||= fs.mkdtempSync(path.join(os.tmpdir(), "sagadeck-ia-check-")));
113
+ const t0 = Date.now();
114
+ const resultados = await Promise.all(ids.map(async (id) => {
115
+ const start = Date.now();
116
+ let r;
117
+ if (NEEDS_LLM.has(id) && !llmConfigured(cfg)) r = { status: "nao_configurado", detalhe: cfg.problem || "a IA não está configurada (Configurar IA, no Studio, ou ~/.sagadeck/ia.json)" };
118
+ else {
119
+ try { r = await RUN[id]({ cfg, env, fetcher, seed, videoReal, videoTimeoutS, videoPollMs, outDir: out }); }
120
+ catch (e) { r = { status: "falhou", detalhe: e?.message || String(e) }; }
121
+ }
122
+ const res = { id, nome: CHECKS[id], ms: Date.now() - start, ...r };
123
+ onResult(res);
124
+ return res;
125
+ }));
126
+ return { ok: resultados.every((r) => r.status !== "falhou"), ms: Date.now() - t0, provedor: cfg.url || cfg.adaptador || "", resultados };
127
+ }
@@ -17,10 +17,10 @@ export const PROVIDERS = {
17
17
  // responde, o problema é o ambiente (rede, chave), não o modelo escolhido. Depois, cada um põe o modelo que quiser.
18
18
  export const RECOMMENDED = {
19
19
  provider: "openrouter",
20
- models: { text: "deepseek/deepseek-v4.1-flash", vision: "deepseek/deepseek-v4.1-flash", image: "google/gemini-3.1-flash-image", search: "deepseek/deepseek-v4.1-flash:online" },
20
+ models: { text: "deepseek/deepseek-v4.1-flash", vision: "deepseek/deepseek-v4.1-flash", image: "google/gemini-3.1-flash-image", search: "deepseek/deepseek-v4.1-flash:online", video: "google/veo-3.1-lite" },
21
21
  };
22
22
  // os papéis que o sagadeck usa; cada um aponta para um modelo do provedor
23
- export const ROLES = ["text", "vision", "image", "search"];
23
+ export const ROLES = ["text", "vision", "image", "search", "video"];
24
24
 
25
25
  export function iaFile(env = process.env, home = os.homedir()) {
26
26
  return env.SAGADECK_IA || path.join(home, ".sagadeck", "ia.json");
package/src/ai/llm.js CHANGED
@@ -48,6 +48,8 @@ export function llmConfig(env = process.env) {
48
48
  visionModel: env.SAGADECK_VISION_MODEL || m.vision || m.text || "vision",
49
49
  // busca na web pelo modelo (no OpenRouter, um modelo ":online"); sem ele, a pesquisa usa os buscadores diretos
50
50
  searchModel: env.SAGADECK_SEARCH_MODEL || m.search || (fromEnv ? "search" : ""),
51
+ // vídeo (opcional): com ele, a geração de vídeo usa esta mesma camada (src/ai/video-generation.js, videoSetup)
52
+ videoModel: env.SAGADECK_VIDEO_MODEL || m.video || "",
51
53
  headers: ia?.headers || {},
52
54
  adaptador: ia?.adaptador || "",
53
55
  source: fromEnv ? "variável" : ia ? "arquivo" : "nenhuma",
@@ -61,7 +63,7 @@ export const llmConfigured = (cfg = llmConfig()) => !!(cfg.url || cfg.adaptador)
61
63
 
62
64
  // o papel (text, vision, image, search) vira o modelo configurado; outro nome passa como veio
63
65
  export function resolveModel(cfg, name) {
64
- const roles = { text: cfg.textModel, vision: cfg.visionModel, image: cfg.imageModel, search: cfg.searchModel };
66
+ const roles = { text: cfg.textModel, vision: cfg.visionModel, image: cfg.imageModel, search: cfg.searchModel, video: cfg.videoModel };
65
67
  return roles[name] || name;
66
68
  }
67
69
 
@@ -74,6 +76,8 @@ function headers(cfg) {
74
76
  // Adaptador (opcional, da pessoa): um .mjs cujo default é (url, init) => Response. Serve para o que não é compatível
75
77
  // com a API da OpenAI (outro jeito de autenticar, outro endereço) sem mexer no sagadeck. Sem ele, o fetch normal.
76
78
  const adapters = new Map();
79
+ // (exportado: a geração de vídeo manda pelo mesmo caminho, com o mesmo adaptador)
80
+ export const providerFetch = (cfg, url, init) => send(cfg, url, init);
77
81
  async function send(cfg, url, init) {
78
82
  if (!cfg.adaptador) return fetch(url, init);
79
83
  if (!adapters.has(cfg.adaptador)) adapters.set(cfg.adaptador, import(pathToFileURL(path.resolve(cfg.adaptador)).href).then((mod) => mod.default));
@@ -67,7 +67,7 @@ export async function runAnalysis(a, { P, cfg, onProgress = () => {} }) {
67
67
  const lines = readShapes(P, a.linha).filter((f) => /LineString/.test(f.geometry?.type || ""));
68
68
  if (!lines.length) throw new Error(`${a.linha} não tem linhas`);
69
69
  const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `${a.linha.replace(/\.[^.]+$/, "")}-faixa-${Math.round(metros)}m.geojson`;
70
- Project.writeText(P, arquivo, fc(lines.map((f, k) => ({ type: "Feature", properties: { nome: `${nameOf(f.properties, k)} (faixa de ${fmtMetros(metros)})` }, geometry: lineBuffer(f.geometry, metros) }))));
70
+ Project.writeText(P, arquivo, fc(lines.map((f, k) => ({ type: "Feature", properties: { nome: `${nameOf(f.properties, k)} (faixa de ${fmtMetros(metros)})`, _faixa: true }, geometry: lineBuffer(f.geometry, metros) }))));
71
71
  let extra = "";
72
72
  if (a.pontos) {
73
73
  const pts = await readPoints(P, a.pontos);
package/src/ai/quality.js CHANGED
@@ -51,7 +51,7 @@ export function auditText(spec, indices, { briefing = '', render = null } = {})
51
51
  // título; o layout da figura já reserva a área. Vale para os layouts com cabeçalho comum (não capa/seção/manchete).
52
52
  for (const index of indices) {
53
53
  const s = slides[index];
54
- if (!s || !Number.isFinite(Number(s.titleSize)) || ONE_LINER.has(s.layout) || ['cover', 'section', 'end', 'kinetic', 'scenography', 'full'].includes(s.layout)) continue;
54
+ if (!s || !Number.isFinite(Number(s.titleSize)) || ONE_LINER.has(s.layout) || ['cover', 'section', 'end', 'full'].includes(s.layout)) continue;
55
55
  if (Number(s.titleSize) < 60) issues.push({ slide: index + 1, text: `Título com titleSize ${s.titleSize}, bem menor que o dos outros slides: a hierarquia do deck fica desigual. Tire o titleSize (o layout já dá a área da figura); se o título não couber, encurte o texto.` });
56
56
  }
57
57
  // muitas telas de frase solta: a partir da terceira (ou de 15% do deck), cada uma precisa justificar o lugar
@@ -1,28 +1,65 @@
1
1
  import { mediaPrompt } from './art-direction.js';
2
+ import { llmConfig, providerFetch } from './llm.js';
2
3
  // Operações curtas e retomáveis: o chat não fica preso esperando o render do provedor.
3
4
  import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import crypto from 'node:crypto';
6
7
 
7
8
  const BASE='https://openrouter.ai/api/v1';
9
+ export const DEFAULT_VIDEO_MODEL='google/veo-3.1-lite';
10
+
11
+ // De onde o vídeo sai. Com um modelo de vídeo em Configurar IA (papel "video"), pela mesma camada da IA: o endereço, a
12
+ // chave, os cabeçalhos e o adaptador de lá, como texto e imagem. Sem ele, como sempre foi: OpenRouter, com
13
+ // SAGADECK_VIDEO_KEY ou OPENROUTER_API_KEY. SAGADECK_VIDEO_URL e SAGADECK_VIDEO_MODEL trocam endereço e modelo.
14
+ export function videoSetup({env=process.env,cfg=llmConfig(env)}={}){
15
+ const viaConfig=!!cfg.videoModel&&!!(cfg.url||cfg.adaptador);
16
+ return {
17
+ base:String(env.SAGADECK_VIDEO_URL||(viaConfig?cfg.url:BASE)).replace(/\/+$/,''),
18
+ key:env.SAGADECK_VIDEO_KEY||(viaConfig?cfg.key:'')||env.OPENROUTER_API_KEY||'',
19
+ model:env.SAGADECK_VIDEO_MODEL||cfg.videoModel||DEFAULT_VIDEO_MODEL,
20
+ headers:viaConfig?cfg.headers||{}:{},adaptador:viaConfig?cfg.adaptador||'':'',
21
+ origem:viaConfig?'configuração da IA':'variáveis de ambiente',
22
+ };
23
+ }
24
+ // a conexão de cada operação: setup explícito, ou {key, fetcher} (compatível com quem já chamava assim, no OpenRouter)
25
+ function conn({setup,key,fetcher}={}){
26
+ const s=setup||(key!==undefined?{base:BASE,key,model:DEFAULT_VIDEO_MODEL,headers:{},adaptador:''}:videoSetup());
27
+ const call=fetcher||(s.adaptador?(url,init)=>providerFetch({adaptador:s.adaptador},url,init):fetch);
28
+ const auth=s.key?{Authorization:`Bearer ${s.key}`}:{};
29
+ return {...s,base:String(s.base||BASE).replace(/\/+$/,''),call,auth:{...(s.headers||{}),...auth}};
30
+ }
31
+ // o catálogo de modelos de vídeo, quando o provedor tem um (nem todo tem): { ok, models } ou { ok: false, motivo }
32
+ export async function videoCatalog(opts={}){
33
+ const c=conn(opts);
34
+ try{
35
+ const r=await c.call(`${c.base}/videos/models`,{method:'GET',headers:c.auth,signal:AbortSignal.timeout(15000)});
36
+ if(r.status===401||r.status===403)return{ok:false,motivo:`a chave foi recusada (HTTP ${r.status})`};
37
+ if(!r.ok)return{ok:false,motivo:`o provedor não tem catálogo de vídeo (HTTP ${r.status})`};
38
+ const data=(await r.json()).data;
39
+ return Array.isArray(data)?{ok:true,models:data}:{ok:false,motivo:'o provedor não devolveu um catálogo de vídeo'};
40
+ }catch(e){return{ok:false,motivo:`o catálogo não respondeu (${e.message})`};}
41
+ }
8
42
  export function parseVideoArgs(args){
9
43
  const result={};for(let i=0;i<args.length;i++){
10
44
  const m=args[i].match(/^--([^=]+)(?:=(.*))?$/);if(!m)throw new Error(`Argumento inesperado: ${args[i]}`);
11
45
  result[m[1]]=m[2]??(args[i+1]&&!args[i+1].startsWith('--')?args[++i]:true);
12
46
  }return result;
13
47
  }
14
- export function videoApiURL(value){
15
- const url=new URL(String(value).startsWith('/api/v1/')?value:String(value).replace(/^\//,''),BASE+'/');
16
- if(url.origin!=='https://openrouter.ai'||!url.pathname.startsWith('/api/v1/videos'))throw new Error('Polling precisa usar a API de vídeo do OpenRouter.');
48
+ // endereço de uma operação de vídeo: relativo à base configurada, ou absoluto mas no MESMO servidor e caminho (a
49
+ // chave nunca vai para outro lugar, mesmo que a resposta do provedor mande consultar outro endereço)
50
+ export function videoApiURL(value,base=BASE){
51
+ const b=new URL(String(base).replace(/\/+$/,'')+'/'),v=String(value);
52
+ const url=/^https?:/i.test(v)?new URL(v):v.startsWith(b.pathname)?new URL(v,b.origin):new URL(v.replace(/^\//,''),b);
53
+ if(url.origin!==b.origin||!url.pathname.startsWith(b.pathname+'videos'))throw new Error(`O acompanhamento do vídeo precisa usar o mesmo endereço de vídeo configurado (${b.host}): a chave não vai para outro servidor.`);
17
54
  return url.href;
18
55
  }
19
56
  // quanto custa ANTES de gerar (para mostrar na aprovação): catálogo é de graça; null se não der para estimar
20
- export async function estimateVideoCost({model,duration,resolution},{key=process.env.SAGADECK_VIDEO_KEY||process.env.OPENROUTER_API_KEY,fetcher=fetch}={}){
57
+ export async function estimateVideoCost({model,duration,resolution},opts={}){
21
58
  try{
22
- if(!key)return null;
23
- const r=await fetcher(`${BASE}/videos/models`,{headers:{Authorization:`Bearer ${key}`},signal:AbortSignal.timeout(15000)});
24
- if(!r.ok)return null;
25
- const meta=(await r.json()).data?.find((m)=>m.id===model||m.canonical_slug===model);
59
+ const c=conn(opts);
60
+ if(!c.key&&!c.adaptador)return null;
61
+ const cat=await videoCatalog(opts);if(!cat.ok)return null;
62
+ const meta=cat.models.find((m)=>m.id===(model||c.model)||m.canonical_slug===(model||c.model));
26
63
  const skus=meta?.pricing_skus||meta?.pricing||null;
27
64
  const perSec=Number(skus?.[/1080|1k|2k|4k/i.test(String(resolution||''))?'per-video-second-1080p':'per-video-second']);
28
65
  if(!Number.isFinite(perSec)||perSec<0)return null;
@@ -35,22 +72,28 @@ function within(dir,name){
35
72
  if(!rel||rel.startsWith('..')||path.isAbsolute(rel))throw new Error('Salve o vídeo dentro da pasta da apresentação.');
36
73
  return target;
37
74
  }
38
- export async function videoOperation(request,{cwd,key=process.env.SAGADECK_VIDEO_KEY||process.env.OPENROUTER_API_KEY,fetcher=fetch}={}){
75
+ export async function videoOperation(request,{cwd,...opts}={}){
39
76
  if(request.action==='frame'){const {videoFrame}=await import('./video-frames.js');return videoFrame(request,{cwd});}
40
- if(!key)throw new Error('Configure SAGADECK_VIDEO_KEY ou OPENROUTER_API_KEY para a API de vídeo. O modelo de texto não gera vídeo.');
77
+ const c=conn(opts);
78
+ if(!c.key&&!c.adaptador)throw new Error('Configure o vídeo: um modelo de vídeo em Configurar IA, ou SAGADECK_VIDEO_KEY / OPENROUTER_API_KEY. O modelo de texto não gera vídeo.');
41
79
  const api=async(value,body)=>{
42
- const r=await fetcher(videoApiURL(value),{method:body?'POST':'GET',headers:{Authorization:`Bearer ${key}`,'Content-Type':'application/json'},...(body?{body:JSON.stringify(body)}:{}),signal:AbortSignal.timeout(30000)});
80
+ const r=await c.call(videoApiURL(value,c.base),{method:body?'POST':'GET',headers:{...c.auth,'Content-Type':'application/json'},...(body?{body:JSON.stringify(body)}:{}),signal:AbortSignal.timeout(30000)});
43
81
  if(!r.ok)throw new Error(`API de vídeo retornou HTTP ${r.status}.`);return r.json();
44
82
  };
45
83
  const action=request.action||'plan';
46
84
  if(['plan','submit'].includes(action)){
47
- const model=request.model||'google/veo-3.1-lite',catalog=await api('/videos/models');
48
- const meta=catalog.data?.find(m=>m.id===model||m.canonical_slug===model);if(!meta)throw new Error(`Modelo de vídeo indisponível: ${model}`);
49
- const body={model,prompt:String(request.prompt||''),duration:Number(request.duration||4),resolution:request.resolution||'720p',aspect_ratio:request.aspect||'16:9',generate_audio:false};
85
+ // o catálogo valida modelo, duração e resolução quando existe; sem ele (nem todo provedor tem), o pedido vai como
86
+ // veio e quem recusa é a API. duration/resolution "min": o clipe mais curto e leve que o modelo aceita (teste)
87
+ const model=request.model||c.model,cat=await videoCatalog(opts);
88
+ const meta=cat.ok?cat.models.find(m=>m.id===model||m.canonical_slug===model):null;if(cat.ok&&!meta)throw new Error(`Modelo de vídeo indisponível: ${model}`);
89
+ const px=(r)=>Number(String(r).match(/\d+/)?.[0]||0)*(/k/i.test(String(r))?1000:1);
90
+ const duration=request.duration==='min'?Math.min(...(meta?.supported_durations?.map(Number).filter(Number.isFinite)||[4])):Number(request.duration||4);
91
+ const resolution=request.resolution==='min'?([...(meta?.supported_resolutions||[])].sort((a,b)=>px(a)-px(b))[0]||'720p'):request.resolution||'720p';
92
+ const body={model,prompt:String(request.prompt||''),duration,resolution,aspect_ratio:request.aspect||'16:9',generate_audio:false};
50
93
  if(!body.prompt.trim())throw new Error('Descreva o clipe antes de gerar.');
51
94
  body.prompt=mediaPrompt(body.prompt); // plan mostra exatamente o prompt que submit enviará.
52
95
  if(!Number.isInteger(body.duration)||body.duration<1)throw new Error('Duração precisa ser um número inteiro positivo.');
53
- for(const [field,list] of [['duration',meta.supported_durations],['resolution',meta.supported_resolutions],['aspect_ratio',meta.supported_aspect_ratios]])if(list?.length&&!list.map(String).includes(String(body[field])))throw new Error(`${field} não suportado por ${model}.`);
96
+ for(const [field,list] of [['duration',meta?.supported_durations],['resolution',meta?.supported_resolutions],['aspect_ratio',meta?.supported_aspect_ratios]])if(list?.length&&!list.map(String).includes(String(body[field])))throw new Error(`${field} não suportado por ${model}.`);
54
97
  if(request.seed!=null){body.seed=Number(request.seed);if(!Number.isInteger(body.seed)||body.seed<0)throw new Error('seed precisa ser inteiro não negativo.');}
55
98
  const frames=[];
56
99
  for(const [field,type] of [['firstFrame','first_frame'],['lastFrame','last_frame']])if(request[field]){
@@ -62,13 +105,13 @@ export async function videoOperation(request,{cwd,key=process.env.SAGADECK_VIDEO
62
105
  }
63
106
  if(frames.length)body.frame_images=frames;
64
107
  const summary={...body,...(frames.length?{frame_images:frames.map(f=>({frame_type:f.frame_type,local:true}))}:{})};
65
- if(action==='plan')return{action,costIncurred:false,request:summary,pricing:meta.pricing_skus||meta.pricing||null};
108
+ if(action==='plan')return{action,costIncurred:false,request:summary,pricing:meta?.pricing_skus||meta?.pricing||null};
66
109
  if(!cwd)throw new Error('Abra uma apresentação salva antes de gerar vídeo.');
67
110
  const output=within(cwd,request.out||`videos/clip-${crypto.randomUUID()}.mp4`);if(fs.existsSync(output))throw new Error('O arquivo de saída já existe; escolha outro nome.');
68
111
  const job=await api('/videos',body);
69
112
  if(!/^[\w-]+$/.test(job.id||''))throw new Error('A API não devolveu um identificador de job válido.');
70
113
  const folder=path.join(cwd,'.sagadeck','videos');fs.mkdirSync(folder,{recursive:true});
71
- const record={id:job.id,polling_url:videoApiURL(job.polling_url||`/videos/${job.id}`),output:path.relative(cwd,output).split(path.sep).join("/"),request:summary,createdAt:new Date().toISOString()};
114
+ const record={id:job.id,polling_url:videoApiURL(job.polling_url||`/videos/${job.id}`,c.base),output:path.relative(cwd,output).split(path.sep).join("/"),request:summary,createdAt:new Date().toISOString()};
72
115
  fs.writeFileSync(path.join(folder,job.id+'.json'),JSON.stringify(record,null,2),{flag:'wx'});
73
116
  return{id:job.id,status:job.status,output:record.output,nextAction:'status'};
74
117
  }
@@ -78,9 +121,10 @@ export async function videoOperation(request,{cwd,key=process.env.SAGADECK_VIDEO
78
121
  const job=await api(record.polling_url);
79
122
  if(action==='status'||job.status!=='completed')return{id:record.id,status:job.status,error:job.error||null,cost:job.usage?.cost??null};
80
123
  const output=within(cwd,record.output);if(fs.existsSync(output))return{id:record.id,status:'downloaded',video:record.output,cached:true};
81
- const url=new URL(job.unsigned_urls?.[0]||`${BASE}/videos/${record.id}/content?index=0`);
82
- if(url.protocol!=='https:')throw new Error('Download do vídeo exige HTTPS.');
83
- const r=await fetcher(url.href,{headers:url.origin==='https://openrouter.ai'?{Authorization:`Bearer ${key}`}:{},signal:AbortSignal.timeout(30000)});
124
+ const url=new URL(job.unsigned_urls?.[0]||`${c.base}/videos/${record.id}/content?index=0`);
125
+ if(url.protocol!=='https:'&&url.origin!==new URL(c.base).origin)throw new Error('Download do vídeo exige HTTPS.');
126
+ // a chave só vai junto se o download é no próprio provedor (o link assinado de um CDN não precisa dela)
127
+ const r=await c.call(url.href,{headers:url.origin===new URL(c.base).origin?c.auth:{},signal:AbortSignal.timeout(30000)});
84
128
  if(!r.ok)throw new Error(`Download retornou HTTP ${r.status}.`);
85
129
  const mime=r.headers.get('content-type')||'';if(!/^video\/|application\/octet-stream/i.test(mime))throw new Error('O provedor não retornou um arquivo de vídeo.');
86
130
  const chunks=[];let bytes=0;for await(const chunk of r.body){bytes+=chunk.length;if(bytes>100*1024*1024)throw new Error('Vídeo excede 100 MB.');chunks.push(chunk);}
@@ -92,7 +136,8 @@ export async function videoOperation(request,{cwd,key=process.env.SAGADECK_VIDEO
92
136
  // vigia os jobs do deck: confere andamento (grátis), baixa sozinho o que terminou.
93
137
  // para o ticker do Studio avisar "clipe pronto" sem a pessoa ficar olhando.
94
138
  const lastCheck = new Map(); // polling_url -> { at, status }
95
- export async function pollVideoJobs(cwd,{key=process.env.SAGADECK_VIDEO_KEY||process.env.OPENROUTER_API_KEY,fetcher=fetch,cooldownMs=60000}={}){
139
+ export async function pollVideoJobs(cwd,{cooldownMs=60000,...opts}={}){
140
+ const c=conn(opts),can=!!(c.key||c.adaptador);
96
141
  const dir=path.join(path.resolve(cwd),'.sagadeck','videos');
97
142
  let files=[];
98
143
  try{files=fs.readdirSync(dir).filter((f)=>f.endsWith('.json'));}catch{return[];}
@@ -104,16 +149,16 @@ export async function pollVideoJobs(cwd,{key=process.env.SAGADECK_VIDEO_KEY||pro
104
149
  const cached=lastCheck.get(record.polling_url);
105
150
  let status=record.status||'unknown';
106
151
  if(cached&&Date.now()-cached.at<cooldownMs) status=cached.status;
107
- else if(!done&&key){
152
+ else if(!done&&can){
108
153
  try{
109
- const r=await fetcher(record.polling_url,{headers:{Authorization:`Bearer ${key}`},signal:AbortSignal.timeout(15000)});
154
+ const r=await c.call(videoApiURL(record.polling_url,c.base),{headers:c.auth,signal:AbortSignal.timeout(15000)});
110
155
  if(r.ok){const job=await r.json();status=job.status||status;lastCheck.set(record.polling_url,{at:Date.now(),status});}
111
156
  }catch{/* sem rede: mantém o último */}
112
157
  }
113
158
  let video=record.output&&fs.existsSync(path.join(path.resolve(cwd),record.output))?record.output:null;
114
- if(status==='completed'&&!video&&key){
159
+ if(status==='completed'&&!video&&can){
115
160
  try{
116
- const dl=await videoOperation({action:'download',id:record.id},{cwd,key,fetcher});
161
+ const dl=await videoOperation({action:'download',id:record.id},{cwd,...opts});
117
162
  video=dl.video;status='downloaded';
118
163
  record.downloadedAt=new Date().toISOString();record.status='completed';
119
164
  try{fs.writeFileSync(path.join(dir,f),JSON.stringify(record,null,2));}catch{}
package/src/build.js CHANGED
@@ -8,9 +8,9 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import YAML from "yaml";
11
- import { resolveTheme, themeCSS, scopedThemeCSS } from "./themes.js";
11
+ import { resolveTheme, themeCSS, scopedThemeCSS, THEMES } from "./themes.js";
12
12
  import { identityFor, identitiesFile } from "./identity.js";
13
- import { LAYOUTS, SCENES, fullAsFigure } from "./layouts.js";
13
+ import { LAYOUTS, fullAsFigure } from "./layouts.js";
14
14
  import { setDeckLocale } from "./locale.js";
15
15
  import { applyVisualEdits } from "./visual-edits.js";
16
16
  import { el, imageSrc } from "./elements.js";
@@ -24,6 +24,12 @@ import { loadMapConfig, mapaFile, tilesForDeck } from "./map-config.js";
24
24
 
25
25
  const HERE = path.dirname(fileURLToPath(import.meta.url));
26
26
  const read = (p) => fs.readFileSync(path.join(HERE, p), "utf8");
27
+ let mapBackgroundAssets;
28
+ const embeddedMapBackground = (spec, tiles) => {
29
+ if (!spec.slides.some((s) => s.layout === "map" && tiles?.[s.basemap || spec.defaults?.basemap]?.tipo === "vetorial")) return "";
30
+ mapBackgroundAssets ||= JSON.stringify(Object.fromEntries(Object.entries({ js: "maplibre-gl.js", css: "maplibre-gl.css", worker: "maplibre-worker.js", adapter: "maplibre-leaflet.js" }).map(([key, file]) => [key, fs.readFileSync(path.join(HERE, "runtime/vendor", file)).toString("base64")])));
31
+ return `<script type="application/json" id="map-background-assets">${mapBackgroundAssets}</script>`;
32
+ };
27
33
 
28
34
  export function loadSpec(file) {
29
35
  const src = fs.readFileSync(file, "utf8");
@@ -59,7 +65,7 @@ export function inferLayout(s) {
59
65
  }
60
66
 
61
67
  const DEFAULT_TONE = { section: "accent", cover: "light", end: "dark" };
62
- const NO_FOOTER = new Set(["cover", "section", "end", "image", "canvas", "full", "headline", "kinetic", "scenography"]);
68
+ const NO_FOOTER = new Set(["cover", "section", "end", "image", "canvas", "full", "headline"]);
63
69
 
64
70
  export function wordCount(s) {
65
71
  const txt = [];
@@ -81,7 +87,11 @@ export function wordCount(s) {
81
87
  // identidade do deck (fontes da empresa, do arquivo local; ver src/identity.js)
82
88
  const identityOf = (spec) => (spec.identity ? identityFor(spec.identity) : null);
83
89
  const slideTheme = (s, deckTheme, spec) => (s.theme || s.palette ? resolveTheme(s.theme || spec.theme, s.palette ?? spec.palette, identityOf(spec)) : deckTheme);
84
- const skinCSS = (name) => { try { return read(`runtime/skins/${name}.css`); } catch { return ""; } };
90
+ // tema irmão (skin: "musgo" no wine): usa a pele do outro, com o seletor trocado para o nome dele
91
+ const skinCSS = (name) => {
92
+ const base = THEMES[name]?.skin;
93
+ try { return base ? read(`runtime/skins/${base}.css`).replaceAll(`.th-${base}`, `.th-${name}`) : read(`runtime/skins/${name}.css`); } catch { return ""; }
94
+ };
85
95
  export function deckThemeCSS(spec, deckTheme) {
86
96
  const looks = new Map(), names = new Set([deckTheme.name]);
87
97
  for (const raw of spec.slides || []) {
@@ -145,7 +155,7 @@ export function buildHTML(rawSpec, opts = {}) {
145
155
  const layout = inferLayout(s);
146
156
  const fn = LAYOUTS[layout];
147
157
  if (!fn) throw new Error(`Slide ${i + 1}: layout "${layout}" não existe. Use: ${Object.keys(LAYOUTS).join(", ")}`);
148
- const tone = s.tone || (layout === "scenography" && SCENES[s.scene || "stage"]?.tone) || DEFAULT_TONE[layout] || spec.tone || "light";
158
+ const tone = s.tone || DEFAULT_TONE[layout] || spec.tone || "light";
149
159
  let inner;
150
160
  const th = slideTheme(s, theme, spec), sctx = th === theme ? ctx : { ...ctx, theme: th };
151
161
  try { inner = fn(s, sctx); } catch (e) { throw new Error(`Slide ${i + 1} (${layout}${s.title ? `: ${plain(s.title).slice(0, 40)}` : ""}): ${e.message}`); }
@@ -234,7 +244,7 @@ ${spec.slides.some((s) => s.layout === "diagram") ? `<script>${read("runtime/ven
234
244
  ${spec.slides.some(s => s.layout === "science") ? `<script>${read("runtime/vendor/plotly.min.js").replace(/<\/script/gi,"<\\/script")}</script><script>${read("runtime/formula.js")}</script><script>${read("runtime/science.js")}</script>` : ""}
235
245
  ${spec.slides.some(s => s.layout === "calc") ? `${spec.slides.some(s => s.layout === "science") ? "" : `<script>${read("runtime/formula.js")}</script>`}<script>${read("runtime/calc.js")}</script>` : ""}
236
246
  ${spec.slides.some(s=>s.layout==="portal") ? `<script>${read("runtime/portal-scene.js")}</script>` : ""}
237
- ${spec.slides.some((s) => s.layout === "map") ? `<style>${read("runtime/vendor/leaflet.css")}</style><script>${read("runtime/vendor/leaflet.js").replace(/<\/script/gi, "<\\/script")}</script><script>${read("runtime/map.js")}</script>` : ""}
247
+ ${spec.slides.some((s) => s.layout === "map") ? `${embeddedMapBackground(spec, mapTiles)}<style>${read("runtime/vendor/leaflet.css")}</style><script>${read("runtime/vendor/leaflet.js").replace(/<\/script/gi, "<\\/script")}</script><script>${read("runtime/map-background.js")}</script><script>${read("runtime/map.js")}</script>` : ""}
238
248
  ${JSON.stringify(spec.slides || []).includes('"points"') ? `<script>${read("runtime/points.js")}</script>` : ""}
239
249
  ${spec.slides.some(s=>s.layout==="playground") ? `<script>${read("runtime/playground.js")}</script>` : ""}
240
250
  ${hasApi ? `${apiScripts}\n<script>${read("runtime/api-ui.js")}</script><script>${read("runtime/api-collection-ui.js")}</script>` : ""}
@@ -258,21 +268,41 @@ export function setFitDefaults(d = {}) {
258
268
  export const fitDefaults = () => ({ ...FIT_DEFAULTS });
259
269
 
260
270
  // eco ambiente: camada viva discreta atrás do conteúdo (só no HTML; PPTX/PDF usam o quadro parado)
261
- const AMBIENT = ["pontos", "grade"];
262
- function ambientOf(s, i, ctx) {
263
- if (s.ambient == null || s.ambient === "") return "";
271
+ // (rede: nós à deriva ligados por proximidade, num canvas animado pelo runtime; o tema pode pô-la sozinha na capa,
272
+ // coverAmbient, e ambient: false tira)
273
+ const AMBIENT = ["pontos", "grade", "rede"];
274
+ function ambientOf(s, i, ctx, theme, layout) {
275
+ if (s.ambient === false) return "";
276
+ if (s.ambient == null || s.ambient === "") return layout === "cover" && theme?.coverAmbient ? theme.coverAmbient : "";
264
277
  if (!AMBIENT.includes(s.ambient)) { ctx.warnings?.push(`slide ${i + 1}: ambiente "${s.ambient}" desconhecido (vale: ${AMBIENT.join(", ")})`); return ""; }
265
278
  return s.ambient;
266
279
  }
280
+ // faixa que corre na capa: `ticker: [..]` no slide, ou automática nos temas com ticker (musgo), com os títulos das
281
+ // seções (sem seções suficientes, os dos slides). `ticker: false` desliga. Só no HTML; parada sem movimento/exportação.
282
+ function tickerOf(s, spec, theme, layout) {
283
+ if (s.ticker === false || layout !== "cover") return null;
284
+ const clean = (list) => [...new Set(list.map((x) => plain(String(x ?? "")).trim()).filter(Boolean))].slice(0, 16);
285
+ if (Array.isArray(s.ticker)) return clean(s.ticker);
286
+ if (!theme.ticker && s.ticker !== true) return null;
287
+ const others = (spec.slides || []).filter((x) => x && typeof x === "object");
288
+ let items = clean(others.filter((x) => inferLayout(x) === "section").map((x) => x.title || x.kicker));
289
+ if (items.length < 3) items = clean(others.filter((x) => !isCoverLayout(inferLayout(x))).map((x) => x.title || x.kicker));
290
+ return items.length ? items : null;
291
+ }
292
+ function tickerHTML(items) {
293
+ const run = items.map((t) => `<span class="tk-it t f-mono">${esc(t)}</span><span class="tk-sep"></span>`).join("");
294
+ const dur = Math.max(24, Math.round(items.join("").length * 0.22));
295
+ return `<div class="ticker" aria-hidden="true"><div class="tk-track" style="--tk-dur:${dur}s">${run}${run}</div></div>`;
296
+ }
267
297
  // <section> de um slide: tom, textura, estilo do destaque, fundo, área, cabeçalho e rodapé.
268
298
  // Única montagem para o Studio (renderSlide) e para a apresentação/exportação (buildHTML).
269
299
  function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, current = false }) {
270
300
  let inner = innerIn;
271
- const ambient = ambientOf(s, i, ctx);
301
+ const ambient = ambientOf(s, i, ctx, theme, layout);
272
302
  const deco = s.deco ?? theme.deco;
273
303
  const style = (s.bg ? `--bg:#${String(s.bg).replace("#", "")};` : "") + (s.fg ? `--fg:#${String(s.fg).replace("#", "")};` : "");
274
304
  const bars = s.footer !== false && (s.footer === true || !NO_FOOTER.has(layout));
275
- const area = layout === "canvas" || layout === "full" || layout === "kinetic" || layout === "scenography" ? "free" : "safe";
305
+ const area = layout === "canvas" || layout === "full" ? "free" : "safe";
276
306
  const motionAccent = area === 'safe' && s.motionAccent && typeof s.motionAccent === 'object';
277
307
  // estilo do ==destaque== (marca-texto | sublinhado | cor | negrito | nenhum), no deck ou por slide
278
308
  const markStyle = s.markStyle || spec.markStyle;
@@ -293,7 +323,7 @@ function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, curr
293
323
  if (hd) { bandTitle = `<div class="master-title">${hd[0].replace(/<[a-z0-9]+ [^>]*class="[^"]*\bttl\b[^"]*"[^>]*/, (tag) => `${tag}${/\sdata-fit[\s=>]|\sdata-fit$/.test(tag) ? "" : " data-fit"} data-fit-self`)}</div>`; inner = inner.replace(hd[0], ""); }
294
324
  }
295
325
  let html = `<section class="slide${current ? " current" : ""}${withMaster ? ` has-master${isCoverLayout(layout) ? " master-cover" : ""}${band ? " master-band" : ""}` : ""} th-${theme.name} lk-${theme.key} tone-${tone} ${deco && deco !== "none" ? "deco-" + deco : ""} ${markStyle && markStyle !== "marca-texto" ? "ms-" + markStyle : ""} L-${layout}-slide${["compact", "dense"].includes(s.density) ? " density-" + s.density : ""}" data-idx="${i}"${s.uid ? ` data-uid="${esc(String(s.uid))}"` : ""} data-layout="${layout}" data-tr="${s.transition || "fade"}"${ambient ? ` data-ambient="${ambient}"` : ""}${nav}${fitAttrs}${!Array.isArray(s.steps) && Number.isFinite(Number(s.steps)) && Number(s.steps) > 0 ? ` data-steps="${Number(s.steps)}"` : ""}${style ? ` style="${style}"` : ""}>`;
296
- if (ambient) html += `<div class="ambient" aria-hidden="true"></div>`;
326
+ if (ambient) html += `<div class="ambient" aria-hidden="true">${ambient === "rede" ? "<canvas></canvas>" : ""}</div>`;
297
327
  if (s.background) html += `<div class="bgfig" style="${s.backgroundStyle || ""}">${el(s.background, ctx, 1920, slideSize(spec).h)}</div>`;
298
328
  // mestre do deck (logos, faixas, número da página: src/master.js), atrás do conteúdo
299
329
  if (withMaster) html += `<div class="master" aria-hidden="true">${masterElements(spec, layout, i).map((e) => el(e, ctx)).join("")}</div>`;
@@ -302,7 +332,9 @@ function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, curr
302
332
  if (bars && s.header !== false) html += barHTML("header", spec, i, total);
303
333
  if (bandTitle) html += applyVisualEdits(bandTitle, s.visualEdits, { src: (p) => imageSrc(p, ctx) || p });
304
334
  html += `<div class="${area}${motionAccent ? ' with-motion-accent' : ''}">${applyVisualEdits(inner, s.visualEdits, { src: (p) => imageSrc(p, ctx) || p })}</div>`;
305
- if (motionAccent) {const fg=s.fg||theme.tones[tone]?.fg||'ink';html += `<div class="motion-accent" aria-hidden="true">${el({motion:{background:'transparent',foreground:theme.colors[fg]||fg,...s.motionAccent}},ctx,320,90)}</div>`;}
335
+ const ticker = tickerOf(s, spec, theme, layout);
336
+ if (ticker) html += tickerHTML(ticker);
337
+ if (motionAccent) {const fg=s.fg||theme.tones[tone]?.fg||'ink';html +=`<div class="motion-accent" aria-hidden="true">${el({motion:{background:'transparent',foreground:theme.colors[fg]||fg,...s.motionAccent}},ctx,320,90)}</div>`;}
306
338
  if (bars) html += barHTML("footer", spec, i, total);
307
339
  if (s.back != null && s.back !== "") {
308
340
  const alvo = (spec.slides || []).find((x) => x && String(x.id) === String(s.back));
@@ -321,7 +353,7 @@ export function renderSlide(raw, i = 0, spec = {}) {
321
353
  const layout = inferLayout(s);
322
354
  const fn = LAYOUTS[layout];
323
355
  if (!fn) throw new Error(`Slide ${i + 1}: layout "${layout}" não existe.`);
324
- const tone = s.tone || (layout === "scenography" && SCENES[s.scene || "stage"]?.tone) || DEFAULT_TONE[layout] || spec.tone || "light";
356
+ const tone = s.tone || DEFAULT_TONE[layout] || spec.tone || "light";
325
357
  const inner = fn(s, ctx);
326
358
  const deco = s.deco ?? theme.deco;
327
359
  const html = slideShell({ s, i, spec, theme, ctx, layout, tone, inner, current: true });
package/src/elements.js CHANGED
@@ -9,7 +9,7 @@ import katex from "katex";
9
9
  import { formatNumber, decimalSeparator } from "./locale.js";
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
- import { md, esc } from "./markup.js";
12
+ import { md, esc, plain } from "./markup.js";
13
13
  import { iconSVG } from "./figures/icons.js";
14
14
  import { picto } from "./figures/pictos.js";
15
15
  import { diagram } from "./figures/diagrams.js";
@@ -258,6 +258,7 @@ export function el(e, ctx, w, h) {
258
258
  if (e.poll) return poll(e, ctx);
259
259
  if (e.list) return list(e);
260
260
  if (e.cards) return cards(e, ctx);
261
+ if (e.pop) return popEl(e);
261
262
  if (e.stats || e.kpis) return stats(e, ctx);
262
263
  if (e.steps || e.process || e.flow) return steps(e, ctx);
263
264
  if (e.progress != null) return progress(e);
@@ -478,6 +479,7 @@ export function cards(e, ctx) {
478
479
  if (c.check === true || c.status === "pro") extraWidgets += `<div class="check-pill pro">${iconSVG("check", { size: 26, stroke: 2.4 })} ${c.checkText ? md(c.checkText) : "Recomendado"}</div>`;
479
480
  if (c.check === false || c.status === "con") extraWidgets += `<div class="check-pill con">${iconSVG("x", { size: 26, stroke: 2.4 })} ${c.checkText ? md(c.checkText) : "Evitar"}</div>`;
480
481
 
482
+ if (c.flip != null && c.flip !== "") return flipCard(c, step, top, badgeHtml, extraWidgets);
481
483
  return `<div${attrs({ ...c, step, card: c.hl ? "hi" : true }, "cd")}>
482
484
  <div style="display:flex;justify-content:space-between;align-items:center;width:100%;">
483
485
  ${top}
@@ -493,6 +495,42 @@ export function cards(e, ctx) {
493
495
  return `<div${attrs(e, "cards", `grid-template-columns:repeat(${cols},1fr);`)}>${body}</div>`;
494
496
  }
495
497
 
498
+ // Cartão que vira (flip: no cartão): clicar gira e mostra o verso (flipTitle + flip; "back" já é o Voltar da navegação). Frente e verso ficam no
499
+ // mesmo lugar da grade; o mais alto dita a altura. Na exportação e no PPTX vale a frente.
500
+ function flipCard(c, step, top, badgeHtml, extraWidgets) {
501
+ const face = `card${c.hl ? " card-hi" : ""} flip-face`;
502
+ const back = typeof c.flip === "object" ? c.flip : { text: c.flip };
503
+ return `<div${attrs({ ...c, step, card: false }, "cd flip")} data-flip tabindex="0" role="button" aria-pressed="false" aria-label="${esc(plain(String(c.title || "Cartão")))}: virar">
504
+ <div class="flip-in">
505
+ <div class="${face} flip-front">
506
+ <div style="display:flex;justify-content:space-between;align-items:center;width:100%;">${top}${badgeHtml}</div>
507
+ ${c.title ? `<div class="cd-title t f-heading">${md(c.title)}</div>` : ""}
508
+ ${c.text ? `<div class="cd-text t f-body">${md(c.text)}</div>` : ""}
509
+ ${extraWidgets}
510
+ ${c.foot ? `<div class="cd-foot t f-label">${md(c.foot)}</div>` : ""}
511
+ <span class="flip-hint" aria-hidden="true">${iconSVG("rotate-ccw", { size: 28, stroke: 2.2 })}</span>
512
+ </div>
513
+ <div class="${face} flip-back" aria-hidden="true">
514
+ ${back.title || c.flipTitle ? `<div class="cd-title t f-heading">${md(back.title || c.flipTitle)}</div>` : ""}
515
+ <div class="cd-text t f-body">${md(String(back.text ?? ""))}</div>
516
+ <span class="flip-hint" aria-hidden="true">${iconSVG("rotate-ccw", { size: 28, stroke: 2.2 })}</span>
517
+ </div>
518
+ </div>
519
+ </div>`;
520
+ }
521
+
522
+ // Botão + com balão (pop): clicar abre um balãozinho com título e texto e escurece o resto do slide; clicar de novo,
523
+ // fora dele ou trocar de slide fecha. side: above (padrão) | below | left | right. Na exportação, fechado.
524
+ export function popHTML(more, side = "above") {
525
+ const m = typeof more === "object" && more ? more : { text: more };
526
+ const sd = ["above", "below", "left", "right"].includes(m.side || side) ? m.side || side : "above";
527
+ return `<span class="pop pop-${sd}" data-pop><button type="button" class="pop-btn" aria-expanded="false" aria-label="${esc(plain(String(m.title || "Ver mais")))}">${iconSVG("plus", { size: 30, stroke: 2.6 })}</button><span class="pop-card" role="note">${m.title ? `<span class="pop-title t f-heading">${md(String(m.title))}</span>` : ""}<span class="pop-text t f-body">${md(String(m.text ?? ""))}</span></span></span>`;
528
+ }
529
+ function popEl(e) {
530
+ const m = typeof e.pop === "object" ? e.pop : { text: e.pop, title: e.title, side: e.side };
531
+ return `<span${attrs(e, "pop-el")}>${popHTML(m)}</span>`;
532
+ }
533
+
496
534
  export function stats(e, ctx) {
497
535
  const items = e.stats || e.kpis || [];
498
536
  const cols = e.cols || Math.min(items.length, 4) || 3;