"use client"; import { useEffect, useMemo, useRef, useState, type ChangeEvent, type PointerEvent as ReactPointerEvent } from "react"; import type { LightingDirection, LightingPosition, LightingSettings, LightingViewMode } from "@/types"; import { DEFAULT_LIGHTING_SETTINGS, LIGHTING_PRESETS, normalizeLightingSettings } from "@/utils/lighting"; interface LightingEffectModalProps { isOpen: boolean; value?: LightingSettings | null; sourceImage?: string | null; onClose: () => void; onApply: (settings: LightingSettings) => void; } const DIRECTIONS: Array<{ id: LightingDirection; label: string }> = [ { id: "left", label: "左侧" }, { id: "top", label: "顶部" }, { id: "right", label: "右侧" }, { id: "front", label: "前方" }, { id: "bottom", label: "底部" }, { id: "back", label: "后方" }, ]; const VIEW_MODES: Array<{ id: LightingViewMode; label: string }> = [ { id: "perspective", label: "透视" }, { id: "front", label: "正面" }, ]; function readImageFile(file: File): Promise { if (!file.type.match(/^image\/(png|jpeg|webp)$/)) return Promise.resolve(null); return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (event) => resolve((event.target?.result as string | undefined) ?? null); reader.onerror = () => resolve(null); reader.readAsDataURL(file); }); } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function formatCssNumber(value: number): string { const rounded = Number(value.toFixed(2)); return Object.is(rounded, -0) ? "0" : `${rounded}`; } function normalizeHex(value: string): string | null { const normalized = value.trim().replace(/^#/, ""); if (!/^[0-9a-fA-F]{6}$/.test(normalized)) return null; return `#${normalized.toLowerCase()}`; } function hexToRgb(hex: string): { r: number; g: number; b: number } { const normalized = normalizeHex(hex) ?? "#ffffff"; return { r: Number.parseInt(normalized.slice(1, 3), 16), g: Number.parseInt(normalized.slice(3, 5), 16), b: Number.parseInt(normalized.slice(5, 7), 16), }; } function rgbToHex(r: number, g: number, b: number): string { return `#${[r, g, b].map((channel) => clamp(Math.round(channel), 0, 255).toString(16).padStart(2, "0")).join("")}`; } function hexToHsv(hex: string): { h: number; s: number; v: number } { const { r, g, b } = hexToRgb(hex); const rn = r / 255; const gn = g / 255; const bn = b / 255; const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn); const delta = max - min; let h = 0; if (delta !== 0) { if (max === rn) h = ((gn - bn) / delta) % 6; else if (max === gn) h = (bn - rn) / delta + 2; else h = (rn - gn) / delta + 4; h *= 60; if (h < 0) h += 360; } return { h, s: max === 0 ? 0 : delta / max, v: max, }; } function hsvToHex(h: number, s: number, v: number): string { const c = v * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = v - c; let r = 0; let g = 0; let b = 0; if (h < 60) [r, g, b] = [c, x, 0]; else if (h < 120) [r, g, b] = [x, c, 0]; else if (h < 180) [r, g, b] = [0, c, x]; else if (h < 240) [r, g, b] = [0, x, c]; else if (h < 300) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x]; return rgbToHex((r + m) * 255, (g + m) * 255, (b + m) * 255); } function directionToPosition(direction: LightingDirection): LightingPosition { switch (direction) { case "left": return { x: -1, y: 0, z: 0 }; case "top": return { x: 0, y: -1, z: 0 }; case "right": return { x: 1, y: 0, z: 0 }; case "bottom": return { x: 0, y: 1, z: 0 }; case "back": return { x: 0, y: 0, z: -1 }; case "front": default: return { x: 0, y: 0, z: 1 }; } } function positionToDirection(position: LightingPosition, fallback: LightingDirection = "front"): LightingDirection { if (position.z <= -0.95 && Math.abs(position.x) < 0.1 && Math.abs(position.y) < 0.1) return "back"; if (position.z >= 0.95 && Math.abs(position.x) < 0.1 && Math.abs(position.y) < 0.1) return "front"; if (position.y <= -0.95 && Math.abs(position.x) < 0.1) return "top"; if (position.y >= 0.95 && Math.abs(position.x) < 0.1) return "bottom"; if (position.x <= -0.95 && Math.abs(position.y) < 0.1) return "left"; if (position.x >= 0.95 && Math.abs(position.y) < 0.1) return "right"; return fallback; } function isCardinalPosition(position: LightingPosition, direction: LightingDirection): boolean { const target = directionToPosition(direction); return ( Math.abs(position.x - target.x) < 0.05 && Math.abs(position.y - target.y) < 0.05 && Math.abs(position.z - target.z) < 0.05 ); } function normalizePosition(position: LightingPosition): LightingPosition { const length = Math.hypot(position.x, position.y, position.z); if (length === 0) return directionToPosition("front"); return { x: position.x / length, y: position.y / length, z: position.z / length }; } const LIGHT_STOPS: Array<{ id: string; position: LightingPosition }> = [ { id: "top", position: { x: 0, y: -1, z: 0 } }, { id: "bottom", position: { x: 0, y: 1, z: 0 } }, { id: "left", position: { x: -1, y: 0, z: 0 } }, { id: "right", position: { x: 1, y: 0, z: 0 } }, { id: "front", position: { x: 0, y: 0, z: 1 } }, { id: "back", position: { x: 0, y: 0, z: -1 } }, { id: "left-top-front", position: { x: -0.58, y: -0.58, z: 0.58 } }, { id: "left-bottom-front", position: { x: -0.58, y: 0.58, z: 0.58 } }, { id: "left-top-back", position: { x: -0.58, y: -0.58, z: -0.58 } }, { id: "left-bottom-back", position: { x: -0.58, y: 0.58, z: -0.58 } }, { id: "right-top-front", position: { x: 0.58, y: -0.58, z: 0.58 } }, { id: "right-bottom-front", position: { x: 0.58, y: 0.58, z: 0.58 } }, { id: "right-top-back", position: { x: 0.58, y: -0.58, z: -0.58 } }, { id: "right-bottom-back", position: { x: 0.58, y: 0.58, z: -0.58 } }, ]; function projectLightPosition(position: LightingPosition, viewMode: LightingViewMode): { left: number; top: number } { if (viewMode === "perspective") { return { left: clamp(50 + position.x * 34 - position.z * 43, 7, 93), top: clamp(50 + position.y * 38 + position.z * 16, 7, 93), }; } if (Math.abs(position.x) < 0.2 && Math.abs(position.y) < 0.2) { return { left: 50, top: 50 }; } const distance = Math.hypot(position.x, position.y) || 1; const x = position.x / distance; const y = position.y / distance; return { left: 50 + x * 49, top: 50 + y * 49, }; } interface PreviewPoint { left: number; top: number; } function getSubjectProjection(viewMode: LightingViewMode) { if (viewMode === "front") { return { left: 50, top: 50, rotateX: 0, rotateY: 0, rotateZ: 0, scale: 1, width: 42, }; } return { left: 50, top: 50, rotateX: -8, rotateY: -58, rotateZ: 0, scale: 0.98, width: 45, }; } function getBeamGeometry(source: PreviewPoint, target: PreviewPoint, viewMode: LightingViewMode) { const deltaX = target.left - source.left; const deltaY = target.top - source.top; const distance = Math.hypot(deltaX, deltaY); return { angle: Math.atan2(deltaY, deltaX) * (180 / Math.PI), length: Math.max(viewMode === "perspective" ? 60 : 54, distance * (viewMode === "perspective" ? 1.8 : 2.2)), }; } function getSubjectHighlight(position: LightingPosition): PreviewPoint { return { left: clamp(50 - position.x * 28, 18, 82), top: clamp(50 - position.y * 28, 18, 82), }; } interface SpherePointer { rect: DOMRect; clientX: number; clientY: number; } function readSpherePointer(event: ReactPointerEvent): SpherePointer { return { rect: event.currentTarget.getBoundingClientRect(), clientX: event.clientX, clientY: event.clientY, }; } function positionFromPointer(pointer: SpherePointer, currentPosition: LightingPosition): LightingPosition { const { rect, clientX, clientY } = pointer; const radius = Math.min(rect.width, rect.height) / 2; const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; let x = (clientX - centerX) / radius; let y = (clientY - centerY) / radius; const distance = Math.hypot(x, y); if (distance < 0.18) { return currentPosition.z < -0.25 ? directionToPosition("back") : directionToPosition("front"); } if (distance > 1) { x /= distance; y /= distance; } const clampedDistance = Math.min(1, Math.hypot(x, y)); const zMagnitude = Math.sqrt(Math.max(0, 1 - clampedDistance * clampedDistance)); const zSign = currentPosition.z < -0.25 ? -1 : 1; return normalizePosition({ x, y, z: zMagnitude * zSign }); } function nearestLightStop(position: LightingPosition): LightingPosition { if (Math.abs(position.x) < 0.2 && Math.abs(position.y) < 0.2) { return position.z < 0 ? directionToPosition("back") : directionToPosition("front"); } const zSign = position.z < -0.2 ? -1 : 1; const candidates = LIGHT_STOPS.filter((stop) => { if (stop.id === "front" || stop.id === "back") return false; if (stop.position.z > 0.1) return zSign > 0; if (stop.position.z < -0.1) return zSign < 0; return true; }); const projected = projectLightPosition(position, "front"); return candidates.reduce((best, stop) => { const stopProjected = projectLightPosition(stop.position, "front"); const distance = Math.hypot(projected.left - stopProjected.left, projected.top - stopProjected.top); return distance < best.distance ? { distance, position: stop.position } : best; }, { distance: Number.POSITIVE_INFINITY, position: candidates[0].position }).position; } function LightingPreview({ settings, sourceImage, onLightPositionChange, }: { settings: LightingSettings; sourceImage?: string | null; onLightPositionChange: (position: LightingPosition) => void; }) { const [draftPosition, setDraftPosition] = useState(settings.lightPosition); const [isDraggingLight, setIsDraggingLight] = useState(false); const position = isDraggingLight ? draftPosition : settings.lightPosition; const projected = projectLightPosition(position, settings.viewMode); const subject = getSubjectProjection(settings.viewMode); const subjectHighlight = getSubjectHighlight(position); const beam = getBeamGeometry(projected, subject, settings.viewMode); const opacity = Math.max(0.2, Math.min(0.9, settings.brightness / 100)); const beamSpread = Math.abs(settings.beamAngle) === 90 ? 30 : 22; const isPureFrontLight = settings.viewMode === "front" && position.z > 0.8 && Math.abs(position.x) < 0.2 && Math.abs(position.y) < 0.2; const isPureBackLight = position.z < -0.8 && Math.abs(position.x) < 0.2 && Math.abs(position.y) < 0.2; const isBackLight = position.z < -0.2; const subjectTransform = [ "perspective(420px)", "translate(-50%, -50%)", `rotateX(${formatCssNumber(subject.rotateX)}deg)`, `rotateY(${formatCssNumber(subject.rotateY)}deg)`, `rotateZ(${formatCssNumber(subject.rotateZ)}deg)`, `scale(${formatCssNumber(subject.scale)})`, ].join(" "); useEffect(() => { if (!isDraggingLight) { setDraftPosition(settings.lightPosition); } }, [isDraggingLight, settings.lightPosition]); const updateDraftPosition = (event: ReactPointerEvent) => { const pointer = readSpherePointer(event); setDraftPosition((current) => positionFromPointer(pointer, current)); }; const commitDraftPosition = (event: ReactPointerEvent) => { const releasedPosition = positionFromPointer(readSpherePointer(event), draftPosition); const snappedPosition = nearestLightStop(releasedPosition); setDraftPosition(snappedPosition); setIsDraggingLight(false); onLightPositionChange(snappedPosition); }; return (
{ event.currentTarget.setPointerCapture(event.pointerId); const pointer = readSpherePointer(event); setIsDraggingLight(true); setDraftPosition(positionFromPointer(pointer, position)); }} onPointerMove={(event) => { if (event.buttons !== 1) return; updateDraftPosition(event); }} onPointerUp={commitDraftPosition} onPointerCancel={commitDraftPosition} aria-label="拖拽光源控制点" >
{isPureFrontLight && (
)} {!isPureFrontLight && !isPureBackLight && (
)}
{sourceImage ? ( // eslint-disable-next-line @next/next/no-img-element ) : (
)}
{!isPureBackLight && (
)}
); } function LightingColorPicker({ value, onChange, }: { value: string; onChange: (value: string) => void; }) { const [open, setOpen] = useState(false); const hsv = useMemo(() => hexToHsv(value), [value]); const [hue, setHue] = useState(hsv.h); const pickerRef = useRef(null); useEffect(() => { if (hsv.s > 0) { setHue(hsv.h); } }, [hsv.h, hsv.s]); useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { const target = event.target; if (target instanceof Node && pickerRef.current?.contains(target)) return; setOpen(false); }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, [open]); const updateSaturationValue = (event: ReactPointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const s = clamp((event.clientX - rect.left) / rect.width, 0, 1); const v = clamp(1 - (event.clientY - rect.top) / rect.height, 0, 1); onChange(hsvToHex(hue, s, v)); }; const updateHue = (nextHue: number) => { const normalizedHue = clamp(nextHue, 0, 360); setHue(normalizedHue); onChange(hsvToHex(normalizedHue, hsv.s, hsv.v)); }; const updateHueFromPointer = (event: ReactPointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const ratio = rect.width > 0 ? clamp((event.clientX - rect.left) / rect.width, 0, 1) : 0; updateHue(ratio * 360); }; const handleHexChange = (nextValue: string) => { const normalized = normalizeHex(nextValue); if (normalized) onChange(normalized); }; return (
{open && (
{ event.currentTarget.setPointerCapture?.(event.pointerId); updateSaturationValue(event); }} onPointerMove={(event) => { if (event.buttons !== 1) return; updateSaturationValue(event); }} >
{ event.currentTarget.setPointerCapture?.(event.pointerId); updateHueFromPointer(event); }} onPointerMove={(event) => { if (event.buttons !== 1) return; updateHueFromPointer(event); }} onKeyDown={(event) => { if (event.key === "ArrowLeft" || event.key === "ArrowDown") { event.preventDefault(); updateHue(hue - 5); } if (event.key === "ArrowRight" || event.key === "ArrowUp") { event.preventDefault(); updateHue(hue + 5); } }} >
handleHexChange(event.target.value)} className="mt-2 h-8 w-full rounded-md border border-neutral-600 bg-neutral-800 px-2 text-xs text-neutral-100 outline-none focus:border-neutral-400" aria-label="打光颜色十六进制" />
)}
); } export function LightingEffectModal({ isOpen, value, sourceImage, onClose, onApply, }: LightingEffectModalProps) { const [settings, setSettings] = useState(() => normalizeLightingSettings(value)); const [modalPosition, setModalPosition] = useState<{ x: number; y: number } | null>(null); const [isDragging, setIsDragging] = useState(false); const referenceInputRef = useRef(null); const modalRef = useRef(null); const dragOffsetRef = useRef({ x: 0, y: 0 }); useEffect(() => { if (isOpen) { setSettings(normalizeLightingSettings(value)); setModalPosition(null); setIsDragging(false); } }, [isOpen, value]); const modalWidthClass = settings.smartMode ? "max-w-[760px]" : "max-w-[458px]"; const appliedPreviewImage = settings.referenceImage || sourceImage; const selectedPreset = useMemo( () => LIGHTING_PRESETS.find((preset) => preset.id === settings.selectedPresetId), [settings.selectedPresetId] ); if (!isOpen) return null; const patchSettings = (patch: Partial) => { setSettings((current) => ({ ...current, ...patch, enabled: true })); }; const handleReferenceChange = async (event: ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; const image = await readImageFile(file); if (image) patchSettings({ referenceImage: image }); }; const handleDragStart = (event: ReactPointerEvent) => { const target = event.target as HTMLElement; if (target.closest("button")) return; const rect = modalRef.current?.getBoundingClientRect(); if (!rect) return; event.currentTarget.setPointerCapture(event.pointerId); dragOffsetRef.current = { x: event.clientX - rect.left, y: event.clientY - rect.top, }; setModalPosition({ x: rect.left, y: rect.top }); setIsDragging(true); }; const handleDragMove = (event: ReactPointerEvent) => { if (!isDragging) return; const rect = modalRef.current?.getBoundingClientRect(); const width = rect?.width ?? 460; const height = rect?.height ?? 420; setModalPosition({ x: clamp(event.clientX - dragOffsetRef.current.x, 8, window.innerWidth - width - 8), y: clamp(event.clientY - dragOffsetRef.current.y, 8, window.innerHeight - height - 8), }); }; const handleDragEnd = () => { setIsDragging(false); }; return (

打光效果

{VIEW_MODES.map((mode) => ( ))}
patchSettings({ lightPosition, mainLightDirection: positionToDirection(lightPosition, settings.mainLightDirection), }) } />
全局
颜色 patchSettings({ color })} />
主光源
{DIRECTIONS.map((direction) => ( ))}
{settings.smartMode && (
智能模式