feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道

Refs #225

Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
ddCat
2026-08-15 12:45:29 +08:00
parent f549767cda
commit 914717cae6
179 changed files with 49746 additions and 1897 deletions

View File

@@ -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 (