feat(canvas):画布 - 相机控制(虚拟摄影机) (#473)

* refactor(canvas): 重构相机控制UI,拆分独立组件并调整位置

将原图像设置弹窗中的相机控制功能抽离为独立组件,调整相机控制按钮到图像设置前的布局,简化原弹窗组件代码

* refactor(canvas-toolbar): 调整光影工具分组与渲染逻辑

将原视角分组中的光影工具拆分到独立lighting分组,更新类型定义与工具栏渲染逻辑,让工具分组更贴合功能场景

---------

Co-authored-by: bensharp <bensharp@qq.com>
This commit is contained in:
zhangerheo
2026-09-10 11:33:18 +08:00
committed by GitHub
parent 76054462f1
commit 2276ab750e
5 changed files with 71 additions and 61 deletions

View File

@@ -1,14 +1,11 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Camera, Settings2 } from "lucide-react";
import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
import { CanvasNodeCameraPanel } from "@/components/canvas/canvas-node-camera-dialog";
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
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";
@@ -22,17 +19,14 @@ 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, cameraControl, onCameraControlChange }: CanvasImageSettingsPopoverProps) {
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft", showCount = true }: 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 = [
@@ -73,48 +67,16 @@ 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 && !onCameraControlChange) return null;
const cameraEnabled = cameraControl?.enabled === true;
if (!hasSettings) return null;
return (
<>
{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>
)}
<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 && (
<AppModal title="摄像机控制" open centered footer={null} width={780} flush onCancel={() => setCameraOpen(false)}>
<CanvasNodeCameraPanel
cameraControl={cameraControl}
onClose={() => setCameraOpen(false)}
onConfirm={(options, _prompt) => {
onCameraControlChange(options);
setCameraOpen(false);
}}
/>
</AppModal>
)}
</>
);
}

View File

@@ -159,7 +159,7 @@ const imageToolDefinitions: ImageToolDefinition[] = [
section: "视角",
description: "调整光线方向、亮度与光效,生成新图片",
icon: () => <Sun className="size-3.5" />,
group: "viewpoint",
group: "lighting",
order: 30,
run: (node, handlers) => handlers.onLighting(node),
},

View File

@@ -1,7 +1,7 @@
import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -14,7 +14,10 @@ import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefa
import { navigateToSettings } from "@/lib/settings-navigation";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasNodeCameraPanel } from "./canvas-node-camera-dialog";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
@@ -392,16 +395,23 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
/>
</Tooltip>
) : mode === "image" ? (
<CanvasImageSettingsPopover
config={config}
placement={expanded ? "topRight" : "topLeft"}
buttonClassName="canvas-node-composer-settings-trigger [&>span]:min-w-0 [&_.lucide]:!size-3"
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 })}
/>
// 图片模式下,显示相机配置与镜头配置
<>
<CameraToolsPopover
cameraControl={node.metadata?.cameraControl}
onCameraControlChange={(options) => onConfigChange(node.id, { cameraControl: options })}
theme={theme}
compact={!expanded}
/>
<CanvasImageSettingsPopover
config={config}
placement={expanded ? "topRight" : "topLeft"}
buttonClassName="canvas-node-composer-settings-trigger [&>span]:min-w-0 [&_.lucide]:!size-3"
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
/>
</>
) : mode === "video" ? (
<CanvasVideoSettingsPopover
config={config}
@@ -537,6 +547,42 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
);
}
function CameraToolsPopover({ cameraControl, onCameraControlChange, theme, compact }: { cameraControl?: CameraControlOptions; onCameraControlChange: (options: CameraControlOptions) => void; theme: CanvasTheme; compact: boolean }) {
const [open, setOpen] = useState(false);
const cameraEnabled = cameraControl?.enabled === true;
return (
<>
<button
type="button"
className={`canvas-node-composer-camera-tools-trigger inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 text-[var(--fs-tiny)] transition-colors ${compact ? "is-compact h-7" : "h-8"}`}
style={{
background: cameraEnabled ? `${theme.node.activeStroke}66` : theme.node.fill,
color: cameraEnabled ? theme.node.panel : theme.node.text,
}}
aria-pressed={cameraEnabled}
aria-label="摄像机控制"
title={`摄像机控制${cameraEnabled ? " · 已启用" : ""}`}
onClick={() => setOpen(true)}
>
<Camera className="size-4.5" />
{!compact ? <span>摄像机</span> : null}
</button>
{open ? (
<AppModal title="摄像机控制" open centered footer={null} width={780} flush onCancel={() => setOpen(false)}>
<CanvasNodeCameraPanel
cameraControl={cameraControl}
onClose={() => setOpen(false)}
onConfirm={(options) => {
onCameraControlChange(options);
setOpen(false);
}}
/>
</AppModal>
) : null}
</>
);
}
function ReferenceToolsPopover({ canAutoMention, autoLinkEnabled, onAutoMention, onAutoLinkEnabledChange, accent, compact }: { canAutoMention: boolean; autoLinkEnabled: boolean; onAutoMention: () => void; onAutoLinkEnabledChange: (enabled: boolean) => void; accent: string; compact: boolean }) {
return (
<Popover
@@ -583,7 +629,7 @@ function ReferenceToolsPopover({ canAutoMention, autoLinkEnabled, onAutoMention,
aria-label="打开智能引用"
title="智能引用"
>
<SlidersHorizontal className="size-3" />
<SlidersHorizontal className="size-4.5" />
{!compact ? <span>引用</span> : null}
</button>
</Popover>

View File

@@ -280,7 +280,8 @@ export function CanvasNodeToolbar({
const primaryTools = narrow ? primary.slice(0, 1) : primary;
const portraitTools = compact ? [] : inGroup("portrait");
const viewpointTools = compact ? [] : inGroup("viewpoint");
const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("process")] : inGroup("process");
const lightingTools = compact ? [] : inGroup("lighting");
const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("lighting"), ...inGroup("process")] : inGroup("process");
const workspaceTools = narrow ? [] : inGroup("workspace");
const utilityTools = inGroup("utility");
const moreTools = [...(narrow ? [...primary.slice(1), ...inGroup("workspace")] : []), ...inGroup("more")];
@@ -315,6 +316,7 @@ export function CanvasNodeToolbar({
{primaryTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{portraitTools.length ? <NodeDockMenuButton menuId="portrait" label="人像调整" icon={<UserRound className="size-3.5" />} tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{viewpointTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{lightingTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
{processTools.length ? <NodeDockMenuButton menuId="process" label={processMenuLabel} icon={isVideo ? <Images className="size-3.5" /> : <SlidersHorizontal className="size-3.5" />} tools={processTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
{workspaceTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
{workspaceTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}

View File

@@ -7,7 +7,7 @@ import type { CanvasNodeData, CanvasNodeMetadata, CanvasNodeTypeId, CanvasToolMo
/** 工具栏标识——每个工具栏有独立的注册表与偏好 */
export type ToolbarId = "main" | "selection" | "node-hover" | "add-node-menu";
export type NodeToolbarGroup = "primary" | "portrait" | "viewpoint" | "process" | "workspace" | "utility" | "more";
export type NodeToolbarGroup = "primary" | "portrait" | "viewpoint" | "lighting" | "process" | "workspace" | "utility" | "more";
/** 工具分类——用于分组渲染、危险隔离与 separator 自动插入 */
export type ToolCategory =