5845 lines
195 KiB
TypeScript
5845 lines
195 KiB
TypeScript
import "server-only";
|
||
|
||
import sharp from "sharp";
|
||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
||
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
||
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
||
import { createBflImage } from "@/lib/ai/providers/bfl-image";
|
||
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
|
||
import { createFalImage } from "@/lib/ai/providers/fal-image";
|
||
import { 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;
|
||
}
|