feat: improve canvas local edit scale adjustment
This commit is contained in:
parent
07854d7c6a
commit
f29a26b43f
@ -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<LocalEditRegion[]>([]);
|
||||
const [draftRect, setDraftRect] = useState<DetailResultLocalEditRect | null>(null);
|
||||
const [activeRegionId, setActiveRegionId] = useState<string | null>(null);
|
||||
const [regionInteraction, setRegionInteraction] =
|
||||
useState<DetailResultLocalEditRegionInteraction | null>(null);
|
||||
const [uploadingRegionId, setUploadingRegionId] = useState<string | null>(null);
|
||||
const [selectedScalePercent, setSelectedScalePercent] =
|
||||
useState<DetailResultScalePercent | null>(null);
|
||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const editSurfaceRef = useRef<HTMLDivElement | null>(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<HTMLDivElement>) => {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const getPoint = useCallback((event: React.PointerEvent<HTMLElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLButtonElement>,
|
||||
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<HTMLButtonElement>, 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<HTMLButtonElement>, 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({
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="flex min-h-[520px] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35 p-3">
|
||||
{target?.imageUrl ? (
|
||||
<div
|
||||
className="relative max-h-[70vh] max-w-full touch-none select-none overflow-hidden rounded-xl"
|
||||
onPointerCancel={(event) => {
|
||||
{activePreviewResult?.resultImageUrl ? (
|
||||
<div className="relative max-h-[70vh] max-w-full overflow-hidden rounded-xl">
|
||||
<DetailResultCompareImage
|
||||
alt={`${activePreviewResult.title || target?.title || "详情图"} 处理结果`}
|
||||
compareSourceUrl={activePreviewResult.sourceImageUrl}
|
||||
imageClassName="block max-h-[70vh] max-w-full object-contain"
|
||||
previewQuality={88}
|
||||
previewWidth={1200}
|
||||
resultUrl={activePreviewResult.resultImageUrl}
|
||||
/>
|
||||
<div className="pointer-events-none absolute left-3 top-3 z-30 inline-flex items-center gap-1.5 rounded-full border border-emerald-200/45 bg-zinc-950/72 px-3 py-1.5 text-xs font-semibold text-emerald-50 shadow-[0_12px_30px_rgba(0,0,0,0.38)] backdrop-blur-md">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-300" />
|
||||
处理完成
|
||||
</div>
|
||||
</div>
|
||||
) : target?.imageUrl ? (
|
||||
<div
|
||||
className="relative max-h-[70vh] max-w-full touch-none select-none overflow-hidden rounded-xl"
|
||||
ref={editSurfaceRef}
|
||||
onPointerCancel={(event) => {
|
||||
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 (
|
||||
<button
|
||||
className="absolute rounded-sm border-2 bg-black/0 text-left transition"
|
||||
key={region.id}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setActiveRegionId(region.id);
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setActiveRegionId(region.id);
|
||||
}}
|
||||
style={{
|
||||
borderColor: region.color,
|
||||
{regions.map((region) => {
|
||||
const isActive = region.id === activeRegionId;
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"absolute rounded-sm border-2 bg-black/0 text-left transition",
|
||||
regionInteraction?.regionId === region.id
|
||||
? "cursor-grabbing"
|
||||
: "cursor-grab"
|
||||
)}
|
||||
key={region.id}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setActiveRegionId(region.id);
|
||||
}}
|
||||
onPointerCancel={(event) =>
|
||||
handleRegionInteractionEnd(event, region.id)
|
||||
}
|
||||
onPointerDown={(event) =>
|
||||
handleRegionInteractionStart(event, region)
|
||||
}
|
||||
onPointerMove={(event) =>
|
||||
handleRegionInteractionMove(event, region.id)
|
||||
}
|
||||
onPointerUp={(event) =>
|
||||
handleRegionInteractionEnd(event, region.id)
|
||||
}
|
||||
style={{
|
||||
borderColor: region.color,
|
||||
boxShadow: isActive
|
||||
? `0 0 0 9999px rgba(0,0,0,0.20), 0 0 0 3px ${region.color}55`
|
||||
: "0 0 0 9999px rgba(0,0,0,0.08)",
|
||||
@ -1191,16 +1397,52 @@ function DetailResultLocalEditDialog({
|
||||
}}
|
||||
title={`区域 ${region.index}`}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="absolute -left-2 -top-2 flex h-6 min-w-6 items-center justify-center rounded-full px-1.5 text-[11px] font-bold text-white shadow-lg"
|
||||
style={{ backgroundColor: region.color }}
|
||||
>
|
||||
{region.index}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className="absolute -left-2 -top-2 flex h-6 min-w-6 items-center justify-center rounded-full px-1.5 text-[11px] font-bold text-white shadow-lg"
|
||||
style={{ backgroundColor: region.color }}
|
||||
>
|
||||
{region.index}
|
||||
</span>
|
||||
{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) => (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute h-3 w-3 rounded-[2px] border-2 bg-white shadow-[0_3px_10px_rgba(0,0,0,0.28)]",
|
||||
item.className
|
||||
)}
|
||||
data-detail-region-handle={item.handle}
|
||||
key={item.handle}
|
||||
style={{ borderColor: region.color }}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{draftRect ? (
|
||||
<div
|
||||
className="pointer-events-none absolute border-2 border-sky-300 bg-sky-400/20 shadow-[0_0_0_9999px_rgba(0,0,0,0.24)]"
|
||||
@ -1227,7 +1469,7 @@ function DetailResultLocalEditDialog({
|
||||
? `拖拽框选物体,建议留出少量周边背景,最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`
|
||||
: `拖拽图片添加区域。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域,每个区域最多 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图。${completionCopy}`}
|
||||
</div>
|
||||
{isScaleAdjustMode ? (
|
||||
{!activePreviewResult && isScaleAdjustMode ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3">
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
|
||||
Scale
|
||||
@ -1257,7 +1499,17 @@ function DetailResultLocalEditDialog({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{regions.length === 0 ? (
|
||||
{activePreviewResult ? (
|
||||
<div className="flex min-h-[260px] flex-col justify-center rounded-2xl border border-emerald-200/18 bg-emerald-400/[0.07] p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-emerald-50">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-300" />
|
||||
处理结果已生成
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-white/58">
|
||||
左侧展示的是本次处理后的图片。可以开启划线对比检查原图与新图差异;不满意可重新处理,满意后再确认应用到结果区。
|
||||
</div>
|
||||
</div>
|
||||
) : regions.length === 0 ? (
|
||||
<div className="flex min-h-[260px] items-center justify-center rounded-2xl border border-dashed border-white/12 bg-white/[0.02] px-5 text-center text-sm text-white/38">
|
||||
{isScaleAdjustMode
|
||||
? "在左侧图片上拖拽框选物体后,选择一个比例即可生成。"
|
||||
@ -1413,29 +1665,54 @@ function DetailResultLocalEditDialog({
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto flex justify-end gap-2">
|
||||
<Button
|
||||
className="border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
|
||||
disabled={submitting}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-white text-black hover:bg-white/90"
|
||||
disabled={
|
||||
submitting ||
|
||||
Boolean(uploadingRegionId) ||
|
||||
regions.length === 0 ||
|
||||
(isScaleAdjustMode && !selectedScalePercent)
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
>
|
||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{isScaleAdjustMode ? "生成比例调整" : "生成局部编辑"}
|
||||
</Button>
|
||||
{activePreviewResult ? (
|
||||
<>
|
||||
<Button
|
||||
className="border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
|
||||
disabled={submitting}
|
||||
onClick={onResetResult}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
重新处理
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-white text-black hover:bg-white/90"
|
||||
disabled={submitting}
|
||||
onClick={onConfirmResult}
|
||||
type="button"
|
||||
>
|
||||
确认应用结果
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
|
||||
disabled={submitting}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-white text-black hover:bg-white/90"
|
||||
disabled={
|
||||
submitting ||
|
||||
Boolean(uploadingRegionId) ||
|
||||
regions.length === 0 ||
|
||||
(isScaleAdjustMode && !selectedScalePercent)
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
>
|
||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{isScaleAdjustMode ? "生成比例调整" : "生成局部编辑"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -2499,6 +2776,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const [sceneResizeEditorTargetUrl, setSceneResizeEditorTargetUrl] = useState<string | null>(null);
|
||||
const [detailLocalEditTarget, setDetailLocalEditTarget] =
|
||||
useState<DetailResultLocalEditTarget | null>(null);
|
||||
const [detailLocalEditPreviewResult, setDetailLocalEditPreviewResult] =
|
||||
useState<DetailResultLocalEditPreviewResult | null>(null);
|
||||
const [detailLocalEditSubmittingId, setDetailLocalEditSubmittingId] =
|
||||
useState<string | null>(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<string, string> = {};
|
||||
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({
|
||||
</ClientErrorBoundary>
|
||||
) : null}
|
||||
|
||||
<DetailResultLocalEditDialog
|
||||
submitting={Boolean(detailLocalEditSubmittingId)}
|
||||
target={detailLocalEditTarget}
|
||||
onClose={() => {
|
||||
if (detailLocalEditSubmittingId) {
|
||||
<DetailResultLocalEditDialog
|
||||
previewResult={detailLocalEditPreviewResult}
|
||||
submitting={Boolean(detailLocalEditSubmittingId)}
|
||||
target={detailLocalEditTarget}
|
||||
onClose={() => {
|
||||
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 */}
|
||||
<AnimatePresence>
|
||||
|
||||
@ -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<BoxCutoutPoint | null>(null);
|
||||
const [localEditMode, setLocalEditMode] = useState(false);
|
||||
const [localEditOperationMode, setLocalEditOperationMode] =
|
||||
useState<CanvasLocalEditOperationMode>("local_edit");
|
||||
const [selectedLocalEditScalePercent, setSelectedLocalEditScalePercent] =
|
||||
useState<CanvasScaleAdjustPercent | null>(null);
|
||||
const [localEditRegions, setLocalEditRegions] = useState<LocalEditRegion[]>(
|
||||
() => 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}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isEditing ? (
|
||||
{isEditing && localEditOperationMode !== "scale_adjust" ? (
|
||||
<input
|
||||
autoFocus={editingLocalEditRegionId === region.id}
|
||||
className="nodrag nopan nowheel h-9 min-w-0 flex-1 rounded-full border border-slate-300 bg-white/95 px-3 text-[12px] font-medium leading-5 text-slate-950 caret-slate-950 shadow-[0_10px_24px_rgba(15,23,42,0.14)] outline-none ring-2 ring-transparent transition placeholder:text-slate-400 focus:border-slate-950 focus:ring-slate-950/15 dark:border-white/20 dark:bg-slate-950/88 dark:text-white dark:caret-white dark:placeholder:text-white/42 dark:focus:border-white/40 dark:focus:ring-white/18"
|
||||
@ -8262,29 +8344,47 @@ export function ImageNode({
|
||||
className="nodrag nopan nowheel h-9 min-w-0 flex-1 truncate rounded-full border border-slate-200 bg-white/95 px-3 text-left text-[12px] font-semibold text-slate-900 shadow-[0_10px_24px_rgba(15,23,42,0.14)] hover:bg-slate-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-slate-900"
|
||||
data-canvas-no-drag="true"
|
||||
data-local-edit-control="true"
|
||||
onClick={(event) => {
|
||||
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}
|
||||
</button>
|
||||
)}
|
||||
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}`}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
||||
className="hidden"
|
||||
@ -8301,8 +8401,11 @@ export function ImageNode({
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
<button
|
||||
className="nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-600 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-slate-950 hover:text-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-white dark:hover:text-slate-950"
|
||||
<button
|
||||
className={cn(
|
||||
"nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-600 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-slate-950 hover:text-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-white dark:hover:text-slate-950",
|
||||
localEditOperationMode === "scale_adjust" && "hidden"
|
||||
)}
|
||||
data-canvas-no-drag="true"
|
||||
data-local-edit-control="true"
|
||||
disabled={
|
||||
@ -8861,27 +8964,101 @@ export function ImageNode({
|
||||
scrollbarWidth: "none",
|
||||
}}
|
||||
>
|
||||
<div className="mr-1 inline-flex h-10 shrink-0 items-center gap-2 rounded-xl bg-slate-100 px-3 font-medium text-[13px] text-slate-700 dark:bg-zinc-800 dark:text-slate-100">
|
||||
<Wand2 className="h-4 w-4 text-slate-950 dark:text-white" />
|
||||
<span className="whitespace-nowrap">局部编辑</span>
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-slate-300">
|
||||
{localEditRegions.length}/{LOCAL_EDIT_MAX_REGIONS}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mr-1 inline-flex h-10 shrink-0 items-center gap-2 rounded-xl bg-slate-100 px-3 font-medium text-[13px] text-slate-700 dark:bg-zinc-800 dark:text-slate-100">
|
||||
<Wand2 className="h-4 w-4 text-slate-950 dark:text-white" />
|
||||
<span className="whitespace-nowrap">
|
||||
{localEditOperationMode === "scale_adjust" ? "比例调整" : "局部编辑"}
|
||||
</span>
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-slate-300">
|
||||
{localEditRegions.length}/{LOCAL_EDIT_MAX_REGIONS}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||||
<div className="inline-flex shrink-0 items-center gap-1 rounded-xl bg-slate-100 p-1 dark:bg-zinc-800">
|
||||
<Button
|
||||
className={cn(
|
||||
"h-8 rounded-lg px-2.5 text-[12px] font-semibold",
|
||||
localEditOperationMode === "local_edit"
|
||||
? "bg-white text-slate-950 shadow-sm dark:bg-white dark:text-slate-950"
|
||||
: "text-slate-500 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
)}
|
||||
disabled={isSubmittingLocalEdit}
|
||||
onClick={() => {
|
||||
setLocalEditOperationMode("local_edit");
|
||||
setSelectedLocalEditScalePercent(null);
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
局编
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-8 rounded-lg px-2.5 text-[12px] font-semibold",
|
||||
localEditOperationMode === "scale_adjust"
|
||||
? "bg-white text-slate-950 shadow-sm dark:bg-white dark:text-slate-950"
|
||||
: "text-slate-500 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
)}
|
||||
disabled={isSubmittingLocalEdit}
|
||||
onClick={() => {
|
||||
setLocalEditOperationMode("scale_adjust");
|
||||
setSelectedLocalEditScalePercent((current) => current || -30);
|
||||
setEditingLocalEditRegionId(null);
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
比例
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-950 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white disabled:bg-slate-300 disabled:text-slate-500 dark:border dark:border-white/14 dark:bg-black dark:text-white dark:disabled:border-transparent dark:disabled:bg-slate-700 dark:disabled:text-slate-400 dark:hover:bg-zinc-900"
|
||||
disabled={
|
||||
isSubmittingLocalEdit ||
|
||||
localEditRegions.length === 0
|
||||
}
|
||||
onClick={() => void handleSubmitLocalEdit()}
|
||||
title="生成局部编辑结果"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||||
|
||||
{localEditOperationMode === "scale_adjust" ? (
|
||||
<div className="inline-flex shrink-0 items-center gap-1">
|
||||
{CANVAS_SCALE_ADJUST_OPTIONS.map((option) => {
|
||||
const active = selectedLocalEditScalePercent === option;
|
||||
const Icon = option > 0 ? Maximize2 : Minimize2;
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 rounded-xl border px-2 text-[12px] font-semibold",
|
||||
active
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-300/45 dark:bg-emerald-400/16 dark:text-emerald-100"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 hover:text-slate-950 dark:border-white/12 dark:bg-zinc-900 dark:text-slate-200 dark:hover:bg-white/10"
|
||||
)}
|
||||
disabled={isSubmittingLocalEdit}
|
||||
key={option}
|
||||
onClick={() => setSelectedLocalEditScalePercent(option)}
|
||||
title={getCanvasScaleAdjustLabel(option)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="mr-1 h-3.5 w-3.5" />
|
||||
{option > 0 ? `+${option}%` : `${option}%`}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-950 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white disabled:bg-slate-300 disabled:text-slate-500 dark:border dark:border-white/14 dark:bg-black dark:text-white dark:disabled:border-transparent dark:disabled:bg-slate-700 dark:disabled:text-slate-400 dark:hover:bg-zinc-900"
|
||||
disabled={
|
||||
isSubmittingLocalEdit ||
|
||||
localEditRegions.length === 0 ||
|
||||
(localEditOperationMode === "scale_adjust" &&
|
||||
!selectedLocalEditScalePercent)
|
||||
}
|
||||
onClick={() => void handleSubmitLocalEdit()}
|
||||
title={
|
||||
localEditOperationMode === "scale_adjust"
|
||||
? "生成比例调整结果"
|
||||
: "生成局部编辑结果"
|
||||
}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isSubmittingLocalEdit ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
@ -9340,14 +9517,31 @@ export function ImageNode({
|
||||
item={imageLocalEditItem}
|
||||
/>
|
||||
{imageLocalEditItem.label}
|
||||
<ImageActionCreditBadge
|
||||
compact
|
||||
label={localEditCostShortLabel}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
<ImageActionCreditBadge
|
||||
compact
|
||||
label={localEditCostShortLabel}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{isSelectedImageActionInline("removeWatermark") ? (
|
||||
{isSelectedImageActionInline("localEdit") ? (
|
||||
<Button
|
||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing || isSubmittingLocalEdit}
|
||||
onClick={() => handleAction("scale-adjust")}
|
||||
title="比例调整"
|
||||
variant="ghost"
|
||||
>
|
||||
<Minimize2 className="mr-1.5 h-4 w-4" />
|
||||
比例调整
|
||||
<ImageActionCreditBadge
|
||||
compact
|
||||
label={localEditCostShortLabel}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{isSelectedImageActionInline("removeWatermark") ? (
|
||||
<Button
|
||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
@ -9617,11 +9811,18 @@ export function ImageNode({
|
||||
fallback={Wand2}
|
||||
item={imageLocalEditItem}
|
||||
/>
|
||||
<span>{imageLocalEditItem.label}</span>
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isSelectedImageActionInMore("removeWatermark") ? (
|
||||
<span>{imageLocalEditItem.label}</span>
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isSelectedImageActionInMore("localEdit") ? (
|
||||
<DropdownMenuItem onClick={() => handleAction("scale-adjust")}>
|
||||
<Minimize2 className="mr-2 h-4 w-4" />
|
||||
<span>比例调整</span>
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isSelectedImageActionInMore("removeWatermark") ? (
|
||||
<DropdownMenuItem onClick={() => handleAction("watermark")}>
|
||||
<CanvasToolbarIcon
|
||||
className="mr-2 h-4 w-4"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user