Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia 6d6abb338a Merge branch 'develop' into refactor/templates-api
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 33s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m4s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m44s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
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 1m44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m13s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m52s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 49s
AI Code Review / AI Code Review (pull_request) Successful in 4m40s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m36s
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 / 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 18s
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 17s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 12:34:57 +08:00
xiaoxia 26e99b28cc refactor(api): 拆分 templates.ts 为目录结构(types/constants/templates/index)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 22s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 38s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 40s
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 3m1s
AI Code Review / AI Code Review (pull_request) Successful in 1m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m50s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m29s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 45s
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 / Frontend Unit Tests (pull_request) Failing after 29s
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 / 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
将 168 行的 templates.ts 拆分为目录化结构:
- types.ts: 类型定义(TemplateItem/TemplateSegment 等)
- constants.ts: 常量配置(分类/时长筛选选项)
- templates.ts: 全部 6 个 API 函数
- index.ts: 统一入口 re-export,保持 @/api/templates 路径向后兼容

主入口从 168 行减少到约 35 行,按职责清晰分层。
2026-07-26 17:09:47 +08:00
10 changed files with 437 additions and 455 deletions
-168
View File
@@ -1,168 +0,0 @@
/**
* 模板相关 API
* 对接后端模板管理接口:
* - GET /api/v1/templates — 模板列表(分页/筛选)
* - GET /api/v1/templates/{id} — 模板详情
* - POST /api/v1/templates/{id}/copy — 复制模板
* - POST /api/v1/templates/{id}/generate — 从模板生成
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
*/
import apiClient from "./client"
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editing-planner"
import type { EditPlanConfig } from "./template-editor"
/* ──────────── 类型定义 ──────────── */
/** 模板条目(后端 TemplateResponse */
export interface TemplateItem {
id: string
user_id?: string
name: string
description?: string
mode?: string
category: string
tags?: string[]
/** 预估时长(后端字段名 estimated_duration */
estimated_duration?: number
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
target_duration?: number
clip_count?: number
/** 使用次数 */
usage_count?: number
thumbnail_url?: string
preview_url?: string
is_active?: boolean
is_favorite?: boolean
/** 素材规则(片段配置) */
segments?: TemplateSegment[]
/** 字幕样式 */
subtitle_config?: SubtitleConfig
/** BGM 配置 */
bgm_config?: BgmConfig
/** 标题配置 */
title_config?: TitleConfig
/** 视频比例 */
aspect_ratio?: string
created_at?: string
updated_at?: string
}
/** 模板片段(素材规则) */
export interface TemplateSegment {
id?: string
segment_order: number
duration_min: number
duration_max: number
material_type: string | null
description?: string
}
/** 模板列表查询参数 */
export interface TemplateListParams {
page?: number
page_size?: number
category?: string
tags?: string
keyword?: string
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
duration_range?: "short" | "medium" | "long"
}
/** 模板列表分页响应 */
export interface TemplateListResponse {
items: TemplateItem[]
total: number
page: number
page_size: number
}
/** 从模板生成请求 */
export interface GenerateFromTemplateRequest {
asset_ids?: string[]
name?: string
config?: EditPlanConfig
}
/** 从模板生成响应 */
export interface GenerateFromTemplateResponse {
plan_id: string
template_id: string
status: string
name: string
}
/** 复制模板响应 */
export interface CopyTemplateResponse {
id: string
name: string
source_template_id: string
}
/* ──────────── API 函数 ──────────── */
/** 获取模板列表(支持分页和筛选) */
export const getTemplates = async (params?: TemplateListParams): Promise<TemplateListResponse> => {
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
params,
})
return data
}
/** 获取模板列表(兼容旧接口,返回数组) */
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
const response = await apiClient.get("/templates")
return response.data.items || response.data || []
}
/** 获取单个模板详情 */
export const getTemplate = async (templateId: string): Promise<TemplateItem> => {
const response = await apiClient.get(`/templates/${templateId}`)
return response.data
}
/** 收藏 / 取消收藏模板 */
export const toggleFavoriteTemplate = async (
templateId: string,
): Promise<{ is_favorite: boolean }> => {
const response = await apiClient.post(`/templates/${templateId}/toggle-favorite`)
return response.data
}
/** 复制模板(创建副本到我的模板) */
export const copyTemplate = async (templateId: string): Promise<CopyTemplateResponse> => {
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
return response.data
}
/** 从模板生成 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`,
data,
)
return response.data
}
/* ──────────── 常量 ──────────── */
/** 模板分类选项 */
export const TEMPLATE_CATEGORY_OPTIONS = [
{ value: "", label: "全部分类" },
{ value: "口播", label: "口播" },
{ value: "种草", label: "种草" },
{ value: "产品", label: "产品" },
{ value: "品牌", label: "品牌" },
{ value: "混剪", label: "混剪" },
{ value: "Vlog", label: "Vlog" },
]
/** 时长筛选选项 */
export const TEMPLATE_DURATION_OPTIONS = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
]
+22
View File
@@ -0,0 +1,22 @@
/**
* 模板相关常量
*/
/** 模板分类选项 */
export const TEMPLATE_CATEGORY_OPTIONS = [
{ value: "", label: "全部分类" },
{ value: "口播", label: "口播" },
{ value: "种草", label: "种草" },
{ value: "产品", label: "产品" },
{ value: "品牌", label: "品牌" },
{ value: "混剪", label: "混剪" },
{ value: "Vlog", label: "Vlog" },
]
/** 时长筛选选项 */
export const TEMPLATE_DURATION_OPTIONS = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
]
+28
View File
@@ -0,0 +1,28 @@
/**
* 模板相关 API — 目录化入口
* 保持与原 templates.ts 相同导出,向后兼容
*/
// 类型
export type {
TemplateItem,
TemplateSegment,
TemplateListParams,
TemplateListResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
CopyTemplateResponse,
} from "./types"
// 常量
export { TEMPLATE_CATEGORY_OPTIONS, TEMPLATE_DURATION_OPTIONS } from "./constants"
// API 函数
export {
getTemplates,
getTemplatesList,
getTemplate,
toggleFavoriteTemplate,
copyTemplate,
generateFromTemplate,
} from "./templates"
+59
View File
@@ -0,0 +1,59 @@
/**
* 模板相关 API 函数
* 对接后端模板管理接口
*/
import apiClient from "../client"
import type {
CopyTemplateResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
TemplateItem,
TemplateListParams,
TemplateListResponse,
} from "./types"
/** 获取模板列表(支持分页和筛选) */
export const getTemplates = async (params?: TemplateListParams): Promise<TemplateListResponse> => {
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
params,
})
return data
}
/** 获取模板列表(兼容旧接口,返回数组) */
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
const response = await apiClient.get("/templates")
return response.data.items || response.data || []
}
/** 获取单个模板详情 */
export const getTemplate = async (templateId: string): Promise<TemplateItem> => {
const response = await apiClient.get(`/templates/${templateId}`)
return response.data
}
/** 收藏 / 取消收藏模板 */
export const toggleFavoriteTemplate = async (
templateId: string,
): Promise<{ is_favorite: boolean }> => {
const response = await apiClient.post(`/templates/${templateId}/toggle-favorite`)
return response.data
}
/** 复制模板(创建副本到我的模板) */
export const copyTemplate = async (templateId: string): Promise<CopyTemplateResponse> => {
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
return response.data
}
/** 从模板生成 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`,
data,
)
return response.data
}
+90
View File
@@ -0,0 +1,90 @@
/**
* 模板相关类型定义
*/
import type { TitleConfig, SubtitleConfig, BgmConfig } from "../editing-planner"
import type { EditPlanConfig } from "../template-editor"
/** 模板条目(后端 TemplateResponse */
export interface TemplateItem {
id: string
user_id?: string
name: string
description?: string
mode?: string
category: string
tags?: string[]
/** 预估时长(后端字段名 estimated_duration */
estimated_duration?: number
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
target_duration?: number
clip_count?: number
/** 使用次数 */
usage_count?: number
thumbnail_url?: string
preview_url?: string
is_active?: boolean
is_favorite?: boolean
/** 素材规则(片段配置) */
segments?: TemplateSegment[]
/** 字幕样式 */
subtitle_config?: SubtitleConfig
/** BGM 配置 */
bgm_config?: BgmConfig
/** 标题配置 */
title_config?: TitleConfig
/** 视频比例 */
aspect_ratio?: string
created_at?: string
updated_at?: string
}
/** 模板片段(素材规则) */
export interface TemplateSegment {
id?: string
segment_order: number
duration_min: number
duration_max: number
material_type: string | null
description?: string
}
/** 模板列表查询参数 */
export interface TemplateListParams {
page?: number
page_size?: number
category?: string
tags?: string
keyword?: string
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
duration_range?: "short" | "medium" | "long"
}
/** 模板列表分页响应 */
export interface TemplateListResponse {
items: TemplateItem[]
total: number
page: number
page_size: number
}
/** 从模板生成请求 */
export interface GenerateFromTemplateRequest {
asset_ids?: string[]
name?: string
config?: EditPlanConfig
}
/** 从模板生成响应 */
export interface GenerateFromTemplateResponse {
plan_id: string
template_id: string
status: string
name: string
}
/** 复制模板响应 */
export interface CopyTemplateResponse {
id: string
name: string
source_template_id: string
}
@@ -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,
}
}