Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia 41eafee545 Merge branch 'develop' into refactor/assets-api
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 20s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m20s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m47s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m29s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m0s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m10s
AI Code Review / AI Code Review (pull_request) Successful in 1m28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m27s
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request) [frontend-only]
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 22s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 19s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 12:35:02 +08:00
xiaoxia d857298737 refactor(assets): API层按模块拆分(443行→入口77行, -83%)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 9s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 51s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 50s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 21s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m29s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 52s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m14s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m3s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 21s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 41s
AI Code Review / AI Code Review (pull_request) Successful in 6m33s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 44s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
将 assets.ts 单文件拆分为目录结构:

- types.ts — 所有类型定义
- diagnosis.ts — 素材诊断 API
- libraries.ts — 素材库 CRUD
- assets.ts — 素材 CRUD
- upload.ts — 表单上传 + OSS 直传(带进度/超时/错误诊断)
- jobs.ts — 入库任务 + 分类任务
- batch.ts — 批量操作(删除/打标签/分类/智能标记)+ 归一化工具
- index.ts — 统一入口,re-export 所有类型和函数

入口文件 443 行 → 77 行,各模块职责单一。
导入路径 @/api/assets 保持向后兼容。
2026-07-26 16:51:36 +08:00
17 changed files with 1271 additions and 1299 deletions
-443
View File
@@ -1,443 +0,0 @@
/**
* 素材相关 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<AssetDiagnosis> => {
const params: Record<string, string> = {}
if (assetId) params.asset_id = assetId
const response = await apiClient.get("/asset-diagnosis", { params })
return response.data
}
// ─── 素材库 ────────────────────────────────────────────────
/** 获取当前用户的所有素材库 */
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
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<AssetLibraryItem> => {
// 后端要求 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<AssetLibraryItem> => {
const response = await apiClient.post("/asset-libraries/ensure-default", data)
return response.data
}
/** 删除素材库 */
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
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<string, string | number> = { 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<AssetItem[]> => {
const params: Record<string, string | number> = { 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<AssetItem> => {
const response = await apiClient.post("/assets", data)
return response.data
}
/** 更新素材(名称、metadata 等) */
export const updateAsset = async (
assetId: string,
data: { name?: string; metadata?: AssetMetadata },
): Promise<AssetItem> => {
const response = await apiClient.put(`/assets/${assetId}`, data)
return response.data
}
/** 更新素材审核状态 */
export const updateAssetReviewStatus = async (
assetId: string,
reviewStatus: "pending_review" | "approved" | "rejected",
): Promise<AssetItem> => {
const response = await apiClient.patch(`/assets/${assetId}/review`, {
review_status: reviewStatus,
})
return response.data
}
/** 删除素材 */
export const deleteAsset = async (assetId: string): Promise<void> => {
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<string, string>
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<void>((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>([^<]+)<\/Code>/)
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/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<IngestJob> => {
const response = await apiClient.get(`/ingest-jobs/${jobId}`)
return response.data
}
/** 提交素材分类任务 */
export const submitClassificationJob = async (data: {
asset_id: string
}): Promise<ClassificationJob> => {
const response = await apiClient.post("/classification-jobs", data)
return response.data
}
/** 查询分类任务状态 */
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
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<string, unknown>): 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<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-delete", {
asset_ids: assetIds,
})
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量打标签 */
export const batchTagAssets = async (data: {
asset_ids: string[]
tags: string[]
mode: "add" | "replace"
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-tag", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量改分类 */
export const batchClassifyAssets = async (data: {
asset_ids: string[]
category: string
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-classify", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量智能标记 */
export const batchMarkAssets = async (data: {
asset_ids: string[]
smart_view: "recommended" | "caution" | "high_risk"
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-mark", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
+87
View File
@@ -0,0 +1,87 @@
/**
* 素材 CRUD API
*/
import apiClient from "../client"
import type { AssetItem, AssetMetadata } from "./types"
/** 获取素材库下的所有素材 */
export const getAssets = async (
libraryId: string,
options?: {
status?: string
page?: number
page_size?: number
},
): Promise<{ items: AssetItem[]; total: number }> => {
const params: Record<string, string | number> = { library_id: libraryId }
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<AssetItem[]> => {
const params: Record<string, string | number> = { 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<AssetItem> => {
const response = await apiClient.post("/assets", data)
return response.data
}
/** 更新素材(名称、metadata 等) */
export const updateAsset = async (
assetId: string,
data: { name?: string; metadata?: AssetMetadata },
): Promise<AssetItem> => {
const response = await apiClient.put(`/assets/${assetId}`, data)
return response.data
}
/** 更新素材审核状态 */
export const updateAssetReviewStatus = async (
assetId: string,
reviewStatus: "pending_review" | "approved" | "rejected",
): Promise<AssetItem> => {
const response = await apiClient.patch(`/assets/${assetId}/review`, {
review_status: reviewStatus,
})
return response.data
}
/** 删除素材 */
export const deleteAsset = async (assetId: string): Promise<void> => {
await apiClient.delete(`/assets/${assetId}`)
}
+51
View File
@@ -0,0 +1,51 @@
/**
* 素材批量操作 API
*/
import apiClient from "../client"
import type { BatchOperationResult } from "./types"
/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */
export const normalizeBatchResult = (raw: Record<string, unknown>): 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<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-delete", {
asset_ids: assetIds,
})
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量打标签 */
export const batchTagAssets = async (data: {
asset_ids: string[]
tags: string[]
mode: "add" | "replace"
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-tag", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量改分类 */
export const batchClassifyAssets = async (data: {
asset_ids: string[]
category: string
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-classify", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量智能标记 */
export const batchMarkAssets = async (data: {
asset_ids: string[]
smart_view: "recommended" | "caution" | "high_risk"
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-mark", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
+13
View File
@@ -0,0 +1,13 @@
/**
* 素材诊断 API
*/
import apiClient from "../client"
import type { AssetDiagnosis } from "./types"
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
const params: Record<string, string> = {}
if (assetId) params.asset_id = assetId
const response = await apiClient.get("/asset-diagnosis", { params })
return response.data
}
+55
View File
@@ -0,0 +1,55 @@
/**
* 素材相关 API — 按模块拆分后的统一入口
* 保持与原 assets.ts 相同的导出结构,向后兼容
*/
// 类型
export type {
AssetMetadata,
AssetClassificationStatus,
AssetItem,
AssetLibraryItem,
IngestJob,
ClassificationJob,
AssetDiagnosis,
BatchOperationResult,
UploadResult,
DirectUploadPrepareResult,
DirectUploadCompleteResult,
} from "./types"
// 素材诊断
export { getAssetDiagnosis } from "./diagnosis"
// 素材库
export {
getAssetLibraries,
createAssetLibrary,
ensureDefaultLibrary,
deleteAssetLibrary,
} from "./libraries"
// 素材 CRUD
export {
getAssets,
getAssetsByKind,
createAsset,
updateAsset,
updateAssetReviewStatus,
deleteAsset,
} from "./assets"
// 上传
export { uploadAsset, prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
// 任务
export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs"
// 批量操作
export {
normalizeBatchResult,
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
} from "./batch"
+25
View File
@@ -0,0 +1,25 @@
/**
* 入库任务 & 分类任务 API
*/
import apiClient from "../client"
import type { IngestJob, ClassificationJob } from "./types"
/** 查询入库任务状态 */
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
const response = await apiClient.get(`/ingest-jobs/${jobId}`)
return response.data
}
/** 提交素材分类任务 */
export const submitClassificationJob = async (data: {
asset_id: string
}): Promise<ClassificationJob> => {
const response = await apiClient.post("/classification-jobs", data)
return response.data
}
/** 查询分类任务状态 */
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
const response = await apiClient.get(`/classification-jobs/${jobId}`)
return response.data
}
+39
View File
@@ -0,0 +1,39 @@
/**
* 素材库 API
*/
import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { AssetLibraryItem } from "./types"
/** 获取当前用户的所有素材库 */
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
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<AssetLibraryItem> => {
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<AssetLibraryItem> => {
const response = await apiClient.post("/asset-libraries/ensure-default", data)
return response.data
}
/** 删除素材库 */
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
await apiClient.delete(`/asset-libraries/${libraryId}`)
}
+138
View File
@@ -0,0 +1,138 @@
/**
* 素材相关类型定义
*/
/** 素材元数据 */
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
}>
}
/** 批量操作结果 */
export interface BatchOperationResult {
succeeded: string[]
failed: string[]
total: number
success_count: number
failure_count: number
}
/** 上传返回 */
export interface UploadResult {
storage_key: string
ingest_job_id: string
url: string
}
/** 预签名直传准备返回 */
export interface DirectUploadPrepareResult {
upload_url: string
method: string
storage_key: string
expires_at: string
fields: Record<string, string>
max_size_bytes: number
}
/** 直传完成确认返回 */
export interface DirectUploadCompleteResult {
storage_key: string
ingest_job_id: string
}
+119
View File
@@ -0,0 +1,119 @@
/**
* 上传相关 API(表单上传 + OSS 直传)
*/
import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { UploadResult, DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
/** 表单上传素材(小文件) */
export const uploadAsset = async (formData: FormData): Promise<UploadResult> => {
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<DirectUploadPrepareResult> => {
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<DirectUploadCompleteResult> => {
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<DirectUploadCompleteResult> => {
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<void>((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>([^<]+)<\/Code>/)
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/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,
})
}
+744
View File
@@ -0,0 +1,744 @@
/**
* 模板草稿 API — 对接后端 Template Editor Schema
* 字段名严格匹配后端 API 响应
*/
import apiClient from "./client"
import type { AssetItem } from "./assets"
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
/* ============================================================
* 后端 API 类型(严格匹配后端 Schema)
* ============================================================ */
/** 模板草稿状态枚举 */
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled"
/** 标题配置(对齐后端 title_config */
export interface TitleConfig {
ai_auto_select: boolean
content: string
font_preset: string
font_color: string
font_size: number
position: string
}
/** 字幕配置 */
export interface SubtitleConfig {
enabled: boolean
position: string
font: string
color: string
size: number
animation: string
}
/** BGM 配置 */
export interface BgmConfig {
enabled: boolean
music_id: string
}
/** 片段 TTS 配置 */
export interface SegmentTtsConfig {
mode: string
text: string
voice_id: string
speed: number
pitch: number
volume: number
subtitle_sync: boolean
}
/** 片段裁剪配置 */
export interface SegmentTrimConfig {
start_time: number
end_time: number
}
/** 片段转场配置 */
export interface SegmentTransitionConfig {
type: string
duration: number
}
/** 模板草稿中的单个片段(config 内部 segments 项) */
export interface EditPlanSegment {
segment_order: number
duration_min: number
duration_max: number
material_type: string
transition?: SegmentTransitionConfig
playback_speed?: number
tts_config?: SegmentTtsConfig
trim_config?: SegmentTrimConfig
}
/** 模板草稿 config 完整类型(对齐后端 config JSON 结构) */
export interface EditPlanConfig {
title_config?: TitleConfig
subtitle_config?: SubtitleConfig
bgm_config?: BgmConfig
estimated_duration?: number
segments?: EditPlanSegment[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
pip_config?: PipConfig
filter_config?: FilterConfig
green_screen_config?: ChromaKeyConfig
sticker_config?: StickerConfig
cover_config?: CoverConfig
/** 前端扩展:关联的素材 ID 列表 */
asset_ids?: string[]
/** 配音 ID */
voice_id?: string
/** 克隆音色档案 ID */
voice_clone_profile_id?: string
/** 自定义配音音频 URL */
custom_audio_url?: string
/** 自定义配音文本 */
custom_text?: string
/** 视频比例 */
ratio?: string
/** 视频风格 */
style?: string
/** 目标时长(秒) */
duration?: number
/** 是否自动生成字幕 */
auto_subtitles?: boolean
/** 是否启用 BGM */
bgm?: boolean
/** 生成数量 */
generate_count?: number
/** 素材模式 */
material_mode?: string
}
/** 模板草稿(后端响应) */
export interface EditPlan {
id: string
template_id: string
name: string
status: EditPlanStatus
total_duration: number
/** 生成视频数量(后端 EditPlanResponse.result_count */
result_count: number
config: EditPlanConfig
created_at: string
updated_at: string
}
/** 创建模板草稿请求(后端要求 template_id + name 必填) */
export interface CreateEditPlanRequest {
template_id: string
name: string
config?: EditPlanConfig
total_duration?: number
/** 来源模板草稿 ID(从模板编辑器跳转到智能剪辑时关联) */
source_edit_plan_id?: string
}
/** 更新模板草稿请求 */
export interface UpdateEditPlanRequest {
name?: string
config?: EditPlanConfig
total_duration?: number
status?: EditPlanStatus
}
/** 生成响应 */
export interface GenerateResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id: string
clip_count: number
}
/** 模板草稿关联的生成记录(实际是 GenerationTask 对象) */
export interface EditPlanGeneration {
id: string // 即 generation_task_id
source_edit_plan_id: string
template_id: string
asset_ids: string[]
status: EditPlanStatus
progress: number
result_count: number
error_message: string
error_info: Record<string, unknown>
logs: Array<Record<string, unknown>>
retry_count: number
created_at?: string
updated_at?: string
}
/** 片段生成状态 */
export interface ClipStatusItem {
clip_id: string
clip_type: string
order: number
status: string
asset_id?: string
text_content?: string
duration?: number
error_message?: string
}
/** 生成状态轮询响应 */
export interface GenerationStatusResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id?: string
error_message?: string
clips: ClipStatusItem[]
error?: string
message?: string
}
/** 生成视频详情(对应后端 GeneratedVideoResponse */
export interface GeneratedVideo {
id: string
project_id?: string
generation_task_id?: string
name: string
file_url: string
file_size?: number
duration?: number
thumbnail_url?: string
width?: number
height?: number
fps?: number
status: string
review_status?: string
download_url?: string
created_at?: string
updated_at?: string
}
/* ============================================================
* AI 推荐 & 封面生成(任务 3.09
* ============================================================ */
/** AI 推荐请求 */
export interface AIRecommendRequest {
asset_ids: string[]
editing_mode?: string
target_duration?: number
}
/** AI 推荐单个片段 */
export interface AIRecommendClipItem {
clip_type: string
order: number
text_content: string
duration: number
transition_effect: string
asset_id: string
start_time: number
config: EditPlanConfig
}
/** AI 推荐响应 */
export interface AIRecommendResponse {
plan_id: string
clips: AIRecommendClipItem[]
config: EditPlanConfig
total_duration: number
confidence: number
}
/** AI 封面生成请求 */
export interface GenerateCoverRequest {
asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
}
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string
cover: CoverResult
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string
asset_id?: string
frame_time?: number
thumbnail_url?: string
}
/* ============================================================
* 前端 UI 类型(EditingPlanner 组件依赖,保留兼容)
* ============================================================ */
/** 转场效果(14 种预设) */
export interface TransitionEffect {
type:
| "none"
| "cut"
| "fade"
| "dissolve"
| "zoom"
| "slide_left"
| "slide_right"
| "slide_up"
| "slide_down"
| "wipe_left"
| "wipe_right"
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop"
duration: number // 转场时长(秒)
/** 播放速度倍率 */
playback_speed?: number
}
/** 素材库资产(UI 层类型,映射自后端 AssetResponse */
export interface MediaAsset {
id: string
name: string
type: "video" | "image" | "audio"
/** 缩略图 URL */
thumbnail_url?: string
/** 时长(秒),仅 video/audio */
duration?: number
/** 文件大小(字节) */
size?: number
/** 标签 */
tags: string[]
created_at: string
/** 质量分 0-100 */
quality_score?: number
/** 分类状态 */
classification_status?: "pending" | "processing" | "completed" | "failed"
}
/* ============================================================
* API 函数 — 严格对接后端
* ============================================================ */
/** 模板草稿列表查询参数 */
export interface EditPlanListParams {
page?: number
page_size?: number
template_id?: string
status?: string
}
/** 模板草稿列表分页响应 */
export interface EditPlanListResponse {
items: EditPlan[]
total: number
page: number
page_size: number
}
/** 获取模板草稿列表(支持分页和筛选) */
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
params,
})
return response.data
}
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
const response = await apiClient.get(`/templates/${templateId}/editor`)
return response.data
}
/** 创建模板草稿 */
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post("/templates/drafts", data)
return response.data
}
/** 更新模板草稿 */
export async function updateEditPlan(
templateId: string,
data: UpdateEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
return response.data
}
/** 删除模板草稿 */
export async function deleteEditPlan(templateId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor`)
}
/** 触发生成 */
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
return response.data
}
/** 获取生成状态(轮询用) */
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
return response.data
}
/** AI 推荐片段方案 */
export async function aiRecommendClips(
templateId: string,
data: AIRecommendRequest,
): Promise<AIRecommendResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
return response.data
}
/** AI 生成封面 */
export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
return response.data
}
/** 获取模板草稿关联的生成记录 */
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
return response.data.items || []
}
/** 获取生成任务的视频结果列表 */
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
return response.data.items || response.data || []
}
/** 取消生成任务 */
export async function cancelGeneration(templateId: string): Promise<void> {
await apiClient.post(`/templates/${templateId}/editor/cancel`)
}
/* ============================================================
* 片段 CRUD(后端 EditPlanClip 独立表)
* ============================================================ */
/** 片段状态 */
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed"
/** 剪辑片段(后端响应) */
export interface EditPlanClip {
id: string
plan_id: string
clip_type: string // main / intro / outro / overlay / background / b_roll 等
order: number
asset_id: string
text_content: string
start_time: number
duration: number
transition_effect: string
transition_duration: number
playback_speed: number
status: EditPlanClipStatus
config: Record<string, unknown>
created_at?: string
updated_at?: string
}
/** 创建片段请求 */
export interface CreateEditPlanClipRequest {
clip_type: string
order: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 更新片段请求 */
export interface UpdateEditPlanClipRequest {
clip_type?: string
order?: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 片段列表响应 */
export interface EditPlanClipListResponse {
items: EditPlanClip[]
total: number
}
/** 片段列表查询参数 */
export interface EditPlanClipListParams {
status?: string
skip?: number
limit?: number
}
/** 获取片段列表 */
export async function getEditPlanClips(
templateId: string,
params?: EditPlanClipListParams,
): Promise<EditPlanClipListResponse> {
const response = await apiClient.get<EditPlanClipListResponse>(
`/templates/${templateId}/editor/clips`,
{
params,
},
)
return response.data
}
/** 获取单个片段详情 */
export async function getEditPlanClip(templateId: string, clipId: string): Promise<EditPlanClip> {
const response = await apiClient.get<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
)
return response.data
}
/** 创建片段 */
export async function createEditPlanClip(
templateId: string,
data: CreateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.post<EditPlanClip>(`/templates/${templateId}/editor/clips`, data)
return response.data
}
/** 更新片段 */
export async function updateEditPlanClip(
templateId: string,
clipId: string,
data: UpdateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.put<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
data,
)
return response.data
}
/** 删除片段 */
export async function deleteEditPlanClip(templateId: string, clipId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor/clips/${clipId}`)
}
/* ============================================================
* 片段批量操作
* ============================================================ */
/** 重排序条目 */
export interface ClipReorderItem {
clip_id: string
new_order: number
}
/** 重排序响应 */
export interface ClipReorderResponse {
success: boolean
updated_count: number
message: string
}
/** 批量删除响应 */
export interface ClipBatchDeleteResponse {
success: boolean
deleted_count: number
message: string
}
/** 从素材批量创建响应 */
export interface ClipsFromAssetsResponse {
success: boolean
created_count: number
message: string
clip_ids: string[]
}
/** 片段重排序(拖拽排序后一次性提交) */
export async function reorderEditPlanClips(
templateId: string,
items: ClipReorderItem[],
): Promise<ClipReorderResponse> {
const response = await apiClient.post<ClipReorderResponse>(
`/templates/${templateId}/editor/clips/reorder`,
{ items },
)
return response.data
}
/** 批量删除片段 */
export async function batchDeleteEditPlanClips(
templateId: string,
clipIds: string[],
): Promise<ClipBatchDeleteResponse> {
const response = await apiClient.post<ClipBatchDeleteResponse>(
`/templates/${templateId}/editor/clips/batch-delete`,
{ clip_ids: clipIds },
)
return response.data
}
/** 从素材批量创建片段(追加到时间线末尾) */
export async function createClipsFromAssets(
templateId: string,
assetIds: string[],
clipType = "main",
): Promise<ClipsFromAssetsResponse> {
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`,
{ asset_ids: assetIds, clip_type: clipType },
)
return response.data
}
/* ============================================================
* 复制计划
* ============================================================ */
/** 复制计划请求 */
export interface CopyEditPlanRequest {
name?: string
project_id?: string
}
/** 复制模板草稿(含所有片段配置) */
export async function copyEditPlan(
templateId: string,
data?: CopyEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.post<EditPlan>(
`/templates/${templateId}/editor/copy`,
data || {},
)
return response.data
}
/**
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
*/
export async function getMediaAssets(libraryId?: string): Promise<MediaAsset[]> {
const response = await apiClient.get("/assets", {
params: libraryId ? { library_id: libraryId } : undefined,
})
const items: AssetItem[] = response.data.items || []
return items.map(mapAssetToMediaAsset)
}
/** 获取单个素材 */
export async function getMediaAsset(id: string): Promise<MediaAsset> {
const response = await apiClient.get(`/assets/${id}`)
return mapAssetToMediaAsset(response.data)
}
/* ============================================================
* 映射函数:AssetResponse → MediaAsset
* ============================================================ */
function inferMediaType(mimeType: string): "video" | "image" | "audio" {
if (mimeType.startsWith("video/")) return "video"
if (mimeType.startsWith("image/")) return "image"
return "audio"
}
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
// 优先取顶层 duration,其次从 metadata 回退
const metaDuration =
typeof asset.metadata?.duration === "number" ? asset.metadata.duration : undefined
return {
id: asset.id,
name: asset.name,
type: inferMediaType(asset.mime_type || ""),
thumbnail_url: asset.thumbnail_url,
duration: asset.duration ?? metaDuration,
size: asset.file_size ?? undefined,
tags: [],
created_at: asset.created_at ?? "",
quality_score: asset.quality_score ?? undefined,
classification_status: asset.classification_status ?? undefined,
}
}
/* ============================================================
* 常量
* ============================================================ */
/** 转场效果选项(14 种预设) */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"]
label: string
icon: string
}[] = [
{ value: "none", label: "无转场", icon: "⊘" },
{ value: "cut", label: "硬切", icon: "✂" },
{ value: "fade", label: "淡入淡出", icon: "◐" },
{ value: "dissolve", label: "溶解", icon: "◈" },
{ value: "zoom", label: "缩放", icon: "⊕" },
{ value: "slide_left", label: "左滑", icon: "←" },
{ value: "slide_right", label: "右滑", icon: "→" },
{ value: "slide_up", label: "上滑", icon: "↑" },
{ value: "slide_down", label: "下滑", icon: "↓" },
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
]
/** 素材类型标签 */
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
}
/** 素材类型图标 */
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
}
/** 计划状态标签 */
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
draft: "草稿",
editing: "编辑中",
rendering: "渲染中",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
}
/** 质量分筛选选项 */
export const QUALITY_OPTIONS: {
value: string
label: string
min?: number
max?: number
}[] = [
{ value: "all", label: "全部质量" },
{ value: "high", label: "高质量 (80-100)", min: 80, max: 100 },
{ value: "medium", label: "中质量 (50-79)", min: 50, max: 79 },
{ value: "low", label: "低质量 (0-49)", min: 0, max: 49 },
]
@@ -1,28 +0,0 @@
/**
* AI 推荐 + 封面生成 API
*/
import apiClient from "../client"
import type {
AIRecommendRequest,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
} from "./types"
/** AI 推荐片段方案 */
export async function aiRecommendClips(
templateId: string,
data: AIRecommendRequest,
): Promise<AIRecommendResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
return response.data
}
/** AI 生成封面 */
export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
return response.data
}
-99
View File
@@ -1,99 +0,0 @@
/**
* 片段 CRUD + 批量操作 API
*/
import apiClient from "../client"
import type {
EditPlanClip,
EditPlanClipListParams,
EditPlanClipListResponse,
CreateEditPlanClipRequest,
UpdateEditPlanClipRequest,
ClipReorderItem,
ClipReorderResponse,
ClipBatchDeleteResponse,
ClipsFromAssetsResponse,
} from "./types"
/** 获取片段列表 */
export async function getEditPlanClips(
templateId: string,
params?: EditPlanClipListParams,
): Promise<EditPlanClipListResponse> {
const response = await apiClient.get<EditPlanClipListResponse>(
`/templates/${templateId}/editor/clips`,
{ params },
)
return response.data
}
/** 获取单个片段详情 */
export async function getEditPlanClip(templateId: string, clipId: string): Promise<EditPlanClip> {
const response = await apiClient.get<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
)
return response.data
}
/** 创建片段 */
export async function createEditPlanClip(
templateId: string,
data: CreateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.post<EditPlanClip>(`/templates/${templateId}/editor/clips`, data)
return response.data
}
/** 更新片段 */
export async function updateEditPlanClip(
templateId: string,
clipId: string,
data: UpdateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.put<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
data,
)
return response.data
}
/** 删除片段 */
export async function deleteEditPlanClip(templateId: string, clipId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor/clips/${clipId}`)
}
/** 片段重排序(拖拽排序后一次性提交) */
export async function reorderEditPlanClips(
templateId: string,
items: ClipReorderItem[],
): Promise<ClipReorderResponse> {
const response = await apiClient.post<ClipReorderResponse>(
`/templates/${templateId}/editor/clips/reorder`,
{ items },
)
return response.data
}
/** 批量删除片段 */
export async function batchDeleteEditPlanClips(
templateId: string,
clipIds: string[],
): Promise<ClipBatchDeleteResponse> {
const response = await apiClient.post<ClipBatchDeleteResponse>(
`/templates/${templateId}/editor/clips/batch-delete`,
{ clip_ids: clipIds },
)
return response.data
}
/** 从素材批量创建片段(追加到时间线末尾) */
export async function createClipsFromAssets(
templateId: string,
assetIds: string[],
clipType = "main",
): Promise<ClipsFromAssetsResponse> {
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`,
{ asset_ids: assetIds, clip_type: clipType },
)
return response.data
}
@@ -1,66 +0,0 @@
/**
* 模板编辑器常量
*/
import type { TransitionEffect, EditPlanStatus } from "./types"
/** 转场效果选项(14 种预设) */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"]
label: string
icon: string
}[] = [
{ value: "none", label: "无转场", icon: "⊘" },
{ value: "cut", label: "硬切", icon: "✂" },
{ value: "fade", label: "淡入淡出", icon: "◐" },
{ value: "dissolve", label: "溶解", icon: "◈" },
{ value: "zoom", label: "缩放", icon: "⊕" },
{ value: "slide_left", label: "左滑", icon: "←" },
{ value: "slide_right", label: "右滑", icon: "→" },
{ value: "slide_up", label: "上滑", icon: "↑" },
{ value: "slide_down", label: "下滑", icon: "↓" },
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
]
/** 素材类型标签 */
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
}
/** 素材类型图标 */
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
}
/** 计划状态标签 */
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
draft: "草稿",
editing: "编辑中",
rendering: "渲染中",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
}
/** 质量分筛选选项 */
export const QUALITY_OPTIONS: {
value: string
label: string
min?: number
max?: number
}[] = [
{ value: "all", label: "全部质量" },
{ value: "high", label: "高质量 (80-100)", min: 80, max: 100 },
{ value: "medium", label: "中质量 (50-79)", min: 50, max: 79 },
{ value: "low", label: "低质量 (0-49)", min: 0, max: 49 },
]
@@ -1,91 +0,0 @@
/**
* 模板草稿 CRUD + 生成相关 API
*/
import apiClient from "../client"
import type {
EditPlan,
EditPlanListParams,
EditPlanListResponse,
CreateEditPlanRequest,
UpdateEditPlanRequest,
GenerateResponse,
GenerationStatusResponse,
EditPlanGeneration,
GeneratedVideo,
CopyEditPlanRequest,
} from "./types"
/** 获取模板草稿列表(支持分页和筛选) */
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
params,
})
return response.data
}
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
const response = await apiClient.get(`/templates/${templateId}/editor`)
return response.data
}
/** 创建模板草稿 */
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post("/templates/drafts", data)
return response.data
}
/** 更新模板草稿 */
export async function updateEditPlan(
templateId: string,
data: UpdateEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
return response.data
}
/** 删除模板草稿 */
export async function deleteEditPlan(templateId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor`)
}
/** 触发生成 */
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
return response.data
}
/** 获取生成状态(轮询用) */
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
return response.data
}
/** 获取模板草稿关联的生成记录 */
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
return response.data.items || []
}
/** 获取生成任务的视频结果列表 */
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
return response.data.items || response.data || []
}
/** 取消生成任务 */
export async function cancelGeneration(templateId: string): Promise<void> {
await apiClient.post(`/templates/${templateId}/editor/cancel`)
}
/** 复制模板草稿(含所有片段配置) */
export async function copyEditPlan(
templateId: string,
data?: CopyEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.post<EditPlan>(
`/templates/${templateId}/editor/copy`,
data || {},
)
return response.data
}
-88
View File
@@ -1,88 +0,0 @@
/**
* 模板编辑器 API — 按模块拆分后的统一入口
* 保持与原 template-editor.ts 相同的导出结构,向后兼容
*/
// 类型
export type {
EditPlanStatus,
TitleConfig,
SubtitleConfig,
BgmConfig,
SegmentTtsConfig,
SegmentTrimConfig,
SegmentTransitionConfig,
EditPlanSegment,
EditPlanConfig,
EditPlan,
CreateEditPlanRequest,
UpdateEditPlanRequest,
EditPlanListParams,
EditPlanListResponse,
GenerateResponse,
EditPlanGeneration,
ClipStatusItem,
GenerationStatusResponse,
GeneratedVideo,
AIRecommendRequest,
AIRecommendClipItem,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
CoverResult,
EditPlanClipStatus,
EditPlanClip,
CreateEditPlanClipRequest,
UpdateEditPlanClipRequest,
EditPlanClipListResponse,
EditPlanClipListParams,
ClipReorderItem,
ClipReorderResponse,
ClipBatchDeleteResponse,
ClipsFromAssetsResponse,
CopyEditPlanRequest,
TransitionEffect,
MediaAsset,
} from "./types"
// 常量
export {
TRANSITION_OPTIONS,
MATERIAL_TYPE_LABELS,
MATERIAL_TYPE_ICONS,
PLAN_STATUS_LABELS,
QUALITY_OPTIONS,
} from "./constants"
// 模板草稿 CRUD + 生成
export {
getEditPlans,
getEditPlan,
createEditPlan,
updateEditPlan,
deleteEditPlan,
generateEditPlan,
getGenerationStatus,
getEditPlanGenerations,
getGenerationTaskResults,
cancelGeneration,
copyEditPlan,
} from "./editPlans"
// 片段 CRUD + 批量操作
export {
getEditPlanClips,
getEditPlanClip,
createEditPlanClip,
updateEditPlanClip,
deleteEditPlanClip,
reorderEditPlanClips,
batchDeleteEditPlanClips,
createClipsFromAssets,
} from "./clips"
// AI 推荐 + 封面生成
export { aiRecommendClips, generateCover } from "./aiFeatures"
// 素材库
export { getMediaAssets, getMediaAsset } from "./mediaAssets"
@@ -1,44 +0,0 @@
/**
* 素材库 API(对接 /assets 接口,映射为 MediaAsset 类型)
*/
import apiClient from "../client"
import type { AssetItem } from "../assets"
import type { MediaAsset } from "./types"
const inferMediaType = (mimeType: string): "video" | "image" | "audio" => {
if (mimeType.startsWith("video/")) return "video"
if (mimeType.startsWith("image/")) return "image"
return "audio"
}
const mapAssetToMediaAsset = (asset: AssetItem): MediaAsset => {
const metaDuration =
typeof asset.metadata?.duration === "number" ? asset.metadata.duration : undefined
return {
id: asset.id,
name: asset.name,
type: inferMediaType(asset.mime_type || ""),
thumbnail_url: asset.thumbnail_url,
duration: asset.duration ?? metaDuration,
size: asset.file_size ?? undefined,
tags: [],
created_at: asset.created_at ?? "",
quality_score: asset.quality_score ?? undefined,
classification_status: asset.classification_status ?? undefined,
}
}
/** 获取素材库列表 */
export async function getMediaAssets(libraryId?: string): Promise<MediaAsset[]> {
const response = await apiClient.get("/assets", {
params: libraryId ? { library_id: libraryId } : undefined,
})
const items: AssetItem[] = response.data.items || []
return items.map(mapAssetToMediaAsset)
}
/** 获取单个素材 */
export async function getMediaAsset(id: string): Promise<MediaAsset> {
const response = await apiClient.get(`/assets/${id}`)
return mapAssetToMediaAsset(response.data)
}
-440
View File
@@ -1,440 +0,0 @@
/**
* 模板编辑器 API 类型定义
* 字段名严格匹配后端 API 响应
*/
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
/* ── 模板草稿状态 ── */
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled"
/* ── 配置相关类型 ── */
/** 标题配置(对齐后端 title_config */
export interface TitleConfig {
ai_auto_select: boolean
content: string
font_preset: string
font_color: string
font_size: number
position: string
}
/** 字幕配置 */
export interface SubtitleConfig {
enabled: boolean
position: string
font: string
color: string
size: number
animation: string
}
/** BGM 配置 */
export interface BgmConfig {
enabled: boolean
music_id: string
}
/** 片段 TTS 配置 */
export interface SegmentTtsConfig {
mode: string
text: string
voice_id: string
speed: number
pitch: number
volume: number
subtitle_sync: boolean
}
/** 片段裁剪配置 */
export interface SegmentTrimConfig {
start_time: number
end_time: number
}
/** 片段转场配置 */
export interface SegmentTransitionConfig {
type: string
duration: number
}
/** 模板草稿中的单个片段 */
export interface EditPlanSegment {
segment_order: number
duration_min: number
duration_max: number
material_type: string
transition?: SegmentTransitionConfig
playback_speed?: number
tts_config?: SegmentTtsConfig
trim_config?: SegmentTrimConfig
}
/** 模板草稿 config 完整类型 */
export interface EditPlanConfig {
title_config?: TitleConfig
subtitle_config?: SubtitleConfig
bgm_config?: BgmConfig
estimated_duration?: number
segments?: EditPlanSegment[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
pip_config?: PipConfig
filter_config?: FilterConfig
green_screen_config?: ChromaKeyConfig
sticker_config?: StickerConfig
cover_config?: CoverConfig
/** 前端扩展:关联的素材 ID 列表 */
asset_ids?: string[]
/** 配音 ID */
voice_id?: string
/** 克隆音色档案 ID */
voice_clone_profile_id?: string
/** 自定义配音音频 URL */
custom_audio_url?: string
/** 自定义配音文本 */
custom_text?: string
/** 视频比例 */
ratio?: string
/** 视频风格 */
style?: string
/** 目标时长(秒) */
duration?: number
/** 是否自动生成字幕 */
auto_subtitles?: boolean
/** 是否启用 BGM */
bgm?: boolean
/** 生成数量 */
generate_count?: number
/** 素材模式 */
material_mode?: string
}
/* ── 模板草稿主体 ── */
/** 模板草稿(后端响应) */
export interface EditPlan {
id: string
template_id: string
name: string
status: EditPlanStatus
total_duration: number
/** 生成视频数量 */
result_count: number
config: EditPlanConfig
created_at: string
updated_at: string
}
/** 创建模板草稿请求 */
export interface CreateEditPlanRequest {
template_id: string
name: string
config?: EditPlanConfig
total_duration?: number
/** 来源模板草稿 ID */
source_edit_plan_id?: string
}
/** 更新模板草稿请求 */
export interface UpdateEditPlanRequest {
name?: string
config?: EditPlanConfig
total_duration?: number
status?: EditPlanStatus
}
/** 模板草稿列表查询参数 */
export interface EditPlanListParams {
page?: number
page_size?: number
template_id?: string
status?: string
}
/** 模板草稿列表分页响应 */
export interface EditPlanListResponse {
items: EditPlan[]
total: number
page: number
page_size: number
}
/* ── 生成相关 ── */
/** 生成响应 */
export interface GenerateResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id: string
clip_count: number
}
/** 模板草稿关联的生成记录 */
export interface EditPlanGeneration {
id: string
source_edit_plan_id: string
template_id: string
asset_ids: string[]
status: EditPlanStatus
progress: number
result_count: number
error_message: string
error_info: Record<string, unknown>
logs: Array<Record<string, unknown>>
retry_count: number
created_at?: string
updated_at?: string
}
/** 片段生成状态 */
export interface ClipStatusItem {
clip_id: string
clip_type: string
order: number
status: string
asset_id?: string
text_content?: string
duration?: number
error_message?: string
}
/** 生成状态轮询响应 */
export interface GenerationStatusResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id?: string
error_message?: string
clips: ClipStatusItem[]
error?: string
message?: string
}
/** 生成视频详情 */
export interface GeneratedVideo {
id: string
project_id?: string
generation_task_id?: string
name: string
file_url: string
file_size?: number
duration?: number
thumbnail_url?: string
width?: number
height?: number
fps?: number
status: string
review_status?: string
download_url?: string
created_at?: string
updated_at?: string
}
/* ── AI 推荐 & 封面生成 ── */
/** AI 推荐请求 */
export interface AIRecommendRequest {
asset_ids: string[]
editing_mode?: string
target_duration?: number
}
/** AI 推荐单个片段 */
export interface AIRecommendClipItem {
clip_type: string
order: number
text_content: string
duration: number
transition_effect: string
asset_id: string
start_time: number
config: EditPlanConfig
}
/** AI 推荐响应 */
export interface AIRecommendResponse {
plan_id: string
clips: AIRecommendClipItem[]
config: EditPlanConfig
total_duration: number
confidence: number
}
/** AI 封面生成请求 */
export interface GenerateCoverRequest {
asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
}
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string
cover: CoverResult
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string
asset_id?: string
frame_time?: number
thumbnail_url?: string
}
/* ── 片段 CRUD 相关 ── */
/** 片段状态 */
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed"
/** 剪辑片段(后端响应) */
export interface EditPlanClip {
id: string
plan_id: string
clip_type: string
order: number
asset_id: string
text_content: string
start_time: number
duration: number
transition_effect: string
transition_duration: number
playback_speed: number
status: EditPlanClipStatus
config: Record<string, unknown>
created_at?: string
updated_at?: string
}
/** 创建片段请求 */
export interface CreateEditPlanClipRequest {
clip_type: string
order: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 更新片段请求 */
export interface UpdateEditPlanClipRequest {
clip_type?: string
order?: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 片段列表响应 */
export interface EditPlanClipListResponse {
items: EditPlanClip[]
total: number
}
/** 片段列表查询参数 */
export interface EditPlanClipListParams {
status?: string
skip?: number
limit?: number
}
/* ── 片段批量操作 ── */
/** 重排序条目 */
export interface ClipReorderItem {
clip_id: string
new_order: number
}
/** 重排序响应 */
export interface ClipReorderResponse {
success: boolean
updated_count: number
message: string
}
/** 批量删除响应 */
export interface ClipBatchDeleteResponse {
success: boolean
deleted_count: number
message: string
}
/** 从素材批量创建响应 */
export interface ClipsFromAssetsResponse {
success: boolean
created_count: number
message: string
clip_ids: string[]
}
/* ── 复制计划 ── */
/** 复制计划请求 */
export interface CopyEditPlanRequest {
name?: string
project_id?: string
}
/* ── 前端 UI 类型 ── */
/** 转场效果(14 种预设) */
export interface TransitionEffect {
type:
| "none"
| "cut"
| "fade"
| "dissolve"
| "zoom"
| "slide_left"
| "slide_right"
| "slide_up"
| "slide_down"
| "wipe_left"
| "wipe_right"
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop"
duration: number
/** 播放速度倍率 */
playback_speed?: number
}
/** 素材库资产(UI 层类型) */
export interface MediaAsset {
id: string
name: string
type: "video" | "image" | "audio"
/** 缩略图 URL */
thumbnail_url?: string
/** 时长(秒),仅 video/audio */
duration?: number
/** 文件大小(字节) */
size?: number
/** 标签 */
tags: string[]
created_at: string
/** 质量分 0-100 */
quality_score?: number
/** 分类状态 */
classification_status?: "pending" | "processing" | "completed" | "failed"
}