Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2fbe78c52 | |||
| cfc58bab16 |
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* TTS 语音合成 API
|
||||
* 对接后端 /api/v1/tts/* 端点
|
||||
*
|
||||
* 任务 3.14 新增
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
|
||||
/* ── 类型定义 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 元数据(合成时附带的扩展信息) */
|
||||
export interface TTSMetadata {
|
||||
/** 语音时长(秒) */
|
||||
duration?: number
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number
|
||||
/** 语言 */
|
||||
language?: string
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** TTS 合成请求参数 */
|
||||
export interface TTSSynthesizeRequest {
|
||||
text: string
|
||||
voice_id?: string
|
||||
output_name?: string
|
||||
language?: string
|
||||
speed?: number
|
||||
voice_model?: string
|
||||
voice_clone_profile_id?: string
|
||||
format?: string
|
||||
metadata?: TTSMetadata
|
||||
}
|
||||
|
||||
/** TTS 合成创建响应 */
|
||||
export interface TTSSynthesizeResponse {
|
||||
job_id: string
|
||||
status: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/** TTS 任务详情 */
|
||||
export interface TTSJob {
|
||||
id: string
|
||||
user_id: string
|
||||
project_id: string | null
|
||||
text: string
|
||||
voice_id: string | null
|
||||
voice_model: string | null
|
||||
voice_clone_profile_id: string | null
|
||||
language: string
|
||||
speed: number
|
||||
output_name: string | null
|
||||
output_audio_url: string | null
|
||||
output_format: string
|
||||
duration_seconds: number | null
|
||||
file_size_bytes: number | null
|
||||
sample_rate: number | null
|
||||
status: string
|
||||
error_message: string | null
|
||||
retry_count: number
|
||||
max_retries: number
|
||||
metadata_: TTSMetadata | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** TTS 任务状态(轻量轮询用) */
|
||||
export interface TTSJobStatus {
|
||||
id: string
|
||||
status: string
|
||||
output_audio_url: string | null
|
||||
error_message: string | null
|
||||
duration_seconds: number | null
|
||||
retry_count: number
|
||||
}
|
||||
|
||||
/** TTS 任务列表响应 */
|
||||
export interface TTSJobListResponse {
|
||||
items: TTSJob[]
|
||||
total: number
|
||||
skip: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
/** TTS 任务列表查询参数 */
|
||||
export interface TTSJobListParams {
|
||||
status?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/* ── API 函数 ──────────────────────────────────── */
|
||||
|
||||
/** 创建 TTS 合成任务 */
|
||||
export const synthesizeSpeech = async (
|
||||
data: TTSSynthesizeRequest,
|
||||
): Promise<TTSSynthesizeResponse> => {
|
||||
const response = await apiClient.post<TTSSynthesizeResponse>("/tts/synthesize", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务详情 */
|
||||
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
|
||||
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务状态(轻量轮询) */
|
||||
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
|
||||
const response = await apiClient.get<TTSJobStatus>(`/tts/jobs/${jobId}/status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务列表 */
|
||||
export const getTTSJobs = async (params?: TTSJobListParams): Promise<TTSJobListResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<TTSJobListResponse>(`/tts/jobs${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 存为素材请求参数 */
|
||||
export interface SaveTtsToLibraryRequest {
|
||||
name?: string
|
||||
tag_ids?: string[]
|
||||
}
|
||||
|
||||
/** 将 TTS 合成结果保存到配音库 */
|
||||
export const saveTtsToLibrary = async (
|
||||
jobId: string,
|
||||
data?: SaveTtsToLibraryRequest,
|
||||
): Promise<void> => {
|
||||
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {})
|
||||
}
|
||||
|
||||
/** 删除 TTS 任务 */
|
||||
export const deleteTTSJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tts/jobs/${jobId}`)
|
||||
}
|
||||
|
||||
/* ── 音色列表 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 音色 */
|
||||
export interface TTSVoice {
|
||||
id: string
|
||||
name: string
|
||||
/** 音色分类标签:male/female/young/service/news/emotion */
|
||||
category?: string
|
||||
/** 语言 */
|
||||
language?: string
|
||||
/** 试听 URL */
|
||||
preview_url?: string
|
||||
/** 描述 */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** 获取 TTS 音色列表 */
|
||||
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
|
||||
const response = await apiClient.get<TTSVoice[]>("/tts/voices")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── TTS 试听 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 试听请求参数 */
|
||||
export interface TTSPreviewRequest {
|
||||
text: string
|
||||
voice_id: string
|
||||
speed?: number
|
||||
pitch?: number
|
||||
}
|
||||
|
||||
/** TTS 试听响应 */
|
||||
export interface TTSPreviewResponse {
|
||||
audio_url: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
/** TTS 试听 */
|
||||
export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewResponse> => {
|
||||
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* TTS 语音合成 API — 目录化入口
|
||||
* 保持与原 tts.ts 相同导出,向后兼容
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
TTSMetadata,
|
||||
TTSSynthesizeRequest,
|
||||
TTSSynthesizeResponse,
|
||||
TTSJob,
|
||||
TTSJobStatus,
|
||||
TTSJobListResponse,
|
||||
TTSJobListParams,
|
||||
SaveTtsToLibraryRequest,
|
||||
TTSVoice,
|
||||
TTSPreviewRequest,
|
||||
TTSPreviewResponse,
|
||||
} from "./types"
|
||||
|
||||
// API 函数
|
||||
export {
|
||||
synthesizeSpeech,
|
||||
getTTSJob,
|
||||
getTTSJobStatus,
|
||||
getTTSJobs,
|
||||
saveTtsToLibrary,
|
||||
deleteTTSJob,
|
||||
getTtsVoices,
|
||||
previewTts,
|
||||
} from "./jobs"
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* TTS 语音合成 API 函数
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
TTSSynthesizeRequest,
|
||||
TTSSynthesizeResponse,
|
||||
TTSJob,
|
||||
TTSJobStatus,
|
||||
TTSJobListResponse,
|
||||
TTSJobListParams,
|
||||
SaveTtsToLibraryRequest,
|
||||
TTSVoice,
|
||||
TTSPreviewRequest,
|
||||
TTSPreviewResponse,
|
||||
} from "./types"
|
||||
|
||||
/** 创建 TTS 合成任务 */
|
||||
export const synthesizeSpeech = async (
|
||||
data: TTSSynthesizeRequest,
|
||||
): Promise<TTSSynthesizeResponse> => {
|
||||
const response = await apiClient.post<TTSSynthesizeResponse>("/tts/synthesize", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务详情 */
|
||||
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
|
||||
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务状态(轻量轮询) */
|
||||
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
|
||||
const response = await apiClient.get<TTSJobStatus>(`/tts/jobs/${jobId}/status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务列表 */
|
||||
export const getTTSJobs = async (params?: TTSJobListParams): Promise<TTSJobListResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<TTSJobListResponse>(`/tts/jobs${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 将 TTS 合成结果保存到配音库 */
|
||||
export const saveTtsToLibrary = async (
|
||||
jobId: string,
|
||||
data?: SaveTtsToLibraryRequest,
|
||||
): Promise<void> => {
|
||||
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {})
|
||||
}
|
||||
|
||||
/** 删除 TTS 任务 */
|
||||
export const deleteTTSJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tts/jobs/${jobId}`)
|
||||
}
|
||||
|
||||
/** 获取 TTS 音色列表 */
|
||||
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
|
||||
const response = await apiClient.get<TTSVoice[]>("/tts/voices")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** TTS 试听 */
|
||||
export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewResponse> => {
|
||||
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* TTS 语音合成类型定义
|
||||
*/
|
||||
|
||||
/** TTS 元数据 */
|
||||
export interface TTSMetadata {
|
||||
duration?: number
|
||||
sample_rate?: number
|
||||
language?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** TTS 合成请求参数 */
|
||||
export interface TTSSynthesizeRequest {
|
||||
text: string
|
||||
voice_id?: string
|
||||
output_name?: string
|
||||
language?: string
|
||||
speed?: number
|
||||
voice_model?: string
|
||||
voice_clone_profile_id?: string
|
||||
format?: string
|
||||
metadata?: TTSMetadata
|
||||
}
|
||||
|
||||
/** TTS 合成创建响应 */
|
||||
export interface TTSSynthesizeResponse {
|
||||
job_id: string
|
||||
status: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/** TTS 任务详情 */
|
||||
export interface TTSJob {
|
||||
id: string
|
||||
user_id: string
|
||||
project_id: string | null
|
||||
text: string
|
||||
voice_id: string | null
|
||||
voice_model: string | null
|
||||
voice_clone_profile_id: string | null
|
||||
language: string
|
||||
speed: number
|
||||
output_name: string | null
|
||||
output_audio_url: string | null
|
||||
output_format: string
|
||||
duration_seconds: number | null
|
||||
file_size_bytes: number | null
|
||||
sample_rate: number | null
|
||||
status: string
|
||||
error_message: string | null
|
||||
retry_count: number
|
||||
max_retries: number
|
||||
metadata_: TTSMetadata | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** TTS 任务状态(轻量轮询用) */
|
||||
export interface TTSJobStatus {
|
||||
id: string
|
||||
status: string
|
||||
output_audio_url: string | null
|
||||
error_message: string | null
|
||||
duration_seconds: number | null
|
||||
retry_count: number
|
||||
}
|
||||
|
||||
/** TTS 任务列表响应 */
|
||||
export interface TTSJobListResponse {
|
||||
items: TTSJob[]
|
||||
total: number
|
||||
skip: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
/** TTS 任务列表查询参数 */
|
||||
export interface TTSJobListParams {
|
||||
status?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/** 存为素材请求参数 */
|
||||
export interface SaveTtsToLibraryRequest {
|
||||
name?: string
|
||||
tag_ids?: string[]
|
||||
}
|
||||
|
||||
/** TTS 音色 */
|
||||
export interface TTSVoice {
|
||||
id: string
|
||||
name: string
|
||||
category?: string
|
||||
language?: string
|
||||
preview_url?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** TTS 试听请求参数 */
|
||||
export interface TTSPreviewRequest {
|
||||
text: string
|
||||
voice_id: string
|
||||
speed?: number
|
||||
pitch?: number
|
||||
}
|
||||
|
||||
/** TTS 试听响应 */
|
||||
export interface TTSPreviewResponse {
|
||||
audio_url: string
|
||||
duration?: number
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user