feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
@@ -10,11 +10,25 @@ 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 { generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
|
||||
import { generationTaskShowsProgress, generationTaskStageLabel } from "@/lib/generation-task-display";
|
||||
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, StoryboardVideoInputMode } from "@/types/canvas";
|
||||
import type {
|
||||
CanvasGenerationBatch,
|
||||
CanvasGenerationBatchItem,
|
||||
CanvasGenerationBatchItemStatus,
|
||||
CanvasNodeData,
|
||||
CanvasNodeStatus,
|
||||
CanvasWorkspaceMode,
|
||||
StoryboardColumn,
|
||||
StoryboardRow,
|
||||
StoryboardShotCount,
|
||||
StoryboardShotDuration,
|
||||
StoryboardVideoInputMode,
|
||||
} from "@/types/canvas";
|
||||
import type { TaskStatus } from "@/services/api/task-center";
|
||||
|
||||
export const STORYBOARD_ROW_HEIGHT = 48;
|
||||
export const STORYBOARD_HEADER_HEIGHT = 124;
|
||||
@@ -60,7 +74,37 @@ const columnOptions: Array<{ label: string; value: StoryboardColumn }> = [
|
||||
{ label: "负面要求", value: "negativePrompt" },
|
||||
];
|
||||
|
||||
export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionReferences, onOpen, onCreateImageNodes, onCreateVideoNodes, onGenerateImages, onGenerateVideos, onVideoInputModeChange, onMergeVideos, onCreateActionBoards, onRetryBatch, onRetryBatchItem, onStopBatch, onCancelBatchItem, onAddRow, onRemoveRow, onUpdateRow, onPromptChange, onGenerateScript, onModelChange, onShotDurationChange, onShotCountChange, onComposerHeightChange, onConnectStart, onScrollTopChange, workspaceMode = "professional" }: {
|
||||
export function CanvasScriptNodeContent({
|
||||
node,
|
||||
batch,
|
||||
pipeline,
|
||||
scale,
|
||||
mentionReferences,
|
||||
onOpen,
|
||||
onCreateImageNodes,
|
||||
onCreateVideoNodes,
|
||||
onGenerateImages,
|
||||
onGenerateVideos,
|
||||
onVideoInputModeChange,
|
||||
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;
|
||||
@@ -111,9 +155,25 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
|
||||
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" ? generationErrorMessage(node.metadata.errorDetails) : "";
|
||||
const taskStatus = node.metadata?.taskStatus;
|
||||
const displayStatus: TaskStatus = taskStatus === "queued" || taskStatus === "succeeded" || taskStatus === "failed" || taskStatus === "cancelled" ? taskStatus : "running";
|
||||
const displayTask = node.metadata?.taskId
|
||||
? {
|
||||
provider: node.metadata.taskProvider,
|
||||
status: displayStatus,
|
||||
stage: node.metadata.taskStage,
|
||||
officialStatus: node.metadata.taskOfficialStatus,
|
||||
errorCode: node.metadata.taskErrorCode,
|
||||
}
|
||||
: null;
|
||||
const taskFeedback =
|
||||
node.metadata?.status === "loading"
|
||||
? displayTask
|
||||
? `${generationTaskStageLabel(displayTask)}${generationTaskShowsProgress(displayTask) && typeof node.metadata.taskProgress === "number" ? ` · ${node.metadata.taskProgress}%` : ""}`
|
||||
: "正在创建任务"
|
||||
: node.metadata?.status === "error"
|
||||
? generationErrorMessage(node.metadata.errorDetails)
|
||||
: "";
|
||||
const [batchDetailsOpen, setBatchDetailsOpen] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const pipelineDisabled = !rows.length || node.metadata?.status === "loading" || hasActiveBatchItems;
|
||||
@@ -124,24 +184,39 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
|
||||
const moreMenuItems: MenuProps["items"] = [
|
||||
{ key: "generate-images", icon: <ImageIcon className="size-3.5" />, label: "生成未完成分镜图", disabled: pipelineDisabled || pipeline.images.incomplete === 0, onClick: () => onGenerateImages(allRowIds) },
|
||||
{ key: "generate-videos", icon: <Video className="size-3.5" />, label: "生成未完成视频", disabled: pipelineDisabled || pipeline.videos.incomplete === 0, onClick: () => onGenerateVideos(allRowIds) },
|
||||
{ key: "merge", icon: <Merge className="size-3.5" />, label: pipeline.final.success ? "成片已完成" : pipeline.successfulVideoNodeIds.length >= 2 ? `合并 ${pipeline.successfulVideoNodeIds.length} 段视频` : "合并成片(至少 2 段视频)", disabled: !canMerge, onClick: () => onMergeVideos() },
|
||||
{
|
||||
key: "merge",
|
||||
icon: <Merge className="size-3.5" />,
|
||||
label: pipeline.final.success ? "成片已完成" : pipeline.successfulVideoNodeIds.length >= 2 ? `合并 ${pipeline.successfulVideoNodeIds.length} 段视频` : "合并成片(至少 2 段视频)",
|
||||
disabled: !canMerge,
|
||||
onClick: () => onMergeVideos(),
|
||||
},
|
||||
{ type: "divider" },
|
||||
{ key: "video-input", icon: <Film className="size-3.5" />, 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: <Grid3X3 className="size-3.5" />, label: missingImages ? `创建 ${missingImages} 个图片节点` : "图片节点已创建", disabled: pipelineDisabled || missingImages === 0, onClick: () => onCreateImageNodes() },
|
||||
{ key: "create-video-nodes", icon: <Film className="size-3.5" />, label: missingVideos ? `创建 ${missingVideos} 个视频节点` : "视频节点已创建", disabled: pipelineDisabled || missingVideos === 0, onClick: () => onCreateVideoNodes() },
|
||||
{ key: "action-boards", icon: <Grid3X3 className="size-3.5" />, label: "生成动作拆分 12 宫格", disabled: !rows.length || hasActiveBatchItems, onClick: () => onCreateActionBoards() },
|
||||
] : []),
|
||||
...(batch ? [
|
||||
{ type: "divider" as const },
|
||||
{ key: "retry", icon: <RefreshCw className="size-3.5" />, label: "重试失败项", disabled: !hasFailedBatchItems, onClick: () => onRetryBatch(batch.id) },
|
||||
{ key: "stop", icon: <Square className="size-3.5" />, label: "停止剩余任务", disabled: !hasWaitingBatchItems, onClick: () => onStopBatch(batch.id) },
|
||||
{ key: "details", icon: <ListTree className="size-3.5" />, label: "查看批次详情", onClick: () => setBatchDetailsOpen(true) },
|
||||
] : []),
|
||||
{
|
||||
key: "video-input",
|
||||
icon: <Film className="size-3.5" />,
|
||||
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: <Grid3X3 className="size-3.5" />, label: missingImages ? `创建 ${missingImages} 个图片节点` : "图片节点已创建", disabled: pipelineDisabled || missingImages === 0, onClick: () => onCreateImageNodes() },
|
||||
{ key: "create-video-nodes", icon: <Film className="size-3.5" />, label: missingVideos ? `创建 ${missingVideos} 个视频节点` : "视频节点已创建", disabled: pipelineDisabled || missingVideos === 0, onClick: () => onCreateVideoNodes() },
|
||||
{ key: "action-boards", icon: <Grid3X3 className="size-3.5" />, label: "生成动作拆分 12 宫格", disabled: !rows.length || hasActiveBatchItems, onClick: () => onCreateActionBoards() },
|
||||
]
|
||||
: []),
|
||||
...(batch
|
||||
? [
|
||||
{ type: "divider" as const },
|
||||
{ key: "retry", icon: <RefreshCw className="size-3.5" />, label: "重试失败项", disabled: !hasFailedBatchItems, onClick: () => onRetryBatch(batch.id) },
|
||||
{ key: "stop", icon: <Square className="size-3.5" />, label: "停止剩余任务", disabled: !hasWaitingBatchItems, onClick: () => onStopBatch(batch.id) },
|
||||
{ key: "details", icon: <ListTree className="size-3.5" />, label: "查看批次详情", onClick: () => setBatchDetailsOpen(true) },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const submitPrompt = () => {
|
||||
const value = prompt.trim();
|
||||
@@ -162,21 +237,67 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
|
||||
<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" title={node.title || "分镜脚本"}>{node.title || "分镜脚本"}</span>
|
||||
{batchSummary ? <span className="min-w-0 max-w-[42%] truncate text-[var(--fs-label)] 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-[var(--fs-label)] font-medium" title={taskFeedback} style={{ color: node.metadata?.status === "error" ? theme.accent.danger : theme.node.muted }}>{taskFeedback}</span> : null}
|
||||
<span className="text-[var(--fs-caption)] font-semibold tabular-nums" style={{ color: theme.node.muted }}>{rows.length} 镜 · {totalDuration}s</span>
|
||||
<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()} onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpen(); }} aria-label="全屏编辑"><Expand className="size-3.5" /></button></Tooltip>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold" title={node.title || "分镜脚本"}>
|
||||
{node.title || "分镜脚本"}
|
||||
</span>
|
||||
{batchSummary ? (
|
||||
<span className="min-w-0 max-w-[42%] truncate text-[var(--fs-label)] 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-[var(--fs-label)] font-medium" title={taskFeedback} style={{ color: node.metadata?.status === "error" ? theme.accent.danger : theme.node.muted }}>
|
||||
{taskFeedback}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[var(--fs-caption)] font-semibold tabular-nums" style={{ color: theme.node.muted }}>
|
||||
{rows.length} 镜 · {totalDuration}s
|
||||
</span>
|
||||
<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()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
aria-label="全屏编辑"
|
||||
>
|
||||
<Expand className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Dropdown open={moreMenuOpen} onOpenChange={setMoreMenuOpen} menu={{ items: moreMenuItems, onClick: () => setMoreMenuOpen(false) }} trigger={["click"]} placement="bottomRight">
|
||||
<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()} onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); setMoreMenuOpen(true); }} aria-label="更多操作"><MoreHorizontal className="size-3.5" /></button>
|
||||
<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()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setMoreMenuOpen(true);
|
||||
}}
|
||||
aria-label="更多操作"
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{batch ? <Modal title="批次详情" open={batchDetailsOpen} onCancel={() => setBatchDetailsOpen(false)} footer={null} width={560} centered destroyOnHidden>
|
||||
<GenerationBatchDetails batch={batch} rows={rows} onRetryItem={(itemId) => onRetryBatchItem(batch.id, itemId)} onCancelItem={(itemId) => onCancelBatchItem(batch.id, itemId)} />
|
||||
</Modal> : null}
|
||||
{batch ? (
|
||||
<Modal title="批次详情" open={batchDetailsOpen} onCancel={() => setBatchDetailsOpen(false)} footer={null} width={560} centered destroyOnHidden>
|
||||
<GenerationBatchDetails batch={batch} rows={rows} onRetryItem={(itemId) => onRetryBatchItem(batch.id, itemId)} onCancelItem={(itemId) => onCancelBatchItem(batch.id, itemId)} />
|
||||
</Modal>
|
||||
) : null}
|
||||
<StoryboardMiniPipeline pipeline={pipeline} theme={theme} rows={rows} />
|
||||
<div className="storyboard-header-gutter 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} 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>
|
||||
@@ -188,34 +309,87 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
|
||||
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); }}
|
||||
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-[var(--fs-micro)] 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>
|
||||
{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-[var(--fs-micro)] 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>
|
||||
<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-36 w-full place-items-center" onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onAddRow(); }}>
|
||||
))
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-full min-h-36 w-full place-items-center"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAddRow();
|
||||
}}
|
||||
>
|
||||
<span className="flex flex-col items-center gap-2.5">
|
||||
<span className="text-sm font-bold">+ 添加第一个镜头</span>
|
||||
<span className="text-[var(--fs-label)] font-medium" style={{ color: theme.node.faint }}>可先连接「故事梗概 / 项目画风」节点,或在下方输入提示词一键生成分镜表</span>
|
||||
<span className="text-[var(--fs-label)] font-medium" style={{ color: theme.node.faint }}>
|
||||
可先连接「故事梗概 / 项目画风」节点,或在下方输入提示词一键生成分镜表
|
||||
</span>
|
||||
</span>
|
||||
</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>
|
||||
<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
|
||||
@@ -258,30 +432,38 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{simpleMode ? <span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>自动拆分 · 时长自动</span> : <Select<StoryboardShotCount>
|
||||
className="min-w-24"
|
||||
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-24"
|
||||
size="small"
|
||||
value={shotDuration}
|
||||
disabled={node.metadata?.status === "loading"}
|
||||
options={[
|
||||
{ value: "auto", label: "时长自动" },
|
||||
{ value: "5", label: "5 秒" },
|
||||
{ value: "10", label: "10 秒" },
|
||||
{ value: "15", label: "15 秒" },
|
||||
{ value: "30", label: "30 秒" },
|
||||
]}
|
||||
popupMatchSelectWidth={false}
|
||||
onChange={onShotDurationChange}
|
||||
/>}
|
||||
{simpleMode ? (
|
||||
<span className="text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
|
||||
自动拆分 · 时长自动
|
||||
</span>
|
||||
) : (
|
||||
<Select<StoryboardShotCount>
|
||||
className="min-w-24"
|
||||
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-24"
|
||||
size="small"
|
||||
value={shotDuration}
|
||||
disabled={node.metadata?.status === "loading"}
|
||||
options={[
|
||||
{ value: "auto", label: "时长自动" },
|
||||
{ value: "5", label: "5 秒" },
|
||||
{ value: "10", label: "10 秒" },
|
||||
{ value: "15", label: "15 秒" },
|
||||
{ value: "30", label: "30 秒" },
|
||||
]}
|
||||
popupMatchSelectWidth={false}
|
||||
onChange={onShotDurationChange}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<Send className="size-4" />}
|
||||
@@ -320,15 +502,38 @@ function StoryboardMiniPipeline({ pipeline, theme, rows }: { pipeline: CanvasSto
|
||||
{ key: "script", label: "分镜", state: rows.length > 0 ? "done" : "idle", hint: rows.length > 0 ? `${rows.length} 个镜头` : "待添加镜头" },
|
||||
{ key: "images", label: "分镜图(可选)", state: storyboardStepState(pipeline.images), hint: pipelineStatusLabel(pipeline.images) },
|
||||
{ key: "videos", label: "视频", state: storyboardStepState(pipeline.videos), hint: pipelineStatusLabel(pipeline.videos) },
|
||||
{ key: "final", label: "合并成片", state: pipeline.final.success > 0 ? "done" : pipeline.final.failed > 0 ? "error" : pipeline.final.loading > 0 || pipeline.successfulVideoNodeIds.length >= 2 ? "current" : "idle", hint: pipelineStatusLabel(pipeline.final) },
|
||||
{
|
||||
key: "final",
|
||||
label: "合并成片",
|
||||
state: pipeline.final.success > 0 ? "done" : pipeline.final.failed > 0 ? "error" : pipeline.final.loading > 0 || pipeline.successfulVideoNodeIds.length >= 2 ? "current" : "idle",
|
||||
hint: pipelineStatusLabel(pipeline.final),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="flex h-9 shrink-0 items-center justify-center overflow-hidden border-b px-4" style={{ borderColor: theme.node.stroke, background: theme.node.fill }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<div
|
||||
className="flex h-9 shrink-0 items-center justify-center overflow-hidden border-b px-4"
|
||||
style={{ borderColor: theme.node.stroke, background: theme.node.fill }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{steps.map((step, index) => (
|
||||
<Fragment key={step.key}>
|
||||
{index > 0 ? <span className="mx-2.5 h-px min-w-3.5 flex-1 max-w-20" style={{ background: theme.node.stroke }} /> : null}
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap text-[var(--fs-tiny)]" title={step.hint} style={{ color: step.state === "done" ? theme.node.muted : step.state === "current" ? theme.accent.primary : step.state === "error" ? theme.accent.danger : theme.node.faint, fontWeight: step.state === "current" || step.state === "error" ? 700 : 500 }}>
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ background: step.state === "done" ? theme.node.activeStroke : step.state === "current" ? theme.accent.primary : step.state === "error" ? theme.accent.danger : theme.node.stroke, boxShadow: step.state === "current" ? `0 0 0 3px ${theme.accent.primarySoft}` : undefined }} />
|
||||
<span
|
||||
className="flex items-center gap-1.5 whitespace-nowrap text-[var(--fs-tiny)]"
|
||||
title={step.hint}
|
||||
style={{
|
||||
color: step.state === "done" ? theme.node.muted : step.state === "current" ? theme.accent.primary : step.state === "error" ? theme.accent.danger : theme.node.faint,
|
||||
fontWeight: step.state === "current" || step.state === "error" ? 700 : 500,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{
|
||||
background: step.state === "done" ? theme.node.activeStroke : step.state === "current" ? theme.accent.primary : step.state === "error" ? theme.accent.danger : theme.node.stroke,
|
||||
boxShadow: step.state === "current" ? `0 0 0 3px ${theme.accent.primarySoft}` : undefined,
|
||||
}}
|
||||
/>
|
||||
{step.label}
|
||||
</span>
|
||||
</Fragment>
|
||||
@@ -339,21 +544,53 @@ function StoryboardMiniPipeline({ pipeline, theme, rows }: { pipeline: CanvasSto
|
||||
|
||||
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 ? generationErrorMessage(item.errorDetails) : undefined}>{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>;
|
||||
})}
|
||||
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 ? generationErrorMessage(item.errorDetails) : undefined}>
|
||||
{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>
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
|
||||
function generationBatchModeLabel(batch: CanvasGenerationBatch) {
|
||||
@@ -382,7 +619,16 @@ function batchItemTone(item?: CanvasGenerationBatchItem): CanvasNodeStatus | und
|
||||
return "loading";
|
||||
}
|
||||
|
||||
export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibleColumnsChange, onGenerateImages, onGenerateVideos, onVideoInputModeChange }: {
|
||||
export function CanvasScriptEditor({
|
||||
node,
|
||||
open,
|
||||
onClose,
|
||||
onUpdateRows,
|
||||
onVisibleColumnsChange,
|
||||
onGenerateImages,
|
||||
onGenerateVideos,
|
||||
onVideoInputModeChange,
|
||||
}: {
|
||||
node: CanvasNodeData | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -399,7 +645,15 @@ export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibl
|
||||
const videoInputMode = node?.metadata?.storyboardVideoInputMode || "direct";
|
||||
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;
|
||||
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) => {
|
||||
@@ -407,7 +661,7 @@ export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibl
|
||||
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 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;
|
||||
@@ -424,17 +678,58 @@ export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibl
|
||||
};
|
||||
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>)} />,
|
||||
}));
|
||||
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>,
|
||||
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 (
|
||||
@@ -443,33 +738,125 @@ export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibl
|
||||
<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)}>生成{videoInputMode === "keyframe" ? "首帧" : "分镜图"}</Button>
|
||||
<Segmented<StoryboardVideoInputMode> value={videoInputMode} options={[{ value: "direct", label: "直接生成" }, { value: "keyframe", label: "先做首帧" }]} onChange={onVideoInputModeChange} />
|
||||
<Button type="primary" icon={<Film className="size-4" />} disabled={!selectedIds.length} onClick={() => onGenerateVideos(selectedIds)}>{videoInputMode === "keyframe" ? "确认首帧并生成" : "生成视频"}</Button>
|
||||
<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)}>
|
||||
生成{videoInputMode === "keyframe" ? "首帧" : "分镜图"}
|
||||
</Button>
|
||||
<Segmented<StoryboardVideoInputMode>
|
||||
value={videoInputMode}
|
||||
options={[
|
||||
{ value: "direct", label: "直接生成" },
|
||||
{ value: "keyframe", label: "先做首帧" },
|
||||
]}
|
||||
onChange={onVideoInputModeChange}
|
||||
/>
|
||||
<Button type="primary" icon={<Film className="size-4" />} disabled={!selectedIds.length} onClick={() => onGenerateVideos(selectedIds)}>
|
||||
{videoInputMode === "keyframe" ? "确认首帧并生成" : "生成视频"}
|
||||
</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)) }} />
|
||||
<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="thin-scrollbar h-full w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words 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()} />;
|
||||
return (
|
||||
<textarea
|
||||
className="thin-scrollbar h-full w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words 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>;
|
||||
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, disabled }: { title: string; children: ReactNode; onClick: () => void; disabled?: boolean }) {
|
||||
return <button type="button" disabled={disabled} className="grid size-7 shrink-0 place-items-center rounded opacity-65 transition enabled:hover:bg-black/5 enabled:hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-25 dark:enabled:hover:bg-white/10" title={title} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onClick(); }}>{children}</button>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="grid size-7 shrink-0 place-items-center rounded opacity-65 transition enabled:hover:bg-black/5 enabled:hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-25 dark:enabled: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: [], narrativeIntent: "", viewerPOV: "", performanceBlocking: "", shotSize: "", emotion: "", lightingAndAtmosphere: "", audioEffects: "", camera: "", motion: "", timeBeats: "", imageGenerationPrompt: "", videoMotionPrompt: "", mustHave: [], optionalDetails: [], continuityOut: "", negativePrompt: "", referenceNodeIds: [], status: "idle" };
|
||||
return {
|
||||
id: `shot-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
shotNumber,
|
||||
durationSeconds: 6,
|
||||
plotDescription: "",
|
||||
dialogue: "",
|
||||
characters: [],
|
||||
narrativeIntent: "",
|
||||
viewerPOV: "",
|
||||
performanceBlocking: "",
|
||||
shotSize: "",
|
||||
emotion: "",
|
||||
lightingAndAtmosphere: "",
|
||||
audioEffects: "",
|
||||
camera: "",
|
||||
motion: "",
|
||||
timeBeats: "",
|
||||
imageGenerationPrompt: "",
|
||||
videoMotionPrompt: "",
|
||||
mustHave: [],
|
||||
optionalDetails: [],
|
||||
continuityOut: "",
|
||||
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 }) {
|
||||
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 inverseHitScale = 1 / Math.max(scale, 0.05);
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user