Your Name f6979f0196
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: 完善无限画布3D生成与实时画布能力
2026-02-28 22:33:16 +08:00

877 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
import { getRunwareWebSocketClient } from "@/lib/realtime/runware-ws";
type RealtimeCanvasConfig = {
enabled?: boolean;
runwareApiKey?: string;
runwareModel?: string;
runwareStylizeModel?: string;
runwareControlModel?: string;
runwareControlPreprocessor?: "canny" | "lineart" | "depth";
renderMode?: "strict-structure" | "balanced" | "stylize";
runwareUseWebSocket?: boolean;
falRealtimeEnabled?: boolean;
falRealtimeModel?: string;
falApiKey?: string;
apiKey?: string;
negativePrompt?: string;
strictSketch?: boolean;
maxInputResolution?: number;
defaults?: {
steps?: number;
cfgScale?: number;
strength?: number;
};
};
type AngleEditConfig = {
falApiKey?: string;
};
type GeneratePayload = {
requestId?: string;
imageUrl?: string;
maskImageUrl?: string;
qualityProfile?: "preview" | "refine";
roi?:
| {
x?: number;
y?: number;
width?: number;
height?: number;
}
| null;
prompt?: string;
seed?: number;
strictSketch?: boolean;
imageSize?: "square" | "square_hd";
steps?: number;
cfgScale?: number;
strength?: number;
};
const FALLBACK_REALTIME_PROMPT =
"严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。";
const FALLBACK_FAL_REALTIME_MODEL = "fal-ai/flux-2/klein/realtime";
const FALLBACK_RUNWARE_STYLIZE_MODEL = "runware:106@1";
const FALLBACK_RUNWARE_CONTROL_MODEL = "runware:25@1";
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function resolveSquareSize(
imageSize: "square" | "square_hd" | undefined,
maxInputResolution: number
) {
const preferred = imageSize === "square_hd" ? 1280 : 1024;
const capped = Math.min(preferred, maxInputResolution);
return clamp(Math.round(capped / 64) * 64, 512, 1536);
}
function parseRunwareError(payload: any) {
if (!payload) return "";
if (Array.isArray(payload)) {
const match = payload.find(
(item) =>
typeof item?.error === "string" ||
typeof item?.message === "string" ||
(Array.isArray(item?.errors) && item.errors.length > 0)
);
if (!match) return "";
if (typeof match.error === "string") return match.error;
if (typeof match.message === "string") return match.message;
if (Array.isArray(match.errors) && match.errors.length > 0) {
const firstError = match.errors[0];
if (typeof firstError === "string") return firstError;
if (typeof firstError?.message === "string") return firstError.message;
}
return "";
}
if (typeof payload?.error === "string") return payload.error;
if (typeof payload?.message === "string") return payload.message;
if (Array.isArray(payload?.errors) && payload.errors.length > 0) {
const firstError = payload.errors[0];
if (typeof firstError?.message === "string") return firstError.message;
if (typeof firstError?.code === "string") return firstError.code;
}
return "";
}
function resolveSeed(seed: unknown) {
const parsed = Number(seed);
if (!Number.isFinite(parsed)) return undefined;
const normalized = Math.floor(parsed);
if (normalized < 0 || normalized > 2147483647) return undefined;
return normalized;
}
function shouldRetryWithoutMask(errorMessage: string) {
const normalized = errorMessage.toLowerCase();
return (
normalized.includes("unsupported use of 'maskimage'") ||
normalized.includes("unsupported use of \"maskimage\"") ||
normalized.includes("unsupported use of maskimage") ||
normalized.includes("maskimage is not supported")
);
}
function shouldRetryWithoutNegativePrompt(errorMessage: string) {
const normalized = errorMessage.toLowerCase();
return (
normalized.includes("unsupportedarchitecturenegativeprompt") ||
normalized.includes("negative prompt is not supported") ||
normalized.includes("unsupported use of 'negativeprompt'") ||
normalized.includes("unsupported use of \"negativeprompt\"")
);
}
function shouldRetryWithoutControlNet(errorMessage: string) {
const normalized = errorMessage.toLowerCase();
return (
normalized.includes("unsupportedcontrolnetmodel") ||
normalized.includes("unsupported use of 'controlnet'") ||
normalized.includes("unsupported use of \"controlnet\"") ||
normalized.includes("controlnet is not supported")
);
}
function resolveRenderModeSettings(
mode: "strict-structure" | "balanced" | "stylize"
) {
if (mode === "strict-structure") {
return {
controlWeight: 1,
seedStrengthMin: 0.14,
seedStrengthMax: 0.58,
};
}
if (mode === "stylize") {
return {
controlWeight: 0.72,
seedStrengthMin: 0.28,
seedStrengthMax: 0.8,
};
}
return {
controlWeight: 0.86,
seedStrengthMin: 0.2,
seedStrengthMax: 0.7,
};
}
function extractFalImageUrl(payload: any) {
const direct =
payload?.images?.[0]?.url ||
payload?.image?.url ||
payload?.output?.[0]?.url ||
payload?.data?.images?.[0]?.url;
if (typeof direct === "string" && direct.trim()) {
return direct.trim();
}
const queue: any[] = [payload];
const visited = new Set<any>();
while (queue.length > 0) {
const current = queue.shift();
if (!current || typeof current !== "object" || visited.has(current)) continue;
visited.add(current);
for (const value of Object.values(current)) {
if (typeof value === "string" && /^https?:\/\//i.test(value)) {
return value;
}
if (value && typeof value === "object") {
queue.push(value);
}
}
}
return "";
}
function extractRunwareOutputImage(payload: any, taskUUID: string) {
const resultList = Array.isArray(payload)
? payload
: Array.isArray(payload?.data)
? payload.data
: [];
const imageResult =
resultList.find(
(item: any) =>
item?.taskUUID === taskUUID && (item?.imageDataURI || item?.imageURL)
) ||
resultList.find((item: any) => item?.imageDataURI || item?.imageURL);
const imageDataURI = String(imageResult?.imageDataURI || "").trim();
const imageURL = String(imageResult?.imageURL || "").trim();
const outputImage = imageDataURI || imageURL;
return {
imageResult,
imageDataURI,
imageURL,
outputImage,
};
}
function extractControlGuideImage(payload: any, taskUUID: string) {
const resultList = Array.isArray(payload)
? payload
: Array.isArray(payload?.data)
? payload.data
: [];
const guideResult =
resultList.find(
(item: any) =>
item?.taskUUID === taskUUID && (item?.guideImageURL || item?.guideImageDataURI)
) ||
resultList.find((item: any) => item?.guideImageURL || item?.guideImageDataURI);
const guideImageDataURI = String(guideResult?.guideImageDataURI || "").trim();
const guideImageURL = String(guideResult?.guideImageURL || "").trim();
return guideImageDataURI || guideImageURL;
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return Response.json({ code: "UNAUTHORIZED", message: "请先登录" }, { status: 401 });
}
let body: GeneratePayload;
try {
body = (await request.json()) as GeneratePayload;
} catch {
return Response.json({ code: "BAD_REQUEST", message: "无效 JSON" }, { status: 400 });
}
const imageUrl = String(body.imageUrl || "").trim();
if (!imageUrl) {
return Response.json({ code: "BAD_REQUEST", message: "缺少 imageUrl" }, { status: 400 });
}
const maskImageUrl = String(body.maskImageUrl || "").trim();
const [setting, angleEditSetting] = await Promise.all([
getSystemSetting({ key: "realtime_canvas_config" }).catch(() => null),
getSystemSetting({ key: "angle_edit_config" }).catch(() => null),
]);
const config = (setting?.value || {}) as RealtimeCanvasConfig;
const angleEditConfig = (angleEditSetting?.value || {}) as AngleEditConfig;
if (config.enabled === false) {
return Response.json(
{ code: "FEATURE_DISABLED", message: "实时画布功能未开启" },
{ status: 403 }
);
}
const runwareApiKey = String(
config.runwareApiKey ||
config.falApiKey ||
config.apiKey ||
process.env.RUNWARE_API_KEY ||
process.env.RUNWARE_KEY ||
""
).trim();
const falApiKey = String(
config.falApiKey ||
angleEditConfig.falApiKey ||
process.env.FAL_KEY ||
process.env.FAL_API_KEY ||
""
).trim();
if (!runwareApiKey) {
return Response.json(
{
code: "RUNWARE_KEY_MISSING",
message: "未配置 Runware API Key",
},
{ status: 500 }
);
}
const fallbackSeedImageModel = "runware:101@1";
const configuredModel = String(
config.runwareModel || process.env.RUNWARE_MODEL || fallbackSeedImageModel
).trim();
const model = configuredModel || fallbackSeedImageModel;
const configuredStylizeModel = String(
config.runwareStylizeModel ||
process.env.RUNWARE_REALTIME_STYLIZE_MODEL ||
FALLBACK_RUNWARE_STYLIZE_MODEL
).trim();
const stylizeModel = configuredStylizeModel || FALLBACK_RUNWARE_STYLIZE_MODEL;
const configuredControlModel = String(
config.runwareControlModel ||
process.env.RUNWARE_REALTIME_CONTROL_MODEL ||
FALLBACK_RUNWARE_CONTROL_MODEL
).trim();
const controlModel = configuredControlModel || FALLBACK_RUNWARE_CONTROL_MODEL;
const controlPreprocessor =
config.runwareControlPreprocessor === "lineart" ||
config.runwareControlPreprocessor === "depth"
? config.runwareControlPreprocessor
: "canny";
const renderMode =
config.renderMode === "strict-structure" ||
config.renderMode === "stylize" ||
config.renderMode === "balanced"
? config.renderMode
: "balanced";
const renderModeSettings = resolveRenderModeSettings(renderMode);
const falRealtimeModel = String(
config.falRealtimeModel || process.env.FAL_REALTIME_MODEL || FALLBACK_FAL_REALTIME_MODEL
).trim();
const preferWebSocket =
config.runwareUseWebSocket !== false &&
process.env.RUNWARE_REALTIME_USE_WS !== "0";
const maxInputResolution = clamp(Number(config.maxInputResolution || 1024), 512, 1536);
const size = resolveSquareSize(body.imageSize, maxInputResolution);
const qualityProfile = body.qualityProfile === "preview" ? "preview" : "refine";
const steps = clamp(
Number(body.steps || config.defaults?.steps || 8),
1,
12
);
const cfgScale = clamp(
Number(body.cfgScale || config.defaults?.cfgScale || 7),
1,
14
);
const strength = clamp(
Number(body.strength || config.defaults?.strength || 0.32),
0.05,
1
);
const strictSketch = Boolean(body.strictSketch ?? config.strictSketch ?? true);
const seed = resolveSeed(body.seed);
const rawPrompt = String(body.prompt || "").trim();
const hasUserPrompt =
rawPrompt.length > 0 && rawPrompt !== FALLBACK_REALTIME_PROMPT;
const positivePrompt = hasUserPrompt
? [
`User prompt: ${rawPrompt}`,
"Keep the object layout and perspective from the sketch.",
"Keep the exact silhouette and part topology from the sketch (same count of legs/supports and same frame structure).",
"The main object category must exactly match the user prompt.",
"Do not swap object class (for example, chair must remain chair, never animal).",
"Do not add bulky cushions, thick armrests, or boxy masses unless they clearly exist in the sketch.",
"Render as a full-color photorealistic product image with realistic material, lighting, and texture.",
"No sketch style, no line art outlines, no monochrome drawing.",
].join(" ")
: FALLBACK_REALTIME_PROMPT;
const negativePrompt =
String(config.negativePrompt || "").trim() ||
"改变构图, 改变主体位置, 多余人物, 多余肢体, 重复物体, 镜像翻转, 错误透视, 模糊轮廓, 文字水印, 大面积背景噪点, 线稿, 线描, 草图, 黑白素描, 轮廓线, line art, sketch, outline drawing, monochrome drawing, wrong object category, unrelated subject, animal when furniture requested, furniture when animal requested";
let fallbackWarning = "";
const useFalRealtime =
config.falRealtimeEnabled === true || process.env.FAL_REALTIME_ENABLED === "1";
if (hasUserPrompt && qualityProfile === "refine" && falApiKey && useFalRealtime) {
try {
const fal = await import("@fal-ai/serverless-client");
fal.config({
credentials: falApiKey,
});
const falInput: Record<string, unknown> = {
prompt: rawPrompt,
negative_prompt: negativePrompt,
image_url: imageUrl,
image_size: body.imageSize === "square_hd" ? "square_hd" : "square",
num_inference_steps: 6,
schedule_mu: 2.3,
output_feedback_strength: 0.95,
sync_mode: true,
};
const falResult: any = await fal.subscribe(falRealtimeModel, {
input: falInput,
logs: false,
timeout: 90_000,
});
const falImageUrl = extractFalImageUrl(falResult);
if (falImageUrl) {
console.log("[RealtimeFal] frame success", {
userId: session.user.id,
requestId: body.requestId || null,
model: falRealtimeModel,
promptLength: rawPrompt.length,
qualityProfile,
});
return Response.json({
ok: true,
provider: "fal",
model: falRealtimeModel,
transport: "fal-http",
imageUrl: falImageUrl,
cost: 0,
});
}
console.warn("[RealtimeFal] empty output, fallback to Runware", {
userId: session.user.id,
requestId: body.requestId || null,
model: falRealtimeModel,
});
} catch (error) {
fallbackWarning =
error instanceof Error && /unauthorized|forbidden/i.test(error.message)
? "FAL 实时模型鉴权失败,当前已回退为 Runware 草图模式;请在系统设置更新有效的 FAL API Key。"
: "FAL 实时模型调用失败,当前已回退为 Runware 草图模式。";
console.warn("[RealtimeFal] request failed, fallback to Runware", {
userId: session.user.id,
requestId: body.requestId || null,
model: falRealtimeModel,
error: error instanceof Error ? error.message : String(error),
});
}
}
type InferenceRequestResult = {
taskUUID: string;
modelId: string;
runtimeStrength: number;
runtimeCfgScale: number;
runtimeSteps: number;
effectiveStrictSketch: boolean;
runtimeSeed?: number;
usedMaskImage: boolean;
controlGuideImage: string;
controlNetModel: string;
path: "seed-image" | "controlnet";
transport: "ws" | "http";
upstreamStatus: number;
upstreamOk: boolean;
upstreamJson: any;
upstreamError: string;
wsFallbackError: string;
};
type RunwareTaskPayload = {
taskType: string;
taskUUID?: string;
[key: string]: unknown;
};
const sendRunwareTask = async (runwareTask: RunwareTaskPayload, timeoutMs = 90_000) => {
let transport: "ws" | "http" = "http";
let upstreamStatus = 0;
let upstreamOk = false;
let upstreamJson: any = null;
let upstreamError = "";
let wsFallbackError = "";
if (preferWebSocket) {
try {
const wsClient = getRunwareWebSocketClient(runwareApiKey);
upstreamJson = await wsClient.sendTask(runwareTask, timeoutMs);
upstreamError = parseRunwareError(upstreamJson);
upstreamStatus = upstreamError ? 502 : 200;
upstreamOk = !upstreamError;
transport = "ws";
} catch (error) {
wsFallbackError = error instanceof Error ? error.message : "Runware WebSocket 调用失败";
}
}
if (!upstreamJson) {
const upstreamResponse = await fetch("https://api.runware.ai/v1", {
method: "POST",
headers: {
Authorization: `Bearer ${runwareApiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify([runwareTask]),
cache: "no-store",
});
upstreamJson = await upstreamResponse.json().catch(() => null);
upstreamError = parseRunwareError(upstreamJson);
upstreamStatus = upstreamResponse.status;
upstreamOk = upstreamResponse.ok && !upstreamError;
transport = "http";
}
return {
transport,
upstreamStatus,
upstreamOk,
upstreamJson,
upstreamError,
wsFallbackError,
};
};
const requestInference = async (
modelId: string,
useMaskImage: boolean,
sourceImageUrl: string
): Promise<InferenceRequestResult> => {
const taskUUID = crypto.randomUUID();
const usePromptStylize = hasUserPrompt;
const previewMode = qualityProfile === "preview";
const isStylizePass =
usePromptStylize &&
qualityProfile === "refine" &&
Boolean(stylizeModel) &&
modelId === stylizeModel;
const relaxStructureForPrompt = usePromptStylize && !isStylizePass;
const effectiveStrictSketch = strictSketch && !relaxStructureForPrompt;
const profileStrength = usePromptStylize
? previewMode
? clamp(Math.max(strength, 0.58), 0.05, 1)
: isStylizePass
? clamp(Math.max(strength, 0.72), 0.05, 1)
: clamp(Math.max(strength, 0.9), 0.05, 1)
: previewMode
? clamp(strength * 0.85, 0.05, 1)
: strength;
const profileCfgScale = usePromptStylize
? previewMode
? clamp(Math.max(5.5, Math.min(cfgScale, 8.5)), 1, 14)
: isStylizePass
? clamp(Math.max(6.5, Math.min(cfgScale, 8.5)), 1, 14)
: clamp(Math.max(6, Math.min(cfgScale, 8.5)), 1, 14)
: previewMode
? clamp(cfgScale * 0.9, 1, 14)
: cfgScale;
const profileSteps = usePromptStylize
? previewMode
? clamp(Math.max(steps, 6), 1, 12)
: isStylizePass
? clamp(Math.max(steps, 8), 1, 10)
: clamp(Math.max(steps, 12), 1, 12)
: previewMode
? clamp(Math.min(steps, 6), 1, 12)
: clamp(Math.max(steps, 6), 1, 12);
const runtimeStrength = effectiveStrictSketch
? (() => {
const minStrength = usePromptStylize && !previewMode ? 0.2 : 0.12;
const maxStrength =
isStylizePass ? 0.62 : usePromptStylize && !previewMode ? 0.72 : 0.55;
return clamp(profileStrength, minStrength, maxStrength);
})()
: profileStrength;
const runtimeCfgScale = effectiveStrictSketch
? clamp(
profileCfgScale,
isStylizePass ? 6.5 : usePromptStylize && !previewMode ? 5 : 6,
14
)
: profileCfgScale;
const runtimeSeed = relaxStructureForPrompt ? undefined : seed;
const isRunware106 = modelId.toLowerCase().startsWith("runware:106@");
const inferenceSize = isRunware106 ? 1024 : size;
const runtimeStrengthLimited = clamp(
runtimeStrength,
renderModeSettings.seedStrengthMin,
renderModeSettings.seedStrengthMax
);
const runwareTask = {
taskType: "imageInference",
taskUUID,
model: modelId,
positivePrompt,
negativePrompt,
seedImage: sourceImageUrl,
width: inferenceSize,
height: inferenceSize,
strength: runtimeStrengthLimited,
CFGScale: runtimeCfgScale,
steps: profileSteps,
includeCost: true,
numberResults: 1,
outputType: "dataURI",
outputFormat: "JPG",
...(useMaskImage && maskImageUrl ? { maskImage: maskImageUrl } : {}),
...(typeof runtimeSeed === "number" ? { seed: runtimeSeed } : {}),
};
const upstream = await sendRunwareTask(runwareTask);
return {
taskUUID,
modelId,
runtimeStrength: runtimeStrengthLimited,
runtimeCfgScale,
runtimeSteps: profileSteps,
effectiveStrictSketch,
runtimeSeed,
usedMaskImage: useMaskImage && Boolean(maskImageUrl),
controlGuideImage: "",
controlNetModel: "",
path: "seed-image" as const,
...upstream,
};
};
const requestWithMaskFallback = async (
modelId: string,
useMaskImage: boolean,
sourceImageUrl: string
) => {
let requestResult = await requestInference(modelId, useMaskImage, sourceImageUrl);
let maskEnabled = useMaskImage;
if (
maskEnabled &&
shouldRetryWithoutMask(requestResult.upstreamError || "")
) {
requestResult = await requestInference(modelId, false, sourceImageUrl);
maskEnabled = false;
}
return { requestResult, maskEnabled };
};
const requestControlNetGuide = async (sourceImageUrl: string) => {
const taskUUID = crypto.randomUUID();
const controlTask = {
taskType: "imageControlNetPreProcess",
taskUUID,
inputImage: sourceImageUrl,
preProcessorType: controlPreprocessor,
};
const upstream = await sendRunwareTask(controlTask, 45_000);
return {
taskUUID,
guideImage: extractControlGuideImage(upstream.upstreamJson, taskUUID),
...upstream,
};
};
const requestControlNetInference = async (
modelId: string,
guideImage: string,
includeNegativePrompt: boolean
): Promise<InferenceRequestResult> => {
const taskUUID = crypto.randomUUID();
const previewMode = qualityProfile === "preview";
const profileSteps = previewMode
? clamp(Math.max(steps, 6), 1, 12)
: clamp(Math.max(steps, 10), 1, 12);
const profileCfgScale = previewMode
? clamp(Math.max(5, Math.min(cfgScale, 8)), 1, 14)
: clamp(Math.max(6, Math.min(cfgScale, 9)), 1, 14);
const controlWeight = previewMode
? clamp(renderModeSettings.controlWeight - 0.06, 0.55, 1)
: renderModeSettings.controlWeight;
const runwareTask: RunwareTaskPayload = {
taskType: "imageInference",
taskUUID,
model: modelId,
positivePrompt,
width: size,
height: size,
CFGScale: profileCfgScale,
steps: profileSteps,
includeCost: true,
numberResults: 1,
outputType: "dataURI",
outputFormat: "JPG",
...(typeof seed === "number" ? { seed } : {}),
...(includeNegativePrompt ? { negativePrompt } : {}),
controlNet: [
{
model: controlModel,
guideImage,
weight: controlWeight,
startStep: 0,
endStep: profileSteps,
controlMode: "balanced",
},
],
};
const upstream = await sendRunwareTask(runwareTask, 120_000);
return {
taskUUID,
modelId,
runtimeStrength: 0,
runtimeCfgScale: profileCfgScale,
runtimeSteps: profileSteps,
effectiveStrictSketch: strictSketch,
runtimeSeed: typeof seed === "number" ? seed : undefined,
usedMaskImage: false,
controlGuideImage: guideImage,
controlNetModel: controlModel,
path: "controlnet" as const,
...upstream,
};
};
const runTwoPassStylize = false;
let maskEnabled = Boolean(maskImageUrl);
let requestResult: InferenceRequestResult;
let pathLabel: "seed-image" | "controlnet" = "seed-image";
let controlGuideUsed = "";
let controlPreprocessUsed = false;
let controlGuideFallback = false;
const shouldUseControlNetPath =
hasUserPrompt && qualityProfile === "refine" && strictSketch;
if (shouldUseControlNetPath) {
const guideResult = await requestControlNetGuide(imageUrl);
const preprocessOk = guideResult.upstreamOk && !guideResult.upstreamError;
const guideImage = preprocessOk && guideResult.guideImage ? guideResult.guideImage : imageUrl;
controlGuideUsed = guideImage;
controlPreprocessUsed = preprocessOk;
controlGuideFallback = !preprocessOk || !guideResult.guideImage;
if (!preprocessOk && (guideResult.upstreamError || "").trim()) {
fallbackWarning = "ControlNet 预处理失败,已回退使用原始线稿引导。";
}
let controlResult = await requestControlNetInference(model, guideImage, true);
if (shouldRetryWithoutNegativePrompt(controlResult.upstreamError || "")) {
controlResult = await requestControlNetInference(model, guideImage, false);
}
if (controlResult.upstreamOk && !controlResult.upstreamError) {
requestResult = controlResult;
maskEnabled = false;
pathLabel = "controlnet";
} else if (shouldRetryWithoutControlNet(controlResult.upstreamError || "")) {
fallbackWarning = "当前模型不支持 ControlNet,已回退到 seedImage 模式。";
const fallbackPass = await requestWithMaskFallback(stylizeModel || model, maskEnabled, imageUrl);
requestResult = fallbackPass.requestResult;
maskEnabled = fallbackPass.maskEnabled;
pathLabel = "seed-image";
} else {
requestResult = controlResult;
maskEnabled = false;
pathLabel = "controlnet";
}
} else if (runTwoPassStylize) {
const basePass = await requestWithMaskFallback(model, maskEnabled, imageUrl);
requestResult = basePass.requestResult;
maskEnabled = basePass.maskEnabled;
if (requestResult.upstreamOk && !requestResult.upstreamError) {
const baseImage = extractRunwareOutputImage(
requestResult.upstreamJson,
requestResult.taskUUID
).outputImage;
if (baseImage) {
console.log("[RealtimeRunware] two-pass base success", {
userId: session.user.id,
requestId: body.requestId || null,
baseModel: model,
stylizeModel,
baseImageType: baseImage.startsWith("data:") ? "dataURI" : "url",
});
const stylizePass = await requestWithMaskFallback(stylizeModel, false, baseImage);
requestResult = stylizePass.requestResult;
maskEnabled = stylizePass.maskEnabled;
}
}
} else {
const initialModel =
hasUserPrompt && qualityProfile === "refine" ? stylizeModel || model : model;
const initialPass = await requestWithMaskFallback(initialModel, maskEnabled, imageUrl);
requestResult = initialPass.requestResult;
maskEnabled = initialPass.maskEnabled;
pathLabel = "seed-image";
}
if (pathLabel === "controlnet" && (!requestResult.upstreamOk || requestResult.upstreamError)) {
fallbackWarning = "ControlNet 推理失败,已自动回退为 seedImage 模式。";
const fallbackPass = await requestWithMaskFallback(stylizeModel || model, maskEnabled, imageUrl);
requestResult = fallbackPass.requestResult;
maskEnabled = fallbackPass.maskEnabled;
pathLabel = "seed-image";
}
const shouldRetryWithSeedImageModel =
(requestResult.upstreamError || "")
.toLowerCase()
.includes("unsupported use of 'seedimage' parameter") &&
model !== fallbackSeedImageModel;
if (shouldRetryWithSeedImageModel) {
fallbackWarning =
"当前模型不支持草图引导,已自动切换到兼容的实时草图模型。";
const fallbackResult = await requestWithMaskFallback(
fallbackSeedImageModel,
maskEnabled,
imageUrl
);
requestResult = fallbackResult.requestResult;
maskEnabled = fallbackResult.maskEnabled;
}
if (!requestResult.upstreamOk || requestResult.upstreamError) {
return Response.json(
{
code: "RUNWARE_REQUEST_FAILED",
message:
requestResult.upstreamError ||
requestResult.wsFallbackError ||
`Runware 请求失败(${requestResult.upstreamStatus})`,
},
{ status: 502 }
);
}
const { imageResult, imageDataURI, imageURL, outputImage } = extractRunwareOutputImage(
requestResult.upstreamJson,
requestResult.taskUUID
);
if (!outputImage) {
return Response.json(
{ code: "RUNWARE_EMPTY_OUTPUT", message: "Runware 未返回图像" },
{ status: 502 }
);
}
const cost = Number(imageResult?.cost || 0);
console.log("[RealtimeRunware] frame success", {
userId: session.user.id,
requestId: body.requestId || null,
taskUUID: requestResult.taskUUID,
model: requestResult.modelId,
transport: requestResult.transport,
seed: typeof requestResult.runtimeSeed === "number" ? requestResult.runtimeSeed : null,
hasUserPrompt,
promptLength: rawPrompt.length,
promptPreview: hasUserPrompt ? rawPrompt.slice(0, 32) : null,
qualityProfile,
steps: requestResult.runtimeSteps,
cfgScale: requestResult.runtimeCfgScale,
strength: requestResult.runtimeStrength,
strictSketchApplied: requestResult.effectiveStrictSketch,
maskEnabled,
roi: body.roi || null,
cost,
renderMode,
path: pathLabel,
controlNetModel: pathLabel === "controlnet" ? requestResult.controlNetModel : null,
controlGuideType:
pathLabel === "controlnet" && controlGuideUsed
? controlGuideUsed.startsWith("data:")
? "dataURI"
: "url"
: null,
controlGuideFallback,
controlPreprocessUsed,
twoPassStylize: runTwoPassStylize,
fallbackWarning: fallbackWarning || null,
});
return Response.json({
ok: true,
provider: "runware",
model: requestResult.modelId,
transport: requestResult.transport,
taskUUID: requestResult.taskUUID,
path: pathLabel,
imageDataURI: imageDataURI || undefined,
imageUrl: imageURL || undefined,
cost,
warning: fallbackWarning || undefined,
});
}