/** * 素材相关 API * Phase 1 重构:去掉 project_id,素材直接归属用户 */ import apiClient from "./client" import { getOrCreateDefaultProject } from "./projects" /** 素材元数据 */ export interface AssetMetadata { /** 时长(秒) */ duration?: number /** 宽度(像素) */ width?: number /** 高度(像素) */ height?: number /** 比特率(bps) */ bitrate?: number /** 编码格式 */ codec?: string /** 帧率 */ fps?: number /** 采样率(Hz) */ sample_rate?: number /** 声道数 */ channels?: number /** 其他扩展字段 */ [key: string]: unknown } /** 素材分类状态 */ export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed" /** 素材条目 */ export interface AssetItem { id: string library_id: string name: string storage_key: string mime_type: string metadata: AssetMetadata file_size?: number file_url?: string thumbnail_url?: string /** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */ duration?: number status?: string classification_status?: AssetClassificationStatus | null quality_score?: number | null tag_ids?: string[] created_at?: string } /** 素材库 */ export interface AssetLibraryItem { id: string name: string kind: "video" | "voice" | "image" asset_count?: number total_size?: number created_at?: string } /** 入库任务 */ export interface IngestJob { id: string library_id: string storage_key: string status: "pending" | "processing" | "completed" | "failed" error_message: string result_asset_id: string } /** 分类任务 */ export interface ClassificationJob { id: string asset_id: string status: "pending" | "processing" | "completed" | "failed" classification: string confidence: number error_message: string } /** 素材诊断信息 */ export interface AssetDiagnosis { readiness_score: number readiness_label: string total_assets: number ready_assets: number video_assets: number image_assets: number voice_assets: number total_duration_seconds: number estimated_video_count: number used_assets: number unused_assets: number pending_review_assets: number smart_views: Array<{ key: string label: string count: number description: string }> gaps: Array<{ key: string severity: "critical" | "warning" | "info" message: string recommendation: string }> } // ─── 素材诊断 ────────────────────────────────────────────── /** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */ export const getAssetDiagnosis = async (assetId?: string): Promise => { const params: Record = {} if (assetId) params.asset_id = assetId const response = await apiClient.get("/asset-diagnosis", { params }) return response.data } // ─── 素材库 ──────────────────────────────────────────────── /** 获取当前用户的所有素材库 */ export const getAssetLibraries = async (): Promise => { const response = await apiClient.get("/asset-libraries") return response.data.items || [] } /** 创建素材库(自动获取或创建默认项目以提供 project_id) */ export const createAssetLibrary = async (data: { name: string kind: "video" | "voice" | "image" }): Promise => { // 后端要求 project_id,前端自动管理默认项目 const project = await getOrCreateDefaultProject() const response = await apiClient.post("/asset-libraries", { project_id: project.id, ...data, }) return response.data } /** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */ export const ensureDefaultLibrary = async (data: { project_id: string kind: "video" | "voice" | "image" }): Promise => { const response = await apiClient.post("/asset-libraries/ensure-default", data) return response.data } /** 删除素材库 */ export const deleteAssetLibrary = async (libraryId: string): Promise => { await apiClient.delete(`/asset-libraries/${libraryId}`) } // ─── 素材 ────────────────────────────────────────────────── /** 获取素材库下的所有素材 */ export const getAssets = async ( libraryId: string, options?: { status?: string; page?: number; page_size?: number }, ): Promise<{ items: AssetItem[]; total: number }> => { const params: Record = { library_id: libraryId } // 默认拉取所有非删除状态的素材(ready/ingesting/processing/uploading/error/failed) // 让用户能看到"处理中"的素材,不会以为上传失败了 if (options?.status) { params.status = options.status } if (options?.page) params.page = options.page if (options?.page_size) params.page_size = options.page_size const response = await apiClient.get("/assets", { params }) const data = response.data || {} const items: AssetItem[] = data.items || [] const total: number = typeof data.total === "number" ? data.total : items.length return { items, total } } /** 按类型获取素材(如 voice/video/image),支持可选筛选 */ export const getAssetsByKind = async ( kind: string, filters?: { keyword?: string gender?: string style?: string tag_ids?: string[] limit?: number page?: number page_size?: number }, ): Promise => { const params: Record = { kind } if (filters?.keyword) params.keyword = filters.keyword if (filters?.gender) params.gender = filters.gender if (filters?.style) params.style = filters.style if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",") if (filters?.limit) params.limit = filters.limit if (filters?.page) params.page = filters.page if (filters?.page_size) params.page_size = filters.page_size const response = await apiClient.get("/assets", { params }) return response.data.items || [] } /** 创建素材(上传文件后调用,附带 metadata) */ export const createAsset = async (data: { library_id: string name: string storage_key: string mime_type: string metadata?: AssetMetadata }): Promise => { const response = await apiClient.post("/assets", data) return response.data } /** 更新素材(名称、metadata 等) */ export const updateAsset = async ( assetId: string, data: { name?: string; metadata?: AssetMetadata }, ): Promise => { const response = await apiClient.put(`/assets/${assetId}`, data) return response.data } /** 更新素材审核状态 */ export const updateAssetReviewStatus = async ( assetId: string, reviewStatus: "pending_review" | "approved" | "rejected", ): Promise => { const response = await apiClient.patch(`/assets/${assetId}/review`, { review_status: reviewStatus, }) return response.data } /** 删除素材 */ export const deleteAsset = async (assetId: string): Promise => { await apiClient.delete(`/assets/${assetId}`) } // ─── 上传 ────────────────────────────────────────────────── /** 表单上传素材(小文件) */ export const uploadAsset = async ( formData: FormData, ): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => { const response = await apiClient.post("/upload", formData, { headers: { "Content-Type": "multipart/form-data" }, timeout: 30 * 60 * 1000, }) return response.data } /** 预签名直传准备 */ export const prepareDirectUpload = async (data: { project_id: string library_id: string filename: string content_type: string file_size: number }): Promise<{ upload_url: string method: string storage_key: string expires_at: string fields: Record max_size_bytes: number }> => { const response = await apiClient.post("/upload/direct/prepare", data) return response.data } /** 直传完成确认 */ export const completeDirectUpload = async (data: { project_id: string library_id: string storage_key: string }): Promise<{ storage_key: string; ingest_job_id: string }> => { const response = await apiClient.post("/upload/direct/complete", data) return response.data } /** 直传上传(大文件推荐),支持可选进度回调 */ export const uploadAssetDirect = async (data: { file: File library_id: string onProgress?: (percent: number) => void }): Promise<{ storage_key: string; ingest_job_id: string }> => { // 后端要求 project_id,前端自动获取默认项目 const project = await getOrCreateDefaultProject() const prepared = await prepareDirectUpload({ project_id: project.id, library_id: data.library_id, filename: data.file.name, content_type: data.file.type || "application/octet-stream", file_size: data.file.size, }) const directForm = new FormData() Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value)) directForm.append("file", data.file) // 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断 await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.open(prepared.method, prepared.upload_url) // 超时 10 分钟 xhr.timeout = 10 * 60 * 1000 xhr.upload.onprogress = (e) => { if (e.lengthComputable && data.onProgress) { data.onProgress(Math.round((e.loaded / e.total) * 100)) } } xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve() } else { // 解析 OSS 返回的 XML 错误信息 let ossError = "" try { const codeMatch = xhr.responseText.match(/([^<]+)<\/Code>/) const msgMatch = xhr.responseText.match(/([^<]+)<\/Message>/) if (codeMatch || msgMatch) { ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]` } } catch { // 无法解析响应体 } const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}` console.error("[OSS Upload] 直传失败:", { url: prepared.upload_url, storage_key: prepared.storage_key, status: xhr.status, statusText: xhr.statusText, }) reject(new Error(detail)) } } xhr.onerror = () => { console.error("[OSS Upload] 网络错误:", { url: prepared.upload_url, storage_key: prepared.storage_key, }) reject(new Error("OSS 上传网络错误,请检查网络连接")) } xhr.ontimeout = () => { console.error("[OSS Upload] 上传超时:", { url: prepared.upload_url, storage_key: prepared.storage_key, }) reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件")) } xhr.send(directForm) }) return completeDirectUpload({ project_id: project.id, library_id: data.library_id, storage_key: prepared.storage_key, }) } // ─── 入库 / 分类任务 ─────────────────────────────────────── /** 查询入库任务状态 */ export const getIngestJob = async (jobId: string): Promise => { const response = await apiClient.get(`/ingest-jobs/${jobId}`) return response.data } /** 提交素材分类任务 */ export const submitClassificationJob = async (data: { asset_id: string }): Promise => { const response = await apiClient.post("/classification-jobs", data) return response.data } /** 查询分类任务状态 */ export const getClassificationJob = async (jobId: string): Promise => { const response = await apiClient.get(`/classification-jobs/${jobId}`) return response.data } // ─── 批量操作 ─────────────────────────────────────────────── /** 批量操作结果 */ export interface BatchOperationResult { succeeded: string[] failed: string[] total: number success_count: number failure_count: number } /** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */ const normalizeBatchResult = (raw: Record): BatchOperationResult => { const succeeded = Array.isArray(raw.succeeded) ? (raw.succeeded as string[]) : [] const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : [] const success_count = typeof raw.success_count === "number" ? raw.success_count : succeeded.length const failure_count = typeof raw.failure_count === "number" ? raw.failure_count : failed.length const total = typeof raw.total === "number" ? raw.total : success_count + failure_count return { succeeded, failed, total, success_count, failure_count } } /** 批量删除素材 */ export const batchDeleteAssets = async (assetIds: string[]): Promise => { const response = await apiClient.post("/assets/batch-delete", { asset_ids: assetIds, }) return normalizeBatchResult((response.data || {}) as Record) } /** 批量打标签 */ export const batchTagAssets = async (data: { asset_ids: string[] tags: string[] mode: "add" | "replace" }): Promise => { const response = await apiClient.post("/assets/batch-tag", data) return normalizeBatchResult((response.data || {}) as Record) } /** 批量改分类 */ export const batchClassifyAssets = async (data: { asset_ids: string[] category: string }): Promise => { const response = await apiClient.post("/assets/batch-classify", data) return normalizeBatchResult((response.data || {}) as Record) } /** 批量智能标记 */ export const batchMarkAssets = async (data: { asset_ids: string[] smart_view: "recommended" | "caution" | "high_risk" }): Promise => { const response = await apiClient.post("/assets/batch-mark", data) return normalizeBatchResult((response.data || {}) as Record) }