Simplify video workflow canvas UI
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-05 09:56:51 +08:00
parent 20d0529365
commit 021f497970
3 changed files with 349 additions and 465 deletions

View File

@ -643,11 +643,11 @@ const AGENT_RESULT_MODE_LABELS: Record<AgentGenerationResultModeType, string> =
sku_replacement: "SKU 替换",
};
const VIDEO_CANVAS_PANEL_CLASS =
"pointer-events-auto overflow-hidden rounded-[28px] border border-slate-200 bg-white text-foreground shadow-[0_20px_50px_-28px_rgba(15,23,42,0.24)] dark:border-zinc-800 dark:bg-zinc-950";
"pointer-events-auto overflow-hidden rounded-[24px] border border-slate-200 bg-white text-foreground shadow-[0_16px_36px_-24px_rgba(15,23,42,0.2)] dark:border-zinc-800 dark:bg-zinc-950";
const VIDEO_CANVAS_PANEL_BADGE_CLASS =
"border-slate-200 bg-white text-foreground/75 dark:border-zinc-800 dark:bg-zinc-950";
"border-slate-200 bg-slate-50 text-foreground/70 dark:border-zinc-800 dark:bg-zinc-900";
const VIDEO_CANVAS_SUBSECTION_CLASS =
"rounded-2xl border border-slate-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-950";
"rounded-[18px] border border-slate-200 bg-slate-50 p-3 dark:border-zinc-800 dark:bg-zinc-900";
const VIDEO_CANVAS_TOOLBAR_CLASS =
"flex items-center gap-1.5 rounded-[28px] border border-slate-200 bg-white px-2.5 py-1.5 shadow-[0_18px_40px_-24px_rgba(15,23,42,0.24)] dark:border-zinc-800 dark:bg-zinc-950";
const VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS =
@ -1088,6 +1088,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
const autoTitleInFlightRef = useRef<string | null>(null);
const autoThumbnailSyncedRef = useRef<string | null>(null);
const lastLoadedCanvasIdRef = useRef<string>("");
const normalizedVideoNodeSizeIdsRef = useRef<Set<string>>(new Set());
const importVideoCanvasInputRef = useRef<HTMLInputElement | null>(null);
const isAgentWorkspaceEnabled = siteSettingsData?.agentEnabled !== false;
const isCanvasAgentModeEnabled = siteSettingsData?.canvasAgentModeEnabled !== false;
@ -1112,6 +1113,38 @@ export default function CanvasEditorPage({ params }: PageProps) {
[siteSettingsData?.agentWorkflowBrandModelConfig]
);
useEffect(() => {
if (!isVideoMode || nodes.length === 0) {
return;
}
nodes.forEach((node) => {
if (normalizedVideoNodeSizeIdsRef.current.has(node.id)) {
return;
}
normalizedVideoNodeSizeIdsRef.current.add(node.id);
const templateType = inferVideoWorkflowTemplateType(node);
const definition = templateType ? getVideoWorkflowNodeDefinition(templateType) : null;
if (!definition) {
return;
}
const nextWidth = Math.min(node.size.width, definition.size.width);
const nextHeight = Math.min(node.size.height, definition.size.height);
if (nextWidth === node.size.width && nextHeight === node.size.height) {
return;
}
updateNode(node.id, {
size: {
width: nextWidth,
height: nextHeight,
},
});
});
}, [isVideoMode, nodes, updateNode]);
// 获取选中的图片节点
const selectedImageNode = selectedNodeIds.length === 1
? nodes.find(n => n.id === selectedNodeIds[0] && n.type === 'image')
@ -1185,10 +1218,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
: null;
const videoModuleSidebarWidth = isVideoMode
? isVideoModuleSidebarCollapsed
? 64
: 296
? 56
: 228
: 0;
const selectedWorkflowPanelLeft = isVideoMode ? videoModuleSidebarWidth + 28 : 16;
const selectedWorkflowPanelLeft = isVideoMode ? videoModuleSidebarWidth + 24 : 16;
const handleDeleteSelectedWorkflowNode = useCallback(() => {
if (!selectedVideoWorkflowNode) return;
deleteNode(selectedVideoWorkflowNode.id);
@ -7538,20 +7571,20 @@ export default function CanvasEditorPage({ params }: PageProps) {
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]"
"overflow-hidden rounded-[24px] border border-slate-200 bg-white shadow-[0_18px_40px_-28px_rgba(15,23,42,0.18)] dark:border-zinc-800 dark:bg-zinc-950",
isVideoModuleSidebarCollapsed ? "w-14" : "w-[228px]"
)}>
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-3 dark:border-zinc-800">
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-2.5 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 className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 text-slate-500 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
<Layers className="h-4 w-4" />
</span>
<div className="text-sm font-semibold text-foreground">模块</div>
</div>
)}
<Button
@ -7571,7 +7604,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
{!isVideoModuleSidebarCollapsed ? (
<ScrollArea className="max-h-[calc(100vh-164px)]">
<div className="space-y-3 p-3">
<div className="space-y-2 p-2">
{VIDEO_CANVAS_SIDEBAR_GROUPS.map((group) => {
const GroupIcon = group.icon;
return (
@ -7585,31 +7618,31 @@ export default function CanvasEditorPage({ params }: PageProps) {
}))
}
>
<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">
<div className="rounded-[20px] 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-3 py-2.5 text-left">
<div className="flex min-w-0 items-center gap-2.5">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-2xl 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 className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{group.label}
</span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
videoModuleSectionOpen[group.id] !== false ? "rotate-0" : "-rotate-90"
)}
/>
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-muted-foreground">
{group.nodeTypes.length}
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
videoModuleSectionOpen[group.id] !== false ? "rotate-0" : "-rotate-90"
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-slate-200 px-3 py-3 dark:border-zinc-800">
<div className="grid gap-2">
<div className="border-t border-slate-200 px-2 pb-2 pt-2 dark:border-zinc-800">
<div className="grid grid-cols-2 gap-1.5">
{group.nodeTypes.map((type) => {
const definition = getVideoWorkflowNodeDefinition(type);
if (!definition) return null;
@ -7619,21 +7652,17 @@ export default function CanvasEditorPage({ params }: PageProps) {
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"
className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-white px-2.5 py-2 text-left transition-colors hover:bg-slate-50 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900"
title={definition.label}
>
<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"
className="flex h-8 w-8 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 className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{definition.label}
</span>
</button>
);
@ -7648,7 +7677,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div>
</ScrollArea>
) : (
<div className="flex flex-col items-center gap-3 px-2 py-3">
<div className="flex flex-col items-center gap-2 px-2 py-2">
{VIDEO_CANVAS_SIDEBAR_GROUPS.map((group) => {
const GroupIcon = group.icon;
return (
@ -7662,7 +7691,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
[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"
className="flex h-10 w-10 items-center justify-center rounded-2xl 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" />
@ -7767,25 +7796,25 @@ export default function CanvasEditorPage({ params }: PageProps) {
selectedVideoWorkflowDefinition &&
selectedWorkflowIsActionable ? (
<div
className="pointer-events-none absolute top-16 z-20 flex w-[min(480px,calc(100vw-32px))] flex-col gap-3"
className="pointer-events-none absolute top-16 z-20 flex w-[min(388px,calc(100vw-32px))] flex-col gap-3"
style={{ left: `${selectedWorkflowPanelLeft}px` }}
>
{selectedWorkflowIsActionable ? (
<Card className={VIDEO_CANVAS_PANEL_CLASS}>
<CardHeader className="pb-3">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">工作流执行</CardTitle>
<CardDescription className="mt-1 line-clamp-2">
<CardTitle className="text-sm">{selectedVideoWorkflowDefinition.label}</CardTitle>
<CardDescription className="mt-1 line-clamp-1 text-xs">
{selectedWorkflowActionHint}
</CardDescription>
</div>
<Badge variant="outline" className={VIDEO_CANVAS_PANEL_BADGE_CLASS}>
Phase 4
执行
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4 pb-5">
<CardContent className="space-y-3 pb-4">
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className={VIDEO_CANVAS_PANEL_BADGE_CLASS}>
提示词 {selectedWorkflowInputs.promptCount}
@ -7847,10 +7876,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div>
<div className={VIDEO_CANVAS_SUBSECTION_CLASS}>
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
上游提示
</div>
<div className="mt-2 line-clamp-4 whitespace-pre-line text-sm leading-6 text-foreground">
<div className="line-clamp-3 whitespace-pre-line text-xs leading-5 text-foreground/85">
{selectedWorkflowPrompt.trim()
? selectedWorkflowPrompt
: "当前还没有抽取到可执行提示词,先在上游接入脚本、分镜或参考素材。"}
@ -7866,9 +7892,6 @@ export default function CanvasEditorPage({ params }: PageProps) {
<div className="mt-2 text-sm font-medium text-foreground">
{selectedWorkflowLinkedAgentProject.title || selectedWorkflowLinkedAgentProject.id}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
再点下方按钮会同步当前视频画布上下文,并直接打开该项目。
</div>
</div>
) : null}

View File

@ -79,6 +79,16 @@ const STATUS_LABELS = {
failed: "异常",
} as const;
const STATUS_CLASS_MAP = {
pending: "border-slate-200 bg-white text-foreground/70 dark:border-zinc-800 dark:bg-zinc-950",
generating:
"border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/40 dark:text-blue-200",
completed:
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/40 dark:text-emerald-200",
failed:
"border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/40 dark:bg-rose-950/40 dark:text-rose-200",
} as const;
export function WorkflowNode({ node, isSelected }: WorkflowNodeProps) {
const deleteNode = useCanvasStore((state) => state.deleteNode);
const data = node.data as WorkflowNodeData;
@ -92,42 +102,36 @@ export function WorkflowNode({ node, isSelected }: WorkflowNodeProps) {
const groupMeta = GROUP_META[resolvedGroup] || GROUP_META.tool;
const GroupIcon = groupMeta.icon;
const accentColor = data.accentColor || definition?.accentColor || "#e2e8f0";
const accentBarColor = toAlphaColor(accentColor, 0.92) || accentColor;
const title = data.title || definition?.label || "工作流节点";
const description = data.description || definition?.description || "等待补充节点配置。";
const previewUrl =
String(metadata.previewMediaUrl || metadata.resultMediaUrl || "").trim();
const statusLabel = STATUS_LABELS[data.status] || STATUS_LABELS.pending;
const statusClassName =
STATUS_CLASS_MAP[data.status] || STATUS_CLASS_MAP.pending;
const previewUrl = String(metadata.previewMediaUrl || metadata.resultMediaUrl || "").trim();
const previewKind =
String(metadata.previewMediaKind || metadata.resultMediaKind || "").trim() === "video"
? "video"
: "image";
const compareItems = Array.isArray(metadata.compareItems)
? metadata.compareItems.slice(0, 4)
: [];
const storyboardShots = Array.isArray(metadata.storyboardShots)
? metadata.storyboardShots.slice(0, 4)
: [];
const analysisHighlights = Array.isArray(metadata.analysisHighlights)
? metadata.analysisHighlights.slice(0, 4)
: [];
const compareItems = Array.isArray(metadata.compareItems)
? metadata.compareItems.slice(0, 4)
: [];
const tableColumns = Array.isArray(metadata.tableColumns)
? metadata.tableColumns.slice(0, 4)
: [];
const tableRows = Array.isArray(metadata.tableRows)
? metadata.tableRows.slice(0, 4)
: [];
const characters = Array.isArray(metadata.characters)
? metadata.characters.slice(0, 3)
: [];
const scenes = Array.isArray(metadata.scenes) ? metadata.scenes.slice(0, 3) : [];
const tableRows = Array.isArray(metadata.tableRows) ? metadata.tableRows.slice(0, 4) : [];
const characters = Array.isArray(metadata.characters) ? metadata.characters.slice(0, 4) : [];
const scenes = Array.isArray(metadata.scenes) ? metadata.scenes.slice(0, 4) : [];
const characterAssets = Array.isArray(metadata.characterAssets)
? metadata.characterAssets.slice(0, 3)
? metadata.characterAssets.slice(0, 4)
: [];
const sceneAssets = Array.isArray(metadata.sceneAssets)
? metadata.sceneAssets.slice(0, 3)
: [];
const agentPlan = Array.isArray(metadata.agentPlan)
? metadata.agentPlan.slice(0, 4)
? metadata.sceneAssets.slice(0, 4)
: [];
const agentPlan = Array.isArray(metadata.agentPlan) ? metadata.agentPlan.slice(0, 4) : [];
const deliverables = Array.isArray(metadata.deliverables)
? metadata.deliverables.slice(0, 4)
: [];
@ -135,432 +139,209 @@ export function WorkflowNode({ node, isSelected }: WorkflowNodeProps) {
const linkedAgentProjectTitle = String(metadata.linkedAgentProjectTitle || "").trim();
const importedAgentResultCount = Math.max(Number(metadata.importedAgentResultCount || 0), 0);
const pendingAgentResultCount = Math.max(Number(metadata.pendingAgentResultCount || 0), 0);
const agentResultGroups = Array.isArray(metadata.agentResultGroups)
? metadata.agentResultGroups.slice(0, 4)
: [];
const batchShotCount = Math.max(
Number(metadata.batchShotCount || 0),
storyboardShots.length
);
const batchResultCount = Number(metadata.batchResultCount || 0);
const batchFailedCount = Number(metadata.batchFailedCount || 0);
const batchMode =
String(metadata.batchMode || "").trim() === "gen-video" ? "视频批量" : "图片批量";
const sectionClassName =
"rounded-2xl border border-slate-200 bg-white p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] dark:border-zinc-800 dark:bg-zinc-950";
const mutedKickerClassName =
"text-[11px] uppercase tracking-[0.18em] text-muted-foreground/80";
const itemClassName =
"rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900";
const badgeClassName =
"border-slate-200 bg-white text-foreground/80 dark:border-zinc-800 dark:bg-zinc-950";
const accentBarColor = toAlphaColor(accentColor, 0.9) || accentColor;
const batchResultCount = Math.max(Number(metadata.batchResultCount || 0), 0);
const batchFailedCount = Math.max(Number(metadata.batchFailedCount || 0), 0);
const comparePreviewItems = compareItems
.map((item, index) => {
const compareItem = (item || {}) as Record<string, any>;
const url = String(compareItem.url || "").trim();
if (!url) return null;
return {
id: `${data.templateType}-compare-${index}`,
url,
kind: String(compareItem.kind || "").trim() === "video" ? "video" : "image",
label: String(compareItem.label || compareItem.title || `对比 ${index + 1}`).trim(),
};
})
.filter(Boolean) as Array<{ id: string; url: string; kind: "image" | "video"; label: string }>;
const compactMetrics: string[] = [];
if (batchShotCount > 0) compactMetrics.push(`镜头 ${batchShotCount}`);
if (batchResultCount > 0) compactMetrics.push(`结果 ${batchResultCount}`);
if (analysisHighlights.length > 0) compactMetrics.push(`分析 ${analysisHighlights.length}`);
if (tableRows.length > 0) compactMetrics.push(`表格 ${tableRows.length}`);
if (characters.length > 0) compactMetrics.push(`角色 ${characters.length}`);
if (scenes.length > 0) compactMetrics.push(`场景 ${scenes.length}`);
if (characterAssets.length > 0) compactMetrics.push(`角色资产 ${characterAssets.length}`);
if (sceneAssets.length > 0) compactMetrics.push(`场景资产 ${sceneAssets.length}`);
if (linkedAgentProjectId) compactMetrics.push("Agent");
if (importedAgentResultCount > 0) compactMetrics.push(`已回流 ${importedAgentResultCount}`);
if (pendingAgentResultCount > 0) compactMetrics.push(`待回流 ${pendingAgentResultCount}`);
if (batchFailedCount > 0) compactMetrics.push(`失败 ${batchFailedCount}`);
const primaryTextCandidates = [
String(data.summary || "").trim(),
String(analysisHighlights[0] || "").trim(),
String((storyboardShots[0] as Record<string, any>)?.title || "").trim(),
String((storyboardShots[0] as Record<string, any>)?.description || "").trim(),
String((characters[0] as Record<string, any>)?.description || "").trim(),
String((scenes[0] as Record<string, any>)?.description || "").trim(),
String(agentPlan[0] || "").trim(),
String(deliverables[0] || "").trim(),
quickConnectOutputs.length > 0 ? `可接 ${quickConnectOutputs.map((item) => item.label).join(" / ")}` : "",
String(definition?.description || data.description || "").trim(),
].filter(Boolean);
const primaryText = primaryTextCandidates[0] || "";
const compactDetailLines = [
storyboardShots[0]
? `分镜:${String((storyboardShots[0] as Record<string, any>)?.title || "镜头草案").trim()}`
: "",
characters[0]
? `角色:${String((characters[0] as Record<string, any>)?.name || (characters[0] as Record<string, any>)?.title || "角色线索").trim()}`
: "",
scenes[0]
? `场景:${String((scenes[0] as Record<string, any>)?.name || (scenes[0] as Record<string, any>)?.title || "场景线索").trim()}`
: "",
tableRows[0] ? "已整理结构化表格" : "",
linkedAgentProjectId ? `Agent:${linkedAgentProjectTitle || linkedAgentProjectId}` : "",
deliverables[0] ? `交付:${String(deliverables[0]).trim()}` : "",
]
.filter(Boolean)
.slice(0, 2);
return (
<div
className={cn(
"relative h-full w-full overflow-hidden rounded-[28px] border border-slate-200 bg-white shadow-[0_22px_55px_-30px_rgba(15,23,42,0.18)] transition-all dark:border-zinc-800 dark:bg-zinc-950",
"relative h-full w-full overflow-hidden rounded-[22px] border border-slate-200 bg-white shadow-[0_18px_38px_-26px_rgba(15,23,42,0.18)] transition-all dark:border-zinc-800 dark:bg-zinc-950",
isSelected &&
"border-primary/55 ring-2 ring-primary/15 shadow-[0_24px_56px_-30px_rgba(59,130,246,0.24)]"
"border-primary/60 ring-2 ring-primary/15 shadow-[0_20px_42px_-24px_rgba(59,130,246,0.24)]"
)}
>
<div
className="pointer-events-none absolute inset-x-0 top-0 h-1"
style={{ backgroundColor: accentBarColor }}
/>
<div className="pointer-events-none absolute inset-x-0 top-1 h-px bg-slate-200 dark:bg-zinc-800" />
<div className="relative flex h-full flex-col gap-4 p-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-slate-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-950">
<GroupIcon className="h-4 w-4" />
</span>
<span>{groupMeta.label}</span>
</div>
<div>
<h3 className="text-base font-semibold leading-tight text-foreground">{title}</h3>
<p className="mt-1 line-clamp-2 text-sm leading-6 text-muted-foreground">
{description}
</p>
</div>
</div>
<div className="flex items-start gap-2">
{isSelected ? (
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
deleteNode(node.id);
}}
className="flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 bg-white text-muted-foreground transition-colors hover:border-destructive/30 hover:bg-red-50 hover:text-destructive dark:border-zinc-800 dark:bg-zinc-950 dark:hover:border-destructive/40 dark:hover:bg-red-950/30"
aria-label="删除模块"
title="删除模块"
>
<Trash2 className="h-4 w-4" />
</button>
) : null}
<Badge variant="outline" className={badgeClassName}>
{STATUS_LABELS[data.status] || STATUS_LABELS.pending}
</Badge>
</div>
</div>
<div className="flex h-full flex-col gap-3 p-3">
<div className="flex items-start gap-3">
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
style={{ color: accentColor }}
>
<GroupIcon className="h-4 w-4" />
</span>
<div className={sectionClassName}>
<div className={mutedKickerClassName}>节点标识</div>
<div className="mt-1 font-mono text-xs text-foreground/80">{data.templateType}</div>
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted-foreground">
{data.summary || "接在上游素材之后,用来处理或输出结果。"}
</p>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="mb-1 flex items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{groupMeta.label}
</span>
<Badge variant="outline" className={cn("h-6 rounded-full px-2 text-[11px]", statusClassName)}>
{statusLabel}
</Badge>
</div>
<h3 className="truncate text-sm font-semibold text-foreground">{title}</h3>
{primaryText ? (
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{primaryText}
</p>
) : null}
</div>
{batchShotCount > 0 || batchResultCount > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>批量执行</div>
<div className="mt-2 flex flex-wrap gap-2">
{batchShotCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
镜头 {batchShotCount}
</Badge>
) : null}
{batchResultCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
结果 {batchResultCount}
</Badge>
) : null}
{batchFailedCount > 0 ? (
<Badge
variant="outline"
className="border-amber-200/70 bg-amber-50 text-amber-700"
{isSelected ? (
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
deleteNode(node.id);
}}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white text-muted-foreground transition-colors hover:border-destructive/30 hover:bg-red-50 hover:text-destructive dark:border-zinc-800 dark:bg-zinc-950 dark:hover:border-destructive/40 dark:hover:bg-red-950/30"
aria-label="删除模块"
title="删除模块"
>
失败 {batchFailedCount}
</Badge>
) : null}
{batchResultCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
{batchMode}
</Badge>
<Trash2 className="h-4 w-4" />
</button>
) : null}
</div>
</div>
) : null}
</div>
{previewUrl ? (
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-slate-950 shadow-[0_16px_40px_-28px_rgba(15,23,42,0.65)] dark:border-zinc-800">
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-slate-950 dark:border-zinc-800">
{previewKind === "video" ? (
<video
src={previewUrl}
className="h-36 w-full object-cover"
className="h-28 w-full object-cover"
controls
muted
playsInline
/>
) : (
<img
src={previewUrl}
alt={title}
className="h-36 w-full object-cover"
/>
<img src={previewUrl} alt={title} className="h-28 w-full object-cover" />
)}
</div>
) : null}
{compareItems.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>对比结果</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{compareItems.map((item, index) => {
const compareItem = (item || {}) as Record<string, any>;
const kind =
String(compareItem.kind || "").trim() === "video" ? "video" : "image";
const url = String(compareItem.url || "").trim();
if (!url) {
return null;
}
return (
<div
key={`${data.templateType}-compare-${index}`}
className="overflow-hidden rounded-2xl border border-slate-200 bg-white dark:border-zinc-800 dark:bg-zinc-950"
>
<div className="border-b border-slate-200 px-3 py-2 dark:border-zinc-800">
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
{String(compareItem.label || `对比 ${index + 1}`)}
</div>
<div className="mt-1 truncate text-xs font-medium text-foreground/90">
{String(compareItem.title || `素材 ${index + 1}`)}
</div>
</div>
{kind === "video" ? (
<video
src={url}
className="h-24 w-full object-cover"
controls
muted
playsInline
/>
) : (
<img
src={url}
alt={String(compareItem.title || `对比素材 ${index + 1}`)}
className="h-24 w-full object-cover"
/>
)}
</div>
);
})}
</div>
) : comparePreviewItems.length > 0 ? (
<div className="grid grid-cols-2 gap-2">
{comparePreviewItems.slice(0, 4).map((item) => (
<div
key={item.id}
className="overflow-hidden rounded-2xl border border-slate-200 bg-white dark:border-zinc-800 dark:bg-zinc-950"
>
{item.kind === "video" ? (
<video
src={item.url}
className="h-20 w-full object-cover"
muted
playsInline
/>
) : (
<img src={item.url} alt={item.label} className="h-20 w-full object-cover" />
)}
</div>
))}
</div>
) : null}
{storyboardShots.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>镜头草案</div>
<div className="mt-2 space-y-2">
{storyboardShots.map((item, index) => {
const shot = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-shot-${index}`} className={itemClassName}>
<div className="text-xs font-semibold text-foreground/90">
{String(shot.title || `镜头 ${index + 1}`)}
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{String(shot.description || "")}
</div>
</div>
);
})}
</div>
{compactMetrics.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{compactMetrics.slice(0, 6).map((metric) => (
<span
key={`${data.templateType}-${metric}`}
className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] font-medium text-foreground/80 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-200"
>
{metric}
</span>
))}
</div>
) : null}
{tableColumns.length > 0 && tableRows.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>结构化表格</div>
<div className="mt-2 flex flex-wrap gap-2">
{tableColumns.map((item, index) => {
const column = (item || {}) as Record<string, any>;
return (
<Badge
key={`${data.templateType}-column-${index}`}
variant="outline"
className={badgeClassName}
>
{String(column.label || column.key || `列 ${index + 1}`)}
</Badge>
);
})}
</div>
<div className="mt-2 space-y-2">
{tableRows.map((item, index) => {
const row = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-row-${index}`} className={itemClassName}>
{tableColumns.map((columnItem, columnIndex) => {
const column = (columnItem || {}) as Record<string, any>;
const columnKey = String(column.key || `col-${columnIndex}`).trim();
const label = String(column.label || columnKey || `列 ${columnIndex + 1}`).trim();
const value = String(row[columnKey] || "").trim();
if (!value) {
return null;
}
return (
<div
key={`${data.templateType}-row-${index}-column-${columnIndex}`}
className={columnIndex > 0 ? "mt-2" : ""}
>
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
{label}
</div>
<div className="mt-1 line-clamp-3 text-xs leading-5 text-foreground/85">
{value}
</div>
</div>
);
})}
</div>
);
})}
</div>
{compactDetailLines.length > 0 ? (
<div className="grid gap-1.5">
{compactDetailLines.map((line) => (
<div
key={`${data.templateType}-${line}`}
className="rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs leading-5 text-foreground/80 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-200"
>
{line}
</div>
))}
</div>
) : null}
{analysisHighlights.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>分析摘要</div>
<div className="mt-2 space-y-2">
{analysisHighlights.map((item, index) => (
<div
key={`${data.templateType}-analysis-${index}`}
className={cn(itemClassName, "line-clamp-3 text-xs leading-5 text-muted-foreground")}
>
{String(item || "")}
</div>
))}
</div>
</div>
) : null}
{characters.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>角色线索</div>
<div className="mt-2 space-y-2">
{characters.map((item, index) => {
const character = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-character-${index}`} className={itemClassName}>
<div className="text-xs font-semibold text-foreground/90">
{String(character.name || character.title || `角色 ${index + 1}`)}
</div>
<div className="mt-1 line-clamp-3 text-xs leading-5 text-muted-foreground">
{String(character.description || character.prompt || "")}
</div>
</div>
);
})}
</div>
</div>
) : null}
{scenes.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>场景线索</div>
<div className="mt-2 space-y-2">
{scenes.map((item, index) => {
const scene = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-scene-${index}`} className={itemClassName}>
<div className="text-xs font-semibold text-foreground/90">
{String(scene.name || scene.title || `场景 ${index + 1}`)}
</div>
<div className="mt-1 line-clamp-3 text-xs leading-5 text-muted-foreground">
{String(scene.description || scene.prompt || "")}
</div>
</div>
);
})}
</div>
</div>
) : null}
{characterAssets.length > 0 || sceneAssets.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>资产沉淀</div>
<div className="mt-2 space-y-2">
{[...characterAssets, ...sceneAssets].slice(0, 4).map((item, index) => {
const asset = (item || {}) as Record<string, any>;
return (
<div key={`${data.templateType}-asset-${index}`} className={itemClassName}>
<div className="text-xs font-semibold text-foreground/90">
{String(asset.name || asset.title || `资产 ${index + 1}`)}
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
{String(asset.description || asset.prompt || "")}
</div>
</div>
);
})}
</div>
</div>
) : null}
{agentPlan.length > 0 || deliverables.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>代理计划</div>
<div className="mt-2 space-y-2">
{agentPlan.map((item, index) => (
<div
key={`${data.templateType}-plan-${index}`}
className={cn(itemClassName, "text-xs leading-5 text-muted-foreground")}
>
{String(item || "")}
</div>
))}
{deliverables.map((item, index) => (
<div
key={`${data.templateType}-deliverable-${index}`}
className="rounded-xl border border-dashed border-border/60 bg-background px-3 py-2 text-xs leading-5 text-muted-foreground"
>
交付物: {String(item || "")}
</div>
))}
</div>
</div>
) : null}
{linkedAgentProjectId ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>Agent 工作台</div>
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="outline" className={badgeClassName}>
已连接
</Badge>
{importedAgentResultCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
已回流 {importedAgentResultCount}
</Badge>
) : null}
{pendingAgentResultCount > 0 ? (
<Badge variant="outline" className={badgeClassName}>
待回流 {pendingAgentResultCount}
</Badge>
) : null}
</div>
<div className="mt-2 text-xs font-medium leading-5 text-foreground/90">
{linkedAgentProjectTitle || linkedAgentProjectId}
</div>
</div>
) : null}
{agentResultGroups.length > 0 ? (
<div className={sectionClassName}>
<div className={mutedKickerClassName}>Agent 结果</div>
<div className="mt-2 space-y-2">
{agentResultGroups.map((item, index) => {
const group = (item || {}) as Record<string, any>;
const label = String(group.label || `结果组 ${index + 1}`).trim();
const count = Math.max(Number(group.count || 0), 0);
const importedCount = Math.max(Number(group.importedCount || 0), 0);
return (
<div
key={`${data.templateType}-agent-group-${index}`}
className={itemClassName}
>
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-semibold text-foreground/90">{label}</div>
<div className="text-[11px] text-muted-foreground">
{importedCount}/{count}
</div>
</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
共 {count} 个结果,已回流 {importedCount} 个。
</div>
</div>
);
})}
</div>
</div>
) : null}
<div className="mt-auto space-y-2">
<div className={mutedKickerClassName}>下一步推荐</div>
<div className="mt-auto flex flex-wrap gap-1.5">
{quickConnectOutputs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{quickConnectOutputs.slice(0, 4).map((item) => (
<span
key={`${data.templateType}-${item.type}`}
className="rounded-full border border-border/60 bg-background px-2.5 py-1 text-xs text-foreground/80 shadow-sm"
>
{item.label}
</span>
))}
</div>
quickConnectOutputs.slice(0, 4).map((item) => (
<span
key={`${data.templateType}-${item.type}`}
className="rounded-full border border-dashed border-slate-200 px-2.5 py-1 text-[11px] text-muted-foreground dark:border-zinc-800"
>
{item.label}
</span>
))
) : (
<div className="rounded-full border border-dashed border-border/70 bg-background px-3 py-1.5 text-xs text-muted-foreground">
当前节点通常作为流程终点或独立处理节点
</div>
<span className="rounded-full border border-dashed border-slate-200 px-2.5 py-1 text-[11px] text-muted-foreground dark:border-zinc-800">
结束节点
</span>
)}
</div>
</div>

View File

@ -126,6 +126,86 @@ const SECTION_DEFINITIONS: VideoWorkflowSectionDefinition[] = [
{ id: "output", label: "输出", description: "预览、对比和保存结果" },
];
const COMPACT_NODE_SIZE_OVERRIDES: Partial<
Record<VideoWorkflowTemplateType, { width: number; height: number }>
> = {
"text-node": { width: 300, height: 170 },
"novel-input": { width: 340, height: 240 },
"input-image": { width: 220, height: 220 },
"video-input": { width: 240, height: 220 },
"video-analyze": { width: 300, height: 210 },
"extract-characters-scenes": { width: 300, height: 210 },
"storyboard-node": { width: 380, height: 240 },
"storyboard-chart-node": { width: 360, height: 220 },
"table-editor-node": { width: 340, height: 220 },
"gen-image": { width: 280, height: 190 },
"gen-video": { width: 280, height: 190 },
"gen-music": { width: 300, height: 200 },
"jimeng-super-resolution": { width: 260, height: 160 },
"motion-control": { width: 300, height: 220 },
"inpaint-crop": { width: 280, height: 180 },
"inpaint-stitch": { width: 280, height: 180 },
"professional-camera": { width: 280, height: 180 },
"camera-movement": { width: 280, height: 180 },
"image-compare": { width: 320, height: 220 },
preview: { width: 320, height: 220 },
"local-save": { width: 260, height: 180 },
"character-description": { width: 300, height: 180 },
"scene-description": { width: 300, height: 180 },
"create-character": { width: 280, height: 160 },
"create-scene": { width: 280, height: 160 },
"generate-character-image": { width: 300, height: 200 },
"generate-character-video": { width: 300, height: 200 },
"generate-scene-image": { width: 300, height: 200 },
"generate-scene-video": { width: 300, height: 200 },
"custom-agent": { width: 340, height: 220 },
"rh-app": { width: 280, height: 180 },
"rh-comfy": { width: 280, height: 180 },
"canvas-node": { width: 360, height: 240 },
"doodle-canvas": { width: 300, height: 180 },
};
function resolveCompactNodeSize(
type: VideoWorkflowTemplateType,
canvasNodeType: NodeType,
size: { width: number; height: number }
) {
const override = COMPACT_NODE_SIZE_OVERRIDES[type];
if (override) {
return override;
}
if (canvasNodeType === "workflow") {
return {
width: Math.min(size.width, 300),
height: Math.min(size.height, 180),
};
}
if (canvasNodeType === "text") {
return {
width: Math.min(size.width, 320),
height: Math.min(size.height, 180),
};
}
if (canvasNodeType === "image") {
return {
width: Math.min(size.width, 220),
height: Math.min(size.height, 220),
};
}
if (canvasNodeType === "video") {
return {
width: Math.min(size.width, 240),
height: Math.min(size.height, 220),
};
}
return size;
}
const createDefinition = (
type: VideoWorkflowTemplateType,
label: string,
@ -139,7 +219,7 @@ const createDefinition = (
label,
description,
group,
size,
size: resolveCompactNodeSize(type, canvasNodeType, size),
canvasNodeType,
accentColor,
quickConnectOutputs: QUICK_CONNECT_CONFIG[type] || [],
@ -625,10 +705,10 @@ export function buildVideoWorkflowCanvasNode(
description: definition.description,
summary:
definition.quickConnectOutputs.length > 0
? `下一步可接:${definition.quickConnectOutputs
? `可接:${definition.quickConnectOutputs
.map((item) => item.label)
.join(" / ")}`
: "当前节点主要承担中间处理或输出检查。",
: "当前节点用于处理中间结果或输出检查。",
status: "pending",
group: definition.group,
accentColor: definition.accentColor,
@ -645,7 +725,7 @@ export function buildVideoWorkflowStarterTemplateGraph(
const template =
VIDEO_WORKFLOW_STARTER_TEMPLATES.find((item) => item.id === templateId) ||
VIDEO_WORKFLOW_STARTER_TEMPLATES[0];
const laneHeight = 540;
const laneHeight = 320;
let cursorX = origin.x;
const nodes = template.nodes.map((type) => {
@ -657,7 +737,7 @@ export function buildVideoWorkflowStarterTemplateGraph(
x: cursorX,
y: origin.y + Math.max(0, (laneHeight - definition.size.height) / 2),
};
cursorX += definition.size.width + 180;
cursorX += definition.size.width + 96;
return {
type,
node: buildVideoWorkflowCanvasNode(type, position),