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

2018 lines
64 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[];
expandRegions?: boolean;
maskMode?: "region" | "diff";
featherMinPx?: number;
featherMaxPx?: number;
featherRatio?: number;
diffLowThreshold?: number;
diffHighThreshold?: number;
diffCompareBlurPx?: number;
}
export interface CanvasScaleAdjustVerificationConfig {
protectedRegions?: CanvasLocalEditCompositeRegion[];
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 = 18;
const LOCAL_EDIT_COMPOSITE_FEATHER_MAX_PX = 72;
const LOCAL_EDIT_COMPOSITE_FEATHER_RATIO = 0.018;
const LOCAL_EDIT_COMPOSITE_DIFF_LOW_THRESHOLD = 10;
const LOCAL_EDIT_COMPOSITE_DIFF_HIGH_THRESHOLD = 34;
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_PIXEL_DIFF_THRESHOLD = 16;
const SCALE_ADJUST_OUTSIDE_PIXEL_CHANGED_RATIO_THRESHOLD = 0.045;
const SCALE_ADJUST_OUTSIDE_PIXEL_MEAN_DIFF_THRESHOLD = 4.5;
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 normalizeOptionalNumber(
value: unknown,
options: { min: number; max: number }
) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return undefined;
return Math.max(options.min, Math.min(options.max, numeric));
}
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 expandRegions = value.expandRegions !== false;
const maskMode = value.maskMode === "diff" ? "diff" : "region";
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((region) => expandRegions ? expandLocalEditCompositeRegion(region) : region)
.filter((region) => region.width > 0 && region.height > 0);
return regions.length > 0
? {
sourceImageUrl,
regions,
expandRegions,
maskMode,
diffHighThreshold: normalizeOptionalNumber(value.diffHighThreshold, {
max: 128,
min: 1,
}),
diffLowThreshold: normalizeOptionalNumber(value.diffLowThreshold, {
max: 127,
min: 0,
}),
diffCompareBlurPx: normalizeOptionalNumber(value.diffCompareBlurPx, {
max: 12,
min: 0,
}),
featherMaxPx: normalizeOptionalNumber(value.featherMaxPx, {
max: 160,
min: 0,
}),
featherMinPx: normalizeOptionalNumber(value.featherMinPx, {
max: 80,
min: 0,
}),
featherRatio: normalizeOptionalNumber(value.featherRatio, {
max: 0.08,
min: 0,
}),
}
: 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));
const protectedRegions = Array.isArray(value.protectedRegions)
? value.protectedRegions
.map((region) => normalizeCanvasRegion(region))
.filter((region): region is CanvasLocalEditCompositeRegion =>
Boolean(region)
)
: [];
return regions.length > 0
? {
sourceImageUrl,
regions,
scalePercent,
...(protectedRegions.length > 0 ? { protectedRegions } : {}),
}
: 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
) {
if (verification.protectedRegions?.length) {
return verification.protectedRegions.map((region) =>
expandCanvasRegion(region, SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN)
);
}
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 getScaleAdjustOutsidePixelChangeStats(params: {
generatedBuffer: Buffer;
sourceBuffer: Buffer;
sourceWidth: number;
sourceHeight: number;
verification: CanvasScaleAdjustVerificationConfig;
}) {
const { generatedBuffer, sourceBuffer, sourceHeight, sourceWidth, verification } =
params;
const protectedRegions = getScaleAdjustProtectedRegions(verification);
const normalizeToRaw = (buffer: Buffer) =>
sharp(buffer, { failOn: "none" })
.rotate()
.resize(sourceWidth, sourceHeight, {
fit: "cover",
position: "center",
})
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer();
const [sourceRaw, generatedRaw] = await Promise.all([
normalizeToRaw(sourceBuffer),
normalizeToRaw(generatedBuffer),
]);
const step = Math.max(2, Math.ceil(Math.max(sourceWidth, sourceHeight) / 1200));
let changedPixelCount = 0;
let sampledPixelCount = 0;
let diffSum = 0;
for (let y = 0; y < sourceHeight; y += step) {
const normalizedY = y / sourceHeight;
for (let x = 0; x < sourceWidth; x += step) {
const normalizedX = x / sourceWidth;
if (
protectedRegions.some(
(region) =>
normalizedX >= region.x &&
normalizedX <= region.x + region.width &&
normalizedY >= region.y &&
normalizedY <= region.y + region.height
)
) {
continue;
}
const rawIndex = (y * sourceWidth + x) * 3;
const diff =
Math.abs(generatedRaw[rawIndex] - sourceRaw[rawIndex]) * 0.299 +
Math.abs(generatedRaw[rawIndex + 1] - sourceRaw[rawIndex + 1]) * 0.587 +
Math.abs(generatedRaw[rawIndex + 2] - sourceRaw[rawIndex + 2]) * 0.114;
diffSum += diff;
sampledPixelCount += 1;
if (diff >= SCALE_ADJUST_OUTSIDE_PIXEL_DIFF_THRESHOLD) {
changedPixelCount += 1;
}
}
}
const changedRatio =
sampledPixelCount > 0 ? changedPixelCount / sampledPixelCount : 0;
const meanDiff = sampledPixelCount > 0 ? diffSum / sampledPixelCount : 0;
return {
changedPixelCount,
changedRatio,
meanDiff,
sampledPixelCount,
step,
};
}
function isScaleAdjustOutsidePixelChanged(params: {
changedRatio: number;
meanDiff: number;
sampledPixelCount: number;
}) {
if (params.sampledPixelCount < 100) return false;
return (
params.changedRatio >= SCALE_ADJUST_OUTSIDE_PIXEL_CHANGED_RATIO_THRESHOLD &&
params.meanDiff >= SCALE_ADJUST_OUTSIDE_PIXEL_MEAN_DIFF_THRESHOLD
);
}
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;
const sourceImage = await loadScaleAdjustSourceImage(verification);
if (!sourceImage) {
console.warn("[Canvas Generate Image] Skip scale adjust result guard: source image unavailable", {
debugLabel,
imageCount: images.length,
regionCount: verification.regions.length,
scalePercent: verification.scalePercent,
});
return images;
}
for (const [index, image] of images.entries()) {
const generatedUrl = image.originalUrl || image.thumbnailUrl;
const generatedBuffer = await fetchImageBufferFromUrl(generatedUrl);
const [diffScores, outsideDiffScores, outsidePixelStats] = await Promise.all([
getScaleAdjustDiffScores({
generatedBuffer,
regions: verification.regions,
sourceBuffer: sourceImage.sourceBuffer,
sourceHeight: sourceImage.sourceHeight,
sourceWidth: sourceImage.sourceWidth,
}),
getScaleAdjustOutsideDiffScores({
generatedBuffer,
sourceBuffer: sourceImage.sourceBuffer,
sourceHeight: sourceImage.sourceHeight,
sourceWidth: sourceImage.sourceWidth,
verification,
}),
getScaleAdjustOutsidePixelChangeStats({
generatedBuffer,
sourceBuffer: sourceImage.sourceBuffer,
sourceHeight: sourceImage.sourceHeight,
sourceWidth: sourceImage.sourceWidth,
verification,
}),
]);
const changedInside = diffScores.some(
(score) => score >= SCALE_ADJUST_VISUAL_DIFF_THRESHOLD
);
const outsideChanged =
isScaleAdjustOutsideChanged(outsideDiffScores) ||
isScaleAdjustOutsidePixelChanged(outsidePixelStats);
console.info("[Canvas Generate Image] Scale adjust result guard:", {
debugLabel,
changedInside,
imageIndex: index,
outsideChanged,
outsideDiffScores: outsideDiffScores.map((score) => Number(score.toFixed(2))),
outsidePixelStats: {
changedPixelCount: outsidePixelStats.changedPixelCount,
changedRatio: Number(outsidePixelStats.changedRatio.toFixed(4)),
meanDiff: Number(outsidePixelStats.meanDiff.toFixed(2)),
sampledPixelCount: outsidePixelStats.sampledPixelCount,
step: outsidePixelStats.step,
},
protectedRegionCount: verification.protectedRegions?.length || 0,
regionCount: verification.regions.length,
regionDiffScores: diffScores.map((score) => Number(score.toFixed(2))),
scalePercent: verification.scalePercent,
});
if (outsideChanged) {
throw new ScaleAdjustOutputRejectedError({
diffScores,
outsideDiffScores,
reason: "outside-frame-changed",
});
}
if (!changedInside) {
throw new ScaleAdjustOutputRejectedError({
diffScores,
outsideDiffScores,
reason: "no-effective-scale-change",
});
}
}
return images;
}
function buildLocalEditCompositeMaskSvg(params: {
width: number;
height: number;
regions: CanvasLocalEditCompositeRegion[];
featherPx?: number;
}) {
const { width, height, regions } = params;
const blur =
typeof params.featherPx === "number"
? Math.max(0, Math.round(params.featherPx))
: Math.max(
LOCAL_EDIT_COMPOSITE_FEATHER_MIN_PX,
Math.min(
LOCAL_EDIT_COMPOSITE_FEATHER_MAX_PX,
Math.round(Math.min(width, height) * LOCAL_EDIT_COMPOSITE_FEATHER_RATIO)
)
);
const rectEntries = 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 { left, top, rectHeight, rectWidth };
})
.filter(({ rectHeight, rectWidth }) => rectWidth > 0 && rectHeight > 0);
const softRects = rectEntries
.map(
({ left, top, rectHeight, rectWidth }) =>
`<rect x="${left}" y="${top}" width="${rectWidth}" height="${rectHeight}" rx="${Math.round(
blur * 0.9
)}" fill="#ffffff" />`
)
.join("");
const coreRects = rectEntries
.map(({ left, top, rectHeight, rectWidth }) => {
const inset = Math.max(
0,
Math.min(
Math.round(blur * 1.35),
Math.floor(rectWidth / 3),
Math.floor(rectHeight / 3)
)
);
const coreWidth = rectWidth - inset * 2;
const coreHeight = rectHeight - inset * 2;
if (coreWidth <= 0 || coreHeight <= 0) return "";
return `<rect x="${left + inset}" y="${top + inset}" width="${coreWidth}" height="${coreHeight}" rx="${Math.round(
blur * 0.45
)}" 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)">
${softRects}
</g>
${coreRects}
</svg>
`);
}
function resolveLocalEditCompositeFeatherPx(params: {
composite: CanvasLocalEditCompositeConfig;
width: number;
height: number;
}) {
const { composite, width, height } = params;
const minPx =
typeof composite.featherMinPx === "number"
? composite.featherMinPx
: LOCAL_EDIT_COMPOSITE_FEATHER_MIN_PX;
const maxPx =
typeof composite.featherMaxPx === "number"
? Math.max(minPx, composite.featherMaxPx)
: LOCAL_EDIT_COMPOSITE_FEATHER_MAX_PX;
const ratio =
typeof composite.featherRatio === "number"
? composite.featherRatio
: LOCAL_EDIT_COMPOSITE_FEATHER_RATIO;
return Math.max(
minPx,
Math.min(maxPx, Math.round(Math.min(width, height) * ratio))
);
}
function getLocalEditCompositeRectEntries(params: {
width: number;
height: number;
regions: CanvasLocalEditCompositeRegion[];
}) {
const { width, height, regions } = params;
return 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 { left, top, rectHeight, rectWidth };
})
.filter(({ rectHeight, rectWidth }) => rectWidth > 0 && rectHeight > 0);
}
function getSmoothDiffAlpha(diff: number, lowThreshold: number, highThreshold: number) {
if (diff <= lowThreshold) return 0;
if (diff >= highThreshold) return 255;
const progress = (diff - lowThreshold) / Math.max(1, highThreshold - lowThreshold);
return Math.round(255 * progress * progress * (3 - 2 * progress));
}
async function buildLocalEditDiffCompositeMask(params: {
composite: CanvasLocalEditCompositeConfig;
generatedBuffer: Buffer;
sourceBuffer: Buffer;
width: number;
height: number;
}) {
const { composite, generatedBuffer, height, sourceBuffer, width } = params;
const compareBlurPx =
typeof composite.diffCompareBlurPx === "number"
? composite.diffCompareBlurPx
: 0;
const buildDiffRaw = (buffer: Buffer) => {
const pipeline = sharp(buffer, { failOn: "none" })
.resize(width, height, { fit: "cover", position: "center" })
.removeAlpha()
.toColorspace("srgb");
return (compareBlurPx >= 0.3 ? pipeline.blur(compareBlurPx) : pipeline)
.raw()
.toBuffer();
};
const [sourceRaw, generatedRaw] = await Promise.all([
buildDiffRaw(sourceBuffer),
buildDiffRaw(generatedBuffer),
]);
const lowThreshold =
typeof composite.diffLowThreshold === "number"
? composite.diffLowThreshold
: LOCAL_EDIT_COMPOSITE_DIFF_LOW_THRESHOLD;
const highThreshold = Math.max(
lowThreshold + 1,
typeof composite.diffHighThreshold === "number"
? composite.diffHighThreshold
: LOCAL_EDIT_COMPOSITE_DIFF_HIGH_THRESHOLD
);
const alpha = Buffer.alloc(width * height, 0);
const rectEntries = getLocalEditCompositeRectEntries({
height,
regions: composite.regions,
width,
});
for (const { left, top, rectHeight, rectWidth } of rectEntries) {
const right = Math.min(width, left + rectWidth);
const bottom = Math.min(height, top + rectHeight);
for (let y = top; y < bottom; y += 1) {
for (let x = left; x < right; x += 1) {
const pixelIndex = y * width + x;
const rawIndex = pixelIndex * 3;
const diff =
Math.abs(generatedRaw[rawIndex] - sourceRaw[rawIndex]) * 0.299 +
Math.abs(generatedRaw[rawIndex + 1] - sourceRaw[rawIndex + 1]) * 0.587 +
Math.abs(generatedRaw[rawIndex + 2] - sourceRaw[rawIndex + 2]) * 0.114;
const nextAlpha = getSmoothDiffAlpha(diff, lowThreshold, highThreshold);
if (nextAlpha > alpha[pixelIndex]) {
alpha[pixelIndex] = nextAlpha;
}
}
}
}
const featherPx = resolveLocalEditCompositeFeatherPx({
composite,
height,
width,
});
return sharp(alpha, { raw: { channels: 1, height, width } })
.blur(Math.max(0.3, featherPx))
.png({ compressionLevel: 6 })
.toBuffer();
}
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 featherPx = resolveLocalEditCompositeFeatherPx({
composite,
height: sourceHeight,
width: sourceWidth,
});
const mergedImages: ParsedImage[] = [];
for (const [index, image] of images.entries()) {
const generatedUrl = image.originalUrl || image.thumbnailUrl;
const generatedBuffer = await fetchImageBufferFromUrl(generatedUrl);
const resizedGeneratedBuffer = await sharp(generatedBuffer, { failOn: "none" })
.rotate()
.resize(sourceWidth, sourceHeight, { fit: "cover", position: "center" })
.png({ compressionLevel: 6 })
.toBuffer();
const overlayBuffer =
composite.maskMode === "diff"
? await sharp(resizedGeneratedBuffer, { failOn: "none" })
.removeAlpha()
.joinChannel(
await buildLocalEditDiffCompositeMask({
composite,
generatedBuffer: resizedGeneratedBuffer,
height: sourceHeight,
sourceBuffer,
width: sourceWidth,
})
)
.png({ compressionLevel: 6 })
.toBuffer()
: await sharp(resizedGeneratedBuffer, { failOn: "none" })
.ensureAlpha()
.composite([
{
input: buildLocalEditCompositeMaskSvg({
featherPx,
height: sourceHeight,
regions: composite.regions,
width: sourceWidth,
}),
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,
expandRegions: composite.expandRegions !== false,
featherPx,
imageCount: mergedImages.length,
maskMode: composite.maskMode || "region",
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[];
requiresImageInput?: boolean;
}) {
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 (params.requiresImageInput && item.model.supportsVision === false) {
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];
}
const normalizedReferenceImages = Array.from(
new Set(
[
...referenceImages,
...(referenceImage ? [referenceImage] : []),
]
.map((item) => String(item || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
).slice(0, 12);
const requiresImageInput = normalizedReferenceImages.length > 0;
let provider = selectedModel.provider;
if (requiresImageInput && selectedModel.model.supportsVision === false) {
if (request.allowFailover === false) {
throw new Error("当前图片模型不支持参考图/图生图,请选择支持图像输入的生图模型。");
}
const imageInputFallback = findCanvasFailoverModel({
selectedModel,
imageModels,
failedModelRefs: getCanvasModelRefKeys(selectedModel),
requiresImageInput,
});
if (!imageInputFallback) {
throw new Error("当前任务需要参考图输入,但暂无可用的图生图模型。");
}
console.warn("[Canvas Generate Image] Fallback to reference-image capable model:", {
debugLabel,
requestedModel: selectedModel.model.modelId || selectedModel.model.id,
fallbackModel: imageInputFallback.model.modelId || imageInputFallback.model.id,
requestedProviderType: provider.type,
fallbackProviderType: imageInputFallback.provider.type,
reason: "selected-model-lacks-reference-image-support",
});
selectedModel = imageInputFallback;
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;
if (requiresImageInput && candidate.model.supportsVision === false) 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 { 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 scaleCheckedImages = await applyScaleAdjustVerificationToImages({
debugLabel,
images: baseImages,
verification: scaleAdjustVerification,
});
const finalImages = 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,
requiresImageInput: normalizedReferenceImages.length > 0,
})
: 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;
}