feat(*): 模型能力配置 - 支持模型级视频限制校验与协议管理
This commit is contained in:
@@ -7,6 +7,7 @@ import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resol
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { modelCapabilityConfigFor, normalizeVideoValue, videoDurationAllowed } from "@/lib/model-capabilities";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
@@ -45,7 +46,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
const mode = node.metadata?.generationMode || "image";
|
||||
const simpleMode = workspaceMode === "simple";
|
||||
const config = buildNodeConfig(globalConfig, node, mode);
|
||||
const operationOptions = node.metadata?.videoEditOperation === "concat" ? [...videoOperationOptions, { label: "合并成片", value: "concat" as const }] : videoOperationOptions;
|
||||
const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined;
|
||||
const operationOptions = videoProfile ? videoOperationOptions.filter((item) => videoProfile.operations.includes(item.value) || item.value === "concat") : videoOperationOptions;
|
||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const priceChannel = resolveModelChannel(config, config.model);
|
||||
const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? count : 1, seconds: mode === "video" ? config.videoSeconds : 1 });
|
||||
@@ -53,7 +55,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
|
||||
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
|
||||
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
|
||||
const canGenerate = hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput);
|
||||
const capabilityError = videoProfile ? videoCapabilityError(videoProfile, config.videoSeconds, node.metadata?.composerContent ?? node.metadata?.prompt ?? "", inputSummary, node.metadata?.videoEditOperation) : "";
|
||||
const canGenerate = (hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput)) && !capabilityError;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
||||
@@ -153,6 +156,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
</div>
|
||||
)}
|
||||
|
||||
{capabilityError ? <div className="mb-2 rounded-md px-2 py-1.5 text-[var(--fs-tiny)]" style={{ background: theme.accent.danger + "18", color: theme.accent.danger }}>{capabilityError}</div> : null}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
|
||||
@@ -213,16 +218,18 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const storedModel = node.metadata?.model;
|
||||
const model = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel;
|
||||
const videoProfile = mode === "video" ? modelCapabilityConfigFor(globalConfig, model).video! : undefined;
|
||||
const normalizedVideo = videoProfile ? normalizeVideoValue(videoProfile, { seconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds, ratio: node.metadata?.size || globalConfig.size || defaultConfig.size, resolution: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality }) : undefined;
|
||||
return {
|
||||
...globalConfig,
|
||||
model,
|
||||
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
|
||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
size: normalizedVideo?.ratio || node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
transparentBackground: (node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false",
|
||||
videoSeconds: normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds),
|
||||
vquality: normalizeVideoResolution(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality),
|
||||
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
|
||||
videoSeconds: normalizedVideo?.seconds || normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds),
|
||||
vquality: normalizedVideo?.resolution.replace(/p$/i, "") || normalizeVideoResolution(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality),
|
||||
videoGenerateAudio: videoProfile?.generateAudio.supported ? node.metadata?.generateAudio || globalConfig.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false",
|
||||
videoWatermark: videoProfile?.watermark.supported ? node.metadata?.watermark || globalConfig.videoWatermark || String(videoProfile.watermark.default) : "false",
|
||||
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
|
||||
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
|
||||
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
|
||||
@@ -238,6 +245,15 @@ function videoConfigPatch(key: keyof AiConfig, value: string) {
|
||||
return { [key]: value };
|
||||
}
|
||||
|
||||
function videoCapabilityError(profile: NonNullable<ReturnType<typeof modelCapabilityConfigFor>["video"]>, seconds: string, prompt: string, input: CanvasConfigNodePanelProps["inputSummary"], operation?: string) {
|
||||
if (!videoDurationAllowed(profile, Number(seconds))) return "当前模型不支持该视频时长";
|
||||
if (Array.from(prompt).length > profile.references.promptMaxChars) return `提示词超过模型限制(最多 ${profile.references.promptMaxChars} 字)`;
|
||||
if (input.imageCount > profile.references.maxImages || input.videoCount > profile.references.maxVideos || input.audioCount > profile.references.maxAudios) return "参考素材数量超过当前模型限制";
|
||||
const resolvedOperation = operation || (input.audioCount > 0 && input.imageCount === 0 && input.videoCount === 0 ? "audio_to_video" : input.videoCount > 0 ? "extend" : input.imageCount > 0 ? "image_to_video" : "text_to_video");
|
||||
if (!profile.operations.includes(resolvedOperation)) return "当前模型不支持该生成模式";
|
||||
return "";
|
||||
}
|
||||
|
||||
function audioConfigPatch(key: CanvasAudioSettingKey, value: string) {
|
||||
if (key === "audioVoice") return { audioVoice: value };
|
||||
if (key === "audioFormat") return { audioFormat: value };
|
||||
|
||||
@@ -77,7 +77,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
const modalShadow = darkSurface ? `0 30px 90px ${theme.spatial.shadow}` : "0 24px 72px rgba(15,23,42,.16)";
|
||||
const referenceShelfHeight = activeReferenceCount ? 42 : 0;
|
||||
const composerMinHeight = activeReferenceCount ? 82 : 58;
|
||||
const composerHeight = Math.min(144, Math.max(composerMinHeight, Math.ceil(promptContentHeight + referenceShelfHeight)));
|
||||
const composerHeight = Math.min(224, Math.max(composerMinHeight, Math.ceil(promptContentHeight + referenceShelfHeight)));
|
||||
const isSubmitDisabled = !isRunning && !prompt.trim();
|
||||
const canExpandPrompt = mode === "image" || mode === "video";
|
||||
const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture);
|
||||
@@ -248,7 +248,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
{renderComposerHeader(false)}
|
||||
|
||||
<div
|
||||
className="relative mt-2 flex max-h-36 flex-col overflow-hidden rounded-xl outline-none ring-0 transition-[height] duration-150 focus-within:outline-none focus-within:ring-0 motion-reduce:transition-none"
|
||||
className="relative mt-2 flex max-h-[var(--prompt-panel-input-max-height)] flex-col overflow-hidden rounded-xl outline-none ring-0 transition-[height] duration-150 focus-within:outline-none focus-within:ring-0 motion-reduce:transition-none"
|
||||
style={{ height: composerHeight, background: composerSurface, boxShadow: composerShadow }}
|
||||
>
|
||||
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} />
|
||||
|
||||
@@ -462,18 +462,25 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
{data.metadata?.versionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-3 top-3 z-[var(--node-z-overlay)] inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[var(--fs-tiny)] font-semibold backdrop-blur transition hover:brightness-110"
|
||||
style={{ background: theme.toolbar.panel, borderColor: data.metadata.versionPrimary ? theme.node.activeStroke : theme.toolbar.border, color: data.metadata.versionPrimary ? theme.node.activeStroke : theme.node.text }}
|
||||
title="查看版本对比"
|
||||
className="absolute left-3 top-3 z-[var(--node-z-overlay)] grid size-7 place-items-center rounded-[var(--r-full)] border p-0.5 text-[var(--node-badge-fs)] font-semibold leading-none backdrop-blur-md transition-[transform,background,border-color,box-shadow] hover:-translate-y-px focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-reduce:hover:translate-y-0"
|
||||
style={{
|
||||
background: data.metadata.versionPrimary ? theme.accent.primarySoft : theme.toolbar.panel,
|
||||
borderColor: data.metadata.versionPrimary ? theme.accent.primary : theme.toolbar.border,
|
||||
color: data.metadata.versionPrimary ? theme.accent.primary : theme.node.text,
|
||||
boxShadow: data.metadata.versionPrimary ? "0 0 0 2px " + theme.accent.primarySoft : "var(--shadow-sm)",
|
||||
outlineColor: theme.accent.primary,
|
||||
}}
|
||||
title={data.metadata.versionLabel + (data.metadata.versionPrimary ? " · 主版本" : "") + ",点击查看版本对比"}
|
||||
aria-label={data.metadata.versionLabel + (data.metadata.versionPrimary ? ",主版本" : "") + ",查看版本对比"}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => { event.stopPropagation(); onOpenVersions?.(data); }}
|
||||
>
|
||||
<Star className={`size-3 ${data.metadata.versionPrimary ? "fill-current" : ""}`} />{data.metadata.versionLabel}
|
||||
{data.metadata.versionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
{showStatusTrack ? (
|
||||
<div className={`absolute right-3 top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 ${data.metadata?.versionLabel ? "max-w-[calc(100%-104px)]" : "max-w-[calc(100%-24px)]"}`}>
|
||||
{resourceLabel ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
|
||||
{resourceLabel && data.type !== CanvasNodeType.Image ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
|
||||
{hasMediaContent && !readOnly ? <ResourceStorageBadge storageKey={data.metadata?.storageKey} active={isActive} theme={theme} /> : null}
|
||||
{isBatchRoot ? <BatchToggleBadge count={batchCount} expanded={batchExpanded} theme={theme} onToggle={() => onToggleBatch?.(data.id)} /> : null}
|
||||
{isBatchChild && !readOnly ? <BatchPrimaryBadge visible={batchPrimary || hovered || isSelected} selected={batchPrimary} theme={theme} onSelect={() => onSetBatchPrimary?.(data)} /> : null}
|
||||
|
||||
@@ -12,10 +12,10 @@ export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimar
|
||||
<Modal title="版本对比" open={open} footer={null} width={modalWidth} centered onCancel={onClose} styles={{ body: { overflow: "hidden" } }}>
|
||||
<div className="thin-scrollbar grid max-h-[70vh] grid-flow-col auto-cols-[328px] gap-3 overflow-x-auto pb-2">
|
||||
{versions.map((node) => (
|
||||
<article key={node.id} className="overflow-hidden rounded-lg border" style={{ borderColor: node.metadata?.versionPrimary ? theme.node.activeStroke : theme.node.stroke, background: theme.node.panel }}>
|
||||
<article key={node.id} className="overflow-hidden rounded-[var(--r-lg)] border" style={{ borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, background: theme.node.panel, boxShadow: node.metadata?.versionPrimary ? "0 0 0 2px " + theme.accent.primarySoft : undefined }}>
|
||||
<div className="flex h-11 items-center justify-between gap-3 border-b px-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2"><span className="grid size-6 shrink-0 place-items-center rounded-md text-xs font-bold" style={{ background: theme.toolbar.itemHover }}>{node.metadata?.versionLabel || "-"}</span><span className="min-w-0 truncate text-xs font-semibold" title={node.title}>{node.title}</span></div>
|
||||
{node.metadata?.versionPrimary ? <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.activeStroke }}><Check className="size-3" />主版本</span> : null}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2"><span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-full)] border text-[var(--node-badge-fs)] font-semibold leading-none" style={{ background: node.metadata?.versionPrimary ? theme.accent.primarySoft : theme.toolbar.itemHover, borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, color: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.text }}>{node.metadata?.versionLabel || "-"}</span><span className="min-w-0 truncate text-xs font-semibold" title={node.title}>{node.title}</span></div>
|
||||
{node.metadata?.versionPrimary ? <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[var(--fs-tiny)] font-medium" style={{ color: theme.accent.primary }}><Check className="size-3" />主版本</span> : null}
|
||||
</div>
|
||||
<button type="button" className="block h-52 w-full overflow-hidden" style={{ background: theme.node.fill }} onClick={() => onFocus(node.id)}>
|
||||
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt={node.title || "版本图片"} className="size-full object-contain" /> : node.type === CanvasNodeType.Video && node.metadata?.content ? <video src={node.metadata.content} className="size-full object-contain" muted preload="metadata" aria-label={node.title || "版本视频"} /> : <span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>点击定位到画布节点</span>}
|
||||
|
||||
Reference in New Issue
Block a user