/** * 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 function asRecord(value: unknown): LooseRecord | null { return value !== null && typeof value === 'object' ? value as LooseRecord : null } function asArray(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() 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(model.orientations).filter((o): o is string => typeof o === 'string' && o.length > 0) const durations = asArray(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 = ['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 { const { data } = await apiClient.post('/sora/generate', req) return data } /** 查询生成记录列表 */ export async function listGenerations(params?: { page?: number page_size?: number status?: string storage_type?: string media_type?: string }): Promise { const { data } = await apiClient.get('/sora/generations', { params }) return normalizeGenerationListResponse(data) } /** 查询生成记录详情 */ export async function getGeneration(id: number): Promise { const { data } = await apiClient.get(`/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 { const { data } = await apiClient.get('/sora/quota') return data } /** 获取可用模型家族列表 */ export async function getModels(): Promise { const { data } = await apiClient.get('/sora/models') return normalizeModelFamiliesResponse(data) } /** 获取存储状态 */ export async function getStorageStatus(): Promise { const { data } = await apiClient.get('/sora/storage-status') return data } const soraAPI = { generate, listGenerations, getGeneration, deleteGeneration, cancelGeneration, saveToStorage, getQuota, getModels, getStorageStatus } export default soraAPI