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.
- package/README.md +55 -4
- package/SKILL.md +10 -3
- package/bin/sagadeck.js +23 -0
- package/docs/ANDAMENTO.md +483 -1
- package/docs/PLANO-MELHORIAS.md +180 -0
- package/docs/REFERENCIA.md +184 -105
- package/package.json +34 -2
- package/src/ai/commands.js +2 -2
- package/src/ai/deck-ai.js +14 -5
- package/src/ai/ia-check.js +127 -0
- package/src/ai/ia-config.js +2 -2
- package/src/ai/llm.js +5 -1
- package/src/ai/map-analysis-run.js +119 -0
- package/src/ai/map-data.js +221 -0
- package/src/ai/quality.js +1 -1
- package/src/ai/video-generation.js +70 -25
- package/src/build.js +51 -15
- package/src/elements.js +39 -1
- package/src/export/browser.js +23 -1
- package/src/export/pptx.js +9 -3
- package/src/export/shots.js +10 -3
- package/src/fiscal/normalize.js +19 -0
- package/src/fs-retry.js +37 -2
- package/src/import/office.js +5 -4
- package/src/layouts.js +114 -99
- package/src/lessons.js +1 -1
- package/src/library.js +7 -7
- package/src/map-analysis.js +109 -0
- package/src/map-config.js +89 -0
- package/src/map-routing.js +82 -0
- package/src/map-services.js +60 -0
- package/src/map.js +274 -0
- package/src/mcp/server.js +17 -0
- package/src/motion.js +20 -6
- package/src/purpose.js +1 -1
- package/src/runtime/base.css +105 -329
- package/src/runtime/fit.js +3 -2
- package/src/runtime/fonts/amatic-sc.css +3 -0
- package/src/runtime/fonts/bebas-neue.css +2 -0
- package/src/runtime/fonts/cabin-sketch.css +2 -0
- package/src/runtime/fonts/fraunces.css +3 -0
- package/src/runtime/fonts/gelasio.css +3 -0
- package/src/runtime/fonts/ibm-plex-mono.css +4 -0
- package/src/runtime/fonts/index.json +10 -1
- package/src/runtime/fonts/itim.css +2 -0
- package/src/runtime/fonts/montserrat.css +2 -0
- package/src/runtime/fonts/space-grotesk.css +2 -0
- package/src/runtime/map-background.js +91 -0
- package/src/runtime/map.js +266 -0
- package/src/runtime/runtime.js +81 -100
- package/src/runtime/skins/caderno.css +33 -0
- package/src/runtime/skins/cartaz.css +27 -0
- package/src/runtime/skins/infografico.css +29 -0
- package/src/runtime/skins/lousa.css +22 -0
- package/src/runtime/skins/musgo.css +42 -0
- package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
- package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
- package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -0
- package/src/runtime/vendor/geo-contornos.json +1 -0
- package/src/runtime/vendor/leaflet.css +661 -0
- package/src/runtime/vendor/leaflet.js +6 -0
- package/src/runtime/vendor/maplibre-gl.css +1 -0
- package/src/runtime/vendor/maplibre-gl.js +825 -0
- package/src/runtime/vendor/maplibre-leaflet.js +234 -0
- package/src/runtime/vendor/maplibre-worker.js +10 -0
- package/src/studio/ai-chat-route.js +20 -0
- package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
- package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
- package/src/studio/command-approval.js +2 -3
- package/src/studio/demo-decks.js +6 -2
- package/src/studio/export-jobs.js +86 -0
- package/src/studio/exporting.js +36 -18
- package/src/studio/feature-demos.js +144 -0
- package/src/studio/geo-routes.js +138 -0
- package/src/studio/ia-routes.js +9 -0
- package/src/studio/layout-samples.js +78 -16
- package/src/studio/library-routes.js +0 -9
- package/src/studio/project.js +2 -1
- package/src/studio/public/ai-settings.js +6 -3
- package/src/studio/public/app.js +46 -52
- package/src/studio/public/explorer.js +23 -8
- package/src/studio/public/export-status.js +154 -0
- package/src/studio/public/index.html +17 -10
- package/src/studio/public/library.css +12 -3
- package/src/studio/public/library.html +3 -0
- package/src/studio/public/library.js +39 -12
- package/src/studio/public/map-data.js +158 -0
- package/src/studio/public/map-editor.js +676 -0
- package/src/studio/public/map-fields.js +21 -0
- package/src/studio/public/map-settings.js +86 -0
- package/src/studio/public/slide-form.js +19 -16
- package/src/studio/public/style.css +58 -0
- package/src/studio/public/ui-icons.js +1 -1
- package/src/studio/public/viewers.js +2 -1
- package/src/studio/public/visual-editor.js +1 -1
- package/src/studio/server.js +34 -9
- package/src/studio/snapshot.js +8 -4
- package/src/studio/template-collections.js +1 -1
- package/src/studio/tour.js +158 -0
- package/src/themes.js +154 -0
- package/templates/cenario-e-ciencia.yaml +1 -21
- package/templates/ensaio-api.yaml +3 -3
- package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
- package/templates/cenario/imagens/cidade.svg +0 -30
- 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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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},
|
|
57
|
+
export async function estimateVideoCost({model,duration,resolution},opts={}){
|
|
21
58
|
try{
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if(!
|
|
25
|
-
const meta=
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
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
|
|
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
|
|
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}
|
|
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]||`${
|
|
82
|
-
if(url.protocol!=='https:')throw new Error('Download do vídeo exige HTTPS.');
|
|
83
|
-
|
|
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,{
|
|
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&&
|
|
152
|
+
else if(!done&&can){
|
|
108
153
|
try{
|
|
109
|
-
const r=await
|
|
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&&
|
|
159
|
+
if(status==='completed'&&!video&&can){
|
|
115
160
|
try{
|
|
116
|
-
const dl=await videoOperation({action:'download',id:record.id},{cwd
|
|
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,
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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"
|
|
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
|
-
|
|
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 ||
|
|
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;
|
package/src/export/browser.js
CHANGED
|
@@ -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 });
|
package/src/export/pptx.js
CHANGED
|
@@ -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++) {
|
package/src/export/shots.js
CHANGED
|
@@ -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
|
package/src/fiscal/normalize.js
CHANGED
|
@@ -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; }
|