@ohos-ports/slides-grab 1.5.0-beta.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/LICENSE +21 -0
- package/README-ko.md +292 -0
- package/README.md +301 -0
- package/bin/ohos-postinstall.cjs +118 -0
- package/bin/ppt-agent.js +620 -0
- package/convert.cjs +20 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/glib-2.0/include/glibconfig.h +220 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/index.js +1 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libexpat.so +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libffi.so +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libjpeg.so +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libpcre2-8.so.0 +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libvips-cpp.so.42.20.3 +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/lib/libvips.so.42.20.3 +0 -0
- package/native/@img/sharp-libvips-openharmony-arm64/package.json +13 -0
- package/native/@img/sharp-openharmony-arm64/lib/sharp-openharmony-arm64.node +0 -0
- package/native/@img/sharp-openharmony-arm64/package.json +16 -0
- package/package.json +95 -0
- package/runtimes/claude-code/agents/design-critic-agent.md +23 -0
- package/runtimes/codex/agents/slides-grab-design-critic.md +22 -0
- package/scripts/build-viewer.js +444 -0
- package/scripts/design-gate.js +258 -0
- package/scripts/download-video.js +213 -0
- package/scripts/editor-server.js +1167 -0
- package/scripts/figma-export.js +169 -0
- package/scripts/generate-image.js +116 -0
- package/scripts/generate-images.js +164 -0
- package/scripts/html2pdf.js +822 -0
- package/scripts/html2png.js +246 -0
- package/scripts/html2pptx.js +162 -0
- package/scripts/import-template.js +86 -0
- package/scripts/install-runtime.js +216 -0
- package/scripts/render-tldraw.js +44 -0
- package/scripts/validate-slides.js +221 -0
- package/skills/slides-grab/SKILL.md +65 -0
- package/skills/slides-grab/references/presentation-workflow-reference.md +61 -0
- package/skills/slides-grab-card-news/SKILL.md +35 -0
- package/skills/slides-grab-design/SKILL.md +88 -0
- package/skills/slides-grab-design/references/beautiful-slide-defaults.md +88 -0
- package/skills/slides-grab-design/references/design-gate.md +349 -0
- package/skills/slides-grab-design/references/design-rules.md +76 -0
- package/skills/slides-grab-design/references/design-system-full.md +568 -0
- package/skills/slides-grab-design/references/detailed-design-rules.md +70 -0
- package/skills/slides-grab-export/SKILL.md +55 -0
- package/skills/slides-grab-export/references/export-rules.md +27 -0
- package/skills/slides-grab-export/references/html2pptx.md +627 -0
- package/skills/slides-grab-export/references/ooxml.md +427 -0
- package/skills/slides-grab-export/references/pptx-skill-reference.md +189 -0
- package/skills/slides-grab-html/SKILL.md +67 -0
- package/skills/slides-grab-image/SKILL.md +82 -0
- package/skills/slides-grab-plan/SKILL.md +68 -0
- package/skills/slides-grab-plan/references/design-md-to-slides-conversion.md +135 -0
- package/skills/slides-grab-plan/references/outline-format.md +47 -0
- package/skills/slides-grab-plan/references/plan-workflow-reference.md +140 -0
- package/src/codex-imagen.js +182 -0
- package/src/design-diversity-data.js +6932 -0
- package/src/design-gate-report.js +244 -0
- package/src/design-gate-state.js +329 -0
- package/src/design-import.js +164 -0
- package/src/design-md-parser.js +415 -0
- package/src/design-styles-data.js +1928 -0
- package/src/design-styles.js +209 -0
- package/src/editor/codex-edit.js +584 -0
- package/src/editor/edit-subprocess.js +170 -0
- package/src/editor/editor-codex-prompt.md +50 -0
- package/src/editor/editor.html +1785 -0
- package/src/editor/js/editor-bbox.js +332 -0
- package/src/editor/js/editor-chat.js +56 -0
- package/src/editor/js/editor-direct-edit.js +238 -0
- package/src/editor/js/editor-dom.js +59 -0
- package/src/editor/js/editor-init.js +405 -0
- package/src/editor/js/editor-navigation.js +54 -0
- package/src/editor/js/editor-select.js +558 -0
- package/src/editor/js/editor-send.js +175 -0
- package/src/editor/js/editor-sse.js +163 -0
- package/src/editor/js/editor-state.js +41 -0
- package/src/editor/js/editor-type.js +71 -0
- package/src/editor/js/editor-utils.js +167 -0
- package/src/editor/js/model-registry.js +37 -0
- package/src/editor/screenshot.js +82 -0
- package/src/export-resolution.cjs +68 -0
- package/src/figma.js +71 -0
- package/src/html2pptx-scale.cjs +120 -0
- package/src/html2pptx.cjs +1262 -0
- package/src/image-contract.js +329 -0
- package/src/image-native.js +468 -0
- package/src/nano-banana.js +841 -0
- package/src/ohos-browser-bridge.cjs +272 -0
- package/src/pptx-raster-export.cjs +299 -0
- package/src/resolve.js +110 -0
- package/src/slide-mode.cjs +72 -0
- package/src/template-fidelity.js +267 -0
- package/src/template-import.js +505 -0
- package/src/template-layout.js +261 -0
- package/src/template-pack.js +255 -0
- package/src/tldraw/render.js +473 -0
- package/src/validation/cli.js +120 -0
- package/src/validation/core.js +1023 -0
- package/templates/chart.html +121 -0
- package/templates/closing.html +54 -0
- package/templates/content.html +50 -0
- package/templates/contents.html +60 -0
- package/templates/cover.html +64 -0
- package/templates/custom/.gitkeep +0 -0
- package/templates/custom/README.md +7 -0
- package/templates/design-styles/README.md +20 -0
- package/templates/design-styles/preview.html +4438 -0
- package/templates/diagram-tldraw.html +56 -0
- package/templates/diagram.html +98 -0
- package/templates/quote.html +31 -0
- package/templates/section-divider.html +43 -0
- package/templates/split-layout.html +43 -0
- package/templates/statistics.html +55 -0
- package/templates/team.html +49 -0
- package/templates/timeline.html +59 -0
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { getSlidesDir } from './resolve.js';
|
|
5
|
+
import {
|
|
6
|
+
CODEX_DEFAULT_MODEL,
|
|
7
|
+
CODEX_PROVIDER_AUTO,
|
|
8
|
+
generateCodexImage,
|
|
9
|
+
getCodexFallbackMessage,
|
|
10
|
+
} from './codex-imagen.js';
|
|
11
|
+
|
|
12
|
+
export const IMAGE_PROVIDER_CODEX = 'codex';
|
|
13
|
+
export const IMAGE_PROVIDER_OPENAI = 'openai';
|
|
14
|
+
export const IMAGE_PROVIDER_NANO_BANANA = 'nano-banana';
|
|
15
|
+
export const DEFAULT_IMAGE_PROVIDER = IMAGE_PROVIDER_CODEX;
|
|
16
|
+
export const DEFAULT_CODEX_MODEL = CODEX_DEFAULT_MODEL;
|
|
17
|
+
export const DEFAULT_OPENAI_IMAGE_MODEL = 'gpt-image-2';
|
|
18
|
+
export const DEFAULT_OPENAI_IMAGE_SIZE = 'auto';
|
|
19
|
+
export const DEFAULT_OPENAI_IMAGE_BASE_URL = 'https://api.openai.com/v1';
|
|
20
|
+
export const DEFAULT_NANO_BANANA_MODEL = 'gemini-3-pro-image-preview';
|
|
21
|
+
export const DEFAULT_NANO_BANANA_ASPECT_RATIO = '16:9';
|
|
22
|
+
export const DEFAULT_NANO_BANANA_IMAGE_SIZE = '4K';
|
|
23
|
+
const VALID_IMAGE_SIZES = new Set(['2K', '4K']);
|
|
24
|
+
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
25
|
+
|
|
26
|
+
const PROVIDER_ALIASES = new Map([
|
|
27
|
+
['codex', IMAGE_PROVIDER_CODEX],
|
|
28
|
+
['codex-cli', IMAGE_PROVIDER_CODEX],
|
|
29
|
+
['openai', IMAGE_PROVIDER_OPENAI],
|
|
30
|
+
['nano-banana', IMAGE_PROVIDER_NANO_BANANA],
|
|
31
|
+
['gemini', IMAGE_PROVIDER_NANO_BANANA],
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const VALID_PROVIDERS = new Set([
|
|
35
|
+
IMAGE_PROVIDER_CODEX,
|
|
36
|
+
IMAGE_PROVIDER_OPENAI,
|
|
37
|
+
IMAGE_PROVIDER_NANO_BANANA,
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
export function normalizeImageProvider(value) {
|
|
41
|
+
const trimmed = String(value || '').trim().toLowerCase();
|
|
42
|
+
if (!trimmed) return '';
|
|
43
|
+
return PROVIDER_ALIASES.get(trimmed) || trimmed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MIME_TYPE_TO_EXTENSION = new Map([
|
|
47
|
+
['image/png', '.png'],
|
|
48
|
+
['image/jpeg', '.jpg'],
|
|
49
|
+
['image/webp', '.webp'],
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
function readOptionValue(argv, index, option) {
|
|
53
|
+
const value = argv[index + 1];
|
|
54
|
+
if (!value || value.startsWith('--')) {
|
|
55
|
+
throw new Error(`${option} is missing a value.`);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getNanoBananaUsage() {
|
|
61
|
+
return [
|
|
62
|
+
'Usage: slides-grab image --prompt <text> [options]',
|
|
63
|
+
'',
|
|
64
|
+
'Generate a deck-local image asset and save it into <slides-dir>/assets/.',
|
|
65
|
+
'Default provider: codex-imagen (uses your local Codex ChatGPT login — no OpenAI/Google API key required).',
|
|
66
|
+
'',
|
|
67
|
+
'Options:',
|
|
68
|
+
' --prompt <text> Required text prompt for image generation',
|
|
69
|
+
' --slides-dir <path> Slides directory (default: slides)',
|
|
70
|
+
' --output <path> Optional explicit output path inside <slides-dir>/assets/',
|
|
71
|
+
' --name <slug> Optional asset basename without extension',
|
|
72
|
+
` --provider <name> Image provider: codex (default), openai, or nano-banana.`,
|
|
73
|
+
' Aliases: codex-cli → codex, openai → openai, gemini → nano-banana.',
|
|
74
|
+
` --model <id> Model id (default: ${DEFAULT_CODEX_MODEL} for codex, ${DEFAULT_OPENAI_IMAGE_MODEL} for openai, ${DEFAULT_NANO_BANANA_MODEL} for nano-banana)`,
|
|
75
|
+
` --aspect-ratio <ratio> Image aspect ratio; for codex it is injected as a prompt hint, for openai it maps to the nearest supported OpenAI size (default: ${DEFAULT_NANO_BANANA_ASPECT_RATIO})`,
|
|
76
|
+
` --image-size <size> Nano Banana image size preset: 2K or 4K (default: ${DEFAULT_NANO_BANANA_IMAGE_SIZE})`,
|
|
77
|
+
' -h, --help Show this help text',
|
|
78
|
+
' --base-url <url> OpenAI-compatible base URL (default: OPENAI_IMAGE_BASE_URL, OPENAI_BASE_URL, then https://api.openai.com/v1)',
|
|
79
|
+
' --api-key-env <name> Env var to read for OpenAI-compatible provider API key',
|
|
80
|
+
' --reference <path> Reference image path(s) for style-guided generation (codex only, repeatable)',
|
|
81
|
+
'',
|
|
82
|
+
'Auth:',
|
|
83
|
+
' Default (codex): run `codex login` once to populate ~/.codex/auth.json. No OpenAI/Google API key required;',
|
|
84
|
+
' requires a Codex/ChatGPT account entitled to image generation.',
|
|
85
|
+
' OpenAI provider: set OPENAI_API_KEY.',
|
|
86
|
+
' Compatible endpoints may use --base-url, OPENAI_IMAGE_BASE_URL, OPENAI_BASE_URL, and --api-key-env.',
|
|
87
|
+
' Nano Banana provider: set GOOGLE_API_KEY or GEMINI_API_KEY.',
|
|
88
|
+
'',
|
|
89
|
+
'WARNING: codex-imagen calls an unsupported private Codex backend that may break without notice.',
|
|
90
|
+
].join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseNanoBananaCliArgs(argv) {
|
|
94
|
+
const parsed = {
|
|
95
|
+
prompt: '',
|
|
96
|
+
slidesDir: 'slides',
|
|
97
|
+
output: '',
|
|
98
|
+
name: '',
|
|
99
|
+
provider: DEFAULT_IMAGE_PROVIDER,
|
|
100
|
+
model: '',
|
|
101
|
+
aspectRatio: DEFAULT_NANO_BANANA_ASPECT_RATIO,
|
|
102
|
+
imageSize: DEFAULT_NANO_BANANA_IMAGE_SIZE,
|
|
103
|
+
help: false,
|
|
104
|
+
baseUrl: '',
|
|
105
|
+
apiKeyEnv: '',
|
|
106
|
+
referenceImages: [],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const args = Array.isArray(argv) ? [...argv] : [];
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
112
|
+
const arg = args[i];
|
|
113
|
+
|
|
114
|
+
if (arg === '--help' || arg === '-h') {
|
|
115
|
+
parsed.help = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (arg === '--prompt') {
|
|
120
|
+
parsed.prompt = readOptionValue(args, i, '--prompt');
|
|
121
|
+
i += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (arg.startsWith('--prompt=')) {
|
|
125
|
+
parsed.prompt = arg.slice('--prompt='.length);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (arg === '--slides-dir') {
|
|
130
|
+
parsed.slidesDir = readOptionValue(args, i, '--slides-dir');
|
|
131
|
+
i += 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (arg.startsWith('--slides-dir=')) {
|
|
135
|
+
parsed.slidesDir = arg.slice('--slides-dir='.length);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (arg === '--output') {
|
|
140
|
+
parsed.output = readOptionValue(args, i, '--output');
|
|
141
|
+
i += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (arg.startsWith('--output=')) {
|
|
145
|
+
parsed.output = arg.slice('--output='.length);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (arg === '--name') {
|
|
150
|
+
parsed.name = readOptionValue(args, i, '--name');
|
|
151
|
+
i += 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (arg.startsWith('--name=')) {
|
|
155
|
+
parsed.name = arg.slice('--name='.length);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (arg === '--provider') {
|
|
160
|
+
parsed.provider = readOptionValue(args, i, '--provider');
|
|
161
|
+
i += 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (arg.startsWith('--provider=')) {
|
|
165
|
+
parsed.provider = arg.slice('--provider='.length);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (arg === '--model') {
|
|
170
|
+
parsed.model = readOptionValue(args, i, '--model');
|
|
171
|
+
i += 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (arg.startsWith('--model=')) {
|
|
175
|
+
parsed.model = arg.slice('--model='.length);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (arg === '--aspect-ratio') {
|
|
180
|
+
parsed.aspectRatio = readOptionValue(args, i, '--aspect-ratio');
|
|
181
|
+
i += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (arg.startsWith('--aspect-ratio=')) {
|
|
185
|
+
parsed.aspectRatio = arg.slice('--aspect-ratio='.length);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (arg === '--image-size') {
|
|
190
|
+
parsed.imageSize = readOptionValue(args, i, '--image-size').toUpperCase();
|
|
191
|
+
i += 1;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (arg.startsWith('--image-size=')) {
|
|
195
|
+
parsed.imageSize = arg.slice('--image-size='.length).toUpperCase();
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (arg === '--base-url') {
|
|
200
|
+
parsed.baseUrl = readOptionValue(args, i, '--base-url');
|
|
201
|
+
i += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (arg.startsWith('--base-url=')) {
|
|
205
|
+
parsed.baseUrl = arg.slice('--base-url='.length);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (arg === '--api-key-env') {
|
|
210
|
+
parsed.apiKeyEnv = readOptionValue(args, i, '--api-key-env');
|
|
211
|
+
i += 1;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (arg.startsWith('--api-key-env=')) {
|
|
215
|
+
parsed.apiKeyEnv = arg.slice('--api-key-env='.length);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if (arg === '--reference' || arg === '--reference-image') {
|
|
221
|
+
parsed.referenceImages.push(readOptionValue(args, i, arg));
|
|
222
|
+
i += 1;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (arg.startsWith('--reference=')) {
|
|
226
|
+
parsed.referenceImages.push(arg.slice('--reference='.length));
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (arg.startsWith('--reference-image=')) {
|
|
230
|
+
parsed.referenceImages.push(arg.slice('--reference-image='.length));
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (parsed.help) {
|
|
238
|
+
return parsed;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (typeof parsed.prompt !== 'string' || parsed.prompt.trim() === '') {
|
|
242
|
+
throw new Error('--prompt must be a non-empty string.');
|
|
243
|
+
}
|
|
244
|
+
parsed.prompt = parsed.prompt.trim();
|
|
245
|
+
|
|
246
|
+
if (typeof parsed.slidesDir !== 'string' || parsed.slidesDir.trim() === '') {
|
|
247
|
+
throw new Error('--slides-dir must be a non-empty string.');
|
|
248
|
+
}
|
|
249
|
+
parsed.slidesDir = parsed.slidesDir.trim();
|
|
250
|
+
|
|
251
|
+
if (typeof parsed.output !== 'string') {
|
|
252
|
+
throw new Error('--output must be a string.');
|
|
253
|
+
}
|
|
254
|
+
parsed.output = parsed.output.trim();
|
|
255
|
+
|
|
256
|
+
if (typeof parsed.name !== 'string') {
|
|
257
|
+
throw new Error('--name must be a string.');
|
|
258
|
+
}
|
|
259
|
+
parsed.name = parsed.name.trim();
|
|
260
|
+
|
|
261
|
+
if (typeof parsed.provider !== 'string' || parsed.provider.trim() === '') {
|
|
262
|
+
throw new Error('--provider must be a non-empty string.');
|
|
263
|
+
}
|
|
264
|
+
parsed.provider = normalizeImageProvider(parsed.provider);
|
|
265
|
+
if (!VALID_PROVIDERS.has(parsed.provider)) {
|
|
266
|
+
throw new Error(`Unknown --provider value: ${parsed.provider}. Expected codex, openai, or nano-banana.`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (typeof parsed.model !== 'string') {
|
|
270
|
+
throw new Error('--model must be a string.');
|
|
271
|
+
}
|
|
272
|
+
if (!parsed.model.trim()) {
|
|
273
|
+
if (parsed.provider === IMAGE_PROVIDER_CODEX) {
|
|
274
|
+
parsed.model = DEFAULT_CODEX_MODEL;
|
|
275
|
+
} else if (parsed.provider === IMAGE_PROVIDER_NANO_BANANA) {
|
|
276
|
+
parsed.model = DEFAULT_NANO_BANANA_MODEL;
|
|
277
|
+
} else {
|
|
278
|
+
parsed.model = DEFAULT_OPENAI_IMAGE_MODEL;
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
parsed.model = parsed.model.trim();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (typeof parsed.aspectRatio !== 'string' || parsed.aspectRatio.trim() === '') {
|
|
285
|
+
throw new Error('--aspect-ratio must be a non-empty string.');
|
|
286
|
+
}
|
|
287
|
+
parsed.aspectRatio = parsed.aspectRatio.trim();
|
|
288
|
+
|
|
289
|
+
if (!VALID_IMAGE_SIZES.has(parsed.imageSize)) {
|
|
290
|
+
throw new Error(`Unknown --image-size value: ${parsed.imageSize}. Expected 2K or 4K.`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (typeof parsed.baseUrl !== 'string') {
|
|
294
|
+
throw new Error('--base-url must be a string.');
|
|
295
|
+
}
|
|
296
|
+
parsed.baseUrl = parsed.baseUrl.trim();
|
|
297
|
+
|
|
298
|
+
if (typeof parsed.apiKeyEnv !== 'string') {
|
|
299
|
+
throw new Error('--api-key-env must be a string.');
|
|
300
|
+
}
|
|
301
|
+
parsed.apiKeyEnv = parsed.apiKeyEnv.trim();
|
|
302
|
+
if (parsed.apiKeyEnv && !ENV_NAME_PATTERN.test(parsed.apiKeyEnv)) {
|
|
303
|
+
throw new Error('--api-key-env must be a valid environment variable name.');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return parsed;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function resolveOpenaiApiKey(env = process.env, { apiKeyEnv = '' } = {}) {
|
|
310
|
+
const explicitEnvName = typeof apiKeyEnv === 'string' ? apiKeyEnv.trim() : '';
|
|
311
|
+
const candidates = explicitEnvName
|
|
312
|
+
? [explicitEnvName, 'OPENAI_IMAGE_API_KEY', 'OPENAI_API_KEY']
|
|
313
|
+
: ['OPENAI_IMAGE_API_KEY', 'OPENAI_API_KEY'];
|
|
314
|
+
|
|
315
|
+
for (const name of candidates) {
|
|
316
|
+
const value = typeof env?.[name] === 'string' ? env[name].trim() : '';
|
|
317
|
+
if (value) {
|
|
318
|
+
return { apiKey: value, source: name };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { apiKey: '', source: '' };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function resolveOpenaiBaseUrl({ baseUrl = '', env = process.env } = {}) {
|
|
326
|
+
const explicitBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : '';
|
|
327
|
+
if (explicitBaseUrl) {
|
|
328
|
+
return { baseUrl: explicitBaseUrl, source: '--base-url' };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const imageBaseUrl = typeof env?.OPENAI_IMAGE_BASE_URL === 'string' ? env.OPENAI_IMAGE_BASE_URL.trim() : '';
|
|
332
|
+
if (imageBaseUrl) {
|
|
333
|
+
return { baseUrl: imageBaseUrl, source: 'OPENAI_IMAGE_BASE_URL' };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const openAiBaseUrl = typeof env?.OPENAI_BASE_URL === 'string' ? env.OPENAI_BASE_URL.trim() : '';
|
|
337
|
+
if (openAiBaseUrl) {
|
|
338
|
+
return { baseUrl: openAiBaseUrl, source: 'OPENAI_BASE_URL' };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { baseUrl: DEFAULT_OPENAI_IMAGE_BASE_URL, source: 'default' };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function resolveNanoBananaApiKey(env = process.env) {
|
|
345
|
+
const googleApiKey = typeof env?.GOOGLE_API_KEY === 'string' ? env.GOOGLE_API_KEY.trim() : '';
|
|
346
|
+
if (googleApiKey) {
|
|
347
|
+
return { apiKey: googleApiKey, source: 'GOOGLE_API_KEY' };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const geminiApiKey = typeof env?.GEMINI_API_KEY === 'string' ? env.GEMINI_API_KEY.trim() : '';
|
|
351
|
+
if (geminiApiKey) {
|
|
352
|
+
return { apiKey: geminiApiKey, source: 'GEMINI_API_KEY' };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return { apiKey: '', source: '' };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function getNanoBananaFallbackMessage(reason) {
|
|
359
|
+
const summary = typeof reason === 'string' && reason.trim() ? reason.trim() : 'Nano Banana image generation failed.';
|
|
360
|
+
return `${summary} Nano Banana is the fallback provider: set GOOGLE_API_KEY (or GEMINI_API_KEY), or fall back to web search + download the chosen image into ./assets/<file>.`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function getOpenaiFallbackMessage(reason) {
|
|
364
|
+
const summary = typeof reason === 'string' && reason.trim() ? reason.trim() : 'OpenAI image generation failed.';
|
|
365
|
+
return `${summary} The OpenAI-compatible provider requires OPENAI_API_KEY (or OPENAI_IMAGE_API_KEY / --api-key-env). Configure compatible endpoints with --base-url, OPENAI_IMAGE_BASE_URL, or OPENAI_BASE_URL. Nano Banana remains available as a fallback with GOOGLE_API_KEY or GEMINI_API_KEY. If image generation credentials are unavailable, use web search and download the chosen image into ./assets/<file>.`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function parseAspectRatioOrientation(aspectRatio) {
|
|
369
|
+
const match = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.exec(String(aspectRatio || '').trim());
|
|
370
|
+
if (!match) {
|
|
371
|
+
return 'landscape';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const width = Number(match[1]);
|
|
375
|
+
const height = Number(match[2]);
|
|
376
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
377
|
+
return 'landscape';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (Math.abs(width - height) < Number.EPSILON) {
|
|
381
|
+
return 'square';
|
|
382
|
+
}
|
|
383
|
+
return width > height ? 'landscape' : 'portrait';
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function resolveOpenaiImageSize({
|
|
387
|
+
aspectRatio = DEFAULT_NANO_BANANA_ASPECT_RATIO,
|
|
388
|
+
size = DEFAULT_OPENAI_IMAGE_SIZE,
|
|
389
|
+
} = {}) {
|
|
390
|
+
if (size && size !== 'auto') {
|
|
391
|
+
return size;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const orientation = parseAspectRatioOrientation(aspectRatio);
|
|
395
|
+
if (orientation === 'square') {
|
|
396
|
+
return '1024x1024';
|
|
397
|
+
}
|
|
398
|
+
if (orientation === 'portrait') {
|
|
399
|
+
return '1024x1536';
|
|
400
|
+
}
|
|
401
|
+
return '1536x1024';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function buildOpenaiImageApiRequest({
|
|
405
|
+
prompt,
|
|
406
|
+
model = DEFAULT_OPENAI_IMAGE_MODEL,
|
|
407
|
+
aspectRatio = DEFAULT_NANO_BANANA_ASPECT_RATIO,
|
|
408
|
+
size = DEFAULT_OPENAI_IMAGE_SIZE,
|
|
409
|
+
}) {
|
|
410
|
+
return {
|
|
411
|
+
model,
|
|
412
|
+
prompt,
|
|
413
|
+
size: resolveOpenaiImageSize({ aspectRatio, size }),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function buildNanoBananaApiRequest({ prompt, aspectRatio, imageSize }) {
|
|
418
|
+
return {
|
|
419
|
+
contents: [
|
|
420
|
+
{
|
|
421
|
+
parts: [{ text: prompt }],
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
generationConfig: {
|
|
425
|
+
imageConfig: {
|
|
426
|
+
aspectRatio,
|
|
427
|
+
imageSize,
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function sanitizeAssetName(value) {
|
|
434
|
+
return value
|
|
435
|
+
.toLowerCase()
|
|
436
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
437
|
+
.replace(/^-+|-+$/g, '')
|
|
438
|
+
.replace(/-{2,}/g, '-')
|
|
439
|
+
.slice(0, 80);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function pickAssetBaseName({ prompt, name, provider = IMAGE_PROVIDER_NANO_BANANA }) {
|
|
443
|
+
const preferred = sanitizeAssetName(name || '');
|
|
444
|
+
if (preferred) return preferred;
|
|
445
|
+
|
|
446
|
+
const fromPrompt = sanitizeAssetName(prompt);
|
|
447
|
+
let prefix;
|
|
448
|
+
if (provider === IMAGE_PROVIDER_CODEX) {
|
|
449
|
+
prefix = 'codex';
|
|
450
|
+
} else if (provider === IMAGE_PROVIDER_OPENAI) {
|
|
451
|
+
prefix = 'openai';
|
|
452
|
+
} else {
|
|
453
|
+
prefix = 'nano-banana';
|
|
454
|
+
}
|
|
455
|
+
return fromPrompt ? `${prefix}-${fromPrompt}` : `${prefix}-generated-image`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function getExtensionFromMimeType(mimeType) {
|
|
459
|
+
return MIME_TYPE_TO_EXTENSION.get((mimeType || '').toLowerCase()) || '.png';
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function ensureInsideDirectory(filePath, directoryPath) {
|
|
463
|
+
const relativePath = relative(directoryPath, filePath);
|
|
464
|
+
return relativePath !== '' && !relativePath.startsWith('..') && !isAbsolute(relativePath);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function resolveRequestedOutputPath(output, assetsDir) {
|
|
468
|
+
const trimmed = output.trim();
|
|
469
|
+
if (isAbsolute(trimmed)) {
|
|
470
|
+
return resolve(trimmed);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const isBareFileName = !/[\\/]/.test(trimmed);
|
|
474
|
+
if (isBareFileName) {
|
|
475
|
+
return resolve(assetsDir, trimmed);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (/^(?:\.\/)?assets[\\/]/.test(trimmed)) {
|
|
479
|
+
const normalized = trimmed
|
|
480
|
+
.replace(/^[.][\\/]/, '')
|
|
481
|
+
.replace(/^assets[\\/]/, '');
|
|
482
|
+
return resolve(assetsDir, normalized);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const cwdRelativePath = resolve(trimmed);
|
|
486
|
+
if (ensureInsideDirectory(cwdRelativePath, assetsDir)) {
|
|
487
|
+
return cwdRelativePath;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return resolve(assetsDir, trimmed);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function resolveNanoBananaOutputPath({
|
|
494
|
+
slidesDir,
|
|
495
|
+
prompt,
|
|
496
|
+
output = '',
|
|
497
|
+
name = '',
|
|
498
|
+
mimeType = 'image/png',
|
|
499
|
+
provider = IMAGE_PROVIDER_NANO_BANANA,
|
|
500
|
+
}) {
|
|
501
|
+
const absoluteSlidesDir = resolve(slidesDir);
|
|
502
|
+
const assetsDir = join(absoluteSlidesDir, 'assets');
|
|
503
|
+
const extension = getExtensionFromMimeType(mimeType);
|
|
504
|
+
|
|
505
|
+
let outputPath;
|
|
506
|
+
if (output) {
|
|
507
|
+
const requestedPath = resolveRequestedOutputPath(output, assetsDir);
|
|
508
|
+
if (!ensureInsideDirectory(requestedPath, assetsDir)) {
|
|
509
|
+
throw new Error(`Generated images must be saved inside ${assetsDir}.`);
|
|
510
|
+
}
|
|
511
|
+
outputPath = extname(requestedPath) ? requestedPath : `${requestedPath}${extension}`;
|
|
512
|
+
} else {
|
|
513
|
+
outputPath = join(assetsDir, `${pickAssetBaseName({ prompt, name, provider })}${extension}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (!ensureInsideDirectory(outputPath, assetsDir)) {
|
|
517
|
+
throw new Error(`Generated images must be saved inside ${assetsDir}.`);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
assetsDir,
|
|
522
|
+
outputPath,
|
|
523
|
+
relativeRef: `./assets/${relative(assetsDir, outputPath).replace(/\\/g, '/')}`,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function extractOpenaiGeneratedImage(payload) {
|
|
528
|
+
const images = Array.isArray(payload?.data) ? payload.data : [];
|
|
529
|
+
for (const image of images) {
|
|
530
|
+
if (typeof image?.b64_json === 'string' && image.b64_json.trim()) {
|
|
531
|
+
return {
|
|
532
|
+
mimeType: 'image/png',
|
|
533
|
+
bytes: Buffer.from(image.b64_json, 'base64'),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
throw new Error('OpenAI image generation response did not include an image payload.');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function extractGeneratedImage(payload) {
|
|
542
|
+
const candidates = Array.isArray(payload?.candidates) ? payload.candidates : [];
|
|
543
|
+
|
|
544
|
+
for (const candidate of candidates) {
|
|
545
|
+
const parts = Array.isArray(candidate?.content?.parts) ? candidate.content.parts : [];
|
|
546
|
+
for (const part of parts) {
|
|
547
|
+
const inlineData = part?.inlineData || part?.inline_data;
|
|
548
|
+
if (!inlineData?.data) continue;
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
mimeType: inlineData.mimeType || inlineData.mime_type || 'image/png',
|
|
552
|
+
bytes: Buffer.from(inlineData.data, 'base64'),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
throw new Error('Nano Banana API response did not include an image payload.');
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const OPENAI_IMAGE_ENDPOINT_PATH = '/images/generations';
|
|
561
|
+
|
|
562
|
+
export function buildOpenaiImageEndpoint(baseUrl = DEFAULT_OPENAI_IMAGE_BASE_URL) {
|
|
563
|
+
const value = String(baseUrl || DEFAULT_OPENAI_IMAGE_BASE_URL).trim() || DEFAULT_OPENAI_IMAGE_BASE_URL;
|
|
564
|
+
const url = new URL(value);
|
|
565
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
566
|
+
if (/\/images\/generations$/i.test(path)) {
|
|
567
|
+
url.pathname = path;
|
|
568
|
+
} else if (/\/v1$/i.test(path)) {
|
|
569
|
+
url.pathname = `${path}${OPENAI_IMAGE_ENDPOINT_PATH}`;
|
|
570
|
+
} else {
|
|
571
|
+
url.pathname = `${path}/v1${OPENAI_IMAGE_ENDPOINT_PATH}`.replace(/\/+/g, '/');
|
|
572
|
+
}
|
|
573
|
+
url.search = '';
|
|
574
|
+
url.hash = '';
|
|
575
|
+
return url.toString();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function buildNanoBananaEndpoint(model) {
|
|
579
|
+
return `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function getApiErrorMessage(payload, status) {
|
|
583
|
+
const message = payload?.error?.message || payload?.message;
|
|
584
|
+
if (typeof message === 'string' && message.trim()) {
|
|
585
|
+
return message.trim();
|
|
586
|
+
}
|
|
587
|
+
return `HTTP ${status}`;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export async function generateOpenaiImage({
|
|
591
|
+
prompt,
|
|
592
|
+
apiKey,
|
|
593
|
+
model = DEFAULT_OPENAI_IMAGE_MODEL,
|
|
594
|
+
aspectRatio = DEFAULT_NANO_BANANA_ASPECT_RATIO,
|
|
595
|
+
size = DEFAULT_OPENAI_IMAGE_SIZE,
|
|
596
|
+
fetchImpl = globalThis.fetch,
|
|
597
|
+
baseUrl = DEFAULT_OPENAI_IMAGE_BASE_URL,
|
|
598
|
+
}) {
|
|
599
|
+
if (typeof fetchImpl !== 'function') {
|
|
600
|
+
throw new Error('Global fetch is unavailable in this runtime.');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const response = await fetchImpl(buildOpenaiImageEndpoint(baseUrl), {
|
|
604
|
+
method: 'POST',
|
|
605
|
+
headers: {
|
|
606
|
+
'Content-Type': 'application/json',
|
|
607
|
+
Authorization: `Bearer ${apiKey}`,
|
|
608
|
+
},
|
|
609
|
+
body: JSON.stringify(buildOpenaiImageApiRequest({ prompt, model, aspectRatio, size })),
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const payload = await response.json();
|
|
613
|
+
if (!response.ok) {
|
|
614
|
+
throw new Error(`OpenAI image generation request failed: ${getApiErrorMessage(payload, response.status)}.`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return extractOpenaiGeneratedImage(payload);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export async function generateNanoBananaImage({
|
|
621
|
+
prompt,
|
|
622
|
+
apiKey,
|
|
623
|
+
model = DEFAULT_NANO_BANANA_MODEL,
|
|
624
|
+
aspectRatio = DEFAULT_NANO_BANANA_ASPECT_RATIO,
|
|
625
|
+
imageSize = DEFAULT_NANO_BANANA_IMAGE_SIZE,
|
|
626
|
+
fetchImpl = globalThis.fetch,
|
|
627
|
+
}) {
|
|
628
|
+
if (typeof fetchImpl !== 'function') {
|
|
629
|
+
throw new Error('Global fetch is unavailable in this runtime.');
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
const response = await fetchImpl(buildNanoBananaEndpoint(model), {
|
|
634
|
+
method: 'POST',
|
|
635
|
+
headers: {
|
|
636
|
+
'Content-Type': 'application/json',
|
|
637
|
+
'x-goog-api-key': apiKey,
|
|
638
|
+
},
|
|
639
|
+
body: JSON.stringify(buildNanoBananaApiRequest({ prompt, aspectRatio, imageSize })),
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const payload = await response.json();
|
|
643
|
+
if (!response.ok) {
|
|
644
|
+
throw new Error(`Nano Banana API request failed: ${getApiErrorMessage(payload, response.status)}.`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return extractGeneratedImage(payload);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
throw new Error(getNanoBananaFallbackMessage(error.message));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function argvIncludesOption(argv, optionName) {
|
|
654
|
+
const args = Array.isArray(argv) ? argv : [];
|
|
655
|
+
return args.some((arg) => arg === optionName || String(arg).startsWith(`${optionName}=`));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function generateNanoBananaFallbackImage({ options, apiKey, fetchImpl }) {
|
|
659
|
+
return generateNanoBananaImage({
|
|
660
|
+
prompt: options.prompt,
|
|
661
|
+
apiKey,
|
|
662
|
+
model: DEFAULT_NANO_BANANA_MODEL,
|
|
663
|
+
aspectRatio: options.aspectRatio,
|
|
664
|
+
imageSize: options.imageSize,
|
|
665
|
+
fetchImpl,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function resolveCodexModel(options) {
|
|
670
|
+
return options.model && options.model.trim() ? options.model : DEFAULT_CODEX_MODEL;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function generateCodexFallbackImage({ options, generateCodexImageImpl }) {
|
|
674
|
+
return generateCodexImageImpl({
|
|
675
|
+
prompt: options.prompt,
|
|
676
|
+
model: resolveCodexModel(options),
|
|
677
|
+
aspectRatio: options.aspectRatio,
|
|
678
|
+
providerMode: CODEX_PROVIDER_AUTO,
|
|
679
|
+
referenceImages: options.referenceImages || [],
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function resolveOpenaiModel(options) {
|
|
684
|
+
return options.model && options.model.trim() && options.model !== DEFAULT_CODEX_MODEL
|
|
685
|
+
? options.model
|
|
686
|
+
: DEFAULT_OPENAI_IMAGE_MODEL;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function generateOpenaiFallbackImage({ options, apiKey, fetchImpl, requestedNanoBananaImageSize, baseUrl }) {
|
|
690
|
+
if (requestedNanoBananaImageSize) {
|
|
691
|
+
throw new Error(
|
|
692
|
+
'--image-size is only supported by the Nano Banana provider; OpenAI maps --aspect-ratio to the nearest supported OpenAI image size. Use --provider nano-banana for 2K or 4K presets.',
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
return generateOpenaiImage({
|
|
696
|
+
prompt: options.prompt,
|
|
697
|
+
apiKey,
|
|
698
|
+
model: resolveOpenaiModel(options),
|
|
699
|
+
aspectRatio: options.aspectRatio,
|
|
700
|
+
fetchImpl,
|
|
701
|
+
baseUrl,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export async function saveNanoBananaImage({
|
|
706
|
+
prompt,
|
|
707
|
+
slidesDir,
|
|
708
|
+
output = '',
|
|
709
|
+
name = '',
|
|
710
|
+
mimeType,
|
|
711
|
+
bytes,
|
|
712
|
+
provider = IMAGE_PROVIDER_NANO_BANANA,
|
|
713
|
+
}) {
|
|
714
|
+
const target = resolveNanoBananaOutputPath({ slidesDir, prompt, output, name, mimeType, provider });
|
|
715
|
+
await mkdir(target.assetsDir, { recursive: true });
|
|
716
|
+
await writeFile(target.outputPath, bytes);
|
|
717
|
+
return target;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export async function runNanoBananaCli(argv = process.argv.slice(2), {
|
|
721
|
+
env = process.env,
|
|
722
|
+
fetchImpl = globalThis.fetch,
|
|
723
|
+
stdout = process.stdout,
|
|
724
|
+
generateCodexImageImpl = generateCodexImage,
|
|
725
|
+
} = {}) {
|
|
726
|
+
const options = parseNanoBananaCliArgs(argv);
|
|
727
|
+
if (options.help) {
|
|
728
|
+
stdout.write(`${getNanoBananaUsage()}\n`);
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let generated;
|
|
733
|
+
let providerUsed = options.provider;
|
|
734
|
+
let modelUsed = options.model;
|
|
735
|
+
const requestedNanoBananaImageSize = argvIncludesOption(argv, '--image-size');
|
|
736
|
+
const fallbackNotices = [];
|
|
737
|
+
|
|
738
|
+
if (options.provider === IMAGE_PROVIDER_CODEX) {
|
|
739
|
+
try {
|
|
740
|
+
generated = await generateCodexFallbackImage({ options, generateCodexImageImpl });
|
|
741
|
+
modelUsed = resolveCodexModel(options);
|
|
742
|
+
} catch (codexImagenError) {
|
|
743
|
+
const openaiResolution = resolveOpenaiApiKey(env, { apiKeyEnv: options.apiKeyEnv });
|
|
744
|
+
if (openaiResolution.apiKey) {
|
|
745
|
+
fallbackNotices.push(`codex failed (${codexImagenError.message?.split('.')[0] || 'error'}); falling back to OpenAI.`);
|
|
746
|
+
try {
|
|
747
|
+
generated = await generateOpenaiFallbackImage({
|
|
748
|
+
options,
|
|
749
|
+
apiKey: openaiResolution.apiKey,
|
|
750
|
+
fetchImpl,
|
|
751
|
+
requestedNanoBananaImageSize,
|
|
752
|
+
baseUrl: resolveOpenaiBaseUrl({ baseUrl: options.baseUrl, env }).baseUrl,
|
|
753
|
+
});
|
|
754
|
+
providerUsed = IMAGE_PROVIDER_OPENAI;
|
|
755
|
+
modelUsed = resolveOpenaiModel(options);
|
|
756
|
+
} catch (openaiError) {
|
|
757
|
+
const nanoResolution = resolveNanoBananaApiKey(env);
|
|
758
|
+
if (!nanoResolution.apiKey) {
|
|
759
|
+
throw new Error(getCodexFallbackMessage(openaiError.message));
|
|
760
|
+
}
|
|
761
|
+
fallbackNotices.push(`OpenAI fallback failed; falling back to Nano Banana.`);
|
|
762
|
+
generated = await generateNanoBananaFallbackImage({ options, apiKey: nanoResolution.apiKey, fetchImpl });
|
|
763
|
+
providerUsed = IMAGE_PROVIDER_NANO_BANANA;
|
|
764
|
+
modelUsed = DEFAULT_NANO_BANANA_MODEL;
|
|
765
|
+
}
|
|
766
|
+
} else {
|
|
767
|
+
const nanoResolution = resolveNanoBananaApiKey(env);
|
|
768
|
+
if (!nanoResolution.apiKey) {
|
|
769
|
+
throw new Error(getCodexFallbackMessage(codexImagenError.message));
|
|
770
|
+
}
|
|
771
|
+
fallbackNotices.push(`codex failed (${codexImagenError.message?.split('.')[0] || 'error'}); falling back to Nano Banana.`);
|
|
772
|
+
generated = await generateNanoBananaFallbackImage({ options, apiKey: nanoResolution.apiKey, fetchImpl });
|
|
773
|
+
providerUsed = IMAGE_PROVIDER_NANO_BANANA;
|
|
774
|
+
modelUsed = DEFAULT_NANO_BANANA_MODEL;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
} else if (options.provider === IMAGE_PROVIDER_OPENAI) {
|
|
778
|
+
const { apiKey: openaiApiKey } = resolveOpenaiApiKey(env, { apiKeyEnv: options.apiKeyEnv });
|
|
779
|
+
if (openaiApiKey) {
|
|
780
|
+
try {
|
|
781
|
+
generated = await generateOpenaiFallbackImage({
|
|
782
|
+
options,
|
|
783
|
+
apiKey: openaiApiKey,
|
|
784
|
+
fetchImpl,
|
|
785
|
+
requestedNanoBananaImageSize,
|
|
786
|
+
baseUrl: resolveOpenaiBaseUrl({ baseUrl: options.baseUrl, env }).baseUrl,
|
|
787
|
+
});
|
|
788
|
+
modelUsed = resolveOpenaiModel(options);
|
|
789
|
+
} catch (error) {
|
|
790
|
+
const { apiKey: fallbackApiKey } = resolveNanoBananaApiKey(env);
|
|
791
|
+
if (!fallbackApiKey) {
|
|
792
|
+
throw new Error(getOpenaiFallbackMessage(error.message));
|
|
793
|
+
}
|
|
794
|
+
providerUsed = IMAGE_PROVIDER_NANO_BANANA;
|
|
795
|
+
generated = await generateNanoBananaFallbackImage({ options, apiKey: fallbackApiKey, fetchImpl });
|
|
796
|
+
modelUsed = DEFAULT_NANO_BANANA_MODEL;
|
|
797
|
+
}
|
|
798
|
+
} else {
|
|
799
|
+
const { apiKey: fallbackApiKey } = resolveNanoBananaApiKey(env);
|
|
800
|
+
if (!fallbackApiKey) {
|
|
801
|
+
throw new Error(getOpenaiFallbackMessage(`OpenAI image generation requires ${options.apiKeyEnv || 'OPENAI_API_KEY'}.`));
|
|
802
|
+
}
|
|
803
|
+
providerUsed = IMAGE_PROVIDER_NANO_BANANA;
|
|
804
|
+
generated = await generateNanoBananaFallbackImage({ options, apiKey: fallbackApiKey, fetchImpl });
|
|
805
|
+
modelUsed = DEFAULT_NANO_BANANA_MODEL;
|
|
806
|
+
}
|
|
807
|
+
} else {
|
|
808
|
+
const { apiKey } = resolveNanoBananaApiKey(env);
|
|
809
|
+
if (!apiKey) {
|
|
810
|
+
throw new Error(getNanoBananaFallbackMessage('Nano Banana image generation requires GOOGLE_API_KEY or GEMINI_API_KEY.'));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
generated = await generateNanoBananaImage({
|
|
814
|
+
prompt: options.prompt,
|
|
815
|
+
apiKey,
|
|
816
|
+
model: options.model,
|
|
817
|
+
aspectRatio: options.aspectRatio,
|
|
818
|
+
imageSize: options.imageSize,
|
|
819
|
+
fetchImpl,
|
|
820
|
+
});
|
|
821
|
+
modelUsed = options.model;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const target = await saveNanoBananaImage({
|
|
825
|
+
prompt: options.prompt,
|
|
826
|
+
slidesDir: getSlidesDir(options.slidesDir),
|
|
827
|
+
output: options.output,
|
|
828
|
+
name: options.name,
|
|
829
|
+
mimeType: generated.mimeType,
|
|
830
|
+
bytes: generated.bytes,
|
|
831
|
+
provider: providerUsed,
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
for (const notice of fallbackNotices) {
|
|
835
|
+
stdout.write(`Fallback: ${notice}\n`);
|
|
836
|
+
}
|
|
837
|
+
stdout.write(`Saved generated image to ${target.outputPath}\n`);
|
|
838
|
+
stdout.write(`Image provider: ${providerUsed}\n`);
|
|
839
|
+
stdout.write(`Reference it from slide HTML as ${target.relativeRef}\n`);
|
|
840
|
+
return { ...target, provider: providerUsed, model: modelUsed };
|
|
841
|
+
}
|