Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25257743a7 | |||
| 9566ff5a96 |
@@ -1,243 +0,0 @@
|
||||
/**
|
||||
* 模板编辑器 API
|
||||
* 对接后端 /api/v1/templates 路由
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types"
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 模板模式(后端枚举值) */
|
||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
||||
|
||||
/** 模式显示名称映射 */
|
||||
export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||
pip: "混剪",
|
||||
voice_over: "人物口播",
|
||||
one_take: "一镜到底",
|
||||
voice_pip: "口播+混剪",
|
||||
}
|
||||
|
||||
/** 模式颜色映射 */
|
||||
export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
pip: "blue",
|
||||
voice_over: "green",
|
||||
one_take: "orange",
|
||||
voice_pip: "purple",
|
||||
}
|
||||
|
||||
/** 标题配置 */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean
|
||||
content: string
|
||||
font_preset: string
|
||||
font_color: string
|
||||
font_size: number
|
||||
position: string
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
color: string
|
||||
size: number
|
||||
animation: string
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean
|
||||
music_id: string
|
||||
}
|
||||
|
||||
/** 模板片段 */
|
||||
export interface TemplateSegment {
|
||||
id?: string
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string | null
|
||||
}
|
||||
|
||||
/** 剪辑模板 */
|
||||
export interface EditingTemplate {
|
||||
id: string
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: TemplateSegment[]
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 混剪配置 */
|
||||
pip_config?: PipConfig
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig
|
||||
is_active?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 模板分类 */
|
||||
export interface TemplateCategory {
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 创建/更新模板请求体 */
|
||||
export interface SaveTemplatePayload {
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: Omit<TemplateSegment, "id">[]
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 混剪配置 */
|
||||
pip_config?: PipConfig
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
export interface GenerateFromTemplatePayload {
|
||||
voiceover_duration: number
|
||||
}
|
||||
|
||||
/** 验证警告详情 */
|
||||
export interface ValidationWarningDetails {
|
||||
/** 相关字段名 */
|
||||
field?: string
|
||||
/** 期望值 */
|
||||
expected?: string | number
|
||||
/** 实际值 */
|
||||
actual?: string | number
|
||||
/** 建议值 */
|
||||
suggested?: string | number
|
||||
}
|
||||
|
||||
/** 验证/生成响应 */
|
||||
export interface ValidateWarning {
|
||||
code: string
|
||||
message: string
|
||||
details?: ValidationWarningDetails
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
template: EditingTemplate
|
||||
warnings: ValidateWarning[]
|
||||
}
|
||||
|
||||
/** 列表响应(带分页) */
|
||||
export interface ListTemplatesResponse {
|
||||
items: EditingTemplate[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 分类列表响应 */
|
||||
export interface ListCategoriesResponse {
|
||||
items: TemplateCategory[]
|
||||
}
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 获取模板列表 */
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string
|
||||
tag?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 50,
|
||||
},
|
||||
})
|
||||
let list = response.data.items
|
||||
if (params?.category) list = list.filter((t) => t.category === params.category)
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
|
||||
return list
|
||||
}
|
||||
|
||||
/** 获取模板详情 */
|
||||
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板 */
|
||||
export const createEditingTemplate = async (
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板 */
|
||||
export const updateEditingTemplate = async (
|
||||
id: string,
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板 */
|
||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/templates/${id}`)
|
||||
}
|
||||
|
||||
/** 获取模板分类列表 */
|
||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
|
||||
return response.data.items
|
||||
}
|
||||
|
||||
/** 使用模板生成视频(调用 validate 端点) */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data: GenerateFromTemplatePayload,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/validate`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 模板编辑器常量
|
||||
*/
|
||||
import type { TemplateMode } from "./types"
|
||||
|
||||
/** 模式显示名称映射 */
|
||||
export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||
pip: "混剪",
|
||||
voice_over: "人物口播",
|
||||
one_take: "一镜到底",
|
||||
voice_pip: "口播+混剪",
|
||||
}
|
||||
|
||||
/** 模式颜色映射 */
|
||||
export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
pip: "blue",
|
||||
voice_over: "green",
|
||||
one_take: "orange",
|
||||
voice_pip: "purple",
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 模板编辑器 API — 目录化入口
|
||||
* 保持与原 editing-planner.ts 相同导出,向后兼容
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
TemplateMode,
|
||||
TitleConfig,
|
||||
SubtitleConfig,
|
||||
BgmConfig,
|
||||
TemplateSegment,
|
||||
EditingTemplate,
|
||||
TemplateCategory,
|
||||
SaveTemplatePayload,
|
||||
GenerateFromTemplatePayload,
|
||||
ValidationWarningDetails,
|
||||
ValidateWarning,
|
||||
GenerateFromTemplateResponse,
|
||||
ListTemplatesResponse,
|
||||
ListCategoriesResponse,
|
||||
} from "./types"
|
||||
|
||||
// 常量
|
||||
export { MODE_LABELS, MODE_COLORS } from "./constants"
|
||||
|
||||
// API 函数
|
||||
export {
|
||||
getEditingTemplates,
|
||||
getEditingTemplate,
|
||||
createEditingTemplate,
|
||||
updateEditingTemplate,
|
||||
deleteEditingTemplate,
|
||||
getTemplateCategories,
|
||||
generateFromTemplate,
|
||||
} from "./templates"
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 模板编辑器 API 函数
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
EditingTemplate,
|
||||
TemplateCategory,
|
||||
SaveTemplatePayload,
|
||||
GenerateFromTemplatePayload,
|
||||
GenerateFromTemplateResponse,
|
||||
ListTemplatesResponse,
|
||||
ListCategoriesResponse,
|
||||
} from "./types"
|
||||
|
||||
/** 获取模板列表 */
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string
|
||||
tag?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 50,
|
||||
},
|
||||
})
|
||||
let list = response.data.items
|
||||
if (params?.category) list = list.filter((t) => t.category === params.category)
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
|
||||
return list
|
||||
}
|
||||
|
||||
/** 获取模板详情 */
|
||||
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板 */
|
||||
export const createEditingTemplate = async (
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板 */
|
||||
export const updateEditingTemplate = async (
|
||||
id: string,
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板 */
|
||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/templates/${id}`)
|
||||
}
|
||||
|
||||
/** 获取模板分类列表 */
|
||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
|
||||
return response.data.items
|
||||
}
|
||||
|
||||
/** 使用模板生成视频(调用 validate 端点) */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data: GenerateFromTemplatePayload,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/validate`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 模板编辑器类型定义
|
||||
*/
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types"
|
||||
|
||||
/** 模板模式(后端枚举值) */
|
||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
||||
|
||||
/** 标题配置 */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean
|
||||
content: string
|
||||
font_preset: string
|
||||
font_color: string
|
||||
font_size: number
|
||||
position: string
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
color: string
|
||||
size: number
|
||||
animation: string
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean
|
||||
music_id: string
|
||||
}
|
||||
|
||||
/** 模板片段 */
|
||||
export interface TemplateSegment {
|
||||
id?: string
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string | null
|
||||
}
|
||||
|
||||
/** 剪辑模板 */
|
||||
export interface EditingTemplate {
|
||||
id: string
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: TemplateSegment[]
|
||||
watermark_config?: WatermarkConfig
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
pip_config?: PipConfig
|
||||
filter_config?: FilterConfig
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
sticker_config?: StickerConfig
|
||||
cover_config?: CoverConfig
|
||||
is_active?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 模板分类 */
|
||||
export interface TemplateCategory {
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 创建/更新模板请求体 */
|
||||
export interface SaveTemplatePayload {
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: Omit<TemplateSegment, "id">[]
|
||||
watermark_config?: WatermarkConfig
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
pip_config?: PipConfig
|
||||
filter_config?: FilterConfig
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
sticker_config?: StickerConfig
|
||||
cover_config?: CoverConfig
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
export interface GenerateFromTemplatePayload {
|
||||
voiceover_duration: number
|
||||
}
|
||||
|
||||
/** 验证警告详情 */
|
||||
export interface ValidationWarningDetails {
|
||||
field?: string
|
||||
expected?: string | number
|
||||
actual?: string | number
|
||||
suggested?: string | number
|
||||
}
|
||||
|
||||
/** 验证警告 */
|
||||
export interface ValidateWarning {
|
||||
code: string
|
||||
message: string
|
||||
details?: ValidationWarningDetails
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
template: EditingTemplate
|
||||
warnings: ValidateWarning[]
|
||||
}
|
||||
|
||||
/** 列表响应(带分页) */
|
||||
export interface ListTemplatesResponse {
|
||||
items: EditingTemplate[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 分类列表响应 */
|
||||
export interface ListCategoriesResponse {
|
||||
items: TemplateCategory[]
|
||||
}
|
||||
+264
-3
@@ -1,5 +1,266 @@
|
||||
/**
|
||||
* ClipDetailSection 入口(向后兼容)
|
||||
* 实际实现位于 ./clip-detail-section/ 目录
|
||||
* 片段详情区块
|
||||
*/
|
||||
export { default } from "./clip-detail-section"
|
||||
import React from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import type { ClipData, ClipType } from "@/pages/editing-planner/types"
|
||||
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties"
|
||||
import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
|
||||
interface ClipDetailSectionProps {
|
||||
clip: ClipData
|
||||
currentMode: TemplateMode
|
||||
voiceMaterials?: AssetItem[]
|
||||
voiceMaterialsLoading?: boolean
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
|
||||
onRefreshVoiceMaterials?: () => void
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
|
||||
onOpenTransitionDrawer?: (clipId: string) => void
|
||||
onOpenSpeedDrawer?: (clipId: string) => void
|
||||
onOpenTtsDrawer?: (clipId: string) => void
|
||||
previewingId: string | null
|
||||
onPreviewVoice: (asset: AssetItem) => void
|
||||
onStopPreview: () => void
|
||||
}
|
||||
|
||||
const ClipDetailSection: React.FC<ClipDetailSectionProps> = ({
|
||||
clip,
|
||||
currentMode,
|
||||
voiceMaterials = [],
|
||||
voiceMaterialsLoading = false,
|
||||
onClipUpdate,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
onOpenTransitionDrawer,
|
||||
onOpenSpeedDrawer,
|
||||
onOpenTtsDrawer,
|
||||
previewingId,
|
||||
onPreviewVoice,
|
||||
onStopPreview,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const isTypeDisabled = (t: ClipType) => {
|
||||
if (currentMode === "pip") return t !== "pip"
|
||||
if (currentMode === "voice_over") return t !== "voice"
|
||||
return false
|
||||
}
|
||||
|
||||
const transitionLabel = (() => {
|
||||
const t = clip.transition
|
||||
if (!t || t.type === "none") return "无转场"
|
||||
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
|
||||
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
|
||||
})()
|
||||
|
||||
const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x"
|
||||
|
||||
const ttsLabel = (() => {
|
||||
const tts = clip.tts_config
|
||||
if (!tts || tts.mode === "none") return "无配音"
|
||||
if (tts.mode === "upload") return "上传配音"
|
||||
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎞️</span>
|
||||
片段详情
|
||||
</div>
|
||||
|
||||
<div className="ep-clip-detail">
|
||||
{/* 类型选择器 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">类型</div>
|
||||
<div className="ep-clip-type-selector">
|
||||
{(["voice", "pip"] as ClipType[]).map((t) => {
|
||||
const disabled = isTypeDisabled(t)
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
className={`ep-clip-type-btn${clip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onClipUpdate(clip.id, { type: t })}
|
||||
>
|
||||
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时长 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">时长</div>
|
||||
<div className="ep-clip-detail-row">
|
||||
<input
|
||||
className="ep-duration-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={clip.duration}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(clip.id, {
|
||||
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-detail-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转场效果入口 */}
|
||||
{onOpenTransitionDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--transition"
|
||||
onClick={() => onOpenTransitionDrawer(clip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎬</span>
|
||||
<span className="ep-advanced-btn-label">转场效果</span>
|
||||
<span className="ep-advanced-btn-value">{transitionLabel}</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放速度入口 */}
|
||||
{onOpenSpeedDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--speed"
|
||||
onClick={() => onOpenSpeedDrawer(clip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">⚡</span>
|
||||
<span className="ep-advanced-btn-label">播放速度</span>
|
||||
<span className="ep-advanced-btn-value">{speedLabel}</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TTS 配音入口 */}
|
||||
{onOpenTtsDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--tts"
|
||||
onClick={() => onOpenTtsDrawer(clip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎙️</span>
|
||||
<span className="ep-advanced-btn-label">TTS 配音</span>
|
||||
<span className="ep-advanced-btn-value">{ttsLabel}</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 素材起始时间 — 仅 voice 类型显示 */}
|
||||
{clip.type === "voice" && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">素材起始时间</div>
|
||||
<div className="ep-clip-detail-row">
|
||||
<input
|
||||
className="ep-duration-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={9999}
|
||||
step={0.1}
|
||||
value={clip.startOffset}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(clip.id, {
|
||||
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-detail-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配音素材选择 — 仅 voice 类型显示 */}
|
||||
{clip.type === "voice" && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">
|
||||
配音素材
|
||||
{onRefreshVoiceMaterials && (
|
||||
<button
|
||||
type="button"
|
||||
className="ep-voice-refresh-btn"
|
||||
title="刷新配音列表"
|
||||
onClick={() => onRefreshVoiceMaterials()}
|
||||
disabled={voiceMaterialsLoading}
|
||||
>
|
||||
{voiceMaterialsLoading ? "⏳" : "🔄"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
|
||||
<div className="ep-voice-loading">加载中...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ep-voice-select-row">
|
||||
<select
|
||||
className="ep-clip-detail-select"
|
||||
value={clip.voice_asset_id ?? ""}
|
||||
onChange={(e) => {
|
||||
const assetId = e.target.value
|
||||
if (!onClipVoiceSelect) return
|
||||
if (!assetId) {
|
||||
onClipVoiceSelect(clip.id, null)
|
||||
} else {
|
||||
const asset = voiceMaterials.find((m) => m.id === assetId)
|
||||
if (asset) onClipVoiceSelect(clip.id, asset)
|
||||
}
|
||||
onStopPreview()
|
||||
}}
|
||||
>
|
||||
<option value="">未选择</option>
|
||||
{voiceMaterials.map((m) => {
|
||||
const gender = getGenderLabel(m)
|
||||
const label = gender ? `${m.name}(${gender})` : m.name
|
||||
return (
|
||||
<option key={m.id} value={m.id}>
|
||||
{label}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
{clip.voice_asset_id && (
|
||||
<button
|
||||
type="button"
|
||||
className="ep-voice-preview-btn"
|
||||
title={previewingId === clip.voice_asset_id ? "暂停" : "试听"}
|
||||
onClick={() => {
|
||||
const asset = voiceMaterials.find((m) => m.id === clip.voice_asset_id)
|
||||
if (asset) onPreviewVoice(asset)
|
||||
}}
|
||||
>
|
||||
{previewingId === clip.voice_asset_id ? "⏸" : "▶️"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{voiceMaterials.length === 0 && (
|
||||
<div className="ep-voice-empty">暂无配音素材,请先上传</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="ep-voice-upload-btn"
|
||||
onClick={() => navigate("/app/voice-materials")}
|
||||
>
|
||||
+ 上传新配音
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClipDetailSection
|
||||
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
import React from "react"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import type { ClipData } from "@/pages/editing-planner/types"
|
||||
|
||||
interface AdvancedEntriesProps {
|
||||
clip: ClipData
|
||||
onOpenTransitionDrawer?: (clipId: string) => void
|
||||
onOpenSpeedDrawer?: (clipId: string) => void
|
||||
onOpenTtsDrawer?: (clipId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 高级功能入口按钮(转场/调速/TTS)
|
||||
*/
|
||||
export const AdvancedEntries: React.FC<AdvancedEntriesProps> = ({
|
||||
clip,
|
||||
onOpenTransitionDrawer,
|
||||
onOpenSpeedDrawer,
|
||||
onOpenTtsDrawer,
|
||||
}) => {
|
||||
const transitionLabel = (() => {
|
||||
const t = clip.transition
|
||||
if (!t || t.type === "none") return "无转场"
|
||||
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
|
||||
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
|
||||
})()
|
||||
|
||||
const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x"
|
||||
|
||||
const ttsLabel = (() => {
|
||||
const tts = clip.tts_config
|
||||
if (!tts || tts.mode === "none") return "无配音"
|
||||
if (tts.mode === "upload") return "上传配音"
|
||||
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
|
||||
})()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 转场效果入口 */}
|
||||
{onOpenTransitionDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--transition"
|
||||
onClick={() => onOpenTransitionDrawer(clip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎬</span>
|
||||
<span className="ep-advanced-btn-label">转场效果</span>
|
||||
<span className="ep-advanced-btn-value">{transitionLabel}</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放速度入口 */}
|
||||
{onOpenSpeedDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--speed"
|
||||
onClick={() => onOpenSpeedDrawer(clip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">⚡</span>
|
||||
<span className="ep-advanced-btn-label">播放速度</span>
|
||||
<span className="ep-advanced-btn-value">{speedLabel}</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TTS 配音入口 */}
|
||||
{onOpenTtsDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--tts"
|
||||
onClick={() => onOpenTtsDrawer(clip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎙️</span>
|
||||
<span className="ep-advanced-btn-label">TTS 配音</span>
|
||||
<span className="ep-advanced-btn-value">{ttsLabel}</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType } from "@/pages/editing-planner/types"
|
||||
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
|
||||
interface ClipTypeAndDurationProps {
|
||||
clip: ClipData
|
||||
currentMode: TemplateMode
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 片段类型选择 + 时长设置
|
||||
*/
|
||||
export const ClipTypeAndDuration: React.FC<ClipTypeAndDurationProps> = ({
|
||||
clip,
|
||||
currentMode,
|
||||
onClipUpdate,
|
||||
}) => {
|
||||
const isTypeDisabled = (t: ClipType) => {
|
||||
if (currentMode === "pip") return t !== "pip"
|
||||
if (currentMode === "voice_over") return t !== "voice"
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 类型选择器 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">类型</div>
|
||||
<div className="ep-clip-type-selector">
|
||||
{(["voice", "pip"] as ClipType[]).map((t) => {
|
||||
const disabled = isTypeDisabled(t)
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
className={`ep-clip-type-btn${clip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onClipUpdate(clip.id, { type: t })}
|
||||
>
|
||||
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时长 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">时长</div>
|
||||
<div className="ep-clip-detail-row">
|
||||
<input
|
||||
className="ep-duration-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={clip.duration}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(clip.id, {
|
||||
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-detail-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
import React from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { ClipData } from "@/pages/editing-planner/types"
|
||||
import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface VoiceMaterialSectionProps {
|
||||
clip: ClipData
|
||||
voiceMaterials: AssetItem[]
|
||||
voiceMaterialsLoading: boolean
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
|
||||
onRefreshVoiceMaterials?: () => void
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
|
||||
previewingId: string | null
|
||||
onPreviewVoice: (asset: AssetItem) => void
|
||||
onStopPreview: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 配音素材选择区(仅 voice 类型显示)
|
||||
*/
|
||||
export const VoiceMaterialSection: React.FC<VoiceMaterialSectionProps> = ({
|
||||
clip,
|
||||
voiceMaterials,
|
||||
voiceMaterialsLoading,
|
||||
onClipUpdate,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
previewingId,
|
||||
onPreviewVoice,
|
||||
onStopPreview,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 素材起始时间 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">素材起始时间</div>
|
||||
<div className="ep-clip-detail-row">
|
||||
<input
|
||||
className="ep-duration-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={9999}
|
||||
step={0.1}
|
||||
value={clip.startOffset}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(clip.id, {
|
||||
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-clip-detail-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配音素材选择 */}
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">
|
||||
配音素材
|
||||
{onRefreshVoiceMaterials && (
|
||||
<button
|
||||
type="button"
|
||||
className="ep-voice-refresh-btn"
|
||||
title="刷新配音列表"
|
||||
onClick={() => onRefreshVoiceMaterials()}
|
||||
disabled={voiceMaterialsLoading}
|
||||
>
|
||||
{voiceMaterialsLoading ? "⏳" : "🔄"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
|
||||
<div className="ep-voice-loading">加载中...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ep-voice-select-row">
|
||||
<select
|
||||
className="ep-clip-detail-select"
|
||||
value={clip.voice_asset_id ?? ""}
|
||||
onChange={(e) => {
|
||||
const assetId = e.target.value
|
||||
if (!onClipVoiceSelect) return
|
||||
if (!assetId) {
|
||||
onClipVoiceSelect(clip.id, null)
|
||||
} else {
|
||||
const asset = voiceMaterials.find((m) => m.id === assetId)
|
||||
if (asset) onClipVoiceSelect(clip.id, asset)
|
||||
}
|
||||
onStopPreview()
|
||||
}}
|
||||
>
|
||||
<option value="">未选择</option>
|
||||
{voiceMaterials.map((m) => {
|
||||
const gender = getGenderLabel(m)
|
||||
const label = gender ? `${m.name}(${gender})` : m.name
|
||||
return (
|
||||
<option key={m.id} value={m.id}>
|
||||
{label}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
{clip.voice_asset_id && (
|
||||
<button
|
||||
type="button"
|
||||
className="ep-voice-preview-btn"
|
||||
title={previewingId === clip.voice_asset_id ? "暂停" : "试听"}
|
||||
onClick={() => {
|
||||
const asset = voiceMaterials.find((m) => m.id === clip.voice_asset_id)
|
||||
if (asset) onPreviewVoice(asset)
|
||||
}}
|
||||
>
|
||||
{previewingId === clip.voice_asset_id ? "⏸" : "▶️"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{voiceMaterials.length === 0 && (
|
||||
<div className="ep-voice-empty">暂无配音素材,请先上传</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button className="ep-voice-upload-btn" onClick={() => navigate("/app/voice-materials")}>
|
||||
+ 上传新配音
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* 片段详情区块
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipData } from "@/pages/editing-planner/types"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import { ClipTypeAndDuration } from "./ClipTypeAndDuration"
|
||||
import { AdvancedEntries } from "./AdvancedEntries"
|
||||
import { VoiceMaterialSection } from "./VoiceMaterialSection"
|
||||
|
||||
interface ClipDetailSectionProps {
|
||||
clip: ClipData
|
||||
currentMode: TemplateMode
|
||||
voiceMaterials?: AssetItem[]
|
||||
voiceMaterialsLoading?: boolean
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
|
||||
onRefreshVoiceMaterials?: () => void
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
|
||||
onOpenTransitionDrawer?: (clipId: string) => void
|
||||
onOpenSpeedDrawer?: (clipId: string) => void
|
||||
onOpenTtsDrawer?: (clipId: string) => void
|
||||
previewingId: string | null
|
||||
onPreviewVoice: (asset: AssetItem) => void
|
||||
onStopPreview: () => void
|
||||
}
|
||||
|
||||
const ClipDetailSection: React.FC<ClipDetailSectionProps> = ({
|
||||
clip,
|
||||
currentMode,
|
||||
voiceMaterials = [],
|
||||
voiceMaterialsLoading = false,
|
||||
onClipUpdate,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
onOpenTransitionDrawer,
|
||||
onOpenSpeedDrawer,
|
||||
onOpenTtsDrawer,
|
||||
previewingId,
|
||||
onPreviewVoice,
|
||||
onStopPreview,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎞️</span>
|
||||
片段详情
|
||||
</div>
|
||||
|
||||
<div className="ep-clip-detail">
|
||||
<ClipTypeAndDuration clip={clip} currentMode={currentMode} onClipUpdate={onClipUpdate} />
|
||||
|
||||
<AdvancedEntries
|
||||
clip={clip}
|
||||
onOpenTransitionDrawer={onOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={onOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={onOpenTtsDrawer}
|
||||
/>
|
||||
|
||||
{clip.type === "voice" && (
|
||||
<VoiceMaterialSection
|
||||
clip={clip}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsLoading}
|
||||
onClipUpdate={onClipUpdate}
|
||||
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
|
||||
onClipVoiceSelect={onClipVoiceSelect}
|
||||
previewingId={previewingId}
|
||||
onPreviewVoice={onPreviewVoice}
|
||||
onStopPreview={onStopPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClipDetailSection
|
||||
Reference in New Issue
Block a user