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

5845 lines
195 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 { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
import { createVisionary } from "@/lib/ai/providers/visionary";
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
import { createVip98k } from "@/lib/ai/providers/vip98k";
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 { modelSupportsTextToImageDescriptor } from "@/lib/ai/model-classification";
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
import {
acquireCanvasImageModelSlot,
getCanvasImageModelQueueStatus,
type CanvasImageModelQueueStatus,
normalizeCanvasImageModelKey,
recordCanvasImageModelExecutionDuration,
} from "./image-concurrency";
import {
classifyAiModelHealthFailure,
recordAiModelHealthFailure,
recordAiModelHealthSuccess,
shouldFailoverAiModelError,
} from "@/lib/ai/runtime/model-health";
import { uploadFile } from "@/lib/storage/service";
import { isImageFallbackModelBalanceCoolingDown } from "@/lib/ai/runtime/image-fallback-balance-cooldown";
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 CanvasScaleAdjustPatchCompositeConfig {
clipMaskImageUrl?: string;
generatedFrame?: "workspace" | "full";
sourceImageUrl: string;
workspaceRegion: CanvasLocalEditCompositeRegion;
regions?: CanvasLocalEditCompositeRegion[];
maskMode?: "workspace" | "diff";
diffAlphaCutoff?: number;
diffAlphaMultiplier?: number;
diffLowThreshold?: number;
diffHighThreshold?: number;
diffCompareBlurPx?: number;
featherMinPx?: number;
featherMaxPx?: number;
featherRatio?: number;
}
export interface CanvasScaleAdjustProductLockCompositeConfig {
overlayImageUrl: string;
backgroundPlateImageUrl?: string;
protectMaskImageUrl?: string;
repairMaskImageUrl?: string;
regions?: Array<{
source: CanvasLocalEditCompositeRegion;
target: CanvasLocalEditCompositeRegion;
}>;
shadowSourceImageUrl?: string;
sourceImageUrl?: string;
sourceSubjectMaskImageUrl?: string;
sourceHeight: number;
sourceWidth: number;
}
export interface CanvasScaleAdjustVerificationConfig {
allowDeterministicFallback?: boolean;
allowOutsideChangedWhenChangedInside?: boolean;
deterministicProductOverlayApplied?: boolean;
oldFootprintMaskImageUrl?: string;
protectedRegions?: CanvasLocalEditCompositeRegion[];
rejectFailedCandidates?: boolean;
rejectNoEffectiveChangeOnly?: boolean;
rejectOldFootprintOnly?: boolean;
regionPrompts?: string[];
sourceImageUrl: string;
regions: CanvasLocalEditCompositeRegion[];
targetRegions?: CanvasLocalEditCompositeRegion[];
scalePercent?: number;
}
export interface CanvasScaleAdjustModelRelightConfig {
model: string;
overlayImageUrl: string;
quality?: "low" | "medium" | "high";
regions: Array<{
target: CanvasLocalEditCompositeRegion;
}>;
sourceHeight: number;
sourceWidth: number;
surfaceMode?: "floor" | "surround";
}
export interface CanvasSemanticScaleAdjustPreparationRegion {
source: CanvasLocalEditCompositeRegion;
sourceCropImageUrl: string;
target: CanvasLocalEditCompositeRegion;
}
export interface CanvasSemanticScaleAdjustPreparationConfig {
cleanupImageSize?: string;
cleanupModel: string;
cleanupPrompt: string;
extractionPrompt: string;
regions: CanvasSemanticScaleAdjustPreparationRegion[];
relightModel?: string;
sourceImageUrl: string;
}
export interface CanvasGenerateImageRetryVariant {
allowFailover?: boolean;
debugMeta?: Record<string, unknown>;
falSafetyTolerance?: string | number;
falSystemPrompt?: string;
falThinkingLevel?: "minimal" | "high";
imageCount?: number;
label?: string;
localEditComposite?: CanvasLocalEditCompositeConfig | null;
model?: string;
negativePrompt?: string;
prompt?: string;
promptExtend?: boolean;
providerImageParams?: Record<string, unknown>;
referenceImage?: string;
referenceImages?: string[];
scaleAdjustPatchComposite?: CanvasScaleAdjustPatchCompositeConfig;
scaleAdjustProductLockComposite?: CanvasScaleAdjustProductLockCompositeConfig;
scaleAdjustModelRelight?: CanvasScaleAdjustModelRelightConfig;
scaleAdjustVerification?: CanvasScaleAdjustVerificationConfig;
}
export interface CanvasGenerateImageRequest {
prompt: string;
displayPrompt?: string;
negativePrompt?: string;
model?: string;
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
falSafetyTolerance?: string | number;
falSystemPrompt?: string;
falThinkingLevel?: "minimal" | "high";
promptExtend?: boolean;
providerImageParams?: Record<string, unknown>;
referenceImage?: string;
referenceImages?: string[];
billingFeature?: "multi-view" | "repaint";
ownerKey?: string;
debugLabel?: string;
debugMeta?: Record<string, unknown>;
ignoreModelMaxImages?: boolean;
localEditComposite?: CanvasLocalEditCompositeConfig | null;
scaleAdjustPatchComposite?: CanvasScaleAdjustPatchCompositeConfig;
scaleAdjustProductLockComposite?: CanvasScaleAdjustProductLockCompositeConfig;
scaleAdjustModelRelight?: CanvasScaleAdjustModelRelightConfig;
scaleAdjustVerification?: CanvasScaleAdjustVerificationConfig;
semanticScaleAdjustPreparation?: CanvasSemanticScaleAdjustPreparationConfig;
retryVariants?: CanvasGenerateImageRetryVariant[];
internalRetryVariantIndex?: number;
allowFailover?: boolean;
internalFailoverTriedModelRefs?: string[];
internalFailoverTriedProviderTypes?: 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[];
images?: ParsedImage[];
outsideDiffScores?: number[];
constructor(params: {
reason: string;
diffScores?: number[];
images?: ParsedImage[];
outsideDiffScores?: number[];
}) {
const message =
params.reason === "outside-frame-changed"
? "比例调整结果改变了框选区域外的画面,已拒绝该模型结果"
: params.reason === "rectangular-seam-detected"
? "比例调整结果出现明显矩形拼接硬边,已拒绝该模型结果"
: params.reason === "old-footprint-remnant-detected"
? "比例调整结果保留了旧尺寸残影,已拒绝该模型结果"
: "比例调整结果没有产生有效变化,已拒绝该模型结果";
super(message);
this.name = "ScaleAdjustOutputRejectedError";
this.reason = params.reason;
this.diffScores = params.diffScores;
this.images = params.images;
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 = 10;
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.16;
const SCALE_ADJUST_OUTSIDE_PIXEL_MEAN_DIFF_THRESHOLD = 14;
const SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN = 0.015;
const SCALE_ADJUST_MARKER_CLEANUP_MARGIN = 0.035;
const SCALE_ADJUST_MARKER_DILATE_RADIUS = 3;
const SCALE_ADJUST_MARKER_MIN_PIXEL_COUNT = 18;
const SCALE_ADJUST_DIFF_SAMPLE_SIZE = 112;
const SCALE_ADJUST_FOOTPRINT_FALLBACK_DIFF_THRESHOLD = 18;
const SCALE_ADJUST_FALLBACK_MASK_DISTANCE_THRESHOLD = 42;
const SCALE_ADJUST_FALLBACK_MASK_LUMA_GAP = 18;
const SCALE_ADJUST_FALLBACK_INPAINT_ITERATIONS = 180;
const SCALE_ADJUST_SHRINK_OUTSIDE_FOOTPRINT_MAX_RATIO = 0.72;
const SCALE_ADJUST_SHRINK_OUTSIDE_FOOTPRINT_RELAXED_MAX_RATIO = 0.84;
const SCALE_ADJUST_SHRINK_OUTSIDE_FOOTPRINT_MIN_REMOVED_RATIO = 0.14;
const SCALE_ADJUST_SHRINK_MIN_SOURCE_OUTSIDE_PIXELS = 60;
const SCALE_ADJUST_SHRINK_TARGET_FOOTPRINT_MARGIN = 0.018;
const SCALE_ADJUST_OLD_FOOTPRINT_TARGET_MARGIN = 0.006;
const SCALE_ADJUST_OLD_FOOTPRINT_EXPLICIT_SIMILAR_DIFF_THRESHOLD = 20;
const SCALE_ADJUST_OLD_FOOTPRINT_SIMILAR_DIFF_THRESHOLD = 56;
const SCALE_ADJUST_OLD_FOOTPRINT_MIN_PIXELS = 80;
const SCALE_ADJUST_OLD_FOOTPRINT_SIMILAR_RATIO_THRESHOLD = 0.18;
const SCALE_ADJUST_OLD_FOOTPRINT_DARK_RATIO_THRESHOLD = 0.22;
const SCALE_ADJUST_RECTANGULAR_SEAM_CHANGE_THRESHOLD = 18;
const SCALE_ADJUST_RECTANGULAR_SEAM_MIN_EDGE_DIFF = 5;
const SCALE_ADJUST_RECTANGULAR_SEAM_MAX_EDGE_DIFF = 54;
const SCALE_ADJUST_RECTANGULAR_SEAM_EDGE_INCREASE = 2.5;
const SCALE_ADJUST_RECTANGULAR_SEAM_PAIR_RUN_RATIO = 0.05;
const SCALE_ADJUST_RECTANGULAR_SEAM_PAIR_TRANSITION_RATIO = 0.07;
const SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_RUN_RATIO = 0.1;
const SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_TRANSITION_RATIO = 0.26;
const SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_RUN_RATIO = 0.55;
const SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_TRANSITION_RATIO = 0.5;
const CANVAS_DEFAULT_PROVIDER_BATCH_TIMEOUT_MS = 10 * 60 * 1000;
const CANVAS_SCALE_ADJUST_PROVIDER_BATCH_TIMEOUT_MS = 3 * 60 * 1000;
const CANVAS_SCALE_ADJUST_FINAL_PROVIDER_BATCH_TIMEOUT_MS = 5 * 60 * 1000;
const CANVAS_SCALE_ADJUST_SNAPGEN_REQUEST_TIMEOUT_MS = 90 * 1000;
const CANVAS_SCALE_ADJUST_SNAPGEN_POLL_TIMEOUT_MS = 150 * 1000;
const CANVAS_SCALE_ADJUST_SNAPGEN_HISTORY_TIMEOUT_MS = 20 * 1000;
const CANVAS_SCALE_ADJUST_SNAPGEN_DOWNLOAD_TIMEOUT_MS = 60 * 1000;
const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
"gemini",
"grsai-gpt-image",
"alibaba-image",
"bfl-image",
"ruxa-image",
"fal-image",
"snapgen-image",
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"vip98k",
"stepfun-image",
"midjourney",
"runway",
"krea",
"volcano-vision",
]);
function isCanvasScaleAdjustDebugRequest(
debugLabel: string,
debugMeta?: Record<string, unknown>
) {
return (
debugLabel === "canvas-scale-adjust" ||
String(debugMeta?.operation || "") === "scale-adjust" ||
Boolean(debugMeta?.scaleAdjustPatchWorkflowApplied) ||
Boolean(debugMeta?.scaleAdjustPatchForcedForShrink) ||
Boolean(debugMeta?.scaleAdjustPatchStructureLockedDraftApplied) ||
Boolean(debugMeta?.scaleAdjustPatchCompositeClipMaskUrl)
);
}
function getCanvasProviderBatchTimeoutMs(params: {
debugLabel: string;
debugMeta?: Record<string, unknown>;
providerType: string;
}) {
if (isCanvasScaleAdjustDebugRequest(params.debugLabel, params.debugMeta)) {
return params.providerType === "fal-image"
? CANVAS_SCALE_ADJUST_FINAL_PROVIDER_BATCH_TIMEOUT_MS
: CANVAS_SCALE_ADJUST_PROVIDER_BATCH_TIMEOUT_MS;
}
return CANVAS_DEFAULT_PROVIDER_BATCH_TIMEOUT_MS;
}
function getCanvasProviderRuntimeParams(params: {
debugLabel: string;
debugMeta?: Record<string, unknown>;
providerType: string;
}) {
if (
params.providerType === "snapgen-image" &&
isCanvasScaleAdjustDebugRequest(params.debugLabel, params.debugMeta)
) {
return {
requestTimeoutMs: CANVAS_SCALE_ADJUST_SNAPGEN_REQUEST_TIMEOUT_MS,
pollTimeoutMs: CANVAS_SCALE_ADJUST_SNAPGEN_POLL_TIMEOUT_MS,
historyRequestTimeoutMs: CANVAS_SCALE_ADJUST_SNAPGEN_HISTORY_TIMEOUT_MS,
imageDownloadTimeoutMs: CANVAS_SCALE_ADJUST_SNAPGEN_DOWNLOAD_TIMEOUT_MS,
};
}
if (
params.providerType === "fal-image" &&
isCanvasScaleAdjustDebugRequest(params.debugLabel, params.debugMeta)
) {
return {
timeoutMs: CANVAS_SCALE_ADJUST_FINAL_PROVIDER_BATCH_TIMEOUT_MS,
};
}
return {};
}
async function withCanvasProviderBatchTimeout<T>(
operation: Promise<T>,
params: {
debugLabel: string;
modelId: string;
providerType: string;
timeoutMs: number;
}
): Promise<T> {
if (!Number.isFinite(params.timeoutMs) || params.timeoutMs <= 0) {
return operation;
}
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
operation,
new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new Error(
`${params.providerType} 图片生成超时(${Math.round(
params.timeoutMs / 1000
)}秒),将自动切换备用模型`
)
);
}, params.timeoutMs);
}),
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
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 normalizeScaleAdjustPatchCompositeConfig(
value: CanvasGenerateImageRequest["scaleAdjustPatchComposite"]
): CanvasScaleAdjustPatchCompositeConfig | null {
if (!value || typeof value !== "object") return null;
const sourceImageUrl = String(value.sourceImageUrl || "").trim();
if (!/^https?:\/\//i.test(sourceImageUrl)) return null;
const workspaceRegion = normalizeCanvasRegion(value.workspaceRegion);
if (!workspaceRegion) return null;
const regions = Array.isArray(value.regions)
? value.regions
.map((region) => normalizeCanvasRegion(region))
.filter((region): region is CanvasLocalEditCompositeRegion =>
Boolean(region)
)
: [];
return {
...(/^https?:\/\//i.test(String(value.clipMaskImageUrl || "").trim())
? { clipMaskImageUrl: String(value.clipMaskImageUrl || "").trim() }
: {}),
sourceImageUrl,
workspaceRegion,
...(regions.length > 0 ? { regions } : {}),
maskMode: value.maskMode === "diff" ? "diff" : "workspace",
diffAlphaCutoff: normalizeOptionalNumber(value.diffAlphaCutoff, {
max: 96,
min: 0,
}),
diffAlphaMultiplier: normalizeOptionalNumber(value.diffAlphaMultiplier, {
max: 6,
min: 0.5,
}),
diffHighThreshold: normalizeOptionalNumber(value.diffHighThreshold, {
max: 120,
min: 1,
}),
diffLowThreshold: normalizeOptionalNumber(value.diffLowThreshold, {
max: 80,
min: 0,
}),
diffCompareBlurPx: normalizeOptionalNumber(value.diffCompareBlurPx, {
max: 12,
min: 0,
}),
featherMaxPx: normalizeOptionalNumber(value.featherMaxPx, {
max: 180,
min: 0,
}),
featherMinPx: normalizeOptionalNumber(value.featherMinPx, {
max: 100,
min: 0,
}),
featherRatio: normalizeOptionalNumber(value.featherRatio, {
max: 0.12,
min: 0,
}),
generatedFrame: value.generatedFrame === "full" ? "full" : "workspace",
};
}
function normalizeScaleAdjustProductLockCompositeConfig(
value: CanvasGenerateImageRequest["scaleAdjustProductLockComposite"]
): CanvasScaleAdjustProductLockCompositeConfig | null {
if (!value || typeof value !== "object") return null;
const overlayImageUrl = String(value.overlayImageUrl || "").trim();
if (!/^https?:\/\//i.test(overlayImageUrl)) return null;
const backgroundPlateImageUrl = String(value.backgroundPlateImageUrl || "").trim();
const protectMaskImageUrl = String(value.protectMaskImageUrl || "").trim();
const repairMaskImageUrl = String(value.repairMaskImageUrl || "").trim();
const shadowSourceImageUrl = String(value.shadowSourceImageUrl || "").trim();
const sourceImageUrl = String(value.sourceImageUrl || "").trim();
const sourceSubjectMaskImageUrl = String(
value.sourceSubjectMaskImageUrl || ""
).trim();
const sourceWidth = Math.round(Number(value.sourceWidth || 0));
const sourceHeight = Math.round(Number(value.sourceHeight || 0));
if (sourceWidth <= 0 || sourceHeight <= 0) return null;
return {
overlayImageUrl,
...(backgroundPlateImageUrl && /^https?:\/\//i.test(backgroundPlateImageUrl)
? { backgroundPlateImageUrl }
: {}),
...(protectMaskImageUrl && /^https?:\/\//i.test(protectMaskImageUrl)
? { protectMaskImageUrl }
: {}),
...(repairMaskImageUrl && /^https?:\/\//i.test(repairMaskImageUrl)
? { repairMaskImageUrl }
: {}),
...(shadowSourceImageUrl && /^https?:\/\//i.test(shadowSourceImageUrl)
? { shadowSourceImageUrl }
: {}),
...(sourceImageUrl && /^https?:\/\//i.test(sourceImageUrl)
? { sourceImageUrl }
: {}),
...(sourceSubjectMaskImageUrl && /^https?:\/\//i.test(sourceSubjectMaskImageUrl)
? { sourceSubjectMaskImageUrl }
: {}),
regions: Array.isArray(value.regions)
? value.regions
.map((item) => ({
source: normalizeCanvasRegion(item?.source),
target: normalizeCanvasRegion(item?.target),
}))
.filter(
(item): item is {
source: CanvasLocalEditCompositeRegion;
target: CanvasLocalEditCompositeRegion;
} => Boolean(item.source && item.target)
)
: [],
sourceHeight,
sourceWidth,
};
}
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 oldFootprintMaskImageUrl = String(
(value as { oldFootprintMaskImageUrl?: unknown }).oldFootprintMaskImageUrl ||
""
).trim();
if (!Array.isArray(value.regions) || value.regions.length === 0) return null;
const rawScalePercent = Number(value.scalePercent);
const scalePercent =
Number.isFinite(rawScalePercent) && Math.abs(rawScalePercent) >= 1
? rawScalePercent
: undefined;
const regions = value.regions
.map((region) => normalizeCanvasRegion(region))
.filter((region): region is CanvasLocalEditCompositeRegion => Boolean(region));
const targetRegions = Array.isArray((value as { targetRegions?: unknown[] }).targetRegions)
? (value as { targetRegions?: unknown[] }).targetRegions
?.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)
)
: [];
const regionPrompts = Array.isArray(value.regionPrompts)
? value.regionPrompts
.map((prompt) => String(prompt || "").trim())
.slice(0, regions.length)
: [];
return regions.length > 0 &&
(targetRegions.length > 0 || typeof scalePercent === "number")
? {
sourceImageUrl,
regions,
allowDeterministicFallback: value.allowDeterministicFallback === true,
allowOutsideChangedWhenChangedInside:
value.allowOutsideChangedWhenChangedInside === true,
deterministicProductOverlayApplied:
value.deterministicProductOverlayApplied === true,
rejectFailedCandidates: value.rejectFailedCandidates === true,
rejectNoEffectiveChangeOnly:
value.rejectNoEffectiveChangeOnly === true,
rejectOldFootprintOnly: value.rejectOldFootprintOnly === true,
...(/^https?:\/\//i.test(oldFootprintMaskImageUrl)
? { oldFootprintMaskImageUrl }
: {}),
...(regionPrompts.length > 0 ? { regionPrompts } : {}),
...(targetRegions.length > 0 ? { targetRegions } : {}),
...(typeof scalePercent === "number" ? { 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)
);
}
if (verification.targetRegions?.length) {
return verification.regions.flatMap((region, index) => {
const targetRegion = verification.targetRegions?.[index];
return targetRegion
? [
expandCanvasRegion(region, SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN),
expandCanvasRegion(
targetRegion,
SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN
),
]
: [expandCanvasRegion(region, SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN)];
});
}
if (typeof verification.scalePercent === "number") {
return verification.regions.flatMap((region) => [
expandCanvasRegion(region, SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN),
expandCanvasRegion(
scaleRegionAroundCenter(region, verification.scalePercent as number),
SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN
),
]);
}
return verification.regions.map((region) =>
expandCanvasRegion(region, 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;
}
function getScaleAdjustEffectiveChangeRegions(
verification: CanvasScaleAdjustVerificationConfig
) {
const regions: CanvasLocalEditCompositeRegion[] = [];
for (const [index, region] of verification.regions.entries()) {
regions.push(region);
const targetRegion = verification.targetRegions?.[index];
if (targetRegion) {
regions.push(targetRegion);
} else if (typeof verification.scalePercent === "number") {
regions.push(scaleRegionAroundCenter(region, verification.scalePercent));
}
}
return regions.filter((region, index) => {
const key = `${region.x.toFixed(4)}:${region.y.toFixed(4)}:${region.width.toFixed(4)}:${region.height.toFixed(4)}`;
return (
regions.findIndex(
(item) =>
`${item.x.toFixed(4)}:${item.y.toFixed(4)}:${item.width.toFixed(4)}:${item.height.toFixed(4)}` ===
key
) === index
);
});
}
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
);
}
function getScaleAdjustRawRgbDiff(raw: Buffer, leftIndex: number, rightIndex: number) {
return (
Math.abs(raw[leftIndex] - raw[rightIndex]) * 0.299 +
Math.abs(raw[leftIndex + 1] - raw[rightIndex + 1]) * 0.587 +
Math.abs(raw[leftIndex + 2] - raw[rightIndex + 2]) * 0.114
);
}
function getScaleAdjustPixelChanged(params: {
generatedRaw: Buffer;
index: number;
sourceRaw: Buffer;
}) {
return (
Math.abs(params.generatedRaw[params.index] - params.sourceRaw[params.index]) *
0.299 +
Math.abs(
params.generatedRaw[params.index + 1] -
params.sourceRaw[params.index + 1]
) *
0.587 +
Math.abs(
params.generatedRaw[params.index + 2] -
params.sourceRaw[params.index + 2]
) *
0.114
) >= SCALE_ADJUST_RECTANGULAR_SEAM_CHANGE_THRESHOLD;
}
function summarizeScaleAdjustSeamLine(params: {
axis: "horizontal" | "vertical";
boxIndex: number;
changeMask: Uint8Array;
excludedBoxes: Array<{
height: number;
left: number;
top: number;
width: number;
}>;
generatedRaw: Buffer;
height: number;
position: number;
rangeEnd: number;
rangeStart: number;
sourceRaw: Buffer;
width: number;
}) {
const {
axis,
boxIndex,
changeMask,
excludedBoxes,
generatedRaw,
height,
position,
rangeEnd,
rangeStart,
sourceRaw,
width,
} = params;
let transitionCount = 0;
let currentRun = 0;
let maxRun = 0;
let edgeDiffSum = 0;
const sampleCount = Math.max(1, rangeEnd - rangeStart);
for (let offset = rangeStart; offset < rangeEnd; offset += 1) {
const leftX = axis === "vertical" ? position - 1 : offset;
const leftY = axis === "vertical" ? offset : position - 1;
const rightX = axis === "vertical" ? position : offset;
const rightY = axis === "vertical" ? offset : position;
if (
leftX < 0 ||
rightX < 0 ||
leftY < 0 ||
rightY < 0 ||
leftX >= width ||
rightX >= width ||
leftY >= height ||
rightY >= height
) {
currentRun = 0;
continue;
}
const intersectsExcludedBox = excludedBoxes.some((box) => {
const right = box.left + box.width;
const bottom = box.top + box.height;
const contains = (x: number, y: number) =>
x >= box.left && x < right && y >= box.top && y < bottom;
return contains(leftX, leftY) || contains(rightX, rightY);
});
if (intersectsExcludedBox) {
currentRun = 0;
continue;
}
const leftPixel = leftY * width + leftX;
const rightPixel = rightY * width + rightX;
const changedBoundary =
changeMask[leftPixel] !== changeMask[rightPixel];
const leftRawIndex = leftPixel * 3;
const rightRawIndex = rightPixel * 3;
const generatedEdgeDiff = getScaleAdjustRawRgbDiff(
generatedRaw,
leftRawIndex,
rightRawIndex
);
const sourceEdgeDiff = getScaleAdjustRawRgbDiff(
sourceRaw,
leftRawIndex,
rightRawIndex
);
const likelyBackgroundSeam =
generatedEdgeDiff >= SCALE_ADJUST_RECTANGULAR_SEAM_MIN_EDGE_DIFF &&
generatedEdgeDiff <= SCALE_ADJUST_RECTANGULAR_SEAM_MAX_EDGE_DIFF &&
generatedEdgeDiff - sourceEdgeDiff >=
SCALE_ADJUST_RECTANGULAR_SEAM_EDGE_INCREASE;
if (changedBoundary && likelyBackgroundSeam) {
transitionCount += 1;
currentRun += 1;
maxRun = Math.max(maxRun, currentRun);
edgeDiffSum += generatedEdgeDiff;
} else {
currentRun = 0;
}
}
return {
axis,
boxIndex,
edgeDiffMean: transitionCount > 0 ? edgeDiffSum / transitionCount : 0,
maxRun,
maxRunRatio: maxRun / sampleCount,
position,
sampleCount,
transitionCount,
transitionRatio: transitionCount / sampleCount,
};
}
async function getScaleAdjustRectangularSeamStats(params: {
generatedBuffer: Buffer;
sourceBuffer: Buffer;
sourceWidth: number;
sourceHeight: number;
verification: CanvasScaleAdjustVerificationConfig;
}) {
const { generatedBuffer, sourceBuffer, sourceHeight, sourceWidth, verification } =
params;
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 changeMask = new Uint8Array(sourceWidth * sourceHeight);
for (let pixel = 0; pixel < sourceWidth * sourceHeight; pixel += 1) {
const rawIndex = pixel * 3;
changeMask[pixel] = getScaleAdjustPixelChanged({
generatedRaw,
index: rawIndex,
sourceRaw,
})
? 1
: 0;
}
const protectedBoxes = getScaleAdjustProtectedRegions(verification).map((region) =>
rectToPixelBox({
height: sourceHeight,
region,
width: sourceWidth,
})
);
const targetExclusionBoxes = (verification.targetRegions || []).map((region) =>
rectToPixelBox({
height: sourceHeight,
region,
width: sourceWidth,
})
);
const lineStep = Math.max(
1,
Math.floor(Math.min(sourceWidth, sourceHeight) / 1400)
);
let bestVertical: ReturnType<typeof summarizeScaleAdjustSeamLine> | null = null;
let bestHorizontal: ReturnType<typeof summarizeScaleAdjustSeamLine> | null = null;
for (const [boxIndex, box] of protectedBoxes.entries()) {
const left = Math.max(2, box.left + 2);
const top = Math.max(2, box.top + 2);
const right = Math.min(sourceWidth - 2, box.left + box.width - 2);
const bottom = Math.min(sourceHeight - 2, box.top + box.height - 2);
if (right <= left || bottom <= top) continue;
for (let x = left; x <= right; x += lineStep) {
const stats = summarizeScaleAdjustSeamLine({
axis: "vertical",
boxIndex,
changeMask,
excludedBoxes: targetExclusionBoxes,
generatedRaw,
height: sourceHeight,
position: x,
rangeEnd: bottom,
rangeStart: top,
sourceRaw,
width: sourceWidth,
});
if (!bestVertical || stats.maxRunRatio > bestVertical.maxRunRatio) {
bestVertical = stats;
}
}
for (let y = top; y <= bottom; y += lineStep) {
const stats = summarizeScaleAdjustSeamLine({
axis: "horizontal",
boxIndex,
changeMask,
excludedBoxes: targetExclusionBoxes,
generatedRaw,
height: sourceHeight,
position: y,
rangeEnd: right,
rangeStart: left,
sourceRaw,
width: sourceWidth,
});
if (!bestHorizontal || stats.maxRunRatio > bestHorizontal.maxRunRatio) {
bestHorizontal = stats;
}
}
}
const verticalPair =
bestVertical &&
bestVertical.maxRunRatio >=
SCALE_ADJUST_RECTANGULAR_SEAM_PAIR_RUN_RATIO &&
bestVertical.transitionRatio >=
SCALE_ADJUST_RECTANGULAR_SEAM_PAIR_TRANSITION_RATIO;
const horizontalPair =
bestHorizontal &&
bestHorizontal.maxRunRatio >=
SCALE_ADJUST_RECTANGULAR_SEAM_PAIR_RUN_RATIO &&
bestHorizontal.transitionRatio >=
SCALE_ADJUST_RECTANGULAR_SEAM_PAIR_TRANSITION_RATIO;
const verticalStrong =
bestVertical &&
bestVertical.maxRunRatio >=
SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_RUN_RATIO &&
bestVertical.transitionRatio >=
SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_TRANSITION_RATIO;
const horizontalStrong =
bestHorizontal &&
bestHorizontal.maxRunRatio >=
SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_RUN_RATIO &&
bestHorizontal.transitionRatio >=
SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_TRANSITION_RATIO;
const verticalVeryStrong =
bestVertical &&
bestVertical.maxRunRatio >=
SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_RUN_RATIO &&
bestVertical.transitionRatio >=
SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_TRANSITION_RATIO;
const horizontalVeryStrong =
bestHorizontal &&
bestHorizontal.maxRunRatio >=
SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_RUN_RATIO &&
bestHorizontal.transitionRatio >=
SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_TRANSITION_RATIO;
return {
detected: Boolean(
(verticalPair && horizontalPair) ||
(verticalStrong && horizontalStrong) ||
verticalVeryStrong ||
horizontalVeryStrong
),
horizontal: bestHorizontal,
lineStep,
protectedBoxCount: protectedBoxes.length,
vertical: bestVertical,
};
}
async function getScaleAdjustFootprintChangeStats(params: {
generatedBuffer: Buffer;
sourceBuffer: Buffer;
sourceWidth: number;
sourceHeight: number;
verification: CanvasScaleAdjustVerificationConfig;
}) {
const { generatedBuffer, sourceBuffer, sourceHeight, sourceWidth, verification } =
params;
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 shrinkStats: Array<{
generatedOutsidePixels: number;
outsideRatio: number;
passed: boolean;
passReason: "low-source-mask" | "strict-ratio" | "removed-old-footprint" | "failed";
regionIndex: number;
removedOutsidePixels: number;
removedOutsideRatio: number;
sourceOutsidePixels: number;
}> = [];
for (const [index, region] of verification.regions.entries()) {
const targetRegion =
verification.targetRegions?.[index] ||
(typeof verification.scalePercent === "number"
? scaleRegionAroundCenter(region, verification.scalePercent)
: null);
if (!targetRegion) continue;
const widthFactor = region.width > 0 ? targetRegion.width / region.width : 1;
const heightFactor = region.height > 0 ? targetRegion.height / region.height : 1;
const isShrink = widthFactor < 0.92 || heightFactor < 0.92;
const isMove =
Math.abs(
targetRegion.x + targetRegion.width / 2 -
(region.x + region.width / 2)
) >= 0.01 ||
Math.abs(
targetRegion.y + targetRegion.height / 2 -
(region.y + region.height / 2)
) >= 0.01;
if (!(isShrink || isMove)) continue;
const sourceBox = rectToPixelBox({
height: sourceHeight,
region,
width: sourceWidth,
});
const targetBox = rectToPixelBox({
height: sourceHeight,
region: expandCanvasRegion(
targetRegion,
SCALE_ADJUST_SHRINK_TARGET_FOOTPRINT_MARGIN
),
width: sourceWidth,
});
const sourceMask = buildScaleAdjustFallbackSubjectMask({
box: sourceBox,
raw: sourceRaw,
width: sourceWidth,
});
const generatedMask = buildScaleAdjustFallbackSubjectMask({
box: sourceBox,
raw: generatedRaw,
width: sourceWidth,
});
let sourceOutsidePixels = 0;
let generatedOutsidePixels = 0;
for (let localY = 0; localY < sourceBox.height; localY += 1) {
const y = sourceBox.top + localY;
for (let localX = 0; localX < sourceBox.width; localX += 1) {
const x = sourceBox.left + localX;
const insideTarget =
x >= targetBox.left &&
x < targetBox.left + targetBox.width &&
y >= targetBox.top &&
y < targetBox.top + targetBox.height;
if (insideTarget) continue;
const maskIndex = localY * sourceBox.width + localX;
if (sourceMask[maskIndex] > 0) sourceOutsidePixels += 1;
if (generatedMask[maskIndex] > 0) generatedOutsidePixels += 1;
}
}
const outsideRatio =
sourceOutsidePixels > 0 ? generatedOutsidePixels / sourceOutsidePixels : 0;
const removedOutsidePixels = sourceOutsidePixels - generatedOutsidePixels;
const removedOutsideRatio =
sourceOutsidePixels > 0 ? removedOutsidePixels / sourceOutsidePixels : 0;
const minRemovedOutsidePixels = Math.max(
SCALE_ADJUST_SHRINK_MIN_SOURCE_OUTSIDE_PIXELS,
Math.round(
sourceOutsidePixels * SCALE_ADJUST_SHRINK_OUTSIDE_FOOTPRINT_MIN_REMOVED_RATIO
)
);
const lowSourceMask =
sourceOutsidePixels < SCALE_ADJUST_SHRINK_MIN_SOURCE_OUTSIDE_PIXELS;
const strictRatio =
outsideRatio <= SCALE_ADJUST_SHRINK_OUTSIDE_FOOTPRINT_MAX_RATIO;
const removedOldFootprint =
outsideRatio <= SCALE_ADJUST_SHRINK_OUTSIDE_FOOTPRINT_RELAXED_MAX_RATIO &&
removedOutsidePixels >= minRemovedOutsidePixels;
const passed = lowSourceMask || strictRatio || removedOldFootprint;
shrinkStats.push({
generatedOutsidePixels,
outsideRatio,
passed,
passReason: lowSourceMask
? "low-source-mask"
: strictRatio
? "strict-ratio"
: removedOldFootprint
? "removed-old-footprint"
: "failed",
regionIndex: index,
removedOutsidePixels,
removedOutsideRatio,
sourceOutsidePixels,
});
}
return {
failedRegionCount: shrinkStats.filter((item) => !item.passed).length,
passed: shrinkStats.every((item) => item.passed),
shrinkRegionCount: shrinkStats.length,
shrinkStats,
};
}
async function loadScaleAdjustOldFootprintMask(params: {
height: number;
url?: string;
width: number;
}) {
const { height, url, width } = params;
if (!url) return null;
try {
return await sharp(await fetchImageBufferFromUrl(url), { failOn: "none" })
.rotate()
.resize(width, height, { fit: "fill" })
.ensureAlpha()
.extractChannel(3)
.greyscale()
.raw()
.toBuffer();
} catch (error) {
console.warn("[Canvas Generate Image] Failed to load scale adjust old footprint mask:", {
error: error instanceof Error ? error.message : String(error),
url,
});
return null;
}
}
async function getScaleAdjustOldFootprintGhostStats(params: {
generatedBuffer: Buffer;
sourceBuffer: Buffer;
sourceWidth: number;
sourceHeight: number;
verification: CanvasScaleAdjustVerificationConfig;
}) {
const { generatedBuffer, sourceBuffer, sourceHeight, sourceWidth, verification } =
params;
const normalizeToRaw = (buffer: Buffer) =>
sharp(buffer, { failOn: "none" })
.rotate()
.resize(sourceWidth, sourceHeight, {
fit: "cover",
position: "center",
})
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer();
const [sourceRaw, generatedRaw, explicitMaskRaw] = await Promise.all([
normalizeToRaw(sourceBuffer),
normalizeToRaw(generatedBuffer),
loadScaleAdjustOldFootprintMask({
height: sourceHeight,
url: verification.oldFootprintMaskImageUrl,
width: sourceWidth,
}),
]);
const shrinkStats: Array<{
detected: boolean;
darkResidualPixels: number;
darkResidualRatio: number;
maskSource: "explicit" | "fallback";
regionIndex: number;
similarPixels: number;
similarRatio: number;
sourceOutsidePixels: number;
}> = [];
for (const [index, region] of verification.regions.entries()) {
const targetRegion =
verification.targetRegions?.[index] ||
(typeof verification.scalePercent === "number"
? scaleRegionAroundCenter(region, verification.scalePercent)
: null);
if (!targetRegion) continue;
const widthFactor = region.width > 0 ? targetRegion.width / region.width : 1;
const heightFactor = region.height > 0 ? targetRegion.height / region.height : 1;
const isShrink = widthFactor < 0.92 || heightFactor < 0.92;
const isMove =
Math.abs(
targetRegion.x + targetRegion.width / 2 -
(region.x + region.width / 2)
) >= 0.01 ||
Math.abs(
targetRegion.y + targetRegion.height / 2 -
(region.y + region.height / 2)
) >= 0.01;
if (!(isShrink || isMove)) continue;
const sourceBox = rectToPixelBox({
height: sourceHeight,
region,
width: sourceWidth,
});
const targetBox = rectToPixelBox({
height: sourceHeight,
region: expandCanvasRegion(
targetRegion,
SCALE_ADJUST_OLD_FOOTPRINT_TARGET_MARGIN * 1.35
),
width: sourceWidth,
});
const fallbackMask = buildScaleAdjustFallbackSubjectMask({
box: sourceBox,
raw: sourceRaw,
width: sourceWidth,
});
const background = estimateScaleAdjustFallbackBackground({
box: sourceBox,
raw: sourceRaw,
width: sourceWidth,
});
let sourceOutsidePixels = 0;
let similarPixels = 0;
let darkResidualPixels = 0;
let explicitPixels = 0;
for (let localY = 0; localY < sourceBox.height; localY += 1) {
const y = sourceBox.top + localY;
for (let localX = 0; localX < sourceBox.width; localX += 1) {
const x = sourceBox.left + localX;
const insideTarget =
x >= targetBox.left &&
x < targetBox.left + targetBox.width &&
y >= targetBox.top &&
y < targetBox.top + targetBox.height;
if (insideTarget) continue;
const localIndex = localY * sourceBox.width + localX;
const absoluteIndex = y * sourceWidth + x;
const explicitMaskValue = explicitMaskRaw?.[absoluteIndex] || 0;
const maskHit = explicitMaskRaw
? explicitMaskValue > 18
: fallbackMask[localIndex] > 0;
if (!maskHit) continue;
if (explicitMaskValue > 18) explicitPixels += 1;
const rawIndex = getRawPixelIndex(sourceWidth, x, y);
const sourceR = sourceRaw[rawIndex] || 0;
const sourceG = sourceRaw[rawIndex + 1] || 0;
const sourceB = sourceRaw[rawIndex + 2] || 0;
const generatedR = generatedRaw[rawIndex] || 0;
const generatedG = generatedRaw[rawIndex + 1] || 0;
const generatedB = generatedRaw[rawIndex + 2] || 0;
const sourceLuma = getLuma(sourceR, sourceG, sourceB);
const generatedLuma = getLuma(generatedR, generatedG, generatedB);
const sourceBackgroundDistance = Math.sqrt(
(sourceR - background.r) ** 2 +
(sourceG - background.g) ** 2 +
(sourceB - background.b) ** 2
);
if (sourceBackgroundDistance < 24 && explicitMaskValue <= 18) continue;
sourceOutsidePixels += 1;
const sourceGeneratedDiff = Math.sqrt(
(sourceR - generatedR) ** 2 +
(sourceG - generatedG) ** 2 +
(sourceB - generatedB) ** 2
);
const similarDiffThreshold = explicitMaskRaw
? SCALE_ADJUST_OLD_FOOTPRINT_EXPLICIT_SIMILAR_DIFF_THRESHOLD
: SCALE_ADJUST_OLD_FOOTPRINT_SIMILAR_DIFF_THRESHOLD;
if (sourceGeneratedDiff <= similarDiffThreshold) {
similarPixels += 1;
}
if (
generatedLuma <= background.luma - 32 &&
sourceLuma <= background.luma - 18
) {
darkResidualPixels += 1;
}
}
}
const similarRatio =
sourceOutsidePixels > 0 ? similarPixels / sourceOutsidePixels : 0;
const darkResidualRatio =
sourceOutsidePixels > 0 ? darkResidualPixels / sourceOutsidePixels : 0;
const enoughPixels =
sourceOutsidePixels >= SCALE_ADJUST_OLD_FOOTPRINT_MIN_PIXELS;
const usesExplicitMask = explicitPixels > 0;
const detected =
enoughPixels &&
(similarRatio >= SCALE_ADJUST_OLD_FOOTPRINT_SIMILAR_RATIO_THRESHOLD ||
(!usesExplicitMask &&
!isMove &&
(darkResidualRatio >=
SCALE_ADJUST_OLD_FOOTPRINT_DARK_RATIO_THRESHOLD ||
(similarRatio >= 0.12 && darkResidualRatio >= 0.14))));
shrinkStats.push({
detected,
darkResidualPixels,
darkResidualRatio,
maskSource: explicitPixels > 0 ? "explicit" : "fallback",
regionIndex: index,
similarPixels,
similarRatio,
sourceOutsidePixels,
});
}
return {
detected: shrinkStats.some((item) => item.detected),
shrinkRegionCount: shrinkStats.length,
shrinkStats,
};
}
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,
};
}
function isScaleAdjustAnnotationMarkerColor(r: number, g: number, b: number) {
const redMarker = r >= 175 && g <= 120 && b <= 120 && r >= g + 55 && r >= b + 55;
const cyanMarker =
g >= 145 && b >= 145 && r <= 135 && Math.min(g, b) >= r + 45;
const blueMarker =
b >= 165 && r <= 125 && b >= r + 55 && b >= g + 20;
return redMarker || cyanMarker || blueMarker;
}
function isPointInCanvasRegions(
normalizedX: number,
normalizedY: number,
regions: CanvasLocalEditCompositeRegion[]
) {
return regions.some(
(region) =>
normalizedX >= region.x &&
normalizedX <= region.x + region.width &&
normalizedY >= region.y &&
normalizedY <= region.y + region.height
);
}
function dilateBinaryMask(params: {
height: number;
mask: Uint8Array;
radius: number;
width: number;
}) {
const { height, mask, radius, width } = params;
if (radius <= 0) return mask;
const dilated = new Uint8Array(mask.length);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (mask[y * width + x] === 0) continue;
const left = Math.max(0, x - radius);
const right = Math.min(width - 1, x + radius);
const top = Math.max(0, y - radius);
const bottom = Math.min(height - 1, y + radius);
for (let yy = top; yy <= bottom; yy += 1) {
for (let xx = left; xx <= right; xx += 1) {
dilated[yy * width + xx] = 255;
}
}
}
}
return dilated;
}
async function removeScaleAdjustAnnotationArtifacts(params: {
debugLabel: string;
generatedBuffer: Buffer;
imageIndex: number;
sourceBuffer: Buffer;
sourceHeight: number;
sourceWidth: number;
verification: CanvasScaleAdjustVerificationConfig;
}) {
const {
debugLabel,
generatedBuffer,
imageIndex,
sourceBuffer,
sourceHeight,
sourceWidth,
verification,
} = params;
const cleanupRegions = getScaleAdjustProtectedRegions(verification).map((region) =>
expandCanvasRegion(region, SCALE_ADJUST_MARKER_CLEANUP_MARGIN)
);
if (cleanupRegions.length === 0) return null;
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 markerMask = new Uint8Array(sourceWidth * sourceHeight);
let markerPixelCount = 0;
for (let y = 0; y < sourceHeight; y += 1) {
const normalizedY = y / sourceHeight;
for (let x = 0; x < sourceWidth; x += 1) {
const normalizedX = x / sourceWidth;
if (!isPointInCanvasRegions(normalizedX, normalizedY, cleanupRegions)) {
continue;
}
const maskIndex = y * sourceWidth + x;
const rawIndex = maskIndex * 3;
if (
isScaleAdjustAnnotationMarkerColor(
generatedRaw[rawIndex],
generatedRaw[rawIndex + 1],
generatedRaw[rawIndex + 2]
)
) {
markerMask[maskIndex] = 255;
markerPixelCount += 1;
}
}
}
if (markerPixelCount < SCALE_ADJUST_MARKER_MIN_PIXEL_COUNT) return null;
const cleanupMask = dilateBinaryMask({
height: sourceHeight,
mask: markerMask,
radius: SCALE_ADJUST_MARKER_DILATE_RADIUS,
width: sourceWidth,
});
const cleanedRaw = Buffer.from(generatedRaw);
let cleanedPixelCount = 0;
for (let index = 0; index < cleanupMask.length; index += 1) {
if (cleanupMask[index] === 0) continue;
const rawIndex = index * 3;
cleanedRaw[rawIndex] = sourceRaw[rawIndex];
cleanedRaw[rawIndex + 1] = sourceRaw[rawIndex + 1];
cleanedRaw[rawIndex + 2] = sourceRaw[rawIndex + 2];
cleanedPixelCount += 1;
}
const cleanedBuffer = await sharp(cleanedRaw, {
raw: { channels: 3, height: sourceHeight, width: sourceWidth },
})
.png({ compressionLevel: 6 })
.toBuffer();
const thumbnailBuffer = await sharp(cleanedBuffer, { failOn: "none" })
.resize(512, 512, { fit: "inside", withoutEnlargement: true })
.png({ compressionLevel: 6 })
.toBuffer();
const suffix = `${Date.now()}-${imageIndex + 1}-${Math.random()
.toString(36)
.slice(2, 8)}`;
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(
`canvas-scale-adjust-marker-cleaned-${suffix}.png`,
bufferToArrayBuffer(cleanedBuffer)
),
uploadFile(
`canvas-scale-adjust-marker-cleaned-thumb-${suffix}.png`,
bufferToArrayBuffer(thumbnailBuffer)
),
]);
console.info("[Canvas Generate Image] Scale adjust annotation artifacts cleaned:", {
cleanupRegionCount: cleanupRegions.length,
cleanedPixelCount,
debugLabel,
imageIndex,
markerPixelCount,
sourceHeight,
sourceWidth,
});
return {
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url,
} satisfies ParsedImage;
}
async function applyScaleAdjustVerificationToImages(params: {
images: ParsedImage[];
verification: CanvasScaleAdjustVerificationConfig | null;
debugLabel: string;
skipAnnotationCleanup?: boolean;
}) {
const { images, verification, debugLabel, skipAnnotationCleanup = false } = 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 ?? null,
targetRegionCount: verification.targetRegions?.length || 0,
});
return images;
}
const evaluatedImages: Array<{
changeDiffScores: number[];
changedInside: boolean;
cleanedImage?: ParsedImage;
diffScores: number[];
footprintStats: Awaited<ReturnType<typeof getScaleAdjustFootprintChangeStats>>;
oldFootprintGhostDetected: boolean;
oldFootprintGhostStats: Awaited<
ReturnType<typeof getScaleAdjustOldFootprintGhostStats>
>;
image: ParsedImage;
index: number;
outsideChanged: boolean;
outsideDiffScores: number[];
outsidePixelStats: Awaited<
ReturnType<typeof getScaleAdjustOutsidePixelChangeStats>
>;
rectangularSeamDetected: boolean;
rectangularSeamStats: Awaited<
ReturnType<typeof getScaleAdjustRectangularSeamStats>
>;
}> = [];
for (const [index, image] of images.entries()) {
const generatedUrl = image.originalUrl || image.thumbnailUrl;
const generatedBuffer = await fetchImageBufferFromUrl(generatedUrl);
const changeRegions = getScaleAdjustEffectiveChangeRegions(verification);
const [
diffScores,
changeDiffScores,
outsideDiffScores,
outsidePixelStats,
footprintStats,
oldFootprintGhostStats,
rectangularSeamStats,
] = await Promise.all([
getScaleAdjustDiffScores({
generatedBuffer,
regions: verification.regions,
sourceBuffer: sourceImage.sourceBuffer,
sourceHeight: sourceImage.sourceHeight,
sourceWidth: sourceImage.sourceWidth,
}),
getScaleAdjustDiffScores({
generatedBuffer,
regions: changeRegions,
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,
}),
getScaleAdjustFootprintChangeStats({
generatedBuffer,
sourceBuffer: sourceImage.sourceBuffer,
sourceHeight: sourceImage.sourceHeight,
sourceWidth: sourceImage.sourceWidth,
verification,
}),
getScaleAdjustOldFootprintGhostStats({
generatedBuffer,
sourceBuffer: sourceImage.sourceBuffer,
sourceHeight: sourceImage.sourceHeight,
sourceWidth: sourceImage.sourceWidth,
verification,
}),
getScaleAdjustRectangularSeamStats({
generatedBuffer,
sourceBuffer: sourceImage.sourceBuffer,
sourceHeight: sourceImage.sourceHeight,
sourceWidth: sourceImage.sourceWidth,
verification,
}),
]);
const visuallyChangedInside = changeDiffScores.some(
(score) => score >= SCALE_ADJUST_VISUAL_DIFF_THRESHOLD
);
const footprintFallbackAccepted =
visuallyChangedInside &&
!footprintStats.passed &&
changeDiffScores.some(
(score) => score >= SCALE_ADJUST_FOOTPRINT_FALLBACK_DIFF_THRESHOLD
);
const deterministicProductOverlayAccepted =
verification.deterministicProductOverlayApplied === true;
const changedInside =
deterministicProductOverlayAccepted ||
(visuallyChangedInside && footprintStats.passed) ||
footprintFallbackAccepted;
const outsideChanged =
isScaleAdjustOutsideChanged(outsideDiffScores) ||
isScaleAdjustOutsidePixelChanged(outsidePixelStats);
const oldFootprintGhostDetected = oldFootprintGhostStats.detected;
const rectangularSeamDetected = rectangularSeamStats.detected;
console.info("[Canvas Generate Image] Scale adjust result guard:", {
debugLabel,
changedInside,
deterministicProductOverlayAccepted,
footprintFallbackAccepted,
footprintChanged: footprintStats.passed,
footprintStats: {
failedRegionCount: footprintStats.failedRegionCount,
shrinkRegionCount: footprintStats.shrinkRegionCount,
shrinkStats: footprintStats.shrinkStats.map((item) => ({
generatedOutsidePixels: item.generatedOutsidePixels,
outsideRatio: Number(item.outsideRatio.toFixed(3)),
passed: item.passed,
passReason: item.passReason,
regionIndex: item.regionIndex,
removedOutsidePixels: item.removedOutsidePixels,
removedOutsideRatio: Number(item.removedOutsideRatio.toFixed(3)),
sourceOutsidePixels: item.sourceOutsidePixels,
})),
},
imageIndex: index,
outsideChanged,
oldFootprintGhostDetected,
oldFootprintGhostStats: {
shrinkRegionCount: oldFootprintGhostStats.shrinkRegionCount,
shrinkStats: oldFootprintGhostStats.shrinkStats.map((item) => ({
darkResidualRatio: Number(item.darkResidualRatio.toFixed(3)),
detected: item.detected,
maskSource: item.maskSource,
regionIndex: item.regionIndex,
similarRatio: Number(item.similarRatio.toFixed(3)),
sourceOutsidePixels: item.sourceOutsidePixels,
})),
},
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,
},
changeRegionDiffScores: changeDiffScores.map((score) =>
Number(score.toFixed(2))
),
protectedRegionCount: verification.protectedRegions?.length || 0,
rectangularSeamDetected,
rectangularSeamStats: {
horizontal: rectangularSeamStats.horizontal
? {
edgeDiffMean: Number(
rectangularSeamStats.horizontal.edgeDiffMean.toFixed(2)
),
maxRunRatio: Number(
rectangularSeamStats.horizontal.maxRunRatio.toFixed(3)
),
position: rectangularSeamStats.horizontal.position,
transitionRatio: Number(
rectangularSeamStats.horizontal.transitionRatio.toFixed(3)
),
}
: null,
lineStep: rectangularSeamStats.lineStep,
vertical: rectangularSeamStats.vertical
? {
edgeDiffMean: Number(
rectangularSeamStats.vertical.edgeDiffMean.toFixed(2)
),
maxRunRatio: Number(
rectangularSeamStats.vertical.maxRunRatio.toFixed(3)
),
position: rectangularSeamStats.vertical.position,
transitionRatio: Number(
rectangularSeamStats.vertical.transitionRatio.toFixed(3)
),
}
: null,
},
regionCount: verification.regions.length,
regionDiffScores: diffScores.map((score) => Number(score.toFixed(2))),
scalePercent: verification.scalePercent ?? null,
targetRegionCount: verification.targetRegions?.length || 0,
});
const guardWarnings = [
...(outsideChanged ? ["outside-frame-changed"] : []),
...(!changedInside ? ["no-effective-scale-change"] : []),
...(rectangularSeamDetected ? ["rectangular-seam-detected"] : []),
...(oldFootprintGhostDetected ? ["old-footprint-remnant-detected"] : []),
];
if (guardWarnings.length > 0) {
console.warn("[Canvas Generate Image] Scale adjust result guard warning; keeping result for preview:", {
debugLabel,
imageIndex: index,
reasons: guardWarnings,
changedInside,
footprintFallbackAccepted,
footprintChanged: footprintStats.passed,
footprintStats: {
failedRegionCount: footprintStats.failedRegionCount,
shrinkRegionCount: footprintStats.shrinkRegionCount,
shrinkStats: footprintStats.shrinkStats.map((item) => ({
generatedOutsidePixels: item.generatedOutsidePixels,
outsideRatio: Number(item.outsideRatio.toFixed(3)),
passed: item.passed,
passReason: item.passReason,
regionIndex: item.regionIndex,
removedOutsidePixels: item.removedOutsidePixels,
removedOutsideRatio: Number(item.removedOutsideRatio.toFixed(3)),
sourceOutsidePixels: item.sourceOutsidePixels,
})),
},
outsideChanged,
oldFootprintGhostDetected,
oldFootprintGhostStats: {
shrinkRegionCount: oldFootprintGhostStats.shrinkRegionCount,
shrinkStats: oldFootprintGhostStats.shrinkStats.map((item) => ({
darkResidualRatio: Number(item.darkResidualRatio.toFixed(3)),
detected: item.detected,
maskSource: item.maskSource,
regionIndex: item.regionIndex,
similarRatio: Number(item.similarRatio.toFixed(3)),
sourceOutsidePixels: item.sourceOutsidePixels,
})),
},
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,
},
rectangularSeamDetected,
rectangularSeamStats: {
horizontal: rectangularSeamStats.horizontal
? {
edgeDiffMean: Number(
rectangularSeamStats.horizontal.edgeDiffMean.toFixed(2)
),
maxRunRatio: Number(
rectangularSeamStats.horizontal.maxRunRatio.toFixed(3)
),
position: rectangularSeamStats.horizontal.position,
transitionRatio: Number(
rectangularSeamStats.horizontal.transitionRatio.toFixed(3)
),
}
: null,
vertical: rectangularSeamStats.vertical
? {
edgeDiffMean: Number(
rectangularSeamStats.vertical.edgeDiffMean.toFixed(2)
),
maxRunRatio: Number(
rectangularSeamStats.vertical.maxRunRatio.toFixed(3)
),
position: rectangularSeamStats.vertical.position,
transitionRatio: Number(
rectangularSeamStats.vertical.transitionRatio.toFixed(3)
),
}
: null,
},
changeRegionDiffScores: changeDiffScores.map((score) =>
Number(score.toFixed(2))
),
regionDiffScores: diffScores.map((score) => Number(score.toFixed(2))),
scalePercent: verification.scalePercent ?? null,
targetRegionCount: verification.targetRegions?.length || 0,
});
}
let cleanedImage: ParsedImage | undefined;
if (!skipAnnotationCleanup) {
try {
cleanedImage = (await removeScaleAdjustAnnotationArtifacts({
debugLabel,
generatedBuffer,
imageIndex: index,
sourceBuffer: sourceImage.sourceBuffer,
sourceHeight: sourceImage.sourceHeight,
sourceWidth: sourceImage.sourceWidth,
verification,
})) ?? undefined;
if (cleanedImage) {
evaluatedImages.push({
changeDiffScores,
changedInside,
cleanedImage,
diffScores,
footprintStats,
oldFootprintGhostDetected,
oldFootprintGhostStats,
image,
index,
outsideChanged,
outsideDiffScores,
outsidePixelStats,
rectangularSeamDetected,
rectangularSeamStats,
});
continue;
}
} catch (cleanupError) {
console.warn("[Canvas Generate Image] Failed to clean scale adjust annotation artifacts:", {
debugLabel,
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
imageIndex: index,
});
}
}
evaluatedImages.push({
changeDiffScores,
changedInside,
cleanedImage,
diffScores,
footprintStats,
oldFootprintGhostDetected,
oldFootprintGhostStats,
image,
index,
outsideChanged,
outsideDiffScores,
outsidePixelStats,
rectangularSeamDetected,
rectangularSeamStats,
});
}
if (evaluatedImages.length === 0) return images;
const scoreEvaluation = (item: (typeof evaluatedImages)[number]) => {
const insideScore = Math.max(...item.changeDiffScores, ...item.diffScores, 0);
const outsideMean = item.outsidePixelStats.meanDiff;
const outsidePenalty = item.outsideChanged ? 100 : 0;
const noChangePenalty = item.changedInside ? 0 : 60;
const seamPenalty = item.rectangularSeamDetected ? 140 : 0;
const ghostPenalty = item.oldFootprintGhostDetected ? 160 : 0;
return (
insideScore -
outsideMean * 0.35 -
outsidePenalty -
noChangePenalty -
seamPenalty -
ghostPenalty
);
};
const rankedImages = [...evaluatedImages].sort((left, right) => {
const leftPassed =
left.changedInside &&
!left.outsideChanged &&
!left.rectangularSeamDetected &&
!left.oldFootprintGhostDetected;
const rightPassed =
right.changedInside &&
!right.outsideChanged &&
!right.rectangularSeamDetected &&
!right.oldFootprintGhostDetected;
if (leftPassed !== rightPassed) return leftPassed ? -1 : 1;
return scoreEvaluation(right) - scoreEvaluation(left);
});
const selected = rankedImages[0];
console.info("[Canvas Generate Image] Scale adjust candidate selected:", {
debugLabel,
candidateCount: evaluatedImages.length,
imageIndex: selected.index,
passed:
selected.changedInside &&
!selected.outsideChanged &&
!selected.rectangularSeamDetected &&
!selected.oldFootprintGhostDetected,
changedInside: selected.changedInside,
oldFootprintGhostDetected: selected.oldFootprintGhostDetected,
outsideChanged: selected.outsideChanged,
rectangularSeamDetected: selected.rectangularSeamDetected,
changeRegionDiffScores: selected.changeDiffScores.map((score) =>
Number(score.toFixed(2))
),
regionDiffScores: selected.diffScores.map((score) => Number(score.toFixed(2))),
outsidePixelStats: {
changedRatio: Number(selected.outsidePixelStats.changedRatio.toFixed(4)),
meanDiff: Number(selected.outsidePixelStats.meanDiff.toFixed(2)),
},
score: Number(scoreEvaluation(selected).toFixed(2)),
});
const outsideChangeAccepted =
verification.allowOutsideChangedWhenChangedInside &&
selected.changedInside &&
selected.outsideChanged;
if (outsideChangeAccepted) {
console.warn("[Canvas Generate Image] Scale adjust accepting best AI candidate despite outside-frame diff:", {
debugLabel,
imageIndex: selected.index,
outsidePixelStats: {
changedRatio: Number(selected.outsidePixelStats.changedRatio.toFixed(4)),
meanDiff: Number(selected.outsidePixelStats.meanDiff.toFixed(2)),
},
changeRegionDiffScores: selected.changeDiffScores.map((score) =>
Number(score.toFixed(2))
),
regionDiffScores: selected.diffScores.map((score) => Number(score.toFixed(2))),
});
}
const rejectionReason = !selected.changedInside
? "no-effective-scale-change"
: verification.rejectNoEffectiveChangeOnly
? null
: verification.rejectOldFootprintOnly
? selected.oldFootprintGhostDetected
? "old-footprint-remnant-detected"
: null
: selected.rectangularSeamDetected
? "rectangular-seam-detected"
: selected.oldFootprintGhostDetected
? "old-footprint-remnant-detected"
: selected.outsideChanged && !outsideChangeAccepted
? "outside-frame-changed"
: null;
if (verification.rejectFailedCandidates && rejectionReason) {
throw new ScaleAdjustOutputRejectedError({
reason: rejectionReason,
diffScores: selected.changeDiffScores,
images: [selected.cleanedImage || selected.image],
outsideDiffScores: selected.outsideDiffScores,
});
}
return [selected.cleanedImage || selected.image];
}
function getRawPixelIndex(width: number, x: number, y: number) {
return (y * width + x) * 3;
}
function normalizeScaleAdjustFocusText(input: unknown) {
return String(input || "")
.trim()
.toLowerCase()
.normalize("NFKC")
.replace(/[「」『』【】[\]()(){}]/g, " ")
.replace(/[_/|::,,.。+]+/g, " ")
.replace(/\s+/g, " ");
}
function getScaleAdjustFallbackFocusDirection(prompt: unknown) {
const text = normalizeScaleAdjustFocusText(prompt);
const compact = text.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "");
if (text.includes("left") || compact.includes("左")) return "left";
if (text.includes("right") || compact.includes("右")) return "right";
if (text.includes("top") || compact.includes("上")) return "top";
if (text.includes("bottom") || compact.includes("下")) return "bottom";
return "";
}
function getScaleAdjustFallbackFocusedSourceBox(params: {
prompt?: string;
sourceBox: ReturnType<typeof rectToPixelBox>;
}) {
const { prompt, sourceBox } = params;
const direction = getScaleAdjustFallbackFocusDirection(prompt);
if (!direction) return sourceBox;
const minWidth = Math.max(1, Math.round(sourceBox.width * 0.42));
const minHeight = Math.max(1, Math.round(sourceBox.height * 0.42));
let left = sourceBox.left;
let top = sourceBox.top;
let width = sourceBox.width;
let height = sourceBox.height;
if (direction === "left") {
width = Math.max(minWidth, Math.round(sourceBox.width * 0.68));
} else if (direction === "right") {
const nextWidth = Math.max(minWidth, Math.round(sourceBox.width * 0.68));
left = sourceBox.left + sourceBox.width - nextWidth;
width = nextWidth;
} else if (direction === "top") {
height = Math.max(minHeight, Math.round(sourceBox.height * 0.72));
} else if (direction === "bottom") {
const nextHeight = Math.max(minHeight, Math.round(sourceBox.height * 0.72));
top = sourceBox.top + sourceBox.height - nextHeight;
height = nextHeight;
}
return {
left,
top,
width,
height,
};
}
function getLuma(r: number, g: number, b: number) {
return r * 0.299 + g * 0.587 + b * 0.114;
}
function dilateMask(params: {
height: number;
mask: Uint8Array;
radius: number;
width: number;
}) {
const { height, mask, radius, width } = params;
if (radius <= 0) return mask;
const output = new Uint8Array(mask.length);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (mask[y * width + x] === 0) continue;
const left = Math.max(0, x - radius);
const right = Math.min(width - 1, x + radius);
const top = Math.max(0, y - radius);
const bottom = Math.min(height - 1, y + radius);
for (let yy = top; yy <= bottom; yy += 1) {
for (let xx = left; xx <= right; xx += 1) {
output[yy * width + xx] = 255;
}
}
}
}
return output;
}
function estimateScaleAdjustFallbackBackground(params: {
box: ReturnType<typeof rectToPixelBox>;
raw: Buffer;
width: number;
}) {
const { box, raw, width } = params;
const samples: Array<{ b: number; g: number; luma: number; r: number }> = [];
for (let y = box.top; y < box.top + box.height; y += 1) {
for (let x = box.left; x < box.left + box.width; x += 1) {
const index = getRawPixelIndex(width, x, y);
const r = raw[index] || 0;
const g = raw[index + 1] || 0;
const b = raw[index + 2] || 0;
samples.push({ b, g, luma: getLuma(r, g, b), r });
}
}
if (samples.length === 0) {
return { b: 245, g: 245, luma: 245, r: 245 };
}
samples.sort((left, right) => right.luma - left.luma);
const keepCount = Math.max(16, Math.ceil(samples.length * 0.35));
const kept = samples.slice(0, keepCount);
const totals = kept.reduce(
(acc, sample) => {
acc.r += sample.r;
acc.g += sample.g;
acc.b += sample.b;
acc.luma += sample.luma;
return acc;
},
{ b: 0, g: 0, luma: 0, r: 0 }
);
return {
b: totals.b / kept.length,
g: totals.g / kept.length,
luma: totals.luma / kept.length,
r: totals.r / kept.length,
};
}
function buildScaleAdjustFallbackSubjectMask(params: {
box: ReturnType<typeof rectToPixelBox>;
raw: Buffer;
width: number;
}) {
const { box, raw, width } = params;
const background = estimateScaleAdjustFallbackBackground(params);
const mask = new Uint8Array(box.width * box.height);
for (let localY = 0; localY < box.height; localY += 1) {
const y = box.top + localY;
for (let localX = 0; localX < box.width; localX += 1) {
if (localX >= box.width * 0.94) continue;
const x = box.left + localX;
const rawIndex = getRawPixelIndex(width, x, y);
const r = raw[rawIndex] || 0;
const g = raw[rawIndex + 1] || 0;
const b = raw[rawIndex + 2] || 0;
const luma = getLuma(r, g, b);
const colorDistance = Math.sqrt(
(r - background.r) ** 2 +
(g - background.g) ** 2 +
(b - background.b) ** 2
);
if (
(colorDistance >= SCALE_ADJUST_FALLBACK_MASK_DISTANCE_THRESHOLD &&
luma <= background.luma - SCALE_ADJUST_FALLBACK_MASK_LUMA_GAP) ||
luma <= Math.min(118, background.luma - 55)
) {
mask[localY * box.width + localX] = 255;
}
}
}
return dilateMask({
height: box.height,
mask,
radius: Math.max(1, Math.round(Math.min(box.width, box.height) * 0.018)),
width: box.width,
});
}
function inpaintScaleAdjustMaskedRegion(params: {
background: { b: number; g: number; r: number };
box: ReturnType<typeof rectToPixelBox>;
mask: Uint8Array;
raw: Buffer;
width: number;
}) {
const { background, box, mask, raw, width } = params;
const working = Buffer.from(raw);
const pending = new Uint8Array(mask);
let remaining = pending.reduce((sum, value) => sum + (value ? 1 : 0), 0);
const offsets = [
[-1, 0],
[1, 0],
[0, -1],
[0, 1],
[-1, -1],
[1, -1],
[-1, 1],
[1, 1],
];
for (
let iteration = 0;
iteration < SCALE_ADJUST_FALLBACK_INPAINT_ITERATIONS && remaining > 0;
iteration += 1
) {
const nextValues: Array<{ b: number; g: number; index: number; r: number }> = [];
for (let localY = 0; localY < box.height; localY += 1) {
for (let localX = 0; localX < box.width; localX += 1) {
const maskIndex = localY * box.width + localX;
if (pending[maskIndex] === 0) continue;
let count = 0;
let totalB = 0;
let totalG = 0;
let totalR = 0;
for (const [offsetX, offsetY] of offsets) {
const nx = localX + offsetX;
const ny = localY + offsetY;
if (nx < 0 || ny < 0 || nx >= box.width || ny >= box.height) continue;
if (pending[ny * box.width + nx] !== 0) continue;
const rawIndex = getRawPixelIndex(width, box.left + nx, box.top + ny);
totalR += working[rawIndex] || 0;
totalG += working[rawIndex + 1] || 0;
totalB += working[rawIndex + 2] || 0;
count += 1;
}
if (count > 0) {
nextValues.push({
b: Math.round(totalB / count),
g: Math.round(totalG / count),
index: maskIndex,
r: Math.round(totalR / count),
});
}
}
}
if (nextValues.length === 0) break;
for (const value of nextValues) {
const localX = value.index % box.width;
const localY = Math.floor(value.index / box.width);
const rawIndex = getRawPixelIndex(width, box.left + localX, box.top + localY);
working[rawIndex] = value.r;
working[rawIndex + 1] = value.g;
working[rawIndex + 2] = value.b;
pending[value.index] = 0;
remaining -= 1;
}
}
if (remaining > 0) {
for (let localY = 0; localY < box.height; localY += 1) {
for (let localX = 0; localX < box.width; localX += 1) {
const maskIndex = localY * box.width + localX;
if (pending[maskIndex] === 0) continue;
const rawIndex = getRawPixelIndex(width, box.left + localX, box.top + localY);
working[rawIndex] = Math.round(background.r);
working[rawIndex + 1] = Math.round(background.g);
working[rawIndex + 2] = Math.round(background.b);
}
}
}
return working;
}
async function buildScaleAdjustFallbackObjectPng(params: {
box: ReturnType<typeof rectToPixelBox>;
mask: Uint8Array;
sourceBuffer: Buffer;
targetBox: ReturnType<typeof rectToPixelBox>;
}) {
const { box, mask, sourceBuffer, targetBox } = params;
const cropBuffer = await sharp(sourceBuffer, { failOn: "none" })
.extract(box)
.removeAlpha()
.png({ compressionLevel: 6 })
.toBuffer();
const alphaBuffer = await sharp(mask, {
raw: { channels: 1, height: box.height, width: box.width },
})
.greyscale()
.blur(Math.max(0.7, Math.min(box.width, box.height) * 0.012))
.greyscale()
.png({ compressionLevel: 6 })
.toBuffer();
return sharp(cropBuffer, { failOn: "none" })
.joinChannel(alphaBuffer)
.resize(targetBox.width, targetBox.height, { fit: "fill" })
.png({ compressionLevel: 6 })
.toBuffer();
}
async function buildScaleAdjustDeterministicFallbackImages(params: {
debugLabel: string;
verification: CanvasScaleAdjustVerificationConfig;
}) {
const { debugLabel, verification } = params;
const sourceImage = await loadScaleAdjustSourceImage(verification);
if (!sourceImage) return [];
let baseRaw = await sharp(sourceImage.sourceBuffer, { failOn: "none" })
.resize(sourceImage.sourceWidth, sourceImage.sourceHeight, {
fit: "cover",
position: "center",
})
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer();
const overlays: Array<{ input: Buffer; left: number; top: number }> = [];
for (const [index, region] of verification.regions.entries()) {
const targetRegion =
verification.targetRegions?.[index] ||
(typeof verification.scalePercent === "number"
? scaleRegionAroundCenter(region, verification.scalePercent)
: null);
if (!targetRegion) continue;
const sourceBox = rectToPixelBox({
height: sourceImage.sourceHeight,
region,
width: sourceImage.sourceWidth,
});
const targetBox = rectToPixelBox({
height: sourceImage.sourceHeight,
region: targetRegion,
width: sourceImage.sourceWidth,
});
const prompt = verification.regionPrompts?.[index] || "";
const focusedSourceBox = getScaleAdjustFallbackFocusedSourceBox({
prompt,
sourceBox,
});
const mask = buildScaleAdjustFallbackSubjectMask({
box: focusedSourceBox,
raw: baseRaw,
width: sourceImage.sourceWidth,
});
const maskedPixels = mask.reduce((sum, value) => sum + (value ? 1 : 0), 0);
if (
maskedPixels <
Math.max(24, focusedSourceBox.width * focusedSourceBox.height * 0.015)
) {
console.warn("[Canvas Generate Image] Scale adjust deterministic fallback skipped sparse mask:", {
debugLabel,
maskedPixels,
regionIndex: index,
});
continue;
}
const background = estimateScaleAdjustFallbackBackground({
box: focusedSourceBox,
raw: baseRaw,
width: sourceImage.sourceWidth,
});
const objectBuffer = await buildScaleAdjustFallbackObjectPng({
box: focusedSourceBox,
mask,
sourceBuffer: sourceImage.sourceBuffer,
targetBox,
});
baseRaw = inpaintScaleAdjustMaskedRegion({
background,
box: focusedSourceBox,
mask,
raw: baseRaw,
width: sourceImage.sourceWidth,
});
overlays.push({
input: objectBuffer,
left: targetBox.left,
top: targetBox.top,
});
}
if (overlays.length === 0) return [];
const baseBuffer = await sharp(baseRaw, {
raw: {
channels: 3,
height: sourceImage.sourceHeight,
width: sourceImage.sourceWidth,
},
})
.png({ compressionLevel: 6 })
.toBuffer();
const mergedBuffer = await sharp(baseBuffer, { failOn: "none" })
.composite(overlays)
.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()}-${Math.random().toString(36).slice(2, 8)}`;
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(
`canvas-scale-adjust-deterministic-fallback-${suffix}.png`,
bufferToArrayBuffer(mergedBuffer)
),
uploadFile(
`canvas-scale-adjust-deterministic-fallback-thumb-${suffix}.png`,
bufferToArrayBuffer(thumbnailBuffer)
),
]);
console.warn("[Canvas Generate Image] Scale adjust deterministic fallback generated:", {
debugLabel,
originalUrl: originalUpload.url,
regionCount: verification.regions.length,
thumbnailUrl: thumbnailUpload.url,
});
return [
{
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url,
},
] satisfies ParsedImage[];
}
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 buildScaleAdjustPatchCompositeMaskSvg(params: {
width: number;
height: number;
featherPx: number;
regions?: Array<{ left: number; top: number; width: number; height: number }>;
touchesLeft: boolean;
touchesTop: boolean;
touchesRight: boolean;
touchesBottom: boolean;
}) {
const { height, width } = params;
const featherPx = Math.max(0, Math.round(params.featherPx));
const regions = params.regions?.filter(
(region) => region.width > 0 && region.height > 0
);
if (regions?.length) {
const blur = Math.max(1, featherPx);
const softPad = Math.max(2, Math.round(featherPx * 1.35));
const corePad = Math.max(0, Math.round(featherPx * 0.22));
const softRects = regions
.map((region) => {
const left = Math.max(0, region.left - softPad);
const top = Math.max(0, region.top - softPad);
const right = Math.min(width, region.left + region.width + softPad);
const bottom = Math.min(height, region.top + region.height + softPad);
return `<rect x="${left}" y="${top}" width="${Math.max(1, right - left)}" height="${Math.max(1, bottom - top)}" fill="#ffffff" filter="url(#scale-adjust-patch-soft-mask)" />`;
})
.join("");
const coreRects = regions
.map((region) => {
const left = Math.max(0, region.left - corePad);
const top = Math.max(0, region.top - corePad);
const right = Math.min(width, region.left + region.width + corePad);
const bottom = Math.min(height, region.top + region.height + corePad);
return `<rect x="${left}" y="${top}" width="${Math.max(1, right - left)}" height="${Math.max(1, bottom - top)}" 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="scale-adjust-patch-soft-mask" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="${blur}" />
</filter>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" fill="rgba(0,0,0,0)" />
${softRects}
${coreRects}
</svg>
`);
}
if (featherPx <= 0) {
return Buffer.from(`
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="${width}" height="${height}" fill="#ffffff" />
</svg>
`);
}
const spill = Math.max(2, Math.round(featherPx * 2.5));
const softLeft = params.touchesLeft ? -spill : 0;
const softTop = params.touchesTop ? -spill : 0;
const softRight = params.touchesRight ? width + spill : width;
const softBottom = params.touchesBottom ? height + spill : height;
const coreInset = Math.max(0, Math.round(featherPx * 1.25));
const coreLeft = params.touchesLeft ? 0 : Math.min(coreInset, Math.floor(width / 3));
const coreTop = params.touchesTop ? 0 : Math.min(coreInset, Math.floor(height / 3));
const coreRight = params.touchesRight
? width
: Math.max(coreLeft + 1, width - Math.min(coreInset, Math.floor(width / 3)));
const coreBottom = params.touchesBottom
? height
: Math.max(coreTop + 1, height - Math.min(coreInset, Math.floor(height / 3)));
return Buffer.from(`
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="scale-adjust-patch-soft-mask" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="${featherPx}" />
</filter>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" fill="rgba(0,0,0,0)" />
<rect x="${softLeft}" y="${softTop}" width="${Math.max(1, softRight - softLeft)}" height="${Math.max(1, softBottom - softTop)}" fill="#ffffff" filter="url(#scale-adjust-patch-soft-mask)" />
<rect x="${coreLeft}" y="${coreTop}" width="${Math.max(1, coreRight - coreLeft)}" height="${Math.max(1, coreBottom - coreTop)}" fill="#ffffff" />
</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 normalizeAlphaMaskToRaw(params: {
maskBuffer: Buffer;
width: number;
height: number;
}) {
const { height, maskBuffer, width } = params;
return sharp(maskBuffer, { failOn: "none" })
.resize(width, height, { fit: "fill" })
.removeAlpha()
.extractChannel(0)
.raw()
.toBuffer();
}
async function normalizeOpacityMaskToRaw(params: {
maskBuffer: Buffer;
width: number;
height: number;
}) {
const { height, maskBuffer, width } = params;
return sharp(maskBuffer, { failOn: "none" })
.resize(width, height, { fit: "fill" })
.ensureAlpha()
.extractChannel(3)
.raw()
.toBuffer();
}
function multiplyAlphaMasks(primaryMask: Buffer, clipMask: Buffer) {
if (primaryMask.length !== clipMask.length) return primaryMask;
const multiplied = Buffer.allocUnsafe(primaryMask.length);
for (let index = 0; index < primaryMask.length; index += 1) {
multiplied[index] = Math.round((primaryMask[index] * clipMask[index]) / 255);
}
return multiplied;
}
function boostAlphaMask(mask: Buffer, cutoff: number, multiplier: number) {
const boosted = Buffer.allocUnsafe(mask.length);
for (let index = 0; index < mask.length; index += 1) {
const value = mask[index];
boosted[index] =
value < cutoff ? 0 : Math.min(255, Math.round(value * multiplier));
}
return boosted;
}
async function applyRawAlphaToImage(params: {
imageBuffer: Buffer;
alphaMaskRaw: Buffer;
width: number;
height: number;
}) {
const { alphaMaskRaw, height, imageBuffer, width } = params;
const rgbRaw = await sharp(imageBuffer, { failOn: "none" })
.resize(width, height, { fit: "fill" })
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer();
return sharp(rgbRaw, { raw: { channels: 3, height, width } })
.joinChannel(alphaMaskRaw, {
raw: { channels: 1, height, width },
})
.png({ compressionLevel: 6 })
.toBuffer();
}
async function matchImageColorToSourceNearAlphaMask(params: {
alphaMaskRaw: Buffer;
height: number;
imageBuffer: Buffer;
sourceBuffer: Buffer;
width: number;
}) {
const { alphaMaskRaw, height, imageBuffer, sourceBuffer, width } = params;
if (alphaMaskRaw.length !== width * height) return imageBuffer;
const [imageRaw, sourceRaw] = await Promise.all([
sharp(imageBuffer, { failOn: "none" })
.resize(width, height, { fit: "fill" })
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer(),
sharp(sourceBuffer, { failOn: "none" })
.resize(width, height, { fit: "fill" })
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer(),
]);
const sourceSum = [0, 0, 0];
const imageSum = [0, 0, 0];
let count = 0;
for (let index = 0; index < alphaMaskRaw.length; index += 1) {
const alpha = alphaMaskRaw[index] || 0;
if (alpha < 4 || alpha > 110) continue;
const offset = index * 3;
const sourceLuma =
(sourceRaw[offset] || 0) * 0.299 +
(sourceRaw[offset + 1] || 0) * 0.587 +
(sourceRaw[offset + 2] || 0) * 0.114;
const imageLuma =
(imageRaw[offset] || 0) * 0.299 +
(imageRaw[offset + 1] || 0) * 0.587 +
(imageRaw[offset + 2] || 0) * 0.114;
if (sourceLuma < 24 || sourceLuma > 248 || imageLuma < 24 || imageLuma > 248) {
continue;
}
for (let channel = 0; channel < 3; channel += 1) {
sourceSum[channel] += sourceRaw[offset + channel] || 0;
imageSum[channel] += imageRaw[offset + channel] || 0;
}
count += 1;
}
if (count < 500) return imageBuffer;
const offsets = sourceSum.map((sum, channel) =>
Math.max(-32, Math.min(32, sum / count - imageSum[channel] / count))
);
const correctedRaw = Buffer.from(imageRaw);
for (let index = 0; index < correctedRaw.length; index += 3) {
correctedRaw[index] = Math.max(
0,
Math.min(255, Math.round((correctedRaw[index] || 0) + offsets[0]))
);
correctedRaw[index + 1] = Math.max(
0,
Math.min(255, Math.round((correctedRaw[index + 1] || 0) + offsets[1]))
);
correctedRaw[index + 2] = Math.max(
0,
Math.min(255, Math.round((correctedRaw[index + 2] || 0) + offsets[2]))
);
}
return sharp(correctedRaw, { raw: { channels: 3, height, width } })
.png({ compressionLevel: 6 })
.toBuffer();
}
async function applyScaleAdjustProductProtectMaskToOverlay(params: {
height: number;
overlayBuffer: Buffer;
protectMaskBuffer: Buffer;
width: number;
}) {
const { height, overlayBuffer, protectMaskBuffer, width } = params;
const overlayRaw = await sharp(overlayBuffer, { failOn: "none" })
.resize(width, height, { fit: "fill" })
.ensureAlpha()
.raw()
.toBuffer();
const protectRaw = await sharp(protectMaskBuffer, { failOn: "none" })
.rotate()
.resize(width, height, { fit: "fill" })
.ensureAlpha()
.extractChannel(3)
.raw()
.toBuffer();
const pixelCount = Math.min(
protectRaw.length,
Math.floor(overlayRaw.length / 4)
);
for (let index = 0; index < pixelCount; index += 1) {
const protect = protectRaw[index] || 0;
if (protect <= 0) continue;
const alphaIndex = index * 4 + 3;
const alpha = overlayRaw[alphaIndex] || 0;
overlayRaw[alphaIndex] = Math.round((alpha * Math.max(0, 255 - protect)) / 255);
}
return sharp(overlayRaw, {
raw: { channels: 4, height, width },
})
.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 applyRawAlphaToImage({
alphaMaskRaw: await normalizeAlphaMaskToRaw({
height: sourceHeight,
maskBuffer: await buildLocalEditDiffCompositeMask({
composite,
generatedBuffer: resizedGeneratedBuffer,
height: sourceHeight,
sourceBuffer,
width: sourceWidth,
}),
width: sourceWidth,
}),
height: sourceHeight,
imageBuffer: resizedGeneratedBuffer,
width: sourceWidth,
})
: 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 resolveScaleAdjustPatchCompositeFeatherPx(params: {
composite: CanvasScaleAdjustPatchCompositeConfig;
width: number;
height: number;
}) {
const { composite, height, width } = params;
const minPx =
typeof composite.featherMinPx === "number" ? composite.featherMinPx : 10;
const maxPx =
typeof composite.featherMaxPx === "number"
? Math.max(minPx, composite.featherMaxPx)
: 42;
const ratio =
typeof composite.featherRatio === "number" ? composite.featherRatio : 0.018;
return Math.max(
minPx,
Math.min(maxPx, Math.round(Math.min(width, height) * ratio))
);
}
async function applyScaleAdjustPatchCompositeToImages(params: {
images: ParsedImage[];
composite: CanvasScaleAdjustPatchCompositeConfig | 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 workspaceBox = rectToPixelBox({
height: sourceHeight,
region: composite.workspaceRegion,
width: sourceWidth,
});
const featherPx = resolveScaleAdjustPatchCompositeFeatherPx({
composite,
height: workspaceBox.height,
width: workspaceBox.width,
});
const touchesLeft = workspaceBox.left <= 0;
const touchesTop = workspaceBox.top <= 0;
const touchesRight = workspaceBox.left + workspaceBox.width >= sourceWidth;
const touchesBottom = workspaceBox.top + workspaceBox.height >= sourceHeight;
const maskRegionBoxes = composite.regions
?.map((region) =>
rectToPixelBox({
height: sourceHeight,
region,
width: sourceWidth,
})
)
.map((box) => {
const left = Math.max(0, box.left - workspaceBox.left);
const top = Math.max(0, box.top - workspaceBox.top);
const right = Math.min(
workspaceBox.width,
box.left + box.width - workspaceBox.left
);
const bottom = Math.min(
workspaceBox.height,
box.top + box.height - workspaceBox.top
);
return {
left,
top,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
};
})
.filter((box) => box.width > 0 && box.height > 0);
const staticPatchMask =
composite.maskMode === "diff"
? null
: buildScaleAdjustPatchCompositeMaskSvg({
featherPx,
height: workspaceBox.height,
regions: maskRegionBoxes,
touchesBottom,
touchesLeft,
touchesRight,
touchesTop,
width: workspaceBox.width,
});
const staticPatchMaskRaw = staticPatchMask
? await normalizeOpacityMaskToRaw({
height: workspaceBox.height,
maskBuffer: staticPatchMask,
width: workspaceBox.width,
})
: null;
const regionClipPatchMask = maskRegionBoxes?.length
? buildScaleAdjustPatchCompositeMaskSvg({
featherPx,
height: workspaceBox.height,
regions: maskRegionBoxes,
touchesBottom,
touchesLeft,
touchesRight,
touchesTop,
width: workspaceBox.width,
})
: null;
const regionClipPatchMaskRaw = regionClipPatchMask
? await normalizeOpacityMaskToRaw({
height: workspaceBox.height,
maskBuffer: regionClipPatchMask,
width: workspaceBox.width,
})
: null;
const sourcePatchBuffer = await sharp(sourceBuffer, { failOn: "none" })
.extract(workspaceBox)
.png({ compressionLevel: 6 })
.toBuffer();
const customClipPatchMaskRaw = composite.clipMaskImageUrl
? await normalizeOpacityMaskToRaw({
height: workspaceBox.height,
maskBuffer: await sharp(await fetchImageBufferFromUrl(composite.clipMaskImageUrl), {
failOn: "none",
})
.rotate()
.resize(sourceWidth, sourceHeight, { fit: "fill" })
.extract(workspaceBox)
.png({ compressionLevel: 6 })
.toBuffer(),
width: workspaceBox.width,
})
: null;
const activeClipPatchMaskRaw = customClipPatchMaskRaw || regionClipPatchMaskRaw;
const diffRegions = (maskRegionBoxes?.length
? maskRegionBoxes
: [{ left: 0, top: 0, width: workspaceBox.width, height: workspaceBox.height }]
).map((box) => ({
x: box.left / Math.max(1, workspaceBox.width),
y: box.top / Math.max(1, workspaceBox.height),
width: box.width / Math.max(1, workspaceBox.width),
height: box.height / Math.max(1, workspaceBox.height),
}));
const mergedImages: ParsedImage[] = [];
for (const [index, image] of images.entries()) {
const generatedUrl = image.originalUrl || image.thumbnailUrl;
const generatedBuffer = await fetchImageBufferFromUrl(generatedUrl);
const resizedGeneratedBuffer =
composite.generatedFrame === "full"
? await sharp(generatedBuffer, { failOn: "none" })
.rotate()
.resize(sourceWidth, sourceHeight, {
fit: "cover",
position: "center",
})
.extract(workspaceBox)
.png({ compressionLevel: 6 })
.toBuffer()
: await sharp(generatedBuffer, { failOn: "none" })
.rotate()
.resize(workspaceBox.width, workspaceBox.height, {
fit: "cover",
position: "center",
})
.png({ compressionLevel: 6 })
.toBuffer();
const diffPatchMask =
composite.maskMode === "diff"
? await buildLocalEditDiffCompositeMask({
composite: {
diffCompareBlurPx: composite.diffCompareBlurPx,
diffHighThreshold: composite.diffHighThreshold,
diffLowThreshold: composite.diffLowThreshold,
featherMaxPx: composite.featherMaxPx,
featherMinPx: composite.featherMinPx,
featherRatio: composite.featherRatio,
maskMode: "diff",
regions: diffRegions,
sourceImageUrl: composite.sourceImageUrl,
},
generatedBuffer: resizedGeneratedBuffer,
height: workspaceBox.height,
sourceBuffer: sourcePatchBuffer,
width: workspaceBox.width,
})
: null;
const patchMask = diffPatchMask || staticPatchMask;
if (!patchMask) {
throw new Error("比例调整局部结果合成失败:无法生成合成蒙版");
}
const patchOverlayBuffer = diffPatchMask
? await applyRawAlphaToImage({
alphaMaskRaw: boostAlphaMask(
multiplyAlphaMasks(
await normalizeAlphaMaskToRaw({
height: workspaceBox.height,
maskBuffer: diffPatchMask,
width: workspaceBox.width,
}),
activeClipPatchMaskRaw ||
Buffer.alloc(workspaceBox.width * workspaceBox.height, 255)
),
typeof composite.diffAlphaCutoff === "number"
? composite.diffAlphaCutoff
: 8,
typeof composite.diffAlphaMultiplier === "number"
? composite.diffAlphaMultiplier
: 3
),
height: workspaceBox.height,
imageBuffer: resizedGeneratedBuffer,
width: workspaceBox.width,
})
: activeClipPatchMaskRaw || staticPatchMaskRaw
? await applyRawAlphaToImage({
alphaMaskRaw:
activeClipPatchMaskRaw && staticPatchMaskRaw
? multiplyAlphaMasks(staticPatchMaskRaw, activeClipPatchMaskRaw)
: activeClipPatchMaskRaw || staticPatchMaskRaw!,
height: workspaceBox.height,
imageBuffer: resizedGeneratedBuffer,
width: workspaceBox.width,
})
: await sharp(resizedGeneratedBuffer, { failOn: "none" })
.ensureAlpha()
.composite([{ input: patchMask, blend: "dest-in" }])
.png({ compressionLevel: 6 })
.toBuffer();
const mergedPngBuffer = await sharp(sourceBuffer, { failOn: "none" })
.ensureAlpha()
.composite([
{
input: patchOverlayBuffer,
left: workspaceBox.left,
top: workspaceBox.top,
},
])
.png({ compressionLevel: 6 })
.toBuffer();
const mergedBuffer = await sharp(mergedPngBuffer, { failOn: "none" })
.flatten({ background: "#ffffff" })
.jpeg({ quality: 94, mozjpeg: true })
.toBuffer();
const thumbnailBuffer = await sharp(mergedPngBuffer, { failOn: "none" })
.flatten({ background: "#ffffff" })
.resize(512, 512, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 88, mozjpeg: true })
.toBuffer();
const suffix = `${Date.now()}-${index + 1}-${Math.random()
.toString(36)
.slice(2, 8)}`;
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(
`canvas-scale-adjust-ai-patch-composited-${suffix}.jpg`,
bufferToArrayBuffer(mergedBuffer)
),
uploadFile(
`canvas-scale-adjust-ai-patch-composited-thumb-${suffix}.jpg`,
bufferToArrayBuffer(thumbnailBuffer)
),
]);
mergedImages.push({
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url,
});
}
console.info("[Canvas Generate Image] Scale adjust AI patch composite applied:", {
debugLabel,
featherPx,
imageCount: mergedImages.length,
customClipMaskApplied: Boolean(customClipPatchMaskRaw),
diffRegionClipApplied: Boolean(regionClipPatchMask),
generatedFrame: composite.generatedFrame || "workspace",
maskMode: composite.maskMode || "workspace",
maskRegionCount: maskRegionBoxes?.length || 0,
sourceHeight,
sourceWidth,
workspaceBox,
});
return mergedImages;
}
async function buildScaleAdjustTransferredShadow(params: {
cleanedBackgroundBuffer: Buffer;
height: number;
regions: NonNullable<CanvasScaleAdjustProductLockCompositeConfig["regions"]>;
sourceBuffer: Buffer;
sourceSubjectMaskBuffer: Buffer;
width: number;
}) {
const {
cleanedBackgroundBuffer,
height,
regions,
sourceBuffer,
sourceSubjectMaskBuffer,
width,
} = params;
if (regions.length === 0) return null;
const [sourceRaw, cleanedRaw, subjectAlpha] = await Promise.all([
sharp(sourceBuffer, { failOn: "none" })
.resize(width, height, { fit: "fill" })
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer(),
sharp(cleanedBackgroundBuffer, { failOn: "none" })
.resize(width, height, { fit: "fill" })
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer(),
normalizeOpacityMaskToRaw({
height,
maskBuffer: sourceSubjectMaskBuffer,
width,
}),
]);
const nearSubjectAlpha = await sharp(subjectAlpha, {
raw: { channels: 1, height, width },
})
.blur(Math.max(3, Math.min(width, height) * 0.012))
.extractChannel(0)
.raw()
.toBuffer();
const fullShadowAlpha = Buffer.alloc(width * height);
let shadowStrength = 0;
for (const region of regions) {
const sourceBox = rectToPixelBox({ height, region: region.source, width });
const targetBox = rectToPixelBox({ height, region: region.target, width });
const shadowAlpha = Buffer.alloc(sourceBox.width * sourceBox.height);
const floorStart = Math.floor(sourceBox.height * 0.72);
for (let localY = floorStart; localY < sourceBox.height; localY += 1) {
const sourceY = sourceBox.top + localY;
for (let localX = 0; localX < sourceBox.width; localX += 1) {
const sourceX = sourceBox.left + localX;
const pixelIndex = sourceY * width + sourceX;
if (subjectAlpha[pixelIndex] >= 12) continue;
const proximity = nearSubjectAlpha[pixelIndex] / 255;
if (proximity < 0.04) continue;
const rgbIndex = pixelIndex * 3;
const sourceLuma =
sourceRaw[rgbIndex] * 0.299 +
sourceRaw[rgbIndex + 1] * 0.587 +
sourceRaw[rgbIndex + 2] * 0.114;
const cleanedLuma =
cleanedRaw[rgbIndex] * 0.299 +
cleanedRaw[rgbIndex + 1] * 0.587 +
cleanedRaw[rgbIndex + 2] * 0.114;
const darkness = Math.max(0, cleanedLuma - sourceLuma - 4);
const alpha = Math.min(56, Math.round(darkness * 1.25 * proximity));
shadowAlpha[localY * sourceBox.width + localX] = alpha;
}
}
const transferredAlpha = await sharp(shadowAlpha, {
raw: {
channels: 1,
height: sourceBox.height,
width: sourceBox.width,
},
})
.resize(targetBox.width, targetBox.height, {
fit: "fill",
kernel: sharp.kernel.lanczos3,
})
.extractChannel(0)
.raw()
.toBuffer();
const targetSubjectAlpha = await sharp(subjectAlpha, {
raw: {
channels: 1,
height,
width,
},
})
.extract(sourceBox)
.resize(targetBox.width, targetBox.height, {
fit: "fill",
kernel: sharp.kernel.lanczos3,
})
.extractChannel(0)
.raw()
.toBuffer();
const bottomByColumn = new Int32Array(targetBox.width).fill(-1);
let globalBottom = -1;
for (let localY = 0; localY < targetBox.height; localY += 1) {
for (let localX = 0; localX < targetBox.width; localX += 1) {
if (targetSubjectAlpha[localY * targetBox.width + localX] < 24) continue;
bottomByColumn[localX] = localY;
globalBottom = Math.max(globalBottom, localY);
}
}
const contactTolerance = Math.max(2, Math.round(targetBox.height * 0.055));
const contactDepth = Math.max(2, Math.round(targetBox.height * 0.022));
for (let localY = 0; localY < targetBox.height; localY += 1) {
const targetY = targetBox.top + localY;
if (targetY < 0 || targetY >= height) continue;
for (let localX = 0; localX < targetBox.width; localX += 1) {
const targetX = targetBox.left + localX;
if (targetX < 0 || targetX >= width) continue;
const transferred = transferredAlpha[localY * targetBox.width + localX];
if (transferred > 0) {
const fullIndex = targetY * width + targetX;
fullShadowAlpha[fullIndex] = Math.max(
fullShadowAlpha[fullIndex],
transferred
);
shadowStrength += transferred;
}
}
}
if (globalBottom < 0) continue;
for (let localX = 0; localX < targetBox.width; localX += 1) {
const contactY = bottomByColumn[localX];
if (contactY < globalBottom - contactTolerance) continue;
for (let depth = -1; depth <= contactDepth; depth += 1) {
const targetX = targetBox.left + localX;
const targetY = targetBox.top + contactY + depth;
if (targetX < 0 || targetX >= width || targetY < 0 || targetY >= height) {
continue;
}
const contactAlpha = Math.max(
12,
Math.round(48 * (1 - Math.max(0, depth) / (contactDepth + 1)))
);
const fullIndex = targetY * width + targetX;
fullShadowAlpha[fullIndex] = Math.max(
fullShadowAlpha[fullIndex],
contactAlpha
);
shadowStrength += contactAlpha;
}
}
}
if (shadowStrength === 0) return null;
const softenedAlpha = await sharp(fullShadowAlpha, {
raw: { channels: 1, height, width },
})
.blur(Math.max(1, Math.min(width, height) * 0.0045))
.extractChannel(0)
.raw()
.toBuffer();
const blackRgb = Buffer.alloc(width * height * 3);
return sharp(blackRgb, { raw: { channels: 3, height, width } })
.joinChannel(softenedAlpha, {
raw: { channels: 1, height, width },
})
.png({ compressionLevel: 6 })
.toBuffer();
}
async function applyScaleAdjustProductLockCompositeToImages(params: {
images: ParsedImage[];
composite: CanvasScaleAdjustProductLockCompositeConfig | null;
debugLabel: string;
}) {
const { images, composite, debugLabel } = params;
if (!composite || images.length === 0) return images;
const overlayBuffer = await sharp(
await fetchImageBufferFromUrl(composite.overlayImageUrl),
{ failOn: "none" }
)
.rotate()
.ensureAlpha()
.png({ compressionLevel: 6 })
.toBuffer();
const protectMaskBuffer = composite.protectMaskImageUrl
? await fetchImageBufferFromUrl(composite.protectMaskImageUrl)
: null;
const repairMaskBuffer = composite.repairMaskImageUrl
? await fetchImageBufferFromUrl(composite.repairMaskImageUrl)
: null;
const backgroundPlateBaseBuffer = composite.backgroundPlateImageUrl
? await sharp(await fetchImageBufferFromUrl(composite.backgroundPlateImageUrl), {
failOn: "none",
})
.rotate()
.ensureAlpha()
.png({ compressionLevel: 6 })
.toBuffer()
: null;
const sourceBaseBuffer = composite.sourceImageUrl
? await sharp(await fetchImageBufferFromUrl(composite.sourceImageUrl), {
failOn: "none",
})
.rotate()
.ensureAlpha()
.png({ compressionLevel: 6 })
.toBuffer()
: null;
const shadowSourceBuffer = composite.shadowSourceImageUrl
? await fetchImageBufferFromUrl(composite.shadowSourceImageUrl)
: null;
const sourceSubjectMaskBuffer = composite.sourceSubjectMaskImageUrl
? await fetchImageBufferFromUrl(composite.sourceSubjectMaskImageUrl)
: null;
const compositeBaseBuffer = backgroundPlateBaseBuffer || sourceBaseBuffer;
const overlayMetadata = await sharp(overlayBuffer, { failOn: "none" }).metadata();
const overlayWidth = Math.round(Number(overlayMetadata.width || 0));
const overlayHeight = Math.round(Number(overlayMetadata.height || 0));
if (overlayWidth <= 0 || overlayHeight <= 0) return images;
const mergedImages: ParsedImage[] = [];
let transferredShadowCount = 0;
for (const [index, image] of images.entries()) {
const generatedUrl = image.originalUrl || image.thumbnailUrl;
const generatedBuffer = await fetchImageBufferFromUrl(generatedUrl);
const normalizedModelBuffer = await sharp(generatedBuffer, {
failOn: "none",
})
.rotate()
.ensureAlpha()
.png({ compressionLevel: 6 })
.toBuffer();
const generatedMetadata = await sharp(normalizedModelBuffer, {
failOn: "none",
}).metadata();
const modelWidth = Math.round(Number(generatedMetadata.width || 0));
const modelHeight = Math.round(Number(generatedMetadata.height || 0));
if (modelWidth <= 0 || modelHeight <= 0) {
mergedImages.push(image);
continue;
}
const generatedWidth = compositeBaseBuffer ? overlayWidth : modelWidth;
const generatedHeight = compositeBaseBuffer ? overlayHeight : modelHeight;
const normalizedGeneratedBuffer =
generatedWidth === modelWidth && generatedHeight === modelHeight
? normalizedModelBuffer
: await sharp(normalizedModelBuffer, { failOn: "none" })
.resize(generatedWidth, generatedHeight, {
fit: "fill",
kernel: sharp.kernel.lanczos3,
})
.ensureAlpha()
.png({ compressionLevel: 6 })
.toBuffer();
const resizedOverlayBaseBuffer = await sharp(overlayBuffer, { failOn: "none" })
.resize(generatedWidth, generatedHeight, {
fit: "fill",
kernel: sharp.kernel.lanczos3,
})
.png({ compressionLevel: 6 })
.toBuffer();
const resizedOverlayBuffer = protectMaskBuffer
? await applyScaleAdjustProductProtectMaskToOverlay({
height: generatedHeight,
overlayBuffer: resizedOverlayBaseBuffer,
protectMaskBuffer,
width: generatedWidth,
})
: resizedOverlayBaseBuffer;
const baseForProductLock = compositeBaseBuffer
? await sharp(compositeBaseBuffer, { failOn: "none" })
.resize(generatedWidth, generatedHeight, {
fit: "fill",
kernel: sharp.kernel.lanczos3,
})
.ensureAlpha()
.png({ compressionLevel: 6 })
.toBuffer()
: normalizedGeneratedBuffer;
const repairAlphaMaskRaw =
compositeBaseBuffer && repairMaskBuffer && !backgroundPlateBaseBuffer
? await sharp(repairMaskBuffer, { failOn: "none" })
.resize(generatedWidth, generatedHeight, { fit: "fill" })
.ensureAlpha()
.extractChannel(3)
.blur(Math.max(4, Math.round(Math.min(generatedWidth, generatedHeight) * 0.006)))
.extractChannel(0)
.raw()
.toBuffer()
: null;
const colorMatchedGeneratedBuffer =
compositeBaseBuffer && repairAlphaMaskRaw
? await matchImageColorToSourceNearAlphaMask({
alphaMaskRaw: repairAlphaMaskRaw,
height: generatedHeight,
imageBuffer: normalizedGeneratedBuffer,
sourceBuffer: baseForProductLock,
width: generatedWidth,
})
: normalizedGeneratedBuffer;
const generatedRepairOverlay =
repairAlphaMaskRaw
? await applyRawAlphaToImage({
alphaMaskRaw: repairAlphaMaskRaw,
height: generatedHeight,
imageBuffer: colorMatchedGeneratedBuffer,
width: generatedWidth,
})
: null;
const transferredShadowBuffer =
!backgroundPlateBaseBuffer &&
shadowSourceBuffer &&
sourceSubjectMaskBuffer &&
composite.regions?.length
? await buildScaleAdjustTransferredShadow({
cleanedBackgroundBuffer: normalizedGeneratedBuffer,
height: generatedHeight,
regions: composite.regions,
sourceBuffer: shadowSourceBuffer,
sourceSubjectMaskBuffer,
width: generatedWidth,
})
: null;
if (transferredShadowBuffer) transferredShadowCount += 1;
const mergedPngBuffer = await sharp(baseForProductLock, {
failOn: "none",
})
.composite([
...(generatedRepairOverlay
? [{ input: generatedRepairOverlay, left: 0, top: 0 }]
: []),
...(transferredShadowBuffer
? [{ input: transferredShadowBuffer, left: 0, top: 0 }]
: []),
{ input: resizedOverlayBuffer, left: 0, top: 0 },
])
.png({ compressionLevel: 6 })
.toBuffer();
const mergedBuffer = await sharp(mergedPngBuffer, { failOn: "none" })
.flatten({ background: "#ffffff" })
.jpeg({ quality: 94, mozjpeg: true })
.toBuffer();
const thumbnailBuffer = await sharp(mergedPngBuffer, { failOn: "none" })
.flatten({ background: "#ffffff" })
.resize(512, 512, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 88, mozjpeg: true })
.toBuffer();
const suffix = `${Date.now()}-${index + 1}-${Math.random()
.toString(36)
.slice(2, 8)}`;
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(
`canvas-scale-adjust-product-lock-${suffix}.jpg`,
bufferToArrayBuffer(mergedBuffer)
),
uploadFile(
`canvas-scale-adjust-product-lock-thumb-${suffix}.jpg`,
bufferToArrayBuffer(thumbnailBuffer)
),
]);
mergedImages.push({
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url,
});
}
console.info("[Canvas Generate Image] Scale adjust product lock composite applied:", {
debugLabel,
imageCount: mergedImages.length,
overlayHeight,
overlayWidth,
protectMaskApplied: Boolean(protectMaskBuffer),
backgroundPlateBaseApplied: Boolean(backgroundPlateBaseBuffer),
repairMaskApplied: Boolean(
repairMaskBuffer && compositeBaseBuffer && !backgroundPlateBaseBuffer
),
sourceBaseApplied: Boolean(sourceBaseBuffer),
transferredShadowCount,
sourceHeight: composite.sourceHeight,
sourceWidth: composite.sourceWidth,
});
return mergedImages;
}
function getProviderBatchLimit(providerType: string) {
switch (providerType) {
case "ruxa-image":
case "bfl-image":
case "openai-image":
case "bananaimage":
case "visionary":
case "atlascloud":
case "vip98k":
case "stepfun-image":
case "midjourney":
case "runway":
return 1;
case "snapgen-image":
return 8;
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 = string;
const CANVAS_FAILOVER_SAME_NAME_THRESHOLD = 0.5;
function getCanvasScaleAdjustProviderFailoverPriority(providerType: string) {
switch (providerType) {
case "ruxa-image":
return 10;
case "snapgen-image":
return 20;
case "bananaimage":
case "visionary":
return 25;
case "krea":
return 30;
case "gemini":
case "grsai-gpt-image":
return 40;
case "alibaba-image":
case "bfl-image":
case "openai-image":
case "stepfun-image":
case "volcano-vision":
return 60;
case "fal-image":
return 1000;
default:
return 80;
}
}
function getCanvasScaleAdjustHealthPenalty(
healthStatus: string | null | undefined
) {
if (healthStatus === "degraded") return 25;
if (healthStatus === "auto_disabled") return 200;
return 0;
}
function getCanvasScaleAdjustFailoverPriority(item: CanvasModelWithProvider) {
return (
getCanvasScaleAdjustProviderFailoverPriority(item.provider.type) +
getCanvasScaleAdjustHealthPenalty(item.model.healthStatus)
);
}
function isCanvasScaleAdjustEmergencyFalEditModel(item: CanvasModelWithProvider) {
if (item.provider.type !== "fal-image") return false;
const descriptor = JSON.stringify([
item.model.modelId,
item.model.displayName,
item.model.capabilities,
item.model.defaultParams,
]).toLowerCase();
return (
descriptor.includes("image-edit") ||
descriptor.includes("image_urls") ||
descriptor.includes("/edit") ||
descriptor.includes(" edit")
);
}
function isCanvasScaleAdjustProviderMaskEditModel(item: CanvasModelWithProvider) {
if (getCanvasScaleAdjustModelFamily(item) !== "gpt-image-2") return false;
if (item.provider.type === "openai-image") return true;
if (item.provider.type !== "fal-image") return false;
const modelId = String(item.model.modelId || "").trim().toLowerCase();
return (
modelId === "openai/gpt-image-2/edit" ||
modelId === "fal-ai/gpt-image-2/edit" ||
modelId.endsWith("/gpt-image-2/edit") ||
modelId.includes("gpt-image-2/edit")
);
}
interface CanvasImageGenConfig {
maxImages?: number;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: boolean;
supportedImageSizes?: string[];
}
function normalizeCanvasImageSizeValue(
value?: string,
fallback: CanvasImageSizeValue = "2K"
): CanvasImageSizeValue {
const trimmed = String(value || "").trim();
if (!trimmed) return fallback;
const kMatch = trimmed.match(/^(\d+(?:\.\d+)?)\s*k$/i);
if (kMatch?.[1]) {
return `${Number(kMatch[1]).toString()}K`;
}
return trimmed;
}
function isCanvasImageSizeSupported(
config: CanvasImageGenConfig | null | undefined,
size: CanvasImageSizeValue
) {
if (!config) return true;
const configuredSizes = Array.isArray(config.supportedImageSizes)
? config.supportedImageSizes
.map((item) => normalizeCanvasImageSizeValue(item, ""))
.filter(Boolean)
: [];
if (configuredSizes.length > 0) {
return configuredSizes.includes(normalizeCanvasImageSizeValue(size, ""));
}
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"],
};
const configuredSizes = Array.isArray(config?.supportedImageSizes)
? config.supportedImageSizes
.map((item) => normalizeCanvasImageSizeValue(item, ""))
.filter(Boolean)
: [];
return (
(preferredFallbacks[requestedSize] || ["2K", "1K", "4K"]).find((candidate) =>
isCanvasImageSizeSupported(config, candidate)
) ||
configuredSizes.find((candidate) =>
isCanvasImageSizeSupported(config, candidate)
) ||
["1K", "2K", "4K"].find((candidate) =>
isCanvasImageSizeSupported(config, candidate)
) ||
"2K"
);
}
function normalizeCanvasImageParamsByModel(params: {
imageCount: number;
imageSize: string;
imageGenConfig: CanvasImageGenConfig | null | undefined;
ignoreModelMaxImages?: boolean;
}) {
const configuredMaxImages =
typeof params.imageGenConfig?.maxImages === "number" &&
Number.isFinite(params.imageGenConfig.maxImages)
? Math.max(1, Math.floor(params.imageGenConfig.maxImages))
: 12;
const maxImages = params.ignoreModelMaxImages ? 12 : configuredMaxImages;
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 resolveCanvasScaleAdjustModelFamilyFromText(value: unknown) {
const normalized = normalizeModelNameForSimilarity(value);
const compact = normalized.replace(/[^a-z0-9]+/g, "");
if (!normalized && !compact) return "";
if (
normalized.includes("gemini-3-1-flash-image-preview") ||
normalized.includes("gemini-3.1-flash-image-preview") ||
normalized.includes("nano-banana-2") ||
compact.includes("nanobanana2")
) {
return "nano-banana-2";
}
if (
normalized.includes("gemini-3-pro-image-preview") ||
normalized.includes("nano-banana-pro") ||
compact.includes("nanobananapro")
) {
return "nano-banana-pro";
}
if (normalized.includes("gpt-image-2") || compact.includes("gptimage2")) {
return "gpt-image-2";
}
return "";
}
function getCanvasScaleAdjustModelFamily(item: CanvasModelWithProvider) {
return (
resolveCanvasScaleAdjustModelFamilyFromText(item.model.modelId) ||
resolveCanvasScaleAdjustModelFamilyFromText(item.model.displayName) ||
resolveCanvasScaleAdjustModelFamilyFromText(item.model.id)
);
}
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[];
failedProviderTypes?: string[];
preferLowCostSameNameProviders?: boolean;
requiresProviderMaskInput?: boolean;
requiresImageInput?: boolean;
requiresTextToImage?: boolean;
scaleAdjustModelFamily?: string | null;
}) {
const failedRefs = new Set(
params.failedModelRefs.map((item) => String(item || "").trim()).filter(Boolean)
);
const failedProviderTypes = new Set(
(params.failedProviderTypes || [])
.map((item) => String(item || "").trim())
.filter(Boolean)
);
const strictScaleAdjustModelFamily =
params.preferLowCostSameNameProviders &&
typeof params.scaleAdjustModelFamily === "string" &&
params.scaleAdjustModelFamily.trim()
? params.scaleAdjustModelFamily.trim()
: "";
const selectedFallbackRefs = [
params.selectedModel.model.fallbackModelId1,
params.selectedModel.model.fallbackModelId2,
]
.map((item) => String(item || "").trim())
.filter(Boolean);
const isUsableFallback = (
item: CanvasModelWithProvider,
options: { allowEmergencyFalEdit?: boolean } = {}
) => {
const isSameFamilyScaleAdjustFallback = strictScaleAdjustModelFamily
? getCanvasScaleAdjustModelFamily(item) === strictScaleAdjustModelFamily
: false;
const isEmergencyFalEditFallback =
options.allowEmergencyFalEdit === true &&
params.preferLowCostSameNameProviders &&
isCanvasScaleAdjustEmergencyFalEditModel(item);
const isSameNameScaleAdjustFallback =
params.preferLowCostSameNameProviders &&
(isSameFamilyScaleAdjustFallback ||
isEmergencyFalEditFallback ||
scoreModelNameSimilarity(params.selectedModel, item) >=
CANVAS_FAILOVER_SAME_NAME_THRESHOLD);
if (
strictScaleAdjustModelFamily &&
!isSameFamilyScaleAdjustFallback &&
!isEmergencyFalEditFallback
) {
return false;
}
if (
!item.model.isEnabled ||
item.model.isSubModel ||
isImageFallbackModelBalanceCoolingDown(item.model) ||
(item.model.excludeFromFailover && !isSameNameScaleAdjustFallback)
) {
return false;
}
if (failedProviderTypes.has(item.provider.type)) return false;
if (!item.model.supportsImageGen) return false;
if (
params.requiresProviderMaskInput &&
!isCanvasScaleAdjustProviderMaskEditModel(item)
) {
return false;
}
if (
params.requiresImageInput &&
modelLacksReferenceImageInputSupport(item.model, item.provider)
) {
return false;
}
if (
params.requiresTextToImage &&
!modelSupportsTextToImageDescriptor({
providerType: item.provider.type,
modelId: item.model.modelId,
capabilities: item.model.capabilities,
defaultParams: item.model.defaultParams,
supportsImageGen: item.model.supportsImageGen,
})
) {
return false;
}
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(item.provider.type)) return false;
return getCanvasModelRefKeys(item).every((key) => !failedRefs.has(key));
};
if (!params.preferLowCostSameNameProviders) {
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;
}
}
let usingEmergencyFalEditFallback = false;
let usableModels = params.imageModels.filter((item) => isUsableFallback(item));
if (
usableModels.length === 0 &&
strictScaleAdjustModelFamily &&
params.preferLowCostSameNameProviders
) {
usingEmergencyFalEditFallback = true;
usableModels = params.imageModels.filter((item) =>
isUsableFallback(item, { allowEmergencyFalEdit: true })
);
}
const scoredModels = usableModels
.map((item, index) => ({
item,
index,
nameSimilarity: scoreModelNameSimilarity(params.selectedModel, item),
sameProviderType: item.provider.type === params.selectedModel.provider.type,
}))
.sort((left, right) => {
if (usingEmergencyFalEditFallback) {
const leftPriority = getCanvasScaleAdjustFailoverPriority(left.item);
const rightPriority = getCanvasScaleAdjustFailoverPriority(right.item);
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
return left.index - right.index;
}
if (strictScaleAdjustModelFamily) {
const leftPriority = getCanvasScaleAdjustFailoverPriority(left.item);
const rightPriority = getCanvasScaleAdjustFailoverPriority(right.item);
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
if (right.nameSimilarity !== left.nameSimilarity) {
return right.nameSimilarity - left.nameSimilarity;
}
if (left.sameProviderType !== right.sameProviderType) {
return left.sameProviderType ? -1 : 1;
}
return left.index - right.index;
}
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 (
params.preferLowCostSameNameProviders &&
leftSameName &&
rightSameName
) {
const leftPriority = getCanvasScaleAdjustFailoverPriority(left.item);
const rightPriority = getCanvasScaleAdjustFailoverPriority(right.item);
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
if (right.nameSimilarity !== left.nameSimilarity) {
return right.nameSimilarity - left.nameSimilarity;
}
if (left.sameProviderType !== right.sameProviderType) {
return left.sameProviderType ? -1 : 1;
}
return left.index - right.index;
}
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 semanticPreparation = request.semanticScaleAdjustPreparation;
if (
semanticPreparation &&
/^https?:\/\//i.test(semanticPreparation.sourceImageUrl) &&
semanticPreparation.cleanupModel.trim() &&
semanticPreparation.cleanupPrompt.trim() &&
semanticPreparation.extractionPrompt.trim() &&
semanticPreparation.regions.length > 0 &&
semanticPreparation.regions.every((region) =>
/^https?:\/\//i.test(region.sourceCropImageUrl)
)
) {
const cleanupComposite: CanvasLocalEditCompositeConfig = {
...(request.localEditComposite || {}),
expandRegions: false,
featherMaxPx: 32,
featherMinPx: 24,
featherRatio: 0.012,
maskMode: "region",
regions:
request.localEditComposite?.regions ||
semanticPreparation.regions.map((region) => region.source),
sourceImageUrl: semanticPreparation.sourceImageUrl,
};
const runSemanticCleanup = (attempt: number) =>
generateCanvasImage({
...request,
debugLabel: `${request.debugLabel || "canvas-image"}-semantic-cleanup${
attempt > 1 ? `-retry-${attempt}` : ""
}`,
debugMeta: {
...(request.debugMeta || {}),
scaleAdjustSemanticCleanupAttempt: attempt,
scaleAdjustSemanticStage: "cleanup",
},
ignoreModelMaxImages: true,
imageSize: semanticPreparation.cleanupImageSize || "2K",
localEditComposite: cleanupComposite,
model: semanticPreparation.cleanupModel,
prompt: semanticPreparation.cleanupPrompt,
providerImageParams: { quality: "high" },
referenceImages: [
semanticPreparation.sourceImageUrl,
...semanticPreparation.regions.map(
(region) => region.sourceCropImageUrl
),
],
retryVariants: [],
scaleAdjustModelRelight: undefined,
scaleAdjustVerification: undefined,
semanticScaleAdjustPreparation: undefined,
});
const cleanupPromise = runSemanticCleanup(1).catch((error) => {
console.warn(
"[Canvas Generate Image] Semantic cleanup failed; retrying without re-extracting product:",
{
debugLabel: request.debugLabel || "canvas-image",
error: error instanceof Error ? error.message : String(error),
}
);
return runSemanticCleanup(2);
});
const extractionPromises = semanticPreparation.regions.map(
(region, regionIndex) =>
generateCanvasImage({
...request,
aspectRatio: "auto",
debugLabel: `${request.debugLabel || "canvas-image"}-semantic-extract-${regionIndex + 1}`,
debugMeta: {
...(request.debugMeta || {}),
scaleAdjustSemanticRegionIndex: regionIndex + 1,
scaleAdjustSemanticStage: "extract",
},
ignoreModelMaxImages: true,
imageCount: 2,
imageSize: "1K",
localEditComposite: null,
prompt: semanticPreparation.extractionPrompt,
providerImageParams: { quality: "high" },
referenceImages: [
region.sourceCropImageUrl,
semanticPreparation.sourceImageUrl,
],
retryVariants: [],
scaleAdjustModelRelight: undefined,
scaleAdjustVerification: undefined,
semanticScaleAdjustPreparation: undefined,
})
);
const [cleanupResult, ...extractionResults] = await Promise.all([
cleanupPromise,
...extractionPromises,
]);
const cleanupImageUrl =
cleanupResult.images[0]?.originalUrl ||
cleanupResult.images[0]?.thumbnailUrl ||
"";
if (!cleanupImageUrl) {
throw new Error("比例调整完整产品清理失败");
}
const semanticRegions = semanticPreparation.regions.map(
(region, regionIndex) => ({
candidateImageUrls: (extractionResults[regionIndex]?.images || [])
.map((image) => image.originalUrl || image.thumbnailUrl)
.filter(Boolean),
sourceCropImageUrl: region.sourceCropImageUrl,
target: region.target,
})
);
if (semanticRegions.some((region) => region.candidateImageUrls.length === 0)) {
throw new Error("比例调整完整产品提取失败");
}
const { buildSemanticScaleAdjustProductComposite } = await import(
"@/lib/canvas/scale-adjust-semantic-product"
);
const semanticComposite = await buildSemanticScaleAdjustProductComposite({
backgroundImageUrl: cleanupImageUrl,
regions: semanticRegions,
sourceHeight:
Number(request.debugMeta?.sourceHeight) > 0
? Number(request.debugMeta?.sourceHeight)
: 1,
sourceWidth:
Number(request.debugMeta?.sourceWidth) > 0
? Number(request.debugMeta?.sourceWidth)
: 1,
});
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const thumbnailBuffer = await sharp(semanticComposite.mergedBuffer, {
failOn: "none",
})
.resize(512, 512, { fit: "inside", withoutEnlargement: true })
.png({ compressionLevel: 6 })
.toBuffer();
const [originalUpload, thumbnailUpload, overlayUpload] = await Promise.all([
uploadFile(
`canvas-scale-adjust-semantic-product-${suffix}.png`,
bufferToArrayBuffer(semanticComposite.mergedBuffer)
),
uploadFile(
`canvas-scale-adjust-semantic-product-thumb-${suffix}.png`,
bufferToArrayBuffer(thumbnailBuffer)
),
uploadFile(
`canvas-scale-adjust-semantic-overlay-${suffix}.png`,
bufferToArrayBuffer(semanticComposite.overlayBuffer)
),
]);
console.info("[Canvas Generate Image] Semantic product lock prepared:", {
candidateImageUrls: semanticRegions.map(
(region) => region.candidateImageUrls
),
candidateStats: semanticComposite.candidateStats,
cleanupImageUrl,
debugLabel: request.debugLabel || "canvas-scale-adjust-semantic-product",
originalUrl: originalUpload.url,
overlayUrl: overlayUpload.url,
});
const verifiedImages = await applyScaleAdjustVerificationToImages({
debugLabel: request.debugLabel || "canvas-scale-adjust-semantic-product",
images: [
{
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url,
},
],
skipAnnotationCleanup: true,
verification: request.scaleAdjustVerification || null,
});
const initialResult: CanvasGenerateImageResult = {
images: verifiedImages,
model: extractionResults[0]?.model || cleanupResult.model,
modelRef: extractionResults[0]?.modelRef || cleanupResult.modelRef,
prompt,
};
console.info("[Canvas Generate Image] Semantic product lock applied:", {
candidateStats: semanticComposite.candidateStats,
debugLabel: request.debugLabel || "canvas-scale-adjust-semantic-product",
regionCount: semanticRegions.length,
});
if (!semanticPreparation.relightModel) return initialResult;
const { applyScaleAdjustModelRelight } = await import(
"@/lib/canvas/scale-adjust-model-relight"
);
return applyScaleAdjustModelRelight({
generateImage: generateCanvasImage,
initialResult,
ownerKey: request.ownerKey,
request: {
...request,
scaleAdjustModelRelight: {
model: semanticPreparation.relightModel,
overlayImageUrl: overlayUpload.url,
quality: "medium",
regions: semanticPreparation.regions.map((region) => ({
target: region.target,
})),
sourceHeight: Number(request.debugMeta?.sourceHeight) || 1,
sourceWidth: Number(request.debugMeta?.sourceWidth) || 1,
surfaceMode: "surround",
},
semanticScaleAdjustPreparation: undefined,
},
});
}
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 providerImageParams =
request.providerImageParams && typeof request.providerImageParams === "object"
? request.providerImageParams
: 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 scaleAdjustModelFamily =
typeof debugMeta?.scaleAdjustModelFamily === "string"
? debugMeta.scaleAdjustModelFamily.trim()
: "";
const requiresProviderMaskInput = Boolean(
debugMeta?.scaleAdjustProductLockMaskEditApplied &&
debugMeta?.scaleAdjustProviderMaskImageUrl
);
const localEditComposite = normalizeLocalEditCompositeConfig(
request.localEditComposite
);
const scaleAdjustPatchComposite = normalizeScaleAdjustPatchCompositeConfig(
request.scaleAdjustPatchComposite
);
const scaleAdjustProductLockComposite =
normalizeScaleAdjustProductLockCompositeConfig(
request.scaleAdjustProductLockComposite
);
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) {
if (model && model !== "auto" && request.allowFailover === false) throw new Error("指定图片模型不存在或未启用,请重新选择模型");
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;
const requiresTextToImage = normalizedReferenceImages.length === 0;
let provider = selectedModel.provider;
if (
requiresImageInput &&
modelLacksReferenceImageInputSupport(selectedModel.model, selectedModel.provider)
) {
if (request.allowFailover === false) {
throw new Error("当前图片模型不支持参考图/图生图,请选择支持图像输入的生图模型。");
}
const imageInputFallback = findCanvasFailoverModel({
selectedModel,
imageModels,
failedModelRefs: getCanvasModelRefKeys(selectedModel),
preferLowCostSameNameProviders: Boolean(
debugMeta?.scaleAdjustPreferSameNameProviderFailover
),
requiresProviderMaskInput,
requiresImageInput,
scaleAdjustModelFamily,
});
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 (
requiresTextToImage &&
!modelSupportsTextToImageDescriptor({
providerType: selectedModel.provider.type,
modelId: selectedModel.model.modelId,
capabilities: selectedModel.model.capabilities,
defaultParams: selectedModel.model.defaultParams,
supportsImageGen: selectedModel.model.supportsImageGen,
})
) {
if (request.allowFailover === false) {
throw new Error("当前图片模型不支持纯文生图,请选择支持文生图的生图模型。");
}
const textToImageFallback = findCanvasFailoverModel({
selectedModel,
imageModels,
failedModelRefs: getCanvasModelRefKeys(selectedModel),
preferLowCostSameNameProviders: Boolean(
debugMeta?.scaleAdjustPreferSameNameProviderFailover
),
requiresProviderMaskInput,
requiresTextToImage,
scaleAdjustModelFamily,
});
if (!textToImageFallback) {
throw new Error("当前任务需要纯文生图,但暂无可用的文生图模型。");
}
console.warn("[Canvas Generate Image] Fallback to text-to-image capable model:", {
debugLabel,
requestedModel: selectedModel.model.modelId || selectedModel.model.id,
fallbackModel: textToImageFallback.model.modelId || textToImageFallback.model.id,
requestedProviderType: provider.type,
fallbackProviderType: textToImageFallback.provider.type,
reason: "selected-model-lacks-text-to-image-support",
});
selectedModel = textToImageFallback;
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/SnapGen/OpenAI/BananaImage/Visionary/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 &&
modelLacksReferenceImageInputSupport(candidate.model, candidate.provider)
) {
continue;
}
if (
requiresTextToImage &&
!modelSupportsTextToImageDescriptor({
providerType: candidate.provider.type,
modelId: candidate.model.modelId,
capabilities: candidate.model.capabilities,
defaultParams: candidate.model.defaultParams,
supportsImageGen: candidate.model.supportsImageGen,
})
) {
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/SnapGen/OpenAI/BananaImage/Visionary/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,
ignoreModelMaxImages: request.ignoreModelMaxImages === true,
});
const imageSize = normalizedImageParams.imageSize;
const imageCount =
scaleAdjustVerification && provider.type === "fal-image"
? Math.max(1, Math.min(4, requestedImageCount))
: 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),
hasScaleAdjustPatchComposite: Boolean(scaleAdjustPatchComposite),
hasScaleAdjustProductLockComposite: Boolean(scaleAdjustProductLockComposite),
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 providerRuntimeParams = getCanvasProviderRuntimeParams({
debugLabel,
debugMeta,
providerType: provider.type,
});
const imageParams = {
...(providerImageParams || {}),
...providerRuntimeParams,
aspectRatio,
safetyTolerance: request.falSafetyTolerance,
imageSize,
imageCount: 1,
negativePrompt,
promptExtend,
systemPrompt: request.falSystemPrompt,
thinkingLevel: request.falThinkingLevel,
};
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 === "snapgen-image") {
const snapgenProvider = createSnapgenImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const runtimeModelId = resolveModelRuntimeId(selectedModel.model, {
providerType: provider.type,
});
const modelInstance = snapgenProvider.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 === "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 === "bananaimage") {
const bananaImageProvider = createBananaImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const runtimeModelId = resolveModelRuntimeId(selectedModel.model, {
providerType: provider.type,
});
const modelInstance = bananaImageProvider.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 === "visionary") {
const visionaryProvider = createVisionary({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const runtimeModelId = resolveModelRuntimeId(selectedModel.model, {
providerType: provider.type,
});
const modelInstance = visionaryProvider.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 === "atlascloud") {
const atlasCloudProvider = createAtlasCloud({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const runtimeModelId = resolveModelRuntimeId(selectedModel.model, {
providerType: provider.type,
});
const modelInstance = atlasCloudProvider.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 === "vip98k") {
const vip98kProvider = createVip98k({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const runtimeModelId = resolveModelRuntimeId(selectedModel.model, {
providerType: provider.type,
});
const modelInstance = vip98kProvider.chat(runtimeModelId, {
capabilities: selectedModel.model.capabilities || null,
imageGenConfig: selectedModel.model.imageGenConfig || null,
});
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 === "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/SnapGen/OpenAI/BananaImage/Visionary/Atlas Cloud/VIP98K/StepFun/Midjourney/Runway/Krea 图片模型`
);
}
const providerBatchLimit = getProviderBatchLimit(provider.type);
const providerBatchTimeoutMs = getCanvasProviderBatchTimeoutMs({
debugLabel,
debugMeta,
providerType: 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 withCanvasProviderBatchTimeout(
executeGenerateBatch(batchImageCount),
{
debugLabel,
modelId: selectedModel.model.modelId,
providerType: provider.type,
timeoutMs: providerBatchTimeoutMs,
}
);
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 compositedImages = await applyLocalEditCompositeToImages({
composite: localEditComposite,
debugLabel,
images: baseImages,
});
const scaleAdjustPatchCompositedImages =
await applyScaleAdjustPatchCompositeToImages({
composite: scaleAdjustPatchComposite,
debugLabel,
images: compositedImages,
});
const scaleAdjustProductLockedImages =
await applyScaleAdjustProductLockCompositeToImages({
composite: scaleAdjustProductLockComposite,
debugLabel,
images: scaleAdjustPatchCompositedImages,
});
const finalImages = await applyScaleAdjustVerificationToImages({
debugLabel,
images: scaleAdjustProductLockedImages,
skipAnnotationCleanup: Boolean(scaleAdjustProductLockComposite),
verification: scaleAdjustVerification,
});
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 retryVariants = Array.isArray(request.retryVariants)
? request.retryVariants
: [];
const scaleAdjustRejection =
caughtError instanceof ScaleAdjustOutputRejectedError ? caughtError : null;
const nextRetryVariantIndex =
typeof request.internalRetryVariantIndex === "number"
? request.internalRetryVariantIndex + 1
: 0;
const retryVariant = retryVariants[nextRetryVariantIndex];
if (retryVariant) {
const {
debugMeta: retryVariantDebugMeta,
label: retryVariantLabel,
...retryOverrides
} = retryVariant;
const retryDebugMeta = {
...debugMeta,
...(retryVariantDebugMeta || {}),
retryFromError:
caughtError instanceof Error ? caughtError.message : String(caughtError),
retryVariantIndex: nextRetryVariantIndex,
retryVariantLabel: retryVariantLabel || `variant-${nextRetryVariantIndex + 1}`,
};
console.warn("[Canvas Generate Image] Retrying with prompt/reference variant:", {
...retryDebugMeta,
debugLabel,
retryVariantIndex: nextRetryVariantIndex,
retryVariantLabel: retryVariantLabel || null,
selectedModelRef: selectedModel.model.id,
selectedModelId: selectedModel.model.modelId,
error: caughtError instanceof Error ? caughtError.message : String(caughtError),
});
const sequentialCleanupReferenceUrl =
Boolean(debugMeta?.scaleAdjustSequentialCleanupRetry) &&
scaleAdjustRejection?.reason === "old-footprint-remnant-detected"
? scaleAdjustRejection.images?.[0]?.originalUrl ||
scaleAdjustRejection.images?.[0]?.thumbnailUrl ||
""
: "";
return await generateCanvasImage({
...request,
...retryOverrides,
...(sequentialCleanupReferenceUrl
? { referenceImages: [sequentialCleanupReferenceUrl] }
: {}),
debugMeta: retryDebugMeta,
internalRetryVariantIndex: nextRetryVariantIndex,
retryVariants,
});
}
const scaleAdjustRejected = Boolean(scaleAdjustRejection);
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 failureClassification = classifyAiModelHealthFailure(caughtError);
const failedProviderTypes = Array.from(
new Set([
...(Array.isArray(request.internalFailoverTriedProviderTypes)
? request.internalFailoverTriedProviderTypes
: []),
...(shouldFailover &&
failureClassification.reason !== "non_provider_failure" &&
failureClassification.reason !== "user_cancelled"
? [provider.type]
: []),
].filter(Boolean))
);
const fallbackModel = allowFailover && shouldFailover
? findCanvasFailoverModel({
selectedModel,
imageModels,
failedModelRefs,
failedProviderTypes,
preferLowCostSameNameProviders: Boolean(
debugMeta?.scaleAdjustPreferSameNameProviderFailover
),
requiresProviderMaskInput,
requiresImageInput: normalizedReferenceImages.length > 0,
requiresTextToImage: normalizedReferenceImages.length === 0,
scaleAdjustModelFamily,
})
: 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,
internalFailoverTriedProviderTypes: failedProviderTypes,
debugMeta: {
...debugMeta,
failoverFromModelId: selectedModel.model.modelId,
failoverFromModelRef: selectedModel.model.id,
},
});
}
if (scaleAdjustRejection?.images?.length) {
console.warn("[Canvas Generate Image] Rejecting scale adjust candidate because no effective edit was detected:", {
debugLabel,
imageCount: scaleAdjustRejection.images.length,
rejectedImageUrls: scaleAdjustRejection.images.map((image) => ({
originalUrl: image.originalUrl,
thumbnailUrl: image.thumbnailUrl,
})),
reason: scaleAdjustRejection.reason,
selectedModelRef: selectedModel.model.id,
selectedModelId: selectedModel.model.modelId,
...debugMeta,
});
if (scaleAdjustVerification?.allowDeterministicFallback) {
try {
const fallbackImages = await buildScaleAdjustDeterministicFallbackImages({
debugLabel,
verification: scaleAdjustVerification,
});
if (fallbackImages.length > 0) {
const verifiedFallbackImages = await applyScaleAdjustVerificationToImages({
debugLabel,
images: fallbackImages,
verification: {
...scaleAdjustVerification,
rejectFailedCandidates: true,
},
});
console.warn("[Canvas Generate Image] Returning deterministic scale adjust fallback:", {
debugLabel,
fallbackImageCount: verifiedFallbackImages.length,
rejectedReason: scaleAdjustRejection.reason,
selectedModelRef: selectedModel.model.id,
selectedModelId: selectedModel.model.modelId,
...debugMeta,
});
return {
images: verifiedFallbackImages,
model: selectedModel.model.displayName,
modelRef: selectedModel.model.id,
prompt: resultPrompt,
};
}
} catch (fallbackError) {
console.warn("[Canvas Generate Image] Scale adjust deterministic fallback failed:", {
debugLabel,
error:
fallbackError instanceof Error
? fallbackError.message
: String(fallbackError),
selectedModelRef: selectedModel.model.id,
selectedModelId: selectedModel.model.modelId,
...debugMeta,
});
}
}
throw caughtError;
}
throw caughtError;
}