Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-script-node.tsx

482 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { Button, Checkbox, Input, InputNumber, Modal, Popover, Select, Table, Tooltip } 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 { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { ModelPicker } from "@/components/model-picker";
import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { pipelineStatusLabel, type CanvasStoryboardPipelineProgress, type StoryboardPipelineStage } from "@/lib/canvas/canvas-storyboard-progress";
import { isContentModerationError } from "@/lib/generation-error";
import { navigateToSettings } from "@/lib/settings-navigation";
import { canvasThemes } from "@/lib/canvas-theme";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasGenerationBatchItemStatus, CanvasNodeData, CanvasNodeStatus, CanvasWorkspaceMode, StoryboardColumn, StoryboardRow, StoryboardShotCount, StoryboardShotDuration } from "@/types/canvas";
export const STORYBOARD_ROW_HEIGHT = 48;
export const STORYBOARD_HEADER_HEIGHT = 124;
const STORYBOARD_ADD_ROW_HEIGHT = 36;
const STORYBOARD_COMPOSER_MIN_HEIGHT = 104;
const STORYBOARD_COMPOSER_MAX_HEIGHT = 180;
const STORYBOARD_PROMPT_MIN_HEIGHT = 40;
const STORYBOARD_PROMPT_MAX_HEIGHT = 116;
const SCRIPT_GRID_TEMPLATE = "72px 150px minmax(280px, 1.4fr) minmax(220px, 1fr) 58px";
const EMPTY_STORYBOARD_ROWS: StoryboardRow[] = [];
export function storyboardNodeHeight(rowCount: number, composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) {
const visibleRows = Math.min(Math.max(rowCount, 1), 4);
return STORYBOARD_HEADER_HEIGHT + visibleRows * STORYBOARD_ROW_HEIGHT + STORYBOARD_ADD_ROW_HEIGHT + Math.min(STORYBOARD_COMPOSER_MAX_HEIGHT, Math.max(STORYBOARD_COMPOSER_MIN_HEIGHT, composerHeight));
}
export function storyboardMinNodeHeight(composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) {
return STORYBOARD_HEADER_HEIGHT + STORYBOARD_ROW_HEIGHT + STORYBOARD_ADD_ROW_HEIGHT + Math.min(STORYBOARD_COMPOSER_MAX_HEIGHT, Math.max(STORYBOARD_COMPOSER_MIN_HEIGHT, composerHeight));
}
export function storyboardTableHeight(nodeHeight: number, composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) {
return Math.max(STORYBOARD_ROW_HEIGHT, nodeHeight - STORYBOARD_HEADER_HEIGHT - STORYBOARD_ADD_ROW_HEIGHT - Math.min(STORYBOARD_COMPOSER_MAX_HEIGHT, Math.max(STORYBOARD_COMPOSER_MIN_HEIGHT, composerHeight)));
}
const columnOptions: Array<{ label: string; value: StoryboardColumn }> = [
{ label: "序号", value: "shotNumber" },
{ label: "时长", value: "durationSeconds" },
{ label: "画面描述", value: "plotDescription" },
{ label: "台词/旁白", value: "dialogue" },
{ label: "景别", value: "shotSize" },
{ label: "情绪", value: "emotion" },
{ label: "光影氛围", value: "lightingAndAtmosphere" },
{ label: "音效", value: "audioEffects" },
{ label: "镜头设计", value: "camera" },
{ label: "运镜", value: "motion" },
{ label: "时间节拍", value: "timeBeats" },
{ label: "图片提示词", value: "imageGenerationPrompt" },
{ label: "视频提示词", value: "videoMotionPrompt" },
{ label: "负面要求", value: "negativePrompt" },
];
export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionReferences, onOpen, onCreateImageNodes, onCreateVideoNodes, onGenerateImages, onGenerateVideos, onMergeVideos, onCreateActionBoards, onRetryBatch, onRetryBatchItem, onStopBatch, onCancelBatchItem, onAddRow, onRemoveRow, onUpdateRow, onPromptChange, onGenerateScript, onModelChange, onShotDurationChange, onShotCountChange, onComposerHeightChange, onConnectStart, onScrollTopChange, workspaceMode = "professional" }: {
node: CanvasNodeData;
batch?: CanvasGenerationBatch;
pipeline: CanvasStoryboardPipelineProgress;
scale: number;
mentionReferences: CanvasResourceReference[];
onOpen: () => void;
onCreateImageNodes: () => void;
onCreateVideoNodes: () => void;
onGenerateImages: () => void;
onGenerateVideos: () => void;
onMergeVideos: () => void;
onCreateActionBoards: () => void;
onRetryBatch: (batchId: string) => void;
onRetryBatchItem: (batchId: string, itemId: string) => void;
onStopBatch: (batchId: string) => void;
onCancelBatchItem: (batchId: string, itemId: string) => void;
onAddRow: () => void;
onRemoveRow: (rowId: string) => void;
onUpdateRow: (rowId: string, patch: Partial<StoryboardRow>) => void;
onPromptChange: (prompt: string) => void;
onGenerateScript: (prompt: string) => void;
onModelChange: (model: string) => void;
onShotDurationChange: (duration: StoryboardShotDuration) => void;
onShotCountChange: (count: StoryboardShotCount) => void;
onComposerHeightChange: (height: number) => void;
onConnectStart: (event: ReactPointerEvent, rowId: string, handleType: "source" | "target") => void;
onScrollTopChange: (scrollTop: number) => void;
workspaceMode?: CanvasWorkspaceMode;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const effectiveConfig = useEffectiveConfig();
const generationConfig = buildGenerationConfig(effectiveConfig, node, "text");
const simpleMode = workspaceMode === "simple";
const rows = node.metadata?.storyboard?.rows || [];
const [prompt, setPrompt] = useState(node.metadata?.composerContent || "");
const [scrollTop, setScrollTop] = useState(0);
const composerHeightChangeRef = useRef(onComposerHeightChange);
const reportedComposerHeightRef = useRef<number | null>(null);
const composerHeight = node.metadata?.storyboardComposerHeight || STORYBOARD_COMPOSER_MIN_HEIGHT;
const tableHeight = storyboardTableHeight(node.height, composerHeight);
const totalDuration = rows.reduce((sum, row) => sum + (Number(row.durationSeconds) || 0), 0);
const shotDuration = node.metadata?.storyboardShotDuration || "auto";
const shotCount = node.metadata?.storyboardShotCount || "auto";
const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]);
const batchSummary = batch ? generationBatchSummary(batch) : null;
const hasFailedBatchItems = Boolean(batch?.items.some((item) => item.status === "failed"));
const hasWaitingBatchItems = Boolean(batch?.items.some((item) => item.status === "waiting" || item.status === "submitting"));
const hasActiveBatchItems = Boolean(batch?.items.some((item) => item.status === "waiting" || item.status === "submitting" || item.status === "queued" || item.status === "running"));
const taskFeedback = node.metadata?.status === "loading"
? `${node.metadata.taskStage || "正在创建任务"}${typeof node.metadata.taskProgress === "number" ? ` · ${node.metadata.taskProgress}%` : ""}`
: node.metadata?.status === "error" ? node.metadata.errorDetails : "";
const submitPrompt = () => {
const value = prompt.trim();
if (value && node.metadata?.status !== "loading") onGenerateScript(value);
};
useLayoutEffect(() => {
composerHeightChangeRef.current = onComposerHeightChange;
}, [onComposerHeightChange]);
const resizePrompt = useCallback((contentHeight: number) => {
const promptHeight = Math.min(STORYBOARD_PROMPT_MAX_HEIGHT, Math.max(STORYBOARD_PROMPT_MIN_HEIGHT, contentHeight));
const composerHeight = promptHeight + 64;
if (reportedComposerHeightRef.current === composerHeight) return;
reportedComposerHeightRef.current = composerHeight;
composerHeightChangeRef.current(composerHeight);
}, []);
return (
<div className="relative flex h-full w-full flex-col overflow-visible" style={{ color: theme.node.text }} onDoubleClick={(event) => event.stopPropagation()}>
<div className="relative flex h-10 shrink-0 items-center gap-2 rounded-t-[17px] border-b px-4" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
<Clapperboard className="size-4" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{node.title || "分镜脚本"}</span>
{batchSummary ? <span className="min-w-0 max-w-[42%] truncate text-[11px] font-medium" title={batchSummary} style={{ color: batch?.status === "partial_failed" ? theme.accent.danger : theme.node.muted }}>{batchSummary}</span> : taskFeedback ? <span className="min-w-0 max-w-[38%] truncate text-[11px] font-medium" title={taskFeedback} style={{ color: node.metadata?.status === "error" ? theme.accent.danger : theme.node.muted }}>{taskFeedback}</span> : null}
<span className="text-xs font-medium" style={{ color: theme.node.muted }}>{rows.length} 镜 · {totalDuration}s</span>
{batch ? <>
{hasFailedBatchItems ? <Tooltip title="重试失败项"><button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onRetryBatch(batch.id); }} aria-label="重试失败项"><RefreshCw className="size-3.5" /></button></Tooltip> : null}
{hasWaitingBatchItems ? <Tooltip title="停止剩余任务"><button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onStopBatch(batch.id); }} aria-label="停止剩余任务"><Square className="size-3.5" /></button></Tooltip> : null}
<Popover placement="bottomRight" trigger="click" content={<GenerationBatchDetails batch={batch} rows={rows} onRetryItem={(itemId) => onRetryBatchItem(batch.id, itemId)} onCancelItem={(itemId) => onCancelBatchItem(batch.id, itemId)} />}><Tooltip title="查看详情"><button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} aria-label="查看批次详情"><ListTree className="size-3.5" /></button></Tooltip></Popover>
</> : null}
{simpleMode ? null : <Tooltip title="生成动作拆分 12 宫格"><button type="button" disabled={!rows.length || hasActiveBatchItems} className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-30 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onCreateActionBoards(); }}><Grid3X3 className="size-3.5" /></button></Tooltip>}
{simpleMode ? null : <Tooltip title="全屏编辑"><button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpen(); }}><Expand className="size-3.5" /></button></Tooltip>}
</div>
<StoryboardPipelineBar
pipeline={pipeline}
simpleMode={simpleMode}
disabled={!rows.length || node.metadata?.status === "loading" || hasActiveBatchItems}
theme={theme}
onCreateImageNodes={onCreateImageNodes}
onCreateVideoNodes={onCreateVideoNodes}
onGenerateImages={onGenerateImages}
onGenerateVideos={onGenerateVideos}
onMergeVideos={onMergeVideos}
/>
<div className="grid h-9 shrink-0 items-center border-b text-xs font-semibold" style={{ borderColor: theme.node.stroke, color: theme.node.muted, gridTemplateColumns: SCRIPT_GRID_TEMPLATE }}>
<HeaderCell borderColor={theme.node.stroke} align="center">序号</HeaderCell>
<HeaderCell borderColor={theme.node.stroke} align="center">时长</HeaderCell>
<HeaderCell borderColor={theme.node.stroke}>画面描述</HeaderCell>
<HeaderCell borderColor={theme.node.stroke}>台词/旁白</HeaderCell>
<span className="text-center">操作</span>
</div>
<div
data-canvas-wheel-scroll
tabIndex={0}
role="region"
aria-label="分镜镜头列表"
className="storyboard-scrollbar min-h-0 flex-1 overflow-y-scroll overflow-x-hidden outline-none focus-visible:ring-1 focus-visible:ring-inset"
style={{ "--tw-ring-color": theme.node.muted } as CSSProperties}
onScroll={(event) => { const next = event.currentTarget.scrollTop; setScrollTop(next); onScrollTopChange(next); }}
onWheel={(event) => event.stopPropagation()}
>
{rows.length ? rows.map((row) => (
<div key={row.id} className="relative grid border-b" style={{ height: STORYBOARD_ROW_HEIGHT, borderColor: theme.node.stroke, gridTemplateColumns: SCRIPT_GRID_TEMPLATE }}>
<div className="flex flex-col items-center justify-center border-r tabular-nums" style={{ color: theme.node.muted, borderColor: theme.node.stroke }}><span className="text-sm">{row.shotNumber}</span>{batchItemByRowId.get(row.id) ? <span className="max-w-16 truncate text-[9px] leading-3" title={generationBatchItemLabel(batchItemByRowId.get(row.id)!)}>{generationBatchItemLabel(batchItemByRowId.get(row.id)!)}</span> : null}</div>
<div className="grid grid-cols-[32px_1fr_32px] items-center border-r px-2" style={{ borderColor: theme.node.stroke }}>
<SmallButton title="减少 1 秒" onClick={() => onUpdateRow(row.id, { durationSeconds: Math.max(1, row.durationSeconds - 1) })}><Minus className="size-3" /></SmallButton>
<span className="text-center text-sm font-medium tabular-nums">{row.durationSeconds}s</span>
<SmallButton title="增加 1 秒" onClick={() => onUpdateRow(row.id, { durationSeconds: Math.min(60, row.durationSeconds + 1) })}><Plus className="size-3" /></SmallButton>
</div>
<CompactInput value={row.plotDescription} placeholder="描述画面内容" onChange={(value) => onUpdateRow(row.id, { plotDescription: value })} borderColor={theme.node.stroke} />
<CompactInput value={row.dialogue} placeholder="台词或旁白" onChange={(value) => onUpdateRow(row.id, { dialogue: value })} borderColor={theme.node.stroke} />
<div className="grid h-full place-items-center">
<button type="button" disabled={rows.length <= 1} className="grid size-7 place-items-center rounded outline-none opacity-55 transition enabled:hover:bg-red-500/10 enabled:hover:opacity-100 focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-20" style={{ color: theme.accent.danger, "--tw-ring-color": theme.accent.danger } as CSSProperties} title={rows.length <= 1 ? "至少保留一个镜头" : "删除镜头"} aria-label={`删除镜头 ${row.shotNumber}`} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onRemoveRow(row.id); }}><Trash2 className="size-3.5" /></button>
</div>
</div>
)) : <button type="button" className="grid h-full min-h-24 w-full place-items-center text-sm" style={{ color: theme.node.muted }} onClick={(event) => { event.stopPropagation(); onAddRow(); }}>+ 添加第一个镜头</button>}
</div>
<div className="flex h-9 shrink-0 items-center justify-center border-b" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
<button type="button" className="inline-flex h-7 items-center gap-1 rounded px-2 text-xs font-medium outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onAddRow(); }}><Plus className="size-3.5" />添加行</button>
</div>
<div className="relative grid shrink-0 grid-rows-[minmax(0,1fr)_28px] gap-1.5 rounded-b-[17px] p-2.5" style={{ height: composerHeight, background: theme.node.panel }}>
<CanvasResourceMentionTextarea
rows={1}
references={mentionReferences}
aria-label="分镜剧情与项目设定"
containerClassName="h-full min-h-0 overflow-hidden"
className="thin-scrollbar h-full min-h-0 w-full touch-pan-y resize-none overflow-y-auto overflow-x-hidden overscroll-contain rounded-md border bg-transparent px-3 py-2 text-sm leading-5 outline-none transition placeholder:opacity-45 focus:ring-1"
style={{ borderColor: theme.node.stroke, color: theme.node.text, "--tw-ring-color": theme.node.muted } as CSSProperties}
value={prompt}
placeholder="描述想生成的脚本或视频内容"
onContentSizeChange={resizePrompt}
onChange={(value) => {
setPrompt(value);
onPromptChange(value);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
submitPrompt();
}
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
/>
<div className="flex min-w-0 items-center justify-end gap-2" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<div className="mr-auto min-w-36 max-w-56 flex-1">
<ModelPicker
className="!h-7 !w-full !min-w-0 !text-[10px] !font-normal [&_img]:!size-3 [&_.lucide]:!size-3"
fullWidth
config={generationConfig}
value={generationConfig.model}
capability="text"
placeholder="选择文本模型"
showSelectedPrice={false}
onChange={onModelChange}
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
/>
</div>
{simpleMode ? <span className="text-[11px]" style={{ color: theme.node.muted }}>自动拆分 · 默认时长</span> : <Select<StoryboardShotCount>
className="min-w-32"
size="small"
value={shotCount}
disabled={node.metadata?.status === "loading"}
options={[{ value: "auto", label: "分镜数量:自动拆分" }, ...Array.from({ length: 10 }, (_, index) => ({ value: String(index + 1) as StoryboardShotCount, label: `分镜数量:${index + 1}` }))]}
popupMatchSelectWidth={false}
onChange={onShotCountChange}
/>}
{simpleMode ? null : <Select<StoryboardShotDuration>
className="min-w-36"
size="small"
value={shotDuration}
disabled={node.metadata?.status === "loading"}
options={[
{ value: "auto", label: "镜头:自动拆分" },
{ value: "5", label: "镜头:单个5S" },
{ value: "10", label: "镜头:单个10S" },
{ value: "15", label: "镜头:单个15S" },
{ value: "30", label: "镜头:单个30S" },
]}
popupMatchSelectWidth={false}
onChange={onShotDurationChange}
/>}
<Button
shape="circle"
icon={<Send className="size-4" />}
disabled={!prompt.trim() || node.metadata?.status === "loading"}
loading={node.metadata?.status === "loading"}
style={{ background: theme.toolbar.itemHover, borderColor: theme.node.stroke, color: theme.node.text }}
onMouseDown={(event) => event.stopPropagation()}
onClick={submitPrompt}
/>
</div>
<RowHandle side="left" top={composerHeight / 2} scale={scale} tone="idle" theme={theme} title="连接文本节点作为项目设定" onPointerDown={(event) => onConnectStart(event, "context", "target")} />
</div>
{rows.map((row, index) => {
const top = STORYBOARD_HEADER_HEIGHT + index * STORYBOARD_ROW_HEIGHT + STORYBOARD_ROW_HEIGHT / 2 - scrollTop;
if (top < STORYBOARD_HEADER_HEIGHT + 4 || top > STORYBOARD_HEADER_HEIGHT + tableHeight - 4) return null;
return (
<div key={`ports-${row.id}`}>
<RowHandle side="left" top={top} scale={scale} tone={batchItemTone(batchItemByRowId.get(row.id)) || row.status} theme={theme} onPointerDown={(event) => onConnectStart(event, row.id, "target")} />
<RowHandle side="right" top={top} scale={scale} tone={batchItemTone(batchItemByRowId.get(row.id)) || row.status} theme={theme} onPointerDown={(event) => onConnectStart(event, row.id, "source")} />
</div>
);
})}
</div>
);
}
function StoryboardPipelineBar({ pipeline, simpleMode, disabled, theme, onCreateImageNodes, onCreateVideoNodes, onGenerateImages, onGenerateVideos, onMergeVideos }: {
pipeline: CanvasStoryboardPipelineProgress;
simpleMode: boolean;
disabled: boolean;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onCreateImageNodes: () => void;
onCreateVideoNodes: () => void;
onGenerateImages: () => void;
onGenerateVideos: () => void;
onMergeVideos: () => void;
}) {
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;
return (
<div className="grid h-12 shrink-0 grid-cols-3 border-b" style={{ borderColor: theme.node.stroke, background: theme.node.fill }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<PipelineStageCell label="分镜图" stage={pipeline.images} theme={theme}>
{simpleMode ? (
<Button size="small" type="text" icon={<ImageIcon className="size-3" />} disabled={disabled || pipeline.images.incomplete === 0} onClick={onGenerateImages}>
{pipeline.images.incomplete ? `生成 ${pipeline.images.incomplete} 张分镜图` : "分镜图已完成"}
</Button>
) : (
<>
<Button size="small" type="text" disabled={disabled || missingImages === 0} onClick={onCreateImageNodes}>{missingImages ? `创建 ${missingImages} 个图片节点` : "图片节点已创建"}</Button>
<Button size="small" type="text" disabled={disabled || pipeline.images.incomplete === 0} onClick={onGenerateImages}>生成未完成的图片</Button>
</>
)}
</PipelineStageCell>
<PipelineStageCell label="镜头视频" stage={pipeline.videos} theme={theme}>
{simpleMode ? (
<Button size="small" type="text" icon={<Video className="size-3" />} disabled={disabled || pipeline.videos.incomplete === 0} onClick={onGenerateVideos}>
{pipeline.videos.incomplete ? `生成 ${pipeline.videos.incomplete} 个镜头视频` : "镜头视频已完成"}
</Button>
) : (
<>
<Button size="small" type="text" disabled={disabled || missingVideos === 0} onClick={onCreateVideoNodes}>{missingVideos ? `创建 ${missingVideos} 个视频节点` : "视频节点已创建"}</Button>
<Button size="small" type="text" disabled={disabled || pipeline.videos.incomplete === 0} onClick={onGenerateVideos}>生成未完成的视频</Button>
</>
)}
</PipelineStageCell>
<PipelineStageCell label="合并成片" stage={pipeline.final} theme={theme} last>
<Button size="small" type={canMerge ? "primary" : "text"} icon={<Merge className="size-3" />} disabled={!canMerge} onClick={onMergeVideos}>
{pipeline.final.success ? "成片已完成" : pipeline.successfulVideoNodeIds.length >= 2 ? `合并 ${pipeline.successfulVideoNodeIds.length} 段视频` : "至少完成 2 段视频"}
</Button>
</PipelineStageCell>
</div>
);
}
function PipelineStageCell({ label, stage, theme, children, last = false }: { label: string; stage: StoryboardPipelineStage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode; last?: boolean }) {
return (
<div className={`flex min-w-0 items-center gap-2 px-3 ${last ? "" : "border-r"}`} style={{ borderColor: theme.node.stroke }}>
<div className="min-w-[64px] shrink-0">
<div className="text-[11px] font-semibold">{label}</div>
<div className="text-[9px] leading-3" style={{ color: stage.failed ? theme.accent.danger : theme.node.muted }}>{pipelineStatusLabel(stage)}</div>
</div>
<div className="flex min-w-0 flex-1 items-center justify-end gap-1 overflow-hidden [&_.ant-btn]:!h-7 [&_.ant-btn]:!px-2 [&_.ant-btn]:!text-[10px]">{children}</div>
</div>
);
}
function GenerationBatchDetails({ batch, rows, onRetryItem, onCancelItem }: { batch: CanvasGenerationBatch; rows: StoryboardRow[]; onRetryItem: (itemId: string) => void; onCancelItem: (itemId: string) => void }) {
const shotByRowId = new Map(rows.map((row) => [row.id, row.shotNumber]));
return <div className="w-80" onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<div className="mb-2 flex items-center justify-between gap-3"><span className="text-sm font-semibold">{generationBatchModeLabel(batch)}详情</span><span className="text-xs text-foreground/50">{batch.items.length} 项</span></div>
<div className="thin-scrollbar max-h-72 overflow-y-auto">
{batch.items.map((item) => {
const cancellable = Boolean(item.taskId && (item.status === "queued" || item.status === "running"));
const requiresPromptChange = isContentModerationError(item.errorDetails);
return <div key={item.id} className="flex min-h-9 items-center gap-2 border-t border-foreground/10 py-1.5 first:border-t-0">
<span className="w-14 shrink-0 text-xs font-medium">镜头 {shotByRowId.get(item.rowId) || "--"}</span>
<span className="min-w-0 flex-1 truncate text-xs text-foreground/60" title={item.errorDetails}>{generationBatchItemLabel(item)}{item.retryCount ? ` · 重试 ${item.retryCount}` : ""}</span>
{item.status === "failed" ? <Tooltip title={requiresPromptChange ? "请先修改提示词,再重试这个镜头" : "只重试这个镜头"}><button type="button" className="grid size-7 shrink-0 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" onClick={() => onRetryItem(item.id)} aria-label={`重试镜头 ${shotByRowId.get(item.rowId) || ""}`}><RefreshCw className="size-3.5" /></button></Tooltip> : null}
{cancellable ? <Tooltip title="取消这个后台任务"><button type="button" className="grid size-7 shrink-0 place-items-center rounded outline-none transition hover:bg-red-500/10 focus-visible:ring-2" onClick={() => onCancelItem(item.id)} aria-label={`取消镜头 ${shotByRowId.get(item.rowId) || ""} 任务`}><X className="size-3.5" /></button></Tooltip> : null}
</div>;
})}
</div>
</div>;
}
function generationBatchModeLabel(batch: CanvasGenerationBatch) {
return batch.mode === "storyboard_video" ? "视频生成" : batch.mode === "storyboard_image" ? "分镜图生成" : "动作板生成";
}
function generationBatchSummary(batch: CanvasGenerationBatch) {
const count = (status: CanvasGenerationBatchItemStatus) => batch.items.filter((item) => item.status === status).length;
const generating = count("submitting") + count("queued") + count("running");
const stopped = count("cancelled");
return `${generationBatchModeLabel(batch)}${batch.status === "completed" ? "完成" : batch.status === "cancelled" ? "已停止" : "中"} · 完成 ${count("succeeded")}/${batch.items.length} / 失败 ${count("failed")} / 生成中 ${generating} / 等待 ${count("waiting")}${stopped ? ` / 已停止 ${stopped}` : ""}`;
}
function generationBatchItemLabel(item: CanvasGenerationBatchItem) {
if (item.costUncertain) return "费用待确认";
if (isContentModerationError(item.errorDetails)) return "审核未通过,需修改提示词";
const labels: Record<CanvasGenerationBatchItemStatus, string> = { waiting: "等待", submitting: "提交中", queued: "排队", running: "生成中", succeeded: "成功", failed: "失败", cancelled: "已停止" };
return labels[item.status];
}
function batchItemTone(item?: CanvasGenerationBatchItem): CanvasNodeStatus | undefined {
if (!item) return undefined;
if (item.status === "succeeded") return "success";
if (item.status === "failed" || item.status === "cancelled") return "error";
if (item.status === "waiting") return "idle";
return "loading";
}
export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibleColumnsChange, onGenerateImages, onGenerateVideos }: {
node: CanvasNodeData | null;
open: boolean;
onClose: () => void;
onUpdateRows: (rows: StoryboardRow[]) => void;
onVisibleColumnsChange: (columns: StoryboardColumn[]) => void;
onGenerateImages: (rowIds: string[]) => void;
onGenerateVideos: (rowIds: string[]) => void;
}) {
const [query, setQuery] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const rows = node?.metadata?.storyboard?.rows || EMPTY_STORYBOARD_ROWS;
const visibleColumns = node?.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"];
const filteredRows = useMemo(() => {
const keyword = query.trim().toLowerCase();
return keyword ? rows.filter((row) => [row.plotDescription, row.dialogue, row.camera, row.motion, row.timeBeats, row.imageGenerationPrompt, row.videoMotionPrompt, row.negativePrompt].some((value) => String(value || "").toLowerCase().includes(keyword))) : rows;
}, [query, rows]);
useEffect(() => {
setSelectedIds((current) => {
const next = current.filter((id) => rows.some((row) => row.id === id));
return next.length === current.length && next.every((id, index) => id === current[index]) ? current : next;
});
}, [rows]);
const updateRow = (rowId: string, patch: Partial<StoryboardRow>) => onUpdateRows(rows.map((row) => row.id === rowId ? { ...row, ...patch } : row));
const moveRow = (rowId: string, direction: -1 | 1) => {
const index = rows.findIndex((row) => row.id === rowId);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= rows.length) return;
const next = [...rows];
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
onUpdateRows(next.map((row, rowIndex) => ({ ...row, shotNumber: rowIndex + 1 })));
};
const duplicateRow = (row: StoryboardRow) => {
const index = rows.findIndex((item) => item.id === row.id);
const next = [...rows];
next.splice(index + 1, 0, { ...row, id: `shot-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, imageNodeId: undefined, videoNodeId: undefined, status: "idle" });
onUpdateRows(next.map((item, rowIndex) => ({ ...item, shotNumber: rowIndex + 1 })));
};
const removeRow = (rowId: string) => onUpdateRows(rows.filter((row) => row.id !== rowId).map((row, index) => ({ ...row, shotNumber: index + 1 })));
const columns: ColumnsType<StoryboardRow> = columnOptions.filter((option) => visibleColumns.includes(option.value)).map((option) => ({
title: option.label,
dataIndex: option.value,
key: option.value,
width: option.value === "shotNumber" ? 72 : option.value === "durationSeconds" ? 100 : option.value === "plotDescription" || option.value === "dialogue" || option.value === "timeBeats" || option.value.endsWith("Prompt") ? 260 : 170,
fixed: option.value === "shotNumber" ? "left" as const : undefined,
render: (_: unknown, row: StoryboardRow) => option.value === "shotNumber" ? <span className="font-semibold">{row.shotNumber}</span> : option.value === "durationSeconds" ? <InputNumber min={1} max={60} value={row.durationSeconds} addonAfter="s" onChange={(value) => updateRow(row.id, { durationSeconds: Number(value) || 1 })} /> : option.value === "shotSize" ? <Select className="w-full" value={row.shotSize || undefined} placeholder="选择景别" options={["特写", "近景", "中景", "全景", "远景"].map((value) => ({ value, label: value }))} onChange={(shotSize) => updateRow(row.id, { shotSize })} /> : <Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} value={String(row[option.value] || "")} placeholder={`填写${option.label}`} onChange={(event) => updateRow(row.id, { [option.value]: event.target.value } as Partial<StoryboardRow>)} />,
}));
columns.push({
title: "操作", key: "actions", dataIndex: "shotNumber", width: 150, fixed: "right" as const,
render: (_: unknown, row: StoryboardRow) => <div className="flex gap-1"><SmallButton title="上移" onClick={() => moveRow(row.id, -1)}><ChevronUp className="size-3.5" /></SmallButton><SmallButton title="下移" onClick={() => moveRow(row.id, 1)}><ChevronDown className="size-3.5" /></SmallButton><SmallButton title="复制" onClick={() => duplicateRow(row)}><Copy className="size-3.5" /></SmallButton><SmallButton title="删除" onClick={() => removeRow(row.id)}><Trash2 className="size-3.5" /></SmallButton></div>,
});
return (
<Modal title={node?.title || "分镜脚本"} open={open} onCancel={onClose} footer={null} width="min(1480px, calc(100vw - 40px))" centered destroyOnHidden>
<div className="mb-3 flex flex-wrap items-center gap-2">
<Input.Search className="w-72" allowClear placeholder="筛选画面、台词或提示词" value={query} onChange={(event) => setQuery(event.target.value)} />
<Checkbox.Group className="script-column-picker" options={columnOptions} value={visibleColumns} onChange={(values) => onVisibleColumnsChange(values as StoryboardColumn[])} />
<span className="min-w-0 flex-1" />
<Button icon={<Plus className="size-4" />} onClick={() => onUpdateRows([...rows, editorRow(rows.length + 1)])}>新增镜头</Button>
<Button icon={<ImageIcon className="size-4" />} disabled={!selectedIds.length} onClick={() => onGenerateImages(selectedIds)}>生成分镜图</Button>
<Button type="primary" icon={<Film className="size-4" />} disabled={!selectedIds.length} onClick={() => onGenerateVideos(selectedIds)}>生成视频</Button>
</div>
<Table<StoryboardRow> rowKey="id" size="small" bordered sticky pagination={false} scroll={{ x: Math.max(900, columns.length * 180), y: "calc(78vh - 170px)" }} dataSource={filteredRows} columns={columns} rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(String)) }} />
</Modal>
);
}
function CompactInput({ value, placeholder, borderColor, onChange }: { value: string; placeholder: string; borderColor: string; onChange: (value: string) => void }) {
return <textarea className="h-full resize-none border-r bg-transparent px-4 py-2.5 text-xs leading-5 outline-none transition placeholder:opacity-35 focus:bg-black/[0.02] dark:focus:bg-white/[0.025]" style={{ borderColor }} value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} />;
}
function HeaderCell({ children, borderColor, align = "left" }: { children: ReactNode; borderColor: string; align?: "left" | "center" }) {
return <span className={`flex h-full items-center border-r px-4 ${align === "center" ? "justify-center text-center" : "justify-start"}`} style={{ borderColor }}>{children}</span>;
}
function SmallButton({ title, children, onClick }: { title: string; children: ReactNode; onClick: () => void }) {
return <button type="button" className="grid size-7 shrink-0 place-items-center rounded opacity-65 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10" title={title} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onClick(); }}>{children}</button>;
}
function editorRow(shotNumber: number): StoryboardRow {
return { id: `shot-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, shotNumber, durationSeconds: 6, plotDescription: "", dialogue: "", characters: [], shotSize: "", emotion: "", lightingAndAtmosphere: "", audioEffects: "", camera: "", motion: "", timeBeats: "", imageGenerationPrompt: "", videoMotionPrompt: "", negativePrompt: "", referenceNodeIds: [], status: "idle" };
}
function RowHandle({ side, top, scale, tone, theme, title, onPointerDown }: { side: "left" | "right"; top: number; scale: number; tone?: StoryboardRow["status"]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; title?: string; onPointerDown: (event: ReactPointerEvent) => void }) {
const color = tone === "loading" ? theme.accent.primary : tone === "error" ? theme.accent.danger : tone === "success" ? theme.node.activeStroke : theme.node.muted;
const inverseScale = 1 / Math.max(scale, 0.05);
return (
<button
type="button"
aria-label={title || `${side === "left" ? "输入" : "输出"}连接点`}
title={title || `${side === "left" ? "引入参考" : "连接到图片、视频或生成节点"}`}
className={`canvas-connection-handle absolute z-50 flex -translate-y-1/2 cursor-pointer items-center justify-center rounded-full outline-none focus-visible:ring-2 ${side === "left" ? "left-0 -translate-x-1/2" : "right-0 translate-x-1/2"}`}
style={{ top, width: 32 * inverseScale, height: 32 * inverseScale, "--tw-ring-color": theme.accent.primary } as CSSProperties}
onPointerDown={onPointerDown}
>
<span className="block rounded-full shadow-sm transition-transform hover:scale-110" style={{ width: 14 * inverseScale, height: 14 * inverseScale, border: `${2 * inverseScale}px solid ${theme.node.panel}`, background: color }} />
</button>
);
}