diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index 12819f9fb..291de03ca 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -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 : { diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 5e00877b0..50fd0b668 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -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( diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 7272fc296..7a54e0464 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -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, ]); diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index b4de8c126..625863be5 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -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; 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; + 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(` + + + + + + + + + + + `); +} + +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,