feat(*): 模型能力配置 - 支持模型级视频限制校验与协议管理

This commit is contained in:
ddCat
2026-08-06 15:34:45 +08:00
parent a18673a101
commit 9ca9dd9df6
36 changed files with 1692 additions and 401 deletions

View File

@@ -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 };