import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ChevronUp, ChevronDown, Camera as CameraIcon, RotateCcw, X } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface"; import { Tooltip } from "@/components/ui/base/tooltip"; import { aceternityMotion } from "@/lib/aceternity-motion"; import { canvasThemes } from "@/lib/canvas-theme"; import { useCopyText } from "@/hooks/use-copy-text"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { APERTURES, APERTURE_META, CAMERA_PROFILES, FOCAL_LENGTHS, FOCAL_LENGTH_META, LENS_PROFILES, buildCameraPrompt, type CameraControlOptions, type CameraProfile, type LensProfile, } from "@/lib/canvas/camera-prompt-library"; const defaultCameraControl: CameraControlOptions = { enabled: false, camera: "arri_alexa_mini_lf", lens: "arri_signature_prime", focalLength: 50, aperture: 2.8, }; /* ── SVG renderers ── */ function CameraBodySvg({ profile, className }: { profile: CameraProfile; className?: string }) { const body = profile.bodyColor; const accent = profile.accentColor; switch (profile.id) { case "panavision_dxl2": return ( ); case "arri_alexa_mini_lf": return ( ); case "red_komodo_6k": case "red_v_raptor_8k": { const is_raptor = profile.id === "red_v_raptor_8k"; return ( RED ); } case "sony_venice_2": case "sony_fx6": return ( SONY ); case "blackmagic_ursa_12k": return ( URSA ); case "canon_c500_mk2": return ( Canon ); default: return ( ); } } function LensBodySvg({ profile, className }: { profile: LensProfile; className?: string }) { const body = profile.lensColor; const ring = profile.ringColor; const rings = profile.id.startsWith("anamorphic") ? 4 : 3; return ( {Array.from({ length: rings }).map((_, i) => ( ))} {profile.id.startsWith("anamorphic") && ( )} ); } /* ── HoverTip ── */ interface HoverTipProps { title: string; description?: string; useCase?: string; children: React.ReactNode; } const HoverTip = memo(({ title, description, useCase, children }: HoverTipProps) => { const theme = canvasThemes[useActiveTheme()]; const [coords, setCoords] = useState<{ left: number; top: number; placement: "top" | "bottom" } | null>(null); const anchorRef = useRef(null); const tipRef = useRef(null); const timerRef = useRef(null); const computePlacement = useCallback(() => { const el = anchorRef.current; if (!el) return null; const rect = el.getBoundingClientRect(); const tooltipWidth = 260; const margin = 10; let left = rect.left + rect.width / 2 - tooltipWidth / 2; left = Math.max(margin, Math.min(window.innerWidth - tooltipWidth - margin, left)); const placement: "top" | "bottom" = "top"; const top = rect.top - margin; return { left, top, placement }; }, []); useEffect(() => { if (!coords) return; const tip = tipRef.current; const anchor = anchorRef.current; if (!tip || !anchor) return; const tipH = tip.getBoundingClientRect().height; const rect = anchor.getBoundingClientRect(); const margin = 10; let placement = coords.placement; if (placement === "top" && rect.top - tipH - margin < 0) placement = "bottom"; if (placement !== coords.placement) { const top = placement === "top" ? rect.top - margin : rect.bottom + margin; setCoords((c) => (c ? { ...c, placement, top } : c)); } }, [coords]); const handleEnter = () => { if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = window.setTimeout(() => { const c = computePlacement(); if (c) setCoords(c); }, 900); }; const handleLeave = () => { if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = null; setCoords(null); }; return ( {children} {coords && createPortal( {title} {description && {description}} {useCase && 使用场景:{useCase}} , document.body, )} ); }); HoverTip.displayName = "HoverTip"; /* ── CardColumn ── */ interface CardColumnProps { label: string; tooltipTitle: string; tooltipDesc?: string; tooltipUseCase?: string; visual: React.ReactNode; cornerBadge?: React.ReactNode; captionBelow: string; onPrev: () => void; onNext: () => void; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; } const CardColumn = memo(({ label, tooltipTitle, tooltipDesc, tooltipUseCase, visual, cornerBadge, captionBelow, onPrev, onNext, theme }: CardColumnProps) => { return ( {label} {visual} {cornerBadge && ( {cornerBadge} )} {captionBelow} ); }); CardColumn.displayName = "CardColumn"; /* ── Main panel ── */ export function CanvasNodeCameraPanel({ cameraControl, onClose, onConfirm, }: { cameraControl?: CameraControlOptions; onClose: () => void; onConfirm: (options: CameraControlOptions, prompt: string) => void; }) { const theme = canvasThemes[useActiveTheme()]; const reducedMotion = useReducedMotion(); const copyText = useCopyText(); const [enabled, setEnabled] = useState(cameraControl?.enabled ?? defaultCameraControl.enabled); const [cameraIdx, setCameraIdx] = useState(() => { const i = CAMERA_PROFILES.findIndex((c) => c.id === cameraControl?.camera); return i >= 0 ? i : 0; }); const [lensIdx, setLensIdx] = useState(() => { const i = LENS_PROFILES.findIndex((l) => l.id === cameraControl?.lens); return i >= 0 ? i : 0; }); const [focal, setFocal] = useState(cameraControl?.focalLength ?? 50); const [aperture, setAperture] = useState(cameraControl?.aperture ?? 4); const currentCamera = CAMERA_PROFILES[cameraIdx]; const currentLens = LENS_PROFILES[lensIdx]; const focalMeta = FOCAL_LENGTH_META[focal]; const apertureMeta = APERTURE_META[aperture]; const summary = useMemo(() => `${currentCamera.zhName} · ${currentLens.zhName} · ${focal}mm · f/${aperture}`, [currentCamera, currentLens, focal, aperture]); const cycleCamera = (dir: 1 | -1) => setCameraIdx((i) => (i + dir + CAMERA_PROFILES.length) % CAMERA_PROFILES.length); const cycleLens = (dir: 1 | -1) => setLensIdx((i) => (i + dir + LENS_PROFILES.length) % LENS_PROFILES.length); const cycleFocal = (dir: 1 | -1) => setFocal((f) => { const idx = FOCAL_LENGTHS.indexOf(f as (typeof FOCAL_LENGTHS)[number]); const nextIdx = (idx + dir + FOCAL_LENGTHS.length) % FOCAL_LENGTHS.length; return FOCAL_LENGTHS[nextIdx]; }); const cycleAperture = (dir: 1 | -1) => setAperture((a) => { const idx = APERTURES.indexOf(a as (typeof APERTURES)[number]); const nextIdx = (idx + dir + APERTURES.length) % APERTURES.length; return APERTURES[nextIdx]; }); const buildPrompt = useCallback(() => { if (!enabled) return ""; return buildCameraPrompt({ cameraId: currentCamera.id, lensId: currentLens.id, focalLengthMm: focal, apertureF: aperture, }); }, [enabled, currentCamera, currentLens, focal, aperture]); const handleApply = () => { const options: CameraControlOptions = { enabled, camera: currentCamera.id, lens: currentLens.id, focalLength: focal, aperture }; onConfirm(options, buildPrompt()); }; const handleReset = () => { setEnabled(defaultCameraControl.enabled); setCameraIdx(0); setLensIdx(0); setFocal(defaultCameraControl.focalLength); setAperture(defaultCameraControl.aperture); }; const secondaryButtonClass = "flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-3 text-[var(--fs-label)] font-medium transition hover:bg-black/5 dark:hover:bg-white/10"; return ( event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > 摄像机控制 为当前图片生成设置镜头、焦距与光圈 {/* 相机、镜头、焦距和光圈均来自已注册枚举,确认时由纯函数再次强校验。 */} } captionBelow={currentCamera.zhName} onPrev={() => cycleCamera(-1)} onNext={() => cycleCamera(1)} theme={theme} /> } captionBelow={currentLens.zhName} onPrev={() => cycleLens(-1)} onNext={() => cycleLens(1)} theme={theme} /> {focal} mm } cornerBadge={focalMeta?.zhName} captionBelow={focalMeta?.zhName ?? ""} onPrev={() => cycleFocal(-1)} onNext={() => cycleFocal(1)} theme={theme} /> f/{aperture} aperture } cornerBadge={apertureMeta?.zhName} captionBelow={apertureMeta?.zhName ?? ""} onPrev={() => cycleAperture(-1)} onNext={() => cycleAperture(1)} theme={theme} /> {/* 当前有效配置摘要。 */} 当前配置 {summary} {/* 操作区:重置与复制不产生写入,只有应用会提交生成配置。 */} 重置参数 copyText(buildPrompt(), "摄像机提示词已复制")}>复制提示词 {/* 关闭后仍保留参数选择,但提交空提示词,便于用户无损恢复。 */} {enabled ? "开启" : "关闭"} setEnabled((v) => !v)} className="canvas-node-camera-switch relative inline-flex h-5 w-9 cursor-pointer items-center rounded-full border transition-colors" style={{ background: enabled ? `${theme.node.activeStroke}22` : theme.toolbar.itemHover, borderColor: enabled ? theme.node.activeStroke : theme.toolbar.border, }} > 应用 ); }