From f29a26b43fd16fc965e60a8585b4b8fa00e116fa Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 18 Jun 2026 18:41:17 +0800 Subject: [PATCH] feat: improve canvas local edit scale adjustment --- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 692 ++++++++++++++---- components/canvas/nodes/ImageNode.tsx | 343 +++++++-- 2 files changed, 815 insertions(+), 220 deletions(-) diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 38bbeebad..173d1d7c7 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -256,6 +256,16 @@ type DetailResultLocalEditRect = { height: number; }; +type DetailResultLocalEditRegionHandle = "move" | "nw" | "ne" | "sw" | "se"; + +type DetailResultLocalEditRegionInteraction = { + handle: DetailResultLocalEditRegionHandle; + pointerId: number; + regionId: string; + startPoint: { x: number; y: number }; + startRect: DetailResultLocalEditRect; +}; + type DetailResultLocalEditSubmitParams = { imageUrl: string; mode: DetailResultLocalEditMode; @@ -264,6 +274,16 @@ type DetailResultLocalEditSubmitParams = { regions: LocalEditRegion[]; }; +type DetailResultLocalEditPreviewResult = { + cardId: string; + resultModeType: "detail_generation" | "detail_replacement"; + sourceImageUrl: string; + resultImageUrl: string; + mode: DetailResultLocalEditMode; + title: string; + scalePercent?: DetailResultScalePercent; +}; + const MAX_CHAT_TEXT_PART_LENGTH = 2000; const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000; const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100; @@ -278,6 +298,7 @@ const DETAIL_SCALE_ADJUST_OPTIONS: DetailResultScalePercent[] = [ 20, 30, ]; +const DETAIL_LOCAL_EDIT_MIN_RECT_RATIO = 0.025; const DIRECT_MODEL_UPLOAD_SLOT_COUNT = 4; const PRODUCT_DETAIL_CHANNEL_OPTIONS: Array<{ value: AgentWorkflowProductDetailChannel; @@ -846,31 +867,46 @@ function getDetailScaleAdjustRegionPrompt(scalePercent: DetailResultScalePercent } function DetailResultLocalEditDialog({ + previewResult, submitting, target, onClose, + onConfirmResult, + onResetResult, onSubmit, }: { + previewResult: DetailResultLocalEditPreviewResult | null; submitting: boolean; target: DetailResultLocalEditTarget | null; onClose: () => void; + onConfirmResult: () => void; + onResetResult: () => void; onSubmit: (params: DetailResultLocalEditSubmitParams) => void; }) { const [regions, setRegions] = useState([]); const [draftRect, setDraftRect] = useState(null); const [activeRegionId, setActiveRegionId] = useState(null); + const [regionInteraction, setRegionInteraction] = + useState(null); const [uploadingRegionId, setUploadingRegionId] = useState(null); const [selectedScalePercent, setSelectedScalePercent] = useState(null); const dragStartRef = useRef<{ x: number; y: number } | null>(null); + const editSurfaceRef = useRef(null); const mode: DetailResultLocalEditMode = target?.mode === "scale_adjust" ? "scale_adjust" : "local_edit"; const isScaleAdjustMode = mode === "scale_adjust"; + const activePreviewResult = + previewResult && target && previewResult.cardId === target.cardId + ? previewResult + : null; + const isBusy = submitting || Boolean(uploadingRegionId); useEffect(() => { setRegions([]); setDraftRect(null); setActiveRegionId(null); + setRegionInteraction(null); setUploadingRegionId(null); setSelectedScalePercent(null); dragStartRef.current = null; @@ -886,8 +922,9 @@ function DetailResultLocalEditDialog({ } }, [activeRegionId, regions]); - const getPoint = useCallback((event: React.PointerEvent) => { - const bounds = event.currentTarget.getBoundingClientRect(); + const getPoint = useCallback((event: React.PointerEvent) => { + const pointSource = editSurfaceRef.current || event.currentTarget; + const bounds = pointSource.getBoundingClientRect(); const x = bounds.width > 0 ? (event.clientX - bounds.left) / bounds.width : 0; const y = bounds.height > 0 ? (event.clientY - bounds.top) / bounds.height : 0; return { @@ -903,13 +940,89 @@ function DetailResultLocalEditDialog({ return { x: left, y: top, - width: Math.min(1 - left, Math.max(0.01, Math.abs(point.x - start.x))), - height: Math.min(1 - top, Math.max(0.01, Math.abs(point.y - start.y))), + width: Math.min( + 1 - left, + Math.max(DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, Math.abs(point.x - start.x)) + ), + height: Math.min( + 1 - top, + Math.max(DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, Math.abs(point.y - start.y)) + ), }; }, [] ); + const clampRegionRect = useCallback((rect: DetailResultLocalEditRect) => { + const width = Math.min(1, Math.max(DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, rect.width)); + const height = Math.min(1, Math.max(DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, rect.height)); + return { + x: Math.min(1 - width, Math.max(0, rect.x)), + y: Math.min(1 - height, Math.max(0, rect.y)), + width, + height, + }; + }, []); + + const updateRegionRect = useCallback( + (regionId: string, rect: DetailResultLocalEditRect) => { + const nextRect = clampRegionRect(rect); + setRegions((current) => + current.map((region) => + region.id === regionId ? { ...region, rect: nextRect } : region + ) + ); + }, + [clampRegionRect] + ); + + const calculateInteractionRect = useCallback( + ( + interaction: DetailResultLocalEditRegionInteraction, + point: { x: number; y: number } + ) => { + const dx = point.x - interaction.startPoint.x; + const dy = point.y - interaction.startPoint.y; + const start = interaction.startRect; + + if (interaction.handle === "move") { + return clampRegionRect({ + ...start, + x: start.x + dx, + y: start.y + dy, + }); + } + + const leftHandle = interaction.handle === "nw" || interaction.handle === "sw"; + const rightHandle = interaction.handle === "ne" || interaction.handle === "se"; + const topHandle = interaction.handle === "nw" || interaction.handle === "ne"; + const bottomHandle = interaction.handle === "sw" || interaction.handle === "se"; + + let left = start.x; + let right = start.x + start.width; + let top = start.y; + let bottom = start.y + start.height; + + if (leftHandle) left = Math.min(right - DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, start.x + dx); + if (rightHandle) right = Math.max(left + DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, right + dx); + if (topHandle) top = Math.min(bottom - DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, start.y + dy); + if (bottomHandle) bottom = Math.max(top + DETAIL_LOCAL_EDIT_MIN_RECT_RATIO, bottom + dy); + + left = Math.max(0, left); + top = Math.max(0, top); + right = Math.min(1, right); + bottom = Math.min(1, bottom); + + return clampRegionRect({ + x: left, + y: top, + width: right - left, + height: bottom - top, + }); + }, + [clampRegionRect] + ); + const updateDraftRect = useCallback( (event: React.PointerEvent) => { const start = dragStartRef.current; @@ -1077,7 +1190,10 @@ function DetailResultLocalEditDialog({ event.currentTarget.releasePointerCapture(event.pointerId); } - if (nextRect.width < 0.025 || nextRect.height < 0.025) { + if ( + nextRect.width < DETAIL_LOCAL_EDIT_MIN_RECT_RATIO || + nextRect.height < DETAIL_LOCAL_EDIT_MIN_RECT_RATIO + ) { toast.info("请拖拽框选更明确的编辑区域"); return; } @@ -1102,11 +1218,73 @@ function DetailResultLocalEditDialog({ [createRectFromPoints, getPoint, isScaleAdjustMode, regions.length, selectedScalePercent] ); + const handleRegionInteractionStart = useCallback( + ( + event: React.PointerEvent, + region: LocalEditRegion, + fallbackHandle: DetailResultLocalEditRegionHandle = "move" + ) => { + if (isBusy) return; + const targetElement = event.target as HTMLElement | null; + const requestedHandle = targetElement?.dataset + ?.detailRegionHandle as DetailResultLocalEditRegionHandle | undefined; + const handle = requestedHandle || fallbackHandle; + event.preventDefault(); + event.stopPropagation(); + setActiveRegionId(region.id); + dragStartRef.current = null; + setDraftRect(null); + event.currentTarget.setPointerCapture(event.pointerId); + setRegionInteraction({ + handle, + pointerId: event.pointerId, + regionId: region.id, + startPoint: getPoint(event), + startRect: region.rect, + }); + }, + [getPoint, isBusy] + ); + + const handleRegionInteractionMove = useCallback( + (event: React.PointerEvent, regionId: string) => { + if ( + !regionInteraction || + regionInteraction.pointerId !== event.pointerId || + regionInteraction.regionId !== regionId + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + updateRegionRect(regionId, calculateInteractionRect(regionInteraction, getPoint(event))); + }, + [calculateInteractionRect, getPoint, regionInteraction, updateRegionRect] + ); + + const handleRegionInteractionEnd = useCallback( + (event: React.PointerEvent, regionId: string) => { + if ( + regionInteraction && + regionInteraction.pointerId === event.pointerId && + regionInteraction.regionId === regionId + ) { + event.preventDefault(); + event.stopPropagation(); + updateRegionRect(regionId, calculateInteractionRect(regionInteraction, getPoint(event))); + setRegionInteraction(null); + } + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }, + [calculateInteractionRect, getPoint, regionInteraction, updateRegionRect] + ); + const completionCopy = target?.resultModeType === "detail_replacement" ? "处理完成后会更新当前套详情结果图。" : "处理完成后会加入 AI详情结果区。"; - const isBusy = submitting || Boolean(uploadingRegionId); const dialogTitle = isScaleAdjustMode ? "比例调整详情图" : "局部编辑详情图"; const dialogDescription = isScaleAdjustMode ? "框选要调整比例的物体,选择固定放大或缩小幅度后生成。" @@ -1123,10 +1301,26 @@ function DetailResultLocalEditDialog({
- {target?.imageUrl ? ( -
{ + {activePreviewResult?.resultImageUrl ? ( +
+ +
+ + 处理完成 +
+
+ ) : target?.imageUrl ? ( +
{ updateDraftRect(event); dragStartRef.current = null; setDraftRect(null); @@ -1163,24 +1357,36 @@ function DetailResultLocalEditDialog({ draggable={false} src={buildImageProxyUrl(target.imageUrl, { width: 1200, quality: 88 })} /> - {regions.map((region) => { - const isActive = region.id === activeRegionId; - return ( - - ); - })} + > + + {region.index} + + {isActive + ? ( + [ + { + handle: "nw", + className: + "-left-[6px] -top-[6px] cursor-nwse-resize", + }, + { + handle: "ne", + className: + "-right-[6px] -top-[6px] cursor-nesw-resize", + }, + { + handle: "sw", + className: + "-bottom-[6px] -left-[6px] cursor-nesw-resize", + }, + { + handle: "se", + className: + "-bottom-[6px] -right-[6px] cursor-nwse-resize", + }, + ] as const + ).map((item) => ( + + )) + : null} + + ); + })} {draftRect ? (
- {isScaleAdjustMode ? ( + {!activePreviewResult && isScaleAdjustMode ? (
Scale @@ -1257,7 +1499,17 @@ function DetailResultLocalEditDialog({
) : null} - {regions.length === 0 ? ( + {activePreviewResult ? ( +
+
+ + 处理结果已生成 +
+
+ 左侧展示的是本次处理后的图片。可以开启划线对比检查原图与新图差异;不满意可重新处理,满意后再确认应用到结果区。 +
+
+ ) : regions.length === 0 ? (
{isScaleAdjustMode ? "在左侧图片上拖拽框选物体后,选择一个比例即可生成。" @@ -1413,29 +1665,54 @@ function DetailResultLocalEditDialog({
)}
- - + {activePreviewResult ? ( + <> + + + + ) : ( + <> + + + + )}
@@ -2499,6 +2776,8 @@ export function AgentGenerationWorkspaceDarkV2({ const [sceneResizeEditorTargetUrl, setSceneResizeEditorTargetUrl] = useState(null); const [detailLocalEditTarget, setDetailLocalEditTarget] = useState(null); + const [detailLocalEditPreviewResult, setDetailLocalEditPreviewResult] = + useState(null); const [detailLocalEditSubmittingId, setDetailLocalEditSubmittingId] = useState(null); const [detailLocalEditSubmittingMode, setDetailLocalEditSubmittingMode] = @@ -3506,6 +3785,15 @@ export function AgentGenerationWorkspaceDarkV2({ }; }, []); + useEffect(() => { + setDetailLocalEditPreviewResult(null); + }, [ + detailLocalEditTarget?.cardId, + detailLocalEditTarget?.imageUrl, + detailLocalEditTarget?.mode, + detailLocalEditTarget?.resultModeType, + ]); + useEffect(() => { if (isWorkflowModelLocked) { setSelectedModelId((current) => (current === "auto" ? current : "auto")); @@ -3957,6 +4245,17 @@ export function AgentGenerationWorkspaceDarkV2({ ), [detailGenerationRunResults, normalizedDeletedResultCollections.detail_generation] ); + const detailGenerationCompareSourceByUrl = useMemo(() => { + const entries: Record = {}; + detailGenerationRunResults.forEach((item) => { + const resultUrl = String(item.originalUrl || item.thumbnailUrl || "").trim(); + const sourceUrl = normalizeResultCompareSourceUrl(resultUrl, item.compareSourceUrl); + if (resultUrl && sourceUrl) { + entries[resultUrl] = sourceUrl; + } + }); + return entries; + }, [detailGenerationRunResults]); const currentLiveResultUrls = currentResultModeType === "detail_generation" ? detailGenerationRunResultUrls @@ -4332,7 +4631,8 @@ export function AgentGenerationWorkspaceDarkV2({ index, url, compareSourceUrl: url - ? normalizeResultCompareSourceUrl(url, resultCompareSourceByUrl[url]) + ? normalizeResultCompareSourceUrl(url, resultCompareSourceByUrl[url]) || + normalizeResultCompareSourceUrl(url, detailGenerationCompareSourceByUrl[url]) : null, status: url ? ("completed" as const) @@ -4351,6 +4651,7 @@ export function AgentGenerationWorkspaceDarkV2({ [ activeResultModeType, activeResultModeUrls, + detailGenerationCompareSourceByUrl, detailGenerationProgress.status, gridPendingRemainingCount, gridResultCardCount, @@ -5607,10 +5908,10 @@ export function AgentGenerationWorkspaceDarkV2({ 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 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) { @@ -5629,9 +5930,51 @@ export function AgentGenerationWorkspaceDarkV2({ delete next[normalizedUrl]; return next; }); - delete processedResultHintTimerRef.current[normalizedUrl]; - }, 2800); - }, []); + delete processedResultHintTimerRef.current[normalizedUrl]; + }, 2800); + }, []); + + const updateDetailGenerationProcessedResult = useCallback( + (params: { + cardId?: string | null; + previousUrl: string; + nextUrl: string; + compareSourceUrl?: string | null; + }) => { + const cardId = String(params.cardId || "").trim(); + const previousUrl = String(params.previousUrl || "").trim(); + const nextUrl = String(params.nextUrl || "").trim(); + if (!/^https?:\/\//i.test(nextUrl)) return; + + const sourceUrl = normalizeResultCompareSourceUrl( + nextUrl, + params.compareSourceUrl || previousUrl + ); + registerResultCompareSource(nextUrl, sourceUrl || previousUrl); + + setDetailGenerationRunResults((current) => { + let changed = false; + const nextItems = current.map((item) => { + const originalUrl = String(item.originalUrl || "").trim(); + const thumbnailUrl = String(item.thumbnailUrl || "").trim(); + const isTarget = + originalUrl === previousUrl || + thumbnailUrl === previousUrl || + (Boolean(cardId) && (originalUrl === cardId || thumbnailUrl === cardId)); + if (!isTarget) return item; + changed = true; + return { + ...item, + originalUrl: nextUrl, + thumbnailUrl: nextUrl, + compareSourceUrl: sourceUrl || previousUrl || null, + }; + }); + return changed ? nextItems : current; + }); + }, + [registerResultCompareSource] + ); const handleEnhanceDetailGenerationResult = useCallback( async (params: { cardId: string; @@ -5673,16 +6016,22 @@ export function AgentGenerationWorkspaceDarkV2({ setActiveDetailReferenceCardId(cardId); setSelectedResultUrl(nextUrl); } else { - if ( - !appendResultUrlToCollection("detail_generation", nextUrl, { - compareSourceUrl: imageUrl, - }) - ) { - throw new Error("当前项目未接入结果保存能力"); - } - setActiveResultModeType("detail_generation"); - setSelectedResultUrl(nextUrl); - } + if ( + !appendResultUrlToCollection("detail_generation", nextUrl, { + compareSourceUrl: imageUrl, + }) + ) { + throw new Error("当前项目未接入结果保存能力"); + } + updateDetailGenerationProcessedResult({ + cardId, + previousUrl: imageUrl, + nextUrl, + compareSourceUrl: imageUrl, + }); + setActiveResultModeType("detail_generation"); + setSelectedResultUrl(nextUrl); + } toast.success( resultModeType === "detail_replacement" ? "高清处理完成,已更新当前套详情结果图" @@ -5700,13 +6049,14 @@ export function AgentGenerationWorkspaceDarkV2({ } }, [ - appendResultUrlToCollection, - detailEnhancingCardIds, - handleUpdateReplacementResultRecord, - ] - ); - const handleSubmitDetailLocalEdit = useCallback( - async (params: DetailResultLocalEditSubmitParams) => { + appendResultUrlToCollection, + detailEnhancingCardIds, + handleUpdateReplacementResultRecord, + updateDetailGenerationProcessedResult, + ] + ); + const handleSubmitDetailLocalEdit = useCallback( + async (params: DetailResultLocalEditSubmitParams) => { const target = detailLocalEditTarget; const cardId = String(target?.cardId || "").trim(); const imageUrl = String(params.imageUrl || "").trim(); @@ -5766,48 +6116,27 @@ 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 (resultModeType === "detail_replacement") { - handleUpdateReplacementResultRecord({ - modeType: "detail_replacement", + 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, - url: nextUrl, - text: isScaleAdjust ? "已完成比例调整处理。" : "已完成局部编辑处理。", - status: "completed", - compareSourceUrl: imageUrl, + resultModeType, + sourceImageUrl: imageUrl, + resultImageUrl: nextUrl, + mode: editMode, + scalePercent: params.scalePercent, + title: params.title || target.title, }); - setActiveResultModeType("detail_replacement"); - setActiveDetailReferenceCardId(cardId); - setSelectedResultUrl(nextUrl); - } else { - if ( - !appendResultUrlToCollection("detail_generation", nextUrl, { - compareSourceUrl: imageUrl, - }) - ) { - throw new Error("当前项目未接入结果保存能力"); - } - setActiveResultModeType("detail_generation"); - setSelectedResultUrl(nextUrl); - } - showProcessedResultHint( - nextUrl, - isScaleAdjust ? "比例调整完成" : "局部编辑完成" - ); - setDetailLocalEditTarget(null); - toast.success( - resultModeType === "detail_replacement" - ? `${isScaleAdjust ? "比例调整" : "局部编辑"}完成,已更新当前套详情结果图` - : `${isScaleAdjust ? "比例调整" : "局部编辑"}完成,已加入 AI详情结果区` - ); - return; - } + toast.success( + `${isScaleAdjust ? "比例调整" : "局部编辑"}完成,请在弹窗中确认结果` + ); + return; + } if (["failed", "cancelled", "expired", "banned"].includes(status)) { throw new Error(task.error || `${isScaleAdjust ? "比例调整" : "局部编辑"}生成失败`); @@ -5832,16 +6161,77 @@ export function AgentGenerationWorkspaceDarkV2({ setDetailLocalEditSubmittingMode(null); } }, - [ - appendResultUrlToCollection, - detailLocalEditSubmittingId, - detailLocalEditTarget, - handleUpdateReplacementResultRecord, - showProcessedResultHint, - ] - ); - const handlePsdDerivedImageCreated = useCallback( - (imageUrl: string) => { + [ + detailLocalEditSubmittingId, + detailLocalEditTarget, + ] + ); + + const handleConfirmDetailLocalEditResult = useCallback(() => { + const preview = detailLocalEditPreviewResult; + if (!preview) return; + const cardId = String(preview.cardId || "").trim(); + const sourceImageUrl = String(preview.sourceImageUrl || "").trim(); + const resultImageUrl = String(preview.resultImageUrl || "").trim(); + const isScaleAdjust = preview.mode === "scale_adjust"; + if (!cardId || !/^https?:\/\//i.test(sourceImageUrl) || !/^https?:\/\//i.test(resultImageUrl)) { + toast.error("处理结果地址无效,无法应用"); + return; + } + + if (preview.resultModeType === "detail_replacement") { + handleUpdateReplacementResultRecord({ + modeType: "detail_replacement", + cardId, + url: resultImageUrl, + text: isScaleAdjust ? "已完成比例调整处理。" : "已完成局部编辑处理。", + status: "completed", + compareSourceUrl: sourceImageUrl, + }); + setActiveResultModeType("detail_replacement"); + setActiveDetailReferenceCardId(cardId); + setSelectedResultUrl(resultImageUrl); + } else { + if ( + !appendResultUrlToCollection("detail_generation", resultImageUrl, { + compareSourceUrl: sourceImageUrl, + }) + ) { + toast.error("当前项目未接入结果保存能力"); + return; + } + updateDetailGenerationProcessedResult({ + cardId, + previousUrl: sourceImageUrl, + nextUrl: resultImageUrl, + compareSourceUrl: sourceImageUrl, + }); + setActiveResultModeType("detail_generation"); + setSelectedResultUrl(resultImageUrl); + } + + showProcessedResultHint(resultImageUrl, isScaleAdjust ? "比例调整完成" : "局部编辑完成"); + setDetailLocalEditPreviewResult(null); + setDetailLocalEditTarget(null); + toast.success( + preview.resultModeType === "detail_replacement" + ? `${isScaleAdjust ? "比例调整" : "局部编辑"}结果已更新当前套详情图` + : `${isScaleAdjust ? "比例调整" : "局部编辑"}结果已加入 AI详情结果区` + ); + }, [ + appendResultUrlToCollection, + detailLocalEditPreviewResult, + handleUpdateReplacementResultRecord, + showProcessedResultHint, + updateDetailGenerationProcessedResult, + ]); + + const handleResetDetailLocalEditPreview = useCallback(() => { + setDetailLocalEditPreviewResult(null); + }, []); + + const handlePsdDerivedImageCreated = useCallback( + (imageUrl: string) => { appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl, { compareSourceUrl: psdTemplateDialogSourceUrl, }); @@ -10170,22 +10560,26 @@ export function AgentGenerationWorkspaceDarkV2({ ) : null} - { - if (detailLocalEditSubmittingId) { + { + if (detailLocalEditSubmittingId) { toast.info( detailLocalEditSubmittingMode === "scale_adjust" ? "比例调整正在处理中,请稍候" : "局部编辑正在处理中,请稍候" ); - return; - } - setDetailLocalEditTarget(null); - }} - onSubmit={handleSubmitDetailLocalEdit} - /> + return; + } + setDetailLocalEditPreviewResult(null); + setDetailLocalEditTarget(null); + }} + onConfirmResult={handleConfirmDetailLocalEditResult} + onResetResult={handleResetDetailLocalEditPreview} + onSubmit={handleSubmitDetailLocalEdit} + /> {/* Fullscreen Preview */} diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index b3237fcdd..887bdb379 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -22,6 +22,7 @@ import { Link2, Maximize2, MessageSquarePlus, + Minimize2, MoreHorizontal, MoreVertical, Pencil, @@ -264,6 +265,7 @@ const AI_CLOUD_IMAGE_ACTIONS = new Set([ "layer-split", "box-cutout", "local-edit", + "scale-adjust", "text-edit", "prompt-regenerate", "generate-3d-model", @@ -392,6 +394,40 @@ type LocalEditRegionInteractionState = { startRect: NormalizedCropRect; }; +type CanvasLocalEditOperationMode = "local_edit" | "scale_adjust"; +type CanvasScaleAdjustPercent = -30 | -20 | -10 | 10 | 20 | 30; + +const CANVAS_SCALE_ADJUST_OPTIONS: CanvasScaleAdjustPercent[] = [ + -30, + -20, + -10, + 10, + 20, + 30, +]; + +function getCanvasScaleAdjustLabel(scalePercent: CanvasScaleAdjustPercent) { + return scalePercent > 0 ? `放大 ${scalePercent}%` : `缩小 ${Math.abs(scalePercent)}%`; +} + +function getCanvasScaleAdjustTargetLabel(scalePercent: CanvasScaleAdjustPercent) { + return `${Math.round((1 + scalePercent / 100) * 100)}%`; +} + +function getCanvasScaleAdjustRegionPrompt(scalePercent: CanvasScaleAdjustPercent | null) { + if (!scalePercent) return "比例调整"; + const actionLabel = getCanvasScaleAdjustLabel(scalePercent); + const targetLabel = getCanvasScaleAdjustTargetLabel(scalePercent); + const scaleVerb = scalePercent > 0 ? "放大" : "缩小"; + return [ + `对框选范围内的主物体执行等比${actionLabel},调整后主体视觉尺寸必须约为原来的 ${targetLabel},需要肉眼明显看出已经${scaleVerb}。`, + "只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。", + scalePercent < 0 + ? "主体缩小后露出的背景、桌面、墙面、阴影和遮挡关系必须自然补全。" + : "主体放大后边缘、遮挡和透视必须自然衔接。", + ].join(" "); +} + type LocalEditPromptCompositionSnapshot = { end: number; start: number; @@ -1507,6 +1543,10 @@ export function ImageNode({ const [boxCutoutDragEnd, setBoxCutoutDragEnd] = useState(null); const [localEditMode, setLocalEditMode] = useState(false); + const [localEditOperationMode, setLocalEditOperationMode] = + useState("local_edit"); + const [selectedLocalEditScalePercent, setSelectedLocalEditScalePercent] = + useState(null); const [localEditRegions, setLocalEditRegions] = useState( () => getLocalEditRegionsFromMetadata(data.metadata) ); @@ -2429,6 +2469,8 @@ export function ImageNode({ const resetLocalEditState = useCallback(() => { setLocalEditMode(false); + setLocalEditOperationMode("local_edit"); + setSelectedLocalEditScalePercent(null); setIsLocalEditDrawing(false); setLocalEditDragStart(null); setLocalEditDragEnd(null); @@ -3363,7 +3405,10 @@ export function ImageNode({ ); const handleEnterLocalEditMode = useCallback( - (event?: React.MouseEvent | React.PointerEvent) => { + ( + event?: React.MouseEvent | React.PointerEvent, + operationMode: CanvasLocalEditOperationMode = "local_edit" + ) => { event?.stopPropagation(); if (!data.src) { toast.error("请先上传图片"); @@ -3391,6 +3436,8 @@ export function ImageNode({ setNotePanelOpen(false); resetBoxCutoutState(); setLocalEditRegions(getLocalEditRegionsFromMetadata(data.metadata)); + setLocalEditOperationMode(operationMode); + setSelectedLocalEditScalePercent(operationMode === "scale_adjust" ? -30 : null); setLocalEditDragStart(null); setLocalEditDragEnd(null); setIsLocalEditDrawing(false); @@ -3398,7 +3445,11 @@ export function ImageNode({ window.requestAnimationFrame(() => { refreshBoxCutoutMetrics(); }); - toast.info("拖拽框选要局部编辑的位置,并在框下方填写提示词"); + toast.info( + operationMode === "scale_adjust" + ? "拖拽框选要调整比例的主体,并选择缩小或放大幅度" + : "拖拽框选要局部编辑的位置,并在框下方填写提示词" + ); }, [ data.metadata, @@ -3490,18 +3541,21 @@ export function ImageNode({ nextWidthPx >= LOCAL_EDIT_MIN_EDGE_PX && nextHeightPx >= LOCAL_EDIT_MIN_EDGE_PX ) { + const isScaleAdjustMode = localEditOperationMode === "scale_adjust"; const nextRegion: LocalEditRegion = { id: createLocalEditRegionId(), index: localEditRegions.length + 1, color: LOCAL_EDIT_COLORS[localEditRegions.length % LOCAL_EDIT_COLORS.length], - prompt: "", + prompt: isScaleAdjustMode + ? getCanvasScaleAdjustRegionPrompt(selectedLocalEditScalePercent) + : "", rect: nextRect, referenceImages: [], createdAt: Date.now(), }; commitLocalEditRegions([...localEditRegions, nextRegion]); - setEditingLocalEditRegionId(nextRegion.id); + setEditingLocalEditRegionId(isScaleAdjustMode ? null : nextRegion.id); } else { toast.info("框选区域太小,请至少拖出 24px 以上的区域"); } @@ -3515,7 +3569,9 @@ export function ImageNode({ getBoxCutoutPointerPoint, localEditDragStart, localEditMode, + localEditOperationMode, localEditRegions, + selectedLocalEditScalePercent, ] ); @@ -3832,12 +3888,15 @@ export function ImageNode({ return; } + const isScaleAdjustMode = localEditOperationMode === "scale_adjust"; const validRegions = reindexLocalEditRegions( localEditRegions.map((region) => ({ ...region, - prompt: ( - localEditPromptDraftRefs.current[region.id] ?? region.prompt - ).trim(), + prompt: isScaleAdjustMode + ? getCanvasScaleAdjustRegionPrompt(selectedLocalEditScalePercent) + : ( + localEditPromptDraftRefs.current[region.id] ?? region.prompt + ).trim(), })) ); @@ -3845,18 +3904,29 @@ export function ImageNode({ toast.error("请先框选至少一个局部编辑区域"); return; } - const emptyRegion = validRegions.find((region) => !region.prompt); - if (emptyRegion) { - setEditingLocalEditRegionId(emptyRegion.id); - toast.error(`请填写区域 ${emptyRegion.index} 的提示词`); - return; + if (isScaleAdjustMode) { + if (!selectedLocalEditScalePercent) { + toast.error("请选择放大或缩小比例"); + return; + } + } else { + const emptyRegion = validRegions.find((region) => !region.prompt); + if (emptyRegion) { + setEditingLocalEditRegionId(emptyRegion.id); + toast.error(`请填写区域 ${emptyRegion.index} 的提示词`); + return; + } } setIsSubmittingLocalEdit(true); setIsProcessing(true); commitLocalEditRegions(validRegions); - const actionName = "局部编辑"; + const actionName = isScaleAdjustMode ? "比例调整" : "局部编辑"; + const editMode = isScaleAdjustMode ? "scale-adjust" : "local-edit"; + const scalePercent = isScaleAdjustMode + ? selectedLocalEditScalePercent || undefined + : undefined; const newNodeId = addNode({ type: "image", position: { @@ -3871,7 +3941,8 @@ export function ImageNode({ title: `${displayTitle}-${actionName}`, originalSrc: data.src, metadata: { - editMode: "local-edit", + editMode, + scalePercent, sourceNodeId: node.id, localEditMode: false, sourceLocalEditRegions: validRegions, @@ -3885,6 +3956,8 @@ export function ImageNode({ const submitResult = await imageGenerationService.localEdit({ sourceImageUrl: data.src, sourceNodeId: node.id, + operation: isScaleAdjustMode ? "scale-adjust" : undefined, + scalePercent, regions: validRegions.map((region) => ({ id: region.id, index: region.index, @@ -3929,8 +4002,9 @@ export function ImageNode({ title: `${displayTitle}-${actionName}`, originalSrc: data.src, metadata: { - editMode: "local-edit", + editMode, sourceNodeId: node.id, + scalePercent, aspectRatio: submitResult.data.aspectRatio, taskId, annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl, @@ -3972,8 +4046,9 @@ export function ImageNode({ originalSrc: data.src, model: task.result?.model || task.result?.modelRef || undefined, metadata: { - editMode: "local-edit", + editMode, sourceNodeId: node.id, + scalePercent, aspectRatio: submitResult.data.aspectRatio, taskId, annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl, @@ -3986,7 +4061,7 @@ export function ImageNode({ }, }); resetLocalEditState(); - toast.success("局部编辑结果已生成"); + toast.success(`${actionName}结果已生成`); return; } @@ -4007,8 +4082,9 @@ export function ImageNode({ title: `${displayTitle}-${actionName}-失败`, originalSrc: data.src, metadata: { - editMode: "local-edit", + editMode, sourceNodeId: node.id, + scalePercent, error: message, localEditMode: false, sourceLocalEditRegions: validRegions, @@ -4027,6 +4103,7 @@ export function ImageNode({ displayTitle, isProcessing, isSubmittingLocalEdit, + localEditOperationMode, localEditRegions, node.id, node.position.x, @@ -4035,6 +4112,7 @@ export function ImageNode({ node.size.width, resetLocalEditState, selectNode, + selectedLocalEditScalePercent, updateNode, ]); @@ -6013,7 +6091,7 @@ export function ImageNode({ return; } setAiWashQuickPanelOpen(false); - if (action !== "local-edit") { + if (action !== "local-edit" && action !== "scale-adjust") { resetLocalEditState(); } @@ -6126,6 +6204,10 @@ export function ImageNode({ handleEnterLocalEditMode(); return; } + if (action === "scale-adjust") { + handleEnterLocalEditMode(undefined, "scale_adjust"); + return; + } if (action === "annotate") { setCropPanelOpen(false); setCropInteraction(null); @@ -8147,7 +8229,7 @@ export function ImageNode({ style={style} >
- {isEditing ? ( + {isEditing && localEditOperationMode !== "scale_adjust" ? ( { - event.preventDefault(); - event.nativeEvent.stopImmediatePropagation?.(); - event.stopPropagation(); - setEditingLocalEditRegionId(region.id); - }} + onClick={(event) => { + event.preventDefault(); + event.nativeEvent.stopImmediatePropagation?.(); + event.stopPropagation(); + if (localEditOperationMode !== "scale_adjust") { + setEditingLocalEditRegionId(region.id); + } + }} onMouseDown={(event) => { event.preventDefault(); event.nativeEvent.stopImmediatePropagation?.(); event.stopPropagation(); }} - onPointerDown={(event) => { - event.preventDefault(); - event.nativeEvent.stopImmediatePropagation?.(); - event.stopPropagation(); - setEditingLocalEditRegionId(region.id); - }} - title={region.prompt} - type="button" - > - {region.index}. {region.prompt} - - )} + onPointerDown={(event) => { + event.preventDefault(); + event.nativeEvent.stopImmediatePropagation?.(); + event.stopPropagation(); + if (localEditOperationMode !== "scale_adjust") { + setEditingLocalEditRegionId(region.id); + } + }} + title={ + localEditOperationMode === "scale_adjust" + ? selectedLocalEditScalePercent + ? getCanvasScaleAdjustLabel(selectedLocalEditScalePercent) + : "请选择比例" + : region.prompt + } + type="button" + > + {localEditOperationMode === "scale_adjust" + ? `${region.index}. ${ + selectedLocalEditScalePercent + ? getCanvasScaleAdjustLabel( + selectedLocalEditScalePercent + ) + : "请选择比例" + }` + : `${region.index}. ${region.prompt}`} + + )} - + +
- + ); + })} +
+ ) : null} + + - ) : null} + + + ) : null} - {isSelectedImageActionInline("removeWatermark") ? ( + {isSelectedImageActionInline("localEdit") ? ( + + ) : null} + + {isSelectedImageActionInline("removeWatermark") ? (