feat(models): 统一模型规格价格与路由

This commit is contained in:
kinoko
2026-08-22 17:00:08 +08:00
parent d268a938e5
commit 236b850177
36 changed files with 3404 additions and 421 deletions

View File

@@ -3,9 +3,9 @@ import { ConfigProvider, Switch } from "antd";
import { type CanvasTheme } from "@/lib/canvas-theme";
import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers";
import { normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { mergedImageCapabilityConfig } from "@/lib/model-selection";
import { type AiConfig } from "@/stores/use-config-store";
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
const qualityOptions = [
{ value: "auto", label: "自动" },
@@ -74,7 +74,8 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
: imageAspectOptions(profile);
const selectedAspect = availableAspects.find((item) => imageOptionValue(profile, item) === activeSize || item.value === activeSize) || availableAspects.find((item) => item.label === activeRatio);
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value });
const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value });
const priceTiers = imageModelPriceTiers(config);
const selectAspect = (value: string) => {
const option = availableAspects.find((item) => item.value === value);
onConfigChange("size", option ? imageOptionValue(profile, option) : "auto");
@@ -110,8 +111,8 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
{profile.quality.supported ? <div className="space-y-2">
<SettingTitle color={theme.node.muted}>{isGrokResolutionQuality(profile) ? "分辨率" : "质量"}</SettingTitle>
<div className={`grid gap-1.5 ${activeQualityOptions.length <= 2 ? "grid-cols-2" : "grid-cols-4"}`}>
{activeQualityOptions.map((item) => (
<OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
{activeQualityOptions.map((item) => (
<OptionPill key={item.value} selected={quality === item.value} disabled={!hasPriceTierForImageSelection(priceTiers, item.value, activeSize)} theme={theme} onClick={() => onConfigChange("quality", item.value)}>
{item.label}
</OptionPill>
))}
@@ -262,12 +263,27 @@ export function imageSizeLabel(size: string) {
return resolutionLabel !== size ? resolutionLabel : aspectOptions.find((item) => (item.size || item.value) === size || item.value === size)?.label || size;
}
function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
function imageModelPriceTiers(config: AiConfig) {
const channel = resolveModelChannel(config, config.model || config.imageModel);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model || config.imageModel));
return cost?.logicalPriceTiers || [];
}
function hasPriceTierForImageSelection(tiers: ReturnType<typeof imageModelPriceTiers>, quality: string, size: string) {
if (!tiers.length) return true;
return tiers.some((tier) => {
const selector = tier.selector || {};
return (!selector.quality || selector.quality === "*" || selector.quality === quality.toLowerCase()) && (!selector.size || selector.size === "*" || selector.size === size.toLowerCase());
});
}
function OptionPill({ selected, disabled = false, theme, onClick, children }: { selected: boolean; disabled?: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
className="h-8 cursor-pointer rounded-full px-2 text-xs transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
className="h-8 cursor-pointer rounded-full px-2 text-xs transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 disabled:cursor-not-allowed disabled:opacity-40"
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
disabled={disabled}
onMouseDown={(event) => event.stopPropagation()}
onClick={onClick}
>

View File

@@ -4,12 +4,14 @@ import { Popover } from "antd";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import { normalizeVideoResolution } from "@/lib/video-generation-options";
import { cn } from "@/lib/utils";
import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { ModelLogo } from "@/components/model-logo";
import { quoteLogicalModel, type LogicalModelQuote, type ModelRequestIntent } from "@/services/api/logical-models";
type ModelPickerProps = {
config: AiConfig;
@@ -53,12 +55,31 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
return channelGroups;
}, [config, options]);
const storedCurrent = value?.trim() || "";
const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, requirements) || storedCurrent;
// 参数档位会在选中模型后由调用方归一到其能力配置,不能因为旧模型留下的参数而禁止切换。
const selectionRequirements = requirements ? { ...requirements, videoSeconds: undefined, imageSize: undefined, options: undefined } : undefined;
const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent;
// 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。
const current = options.includes(resolvedCurrent) ? resolvedCurrent : "";
const currentPrice = modelMenuPrice(config, current);
const currentPrice = modelMenuPrice(config, current, capability);
const quoteRequest = useMemo(() => modelQuoteRequest(config, current, capability, requirements), [capability, config, current, requirements]);
const [routeQuote, setRouteQuote] = useState<LogicalModelQuote | undefined>();
const creationVariant = variant === "creation";
useEffect(() => {
if (!showSelectedPrice || !creditsEnabled || !quoteRequest) {
setRouteQuote(undefined);
return;
}
const controller = new AbortController();
setRouteQuote(undefined);
quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal)
.then((payload) => setRouteQuote(payload.quote))
.catch(() => {
if (!controller.signal.aborted) setRouteQuote(undefined);
});
return () => controller.abort();
}, [creditsEnabled, quoteRequest, showSelectedPrice]);
useEffect(() => {
const closeOtherPicker = (event: Event) => {
if ((event as CustomEvent<string>).detail !== pickerId) setOpen(false);
@@ -141,26 +162,28 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
<div className="grid min-w-0 gap-1">
{group.models.map((modelGroup) => {
const selected = modelGroup.models.includes(current);
const compatibleModel = compatibleModelInGroup(config, modelGroup.models, requirements, selected ? current : undefined);
const model = compatibleModel || modelGroup.models[0];
const displayModel = model;
const incompatibleReason = compatibleModel ? "" : modelCompatibilityError(config, modelGroup.models[0], requirements) || "当前输入不符合该模型能力,切换后将重置参数";
const model = compatibleModelInGroup(config, modelGroup.models, selectionRequirements, selected ? current : undefined);
const displayModel = model || (selected ? current : modelGroup.models[0]);
const disabledReason = model ? "" : modelCompatibilityError(config, modelGroup.models[0], selectionRequirements) || "当前输入不符合该模型能力";
return (
<button
key={modelGroup.key}
type="button"
role="option"
aria-selected={selected}
title={incompatibleReason || pickerModelOptionLabel(config, displayModel, showConfiguredModelName)}
className="canvas-model-picker-option"
style={{ background: "transparent", color: theme.node.text }}
aria-disabled={Boolean(disabledReason)}
disabled={Boolean(disabledReason)}
title={disabledReason || pickerModelOptionLabel(config, displayModel, showConfiguredModelName)}
className="canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45"
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
onClick={() => {
if (!model) return;
onChange(model);
setOpen(false);
window.requestAnimationFrame(() => triggerRef.current?.focus());
}}
>
<ModelLabel config={config} model={displayModel} capability={capability} theme={theme} creationVariant={creationVariant} showConfiguredModelName={showConfiguredModelName} showPrice={creditsEnabled} disabledReason={incompatibleReason} />
<ModelLabel config={config} model={displayModel} capability={capability} theme={theme} creationVariant={creationVariant} showConfiguredModelName={showConfiguredModelName} showPrice={creditsEnabled} disabledReason={disabledReason} />
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
</button>
);
@@ -206,7 +229,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
<ModelIcon config={config} model={current} />
</span>
<span className="min-w-0 flex-1 truncate">{current ? (creationVariant ? pickerModelDisplayName(config, current, showConfiguredModelName) : pickerModelOptionLabel(config, current, showConfiguredModelName)) : placeholder}</span>
{showSelectedPrice && creditsEnabled ? <ModelPrice price={currentPrice} compact /> : null}
{showSelectedPrice && creditsEnabled ? <ModelPrice price={currentPrice} quote={routeQuote} compact /> : null}
</span>
<ChevronDown className={cn("canvas-model-picker-chevron", open && "is-open")} aria-hidden="true" />
</button>
@@ -257,7 +280,7 @@ function ModelLabel({
{capabilitySummary}
</span>
</span>
{showPrice ? <ModelPrice price={modelMenuPrice(config, model)} /> : null}
{showPrice ? <ModelPrice price={modelMenuPrice(config, model, capability, true)} /> : null}
{!creationVariant && meta.time ? (
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[var(--fs-tiny)] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>
{meta.time}
@@ -326,17 +349,24 @@ function formatDurationSummary(profile: NonNullable<ReturnType<typeof modelCapab
return `${profile.duration.min || values[0]}-${profile.duration.max || values[values.length - 1]}s`;
}
type ModelMenuPrice = { value: number; unit: "次" | "秒" | "百万 Token" };
type ModelMenuPrice =
| { kind: "tiers"; label: string; compactLabel: string; title: string }
| { kind: "estimate" }
| { kind: "fixed"; value: number; unit: "次" | "秒" | "百万 Token" };
function modelMenuPrice(config: AiConfig, model: string): ModelMenuPrice | null | undefined {
function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapability, summary = false): ModelMenuPrice | null | undefined {
if (!model) return undefined;
const channel = resolveModelChannel(config, model);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
if (!cost) return channel.scope === "system" ? null : undefined;
if (cost.billingMode === "token") {
return { value: (cost.outputTokenPriceMicrocredits || 0) / 1_000_000, unit: "百万 Token" };
if (cost.pricePolicy === "channel") {
const tiers = cost.logicalPriceTiers || [];
if (!tiers.length) return null;
const matched = summary ? tiers : priceTiersForCurrentSelection(tiers, capability, config);
return channelTierPriceSummary(matched.length ? matched : tiers, tiers);
}
return { value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" };
if (cost.billingMode === "token") return { kind: "estimate" };
return { kind: "fixed", value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" };
}
function pickerModelDisplayName(config: AiConfig, model: string, showConfiguredModelName: boolean) {
@@ -349,9 +379,131 @@ function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredM
return channel.scope === "system" ? displayName : `${displayName}(${channel.name})`;
}
function ModelPrice({ price, compact = false }: { price: ModelMenuPrice | null | undefined; compact?: boolean }) {
function priceTiersForCurrentSelection(
tiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
capability: ModelCapability | undefined,
config: AiConfig,
) {
const requested: Record<string, string> = {};
if (capability === "video") {
const resolution = normalizeTierResolution(config.vquality);
if (resolution !== "*") requested.vquality = resolution;
const seconds = Math.max(0, Math.floor(Number(config.videoSeconds) || 0));
if (seconds > 0) requested.videoSeconds = String(seconds);
}
if (capability === "image") {
if (config.quality && config.quality !== "auto") requested.quality = config.quality.toLowerCase();
if (config.size && config.size !== "auto") requested.size = config.size.toLowerCase();
}
let bestScore = -1;
let matched: typeof tiers = [];
for (const tier of tiers) {
const selector = tier.selector || {};
const conditions = Object.entries(selector).filter(([, value]) => value && value !== "*");
if (conditions.some(([key, value]) => requested[key] !== value)) continue;
const score = conditions.length;
if (score > bestScore) {
bestScore = score;
matched = [tier];
} else if (score === bestScore) {
matched.push(tier);
}
}
return matched;
}
function normalizeTierResolution(value: string) {
const raw = String(value || "").trim();
if (!raw || raw === "*") return "*";
return `${normalizeVideoResolution(raw)}p`;
}
function channelTierPriceSummary(
visibleTiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
allTiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
): Extract<ModelMenuPrice, { kind: "tiers" }> {
const fixedRequestValues = visibleTiers
.filter((tier) => tier.billingMode === "fixed_request")
.map((tier) => tier.unitPriceMicrocredits / 1_000_000)
.filter((value) => value > 0);
const perSecondValues = visibleTiers
.filter((tier) => tier.billingMode === "per_second")
.map((tier) => tier.unitPriceMicrocredits / 1_000_000)
.filter((value) => value > 0);
const hasTokenTier = visibleTiers.some((tier) => tier.billingMode === "token");
const label = fixedRequestValues.length
? formatPriceRange(fixedRequestValues, "积分")
: perSecondValues.length
? formatPriceRange(perSecondValues, "积分/秒")
: hasTokenTier
? "按量预估"
: "未配置";
return {
kind: "tiers",
label,
compactLabel: label,
title: `系统规格价格:${allTiers.map((tier) => `${tierSpecificationLabel(tier)} ${tierPriceLabel(tier)}`).join(";")}`,
};
}
function formatPriceRange(values: number[], suffix: string) {
const unique = Array.from(new Set(values)).sort((left, right) => left - right);
const format = (value: number) => value.toLocaleString("zh-CN", { maximumFractionDigits: 3 });
return unique.length === 1 ? `${format(unique[0])} ${suffix}` : `${format(unique[0])}-${format(unique[unique.length - 1])} ${suffix}`;
}
function tierResolutionLabel(value: string) {
const normalized = normalizeTierResolution(value);
return normalized === "*" ? "全部分辨率" : normalized.toUpperCase();
}
function tierDurationLabel(seconds: number) {
return seconds > 0 ? `${seconds} 秒` : "全部时长";
}
function tierSpecificationLabel(tier: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>[number]) {
const selector = tier.selector || {};
const operationLabels: Record<string, string> = { text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频" };
const operation = selector.operation && selector.operation !== "*" ? (operationLabels[selector.operation] || selector.operation) : "";
const details = [
operation,
selector.quality && selector.quality !== "*" ? selector.quality.toUpperCase() : "",
selector.size && selector.size !== "*" ? selector.size : "",
tier.resolution !== "*" ? tierResolutionLabel(tier.resolution) : "",
tier.videoSeconds ? tierDurationLabel(tier.videoSeconds) : "",
].filter(Boolean);
return details.length ? details.join(" / ") : "默认规格";
}
function tierPriceLabel(tier: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>[number]) {
if (tier.billingMode === "token") return "按量预估";
return `${formatPriceRange([tier.unitPriceMicrocredits / 1_000_000], tier.billingMode === "per_second" ? "积分/秒" : "积分")}`;
}
function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice | null | undefined; quote?: LogicalModelQuote; compact?: boolean }) {
if (quote) {
const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 });
const label = quote.estimated ? `预计 ${amount}` : `${amount}`;
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`${quote.estimated ? "预计" : "本次"}消耗 ${amount} 积分`}>
<Coins className="size-3" />
{compact ? label : `${label} 积分`}
</span>
);
}
if (price === undefined) return null;
if (price === null) return compact ? null : <span className="shrink-0 text-[var(--fs-tiny)] text-foreground/40">未配置</span>;
if (price.kind === "tiers") {
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={price.title}>
<Coins className="size-3" />
{compact ? price.compactLabel : price.label}
</span>
);
}
if (price.kind === "estimate") {
return <span className="shrink-0 text-[var(--fs-tiny)] font-medium text-amber-600 dark:text-amber-300">按量预估</span>;
}
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`每${price.unit}消耗 ${price.value.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`}>
<Coins className="size-3" />
@@ -360,6 +512,31 @@ function ModelPrice({ price, compact = false }: { price: ModelMenuPrice | null |
);
}
function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): { logicalModelID: string; intent: ModelRequestIntent } | undefined {
if (!capability || !value) return undefined;
const channel = resolveModelChannel(config, value);
if (channel.scope !== "system") return undefined;
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
if (!cost?.logicalModelId) return undefined;
const input = requirements?.input;
const intent: ModelRequestIntent = {
capability,
operation: requirements?.videoOperation,
inputs: {
image: (input?.imageCount || 0) + (input?.characterCount || 0),
video: input?.videoCount || 0,
audio: input?.audioCount || 0,
},
options: {
...modelRequestOptions(config, capability),
...(requirements?.options || {}),
...(requirements?.videoSeconds ? { videoSeconds: Number(requirements.videoSeconds) } : {}),
...(requirements?.imageSize ? { size: requirements.imageSize } : {}),
},
};
return { logicalModelID: cost.logicalModelId, intent };
}
function modelMenuMeta(model: string, capability?: ModelCapability): { description: string; time?: string } {
const name = modelOptionName(model).toLowerCase();
if (capability === "image") {

View File

@@ -6,7 +6,7 @@ import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedan
import { type CanvasTheme } from "@/lib/canvas-theme";
import { normalizeVideoDuration, normalizeVideoResolution, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import { modelOptionName, resolveModelChannel, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
const sizeOptions = [
{ value: "1280x720", label: "横屏", width: 1280, height: 720 },
@@ -27,11 +27,12 @@ type VideoSettingsPanelProps = {
export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[292px] space-y-3" }: VideoSettingsPanelProps) {
const profile = modelCapabilityConfigFor(config, config.model).video!;
const priceTiers = modelPriceTiers(config);
if (resolveModelRequestConfig(config, config.model).interfaceType === "volcengine-jimeng-video") {
return <JiMengVideoSettingsPanel config={config} profile={profile} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
return <JiMengVideoSettingsPanel config={config} profile={profile} priceTiers={priceTiers} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
}
if (isSeedanceVideoConfig(config)) {
return <SeedanceVideoSettingsPanel config={config} profile={profile} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
return <SeedanceVideoSettingsPanel config={config} profile={profile} priceTiers={priceTiers} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
}
const seconds = normalizeVideoDuration(config.videoSeconds);
@@ -53,7 +54,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
{configuredResolutions.length ? <SettingGroup title="分辨率" color={theme.node.muted}>
<div className="grid grid-cols-3 gap-1.5">
{configuredResolutions.map((item) => (
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
<OptionPill key={item.value} selected={resolution === item.value} disabled={!hasPriceTierForVideoSelection(priceTiers, item.value, Number(seconds))} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
{item.label}
</OptionPill>
))}
@@ -82,7 +83,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
</div>
</SettingGroup>
<SettingGroup title="秒数" color={theme.node.muted}>
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} disabled={(value) => !hasPriceTierForVideoSelection(priceTiers, resolution, value)} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
</SettingGroup>
{profile.generateAudio.supported || profile.watermark.supported ? <SettingGroup title="输出" color={theme.node.muted}><div className="grid grid-cols-2 gap-3 rounded-md px-2" style={{ background: theme.toolbar.itemHover }}>{profile.generateAudio.supported ? <SwitchRow label="生成声音" checked={generateAudio} theme={theme} onChange={(checked) => onConfigChange("videoGenerateAudio", String(checked))} /> : null}{profile.watermark.supported ? <SwitchRow label="添加水印" checked={watermark} theme={theme} onChange={(checked) => onConfigChange("videoWatermark", String(checked))} /> : null}</div></SettingGroup> : null}
</div>
@@ -90,7 +91,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
);
}
function JiMengVideoSettingsPanel({ config, profile, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig }) {
function JiMengVideoSettingsPanel({ config, profile, priceTiers, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig; priceTiers: ReturnType<typeof modelPriceTiers> }) {
const seconds = normalizeVideoDuration(config.videoSeconds);
return (
<ImageSettingsTheme theme={theme}>
@@ -102,14 +103,14 @@ function JiMengVideoSettingsPanel({ config, profile, onConfigChange, theme, show
</div>
</SettingGroup>
<SettingGroup title="秒数" color={theme.node.muted}>
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
<VideoDurationControl profile={profile} value={Number(seconds)} theme={theme} disabled={(value) => !hasPriceTierForVideoSelection(priceTiers, "*", value)} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
</SettingGroup>
</div>
</ImageSettingsTheme>
);
}
function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig }) {
function SeedanceVideoSettingsPanel({ config, profile, priceTiers, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig; priceTiers: ReturnType<typeof modelPriceTiers> }) {
const model = modelOptionName(config.model || config.videoModel);
const resolution = normalizeSeedanceResolution(config.vquality, model);
const ratio = normalizeSeedanceRatio(config.size);
@@ -125,7 +126,7 @@ function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, sh
<div className="grid grid-cols-3 gap-1.5">
{profile.resolutions.map((value) => {
const item = { value, label: value.toUpperCase() };
const disabled = item.value === "1080p" && isSeedanceFastModel(model);
const disabled = (item.value === "1080p" && isSeedanceFastModel(model)) || !hasPriceTierForVideoSelection(priceTiers, item.value, duration);
return (
<OptionPill key={item.value} selected={resolution === item.value} disabled={disabled} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
{item.label}
@@ -158,7 +159,7 @@ function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, sh
</div>
</SettingGroup>
<SettingGroup title="时长" color={theme.node.muted}>
<VideoDurationControl profile={profile} value={duration} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
<VideoDurationControl profile={profile} value={duration} theme={theme} disabled={(value) => !hasPriceTierForVideoSelection(priceTiers, resolution, value)} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
</SettingGroup>
<SettingGroup title="输出" color={theme.node.muted}>
<div className="grid grid-cols-2 gap-3 rounded-md px-2" style={{ background: theme.toolbar.itemHover }}>
@@ -256,21 +257,44 @@ function DurationInput({ value, min, max, theme, onChange }: { value: number; mi
);
}
function VideoDurationControl({ profile, value, theme, onChange }: { profile: VideoCapabilityConfig; value: number; theme: CanvasTheme; onChange: (value: number) => void }) {
function VideoDurationControl({ profile, value, theme, disabled, onChange }: { profile: VideoCapabilityConfig; value: number; theme: CanvasTheme; disabled?: (value: number) => boolean; onChange: (value: number) => void }) {
if (profile.duration.selection === "range") {
const min = profile.duration.min || VIDEO_DURATION_MIN;
const max = Math.max(min, profile.duration.max || min);
const step = Math.max(1, profile.duration.step || 1);
const normalized = normalizeDurationValue(value, profile.duration.default, min, max, step);
return <DurationRangeControl value={normalized} min={min} max={max} step={step} theme={theme} onChange={onChange} />;
return <DurationRangeControl value={normalized} min={min} max={max} step={step} theme={theme} onChange={(next) => { if (!disabled?.(next)) onChange(next); }} />;
}
const options = videoDurationOptions(profile);
return <div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${Math.min(options.length, 4)}, minmax(0, 1fr))` }}>
{options.map((option) => <OptionPill key={option} selected={normalizedNumber(value) === option} theme={theme} onClick={() => onChange(option)}>{option}s</OptionPill>)}
{options.map((option) => <OptionPill key={option} selected={normalizedNumber(value) === option} disabled={disabled?.(option)} theme={theme} onClick={() => onChange(option)}>{option}s</OptionPill>)}
</div>;
}
function modelPriceTiers(config: AiConfig) {
const channel = resolveModelChannel(config, config.model);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model));
return cost?.logicalPriceTiers || [];
}
function hasPriceTierForVideoSelection(tiers: ReturnType<typeof modelPriceTiers>, resolution: string, seconds: number) {
if (!tiers.length) return true;
const normalizedResolution = normalizeTierResolution(resolution);
return tiers.some((tier) => {
const selector = tier.selector || {};
const tierResolution = selector.vquality || tier.resolution;
const tierSeconds = selector.videoSeconds ? Number(selector.videoSeconds) : tier.videoSeconds;
return (tierResolution === "*" || !tierResolution || normalizeTierResolution(tierResolution) === normalizedResolution) && (!tierSeconds || tierSeconds === seconds);
});
}
function normalizeTierResolution(value: string) {
const normalized = normalizeVideoResolution(value);
if (/^\d+$/.test(normalized)) return `${normalized}p`;
return normalized.toLowerCase();
}
function DurationRangeControl({ value, min, max, step, theme, onChange }: { value: number; min: number; max: number; step: number; theme: CanvasTheme; onChange: (value: number) => void }) {
return <div className="space-y-1.5">
<div className="flex min-w-0 items-center gap-2">