sagadeck 1.7.1 → 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 (105) hide show
  1. package/README.md +55 -4
  2. package/SKILL.md +10 -3
  3. package/bin/sagadeck.js +23 -0
  4. package/docs/ANDAMENTO.md +483 -1
  5. package/docs/PLANO-MELHORIAS.md +180 -0
  6. package/docs/REFERENCIA.md +184 -105
  7. package/package.json +34 -2
  8. package/src/ai/commands.js +2 -2
  9. package/src/ai/deck-ai.js +14 -5
  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 +119 -0
  14. package/src/ai/map-data.js +221 -0
  15. package/src/ai/quality.js +1 -1
  16. package/src/ai/video-generation.js +70 -25
  17. package/src/build.js +51 -15
  18. package/src/elements.js +39 -1
  19. package/src/export/browser.js +23 -1
  20. package/src/export/pptx.js +9 -3
  21. package/src/export/shots.js +10 -3
  22. package/src/fiscal/normalize.js +19 -0
  23. package/src/fs-retry.js +37 -2
  24. package/src/import/office.js +5 -4
  25. package/src/layouts.js +114 -99
  26. package/src/lessons.js +1 -1
  27. package/src/library.js +7 -7
  28. package/src/map-analysis.js +109 -0
  29. package/src/map-config.js +89 -0
  30. package/src/map-routing.js +82 -0
  31. package/src/map-services.js +60 -0
  32. package/src/map.js +274 -0
  33. package/src/mcp/server.js +17 -0
  34. package/src/motion.js +20 -6
  35. package/src/purpose.js +1 -1
  36. package/src/runtime/base.css +105 -329
  37. package/src/runtime/fit.js +3 -2
  38. package/src/runtime/fonts/amatic-sc.css +3 -0
  39. package/src/runtime/fonts/bebas-neue.css +2 -0
  40. package/src/runtime/fonts/cabin-sketch.css +2 -0
  41. package/src/runtime/fonts/fraunces.css +3 -0
  42. package/src/runtime/fonts/gelasio.css +3 -0
  43. package/src/runtime/fonts/ibm-plex-mono.css +4 -0
  44. package/src/runtime/fonts/index.json +10 -1
  45. package/src/runtime/fonts/itim.css +2 -0
  46. package/src/runtime/fonts/montserrat.css +2 -0
  47. package/src/runtime/fonts/space-grotesk.css +2 -0
  48. package/src/runtime/map-background.js +91 -0
  49. package/src/runtime/map.js +266 -0
  50. package/src/runtime/runtime.js +81 -100
  51. package/src/runtime/skins/caderno.css +33 -0
  52. package/src/runtime/skins/cartaz.css +27 -0
  53. package/src/runtime/skins/infografico.css +29 -0
  54. package/src/runtime/skins/lousa.css +22 -0
  55. package/src/runtime/skins/musgo.css +42 -0
  56. package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
  57. package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
  58. package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -0
  59. package/src/runtime/vendor/geo-contornos.json +1 -0
  60. package/src/runtime/vendor/leaflet.css +661 -0
  61. package/src/runtime/vendor/leaflet.js +6 -0
  62. package/src/runtime/vendor/maplibre-gl.css +1 -0
  63. package/src/runtime/vendor/maplibre-gl.js +825 -0
  64. package/src/runtime/vendor/maplibre-leaflet.js +234 -0
  65. package/src/runtime/vendor/maplibre-worker.js +10 -0
  66. package/src/studio/ai-chat-route.js +20 -0
  67. package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
  68. package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
  69. package/src/studio/command-approval.js +2 -3
  70. package/src/studio/demo-decks.js +6 -2
  71. package/src/studio/export-jobs.js +86 -0
  72. package/src/studio/exporting.js +36 -18
  73. package/src/studio/feature-demos.js +144 -0
  74. package/src/studio/geo-routes.js +138 -0
  75. package/src/studio/ia-routes.js +9 -0
  76. package/src/studio/layout-samples.js +78 -16
  77. package/src/studio/library-routes.js +0 -9
  78. package/src/studio/project.js +2 -1
  79. package/src/studio/public/ai-settings.js +6 -3
  80. package/src/studio/public/app.js +46 -52
  81. package/src/studio/public/explorer.js +23 -8
  82. package/src/studio/public/export-status.js +154 -0
  83. package/src/studio/public/index.html +17 -10
  84. package/src/studio/public/library.css +12 -3
  85. package/src/studio/public/library.html +3 -0
  86. package/src/studio/public/library.js +39 -12
  87. package/src/studio/public/map-data.js +158 -0
  88. package/src/studio/public/map-editor.js +676 -0
  89. package/src/studio/public/map-fields.js +21 -0
  90. package/src/studio/public/map-settings.js +86 -0
  91. package/src/studio/public/slide-form.js +19 -16
  92. package/src/studio/public/style.css +58 -0
  93. package/src/studio/public/ui-icons.js +1 -1
  94. package/src/studio/public/viewers.js +2 -1
  95. package/src/studio/public/visual-editor.js +1 -1
  96. package/src/studio/server.js +34 -9
  97. package/src/studio/snapshot.js +8 -4
  98. package/src/studio/template-collections.js +1 -1
  99. package/src/studio/tour.js +158 -0
  100. package/src/themes.js +154 -0
  101. package/templates/cenario-e-ciencia.yaml +1 -21
  102. package/templates/ensaio-api.yaml +3 -3
  103. package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
  104. package/templates/cenario/imagens/cidade.svg +0 -30
  105. package/templates/cenario/imagens/pessoa.svg +0 -15
@@ -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";
@@ -20,9 +20,16 @@ import { normalizeSpec } from "./fiscal/normalize.js";
20
20
  import { readRecordings } from "./api-client.js";
21
21
  import { explorationStates } from './exploration.js';
22
22
  import { calcModel } from './lessons.js';
23
+ import { loadMapConfig, mapaFile, tilesForDeck } from "./map-config.js";
23
24
 
24
25
  const HERE = path.dirname(fileURLToPath(import.meta.url));
25
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
+ };
26
33
 
27
34
  export function loadSpec(file) {
28
35
  const src = fs.readFileSync(file, "utf8");
@@ -58,7 +65,7 @@ export function inferLayout(s) {
58
65
  }
59
66
 
60
67
  const DEFAULT_TONE = { section: "accent", cover: "light", end: "dark" };
61
- 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"]);
62
69
 
63
70
  export function wordCount(s) {
64
71
  const txt = [];
@@ -80,7 +87,11 @@ export function wordCount(s) {
80
87
  // identidade do deck (fontes da empresa, do arquivo local; ver src/identity.js)
81
88
  const identityOf = (spec) => (spec.identity ? identityFor(spec.identity) : null);
82
89
  const slideTheme = (s, deckTheme, spec) => (s.theme || s.palette ? resolveTheme(s.theme || spec.theme, s.palette ?? spec.palette, identityOf(spec)) : deckTheme);
83
- 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
+ };
84
95
  export function deckThemeCSS(spec, deckTheme) {
85
96
  const looks = new Map(), names = new Set([deckTheme.name]);
86
97
  for (const raw of spec.slides || []) {
@@ -132,7 +143,9 @@ export function buildHTML(rawSpec, opts = {}) {
132
143
  const theme = resolveTheme(spec.theme, spec.palette, identityOf(spec));
133
144
  const warnings = [];
134
145
  if (spec.identity && !theme.identity) warnings.push(`identidade "${spec.identity}" não está em ${identitiesFile()} nesta máquina: a apresentação saiu com as fontes do tema`);
135
- const ctx = { baseDir: spec._dir || process.cwd(), theme, spec, warnings };
146
+ // serviços do mapa desta máquina (só o fundo vai para o HTML; nunca chave de rotas/endereços): src/map-config.js
147
+ const mapTiles = (spec.slides || []).some((x) => x?.layout === "map") ? tilesForDeck(loadMapConfig(mapaFile())) : null;
148
+ const ctx = { baseDir: spec._dir || process.cwd(), theme, spec, warnings, mapTiles };
136
149
  const id = spec.id || slug(spec.title);
137
150
  const slidesMeta = [];
138
151
  let html = "";
@@ -142,7 +155,7 @@ export function buildHTML(rawSpec, opts = {}) {
142
155
  const layout = inferLayout(s);
143
156
  const fn = LAYOUTS[layout];
144
157
  if (!fn) throw new Error(`Slide ${i + 1}: layout "${layout}" não existe. Use: ${Object.keys(LAYOUTS).join(", ")}`);
145
- 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";
146
159
  let inner;
147
160
  const th = slideTheme(s, theme, spec), sctx = th === theme ? ctx : { ...ctx, theme: th };
148
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}`); }
@@ -231,6 +244,7 @@ ${spec.slides.some((s) => s.layout === "diagram") ? `<script>${read("runtime/ven
231
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>` : ""}
232
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>` : ""}
233
246
  ${spec.slides.some(s=>s.layout==="portal") ? `<script>${read("runtime/portal-scene.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>` : ""}
234
248
  ${JSON.stringify(spec.slides || []).includes('"points"') ? `<script>${read("runtime/points.js")}</script>` : ""}
235
249
  ${spec.slides.some(s=>s.layout==="playground") ? `<script>${read("runtime/playground.js")}</script>` : ""}
236
250
  ${hasApi ? `${apiScripts}\n<script>${read("runtime/api-ui.js")}</script><script>${read("runtime/api-collection-ui.js")}</script>` : ""}
@@ -254,21 +268,41 @@ export function setFitDefaults(d = {}) {
254
268
  export const fitDefaults = () => ({ ...FIT_DEFAULTS });
255
269
 
256
270
  // eco ambiente: camada viva discreta atrás do conteúdo (só no HTML; PPTX/PDF usam o quadro parado)
257
- const AMBIENT = ["pontos", "grade"];
258
- function ambientOf(s, i, ctx) {
259
- 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 : "";
260
277
  if (!AMBIENT.includes(s.ambient)) { ctx.warnings?.push(`slide ${i + 1}: ambiente "${s.ambient}" desconhecido (vale: ${AMBIENT.join(", ")})`); return ""; }
261
278
  return s.ambient;
262
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
+ }
263
297
  // <section> de um slide: tom, textura, estilo do destaque, fundo, área, cabeçalho e rodapé.
264
298
  // Única montagem para o Studio (renderSlide) e para a apresentação/exportação (buildHTML).
265
299
  function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, current = false }) {
266
300
  let inner = innerIn;
267
- const ambient = ambientOf(s, i, ctx);
301
+ const ambient = ambientOf(s, i, ctx, theme, layout);
268
302
  const deco = s.deco ?? theme.deco;
269
303
  const style = (s.bg ? `--bg:#${String(s.bg).replace("#", "")};` : "") + (s.fg ? `--fg:#${String(s.fg).replace("#", "")};` : "");
270
304
  const bars = s.footer !== false && (s.footer === true || !NO_FOOTER.has(layout));
271
- const area = layout === "canvas" || layout === "full" || layout === "kinetic" || layout === "scenography" ? "free" : "safe";
305
+ const area = layout === "canvas" || layout === "full" ? "free" : "safe";
272
306
  const motionAccent = area === 'safe' && s.motionAccent && typeof s.motionAccent === 'object';
273
307
  // estilo do ==destaque== (marca-texto | sublinhado | cor | negrito | nenhum), no deck ou por slide
274
308
  const markStyle = s.markStyle || spec.markStyle;
@@ -289,7 +323,7 @@ function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, curr
289
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], ""); }
290
324
  }
291
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}"` : ""}>`;
292
- 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>`;
293
327
  if (s.background) html += `<div class="bgfig" style="${s.backgroundStyle || ""}">${el(s.background, ctx, 1920, slideSize(spec).h)}</div>`;
294
328
  // mestre do deck (logos, faixas, número da página: src/master.js), atrás do conteúdo
295
329
  if (withMaster) html += `<div class="master" aria-hidden="true">${masterElements(spec, layout, i).map((e) => el(e, ctx)).join("")}</div>`;
@@ -298,7 +332,9 @@ function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, curr
298
332
  if (bars && s.header !== false) html += barHTML("header", spec, i, total);
299
333
  if (bandTitle) html += applyVisualEdits(bandTitle, s.visualEdits, { src: (p) => imageSrc(p, ctx) || p });
300
334
  html += `<div class="${area}${motionAccent ? ' with-motion-accent' : ''}">${applyVisualEdits(inner, s.visualEdits, { src: (p) => imageSrc(p, ctx) || p })}</div>`;
301
- 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>`;}
302
338
  if (bars) html += barHTML("footer", spec, i, total);
303
339
  if (s.back != null && s.back !== "") {
304
340
  const alvo = (spec.slides || []).find((x) => x && String(x.id) === String(s.back));
@@ -313,11 +349,11 @@ export function renderSlide(raw, i = 0, spec = {}) {
313
349
  const deckTheme = resolveTheme(spec.theme, spec.palette, identityOf(spec));
314
350
  const s = { ...(spec.defaults || {}), ...raw };
315
351
  const theme = slideTheme(s, deckTheme, spec);
316
- const ctx = { baseDir: spec._dir || process.cwd(), theme, spec, warnings: [] };
352
+ const ctx = { baseDir: spec._dir || process.cwd(), theme, spec, warnings: [], mapTiles: inferLayout(s) === "map" ? tilesForDeck(loadMapConfig(mapaFile())) : null };
317
353
  const layout = inferLayout(s);
318
354
  const fn = LAYOUTS[layout];
319
355
  if (!fn) throw new Error(`Slide ${i + 1}: layout "${layout}" não existe.`);
320
- 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";
321
357
  const inner = fn(s, ctx);
322
358
  const deco = s.deco ?? theme.deco;
323
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;
@@ -1,5 +1,7 @@
1
1
  // Abre o deck num Chrome/Edge headless (usa o navegador já instalado; nada é baixado).
2
2
  import fs from "node:fs";
3
+ import http from "node:http";
4
+ import path from "node:path";
3
5
  import { pathToFileURL } from "node:url";
4
6
  import { chromium } from "playwright-core";
5
7
 
@@ -20,14 +22,34 @@ export function findBrowser() {
20
22
  return p;
21
23
  }
22
24
 
25
+ // Deck com mapa abre por um endereço local (http://127.0.0.1), não como arquivo: o servidor de tiles do OpenStreetMap
26
+ // pede que o navegador diga de onde vem o pedido, e página aberta do disco não diz. Só lê a pasta do deck.
27
+ async function serveDir(dir) {
28
+ const server = http.createServer((req, res) => {
29
+ const rel = decodeURIComponent(new URL(req.url, "http://x").pathname).replace(/^\/+/, "");
30
+ const file = path.resolve(dir, rel);
31
+ if (req.method !== "GET" || !file.startsWith(path.resolve(dir)) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { res.writeHead(404); return res.end(); }
32
+ res.writeHead(200, { "Content-Type": file.endsWith(".html") ? "text/html; charset=utf-8" : "application/octet-stream" });
33
+ fs.createReadStream(file).pipe(res);
34
+ });
35
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
36
+ return server;
37
+ }
38
+
39
+ // a exportação, depois de ir a um slide, espera o mapa dele (tiles carregados ou desistência; no máximo 8 s)
40
+ export const settleSlide = (page) => page.evaluate(() => window.SagaMap?.settled?.() ?? null);
41
+
23
42
  export async function openDeck(htmlFile, { scale = 1 } = {}) {
24
43
  const browser = await chromium.launch({ executablePath: findBrowser() });
44
+ const hasMap = fs.readFileSync(htmlFile, "utf8").includes('class="map-box');
45
+ const server = hasMap ? await serveDir(path.dirname(htmlFile)) : null;
46
+ if (server) { const close = browser.close.bind(browser); browser.close = async () => { await close(); server.close(); }; }
25
47
  try {
26
48
  const page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: scale });
27
49
  const errors = [];
28
50
  page.on("pageerror", (e) => errors.push(e.message));
29
51
  page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
30
- await page.goto(pathToFileURL(htmlFile).href + "?export=1");
52
+ await page.goto((server ? `http://127.0.0.1:${server.address().port}/${encodeURIComponent(path.basename(htmlFile))}` : pathToFileURL(htmlFile).href) + "?export=1");
31
53
  await page.waitForFunction(() => window.sagadeck && window.sagadeck.cur >= 0 && document.fonts.status === "loaded");
32
54
  // a janela com o tamanho do slide (deck.aspect: 4:3 → 1920 × 1440): foto, PDF e PPTX sem faixa nem corte
33
55
  const size = await page.evaluate(() => window.sagadeck.size || { w: 1920, h: 1080 });
@@ -9,7 +9,7 @@
9
9
  import fs from "node:fs";
10
10
  import PptxGenJS from "pptxgenjs";
11
11
  import JSZip from "jszip";
12
- import { openDeck } from "./browser.js";
12
+ import { openDeck, settleSlide } from "./browser.js";
13
13
  import { pptxFontMap, macFallbackFace } from "../themes.js";
14
14
  import { notesPlain } from "../markup.js";
15
15
 
@@ -48,6 +48,8 @@ function collectInPage([idx, nativeCharts]) {
48
48
  const isLeaf = (e) => {
49
49
  const d = getComputedStyle(e).display;
50
50
  if ((d.includes("flex") || d.includes("grid")) && e.children.length) return false;
51
+ // Na régua da calculadora, cada faixa e cada rótulo têm caixas próprias: agrupar perderia posições e fundos.
52
+ if (e.classList.contains("calc-track") || e.classList.contains("calc-band")) return false;
51
53
  let has = false;
52
54
  for (const c of e.childNodes) {
53
55
  if (c.nodeType === 3) { if (c.textContent.trim()) has = true; }
@@ -168,7 +170,7 @@ function collectInPage([idx, nativeCharts]) {
168
170
  return;
169
171
  }
170
172
  // coisas que viram imagem
171
- if (tag.toLowerCase() === "svg" || tag === "IMG" || tag === "CANVAS" || tag === "VIDEO" ||
173
+ if (tag.toLowerCase() === "svg" || tag === "IMG" || tag === "CANVAS" || tag === "VIDEO" || (tag === "INPUT" && e.type === "range") ||
172
174
  e.classList.contains("katex") || (isLeaf(e) && e.querySelector(".katex")) || e.classList.contains("widget") || e.classList.contains("timer") || e.classList.contains("code") || e.classList.contains("raster") || transformed(cs)) {
173
175
  const vr = visualRect(e);
174
176
  e.setAttribute("data-pid", String(++pid));
@@ -327,7 +329,7 @@ function materializePseudos() {
327
329
  }
328
330
 
329
331
  // notes: false tira as notas do apresentador (mandar a apresentação para alguém sem a "cola")
330
- export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts = false, notes = true, fontFallback, log = () => {} } = {}) {
332
+ export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts = false, notes = true, fontFallback, log = () => {}, onProgress = () => {} } = {}) {
331
333
  const notesOpt = notes !== false;
332
334
  const { browser, page, errors } = await openDeck(htmlFile, { scale: 2 });
333
335
  try {
@@ -351,8 +353,10 @@ export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts
351
353
  const animBySlide = [];
352
354
 
353
355
  for (let i = 0; i < n; i++) {
356
+ onProgress({ phase: 'slides', completed: i, total: n, slide: i + 1 });
354
357
  const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
355
358
  await page.evaluate(([j, k]) => window.sagadeck.goto(j, k, true), [i, S]);
359
+ await settleSlide(page);
356
360
  await page.waitForTimeout(40);
357
361
  const data = await page.evaluate(collectInPage, [i, nativeCharts]);
358
362
  const slide = pres.addSlide();
@@ -431,8 +435,10 @@ export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts
431
435
  if (notes && withNotes) slide.addNotes(notesPlain(notes));
432
436
  animBySlide.push({ anims, tr: data.tr });
433
437
  log(` slide ${i + 1}/${n}: ${data.items.length} objetos`);
438
+ onProgress({ phase: 'slides', completed: i + 1, total: n, slide: i + 1 });
434
439
  }
435
440
  // pós-processamento: animações + transições
441
+ onProgress({ phase: 'packing', completed: n, total: n });
436
442
  const buf = await pres.write({ outputType: "nodebuffer" });
437
443
  const zip = await JSZip.loadAsync(buf);
438
444
  for (let i = 0; i < animBySlide.length; i++) {
@@ -1,10 +1,10 @@
1
1
  // PNG de cada slide, folha de contato, PDF e verificação automática de qualidade.
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { openDeck, waitForResources } from "./browser.js";
4
+ import { openDeck, waitForResources, settleSlide } from "./browser.js";
5
5
 
6
6
  // Estado final de cada slide (todos os cliques revelados)
7
- export async function shots(htmlFile, outDir, { steps = false, scale = 1, only, jpeg = false } = {}) {
7
+ export async function shots(htmlFile, outDir, { steps = false, scale = 1, only, jpeg = false, onProgress = () => {} } = {}) {
8
8
  fs.mkdirSync(outDir, { recursive: true });
9
9
  for (const f of fs.readdirSync(outDir)) if (/^slide-\d+.*\.(png|jpg)$|^folha-\d+\.png$/.test(f)) fs.unlinkSync(path.join(outDir, f));
10
10
  const { browser, page, errors } = await openDeck(htmlFile, { scale });
@@ -13,15 +13,18 @@ export async function shots(htmlFile, outDir, { steps = false, scale = 1, only,
13
13
  const files = [];
14
14
  for (let i = 0; i < n; i++) {
15
15
  if (only && !only.includes(i + 1)) continue;
16
+ onProgress({ phase: 'slides', completed: i, total: n, slide: i + 1 });
16
17
  const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
17
18
  const list = steps ? Array.from({ length: S + 1 }, (_, k) => k) : [S];
18
19
  for (const k of list) {
19
20
  await page.evaluate(([j, kk]) => window.sagadeck.goto(j, kk), [i, k]);
21
+ await settleSlide(page);
20
22
  await page.waitForTimeout(60);
21
23
  const f = path.join(outDir, `slide-${String(i + 1).padStart(2, "0")}${steps ? `-${k}` : ""}.${jpeg ? "jpg" : "png"}`);
22
24
  await page.screenshot(jpeg ? { path: f, type: "jpeg", quality: 82 } : { path: f });
23
25
  files.push(f);
24
26
  }
27
+ onProgress({ phase: 'slides', completed: i + 1, total: n, slide: i + 1 });
25
28
  }
26
29
  return { files, errors };
27
30
  } finally { await browser.close(); }
@@ -48,18 +51,22 @@ export async function contactSheet(files, outDir, { perSheet = 12, cols = 3 } =
48
51
  } finally { await browser.close(); }
49
52
  }
50
53
 
51
- export async function pdf(htmlFile, outFile) {
54
+ export async function pdf(htmlFile, outFile, { onProgress = () => {} } = {}) {
52
55
  const { browser, page } = await openDeck(htmlFile, { scale: 1 });
53
56
  try {
54
57
  const n = await page.evaluate(() => window.sagadeck.n);
55
58
  const imgs = [], links = [];
56
59
  for (let i = 0; i < n; i++) {
60
+ onProgress({ phase: 'slides', completed: i, total: n, slide: i + 1 });
57
61
  const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
58
62
  await page.evaluate(([j, k]) => window.sagadeck.goto(j, k), [i, S]);
63
+ await settleSlide(page);
59
64
  await page.waitForTimeout(50);
60
65
  imgs.push((await page.screenshot({ type: "jpeg", quality: 92 })).toString("base64"));
61
66
  links.push(await page.evaluate(gotoRects, i));
67
+ onProgress({ phase: 'slides', completed: i + 1, total: n, slide: i + 1 });
62
68
  }
69
+ onProgress({ phase: 'packing', completed: n, total: n });
63
70
  // cada página é a foto do slide; por cima, áreas clicáveis nos itens com goto (o PDF pula para a página do destino)
64
71
  const hit = (l) => `<a href="#p${l.to}" style="position:absolute;left:${l.x}px;top:${l.y}px;width:${l.w}px;height:${l.h}px"></a>`;
65
72
  const { w: W, h: H } = await page.evaluate(() => window.sagadeck.size || { w: 1920, h: 1080 }); // deck.aspect
@@ -52,6 +52,25 @@ export function normalizeSpec(rawSpec) { if (!rawSpec || typeof rawSpec !== "ob
52
52
  spec.slides = spec.slides.map((s) => {
53
53
  if (!s || typeof s !== "object") return { layout: "statement", text: String(s) };
54
54
 
55
+ // Tipografia cinética saiu (Narumi: "se ele não consegue ser melhor, remova completamente"): o deck que a usava
56
+ // vira uma frase em destaque com as mesmas frases, uma por clique; a cena de fundo não tinha onde ficar
57
+ if (s.layout === "kinetic") {
58
+ const beats = (Array.isArray(s.beats) ? s.beats : []).map((b) => (typeof b === "string" ? b : b?.text)).filter((t) => typeof t === "string" && t.trim());
59
+ const phrases = beats.length ? beats : [s.title || s.text, s.subtitle].filter((t) => typeof t === "string" && t.trim());
60
+ const { beats: _b, autoplay: _a, interval: _i, figure: _f, layout: _l, title: _t, subtitle: _s, text: _x, ...rest } = s;
61
+ s = phrases.length > 1 ? { ...rest, layout: "statement", lines: phrases, build: true } : { ...rest, layout: "statement", text: phrases[0] || "" };
62
+ }
63
+
64
+ // Texto no cenário saiu (Narumi: "ele é muito limitado e dá para fazer coisas melhores pedindo pra IA desenhar"):
65
+ // com imagem, vira imagem de página inteira com o título por cima; sem imagem, frase em destaque
66
+ if (s.layout === "scenography") {
67
+ const { scene: _sc, foreground: _fg, imageOpacity: _op, titleSize: _ts, subtitle, caption, title, image, kicker, ...rest } = s;
68
+ const detail = [subtitle, caption].filter((v) => v != null && String(v).trim()).map(String).join("\n\n");
69
+ s = image
70
+ ? { ...rest, layout: "full", image, ...(kicker ? { kicker } : {}), ...(title ? { title } : {}), ...(detail ? { caption: detail } : {}), overlay: "left" }
71
+ : { ...rest, layout: "statement", ...(kicker ? { kicker } : {}), text: String(title || "").replace(/\n/g, " ").trim(), ...(detail ? { by: detail } : {}) };
72
+ }
73
+
55
74
  // Termos em português para campos básicos
56
75
  if (s.titulo && !s.title) { s.title = s.titulo; delete s.titulo; }
57
76
  if (s.subtitulo && !s.subtitle) { s.subtitle = s.subtitulo; delete s.subtitulo; }