diff --git a/web/src/components/canvas/canvas-script-node.tsx b/web/src/components/canvas/canvas-script-node.tsx
index d496885f7..a7bec814e 100644
--- a/web/src/components/canvas/canvas-script-node.tsx
+++ b/web/src/components/canvas/canvas-script-node.tsx
@@ -1,7 +1,8 @@
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
-import { Button, Checkbox, Input, InputNumber, Modal, Popover, Segmented, Select, Table, Tooltip } from "antd";
+import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
+import { Button, Checkbox, Dropdown, Input, InputNumber, Modal, Segmented, Select, Table, Tooltip } from "antd";
+import type { MenuProps } from "antd";
import type { ColumnsType } from "antd/es/table";
-import { ChevronDown, ChevronUp, Clapperboard, Copy, Expand, Film, Grid3X3, Image as ImageIcon, ListTree, Merge, Minus, Plus, RefreshCw, Send, Square, Trash2, Video, X } from "lucide-react";
+import { ChevronDown, ChevronUp, Clapperboard, Copy, Expand, Film, Grid3X3, Image as ImageIcon, ListTree, Merge, Minus, MoreHorizontal, Plus, RefreshCw, Send, Square, Trash2, Video, X } from "lucide-react";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { ModelPicker } from "@/components/model-picker";
@@ -68,8 +69,8 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
onOpen: () => void;
onCreateImageNodes: () => void;
onCreateVideoNodes: () => void;
- onGenerateImages: () => void;
- onGenerateVideos: () => void;
+ onGenerateImages: (rowIds: string[]) => void;
+ onGenerateVideos: (rowIds: string[]) => void;
onVideoInputModeChange: (mode: StoryboardVideoInputMode) => void;
onMergeVideos: () => void;
onCreateActionBoards: () => void;
@@ -113,6 +114,34 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
const taskFeedback = node.metadata?.status === "loading"
? `${node.metadata.taskStage || "正在创建任务"}${typeof node.metadata.taskProgress === "number" ? ` · ${node.metadata.taskProgress}%` : ""}`
: node.metadata?.status === "error" ? generationErrorMessage(node.metadata.errorDetails) : "";
+ const [batchDetailsOpen, setBatchDetailsOpen] = useState(false);
+ const pipelineDisabled = !rows.length || node.metadata?.status === "loading" || hasActiveBatchItems;
+ const missingImages = Math.max(0, pipeline.images.total - pipeline.images.created);
+ const missingVideos = Math.max(0, pipeline.videos.total - pipeline.videos.created);
+ const canMerge = pipeline.successfulVideoNodeIds.length >= 2 && pipeline.final.success === 0;
+ const allRowIds = pipeline.rows.map((item) => item.row.id);
+ const moreMenuItems: MenuProps["items"] = [
+ { key: "generate-images", icon: , label: "生成未完成分镜图", disabled: pipelineDisabled || pipeline.images.incomplete === 0, onClick: () => onGenerateImages(allRowIds) },
+ { key: "generate-videos", icon: , label: "生成未完成视频", disabled: pipelineDisabled || pipeline.videos.incomplete === 0, onClick: () => onGenerateVideos(allRowIds) },
+ { key: "merge", icon: , label: pipeline.final.success ? "成片已完成" : pipeline.successfulVideoNodeIds.length >= 2 ? `合并 ${pipeline.successfulVideoNodeIds.length} 段视频` : "合并成片(至少 2 段视频)", disabled: !canMerge, onClick: () => onMergeVideos() },
+ { type: "divider" },
+ { key: "video-input", icon: , label: "视频输入模式", children: [
+ { key: "video-input-direct", label: videoInputMode === "direct" ? "✓ 直接生成" : "直接生成", onClick: () => onVideoInputModeChange("direct") },
+ { key: "video-input-keyframe", label: videoInputMode === "keyframe" ? "✓ 先做首帧" : "先做首帧", onClick: () => onVideoInputModeChange("keyframe") },
+ ] },
+ ...(!simpleMode ? [
+ { type: "divider" as const },
+ { key: "create-image-nodes", icon: , label: missingImages ? `创建 ${missingImages} 个图片节点` : "图片节点已创建", disabled: pipelineDisabled || missingImages === 0, onClick: () => onCreateImageNodes() },
+ { key: "create-video-nodes", icon: , label: missingVideos ? `创建 ${missingVideos} 个视频节点` : "视频节点已创建", disabled: pipelineDisabled || missingVideos === 0, onClick: () => onCreateVideoNodes() },
+ { key: "action-boards", icon: , label: "生成动作拆分 12 宫格", disabled: !rows.length || hasActiveBatchItems, onClick: () => onCreateActionBoards() },
+ ] : []),
+ ...(batch ? [
+ { type: "divider" as const },
+ { key: "retry", icon: , label: "重试失败项", disabled: !hasFailedBatchItems, onClick: () => onRetryBatch(batch.id) },
+ { key: "stop", icon: , label: "停止剩余任务", disabled: !hasWaitingBatchItems, onClick: () => onStopBatch(batch.id) },
+ { key: "details", icon: , label: "查看批次详情", onClick: () => setBatchDetailsOpen(true) },
+ ] : []),
+ ];
const submitPrompt = () => {
const value = prompt.trim();
if (value && node.metadata?.status !== "loading") onGenerateScript(value);
@@ -135,27 +164,15 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
{node.title || "分镜脚本"}
{batchSummary ? {batchSummary} : taskFeedback ? {taskFeedback} : null}
{rows.length} 镜 · {totalDuration}s
- {batch ? <>
- {hasFailedBatchItems ? : null}
- {hasWaitingBatchItems ? : null}
- onRetryBatchItem(batch.id, itemId)} onCancelItem={(itemId) => onCancelBatchItem(batch.id, itemId)} />}>
- > : null}
- {simpleMode ? null : }
- {simpleMode ? null : }
+
+
+
+
-
+ {batch ? setBatchDetailsOpen(false)} footer={null} width={560} centered destroyOnHidden>
+ onRetryBatchItem(batch.id, itemId)} onCancelItem={(itemId) => onCancelBatchItem(batch.id, itemId)} />
+ : null}
+
序号
时长
@@ -187,7 +204,14 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
- )) : }
+ )) : (
+
+ )}
@@ -233,26 +257,26 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
/>
- {simpleMode ? 自动拆分 · 默认时长 :