Compare commits

..

8 Commits

Author SHA1 Message Date
xiaoxia 07562a71c2 fix: 删除同名文件避免循环导入(目录index.ts已作为入口)
CI/CD Pipeline / CI Gate (pull_request) CI运行中 [frontend-only]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 16s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 44s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m35s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m32s
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 / Validate - Code Quality (pull_request) Failing after 2m25s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 49s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m1s
AI Code Review / AI Code Review (pull_request) Successful in 1m43s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m46s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m5s
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 16s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 9s
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
2026-07-27 15:24:38 +08:00
xiaoxia 8036b134de fix: 修正 useClipTrim.ts 中 types 导入路径(../types → ../../types)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m34s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m13s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 26s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 22s
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 API Image (pull_request) Successful in 1m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 38s
AI Code Review / AI Code Review (pull_request) Successful in 1m36s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 32s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 41s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 3m5s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
2026-07-27 12:41:34 +08:00
xiaoxia 663f936a8c fix: 修正 useClipEffects.ts 中 types 导入路径(../types → ../../types)
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 36s
2026-07-27 12:41:33 +08:00
xiaoxia eadb2eef57 fix: 修正 useClipBasicOps.ts 中 types 导入路径(../types → ../../types)
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 45m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m38s
2026-07-27 12:41:32 +08:00
xiaoxia 9a854679f8 fix: 修正 index.ts 中 types 导入路径(../types → ../../types) 2026-07-27 12:41:31 +08:00
xiaoxia 2a16c755bf fix: 恢复 useClipOperations.ts 主入口,re-export 子目录保持路径兼容
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 36s
2026-07-27 12:41:29 +08:00
xiaoxia b052199e56 Merge branch 'develop' into refactor/use-clip-operations-split
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 55s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 56s
2026-07-27 12:34:26 +08:00
xiaoxia ee60770853 refactor(editing-planner): 拆分 useClipOperations Hook,按职责拆为 3 个子模块
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 20s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 46s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m8s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m15s
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 / Validate - Code Quality (pull_request) Failing after 1m50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m18s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m10s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m30s
AI Code Review / AI Code Review (pull_request) Successful in 2m1s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m44s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m9s
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 53s
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
主文件 238→60 行(-75%),拆分为:
- useClipBasicOps: 选中状态 + 增删改查重排
- useClipTrim: 裁剪、分割、重置裁剪
- useClipEffects: 转场、调速、TTS、配音选择

导入路径保持向后兼容
2026-07-27 07:27:24 +08:00
14 changed files with 730 additions and 765 deletions
+443
View File
@@ -0,0 +1,443 @@
/**
* 素材相关 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
@@ -1,87 +0,0 @@
/**
* 素材 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
@@ -1,51 +0,0 @@
/**
* 素材批量操作 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
@@ -1,13 +0,0 @@
/**
* 素材诊断 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
@@ -1,55 +0,0 @@
/**
* 素材相关 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
@@ -1,25 +0,0 @@
/**
* 入库任务 & 分类任务 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
@@ -1,39 +0,0 @@
/**
* 素材库 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
@@ -1,138 +0,0 @@
/**
* 素材相关类型定义
*/
/** 素材元数据 */
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
@@ -1,119 +0,0 @@
/**
* 上传相关 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,
})
}
@@ -1,238 +0,0 @@
import { useState, useCallback, useMemo } from "react"
import { Modal, message } from "antd"
import type {
ClipData,
ClipType,
TransitionConfig,
SpeedConfig,
TtsConfig,
TrimConfig,
} from "../types"
import type { AssetItem } from "@/api/assets"
interface UseClipOperationsParams {
clips: ClipData[]
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
}
/**
* 片段操作 Hook
* 片段增删改查、排序、裁剪、分割、转场、调速、TTS、配音选择
*/
export const useClipOperations = ({ clips, setClips }: UseClipOperationsParams) => {
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
const selectedClip = useMemo(
() => clips.find((c) => c.id === selectedClipId) || null,
[clips, selectedClipId],
)
/* ── 选中 / 重排 / 删除 ── */
const handleClipSelect = useCallback((clipId: string) => {
setSelectedClipId(clipId)
}, [])
const handleClipReorder = useCallback(
(fromIdx: number, toIdx: number) => {
setClips((prev) => {
const updated = [...prev]
const [moved] = updated.splice(fromIdx, 1)
updated.splice(toIdx, 0, moved)
return updated.map((c, i) => ({ ...c, order: i }))
})
},
[setClips],
)
const handleClipRemove = useCallback(
(clipId: string) => {
Modal.confirm({
title: "删除片段",
content: "确定要删除这个片段吗?此操作可通过撤销恢复。",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: () => {
setClips((prev) => prev.filter((c) => c.id !== clipId))
if (selectedClipId === clipId) setSelectedClipId(null)
},
})
},
[setClips, selectedClipId],
)
const handleClipUpdate = useCallback(
(clipId: string, data: Partial<ClipData>) => {
setClips((prev) => prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)))
},
[setClips],
)
/**
* 添加片段(不绑定任何素材)
* 片段 = 时间规划 + 类型标记
*/
const handleAddClip = useCallback(
(type: ClipType, duration: number) => {
const newClip: ClipData = {
id: `clip-${Date.now()}`,
type,
duration,
startOffset: 0,
order: clips.length,
}
setClips((prev) => [...prev, newClip])
},
[clips.length, setClips],
)
/* ── 裁剪 / 分割 / 重置 ── */
const handleClipTrim = useCallback(
(clipId: string, trimConfig: TrimConfig, newDuration: number) => {
setClips((prev) =>
prev.map((c) =>
c.id === clipId ? { ...c, trim_config: trimConfig, duration: newDuration } : c,
),
)
},
[setClips],
)
const handleClipSplit = useCallback(
(clipId: string, splitRatio: number) => {
setClips((prev) => {
const idx = prev.findIndex((c) => c.id === clipId)
if (idx === -1) return prev
const clip = prev[idx]
const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10
if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev
// 前半段
const firstHalf: ClipData = {
...clip,
duration: splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
end_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
}
// 后半段
const secondHalf: ClipData = {
...clip,
id: `clip-${Date.now()}`,
duration: clip.duration - splitPoint,
startOffset: clip.startOffset + splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
start_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
order: (clip.order ?? idx) + 1,
}
const updated = [...prev]
updated[idx] = firstHalf
updated.splice(idx + 1, 0, secondHalf)
return updated.map((c, i) => ({ ...c, order: i }))
})
},
[setClips],
)
const handleClipResetTrim = useCallback(
(clipId: string) => {
setClips((prev) =>
prev.map((c) => {
if (c.id !== clipId || !c.trim_config) return c
const originalDuration = c.trim_config.original_duration ?? c.duration
return {
...c,
duration: originalDuration,
trim_config: undefined,
}
}),
)
},
[setClips],
)
/* ── 转场 / 调速 / TTS ── */
const handleTransitionChange = useCallback(
(targetClipId: string | null, config: TransitionConfig) => {
if (targetClipId) {
handleClipUpdate(targetClipId, { transition: config })
}
// 同时更新全局默认转场(供新片段使用)—— 暂未实现全局默认
},
[handleClipUpdate],
)
const handleSpeedChange = useCallback(
(targetClipId: string | null, config: SpeedConfig) => {
if (targetClipId) {
handleClipUpdate(targetClipId, { speed: config })
}
},
[handleClipUpdate],
)
const handleApplySpeedAll = useCallback(
(config: SpeedConfig) => {
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })))
message.success("已应用到所有片段")
},
[setClips],
)
const handleTtsChange = useCallback(
(targetClipId: string | null, ttsConfig: TtsConfig) => {
if (!targetClipId) return
handleClipUpdate(targetClipId, { tts_config: ttsConfig })
},
[handleClipUpdate],
)
/** 为片段选择配音素材 */
const handleClipVoiceSelect = useCallback(
(clipId: string, asset: AssetItem | null) => {
setClips((prev) =>
prev.map((c) =>
c.id === clipId
? {
...c,
voice_asset_id: asset?.id ?? undefined,
voice_file_url: asset?.file_url ?? undefined,
}
: c,
),
)
},
[setClips],
)
return {
selectedClipId,
setSelectedClipId,
selectedClip,
handleClipSelect,
handleClipReorder,
handleClipRemove,
handleClipUpdate,
handleAddClip,
handleClipTrim,
handleClipSplit,
handleClipResetTrim,
handleTransitionChange,
handleSpeedChange,
handleApplySpeedAll,
handleTtsChange,
handleClipVoiceSelect,
}
}
@@ -0,0 +1,38 @@
import type { ClipData } from "../../types"
import { useClipBasicOps } from "./useClipBasicOps"
import { useClipTrim } from "./useClipTrim"
import { useClipEffects } from "./useClipEffects"
interface UseClipOperationsParams {
clips: ClipData[]
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
}
/**
* 片段操作 Hook
* 片段增删改查、排序、裁剪、分割、转场、调速、TTS、配音选择
*/
export const useClipOperations = ({ clips, setClips }: UseClipOperationsParams) => {
const basicOps = useClipBasicOps({ clips, setClips })
const trimOps = useClipTrim({ setClips })
const effectOps = useClipEffects({ setClips, handleClipUpdate: basicOps.handleClipUpdate })
return {
selectedClipId: basicOps.selectedClipId,
setSelectedClipId: basicOps.setSelectedClipId,
selectedClip: basicOps.selectedClip,
handleClipSelect: basicOps.handleClipSelect,
handleClipReorder: basicOps.handleClipReorder,
handleClipRemove: basicOps.handleClipRemove,
handleClipUpdate: basicOps.handleClipUpdate,
handleAddClip: basicOps.handleAddClip,
handleClipTrim: trimOps.handleClipTrim,
handleClipSplit: trimOps.handleClipSplit,
handleClipResetTrim: trimOps.handleClipResetTrim,
handleTransitionChange: effectOps.handleTransitionChange,
handleSpeedChange: effectOps.handleSpeedChange,
handleApplySpeedAll: effectOps.handleApplySpeedAll,
handleTtsChange: effectOps.handleTtsChange,
handleClipVoiceSelect: effectOps.handleClipVoiceSelect,
}
}
@@ -0,0 +1,86 @@
import { useState, useMemo, useCallback } from "react"
import { Modal } from "antd"
import type { ClipData, ClipType } from "../../types"
interface UseClipBasicOpsParams {
clips: ClipData[]
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
}
/**
* 片段基础操作 Hook
* 选中状态、增删改查、重排
*/
export function useClipBasicOps({ clips, setClips }: UseClipBasicOpsParams) {
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
const selectedClip = useMemo(
() => clips.find((c) => c.id === selectedClipId) || null,
[clips, selectedClipId],
)
const handleClipSelect = useCallback((clipId: string) => {
setSelectedClipId(clipId)
}, [])
const handleClipReorder = useCallback(
(fromIdx: number, toIdx: number) => {
setClips((prev) => {
const updated = [...prev]
const [moved] = updated.splice(fromIdx, 1)
updated.splice(toIdx, 0, moved)
return updated.map((c, i) => ({ ...c, order: i }))
})
},
[setClips],
)
const handleClipRemove = useCallback(
(clipId: string) => {
Modal.confirm({
title: "删除片段",
content: "确定要删除这个片段吗?此操作可通过撤销恢复。",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: () => {
setClips((prev) => prev.filter((c) => c.id !== clipId))
if (selectedClipId === clipId) setSelectedClipId(null)
},
})
},
[setClips, selectedClipId],
)
const handleClipUpdate = useCallback(
(clipId: string, data: Partial<ClipData>) => {
setClips((prev) => prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)))
},
[setClips],
)
const handleAddClip = useCallback(
(type: ClipType, duration: number) => {
const newClip: ClipData = {
id: `clip-${Date.now()}`,
type,
duration,
startOffset: 0,
order: clips.length,
}
setClips((prev) => [...prev, newClip])
},
[clips.length, setClips],
)
return {
selectedClipId,
setSelectedClipId,
selectedClip,
handleClipSelect,
handleClipReorder,
handleClipRemove,
handleClipUpdate,
handleAddClip,
}
}
@@ -0,0 +1,74 @@
import { useCallback } from "react"
import { message } from "antd"
import type { ClipData, TransitionConfig, SpeedConfig, TtsConfig } from "../../types"
import type { AssetItem } from "@/api/assets"
interface UseClipEffectsParams {
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
handleClipUpdate: (clipId: string, data: Partial<ClipData>) => void
}
/**
* 片段效果操作 Hook
* 转场、调速、TTS、配音选择
*/
export function useClipEffects({ setClips, handleClipUpdate }: UseClipEffectsParams) {
const handleTransitionChange = useCallback(
(targetClipId: string | null, config: TransitionConfig) => {
if (targetClipId) {
handleClipUpdate(targetClipId, { transition: config })
}
},
[handleClipUpdate],
)
const handleSpeedChange = useCallback(
(targetClipId: string | null, config: SpeedConfig) => {
if (targetClipId) {
handleClipUpdate(targetClipId, { speed: config })
}
},
[handleClipUpdate],
)
const handleApplySpeedAll = useCallback(
(config: SpeedConfig) => {
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })))
message.success("已应用到所有片段")
},
[setClips],
)
const handleTtsChange = useCallback(
(targetClipId: string | null, ttsConfig: TtsConfig) => {
if (!targetClipId) return
handleClipUpdate(targetClipId, { tts_config: ttsConfig })
},
[handleClipUpdate],
)
const handleClipVoiceSelect = useCallback(
(clipId: string, asset: AssetItem | null) => {
setClips((prev) =>
prev.map((c) =>
c.id === clipId
? {
...c,
voice_asset_id: asset?.id ?? undefined,
voice_file_url: asset?.file_url ?? undefined,
}
: c,
),
)
},
[setClips],
)
return {
handleTransitionChange,
handleSpeedChange,
handleApplySpeedAll,
handleTtsChange,
handleClipVoiceSelect,
}
}
@@ -0,0 +1,89 @@
import { useCallback } from "react"
import type { ClipData, TrimConfig } from "../../types"
interface UseClipTrimParams {
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
}
/**
* 片段裁剪分割 Hook
* 裁剪、分割、重置裁剪
*/
export function useClipTrim({ setClips }: UseClipTrimParams) {
const handleClipTrim = useCallback(
(clipId: string, trimConfig: TrimConfig, newDuration: number) => {
setClips((prev) =>
prev.map((c) =>
c.id === clipId ? { ...c, trim_config: trimConfig, duration: newDuration } : c,
),
)
},
[setClips],
)
const handleClipSplit = useCallback(
(clipId: string, splitRatio: number) => {
setClips((prev) => {
const idx = prev.findIndex((c) => c.id === clipId)
if (idx === -1) return prev
const clip = prev[idx]
const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10
if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev
const firstHalf: ClipData = {
...clip,
duration: splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
end_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
}
const secondHalf: ClipData = {
...clip,
id: `clip-${Date.now()}`,
duration: clip.duration - splitPoint,
startOffset: clip.startOffset + splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
start_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
order: (clip.order ?? idx) + 1,
}
const updated = [...prev]
updated[idx] = firstHalf
updated.splice(idx + 1, 0, secondHalf)
return updated.map((c, i) => ({ ...c, order: i }))
})
},
[setClips],
)
const handleClipResetTrim = useCallback(
(clipId: string) => {
setClips((prev) =>
prev.map((c) => {
if (c.id !== clipId || !c.trim_config) return c
const originalDuration = c.trim_config.original_duration ?? c.duration
return {
...c,
duration: originalDuration,
trim_config: undefined,
}
}),
)
},
[setClips],
)
return {
handleClipTrim,
handleClipSplit,
handleClipResetTrim,
}
}