fix: persist detail scale adjustments
This commit is contained in:
parent
43769c5b58
commit
baa924c8d4
@ -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
|
||||
: {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user