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.
Files changed (76) hide show
  1. package/README.en.md +148 -133
  2. package/README.md +148 -133
  3. package/docs/assets/readme/canvas-edit.webp +0 -0
  4. package/docs/assets/readme/canvas-sketch.webp +0 -0
  5. package/docs/assets/readme/chat-batch.webp +0 -0
  6. package/docs/assets/readme/config.webp +0 -0
  7. package/docs/assets/readme/hero-batch.webp +0 -0
  8. package/docs/assets/readme/hero-canvas.webp +0 -0
  9. package/docs/assets/readme/studio-ref.webp +0 -0
  10. package/docs/assets/readme/studio.webp +0 -0
  11. package/lib/client.js +10848 -6335
  12. package/lib/index.js +2625 -145
  13. package/lib/types/capabilities.d.ts +10 -6
  14. package/lib/types/client/browser-image-utils.d.ts +6 -0
  15. package/lib/types/client/conversation-image-revisions.d.ts +3 -0
  16. package/lib/types/client/conversation-regenerate.d.ts +9 -1
  17. package/lib/types/client/datalist-scroll-fix.d.ts +32 -0
  18. package/lib/types/client/gallery-batch-bar.d.ts +8 -0
  19. package/lib/types/client/gallery-card.d.ts +21 -0
  20. package/lib/types/client/gallery-format.d.ts +7 -0
  21. package/lib/types/client/gallery-i18n.d.ts +236 -0
  22. package/lib/types/client/gallery-media.d.ts +58 -0
  23. package/lib/types/client/gallery-merge.d.ts +15 -0
  24. package/lib/types/client/gallery-store.d.ts +23 -2
  25. package/lib/types/client/gallery-thumbs.d.ts +24 -0
  26. package/lib/types/client/gallery-view.d.ts +0 -207
  27. package/lib/types/client/gif-card.d.ts +67 -0
  28. package/lib/types/client/gif-comments.d.ts +95 -0
  29. package/lib/types/client/gif-controls.d.ts +22 -0
  30. package/lib/types/client/gif-edit.d.ts +87 -0
  31. package/lib/types/client/gif-filmstrip.d.ts +23 -0
  32. package/lib/types/client/gif-gallery.d.ts +39 -0
  33. package/lib/types/client/gif-i18n.d.ts +159 -0
  34. package/lib/types/client/gif-menus.d.ts +36 -0
  35. package/lib/types/client/gif-player.d.ts +131 -0
  36. package/lib/types/client/gif-sheet.d.ts +116 -0
  37. package/lib/types/client/gif-style.d.ts +2 -0
  38. package/lib/types/client/image-result-node.d.ts +19 -1
  39. package/lib/types/client/image-result-view.d.ts +26 -0
  40. package/lib/types/client/index.d.ts +1 -20
  41. package/lib/types/client/plugin-i18n.d.ts +375 -0
  42. package/lib/types/client/plugin-style.d.ts +1 -0
  43. package/lib/types/client/provider-pill.d.ts +9 -0
  44. package/lib/types/client/studio-helpers.d.ts +63 -0
  45. package/lib/types/client/studio-session.d.ts +54 -0
  46. package/lib/types/client/studio-style.d.ts +1 -1
  47. package/lib/types/client/studio-view.d.ts +6 -7
  48. package/lib/types/client/tl/gallery-image-transfer.d.ts +1 -1
  49. package/lib/types/config.d.ts +12 -0
  50. package/lib/types/gif-info.d.ts +32 -0
  51. package/lib/types/import-route.d.ts +11 -0
  52. package/lib/types/multipart.d.ts +29 -0
  53. package/lib/types/openai-compatible.d.ts +8 -0
  54. package/lib/types/openai-output.d.ts +20 -1
  55. package/lib/types/request-cache.d.ts +41 -0
  56. package/lib/types/shared.d.ts +57 -3
  57. package/lib/types/storage-stats.d.ts +34 -0
  58. package/lib/types/studio-route.d.ts +2 -0
  59. package/lib/types/studio.d.ts +9 -0
  60. package/lib/types/subscription/vendors/codex.d.ts +4 -3
  61. package/lib/types/thumb-route.d.ts +25 -0
  62. package/lib/types/vault-registry.d.ts +66 -0
  63. package/lib/types/vault-route.d.ts +8 -0
  64. package/lib/types/vault-store.d.ts +33 -0
  65. package/lib/types/zip-progress.d.ts +18 -0
  66. package/lib/types/zip-route.d.ts +25 -0
  67. package/lib/types/zip-stream.d.ts +43 -0
  68. package/package.json +2 -2
  69. package/lib/types/client/tl/tl-trace-bridge.d.ts +0 -55
  70. package/lib/types/client/tl/trace-playback.d.ts +0 -101
  71. package/lib/types/trace-drawing.d.ts +0 -102
  72. package/lib/types/trace-route.d.ts +0 -12
  73. package/lib/types/trace-sketch.d.ts +0 -26
  74. package/lib/types/trace-store.d.ts +0 -23
  75. package/lib/types/vendor/drawai/raster.d.ts +0 -43
  76. 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 { lstat, mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
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-260128";
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.5-flare",
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 ?? "doubao-seedream-5-0-260128",
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$6(res, 405, "method-not-allowed");
6779
- if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$6(res, 415, "json-required");
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$6(res, 403, "origin-rejected");
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$5(req, deps.maxBodyBytes));
6847
+ body = JSON.parse(await readBody$8(req, deps.maxBodyBytes));
6786
6848
  } catch (error) {
6787
- return jsonError$6(res, error instanceof Error && error.message === "request-too-large" ? 413 : 400, "invalid-request");
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$6(res, 400, "invalid-canvas-state");
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$6(res, 400, "invalid-selection-image");
6795
- if (decoded.data.byteLength > deps.maxImageBytes) return jsonError$6(res, 413, "selection-image-too-large");
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$5(req, maxBytes) {
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$6(res, status, code) {
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 !== "POST") return jsonError$5(res, 405, "method-not-allowed");
8150
- if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$5(res, 415, "json-required");
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$5(res, 403, "origin-rejected");
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$4(req));
8220
+ body = JSON.parse(await readBody$7(req));
8157
8221
  } catch {
8158
- return jsonError$5(res, 400, "invalid-request");
8222
+ return jsonError$10(res, 400, "invalid-request");
8159
8223
  }
8160
8224
  const attachment = attachmentFromRequest(body);
8161
- if (attachment === void 0) return jsonError$5(res, 400, "invalid-attachment");
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$5(res, 404, "image-unavailable");
8236
+ jsonError$10(res, 404, "image-unavailable");
8173
8237
  }
8174
8238
  }
8175
- function sameOrigin$4(req) {
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$5(res, 405, "method-not-allowed");
8184
- if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$5(res, 415, "json-required");
8185
- if (!sameOrigin$4(req)) return jsonError$5(res, 403, "origin-rejected");
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$4(req));
8286
+ body = JSON.parse(await readBody$7(req));
8189
8287
  } catch {
8190
- return jsonError$5(res, 400, "invalid-request");
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$4(req)) return jsonError$5(res, 403, "origin-rejected");
8221
- if (req.method !== "POST") return jsonError$5(res, 405, "method-not-allowed");
8222
- if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return jsonError$5(res, 415, "json-required");
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$4(req));
8322
+ const raw = JSON.parse(await readBody$7(req));
8225
8323
  const attachment = parseImageAttachmentRef(raw.attachment);
8226
- if (!attachment) return jsonError$5(res, 400, "invalid-attachment");
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$5(res, 403, "workspace-root-not-allowed");
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$5(res, 404, "image-not-found");
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$4(req) {
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$5(res, status, code) {
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 BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
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$4(res, 405, "method-not-allowed");
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$4(res, 403, "origin-rejected");
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$3(req, bodyLimit(deps.maxImageBytes)));
8636
+ body = JSON.parse(await readBody$6(req, bodyLimit(deps.maxImageBytes)));
8320
8637
  } catch {
8321
- return jsonError$4(res, 400, "invalid-request");
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$4(res, 400, "invalid-image-count");
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 (!BASE64_PATTERN.test(record.data)) {
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 attachment = await deps.saveImage({
8360
- data: bytes,
8361
- mediaType: record.mediaType,
8362
- ...typeof record.name === "string" && record.name.length > 0 ? { name: record.name } : {}
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
- images: saved,
8378
- failures
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, maxBytes) {
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 > maxBytes) throw new Error("request-too-large");
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 form = new FormData();
8539
- const imageField = input.sourceImages.length > 1 ? "image[]" : "image";
8540
- input.sourceImages.forEach((sourceImage, index) => {
8541
- const uploadBytes = new Uint8Array(sourceImage.data);
8542
- const blob = new Blob([uploadBytes], { type: sourceImage.mediaType });
8543
- const filename = `reference-${index + 1}.${extensionOf(sourceImage.mediaType)}`;
8544
- form.append(imageField, blob, filename);
8545
- });
8546
- form.append("prompt", input.prompt);
8547
- form.append("model", input.model);
8548
- if (input.size !== void 0 && input.size.length > 0) form.append("size", input.size);
8549
- if (input.quality !== void 0) form.append("quality", input.quality);
8550
- for (const [key, value] of Object.entries(input.extraBody ?? {})) form.append(key, String(value));
8551
- return parseImageResponse(await guardedFetch("openai image edit request", input.signal, () => fetch(imageEndpoint$1(input.baseURL, "edits"), {
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), "openai", input);
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 sameOrigin = new URL(url).origin === new URL(input.baseURL).origin;
8618
- let response = await guardedFetch(`${provider} image download`, input.signal, () => fetch(url, {
8619
- redirect: "follow",
8620
- signal: input.signal,
8621
- ...input.apiKey === void 0 || !sameOrigin ? {} : { headers: { authorization: `Bearer ${input.apiKey}` } }
8622
- }), input.apiKey);
8623
- if (!response.ok && input.apiKey !== void 0 && sameOrigin) response = await guardedFetch(`${provider} image download`, input.signal, () => fetch(url, {
8624
- redirect: "follow",
8625
- signal: input.signal
8626
- }), input.apiKey);
8627
- if (!response.ok) throw new Error(`${provider} image download failed (${response.status})`);
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) throw new Error(`seedream image download failed (${response.status})`);
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) throw new Error("seedream image download returned unsupported content type");
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 (probed live: gpt-image-2.5-flare works
9039
- * on the ChatGPT internal generations route as of 2026-09-14). */
9040
- const CODEX_IMAGE_MODEL = "gpt-image-2.5-flare";
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
- assertAllowed(studioProfile(requestConfig, input.provider, true, channel?.id), input);
28045
- const active = resolveProvider(withProviderOverrides(requestConfig, input.provider, input.model));
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 : CLOUD_CAPABILITIES[p].sizeFor?.(input.ratio, input.quality);
28089
- if (p === "openai" && size !== void 0) validateOfficialSize(wired.model, size);
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 extraBody = p === "xai" ? {
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 = CLOUD_CAPABILITIES.seedream.sizeFor?.(input.ratio, input.quality) ?? input.quality;
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: await ctx.attachments.saveImage({
28182
- data: generated.data,
28183
- mediaType: generated.mediaType,
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: input.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: input.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 = CLOUD_CAPABILITIES[provider];
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
- function assertAllowed(profile, input) {
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 (!profile.ratioOptions.some((option) => option.value === input.ratio)) throw new Error("该 Provider 不支持所选比例");
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 (!profile.qualityOptions.some((option) => option.value === selectedQuality)) throw new Error("该 Provider 不支持所选清晰度");
28470
- if (input.resolution !== void 0 && profile.resolutionOptions !== void 0 && !profile.resolutionOptions[input.ratio]?.some((option) => option.value === input.resolution)) throw new Error("该比例不支持所选分辨率");
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 timeoutMs = subscriptionChannel ? SUBSCRIPTION_TIMEOUT_MS : deps.timeoutMs ?? 15e4;
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 ?? 15e4, exec.signal, operation);
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 size = args.size ?? active.imageSize;
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") validateOfficialSize(active.model, size);
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 value = await saveGenerated(ctx, await runCloud((signal) => generateOpenAICompatibleImage({
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
- ...active.provider === "openai-compat" ? { extraBody: requestConfig.openaiCompatExtraBody ?? {} } : {},
31767
+ ...extraBody === void 0 ? {} : { extraBody },
29381
31768
  ...arkOptions === void 0 ? {} : { arkOptions }
29382
- })), active.provider, active.model, quality === void 0 ? size : `${size}, quality=${quality}`, current(), exec, knownWorkspaceRoots);
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: (_args, value) => [{
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({}, image).map((block) => block.type === "text" ? {
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
- if (active.provider === "openai") validateOfficialSize(active.model, size);
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 value = await saveGenerated(ctx, await editOpenAICompatibleImage({
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
- }), active.provider, active.model, active.provider === "xai" ? xaiExtraBody?.aspect_ratio ?? "auto" : quality === void 0 ? size : `${size}, quality=${quality}`, current(), exec, knownWorkspaceRoots);
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: (_args, value) => {
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: ![animated GIF](<${value.savedTo.replace(/\\/g, "/")}>)` : "";
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;