* 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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" }) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user