308 lines
9.0 KiB
TypeScript
308 lines
9.0 KiB
TypeScript
/**
|
||
* Sora 客户端 API
|
||
* 封装所有 Sora 生成、作品库、配额等接口调用
|
||
*/
|
||
|
||
import { apiClient } from './client'
|
||
|
||
// ==================== 类型定义 ====================
|
||
|
||
export interface SoraGeneration {
|
||
id: number
|
||
user_id: number
|
||
model: string
|
||
prompt: string
|
||
media_type: string
|
||
status: string // pending | generating | completed | failed | cancelled
|
||
storage_type: string // upstream | s3 | local
|
||
media_url: string
|
||
media_urls: string[]
|
||
s3_object_keys: string[]
|
||
file_size_bytes: number
|
||
error_message: string
|
||
created_at: string
|
||
completed_at?: string
|
||
}
|
||
|
||
export interface GenerateRequest {
|
||
model: string
|
||
prompt: string
|
||
video_count?: number
|
||
media_type?: string
|
||
image_input?: string
|
||
api_key_id?: number
|
||
}
|
||
|
||
export interface GenerateResponse {
|
||
generation_id: number
|
||
status: string
|
||
}
|
||
|
||
export interface GenerationListResponse {
|
||
data: SoraGeneration[]
|
||
total: number
|
||
page: number
|
||
}
|
||
|
||
export interface QuotaInfo {
|
||
quota_bytes: number
|
||
used_bytes: number
|
||
available_bytes: number
|
||
quota_source: string // user | group | system | unlimited
|
||
source?: string // 兼容旧字段
|
||
}
|
||
|
||
export interface StorageStatus {
|
||
s3_enabled: boolean
|
||
s3_healthy: boolean
|
||
local_enabled: boolean
|
||
}
|
||
|
||
/** 单个扁平模型(旧接口,保留兼容) */
|
||
export interface SoraModel {
|
||
id: string
|
||
name: string
|
||
type: string // video | image
|
||
orientation?: string
|
||
duration?: number
|
||
}
|
||
|
||
/** 模型家族(新接口 — 后端从 soraModelConfigs 自动聚合) */
|
||
export interface SoraModelFamily {
|
||
id: string // 家族 ID,如 "sora2"
|
||
name: string // 显示名,如 "Sora 2"
|
||
type: string // "video" | "image"
|
||
orientations: string[] // ["landscape", "portrait"] 或 ["landscape", "portrait", "square"]
|
||
durations?: number[] // [10, 15, 25](仅视频模型)
|
||
}
|
||
|
||
type LooseRecord = Record<string, unknown>
|
||
|
||
function asRecord(value: unknown): LooseRecord | null {
|
||
return value !== null && typeof value === 'object' ? value as LooseRecord : null
|
||
}
|
||
|
||
function asArray<T = unknown>(value: unknown): T[] {
|
||
return Array.isArray(value) ? value as T[] : []
|
||
}
|
||
|
||
function asPositiveInt(value: unknown): number | null {
|
||
const n = Number(value)
|
||
if (!Number.isFinite(n) || n <= 0) return null
|
||
return Math.round(n)
|
||
}
|
||
|
||
function dedupeStrings(values: string[]): string[] {
|
||
return Array.from(new Set(values))
|
||
}
|
||
|
||
function extractOrientationFromModelID(modelID: string): string | null {
|
||
const m = modelID.match(/-(landscape|portrait|square)(?:-\d+s)?$/i)
|
||
return m ? m[1].toLowerCase() : null
|
||
}
|
||
|
||
function extractDurationFromModelID(modelID: string): number | null {
|
||
const m = modelID.match(/-(\d+)s$/i)
|
||
return m ? asPositiveInt(m[1]) : null
|
||
}
|
||
|
||
function normalizeLegacyFamilies(candidates: unknown[]): SoraModelFamily[] {
|
||
const familyMap = new Map<string, SoraModelFamily>()
|
||
|
||
for (const item of candidates) {
|
||
const model = asRecord(item)
|
||
if (!model || typeof model.id !== 'string' || model.id.trim() === '') continue
|
||
|
||
const rawID = model.id.trim()
|
||
const type = model.type === 'image' ? 'image' : 'video'
|
||
const name = typeof model.name === 'string' && model.name.trim() ? model.name.trim() : rawID
|
||
const baseID = rawID.replace(/-(landscape|portrait|square)(?:-\d+s)?$/i, '')
|
||
const orientation =
|
||
typeof model.orientation === 'string' && model.orientation
|
||
? model.orientation.toLowerCase()
|
||
: extractOrientationFromModelID(rawID)
|
||
const duration = asPositiveInt(model.duration) ?? extractDurationFromModelID(rawID)
|
||
const familyKey = baseID || rawID
|
||
|
||
const family = familyMap.get(familyKey) ?? {
|
||
id: familyKey,
|
||
name,
|
||
type,
|
||
orientations: [],
|
||
durations: []
|
||
}
|
||
|
||
if (orientation) {
|
||
family.orientations.push(orientation)
|
||
}
|
||
if (type === 'video' && duration) {
|
||
family.durations = family.durations || []
|
||
family.durations.push(duration)
|
||
}
|
||
|
||
familyMap.set(familyKey, family)
|
||
}
|
||
|
||
return Array.from(familyMap.values())
|
||
.map((family) => ({
|
||
...family,
|
||
orientations:
|
||
family.orientations.length > 0
|
||
? dedupeStrings(family.orientations)
|
||
: (family.type === 'image' ? ['square'] : ['landscape']),
|
||
durations:
|
||
family.type === 'video'
|
||
? Array.from(new Set((family.durations || []).filter((d): d is number => Number.isFinite(d)))).sort((a, b) => a - b)
|
||
: []
|
||
}))
|
||
.filter((family) => family.id !== '')
|
||
}
|
||
|
||
function normalizeModelFamilyRecord(item: unknown): SoraModelFamily | null {
|
||
const model = asRecord(item)
|
||
if (!model || typeof model.id !== 'string' || model.id.trim() === '') return null
|
||
// 仅把明确的“家族结构”识别为 family;老结构(单模型)走 legacy 聚合逻辑。
|
||
if (!Array.isArray(model.orientations) && !Array.isArray(model.durations)) return null
|
||
|
||
const orientations = asArray<string>(model.orientations).filter((o): o is string => typeof o === 'string' && o.length > 0)
|
||
const durations = asArray<unknown>(model.durations)
|
||
.map(asPositiveInt)
|
||
.filter((d): d is number => d !== null)
|
||
|
||
return {
|
||
id: model.id.trim(),
|
||
name: typeof model.name === 'string' && model.name.trim() ? model.name.trim() : model.id.trim(),
|
||
type: model.type === 'image' ? 'image' : 'video',
|
||
orientations: dedupeStrings(orientations),
|
||
durations: Array.from(new Set(durations)).sort((a, b) => a - b)
|
||
}
|
||
}
|
||
|
||
function extractCandidateArray(payload: unknown): unknown[] {
|
||
if (Array.isArray(payload)) return payload
|
||
const record = asRecord(payload)
|
||
if (!record) return []
|
||
|
||
const keys: Array<keyof LooseRecord> = ['data', 'items', 'models', 'families']
|
||
for (const key of keys) {
|
||
if (Array.isArray(record[key])) {
|
||
return record[key] as unknown[]
|
||
}
|
||
}
|
||
return []
|
||
}
|
||
|
||
export function normalizeModelFamiliesResponse(payload: unknown): SoraModelFamily[] {
|
||
const candidates = extractCandidateArray(payload)
|
||
if (candidates.length === 0) return []
|
||
|
||
const normalized = candidates
|
||
.map(normalizeModelFamilyRecord)
|
||
.filter((item): item is SoraModelFamily => item !== null)
|
||
|
||
if (normalized.length > 0) return normalized
|
||
return normalizeLegacyFamilies(candidates)
|
||
}
|
||
|
||
export function normalizeGenerationListResponse(payload: unknown): GenerationListResponse {
|
||
const record = asRecord(payload)
|
||
if (!record) {
|
||
return { data: [], total: 0, page: 1 }
|
||
}
|
||
|
||
const data = Array.isArray(record.data)
|
||
? (record.data as SoraGeneration[])
|
||
: Array.isArray(record.items)
|
||
? (record.items as SoraGeneration[])
|
||
: []
|
||
|
||
const total = Number(record.total)
|
||
const page = Number(record.page)
|
||
|
||
return {
|
||
data,
|
||
total: Number.isFinite(total) ? total : data.length,
|
||
page: Number.isFinite(page) && page > 0 ? page : 1
|
||
}
|
||
}
|
||
|
||
// ==================== API 方法 ====================
|
||
|
||
/** 异步生成 — 创建 pending 记录后立即返回 */
|
||
export async function generate(req: GenerateRequest): Promise<GenerateResponse> {
|
||
const { data } = await apiClient.post<GenerateResponse>('/sora/generate', req)
|
||
return data
|
||
}
|
||
|
||
/** 查询生成记录列表 */
|
||
export async function listGenerations(params?: {
|
||
page?: number
|
||
page_size?: number
|
||
status?: string
|
||
storage_type?: string
|
||
media_type?: string
|
||
}): Promise<GenerationListResponse> {
|
||
const { data } = await apiClient.get<unknown>('/sora/generations', { params })
|
||
return normalizeGenerationListResponse(data)
|
||
}
|
||
|
||
/** 查询生成记录详情 */
|
||
export async function getGeneration(id: number): Promise<SoraGeneration> {
|
||
const { data } = await apiClient.get<SoraGeneration>(`/sora/generations/${id}`)
|
||
return data
|
||
}
|
||
|
||
/** 删除生成记录 */
|
||
export async function deleteGeneration(id: number): Promise<{ message: string }> {
|
||
const { data } = await apiClient.delete<{ message: string }>(`/sora/generations/${id}`)
|
||
return data
|
||
}
|
||
|
||
/** 取消生成任务 */
|
||
export async function cancelGeneration(id: number): Promise<{ message: string }> {
|
||
const { data } = await apiClient.post<{ message: string }>(`/sora/generations/${id}/cancel`)
|
||
return data
|
||
}
|
||
|
||
/** 手动保存到 S3 */
|
||
export async function saveToStorage(
|
||
id: number
|
||
): Promise<{ message: string; object_key: string; object_keys?: string[] }> {
|
||
const { data } = await apiClient.post<{ message: string; object_key: string; object_keys?: string[] }>(
|
||
`/sora/generations/${id}/save`
|
||
)
|
||
return data
|
||
}
|
||
|
||
/** 查询配额信息 */
|
||
export async function getQuota(): Promise<QuotaInfo> {
|
||
const { data } = await apiClient.get<QuotaInfo>('/sora/quota')
|
||
return data
|
||
}
|
||
|
||
/** 获取可用模型家族列表 */
|
||
export async function getModels(): Promise<SoraModelFamily[]> {
|
||
const { data } = await apiClient.get<unknown>('/sora/models')
|
||
return normalizeModelFamiliesResponse(data)
|
||
}
|
||
|
||
/** 获取存储状态 */
|
||
export async function getStorageStatus(): Promise<StorageStatus> {
|
||
const { data } = await apiClient.get<StorageStatus>('/sora/storage-status')
|
||
return data
|
||
}
|
||
|
||
const soraAPI = {
|
||
generate,
|
||
listGenerations,
|
||
getGeneration,
|
||
deleteGeneration,
|
||
cancelGeneration,
|
||
saveToStorage,
|
||
getQuota,
|
||
getModels,
|
||
getStorageStatus
|
||
}
|
||
|
||
export default soraAPI
|