fix(canvas): 分镜脚本节点文字溢出与视频模型下拉异常 #84 (#85)

* fix(canvas): 分镜脚本节点文字溢出与视频模型下拉异常 #84

- CompactInput 补齐 overflow-y-auto/whitespace-pre-wrap/break-words,长文本不再横向溢出撑开列宽
- filterModelsByCapability 增加 modelCosts[].protocol 协议层回退,未显式配置 capability 时通过协议识别能力
- isVideoModelName 扩展 pika/runway/gen-3/hunyuan-video 等主流视频模型名关键字
- ModelPicker.options 保留当前选中 value,避免过滤后 options 为空触发跳转设置页

* fix(canvas): 模型过滤协议反向排除 + 分镜脚本节点模型选择提示 #84

- filterModelsByCapability 协议层提升至最高优先级,协议明确属于其他能力时直接排除,
  防止 video/image/audio 协议的模型被误标为 text 后混入文本下拉
- isVideoModelName 补充 cogvideo/mochi/latte/stable-video/svd/animatediff/ltx-video/minimax-video/abab-video 等关键字
- isImageModelName 补充 nano-banana/ideogram/recraft/playground/leonardo 等关键字
- 分镜脚本节点 ModelPicker 包裹 Tooltip,提示"脚本生成仅支持文本模型"

* fix(canvas): 节点标题截断修复 — flex min-width 约束 + 宽度边界 + hover tooltip

- NodeExternalHeader: 容器增加 max-w-full 双重约束,窄节点时不越界,宽节点时上限 240px
- NodeExternalHeader: 标题 span 增加 min-w-0,解决 flex min-width:auto 导致 truncate 失效
- 所有节点标题(绘图/技能/角色参考/音频/分镜脚本)增加 title 属性,hover 显示完整标题
- AudioNodeContent: 标题 span 增加 min-w-0,修复 flex 子项截断

* fix(canvas): 节点顶部标题真正截断 — JS 计算 maxWidth + overflow-hidden + flex-1

- NodeExternalHeader 容器: 移除 Tailwind 级联冲突的 max-w-[240px]/max-w-full,
  改用 JS 计算 Math.min(240, node.width) 作为 inline style maxWidth,
  从根本上解决两个 max-width 声明的优先级不确定问题
- 容器增加 overflow-hidden 作为第二道防线
- 内部 text/input/button 元素改用 flex-1 + min-w-0 替代固定 max-w-[Npx],
  让 flex 子项自动填充可用空间,文本在剩余空间内截断

* fix(canvas): 节点标题缩放溢出 — 考虑 canvas zoom scale

- maxHeaderWidth 改为 Math.min(240, node.width * scale),
  因为 header 的视觉宽度 = maxHeaderWidth(inverseScale 与 canvas scale 抵消),
  而节点的视觉宽度 = node.width * scale,两者必须比较同一维度
- 修复 zoom 缩小时(scale < 1)header 视觉宽度超过节点视觉宽度的溢出问题

---------

Co-authored-by: sikongyue <3155157671@qq.com>
This commit is contained in:
爱笑的毛毛虫
2026-08-03 08:40:04 +08:00
committed by GitHub
parent 9c910e8823
commit f8cb3d0201
4 changed files with 77 additions and 31 deletions

View File

@@ -559,7 +559,7 @@ function DrawingContent({ node, theme, drawingProjectId }: NodeContentRendererPr
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 px-4 pb-3 pt-12" style={{ background: `linear-gradient(to top, ${theme.node.fill}, ${theme.node.fill}e6 55%, transparent)` }}>
<div className="min-w-0">
<div className="truncate text-xs font-semibold">{node.title || "绘图"}</div>
<div className="truncate text-xs font-semibold" title={node.title || "绘图"}>{node.title || "绘图"}</div>
<div className="mt-0.5 text-[10px]" style={{ color: theme.node.muted }}>{shapeCount} 个图形 · {pageCount} 个页面</div>
</div>
<Pencil className="size-3.5 shrink-0" style={{ color: theme.accent.primary }} />
@@ -723,7 +723,7 @@ function SkillContent({ node, theme }: NodeContentRendererProps) {
<BookOpenCheck className="size-4" />
</span>
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{skill?.name || node.title || "技能"}</div>
<div className="truncate text-sm font-semibold" title={skill?.name || node.title || "技能"}>{skill?.name || node.title || "技能"}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px]" style={{ color: theme.node.muted }}>
<span>{skillCategoryLabel(skill?.category)}</span>
<span>·</span>
@@ -870,7 +870,7 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
</div>
{isCharacterReference ? (
<div className="max-w-[80%] text-center">
<div className="truncate text-xs font-medium" style={{ color: theme.node.muted }}>{node.metadata?.characterName || node.title}</div>
<div className="truncate text-xs font-medium" title={node.metadata?.characterName || node.title} style={{ color: theme.node.muted }}>{node.metadata?.characterName || node.title}</div>
<div className="mt-1 text-[10px] tracking-[0.12em] opacity-50">多视角参考 · 待生成</div>
</div>
) : <span className="text-[10px] tracking-[0.18em] opacity-50">空图片节点</span>}
@@ -924,7 +924,7 @@ function AudioNodeContent({ node, theme }: NodeContentRendererProps) {
<div className="flex h-full w-full flex-col justify-center gap-3 px-4" style={{ background: theme.node.fill, color: theme.node.text }}>
<div className="flex min-w-0 items-center gap-2 text-sm opacity-70">
<Music2 className="size-4 shrink-0" />
<span className="truncate">{node.title || "音频"}</span>
<span className="min-w-0 truncate" title={node.title || "音频"}>{node.title || "音频"}</span>
</div>
<audio ref={audioRef} src={url} controls preload="metadata" className="w-full" data-canvas-no-zoom />
</div>
@@ -1137,11 +1137,12 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
if (scale < NODE_EXTERNAL_HEADER_MIN_SCALE && !editing) return null;
const inverseScale = 1 / Math.max(scale, 0.05);
const Icon = nodeTypeIcon(node.type);
const maxHeaderWidth = Math.min(240, node.width * scale);
return (
<div
className="canvas-node-external-header absolute bottom-full left-0 z-40 flex h-6 max-w-[240px] items-center gap-1"
style={{ color: active ? theme.node.text : theme.node.label, transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`, transformOrigin: "left bottom" }}
className="canvas-node-external-header absolute bottom-full left-0 z-40 flex h-6 items-center gap-1 overflow-hidden"
style={{ maxWidth: maxHeaderWidth, color: active ? theme.node.text : theme.node.label, transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`, transformOrigin: "left bottom" }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
@@ -1150,7 +1151,7 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
<input
autoFocus
value={draft}
className="h-6 min-w-20 max-w-[190px] rounded border bg-transparent px-1.5 text-xs font-medium outline-none"
className="h-6 min-w-20 max-w-[190px] flex-1 truncate rounded border bg-transparent px-1.5 text-xs font-medium outline-none"
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 10px 28px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.06)` }}
onChange={(event) => onDraftChange(event.target.value)}
onFocus={(event) => event.currentTarget.select()}
@@ -1162,12 +1163,12 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
aria-label="节点名称"
/>
) : editable ? (
<button type="button" className="group flex min-w-0 items-center gap-1 rounded px-0.5 text-xs font-medium outline-none transition-opacity hover:opacity-100 focus-visible:ring-1" style={{ opacity: active ? 1 : 0.78, "--tw-ring-color": theme.node.activeStroke } as React.CSSProperties} onClick={onEdit} aria-label={`编辑节点名称:${node.title}`}>
<span className="max-w-[190px] truncate">{node.title}</span>
<button type="button" className="group flex min-w-0 flex-1 items-center gap-1 rounded px-0.5 text-xs font-medium outline-none transition-opacity hover:opacity-100 focus-visible:ring-1" style={{ opacity: active ? 1 : 0.78, "--tw-ring-color": theme.node.activeStroke } as React.CSSProperties} onClick={onEdit} aria-label={`编辑节点名称:${node.title}`}>
<span className="min-w-0 flex-1 truncate" title={node.title}>{node.title}</span>
<Pencil className="size-2.5 shrink-0 opacity-55 transition-opacity group-hover:opacity-100" />
</button>
) : (
<span className="max-w-[210px] truncate text-xs font-medium" style={{ opacity: active ? 1 : 0.78 }}>{node.title}</span>
<span className="min-w-0 flex-1 truncate text-xs font-medium" title={node.title} style={{ opacity: active ? 1 : 0.78 }}>{node.title}</span>
)}
</div>
);

View File

@@ -126,7 +126,7 @@ 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">{node.title || "分镜脚本"}</span>
<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-[11px] font-medium" title={batchSummary} style={{ color: batch?.status === "partial_failed" ? theme.accent.danger : theme.node.muted }}>{batchSummary}</span> : taskFeedback ? <span className="min-w-0 max-w-[38%] truncate text-[11px] font-medium" title={taskFeedback} style={{ color: node.metadata?.status === "error" ? theme.accent.danger : theme.node.muted }}>{taskFeedback}</span> : null}
<span className="text-xs font-medium" style={{ color: theme.node.muted }}>{rows.length} 镜 · {totalDuration}s</span>
{batch ? <>
@@ -210,19 +210,21 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
onWheel={(event) => event.stopPropagation()}
/>
<div className="flex min-w-0 items-center justify-end gap-2" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<div className="mr-auto min-w-36 max-w-56 flex-1">
<ModelPicker
className="!h-7 !w-full !min-w-0 !text-[10px] !font-normal [&_img]:!size-3 [&_.lucide]:!size-3"
fullWidth
config={generationConfig}
value={generationConfig.model}
capability="text"
placeholder="选择文本模型"
showSelectedPrice={false}
onChange={onModelChange}
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
/>
</div>
<Tooltip title="脚本生成需要文本理解与结构化输出能力,仅展示文本模型;视频/图片模型无法生成分镜表" placement="topLeft">
<div className="mr-auto min-w-36 max-w-56 flex-1">
<ModelPicker
className="!h-7 !w-full !min-w-0 !text-[10px] !font-normal [&_img]:!size-3 [&_.lucide]:!size-3"
fullWidth
config={generationConfig}
value={generationConfig.model}
capability="text"
placeholder="选择文本模型"
showSelectedPrice={false}
onChange={onModelChange}
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
/>
</div>
</Tooltip>
{simpleMode ? <span className="text-[11px]" style={{ color: theme.node.muted }}>自动拆分 · 默认时长</span> : <Select<StoryboardShotCount>
className="min-w-32"
size="small"
@@ -448,7 +450,7 @@ export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibl
}
function CompactInput({ value, placeholder, borderColor, onChange }: { value: string; placeholder: string; borderColor: string; onChange: (value: string) => void }) {
return <textarea className="h-full resize-none border-r bg-transparent px-4 py-2.5 text-xs leading-5 outline-none transition placeholder:opacity-35 focus:bg-black/[0.02] dark:focus:bg-white/[0.025]" style={{ borderColor }} value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} />;
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" }) {

View File

@@ -27,7 +27,12 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const options = useMemo(
() => Array.from(new Set([...(config.channelMode === "local" && !capability ? [value] : []), ...selectableModelsByCapability(config, capability)].filter((model): model is string => Boolean(model)))),
() => {
const filtered = selectableModelsByCapability(config, capability);
const current = value?.trim();
const currentIncluded = current ? filtered.includes(current) : true;
return Array.from(new Set([...filtered, ...(!currentIncluded && current ? [current] : [])].filter((model): model is string => Boolean(model))));
},
[capability, config, value],
);
const optionGroups = useMemo(() => {

View File

@@ -129,7 +129,29 @@ export type ConfigStoreSnapshot = {
function isVideoModelName(model: string) {
const value = modelOptionName(model).toLowerCase();
return value.includes("seedance") || value.includes("video") || value.includes("sora") || value.includes("veo") || value.includes("kling") || value.includes("wan") || value.includes("hailuo");
return value.includes("seedance")
|| value.includes("video")
|| value.includes("sora")
|| value.includes("veo")
|| value.includes("kling")
|| value.includes("wan")
|| value.includes("hailuo")
|| value.includes("pika")
|| value.includes("runway")
|| value.includes("gen-3")
|| value.includes("gen3")
|| value.includes("hunyuan-video")
|| value.includes("hunyuanvideo")
|| value.includes("cogvideo")
|| value.includes("mochi")
|| value.includes("latte")
|| value.includes("stable-video")
|| value.includes("svd")
|| value.includes("animatediff")
|| value.includes("ltx-video")
|| value.includes("ltxvideo")
|| value.includes("minimax-video")
|| value.includes("abab-video");
}
function isImageModelName(model: string) {
@@ -146,7 +168,13 @@ function isImageModelName(model: string) {
value.includes("flux") ||
value.includes("sdxl") ||
value.includes("stable-diffusion") ||
value.includes("midjourney"))
value.includes("midjourney") ||
value.includes("nano-banana") ||
value.includes("nanobanana") ||
value.includes("ideogram") ||
value.includes("recraft") ||
value.includes("playground") ||
value.includes("leonardo"))
);
}
@@ -172,10 +200,20 @@ export function filterModelsByCapability(models: string[], capability?: ModelCap
return models.filter((model) => {
const decoded = decodeChannelModel(model);
const channel = decoded ? channels?.find((item) => item.id === decoded.channelId) : undefined;
const configuredCapability = channel?.modelCosts?.find((item) => item.model === decoded?.model)?.capability;
if (configuredCapability) return configuredCapability === capability;
const modelName = decoded?.model || modelOptionName(model);
const costEntry = channel?.modelCosts?.find((item) => item.model === modelName);
// 协议层优先级最高:协议决定 API 端点,明确属于其他能力时直接排除,
// 防止用户将 video/image/audio 协议的模型误标为 text 后混入文本下拉。
const protocolCapability = modelProtocolCapability(costEntry?.protocol);
if (protocolCapability) return protocolCapability === capability;
// 渠道接口层:渠道级协议推断能力
const channelCapability = capabilityForChannelInterface(channel?.interfaceType);
return channelCapability ? channelCapability === capability : modelMatchesCapability(model, capability);
if (channelCapability) return channelCapability === capability;
// 配置能力层:用户显式标记的 capability
const configuredCapability = costEntry?.capability;
if (configuredCapability) return configuredCapability === capability;
// 模型名启发式:最后回退
return modelMatchesCapability(model, capability);
});
}