"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) => (
); })} {(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) => (
{reference.name
))}
) : 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", }} >
{aiWashQuickModeLabel}
{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 )} {/* 图片批注对话框 */} 图片批注