diff --git a/frontend/src/videoCaps.js b/frontend/src/videoCaps.js new file mode 100644 index 0000000..3014109 --- /dev/null +++ b/frontend/src/videoCaps.js @@ -0,0 +1,27 @@ +// 视频模型的参考资产分类上限(图片 / 视频 / 音频)。 +// 单一真源是后端 /video-presets 的 max_images / max_videos / max_audios(服务端 +// leonardoVideoSpecs 校验的就是这套数),预设没声明音视频上限的 seedance 系回落 +// 到 3 / 3,与画图台 (PlaygroundView) 的判定保持一致。 +const SEEDANCE_FALLBACK = { videos: 3, audios: 3 } + +function declared(v) { + return v === undefined || v === null ? null : Number(v) +} + +// mediaCaps 返回 { images, videos, audios }(0 = 不支持该类),不支持图片以外的 +// 参考资产时返回 null。model 兼容 /managed-models(type) 与 /models(kind) 两种字段。 +export function mediaCaps(model, preset) { + if ((model?.type || model?.kind) !== 'video') return null + const isSeedance = /^seedance/.test(model?.id || '') + const videos = declared(preset?.max_videos) ?? (isSeedance ? SEEDANCE_FALLBACK.videos : 0) + const audios = declared(preset?.max_audios) ?? (isSeedance ? SEEDANCE_FALLBACK.audios : 0) + if (!videos && !audios) return null + return { images: declared(preset?.max_images) ?? 0, videos, audios } +} + +// presetMap 把 /video-presets 的列表转成 key → preset,便于按模型 id 取预设。 +export function presetMap(list) { + const out = {} + for (const p of list || []) if (p?.key) out[p.key] = p + return out +} diff --git a/frontend/src/views/DocsView.vue b/frontend/src/views/DocsView.vue index adbba91..ffff8e3 100644 --- a/frontend/src/views/DocsView.vue +++ b/frontend/src/views/DocsView.vue @@ -6,16 +6,26 @@ import { ref, computed, onMounted } from 'vue' import { auth } from '../auth' import { api } from '../api' import Icon from '../components/Icon.vue' +import { points } from '../credits' +import { mediaCaps, presetMap } from '../videoCaps' const base = computed(() => location.origin) // /v1 is same-origin (dev: Vite proxy) const keyHint = computed(() => auth.user?.api_keys?.[0]?.key_preview || 'YOUR_API_KEY') const models = ref([]) +const presets = ref({}) // /video-presets: key → 预设(参考资产分类上限的真源) onMounted(async () => { - const r = await api('/managed-models') + const [r, p] = await Promise.all([api('/managed-models'), api('/video-presets')]) if (r.ok) models.value = (r.data?.data || []).filter((m) => m.enabled !== false) + if (p.ok) presets.value = presetMap(p.data?.data) }) +// 各类参考资产的上限(图片/视频/音频)由后端预设给出,不在这里写死。 +function caps(m) { return mediaCaps(m, presets.value[m.id]) } +// 参考图张数:支持音视频参考的模型,max_reference_images 是各类资产合计,图片自己 +// 的上限来自预设 max_images。 +function refImages(m) { return caps(m)?.images || m.max_reference_images } + const imageModels = computed(() => models.value.filter((m) => m.type === 'image')) const videoModels = computed(() => models.value.filter((m) => m.type === 'video')) function pubName(m) { @@ -27,23 +37,29 @@ const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0] function modeEmpty(m) { if (m.type === 'image') return !(m.resolutions || []).length && !m.image_to_image && !(m.ratios || []).length - return !(m.resolutions || []).length && !(m.durations || []).length && !m.max_reference_images && !m.id?.startsWith('seedance') && !(m.ratios || []).length + return !(m.resolutions || []).length && !(m.durations || []).length && !m.max_reference_images && !caps(m) && !(m.ratios || []).length } -function priceOf(m) { - if (m.type === 'video') { - // Video charge = resolution price + duration price; show the combined range. - const rv = Object.values(m.prices || {}).filter((v) => v != null).map(Number) - const dv = Object.values(m.duration_prices || {}).filter((v) => v != null).map(Number) - if (!rv.length || !dv.length) return '—' - const lo = Math.min(...rv) + Math.min(...dv) - const hi = Math.max(...rv) + Math.max(...dv) - return lo === hi ? `${points(lo)} 积分` : `${points(lo)}–${points(hi)} 积分` +// ---- 定价(同模型管理的分档展示):代理账号看代理价,某档没设代理价时回退普通价; +// 普通价为空表示该档不支持,不展示。视频价 = 分辨率价 + 时长价(按秒计价的模型给 /s 价)。 +const isAgent = computed(() => auth.user?.role === 'agent') +function tierPrice(normal, agent, key) { + const n = (normal || {})[key] + if (n == null) return null + if (isAgent.value) { + const a = (agent || {})[key] + if (a != null) return Number(a) } - const vals = Object.values(m.prices || {}).filter((v) => v != null).map(Number) - if (!vals.length) return '—' - const lo = Math.min(...vals), hi = Math.max(...vals) - return lo === hi ? `${points(lo)} 积分` : `${points(lo)}–${points(hi)} 积分` + return Number(n) +} +function resPrice(m, r) { return tierPrice(m.prices, m.prices_agent, r) } +function durPrice(m, d) { return tierPrice(m.duration_prices, m.duration_prices_agent, d) } +function perSecondPrice(m) { return tierPrice(m.duration_prices, m.duration_prices_agent, 'per_second') } +function priceEmpty(m) { + if ((m.resolutions || []).some((r) => resPrice(m, r) != null)) return false + if (m.type !== 'video') return true + if (perSecondPrice(m) != null) return false + return !(m.durations || []).some((d) => durPrice(m, d) != null) } // ---- request parameter tables ---- @@ -272,15 +288,30 @@ async function copy(text) {
| model | -类型 | +model | +类型 | +定价 积分 | 能力 |
|---|---|---|---|---|---|
| {{ pubName(m) }} | -{{ m.type === 'video' ? '视频' : '图像' }} | +{{ pubName(m) }} | +{{ m.type === 'video' ? '视频' : '图像' }} | +
+
+
+ {{ r }}{{ points(resPrice(m, r)) }}
+
+
+ /s{{ points(perSecondPrice(m)) }}
+
+ {{ d }}+{{ points(durPrice(m, d)) }}
+
+
+ —
+
+ |
@@ -292,17 +323,19 @@ async function copy(text) {
{{ m.durations[0] }}
首尾帧 {{ Math.min(m.max_reference_images, 2) }}
- 参考图 {{ m.max_reference_images }}
- 参考图 {{ m.max_reference_images }}
+ 参考图 {{ refImages(m) }}
+ 参考图 {{ refImages(m) }}
参考图 1
- 视频 3
- 音频 3
+
+ 视频 {{ caps(m).videos }}
+ 音频 {{ caps(m).audios }}
+
{{ r.replace(':', '×') }}
—
|
| 暂无可用模型 | |||||
| 暂无可用模型 | |||||