fix(models): 模型规格价格 - 修复主线兼容与参考图 SKU 匹配

This commit is contained in:
ddCat
2026-08-22 17:28:22 +08:00
parent 7ebbf6bf75
commit 1bbd880c84
9 changed files with 112 additions and 46 deletions

View File

@@ -187,8 +187,10 @@ func (r *Repository) attachChannelModelPriceTiers(items []*model.ChannelModel) e
ids = append(ids, item.ID)
}
var tiers []model.ChannelModelPriceTier
if err := r.db.Where("channel_model_id IN ?", ids).Order("selector_key asc, created_at asc").Find(&tiers).Error; err != nil {
return err
if r.db.Migrator().HasTable(&model.ChannelModelPriceTier{}) {
if err := r.db.Where("channel_model_id IN ?", ids).Order("selector_key asc, created_at asc").Find(&tiers).Error; err != nil {
return err
}
}
for index := range tiers {
tiers[index].Selector = model.DecodeSKUSelector(tiers[index].SelectorJSON)
@@ -199,6 +201,16 @@ func (r *Repository) attachChannelModelPriceTiers(items []*model.ChannelModel) e
}
for _, item := range items {
item.PriceTiers = tiersByModelID[item.ID]
// 兼容尚未执行价格档回填的旧数据库;正式迁移会将同一数据持久化为默认档。
if len(item.PriceTiers) == 0 && item.PriceConfigured {
item.PriceTiers = []model.ChannelModelPriceTier{{
ChannelModelID: item.ID, SelectorKey: "{}", SelectorJSON: "{}", Resolution: "*",
ProviderModelKey: item.ProviderModelKey, BillingMode: item.BillingMode,
UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits,
OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits,
PriceConfigured: item.PriceConfigured, Enabled: item.Enabled, PriceVersion: item.PriceVersion,
}}
}
}
return nil
}

View File

@@ -837,7 +837,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
if item.Enabled && item.PriceConfigured {
capabilityConfig, decodeErr := DecodeModelCapabilityConfig(item.CapabilityConfigJSON)
if decodeErr == nil && capabilityConfig != nil {
if normalized, normalizeErr := NormalizeModelCapabilityConfig(item.Capability, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), channel.APIFormat, capabilityConfig); normalizeErr == nil {
if normalized, normalizeErr := NormalizeModelCapabilityConfig(item.Capability, string(item.Protocol), capabilityConfig); normalizeErr == nil {
capabilityConfig = normalized
}
}

View File

@@ -36,7 +36,7 @@ type ChannelModelRequest struct {
// ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。
// Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。
type ChannelModelPriceTierRequest struct {
// Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds;
// Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds、imageCount;
// operation 可区分文生/图生/视频生,避免同一分辨率下错误复用价格。
Selector map[string]string `json:"selector"`
Resolution string `json:"resolution"`
@@ -84,7 +84,7 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
if _, err := s.repo.AdminSystemChannel(channelID); err != nil {
if _, err := s.adminSystemChannel(channelID); err != nil {
return nil, err
}
items, err := s.ensureChannelModels(channelID, true)
@@ -99,7 +99,7 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod
if decodeErr != nil || config == nil {
continue
}
normalized, normalizeErr := NormalizeModelCapabilityConfig(items[index].Capability, string(items[index].Protocol), firstNonEmpty(items[index].ProviderModelKey, items[index].ModelKey), channel.APIFormat, config)
normalized, normalizeErr := NormalizeModelCapabilityConfig(items[index].Capability, string(items[index].Protocol), config)
if normalizeErr != nil || normalized == nil {
continue
}
@@ -203,7 +203,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
return nil, BadAuthRequest("该渠道已存在模型 " + modelKey + ",请直接编辑已有模型")
}
if capability == "text" || capability == "image" || capability == "video" {
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig); err != nil {
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil {
return nil, err
}
}
@@ -233,7 +233,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
item.Protocol = protocol
s.applyChannelModelPriceTierSummary(item, tiers)
if capability == "text" || capability == "image" || capability == "video" {
capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig)
capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig)
if normalizeErr != nil {
return nil, normalizeErr
}
@@ -403,6 +403,15 @@ func normalizeChannelModelTierSelector(capability string, input ChannelModelPric
continue
}
value = strconv.Itoa(seconds)
case "imageCount":
count, err := strconv.Atoi(value)
if err != nil || count < 0 {
return nil, "", 0, BadAuthRequest("参考图片数量必须是非负整数")
}
if count == 0 {
continue
}
value = strconv.Itoa(count)
default:
return nil, "", 0, BadAuthRequest("价格档不支持规格字段:" + key)
}
@@ -433,6 +442,9 @@ func normalizeChannelModelTierSelector(capability string, input ChannelModelPric
if _, exists := selector["videoSeconds"]; exists && capability != "video" {
return nil, "", 0, BadAuthRequest("只有视频模型可以按时长配置价格档")
}
if _, exists := selector["imageCount"]; exists && capability != "video" {
return nil, "", 0, BadAuthRequest("只有视频模型可以按参考图片数量配置价格档")
}
resolution := "*"
if value := selector["vquality"]; value != "" {
resolution = value
@@ -522,7 +534,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
return nil, err
}
if capability == "text" || capability == "image" || capability == "video" {
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig); err != nil {
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil {
return nil, err
}
}
@@ -552,7 +564,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
imageSize, imageQuality := "", ""
var imageProfile *ImageCapabilityConfig
if capability == "image" {
profile, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig)
profile, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig)
if normalizeErr != nil {
return nil, normalizeErr
}

View File

@@ -579,6 +579,14 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error {
return validateVideoTask(profile.Video, taskInput)
}
// applyModelSpecificImageCapability is retained as a narrow normalization hook
// for provider-specific image validation. The stored capability profile is
// already normalized when the channel model is saved, so no second override is
// needed here.
func applyModelSpecificImageCapability(profile *ImageCapabilityConfig, _ string, _ string, _ string) *ImageCapabilityConfig {
return profile
}
// applyFixedVideoResolution 让单档位 SKU 的预扣、恢复和上游请求保持同一分辨率。
func applyFixedVideoResolution(input *canvasGenerationInput, profile *VideoCapabilityConfig) {
if input == nil || profile == nil || len(profile.Resolutions) != 1 {

View File

@@ -684,6 +684,9 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string {
}
switch normalizeCapability(intent.Capability) {
case "video":
if count := intent.Inputs["image"]; count > 0 {
selector["imageCount"] = strconv.Itoa(count)
}
if value := normalizeChannelModelTierResolution(fmt.Sprint(intent.Options["vquality"])); value != "*" {
selector["vquality"] = value
}

View File

@@ -1,6 +1,10 @@
package service
import "testing"
import (
"testing"
"infinite-canvas/backend/internal/model"
)
func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
input := map[string]any{
@@ -12,3 +16,18 @@ func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
t.Fatalf("vquality = %#v, want 480p", got)
}
}
func TestSKUSelectorIncludesVideoReferenceImageCount(t *testing.T) {
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "video", Inputs: map[string]int{"image": 5}, Options: map[string]any{"vquality": "720p"}})
if selector["imageCount"] != "5" || selector["vquality"] != "720p" {
t.Fatalf("selector = %#v", selector)
}
modelWithTiers := model.ChannelModel{PriceTiers: []model.ChannelModelPriceTier{
{SelectorJSON: `{"vquality":"720p","imageCount":"5"}`, Enabled: true, PriceConfigured: true},
{SelectorJSON: `{"vquality":"720p","imageCount":"9"}`, Enabled: true, PriceConfigured: true},
}}
matched := channelModelPriceTierForIntent(modelWithTiers, ModelRequestIntent{Capability: "video", Inputs: map[string]int{"image": 5}, Options: map[string]any{"vquality": "720p"}})
if matched == nil || matched.SelectorJSON != `{"vquality":"720p","imageCount":"5"}` {
t.Fatalf("matched tier = %#v", matched)
}
}

View File

@@ -825,14 +825,19 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
modelKey = requestedModel
}
if modelKey == "" {
models := channelModelNames(*channel)
if len(models) == 0 {
return providerConfig{}, errors.New("系统渠道未配置可用模型")
channelModels, listErr := s.repo.ChannelModels(channel.ID, false)
if listErr != nil {
return providerConfig{}, listErr
}
if len(channelModels) > 0 {
modelKey = channelModels[0].ModelKey
} else {
models := channelModelNames(*channel)
if len(models) == 0 {
return providerConfig{}, errors.New("系统渠道未配置可用模型")
}
modelKey = models[0]
}
modelKey = models[0]
}
if !stringInSlice(modelKey, channelModelNames(*channel)) {
return providerConfig{}, errors.New("当前系统渠道未授权该模型")
}
if _, err := s.validateChannelOutboundURL(channel.BaseURL, channel.AllowLocalChannel, false); err != nil {
return providerConfig{}, err

View File

@@ -60,7 +60,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent;
// 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。
const current = options.includes(resolvedCurrent) ? resolvedCurrent : "";
const currentPrice = modelMenuPrice(config, current, capability);
const currentPrice = modelMenuPrice(config, current, capability, false, requirements);
const quoteRequest = useMemo(() => modelQuoteRequest(config, current, capability, requirements), [capability, config, current, requirements]);
const [routeQuote, setRouteQuote] = useState<LogicalModelQuote | undefined>();
const creationVariant = variant === "creation";
@@ -354,7 +354,7 @@ type ModelMenuPrice =
| { kind: "estimate" }
| { kind: "fixed"; value: number; unit: "次" | "秒" | "百万 Token" };
function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapability, summary = false): ModelMenuPrice | null | undefined {
function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapability, summary = false, requirements?: ModelRequirements): ModelMenuPrice | null | undefined {
if (!model) return undefined;
const channel = resolveModelChannel(config, model);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
@@ -362,7 +362,7 @@ function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapab
if (cost.pricePolicy === "channel") {
const tiers = cost.logicalPriceTiers || [];
if (!tiers.length) return null;
const matched = summary ? tiers : priceTiersForCurrentSelection(tiers, capability, config);
const matched = summary ? tiers : priceTiersForCurrentSelection(tiers, capability, config, requirements);
return channelTierPriceSummary(matched.length ? matched : tiers, tiers);
}
if (cost.billingMode === "token") return { kind: "estimate" };
@@ -381,12 +381,15 @@ function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredM
function priceTiersForCurrentSelection(
tiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
capability: ModelCapability | undefined,
config: AiConfig,
capability: ModelCapability | undefined,
config: AiConfig,
requirements?: ModelRequirements,
) {
const requested: Record<string, string> = {};
if (capability === "video") {
const resolution = normalizeTierResolution(config.vquality);
if (capability === "video") {
const imageCount = (requirements?.input?.imageCount || 0) + (requirements?.input?.characterCount || 0);
if (imageCount > 0) requested.imageCount = String(imageCount);
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);
@@ -471,6 +474,7 @@ function tierSpecificationLabel(tier: NonNullable<NonNullable<AiConfig["channels
selector.size && selector.size !== "*" ? selector.size : "",
tier.resolution !== "*" ? tierResolutionLabel(tier.resolution) : "",
tier.videoSeconds ? tierDurationLabel(tier.videoSeconds) : "",
selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "",
].filter(Boolean);
return details.length ? details.join(" / ") : "默认规格";
}

View File

@@ -31,8 +31,9 @@ type PriceTierFormValues = {
operation: string;
quality: string;
size: string;
resolution: string;
videoSeconds: number;
resolution: string;
videoSeconds: number;
imageCount: number;
providerModelKey?: string;
billingMode: ChannelModel["billingMode"];
unitPrice: number;
@@ -129,7 +130,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
priceTiers: item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)],
enabled: item.enabled,
capabilityConfig: item.capability === "text" || item.capability === "image" || item.capability === "video"
? normalizeModelCapabilityConfig(item.capabilityConfig, item.protocol, item.providerModelKey || item.modelKey, channel.apiFormat)
? normalizeModelCapabilityConfig(item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.providerModelKey || item.modelKey))
: undefined,
});
setEditorOpen(true);
@@ -139,7 +140,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const values = await form.validateFields();
const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim();
const capabilityConfig = values.capability === "text" || values.capability === "image" || values.capability === "video"
? normalizeModelCapabilityConfig(values.capabilityConfig, values.protocol, upstreamModel, channel.apiFormat)
? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel))
: undefined;
setSaving(true);
try {
@@ -183,7 +184,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const values = await form.validateFields(["modelKey", "providerModelKey", "capability", "protocol", ...(modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? ["capabilityConfig"] : [])]);
const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim();
const capabilityConfig = values.capability === "text" || values.capability === "image" || values.capability === "video"
? normalizeModelCapabilityConfig(values.capabilityConfig, values.protocol, upstreamModel, channel.apiFormat)
? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel))
: undefined;
setTesting(true);
try {
@@ -217,12 +218,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
if (changed.protocol && (modelCapability === "image" || modelCapability === "video")) {
form.setFieldValue("capabilityConfig", defaultModelCapabilityConfig(changed.protocol, form.getFieldValue("modelKey")));
}
const currentBillingMode = form.getFieldValue("billingMode") as ChannelModel["billingMode"] | undefined;
const currentCapability = form.getFieldValue("capability") as EditableCapability | undefined;
const currentProtocol = form.getFieldValue("protocol") as ModelProtocol | undefined;
if ((currentBillingMode === "per_second" && currentCapability !== "video") || (currentBillingMode === "token" && !modelProtocolSupportsTokenBilling(currentCapability, currentProtocol))) {
form.setFieldValue("billingMode", "fixed_request");
}
if (!changed.capability) return;
const current = form.getFieldValue("protocol") as ModelProtocol | undefined;
if (modelProtocolCapability(current) !== changed.capability) {
@@ -235,8 +230,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
operation: tier.operation || "*",
quality: changed.capability === "image" ? tier.quality || "*" : "*",
size: changed.capability === "image" ? tier.size || "*" : "*",
resolution: changed.capability === "video" ? tier.resolution || "*" : "*",
videoSeconds: changed.capability === "video" ? tier.videoSeconds || 0 : 0,
resolution: changed.capability === "video" ? tier.resolution || "*" : "*",
videoSeconds: changed.capability === "video" ? tier.videoSeconds || 0 : 0,
imageCount: changed.capability === "video" ? tier.imageCount || 0 : 0,
billingMode: tier.billingMode === "per_second" && changed.capability !== "video" ? "fixed_request" : tier.billingMode,
}));
form.setFieldValue("priceTiers", nextTiers);
@@ -523,11 +519,16 @@ function PriceTierFields({
<Select options={[{ label: "任意分辨率", value: "*" }, ...resolutionOptions.map((value) => ({ label: value.toUpperCase(), value }))]} />
</Form.Item>
) : null}
{isVideo ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "videoSeconds"]} label="时长" rules={[{ required: true, message: "请输入时长" }]}>
{durationOptions.length ? <Select options={[{ label: "任意时长", value: 0 }, ...durationOptions.map((value) => ({ label: `${value} 秒`, value }))]} /> : <InputNumber className="w-full" min={0} precision={0} />}
</Form.Item>
{isVideo ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "videoSeconds"]} label="时长" rules={[{ required: true, message: "请输入时长" }]}>
{durationOptions.length ? <Select options={[{ label: "任意时长", value: 0 }, ...durationOptions.map((value) => ({ label: `${value} 秒`, value }))]} /> : <InputNumber className="w-full" min={0} precision={0} />}
</Form.Item>
) : null}
{isVideo ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "imageCount"]} label="参考图数量" rules={[{ required: true, message: "请输入参考图数量" }]}>
<InputNumber className="w-full" min={0} max={9} precision={0} placeholder="0 表示任意数量" />
</Form.Item>
) : null}
{isImage ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "quality"]} label="质量/分辨率" rules={[{ required: true, message: "请选择质量或分辨率" }]}>
<Select options={[{ label: "任意质量", value: "*" }, { label: "1K", value: "1k" }, { label: "2K", value: "2k" }, { label: "4K", value: "4k" }]} />
@@ -580,13 +581,13 @@ function PriceTierFields({
}
function defaultPriceTier(): PriceTierFormValues {
return { operation: "*", quality: "*", size: "*", resolution: "*", videoSeconds: 0, providerModelKey: "", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, priceConfigured: true, enabled: true };
return { operation: "*", quality: "*", size: "*", resolution: "*", videoSeconds: 0, imageCount: 0, providerModelKey: "", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, priceConfigured: true, enabled: true };
}
function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValues {
return {
operation: tier.selector?.operation || "*", quality: tier.selector?.quality || "*", size: tier.selector?.size || "*",
resolution: tier.resolution || "*", videoSeconds: tier.videoSeconds || 0, providerModelKey: tier.providerModelKey || "", billingMode: tier.billingMode,
resolution: tier.resolution || "*", videoSeconds: tier.videoSeconds || 0, imageCount: Number(tier.selector?.imageCount || 0), providerModelKey: tier.providerModelKey || "", billingMode: tier.billingMode,
unitPrice: tier.unitPriceMicrocredits / 1_000_000, inputTokenPrice: tier.inputTokenPriceMicrocredits / 1_000_000,
outputTokenPrice: tier.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: tier.cachedTokenPriceMicrocredits / 1_000_000,
priceConfigured: tier.priceConfigured, enabled: tier.enabled,
@@ -595,7 +596,7 @@ function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValues {
function legacyPriceTierToForm(item: ChannelModel): PriceTierFormValues {
return {
operation: "*", quality: "*", size: "*", resolution: "*", videoSeconds: 0, providerModelKey: item.providerModelKey || "", billingMode: item.billingMode,
operation: "*", quality: "*", size: "*", resolution: "*", videoSeconds: 0, imageCount: 0, providerModelKey: item.providerModelKey || "", billingMode: item.billingMode,
unitPrice: item.unitPriceMicrocredits / 1_000_000, inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000,
outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000,
priceConfigured: item.priceConfigured, enabled: item.enabled,
@@ -615,7 +616,8 @@ function priceTierLabel(tier: ChannelModelPriceTier) {
selector.quality && selector.quality !== "*" ? selector.quality.toUpperCase() : "",
selector.size && selector.size !== "*" ? selector.size : "",
tier.resolution === "*" ? "" : tier.resolution.toUpperCase(),
tier.videoSeconds ? `${tier.videoSeconds} 秒` : "",
tier.videoSeconds ? `${tier.videoSeconds} 秒` : "",
selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "",
].filter(Boolean);
const spec = specParts.length ? specParts.join(" / ") : "默认规格";
if (tier.billingMode === "token") return `${spec} · ${formatCredits(tier.outputTokenPriceMicrocredits)} / 百万 Token`;
@@ -640,6 +642,7 @@ function skuSelectorFromForm(capability: EditableCapability, tier: PriceTierForm
if (capability === "video") {
if (tier.resolution && tier.resolution !== "*") selector.vquality = tier.resolution;
if (Number(tier.videoSeconds) > 0) selector.videoSeconds = String(Number(tier.videoSeconds));
if (Number(tier.imageCount) > 0) selector.imageCount = String(Number(tier.imageCount));
}
if (capability === "image") {
if (tier.quality && tier.quality !== "*") selector.quality = tier.quality;