Add collapsible video workflow sidebar
This commit is contained in:
parent
d0c26e9fab
commit
3876a0c403
@ -7,6 +7,9 @@ import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Save,
|
||||
Undo2,
|
||||
Redo2,
|
||||
@ -56,6 +59,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@ -243,6 +251,14 @@ interface VideoCanvasInsertMenuItem {
|
||||
accent?: "highlight";
|
||||
}
|
||||
|
||||
interface VideoCanvasSidebarGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
nodeTypes: VideoWorkflowTemplateType[];
|
||||
}
|
||||
|
||||
const IMPORTABLE_CANVAS_NODE_TYPES = new Set<CanvasNode["type"]>([
|
||||
"message",
|
||||
"image",
|
||||
@ -355,6 +371,83 @@ function getVideoWorkflowQuickConnectIcon(type: VideoWorkflowTemplateType) {
|
||||
return VIDEO_WORKFLOW_QUICK_CONNECT_ICON_MAP[type] || Plus;
|
||||
}
|
||||
|
||||
const VIDEO_CANVAS_SIDEBAR_GROUPS: VideoCanvasSidebarGroup[] = [
|
||||
{
|
||||
id: "input",
|
||||
label: "输入层",
|
||||
description: "文本、图片、视频、小说",
|
||||
icon: FileImage,
|
||||
nodeTypes: ["text-node", "input-image", "video-input", "novel-input"],
|
||||
},
|
||||
{
|
||||
id: "analysis",
|
||||
label: "分析层",
|
||||
description: "视频拆解、角色场景抽取",
|
||||
icon: Scan,
|
||||
nodeTypes: [
|
||||
"video-analyze",
|
||||
"extract-characters-scenes",
|
||||
"character-description",
|
||||
"scene-description",
|
||||
"create-character",
|
||||
"create-scene",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "storyboard",
|
||||
label: "分镜层",
|
||||
description: "分镜节点、分镜图表、表格",
|
||||
icon: GitBranch,
|
||||
nodeTypes: [
|
||||
"storyboard-node",
|
||||
"storyboard-chart-node",
|
||||
"table-editor-node",
|
||||
"camera-movement",
|
||||
"professional-camera",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "generation",
|
||||
label: "生成层",
|
||||
description: "生图、生视频、音乐、超清",
|
||||
icon: Sparkles,
|
||||
nodeTypes: [
|
||||
"gen-image",
|
||||
"gen-video",
|
||||
"gen-music",
|
||||
"jimeng-super-resolution",
|
||||
"generate-character-image",
|
||||
"generate-character-video",
|
||||
"generate-scene-image",
|
||||
"generate-scene-video",
|
||||
"motion-control",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tool",
|
||||
label: "工具层",
|
||||
description: "局部重绘、Comfy、Agent",
|
||||
icon: Wand2,
|
||||
nodeTypes: [
|
||||
"inpaint-crop",
|
||||
"inpaint-stitch",
|
||||
"rh-comfy",
|
||||
"rh-app",
|
||||
"custom-agent",
|
||||
"doodle-canvas",
|
||||
"canvas-node",
|
||||
"image-compare",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "output",
|
||||
label: "输出层",
|
||||
description: "预览、保存",
|
||||
icon: Save,
|
||||
nodeTypes: ["preview", "local-save"],
|
||||
},
|
||||
];
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@ -862,6 +955,15 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = useState<string | null>(null);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
|
||||
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] = useState(false);
|
||||
const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState<Record<string, boolean>>({
|
||||
input: true,
|
||||
analysis: true,
|
||||
storyboard: true,
|
||||
generation: true,
|
||||
tool: true,
|
||||
output: true,
|
||||
});
|
||||
const [videoCanvasInsertMenu, setVideoCanvasInsertMenu] =
|
||||
useState<VideoCanvasInsertMenuState | null>(null);
|
||||
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
|
||||
@ -890,6 +992,12 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
setVideoCanvasInsertMenu(null);
|
||||
}
|
||||
}, [isVideoMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideoMode) {
|
||||
setIsVideoModuleSidebarCollapsed(false);
|
||||
}
|
||||
}, [isVideoMode]);
|
||||
const [appearanceRecognitionEnabled, setAppearanceRecognitionEnabled] = useState(true);
|
||||
const [selectedBrandModel, setSelectedBrandModel] =
|
||||
useState<AgentWorkflowSelectedBrandModel | null>(null);
|
||||
@ -1073,6 +1181,12 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
selectedWorkflowQuickConnectAnchor && selectedVideoWorkflowNode
|
||||
? selectedVideoWorkflowNode
|
||||
: null;
|
||||
const videoModuleSidebarWidth = isVideoMode
|
||||
? isVideoModuleSidebarCollapsed
|
||||
? 64
|
||||
: 296
|
||||
: 0;
|
||||
const selectedWorkflowPanelLeft = isVideoMode ? videoModuleSidebarWidth + 28 : 16;
|
||||
const workflowGraph = useMemo(
|
||||
() => createWorkflowGraphContext(nodes, edges),
|
||||
[edges, nodes]
|
||||
@ -7411,6 +7525,149 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{isVideoMode ? (
|
||||
<div
|
||||
className="pointer-events-auto absolute left-4 top-16 z-20"
|
||||
style={{ width: `${videoModuleSidebarWidth}px` }}
|
||||
>
|
||||
<div className={cn(
|
||||
"overflow-hidden rounded-[28px] border border-slate-200 bg-white shadow-[0_24px_60px_-30px_rgba(15,23,42,0.18)] dark:border-zinc-800 dark:bg-zinc-950",
|
||||
isVideoModuleSidebarCollapsed ? "w-16" : "w-[296px]"
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-3 dark:border-zinc-800">
|
||||
{isVideoModuleSidebarCollapsed ? (
|
||||
<div className="flex w-full justify-center">
|
||||
<Layers className="h-5 w-5 text-slate-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">视频模块栏</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
按工作流层级添加模块
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsVideoModuleSidebarCollapsed((prev) => !prev)}
|
||||
className="h-8 w-8 rounded-xl text-muted-foreground hover:bg-slate-50 hover:text-foreground dark:hover:bg-zinc-900"
|
||||
>
|
||||
{isVideoModuleSidebarCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isVideoModuleSidebarCollapsed ? (
|
||||
<ScrollArea className="max-h-[calc(100vh-164px)]">
|
||||
<div className="space-y-3 p-3">
|
||||
{VIDEO_CANVAS_SIDEBAR_GROUPS.map((group) => {
|
||||
const GroupIcon = group.icon;
|
||||
return (
|
||||
<Collapsible
|
||||
key={group.id}
|
||||
open={videoModuleSectionOpen[group.id] !== false}
|
||||
onOpenChange={(open) =>
|
||||
setVideoModuleSectionOpen((prev) => ({
|
||||
...prev,
|
||||
[group.id]: open,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300">
|
||||
<GroupIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold text-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{group.description}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
videoModuleSectionOpen[group.id] !== false ? "rotate-0" : "-rotate-90"
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t border-slate-200 px-3 py-3 dark:border-zinc-800">
|
||||
<div className="grid gap-2">
|
||||
{group.nodeTypes.map((type) => {
|
||||
const definition = getVideoWorkflowNodeDefinition(type);
|
||||
if (!definition) return null;
|
||||
const Icon = getVideoWorkflowQuickConnectIcon(type);
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => handleAddWorkflowNode(type)}
|
||||
className="flex items-center gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-left transition-colors hover:bg-slate-50 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900"
|
||||
>
|
||||
<span
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-slate-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300"
|
||||
style={{ color: definition.accentColor }}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{definition.label}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
|
||||
{definition.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 px-2 py-3">
|
||||
{VIDEO_CANVAS_SIDEBAR_GROUPS.map((group) => {
|
||||
const GroupIcon = group.icon;
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsVideoModuleSidebarCollapsed(false);
|
||||
setVideoModuleSectionOpen((prev) => ({
|
||||
...prev,
|
||||
[group.id]: true,
|
||||
}));
|
||||
}}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 bg-white text-xs font-semibold text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-800 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
|
||||
title={group.label}
|
||||
>
|
||||
<GroupIcon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isVideoMode &&
|
||||
selectedWorkflowQuickConnectAnchor &&
|
||||
selectedWorkflowQuickConnectSourceNode &&
|
||||
@ -7502,7 +7759,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
{isVideoMode &&
|
||||
selectedVideoWorkflowDefinition &&
|
||||
selectedWorkflowIsActionable ? (
|
||||
<div className="pointer-events-none absolute left-4 top-16 z-20 flex w-[min(480px,calc(100vw-32px))] flex-col gap-3">
|
||||
<div
|
||||
className="pointer-events-none absolute top-16 z-20 flex w-[min(480px,calc(100vw-32px))] flex-col gap-3"
|
||||
style={{ left: `${selectedWorkflowPanelLeft}px` }}
|
||||
>
|
||||
{selectedWorkflowIsActionable ? (
|
||||
<Card className={VIDEO_CANVAS_PANEL_CLASS}>
|
||||
<CardHeader className="pb-3">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user