1640 lines
52 KiB
TypeScript
1640 lines
52 KiB
TypeScript
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;
|
||
}
|