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) => 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(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 (
event.stopPropagation()}>
{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 : }
序号 时长 画面描述 台词/旁白 操作
{ const next = event.currentTarget.scrollTop; setScrollTop(next); onScrollTopChange(next); }} onWheel={(event) => event.stopPropagation()} > {rows.length ? rows.map((row) => (
{row.shotNumber}{batchItemByRowId.get(row.id) ? {generationBatchItemLabel(batchItemByRowId.get(row.id)!)} : null}
onUpdateRow(row.id, { durationSeconds: Math.max(1, row.durationSeconds - 1) })}> {row.durationSeconds}s onUpdateRow(row.id, { durationSeconds: Math.min(60, row.durationSeconds + 1) })}>
onUpdateRow(row.id, { plotDescription: value })} borderColor={theme.node.stroke} /> onUpdateRow(row.id, { dialogue: value })} borderColor={theme.node.stroke} />
)) : }
{ 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()} />
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
navigateToSettings({ continueCreation: true })} />
{simpleMode ? 自动拆分 · 默认时长 : 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 : 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} />}
onConnectStart(event, "context", "target")} />
{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 (
onConnectStart(event, row.id, "target")} /> onConnectStart(event, row.id, "source")} />
); })}
); } 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 (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> {simpleMode ? ( ) : ( <> )} {simpleMode ? ( ) : ( <> )}
); } function PipelineStageCell({ label, stage, theme, children, last = false }: { label: string; stage: StoryboardPipelineStage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode; last?: boolean }) { return (
{label}
{pipelineStatusLabel(stage)}
{children}
); } 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
event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
{generationBatchModeLabel(batch)}详情{batch.items.length} 项
{batch.items.map((item) => { const cancellable = Boolean(item.taskId && (item.status === "queued" || item.status === "running")); const requiresPromptChange = isContentModerationError(item.errorDetails); return
镜头 {shotByRowId.get(item.rowId) || "--"} {generationBatchItemLabel(item)}{item.retryCount ? ` · 重试 ${item.retryCount}` : ""} {item.status === "failed" ? : null} {cancellable ? : null}
; })}
; } 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 = { 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([]); 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) => 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 = 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" ? {row.shotNumber} : option.value === "durationSeconds" ? updateRow(row.id, { durationSeconds: Number(value) || 1 })} /> : option.value === "shotSize" ?