ima2-gen 1.1.6 → 1.1.8
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/.env.example +5 -0
- package/README.md +3 -0
- package/assets/phase-a-bg-cleanup-test.png +0 -0
- package/config.js +111 -0
- package/docs/FAQ.ko.md +20 -0
- package/docs/FAQ.md +20 -0
- package/docs/README.ko.md +3 -0
- package/docs/README.zh-CN.md +3 -0
- package/integrations/comfyui/ima2_gen_bridge/README.md +88 -0
- package/integrations/comfyui/ima2_gen_bridge/__init__.py +3 -0
- package/integrations/comfyui/ima2_gen_bridge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/integrations/comfyui/ima2_gen_bridge/__pycache__/nodes.cpython-313.pyc +0 -0
- package/integrations/comfyui/ima2_gen_bridge/nodes.py +238 -0
- package/lib/canvasVersionStore.js +181 -0
- package/lib/cardNewsPlannerClient.js +4 -2
- package/lib/comfyBridge.js +214 -0
- package/lib/db.js +14 -0
- package/lib/historyList.js +4 -0
- package/lib/imageMetadata.js +4 -0
- package/lib/imageModels.js +20 -0
- package/lib/localImportStore.js +111 -0
- package/lib/oauthProxy.js +88 -38
- package/lib/pngInfo.js +26 -0
- package/lib/promptImport/curatedSources.js +139 -0
- package/lib/promptImport/discoveryRegistry.js +236 -0
- package/lib/promptImport/errors.js +16 -0
- package/lib/promptImport/githubDiscovery.js +248 -0
- package/lib/promptImport/githubFolder.js +308 -0
- package/lib/promptImport/githubSource.js +239 -0
- package/lib/promptImport/gptImageHints.js +68 -0
- package/lib/promptImport/parsePromptCandidates.js +153 -0
- package/lib/promptImport/promptIndex.js +248 -0
- package/lib/promptImport/rankPromptCandidates.js +49 -0
- package/package.json +3 -2
- package/routes/annotations.js +95 -0
- package/routes/canvasVersions.js +64 -0
- package/routes/comfy.js +39 -0
- package/routes/edit.js +73 -4
- package/routes/generate.js +16 -2
- package/routes/imageImport.js +33 -0
- package/routes/index.js +10 -0
- package/routes/multimode.js +18 -1
- package/routes/nodes.js +25 -3
- package/routes/promptImport.js +354 -0
- package/ui/dist/assets/index-BDffwmLs.css +1 -0
- package/ui/dist/assets/index-D0fdHLkJ.js +31 -0
- package/ui/dist/assets/index-D0fdHLkJ.js.map +1 -0
- package/ui/dist/index.html +6 -3
- package/ui/dist/assets/index-3X-6VjbF.css +0 -1
- package/ui/dist/assets/index-DPSq9qEs.js +0 -31
- package/ui/dist/assets/index-DPSq9qEs.js.map +0 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { mkdir, writeFile, access, readFile } from "fs/promises";
|
|
2
|
+
import { constants } from "fs";
|
|
3
|
+
import { basename, join, normalize, parse } from "path";
|
|
4
|
+
import { randomBytes } from "crypto";
|
|
5
|
+
import { embedImageMetadataBestEffort } from "./imageMetadataStore.js";
|
|
6
|
+
|
|
7
|
+
const PNG_SIGNATURE = "89504e470d0a1a0a";
|
|
8
|
+
|
|
9
|
+
function assertPngBuffer(buffer) {
|
|
10
|
+
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
|
11
|
+
const err = new Error("PNG body is required");
|
|
12
|
+
err.status = 400;
|
|
13
|
+
err.code = "EMPTY_CANVAS_VERSION";
|
|
14
|
+
throw err;
|
|
15
|
+
}
|
|
16
|
+
if (buffer.subarray(0, 8).toString("hex") !== PNG_SIGNATURE) {
|
|
17
|
+
const err = new Error("Canvas version body must be a PNG image");
|
|
18
|
+
err.status = 400;
|
|
19
|
+
err.code = "CANVAS_VERSION_NOT_PNG";
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assertSafeFilename(filename) {
|
|
25
|
+
if (
|
|
26
|
+
typeof filename !== "string" ||
|
|
27
|
+
filename.length === 0 ||
|
|
28
|
+
filename !== basename(filename) ||
|
|
29
|
+
filename.includes("..") ||
|
|
30
|
+
!/^canvas-[a-zA-Z0-9._-]+\.png$/.test(filename)
|
|
31
|
+
) {
|
|
32
|
+
const err = new Error("Invalid canvas version filename");
|
|
33
|
+
err.status = 400;
|
|
34
|
+
err.code = "INVALID_CANVAS_VERSION_FILENAME";
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function safeSourceBase(sourceFilename) {
|
|
40
|
+
const parsed = parse(basename(String(sourceFilename || "image")));
|
|
41
|
+
return parsed.name.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "image";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ensureInsideGeneratedDir(generatedDir, filename) {
|
|
45
|
+
const full = normalize(join(generatedDir, filename));
|
|
46
|
+
const root = normalize(generatedDir);
|
|
47
|
+
if (!full.startsWith(root)) {
|
|
48
|
+
const err = new Error("Canvas version path escapes generated directory");
|
|
49
|
+
err.status = 400;
|
|
50
|
+
err.code = "CANVAS_VERSION_PATH_ESCAPE";
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
return full;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeCanvasFilename(sourceFilename) {
|
|
57
|
+
const stamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
|
58
|
+
const rand = randomBytes(3).toString("hex");
|
|
59
|
+
return `canvas-${safeSourceBase(sourceFilename)}-${stamp}-${rand}.png`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function writeCanvasPng(ctx, filename, buffer, meta) {
|
|
63
|
+
await mkdir(ctx.config.storage.generatedDir, { recursive: true });
|
|
64
|
+
const full = ensureInsideGeneratedDir(ctx.config.storage.generatedDir, filename);
|
|
65
|
+
const embedded = await embedImageMetadataBestEffort(buffer, "png", meta, {
|
|
66
|
+
version: ctx.packageVersion,
|
|
67
|
+
});
|
|
68
|
+
await writeFile(full, embedded.buffer);
|
|
69
|
+
await writeFile(`${full}.json`, JSON.stringify(meta)).catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readGeneratedMetadata(ctx, filename) {
|
|
73
|
+
if (!filename) return null;
|
|
74
|
+
try {
|
|
75
|
+
const full = ensureInsideGeneratedDir(ctx.config.storage.generatedDir, basename(filename));
|
|
76
|
+
return JSON.parse(await readFile(`${full}.json`, "utf8"));
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function firstString(...values) {
|
|
83
|
+
return values.find((value) => typeof value === "string" && value.trim().length > 0) ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toGenerateItem(filename, meta) {
|
|
87
|
+
const url = `/generated/${encodeURIComponent(filename)}`;
|
|
88
|
+
return {
|
|
89
|
+
image: url,
|
|
90
|
+
url,
|
|
91
|
+
thumb: url,
|
|
92
|
+
filename,
|
|
93
|
+
prompt: meta.prompt || undefined,
|
|
94
|
+
userPrompt: meta.userPrompt || meta.prompt || null,
|
|
95
|
+
revisedPrompt: null,
|
|
96
|
+
promptMode: meta.promptMode || "direct",
|
|
97
|
+
provider: meta.provider || "canvas",
|
|
98
|
+
quality: meta.quality || null,
|
|
99
|
+
size: meta.size || null,
|
|
100
|
+
format: "png",
|
|
101
|
+
moderation: meta.moderation || null,
|
|
102
|
+
model: meta.model || null,
|
|
103
|
+
usage: null,
|
|
104
|
+
createdAt: meta.createdAt,
|
|
105
|
+
kind: "edit",
|
|
106
|
+
canvasMergedAt: meta.canvasMergedAt,
|
|
107
|
+
canvasVersion: true,
|
|
108
|
+
canvasSourceFilename: meta.canvasSourceFilename || null,
|
|
109
|
+
canvasEditableFilename: filename,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function createCanvasVersion(ctx, input) {
|
|
114
|
+
assertPngBuffer(input.buffer);
|
|
115
|
+
const sourceFilename = basename(String(input.sourceFilename || ""));
|
|
116
|
+
if (!sourceFilename) {
|
|
117
|
+
const err = new Error("sourceFilename is required");
|
|
118
|
+
err.status = 400;
|
|
119
|
+
err.code = "CANVAS_SOURCE_REQUIRED";
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
const filename = makeCanvasFilename(sourceFilename);
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
const sourceMeta = await readGeneratedMetadata(ctx, sourceFilename);
|
|
125
|
+
const prompt = firstString(input.prompt, sourceMeta?.userPrompt, sourceMeta?.prompt);
|
|
126
|
+
const meta = {
|
|
127
|
+
kind: "edit",
|
|
128
|
+
provider: "canvas",
|
|
129
|
+
format: "png",
|
|
130
|
+
prompt,
|
|
131
|
+
userPrompt: prompt,
|
|
132
|
+
promptMode: sourceMeta?.promptMode || "direct",
|
|
133
|
+
createdAt: now,
|
|
134
|
+
canvasMergedAt: now,
|
|
135
|
+
canvasVersion: true,
|
|
136
|
+
canvasSourceFilename: sourceFilename,
|
|
137
|
+
canvasEditableFilename: filename,
|
|
138
|
+
};
|
|
139
|
+
await writeCanvasPng(ctx, filename, input.buffer, meta);
|
|
140
|
+
return toGenerateItem(filename, meta);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function updateCanvasVersion(ctx, filename, input) {
|
|
144
|
+
assertSafeFilename(filename);
|
|
145
|
+
assertPngBuffer(input.buffer);
|
|
146
|
+
const full = ensureInsideGeneratedDir(ctx.config.storage.generatedDir, filename);
|
|
147
|
+
await access(full, constants.F_OK).catch(() => {
|
|
148
|
+
const err = new Error("Canvas version not found");
|
|
149
|
+
err.status = 404;
|
|
150
|
+
err.code = "CANVAS_VERSION_NOT_FOUND";
|
|
151
|
+
throw err;
|
|
152
|
+
});
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
const sourceFilename = typeof input.sourceFilename === "string"
|
|
155
|
+
? basename(input.sourceFilename)
|
|
156
|
+
: null;
|
|
157
|
+
const sourceMeta = await readGeneratedMetadata(ctx, sourceFilename);
|
|
158
|
+
const previousMeta = await readGeneratedMetadata(ctx, filename);
|
|
159
|
+
const prompt = firstString(
|
|
160
|
+
input.prompt,
|
|
161
|
+
sourceMeta?.userPrompt,
|
|
162
|
+
sourceMeta?.prompt,
|
|
163
|
+
previousMeta?.userPrompt,
|
|
164
|
+
previousMeta?.prompt,
|
|
165
|
+
);
|
|
166
|
+
const meta = {
|
|
167
|
+
kind: "edit",
|
|
168
|
+
provider: "canvas",
|
|
169
|
+
format: "png",
|
|
170
|
+
prompt,
|
|
171
|
+
userPrompt: prompt,
|
|
172
|
+
promptMode: sourceMeta?.promptMode || previousMeta?.promptMode || "direct",
|
|
173
|
+
createdAt: now,
|
|
174
|
+
canvasMergedAt: now,
|
|
175
|
+
canvasVersion: true,
|
|
176
|
+
canvasSourceFilename: sourceFilename,
|
|
177
|
+
canvasEditableFilename: filename,
|
|
178
|
+
};
|
|
179
|
+
await writeCanvasPng(ctx, filename, input.buffer, meta);
|
|
180
|
+
return toGenerateItem(filename, meta);
|
|
181
|
+
}
|
|
@@ -20,7 +20,7 @@ function extractText(json) {
|
|
|
20
20
|
return "";
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
async function requestJson({ oauthUrl, model, messages, timeoutMs, structured }) {
|
|
23
|
+
async function requestJson({ oauthUrl, model, messages, timeoutMs, structured, reasoningEffort }) {
|
|
24
24
|
const controller = new AbortController();
|
|
25
25
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
26
26
|
try {
|
|
@@ -28,6 +28,7 @@ async function requestJson({ oauthUrl, model, messages, timeoutMs, structured })
|
|
|
28
28
|
model,
|
|
29
29
|
input: messages,
|
|
30
30
|
stream: false,
|
|
31
|
+
...(reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}),
|
|
31
32
|
...(structured
|
|
32
33
|
? {
|
|
33
34
|
text: {
|
|
@@ -95,10 +96,11 @@ export async function requestCardNewsPlannerJson(input, options = {}) {
|
|
|
95
96
|
const oauthUrl = options.oauthUrl || `http://127.0.0.1:${config.oauth.proxyPort}`;
|
|
96
97
|
const model = options.model || config.cardNewsPlanner.model;
|
|
97
98
|
const timeoutMs = options.timeoutMs || config.cardNewsPlanner.timeoutMs;
|
|
99
|
+
const reasoningEffort = options.reasoningEffort || config.imageModels?.reasoningEffort || "medium";
|
|
98
100
|
let text = "";
|
|
99
101
|
let mode = "structured-output";
|
|
100
102
|
try {
|
|
101
|
-
text = await requestJson({ oauthUrl, model, messages: input.messages, timeoutMs, structured: true });
|
|
103
|
+
text = await requestJson({ oauthUrl, model, messages: input.messages, timeoutMs, structured: true, reasoningEffort });
|
|
102
104
|
} catch (err) {
|
|
103
105
|
if (err.code !== "PLANNER_UPSTREAM_FAILED") throw err;
|
|
104
106
|
mode = "json-mode";
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { access, readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, isAbsolute, join, relative } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const COMFY_ERROR = {
|
|
6
|
+
URL_NOT_LOCAL: "COMFY_URL_NOT_LOCAL",
|
|
7
|
+
IMAGE_INVALID: "COMFY_IMAGE_INVALID",
|
|
8
|
+
IMAGE_NOT_FOUND: "COMFY_IMAGE_NOT_FOUND",
|
|
9
|
+
UPLOAD_FAILED: "COMFY_UPLOAD_FAILED",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
13
|
+
|
|
14
|
+
class ComfyBridgeError extends Error {
|
|
15
|
+
constructor(code, message, status) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "ComfyBridgeError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.status = status;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function bridgeError(code, message, status) {
|
|
24
|
+
return new ComfyBridgeError(code, message, status);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isComfyBridgeError(error) {
|
|
28
|
+
return error instanceof ComfyBridgeError;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeComfyOrigin(rawUrl) {
|
|
32
|
+
if (typeof rawUrl !== "string" || !rawUrl.trim()) {
|
|
33
|
+
throw bridgeError(COMFY_ERROR.URL_NOT_LOCAL, "ComfyUI URL is not configured.", 400);
|
|
34
|
+
}
|
|
35
|
+
const trimmed = rawUrl.trim();
|
|
36
|
+
const rawHost = trimmed.match(/^http:\/\/(?:[^@/]+@)?(\[[^\]]+\]|[^/:?#]+)/i)?.[1] ?? "";
|
|
37
|
+
if (
|
|
38
|
+
rawHost === "localhost." ||
|
|
39
|
+
/^\d+$/.test(rawHost) ||
|
|
40
|
+
/^0x/i.test(rawHost) ||
|
|
41
|
+
/^0[0-9]+/.test(rawHost) ||
|
|
42
|
+
(/^[0-9.]+$/.test(rawHost) && rawHost.split(".").length !== 4) ||
|
|
43
|
+
rawHost.split(".").some((part) => part.length > 1 && part.startsWith("0"))
|
|
44
|
+
) {
|
|
45
|
+
throw bridgeError(COMFY_ERROR.URL_NOT_LOCAL, "ComfyUI URL is not local.", 400);
|
|
46
|
+
}
|
|
47
|
+
let url;
|
|
48
|
+
try {
|
|
49
|
+
url = new URL(trimmed);
|
|
50
|
+
} catch {
|
|
51
|
+
throw bridgeError(COMFY_ERROR.URL_NOT_LOCAL, "ComfyUI URL is invalid.", 400);
|
|
52
|
+
}
|
|
53
|
+
if (url.protocol !== "http:") {
|
|
54
|
+
throw bridgeError(COMFY_ERROR.URL_NOT_LOCAL, "ComfyUI URL must use HTTP.", 400);
|
|
55
|
+
}
|
|
56
|
+
if (url.username || url.password || !url.port) {
|
|
57
|
+
throw bridgeError(COMFY_ERROR.URL_NOT_LOCAL, "ComfyUI URL is not local.", 400);
|
|
58
|
+
}
|
|
59
|
+
if (!LOCAL_HOSTS.has(url.hostname)) {
|
|
60
|
+
throw bridgeError(COMFY_ERROR.URL_NOT_LOCAL, "ComfyUI URL is not local.", 400);
|
|
61
|
+
}
|
|
62
|
+
if (url.pathname !== "/" || url.search || url.hash) {
|
|
63
|
+
throw bridgeError(COMFY_ERROR.URL_NOT_LOCAL, "ComfyUI URL must be an origin.", 400);
|
|
64
|
+
}
|
|
65
|
+
return url.origin;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasEncodedSeparator(filename) {
|
|
69
|
+
try {
|
|
70
|
+
const decoded = decodeURIComponent(filename);
|
|
71
|
+
return decoded.includes("/") || decoded.includes("\\");
|
|
72
|
+
} catch {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validateFilename(filename) {
|
|
78
|
+
if (typeof filename !== "string" || !filename.trim()) {
|
|
79
|
+
throw bridgeError(COMFY_ERROR.IMAGE_INVALID, "A generated filename is required.", 400);
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
isAbsolute(filename) ||
|
|
83
|
+
filename !== basename(filename) ||
|
|
84
|
+
filename.includes("/") ||
|
|
85
|
+
filename.includes("\\") ||
|
|
86
|
+
filename.includes("..") ||
|
|
87
|
+
/^[a-z][a-z0-9+.-]*:/i.test(filename) ||
|
|
88
|
+
hasEncodedSeparator(filename)
|
|
89
|
+
) {
|
|
90
|
+
throw bridgeError(COMFY_ERROR.IMAGE_INVALID, "Generated filename is invalid.", 400);
|
|
91
|
+
}
|
|
92
|
+
return filename;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isInsideDirectory(parent, candidate) {
|
|
96
|
+
const rel = relative(parent, candidate);
|
|
97
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sniffImage(buffer) {
|
|
101
|
+
if (
|
|
102
|
+
buffer.length >= 8 &&
|
|
103
|
+
buffer[0] === 0x89 &&
|
|
104
|
+
buffer[1] === 0x50 &&
|
|
105
|
+
buffer[2] === 0x4e &&
|
|
106
|
+
buffer[3] === 0x47 &&
|
|
107
|
+
buffer[4] === 0x0d &&
|
|
108
|
+
buffer[5] === 0x0a &&
|
|
109
|
+
buffer[6] === 0x1a &&
|
|
110
|
+
buffer[7] === 0x0a
|
|
111
|
+
) {
|
|
112
|
+
return { ext: "png", mime: "image/png" };
|
|
113
|
+
}
|
|
114
|
+
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
|
115
|
+
return { ext: "jpg", mime: "image/jpeg" };
|
|
116
|
+
}
|
|
117
|
+
if (
|
|
118
|
+
buffer.length >= 12 &&
|
|
119
|
+
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
120
|
+
buffer.subarray(8, 12).toString("ascii") === "WEBP"
|
|
121
|
+
) {
|
|
122
|
+
return { ext: "webp", mime: "image/webp" };
|
|
123
|
+
}
|
|
124
|
+
throw bridgeError(COMFY_ERROR.IMAGE_INVALID, "Generated file is not a supported image.", 400);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sanitizeBaseName(filename) {
|
|
128
|
+
const raw = basename(filename, extname(filename));
|
|
129
|
+
const safe = raw.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
|
|
130
|
+
return safe || "image";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function readGeneratedImage(ctx, filename) {
|
|
134
|
+
const safeFilename = validateFilename(filename);
|
|
135
|
+
const generatedDir = await realpath(ctx.config.storage.generatedDir);
|
|
136
|
+
const candidatePath = join(ctx.config.storage.generatedDir, safeFilename);
|
|
137
|
+
try {
|
|
138
|
+
await access(candidatePath, fsConstants.F_OK);
|
|
139
|
+
} catch {
|
|
140
|
+
throw bridgeError(COMFY_ERROR.IMAGE_NOT_FOUND, "Generated image was not found.", 404);
|
|
141
|
+
}
|
|
142
|
+
let candidateReal;
|
|
143
|
+
try {
|
|
144
|
+
candidateReal = await realpath(candidatePath);
|
|
145
|
+
} catch {
|
|
146
|
+
throw bridgeError(COMFY_ERROR.IMAGE_NOT_FOUND, "Generated image was not found.", 404);
|
|
147
|
+
}
|
|
148
|
+
if (!isInsideDirectory(generatedDir, candidateReal)) {
|
|
149
|
+
throw bridgeError(COMFY_ERROR.IMAGE_INVALID, "Generated filename is invalid.", 400);
|
|
150
|
+
}
|
|
151
|
+
const info = await stat(candidateReal);
|
|
152
|
+
if (!info.isFile()) {
|
|
153
|
+
throw bridgeError(COMFY_ERROR.IMAGE_INVALID, "Generated filename is invalid.", 400);
|
|
154
|
+
}
|
|
155
|
+
if (info.size > ctx.config.comfy.maxUploadBytes) {
|
|
156
|
+
throw bridgeError(COMFY_ERROR.IMAGE_INVALID, "Generated image is too large.", 400);
|
|
157
|
+
}
|
|
158
|
+
const buffer = await readFile(candidateReal);
|
|
159
|
+
const imageType = sniffImage(buffer);
|
|
160
|
+
return {
|
|
161
|
+
buffer,
|
|
162
|
+
imageType,
|
|
163
|
+
sourceFilename: safeFilename,
|
|
164
|
+
uploadFilename: `ima2_${Date.now()}_${sanitizeBaseName(safeFilename)}.${imageType.ext}`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function postToComfy(origin, image, timeoutMs, fetchImpl = fetch) {
|
|
169
|
+
const controller = new AbortController();
|
|
170
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
171
|
+
try {
|
|
172
|
+
const form = new FormData();
|
|
173
|
+
form.append("image", new Blob([image.buffer], { type: image.imageType.mime }), image.uploadFilename);
|
|
174
|
+
form.append("type", "input");
|
|
175
|
+
const res = await fetchImpl(`${origin}/upload/image`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
body: form,
|
|
178
|
+
redirect: "manual",
|
|
179
|
+
signal: controller.signal,
|
|
180
|
+
});
|
|
181
|
+
if (res.status >= 300 && res.status < 400) {
|
|
182
|
+
throw bridgeError(COMFY_ERROR.UPLOAD_FAILED, "Could not upload image to ComfyUI.", 502);
|
|
183
|
+
}
|
|
184
|
+
if (!res.ok) {
|
|
185
|
+
throw bridgeError(COMFY_ERROR.UPLOAD_FAILED, "Could not upload image to ComfyUI.", 502);
|
|
186
|
+
}
|
|
187
|
+
const data = await res.json().catch(() => null);
|
|
188
|
+
if (!data || typeof data.name !== "string" || !data.name.trim()) {
|
|
189
|
+
throw bridgeError(COMFY_ERROR.UPLOAD_FAILED, "Could not upload image to ComfyUI.", 502);
|
|
190
|
+
}
|
|
191
|
+
return data.name;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (isComfyBridgeError(error)) throw error;
|
|
194
|
+
throw bridgeError(COMFY_ERROR.UPLOAD_FAILED, "Could not upload image to ComfyUI.", 502);
|
|
195
|
+
} finally {
|
|
196
|
+
clearTimeout(timeout);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function exportImageToComfy(ctx, input, options = {}) {
|
|
201
|
+
const origin = normalizeComfyOrigin(options.comfyUrl ?? ctx.config.comfy.defaultUrl);
|
|
202
|
+
const image = await readGeneratedImage(ctx, input.filename);
|
|
203
|
+
const uploadedFilename = await postToComfy(
|
|
204
|
+
origin,
|
|
205
|
+
image,
|
|
206
|
+
ctx.config.comfy.uploadTimeoutMs,
|
|
207
|
+
options.fetchImpl,
|
|
208
|
+
);
|
|
209
|
+
return {
|
|
210
|
+
ok: true,
|
|
211
|
+
sourceFilename: image.sourceFilename,
|
|
212
|
+
uploadedFilename,
|
|
213
|
+
};
|
|
214
|
+
}
|
package/lib/db.js
CHANGED
|
@@ -129,6 +129,20 @@ function migrate(database) {
|
|
|
129
129
|
UNIQUE(browser_id, filename)
|
|
130
130
|
);
|
|
131
131
|
|
|
132
|
+
CREATE TABLE IF NOT EXISTS image_annotations (
|
|
133
|
+
id TEXT PRIMARY KEY,
|
|
134
|
+
browser_id TEXT NOT NULL,
|
|
135
|
+
filename TEXT NOT NULL,
|
|
136
|
+
payload TEXT NOT NULL,
|
|
137
|
+
schema_version INTEGER NOT NULL DEFAULT 1,
|
|
138
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
139
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
140
|
+
UNIQUE(browser_id, filename)
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
CREATE INDEX IF NOT EXISTS idx_image_annotations_filename
|
|
144
|
+
ON image_annotations(filename);
|
|
145
|
+
|
|
132
146
|
INSERT OR IGNORE INTO prompt_folders (id, parent_id, name) VALUES
|
|
133
147
|
('__root__', '__root__', '__root__'),
|
|
134
148
|
('__trash__', '__root__', '__trash__');
|
package/lib/historyList.js
CHANGED
|
@@ -51,6 +51,10 @@ export async function listHistoryRows(baseDir = config.storage.generatedDir) {
|
|
|
51
51
|
clientNodeId: meta?.clientNodeId || null,
|
|
52
52
|
requestId: meta?.requestId || null,
|
|
53
53
|
kind: meta?.kind || null,
|
|
54
|
+
canvasVersion: Boolean(meta?.canvasVersion),
|
|
55
|
+
canvasSourceFilename: meta?.canvasSourceFilename || null,
|
|
56
|
+
canvasEditableFilename: meta?.canvasEditableFilename || null,
|
|
57
|
+
canvasMergedAt: Number.isFinite(meta?.canvasMergedAt) ? meta.canvasMergedAt : null,
|
|
54
58
|
setId: meta?.setId || null,
|
|
55
59
|
cardId: meta?.cardId || null,
|
|
56
60
|
cardOrder: Number.isFinite(meta?.cardOrder) ? meta.cardOrder : null,
|
package/lib/imageMetadata.js
CHANGED
|
@@ -39,6 +39,10 @@ export function buildIma2MetadataPayload(meta = {}, context = {}) {
|
|
|
39
39
|
version: stringOrNull(context.version, 80) || null,
|
|
40
40
|
createdAt: numberOrNull(meta.createdAt) || Date.now(),
|
|
41
41
|
kind: stringOrNull(meta.kind, 80),
|
|
42
|
+
canvasVersion: Boolean(meta.canvasVersion),
|
|
43
|
+
canvasSourceFilename: stringOrNull(meta.canvasSourceFilename, 240),
|
|
44
|
+
canvasEditableFilename: stringOrNull(meta.canvasEditableFilename, 240),
|
|
45
|
+
canvasMergedAt: numberOrNull(meta.canvasMergedAt),
|
|
42
46
|
prompt: stringOrNull(meta.prompt),
|
|
43
47
|
userPrompt: stringOrNull(meta.userPrompt) || stringOrNull(meta.prompt),
|
|
44
48
|
revisedPrompt: stringOrNull(meta.revisedPrompt),
|
package/lib/imageModels.js
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
const FALLBACK_IMAGE_MODEL = "gpt-5.4-mini";
|
|
2
2
|
const VALID_IMAGE_MODELS = new Set(["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]);
|
|
3
3
|
const UNSUPPORTED_IMAGE_MODELS = new Set(["gpt-5.3-codex-spark"]);
|
|
4
|
+
const FALLBACK_REASONING_EFFORT = "medium";
|
|
5
|
+
const VALID_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
|
|
6
|
+
|
|
7
|
+
export function normalizeReasoningEffort(ctx, rawEffort) {
|
|
8
|
+
const configured = ctx?.config?.imageModels;
|
|
9
|
+
const fallback = configured?.reasoningEffort ?? FALLBACK_REASONING_EFFORT;
|
|
10
|
+
const valid = configured?.validReasoningEfforts ?? VALID_REASONING_EFFORTS;
|
|
11
|
+
|
|
12
|
+
if (typeof rawEffort !== "string" || rawEffort.length === 0) {
|
|
13
|
+
return { effort: valid.has(fallback) ? fallback : FALLBACK_REASONING_EFFORT };
|
|
14
|
+
}
|
|
15
|
+
if (!valid.has(rawEffort)) {
|
|
16
|
+
return {
|
|
17
|
+
error: "reasoningEffort must be one of: low, medium, high, xhigh",
|
|
18
|
+
code: "INVALID_REASONING_EFFORT",
|
|
19
|
+
status: 400,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return { effort: rawEffort };
|
|
23
|
+
}
|
|
4
24
|
|
|
5
25
|
export function normalizeImageModel(ctx, rawModel) {
|
|
6
26
|
const configured = ctx?.config?.imageModels;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
2
|
+
import { basename, join, normalize } from "path";
|
|
3
|
+
import { randomBytes } from "crypto";
|
|
4
|
+
import { embedImageMetadataBestEffort } from "./imageMetadataStore.js";
|
|
5
|
+
|
|
6
|
+
const PNG_SIGNATURE_HEX = "89504e470d0a1a0a";
|
|
7
|
+
const JPEG_SIGNATURE_HEX = "ffd8ff";
|
|
8
|
+
const WEBP_RIFF_HEAD = "52494646";
|
|
9
|
+
const WEBP_VP_TAIL = "57454250";
|
|
10
|
+
|
|
11
|
+
function detectFormat(buffer) {
|
|
12
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null;
|
|
13
|
+
const head8 = buffer.subarray(0, 8).toString("hex");
|
|
14
|
+
if (head8 === PNG_SIGNATURE_HEX) return "png";
|
|
15
|
+
if (head8.startsWith(JPEG_SIGNATURE_HEX)) return "jpeg";
|
|
16
|
+
if (
|
|
17
|
+
buffer.subarray(0, 4).toString("hex") === WEBP_RIFF_HEAD &&
|
|
18
|
+
buffer.subarray(8, 12).toString("hex") === WEBP_VP_TAIL
|
|
19
|
+
) {
|
|
20
|
+
return "webp";
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ensureInsideGeneratedDir(generatedDir, filename) {
|
|
26
|
+
const full = normalize(join(generatedDir, filename));
|
|
27
|
+
const root = normalize(generatedDir);
|
|
28
|
+
if (!full.startsWith(root)) {
|
|
29
|
+
const err = new Error("Imported path escapes generated directory");
|
|
30
|
+
err.status = 400;
|
|
31
|
+
err.code = "IMPORT_PATH_ESCAPE";
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
return full;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeImportedFilename(format) {
|
|
38
|
+
const stamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
|
39
|
+
const rand = randomBytes(3).toString("hex");
|
|
40
|
+
return `imported-${stamp}-${rand}.${format}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function safeOriginalName(input) {
|
|
44
|
+
if (typeof input !== "string" || !input) return null;
|
|
45
|
+
const trimmed = input.slice(0, 200);
|
|
46
|
+
return basename(trimmed);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function createLocalImport(ctx, { buffer, originalFilename }) {
|
|
50
|
+
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
|
51
|
+
const err = new Error("Image body is required");
|
|
52
|
+
err.status = 400;
|
|
53
|
+
err.code = "EMPTY_IMPORT";
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
const format = detectFormat(buffer);
|
|
57
|
+
if (!format) {
|
|
58
|
+
const err = new Error("Only PNG, JPEG, or WebP is supported");
|
|
59
|
+
err.status = 400;
|
|
60
|
+
err.code = "IMPORT_BAD_FORMAT";
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
const filename = makeImportedFilename(format);
|
|
64
|
+
const fullPath = ensureInsideGeneratedDir(ctx.config.storage.generatedDir, filename);
|
|
65
|
+
await mkdir(ctx.config.storage.generatedDir, { recursive: true });
|
|
66
|
+
|
|
67
|
+
const meta = {
|
|
68
|
+
schema: "ima2.generation.v1",
|
|
69
|
+
app: "ima2-gen",
|
|
70
|
+
version: ctx.packageVersion,
|
|
71
|
+
createdAt: Date.now(),
|
|
72
|
+
kind: "imported",
|
|
73
|
+
canvasVersion: false,
|
|
74
|
+
originalFilename: safeOriginalName(originalFilename),
|
|
75
|
+
format,
|
|
76
|
+
};
|
|
77
|
+
const embedded = await embedImageMetadataBestEffort(buffer, format, meta, {
|
|
78
|
+
version: ctx.packageVersion,
|
|
79
|
+
});
|
|
80
|
+
await writeFile(fullPath, embedded.buffer);
|
|
81
|
+
await writeFile(`${fullPath}.json`, JSON.stringify(meta)).catch(() => {});
|
|
82
|
+
|
|
83
|
+
const url = `/generated/${encodeURIComponent(filename)}`;
|
|
84
|
+
return {
|
|
85
|
+
filename,
|
|
86
|
+
url,
|
|
87
|
+
image: url,
|
|
88
|
+
thumb: url,
|
|
89
|
+
createdAt: meta.createdAt,
|
|
90
|
+
format,
|
|
91
|
+
kind: "imported",
|
|
92
|
+
canvasVersion: false,
|
|
93
|
+
canvasSourceFilename: null,
|
|
94
|
+
canvasEditableFilename: null,
|
|
95
|
+
prompt: null,
|
|
96
|
+
userPrompt: null,
|
|
97
|
+
revisedPrompt: null,
|
|
98
|
+
promptMode: null,
|
|
99
|
+
quality: null,
|
|
100
|
+
size: null,
|
|
101
|
+
model: null,
|
|
102
|
+
provider: null,
|
|
103
|
+
usage: null,
|
|
104
|
+
webSearchCalls: 0,
|
|
105
|
+
sessionId: null,
|
|
106
|
+
nodeId: null,
|
|
107
|
+
parentNodeId: null,
|
|
108
|
+
refsCount: 0,
|
|
109
|
+
isFavorite: false,
|
|
110
|
+
};
|
|
111
|
+
}
|