dsh-image-gen 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +148 -133
- package/README.md +148 -133
- package/docs/assets/readme/canvas-edit.webp +0 -0
- package/docs/assets/readme/canvas-sketch.webp +0 -0
- package/docs/assets/readme/chat-batch.webp +0 -0
- package/docs/assets/readme/config.webp +0 -0
- package/docs/assets/readme/hero-batch.webp +0 -0
- package/docs/assets/readme/hero-canvas.webp +0 -0
- package/docs/assets/readme/studio-ref.webp +0 -0
- package/docs/assets/readme/studio.webp +0 -0
- package/lib/client.js +10848 -6335
- package/lib/index.js +2625 -145
- package/lib/types/capabilities.d.ts +10 -6
- package/lib/types/client/browser-image-utils.d.ts +6 -0
- package/lib/types/client/conversation-image-revisions.d.ts +3 -0
- package/lib/types/client/conversation-regenerate.d.ts +9 -1
- package/lib/types/client/datalist-scroll-fix.d.ts +32 -0
- package/lib/types/client/gallery-batch-bar.d.ts +8 -0
- package/lib/types/client/gallery-card.d.ts +21 -0
- package/lib/types/client/gallery-format.d.ts +7 -0
- package/lib/types/client/gallery-i18n.d.ts +236 -0
- package/lib/types/client/gallery-media.d.ts +58 -0
- package/lib/types/client/gallery-merge.d.ts +15 -0
- package/lib/types/client/gallery-store.d.ts +23 -2
- package/lib/types/client/gallery-thumbs.d.ts +24 -0
- package/lib/types/client/gallery-view.d.ts +0 -207
- package/lib/types/client/gif-card.d.ts +67 -0
- package/lib/types/client/gif-comments.d.ts +95 -0
- package/lib/types/client/gif-controls.d.ts +22 -0
- package/lib/types/client/gif-edit.d.ts +87 -0
- package/lib/types/client/gif-filmstrip.d.ts +23 -0
- package/lib/types/client/gif-gallery.d.ts +39 -0
- package/lib/types/client/gif-i18n.d.ts +159 -0
- package/lib/types/client/gif-menus.d.ts +36 -0
- package/lib/types/client/gif-player.d.ts +131 -0
- package/lib/types/client/gif-sheet.d.ts +116 -0
- package/lib/types/client/gif-style.d.ts +2 -0
- package/lib/types/client/image-result-node.d.ts +19 -1
- package/lib/types/client/image-result-view.d.ts +26 -0
- package/lib/types/client/index.d.ts +1 -20
- package/lib/types/client/plugin-i18n.d.ts +375 -0
- package/lib/types/client/plugin-style.d.ts +1 -0
- package/lib/types/client/provider-pill.d.ts +9 -0
- package/lib/types/client/studio-helpers.d.ts +63 -0
- package/lib/types/client/studio-session.d.ts +54 -0
- package/lib/types/client/studio-style.d.ts +1 -1
- package/lib/types/client/studio-view.d.ts +6 -7
- package/lib/types/client/tl/gallery-image-transfer.d.ts +1 -1
- package/lib/types/config.d.ts +12 -0
- package/lib/types/gif-info.d.ts +32 -0
- package/lib/types/import-route.d.ts +11 -0
- package/lib/types/multipart.d.ts +29 -0
- package/lib/types/openai-compatible.d.ts +8 -0
- package/lib/types/openai-output.d.ts +20 -1
- package/lib/types/request-cache.d.ts +41 -0
- package/lib/types/shared.d.ts +57 -3
- package/lib/types/storage-stats.d.ts +34 -0
- package/lib/types/studio-route.d.ts +2 -0
- package/lib/types/studio.d.ts +9 -0
- package/lib/types/subscription/vendors/codex.d.ts +4 -3
- package/lib/types/thumb-route.d.ts +25 -0
- package/lib/types/vault-registry.d.ts +66 -0
- package/lib/types/vault-route.d.ts +8 -0
- package/lib/types/vault-store.d.ts +33 -0
- package/lib/types/zip-progress.d.ts +18 -0
- package/lib/types/zip-route.d.ts +25 -0
- package/lib/types/zip-stream.d.ts +43 -0
- package/package.json +2 -2
- package/lib/types/client/tl/tl-trace-bridge.d.ts +0 -55
- package/lib/types/client/tl/trace-playback.d.ts +0 -101
- package/lib/types/trace-drawing.d.ts +0 -102
- package/lib/types/trace-route.d.ts +0 -12
- package/lib/types/trace-sketch.d.ts +0 -26
- package/lib/types/trace-store.d.ts +0 -23
- package/lib/types/vendor/drawai/raster.d.ts +0 -43
- package/lib/types/vendor/drawai/trace-image.d.ts +0 -257
package/lib/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { Context, Service } from "@deepseek-ai/cordis";
|
|
3
3
|
import z from "@deepseek-ai/schemastery";
|
|
4
|
-
import {
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
6
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
7
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
8
|
+
import { crc32 } from "node:zlib";
|
|
7
9
|
import http from "node:http";
|
|
8
10
|
//#region node_modules/.pnpm/@deepseek-ai+dsh-scope@0.1._0271d3a82adfe807020938592f267574/node_modules/@deepseek-ai/dsh-scope/lib/index.js
|
|
9
11
|
/**
|
|
@@ -5693,6 +5695,20 @@ function watermarkProfile(config, provider, model) {
|
|
|
5693
5695
|
//#region lib/types/shared.js
|
|
5694
5696
|
/** Browser route used by the generated-image card. */
|
|
5695
5697
|
const IMAGE_ROUTE = "/plugins/dsh-image-gen/image";
|
|
5698
|
+
/** Decode a GET query value; the caller supplies the reference parser. */
|
|
5699
|
+
function decodeImageRef(value, parse) {
|
|
5700
|
+
try {
|
|
5701
|
+
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
5702
|
+
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
5703
|
+
const binary = atob(padded);
|
|
5704
|
+
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
5705
|
+
return parse(JSON.parse(new TextDecoder().decode(bytes)));
|
|
5706
|
+
} catch {
|
|
5707
|
+
return;
|
|
5708
|
+
}
|
|
5709
|
+
}
|
|
5710
|
+
/** Content-addressed vault key: the sha256 of the original bytes, validated on both faces. */
|
|
5711
|
+
const VAULT_KEY_PATTERN = /^[a-f0-9]{64}$/;
|
|
5696
5712
|
/** Browser route that uploads user-picked images as DSH attachments. */
|
|
5697
5713
|
const IMPORT_ROUTE = "/plugins/dsh-image-gen/import";
|
|
5698
5714
|
/** Browser route used for deleting generated images and workspace files. */
|
|
@@ -5703,6 +5719,17 @@ const STUDIO_ROUTE = "/plugins/dsh-image-gen/studio";
|
|
|
5703
5719
|
const INSPIRATION_ROUTE = "/plugins/dsh-image-gen/inspiration";
|
|
5704
5720
|
/** Browser route used for saving generated images to workspace on demand. */
|
|
5705
5721
|
const SAVE_WORKSPACE_ROUTE = "/plugins/dsh-image-gen/save-workspace";
|
|
5722
|
+
/** Browser route that streams a ZIP export of the selected gallery images. */
|
|
5723
|
+
const ZIP_ROUTE = "/plugins/dsh-image-gen/zip";
|
|
5724
|
+
/** Browser route that serves host-rendered gallery thumbnails. */
|
|
5725
|
+
const THUMB_ROUTE = "/plugins/dsh-image-gen/thumb";
|
|
5726
|
+
const PREVIEW_ROUTE = "/plugins/dsh-image-gen/preview";
|
|
5727
|
+
const VAULT_ROUTE = "/plugins/dsh-image-gen/vault";
|
|
5728
|
+
const VAULT_REFS_ROUTE = "/plugins/dsh-image-gen/vault-refs";
|
|
5729
|
+
const VAULT_DELETE_ROUTE = "/plugins/dsh-image-gen/vault-delete";
|
|
5730
|
+
const ZIP_PROGRESS_ROUTE = "/plugins/dsh-image-gen/zip/progress";
|
|
5731
|
+
const ZIP_CANCEL_ROUTE = "/plugins/dsh-image-gen/zip/cancel";
|
|
5732
|
+
const STORAGE_STATS_ROUTE = "/plugins/dsh-image-gen/storage-stats";
|
|
5706
5733
|
/** Browser route the settings card probes provider connectivity through. */
|
|
5707
5734
|
const TEST_CONNECTION_ROUTE = "/plugins/dsh-image-gen/test";
|
|
5708
5735
|
/** Non-secret settings capability probe, available even when the host cannot serve a form. */
|
|
@@ -5841,6 +5868,15 @@ const DEFAULT_CLOUD_TIMEOUT_MS = 15e4;
|
|
|
5841
5868
|
/** Timeout for one subscription image call, shared by the tool path and the
|
|
5842
5869
|
* studio route so neither drifts from the other. */
|
|
5843
5870
|
const SUBSCRIPTION_TIMEOUT_MS = 3e5;
|
|
5871
|
+
const GIF_FRAME_DURATION_MS_MAX = 2e3;
|
|
5872
|
+
const GIF_SHEET_MAX_EDGE = 3840;
|
|
5873
|
+
/** A GIF draws every frame in one image call, so it runs longer than a still; the host's HTTP client gives up on response headers after 300 s anyway. */
|
|
5874
|
+
const DEFAULT_GIF_TIMEOUT_MS = 3e5;
|
|
5875
|
+
/** The cloud timeout for a request: a GIF gets the longer GIF default while the cloud timeout is left at its default. */
|
|
5876
|
+
function cloudTimeoutFor(configuredMs, gif) {
|
|
5877
|
+
const timeout = configuredMs ?? 15e4;
|
|
5878
|
+
return gif && timeout === 15e4 ? DEFAULT_GIF_TIMEOUT_MS : timeout;
|
|
5879
|
+
}
|
|
5844
5880
|
/**
|
|
5845
5881
|
* Credential references resolved through the DSH Credentials service (BYOK).
|
|
5846
5882
|
* These are POSIX-style reference names, not environment variables: the host
|
|
@@ -5899,6 +5935,10 @@ const STUDIO_WATERMARK_VALUES = [
|
|
|
5899
5935
|
"off",
|
|
5900
5936
|
"on"
|
|
5901
5937
|
];
|
|
5938
|
+
/** A model picker/request must use the same operation-specific default. */
|
|
5939
|
+
function studioModelForMode(profile, mode) {
|
|
5940
|
+
return mode === "edit" ? profile.editModel ?? profile.model : profile.model;
|
|
5941
|
+
}
|
|
5902
5942
|
function studioModelOptionsForMode(profile, mode) {
|
|
5903
5943
|
if (mode === "edit" && profile.editModel !== void 0) return [{
|
|
5904
5944
|
value: profile.editModel,
|
|
@@ -5954,10 +5994,26 @@ function arkOutputBody(options, { background = true } = {}) {
|
|
|
5954
5994
|
}
|
|
5955
5995
|
/** Default model names. */
|
|
5956
5996
|
const DEFAULT_GOOGLE_MODEL = "gemini-3.1-flash-image";
|
|
5957
|
-
const DEFAULT_OPENAI_MODEL = "gpt-image-2";
|
|
5958
|
-
const DEFAULT_SEEDREAM_MODEL = "doubao-seedream-5-0-
|
|
5997
|
+
const DEFAULT_OPENAI_MODEL = "gpt-image-2.6-flare";
|
|
5998
|
+
const DEFAULT_SEEDREAM_MODEL = "doubao-seedream-5-0-pro-260628";
|
|
5999
|
+
/**
|
|
6000
|
+
* Seedream models Volcano Ark retires on 2026-11-24 (batch 10), mapped to the
|
|
6001
|
+
* model that takes their requests. A saved settings value naming one of them
|
|
6002
|
+
* (the plugin's own former default among them) would start failing that day.
|
|
6003
|
+
*/
|
|
6004
|
+
const RETIRED_SEEDREAM_MODELS = {
|
|
6005
|
+
"doubao-seedream-5-0-260128": DEFAULT_SEEDREAM_MODEL,
|
|
6006
|
+
"doubao-seedream-5-0-lite-260128": DEFAULT_SEEDREAM_MODEL,
|
|
6007
|
+
"doubao-seedream-4-5-251128": DEFAULT_SEEDREAM_MODEL
|
|
6008
|
+
};
|
|
6009
|
+
/** The Seedream model a request should use: a retired one is replaced, anything else is kept. */
|
|
6010
|
+
function currentSeedreamModel(model) {
|
|
6011
|
+
const trimmed = model?.trim();
|
|
6012
|
+
if (trimmed === void 0 || trimmed.length === 0) return DEFAULT_SEEDREAM_MODEL;
|
|
6013
|
+
return RETIRED_SEEDREAM_MODELS[trimmed] ?? trimmed;
|
|
6014
|
+
}
|
|
5959
6015
|
const DEFAULT_DASHSCOPE_MODEL = "qwen-image-3.0";
|
|
5960
|
-
const DEFAULT_XAI_MODEL = "grok-imagine-image";
|
|
6016
|
+
const DEFAULT_XAI_MODEL = "grok-imagine-image-2.0";
|
|
5961
6017
|
const DEFAULT_ZHIPU_MODEL = "glm-image";
|
|
5962
6018
|
/** Named workflows, falling back to the legacy single-workflow fields when the list is empty. */
|
|
5963
6019
|
function resolveComfyUIWorkflows(source) {
|
|
@@ -6007,7 +6063,7 @@ function mergeComfyUIPrompt(preset, user) {
|
|
|
6007
6063
|
}
|
|
6008
6064
|
/** Default models for the subscription channels; fixed by the bridge protocol. */
|
|
6009
6065
|
const DEFAULT_SUBSCRIPTION_MODELS = {
|
|
6010
|
-
"chatgpt-sub": "gpt-image-2.
|
|
6066
|
+
"chatgpt-sub": "gpt-image-2.6-flare",
|
|
6011
6067
|
"grok-sub": "grok-imagine-image-2.0",
|
|
6012
6068
|
"google-sub": "gemini-3.1-flash-image"
|
|
6013
6069
|
};
|
|
@@ -6109,7 +6165,13 @@ const Config = z.object({
|
|
|
6109
6165
|
comfyuiTimeoutMs: volatile(z.number().min(1e3).max(36e5).default(DEFAULT_COMFYUI_TIMEOUT_MS)),
|
|
6110
6166
|
saveToWorkspace: volatile(z.boolean().default(true)),
|
|
6111
6167
|
workspaceFolder: volatile(z.string().default(DEFAULT_WORKSPACE_FOLDER)),
|
|
6112
|
-
showProviderPill: volatile(z.boolean().default(false))
|
|
6168
|
+
showProviderPill: volatile(z.boolean().default(false)),
|
|
6169
|
+
requestCachePrune: volatile(z.boolean().default(true)),
|
|
6170
|
+
requestCacheTtlDays: volatile(z.number().min(1).max(365).default(14)),
|
|
6171
|
+
requestCacheMaxBytes: volatile(z.number().min(0).default(268435456)),
|
|
6172
|
+
requestCacheDir: volatile(z.string().default("")),
|
|
6173
|
+
vaultEnabled: volatile(z.boolean().default(true)),
|
|
6174
|
+
vaultDir: volatile(z.string().default(""))
|
|
6113
6175
|
});
|
|
6114
6176
|
/** Resolve exactly one provider profile for a tool call. */
|
|
6115
6177
|
function resolveProvider(config) {
|
|
@@ -6117,7 +6179,7 @@ function resolveProvider(config) {
|
|
|
6117
6179
|
case "openai": return {
|
|
6118
6180
|
provider: "openai",
|
|
6119
6181
|
apiKeyEnv: OPENAI_API_KEY_ENV,
|
|
6120
|
-
model: config.openaiModel ?? "gpt-image-2",
|
|
6182
|
+
model: config.openaiModel ?? "gpt-image-2.6-flare",
|
|
6121
6183
|
baseURL: config.openaiBaseURL ?? "https://api.openai.com/v1",
|
|
6122
6184
|
imageSize: "1024x1024"
|
|
6123
6185
|
};
|
|
@@ -6141,7 +6203,7 @@ function resolveProvider(config) {
|
|
|
6141
6203
|
return {
|
|
6142
6204
|
provider: "seedream",
|
|
6143
6205
|
apiKeyEnv: SEEDREAM_API_KEY_ENV,
|
|
6144
|
-
model: config.seedreamModel
|
|
6206
|
+
model: currentSeedreamModel(config.seedreamModel),
|
|
6145
6207
|
baseURL: config.seedreamBaseURL ?? "https://ark.cn-beijing.volces.com/api/v3",
|
|
6146
6208
|
imageSize: "2K",
|
|
6147
6209
|
arkOptions: {
|
|
@@ -6161,7 +6223,7 @@ function resolveProvider(config) {
|
|
|
6161
6223
|
case "xai": return {
|
|
6162
6224
|
provider: "xai",
|
|
6163
6225
|
apiKeyEnv: XAI_API_KEY_ENV,
|
|
6164
|
-
model: config.xaiModel ?? "grok-imagine-image",
|
|
6226
|
+
model: config.xaiModel ?? "grok-imagine-image-2.0",
|
|
6165
6227
|
baseURL: config.xaiBaseURL ?? "https://api.x.ai/v1",
|
|
6166
6228
|
imageSize: "1024x1024"
|
|
6167
6229
|
};
|
|
@@ -6775,24 +6837,24 @@ const MAX_NODE_COUNT = 1e5;
|
|
|
6775
6837
|
const MAX_SELECTION_COUNT = 1e3;
|
|
6776
6838
|
/** Serve one canvas-state push. */
|
|
6777
6839
|
async function serveCanvasState(req, res, deps) {
|
|
6778
|
-
if (req.method !== "POST") return jsonError$
|
|
6779
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$
|
|
6840
|
+
if (req.method !== "POST") return jsonError$11(res, 405, "method-not-allowed");
|
|
6841
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$11(res, 415, "json-required");
|
|
6780
6842
|
const origin = req.headers.origin;
|
|
6781
6843
|
const host = req.headers.host;
|
|
6782
|
-
if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$
|
|
6844
|
+
if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$11(res, 403, "origin-rejected");
|
|
6783
6845
|
let body;
|
|
6784
6846
|
try {
|
|
6785
|
-
body = JSON.parse(await readBody$
|
|
6847
|
+
body = JSON.parse(await readBody$8(req, deps.maxBodyBytes));
|
|
6786
6848
|
} catch (error) {
|
|
6787
|
-
return jsonError$
|
|
6849
|
+
return jsonError$11(res, error instanceof Error && error.message === "request-too-large" ? 413 : 400, "invalid-request");
|
|
6788
6850
|
}
|
|
6789
6851
|
const push = parseCanvasStatePush(body);
|
|
6790
|
-
if (push === void 0) return jsonError$
|
|
6852
|
+
if (push === void 0) return jsonError$11(res, 400, "invalid-canvas-state");
|
|
6791
6853
|
let selectionImage;
|
|
6792
6854
|
if (push.selectionImage !== void 0) {
|
|
6793
6855
|
const decoded = decodeSelectionImage(push.selectionImage);
|
|
6794
|
-
if (decoded === void 0) return jsonError$
|
|
6795
|
-
if (decoded.data.byteLength > deps.maxImageBytes) return jsonError$
|
|
6856
|
+
if (decoded === void 0) return jsonError$11(res, 400, "invalid-selection-image");
|
|
6857
|
+
if (decoded.data.byteLength > deps.maxImageBytes) return jsonError$11(res, 413, "selection-image-too-large");
|
|
6796
6858
|
selectionImage = decoded;
|
|
6797
6859
|
}
|
|
6798
6860
|
deps.mirror.apply(push, selectionImage);
|
|
@@ -6960,7 +7022,7 @@ function optionalDimension(value) {
|
|
|
6960
7022
|
function nonNegativeInteger(value) {
|
|
6961
7023
|
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
6962
7024
|
}
|
|
6963
|
-
async function readBody$
|
|
7025
|
+
async function readBody$8(req, maxBytes) {
|
|
6964
7026
|
const chunks = [];
|
|
6965
7027
|
let bytes = 0;
|
|
6966
7028
|
for await (const chunk of req) {
|
|
@@ -6971,7 +7033,7 @@ async function readBody$5(req, maxBytes) {
|
|
|
6971
7033
|
}
|
|
6972
7034
|
return Buffer.concat(chunks).toString("utf8");
|
|
6973
7035
|
}
|
|
6974
|
-
function jsonError$
|
|
7036
|
+
function jsonError$11(res, status, code) {
|
|
6975
7037
|
res.writeHead(status, {
|
|
6976
7038
|
"content-type": "application/json",
|
|
6977
7039
|
"cache-control": "no-store"
|
|
@@ -7308,6 +7370,7 @@ function redactSecrets(text, ...secrets) {
|
|
|
7308
7370
|
let redacted = text;
|
|
7309
7371
|
for (const secret of secrets) if (secret !== void 0 && secret.length >= 8) redacted = redacted.split(secret).join(REDACTED);
|
|
7310
7372
|
for (const pattern of KEY_SHAPED_PATTERNS) redacted = redacted.replace(pattern, REDACTED);
|
|
7373
|
+
redacted = redacted.replace(/\/\/[^\s/?#@]+@/g, `//${REDACTED}@`);
|
|
7311
7374
|
return redacted;
|
|
7312
7375
|
}
|
|
7313
7376
|
//#endregion
|
|
@@ -7921,7 +7984,7 @@ async function readBoundedText$2(response, maxBytes, label) {
|
|
|
7921
7984
|
//#region lib/types/workspace-save.js
|
|
7922
7985
|
/** Persist one generated image as a file under the session workspace. */
|
|
7923
7986
|
/** File extension for each supported image media type. */
|
|
7924
|
-
const EXTENSION = {
|
|
7987
|
+
const EXTENSION$1 = {
|
|
7925
7988
|
"image/png": "png",
|
|
7926
7989
|
"image/jpeg": "jpg",
|
|
7927
7990
|
"image/webp": "webp",
|
|
@@ -7938,7 +8001,7 @@ const EXTENSION = {
|
|
|
7938
8001
|
* @returns the file name (no directory).
|
|
7939
8002
|
*/
|
|
7940
8003
|
function workspaceImageName(attachmentId, mediaType) {
|
|
7941
|
-
return `image-${(attachmentId.startsWith("sha256:") ? attachmentId.slice(7) : attachmentId).slice(0, 8).padEnd(8, "0")}.${EXTENSION[mediaType]}`;
|
|
8004
|
+
return `image-${(attachmentId.startsWith("sha256:") ? attachmentId.slice(7) : attachmentId).slice(0, 8).padEnd(8, "0")}.${EXTENSION$1[mediaType]}`;
|
|
7942
8005
|
}
|
|
7943
8006
|
/**
|
|
7944
8007
|
* Resolve the configured image folder inside the session workspace. The
|
|
@@ -8146,19 +8209,20 @@ async function deleteImageFromWorkspace(filePath, allowedWorkspaceRoots) {
|
|
|
8146
8209
|
const MAX_BODY_BYTES$2 = 65536;
|
|
8147
8210
|
/** Serve one verified durable image reference to a same-origin browser request. */
|
|
8148
8211
|
async function serveImage(req, res, deps) {
|
|
8149
|
-
if (req.method
|
|
8150
|
-
if (
|
|
8212
|
+
if (req.method === "GET") return serveCachedImage(req, res, deps);
|
|
8213
|
+
if (req.method !== "POST") return jsonError$10(res, 405, "method-not-allowed");
|
|
8214
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$10(res, 415, "json-required");
|
|
8151
8215
|
const origin = req.headers.origin;
|
|
8152
8216
|
const host = req.headers.host;
|
|
8153
|
-
if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$
|
|
8217
|
+
if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$10(res, 403, "origin-rejected");
|
|
8154
8218
|
let body;
|
|
8155
8219
|
try {
|
|
8156
|
-
body = JSON.parse(await readBody$
|
|
8220
|
+
body = JSON.parse(await readBody$7(req));
|
|
8157
8221
|
} catch {
|
|
8158
|
-
return jsonError$
|
|
8222
|
+
return jsonError$10(res, 400, "invalid-request");
|
|
8159
8223
|
}
|
|
8160
8224
|
const attachment = attachmentFromRequest(body);
|
|
8161
|
-
if (attachment === void 0) return jsonError$
|
|
8225
|
+
if (attachment === void 0) return jsonError$10(res, 400, "invalid-attachment");
|
|
8162
8226
|
try {
|
|
8163
8227
|
const stored = await deps.readImage(attachment);
|
|
8164
8228
|
res.writeHead(200, {
|
|
@@ -8169,10 +8233,44 @@ async function serveImage(req, res, deps) {
|
|
|
8169
8233
|
});
|
|
8170
8234
|
res.end(stored.data);
|
|
8171
8235
|
} catch {
|
|
8172
|
-
jsonError$
|
|
8236
|
+
jsonError$10(res, 404, "image-unavailable");
|
|
8173
8237
|
}
|
|
8174
8238
|
}
|
|
8175
|
-
|
|
8239
|
+
/**
|
|
8240
|
+
* Cacheable GET form: attachments are content-addressed, so the response can be
|
|
8241
|
+
* marked immutable and revalidated with a stable ETag. Preview, copy and
|
|
8242
|
+
* download then hit the browser cache instead of re-reading the host.
|
|
8243
|
+
*/
|
|
8244
|
+
async function serveCachedImage(req, res, deps) {
|
|
8245
|
+
if (!sameOrigin$8(req)) return jsonError$10(res, 403, "origin-rejected");
|
|
8246
|
+
const raw = new URL(req.url ?? "/", "http://x").searchParams.get("ref");
|
|
8247
|
+
const attachment = raw === null ? void 0 : decodeImageRef(raw, parseImageAttachmentRef);
|
|
8248
|
+
if (attachment === void 0) return jsonError$10(res, 400, "invalid-attachment");
|
|
8249
|
+
const etag = "\"" + String(attachment.attachmentId) + "\"";
|
|
8250
|
+
const cacheHeaders = {
|
|
8251
|
+
"cache-control": "private, max-age=31536000, immutable",
|
|
8252
|
+
etag
|
|
8253
|
+
};
|
|
8254
|
+
const ifNoneMatch = req.headers["if-none-match"];
|
|
8255
|
+
if (typeof ifNoneMatch === "string" && ifNoneMatch.split(",").some((value) => value.trim() === etag)) {
|
|
8256
|
+
res.writeHead(304, cacheHeaders);
|
|
8257
|
+
res.end();
|
|
8258
|
+
return;
|
|
8259
|
+
}
|
|
8260
|
+
try {
|
|
8261
|
+
const stored = await deps.readImage(attachment);
|
|
8262
|
+
res.writeHead(200, {
|
|
8263
|
+
"content-type": stored.ref.mediaType,
|
|
8264
|
+
"content-length": String(stored.data.byteLength),
|
|
8265
|
+
"x-content-type-options": "nosniff",
|
|
8266
|
+
...cacheHeaders
|
|
8267
|
+
});
|
|
8268
|
+
res.end(stored.data);
|
|
8269
|
+
} catch {
|
|
8270
|
+
jsonError$10(res, 404, "image-unavailable");
|
|
8271
|
+
}
|
|
8272
|
+
}
|
|
8273
|
+
function sameOrigin$8(req) {
|
|
8176
8274
|
const origin = req.headers.origin;
|
|
8177
8275
|
const host = req.headers.host;
|
|
8178
8276
|
if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return false;
|
|
@@ -8180,14 +8278,14 @@ function sameOrigin$4(req) {
|
|
|
8180
8278
|
}
|
|
8181
8279
|
/** Safely delete one or more generated image files from the workspace disk. */
|
|
8182
8280
|
async function serveDelete(req, res, deps) {
|
|
8183
|
-
if (req.method !== "POST") return jsonError$
|
|
8184
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$
|
|
8185
|
-
if (!sameOrigin$
|
|
8281
|
+
if (req.method !== "POST") return jsonError$10(res, 405, "method-not-allowed");
|
|
8282
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$10(res, 415, "json-required");
|
|
8283
|
+
if (!sameOrigin$8(req)) return jsonError$10(res, 403, "origin-rejected");
|
|
8186
8284
|
let body;
|
|
8187
8285
|
try {
|
|
8188
|
-
body = JSON.parse(await readBody$
|
|
8286
|
+
body = JSON.parse(await readBody$7(req));
|
|
8189
8287
|
} catch {
|
|
8190
|
-
return jsonError$
|
|
8288
|
+
return jsonError$10(res, 400, "invalid-request");
|
|
8191
8289
|
}
|
|
8192
8290
|
const rawPaths = typeof body === "object" && body !== null && "paths" in body && Array.isArray(body.paths) ? body.paths : [];
|
|
8193
8291
|
const deletedFiles = [];
|
|
@@ -8217,13 +8315,13 @@ async function serveDelete(req, res, deps) {
|
|
|
8217
8315
|
}
|
|
8218
8316
|
/** Serve on-demand image persistence into the workspace. */
|
|
8219
8317
|
async function serveSaveWorkspace(req, res, deps) {
|
|
8220
|
-
if (!sameOrigin$
|
|
8221
|
-
if (req.method !== "POST") return jsonError$
|
|
8222
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$
|
|
8318
|
+
if (!sameOrigin$8(req)) return jsonError$10(res, 403, "origin-rejected");
|
|
8319
|
+
if (req.method !== "POST") return jsonError$10(res, 405, "method-not-allowed");
|
|
8320
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$10(res, 415, "json-required");
|
|
8223
8321
|
try {
|
|
8224
|
-
const raw = JSON.parse(await readBody$
|
|
8322
|
+
const raw = JSON.parse(await readBody$7(req));
|
|
8225
8323
|
const attachment = parseImageAttachmentRef(raw.attachment);
|
|
8226
|
-
if (!attachment) return jsonError$
|
|
8324
|
+
if (!attachment) return jsonError$10(res, 400, "invalid-attachment");
|
|
8227
8325
|
if (!deps.isSaveEnabled()) {
|
|
8228
8326
|
res.writeHead(200, {
|
|
8229
8327
|
"content-type": "application/json",
|
|
@@ -8241,10 +8339,10 @@ async function serveSaveWorkspace(req, res, deps) {
|
|
|
8241
8339
|
try {
|
|
8242
8340
|
targetRoot = await assertWorkspaceAllowed(rawTarget, allowedRoots);
|
|
8243
8341
|
} catch {
|
|
8244
|
-
return jsonError$
|
|
8342
|
+
return jsonError$10(res, 403, "workspace-root-not-allowed");
|
|
8245
8343
|
}
|
|
8246
8344
|
const stored = await deps.readImage(attachment);
|
|
8247
|
-
if (!stored || !stored.data) return jsonError$
|
|
8345
|
+
if (!stored || !stored.data) return jsonError$10(res, 404, "image-not-found");
|
|
8248
8346
|
const savedTo = await deps.saveToWorkspace({
|
|
8249
8347
|
workspaceRoot: targetRoot,
|
|
8250
8348
|
attachmentId: String(attachment.attachmentId),
|
|
@@ -8282,7 +8380,7 @@ function attachmentFromRequest(value) {
|
|
|
8282
8380
|
function record$4(value) {
|
|
8283
8381
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
8284
8382
|
}
|
|
8285
|
-
async function readBody$
|
|
8383
|
+
async function readBody$7(req) {
|
|
8286
8384
|
const chunks = [];
|
|
8287
8385
|
let bytes = 0;
|
|
8288
8386
|
for await (const chunk of req) {
|
|
@@ -8293,7 +8391,7 @@ async function readBody$4(req) {
|
|
|
8293
8391
|
}
|
|
8294
8392
|
return Buffer.concat(chunks).toString("utf8");
|
|
8295
8393
|
}
|
|
8296
|
-
function jsonError$
|
|
8394
|
+
function jsonError$10(res, status, code) {
|
|
8297
8395
|
res.writeHead(status, {
|
|
8298
8396
|
"content-type": "application/json",
|
|
8299
8397
|
"cache-control": "no-store"
|
|
@@ -8301,30 +8399,249 @@ function jsonError$5(res, status, code) {
|
|
|
8301
8399
|
res.end(JSON.stringify({ error: code }));
|
|
8302
8400
|
}
|
|
8303
8401
|
//#endregion
|
|
8402
|
+
//#region lib/types/multipart.js
|
|
8403
|
+
var MultipartError = class extends Error {
|
|
8404
|
+
status;
|
|
8405
|
+
code;
|
|
8406
|
+
constructor(status, code) {
|
|
8407
|
+
super(code);
|
|
8408
|
+
this.status = status;
|
|
8409
|
+
this.code = code;
|
|
8410
|
+
this.name = "MultipartError";
|
|
8411
|
+
}
|
|
8412
|
+
};
|
|
8413
|
+
const CRLF = Buffer.from("\r\n");
|
|
8414
|
+
const DASH = Buffer.from("--");
|
|
8415
|
+
const HEADER_LIMIT = 16384;
|
|
8416
|
+
/**
|
|
8417
|
+
* Parse a multipart body incrementally. Only one part is buffered at a time,
|
|
8418
|
+
* so an 8 x 40 MiB upload never holds the whole request in memory.
|
|
8419
|
+
*/
|
|
8420
|
+
async function readMultipart(req, boundary, options) {
|
|
8421
|
+
if (boundary.length === 0 || boundary.length > 200) throw new MultipartError(400, "invalid-boundary");
|
|
8422
|
+
const first = Buffer.from("--" + boundary);
|
|
8423
|
+
const delimiter = Buffer.from("\r\n--" + boundary);
|
|
8424
|
+
let buffer = Buffer.alloc(0);
|
|
8425
|
+
let state = "preamble";
|
|
8426
|
+
let part;
|
|
8427
|
+
const append = (chunk) => {
|
|
8428
|
+
if (part === void 0 || chunk.length === 0) return;
|
|
8429
|
+
part.bytes += chunk.length;
|
|
8430
|
+
if (part.tooLarge) return;
|
|
8431
|
+
if (part.bytes > options.maxPartBytes) {
|
|
8432
|
+
part.tooLarge = true;
|
|
8433
|
+
part.chunks = [];
|
|
8434
|
+
return;
|
|
8435
|
+
}
|
|
8436
|
+
part.chunks.push(Buffer.from(chunk));
|
|
8437
|
+
};
|
|
8438
|
+
const finishPart = async () => {
|
|
8439
|
+
if (part === void 0) return;
|
|
8440
|
+
const completed = part;
|
|
8441
|
+
part = void 0;
|
|
8442
|
+
if (completed.name !== "images") return;
|
|
8443
|
+
await options.onPart({
|
|
8444
|
+
name: completed.name,
|
|
8445
|
+
...completed.filename === void 0 ? {} : { filename: completed.filename },
|
|
8446
|
+
...completed.mediaType === void 0 ? {} : { mediaType: completed.mediaType },
|
|
8447
|
+
data: completed.tooLarge ? Buffer.alloc(0) : Buffer.concat(completed.chunks, completed.bytes),
|
|
8448
|
+
tooLarge: completed.tooLarge,
|
|
8449
|
+
bytes: completed.bytes
|
|
8450
|
+
});
|
|
8451
|
+
};
|
|
8452
|
+
const consume = async () => {
|
|
8453
|
+
while (true) {
|
|
8454
|
+
if (state === "done") {
|
|
8455
|
+
buffer = Buffer.alloc(0);
|
|
8456
|
+
return;
|
|
8457
|
+
}
|
|
8458
|
+
if (state === "preamble") {
|
|
8459
|
+
const index = buffer.indexOf(first);
|
|
8460
|
+
if (index === -1) {
|
|
8461
|
+
if (buffer.length > first.length + 4) buffer = buffer.subarray(buffer.length - (first.length + 4));
|
|
8462
|
+
return;
|
|
8463
|
+
}
|
|
8464
|
+
if (buffer.length < index + first.length + 2) return;
|
|
8465
|
+
const suffix = buffer.subarray(index + first.length, index + first.length + 2);
|
|
8466
|
+
if (suffix.equals(DASH)) {
|
|
8467
|
+
state = "done";
|
|
8468
|
+
buffer = Buffer.alloc(0);
|
|
8469
|
+
return;
|
|
8470
|
+
}
|
|
8471
|
+
if (!suffix.equals(CRLF)) throw new MultipartError(400, "invalid-multipart");
|
|
8472
|
+
buffer = buffer.subarray(index + first.length + 2);
|
|
8473
|
+
state = "headers";
|
|
8474
|
+
continue;
|
|
8475
|
+
}
|
|
8476
|
+
if (state === "headers") {
|
|
8477
|
+
const end = buffer.indexOf("\r\n\r\n");
|
|
8478
|
+
if (end === -1) {
|
|
8479
|
+
if (buffer.length > HEADER_LIMIT) throw new MultipartError(400, "invalid-headers");
|
|
8480
|
+
return;
|
|
8481
|
+
}
|
|
8482
|
+
const headerBuffer = buffer.subarray(0, end);
|
|
8483
|
+
buffer = buffer.subarray(end + 4);
|
|
8484
|
+
const headers = parsePartHeaders(headerBuffer);
|
|
8485
|
+
if (headers.name.length === 0) throw new MultipartError(400, "invalid-headers");
|
|
8486
|
+
part = {
|
|
8487
|
+
name: headers.name,
|
|
8488
|
+
...headers.filename === void 0 ? {} : { filename: headers.filename },
|
|
8489
|
+
...headers.mediaType === void 0 ? {} : { mediaType: headers.mediaType },
|
|
8490
|
+
chunks: [],
|
|
8491
|
+
bytes: 0,
|
|
8492
|
+
tooLarge: false
|
|
8493
|
+
};
|
|
8494
|
+
state = "body";
|
|
8495
|
+
continue;
|
|
8496
|
+
}
|
|
8497
|
+
if (state === "body") {
|
|
8498
|
+
const found = findDelimiter(buffer, delimiter);
|
|
8499
|
+
if (found === -1) {
|
|
8500
|
+
const keep = delimiter.length + 2;
|
|
8501
|
+
if (buffer.length > keep) {
|
|
8502
|
+
append(buffer.subarray(0, buffer.length - keep));
|
|
8503
|
+
buffer = buffer.subarray(buffer.length - keep);
|
|
8504
|
+
}
|
|
8505
|
+
return;
|
|
8506
|
+
}
|
|
8507
|
+
append(buffer.subarray(0, found));
|
|
8508
|
+
buffer = buffer.subarray(found + delimiter.length);
|
|
8509
|
+
const suffix = buffer.subarray(0, 2);
|
|
8510
|
+
buffer = buffer.subarray(2);
|
|
8511
|
+
await finishPart();
|
|
8512
|
+
if (suffix.equals(DASH)) {
|
|
8513
|
+
state = "done";
|
|
8514
|
+
buffer = Buffer.alloc(0);
|
|
8515
|
+
return;
|
|
8516
|
+
}
|
|
8517
|
+
if (!suffix.equals(CRLF)) throw new MultipartError(400, "invalid-multipart");
|
|
8518
|
+
state = "headers";
|
|
8519
|
+
continue;
|
|
8520
|
+
}
|
|
8521
|
+
}
|
|
8522
|
+
};
|
|
8523
|
+
for await (const chunk of req) {
|
|
8524
|
+
if (state === "done") continue;
|
|
8525
|
+
const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
8526
|
+
buffer = buffer.length === 0 ? incoming : Buffer.concat([buffer, incoming]);
|
|
8527
|
+
await consume();
|
|
8528
|
+
}
|
|
8529
|
+
await consume();
|
|
8530
|
+
if (state !== "done") throw new MultipartError(400, "truncated-multipart");
|
|
8531
|
+
}
|
|
8532
|
+
/** Locate a delimiter whose following two bytes are CRLF or a closing dash. */
|
|
8533
|
+
function findDelimiter(buffer, delimiter) {
|
|
8534
|
+
let from = 0;
|
|
8535
|
+
while (true) {
|
|
8536
|
+
const index = buffer.indexOf(delimiter, from);
|
|
8537
|
+
if (index === -1) return -1;
|
|
8538
|
+
if (buffer.length < index + delimiter.length + 2) return -1;
|
|
8539
|
+
const suffix = buffer.subarray(index + delimiter.length, index + delimiter.length + 2);
|
|
8540
|
+
if (suffix.equals(CRLF) || suffix.equals(DASH)) return index;
|
|
8541
|
+
from = index + 1;
|
|
8542
|
+
}
|
|
8543
|
+
}
|
|
8544
|
+
function parsePartHeaders(raw) {
|
|
8545
|
+
let name = "";
|
|
8546
|
+
let filename;
|
|
8547
|
+
let mediaType;
|
|
8548
|
+
for (const line of raw.toString("utf8").split("\r\n")) {
|
|
8549
|
+
const colon = line.indexOf(":");
|
|
8550
|
+
if (colon === -1) continue;
|
|
8551
|
+
const key = line.slice(0, colon).trim().toLowerCase();
|
|
8552
|
+
const value = line.slice(colon + 1).trim();
|
|
8553
|
+
if (key === "content-disposition") {
|
|
8554
|
+
name = dispositionParam(value, "name") ?? "";
|
|
8555
|
+
filename = dispositionParam(value, "filename");
|
|
8556
|
+
} else if (key === "content-type") mediaType = value.split(";")[0]?.trim() || void 0;
|
|
8557
|
+
}
|
|
8558
|
+
return {
|
|
8559
|
+
name,
|
|
8560
|
+
...filename === void 0 ? {} : { filename },
|
|
8561
|
+
...mediaType === void 0 ? {} : { mediaType }
|
|
8562
|
+
};
|
|
8563
|
+
}
|
|
8564
|
+
function dispositionParam(value, key) {
|
|
8565
|
+
const params = [];
|
|
8566
|
+
let start = 0;
|
|
8567
|
+
let quoted = false;
|
|
8568
|
+
let escaped = false;
|
|
8569
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
8570
|
+
const char = value[index];
|
|
8571
|
+
if (escaped) {
|
|
8572
|
+
escaped = false;
|
|
8573
|
+
continue;
|
|
8574
|
+
}
|
|
8575
|
+
if (quoted && char === "\\") {
|
|
8576
|
+
escaped = true;
|
|
8577
|
+
continue;
|
|
8578
|
+
}
|
|
8579
|
+
if (char === "\"") {
|
|
8580
|
+
quoted = !quoted;
|
|
8581
|
+
continue;
|
|
8582
|
+
}
|
|
8583
|
+
if (char === ";" && !quoted) {
|
|
8584
|
+
params.push(value.slice(start, index));
|
|
8585
|
+
start = index + 1;
|
|
8586
|
+
}
|
|
8587
|
+
}
|
|
8588
|
+
params.push(value.slice(start));
|
|
8589
|
+
for (const param of params) {
|
|
8590
|
+
const equals = param.indexOf("=");
|
|
8591
|
+
if (equals <= 0 || param.slice(0, equals).trim().toLowerCase() !== key.toLowerCase()) continue;
|
|
8592
|
+
const raw = param.slice(equals + 1).trim();
|
|
8593
|
+
if (raw.length >= 2 && raw.startsWith("\"") && raw.endsWith("\"")) return raw.slice(1, -1).replace(/\\(.)/g, "$1");
|
|
8594
|
+
return raw;
|
|
8595
|
+
}
|
|
8596
|
+
}
|
|
8597
|
+
//#endregion
|
|
8304
8598
|
//#region lib/types/import-route.js
|
|
8305
8599
|
const MAX_IMAGES_PER_REQUEST = 8;
|
|
8306
|
-
const
|
|
8600
|
+
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
8601
|
+
const BASE64_LOOKUP = (() => {
|
|
8602
|
+
const table = /* @__PURE__ */ new Uint8Array(128);
|
|
8603
|
+
for (let index = 0; index < 64; index++) table[BASE64_ALPHABET.charCodeAt(index)] = 1;
|
|
8604
|
+
return table;
|
|
8605
|
+
})();
|
|
8606
|
+
/**
|
|
8607
|
+
* Validate base64 in linear time. A backtracking regexp looks harmless but V8
|
|
8608
|
+
* throws RangeError ("Maximum call stack size exceeded") once the input passes
|
|
8609
|
+
* a few megabytes, which turned every large import into a bare HTTP 400.
|
|
8610
|
+
*/
|
|
8611
|
+
function isValidBase64(value) {
|
|
8612
|
+
if (value.length % 4 !== 0) return false;
|
|
8613
|
+
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
|
|
8614
|
+
const end = value.length - padding;
|
|
8615
|
+
for (let index = 0; index < end; index++) {
|
|
8616
|
+
const code = value.charCodeAt(index);
|
|
8617
|
+
if (code > 127 || BASE64_LOOKUP[code] !== 1) return false;
|
|
8618
|
+
}
|
|
8619
|
+
return true;
|
|
8620
|
+
}
|
|
8307
8621
|
/** base64 inflates bytes by 4/3; allow one full batch plus JSON overhead. */
|
|
8308
8622
|
function bodyLimit(maxImageBytes) {
|
|
8309
8623
|
return MAX_IMAGES_PER_REQUEST * Math.ceil(maxImageBytes * 1.4) + 4096;
|
|
8310
8624
|
}
|
|
8311
8625
|
async function serveImport(req, res, deps) {
|
|
8312
|
-
if (req.method !== "POST") return jsonError$
|
|
8313
|
-
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$4(res, 415, "json-required");
|
|
8626
|
+
if (req.method !== "POST") return jsonError$9(res, 405, "method-not-allowed");
|
|
8314
8627
|
const origin = req.headers.origin;
|
|
8315
8628
|
const host = req.headers.host;
|
|
8316
|
-
if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$
|
|
8629
|
+
if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return jsonError$9(res, 403, "origin-rejected");
|
|
8630
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
8631
|
+
const boundary = multipartBoundary(contentType);
|
|
8632
|
+
if (boundary !== void 0) return serveMultipartImport(req, res, deps, boundary);
|
|
8633
|
+
if (!contentType.toLowerCase().startsWith("application/json")) return jsonError$9(res, 415, "json-required");
|
|
8317
8634
|
let body;
|
|
8318
8635
|
try {
|
|
8319
|
-
body = JSON.parse(await readBody$
|
|
8636
|
+
body = JSON.parse(await readBody$6(req, bodyLimit(deps.maxImageBytes)));
|
|
8320
8637
|
} catch {
|
|
8321
|
-
return jsonError$
|
|
8638
|
+
return jsonError$9(res, 400, "invalid-request");
|
|
8322
8639
|
}
|
|
8323
8640
|
const images = typeof body === "object" && body !== null ? body.images : void 0;
|
|
8324
|
-
if (!Array.isArray(images) || images.length === 0 || images.length > MAX_IMAGES_PER_REQUEST) return jsonError$
|
|
8641
|
+
if (!Array.isArray(images) || images.length === 0 || images.length > MAX_IMAGES_PER_REQUEST) return jsonError$9(res, 400, "invalid-image-count");
|
|
8325
8642
|
const saved = [];
|
|
8326
8643
|
const failures = [];
|
|
8327
|
-
for (const [index, item] of images.entries()) {
|
|
8644
|
+
for (const [index, item] of images.entries()) try {
|
|
8328
8645
|
const record = typeof item === "object" && item !== null ? item : void 0;
|
|
8329
8646
|
if (record === void 0 || typeof record.data !== "string" || typeof record.mediaType !== "string") {
|
|
8330
8647
|
failures.push({
|
|
@@ -8340,7 +8657,7 @@ async function serveImport(req, res, deps) {
|
|
|
8340
8657
|
});
|
|
8341
8658
|
continue;
|
|
8342
8659
|
}
|
|
8343
|
-
if (!
|
|
8660
|
+
if (!isValidBase64(record.data)) {
|
|
8344
8661
|
failures.push({
|
|
8345
8662
|
index,
|
|
8346
8663
|
error: "invalid-base64"
|
|
@@ -8356,39 +8673,1675 @@ async function serveImport(req, res, deps) {
|
|
|
8356
8673
|
continue;
|
|
8357
8674
|
}
|
|
8358
8675
|
try {
|
|
8359
|
-
const
|
|
8360
|
-
|
|
8361
|
-
|
|
8362
|
-
|
|
8363
|
-
}
|
|
8364
|
-
saved.push({ attachment });
|
|
8676
|
+
const persisted = await persistImport(deps, bytes, record.mediaType, record.name);
|
|
8677
|
+
saved.push(persisted);
|
|
8678
|
+
try {
|
|
8679
|
+
deps.warmThumbnail?.(persisted.attachment);
|
|
8680
|
+
} catch {}
|
|
8365
8681
|
} catch {
|
|
8366
8682
|
failures.push({
|
|
8367
8683
|
index,
|
|
8368
8684
|
error: "save-failed"
|
|
8369
8685
|
});
|
|
8370
8686
|
}
|
|
8687
|
+
} catch {
|
|
8688
|
+
failures.push({
|
|
8689
|
+
index,
|
|
8690
|
+
error: "internal-error"
|
|
8691
|
+
});
|
|
8692
|
+
}
|
|
8693
|
+
res.writeHead(200, {
|
|
8694
|
+
"content-type": "application/json",
|
|
8695
|
+
"cache-control": "no-store"
|
|
8696
|
+
});
|
|
8697
|
+
res.end(JSON.stringify({
|
|
8698
|
+
images: saved,
|
|
8699
|
+
failures
|
|
8700
|
+
}));
|
|
8701
|
+
}
|
|
8702
|
+
async function serveMultipartImport(req, res, deps, boundary) {
|
|
8703
|
+
const saved = [];
|
|
8704
|
+
const failures = [];
|
|
8705
|
+
let tempDir;
|
|
8706
|
+
try {
|
|
8707
|
+
tempDir = await mkdtemp(join(tmpdir(), "dsh-image-import-"));
|
|
8708
|
+
const spooled = [];
|
|
8709
|
+
let index = 0;
|
|
8710
|
+
await readMultipart(req, boundary, {
|
|
8711
|
+
maxPartBytes: deps.maxImageBytes,
|
|
8712
|
+
onPart: async (part) => {
|
|
8713
|
+
const current = index++;
|
|
8714
|
+
if (current >= MAX_IMAGES_PER_REQUEST) {
|
|
8715
|
+
failures.push({
|
|
8716
|
+
index: current,
|
|
8717
|
+
error: "invalid-image-count"
|
|
8718
|
+
});
|
|
8719
|
+
return;
|
|
8720
|
+
}
|
|
8721
|
+
if (part.tooLarge || part.bytes === 0 || part.bytes > deps.maxImageBytes) {
|
|
8722
|
+
failures.push({
|
|
8723
|
+
index: current,
|
|
8724
|
+
error: "size-out-of-range (max " + String(deps.maxImageBytes) + " bytes)"
|
|
8725
|
+
});
|
|
8726
|
+
return;
|
|
8727
|
+
}
|
|
8728
|
+
const declared = part.mediaType !== void 0 && deps.mediaTypes.includes(part.mediaType) ? part.mediaType : mediaTypeFromFilename(part.filename);
|
|
8729
|
+
if (declared === void 0 || !deps.mediaTypes.includes(declared)) {
|
|
8730
|
+
failures.push({
|
|
8731
|
+
index: current,
|
|
8732
|
+
error: "unsupported-media-type: " + (declared ?? "unknown")
|
|
8733
|
+
});
|
|
8734
|
+
return;
|
|
8735
|
+
}
|
|
8736
|
+
const path = join(tempDir, String(current) + ".part");
|
|
8737
|
+
await writeFile(path, part.data);
|
|
8738
|
+
spooled.push({
|
|
8739
|
+
index: current,
|
|
8740
|
+
path,
|
|
8741
|
+
mediaType: declared,
|
|
8742
|
+
...part.filename === void 0 ? {} : { filename: part.filename },
|
|
8743
|
+
bytes: part.bytes
|
|
8744
|
+
});
|
|
8745
|
+
}
|
|
8746
|
+
});
|
|
8747
|
+
for (const part of spooled) try {
|
|
8748
|
+
const persisted = await persistImport(deps, await readFile(part.path), part.mediaType, part.filename);
|
|
8749
|
+
saved.push(persisted);
|
|
8750
|
+
try {
|
|
8751
|
+
deps.warmThumbnail?.(persisted.attachment);
|
|
8752
|
+
} catch {}
|
|
8753
|
+
} catch {
|
|
8754
|
+
failures.push({
|
|
8755
|
+
index: part.index,
|
|
8756
|
+
error: "save-failed"
|
|
8757
|
+
});
|
|
8758
|
+
}
|
|
8759
|
+
} catch (err) {
|
|
8760
|
+
if (err instanceof MultipartError) return jsonError$9(res, err.status, err.code);
|
|
8761
|
+
return jsonError$9(res, 400, "invalid-request");
|
|
8762
|
+
} finally {
|
|
8763
|
+
if (tempDir !== void 0) await rm(tempDir, {
|
|
8764
|
+
recursive: true,
|
|
8765
|
+
force: true
|
|
8766
|
+
}).catch(() => void 0);
|
|
8767
|
+
}
|
|
8768
|
+
if (saved.length === 0 && failures.length === 0) return jsonError$9(res, 400, "invalid-image-count");
|
|
8769
|
+
failures.sort((a, b) => a.index - b.index);
|
|
8770
|
+
res.writeHead(200, {
|
|
8771
|
+
"content-type": "application/json",
|
|
8772
|
+
"cache-control": "no-store"
|
|
8773
|
+
});
|
|
8774
|
+
res.end(JSON.stringify({
|
|
8775
|
+
images: saved,
|
|
8776
|
+
failures
|
|
8777
|
+
}));
|
|
8778
|
+
}
|
|
8779
|
+
/** Save one image and, when the attachment layer normalized it, keep the raw copy. */
|
|
8780
|
+
async function persistImport(deps, bytes, mediaType, name) {
|
|
8781
|
+
const attachment = await deps.saveImage({
|
|
8782
|
+
data: bytes,
|
|
8783
|
+
mediaType,
|
|
8784
|
+
...typeof name === "string" && name.length > 0 ? { name } : {}
|
|
8785
|
+
});
|
|
8786
|
+
let original;
|
|
8787
|
+
if (deps.saveOriginal !== void 0 && (attachment.mediaType !== mediaType || attachment.bytes !== bytes.byteLength)) original = await deps.saveOriginal({
|
|
8788
|
+
data: bytes,
|
|
8789
|
+
mediaType
|
|
8790
|
+
});
|
|
8791
|
+
return {
|
|
8792
|
+
attachment,
|
|
8793
|
+
...original === void 0 ? {} : { original }
|
|
8794
|
+
};
|
|
8795
|
+
}
|
|
8796
|
+
function multipartBoundary(contentType) {
|
|
8797
|
+
if (!contentType.toLowerCase().startsWith("multipart/form-data")) return void 0;
|
|
8798
|
+
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
|
|
8799
|
+
const value = (match?.[1] ?? match?.[2] ?? "").trim();
|
|
8800
|
+
return value.length > 0 ? value : void 0;
|
|
8801
|
+
}
|
|
8802
|
+
const FILENAME_MEDIA_TYPES = {
|
|
8803
|
+
png: "image/png",
|
|
8804
|
+
jpg: "image/jpeg",
|
|
8805
|
+
jpeg: "image/jpeg",
|
|
8806
|
+
webp: "image/webp",
|
|
8807
|
+
gif: "image/gif"
|
|
8808
|
+
};
|
|
8809
|
+
function mediaTypeFromFilename(name) {
|
|
8810
|
+
if (name === void 0) return void 0;
|
|
8811
|
+
const dot = name.lastIndexOf(".");
|
|
8812
|
+
if (dot === -1) return void 0;
|
|
8813
|
+
return FILENAME_MEDIA_TYPES[name.slice(dot + 1).toLowerCase()];
|
|
8814
|
+
}
|
|
8815
|
+
async function readBody$6(req, maxBytes) {
|
|
8816
|
+
const chunks = [];
|
|
8817
|
+
let bytes = 0;
|
|
8818
|
+
for await (const chunk of req) {
|
|
8819
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
8820
|
+
bytes += buffer.byteLength;
|
|
8821
|
+
if (bytes > maxBytes) throw new Error("request-too-large");
|
|
8822
|
+
chunks.push(buffer);
|
|
8823
|
+
}
|
|
8824
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
8825
|
+
}
|
|
8826
|
+
function jsonError$9(res, status, code) {
|
|
8827
|
+
res.writeHead(status, {
|
|
8828
|
+
"content-type": "application/json",
|
|
8829
|
+
"cache-control": "no-store"
|
|
8830
|
+
});
|
|
8831
|
+
res.end(JSON.stringify({ error: code }));
|
|
8832
|
+
}
|
|
8833
|
+
//#endregion
|
|
8834
|
+
//#region lib/types/zip-stream.js
|
|
8835
|
+
const LOCAL_HEADER_BYTES = 30;
|
|
8836
|
+
const CENTRAL_HEADER_BYTES = 46;
|
|
8837
|
+
const END_OF_CENTRAL_BYTES = 22;
|
|
8838
|
+
const ZIP64_END_BYTES = 56;
|
|
8839
|
+
const ZIP64_LOCATOR_BYTES = 20;
|
|
8840
|
+
const ZIP64_EXTRA_ID = 1;
|
|
8841
|
+
const ZIP64_VERSION = 45;
|
|
8842
|
+
const ZIP32_VERSION = 20;
|
|
8843
|
+
const UINT32_MAX = 4294967295;
|
|
8844
|
+
const UINT16_MAX = 65535;
|
|
8845
|
+
const MAX_ENTRIES = 4294967294;
|
|
8846
|
+
/** Raised when the selection cannot be represented even with ZIP64 records. */
|
|
8847
|
+
var ZipTooLargeError = class extends Error {
|
|
8848
|
+
constructor(message) {
|
|
8849
|
+
super(message);
|
|
8850
|
+
this.name = "ZipTooLargeError";
|
|
8851
|
+
}
|
|
8852
|
+
};
|
|
8853
|
+
/**
|
|
8854
|
+
* Write a ZIP archive straight to an HTTP response.
|
|
8855
|
+
*
|
|
8856
|
+
* Images are already compressed, so entries are stored (method 0) and the only
|
|
8857
|
+
* CPU work is the CRC — `zlib.crc32` runs in native code. Sizes are known before
|
|
8858
|
+
* each header is written, so no data descriptor is needed; entries that cross
|
|
8859
|
+
* the ZIP32 limits get ZIP64 extra fields, and the archive ends with a ZIP64
|
|
8860
|
+
* end-of-central-directory record when any entry needs one.
|
|
8861
|
+
*/
|
|
8862
|
+
var ZipStreamWriter = class {
|
|
8863
|
+
res;
|
|
8864
|
+
options;
|
|
8865
|
+
central = [];
|
|
8866
|
+
names = /* @__PURE__ */ new Set();
|
|
8867
|
+
offset = 0;
|
|
8868
|
+
entries = 0;
|
|
8869
|
+
closed = false;
|
|
8870
|
+
zip64Archive = false;
|
|
8871
|
+
constructor(res, options = {}) {
|
|
8872
|
+
this.res = res;
|
|
8873
|
+
this.options = options;
|
|
8874
|
+
}
|
|
8875
|
+
get entryCount() {
|
|
8876
|
+
return this.entries;
|
|
8877
|
+
}
|
|
8878
|
+
get bytesWritten() {
|
|
8879
|
+
return this.offset;
|
|
8880
|
+
}
|
|
8881
|
+
get isClosed() {
|
|
8882
|
+
return this.closed;
|
|
8883
|
+
}
|
|
8884
|
+
/** Stop writing as soon as the browser closes the download. */
|
|
8885
|
+
markClientClosed() {
|
|
8886
|
+
this.closed = true;
|
|
8887
|
+
}
|
|
8888
|
+
/** Append one file entry. */
|
|
8889
|
+
async add(name, data) {
|
|
8890
|
+
if (this.entries >= MAX_ENTRIES) throw new ZipTooLargeError("ZIP export exceeds the entry limit");
|
|
8891
|
+
const safeName = this.uniqueName(name);
|
|
8892
|
+
const nameBytes = Buffer.from(safeName, "utf8");
|
|
8893
|
+
const size = data.byteLength;
|
|
8894
|
+
const checksum = crc32(data);
|
|
8895
|
+
const { time, date } = dosDateTime();
|
|
8896
|
+
const headerOffset = this.offset;
|
|
8897
|
+
const zip64 = this.options.zip64 ?? "auto";
|
|
8898
|
+
const size64 = zip64 === "always" || size >= UINT32_MAX;
|
|
8899
|
+
const offset64 = zip64 === "always" || headerOffset >= UINT32_MAX;
|
|
8900
|
+
if (size64 || offset64) this.zip64Archive = true;
|
|
8901
|
+
const localExtra = size64 ? zip64Extra([size, size]) : Buffer.alloc(0);
|
|
8902
|
+
const version = size64 ? ZIP64_VERSION : ZIP32_VERSION;
|
|
8903
|
+
const local = Buffer.alloc(LOCAL_HEADER_BYTES);
|
|
8904
|
+
local.writeUInt32LE(67324752, 0);
|
|
8905
|
+
local.writeUInt16LE(version, 4);
|
|
8906
|
+
local.writeUInt16LE(2048, 6);
|
|
8907
|
+
local.writeUInt16LE(0, 8);
|
|
8908
|
+
local.writeUInt16LE(time, 10);
|
|
8909
|
+
local.writeUInt16LE(date, 12);
|
|
8910
|
+
local.writeUInt32LE(checksum, 14);
|
|
8911
|
+
local.writeUInt32LE(size64 ? UINT32_MAX : size, 18);
|
|
8912
|
+
local.writeUInt32LE(size64 ? UINT32_MAX : size, 22);
|
|
8913
|
+
local.writeUInt16LE(nameBytes.length, 26);
|
|
8914
|
+
local.writeUInt16LE(localExtra.length, 28);
|
|
8915
|
+
await this.write(local);
|
|
8916
|
+
await this.write(nameBytes);
|
|
8917
|
+
if (localExtra.length > 0) await this.write(localExtra);
|
|
8918
|
+
await this.write(data);
|
|
8919
|
+
this.central.push(this.centralRecord({
|
|
8920
|
+
nameBytes,
|
|
8921
|
+
checksum,
|
|
8922
|
+
size,
|
|
8923
|
+
headerOffset,
|
|
8924
|
+
size64,
|
|
8925
|
+
offset64,
|
|
8926
|
+
time,
|
|
8927
|
+
date
|
|
8928
|
+
}));
|
|
8929
|
+
this.entries += 1;
|
|
8930
|
+
}
|
|
8931
|
+
/** Write the central directory and finish the response. */
|
|
8932
|
+
async finish() {
|
|
8933
|
+
const centralOffset = this.offset;
|
|
8934
|
+
let centralSize = 0;
|
|
8935
|
+
for (const record of this.central) centralSize += record.byteLength;
|
|
8936
|
+
const zip64 = this.zip64Archive || this.entries > UINT16_MAX || centralOffset >= UINT32_MAX || centralSize >= UINT32_MAX;
|
|
8937
|
+
if (zip64) this.zip64Archive = true;
|
|
8938
|
+
for (const record of this.central) await this.write(record);
|
|
8939
|
+
if (zip64) {
|
|
8940
|
+
const zip64Offset = this.offset;
|
|
8941
|
+
const end64 = Buffer.alloc(ZIP64_END_BYTES);
|
|
8942
|
+
end64.writeUInt32LE(101075792, 0);
|
|
8943
|
+
end64.writeBigUInt64LE(44n, 4);
|
|
8944
|
+
end64.writeUInt16LE(ZIP64_VERSION, 12);
|
|
8945
|
+
end64.writeUInt16LE(ZIP64_VERSION, 14);
|
|
8946
|
+
end64.writeUInt32LE(0, 16);
|
|
8947
|
+
end64.writeUInt32LE(0, 20);
|
|
8948
|
+
end64.writeBigUInt64LE(BigInt(this.entries), 24);
|
|
8949
|
+
end64.writeBigUInt64LE(BigInt(this.entries), 32);
|
|
8950
|
+
end64.writeBigUInt64LE(BigInt(centralSize), 40);
|
|
8951
|
+
end64.writeBigUInt64LE(BigInt(centralOffset), 48);
|
|
8952
|
+
await this.write(end64);
|
|
8953
|
+
const locator = Buffer.alloc(ZIP64_LOCATOR_BYTES);
|
|
8954
|
+
locator.writeUInt32LE(117853008, 0);
|
|
8955
|
+
locator.writeUInt32LE(0, 4);
|
|
8956
|
+
locator.writeBigUInt64LE(BigInt(zip64Offset), 8);
|
|
8957
|
+
locator.writeUInt32LE(1, 16);
|
|
8958
|
+
await this.write(locator);
|
|
8959
|
+
}
|
|
8960
|
+
const end = Buffer.alloc(END_OF_CENTRAL_BYTES);
|
|
8961
|
+
end.writeUInt32LE(101010256, 0);
|
|
8962
|
+
end.writeUInt16LE(0, 4);
|
|
8963
|
+
end.writeUInt16LE(0, 6);
|
|
8964
|
+
end.writeUInt16LE(zip64 ? UINT16_MAX : this.entries, 8);
|
|
8965
|
+
end.writeUInt16LE(zip64 ? UINT16_MAX : this.entries, 10);
|
|
8966
|
+
end.writeUInt32LE(zip64 ? UINT32_MAX : centralSize, 12);
|
|
8967
|
+
end.writeUInt32LE(zip64 ? UINT32_MAX : centralOffset, 16);
|
|
8968
|
+
end.writeUInt16LE(0, 20);
|
|
8969
|
+
await this.write(end);
|
|
8970
|
+
this.res.end();
|
|
8971
|
+
}
|
|
8972
|
+
centralRecord(entry) {
|
|
8973
|
+
const extraValues = [...entry.size64 ? [entry.size, entry.size] : [], ...entry.offset64 ? [entry.headerOffset] : []];
|
|
8974
|
+
const extra = extraValues.length > 0 ? zip64Extra(extraValues) : Buffer.alloc(0);
|
|
8975
|
+
const central = Buffer.alloc(CENTRAL_HEADER_BYTES);
|
|
8976
|
+
central.writeUInt32LE(33639248, 0);
|
|
8977
|
+
central.writeUInt16LE(entry.size64 || entry.offset64 ? ZIP64_VERSION : ZIP32_VERSION, 4);
|
|
8978
|
+
central.writeUInt16LE(entry.size64 || entry.offset64 ? ZIP64_VERSION : ZIP32_VERSION, 6);
|
|
8979
|
+
central.writeUInt16LE(2048, 8);
|
|
8980
|
+
central.writeUInt16LE(0, 10);
|
|
8981
|
+
central.writeUInt16LE(entry.time, 12);
|
|
8982
|
+
central.writeUInt16LE(entry.date, 14);
|
|
8983
|
+
central.writeUInt32LE(entry.checksum, 16);
|
|
8984
|
+
central.writeUInt32LE(entry.size64 ? UINT32_MAX : entry.size, 20);
|
|
8985
|
+
central.writeUInt32LE(entry.size64 ? UINT32_MAX : entry.size, 24);
|
|
8986
|
+
central.writeUInt16LE(entry.nameBytes.length, 28);
|
|
8987
|
+
central.writeUInt16LE(extra.length, 30);
|
|
8988
|
+
central.writeUInt16LE(0, 32);
|
|
8989
|
+
central.writeUInt16LE(0, 34);
|
|
8990
|
+
central.writeUInt16LE(0, 36);
|
|
8991
|
+
central.writeUInt32LE(0, 38);
|
|
8992
|
+
central.writeUInt32LE(entry.offset64 ? UINT32_MAX : entry.headerOffset, 42);
|
|
8993
|
+
return Buffer.concat([
|
|
8994
|
+
central,
|
|
8995
|
+
entry.nameBytes,
|
|
8996
|
+
extra
|
|
8997
|
+
]);
|
|
8998
|
+
}
|
|
8999
|
+
async write(chunk) {
|
|
9000
|
+
if (this.closed) throw new Error("client closed the download");
|
|
9001
|
+
if (this.offset + chunk.byteLength > (this.options.maxBytes ?? Number.POSITIVE_INFINITY)) throw new ZipTooLargeError("ZIP export exceeds the archive byte limit");
|
|
9002
|
+
this.offset += chunk.byteLength;
|
|
9003
|
+
if (this.res.write(chunk)) return;
|
|
9004
|
+
await new Promise((resolve, reject) => {
|
|
9005
|
+
const cleanup = () => {
|
|
9006
|
+
this.res.off("drain", onDrain);
|
|
9007
|
+
this.res.off("close", onClose);
|
|
9008
|
+
};
|
|
9009
|
+
const onDrain = () => {
|
|
9010
|
+
cleanup();
|
|
9011
|
+
resolve();
|
|
9012
|
+
};
|
|
9013
|
+
const onClose = () => {
|
|
9014
|
+
cleanup();
|
|
9015
|
+
reject(/* @__PURE__ */ new Error("client closed the download"));
|
|
9016
|
+
};
|
|
9017
|
+
this.res.once("drain", onDrain);
|
|
9018
|
+
this.res.once("close", onClose);
|
|
9019
|
+
});
|
|
9020
|
+
}
|
|
9021
|
+
uniqueName(raw) {
|
|
9022
|
+
const cleaned = sanitizeName(raw);
|
|
9023
|
+
if (!this.names.has(cleaned)) {
|
|
9024
|
+
this.names.add(cleaned);
|
|
9025
|
+
return cleaned;
|
|
9026
|
+
}
|
|
9027
|
+
const dot = cleaned.lastIndexOf(".");
|
|
9028
|
+
const stem = dot > 0 ? cleaned.slice(0, dot) : cleaned;
|
|
9029
|
+
const extension = dot > 0 ? cleaned.slice(dot) : "";
|
|
9030
|
+
let suffix = 1;
|
|
9031
|
+
let candidate = cleaned;
|
|
9032
|
+
while (this.names.has(candidate)) {
|
|
9033
|
+
candidate = stem + "-" + suffix + extension;
|
|
9034
|
+
suffix += 1;
|
|
9035
|
+
}
|
|
9036
|
+
this.names.add(candidate);
|
|
9037
|
+
return candidate;
|
|
9038
|
+
}
|
|
9039
|
+
};
|
|
9040
|
+
/** Encode a ZIP64 extra field: id, size, then one or more 8-byte values. */
|
|
9041
|
+
function zip64Extra(values) {
|
|
9042
|
+
const body = Buffer.alloc(values.length * 8);
|
|
9043
|
+
values.forEach((value, index) => body.writeBigUInt64LE(BigInt(value), index * 8));
|
|
9044
|
+
const extra = Buffer.alloc(4 + body.length);
|
|
9045
|
+
extra.writeUInt16LE(ZIP64_EXTRA_ID, 0);
|
|
9046
|
+
extra.writeUInt16LE(body.length, 2);
|
|
9047
|
+
body.copy(extra, 4);
|
|
9048
|
+
return extra;
|
|
9049
|
+
}
|
|
9050
|
+
/** Keep download names portable: no paths, no control characters, bounded length. */
|
|
9051
|
+
function sanitizeName(raw) {
|
|
9052
|
+
const cleaned = (raw.split(/[\\/]/).pop() ?? "image").replace(/[^A-Za-z0-9._@-]+/g, "_").replace(/^[._]+/, "").slice(0, 120);
|
|
9053
|
+
return cleaned.length > 0 ? cleaned : "image";
|
|
9054
|
+
}
|
|
9055
|
+
function dosDateTime(when = /* @__PURE__ */ new Date()) {
|
|
9056
|
+
return {
|
|
9057
|
+
time: when.getHours() << 11 | when.getMinutes() << 5 | Math.floor(when.getSeconds() / 2),
|
|
9058
|
+
date: (Math.max(when.getFullYear() - 1980, 0) << 9 | when.getMonth() + 1 << 5 | when.getDate()) & 65535
|
|
9059
|
+
};
|
|
9060
|
+
}
|
|
9061
|
+
//#endregion
|
|
9062
|
+
//#region lib/types/zip-progress.js
|
|
9063
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
9064
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
|
|
9065
|
+
const JOB_TTL_MS = 6e5;
|
|
9066
|
+
/** Tokens are client-generated identifiers; anything else is ignored. */
|
|
9067
|
+
function isValidZipToken(token) {
|
|
9068
|
+
return TOKEN_PATTERN.test(token);
|
|
9069
|
+
}
|
|
9070
|
+
function notify(job) {
|
|
9071
|
+
job.updatedAt = Date.now();
|
|
9072
|
+
for (const listener of [...job.listeners]) listener();
|
|
9073
|
+
}
|
|
9074
|
+
function prune() {
|
|
9075
|
+
const cutoff = Date.now() - JOB_TTL_MS;
|
|
9076
|
+
for (const [token, job] of jobs) if (job.updatedAt < cutoff) jobs.delete(token);
|
|
9077
|
+
}
|
|
9078
|
+
function ensureJob(token) {
|
|
9079
|
+
if (!isValidZipToken(token)) return void 0;
|
|
9080
|
+
let job = jobs.get(token);
|
|
9081
|
+
if (job === void 0) {
|
|
9082
|
+
prune();
|
|
9083
|
+
job = {
|
|
9084
|
+
token,
|
|
9085
|
+
total: 0,
|
|
9086
|
+
done: 0,
|
|
9087
|
+
state: "running",
|
|
9088
|
+
cancelled: false,
|
|
9089
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
9090
|
+
updatedAt: Date.now()
|
|
9091
|
+
};
|
|
9092
|
+
jobs.set(token, job);
|
|
9093
|
+
}
|
|
9094
|
+
return job;
|
|
9095
|
+
}
|
|
9096
|
+
/** The SSE listener may connect first; createZipJob then fills in the total. */
|
|
9097
|
+
function createZipJob(token, total) {
|
|
9098
|
+
const job = ensureJob(token);
|
|
9099
|
+
if (job === void 0) return;
|
|
9100
|
+
job.total = total;
|
|
9101
|
+
job.done = 0;
|
|
9102
|
+
job.state = "running";
|
|
9103
|
+
notify(job);
|
|
9104
|
+
}
|
|
9105
|
+
function updateZipJob(token, done) {
|
|
9106
|
+
const job = jobs.get(token);
|
|
9107
|
+
if (job === void 0 || job.state !== "running") return;
|
|
9108
|
+
job.done = Math.max(job.done, done);
|
|
9109
|
+
notify(job);
|
|
9110
|
+
}
|
|
9111
|
+
function finishZipJob(token, state) {
|
|
9112
|
+
const job = jobs.get(token);
|
|
9113
|
+
if (job === void 0 || job.state !== "running") return;
|
|
9114
|
+
job.state = state;
|
|
9115
|
+
if (state === "done") job.done = job.total;
|
|
9116
|
+
notify(job);
|
|
9117
|
+
for (const listener of [...job.listeners]) job.listeners.delete(listener);
|
|
9118
|
+
}
|
|
9119
|
+
function isZipJobCancelled(token) {
|
|
9120
|
+
return jobs.get(token)?.cancelled === true;
|
|
9121
|
+
}
|
|
9122
|
+
function cancelZipJob(token) {
|
|
9123
|
+
const job = jobs.get(token);
|
|
9124
|
+
if (job === void 0) return false;
|
|
9125
|
+
if (job.state !== "running") return false;
|
|
9126
|
+
job.cancelled = true;
|
|
9127
|
+
notify(job);
|
|
9128
|
+
return true;
|
|
9129
|
+
}
|
|
9130
|
+
/**
|
|
9131
|
+
* Server-sent events for one export: the client keeps the fast iframe download
|
|
9132
|
+
* and still sees "N/M" progress plus completion.
|
|
9133
|
+
*/
|
|
9134
|
+
function serveZipProgress(req, res) {
|
|
9135
|
+
if (req.method !== "GET") return jsonError$8(res, 405, "method-not-allowed");
|
|
9136
|
+
const job = ensureJob(new URL(req.url ?? "/", "http://x").searchParams.get("token") ?? "");
|
|
9137
|
+
if (job === void 0) return jsonError$8(res, 404, "unknown-job");
|
|
9138
|
+
res.writeHead(200, {
|
|
9139
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
9140
|
+
"cache-control": "no-store",
|
|
9141
|
+
connection: "keep-alive",
|
|
9142
|
+
"x-accel-buffering": "no"
|
|
9143
|
+
});
|
|
9144
|
+
const send = () => {
|
|
9145
|
+
if (res.writableEnded) return;
|
|
9146
|
+
res.write("event: progress\ndata: " + JSON.stringify({
|
|
9147
|
+
done: job.done,
|
|
9148
|
+
total: job.total,
|
|
9149
|
+
state: job.state
|
|
9150
|
+
}) + "\n\n");
|
|
9151
|
+
if (job.state !== "running") {
|
|
9152
|
+
job.listeners.delete(send);
|
|
9153
|
+
res.end();
|
|
9154
|
+
}
|
|
9155
|
+
};
|
|
9156
|
+
send();
|
|
9157
|
+
if (job.state === "running") {
|
|
9158
|
+
job.listeners.add(send);
|
|
9159
|
+
const heartbeat = setInterval(() => {
|
|
9160
|
+
if (!res.writableEnded) res.write(": ping\n\n");
|
|
9161
|
+
}, 15e3);
|
|
9162
|
+
heartbeat.unref?.();
|
|
9163
|
+
res.on("close", () => {
|
|
9164
|
+
clearInterval(heartbeat);
|
|
9165
|
+
job.listeners.delete(send);
|
|
9166
|
+
});
|
|
9167
|
+
}
|
|
9168
|
+
}
|
|
9169
|
+
/** Stop an in-flight export; the ZIP response is destroyed so the download fails. */
|
|
9170
|
+
function serveZipCancel(req, res) {
|
|
9171
|
+
if (req.method !== "POST") return jsonError$8(res, 405, "method-not-allowed");
|
|
9172
|
+
const ok = cancelZipJob(new URL(req.url ?? "/", "http://x").searchParams.get("token") ?? "");
|
|
9173
|
+
res.writeHead(200, {
|
|
9174
|
+
"content-type": "application/json; charset=utf-8",
|
|
9175
|
+
"cache-control": "no-store"
|
|
9176
|
+
});
|
|
9177
|
+
res.end(JSON.stringify({ ok }));
|
|
9178
|
+
}
|
|
9179
|
+
function jsonError$8(res, status, code) {
|
|
9180
|
+
res.writeHead(status, {
|
|
9181
|
+
"content-type": "application/json; charset=utf-8",
|
|
9182
|
+
"cache-control": "no-store"
|
|
9183
|
+
});
|
|
9184
|
+
res.end(JSON.stringify({ error: code }));
|
|
9185
|
+
}
|
|
9186
|
+
//#endregion
|
|
9187
|
+
//#region lib/types/vault-store.js
|
|
9188
|
+
/** Content-addressed store for original image bytes the attachment layer normalized. */
|
|
9189
|
+
const EXTENSION = {
|
|
9190
|
+
"image/png": "png",
|
|
9191
|
+
"image/jpeg": "jpg",
|
|
9192
|
+
"image/webp": "webp",
|
|
9193
|
+
"image/gif": "gif"
|
|
9194
|
+
};
|
|
9195
|
+
const MEDIA_TYPE = {
|
|
9196
|
+
png: "image/png",
|
|
9197
|
+
jpg: "image/jpeg",
|
|
9198
|
+
webp: "image/webp",
|
|
9199
|
+
gif: "image/gif"
|
|
9200
|
+
};
|
|
9201
|
+
function resolveDshHome$1(home) {
|
|
9202
|
+
return home?.trim() || process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
|
|
9203
|
+
}
|
|
9204
|
+
function resolveVaultDir(home) {
|
|
9205
|
+
return join(resolveDshHome$1(home), "image-gen", "vault");
|
|
9206
|
+
}
|
|
9207
|
+
/** Resolve a configured vault path; relative values stay below the DSH home, not the process cwd. */
|
|
9208
|
+
function resolveVaultPath(configured, home) {
|
|
9209
|
+
const value = configured.trim();
|
|
9210
|
+
if (value.length === 0) return resolveVaultDir(home);
|
|
9211
|
+
return isAbsolute(value) ? value : join(resolveDshHome$1(home), value);
|
|
9212
|
+
}
|
|
9213
|
+
function isValidVaultKey(key) {
|
|
9214
|
+
return VAULT_KEY_PATTERN.test(key);
|
|
9215
|
+
}
|
|
9216
|
+
/** Vault settings as configured: off only when explicitly disabled; a relative dir stays below the DSH home. */
|
|
9217
|
+
function vaultConfigOf(config) {
|
|
9218
|
+
return {
|
|
9219
|
+
enabled: config.vaultEnabled !== false,
|
|
9220
|
+
dir: resolveVaultPath(config.vaultDir ?? "")
|
|
9221
|
+
};
|
|
9222
|
+
}
|
|
9223
|
+
/** Persist one original file; the same bytes are shared through content addressing. */
|
|
9224
|
+
async function saveOriginal(config, input) {
|
|
9225
|
+
if (!config.enabled) return void 0;
|
|
9226
|
+
const extension = EXTENSION[input.mediaType];
|
|
9227
|
+
if (extension === void 0) return void 0;
|
|
9228
|
+
const key = createHash("sha256").update(input.data).digest("hex");
|
|
9229
|
+
const target = join(config.dir, key.slice(0, 2), key + "." + extension);
|
|
9230
|
+
try {
|
|
9231
|
+
const existing = await stat(target);
|
|
9232
|
+
if (existing.size === input.data.byteLength) return {
|
|
9233
|
+
key,
|
|
9234
|
+
bytes: existing.size,
|
|
9235
|
+
mediaType: input.mediaType
|
|
9236
|
+
};
|
|
9237
|
+
} catch {}
|
|
9238
|
+
await mkdir(dirname(target), { recursive: true });
|
|
9239
|
+
const temporary = target + ".tmp-" + randomUUID();
|
|
9240
|
+
try {
|
|
9241
|
+
await writeFile(temporary, input.data);
|
|
9242
|
+
await rename(temporary, target);
|
|
9243
|
+
} catch (err) {
|
|
9244
|
+
await unlink(temporary).catch(() => void 0);
|
|
9245
|
+
throw err;
|
|
9246
|
+
}
|
|
9247
|
+
return {
|
|
9248
|
+
key,
|
|
9249
|
+
bytes: input.data.byteLength,
|
|
9250
|
+
mediaType: input.mediaType
|
|
9251
|
+
};
|
|
9252
|
+
}
|
|
9253
|
+
async function readOriginal(config, key, preferredMediaType) {
|
|
9254
|
+
if (!config.enabled || !isValidVaultKey(key)) return void 0;
|
|
9255
|
+
const preferred = preferredMediaType === void 0 ? void 0 : EXTENSION[preferredMediaType];
|
|
9256
|
+
const all = Object.entries(MEDIA_TYPE);
|
|
9257
|
+
const candidates = preferred === void 0 ? all : [[preferred, preferredMediaType], ...all.filter(([extension]) => extension !== preferred)];
|
|
9258
|
+
for (const [extension, mediaType] of candidates) try {
|
|
9259
|
+
return {
|
|
9260
|
+
data: await readFile(join(config.dir, key.slice(0, 2), key + "." + extension)),
|
|
9261
|
+
mediaType
|
|
9262
|
+
};
|
|
9263
|
+
} catch {}
|
|
9264
|
+
}
|
|
9265
|
+
async function listVaultKeys(config) {
|
|
9266
|
+
return (await listVaultFiles(config)).map((file) => file.key);
|
|
9267
|
+
}
|
|
9268
|
+
async function deleteVaultKeys(config, keys) {
|
|
9269
|
+
if (!config.enabled) return {
|
|
9270
|
+
removed: 0,
|
|
9271
|
+
bytes: 0
|
|
9272
|
+
};
|
|
9273
|
+
const targets = /* @__PURE__ */ new Set();
|
|
9274
|
+
for (const key of keys) if (isValidVaultKey(key)) targets.add(key);
|
|
9275
|
+
if (targets.size === 0) return {
|
|
9276
|
+
removed: 0,
|
|
9277
|
+
bytes: 0
|
|
9278
|
+
};
|
|
9279
|
+
const files = await listVaultFiles(config);
|
|
9280
|
+
let removed = 0;
|
|
9281
|
+
let bytes = 0;
|
|
9282
|
+
for (const file of files) {
|
|
9283
|
+
if (!targets.has(file.key)) continue;
|
|
9284
|
+
try {
|
|
9285
|
+
await unlink(file.path);
|
|
9286
|
+
removed += 1;
|
|
9287
|
+
bytes += file.bytes;
|
|
9288
|
+
} catch {}
|
|
9289
|
+
}
|
|
9290
|
+
await removeEmptyShards(config);
|
|
9291
|
+
return {
|
|
9292
|
+
removed,
|
|
9293
|
+
bytes
|
|
9294
|
+
};
|
|
9295
|
+
}
|
|
9296
|
+
async function removeEmptyShards(config) {
|
|
9297
|
+
try {
|
|
9298
|
+
for (const shard of await readdir(config.dir, { withFileTypes: true })) {
|
|
9299
|
+
if (!shard.isDirectory() || !/^[a-f0-9]{2}$/.test(shard.name)) continue;
|
|
9300
|
+
try {
|
|
9301
|
+
await rmdir(join(config.dir, shard.name));
|
|
9302
|
+
} catch {}
|
|
9303
|
+
}
|
|
9304
|
+
} catch {}
|
|
9305
|
+
}
|
|
9306
|
+
async function listVaultFiles(config) {
|
|
9307
|
+
const files = [];
|
|
9308
|
+
try {
|
|
9309
|
+
for (const shard of await readdir(config.dir, { withFileTypes: true })) {
|
|
9310
|
+
if (!shard.isDirectory() || !/^[a-f0-9]{2}$/.test(shard.name)) continue;
|
|
9311
|
+
for (const entry of await readdir(join(config.dir, shard.name), { withFileTypes: true })) {
|
|
9312
|
+
if (!entry.isFile()) continue;
|
|
9313
|
+
const dot = entry.name.lastIndexOf(".");
|
|
9314
|
+
const key = dot > 0 ? entry.name.slice(0, dot) : entry.name;
|
|
9315
|
+
if (!isValidVaultKey(key)) continue;
|
|
9316
|
+
const path = join(config.dir, shard.name, entry.name);
|
|
9317
|
+
try {
|
|
9318
|
+
const info = await stat(path);
|
|
9319
|
+
files.push({
|
|
9320
|
+
path,
|
|
9321
|
+
key,
|
|
9322
|
+
bytes: info.size,
|
|
9323
|
+
mtimeMs: info.mtimeMs
|
|
9324
|
+
});
|
|
9325
|
+
} catch {}
|
|
9326
|
+
}
|
|
9327
|
+
}
|
|
9328
|
+
} catch {
|
|
9329
|
+
return [];
|
|
9330
|
+
}
|
|
9331
|
+
return files;
|
|
9332
|
+
}
|
|
9333
|
+
/** Delete only the explicitly requested originals; callers must prove they are unreferenced. */
|
|
9334
|
+
//#endregion
|
|
9335
|
+
//#region lib/types/zip-route.js
|
|
9336
|
+
const DEFAULT_MAX_ENTRIES = 2e3;
|
|
9337
|
+
const DEFAULT_MAX_BODY_BYTES = 4194304;
|
|
9338
|
+
/** Sanity ceiling: ZIP64 removed the 4 GiB cap, not an accidental 2000 × 40 GiB. */
|
|
9339
|
+
const MAX_ARCHIVE_BYTES = 68719476736;
|
|
9340
|
+
const IMAGE_EXTENSIONS = {
|
|
9341
|
+
"image/png": "png",
|
|
9342
|
+
"image/jpeg": "jpg",
|
|
9343
|
+
"image/webp": "webp",
|
|
9344
|
+
"image/gif": "gif"
|
|
9345
|
+
};
|
|
9346
|
+
/**
|
|
9347
|
+
* Package selected gallery images into one ZIP and stream it to the browser.
|
|
9348
|
+
*
|
|
9349
|
+
* The request is a plain form post (`items` = JSON array of {name, attachment}),
|
|
9350
|
+
* so the renderer never builds the archive: the browser download manager owns
|
|
9351
|
+
* the transfer while this route reads attachments one at a time.
|
|
9352
|
+
*/
|
|
9353
|
+
async function serveZip(req, res, deps) {
|
|
9354
|
+
if (req.method !== "POST") return jsonError$7(res, 405, "method-not-allowed");
|
|
9355
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/x-www-form-urlencoded")) return jsonError$7(res, 415, "form-required");
|
|
9356
|
+
if (!sameOrigin$7(req)) return jsonError$7(res, 403, "origin-rejected");
|
|
9357
|
+
const queryToken = new URL(req.url ?? "/", "http://x").searchParams.get("token") ?? "";
|
|
9358
|
+
let jobToken = isValidZipToken(queryToken) ? queryToken : "";
|
|
9359
|
+
const fail = (status, code) => {
|
|
9360
|
+
if (jobToken !== "") {
|
|
9361
|
+
createZipJob(jobToken, 0);
|
|
9362
|
+
finishZipJob(jobToken, "error");
|
|
9363
|
+
}
|
|
9364
|
+
jsonError$7(res, status, code);
|
|
9365
|
+
};
|
|
9366
|
+
let body;
|
|
9367
|
+
try {
|
|
9368
|
+
body = await readBody$5(req, deps.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
|
|
9369
|
+
} catch {
|
|
9370
|
+
return fail(413, "request-too-large");
|
|
9371
|
+
}
|
|
9372
|
+
const params = new URLSearchParams(body);
|
|
9373
|
+
const tokenParam = params.get("token") ?? "";
|
|
9374
|
+
if (jobToken === "" && isValidZipToken(tokenParam)) jobToken = tokenParam;
|
|
9375
|
+
const rawItems = params.get("items");
|
|
9376
|
+
if (rawItems === null) return fail(400, "invalid-request");
|
|
9377
|
+
let parsed;
|
|
9378
|
+
try {
|
|
9379
|
+
parsed = JSON.parse(rawItems);
|
|
9380
|
+
} catch {
|
|
9381
|
+
return fail(400, "invalid-request");
|
|
9382
|
+
}
|
|
9383
|
+
if (!Array.isArray(parsed)) return fail(400, "invalid-request");
|
|
9384
|
+
const items = [];
|
|
9385
|
+
for (const entry of parsed) {
|
|
9386
|
+
const record = typeof entry === "object" && entry !== null && !Array.isArray(entry) ? entry : void 0;
|
|
9387
|
+
const attachment = parseImageAttachmentRef(record?.attachment);
|
|
9388
|
+
if (attachment === void 0) continue;
|
|
9389
|
+
items.push({
|
|
9390
|
+
attachment,
|
|
9391
|
+
...typeof record?.name === "string" ? { name: record.name } : {},
|
|
9392
|
+
...typeof record?.originalKey === "string" && isValidVaultKey(record.originalKey) ? { originalKey: record.originalKey } : {},
|
|
9393
|
+
...typeof record?.originalMediaType === "string" ? { originalMediaType: record.originalMediaType } : {}
|
|
9394
|
+
});
|
|
9395
|
+
}
|
|
9396
|
+
if (items.length === 0) return fail(400, "no-attachments");
|
|
9397
|
+
const maxEntries = deps.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
9398
|
+
if (items.length > maxEntries) return fail(413, "too-many-attachments");
|
|
9399
|
+
let declaredBytes = 0;
|
|
9400
|
+
for (const item of items) declaredBytes += item.attachment.bytes;
|
|
9401
|
+
const maxArchiveBytes = deps.maxArchiveBytes ?? MAX_ARCHIVE_BYTES;
|
|
9402
|
+
if (declaredBytes > maxArchiveBytes) return fail(413, "archive-too-large");
|
|
9403
|
+
if (jobToken !== "") createZipJob(jobToken, items.length);
|
|
9404
|
+
const filename = "dsh-gallery-" + (/* @__PURE__ */ new Date()).toISOString().slice(0, 10).replace(/-/g, "") + ".zip";
|
|
9405
|
+
res.writeHead(200, {
|
|
9406
|
+
"content-type": "application/zip",
|
|
9407
|
+
"content-disposition": "attachment; filename=\"" + filename + "\"",
|
|
9408
|
+
"cache-control": "no-store",
|
|
9409
|
+
"x-content-type-options": "nosniff"
|
|
9410
|
+
});
|
|
9411
|
+
const zip = new ZipStreamWriter(res, { maxBytes: maxArchiveBytes });
|
|
9412
|
+
const skipped = [];
|
|
9413
|
+
const onClose = () => {
|
|
9414
|
+
if (res.writableFinished) return;
|
|
9415
|
+
zip.markClientClosed();
|
|
9416
|
+
if (jobToken !== "") finishZipJob(jobToken, "cancelled");
|
|
9417
|
+
};
|
|
9418
|
+
res.on("close", onClose);
|
|
9419
|
+
const stopped = () => {
|
|
9420
|
+
if (zip.isClosed) return true;
|
|
9421
|
+
if (jobToken !== "" && isZipJobCancelled(jobToken)) {
|
|
9422
|
+
finishZipJob(jobToken, "cancelled");
|
|
9423
|
+
res.destroy();
|
|
9424
|
+
return true;
|
|
9425
|
+
}
|
|
9426
|
+
return false;
|
|
9427
|
+
};
|
|
9428
|
+
try {
|
|
9429
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
9430
|
+
if (stopped()) return;
|
|
9431
|
+
const item = items[index];
|
|
9432
|
+
let original;
|
|
9433
|
+
if (item.originalKey !== void 0 && deps.readVault !== void 0) {
|
|
9434
|
+
try {
|
|
9435
|
+
original = await deps.readVault(item.originalKey, item.originalMediaType);
|
|
9436
|
+
} catch {
|
|
9437
|
+
original = void 0;
|
|
9438
|
+
}
|
|
9439
|
+
if (stopped()) return;
|
|
9440
|
+
}
|
|
9441
|
+
const mediaType = original?.mediaType ?? item.attachment.mediaType;
|
|
9442
|
+
const extension = IMAGE_EXTENSIONS[mediaType] ?? "png";
|
|
9443
|
+
const fallback = "dsh-" + String(item.attachment.attachmentId).replace(/^sha256:/, "").slice(0, 8) + "." + extension;
|
|
9444
|
+
const entryName = item.name === void 0 ? fallback : original === void 0 ? replaceExtension(item.name, extension) : item.name;
|
|
9445
|
+
try {
|
|
9446
|
+
if (original !== void 0) await zip.add(entryName, Buffer.from(original.data));
|
|
9447
|
+
else {
|
|
9448
|
+
const stored = await deps.readImage(item.attachment);
|
|
9449
|
+
if (stopped()) return;
|
|
9450
|
+
await zip.add(entryName, Buffer.from(stored.data));
|
|
9451
|
+
}
|
|
9452
|
+
} catch (err) {
|
|
9453
|
+
if (isClosed(zip, err)) return;
|
|
9454
|
+
if (err instanceof ZipTooLargeError) throw err;
|
|
9455
|
+
skipped.push(String(item.attachment.attachmentId) + " " + (err instanceof Error ? err.message : "unavailable"));
|
|
9456
|
+
}
|
|
9457
|
+
if (jobToken !== "") updateZipJob(jobToken, index + 1);
|
|
9458
|
+
}
|
|
9459
|
+
if (skipped.length > 0) {
|
|
9460
|
+
if (stopped()) return;
|
|
9461
|
+
await zip.add("skipped.txt", Buffer.from(skipped.join("\n") + "\n", "utf8"));
|
|
9462
|
+
}
|
|
9463
|
+
if (stopped()) return;
|
|
9464
|
+
await zip.finish();
|
|
9465
|
+
await waitForResponseFinish(res);
|
|
9466
|
+
if (jobToken !== "") finishZipJob(jobToken, "done");
|
|
9467
|
+
} catch (err) {
|
|
9468
|
+
if (isClosed(zip, err)) return;
|
|
9469
|
+
if (jobToken !== "") finishZipJob(jobToken, "error");
|
|
9470
|
+
if (!res.headersSent) {
|
|
9471
|
+
jsonError$7(res, err instanceof ZipTooLargeError ? 413 : 500, err instanceof ZipTooLargeError ? "archive-too-large" : "zip-failed");
|
|
9472
|
+
return;
|
|
9473
|
+
}
|
|
9474
|
+
res.destroy(err instanceof Error ? err : void 0);
|
|
9475
|
+
} finally {
|
|
9476
|
+
res.off("close", onClose);
|
|
9477
|
+
}
|
|
9478
|
+
}
|
|
9479
|
+
function waitForResponseFinish(res) {
|
|
9480
|
+
if (res.writableFinished) return Promise.resolve();
|
|
9481
|
+
if (res.destroyed) return Promise.reject(/* @__PURE__ */ new Error("client closed the download"));
|
|
9482
|
+
return new Promise((resolve, reject) => {
|
|
9483
|
+
const cleanup = () => {
|
|
9484
|
+
res.off("finish", onFinish);
|
|
9485
|
+
res.off("close", onClose);
|
|
9486
|
+
};
|
|
9487
|
+
const onFinish = () => {
|
|
9488
|
+
cleanup();
|
|
9489
|
+
resolve();
|
|
9490
|
+
};
|
|
9491
|
+
const onClose = () => {
|
|
9492
|
+
cleanup();
|
|
9493
|
+
reject(/* @__PURE__ */ new Error("client closed the download"));
|
|
9494
|
+
};
|
|
9495
|
+
res.once("finish", onFinish);
|
|
9496
|
+
res.once("close", onClose);
|
|
9497
|
+
});
|
|
9498
|
+
}
|
|
9499
|
+
function replaceExtension(name, extension) {
|
|
9500
|
+
const slash = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\"));
|
|
9501
|
+
const dot = name.lastIndexOf(".");
|
|
9502
|
+
return dot <= slash ? name + "." + extension : name.slice(0, dot) + "." + extension;
|
|
9503
|
+
}
|
|
9504
|
+
function isClosed(zip, err) {
|
|
9505
|
+
return zip.isClosed || err instanceof Error && err.message === "client closed the download";
|
|
9506
|
+
}
|
|
9507
|
+
function sameOrigin$7(req) {
|
|
9508
|
+
const origin = req.headers.origin;
|
|
9509
|
+
const host = req.headers.host;
|
|
9510
|
+
if (origin !== void 0 && host !== void 0 && origin !== "http://" + host && origin !== "https://" + host) return false;
|
|
9511
|
+
return true;
|
|
9512
|
+
}
|
|
9513
|
+
async function readBody$5(req, limit) {
|
|
9514
|
+
const chunks = [];
|
|
9515
|
+
let bytes = 0;
|
|
9516
|
+
for await (const chunk of req) {
|
|
9517
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
9518
|
+
bytes += buffer.byteLength;
|
|
9519
|
+
if (bytes > limit) throw new Error("request too large");
|
|
9520
|
+
chunks.push(buffer);
|
|
9521
|
+
}
|
|
9522
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
9523
|
+
}
|
|
9524
|
+
function jsonError$7(res, status, code) {
|
|
9525
|
+
res.writeHead(status, {
|
|
9526
|
+
"content-type": "application/json; charset=utf-8",
|
|
9527
|
+
"cache-control": "no-store"
|
|
9528
|
+
});
|
|
9529
|
+
res.end(JSON.stringify({ error: code }));
|
|
9530
|
+
}
|
|
9531
|
+
/** The cache layout is <dir>/<2 hex>/<variant hash>; two levels are enough. */
|
|
9532
|
+
const MAX_DEPTH = 2;
|
|
9533
|
+
/** Resolve the default cache root below DSH_HOME. */
|
|
9534
|
+
function resolveRequestImageCacheDir(home) {
|
|
9535
|
+
const root = home?.trim() || process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
|
|
9536
|
+
return join(root, "cache", "attachments", "request-images");
|
|
9537
|
+
}
|
|
9538
|
+
/** Durable attachment objects; never a pruning target, even via a broad override. */
|
|
9539
|
+
function durableAttachmentsRoot() {
|
|
9540
|
+
return join(process.env.DSH_HOME?.trim() || join(homedir(), ".dsh"), "attachments");
|
|
9541
|
+
}
|
|
9542
|
+
function comparable(path) {
|
|
9543
|
+
const resolved = resolve(path);
|
|
9544
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
9545
|
+
}
|
|
9546
|
+
function isWithin(path, root) {
|
|
9547
|
+
return path === root || path.startsWith(root + sep);
|
|
9548
|
+
}
|
|
9549
|
+
/** Resolve through junctions/symlinks, falling back to the nearest existing ancestor. */
|
|
9550
|
+
async function canonicalPath(path) {
|
|
9551
|
+
let probe = resolve(path);
|
|
9552
|
+
const suffix = [];
|
|
9553
|
+
while (true) try {
|
|
9554
|
+
const real = await realpath(probe);
|
|
9555
|
+
return comparable(join(real, ...suffix.reverse()));
|
|
9556
|
+
} catch {
|
|
9557
|
+
const parent = dirname(probe);
|
|
9558
|
+
if (parent === probe) return comparable(resolve(path));
|
|
9559
|
+
suffix.push(basename(probe));
|
|
9560
|
+
probe = parent;
|
|
9561
|
+
}
|
|
9562
|
+
}
|
|
9563
|
+
async function overlapsProtectedRoot(target, roots) {
|
|
9564
|
+
for (const root of roots) {
|
|
9565
|
+
const protectedRoot = await canonicalPath(root);
|
|
9566
|
+
if (target === protectedRoot || isWithin(target, protectedRoot) || isWithin(protectedRoot, target)) return true;
|
|
9567
|
+
}
|
|
9568
|
+
return false;
|
|
9569
|
+
}
|
|
9570
|
+
/**
|
|
9571
|
+
* Remove stale request-image variants.
|
|
9572
|
+
*
|
|
9573
|
+
* The attachment backend writes one file per (attachment, target) variant and
|
|
9574
|
+
* never cleans them up itself, so a gallery full of 320 px thumbnails grows it
|
|
9575
|
+
* without bound. Deletion is safe: variants are deterministic and regenerated
|
|
9576
|
+
* on demand, so removing an entry only costs one sharp pass next time.
|
|
9577
|
+
*/
|
|
9578
|
+
async function pruneRequestImageCache(options) {
|
|
9579
|
+
const ttlMs = options.ttlMs ?? 12096e5;
|
|
9580
|
+
const maxBytes = options.maxBytes ?? 268435456;
|
|
9581
|
+
const keepMs = options.keepMs ?? 36e5;
|
|
9582
|
+
const now = options.now ?? Date.now();
|
|
9583
|
+
const protectedRoots = options.protectedRoots ?? [durableAttachmentsRoot()];
|
|
9584
|
+
const cacheRoot = await canonicalPath(options.dir);
|
|
9585
|
+
if (await overlapsProtectedRoot(cacheRoot, protectedRoots)) return {
|
|
9586
|
+
scanned: 0,
|
|
9587
|
+
removed: 0,
|
|
9588
|
+
freedBytes: 0,
|
|
9589
|
+
remainingBytes: 0
|
|
9590
|
+
};
|
|
9591
|
+
const files = [];
|
|
9592
|
+
await collect(options.dir, 0, files, cacheRoot);
|
|
9593
|
+
let remainingBytes = files.reduce((sum, file) => sum + file.bytes, 0);
|
|
9594
|
+
const protectedAfter = now - keepMs;
|
|
9595
|
+
const expiredBefore = now - ttlMs;
|
|
9596
|
+
const doomed = /* @__PURE__ */ new Set();
|
|
9597
|
+
for (const file of files) if (file.mtimeMs < expiredBefore && file.mtimeMs < protectedAfter) {
|
|
9598
|
+
doomed.add(file.path);
|
|
9599
|
+
remainingBytes -= file.bytes;
|
|
9600
|
+
}
|
|
9601
|
+
if (maxBytes > 0 && remainingBytes > maxBytes) {
|
|
9602
|
+
const candidates = files.filter((file) => !doomed.has(file.path) && file.mtimeMs < protectedAfter).sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
9603
|
+
for (const file of candidates) {
|
|
9604
|
+
if (remainingBytes <= maxBytes) break;
|
|
9605
|
+
doomed.add(file.path);
|
|
9606
|
+
remainingBytes -= file.bytes;
|
|
9607
|
+
}
|
|
9608
|
+
}
|
|
9609
|
+
let removed = 0;
|
|
9610
|
+
let freedBytes = 0;
|
|
9611
|
+
for (const file of files) {
|
|
9612
|
+
if (!doomed.has(file.path)) continue;
|
|
9613
|
+
try {
|
|
9614
|
+
const latest = await lstat(file.path);
|
|
9615
|
+
if (latest.isSymbolicLink() || !latest.isFile() || !isWithin(await canonicalPath(file.path), cacheRoot)) {
|
|
9616
|
+
remainingBytes += file.bytes;
|
|
9617
|
+
continue;
|
|
9618
|
+
}
|
|
9619
|
+
await unlink(file.path);
|
|
9620
|
+
removed += 1;
|
|
9621
|
+
freedBytes += file.bytes;
|
|
9622
|
+
} catch {
|
|
9623
|
+
remainingBytes += file.bytes;
|
|
9624
|
+
}
|
|
9625
|
+
}
|
|
9626
|
+
return {
|
|
9627
|
+
scanned: files.length,
|
|
9628
|
+
removed,
|
|
9629
|
+
freedBytes,
|
|
9630
|
+
remainingBytes
|
|
9631
|
+
};
|
|
9632
|
+
}
|
|
9633
|
+
async function collect(dir, depth, out, cacheRoot) {
|
|
9634
|
+
let entries;
|
|
9635
|
+
try {
|
|
9636
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
9637
|
+
} catch {
|
|
9638
|
+
return;
|
|
9639
|
+
}
|
|
9640
|
+
for (const entry of entries) {
|
|
9641
|
+
const path = join(dir, entry.name);
|
|
9642
|
+
let info;
|
|
9643
|
+
try {
|
|
9644
|
+
info = await lstat(path);
|
|
9645
|
+
} catch {
|
|
9646
|
+
continue;
|
|
9647
|
+
}
|
|
9648
|
+
if (info.isSymbolicLink()) continue;
|
|
9649
|
+
if (info.isDirectory()) {
|
|
9650
|
+
if (depth >= MAX_DEPTH) continue;
|
|
9651
|
+
if (!isWithin(await canonicalPath(path), cacheRoot)) continue;
|
|
9652
|
+
await collect(path, depth + 1, out, cacheRoot);
|
|
9653
|
+
continue;
|
|
9654
|
+
}
|
|
9655
|
+
if (!info.isFile()) continue;
|
|
9656
|
+
if (!isWithin(await canonicalPath(path), cacheRoot)) continue;
|
|
9657
|
+
try {
|
|
9658
|
+
const latest = await stat(path);
|
|
9659
|
+
out.push({
|
|
9660
|
+
path,
|
|
9661
|
+
bytes: latest.size,
|
|
9662
|
+
mtimeMs: latest.mtimeMs
|
|
9663
|
+
});
|
|
9664
|
+
} catch {}
|
|
9665
|
+
}
|
|
9666
|
+
}
|
|
9667
|
+
function resolveDshHome() {
|
|
9668
|
+
return process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
|
|
9669
|
+
}
|
|
9670
|
+
function resolveAttachmentsRoot(home = resolveDshHome()) {
|
|
9671
|
+
return join(home, "attachments", "v1", "objects");
|
|
9672
|
+
}
|
|
9673
|
+
async function scanTree(root) {
|
|
9674
|
+
try {
|
|
9675
|
+
const entries = await readdir(root, {
|
|
9676
|
+
recursive: true,
|
|
9677
|
+
withFileTypes: true
|
|
9678
|
+
});
|
|
9679
|
+
const files = [];
|
|
9680
|
+
let bytes = 0;
|
|
9681
|
+
for (const entry of entries) {
|
|
9682
|
+
if (!entry.isFile()) continue;
|
|
9683
|
+
const parent = entry.parentPath ?? root;
|
|
9684
|
+
try {
|
|
9685
|
+
const info = await stat(join(parent, entry.name));
|
|
9686
|
+
bytes += info.size;
|
|
9687
|
+
files.push({
|
|
9688
|
+
name: entry.name,
|
|
9689
|
+
bytes: info.size
|
|
9690
|
+
});
|
|
9691
|
+
} catch {}
|
|
9692
|
+
}
|
|
9693
|
+
return {
|
|
9694
|
+
count: files.length,
|
|
9695
|
+
bytes,
|
|
9696
|
+
files
|
|
9697
|
+
};
|
|
9698
|
+
} catch {
|
|
9699
|
+
return {
|
|
9700
|
+
count: 0,
|
|
9701
|
+
bytes: 0,
|
|
9702
|
+
files: []
|
|
9703
|
+
};
|
|
9704
|
+
}
|
|
9705
|
+
}
|
|
9706
|
+
let baseCache = null;
|
|
9707
|
+
async function collectStorageStats(attachmentIds, options = {}) {
|
|
9708
|
+
const now = options.now ?? Date.now();
|
|
9709
|
+
const ttl = options.ttlMs ?? 6e5;
|
|
9710
|
+
const injected = options.attachmentsRoot !== void 0;
|
|
9711
|
+
const attachmentsRoot = options.attachmentsRoot ?? resolveAttachmentsRoot();
|
|
9712
|
+
const requestImagesRoot = options.requestImagesRoot ?? resolveRequestImageCacheDir(resolveDshHome());
|
|
9713
|
+
if (!injected && baseCache !== null && baseCache.requestImagesRoot === requestImagesRoot && now - baseCache.at < ttl) return buildStats(baseCache.attachments, baseCache.requestImages, attachmentIds, now);
|
|
9714
|
+
const [attachments, requestImages] = await Promise.all([scanTree(attachmentsRoot), scanTree(requestImagesRoot)]);
|
|
9715
|
+
if (!injected) baseCache = {
|
|
9716
|
+
at: now,
|
|
9717
|
+
attachments,
|
|
9718
|
+
requestImages,
|
|
9719
|
+
requestImagesRoot
|
|
9720
|
+
};
|
|
9721
|
+
return buildStats(attachments, requestImages, attachmentIds, now);
|
|
9722
|
+
}
|
|
9723
|
+
function buildStats(attachments, requestImages, attachmentIds, now) {
|
|
9724
|
+
const refs = /* @__PURE__ */ new Set();
|
|
9725
|
+
for (const id of attachmentIds) {
|
|
9726
|
+
const value = String(id);
|
|
9727
|
+
refs.add(value);
|
|
9728
|
+
refs.add(value.replace(/^sha256:/, ""));
|
|
9729
|
+
}
|
|
9730
|
+
const orphans = attachments.files.filter((file) => !refs.has(file.name) && !refs.has("sha256:" + file.name)).map((file) => ({
|
|
9731
|
+
id: "sha256:" + file.name,
|
|
9732
|
+
bytes: file.bytes
|
|
9733
|
+
}));
|
|
9734
|
+
return {
|
|
9735
|
+
attachments: {
|
|
9736
|
+
count: attachments.count,
|
|
9737
|
+
bytes: attachments.bytes
|
|
9738
|
+
},
|
|
9739
|
+
requestImages: {
|
|
9740
|
+
count: requestImages.count,
|
|
9741
|
+
bytes: requestImages.bytes
|
|
9742
|
+
},
|
|
9743
|
+
orphans: {
|
|
9744
|
+
count: orphans.length,
|
|
9745
|
+
bytes: orphans.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
9746
|
+
ids: orphans.map((entry) => entry.id)
|
|
9747
|
+
},
|
|
9748
|
+
scannedAt: now
|
|
9749
|
+
};
|
|
9750
|
+
}
|
|
9751
|
+
/** POST {attachmentIds: string[]} -> read-only usage numbers. */
|
|
9752
|
+
async function serveStorageStats(req, res, deps = {}) {
|
|
9753
|
+
if (req.method !== "POST") return jsonError$6(res, 405, "method-not-allowed");
|
|
9754
|
+
if (!sameOrigin$6(req)) return jsonError$6(res, 403, "origin-rejected");
|
|
9755
|
+
let body;
|
|
9756
|
+
try {
|
|
9757
|
+
body = await readBody$4(req, 2097152);
|
|
9758
|
+
} catch {
|
|
9759
|
+
return jsonError$6(res, 413, "request-too-large");
|
|
9760
|
+
}
|
|
9761
|
+
let parsed;
|
|
9762
|
+
try {
|
|
9763
|
+
parsed = JSON.parse(body);
|
|
9764
|
+
} catch {
|
|
9765
|
+
return jsonError$6(res, 400, "invalid-request");
|
|
9766
|
+
}
|
|
9767
|
+
const ids = parsed?.attachmentIds;
|
|
9768
|
+
if (!Array.isArray(ids)) return jsonError$6(res, 400, "invalid-request");
|
|
9769
|
+
const stats = await collectStorageStats(ids.filter((id) => typeof id === "string"), deps.requestImagesRoot === void 0 ? {} : { requestImagesRoot: deps.requestImagesRoot });
|
|
9770
|
+
res.writeHead(200, {
|
|
9771
|
+
"content-type": "application/json; charset=utf-8",
|
|
9772
|
+
"cache-control": "no-store"
|
|
9773
|
+
});
|
|
9774
|
+
res.end(JSON.stringify(stats));
|
|
9775
|
+
}
|
|
9776
|
+
function sameOrigin$6(req) {
|
|
9777
|
+
const origin = req.headers.origin;
|
|
9778
|
+
const host = req.headers.host;
|
|
9779
|
+
if (origin !== void 0 && host !== void 0 && origin !== "http://" + host && origin !== "https://" + host) return false;
|
|
9780
|
+
return true;
|
|
9781
|
+
}
|
|
9782
|
+
async function readBody$4(req, limit) {
|
|
9783
|
+
const chunks = [];
|
|
9784
|
+
let bytes = 0;
|
|
9785
|
+
for await (const chunk of req) {
|
|
9786
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
9787
|
+
bytes += buffer.byteLength;
|
|
9788
|
+
if (bytes > limit) throw new Error("request too large");
|
|
9789
|
+
chunks.push(buffer);
|
|
9790
|
+
}
|
|
9791
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
9792
|
+
}
|
|
9793
|
+
function jsonError$6(res, status, code) {
|
|
9794
|
+
res.writeHead(status, {
|
|
9795
|
+
"content-type": "application/json; charset=utf-8",
|
|
9796
|
+
"cache-control": "no-store"
|
|
9797
|
+
});
|
|
9798
|
+
res.end(JSON.stringify({ error: code }));
|
|
9799
|
+
}
|
|
9800
|
+
//#endregion
|
|
9801
|
+
//#region lib/types/gif-info.js
|
|
9802
|
+
/**
|
|
9803
|
+
* Frame count, timing and size of a GIF, read from its block structure, plus
|
|
9804
|
+
* a speed change that rewrites only the frame delays: no decoding, no node
|
|
9805
|
+
* imports, so the browser bundle shares it.
|
|
9806
|
+
*/
|
|
9807
|
+
/**
|
|
9808
|
+
* Read every frame's delay. Returns undefined for anything that is not a GIF
|
|
9809
|
+
* with at least one intact frame: callers only decorate a successful result
|
|
9810
|
+
* with these facts, so a parse failure must never fail the image.
|
|
9811
|
+
*/
|
|
9812
|
+
function readGifInfo(bytes) {
|
|
9813
|
+
const delaysMs = [];
|
|
9814
|
+
return walkGif(bytes, (delayMs) => {
|
|
9815
|
+
delaysMs.push(delayMs);
|
|
9816
|
+
}) && delaysMs.length > 0 ? {
|
|
9817
|
+
frames: delaysMs.length,
|
|
9818
|
+
delaysMs,
|
|
9819
|
+
width: u16(bytes, 6),
|
|
9820
|
+
height: u16(bytes, 8)
|
|
9821
|
+
} : void 0;
|
|
9822
|
+
}
|
|
9823
|
+
/** The most common frame delay (the first one on a tie); 0 without frames. */
|
|
9824
|
+
function gifFrameDurationMs(delaysMs) {
|
|
9825
|
+
const counts = /* @__PURE__ */ new Map();
|
|
9826
|
+
for (const delay of delaysMs) counts.set(delay, (counts.get(delay) ?? 0) + 1);
|
|
9827
|
+
let winner = delaysMs[0] ?? 0;
|
|
9828
|
+
for (const [delay, count] of counts) if (count > (counts.get(winner) ?? 0)) winner = delay;
|
|
9829
|
+
return winner;
|
|
9830
|
+
}
|
|
9831
|
+
/**
|
|
9832
|
+
* Walk the block sequence after the header and report each image descriptor
|
|
9833
|
+
* with the delay of the Graphic Control Extension that precedes it (and the
|
|
9834
|
+
* byte offset of that delay field, when there is one). The walk follows block
|
|
9835
|
+
* lengths instead of scanning for byte patterns because the LZW payload can
|
|
9836
|
+
* contain anything, including a fake `0x21 0xF9`. Returns true when every
|
|
9837
|
+
* block was intact: a missing trailer byte is tolerated, as decoders tolerate
|
|
9838
|
+
* it, but a cut block or an unknown block id is not.
|
|
9839
|
+
*/
|
|
9840
|
+
function walkGif(bytes, onFrame) {
|
|
9841
|
+
if (bytes.length < 13) return false;
|
|
9842
|
+
const signature = String.fromCharCode(...bytes.subarray(0, 6));
|
|
9843
|
+
if (signature !== "GIF87a" && signature !== "GIF89a") return false;
|
|
9844
|
+
let offset = 13 + colorTableBytes(bytes[10]);
|
|
9845
|
+
let pendingDelayMs = 0;
|
|
9846
|
+
let pendingDelayOffset;
|
|
9847
|
+
while (offset < bytes.length) {
|
|
9848
|
+
const block = bytes[offset];
|
|
9849
|
+
if (block === 59) return true;
|
|
9850
|
+
if (block === 33) {
|
|
9851
|
+
const label = bytes[offset + 1];
|
|
9852
|
+
if (label === void 0) return false;
|
|
9853
|
+
let cursor = offset + 2;
|
|
9854
|
+
let first = true;
|
|
9855
|
+
while (true) {
|
|
9856
|
+
const size = bytes[cursor];
|
|
9857
|
+
if (size === void 0) return false;
|
|
9858
|
+
if (size === 0) break;
|
|
9859
|
+
if (first && label === 249 && size >= 3) {
|
|
9860
|
+
pendingDelayOffset = cursor + 2;
|
|
9861
|
+
pendingDelayMs = u16(bytes, pendingDelayOffset) * 10;
|
|
9862
|
+
}
|
|
9863
|
+
first = false;
|
|
9864
|
+
cursor += 1 + size;
|
|
9865
|
+
}
|
|
9866
|
+
offset = cursor + 1;
|
|
9867
|
+
continue;
|
|
9868
|
+
}
|
|
9869
|
+
if (block === 44) {
|
|
9870
|
+
if (offset + 10 > bytes.length) return false;
|
|
9871
|
+
let cursor = offset + 10 + colorTableBytes(bytes[offset + 9]);
|
|
9872
|
+
cursor += 1;
|
|
9873
|
+
while (true) {
|
|
9874
|
+
const size = bytes[cursor];
|
|
9875
|
+
if (size === void 0) return false;
|
|
9876
|
+
if (size === 0) break;
|
|
9877
|
+
cursor += 1 + size;
|
|
9878
|
+
}
|
|
9879
|
+
onFrame(pendingDelayMs, pendingDelayOffset);
|
|
9880
|
+
pendingDelayMs = 0;
|
|
9881
|
+
pendingDelayOffset = void 0;
|
|
9882
|
+
offset = cursor + 1;
|
|
9883
|
+
continue;
|
|
9884
|
+
}
|
|
9885
|
+
return false;
|
|
9886
|
+
}
|
|
9887
|
+
return true;
|
|
9888
|
+
}
|
|
9889
|
+
function u16(bytes, offset) {
|
|
9890
|
+
return (bytes[offset] ?? 0) | (bytes[offset + 1] ?? 0) << 8;
|
|
9891
|
+
}
|
|
9892
|
+
/** Byte length of a colour table announced by a packed descriptor byte, 0 when absent. */
|
|
9893
|
+
function colorTableBytes(packed) {
|
|
9894
|
+
return (packed & 128) === 0 ? 0 : 3 * (1 << (packed & 7) + 1);
|
|
9895
|
+
}
|
|
9896
|
+
//#endregion
|
|
9897
|
+
//#region lib/types/vault-registry.js
|
|
9898
|
+
/** Persistent per-profile vault references so one client cannot delete another client's originals. */
|
|
9899
|
+
const REGISTRY_FILE = ".refs.json";
|
|
9900
|
+
const PROFILE_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
|
|
9901
|
+
function isValidVaultProfileId(value) {
|
|
9902
|
+
return typeof value === "string" && PROFILE_PATTERN.test(value);
|
|
9903
|
+
}
|
|
9904
|
+
function registryPath(dir) {
|
|
9905
|
+
return join(dir, REGISTRY_FILE);
|
|
9906
|
+
}
|
|
9907
|
+
function normalizeRegistry(value) {
|
|
9908
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
9909
|
+
const raw = value;
|
|
9910
|
+
if (raw.version !== 1 || typeof raw.profiles !== "object" || raw.profiles === null || Array.isArray(raw.profiles)) return void 0;
|
|
9911
|
+
const profiles = Object.create(null);
|
|
9912
|
+
for (const [profileId, profile] of Object.entries(raw.profiles)) {
|
|
9913
|
+
if (!isValidVaultProfileId(profileId) || typeof profile !== "object" || profile === null || Array.isArray(profile)) return void 0;
|
|
9914
|
+
const { keys, updatedAt } = profile;
|
|
9915
|
+
if (!Array.isArray(keys) || !keys.every((key) => typeof key === "string" && isValidVaultKey(key))) return void 0;
|
|
9916
|
+
if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt) || updatedAt < 0) return void 0;
|
|
9917
|
+
profiles[profileId] = {
|
|
9918
|
+
keys: Array.from(new Set(keys)),
|
|
9919
|
+
updatedAt
|
|
9920
|
+
};
|
|
9921
|
+
}
|
|
9922
|
+
return {
|
|
9923
|
+
version: 1,
|
|
9924
|
+
profiles
|
|
9925
|
+
};
|
|
9926
|
+
}
|
|
9927
|
+
async function loadVaultRegistry(dir) {
|
|
9928
|
+
try {
|
|
9929
|
+
const registry = normalizeRegistry(JSON.parse(await readFile(registryPath(dir), "utf8")));
|
|
9930
|
+
return registry === void 0 ? { status: "corrupt" } : {
|
|
9931
|
+
status: "ok",
|
|
9932
|
+
registry
|
|
9933
|
+
};
|
|
9934
|
+
} catch (err) {
|
|
9935
|
+
if (err.code === "ENOENT") return { status: "missing" };
|
|
9936
|
+
return { status: "corrupt" };
|
|
9937
|
+
}
|
|
9938
|
+
}
|
|
9939
|
+
const queues = /* @__PURE__ */ new Map();
|
|
9940
|
+
/** Canonicalize existing ancestors too, so aliases share a lock before creation. */
|
|
9941
|
+
async function vaultTransactionKey(dir) {
|
|
9942
|
+
let parent = resolve(dir);
|
|
9943
|
+
const suffix = [];
|
|
9944
|
+
while (true) try {
|
|
9945
|
+
const canonical = join(await realpath(parent), ...suffix);
|
|
9946
|
+
return process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
9947
|
+
} catch (error) {
|
|
9948
|
+
if (error.code !== "ENOENT" || dirname(parent) === parent) throw error;
|
|
9949
|
+
suffix.unshift(basename(parent));
|
|
9950
|
+
parent = dirname(parent);
|
|
9951
|
+
}
|
|
9952
|
+
}
|
|
9953
|
+
/** Serialize reference mutations and deletion together within this host. */
|
|
9954
|
+
async function inVaultTransaction(dir, run) {
|
|
9955
|
+
const key = await vaultTransactionKey(dir);
|
|
9956
|
+
const operation = (queues.get(key) ?? Promise.resolve()).then(run);
|
|
9957
|
+
const tail = operation.then(() => void 0, () => void 0);
|
|
9958
|
+
queues.set(key, tail);
|
|
9959
|
+
tail.then(() => {
|
|
9960
|
+
if (queues.get(key) === tail) queues.delete(key);
|
|
9961
|
+
});
|
|
9962
|
+
return operation;
|
|
9963
|
+
}
|
|
9964
|
+
/** Persist a complete profile only after all newly claimed originals exist. */
|
|
9965
|
+
async function writeVaultProfile(dir, profileId, keys, loaded) {
|
|
9966
|
+
if (loaded.status === "corrupt" || !isValidVaultProfileId(profileId) || profileId === "legacy-vault" || !keys.every(isValidVaultKey)) return void 0;
|
|
9967
|
+
const registry = loaded.registry ?? {
|
|
9968
|
+
version: 1,
|
|
9969
|
+
profiles: {}
|
|
9970
|
+
};
|
|
9971
|
+
const accepted = Array.from(new Set(keys));
|
|
9972
|
+
const previousProfile = registry.profiles[profileId];
|
|
9973
|
+
const previous = new Set(previousProfile?.keys ?? []);
|
|
9974
|
+
if (loaded.status === "ok" && previousProfile !== void 0 && accepted.length === previous.size && accepted.every((key) => previous.has(key))) return registry;
|
|
9975
|
+
let existing;
|
|
9976
|
+
if (loaded.status === "missing" || accepted.some((key) => !previous.has(key))) {
|
|
9977
|
+
existing = await listVaultKeys({
|
|
9978
|
+
enabled: true,
|
|
9979
|
+
dir
|
|
9980
|
+
});
|
|
9981
|
+
const available = new Set(existing);
|
|
9982
|
+
if (accepted.some((key) => !available.has(key))) return void 0;
|
|
9983
|
+
}
|
|
9984
|
+
if (loaded.status === "missing") {
|
|
9985
|
+
const legacy = existing.filter((key) => !accepted.includes(key));
|
|
9986
|
+
if (legacy.length > 0) registry.profiles["legacy-vault"] = {
|
|
9987
|
+
keys: legacy,
|
|
9988
|
+
updatedAt: 0
|
|
9989
|
+
};
|
|
9990
|
+
}
|
|
9991
|
+
Object.defineProperty(registry.profiles, profileId, {
|
|
9992
|
+
value: {
|
|
9993
|
+
keys: accepted,
|
|
9994
|
+
updatedAt: Date.now()
|
|
9995
|
+
},
|
|
9996
|
+
enumerable: true,
|
|
9997
|
+
writable: true,
|
|
9998
|
+
configurable: true
|
|
9999
|
+
});
|
|
10000
|
+
await persistRegistry(dir, registry);
|
|
10001
|
+
return registry;
|
|
10002
|
+
}
|
|
10003
|
+
async function persistRegistry(dir, registry) {
|
|
10004
|
+
await mkdir(dir, { recursive: true });
|
|
10005
|
+
const target = registryPath(dir);
|
|
10006
|
+
const temporary = target + ".tmp-" + randomUUID();
|
|
10007
|
+
try {
|
|
10008
|
+
await writeFile(temporary, JSON.stringify(registry, null, 2), "utf8");
|
|
10009
|
+
await rename(temporary, target);
|
|
10010
|
+
} finally {
|
|
10011
|
+
await unlink(temporary).catch(() => void 0);
|
|
10012
|
+
}
|
|
10013
|
+
}
|
|
10014
|
+
/** Store and claim an import in the same transaction used by explicit deletion. */
|
|
10015
|
+
function saveImportedOriginal(config, input, profileId) {
|
|
10016
|
+
if (!config.enabled) return Promise.resolve(void 0);
|
|
10017
|
+
return inVaultTransaction(config.dir, async () => {
|
|
10018
|
+
if (profileId !== void 0 && (!isValidVaultProfileId(profileId) || profileId === "legacy-vault")) throw new Error("invalid-vault-profile");
|
|
10019
|
+
const loaded = await loadVaultRegistry(config.dir);
|
|
10020
|
+
if (loaded.status === "corrupt") throw new Error("registry-unavailable");
|
|
10021
|
+
const registry = loaded.registry ?? {
|
|
10022
|
+
version: 1,
|
|
10023
|
+
profiles: {}
|
|
10024
|
+
};
|
|
10025
|
+
if (loaded.status === "missing") {
|
|
10026
|
+
const legacy = await listVaultKeys(config);
|
|
10027
|
+
if (legacy.length > 0) registry.profiles["legacy-vault"] = {
|
|
10028
|
+
keys: legacy,
|
|
10029
|
+
updatedAt: 0
|
|
10030
|
+
};
|
|
10031
|
+
}
|
|
10032
|
+
const saved = await saveOriginal(config, input);
|
|
10033
|
+
if (saved === void 0) return void 0;
|
|
10034
|
+
const owner = profileId ?? "legacy-vault";
|
|
10035
|
+
Object.defineProperty(registry.profiles, owner, {
|
|
10036
|
+
value: {
|
|
10037
|
+
keys: [.../* @__PURE__ */ new Set([...registry.profiles[owner]?.keys ?? [], saved.key])],
|
|
10038
|
+
updatedAt: Date.now()
|
|
10039
|
+
},
|
|
10040
|
+
enumerable: true,
|
|
10041
|
+
configurable: true,
|
|
10042
|
+
writable: true
|
|
10043
|
+
});
|
|
10044
|
+
await persistRegistry(config.dir, registry);
|
|
10045
|
+
return saved;
|
|
10046
|
+
});
|
|
10047
|
+
}
|
|
10048
|
+
/**
|
|
10049
|
+
* Store a generated original without claiming it: the browser profiles that
|
|
10050
|
+
* render its card claim it one by one, so it is owned by whoever has seen it.
|
|
10051
|
+
* The registry is created here, with the same legacy sweep an import does,
|
|
10052
|
+
* because the first single-key claim would otherwise create it and sweep every
|
|
10053
|
+
* other unclaimed generated file into the legacy profile, where the gallery
|
|
10054
|
+
* could never delete it. A corrupt registry is left alone: it already blocks
|
|
10055
|
+
* every sync and deletion, while the file is still worth keeping for playback.
|
|
10056
|
+
*/
|
|
10057
|
+
function saveGeneratedOriginal(config, input) {
|
|
10058
|
+
if (!config.enabled) return Promise.resolve(void 0);
|
|
10059
|
+
return inVaultTransaction(config.dir, async () => {
|
|
10060
|
+
if ((await loadVaultRegistry(config.dir)).status === "missing") {
|
|
10061
|
+
const legacy = await listVaultKeys(config);
|
|
10062
|
+
const registry = {
|
|
10063
|
+
version: 1,
|
|
10064
|
+
profiles: {}
|
|
10065
|
+
};
|
|
10066
|
+
if (legacy.length > 0) registry.profiles["legacy-vault"] = {
|
|
10067
|
+
keys: legacy,
|
|
10068
|
+
updatedAt: 0
|
|
10069
|
+
};
|
|
10070
|
+
await persistRegistry(config.dir, registry);
|
|
10071
|
+
}
|
|
10072
|
+
return saveOriginal(config, input);
|
|
10073
|
+
});
|
|
10074
|
+
}
|
|
10075
|
+
/**
|
|
10076
|
+
* Keep the raw bytes of a generated GIF when the attachment layer normalized
|
|
10077
|
+
* it (a GIF is the only type where normalization drops the animation; a still
|
|
10078
|
+
* image loses at most some compression, and its workspace file keeps the
|
|
10079
|
+
* original). A single-frame GIF is a still image as well, as the tool result
|
|
10080
|
+
* already tells the model, and keeps nothing. The image is already paid for,
|
|
10081
|
+
* so a vault failure is reported through `warn` and the result stays usable
|
|
10082
|
+
* without the original.
|
|
10083
|
+
*/
|
|
10084
|
+
async function keepGifOriginal(config, generated, attachment, warn) {
|
|
10085
|
+
if (generated.mediaType !== "image/gif") return void 0;
|
|
10086
|
+
if (attachment.mediaType === "image/gif" && attachment.bytes === generated.data.byteLength) return void 0;
|
|
10087
|
+
if ((readGifInfo(generated.data)?.frames ?? 0) < 2) return void 0;
|
|
10088
|
+
try {
|
|
10089
|
+
return await saveGeneratedOriginal(config, {
|
|
10090
|
+
data: generated.data,
|
|
10091
|
+
mediaType: "image/gif"
|
|
10092
|
+
});
|
|
10093
|
+
} catch (error) {
|
|
10094
|
+
warn(`dsh-image-gen: failed to keep the original GIF: ${error instanceof Error ? error.message : String(error)}`);
|
|
10095
|
+
return;
|
|
10096
|
+
}
|
|
10097
|
+
}
|
|
10098
|
+
/**
|
|
10099
|
+
* Merge one profile's complete key set into the persistent registry.
|
|
10100
|
+
*
|
|
10101
|
+
* On first creation, pre-existing vault files that the syncing profile does
|
|
10102
|
+
* not already reference are placed in a reserved legacy profile, so a browser
|
|
10103
|
+
* that has not opened yet cannot lose an original. Once a profile syncs a
|
|
10104
|
+
* legacy key, the unknown legacy owners remain protected too.
|
|
10105
|
+
*
|
|
10106
|
+
* A refused claim is told apart: `'missing-original'` when a key of this
|
|
10107
|
+
* request is not on disk (the only refusal a client should drop its reference
|
|
10108
|
+
* for), undefined when the registry itself cannot be written.
|
|
10109
|
+
*/
|
|
10110
|
+
function updateVaultRegistry(dir, profileId, keys) {
|
|
10111
|
+
return inVaultTransaction(dir, async () => {
|
|
10112
|
+
const loaded = await loadVaultRegistry(dir);
|
|
10113
|
+
const registry = await writeVaultProfile(dir, profileId, [...loaded.registry?.profiles[profileId]?.keys ?? [], ...keys], loaded);
|
|
10114
|
+
if (registry !== void 0 || loaded.status === "corrupt") return registry;
|
|
10115
|
+
const available = new Set(await listVaultKeys({
|
|
10116
|
+
enabled: true,
|
|
10117
|
+
dir
|
|
10118
|
+
}));
|
|
10119
|
+
return keys.some((key) => !available.has(key)) ? "missing-original" : void 0;
|
|
10120
|
+
}).catch(() => void 0);
|
|
10121
|
+
}
|
|
10122
|
+
/** Remove explicitly owned keys using the same transaction as reference sync. */
|
|
10123
|
+
function deleteUnreferencedOriginals(config, profileId, keys, deleteKeys) {
|
|
10124
|
+
return inVaultTransaction(config.dir, async () => {
|
|
10125
|
+
const loaded = await loadVaultRegistry(config.dir);
|
|
10126
|
+
if (loaded.status !== "ok" || loaded.registry === void 0) return void 0;
|
|
10127
|
+
const previouslyOwned = new Set(loaded.registry.profiles[profileId]?.keys ?? []);
|
|
10128
|
+
const removedKeys = new Set(deleteKeys);
|
|
10129
|
+
const remaining = [...previouslyOwned].filter((key) => !removedKeys.has(key));
|
|
10130
|
+
const registry = await writeVaultProfile(config.dir, profileId, [...remaining, ...keys], loaded);
|
|
10131
|
+
if (registry === void 0) return void 0;
|
|
10132
|
+
const referenced = new Set(unionVaultKeys(registry));
|
|
10133
|
+
return deleteVaultKeys(config, deleteKeys.filter((key) => previouslyOwned.has(key) && !referenced.has(key)));
|
|
10134
|
+
}).catch(() => void 0);
|
|
10135
|
+
}
|
|
10136
|
+
function unionVaultKeys(registry) {
|
|
10137
|
+
const keys = /* @__PURE__ */ new Set();
|
|
10138
|
+
for (const profile of Object.values(registry.profiles)) for (const key of profile.keys) keys.add(key);
|
|
10139
|
+
return Array.from(keys);
|
|
10140
|
+
}
|
|
10141
|
+
//#endregion
|
|
10142
|
+
//#region lib/types/vault-route.js
|
|
10143
|
+
async function serveVault(req, res, config) {
|
|
10144
|
+
if (req.method !== "GET") return jsonError$5(res, 405, "method-not-allowed");
|
|
10145
|
+
if (!sameOrigin$5(req)) return jsonError$5(res, 403, "origin-rejected");
|
|
10146
|
+
const params = new URL(req.url ?? "/", "http://x").searchParams;
|
|
10147
|
+
const key = params.get("key") ?? "";
|
|
10148
|
+
if (!isValidVaultKey(key)) return jsonError$5(res, 400, "invalid-key");
|
|
10149
|
+
const mediaType = params.get("mediaType") ?? void 0;
|
|
10150
|
+
if (!config.enabled) return jsonError$5(res, 503, "vault-disabled");
|
|
10151
|
+
const original = await readOriginal(config, key, mediaType);
|
|
10152
|
+
if (original === void 0) return jsonError$5(res, 404, "not-found");
|
|
10153
|
+
const etag = "\"sha256:" + key + "\"";
|
|
10154
|
+
const cacheControl = "private, max-age=31536000, immutable";
|
|
10155
|
+
const noneMatch = req.headers["if-none-match"];
|
|
10156
|
+
if (typeof noneMatch === "string" && noneMatch.split(",").some((value) => value.trim() === etag)) {
|
|
10157
|
+
res.writeHead(304, {
|
|
10158
|
+
"cache-control": cacheControl,
|
|
10159
|
+
etag
|
|
10160
|
+
});
|
|
10161
|
+
res.end();
|
|
10162
|
+
return;
|
|
10163
|
+
}
|
|
10164
|
+
res.writeHead(200, {
|
|
10165
|
+
"content-type": original.mediaType,
|
|
10166
|
+
"content-length": String(original.data.byteLength),
|
|
10167
|
+
"cache-control": cacheControl,
|
|
10168
|
+
etag,
|
|
10169
|
+
"x-content-type-options": "nosniff"
|
|
10170
|
+
});
|
|
10171
|
+
res.end(original.data);
|
|
10172
|
+
}
|
|
10173
|
+
/** Sync one browser profile's references; never deletes originals. 410 names a key that is not on disk, 409 a registry no profile can write. */
|
|
10174
|
+
async function serveVaultRefs(req, res, config) {
|
|
10175
|
+
if (req.method !== "POST") return jsonError$5(res, 405, "method-not-allowed");
|
|
10176
|
+
if (!sameOrigin$5(req)) return jsonError$5(res, 403, "origin-rejected");
|
|
10177
|
+
const payload = await readJson(req, res);
|
|
10178
|
+
if (payload === void 0) return;
|
|
10179
|
+
const profileId = payload.profileId;
|
|
10180
|
+
const keys = payload.keys;
|
|
10181
|
+
if (!isValidVaultProfileId(profileId)) {
|
|
10182
|
+
jsonOk(res, {
|
|
10183
|
+
removed: 0,
|
|
10184
|
+
bytes: 0
|
|
10185
|
+
});
|
|
10186
|
+
return;
|
|
10187
|
+
}
|
|
10188
|
+
if (!validKeys(keys) || profileId === "legacy-vault") return jsonError$5(res, 400, "invalid-request");
|
|
10189
|
+
const registry = await updateVaultRegistry(config.dir, profileId, keys);
|
|
10190
|
+
if (registry === "missing-original") return jsonError$5(res, 410, "original-missing");
|
|
10191
|
+
if (registry === void 0) return jsonError$5(res, 409, "registry-unavailable");
|
|
10192
|
+
jsonOk(res, {
|
|
10193
|
+
removed: 0,
|
|
10194
|
+
bytes: 0,
|
|
10195
|
+
profiles: Object.keys(registry.profiles).length
|
|
10196
|
+
});
|
|
10197
|
+
}
|
|
10198
|
+
/** Explicit gallery deletion: remove only keys no profile still references. */
|
|
10199
|
+
async function serveVaultDelete(req, res, config) {
|
|
10200
|
+
if (req.method !== "POST") return jsonError$5(res, 405, "method-not-allowed");
|
|
10201
|
+
if (!sameOrigin$5(req)) return jsonError$5(res, 403, "origin-rejected");
|
|
10202
|
+
const payload = await readJson(req, res);
|
|
10203
|
+
if (payload === void 0) return;
|
|
10204
|
+
const profileId = payload.profileId;
|
|
10205
|
+
const keys = payload.keys;
|
|
10206
|
+
const deleteKeys = payload.deleteKeys;
|
|
10207
|
+
if (!isValidVaultProfileId(profileId) || profileId === "legacy-vault") return jsonError$5(res, 400, "invalid-profile");
|
|
10208
|
+
if (!validKeys(keys) || !validKeys(deleteKeys) || payload.confirm !== true) return jsonError$5(res, 400, "invalid-request");
|
|
10209
|
+
const stats = await deleteUnreferencedOriginals(config, profileId, keys, deleteKeys);
|
|
10210
|
+
if (stats === void 0) return jsonError$5(res, 409, "registry-unavailable");
|
|
10211
|
+
jsonOk(res, stats);
|
|
10212
|
+
}
|
|
10213
|
+
function validKeys(value) {
|
|
10214
|
+
return Array.isArray(value) && value.every((key) => typeof key === "string" && isValidVaultKey(key));
|
|
10215
|
+
}
|
|
10216
|
+
async function readJson(req, res) {
|
|
10217
|
+
let body;
|
|
10218
|
+
try {
|
|
10219
|
+
body = await readBody$3(req, 4194304);
|
|
10220
|
+
} catch {
|
|
10221
|
+
jsonError$5(res, 413, "request-too-large");
|
|
10222
|
+
return;
|
|
10223
|
+
}
|
|
10224
|
+
try {
|
|
10225
|
+
const parsed = JSON.parse(body);
|
|
10226
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
10227
|
+
jsonError$5(res, 400, "invalid-request");
|
|
10228
|
+
return;
|
|
10229
|
+
}
|
|
10230
|
+
return parsed;
|
|
10231
|
+
} catch {
|
|
10232
|
+
jsonError$5(res, 400, "invalid-request");
|
|
10233
|
+
return;
|
|
8371
10234
|
}
|
|
10235
|
+
}
|
|
10236
|
+
function jsonOk(res, value) {
|
|
8372
10237
|
res.writeHead(200, {
|
|
8373
|
-
"content-type": "application/json",
|
|
10238
|
+
"content-type": "application/json; charset=utf-8",
|
|
8374
10239
|
"cache-control": "no-store"
|
|
8375
10240
|
});
|
|
8376
|
-
res.end(JSON.stringify(
|
|
8377
|
-
|
|
8378
|
-
|
|
8379
|
-
|
|
10241
|
+
res.end(JSON.stringify(value));
|
|
10242
|
+
}
|
|
10243
|
+
function sameOrigin$5(req) {
|
|
10244
|
+
const origin = req.headers.origin;
|
|
10245
|
+
const host = req.headers.host;
|
|
10246
|
+
if (origin !== void 0 && host !== void 0 && origin !== "http://" + host && origin !== "https://" + host) return false;
|
|
10247
|
+
return true;
|
|
8380
10248
|
}
|
|
8381
|
-
async function readBody$3(req,
|
|
10249
|
+
async function readBody$3(req, limit) {
|
|
8382
10250
|
const chunks = [];
|
|
8383
10251
|
let bytes = 0;
|
|
8384
10252
|
for await (const chunk of req) {
|
|
8385
10253
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
8386
10254
|
bytes += buffer.byteLength;
|
|
8387
|
-
if (bytes >
|
|
10255
|
+
if (bytes > limit) throw new Error("request too large");
|
|
8388
10256
|
chunks.push(buffer);
|
|
8389
10257
|
}
|
|
8390
10258
|
return Buffer.concat(chunks).toString("utf8");
|
|
8391
10259
|
}
|
|
10260
|
+
function jsonError$5(res, status, code) {
|
|
10261
|
+
res.writeHead(status, {
|
|
10262
|
+
"content-type": "application/json; charset=utf-8",
|
|
10263
|
+
"cache-control": "no-store"
|
|
10264
|
+
});
|
|
10265
|
+
res.end(JSON.stringify({ error: code }));
|
|
10266
|
+
}
|
|
10267
|
+
//#endregion
|
|
10268
|
+
//#region lib/types/thumb-route.js
|
|
10269
|
+
const THUMB = {
|
|
10270
|
+
defaultWidth: 320,
|
|
10271
|
+
minWidth: 64,
|
|
10272
|
+
maxWidth: 1024,
|
|
10273
|
+
/** Generous for a 320 px tile, so the quality ladder stays at its top rung. */
|
|
10274
|
+
maxBytes: 98304,
|
|
10275
|
+
unavailableCode: "thumbnail-unavailable"
|
|
10276
|
+
};
|
|
10277
|
+
/**
|
|
10278
|
+
* A 1600 px long edge keeps lightbox decoding small (a 33.5 MP original costs
|
|
10279
|
+
* roughly 130 MB as RGBA) while staying sharp on a desktop window.
|
|
10280
|
+
*/
|
|
10281
|
+
const PREVIEW = {
|
|
10282
|
+
defaultWidth: 1600,
|
|
10283
|
+
minWidth: 256,
|
|
10284
|
+
maxWidth: 2048,
|
|
10285
|
+
maxBytes: 524288,
|
|
10286
|
+
unavailableCode: "preview-unavailable"
|
|
10287
|
+
};
|
|
10288
|
+
/**
|
|
10289
|
+
* Serve one downscaled, cached thumbnail.
|
|
10290
|
+
*
|
|
10291
|
+
* The attachment backend already derives deterministic request images (resize by
|
|
10292
|
+
* long edge + JPEG/WebP quality ladder) and caches them on disk, so the gallery
|
|
10293
|
+
* can render a tile without transferring the original to the renderer at all.
|
|
10294
|
+
*/
|
|
10295
|
+
async function serveThumb(req, res, deps) {
|
|
10296
|
+
return serveVariant(req, res, deps, THUMB);
|
|
10297
|
+
}
|
|
10298
|
+
/** Serve the mid-size variant used by the lightbox preview. */
|
|
10299
|
+
async function servePreview(req, res, deps) {
|
|
10300
|
+
return serveVariant(req, res, deps, PREVIEW);
|
|
10301
|
+
}
|
|
10302
|
+
async function serveVariant(req, res, deps, config) {
|
|
10303
|
+
if (req.method !== "GET") return jsonError$4(res, 405, "method-not-allowed");
|
|
10304
|
+
if (!sameOrigin$4(req)) return jsonError$4(res, 403, "origin-rejected");
|
|
10305
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
10306
|
+
const rawRef = url.searchParams.get("ref");
|
|
10307
|
+
const attachment = rawRef === null ? void 0 : decodeImageRef(rawRef, parseImageAttachmentRef);
|
|
10308
|
+
if (attachment === void 0) return jsonError$4(res, 400, "invalid-attachment");
|
|
10309
|
+
const requested = Number(url.searchParams.get("w") ?? config.defaultWidth);
|
|
10310
|
+
const width = Number.isFinite(requested) ? Math.min(config.maxWidth, Math.max(config.minWidth, Math.round(requested))) : config.defaultWidth;
|
|
10311
|
+
try {
|
|
10312
|
+
const variant = await deps.readImageRequest(attachment, {
|
|
10313
|
+
width,
|
|
10314
|
+
height: width,
|
|
10315
|
+
maxBytes: config.maxBytes
|
|
10316
|
+
});
|
|
10317
|
+
const etag = "\"" + String(variant.variantId) + "\"";
|
|
10318
|
+
const headers = {
|
|
10319
|
+
"cache-control": "private, max-age=31536000, immutable",
|
|
10320
|
+
etag
|
|
10321
|
+
};
|
|
10322
|
+
const ifNoneMatch = req.headers["if-none-match"];
|
|
10323
|
+
if (typeof ifNoneMatch === "string" && ifNoneMatch.split(",").some((value) => value.trim() === etag)) {
|
|
10324
|
+
res.writeHead(304, headers);
|
|
10325
|
+
res.end();
|
|
10326
|
+
return;
|
|
10327
|
+
}
|
|
10328
|
+
res.writeHead(200, {
|
|
10329
|
+
"content-type": variant.mediaType,
|
|
10330
|
+
"content-length": String(variant.data.byteLength),
|
|
10331
|
+
"x-content-type-options": "nosniff",
|
|
10332
|
+
...headers
|
|
10333
|
+
});
|
|
10334
|
+
res.end(variant.data);
|
|
10335
|
+
} catch {
|
|
10336
|
+
jsonError$4(res, 404, config.unavailableCode);
|
|
10337
|
+
}
|
|
10338
|
+
}
|
|
10339
|
+
function sameOrigin$4(req) {
|
|
10340
|
+
const origin = req.headers.origin;
|
|
10341
|
+
const host = req.headers.host;
|
|
10342
|
+
if (origin !== void 0 && host !== void 0 && origin !== `http://${host}` && origin !== `https://${host}`) return false;
|
|
10343
|
+
return true;
|
|
10344
|
+
}
|
|
8392
10345
|
function jsonError$4(res, status, code) {
|
|
8393
10346
|
res.writeHead(status, {
|
|
8394
10347
|
"content-type": "application/json",
|
|
@@ -8535,26 +10488,66 @@ async function editOpenAICompatibleImage(input) {
|
|
|
8535
10488
|
body: form
|
|
8536
10489
|
}), input.apiKey), "openai", input);
|
|
8537
10490
|
}
|
|
8538
|
-
const
|
|
8539
|
-
const
|
|
8540
|
-
|
|
8541
|
-
const
|
|
8542
|
-
const
|
|
8543
|
-
|
|
8544
|
-
|
|
8545
|
-
|
|
8546
|
-
|
|
8547
|
-
|
|
8548
|
-
|
|
8549
|
-
|
|
8550
|
-
|
|
8551
|
-
|
|
10491
|
+
const responseFormat = input.editResponseFormat ?? "url";
|
|
10492
|
+
const explicitResponseFormat = Object.prototype.hasOwnProperty.call(input.extraBody ?? {}, "response_format");
|
|
10493
|
+
const buildEditForm = (includeResponseFormat) => {
|
|
10494
|
+
const form = new FormData();
|
|
10495
|
+
const imageField = input.sourceImages.length > 1 ? "image[]" : "image";
|
|
10496
|
+
input.sourceImages.forEach((sourceImage, index) => {
|
|
10497
|
+
const uploadBytes = new Uint8Array(sourceImage.data);
|
|
10498
|
+
const blob = new Blob([uploadBytes], { type: sourceImage.mediaType });
|
|
10499
|
+
const filename = `reference-${index + 1}.${extensionOf(sourceImage.mediaType)}`;
|
|
10500
|
+
form.append(imageField, blob, filename);
|
|
10501
|
+
});
|
|
10502
|
+
form.append("prompt", input.prompt);
|
|
10503
|
+
form.append("model", input.model);
|
|
10504
|
+
if (input.size !== void 0 && input.size.length > 0) form.append("size", input.size);
|
|
10505
|
+
if (input.quality !== void 0) form.append("quality", input.quality);
|
|
10506
|
+
if (includeResponseFormat && !explicitResponseFormat) form.append("response_format", "b64_json");
|
|
10507
|
+
for (const [key, value] of Object.entries(input.extraBody ?? {})) {
|
|
10508
|
+
if (value === void 0) continue;
|
|
10509
|
+
form.append(key, String(value));
|
|
10510
|
+
}
|
|
10511
|
+
return form;
|
|
10512
|
+
};
|
|
10513
|
+
const sendEdit = (form) => guardedFetch("openai image edit request", input.signal, () => fetch(imageEndpoint$1(input.baseURL, "edits"), {
|
|
8552
10514
|
method: "POST",
|
|
8553
10515
|
redirect: "error",
|
|
8554
10516
|
signal: input.signal,
|
|
8555
10517
|
headers: { authorization: `Bearer ${input.apiKey}` },
|
|
8556
10518
|
body: form
|
|
8557
|
-
}), input.apiKey)
|
|
10519
|
+
}), input.apiKey);
|
|
10520
|
+
let response = await sendEdit(buildEditForm(responseFormat !== "url"));
|
|
10521
|
+
if (responseFormat === "auto" && !explicitResponseFormat && (response.status === 400 || response.status === 422)) {
|
|
10522
|
+
const text = await readBoundedText$1(response, ERROR_LIMIT$1);
|
|
10523
|
+
if (!rejectsResponseFormat(text)) throw new Error(`openai image request failed (${response.status}): ${redactSecrets(text, input.apiKey).slice(0, ERROR_LIMIT$1)}`);
|
|
10524
|
+
input.onResponseFormatRetry?.(response.status);
|
|
10525
|
+
response = await sendEdit(buildEditForm(false));
|
|
10526
|
+
}
|
|
10527
|
+
return parseImageResponse(response, "openai", input);
|
|
10528
|
+
}
|
|
10529
|
+
function rejectsResponseFormat(text) {
|
|
10530
|
+
let message = text;
|
|
10531
|
+
try {
|
|
10532
|
+
const body = JSON.parse(text);
|
|
10533
|
+
if (typeof body === "string") message = body;
|
|
10534
|
+
else if (body !== null && typeof body === "object") {
|
|
10535
|
+
const error = typeof body.error === "object" && body.error !== null ? body.error : void 0;
|
|
10536
|
+
if (typeof error?.param === "string") return error.param === "response_format";
|
|
10537
|
+
if (Array.isArray(body.detail)) return body.detail.length > 0 && body.detail.every((entry) => {
|
|
10538
|
+
const loc = entry !== null && typeof entry === "object" ? entry.loc : void 0;
|
|
10539
|
+
return Array.isArray(loc) && loc.includes("response_format");
|
|
10540
|
+
});
|
|
10541
|
+
message = [
|
|
10542
|
+
typeof body.error === "string" ? body.error : void 0,
|
|
10543
|
+
error?.message,
|
|
10544
|
+
error?.code,
|
|
10545
|
+
body.message,
|
|
10546
|
+
body.detail
|
|
10547
|
+
].filter((value) => typeof value === "string").join(" ");
|
|
10548
|
+
}
|
|
10549
|
+
} catch {}
|
|
10550
|
+
return /(?:response[_ -]?format|b64_json)/i.test(message) && /(?:unsupported|not\s+(?:support|allowed|permitted|accepted|implemented)|unknown|unrecognized|unexpected|invalid|extra\s+(?:field|input)|不支持|不允许|未知|无效|未识别)/i.test(message);
|
|
8558
10551
|
}
|
|
8559
10552
|
async function parseImageResponse(response, provider, input) {
|
|
8560
10553
|
const text = await readBoundedText$1(response, Math.ceil(input.maxBytes * 1.4) + ERROR_LIMIT$1);
|
|
@@ -8614,17 +10607,19 @@ async function downloadImage$1(url, provider, input) {
|
|
|
8614
10607
|
mediaType: detectImageMediaType(data) ?? imageMediaType$2(parsed.mediaType) ?? "image/png"
|
|
8615
10608
|
};
|
|
8616
10609
|
}
|
|
8617
|
-
const
|
|
8618
|
-
let response
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8622
|
-
|
|
8623
|
-
|
|
8624
|
-
|
|
8625
|
-
|
|
8626
|
-
})
|
|
8627
|
-
|
|
10610
|
+
const candidates = imageDownloadCandidates(url, input.baseURL, provider);
|
|
10611
|
+
let response;
|
|
10612
|
+
let failure;
|
|
10613
|
+
for (const candidate of candidates) try {
|
|
10614
|
+
response = await requestImageCandidate(candidate, provider, input);
|
|
10615
|
+
if (response.ok) break;
|
|
10616
|
+
failure = /* @__PURE__ */ new Error(`${provider} image download failed (${response.status})`);
|
|
10617
|
+
await response.body?.cancel().catch(() => void 0);
|
|
10618
|
+
response = void 0;
|
|
10619
|
+
} catch (err) {
|
|
10620
|
+
failure = err;
|
|
10621
|
+
}
|
|
10622
|
+
if (response === void 0) throw failure instanceof Error ? failure : /* @__PURE__ */ new Error(`${provider} image download failed`);
|
|
8628
10623
|
const data = await readBoundedBytes$1(response, input.maxBytes);
|
|
8629
10624
|
const mediaType = detectImageMediaType(data) ?? imageMediaType$2(response.headers.get("content-type"));
|
|
8630
10625
|
if (mediaType === void 0) throw new Error(`${provider} image download returned unsupported content type`);
|
|
@@ -8633,6 +10628,34 @@ async function downloadImage$1(url, provider, input) {
|
|
|
8633
10628
|
mediaType
|
|
8634
10629
|
};
|
|
8635
10630
|
}
|
|
10631
|
+
function imageDownloadCandidates(url, baseURL, provider) {
|
|
10632
|
+
const candidates = [];
|
|
10633
|
+
try {
|
|
10634
|
+
candidates.push(new URL(url, baseURL.endsWith("/") ? baseURL : baseURL + "/").toString());
|
|
10635
|
+
} catch {}
|
|
10636
|
+
if (url.startsWith("/")) try {
|
|
10637
|
+
const stripped = new URL(baseURL).origin + url.replace(/^\/v1(?=\/)/, "");
|
|
10638
|
+
if (!candidates.includes(stripped)) candidates.push(stripped);
|
|
10639
|
+
} catch {}
|
|
10640
|
+
if (candidates.length === 0) throw new Error(provider + " image download URL is not absolute");
|
|
10641
|
+
return candidates;
|
|
10642
|
+
}
|
|
10643
|
+
async function requestImageCandidate(candidate, provider, input) {
|
|
10644
|
+
const sameOrigin = new URL(candidate).origin === new URL(input.baseURL).origin;
|
|
10645
|
+
let response = await guardedFetch(provider + " image download", input.signal, () => fetch(candidate, {
|
|
10646
|
+
redirect: "follow",
|
|
10647
|
+
signal: input.signal,
|
|
10648
|
+
...input.apiKey === void 0 || !sameOrigin ? {} : { headers: { authorization: "Bearer " + input.apiKey } }
|
|
10649
|
+
}), input.apiKey);
|
|
10650
|
+
if (!response.ok && input.apiKey !== void 0 && sameOrigin) {
|
|
10651
|
+
await response.body?.cancel().catch(() => void 0);
|
|
10652
|
+
response = await guardedFetch(provider + " image download", input.signal, () => fetch(candidate, {
|
|
10653
|
+
redirect: "follow",
|
|
10654
|
+
signal: input.signal
|
|
10655
|
+
}), input.apiKey);
|
|
10656
|
+
}
|
|
10657
|
+
return response;
|
|
10658
|
+
}
|
|
8636
10659
|
function parseDataUrl(value) {
|
|
8637
10660
|
const match = /^data:([^;,]+);base64,(.*)$/s.exec(value.trim());
|
|
8638
10661
|
return match?.[1] !== void 0 && match[2] !== void 0 ? {
|
|
@@ -8675,6 +10698,9 @@ async function readBoundedBytes$1(response, maxBytes) {
|
|
|
8675
10698
|
if (bytes > maxBytes) throw new Error(`Image response exceeded the ${String(maxBytes)} byte limit`);
|
|
8676
10699
|
chunks.push(next.value);
|
|
8677
10700
|
}
|
|
10701
|
+
} catch (error) {
|
|
10702
|
+
await reader.cancel().catch(() => void 0);
|
|
10703
|
+
throw error;
|
|
8678
10704
|
} finally {
|
|
8679
10705
|
reader.releaseLock();
|
|
8680
10706
|
}
|
|
@@ -8760,9 +10786,15 @@ async function downloadImage(url, input) {
|
|
|
8760
10786
|
redirect: "follow",
|
|
8761
10787
|
signal: input.signal
|
|
8762
10788
|
}), input.apiKey);
|
|
8763
|
-
if (!response.ok)
|
|
10789
|
+
if (!response.ok) {
|
|
10790
|
+
await response.body?.cancel().catch(() => void 0);
|
|
10791
|
+
throw new Error(`seedream image download failed (${response.status})`);
|
|
10792
|
+
}
|
|
8764
10793
|
const mediaType = imageMediaType$1(response.headers.get("content-type"));
|
|
8765
|
-
if (mediaType === void 0)
|
|
10794
|
+
if (mediaType === void 0) {
|
|
10795
|
+
await response.body?.cancel().catch(() => void 0);
|
|
10796
|
+
throw new Error("seedream image download returned unsupported content type");
|
|
10797
|
+
}
|
|
8766
10798
|
return {
|
|
8767
10799
|
data: await readBoundedBytes(response, input.maxBytes),
|
|
8768
10800
|
mediaType
|
|
@@ -8795,6 +10827,9 @@ async function readBoundedBytes(response, maxBytes) {
|
|
|
8795
10827
|
if (bytes > maxBytes) throw new Error(`Image response exceeded the ${String(maxBytes)} byte limit`);
|
|
8796
10828
|
chunks.push(next.value);
|
|
8797
10829
|
}
|
|
10830
|
+
} catch (error) {
|
|
10831
|
+
await reader.cancel().catch(() => void 0);
|
|
10832
|
+
throw error;
|
|
8798
10833
|
} finally {
|
|
8799
10834
|
reader.releaseLock();
|
|
8800
10835
|
}
|
|
@@ -9035,9 +11070,10 @@ const CODEX_SCOPE = "openid profile email offline_access api.connectors.read api
|
|
|
9035
11070
|
const CODEX_IMAGE_URL = "https://chatgpt.com/backend-api/codex/images/generations";
|
|
9036
11071
|
/** Where the ChatGPT subscription image edit request goes. */
|
|
9037
11072
|
const CODEX_IMAGE_EDIT_URL = "https://chatgpt.com/backend-api/codex/images/edits";
|
|
9038
|
-
/** The model served by this endpoint
|
|
9039
|
-
*
|
|
9040
|
-
|
|
11073
|
+
/** The model served by this endpoint: Images 2.6 (2026-11-04), probed live
|
|
11074
|
+
* for stills on 2026-11-08 (2.5-flare before). The route rejects
|
|
11075
|
+
* `output_format: gif` (HTTP 400), so GIF stays off this channel. */
|
|
11076
|
+
const CODEX_IMAGE_MODEL = "gpt-image-2.6-flare";
|
|
9041
11077
|
/** Public client id of the Codex CLI; vendor-fixed redirect on port 1455. */
|
|
9042
11078
|
const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
9043
11079
|
const CODEX_REDIRECT_URI = "http://localhost:1455/auth/callback";
|
|
@@ -10112,6 +12148,52 @@ const SEEDREAM_SIZE_TABLE = {
|
|
|
10112
12148
|
"4K": "6240x2656"
|
|
10113
12149
|
}
|
|
10114
12150
|
};
|
|
12151
|
+
/**
|
|
12152
|
+
* Seedream 5.0 pro / flash tier × ratio table (Ark docs, seedream-5-0-pro):
|
|
12153
|
+
* these models take 1K / 1.5K / 2K only, within 921600–4624220 pixels.
|
|
12154
|
+
*/
|
|
12155
|
+
const SEEDREAM_PRO_SIZE_TABLE = {
|
|
12156
|
+
"1:1": {
|
|
12157
|
+
"1K": "1024x1024",
|
|
12158
|
+
"1.5K": "1536x1536",
|
|
12159
|
+
"2K": "2048x2048"
|
|
12160
|
+
},
|
|
12161
|
+
"4:3": {
|
|
12162
|
+
"1K": "1152x864",
|
|
12163
|
+
"1.5K": "1792x1344",
|
|
12164
|
+
"2K": "2368x1776"
|
|
12165
|
+
},
|
|
12166
|
+
"3:4": {
|
|
12167
|
+
"1K": "864x1152",
|
|
12168
|
+
"1.5K": "1344x1792",
|
|
12169
|
+
"2K": "1776x2368"
|
|
12170
|
+
},
|
|
12171
|
+
"16:9": {
|
|
12172
|
+
"1K": "1424x800",
|
|
12173
|
+
"1.5K": "2048x1152",
|
|
12174
|
+
"2K": "2816x1584"
|
|
12175
|
+
},
|
|
12176
|
+
"9:16": {
|
|
12177
|
+
"1K": "800x1424",
|
|
12178
|
+
"1.5K": "1152x2048",
|
|
12179
|
+
"2K": "1584x2816"
|
|
12180
|
+
},
|
|
12181
|
+
"3:2": {
|
|
12182
|
+
"1K": "1248x832",
|
|
12183
|
+
"1.5K": "1872x1248",
|
|
12184
|
+
"2K": "2496x1664"
|
|
12185
|
+
},
|
|
12186
|
+
"2:3": {
|
|
12187
|
+
"1K": "832x1248",
|
|
12188
|
+
"1.5K": "1248x1872",
|
|
12189
|
+
"2K": "1664x2496"
|
|
12190
|
+
},
|
|
12191
|
+
"21:9": {
|
|
12192
|
+
"1K": "1568x672",
|
|
12193
|
+
"1.5K": "2352x1008",
|
|
12194
|
+
"2K": "3136x1344"
|
|
12195
|
+
}
|
|
12196
|
+
};
|
|
10115
12197
|
/** qwen-image-3.0 pixel sizes within the documented 512*512–2048*2048 area. */
|
|
10116
12198
|
const DASHSCOPE_SIZE_TABLE = {
|
|
10117
12199
|
"1:1": "1024x1024",
|
|
@@ -10208,6 +12290,20 @@ const CLOUD_CAPABILITIES = {
|
|
|
10208
12290
|
label: "高清"
|
|
10209
12291
|
}], "1:1", "hd", (ratio) => ZHIPU_SIZE_TABLE[ratio] ?? "1280x1280")
|
|
10210
12292
|
};
|
|
12293
|
+
/** Seedream 5.0 pro / flash: a different tier set from the earlier Seedream models. */
|
|
12294
|
+
const SEEDREAM_PRO_CAPABILITY = capability(["auto", ...Object.keys(SEEDREAM_PRO_SIZE_TABLE)], [
|
|
12295
|
+
"1K",
|
|
12296
|
+
"1.5K",
|
|
12297
|
+
"2K"
|
|
12298
|
+
].map((value) => ({
|
|
12299
|
+
value,
|
|
12300
|
+
label: value
|
|
12301
|
+
})), "auto", "2K", (ratio, quality) => ratio === "auto" ? quality : SEEDREAM_PRO_SIZE_TABLE[ratio]?.[quality] ?? quality);
|
|
12302
|
+
/** The pickers of a cloud row for the model it runs; only Seedream differs by model today. */
|
|
12303
|
+
function cloudCapability(provider, model) {
|
|
12304
|
+
if (provider === "seedream" && /seedream-5-0-(?:pro|flash)(?:$|-)/.test(model)) return SEEDREAM_PRO_CAPABILITY;
|
|
12305
|
+
return CLOUD_CAPABILITIES[provider];
|
|
12306
|
+
}
|
|
10211
12307
|
const SUBSCRIPTION_CAPABILITIES = {
|
|
10212
12308
|
"chatgpt-sub": capability(["auto", ...Object.keys(OPENAI_SIZE_TABLE)], [AUTO, ...[
|
|
10213
12309
|
"low",
|
|
@@ -27843,6 +29939,35 @@ function validateOfficialSize(model, size) {
|
|
|
27843
29939
|
const pixels = width * height;
|
|
27844
29940
|
if (!match || width % 16 !== 0 || height % 16 !== 0 || Math.max(width, height) > 3840 || pixels < 655360 || pixels > 8294400 || Math.max(width, height) > 3 * Math.min(width, height)) throw new Error(`OpenAI 尺寸 ${size} 无效:边长须为 16 的倍数且不超过 3840,比例在 1:3 到 3:1,像素数在 655360 到 8294400`);
|
|
27845
29941
|
}
|
|
29942
|
+
/** Fold one call's GIF parameters into OpenAI images request fields; anything but a GIF yields {}. */
|
|
29943
|
+
function gifRequestFields(options) {
|
|
29944
|
+
if (options.outputFormat !== "gif") {
|
|
29945
|
+
if (options.frameCount !== void 0 || options.frameDurationMs !== void 0) throw new Error("frame_count / frame_duration_ms 仅在 output_format=gif 时有效");
|
|
29946
|
+
return {};
|
|
29947
|
+
}
|
|
29948
|
+
if (options.frameCount !== void 0 && !inRange(options.frameCount, 2, 16)) throw new Error(`GIF frame_count 须为 ${String(2)} 到 ${String(16)} 的整数`);
|
|
29949
|
+
if (options.frameDurationMs !== void 0 && !inRange(options.frameDurationMs, 20, 2e3)) throw new Error(`GIF frame_duration_ms 须为 ${String(20)} 到 ${String(GIF_FRAME_DURATION_MS_MAX)} 的整数`);
|
|
29950
|
+
return {
|
|
29951
|
+
output_format: "gif",
|
|
29952
|
+
...options.frameCount === void 0 ? {} : { frame_count: options.frameCount },
|
|
29953
|
+
...options.frameDurationMs === void 0 ? {} : { frame_duration_ms: options.frameDurationMs }
|
|
29954
|
+
};
|
|
29955
|
+
}
|
|
29956
|
+
function inRange(value, min, max) {
|
|
29957
|
+
return Number.isInteger(value) && value >= min && value <= max;
|
|
29958
|
+
}
|
|
29959
|
+
/**
|
|
29960
|
+
* A GIF `size` is the single-frame size: auto or multiples of 16 with the
|
|
29961
|
+
* longest edge within the sheet limit. The still-image pixel floor of
|
|
29962
|
+
* validateOfficialSize does not apply; the provider judges the frame budget.
|
|
29963
|
+
*/
|
|
29964
|
+
function validateGifFrameSize(size) {
|
|
29965
|
+
if (size === "auto") return;
|
|
29966
|
+
const match = /^(\d+)x(\d+)$/.exec(size);
|
|
29967
|
+
const width = Number(match?.[1]);
|
|
29968
|
+
const height = Number(match?.[2]);
|
|
29969
|
+
if (!match || width <= 0 || height <= 0 || width % 16 !== 0 || height % 16 !== 0 || Math.max(width, height) > 3840) throw new Error(`GIF 单帧尺寸 ${size} 无效:边长须为 16 的倍数且不超过 ${String(GIF_SHEET_MAX_EDGE)};所有帧会拼成一张 sheet,其最长边同样不得超过 ${String(GIF_SHEET_MAX_EDGE)},帧越多单帧应越小`);
|
|
29970
|
+
}
|
|
27846
29971
|
function officialQualityOptions(model) {
|
|
27847
29972
|
return [
|
|
27848
29973
|
"auto",
|
|
@@ -27955,6 +30080,17 @@ function openAIRequestSize(config, ratio, quality) {
|
|
|
27955
30080
|
if (withinBudget.length === 0) throw new Error(`该比例不支持 ${quality} 清晰度,请降低清晰度或更换比例`);
|
|
27956
30081
|
return withinBudget.reduce((best, entry) => entry.rank > best.rank ? entry : best).size;
|
|
27957
30082
|
}
|
|
30083
|
+
/** The relay table entry the user picked, if a table is configured and lists this ratio; otherwise nothing. */
|
|
30084
|
+
function configuredCompatSize(config, input) {
|
|
30085
|
+
const table = config.openaiCompatSizes;
|
|
30086
|
+
if (table === void 0 || Object.keys(table).length === 0) return void 0;
|
|
30087
|
+
if (!openAICompatTable(config).some((group) => group.ratio === input.ratio)) return void 0;
|
|
30088
|
+
try {
|
|
30089
|
+
return openAIRequestSize(config, input.ratio, input.resolution ?? input.quality);
|
|
30090
|
+
} catch {
|
|
30091
|
+
return;
|
|
30092
|
+
}
|
|
30093
|
+
}
|
|
27958
30094
|
/** Workbench profile derived from the configured relay table. */
|
|
27959
30095
|
function openAICompatStudioProfile(config, model, configured) {
|
|
27960
30096
|
const groups = openAICompatTable(config);
|
|
@@ -28032,17 +30168,27 @@ const SUBSCRIPTION_DISPLAY_LABELS = {
|
|
|
28032
30168
|
"grok-sub": "Grok 订阅",
|
|
28033
30169
|
"google-sub": "Google 订阅"
|
|
28034
30170
|
};
|
|
30171
|
+
/** A generation on an OpenAI-compatible channel whose extras select GIF is a GIF request even when it carries no `output`. */
|
|
30172
|
+
function channelSelectsGif(config, input) {
|
|
30173
|
+
return input.provider === "openai-compat" && input.mode === "generate" && withOpenAICompatChannel(config, input.channel).openaiCompatExtraBody?.output_format === "gif";
|
|
30174
|
+
}
|
|
28035
30175
|
/** Execute one validated browser workbench request using the existing provider adapters. */
|
|
28036
30176
|
async function generateFromStudio(ctx, config, input, signal, fallbackWorkspaceRoot, subscriptions) {
|
|
28037
30177
|
if (isSubscriptionProvider(input.provider)) {
|
|
28038
30178
|
if (subscriptions === void 0) throw new Error("订阅生图不可用:订阅管理器未初始化");
|
|
28039
|
-
assertAllowed(subscriptionStudioProfile(input.provider, true), input);
|
|
30179
|
+
assertAllowed(subscriptionStudioProfile(input.provider, true), input, input.output?.format === "gif");
|
|
28040
30180
|
return generateSubscriptionFromStudio(ctx, subscriptions, input, signal);
|
|
28041
30181
|
}
|
|
28042
30182
|
const channel = input.provider === "openai-compat" ? resolveOpenAICompatChannel(config, input.channel) : void 0;
|
|
28043
30183
|
const requestConfig = input.provider === "openai-compat" ? withOpenAICompatChannel(config, input.channel) : config;
|
|
28044
|
-
|
|
28045
|
-
const
|
|
30184
|
+
const profile = studioProfile(requestConfig, input.provider, true, channel?.id);
|
|
30185
|
+
const model = withGenerationModel(profile, input).model;
|
|
30186
|
+
const channelGif = channelSelectsGif(config, input);
|
|
30187
|
+
assertAllowed(profile, {
|
|
30188
|
+
...input,
|
|
30189
|
+
model
|
|
30190
|
+
}, input.output?.format === "gif" || channelGif);
|
|
30191
|
+
const active = resolveProvider(withProviderOverrides(requestConfig, input.provider, model));
|
|
28046
30192
|
if (active.provider === "comfyui") throw new Error("ComfyUI 暂未接入工作台");
|
|
28047
30193
|
if (isSubscriptionProvider(active.provider)) throw new Error("订阅生图应通过订阅渠道执行");
|
|
28048
30194
|
const wired = active;
|
|
@@ -28083,16 +30229,29 @@ async function generateFromStudio(ctx, config, input, signal, fallbackWorkspaceR
|
|
|
28083
30229
|
output = `${aspectRatio}, ${imageSize}`;
|
|
28084
30230
|
} else if (wired.provider === "openai" || wired.provider === "openai-compat" || wired.provider === "xai" || wired.provider === "zhipu") {
|
|
28085
30231
|
const p = wired.provider;
|
|
30232
|
+
const gifFields = p === "openai" || p === "openai-compat" ? gifRequestFields({
|
|
30233
|
+
outputFormat: input.output?.format ?? (channelGif ? "gif" : void 0),
|
|
30234
|
+
frameCount: input.output?.frameCount,
|
|
30235
|
+
frameDurationMs: input.output?.frameDurationMs
|
|
30236
|
+
}) : {};
|
|
30237
|
+
const gif = gifFields.output_format === "gif";
|
|
28086
30238
|
const officialTable = p === "openai" ? officialSizeTable(requestConfig, wired.model) : void 0;
|
|
28087
30239
|
const officialResolution = input.resolution ?? (officialTable ? openAICompatTable({ openaiCompatSizes: officialTable }).find((group) => group.ratio === input.ratio)?.tiers[0]?.tier : void 0) ?? "1K";
|
|
28088
|
-
const size = p === "openai-compat" ? openAIRequestSize(requestConfig, input.ratio, input.resolution ?? input.quality) : p === "openai" ? openAIRequestSize({ openaiCompatSizes: officialTable }, input.ratio, officialResolution) : p === "xai" ? void 0 :
|
|
28089
|
-
if (p === "openai" && size !== void 0)
|
|
30240
|
+
const size = gif ? input.output?.size ?? (p === "openai-compat" ? configuredCompatSize(requestConfig, input) : void 0) : p === "openai-compat" ? openAIRequestSize(requestConfig, input.ratio, input.resolution ?? input.quality) : p === "openai" ? openAIRequestSize({ openaiCompatSizes: officialTable }, input.ratio, officialResolution) : p === "xai" ? void 0 : cloudCapability(p, wired.model).sizeFor?.(input.ratio, input.quality);
|
|
30241
|
+
if (p === "openai" && size !== void 0) {
|
|
30242
|
+
if (gif) validateGifFrameSize(size);
|
|
30243
|
+
else validateOfficialSize(wired.model, size);
|
|
30244
|
+
}
|
|
28090
30245
|
const requestedQuality = input.generationQuality ?? input.quality;
|
|
28091
30246
|
const quality = p === "openai" ? requestedQuality === "auto" ? void 0 : requestedQuality : p === "openai-compat" ? compatRequestQuality(requestConfig, input.generationQuality) : p === "zhipu" ? "hd" : void 0;
|
|
28092
|
-
const
|
|
30247
|
+
const channelExtraBody = p === "xai" ? {
|
|
28093
30248
|
aspect_ratio: input.ratio,
|
|
28094
30249
|
resolution: input.quality
|
|
28095
30250
|
} : p === "openai-compat" ? input.mode === "generate" ? compatGenerationExtraBody(requestConfig, input.watermark) : compatEditExtraBody(requestConfig, input.watermark) : void 0;
|
|
30251
|
+
const extraBody = Object.keys(gifFields).length === 0 ? channelExtraBody : {
|
|
30252
|
+
...channelExtraBody,
|
|
30253
|
+
...gifFields
|
|
30254
|
+
};
|
|
28096
30255
|
generated = input.mode === "edit" ? await editOpenAICompatibleImage({
|
|
28097
30256
|
apiKey: credential,
|
|
28098
30257
|
baseURL: wired.baseURL,
|
|
@@ -28123,7 +30282,7 @@ async function generateFromStudio(ctx, config, input, signal, fallbackWorkspaceR
|
|
|
28123
30282
|
});
|
|
28124
30283
|
output = size ?? `${input.ratio}, ${input.quality}`;
|
|
28125
30284
|
} else if (wired.provider === "seedream") {
|
|
28126
|
-
const size =
|
|
30285
|
+
const size = cloudCapability("seedream", wired.model).sizeFor?.(input.ratio, input.quality) ?? input.quality;
|
|
28127
30286
|
const arkOptions = {
|
|
28128
30287
|
...wired.arkOptions,
|
|
28129
30288
|
watermark: selectedWatermark(input.watermark, wired.arkOptions.watermark)
|
|
@@ -28177,13 +30336,18 @@ async function generateFromStudio(ctx, config, input, signal, fallbackWorkspaceR
|
|
|
28177
30336
|
output = size;
|
|
28178
30337
|
} else throw new Error("当前渠道未接入 API 工作台");
|
|
28179
30338
|
if (!ctx.attachments.imageLimits.mediaTypes.includes(generated.mediaType)) throw new Error(`当前 DSH 不支持保存 ${generated.mediaType} 图片`);
|
|
30339
|
+
const attachment = await ctx.attachments.saveImage({
|
|
30340
|
+
data: generated.data,
|
|
30341
|
+
mediaType: generated.mediaType,
|
|
30342
|
+
name: count > 1 ? `studio-image-${index + 1}` : "studio-image"
|
|
30343
|
+
});
|
|
30344
|
+
const original = await keepGifOriginal(vaultConfigOf(requestConfig), generated, attachment, (message) => {
|
|
30345
|
+
ctx.logger.warn(message);
|
|
30346
|
+
});
|
|
28180
30347
|
return {
|
|
28181
|
-
attachment
|
|
28182
|
-
|
|
28183
|
-
|
|
28184
|
-
name: count > 1 ? `studio-image-${index + 1}` : "studio-image"
|
|
28185
|
-
}),
|
|
28186
|
-
output
|
|
30348
|
+
attachment,
|
|
30349
|
+
output,
|
|
30350
|
+
...original === void 0 ? {} : { original }
|
|
28187
30351
|
};
|
|
28188
30352
|
};
|
|
28189
30353
|
if (count === 1) {
|
|
@@ -28193,14 +30357,15 @@ async function generateFromStudio(ctx, config, input, signal, fallbackWorkspaceR
|
|
|
28193
30357
|
output: single.output,
|
|
28194
30358
|
provider: input.provider,
|
|
28195
30359
|
...channel === void 0 ? {} : { channel: channel.id },
|
|
28196
|
-
model
|
|
30360
|
+
model,
|
|
28197
30361
|
prompt: input.prompt,
|
|
28198
30362
|
createdAt: Date.now(),
|
|
28199
30363
|
elapsedMs: Date.now() - startedAt,
|
|
28200
30364
|
requestedCount: 1,
|
|
28201
30365
|
failedCount: 0,
|
|
28202
30366
|
items: [single],
|
|
28203
|
-
...single.savedTo ? { savedTo: single.savedTo } : {}
|
|
30367
|
+
...single.savedTo ? { savedTo: single.savedTo } : {},
|
|
30368
|
+
...single.original === void 0 ? {} : { original: single.original }
|
|
28204
30369
|
};
|
|
28205
30370
|
}
|
|
28206
30371
|
const poolResults = await runPool(Array.from({ length: count }, (_, i) => () => generateSingle(i)), 2);
|
|
@@ -28224,7 +30389,7 @@ async function generateFromStudio(ctx, config, input, signal, fallbackWorkspaceR
|
|
|
28224
30389
|
output: first.output,
|
|
28225
30390
|
provider: input.provider,
|
|
28226
30391
|
...channel === void 0 ? {} : { channel: channel.id },
|
|
28227
|
-
model
|
|
30392
|
+
model,
|
|
28228
30393
|
prompt: input.prompt,
|
|
28229
30394
|
createdAt: Date.now(),
|
|
28230
30395
|
elapsedMs: Date.now() - startedAt,
|
|
@@ -28232,7 +30397,8 @@ async function generateFromStudio(ctx, config, input, signal, fallbackWorkspaceR
|
|
|
28232
30397
|
failedCount: errors.length,
|
|
28233
30398
|
items: successes,
|
|
28234
30399
|
...errors.length > 0 ? { errors } : {},
|
|
28235
|
-
...first.savedTo ? { savedTo: first.savedTo } : {}
|
|
30400
|
+
...first.savedTo ? { savedTo: first.savedTo } : {},
|
|
30401
|
+
...first.original === void 0 ? {} : { original: first.original }
|
|
28236
30402
|
};
|
|
28237
30403
|
}
|
|
28238
30404
|
async function runPool(tasks, concurrency = 2) {
|
|
@@ -28392,7 +30558,7 @@ function cloudStudioProfile(config, provider, configured) {
|
|
|
28392
30558
|
defaultResolution: resolutionOptions[defaultRatio]?.[0]?.value ?? "1K"
|
|
28393
30559
|
};
|
|
28394
30560
|
}
|
|
28395
|
-
const cap =
|
|
30561
|
+
const cap = cloudCapability(provider, model);
|
|
28396
30562
|
return profile(provider, model, configured, cap.ratioOptions, cap.qualityOptions, cap.defaultRatio, cap.defaultQuality);
|
|
28397
30563
|
}
|
|
28398
30564
|
/** Output capabilities remain visible before credentials, address or model are configured. */
|
|
@@ -28462,12 +30628,29 @@ function option(value) {
|
|
|
28462
30628
|
label: RATIO_LABELS[value] ?? value
|
|
28463
30629
|
};
|
|
28464
30630
|
}
|
|
28465
|
-
|
|
30631
|
+
/**
|
|
30632
|
+
* Regenerating an edit result is a generation, but the card remembers the
|
|
30633
|
+
* channel's separate editing model that drew it, which the generation list
|
|
30634
|
+
* may not offer. That model is swapped for the channel's generation model;
|
|
30635
|
+
* any other unknown model still fails as a stale workbench below.
|
|
30636
|
+
*/
|
|
30637
|
+
function withGenerationModel(profile, input) {
|
|
30638
|
+
if (input.mode !== "generate" || profile.editModel === void 0 || input.model !== profile.editModel) return input;
|
|
30639
|
+
if (studioModelOptionsForMode(profile, "generate").some((option) => option.value === input.model)) return input;
|
|
30640
|
+
return {
|
|
30641
|
+
...input,
|
|
30642
|
+
model: studioModelForMode(profile, "generate")
|
|
30643
|
+
};
|
|
30644
|
+
}
|
|
30645
|
+
function assertAllowed(profile, input, gif) {
|
|
28466
30646
|
if (!studioModelOptionsForMode(profile, input.mode).some((option) => option.value === input.model)) throw new Error("模型配置已变化,请刷新工作台后重试");
|
|
28467
|
-
if (
|
|
30647
|
+
if (input.output !== void 0 && input.provider !== "openai" && input.provider !== "openai-compat") throw new Error("该 Provider 不支持 GIF 输出参数");
|
|
28468
30648
|
const selectedQuality = input.provider === "openai" ? input.generationQuality ?? input.quality : input.resolution ?? input.quality;
|
|
28469
|
-
if (!
|
|
28470
|
-
|
|
30649
|
+
if (!gif) {
|
|
30650
|
+
if (!profile.ratioOptions.some((option) => option.value === input.ratio)) throw new Error("该 Provider 不支持所选比例");
|
|
30651
|
+
if (!profile.qualityOptions.some((option) => option.value === selectedQuality)) throw new Error("该 Provider 不支持所选清晰度");
|
|
30652
|
+
if (input.resolution !== void 0 && profile.resolutionOptions !== void 0 && !profile.resolutionOptions[input.ratio]?.some((option) => option.value === input.resolution)) throw new Error("该比例不支持所选分辨率");
|
|
30653
|
+
} else if (input.provider === "openai" && !profile.qualityOptions.some((option) => option.value === selectedQuality)) throw new Error("该 Provider 不支持所选清晰度");
|
|
28471
30654
|
if (input.generationQuality !== void 0 && input.provider === "openai-compat" && !profile.generationQualityOptions?.some((option) => option.value === input.generationQuality)) throw new Error("该 Provider 不支持所选生成质量");
|
|
28472
30655
|
if ((input.resolution !== void 0 || input.generationQuality !== void 0) && input.provider !== "openai" && input.provider !== "openai-compat") throw new Error("该 Provider 不支持独立的 OpenAI 输出参数");
|
|
28473
30656
|
if (input.watermark !== void 0 && input.watermark !== "default" && studioWatermarkCapability(profile, input.mode).status !== "supported") throw new Error("该渠道尚未确认当前操作的水印控制,请刷新工作台");
|
|
@@ -28531,7 +30714,8 @@ async function serveStudio(req, res, deps) {
|
|
|
28531
30714
|
return jsonError$1(res, 400, errorMessage(error, "invalid-request"));
|
|
28532
30715
|
}
|
|
28533
30716
|
const subscriptionChannel = isSubscriptionProvider(input.provider);
|
|
28534
|
-
const
|
|
30717
|
+
const gif = input.output?.format === "gif" || deps.channelSelectsGif?.(input) === true;
|
|
30718
|
+
const timeoutMs = subscriptionChannel ? SUBSCRIPTION_TIMEOUT_MS : cloudTimeoutFor(deps.timeoutMs, gif);
|
|
28535
30719
|
const timeout = setTimeout(() => {
|
|
28536
30720
|
watchdog.abort();
|
|
28537
30721
|
}, timeoutMs);
|
|
@@ -28581,9 +30765,11 @@ function parseStudioGenerateRequest(value) {
|
|
|
28581
30765
|
const generationQuality = input.generationQuality === void 0 ? void 0 : requiredText(input.generationQuality, "请选择生成质量", 100);
|
|
28582
30766
|
const watermark = input.watermark === void 0 ? void 0 : STUDIO_WATERMARK_VALUES.find((value) => value === input.watermark);
|
|
28583
30767
|
if (input.watermark !== void 0 && watermark === void 0) throw new Error("请选择有效的水印选项");
|
|
30768
|
+
const output = input.output === void 0 ? void 0 : parseOutput(input.output);
|
|
28584
30769
|
const references = (Array.isArray(input.references) ? input.references : input.reference !== void 0 ? [input.reference] : []).map(parseReference);
|
|
28585
30770
|
if (input.mode === "edit" && references.length === 0) throw new Error("图生图需要至少一张参考图");
|
|
28586
30771
|
if (references.length > 5) throw new Error("最多支持上传 5 张参考图");
|
|
30772
|
+
if (input.mode === "edit" && output !== void 0 && references.length > 1) throw new Error("动图修改只接受一张参考拼图");
|
|
28587
30773
|
const workspaceRoot = typeof input.workspaceRoot === "string" && input.workspaceRoot.trim().length > 0 ? input.workspaceRoot.trim() : void 0;
|
|
28588
30774
|
let count;
|
|
28589
30775
|
if (input.count !== void 0) {
|
|
@@ -28601,6 +30787,7 @@ function parseStudioGenerateRequest(value) {
|
|
|
28601
30787
|
...resolution === void 0 ? {} : { resolution },
|
|
28602
30788
|
...generationQuality === void 0 ? {} : { generationQuality },
|
|
28603
30789
|
...watermark === void 0 ? {} : { watermark },
|
|
30790
|
+
...output === void 0 ? {} : { output },
|
|
28604
30791
|
...count !== void 0 ? { count } : {},
|
|
28605
30792
|
...references.length > 0 ? {
|
|
28606
30793
|
references,
|
|
@@ -28609,6 +30796,19 @@ function parseStudioGenerateRequest(value) {
|
|
|
28609
30796
|
...workspaceRoot !== void 0 ? { workspaceRoot } : {}
|
|
28610
30797
|
};
|
|
28611
30798
|
}
|
|
30799
|
+
/** Shape only; the frame ranges are checked where the request fields are built, like the chat tools. */
|
|
30800
|
+
function parseOutput(value) {
|
|
30801
|
+
const output = record$1(value);
|
|
30802
|
+
if (output === void 0 || output.format !== "gif") throw new Error("输出格式仅支持 gif");
|
|
30803
|
+
for (const field of ["frameCount", "frameDurationMs"]) if (output[field] !== void 0 && typeof output[field] !== "number") throw new Error("GIF 帧参数须为数字");
|
|
30804
|
+
const size = output.size === void 0 ? void 0 : requiredText(output.size, "GIF 帧尺寸无效", 32);
|
|
30805
|
+
return {
|
|
30806
|
+
format: "gif",
|
|
30807
|
+
...typeof output.frameCount === "number" ? { frameCount: output.frameCount } : {},
|
|
30808
|
+
...typeof output.frameDurationMs === "number" ? { frameDurationMs: output.frameDurationMs } : {},
|
|
30809
|
+
...size === void 0 ? {} : { size }
|
|
30810
|
+
};
|
|
30811
|
+
}
|
|
28612
30812
|
function parseReference(value) {
|
|
28613
30813
|
const reference = record$1(value);
|
|
28614
30814
|
if (reference === void 0) throw new Error("参考图格式无效");
|
|
@@ -29044,6 +31244,20 @@ function serveSettingsHealth(req, res, health) {
|
|
|
29044
31244
|
//#endregion
|
|
29045
31245
|
//#region lib/types/image-tool-context.js
|
|
29046
31246
|
/** Live, credential-free instructions for the conversation model's image tools. */
|
|
31247
|
+
/** Workflow names listed for the model; a long list is cut so the system prompt stays small. */
|
|
31248
|
+
const MAX_LISTED_WORKFLOWS = 20;
|
|
31249
|
+
/**
|
|
31250
|
+
* The ComfyUI workflows the `workflow` argument accepts (#98). Names are user
|
|
31251
|
+
* text, so each is JSON-quoted; the list stays absent without workflows.
|
|
31252
|
+
*/
|
|
31253
|
+
function comfyUIWorkflowLine(config) {
|
|
31254
|
+
const workflows = resolveComfyUIWorkflows(config);
|
|
31255
|
+
if (workflows.length === 0) return [];
|
|
31256
|
+
const listed = workflows.slice(0, MAX_LISTED_WORKFLOWS).map((workflow) => JSON.stringify(workflow.name)).join(", ");
|
|
31257
|
+
const more = workflows.length > MAX_LISTED_WORKFLOWS ? ` (+${String(workflows.length - MAX_LISTED_WORKFLOWS)} more)` : "";
|
|
31258
|
+
const active = activeComfyUIWorkflow(config);
|
|
31259
|
+
return [`ComfyUI workflows available for the workflow argument (provider=comfyui): ${listed}${more}; active: ${JSON.stringify(active?.name ?? "")}. Pass workflow only when the user asks for a specific one; the local ComfyUI server may not be running.`];
|
|
31260
|
+
}
|
|
29047
31261
|
function imageToolContext(config, configuredProviders) {
|
|
29048
31262
|
const provider = config.provider ?? "google";
|
|
29049
31263
|
const configured = configuredProviders === void 0 ? void 0 : [...configuredProviders];
|
|
@@ -29059,6 +31273,7 @@ function imageToolContext(config, configuredProviders) {
|
|
|
29059
31273
|
return [
|
|
29060
31274
|
`Image tools current default provider: ${provider}; generation model: ${JSON.stringify(model)}; editing model: ${JSON.stringify(editModel)}.`,
|
|
29061
31275
|
...configured === void 0 ? [] : [`Configured image providers (credential saved, subscription logged in, or ComfyUI workflow present): ${configured.length > 0 ? configured.join(", ") : "none"}. Do not select unlisted providers; configuration presence does not guarantee service reachability.`],
|
|
31276
|
+
...comfyUIWorkflowLine(config),
|
|
29062
31277
|
"Unless the user explicitly requests a different image provider or model, OMIT provider and model from generate_image, generate_images and edit_image. Do not guess a channel or switch channels after an error.",
|
|
29063
31278
|
"Omit unused optional arguments entirely; never fill them with empty strings. size and aspect_ratio are alternatives: provide at most one.",
|
|
29064
31279
|
"Subscription parameters: chatgpt-sub accepts size OR aspect_ratio, but NOT image_size. grok-sub image_size: 1K or 2K. google-sub image_size: 1K or 4K. Do not copy parameters from another provider.",
|
|
@@ -29084,7 +31299,8 @@ function cleanImageToolArgs(args) {
|
|
|
29084
31299
|
"aspect_ratio",
|
|
29085
31300
|
"image_size",
|
|
29086
31301
|
"quality",
|
|
29087
|
-
"workflow"
|
|
31302
|
+
"workflow",
|
|
31303
|
+
"output_format"
|
|
29088
31304
|
]) {
|
|
29089
31305
|
const value = fields[key];
|
|
29090
31306
|
if (typeof value !== "string") continue;
|
|
@@ -29093,6 +31309,73 @@ function cleanImageToolArgs(args) {
|
|
|
29093
31309
|
}
|
|
29094
31310
|
return clean;
|
|
29095
31311
|
}
|
|
31312
|
+
/** GIF output parameters shared by generate_image and generate_images (OpenAI images contract). */
|
|
31313
|
+
const gifToolParameters = {
|
|
31314
|
+
output_format: {
|
|
31315
|
+
type: "string",
|
|
31316
|
+
enum: ["gif"],
|
|
31317
|
+
description: "Optional. Only for provider=openai or openai-compat; gif produces a looping animation. For GIF, omit size unless the user asks for exact dimensions: all frames are tiled into one sheet whose longest edge must stay at or below 3840 px. To animate a subject from reference images, use edit_image with output_format=gif instead."
|
|
31318
|
+
},
|
|
31319
|
+
frame_count: {
|
|
31320
|
+
type: "integer",
|
|
31321
|
+
description: "Optional, GIF only: number of frames, 2-16. Omit to use the provider default."
|
|
31322
|
+
},
|
|
31323
|
+
frame_duration_ms: {
|
|
31324
|
+
type: "integer",
|
|
31325
|
+
description: "Optional, GIF only: milliseconds per frame, 20-2000; the provider rounds to 10 ms. Omit to use the provider default."
|
|
31326
|
+
}
|
|
31327
|
+
};
|
|
31328
|
+
/** The same GIF parameters on edit_image, which animates the reference images. */
|
|
31329
|
+
const editGifToolParameters = {
|
|
31330
|
+
...gifToolParameters,
|
|
31331
|
+
output_format: {
|
|
31332
|
+
type: "string",
|
|
31333
|
+
enum: ["gif"],
|
|
31334
|
+
description: "Optional. Only for provider=openai or openai-compat; gif animates the reference images into a looping animation, for example a sticker of the same character in motion. Omit size unless the user asks for exact dimensions: all frames are tiled into one sheet whose longest edge must stay at or below 3840 px. Many OpenAI-compatible relays do not implement image edits; if the relay rejects the edit or returns a still image, tell the user instead of retrying or assembling frames yourself."
|
|
31335
|
+
}
|
|
31336
|
+
};
|
|
31337
|
+
/** Output schema node for GeneratedValue.animation; every object node must state additionalProperties. */
|
|
31338
|
+
const animationSchema = {
|
|
31339
|
+
type: "object",
|
|
31340
|
+
additionalProperties: false,
|
|
31341
|
+
properties: {
|
|
31342
|
+
frames: {
|
|
31343
|
+
type: "integer",
|
|
31344
|
+
required: true
|
|
31345
|
+
},
|
|
31346
|
+
frameDurationMs: {
|
|
31347
|
+
type: "integer",
|
|
31348
|
+
required: true
|
|
31349
|
+
},
|
|
31350
|
+
durationMs: {
|
|
31351
|
+
type: "integer",
|
|
31352
|
+
required: true
|
|
31353
|
+
}
|
|
31354
|
+
}
|
|
31355
|
+
};
|
|
31356
|
+
/** Output schema node for GeneratedValue.original, the same shape the gallery stores for imports. */
|
|
31357
|
+
const originalSchema = {
|
|
31358
|
+
type: "object",
|
|
31359
|
+
additionalProperties: false,
|
|
31360
|
+
properties: {
|
|
31361
|
+
key: {
|
|
31362
|
+
type: "string",
|
|
31363
|
+
required: true
|
|
31364
|
+
},
|
|
31365
|
+
bytes: {
|
|
31366
|
+
type: "integer",
|
|
31367
|
+
required: true
|
|
31368
|
+
},
|
|
31369
|
+
mediaType: {
|
|
31370
|
+
type: "string",
|
|
31371
|
+
required: true
|
|
31372
|
+
}
|
|
31373
|
+
}
|
|
31374
|
+
};
|
|
31375
|
+
/** GIF fields follow the OpenAI images contract; other providers would drop them silently. */
|
|
31376
|
+
function assertGifProvider(args, provider) {
|
|
31377
|
+
if ((args.output_format !== void 0 || args.frame_count !== void 0 || args.frame_duration_ms !== void 0) && provider !== "openai" && provider !== "openai-compat") throw new Error("output_format, frame_count and frame_duration_ms are only supported with provider=openai or openai-compat.");
|
|
31378
|
+
}
|
|
29096
31379
|
/** Validate the untrusted per-call provider override from tool arguments. */
|
|
29097
31380
|
function providerOverrideOf(value) {
|
|
29098
31381
|
if (value === void 0 || value === null || value === "") return void 0;
|
|
@@ -29124,6 +31407,7 @@ function apply(ctx, config = {}) {
|
|
|
29124
31407
|
if (revision === imageContextRevision) configuredImageProviders = providers;
|
|
29125
31408
|
} catch {}
|
|
29126
31409
|
};
|
|
31410
|
+
const vaultConfig = () => vaultConfigOf(current());
|
|
29127
31411
|
const settingsHealth = installImageSettings(ctx, current(), {
|
|
29128
31412
|
setSource: (source) => {
|
|
29129
31413
|
current = () => migrateOpenAICompatConfig(plainConfig(source()));
|
|
@@ -29132,6 +31416,33 @@ function apply(ctx, config = {}) {
|
|
|
29132
31416
|
refreshImageToolContext();
|
|
29133
31417
|
}
|
|
29134
31418
|
});
|
|
31419
|
+
ctx.effect(() => {
|
|
31420
|
+
const run = () => {
|
|
31421
|
+
const config = current();
|
|
31422
|
+
if (config.requestCachePrune === false) return;
|
|
31423
|
+
const ttlDays = config.requestCacheTtlDays ?? 14;
|
|
31424
|
+
const maxBytes = config.requestCacheMaxBytes ?? 268435456;
|
|
31425
|
+
const configured = config.requestCacheDir ?? "";
|
|
31426
|
+
pruneRequestImageCache({
|
|
31427
|
+
dir: configured.length > 0 ? configured : resolveRequestImageCacheDir(),
|
|
31428
|
+
ttlMs: ttlDays * 24 * 60 * 60 * 1e3,
|
|
31429
|
+
maxBytes,
|
|
31430
|
+
protectedRoots: [durableAttachmentsRoot(), vaultConfig().dir]
|
|
31431
|
+
}).then((stats) => {
|
|
31432
|
+
if (stats.removed > 0) ctx.logger.info("dsh-image-gen: pruned " + String(stats.removed) + " request-image variants (" + String(stats.freedBytes) + " bytes)");
|
|
31433
|
+
}).catch((error) => {
|
|
31434
|
+
ctx.logger.warn("dsh-image-gen: request-image cache prune failed: " + (error instanceof Error ? error.message : String(error)));
|
|
31435
|
+
});
|
|
31436
|
+
};
|
|
31437
|
+
const startup = setTimeout(run, 3e4);
|
|
31438
|
+
const daily = setInterval(run, 864e5);
|
|
31439
|
+
startup.unref();
|
|
31440
|
+
daily.unref();
|
|
31441
|
+
return () => {
|
|
31442
|
+
clearTimeout(startup);
|
|
31443
|
+
clearInterval(daily);
|
|
31444
|
+
};
|
|
31445
|
+
}, "dsh-image-gen: request image cache upkeep");
|
|
29135
31446
|
let webAdmission;
|
|
29136
31447
|
ctx.inject(["connection"], (connectionCtx) => {
|
|
29137
31448
|
webAdmission = (request) => connectionCtx.connection.requestRejection(request);
|
|
@@ -29149,15 +31460,75 @@ function apply(ctx, config = {}) {
|
|
|
29149
31460
|
path: IMAGE_ROUTE,
|
|
29150
31461
|
handler: (req, res) => serveImage(req, res, { readImage: (ref) => ctx.attachments.readImage(ref) })
|
|
29151
31462
|
}), "dsh-image-gen: image route");
|
|
31463
|
+
webCtx.effect(() => register({
|
|
31464
|
+
kind: "exact",
|
|
31465
|
+
path: THUMB_ROUTE,
|
|
31466
|
+
handler: (req, res) => serveThumb(req, res, { readImageRequest: (ref, target, signal) => ctx.attachments.readImageRequest(ref, target, signal) })
|
|
31467
|
+
}), "dsh-image-gen: thumbnail route");
|
|
31468
|
+
webCtx.effect(() => register({
|
|
31469
|
+
kind: "exact",
|
|
31470
|
+
path: PREVIEW_ROUTE,
|
|
31471
|
+
handler: (req, res) => servePreview(req, res, { readImageRequest: (ref, target, signal) => ctx.attachments.readImageRequest(ref, target, signal) })
|
|
31472
|
+
}), "dsh-image-gen: preview route");
|
|
29152
31473
|
webCtx.effect(() => register({
|
|
29153
31474
|
kind: "exact",
|
|
29154
31475
|
path: IMPORT_ROUTE,
|
|
29155
31476
|
handler: (req, res) => serveImport(req, res, {
|
|
29156
31477
|
saveImage: (image) => ctx.attachments.saveImage(image),
|
|
29157
31478
|
maxImageBytes: ctx.attachments.imageLimits.maxImageBytes,
|
|
29158
|
-
mediaTypes: ctx.attachments.imageLimits.mediaTypes
|
|
31479
|
+
mediaTypes: ctx.attachments.imageLimits.mediaTypes,
|
|
31480
|
+
warmThumbnail: (ref) => {
|
|
31481
|
+
ctx.attachments.readImageRequest(ref, {
|
|
31482
|
+
width: 320,
|
|
31483
|
+
height: 320,
|
|
31484
|
+
maxBytes: 98304
|
|
31485
|
+
}).catch(() => void 0);
|
|
31486
|
+
},
|
|
31487
|
+
saveOriginal: (input) => saveImportedOriginal(vaultConfig(), input, typeof req.headers["x-dsh-vault-profile"] === "string" ? req.headers["x-dsh-vault-profile"] : void 0)
|
|
29159
31488
|
})
|
|
29160
31489
|
}), "dsh-image-gen: import route");
|
|
31490
|
+
webCtx.effect(() => register({
|
|
31491
|
+
kind: "exact",
|
|
31492
|
+
path: ZIP_ROUTE,
|
|
31493
|
+
handler: (req, res) => serveZip(req, res, {
|
|
31494
|
+
readImage: (ref) => ctx.attachments.readImage(ref),
|
|
31495
|
+
readVault: (key, mediaType) => readOriginal(vaultConfig(), key, mediaType),
|
|
31496
|
+
maxEntries: 2e3
|
|
31497
|
+
})
|
|
31498
|
+
}), "dsh-image-gen: zip export route");
|
|
31499
|
+
webCtx.effect(() => register({
|
|
31500
|
+
kind: "exact",
|
|
31501
|
+
path: ZIP_PROGRESS_ROUTE,
|
|
31502
|
+
handler: (req, res) => serveZipProgress(req, res)
|
|
31503
|
+
}), "dsh-image-gen: zip progress route");
|
|
31504
|
+
webCtx.effect(() => register({
|
|
31505
|
+
kind: "exact",
|
|
31506
|
+
path: ZIP_CANCEL_ROUTE,
|
|
31507
|
+
handler: (req, res) => serveZipCancel(req, res)
|
|
31508
|
+
}), "dsh-image-gen: zip cancel route");
|
|
31509
|
+
webCtx.effect(() => register({
|
|
31510
|
+
kind: "exact",
|
|
31511
|
+
path: VAULT_ROUTE,
|
|
31512
|
+
handler: (req, res) => serveVault(req, res, vaultConfig())
|
|
31513
|
+
}), "dsh-image-gen: vault route");
|
|
31514
|
+
webCtx.effect(() => register({
|
|
31515
|
+
kind: "exact",
|
|
31516
|
+
path: VAULT_REFS_ROUTE,
|
|
31517
|
+
handler: (req, res) => serveVaultRefs(req, res, vaultConfig())
|
|
31518
|
+
}), "dsh-image-gen: vault refs route");
|
|
31519
|
+
webCtx.effect(() => register({
|
|
31520
|
+
kind: "exact",
|
|
31521
|
+
path: VAULT_DELETE_ROUTE,
|
|
31522
|
+
handler: (req, res) => serveVaultDelete(req, res, vaultConfig())
|
|
31523
|
+
}), "dsh-image-gen: vault delete route");
|
|
31524
|
+
webCtx.effect(() => register({
|
|
31525
|
+
kind: "exact",
|
|
31526
|
+
path: STORAGE_STATS_ROUTE,
|
|
31527
|
+
handler: (req, res) => {
|
|
31528
|
+
const configured = current().requestCacheDir?.trim() ?? "";
|
|
31529
|
+
serveStorageStats(req, res, { requestImagesRoot: configured.length > 0 ? configured : resolveRequestImageCacheDir() });
|
|
31530
|
+
}
|
|
31531
|
+
}), "dsh-image-gen: storage stats route");
|
|
29161
31532
|
webCtx.effect(() => register({
|
|
29162
31533
|
kind: "exact",
|
|
29163
31534
|
path: DELETE_ROUTE,
|
|
@@ -29271,7 +31642,8 @@ function apply(ctx, config = {}) {
|
|
|
29271
31642
|
return generateFromStudio(ctx, current(), input, signal, fallbackRoot, subscriptionManager);
|
|
29272
31643
|
},
|
|
29273
31644
|
maxBodyBytes: Math.ceil(ctx.attachments.imageLimits.maxImageBytes * 1.4 * 5) + 262144,
|
|
29274
|
-
timeoutMs: current().cloudTimeoutMs ?? 15e4
|
|
31645
|
+
timeoutMs: current().cloudTimeoutMs ?? 15e4,
|
|
31646
|
+
channelSelectsGif: (input) => channelSelectsGif(current(), input)
|
|
29275
31647
|
})
|
|
29276
31648
|
}), "dsh-image-gen: studio route");
|
|
29277
31649
|
const serveInspiration = createInspirationRoute();
|
|
@@ -29290,6 +31662,7 @@ function apply(ctx, config = {}) {
|
|
|
29290
31662
|
const args = cleanImageToolArgs(rawArgs);
|
|
29291
31663
|
const requestConfig = configForToolCall(current(), providerOverrideOf(args.provider), args.channel);
|
|
29292
31664
|
const active = resolveProvider(withProviderOverrides(requestConfig, providerOverrideOf(args.provider), args.model));
|
|
31665
|
+
assertGifProvider(args, active.provider);
|
|
29293
31666
|
if (active.provider === "comfyui") {
|
|
29294
31667
|
const workflow = selectComfyUIWorkflow(active, args.workflow);
|
|
29295
31668
|
return saveGenerated(ctx, await generateComfyUIImage({
|
|
@@ -29316,7 +31689,7 @@ function apply(ctx, config = {}) {
|
|
|
29316
31689
|
signal: exec.signal
|
|
29317
31690
|
}), active.provider, active.model, "subscription", current(), exec, knownWorkspaceRoots);
|
|
29318
31691
|
}
|
|
29319
|
-
const runCloud = (operation) => withGenerationTimeout(current().cloudTimeoutMs
|
|
31692
|
+
const runCloud = (operation, gif = false) => withGenerationTimeout(cloudTimeoutFor(current().cloudTimeoutMs, gif), exec.signal, operation);
|
|
29320
31693
|
const credential = await requireApiKey(ctx, active.provider, "generate_image", active.provider === "openai-compat" ? resolveOpenAICompatChannel(requestConfig, args.channel).keyRef : void 0);
|
|
29321
31694
|
if (active.provider === "google") {
|
|
29322
31695
|
const aspectRatio = args.aspect_ratio ?? active.aspectRatio;
|
|
@@ -29362,24 +31735,40 @@ function apply(ctx, config = {}) {
|
|
|
29362
31735
|
signal
|
|
29363
31736
|
})), active.provider, active.model, extraBody.aspect_ratio ?? "auto", current(), exec, knownWorkspaceRoots);
|
|
29364
31737
|
}
|
|
29365
|
-
const
|
|
31738
|
+
const channelExtraBody = active.provider === "openai-compat" ? requestConfig.openaiCompatExtraBody ?? {} : void 0;
|
|
31739
|
+
const gifFields = gifRequestFields({
|
|
31740
|
+
outputFormat: args.output_format ?? (channelExtraBody?.output_format === "gif" ? "gif" : void 0),
|
|
31741
|
+
frameCount: args.frame_count,
|
|
31742
|
+
frameDurationMs: args.frame_duration_ms
|
|
31743
|
+
});
|
|
31744
|
+
const gifRequested = gifFields.output_format === "gif";
|
|
31745
|
+
const size = gifRequested && args.size === void 0 ? void 0 : args.size ?? active.imageSize;
|
|
29366
31746
|
const arkOptions = active.provider === "seedream" ? active.arkOptions : void 0;
|
|
29367
|
-
if (active.provider === "openai"
|
|
31747
|
+
if (active.provider === "openai" && size !== void 0) {
|
|
31748
|
+
if (gifRequested) validateGifFrameSize(size);
|
|
31749
|
+
else validateOfficialSize(active.model, size);
|
|
31750
|
+
}
|
|
29368
31751
|
const quality = active.provider === "openai-compat" ? compatRequestQuality(requestConfig, args.quality) : active.provider === "openai" ? args.quality : void 0;
|
|
29369
31752
|
if (active.provider === "openai" && quality !== void 0 && !officialQualityOptions(active.model).some((option) => option.value === quality)) throw new Error("该 OpenAI 模型不支持所选生成质量");
|
|
29370
|
-
const
|
|
31753
|
+
const extraBody = channelExtraBody !== void 0 ? {
|
|
31754
|
+
...channelExtraBody,
|
|
31755
|
+
...gifFields
|
|
31756
|
+
} : Object.keys(gifFields).length === 0 ? void 0 : gifFields;
|
|
31757
|
+
const generated = await runCloud((signal) => generateOpenAICompatibleImage({
|
|
29371
31758
|
provider: active.provider,
|
|
29372
31759
|
apiKey: credential,
|
|
29373
31760
|
baseURL: active.baseURL,
|
|
29374
31761
|
model: active.model,
|
|
29375
31762
|
prompt: args.prompt,
|
|
29376
|
-
size,
|
|
31763
|
+
...size === void 0 ? {} : { size },
|
|
29377
31764
|
maxBytes: ctx.attachments.imageLimits.maxImageBytes,
|
|
29378
31765
|
signal,
|
|
29379
31766
|
...quality === void 0 ? {} : { quality },
|
|
29380
|
-
...
|
|
31767
|
+
...extraBody === void 0 ? {} : { extraBody },
|
|
29381
31768
|
...arkOptions === void 0 ? {} : { arkOptions }
|
|
29382
|
-
})
|
|
31769
|
+
}), gifRequested);
|
|
31770
|
+
const output = size ?? "auto";
|
|
31771
|
+
const value = await saveGenerated(ctx, generated, active.provider, active.model, quality === void 0 ? output : `${output}, quality=${quality}`, current(), exec, knownWorkspaceRoots);
|
|
29383
31772
|
return active.provider === "openai-compat" ? {
|
|
29384
31773
|
...value,
|
|
29385
31774
|
channel: resolveOpenAICompatChannel(requestConfig, args.channel).id
|
|
@@ -29455,7 +31844,8 @@ function apply(ctx, config = {}) {
|
|
|
29455
31844
|
workflow: {
|
|
29456
31845
|
type: "string",
|
|
29457
31846
|
description: "Optional name of the ComfyUI workflow to run; omit to use the active workflow from settings. Only meaningful when the ComfyUI provider is selected."
|
|
29458
|
-
}
|
|
31847
|
+
},
|
|
31848
|
+
...gifToolParameters
|
|
29459
31849
|
},
|
|
29460
31850
|
output: imageOutput("Generated"),
|
|
29461
31851
|
async execute(args, exec) {
|
|
@@ -29534,7 +31924,8 @@ function apply(ctx, config = {}) {
|
|
|
29534
31924
|
workflow: {
|
|
29535
31925
|
type: "string",
|
|
29536
31926
|
description: "Optional name of the ComfyUI workflow to run; omit to use the active workflow from settings."
|
|
29537
|
-
}
|
|
31927
|
+
},
|
|
31928
|
+
...gifToolParameters
|
|
29538
31929
|
},
|
|
29539
31930
|
output: {
|
|
29540
31931
|
schema: {
|
|
@@ -29605,6 +31996,8 @@ function apply(ctx, config = {}) {
|
|
|
29605
31996
|
savedTo: { type: "string" },
|
|
29606
31997
|
saveError: { type: "string" },
|
|
29607
31998
|
seed: { type: "integer" },
|
|
31999
|
+
animation: animationSchema,
|
|
32000
|
+
original: originalSchema,
|
|
29608
32001
|
prompt: {
|
|
29609
32002
|
type: "string",
|
|
29610
32003
|
required: true
|
|
@@ -29635,10 +32028,10 @@ function apply(ctx, config = {}) {
|
|
|
29635
32028
|
}
|
|
29636
32029
|
}
|
|
29637
32030
|
},
|
|
29638
|
-
render: (
|
|
32031
|
+
render: (args, value) => [{
|
|
29639
32032
|
type: "text",
|
|
29640
32033
|
text: `Generated ${String(value.images.length)} of ${String(value.images.length + value.failures.length)} images.\n${value.failures.map((failure) => `${failure.prompt.slice(0, 80)} — failed: ${failure.error}`).join("\n")}`
|
|
29641
|
-
}, ...value.images.flatMap((image) => imageOutput("Generated").render(
|
|
32034
|
+
}, ...value.images.flatMap((image) => imageOutput("Generated").render(args, image).map((block) => block.type === "text" ? {
|
|
29642
32035
|
...block,
|
|
29643
32036
|
text: `${block.text}\nImage prompt: ${image.prompt}`
|
|
29644
32037
|
} : block))],
|
|
@@ -29671,7 +32064,10 @@ function apply(ctx, config = {}) {
|
|
|
29671
32064
|
...args.image_size !== void 0 ? { image_size: args.image_size } : {},
|
|
29672
32065
|
...args.size !== void 0 ? { size: args.size } : {},
|
|
29673
32066
|
...args.quality !== void 0 ? { quality: args.quality } : {},
|
|
29674
|
-
...args.workflow !== void 0 ? { workflow: args.workflow } : {}
|
|
32067
|
+
...args.workflow !== void 0 ? { workflow: args.workflow } : {},
|
|
32068
|
+
...args.output_format !== void 0 ? { output_format: args.output_format } : {},
|
|
32069
|
+
...args.frame_count !== void 0 ? { frame_count: args.frame_count } : {},
|
|
32070
|
+
...args.frame_duration_ms !== void 0 ? { frame_duration_ms: args.frame_duration_ms } : {}
|
|
29675
32071
|
}, exec);
|
|
29676
32072
|
images.push({
|
|
29677
32073
|
...value,
|
|
@@ -29694,7 +32090,7 @@ function apply(ctx, config = {}) {
|
|
|
29694
32090
|
}));
|
|
29695
32091
|
ctx.tools.register(defineTool({
|
|
29696
32092
|
name: "edit_image",
|
|
29697
|
-
description: "Edit, combine, or restyle existing images with the configured provider. Images attached inline to the latest human message are already readable DSH attachments even when no workspace file exists. In that case, call edit_image immediately with prompt only; NEVER call read_image, glob, or shell to locate them, and NEVER invent @ paths. All inline images will be used in upload order. For specific older conversation images use source_attachment_id or source_attachment_ids; both canonical sha256: IDs and full bare SHA-256 digests are accepted. For files the user explicitly names in the workspace use source_path or source_paths. For what the user selected or drew on the image-gen workbench canvas (for example a hand-drawn sketch) use source=canvas_selection; canvas_state can verify a selection exists first. Provide exactly one selector field. Without a selector, images from the latest human message take priority; only when that message has no images does editing fall back to the newest conversation image. When the user wants a person, character, or object from the reference images kept as the same identity, write short hard identity-preservation instructions (use the same subject from the references; do not redesign it or synthesize a similar-looking replacement; change only scene, clothing, pose, lighting, style, or composition) instead of long generic appearance descriptions, which make the model replace the subject with a synthesized lookalike.",
|
|
32093
|
+
description: "Edit, combine, or restyle existing images with the configured provider. Images attached inline to the latest human message are already readable DSH attachments even when no workspace file exists. In that case, call edit_image immediately with prompt only (plus output options such as output_format=gif when the user wants an animation); NEVER call read_image, glob, or shell to locate them, and NEVER invent @ paths. All inline images will be used in upload order. For specific older conversation images use source_attachment_id or source_attachment_ids; both canonical sha256: IDs and full bare SHA-256 digests are accepted. For files the user explicitly names in the workspace use source_path or source_paths. For what the user selected or drew on the image-gen workbench canvas (for example a hand-drawn sketch) use source=canvas_selection; canvas_state can verify a selection exists first. Provide exactly one selector field. Without a selector, images from the latest human message take priority; only when that message has no images does editing fall back to the newest conversation image. When the user wants a person, character, or object from the reference images kept as the same identity, write short hard identity-preservation instructions (use the same subject from the references; do not redesign it or synthesize a similar-looking replacement; change only scene, clothing, pose, lighting, style, or composition) instead of long generic appearance descriptions, which make the model replace the subject with a synthesized lookalike.",
|
|
29698
32094
|
parameters: {
|
|
29699
32095
|
prompt: {
|
|
29700
32096
|
type: "string",
|
|
@@ -29785,13 +32181,15 @@ function apply(ctx, config = {}) {
|
|
|
29785
32181
|
workflow: {
|
|
29786
32182
|
type: "string",
|
|
29787
32183
|
description: "Optional name of the ComfyUI workflow to run; omit to use the active workflow from settings. Only meaningful when the ComfyUI provider is selected."
|
|
29788
|
-
}
|
|
32184
|
+
},
|
|
32185
|
+
...editGifToolParameters
|
|
29789
32186
|
},
|
|
29790
32187
|
output: imageOutput("Edited"),
|
|
29791
32188
|
async execute(rawArgs, exec) {
|
|
29792
32189
|
const args = cleanImageToolArgs(rawArgs);
|
|
29793
32190
|
const requestConfig = configForToolCall(current(), providerOverrideOf(args.provider), args.channel);
|
|
29794
32191
|
const active = resolveProvider(withProviderOverrides(requestConfig, providerOverrideOf(args.provider), args.model, "edit"));
|
|
32192
|
+
assertGifProvider(args, active.provider);
|
|
29795
32193
|
const canvasSelection = args.source === "canvas_selection";
|
|
29796
32194
|
if (canvasSelection && (args.source_attachment_id !== void 0 || Array.isArray(args.source_attachment_ids) || args.source_path !== void 0 || Array.isArray(args.source_paths))) throw new Error("edit_image source=canvas_selection cannot be combined with source_attachment_id, source_attachment_ids, source_path, or source_paths; provide exactly one selector");
|
|
29797
32195
|
const sourceImages = canvasSelection ? await resolveCanvasSelectionReferences({
|
|
@@ -29864,7 +32262,17 @@ function apply(ctx, config = {}) {
|
|
|
29864
32262
|
}
|
|
29865
32263
|
const size = args.size ?? active.imageSize;
|
|
29866
32264
|
if (active.provider === "openai" || active.provider === "openai-compat" || active.provider === "xai" || active.provider === "zhipu") {
|
|
29867
|
-
|
|
32265
|
+
const gifFields = gifRequestFields({
|
|
32266
|
+
outputFormat: args.output_format,
|
|
32267
|
+
frameCount: args.frame_count,
|
|
32268
|
+
frameDurationMs: args.frame_duration_ms
|
|
32269
|
+
});
|
|
32270
|
+
const gifRequested = gifFields.output_format === "gif";
|
|
32271
|
+
const editSize = gifRequested && args.size === void 0 ? void 0 : size;
|
|
32272
|
+
if (active.provider === "openai" && editSize !== void 0) {
|
|
32273
|
+
if (gifRequested) validateGifFrameSize(editSize);
|
|
32274
|
+
else validateOfficialSize(active.model, editSize);
|
|
32275
|
+
}
|
|
29868
32276
|
const quality = active.provider === "openai-compat" ? compatRequestQuality(requestConfig, args.quality) : active.provider === "openai" ? args.quality : void 0;
|
|
29869
32277
|
if (active.provider === "openai" && quality !== void 0 && !officialQualityOptions(active.model).some((option) => option.value === quality)) throw new Error("该 OpenAI 模型不支持所选生成质量");
|
|
29870
32278
|
const xaiExtraBody = active.provider === "xai" ? xaiToolParameters({
|
|
@@ -29872,25 +32280,32 @@ function apply(ctx, config = {}) {
|
|
|
29872
32280
|
...args.image_size !== void 0 ? { imageSize: args.image_size } : {},
|
|
29873
32281
|
...args.size !== void 0 ? { size: args.size } : {}
|
|
29874
32282
|
}) : void 0;
|
|
29875
|
-
const
|
|
32283
|
+
const generated = await editOpenAICompatibleImage({
|
|
29876
32284
|
apiKey: credential,
|
|
29877
32285
|
baseURL: active.baseURL,
|
|
29878
32286
|
model: active.model,
|
|
29879
32287
|
prompt: args.prompt,
|
|
29880
32288
|
sourceImages,
|
|
29881
|
-
...active.provider === "xai" ? {} : { size },
|
|
32289
|
+
...active.provider === "xai" || editSize === void 0 ? {} : { size: editSize },
|
|
29882
32290
|
...quality === void 0 ? {} : { quality },
|
|
29883
32291
|
maxBytes: ctx.attachments.imageLimits.maxImageBytes,
|
|
29884
32292
|
signal: exec.signal,
|
|
32293
|
+
...gifRequested ? { extraBody: gifFields } : {},
|
|
29885
32294
|
...active.provider === "openai-compat" ? {
|
|
29886
32295
|
editFormat: active.editFormat,
|
|
29887
32296
|
editExtra: active.editExtra,
|
|
29888
|
-
editSizeMode: requestConfig.openaiCompatEditSizeMode ?? "auto"
|
|
32297
|
+
editSizeMode: requestConfig.openaiCompatEditSizeMode ?? "auto",
|
|
32298
|
+
editResponseFormat: "auto",
|
|
32299
|
+
onResponseFormatRetry: (status) => {
|
|
32300
|
+
ctx.logger.warn("dsh-image-gen: openai-compatible relay rejected response_format=b64_json (" + String(status) + "); retrying without it");
|
|
32301
|
+
}
|
|
29889
32302
|
} : active.provider === "xai" ? {
|
|
29890
32303
|
editFormat: "xaiJson",
|
|
29891
32304
|
...xaiExtraBody === void 0 ? {} : { extraBody: xaiExtraBody }
|
|
29892
32305
|
} : {}
|
|
29893
|
-
})
|
|
32306
|
+
});
|
|
32307
|
+
const output = editSize ?? "auto";
|
|
32308
|
+
const value = await saveGenerated(ctx, generated, active.provider, active.model, active.provider === "xai" ? xaiExtraBody?.aspect_ratio ?? "auto" : quality === void 0 ? output : `${output}, quality=${quality}`, current(), exec, knownWorkspaceRoots);
|
|
29894
32309
|
return active.provider === "openai-compat" ? {
|
|
29895
32310
|
...value,
|
|
29896
32311
|
channel: resolveOpenAICompatChannel(requestConfig, args.channel).id
|
|
@@ -30070,15 +32485,17 @@ function imageOutput(verb) {
|
|
|
30070
32485
|
},
|
|
30071
32486
|
savedTo: { type: "string" },
|
|
30072
32487
|
saveError: { type: "string" },
|
|
30073
|
-
seed: { type: "integer" }
|
|
32488
|
+
seed: { type: "integer" },
|
|
32489
|
+
animation: animationSchema,
|
|
32490
|
+
original: originalSchema
|
|
30074
32491
|
}
|
|
30075
32492
|
},
|
|
30076
|
-
render: (
|
|
32493
|
+
render: (args, value) => {
|
|
30077
32494
|
const saved = typeof value.savedTo === "string" ? ` It was also saved to the workspace as ${value.savedTo}.` : typeof value.saveError === "string" ? ` Saving it to the workspace failed: ${value.saveError}.` : " It has no local file path.";
|
|
30078
32495
|
const action = verb === "Generated" ? "It is already attached to the conversation." : "The edited image is attached to the conversation.";
|
|
30079
32496
|
return [{
|
|
30080
32497
|
type: "text",
|
|
30081
|
-
text: `${verb} one image with ${value.provider}/${value.model} (${value.output}). Attachment ID: ${String(value.attachment.attachmentId)}. ${action}${saved} Respond to the user without reading or searching for the image
|
|
32498
|
+
text: `${verb} one image with ${value.provider}/${value.model} (${value.output}). Attachment ID: ${String(value.attachment.attachmentId)}. ${action}${saved} Respond to the user without reading or searching for the image.${animationNotes(args, value)}${cardNote(value)}${originalNote(value)}`
|
|
30082
32499
|
}, {
|
|
30083
32500
|
type: "image",
|
|
30084
32501
|
attachment: value.attachment
|
|
@@ -30094,10 +32511,67 @@ function imageOutput(verb) {
|
|
|
30094
32511
|
...verb === "Edited" ? { operation: "edit" } : {},
|
|
30095
32512
|
...typeof value.savedTo === "string" ? { savedTo: value.savedTo } : {},
|
|
30096
32513
|
...typeof value.seed === "number" ? { seed: value.seed } : {},
|
|
32514
|
+
...value.original === void 0 ? {} : { original: {
|
|
32515
|
+
key: value.original.key,
|
|
32516
|
+
bytes: value.original.bytes,
|
|
32517
|
+
mediaType: value.original.mediaType
|
|
32518
|
+
} },
|
|
30097
32519
|
prompt: args.prompt
|
|
30098
32520
|
})
|
|
30099
32521
|
};
|
|
30100
32522
|
}
|
|
32523
|
+
/**
|
|
32524
|
+
* What the model needs to know about an animated result: that it is complete
|
|
32525
|
+
* (the attached preview may be a still first frame), and whether the provider
|
|
32526
|
+
* honoured the GIF parameters it was asked for.
|
|
32527
|
+
*/
|
|
32528
|
+
function animationNotes(args, value) {
|
|
32529
|
+
const requested = args ?? {};
|
|
32530
|
+
const animation = value.animation;
|
|
32531
|
+
if (animation === void 0) return requested.output_format === "gif" ? " Requested a GIF, but the provider returned a still image (no animation frames); the model or relay may not support GIF output." : "";
|
|
32532
|
+
let notes = ` The image is an animated GIF: ${String(animation.frames)} frames × ${String(animation.frameDurationMs)} ms (${String(animation.durationMs / 1e3)}s loop). It is complete; do not regenerate or redraw individual frames unless the user asks.`;
|
|
32533
|
+
if (value.attachment.mediaType !== "image/gif") notes += " The attached preview shows only its first frame.";
|
|
32534
|
+
const mismatches = [];
|
|
32535
|
+
if (typeof requested.frame_count === "number" && requested.frame_count !== animation.frames) mismatches.push(`Requested ${String(requested.frame_count)} frames but received ${String(animation.frames)}`);
|
|
32536
|
+
if (typeof requested.frame_duration_ms === "number" && Math.abs(requested.frame_duration_ms - animation.frameDurationMs) >= 10) mismatches.push(`Requested ${String(requested.frame_duration_ms)} ms per frame but received ${String(animation.frameDurationMs)} ms`);
|
|
32537
|
+
if (mismatches.length > 0) notes += ` ${mismatches.join(". ")}; the provider may have ignored the GIF parameters.`;
|
|
32538
|
+
return notes;
|
|
32539
|
+
}
|
|
32540
|
+
/**
|
|
32541
|
+
* The result card already shows the image and links its saved file; an embed
|
|
32542
|
+
* or a link in the reply shows the same result twice, and a ready-made embed
|
|
32543
|
+
* gets copied even when told not to. A GIF the card cannot play (no vault
|
|
32544
|
+
* copy, still attachment) is the exception: only an embed of the saved file
|
|
32545
|
+
* animates. Inside run_code the program receives the structured value, never
|
|
32546
|
+
* this text, so the text only serves direct calls.
|
|
32547
|
+
*/
|
|
32548
|
+
function cardNote(value) {
|
|
32549
|
+
const animated = value.animation !== void 0;
|
|
32550
|
+
if (animated && value.attachment.mediaType !== "image/gif" && value.original === void 0) return typeof value.savedTo === "string" ? ` To show the animation, embed the saved file in your reply with Markdown: }>)` : "";
|
|
32551
|
+
const saved = typeof value.savedTo === "string" ? " and where it was saved" : "";
|
|
32552
|
+
return ` The result card in the conversation already ${animated ? "plays the animation" : "shows the image"}${saved}; do not embed the image or link its file in your reply unless the user asks.`;
|
|
32553
|
+
}
|
|
32554
|
+
/**
|
|
32555
|
+
* A run_code sub-call reaches the client only as this rendered text (no
|
|
32556
|
+
* presentationMeta), so the vault reference rides here in a fixed sentence the
|
|
32557
|
+
* PTC card parser reads (image-result-node.ts ORIGINAL_NOTE_PATTERN). Without
|
|
32558
|
+
* it a GIF made through run_code shows its still first frame only.
|
|
32559
|
+
*/
|
|
32560
|
+
function originalNote(value) {
|
|
32561
|
+
const original = value.original;
|
|
32562
|
+
return original === void 0 ? "" : ` Original animation (for the result card only): vault ${original.key}, ${String(original.bytes)} bytes, ${original.mediaType}.`;
|
|
32563
|
+
}
|
|
32564
|
+
/** Frame facts for a GIF result; a single-frame GIF is a still image and gets none. */
|
|
32565
|
+
function animationOf(generated) {
|
|
32566
|
+
if (generated.mediaType !== "image/gif") return void 0;
|
|
32567
|
+
const info = readGifInfo(generated.data);
|
|
32568
|
+
if (info === void 0 || info.frames < 2) return void 0;
|
|
32569
|
+
return {
|
|
32570
|
+
frames: info.frames,
|
|
32571
|
+
frameDurationMs: gifFrameDurationMs(info.delaysMs),
|
|
32572
|
+
durationMs: info.delaysMs.reduce((sum, delay) => sum + delay, 0)
|
|
32573
|
+
};
|
|
32574
|
+
}
|
|
30101
32575
|
function attachmentMeta(ref) {
|
|
30102
32576
|
return {
|
|
30103
32577
|
attachmentId: String(ref.attachmentId),
|
|
@@ -30119,12 +32593,18 @@ async function saveGenerated(ctx, generated, provider, model, output, config, ex
|
|
|
30119
32593
|
mediaType: generated.mediaType,
|
|
30120
32594
|
name: "generated-image"
|
|
30121
32595
|
});
|
|
32596
|
+
const animation = animationOf(generated);
|
|
32597
|
+
const original = await keepGifOriginal(vaultConfigOf(config), generated, attachment, (message) => {
|
|
32598
|
+
ctx.logger.warn(message);
|
|
32599
|
+
});
|
|
30122
32600
|
const value = {
|
|
30123
32601
|
attachment,
|
|
30124
32602
|
provider,
|
|
30125
32603
|
model,
|
|
30126
32604
|
output,
|
|
30127
|
-
...typeof generated.seed === "number" ? { seed: generated.seed } : {}
|
|
32605
|
+
...typeof generated.seed === "number" ? { seed: generated.seed } : {},
|
|
32606
|
+
...animation === void 0 ? {} : { animation },
|
|
32607
|
+
...original === void 0 ? {} : { original }
|
|
30128
32608
|
};
|
|
30129
32609
|
if (config.saveToWorkspace === false) return value;
|
|
30130
32610
|
const workspaceRoot = exec.agent?.session.header.cwd;
|