playable-lessons 0.2.2 → 0.6.2
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 +198 -6
- package/out/cli/index.js +415 -37
- package/out/shared/aiTaskExport.js +161 -0
- package/out/shared/caseStudyExport.js +115 -0
- package/out/shared/flashcardExport.js +161 -0
- package/out/shared/generate.js +490 -11
- package/out/shared/h5pExporter.js +286 -0
- package/out/shared/imageClient.js +227 -0
- package/out/shared/inkParser.js +187 -0
- package/out/shared/planExport.js +110 -0
- package/out/shared/project.js +12 -0
- package/out/shared/prompts.js +433 -1
- package/out/shared/quizExport.js +182 -0
- package/out/shared/storyExport.js +53 -4
- package/out/shared/summaryExport.js +144 -0
- package/out/shared/version.generated.js +7 -0
- package/package.json +7 -4
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* H5P export: convert a pure-branching Ink story into an H5P Branching
|
|
4
|
+
* Scenario package (.h5p).
|
|
5
|
+
*
|
|
6
|
+
* H5P's Branching Scenario is a decision tree — it has no variables and no
|
|
7
|
+
* conditional logic, so only stories with no state can be converted
|
|
8
|
+
* faithfully. `findStateConstructs` detects state so callers can refuse (with
|
|
9
|
+
* a useful message) rather than silently flatten; `exportH5P` throws on
|
|
10
|
+
* stateful sources for the same reason.
|
|
11
|
+
*
|
|
12
|
+
* The package is "content-only": it declares its library dependencies in
|
|
13
|
+
* h5p.json but does not bundle the libraries themselves. It imports into any
|
|
14
|
+
* host that already has Branching Scenario installed (an LMS with H5P, Lumi
|
|
15
|
+
* via the H5P Hub). Targets H5P.BranchingScenario 1.x semantics.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.findStateConstructs = findStateConstructs;
|
|
19
|
+
exports.describeStateConstructs = describeStateConstructs;
|
|
20
|
+
exports.toBranchingScenarioContent = toBranchingScenarioContent;
|
|
21
|
+
exports.exportH5P = exportH5P;
|
|
22
|
+
exports.bytesToBase64 = bytesToBase64;
|
|
23
|
+
const inkParser_js_1 = require("./inkParser.js");
|
|
24
|
+
const storyExport_js_1 = require("./storyExport.js");
|
|
25
|
+
/**
|
|
26
|
+
* Scan Ink source for constructs Branching Scenario cannot represent:
|
|
27
|
+
* VAR declarations, ~ assignments/expressions, and any { ... } logic
|
|
28
|
+
* (conditional blocks, inline conditionals, alternatives, choice conditions).
|
|
29
|
+
* Tag lines (# ...) are ignored.
|
|
30
|
+
*/
|
|
31
|
+
function findStateConstructs(source) {
|
|
32
|
+
const issues = [];
|
|
33
|
+
const lines = source.split('\n');
|
|
34
|
+
for (let i = 0; i < lines.length; i++) {
|
|
35
|
+
const trimmed = lines[i].trim();
|
|
36
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//'))
|
|
37
|
+
continue;
|
|
38
|
+
const push = (kind) => issues.push({ line: i + 1, kind, snippet: trimmed.slice(0, 80) });
|
|
39
|
+
if (/^VAR\s+\w+\s*=/.test(trimmed)) {
|
|
40
|
+
push('variable declaration');
|
|
41
|
+
}
|
|
42
|
+
else if (trimmed.startsWith('~')) {
|
|
43
|
+
push('variable assignment');
|
|
44
|
+
}
|
|
45
|
+
else if (/[{}]/.test(trimmed)) {
|
|
46
|
+
push('conditional logic');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return issues;
|
|
50
|
+
}
|
|
51
|
+
/** One-line human summary of why a story can't convert, for error messages. */
|
|
52
|
+
function describeStateConstructs(issues) {
|
|
53
|
+
const shown = issues
|
|
54
|
+
.slice(0, 5)
|
|
55
|
+
.map((c) => ` line ${c.line} (${c.kind}): ${c.snippet}`)
|
|
56
|
+
.join('\n');
|
|
57
|
+
const more = issues.length > 5 ? `\n …and ${issues.length - 5} more` : '';
|
|
58
|
+
return (`This story uses state that H5P Branching Scenario cannot represent:\n${shown}${more}\n` +
|
|
59
|
+
`Regenerate the story with "H5P / LMS compatible" enabled to get a convertible version.`);
|
|
60
|
+
}
|
|
61
|
+
// ─── Ink graph → Branching Scenario content ──────────────────────────────────
|
|
62
|
+
/** Divert targets that mean "the story ends here". */
|
|
63
|
+
const END_TARGETS = new Set(['END', 'DONE']);
|
|
64
|
+
/**
|
|
65
|
+
* The passage prose shown for a knot: lines up to the first choice, minus
|
|
66
|
+
* structural lines (tags, diverts, stitch markers). Returned as escaped HTML
|
|
67
|
+
* paragraphs.
|
|
68
|
+
*/
|
|
69
|
+
function passageHtml(knot) {
|
|
70
|
+
const paragraphs = [];
|
|
71
|
+
for (const raw of knot.rawContent.split('\n')) {
|
|
72
|
+
const line = raw.trim();
|
|
73
|
+
if (!line)
|
|
74
|
+
continue;
|
|
75
|
+
if (line.startsWith('*') || line.startsWith('+'))
|
|
76
|
+
break; // choices begin
|
|
77
|
+
if (line.startsWith('#') || line.startsWith('->') || line.startsWith('='))
|
|
78
|
+
continue;
|
|
79
|
+
paragraphs.push(`<p>${(0, storyExport_js_1.escapeHtml)(line)}</p>`);
|
|
80
|
+
}
|
|
81
|
+
return paragraphs.join('\n') || '<p></p>';
|
|
82
|
+
}
|
|
83
|
+
/** The knot's plain divert target (first `-> target` outside a choice), if any. */
|
|
84
|
+
function plainDivertTarget(knot) {
|
|
85
|
+
for (const raw of knot.rawContent.split('\n')) {
|
|
86
|
+
const line = raw.trim();
|
|
87
|
+
if (line.startsWith('*') || line.startsWith('+'))
|
|
88
|
+
break; // choice diverts don't count
|
|
89
|
+
const m = line.match(/^->\s*(\w+)/);
|
|
90
|
+
if (m)
|
|
91
|
+
return m[1];
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/** Deterministic uuid-shaped subContentId (H5P requires the uuid format). */
|
|
96
|
+
function subContentId(n) {
|
|
97
|
+
const hex = n.toString(16).padStart(12, '0');
|
|
98
|
+
return `00000000-0000-4000-8000-${hex}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build the Branching Scenario content.json object from Ink source.
|
|
102
|
+
* Throws if the story uses state (variables/conditionals).
|
|
103
|
+
*
|
|
104
|
+
* Mapping: each knot becomes one screen. A knot with choices becomes a
|
|
105
|
+
* Branching Question (passage prose as the question, choices as alternatives);
|
|
106
|
+
* a knot without choices becomes a text screen whose "proceed" follows the
|
|
107
|
+
* knot's divert. Diverts to END/DONE (or nowhere) go to the end screen (-1).
|
|
108
|
+
*/
|
|
109
|
+
function toBranchingScenarioContent(source, title) {
|
|
110
|
+
const issues = findStateConstructs(source);
|
|
111
|
+
if (issues.length > 0) {
|
|
112
|
+
throw new Error(describeStateConstructs(issues));
|
|
113
|
+
}
|
|
114
|
+
const parsed = (0, inkParser_js_1.parseInkSource)(source);
|
|
115
|
+
if (parsed.knots.length === 0) {
|
|
116
|
+
throw new Error('No knots found in the Ink source — nothing to export.');
|
|
117
|
+
}
|
|
118
|
+
// Order knots start-first: content[0] is where Branching Scenario begins.
|
|
119
|
+
const startId = parsed.topLevelDivert && parsed.knots.some((k) => k.id === parsed.topLevelDivert)
|
|
120
|
+
? parsed.topLevelDivert
|
|
121
|
+
: parsed.knots[0].id;
|
|
122
|
+
const knots = [
|
|
123
|
+
...parsed.knots.filter((k) => k.id === startId),
|
|
124
|
+
...parsed.knots.filter((k) => k.id !== startId)
|
|
125
|
+
];
|
|
126
|
+
const idOf = new Map();
|
|
127
|
+
knots.forEach((k, i) => idOf.set(k.id, i));
|
|
128
|
+
const resolve = (target) => {
|
|
129
|
+
if (!target || END_TARGETS.has(target))
|
|
130
|
+
return -1;
|
|
131
|
+
return idOf.get(target) ?? -1;
|
|
132
|
+
};
|
|
133
|
+
const content = knots.map((knot, i) => {
|
|
134
|
+
const common = {
|
|
135
|
+
contentBehaviour: 'useBehavioural',
|
|
136
|
+
forceContentFinished: 'useBehavioural',
|
|
137
|
+
showContentTitle: false,
|
|
138
|
+
feedback: { subtitle: '' },
|
|
139
|
+
contentId: i
|
|
140
|
+
};
|
|
141
|
+
if (knot.choices.length > 0) {
|
|
142
|
+
const alternatives = knot.choices.map((c) => ({
|
|
143
|
+
text: c.text,
|
|
144
|
+
nextContentId: resolve(c.target),
|
|
145
|
+
addFeedback: false,
|
|
146
|
+
feedback: { title: '', subtitle: '' }
|
|
147
|
+
}));
|
|
148
|
+
return {
|
|
149
|
+
...common,
|
|
150
|
+
type: {
|
|
151
|
+
library: 'H5P.BranchingQuestion 1.0',
|
|
152
|
+
params: { branchingQuestion: { question: passageHtml(knot), alternatives } },
|
|
153
|
+
subContentId: subContentId(i),
|
|
154
|
+
metadata: { contentType: 'Branching Question', license: 'U', title: knot.title }
|
|
155
|
+
},
|
|
156
|
+
nextContentId: -1
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
...common,
|
|
161
|
+
type: {
|
|
162
|
+
library: 'H5P.AdvancedText 1.1',
|
|
163
|
+
params: { text: passageHtml(knot) },
|
|
164
|
+
subContentId: subContentId(i),
|
|
165
|
+
metadata: { contentType: 'Text', license: 'U', title: knot.title }
|
|
166
|
+
},
|
|
167
|
+
nextContentId: resolve(plainDivertTarget(knot) ?? '')
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
return {
|
|
171
|
+
branchingScenario: {
|
|
172
|
+
content,
|
|
173
|
+
startScreen: { startScreenTitle: title, startScreenSubtitle: '' },
|
|
174
|
+
endScreens: [
|
|
175
|
+
{ endScreenTitle: 'The End', endScreenSubtitle: '', contentId: -1, endScreenScore: 0 }
|
|
176
|
+
],
|
|
177
|
+
scoringOptionGroup: { scoringOption: 'no-score', includeInteractionsScores: false },
|
|
178
|
+
behaviour: {
|
|
179
|
+
enableBackwardsNavigation: false,
|
|
180
|
+
forceContentFinished: false,
|
|
181
|
+
randomizeBranchingQuestions: false
|
|
182
|
+
},
|
|
183
|
+
l10n: {
|
|
184
|
+
startScreenButtonText: 'Start',
|
|
185
|
+
endScreenButtonText: 'Restart',
|
|
186
|
+
backButtonText: 'Back',
|
|
187
|
+
disableProceedButtonText: 'Require to complete the current module',
|
|
188
|
+
replayButtonText: 'Replay',
|
|
189
|
+
scoreText: 'Your score:',
|
|
190
|
+
fullscreenAria: 'Fullscreen'
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/** The h5p.json package manifest (content-only: dependencies declared, not bundled). */
|
|
196
|
+
function h5pManifest(title) {
|
|
197
|
+
return {
|
|
198
|
+
title,
|
|
199
|
+
language: 'und',
|
|
200
|
+
defaultLanguage: 'en',
|
|
201
|
+
mainLibrary: 'H5P.BranchingScenario',
|
|
202
|
+
embedTypes: ['iframe'],
|
|
203
|
+
license: 'U',
|
|
204
|
+
preloadedDependencies: [
|
|
205
|
+
{ machineName: 'H5P.BranchingScenario', majorVersion: '1', minorVersion: '8' },
|
|
206
|
+
{ machineName: 'H5P.AdvancedText', majorVersion: '1', minorVersion: '1' },
|
|
207
|
+
{ machineName: 'H5P.BranchingQuestion', majorVersion: '1', minorVersion: '0' }
|
|
208
|
+
]
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
// ─── Minimal ZIP writer (store method, no compression) ───────────────────────
|
|
212
|
+
const CRC_TABLE = (() => {
|
|
213
|
+
const table = new Uint32Array(256);
|
|
214
|
+
for (let n = 0; n < 256; n++) {
|
|
215
|
+
let c = n;
|
|
216
|
+
for (let k = 0; k < 8; k++)
|
|
217
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
218
|
+
table[n] = c >>> 0;
|
|
219
|
+
}
|
|
220
|
+
return table;
|
|
221
|
+
})();
|
|
222
|
+
function crc32(data) {
|
|
223
|
+
let crc = 0xffffffff;
|
|
224
|
+
for (let i = 0; i < data.length; i++) {
|
|
225
|
+
crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
|
|
226
|
+
}
|
|
227
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
228
|
+
}
|
|
229
|
+
// Fixed DOS timestamp (2026-01-01 00:00) keeps output byte-identical across runs.
|
|
230
|
+
const DOS_DATE = ((2026 - 1980) << 9) | (1 << 5) | 1;
|
|
231
|
+
const DOS_TIME = 0;
|
|
232
|
+
/** Assemble a store-only (uncompressed) zip from named UTF-8 entries. */
|
|
233
|
+
function zipStore(entries) {
|
|
234
|
+
const encoder = new TextEncoder();
|
|
235
|
+
const chunks = [];
|
|
236
|
+
const central = [];
|
|
237
|
+
let offset = 0;
|
|
238
|
+
const u16 = (v) => new Uint8Array([v & 0xff, (v >> 8) & 0xff]);
|
|
239
|
+
const u32 = (v) => new Uint8Array([v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]);
|
|
240
|
+
const cat = (...parts) => {
|
|
241
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
242
|
+
const out = new Uint8Array(total);
|
|
243
|
+
let pos = 0;
|
|
244
|
+
for (const p of parts) {
|
|
245
|
+
out.set(p, pos);
|
|
246
|
+
pos += p.length;
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
};
|
|
250
|
+
for (const entry of entries) {
|
|
251
|
+
const name = encoder.encode(entry.name);
|
|
252
|
+
const data = encoder.encode(entry.text);
|
|
253
|
+
const crc = crc32(data);
|
|
254
|
+
// Local file header: flag bit 11 set = UTF-8 names; method 0 = store.
|
|
255
|
+
const local = cat(u32(0x04034b50), u16(20), u16(0x0800), u16(0), u16(DOS_TIME), u16(DOS_DATE), u32(crc), u32(data.length), u32(data.length), u16(name.length), u16(0), name, data);
|
|
256
|
+
central.push(cat(u32(0x02014b50), u16(20), u16(20), u16(0x0800), u16(0), u16(DOS_TIME), u16(DOS_DATE), u32(crc), u32(data.length), u32(data.length), u16(name.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(offset), name));
|
|
257
|
+
chunks.push(local);
|
|
258
|
+
offset += local.length;
|
|
259
|
+
}
|
|
260
|
+
const centralBlob = cat(...central);
|
|
261
|
+
const eocd = cat(u32(0x06054b50), u16(0), u16(0), u16(entries.length), u16(entries.length), u32(centralBlob.length), u32(offset), u16(0));
|
|
262
|
+
return cat(...chunks, centralBlob, eocd);
|
|
263
|
+
}
|
|
264
|
+
// ─── Public entry point ──────────────────────────────────────────────────────
|
|
265
|
+
/**
|
|
266
|
+
* Convert a pure-branching Ink story to a .h5p package (zip bytes).
|
|
267
|
+
* Throws with a construct-by-construct explanation if the story uses state.
|
|
268
|
+
*/
|
|
269
|
+
function exportH5P(inkSource, title) {
|
|
270
|
+
const content = toBranchingScenarioContent(inkSource, title);
|
|
271
|
+
return zipStore([
|
|
272
|
+
{ name: 'h5p.json', text: JSON.stringify(h5pManifest(title)) },
|
|
273
|
+
{ name: 'content/content.json', text: JSON.stringify(content) }
|
|
274
|
+
]);
|
|
275
|
+
}
|
|
276
|
+
/** Base64-encode package bytes (works in both Node and the browser). */
|
|
277
|
+
function bytesToBase64(bytes) {
|
|
278
|
+
const B = globalThis.Buffer;
|
|
279
|
+
if (B)
|
|
280
|
+
return B.from(bytes).toString('base64');
|
|
281
|
+
let binary = '';
|
|
282
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
283
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
284
|
+
}
|
|
285
|
+
return btoa(binary);
|
|
286
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Provider-agnostic image generation, shared by the renderer and the CLI.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors aiClient.ts: pure and config-driven (no Zustand store, no DOM), using
|
|
6
|
+
* the global `fetch`/`AbortController` available in the browser and Node 18+.
|
|
7
|
+
* Every provider returns a data URL so images embed directly into the preview,
|
|
8
|
+
* project.json, and standalone HTML exports without filesystem access.
|
|
9
|
+
*
|
|
10
|
+
* Provider pattern borrowed from slide-stream's providers/images.py.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.buildSceneImagePrompt = buildSceneImagePrompt;
|
|
14
|
+
exports.isImageProviderConfigured = isImageProviderConfigured;
|
|
15
|
+
exports.generateImage = generateImage;
|
|
16
|
+
exports.normalizeImageBaseUrl = normalizeImageBaseUrl;
|
|
17
|
+
const REQUEST_TIMEOUT_MS = 180_000; // image generation can be slow
|
|
18
|
+
/** Short style directive prepended to every scene prompt. */
|
|
19
|
+
const STYLE_DIRECTIVES = {
|
|
20
|
+
cartoon: 'Flat cartoon illustration: bold clean outlines, vibrant colors, simple storybook style.',
|
|
21
|
+
photorealistic: 'Photorealistic cinematic photograph: natural lighting, shallow depth of field, high detail.'
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Build the full image prompt for a story scene. The style directive keeps
|
|
25
|
+
* every image in a story visually consistent; "no text" avoids garbled
|
|
26
|
+
* captions baked into the image.
|
|
27
|
+
*/
|
|
28
|
+
function buildSceneImagePrompt(scenePrompt, style = 'cartoon') {
|
|
29
|
+
const directive = STYLE_DIRECTIVES[style] ?? STYLE_DIRECTIVES.cartoon;
|
|
30
|
+
return `${directive} No text, words, or letters in the image. Scene: ${scenePrompt.trim()}`;
|
|
31
|
+
}
|
|
32
|
+
/** True when the config has enough to attempt a generation. */
|
|
33
|
+
function isImageProviderConfigured(config) {
|
|
34
|
+
switch (config.provider) {
|
|
35
|
+
case 'openai':
|
|
36
|
+
return !!config.apiKey;
|
|
37
|
+
case 'gemini':
|
|
38
|
+
return !!config.apiKey;
|
|
39
|
+
case 'custom':
|
|
40
|
+
return !!config.baseUrl && !!config.model;
|
|
41
|
+
case 'swarmui':
|
|
42
|
+
return !!config.baseUrl;
|
|
43
|
+
default:
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Generate one image. Resolves to a data URL (data:image/...;base64,...). */
|
|
48
|
+
async function generateImage(prompt, config) {
|
|
49
|
+
switch (config.provider) {
|
|
50
|
+
case 'openai':
|
|
51
|
+
return generateOpenAICompatibleImage(prompt, 'https://api.openai.com/v1', config);
|
|
52
|
+
case 'gemini':
|
|
53
|
+
return generateGeminiImage(prompt, config);
|
|
54
|
+
case 'custom':
|
|
55
|
+
if (!config.baseUrl)
|
|
56
|
+
throw new Error('No base URL configured for the custom image endpoint');
|
|
57
|
+
return generateOpenAICompatibleImage(prompt, normalizeImageBaseUrl(config.baseUrl), config);
|
|
58
|
+
case 'swarmui':
|
|
59
|
+
return generateSwarmUIImage(prompt, config);
|
|
60
|
+
default:
|
|
61
|
+
throw new Error(`Unknown image provider: ${String(config.provider)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// ─── OpenAI + OpenAI-compatible (/images/generations) ────────────────────────
|
|
65
|
+
async function generateOpenAICompatibleImage(prompt, baseUrl, config) {
|
|
66
|
+
const model = config.model || 'gpt-image-1';
|
|
67
|
+
const body = {
|
|
68
|
+
model,
|
|
69
|
+
prompt,
|
|
70
|
+
n: 1,
|
|
71
|
+
// Custom endpoints are typically self-hosted (CPU LocalAI etc.) where
|
|
72
|
+
// 1024px is impractically slow — default smaller there.
|
|
73
|
+
size: config.size || (config.provider === 'custom' ? '512x512' : '1024x1024')
|
|
74
|
+
};
|
|
75
|
+
// gpt-image models always return b64_json and reject response_format;
|
|
76
|
+
// dall-e models need it explicitly to avoid a URL response.
|
|
77
|
+
if (model.startsWith('dall-e'))
|
|
78
|
+
body.response_format = 'b64_json';
|
|
79
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
80
|
+
if (config.apiKey)
|
|
81
|
+
headers['Authorization'] = `Bearer ${config.apiKey}`;
|
|
82
|
+
const response = await fetchWithTimeout(`${baseUrl}/images/generations`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers,
|
|
85
|
+
body: JSON.stringify(body)
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new Error(`Image API error (${response.status}): ${await response.text()}`);
|
|
89
|
+
}
|
|
90
|
+
const data = await response.json();
|
|
91
|
+
const item = data?.data?.[0];
|
|
92
|
+
if (!item)
|
|
93
|
+
throw new Error('Image API returned no image data');
|
|
94
|
+
if (typeof item.b64_json === 'string' && item.b64_json) {
|
|
95
|
+
return `data:image/png;base64,${item.b64_json}`;
|
|
96
|
+
}
|
|
97
|
+
if (typeof item.url === 'string' && item.url) {
|
|
98
|
+
return fetchAsDataUrl(item.url);
|
|
99
|
+
}
|
|
100
|
+
throw new Error('Image API returned neither b64_json nor a URL');
|
|
101
|
+
}
|
|
102
|
+
// ─── Google Imagen (:predict) ────────────────────────────────────────────────
|
|
103
|
+
async function generateGeminiImage(prompt, config) {
|
|
104
|
+
if (!config.apiKey)
|
|
105
|
+
throw new Error('A Google API key is required for Imagen');
|
|
106
|
+
const model = config.model || 'imagen-4.0-fast-generate-001';
|
|
107
|
+
const aspectRatio = config.size || '4:3';
|
|
108
|
+
const response = await fetchWithTimeout(`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:predict`, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: {
|
|
111
|
+
'Content-Type': 'application/json',
|
|
112
|
+
'x-goog-api-key': config.apiKey
|
|
113
|
+
},
|
|
114
|
+
body: JSON.stringify({
|
|
115
|
+
instances: [{ prompt }],
|
|
116
|
+
parameters: { sampleCount: 1, aspectRatio }
|
|
117
|
+
})
|
|
118
|
+
});
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new Error(`Imagen API error (${response.status}): ${await response.text()}`);
|
|
121
|
+
}
|
|
122
|
+
const data = await response.json();
|
|
123
|
+
const prediction = data?.predictions?.[0];
|
|
124
|
+
const b64 = prediction?.bytesBase64Encoded;
|
|
125
|
+
if (typeof b64 !== 'string' || !b64)
|
|
126
|
+
throw new Error('Imagen returned no image');
|
|
127
|
+
const mime = typeof prediction?.mimeType === 'string' ? prediction.mimeType : 'image/png';
|
|
128
|
+
return `data:${mime};base64,${b64}`;
|
|
129
|
+
}
|
|
130
|
+
// ─── SwarmUI (native API — it does not speak OpenAI /images) ─────────────────
|
|
131
|
+
//
|
|
132
|
+
// Flow ported from slide-stream's SwarmUIImageProvider: open a session, call
|
|
133
|
+
// GenerateText2Image, then download the returned server path. apiKey is sent as
|
|
134
|
+
// a Bearer token (only needed when the server is fronted by auth).
|
|
135
|
+
async function generateSwarmUIImage(prompt, config) {
|
|
136
|
+
if (!config.baseUrl)
|
|
137
|
+
throw new Error('No base URL configured for SwarmUI');
|
|
138
|
+
const baseUrl = normalizeImageBaseUrl(config.baseUrl);
|
|
139
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
140
|
+
if (config.apiKey)
|
|
141
|
+
headers['Authorization'] = `Bearer ${config.apiKey}`;
|
|
142
|
+
// 1. Open a session.
|
|
143
|
+
const session = await fetchWithTimeout(`${baseUrl}/API/GetNewSession`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers,
|
|
146
|
+
body: '{}'
|
|
147
|
+
}, 30_000);
|
|
148
|
+
if (!session.ok) {
|
|
149
|
+
throw new Error(`SwarmUI session error (${session.status}): ${await session.text()}`);
|
|
150
|
+
}
|
|
151
|
+
const sessionId = (await session.json())?.session_id;
|
|
152
|
+
if (typeof sessionId !== 'string' || !sessionId) {
|
|
153
|
+
throw new Error('SwarmUI did not return a session_id');
|
|
154
|
+
}
|
|
155
|
+
// 2. Generate. SDXL-class models want ~1024px; a landscape frame suits a
|
|
156
|
+
// scene illustration above story text.
|
|
157
|
+
const [width, height] = parseSize(config.size) ?? [1024, 576];
|
|
158
|
+
const body = { session_id: sessionId, prompt, images: 1, width, height };
|
|
159
|
+
if (config.model)
|
|
160
|
+
body.model = config.model;
|
|
161
|
+
const gen = await fetchWithTimeout(`${baseUrl}/API/GenerateText2Image`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers,
|
|
164
|
+
body: JSON.stringify(body)
|
|
165
|
+
});
|
|
166
|
+
if (!gen.ok) {
|
|
167
|
+
throw new Error(`SwarmUI generation error (${gen.status}): ${await gen.text()}`);
|
|
168
|
+
}
|
|
169
|
+
const data = await gen.json();
|
|
170
|
+
const images = data?.images;
|
|
171
|
+
if (!Array.isArray(images) || typeof images[0] !== 'string') {
|
|
172
|
+
throw new Error('SwarmUI returned no image');
|
|
173
|
+
}
|
|
174
|
+
// 3. Fetch the generated image by its server path.
|
|
175
|
+
return fetchAsDataUrl(`${baseUrl}/${images[0].replace(/^\/+/, '')}`, headers);
|
|
176
|
+
}
|
|
177
|
+
/** Parse a '1024x576' size string into [width, height]; null if malformed. */
|
|
178
|
+
function parseSize(size) {
|
|
179
|
+
if (!size)
|
|
180
|
+
return null;
|
|
181
|
+
const m = size.trim().match(/^(\d+)\s*x\s*(\d+)$/i);
|
|
182
|
+
if (!m)
|
|
183
|
+
return null;
|
|
184
|
+
const w = parseInt(m[1], 10);
|
|
185
|
+
const h = parseInt(m[2], 10);
|
|
186
|
+
return w > 0 && h > 0 ? [w, h] : null;
|
|
187
|
+
}
|
|
188
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
189
|
+
/** Strip trailing slashes so `${base}/images/generations` is well-formed. */
|
|
190
|
+
function normalizeImageBaseUrl(url) {
|
|
191
|
+
return url.trim().replace(/\/+$/, '');
|
|
192
|
+
}
|
|
193
|
+
async function fetchWithTimeout(url, options, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
196
|
+
try {
|
|
197
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
201
|
+
throw new Error(`Image request timed out after ${timeoutMs / 1000}s`);
|
|
202
|
+
}
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Download an image URL and return it as a data URL (avoids expiring URLs). */
|
|
210
|
+
async function fetchAsDataUrl(url, headers = {}) {
|
|
211
|
+
const response = await fetchWithTimeout(url, { method: 'GET', headers }, 60_000);
|
|
212
|
+
if (!response.ok)
|
|
213
|
+
throw new Error(`Could not download image (${response.status})`);
|
|
214
|
+
const mime = response.headers.get('content-type')?.split(';')[0] || 'image/png';
|
|
215
|
+
const buffer = await response.arrayBuffer();
|
|
216
|
+
return `data:${mime};base64,${arrayBufferToBase64(buffer)}`;
|
|
217
|
+
}
|
|
218
|
+
/** ArrayBuffer → base64 without Node's Buffer (the renderer has no Buffer). */
|
|
219
|
+
function arrayBufferToBase64(buffer) {
|
|
220
|
+
const bytes = new Uint8Array(buffer);
|
|
221
|
+
let binary = '';
|
|
222
|
+
const chunk = 0x8000;
|
|
223
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
224
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
225
|
+
}
|
|
226
|
+
return btoa(binary);
|
|
227
|
+
}
|