const RUNWARE_ENDPOINT = "https://api.runware.ai/v1"; const DEFAULT_TIMEOUT_MS = 12_000; const PROBE_IMAGE_URL = "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/Example.jpg/512px-Example.jpg"; const DEFAULT_MODEL_ID = "runware:101@1"; export type RunwareCapabilityCode = | "SUPPORTED" | "UNSUPPORTED_SEED_IMAGE" | "MODEL_REQUIRES_MASK" | "AUTH_FAILED" | "MODEL_INVALID" | "UPSTREAM_ERROR"; export type RunwareSeedImageProbeResult = { supported: boolean; code: RunwareCapabilityCode; message: string; status?: number; }; type RunwareErrorInfo = { code: string; message: string; parameter: string; }; function toText(value: unknown) { return typeof value === "string" ? value.trim() : ""; } function collectErrorInfos(payload: unknown): RunwareErrorInfo[] { const infos: RunwareErrorInfo[] = []; const visited = new Set(); const queue: unknown[] = [payload]; while (queue.length > 0) { const current = queue.shift(); if (!current || typeof current !== "object" || visited.has(current)) { continue; } visited.add(current); if (Array.isArray(current)) { for (const item of current) { queue.push(item); } continue; } const objectValue = current as Record; const message = toText(objectValue.message || objectValue.error); const code = toText(objectValue.code); const parameter = toText(objectValue.parameter); const lowerCode = code.toLowerCase(); const looksLikeErrorCode = containsAny(lowerCode, [ "error", "invalid", "unsupported", "unauthorized", "forbidden", "notfound", "not_found", "failed", ]); if (message || parameter || looksLikeErrorCode) { infos.push({ code, message, parameter }); } const nestedErrors = objectValue.errors; const nestedData = objectValue.data; if (nestedErrors) { queue.push(nestedErrors); } if (nestedData) { queue.push(nestedData); } } return infos; } function containsAny(text: string, patterns: string[]) { return patterns.some((pattern) => text.includes(pattern)); } function normalizeErrorText(errorInfo: RunwareErrorInfo) { return `${errorInfo.code} ${errorInfo.parameter} ${errorInfo.message}`.toLowerCase(); } function isUnsupportedSeedImage(errorInfo: RunwareErrorInfo) { const text = normalizeErrorText(errorInfo); return ( containsAny(text, [ "unsupported use of 'seedimage'", 'unsupported use of "seedimage"', "unsupported use of seedimage", "seedimage is not supported", "unsupportedparameter seedimage", "unsupported parameter seedimage", ]) || (text.includes("seedimage") && text.includes("unsupported")) ); } function isAuthFailure(status: number, errorInfo: RunwareErrorInfo) { const text = normalizeErrorText(errorInfo); if (status === 401 || status === 403) return true; return containsAny(text, [ "unauthorized", "forbidden", "invalid api key", "invalidapikey", "authentication failed", "auth failed", "invalid credentials", ]); } function isModelInvalid(errorInfo: RunwareErrorInfo) { const text = normalizeErrorText(errorInfo); return containsAny(text, [ "invalid model", "model not found", "unknown model", "model does not exist", "invalidmodel", ]); } function modelRequiresMask(errorInfo: RunwareErrorInfo) { const text = normalizeErrorText(errorInfo); return containsAny(text, [ "missingmaskimage", "missing maskimage", "requires a mask image", "requires mask image", "requires a mask", ]); } function responseHasImage(payload: unknown, taskUUID: string) { if (!payload) return false; const list = Array.isArray(payload) ? payload : Array.isArray((payload as Record)?.data) ? ((payload as Record).data as unknown[]) : []; return list.some((item) => { if (!item || typeof item !== "object") return false; const row = item as Record; const image = toText(row.imageDataURI || row.imageURL || row.imageUrl); if (!image) return false; const rowTaskUUID = toText(row.taskUUID); return !rowTaskUUID || rowTaskUUID === taskUUID; }); } function parseProbeFailure( status: number, modelId: string, infos: RunwareErrorInfo[] ): RunwareSeedImageProbeResult { const firstMessage = infos.find((info) => info.message)?.message; if (status === 401 || status === 403) { return { supported: false, code: "AUTH_FAILED", message: "Runware API Key 无效或无权限,请检查后重试。", status, }; } if (infos.some((info) => isUnsupportedSeedImage(info))) { return { supported: false, code: "UNSUPPORTED_SEED_IMAGE", message: `模型 ${modelId} 不支持 seedImage,已阻止保存。`, status, }; } if (infos.some((info) => modelRequiresMask(info))) { return { supported: false, code: "MODEL_REQUIRES_MASK", message: `模型 ${modelId} 依赖 maskImage,当前实时画布链路不兼容。`, status, }; } if (infos.some((info) => isAuthFailure(status, info))) { return { supported: false, code: "AUTH_FAILED", message: "Runware API Key 无效或无权限,请检查后重试。", status, }; } if (infos.some((info) => isModelInvalid(info))) { return { supported: false, code: "MODEL_INVALID", message: `模型 ${modelId} 不存在或不可用,请更换模型后重试。`, status, }; } return { supported: false, code: "UPSTREAM_ERROR", message: firstMessage || `Runware 探针请求失败(${status || 0})`, status, }; } export async function probeRunwareSeedImageSupport(params: { apiKey: string; modelId?: string; timeoutMs?: number; }): Promise { const apiKey = params.apiKey.trim(); const modelId = (params.modelId || DEFAULT_MODEL_ID).trim() || DEFAULT_MODEL_ID; const timeoutMs = Number.isFinite(params.timeoutMs) ? Number(params.timeoutMs) : DEFAULT_TIMEOUT_MS; if (!apiKey) { return { supported: false, code: "AUTH_FAILED", message: "缺少 Runware API Key。", }; } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), Math.max(2_000, timeoutMs)); let status = 0; let payload: unknown = null; try { const taskUUID = crypto.randomUUID(); const response = await fetch(RUNWARE_ENDPOINT, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify([ { taskType: "imageInference", taskUUID, model: modelId, positivePrompt: "probe", seedImage: PROBE_IMAGE_URL, width: 512, height: 512, strength: 0.2, CFGScale: 7, steps: 1, numberResults: 1, outputType: "dataURI", }, ]), cache: "no-store", signal: controller.signal, }); status = response.status; payload = await response.json().catch(() => null); const infos = collectErrorInfos(payload); if (response.ok && infos.length === 0 && responseHasImage(payload, taskUUID)) { return { supported: true, code: "SUPPORTED", message: "模型支持 seedImage。", status, }; } return parseProbeFailure(status, modelId, infos); } catch (error) { if (error instanceof Error && error.name === "AbortError") { return { supported: false, code: "UPSTREAM_ERROR", message: "Runware 探针请求超时,请稍后重试。", }; } return { supported: false, code: "UPSTREAM_ERROR", message: error instanceof Error ? error.message : "Runware 探针请求失败。", }; } finally { clearTimeout(timer); } }