sagadeck 1.8.0 → 1.10.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 +25 -8
- package/SKILL.md +10 -3
- package/bin/sagadeck.js +23 -0
- package/docs/ANDAMENTO.md +482 -7
- package/docs/PLANO-MELHORIAS.md +180 -0
- package/docs/REFERENCIA.md +178 -111
- package/package.json +35 -4
- package/src/ai/commands.js +2 -2
- package/src/ai/context.js +7 -2
- package/src/ai/deck-ai.js +4 -2
- 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 +1 -1
- package/src/ai/quality.js +1 -1
- package/src/ai/video-generation.js +70 -25
- package/src/api-client.js +42 -10
- package/src/build.js +46 -14
- package/src/code-highlight.js +19 -2
- package/src/code-language.js +13 -0
- package/src/elements.js +39 -1
- package/src/export/pptx.js +7 -2
- package/src/export/shots.js +7 -2
- 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 +104 -99
- package/src/lessons.js +1 -1
- package/src/library.js +7 -7
- package/src/map-analysis.js +4 -1
- package/src/map-config.js +10 -4
- package/src/map.js +21 -8
- package/src/mcp/server.js +17 -0
- package/src/motion.js +20 -6
- package/src/purpose.js +1 -1
- package/src/runtime/api-core.js +122 -11
- package/src/runtime/api-ui.js +2 -1
- package/src/runtime/base.css +88 -332
- 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 +41 -9
- package/src/runtime/runtime.js +81 -100
- package/src/runtime/skins/aurora-dev.css +39 -0
- 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/skins/terminal-pro.css +33 -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 -1
- 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 +26 -7
- 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 +4 -2
- package/src/studio/developer-starters.js +46 -0
- 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/ia-routes.js +9 -0
- package/src/studio/layout-samples.js +9 -18
- package/src/studio/library-routes.js +7 -9
- package/src/studio/project.js +1 -0
- package/src/studio/public/about.js +68 -0
- package/src/studio/public/ai-settings.js +2 -2
- package/src/studio/public/app.js +43 -52
- package/src/studio/public/chat.js +5 -1
- package/src/studio/public/developer-authoring.css +12 -0
- package/src/studio/public/developer-authoring.js +306 -0
- package/src/studio/public/export-status.js +154 -0
- package/src/studio/public/generation-guide.js +42 -0
- package/src/studio/public/index.html +31 -10
- package/src/studio/public/library.css +48 -3
- package/src/studio/public/library.html +5 -1
- package/src/studio/public/library.js +150 -41
- package/src/studio/public/map-data.js +7 -3
- package/src/studio/public/map-editor.js +625 -236
- package/src/studio/public/map-settings.js +3 -1
- package/src/studio/public/slide-form.js +27 -29
- package/src/studio/public/style.css +56 -9
- package/src/studio/public/ui-icons.js +1 -1
- package/src/studio/server.js +29 -14
- package/src/studio/snapshot.js +8 -4
- package/src/studio/template-collections.js +1 -1
- package/src/studio/tour.js +89 -7
- package/src/themes.js +202 -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
package/package.json
CHANGED
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sagadeck",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Gerador de apresentações: YAML -> HTML animado + PowerPoint editável, com temas e figuras geradas na hora.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"sagadeck": "bin/sagadeck.js"
|
|
8
8
|
},
|
|
9
9
|
"engines": {
|
|
10
|
-
"node": ">=
|
|
10
|
+
"node": ">=24.0.0"
|
|
11
11
|
},
|
|
12
12
|
"license": "MIT",
|
|
13
|
+
"homepage": "https://github.com/naruminho/sagadeck#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/naruminho/sagadeck/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"apresentacoes",
|
|
19
|
+
"slides",
|
|
20
|
+
"presentation",
|
|
21
|
+
"pptx",
|
|
22
|
+
"powerpoint",
|
|
23
|
+
"pdf",
|
|
24
|
+
"yaml",
|
|
25
|
+
"llm",
|
|
26
|
+
"ai",
|
|
27
|
+
"generator",
|
|
28
|
+
"cli"
|
|
29
|
+
],
|
|
13
30
|
"dependencies": {
|
|
14
31
|
"jszip": "^3.10.2",
|
|
15
|
-
"katex": "^0.
|
|
32
|
+
"katex": "^0.19.0",
|
|
16
33
|
"lucide-static": "^1.47.0",
|
|
17
34
|
"pdfjs-dist": "^6.3.289",
|
|
18
35
|
"playwright-core": "^1.63.0",
|
|
@@ -21,20 +38,34 @@
|
|
|
21
38
|
"qrcode-generator": "^2.0.4",
|
|
22
39
|
"yaml": "^2.9.1"
|
|
23
40
|
},
|
|
41
|
+
"overrides": {
|
|
42
|
+
"image-size": "^2.0.4"
|
|
43
|
+
},
|
|
24
44
|
"devDependencies": {
|
|
25
45
|
"@fontsource-variable/caveat": "^5.3.0",
|
|
46
|
+
"@fontsource-variable/fraunces": "^5.3.0",
|
|
26
47
|
"@fontsource-variable/fredoka": "^5.3.0",
|
|
48
|
+
"@fontsource-variable/gelasio": "^5.3.0",
|
|
27
49
|
"@fontsource-variable/inter": "^5.3.0",
|
|
50
|
+
"@fontsource-variable/montserrat": "^5.3.0",
|
|
28
51
|
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
|
|
52
|
+
"@fontsource-variable/space-grotesk": "^5.3.0",
|
|
53
|
+
"@fontsource/amatic-sc": "^5.3.0",
|
|
54
|
+
"@fontsource/bebas-neue": "^5.3.0",
|
|
55
|
+
"@fontsource/cabin-sketch": "^5.3.0",
|
|
29
56
|
"@fontsource/comic-neue": "^5.3.0",
|
|
57
|
+
"@fontsource/ibm-plex-mono": "^5.3.0",
|
|
58
|
+
"@fontsource/itim": "^5.3.0",
|
|
30
59
|
"@fontsource/patrick-hand": "^5.3.0",
|
|
31
60
|
"@fontsource/press-start-2p": "^5.3.0",
|
|
32
61
|
"@fontsource/vt323": "^5.3.0",
|
|
62
|
+
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
|
33
63
|
"@material-symbols/svg-400": "^0.47.5",
|
|
34
64
|
"esbuild": "^0.28.2",
|
|
35
65
|
"jsqr": "^1.4.0",
|
|
36
66
|
"leaflet": "1.9.4",
|
|
37
|
-
"
|
|
67
|
+
"maplibre-gl": "6.13.0",
|
|
68
|
+
"mermaid": "^12.1.0"
|
|
38
69
|
},
|
|
39
70
|
"scripts": {
|
|
40
71
|
"dev": "node bin/sagadeck.js studio",
|
package/src/ai/commands.js
CHANGED
|
@@ -11,7 +11,7 @@ import { spawn } from "node:child_process";
|
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import os from "node:os";
|
|
14
|
-
import { videoOperation } from './video-generation.js';
|
|
14
|
+
import { videoOperation, videoSetup } from './video-generation.js';
|
|
15
15
|
import { webOperation } from './web-operation.js';
|
|
16
16
|
|
|
17
17
|
// Registro de auditoria: cada comando pedido pela IA (aprovado, recusado ou executado), uma linha JSON em
|
|
@@ -69,7 +69,7 @@ export async function runCommand(request, { cwd, env = {}, mask = (s) => s, time
|
|
|
69
69
|
}
|
|
70
70
|
if(language==='video'){
|
|
71
71
|
let args; try { args = typeof code === 'object' && code !== null ? code : JSON.parse(code); } catch(e) { return {language,exitCode:1,stdout:'',stderr:mask(`run.code precisa de JSON válido: ${e.message}`),timedOut:false}; }
|
|
72
|
-
try{return{language,exitCode:0,stdout:mask(JSON.stringify(await videoOperation(args,{cwd,
|
|
72
|
+
try{return{language,exitCode:0,stdout:mask(JSON.stringify(await videoOperation(args,{cwd,setup:videoSetup({env:{...process.env,...env}})}))),stderr:'',timedOut:false};}
|
|
73
73
|
catch(e){return{language,exitCode:1,stdout:'',stderr:mask(e.message),timedOut:false};}
|
|
74
74
|
}
|
|
75
75
|
const win = process.platform === "win32";
|
package/src/ai/context.js
CHANGED
|
@@ -23,7 +23,12 @@ export async function extractDocText(name, buf) {
|
|
|
23
23
|
if (buf.length > UPLOAD_MAX_BYTES) throw new Error(`Arquivo grande demais (${(buf.length / 1048576).toFixed(1)} MB, limite 15 MB).`);
|
|
24
24
|
const ext = (String(name).match(/\.([a-z0-9]+)$/i)?.[1] || "").toLowerCase();
|
|
25
25
|
if(ext==='doc')return {...await extractDocText('converted.docx',await convertLegacyWord(buf)),detail:'doc (convertido para docx)'};
|
|
26
|
-
|
|
26
|
+
// Código é material da pessoa: espaços e linhas podem fazer parte de strings ou da sintaxe.
|
|
27
|
+
if (["js", "mjs", "cjs", "ts", "tsx", "jsx", "py", "sh", "bash", "ps1", "http", "sql", "go", "rs", "java", "c", "cpp", "h", "cs", "rb", "php", "html", "css"].includes(ext)) {
|
|
28
|
+
return { text: buf.toString("utf8"), detail: `código (.${ext})` };
|
|
29
|
+
}
|
|
30
|
+
if (["json", "jsonc", "yaml", "yml"].includes(ext)) return { text: buf.toString("utf8"), detail: "texto" };
|
|
31
|
+
if (["txt", "md", "markdown", "csv"].includes(ext)) {
|
|
27
32
|
return { text: clean(buf.toString("utf8")), detail: "texto" };
|
|
28
33
|
}
|
|
29
34
|
if (ext === "docx") {
|
|
@@ -74,7 +79,7 @@ export async function extractDocText(name, buf) {
|
|
|
74
79
|
return { text: clean(parts.join("\n\n")), detail: `xlsx (${sheets.length} planilha(s))` };
|
|
75
80
|
}
|
|
76
81
|
if (ext === "pdf") return pdfText(buf);
|
|
77
|
-
throw new Error(`Tipo não suportado${ext ? ` (.${ext})` : ""}:
|
|
82
|
+
throw new Error(`Tipo não suportado${ext ? ` (.${ext})` : ""}: anexe texto, código (js, ts, py, sh, http, sql e outros), docx, xlsx, pptx ou pdf.`);
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
async function pdfText(buf) {
|
package/src/ai/deck-ai.js
CHANGED
|
@@ -69,7 +69,7 @@ Siga ESTRITAMENTE a referência abaixo: use só layouts, elementos, campos e fig
|
|
|
69
69
|
- ANTI-FEIJÃO-COM-ARROZ: varie os layouts de verdade; capa (cover) no início e encerramento (end) no fim quando fizer sentido. Regras duras: nunca 2 slides seguidos com o mesmo layout (salvo série intencional); em 6+ slides use 5+ layouts diferentes com 1 slide de impacto (headline, number, statement, full, image, question); no máximo 45% do deck em listas/cartões (cards, list, steps, stats, bento, agenda). Conteúdo manda no layout, não o contrário (ver o mapa em "Layouts válidos").
|
|
70
70
|
- Slide denso (documentação, referência, números): feche com 1 takeaway em ==destaque== e use o elemento \`aviso\` (tipos: \`importante\`, \`atencao\`, \`dica\`, \`perigo\`) para o que não pode passar batido; grife ==palavras-chave== no texto corrido em vez de encher de negrito.
|
|
71
71
|
- Escreva no idioma do pedido do usuário.
|
|
72
|
-
- Layouts válidos (\`layout:\`): ${Object.keys(LAYOUTS).join(", ")}. Conteúdo → layout (use o que comunica, não o mais fácil): processo/fluxo/arquitetura/UML: layout \`diagram\` (Mermaid); itens em volta de uma ideia: layout \`infographic\`; gráfico: \`chart\` (fórmula/curva/3D: \`science\`); datas: \`calendar\` (grade) ou \`timeline\` (marcos); página densa estilo revista: \`poster\`; jornada+problema+solução+painel: \`onepage\`; status semanal: \`status\`; definição: \`definition\`; citação: \`quote\`; número gigante: \`number\`; KPIs: \`stats\`; A×B: \`compare\`; 2×2: \`matrix\`; pergunta à plateia: \`question\`/\`poll\`; código: \`code\`, guiado: \`codewalk\`; screenshot com foco: \`spotlight\`; API ao vivo: \`api\`; conta/calculadora: \`solution\`/\`calc\`; algoritmo animado: \`algo\`; redes/rotas: \`graphlab\`; código executável pelo aluno: \`codelab\`; experimento: \`playground\`; decisão: \`decisionlab\`; duelo/terminais/turnos: \`duel\`/\`terminals\`/\`turns\`; carrossel: \`carousel\`; funil/pirâmide/agenda: \`funnel\`/\`pyramid\`/\`agenda\`; grade/consulta: \`bento\`/\`mosaic\`/\`ribbon\`/\`dossier\`;
|
|
72
|
+
- Layouts válidos (\`layout:\`): ${Object.keys(LAYOUTS).join(", ")}. Conteúdo → layout (use o que comunica, não o mais fácil): processo/fluxo/arquitetura/UML: layout \`diagram\` (Mermaid); itens em volta de uma ideia: layout \`infographic\`; gráfico: \`chart\` (fórmula/curva/3D: \`science\`); datas: \`calendar\` (grade) ou \`timeline\` (marcos); página densa estilo revista: \`poster\`; jornada+problema+solução+painel: \`onepage\`; status semanal: \`status\`; definição: \`definition\`; citação: \`quote\`; número gigante: \`number\`; KPIs: \`stats\`; A×B: \`compare\`; 2×2: \`matrix\`; pergunta à plateia: \`question\`/\`poll\`; código: \`code\`, guiado: \`codewalk\`; screenshot com foco: \`spotlight\`; API ao vivo: \`api\`; conta/calculadora: \`solution\`/\`calc\`; algoritmo animado: \`algo\`; redes/rotas: \`graphlab\`; código executável pelo aluno: \`codelab\`; experimento: \`playground\`; decisão: \`decisionlab\`; duelo/terminais/turnos: \`duel\`/\`terminals\`/\`turns\`; carrossel: \`carousel\`; funil/pirâmide/agenda: \`funnel\`/\`pyramid\`/\`agenda\`; grade/consulta: \`bento\`/\`mosaic\`/\`ribbon\`/\`dossier\`; caminhos com volta ao mapa: \`hub\`+\`goto\`/\`back\`/\`next\`; manchete: \`headline\`; imagem total: \`full\`/\`image\`. Diagramas simples (\`diagram: loop/flow/venn…\`) e gráficos também existem como ELEMENTOS, dentro de figure/content/side.
|
|
73
73
|
- Temas disponíveis: ${themes}.
|
|
74
74
|
- YAML: coloque entre aspas duplas todo texto que comece com marcação (\`**\`, \`*\`, \`==\`, \`^^\`, \`~~\`, \`[\`) ou que contenha ": ".
|
|
75
75
|
- Em sequências inline ([{...}]): coloque entre aspas todo valor de texto com vírgula ("3,5 peixes por m³"). Sem aspas a vírgula parte o objeto e o dado sai truncado.
|
|
@@ -1063,7 +1063,7 @@ Antes de responder, verifique (e siga as Regras de edição):
|
|
|
1063
1063
|
const touched = { ...edited, slides: changed.map((i) => edited.slides[i]).filter(Boolean) }; // mesmos objetos: gera no lugar
|
|
1064
1064
|
const nImgs = images && !deferImages ? countImagePrompts(touched) : 0; // deferImages: quem chamou gera depois (Criar com IA)
|
|
1065
1065
|
const imgs = nImgs
|
|
1066
|
-
? await materializeImages(touched, { ...imageOptions, keepFailed: true,
|
|
1066
|
+
? await materializeImages(touched, { ...(typeof imageOptions === "function" ? imageOptions() : imageOptions), keepFailed: true,
|
|
1067
1067
|
onProgress: (m) => onProgress?.({ phase: "images", text: `${m[0].toUpperCase()}${m.slice(1)} (${nImgs} no total)…` }) })
|
|
1068
1068
|
: { done: [], failed: [] };
|
|
1069
1069
|
imgs.done.forEach((d) => actions.push(`Imagem gerada: ${path.basename(d.file)}`));
|
|
@@ -1148,6 +1148,8 @@ export function slidesForMinutes(min) {
|
|
|
1148
1148
|
// Estilo do modal (uma coleção) → { theme, direction } para a geração. Desconhecido: null.
|
|
1149
1149
|
// estilos que não são coleção: material técnico, o par claro/escuro do tema manual
|
|
1150
1150
|
const TECH_STYLES = {
|
|
1151
|
+
"terminal-pro": { theme: "terminal-pro", direction: "Desenvolvimento em Terminal Pro: fundo grafite, menta, código legível em janelas de IDE e transições discretas. Use os recursos nativos de código guiado, terminal de cenas e API quando ajudarem a explicar o conteúdo; preserve os exemplos da pessoa. Use o tema terminal-pro, sem trocar sua paleta escura por uma clara." },
|
|
1152
|
+
"aurora-dev": { theme: "aurora-dev", direction: "Desenvolvimento em Aurora Dev: azul noite com lilás e ciano, luz ambiente, diagramas e código em painéis legíveis, movimento suave sem competir com a leitura. Use recursos nativos de programação e APIs conforme o pedido; preserve os exemplos da pessoa. Use o tema aurora-dev, sem trocar sua paleta escura por uma clara." },
|
|
1151
1153
|
manual: { theme: "manual", direction: "Documentação técnica clara: texto corrido legível, código completo, diagramas e avisos; boa para imprimir. Use o tema manual." },
|
|
1152
1154
|
"manual-noite": { theme: "manual-noite", direction: "Documentação técnica escura: texto corrido legível, código em destaque, diagramas e avisos; descansa a vista. Use o tema manual-noite." },
|
|
1153
1155
|
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Diagnóstico da IA configurada, sem tela: cada recurso (texto, visão, imagem, pesquisa, vídeo) com uma chamada de
|
|
2
|
+
// verdade ao provedor, todos ao mesmo tempo, cada resultado avisado assim que chega. Para quem configura e para agentes
|
|
3
|
+
// (Copilot, Claude) saberem em segundos o que funciona, sem abrir o Studio. `sagadeck ia-check`, a ferramenta
|
|
4
|
+
// sagadeck_ia_check do MCP e POST /api/ia/check usam esta mesma função.
|
|
5
|
+
//
|
|
6
|
+
// Vídeo: por padrão só confere a chave e o modelo no catálogo, sem custo; com videoReal: true gera um clipe curto de
|
|
7
|
+
// verdade, baixa e confere que é um MP4 (é o único jeito de provar; nem todo provedor tem catálogo).
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import zlib from "node:zlib";
|
|
12
|
+
import { chat, generateImage, llmConfig, llmConfigured } from "./llm.js";
|
|
13
|
+
import { videoOperation, videoCatalog, videoSetup } from "./video-generation.js";
|
|
14
|
+
|
|
15
|
+
export const CHECKS = { texto: "Texto", visao: "Visão", imagem: "Geração de imagem", pesquisa: "Pesquisa na web", video: "Geração de vídeo" };
|
|
16
|
+
// prazo de cada um (s): um modelo travado não segura os outros
|
|
17
|
+
const PRAZO = { texto: 30, visao: 45, imagem: 120, pesquisa: 60 };
|
|
18
|
+
|
|
19
|
+
// ---- a imagem do teste de visão: duas faixas de cor, desenhada aqui mesmo (PNG mínimo, sem navegador)
|
|
20
|
+
const CORES = { vermelho: [229, 57, 53], azul: [30, 99, 233], verde: [46, 158, 68], amarelo: [245, 197, 24] };
|
|
21
|
+
const SINONIMOS = { vermelho: /vermelh|\bred\b/, azul: /\bazul|\bblue\b/, verde: /\bverde|\bgreen\b/, amarelo: /amarel|\byellow\b/ };
|
|
22
|
+
export function twoColorPNG(left, right, w = 96, h = 48) {
|
|
23
|
+
const row = Buffer.alloc(1 + w * 3);
|
|
24
|
+
for (let x = 0; x < w; x++) row.set(CORES[x < w / 2 ? left : right], 1 + x * 3);
|
|
25
|
+
const chunk = (type, data) => {
|
|
26
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
27
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
|
28
|
+
const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(body));
|
|
29
|
+
return Buffer.concat([len, body, crc]);
|
|
30
|
+
};
|
|
31
|
+
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
|
|
32
|
+
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr),
|
|
33
|
+
chunk("IDAT", zlib.deflateSync(Buffer.concat(Array.from({ length: h }, () => row)))), chunk("IEND", Buffer.alloc(0))]);
|
|
34
|
+
}
|
|
35
|
+
// as duas cores: sorteadas (um palpite certo por acaso é improvável) ou fixas pelo seed (testes)
|
|
36
|
+
export function colorsFor(seed) {
|
|
37
|
+
const nomes = Object.keys(CORES), i = seed == null ? Math.floor(Math.random() * 12) : Number(seed) % 12;
|
|
38
|
+
const a = Math.floor(i / 3), b = (a + 1 + (i % 3)) % 4;
|
|
39
|
+
return [nomes[a], nomes[b]];
|
|
40
|
+
}
|
|
41
|
+
const IMG_MAGIC = [["image/png", "89504e47"], ["image/jpeg", "ffd8ff"], ["image/webp", "52494646"], ["image/gif", "47494638"]];
|
|
42
|
+
const today = (offsetH = -3) => new Date(Date.now() + offsetH * 3600_000).toISOString().slice(0, 10);
|
|
43
|
+
const quick = (cfg, s) => ({ ...cfg, timeoutMs: s * 1000, firstTimeoutMs: s * 1000 });
|
|
44
|
+
const ask = (cfg, model, content, s) => chat([{ role: "user", content }], { cfg: quick(cfg, s), model, think: false, temperature: 0, maxTokens: 80, allowTruncated: true, retries: 0 });
|
|
45
|
+
const said = (t) => `respondeu "${String(t || "").trim().replace(/\s+/g, " ").slice(0, 80)}"`;
|
|
46
|
+
|
|
47
|
+
const RUN = {
|
|
48
|
+
async texto({ cfg }) {
|
|
49
|
+
const r = await ask(cfg, cfg.textModel, "Responda só com a palavra: pronto", PRAZO.texto);
|
|
50
|
+
return /pronto/i.test(r.text) ? { status: "ok", modelo: cfg.textModel, detalhe: said(r.text) } : { status: "falhou", modelo: cfg.textModel, detalhe: `${said(r.text)} (esperado: pronto)` };
|
|
51
|
+
},
|
|
52
|
+
async visao({ cfg, seed }) {
|
|
53
|
+
const [esq, dir] = colorsFor(seed);
|
|
54
|
+
const url = `data:image/png;base64,${twoColorPNG(esq, dir).toString("base64")}`;
|
|
55
|
+
const r = await ask(cfg, cfg.visionModel, [{ type: "text", text: "A imagem tem duas faixas verticais de cor. Quais são as cores, da esquerda para a direita? Responda só as duas cores, em português, separadas por vírgula." }, { type: "image_url", image_url: { url } }], PRAZO.visao);
|
|
56
|
+
const modelo = r.visionRouted || cfg.visionModel;
|
|
57
|
+
if (r.imagesDropped) return { status: "falhou", modelo, detalhe: "o modelo não aceitou a imagem: respondeu sem vê-la. Escolha em Configurar IA um modelo de visão que aceite imagem." };
|
|
58
|
+
const t = String(r.text || "").toLowerCase(), m = (c) => t.search(SINONIMOS[c]);
|
|
59
|
+
const ok = m(esq) >= 0 && m(dir) >= 0 && m(esq) < m(dir);
|
|
60
|
+
return { status: ok ? "ok" : "falhou", modelo, detalhe: `${said(r.text)}${ok ? "" : ` (a imagem era ${esq}, ${dir})`}` };
|
|
61
|
+
},
|
|
62
|
+
async imagem({ cfg, outDir }) {
|
|
63
|
+
const img = await Promise.race([generateImage("a single red circle centered on a plain white background, flat, minimal", { cfg: quick(cfg, PRAZO.imagem) }),
|
|
64
|
+
new Promise((_, no) => setTimeout(() => no(new Error(`passou de ${PRAZO.imagem} s sem devolver a imagem`)), PRAZO.imagem * 1000).unref())]);
|
|
65
|
+
const hex = img.data.subarray(0, 4).toString("hex"), tipo = IMG_MAGIC.find(([, m]) => hex.startsWith(m))?.[0];
|
|
66
|
+
if (!tipo || img.data.length < 60) return { status: "falhou", modelo: cfg.imageModel, detalhe: `o que voltou não é uma imagem (${img.data.length} bytes)` };
|
|
67
|
+
const arquivo = path.join(outDir(), `imagem${tipo === "image/jpeg" ? ".jpg" : "." + tipo.split("/")[1]}`);
|
|
68
|
+
fs.writeFileSync(arquivo, img.data);
|
|
69
|
+
return { status: "ok", modelo: cfg.imageModel, detalhe: `${tipo}, ${Math.round(img.data.length / 1024)} KB`, arquivo };
|
|
70
|
+
},
|
|
71
|
+
async pesquisa({ cfg }) {
|
|
72
|
+
if (!cfg.searchModel) return { status: "nao_configurado", detalhe: "sem modelo de pesquisa (opcional: sem ele, a pesquisa usa os buscadores diretos)" };
|
|
73
|
+
const r = await ask(cfg, cfg.searchModel, "Pesquise na web: qual é a data de hoje no horário de Brasília? Responda só no formato AAAA-MM-DD.", PRAZO.pesquisa);
|
|
74
|
+
const d = String(r.text || "").match(/(\d{4})-(\d{2})-(\d{2})/)?.[0];
|
|
75
|
+
const dias = d ? Math.abs(Date.parse(d) - Date.parse(today())) / 86400_000 : Infinity;
|
|
76
|
+
return dias <= 1 ? { status: "ok", modelo: cfg.searchModel, detalhe: said(r.text) }
|
|
77
|
+
: { status: "falhou", modelo: cfg.searchModel, detalhe: `${said(r.text)}; hoje é ${today()} (não parece ter pesquisado)` };
|
|
78
|
+
},
|
|
79
|
+
// o mesmo caminho do produto (videoSetup): o modelo de vídeo do Configurar IA, com a chave e o adaptador de lá, ou as
|
|
80
|
+
// variáveis SAGADECK_VIDEO_KEY / OPENROUTER_API_KEY para quem já usava assim
|
|
81
|
+
async video({ cfg, env, fetcher, videoReal, videoTimeoutS, videoPollMs, outDir }) {
|
|
82
|
+
const setup = videoSetup({ env, cfg }), modelo = setup.model, de = ` (via ${setup.origem})`;
|
|
83
|
+
if (!setup.key && !setup.adaptador) return { status: "nao_configurado", modelo, detalhe: "ponha um modelo de vídeo em Configurar IA (usa a chave de lá) ou defina SAGADECK_VIDEO_KEY / OPENROUTER_API_KEY" };
|
|
84
|
+
const net = { setup, ...(fetcher !== fetch ? { fetcher } : {}) };
|
|
85
|
+
if (!videoReal) {
|
|
86
|
+
const cat = await videoCatalog(net);
|
|
87
|
+
if (!cat.ok) return { status: "sem_prova", modelo, detalhe: `${cat.motivo}; só o teste real prova: --video-real${de}` };
|
|
88
|
+
const meta = cat.models.find((m) => m.id === modelo || m.canonical_slug === modelo);
|
|
89
|
+
return meta ? { status: "sem_prova", modelo, detalhe: `o modelo está no catálogo, mas a chave e a geração só se provam gerando: --video-real (é cobrado)${de}` }
|
|
90
|
+
: { status: "falhou", modelo, detalhe: `o modelo não está no catálogo do provedor (${cat.models.length} modelos)` };
|
|
91
|
+
}
|
|
92
|
+
const cwd = outDir();
|
|
93
|
+
const sub = await videoOperation({ action: "submit", model: modelo, prompt: "a small red ball rolling slowly across a plain white table", duration: "min", resolution: "min", out: "video.mp4" }, { cwd, ...net });
|
|
94
|
+
const limite = Date.now() + videoTimeoutS * 1000;
|
|
95
|
+
let st = { status: sub.status };
|
|
96
|
+
while (!["completed", "failed", "error", "cancelled"].includes(st.status)) {
|
|
97
|
+
if (Date.now() > limite) return { status: "falhou", modelo, detalhe: `o clipe não ficou pronto em ${videoTimeoutS} s (job ${sub.id}, último estado: ${st.status || "?"})` };
|
|
98
|
+
await new Promise((r) => setTimeout(r, videoPollMs));
|
|
99
|
+
st = await videoOperation({ action: "status", id: sub.id }, { cwd, ...net });
|
|
100
|
+
}
|
|
101
|
+
if (st.status !== "completed") return { status: "falhou", modelo, detalhe: `o provedor encerrou com ${st.status}${st.error ? `: ${typeof st.error === "string" ? st.error : JSON.stringify(st.error)}` : ""}` };
|
|
102
|
+
const dl = await videoOperation({ action: "download", id: sub.id }, { cwd, ...net });
|
|
103
|
+
return { status: "ok", modelo, detalhe: `MP4 de ${Math.round(dl.bytes / 1024)} KB${dl.cost != null ? `, custou $${Number(dl.cost).toFixed(2)}` : ""}${de}`, arquivo: path.join(cwd, dl.video) };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const NEEDS_LLM = new Set(["texto", "visao", "imagem", "pesquisa"]);
|
|
108
|
+
// only: lista de ids (padrão: todos); rapido: sem imagem e vídeo (os lentos e cobrados); videoReal: gera um clipe
|
|
109
|
+
export async function iaCheck({ only, rapido = false, videoReal = false, videoTimeoutS = 600, videoPollMs = 5000, cfg = llmConfig(), env = process.env, fetcher = fetch, seed, outDir, onResult = () => {} } = {}) {
|
|
110
|
+
const ids = (only?.length ? only : Object.keys(CHECKS)).filter((id) => CHECKS[id] && !(rapido && (id === "imagem" || id === "video")));
|
|
111
|
+
let dir = outDir;
|
|
112
|
+
const out = () => (dir ||= fs.mkdtempSync(path.join(os.tmpdir(), "sagadeck-ia-check-")));
|
|
113
|
+
const t0 = Date.now();
|
|
114
|
+
const resultados = await Promise.all(ids.map(async (id) => {
|
|
115
|
+
const start = Date.now();
|
|
116
|
+
let r;
|
|
117
|
+
if (NEEDS_LLM.has(id) && !llmConfigured(cfg)) r = { status: "nao_configurado", detalhe: cfg.problem || "a IA não está configurada (Configurar IA, no Studio, ou ~/.sagadeck/ia.json)" };
|
|
118
|
+
else {
|
|
119
|
+
try { r = await RUN[id]({ cfg, env, fetcher, seed, videoReal, videoTimeoutS, videoPollMs, outDir: out }); }
|
|
120
|
+
catch (e) { r = { status: "falhou", detalhe: e?.message || String(e) }; }
|
|
121
|
+
}
|
|
122
|
+
const res = { id, nome: CHECKS[id], ms: Date.now() - start, ...r };
|
|
123
|
+
onResult(res);
|
|
124
|
+
return res;
|
|
125
|
+
}));
|
|
126
|
+
return { ok: resultados.every((r) => r.status !== "falhou"), ms: Date.now() - t0, provedor: cfg.url || cfg.adaptador || "", resultados };
|
|
127
|
+
}
|
package/src/ai/ia-config.js
CHANGED
|
@@ -17,10 +17,10 @@ export const PROVIDERS = {
|
|
|
17
17
|
// responde, o problema é o ambiente (rede, chave), não o modelo escolhido. Depois, cada um põe o modelo que quiser.
|
|
18
18
|
export const RECOMMENDED = {
|
|
19
19
|
provider: "openrouter",
|
|
20
|
-
models: { text: "deepseek/deepseek-v4.1-flash", vision: "deepseek/deepseek-v4.1-flash", image: "google/gemini-3.1-flash-image", search: "deepseek/deepseek-v4.1-flash:online" },
|
|
20
|
+
models: { text: "deepseek/deepseek-v4.1-flash", vision: "deepseek/deepseek-v4.1-flash", image: "google/gemini-3.1-flash-image", search: "deepseek/deepseek-v4.1-flash:online", video: "google/veo-3.1-lite" },
|
|
21
21
|
};
|
|
22
22
|
// os papéis que o sagadeck usa; cada um aponta para um modelo do provedor
|
|
23
|
-
export const ROLES = ["text", "vision", "image", "search"];
|
|
23
|
+
export const ROLES = ["text", "vision", "image", "search", "video"];
|
|
24
24
|
|
|
25
25
|
export function iaFile(env = process.env, home = os.homedir()) {
|
|
26
26
|
return env.SAGADECK_IA || path.join(home, ".sagadeck", "ia.json");
|
package/src/ai/llm.js
CHANGED
|
@@ -48,6 +48,8 @@ export function llmConfig(env = process.env) {
|
|
|
48
48
|
visionModel: env.SAGADECK_VISION_MODEL || m.vision || m.text || "vision",
|
|
49
49
|
// busca na web pelo modelo (no OpenRouter, um modelo ":online"); sem ele, a pesquisa usa os buscadores diretos
|
|
50
50
|
searchModel: env.SAGADECK_SEARCH_MODEL || m.search || (fromEnv ? "search" : ""),
|
|
51
|
+
// vídeo (opcional): com ele, a geração de vídeo usa esta mesma camada (src/ai/video-generation.js, videoSetup)
|
|
52
|
+
videoModel: env.SAGADECK_VIDEO_MODEL || m.video || "",
|
|
51
53
|
headers: ia?.headers || {},
|
|
52
54
|
adaptador: ia?.adaptador || "",
|
|
53
55
|
source: fromEnv ? "variável" : ia ? "arquivo" : "nenhuma",
|
|
@@ -61,7 +63,7 @@ export const llmConfigured = (cfg = llmConfig()) => !!(cfg.url || cfg.adaptador)
|
|
|
61
63
|
|
|
62
64
|
// o papel (text, vision, image, search) vira o modelo configurado; outro nome passa como veio
|
|
63
65
|
export function resolveModel(cfg, name) {
|
|
64
|
-
const roles = { text: cfg.textModel, vision: cfg.visionModel, image: cfg.imageModel, search: cfg.searchModel };
|
|
66
|
+
const roles = { text: cfg.textModel, vision: cfg.visionModel, image: cfg.imageModel, search: cfg.searchModel, video: cfg.videoModel };
|
|
65
67
|
return roles[name] || name;
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -74,6 +76,8 @@ function headers(cfg) {
|
|
|
74
76
|
// Adaptador (opcional, da pessoa): um .mjs cujo default é (url, init) => Response. Serve para o que não é compatível
|
|
75
77
|
// com a API da OpenAI (outro jeito de autenticar, outro endereço) sem mexer no sagadeck. Sem ele, o fetch normal.
|
|
76
78
|
const adapters = new Map();
|
|
79
|
+
// (exportado: a geração de vídeo manda pelo mesmo caminho, com o mesmo adaptador)
|
|
80
|
+
export const providerFetch = (cfg, url, init) => send(cfg, url, init);
|
|
77
81
|
async function send(cfg, url, init) {
|
|
78
82
|
if (!cfg.adaptador) return fetch(url, init);
|
|
79
83
|
if (!adapters.has(cfg.adaptador)) adapters.set(cfg.adaptador, import(pathToFileURL(path.resolve(cfg.adaptador)).href).then((mod) => mod.default));
|
|
@@ -67,7 +67,7 @@ export async function runAnalysis(a, { P, cfg, onProgress = () => {} }) {
|
|
|
67
67
|
const lines = readShapes(P, a.linha).filter((f) => /LineString/.test(f.geometry?.type || ""));
|
|
68
68
|
if (!lines.length) throw new Error(`${a.linha} não tem linhas`);
|
|
69
69
|
const arquivo = okFile(a.arquivo, ".geojson") ? a.arquivo : `${a.linha.replace(/\.[^.]+$/, "")}-faixa-${Math.round(metros)}m.geojson`;
|
|
70
|
-
Project.writeText(P, arquivo, fc(lines.map((f, k) => ({ type: "Feature", properties: { nome: `${nameOf(f.properties, k)} (faixa de ${fmtMetros(metros)})
|
|
70
|
+
Project.writeText(P, arquivo, fc(lines.map((f, k) => ({ type: "Feature", properties: { nome: `${nameOf(f.properties, k)} (faixa de ${fmtMetros(metros)})`, _faixa: true }, geometry: lineBuffer(f.geometry, metros) }))));
|
|
71
71
|
let extra = "";
|
|
72
72
|
if (a.pontos) {
|
|
73
73
|
const pts = await readPoints(P, a.pontos);
|
package/src/ai/quality.js
CHANGED
|
@@ -51,7 +51,7 @@ export function auditText(spec, indices, { briefing = '', render = null } = {})
|
|
|
51
51
|
// título; o layout da figura já reserva a área. Vale para os layouts com cabeçalho comum (não capa/seção/manchete).
|
|
52
52
|
for (const index of indices) {
|
|
53
53
|
const s = slides[index];
|
|
54
|
-
if (!s || !Number.isFinite(Number(s.titleSize)) || ONE_LINER.has(s.layout) || ['cover', 'section', 'end', '
|
|
54
|
+
if (!s || !Number.isFinite(Number(s.titleSize)) || ONE_LINER.has(s.layout) || ['cover', 'section', 'end', 'full'].includes(s.layout)) continue;
|
|
55
55
|
if (Number(s.titleSize) < 60) issues.push({ slide: index + 1, text: `Título com titleSize ${s.titleSize}, bem menor que o dos outros slides: a hierarquia do deck fica desigual. Tire o titleSize (o layout já dá a área da figura); se o título não couber, encurte o texto.` });
|
|
56
56
|
}
|
|
57
57
|
// muitas telas de frase solta: a partir da terceira (ou de 15% do deck), cada uma precisa justificar o lugar
|
|
@@ -1,28 +1,65 @@
|
|
|
1
1
|
import { mediaPrompt } from './art-direction.js';
|
|
2
|
+
import { llmConfig, providerFetch } from './llm.js';
|
|
2
3
|
// Operações curtas e retomáveis: o chat não fica preso esperando o render do provedor.
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import crypto from 'node:crypto';
|
|
6
7
|
|
|
7
8
|
const BASE='https://openrouter.ai/api/v1';
|
|
9
|
+
export const DEFAULT_VIDEO_MODEL='google/veo-3.1-lite';
|
|
10
|
+
|
|
11
|
+
// De onde o vídeo sai. Com um modelo de vídeo em Configurar IA (papel "video"), pela mesma camada da IA: o endereço, a
|
|
12
|
+
// chave, os cabeçalhos e o adaptador de lá, como texto e imagem. Sem ele, como sempre foi: OpenRouter, com
|
|
13
|
+
// SAGADECK_VIDEO_KEY ou OPENROUTER_API_KEY. SAGADECK_VIDEO_URL e SAGADECK_VIDEO_MODEL trocam endereço e modelo.
|
|
14
|
+
export function videoSetup({env=process.env,cfg=llmConfig(env)}={}){
|
|
15
|
+
const viaConfig=!!cfg.videoModel&&!!(cfg.url||cfg.adaptador);
|
|
16
|
+
return {
|
|
17
|
+
base:String(env.SAGADECK_VIDEO_URL||(viaConfig?cfg.url:BASE)).replace(/\/+$/,''),
|
|
18
|
+
key:env.SAGADECK_VIDEO_KEY||(viaConfig?cfg.key:'')||env.OPENROUTER_API_KEY||'',
|
|
19
|
+
model:env.SAGADECK_VIDEO_MODEL||cfg.videoModel||DEFAULT_VIDEO_MODEL,
|
|
20
|
+
headers:viaConfig?cfg.headers||{}:{},adaptador:viaConfig?cfg.adaptador||'':'',
|
|
21
|
+
origem:viaConfig?'configuração da IA':'variáveis de ambiente',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// a conexão de cada operação: setup explícito, ou {key, fetcher} (compatível com quem já chamava assim, no OpenRouter)
|
|
25
|
+
function conn({setup,key,fetcher}={}){
|
|
26
|
+
const s=setup||(key!==undefined?{base:BASE,key,model:DEFAULT_VIDEO_MODEL,headers:{},adaptador:''}:videoSetup());
|
|
27
|
+
const call=fetcher||(s.adaptador?(url,init)=>providerFetch({adaptador:s.adaptador},url,init):fetch);
|
|
28
|
+
const auth=s.key?{Authorization:`Bearer ${s.key}`}:{};
|
|
29
|
+
return {...s,base:String(s.base||BASE).replace(/\/+$/,''),call,auth:{...(s.headers||{}),...auth}};
|
|
30
|
+
}
|
|
31
|
+
// o catálogo de modelos de vídeo, quando o provedor tem um (nem todo tem): { ok, models } ou { ok: false, motivo }
|
|
32
|
+
export async function videoCatalog(opts={}){
|
|
33
|
+
const c=conn(opts);
|
|
34
|
+
try{
|
|
35
|
+
const r=await c.call(`${c.base}/videos/models`,{method:'GET',headers:c.auth,signal:AbortSignal.timeout(15000)});
|
|
36
|
+
if(r.status===401||r.status===403)return{ok:false,motivo:`a chave foi recusada (HTTP ${r.status})`};
|
|
37
|
+
if(!r.ok)return{ok:false,motivo:`o provedor não tem catálogo de vídeo (HTTP ${r.status})`};
|
|
38
|
+
const data=(await r.json()).data;
|
|
39
|
+
return Array.isArray(data)?{ok:true,models:data}:{ok:false,motivo:'o provedor não devolveu um catálogo de vídeo'};
|
|
40
|
+
}catch(e){return{ok:false,motivo:`o catálogo não respondeu (${e.message})`};}
|
|
41
|
+
}
|
|
8
42
|
export function parseVideoArgs(args){
|
|
9
43
|
const result={};for(let i=0;i<args.length;i++){
|
|
10
44
|
const m=args[i].match(/^--([^=]+)(?:=(.*))?$/);if(!m)throw new Error(`Argumento inesperado: ${args[i]}`);
|
|
11
45
|
result[m[1]]=m[2]??(args[i+1]&&!args[i+1].startsWith('--')?args[++i]:true);
|
|
12
46
|
}return result;
|
|
13
47
|
}
|
|
14
|
-
|
|
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{}
|