ai-chatbot/lib/canvas/generate-image.ts

1640 lines
52 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 "server-only";
import sharp from "sharp";
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
import { createBflImage } from "@/lib/ai/providers/bfl-image";
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
import { createFalImage } from "@/lib/ai/providers/fal-image";
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
import { createMidjourney } from "@/lib/ai/providers/midjourney";
import { createRunway } from "@/lib/ai/providers/runway";
import { createKrea } from "@/lib/ai/providers/krea";
import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision";
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
import {
acquireCanvasImageModelSlot,
getCanvasImageModelQueueStatus,
type CanvasImageModelQueueStatus,
normalizeCanvasImageModelKey,
recordCanvasImageModelExecutionDuration,
} from "./image-concurrency";
import {
recordAiModelHealthFailure,
recordAiModelHealthSuccess,
shouldFailoverAiModelError,
} from "@/lib/ai/runtime/model-health";
import { uploadFile } from "@/lib/storage/service";
export interface CanvasLocalEditCompositeRegion {
x: number;
y: number;
width: number;
height: number;
}
export interface CanvasLocalEditCompositeConfig {
sourceImageUrl: string;
regions: CanvasLocalEditCompositeRegion[];
}
export interface CanvasScaleAdjustVerificationConfig {
sourceImageUrl: string;
regions: CanvasLocalEditCompositeRegion[];
scalePercent: number;
}
export interface CanvasGenerateImageRequest {
prompt: string;
displayPrompt?: string;
negativePrompt?: string;
model?: string;
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
promptExtend?: boolean;
referenceImage?: string;
referenceImages?: string[];
billingFeature?: "multi-view" | "repaint";
ownerKey?: string;
debugLabel?: string;
debugMeta?: Record<string, unknown>;
localEditComposite?: CanvasLocalEditCompositeConfig;
scaleAdjustVerification?: CanvasScaleAdjustVerificationConfig;
allowFailover?: boolean;
internalFailoverTriedModelRefs?: string[];
onQueueStatusChange?: (
status: CanvasImageModelQueueStatus & {
modelName?: string | null;
modelRef?: string | null;
}
) => void | Promise<void>;
}
export interface ParsedImage {
thumbnailUrl: string;
originalUrl: string;
}
export interface CanvasGenerateImageResult {
images: ParsedImage[];
model: string;
modelRef: string;
prompt: string;
}
class ScaleAdjustOutputRejectedError extends Error {
reason: string;
diffScores?: number[];
outsideDiffScores?: number[];
constructor(params: {
reason: string;
diffScores?: number[];
outsideDiffScores?: number[];
}) {
const message =
params.reason === "outside-frame-changed"
? "比例调整结果改变了框选区域外的画面,已拒绝该模型结果"
: "比例调整结果没有产生有效变化,已拒绝该模型结果";
super(message);
this.name = "ScaleAdjustOutputRejectedError";
this.reason = params.reason;
this.diffScores = params.diffScores;
this.outsideDiffScores = params.outsideDiffScores;
}
}
const LOCAL_EDIT_COMPOSITE_PADDING_RATIO = 0.14;
const LOCAL_EDIT_COMPOSITE_MIN_PADDING_RATIO = 0.012;
const LOCAL_EDIT_COMPOSITE_MAX_PADDING_RATIO = 0.06;
const LOCAL_EDIT_COMPOSITE_FEATHER_MIN_PX = 10;
const LOCAL_EDIT_COMPOSITE_FEATHER_MAX_PX = 44;
const SCALE_ADJUST_VISUAL_DIFF_THRESHOLD = 14;
const SCALE_ADJUST_OUTSIDE_DIFF_THRESHOLD = 18;
const SCALE_ADJUST_OUTSIDE_DIFF_MIN_CHANGED_RATIO = 0.28;
const SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN = 0.015;
const SCALE_ADJUST_DIFF_SAMPLE_SIZE = 112;
const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
"gemini",
"grsai-gpt-image",
"alibaba-image",
"bfl-image",
"ruxa-image",
"fal-image",
"openai-image",
"stepfun-image",
"midjourney",
"runway",
"krea",
"volcano-vision",
]);
function summarizeReferenceImageHosts(urls: string[]) {
return Array.from(
new Set(
urls
.map((value) => {
try {
return new URL(value).host;
} catch {
return "";
}
})
.filter(Boolean)
)
);
}
function parseImagesFromMarkdown(markdown: string): ParsedImage[] {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...markdown.matchAll(imageRegex)];
const parsed: ParsedImage[] = [];
for (const match of matches) {
const altText = match[1] || "";
const thumbnailUrl = match[2];
let originalUrl = thumbnailUrl;
if (altText.includes("thumb|||")) {
const content = altText.substring(altText.indexOf("thumb|||") + 8);
const parts = content.split("|||");
originalUrl = parts[0] || thumbnailUrl;
} else if (altText.includes("thumb$$")) {
const content = altText.substring(altText.indexOf("thumb$$") + 7);
const parts = content.split("$$");
originalUrl = parts[0] || thumbnailUrl;
} else if (altText.includes("thumb|")) {
const content = altText.substring(altText.indexOf("thumb|") + 6);
const parts = content.split("|");
originalUrl = parts[0] || thumbnailUrl;
}
parsed.push({ thumbnailUrl, originalUrl });
}
return parsed;
}
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
function clampRatio(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.min(1, Math.max(0, value));
}
function expandLocalEditCompositeRegion(region: CanvasLocalEditCompositeRegion) {
const padX = clampRatio(
Math.min(
LOCAL_EDIT_COMPOSITE_MAX_PADDING_RATIO,
Math.max(
LOCAL_EDIT_COMPOSITE_MIN_PADDING_RATIO,
region.width * LOCAL_EDIT_COMPOSITE_PADDING_RATIO
)
)
);
const padY = clampRatio(
Math.min(
LOCAL_EDIT_COMPOSITE_MAX_PADDING_RATIO,
Math.max(
LOCAL_EDIT_COMPOSITE_MIN_PADDING_RATIO,
region.height * LOCAL_EDIT_COMPOSITE_PADDING_RATIO
)
)
);
const left = clampRatio(region.x - padX);
const top = clampRatio(region.y - padY);
const right = clampRatio(region.x + region.width + padX);
const bottom = clampRatio(region.y + region.height + padY);
return {
x: left,
y: top,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
};
}
function normalizeLocalEditCompositeConfig(
value: CanvasGenerateImageRequest["localEditComposite"]
): CanvasLocalEditCompositeConfig | null {
if (!value || typeof value !== "object") return null;
const sourceImageUrl = String(value.sourceImageUrl || "").trim();
if (!/^https?:\/\//i.test(sourceImageUrl)) return null;
if (!Array.isArray(value.regions) || value.regions.length === 0) return null;
const regions = value.regions
.map((region) => {
const x = clampRatio(Number(region?.x));
const y = clampRatio(Number(region?.y));
const width = clampRatio(Number(region?.width));
const height = clampRatio(Number(region?.height));
return {
x,
y,
width: Math.min(width, Math.max(0, 1 - x)),
height: Math.min(height, Math.max(0, 1 - y)),
};
})
.map(expandLocalEditCompositeRegion)
.filter((region) => region.width > 0 && region.height > 0);
return regions.length > 0 ? { sourceImageUrl, regions } : null;
}
function normalizeCanvasRegion(region: unknown): CanvasLocalEditCompositeRegion | null {
if (!region || typeof region !== "object") return null;
const raw = region as Partial<CanvasLocalEditCompositeRegion>;
const x = clampRatio(Number(raw.x));
const y = clampRatio(Number(raw.y));
const width = clampRatio(Number(raw.width));
const height = clampRatio(Number(raw.height));
const normalized = {
x,
y,
width: Math.min(width, Math.max(0, 1 - x)),
height: Math.min(height, Math.max(0, 1 - y)),
};
return normalized.width > 0 && normalized.height > 0 ? normalized : null;
}
function normalizeScaleAdjustVerificationConfig(
value: CanvasGenerateImageRequest["scaleAdjustVerification"]
): CanvasScaleAdjustVerificationConfig | null {
if (!value || typeof value !== "object") return null;
const sourceImageUrl = String(value.sourceImageUrl || "").trim();
if (!/^https?:\/\//i.test(sourceImageUrl)) return null;
const scalePercent = Number(value.scalePercent);
if (!Number.isFinite(scalePercent) || Math.abs(scalePercent) < 1) return null;
if (!Array.isArray(value.regions) || value.regions.length === 0) return null;
const regions = value.regions
.map((region) => normalizeCanvasRegion(region))
.filter((region): region is CanvasLocalEditCompositeRegion => Boolean(region));
return regions.length > 0 ? { sourceImageUrl, regions, scalePercent } : null;
}
function scaleRegionAroundCenter(
region: CanvasLocalEditCompositeRegion,
scalePercent: number
): CanvasLocalEditCompositeRegion {
const requestedFactor = Math.max(0.05, 1 + scalePercent / 100);
const maxUniformFactor = Math.max(
0.05,
Math.min(
region.width > 0 ? 1 / region.width : requestedFactor,
region.height > 0 ? 1 / region.height : requestedFactor
)
);
const factor = Math.min(requestedFactor, maxUniformFactor);
let width = clampRatio(region.width * factor);
let height = clampRatio(region.height * factor);
const centerX = region.x + region.width / 2;
const centerY = region.y + region.height / 2;
let x = centerX - width / 2;
let y = centerY - height / 2;
if (x < 0) x = 0;
if (y < 0) y = 0;
if (x + width > 1) x = 1 - width;
if (y + height > 1) y = 1 - height;
x = clampRatio(x);
y = clampRatio(y);
width = Math.min(width, Math.max(0, 1 - x));
height = Math.min(height, Math.max(0, 1 - y));
return { x, y, width, height };
}
function expandCanvasRegion(
region: CanvasLocalEditCompositeRegion,
margin: number
): CanvasLocalEditCompositeRegion {
const x = clampRatio(region.x - margin);
const y = clampRatio(region.y - margin);
const right = clampRatio(region.x + region.width + margin);
const bottom = clampRatio(region.y + region.height + margin);
return {
x,
y,
width: Math.max(0, right - x),
height: Math.max(0, bottom - y),
};
}
function canvasRegionsIntersect(
left: CanvasLocalEditCompositeRegion,
right: CanvasLocalEditCompositeRegion
) {
return !(
left.x + left.width <= right.x ||
right.x + right.width <= left.x ||
left.y + left.height <= right.y ||
right.y + right.height <= left.y
);
}
function getScaleAdjustProtectedRegions(
verification: CanvasScaleAdjustVerificationConfig
) {
return verification.regions.flatMap((region) => [
expandCanvasRegion(region, SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN),
expandCanvasRegion(
scaleRegionAroundCenter(region, verification.scalePercent),
SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN
),
]);
}
function getScaleAdjustOutsideSampleRegions(
verification: CanvasScaleAdjustVerificationConfig
) {
const protectedRegions = getScaleAdjustProtectedRegions(verification);
const fixedCandidates: CanvasLocalEditCompositeRegion[] = [
{ x: 0.03, y: 0.03, width: 0.2, height: 0.16 },
{ x: 0.4, y: 0.03, width: 0.2, height: 0.16 },
{ x: 0.77, y: 0.03, width: 0.2, height: 0.16 },
{ x: 0.03, y: 0.28, width: 0.2, height: 0.16 },
{ x: 0.77, y: 0.28, width: 0.2, height: 0.16 },
{ x: 0.03, y: 0.55, width: 0.2, height: 0.16 },
{ x: 0.77, y: 0.55, width: 0.2, height: 0.16 },
{ x: 0.03, y: 0.81, width: 0.2, height: 0.16 },
{ x: 0.4, y: 0.81, width: 0.2, height: 0.16 },
{ x: 0.77, y: 0.81, width: 0.2, height: 0.16 },
{ x: 0.28, y: 0.28, width: 0.18, height: 0.16 },
{ x: 0.54, y: 0.28, width: 0.18, height: 0.16 },
{ x: 0.28, y: 0.55, width: 0.18, height: 0.16 },
{ x: 0.54, y: 0.55, width: 0.18, height: 0.16 },
];
const gridCandidates: CanvasLocalEditCompositeRegion[] = [];
const columns = 7;
const rows = 7;
const cellWidth = 0.12;
const cellHeight = 0.1;
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
gridCandidates.push({
x: (column / Math.max(1, columns - 1)) * (1 - cellWidth),
y: (row / Math.max(1, rows - 1)) * (1 - cellHeight),
width: cellWidth,
height: cellHeight,
});
}
}
const candidates = [...fixedCandidates, ...gridCandidates];
const uniqueCandidates = candidates.filter((candidate, index) => {
const key = `${candidate.x.toFixed(3)}:${candidate.y.toFixed(3)}:${candidate.width.toFixed(3)}:${candidate.height.toFixed(3)}`;
return (
candidates.findIndex(
(item) =>
`${item.x.toFixed(3)}:${item.y.toFixed(3)}:${item.width.toFixed(3)}:${item.height.toFixed(3)}` ===
key
) === index
);
});
return uniqueCandidates
.filter(
(candidate) =>
!protectedRegions.some((protectedRegion) =>
canvasRegionsIntersect(candidate, protectedRegion)
)
)
.slice(0, 28);
}
function rectToPixelBox(params: {
region: CanvasLocalEditCompositeRegion;
width: number;
height: number;
}) {
const { region, width, height } = params;
const left = Math.max(
0,
Math.min(width - 1, Math.round(region.x * width))
);
const top = Math.max(
0,
Math.min(height - 1, Math.round(region.y * height))
);
const right = Math.max(
left + 1,
Math.min(width, Math.round((region.x + region.width) * width))
);
const bottom = Math.max(
top + 1,
Math.min(height, Math.round((region.y + region.height) * height))
);
return {
left,
top,
width: Math.max(1, right - left),
height: Math.max(1, bottom - top),
};
}
async function getScaleAdjustRegionSample(params: {
buffer: Buffer;
sourceWidth: number;
sourceHeight: number;
region: CanvasLocalEditCompositeRegion;
}) {
const normalizedBuffer = await sharp(params.buffer, { failOn: "none" })
.rotate()
.resize(params.sourceWidth, params.sourceHeight, {
fit: "cover",
position: "center",
})
.png({ compressionLevel: 6 })
.toBuffer();
const box = rectToPixelBox({
region: params.region,
width: params.sourceWidth,
height: params.sourceHeight,
});
return sharp(normalizedBuffer, { failOn: "none" })
.extract(box)
.resize(SCALE_ADJUST_DIFF_SAMPLE_SIZE, SCALE_ADJUST_DIFF_SAMPLE_SIZE, {
fit: "fill",
})
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer();
}
function calculateMeanAbsoluteDiff(left: Buffer, right: Buffer) {
const length = Math.min(left.length, right.length);
if (length <= 0) return Number.POSITIVE_INFINITY;
let sum = 0;
for (let index = 0; index < length; index += 1) {
sum += Math.abs(left[index] - right[index]);
}
return sum / length;
}
async function getScaleAdjustDiffScores(params: {
generatedBuffer: Buffer;
sourceBuffer: Buffer;
sourceWidth: number;
sourceHeight: number;
regions: CanvasLocalEditCompositeRegion[];
}) {
const scores: number[] = [];
for (const region of params.regions) {
const [sourceSample, generatedSample] = await Promise.all([
getScaleAdjustRegionSample({
buffer: params.sourceBuffer,
sourceWidth: params.sourceWidth,
sourceHeight: params.sourceHeight,
region,
}),
getScaleAdjustRegionSample({
buffer: params.generatedBuffer,
sourceWidth: params.sourceWidth,
sourceHeight: params.sourceHeight,
region,
}),
]);
scores.push(calculateMeanAbsoluteDiff(sourceSample, generatedSample));
}
return scores;
}
async function getScaleAdjustOutsideDiffScores(params: {
generatedBuffer: Buffer;
sourceBuffer: Buffer;
sourceWidth: number;
sourceHeight: number;
verification: CanvasScaleAdjustVerificationConfig;
}) {
const sampleRegions = getScaleAdjustOutsideSampleRegions(params.verification);
const scores: number[] = [];
for (const region of sampleRegions) {
const [sourceSample, generatedSample] = await Promise.all([
getScaleAdjustRegionSample({
buffer: params.sourceBuffer,
sourceWidth: params.sourceWidth,
sourceHeight: params.sourceHeight,
region,
}),
getScaleAdjustRegionSample({
buffer: params.generatedBuffer,
sourceWidth: params.sourceWidth,
sourceHeight: params.sourceHeight,
region,
}),
]);
scores.push(calculateMeanAbsoluteDiff(sourceSample, generatedSample));
}
return scores;
}
function isScaleAdjustOutsideChanged(scores: number[]) {
if (scores.length < 2) return false;
const changedCount = scores.filter(
(score) => score >= SCALE_ADJUST_OUTSIDE_DIFF_THRESHOLD
).length;
const requiredCount = Math.max(
3,
Math.ceil(scores.length * SCALE_ADJUST_OUTSIDE_DIFF_MIN_CHANGED_RATIO)
);
return changedCount >= requiredCount;
}
async function loadScaleAdjustSourceImage(
verification: CanvasScaleAdjustVerificationConfig
) {
const sourceBuffer = await sharp(
await fetchImageBufferFromUrl(verification.sourceImageUrl),
{ failOn: "none" }
)
.rotate()
.png({ compressionLevel: 6 })
.toBuffer();
const sourceMetadata = await sharp(sourceBuffer, { failOn: "none" }).metadata();
const sourceWidth = Number(sourceMetadata.width || 0);
const sourceHeight = Number(sourceMetadata.height || 0);
if (sourceWidth <= 0 || sourceHeight <= 0) {
return null;
}
return {
sourceBuffer,
sourceHeight,
sourceWidth,
};
}
async function applyScaleAdjustVerificationToImages(params: {
images: ParsedImage[];
verification: CanvasScaleAdjustVerificationConfig | null;
debugLabel: string;
}) {
const { images, verification, debugLabel } = params;
if (!verification || images.length === 0) return images;
try {
const sourceImage = await loadScaleAdjustSourceImage(verification);
if (!sourceImage) {
return images;
}
const { sourceBuffer, sourceHeight, sourceWidth } = sourceImage;
const checkedImages: ParsedImage[] = [];
for (const [index, image] of images.entries()) {
const generatedUrl = image.originalUrl || image.thumbnailUrl;
const generatedBuffer = Buffer.from(await fetchImageBufferFromUrl(generatedUrl));
const diffScores = await getScaleAdjustDiffScores({
generatedBuffer,
sourceBuffer,
sourceHeight,
sourceWidth,
regions: verification.regions,
});
const outsideDiffScores = await getScaleAdjustOutsideDiffScores({
generatedBuffer,
sourceBuffer,
sourceHeight,
sourceWidth,
verification,
});
const isVisuallyUnchanged =
diffScores.length > 0 &&
diffScores.every((score) => score < SCALE_ADJUST_VISUAL_DIFF_THRESHOLD);
const outsideChanged = isScaleAdjustOutsideChanged(outsideDiffScores);
if (!isVisuallyUnchanged && !outsideChanged) {
console.info("[Canvas Generate Image] Scale adjust output accepted:", {
debugLabel,
diffScores: diffScores.map((score) => Math.round(score * 10) / 10),
imageIndex: index + 1,
outsideDiffScores: outsideDiffScores.map(
(score) => Math.round(score * 10) / 10
),
scalePercent: verification.scalePercent,
});
checkedImages.push(image);
continue;
}
const rejection = new ScaleAdjustOutputRejectedError({
diffScores,
outsideDiffScores,
reason: outsideChanged ? "outside-frame-changed" : "unchanged-output",
});
console.warn("[Canvas Generate Image] Scale adjust output rejected:", {
debugLabel,
diffScores: diffScores.map((score) => Math.round(score * 10) / 10),
imageIndex: index + 1,
outsideDiffScores: outsideDiffScores.map(
(score) => Math.round(score * 10) / 10
),
reason: rejection.reason,
scalePercent: verification.scalePercent,
});
throw rejection;
}
return checkedImages;
} catch (error) {
if (error instanceof ScaleAdjustOutputRejectedError) {
throw error;
}
console.warn("[Canvas Generate Image] Scale adjust verification skipped:", {
debugLabel,
error: error instanceof Error ? error.message : String(error),
regionCount: verification.regions.length,
scalePercent: verification.scalePercent,
});
return images;
}
}
function buildLocalEditCompositeMaskSvg(params: {
width: number;
height: number;
regions: CanvasLocalEditCompositeRegion[];
}) {
const { width, height, regions } = params;
const blur = Math.max(
LOCAL_EDIT_COMPOSITE_FEATHER_MIN_PX,
Math.min(
LOCAL_EDIT_COMPOSITE_FEATHER_MAX_PX,
Math.round(Math.min(width, height) * 0.012)
)
);
const rects = regions
.map((region) => {
const left = Math.max(0, Math.round(region.x * width));
const top = Math.max(0, Math.round(region.y * height));
const rectWidth = Math.min(
width - left,
Math.max(1, Math.round(region.width * width))
);
const rectHeight = Math.min(
height - top,
Math.max(1, Math.round(region.height * height))
);
return `<rect x="${left}" y="${top}" width="${rectWidth}" height="${rectHeight}" rx="${Math.round(blur * 0.9)}" fill="#ffffff" />`;
})
.join("");
return Buffer.from(`
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="local-edit-soft-mask" x="-30%" y="-30%" width="160%" height="160%" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="${blur}" />
</filter>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" fill="rgba(0,0,0,0)" />
<g filter="url(#local-edit-soft-mask)">
${rects}
</g>
${rects}
</svg>
`);
}
async function applyLocalEditCompositeToImages(params: {
images: ParsedImage[];
composite: CanvasLocalEditCompositeConfig | null;
debugLabel: string;
}) {
const { images, composite, debugLabel } = params;
if (!composite || images.length === 0) return images;
const sourceBuffer = await sharp(
await fetchImageBufferFromUrl(composite.sourceImageUrl),
{ failOn: "none" }
)
.rotate()
.png({ compressionLevel: 6 })
.toBuffer();
const sourceMetadata = await sharp(sourceBuffer, { failOn: "none" }).metadata();
const sourceWidth = Number(sourceMetadata.width || 0);
const sourceHeight = Number(sourceMetadata.height || 0);
if (sourceWidth <= 0 || sourceHeight <= 0) {
throw new Error("局部编辑结果合成失败:无法识别原图尺寸");
}
const maskSvg = buildLocalEditCompositeMaskSvg({
height: sourceHeight,
regions: composite.regions,
width: sourceWidth,
});
const mergedImages: ParsedImage[] = [];
for (const [index, image] of images.entries()) {
const generatedUrl = image.originalUrl || image.thumbnailUrl;
const generatedBuffer = await fetchImageBufferFromUrl(generatedUrl);
const overlayBuffer = await sharp(generatedBuffer, { failOn: "none" })
.rotate()
.resize(sourceWidth, sourceHeight, { fit: "cover", position: "center" })
.ensureAlpha()
.composite([{ input: maskSvg, blend: "dest-in" }])
.png({ compressionLevel: 6 })
.toBuffer();
const mergedBuffer = await sharp(sourceBuffer, { failOn: "none" })
.ensureAlpha()
.composite([{ input: overlayBuffer, left: 0, top: 0 }])
.png({ compressionLevel: 6 })
.toBuffer();
const thumbnailBuffer = await sharp(mergedBuffer, { failOn: "none" })
.resize(512, 512, { fit: "inside", withoutEnlargement: true })
.png({ compressionLevel: 6 })
.toBuffer();
const suffix = `${Date.now()}-${index + 1}-${Math.random().toString(36).slice(2, 8)}`;
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(
`canvas-local-edit-composited-${suffix}.png`,
bufferToArrayBuffer(mergedBuffer)
),
uploadFile(
`canvas-local-edit-composited-thumb-${suffix}.png`,
bufferToArrayBuffer(thumbnailBuffer)
),
]);
mergedImages.push({
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url,
});
}
console.info("[Canvas Generate Image] Local edit composite applied:", {
debugLabel,
imageCount: mergedImages.length,
regionCount: composite.regions.length,
sourceHeight,
sourceWidth,
});
return mergedImages;
}
function getProviderBatchLimit(providerType: string) {
switch (providerType) {
case "ruxa-image":
case "bfl-image":
case "openai-image":
case "stepfun-image":
case "midjourney":
case "runway":
return 1;
case "krea":
return 4;
case "gemini":
case "grsai-gpt-image":
return 2;
case "alibaba-image":
case "fal-image":
return 6;
default:
return 2;
}
}
type CanvasModelWithProvider = Awaited<
ReturnType<typeof getEnabledAiModelsWithProvider>
>[number];
type CanvasImageSizeValue = "1K" | "2K" | "4K";
const CANVAS_FAILOVER_SAME_NAME_THRESHOLD = 0.5;
interface CanvasImageGenConfig {
maxImages?: number;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
}
function normalizeCanvasImageSizeValue(
value?: string,
fallback: CanvasImageSizeValue = "2K"
): CanvasImageSizeValue {
const normalized = String(value || "").trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
return normalized;
}
return fallback;
}
function isCanvasImageSizeSupported(
config: CanvasImageGenConfig | null | undefined,
size: CanvasImageSizeValue
) {
if (!config) return true;
if (size === "1K") return config.supports1K !== false;
if (size === "2K") return config.supports2K !== false;
if (size === "4K") return config.supports4K !== false;
return true;
}
function pickSupportedCanvasImageSize(
config: CanvasImageGenConfig | null | undefined,
requestedSize: CanvasImageSizeValue
): CanvasImageSizeValue {
if (isCanvasImageSizeSupported(config, requestedSize)) return requestedSize;
const preferredFallbacks: Record<CanvasImageSizeValue, CanvasImageSizeValue[]> = {
"4K": ["2K", "1K"],
"2K": ["1K"],
"1K": ["2K", "4K"],
};
return (
preferredFallbacks[requestedSize].find((candidate) =>
isCanvasImageSizeSupported(config, candidate)
) ||
(["1K", "2K", "4K"] as const).find((candidate) =>
isCanvasImageSizeSupported(config, candidate)
) ||
"2K"
);
}
function normalizeCanvasImageParamsByModel(params: {
imageCount: number;
imageSize: string;
imageGenConfig: CanvasImageGenConfig | null | undefined;
}) {
const maxImages =
typeof params.imageGenConfig?.maxImages === "number" &&
Number.isFinite(params.imageGenConfig.maxImages)
? Math.max(1, Math.floor(params.imageGenConfig.maxImages))
: 12;
const imageCount = Math.max(1, Math.min(maxImages, Math.floor(params.imageCount)));
const requestedSize = normalizeCanvasImageSizeValue(params.imageSize);
const imageSize = pickSupportedCanvasImageSize(params.imageGenConfig, requestedSize);
return { imageCount, imageSize };
}
function getCanvasModelRefKeys(item: CanvasModelWithProvider) {
return [
String(item.model.id || "").trim(),
String(item.model.modelId || "").trim(),
].filter(Boolean);
}
function normalizeModelNameForSimilarity(value: unknown) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[「」『』【】[\]()(){}]/g, " ")
.replace(/[_/|::,,.。+]+/g, "-")
.replace(/\s+/g, " ");
}
function tokenizeModelNameForSimilarity(value: unknown) {
const normalized = normalizeModelNameForSimilarity(value);
const compact = normalized.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "");
const tokens = new Set(
normalized
.split(/[^a-z0-9\u4e00-\u9fa5]+/g)
.map((item) => item.trim())
.filter((item) => item.length >= 2)
);
if (compact.length >= 2) {
tokens.add(compact);
for (let index = 0; index < compact.length - 1; index += 1) {
tokens.add(compact.slice(index, index + 2));
}
}
return tokens;
}
function scoreModelNameSimilarity(
source: CanvasModelWithProvider,
candidate: CanvasModelWithProvider
) {
const sourceNames = [
source.model.displayName,
source.model.modelId,
].filter(Boolean);
const candidateNames = [
candidate.model.displayName,
candidate.model.modelId,
].filter(Boolean);
let bestScore = 0;
for (const sourceName of sourceNames) {
const sourceTokens = tokenizeModelNameForSimilarity(sourceName);
if (sourceTokens.size === 0) continue;
for (const candidateName of candidateNames) {
const candidateTokens = tokenizeModelNameForSimilarity(candidateName);
if (candidateTokens.size === 0) continue;
let overlap = 0;
sourceTokens.forEach((token) => {
if (candidateTokens.has(token)) overlap += 1;
});
const score = overlap / Math.max(sourceTokens.size, candidateTokens.size);
bestScore = Math.max(bestScore, score);
}
}
return bestScore;
}
function findCanvasFailoverModel(params: {
selectedModel: CanvasModelWithProvider;
imageModels: CanvasModelWithProvider[];
failedModelRefs: string[];
}) {
const failedRefs = new Set(
params.failedModelRefs.map((item) => String(item || "").trim()).filter(Boolean)
);
const selectedFallbackRefs = [
params.selectedModel.model.fallbackModelId1,
params.selectedModel.model.fallbackModelId2,
]
.map((item) => String(item || "").trim())
.filter(Boolean);
const isUsableFallback = (item: CanvasModelWithProvider) => {
if (!item.model.isEnabled || item.model.isSubModel || item.model.excludeFromFailover) {
return false;
}
if (!item.model.supportsImageGen) return false;
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(item.provider.type)) return false;
return getCanvasModelRefKeys(item).every((key) => !failedRefs.has(key));
};
for (const fallbackRef of selectedFallbackRefs) {
const configured = params.imageModels.find(
(item) =>
isUsableFallback(item) &&
(item.model.id === fallbackRef || item.model.modelId === fallbackRef)
);
if (configured) return configured;
}
const usableModels = params.imageModels.filter(isUsableFallback);
const scoredModels = usableModels
.map((item, index) => ({
item,
index,
nameSimilarity: scoreModelNameSimilarity(params.selectedModel, item),
sameProviderType: item.provider.type === params.selectedModel.provider.type,
}))
.sort((left, right) => {
const leftSameName =
left.nameSimilarity >= CANVAS_FAILOVER_SAME_NAME_THRESHOLD;
const rightSameName =
right.nameSimilarity >= CANVAS_FAILOVER_SAME_NAME_THRESHOLD;
if (leftSameName !== rightSameName) return leftSameName ? -1 : 1;
if (left.sameProviderType !== right.sameProviderType) {
return left.sameProviderType ? -1 : 1;
}
if (right.nameSimilarity !== left.nameSimilarity) {
return right.nameSimilarity - left.nameSimilarity;
}
return left.index - right.index;
});
return scoredModels[0]?.item || null;
}
export async function generateCanvasImage(
request: CanvasGenerateImageRequest
): Promise<CanvasGenerateImageResult> {
const startedAt = Date.now();
const prompt = String(request.prompt || "").trim();
if (!prompt) {
throw new Error("请输入提示词");
}
const negativePrompt =
typeof request.negativePrompt === "string" ? request.negativePrompt : undefined;
const model = typeof request.model === "string" ? request.model : undefined;
const aspectRatio =
typeof request.aspectRatio === "string" && request.aspectRatio.trim()
? request.aspectRatio.trim()
: "auto";
const requestedImageSize =
typeof request.imageSize === "string" && request.imageSize.trim()
? request.imageSize.trim()
: "2K";
const requestedImageCount =
typeof request.imageCount === "number" && Number.isFinite(request.imageCount)
? Math.max(1, Math.min(12, Math.floor(request.imageCount)))
: 1;
const promptExtend =
typeof request.promptExtend === "boolean" ? request.promptExtend : undefined;
const ownerKey =
typeof request.ownerKey === "string" && request.ownerKey.trim()
? request.ownerKey.trim()
: undefined;
const referenceImage =
typeof request.referenceImage === "string" ? request.referenceImage : undefined;
const referenceImages = Array.isArray(request.referenceImages)
? request.referenceImages
: [];
const debugLabel = String(request.debugLabel || "").trim() || "canvas-image";
const debugMeta =
request.debugMeta && typeof request.debugMeta === "object"
? request.debugMeta
: undefined;
const localEditComposite = normalizeLocalEditCompositeConfig(
request.localEditComposite
);
const scaleAdjustVerification = normalizeScaleAdjustVerificationConfig(
request.scaleAdjustVerification
);
const allModels = await getEnabledAiModelsWithProvider();
const imageModels = allModels.filter((m) => m.model.supportsImageGen);
if (!imageModels || imageModels.length === 0) {
throw new Error("暂无可用的图片生成模型");
}
let selectedModel = model
? imageModels.find((m) => m.model.modelId === model || m.model.id === model)
: imageModels.find((m) => m.model.isDefault) || imageModels[0];
if (!selectedModel) {
selectedModel = imageModels[0];
}
let provider = selectedModel.provider;
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(provider.type)) {
if (request.allowFailover === false) {
throw new Error(
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/OpenAI/StepFun/Runway/Krea 图片模型。"
);
}
let fallbackModel = selectedModel;
let fallbackProvider = provider;
for (const candidate of imageModels) {
const candidateProvider = candidate.provider;
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(candidateProvider.type)) continue;
fallbackModel = candidate;
fallbackProvider = candidateProvider;
break;
}
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(fallbackProvider.type)) {
throw new Error(
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/OpenAI/StepFun/Runway/Krea 图片模型。"
);
}
if (fallbackModel.model.id !== selectedModel.model.id) {
console.warn("[Canvas Generate Image] Fallback to supported image model:", {
requestedModel: selectedModel.model.modelId || selectedModel.model.id,
fallbackModel: fallbackModel.model.modelId || fallbackModel.model.id,
requestedProviderType: provider.type,
fallbackProviderType: fallbackProvider.type,
});
}
selectedModel = fallbackModel;
provider = fallbackProvider;
}
const normalizedImageParams = normalizeCanvasImageParamsByModel({
imageCount: requestedImageCount,
imageSize: requestedImageSize,
imageGenConfig: selectedModel.model.imageGenConfig as CanvasImageGenConfig | null | undefined,
});
const imageSize = normalizedImageParams.imageSize;
const imageCount = normalizedImageParams.imageCount;
if (imageSize !== requestedImageSize || imageCount !== requestedImageCount) {
console.info("[Canvas Generate Image] Normalized model image params:", {
debugLabel,
requestedImageSize,
normalizedImageSize: imageSize,
requestedImageCount,
normalizedImageCount: imageCount,
selectedModelRef: selectedModel.model.id,
selectedModelId: selectedModel.model.modelId,
providerType: provider.type,
});
}
const normalizedReferenceImages = Array.from(
new Set(
[
...referenceImages,
...(referenceImage ? [referenceImage] : []),
]
.map((item) => String(item || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
).slice(0, 12);
const { kept: reachableReferenceImages, dropped: unreachableReferenceImages } =
await filterReachableRemoteUrls(normalizedReferenceImages, { timeoutMs: 3000 });
const effectiveReferenceImages =
reachableReferenceImages.length > 0
? reachableReferenceImages
: normalizedReferenceImages;
if (unreachableReferenceImages.length > 0) {
console.warn("[Canvas Generate Image] Local reachability check failed for some reference images:", {
count: unreachableReferenceImages.length,
urls: unreachableReferenceImages.slice(0, 6),
hosts: summarizeReferenceImageHosts(unreachableReferenceImages),
});
}
if (normalizedReferenceImages.length > 0 && reachableReferenceImages.length === 0) {
console.warn(
"[Canvas Generate Image] All reference images failed local reachability check; proceeding with original URLs.",
{
count: normalizedReferenceImages.length,
hosts: summarizeReferenceImageHosts(normalizedReferenceImages),
debugLabel,
}
);
}
console.info("[Canvas Generate Image] Started:", {
debugLabel,
promptLength: prompt.length,
aspectRatio,
imageSize,
imageCount,
requestedReferenceImageCount: normalizedReferenceImages.length,
reachableReferenceImageCount: reachableReferenceImages.length,
effectiveReferenceImageCount: effectiveReferenceImages.length,
referenceImageHosts: summarizeReferenceImageHosts(effectiveReferenceImages),
selectedModelRef: selectedModel.model.id,
selectedModelId: selectedModel.model.modelId,
providerType: provider.type,
...debugMeta,
hasLocalEditComposite: Boolean(localEditComposite),
hasScaleAdjustVerification: Boolean(scaleAdjustVerification),
});
const resultPrompt =
typeof request.displayPrompt === "string" && request.displayPrompt.trim()
? request.displayPrompt.trim()
: prompt;
const messages = [
{
role: "user" as const,
content:
effectiveReferenceImages.length > 0
? [
{ type: "text" as const, text: prompt },
...effectiveReferenceImages.map((url) => ({
type: "image_url" as const,
image_url: { url },
})),
]
: prompt,
},
];
const imageParams = {
aspectRatio,
imageSize,
imageCount: 1,
negativePrompt,
promptExtend,
};
let executeGenerateBatch:
| ((batchImageCount: number) => Promise<string>)
| null = null;
if (provider.type === "gemini" || provider.type === "grsai-gpt-image") {
const geminiProvider = createGeminiImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = geminiProvider.chat(selectedModel.model.modelId);
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
});
return result.text || "";
};
} else if (provider.type === "alibaba-image") {
const alibabaProvider = createAlibabaImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = alibabaProvider.chat(selectedModel.model.modelId);
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
});
return result.text || "";
};
} else if (provider.type === "bfl-image") {
const bflProvider = createBflImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = bflProvider.chat(selectedModel.model.modelId);
executeGenerateBatch = async (_batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams,
});
return result.text || "";
};
} else if (provider.type === "ruxa-image") {
const ruxaProvider = createRuxaImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const runtimeModelId = resolveModelRuntimeId(selectedModel.model, {
providerType: provider.type,
});
const modelInstance = ruxaProvider.chat(runtimeModelId);
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
imageGenConfig: selectedModel.model.imageGenConfig || null,
});
return result.text || "";
};
} else if (provider.type === "fal-image") {
const falProvider = createFalImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = falProvider.chat(selectedModel.model.modelId);
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
});
return result.text || "";
};
} else if (provider.type === "openai-image") {
const openAIImageProvider = createOpenAIImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = openAIImageProvider.chat(selectedModel.model.modelId);
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
});
return result.text || "";
};
} else if (provider.type === "stepfun-image") {
const stepfunProvider = createStepfunImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = stepfunProvider.chat(selectedModel.model.modelId);
executeGenerateBatch = async (_batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams,
});
return result.text || "";
};
} else if (provider.type === "midjourney") {
const midjourneyProvider = createMidjourney({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = midjourneyProvider.chat(selectedModel.model.modelId);
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
});
return result.text || "";
};
} else if (provider.type === "runway") {
const runwayProvider = createRunway({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = runwayProvider.chat(selectedModel.model.modelId);
executeGenerateBatch = async (_batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams,
});
return result.text || "";
};
} else if (provider.type === "krea") {
const kreaProvider = createKrea({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = kreaProvider.chat(selectedModel.model.modelId);
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
});
return result.text || "";
};
} else if (provider.type === "volcano-vision") {
const volcanoVisionProvider = createVolcanoVision({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const modelInstance = volcanoVisionProvider.chat(selectedModel.model.modelId);
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
imageParams: {
...imageParams,
imageCount: batchImageCount,
},
});
return result.text || "";
};
} else {
throw new Error(
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/OpenAI/StepFun/Midjourney/Runway/Krea 图片模型`
);
}
const providerBatchLimit = getProviderBatchLimit(provider.type);
const targetImageCount = Math.max(1, imageCount);
const collectedImages: ParsedImage[] = [];
const maxBatches = Math.max(1, Math.ceil(targetImageCount / providerBatchLimit));
const resolvedModelKey = normalizeCanvasImageModelKey(
selectedModel.model.modelId || selectedModel.model.id || model
);
const slot = await acquireCanvasImageModelSlot({
modelKey: resolvedModelKey,
ownerKey,
debugLabel,
debugMeta: {
selectedModelId: selectedModel.model.modelId,
selectedModelRef: selectedModel.model.id,
providerType: provider.type,
...debugMeta,
},
onQueued: request.onQueueStatusChange
? async (queueStatus) => {
await request.onQueueStatusChange?.({
...queueStatus,
modelName: selectedModel.model.displayName || null,
modelRef: selectedModel.model.id || null,
});
}
: undefined,
});
if (slot.waitMs > 0 && request.onQueueStatusChange) {
const runningStatus = await getCanvasImageModelQueueStatus({
modelKey: resolvedModelKey,
});
await request.onQueueStatusChange({
...runningStatus,
status: "running",
queuePosition: 0,
estimatedWaitMs: 0,
modelName: selectedModel.model.displayName || null,
modelRef: selectedModel.model.id || null,
});
}
let caughtError: unknown = null;
try {
for (let batchIndex = 0; batchIndex < maxBatches; batchIndex += 1) {
const remaining = targetImageCount - collectedImages.length;
if (remaining <= 0) break;
const batchImageCount = Math.min(providerBatchLimit, remaining);
const batchStartedAt = Date.now();
console.info("[Canvas Generate Image] Batch started:", {
debugLabel,
batchIndex: batchIndex + 1,
totalBatches: maxBatches,
batchImageCount,
currentCollectedCount: collectedImages.length,
providerType: provider.type,
selectedModelId: selectedModel.model.modelId,
...debugMeta,
});
const imageMarkdown = await executeGenerateBatch(batchImageCount);
const parsedImages = parseImagesFromMarkdown(imageMarkdown);
console.info("[Canvas Generate Image] Batch completed:", {
debugLabel,
batchIndex: batchIndex + 1,
totalBatches: maxBatches,
batchImageCount,
parsedImageCount: parsedImages.length,
elapsedMs: Date.now() - batchStartedAt,
...debugMeta,
});
if (parsedImages.length === 0) {
if (collectedImages.length > 0) {
break;
}
throw new Error("图片生成失败");
}
collectedImages.push(...parsedImages.slice(0, batchImageCount));
}
if (collectedImages.length === 0) {
throw new Error("图片生成失败");
}
const baseImages = collectedImages.slice(0, targetImageCount);
const shouldCompositeBeforeScaleAdjustVerification = Boolean(
scaleAdjustVerification && localEditComposite
);
const protectedImages = shouldCompositeBeforeScaleAdjustVerification
? await applyLocalEditCompositeToImages({
composite: localEditComposite,
debugLabel,
images: baseImages,
})
: baseImages;
const scaleCheckedImages = await applyScaleAdjustVerificationToImages({
debugLabel,
images: protectedImages,
verification: scaleAdjustVerification,
});
const finalImages = shouldCompositeBeforeScaleAdjustVerification
? scaleCheckedImages
: await applyLocalEditCompositeToImages({
composite: localEditComposite,
debugLabel,
images: scaleCheckedImages,
});
console.info("[Canvas Generate Image] Completed:", {
debugLabel,
elapsedMs: Date.now() - startedAt,
waitMs: slot.waitMs,
singleModelConcurrency: slot.maxConcurrent,
finalImageCount: finalImages.length,
providerType: provider.type,
selectedModelRef: selectedModel.model.id,
selectedModelId: selectedModel.model.modelId,
...debugMeta,
});
await recordAiModelHealthSuccess({
model: selectedModel.model,
}).catch((healthError) => {
console.warn("[Canvas Generate Image] Record model health success failed:", {
selectedModelRef: selectedModel.model.id,
selectedModelId: selectedModel.model.modelId,
error: healthError instanceof Error ? healthError.message : String(healthError),
});
});
recordCanvasImageModelExecutionDuration(
resolvedModelKey,
Date.now() - startedAt
);
return {
images: finalImages,
model: selectedModel.model.displayName,
modelRef: selectedModel.model.id,
prompt: resultPrompt,
};
} catch (error) {
console.error("[Canvas Generate Image] Failed:", {
debugLabel,
elapsedMs: Date.now() - startedAt,
waitMs: slot.waitMs,
singleModelConcurrency: slot.maxConcurrent,
providerType: provider.type,
selectedModelRef: selectedModel.model.id,
selectedModelId: selectedModel.model.modelId,
error: error instanceof Error ? error.message : String(error),
...debugMeta,
});
caughtError = error;
} finally {
await slot.release();
}
const scaleAdjustRejected =
caughtError instanceof ScaleAdjustOutputRejectedError;
if (scaleAdjustRejected) {
console.info("[Canvas Generate Image] Skip model health failure for scale adjust rejection:", {
debugLabel,
selectedModelRef: selectedModel.model.id,
selectedModelId: selectedModel.model.modelId,
error: caughtError instanceof Error ? caughtError.message : String(caughtError),
});
} else {
await recordAiModelHealthFailure({
model: selectedModel.model,
provider,
error: caughtError,
context: debugLabel,
elapsedMs: Date.now() - startedAt,
metadata: {
aspectRatio,
imageSize,
imageCount,
requestedReferenceImageCount: normalizedReferenceImages.length,
reachableReferenceImageCount: reachableReferenceImages.length,
effectiveReferenceImageCount: effectiveReferenceImages.length,
droppedReferenceImageCount: unreachableReferenceImages.length,
...debugMeta,
},
}).catch((healthError) => {
console.warn("[Canvas Generate Image] Record model health failure failed:", {
selectedModelRef: selectedModel.model.id,
selectedModelId: selectedModel.model.modelId,
error: healthError instanceof Error ? healthError.message : String(healthError),
});
});
}
const failedModelRefs = Array.from(
new Set([
...(Array.isArray(request.internalFailoverTriedModelRefs)
? request.internalFailoverTriedModelRefs
: []),
selectedModel.model.id,
selectedModel.model.modelId,
].filter(Boolean))
);
const allowFailover = request.allowFailover !== false;
const shouldFailover =
shouldFailoverAiModelError(caughtError) ||
scaleAdjustRejected ||
(Boolean(debugMeta?.detailFixedModelApplied) && Boolean(caughtError));
const fallbackModel = allowFailover && shouldFailover
? findCanvasFailoverModel({
selectedModel,
imageModels,
failedModelRefs,
})
: null;
if (fallbackModel) {
console.warn("[Canvas Generate Image] Failover to alternate image model:", {
debugLabel,
failedModelRef: selectedModel.model.id,
failedModelId: selectedModel.model.modelId,
fallbackModelRef: fallbackModel.model.id,
fallbackModelId: fallbackModel.model.modelId,
fallbackProviderType: fallbackModel.provider.type,
error: caughtError instanceof Error ? caughtError.message : String(caughtError),
...debugMeta,
});
return await generateCanvasImage({
...request,
model: fallbackModel.model.id,
internalFailoverTriedModelRefs: failedModelRefs,
debugMeta: {
...debugMeta,
failoverFromModelId: selectedModel.model.modelId,
failoverFromModelRef: selectedModel.model.id,
},
});
}
throw caughtError;
}