"use client";
import {
Box,
Camera,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Crosshair,
Crop,
Download,
Eraser,
Expand,
FileImage,
FlipHorizontal2,
FlipVertical2,
Globe2,
ImagePlus,
Layers,
LayoutTemplate,
Loader2,
Link2,
Maximize2,
MessageSquarePlus,
MoreHorizontal,
MoreVertical,
Pencil,
RefreshCw,
RotateCcw,
Scan,
Scissors,
SlidersHorizontal,
Sparkles,
StickyNote,
Trash2,
Type,
Unlink2,
Wand2,
X,
Zap,
} from "lucide-react";
import type React from "react";
import { useParams, useRouter } from "next/navigation";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import useSWR from "swr";
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
import { CanvasLayoutReplaceBatchDialog } from "@/components/canvas/CanvasLayoutReplaceBatchDialog";
import { TextEditColorField } from "@/components/canvas/TextEditColorField";
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
import {
areImageAdjustmentsEqual,
buildImageAdjustmentFilterStyle,
DEFAULT_IMAGE_ADJUSTMENTS,
ImageAdjustFloatingPanel,
type ImageAdjustmentState,
type ImageAdjustPanelTab,
isDefaultImageAdjustments,
normalizeImageAdjustments,
} from "@/components/canvas/nodes/image-node/image-node-adjustments";
import { ImageNodeAdjustOverlays } from "@/components/canvas/nodes/image-node/image-node-adjust-overlays";
import {
ImageNodeDeferredLoadState,
ImageNodeEmptyState,
ImageNodeProcessingOverlay,
} from "@/components/canvas/nodes/image-node/image-node-empty-state";
import {
BoxCutoutPoint,
createCenteredCropRectForAspect,
createCenteredCropRectFromSize,
createNormalizedRect,
CROP_PRESET_GROUPS,
type CropInteractionHandle,
type CropPanelGroupKey,
DEFAULT_CROP_PRESET_ID,
getCropPresetById,
getNormalizedMinCropSize,
updateCropRectWithPointer,
} from "@/components/canvas/nodes/image-node/image-node-crop";
import {
getImageNodeTitle,
guessNearestAspectRatio,
inferModelFormat,
inferTripoImageType,
normalizeImageIdentityUrl,
normalizeImageTitle,
normalizeMediaUrl,
resolveRegeneratePromptSeed,
resolveOriginalImageUrl,
} from "@/components/canvas/nodes/image-node/image-node-utils";
import {
createLocalEditPromptDraftMap,
createLocalEditReferenceImageId,
createLocalEditRegionId,
getLocalEditRegionsFromMetadata,
getNormalizedRectOverlayStyle,
hasScaleAdjustTargetChanged,
isHttpImageUrl,
LOCAL_EDIT_COLORS,
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION,
LOCAL_EDIT_MAX_REGIONS,
LOCAL_EDIT_MAX_POLL_ATTEMPTS,
LOCAL_EDIT_METADATA_KEY,
LOCAL_EDIT_MIN_EDGE_PX,
LOCAL_EDIT_POLL_INTERVAL_MS,
reindexLocalEditRegions,
type LocalEditReferenceImage,
type LocalEditRegion,
} from "@/components/canvas/nodes/image-node/image-node-local-edit";
import {
PromptRegenerateDialog,
ThreeDGenerationDialog,
} from "@/components/canvas/nodes/image-node/image-node-dialogs";
import { ImageNodeSelectedTitle } from "@/components/canvas/nodes/image-node/image-node-title";
import {
ImageAnnotator,
type ImageAnnotatorConfirmPayload,
} from "@/components/image-annotator";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import {
appendCanvasVisualAnnotationsMetadata,
syncCanvasLocalEditRegionAnnotationsMetadata,
syncCanvasNodeNoteAnnotationMetadata,
} from "@/lib/canvas/agent-annotation-metadata";
import {
dispatchCanvasAgentAnnotationEditRequest,
dispatchCanvasAgentOpenChatPanelRequest,
storeCanvasAgentAnnotationEditRequest,
type CanvasAgentPendingRequestWindow,
} from "@/lib/canvas/agent-pending-requests";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
downloadCanvasImageAsPng,
getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download";
import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2";
import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder";
import {
type DetailUiSubjectAnalysis,
normalizeDetailUiSubjectAnalysis,
} from "@/lib/detail-ui-subject-analysis";
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId,
resolveThreeDModelBillingId,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
import {
calculateEnhanceProPointsEstimate,
defaultPointsSystemConfig,
get3DTaskCreditCost,
get3DWorldCreditCost,
getImageToolCreditCost,
normalizePointsSystemConfig,
type ImageToolCreditFeature,
type PointsSystemConfig,
} from "@/lib/points/config";
import {
isArchivedWorldLabsAssetUrl,
pickBestArchivedWorldLabsSplatUrl,
} from "@/lib/worldlabs/splat-utils";
import {
getNodeContentScale,
getScaledNodeContentStyle,
} from "@/lib/canvas/node-content-scale";
import {
getSelectedImageInlineActionCount,
getCanvasToolbarItem,
isCanvasToolbarItemEnabled,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import {
AI_WASH_CONTROL_ORDER,
AI_WASH_DEFAULT_MODE_LABEL,
defaultAiWashConfig,
getEnabledAiWashPresets,
normalizeAiWashConfig,
type AiWashConfig,
type AiWashControl,
type AiWashControlId,
type AiWashControlLevel,
type AiWashPreset,
} from "@/lib/ai-wash/config";
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
import type { CanvasCapabilityClientAction } from "@/lib/canvas/capabilities/types";
import { cn, fetcher } from "@/lib/utils";
import type {
CanvasTextEditPayloadItem,
NormalizedCropRect,
} from "@/services/canvas";
import {
canvasService,
imageGenerationService,
model3DGenerationService,
worldLabsGenerationService,
} from "@/services/canvas";
import type { PickedObject } from "@/store/canvas";
import { useCanvasStore } from "@/store/canvas";
import type {
CanvasNode,
ImageNodeData,
ImageTextEditItem,
} from "@/types/canvas";
// 已标注的物品标记(本地状态)
interface PickedMarker {
clickX: number;
clickY: number;
objectName: string;
index: number;
}
interface ImageDisplayMetrics {
offsetX: number;
offsetY: number;
renderedWidth: number;
renderedHeight: number;
naturalWidth: number;
naturalHeight: number;
}
interface ImageOverlayRectStyle {
left: number;
top: number;
width: number;
height: number;
}
interface CanvasNodeAnnotationDisplayItem {
id: string;
type: "text-note" | "arrow-note" | "box" | "freehand" | "crop-box";
text?: string;
bounds?: {
x: number;
y: number;
width: number;
height: number;
};
targetPoint?: {
x: number;
y: number;
};
color?: string;
createdAt?: string;
createdByAgent?: boolean;
}
interface LayerSplitBounds {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
coverage: number;
}
const AI_CLOUD_IMAGE_ACTIONS = new Set([
"ai-wash",
"rmbg",
"watermark",
"enhance",
"extend",
"layer-split",
"box-cutout",
"local-edit",
"scale-adjust",
"text-edit",
"prompt-regenerate",
"generate-3d-model",
"generate-3d-world",
]);
type AiWashResultStatus =
| "idle"
| "submitting"
| "running"
| "succeeded"
| "failed";
type AiWashModelOption = {
id: string;
modelId: string;
displayName: string;
avatarUrl?: string | null;
providerName?: string;
providerType?: string;
isDefault?: boolean;
unitCost: number;
creditDiscountLabel?: string | null;
availabilityStatus?: "available" | "maintenance" | "testing";
availabilityLabel?: string | null;
selectable?: boolean;
disabledReason?: string | null;
};
type AiWashConfigResponse = {
success?: boolean;
config?: AiWashConfig;
lockedModels?: AiWashModelOption[];
};
type AiWashModelOptionsResponse = {
success?: boolean;
defaultModelId?: string | null;
models?: AiWashModelOption[];
};
type MembershipSummaryResponse = {
isHighVip?: boolean;
pointsSystemConfig?: PointsSystemConfig;
};
type ImageActionCreditBadgeProps = {
label?: string | null;
compact?: boolean;
className?: string;
};
function formatPointAmount(value: number) {
const normalized = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
return normalized.toLocaleString("en-US");
}
function formatImageNodeFeatureCostLabel({
cost,
isHighVip,
pointsEnabled,
}: {
cost: number;
isHighVip?: boolean;
pointsEnabled: boolean;
}) {
if (!pointsEnabled) return "不扣积分";
if (isHighVip) return "VIP免费";
if (cost <= 0) return "免费";
return `${formatPointAmount(cost)} 积分`;
}
function formatImageNodeFeatureCostShortLabel({
cost,
isHighVip,
pointsEnabled,
}: {
cost: number;
isHighVip?: boolean;
pointsEnabled: boolean;
}) {
if (!pointsEnabled) return null;
if (isHighVip) return "VIP";
if (cost <= 0) return "0";
return formatPointAmount(cost);
}
function ImageActionCreditBadge({
className,
compact = false,
label,
}: ImageActionCreditBadgeProps) {
if (!label) return null;
return (
{label}
);
}
type AngleEditPanelMode = "subject" | "camera";
type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
type CropInteractionState = {
handle: CropInteractionHandle;
pointerId: number;
startPoint: BoxCutoutPoint;
startRect: NormalizedCropRect;
};
type LocalEditRegionInteractionState = {
handle: CropInteractionHandle;
pointerId: number;
regionId: string;
target: "source" | "scaleTarget";
startPoint: BoxCutoutPoint;
startRect: NormalizedCropRect;
};
type CanvasLocalEditOperationMode = "local_edit" | "scale_adjust";
const CANVAS_SCALE_TARGET_MIN_PERCENT = -80;
const CANVAS_SCALE_TARGET_MAX_PERCENT = 200;
function getCanvasScaleAdjustRegionPrompt(
_scalePercent: number | null,
hasTargetPosition = false
) {
return [
"按目标框线对框选范围内的主物体执行等比比例调整。",
hasTargetPosition
? "红色框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,主体可以自然超出红色框,并由周边背景补全衔接。"
: "框选区域只用于识别要调整的主体,不是调整后的边界;主体可以自然超出原始框选范围,并由周边背景补全衔接。",
"只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
"目标主体调整后露出或覆盖的背景、桌面、墙面、阴影和遮挡关系必须自然补全。",
].join(" ");
}
function deriveCanvasScalePercentFromRects(
sourceRect: NormalizedCropRect,
targetRect: NormalizedCropRect | null | undefined
) {
if (!targetRect || sourceRect.width <= 0 || sourceRect.height <= 0) {
return 0;
}
const widthRatio = Math.max(0.05, targetRect.width / sourceRect.width);
const heightRatio = Math.max(0.05, targetRect.height / sourceRect.height);
const uniformRatio = Math.sqrt(widthRatio * heightRatio);
const percent = Math.round((uniformRatio - 1) * 100);
return Math.min(
CANVAS_SCALE_TARGET_MAX_PERCENT,
Math.max(CANVAS_SCALE_TARGET_MIN_PERCENT, percent)
);
}
type LocalEditPromptCompositionSnapshot = {
end: number;
start: number;
value: string;
};
type AnglePreviewDragState = {
pointerId: number;
startX: number;
startY: number;
startHorizontalAngle: number;
startVerticalAngle: number;
};
const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08;
const BOX_CUTOUT_MIN_EDGE_PX = 24;
const isLocalEditControlTarget = (target: EventTarget | null) =>
target instanceof HTMLElement &&
Boolean(target.closest('[data-local-edit-control="true"]'));
const FLOATING_TOOLBAR_SCALE = 0.8;
const IMAGE_EDIT_PANEL_SCALE = 0.5;
const ANGLE_PANEL_SCALE = 0.65;
const ANGLE_PREVIEW_HORIZONTAL_DRAG_MULTIPLIER = 0.72;
const ANGLE_PREVIEW_VERTICAL_DRAG_MULTIPLIER = 0.46;
const ANGLE_PANEL_SOURCE_WIDTH = 520;
const ANGLE_PANEL_SOURCE_HEIGHT = 700;
const ANGLE_PANEL_PREVIEW_SIZE = 392;
const ANGLE_PANEL_CAMERA_SCENE_SIZE = 384;
const ANGLE_PANEL_WIREFRAME_RADIUS = 132;
const ANGLE_PANEL_WIREFRAME_DIAMETER = ANGLE_PANEL_WIREFRAME_RADIUS * 2;
const ANGLE_PREVIEW_VERTICAL_RING_ROTATIONS = [
0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165,
];
const ANGLE_PREVIEW_HORIZONTAL_RING_ROTATIONS = [
-78, -56, -34, -14, 0, 14, 34, 56, 78,
];
const ANGLE_PREVIEW_DIAGONAL_RING_ROTATIONS = [-62, -40, -18, 8, 34, 58];
const normalizeAnglePanelHorizontalAngle = (value: number) => {
if (!Number.isFinite(value)) return 0;
return value < 0 ? 360 + value : value;
};
const mapAnglePanelZoomToApi = (value: number) => {
const safeValue = Math.max(
0,
Math.min(100, Number.isFinite(value) ? value : 50)
);
return Number((1 + (safeValue / 100) * 8).toFixed(1));
};
const formatAnglePanelZoomLabel = (value: number) => {
if (value <= 32) return "远景";
if (value >= 68) return "近景";
return "中等";
};
function AngleEditPanelSlider({
label,
max,
min,
onChange,
step = 1,
value,
valueLabel,
}: {
label: string;
max: number;
min: number;
onChange: (value: number) => void;
step?: number;
value: number;
valueLabel: string;
}) {
return (
{label}
{valueLabel}
onChange(nextValue ?? value)}
rangeClassName="bg-[#2d3035]"
step={step}
thumbClassName="h-5 w-5 border-[4px] border-[#2d3035] bg-white shadow-[0_2px_10px_rgba(24,24,27,0.14)]"
trackClassName="h-[4px] bg-[#e7e8ec]"
value={[value]}
/>
);
}
function AngleCameraModelGlyph() {
return (
);
}
const clampNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const fitSizeWithinBox = ({
maxHeight,
maxWidth,
minHeight = 72,
minWidth = 72,
sourceHeight,
sourceWidth,
}: {
maxHeight: number;
maxWidth: number;
minHeight?: number;
minWidth?: number;
sourceHeight: number;
sourceWidth: number;
}) => {
const safeSourceWidth = sourceWidth > 0 ? sourceWidth : maxWidth;
const safeSourceHeight = sourceHeight > 0 ? sourceHeight : maxHeight;
let scale = Math.min(maxWidth / safeSourceWidth, maxHeight / safeSourceHeight);
let fittedWidth = safeSourceWidth * scale;
let fittedHeight = safeSourceHeight * scale;
if (fittedWidth < minWidth) {
scale = minWidth / safeSourceWidth;
fittedWidth = safeSourceWidth * scale;
fittedHeight = safeSourceHeight * scale;
}
if (fittedHeight < minHeight) {
scale = minHeight / safeSourceHeight;
fittedWidth = safeSourceWidth * scale;
fittedHeight = safeSourceHeight * scale;
}
if (fittedWidth > maxWidth || fittedHeight > maxHeight) {
scale = Math.min(maxWidth / fittedWidth, maxHeight / fittedHeight);
fittedWidth *= scale;
fittedHeight *= scale;
}
return {
width: Math.round(fittedWidth),
height: Math.round(fittedHeight),
};
};
const normalizeTextEditItems = (
items: ImageTextEditItem[] | undefined
): ImageTextEditItem[] => {
if (!items || items.length === 0) return [];
return items
.map((item, index) => {
const originalText = (item.originalText || "").trim();
if (!originalText) return null;
return {
id: item.id || String(index + 1),
originalText,
editedText: (item.editedText || item.originalText || "").trim(),
textColor: normalizeOptionalHexColor(item.textColor),
confidence: item.confidence,
} as ImageTextEditItem;
})
.filter((item): item is ImageTextEditItem => !!item);
};
const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024;
const CANVAS_3D_POLL_INTERVAL_MS = 2000;
const CANVAS_3D_MAX_POLL_ATTEMPTS = 360;
const AI_WASH_TASK_POLL_INTERVAL_MS = 2500;
const AI_WASH_TASK_POLL_LIMIT = 80;
const HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS = 15 * 1000;
const HYDRATED_IMAGE_TASK_POLL_INTERVAL_MS = 2500;
const HYDRATED_IMAGE_TASK_POLL_LIMIT = 240;
const DEFAULT_CANVAS_TRIPO_MODEL = getDefaultThreeDModelAliasId(
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.tripoModels
);
const DEFAULT_CANVAS_WORLD_MODEL = getDefaultThreeDModelAliasId(
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.worldModels
);
const sleep = (ms: number) =>
new Promise((resolve) => window.setTimeout(resolve, ms));
function getAiWashPresetControl(
preset: AiWashPreset | null | undefined,
controlId: AiWashControlId
): AiWashControl | null {
if (!preset) return null;
return preset.controls.find((control) => control.id === controlId) || null;
}
function getAiWashControlLevelByIndex(
control: AiWashControl | null,
selectedIndex: number
): AiWashControlLevel | null {
if (!control || control.levels.length === 0) return null;
const clampedIndex = Math.max(
0,
Math.min(control.levels.length - 1, Math.floor(selectedIndex))
);
return control.levels[clampedIndex] || control.levels[0] || null;
}
function composeAiWashQuickPrompt(params: {
preset: AiWashPreset;
selectedLevelIndexByControl: Record;
}) {
const promptParts = [params.preset.prompt.trim()].filter(Boolean);
for (const controlId of AI_WASH_CONTROL_ORDER) {
const level = getAiWashControlLevelByIndex(
getAiWashPresetControl(params.preset, controlId),
params.selectedLevelIndexByControl[controlId] ?? 0
);
const fragment = String(level?.promptFragment || "").trim();
if (fragment) {
promptParts.push(fragment);
}
}
return promptParts.join("\n");
}
function resolveAiWashQuickAdminModelId(params: {
preset: AiWashPreset;
selectedLevelIndexByControl: Record;
}) {
for (const controlId of AI_WASH_CONTROL_ORDER) {
const level = getAiWashControlLevelByIndex(
getAiWashPresetControl(params.preset, controlId),
params.selectedLevelIndexByControl[controlId] ?? 0
);
const levelModelId = String(level?.modelId || "").trim();
if (levelModelId) return levelModelId;
}
return String(params.preset.modelId || "").trim();
}
function getAiWashStatusLabel(status?: AiWashResultStatus) {
switch (status) {
case "submitting":
return "提交中";
case "running":
return "生成中";
case "succeeded":
return "已完成";
case "failed":
return "失败";
case "idle":
default:
return "待生成";
}
}
const CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT = "canvas:image-node-manual-upload";
function stripImageGenerationMetadata(metadata?: ImageNodeData["metadata"]) {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined;
}
const next = { ...metadata };
delete next.canvasGenerationPlaceholder;
delete next.detailProgressLabel;
delete next.generationProgress;
delete next.latestGeneratedHighlight;
delete next.recentlyGeneratedAt;
delete next.taskId;
delete next.taskRecoveredAt;
delete next.taskRecoveryStatus;
delete next.error;
return Object.keys(next).length > 0 ? next : undefined;
}
async function pollAiWashQuickTask(taskId: string) {
for (let attempt = 0; attempt < AI_WASH_TASK_POLL_LIMIT; attempt += 1) {
const response = await fetch(
`/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`
);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true || !payload?.task) {
throw new Error(payload?.error || "查询 AI洗图任务失败");
}
const task = payload.task as {
status: string;
error?: string | null;
result?: {
images?: Array<{ thumbnailUrl?: string; originalUrl?: string }>;
model?: string;
modelRef?: string;
prompt?: string;
} | null;
};
const status = String(task.status || "").toLowerCase();
if (status === "succeeded" || status === "completed") {
const image = task.result?.images?.[0];
if (!image?.originalUrl) {
throw new Error("AI洗图已完成,但没有拿到结果图片");
}
return {
imageUrl: image.originalUrl,
thumbnailUrl: image.thumbnailUrl || image.originalUrl,
modelId: String(task.result?.model || task.result?.modelRef || "").trim(),
prompt: String(task.result?.prompt || "").trim(),
};
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
throw new Error(task.error || "AI洗图任务失败");
}
await sleep(AI_WASH_TASK_POLL_INTERVAL_MS);
}
throw new Error("AI洗图任务轮询超时,请稍后重试");
}
const isTerminal3DFailureStatus = (status?: string | null) =>
["failed", "banned", "expired", "cancelled"].includes(String(status || ""));
function getChatAttachmentExtension(mimeType: string) {
const normalized = mimeType.toLowerCase();
if (normalized.includes("png")) return "png";
if (normalized.includes("webp")) return "webp";
if (normalized.includes("gif")) return "gif";
if (normalized.includes("svg")) return "svg";
return "jpg";
}
function normalizeChatAttachmentFileName(name: string, mimeType: string) {
const baseName =
String(name || "canvas-image")
.trim()
.replace(/\.[^.]+$/, "")
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || "canvas-image";
return `${baseName}-${Date.now()}.${getChatAttachmentExtension(mimeType)}`;
}
async function uploadChatAttachmentBlob(blob: Blob, displayName: string) {
const uploadBlob = blob.type ? blob : new Blob([blob], { type: "image/jpeg" });
if (!uploadBlob.type.startsWith("image/")) {
throw new Error("只能添加图片到对话");
}
if (!["image/jpeg", "image/jpg", "image/png", "image/webp"].includes(uploadBlob.type)) {
throw new Error("暂只支持 JPG、PNG、WebP 图片添加到对话");
}
if (uploadBlob.size > CHAT_ATTACHMENT_MAX_UPLOAD_BYTES) {
throw new Error("图片过大,请先压缩后再添加到对话");
}
const formData = new FormData();
const contentType = uploadBlob.type || "image/jpeg";
formData.append("file", uploadBlob, normalizeChatAttachmentFileName(displayName, contentType));
formData.append("purpose", "canvas-upload");
const response = await fetchUploadWithRetry(formData);
let payload: any = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok) {
throw new Error(payload?.error || "图片上传失败,请稍后重试");
}
const uploadedUrl = String(payload?.url || "").trim();
if (!isHttpImageUrl(uploadedUrl)) {
throw new Error("图片上传后未返回可用地址");
}
return {
url: uploadedUrl,
contentType,
};
}
async function resolveChatAttachmentFromImageSrc(src: string, displayName: string) {
const trimmedSrc = String(src || "").trim();
if (!trimmedSrc) {
throw new Error("当前图片地址不可用");
}
if (isHttpImageUrl(trimmedSrc)) {
return {
url: trimmedSrc,
contentType: "image/jpeg",
};
}
if (trimmedSrc.startsWith("data:image/")) {
if (trimmedSrc.length > CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH) {
throw new Error("图片数据过大,请先下载压缩后再添加到对话");
}
const blob = await fetch(trimmedSrc).then((response) => response.blob());
return uploadChatAttachmentBlob(blob, displayName);
}
if (trimmedSrc.startsWith("blob:")) {
const blob = await fetch(trimmedSrc).then((response) => response.blob());
return uploadChatAttachmentBlob(blob, displayName);
}
throw new Error("当前图片地址不可直接加入对话,请重新上传图片");
}
async function renderAndUploadFlippedImage(
imageSrc: string,
action: Exclude,
displayName: string
) {
const sourceUrl = buildImageProxyUrl(String(imageSrc || "").trim(), {
delivery: "proxy",
});
const response = await fetch(sourceUrl);
if (!response.ok) {
throw new Error("读取原图失败,请稍后重试");
}
const sourceBlob = await response.blob();
const objectUrl = URL.createObjectURL(sourceBlob);
try {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("图片加载失败"));
img.src = objectUrl;
});
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
if (!width || !height) {
throw new Error("图片尺寸无效,无法执行翻转");
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("无法创建画布上下文");
}
ctx.save();
if (action === "horizontal") {
ctx.translate(width, 0);
ctx.scale(-1, 1);
} else {
ctx.translate(0, height);
ctx.scale(1, -1);
}
ctx.drawImage(image, 0, 0, width, height);
ctx.restore();
const outputType = ["image/png", "image/jpeg", "image/webp"].includes(
sourceBlob.type
)
? sourceBlob.type
: "image/png";
const transformedBlob = await new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("导出翻转图片失败"));
},
outputType,
outputType === "image/jpeg" || outputType === "image/webp"
? 0.96
: undefined
);
});
const uploadResult = await uploadChatAttachmentBlob(
transformedBlob,
displayName
);
return uploadResult.url;
} finally {
URL.revokeObjectURL(objectUrl);
}
}
async function renderAndUploadCroppedImage(
imageSrc: string,
cropRect: NormalizedCropRect,
displayName: string
) {
const sourceUrl = buildImageProxyUrl(String(imageSrc || "").trim(), {
delivery: "proxy",
});
const response = await fetch(sourceUrl);
if (!response.ok) {
throw new Error("读取原图失败,请稍后重试");
}
const sourceBlob = await response.blob();
const objectUrl = URL.createObjectURL(sourceBlob);
try {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("图片加载失败"));
img.src = objectUrl;
});
const naturalWidth = image.naturalWidth || image.width;
const naturalHeight = image.naturalHeight || image.height;
if (!naturalWidth || !naturalHeight) {
throw new Error("图片尺寸无效,无法执行裁剪");
}
const left = Math.round(clampNumber(cropRect.x, 0, 1) * naturalWidth);
const top = Math.round(clampNumber(cropRect.y, 0, 1) * naturalHeight);
const width = Math.max(
BOX_CUTOUT_MIN_EDGE_PX,
Math.round(clampNumber(cropRect.width, 0, 1) * naturalWidth)
);
const height = Math.max(
BOX_CUTOUT_MIN_EDGE_PX,
Math.round(clampNumber(cropRect.height, 0, 1) * naturalHeight)
);
const safeWidth = Math.min(width, naturalWidth - left);
const safeHeight = Math.min(height, naturalHeight - top);
if (safeWidth <= 0 || safeHeight <= 0) {
throw new Error("裁剪区域无效,请重新调整");
}
const canvas = document.createElement("canvas");
canvas.width = safeWidth;
canvas.height = safeHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("无法创建画布上下文");
}
ctx.drawImage(
image,
left,
top,
safeWidth,
safeHeight,
0,
0,
safeWidth,
safeHeight
);
const outputType = ["image/png", "image/jpeg", "image/webp"].includes(
sourceBlob.type
)
? sourceBlob.type
: "image/png";
const croppedBlob = await new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("导出裁剪图片失败"));
},
outputType,
outputType === "image/jpeg" || outputType === "image/webp"
? 0.96
: undefined
);
});
const uploadResult = await uploadChatAttachmentBlob(croppedBlob, displayName);
return {
url: uploadResult.url,
width: safeWidth,
height: safeHeight,
};
} finally {
URL.revokeObjectURL(objectUrl);
}
}
/**
* 在图片上绘制标记点,返回带标记的图片 base64
*/
async function drawMarkerOnImage(
imageUrl: string,
clickX: number,
clickY: number
): Promise {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
const loadUrl = buildImageProxyUrl(imageUrl, { delivery: "proxy" });
img.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("无法创建 canvas"));
return;
}
// 压缩图片尺寸(最大 800px)
const maxSize = 800;
let width = img.width;
let height = img.height;
if (width > maxSize || height > maxSize) {
if (width > height) {
height = (height / width) * maxSize;
width = maxSize;
} else {
width = (width / height) * maxSize;
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
// 计算标记位置
const markerX = clickX * width;
const markerY = clickY * height;
const markerRadius = Math.max(12, Math.min(width, height) * 0.025);
// 绘制红色标记
ctx.beginPath();
ctx.arc(markerX, markerY, markerRadius + 3, 0, Math.PI * 2);
ctx.fillStyle = "white";
ctx.fill();
ctx.beginPath();
ctx.arc(markerX, markerY, markerRadius, 0, Math.PI * 2);
ctx.fillStyle = "#FF0000";
ctx.fill();
// 绘制十字线
ctx.strokeStyle = "#FF0000";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(markerX - markerRadius * 2, markerY);
ctx.lineTo(markerX - markerRadius - 2, markerY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(markerX + markerRadius + 2, markerY);
ctx.lineTo(markerX + markerRadius * 2, markerY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(markerX, markerY - markerRadius * 2);
ctx.lineTo(markerX, markerY - markerRadius - 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(markerX, markerY + markerRadius + 2);
ctx.lineTo(markerX, markerY + markerRadius * 2);
ctx.stroke();
resolve(canvas.toDataURL("image/jpeg", 0.85));
};
img.onerror = () => reject(new Error("图片加载失败"));
img.src = loadUrl;
});
}
function normalizeCanvasNodeAnnotations(
metadata: unknown
): CanvasNodeAnnotationDisplayItem[] {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return [];
}
const rawAnnotations = (metadata as Record).annotations;
if (!Array.isArray(rawAnnotations)) return [];
return rawAnnotations
.map((annotation, index): CanvasNodeAnnotationDisplayItem | null => {
if (!annotation || typeof annotation !== "object" || Array.isArray(annotation)) {
return null;
}
const raw = annotation as Record;
const type = String(raw.type || "text-note");
if (
type !== "text-note" &&
type !== "arrow-note" &&
type !== "box" &&
type !== "freehand" &&
type !== "crop-box"
) {
return null;
}
const normalizePoint = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const point = value as Record;
const x = Number(point.x);
const y = Number(point.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
return { x, y };
};
const normalizeBounds = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const bounds = value as Record;
const x = Number(bounds.x);
const y = Number(bounds.y);
const width = Number(bounds.width);
const height = Number(bounds.height);
if (
!Number.isFinite(x) ||
!Number.isFinite(y) ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
return { x, y, width, height };
};
return {
id: String(raw.id || `annotation-${index + 1}`).slice(0, 128),
type: type as CanvasNodeAnnotationDisplayItem["type"],
text:
typeof raw.text === "string"
? String(raw.text).trim().slice(0, 180)
: undefined,
bounds: normalizeBounds(raw.bounds),
targetPoint: normalizePoint(raw.targetPoint),
color:
typeof raw.color === "string" ? String(raw.color).slice(0, 64) : undefined,
createdAt:
typeof raw.createdAt === "string"
? String(raw.createdAt).slice(0, 64)
: undefined,
createdByAgent: raw.createdByAgent === true,
};
})
.filter((annotation): annotation is CanvasNodeAnnotationDisplayItem =>
Boolean(annotation)
)
.slice(-12);
}
function getCanvasAnnotationBoundsStyle(
annotation: CanvasNodeAnnotationDisplayItem,
metrics: ImageDisplayMetrics | null
): React.CSSProperties | null {
if (!metrics || !annotation.bounds) return null;
const { bounds } = annotation;
const normalized =
Math.abs(bounds.x) <= 1 &&
Math.abs(bounds.y) <= 1 &&
Math.abs(bounds.width) <= 1 &&
Math.abs(bounds.height) <= 1;
if (normalized) {
return {
left: metrics.offsetX + bounds.x * metrics.renderedWidth,
top: metrics.offsetY + bounds.y * metrics.renderedHeight,
width: Math.max(bounds.width * metrics.renderedWidth, 1),
height: Math.max(bounds.height * metrics.renderedHeight, 1),
};
}
const scaleX =
metrics.naturalWidth > 0 ? metrics.renderedWidth / metrics.naturalWidth : 1;
const scaleY =
metrics.naturalHeight > 0 ? metrics.renderedHeight / metrics.naturalHeight : 1;
return {
left: metrics.offsetX + bounds.x * scaleX,
top: metrics.offsetY + bounds.y * scaleY,
width: Math.max(bounds.width * scaleX, 1),
height: Math.max(bounds.height * scaleY, 1),
};
}
interface ImageNodeProps {
node: CanvasNode;
isSelected: boolean;
isLayerSplitEnabled?: boolean;
batchProcessingEnabled?: boolean;
canvasToolbarConfig?: CanvasToolbarConfig;
angleEditEnabled?: boolean;
tripo3dEnabled?: boolean;
worldlabs3dEnabled?: boolean;
}
export function ImageNode({
node,
isSelected,
isLayerSplitEnabled = false,
batchProcessingEnabled = true,
canvasToolbarConfig,
angleEditEnabled = false,
tripo3dEnabled = false,
worldlabs3dEnabled = false,
}: ImageNodeProps) {
const router = useRouter();
const routeParams = useParams();
const canvasId = Array.isArray(routeParams?.id)
? routeParams.id[0] || ""
: typeof routeParams?.id === "string"
? routeParams.id
: "";
const data = node.data as ImageNodeData;
const { data: aiWashConfigPayload, isLoading: aiWashConfigLoading } =
useSWR("/api/ai-wash/config", fetcher);
const aiWashQuickConfig = useMemo(
() =>
normalizeAiWashConfig(
aiWashConfigPayload?.config || defaultAiWashConfig()
),
[aiWashConfigPayload?.config]
);
const aiWashQuickPresets = useMemo(
() => getEnabledAiWashPresets(aiWashQuickConfig).filter((preset) => preset.enabled),
[aiWashQuickConfig]
);
const imageAiWashQuickEnabled =
aiWashQuickConfig.enabled && aiWashQuickPresets.length > 0;
const aiWashQuickModeLabel =
aiWashQuickConfig.modeLabel || AI_WASH_DEFAULT_MODE_LABEL;
const {
data: aiWashModelOptionsPayload,
isLoading: aiWashModelOptionsLoading,
} = useSWR(
aiWashQuickConfig.enabled ? "/api/ai-wash/model-options" : null,
fetcher
);
const { data: membershipSummary } = useSWR(
"/api/user/membership-summary",
fetcher
);
const canvasAgentAnnotations = useMemo(
() => normalizeCanvasNodeAnnotations(data.metadata),
[data.metadata]
);
const visibleCanvasAgentAnnotations = useMemo(
() => canvasAgentAnnotations.slice(-3),
[canvasAgentAnnotations]
);
const hasCanvasAgentAnnotations = canvasAgentAnnotations.length > 0;
const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore");
const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat");
const imagePickObjectItem = getCanvasToolbarItem(canvasToolbarConfig, "imagePickObject");
const imageEnhanceItem = getCanvasToolbarItem(canvasToolbarConfig, "imageEnhance");
const imageRemoveBackgroundItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageRemoveBackground"
);
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
const imageLocalEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLocalEdit");
const imageLayoutReplaceItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageLayoutReplace"
);
const imageRemoveWatermarkItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageRemoveWatermark"
);
const imageResizeItem = getCanvasToolbarItem(canvasToolbarConfig, "imageResize");
const imageCropItem = getCanvasToolbarItem(canvasToolbarConfig, "imageCrop");
const imageTextEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageTextEdit");
const imageMultiAngleItem = getCanvasToolbarItem(canvasToolbarConfig, "imageMultiAngle");
const imageGenerate3dModelItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageGenerate3dModel"
);
const imageGenerate3dWorldItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageGenerate3dWorld"
);
const imageLayerSplitItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLayerSplit");
const imageExtendItem = getCanvasToolbarItem(canvasToolbarConfig, "imageExtend");
const imageAdjustItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAdjust");
const imageFlipRotateItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageFlipRotate"
);
const imagePromptRegenerateItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imagePromptRegenerate"
);
const imageNoteItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNote");
const imageRenameItem = getCanvasToolbarItem(canvasToolbarConfig, "imageRename");
const imageDownloadItem = getCanvasToolbarItem(canvasToolbarConfig, "imageDownload");
const imageDeleteItem = getCanvasToolbarItem(canvasToolbarConfig, "imageDelete");
const imageCompareItem = getCanvasToolbarItem(canvasToolbarConfig, "imageCompare");
const imageConfirmCutoutItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageConfirmCutout"
);
const imageCancelCutoutItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageCancelCutout"
);
const imageNodeMoreEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageNodeMore"
);
const imageAddToChatEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageAddToChat"
);
const imagePickObjectEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imagePickObject"
);
const imageRemoveBackgroundEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageRemoveBackground"
);
const imageRemoveWatermarkEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageRemoveWatermark"
);
const imageEnhanceEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageEnhance"
);
const imageNoteEnabled = isCanvasToolbarItemEnabled(canvasToolbarConfig, "imageNote");
const imageBoxCutoutEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageBoxCutout"
);
// 局部编辑是无限画布图片的核心入口:即使旧后台工具栏配置里还没有该 key,
// 也必须默认展示,避免历史配置把新功能吞掉。
const imageLocalEditEnabled = true;
// 排版替换是无限画布图片的核心 AI 入口;旧配置缺少该 key 时也保持可见。
const imageLayoutReplaceEnabled = true;
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageLayerSplit"
);
const imageResizeEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageResize"
);
const imageMultiAngleEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageMultiAngle"
) && angleEditEnabled;
const imageGenerate3dModelEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageGenerate3dModel"
) && tripo3dEnabled;
const imageGenerate3dWorldEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageGenerate3dWorld"
) && worldlabs3dEnabled;
const imagePromptRegenerateEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imagePromptRegenerate"
);
const imageTextEditEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageTextEdit"
);
const imageExtendEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageExtend"
);
const imageCropEnabled = isCanvasToolbarItemEnabled(canvasToolbarConfig, "imageCrop");
const imageAdjustEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageAdjust"
);
const imageFlipRotateEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageFlipRotate"
);
const imageRenameEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageRename"
);
const imageDownloadEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageDownload"
);
const imageDeleteEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"imageDelete"
);
const imageLayerSplitEnabled =
isLayerSplitEnabled && imageLayerSplitEnabledByToolbar;
const selectedImageInlineActionCount =
getSelectedImageInlineActionCount(canvasToolbarConfig);
const selectedImageToolbarActionCandidates = [
{ key: "pickObject", enabled: imagePickObjectEnabled },
{ key: "enhance", enabled: imageEnhanceEnabled },
{ key: "removeBackground", enabled: imageRemoveBackgroundEnabled },
{ key: "aiWashQuick", enabled: imageAiWashQuickEnabled },
{ key: "textEdit", enabled: imageTextEditEnabled },
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
{ key: "localEdit", enabled: imageLocalEditEnabled },
{ key: "layoutReplace", enabled: imageLayoutReplaceEnabled },
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
{ key: "resize", enabled: imageResizeEnabled },
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
{ key: "generate3dModel", enabled: imageGenerate3dModelEnabled },
{ key: "generate3dWorld", enabled: imageGenerate3dWorldEnabled },
{ key: "layerSplit", enabled: imageLayerSplitEnabled },
{ key: "extend", enabled: imageExtendEnabled },
{ key: "crop", enabled: imageCropEnabled },
{ key: "adjust", enabled: imageAdjustEnabled },
{ key: "flipRotate", enabled: imageFlipRotateEnabled },
{ key: "promptRegenerate", enabled: imagePromptRegenerateEnabled },
{ key: "note", enabled: imageNoteEnabled },
] as const;
type SelectedImageToolbarActionKey =
(typeof selectedImageToolbarActionCandidates)[number]["key"];
const enabledSelectedImageToolbarActions =
selectedImageToolbarActionCandidates.filter((action) => action.enabled);
const selectedImageInlineActionKeys = new Set(
enabledSelectedImageToolbarActions
.slice(0, selectedImageInlineActionCount)
.map((action) => action.key)
);
const selectedImageMoreActionKeys = new Set(
enabledSelectedImageToolbarActions
.slice(selectedImageInlineActionCount)
.map((action) => action.key)
);
const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) =>
selectedImageInlineActionKeys.has(key);
const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) =>
selectedImageMoreActionKeys.has(key);
const hasHoverImageMorePrimaryActions =
imageRemoveBackgroundEnabled ||
imageRemoveWatermarkEnabled ||
imageEnhanceEnabled ||
imageLayoutReplaceEnabled ||
imageNoteEnabled ||
hasCanvasAgentAnnotations ||
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
imageResizeEnabled ||
imageMultiAngleEnabled ||
imagePromptRegenerateEnabled ||
imageTextEditEnabled ||
imageExtendEnabled ||
imageCropEnabled ||
imageAdjustEnabled ||
imageFlipRotateEnabled ||
imageRenameEnabled;
const hasHoverImageMoreSecondaryActions =
imageDownloadEnabled || imageDeleteEnabled;
const hasHoverImageMoreMenu =
imageNodeMoreEnabled &&
(hasHoverImageMorePrimaryActions || hasHoverImageMoreSecondaryActions);
const hasSelectedImageMorePrimaryActions =
selectedImageMoreActionKeys.size > 0;
const hasSelectedImageMoreMenu =
hasSelectedImageMorePrimaryActions ||
(imageNodeMoreEnabled && hasCanvasAgentAnnotations) ||
(imageNodeMoreEnabled && imageDeleteEnabled);
const hasSelectedImagePrimaryToolbarActions =
selectedImageInlineActionKeys.size > 0;
const hasSelectedImageSecondaryToolbarActions =
hasSelectedImageMoreMenu || imageDownloadEnabled;
const contentScale = useMemo(
() =>
getNodeContentScale(node.size, {
width: 220,
height: 220,
}),
[node.size.height, node.size.width]
);
const viewportZoom = useCanvasStore((state) => state.viewport.zoom);
const nodeContentScale = contentScale * viewportZoom;
const localEditPromptVisualScale = clampNumber(nodeContentScale, 0.5, 1);
const scaledContentStyle = useMemo(
() => getScaledNodeContentStyle(nodeContentScale, { allowUpscale: true }),
[nodeContentScale]
);
const selectedTitleStyle = useMemo(
() => ({
fontSize: 12,
lineHeight: "16px",
top: -28,
}),
[]
);
const detailProgressLabel =
typeof data.metadata?.detailProgressLabel === "string"
? data.metadata.detailProgressLabel.trim()
: "";
const generationProgress =
typeof data.metadata?.generationProgress === "number" &&
Number.isFinite(data.metadata.generationProgress)
? Math.max(0, Math.min(100, Math.round(data.metadata.generationProgress)))
: null;
const recentlyGeneratedAt =
typeof data.metadata?.recentlyGeneratedAt === "number" &&
Number.isFinite(data.metadata.recentlyGeneratedAt)
? data.metadata.recentlyGeneratedAt
: 0;
const hasPersistentGeneratedHighlight =
data.metadata?.latestGeneratedHighlight === true;
const hasTransientGeneratedHighlight =
data.metadata?.latestGeneratedHighlight !== false &&
Boolean(data.src) &&
recentlyGeneratedAt > 0 &&
Date.now() - recentlyGeneratedAt < 20_000;
const isRecentlyGenerated =
Boolean(data.src) &&
(hasPersistentGeneratedHighlight || hasTransientGeneratedHighlight);
const flipRotateOriginalSrc =
data.metadata && typeof data.metadata === "object"
? String((data.metadata as Record).flipRotateOriginalSrc || "").trim()
: "";
const hasFlipRotateReset =
flipRotateOriginalSrc.length > 0 && flipRotateOriginalSrc !== data.src;
const storedImageAdjustments = useMemo(
() =>
normalizeImageAdjustments(
data.metadata && typeof data.metadata === "object"
? (data.metadata as Record).imageAdjustments
: undefined
),
[data.metadata]
);
const selectedNodeIds = useCanvasStore((state) => state.selectedNodeIds);
const { data: modelAliasConfigResponse } = useSWR<{
success?: boolean;
data?: PublicThreeDModelAliasConfig;
}>("/api/canvas/model-aliases", fetcher);
const publicModelAliasConfig =
modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG;
const modelFeatureVisibility =
publicModelAliasConfig.featureVisibility ||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.featureVisibility;
const tripoModelOptions = useMemo(() => {
return publicModelAliasConfig.tripoModels.filter(
(entry) => entry.enabled !== false
);
}, [publicModelAliasConfig.tripoModels]);
const worldModelOptions = useMemo(() => {
return publicModelAliasConfig.worldModels.filter(
(entry) => entry.enabled !== false
);
}, [publicModelAliasConfig.worldModels]);
const [isHovered, setIsHovered] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isCreatingDetachedResultNode, setIsCreatingDetachedResultNode] =
useState(false);
const [imageAdjustPanelOpen, setImageAdjustPanelOpen] = useState(false);
const [imageAdjustTab, setImageAdjustTab] =
useState("light");
const [imageAdjustmentsDraft, setImageAdjustmentsDraft] =
useState(storedImageAdjustments);
const [cropPanelOpen, setCropPanelOpen] = useState(false);
const [cropRect, setCropRect] = useState(null);
const [cropPresetId, setCropPresetId] = useState(DEFAULT_CROP_PRESET_ID);
const [cropLockAspectRatio, setCropLockAspectRatio] = useState(true);
const [cropExpandedGroups, setCropExpandedGroups] = useState<
Record
>({
common: true,
instagram: false,
facebook: false,
tiktok: false,
youtube: false,
});
const [cropInteraction, setCropInteraction] =
useState(null);
const [isApplyingCrop, setIsApplyingCrop] = useState(false);
const [flipRotatePanelOpen, setFlipRotatePanelOpen] = useState(false);
const [isApplyingFlipRotate, setIsApplyingFlipRotate] = useState(false);
const [anglePanelOpen, setAnglePanelOpen] = useState(false);
const [anglePanelMode, setAnglePanelMode] =
useState("camera");
const [angleHorizontalAngle, setAngleHorizontalAngle] = useState(-50);
const [angleVerticalAngle, setAngleVerticalAngle] = useState(-30);
const [angleZoom, setAngleZoom] = useState(50);
const [isApplyingAngleEdit, setIsApplyingAngleEdit] = useState(false);
const [isAnglePreviewDragging, setIsAnglePreviewDragging] = useState(false);
const [anglePanelOpening, setAnglePanelOpening] = useState(false);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
const [resizeAnnotatorOpen, setResizeAnnotatorOpen] = useState(false);
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
const [threedGenerationDialogMode, setThreedGenerationDialogMode] = useState<
"model" | "world" | null
>(null);
const [selectedTripoModelVersion, setSelectedTripoModelVersion] = useState(
DEFAULT_CANVAS_TRIPO_MODEL || "tripo-standard"
);
const [selectedWorldModelVersion, setSelectedWorldModelVersion] = useState(
DEFAULT_CANVAS_WORLD_MODEL || "world-standard"
);
const [regeneratePrompt, setRegeneratePrompt] = useState("");
const [layoutReplaceDialogOpen, setLayoutReplaceDialogOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [downloadProgress, setDownloadProgress] =
useState(null);
const downloadAbortControllerRef = useRef(null);
const [textEditPanelOpen, setTextEditPanelOpen] = useState(false);
const [notePanelOpen, setNotePanelOpen] = useState(false);
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
const [hasEnteredViewport, setHasEnteredViewport] = useState(false);
const [textEditItems, setTextEditItems] = useState(() =>
normalizeTextEditItems(data.textEditItems)
);
const [textEditError, setTextEditError] = useState(null);
const [isDetectingText, setIsDetectingText] = useState(false);
const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false);
const [deletedTextItems, setDeletedTextItems] = useState<
Array<{ id: string; originalText: string }>
>([]);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [draftNote, setDraftNote] = useState("");
const [isAddingToChat, setIsAddingToChat] = useState(false);
const [isCompareMode, setIsCompareMode] = useState(false);
const [compareSplitPercent, setCompareSplitPercent] = useState(50);
const [aiWashQuickPanelOpen, setAiWashQuickPanelOpen] = useState(false);
const [aiWashQuickPresetId, setAiWashQuickPresetId] = useState("");
const [aiWashQuickModelId, setAiWashQuickModelId] = useState("");
const [aiWashQuickLevels, setAiWashQuickLevels] = useState<
Record
>({});
const [isSubmittingAiWashQuick, setIsSubmittingAiWashQuick] = useState(false);
const [aiWashQuickStatus, setAiWashQuickStatus] =
useState("idle");
const [toolbarSize, setToolbarSize] = useState({ width: 0, height: 0 });
const [boxCutoutToolbarSize, setBoxCutoutToolbarSize] = useState({
width: 0,
height: 0,
});
const [boxCutoutMode, setBoxCutoutMode] = useState(false);
const [isBoxCutoutDrawing, setIsBoxCutoutDrawing] = useState(false);
const [boxCutoutSelection, setBoxCutoutSelection] =
useState(null);
const [boxCutoutDragStart, setBoxCutoutDragStart] =
useState(null);
const [boxCutoutDragEnd, setBoxCutoutDragEnd] =
useState(null);
const [localEditMode, setLocalEditMode] = useState(false);
const [localEditOperationMode, setLocalEditOperationMode] =
useState("local_edit");
const [localEditRegions, setLocalEditRegions] = useState(
() => getLocalEditRegionsFromMetadata(data.metadata)
);
const [localEditPromptDrafts, setLocalEditPromptDrafts] = useState<
Record
>({});
const [isLocalEditDrawing, setIsLocalEditDrawing] = useState(false);
const [localEditDragStart, setLocalEditDragStart] =
useState(null);
const [localEditDragEnd, setLocalEditDragEnd] =
useState(null);
const [localEditDrawingTargetRegionId, setLocalEditDrawingTargetRegionId] =
useState(null);
const [localEditRegionInteraction, setLocalEditRegionInteraction] =
useState(null);
const [editingLocalEditRegionId, setEditingLocalEditRegionId] =
useState(null);
const [isSubmittingLocalEdit, setIsSubmittingLocalEdit] = useState(false);
const [
uploadingLocalEditReferenceRegionId,
setUploadingLocalEditReferenceRegionId,
] = useState(null);
const anglePreviewDragRef = useRef(null);
const activeImageAdjustments = imageAdjustPanelOpen
? imageAdjustmentsDraft
: storedImageAdjustments;
const activeImageAdjustmentsStyle = useMemo(
() => buildImageAdjustmentFilterStyle(activeImageAdjustments),
[activeImageAdjustments]
);
const hasActiveImageAdjustments = !isDefaultImageAdjustments(
activeImageAdjustments
);
const selectedAiWashQuickPreset = useMemo(
() =>
aiWashQuickPresets.find((preset) => preset.id === aiWashQuickPresetId) ||
aiWashQuickPresets.find((preset) => preset.isDefault) ||
aiWashQuickPresets[0] ||
null,
[aiWashQuickPresetId, aiWashQuickPresets]
);
const aiWashQuickControls = useMemo(
() =>
AI_WASH_CONTROL_ORDER.map((controlId) =>
getAiWashPresetControl(selectedAiWashQuickPreset, controlId)
).filter(Boolean) as AiWashControl[],
[selectedAiWashQuickPreset]
);
const aiWashQuickSelectableModels = useMemo(
() => aiWashModelOptionsPayload?.models || [],
[aiWashModelOptionsPayload?.models]
);
const aiWashQuickLockedModels = useMemo(
() => aiWashConfigPayload?.lockedModels || [],
[aiWashConfigPayload?.lockedModels]
);
const aiWashQuickModelOptionByRef = useMemo(() => {
const map = new Map();
[...aiWashQuickLockedModels, ...aiWashQuickSelectableModels].forEach((model) => {
map.set(model.id, model);
map.set(model.modelId, model);
});
return map;
}, [aiWashQuickLockedModels, aiWashQuickSelectableModels]);
const aiWashQuickAdminModelRef = useMemo(
() =>
selectedAiWashQuickPreset
? resolveAiWashQuickAdminModelId({
preset: selectedAiWashQuickPreset,
selectedLevelIndexByControl: aiWashQuickLevels,
})
: "",
[aiWashQuickLevels, selectedAiWashQuickPreset]
);
const shouldShowAiWashQuickModelSelector =
!aiWashQuickConfig.hideModelSelector && !aiWashQuickAdminModelRef;
const effectiveAiWashQuickModelRef =
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
const selectedAiWashQuickModel = useMemo(
() =>
aiWashQuickModelOptionByRef.get(effectiveAiWashQuickModelRef) ||
aiWashQuickModelOptionByRef.get(aiWashQuickModelId) ||
null,
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
);
const aiWashQuickHasExecutableModel = Boolean(
effectiveAiWashQuickModelRef &&
selectedAiWashQuickModel &&
selectedAiWashQuickModel.selectable !== false
);
const aiWashQuickPrompt = useMemo(
() =>
selectedAiWashQuickPreset
? composeAiWashQuickPrompt({
preset: selectedAiWashQuickPreset,
selectedLevelIndexByControl: aiWashQuickLevels,
})
: "",
[aiWashQuickLevels, selectedAiWashQuickPreset]
);
useEffect(() => {
if (!batchProcessingEnabled) {
setSceneFusionDialogOpen(false);
}
}, [batchProcessingEnabled]);
useEffect(() => {
if (aiWashQuickPresets.length === 0) {
setAiWashQuickPresetId("");
return;
}
setAiWashQuickPresetId((current) => {
if (aiWashQuickPresets.some((preset) => preset.id === current)) {
return current;
}
return (
aiWashQuickPresets.find((preset) => preset.isDefault)?.id ||
aiWashQuickPresets[0]?.id ||
""
);
});
}, [aiWashQuickPresets]);
useEffect(() => {
if (!selectedAiWashQuickPreset) {
setAiWashQuickLevels({});
return;
}
setAiWashQuickLevels(
AI_WASH_CONTROL_ORDER.reduce>(
(accumulator, controlId) => {
accumulator[controlId] =
getAiWashPresetControl(selectedAiWashQuickPreset, controlId)
?.defaultLevel ?? 0;
return accumulator;
},
{}
)
);
setAiWashQuickStatus("idle");
}, [selectedAiWashQuickPreset]);
useEffect(() => {
const fallbackModelId =
String(aiWashModelOptionsPayload?.defaultModelId || "").trim() ||
aiWashQuickSelectableModels.find((model) => model.isDefault)?.id ||
aiWashQuickSelectableModels[0]?.id ||
"";
setAiWashQuickModelId((current) => {
if (
current &&
aiWashQuickSelectableModels.some((model) => model.id === current)
) {
return current;
}
return fallbackModelId;
});
}, [aiWashModelOptionsPayload?.defaultModelId, aiWashQuickSelectableModels]);
useEffect(() => {
if (!imageAiWashQuickEnabled || !isSelected) {
setAiWashQuickPanelOpen(false);
}
}, [imageAiWashQuickEnabled, isSelected]);
useEffect(() => {
if (!imageAdjustPanelOpen) return;
setImageAdjustmentsDraft(storedImageAdjustments);
}, [imageAdjustPanelOpen, storedImageAdjustments, data.src]);
useEffect(() => {
if (!cropPanelOpen) return;
if (cropRect) return;
const centerX = 0.5;
const centerY = 0.5;
const preset = getCropPresetById(cropPresetId);
if (preset) {
setCropRect(createCenteredCropRectForAspect(preset.ratio, centerX, centerY));
return;
}
setCropRect({
x: 0,
y: 0,
width: 1,
height: 1,
});
}, [cropPanelOpen, cropPresetId, cropRect]);
useEffect(() => {
if (!imageAdjustPanelOpen || typeof window === "undefined") return;
if (areImageAdjustmentsEqual(imageAdjustmentsDraft, storedImageAdjustments)) {
return;
}
const timer = window.setTimeout(() => {
const nextMetadata =
data.metadata && typeof data.metadata === "object"
? { ...(data.metadata as Record) }
: {};
if (isDefaultImageAdjustments(imageAdjustmentsDraft)) {
delete nextMetadata.imageAdjustments;
delete nextMetadata.imageAdjustmentsUpdatedAt;
} else {
nextMetadata.imageAdjustments = imageAdjustmentsDraft;
nextMetadata.imageAdjustmentsUpdatedAt = new Date().toISOString();
}
useCanvasStore.getState().updateNode(node.id, {
data: {
...data,
metadata: Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined,
},
});
}, 140);
return () => window.clearTimeout(timer);
}, [
data,
imageAdjustPanelOpen,
imageAdjustmentsDraft,
node.id,
storedImageAdjustments,
]);
useEffect(() => {
if (!anglePanelOpen) return;
const savedSettings =
data.metadata &&
typeof data.metadata === "object" &&
!Array.isArray(data.metadata)
? (data.metadata as {
angleEditSettings?: {
mode?: AngleEditPanelMode;
horizontalAngle?: number;
verticalAngle?: number;
zoom?: number;
};
}).angleEditSettings
: undefined;
if (!savedSettings) return;
if (savedSettings.mode === "subject" || savedSettings.mode === "camera") {
setAnglePanelMode(savedSettings.mode);
}
if (typeof savedSettings.horizontalAngle === "number") {
setAngleHorizontalAngle(clampNumber(savedSettings.horizontalAngle, -180, 180));
}
if (typeof savedSettings.verticalAngle === "number") {
setAngleVerticalAngle(clampNumber(savedSettings.verticalAngle, -30, 60));
}
if (typeof savedSettings.zoom === "number") {
setAngleZoom(clampNumber(savedSettings.zoom, 0, 100));
}
}, [anglePanelOpen, data.metadata]);
useEffect(() => {
if (!anglePanelOpen || typeof window === "undefined") {
setAnglePanelOpening(false);
return;
}
setAnglePanelOpening(true);
const timer = window.setTimeout(() => {
setAnglePanelOpening(false);
}, 560);
return () => window.clearTimeout(timer);
}, [anglePanelOpen]);
useEffect(() => {
const nextDefaultTripoModelId =
getDefaultThreeDModelAliasId(publicModelAliasConfig.tripoModels) ||
DEFAULT_CANVAS_TRIPO_MODEL ||
"tripo-standard";
if (!tripoModelOptions.some((entry) => entry.id === selectedTripoModelVersion)) {
setSelectedTripoModelVersion(nextDefaultTripoModelId);
}
}, [
publicModelAliasConfig.tripoModels,
selectedTripoModelVersion,
tripoModelOptions,
]);
useEffect(() => {
const nextDefaultWorldModelId =
getDefaultThreeDModelAliasId(publicModelAliasConfig.worldModels) ||
DEFAULT_CANVAS_WORLD_MODEL ||
"world-standard";
if (!worldModelOptions.some((entry) => entry.id === selectedWorldModelVersion)) {
setSelectedWorldModelVersion(nextDefaultWorldModelId);
}
}, [
publicModelAliasConfig.worldModels,
selectedWorldModelVersion,
worldModelOptions,
]);
const [boxCutoutMetrics, setBoxCutoutMetrics] =
useState(null);
// 文件输入的 ref
const fileInputRef = useRef(null);
const containerRef = useRef(null);
const [localEditPromptPortalFrame, setLocalEditPromptPortalFrame] = useState<{
left: number;
top: number;
width: number;
height: number;
} | null>(null);
const displayImageRef = useRef(null);
const compareFrameRef = useRef(null);
const cropOverlayRef = useRef(null);
const toolbarRef = useRef(null);
const boxCutoutToolbarRef = useRef(null);
const localEditPromptInputRefs = useRef<
Record
>({});
const localEditPromptDraftRefs = useRef>({});
const localEditPromptCompositionStartRefs = useRef<
Record
>({});
const localEditRegionsRef = useRef(localEditRegions);
const hydratedImageTaskRecoveryRef = useRef(null);
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const addNode = useCanvasStore((state) => state.addNode);
const selectNode = useCanvasStore((state) => state.selectNode);
const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
const addPendingChatAttachment = useCanvasStore(
(state) => state.addPendingChatAttachment
);
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
const imageNodeActionRequest = useCanvasStore(
(state) => state.imageNodeActionRequest
);
const clearImageNodeActionRequest = useCanvasStore(
(state) => state.clearImageNodeActionRequest
);
const allNodes = useCanvasStore((state) => state.nodes);
useEffect(() => {
if (data.status !== "generating" || data.src) return;
const metadata =
data.metadata && typeof data.metadata === "object" && !Array.isArray(data.metadata)
? (data.metadata as Record)
: {};
const taskId = String(metadata.taskId || "").trim();
if (!taskId || hydratedImageTaskRecoveryRef.current === taskId) return;
const createdAtTime = new Date(node.createdAt).getTime();
if (
Number.isFinite(createdAtTime) &&
Date.now() - createdAtTime < HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS
) {
return;
}
let cancelled = false;
hydratedImageTaskRecoveryRef.current = taskId;
const updateCurrentGeneratingTaskNode = (
nextData: (currentData: ImageNodeData) => ImageNodeData
) => {
const currentNode = useCanvasStore
.getState()
.nodes.find((item) => item.id === node.id);
if (!currentNode || currentNode.type !== "image") return;
const currentData = currentNode.data as ImageNodeData;
const currentMetadata =
currentData.metadata &&
typeof currentData.metadata === "object" &&
!Array.isArray(currentData.metadata)
? (currentData.metadata as Record)
: {};
if (
currentData.status !== "generating" ||
currentData.src ||
String(currentMetadata.taskId || "").trim() !== taskId
) {
return;
}
updateNode(node.id, {
data: nextData(currentData),
});
};
const markFailed = (message: string) => {
updateCurrentGeneratingTaskNode((currentData) => ({
...currentData,
src: "",
status: "failed",
prompt: message,
title: currentData.title?.includes("失败")
? currentData.title
: `${currentData.title || "图片生成"}-失败`,
metadata: {
...(currentData.metadata || {}),
error: message,
taskId,
taskRecoveredAt: new Date().toISOString(),
taskRecoveryStatus: "failed",
},
}));
};
void (async () => {
let lastError = "";
for (let attempt = 0; attempt < HYDRATED_IMAGE_TASK_POLL_LIMIT; attempt += 1) {
if (cancelled) return;
const taskResult = await imageGenerationService.getImageTask(taskId);
const task = taskResult.data?.task;
if (!taskResult.success || !task) {
lastError = taskResult.error || "查询生图任务失败";
} else {
const status = String(task.status || "").toLowerCase();
if (status === "succeeded" || status === "completed") {
const image = task.result?.images?.[0];
const imageUrl = image?.originalUrl || image?.thumbnailUrl || "";
if (!imageUrl) {
markFailed("任务已完成但未返回图片");
return;
}
updateCurrentGeneratingTaskNode((currentData) => ({
...currentData,
src: imageUrl,
status: "completed",
prompt: task.result?.prompt || currentData.prompt || "图片生成结果",
model: task.result?.model || task.result?.modelRef || currentData.model,
metadata: {
...(currentData.metadata || {}),
taskId,
taskRecoveredAt: new Date().toISOString(),
taskRecoveryStatus: "succeeded",
},
}));
return;
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
markFailed(task.error || "图片生成任务失败");
return;
}
}
await sleep(HYDRATED_IMAGE_TASK_POLL_INTERVAL_MS);
}
if (!cancelled) {
markFailed(lastError || "图片生成任务等待超时,请重新发起生成");
}
})();
return () => {
cancelled = true;
};
}, [data.metadata, data.src, data.status, node.createdAt, node.id, updateNode]);
const selectedTripoModelEntry = useMemo(
() =>
tripoModelOptions.find((entry) => entry.id === selectedTripoModelVersion) ||
tripoModelOptions[0] ||
null,
[selectedTripoModelVersion, tripoModelOptions]
);
const selectedWorldModelEntry = useMemo(
() =>
worldModelOptions.find((entry) => entry.id === selectedWorldModelVersion) ||
worldModelOptions[0] ||
null,
[selectedWorldModelVersion, worldModelOptions]
);
const pointsSystemConfig = useMemo(
() =>
normalizePointsSystemConfig(
membershipSummary?.pointsSystemConfig || defaultPointsSystemConfig
),
[membershipSummary?.pointsSystemConfig]
);
const pointsEnabled = pointsSystemConfig.enabled === true;
const isHighVip = membershipSummary?.isHighVip === true;
const tripoImageToModelCreditCost = useMemo(
() =>
get3DTaskCreditCost(pointsSystemConfig, {
taskType: "image_to_model",
}),
[pointsSystemConfig]
);
const worldImageCreditCost = useMemo(
() => {
const worldBillingModelId = resolveThreeDModelBillingId(
selectedWorldModelEntry?.id || selectedWorldModelVersion,
publicModelAliasConfig.worldModels
);
return get3DWorldCreditCost(pointsSystemConfig, worldBillingModelId);
},
[
pointsSystemConfig,
publicModelAliasConfig.worldModels,
selectedWorldModelEntry?.id,
selectedWorldModelVersion,
]
);
const tripoImageToModelCostLabel = formatImageNodeFeatureCostLabel({
cost: tripoImageToModelCreditCost,
isHighVip,
pointsEnabled,
});
const worldImageCostLabel = formatImageNodeFeatureCostLabel({
cost: worldImageCreditCost,
isHighVip,
pointsEnabled,
});
const tripoImageToModelCostShortLabel = formatImageNodeFeatureCostShortLabel({
cost: tripoImageToModelCreditCost,
isHighVip,
pointsEnabled,
});
const worldImageCostShortLabel = formatImageNodeFeatureCostShortLabel({
cost: worldImageCreditCost,
isHighVip,
pointsEnabled,
});
const getImageToolCostShortLabel = useCallback(
(feature: ImageToolCreditFeature) =>
formatImageNodeFeatureCostShortLabel({
cost: getImageToolCreditCost(pointsSystemConfig, feature),
isHighVip,
pointsEnabled,
}),
[isHighVip, pointsEnabled, pointsSystemConfig]
);
const removeBackgroundCostShortLabel =
getImageToolCostShortLabel("remove-background");
const boxCutoutCostShortLabel = getImageToolCostShortLabel("box-cutout");
const removeWatermarkCostShortLabel =
getImageToolCostShortLabel("watermark-remove");
const extendCostShortLabel = getImageToolCostShortLabel("extend");
const promptRegenerateCostShortLabel = getImageToolCostShortLabel("repaint");
const localEditCostShortLabel = promptRegenerateCostShortLabel;
const angleEditCostShortLabel = getImageToolCostShortLabel("angle-edit");
const enhanceCostShortLabel = formatImageNodeFeatureCostShortLabel({
cost: calculateEnhanceProPointsEstimate(pointsSystemConfig, {
width: Math.max(1, Math.round(node.size.width || 1)),
height: Math.max(1, Math.round(node.size.height || 1)),
upscalingActivated: true,
scaleFactor: 2,
}).cost,
isHighVip,
pointsEnabled:
pointsEnabled && pointsSystemConfig.enhanceProPricing.enabled === true,
});
const aiWashQuickCostShortLabel =
pointsEnabled && !aiWashQuickConfig.hideModelSelector && selectedAiWashQuickModel
? formatImageNodeFeatureCostShortLabel({
cost: selectedAiWashQuickModel.unitCost,
isHighVip,
pointsEnabled,
})
: null;
// 🆕 物品拾取相关状态
const isPickerMode = useCanvasStore((state) => state.isPickerMode);
const pickerTargetNodeId = useCanvasStore(
(state) => state.pickerTargetNodeId
);
const isPickerLoading = useCanvasStore((state) => state.isPickerLoading);
const setPickerLoading = useCanvasStore((state) => state.setPickerLoading);
const addPendingPickedObject = useCanvasStore(
(state) => state.addPendingPickedObject
);
// 当前节点是否处于拾取模式
const isThisNodePicking = isPickerMode && pickerTargetNodeId === node.id;
// 当前正在识别的点击位置
const [currentClick, setCurrentClick] = useState<{
x: number;
y: number;
} | null>(null);
const displayTitle = getImageNodeTitle(data, node.id);
const agentExecutionSnapshotId =
typeof data.metadata?.agentExecutionSnapshotId === "string"
? data.metadata.agentExecutionSnapshotId.trim()
: "";
const detailUiScreenOverlay = useMemo(() => {
const raw = data.metadata?.detailUiScreenOverlay;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return raw as DetailUiScreenOverlaySnapshot;
}, [data.metadata]);
const detailSubjectAnalysis = useMemo(() => {
return normalizeDetailUiSubjectAnalysis(
data.metadata?.detailSubjectAnalysis
);
}, [data.metadata]);
const detailUiDslDocumentFromMetadata = useMemo(() => {
const raw = data.metadata?.detailUiDslDocument;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return raw as DetailUiDslV2Document;
}, [data.metadata]);
const detailUiDslDocument = useMemo(() => {
if (detailUiDslDocumentFromMetadata) return detailUiDslDocumentFromMetadata;
if (!detailUiScreenOverlay) return null;
return buildDetailUiDslV2({
overlay: detailUiScreenOverlay,
subjectAnalysis: detailSubjectAnalysis,
});
}, [
detailSubjectAnalysis,
detailUiDslDocumentFromMetadata,
detailUiScreenOverlay,
]);
const shouldRenderDetailOverlay =
data.metadata?.detailGenerationMode === "agent_layout" &&
Boolean(detailUiScreenOverlay);
const shortAgentExecutionSnapshotId = agentExecutionSnapshotId
? agentExecutionSnapshotId.slice(0, 8)
: "";
const isMultiSelected = isSelected && selectedNodeIds.length > 1;
const compareSourceInfo = useMemo(() => {
const targetIdentity = normalizeImageIdentityUrl(data.src || "");
if (!targetIdentity) return { url: "", width: 0, height: 0 };
const candidates: Array<{ height: number; url: string; width: number }> = [];
const resolveCandidateResolution = (
sourceNode?: CanvasNode | null,
sourceData?: Partial | null
) => {
const metadata = sourceData?.metadata || {};
const metadataWidth = Number((metadata as any).originalWidth || 0);
const metadataHeight = Number((metadata as any).originalHeight || 0);
const nodeWidth = Number(sourceNode?.size?.width || 0);
const nodeHeight = Number(sourceNode?.size?.height || 0);
const width = metadataWidth > 0 ? metadataWidth : nodeWidth;
const height = metadataHeight > 0 ? metadataHeight : nodeHeight;
return {
width: Number.isFinite(width) && width > 0 ? width : 0,
height: Number.isFinite(height) && height > 0 ? height : 0,
};
};
const pushCandidate = (
value: unknown,
sourceNode?: CanvasNode | null,
sourceData?: Partial | null
) => {
const raw = String(value || "").trim();
if (!raw) return;
candidates.push({
url: raw,
...resolveCandidateResolution(sourceNode, sourceData),
});
};
const visited = new Set([node.id]);
const queue = Array.isArray(node.connections) ? [...node.connections] : [];
let safety = 0;
while (queue.length > 0 && safety < 120) {
safety += 1;
const sourceNodeId = queue.shift();
if (!sourceNodeId || visited.has(sourceNodeId)) continue;
visited.add(sourceNodeId);
const sourceNode = allNodes.find((item) => item.id === sourceNodeId);
if (!sourceNode || sourceNode.type !== "image") continue;
const sourceData = (sourceNode.data as any) || {};
pushCandidate(sourceData.src, sourceNode, sourceData);
pushCandidate(sourceData.originalSrc, sourceNode, sourceData);
pushCandidate(sourceData.metadata?.compareSourceUrl, sourceNode, sourceData);
if (
Array.isArray(sourceNode.connections) &&
sourceNode.connections.length > 0
) {
queue.push(...sourceNode.connections);
}
}
pushCandidate(data.originalSrc, node, data);
pushCandidate(data.metadata?.compareSourceUrl, node, data);
for (const candidate of candidates) {
const identity = normalizeImageIdentityUrl(candidate.url);
if (!identity) continue;
if (identity !== targetIdentity) {
return candidate;
}
}
return { url: "", width: 0, height: 0 };
}, [
allNodes,
data.metadata,
data.originalSrc,
data.src,
node.connections,
node.id,
node.size.height,
node.size.width,
]);
const compareSource = compareSourceInfo.url;
const hasCompareSource = Boolean(data.src) && Boolean(compareSource);
const compareButtonVisualSize = useMemo(() => {
const minSide = Math.max(
1,
Math.min(node.size.width || 0, node.size.height || 0)
);
return Math.round(Math.max(24, Math.min(32, minSide * 0.09)));
}, [node.size.height, node.size.width]);
const compareButtonHitSize = useMemo(
() => Math.max(34, compareButtonVisualSize + 8),
[compareButtonVisualSize]
);
const compareButtonIconSize = useMemo(
() => Math.round(Math.max(12, Math.min(16, compareButtonVisualSize * 0.5))),
[compareButtonVisualSize]
);
const compareButtonInset = useMemo(
() => Math.max(6, Math.min(10, Math.round(compareButtonVisualSize * 0.22))),
[compareButtonVisualSize]
);
const shouldUseHighFidelityDisplayImage =
isSelected ||
isHovered ||
isCompareMode ||
boxCutoutMode ||
localEditMode ||
cropPanelOpen ||
imageAdjustPanelOpen ||
fullscreenPreviewOpen ||
isThisNodePicking;
const displayImageWidth = useMemo(() => {
const devicePixelRatio =
typeof window !== "undefined"
? clampNumber(window.devicePixelRatio || 1, 1, 3)
: 2;
const zoomMultiplier = clampNumber(
viewportZoom || 1,
0.75,
shouldUseHighFidelityDisplayImage ? 2.4 : 1.6
);
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
return Math.max(
minWidth,
Math.min(
maxWidth,
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
)
);
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
const displayImageSrc = useMemo(
() =>
buildImageProxyUrl(data.src, {
width: displayImageWidth,
quality: displayImageQuality,
format: "webp",
}),
[data.src, displayImageQuality, displayImageWidth]
);
const fullscreenPreviewSrc = useMemo(() => {
const originalUrl = resolveOriginalImageUrl(data.src || "");
return buildImageProxyUrl(originalUrl || data.src || "", {
delivery: "proxy",
width: 4096,
quality: 92,
format: "webp",
});
}, [data.src]);
const displayCompareSrc = useMemo(
() =>
buildImageProxyUrl(compareSource, {
width: displayImageWidth,
quality: displayImageQuality,
format: "webp",
}),
[compareSource, displayImageQuality, displayImageWidth]
);
const shouldRequestDisplayImage =
hasEnteredViewport ||
isSelected ||
isCompareMode ||
boxCutoutMode ||
localEditMode ||
cropPanelOpen ||
isThisNodePicking ||
fullscreenPreviewOpen ||
annotatorOpen ||
resizeAnnotatorOpen ||
textEditPanelOpen ||
imageAdjustPanelOpen ||
flipRotatePanelOpen ||
anglePanelOpen;
const [loadedResolution, setLoadedResolution] = useState<{
width: number;
height: number;
} | null>(null);
const metadataResolution = useMemo(() => {
const rawWidth = Number((data.metadata as any)?.originalWidth ?? 0);
const rawHeight = Number((data.metadata as any)?.originalHeight ?? 0);
const width = Number.isFinite(rawWidth) ? Math.round(rawWidth) : 0;
const height = Number.isFinite(rawHeight) ? Math.round(rawHeight) : 0;
if (width > 0 && height > 0) {
return { width, height };
}
return null;
}, [data.metadata]);
const compareImageFrameStyle = useMemo(() => {
const boxWidth = Math.max(1, Number(node.size.width || 0));
const boxHeight = Math.max(1, Number(node.size.height || 0));
const activeResolution = metadataResolution || loadedResolution;
const naturalWidth = Math.max(1, Number(activeResolution?.width || boxWidth));
const naturalHeight = Math.max(1, Number(activeResolution?.height || boxHeight));
const scale = Math.min(boxWidth / naturalWidth, boxHeight / naturalHeight);
const renderedWidth = Math.max(1, naturalWidth * scale);
const renderedHeight = Math.max(1, naturalHeight * scale);
return {
height: renderedHeight,
left: (boxWidth - renderedWidth) / 2,
top: (boxHeight - renderedHeight) / 2,
width: renderedWidth,
};
}, [loadedResolution, metadataResolution, node.size.height, node.size.width]);
const resolutionLabel = useMemo(() => {
const source = metadataResolution || loadedResolution;
if (!source) return "";
return `${source.width} x ${source.height}`;
}, [loadedResolution, metadataResolution]);
const angleSourceResolution = useMemo(() => {
const source = metadataResolution || loadedResolution;
if (source?.width && source?.height) {
return source;
}
return {
width: Math.max(1, Math.round(node.size.width || 1)),
height: Math.max(1, Math.round(node.size.height || 1)),
};
}, [loadedResolution, metadataResolution, node.size.height, node.size.width]);
const anglePreviewFrameSize = useMemo(
() =>
fitSizeWithinBox({
sourceWidth: angleSourceResolution.width,
sourceHeight: angleSourceResolution.height,
maxWidth: anglePanelMode === "subject" ? 236 : 188,
maxHeight: anglePanelMode === "subject" ? 196 : 150,
minWidth: 96,
minHeight: 80,
}),
[anglePanelMode, angleSourceResolution.height, angleSourceResolution.width]
);
const anglePreviewDepth = useMemo(
() =>
Math.round(
clampNumber(
anglePanelMode === "subject"
? anglePreviewFrameSize.width * 0.18
: anglePreviewFrameSize.width * 0.22,
16,
36
)
),
[anglePanelMode, anglePreviewFrameSize.width]
);
const anglePreviewScale = useMemo(
() =>
Number(
(
(anglePanelMode === "subject" ? 0.9 : 0.92) +
(angleZoom / 100) * (anglePanelMode === "subject" ? 0.46 : 0.34)
).toFixed(3)
),
[anglePanelMode, angleZoom]
);
const anglePreviewImageSrc = displayImageSrc || data.src;
const angleCameraOrbitPosition = useMemo(() => {
const horizontalRadians = (angleHorizontalAngle * Math.PI) / 180;
const normalizedVertical = (angleVerticalAngle + 30) / 90;
const x = Math.sin(horizontalRadians) * 110;
const y = -normalizedVertical * 72 + 26;
const z = Math.cos(horizontalRadians) * 38;
return {
x: Number(x.toFixed(2)),
y: Number(y.toFixed(2)),
z: Number(z.toFixed(2)),
};
}, [angleHorizontalAngle, angleVerticalAngle]);
const angleCameraConnection = useMemo(() => {
const length = Math.sqrt(
angleCameraOrbitPosition.x * angleCameraOrbitPosition.x +
angleCameraOrbitPosition.y * angleCameraOrbitPosition.y
);
const angle =
(Math.atan2(angleCameraOrbitPosition.y, angleCameraOrbitPosition.x) *
180) /
Math.PI;
return {
angle: Number(angle.toFixed(2)),
length: Number(length.toFixed(2)),
};
}, [angleCameraOrbitPosition]);
const angleOrbitWireframeStyle = useMemo(
() => ({
transform: `rotateX(${(-angleVerticalAngle * 0.24) + (anglePanelOpening ? 14 : 0)}deg) rotateY(${(angleHorizontalAngle * 0.34) + (anglePanelOpening ? -34 : 0)}deg) rotateZ(${(angleHorizontalAngle * 0.05) + (anglePanelOpening ? 7 : 0)}deg)`,
transformStyle: "preserve-3d",
transition: isAnglePreviewDragging
? "none"
: `transform ${anglePanelOpening ? 560 : 180}ms cubic-bezier(0.22, 1, 0.36, 1)`,
}),
[
angleHorizontalAngle,
anglePanelOpening,
angleVerticalAngle,
isAnglePreviewDragging,
]
);
const angleCameraSceneScale = useMemo(
() => Number((0.94 + (angleZoom / 100) * 0.22).toFixed(3)),
[angleZoom]
);
const cropImageMetrics = useMemo(() => {
if (boxCutoutMetrics) {
return {
naturalWidth: boxCutoutMetrics.naturalWidth,
naturalHeight: boxCutoutMetrics.naturalHeight,
};
}
const fallback = metadataResolution || loadedResolution;
if (!fallback) return null;
return {
naturalWidth: fallback.width,
naturalHeight: fallback.height,
};
}, [boxCutoutMetrics, loadedResolution, metadataResolution]);
const boxCutoutDraftRect = useMemo(() => {
if (!boxCutoutMode || !boxCutoutDragStart || !boxCutoutDragEnd) {
return null;
}
return createNormalizedRect(boxCutoutDragStart, boxCutoutDragEnd);
}, [boxCutoutDragEnd, boxCutoutDragStart, boxCutoutMode]);
const activeBoxCutoutRect = boxCutoutSelection || boxCutoutDraftRect;
const activeBoxCutoutRectStyle = useMemo(() => {
if (!boxCutoutMetrics || !activeBoxCutoutRect) return null;
return getNormalizedRectOverlayStyle(activeBoxCutoutRect, boxCutoutMetrics);
}, [activeBoxCutoutRect, boxCutoutMetrics]);
const localEditDraftRect = useMemo(() => {
if (!localEditMode || !localEditDragStart || !localEditDragEnd) {
return null;
}
return createNormalizedRect(localEditDragStart, localEditDragEnd);
}, [localEditDragEnd, localEditDragStart, localEditMode]);
const localEditDraftRectStyle = useMemo(() => {
if (!boxCutoutMetrics || !localEditDraftRect) return null;
return getNormalizedRectOverlayStyle(localEditDraftRect, boxCutoutMetrics);
}, [boxCutoutMetrics, localEditDraftRect]);
const localEditRegionOverlays = useMemo(() => {
if (!boxCutoutMetrics) return [];
return localEditRegions.map((region) => ({
region,
style: getNormalizedRectOverlayStyle(region.rect, boxCutoutMetrics),
}));
}, [boxCutoutMetrics, localEditRegions]);
const localEditScaleTargetOverlays = useMemo(() => {
if (!boxCutoutMetrics || localEditOperationMode !== "scale_adjust") {
return [];
}
return localEditRegions
.filter((region) => region.targetRect)
.map((region) => {
const targetRect = region.targetRect as NormalizedCropRect;
return {
region,
sourceStyle: getNormalizedRectOverlayStyle(region.rect, boxCutoutMetrics),
targetRect,
style: getNormalizedRectOverlayStyle(targetRect, boxCutoutMetrics),
};
});
}, [boxCutoutMetrics, localEditOperationMode, localEditRegions]);
const localEditPromptInlineOverlays = useMemo(() => {
if (!localEditMode || !boxCutoutMetrics) {
return [];
}
return localEditRegionOverlays.map(({ region, style }) => {
const left = Number(style.left || 0);
const top = Number(style.top || 0);
const width = Number(style.width || 0);
const height = Number(style.height || 0);
const imageBottom = boxCutoutMetrics.offsetY + boxCutoutMetrics.renderedHeight;
const referenceCount = region.referenceImages?.length || 0;
const promptStackHeight = referenceCount > 0 ? 82 : 42;
const placeAbove = top + height + promptStackHeight + 14 > imageBottom;
const promptTop = placeAbove
? top - promptStackHeight - 8
: top + height + 8;
const promptWidth = Math.min(Math.max(width, 236), 360);
const isEditing =
editingLocalEditRegionId === region.id || !region.prompt.trim();
return {
region,
isEditing,
style: {
left,
top: Math.max(4, promptTop),
width: promptWidth,
zIndex: isEditing ? 190 : 170,
} satisfies React.CSSProperties,
};
});
}, [
boxCutoutMetrics,
editingLocalEditRegionId,
localEditMode,
localEditRegionOverlays,
]);
const localEditPromptViewportOverlays = useMemo(
() =>
localEditPromptInlineOverlays.map(({ region, isEditing, style }) => {
const viewportWidth = Number(style.width || 0) * nodeContentScale;
return {
region,
isEditing,
isAwaitingScaleTarget:
localEditOperationMode === "scale_adjust" && !region.targetRect,
style: {
...style,
left: Number(style.left || 0) * nodeContentScale,
top: Number(style.top || 0) * nodeContentScale,
transform: `scale(${localEditPromptVisualScale})`,
transformOrigin: "top left",
width:
localEditPromptVisualScale > 0
? viewportWidth / localEditPromptVisualScale
: viewportWidth,
} satisfies React.CSSProperties,
};
}),
[
localEditOperationMode,
localEditPromptInlineOverlays,
localEditPromptVisualScale,
nodeContentScale,
]
);
useEffect(() => {
if (
!localEditMode ||
!data.src ||
localEditPromptViewportOverlays.length === 0 ||
typeof window === "undefined"
) {
setLocalEditPromptPortalFrame(null);
return;
}
let animationFrame = 0;
const updatePortalFrame = () => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) {
return;
}
const nextFrame = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
setLocalEditPromptPortalFrame((currentFrame) => {
if (
currentFrame &&
Math.abs(currentFrame.left - nextFrame.left) < 0.5 &&
Math.abs(currentFrame.top - nextFrame.top) < 0.5 &&
Math.abs(currentFrame.width - nextFrame.width) < 0.5 &&
Math.abs(currentFrame.height - nextFrame.height) < 0.5
) {
return currentFrame;
}
return nextFrame;
});
};
const schedulePortalFrameUpdate = () => {
window.cancelAnimationFrame(animationFrame);
animationFrame = window.requestAnimationFrame(updatePortalFrame);
};
updatePortalFrame();
const intervalId = window.setInterval(updatePortalFrame, 250);
window.addEventListener("resize", schedulePortalFrameUpdate);
window.addEventListener("scroll", schedulePortalFrameUpdate, true);
return () => {
window.cancelAnimationFrame(animationFrame);
window.clearInterval(intervalId);
window.removeEventListener("resize", schedulePortalFrameUpdate);
window.removeEventListener("scroll", schedulePortalFrameUpdate, true);
};
}, [data.src, localEditMode, localEditPromptViewportOverlays.length]);
const currentPickerClickStyle = useMemo(() => {
if (!boxCutoutMetrics || !currentClick) return null;
return {
left:
boxCutoutMetrics.offsetX +
currentClick.x * boxCutoutMetrics.renderedWidth,
top:
boxCutoutMetrics.offsetY +
currentClick.y * boxCutoutMetrics.renderedHeight,
};
}, [boxCutoutMetrics, currentClick]);
const cropRectStyle = useMemo(() => {
if (!boxCutoutMetrics || !cropRect) return null;
return {
left: boxCutoutMetrics.offsetX + cropRect.x * boxCutoutMetrics.renderedWidth,
top: boxCutoutMetrics.offsetY + cropRect.y * boxCutoutMetrics.renderedHeight,
width: cropRect.width * boxCutoutMetrics.renderedWidth,
height: cropRect.height * boxCutoutMetrics.renderedHeight,
};
}, [boxCutoutMetrics, cropRect]);
const cropPixelSize = useMemo(() => {
if (!cropRect || !cropImageMetrics) {
return { width: 0, height: 0 };
}
return {
width: Math.max(
0,
Math.round(cropRect.width * cropImageMetrics.naturalWidth)
),
height: Math.max(
0,
Math.round(cropRect.height * cropImageMetrics.naturalHeight)
),
};
}, [cropImageMetrics, cropRect]);
const currentCropAspectRatio =
cropRect && cropRect.height > 0 ? cropRect.width / cropRect.height : null;
const selectedCropPreset = getCropPresetById(cropPresetId);
const effectiveCropAspectRatio =
cropLockAspectRatio
? selectedCropPreset?.ratio || currentCropAspectRatio
: null;
const resetBoxCutoutState = useCallback(() => {
setBoxCutoutMode(false);
setIsBoxCutoutDrawing(false);
setBoxCutoutSelection(null);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
}, []);
const resetLocalEditState = useCallback(() => {
setLocalEditMode(false);
setLocalEditOperationMode("local_edit");
setIsLocalEditDrawing(false);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
setLocalEditRegionInteraction(null);
setEditingLocalEditRegionId(null);
setUploadingLocalEditReferenceRegionId(null);
}, []);
const focusLocalEditPromptInput = useCallback((regionId: string) => {
if (typeof window === "undefined") return;
window.requestAnimationFrame(() => {
const input = localEditPromptInputRefs.current[regionId];
if (!input) return;
input.focus({ preventScroll: true });
const cursorPosition = input.value.length;
input.setSelectionRange(cursorPosition, cursorPosition);
});
}, []);
useEffect(() => {
if (!localEditMode || !editingLocalEditRegionId) return;
focusLocalEditPromptInput(editingLocalEditRegionId);
}, [editingLocalEditRegionId, focusLocalEditPromptInput, localEditMode]);
useEffect(() => {
localEditRegionsRef.current = localEditRegions;
}, [localEditRegions]);
const commitLocalEditRegions = useCallback(
(regions: LocalEditRegion[]) => {
const nextRegions = reindexLocalEditRegions(
regions.slice(0, LOCAL_EDIT_MAX_REGIONS)
);
const nextDrafts = createLocalEditPromptDraftMap(
nextRegions,
localEditPromptDraftRefs.current
);
localEditRegionsRef.current = nextRegions;
setLocalEditRegions(nextRegions);
localEditPromptDraftRefs.current = nextDrafts;
setLocalEditPromptDrafts(nextDrafts);
const nextMetadata =
data.metadata && typeof data.metadata === "object"
? { ...(data.metadata as Record) }
: {};
if (nextRegions.length > 0) {
nextMetadata[LOCAL_EDIT_METADATA_KEY] = nextRegions;
} else {
delete nextMetadata[LOCAL_EDIT_METADATA_KEY];
}
const syncedAnnotationMetadata =
syncCanvasLocalEditRegionAnnotationsMetadata({
metadata: nextMetadata,
nodeId: node.id,
regions: nextRegions,
});
updateNode(node.id, {
data: {
...data,
metadata:
Object.keys(syncedAnnotationMetadata.metadata).length > 0
? syncedAnnotationMetadata.metadata
: undefined,
},
});
},
[data, node.id, updateNode]
);
const updateLocalEditPromptDraft = useCallback(
(regionId: string, prompt: string) => {
localEditPromptDraftRefs.current[regionId] = prompt;
setLocalEditPromptDrafts((current) =>
current[regionId] === prompt
? current
: {
...current,
[regionId]: prompt,
}
);
},
[]
);
const updateLocalEditPromptDraftRef = useCallback(
(regionId: string, prompt: string) => {
localEditPromptDraftRefs.current[regionId] = prompt;
},
[]
);
const restoreLocalEditPromptSelection = useCallback(
(regionId: string, position: number) => {
if (typeof window === "undefined") return;
window.requestAnimationFrame(() => {
const input = localEditPromptInputRefs.current[regionId];
if (!input) return;
const safePosition = clampNumber(position, 0, input.value.length);
input.setSelectionRange(safePosition, safePosition);
});
},
[]
);
const applyLocalEditPromptKeyEdit = useCallback(
(
regionId: string,
input: HTMLInputElement,
event: React.KeyboardEvent
) => {
if (
event.nativeEvent.isComposing ||
event.key === "Process" ||
event.metaKey ||
event.ctrlKey ||
event.altKey
) {
return false;
}
const currentValue = localEditPromptDraftRefs.current[regionId] ?? input.value;
const selectionStart = input.selectionStart ?? currentValue.length;
const selectionEnd = input.selectionEnd ?? selectionStart;
const start = clampNumber(selectionStart, 0, currentValue.length);
const end = clampNumber(selectionEnd, start, currentValue.length);
let nextValue: string | null = null;
let nextCursor = start;
if (event.key.length === 1) {
nextValue = `${currentValue.slice(0, start)}${event.key}${currentValue.slice(end)}`;
nextCursor = start + event.key.length;
} else if (event.key === "Backspace") {
if (start !== end) {
nextValue = `${currentValue.slice(0, start)}${currentValue.slice(end)}`;
nextCursor = start;
} else if (start > 0) {
nextValue = `${currentValue.slice(0, start - 1)}${currentValue.slice(end)}`;
nextCursor = start - 1;
}
} else if (event.key === "Delete") {
if (start !== end) {
nextValue = `${currentValue.slice(0, start)}${currentValue.slice(end)}`;
} else if (start < currentValue.length) {
nextValue = `${currentValue.slice(0, start)}${currentValue.slice(start + 1)}`;
}
nextCursor = start;
}
if (nextValue === null) {
return false;
}
event.preventDefault();
input.value = nextValue;
updateLocalEditPromptDraftRef(regionId, nextValue);
restoreLocalEditPromptSelection(regionId, nextCursor);
return true;
},
[restoreLocalEditPromptSelection, updateLocalEditPromptDraftRef]
);
const finishLocalEditPromptComposition = useCallback(
(regionId: string, input: HTMLInputElement, compositionText: string) => {
const snapshot = localEditPromptCompositionStartRefs.current[regionId];
delete localEditPromptCompositionStartRefs.current[regionId];
if (!snapshot) {
updateLocalEditPromptDraftRef(regionId, input.value);
return;
}
if (input.value !== snapshot.value) {
updateLocalEditPromptDraftRef(regionId, input.value);
return;
}
if (!compositionText) {
return;
}
const nextValue = `${snapshot.value.slice(0, snapshot.start)}${compositionText}${snapshot.value.slice(snapshot.end)}`;
input.value = nextValue;
updateLocalEditPromptDraftRef(regionId, nextValue);
restoreLocalEditPromptSelection(
regionId,
snapshot.start + compositionText.length
);
},
[restoreLocalEditPromptSelection, updateLocalEditPromptDraftRef]
);
const refreshBoxCutoutMetrics = useCallback(() => {
const imageElement = displayImageRef.current;
if (!imageElement) {
setBoxCutoutMetrics(null);
return;
}
const naturalWidth =
imageElement.naturalWidth ||
metadataResolution?.width ||
loadedResolution?.width ||
0;
const naturalHeight =
imageElement.naturalHeight ||
metadataResolution?.height ||
loadedResolution?.height ||
0;
const boxWidth = imageElement.clientWidth;
const boxHeight = imageElement.clientHeight;
if (!naturalWidth || !naturalHeight || !boxWidth || !boxHeight) {
setBoxCutoutMetrics(null);
return;
}
const displayScale = Math.min(
boxWidth / naturalWidth,
boxHeight / naturalHeight
);
const renderedWidth = naturalWidth * displayScale;
const renderedHeight = naturalHeight * displayScale;
setBoxCutoutMetrics({
offsetX: (boxWidth - renderedWidth) / 2,
offsetY: (boxHeight - renderedHeight) / 2,
renderedWidth,
renderedHeight,
naturalWidth,
naturalHeight,
});
}, [loadedResolution, metadataResolution]);
const handleDisplayImageLoad = useCallback(
(event: React.SyntheticEvent) => {
const width = Math.round(event.currentTarget.naturalWidth || 0);
const height = Math.round(event.currentTarget.naturalHeight || 0);
if (width > 0 && height > 0) {
setLoadedResolution((current) => {
if (current?.width === width && current?.height === height) {
return current;
}
return { width, height };
});
}
refreshBoxCutoutMetrics();
},
[refreshBoxCutoutMetrics]
);
const getBoxCutoutPointerPoint = useCallback(
(clientX: number, clientY: number) => {
const imageElement = displayImageRef.current;
if (!imageElement) return null;
const currentMetrics = (() => {
if (
boxCutoutMetrics &&
boxCutoutMetrics.renderedWidth > 0 &&
boxCutoutMetrics.renderedHeight > 0
) {
return boxCutoutMetrics;
}
const naturalWidth =
imageElement.naturalWidth ||
metadataResolution?.width ||
loadedResolution?.width ||
0;
const naturalHeight =
imageElement.naturalHeight ||
metadataResolution?.height ||
loadedResolution?.height ||
0;
const boxWidth = imageElement.clientWidth;
const boxHeight = imageElement.clientHeight;
if (!naturalWidth || !naturalHeight || !boxWidth || !boxHeight) {
return null;
}
const displayScale = Math.min(
boxWidth / naturalWidth,
boxHeight / naturalHeight
);
return {
offsetX: (boxWidth - naturalWidth * displayScale) / 2,
offsetY: (boxHeight - naturalHeight * displayScale) / 2,
renderedWidth: naturalWidth * displayScale,
renderedHeight: naturalHeight * displayScale,
naturalWidth,
naturalHeight,
} satisfies ImageDisplayMetrics;
})();
if (!currentMetrics) return null;
const rect = imageElement.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
// The image node content may be counter-scaled with CSS transforms.
// getBoundingClientRect() returns the transformed size, while
// object-contain offsets are calculated in the element's local box.
// Convert the pointer back into that local coordinate space first.
const localX =
((clientX - rect.left) / rect.width) * imageElement.clientWidth;
const localY =
((clientY - rect.top) / rect.height) * imageElement.clientHeight;
const rawX = localX - currentMetrics.offsetX;
const rawY = localY - currentMetrics.offsetY;
const clampedX = clampNumber(rawX, 0, currentMetrics.renderedWidth);
const clampedY = clampNumber(rawY, 0, currentMetrics.renderedHeight);
return {
point: {
x:
currentMetrics.renderedWidth > 0
? clampedX / currentMetrics.renderedWidth
: 0,
y:
currentMetrics.renderedHeight > 0
? clampedY / currentMetrics.renderedHeight
: 0,
} satisfies BoxCutoutPoint,
insideImage:
rawX >= 0 &&
rawX <= currentMetrics.renderedWidth &&
rawY >= 0 &&
rawY <= currentMetrics.renderedHeight,
metrics: currentMetrics,
};
},
[boxCutoutMetrics, loadedResolution, metadataResolution]
);
// 退出拾取模式时清空状态
useEffect(() => {
if (!isThisNodePicking) {
setCurrentClick(null);
}
}, [isThisNodePicking]);
useEffect(() => {
if (!textEditPanelOpen) {
setTextEditItems(normalizeTextEditItems(data.textEditItems));
setDeletedTextItems([]);
setTextEditError(null);
}
}, [data.textEditItems, textEditPanelOpen]);
useEffect(() => {
if (localEditMode) return;
const nextRegions = getLocalEditRegionsFromMetadata(data.metadata);
const nextDrafts = createLocalEditPromptDraftMap(nextRegions);
localEditPromptDraftRefs.current = nextDrafts;
setLocalEditPromptDrafts(nextDrafts);
setLocalEditRegions(nextRegions);
}, [data.metadata, data.src, localEditMode]);
useEffect(() => {
if (!isSelected) {
setCropPanelOpen(false);
setCropRect(null);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setIsEditingTitle(false);
setPromptDialogOpen(false);
resetBoxCutoutState();
// 局部编辑有独立的“退出”按钮,避免用户点到浮层或画布边缘时丢失框选与底部操作条。
if (!localEditMode) {
resetLocalEditState();
}
}
}, [isSelected, localEditMode, resetBoxCutoutState, resetLocalEditState]);
useEffect(() => {
if (anglePanelOpen) return;
anglePreviewDragRef.current = null;
setIsAnglePreviewDragging(false);
}, [anglePanelOpen]);
useEffect(() => {
if (!hasCompareSource || isThisNodePicking) {
setIsCompareMode(false);
}
}, [hasCompareSource, isThisNodePicking]);
useEffect(() => {
if (
(isThisNodePicking ||
isCompareMode ||
sceneFusionDialogOpen ||
promptDialogOpen ||
notePanelOpen) &&
(boxCutoutMode || cropPanelOpen || localEditMode)
) {
resetBoxCutoutState();
resetLocalEditState();
setCropPanelOpen(false);
setCropInteraction(null);
}
}, [
boxCutoutMode,
cropPanelOpen,
isCompareMode,
isThisNodePicking,
localEditMode,
notePanelOpen,
promptDialogOpen,
resetBoxCutoutState,
resetLocalEditState,
sceneFusionDialogOpen,
]);
useEffect(() => {
if (!boxCutoutMode || typeof window === "undefined") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
resetBoxCutoutState();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [boxCutoutMode, resetBoxCutoutState]);
useEffect(() => {
if (!localEditMode || typeof window === "undefined") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (isLocalEditControlTarget(event.target)) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
resetLocalEditState();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [localEditMode, resetLocalEditState]);
useEffect(() => {
if (!cropPanelOpen || typeof window === "undefined") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setCropPanelOpen(false);
setCropInteraction(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [cropPanelOpen]);
useEffect(() => {
setLoadedResolution(null);
}, [data.src]);
useEffect(() => {
setHasEnteredViewport(false);
}, [data.src]);
useEffect(() => {
if (!data.src || hasEnteredViewport || typeof window === "undefined") {
return;
}
const element = containerRef.current;
if (!element || typeof IntersectionObserver === "undefined") {
setHasEnteredViewport(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setHasEnteredViewport(true);
observer.disconnect();
}
},
{
root: null,
// 轻微提前加载,保证拖动画布时体验顺滑,但不请求远离视口的图片。
rootMargin: "320px",
threshold: 0.01,
}
);
observer.observe(element);
return () => observer.disconnect();
}, [data.src, hasEnteredViewport]);
useEffect(() => {
if (typeof window === "undefined") return;
let rafId: number | null = null;
const scheduleRefresh = () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
rafId = window.requestAnimationFrame(() => {
refreshBoxCutoutMetrics();
});
};
scheduleRefresh();
window.addEventListener("resize", scheduleRefresh);
const resizeObserver =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(scheduleRefresh)
: null;
if (containerRef.current) {
resizeObserver?.observe(containerRef.current);
}
if (displayImageRef.current) {
resizeObserver?.observe(displayImageRef.current);
}
return () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
window.removeEventListener("resize", scheduleRefresh);
resizeObserver?.disconnect();
};
}, [data.src, refreshBoxCutoutMetrics]);
const updateCompareSplitByClientX = useCallback((clientX: number) => {
const frame = compareFrameRef.current;
if (!frame) return;
const rect = frame.getBoundingClientRect();
if (rect.width <= 0) return;
const rawPercent = ((clientX - rect.left) / rect.width) * 100;
setCompareSplitPercent(Math.max(0, Math.min(100, rawPercent)));
}, []);
useEffect(() => {
if (!isCompareMode) return;
const handlePointerDownOutside = (event: PointerEvent) => {
const target = event.target as Node | null;
if (!target) return;
const container = containerRef.current;
if (container && container.contains(target)) return;
setIsCompareMode(false);
};
document.addEventListener("pointerdown", handlePointerDownOutside, true);
return () => {
document.removeEventListener(
"pointerdown",
handlePointerDownOutside,
true
);
};
}, [isCompareMode]);
useEffect(() => {
if (!isEditingTitle) {
setDraftTitle(getImageNodeTitle(data, node.id));
}
}, [data, isEditingTitle, node.id]);
useEffect(() => {
if (promptDialogOpen) {
setRegeneratePrompt(resolveRegeneratePromptSeed(data));
}
}, [data, promptDialogOpen]);
useEffect(() => {
if (notePanelOpen) {
setDraftNote(data.note || "");
}
}, [data.note, notePanelOpen]);
useEffect(() => {
if (
typeof window === "undefined" ||
!isSelected ||
isMultiSelected ||
!data.src ||
isEditingTitle ||
sceneFusionDialogOpen ||
promptDialogOpen ||
notePanelOpen ||
boxCutoutMode ||
localEditMode
) {
return;
}
const toolbarEl = toolbarRef.current;
if (!toolbarEl) return;
let rafId: number | null = null;
const measure = () => {
const next = {
width: toolbarEl.offsetWidth,
height: toolbarEl.offsetHeight,
};
setToolbarSize((prev) =>
Math.abs(prev.width - next.width) < 0.5 &&
Math.abs(prev.height - next.height) < 0.5
? prev
: next
);
};
const scheduleMeasure = () => {
if (rafId !== null) window.cancelAnimationFrame(rafId);
rafId = window.requestAnimationFrame(measure);
};
scheduleMeasure();
window.addEventListener("resize", scheduleMeasure);
const resizeObserver =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(scheduleMeasure)
: null;
resizeObserver?.observe(toolbarEl);
return () => {
window.removeEventListener("resize", scheduleMeasure);
resizeObserver?.disconnect();
if (rafId !== null) window.cancelAnimationFrame(rafId);
};
}, [
boxCutoutMode,
data.src,
isEditingTitle,
isMultiSelected,
isSelected,
localEditMode,
notePanelOpen,
promptDialogOpen,
sceneFusionDialogOpen,
]);
useEffect(() => {
if (
typeof window === "undefined" ||
!isSelected ||
isMultiSelected ||
!data.src ||
(!boxCutoutMode && !localEditMode)
) {
return;
}
const toolbarEl = boxCutoutToolbarRef.current;
if (!toolbarEl) return;
let rafId: number | null = null;
const measure = () => {
const next = {
width: toolbarEl.offsetWidth,
height: toolbarEl.offsetHeight,
};
setBoxCutoutToolbarSize((prev) =>
Math.abs(prev.width - next.width) < 0.5 &&
Math.abs(prev.height - next.height) < 0.5
? prev
: next
);
};
const scheduleMeasure = () => {
if (rafId !== null) window.cancelAnimationFrame(rafId);
rafId = window.requestAnimationFrame(measure);
};
scheduleMeasure();
window.addEventListener("resize", scheduleMeasure);
const resizeObserver =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(scheduleMeasure)
: null;
resizeObserver?.observe(toolbarEl);
return () => {
window.removeEventListener("resize", scheduleMeasure);
resizeObserver?.disconnect();
if (rafId !== null) window.cancelAnimationFrame(rafId);
};
}, [boxCutoutMode, data.src, isMultiSelected, isSelected, localEditMode]);
// 🆕 处理图片点击标注
const handlePickerClick = useCallback(
async (e: React.MouseEvent) => {
if (!isThisNodePicking || isPickerLoading || !data.src) return;
e.stopPropagation();
e.preventDefault();
const pointerResult = getBoxCutoutPointerPoint(e.clientX, e.clientY);
if (!pointerResult?.insideImage) {
toast.info("请点击图片内容区域");
return;
}
const clickX = pointerResult.point.x;
const clickY = pointerResult.point.y;
setCurrentClick({ x: clickX, y: clickY });
setPickerLoading(true);
try {
// 在图片上绘制红色标记点
const markedImageBase64 = await drawMarkerOnImage(
data.src,
clickX,
clickY
);
const response = await fetch("/api/pick-object", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
imageData: markedImageBase64,
clickX,
clickY,
}),
});
if (!response.ok) throw new Error("识别失败");
const result = await response.json();
if (result.success && result.object) {
const picked: PickedObject = {
imageUrl: data.src,
imageNodeId: node.id,
clickX,
clickY,
objectName: result.object.name,
objectPosition: result.object.position,
confidence: result.object.confidence,
boundingBox: result.object.boundingBox,
};
addPendingPickedObject(picked);
// 标注成功后自动关闭拾取模式,直接生成标签
setPickerMode(false);
setCurrentClick(null);
toast.success(`已添加: ${result.object.name}`);
} else {
toast.error(result.error || "未能识别物品");
setCurrentClick(null);
}
} catch (error) {
console.error("物品拾取失败:", error);
toast.error("识别失败,请重试");
setCurrentClick(null);
} finally {
setPickerLoading(false);
}
},
[
isThisNodePicking,
isPickerLoading,
data.src,
getBoxCutoutPointerPoint,
node.id,
setPickerLoading,
addPendingPickedObject,
setPickerMode,
]
);
// 🆕 取消标注
const handleCancelPicking = useCallback(() => {
setPickerMode(false);
}, [setPickerMode]);
const handleStartPicking = useCallback(
(event?: React.MouseEvent) => {
event?.stopPropagation();
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
resetLocalEditState();
setPickerMode(true, node.id);
toast.info("点击图片中的物品进行拾取");
},
[data.src, isProcessing, node.id, resetLocalEditState, setPickerMode]
);
const handleEnterBoxCutoutMode = useCallback(
(event?: React.MouseEvent | React.PointerEvent) => {
event?.stopPropagation();
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
if (isThisNodePicking) {
setPickerMode(false);
}
setIsCompareMode(false);
resetLocalEditState();
setBoxCutoutSelection(null);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
setIsBoxCutoutDrawing(false);
setBoxCutoutMode(true);
window.requestAnimationFrame(() => {
refreshBoxCutoutMetrics();
});
},
[
data.src,
isProcessing,
isThisNodePicking,
refreshBoxCutoutMetrics,
resetLocalEditState,
setPickerMode,
]
);
const handleBoxCutoutPointerDown = useCallback(
(event: React.PointerEvent) => {
if (!boxCutoutMode || isProcessing) return;
event.preventDefault();
event.stopPropagation();
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult?.insideImage) {
return;
}
setBoxCutoutSelection(null);
setBoxCutoutDragStart(pointerResult.point);
setBoxCutoutDragEnd(pointerResult.point);
setIsBoxCutoutDrawing(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[boxCutoutMode, getBoxCutoutPointerPoint, isProcessing]
);
const handleBoxCutoutPointerMove = useCallback(
(event: React.PointerEvent) => {
if (!boxCutoutMode || !isBoxCutoutDrawing) return;
event.preventDefault();
event.stopPropagation();
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult) return;
setBoxCutoutDragEnd(pointerResult.point);
},
[boxCutoutMode, getBoxCutoutPointerPoint, isBoxCutoutDrawing]
);
const finalizeBoxCutoutSelection = useCallback(
(clientX: number, clientY: number) => {
if (!boxCutoutMode || !boxCutoutDragStart) {
setIsBoxCutoutDrawing(false);
return;
}
const pointerResult = getBoxCutoutPointerPoint(clientX, clientY);
if (!pointerResult) {
setIsBoxCutoutDrawing(false);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
return;
}
const nextRect = createNormalizedRect(
boxCutoutDragStart,
pointerResult.point
);
const nextWidthPx = nextRect.width * pointerResult.metrics.naturalWidth;
const nextHeightPx =
nextRect.height * pointerResult.metrics.naturalHeight;
if (
nextWidthPx >= BOX_CUTOUT_MIN_EDGE_PX &&
nextHeightPx >= BOX_CUTOUT_MIN_EDGE_PX
) {
setBoxCutoutSelection(nextRect);
} else {
setBoxCutoutSelection(null);
}
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
setIsBoxCutoutDrawing(false);
},
[boxCutoutDragStart, boxCutoutMode, getBoxCutoutPointerPoint]
);
const handleBoxCutoutPointerUp = useCallback(
(event: React.PointerEvent) => {
if (!boxCutoutMode) return;
event.preventDefault();
event.stopPropagation();
finalizeBoxCutoutSelection(event.clientX, event.clientY);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[boxCutoutMode, finalizeBoxCutoutSelection]
);
const handleBoxCutoutPointerCancel = useCallback(
(event: React.PointerEvent) => {
if (!boxCutoutMode) return;
event.preventDefault();
event.stopPropagation();
setIsBoxCutoutDrawing(false);
setBoxCutoutDragStart(null);
setBoxCutoutDragEnd(null);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[boxCutoutMode]
);
const handleEnterLocalEditMode = useCallback(
(
event?: React.MouseEvent | React.PointerEvent,
operationMode: CanvasLocalEditOperationMode = "local_edit"
) => {
event?.stopPropagation();
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("局部编辑需要使用已上传到云端的图片");
return;
}
if (isProcessing || isSubmittingLocalEdit) {
toast.info("正在处理中,请稍候...");
return;
}
if (isThisNodePicking) {
setPickerMode(false);
}
setIsCompareMode(false);
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
setLocalEditRegions(getLocalEditRegionsFromMetadata(data.metadata));
setLocalEditOperationMode(operationMode);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
setIsLocalEditDrawing(false);
setLocalEditMode(true);
window.requestAnimationFrame(() => {
refreshBoxCutoutMetrics();
});
toast.info(
operationMode === "scale_adjust"
? "先框选要调整比例的主体,再框选青色目标大小和位置"
: "拖拽框选要局部编辑的位置,并在框下方填写提示词"
);
},
[
data.metadata,
data.src,
isProcessing,
isSubmittingLocalEdit,
isThisNodePicking,
refreshBoxCutoutMetrics,
resetBoxCutoutState,
setPickerMode,
]
);
const handleLocalEditPointerDown = useCallback(
(event: React.PointerEvent) => {
if (!localEditMode || isProcessing || isSubmittingLocalEdit) return;
event.preventDefault();
event.stopPropagation();
const pendingTargetRegion =
localEditOperationMode === "scale_adjust"
? localEditRegions.find((region) => !region.targetRect)
: null;
if (!pendingTargetRegion && localEditRegions.length >= LOCAL_EDIT_MAX_REGIONS) {
toast.info(`局部编辑最多支持 ${LOCAL_EDIT_MAX_REGIONS} 个区域`);
return;
}
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult?.insideImage) return;
setEditingLocalEditRegionId(null);
setLocalEditDrawingTargetRegionId(pendingTargetRegion?.id || null);
setLocalEditDragStart(pointerResult.point);
setLocalEditDragEnd(pointerResult.point);
setIsLocalEditDrawing(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[
getBoxCutoutPointerPoint,
isProcessing,
isSubmittingLocalEdit,
localEditMode,
localEditOperationMode,
localEditRegions,
localEditRegions.length,
]
);
const handleLocalEditPointerMove = useCallback(
(event: React.PointerEvent) => {
if (!localEditMode || !isLocalEditDrawing) return;
event.preventDefault();
event.stopPropagation();
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult) return;
setLocalEditDragEnd(pointerResult.point);
},
[getBoxCutoutPointerPoint, isLocalEditDrawing, localEditMode]
);
const finalizeLocalEditRegion = useCallback(
(clientX: number, clientY: number) => {
if (!localEditMode || !localEditDragStart) {
setIsLocalEditDrawing(false);
return;
}
const pointerResult = getBoxCutoutPointerPoint(clientX, clientY);
if (!pointerResult) {
setIsLocalEditDrawing(false);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
return;
}
const nextRect = createNormalizedRect(
localEditDragStart,
pointerResult.point
);
const nextWidthPx = nextRect.width * pointerResult.metrics.naturalWidth;
const nextHeightPx =
nextRect.height * pointerResult.metrics.naturalHeight;
if (
nextWidthPx >= LOCAL_EDIT_MIN_EDGE_PX &&
nextHeightPx >= LOCAL_EDIT_MIN_EDGE_PX
) {
if (localEditDrawingTargetRegionId) {
const nextRegions = reindexLocalEditRegions(
localEditRegions.map((region) => {
if (region.id !== localEditDrawingTargetRegionId) return region;
return {
...region,
targetRect: nextRect,
prompt: getCanvasScaleAdjustRegionPrompt(null, true),
};
})
);
commitLocalEditRegions(nextRegions);
toast.info("已设置青色目标框,可拖动它继续调整大小和位置");
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
setIsLocalEditDrawing(false);
return;
}
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: isScaleAdjustMode
? "比例调整"
: "",
rect: nextRect,
referenceImages: [],
createdAt: Date.now(),
};
commitLocalEditRegions([...localEditRegions, nextRegion]);
setEditingLocalEditRegionId(isScaleAdjustMode ? null : nextRegion.id);
if (isScaleAdjustMode) {
setLocalEditDrawingTargetRegionId(nextRegion.id);
toast.info("继续拖拽框选青色目标大小和位置");
}
} else {
toast.info("框选区域太小,请至少拖出 24px 以上的区域");
}
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
setIsLocalEditDrawing(false);
},
[
commitLocalEditRegions,
getBoxCutoutPointerPoint,
localEditDragStart,
localEditDrawingTargetRegionId,
localEditMode,
localEditOperationMode,
localEditRegions,
]
);
const handleLocalEditPointerUp = useCallback(
(event: React.PointerEvent) => {
if (!localEditMode) return;
event.preventDefault();
event.stopPropagation();
finalizeLocalEditRegion(event.clientX, event.clientY);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[finalizeLocalEditRegion, localEditMode]
);
const handleLocalEditPointerCancel = useCallback(
(event: React.PointerEvent) => {
if (!localEditMode) return;
event.preventDefault();
event.stopPropagation();
setIsLocalEditDrawing(false);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setLocalEditDrawingTargetRegionId(null);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[localEditMode]
);
const updateLocalEditRegionRect = useCallback(
(
regionId: string,
rect: NormalizedCropRect,
target: LocalEditRegionInteractionState["target"] = "source",
shouldCommit = false
) => {
const nextRegions = reindexLocalEditRegions(
localEditRegionsRef.current.map((region) => {
if (region.id !== regionId) return region;
if (target === "scaleTarget") {
return {
...region,
targetRect: rect,
prompt: getCanvasScaleAdjustRegionPrompt(null, true),
};
}
return { ...region, rect };
})
);
localEditRegionsRef.current = nextRegions;
setLocalEditRegions(nextRegions);
if (shouldCommit) {
commitLocalEditRegions(nextRegions);
}
},
[commitLocalEditRegions]
);
const handleLocalEditRegionPointerDown = useCallback(
(
event: React.PointerEvent,
region: LocalEditRegion,
target: LocalEditRegionInteractionState["target"] = "source",
targetRect?: NormalizedCropRect
) => {
if (!localEditMode || isProcessing || isSubmittingLocalEdit) return;
event.preventDefault();
event.stopPropagation();
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult?.insideImage) return;
const handleElement = (event.target as HTMLElement | null)?.closest(
"[data-local-edit-region-handle]"
) as HTMLElement | null;
const handle =
(handleElement?.dataset.localEditRegionHandle as
| CropInteractionHandle
| undefined) || "move";
setIsLocalEditDrawing(false);
setLocalEditDragStart(null);
setLocalEditDragEnd(null);
setEditingLocalEditRegionId(null);
setLocalEditRegionInteraction({
handle,
pointerId: event.pointerId,
regionId: region.id,
target,
startPoint: pointerResult.point,
startRect: target === "scaleTarget" ? targetRect || region.rect : region.rect,
});
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[
getBoxCutoutPointerPoint,
isProcessing,
isSubmittingLocalEdit,
localEditMode,
]
);
const handleLocalEditRegionPointerMove = useCallback(
(event: React.PointerEvent, regionId: string) => {
if (
!localEditMode ||
!localEditRegionInteraction ||
localEditRegionInteraction.regionId !== regionId ||
localEditRegionInteraction.pointerId !== event.pointerId
) {
return;
}
event.preventDefault();
event.stopPropagation();
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult) return;
const minWidth = Math.min(
LOCAL_EDIT_MIN_EDGE_PX / Math.max(pointerResult.metrics.naturalWidth, 1),
1
);
const minHeight = Math.min(
LOCAL_EDIT_MIN_EDGE_PX / Math.max(pointerResult.metrics.naturalHeight, 1),
1
);
const nextRect = updateCropRectWithPointer({
aspectRatio: null,
handle: localEditRegionInteraction.handle,
minHeight,
minWidth,
pointer: pointerResult.point,
startPoint: localEditRegionInteraction.startPoint,
startRect: localEditRegionInteraction.startRect,
});
updateLocalEditRegionRect(
regionId,
nextRect,
localEditRegionInteraction.target
);
},
[
getBoxCutoutPointerPoint,
localEditMode,
localEditRegionInteraction,
updateLocalEditRegionRect,
]
);
const handleLocalEditRegionPointerUp = useCallback(
(event: React.PointerEvent, regionId: string) => {
if (
!localEditRegionInteraction ||
localEditRegionInteraction.regionId !== regionId ||
localEditRegionInteraction.pointerId !== event.pointerId
) {
return;
}
event.preventDefault();
event.stopPropagation();
setLocalEditRegionInteraction(null);
commitLocalEditRegions(localEditRegionsRef.current);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[commitLocalEditRegions, localEditRegionInteraction]
);
const handleLocalEditRegionPointerCancel = useCallback(
(event: React.PointerEvent, regionId: string) => {
if (
!localEditRegionInteraction ||
localEditRegionInteraction.regionId !== regionId ||
localEditRegionInteraction.pointerId !== event.pointerId
) {
return;
}
event.preventDefault();
event.stopPropagation();
setLocalEditRegionInteraction(null);
commitLocalEditRegions(localEditRegionsRef.current);
event.currentTarget.releasePointerCapture?.(event.pointerId);
},
[commitLocalEditRegions, localEditRegionInteraction]
);
const persistLocalEditRegionPrompt = useCallback(
(regionId: string, prompt: string) => {
updateLocalEditPromptDraft(regionId, prompt);
commitLocalEditRegions(
localEditRegionsRef.current.map((region) =>
region.id === regionId ? { ...region, prompt } : region
)
);
},
[commitLocalEditRegions, updateLocalEditPromptDraft]
);
const deleteLocalEditRegion = useCallback(
(regionId: string) => {
delete localEditPromptDraftRefs.current[regionId];
delete localEditPromptCompositionStartRefs.current[regionId];
setLocalEditPromptDrafts((current) => {
if (!(regionId in current)) return current;
const next = { ...current };
delete next[regionId];
return next;
});
commitLocalEditRegions(
localEditRegionsRef.current.filter((region) => region.id !== regionId)
);
if (editingLocalEditRegionId === regionId) {
setEditingLocalEditRegionId(null);
}
},
[commitLocalEditRegions, editingLocalEditRegionId]
);
const handleLocalEditReferenceUpload = useCallback(
async (regionId: string, fileList: FileList | null) => {
const files = Array.from(fileList || []).filter((file) =>
file.type.startsWith("image/")
);
if (files.length === 0) return;
const currentRegion = localEditRegionsRef.current.find(
(region) => region.id === regionId
);
if (!currentRegion) return;
const currentReferences = currentRegion.referenceImages || [];
const remainingSlots =
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION - currentReferences.length;
if (remainingSlots <= 0) {
toast.info(
`每个局部编辑区域最多添加 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图`
);
return;
}
const selectedFiles = files.slice(0, remainingSlots);
if (files.length > selectedFiles.length) {
toast.info(
`已自动保留前 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图`
);
}
setUploadingLocalEditReferenceRegionId(regionId);
try {
const uploadedReferences = await Promise.all(
selectedFiles.map(async (file) => {
const uploaded = await uploadChatAttachmentBlob(file, file.name);
return {
id: createLocalEditReferenceImageId(),
url: uploaded.url,
name: file.name,
source: "upload" as const,
createdAt: Date.now(),
} satisfies LocalEditReferenceImage;
})
);
commitLocalEditRegions(
localEditRegionsRef.current.map((region) =>
region.id === regionId
? {
...region,
referenceImages: [
...(region.referenceImages || []),
...uploadedReferences,
].slice(0, LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION),
}
: region
)
);
toast.success(
uploadedReferences.length > 1
? `已添加 ${uploadedReferences.length} 张区域参考图`
: "已添加区域参考图"
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "参考图上传失败");
} finally {
setUploadingLocalEditReferenceRegionId(null);
}
},
[commitLocalEditRegions]
);
const removeLocalEditReferenceImage = useCallback(
(regionId: string, referenceId: string) => {
commitLocalEditRegions(
localEditRegionsRef.current.map((region) =>
region.id === regionId
? {
...region,
referenceImages: (region.referenceImages || []).filter(
(reference) => reference.id !== referenceId
),
}
: region
)
);
},
[commitLocalEditRegions]
);
const clearLocalEditRegions = useCallback(() => {
localEditPromptDraftRefs.current = {};
localEditPromptCompositionStartRefs.current = {};
setLocalEditPromptDrafts({});
commitLocalEditRegions([]);
setEditingLocalEditRegionId(null);
}, [commitLocalEditRegions]);
const handleSubmitLocalEdit = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("局部编辑需要使用已上传到云端的图片");
return;
}
if (isSubmittingLocalEdit || isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
const isScaleAdjustMode = localEditOperationMode === "scale_adjust";
const validRegions = reindexLocalEditRegions(
localEditRegions.map((region) => {
if (isScaleAdjustMode) {
const regionScalePercent = deriveCanvasScalePercentFromRects(
region.rect,
region.targetRect
);
return {
...region,
prompt: getCanvasScaleAdjustRegionPrompt(regionScalePercent, true),
};
}
return {
...region,
prompt: (
localEditPromptDraftRefs.current[region.id] ?? region.prompt
).trim(),
};
})
);
if (validRegions.length === 0) {
toast.error("请先框选至少一个局部编辑区域");
return;
}
if (isScaleAdjustMode) {
const missingTargetRegion = validRegions.find((region) => !region.targetRect);
if (missingTargetRegion) {
toast.error(`请为区域 ${missingTargetRegion.index} 框选青色目标框`);
return;
}
const unchangedRegion = validRegions.find(
(region) => !hasScaleAdjustTargetChanged(region.rect, region.targetRect)
);
if (unchangedRegion) {
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小或位置`);
return;
}
} else {
const emptyRegion = validRegions.find((region) => !region.prompt);
if (emptyRegion) {
setEditingLocalEditRegionId(emptyRegion.id);
toast.error(`请填写区域 ${emptyRegion.index} 的提示词`);
return;
}
}
const actionName = isScaleAdjustMode ? "比例调整" : "局部编辑";
const editMode = isScaleAdjustMode ? "scale-adjust" : "local-edit";
const scalePercent = isScaleAdjustMode
? deriveCanvasScalePercentFromRects(
validRegions[0].rect,
validRegions[0].targetRect
)
: undefined;
setIsSubmittingLocalEdit(true);
commitLocalEditRegions(validRegions);
resetLocalEditState();
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
editMode,
scalePercent,
sourceNodeId: node.id,
localEditMode: false,
sourceLocalEditRegions: validRegions,
},
},
connections: [node.id],
});
selectNode(newNodeId);
try {
const submitResult = await imageGenerationService.localEdit({
sourceImageUrl: data.src,
sourceNodeId: node.id,
operation: isScaleAdjustMode ? "scale-adjust" : undefined,
regions: validRegions.map((region) => ({
id: region.id,
index: region.index,
color: region.color,
prompt: region.prompt,
rect: region.rect,
targetRect: region.targetRect,
referenceImages: (region.referenceImages || []).map((reference) => ({
id: reference.id,
url: reference.url,
name: reference.name,
source: reference.source,
})),
})),
});
if (!submitResult.success || !submitResult.data?.taskId) {
throw new Error(submitResult.error || "局部编辑提交失败");
}
const taskId = submitResult.data.taskId;
const localEditDisplayPrompt =
submitResult.data.displayPrompt || submitResult.data.prompt || `${actionName}处理中...`;
const localEditSourceWidth = Number(submitResult.data.sourceWidth || 0);
const localEditSourceHeight = Number(submitResult.data.sourceHeight || 0);
const localEditSourceRatio =
localEditSourceWidth > 0 && localEditSourceHeight > 0
? localEditSourceWidth / localEditSourceHeight
: 0;
const localEditResultSize =
localEditSourceRatio > 0
? {
width: node.size.width,
height: Math.max(80, Math.round(node.size.width / localEditSourceRatio)),
}
: node.size;
updateNode(newNodeId, {
size: localEditResultSize,
data: {
src: "",
status: "generating",
prompt: localEditDisplayPrompt,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
editMode,
sourceNodeId: node.id,
scalePercent,
aspectRatio: submitResult.data.aspectRatio,
taskId,
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
localEditMode: false,
sourceHeight: submitResult.data.sourceHeight,
sourceLocalEditRegions: validRegions,
sourceWidth: submitResult.data.sourceWidth,
},
},
});
for (let attempt = 0; attempt < LOCAL_EDIT_MAX_POLL_ATTEMPTS; attempt += 1) {
await sleep(LOCAL_EDIT_POLL_INTERVAL_MS);
const taskResult = await imageGenerationService.getImageTask(taskId);
const task = taskResult.data?.task;
const status = String(task?.status || "").toLowerCase();
if (!taskResult.success || !task) {
continue;
}
if (status === "succeeded" || status === "completed") {
const generatedImage = task.result?.images?.[0];
const generatedUrl =
generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "";
if (!generatedUrl) {
throw new Error("局部编辑已完成但未返回图片");
}
updateNode(newNodeId, {
data: {
src: generatedUrl,
status: "completed",
prompt:
task.result?.prompt ||
submitResult.data.displayPrompt ||
submitResult.data.prompt ||
`${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
model: task.result?.model || task.result?.modelRef || undefined,
metadata: {
editMode,
sourceNodeId: node.id,
scalePercent,
aspectRatio: submitResult.data.aspectRatio,
taskId,
annotatedReferenceUrl: submitResult.data.annotatedReferenceUrl,
localEditMode: false,
localEditRegions: [],
sourceHeight: submitResult.data.sourceHeight,
sourceLocalEditRegions: validRegions,
sourceWidth: submitResult.data.sourceWidth,
},
},
});
resetLocalEditState();
toast.success(`${actionName}结果已生成`);
return;
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
throw new Error(task.error || "局部编辑生成失败");
}
}
throw new Error("局部编辑任务等待超时,请稍后重试");
} catch (error) {
const message = error instanceof Error ? error.message : "局部编辑失败";
console.error("[Canvas Local Edit] failed:", error);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
editMode,
sourceNodeId: node.id,
scalePercent,
error: message,
localEditMode: false,
sourceLocalEditRegions: validRegions,
},
},
});
toast.error(message);
} finally {
setIsProcessing(false);
setIsSubmittingLocalEdit(false);
}
}, [
addNode,
commitLocalEditRegions,
data.src,
displayTitle,
isProcessing,
isSubmittingLocalEdit,
localEditOperationMode,
localEditRegions,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
resetLocalEditState,
selectNode,
updateNode,
]);
const handleConfirmBoxCutout = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!boxCutoutSelection) {
toast.info("请先框选要抠出的元素");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
const actionName = "框选抠图";
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
connections: [node.id],
});
try {
const result = await imageGenerationService.boxCutout({
imageUrl: data.src,
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
sourceNodeId: node.id,
});
if (result.success && result.data?.url) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: "completed",
prompt: `${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
cropRectApplied: result.data.cropRectApplied,
paddedCropRectApplied: result.data.paddedCropRectApplied,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
editMode: "box-cutout",
},
},
});
toast.success(`${actionName}完成`);
} else {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(result.error || `${actionName}失败`);
}
} catch (error) {
console.error("框选抠图失败:", error);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(`${actionName}失败,请重试`);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
resetBoxCutoutState();
}
}, [
addNode,
boxCutoutSelection,
data.src,
displayTitle,
isProcessing,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
resetBoxCutoutState,
updateNode,
]);
const animateAddToChat = useCallback(async (previewUrl?: string) => {
if (!containerRef.current || typeof window === "undefined") {
return false;
}
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return false;
}
const target = document.querySelector(
'[data-canvas-chat-input-anchor="true"]'
) as HTMLElement | null;
if (!target) return false;
const animationImageSrc = isHttpImageUrl(previewUrl)
? buildImageProxyUrl(previewUrl || "", {
width: 360,
quality: 76,
format: "webp",
})
: isHttpImageUrl(data.src)
? buildImageProxyUrl(data.src, {
width: 360,
quality: 76,
format: "webp",
})
: "";
const sourceImage =
(containerRef.current.querySelector("img") as HTMLImageElement | null) ||
containerRef.current;
const sourceRect = sourceImage.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
if (
sourceRect.width <= 0 ||
sourceRect.height <= 0 ||
targetRect.width <= 0 ||
targetRect.height <= 0
) {
return false;
}
const waitForAnimation = (
animation: Animation | null,
fallbackMs: number
) =>
new Promise((resolve) => {
if (!animation) {
resolve();
return;
}
let finished = false;
const done = () => {
if (finished) return;
finished = true;
resolve();
};
const timeout = window.setTimeout(done, fallbackMs);
animation.addEventListener(
"finish",
() => {
window.clearTimeout(timeout);
done();
},
{ once: true }
);
animation.addEventListener(
"cancel",
() => {
window.clearTimeout(timeout);
done();
},
{ once: true }
);
});
const sourceCenterX = sourceRect.left + sourceRect.width / 2;
const sourceCenterY = sourceRect.top + sourceRect.height / 2;
const labelText =
displayTitle.length > 12
? `${displayTitle.slice(0, 10)}...`
: displayTitle;
const tagWidth = Math.min(172, Math.max(112, 58 + labelText.length * 8));
const tagHeight = 34;
const tagLeft = Math.min(
Math.max(sourceCenterX - tagWidth / 2, 8),
window.innerWidth - tagWidth - 8
);
const defaultTagTop = sourceRect.top - 46;
const tagTop =
defaultTagTop < 10
? Math.min(sourceRect.bottom + 10, window.innerHeight - tagHeight - 10)
: defaultTagTop;
const flightTag = document.createElement("div");
flightTag.style.position = "fixed";
flightTag.style.left = `${sourceRect.left}px`;
flightTag.style.top = `${sourceRect.top}px`;
flightTag.style.width = `${sourceRect.width}px`;
flightTag.style.height = `${sourceRect.height}px`;
flightTag.style.borderRadius = "14px";
flightTag.style.overflow = "hidden";
flightTag.style.pointerEvents = "none";
flightTag.style.zIndex = "99999";
flightTag.style.background = "#ffffff";
flightTag.style.border = "1px solid rgba(148, 163, 184, 0.24)";
flightTag.style.boxShadow = "0 14px 30px rgba(15, 23, 42, 0.28)";
flightTag.style.transformOrigin = "center center";
flightTag.style.willChange = "transform, opacity, filter";
if (animationImageSrc) {
const previewImage = document.createElement("img");
previewImage.src = animationImageSrc;
previewImage.alt = "";
previewImage.style.width = "100%";
previewImage.style.height = "100%";
previewImage.style.objectFit = "cover";
flightTag.appendChild(previewImage);
} else {
const placeholder = document.createElement("div");
placeholder.style.width = "100%";
placeholder.style.height = "100%";
placeholder.style.background =
"linear-gradient(135deg, rgba(15,23,42,0.06), rgba(59,130,246,0.14))";
flightTag.appendChild(placeholder);
}
document.body.appendChild(flightTag);
const compressDx = tagLeft + tagWidth / 2 - sourceCenterX;
const compressDy = tagTop + tagHeight / 2 - sourceCenterY;
const compressScaleX = tagWidth / sourceRect.width;
const compressScaleY = tagHeight / sourceRect.height;
const compressAnimation = flightTag.animate(
[
{
transform: "translate(0px, 0px) scale(1, 1)",
borderRadius: "14px",
boxShadow: "0 14px 30px rgba(15, 23, 42, 0.28)",
},
{
offset: 0.72,
transform: `translate(${compressDx * 1.03}px, ${compressDy * 1.03}px) scale(${compressScaleX * 1.05}, ${compressScaleY * 1.08})`,
borderRadius: "999px",
boxShadow: "0 10px 24px rgba(59, 130, 246, 0.24)",
},
{
transform: `translate(${compressDx}px, ${compressDy}px) scale(${compressScaleX}, ${compressScaleY})`,
borderRadius: "999px",
boxShadow: "0 8px 20px rgba(59, 130, 246, 0.2)",
},
],
{
duration: 320,
easing: "cubic-bezier(0.2, 0.9, 0.2, 1.2)",
fill: "forwards",
}
);
await waitForAnimation(compressAnimation, 420);
flightTag.style.left = `${tagLeft}px`;
flightTag.style.top = `${tagTop}px`;
flightTag.style.width = `${tagWidth}px`;
flightTag.style.height = `${tagHeight}px`;
flightTag.style.transform = "none";
flightTag.style.borderRadius = "999px";
flightTag.style.boxShadow = "0 8px 20px rgba(59, 130, 246, 0.2)";
flightTag.style.display = "flex";
flightTag.style.alignItems = "center";
flightTag.style.gap = "6px";
flightTag.style.padding = "4px 10px 4px 4px";
flightTag.style.background = "rgba(255,255,255,0.96)";
flightTag.style.backdropFilter = "blur(6px)";
flightTag.innerHTML = "";
const chipImage = document.createElement(animationImageSrc ? "img" : "span");
if (animationImageSrc && chipImage instanceof HTMLImageElement) {
chipImage.src = animationImageSrc;
chipImage.alt = "";
}
chipImage.style.width = "24px";
chipImage.style.height = "24px";
chipImage.style.objectFit = "cover";
chipImage.style.borderRadius = "999px";
chipImage.style.border = "1px solid rgba(148, 163, 184, 0.35)";
chipImage.style.background = "rgba(59, 130, 246, 0.12)";
const chipLabel = document.createElement("span");
chipLabel.textContent = labelText || "图片";
chipLabel.style.fontSize = "12px";
chipLabel.style.fontWeight = "500";
chipLabel.style.color = "rgb(51, 65, 85)";
chipLabel.style.whiteSpace = "nowrap";
chipLabel.style.overflow = "hidden";
chipLabel.style.textOverflow = "ellipsis";
flightTag.appendChild(chipImage);
flightTag.appendChild(chipLabel);
const tagCenterX = tagLeft + tagWidth / 2;
const tagCenterY = tagTop + tagHeight / 2;
const targetCenterX = targetRect.left + targetRect.width / 2;
const targetCenterY = targetRect.top + targetRect.height * 0.55;
const flyDx = targetCenterX - tagCenterX;
const flyDy = targetCenterY - tagCenterY;
const flyAnimation = flightTag.animate(
[
{
transform: "translate(0px, 0px) scale(1)",
opacity: 1,
filter: "blur(0px)",
},
{
offset: 0.55,
transform: `translate(${flyDx * 0.64}px, ${flyDy * 0.62 - 46}px) scale(0.94)`,
opacity: 0.98,
filter: "blur(0px)",
},
{
offset: 0.84,
transform: `translate(${flyDx + 9}px, ${flyDy - 7}px) scale(0.74)`,
opacity: 0.82,
filter: "blur(0.4px)",
},
{
transform: `translate(${flyDx}px, ${flyDy}px) scale(0.64)`,
opacity: 0.15,
filter: "blur(1.2px)",
},
],
{
duration: 760,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
fill: "forwards",
}
);
target.animate(
[
{ boxShadow: "0 0 0 0 rgba(59,130,246,0)", transform: "scale(1)" },
{
offset: 0.58,
boxShadow: "0 0 0 8px rgba(59,130,246,0.14)",
transform: "scale(1.02)",
},
{ boxShadow: "0 0 0 0 rgba(59,130,246,0)", transform: "scale(1)" },
],
{ duration: 720, easing: "cubic-bezier(0.2, 0.9, 0.2, 1.2)" }
);
await waitForAnimation(flyAnimation, 860);
flightTag.remove();
return true;
}, [data.src, displayTitle]);
const handleAddToChat = useCallback(async () => {
if (!data.src || isProcessing || isAddingToChat) return;
setIsAddingToChat(true);
try {
const attachment = await resolveChatAttachmentFromImageSrc(
data.src,
displayTitle
);
await animateAddToChat(attachment.url);
const metadata = (data.metadata || {}) as Record;
const sourceWidth =
Number(metadata.originalWidth || 0) ||
Number(node.size?.width || 0) ||
0;
const sourceHeight =
Number(metadata.originalHeight || 0) ||
Number(node.size?.height || 0) ||
0;
addPendingChatAttachment({
url: attachment.url,
name: displayTitle,
contentType: attachment.contentType,
width:
Number.isFinite(sourceWidth) && sourceWidth > 0
? Math.round(sourceWidth)
: undefined,
height:
Number.isFinite(sourceHeight) && sourceHeight > 0
? Math.round(sourceHeight)
: undefined,
});
toast.success("已添加到对话框");
} catch (error) {
console.error("[CanvasImageNode] add to chat failed:", error);
toast.error(error instanceof Error ? error.message : "添加到对话失败");
} finally {
setIsAddingToChat(false);
}
}, [
addPendingChatAttachment,
animateAddToChat,
data.metadata,
data.src,
displayTitle,
isAddingToChat,
isProcessing,
node.size?.height,
node.size?.width,
]);
const getFloatingViewportBounds = (toolbarHeight: number) => {
const viewportPadding = 12;
let unsafeBottomTop = window.innerHeight;
const bottomOverlaySelectors = ['[data-canvas-floating-toolbar="true"]'];
for (const selector of bottomOverlaySelectors) {
const elements = Array.from(
document.querySelectorAll(selector)
);
for (const element of elements) {
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden") continue;
const overlayRect = element.getBoundingClientRect();
if (overlayRect.width <= 0 || overlayRect.height <= 0) continue;
if (overlayRect.bottom <= 0 || overlayRect.top >= window.innerHeight)
continue;
if (overlayRect.top < window.innerHeight * 0.55) continue;
unsafeBottomTop = Math.min(unsafeBottomTop, overlayRect.top);
}
}
const safeBottom = Math.max(
viewportPadding,
Math.min(
window.innerHeight - viewportPadding,
unsafeBottomTop - viewportPadding
)
);
const stageElement = document.querySelector(
'[data-canvas-stage="true"]'
) as HTMLElement | null;
const panelElement = document.querySelector(
'[data-canvas-right-panel="true"]'
) as HTMLElement | null;
let safeLeft = viewportPadding;
let safeRight = window.innerWidth - viewportPadding;
if (stageElement) {
const stageRect = stageElement.getBoundingClientRect();
if (stageRect.width > 0) {
safeLeft = Math.max(safeLeft, stageRect.left + viewportPadding);
safeRight = Math.min(safeRight, stageRect.right - viewportPadding);
}
}
if (panelElement) {
const panelRect = panelElement.getBoundingClientRect();
if (panelRect.width > 0 && panelRect.left < window.innerWidth) {
safeRight = Math.min(safeRight, panelRect.left - viewportPadding);
}
}
if (safeRight <= safeLeft + 8) {
safeLeft = viewportPadding;
safeRight = window.innerWidth - viewportPadding;
}
return {
viewportPadding,
safeBottom,
safeLeft,
safeRight,
maxTop: Math.max(viewportPadding, safeBottom - toolbarHeight),
};
};
// 获取工具栏位置 - 在渲染时直接计算
const getToolbarPosition = () => {
if (containerRef.current && typeof window !== "undefined") {
const rect = containerRef.current.getBoundingClientRect();
const toolbarWidth = Math.max(
280,
(toolbarSize.width || 640) * FLOATING_TOOLBAR_SCALE
);
const toolbarHeight = Math.max(
42,
(toolbarSize.height || 60) * FLOATING_TOOLBAR_SCALE
);
const titleSafeHeight = 34;
const gap = 12;
const { viewportPadding, maxTop, safeBottom, safeLeft, safeRight } =
getFloatingViewportBounds(toolbarHeight);
let top = rect.bottom + gap;
if (top + toolbarHeight > safeBottom) {
top = rect.top - toolbarHeight - titleSafeHeight - gap;
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
const maxToolbarWidth = Math.max(180, safeRight - safeLeft);
const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth);
let left = rect.left + rect.width / 2 - effectiveToolbarWidth / 2;
if (effectiveToolbarWidth >= maxToolbarWidth - 1) {
left = safeLeft;
} else {
left = Math.min(
Math.max(left, safeLeft),
safeRight - effectiveToolbarWidth
);
}
return {
top,
left,
maxWidth: maxToolbarWidth,
};
}
return { top: 0, left: 0, maxWidth: 480 };
};
const getAiWashQuickPanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0, width: 286, maxHeight: 430 };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = 286;
const panelHeight = 430;
const gap = 12;
const { viewportPadding, safeBottom, safeLeft, safeRight, maxTop } =
getFloatingViewportBounds(panelHeight);
const width = Math.min(panelWidth, Math.max(260, safeRight - safeLeft));
let left = rect.right + gap;
if (left + width > safeRight) {
left = rect.left - width - gap;
}
left = Math.min(Math.max(left, safeLeft), Math.max(safeLeft, safeRight - width));
let top = rect.top;
if (top + panelHeight > safeBottom) {
top = rect.bottom - panelHeight;
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
return {
top,
left,
width,
maxHeight: Math.max(260, Math.min(panelHeight, safeBottom - top)),
};
};
const getBoxCutoutToolbarPosition = () => {
if (containerRef.current && typeof window !== "undefined") {
const rect = containerRef.current.getBoundingClientRect();
const toolbarWidth = Math.max(
240,
(boxCutoutToolbarSize.width || 460) * FLOATING_TOOLBAR_SCALE
);
const toolbarHeight = Math.max(
42,
(boxCutoutToolbarSize.height || 60) * FLOATING_TOOLBAR_SCALE
);
const gap = 14;
const { viewportPadding, safeBottom, safeLeft, safeRight, maxTop } =
getFloatingViewportBounds(toolbarHeight);
let top = rect.bottom + gap;
if (top + toolbarHeight > safeBottom) {
top = rect.top - toolbarHeight - gap;
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
const maxToolbarWidth = Math.max(180, safeRight - safeLeft);
const effectiveToolbarWidth = Math.min(toolbarWidth, maxToolbarWidth);
let left = rect.left + rect.width / 2 - effectiveToolbarWidth / 2;
if (effectiveToolbarWidth >= maxToolbarWidth - 1) {
left = safeLeft;
} else {
left = Math.min(
Math.max(left, safeLeft),
safeRight - effectiveToolbarWidth
);
}
return {
top,
left,
maxWidth: maxToolbarWidth,
};
}
return { top: 0, left: 0, maxWidth: 460 };
};
const getImageAdjustPanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0 };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = 520 * IMAGE_EDIT_PANEL_SCALE;
const panelHeight = 760 * IMAGE_EDIT_PANEL_SCALE;
const spacing = 18;
const maxTop = Math.max(12, window.innerHeight - panelHeight);
let left = rect.right + spacing;
if (left + panelWidth > window.innerWidth - 12) {
left = Math.max(12, rect.left - panelWidth - spacing);
}
const top = Math.min(Math.max(12, rect.top + 10), maxTop);
return { top, left };
};
const getCropPanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0 };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = 474 * IMAGE_EDIT_PANEL_SCALE;
const panelHeight = 980 * IMAGE_EDIT_PANEL_SCALE;
const spacing = 16;
const maxTop = Math.max(12, window.innerHeight - panelHeight);
let left = rect.right + spacing;
if (left + panelWidth > window.innerWidth - 12) {
left = Math.max(12, rect.left - panelWidth - spacing);
}
const top = Math.min(Math.max(12, rect.top - 4), maxTop);
return { top, left };
};
const getFlipRotatePanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0, maxWidth: 660 * IMAGE_EDIT_PANEL_SCALE };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = 640 * IMAGE_EDIT_PANEL_SCALE;
const panelHeight = 84 * IMAGE_EDIT_PANEL_SCALE;
const gap = 18;
const titleSafeHeight = 34;
const { viewportPadding, safeLeft, safeRight, maxTop } =
getFloatingViewportBounds(panelHeight);
let top = rect.top - panelHeight - titleSafeHeight - gap;
if (top < viewportPadding) {
top = rect.bottom + gap;
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
const maxPanelWidth = Math.max(220, safeRight - safeLeft);
const effectivePanelWidth = Math.min(panelWidth, maxPanelWidth);
let left = rect.left + rect.width / 2 - effectivePanelWidth / 2;
if (effectivePanelWidth >= maxPanelWidth - 1) {
left = safeLeft;
} else {
left = Math.min(Math.max(left, safeLeft), safeRight - effectivePanelWidth);
}
return {
top,
left,
maxWidth: maxPanelWidth,
};
};
const getTextPanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0 };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = 360;
const spacing = 12;
const maxTop = Math.max(12, window.innerHeight - 460);
let left = rect.right + spacing;
if (left + panelWidth > window.innerWidth - 12) {
left = Math.max(12, rect.left - panelWidth - spacing);
}
const top = Math.min(Math.max(12, rect.top), maxTop);
return { top, left };
};
const getAnglePanelPosition = () => {
if (!containerRef.current || typeof window === "undefined") {
return { top: 0, left: 0 };
}
const rect = containerRef.current.getBoundingClientRect();
const panelWidth = ANGLE_PANEL_SOURCE_WIDTH * ANGLE_PANEL_SCALE;
const panelHeight = ANGLE_PANEL_SOURCE_HEIGHT * ANGLE_PANEL_SCALE;
const spacing = 16;
const maxTop = Math.max(12, window.innerHeight - panelHeight);
let left = rect.right + spacing;
if (left + panelWidth > window.innerWidth - 12) {
left = Math.max(12, rect.left - panelWidth - spacing);
}
const top = Math.min(Math.max(12, rect.top - 8), maxTop);
return { top, left };
};
const handleResetAnglePanel = useCallback(() => {
setAnglePanelMode("camera");
setAngleHorizontalAngle(-50);
setAngleVerticalAngle(-30);
setAngleZoom(50);
}, []);
const handleAnglePreviewPointerDown = useCallback(
(event: React.PointerEvent) => {
if (isApplyingAngleEdit) return;
anglePreviewDragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startHorizontalAngle: angleHorizontalAngle,
startVerticalAngle: angleVerticalAngle,
};
setIsAnglePreviewDragging(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
event.preventDefault();
event.stopPropagation();
},
[angleHorizontalAngle, angleVerticalAngle, isApplyingAngleEdit]
);
const handleAnglePreviewPointerMove = useCallback(
(event: React.PointerEvent) => {
const dragState = anglePreviewDragRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return;
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
setAngleHorizontalAngle(
clampNumber(
dragState.startHorizontalAngle +
deltaX * ANGLE_PREVIEW_HORIZONTAL_DRAG_MULTIPLIER,
-180,
180
)
);
setAngleVerticalAngle(
clampNumber(
dragState.startVerticalAngle -
deltaY * ANGLE_PREVIEW_VERTICAL_DRAG_MULTIPLIER,
-30,
60
)
);
event.preventDefault();
event.stopPropagation();
},
[]
);
const handleAnglePreviewPointerEnd = useCallback(
(event: React.PointerEvent) => {
if (anglePreviewDragRef.current?.pointerId !== event.pointerId) return;
anglePreviewDragRef.current = null;
setIsAnglePreviewDragging(false);
event.currentTarget.releasePointerCapture?.(event.pointerId);
event.preventDefault();
event.stopPropagation();
},
[]
);
const handleApplyFlipRotate = useCallback(
async (action: FlipRotateEditAction) => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (isApplyingFlipRotate) {
return;
}
if (action === "reset") {
if (!hasFlipRotateReset) {
toast.info("当前图片没有可恢复的翻转记录");
return;
}
const nextMetadata =
data.metadata && typeof data.metadata === "object"
? { ...(data.metadata as Record) }
: {};
delete nextMetadata.flipRotateOriginalSrc;
delete nextMetadata.flipRotateLastAction;
delete nextMetadata.flipRotateUpdatedAt;
updateNode(node.id, {
data: {
...data,
src: flipRotateOriginalSrc,
status: "completed",
metadata: Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined,
},
});
toast.success("已恢复翻转前图片");
return;
}
setIsApplyingFlipRotate(true);
setIsProcessing(true);
try {
const nextUrl = await renderAndUploadFlippedImage(
data.src,
action,
`${displayTitle}-${action === "horizontal" ? "水平翻转" : "垂直翻转"}`
);
updateNode(node.id, {
data: {
...data,
src: nextUrl,
status: "completed",
originalSrc: data.originalSrc || data.src,
metadata: {
...(data.metadata || {}),
flipRotateOriginalSrc: flipRotateOriginalSrc || data.src,
flipRotateLastAction: action,
flipRotateUpdatedAt: new Date().toISOString(),
},
},
});
toast.success(action === "horizontal" ? "已完成水平翻转" : "已完成垂直翻转");
} catch (error) {
const message = error instanceof Error ? error.message : "翻转失败";
toast.error(message);
} finally {
setIsApplyingFlipRotate(false);
setIsProcessing(false);
}
},
[
data,
displayTitle,
flipRotateOriginalSrc,
hasFlipRotateReset,
isApplyingFlipRotate,
node.id,
updateNode,
]
);
const handleApplyAngleEdit = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
if (isApplyingAngleEdit) {
return;
}
setIsApplyingAngleEdit(true);
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: "多视角处理中...",
title: `${displayTitle}-多视角`,
originalSrc: data.src,
metadata: {
angleEditSettings: {
mode: anglePanelMode,
horizontalAngle: Math.round(angleHorizontalAngle),
verticalAngle: Math.round(angleVerticalAngle),
zoom: Math.round(angleZoom),
},
},
},
connections: [node.id],
});
try {
const result = await imageGenerationService.editAngle({
imageUrl: data.src,
horizontalAngle: normalizeAnglePanelHorizontalAngle(
Math.round(angleHorizontalAngle)
),
verticalAngle: Math.round(angleVerticalAngle),
zoom: mapAnglePanelZoomToApi(angleZoom),
});
if (!result.success || !result.data?.url) {
throw new Error(result.error || "多视角生成失败");
}
updateNode(newNodeId, {
data: {
src: result.data.url,
status: "completed",
prompt: result.data.prompt || "多视角结果",
title: `${displayTitle}-多视角`,
originalSrc: data.src,
metadata: {
angleEditSettings: {
mode: anglePanelMode,
horizontalAngle: Math.round(angleHorizontalAngle),
verticalAngle: Math.round(angleVerticalAngle),
zoom: Math.round(angleZoom),
},
angles: result.data.angles || null,
},
},
});
updateNode(node.id, {
data: {
...data,
metadata: {
...(data.metadata || {}),
angleEditSettings: {
mode: anglePanelMode,
horizontalAngle: Math.round(angleHorizontalAngle),
verticalAngle: Math.round(angleVerticalAngle),
zoom: Math.round(angleZoom),
},
},
},
});
selectNode(newNodeId);
setAnglePanelOpen(false);
toast.success("多视角结果已生成");
} catch (error) {
const message =
error instanceof Error ? error.message : "多视角生成失败";
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "多视角生成失败",
title: `${displayTitle}-多视角失败`,
originalSrc: data.src,
},
});
toast.error(message);
} finally {
setIsApplyingAngleEdit(false);
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
}, [
addNode,
angleHorizontalAngle,
anglePanelMode,
angleVerticalAngle,
angleZoom,
data,
displayTitle,
isApplyingAngleEdit,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
selectNode,
updateNode,
]);
const handleSaveTitle = useCallback(() => {
const nextTitle = normalizeImageTitle(draftTitle) || displayTitle;
updateNode(node.id, {
data: {
...data,
title: nextTitle,
},
});
setDraftTitle(nextTitle);
setIsEditingTitle(false);
toast.success("图片名称已更新");
}, [data, displayTitle, draftTitle, node.id, updateNode]);
const handleOpenImageAdjustPanel = useCallback(() => {
setCropPanelOpen(false);
setCropInteraction(null);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
setImageAdjustTab("light");
setImageAdjustmentsDraft(storedImageAdjustments);
setImageAdjustPanelOpen(true);
}, [resetBoxCutoutState, storedImageAdjustments]);
const handleResetImageAdjustments = useCallback(() => {
setImageAdjustmentsDraft(DEFAULT_IMAGE_ADJUSTMENTS);
}, []);
const handleOpenCropPanel = useCallback(() => {
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
setCropInteraction(null);
setCropPanelOpen(true);
window.requestAnimationFrame(() => {
refreshBoxCutoutMetrics();
});
}, [refreshBoxCutoutMetrics, resetBoxCutoutState]);
const handleToggleCropGroup = useCallback((groupKey: CropPanelGroupKey) => {
setCropExpandedGroups((prev) => ({
...prev,
[groupKey]: !prev[groupKey],
}));
}, []);
const handleSelectCropPreset = useCallback((presetId: string) => {
const preset = getCropPresetById(presetId);
if (!preset) return;
const centerX = cropRect ? cropRect.x + cropRect.width / 2 : 0.5;
const centerY = cropRect ? cropRect.y + cropRect.height / 2 : 0.5;
setCropPresetId(preset.id);
setCropLockAspectRatio(true);
setCropRect(createCenteredCropRectForAspect(preset.ratio, centerX, centerY));
}, [cropRect]);
const handleChangeCropDimension = useCallback(
(dimension: "width" | "height", rawValue: string) => {
if (!cropRect || !cropImageMetrics) return;
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || parsed < BOX_CUTOUT_MIN_EDGE_PX) {
return;
}
const naturalWidth = cropImageMetrics.naturalWidth;
const naturalHeight = cropImageMetrics.naturalHeight;
if (!naturalWidth || !naturalHeight) return;
let nextWidth = dimension === "width" ? parsed : cropPixelSize.width;
let nextHeight = dimension === "height" ? parsed : cropPixelSize.height;
const lockedAspect =
cropLockAspectRatio &&
(selectedCropPreset?.ratio ||
(currentCropAspectRatio && Number.isFinite(currentCropAspectRatio)
? currentCropAspectRatio
: null));
if (lockedAspect) {
if (dimension === "width") {
nextHeight = Math.round(nextWidth / lockedAspect);
} else {
nextWidth = Math.round(nextHeight * lockedAspect);
}
}
nextWidth = clampNumber(nextWidth, BOX_CUTOUT_MIN_EDGE_PX, naturalWidth);
nextHeight = clampNumber(nextHeight, BOX_CUTOUT_MIN_EDGE_PX, naturalHeight);
const centerX = cropRect.x + cropRect.width / 2;
const centerY = cropRect.y + cropRect.height / 2;
let normalizedWidth = nextWidth / naturalWidth;
let normalizedHeight = nextHeight / naturalHeight;
if (lockedAspect) {
const fitted = createCenteredCropRectForAspect(lockedAspect, centerX, centerY);
const maxWidth = fitted.width * naturalWidth;
const maxHeight = fitted.height * naturalHeight;
if (nextWidth > maxWidth || nextHeight > maxHeight) {
normalizedWidth = fitted.width;
normalizedHeight = fitted.height;
}
}
setCropRect(
createCenteredCropRectFromSize(
centerX,
centerY,
normalizedWidth,
normalizedHeight
)
);
},
[
cropImageMetrics,
cropLockAspectRatio,
cropPixelSize.height,
cropPixelSize.width,
cropRect,
currentCropAspectRatio,
selectedCropPreset,
]
);
const handleCropPointerDown = useCallback(
(event: React.PointerEvent) => {
if (!cropRect || !cropPanelOpen || isApplyingCrop) return;
const target = event.target as HTMLElement | null;
const interactive = target?.closest("[data-crop-handle]");
const handle =
(interactive?.dataset.cropHandle as CropInteractionHandle | undefined) ||
null;
if (!handle) return;
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult?.insideImage) return;
event.preventDefault();
event.stopPropagation();
setCropInteraction({
handle,
pointerId: event.pointerId,
startPoint: pointerResult.point,
startRect: cropRect,
});
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[cropPanelOpen, cropRect, getBoxCutoutPointerPoint, isApplyingCrop]
);
const handleCropPointerMove = useCallback(
(event: React.PointerEvent) => {
if (!cropInteraction || !cropImageMetrics) return;
const pointerResult = getBoxCutoutPointerPoint(
event.clientX,
event.clientY
);
if (!pointerResult) return;
event.preventDefault();
event.stopPropagation();
const minSize = getNormalizedMinCropSize(
cropImageMetrics.naturalWidth,
cropImageMetrics.naturalHeight
);
setCropRect(
updateCropRectWithPointer({
aspectRatio: effectiveCropAspectRatio,
handle: cropInteraction.handle,
minHeight: minSize.height,
minWidth: minSize.width,
pointer: pointerResult.point,
startPoint: cropInteraction.startPoint,
startRect: cropInteraction.startRect,
})
);
},
[cropImageMetrics, cropInteraction, effectiveCropAspectRatio, getBoxCutoutPointerPoint]
);
const handleCropPointerUp = useCallback(
(event: React.PointerEvent) => {
if (!cropInteraction) return;
event.preventDefault();
event.stopPropagation();
setCropInteraction(null);
event.currentTarget.releasePointerCapture?.(cropInteraction.pointerId);
},
[cropInteraction]
);
const handleCropPointerCancel = useCallback(
(event: React.PointerEvent) => {
if (!cropInteraction) return;
event.preventDefault();
event.stopPropagation();
setCropInteraction(null);
event.currentTarget.releasePointerCapture?.(cropInteraction.pointerId);
},
[cropInteraction]
);
const handleApplyCrop = useCallback(async () => {
if (!data.src || !cropRect) {
toast.error("当前没有可用的裁剪区域");
return;
}
if (isApplyingCrop) {
return;
}
setIsApplyingCrop(true);
setIsProcessing(true);
try {
const result = await renderAndUploadCroppedImage(
data.src,
cropRect,
`${displayTitle}-裁剪`
);
updateNode(node.id, {
data: {
...data,
src: result.url,
status: "completed",
originalSrc: data.originalSrc || data.src,
metadata: {
...(data.metadata || {}),
compareSourceUrl: data.src,
cropPresetId,
cropLockAspectRatio,
cropUpdatedAt: new Date().toISOString(),
originalWidth: result.width,
originalHeight: result.height,
},
},
});
setCropPanelOpen(false);
setCropInteraction(null);
setCropRect(null);
toast.success("裁剪已完成");
} catch (error) {
const message = error instanceof Error ? error.message : "裁剪失败";
toast.error(message);
} finally {
setIsApplyingCrop(false);
setIsProcessing(false);
}
}, [
cropLockAspectRatio,
cropPresetId,
cropRect,
data,
displayTitle,
isApplyingCrop,
node.id,
updateNode,
]);
const handleOpenNotePanel = useCallback(() => {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setDraftNote(data.note || "");
setNotePanelOpen(true);
}, [data.note]);
const handleSaveNote = useCallback(() => {
const nextNote = draftNote.trim();
const noteAnnotationMetadata = syncCanvasNodeNoteAnnotationMetadata({
metadata: data.metadata,
nodeId: node.id,
note: nextNote,
});
updateNode(node.id, {
data: {
...data,
note: nextNote || undefined,
metadata:
Object.keys(noteAnnotationMetadata.metadata).length > 0
? noteAnnotationMetadata.metadata
: undefined,
},
});
setDraftNote(nextNote);
setNotePanelOpen(false);
toast.success(nextNote ? "批注已保存" : "批注已清除");
}, [data, draftNote, node.id, updateNode]);
const detectTextItems = useCallback(
async (forceRefresh = false) => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
if (isDetectingText) {
return;
}
if (!forceRefresh && data.textEditSource === data.src) {
const cached = normalizeTextEditItems(data.textEditItems);
if (cached.length > 0) {
setTextEditItems(cached);
setDeletedTextItems([]);
setTextEditError(null);
return;
}
}
setIsDetectingText(true);
setTextEditError(null);
try {
const result = await imageGenerationService.detectCanvasText(data.src);
if (!result.success) {
throw new Error(result.error || "文字识别失败");
}
const detectedItems: ImageTextEditItem[] = (result.data?.items || [])
.map((item, index) => {
const text = (item.text || "").trim();
if (!text) return null;
return {
id: item.id || String(index + 1),
originalText: text,
editedText: text,
textColor: undefined,
confidence: item.confidence,
} as ImageTextEditItem;
})
.filter((item): item is ImageTextEditItem => !!item);
setTextEditItems(detectedItems);
setDeletedTextItems([]);
updateNode(node.id, {
data: {
...data,
textEditItems: detectedItems,
textEditSource: data.src,
textEditUpdatedAt: new Date().toISOString(),
},
});
if (detectedItems.length === 0) {
toast.info("未识别到可编辑文字");
} else {
toast.success(`已识别 ${detectedItems.length} 段文字`);
}
} catch (error) {
const message = error instanceof Error ? error.message : "文字识别失败";
setTextEditError(message);
toast.error(message);
} finally {
setIsDetectingText(false);
}
},
[data, isDetectingText, node.id, updateNode]
);
const handleOpenTextEdit = useCallback(async () => {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setNotePanelOpen(false);
setTextEditPanelOpen(true);
setTextEditError(null);
const cached = normalizeTextEditItems(data.textEditItems);
if (cached.length > 0 && data.textEditSource === data.src) {
setTextEditItems(cached);
setDeletedTextItems([]);
return;
}
await detectTextItems(true);
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
const handleOpenLayoutReplace = useCallback(() => {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
setPromptDialogOpen(false);
setAiWashQuickPanelOpen(false);
resetBoxCutoutState();
resetLocalEditState();
setLayoutReplaceDialogOpen(true);
}, [resetBoxCutoutState, resetLocalEditState]);
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
}
clearImageNodeActionRequest();
if (imageNodeActionRequest.action === "box-cutout") {
handleEnterBoxCutoutMode();
return;
}
if (imageNodeActionRequest.action === "text-edit") {
void handleOpenTextEdit();
return;
}
if (imageNodeActionRequest.action === "local-edit") {
handleEnterLocalEditMode();
return;
}
if (imageNodeActionRequest.action === "layout-replace") {
handleOpenLayoutReplace();
}
}, [
clearImageNodeActionRequest,
handleEnterBoxCutoutMode,
handleEnterLocalEditMode,
handleOpenLayoutReplace,
handleOpenTextEdit,
imageNodeActionRequest,
node.id,
]);
const handleTextItemChange = (id: string, value: string) => {
setTextEditItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, editedText: value } : item
)
);
};
const handleTextItemColorChange = (id: string, value?: string) => {
setTextEditItems((prev) =>
prev.map((item) =>
item.id === id
? { ...item, textColor: normalizeOptionalHexColor(value) }
: item
)
);
};
const handleRemoveTextItem = (id: string) => {
setTextEditItems((prev) => {
const target = prev.find((item) => item.id === id);
if (target?.originalText) {
setDeletedTextItems((deletedPrev) => {
if (deletedPrev.some((item) => item.id === id)) return deletedPrev;
return [...deletedPrev, { id, originalText: target.originalText }];
});
}
return prev.filter((item) => item.id !== id);
});
};
const hasTextEditChanges = useMemo(
() =>
textEditItems.some((item) => {
const originalText = item.originalText.trim();
const editedText = item.editedText.trim();
return (
originalText.length > 0 &&
(editedText.length === 0 ||
editedText !== originalText ||
Boolean(normalizeOptionalHexColor(item.textColor)))
);
}) ||
deletedTextItems.some((item) => item.originalText.trim().length > 0),
[deletedTextItems, textEditItems]
);
const handleApplyTextEdit = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
const changedItems: CanvasTextEditPayloadItem[] = textEditItems
.map((item) => {
const editedText = item.editedText.trim();
const action: "replace" | "delete" =
editedText.length === 0 ? "delete" : "replace";
return {
id: item.id,
originalText: item.originalText.trim(),
editedText,
textColor: normalizeOptionalHexColor(item.textColor),
action,
};
})
.filter(
(item) =>
item.originalText.length > 0 &&
(item.action === "delete" ||
(item.editedText &&
item.editedText.length > 0 &&
(item.originalText !== item.editedText ||
Boolean(item.textColor))))
);
const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems
.map((item) => ({
id: item.id,
originalText: item.originalText.trim(),
action: "delete" as const,
}))
.filter((item) => item.originalText.length > 0);
const requestItems = [...changedItems, ...removedItems];
if (requestItems.length === 0) {
toast.info("请先修改文字内容、选择文字颜色,或点击垃圾桶删除该文字");
return;
}
if (isApplyingTextEdit) {
return;
}
setIsApplyingTextEdit(true);
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
setTextEditError(null);
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: "文字替换处理中...",
title: `${displayTitle}-文字替换`,
originalSrc: data.src,
},
connections: [node.id],
});
try {
const result = await imageGenerationService.editCanvasText({
imageUrl: data.src,
items: requestItems,
});
if (!result.success || !result.data?.originalUrl) {
throw new Error(result.error || "文字替换失败");
}
updateNode(newNodeId, {
data: {
src: result.data.originalUrl,
status: "completed",
prompt: "文字替换结果",
title: `${displayTitle}-文字替换`,
originalSrc: data.src,
model: result.data.model,
},
});
updateNode(node.id, {
data: {
...data,
textEditItems,
textEditSource: data.src,
textEditUpdatedAt: new Date().toISOString(),
},
});
setTextEditPanelOpen(false);
setDeletedTextItems([]);
toast.success("文字替换已完成");
} catch (error) {
const message = error instanceof Error ? error.message : "文字替换失败";
setTextEditError(message);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "文字替换失败",
title: `${displayTitle}-文字替换失败`,
originalSrc: data.src,
},
});
toast.error(message);
} finally {
setIsApplyingTextEdit(false);
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
}, [
addNode,
data,
deletedTextItems,
isApplyingTextEdit,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
textEditItems,
updateNode,
]);
// 触发文件选择
const handleSelectFile = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
fileInputRef.current?.click();
};
// 处理图片上传 - 上传到存储桶,不使用 Base64
const handleImageUpload = async (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
if (!file) return;
// 验证文件类型
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
// 验证文件大小 (50MB)
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
return;
}
setIsUploading(true);
window.dispatchEvent(
new CustomEvent(CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT, {
detail: { nodeId: node.id },
})
);
const cleanMetadata = stripImageGenerationMetadata(data.metadata);
// 先显示上传中状态。手动上传不是生图,不能进入 generating 占位态。
updateNode(node.id, {
data: {
...data,
src: "",
status: "uploading",
metadata: cleanMetadata,
},
});
try {
const fileBaseName = file.name.replace(/\.[^.]+$/, "");
// 上传到存储桶
const result = await imageGenerationService.uploadImage(file);
if (result.success && result.data?.url) {
updateNode(node.id, {
data: {
...data,
src: result.data.url,
assetId: result.data.assetId,
status: "completed",
title: normalizeImageTitle(data.title) || fileBaseName,
metadata: cleanMetadata,
},
});
toast.success("图片上传成功");
} else {
updateNode(node.id, {
data: {
...data,
src: "",
status: "failed",
metadata: {
...(cleanMetadata || {}),
uploadError: true,
detailProgressLabel: "上传失败",
},
},
});
toast.error(result.error || "图片上传失败");
}
} catch (error) {
console.error("图片上传失败:", error);
updateNode(node.id, {
data: {
...data,
src: "",
status: "failed",
metadata: {
...(cleanMetadata || {}),
uploadError: true,
detailProgressLabel: "上传失败",
},
},
});
toast.error("图片上传失败,请重试");
} finally {
setIsUploading(false);
}
// 重置 input
e.target.value = "";
};
// 获取操作的中文名称
const getActionName = (action: string): string => {
const names: Record = {
rmbg: "抠图",
watermark: "去印",
enhance: "增强",
extend: "扩图",
crop: "裁剪",
adjust: "调整",
"flip-rotate": "翻转与旋转",
resize: "调尺",
angle: "视角",
"layer-split": "图层拆分",
"scene-fusion": "批量处理",
annotate: "标注",
"text-edit": "改字",
note: "添加批注",
"prompt-regenerate": "重绘",
"box-cutout": "框选抠图",
"local-edit": "局部编辑",
"layout-replace": "排版替换",
};
return names[action] || action;
};
const openThreedGenerationDialog = useCallback(
(mode: "model" | "world") => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (!isHttpImageUrl(data.src)) {
toast.error("请选择一张可访问的 URL 图片");
return;
}
if (mode === "model") {
if (!tripo3dEnabled) {
toast.error("Tripo3D 当前未开启,请先在系统设置中启用");
return;
}
if (tripoModelOptions.length === 0) {
toast.error("当前没有可用的 3D 模型");
return;
}
} else {
if (!worldlabs3dEnabled) {
toast.error("World Labs 当前未开启,请先在系统设置中启用");
return;
}
if (worldModelOptions.length === 0) {
toast.error("当前没有可用的 3D 世界模型");
return;
}
}
setThreedGenerationDialogMode(mode);
},
[
data.src,
tripo3dEnabled,
tripoModelOptions.length,
worldModelOptions.length,
worldlabs3dEnabled,
]
);
useEffect(() => {
if (!angleEditEnabled && anglePanelOpen) {
setAnglePanelOpen(false);
}
}, [angleEditEnabled, anglePanelOpen]);
async function handleConfirmThreedGeneration() {
if (threedGenerationDialogMode === "model") {
const targetModelVersion = selectedTripoModelEntry?.id || "";
if (!targetModelVersion) {
toast.error("当前没有可用的 3D 模型");
return;
}
setThreedGenerationDialogMode(null);
await handleGenerateCanvas3DModel(targetModelVersion);
return;
}
if (threedGenerationDialogMode === "world") {
const targetModelVersion = selectedWorldModelEntry?.id || "";
if (!targetModelVersion) {
toast.error("当前没有可用的 3D 世界模型");
return;
}
setThreedGenerationDialogMode(null);
await handleGenerateCanvas3DWorld(targetModelVersion);
}
}
const handleSubmitAiWashQuick = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (isCanvasImageUploadPending(node) || !isHttpImageUrl(data.src)) {
toast.error("图片仍在本地,上传完成后再进行 AI洗图");
return;
}
if (!aiWashQuickConfig.enabled) {
toast.error("AI洗图当前未开启,请先在系统设置中启用");
return;
}
if (!selectedAiWashQuickPreset) {
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
return;
}
if (!aiWashQuickHasExecutableModel || !selectedAiWashQuickModel) {
toast.error(
selectedAiWashQuickModel?.disabledReason ||
"当前 AI洗图模型不可用,请在后台重新配置模型"
);
return;
}
if (isProcessing || isSubmittingAiWashQuick) {
toast.info("正在处理中,请稍候...");
return;
}
setImageAdjustPanelOpen(false);
setCropPanelOpen(false);
setCropInteraction(null);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
resetLocalEditState();
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
setIsSubmittingAiWashQuick(true);
setAiWashQuickStatus("submitting");
const actionName = aiWashQuickModeLabel || "AI洗图";
const resultTitle = `${displayTitle}-${selectedAiWashQuickPreset.title}`;
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: `${actionName}处理中...`,
title: resultTitle,
originalSrc: data.src,
metadata: {
editMode: "ai-wash",
sourceNodeId: node.id,
aiWashPresetId: selectedAiWashQuickPreset.id,
aiWashPresetTitle: selectedAiWashQuickPreset.title,
aiWashLevels: aiWashQuickLevels,
},
},
connections: [node.id],
});
selectNode(newNodeId);
try {
const submitResponse = await fetch("/api/ai-wash/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: aiWashQuickPrompt,
referenceImage: data.src,
selectedModelId: selectedAiWashQuickModel.id || effectiveAiWashQuickModelRef,
imageSize: aiWashQuickConfig.imageSize,
promptExtend: aiWashQuickConfig.promptExtend,
title: selectedAiWashQuickPreset.title,
}),
});
const submitPayload = await submitResponse.json().catch(() => ({}));
if (
!submitResponse.ok ||
submitPayload?.success !== true ||
!submitPayload?.taskId
) {
throw new Error(submitPayload?.error || "AI洗图提交失败");
}
setAiWashQuickStatus("running");
updateNode(newNodeId, {
data: {
src: "",
status: "generating",
prompt: aiWashQuickPrompt || `${actionName}处理中...`,
title: resultTitle,
originalSrc: data.src,
metadata: {
editMode: "ai-wash",
sourceNodeId: node.id,
taskId: submitPayload.taskId,
aiWashPresetId: selectedAiWashQuickPreset.id,
aiWashPresetTitle: selectedAiWashQuickPreset.title,
aiWashLevels: aiWashQuickLevels,
aiWashImageSize: aiWashQuickConfig.imageSize,
},
},
});
const completed = await pollAiWashQuickTask(submitPayload.taskId);
const resolvedModelLabel =
completed.modelId ||
selectedAiWashQuickModel?.displayName ||
selectedAiWashQuickModel?.modelId ||
effectiveAiWashQuickModelRef;
updateNode(newNodeId, {
data: {
src: completed.imageUrl,
status: "completed",
prompt: completed.prompt || aiWashQuickPrompt || `${actionName}结果`,
title: resultTitle,
originalSrc: data.src,
model: resolvedModelLabel,
metadata: {
editMode: "ai-wash",
sourceNodeId: node.id,
taskId: submitPayload.taskId,
thumbnailUrl: completed.thumbnailUrl,
aiWashPresetId: selectedAiWashQuickPreset.id,
aiWashPresetTitle: selectedAiWashQuickPreset.title,
aiWashLevels: aiWashQuickLevels,
aiWashImageSize: aiWashQuickConfig.imageSize,
modelId: resolvedModelLabel,
},
},
});
setAiWashQuickStatus("succeeded");
setAiWashQuickPanelOpen(false);
toast.success("AI洗图结果已生成到画布");
} catch (error) {
const message = error instanceof Error ? error.message : "AI洗图失败";
console.error("[ImageNode] AI洗图快捷生成失败:", error);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${resultTitle}-失败`,
originalSrc: data.src,
metadata: {
editMode: "ai-wash",
sourceNodeId: node.id,
error: message,
aiWashPresetId: selectedAiWashQuickPreset.id,
aiWashPresetTitle: selectedAiWashQuickPreset.title,
aiWashLevels: aiWashQuickLevels,
},
},
});
setAiWashQuickStatus("failed");
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
setIsSubmittingAiWashQuick(false);
}
}, [
addNode,
aiWashQuickConfig.enabled,
aiWashQuickConfig.hideModelSelector,
aiWashQuickConfig.imageSize,
aiWashQuickConfig.promptExtend,
aiWashQuickLevels,
aiWashQuickModeLabel,
aiWashQuickPrompt,
data.src,
displayTitle,
aiWashQuickHasExecutableModel,
effectiveAiWashQuickModelRef,
isProcessing,
isSubmittingAiWashQuick,
node,
resetBoxCutoutState,
resetLocalEditState,
selectNode,
selectedAiWashQuickModel?.disabledReason,
selectedAiWashQuickModel?.displayName,
selectedAiWashQuickModel?.id,
selectedAiWashQuickModel?.modelId,
selectedAiWashQuickPreset,
updateNode,
]);
const executeSharedToolbarCapability = async (
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
) => {
if (!projectId) {
toast.error("画布还在加载,请稍后再试");
return;
}
const capability = getCanvasCapabilityByClientAction(action);
if (!capability) {
toast.error("画布能力未注册");
return;
}
setIsCreatingDetachedResultNode(false);
setIsProcessing(true);
try {
const command = createCanvasCapabilityClientCommand({
capabilityId: capability.id,
nodeIds: [node.id],
projectId,
source: "toolbar",
});
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(command),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || `${capability.title}失败`);
}
const projectResult = await canvasService.getProject(projectId);
if (!projectResult.success || !projectResult.data?.canvasData) {
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
}
loadCanvas(projectResult.data.canvasData);
toast.success(`${capability.title}完成`);
} catch (error) {
const message =
error instanceof Error ? error.message : `${capability.title}失败`;
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
};
// 处理图片编辑操作
const handleAction = async (
action: CanvasCapabilityClientAction | "scene-fusion" | "annotate"
) => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (
AI_CLOUD_IMAGE_ACTIONS.has(action) &&
(isCanvasImageUploadPending(node) || !isHttpImageUrl(data.src))
) {
toast.error("图片仍在本地,上传完成后再进行 AI 处理");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
if (action === "ai-wash") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
resetLocalEditState();
setAiWashQuickPanelOpen((current) => !current);
return;
}
setAiWashQuickPanelOpen(false);
if (action !== "local-edit" && action !== "scale-adjust") {
resetLocalEditState();
}
// 对话框类型的操作
if (action === "crop") {
handleOpenCropPanel();
return;
}
if (action === "adjust") {
handleOpenImageAdjustPanel();
return;
}
if (action === "flip-rotate") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
setFlipRotatePanelOpen(true);
return;
}
if (action === "angle") {
if (!angleEditEnabled) {
toast.info("多视角功能未开启");
return;
}
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
setAnglePanelOpening(true);
setAnglePanelOpen(true);
return;
}
if (action === "scene-fusion") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
if (!batchProcessingEnabled) {
toast.info("批量处理功能未开启");
return;
}
if (!canvasId) {
toast.error("当前画布上下文缺失,暂时无法打开批量处理页面");
return;
}
router.push(`/canvas/${canvasId}/batch`);
return;
}
if (action === "resize") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setResizeAnnotatorOpen(true);
return;
}
if (action === "layer-split") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
if (!isLayerSplitEnabled) {
toast.info("图层拆分功能未开启");
return;
}
await executeSharedToolbarCapability("layer-split");
return;
}
if (action === "box-cutout") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
handleEnterBoxCutoutMode();
return;
}
if (action === "local-edit") {
handleEnterLocalEditMode();
return;
}
if (action === "layout-replace") {
handleOpenLayoutReplace();
return;
}
if (action === "scale-adjust") {
handleEnterLocalEditMode(undefined, "scale_adjust");
return;
}
if (action === "annotate") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnnotatorOpen(true);
return;
}
if (action === "text-edit") {
await handleOpenTextEdit();
return;
}
if (action === "note") {
handleOpenNotePanel();
return;
}
if (action === "prompt-regenerate") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setRegeneratePrompt(resolveRegeneratePromptSeed(data));
setPromptDialogOpen(true);
return;
}
if (action === "generate-3d-model") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
openThreedGenerationDialog("model");
return;
}
if (action === "generate-3d-world") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
openThreedGenerationDialog("world");
return;
}
if (
action === "rmbg" ||
action === "watermark" ||
action === "enhance" ||
action === "extend"
) {
await executeSharedToolbarCapability(action);
}
};
const requestCanvasAgentAnnotationEdit = useCallback(() => {
if (!hasCanvasAgentAnnotations || typeof window === "undefined") {
toast.info("当前图片还没有可用于 Agent 的批注");
return;
}
const detail = {
nodeId: node.id,
title: displayTitle,
annotationCount: canvasAgentAnnotations.length,
};
const targetWindow = window as CanvasAgentPendingRequestWindow;
selectNode(node.id, false);
storeCanvasAgentAnnotationEditRequest(targetWindow, detail);
dispatchCanvasAgentOpenChatPanelRequest(targetWindow, {
reason: "agent-annotation-edit",
nodeId: detail.nodeId,
});
dispatchCanvasAgentAnnotationEditRequest(targetWindow, detail);
}, [
canvasAgentAnnotations.length,
displayTitle,
hasCanvasAgentAnnotations,
node.id,
selectNode,
]);
useEffect(() => {
if (typeof window === "undefined") return;
const onExternalAction = (event: Event) => {
const customEvent = event as CustomEvent<{
nodeId?: string;
action?: string;
}>;
const targetNodeId = customEvent.detail?.nodeId;
const action = customEvent.detail?.action;
if (!targetNodeId || !action || targetNodeId !== node.id) return;
if (action === "scene-fusion" || action === "annotate") {
void handleAction(action);
return;
}
const capability = getCanvasCapabilityByClientAction(
action as CanvasCapabilityClientAction
);
if (capability) {
void handleAction(capability.clientAction);
}
};
window.addEventListener(
"canvas:image-action",
onExternalAction as EventListener
);
return () => {
window.removeEventListener(
"canvas:image-action",
onExternalAction as EventListener
);
};
}, [handleAction, node.id]);
const handleSceneFusionBatchAddToCanvas = useCallback(
(imageUrls: string[]) => {
const validUrls = imageUrls.filter(
(url) => typeof url === "string" && url.trim().length > 0
);
if (validUrls.length === 0) {
throw new Error("没有可添加到画布的图片");
}
const maxColumns = 3;
const gap = 20;
const baseX = node.position.x + node.size.width + gap;
const baseY = node.position.y;
validUrls.forEach((url, index) => {
const column = index % maxColumns;
const row = Math.floor(index / maxColumns);
addNode({
type: "image",
position: {
x: baseX + column * (node.size.width + gap),
y: baseY + row * (node.size.height + gap),
},
size: { width: node.size.width, height: node.size.height },
data: {
src: url,
status: "completed",
prompt: "批量处理结果",
title: `${displayTitle}-批量${index + 1}`,
originalSrc: data.src,
},
connections: [node.id],
});
});
},
[
addNode,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
]
);
const handleLayerSplitComplete = useCallback(
(
layers: Array<{
originalUrl: string;
title?: string;
layerIndex: number;
totalLayers: number;
bounds?: {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
coverage: number;
} | null;
}>
) => {
const validLayers = layers.filter(
(item) =>
typeof item?.originalUrl === "string" &&
item.originalUrl.trim().length > 0
);
if (validLayers.length === 0) {
throw new Error("没有可添加到画布的图层结果");
}
const isFullFrameLayer = (bounds?: LayerSplitBounds | null) => {
if (!bounds) return false;
return (
bounds.coverage >= 0.92 &&
bounds.width >= bounds.originalWidth - 2 &&
bounds.height >= bounds.originalHeight - 2
);
};
const backgroundLayers = validLayers
.filter((layer) => isFullFrameLayer(layer.bounds))
.sort(
(left, right) => (left.layerIndex || 0) - (right.layerIndex || 0)
);
const subjectLayers = validLayers
.filter((layer) => !isFullFrameLayer(layer.bounds))
.sort(
(left, right) => (left.layerIndex || 0) - (right.layerIndex || 0)
);
const orderedLayers = [...backgroundLayers, ...subjectLayers];
const groupGap = 28;
const groupOffsetX = node.size.width + groupGap;
const groupOffsetY = 0;
orderedLayers.forEach((layer, index) => {
const layerTitle = `${displayTitle}-${layer.title || `图层${layer.layerIndex || index + 1}`}`;
const bounds = layer.bounds;
const fullFrameLayer = isFullFrameLayer(bounds);
const targetPosition = {
x: node.position.x + groupOffsetX,
y: node.position.y + groupOffsetY,
};
const targetSize = { width: node.size.width, height: node.size.height };
addNode({
type: "image",
position: targetPosition,
size: targetSize,
data: {
src: layer.originalUrl,
status: "completed",
prompt: `图层拆分结果 ${layer.layerIndex}/${layer.totalLayers}`,
title: layerTitle,
originalSrc: data.src,
metadata: {
layerIndex: layer.layerIndex,
totalLayers: layer.totalLayers,
bounds: layer.bounds,
allowOverlap: true,
placementMode: "overlay",
layerSplitRole: fullFrameLayer ? "background" : "subject",
},
},
connections: [node.id],
});
});
return orderedLayers.length;
},
[
addNode,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
]
);
// 图片标注完成回调
const handleAnnotateComplete = async (
annotatedImageUrl: string,
payload?: ImageAnnotatorConfirmPayload
) => {
setAnnotatorOpen(false);
// 如果仍然是 base64 格式,先上传到服务器(ImageAnnotator 应该已经处理了,这里作为保护)
let finalUrl = annotatedImageUrl;
if (annotatedImageUrl.startsWith("data:")) {
try {
toast.info("正在上传图片...");
const response = await fetch(annotatedImageUrl);
const blob = await response.blob();
const formData = new FormData();
formData.append("file", blob, `annotated-${Date.now()}.png`);
formData.append("purpose", "canvas-upload");
const uploadResponse = await fetchUploadWithRetry(formData);
if (uploadResponse.ok) {
const result = await uploadResponse.json();
finalUrl = result.url;
} else {
toast.error("上传失败");
return;
}
} catch (error) {
console.error("上传标注图片失败:", error);
toast.error("上传失败");
return;
}
}
const visualAnnotationMetadata = appendCanvasVisualAnnotationsMetadata({
metadata: data.metadata,
nodeId: node.id,
actions: payload?.actions || [],
imageWidth: payload?.imageWidth,
imageHeight: payload?.imageHeight,
});
// 更新当前节点,标记为已标注图片
// 标注后的图片会带有视觉标记(箭头、圆圈、文字等)
// 这些标记指示了用户希望 AI 重点关注/修改的区域
updateNode(node.id, {
data: {
...data,
src: finalUrl,
hasAnnotations: true, // 🆕 标记为已标注
originalSrc: data.originalSrc || data.src, // 保留原始图片
prompt: data.prompt ? `${data.prompt} (已标注)` : "已标注图片",
metadata:
Object.keys(visualAnnotationMetadata.metadata).length > 0
? visualAnnotationMetadata.metadata
: undefined,
},
});
toast.success("标注已保存!发送对话时将重点处理标注区域");
};
const uploadBase64ToStorage = useCallback(async (base64Url: string) => {
const response = await fetch(base64Url);
const blob = await response.blob();
const formData = new FormData();
formData.append("file", blob, `canvas-resize-${Date.now()}.png`);
formData.append("purpose", "canvas-upload");
const uploadResponse = await fetchUploadWithRetry(formData);
if (!uploadResponse.ok) {
throw new Error("上传失败");
}
const result = await uploadResponse.json();
return result.url as string;
}, []);
const handleResizeEditorComplete = useCallback(
async (resizedImageUrl: string) => {
let finalUrl = resizedImageUrl;
if (resizedImageUrl.startsWith("data:")) {
try {
toast.info("正在上传尺寸调整结果...");
finalUrl = await uploadBase64ToStorage(resizedImageUrl);
} catch (error) {
console.error("上传尺寸调整图片失败:", error);
toast.error("尺寸调整结果上传失败");
return;
}
}
const title = `${displayTitle}-尺寸调整`;
addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: finalUrl,
status: "completed",
prompt: "尺寸调整结果",
title,
originalSrc: data.src,
},
connections: [node.id],
});
setResizeAnnotatorOpen(false);
toast.success("尺寸调整结果已发送到画布");
},
[
addNode,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
uploadBase64ToStorage,
]
);
const persistCanvasStateSnapshot = useCallback(() => {
if (!projectId || typeof window === "undefined") return;
window.setTimeout(() => {
void canvasService.saveCanvasState(projectId, getCanvasState());
}, 0);
}, [getCanvasState, projectId]);
const persistCanvas3DAsset = useCallback(
async (asset: Record) => {
if (!projectId) return;
try {
const response = await fetch(`/api/canvas/${projectId}/model3d-assets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ asset }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload?.error || `HTTP ${response.status}`);
}
} catch (error) {
console.error("[ImageNode] 保存画布 3D 资产失败:", error);
}
},
[projectId]
);
const pollCanvasModel3DTask = useCallback(
async (params: {
nodeId: string;
taskId: string;
providerId?: string | null;
providerName?: string | null;
promptText: string;
modelVersion: string;
sourceImageUrl: string;
previewImageUrl?: string | null;
}) => {
for (let attempt = 0; attempt < CANVAS_3D_MAX_POLL_ATTEMPTS; attempt += 1) {
await sleep(CANVAS_3D_POLL_INTERVAL_MS);
const result = await model3DGenerationService.getTask(params.taskId, {
providerId: params.providerId,
});
const status = String(result.data?.status || "unknown");
const progress = result.data?.progress ?? null;
if (!result.success || !result.data) {
const error = result.error || "3D 模型任务查询失败";
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
if (
status === "failed" ||
status === "error" ||
status === "cancelled" ||
status === "canceled"
) {
const error = result.data.errorMessage || "3D 模型任务失败";
const refundedPoints = Number(result.data.refundedPoints || 0);
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress,
error,
errorCode: result.data.errorCode ?? null,
refundedPoints,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
errorCode: result.data.errorCode ?? null,
refundedPoints,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(
refundedPoints > 0
? `${error},已退回 ${refundedPoints} 积分`
: error
);
return;
}
if (status === "success") {
const modelUrl = String(result.data.modelUrl || "").trim();
const previewImageUrl =
result.data.previewImageUrl ||
params.previewImageUrl ||
params.sourceImageUrl;
const responseAssetPayload =
result.data.assetPayload &&
typeof result.data.assetPayload === "object" &&
!Array.isArray(result.data.assetPayload)
? (result.data.assetPayload as Record)
: null;
const nextAssetPayload = {
...(responseAssetPayload || {}),
providerId:
params.providerId ||
(typeof responseAssetPayload?.providerId === "string"
? responseAssetPayload.providerId
: null),
providerName:
params.providerName ||
(typeof responseAssetPayload?.providerName === "string"
? responseAssetPayload.providerName
: null),
sourceImageUrl: params.sourceImageUrl,
};
if (!modelUrl) {
const error = "3D 模型已完成,但没有返回模型文件";
updateNode(params.nodeId, {
data: {
src: "",
previewImage: previewImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
updateNode(params.nodeId, {
data: {
src: modelUrl,
previewImage: previewImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
format: inferModelFormat(modelUrl),
status: "completed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress: 100,
output: result.data.output || null,
assetPayload: nextAssetPayload,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "success",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress: 100,
modelUrl,
previewImageUrl,
error: null,
assetPayload: nextAssetPayload,
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.success("3D 模型已生成到画布");
return;
}
if (isTerminal3DFailureStatus(status)) {
const error = result.data.errorMessage || "3D 模型生成失败";
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "generating",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
progress,
},
},
});
persistCanvasStateSnapshot();
}
const error = "3D 模型任务轮询超时,请稍后重试";
updateNode(params.nodeId, {
data: {
src: "",
previewImage: params.previewImageUrl || params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
metadata: {
taskId: params.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl: params.sourceImageUrl,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "tripo-3d",
status: "failed",
mode: "image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress: null,
modelUrl: null,
previewImageUrl: params.previewImageUrl || params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
},
[persistCanvas3DAsset, persistCanvasStateSnapshot, updateNode]
);
const pollCanvasWorld3DTask = useCallback(
async (params: {
nodeId: string;
taskId: string;
providerId?: string | null;
providerName?: string | null;
promptText: string;
modelVersion: string;
sourceImageUrl: string;
}) => {
for (let attempt = 0; attempt < CANVAS_3D_MAX_POLL_ATTEMPTS; attempt += 1) {
await sleep(CANVAS_3D_POLL_INTERVAL_MS);
const result = await worldLabsGenerationService.getTask(params.taskId, {
providerId: params.providerId,
});
const status = String(result.data?.status || "unknown");
const progress = result.data?.progress ?? null;
if (!result.success || !result.data) {
const error = result.error || "3D 世界任务查询失败";
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "worldlabs-3d",
status: "failed",
mode: "world-image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
if (status === "success") {
const assetPayload = (result.data.assetPayload || {}) as Record;
const splatUrl = pickBestArchivedWorldLabsSplatUrl(
assetPayload.spzUrls as Record | null | undefined
);
const returnedPanoUrl =
typeof assetPayload.panoUrl === "string" && assetPayload.panoUrl.trim()
? assetPayload.panoUrl.trim()
: null;
const returnedThumbnailUrl =
typeof assetPayload.thumbnailUrl === "string" && assetPayload.thumbnailUrl.trim()
? assetPayload.thumbnailUrl.trim()
: null;
const returnedPreviewImageUrl =
typeof result.data.previewImageUrl === "string" &&
result.data.previewImageUrl.trim()
? result.data.previewImageUrl.trim()
: null;
const rawReturnedMeshUrl =
result.data.modelUrl ||
(typeof assetPayload.meshUrl === "string" && assetPayload.meshUrl.trim()
? assetPayload.meshUrl.trim()
: null);
const returnedMeshUrl = isArchivedWorldLabsAssetUrl(rawReturnedMeshUrl)
? rawReturnedMeshUrl
: null;
const hasRenderableWorldAsset = Boolean(
splatUrl ||
returnedPanoUrl ||
returnedThumbnailUrl ||
returnedPreviewImageUrl ||
returnedMeshUrl
);
if (!hasRenderableWorldAsset) {
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "generating",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
progress: 95,
assetPayload,
},
},
});
persistCanvasStateSnapshot();
continue;
}
const imageUrl =
returnedPanoUrl ||
returnedThumbnailUrl ||
returnedPreviewImageUrl ||
params.sourceImageUrl;
const meshUrl = returnedMeshUrl;
const worldUrl =
typeof assetPayload.worldUrl === "string" ? assetPayload.worldUrl : null;
const previewImageUrl = result.data.previewImageUrl || imageUrl;
updateNode(params.nodeId, {
data: {
worldUrl: worldUrl || undefined,
imageUrl: imageUrl || undefined,
meshUrl: meshUrl || undefined,
splatUrl: splatUrl || undefined,
previewImage: previewImageUrl || undefined,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "completed",
activeView: splatUrl ? "splat" : meshUrl ? "mesh" : imageUrl ? "image" : "image",
firstPerson: false,
hotspots: [],
semanticsMetadata:
assetPayload.semanticsMetadata &&
typeof assetPayload.semanticsMetadata === "object"
? assetPayload.semanticsMetadata
: undefined,
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
progress: 100,
assetPayload: {
...assetPayload,
providerId: params.providerId || assetPayload.providerId || null,
providerName:
params.providerName || assetPayload.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "worldlabs-3d",
status: "success",
mode: "world-image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress: 100,
modelUrl: meshUrl,
previewImageUrl,
error: null,
assetPayload: {
...assetPayload,
providerId: params.providerId || assetPayload.providerId || null,
providerName: params.providerName || assetPayload.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.success("3D 世界已生成到画布");
return;
}
if (isTerminal3DFailureStatus(status)) {
const error = result.data.errorMessage || "3D 世界生成失败";
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
progress,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "worldlabs-3d",
status: "failed",
mode: "world-image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress,
modelUrl: null,
previewImageUrl: params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
return;
}
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "generating",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
progress,
},
},
});
persistCanvasStateSnapshot();
}
const error = "3D 世界任务轮询超时,请稍后重试";
updateNode(params.nodeId, {
data: {
imageUrl: params.sourceImageUrl,
previewImage: params.sourceImageUrl,
prompt: params.promptText,
modelVersion: params.modelVersion,
status: "failed",
activeView: "image",
metadata: {
taskId: params.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl: params.sourceImageUrl,
error,
},
},
});
await persistCanvas3DAsset({
taskId: params.taskId,
provider: "worldlabs-3d",
status: "failed",
mode: "world-image",
prompt: params.promptText,
modelVersion: params.modelVersion,
progress: null,
modelUrl: null,
previewImageUrl: params.sourceImageUrl,
error,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.error(error);
},
[persistCanvas3DAsset, persistCanvasStateSnapshot, updateNode]
);
const handleGenerateCanvas3DModel = useCallback(async (requestedModelVersion?: string) => {
if (!data.src || !isHttpImageUrl(data.src)) {
toast.error("请选择一张可访问的 URL 图片");
return;
}
if (!projectId) {
toast.error("画布还在加载,请稍后再试");
return;
}
const promptText = (data.prompt || displayTitle || "图生 3D 模型").trim();
const modelVersion =
requestedModelVersion || DEFAULT_CANVAS_TRIPO_MODEL || "tripo-standard";
const sourceImageUrl = data.src;
const newNodeId = addNode({
type: "model3d",
position: {
x: node.position.x + node.size.width + 24,
y: node.position.y,
},
size: { width: Math.max(360, node.size.width), height: 300 },
data: {
src: "",
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
format: "glb",
status: "generating",
metadata: {
provider: "tripo-3d",
mode: "image",
sourceImageUrl,
},
},
connections: [node.id],
});
selectNode(newNodeId);
persistCanvasStateSnapshot();
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
try {
const createResult = await model3DGenerationService.createTask({
taskType: "image_to_model",
imageUrl: sourceImageUrl,
imageType: inferTripoImageType(sourceImageUrl),
prompt: promptText,
modelVersion,
faceLimit: 120000,
texture: true,
pbr: modelFeatureVisibility.pbr !== false,
autoSize: false,
quad: false,
generateParts: false,
smartLowPoly: false,
geometryQuality: "standard",
textureQuality: "standard",
textureAlignment: "original_image",
orientation: "default",
});
if (!createResult.success || !createResult.data?.taskId) {
throw new Error(createResult.error || "创建图生 3D 模型任务失败");
}
const providerId = createResult.data.providerId || null;
const providerName = createResult.data.providerName || null;
updateNode(newNodeId, {
data: {
src: "",
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
format: "glb",
status: "generating",
metadata: {
taskId: createResult.data.taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl,
providerId,
providerName,
},
},
});
await persistCanvas3DAsset({
taskId: createResult.data.taskId,
provider: "tripo-3d",
status: createResult.data.status || "running",
mode: "image",
prompt: promptText,
modelVersion,
progress: null,
modelUrl: null,
previewImageUrl: sourceImageUrl,
error: null,
assetPayload: { providerId, providerName, sourceImageUrl },
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.success("已在画布创建 3D 模型任务");
void pollCanvasModel3DTask({
nodeId: newNodeId,
taskId: createResult.data.taskId,
providerId,
providerName,
promptText,
modelVersion,
sourceImageUrl,
previewImageUrl: sourceImageUrl,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "创建图生 3D 模型任务失败";
updateNode(newNodeId, {
data: {
src: "",
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
format: "glb",
status: "failed",
metadata: {
provider: "tripo-3d",
mode: "image",
sourceImageUrl,
error: message,
},
},
});
persistCanvasStateSnapshot();
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
}, [
addNode,
data.prompt,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.width,
persistCanvas3DAsset,
persistCanvasStateSnapshot,
pollCanvasModel3DTask,
projectId,
selectNode,
updateNode,
]);
const handleGenerateCanvas3DWorld = useCallback(async (requestedModelVersion?: string) => {
if (!data.src || !isHttpImageUrl(data.src)) {
toast.error("请选择一张可访问的 URL 图片");
return;
}
if (!projectId) {
toast.error("画布还在加载,请稍后再试");
return;
}
const promptText = (data.prompt || displayTitle || "图生 3D 世界").trim();
const modelVersion =
requestedModelVersion || DEFAULT_CANVAS_WORLD_MODEL || "world-standard";
const sourceImageUrl = data.src;
const newNodeId = addNode({
type: "world3d",
position: {
x: node.position.x + node.size.width + 24,
y: node.position.y + 24,
},
size: { width: Math.max(420, node.size.width), height: 320 },
data: {
imageUrl: sourceImageUrl,
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
status: "generating",
activeView: "image",
firstPerson: false,
hotspots: [],
metadata: {
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl,
generationStartedAt: new Date().toISOString(),
},
},
connections: [node.id],
});
selectNode(newNodeId);
persistCanvasStateSnapshot();
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
try {
const createResult = await worldLabsGenerationService.createTask({
mode: "image",
sourceUrl: sourceImageUrl,
prompt: promptText || undefined,
displayName: displayTitle || promptText || "画布图生世界",
model: modelVersion,
disableRecaption: false,
makePublic: false,
});
if (!createResult.success || !createResult.data?.taskId) {
throw new Error(createResult.error || "创建图生 3D 世界任务失败");
}
const createAssetPayload = {
...(createResult.data.assetPayload || {}),
sourceImageUrl,
};
updateNode(newNodeId, {
data: {
imageUrl: sourceImageUrl,
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
status: "generating",
activeView: "image",
firstPerson: false,
hotspots: [],
metadata: {
taskId: createResult.data.taskId,
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl,
generationStartedAt: new Date().toISOString(),
assetPayload: createAssetPayload,
},
},
});
await persistCanvas3DAsset({
taskId: createResult.data.taskId,
provider: "worldlabs-3d",
status: createResult.data.status || "running",
mode: "world-image",
prompt: promptText,
modelVersion,
progress: null,
modelUrl: null,
previewImageUrl: sourceImageUrl,
error: null,
assetPayload: createAssetPayload,
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
toast.success("已在画布创建 3D 世界任务");
void pollCanvasWorld3DTask({
nodeId: newNodeId,
taskId: createResult.data.taskId,
providerId: createAssetPayload.providerId || null,
providerName: createAssetPayload.providerName || null,
promptText,
modelVersion,
sourceImageUrl,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "创建图生 3D 世界任务失败";
updateNode(newNodeId, {
data: {
imageUrl: sourceImageUrl,
previewImage: sourceImageUrl,
prompt: promptText,
modelVersion,
status: "failed",
activeView: "image",
firstPerson: false,
hotspots: [],
metadata: {
provider: "worldlabs-3d",
mode: "world-image",
sourceImageUrl,
error: message,
},
},
});
persistCanvasStateSnapshot();
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
}, [
addNode,
data.prompt,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.width,
persistCanvas3DAsset,
persistCanvasStateSnapshot,
pollCanvasWorld3DTask,
projectId,
selectNode,
updateNode,
]);
// 基于当前图片+新提示词重绘
const handlePromptRegenerate = async () => {
if (!data.src) return;
if (!isHttpImageUrl(data.src)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
const promptText = regeneratePrompt.trim();
if (!promptText) {
toast.error("请输入提示词");
return;
}
if (isProcessing) {
toast.info("正在处理中,请稍候...");
return;
}
setPromptDialogOpen(false);
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: promptText,
title: `${displayTitle}-重绘`,
originalSrc: data.src,
},
connections: [node.id],
});
try {
const aspectRatio = guessNearestAspectRatio(
node.size.width,
node.size.height
);
const result = await imageGenerationService.generateImage({
prompt: promptText,
referenceImage: data.src,
aspectRatio,
imageCount: 1,
billingFeature: "repaint",
});
const image = result.data?.images?.[0];
if (result.success && image?.originalUrl) {
updateNode(newNodeId, {
data: {
src: image.originalUrl,
status: "completed",
prompt: promptText,
title: `${promptText.slice(0, 24) || displayTitle}-重绘`,
originalSrc: data.src,
},
});
toast.success("已根据新提示词生成图片");
} else {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "提示词重绘失败",
title: `${displayTitle}-重绘失败`,
originalSrc: data.src,
},
});
toast.error(result.error || "重绘失败");
}
} catch (error) {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: "提示词重绘失败",
title: `${displayTitle}-重绘失败`,
originalSrc: data.src,
},
});
toast.error("重绘失败");
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
};
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
const isDownloadingOriginalImage = Boolean(downloadProgress);
const handleDownload = async () => {
if (!data.src) return;
if (isDownloadingOriginalImage) {
toast.info("原图正在下载中");
return;
}
const controller = new AbortController();
downloadAbortControllerRef.current = controller;
setDownloadProgress({
phase: "preparing",
message: "正在准备原图...",
percent: 5,
});
try {
await downloadCanvasImageAsPng({
url: data.src,
name: displayTitle,
candidates: [
displayImageSrc,
fullscreenPreviewSrc,
buildImageProxyUrl(resolveOriginalImageUrl(data.src) || data.src, {
delivery: "proxy",
}),
],
signal: controller.signal,
onProgress: setDownloadProgress,
});
toast.success("原图下载成功");
} catch (error) {
if (controller.signal.aborted) {
toast.message("已取消下载");
} else {
console.error("下载失败:", error);
toast.error(getCanvasImageDownloadErrorMessage(error));
}
} finally {
if (downloadAbortControllerRef.current === controller) {
downloadAbortControllerRef.current = null;
}
setDownloadProgress(null);
}
};
const handleCancelDownload = () => {
downloadAbortControllerRef.current?.abort();
setDownloadProgress(null);
};
const imageAdjustOverlayStyle = useMemo(() => {
if (boxCutoutMetrics) {
return {
left: boxCutoutMetrics.offsetX,
top: boxCutoutMetrics.offsetY,
width: boxCutoutMetrics.renderedWidth,
height: boxCutoutMetrics.renderedHeight,
};
}
return { inset: 0 };
}, [boxCutoutMetrics]);
const renderImageAdjustmentOverlays = useCallback(() => {
return (
);
}, [
activeImageAdjustments,
hasActiveImageAdjustments,
imageAdjustOverlayStyle,
]);
const cropPanelPosition = getCropPanelPosition();
const imageAdjustPanelPosition = getImageAdjustPanelPosition();
const toolbarPosition = getToolbarPosition();
const aiWashQuickPanelPosition = getAiWashQuickPanelPosition();
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
const flipRotatePanelPosition = getFlipRotatePanelPosition();
const anglePanelPosition = getAnglePanelPosition();
const boxCutoutHintText = boxCutoutSelection
? "确认后将仅抠出框选区域"
: isBoxCutoutDrawing
? "松开完成框选"
: "在图片中拖拽矩形框";
return (
<>
{/* 选中时在图片上方显示标题 */}
{isSelected && !isMultiSelected && data.src && !sceneFusionDialogOpen && (
setIsEditingTitle(false)}
onDraftTitleChange={setDraftTitle}
onSaveTitle={handleSaveTitle}
onStartEditing={() => setIsEditingTitle(true)}
resolutionLabel={resolutionLabel}
style={selectedTitleStyle}
/>
)}
setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
ref={containerRef}
>
{isRecentlyGenerated ? (
) : null}
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
{data.src ? (
{!shouldRequestDisplayImage ? (
) : isCompareMode && hasCompareSource ? (
{
event.stopPropagation();
updateCompareSplitByClientX(event.clientX);
}}
onPointerMove={(event) => {
updateCompareSplitByClientX(event.clientX);
}}
style={{ touchAction: "none" }}
>
![{displayTitle}]()
{
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
}
}}
onLoad={handleDisplayImageLoad}
ref={displayImageRef}
src={displayImageSrc}
style={activeImageAdjustmentsStyle}
/>
![{`${displayTitle}-原图`}]()
{
if (event.currentTarget.src !== compareSource) {
event.currentTarget.src = compareSource;
}
}}
src={displayCompareSrc}
/>
<>
) : (
<>
![{displayTitle}]()
{
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
e.stopPropagation();
setFullscreenPreviewOpen(true);
}
}}
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
}
}}
onLoad={handleDisplayImageLoad}
ref={displayImageRef}
src={displayImageSrc}
style={activeImageAdjustmentsStyle}
/>
{renderImageAdjustmentOverlays()}
{shouldRenderDetailOverlay ? (
detailUiDslDocument ? (
) : (
)
) : null}
{canvasAgentAnnotations.length > 0 &&
!boxCutoutMode &&
!localEditMode &&
!cropPanelOpen &&
!isThisNodePicking ? (
{boxCutoutMetrics
? canvasAgentAnnotations.map((annotation) => {
const boundsStyle = getCanvasAnnotationBoundsStyle(
annotation,
boxCutoutMetrics
);
if (!boundsStyle) return null;
const color = annotation.color || "#f59e0b";
return (
);
})
: null}
annotation.text)
.filter(Boolean)
.join(" / ")}
>
批注
{canvasAgentAnnotations.length}
{isSelected
? visibleCanvasAgentAnnotations
.filter((annotation) => annotation.text)
.map((annotation) => (
{annotation.text}
))
: null}
) : null}
>
)}
{cropPanelOpen &&
!isThisNodePicking &&
!isCompareMode &&
boxCutoutMetrics &&
cropRectStyle &&
cropRect && (
{
event.preventDefault();
event.stopPropagation();
}}
onPointerCancel={handleCropPointerCancel}
onPointerDown={handleCropPointerDown}
onPointerMove={handleCropPointerMove}
onPointerUp={handleCropPointerUp}
ref={cropOverlayRef}
style={{
left: boxCutoutMetrics.offsetX,
top: boxCutoutMetrics.offsetY,
width: boxCutoutMetrics.renderedWidth,
height: boxCutoutMetrics.renderedHeight,
}}
>
{(
[
{ handle: "nw", className: "-left-3 -top-3 cursor-nwse-resize" },
{ handle: "ne", className: "-right-3 -top-3 cursor-nesw-resize" },
{ handle: "sw", className: "-left-3 -bottom-3 cursor-nesw-resize" },
{ handle: "se", className: "-right-3 -bottom-3 cursor-nwse-resize" },
{ handle: "n", className: "left-1/2 -top-3 -translate-x-1/2 cursor-ns-resize" },
{ handle: "s", className: "left-1/2 -bottom-3 -translate-x-1/2 cursor-ns-resize" },
{ handle: "w", className: "-left-3 top-1/2 -translate-y-1/2 cursor-ew-resize" },
{ handle: "e", className: "-right-3 top-1/2 -translate-y-1/2 cursor-ew-resize" },
] as const
).map((handleItem) => (
))}
)}
{boxCutoutMode && !isThisNodePicking && !isCompareMode && (
<>
{
event.preventDefault();
event.stopPropagation();
}}
onPointerCancel={handleBoxCutoutPointerCancel}
onPointerDown={handleBoxCutoutPointerDown}
onPointerMove={handleBoxCutoutPointerMove}
onPointerUp={handleBoxCutoutPointerUp}
/>
{activeBoxCutoutRectStyle ? (
) : (
)}
>
)}
{localEditMode && !isThisNodePicking && !isCompareMode && (
<>
{
event.preventDefault();
event.stopPropagation();
}}
onPointerCancel={handleLocalEditPointerCancel}
onPointerDown={handleLocalEditPointerDown}
onPointerMove={handleLocalEditPointerMove}
onPointerUp={handleLocalEditPointerUp}
/>
{boxCutoutMetrics ? (
) : null}
{localEditScaleTargetOverlays.map(({ region, sourceStyle, style }) => {
const sourceLeft = Number(sourceStyle.left || 0);
const sourceTop = Number(sourceStyle.top || 0);
const sourceWidth = Number(sourceStyle.width || 0);
const sourceHeight = Number(sourceStyle.height || 0);
const targetLeft = Number(style.left || 0);
const targetTop = Number(style.top || 0);
const targetWidth = Number(style.width || 0);
const targetHeight = Number(style.height || 0);
const markerId = `canvas-scale-arrow-${node.id}-${region.id}`;
const connectorPoints = [
{
key: "nw",
source: { x: sourceLeft, y: sourceTop },
target: { x: targetLeft, y: targetTop },
},
{
key: "ne",
source: { x: sourceLeft + sourceWidth, y: sourceTop },
target: { x: targetLeft + targetWidth, y: targetTop },
},
{
key: "sw",
source: { x: sourceLeft, y: sourceTop + sourceHeight },
target: { x: targetLeft, y: targetTop + targetHeight },
},
{
key: "se",
source: {
x: sourceLeft + sourceWidth,
y: sourceTop + sourceHeight,
},
target: {
x: targetLeft + targetWidth,
y: targetTop + targetHeight,
},
},
];
return (
);
})}
{localEditScaleTargetOverlays.map(
({ region, style, targetRect }) => {
const left = Number(style.left || 0);
const top = Number(style.top || 0);
const width = Number(style.width || 0);
const height = Number(style.height || 0);
const active =
localEditRegionInteraction?.regionId === region.id &&
localEditRegionInteraction.target === "scaleTarget";
return (
{
event.preventDefault();
event.stopPropagation();
}}
onPointerCancel={(event) =>
handleLocalEditRegionPointerCancel(event, region.id)
}
onPointerDown={(event) =>
handleLocalEditRegionPointerDown(
event,
region,
"scaleTarget",
targetRect
)
}
onPointerMove={(event) =>
handleLocalEditRegionPointerMove(event, region.id)
}
onPointerUp={(event) =>
handleLocalEditRegionPointerUp(event, region.id)
}
style={{
left,
top,
width: Math.max(width, 1),
height: Math.max(height, 1),
}}
title="目标框"
>
目标框
{(
[
{
handle: "nw",
className: "-left-[5px] -top-[5px] cursor-nwse-resize",
},
{
handle: "ne",
className: "-right-[5px] -top-[5px] cursor-nesw-resize",
},
{
handle: "sw",
className: "-left-[5px] -bottom-[5px] cursor-nesw-resize",
},
{
handle: "se",
className: "-right-[5px] -bottom-[5px] cursor-nwse-resize",
},
] as const
).map((item) => (
))}
);
}
)}
{localEditRegionOverlays.map(({ region, style }) => {
const left = Number(style.left || 0);
const top = Number(style.top || 0);
const width = Number(style.width || 0);
const height = Number(style.height || 0);
const isAwaitingScaleTarget =
localEditOperationMode === "scale_adjust" && !region.targetRect;
const active =
localEditRegionInteraction?.regionId === region.id &&
localEditRegionInteraction.target === "source";
return (
{
event.preventDefault();
event.stopPropagation();
}}
onPointerCancel={(event) =>
handleLocalEditRegionPointerCancel(event, region.id)
}
onPointerDown={(event) =>
handleLocalEditRegionPointerDown(event, region)
}
onPointerMove={(event) =>
handleLocalEditRegionPointerMove(event, region.id)
}
onPointerUp={(event) =>
handleLocalEditRegionPointerUp(event, region.id)
}
style={{
left,
top,
width: Math.max(width, 1),
height: Math.max(height, 1),
borderColor: region.color,
}}
>
{!isAwaitingScaleTarget ? (
[
{
handle: "nw",
className: "-left-[5px] -top-[5px] cursor-nwse-resize",
},
{
handle: "ne",
className: "-right-[5px] -top-[5px] cursor-nesw-resize",
},
{
handle: "sw",
className: "-left-[5px] -bottom-[5px] cursor-nesw-resize",
},
{
handle: "se",
className: "-right-[5px] -bottom-[5px] cursor-nwse-resize",
},
] as const
).map((item) => (
)) : null}
);
})}
{(localEditOperationMode as CanvasLocalEditOperationMode) !== "scale_adjust" &&
typeof document !== "undefined" &&
localEditPromptPortalFrame
? createPortal(
{localEditPromptViewportOverlays.map(
({ region, isEditing, isAwaitingScaleTarget, style }) => (
event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
style={style}
>
{isEditing ? (
{
const nextPrompt =
localEditPromptDraftRefs.current[region.id] ??
event.currentTarget.value;
event.currentTarget.value = nextPrompt;
persistLocalEditRegionPrompt(region.id, nextPrompt);
}}
onClick={(event) => {
event.stopPropagation();
}}
onCompositionEnd={(event) => {
finishLocalEditPromptComposition(
region.id,
event.currentTarget,
event.data
);
}}
onCompositionStart={(event) => {
localEditPromptCompositionStartRefs.current[
region.id
] = {
end:
event.currentTarget.selectionEnd ??
event.currentTarget.value.length,
start:
event.currentTarget.selectionStart ??
event.currentTarget.value.length,
value:
localEditPromptDraftRefs.current[region.id] ??
event.currentTarget.value,
};
}}
onFocus={(event) => {
event.stopPropagation();
setEditingLocalEditRegionId((current) =>
current === region.id ? current : region.id
);
}}
onChange={(event) => {
updateLocalEditPromptDraftRef(
region.id,
event.currentTarget.value
);
}}
onKeyDown={(event) => {
const isComposing =
event.nativeEvent.isComposing ||
event.key === "Process";
if (isComposing) {
return;
}
if (
applyLocalEditPromptKeyEdit(
region.id,
event.currentTarget,
event
)
) {
return;
}
if (
event.key === "Enter" &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
event.stopPropagation();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
}}
onMouseDown={(event) => {
event.stopPropagation();
}}
onPointerDown={(event) => {
event.stopPropagation();
}}
placeholder={`区域 ${region.index} 提示词`}
ref={(input) => {
localEditPromptInputRefs.current[region.id] = input;
}}
spellCheck={false}
type="text"
/>
) : (
)}
{
void handleLocalEditReferenceUpload(
region.id,
event.currentTarget.files
);
event.currentTarget.value = "";
}}
type="file"
/>
{region.referenceImages?.length ? (
参考
{region.referenceImages.map((reference) => (
))}
) : null}
)
)}
,
document.body
)
: null}
{localEditDraftRectStyle ? (
) : null}
>
)}
{/* 🆕 拾取模式:当前点击位置(正在识别) */}
{isThisNodePicking && currentPickerClickStyle && (
)}
{/* 🆕 拾取模式:状态栏 */}
{isThisNodePicking && (
{isPickerLoading ? "正在识别..." : "点击标注物品"}
)}
{hasCompareSource &&
!isThisNodePicking &&
!isCompareMode &&
!boxCutoutMode &&
!localEditMode &&
!cropPanelOpen &&
isSelected &&
!isMultiSelected && (
)}
) : (
)}
{/* 处理中遮罩 */}
{isProcessing &&
!isSubmittingLocalEdit &&
!isCreatingDetachedResultNode && (
)}
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
{/* 悬浮时的更多操作按钮 (右上角) */}
{isHovered &&
data.src &&
!isProcessing &&
!isSelected &&
hasHoverImageMoreMenu ? (
{imageRemoveBackgroundEnabled ? (
handleAction("rmbg")}>
{imageRemoveBackgroundItem.label}
) : null}
{imageRemoveWatermarkEnabled ? (
handleAction("watermark")}>
{imageRemoveWatermarkItem.label}
) : null}
{imageEnhanceEnabled ? (
handleAction("enhance")}>
{imageEnhanceItem.label}
) : null}
{imageNoteEnabled ? (
handleAction("note")}>
{imageNoteItem.label}
) : null}
{hasCanvasAgentAnnotations ? (
按批注修改
) : null}
{isLayerSplitEnabled && imageLayerSplitEnabledByToolbar ? (
handleAction("layer-split")}>
{imageLayerSplitItem.label}
) : null}
{imageResizeEnabled ? (
handleAction("resize")}>
{imageResizeItem.label}
) : null}
{imageMultiAngleEnabled ? (
handleAction("angle")}>
{imageMultiAngleItem.label}
) : null}
{imagePromptRegenerateEnabled ? (
handleAction("prompt-regenerate")}
>
{imagePromptRegenerateItem.label}
) : null}
{imageTextEditEnabled ? (
handleAction("text-edit")}>
{imageTextEditItem.label}
) : null}
{imageLayoutReplaceEnabled ? (
handleAction("layout-replace")}
>
{imageLayoutReplaceItem.label}
) : null}
{imageExtendEnabled ? (
handleAction("extend")}>
{imageExtendItem.label}
) : null}
{imageCropEnabled ? (
handleAction("crop")}>
{imageCropItem.label}
) : null}
{imageAdjustEnabled ? (
handleAction("adjust")}>
{imageAdjustItem.label}
) : null}
{imageFlipRotateEnabled ? (
handleAction("flip-rotate")}>
{imageFlipRotateItem.label}
) : null}
{imageRenameEnabled ? (
{
setDraftTitle(displayTitle);
setIsEditingTitle(true);
}}
>
{imageRenameItem.label}
) : null}
{hasHoverImageMorePrimaryActions &&
hasHoverImageMoreSecondaryActions ? (
) : null}
{imageDownloadEnabled ? (
{
void handleDownload();
}}
>
{isDownloadingOriginalImage ? (
) : (
)}
{imageDownloadItem.label}
) : null}
{imageDeleteEnabled ? (
deleteNode(node.id)}
>
{imageDeleteItem.label}
) : null}
) : null}
{data.note && !isSelected && (
{imageNoteItem.label}
)}
{shortAgentExecutionSnapshotId ? (
) : null}
{/* 批量处理对话框 */}
{batchProcessingEnabled ? (
) : null}
{/* 提示词重绘对话框 */}
{layoutReplaceDialogOpen ? (
) : null}
{localEditMode &&
!isMultiSelected &&
data.src &&
!isThisNodePicking &&
!isCompareMode &&
typeof document !== "undefined" &&
createPortal(
event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
ref={boxCutoutToolbarRef}
style={{
top: boxCutoutToolbarPosition.top,
left: boxCutoutToolbarPosition.left,
transform: `scale(${FLOATING_TOOLBAR_SCALE})`,
transformOrigin: "top left",
pointerEvents: "auto",
maxWidth: `${Math.max(
180,
Math.floor(boxCutoutToolbarPosition.maxWidth / FLOATING_TOOLBAR_SCALE)
)}px`,
overflowX: "auto",
scrollbarWidth: "none",
}}
>
{localEditOperationMode === "scale_adjust" ? "比例调整" : "局部编辑"}
{localEditRegions.length}/{LOCAL_EDIT_MAX_REGIONS}
,
document.body
)}
{aiWashQuickPanelOpen &&
imageAiWashQuickEnabled &&
isSelected &&
!isMultiSelected &&
data.src &&
!isEditingTitle &&
!sceneFusionDialogOpen &&
!promptDialogOpen &&
!layoutReplaceDialogOpen &&
!threedGenerationDialogMode &&
!notePanelOpen &&
!boxCutoutMode &&
!localEditMode &&
!cropPanelOpen &&
typeof document !== "undefined" &&
createPortal(
event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
style={{
top: aiWashQuickPanelPosition.top,
left: aiWashQuickPanelPosition.left,
width: aiWashQuickPanelPosition.width,
maxHeight: aiWashQuickPanelPosition.maxHeight,
overflowY: "auto",
scrollbarWidth: "none",
}}
>
{aiWashConfigLoading ? (
正在加载 AI洗图 配置...
) : null}
目标
{getAiWashStatusLabel(aiWashQuickStatus)}
{selectedAiWashQuickPreset?.description ? (
{selectedAiWashQuickPreset.description}
) : null}
参数
{shouldShowAiWashQuickModelSelector ? (
{selectedAiWashQuickModel
? `${selectedAiWashQuickModel.displayName} · ${
selectedAiWashQuickModel.unitCost > 0
? `${selectedAiWashQuickModel.unitCost} 积分/张`
: "未设置积分"
}`
: aiWashModelOptionsLoading
? "正在加载可选模型..."
: "没有可选模型"}
) : null}
{aiWashQuickControls.map((control) => {
const selectedIndex =
aiWashQuickLevels[control.id] ?? control.defaultLevel;
const activeLevel =
getAiWashControlLevelByIndex(control, selectedIndex) ||
control.levels[0] ||
null;
return (
{control.label}
{activeLevel?.label || selectedIndex + 1}
setAiWashQuickLevels((current) => ({
...current,
[control.id]: Math.round(next ?? 0),
}))
}
rangeClassName="bg-slate-950 dark:bg-white"
step={1}
thumbClassName="h-3.5 w-3.5 border border-slate-300 bg-white shadow-[0_0_0_4px_rgba(15,23,42,0.06)]"
trackClassName="h-1.5 bg-slate-200 dark:bg-white/14"
value={[selectedIndex]}
/>
);
})}
,
document.body
)}
{isSelected &&
!isMultiSelected &&
data.src &&
!isEditingTitle &&
!sceneFusionDialogOpen &&
!promptDialogOpen &&
!threedGenerationDialogMode &&
!notePanelOpen &&
!boxCutoutMode &&
!localEditMode &&
!cropPanelOpen &&
typeof document !== "undefined" &&
createPortal(
e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
ref={toolbarRef}
style={{
top: toolbarPosition.top,
left: toolbarPosition.left,
transform: `scale(${FLOATING_TOOLBAR_SCALE})`,
transformOrigin: "top left",
pointerEvents: "auto",
maxWidth: `${Math.max(
180,
Math.floor(toolbarPosition.maxWidth / FLOATING_TOOLBAR_SCALE)
)}px`,
overflowX: "visible",
scrollbarWidth: "none",
}}
>
{imageAddToChatEnabled ? (
) : null}
{imageAddToChatEnabled &&
(hasSelectedImagePrimaryToolbarActions ||
hasSelectedImageSecondaryToolbarActions) ? (
) : null}
{isSelectedImageActionInline("pickObject") ? (
) : null}
{isSelectedImageActionInline("enhance") ? (
) : null}
{isSelectedImageActionInline("removeBackground") ? (
) : null}
{isSelectedImageActionInline("aiWashQuick") ? (
) : null}
{isSelectedImageActionInline("textEdit") ? (
) : null}
{isSelectedImageActionInline("boxCutout") ? (
) : null}
{isSelectedImageActionInline("localEdit") ? (
) : null}
{isSelectedImageActionInline("layoutReplace") ? (
) : null}
{isSelectedImageActionInline("removeWatermark") ? (
) : null}
{isSelectedImageActionInline("resize") ? (
) : null}
{isSelectedImageActionInline("multiAngle") ? (
) : null}
{isSelectedImageActionInline("generate3dModel") ? (
) : null}
{isSelectedImageActionInline("generate3dWorld") ? (
) : null}
{isSelectedImageActionInline("layerSplit") ? (
) : null}
{isSelectedImageActionInline("extend") ? (
) : null}
{isSelectedImageActionInline("crop") ? (
) : null}
{isSelectedImageActionInline("adjust") ? (
) : null}
{isSelectedImageActionInline("flipRotate") ? (
) : null}
{isSelectedImageActionInline("promptRegenerate") ? (
) : null}
{isSelectedImageActionInline("note") ? (
) : null}
{hasSelectedImagePrimaryToolbarActions &&
hasSelectedImageSecondaryToolbarActions ? (
) : null}
{hasSelectedImageMoreMenu ? (
{isSelectedImageActionInMore("boxCutout") ? (
handleAction("box-cutout")}>
{imageBoxCutoutItem.label}
) : null}
{isSelectedImageActionInMore("localEdit") ? (
handleAction("local-edit")}>
{imageLocalEditItem.label}
) : null}
{isSelectedImageActionInMore("layoutReplace") ? (
handleAction("layout-replace")}
>
{imageLayoutReplaceItem.label}
) : null}
{isSelectedImageActionInMore("removeWatermark") ? (
handleAction("watermark")}>
{imageRemoveWatermarkItem.label}
) : null}
{isSelectedImageActionInMore("resize") ? (
handleAction("resize")}>
{imageResizeItem.label}
) : null}
{isSelectedImageActionInMore("multiAngle") ? (
handleAction("angle")}>
{imageMultiAngleItem.label}
) : null}
{isSelectedImageActionInMore("generate3dModel") ? (
handleAction("generate-3d-model")}
>
{imageGenerate3dModelItem.label}
) : null}
{isSelectedImageActionInMore("generate3dWorld") ? (
handleAction("generate-3d-world")}
>
{imageGenerate3dWorldItem.label}
) : null}
{isSelectedImageActionInMore("layerSplit") ? (
handleAction("layer-split")}>
{imageLayerSplitItem.label}
) : null}
{isSelectedImageActionInMore("extend") ? (
handleAction("extend")}>
{imageExtendItem.label}
) : null}
{isSelectedImageActionInMore("crop") ? (
handleAction("crop")}>
{imageCropItem.label}
) : null}
{isSelectedImageActionInMore("adjust") ? (
handleAction("adjust")}>
{imageAdjustItem.label}
) : null}
{isSelectedImageActionInMore("flipRotate") ? (
handleAction("flip-rotate")}>
{imageFlipRotateItem.label}
) : null}
{isSelectedImageActionInMore("promptRegenerate") ? (
handleAction("prompt-regenerate")}
>
{imagePromptRegenerateItem.label}
) : null}
{isSelectedImageActionInMore("note") ? (
handleAction("note")}>
{imageNoteItem.label}
) : null}
{hasCanvasAgentAnnotations ? (
按批注修改
) : null}
{(hasSelectedImageMorePrimaryActions || hasCanvasAgentAnnotations) &&
imageDeleteEnabled ? (
) : null}
{imageDeleteEnabled ? (
deleteNode(node.id)}
>
{imageDeleteItem.label}
) : null}
) : null}
{imageDownloadEnabled ? (
) : null}
,
document.body
)}
{isSelected &&
!isMultiSelected &&
data.src &&
boxCutoutMode &&
typeof document !== "undefined" &&
createPortal(
event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
ref={boxCutoutToolbarRef}
style={{
top: boxCutoutToolbarPosition.top,
left: boxCutoutToolbarPosition.left,
transform: `scale(${FLOATING_TOOLBAR_SCALE})`,
transformOrigin: "top left",
pointerEvents: "auto",
maxWidth: `${Math.max(
180,
Math.floor(boxCutoutToolbarPosition.maxWidth / FLOATING_TOOLBAR_SCALE)
)}px`,
overflowX: "auto",
scrollbarWidth: "none",
}}
>
{boxCutoutHintText}
,
document.body
)}
{/* 图片批注对话框 */}
{isSelected &&
cropPanelOpen &&
data.src &&
typeof document !== "undefined" &&
createPortal(
event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
style={{
top: cropPanelPosition.top,
left: cropPanelPosition.left,
transform: `scale(${IMAGE_EDIT_PANEL_SCALE})`,
transformOrigin: "top left",
}}
>
{imageCropItem.label}
预设
{CROP_PRESET_GROUPS.map((group) => {
const expanded = cropExpandedGroups[group.key];
return (
{expanded ? (
{group.options.map((option) => {
const selected = cropPresetId === option.id;
return (
);
})}
) : null}
);
})}
,
document.body
)}
setImageAdjustPanelOpen(false)}
onDraftChange={setImageAdjustmentsDraft}
onReset={handleResetImageAdjustments}
onTabChange={setImageAdjustTab}
open={imageAdjustPanelOpen}
position={imageAdjustPanelPosition}
scale={IMAGE_EDIT_PANEL_SCALE}
tab={imageAdjustTab}
/>
{isSelected &&
flipRotatePanelOpen &&
data.src &&
typeof document !== "undefined" &&
createPortal(
event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
style={{
top: flipRotatePanelPosition.top,
left: flipRotatePanelPosition.left,
maxWidth: `${Math.floor(
flipRotatePanelPosition.maxWidth / IMAGE_EDIT_PANEL_SCALE
)}px`,
transform: `scale(${IMAGE_EDIT_PANEL_SCALE})`,
transformOrigin: "top left",
}}
>
{imageFlipRotateItem.label}
0°
恢复原图
水平翻转
垂直翻转
关闭
,
document.body
)}
{isSelected &&
anglePanelOpen &&
data.src &&
typeof document !== "undefined" &&
createPortal(
event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
style={{
top: anglePanelPosition.top,
left: anglePanelPosition.left,
transform: `scale(${ANGLE_PANEL_SCALE})`,
transformOrigin: "top left",
}}
>
多角度
{(
[
{ id: "subject", label: "主体" },
{ id: "camera", label: "摄像头" },
] as const
).map((item) => {
const active = anglePanelMode === item.id;
return (
);
})}
{ANGLE_PREVIEW_VERTICAL_RING_ROTATIONS.map((rotation, index) => (
))}
{ANGLE_PREVIEW_HORIZONTAL_RING_ROTATIONS.map((rotation, index) => (
))}
{ANGLE_PREVIEW_DIAGONAL_RING_ROTATIONS.map((rotation, index) => (
))}
{anglePanelMode === "subject" ? (
) : (
)}
{anglePanelMode === "subject" ? (
) : null}
,
document.body
)}
{/* 文字编辑面板(显示在图片节点旁边) */}
{isSelected &&
textEditPanelOpen &&
data.src &&
typeof document !== "undefined" &&
createPortal(
e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
style={{
top: getTextPanelPosition().top,
left: getTextPanelPosition().left,
}}
>
编辑文字
识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。
{isDetectingText && (
正在识别图片文字...
)}
{textEditError && !isDetectingText && (
{textEditError}
)}
{!isDetectingText &&
textEditItems.length === 0 &&
!textEditError && (
未识别到可编辑文字,可点击“重新识别”再试一次。
)}
{textEditItems.map((item, index) => (
文本 {index + 1}
{typeof item.confidence === "number" &&
` · 置信度 ${(item.confidence * 100).toFixed(0)}%`}
原文:{item.originalText}
handleTextItemChange(item.id, e.target.value)
}
placeholder="输入替换后的文字"
value={item.editedText}
/>
handleTextItemColorChange(item.id, value)
}
/>
))}
{deletedTextItems.length > 0 && (
已标记删除 {deletedTextItems.length}{" "}
条文字,应用时会明确告诉生图模型移除这些文字并修复背景。
)}
,
document.body
)}
{/* 图片标注器 */}
{annotatorOpen && data.src && (
setAnnotatorOpen(false)}
onConfirm={handleAnnotateComplete}
onOpenChange={setAnnotatorOpen}
open={annotatorOpen}
/>
)}
{resizeAnnotatorOpen && data.src && (
setResizeAnnotatorOpen(false)}
onConfirm={handleResizeEditorComplete}
onOpenChange={setResizeAnnotatorOpen}
open={resizeAnnotatorOpen}
/>
)}
{/* 双击放大预览弹窗 */}
{fullscreenPreviewOpen &&
data.src &&
createPortal(
setFullscreenPreviewOpen(false)}
>
{/* 关闭按钮 */}
{/* 图片 */}
![{data.prompt]()
e.stopPropagation()}
src={fullscreenPreviewSrc}
style={activeImageAdjustmentsStyle}
/>
{/* 底部提示 */}
点击任意位置关闭
,
document.body
)}
>
);
}