303 lines
7.9 KiB
TypeScript
303 lines
7.9 KiB
TypeScript
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<unknown>();
|
||
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<string, unknown>;
|
||
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<string, unknown>)?.data)
|
||
? ((payload as Record<string, unknown>).data as unknown[])
|
||
: [];
|
||
|
||
return list.some((item) => {
|
||
if (!item || typeof item !== "object") return false;
|
||
const row = item as Record<string, unknown>;
|
||
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<RunwareSeedImageProbeResult> {
|
||
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);
|
||
}
|
||
}
|