dsh-image-video 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,3509 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { createReadStream } from "node:fs";
3
+ import { mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { basename, extname, join, resolve, sep } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { randomBytes, randomUUID } from "node:crypto";
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ import { defineTool } from "@deepseek-ai/dsh-tools";
10
+ import sharp from "sharp";
11
+ //#region src/config.ts
12
+ /**
13
+ * 插件配置类型与 Schemastery schema。所有部署可变参数都通过 Config 暴露,
14
+ * 不存在硬编码可调参数;切换服务商只需改 `provider` 字段,HMR 自动重载。
15
+ * @module dsh-image-video/config
16
+ */
17
+ /** 服务商凭证 schema,复用于 threerouter/wanx/seedance。apiKey 可空(未激活的 provider 留空)。 */
18
+ const ProviderCredentialsSchema = z.object({
19
+ apiKey: z.string().default("").description("服务商 API Key;未激活的 provider 可留空"),
20
+ baseURL: z.string().default("").description("自定义接口地址,留空使用默认端点")
21
+ });
22
+ /** 插件配置 schema,默认服务商为 threerouter(图片+视频统一入口),wanx/seedance 可选。 */
23
+ const Config = z.object({
24
+ provider: z.union([
25
+ "threerouter",
26
+ "wanx",
27
+ "minimax",
28
+ "seedance"
29
+ ]).default("threerouter").description("激活的生成服务商"),
30
+ threerouter: ProviderCredentialsSchema.default({ apiKey: "" }).description("Threerouter 凭证(默认服务商,聚合器)"),
31
+ wanx: ProviderCredentialsSchema.default({ apiKey: "" }).description("万象(wanx)凭证"),
32
+ minimax: ProviderCredentialsSchema.default({ apiKey: "" }).description("MiniMax 官方平台凭证(仅视频)"),
33
+ seedance: ProviderCredentialsSchema.default({ apiKey: "" }).description("Seedance2.5 凭证"),
34
+ defaultImageProvider: z.union([
35
+ "",
36
+ "threerouter",
37
+ "wanx",
38
+ "minimax",
39
+ "seedance"
40
+ ]).default("").description("默认图片服务商,留空跟随激活服务商"),
41
+ defaultVideoProvider: z.union([
42
+ "",
43
+ "threerouter",
44
+ "wanx",
45
+ "minimax",
46
+ "seedance"
47
+ ]).default("").description("默认视频服务商,留空跟随激活服务商"),
48
+ defaultImageModel: z.string().default("").description("默认图片模型,留空使用服务商内置默认模型"),
49
+ defaultVideoModel: z.string().default("").description("默认视频模型,留空使用服务商内置默认模型"),
50
+ defaultImageSize: z.string().default("3:4").description("默认图片尺寸/比例,如 3:4(qwen/wan 系自动换算为宽*高,threerouter/方舟换算为宽x高)"),
51
+ defaultVideoDuration: z.number().default(5).min(1).max(30).description("默认视频时长(秒),上限 30,由上游模型校验具体能力"),
52
+ imageTransport: z.union([
53
+ "auto",
54
+ "async",
55
+ "sync"
56
+ ]).default("auto").description("图片提交传输:auto 优先异步(幂等+超时找回)并按需降级,async 强制异步,sync 强制同步单次提交"),
57
+ imageUnknownStatePolicy: z.union(["fail", "resubmit-same-key"]).default("fail").description("提交状态未知时的策略:fail 不重提(默认),resubmit-same-key 用同一幂等键重提一次(服务端按键去重)"),
58
+ timeoutMs: z.number().default(6e4).min(1e3).description("单次 HTTP 请求超时(毫秒)"),
59
+ pollIntervalMs: z.number().default(5e3).min(1e3).description("视频任务轮询间隔(毫秒)"),
60
+ pollTimeoutMs: z.number().default(6e5).min(1e4).description("视频任务整体超时(毫秒)"),
61
+ retryTimes: z.number().default(3).min(0).max(10).description("可重试错误的最大重试次数(仅读取类请求;生图提交永远不重试)"),
62
+ outputsDir: z.string().default("./outputs").description("生成媒体落地目录(插件唯一解析点,启动时打绝对路径日志)"),
63
+ watermark: z.object({
64
+ enabled: z.boolean().default(true).description("是否对最终图片合成品牌水印"),
65
+ text: z.string().default("Threerouter").description("水印文字"),
66
+ opacity: z.number().default(.68).min(0).max(1).description("主文字不透明度"),
67
+ position: z.union([
68
+ "bottom-right",
69
+ "bottom-left",
70
+ "top-right",
71
+ "top-left"
72
+ ]).default("bottom-right").description("水印位置"),
73
+ fontSizeRatio: z.number().default(.032).min(.005).max(.2).description("字号占图片宽度比例"),
74
+ marginXRatio: z.number().default(.028).min(0).max(.3).description("水平留白占比"),
75
+ marginYRatio: z.number().default(.012).min(0).max(.3).description("垂直留白占比"),
76
+ glowEnabled: z.boolean().default(true).description("是否加柔光"),
77
+ glowColor: z.string().default("#ffffff").description("柔光颜色"),
78
+ glowBlurRatio: z.number().default(.18).min(0).max(1).description("柔光半径占字号比例")
79
+ }).default({
80
+ enabled: true,
81
+ text: "Threerouter",
82
+ opacity: .68,
83
+ position: "bottom-right",
84
+ fontSizeRatio: .032,
85
+ marginXRatio: .028,
86
+ marginYRatio: .012,
87
+ glowEnabled: true,
88
+ glowColor: "#ffffff",
89
+ glowBlurRatio: .18
90
+ }).description("图片最终结果的品牌水印后处理(内存内合成,只落盘最终图)")
91
+ });
92
+ /**
93
+ * 读取指定服务商的凭证,不校验 key 非空。供候选服务商构建时的 key 过滤
94
+ * (resolveModelCandidates 用它跳过未配置 key 的候选),不抛错。
95
+ */
96
+ function peekProviderCredentials(config, provider) {
97
+ return provider === "threerouter" ? config.threerouter : provider === "wanx" ? config.wanx : provider === "minimax" ? config.minimax : config.seedance;
98
+ }
99
+ /**
100
+ * 解析指定服务商的凭证,校验非空。供按模型自动路由的生成工具使用:
101
+ * composer 选中某服务商分组下的模型时,工具以该服务商的凭证直连。
102
+ * @param config - 已校验的插件配置。
103
+ * @param provider - 目标服务商。
104
+ * @returns 服务商凭证与端点。
105
+ * @throws 当该服务商未配置 API Key 时(报错指明缺 key 的 provider 字段)。
106
+ */
107
+ function resolveProviderCredentials(config, provider) {
108
+ const creds = peekProviderCredentials(config, provider);
109
+ if (!creds.apiKey || creds.apiKey.trim().length === 0) throw new Error(`dsh-image-video: 服务商 ${provider} 未配置 API Key,请在配置中设置 ${provider}.apiKey`);
110
+ return {
111
+ provider,
112
+ apiKey: creds.apiKey,
113
+ baseURL: creds.baseURL?.trim() || defaultBaseURL(provider)
114
+ };
115
+ }
116
+ /** 服务商默认接口地址。 */
117
+ function defaultBaseURL(provider) {
118
+ switch (provider) {
119
+ case "threerouter": return "https://api.threerouter.com/v1";
120
+ case "wanx": return "https://dashscope.aliyuncs.com/api/v1";
121
+ case "minimax": return "https://api.minimaxi.com/v1";
122
+ case "seedance": return "https://ark.cn-beijing.volces.com/api/v3";
123
+ }
124
+ }
125
+ //#endregion
126
+ //#region src/media-route.ts
127
+ /**
128
+ * outputs/ 媒体 HTTP 路由:把本地 outputs 目录经 webServer 服务以只读方式
129
+ * 暴露给渲染进程,供桌面客户端 keyed toolview 内嵌 <video>/<img> 直接加载
130
+ * 生成结果,替代"给出本地路径、让用户自行用本地播放器打开"的体验。
131
+ *
132
+ * 上游 `webServer` 服务(@deepseek-ai/dsh-host-webserver)在本插件中按结构类型
133
+ * 引用:`ctx.inject(['webServer'], cb)` 无需类型 augment,`ctx.get('webServer')`
134
+ * 返回 any,本文件声明本地 `MediaWebServer` 最小结构接口做断言,
135
+ * 不新增对上游 host 包的依赖。
136
+ *
137
+ * 安全边界:
138
+ * - 仅当 webServer 绑定在 127.0.0.1(本机回环)时注册路由;0.0.0.0 时跳过,
139
+ * 避免生成的媒体文件暴露到局域网。
140
+ * - 文件名校验:URL 解码后必须是不含路径分隔符的单段文件名,且 resolve 后
141
+ * 仍落在 outputsDir 内,杜绝 ../ 目录遍历。
142
+ * - 扩展名白名单映射 Content-Type,白名单外一律 404。
143
+ *
144
+ * @module dsh-image-video/media-route
145
+ */
146
+ /** outputs 媒体路由前缀(prefix 匹配 /outputs 与 /outputs/<文件名>)。 */
147
+ const OUTPUTS_ROUTE_PATH = "/outputs";
148
+ /** 扩展名 → Content-Type 白名单;表外扩展名一律 404。 */
149
+ const CONTENT_TYPES = {
150
+ ".mp4": "video/mp4",
151
+ ".mov": "video/quicktime",
152
+ ".webm": "video/webm",
153
+ ".png": "image/png",
154
+ ".jpg": "image/jpeg",
155
+ ".jpeg": "image/jpeg",
156
+ ".webp": "image/webp",
157
+ ".gif": "image/gif"
158
+ };
159
+ /**
160
+ * 校验 URL 解码后的路径段是否为 outputs/ 内的合法单段文件名。
161
+ * 拒绝空段、相对段(. / ..)、任何形式的路径分隔符与 NUL 字节;再以 resolve
162
+ * 归一化双重确认结果仍位于 outputsDir 内(防御分隔符/大小写差异导致的绕过)。
163
+ * @param raw - decodeURIComponent 之后的原始段。
164
+ * @param outputsDir - 归一化后的 outputs 绝对目录。
165
+ * @returns 合法文件名;不合法返回 undefined。
166
+ */
167
+ function safeOutputFileName(raw, outputsDir) {
168
+ if (raw === "" || raw === "." || raw === ".." || raw.includes("\0")) return void 0;
169
+ if (raw.includes("/") || raw.includes("\\") || raw.includes(sep)) return void 0;
170
+ if (basename(raw) !== raw) return void 0;
171
+ const root = resolve(outputsDir);
172
+ const full = resolve(root, raw);
173
+ return full === root || full.startsWith(root + sep) ? raw : void 0;
174
+ }
175
+ /**
176
+ * 扩展名(含点、忽略大小写)→ Content-Type;白名单外返回 undefined。
177
+ * @param ext - 如 '.mp4'。
178
+ */
179
+ function contentTypeForExtension(ext) {
180
+ return CONTENT_TYPES[ext.toLowerCase()];
181
+ }
182
+ /**
183
+ * 解析 Range 请求头(bytes=start-end / bytes=start- / bytes=-suffix)。
184
+ * 只取第一段(视频播放器均发单段请求);末字节越界截断到文件尾。
185
+ * @param header - Range 头原文;undefined 或非 bytes 单位返回 undefined(按全量处理)。
186
+ * @param size - 资源总字节数。
187
+ * @returns 可满足的闭区间;头非法或区间不可满足时返回 undefined。
188
+ */
189
+ function parseRange(header, size) {
190
+ if (header === void 0 || !header.startsWith("bytes=")) return void 0;
191
+ const spec = (header.slice(6).split(",")[0] ?? "").trim();
192
+ const dash = spec.indexOf("-");
193
+ if (dash === -1) return void 0;
194
+ const startText = spec.slice(0, dash).trim();
195
+ const endText = spec.slice(dash + 1).trim();
196
+ if (startText === "" && endText === "") return void 0;
197
+ let start;
198
+ let end;
199
+ if (startText === "") {
200
+ const suffix = Number(endText);
201
+ if (!Number.isInteger(suffix) || suffix <= 0 || size === 0) return void 0;
202
+ start = Math.max(0, size - suffix);
203
+ end = size - 1;
204
+ } else {
205
+ start = Number(startText);
206
+ if (!Number.isInteger(start) || start < 0) return void 0;
207
+ end = endText === "" ? size - 1 : Number(endText);
208
+ if (!Number.isInteger(end)) return void 0;
209
+ if (end >= size) end = size - 1;
210
+ }
211
+ if (start > end || start >= size) return void 0;
212
+ return {
213
+ start,
214
+ end
215
+ };
216
+ }
217
+ /**
218
+ * 创建 outputs 路由 handler:GET/HEAD 流式返回媒体文件,GET 支持 Range 206
219
+ * 分段加载(视频进度条拖动依赖)。白名单外扩展名、越界路径、不存在的文件
220
+ * 一律 404;URL 解码失败 400;非 GET/HEAD 405。
221
+ * @param outputsDir - outputs 目录(相对路径按进程 cwd 归一化)。
222
+ */
223
+ function createOutputsRouteHandler(outputsDir) {
224
+ const root = resolve(outputsDir);
225
+ return async (req, res) => {
226
+ if (req.method !== "GET" && req.method !== "HEAD") {
227
+ res.writeHead(405, { Allow: "GET, HEAD" });
228
+ res.end();
229
+ return;
230
+ }
231
+ let raw;
232
+ try {
233
+ raw = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname.replace(/^\/outputs\/?/, ""));
234
+ } catch {
235
+ res.writeHead(400);
236
+ res.end();
237
+ return;
238
+ }
239
+ const name = safeOutputFileName(raw, root);
240
+ const contentType = name === void 0 ? void 0 : contentTypeForExtension(extname(name));
241
+ if (name === void 0 || contentType === void 0) {
242
+ res.writeHead(404);
243
+ res.end();
244
+ return;
245
+ }
246
+ let size;
247
+ try {
248
+ const info = await stat(resolve(root, name));
249
+ if (!info.isFile()) {
250
+ res.writeHead(404);
251
+ res.end();
252
+ return;
253
+ }
254
+ size = info.size;
255
+ } catch {
256
+ res.writeHead(404);
257
+ res.end();
258
+ return;
259
+ }
260
+ const range = req.method === "GET" ? parseRange(req.headers.range, size) : void 0;
261
+ if (req.method === "GET" && req.headers.range !== void 0 && range === void 0) {
262
+ res.writeHead(416, { "Content-Range": `bytes */${size}` });
263
+ res.end();
264
+ return;
265
+ }
266
+ const headers = {
267
+ "Content-Type": contentType,
268
+ "Content-Length": range === void 0 ? size : range.end - range.start + 1,
269
+ "Accept-Ranges": "bytes",
270
+ "Cache-Control": "no-store"
271
+ };
272
+ if (range !== void 0) headers["Content-Range"] = `bytes ${range.start}-${range.end}/${size}`;
273
+ res.writeHead(range === void 0 ? 200 : 206, headers);
274
+ if (req.method === "HEAD" || size === 0) {
275
+ res.end();
276
+ return;
277
+ }
278
+ const stream = createReadStream(resolve(root, name), range === void 0 ? void 0 : {
279
+ start: range.start,
280
+ end: range.end
281
+ });
282
+ res.on("close", () => {
283
+ stream.destroy();
284
+ });
285
+ stream.on("error", () => {
286
+ if (!res.headersSent) res.writeHead(500);
287
+ res.destroy();
288
+ });
289
+ stream.pipe(res);
290
+ };
291
+ }
292
+ /**
293
+ * 把 outputs 媒体路由注册进 webServer。仅回环地址注册:host 非 127.0.0.1 时
294
+ * 返回 undefined 且不注册,生成的媒体不暴露到非本机网络。
295
+ * @param webServer - webServer 服务实例。
296
+ * @param outputsDir - outputs 目录(相对路径按进程 cwd 归一化)。
297
+ * @returns 路由注销函数;未注册返回 undefined。
298
+ */
299
+ function registerOutputsRoute(webServer, outputsDir) {
300
+ if (webServer.host !== "127.0.0.1") return void 0;
301
+ return webServer.register({
302
+ kind: "prefix",
303
+ path: OUTPUTS_ROUTE_PATH,
304
+ handler: createOutputsRouteHandler(outputsDir)
305
+ });
306
+ }
307
+ //#endregion
308
+ //#region src/http-client.ts
309
+ /** 所有生成相关错误的基类,携带友好中文提示与分类标记。 */
310
+ var GenerationError = class extends Error {
311
+ kind;
312
+ /** 是否值得重试:仅超时与网络抖动重试,鉴权/配额/任务逻辑错误立即失败。 */
313
+ retryable;
314
+ /** 原始 HTTP 状态码,任务级错误可能为 undefined。 */
315
+ status;
316
+ /** 服务端 Retry-After 建议的等待时间(毫秒)。 */
317
+ retryAfterMs;
318
+ /**
319
+ * 服务端返回的机器可读错误码(形如 `{"error":{"code":"IDEMPOTENCY_IN_PROGRESS"}}`)。
320
+ * 生图事务靠它区分「提交未落地(可回退候选)」与「提交状态未知(绝不重提)」,
321
+ * 因此必须比 message 文本更可靠地保留下来。
322
+ */
323
+ code;
324
+ constructor(kind, message, retryable, status, retryAfterMs, code) {
325
+ super(message);
326
+ this.name = "GenerationError";
327
+ this.kind = kind;
328
+ this.retryable = retryable;
329
+ this.status = status;
330
+ if (retryAfterMs !== void 0) this.retryAfterMs = retryAfterMs;
331
+ if (code !== void 0 && code !== "") this.code = code;
332
+ }
333
+ };
334
+ /**
335
+ * 判定错误是否为「异步图片端点在本环境不可用」:功能未开启(未配对象存储)或
336
+ * 该分组平台不支持 Images API。两者都在创建任务前返回 404,因此降级到同步
337
+ * 单次提交不会产生重复生成。
338
+ */
339
+ function isAsyncImageUnavailableError(err) {
340
+ if (!(err instanceof GenerationError) || err.status !== 404) return false;
341
+ return /async image tasks are not enabled|not supported for this platform/i.test(err.message);
342
+ }
343
+ /** 判定错误是否为「同一幂等键首次提交仍在进行中」:任务已存在,应凭 request_id 找回而非重提。 */
344
+ function isIdempotencyInProgressError(err) {
345
+ return err instanceof GenerationError && err.code === "IDEMPOTENCY_IN_PROGRESS";
346
+ }
347
+ /** 判定错误是否为「同一幂等键被用于不同请求体」:客户端事务 ID 复用,必须响亮失败。 */
348
+ function isIdempotencyConflictError(err) {
349
+ return err instanceof GenerationError && err.code === "IDEMPOTENCY_KEY_CONFLICT";
350
+ }
351
+ /**
352
+ * 判定错误是否为「提交状态未知」——客户端无法确认请求是否已在服务端创建任务。
353
+ * 超时、连接中断、502/503 都属于此类:**绝不允许自动重提或换模型**,只能凭
354
+ * request_id 反查(见 image-transaction.ts)。
355
+ */
356
+ function isUnknownSubmitStateError(err) {
357
+ if (isCancelledError(err)) return false;
358
+ if (!(err instanceof GenerationError)) return true;
359
+ if (err.status !== void 0 && err.status >= 500) return true;
360
+ return err.kind === "timeout" || err.kind === "network";
361
+ }
362
+ /**
363
+ * 判定错误是否为「调用方主动取消」。取消同样可能发生在请求已抵达服务端之后,
364
+ * 因此不视为「未提交」——只是不再做任何自动动作(找回/重提都不做)。
365
+ */
366
+ function isCancelledError(err) {
367
+ return err instanceof GenerationError && /已被取消/.test(err.message);
368
+ }
369
+ /**
370
+ * 判定错误是否由服务端**在创建任务之前**返回——按 threerouter 的 HTTP 契约
371
+ * (docs/ASYNC_IMAGE_TASKS.md「HTTP status code contract」),400/401/403/404/413/429
372
+ * 都在落库之前拦截,因此这类错误可以安全地回退到下一候选服务商或降级传输;
373
+ * 409 例外(IN_PROGRESS 时任务已存在),5xx/超时同样例外。
374
+ */
375
+ function isDefinitelyNoTaskError(err) {
376
+ if (!(err instanceof GenerationError)) return false;
377
+ if (err.status === void 0) return false;
378
+ return err.status >= 400 && err.status < 500 && err.status !== 409;
379
+ }
380
+ /**
381
+ * 判定错误是否为「模型不被该服务商接受」类,供工具层在候选服务商间回退。
382
+ * 实测形态(2026-09):
383
+ * - threerouter:目录中无可用渠道的模型 → HTTP 503 capacity_error
384
+ * "No available media generation channels"(网关按模型找渠道,未知模型即无渠道);
385
+ * - threerouter:分组未开通生图 → HTTP 403 permission_error
386
+ * "Image generation is not enabled for this group"(文档语义:401=Key 无效,
387
+ * 403=无该分组/模型权限或未开通生图 allow_image_generation——属于「该候选
388
+ * 无法服务此请求」,应换下一候选,而非响亮终止);
389
+ * - 部分服务商:HTTP 400/404 + 模型不存在类消息("model not found" / "模型不存在");
390
+ * - 能力缺失:如「不支持图片生成」。
391
+ * 注意区分语义相近的参数级 400——如 "model X does not support duration 1s"
392
+ * (时长档位问题,模型本身可用),该类消息不命中本判定,不触发换家。
393
+ * 其余 5xx(无 capacity_error 语义)、Key 无效(401)、配额(429)、超时一律不成立:
394
+ * 响亮失败,避免用别家的 key 静默掩盖本服务商的配置问题。
395
+ */
396
+ function isModelNotAcceptedError(err) {
397
+ if (!(err instanceof GenerationError)) return false;
398
+ const message = err.message ?? "";
399
+ const modelMissing = /not\s*found|not\s*exist|does\s*not\s*exist|unknown\s*model|invalid\s*model|不存在|未找到|未开通|无效的?模型/i.test(message);
400
+ const capabilityMissing = message.includes("不支持");
401
+ if (err.kind === "task") return modelMissing || capabilityMissing;
402
+ if (err.kind === "network") return /no available media generation channels|capacity_error/i.test(message);
403
+ if (err.kind === "auth") return /image generation is not enabled|not enabled for this group|allow_image_generation|未开通生图|无该分组|无该模型权限/i.test(message);
404
+ return false;
405
+ }
406
+ /** 重试退避基数(毫秒),指数退避:base * 2^attempt。 */
407
+ const RETRY_BACKOFF_MS = 1e3;
408
+ /**
409
+ * 执行单次 HTTP 请求,带超时控制。不处理重试。
410
+ * @throws {GenerationError} 超时或网络错误(可重试)。
411
+ */
412
+ async function singleRequest(opts) {
413
+ const controller = new AbortController();
414
+ const timeoutId = setTimeout(() => controller.abort(), opts.timeoutMs);
415
+ const onExternalAbort = () => controller.abort();
416
+ opts.signal?.addEventListener("abort", onExternalAbort);
417
+ try {
418
+ const init = {
419
+ method: opts.method,
420
+ headers: opts.headers,
421
+ signal: controller.signal
422
+ };
423
+ if (opts.method === "POST" && opts.body !== void 0) init.body = JSON.stringify(opts.body);
424
+ const res = await fetch(opts.url, init);
425
+ const data = await parseBody(res);
426
+ return {
427
+ ok: true,
428
+ status: res.status,
429
+ data,
430
+ headers: res.headers
431
+ };
432
+ } catch (err) {
433
+ if (controller.signal.aborted && !opts.signal?.aborted) throw new GenerationError("timeout", `请求超时(${opts.timeoutMs}ms),URL: ${opts.url}`, true);
434
+ if (opts.signal?.aborted) throw new GenerationError("timeout", "任务已被取消", false);
435
+ throw new GenerationError("network", `网络请求失败:${err instanceof Error ? err.message : String(err)}`, true);
436
+ } finally {
437
+ clearTimeout(timeoutId);
438
+ opts.signal?.removeEventListener("abort", onExternalAbort);
439
+ }
440
+ }
441
+ /** 解析响应体为 JSON,空体返回 null。 */
442
+ async function parseBody(res) {
443
+ const text = await res.text();
444
+ if (text.length === 0) return null;
445
+ try {
446
+ return JSON.parse(text);
447
+ } catch {
448
+ return text;
449
+ }
450
+ }
451
+ /**
452
+ * 分类 HTTP 响应错误,生成友好中文提示。
453
+ * 内部实现,不在 execute 外部直接调用。通过 `classifyErrorForTest` 导出用于单元测试。
454
+ */
455
+ /**
456
+ * 解析 Retry-After 响应头为毫秒(支持秒数与 HTTP 日期两种形态),上限 180s。
457
+ * 导出供适配器读取 202/409 上的服务端建议轮询间隔。
458
+ */
459
+ function parseRetryAfterMs(value) {
460
+ if (value === null || value === void 0) return void 0;
461
+ const seconds = Number(value.trim());
462
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1e3, 18e4);
463
+ const timestamp = Date.parse(value);
464
+ if (!Number.isNaN(timestamp)) return Math.min(Math.max(0, timestamp - Date.now()), 18e4);
465
+ }
466
+ function classifyHttpError(status, data, url, retryAfterHeader) {
467
+ const errMsg = extractErrorMessage(data);
468
+ const code = extractErrorCode(data);
469
+ if (status === 401) return new GenerationError("auth", `鉴权失败(HTTP 401):API Key 无效。${errMsg}`, false, status, void 0, code);
470
+ if (status === 403) return new GenerationError("auth", `权限失败(HTTP 403):分组/模型未开通该能力,请联系服务方开通。${errMsg}`, false, status, void 0, code);
471
+ if (status === 429) {
472
+ const retryAfterMs = parseRetryAfterMs(retryAfterHeader);
473
+ return new GenerationError("quota", `配额耗尽(HTTP 429):请求频率或额度超限,请稍后重试或检查账户余额。${errMsg}`, true, status, retryAfterMs, code);
474
+ }
475
+ if (status === 409) {
476
+ const retryAfterMs = parseRetryAfterMs(retryAfterHeader);
477
+ return new GenerationError("task", `幂等冲突(HTTP 409):${errMsg || "同一幂等键的请求已存在"}`, false, status, retryAfterMs, code);
478
+ }
479
+ if (status >= 500) return new GenerationError("network", `服务端错误(HTTP ${status}),将重试。${errMsg}`, true, status, void 0, code);
480
+ return new GenerationError("task", `任务报错(HTTP ${status}):${errMsg || "服务端返回错误"},URL: ${url}`, false, status, void 0, code);
481
+ }
482
+ /**
483
+ * 从响应体提取机器可读错误码,兼容 `{"error":{"code":…}}`、`{"error":{"type":…}}`
484
+ * 与顶层 `code` / `type`。threerouter 网关的异步图片契约用的是
485
+ * `{"error":{"type":code,"code":code,"message":msg}}`。
486
+ */
487
+ function extractErrorCode(data) {
488
+ if (data === null || typeof data !== "object") return void 0;
489
+ const obj = data;
490
+ const pick = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
491
+ const error = obj.error;
492
+ if (error !== null && typeof error === "object") {
493
+ const errObj = error;
494
+ return pick(errObj.code) ?? pick(errObj.type);
495
+ }
496
+ return pick(obj.code) ?? pick(obj.type);
497
+ }
498
+ /** 从服务商响应体提取错误消息,兼容常见结构。 */
499
+ function extractErrorMessage(data) {
500
+ if (data === null || typeof data !== "object") return "";
501
+ const obj = data;
502
+ const message = obj.message ?? obj.error_message ?? obj.msg;
503
+ if (typeof message === "string") return message;
504
+ const error = obj.error;
505
+ if (typeof error === "string") return error;
506
+ if (error !== null && typeof error === "object") {
507
+ const errObj = error;
508
+ if (typeof errObj.message === "string") return errObj.message;
509
+ if (typeof errObj.code === "string") return `错误码: ${errObj.code}`;
510
+ }
511
+ if (typeof obj.code === "string") return `错误码: ${obj.code}`;
512
+ return "";
513
+ }
514
+ /**
515
+ * 统一 HTTP 请求入口:执行请求,分类异常,对可重试错误按指数退避重试。
516
+ * 鉴权失败与任务逻辑错误立即抛出;配额错误按 Retry-After 和 retryTimes 有限重试。
517
+ * @returns 解析后的响应数据。
518
+ * @throws {GenerationError} 分类后的生成错误。
519
+ */
520
+ async function request(opts) {
521
+ return (await requestFull(opts)).data;
522
+ }
523
+ /**
524
+ * 与 {@link request} 相同的重试/分类语义,但返回完整响应(含 status 与 headers)。
525
+ *
526
+ * 存在的唯一理由:生图事务必须读到 `X-Idempotency-Replayed`(本次是幂等回放、
527
+ * 没有真正新建任务)与 `Retry-After`(409 进行中的锁窗口)——这两条信息只在
528
+ * 响应头上,`request()` 会把它们丢掉。
529
+ * @throws {GenerationError} 分类后的生成错误。
530
+ */
531
+ async function requestFull(opts) {
532
+ let lastError;
533
+ for (let attempt = 0; attempt <= opts.retryTimes; attempt++) {
534
+ if (opts.signal?.aborted) throw new GenerationError("timeout", "任务已被取消", false);
535
+ try {
536
+ const result = await singleRequest(opts);
537
+ if (result.status >= 200 && result.status < 300) return result;
538
+ throw classifyHttpError(result.status, result.data, opts.url, result.headers.get("retry-after"));
539
+ } catch (err) {
540
+ if (err instanceof GenerationError) {
541
+ if (!err.retryable) throw err;
542
+ lastError = err;
543
+ if (attempt < opts.retryTimes) {
544
+ await sleep(err.retryAfterMs ?? RETRY_BACKOFF_MS * Math.pow(2, attempt), opts.signal);
545
+ continue;
546
+ }
547
+ } else lastError = new GenerationError("network", `未知错误:${err instanceof Error ? err.message : String(err)}`, true);
548
+ }
549
+ }
550
+ throw lastError ?? new GenerationError("network", "请求失败且未捕获具体错误", true);
551
+ }
552
+ /**
553
+ * 下载二进制媒体到 Uint8Array。重试逻辑同 request。
554
+ * @param url - 媒体下载地址。
555
+ * @param opts - 超时、重试、取消信号。
556
+ * @returns 媒体字节与 Content-Type。
557
+ * @throws {GenerationError} 下载失败。
558
+ */
559
+ async function downloadMedia(url, opts) {
560
+ let lastError;
561
+ for (let attempt = 0; attempt <= opts.retryTimes; attempt++) {
562
+ if (opts.signal?.aborted) throw new GenerationError("timeout", "下载任务已被取消", false);
563
+ const controller = new AbortController();
564
+ const timeoutId = setTimeout(() => controller.abort(), opts.timeoutMs);
565
+ const onExternalAbort = () => controller.abort();
566
+ opts.signal?.addEventListener("abort", onExternalAbort);
567
+ try {
568
+ const res = await fetch(url, { signal: controller.signal });
569
+ if (!res.ok) {
570
+ const data = await parseBody(res);
571
+ throw classifyHttpError(res.status, data, url, res.headers.get("retry-after"));
572
+ }
573
+ const buffer = await res.arrayBuffer();
574
+ return {
575
+ data: new Uint8Array(buffer),
576
+ contentType: res.headers.get("content-type") ?? "application/octet-stream"
577
+ };
578
+ } catch (err) {
579
+ if (err instanceof GenerationError) {
580
+ if (!err.retryable) throw err;
581
+ lastError = err;
582
+ } else if (controller.signal.aborted && !opts.signal?.aborted) lastError = new GenerationError("timeout", `下载超时(${opts.timeoutMs}ms)`, true);
583
+ else lastError = new GenerationError("network", `下载失败:${err instanceof Error ? err.message : String(err)}`, true);
584
+ if (attempt < opts.retryTimes) await sleep(RETRY_BACKOFF_MS * Math.pow(2, attempt), opts.signal);
585
+ } finally {
586
+ clearTimeout(timeoutId);
587
+ opts.signal?.removeEventListener("abort", onExternalAbort);
588
+ }
589
+ }
590
+ throw lastError ?? new GenerationError("network", "下载失败", true);
591
+ }
592
+ /** 可被取消的延时。 */
593
+ function sleep(ms, signal) {
594
+ return new Promise((resolve, reject) => {
595
+ if (signal?.aborted) {
596
+ reject(new GenerationError("timeout", "任务已被取消", false));
597
+ return;
598
+ }
599
+ const timer = setTimeout(resolve, ms);
600
+ signal?.addEventListener("abort", () => {
601
+ clearTimeout(timer);
602
+ reject(new GenerationError("timeout", "任务已被取消", false));
603
+ }, { once: true });
604
+ });
605
+ }
606
+ //#endregion
607
+ //#region src/media.ts
608
+ /**
609
+ * 媒体渲染与输出模块:下载生成结果到 outputs/ 目录。
610
+ * 图片字节经 attachment 服务持久化,附件引用走 presentationMeta(UI-only 通道),
611
+ * 模型只接收文本摘要;视频返回本地文件链接。
612
+ * @module dsh-image-video/media
613
+ */
614
+ /**
615
+ * 把配置里的 outputsDir 解析为**绝对路径**。插件只在 apply() 里调用一次,
616
+ * 之后所有落盘(生成、下载、水印后处理)与只读 media 路由都使用同一个解析结果,
617
+ * 从根本上杜绝「模型结果在 A 目录、后处理结果在 B 目录」这类旁路产物。
618
+ * @param outputsDir - 配置值(可为相对路径)。
619
+ * @returns 绝对目录路径。
620
+ */
621
+ function resolveOutputsDir(outputsDir) {
622
+ return resolve(outputsDir);
623
+ }
624
+ /**
625
+ * 把最终媒体字节写入 outputsDir(唯一落盘入口)。
626
+ *
627
+ * 「唯一入口」是刻意的设计约束:图片生成的后处理(水印)在内存里完成后再调用
628
+ * 本函数,因此磁盘上只可能出现一份文件、且内容就是最终结果——交互流展示的
629
+ * 附件与本地文件必然一致。
630
+ * @param data - 最终字节。
631
+ * @param contentType - 最终 MIME 类型。
632
+ * @param outputsDir - 输出目录(绝对路径,见 resolveOutputsDir)。
633
+ * @param fallbackExt - Content-Type 无法识别时的扩展名。
634
+ * @param sourceUrl - 上游来源地址(base64 形态留空)。
635
+ * @returns 保存结果。
636
+ */
637
+ async function writeOutputFile(data, contentType, outputsDir, fallbackExt, sourceUrl = "") {
638
+ const dir = resolve(outputsDir);
639
+ await mkdir(dir, { recursive: true });
640
+ const ext = extFromContentType(contentType, fallbackExt);
641
+ const filename = `${Date.now()}-${randomBytes(4).toString("hex")}${ext}`;
642
+ const localPath = resolve(dir, filename);
643
+ await writeFile(localPath, data);
644
+ return {
645
+ localPath,
646
+ sourceUrl,
647
+ contentType,
648
+ bytes: data.byteLength,
649
+ data
650
+ };
651
+ }
652
+ /** 下载媒体字节到内存(不落盘)。后处理链路的第一步。 */
653
+ async function fetchMediaBytes(url, opts) {
654
+ return await downloadMedia(url, opts);
655
+ }
656
+ /** 解码服务商直接返回的 base64 图片为内存字节(不落盘)。 */
657
+ function decodeBase64Media(base64, mediaType) {
658
+ return {
659
+ data: new Uint8Array(Buffer.from(base64, "base64")),
660
+ contentType: mediaType
661
+ };
662
+ }
663
+ /**
664
+ * 下载媒体并保存到 outputs/ 目录。
665
+ * @param url - 服务商返回的媒体下载 URL。
666
+ * @param outputsDir - 配置的输出目录。
667
+ * @param ext - 文件扩展名(如 .png、.mp4)。
668
+ * @param opts - HTTP 下载选项。
669
+ * @returns 保存结果。
670
+ */
671
+ async function downloadAndSave(url, outputsDir, fallbackExt, opts) {
672
+ const { data, contentType } = await downloadMedia(url, opts);
673
+ return await writeOutputFile(data, contentType, outputsDir, fallbackExt, url);
674
+ }
675
+ /** 从扩展名推断图片 MIME 类型;未知扩展名回退 image/png,由服务商对无法识别的字节响亮报错。 */
676
+ function imageMimeFromPath(path) {
677
+ switch (extname(path).toLowerCase()) {
678
+ case ".png": return "image/png";
679
+ case ".jpg":
680
+ case ".jpeg": return "image/jpeg";
681
+ case ".webp": return "image/webp";
682
+ case ".gif": return "image/gif";
683
+ case ".bmp": return "image/bmp";
684
+ default: return "image/png";
685
+ }
686
+ }
687
+ /**
688
+ * 解析 generate_video 的 image 入参为服务商可直接消费的图片引用。
689
+ * http(s) URL 与 data URL 原样返回;其余按本地文件路径读取并编码为 data URL。
690
+ * @param input - http(s) URL、data URL 或本地文件路径。
691
+ * @returns 服务商可直接消费的图片引用。
692
+ * @throws 本地路径不存在或不可读时原样抛出 Node fs 错误。
693
+ */
694
+ async function resolveImageReference(input) {
695
+ const ref = input.trim();
696
+ if (/^(https?|data):/.test(ref)) return ref;
697
+ const data = await readFile(ref);
698
+ return `data:${imageMimeFromPath(ref)};base64,${data.toString("base64")}`;
699
+ }
700
+ const execFileAsync = promisify(execFile);
701
+ /** 首帧压缩阈值:data URL 解码后字节数超过该值才压缩(小图直接提交)。 */
702
+ const FIRST_FRAME_COMPRESS_THRESHOLD = 15e5;
703
+ /** 压缩后长边上限:视频输出最高 768P~2K,长边 1600 绰绰有余。 */
704
+ const FIRST_FRAME_LONG_EDGE = 1600;
705
+ /**
706
+ * 视频首帧压缩:超大的本地图片 data URL 在提交前经 ffmpeg 缩放转 JPEG。
707
+ * 实测(2026-09-14):~3MB 原图转 data URL 提交会被网关长时间消化直至提交超时,
708
+ * 压到 ~150KB 后秒收;视频输出本就 ≤768P/1080P,压图不损观感。
709
+ * 仅处理 data:image/ 形态且解码后超过阈值的引用;ffmpeg 不可用或执行失败时
710
+ * 原样返回(不阻塞提交,只是慢),http(s) URL 不处理。
711
+ */
712
+ async function compressVideoFirstFrame(ref) {
713
+ if (!ref.startsWith("data:image/")) return ref;
714
+ const commaIdx = ref.indexOf(",");
715
+ if (commaIdx < 0) return ref;
716
+ const header = ref.slice(0, commaIdx);
717
+ const b64 = ref.slice(commaIdx + 1);
718
+ if (Math.floor(b64.length * 3 / 4) <= FIRST_FRAME_COMPRESS_THRESHOLD) return ref;
719
+ const inExt = header.includes("jpeg") || header.includes("jpg") ? ".jpg" : header.includes("webp") ? ".webp" : ".png";
720
+ const dir = tmpdir();
721
+ const inPath = join(dir, `dsh-frame-${randomBytes(6).toString("hex")}${inExt}`);
722
+ const outPath = join(dir, `dsh-frame-${randomBytes(6).toString("hex")}.jpg`);
723
+ try {
724
+ await writeFile(inPath, Buffer.from(b64, "base64"));
725
+ await execFileAsync("ffmpeg", [
726
+ "-y",
727
+ "-v",
728
+ "error",
729
+ "-i",
730
+ inPath,
731
+ "-vf",
732
+ `scale='min(${FIRST_FRAME_LONG_EDGE},iw)':-2`,
733
+ "-q:v",
734
+ "3",
735
+ outPath
736
+ ]);
737
+ const out = await readFile(outPath);
738
+ if (out.byteLength === 0) return ref;
739
+ return `data:image/jpeg;base64,${out.toString("base64")}`;
740
+ } catch {
741
+ return ref;
742
+ } finally {
743
+ await unlink(inPath).catch(() => {});
744
+ await unlink(outPath).catch(() => {});
745
+ }
746
+ }
747
+ /**
748
+ * wan3.0-video 多关键帧媒体解析:把 `{image, position}` 原始参数数组解析为
749
+ * 适配器可直接消费的 `{url: dataURL, position}` 形态,每个图先压缩再编码。
750
+ * 已有 data URL 的条目跳过压缩。
751
+ * @param ms - 原始媒体数组,image 为本地路径/URL/data URL,position 可选。
752
+ * @returns 适配器可直接消费的媒体数组。
753
+ */
754
+ async function resolveVideoMedia(ms) {
755
+ return await Promise.all(ms.map(async (m) => {
756
+ const url = await resolveImageReference(m.image);
757
+ const position = m.position?.toLowerCase();
758
+ const type = position === "first_frame" || position === "0s" ? "first_frame" : position === "last_frame" ? "last_frame" : "reference_image";
759
+ return {
760
+ url: await compressVideoFirstFrame(url),
761
+ type
762
+ };
763
+ }));
764
+ }
765
+ /** 从 Content-Type 推断图片媒体类型(attachment 服务要求精确类型)。 */
766
+ function toImageMediaType(contentType) {
767
+ return toImageMediaTypeForTest(contentType);
768
+ }
769
+ /** 单元测试导出:实现与 toImageMediaType 相同,测试直接调用避免类型依赖循环。 */
770
+ function toImageMediaTypeForTest(contentType) {
771
+ const ct = contentType.toLowerCase().split(";")[0]?.trim() ?? "";
772
+ if (ct.includes("png")) return "image/png";
773
+ if (ct.includes("jpeg") || ct.includes("jpg")) return "image/jpeg";
774
+ if (ct.includes("webp")) return "image/webp";
775
+ if (ct.includes("gif")) return "image/gif";
776
+ return "image/png";
777
+ }
778
+ /** 从 Content-Type 推断文件扩展名。 */
779
+ function extFromContentType(contentType, fallback) {
780
+ const ct = contentType.toLowerCase().split(";")[0]?.trim() ?? "";
781
+ if (ct.includes("png")) return ".png";
782
+ if (ct.includes("jpeg") || ct.includes("jpg")) return ".jpg";
783
+ if (ct.includes("webp")) return ".webp";
784
+ if (ct.includes("gif")) return ".gif";
785
+ if (ct.includes("mp4")) return ".mp4";
786
+ if (ct.includes("quicktime") || ct.includes("mov")) return ".mov";
787
+ return fallback;
788
+ }
789
+ /**
790
+ * 保存图片字节到 attachment 服务,返回可被 presentationMeta 引用的附件引用。
791
+ * `attachments` 由调用方保证非 undefined(generate_image 工具经 `ctx.inject` 注入),
792
+ * 此处不做运行时回退——依赖关系在调用栈上游即已明确。
793
+ *
794
+ * 图片不再作为模型可见的 image ContentBlock 注入工具结果:纯文本模型无法接收
795
+ * image 块(llm 适配器会序列化成 image_url,网关不支持时 400)。附件引用改为
796
+ * 经 `output.presentationMeta` 走 UI-only 通道持久化,对模型不可见。
797
+ * @param attachments - attachment 服务实例。
798
+ * @param data - 图片字节。
799
+ * @param contentType - 图片 Content-Type。
800
+ * @param name - 显示名称。
801
+ * @returns 持久化后的图片附件引用。
802
+ */
803
+ async function saveImageAttachment(attachments, data, contentType, name) {
804
+ const mediaType = toImageMediaType(contentType);
805
+ return attachments.saveImage({
806
+ data,
807
+ mediaType,
808
+ name
809
+ });
810
+ }
811
+ /**
812
+ * 构造模型可见的图片生成摘要文本(纯文本,不含图片字节)。
813
+ * 标准输出固定包含:提示词、服务商、**实际使用的模型**、尺寸、提交次数、
814
+ * 传输方式、后处理与透明告知——「这张图怎么来的、用了谁、提交了几次、
815
+ * 有没有打水印」直接看结果即可,无需查配置或服务商后台。
816
+ * @param fields - 生成输出中的展示字段。
817
+ * @returns 供工具结果 render 返回的文本块。
818
+ */
819
+ function createImageSummaryText(fields) {
820
+ const size = fields.width !== void 0 && fields.height !== void 0 ? `${fields.width}×${fields.height}` : "未知尺寸";
821
+ const model = fields.model && fields.model.length > 0 ? fields.model : "服务商内置默认";
822
+ const mode = fields.mode ? `,模式:${fields.mode === "image-to-image" ? "图生图" : "文生图"}` : "";
823
+ const submit = fields.submitAttempts === void 0 ? "" : `,提交次数:${fields.submitAttempts}`;
824
+ const transport = fields.transport === void 0 ? "" : `,传输:${fields.transport}`;
825
+ const postprocess = fields.postprocess && fields.postprocess.length > 0 ? `,后处理:${fields.postprocess.join(" + ")}` : "";
826
+ const promptBlock = fields.prompt && fields.prompt.length > 0 ? `\n提示词:${fields.prompt}` : "";
827
+ const requestBlock = fields.requestId === void 0 ? "" : `\nrequest_id:${fields.requestId}`;
828
+ const notesBlock = fields.notes && fields.notes.length > 0 ? `\n透明告知:\n${fields.notes.map((note) => `- ${note}`).join("\n")}` : "";
829
+ return [{
830
+ type: "text",
831
+ text: `图片已生成并保存到本地:${fields.localPath}(服务商:${fields.provider},模型:${model}${mode},尺寸:${size},大小:${(fields.bytes / 1024).toFixed(1)} KB${submit}${transport}${postprocess})${promptBlock}${requestBlock}${notesBlock}`
832
+ }];
833
+ }
834
+ /**
835
+ * 创建视频渲染块。DSH 无原生视频内容块,
836
+ * 返回文本块含本地文件路径,供用户点击打开;同时把**提示词、服务商、实际模型、
837
+ * 透明告知**写进可见文本,与客户端 keyed toolview 的内嵌播放器(消费
838
+ * presentationMeta.localPath/prompt)共同构成固定标准输出:
839
+ * 「提示词 + 播放器 + 结果块」,任何人安装插件即可见,无需任何配置。
840
+ * @param localPath - 落地文件路径。
841
+ * @param bytes - 文件字节数。
842
+ * @param sourceUrl - 上游源地址。
843
+ * @param details - 展示字段(提示词/服务商/模型/模式/时长/分辨率/透明告知)。
844
+ */
845
+ function createVideoContent(localPath, bytes, sourceUrl, details = {}) {
846
+ const sizeKb = (bytes / 1024).toFixed(1);
847
+ const model = details.model && details.model.length > 0 ? details.model : "服务商内置默认";
848
+ const lines = [
849
+ "视频已生成并保存到本地:",
850
+ `- 文件路径:${localPath}`,
851
+ `- 文件大小:${sizeKb} KB`,
852
+ ...details.prompt && details.prompt.length > 0 ? [`- 提示词:${details.prompt}`] : [],
853
+ ...details.provider ? [`- 服务商:${details.provider}`] : [],
854
+ `- 模型:${model}`,
855
+ ...details.mode ? [`- 生成模式:${details.mode}`] : [],
856
+ ...details.duration !== void 0 ? [`- 时长参数:${details.duration} 秒`] : [],
857
+ ...details.resolution ? [`- 分辨率:${details.resolution}`] : [],
858
+ `- 源地址:${sourceUrl}`
859
+ ];
860
+ const notesBlock = details.notes && details.notes.length > 0 ? `\n\n透明告知:\n${details.notes.map((note) => `- ${note}`).join("\n")}` : "";
861
+ return [{
862
+ type: "text",
863
+ text: `${lines.join("\n")}${notesBlock}\n\n请用本地播放器打开上述文件路径查看视频。`
864
+ }];
865
+ }
866
+ //#endregion
867
+ //#region src/runtime-defaults.ts
868
+ /** 创建内存态运行时默认值存储。 */
869
+ function createRuntimeDefaultsStore() {
870
+ let current = {};
871
+ return {
872
+ get: () => ({ ...current }),
873
+ patch(patch) {
874
+ const next = { ...current };
875
+ for (const [key, value] of Object.entries(patch)) if (value === null) delete next[key];
876
+ else next[key] = value;
877
+ current = next;
878
+ return { ...current };
879
+ },
880
+ reset() {
881
+ current = {};
882
+ }
883
+ };
884
+ }
885
+ /** 图片风格选项:id 即协议值,label 供下拉 UI 展示;'' = 自动(不拼接后缀)。 */
886
+ const IMAGE_STYLE_OPTIONS = [
887
+ {
888
+ id: "",
889
+ label: "自动"
890
+ },
891
+ {
892
+ id: "photo",
893
+ label: "摄影"
894
+ },
895
+ {
896
+ id: "illustration",
897
+ label: "插画"
898
+ },
899
+ {
900
+ id: "3d",
901
+ label: "3D 渲染"
902
+ },
903
+ {
904
+ id: "anime",
905
+ label: "动漫"
906
+ },
907
+ {
908
+ id: "ink",
909
+ label: "水墨"
910
+ }
911
+ ];
912
+ /** 风格 id → 英文提示词后缀(对所有服务商通用:直接拼接进 prompt,不改 API 参数)。 */
913
+ const STYLE_SUFFIXES = {
914
+ photo: "photorealistic style, professional photography, high detail",
915
+ illustration: "flat illustration style, clean vector art",
916
+ "3d": "3D render style, octane render, soft studio lighting",
917
+ anime: "anime style, cel shading, vibrant colors",
918
+ ink: "Chinese ink painting style, expressive brush strokes"
919
+ };
920
+ /**
921
+ * 把风格 id 拼接为英文提示词后缀(生成服务端通用做法:对所有 provider 生效)。
922
+ * @param prompt - 原始提示词。
923
+ * @param style - 风格 id;undefined / '' / 白名单外一律原样返回。
924
+ * @returns 实际发给服务商的提示词。
925
+ */
926
+ function applyImageStyle(prompt, style) {
927
+ if (style === void 0 || style === "") return prompt;
928
+ const suffix = STYLE_SUFFIXES[style];
929
+ if (suffix === void 0) return prompt;
930
+ const trimmed = prompt.trim();
931
+ if (/[,,,、]$/.test(trimmed)) return `${trimmed} ${suffix}`;
932
+ return `${trimmed}, ${suffix}`;
933
+ }
934
+ /** 图片尺寸白名单('宽*高');'' 由协议层表示「自动(清除覆盖)」,不在表内。 */
935
+ const IMAGE_SIZE_OPTIONS = [
936
+ {
937
+ id: "1:1",
938
+ label: "1:1",
939
+ size: "1024*1024"
940
+ },
941
+ {
942
+ id: "4:3",
943
+ label: "4:3",
944
+ size: "1152*864"
945
+ },
946
+ {
947
+ id: "3:4",
948
+ label: "3:4",
949
+ size: "1152*1536"
950
+ },
951
+ {
952
+ id: "16:9",
953
+ label: "16:9",
954
+ size: "1280*720"
955
+ },
956
+ {
957
+ id: "9:16",
958
+ label: "9:16",
959
+ size: "720*1280"
960
+ }
961
+ ];
962
+ /** 视频宽高比白名单。 */
963
+ const VIDEO_ASPECT_RATIOS = [
964
+ "16:9",
965
+ "9:16",
966
+ "1:1"
967
+ ];
968
+ /** 视频时长上下限(与 generate_video 工具的强制规范一致)。 */
969
+ const MIN_VIDEO_DURATION = 1;
970
+ const MAX_VIDEO_DURATION$1 = 30;
971
+ /** 合法服务商清单(与 config.ts 的 Provider 联合一一对应),供协议层白名单校验。 */
972
+ const PROVIDERS = [
973
+ "threerouter",
974
+ "wanx",
975
+ "minimax",
976
+ "seedance"
977
+ ];
978
+ /** 值是否为合法服务商('' / 未知值均拒绝)。 */
979
+ function isProvider(value) {
980
+ return PROVIDERS.includes(value);
981
+ }
982
+ /**
983
+ * 模型家族规则:按厂商关键词对显式 model 参数做小写包含匹配,得到该模型的家族
984
+ * 候选服务商。维护点按「厂商」而非「模型 id」——新模型(wan3.0、qwen-video、
985
+ * minimax 新版本等)自动命中家族规则,无需逐个登记。
986
+ *
987
+ * 家族事实(2026-09):threerouter 是聚合器,出所有家族的模型(wan/minimax/seedance
988
+ * 及文本/图片模型);wanx(阿里百炼)、minimax(官方平台,仅视频)、seedance(火山方舟)
989
+ * 是家族直连商。规则数组顺序即匹配优先级(更具体的家族在前)。
990
+ */
991
+ const MODEL_FAMILY_RULES = [
992
+ {
993
+ family: "minimax",
994
+ keywords: ["minimax", "hailuo"],
995
+ providers: ["minimax", "threerouter"],
996
+ kinds: ["video", "image"]
997
+ },
998
+ {
999
+ family: "seedance",
1000
+ keywords: [
1001
+ "doubao",
1002
+ "seedance",
1003
+ "seedream"
1004
+ ],
1005
+ providers: ["seedance", "threerouter"],
1006
+ kinds: ["image", "video"]
1007
+ },
1008
+ {
1009
+ family: "wan",
1010
+ keywords: ["wan"],
1011
+ providers: ["wanx", "threerouter"],
1012
+ kinds: ["image", "video"]
1013
+ }
1014
+ ];
1015
+ /**
1016
+ * 不支持自定义时长的视频模型集合:上游按模型内置档位出片,请求携带 duration 会被
1017
+ * 原样拒绝("duration customization is not supported")。工具层对命中模型丢弃
1018
+ * duration,并在结果 notes 中透明注明。列表按上游实测维护(2026-09 实测
1019
+ * wan2.2-t2v-plus 与 wan2.7-t2v 均拒绝;wan3.0-video / wan2.2-i2v-plus 尚未验证,
1020
+ * 暂保持透传,验证后再入表)。
1021
+ */
1022
+ const VIDEO_DURATION_UNSUPPORTED = /* @__PURE__ */ new Set(["wan2.2-t2v-plus", "wan2.7-t2v"]);
1023
+ /**
1024
+ * 未显式指定 resolution 时的模型默认档位:Threerouter 的 MiniMax 系要求请求携带
1025
+ * resolution(缺失时上游 400),注入默认档位避免默认路径失败;wan 系上游按模型
1026
+ * 默认处理,无需注入。
1027
+ */
1028
+ const VIDEO_MODEL_DEFAULT_RESOLUTION = {
1029
+ "minimax-h3": "768P",
1030
+ "MiniMax-H3": "768P"
1031
+ };
1032
+ /**
1033
+ * 支持多关键帧(media[])的视频模型列表,按优先序排列。
1034
+ * media 存在且未显式指定模型时自动选择首项;当前只有 wan3.0-video。
1035
+ * 后续新增模型(如 seedance 多帧支持)直接追加即可。
1036
+ */
1037
+ const MULTI_FRAME_CAPABLE_MODELS = [
1038
+ "MiniMax-H3",
1039
+ "minimax-h3",
1040
+ "wan3.0-video"
1041
+ ];
1042
+ /**
1043
+ * 构建候选服务商序列(工具层按序尝试提交,回退语义见工具实现):
1044
+ * ① 配置链服务商(会话选定 > settings 默认 > 激活服务商)——配置优先原则;
1045
+ * ② 显式 model 参数命中 {@link MODEL_FAMILY_RULES} 时的家族候选(直连商在前);
1046
+ * ③ threerouter 聚合器兜底——其目录覆盖所有家族的模型,永远作为最后候选。
1047
+ * 未配置 key 的候选一律跳过(不会在提交阶段撞「未配置 API Key」)。
1048
+ * @param kind - 生成类型(minimax 家族仅参与视频候选)。
1049
+ * @param explicitModel - 工具显式 model 参数;undefined / 空串表示未指定(无家族匹配)。
1050
+ * @param configChain - 配置链解析出的服务商(可为 undefined)。
1051
+ * @param hasKey - 判断服务商是否已配置 key(工具层以 peekProviderCredentials 实现)。
1052
+ * @returns 去重后的候选服务商有序列表;可能为空 = 没有任何已配置凭证(工具层响亮报错)。
1053
+ */
1054
+ function resolveModelCandidates(kind, explicitModel, configChain, hasKey) {
1055
+ if (kind === "video") return hasKey("threerouter") ? ["threerouter"] : [];
1056
+ const candidates = [];
1057
+ const push = (provider) => {
1058
+ if (!candidates.includes(provider) && hasKey(provider)) candidates.push(provider);
1059
+ };
1060
+ if (configChain) push(configChain);
1061
+ if (explicitModel) {
1062
+ const lower = explicitModel.toLowerCase();
1063
+ for (const rule of MODEL_FAMILY_RULES) {
1064
+ if (!rule.kinds.includes(kind)) continue;
1065
+ if (rule.keywords.some((keyword) => lower.includes(keyword))) {
1066
+ for (const provider of rule.providers) push(provider);
1067
+ break;
1068
+ }
1069
+ }
1070
+ }
1071
+ push("threerouter");
1072
+ return candidates;
1073
+ }
1074
+ /** defaults 路由路径(exact 匹配;桌面渲染进程同源调用)。 */
1075
+ const DEFAULTS_ROUTE_PATH = "/image-video/defaults";
1076
+ /**
1077
+ * 从插件持久 config 提取 settings 默认值,作为 defaults 路由合并视图的回落层。
1078
+ * 白名单/范围守卫:服务商须命中 PROVIDERS 白名单(空串 = 跟随激活服务商,不
1079
+ * 提取)、imageSize 须命中 IMAGE_SIZE_OPTIONS 白名单、videoDuration 须 1-10 整数;
1080
+ * settings 手填的遗留越界值一律忽略(composer 显示「自动」,工具用内置默认),
1081
+ * 避免把非法持久值经合并视图当作生效值回显。
1082
+ * @param config - 已由 Schemastery 填充默认值的插件配置。
1083
+ */
1084
+ function extractPersistedDefaults(config) {
1085
+ const persisted = {};
1086
+ if (isProvider(config.defaultImageProvider)) persisted.imageProvider = config.defaultImageProvider;
1087
+ if (IMAGE_SIZE_OPTIONS.some((option) => option.size === config.defaultImageSize)) persisted.imageSize = config.defaultImageSize;
1088
+ if (isProvider(config.defaultVideoProvider)) persisted.videoProvider = config.defaultVideoProvider;
1089
+ if (Number.isInteger(config.defaultVideoDuration) && config.defaultVideoDuration >= MIN_VIDEO_DURATION && config.defaultVideoDuration <= MAX_VIDEO_DURATION$1) persisted.videoDuration = config.defaultVideoDuration;
1090
+ return persisted;
1091
+ }
1092
+ /**
1093
+ * 合并为响应视图:运行时覆盖优先,缺失回落 settings 持久默认,两者皆无 → null。
1094
+ * 风格与视频比例是纯运行时概念(settings 无对应持久字段),始终取覆盖层。
1095
+ */
1096
+ function toView(defaults, persisted) {
1097
+ return {
1098
+ imageProvider: defaults.imageProvider ?? persisted.imageProvider ?? null,
1099
+ imageSize: defaults.imageSize ?? persisted.imageSize ?? null,
1100
+ imageStyle: defaults.imageStyle ?? null,
1101
+ videoProvider: defaults.videoProvider ?? persisted.videoProvider ?? null,
1102
+ videoAspectRatio: defaults.videoAspectRatio ?? null,
1103
+ videoDuration: defaults.videoDuration ?? persisted.videoDuration ?? null
1104
+ };
1105
+ }
1106
+ /**
1107
+ * 校验并归一化 POST body 为存储 patch。严格协议:仅接受六个已知键;
1108
+ * null 清除覆盖;'' 表示「自动」(归一化为 null);其余值按字段白名单/范围校验。
1109
+ * @param body - 已 JSON.parse 的请求体(可能是任意值)。
1110
+ * @returns 归一化后的 patch;校验失败返回错误信息(字符串)。
1111
+ */
1112
+ function parseDefaultsPatch(body) {
1113
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return {
1114
+ ok: false,
1115
+ error: "请求体必须是 JSON 对象"
1116
+ };
1117
+ const record = body;
1118
+ const KNOWN_KEYS = [
1119
+ "imageProvider",
1120
+ "imageSize",
1121
+ "imageStyle",
1122
+ "videoProvider",
1123
+ "videoAspectRatio",
1124
+ "videoDuration"
1125
+ ];
1126
+ for (const key of Object.keys(record)) if (!KNOWN_KEYS.includes(key)) return {
1127
+ ok: false,
1128
+ error: `未知字段: ${key}`
1129
+ };
1130
+ const patch = {};
1131
+ for (const key of KNOWN_KEYS) {
1132
+ const value = record[key];
1133
+ if (value === void 0) continue;
1134
+ if (value === null || value === "") {
1135
+ patch[key] = null;
1136
+ continue;
1137
+ }
1138
+ if (key === "imageProvider" || key === "videoProvider") {
1139
+ if (typeof value !== "string" || !isProvider(value)) return {
1140
+ ok: false,
1141
+ error: `${key} 必须是 ${PROVIDERS.join(" / ")} 之一`
1142
+ };
1143
+ patch[key] = value;
1144
+ continue;
1145
+ }
1146
+ if (key === "imageSize") {
1147
+ if (typeof value !== "string" || !IMAGE_SIZE_OPTIONS.some((option) => option.size === value)) return {
1148
+ ok: false,
1149
+ error: `imageSize 必须是白名单尺寸之一: ${IMAGE_SIZE_OPTIONS.map((o) => o.size).join(" / ")}`
1150
+ };
1151
+ patch[key] = value;
1152
+ continue;
1153
+ }
1154
+ if (key === "imageStyle") {
1155
+ if (typeof value !== "string" || !IMAGE_STYLE_OPTIONS.some((option) => option.id === value && option.id !== "")) return {
1156
+ ok: false,
1157
+ error: `imageStyle 必须是白名单风格之一: ${IMAGE_STYLE_OPTIONS.filter((o) => o.id !== "").map((o) => o.id).join(" / ")}`
1158
+ };
1159
+ patch[key] = value;
1160
+ continue;
1161
+ }
1162
+ if (key === "videoAspectRatio") {
1163
+ if (typeof value !== "string" || !VIDEO_ASPECT_RATIOS.includes(value)) return {
1164
+ ok: false,
1165
+ error: `videoAspectRatio 必须是 ${VIDEO_ASPECT_RATIOS.join(" / ")} 之一`
1166
+ };
1167
+ patch[key] = value;
1168
+ continue;
1169
+ }
1170
+ if (typeof value !== "number" || !Number.isInteger(value) || value < MIN_VIDEO_DURATION || value > MAX_VIDEO_DURATION$1) return {
1171
+ ok: false,
1172
+ error: `videoDuration 必须是 ${MIN_VIDEO_DURATION}-${MAX_VIDEO_DURATION$1} 的整数秒`
1173
+ };
1174
+ patch[key] = value;
1175
+ }
1176
+ return {
1177
+ ok: true,
1178
+ patch
1179
+ };
1180
+ }
1181
+ /**
1182
+ * 创建 defaults 路由 handler。GET 返回「运行时覆盖 ?? settings 持久默认」合并
1183
+ * 视图;POST 校验写入并返回合并视图;非 GET/POST 405;请求体不可解析或校验
1184
+ * 失败 400。
1185
+ * @param store - 运行时默认值存储。
1186
+ * @param persisted - settings 持久默认回落层({@link extractPersistedDefaults}
1187
+ * 提取;未提供时用空对象,即不回落)。
1188
+ */
1189
+ function createDefaultsRouteHandler(store, persisted = {}) {
1190
+ return async (req, res) => {
1191
+ const sendJson = (status, payload) => {
1192
+ res.writeHead(status, {
1193
+ "Content-Type": "application/json; charset=utf-8",
1194
+ "Cache-Control": "no-store"
1195
+ });
1196
+ res.end(JSON.stringify(payload));
1197
+ };
1198
+ if (req.method === "GET") {
1199
+ sendJson(200, toView(store.get(), persisted));
1200
+ return;
1201
+ }
1202
+ if (req.method !== "POST") {
1203
+ res.writeHead(405, { Allow: "GET, POST" });
1204
+ res.end();
1205
+ return;
1206
+ }
1207
+ const chunks = [];
1208
+ const body = await new Promise((resolve) => {
1209
+ req.on("data", (chunk) => chunks.push(chunk));
1210
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
1211
+ req.on("error", () => resolve(""));
1212
+ });
1213
+ let parsed;
1214
+ try {
1215
+ parsed = body === "" ? void 0 : JSON.parse(body);
1216
+ } catch {
1217
+ sendJson(400, { error: "请求体不是合法 JSON" });
1218
+ return;
1219
+ }
1220
+ const result = parseDefaultsPatch(parsed);
1221
+ if (!result.ok) {
1222
+ sendJson(400, { error: result.error });
1223
+ return;
1224
+ }
1225
+ sendJson(200, toView(store.patch(result.patch), persisted));
1226
+ };
1227
+ }
1228
+ /**
1229
+ * 把 defaults 路由注册进 webServer。仅回环地址注册:host 非 127.0.0.1 时返回
1230
+ * undefined 且不注册——运行时覆盖值属本机会话状态,不暴露到局域网。
1231
+ * @param webServer - webServer 服务实例(结构类型,见 media-route.ts)。
1232
+ * @param store - 运行时默认值存储。
1233
+ * @param persisted - settings 持久默认回落层(同 {@link createDefaultsRouteHandler})。
1234
+ * @returns 路由注销函数;未注册返回 undefined。
1235
+ */
1236
+ function registerDefaultsRoute(webServer, store, persisted = {}) {
1237
+ if (webServer.host !== "127.0.0.1") return void 0;
1238
+ return webServer.register({
1239
+ kind: "exact",
1240
+ path: DEFAULTS_ROUTE_PATH,
1241
+ handler: createDefaultsRouteHandler(store, persisted)
1242
+ });
1243
+ }
1244
+ //#endregion
1245
+ //#region src/task-manager.ts
1246
+ /** 连续查询失败阈值:超过即认为轮询链路已断,交回上层收口(任务仍在服务端)。 */
1247
+ const MAX_CONSECUTIVE_QUERY_FAILURES = 5;
1248
+ /**
1249
+ * 任务管理器:管理所有进行中的生成任务轮询。
1250
+ * 在 apply() 中实例化,通过 ctx.effect 注册卸载清理。
1251
+ */
1252
+ var TaskManager = class {
1253
+ /** 进行中的任务,key 为 taskId。 */
1254
+ active = /* @__PURE__ */ new Map();
1255
+ /** 配置引用。 */
1256
+ config;
1257
+ constructor(ctx, config) {
1258
+ this.config = config;
1259
+ ctx.effect(() => () => {
1260
+ for (const task of this.active.values()) task.controller.abort();
1261
+ this.active.clear();
1262
+ });
1263
+ }
1264
+ /**
1265
+ * 轮询任务直到完成、失败或超时。
1266
+ * 使用 AbortSignal 支持外部取消(插件卸载或工具调用超时)。
1267
+ * 轮询过程中不产生中间输出,仅最终结果返回给模型,不阻塞对话上下文。
1268
+ * @param taskId - 服务商返回的任务 ID。
1269
+ * @param query - 查询实现:适配器实例(用其 queryTask)或直接的查询函数。
1270
+ * @param httpOpts - HTTP 请求选项(含凭证与超时)。
1271
+ * @param externalSignal - 外部取消信号(工具执行上下文的 exec.signal)。
1272
+ * @returns 媒体 URL 与耗时。
1273
+ * @throws {GenerationError} 任务失败、超时或被取消(超时错误带 task_id)。
1274
+ */
1275
+ async pollUntilDone(taskId, query, httpOpts, externalSignal) {
1276
+ const controller = new AbortController();
1277
+ const startTime = Date.now();
1278
+ const queryFn = typeof query === "function" ? query : (id, opts) => query.queryTask(id, opts);
1279
+ const onExternalAbort = () => controller.abort();
1280
+ externalSignal?.addEventListener("abort", onExternalAbort);
1281
+ const task = {
1282
+ controller,
1283
+ taskId,
1284
+ label: `${httpOpts.baseURL}#${taskId}`
1285
+ };
1286
+ this.active.set(taskId, task);
1287
+ try {
1288
+ const deadline = startTime + this.config.pollTimeoutMs;
1289
+ let consecutiveFailures = 0;
1290
+ while (Date.now() < deadline) {
1291
+ if (controller.signal.aborted) throw new GenerationError("timeout", "生成任务已被取消", false);
1292
+ const httpOptsWithSignal = {
1293
+ ...httpOpts,
1294
+ timeoutMs: Math.min(httpOpts.timeoutMs, 3e4),
1295
+ signal: controller.signal
1296
+ };
1297
+ let result;
1298
+ try {
1299
+ result = await queryFn(taskId, httpOptsWithSignal);
1300
+ consecutiveFailures = 0;
1301
+ } catch (err) {
1302
+ if (controller.signal.aborted) throw err;
1303
+ if (!this.isTransientQueryError(err)) throw err;
1304
+ consecutiveFailures += 1;
1305
+ if (consecutiveFailures >= MAX_CONSECUTIVE_QUERY_FAILURES) throw new GenerationError("timeout", `查询图片/视频任务状态连续失败 ${consecutiveFailures} 次(task_id=${taskId}):${err instanceof Error ? err.message : String(err)}。任务未丢失,可用 request_id 稍后找回`, false);
1306
+ await this.sleep(this.config.pollIntervalMs, controller.signal);
1307
+ continue;
1308
+ }
1309
+ if (result.status === "succeeded") return {
1310
+ mediaUrl: result.mediaUrl,
1311
+ elapsedMs: Date.now() - startTime
1312
+ };
1313
+ if (result.status === "failed") throw new GenerationError("task", result.error, false);
1314
+ await this.sleep(this.config.pollIntervalMs, controller.signal);
1315
+ }
1316
+ throw new GenerationError("timeout", `生成任务轮询超时(${this.config.pollTimeoutMs}ms):task_id=${taskId} 仍在服务端执行/缓存,本次不重新提交(避免重复扣费),可稍后用 request_id 找回`, false);
1317
+ } finally {
1318
+ this.active.delete(taskId);
1319
+ externalSignal?.removeEventListener("abort", onExternalAbort);
1320
+ }
1321
+ }
1322
+ /** 获取当前进行中的任务数,供状态展示。 */
1323
+ get activeCount() {
1324
+ return this.active.size;
1325
+ }
1326
+ /**
1327
+ * 判定查询阶段的错误是否属于「瞬时故障、值得继续轮询」。
1328
+ * 可重试分类(网络/超时/5xx/限流)与不可重试的鉴权/参数错误区分开:
1329
+ * 后者继续轮询只会白等,立即抛出。
1330
+ */
1331
+ isTransientQueryError(err) {
1332
+ if (!(err instanceof GenerationError)) return true;
1333
+ if (err.retryable) return true;
1334
+ if (err.status !== void 0 && err.status >= 500) return true;
1335
+ return false;
1336
+ }
1337
+ /** 可被取消的延时;信号触发时立即 reject。 */
1338
+ sleep(ms, signal) {
1339
+ return new Promise((resolve, reject) => {
1340
+ if (signal.aborted) {
1341
+ reject(new GenerationError("timeout", "生成任务已被取消", false));
1342
+ return;
1343
+ }
1344
+ const timer = setTimeout(resolve, ms);
1345
+ signal.addEventListener("abort", () => {
1346
+ clearTimeout(timer);
1347
+ reject(new GenerationError("timeout", "生成任务已被取消", false));
1348
+ }, { once: true });
1349
+ });
1350
+ }
1351
+ };
1352
+ //#endregion
1353
+ //#region node_modules/.pnpm/@deepseek-ai+dsh-attachment@0.1.1-rc.2_@deepseek-ai+cordis@4.0.1_@deepseek-ai+dsh-brand_75c4d5bd4bb11efc15437276d91208a8/node_modules/@deepseek-ai/dsh-attachment/lib/index.js
1354
+ /** Attachment identifier brand. @module @deepseek-ai/dsh-attachment/brand */
1355
+ /**
1356
+ * Brand a validated storage identifier.
1357
+ * @param value - backend-produced opaque identifier.
1358
+ * @returns the branded identifier.
1359
+ */
1360
+ function AttachmentId(value) {
1361
+ return value;
1362
+ }
1363
+ //#endregion
1364
+ //#region src/watermark.ts
1365
+ /**
1366
+ * 品牌水印后处理(正式实现,非临时脚本)。
1367
+ *
1368
+ * 位置:图片生成流程的**后处理阶段**——模型原始字节下载到内存后、写入 outputs/
1369
+ * 与 attachment 之前完成合成。因此:
1370
+ * - 磁盘上只有一份文件(最终图),不存在「原图 + 水印图」两份产物;
1371
+ * - 交互流展示的附件与文件是同一份字节,用户看到的就是最终结果;
1372
+ * - 不依赖工作目录、不需要任何外部脚本或 Python 环境。
1373
+ *
1374
+ * 合成方式:sharp 把一段 SVG 合到原图上(librsvg 负责文字栅格化)。
1375
+ * 语义要点:
1376
+ * - 尺寸全部按图片宽度/高度的**比例**表达,同一套配置对任意尺寸出图观感一致;
1377
+ * - 右下角(默认)与四角位置均支持,左右用 text-anchor 对齐、上下用基线偏移;
1378
+ * - 文字过宽(超窄图)时自动缩号,保证水印不会横向溢出画面;
1379
+ * - 失败**开放**:水印是装饰性后处理,任何异常都不能让用户的付费结果丢失,
1380
+ * 因此失败时原样返回模型原图并回报原因。
1381
+ * @module dsh-image-video/watermark
1382
+ */
1383
+ /**
1384
+ * 给图片加品牌水印。
1385
+ * @param data - 模型返回的原始图片字节。
1386
+ * @param contentType - 原始 MIME 类型(决定输出编码,保持与上游一致的格式)。
1387
+ * @param config - 水印配置(enabled=false 时直接原样返回)。
1388
+ * @returns 处理结果(含 applied / note,供结果层如实告知)。
1389
+ */
1390
+ async function applyImageWatermark(data, contentType, config) {
1391
+ if (!config.enabled) return {
1392
+ data,
1393
+ contentType,
1394
+ applied: false,
1395
+ note: "品牌水印已在配置中关闭(watermark.enabled=false)"
1396
+ };
1397
+ const text = config.text.trim();
1398
+ if (text === "") return {
1399
+ data,
1400
+ contentType,
1401
+ applied: false,
1402
+ note: "品牌水印文本为空,已跳过水印后处理"
1403
+ };
1404
+ try {
1405
+ const image = sharp(data).rotate();
1406
+ const meta = await image.metadata();
1407
+ const width = meta.width ?? 0;
1408
+ const height = meta.height ?? 0;
1409
+ if (width <= 0 || height <= 0) return {
1410
+ data,
1411
+ contentType,
1412
+ applied: false,
1413
+ note: "品牌水印未合成:无法读取图片尺寸(格式异常),已保留模型原图"
1414
+ };
1415
+ const svg = buildWatermarkSvg(width, height, text, config);
1416
+ const composited = image.composite([{
1417
+ input: Buffer.from(svg),
1418
+ blend: "over"
1419
+ }]);
1420
+ const normalized = contentType.toLowerCase();
1421
+ const output = normalized.includes("png") ? await composited.png().toBuffer() : normalized.includes("webp") ? await composited.webp({ quality: 95 }).toBuffer() : await composited.jpeg({ quality: 95 }).toBuffer();
1422
+ return {
1423
+ data: new Uint8Array(output),
1424
+ contentType: normalized.includes("png") ? "image/png" : normalized.includes("webp") ? "image/webp" : "image/jpeg",
1425
+ applied: true
1426
+ };
1427
+ } catch (err) {
1428
+ return {
1429
+ data,
1430
+ contentType,
1431
+ applied: false,
1432
+ note: `品牌水印未合成(${err instanceof Error ? err.message : String(err)}),已保留模型原图`
1433
+ };
1434
+ }
1435
+ }
1436
+ /**
1437
+ * 构造水印 SVG。导出供单测直接断言坐标/字号等几何决策,无需真的解码图片。
1438
+ * @param width - 图片宽度(像素)。
1439
+ * @param height - 图片高度(像素)。
1440
+ * @param text - 水印文字(已 trim,非空)。
1441
+ * @param config - 水印配置。
1442
+ */
1443
+ function buildWatermarkSvg(width, height, text, config) {
1444
+ const marginX = Math.max(4, Math.round(width * config.marginXRatio));
1445
+ const marginY = Math.max(4, Math.round(height * config.marginYRatio));
1446
+ const fontSize = fitFontSize(text, width, marginX, config.fontSizeRatio);
1447
+ const letterSpacing = Math.max(1, Math.round(fontSize * .08));
1448
+ const right = config.position.endsWith("right");
1449
+ const bottom = config.position.startsWith("bottom");
1450
+ const x = right ? width - marginX : marginX;
1451
+ const y = bottom ? Math.max(fontSize, height - marginY - Math.round(fontSize * .18)) : marginY + fontSize;
1452
+ const anchor = right ? "end" : "start";
1453
+ const escaped = escapeXml(text);
1454
+ const glowStd = Math.max(1, fontSize * config.glowBlurRatio);
1455
+ return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"><defs>${config.glowEnabled ? `<filter id="wm-glow" x="-50%" y="-150%" width="200%" height="400%"><feGaussianBlur stdDeviation="${glowStd.toFixed(2)}"/></filter>` : ""}</defs>${config.glowEnabled ? `<text x="${x}" y="${y}" text-anchor="${anchor}" fill="${config.glowColor}" opacity="${clamp01(config.opacity * .72)}" filter="url(#wm-glow)" font-family="Helvetica Neue,Helvetica,Arial,sans-serif" font-size="${fontSize}px" font-weight="500" letter-spacing="${letterSpacing}px">${escaped}</text>` : ""}${`<text x="${x}" y="${y}" text-anchor="${anchor}" fill="#ffffff" opacity="${clamp01(config.opacity)}" font-family="Helvetica Neue,Helvetica,Arial,sans-serif" font-size="${fontSize}px" font-weight="500" letter-spacing="${letterSpacing}px">${escaped}</text>`}</svg>`;
1456
+ }
1457
+ /**
1458
+ * 计算字号:基准为 `宽度 × fontSizeRatio`,并保证估算文字宽度不超过
1459
+ * 可用宽度(图片宽度减两侧留白)的 95%,避免窄图上水印横向溢出。
1460
+ * @param text - 水印文字。
1461
+ * @param width - 图片宽度。
1462
+ * @param marginX - 单侧水平留白。
1463
+ * @param fontSizeRatio - 字号占图片宽度的比例。
1464
+ * @returns 最终字号(像素,至少 10px)。
1465
+ */
1466
+ function fitFontSize(text, width, marginX, fontSizeRatio) {
1467
+ const base = Math.max(10, Math.round(width * fontSizeRatio));
1468
+ const available = Math.max(1, width - marginX * 2);
1469
+ if (text.length * base * .7 <= available * .95) return base;
1470
+ const scaled = Math.floor(available * .95 / (text.length * .7));
1471
+ return Math.max(10, scaled);
1472
+ }
1473
+ /** 把 [0,1] 之外的值收敛回区间(配置已由 schema 校验,此处防御脏值)。 */
1474
+ function clamp01(value) {
1475
+ if (!Number.isFinite(value)) return .68;
1476
+ return Math.min(1, Math.max(0, value));
1477
+ }
1478
+ /** XML 转义:水印文字来自配置,仍需防止破坏 SVG 结构。 */
1479
+ function escapeXml(value) {
1480
+ return value.replace(/[<>&'"]/g, (char) => char === "<" ? "&lt;" : char === ">" ? "&gt;" : char === "&" ? "&amp;" : char === "'" ? "&apos;" : "&quot;");
1481
+ }
1482
+ //#endregion
1483
+ //#region src/image-transaction.ts
1484
+ /** 创建事务账本。 */
1485
+ function createImageTransaction(input) {
1486
+ return {
1487
+ requestId: input.requestId,
1488
+ provider: input.provider,
1489
+ model: input.model,
1490
+ transport: input.transport,
1491
+ status: "idle",
1492
+ submitAttempts: 0,
1493
+ recoveryLookups: 0
1494
+ };
1495
+ }
1496
+ /** 提交预算上限:默认 1;同键重提策略下允许 2(第二次必须复用同一个幂等键)。 */
1497
+ function submitBudget(options) {
1498
+ return options?.allowSameKeyRetry === true ? 2 : 1;
1499
+ }
1500
+ /**
1501
+ * 消费一次提交预算。达到上限后再次调用一律抛错——这是「一次请求只生成一张」
1502
+ * 的硬闸门:任何异常路径都不可能在同一个事务里再发一次新的计费请求。
1503
+ * @throws {GenerationError} 当本事务已用尽提交预算。
1504
+ */
1505
+ function consumeSubmitBudget(tx, options) {
1506
+ if (tx.submitAttempts >= submitBudget(options)) throw new GenerationError("task", "dsh-image-video:本次生成事务已提交过一次生图请求,拒绝重复提交(防止重复扣费)", false);
1507
+ tx.submitAttempts += 1;
1508
+ tx.status = "submitting";
1509
+ tx.submitStartedAt = Date.now();
1510
+ }
1511
+ /** 标记提交阶段结束(成功或失败都会调用,供结果元数据展示耗时)。 */
1512
+ function finishSubmit(tx) {
1513
+ tx.submitFinishedAt = Date.now();
1514
+ }
1515
+ /**
1516
+ * 判定当前失败是否允许回退到**下一个候选服务商**。
1517
+ *
1518
+ * 与 `isModelNotAcceptedError` 的区别在于多了一层事务状态门:
1519
+ * - 状态必须停在 `failed`——即服务端明确表示「没有创建任务」;
1520
+ * 状态为 `unknown` 时绝不换家(否则可能两家各生成一张、各扣一次费)。
1521
+ * - 提交次数必须恰好为 1(未被同键重提污染)。
1522
+ * - 5xx 只有实证的「无可用渠道」形态才允许换家:网关按模型查渠道发生在调用上游
1523
+ * 之前,无渠道即无任务;其余 5xx 一律视为未知状态。
1524
+ * @param err - 提交抛出的错误。
1525
+ * @param tx - 当前事务账本。
1526
+ * @returns 是否允许换下一个候选服务商。
1527
+ */
1528
+ function canFallbackToNextProvider(err, tx) {
1529
+ if (tx.status !== "failed") return false;
1530
+ if (tx.submitAttempts !== 1) return false;
1531
+ if (isCancelledError(err)) return false;
1532
+ if (isIdempotencyConflictError(err)) return false;
1533
+ if (!isModelNotAcceptedError(err)) return false;
1534
+ if (err instanceof GenerationError && err.status !== void 0 && err.status >= 500) return /no available media generation channels|capacity_error/i.test(err.message);
1535
+ return true;
1536
+ }
1537
+ /** 导出事务摘要(缺省字段不出现在结果里,避免噪音)。 */
1538
+ function reportTransaction(tx) {
1539
+ return {
1540
+ requestId: tx.requestId,
1541
+ transport: tx.transport,
1542
+ submitAttempts: tx.submitAttempts,
1543
+ recoveryLookups: tx.recoveryLookups,
1544
+ status: tx.status,
1545
+ ...tx.taskId === void 0 ? {} : { taskId: tx.taskId },
1546
+ ...tx.replayed === void 0 ? {} : { replayed: tx.replayed },
1547
+ ...tx.degraded === void 0 ? {} : { degraded: tx.degraded }
1548
+ };
1549
+ }
1550
+ /**
1551
+ * 构造「提交状态未知」错误。文案必须做到三件事:说清事实(可能已在计费)、
1552
+ * 说清客户端已经做了什么(按幂等键查过了、没查到)、给可执行的下一步
1553
+ * (凭 requestId 找回,或用户明确要求后重试)。
1554
+ */
1555
+ function unknownStateError(tx, cause) {
1556
+ const causeText = cause instanceof Error ? cause.message : String(cause);
1557
+ return new GenerationError("timeout", `图片请求已提交但客户端未收到结果(提交状态未知)。为避免重复扣费,本次不会自动重新生成,也不会自动换模型。已按幂等键反查 ${String(tx.recoveryLookups)} 次,未查到对应任务。request_id=${tx.requestId}(服务端保留 24 小时,可用 generate_image 的 recoverRequestId 参数找回)。原始错误:${causeText}`, false);
1558
+ }
1559
+ /**
1560
+ * 执行图片生成事务:**最多一次提交**,提交后轮询结果;提交结果未知时凭幂等键
1561
+ * 找回原任务,而不是重新生成。
1562
+ *
1563
+ * 分支与依据(服务端 HTTP 契约表):
1564
+ * - 202 接受 → 轮询 `task_id`;`X-Idempotency-Replayed` 为真表示本次未新建任务。
1565
+ * - 409 `IDEMPOTENCY_IN_PROGRESS` → 任务已存在 → 按 Retry-After 等一下再反查。
1566
+ * - 409 `IDEMPOTENCY_KEY_CONFLICT` → 幂等键被复用于不同请求体(客户端 bug)→ 响亮失败。
1567
+ * - 404 异步端点不可用 → 抛 {@link AsyncTransportUnavailableError},由调用方降级同步
1568
+ * (服务端在创建任务前返回,降级不会重复生成)。
1569
+ * - 4xx 其他(400/401/403/413/429)→ 明确没建任务 → 状态置 `failed`,允许换候选。
1570
+ * - 超时 / 断连 / 5xx → 状态置 `unknown` → 反查;查到就继续等,查不到按策略处理。
1571
+ *
1572
+ * @returns 提交结果与最终使用的传输方式。
1573
+ * @throws {AsyncTransportUnavailableError} 异步端点在本环境不可用(请降级同步)。
1574
+ * @throws {GenerationError} 其他分类错误;`tx.status` 反映是否允许换候选。
1575
+ */
1576
+ async function runImageTransaction(deps) {
1577
+ const { tx, adapter, params, httpOpts } = deps;
1578
+ const note = deps.onNote ?? (() => {});
1579
+ const api = adapter.imageAsync;
1580
+ if (tx.transport === "async" && api === void 0) throw new GenerationError("task", `服务商 ${tx.provider} 不支持异步图片传输(缺少 imageAsync 能力)`, false);
1581
+ if (tx.transport === "sync" || api === void 0) return await runSyncAttempt(deps);
1582
+ let accepted;
1583
+ try {
1584
+ accepted = await submitAsyncOnce(tx, api, params, httpOpts, note);
1585
+ } catch (err) {
1586
+ return await handleSubmitFailure(deps, err);
1587
+ }
1588
+ if (accepted === void 0) throw new GenerationError("task", "生图事务内部错误:提交结果缺失", false);
1589
+ const mediaUrl = await awaitTask(deps, accepted.taskId);
1590
+ tx.status = "succeeded";
1591
+ return {
1592
+ submit: {
1593
+ taskId: accepted.taskId,
1594
+ async: false,
1595
+ mediaUrl,
1596
+ mediaType: "image",
1597
+ ...accepted.model === void 0 ? {} : { model: accepted.model }
1598
+ },
1599
+ transport: "async"
1600
+ };
1601
+ }
1602
+ /**
1603
+ * 提交失败后的分流:明确没建任务的错误直接抛出(允许换候选),
1604
+ * 状态未知的错误先凭幂等键反查原任务,绝不重提。
1605
+ * @returns 找回成功时的事务结果。
1606
+ * @throws {AsyncTransportUnavailableError} 异步端点不可用(调用方降级同步)。
1607
+ * @throws {GenerationError} 分类后的失败;`tx.status` 标明是否允许换候选。
1608
+ */
1609
+ async function handleSubmitFailure(deps, err) {
1610
+ const { tx } = deps;
1611
+ const note = deps.onNote ?? (() => {});
1612
+ if (isCancelledError(err)) {
1613
+ tx.status = "unknown";
1614
+ throw err;
1615
+ }
1616
+ if (isIdempotencyConflictError(err)) {
1617
+ tx.status = "failed";
1618
+ throw new GenerationError("task", `生图提交被服务端拒绝:幂等键被用于不同请求体(request_id=${tx.requestId})。这是客户端事务 ID 复用问题,请把该 request_id 提供给插件维护者排查`, false, err instanceof GenerationError ? err.status : void 0, void 0, err instanceof GenerationError ? err.code : void 0);
1619
+ }
1620
+ if (isAsyncImageUnavailableError(err)) {
1621
+ tx.status = "failed";
1622
+ tx.degraded = true;
1623
+ throw new AsyncTransportUnavailableError(err instanceof Error ? err.message : String(err));
1624
+ }
1625
+ if (isIdempotencyInProgressError(err)) {
1626
+ tx.status = "unknown";
1627
+ const retryAfterMs = err instanceof GenerationError ? err.retryAfterMs : void 0;
1628
+ note("服务端报告同一幂等键的提交仍在进行中:任务已存在,改为凭 request_id 找回原任务(不重新生成)");
1629
+ return await recoverResult(deps, err, retryAfterMs);
1630
+ }
1631
+ if (isDefinitelyNoTaskError(err)) {
1632
+ tx.status = "failed";
1633
+ throw err;
1634
+ }
1635
+ tx.status = "unknown";
1636
+ note("提交结果未知(超时/断连/5xx):不重新提交、不换模型,先凭 request_id 反查原任务");
1637
+ return await recoverResult(deps, err);
1638
+ }
1639
+ /**
1640
+ * 凭幂等键找回原任务:查到就继续轮询它(不产生新的生成请求),查不到按策略处理。
1641
+ *
1642
+ * 404 有两重含义(服务端契约):该键无记录,**或**记录仍在进行中、响应体尚未落库——
1643
+ * 因此必须按预算重试若干次再下结论,不能一次 404 就断定任务不存在。
1644
+ * @throws {GenerationError} 找回无果且策略为 fail 时的「状态未知」错误。
1645
+ */
1646
+ async function recoverResult(deps, cause, firstDelayMs) {
1647
+ const { tx, httpOpts } = deps;
1648
+ const note = deps.onNote ?? (() => {});
1649
+ const sleep = deps.sleep ?? defaultSleep;
1650
+ const api = deps.adapter.imageAsync;
1651
+ if (api === void 0) throw unknownStateError(tx, cause);
1652
+ let delay = firstDelayMs ?? deps.recovery.delayMs;
1653
+ for (let attempt = 0; attempt < deps.recovery.attempts; attempt++) {
1654
+ await sleep(delay, httpOpts.signal);
1655
+ tx.recoveryLookups += 1;
1656
+ let found;
1657
+ try {
1658
+ found = await api.findByRequest(tx.requestId, httpOpts);
1659
+ } catch (lookupErr) {
1660
+ note(`按 request_id 反查失败(第 ${String(tx.recoveryLookups)} 次):${lookupErr instanceof Error ? lookupErr.message : String(lookupErr)}`);
1661
+ delay = deps.recovery.delayMs;
1662
+ continue;
1663
+ }
1664
+ if (found !== void 0) {
1665
+ tx.taskId = found.taskId;
1666
+ tx.status = "accepted";
1667
+ note(`已凭 request_id 找回原任务 ${found.taskId}:继续等待该任务结果,未产生新的生成请求`);
1668
+ const mediaUrl = await awaitTask(deps, found.taskId);
1669
+ tx.status = "succeeded";
1670
+ return {
1671
+ submit: {
1672
+ taskId: found.taskId,
1673
+ async: false,
1674
+ mediaUrl,
1675
+ mediaType: "image",
1676
+ ...tx.model === void 0 ? {} : { model: tx.model }
1677
+ },
1678
+ transport: "async"
1679
+ };
1680
+ }
1681
+ delay = deps.recovery.delayMs;
1682
+ }
1683
+ if (deps.unknownStatePolicy === "resubmit-same-key" && tx.submitAttempts < submitBudget({ allowSameKeyRetry: true })) {
1684
+ note("反查无果且策略允许:用同一个幂等键重新提交(服务端按键去重,不会重复生成)");
1685
+ let accepted;
1686
+ try {
1687
+ accepted = await submitAsyncOnce(tx, api, deps.params, httpOpts, note, { allowSameKeyRetry: true });
1688
+ } catch (resubmitErr) {
1689
+ tx.status = "unknown";
1690
+ throw unknownStateError(tx, resubmitErr);
1691
+ }
1692
+ const mediaUrl = await awaitTask(deps, accepted.taskId);
1693
+ tx.status = "succeeded";
1694
+ return {
1695
+ submit: {
1696
+ taskId: accepted.taskId,
1697
+ async: false,
1698
+ mediaUrl,
1699
+ mediaType: "image",
1700
+ ...accepted.model === void 0 ? {} : { model: accepted.model }
1701
+ },
1702
+ transport: "async"
1703
+ };
1704
+ }
1705
+ throw unknownStateError(tx, cause);
1706
+ }
1707
+ /**
1708
+ * 轮询任务直到有结果。走到这里任务已确定存在(有 taskId),因此失败不再涉及
1709
+ * 「是否重复生成」:只需把任务句柄带进错误,供用户稍后找回或直接判定任务失败。
1710
+ */
1711
+ async function awaitTask(deps, taskId) {
1712
+ try {
1713
+ return (await deps.poll(taskId, deps.httpOpts.signal)).mediaUrl;
1714
+ } catch (err) {
1715
+ const detail = err instanceof Error ? err.message : String(err);
1716
+ if (err instanceof GenerationError && err.kind === "task") {
1717
+ deps.tx.status = "failed";
1718
+ throw new GenerationError("task", `图片任务执行失败:${detail}(task_id=${taskId},request_id=${deps.tx.requestId};本次只提交过一次,不会自动重新生成)`, false);
1719
+ }
1720
+ deps.tx.status = "failed";
1721
+ throw new GenerationError("timeout", `图片任务已创建(task_id=${taskId},request_id=${deps.tx.requestId})但客户端未等到结果:${detail}。任务仍在服务端执行/缓存,可直接用 recoverRequestId 找回,无需重新生成`, false);
1722
+ }
1723
+ }
1724
+ /**
1725
+ * 消费提交预算并执行一次异步提交。服务端 202 即代表任务已落库,
1726
+ * 因此 `tx.taskId` 在此刻成为确定事实。
1727
+ */
1728
+ async function submitAsyncOnce(tx, api, params, httpOpts, note, options) {
1729
+ consumeSubmitBudget(tx, options);
1730
+ try {
1731
+ const accepted = await api.submit(params, httpOpts);
1732
+ tx.taskId = accepted.taskId;
1733
+ tx.status = "accepted";
1734
+ if (accepted.replayed === true) {
1735
+ tx.replayed = true;
1736
+ note(`服务端幂等回放:本次没有新建任务,直接复用原任务 ${accepted.taskId}`);
1737
+ }
1738
+ return {
1739
+ taskId: accepted.taskId,
1740
+ ...accepted.model === void 0 ? {} : { model: accepted.model }
1741
+ };
1742
+ } finally {
1743
+ finishSubmit(tx);
1744
+ }
1745
+ }
1746
+ /**
1747
+ * 同步传输分支:调适配器的同步提交(内部强制 `retryTimes: 0`),拿到结果或任务句柄。
1748
+ * 同步端点没有幂等键记录,因此未知状态一律直接失败——**绝不重提**。
1749
+ */
1750
+ async function runSyncAttempt(deps) {
1751
+ const { tx, adapter, params, httpOpts } = deps;
1752
+ const note = deps.onNote ?? (() => {});
1753
+ consumeSubmitBudget(tx);
1754
+ let submit;
1755
+ try {
1756
+ submit = await adapter.submitImage(params, {
1757
+ ...httpOpts,
1758
+ retryTimes: 0
1759
+ });
1760
+ tx.status = "accepted";
1761
+ if (submit.async && submit.taskId !== "") tx.taskId = submit.taskId;
1762
+ finishSubmit(tx);
1763
+ } catch (err) {
1764
+ finishSubmit(tx);
1765
+ if (isCancelledError(err)) {
1766
+ tx.status = "unknown";
1767
+ throw err;
1768
+ }
1769
+ if (isDefinitelyNoTaskError(err)) {
1770
+ tx.status = "failed";
1771
+ throw err;
1772
+ }
1773
+ if (isModelNotAcceptedError(err) && !isUnknownSubmitStateError(err)) {
1774
+ tx.status = "failed";
1775
+ throw err;
1776
+ }
1777
+ if (isModelNotAcceptedError(err)) {
1778
+ tx.status = "failed";
1779
+ throw err;
1780
+ }
1781
+ tx.status = "unknown";
1782
+ throw new GenerationError("timeout", `同步图片请求已提交但客户端未收到结果(提交状态未知)。同步端点没有幂等键记录,为避免重复扣费,本次不会自动重新生成,也不会自动换模型。request_id=${tx.requestId}。原始错误:${err instanceof Error ? err.message : String(err)}`, false);
1783
+ }
1784
+ if (submit.async && submit.taskId !== "") {
1785
+ note(`服务商同步端点返回了异步任务形态,改为轮询任务 ${submit.taskId}`);
1786
+ const mediaUrl = await awaitTask(deps, submit.taskId);
1787
+ tx.status = "succeeded";
1788
+ return {
1789
+ submit: {
1790
+ ...submit,
1791
+ async: false,
1792
+ mediaUrl
1793
+ },
1794
+ transport: "sync"
1795
+ };
1796
+ }
1797
+ if (submit.mediaBase64 === void 0 && (submit.mediaUrl === void 0 || submit.mediaUrl === "")) {
1798
+ tx.status = "failed";
1799
+ throw new GenerationError("task", "生成失败:服务端既未返回图片 URL 也未返回图片数据", false);
1800
+ }
1801
+ tx.status = "succeeded";
1802
+ return {
1803
+ submit,
1804
+ transport: "sync"
1805
+ };
1806
+ }
1807
+ /** 异步图片传输在本环境不可用(功能未开启 / 分组平台不支持):调用方应降级同步。 */
1808
+ var AsyncTransportUnavailableError = class extends GenerationError {
1809
+ constructor(detail) {
1810
+ super("task", `异步图片端点在本环境不可用(${detail})。将降级为同步单次提交:同步路径没有幂等键记录,因此客户端不会自动重试,也不会在提交超时后重新生成`, false, 404, void 0, CODE_ASYNC_UNAVAILABLE);
1811
+ this.name = "AsyncTransportUnavailableError";
1812
+ }
1813
+ };
1814
+ /** 异步端点不可用的错误码(工具层据此决定降级)。 */
1815
+ const CODE_ASYNC_UNAVAILABLE = "ASYNC_IMAGE_UNAVAILABLE";
1816
+ /** 可被取消的等待。 */
1817
+ function defaultSleep(ms, signal) {
1818
+ return new Promise((resolve, reject) => {
1819
+ if (signal?.aborted) {
1820
+ reject(new GenerationError("timeout", "任务已被取消", false));
1821
+ return;
1822
+ }
1823
+ const timer = setTimeout(resolve, ms);
1824
+ signal?.addEventListener("abort", () => {
1825
+ clearTimeout(timer);
1826
+ reject(new GenerationError("timeout", "任务已被取消", false));
1827
+ }, { once: true });
1828
+ });
1829
+ }
1830
+ /** 传输能力探测缓存有效期:服务端上线/开启对象存储后自动恢复,无需重启客户端。 */
1831
+ const TRANSPORT_PROBE_TTL_MS = 6e5;
1832
+ /** 默认找回预算:5 次、间隔 5 秒(服务端提交响应通常秒级落库)。 */
1833
+ const DEFAULT_RECOVERY_BUDGET = {
1834
+ attempts: 5,
1835
+ delayMs: 5e3
1836
+ };
1837
+ /**
1838
+ * 依据配置与探测缓存决定本次调用的传输方式。
1839
+ * - `sync`:强制同步(无幂等,仅单次提交保障)。
1840
+ * - `async`:强制异步;端点不可用时响亮失败(上线验收用这个口径)。
1841
+ * - `auto`:优先异步,服务商不支持或近期探测到不可用时降级同步。
1842
+ */
1843
+ function resolveImageTransport(configured, adapter, probe, now = Date.now()) {
1844
+ if (configured === "sync") return "sync";
1845
+ if (adapter.imageAsync === void 0) return "sync";
1846
+ if (configured === "async") return "async";
1847
+ if (probe !== void 0 && probe.unavailableUntil > now) return "sync";
1848
+ return "async";
1849
+ }
1850
+ //#endregion
1851
+ //#region src/image-preflight.ts
1852
+ /** 尺寸写法白名单:宽*高 / 宽x高 / 比例 / 常见档位别名。 */
1853
+ const SIZE_PATTERN = /^(?:\d+[*xX]\d+|\d+:\d+|\d+K|auto)$/i;
1854
+ /**
1855
+ * 视频模型特征:`t2v` / `i2v` / `v2v` / `flf2v` / 独立 `video` 词段。
1856
+ * 只匹配**分段**出现(横杠/下划线/点号分隔),避免误伤 `wide-video-editing`
1857
+ * 之类含子串的图片模型名。
1858
+ */
1859
+ const VIDEO_MODEL_PATTERN = /(?:^|[-_.])(?:t2v|i2v|v2v|flf2v|video)(?:[-_.]|$)/i;
1860
+ /**
1861
+ * 执行调用前预检查。任何**确定无法成功**的输入在这里直接失败,绝不带着错误
1862
+ * 参数去打一次真金白银的生成请求。
1863
+ * @param input - 已解析的调用决策。
1864
+ * @returns 预检查结论。
1865
+ * @throws {GenerationError} 尺寸写法无法识别、模型明显是视频模型、异步传输不被支持。
1866
+ */
1867
+ function resolveImagePreflight(input) {
1868
+ const { provider, model, size, config } = input;
1869
+ const trimmedSize = size.trim();
1870
+ if (trimmedSize === "") throw new GenerationError("task", "生图参数错误:尺寸为空。请给出 宽*高(如 1024*1024)、比例(如 3:4)或 1K/2K/auto", false);
1871
+ if (!SIZE_PATTERN.test(trimmedSize)) throw new GenerationError("task", `生图参数错误:无法识别的尺寸写法「${trimmedSize}」。支持 宽*高(1024*1024)、宽x高(1024x1024)、比例(3:4)或 1K/2K/4K/auto`, false);
1872
+ if (model !== void 0 && model.trim() !== "" && looksLikeVideoModel(model, config)) throw new GenerationError("task", `生图参数错误:模型「${model}」是视频模型(或等于配置 defaultVideoModel=${config.defaultVideoModel}),不能用于 generate_image。生图请用图片模型(如 qwen-image-3.0 / qwen-image-3.0-pro),或修正配置中的 defaultImageModel`, false);
1873
+ if (input.transport === "async" && !input.adapterSupportsAsync) throw new GenerationError("task", `生图配置错误:服务商 ${provider} 不支持异步图片传输,但 imageTransport=async 要求它。可改用 threerouter(唯一实现异步图片网关的服务商),或把 imageTransport 改为 auto/sync`, false);
1874
+ return {
1875
+ provider,
1876
+ model: model === void 0 || model.trim() === "" ? void 0 : model,
1877
+ size: trimmedSize,
1878
+ mode: input.hasReferenceImage ? "image-to-image" : "text-to-image",
1879
+ transport: input.transport,
1880
+ watermark: {
1881
+ enabled: config.watermark.enabled,
1882
+ text: config.watermark.text
1883
+ }
1884
+ };
1885
+ }
1886
+ /**
1887
+ * 模型名是否明显属于视频模型。命中条件之一即可,且**显式配置优先**:
1888
+ * 当用户把 defaultImageModel 明确设成该名字时不再拦截(用户的显式选择胜过启发式)。
1889
+ * @param model - 模型名。
1890
+ * @param config - 插件配置。
1891
+ * @returns 是否判定为视频模型。
1892
+ */
1893
+ function looksLikeVideoModel(model, config) {
1894
+ const name = model.trim();
1895
+ if (name === "") return false;
1896
+ const explicitImageModel = config.defaultImageModel.trim();
1897
+ if (explicitImageModel !== "" && name === explicitImageModel) return false;
1898
+ const videoModel = config.defaultVideoModel.trim();
1899
+ if (videoModel !== "" && name === videoModel) return true;
1900
+ return VIDEO_MODEL_PATTERN.test(name);
1901
+ }
1902
+ /**
1903
+ * 把预检查结论格式化为一行透明告知,写进工具结果 notes:
1904
+ * 「本次用了谁、什么模型、什么尺寸、走哪条传输、要不要打水印」一句话说完,
1905
+ * 用户不必查配置或翻服务商后台。
1906
+ */
1907
+ function formatPreflightNote(preflight) {
1908
+ const model = preflight.model ?? "(服务商内置默认)";
1909
+ const mode = preflight.mode === "image-to-image" ? "图生图" : "文生图";
1910
+ const watermark = preflight.watermark.enabled ? `开(${preflight.watermark.text})` : "关";
1911
+ return `调用前预检查通过:服务商=${preflight.provider},模型=${model},模式=${mode},尺寸=${preflight.size},传输=${preflight.transport},品牌水印=${watermark}`;
1912
+ }
1913
+ //#endregion
1914
+ //#region src/providers/types.ts
1915
+ /** 将 HttpOpts 转换为 RequestOptions。 */
1916
+ function toRequestOpts(method, url, headers, body, opts) {
1917
+ return {
1918
+ method,
1919
+ url,
1920
+ headers,
1921
+ body,
1922
+ timeoutMs: opts.timeoutMs,
1923
+ retryTimes: opts.retryTimes,
1924
+ signal: opts.signal
1925
+ };
1926
+ }
1927
+ /**
1928
+ * 尺寸分隔符归一化:配置默认值沿用百炼风格 "1024*1024"(星号),
1929
+ * 而 threerouter(OpenAI 风格)与火山方舟均要求 "1024x1024"(字母 x)。
1930
+ * 非 `宽x高` 形态(如 "1K"/"2K"/"auto")原样返回。
1931
+ */
1932
+ function normalizeImageSize(size) {
1933
+ return /^\d+\*\d+$/.test(size.trim()) ? size.trim().replace(/\*/g, "x") : size.trim();
1934
+ }
1935
+ /**
1936
+ * 阿里系(千问 qwen-image-* / 万相 wan*-image 等,DashScope 与经 threerouter 透传皆同)
1937
+ * 原生尺寸归一化:上游要求 `宽*高` 且不接受比例写法(2026-09-14 实测:`3:4` 原样
1938
+ * 透传被 400 "Expected format: '<width>*<height>'")。比例写法按长边 1536、32 对齐
1939
+ * 换算(3:4 → 1152*1536;16:9 → 1536*864;1:1 → 1024*1024);WxH / W*H 统一 `*` 分隔。
1940
+ */
1941
+ function aliImageSize(size) {
1942
+ const ratio = /^(\d+):(\d+)$/.exec(size.trim());
1943
+ if (ratio) {
1944
+ const a = Number(ratio[1]);
1945
+ const b = Number(ratio[2]);
1946
+ if (a > 0 && b > 0) {
1947
+ if (a === b) return "1024*1024";
1948
+ const long = 1536;
1949
+ return `${a > b ? long : Math.round(long * a / b / 32) * 32}*${a > b ? Math.round(long * b / a / 32) * 32 : long}`;
1950
+ }
1951
+ }
1952
+ return size.trim().replace(/[xX]/g, "*");
1953
+ }
1954
+ //#endregion
1955
+ //#region src/providers/wanx.ts
1956
+ /**
1957
+ * 万象(wanx)适配器:基于阿里云百炼 DashScope 异步 API。
1958
+ * 文生图与文生视频均采用「提交任务 → 轮询查询 → 下载结果」异步模式。
1959
+ * 鉴权统一 Bearer Token,X-DashScope-Async: enable 标记异步调用。
1960
+ * @module dsh-image-video/providers/wanx
1961
+ */
1962
+ /** 万象默认文生图模型(通义万相)。 */
1963
+ const DEFAULT_IMAGE_MODEL$3 = "wanx2.1-t2i-turbo";
1964
+ /** 万象图生图默认模型(image2image 图编辑:按 prompt 描述编辑参考图)。 */
1965
+ const DEFAULT_IMAGE_EDIT_MODEL$2 = "wanx2.1-imageedit";
1966
+ /** 万象默认文生视频模型。 */
1967
+ const DEFAULT_VIDEO_MODEL$3 = "wan2.2-t2v-plus";
1968
+ /** 万象默认图生视频模型(首帧驱动)。 */
1969
+ const DEFAULT_IMAGE_TO_VIDEO_MODEL = "wan2.2-i2v-plus";
1970
+ /** DashScope 请求头。 */
1971
+ function dashscopeHeaders(apiKey, requestId) {
1972
+ return {
1973
+ "Content-Type": "application/json",
1974
+ "Authorization": `Bearer ${apiKey}`,
1975
+ "X-DashScope-Async": "enable",
1976
+ ...requestId ? {
1977
+ "Idempotency-Key": requestId,
1978
+ "X-Request-Id": requestId
1979
+ } : {}
1980
+ };
1981
+ }
1982
+ /** 查询任务公共头(无 Async 标记)。 */
1983
+ function queryHeaders(apiKey) {
1984
+ return { "Authorization": `Bearer ${apiKey}` };
1985
+ }
1986
+ /**
1987
+ * 提交图片任务:带参考图走 image2image 图编辑(wanx2.1-imageedit,description_edit:
1988
+ * 按 prompt 描述编辑参考图,异步任务),否则走文生图(wanx2.1-t2i-turbo,异步任务)。
1989
+ * 两者同为「提交任务 → 轮询 → 下载」模式,复用同一 queryTask。
1990
+ */
1991
+ async function submitImage$3(params, opts) {
1992
+ const effectiveModel = params.model ?? (params.image ? DEFAULT_IMAGE_EDIT_MODEL$2 : DEFAULT_IMAGE_MODEL$3);
1993
+ if (params.image) {
1994
+ const editUrl = `${opts.baseURL}/services/aigc/image2image/image-synthesis`;
1995
+ const editBody = {
1996
+ model: effectiveModel,
1997
+ input: {
1998
+ function: "description_edit",
1999
+ prompt: params.prompt,
2000
+ base_image_url: params.image
2001
+ }
2002
+ };
2003
+ const taskId = (await request(toRequestOpts("POST", editUrl, dashscopeHeaders(opts.apiKey, params.requestId), editBody, opts)))?.output?.task_id;
2004
+ if (!taskId) throw new Error("万象 图生图:未返回 task_id");
2005
+ return {
2006
+ taskId,
2007
+ async: true,
2008
+ mediaType: "image",
2009
+ model: effectiveModel
2010
+ };
2011
+ }
2012
+ const url = `${opts.baseURL}/services/aigc/text2image/image-synthesis`;
2013
+ const body = {
2014
+ model: effectiveModel,
2015
+ input: { prompt: params.prompt },
2016
+ parameters: {
2017
+ size: params.size ? aliImageSize(params.size) : void 0,
2018
+ n: 1
2019
+ }
2020
+ };
2021
+ const taskId = (await request(toRequestOpts("POST", url, dashscopeHeaders(opts.apiKey), body, opts)))?.output?.task_id;
2022
+ if (!taskId) throw new Error("万象 文生图:未返回 task_id");
2023
+ return {
2024
+ taskId,
2025
+ async: true,
2026
+ mediaType: "image",
2027
+ model: effectiveModel
2028
+ };
2029
+ }
2030
+ /**
2031
+ * 提交视频任务。存在 image 时为首帧驱动的图生视频:img_url 携带首帧引用
2032
+ * (公网 URL 或 base64 data URL),模型默认取图生视频模型,构图由首帧决定,
2033
+ * 不传 aspect_ratio;纯文生保持原有参数。resolution 为可选分辨率档位,按模型支持透传。
2034
+ * wan 系模型(wan2.2-t2v-plus / wan2.7-t2v 等)不支持自定义时长:命中能力表时
2035
+ * 丢弃 duration(SubmitResult.droppedDuration 标记,结果层在 notes 注明)。
2036
+ */
2037
+ async function submitVideo$3(params, opts) {
2038
+ const url = `${opts.baseURL}/services/aigc/video-generation/video-synthesis`;
2039
+ const input = { prompt: params.prompt };
2040
+ if (params.image) input.img_url = params.image;
2041
+ const effectiveModel = params.model ?? (params.image ? DEFAULT_IMAGE_TO_VIDEO_MODEL : DEFAULT_VIDEO_MODEL$3);
2042
+ const droppedDuration = VIDEO_DURATION_UNSUPPORTED.has(effectiveModel);
2043
+ const parameters = {};
2044
+ if (!droppedDuration) parameters.duration = params.duration;
2045
+ if (params.resolution) parameters.resolution = params.resolution;
2046
+ if (!params.image) {
2047
+ parameters.aspect_ratio = params.aspectRatio ?? "16:9";
2048
+ parameters.audio = false;
2049
+ }
2050
+ const body = {
2051
+ model: effectiveModel,
2052
+ input,
2053
+ parameters
2054
+ };
2055
+ const taskId = (await request(toRequestOpts("POST", url, dashscopeHeaders(opts.apiKey), body, opts)))?.output?.task_id;
2056
+ if (!taskId) throw new Error("万象 文生视频:未返回 task_id");
2057
+ return {
2058
+ taskId,
2059
+ async: true,
2060
+ mediaType: "video",
2061
+ model: effectiveModel,
2062
+ ...droppedDuration ? { droppedDuration } : {}
2063
+ };
2064
+ }
2065
+ /** 查询任务状态。 */
2066
+ async function queryTask$3(taskId, opts) {
2067
+ const output = (await request(toRequestOpts("GET", `${opts.baseURL}/tasks/${taskId}`, queryHeaders(opts.apiKey), void 0, opts)))?.output;
2068
+ if (!output) return {
2069
+ status: "failed",
2070
+ error: "万象 查询返回为空"
2071
+ };
2072
+ switch (output.task_status) {
2073
+ case "PENDING":
2074
+ case "RUNNING": return { status: output.task_status === "PENDING" ? "pending" : "running" };
2075
+ case "SUCCEEDED": {
2076
+ const imageUrl = output.results?.[0]?.url ?? output.image_url;
2077
+ const mediaUrl = output.video_url ?? imageUrl;
2078
+ if (!mediaUrl) return {
2079
+ status: "failed",
2080
+ error: "万象 任务成功但未返回媒体 URL"
2081
+ };
2082
+ return {
2083
+ status: "succeeded",
2084
+ mediaUrl
2085
+ };
2086
+ }
2087
+ case "FAILED": return {
2088
+ status: "failed",
2089
+ error: output.message ?? "万象 任务执行失败"
2090
+ };
2091
+ case "CANCELED": return {
2092
+ status: "failed",
2093
+ error: "万象 任务已取消"
2094
+ };
2095
+ case "UNKNOWN": return {
2096
+ status: "failed",
2097
+ error: "万象 任务不存在或已过期"
2098
+ };
2099
+ default: return {
2100
+ status: "failed",
2101
+ error: `万象 未知任务状态: ${output.task_status}`
2102
+ };
2103
+ }
2104
+ }
2105
+ /** 万象适配器实例。 */
2106
+ const wanxAdapter = {
2107
+ submitImage: submitImage$3,
2108
+ submitVideo: submitVideo$3,
2109
+ queryTask: queryTask$3
2110
+ };
2111
+ //#endregion
2112
+ //#region src/providers/seedance.ts
2113
+ /**
2114
+ * Seedance2.5 适配器:基于火山引擎方舟 Ark API。
2115
+ * 文生图/图生图走同步 /images/generations 接口(即时返回 URL,24h 有效需立即下载;
2116
+ * Seedream 4.0 起支持 image 参考图入参做图生图,3.0 不支持——带参考图时默认模型
2117
+ * 切换为 Seedream 4.0);文生视频走异步 /contents/generations/tasks 接口
2118
+ * (提交 → 轮询 → 下载)。鉴权统一 Bearer Token。
2119
+ * @module dsh-image-video/providers/seedance
2120
+ */
2121
+ /** Seedance 默认文生图模型(即梦/Seedream)。 */
2122
+ const DEFAULT_IMAGE_MODEL$2 = "doubao-seedream-3-0-t2i-250415";
2123
+ /** Seedance 图生图默认模型(Seedream 3.0 不支持 image 入参,4.0 起支持单图生图)。 */
2124
+ const DEFAULT_IMAGE_EDIT_MODEL$1 = "doubao-seedream-4-0-250828";
2125
+ /** Seedance 默认文生视频模型。 */
2126
+ const DEFAULT_VIDEO_MODEL$2 = "doubao-seedance-1-0-pro-250428";
2127
+ /** Ark API 请求头。 */
2128
+ function arkHeaders(apiKey) {
2129
+ return {
2130
+ "Content-Type": "application/json",
2131
+ "Authorization": `Bearer ${apiKey}`
2132
+ };
2133
+ }
2134
+ /**
2135
+ * 提交图片任务(同步接口,直接返回图片 URL)。带参考图走图生图:Seedream 4.0+ 的
2136
+ * image 入参(URL 或 data URL),显式关闭组图与水印;尺寸分隔符归一化为方舟要求的
2137
+ * `宽x高`(配置默认值为百炼风格的 `*`)。不带参考图为文生图。
2138
+ */
2139
+ async function submitImage$2(params, opts) {
2140
+ const url = `${opts.baseURL}/images/generations`;
2141
+ const effectiveModel = params.model ?? (params.image ? DEFAULT_IMAGE_EDIT_MODEL$1 : DEFAULT_IMAGE_MODEL$2);
2142
+ const body = {
2143
+ model: effectiveModel,
2144
+ prompt: params.prompt,
2145
+ ...params.image ? {
2146
+ image: params.image,
2147
+ sequential_image_generation: "disabled",
2148
+ watermark: false
2149
+ } : { watermark: false },
2150
+ ...params.size ? { size: normalizeImageSize(params.size) } : {},
2151
+ n: 1,
2152
+ response_format: "url"
2153
+ };
2154
+ const mediaUrl = (await request(toRequestOpts("POST", url, arkHeaders(opts.apiKey), body, opts)))?.data?.[0]?.url;
2155
+ if (!mediaUrl) throw new Error("Seedance 图片生成:未返回图片 URL");
2156
+ return {
2157
+ taskId: "",
2158
+ async: false,
2159
+ mediaUrl,
2160
+ mediaType: "image",
2161
+ model: effectiveModel
2162
+ };
2163
+ }
2164
+ /**
2165
+ * 提交视频任务(异步接口,返回任务 ID)。存在 image 时为首帧驱动的图生视频,
2166
+ * 按 Ark content 数组协议追加 image_url 块;resolution 为可选分辨率档位,按模型支持透传。
2167
+ */
2168
+ async function submitVideo$2(params, opts) {
2169
+ const url = `${opts.baseURL}/contents/generations/tasks`;
2170
+ const content = [{
2171
+ type: "text",
2172
+ text: params.prompt
2173
+ }];
2174
+ if (params.image) content.push({
2175
+ type: "image_url",
2176
+ image_url: { url: params.image }
2177
+ });
2178
+ const effectiveModel = params.model ?? DEFAULT_VIDEO_MODEL$2;
2179
+ const body = {
2180
+ model: effectiveModel,
2181
+ content,
2182
+ ...params.duration ? { duration: `${params.duration}s` } : {},
2183
+ ...params.resolution ? { resolution: params.resolution } : {}
2184
+ };
2185
+ const taskId = (await request(toRequestOpts("POST", url, arkHeaders(opts.apiKey), body, opts)))?.id;
2186
+ if (!taskId) throw new Error("Seedance 文生视频:未返回 task_id");
2187
+ return {
2188
+ taskId,
2189
+ async: true,
2190
+ mediaType: "video",
2191
+ model: effectiveModel
2192
+ };
2193
+ }
2194
+ /** 查询异步任务状态。 */
2195
+ async function queryTask$2(taskId, opts) {
2196
+ const data = await request(toRequestOpts("GET", `${opts.baseURL}/contents/generations/tasks/${taskId}`, arkHeaders(opts.apiKey), void 0, opts));
2197
+ switch (data?.status) {
2198
+ case "queued": return { status: "pending" };
2199
+ case "running":
2200
+ case "processing": return { status: "running" };
2201
+ case "succeeded": {
2202
+ const raw = data.content?.video_url;
2203
+ const videoUrl = typeof raw === "string" ? raw : raw?.url;
2204
+ if (!videoUrl) return {
2205
+ status: "failed",
2206
+ error: "Seedance 任务成功但未返回视频 URL"
2207
+ };
2208
+ return {
2209
+ status: "succeeded",
2210
+ mediaUrl: videoUrl
2211
+ };
2212
+ }
2213
+ case "failed": return {
2214
+ status: "failed",
2215
+ error: data.error?.message ?? "Seedance 任务执行失败"
2216
+ };
2217
+ default: return {
2218
+ status: "failed",
2219
+ error: `Seedance 未知任务状态: ${data?.status ?? "空"}`
2220
+ };
2221
+ }
2222
+ }
2223
+ /** Seedance 适配器实例。 */
2224
+ const seedanceAdapter = {
2225
+ submitImage: submitImage$2,
2226
+ submitVideo: submitVideo$2,
2227
+ queryTask: queryTask$2
2228
+ };
2229
+ //#endregion
2230
+ //#region src/providers/threerouter.ts
2231
+ /**
2232
+ * Threerouter 适配器:基于 threerouter.com 统一媒体生成 API。
2233
+ * 生图(文生图 + 图生图)两条传输:
2234
+ * 1. 异步(推荐,`imageTransport: async`)——POST /images/generations/async 返回
2235
+ * 202 + task_id,GET /images/tasks/{task_id} 轮询,响应丢失时
2236
+ * GET /images/generations/by-request/{request_id} 凭幂等键找回原任务。
2237
+ * 提交携带 `Idempotency-Key`,服务端保证同一键只创建一次任务。
2238
+ * 2. 同步(`imageTransport: sync` / 异步端点在本环境不可用时降级)——
2239
+ * POST /images/generations 直接返回 OpenAI 标准结构 data[0].url / b64_json,
2240
+ * 兼容统一入口顶层 url / urls 形态。同步路径无幂等保证,因此**永不自动重提**。
2241
+ * 视频走 POST /media/generations 创建 → GET /media/{id} 轮询 → GET /media/{id}/content 下载,
2242
+ * 请求显式携带 media_kind=video。鉴权统一 Bearer Token。
2243
+ * 服务端契约见 docs/threerouter-async-image-contract.md。
2244
+ * @module dsh-image-video/providers/threerouter
2245
+ */
2246
+ /** Threerouter 默认文生图模型(千问图像 3.0,服务端模态注册表收录;旧默认 wan2.1-image 未收录会按文本派发)。 */
2247
+ const DEFAULT_IMAGE_MODEL$1 = "qwen-image-3.0";
2248
+ /** Threerouter 图生图默认模型(千问图像 3.0 Pro,支持 image 参考图入参,文档快速上手示例同款)。 */
2249
+ const DEFAULT_IMAGE_EDIT_MODEL = "qwen-image-3.0-pro";
2250
+ /** Threerouter 默认文生视频模型(MiniMax 系,支持 4-15 秒自定义时长)。账号可用模型见 threerouter.com 控制台。 */
2251
+ const DEFAULT_VIDEO_MODEL$1 = "minimax-h3";
2252
+ /**
2253
+ * 阿里系(千问/万相)模型判定:这些上游要求 `宽*高` 原生格式,比例写法需在
2254
+ * 客户端换算(共享 aliImageSize,见 types.ts);其余模型保持 `*`→`x` 归一化。
2255
+ */
2256
+ function isAliNativeImageModel(model) {
2257
+ return /qwen|wan/i.test(model);
2258
+ }
2259
+ /**
2260
+ * 幂等键请求头。服务端契约:`Idempotency-Key` 头优先于 body 的 `request_id`。
2261
+ * 客户端只发头、不发 body 字段——同步端点的请求体会原样转发上游,而 OpenAI 系
2262
+ * 上游对未知顶层参数是严格拒绝的(`Unrecognized request argument`),
2263
+ * 只有异步端点会在下发前摘掉 `request_id`。
2264
+ */
2265
+ function threerouterHeaders(apiKey, requestId) {
2266
+ return {
2267
+ "Content-Type": "application/json",
2268
+ "Authorization": `Bearer ${apiKey}`,
2269
+ ...requestId ? { "Idempotency-Key": requestId } : {}
2270
+ };
2271
+ }
2272
+ /**
2273
+ * 生图请求体:同步 `/images/generations` 与异步 `/images/generations/async`
2274
+ * 使用完全相同的 payload(服务端契约:异步端点接受与同步端点同样的请求体)。
2275
+ */
2276
+ function imageRequestBody(params, effectiveModel) {
2277
+ return {
2278
+ model: effectiveModel,
2279
+ prompt: params.prompt,
2280
+ n: 1,
2281
+ response_format: "url",
2282
+ ...params.images !== void 0 && params.images.length > 0 ? {
2283
+ image: params.images[0],
2284
+ image_urls: params.images
2285
+ } : params.image ? { image: params.image } : {},
2286
+ ...params.size ? { size: isAliNativeImageModel(effectiveModel) ? aliImageSize(params.size) : normalizeImageSize(params.size) } : {}
2287
+ };
2288
+ }
2289
+ /** 生图实际使用的模型:显式 model > 参考图默认(Pro)> 文生图默认。 */
2290
+ function effectiveImageModel(params) {
2291
+ return params.model ?? (params.image ? DEFAULT_IMAGE_EDIT_MODEL : DEFAULT_IMAGE_MODEL$1);
2292
+ }
2293
+ /** 从任务响应里取图片 URL:优先顶层 image_url,再回退 result.data[0].url。 */
2294
+ function imageTaskMediaUrl(data) {
2295
+ const top = typeof data?.image_url === "string" && data.image_url !== "" ? data.image_url : void 0;
2296
+ if (top !== void 0) return top;
2297
+ const first = data?.result?.data?.[0]?.url;
2298
+ return typeof first === "string" && first !== "" ? first : void 0;
2299
+ }
2300
+ /**
2301
+ * 把网关返回的失败对象拼成可读错误:`{type, code, message}` 三件套按有的拼,
2302
+ * 并带上 http_status(异步任务失败时上游状态码在这里,不在任务状态里)。
2303
+ */
2304
+ function extractImageTaskError(data) {
2305
+ const error = data?.error;
2306
+ let detail = "";
2307
+ if (typeof error === "string") detail = error;
2308
+ else if (error !== null && typeof error === "object") {
2309
+ const obj = error;
2310
+ detail = [typeof obj.code === "string" ? obj.code : typeof obj.type === "string" ? obj.type : "", typeof obj.message === "string" ? obj.message : ""].filter((part) => part !== "").join(": ");
2311
+ }
2312
+ if (detail === "") detail = "Threerouter 图片任务执行失败";
2313
+ return data?.http_status ? `${detail}(上游 HTTP ${data.http_status})` : detail;
2314
+ }
2315
+ /**
2316
+ * 提交图片任务(文生图 + 图生图统一端点 /images/generations,同步返回)。
2317
+ * 参考图经 `image` 字段传入(data URL 或公网 URL,服务端亦接受 image_url / image_urls
2318
+ * 数组形态,此处用最简的单字符串形态);响应兼容 OpenAI 标准结构(data[0].url /
2319
+ * b64_json)、统一入口顶层 url / urls、失败形态(status:'failed' + error)与异步
2320
+ * 任务形态(status:'processing' + id,交由工具层轮询 GET /media/{id})。
2321
+ * size:qwen 系按上游要求归一化为 `宽*高`(实测 `3:4` 原样透传会被 400),
2322
+ * 其余模型 `*`→`x`(配置默认值为百炼风格);超时对齐官方建议抬到 ≥600s
2323
+ * (千问图像 prompt_extend + 思考模式实测约 5 分钟出图)。
2324
+ *
2325
+ * 重试策略:**同步生图请求永不自动重试**(`retryTimes: 0`)。此端点一旦提交,
2326
+ * 上游可能已开始生成并计费;客户端超时/断连时无法确认结果,重提就是重复扣费。
2327
+ * 需要幂等与超时找回时请走 `imageTransport: async`。
2328
+ */
2329
+ async function submitImage$1(params, opts) {
2330
+ const url = `${opts.baseURL}/images/generations`;
2331
+ const effectiveModel = effectiveImageModel(params);
2332
+ const reqOpts = toRequestOpts("POST", url, threerouterHeaders(opts.apiKey, params.requestId), imageRequestBody(params, effectiveModel), {
2333
+ ...opts,
2334
+ retryTimes: 0
2335
+ });
2336
+ reqOpts.timeoutMs = Math.max(reqOpts.timeoutMs, 6e5);
2337
+ const data = await request(reqOpts);
2338
+ const first = data?.data?.[0];
2339
+ const topLevelUrl = data?.url ?? (Array.isArray(data?.urls) ? data.urls[0] : void 0);
2340
+ const mediaUrl = first?.url ?? (typeof topLevelUrl === "string" && topLevelUrl !== "" ? topLevelUrl : void 0);
2341
+ const base = {
2342
+ taskId: "",
2343
+ async: false,
2344
+ mediaType: "image",
2345
+ model: effectiveModel
2346
+ };
2347
+ if (mediaUrl) return {
2348
+ ...base,
2349
+ mediaUrl
2350
+ };
2351
+ if (first?.b64_json) return {
2352
+ ...base,
2353
+ mediaBase64: {
2354
+ data: first.b64_json,
2355
+ mediaType: "image/png"
2356
+ }
2357
+ };
2358
+ const errMsg = typeof data?.error === "string" ? data.error : data?.error !== null && typeof data?.error === "object" && typeof data.error.message === "string" ? data.error.message : "";
2359
+ if (data?.status === "failed") throw new Error(`Threerouter 生图:上游失败。${errMsg}`);
2360
+ if (data?.id && (!data.status || data.status === "processing" || data.status === "pending")) return {
2361
+ taskId: data.id,
2362
+ async: true,
2363
+ mediaType: "image",
2364
+ model: effectiveModel
2365
+ };
2366
+ throw new Error(params.image ? "Threerouter 图生图:响应未包含图片数据" : "Threerouter 文生图:响应未包含图片数据");
2367
+ }
2368
+ /**
2369
+ * 异步提交图片任务:`POST /images/generations/async` → 202 + task_id。
2370
+ *
2371
+ * 与同步提交的本质区别:**服务端在返回 202 时就已落库任务**,生成脱离客户端
2372
+ * 连接继续执行,因此提交响应丢失(超时/断连/502)时任务仍然存在,可凭
2373
+ * `Idempotency-Key` 反查找回(见 findByRequestImageTask)——这正是「绝不重复生成」
2374
+ * 的落点。请求本身携带幂等键,重复提交也只会返回同一个 task_id。
2375
+ *
2376
+ * `retryTimes: 0`:提交是「可能创建任务」的写操作,任何自动重试都由事务层
2377
+ * 在确认状态后显式决定,不能藏在 HTTP 层。
2378
+ */
2379
+ async function submitImageAsync(params, opts) {
2380
+ const url = `${opts.baseURL}/images/generations/async`;
2381
+ const effectiveModel = effectiveImageModel(params);
2382
+ const res = await requestFull(toRequestOpts("POST", url, threerouterHeaders(opts.apiKey, params.requestId), imageRequestBody(params, effectiveModel), {
2383
+ ...opts,
2384
+ retryTimes: 0
2385
+ }));
2386
+ const data = res.data;
2387
+ const taskId = typeof data?.task_id === "string" && data.task_id !== "" ? data.task_id : typeof data?.id === "string" ? data.id : "";
2388
+ if (taskId === "") throw new GenerationError("task", "Threerouter 异步生图:202 响应未包含 task_id,无法轮询(服务端契约异常)", false, res.status);
2389
+ const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
2390
+ return {
2391
+ taskId,
2392
+ model: effectiveModel,
2393
+ ...typeof data?.request_id === "string" && data.request_id !== "" ? { requestId: data.request_id } : {},
2394
+ ...res.headers.get("x-idempotency-replayed") === "true" ? { replayed: true } : {},
2395
+ ...retryAfterMs !== void 0 ? { retryAfterSec: Math.max(1, Math.round(retryAfterMs / 1e3)) } : {}
2396
+ };
2397
+ }
2398
+ /**
2399
+ * 查询异步图片任务:`GET /images/tasks/{task_id}`。
2400
+ * 状态口径以服务端契约为准:`processing` → 继续轮询;`succeeded`(旧值
2401
+ * `completed`,同时出现在 legacy_status)→ 取 image_url / result.data[0].url;
2402
+ * `failed` → 带出 error 与 http_status。`accepted`/`queued`/`cancelled` 服务端
2403
+ * 不会产生,出现时按运行中/失败保守处理而不是空等。
2404
+ */
2405
+ async function queryImageTask(taskId, opts) {
2406
+ const data = await request(toRequestOpts("GET", `${opts.baseURL}/images/tasks/${encodeURIComponent(taskId)}`, threerouterHeaders(opts.apiKey), void 0, opts));
2407
+ switch (data?.status) {
2408
+ case "processing":
2409
+ case "pending": return { status: "running" };
2410
+ case "succeeded":
2411
+ case "completed": {
2412
+ const mediaUrl = imageTaskMediaUrl(data);
2413
+ if (mediaUrl === void 0) return {
2414
+ status: "failed",
2415
+ error: `Threerouter 图片任务已完成但未返回图片 URL(task ${taskId})`
2416
+ };
2417
+ return {
2418
+ status: "succeeded",
2419
+ mediaUrl
2420
+ };
2421
+ }
2422
+ case "failed":
2423
+ case "cancelled": return {
2424
+ status: "failed",
2425
+ error: extractImageTaskError(data)
2426
+ };
2427
+ default: return {
2428
+ status: "failed",
2429
+ error: `Threerouter 未知图片任务状态: ${data?.status ?? "空"}(task ${taskId})`
2430
+ };
2431
+ }
2432
+ }
2433
+ /**
2434
+ * 凭幂等键找回原任务:`GET /images/generations/by-request/{request_id}`。
2435
+ *
2436
+ * 提交响应因超时/断连/502 丢失时,客户端手里只剩幂等键——这是唯一能在
2437
+ * **不重新生成**的前提下确认任务是否存在并继续等待的通道。服务端契约:
2438
+ * 200 返回与轮询端点相同的任务对象(含 request_id);404 表示该键无记录
2439
+ * (从未成功提交,或记录已过期)。404 返回 undefined 而不是抛错,由上层
2440
+ * 的事务策略决定下一步(默认响亮失败并要求用户确认,而不是自动重提)。
2441
+ */
2442
+ async function findByRequestImageTask(requestId, opts) {
2443
+ const url = `${opts.baseURL}/images/generations/by-request/${encodeURIComponent(requestId)}`;
2444
+ try {
2445
+ const data = await request(toRequestOpts("GET", url, threerouterHeaders(opts.apiKey), void 0, opts));
2446
+ const taskId = typeof data?.task_id === "string" && data.task_id !== "" ? data.task_id : typeof data?.id === "string" ? data.id : "";
2447
+ return taskId === "" ? void 0 : { taskId };
2448
+ } catch (err) {
2449
+ if (err instanceof GenerationError && err.status === 404) return void 0;
2450
+ throw err;
2451
+ }
2452
+ }
2453
+ /**
2454
+ * 提交视频任务(media_kind=video)。存在 image 时为首帧驱动的图生视频,
2455
+ * 请求携带 image 字段(服务端按此字段路由到图生视频通道),此时构图由首帧决定,不再传 ratio;
2456
+ * 纯文生时保持 ratio。resolution 为可选分辨率档位,MiniMax 系要求显式携带(缺失时上游 400),
2457
+ * 未指定时注入模型默认档位;wan 系模型不支持自定义时长,命中能力表时丢弃 duration
2458
+ * (SubmitResult.droppedDuration 标记,结果层在 notes 注明)。
2459
+ */
2460
+ async function submitVideo$1(params, opts) {
2461
+ const url = `${opts.baseURL}/media/generations`;
2462
+ const effectiveModel = params.model ?? DEFAULT_VIDEO_MODEL$1;
2463
+ const body = {
2464
+ model: effectiveModel,
2465
+ prompt: params.prompt,
2466
+ media_kind: "video"
2467
+ };
2468
+ const droppedDuration = VIDEO_DURATION_UNSUPPORTED.has(effectiveModel);
2469
+ if (!droppedDuration) body.duration = params.duration;
2470
+ if (params.media) body.media = params.media.map(({ url: mediaUrl, type, position }) => ({
2471
+ type: type ?? (position?.toLowerCase() === "first_frame" || position === "0s" ? "first_frame" : position?.toLowerCase() === "last_frame" ? "last_frame" : "reference_image"),
2472
+ url: mediaUrl
2473
+ }));
2474
+ else if (params.image) body.image = params.image;
2475
+ else if (params.aspectRatio) body.ratio = params.aspectRatio;
2476
+ const resolution = params.resolution ?? VIDEO_MODEL_DEFAULT_RESOLUTION[effectiveModel];
2477
+ if (resolution) body.resolution = resolution;
2478
+ const reqOpts = toRequestOpts("POST", url, threerouterHeaders(opts.apiKey), body, opts);
2479
+ reqOpts.timeoutMs = Math.max(reqOpts.timeoutMs, 3e5);
2480
+ const data = await request(reqOpts);
2481
+ if (!data?.id) throw new Error("Threerouter 文生视频:未返回任务 ID");
2482
+ return {
2483
+ taskId: data.id,
2484
+ async: true,
2485
+ mediaType: "video",
2486
+ model: effectiveModel,
2487
+ ...droppedDuration ? { droppedDuration } : {}
2488
+ };
2489
+ }
2490
+ /** 查询异步视频任务状态。完成后优先使用响应 url,缺失时回退 /content 302 端点。 */
2491
+ async function queryTask$1(taskId, opts) {
2492
+ const data = await request(toRequestOpts("GET", `${opts.baseURL}/media/${taskId}`, threerouterHeaders(opts.apiKey), void 0, opts));
2493
+ switch (data?.status) {
2494
+ case "processing": return { status: "running" };
2495
+ case "succeeded": return {
2496
+ status: "succeeded",
2497
+ mediaUrl: typeof data.url === "string" && data.url.length > 0 ? data.url : `${opts.baseURL}/media/${taskId}/content`
2498
+ };
2499
+ case "failed": return {
2500
+ status: "failed",
2501
+ error: extractVideoTaskError(data.error)
2502
+ };
2503
+ case "cancelled": return {
2504
+ status: "failed",
2505
+ error: "Threerouter 任务已取消"
2506
+ };
2507
+ default: return {
2508
+ status: "failed",
2509
+ error: `Threerouter 未知任务状态: ${data?.status ?? "空"}`
2510
+ };
2511
+ }
2512
+ }
2513
+ /** 从视频任务失败响应中提取错误信息,兼容 string 与对象两种形态。 */
2514
+ function extractVideoTaskError(error) {
2515
+ if (typeof error === "string" && error.length > 0) return error;
2516
+ if (error !== null && typeof error === "object") {
2517
+ const obj = error;
2518
+ if (typeof obj.message === "string") return obj.message;
2519
+ }
2520
+ return "Threerouter 任务执行失败";
2521
+ }
2522
+ /** Threerouter 适配器实例:统一入口同时支持文生图与文生视频。 */
2523
+ const threerouterAdapter = {
2524
+ submitImage: submitImage$1,
2525
+ submitVideo: submitVideo$1,
2526
+ queryTask: queryTask$1,
2527
+ imageAsync: {
2528
+ submit: submitImageAsync,
2529
+ query: queryImageTask,
2530
+ findByRequest: findByRequestImageTask
2531
+ }
2532
+ };
2533
+ //#endregion
2534
+ //#region src/providers/minimax.ts
2535
+ /**
2536
+ * MiniMax 官方平台适配器:视频基于 video-generation v2 API,图片基于
2537
+ * image_generation API(同步返回 base64,无下载 URL)。
2538
+ * 创建走 POST /video_generation(异步,返回 task_id),轮询走
2539
+ * GET /query/video_generation,完成后经 file.download_url(或 files/retrieve
2540
+ * 换取下载地址)落盘。鉴权统一 Bearer Token。
2541
+ * 注意:本适配器按官方文档实现(2026-09),尚无平台 key 实测——字段语义以
2542
+ * https://platform.minimaxi.com/docs/api-reference 为准,
2543
+ * 取得 MINIMAX_API_KEY 后应在真实账号上验证一轮。
2544
+ * 图片:文生图 + subject_reference 主体一致性图生图(当前每次仅支持 1 张参考图),
2545
+ * 响应为 data.image_base64(裸 base64,无 URL 模式),结果层直接落盘。
2546
+ * @module dsh-image-video/providers/minimax
2547
+ */
2548
+ /** MiniMax 官方默认文生视频模型(Hailuo 系)。 */
2549
+ const DEFAULT_VIDEO_MODEL = "MiniMax-Hailuo-02";
2550
+ /** MiniMax 官方默认图片模型(image_generation)。 */
2551
+ const DEFAULT_IMAGE_MODEL = "image-01";
2552
+ /** 未显式指定分辨率时的默认档位(Hailuo-02 支持 768P/1080P,768P 档时长兼容性最好)。 */
2553
+ const DEFAULT_RESOLUTION = "768P";
2554
+ /** 从 "宽*高"/"宽x高" 尺寸推导 MiniMax 的 aspect_ratio(约分),无法解析回落 "1:1"。 */
2555
+ function deriveAspectRatio(size) {
2556
+ const match = /^(\d+)[*x](\d+)$/i.exec(size.trim());
2557
+ if (!match || match[1] === void 0 || match[2] === void 0) return "1:1";
2558
+ const width = Number(match[1]);
2559
+ const height = Number(match[2]);
2560
+ if (!width || !height) return "1:1";
2561
+ let a = width;
2562
+ let b = height;
2563
+ while (b !== 0) {
2564
+ const t = b;
2565
+ b = a % b;
2566
+ a = t;
2567
+ }
2568
+ return `${width / a}:${height / a}`;
2569
+ }
2570
+ /** MiniMax API 请求头。 */
2571
+ function minimaxHeaders(apiKey) {
2572
+ return {
2573
+ "Content-Type": "application/json",
2574
+ "Authorization": `Bearer ${apiKey}`
2575
+ };
2576
+ }
2577
+ /** 校验 base_resp,非零状态码按 task 级错误抛出(消息保留原始 status_msg 供分类器判定)。 */
2578
+ function assertBaseResp(baseResp, api) {
2579
+ const code = baseResp?.status_code;
2580
+ if (code !== void 0 && code !== 0) throw new GenerationError("task", `MiniMax ${api} 业务错误(${code}):${baseResp?.status_msg ?? "未提供错误说明"}`, false);
2581
+ }
2582
+ /**
2583
+ * 提交图片任务(同步接口,直接返回 base64)。带参考图走 subject_reference
2584
+ * 主体一致性图生图(type: character,当前每次仅支持 1 张参考图,保留主体特征
2585
+ * 按 prompt 换场景);aspect_ratio 由尺寸约分推导(MiniMax 无像素尺寸概念)。
2586
+ * 注意:官方示例 image_file 仅展示网络 URL,本地图片转 data URL 传入待实测。
2587
+ */
2588
+ async function submitImage(params, opts) {
2589
+ const url = `${opts.baseURL}/image_generation`;
2590
+ const effectiveModel = params.model ?? DEFAULT_IMAGE_MODEL;
2591
+ const body = {
2592
+ model: effectiveModel,
2593
+ prompt: params.prompt,
2594
+ aspect_ratio: deriveAspectRatio(params.size),
2595
+ response_format: "base64",
2596
+ ...params.image ? { subject_reference: [{
2597
+ type: "character",
2598
+ image_file: params.image
2599
+ }] } : {}
2600
+ };
2601
+ const data = await request(toRequestOpts("POST", url, minimaxHeaders(opts.apiKey), body, opts));
2602
+ assertBaseResp(data?.base_resp, "image_generation");
2603
+ const base64 = data?.data?.image_base64?.[0];
2604
+ if (!base64) throw new Error("MiniMax 图片生成:未返回图片数据");
2605
+ return {
2606
+ taskId: "",
2607
+ async: false,
2608
+ mediaType: "image",
2609
+ model: effectiveModel,
2610
+ mediaBase64: {
2611
+ data: base64,
2612
+ mediaType: "image/jpeg"
2613
+ }
2614
+ };
2615
+ }
2616
+ /**
2617
+ * 提交视频任务(异步)。存在 image 时为首帧驱动的图生视频(first_frame_image);
2618
+ * resolution 缺省注入 768P;duration 透传(Hailuo 系支持自定义时长档位,
2619
+ * 不支持档位时上游报错,响亮失败不静默改写)。
2620
+ */
2621
+ async function submitVideo(params, opts) {
2622
+ const url = `${opts.baseURL}/video_generation`;
2623
+ const effectiveModel = params.model ?? DEFAULT_VIDEO_MODEL;
2624
+ const body = {
2625
+ model: effectiveModel,
2626
+ prompt: params.prompt,
2627
+ ...params.image ? { first_frame_image: params.image } : {},
2628
+ ...params.duration ? { duration: params.duration } : {},
2629
+ resolution: params.resolution ?? DEFAULT_RESOLUTION
2630
+ };
2631
+ const data = await request(toRequestOpts("POST", url, minimaxHeaders(opts.apiKey), body, opts));
2632
+ assertBaseResp(data?.base_resp, "video_generation");
2633
+ const taskId = data?.task_id;
2634
+ if (!taskId) throw new Error("MiniMax 文生视频:未返回 task_id");
2635
+ return {
2636
+ taskId,
2637
+ async: true,
2638
+ mediaType: "video",
2639
+ model: effectiveModel
2640
+ };
2641
+ }
2642
+ /** 查询异步任务状态;Success 时解析下载地址,必要时经 files/retrieve 二次换取。 */
2643
+ async function queryTask(taskId, opts) {
2644
+ const data = await request(toRequestOpts("GET", `${opts.baseURL}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, minimaxHeaders(opts.apiKey), void 0, opts));
2645
+ switch (data?.status) {
2646
+ case "Queueing": return { status: "pending" };
2647
+ case "Processing": return { status: "running" };
2648
+ case "Success": {
2649
+ let downloadUrl = data.file?.download_url;
2650
+ if (!downloadUrl && data.file_id !== void 0) downloadUrl = (await request(toRequestOpts("GET", `${opts.baseURL}/files/retrieve?file_id=${encodeURIComponent(String(data.file_id))}`, minimaxHeaders(opts.apiKey), void 0, opts)))?.file?.download_url;
2651
+ if (!downloadUrl) return {
2652
+ status: "failed",
2653
+ error: "MiniMax 任务成功但未返回视频下载地址"
2654
+ };
2655
+ return {
2656
+ status: "succeeded",
2657
+ mediaUrl: downloadUrl
2658
+ };
2659
+ }
2660
+ case "Fail": return {
2661
+ status: "failed",
2662
+ error: data.base_resp?.status_msg ?? data.fail_reason ?? "MiniMax 任务执行失败"
2663
+ };
2664
+ default: return {
2665
+ status: "failed",
2666
+ error: `MiniMax 未知任务状态: ${data?.status ?? "空"}`
2667
+ };
2668
+ }
2669
+ }
2670
+ /** MiniMax 适配器实例。 */
2671
+ const minimaxAdapter = {
2672
+ submitImage,
2673
+ submitVideo,
2674
+ queryTask
2675
+ };
2676
+ //#endregion
2677
+ //#region src/tools/generate-image.ts
2678
+ /**
2679
+ * 判断当前调用路由是否声明支持图片输入,与官方 read_image 的能力门一致:
2680
+ * 解析会话当前 provider/model 后经 llm 服务读取输入模态。任何环节缺服务或
2681
+ * 解析失败都视为「不支持」,从而回退纯文本摘要——绝不向纯文本模型注入 image
2682
+ * 块触发网关 400。@param ctx 插件上下文。@param exec 工具执行上下文。
2683
+ * @returns 路由是否声明了 image 输入模态。
2684
+ */
2685
+ async function routeAcceptsImages(ctx, exec) {
2686
+ const routed = exec.agent?.session.requestHeader()?.config;
2687
+ const provider = routed?.provider ?? exec.agent?.options.provider;
2688
+ const model = routed?.model ?? exec.agent?.options.model;
2689
+ const llm = ctx.get("llm");
2690
+ if (provider === void 0 || model === void 0 || llm === void 0) return false;
2691
+ try {
2692
+ return (await llm.resolveModelInfo(provider, model, exec.signal)).inputModalities?.includes("image") === true;
2693
+ } catch {
2694
+ return false;
2695
+ }
2696
+ }
2697
+ /**
2698
+ * 把工具输出里的 image 字段重建为 attachment 持久化引用,供 `image` 内容块携带。
2699
+ * execute 返回的是 schema 校验后的明文对象(attachmentId 为字符串),此处补上品牌化 ID
2700
+ * 并还原为 durable 引用,与官方 read_image 的 imageRefFromValue 保持一致。
2701
+ */
2702
+ function imageAttachmentRef(image) {
2703
+ return {
2704
+ attachmentId: AttachmentId(image.attachmentId),
2705
+ mediaType: image.mediaType,
2706
+ bytes: image.bytes,
2707
+ width: image.width,
2708
+ height: image.height,
2709
+ ...image.name === void 0 ? {} : { name: image.name }
2710
+ };
2711
+ }
2712
+ /**
2713
+ /**
2714
+ * 从当前会话最后一条用户消息读取已持久化图片,并转为服务商可消费的 data URL。
2715
+ * 粘贴图片进入对话后,输入框已经由宿主 attachment 服务持久化;这里不再要求用户
2716
+ * 复制路径,也不扫描历史图片,严格只取本轮最新用户消息中的图片,顺序保持不变。
2717
+ */
2718
+ async function resolveConversationImages(exec, attachments) {
2719
+ const latest = [...exec.agent?.session.deriveMessages() ?? []].reverse().find((message) => message.role === "user");
2720
+ if (latest === void 0) return [];
2721
+ const refs = latest.content.filter((block) => block.type === "image").map((block) => block.attachment);
2722
+ if (refs.length === 0) return [];
2723
+ const resolved = [];
2724
+ for (const ref of refs) {
2725
+ const stored = await attachments.readImage(ref, exec.signal);
2726
+ resolved.push(`data:${stored.ref.mediaType};base64,${Buffer.from(stored.data).toString("base64")}`);
2727
+ }
2728
+ return resolved;
2729
+ }
2730
+ /**
2731
+ * 创建 generate_image 工具定义。
2732
+ * 工具参数:prompt(必填,找回模式除外)、image(单图)、images(多参考图)、size、model、recoverRequestId。
2733
+ */
2734
+ function createGenerateImageTool(deps) {
2735
+ const { config, taskManager, attachments, ctx, runtimeDefaults, outputsDir } = deps;
2736
+ /**
2737
+ * 异步传输能力探测缓存(按服务商):某个服务商的异步端点被判为不可用后,
2738
+ * 在 TTL(10 分钟)内直接走同步,省掉每次调用一次的 404 往返;TTL 过后自动
2739
+ * 重新探测——服务端上线异步能力后无需重启客户端即可切换。
2740
+ */
2741
+ const transportProbe = /* @__PURE__ */ new Map();
2742
+ const adapterFor = (p) => p === "threerouter" ? threerouterAdapter : p === "wanx" ? wanxAdapter : p === "minimax" ? minimaxAdapter : seedanceAdapter;
2743
+ /** 轮询实现:异步传输查网关图片任务端点,同步传输沿用适配器自身的任务查询。 */
2744
+ const pollFor = (adapter, transport, httpOpts) => {
2745
+ const api = adapter.imageAsync;
2746
+ const query = transport === "async" && api !== void 0 ? (taskId, opts) => api.query(taskId, opts) : adapter;
2747
+ return async (taskId, signal) => await taskManager.pollUntilDone(taskId, query, {
2748
+ ...httpOpts,
2749
+ ...signal === void 0 ? {} : { signal }
2750
+ }, signal);
2751
+ };
2752
+ /**
2753
+ * 尝试一个候选服务商:预检查 → 单次提交(async 优先)→ 必要时降级同步。
2754
+ * 失败时把事务账本一并带回,让调用方按 `canFallbackToNextProvider` 判定
2755
+ * 「能否安全换家」——未知状态绝不允许换家。
2756
+ */
2757
+ const attemptCandidate = async (candidate, input) => {
2758
+ const { requestId, model, size, imageReference, imageReferences, prompt, exec, notes } = input;
2759
+ const adapter = adapterFor(candidate);
2760
+ const creds = resolveProviderCredentials(config, candidate);
2761
+ const httpOpts = {
2762
+ apiKey: creds.apiKey,
2763
+ baseURL: creds.baseURL,
2764
+ timeoutMs: config.timeoutMs,
2765
+ retryTimes: config.retryTimes,
2766
+ signal: exec.signal
2767
+ };
2768
+ const configuredTransport = resolveImageTransport(config.imageTransport, adapter, transportProbe.get(candidate));
2769
+ if (config.imageTransport === "auto" && configuredTransport === "sync" && adapter.imageAsync !== void 0) notes.push("该服务商的异步图片端点近期探测为不可用(生图功能未开启或分组平台不支持),本次直接走同步单次提交;服务端恢复后 10 分钟内自动切回异步");
2770
+ const preflight = resolveImagePreflight({
2771
+ provider: candidate,
2772
+ model,
2773
+ size,
2774
+ hasReferenceImage: imageReference !== void 0 || imageReferences.length > 0,
2775
+ transport: configuredTransport,
2776
+ adapterSupportsAsync: adapter.imageAsync !== void 0,
2777
+ config
2778
+ });
2779
+ notes.push(formatPreflightNote(preflight));
2780
+ const params = {
2781
+ prompt,
2782
+ size,
2783
+ model,
2784
+ requestId,
2785
+ ...imageReferences.length > 0 ? { images: imageReferences } : imageReference === void 0 ? {} : { image: imageReference }
2786
+ };
2787
+ const run = async (transport, tx) => await runImageTransaction({
2788
+ tx,
2789
+ adapter,
2790
+ params,
2791
+ httpOpts,
2792
+ poll: pollFor(adapter, transport, httpOpts),
2793
+ unknownStatePolicy: config.imageUnknownStatePolicy === "resubmit-same-key" ? "resubmit-same-key" : "fail",
2794
+ recovery: DEFAULT_RECOVERY_BUDGET,
2795
+ onNote: (note) => {
2796
+ notes.push(note);
2797
+ }
2798
+ });
2799
+ let tx = createImageTransaction({
2800
+ requestId,
2801
+ provider: candidate,
2802
+ model,
2803
+ transport: configuredTransport
2804
+ });
2805
+ try {
2806
+ return {
2807
+ ok: true,
2808
+ result: (await run(configuredTransport, tx)).submit,
2809
+ tx,
2810
+ preflight
2811
+ };
2812
+ } catch (err) {
2813
+ if (err instanceof AsyncTransportUnavailableError && config.imageTransport === "auto") {
2814
+ transportProbe.set(candidate, { unavailableUntil: Date.now() + TRANSPORT_PROBE_TTL_MS });
2815
+ notes.push(`异步图片端点不可用,已降级为同步单次提交:${err.message}`);
2816
+ notes.push("降级说明:同步端点没有幂等键记录,因此不会自动重试,提交超时后也不会重新生成");
2817
+ const syncTx = createImageTransaction({
2818
+ requestId,
2819
+ provider: candidate,
2820
+ model,
2821
+ transport: "sync"
2822
+ });
2823
+ try {
2824
+ return {
2825
+ ok: true,
2826
+ result: (await run("sync", syncTx)).submit,
2827
+ tx: syncTx,
2828
+ preflight
2829
+ };
2830
+ } catch (syncErr) {
2831
+ return {
2832
+ ok: false,
2833
+ error: syncErr,
2834
+ tx: syncTx
2835
+ };
2836
+ }
2837
+ }
2838
+ return {
2839
+ ok: false,
2840
+ error: err,
2841
+ tx
2842
+ };
2843
+ }
2844
+ };
2845
+ /**
2846
+ * 交付结果:内存下载 → 水印后处理 → 只落盘最终图 → 写最终字节附件。
2847
+ * 图片生成与「凭 request_id 找回」共用本函数,保证两条路径的产出一致。
2848
+ */
2849
+ const deliverImage = async (source, exec, notes) => {
2850
+ if (source.mediaUrl === void 0 && source.mediaBase64 === void 0) throw new Error("生成失败:服务端既未返回图片 URL 也未返回图片数据");
2851
+ const media = source.mediaBase64 !== void 0 ? decodeBase64Media(source.mediaBase64.data, source.mediaBase64.mediaType) : await fetchMediaBytes(source.mediaUrl, {
2852
+ timeoutMs: config.timeoutMs,
2853
+ retryTimes: config.retryTimes,
2854
+ signal: exec.signal
2855
+ });
2856
+ const watermark = await applyImageWatermark(media.data, media.contentType, config.watermark);
2857
+ if (watermark.note !== void 0) notes.push(watermark.note);
2858
+ return {
2859
+ saved: await writeOutputFile(watermark.data, watermark.contentType, outputsDir, ".png", source.mediaUrl ?? ""),
2860
+ postprocess: watermark.applied ? [`品牌水印 ${config.watermark.text}(${config.watermark.position})`] : []
2861
+ };
2862
+ };
2863
+ return defineTool({
2864
+ name: "generate_image",
2865
+ description: "根据文本提示词生成图片;传入 image 参考图即为图生图(threerouter 统一生图端点按提示词编辑、Seedream 参考编辑、MiniMax 保留主体特征换场景——主体一致性)。服务商选择:配置链服务商优先(composer 会话选定 > 配置默认服务商 > 激活服务商,默认 threerouter 聚合器);显式指定模型时按模型家族自动路由(wan/wanx→百炼直连,doubao/seedream/seedance→火山方舟,minimax→MiniMax 官方),候选仅在「服务端明确拒绝该模型且未创建任务」时按序回退,threerouter 永远兜底。一次调用只提交一次生成请求:默认走 threerouter 异步图片端点(携带幂等键),提交结果未知时凭同一 request_id 找回原任务,绝不自动重提、绝不自动换模型、不生成多张候选;需要多个版本时请分别发起多次调用。生成结果经品牌水印后处理(可配置)后保存到客户端统一 outputs 目录(对话内附最终图片附件)。不要自行编写脚本、不要直接调用服务商 API、不要把结果写到 outputs 之外。参数:prompt(提示词,必填;仅找回模式可省略)、image(单张参考图本地路径/URL,可选); images(多参考图数组,直接粘贴到对话的图片会自动作为参考图,首图为底图,其余为脸部/风格参考)、size(尺寸或比例,可选,默认 3:4;qwen/wan 系自动换算为宽*高)、model(模型名,可选,留空用配置 defaultImageModel 或服务商内置默认模型)、recoverRequestId(可选:凭上一次调用的 request_id 找回结果,不产生新的生成请求)。",
2866
+ parameters: {
2867
+ prompt: {
2868
+ type: "string",
2869
+ description: "描述要生成的图片内容,支持中英文。仅当使用 recoverRequestId 找回模式时可省略。"
2870
+ },
2871
+ image: {
2872
+ type: "string",
2873
+ description: "单张参考图(本地文件路径或 http(s) URL)。"
2874
+ },
2875
+ images: {
2876
+ type: "array",
2877
+ items: { type: "string" },
2878
+ description: "多张参考图路径/URL;首图为底图,其余按顺序作为编辑、脸部或风格参考。若省略,自动读取本轮对话中粘贴的图片。"
2879
+ },
2880
+ size: {
2881
+ type: "string",
2882
+ description: "图片尺寸,如 1024*1024、1280*720。留空使用配置默认值。"
2883
+ },
2884
+ model: {
2885
+ type: "string",
2886
+ description: "指定模型名称。留空使用配置 defaultImageModel 或服务商默认模型。"
2887
+ },
2888
+ recoverRequestId: {
2889
+ type: "string",
2890
+ description: "仅用于找回:上一次调用返回的 request_id。凭它取回已提交任务的结果(服务端保留 24 小时),不会重新生成。"
2891
+ }
2892
+ },
2893
+ output: {
2894
+ schema: {
2895
+ type: "object",
2896
+ additionalProperties: false,
2897
+ properties: {
2898
+ provider: {
2899
+ type: "string",
2900
+ required: true
2901
+ },
2902
+ prompt: {
2903
+ type: "string",
2904
+ description: "本次实际使用的提示词;找回模式下为服务端任务回显值或空串。"
2905
+ },
2906
+ mode: {
2907
+ type: "string",
2908
+ enum: ["text-to-image", "image-to-image"],
2909
+ required: true
2910
+ },
2911
+ localPath: {
2912
+ type: "string",
2913
+ required: true
2914
+ },
2915
+ sourceUrl: {
2916
+ type: "string",
2917
+ required: true
2918
+ },
2919
+ bytes: {
2920
+ type: "integer",
2921
+ required: true
2922
+ },
2923
+ model: {
2924
+ type: "string",
2925
+ description: "实际发给上游的模型名(含服务商内置默认),供用户核对本次到底用了哪个模型。"
2926
+ },
2927
+ transport: {
2928
+ type: "string",
2929
+ enum: ["async", "sync"],
2930
+ description: "本次使用的提交传输:async=网关异步任务(有幂等与找回),sync=同步单次提交。"
2931
+ },
2932
+ submitAttempts: {
2933
+ type: "integer",
2934
+ description: "本次生成事务的物理提交次数:正常为 1,仅同键重提策略下为 2(服务端按键去重)。"
2935
+ },
2936
+ recoveryLookups: {
2937
+ type: "integer",
2938
+ description: "凭 request_id 反查原任务的次数(只读查询,不计费)。"
2939
+ },
2940
+ requestId: {
2941
+ type: "string",
2942
+ description: "本次生成事务的幂等键:可用 recoverRequestId 参数找回结果。"
2943
+ },
2944
+ taskId: {
2945
+ type: "string",
2946
+ description: "服务端任务 ID(async 传输或服务商异步任务形态下有值)。"
2947
+ },
2948
+ postprocess: {
2949
+ type: "array",
2950
+ items: { type: "string" },
2951
+ description: "后处理链路(如品牌水印);无后处理时省略。"
2952
+ },
2953
+ notes: {
2954
+ type: "array",
2955
+ items: { type: "string" }
2956
+ },
2957
+ image: {
2958
+ type: "object",
2959
+ additionalProperties: false,
2960
+ description: "内嵌图片附件引用(模型可见)。路由支持图片输入时 render 一并注入 image 块使对话内嵌显示;否则省略。",
2961
+ properties: {
2962
+ attachmentId: {
2963
+ type: "string",
2964
+ required: true
2965
+ },
2966
+ mediaType: {
2967
+ type: "string",
2968
+ enum: [
2969
+ "image/png",
2970
+ "image/jpeg",
2971
+ "image/webp",
2972
+ "image/gif"
2973
+ ],
2974
+ required: true
2975
+ },
2976
+ bytes: {
2977
+ type: "integer",
2978
+ required: true
2979
+ },
2980
+ width: {
2981
+ type: "integer",
2982
+ required: true
2983
+ },
2984
+ height: {
2985
+ type: "integer",
2986
+ required: true
2987
+ },
2988
+ name: { type: "string" }
2989
+ }
2990
+ },
2991
+ previewImage: {
2992
+ type: "object",
2993
+ additionalProperties: false,
2994
+ description: "最终图片的附件引用(UI-only,始终提供):与 localPath 是同一份后处理后的字节。",
2995
+ properties: {
2996
+ attachmentId: {
2997
+ type: "string",
2998
+ required: true
2999
+ },
3000
+ mediaType: {
3001
+ type: "string",
3002
+ enum: [
3003
+ "image/png",
3004
+ "image/jpeg",
3005
+ "image/webp",
3006
+ "image/gif"
3007
+ ],
3008
+ required: true
3009
+ },
3010
+ bytes: {
3011
+ type: "integer",
3012
+ required: true
3013
+ },
3014
+ width: {
3015
+ type: "integer",
3016
+ required: true
3017
+ },
3018
+ height: {
3019
+ type: "integer",
3020
+ required: true
3021
+ },
3022
+ name: { type: "string" }
3023
+ }
3024
+ }
3025
+ }
3026
+ },
3027
+ render: (_args, value) => {
3028
+ const v = value;
3029
+ const dimensions = v.previewImage ?? v.image;
3030
+ const content = createImageSummaryText({
3031
+ provider: v.provider,
3032
+ localPath: v.localPath,
3033
+ bytes: v.bytes,
3034
+ ...v.prompt === void 0 ? {} : { prompt: v.prompt },
3035
+ mode: v.mode,
3036
+ ...v.model === void 0 ? {} : { model: v.model },
3037
+ ...v.notes === void 0 ? {} : { notes: v.notes },
3038
+ ...v.postprocess === void 0 ? {} : { postprocess: v.postprocess },
3039
+ ...v.submitAttempts === void 0 ? {} : { submitAttempts: v.submitAttempts },
3040
+ ...v.transport === void 0 ? {} : { transport: v.transport },
3041
+ ...v.requestId === void 0 ? {} : { requestId: v.requestId },
3042
+ ...dimensions === void 0 ? {} : {
3043
+ width: dimensions.width,
3044
+ height: dimensions.height
3045
+ }
3046
+ });
3047
+ if (v.image !== void 0) content.push({
3048
+ type: "image",
3049
+ attachment: imageAttachmentRef(v.image)
3050
+ });
3051
+ return content;
3052
+ },
3053
+ presentationMeta: (_args, value) => {
3054
+ const v = value;
3055
+ const preview = v.previewImage ?? v.image;
3056
+ return {
3057
+ provider: v.provider,
3058
+ model: v.model,
3059
+ mode: v.mode,
3060
+ prompt: v.prompt,
3061
+ localPath: v.localPath,
3062
+ sourceUrl: v.sourceUrl,
3063
+ bytes: v.bytes,
3064
+ transport: v.transport,
3065
+ submitAttempts: v.submitAttempts,
3066
+ requestId: v.requestId,
3067
+ ...v.taskId === void 0 ? {} : { taskId: v.taskId },
3068
+ ...v.postprocess === void 0 ? {} : { postprocess: v.postprocess },
3069
+ ...v.notes ? { notes: v.notes } : {},
3070
+ ...preview === void 0 ? {} : { image: preview }
3071
+ };
3072
+ }
3073
+ },
3074
+ async execute(args, exec) {
3075
+ const typedArgs = args;
3076
+ if (typedArgs.recoverRequestId !== void 0 && typedArgs.recoverRequestId.trim() !== "") return await recoverByRequestId(typedArgs.recoverRequestId.trim());
3077
+ const rawPrompt = typedArgs.prompt?.trim() ?? "";
3078
+ if (rawPrompt === "") throw new Error("generate_image:prompt 必填(仅使用 recoverRequestId 找回模式时可省略)");
3079
+ const runtime = runtimeDefaults.get();
3080
+ const prompt = applyImageStyle(rawPrompt, runtime.imageStyle);
3081
+ const conversationReferences = await resolveConversationImages(exec, attachments);
3082
+ const explicitReferences = Array.isArray(typedArgs.images) ? await Promise.all(typedArgs.images.filter((value) => typeof value === "string" && value.trim() !== "").map(resolveImageReference)) : [];
3083
+ const resolvedReferences = explicitReferences.length > 0 ? explicitReferences : conversationReferences;
3084
+ const imageReference = typedArgs.image ? await resolveImageReference(typedArgs.image) : void 0;
3085
+ const imageReferences = imageReference !== void 0 ? [imageReference] : resolvedReferences;
3086
+ const model = typedArgs.model || config.defaultImageModel || void 0;
3087
+ const size = typedArgs.size ?? runtime.imageSize ?? config.defaultImageSize;
3088
+ const candidates = resolveModelCandidates("image", model, runtime.imageProvider ?? (config.defaultImageProvider === "" ? void 0 : config.defaultImageProvider) ?? config.provider, (p) => peekProviderCredentials(config, p).apiKey.trim().length > 0);
3089
+ if (candidates.length === 0) throw new Error("dsh-image-video:没有任何已配置 API Key 的服务商,请至少为一个服务商配置 apiKey");
3090
+ const requestId = randomUUID();
3091
+ const notes = [];
3092
+ const attempts = [];
3093
+ let chosen;
3094
+ let lastError;
3095
+ for (const candidate of candidates) {
3096
+ const attempt = await attemptCandidate(candidate, {
3097
+ requestId,
3098
+ model,
3099
+ size,
3100
+ imageReference,
3101
+ imageReferences,
3102
+ prompt,
3103
+ exec,
3104
+ notes
3105
+ });
3106
+ if (attempt.ok) {
3107
+ chosen = attempt;
3108
+ break;
3109
+ }
3110
+ lastError = attempt.error;
3111
+ if (canFallbackToNextProvider(attempt.error, attempt.tx)) {
3112
+ attempts.push(`${candidate} 不接受该模型(${attempt.error instanceof Error ? attempt.error.message.slice(0, 120) : String(attempt.error)})`);
3113
+ continue;
3114
+ }
3115
+ throw attempt.error;
3116
+ }
3117
+ if (chosen === void 0) throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`图片提交失败:所有候选服务商均不接受模型 ${model ?? "(服务商默认)"}`);
3118
+ if (attempts.length > 0) notes.unshift(`模型自动路由回退:${attempts.join(";")};最终由 ${chosen.tx.provider} 提交`);
3119
+ const { saved, postprocess } = await deliverImage(chosen.result, exec, notes);
3120
+ const imageRef = await saveImageAttachment(attachments, saved.data, saved.contentType, "generated-image");
3121
+ const imageInline = await routeAcceptsImages(ctx, exec);
3122
+ const actualModel = chosen.result.model ?? model ?? "";
3123
+ const txReport = reportTransaction(chosen.tx);
3124
+ return {
3125
+ provider: chosen.tx.provider,
3126
+ prompt,
3127
+ mode: chosen.preflight.mode,
3128
+ localPath: saved.localPath,
3129
+ sourceUrl: saved.sourceUrl,
3130
+ bytes: saved.bytes,
3131
+ model: actualModel,
3132
+ transport: chosen.tx.transport,
3133
+ submitAttempts: txReport.submitAttempts,
3134
+ recoveryLookups: txReport.recoveryLookups,
3135
+ requestId: txReport.requestId,
3136
+ ...txReport.taskId === void 0 ? {} : { taskId: txReport.taskId },
3137
+ ...postprocess.length === 0 ? {} : { postprocess },
3138
+ ...notes.length > 0 ? { notes } : {},
3139
+ previewImage: imageRef,
3140
+ ...imageInline ? { image: imageRef } : {}
3141
+ };
3142
+ /**
3143
+ * 凭 request_id 找回上一次调用的任务结果并交付(不提交任何生成请求)。
3144
+ * 逐个候选服务商的反查端点查询:只有提交时那把 key 能查到自己的任务,
3145
+ * 因此用不到的服务商会直接 404(不计费、不产生任务)。
3146
+ */
3147
+ async function recoverByRequestId(recoverRequestId) {
3148
+ const recoverNotes = [];
3149
+ const probeCandidates = candidates.includes("threerouter") ? candidates : [...candidates, "threerouter"];
3150
+ let found;
3151
+ let lookupError;
3152
+ for (const candidate of probeCandidates) {
3153
+ const adapter = adapterFor(candidate);
3154
+ if (adapter.imageAsync === void 0) continue;
3155
+ const creds = resolveProviderCredentials(config, candidate);
3156
+ const httpOpts = {
3157
+ apiKey: creds.apiKey,
3158
+ baseURL: creds.baseURL,
3159
+ timeoutMs: config.timeoutMs,
3160
+ retryTimes: config.retryTimes,
3161
+ signal: exec.signal
3162
+ };
3163
+ try {
3164
+ const hit = await adapter.imageAsync.findByRequest(recoverRequestId, httpOpts);
3165
+ if (hit !== void 0) {
3166
+ found = {
3167
+ provider: candidate,
3168
+ taskId: hit.taskId,
3169
+ adapter,
3170
+ httpOpts
3171
+ };
3172
+ break;
3173
+ }
3174
+ } catch (err) {
3175
+ lookupError = err;
3176
+ }
3177
+ }
3178
+ if (found === void 0) throw new Error(`未找到 request_id=${recoverRequestId} 对应的任务:该键没有记录(从未成功提交)或已超过服务端 24 小时保留期。` + (lookupError === void 0 ? "" : `查询过程中的错误:${lookupError instanceof Error ? lookupError.message : String(lookupError)}。`) + "本次没有发起任何新的生成请求。");
3179
+ const hit = found;
3180
+ const hitApi = hit.adapter.imageAsync;
3181
+ if (hitApi === void 0) throw new Error(`服务商 ${hit.provider} 不支持按 request_id 找回任务`);
3182
+ recoverNotes.unshift(`找回模式:凭 request_id=${recoverRequestId} 取回任务 ${hit.taskId}(服务商 ${hit.provider}),本次未提交任何新的生成请求`);
3183
+ const mediaUrl = (await taskManager.pollUntilDone(hit.taskId, (taskId, opts) => hitApi.query(taskId, opts), hit.httpOpts, exec.signal)).mediaUrl;
3184
+ const { saved, postprocess } = await deliverImage({ mediaUrl }, exec, recoverNotes);
3185
+ const imageRef = await saveImageAttachment(attachments, saved.data, saved.contentType, "recovered-image");
3186
+ const imageInline = await routeAcceptsImages(ctx, exec);
3187
+ return {
3188
+ provider: hit.provider,
3189
+ prompt: "",
3190
+ mode: "text-to-image",
3191
+ localPath: saved.localPath,
3192
+ sourceUrl: saved.sourceUrl,
3193
+ bytes: saved.bytes,
3194
+ model: model ?? "",
3195
+ transport: "async",
3196
+ submitAttempts: 0,
3197
+ recoveryLookups: 1,
3198
+ requestId: recoverRequestId,
3199
+ taskId: hit.taskId,
3200
+ ...postprocess.length === 0 ? {} : { postprocess },
3201
+ notes: recoverNotes,
3202
+ previewImage: imageRef,
3203
+ ...imageInline ? { image: imageRef } : {}
3204
+ };
3205
+ }
3206
+ },
3207
+ presentCall: (args) => ({
3208
+ card: "generic",
3209
+ title: "生成图片",
3210
+ kind: "other",
3211
+ rawInput: args
3212
+ }),
3213
+ timeoutMs: config.pollTimeoutMs
3214
+ });
3215
+ }
3216
+ //#endregion
3217
+ //#region src/tools/generate-video.ts
3218
+ /**
3219
+ * generate_video 工具:文生视频 + 图生视频(image 首帧驱动),支持 threerouter(聚合器)/
3220
+ * 万象(wanx,百炼直连)/ MiniMax 官方平台 / Seedance(火山方舟),按模型家族自动路由。
3221
+ * 提交任务后后台轮询直到完成,下载视频到 outputs/ 目录。
3222
+ * 视频生成耗时较长(1-5 分钟),轮询过程不产生中间输出,仅最终结果返回模型,
3223
+ * 不阻塞对话上下文。时长上限 30 秒,由 schema 与运行时双重校验。
3224
+ * @module dsh-image-video/tools/generate-video
3225
+ */
3226
+ /** 视频时长上限(秒),强制规范。 */
3227
+ const MAX_VIDEO_DURATION = 30;
3228
+ /**
3229
+ * 创建 generate_video 工具定义。
3230
+ * 工具参数:prompt(必填)、duration(可选,1-10秒)、model(可选)、aspectRatio(可选)、
3231
+ * image(可选首帧图片,传了即图生视频)、resolution(可选分辨率档位)。
3232
+ */
3233
+ function createGenerateVideoTool(deps) {
3234
+ const { config, taskManager, runtimeDefaults } = deps;
3235
+ const outputsDir = deps.outputsDir ?? config.outputsDir;
3236
+ return defineTool({
3237
+ name: "generate_video",
3238
+ description: `根据文本提示词生成短视频;传入 image(首帧图片)时为图生视频,让图片动起来。服务商选择:配置链服务商优先(composer 会话选定 > 配置默认服务商 > 激活服务商,默认 threerouter 聚合器);视频生成固定通过 ThreeRouter 提交(项目约定,禁止切换到 OpenArt 或其他直连服务商),不使用任何其他服务商回退。模型取值:调用参数 model > 配置 defaultVideoModel > 多关键帧自动路由 > 服务商内置默认模型(threerouter 默认 minimax-h3)。不要自行编写脚本或直接调用服务商 API。视频时长上限 ${MAX_VIDEO_DURATION} 秒;具体模型能力由上游校验,wan 系模型不支持自定义时长时会在结果 notes 注明。生成完成后视频保存到本地 outputs/ 目录。参数:prompt(提示词,必填)、duration(时长秒数,1-30,可选)、model(模型名,可选,留空用配置或服务商内置默认模型)、aspectRatio(宽高比,可选,留空 16:9;图生视频时忽略,多关键帧时也忽略)、image(首帧图片:本地路径/URL,可选,单图时用)、media(参考素材序列:数组,每项含 image 路径/URL 和 position 时间点如 "0s""1s";适用于 MiniMax-H3 / wan3.0-video,此时 image 字段忽略)、resolution(分辨率档位,可选,取值随模型)。`,
3239
+ parameters: {
3240
+ prompt: {
3241
+ type: "string",
3242
+ required: true,
3243
+ description: "描述要生成的视频内容,支持中英文。"
3244
+ },
3245
+ duration: {
3246
+ type: "integer",
3247
+ description: `视频时长(秒),范围 1-${MAX_VIDEO_DURATION}。留空使用配置默认值。具体模型能力由上游校验。`
3248
+ },
3249
+ model: {
3250
+ type: "string",
3251
+ description: "指定模型名称。留空使用服务商内置默认模型(图生视频用服务商内置 i2v 默认模型)。"
3252
+ },
3253
+ aspectRatio: {
3254
+ type: "string",
3255
+ description: "视频宽高比,如 16:9、9:16、1:1。留空使用 16:9;图生视频时构图由首帧图片决定,此参数被忽略。"
3256
+ },
3257
+ image: {
3258
+ type: "string",
3259
+ description: "首帧图片,用于图生视频(让图片动起来):本地文件路径、http(s) URL 或 data URL。单图时用,与 media 二选一。"
3260
+ },
3261
+ media: {
3262
+ type: "array",
3263
+ description: "参考素材序列(MiniMax-H3 / wan3.0-video):每项为 {image, position},适配器会转换为服务端要求的 type/url。存在时 image 字段忽略。",
3264
+ items: {
3265
+ type: "object",
3266
+ additionalProperties: false,
3267
+ properties: {
3268
+ image: {
3269
+ type: "string",
3270
+ description: "本地路径或 http(s) URL / data URL"
3271
+ },
3272
+ position: {
3273
+ type: "string",
3274
+ description: "参考图对应时间点,如 '0s' / '1s' / 'first_frame' / 'last_frame'"
3275
+ }
3276
+ }
3277
+ }
3278
+ },
3279
+ resolution: {
3280
+ type: "string",
3281
+ description: "分辨率档位,取值由模型决定(如 MiniMax-H3:480P/768P/2K;wan 图生视频:480P/1080P)。留空使用服务商默认。"
3282
+ }
3283
+ },
3284
+ output: {
3285
+ schema: {
3286
+ type: "object",
3287
+ additionalProperties: false,
3288
+ properties: {
3289
+ provider: {
3290
+ type: "string",
3291
+ required: true
3292
+ },
3293
+ prompt: {
3294
+ type: "string",
3295
+ required: true
3296
+ },
3297
+ duration: {
3298
+ type: "integer",
3299
+ required: true
3300
+ },
3301
+ mode: {
3302
+ type: "string",
3303
+ required: true
3304
+ },
3305
+ resolution: {
3306
+ type: "string",
3307
+ required: true
3308
+ },
3309
+ localPath: {
3310
+ type: "string",
3311
+ required: true
3312
+ },
3313
+ sourceUrl: {
3314
+ type: "string",
3315
+ required: true
3316
+ },
3317
+ bytes: {
3318
+ type: "integer",
3319
+ required: true
3320
+ },
3321
+ elapsedMs: {
3322
+ type: "integer",
3323
+ required: true
3324
+ },
3325
+ model: {
3326
+ type: "string",
3327
+ description: "实际发给上游的模型名(含服务商内置默认),供用户核对本次到底用了哪个模型。"
3328
+ },
3329
+ notes: {
3330
+ type: "array",
3331
+ items: { type: "string" }
3332
+ }
3333
+ }
3334
+ },
3335
+ render: (_args, value) => {
3336
+ const v = value;
3337
+ return createVideoContent(v.localPath, v.bytes, v.sourceUrl, {
3338
+ prompt: v.prompt,
3339
+ provider: v.provider,
3340
+ model: v.model,
3341
+ mode: v.mode,
3342
+ duration: v.duration,
3343
+ resolution: v.resolution,
3344
+ ...v.notes ? { notes: v.notes } : {}
3345
+ });
3346
+ },
3347
+ presentationMeta: (_args, value) => {
3348
+ const v = value;
3349
+ return {
3350
+ provider: v.provider,
3351
+ model: v.model,
3352
+ prompt: v.prompt,
3353
+ duration: v.duration,
3354
+ localPath: v.localPath,
3355
+ sourceUrl: v.sourceUrl,
3356
+ bytes: v.bytes,
3357
+ ...v.notes ? { notes: v.notes } : {}
3358
+ };
3359
+ }
3360
+ },
3361
+ async execute(args, exec) {
3362
+ const typedArgs = args;
3363
+ const runtime = runtimeDefaults.get();
3364
+ const duration = typedArgs.duration ?? runtime.videoDuration ?? config.defaultVideoDuration;
3365
+ if (duration < 1 || duration > MAX_VIDEO_DURATION) throw new Error(`视频时长必须在 1-${MAX_VIDEO_DURATION} 秒之间,当前为 ${duration}`);
3366
+ const preferred = runtime.videoProvider ?? (config.defaultVideoProvider === "" ? void 0 : config.defaultVideoProvider);
3367
+ const candidates = resolveModelCandidates("video", typedArgs.model, preferred ?? config.provider, (p) => peekProviderCredentials(config, p).apiKey.trim().length > 0);
3368
+ if (candidates.length === 0) throw new Error("dsh-image-video:没有任何已配置 API Key 的服务商,请至少为一个服务商配置 apiKey");
3369
+ const adapterFor = (p) => p === "threerouter" ? threerouterAdapter : p === "wanx" ? wanxAdapter : p === "minimax" ? minimaxAdapter : seedanceAdapter;
3370
+ const imageRef = typedArgs.image ? await compressVideoFirstFrame(await resolveImageReference(typedArgs.image)) : void 0;
3371
+ const mediaRef = typedArgs.media ? await resolveVideoMedia(typedArgs.media) : void 0;
3372
+ let model = typedArgs.model || config.defaultVideoModel || void 0;
3373
+ const hasMedia = !!mediaRef;
3374
+ if (hasMedia) {
3375
+ if (model && !MULTI_FRAME_CAPABLE_MODELS.includes(model)) throw new Error(`模型「${model}」不支持多关键帧。请使用支持多帧的模型:${MULTI_FRAME_CAPABLE_MODELS.join("、")}`);
3376
+ if (!model) model = MULTI_FRAME_CAPABLE_MODELS[0];
3377
+ }
3378
+ const videoParams = {
3379
+ prompt: typedArgs.prompt,
3380
+ duration,
3381
+ model,
3382
+ aspectRatio: hasMedia ? void 0 : typedArgs.aspectRatio || runtime.videoAspectRatio || "16:9",
3383
+ image: hasMedia ? void 0 : imageRef,
3384
+ media: mediaRef,
3385
+ resolution: typedArgs.resolution
3386
+ };
3387
+ let provider;
3388
+ let adapter;
3389
+ let httpOpts;
3390
+ let submitResult;
3391
+ const fallbackNotes = [];
3392
+ let lastError;
3393
+ for (const candidate of candidates) {
3394
+ const creds = resolveProviderCredentials(config, candidate);
3395
+ const candidateAdapter = adapterFor(candidate);
3396
+ const candidateOpts = {
3397
+ apiKey: creds.apiKey,
3398
+ baseURL: creds.baseURL,
3399
+ timeoutMs: config.timeoutMs,
3400
+ retryTimes: config.retryTimes,
3401
+ signal: exec.signal
3402
+ };
3403
+ try {
3404
+ submitResult = await candidateAdapter.submitVideo(videoParams, candidateOpts);
3405
+ provider = candidate;
3406
+ adapter = candidateAdapter;
3407
+ httpOpts = candidateOpts;
3408
+ break;
3409
+ } catch (err) {
3410
+ if (!isModelNotAcceptedError(err)) throw err;
3411
+ lastError = err;
3412
+ fallbackNotes.push(`${candidate} 不接受该模型(${err instanceof Error ? err.message.slice(0, 120) : String(err)})`);
3413
+ }
3414
+ }
3415
+ if (!submitResult || !provider || !adapter || !httpOpts) throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`视频提交失败:所有候选服务商均不接受模型 ${model ?? "(服务商默认)"}`);
3416
+ if (!submitResult.taskId) throw new Error("视频任务提交失败:未返回 task_id");
3417
+ const actualModel = submitResult.model ?? model ?? "";
3418
+ const notes = [];
3419
+ if (submitResult.droppedDuration) notes.push(`当前模型 ${actualModel || "(服务商内置默认)"} 不支持自定义时长,已忽略 duration=${duration} 秒,实际时长由上游模型默认决定`);
3420
+ if (fallbackNotes.length > 0) notes.push(`模型自动路由回退:${fallbackNotes.join(";")};最终由 ${provider} 提交`);
3421
+ const pollResult = await taskManager.pollUntilDone(submitResult.taskId, adapter, httpOpts, exec.signal);
3422
+ const downloadOpts = {
3423
+ timeoutMs: config.timeoutMs,
3424
+ retryTimes: config.retryTimes,
3425
+ signal: exec.signal
3426
+ };
3427
+ const saved = await downloadAndSave(pollResult.mediaUrl, outputsDir, ".mp4", downloadOpts);
3428
+ return {
3429
+ provider,
3430
+ prompt: typedArgs.prompt,
3431
+ duration,
3432
+ mode: imageRef ? "image-to-video" : "text-to-video",
3433
+ resolution: typedArgs.resolution ?? "",
3434
+ model: actualModel,
3435
+ localPath: saved.localPath,
3436
+ sourceUrl: saved.sourceUrl,
3437
+ bytes: saved.bytes,
3438
+ elapsedMs: pollResult.elapsedMs,
3439
+ ...notes.length > 0 ? { notes } : {}
3440
+ };
3441
+ },
3442
+ presentCall: (args) => ({
3443
+ card: "generic",
3444
+ title: "生成视频",
3445
+ kind: "other",
3446
+ rawInput: args
3447
+ }),
3448
+ timeoutMs: config.pollTimeoutMs
3449
+ });
3450
+ }
3451
+ //#endregion
3452
+ //#region src/index.ts
3453
+ /** Cordis 插件名,用于 loader 诊断。 */
3454
+ const name = "image-video";
3455
+ /**
3456
+ * 必需服务依赖:`tools`(工具注册表)。
3457
+ * `attachments` 不在此声明——它由 `generate_image` 通过 `ctx.inject` 按需声明,
3458
+ * 缺失时仅 generate_image 不注册,generate_video 与插件本身不受影响。
3459
+ */
3460
+ const inject = ["tools"];
3461
+ /**
3462
+ * 插件入口:创建任务管理器(注册卸载清理),注册 generate_image / generate_video 工具。
3463
+ *
3464
+ * `generate_image` 通过 `ctx.inject(['attachments'], cb)` 显式声明对 attachment 服务的依赖:
3465
+ * callback 接收已注入 attachments 的子上下文,从中读取服务实例构造时注入工具,
3466
+ * 不在执行体内部运行时 `ctx.get` 读取。attachments 服务撤销时,该 fiber dispose,
3467
+ * `ctx.tools.register` 的 disposer 自动注销工具。
3468
+ *
3469
+ * `generate_video` 不依赖 attachments,始终注册。工具注册的 disposer 由 `ctx.tools.register`
3470
+ * 内部经 effect 注册,fiber dispose 时自动清理。
3471
+ *
3472
+ * @param ctx - 插件上下文。
3473
+ * @param config - 已由 Schemastery 填充默认值的插件配置。
3474
+ */
3475
+ function apply(ctx, config) {
3476
+ const outputsDir = resolveOutputsDir(config.outputsDir);
3477
+ const keyState = (provider) => peekProviderCredentials(config, provider).apiKey.trim().length > 0 ? "已配置" : "未配置";
3478
+ ctx.logger.info(`dsh-image-video 生效配置:provider=${config.provider} defaultVideoProvider=${config.defaultVideoProvider || "(跟随激活服务商)"} defaultImageProvider=${config.defaultImageProvider || "(跟随激活服务商)"} defaultVideoModel=${config.defaultVideoModel || "(服务商内置默认)"} defaultImageModel=${config.defaultImageModel || "(服务商内置默认)"} defaultImageSize=${config.defaultImageSize} imageTransport=${config.imageTransport} imageUnknownStatePolicy=${config.imageUnknownStatePolicy} watermark=${config.watermark.enabled ? `${config.watermark.text}@${config.watermark.position}` : "关闭"} outputsDir=${outputsDir} keys{threerouter=${keyState("threerouter")}, wanx=${keyState("wanx")}, minimax=${keyState("minimax")}, seedance=${keyState("seedance")}}`);
3479
+ const taskManager = new TaskManager(ctx, config);
3480
+ const runtimeDefaults = createRuntimeDefaultsStore();
3481
+ ctx.inject(["attachments"], (imageCtx) => {
3482
+ const attachments = imageCtx.get("attachments");
3483
+ if (!attachments) return;
3484
+ imageCtx.tools.register(createGenerateImageTool({
3485
+ config,
3486
+ taskManager,
3487
+ attachments,
3488
+ ctx,
3489
+ runtimeDefaults,
3490
+ outputsDir
3491
+ }));
3492
+ });
3493
+ ctx.tools.register(createGenerateVideoTool({
3494
+ config,
3495
+ taskManager,
3496
+ runtimeDefaults,
3497
+ outputsDir
3498
+ }));
3499
+ ctx.inject(["webServer"], (mediaCtx) => {
3500
+ const webServer = mediaCtx.get("webServer");
3501
+ if (!webServer) return;
3502
+ const disposeRoute = registerOutputsRoute(webServer, outputsDir);
3503
+ if (disposeRoute) mediaCtx.effect(() => disposeRoute, "dsh-image-video: outputs media route");
3504
+ const disposeDefaults = registerDefaultsRoute(webServer, runtimeDefaults, extractPersistedDefaults(config));
3505
+ if (disposeDefaults) mediaCtx.effect(() => disposeDefaults, "dsh-image-video: runtime defaults route");
3506
+ });
3507
+ }
3508
+ //#endregion
3509
+ export { AsyncTransportUnavailableError, Config, DEFAULTS_ROUTE_PATH, DEFAULT_RECOVERY_BUDGET, GenerationError, IMAGE_SIZE_OPTIONS, IMAGE_STYLE_OPTIONS, TRANSPORT_PROBE_TTL_MS, TaskManager, apply, applyImageStyle, applyImageWatermark, buildWatermarkSvg, canFallbackToNextProvider, consumeSubmitBudget, createDefaultsRouteHandler, createGenerateImageTool, createGenerateVideoTool, createImageTransaction, createRuntimeDefaultsStore, extractPersistedDefaults, fitFontSize, formatPreflightNote, inject, isAsyncImageUnavailableError, isModelNotAcceptedError, isUnknownSubmitStateError, looksLikeVideoModel, name, parseDefaultsPatch, registerDefaultsRoute, reportTransaction, resolveImagePreflight, resolveImageTransport, runImageTransaction, seedanceAdapter, submitBudget, threerouterAdapter, unknownStateError, wanxAdapter };