Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-node-camera-dialog.tsx

488 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
<rect x="18" y="8" width="30" height="32" rx="3" fill={body} stroke={accent} strokeWidth="1.2" />
<rect x="10" y="12" width="12" height="24" rx="2" fill="#1b1b1b" stroke={accent} />
<rect x="48" y="14" width="16" height="20" rx="2" fill="#222" stroke={accent} />
<circle cx="56" cy="24" r="6" fill="#0a0a0a" stroke={accent} />
<rect x="26" y="3" width="14" height="6" rx="1" fill={body} />
<rect x="20" y="42" width="26" height="4" rx="1" fill={accent} opacity="0.6" />
</svg>
);
case "arri_alexa_mini_lf":
return (
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
<rect x="14" y="12" width="38" height="28" rx="4" fill={body} stroke={accent} />
<circle cx="52" cy="26" r="10" fill="#111" stroke={accent} strokeWidth="1.2" />
<circle cx="52" cy="26" r="6" fill="#2b2b2b" stroke="#666" />
<rect x="20" y="6" width="18" height="8" rx="1.5" fill={body} />
<rect x="18" y="18" width="8" height="6" rx="1" fill={accent} opacity="0.3" />
</svg>
);
case "red_komodo_6k":
case "red_v_raptor_8k": {
const is_raptor = profile.id === "red_v_raptor_8k";
return (
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
<rect x="18" y="14" width={is_raptor ? "32" : "28"} height="26" rx="2" fill={body} stroke={accent} />
<circle cx={is_raptor ? "50" : "46"} cy="27" r="9" fill="#050505" stroke={accent} />
<circle cx={is_raptor ? "50" : "46"} cy="27" r="5" fill="#1a1a1a" stroke="#666" />
<rect x="20" y="8" width="8" height="7" rx="1" fill={accent} opacity="0.85" />
<text x="22" y="36" fontSize="6" fill="#fff" fontWeight="bold">RED</text>
</svg>
);
}
case "sony_venice_2":
case "sony_fx6":
return (
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
<rect x="14" y="14" width="36" height="26" rx="3" fill={body} stroke={accent} />
<circle cx="52" cy="27" r="9" fill="#050505" stroke={accent} />
<rect x="16" y="18" width="6" height="6" rx="1" fill={accent} opacity="0.5" />
<rect x="22" y="8" width="16" height="8" rx="1.5" fill={body} />
<text x="22" y="36" fontSize="5.5" fill="#ccc">SONY</text>
</svg>
);
case "blackmagic_ursa_12k":
return (
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
<rect x="16" y="12" width="32" height="28" rx="2" fill={body} stroke={accent} />
<circle cx="50" cy="26" r="10" fill="#050505" stroke={accent} />
<circle cx="50" cy="26" r="6" fill="#1a1a1a" stroke="#666" />
<rect x="24" y="5" width="12" height="8" rx="1" fill={body} />
<text x="18" y="35" fontSize="5" fill={accent} fontWeight="bold">URSA</text>
</svg>
);
case "canon_c500_mk2":
return (
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
<rect x="16" y="12" width="34" height="28" rx="3" fill={body} stroke={accent} />
<circle cx="50" cy="26" r="9" fill="#050505" stroke={accent} />
<rect x="18" y="16" width="10" height="5" rx="1" fill={accent} opacity="0.7" />
<text x="20" y="36" fontSize="5.5" fill="#eee">Canon</text>
</svg>
);
default:
return (
<svg viewBox="0 0 72 52" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
<rect x="16" y="14" width="36" height="26" rx="3" fill={body} stroke={accent} />
<circle cx="52" cy="27" r="9" fill="#0a0a0a" stroke={accent} />
</svg>
);
}
}
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 (
<svg viewBox="0 0 72 44" className={className} xmlns="http://www.w3.org/2000/svg" aria-hidden>
<rect x="6" y="14" width="56" height="18" rx="3" fill={body} stroke="#444" />
{Array.from({ length: rings }).map((_, i) => (
<rect key={i} x={12 + i * 12} y="14" width="4" height="18" fill={ring} opacity="0.6" />
))}
<circle cx="58" cy="23" r="7" fill="#050505" stroke={ring} strokeWidth="1.5" />
<circle cx="58" cy="23" r="3.5" fill="#222" stroke="#888" />
{profile.id.startsWith("anamorphic") && (
<ellipse cx="58" cy="23" rx="7" ry="3" fill="none" stroke={ring} strokeWidth="0.8" opacity="0.7" />
)}
</svg>
);
}
/* ── 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<HTMLDivElement | null>(null);
const tipRef = useRef<HTMLDivElement | null>(null);
const timerRef = useRef<number | null>(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 (
<div ref={anchorRef} className="relative" onMouseEnter={handleEnter} onMouseLeave={handleLeave}>
{children}
{coords &&
createPortal(
<div
ref={tipRef}
className="pointer-events-none fixed z-[20000] w-[260px] rounded-lg border p-3 shadow-2xl"
style={{
left: `${coords.left}px`,
top: coords.placement === "bottom" ? `${coords.top}px` : undefined,
bottom: coords.placement === "top" ? `${window.innerHeight - coords.top}px` : undefined,
borderColor: theme.toolbar.border,
background: theme.spatial.elevated,
color: theme.node.text,
}}
>
<div className="text-[12px] font-semibold">{title}</div>
{description && <div className="mt-1 text-[11px] leading-5" style={{ color: theme.node.muted }}>{description}</div>}
{useCase && <div className="mt-2 rounded px-2 py-1 text-[10px]" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>使用场景:{useCase}</div>}
</div>,
document.body,
)}
</div>
);
});
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 (
<div className="flex min-w-0 flex-col items-center gap-1.5">
<button
type="button"
onClick={onPrev}
className="flex h-5 w-full items-center justify-center rounded-md transition-colors"
style={{ color: theme.node.faint }}
title="上一项"
>
<ChevronUp className="size-3.5" />
</button>
<HoverTip title={tooltipTitle} description={tooltipDesc} useCase={tooltipUseCase}>
<div
className="relative flex h-[clamp(116px,14vw,140px)] w-full min-w-0 cursor-help flex-col items-center justify-between rounded-xl border px-2.5 pt-2 pb-2 transition-colors"
style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover }}
>
<span className="text-[11px] font-medium tracking-wide" style={{ color: theme.node.muted }}>{label}</span>
<div className="flex w-full flex-1 items-center justify-center">{visual}</div>
{cornerBadge && (
<div className="absolute right-1.5 top-1.5 rounded-md px-1.5 py-0.5 text-[10px] font-medium" style={{ background: "rgba(0,0,0,.5)", color: "rgba(255,255,255,.8)" }}>
{cornerBadge}
</div>
)}
</div>
</HoverTip>
<button
type="button"
onClick={onNext}
className="flex h-5 w-full items-center justify-center rounded-md transition-colors"
style={{ color: theme.node.faint }}
title="下一项"
>
<ChevronDown className="size-3.5" />
</button>
<span className="max-w-full truncate text-center text-[11px] leading-4" style={{ color: theme.node.muted }}>{captionBelow}</span>
</div>
);
});
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 (
<SpotlightSurface
data-canvas-no-zoom
spotlightColor={theme.toolbar.itemHover}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.panel}
className="w-full overflow-hidden rounded-[var(--r-xl)] border backdrop-blur-2xl"
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 28px 80px ${theme.spatial.shadow}` }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b px-4 py-3" style={{ borderColor: theme.toolbar.border }}>
<div className="min-w-0">
<div className="text-sm font-semibold tracking-tight">摄像机控制</div>
<div className="mt-0.5 truncate text-[11px]" style={{ color: theme.node.muted }}>为当前图片生成设置镜头、焦距与光圈</div>
</div>
<button
type="button"
className="grid size-7 shrink-0 place-items-center rounded-md transition-colors hover:bg-white/10"
style={{ color: theme.node.muted }}
onClick={onClose}
aria-label="关闭摄像机控制"
title="关闭"
>
<X className="size-4" />
</button>
</div>
{/* 相机、镜头、焦距和光圈均来自已注册枚举,确认时由纯函数再次强校验。 */}
<div className="grid grid-cols-2 gap-2.5 px-4 py-4 sm:grid-cols-4">
<CardColumn
label="相机"
tooltipTitle={`${currentCamera.zhName} · ${currentCamera.label}`}
tooltipDesc={currentCamera.description}
tooltipUseCase={currentCamera.useCase}
visual={<CameraBodySvg profile={currentCamera} className="h-16" />}
captionBelow={currentCamera.zhName}
onPrev={() => cycleCamera(-1)}
onNext={() => cycleCamera(1)}
theme={theme}
/>
<CardColumn
label="镜头"
tooltipTitle={`${currentLens.zhName} · ${currentLens.label}`}
tooltipDesc={currentLens.description}
tooltipUseCase={currentLens.useCase}
visual={<LensBodySvg profile={currentLens} className="h-14" />}
captionBelow={currentLens.zhName}
onPrev={() => cycleLens(-1)}
onNext={() => cycleLens(1)}
theme={theme}
/>
<CardColumn
label="焦距"
tooltipTitle={`${focal}mm · ${focalMeta?.zhName ?? ""}`}
tooltipDesc={focalMeta?.description}
tooltipUseCase={focalMeta?.useCase}
visual={
<div className="flex flex-col items-center">
<div className="text-[40px] font-light leading-none">{focal}</div>
<div className="mt-1 text-[10px] tracking-wider" style={{ color: theme.node.faint }}>mm</div>
</div>
}
cornerBadge={focalMeta?.zhName}
captionBelow={focalMeta?.zhName ?? ""}
onPrev={() => cycleFocal(-1)}
onNext={() => cycleFocal(1)}
theme={theme}
/>
<CardColumn
label="光圈"
tooltipTitle={`f/${aperture} · ${apertureMeta?.zhName ?? ""}`}
tooltipDesc={apertureMeta?.description}
tooltipUseCase={apertureMeta?.useCase}
visual={
<div className="flex flex-col items-center">
<div className="text-[30px] font-light leading-none">
<span className="text-[18px]" style={{ color: theme.node.faint }}>f/</span>{aperture}
</div>
<div className="mt-1 text-[10px] tracking-wider" style={{ color: theme.node.faint }}>aperture</div>
</div>
}
cornerBadge={apertureMeta?.zhName}
captionBelow={apertureMeta?.zhName ?? ""}
onPrev={() => cycleAperture(-1)}
onNext={() => cycleAperture(1)}
theme={theme}
/>
</div>
{/* 当前有效配置摘要。 */}
<div className="mx-4 mb-3 rounded-lg border px-3.5 py-2.5" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover }}>
<div className="text-[10px] uppercase tracking-wide" style={{ color: theme.node.faint }}>当前配置</div>
<div className="mt-0.5 text-xs">{summary}</div>
</div>
{/* 操作区:重置与复制不产生写入,只有应用会提交生成配置。 */}
<div className="flex min-h-12 flex-wrap items-center gap-2 border-t px-4 py-2" style={{ borderColor: theme.toolbar.border }}>
<button type="button" className={secondaryButtonClass} style={{ color: theme.node.muted }} onClick={handleReset}>
<RotateCcw className="size-3.5" />重置参数
</button>
<span className="flex-1" />
<Tooltip title="复制当前生成的摄像机提示词">
<button type="button" className={secondaryButtonClass} onClick={() => copyText(buildPrompt(), "摄像机提示词已复制")}>复制提示词</button>
</Tooltip>
{/* 关闭后仍保留参数选择,但提交空提示词,便于用户无损恢复。 */}
<div className="flex items-center gap-2" title={enabled ? "当前启用摄像机控制" : "当前关闭摄像机控制"}>
<span className="text-xs transition-colors" style={{ color: enabled ? theme.accent.primary : theme.node.muted, fontWeight: enabled ? 600 : 400 }}>
{enabled ? "开启" : "关闭"}
</span>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={() => 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,
}}
>
<span
className={`inline-block size-3.5 transform rounded-full shadow-sm transition-transform ${enabled ? "translate-x-[18px]" : "translate-x-0.5"}`}
style={{ background: enabled ? theme.node.activeStroke : theme.node.muted }}
/>
</button>
</div>
<motion.button
type="button"
whileHover={reducedMotion ? undefined : { y: -1 }}
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
className="canvas-node-camera-apply flex h-7 items-center gap-1.5 rounded-[var(--dock-item-radius)] border px-3.5 text-[var(--fs-label)] font-semibold transition-colors"
style={{ borderColor: theme.node.activeStroke, color: theme.node.activeStroke, background: "transparent" }}
onClick={handleApply}
>
<CameraIcon className="size-3.5" />应用
</motion.button>
</div>
</SpotlightSurface>
);
}