ima2-gen 1.1.4 → 1.1.6

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/routes/edit.js CHANGED
@@ -5,8 +5,6 @@ import { editViaOAuth } from "../lib/oauthProxy.js";
5
5
  import { classifyUpstreamError } from "../lib/errorClassify.js";
6
6
  import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
7
7
  import { normalizeImageModel } from "../lib/imageModels.js";
8
- import { getStyleSheet } from "../lib/sessionStore.js";
9
- import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
10
8
  import { startJob, finishJob } from "../lib/inflight.js";
11
9
  import { logEvent, logError } from "../lib/logger.js";
12
10
 
@@ -57,7 +55,6 @@ export function registerEditRoutes(app, ctx) {
57
55
  quality,
58
56
  model: imageModel,
59
57
  size,
60
- styleSheetApplied: false,
61
58
  },
62
59
  });
63
60
 
@@ -81,21 +78,6 @@ export function registerEditRoutes(app, ctx) {
81
78
  return res.status(403).json({ error: "API key provider is disabled. Use OAuth (Codex login).", code: "APIKEY_DISABLED" });
82
79
  }
83
80
 
84
- let effectivePrompt = prompt;
85
- let styleSheetApplied = null;
86
- if (sessionId) {
87
- try {
88
- const data = getStyleSheet(sessionId);
89
- if (data && data.enabled && data.styleSheet) {
90
- const prefix = renderStyleSheetPrefix(data.styleSheet);
91
- if (prefix) {
92
- effectivePrompt = `${prefix} ${prompt}`.slice(0, 4000);
93
- styleSheetApplied = data.styleSheet;
94
- }
95
- }
96
- } catch {}
97
- }
98
-
99
81
  logEvent("edit", "request", {
100
82
  requestId,
101
83
  client: req.get("x-ima2-client") || "ui",
@@ -107,12 +89,11 @@ export function registerEditRoutes(app, ctx) {
107
89
  sessionId,
108
90
  promptChars: typeof prompt === "string" ? prompt.length : 0,
109
91
  promptMode: normalizedPromptMode,
110
- styleSheetApplied: !!styleSheetApplied,
111
92
  inputImageChars: typeof imageB64 === "string" ? imageB64.length : 0,
112
93
  });
113
94
  const startTime = Date.now();
114
95
  const { b64: resultB64, usage, revisedPrompt } = await editViaOAuth(
115
- effectivePrompt,
96
+ prompt,
116
97
  imageB64,
117
98
  quality,
118
99
  size,
@@ -132,8 +113,6 @@ export function registerEditRoutes(app, ctx) {
132
113
  userPrompt: prompt,
133
114
  revisedPrompt: revisedPrompt || null,
134
115
  promptMode: normalizedPromptMode,
135
- effectivePrompt: styleSheetApplied ? effectivePrompt : undefined,
136
- styleSheetApplied: styleSheetApplied || undefined,
137
116
  quality,
138
117
  size,
139
118
  moderation,
@@ -8,9 +8,8 @@ import { normalizeImageModel } from "../lib/imageModels.js";
8
8
  import { generateViaOAuth } from "../lib/oauthProxy.js";
9
9
  import { isNonRetryableGenerationError, normalizeGenerationFailure } from "../lib/generationErrors.js";
10
10
  import { startJob, finishJob } from "../lib/inflight.js";
11
- import { getStyleSheet } from "../lib/sessionStore.js";
12
- import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
13
11
  import { logEvent, logError } from "../lib/logger.js";
12
+ import { embedImageMetadataBestEffort } from "../lib/imageMetadataStore.js";
14
13
 
15
14
  function validateModeration(ctx, moderation) {
16
15
  if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
@@ -57,25 +56,10 @@ export function registerGenerateRoutes(app, ctx) {
57
56
  if (moderationCheck.error) return res.status(400).json({ error: moderationCheck.error });
58
57
  const count = Math.min(Math.max(parseInt(n) || 1, 1), 8);
59
58
 
60
- let effectivePrompt = prompt;
61
- let styleSheetApplied = null;
62
- if (sessionId) {
63
- try {
64
- const data = getStyleSheet(sessionId);
65
- if (data && data.enabled && data.styleSheet) {
66
- const prefix = renderStyleSheetPrefix(data.styleSheet);
67
- if (prefix) {
68
- effectivePrompt = `${prefix} ${prompt}`.slice(0, 4000);
69
- styleSheetApplied = data.styleSheet;
70
- }
71
- }
72
- } catch {}
73
- }
74
-
75
59
  startJob({
76
60
  requestId,
77
61
  kind: "classic",
78
- prompt: effectivePrompt,
62
+ prompt,
79
63
  meta: {
80
64
  kind: "classic",
81
65
  sessionId,
@@ -85,7 +69,6 @@ export function registerGenerateRoutes(app, ctx) {
85
69
  model: imageModel,
86
70
  size,
87
71
  n: count,
88
- styleSheetApplied: !!styleSheetApplied,
89
72
  },
90
73
  });
91
74
 
@@ -104,6 +87,8 @@ export function registerGenerateRoutes(app, ctx) {
104
87
  return res.status(403).json({ error: "API key provider is disabled. Use OAuth (Codex login).", code: "APIKEY_DISABLED" });
105
88
  }
106
89
  const client = req.get("x-ima2-client") || "ui";
90
+ const referenceDiagnostics = refCheck.referenceDiagnostics || [];
91
+ const referenceMismatchCount = referenceDiagnostics.filter((ref) => ref.warnings?.includes("mime_mismatch")).length;
107
92
  logEvent("generate", "request", {
108
93
  requestId,
109
94
  client,
@@ -114,11 +99,13 @@ export function registerGenerateRoutes(app, ctx) {
114
99
  moderation,
115
100
  n: count,
116
101
  refs: refCheck.refs.length,
102
+ referenceMismatchCount,
103
+ refDetectedMimes: [...new Set(referenceDiagnostics.map((ref) => ref.detectedMime).filter(Boolean))].join(","),
104
+ refDeclaredMimes: [...new Set(referenceDiagnostics.map((ref) => ref.declaredMime).filter(Boolean))].join(","),
117
105
  sessionId,
118
106
  clientNodeId,
119
107
  promptChars: typeof prompt === "string" ? prompt.length : 0,
120
108
  promptMode: normalizedPromptMode,
121
- styleSheetApplied: !!styleSheetApplied,
122
109
  });
123
110
  const startTime = Date.now();
124
111
 
@@ -132,11 +119,11 @@ export function registerGenerateRoutes(app, ctx) {
132
119
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
133
120
  try {
134
121
  const r = await generateViaOAuth(
135
- effectivePrompt,
122
+ prompt,
136
123
  quality,
137
124
  size,
138
125
  moderation,
139
- refCheck.refs,
126
+ refCheck.refDetails || refCheck.refs,
140
127
  requestId,
141
128
  normalizedPromptMode,
142
129
  ctx,
@@ -165,14 +152,15 @@ export function registerGenerateRoutes(app, ctx) {
165
152
  if (r.status === "fulfilled" && r.value.b64) {
166
153
  const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
167
154
  const filename = `${Date.now()}_${rand}_${images.length}.${format}`;
168
- await writeFile(join(ctx.config.storage.generatedDir, filename), Buffer.from(r.value.b64, "base64"));
169
155
  const meta = {
156
+ kind: "classic",
157
+ requestId,
158
+ sessionId,
159
+ clientNodeId,
170
160
  prompt,
171
161
  userPrompt: prompt,
172
162
  revisedPrompt: r.value.revisedPrompt || null,
173
163
  promptMode: normalizedPromptMode,
174
- effectivePrompt: styleSheetApplied ? effectivePrompt : undefined,
175
- styleSheetApplied: styleSheetApplied || undefined,
176
164
  quality,
177
165
  size,
178
166
  format,
@@ -182,7 +170,21 @@ export function registerGenerateRoutes(app, ctx) {
182
170
  createdAt: Date.now(),
183
171
  usage: r.value.usage || null,
184
172
  webSearchCalls: r.value.webSearchCalls || 0,
173
+ refsCount: refCheck.refs.length,
185
174
  };
175
+ const rawBuffer = Buffer.from(r.value.b64, "base64");
176
+ const embedded = await embedImageMetadataBestEffort(rawBuffer, format, meta, {
177
+ version: ctx.packageVersion,
178
+ });
179
+ if (!embedded.embedded) {
180
+ logEvent("generate", "metadata_embed_skipped", {
181
+ requestId,
182
+ filename,
183
+ code: embedded.code,
184
+ warning: embedded.warning,
185
+ });
186
+ }
187
+ await writeFile(join(ctx.config.storage.generatedDir, filename), embedded.buffer);
186
188
  await writeFile(join(ctx.config.storage.generatedDir, filename + ".json"), JSON.stringify(meta)).catch(() => {});
187
189
  images.push({
188
190
  image: `data:${mime};base64,${r.value.b64}`,
@@ -214,6 +216,10 @@ export function registerGenerateRoutes(app, ctx) {
214
216
  upstreamCode: firstErr.upstreamCode || null,
215
217
  upstreamType: firstErr.upstreamType || null,
216
218
  upstreamParam: firstErr.upstreamParam || null,
219
+ diagnosticReason: firstErr.diagnosticReason || null,
220
+ retryKind: firstErr.retryKind || null,
221
+ referencesDroppedOnRetry: firstErr.referencesDroppedOnRetry ?? null,
222
+ errorEventCount: firstErr.eventCount ?? null,
217
223
  requestId,
218
224
  });
219
225
  }
@@ -270,6 +276,10 @@ export function registerGenerateRoutes(app, ctx) {
270
276
  upstreamCode: err.upstreamCode || null,
271
277
  upstreamType: err.upstreamType || null,
272
278
  upstreamParam: err.upstreamParam || null,
279
+ diagnosticReason: err.diagnosticReason || null,
280
+ retryKind: err.retryKind || null,
281
+ referencesDroppedOnRetry: err.referencesDroppedOnRetry ?? null,
282
+ errorEventCount: err.eventCount ?? null,
273
283
  requestId,
274
284
  });
275
285
  } finally {
package/routes/history.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { listHistoryRows } from "../lib/historyList.js";
2
- import { trashAsset, restoreAsset } from "../lib/assetLifecycle.js";
2
+ import { trashAsset, restoreAsset, deleteAssetPermanent } from "../lib/assetLifecycle.js";
3
3
  import { getSessionTitleMap } from "../lib/sessionStore.js";
4
4
  import { logError, logEvent } from "../lib/logger.js";
5
+ import { getDb } from "../lib/db.js";
5
6
 
6
7
  export function registerHistoryRoutes(app, ctx) {
7
8
  app.get("/api/history", async (req, res) => {
@@ -16,9 +17,18 @@ export function registerHistoryRoutes(app, ctx) {
16
17
  const sinceTs = parseInt(req.query.since);
17
18
  const sessionId = typeof req.query.sessionId === "string" ? req.query.sessionId : null;
18
19
  const groupBy = req.query.groupBy === "session" ? "session" : null;
20
+ const browserId = req.headers["x-ima2-browser-id"] || null;
19
21
 
20
22
  const rows = await listHistoryRows(ctx.config.storage.generatedDir);
21
23
 
24
+ // Enrich with favorite status
25
+ let favoriteSet = new Set();
26
+ if (browserId) {
27
+ const db = getDb();
28
+ const favRows = db.prepare("SELECT filename FROM gallery_favorites WHERE browser_id = ?").all(browserId);
29
+ favoriteSet = new Set(favRows.map((r) => r.filename));
30
+ }
31
+
22
32
  let filtered = rows;
23
33
  if (Number.isFinite(sinceTs)) {
24
34
  filtered = filtered.filter((r) => r.createdAt > sinceTs);
@@ -34,7 +44,7 @@ export function registerHistoryRoutes(app, ctx) {
34
44
  filtered = filtered.filter((r) => r.sessionId === sessionId);
35
45
  }
36
46
 
37
- const page = filtered.slice(0, limit);
47
+ const page = filtered.slice(0, limit).map((r) => ({ ...r, isFavorite: favoriteSet.has(r.filename) }));
38
48
  const nextCursor = page.length === limit && filtered.length > limit
39
49
  ? { before: page[page.length - 1].createdAt, beforeFilename: page[page.length - 1].filename }
40
50
  : null;
@@ -78,6 +88,16 @@ export function registerHistoryRoutes(app, ctx) {
78
88
  }
79
89
  });
80
90
 
91
+ app.delete("/api/history/:filename/permanent", async (req, res) => {
92
+ try {
93
+ const filename = decodeURIComponent(req.params.filename);
94
+ const result = await deleteAssetPermanent(ctx.rootDir, filename);
95
+ res.json(result);
96
+ } catch (err) {
97
+ res.status(err.status || 500).json({ error: err.message, code: err.code });
98
+ }
99
+ });
100
+
81
101
  app.delete("/api/history/:filename", async (req, res) => {
82
102
  try {
83
103
  const filename = decodeURIComponent(req.params.filename);
@@ -99,4 +119,35 @@ export function registerHistoryRoutes(app, ctx) {
99
119
  res.status(err.status || 500).json({ error: err.message });
100
120
  }
101
121
  });
122
+
123
+ app.post("/api/history/favorite", async (req, res) => {
124
+ try {
125
+ const db = getDb();
126
+ const { filename } = req.body || {};
127
+ const browserId = req.headers["x-ima2-browser-id"];
128
+
129
+ if (!filename || typeof filename !== "string") {
130
+ return res.status(400).json({ error: "filename is required" });
131
+ }
132
+ if (!browserId || typeof browserId !== "string") {
133
+ return res.status(400).json({ error: "X-Ima2-Browser-Id header is required" });
134
+ }
135
+
136
+ const existing = db.prepare("SELECT id FROM gallery_favorites WHERE browser_id = ? AND filename = ?").get(browserId, filename);
137
+
138
+ if (existing) {
139
+ db.prepare("DELETE FROM gallery_favorites WHERE browser_id = ? AND filename = ?").run(browserId, filename);
140
+ res.json({ isFavorite: false });
141
+ } else {
142
+ const id = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
143
+ db.prepare(
144
+ "INSERT INTO gallery_favorites (id, browser_id, filename, favorited_at) VALUES (?, ?, ?, ?)"
145
+ ).run(id, browserId, filename, Math.floor(Date.now() / 1000));
146
+ res.json({ isFavorite: true });
147
+ }
148
+ } catch (err) {
149
+ logError("history", "favorite_error", err);
150
+ res.status(500).json({ error: err.message });
151
+ }
152
+ });
102
153
  }
package/routes/index.js CHANGED
@@ -4,16 +4,22 @@ import { registerSessionRoutes } from "./sessions.js";
4
4
  import { registerEditRoutes } from "./edit.js";
5
5
  import { registerNodeRoutes } from "./nodes.js";
6
6
  import { registerGenerateRoutes } from "./generate.js";
7
+ import { registerMultimodeRoutes } from "./multimode.js";
7
8
  import { registerStorageRoutes } from "./storage.js";
8
9
  import { registerCardNewsRoutes } from "./cardNews.js";
10
+ import { registerMetadataRoutes } from "./metadata.js";
11
+ import { registerPromptRoutes } from "./prompts.js";
9
12
 
10
13
  export function configureRoutes(app, ctx) {
11
14
  registerHealthRoutes(app, ctx);
12
15
  registerStorageRoutes(app, ctx);
16
+ registerMetadataRoutes(app, ctx);
13
17
  registerHistoryRoutes(app, ctx);
14
18
  registerSessionRoutes(app, ctx);
15
19
  registerEditRoutes(app, ctx);
16
20
  registerNodeRoutes(app, ctx);
17
21
  if (ctx.config.features.cardNews) registerCardNewsRoutes(app, ctx);
22
+ registerMultimodeRoutes(app, ctx);
18
23
  registerGenerateRoutes(app, ctx);
24
+ registerPromptRoutes(app, ctx);
19
25
  }
@@ -0,0 +1,71 @@
1
+ import { Buffer } from "node:buffer";
2
+ import {
3
+ isSupportedMetadataFormat,
4
+ normalizeImageMetadataFormat,
5
+ readEmbeddedImageMetadata,
6
+ } from "../lib/imageMetadataStore.js";
7
+
8
+ const MIME_FORMATS = {
9
+ "image/png": "png",
10
+ "image/jpeg": "jpeg",
11
+ "image/webp": "webp",
12
+ };
13
+
14
+ function parseDataUrl(dataUrl) {
15
+ if (typeof dataUrl !== "string") return null;
16
+ const match = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl);
17
+ if (!match) return null;
18
+ return { mime: match[1].toLowerCase(), rawB64: match[2] };
19
+ }
20
+
21
+ export function registerMetadataRoutes(app, ctx) {
22
+ app.post("/api/metadata/read", async (req, res) => {
23
+ try {
24
+ const parsed = parseDataUrl(req.body?.dataUrl);
25
+ if (!parsed) {
26
+ return res.status(400).json({
27
+ ok: false,
28
+ code: "IMAGE_METADATA_INVALID",
29
+ error: "A base64 image data URL is required.",
30
+ });
31
+ }
32
+ if (parsed.rawB64.length > ctx.config.limits.maxMetadataReadB64Bytes) {
33
+ return res.status(413).json({
34
+ ok: false,
35
+ code: "IMAGE_METADATA_TOO_LARGE",
36
+ error: "Image is too large to inspect for metadata.",
37
+ });
38
+ }
39
+ const format = normalizeImageMetadataFormat(MIME_FORMATS[parsed.mime]);
40
+ if (!isSupportedMetadataFormat(format)) {
41
+ return res.status(400).json({
42
+ ok: false,
43
+ code: "IMAGE_METADATA_UNSUPPORTED_FORMAT",
44
+ error: "Only PNG, JPEG, and WebP metadata can be inspected.",
45
+ });
46
+ }
47
+ const result = await readEmbeddedImageMetadata(Buffer.from(parsed.rawB64, "base64"));
48
+ if (!result.metadata) {
49
+ return res.json({
50
+ ok: true,
51
+ metadata: null,
52
+ source: null,
53
+ code: "IMAGE_METADATA_NOT_FOUND",
54
+ warnings: result.warnings,
55
+ });
56
+ }
57
+ return res.json({
58
+ ok: true,
59
+ metadata: result.metadata,
60
+ source: result.source,
61
+ warnings: result.warnings,
62
+ });
63
+ } catch (error) {
64
+ return res.status(400).json({
65
+ ok: false,
66
+ code: error?.code || "IMAGE_METADATA_INVALID",
67
+ error: error?.message || "Could not read image metadata.",
68
+ });
69
+ }
70
+ });
71
+ }
@@ -0,0 +1,264 @@
1
+ import { mkdir, writeFile } from "fs/promises";
2
+ import { join } from "path";
3
+ import { randomBytes } from "crypto";
4
+ import { validateAndNormalizeRefs } from "../lib/refs.js";
5
+ import { classifyUpstreamError } from "../lib/errorClassify.js";
6
+ import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
7
+ import { normalizeImageModel } from "../lib/imageModels.js";
8
+ import { generateMultimodeViaOAuth } from "../lib/oauthProxy.js";
9
+ import { startJob, finishJob } from "../lib/inflight.js";
10
+ import { logEvent, logError } from "../lib/logger.js";
11
+ import { embedImageMetadataBestEffort } from "../lib/imageMetadataStore.js";
12
+
13
+ function sendSse(res, event, data) {
14
+ res.write(`event: ${event}\n`);
15
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
16
+ }
17
+
18
+ function validateModeration(ctx, moderation) {
19
+ if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
20
+ return { error: "moderation must be one of: auto, low" };
21
+ }
22
+ return { moderation };
23
+ }
24
+
25
+ function normalizeMaxImages(value) {
26
+ return Math.min(8, Math.max(1, Math.trunc(Number(value) || 1)));
27
+ }
28
+
29
+ function sequenceStatus(returned, requested) {
30
+ if (returned <= 0) return "empty";
31
+ if (returned < requested) return "partial";
32
+ return "complete";
33
+ }
34
+
35
+ export function registerMultimodeRoutes(app, ctx) {
36
+ app.post("/api/generate/multimode", async (req, res) => {
37
+ const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : req.id;
38
+ let finishStatus = "completed";
39
+ let finishHttpStatus = 200;
40
+ let finishErrorCode;
41
+ let finishMeta = {};
42
+
43
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
44
+ res.setHeader("Cache-Control", "no-cache, no-transform");
45
+ res.setHeader("Connection", "keep-alive");
46
+ res.flushHeaders?.();
47
+
48
+ try {
49
+ const {
50
+ prompt,
51
+ quality: rawQuality = "medium",
52
+ size = "1024x1024",
53
+ format = "png",
54
+ moderation = "low",
55
+ provider = "auto",
56
+ references = [],
57
+ mode: promptMode = "auto",
58
+ model: rawModel,
59
+ } = req.body;
60
+ const maxImages = normalizeMaxImages(req.body?.maxImages);
61
+ const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
62
+ const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
63
+ const modelCheck = normalizeImageModel(ctx, rawModel);
64
+ if (modelCheck.error) {
65
+ finishStatus = "error";
66
+ finishHttpStatus = modelCheck.status;
67
+ finishErrorCode = modelCheck.code;
68
+ sendSse(res, "error", { error: modelCheck.error, code: modelCheck.code, status: modelCheck.status, requestId });
69
+ return;
70
+ }
71
+ const imageModel = modelCheck.model;
72
+ if (!prompt) {
73
+ finishStatus = "error";
74
+ finishHttpStatus = 400;
75
+ finishErrorCode = "PROMPT_REQUIRED";
76
+ sendSse(res, "error", { error: "Prompt is required", code: finishErrorCode, status: 400, requestId });
77
+ return;
78
+ }
79
+ const moderationCheck = validateModeration(ctx, moderation);
80
+ if (moderationCheck.error) {
81
+ finishStatus = "error";
82
+ finishHttpStatus = 400;
83
+ finishErrorCode = "INVALID_MODERATION";
84
+ sendSse(res, "error", { error: moderationCheck.error, code: finishErrorCode, status: 400, requestId });
85
+ return;
86
+ }
87
+ if (provider === "api") {
88
+ finishStatus = "error";
89
+ finishHttpStatus = 403;
90
+ finishErrorCode = "APIKEY_DISABLED";
91
+ sendSse(res, "error", {
92
+ error: "API key provider is disabled. Use OAuth (Codex login).",
93
+ code: finishErrorCode,
94
+ status: 403,
95
+ requestId,
96
+ });
97
+ return;
98
+ }
99
+
100
+ const refCheck = validateAndNormalizeRefs(references);
101
+ if (refCheck.error) {
102
+ finishStatus = "error";
103
+ finishHttpStatus = 400;
104
+ finishErrorCode = refCheck.code;
105
+ sendSse(res, "error", { error: refCheck.error, code: refCheck.code, status: 400, requestId });
106
+ return;
107
+ }
108
+
109
+ startJob({
110
+ requestId,
111
+ kind: "multimode",
112
+ prompt,
113
+ meta: { kind: "multimode", quality, model: imageModel, size, maxImages },
114
+ });
115
+
116
+ logEvent("multimode", "request", {
117
+ requestId,
118
+ quality,
119
+ model: imageModel,
120
+ size,
121
+ moderation,
122
+ maxImages,
123
+ refs: refCheck.refs.length,
124
+ promptChars: typeof prompt === "string" ? prompt.length : 0,
125
+ });
126
+
127
+ const startTime = Date.now();
128
+ const mimeMap = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
129
+ const mime = mimeMap[format] || "image/png";
130
+ const sequenceId = `seq_${Date.now().toString(36)}_${randomBytes(4).toString("hex")}`;
131
+ await mkdir(ctx.config.storage.generatedDir, { recursive: true });
132
+
133
+ sendSse(res, "phase", { phase: "streaming", requestId, sequenceId, maxImages });
134
+ const generated = await generateMultimodeViaOAuth(
135
+ prompt,
136
+ quality,
137
+ size,
138
+ moderation,
139
+ refCheck.refDetails || refCheck.refs,
140
+ requestId,
141
+ normalizedPromptMode,
142
+ ctx,
143
+ {
144
+ model: imageModel,
145
+ maxImages,
146
+ onPartialImage: (partial) =>
147
+ sendSse(res, "partial", {
148
+ image: `data:${mime};base64,${partial.b64}`,
149
+ requestId,
150
+ sequenceId,
151
+ index: partial.index,
152
+ }),
153
+ },
154
+ );
155
+
156
+ const returned = generated.images.length;
157
+ const status = sequenceStatus(returned, maxImages);
158
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
159
+ const images = [];
160
+
161
+ for (const [index, image] of generated.images.entries()) {
162
+ const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
163
+ const filename = `${Date.now()}_${rand}_multimode_${index}.${format}`;
164
+ const meta = {
165
+ kind: "multimode-image",
166
+ generationStrategy: "one-call-text-sequence",
167
+ sequenceId,
168
+ sequenceIndex: index + 1,
169
+ sequenceTotalRequested: maxImages,
170
+ sequenceTotalReturned: returned,
171
+ sequenceStatus: status,
172
+ stageLabel: String.fromCharCode(65 + index),
173
+ requestId,
174
+ prompt,
175
+ userPrompt: prompt,
176
+ revisedPrompt: image.revisedPrompt || null,
177
+ promptMode: normalizedPromptMode,
178
+ quality,
179
+ size,
180
+ format,
181
+ moderation,
182
+ model: imageModel,
183
+ provider: "oauth",
184
+ createdAt: Date.now(),
185
+ usage: generated.usage || null,
186
+ webSearchCalls: generated.webSearchCalls || 0,
187
+ refsCount: refCheck.refs.length,
188
+ };
189
+ const rawBuffer = Buffer.from(image.b64, "base64");
190
+ const embedded = await embedImageMetadataBestEffort(rawBuffer, format, meta, {
191
+ version: ctx.packageVersion,
192
+ });
193
+ await writeFile(join(ctx.config.storage.generatedDir, filename), embedded.buffer);
194
+ await writeFile(join(ctx.config.storage.generatedDir, filename + ".json"), JSON.stringify(meta)).catch(() => {});
195
+ const item = {
196
+ image: `data:${mime};base64,${image.b64}`,
197
+ filename,
198
+ revisedPrompt: image.revisedPrompt || null,
199
+ sequenceId,
200
+ sequenceIndex: index + 1,
201
+ sequenceTotalRequested: maxImages,
202
+ sequenceTotalReturned: returned,
203
+ sequenceStatus: status,
204
+ };
205
+ images.push(item);
206
+ sendSse(res, "image", item);
207
+ }
208
+
209
+ finishMeta = { sequenceId, imageCount: returned, maxImages, status };
210
+ finishHttpStatus = 200;
211
+ sendSse(res, "done", {
212
+ ok: true,
213
+ requestId,
214
+ sequenceId,
215
+ requested: maxImages,
216
+ returned,
217
+ status,
218
+ elapsed,
219
+ images,
220
+ provider: "oauth",
221
+ quality,
222
+ size,
223
+ moderation,
224
+ model: imageModel,
225
+ usage: generated.usage || null,
226
+ webSearchCalls: generated.webSearchCalls || 0,
227
+ warnings: qualityWarnings,
228
+ extraIgnored: generated.extraIgnored || 0,
229
+ promptMode: normalizedPromptMode,
230
+ });
231
+ logEvent("multimode", "saved", {
232
+ requestId,
233
+ sequenceId,
234
+ imageCount: returned,
235
+ maxImages,
236
+ status,
237
+ elapsedMs: Date.now() - startTime,
238
+ });
239
+ } catch (err) {
240
+ const fallbackCode = err.code || classifyUpstreamError(err.message);
241
+ finishStatus = "error";
242
+ finishHttpStatus = err.status || 500;
243
+ finishErrorCode = fallbackCode || "MULTIMODE_GENERATE_FAILED";
244
+ logError("multimode", "error", err, { requestId, code: finishErrorCode });
245
+ sendSse(res, "error", {
246
+ error: err.message,
247
+ code: finishErrorCode,
248
+ status: finishHttpStatus,
249
+ requestId,
250
+ upstreamCode: err.upstreamCode || null,
251
+ upstreamType: err.upstreamType || null,
252
+ upstreamParam: err.upstreamParam || null,
253
+ });
254
+ } finally {
255
+ finishJob(requestId, {
256
+ status: finishStatus,
257
+ httpStatus: finishHttpStatus,
258
+ errorCode: finishErrorCode,
259
+ meta: finishMeta,
260
+ });
261
+ res.end();
262
+ }
263
+ });
264
+ }