ima2-gen 1.1.5 → 1.1.7
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/config.js +58 -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/assetLifecycle.js +21 -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 +9 -0
- package/lib/imageMetadata.js +4 -0
- package/lib/imageModels.js +20 -0
- package/lib/oauthProxy.js +341 -32
- package/lib/pngInfo.js +26 -0
- package/lib/promptImport/errors.js +16 -0
- package/lib/promptImport/githubSource.js +205 -0
- package/lib/promptImport/parsePromptCandidates.js +140 -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 +74 -26
- package/routes/generate.js +18 -25
- package/routes/history.js +11 -1
- package/routes/index.js +10 -0
- package/routes/multimode.js +281 -0
- package/routes/nodes.js +28 -26
- package/routes/promptImport.js +175 -0
- package/ui/dist/assets/index-DARPdT4Q.css +1 -0
- package/ui/dist/assets/index-ht80GMq4.js +31 -0
- package/ui/dist/assets/index-ht80GMq4.js.map +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-0SyTGr-u.js +0 -25
- package/ui/dist/assets/index-0SyTGr-u.js.map +0 -1
- package/ui/dist/assets/index-DfiV508Q.css +0 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { promptImportError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
const ALLOWED_HOSTS = new Set(["github.com", "raw.githubusercontent.com"]);
|
|
4
|
+
const SUPPORTED_EXTENSIONS = new Set(["md", "markdown", "txt"]);
|
|
5
|
+
const OWNER_REPO_RE = /^[A-Za-z0-9_.-]+$/;
|
|
6
|
+
|
|
7
|
+
function safeDecode(value) {
|
|
8
|
+
try {
|
|
9
|
+
return decodeURIComponent(value);
|
|
10
|
+
} catch {
|
|
11
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "Invalid encoded GitHub path");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assertCleanPath(path) {
|
|
16
|
+
const lower = path.toLowerCase();
|
|
17
|
+
if (path.includes("\0") || lower.includes("%00")) {
|
|
18
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub path contains a null byte");
|
|
19
|
+
}
|
|
20
|
+
if (/%2f|%5c/i.test(path)) {
|
|
21
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub path contains an encoded slash");
|
|
22
|
+
}
|
|
23
|
+
const decoded = safeDecode(path);
|
|
24
|
+
if (decoded.includes("\\") || decoded.split("/").includes("..")) {
|
|
25
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub path traversal is not allowed");
|
|
26
|
+
}
|
|
27
|
+
return decoded.replace(/^\/+/, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function extensionForPath(path) {
|
|
31
|
+
const match = /\.([A-Za-z0-9]+)$/.exec(path);
|
|
32
|
+
return match?.[1]?.toLowerCase() ?? "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertSupportedFilePath(path) {
|
|
36
|
+
const ext = extensionForPath(path);
|
|
37
|
+
if (!ext) {
|
|
38
|
+
throw promptImportError("FOLDER_IMPORT_DEFERRED", "Folder import is planned for a later version", 422);
|
|
39
|
+
}
|
|
40
|
+
if (!SUPPORTED_EXTENSIONS.has(ext)) {
|
|
41
|
+
throw promptImportError("UNSUPPORTED_EXTENSION", "Only .md, .markdown, and .txt files are supported");
|
|
42
|
+
}
|
|
43
|
+
return ext;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assertOwnerRepo(owner, repo) {
|
|
47
|
+
if (!OWNER_REPO_RE.test(owner || "") || !OWNER_REPO_RE.test(repo || "")) {
|
|
48
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "Invalid GitHub owner or repository");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeUrlInput(input) {
|
|
53
|
+
let url;
|
|
54
|
+
try {
|
|
55
|
+
url = new URL(input);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
60
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "Only http(s) GitHub URLs are supported");
|
|
61
|
+
}
|
|
62
|
+
if (!ALLOWED_HOSTS.has(url.hostname)) {
|
|
63
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "Only GitHub file URLs are supported");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
67
|
+
if (url.hostname === "github.com") {
|
|
68
|
+
const [owner, repo, marker, ref, ...pathParts] = parts;
|
|
69
|
+
assertOwnerRepo(owner, repo);
|
|
70
|
+
if (marker !== "blob") {
|
|
71
|
+
throw promptImportError("FOLDER_IMPORT_DEFERRED", "Only GitHub file URLs are supported in PR1", 422);
|
|
72
|
+
}
|
|
73
|
+
if (!ref || pathParts.length === 0) {
|
|
74
|
+
throw promptImportError("FOLDER_IMPORT_DEFERRED", "Folder import is planned for a later version", 422);
|
|
75
|
+
}
|
|
76
|
+
const path = assertCleanPath(pathParts.join("/"));
|
|
77
|
+
const ext = assertSupportedFilePath(path);
|
|
78
|
+
return {
|
|
79
|
+
kind: "github",
|
|
80
|
+
owner,
|
|
81
|
+
repo,
|
|
82
|
+
ref: safeDecode(ref),
|
|
83
|
+
path,
|
|
84
|
+
extension: ext,
|
|
85
|
+
htmlUrl: url.toString(),
|
|
86
|
+
rawUrl: `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(safeDecode(ref))}/${path}`,
|
|
87
|
+
tags: ["github", `repo:${owner}/${repo}`, `ref:${safeDecode(ref)}`, `file:${path.split("/").pop()}`, `ext:${ext}`],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const [owner, repo, ref, ...pathParts] = parts;
|
|
92
|
+
assertOwnerRepo(owner, repo);
|
|
93
|
+
if (!ref || pathParts.length === 0) {
|
|
94
|
+
throw promptImportError("FOLDER_IMPORT_DEFERRED", "Folder import is planned for a later version", 422);
|
|
95
|
+
}
|
|
96
|
+
const path = assertCleanPath(pathParts.join("/"));
|
|
97
|
+
const ext = assertSupportedFilePath(path);
|
|
98
|
+
return {
|
|
99
|
+
kind: "github",
|
|
100
|
+
owner,
|
|
101
|
+
repo,
|
|
102
|
+
ref: safeDecode(ref),
|
|
103
|
+
path,
|
|
104
|
+
extension: ext,
|
|
105
|
+
htmlUrl: `https://github.com/${owner}/${repo}/blob/${safeDecode(ref)}/${path}`,
|
|
106
|
+
rawUrl: url.toString(),
|
|
107
|
+
tags: ["github", `repo:${owner}/${repo}`, `ref:${safeDecode(ref)}`, `file:${path.split("/").pop()}`, `ext:${ext}`],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeShorthand(input) {
|
|
112
|
+
const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:@([^:]+))?:(.+)$/.exec(input);
|
|
113
|
+
if (!match) {
|
|
114
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "Enter a GitHub file URL or owner/repo:path");
|
|
115
|
+
}
|
|
116
|
+
const [, owner, repo, rawRef, rawPath] = match;
|
|
117
|
+
assertOwnerRepo(owner, repo);
|
|
118
|
+
const ref = rawRef ? safeDecode(rawRef.trim()) : "main";
|
|
119
|
+
if (ref.includes("/")) {
|
|
120
|
+
throw promptImportError("AMBIGUOUS_GITHUB_REF", "Branches with slashes need GitHub API folder support planned for PR3");
|
|
121
|
+
}
|
|
122
|
+
const path = assertCleanPath(rawPath.trim());
|
|
123
|
+
const ext = assertSupportedFilePath(path);
|
|
124
|
+
return {
|
|
125
|
+
kind: "github",
|
|
126
|
+
owner,
|
|
127
|
+
repo,
|
|
128
|
+
ref,
|
|
129
|
+
path,
|
|
130
|
+
extension: ext,
|
|
131
|
+
htmlUrl: `https://github.com/${owner}/${repo}/blob/${ref}/${path}`,
|
|
132
|
+
rawUrl: `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(ref)}/${path}`,
|
|
133
|
+
tags: ["github", `repo:${owner}/${repo}`, `ref:${ref}`, `file:${path.split("/").pop()}`, `ext:${ext}`],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateFinalFetchUrl(rawUrl) {
|
|
138
|
+
let url;
|
|
139
|
+
try {
|
|
140
|
+
url = new URL(rawUrl);
|
|
141
|
+
} catch {
|
|
142
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub fetch returned an invalid final URL");
|
|
143
|
+
}
|
|
144
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
145
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub fetch returned an unsupported protocol");
|
|
146
|
+
}
|
|
147
|
+
if (!ALLOWED_HOSTS.has(url.hostname)) {
|
|
148
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub fetch redirected to an unsupported host");
|
|
149
|
+
}
|
|
150
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
151
|
+
if (url.hostname === "github.com") {
|
|
152
|
+
const marker = parts[2];
|
|
153
|
+
if (parts.length < 5 || marker !== "blob") {
|
|
154
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub fetch redirected to a non-file page");
|
|
155
|
+
}
|
|
156
|
+
const finalPath = assertCleanPath(parts.slice(4).join("/"));
|
|
157
|
+
assertSupportedFilePath(finalPath);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (parts.length < 4) {
|
|
161
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub fetch returned a non-file path");
|
|
162
|
+
}
|
|
163
|
+
const finalPath = assertCleanPath(parts.slice(3).join("/"));
|
|
164
|
+
assertSupportedFilePath(finalPath);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function normalizeGitHubSource(input) {
|
|
168
|
+
const trimmed = typeof input === "string" ? input.trim() : "";
|
|
169
|
+
if (!trimmed) {
|
|
170
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", "GitHub source is required");
|
|
171
|
+
}
|
|
172
|
+
return normalizeUrlInput(trimmed) ?? normalizeShorthand(trimmed);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function fetchGitHubSourceText(source, limits) {
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
const timer = setTimeout(() => controller.abort(), limits.fetchTimeoutMs);
|
|
178
|
+
try {
|
|
179
|
+
const response = await fetch(source.rawUrl, { signal: controller.signal });
|
|
180
|
+
validateFinalFetchUrl(response.url);
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
throw promptImportError("INVALID_GITHUB_SOURCE", `GitHub file fetch failed with ${response.status}`, 422);
|
|
183
|
+
}
|
|
184
|
+
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
185
|
+
if (contentLength > limits.maxFileBytesForPreview) {
|
|
186
|
+
throw promptImportError("REMOTE_FILE_TOO_LARGE", "Remote file is too large", 413);
|
|
187
|
+
}
|
|
188
|
+
const buffer = await response.arrayBuffer();
|
|
189
|
+
if (buffer.byteLength > limits.maxFileBytesForPreview) {
|
|
190
|
+
throw promptImportError("REMOTE_FILE_TOO_LARGE", "Remote file is too large", 413);
|
|
191
|
+
}
|
|
192
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(buffer);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error?.name === "AbortError") {
|
|
195
|
+
throw promptImportError("REMOTE_FETCH_TIMEOUT", "GitHub fetch timed out", 504);
|
|
196
|
+
}
|
|
197
|
+
throw error;
|
|
198
|
+
} finally {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function isSupportedPromptFileName(filename) {
|
|
204
|
+
return SUPPORTED_EXTENSIONS.has(extensionForPath(filename || ""));
|
|
205
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
|
|
3
|
+
function normalizeWhitespace(text) {
|
|
4
|
+
return text.replace(/\r\n/g, "\n").replace(/[ \t]+\n/g, "\n").trim();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function stripFrontmatter(text) {
|
|
8
|
+
return text.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isBoilerplate(line) {
|
|
12
|
+
const trimmed = line.trim();
|
|
13
|
+
return (
|
|
14
|
+
!trimmed ||
|
|
15
|
+
/^\[!\[.*\]\(.+\)\]\(.+\)$/.test(trimmed) ||
|
|
16
|
+
/^!\[.*\]\(.+\)$/.test(trimmed) ||
|
|
17
|
+
/^\[.*\]\(.+\)$/.test(trimmed)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function titleFromFilename(filename) {
|
|
22
|
+
return (filename || "Imported prompt")
|
|
23
|
+
.replace(/\.(txt|md|markdown)$/i, "")
|
|
24
|
+
.replace(/[-_]+/g, " ")
|
|
25
|
+
.trim() || "Imported prompt";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function candidateId(text, ordinal) {
|
|
29
|
+
return `candidate_${ordinal}_${createHash("sha256").update(text).digest("hex").slice(0, 10)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function headingName(heading, fallback) {
|
|
33
|
+
return heading?.replace(/^#+\s*/, "").trim() || fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function allowedCandidate(text, limits) {
|
|
37
|
+
const length = text.trim().length;
|
|
38
|
+
return length >= limits.minCandidateChars && length <= limits.maxCandidateChars;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cleanMarkdownBody(text) {
|
|
42
|
+
return normalizeWhitespace(
|
|
43
|
+
text
|
|
44
|
+
.split("\n")
|
|
45
|
+
.filter((line) => !isBoilerplate(line))
|
|
46
|
+
.filter((line) => !/^\|.*\|$/.test(line.trim()))
|
|
47
|
+
.join("\n"),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function pushCandidate(candidates, rawText, options) {
|
|
52
|
+
const text = normalizeWhitespace(rawText);
|
|
53
|
+
if (!allowedCandidate(text, options.limits)) return;
|
|
54
|
+
const ordinal = candidates.length + 1;
|
|
55
|
+
candidates.push({
|
|
56
|
+
id: candidateId(text, ordinal),
|
|
57
|
+
name: options.name,
|
|
58
|
+
text,
|
|
59
|
+
tags: [...new Set(options.tags)],
|
|
60
|
+
warnings: options.warnings ?? [],
|
|
61
|
+
source: options.source,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseMarkdown(text, options) {
|
|
66
|
+
const source = stripFrontmatter(text).slice(0, options.limits.maxSourceCharsScanned);
|
|
67
|
+
const fencePattern = /```([A-Za-z0-9_-]*)\n([\s\S]*?)```/g;
|
|
68
|
+
const acceptedFenceLanguages = new Set(["", "prompt", "text", "markdown", "md"]);
|
|
69
|
+
const ranges = [];
|
|
70
|
+
|
|
71
|
+
for (const match of source.matchAll(fencePattern)) {
|
|
72
|
+
const language = (match[1] || "").toLowerCase();
|
|
73
|
+
if (!acceptedFenceLanguages.has(language)) continue;
|
|
74
|
+
ranges.push([match.index ?? 0, (match.index ?? 0) + match[0].length]);
|
|
75
|
+
pushCandidate(options.candidates, match[2], {
|
|
76
|
+
...options,
|
|
77
|
+
name: `${options.baseName} ${options.candidates.length + 1}`,
|
|
78
|
+
});
|
|
79
|
+
if (options.candidates.length >= options.limits.maxPromptCandidatesPerFile) return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const withoutFences = source
|
|
83
|
+
.split("\n")
|
|
84
|
+
.filter((line, index, lines) => {
|
|
85
|
+
const offset = lines.slice(0, index).join("\n").length + (index > 0 ? 1 : 0);
|
|
86
|
+
return !ranges.some(([start, end]) => offset >= start && offset < end);
|
|
87
|
+
})
|
|
88
|
+
.join("\n");
|
|
89
|
+
const sections = withoutFences.split(/(?=^#{1,4}\s+)/gm);
|
|
90
|
+
for (const section of sections) {
|
|
91
|
+
const heading = /^#{1,4}\s+(.+)$/m.exec(section)?.[1];
|
|
92
|
+
const body = cleanMarkdownBody(section.replace(/^#{1,4}\s+.+$/m, ""));
|
|
93
|
+
pushCandidate(options.candidates, body, {
|
|
94
|
+
...options,
|
|
95
|
+
name: headingName(heading, `${options.baseName} ${options.candidates.length + 1}`),
|
|
96
|
+
});
|
|
97
|
+
if (options.candidates.length >= options.limits.maxPromptCandidatesPerFile) return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function splitTextPrompts(text) {
|
|
102
|
+
const normalized = normalizeWhitespace(text);
|
|
103
|
+
const separatorBlocks = normalized.split(/\n\s*---+\s*\n/g).filter(Boolean);
|
|
104
|
+
const blocks = separatorBlocks.length > 1
|
|
105
|
+
? separatorBlocks
|
|
106
|
+
: normalized.split(/\n\s*\n+/g).filter(Boolean);
|
|
107
|
+
const numbered = normalized.split(/(?=^\s*\d+[.)]\s+)/gm).filter(Boolean);
|
|
108
|
+
const longLines = normalized.split("\n").map((line) => line.trim()).filter((line) => line.length >= 80);
|
|
109
|
+
return blocks.length > 1 ? blocks : numbered.length > 1 ? numbered : longLines.length > 1 ? longLines : [normalized];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parsePlainText(text, options) {
|
|
113
|
+
const chunks = splitTextPrompts(text.slice(0, options.limits.maxSourceCharsScanned));
|
|
114
|
+
for (const chunk of chunks) {
|
|
115
|
+
const clean = chunk.replace(/^\s*\d+[.)]\s+/, "");
|
|
116
|
+
pushCandidate(options.candidates, clean, {
|
|
117
|
+
...options,
|
|
118
|
+
name: `${options.baseName} ${options.candidates.length + 1}`,
|
|
119
|
+
});
|
|
120
|
+
if (options.candidates.length >= options.limits.maxPromptCandidatesPerFile) return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function parsePromptCandidates({ text, filename, source, tags = [], limits }) {
|
|
125
|
+
const candidates = [];
|
|
126
|
+
const extension = (filename.split(".").pop() || "").toLowerCase();
|
|
127
|
+
const baseName = titleFromFilename(filename);
|
|
128
|
+
const common = {
|
|
129
|
+
candidates,
|
|
130
|
+
limits,
|
|
131
|
+
baseName,
|
|
132
|
+
tags,
|
|
133
|
+
source,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (extension === "txt") parsePlainText(text, common);
|
|
137
|
+
else parseMarkdown(text, common);
|
|
138
|
+
|
|
139
|
+
return candidates.slice(0, limits.maxPromptCandidatesPerFile);
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ima2-gen",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"description": "Local OAuth image generation studio with classic and node workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"test:package-install": "node --test tests/package-install-smoke.mjs",
|
|
19
19
|
"setup": "node bin/ima2.js setup",
|
|
20
20
|
"prepublishOnly": "npm test && npm run build && npm run test:package-install && npm run lint:pkg",
|
|
21
|
-
"lint:pkg": "node -e \"const p=require('./package.json'); const req=['name','version','bin']; for(const k of req){if(!p[k])throw new Error('missing '+k)} const mustInclude=['bin/','lib/','routes/','server.js']; for(const f of mustInclude){if(!p.files.includes(f))throw new Error('files[] must include '+f)}\"",
|
|
21
|
+
"lint:pkg": "node -e \"const p=require('./package.json'); const req=['name','version','bin']; for(const k of req){if(!p[k])throw new Error('missing '+k)} const mustInclude=['bin/','lib/','routes/','integrations/','server.js']; for(const f of mustInclude){if(!p.files.includes(f))throw new Error('files[] must include '+f)}\"",
|
|
22
22
|
"release:patch": "npm version patch && npm publish && git push origin main --tags",
|
|
23
23
|
"release:minor": "npm version minor && npm publish && git push origin main --tags",
|
|
24
24
|
"release:major": "npm version major && npm publish && git push origin main --tags"
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"bin/",
|
|
40
40
|
"lib/",
|
|
41
41
|
"routes/",
|
|
42
|
+
"integrations/",
|
|
42
43
|
"ui/dist/",
|
|
43
44
|
"docs/",
|
|
44
45
|
"assets/",
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { getDb } from "../lib/db.js";
|
|
2
|
+
|
|
3
|
+
const MAX_ANNOTATION_PAYLOAD_CHARS = 256 * 1024;
|
|
4
|
+
|
|
5
|
+
function getBrowserId(req) {
|
|
6
|
+
const browserId = req.headers["x-ima2-browser-id"];
|
|
7
|
+
return typeof browserId === "string" && browserId.trim() ? browserId.trim() : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isSafeFilename(filename) {
|
|
11
|
+
return (
|
|
12
|
+
typeof filename === "string" &&
|
|
13
|
+
filename.length > 0 &&
|
|
14
|
+
filename.length <= 240 &&
|
|
15
|
+
!filename.includes("..") &&
|
|
16
|
+
!filename.startsWith("/") &&
|
|
17
|
+
!filename.includes("\\")
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizePayload(value) {
|
|
22
|
+
const payload = value?.annotations ?? value;
|
|
23
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
24
|
+
return { error: "annotations payload is required" };
|
|
25
|
+
}
|
|
26
|
+
const paths = Array.isArray(payload.paths) ? payload.paths : [];
|
|
27
|
+
const boxes = Array.isArray(payload.boxes) ? payload.boxes : [];
|
|
28
|
+
const memos = Array.isArray(payload.memos) ? payload.memos : [];
|
|
29
|
+
const normalized = { paths, boxes, memos };
|
|
30
|
+
const text = JSON.stringify(normalized);
|
|
31
|
+
if (text.length > MAX_ANNOTATION_PAYLOAD_CHARS) {
|
|
32
|
+
return { error: "annotations payload is too large" };
|
|
33
|
+
}
|
|
34
|
+
return { payload: normalized, text };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registerAnnotationRoutes(app) {
|
|
38
|
+
app.get("/api/annotations/:filename", (req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const browserId = getBrowserId(req);
|
|
41
|
+
const filename = decodeURIComponent(req.params.filename);
|
|
42
|
+
if (!browserId) return res.status(400).json({ error: "X-Ima2-Browser-Id header is required" });
|
|
43
|
+
if (!isSafeFilename(filename)) return res.status(400).json({ error: "invalid filename" });
|
|
44
|
+
|
|
45
|
+
const row = getDb()
|
|
46
|
+
.prepare("SELECT payload FROM image_annotations WHERE browser_id = ? AND filename = ?")
|
|
47
|
+
.get(browserId, filename);
|
|
48
|
+
const annotations = row ? JSON.parse(row.payload) : null;
|
|
49
|
+
res.json({ annotations });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
res.status(500).json({ error: err.message });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
app.put("/api/annotations/:filename", (req, res) => {
|
|
56
|
+
try {
|
|
57
|
+
const browserId = getBrowserId(req);
|
|
58
|
+
const filename = decodeURIComponent(req.params.filename);
|
|
59
|
+
if (!browserId) return res.status(400).json({ error: "X-Ima2-Browser-Id header is required" });
|
|
60
|
+
if (!isSafeFilename(filename)) return res.status(400).json({ error: "invalid filename" });
|
|
61
|
+
|
|
62
|
+
const normalized = normalizePayload(req.body);
|
|
63
|
+
if (normalized.error) return res.status(400).json({ error: normalized.error });
|
|
64
|
+
|
|
65
|
+
const id = `${browserId}:${filename}`;
|
|
66
|
+
getDb().prepare(`
|
|
67
|
+
INSERT INTO image_annotations (id, browser_id, filename, payload, schema_version, updated_at)
|
|
68
|
+
VALUES (?, ?, ?, ?, 1, unixepoch())
|
|
69
|
+
ON CONFLICT(browser_id, filename) DO UPDATE SET
|
|
70
|
+
payload = excluded.payload,
|
|
71
|
+
schema_version = excluded.schema_version,
|
|
72
|
+
updated_at = unixepoch()
|
|
73
|
+
`).run(id, browserId, filename, normalized.text);
|
|
74
|
+
res.json({ ok: true });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
res.status(500).json({ error: err.message });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
app.delete("/api/annotations/:filename", (req, res) => {
|
|
81
|
+
try {
|
|
82
|
+
const browserId = getBrowserId(req);
|
|
83
|
+
const filename = decodeURIComponent(req.params.filename);
|
|
84
|
+
if (!browserId) return res.status(400).json({ error: "X-Ima2-Browser-Id header is required" });
|
|
85
|
+
if (!isSafeFilename(filename)) return res.status(400).json({ error: "invalid filename" });
|
|
86
|
+
|
|
87
|
+
getDb()
|
|
88
|
+
.prepare("DELETE FROM image_annotations WHERE browser_id = ? AND filename = ?")
|
|
89
|
+
.run(browserId, filename);
|
|
90
|
+
res.json({ ok: true });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
res.status(500).json({ error: err.message });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { createCanvasVersion, updateCanvasVersion } from "../lib/canvasVersionStore.js";
|
|
3
|
+
|
|
4
|
+
function decodeHeader(value) {
|
|
5
|
+
if (typeof value !== "string" || !value) return null;
|
|
6
|
+
try {
|
|
7
|
+
return decodeURIComponent(value);
|
|
8
|
+
} catch {
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getRequestBuffer(req) {
|
|
14
|
+
return Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getPrompt(req) {
|
|
18
|
+
return decodeHeader(req.headers["x-ima2-canvas-prompt"]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registerCanvasVersionRoutes(app, ctx) {
|
|
22
|
+
const rawPng = express.raw({ type: "image/png", limit: ctx.config.server.bodyLimit });
|
|
23
|
+
|
|
24
|
+
app.post("/api/canvas-versions", rawPng, async (req, res) => {
|
|
25
|
+
try {
|
|
26
|
+
const sourceFilename =
|
|
27
|
+
typeof req.query.sourceFilename === "string"
|
|
28
|
+
? req.query.sourceFilename
|
|
29
|
+
: decodeHeader(req.headers["x-ima2-canvas-source-filename"]);
|
|
30
|
+
const item = await createCanvasVersion(ctx, {
|
|
31
|
+
sourceFilename,
|
|
32
|
+
prompt: getPrompt(req),
|
|
33
|
+
buffer: getRequestBuffer(req),
|
|
34
|
+
});
|
|
35
|
+
res.status(201).json({ item });
|
|
36
|
+
} catch (err) {
|
|
37
|
+
res.status(err.status || 500).json({
|
|
38
|
+
error: err.message,
|
|
39
|
+
code: err.code || "CANVAS_VERSION_SAVE_FAILED",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
app.put("/api/canvas-versions/:filename", rawPng, async (req, res) => {
|
|
45
|
+
try {
|
|
46
|
+
const filename = decodeURIComponent(req.params.filename);
|
|
47
|
+
const sourceFilename =
|
|
48
|
+
typeof req.query.sourceFilename === "string"
|
|
49
|
+
? req.query.sourceFilename
|
|
50
|
+
: decodeHeader(req.headers["x-ima2-canvas-source-filename"]);
|
|
51
|
+
const item = await updateCanvasVersion(ctx, filename, {
|
|
52
|
+
sourceFilename,
|
|
53
|
+
prompt: getPrompt(req),
|
|
54
|
+
buffer: getRequestBuffer(req),
|
|
55
|
+
});
|
|
56
|
+
res.json({ item });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
res.status(err.status || 500).json({
|
|
59
|
+
error: err.message,
|
|
60
|
+
code: err.code || "CANVAS_VERSION_SAVE_FAILED",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
package/routes/comfy.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { exportImageToComfy, isComfyBridgeError } from "../lib/comfyBridge.js";
|
|
2
|
+
|
|
3
|
+
const ALLOWED_BODY_KEYS = new Set(["filename"]);
|
|
4
|
+
|
|
5
|
+
function hasExactBodyShape(body) {
|
|
6
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return false;
|
|
7
|
+
const keys = Object.keys(body);
|
|
8
|
+
return keys.length === 1 && ALLOWED_BODY_KEYS.has(keys[0]) && typeof body.filename === "string";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function errorPayload(code, message) {
|
|
12
|
+
return {
|
|
13
|
+
ok: false,
|
|
14
|
+
error: { code, message },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function registerComfyRoutes(app, ctx) {
|
|
19
|
+
app.post("/api/comfy/export-image", async (req, res) => {
|
|
20
|
+
try {
|
|
21
|
+
if (!hasExactBodyShape(req.body)) {
|
|
22
|
+
return res.status(400).json(errorPayload(
|
|
23
|
+
"COMFY_IMAGE_INVALID",
|
|
24
|
+
"Request body must contain exactly one filename.",
|
|
25
|
+
));
|
|
26
|
+
}
|
|
27
|
+
const result = await exportImageToComfy(ctx, { filename: req.body.filename });
|
|
28
|
+
return res.json(result);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (isComfyBridgeError(error)) {
|
|
31
|
+
return res.status(error.status).json(errorPayload(error.code, error.message));
|
|
32
|
+
}
|
|
33
|
+
return res.status(502).json(errorPayload(
|
|
34
|
+
"COMFY_UPLOAD_FAILED",
|
|
35
|
+
"Could not upload image to ComfyUI.",
|
|
36
|
+
));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|