slides-grab 1.2.5 → 1.3.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-ko.md +256 -0
- package/README.md +27 -9
- package/bin/ppt-agent.js +125 -4
- package/package.json +11 -7
- package/scripts/editor-server.js +170 -14
- package/scripts/generate-image.js +44 -0
- package/skills/slides-grab/SKILL.md +3 -3
- package/skills/slides-grab/references/presentation-workflow-reference.md +3 -3
- package/skills/slides-grab-design/SKILL.md +8 -4
- package/skills/slides-grab-design/references/design-rules.md +3 -3
- package/skills/slides-grab-design/references/detailed-design-rules.md +2 -2
- package/skills/slides-grab-plan/SKILL.md +17 -4
- package/skills/slides-grab-plan/references/design-md-to-slides-conversion.md +135 -0
- package/src/design-import.js +164 -0
- package/src/design-md-parser.js +415 -0
- package/src/design-styles.js +67 -2
- package/src/editor/codex-edit.js +43 -13
- package/src/editor/edit-subprocess.js +78 -5
- package/src/editor/editor-codex-prompt.md +2 -2
- package/src/editor/editor.html +3 -0
- package/src/editor/js/editor-state.js +2 -2
- package/src/editor/js/model-registry.js +38 -0
- package/src/god-tibo-imagen.js +135 -0
- package/src/nano-banana.js +332 -27
|
@@ -23,6 +23,14 @@ export function buildEditTimeoutMessage({ engineLabel = 'Editor process', timeou
|
|
|
23
23
|
return `${engineLabel} edit timed out after ${timeoutMs}ms and was terminated.`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export const EDIT_ABORT_EXIT_CODE = 130;
|
|
27
|
+
export const EDIT_ABORT_KILL_SIGNAL = 'SIGTERM';
|
|
28
|
+
export const EDIT_ABORT_FORCE_KILL_AFTER_MS = 5_000;
|
|
29
|
+
|
|
30
|
+
export function buildEditAbortMessage({ engineLabel = 'Editor process' } = {}) {
|
|
31
|
+
return `${engineLabel} edit was aborted and the child process was terminated.`;
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
export function runEditSubprocess({
|
|
27
35
|
bin,
|
|
28
36
|
args,
|
|
@@ -32,32 +40,79 @@ export function runEditSubprocess({
|
|
|
32
40
|
timeoutMs = DEFAULT_EDIT_TIMEOUT_MS,
|
|
33
41
|
engineLabel,
|
|
34
42
|
onLog = () => {},
|
|
43
|
+
onChild = () => {},
|
|
44
|
+
signal,
|
|
35
45
|
spawnImpl = spawn,
|
|
36
46
|
}) {
|
|
37
47
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
38
48
|
const child = spawnImpl(bin, args, { cwd, env, stdio });
|
|
39
49
|
|
|
50
|
+
try {
|
|
51
|
+
onChild(child);
|
|
52
|
+
} catch {
|
|
53
|
+
// Never let a faulty observer crash the spawn lifecycle.
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
let stdout = '';
|
|
41
57
|
let stderr = '';
|
|
42
58
|
let timedOut = false;
|
|
59
|
+
let aborted = false;
|
|
43
60
|
let settled = false;
|
|
44
61
|
let forceKillTimer = null;
|
|
62
|
+
let abortForceKillTimer = null;
|
|
45
63
|
|
|
46
64
|
const timeoutMessage = buildEditTimeoutMessage({ engineLabel, timeoutMs });
|
|
65
|
+
const abortMessage = buildEditAbortMessage({ engineLabel });
|
|
47
66
|
|
|
48
67
|
const timeoutTimer = setTimeout(() => {
|
|
68
|
+
if (settled || aborted) return;
|
|
49
69
|
timedOut = true;
|
|
50
70
|
const messageLine = `${timeoutMessage}\n`;
|
|
51
71
|
stderr += messageLine;
|
|
52
72
|
onLog('stderr', messageLine);
|
|
53
|
-
|
|
73
|
+
try {
|
|
74
|
+
child.kill(EDIT_TIMEOUT_KILL_SIGNAL);
|
|
75
|
+
} catch {}
|
|
54
76
|
forceKillTimer = setTimeout(() => {
|
|
55
|
-
|
|
77
|
+
try {
|
|
78
|
+
child.kill('SIGKILL');
|
|
79
|
+
} catch {}
|
|
56
80
|
}, EDIT_TIMEOUT_FORCE_KILL_AFTER_MS);
|
|
57
81
|
forceKillTimer.unref?.();
|
|
58
82
|
}, timeoutMs);
|
|
59
83
|
timeoutTimer.unref?.();
|
|
60
84
|
|
|
85
|
+
function abortChild() {
|
|
86
|
+
if (settled || aborted) return;
|
|
87
|
+
aborted = true;
|
|
88
|
+
const messageLine = `${abortMessage}\n`;
|
|
89
|
+
stderr += messageLine;
|
|
90
|
+
try {
|
|
91
|
+
onLog('stderr', messageLine);
|
|
92
|
+
} catch {}
|
|
93
|
+
try {
|
|
94
|
+
child.kill(EDIT_ABORT_KILL_SIGNAL);
|
|
95
|
+
} catch {}
|
|
96
|
+
abortForceKillTimer = setTimeout(() => {
|
|
97
|
+
try {
|
|
98
|
+
child.kill('SIGKILL');
|
|
99
|
+
} catch {}
|
|
100
|
+
}, EDIT_ABORT_FORCE_KILL_AFTER_MS);
|
|
101
|
+
abortForceKillTimer.unref?.();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (signal) {
|
|
105
|
+
if (signal.aborted) {
|
|
106
|
+
setImmediate(abortChild);
|
|
107
|
+
} else {
|
|
108
|
+
const onAbort = () => abortChild();
|
|
109
|
+
signal.addEventListener?.('abort', onAbort, { once: true });
|
|
110
|
+
child.once('close', () => {
|
|
111
|
+
signal.removeEventListener?.('abort', onAbort);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
61
116
|
child.stdout?.on('data', (chunk) => {
|
|
62
117
|
const text = chunk.toString();
|
|
63
118
|
stdout += text;
|
|
@@ -70,19 +125,36 @@ export function runEditSubprocess({
|
|
|
70
125
|
onLog('stderr', text);
|
|
71
126
|
});
|
|
72
127
|
|
|
73
|
-
child.on('close', (code,
|
|
128
|
+
child.on('close', (code, exitSignal) => {
|
|
74
129
|
if (settled) return;
|
|
75
130
|
settled = true;
|
|
76
131
|
clearTimeout(timeoutTimer);
|
|
77
132
|
clearTimeout(forceKillTimer);
|
|
133
|
+
clearTimeout(abortForceKillTimer);
|
|
134
|
+
|
|
135
|
+
let resolvedCode;
|
|
136
|
+
if (timedOut) {
|
|
137
|
+
resolvedCode = EDIT_TIMEOUT_EXIT_CODE;
|
|
138
|
+
} else if (aborted) {
|
|
139
|
+
resolvedCode = EDIT_ABORT_EXIT_CODE;
|
|
140
|
+
} else {
|
|
141
|
+
resolvedCode = code ?? 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let resolvedSignal = exitSignal;
|
|
145
|
+
if (timedOut && !exitSignal) resolvedSignal = EDIT_TIMEOUT_KILL_SIGNAL;
|
|
146
|
+
else if (aborted && !exitSignal) resolvedSignal = EDIT_ABORT_KILL_SIGNAL;
|
|
147
|
+
|
|
78
148
|
resolvePromise({
|
|
79
|
-
code:
|
|
149
|
+
code: resolvedCode,
|
|
80
150
|
stdout,
|
|
81
151
|
stderr,
|
|
82
|
-
signal:
|
|
152
|
+
signal: resolvedSignal,
|
|
83
153
|
timedOut,
|
|
154
|
+
aborted,
|
|
84
155
|
timeoutMs: timedOut ? timeoutMs : null,
|
|
85
156
|
timeoutMessage: timedOut ? timeoutMessage : null,
|
|
157
|
+
abortMessage: aborted ? abortMessage : null,
|
|
86
158
|
});
|
|
87
159
|
});
|
|
88
160
|
|
|
@@ -91,6 +163,7 @@ export function runEditSubprocess({
|
|
|
91
163
|
settled = true;
|
|
92
164
|
clearTimeout(timeoutTimer);
|
|
93
165
|
clearTimeout(forceKillTimer);
|
|
166
|
+
clearTimeout(abortForceKillTimer);
|
|
94
167
|
rejectPromise(error);
|
|
95
168
|
});
|
|
96
169
|
});
|
|
@@ -29,8 +29,8 @@ The user's edit request is the primary objective. All rules below exist to suppo
|
|
|
29
29
|
- Do not use absolute filesystem paths in slide HTML.
|
|
30
30
|
- Do not use non-body `background-image` for content imagery; use `<img>` instead.
|
|
31
31
|
- Use `data-image-placeholder` to reserve space when no image is available yet.
|
|
32
|
-
- When the request needs bespoke imagery, prefer `slides-grab image --prompt "<prompt>" --slides-dir <path>` so
|
|
33
|
-
-
|
|
32
|
+
- When the request needs bespoke imagery, prefer `slides-grab image --prompt "<prompt>" --slides-dir <path>` so the default god-tibo-imagen provider saves the asset under `<slides-dir>/assets/` via your local Codex ChatGPT login (run `codex login` once if needed).
|
|
33
|
+
- Default provider god-tibo-imagen reuses `~/.codex/auth.json` — no OpenAI/Google API key required; requires a Codex/ChatGPT account entitled to image generation. ⚠️ god-tibo calls an unsupported private Codex backend that may break without notice. Optional fallbacks: `--provider codex` (Codex/OpenAI gpt-image-2 via `OPENAI_API_KEY`; maps `--aspect-ratio` to the nearest supported OpenAI image size; `--image-size 2K|4K` is Nano Banana-only) or `--provider nano-banana` (Google `gemini-3-pro-image-preview` via `GOOGLE_API_KEY` / `GEMINI_API_KEY`; supports `--image-size 2K|4K`). If credentials are unavailable, fall back to web search + download into `./assets/`.
|
|
34
34
|
- For local videos, use `<video src="./assets/<file>">` with `poster="./assets/<file>"`.
|
|
35
35
|
- If a video starts on YouTube or a supported page, use `slides-grab fetch-video --url <url> --slides-dir <path>` to download first.
|
|
36
36
|
|
package/src/editor/editor.html
CHANGED
|
@@ -1630,9 +1630,12 @@
|
|
|
1630
1630
|
<div class="sidebar-section">
|
|
1631
1631
|
<span class="sidebar-label">Model</span>
|
|
1632
1632
|
<select id="model-select" class="model-select">
|
|
1633
|
+
<option value="gpt-5.5">gpt-5.5</option>
|
|
1633
1634
|
<option value="gpt-5.4">gpt-5.4</option>
|
|
1634
1635
|
<option value="gpt-5.3-codex">gpt-5.3-codex</option>
|
|
1635
1636
|
<option value="gpt-5.3-codex-spark">gpt-5.3-codex-spark</option>
|
|
1637
|
+
<option value="claude-opus-4-7">claude-opus-4-7</option>
|
|
1638
|
+
<option value="claude-sonnet-4-6">claude-sonnet-4-6</option>
|
|
1636
1639
|
</select>
|
|
1637
1640
|
</div>
|
|
1638
1641
|
<button class="sidebar-btn" id="btn-send" title="Run Codex">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { ALL_MODELS } from './model-registry.js';
|
|
2
2
|
|
|
3
3
|
export let SLIDE_W = 960;
|
|
4
4
|
export let SLIDE_H = 540;
|
|
@@ -15,7 +15,7 @@ export const POPOVER_TEXT = 'text';
|
|
|
15
15
|
export const POPOVER_TEXT_COLOR = 'text-color';
|
|
16
16
|
export const POPOVER_BG_COLOR = 'bg-color';
|
|
17
17
|
export const POPOVER_SIZE = 'size';
|
|
18
|
-
export const DEFAULT_MODELS =
|
|
18
|
+
export const DEFAULT_MODELS = ALL_MODELS.slice();
|
|
19
19
|
export const DIRECT_TEXT_TAGS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li']);
|
|
20
20
|
export const NON_SELECTABLE_TAGS = new Set(['html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript']);
|
|
21
21
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Canonical editor model registry — single source of truth for the slide
|
|
2
|
+
// editor's model dropdown and dispatch routing. Imported by both Node code
|
|
3
|
+
// (scripts/editor-server.js, src/editor/codex-edit.js) and browser code
|
|
4
|
+
// (src/editor/js/editor-state.js), so this module MUST stay browser-safe:
|
|
5
|
+
// no Node imports, no Node-only globals.
|
|
6
|
+
//
|
|
7
|
+
// To add a new model:
|
|
8
|
+
// 1. Append to CODEX_MODELS or CLAUDE_MODELS below.
|
|
9
|
+
// 2. Add a matching <option> to src/editor/editor.html (also a fallback).
|
|
10
|
+
// 3. Existing tests/editor/editor-model-dispatch.test.js will automatically
|
|
11
|
+
// verify the new model dispatches correctly through the editor pipeline.
|
|
12
|
+
// No per-model test additions needed.
|
|
13
|
+
|
|
14
|
+
export const CODEX_MODELS = [
|
|
15
|
+
'gpt-5.5',
|
|
16
|
+
'gpt-5.4',
|
|
17
|
+
'gpt-5.3-codex',
|
|
18
|
+
'gpt-5.3-codex-spark',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const CLAUDE_MODELS = ['claude-opus-4-7', 'claude-sonnet-4-6'];
|
|
22
|
+
|
|
23
|
+
export const ALL_MODELS = [...CODEX_MODELS, ...CLAUDE_MODELS];
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_CODEX_MODEL = CODEX_MODELS[0];
|
|
26
|
+
export const DEFAULT_MODEL = DEFAULT_CODEX_MODEL;
|
|
27
|
+
|
|
28
|
+
export function isClaudeModel(model) {
|
|
29
|
+
return typeof model === 'string' && CLAUDE_MODELS.includes(model.trim());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isCodexModel(model) {
|
|
33
|
+
return typeof model === 'string' && CODEX_MODELS.includes(model.trim());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isKnownEditorModel(model) {
|
|
37
|
+
return isCodexModel(model) || isClaudeModel(model);
|
|
38
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { mkdir, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createProvider as defaultCreateProvider,
|
|
8
|
+
resolveConfig as defaultResolveConfig,
|
|
9
|
+
} from 'god-tibo-imagen';
|
|
10
|
+
|
|
11
|
+
export const GOD_TIBO_DEFAULT_MODEL = 'gpt-5.4';
|
|
12
|
+
export const GOD_TIBO_PROVIDER_AUTO = 'auto';
|
|
13
|
+
export const GOD_TIBO_PROVIDER_PRIVATE_CODEX = 'private-codex';
|
|
14
|
+
export const GOD_TIBO_PROVIDER_CODEX_CLI = 'codex-cli';
|
|
15
|
+
|
|
16
|
+
const ASPECT_RATIO_HINTS = new Map([
|
|
17
|
+
['16:9', 'wide landscape 16:9 aspect ratio'],
|
|
18
|
+
['9:16', 'tall portrait 9:16 aspect ratio'],
|
|
19
|
+
['1:1', 'square 1:1 aspect ratio'],
|
|
20
|
+
['4:3', '4:3 aspect ratio'],
|
|
21
|
+
['3:4', 'portrait 3:4 aspect ratio'],
|
|
22
|
+
['3:2', 'landscape 3:2 aspect ratio'],
|
|
23
|
+
['2:3', 'portrait 2:3 aspect ratio'],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export function injectAspectRatioHint(prompt, aspectRatio) {
|
|
27
|
+
if (typeof prompt !== 'string' || prompt.trim() === '') {
|
|
28
|
+
throw new Error('injectAspectRatioHint: prompt must be a non-empty string.');
|
|
29
|
+
}
|
|
30
|
+
if (!aspectRatio || typeof aspectRatio !== 'string') {
|
|
31
|
+
return prompt;
|
|
32
|
+
}
|
|
33
|
+
const trimmed = aspectRatio.trim();
|
|
34
|
+
const explicit = ASPECT_RATIO_HINTS.get(trimmed);
|
|
35
|
+
if (explicit) {
|
|
36
|
+
return `${prompt} (${explicit})`;
|
|
37
|
+
}
|
|
38
|
+
if (/^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(trimmed)) {
|
|
39
|
+
return `${prompt} (${trimmed} aspect ratio)`;
|
|
40
|
+
}
|
|
41
|
+
return prompt;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveGodTiboConfig({ providerMode = GOD_TIBO_PROVIDER_AUTO, resolveConfigImpl = defaultResolveConfig } = {}) {
|
|
45
|
+
return resolveConfigImpl({ provider: providerMode });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isCodexAuthError(error) {
|
|
49
|
+
if (!error || typeof error !== 'object') return false;
|
|
50
|
+
const code = String(error.code || '').toUpperCase();
|
|
51
|
+
if (code === 'UNAUTHORIZED' || code === 'ENOENT') return true;
|
|
52
|
+
const message = String(error.message || '').toLowerCase();
|
|
53
|
+
return (
|
|
54
|
+
message.includes('auth.json') ||
|
|
55
|
+
message.includes('unauthorized') ||
|
|
56
|
+
message.includes('chatgpt auth') ||
|
|
57
|
+
message.includes('codex login')
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getGodTiboFallbackMessage(reason) {
|
|
62
|
+
const summary = typeof reason === 'string' && reason.trim()
|
|
63
|
+
? reason.trim()
|
|
64
|
+
: 'god-tibo-imagen image generation failed.';
|
|
65
|
+
return `${summary} god-tibo-imagen is the default image provider and reuses your local Codex ChatGPT login (~/.codex/auth.json). Run \`codex login\` once to enable it. Optional fallbacks: set OPENAI_API_KEY (Codex/OpenAI gpt-image-2) or GOOGLE_API_KEY/GEMINI_API_KEY (Nano Banana). If image generation credentials are unavailable, use web search and download the chosen image into ./assets/<file>.`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function generateGodTiboImage({
|
|
69
|
+
prompt,
|
|
70
|
+
model = GOD_TIBO_DEFAULT_MODEL,
|
|
71
|
+
aspectRatio,
|
|
72
|
+
providerMode = GOD_TIBO_PROVIDER_AUTO,
|
|
73
|
+
dryRun = false,
|
|
74
|
+
deps = {},
|
|
75
|
+
} = {}) {
|
|
76
|
+
if (typeof prompt !== 'string' || prompt.trim() === '') {
|
|
77
|
+
throw new Error('generateGodTiboImage: prompt must be a non-empty string.');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const createProviderImpl = deps.createProvider || defaultCreateProvider;
|
|
81
|
+
const resolveConfigImpl = deps.resolveConfig || defaultResolveConfig;
|
|
82
|
+
|
|
83
|
+
const enrichedPrompt = injectAspectRatioHint(prompt, aspectRatio);
|
|
84
|
+
const config = resolveGodTiboConfig({ providerMode, resolveConfigImpl });
|
|
85
|
+
const provider = createProviderImpl(config);
|
|
86
|
+
|
|
87
|
+
// god-tibo's provider.generateImage writes a PNG to outputPath as a side
|
|
88
|
+
// effect. slides-grab centralizes asset path resolution in
|
|
89
|
+
// saveNanoBananaImage (src/nano-banana.js) - so we route god-tibo's write
|
|
90
|
+
// to a tmp file and read the bytes back, letting the caller persist via
|
|
91
|
+
// the existing asset contract. This keeps the asset path policy single-sourced.
|
|
92
|
+
const tempDir = join(tmpdir(), `slides-grab-godtibo-${randomUUID()}`);
|
|
93
|
+
await mkdir(tempDir, { recursive: true });
|
|
94
|
+
const tempPath = join(tempDir, 'image.png');
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const result = await provider.generateImage({
|
|
98
|
+
prompt: enrichedPrompt,
|
|
99
|
+
model,
|
|
100
|
+
outputPath: tempPath,
|
|
101
|
+
dryRun: Boolean(dryRun),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (dryRun) {
|
|
105
|
+
return {
|
|
106
|
+
mimeType: 'image/png',
|
|
107
|
+
bytes: Buffer.alloc(0),
|
|
108
|
+
mode: result?.mode || 'dry-run',
|
|
109
|
+
warnings: Array.isArray(result?.warnings) ? result.warnings : [],
|
|
110
|
+
revisedPrompt: result?.revisedPrompt ?? null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const bytes = await readFile(tempPath);
|
|
115
|
+
return {
|
|
116
|
+
mimeType: 'image/png',
|
|
117
|
+
bytes,
|
|
118
|
+
mode: result?.mode || 'live',
|
|
119
|
+
warnings: Array.isArray(result?.warnings) ? result.warnings : [],
|
|
120
|
+
revisedPrompt: result?.revisedPrompt ?? null,
|
|
121
|
+
};
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const wrapped = new Error(getGodTiboFallbackMessage(error?.message || String(error)));
|
|
124
|
+
wrapped.cause = error;
|
|
125
|
+
wrapped.isAuthError = isCodexAuthError(error);
|
|
126
|
+
throw wrapped;
|
|
127
|
+
} finally {
|
|
128
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const __test_only__ = {
|
|
133
|
+
ASPECT_RATIO_HINTS,
|
|
134
|
+
isCodexAuthError,
|
|
135
|
+
};
|