fix: persist detail scale adjustments

This commit is contained in:
134 2026-06-19 01:18:34 +08:00
parent 43769c5b58
commit baa924c8d4
4 changed files with 574 additions and 85 deletions

View File

@ -780,6 +780,13 @@ export async function POST(request: NextRequest) {
prompt,
promptExtend: false,
referenceImages,
scaleAdjustVerification: isScaleAdjustOperation
? {
sourceImageUrl,
regions: regions.map((region) => region.rect),
scalePercent: scalePercent as ScaleAdjustPercent,
}
: undefined,
localEditComposite: isScaleAdjustOperation
? undefined
: {

View File

@ -1701,6 +1701,19 @@ function getFirstResultUrlFromCollections(
);
}
function getAllResultUrlsFromCollections(
collections: AgentGenerationProjectResultCollections
) {
return Array.from(
new Set([
...normalizeResultCollectionUrls(collections.scene_generation),
...normalizeResultCollectionUrls(collections.detail_generation),
...normalizeResultCollectionUrls(collections.detail_replacement),
...normalizeResultCollectionUrls(collections.sku_replacement),
])
);
}
export default function CanvasEditorLegacyPage({ params }: PageProps) {
// 使用 useParams 确保客户端导航时能正确获取更新的 id
const routeParams = useParams();
@ -4308,21 +4321,29 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
...current,
[modeType]: normalizedUrls,
};
return areResultCollectionsEqual(current, nextCollections)
? current
: nextCollections;
});
setAgentProjectThumbnail((current) => {
const nextThumbnail =
normalizedUrls[0] ||
getFirstResultUrlFromCollections({
...agentProjectResultCollections,
[modeType]: normalizedUrls,
});
return current === nextThumbnail ? current : nextThumbnail;
if (areResultCollectionsEqual(current, nextCollections)) {
return current;
}
const nextProjectState: AgentGenerationProjectPersistedState = {
...persistedAgentProjectStateRef.current,
resultCollections: nextCollections,
resultImageUrls: getAllResultUrlsFromCollections(nextCollections),
};
persistedAgentProjectStateRef.current = nextProjectState;
writeAgentProjectDraftSnapshot(nextProjectState);
setAgentProjectThumbnail((currentThumbnail) => {
const nextThumbnail =
normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections);
return currentThumbnail === nextThumbnail ? currentThumbnail : nextThumbnail;
});
return nextCollections;
});
},
[agentProjectResultCollections]
[writeAgentProjectDraftSnapshot]
);
const handleAgentResultImageDelete = useCallback(

View File

@ -5887,15 +5887,15 @@ export function AgentGenerationWorkspaceDarkV2({
uploadBase64ResultToStorage,
]
);
const appendResultUrlToCollection = useCallback(
(
const appendResultUrlToCollection = useCallback(
(
modeType: AgentGenerationResultModeType,
imageUrl: string,
options?: { compareSourceUrl?: string | null }
) => {
const normalizedUrl = String(imageUrl || "").trim();
if (!normalizedUrl || !onResultPreviewUrlsChange) {
return false;
const normalizedUrl = String(imageUrl || "").trim();
if (!normalizedUrl || !onResultPreviewUrlsChange) {
return false;
}
const nextUrls = Array.from(
@ -5912,39 +5912,91 @@ export function AgentGenerationWorkspaceDarkV2({
setSelectedResultUrl(normalizedUrl);
}
return true;
},
[
},
[
activeResultModeType,
mergedResultCollections,
onResultPreviewUrlsChange,
registerResultCompareSource,
]
);
const showProcessedResultHint = useCallback((imageUrl: string, label: string) => {
const normalizedUrl = String(imageUrl || "").trim();
const normalizedLabel = String(label || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl) || !normalizedLabel) return;
);
const existingTimer = processedResultHintTimerRef.current[normalizedUrl];
if (existingTimer) {
window.clearTimeout(existingTimer);
}
const replaceResultUrlInCollection = useCallback(
(
modeType: AgentGenerationResultModeType,
previousUrl: string,
nextUrl: string,
options?: { compareSourceUrl?: string | null }
) => {
const normalizedPreviousUrl = String(previousUrl || "").trim();
const normalizedNextUrl = String(nextUrl || "").trim();
if (!/^https?:\/\//i.test(normalizedNextUrl) || !onResultPreviewUrlsChange) {
return false;
}
setProcessedResultHintByUrl((current) => ({
...current,
[normalizedUrl]: normalizedLabel,
}));
const currentUrls = normalizeResultCollectionUrls(mergedResultCollections[modeType]);
const nextUrls: string[] = [];
let replaced = false;
processedResultHintTimerRef.current[normalizedUrl] = window.setTimeout(() => {
setProcessedResultHintByUrl((current) => {
if (!(normalizedUrl in current)) return current;
const next = { ...current };
delete next[normalizedUrl];
return next;
});
delete processedResultHintTimerRef.current[normalizedUrl];
}, 2800);
}, []);
currentUrls.forEach((url) => {
if (url === normalizedPreviousUrl) {
replaced = true;
if (!nextUrls.includes(normalizedNextUrl)) {
nextUrls.push(normalizedNextUrl);
}
return;
}
if (url !== normalizedNextUrl) {
nextUrls.push(url);
}
});
if (!replaced && !nextUrls.includes(normalizedNextUrl)) {
nextUrls.push(normalizedNextUrl);
}
onResultPreviewUrlsChange(modeType, nextUrls);
if (options && "compareSourceUrl" in options) {
registerResultCompareSource(normalizedNextUrl, options.compareSourceUrl);
}
if (activeResultModeType === modeType) {
setSelectedResultUrl(normalizedNextUrl);
}
return true;
},
[
activeResultModeType,
mergedResultCollections,
onResultPreviewUrlsChange,
registerResultCompareSource,
]
);
const showProcessedResultHint = useCallback((imageUrl: string, label: string) => {
const normalizedUrl = String(imageUrl || "").trim();
const normalizedLabel = String(label || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl) || !normalizedLabel) return;
const existingTimer = processedResultHintTimerRef.current[normalizedUrl];
if (existingTimer) {
window.clearTimeout(existingTimer);
}
setProcessedResultHintByUrl((current) => ({
...current,
[normalizedUrl]: normalizedLabel,
}));
processedResultHintTimerRef.current[normalizedUrl] = window.setTimeout(() => {
setProcessedResultHintByUrl((current) => {
if (!(normalizedUrl in current)) return current;
const next = { ...current };
delete next[normalizedUrl];
return next;
});
delete processedResultHintTimerRef.current[normalizedUrl];
}, 2800);
}, []);
const updateDetailGenerationProcessedResult = useCallback(
(params: {
@ -5987,10 +6039,10 @@ export function AgentGenerationWorkspaceDarkV2({
},
[registerResultCompareSource]
);
const handleEnhanceDetailGenerationResult = useCallback(
async (params: {
cardId: string;
imageUrl: string;
const handleEnhanceDetailGenerationResult = useCallback(
async (params: {
cardId: string;
imageUrl: string;
resultModeType?: "detail_generation" | "detail_replacement";
}) => {
const cardId = String(params.cardId || "").trim();
@ -6024,26 +6076,29 @@ export function AgentGenerationWorkspaceDarkV2({
status: "completed",
compareSourceUrl: imageUrl,
});
replaceResultUrlInCollection("detail_replacement", imageUrl, nextUrl, {
compareSourceUrl: imageUrl,
});
setActiveResultModeType("detail_replacement");
setActiveDetailReferenceCardId(cardId);
setSelectedResultUrl(nextUrl);
} else {
if (
!appendResultUrlToCollection("detail_generation", nextUrl, {
compareSourceUrl: imageUrl,
})
) {
throw new Error("当前项目未接入结果保存能力");
}
if (
!replaceResultUrlInCollection("detail_generation", imageUrl, nextUrl, {
compareSourceUrl: imageUrl,
})
) {
throw new Error("当前项目未接入结果保存能力");
}
updateDetailGenerationProcessedResult({
cardId,
previousUrl: imageUrl,
nextUrl,
compareSourceUrl: imageUrl,
});
setActiveResultModeType("detail_generation");
setSelectedResultUrl(nextUrl);
}
setActiveResultModeType("detail_generation");
setSelectedResultUrl(nextUrl);
}
toast.success(
resultModeType === "detail_replacement"
? "高清处理完成,已更新当前套详情结果图"
@ -6061,14 +6116,14 @@ export function AgentGenerationWorkspaceDarkV2({
}
},
[
appendResultUrlToCollection,
detailEnhancingCardIds,
handleUpdateReplacementResultRecord,
updateDetailGenerationProcessedResult,
]
);
const handleSubmitDetailLocalEdit = useCallback(
async (params: DetailResultLocalEditSubmitParams) => {
detailEnhancingCardIds,
handleUpdateReplacementResultRecord,
replaceResultUrlInCollection,
updateDetailGenerationProcessedResult,
]
);
const handleSubmitDetailLocalEdit = useCallback(
async (params: DetailResultLocalEditSubmitParams) => {
const target = detailLocalEditTarget;
const cardId = String(target?.cardId || "").trim();
const imageUrl = String(params.imageUrl || "").trim();
@ -6081,6 +6136,10 @@ export function AgentGenerationWorkspaceDarkV2({
return;
}
if (detailLocalEditSubmittingId) return;
const resolvedLocalEditModelId =
selectedModelId !== "auto"
? selectedModelId
: effectiveDefaultModelId || undefined;
setDetailLocalEditSubmittingId(cardId);
setDetailLocalEditSubmittingMode(editMode);
@ -6090,6 +6149,10 @@ export function AgentGenerationWorkspaceDarkV2({
const submitResult = await imageGenerationService.localEdit({
aspectRatio: "auto",
imageSize: "2K",
model:
resolvedLocalEditModelId && resolvedLocalEditModelId !== "auto"
? resolvedLocalEditModelId
: undefined,
operation: isScaleAdjust ? "scale-adjust" : undefined,
scalePercent: isScaleAdjust ? params.scalePercent : undefined,
sourceImageUrl: imageUrl,
@ -6128,13 +6191,13 @@ export function AgentGenerationWorkspaceDarkV2({
continue;
}
if (status === "succeeded" || status === "completed") {
const generatedImage = task.result?.images?.[0];
const nextUrl =
generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "";
if (!/^https?:\/\//i.test(nextUrl)) {
throw new Error(`${isScaleAdjust ? "比例调整" : "局部编辑"}完成但结果地址无效`);
}
if (status === "succeeded" || status === "completed") {
const generatedImage = task.result?.images?.[0];
const nextUrl =
generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "";
if (!/^https?:\/\//i.test(nextUrl)) {
throw new Error(`${isScaleAdjust ? "比例调整" : "局部编辑"}完成但结果地址无效`);
}
setDetailLocalEditPreviewResult({
cardId,
resultModeType,
@ -6144,11 +6207,11 @@ export function AgentGenerationWorkspaceDarkV2({
scalePercent: params.scalePercent,
title: params.title || target.title,
});
toast.success(
`${isScaleAdjust ? "比例调整" : "局部编辑"}完成,请在弹窗中确认结果`
);
return;
}
toast.success(
`${isScaleAdjust ? "比例调整" : "局部编辑"}完成,请在弹窗中确认结果`
);
return;
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
throw new Error(task.error || `${isScaleAdjust ? "比例调整" : "局部编辑"}生成失败`);
@ -6173,11 +6236,13 @@ export function AgentGenerationWorkspaceDarkV2({
setDetailLocalEditSubmittingMode(null);
}
},
[
detailLocalEditSubmittingId,
detailLocalEditTarget,
]
);
[
detailLocalEditSubmittingId,
detailLocalEditTarget,
effectiveDefaultModelId,
selectedModelId,
]
);
const handleConfirmDetailLocalEditResult = useCallback(() => {
const preview = detailLocalEditPreviewResult;
@ -6200,12 +6265,15 @@ export function AgentGenerationWorkspaceDarkV2({
status: "completed",
compareSourceUrl: sourceImageUrl,
});
replaceResultUrlInCollection("detail_replacement", sourceImageUrl, resultImageUrl, {
compareSourceUrl: sourceImageUrl,
});
setActiveResultModeType("detail_replacement");
setActiveDetailReferenceCardId(cardId);
setSelectedResultUrl(resultImageUrl);
} else {
if (
!appendResultUrlToCollection("detail_generation", resultImageUrl, {
!replaceResultUrlInCollection("detail_generation", sourceImageUrl, resultImageUrl, {
compareSourceUrl: sourceImageUrl,
})
) {
@ -6231,9 +6299,9 @@ export function AgentGenerationWorkspaceDarkV2({
: `${isScaleAdjust ? "比例调整" : "局部编辑"}结果已加入 AI详情结果区`
);
}, [
appendResultUrlToCollection,
detailLocalEditPreviewResult,
handleUpdateReplacementResultRecord,
replaceResultUrlInCollection,
showProcessedResultHint,
updateDetailGenerationProcessedResult,
]);

View File

@ -42,6 +42,12 @@ export interface CanvasLocalEditCompositeConfig {
regions: CanvasLocalEditCompositeRegion[];
}
export interface CanvasScaleAdjustVerificationConfig {
sourceImageUrl: string;
regions: CanvasLocalEditCompositeRegion[];
scalePercent: number;
}
export interface CanvasGenerateImageRequest {
prompt: string;
displayPrompt?: string;
@ -58,6 +64,7 @@ export interface CanvasGenerateImageRequest {
debugLabel?: string;
debugMeta?: Record<string, unknown>;
localEditComposite?: CanvasLocalEditCompositeConfig;
scaleAdjustVerification?: CanvasScaleAdjustVerificationConfig;
allowFailover?: boolean;
internalFailoverTriedModelRefs?: string[];
onQueueStatusChange?: (
@ -85,6 +92,10 @@ const LOCAL_EDIT_COMPOSITE_MIN_PADDING_RATIO = 0.012;
const LOCAL_EDIT_COMPOSITE_MAX_PADDING_RATIO = 0.06;
const LOCAL_EDIT_COMPOSITE_FEATHER_MIN_PX = 10;
const LOCAL_EDIT_COMPOSITE_FEATHER_MAX_PX = 44;
const SCALE_ADJUST_VISUAL_DIFF_THRESHOLD = 14;
const SCALE_ADJUST_DIFF_SAMPLE_SIZE = 112;
const SCALE_ADJUST_FALLBACK_FEATHER_MIN_PX = 8;
const SCALE_ADJUST_FALLBACK_FEATHER_MAX_PX = 36;
const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
"gemini",
@ -218,6 +229,378 @@ function normalizeLocalEditCompositeConfig(
return regions.length > 0 ? { sourceImageUrl, regions } : null;
}
function normalizeCanvasRegion(region: unknown): CanvasLocalEditCompositeRegion | null {
if (!region || typeof region !== "object") return null;
const raw = region as Partial<CanvasLocalEditCompositeRegion>;
const x = clampRatio(Number(raw.x));
const y = clampRatio(Number(raw.y));
const width = clampRatio(Number(raw.width));
const height = clampRatio(Number(raw.height));
const normalized = {
x,
y,
width: Math.min(width, Math.max(0, 1 - x)),
height: Math.min(height, Math.max(0, 1 - y)),
};
return normalized.width > 0 && normalized.height > 0 ? normalized : null;
}
function normalizeScaleAdjustVerificationConfig(
value: CanvasGenerateImageRequest["scaleAdjustVerification"]
): CanvasScaleAdjustVerificationConfig | null {
if (!value || typeof value !== "object") return null;
const sourceImageUrl = String(value.sourceImageUrl || "").trim();
if (!/^https?:\/\//i.test(sourceImageUrl)) return null;
const scalePercent = Number(value.scalePercent);
if (!Number.isFinite(scalePercent) || Math.abs(scalePercent) < 1) return null;
if (!Array.isArray(value.regions) || value.regions.length === 0) return null;
const regions = value.regions
.map((region) => normalizeCanvasRegion(region))
.filter((region): region is CanvasLocalEditCompositeRegion => Boolean(region));
return regions.length > 0 ? { sourceImageUrl, regions, scalePercent } : null;
}
function scaleRegionAroundCenter(
region: CanvasLocalEditCompositeRegion,
scalePercent: number
): CanvasLocalEditCompositeRegion {
const factor = Math.max(0.05, 1 + scalePercent / 100);
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 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),
};
}
function buildScaleAdjustSoftMaskSvg(width: number, height: number) {
const blur = Math.max(
SCALE_ADJUST_FALLBACK_FEATHER_MIN_PX,
Math.min(
SCALE_ADJUST_FALLBACK_FEATHER_MAX_PX,
Math.round(Math.min(width, height) * 0.035)
)
);
const inset = Math.min(
Math.floor(Math.min(width, height) / 4),
Math.max(1, Math.round(blur * 0.75))
);
const rectWidth = Math.max(1, width - inset * 2);
const rectHeight = Math.max(1, height - inset * 2);
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-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)" />
<rect x="${inset}" y="${inset}" width="${rectWidth}" height="${rectHeight}" rx="${Math.round(blur * 0.9)}" fill="#ffffff" filter="url(#scale-adjust-soft-mask)" />
<rect x="${Math.max(0, Math.round(inset / 2))}" y="${Math.max(0, Math.round(inset / 2))}" width="${Math.max(1, width - inset)}" height="${Math.max(1, height - inset)}" rx="${Math.round(blur * 0.5)}" fill="#ffffff" opacity="0.92" />
</svg>
`);
}
async function getScaleAdjustRegionSample(params: {
buffer: Buffer;
sourceWidth: number;
sourceHeight: number;
region: CanvasLocalEditCompositeRegion;
}) {
const box = rectToPixelBox({
region: params.region,
width: params.sourceWidth,
height: params.sourceHeight,
});
return sharp(params.buffer, { failOn: "none" })
.rotate()
.resize(params.sourceWidth, params.sourceHeight, {
fit: "cover",
position: "center",
})
.extract(box)
.resize(SCALE_ADJUST_DIFF_SAMPLE_SIZE, SCALE_ADJUST_DIFF_SAMPLE_SIZE, {
fit: "fill",
})
.removeAlpha()
.toColorspace("srgb")
.raw()
.toBuffer();
}
function calculateMeanAbsoluteDiff(left: Buffer, right: Buffer) {
const length = Math.min(left.length, right.length);
if (length <= 0) return Number.POSITIVE_INFINITY;
let sum = 0;
for (let index = 0; index < length; index += 1) {
sum += Math.abs(left[index] - right[index]);
}
return sum / length;
}
async function getScaleAdjustDiffScores(params: {
generatedBuffer: Buffer;
sourceBuffer: Buffer;
sourceWidth: number;
sourceHeight: number;
regions: CanvasLocalEditCompositeRegion[];
}) {
const scores: number[] = [];
for (const region of params.regions) {
const [sourceSample, generatedSample] = await Promise.all([
getScaleAdjustRegionSample({
buffer: params.sourceBuffer,
sourceWidth: params.sourceWidth,
sourceHeight: params.sourceHeight,
region,
}),
getScaleAdjustRegionSample({
buffer: params.generatedBuffer,
sourceWidth: params.sourceWidth,
sourceHeight: params.sourceHeight,
region,
}),
]);
scores.push(calculateMeanAbsoluteDiff(sourceSample, generatedSample));
}
return scores;
}
async function buildScaleAdjustFallbackImage(params: {
sourceBuffer: Buffer;
sourceWidth: number;
sourceHeight: number;
verification: CanvasScaleAdjustVerificationConfig;
}) {
let workingBuffer = await sharp(params.sourceBuffer, { failOn: "none" })
.rotate()
.resize(params.sourceWidth, params.sourceHeight, {
fit: "cover",
position: "center",
})
.png({ compressionLevel: 6 })
.toBuffer();
for (const region of params.verification.regions) {
const sourceBox = rectToPixelBox({
region,
width: params.sourceWidth,
height: params.sourceHeight,
});
const targetBox = rectToPixelBox({
region: scaleRegionAroundCenter(region, params.verification.scalePercent),
width: params.sourceWidth,
height: params.sourceHeight,
});
const targetMask = buildScaleAdjustSoftMaskSvg(
targetBox.width,
targetBox.height
);
const scaledPatch = await sharp(params.sourceBuffer, { failOn: "none" })
.rotate()
.resize(params.sourceWidth, params.sourceHeight, {
fit: "cover",
position: "center",
})
.extract(sourceBox)
.resize(targetBox.width, targetBox.height, { fit: "fill" })
.ensureAlpha()
.composite([{ input: targetMask, blend: "dest-in" }])
.png({ compressionLevel: 6 })
.toBuffer();
const composites: sharp.OverlayOptions[] = [];
if (params.verification.scalePercent < 0) {
const fillMask = buildScaleAdjustSoftMaskSvg(
sourceBox.width,
sourceBox.height
);
const fillPatch = await sharp(params.sourceBuffer, { failOn: "none" })
.rotate()
.resize(params.sourceWidth, params.sourceHeight, {
fit: "cover",
position: "center",
})
.extract(sourceBox)
.blur(
Math.max(
8,
Math.min(34, Math.round(Math.min(sourceBox.width, sourceBox.height) * 0.08))
)
)
.modulate({ brightness: 1.02, saturation: 0.98 })
.ensureAlpha()
.composite([{ input: fillMask, blend: "dest-in" }])
.png({ compressionLevel: 6 })
.toBuffer();
composites.push({
input: fillPatch,
left: sourceBox.left,
top: sourceBox.top,
});
}
composites.push({
input: scaledPatch,
left: targetBox.left,
top: targetBox.top,
});
workingBuffer = await sharp(workingBuffer, { failOn: "none" })
.ensureAlpha()
.composite(composites)
.png({ compressionLevel: 6 })
.toBuffer();
}
return workingBuffer;
}
async function applyScaleAdjustVerificationToImages(params: {
images: ParsedImage[];
verification: CanvasScaleAdjustVerificationConfig | null;
debugLabel: string;
}) {
const { images, verification, debugLabel } = params;
if (!verification || images.length === 0) return images;
try {
const 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 images;
}
const checkedImages: ParsedImage[] = [];
for (const [index, image] of images.entries()) {
const generatedUrl = image.originalUrl || image.thumbnailUrl;
const generatedBuffer = Buffer.from(await fetchImageBufferFromUrl(generatedUrl));
const diffScores = await getScaleAdjustDiffScores({
generatedBuffer,
sourceBuffer,
sourceHeight,
sourceWidth,
regions: verification.regions,
});
const isVisuallyUnchanged =
diffScores.length > 0 &&
diffScores.every((score) => score < SCALE_ADJUST_VISUAL_DIFF_THRESHOLD);
if (!isVisuallyUnchanged) {
console.info("[Canvas Generate Image] Scale adjust output accepted:", {
debugLabel,
diffScores: diffScores.map((score) => Math.round(score * 10) / 10),
imageIndex: index + 1,
scalePercent: verification.scalePercent,
});
checkedImages.push(image);
continue;
}
const fallbackBuffer = await buildScaleAdjustFallbackImage({
sourceBuffer,
sourceHeight,
sourceWidth,
verification,
});
const thumbnailBuffer = await sharp(fallbackBuffer, { 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-scale-adjust-fallback-${suffix}.png`,
bufferToArrayBuffer(fallbackBuffer)
),
uploadFile(
`canvas-scale-adjust-fallback-thumb-${suffix}.png`,
bufferToArrayBuffer(thumbnailBuffer)
),
]);
console.warn("[Canvas Generate Image] Scale adjust fallback applied:", {
debugLabel,
diffScores: diffScores.map((score) => Math.round(score * 10) / 10),
imageIndex: index + 1,
regionCount: verification.regions.length,
scalePercent: verification.scalePercent,
sourceHeight,
sourceWidth,
});
checkedImages.push({
originalUrl: originalUpload.url,
thumbnailUrl: thumbnailUpload.url,
});
}
return checkedImages;
} catch (error) {
console.warn("[Canvas Generate Image] Scale adjust verification skipped:", {
debugLabel,
error: error instanceof Error ? error.message : String(error),
regionCount: verification.regions.length,
scalePercent: verification.scalePercent,
});
return images;
}
}
function buildLocalEditCompositeMaskSvg(params: {
width: number;
height: number;
@ -608,6 +991,9 @@ export async function generateCanvasImage(
const localEditComposite = normalizeLocalEditCompositeConfig(
request.localEditComposite
);
const scaleAdjustVerification = normalizeScaleAdjustVerificationConfig(
request.scaleAdjustVerification
);
const allModels = await getEnabledAiModelsWithProvider();
const imageModels = allModels.filter((m) => m.model.supportsImageGen);
@ -728,6 +1114,7 @@ export async function generateCanvasImage(
providerType: provider.type,
...debugMeta,
hasLocalEditComposite: Boolean(localEditComposite),
hasScaleAdjustVerification: Boolean(scaleAdjustVerification),
});
const resultPrompt =
@ -1024,10 +1411,16 @@ export async function generateCanvasImage(
throw new Error("图片生成失败");
}
const scaleCheckedImages = await applyScaleAdjustVerificationToImages({
debugLabel,
images: collectedImages.slice(0, targetImageCount),
verification: scaleAdjustVerification,
});
const finalImages = await applyLocalEditCompositeToImages({
composite: localEditComposite,
debugLabel,
images: collectedImages.slice(0, targetImageCount),
images: scaleCheckedImages,
});
console.info("[Canvas Generate Image] Completed:", {
debugLabel,