diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 88477d4a8..5b615d82e 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -1,10 +1,8 @@ "use client"; import { - Blend, Box, Camera, - CircleDot, Check, ChevronDown, ChevronLeft, @@ -50,6 +48,16 @@ import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecution import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer"; import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; +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, @@ -186,7 +194,6 @@ const AI_CLOUD_IMAGE_ACTIONS = new Set([ type AngleEditPanelMode = "subject" | "camera"; type FlipRotateEditAction = "horizontal" | "vertical" | "reset"; -type ImageAdjustPanelTab = "light" | "color" | "detail" | "effects"; type CropPanelGroupKey = | "common" | "instagram" @@ -204,28 +211,6 @@ type CropInteractionHandle = | "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; @@ -330,28 +315,6 @@ const ANGLE_PREVIEW_HORIZONTAL_RING_ROTATIONS = [ ]; const ANGLE_PREVIEW_DIAGONAL_RING_ROTATIONS = [-62, -40, -18, 8, 34, 58]; const DEFAULT_CROP_PRESET_ID = "1:1"; -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", @@ -398,128 +361,6 @@ const CROP_PRESET_GROUPS: CropPresetGroup[] = [ }, ]; -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; @@ -7600,76 +7441,6 @@ export function ImageNode({ 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 @@ -9506,97 +9277,20 @@ export function ImageNode({ 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, - transform: `scale(${IMAGE_EDIT_PANEL_SCALE})`, - transformOrigin: "top 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 - )} + setImageAdjustPanelOpen(false)} + onDraftChange={setImageAdjustmentsDraft} + onReset={handleResetImageAdjustments} + onTabChange={setImageAdjustTab} + open={imageAdjustPanelOpen} + position={imageAdjustPanelPosition} + scale={IMAGE_EDIT_PANEL_SCALE} + tab={imageAdjustTab} + /> {isSelected && flipRotatePanelOpen && diff --git a/components/canvas/nodes/image-node/image-node-adjustments.tsx b/components/canvas/nodes/image-node/image-node-adjustments.tsx new file mode 100644 index 000000000..9034beb4e --- /dev/null +++ b/components/canvas/nodes/image-node/image-node-adjustments.tsx @@ -0,0 +1,379 @@ +import type React from "react"; +import { createPortal } from "react-dom"; +import { + Blend, + Box, + CircleDot, + SlidersHorizontal, + Sparkles, + X, +} from "lucide-react"; +import { Slider } from "@/components/ui/slider"; +import { cn } from "@/lib/utils"; + +export type ImageAdjustPanelTab = "light" | "color" | "detail" | "effects"; + +export 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; +}; + +export type ImageAdjustmentKey = keyof ImageAdjustmentState; + +export 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, +}; + +export const IMAGE_ADJUSTMENT_KEYS = Object.keys( + DEFAULT_IMAGE_ADJUSTMENTS +) as ImageAdjustmentKey[]; + +export const IMAGE_ADJUST_SECTIONS: 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 IMAGE_ADJUST_TAB_OPTIONS = [ + { + 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 clampImageAdjustmentNumber = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, Number.isFinite(value) ? value : min)); + +export function normalizeImageAdjustments(value: unknown): ImageAdjustmentState { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Partial>) + : {}; + + return { + light: clampImageAdjustmentNumber(Number(raw.light ?? 0), -100, 100), + exposure: clampImageAdjustmentNumber(Number(raw.exposure ?? 0), -100, 100), + contrast: clampImageAdjustmentNumber(Number(raw.contrast ?? 0), -100, 100), + highlights: clampImageAdjustmentNumber(Number(raw.highlights ?? 0), -100, 100), + shadows: clampImageAdjustmentNumber(Number(raw.shadows ?? 0), -100, 100), + whites: clampImageAdjustmentNumber(Number(raw.whites ?? 0), -100, 100), + blacks: clampImageAdjustmentNumber(Number(raw.blacks ?? 0), -100, 100), + vibrance: clampImageAdjustmentNumber(Number(raw.vibrance ?? 0), -100, 100), + saturation: clampImageAdjustmentNumber(Number(raw.saturation ?? 0), -100, 100), + temperature: clampImageAdjustmentNumber(Number(raw.temperature ?? 0), -100, 100), + tint: clampImageAdjustmentNumber(Number(raw.tint ?? 0), -100, 100), + sharpen: clampImageAdjustmentNumber(Number(raw.sharpen ?? 0), 0, 100), + clarity: clampImageAdjustmentNumber(Number(raw.clarity ?? 0), 0, 100), + grain: clampImageAdjustmentNumber(Number(raw.grain ?? 0), 0, 100), + vignette: clampImageAdjustmentNumber(Number(raw.vignette ?? 0), 0, 100), + softLight: clampImageAdjustmentNumber(Number(raw.softLight ?? 0), 0, 100), + glow: clampImageAdjustmentNumber(Number(raw.glow ?? 0), 0, 100), + }; +} + +export function areImageAdjustmentsEqual( + left: ImageAdjustmentState, + right: ImageAdjustmentState +) { + return IMAGE_ADJUSTMENT_KEYS.every((key) => left[key] === right[key]); +} + +export function isDefaultImageAdjustments(value: ImageAdjustmentState) { + return areImageAdjustmentsEqual(value, DEFAULT_IMAGE_ADJUSTMENTS); +} + +export 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 = clampImageAdjustmentNumber(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 = clampImageAdjustmentNumber(1 + contrastBoost / 100, 0.45, 2.1); + const saturationBoost = + adjustments.saturation * 0.9 + + adjustments.vibrance * 0.55 + + adjustments.sharpen * 0.04; + const saturate = clampImageAdjustmentNumber(1 + saturationBoost / 100, 0.1, 2.4); + const hueRotate = clampImageAdjustmentNumber(adjustments.tint * 0.35, -36, 36); + const sepia = clampImageAdjustmentNumber( + 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", + }; +} + +export 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]} + /> +
+ ); +} + +export function ImageAdjustFloatingPanel({ + disabled = false, + draft, + hasImageSource, + isSelected, + label, + onClose, + onDraftChange, + onReset, + onTabChange, + open, + position, + scale, + tab, +}: { + disabled?: boolean; + draft: ImageAdjustmentState; + hasImageSource: boolean; + isSelected: boolean; + label: string; + onClose: () => void; + onDraftChange: (updater: (previous: ImageAdjustmentState) => ImageAdjustmentState) => void; + onReset: () => void; + onTabChange: (tab: ImageAdjustPanelTab) => void; + open: boolean; + position: { left: number; top: number }; + scale: number; + tab: ImageAdjustPanelTab; +}) { + if (!isSelected || !open || !hasImageSource || typeof document === "undefined") { + return null; + } + + return createPortal( +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + style={{ + top: position.top, + left: position.left, + transform: `scale(${scale})`, + transformOrigin: "top left", + }} + > +
+

+ {label} +

+
+ + +
+
+ +
+
+
+ {IMAGE_ADJUST_TAB_OPTIONS.map((item) => { + const active = tab === item.id; + const Icon = item.icon; + return ( + + ); + })} +
+
+ +
+
+ {IMAGE_ADJUST_SECTIONS[tab].map((field) => ( + + onDraftChange((previous) => ({ + ...previous, + [field.key]: value, + })) + } + trackClassName={field.trackClassName} + value={draft[field.key]} + /> + ))} +
+
+
+
, + document.body + ); +}