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/config.js +5 -0
- package/lib/assetLifecycle.js +21 -0
- package/lib/db.js +41 -3
- package/lib/generationErrors.js +24 -0
- package/lib/historyList.js +19 -1
- package/lib/imageMetadata.js +107 -0
- package/lib/imageMetadataStore.js +67 -0
- package/lib/nodeStore.js +13 -1
- package/lib/oauthProxy.js +387 -24
- package/lib/refs.js +65 -2
- package/package.json +1 -1
- package/routes/edit.js +1 -22
- package/routes/generate.js +35 -25
- package/routes/history.js +53 -2
- package/routes/index.js +6 -0
- package/routes/metadata.js +71 -0
- package/routes/multimode.js +264 -0
- package/routes/nodes.js +20 -26
- package/routes/prompts.js +379 -0
- package/ui/dist/assets/index-3X-6VjbF.css +1 -0
- package/ui/dist/assets/index-DPSq9qEs.js +31 -0
- package/ui/dist/assets/index-DPSq9qEs.js.map +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-DHeTnSPD.css +0 -1
- package/ui/dist/assets/index-fDTlOt4w.js +0 -23
- package/ui/dist/assets/index-fDTlOt4w.js.map +0 -1
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
|
-
|
|
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,
|
package/routes/generate.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|