diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 5285f4d87..03804bcf1 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -116,14 +116,17 @@ import { Brain, CheckCircle2, Copy, + Crop, Crosshair, Download, Eraser, ExternalLink, + FlipHorizontal2, FolderOpen, Globe2, HardDrive, Plus, + SlidersHorizontal, Edit, Eye, EyeOff, @@ -920,10 +923,13 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record = imageResize: Maximize2, imageTextEdit: Type, imageMultiAngle: Box, + imageAdjust: SlidersHorizontal, + imageFlipRotate: FlipHorizontal2, imageGenerate3dModel: Box, imageGenerate3dWorld: Globe2, imageLayerSplit: Layers, imageExtend: Expand, + imageCrop: Crop, imagePromptRegenerate: RefreshCw, imageNote: StickyNote, imageRename: Edit, diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 95db63ff6..6898f31bf 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -2,28 +2,42 @@ import { AlertCircle, + Blend, Box, + Camera, + CircleDot, Check, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, Crosshair, + Crop, Download, Eraser, Expand, + FlipHorizontal2, + FlipVertical2, Globe2, ImagePlus, Layers, Loader2, + Link2, Maximize2, MessageSquarePlus, MoreHorizontal, MoreVertical, Pencil, RefreshCw, + RotateCcw, Scan, Scissors, + SlidersHorizontal, Sparkles, StickyNote, Trash2, Type, + Unlink2, X, } from "lucide-react"; import type React from "react"; @@ -32,7 +46,6 @@ import { createPortal } from "react-dom"; import { toast } from "sonner"; import useSWR from "swr"; import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog"; -import { AngleEditDialog } from "@/components/angle-edit-dialog"; import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer"; import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; @@ -62,6 +75,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, @@ -142,6 +156,67 @@ interface LayerSplitBounds { coverage: number; } +type AngleEditPanelMode = "subject" | "camera"; +type FlipRotateEditAction = "horizontal" | "vertical" | "reset"; +type ImageAdjustPanelTab = "light" | "color" | "detail" | "effects"; +type CropPanelGroupKey = + | "common" + | "instagram" + | "facebook" + | "tiktok" + | "youtube"; +type CropInteractionHandle = + | "move" + | "n" + | "s" + | "e" + | "w" + | "nw" + | "ne" + | "sw" + | "se"; + +type ImageAdjustmentState = { + light: number; + exposure: number; + contrast: number; + highlights: number; + shadows: number; + whites: number; + blacks: number; + vibrance: number; + saturation: number; + temperature: number; + tint: number; + sharpen: number; + clarity: number; + grain: number; + vignette: number; + softLight: number; + glow: number; +}; + +type ImageAdjustmentKey = keyof ImageAdjustmentState; + +type CropPresetOption = { + id: string; + label: string; + ratio: number; +}; + +type CropPresetGroup = { + key: CropPanelGroupKey; + label: string; + options: CropPresetOption[]; +}; + +type CropInteractionState = { + handle: CropInteractionHandle; + pointerId: number; + startPoint: BoxCutoutPoint; + startRect: NormalizedCropRect; +}; + interface BoxCutoutPoint { x: number; y: number; @@ -150,6 +225,260 @@ interface BoxCutoutPoint { const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08; const BOX_CUTOUT_MIN_EDGE_PX = 24; const FLOATING_TOOLBAR_SCALE = 0.8; +const DEFAULT_CROP_PRESET_ID = "1:1"; +const IMAGE_ADJUST_NOISE_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent( + "" +)}`; +const DEFAULT_IMAGE_ADJUSTMENTS: ImageAdjustmentState = { + light: 0, + exposure: 0, + contrast: 0, + highlights: 0, + shadows: 0, + whites: 0, + blacks: 0, + vibrance: 0, + saturation: 0, + temperature: 0, + tint: 0, + sharpen: 0, + clarity: 0, + grain: 0, + vignette: 0, + softLight: 0, + glow: 0, +}; +const IMAGE_ADJUSTMENT_KEYS = Object.keys( + DEFAULT_IMAGE_ADJUSTMENTS +) as ImageAdjustmentKey[]; +const CROP_PRESET_GROUPS: CropPresetGroup[] = [ + { + key: "common", + label: "通用", + options: [ + { id: "1:1", label: "1:1", ratio: 1 }, + { id: "3:4", label: "3:4", ratio: 3 / 4 }, + { id: "2:3", label: "2:3", ratio: 2 / 3 }, + { id: "9:16", label: "9:16", ratio: 9 / 16 }, + { id: "4:3", label: "4:3", ratio: 4 / 3 }, + { id: "3:2", label: "3:2", ratio: 3 / 2 }, + { id: "16:9", label: "16:9", ratio: 16 / 9 }, + ], + }, + { + key: "instagram", + label: "Instagram", + options: [ + { id: "ig-square", label: "1:1", ratio: 1 }, + { id: "ig-portrait", label: "4:5", ratio: 4 / 5 }, + { id: "ig-story", label: "9:16", ratio: 9 / 16 }, + ], + }, + { + key: "facebook", + label: "Facebook", + options: [ + { id: "fb-feed", label: "4:5", ratio: 4 / 5 }, + { id: "fb-cover", label: "16:9", ratio: 16 / 9 }, + ], + }, + { + key: "tiktok", + label: "TikTok", + options: [{ id: "tt-video", label: "9:16", ratio: 9 / 16 }], + }, + { + key: "youtube", + label: "YouTube", + options: [ + { id: "yt-cover", label: "16:9", ratio: 16 / 9 }, + { id: "yt-short", label: "9:16", ratio: 9 / 16 }, + ], + }, +]; + +function normalizeImageAdjustments(value: unknown): ImageAdjustmentState { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Partial>) + : {}; + + return { + light: clampNumber(Number(raw.light ?? 0), -100, 100), + exposure: clampNumber(Number(raw.exposure ?? 0), -100, 100), + contrast: clampNumber(Number(raw.contrast ?? 0), -100, 100), + highlights: clampNumber(Number(raw.highlights ?? 0), -100, 100), + shadows: clampNumber(Number(raw.shadows ?? 0), -100, 100), + whites: clampNumber(Number(raw.whites ?? 0), -100, 100), + blacks: clampNumber(Number(raw.blacks ?? 0), -100, 100), + vibrance: clampNumber(Number(raw.vibrance ?? 0), -100, 100), + saturation: clampNumber(Number(raw.saturation ?? 0), -100, 100), + temperature: clampNumber(Number(raw.temperature ?? 0), -100, 100), + tint: clampNumber(Number(raw.tint ?? 0), -100, 100), + sharpen: clampNumber(Number(raw.sharpen ?? 0), 0, 100), + clarity: clampNumber(Number(raw.clarity ?? 0), 0, 100), + grain: clampNumber(Number(raw.grain ?? 0), 0, 100), + vignette: clampNumber(Number(raw.vignette ?? 0), 0, 100), + softLight: clampNumber(Number(raw.softLight ?? 0), 0, 100), + glow: clampNumber(Number(raw.glow ?? 0), 0, 100), + }; +} + +function areImageAdjustmentsEqual( + left: ImageAdjustmentState, + right: ImageAdjustmentState +) { + return IMAGE_ADJUSTMENT_KEYS.every((key) => left[key] === right[key]); +} + +function isDefaultImageAdjustments(value: ImageAdjustmentState) { + return areImageAdjustmentsEqual(value, DEFAULT_IMAGE_ADJUSTMENTS); +} + +function buildImageAdjustmentFilterStyle( + adjustments: ImageAdjustmentState +): React.CSSProperties { + const brightnessLift = + adjustments.light * 0.34 + + adjustments.exposure * 0.52 + + adjustments.shadows * 0.2 + + adjustments.whites * 0.12 - + adjustments.blacks * 0.16 - + adjustments.highlights * 0.06; + const brightness = clampNumber(1 + brightnessLift / 100, 0.35, 2.2); + const contrastBoost = + adjustments.contrast * 0.78 + + adjustments.clarity * 0.18 + + adjustments.sharpen * 0.1 + + adjustments.highlights * 0.06 - + adjustments.shadows * 0.04; + const contrast = clampNumber(1 + contrastBoost / 100, 0.45, 2.1); + const saturationBoost = + adjustments.saturation * 0.9 + + adjustments.vibrance * 0.55 + + adjustments.sharpen * 0.04; + const saturate = clampNumber(1 + saturationBoost / 100, 0.1, 2.4); + const hueRotate = clampNumber(adjustments.tint * 0.35, -36, 36); + const sepia = clampNumber( + adjustments.temperature > 0 ? adjustments.temperature * 0.18 : 0, + 0, + 22 + ); + + return { + filter: [ + `brightness(${brightness.toFixed(3)})`, + `contrast(${contrast.toFixed(3)})`, + `saturate(${saturate.toFixed(3)})`, + `hue-rotate(${hueRotate.toFixed(1)}deg)`, + `sepia(${sepia.toFixed(1)}%)`, + ].join(" "), + willChange: "filter", + }; +} + +function ImageAdjustPanelSlider({ + disabled = false, + label, + max, + min, + onChange, + trackClassName = "h-[4px] bg-[#e5e5e5]", + value, +}: { + disabled?: boolean; + label: string; + max: number; + min: number; + onChange: (value: number) => void; + trackClassName?: string; + value: number; +}) { + return ( +
+
+ + {label} + + + {Math.round(value)} + +
+ onChange(nextValue ?? value)} + rangeClassName="bg-transparent" + thumbClassName="h-5 w-5 border-[5px] border-[#2d2f33] bg-white shadow-[0_1px_3px_rgba(0,0,0,0.18)]" + trackClassName={trackClassName} + value={[value]} + /> +
+ ); +} + +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-[#2f3133]" + step={step} + thumbClassName="h-5 w-5 border-[5px] border-[#2f3133] bg-white shadow-[0_1px_3px_rgba(0,0,0,0.18)]" + trackClassName="h-[4px] bg-[#e6e6e6]" + value={[value]} + /> +
+ ); +} const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); @@ -171,6 +500,232 @@ const createNormalizedRect = ( }; }; +const getCropPresetById = (presetId: string) => { + for (const group of CROP_PRESET_GROUPS) { + const matched = group.options.find((option) => option.id === presetId); + if (matched) return matched; + } + return null; +}; + +const getNormalizedMinCropSize = ( + naturalWidth: number, + naturalHeight: number +) => ({ + width: naturalWidth > 0 ? Math.min(1, BOX_CUTOUT_MIN_EDGE_PX / naturalWidth) : 0.02, + height: + naturalHeight > 0 ? Math.min(1, BOX_CUTOUT_MIN_EDGE_PX / naturalHeight) : 0.02, +}); + +const createCenteredCropRectForAspect = ( + ratio: number, + centerX = 0.5, + centerY = 0.5 +): NormalizedCropRect => { + const safeRatio = Number.isFinite(ratio) && ratio > 0 ? ratio : 1; + const maxHalfWidth = Math.min(centerX, 1 - centerX); + const maxHalfHeight = Math.min(centerY, 1 - centerY); + + let width = maxHalfHeight * 2 * safeRatio; + let height = width / safeRatio; + + if (width > maxHalfWidth * 2) { + width = maxHalfWidth * 2; + height = width / safeRatio; + } + + if (height > maxHalfHeight * 2) { + height = maxHalfHeight * 2; + width = height * safeRatio; + } + + return { + x: clampNumber(centerX - width / 2, 0, 1 - width), + y: clampNumber(centerY - height / 2, 0, 1 - height), + width: clampNumber(width, 0, 1), + height: clampNumber(height, 0, 1), + }; +}; + +const createCenteredCropRectFromSize = ( + centerX: number, + centerY: number, + width: number, + height: number +): NormalizedCropRect => { + const safeWidth = clampNumber(width, 0.0001, 1); + const safeHeight = clampNumber(height, 0.0001, 1); + let x = centerX - safeWidth / 2; + let y = centerY - safeHeight / 2; + + x = clampNumber(x, 0, 1 - safeWidth); + y = clampNumber(y, 0, 1 - safeHeight); + + return { + x, + y, + width: safeWidth, + height: safeHeight, + }; +}; + +const updateCropRectWithPointer = ({ + aspectRatio, + handle, + minHeight, + minWidth, + pointer, + startPoint, + startRect, +}: { + aspectRatio: number | null; + handle: CropInteractionHandle; + minHeight: number; + minWidth: number; + pointer: BoxCutoutPoint; + startPoint: BoxCutoutPoint; + startRect: NormalizedCropRect; +}): NormalizedCropRect => { + const startRight = startRect.x + startRect.width; + const startBottom = startRect.y + startRect.height; + const currentAspect = + aspectRatio && Number.isFinite(aspectRatio) && aspectRatio > 0 + ? aspectRatio + : null; + + if (handle === "move") { + const nextX = clampNumber( + startRect.x + (pointer.x - startPoint.x), + 0, + 1 - startRect.width + ); + const nextY = clampNumber( + startRect.y + (pointer.y - startPoint.y), + 0, + 1 - startRect.height + ); + return { + x: nextX, + y: nextY, + width: startRect.width, + height: startRect.height, + }; + } + + if (!currentAspect) { + let x = startRect.x; + let y = startRect.y; + let width = startRect.width; + let height = startRect.height; + + if (handle.includes("w")) { + x = clampNumber(pointer.x, 0, startRight - minWidth); + width = startRight - x; + } + if (handle.includes("e")) { + width = clampNumber(pointer.x - startRect.x, minWidth, 1 - startRect.x); + } + if (handle.includes("n")) { + y = clampNumber(pointer.y, 0, startBottom - minHeight); + height = startBottom - y; + } + if (handle.includes("s")) { + height = clampNumber(pointer.y - startRect.y, minHeight, 1 - startRect.y); + } + + return { + x, + y, + width, + height, + }; + } + + if (handle === "n" || handle === "s") { + const anchorY = handle === "n" ? startBottom : startRect.y; + let height = Math.abs(anchorY - pointer.y); + height = clampNumber( + height, + minHeight, + handle === "n" ? anchorY : 1 - anchorY + ); + let width = height * currentAspect; + const centerX = startRect.x + startRect.width / 2; + const maxWidth = Math.min(centerX, 1 - centerX) * 2; + if (width > maxWidth) { + width = maxWidth; + height = width / currentAspect; + } + return { + x: clampNumber(centerX - width / 2, 0, 1 - width), + y: handle === "n" ? anchorY - height : anchorY, + width, + height, + }; + } + + if (handle === "w" || handle === "e") { + const anchorX = handle === "w" ? startRight : startRect.x; + let width = Math.abs(anchorX - pointer.x); + width = clampNumber(width, minWidth, handle === "w" ? anchorX : 1 - anchorX); + let height = width / currentAspect; + const centerY = startRect.y + startRect.height / 2; + const maxHeight = Math.min(centerY, 1 - centerY) * 2; + if (height > maxHeight) { + height = maxHeight; + width = height * currentAspect; + } + return { + x: handle === "w" ? anchorX - width : anchorX, + y: clampNumber(centerY - height / 2, 0, 1 - height), + width, + height, + }; + } + + const isWest = handle.includes("w"); + const isNorth = handle.includes("n"); + const anchorX = isWest ? startRight : startRect.x; + const anchorY = isNorth ? startBottom : startRect.y; + const maxWidth = isWest ? anchorX : 1 - anchorX; + const maxHeight = isNorth ? anchorY : 1 - anchorY; + + let width = Math.abs(anchorX - pointer.x); + let height = width / currentAspect; + + if (height > maxHeight) { + height = maxHeight; + width = height * currentAspect; + } + if (width > maxWidth) { + width = maxWidth; + height = width / currentAspect; + } + if (width < minWidth) { + width = minWidth; + height = width / currentAspect; + } + if (height < minHeight) { + height = minHeight; + width = height * currentAspect; + } + if (height > maxHeight) { + height = maxHeight; + width = height * currentAspect; + } + if (width > maxWidth) { + width = maxWidth; + height = width / currentAspect; + } + + return { + x: isWest ? anchorX - width : anchorX, + y: isNorth ? anchorY - height : anchorY, + width, + height, + }; +}; + const normalizeTextEditItems = ( items: ImageTextEditItem[] | undefined ): ImageTextEditItem[] => { @@ -300,6 +855,189 @@ async function resolveChatAttachmentFromImageSrc(src: string, displayName: strin 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); + } +} + function inferModelFormat(modelUrl?: string | null) { const value = String(modelUrl || "").toLowerCase(); if (!value) return "glb"; @@ -509,6 +1247,8 @@ interface ImageNodeProps { isLayerSplitEnabled?: boolean; batchProcessingEnabled?: boolean; canvasToolbarConfig?: CanvasToolbarConfig; + tripo3dEnabled?: boolean; + worldlabs3dEnabled?: boolean; } export function ImageNode({ @@ -517,6 +1257,8 @@ export function ImageNode({ isLayerSplitEnabled = false, batchProcessingEnabled = true, canvasToolbarConfig, + tripo3dEnabled = false, + worldlabs3dEnabled = false, }: ImageNodeProps) { const data = node.data as ImageNodeData; const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore"); @@ -533,6 +1275,7 @@ export function ImageNode({ "imageRemoveWatermark" ); const imageResizeItem = getCanvasToolbarItem(canvasToolbarConfig, "imageResize"); + const imageCropItem = getCanvasToolbarItem(canvasToolbarConfig, "imageCrop"); const imageTextEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageTextEdit"); const imageMultiAngleItem = getCanvasToolbarItem(canvasToolbarConfig, "imageMultiAngle"); const imageGenerate3dModelItem = getCanvasToolbarItem( @@ -545,6 +1288,11 @@ export function ImageNode({ ); 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" @@ -589,6 +1337,21 @@ export function ImageNode({ typeof data.metadata?.detailProgressLabel === "string" ? data.metadata.detailProgressLabel.trim() : ""; + 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; @@ -596,25 +1359,51 @@ export function ImageNode({ }>("/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(() => { - const enabledEntries = publicModelAliasConfig.tripoModels.filter( + return publicModelAliasConfig.tripoModels.filter( (entry) => entry.enabled !== false ); - return enabledEntries.length > 0 - ? enabledEntries - : publicModelAliasConfig.tripoModels; }, [publicModelAliasConfig.tripoModels]); const worldModelOptions = useMemo(() => { - const enabledEntries = publicModelAliasConfig.worldModels.filter( + return publicModelAliasConfig.worldModels.filter( (entry) => entry.enabled !== false ); - return enabledEntries.length > 0 - ? enabledEntries - : publicModelAliasConfig.worldModels; }, [publicModelAliasConfig.worldModels]); const [isHovered, setIsHovered] = useState(false); const [isProcessing, setIsProcessing] = useState(false); - const [angleDialogOpen, setAngleDialogOpen] = 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 [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false); const [resizeAnnotatorOpen, setResizeAnnotatorOpen] = useState(false); const [promptDialogOpen, setPromptDialogOpen] = useState(false); @@ -663,12 +1452,111 @@ export function ImageNode({ useState(null); const [boxCutoutDragEnd, setBoxCutoutDragEnd] = useState(null); + const activeImageAdjustments = imageAdjustPanelOpen + ? imageAdjustmentsDraft + : storedImageAdjustments; + const activeImageAdjustmentsStyle = useMemo( + () => buildImageAdjustmentFilterStyle(activeImageAdjustments), + [activeImageAdjustments] + ); + const hasActiveImageAdjustments = !isDefaultImageAdjustments( + activeImageAdjustments + ); useEffect(() => { if (!batchProcessingEnabled) { setSceneFusionDialogOpen(false); } }, [batchProcessingEnabled]); + useEffect(() => { + if (!imageAdjustPanelOpen) return; + setImageAdjustmentsDraft(storedImageAdjustments); + }, [imageAdjustPanelOpen, storedImageAdjustments, data.src]); + useEffect(() => { + if (!cropPanelOpen) return; + if (cropRect) return; + const centerX = cropRect ? cropRect.x + cropRect.width / 2 : 0.5; + const centerY = cropRect ? cropRect.y + cropRect.height / 2 : 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(() => { const nextDefaultTripoModelId = getDefaultThreeDModelAliasId(publicModelAliasConfig.tripoModels) || @@ -703,6 +1591,7 @@ export function ImageNode({ const containerRef = useRef(null); const displayImageRef = useRef(null); const compareFrameRef = useRef(null); + const cropOverlayRef = useRef(null); const toolbarRef = useRef(null); const boxCutoutToolbarRef = useRef(null); @@ -909,11 +1798,15 @@ export function ImageNode({ isSelected || isCompareMode || boxCutoutMode || + cropPanelOpen || isThisNodePicking || fullscreenPreviewOpen || annotatorOpen || resizeAnnotatorOpen || - textEditPanelOpen; + textEditPanelOpen || + imageAdjustPanelOpen || + flipRotatePanelOpen || + anglePanelOpen; const [loadedResolution, setLoadedResolution] = useState<{ width: number; height: number; @@ -933,6 +1826,20 @@ export function ImageNode({ if (!source) return ""; return `${source.width} x ${source.height}`; }, [loadedResolution, metadataResolution]); + 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; @@ -966,6 +1873,38 @@ export function ImageNode({ 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); @@ -1119,6 +2058,12 @@ export function ImageNode({ useEffect(() => { if (!isSelected) { + setCropPanelOpen(false); + setCropRect(null); + setCropInteraction(null); + setImageAdjustPanelOpen(false); + setFlipRotatePanelOpen(false); + setAnglePanelOpen(false); setTextEditPanelOpen(false); setIsEditingTitle(false); setPromptDialogOpen(false); @@ -1139,12 +2084,15 @@ export function ImageNode({ sceneFusionDialogOpen || promptDialogOpen || notePanelOpen) && - boxCutoutMode + (boxCutoutMode || cropPanelOpen) ) { resetBoxCutoutState(); + setCropPanelOpen(false); + setCropInteraction(null); } }, [ boxCutoutMode, + cropPanelOpen, isCompareMode, isThisNodePicking, notePanelOpen, @@ -1168,6 +2116,22 @@ export function ImageNode({ window.removeEventListener("keydown", handleKeyDown); }; }, [boxCutoutMode, resetBoxCutoutState]); + 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); @@ -2211,6 +3175,81 @@ export function ImageNode({ 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; + const panelHeight = 760; + 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 = 500; + const panelHeight = 980; + 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 }; + } + + const rect = containerRef.current.getBoundingClientRect(); + const panelWidth = 640; + const panelHeight = 84; + 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 }; @@ -2230,6 +3269,244 @@ export function ImageNode({ return { top, left }; }; + const getAnglePanelPosition = () => { + if (!containerRef.current || typeof window === "undefined") { + return { top: 0, left: 0 }; + } + + const rect = containerRef.current.getBoundingClientRect(); + const panelWidth = 380; + const panelHeight = 760; + 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 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); + 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); + 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, { @@ -2243,7 +3520,277 @@ export function ImageNode({ 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]); @@ -2339,6 +3886,12 @@ export function ImageNode({ ); const handleOpenTextEdit = useCallback(async () => { + setCropPanelOpen(false); + setCropInteraction(null); + setImageAdjustPanelOpen(false); + setFlipRotatePanelOpen(false); + setAnglePanelOpen(false); + setNotePanelOpen(false); setTextEditPanelOpen(true); setTextEditError(null); @@ -2605,6 +4158,9 @@ export function ImageNode({ watermark: "去印", enhance: "增强", extend: "扩图", + crop: "裁剪", + adjust: "调整", + "flip-rotate": "翻转与旋转", resize: "调尺", angle: "视角", "layer-split": "图层拆分", @@ -2630,38 +4186,54 @@ export function ImageNode({ } if (mode === "model") { + if (!tripo3dEnabled) { + toast.error("Tripo3D 当前未开启,请先在系统设置中启用"); + return; + } if (tripoModelOptions.length === 0) { toast.error("当前没有可用的 3D 模型"); return; } - } else if (worldModelOptions.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, tripoModelOptions.length, worldModelOptions.length] + [ + data.src, + tripo3dEnabled, + tripoModelOptions.length, + worldModelOptions.length, + worldlabs3dEnabled, + ] ); async function handleConfirmThreedGeneration() { if (threedGenerationDialogMode === "model") { - const targetModelVersion = - selectedTripoModelEntry?.id || - selectedTripoModelVersion || - DEFAULT_CANVAS_TRIPO_MODEL || - "tripo-standard"; + const targetModelVersion = selectedTripoModelEntry?.id || ""; + if (!targetModelVersion) { + toast.error("当前没有可用的 3D 模型"); + return; + } setThreedGenerationDialogMode(null); await handleGenerateCanvas3DModel(targetModelVersion); return; } if (threedGenerationDialogMode === "world") { - const targetModelVersion = - selectedWorldModelEntry?.id || - selectedWorldModelVersion || - DEFAULT_CANVAS_WORLD_MODEL || - "world-standard"; + const targetModelVersion = selectedWorldModelEntry?.id || ""; + if (!targetModelVersion) { + toast.error("当前没有可用的 3D 世界模型"); + return; + } setThreedGenerationDialogMode(null); await handleGenerateCanvas3DWorld(targetModelVersion); } @@ -2679,11 +4251,41 @@ export function ImageNode({ } // 对话框类型的操作 + 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") { - setAngleDialogOpen(true); + setCropPanelOpen(false); + setCropInteraction(null); + setImageAdjustPanelOpen(false); + setFlipRotatePanelOpen(false); + setTextEditPanelOpen(false); + setNotePanelOpen(false); + resetBoxCutoutState(); + setAnglePanelOpen(true); return; } if (action === "scene-fusion") { + setCropPanelOpen(false); + setCropInteraction(null); + setImageAdjustPanelOpen(false); + setFlipRotatePanelOpen(false); if (!batchProcessingEnabled) { toast.info("批量处理功能未开启"); return; @@ -2692,10 +4294,18 @@ export function ImageNode({ 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; @@ -2729,10 +4339,18 @@ export function ImageNode({ return; } if (action === "box-cutout") { + setCropPanelOpen(false); + setCropInteraction(null); + setImageAdjustPanelOpen(false); + setFlipRotatePanelOpen(false); handleEnterBoxCutoutMode(); return; } if (action === "annotate") { + setCropPanelOpen(false); + setCropInteraction(null); + setImageAdjustPanelOpen(false); + setFlipRotatePanelOpen(false); setAnnotatorOpen(true); return; } @@ -2745,21 +4363,39 @@ export function ImageNode({ 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; } const actionName = getActionName(action); + setImageAdjustPanelOpen(false); + setCropPanelOpen(false); + setCropInteraction(null); + setFlipRotatePanelOpen(false); + setAnglePanelOpen(false); setIsProcessing(true); // 先创建一个"处理中"状态的节点,显示在原图右侧 const newNodeId = addNode({ @@ -2877,27 +4513,6 @@ export function ImageNode({ }; }, [handleAction, node.id]); - // 角度编辑完成回调 - const handleAngleEditComplete = (resultUrl: string) => { - // 创建新的图片节点显示结果 - 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: resultUrl, - status: "completed", - prompt: "角度编辑结果", - title: `${displayTitle}-角度编辑`, - originalSrc: data.src, - }, - connections: [node.id], - }); - }; - const handleSceneFusionBatchAddToCanvas = useCallback( (imageUrls: string[]) => { const validUrls = imageUrls.filter( @@ -3240,6 +4855,62 @@ export function ImageNode({ 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 = @@ -3773,7 +5444,7 @@ export function ImageNode({ modelVersion, faceLimit: 120000, texture: true, - pbr: true, + pbr: modelFeatureVisibility.pbr !== false, autoSize: false, quad: false, generateParts: false, @@ -4170,8 +5841,203 @@ export function ImageNode({ } }; + 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(() => { + if (!hasActiveImageAdjustments) return null; + + const temperatureOpacity = Math.min( + 0.38, + Math.abs(activeImageAdjustments.temperature) / 260 + ); + const tintOpacity = Math.min( + 0.28, + Math.abs(activeImageAdjustments.tint) / 300 + ); + const grainOpacity = Math.min(0.28, activeImageAdjustments.grain / 340); + const vignetteOpacity = Math.min( + 0.42, + activeImageAdjustments.vignette / 180 + ); + const softLightOpacity = Math.min( + 0.24, + activeImageAdjustments.softLight / 320 + ); + const glowOpacity = Math.min(0.22, activeImageAdjustments.glow / 280); + + return ( + <> + {temperatureOpacity > 0 ? ( +
= 0 + ? "linear-gradient(135deg, rgba(255,188,109,0.9), rgba(255,232,194,0.42))" + : "linear-gradient(135deg, rgba(109,180,255,0.86), rgba(219,240,255,0.36))", + mixBlendMode: "soft-light", + opacity: temperatureOpacity, + }} + /> + ) : null} + {tintOpacity > 0 ? ( +
= 0 + ? "linear-gradient(135deg, rgba(244,105,214,0.78), rgba(255,203,236,0.28))" + : "linear-gradient(135deg, rgba(76,214,141,0.76), rgba(194,255,221,0.28))", + mixBlendMode: "overlay", + opacity: tintOpacity, + }} + /> + ) : null} + {softLightOpacity > 0 ? ( +
+ ) : null} + {glowOpacity > 0 ? ( +
+ ) : null} + {vignetteOpacity > 0 ? ( +
+ ) : null} + {grainOpacity > 0 ? ( +
+ ) : null} + + ); + }, [ + activeImageAdjustments, + hasActiveImageAdjustments, + imageAdjustOverlayStyle, + ]); + + const cropPanelPosition = getCropPanelPosition(); + const imageAdjustPanelPosition = getImageAdjustPanelPosition(); const toolbarPosition = getToolbarPosition(); const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition(); + const flipRotatePanelPosition = getFlipRotatePanelPosition(); + const anglePanelPosition = getAnglePanelPosition(); + const imageAdjustTabOptions = [ + { + id: "light" as const, + label: "光线", + icon: SlidersHorizontal, + }, + { + id: "color" as const, + label: "颜色", + icon: Blend, + }, + { + id: "detail" as const, + label: "细节", + icon: CircleDot, + }, + { + id: "effects" as const, + label: "效果", + icon: Box, + }, + ]; + const imageAdjustSections: Record< + ImageAdjustPanelTab, + Array<{ + key: ImageAdjustmentKey; + label: string; + min: number; + max: number; + trackClassName?: string; + }> + > = { + light: [ + { key: "light", label: "光线", min: -100, max: 100 }, + { key: "exposure", label: "曝光", min: -100, max: 100 }, + { key: "contrast", label: "对比度", min: -100, max: 100 }, + { key: "highlights", label: "高光", min: -100, max: 100 }, + { key: "shadows", label: "阴影", min: -100, max: 100 }, + { key: "whites", label: "白色", min: -100, max: 100 }, + { key: "blacks", label: "黑色", min: -100, max: 100 }, + ], + color: [ + { key: "vibrance", label: "自然饱和度", min: -100, max: 100 }, + { key: "saturation", label: "饱和度", min: -100, max: 100 }, + { + key: "temperature", + label: "色温", + min: -100, + max: 100, + trackClassName: "h-[4px] bg-[linear-gradient(90deg,#4aa7ff,#f2c23a)]", + }, + { + key: "tint", + label: "色调", + min: -100, + max: 100, + trackClassName: "h-[4px] bg-[linear-gradient(90deg,#ff66d7,#41d278)]", + }, + ], + detail: [ + { key: "sharpen", label: "锐化", min: 0, max: 100 }, + { key: "clarity", label: "清晰度", min: 0, max: 100 }, + { key: "grain", label: "颗粒", min: 0, max: 100 }, + ], + effects: [ + { key: "vignette", label: "晕影", min: 0, max: 100 }, + { key: "softLight", label: "柔光", min: 0, max: 100 }, + { key: "glow", label: "辉光", min: 0, max: 100 }, + ], + }; const boxCutoutHintText = boxCutoutSelection ? "确认后将仅抠出框选区域" : isBoxCutoutDrawing @@ -4281,13 +6147,15 @@ export function ImageNode({ decoding="async" draggable={false} loading="lazy" - onError={(event) => { - if (event.currentTarget.src !== data.src) { - event.currentTarget.src = data.src; - } - }} - src={displayImageSrc} - /> + onError={(event) => { + if (event.currentTarget.src !== data.src) { + event.currentTarget.src = data.src; + } + }} + style={activeImageAdjustmentsStyle} + src={displayImageSrc} + /> + {renderImageAdjustmentOverlays()}
+ {renderImageAdjustmentOverlays()} {shouldRenderDetailOverlay ? ( detailUiDslDocument ? ( )} + {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 && ( <>
{imageExtendItem.label} + handleAction("crop")}> + + {imageCropItem.label} + + handleAction("adjust")}> + + {imageAdjustItem.label} + + handleAction("flip-rotate")}> + + {imageFlipRotateItem.label} + { setDraftTitle(displayTitle); @@ -4733,16 +6732,6 @@ export function ImageNode({ ) : null}
- - {/* 角度编辑对话框 */} - {data.src && ( - - )} @@ -5088,35 +7078,39 @@ export function ImageNode({ {imageMultiAngleItem.label} - + {tripo3dEnabled ? ( + + ) : null} - + {worldlabs3dEnabled ? ( + + ) : null} {isLayerSplitEnabled ? ( + +
+
+ H + + handleChangeCropDimension("height", event.target.value) + } + value={cropPixelSize.height ? String(cropPixelSize.height) : ""} + /> +
+
+
+ +
+
预设
+
+ {CROP_PRESET_GROUPS.map((group) => { + const expanded = cropExpandedGroups[group.key]; + return ( +
+ + + {expanded ? ( +
+ {group.options.map((option) => { + const selected = cropPresetId === option.id; + return ( + + ); + })} +
+ ) : null} +
+ ); + })} +
+
+
+ +
+ + +
+
, + document.body + )} + + {isSelected && + imageAdjustPanelOpen && + data.src && + typeof document !== "undefined" && + createPortal( +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + style={{ + top: imageAdjustPanelPosition.top, + left: imageAdjustPanelPosition.left, + }} + > +
+

+ {imageAdjustItem.label} +

+
+ + +
+
+ +
+
+
+ {imageAdjustTabOptions.map((item) => { + const active = imageAdjustTab === item.id; + const Icon = item.icon; + return ( + + ); + })} +
+
+ +
+
+ {imageAdjustSections[imageAdjustTab].map((field) => ( + + setImageAdjustmentsDraft((prev) => ({ + ...prev, + [field.key]: value, + })) + } + trackClassName={field.trackClassName} + value={imageAdjustmentsDraft[field.key]} + /> + ))} +
+
+
+
, + document.body + )} + + {isSelected && + flipRotatePanelOpen && + data.src && + typeof document !== "undefined" && + createPortal( + +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + style={{ + top: flipRotatePanelPosition.top, + left: flipRotatePanelPosition.left, + maxWidth: `${flipRotatePanelPosition.maxWidth}px`, + }} + > +
+ + + {imageFlipRotateItem.label} + +
+ +
+ +
+ + + 0° + +
+ +
+ +
+ + + + + 恢复原图 + + + + + + + 水平翻转 + + + + + + + 垂直翻转 + + + + + + + 关闭 + +
+
+ , + document.body + )} + + {isSelected && + anglePanelOpen && + data.src && + typeof document !== "undefined" && + createPortal( +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + style={{ + top: anglePanelPosition.top, + left: anglePanelPosition.left, + }} + > +
+

+ 多视角 +

+ +
+ +
+
+ {( + [ + { id: "subject", label: "主体" }, + { id: "camera", label: "摄像头" }, + ] as const + ).map((item) => { + const active = anglePanelMode === item.id; + return ( + + ); + })} +
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + + + + + +
+ {displayTitle} +
+ +
+ {anglePanelMode === "subject" ? ( + + ) : ( + + )} +
+
+
+ +
+ + + +
+ +
+ + +
+
, + document.body + )} + {/* 文字编辑面板(显示在图片节点旁边) */} {isSelected && textEditPanelOpen && @@ -5521,6 +8100,7 @@ export function ImageNode({ draggable={false} onClick={(e) => e.stopPropagation()} src={fullscreenPreviewSrc} + style={activeImageAdjustmentsStyle} /> {/* 底部提示 */} diff --git a/lib/canvas-toolbar-config.ts b/lib/canvas-toolbar-config.ts index 4e482a654..2804ce298 100644 --- a/lib/canvas-toolbar-config.ts +++ b/lib/canvas-toolbar-config.ts @@ -247,6 +247,24 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [ label: "扩图", description: "扩展图片画幅", }, + { + key: "imageCrop", + group: "节点工具栏", + label: "裁剪", + description: "打开图片裁剪面板", + }, + { + key: "imageAdjust", + group: "节点工具栏", + label: "调整", + description: "打开图片调整面板", + }, + { + key: "imageFlipRotate", + group: "节点工具栏", + label: "翻转与旋转", + description: "打开图片翻转与旋转工具条", + }, { key: "imagePromptRegenerate", group: "节点工具栏", diff --git a/types/canvas/index.ts b/types/canvas/index.ts index e289381b3..e50c3faa0 100644 --- a/types/canvas/index.ts +++ b/types/canvas/index.ts @@ -266,7 +266,21 @@ export interface Attachment { // 图片编辑操作 export interface ImageEditAction { - type: 'rmbg' | 'enhance' | 'enhance-pro' | 'extend' | 'resize' | 'angle-edit' | 'scene-fusion' | 'annotate' | 'text-edit' | 'box-cutout' | 'layer-split'; + type: + | 'rmbg' + | 'enhance' + | 'enhance-pro' + | 'extend' + | 'crop' + | 'adjust' + | 'flip-rotate' + | 'resize' + | 'angle-edit' + | 'scene-fusion' + | 'annotate' + | 'text-edit' + | 'box-cutout' + | 'layer-split'; timestamp: Date; params?: Record; resultUrl?: string;