feat(canvas): add camera control prompt generation feature

新增摄像机控制功能,允许用户为画布节点配置相机、镜头、焦距和光圈参数,生成时自动追加对应的摄影提示词到生成prompt中:
1. 新增相机参数类型定义与画布元数据存储支持
2. 实现相机/镜头参数库与提示词构建逻辑
3. 添加可视化配置弹窗与面板组件
4. 集成到图像生成流程中自动拼接摄影提示词
This commit is contained in:
bensharp
2026-09-09 19:13:11 +08:00
parent c0d68fad52
commit c242138ae6
6 changed files with 835 additions and 10 deletions

View File

@@ -1,11 +1,13 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { Camera, Settings2 } from "lucide-react";
import { Button, Modal } from "antd";
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
import { CanvasNodeCameraPanel } from "@/components/canvas/canvas-node-camera-dialog";
import { canvasThemes } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library";
import { useThemeStore } from "@/stores/use-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
@@ -19,14 +21,17 @@ type CanvasImageSettingsPopoverProps = {
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
autoAdjustOverflow?: boolean;
showCount?: boolean;
cameraControl?: CameraControlOptions;
onCameraControlChange?: (options: CameraControlOptions) => void;
};
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft", showCount = true }: CanvasImageSettingsPopoverProps) {
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft", showCount = true, cameraControl, onCameraControlChange }: CanvasImageSettingsPopoverProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
const [cameraOpen, setCameraOpen] = useState(false);
const profile = modelCapabilityConfigFor(config, config.model || config.imageModel).image!;
const normalized = normalizeImageValue(profile, config);
const summaryParts = [
@@ -67,16 +72,48 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
const panel = open && buttonRect ? <ImageSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} showCount={showCount} onConfigChange={onConfigChange} /> : null;
if (!hasSettings) return null;
if (!hasSettings && !onCameraControlChange) return null;
const cameraEnabled = cameraControl?.enabled === true;
return (
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`图像设置:${summary}`} title={`图像设置 · ${summary}`} onClick={() => updateOpen(!open)}>
<span className="truncate">{summary}</span>
</Button>
</span>
{onCameraControlChange && (
<Button
size="small"
type="text"
className="canvas-camera-control-trigger !h-8 !rounded-full !px-2.5"
style={{
background: cameraEnabled ? theme.node.activeStroke : theme.node.fill,
color: cameraEnabled ? theme.node.panel : theme.node.text,
}}
icon={<Camera className="size-3.5" />}
aria-pressed={cameraEnabled}
aria-label="摄像机控制"
title={`摄像机控制${cameraEnabled ? " · 已启用" : ""}`}
onClick={() => setCameraOpen(true)}
/>
)}
{hasSettings && (
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={`canvas-generation-settings-trigger ${buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"}`} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} aria-expanded={open} aria-label={`图像设置:${summary}`} title={`图像设置 · ${summary}`} onClick={() => updateOpen(!open)}>
<span className="truncate">{summary}</span>
</Button>
</span>
)}
{panel}
{cameraOpen && onCameraControlChange && (
<Modal title="摄像机控制" open centered footer={null} width={780} destroyOnHidden onCancel={() => setCameraOpen(false)} styles={{ body: { padding: 0 } }}>
<CanvasNodeCameraPanel
cameraControl={cameraControl}
onClose={() => setCameraOpen(false)}
onConfirm={(options, _prompt) => {
onCameraControlChange(options);
setCameraOpen(false);
}}
/>
</Modal>
)}
</>
);
}

View File

@@ -0,0 +1,467 @@
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 { useThemeStore } from "@/stores/use-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: true,
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[useThemeStore((state) => state.theme)];
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 flex-1 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-[140px] w-[140px] cursor-help flex-col items-center justify-between rounded-xl border px-3 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[useThemeStore((state) => state.theme)];
const reducedMotion = useReducedMotion();
const copyText = useCopyText();
const [enabled, setEnabled] = useState(cameraControl?.enabled ?? true);
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-2xl)] 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()}
>
{/* Header */}
{/* Card columns */}
<div className="flex justify-center gap-3 px-5 py-5">
<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>
{/* Summary */}
<div className="mx-5 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>
{/* Footer */}
<div className="flex h-12 items-center gap-2 border-t px-5" 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>
{/* Enable toggle */}
<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="relative inline-flex h-6 w-11 cursor-pointer items-center rounded-full transition-colors"
style={{ background: enabled ? theme.node.activeStroke : theme.toolbar.itemHover }}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${enabled ? "translate-x-[22px]" : "translate-x-0.5"}`} />
</button>
</div>
<motion.button
type="button"
whileHover={reducedMotion ? undefined : { y: -1 }}
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
className="flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-4 text-[var(--fs-label)] font-semibold"
style={{ background: theme.node.activeStroke, color: theme.node.panel }}
onClick={handleApply}
>
<CameraIcon className="size-3.5" />应用
</motion.button>
</div>
</SpotlightSurface>
);
}

View File

@@ -380,6 +380,8 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
cameraControl={node.metadata?.cameraControl}
onCameraControlChange={(options) => onConfigChange(node.id, { cameraControl: options })}
/>
) : mode === "video" ? (
<CanvasVideoSettingsPopover