Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4eac64573f | |||
| 44e6227ca6 | |||
| 219ecaf18c | |||
| eaf035626f | |||
| e946dbf625 | |||
| 56ab6bcef4 | |||
| 6fffcd105e | |||
| 7a8e99cd8e | |||
| 6a6f8e7a22 | |||
| 246367f6f0 | |||
| 107a1bd724 | |||
| cfc836484f | |||
| ef30c7db26 | |||
| 5d9544a76f | |||
| f6c366b979 | |||
| aeefd7aba5 | |||
| a5f1a31ca3 | |||
| 07805e72c5 | |||
| 4105a4df41 | |||
| a1bda3e484 | |||
| 7bf25023a0 | |||
| e8f7788e56 | |||
| 27c1f05f74 | |||
| de1311fe31 | |||
| 7ce78a3e8a | |||
| edb141b1da | |||
| 2e67be39f7 | |||
| f55d0d6f0e |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 所有弹窗和 Drawer 组件的集合
|
||||
* 纯渲染层,业务逻辑和 state 留在父组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TemplateCategory } from "@/api/editing-planner"
|
||||
import type {
|
||||
ClipData,
|
||||
TransitionConfig,
|
||||
SpeedConfig,
|
||||
TtsConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
} from "../types"
|
||||
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import SaveModal from "./SaveModal"
|
||||
import BgmSelector from "./BgmSelector"
|
||||
import SubtitleStylePanel from "./SubtitleStylePanel"
|
||||
import TransitionSelector from "./TransitionSelector"
|
||||
import SpeedPanel from "./SpeedPanel"
|
||||
import TtsPanel from "./TtsPanel"
|
||||
import WatermarkPanel from "./WatermarkPanel"
|
||||
import IntroOutroPanel from "./IntroOutroPanel"
|
||||
import PipConfigPanel from "./PipConfigPanel"
|
||||
import FilterPanel from "./FilterPanel"
|
||||
import GreenScreenPanel from "./GreenScreenPanel"
|
||||
import StickerPanel from "./StickerPanel"
|
||||
|
||||
interface EditingDrawersProps {
|
||||
/* 保存弹窗 */
|
||||
saveModalOpen: boolean
|
||||
saveLoading: boolean
|
||||
isUpdate: boolean
|
||||
draftName: string
|
||||
draftCategory: string
|
||||
draftTags: string
|
||||
categories: TemplateCategory[]
|
||||
estimatedDuration: number
|
||||
onNameChange: (name: string) => void
|
||||
onCategoryChange: (cat: string) => void
|
||||
onTagsChange: (tags: string) => void
|
||||
onSave: () => Promise<void>
|
||||
onCancelSave: () => void
|
||||
/* BGM */
|
||||
bgmDrawerOpen: boolean
|
||||
bgmSettings: BgmMixConfig
|
||||
onCloseBgmDrawer: () => void
|
||||
onChangeBgmSettings: (config: BgmMixConfig) => void
|
||||
/* 字幕 */
|
||||
subtitleDrawerOpen: boolean
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
onCloseSubtitleDrawer: () => void
|
||||
onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void
|
||||
/* 转场 */
|
||||
transitionDrawerOpen: boolean
|
||||
transitionTargetClipId: string | null
|
||||
onCloseTransitionDrawer: () => void
|
||||
onTransitionChange: (config: TransitionConfig) => void
|
||||
/* 调速 */
|
||||
speedDrawerOpen: boolean
|
||||
speedTargetClipId: string | null
|
||||
onCloseSpeedDrawer: () => void
|
||||
onSpeedChange: (config: SpeedConfig) => void
|
||||
onApplySpeedAll: (config: SpeedConfig) => void
|
||||
/* TTS 配音 */
|
||||
ttsDrawerOpen: boolean
|
||||
ttsTargetClipId: string | null
|
||||
onCloseTtsDrawer: () => void
|
||||
onTtsChange: (config: TtsConfig) => void
|
||||
/* 水印 */
|
||||
watermarkDrawerOpen: boolean
|
||||
watermarkSettings: WatermarkConfig
|
||||
onCloseWatermarkDrawer: () => void
|
||||
onWatermarkChange: (config: WatermarkConfig) => void
|
||||
/* 片头片尾 */
|
||||
introOutroDrawerOpen: boolean
|
||||
introOutroSettings: IntroOutroConfig
|
||||
onCloseIntroOutroDrawer: () => void
|
||||
onIntroOutroChange: (config: IntroOutroConfig) => void
|
||||
/* 混剪 */
|
||||
pipDrawerOpen: boolean
|
||||
pipSettings: PipConfig
|
||||
onClosePipDrawer: () => void
|
||||
onPipChange: (config: PipConfig) => void
|
||||
/* 滤镜调色 */
|
||||
filterDrawerOpen: boolean
|
||||
filterSettings: FilterConfig
|
||||
onCloseFilterDrawer: () => void
|
||||
onFilterChange: (config: FilterConfig) => void
|
||||
/* 绿幕抠像 */
|
||||
chromaKeyDrawerOpen: boolean
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
onCloseChromaKeyDrawer: () => void
|
||||
onChromaKeyChange: (config: ChromaKeyConfig) => void
|
||||
/* 贴纸 */
|
||||
stickerDrawerOpen: boolean
|
||||
stickerSettings: StickerConfig
|
||||
onCloseStickerDrawer: () => void
|
||||
onStickerChange: (config: StickerConfig) => void
|
||||
/* 共享数据 */
|
||||
clips: ClipData[]
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
const EditingDrawers: React.FC<EditingDrawersProps> = ({
|
||||
saveModalOpen,
|
||||
saveLoading,
|
||||
isUpdate,
|
||||
draftName,
|
||||
draftCategory,
|
||||
draftTags,
|
||||
categories,
|
||||
estimatedDuration,
|
||||
onNameChange,
|
||||
onCategoryChange,
|
||||
onTagsChange,
|
||||
onSave,
|
||||
onCancelSave,
|
||||
bgmDrawerOpen,
|
||||
bgmSettings,
|
||||
onCloseBgmDrawer,
|
||||
onChangeBgmSettings,
|
||||
subtitleDrawerOpen,
|
||||
subtitleSettings,
|
||||
onCloseSubtitleDrawer,
|
||||
onChangeSubtitleSettings,
|
||||
transitionDrawerOpen,
|
||||
transitionTargetClipId,
|
||||
onCloseTransitionDrawer,
|
||||
onTransitionChange,
|
||||
speedDrawerOpen,
|
||||
speedTargetClipId,
|
||||
onCloseSpeedDrawer,
|
||||
onSpeedChange,
|
||||
onApplySpeedAll,
|
||||
ttsDrawerOpen,
|
||||
ttsTargetClipId,
|
||||
onCloseTtsDrawer,
|
||||
onTtsChange,
|
||||
watermarkDrawerOpen,
|
||||
watermarkSettings,
|
||||
onCloseWatermarkDrawer,
|
||||
onWatermarkChange,
|
||||
introOutroDrawerOpen,
|
||||
introOutroSettings,
|
||||
onCloseIntroOutroDrawer,
|
||||
onIntroOutroChange,
|
||||
pipDrawerOpen,
|
||||
pipSettings,
|
||||
onClosePipDrawer,
|
||||
onPipChange,
|
||||
filterDrawerOpen,
|
||||
filterSettings,
|
||||
onCloseFilterDrawer,
|
||||
onFilterChange,
|
||||
chromaKeyDrawerOpen,
|
||||
chromaKeySettings,
|
||||
onCloseChromaKeyDrawer,
|
||||
onChromaKeyChange,
|
||||
stickerDrawerOpen,
|
||||
stickerSettings,
|
||||
onCloseStickerDrawer,
|
||||
onStickerChange,
|
||||
clips,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const transitionConfig = transitionTargetClipId
|
||||
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
|
||||
: DEFAULT_TRANSITION
|
||||
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
|
||||
|
||||
const speedConfig = speedTargetClipId
|
||||
? (clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED)
|
||||
: DEFAULT_SPEED
|
||||
|
||||
const ttsConfig = ttsTargetClipId
|
||||
? (clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG)
|
||||
: DEFAULT_TTS_CONFIG
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ═══ 保存弹窗 ═══ */}
|
||||
<SaveModal
|
||||
open={saveModalOpen}
|
||||
loading={saveLoading}
|
||||
isUpdate={isUpdate}
|
||||
draftName={draftName}
|
||||
draftCategory={draftCategory}
|
||||
draftTags={draftTags}
|
||||
categories={categories}
|
||||
estimatedDuration={estimatedDuration}
|
||||
onNameChange={onNameChange}
|
||||
onCategoryChange={onCategoryChange}
|
||||
onTagsChange={onTagsChange}
|
||||
onSave={onSave}
|
||||
onCancel={onCancelSave}
|
||||
/>
|
||||
|
||||
{/* ═══ BGM 选择器 Drawer ═══ */}
|
||||
<BgmSelector
|
||||
open={bgmDrawerOpen}
|
||||
onClose={onCloseBgmDrawer}
|
||||
config={bgmSettings}
|
||||
onChange={onChangeBgmSettings}
|
||||
/>
|
||||
|
||||
{/* ═══ 字幕样式配置 Drawer ═══ */}
|
||||
<SubtitleStylePanel
|
||||
open={subtitleDrawerOpen}
|
||||
onClose={onCloseSubtitleDrawer}
|
||||
config={subtitleSettings}
|
||||
onChange={onChangeSubtitleSettings}
|
||||
/>
|
||||
|
||||
{/* ═══ 转场特效选择器 Drawer ═══ */}
|
||||
<TransitionSelector
|
||||
open={transitionDrawerOpen}
|
||||
onClose={onCloseTransitionDrawer}
|
||||
config={transitionConfig}
|
||||
onChange={onTransitionChange}
|
||||
title={transitionTitle}
|
||||
/>
|
||||
|
||||
{/* ═══ 片段调速面板 Drawer ═══ */}
|
||||
{speedTargetClipId && (
|
||||
<SpeedPanel
|
||||
open={speedDrawerOpen}
|
||||
onClose={onCloseSpeedDrawer}
|
||||
config={speedConfig}
|
||||
onChange={onSpeedChange}
|
||||
onApplyAll={onApplySpeedAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ TTS 配音面板 Drawer ═══ */}
|
||||
{ttsTargetClipId && (
|
||||
<TtsPanel
|
||||
open={ttsDrawerOpen}
|
||||
onClose={onCloseTtsDrawer}
|
||||
config={ttsConfig}
|
||||
onChange={onTtsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ 水印配置面板 ═══ */}
|
||||
<WatermarkPanel
|
||||
open={watermarkDrawerOpen}
|
||||
onClose={onCloseWatermarkDrawer}
|
||||
config={watermarkSettings}
|
||||
onChange={onWatermarkChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 片头片尾配置面板 ═══ */}
|
||||
<IntroOutroPanel
|
||||
open={introOutroDrawerOpen}
|
||||
onClose={onCloseIntroOutroDrawer}
|
||||
config={introOutroSettings}
|
||||
onChange={onIntroOutroChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 混剪配置面板 ═══ */}
|
||||
<PipConfigPanel
|
||||
open={pipDrawerOpen}
|
||||
onClose={onClosePipDrawer}
|
||||
config={pipSettings}
|
||||
onChange={onPipChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* ═══ 滤镜调色面板 ═══ */}
|
||||
<FilterPanel
|
||||
open={filterDrawerOpen}
|
||||
onClose={onCloseFilterDrawer}
|
||||
config={filterSettings}
|
||||
onChange={onFilterChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 绿幕抠像面板 ═══ */}
|
||||
<GreenScreenPanel
|
||||
open={chromaKeyDrawerOpen}
|
||||
onClose={onCloseChromaKeyDrawer}
|
||||
config={chromaKeySettings}
|
||||
onChange={onChromaKeyChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 贴纸面板 ═══ */}
|
||||
<StickerPanel
|
||||
open={stickerDrawerOpen}
|
||||
onClose={onCloseStickerDrawer}
|
||||
config={stickerSettings}
|
||||
onChange={onStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditingDrawers
|
||||
@@ -0,0 +1,246 @@
|
||||
import React from "react"
|
||||
import BgmSelector from "./BgmSelector"
|
||||
import SubtitleStylePanel from "./SubtitleStylePanel"
|
||||
import TransitionSelector from "./TransitionSelector"
|
||||
import SpeedPanel from "./SpeedPanel"
|
||||
import TtsPanel from "./TtsPanel"
|
||||
import WatermarkPanel from "./WatermarkPanel"
|
||||
import IntroOutroPanel from "./IntroOutroPanel"
|
||||
import PipConfigPanel from "./PipConfigPanel"
|
||||
import FilterPanel from "./FilterPanel"
|
||||
import GreenScreenPanel from "./GreenScreenPanel"
|
||||
import StickerPanel from "./StickerPanel"
|
||||
import type {
|
||||
ClipData,
|
||||
TransitionConfig,
|
||||
SpeedConfig,
|
||||
TtsConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
|
||||
|
||||
interface EditorDrawersProps {
|
||||
// BGM
|
||||
bgmDrawerOpen: boolean
|
||||
bgmSettings: BgmMixConfig
|
||||
onBgmSettingsChange: (config: BgmMixConfig) => void
|
||||
onCloseBgmDrawer: () => void
|
||||
// 字幕
|
||||
subtitleDrawerOpen: boolean
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
onSubtitleSettingsChange: (config: SubtitleStyleConfig) => void
|
||||
onCloseSubtitleDrawer: () => void
|
||||
// 转场
|
||||
transitionDrawerOpen: boolean
|
||||
transitionTargetClipId: string | null
|
||||
clips: ClipData[]
|
||||
onTransitionChange: (config: TransitionConfig) => void
|
||||
onCloseTransitionDrawer: () => void
|
||||
// 调速
|
||||
speedDrawerOpen: boolean
|
||||
speedTargetClipId: string | null
|
||||
onSpeedChange: (config: SpeedConfig) => void
|
||||
onApplySpeedAll: (config: SpeedConfig) => void
|
||||
onCloseSpeedDrawer: () => void
|
||||
// TTS
|
||||
ttsDrawerOpen: boolean
|
||||
ttsTargetClipId: string | null
|
||||
onTtsChange: (config: TtsConfig) => void
|
||||
onCloseTtsDrawer: () => void
|
||||
// 水印
|
||||
watermarkDrawerOpen: boolean
|
||||
watermarkSettings: WatermarkConfig
|
||||
onWatermarkChange: (config: WatermarkConfig) => void
|
||||
onCloseWatermarkDrawer: () => void
|
||||
// 片头片尾
|
||||
introOutroDrawerOpen: boolean
|
||||
introOutroSettings: IntroOutroConfig
|
||||
onIntroOutroChange: (config: IntroOutroConfig) => void
|
||||
onCloseIntroOutroDrawer: () => void
|
||||
// 混剪
|
||||
pipDrawerOpen: boolean
|
||||
pipSettings: PipConfig
|
||||
totalDuration: number
|
||||
onPipChange: (config: PipConfig) => void
|
||||
onClosePipDrawer: () => void
|
||||
// 滤镜
|
||||
filterDrawerOpen: boolean
|
||||
filterSettings: FilterConfig
|
||||
onFilterChange: (config: FilterConfig) => void
|
||||
onCloseFilterDrawer: () => void
|
||||
// 绿幕
|
||||
chromaKeyDrawerOpen: boolean
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
onChromaKeyChange: (config: ChromaKeyConfig) => void
|
||||
onCloseChromaKeyDrawer: () => void
|
||||
// 贴纸
|
||||
stickerDrawerOpen: boolean
|
||||
stickerSettings: StickerConfig
|
||||
onStickerChange: (config: StickerConfig) => void
|
||||
onCloseStickerDrawer: () => void
|
||||
}
|
||||
|
||||
const EditorDrawers: React.FC<EditorDrawersProps> = ({
|
||||
bgmDrawerOpen,
|
||||
bgmSettings,
|
||||
onBgmSettingsChange,
|
||||
onCloseBgmDrawer,
|
||||
subtitleDrawerOpen,
|
||||
subtitleSettings,
|
||||
onSubtitleSettingsChange,
|
||||
onCloseSubtitleDrawer,
|
||||
transitionDrawerOpen,
|
||||
transitionTargetClipId,
|
||||
clips,
|
||||
onTransitionChange,
|
||||
onCloseTransitionDrawer,
|
||||
speedDrawerOpen,
|
||||
speedTargetClipId,
|
||||
onSpeedChange,
|
||||
onApplySpeedAll,
|
||||
onCloseSpeedDrawer,
|
||||
ttsDrawerOpen,
|
||||
ttsTargetClipId,
|
||||
onTtsChange,
|
||||
onCloseTtsDrawer,
|
||||
watermarkDrawerOpen,
|
||||
watermarkSettings,
|
||||
onWatermarkChange,
|
||||
onCloseWatermarkDrawer,
|
||||
introOutroDrawerOpen,
|
||||
introOutroSettings,
|
||||
onIntroOutroChange,
|
||||
onCloseIntroOutroDrawer,
|
||||
pipDrawerOpen,
|
||||
pipSettings,
|
||||
totalDuration,
|
||||
onPipChange,
|
||||
onClosePipDrawer,
|
||||
filterDrawerOpen,
|
||||
filterSettings,
|
||||
onFilterChange,
|
||||
onCloseFilterDrawer,
|
||||
chromaKeyDrawerOpen,
|
||||
chromaKeySettings,
|
||||
onChromaKeyChange,
|
||||
onCloseChromaKeyDrawer,
|
||||
stickerDrawerOpen,
|
||||
stickerSettings,
|
||||
onStickerChange,
|
||||
onCloseStickerDrawer,
|
||||
}) => {
|
||||
const transitionConfig = transitionTargetClipId
|
||||
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
|
||||
: DEFAULT_TRANSITION
|
||||
|
||||
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* BGM 选择器 Drawer */}
|
||||
<BgmSelector
|
||||
open={bgmDrawerOpen}
|
||||
onClose={onCloseBgmDrawer}
|
||||
config={bgmSettings}
|
||||
onChange={onBgmSettingsChange}
|
||||
/>
|
||||
|
||||
{/* 字幕样式配置 Drawer */}
|
||||
<SubtitleStylePanel
|
||||
open={subtitleDrawerOpen}
|
||||
onClose={onCloseSubtitleDrawer}
|
||||
config={subtitleSettings}
|
||||
onChange={onSubtitleSettingsChange}
|
||||
/>
|
||||
|
||||
{/* 转场特效选择器 Drawer */}
|
||||
<TransitionSelector
|
||||
open={transitionDrawerOpen}
|
||||
onClose={onCloseTransitionDrawer}
|
||||
config={transitionConfig}
|
||||
onChange={onTransitionChange}
|
||||
title={transitionTitle}
|
||||
/>
|
||||
|
||||
{/* 片段调速面板 Drawer */}
|
||||
{speedTargetClipId && (
|
||||
<SpeedPanel
|
||||
open={speedDrawerOpen}
|
||||
onClose={onCloseSpeedDrawer}
|
||||
config={clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED}
|
||||
onChange={onSpeedChange}
|
||||
onApplyAll={onApplySpeedAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TTS 配音面板 Drawer */}
|
||||
{ttsTargetClipId && (
|
||||
<TtsPanel
|
||||
open={ttsDrawerOpen}
|
||||
onClose={onCloseTtsDrawer}
|
||||
config={clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG}
|
||||
onChange={onTtsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 水印配置面板 */}
|
||||
<WatermarkPanel
|
||||
open={watermarkDrawerOpen}
|
||||
onClose={onCloseWatermarkDrawer}
|
||||
config={watermarkSettings}
|
||||
onChange={onWatermarkChange}
|
||||
/>
|
||||
|
||||
{/* 片头片尾配置面板 */}
|
||||
<IntroOutroPanel
|
||||
open={introOutroDrawerOpen}
|
||||
onClose={onCloseIntroOutroDrawer}
|
||||
config={introOutroSettings}
|
||||
onChange={onIntroOutroChange}
|
||||
/>
|
||||
|
||||
{/* 混剪配置面板 */}
|
||||
<PipConfigPanel
|
||||
open={pipDrawerOpen}
|
||||
onClose={onClosePipDrawer}
|
||||
config={pipSettings}
|
||||
onChange={onPipChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* 滤镜调色面板 */}
|
||||
<FilterPanel
|
||||
open={filterDrawerOpen}
|
||||
onClose={onCloseFilterDrawer}
|
||||
config={filterSettings}
|
||||
onChange={onFilterChange}
|
||||
/>
|
||||
|
||||
{/* 绿幕抠像面板 */}
|
||||
<GreenScreenPanel
|
||||
open={chromaKeyDrawerOpen}
|
||||
onClose={onCloseChromaKeyDrawer}
|
||||
config={chromaKeySettings}
|
||||
onChange={onChromaKeyChange}
|
||||
/>
|
||||
|
||||
{/* 贴纸面板 */}
|
||||
<StickerPanel
|
||||
open={stickerDrawerOpen}
|
||||
onClose={onCloseStickerDrawer}
|
||||
config={stickerSettings}
|
||||
onChange={onStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditorDrawers
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from "react"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
|
||||
interface ModeBarProps {
|
||||
modeList: { key: TemplateMode; label: string; icon: string }[]
|
||||
currentMode: TemplateMode
|
||||
onModeChange: (mode: TemplateMode) => void
|
||||
}
|
||||
|
||||
const ModeBar: React.FC<ModeBarProps> = ({ modeList, currentMode, onModeChange }) => {
|
||||
return (
|
||||
<div className="ep-mode-bar">
|
||||
<span className="ep-mode-bar-label">剪辑模式:</span>
|
||||
{modeList.map((m) => (
|
||||
<button
|
||||
key={m.key}
|
||||
className={`ep-mode-btn ${currentMode === m.key ? "active" : ""}`}
|
||||
onClick={() => onModeChange(m.key)}
|
||||
>
|
||||
<span className="ep-mode-icon">{m.icon}</span>
|
||||
<span className="ep-mode-label">{m.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModeBar
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from "react"
|
||||
import ClipPropertiesPanel from "./ClipPropertiesPanel"
|
||||
import EditorClipList from "./EditorClipList"
|
||||
import type { ClipData } from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface RightPanelProps {
|
||||
rightTab: "properties" | "clips"
|
||||
onTabChange: (tab: "properties" | "clips") => void
|
||||
// 属性 tab
|
||||
selectedClip: ClipData | null
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
bgmSettings: BgmMixConfig
|
||||
clipsCount: number
|
||||
totalDuration: number
|
||||
currentMode: TemplateMode
|
||||
onSubtitleSettingsChange: (partial: Partial<SubtitleStyleConfig>) => void
|
||||
onBgmSettingsChange: (partial: Partial<BgmMixConfig>) => void
|
||||
onClipUpdate: (clipId: string, updates: Partial<ClipData>) => void
|
||||
onOpenBgmDrawer: () => void
|
||||
onOpenSubtitleDrawer: () => void
|
||||
voiceMaterials: AssetItem[]
|
||||
voiceMaterialsLoading: boolean
|
||||
onRefreshVoiceMaterials: () => void
|
||||
onClipVoiceSelect: (clipId: string, asset: AssetItem | null) => void
|
||||
onOpenTransitionDrawer: (clipId?: string) => void
|
||||
onOpenSpeedDrawer: (clipId: string) => void
|
||||
onOpenTtsDrawer: (clipId: string) => void
|
||||
onOpenWatermarkDrawer: () => void
|
||||
onOpenIntroOutroDrawer: () => void
|
||||
onOpenPipDrawer: () => void
|
||||
onOpenFilterDrawer: () => void
|
||||
onOpenGreenScreenDrawer: () => void
|
||||
onOpenStickerDrawer: () => void
|
||||
// 片段 tab
|
||||
clips: ClipData[]
|
||||
selectedClipId: string | null
|
||||
onClipSelect: (clipId: string) => void
|
||||
onClipMoveUp: (clipId: string) => void
|
||||
onClipMoveDown: (clipId: string) => void
|
||||
onClipRemove: (clipId: string) => void
|
||||
onClipAdd: () => void
|
||||
}
|
||||
|
||||
const RightPanel: React.FC<RightPanelProps> = ({
|
||||
rightTab,
|
||||
onTabChange,
|
||||
selectedClip,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
clipsCount,
|
||||
totalDuration,
|
||||
currentMode,
|
||||
onSubtitleSettingsChange,
|
||||
onBgmSettingsChange,
|
||||
onClipUpdate,
|
||||
onOpenBgmDrawer,
|
||||
onOpenSubtitleDrawer,
|
||||
voiceMaterials,
|
||||
voiceMaterialsLoading,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
onOpenTransitionDrawer,
|
||||
onOpenSpeedDrawer,
|
||||
onOpenTtsDrawer,
|
||||
onOpenWatermarkDrawer,
|
||||
onOpenIntroOutroDrawer,
|
||||
onOpenPipDrawer,
|
||||
onOpenFilterDrawer,
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
clips,
|
||||
selectedClipId,
|
||||
onClipSelect,
|
||||
onClipMoveUp,
|
||||
onClipMoveDown,
|
||||
onClipRemove,
|
||||
onClipAdd,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-right-panel">
|
||||
{/* Tab 切换 */}
|
||||
<div className="ep-right-tabs">
|
||||
<button
|
||||
className={`ep-right-tab${rightTab === "properties" ? " active" : ""}`}
|
||||
onClick={() => onTabChange("properties")}
|
||||
>
|
||||
属性
|
||||
</button>
|
||||
<button
|
||||
className={`ep-right-tab${rightTab === "clips" ? " active" : ""}`}
|
||||
onClick={() => onTabChange("clips")}
|
||||
>
|
||||
片段
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 属性 Tab */}
|
||||
{rightTab === "properties" && (
|
||||
<div className="ep-right-tab-content">
|
||||
{(() => {
|
||||
// ClipPropertiesPanel 内部类型与主文件类型结构一致但字段细节不同
|
||||
// 使用 unknown 作为中间类型避免 any 警告
|
||||
const sub = subtitleSettings as unknown as React.ComponentProps<
|
||||
typeof ClipPropertiesPanel
|
||||
>["subtitleSettings"]
|
||||
const bgm = bgmSettings as unknown as React.ComponentProps<
|
||||
typeof ClipPropertiesPanel
|
||||
>["bgmSettings"]
|
||||
const onSubChange = onSubtitleSettingsChange as unknown as React.ComponentProps<
|
||||
typeof ClipPropertiesPanel
|
||||
>["onSubtitleSettingsChange"]
|
||||
const onBgmChange = onBgmSettingsChange as unknown as React.ComponentProps<
|
||||
typeof ClipPropertiesPanel
|
||||
>["onBgmSettingsChange"]
|
||||
return (
|
||||
<ClipPropertiesPanel
|
||||
selectedClip={selectedClip}
|
||||
subtitleSettings={sub}
|
||||
bgmSettings={bgm}
|
||||
clipsCount={clipsCount}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={currentMode}
|
||||
onSubtitleSettingsChange={onSubChange}
|
||||
onBgmSettingsChange={onBgmChange}
|
||||
onClipUpdate={onClipUpdate}
|
||||
onOpenBgmDrawer={onOpenBgmDrawer}
|
||||
onOpenSubtitleDrawer={onOpenSubtitleDrawer}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsLoading}
|
||||
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
|
||||
onClipVoiceSelect={onClipVoiceSelect}
|
||||
onOpenTransitionDrawer={onOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={onOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={onOpenTtsDrawer}
|
||||
onOpenWatermarkDrawer={onOpenWatermarkDrawer}
|
||||
onOpenIntroOutroDrawer={onOpenIntroOutroDrawer}
|
||||
onOpenPipDrawer={onOpenPipDrawer}
|
||||
onOpenFilterDrawer={onOpenFilterDrawer}
|
||||
onOpenGreenScreenDrawer={onOpenGreenScreenDrawer}
|
||||
onOpenStickerDrawer={onOpenStickerDrawer}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 片段 Tab */}
|
||||
{rightTab === "clips" && (
|
||||
<div className="ep-right-tab-content">
|
||||
<EditorClipList
|
||||
clips={clips}
|
||||
selectedClipId={selectedClipId}
|
||||
onSelect={onClipSelect}
|
||||
onMoveUp={onClipMoveUp}
|
||||
onMoveDown={onClipMoveDown}
|
||||
onRemove={onClipRemove}
|
||||
onAdd={onClipAdd}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RightPanel
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react"
|
||||
|
||||
interface StatusBarProps {
|
||||
clipsCount: number
|
||||
totalDuration: number
|
||||
currentModeLabel: string
|
||||
templateSegments: number
|
||||
}
|
||||
|
||||
const StatusBar: React.FC<StatusBarProps> = ({
|
||||
clipsCount,
|
||||
totalDuration,
|
||||
currentModeLabel,
|
||||
templateSegments,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-status-bar">
|
||||
<div className="ep-status-left">
|
||||
<span>📋 片段: {clipsCount}</span>
|
||||
<span className="ep-status-sep">|</span>
|
||||
<span>⏱️ 总时长: {totalDuration.toFixed(1)}s</span>
|
||||
</div>
|
||||
<div className="ep-status-right">
|
||||
<span>🎬 {currentModeLabel}</span>
|
||||
<span className="ep-status-sep">|</span>
|
||||
<span>📐 模板片段: {templateSegments}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusBar
|
||||
@@ -8,9 +8,20 @@
|
||||
* - 拖动时实时显示裁剪预览(入点/出点/时长)
|
||||
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react"
|
||||
import type { ClipData, ClipType, TrimConfig } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import {
|
||||
DEFAULT_PIXELS_PER_SECOND,
|
||||
} from "../constants/timeline"
|
||||
import { formatTime } from "../utils/timeline"
|
||||
import { useClipDrag } from "../hooks/useClipDrag"
|
||||
import { useTrimDrag } from "../hooks/useTrimDrag"
|
||||
import { useTimelineMenus } from "../hooks/useTimelineMenus"
|
||||
import { ClipCard } from "./timeline/ClipCard"
|
||||
import { TimeRuler } from "./timeline/TimeRuler"
|
||||
import { AddClipPicker } from "./timeline/AddClipPicker"
|
||||
import { TrimPreview } from "./timeline/TrimPreview"
|
||||
import { ContextMenu } from "./timeline/ContextMenu"
|
||||
|
||||
interface TimelinePanelProps {
|
||||
clips: ClipData[]
|
||||
@@ -38,37 +49,6 @@ interface TimelinePanelProps {
|
||||
totalDuration?: number
|
||||
}
|
||||
|
||||
/** 片段类型图标 */
|
||||
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
|
||||
voice: "🎙️",
|
||||
pip: "🖼️",
|
||||
}
|
||||
|
||||
/** 片段类型标签 */
|
||||
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "混剪",
|
||||
}
|
||||
|
||||
/** 裁剪拖拽方向 */
|
||||
type TrimDirection = "left" | "right"
|
||||
|
||||
/** 裁剪拖拽状态 */
|
||||
interface TrimDragState {
|
||||
clipId: string
|
||||
direction: TrimDirection
|
||||
startX: number
|
||||
originalTrim: TrimConfig
|
||||
originalDuration: number
|
||||
}
|
||||
|
||||
/** 右键菜单状态 */
|
||||
interface ContextMenuState {
|
||||
x: number
|
||||
y: number
|
||||
clipId: string
|
||||
}
|
||||
|
||||
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
@@ -86,147 +66,51 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onSeek,
|
||||
totalDuration: totalDurationProp,
|
||||
}) => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
const dragRef = useRef<number | null>(null)
|
||||
const [showAddPicker, setShowAddPicker] = useState(false)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
const addCardRef = useRef<HTMLDivElement>(null)
|
||||
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({
|
||||
top: 0,
|
||||
right: 0,
|
||||
})
|
||||
/* ── 缩放 & 时长 ── */
|
||||
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
|
||||
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
|
||||
|
||||
/* ── 裁剪拖拽状态 ── */
|
||||
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
|
||||
const [trimPreview, setTrimPreview] = useState<{
|
||||
clipId: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
duration: number
|
||||
x: number
|
||||
y: number
|
||||
} | null>(null)
|
||||
/* ── 裁剪拖拽 ── */
|
||||
const { trimDrag, trimPreview, handleTrimHandleMouseDown } = useTrimDrag(clips, pps, onClipTrim)
|
||||
|
||||
/* ── 右键菜单 ── */
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
/* ── 片段拖拽排序 ── */
|
||||
const {
|
||||
dragIdx,
|
||||
dragOverIdx,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleDrop,
|
||||
handleEmptyDragOver,
|
||||
} = useClipDrag(onClipReorder, !!trimDrag)
|
||||
|
||||
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
||||
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
|
||||
/* ── 菜单 & 面板 ── */
|
||||
const {
|
||||
contextMenu,
|
||||
contextMenuRef,
|
||||
handleContextMenu,
|
||||
handleContextSplit,
|
||||
handleContextResetTrim,
|
||||
handleContextDelete,
|
||||
showAddPicker,
|
||||
pickerRef,
|
||||
addCardRef,
|
||||
pickerPos,
|
||||
availableTypes,
|
||||
addType,
|
||||
addDuration,
|
||||
setAddType,
|
||||
setAddDuration,
|
||||
handleTogglePicker,
|
||||
handleConfirmAdd,
|
||||
hoveredClipId,
|
||||
setHoveredClipId,
|
||||
} = useTimelineMenus(clips, currentMode, onAddClip, onClipSplit, onClipResetTrim, onClipRemove)
|
||||
|
||||
/* ── 播放头拖拽状态 ── */
|
||||
const [playheadDragging, setPlayheadDragging] = useState(false)
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/* ── 根据模式决定可选类型 ── */
|
||||
const availableTypes: ClipType[] = useMemo(
|
||||
() =>
|
||||
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"], // voice_pip / one_take / 默认
|
||||
[currentMode],
|
||||
)
|
||||
|
||||
/* ── 默认添加类型:跟随模式 ── */
|
||||
const defaultAddType: ClipType = useMemo(() => {
|
||||
if (currentMode === "voice_over") return "voice"
|
||||
if (currentMode === "pip") return "pip"
|
||||
return "voice"
|
||||
}, [currentMode])
|
||||
|
||||
/* ── "+" 卡片:类型+时长选择状态 ── */
|
||||
const [addType, setAddType] = useState<ClipType>(defaultAddType)
|
||||
const [addDuration, setAddDuration] = useState<number>(5)
|
||||
|
||||
/* ── 模式切换时自动同步默认添加类型 ── */
|
||||
useEffect(() => {
|
||||
if (!availableTypes.includes(addType)) {
|
||||
setAddType(defaultAddType)
|
||||
}
|
||||
}, [currentMode, addType, availableTypes, defaultAddType])
|
||||
|
||||
/* ── 面板尺寸 ── */
|
||||
const PICKER_W = 240
|
||||
const GAP = 6
|
||||
|
||||
/* ── 计算 picker 初始位置 ── */
|
||||
const updatePickerPosition = useCallback(() => {
|
||||
if (!addCardRef.current) return
|
||||
const rect = addCardRef.current.getBoundingClientRect()
|
||||
const vw = window.innerWidth
|
||||
const roughHeight = 180
|
||||
let top = rect.top - GAP - roughHeight
|
||||
if (top < 8) top = 8
|
||||
let right = vw - rect.right
|
||||
if (rect.right - PICKER_W < 8) {
|
||||
right = vw - PICKER_W - 8
|
||||
}
|
||||
setPickerPos({ top, right })
|
||||
}, [])
|
||||
|
||||
const handleTogglePicker = () => {
|
||||
if (!showAddPicker) {
|
||||
const defaultType =
|
||||
currentMode === "pip" ? "pip" : currentMode === "voice_over" ? "voice" : "voice"
|
||||
setAddType(defaultType)
|
||||
updatePickerPosition()
|
||||
}
|
||||
setShowAddPicker((v) => !v)
|
||||
}
|
||||
|
||||
/* ── 渲染后精确边界校正 ── */
|
||||
useLayoutEffect(() => {
|
||||
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
|
||||
const pickerEl = pickerRef.current
|
||||
const addRect = addCardRef.current.getBoundingClientRect()
|
||||
const pickerH = pickerEl.offsetHeight
|
||||
const vh = window.innerHeight
|
||||
const vw = window.innerWidth
|
||||
let top = addRect.top - GAP - pickerH
|
||||
if (top < 8) {
|
||||
top = addRect.bottom + GAP
|
||||
if (top + pickerH > vh - 8) {
|
||||
top = vh - 8 - pickerH
|
||||
if (top < 8) top = 8
|
||||
}
|
||||
}
|
||||
let right = vw - addRect.right
|
||||
const pickerRect = pickerEl.getBoundingClientRect()
|
||||
if (pickerRect.left < 8) {
|
||||
right = vw - PICKER_W - 8
|
||||
}
|
||||
setPickerPos({ top, right })
|
||||
}, [showAddPicker])
|
||||
|
||||
/* ── 点击外部关闭添加面板 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
||||
setShowAddPicker(false)
|
||||
}
|
||||
}
|
||||
if (showAddPicker) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [showAddPicker])
|
||||
|
||||
/* ── 点击外部关闭右键菜单 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
setContextMenu(null)
|
||||
}
|
||||
}
|
||||
if (contextMenu) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [contextMenu])
|
||||
|
||||
/* ── 缩放 & 时长 ── */
|
||||
const pps = pixelsPerSecond ?? 40
|
||||
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
|
||||
|
||||
/* ── 播放头拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!playheadDragging) return
|
||||
@@ -270,182 +154,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
setPlayheadDragging(true)
|
||||
}, [])
|
||||
|
||||
/* ── 确认添加片段 ── */
|
||||
const handleConfirmAdd = () => {
|
||||
onAddClip(addType, addDuration)
|
||||
setShowAddPicker(false)
|
||||
}
|
||||
|
||||
/* ── 片段拖拽排序 ── */
|
||||
const handleDragStart = (e: React.DragEvent, idx: number) => {
|
||||
// 如果正在裁剪拖拽,不允许排序拖拽
|
||||
if (trimDrag) return
|
||||
dragRef.current = idx
|
||||
setDragIdx(idx)
|
||||
e.dataTransfer.setData("application/x-clip-drag", String(idx))
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = "move"
|
||||
setDragOverIdx(idx)
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
dragRef.current = null
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent, toIdx: number) => {
|
||||
e.preventDefault()
|
||||
setDragOverIdx(null)
|
||||
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
|
||||
if (fromStr !== "") {
|
||||
const fromIdx = Number(fromStr)
|
||||
if (fromIdx !== toIdx) {
|
||||
onClipReorder(fromIdx, toIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 空轨道区域不接受素材拖入 ── */
|
||||
const handleEmptyDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
/* ── 裁剪手柄拖拽 ── */
|
||||
const handleTrimHandleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, clipId: string, direction: TrimDirection) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const clip = clips.find((c) => c.id === clipId)
|
||||
if (!clip) return
|
||||
|
||||
const trim: TrimConfig = clip.trim_config ?? {
|
||||
start_time: 0,
|
||||
end_time: clip.duration,
|
||||
original_duration: clip.duration,
|
||||
}
|
||||
|
||||
setTrimDrag({
|
||||
clipId,
|
||||
direction,
|
||||
startX: e.clientX,
|
||||
originalTrim: { ...trim },
|
||||
originalDuration: clip.duration,
|
||||
})
|
||||
},
|
||||
[clips],
|
||||
)
|
||||
|
||||
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!trimDrag) return
|
||||
|
||||
const PX_PER_SECOND = pixelsPerSecond ?? 40 // 与缩放级别同步
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - trimDrag.startX
|
||||
const dtSec = dx / PX_PER_SECOND
|
||||
const clip = clips.find((c) => c.id === trimDrag.clipId)
|
||||
if (!clip) return
|
||||
|
||||
const origTrim = trimDrag.originalTrim
|
||||
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
|
||||
let newStart = origTrim.start_time
|
||||
let newEnd = origTrim.end_time
|
||||
|
||||
if (trimDrag.direction === "left") {
|
||||
// 左手柄:调整入点
|
||||
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - 1))
|
||||
} else {
|
||||
// 右手柄:调整出点
|
||||
newEnd = Math.max(origTrim.start_time + 1, Math.min(origTrim.end_time + dtSec, origDur))
|
||||
}
|
||||
|
||||
const newDuration = Math.round((newEnd - newStart) * 10) / 10
|
||||
|
||||
setTrimPreview({
|
||||
clipId: trimDrag.clipId,
|
||||
startTime: Math.round(newStart * 10) / 10,
|
||||
endTime: Math.round(newEnd * 10) / 10,
|
||||
duration: newDuration,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
|
||||
const newTrim: TrimConfig = {
|
||||
start_time: trimPreview.startTime,
|
||||
end_time: trimPreview.endTime,
|
||||
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
|
||||
}
|
||||
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
|
||||
}
|
||||
setTrimDrag(null)
|
||||
setTrimPreview(null)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond])
|
||||
|
||||
/* ── 右键菜单 ── */
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
|
||||
}, [])
|
||||
|
||||
/* ── 右键菜单操作 ── */
|
||||
const handleContextSplit = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
if (onClipSplit) {
|
||||
onClipSplit(contextMenu.clipId, 0.5) // 在中间分割
|
||||
}
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipSplit])
|
||||
|
||||
const handleContextResetTrim = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
if (onClipResetTrim) {
|
||||
onClipResetTrim(contextMenu.clipId)
|
||||
}
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipResetTrim])
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipRemove(contextMenu.clipId)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipRemove])
|
||||
|
||||
/* ── 时间标尺 ── */
|
||||
const trackWidth = Math.max(totalDuration * pps, 300)
|
||||
const rulerMarks: number[] = []
|
||||
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15
|
||||
for (let t = 0; t <= totalDuration + step; t += step) {
|
||||
rulerMarks.push(t)
|
||||
}
|
||||
|
||||
const formatTime = (sec: number) => {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化裁剪时间(精确到0.1秒) */
|
||||
const formatTrimTime = (sec: number) => {
|
||||
return `${sec.toFixed(1)}s`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ep-timeline-area">
|
||||
@@ -506,15 +215,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
{/* 时间标尺 */}
|
||||
{currentMode !== "one_take" && (
|
||||
<div className="ep-time-ruler" onClick={handleRulerClick}>
|
||||
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
|
||||
{rulerMarks.map((t) => (
|
||||
<span key={t} className="ep-time-mark" style={{ left: `${t * pps}px` }}>
|
||||
{t}s
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<TimeRuler totalDuration={totalDuration} pps={pps} onClick={handleRulerClick} />
|
||||
)}
|
||||
|
||||
{/* 水平片段轨道 */}
|
||||
@@ -536,115 +237,30 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
<div className="ep-track-empty-text">点击右侧 + 添加片段</div>
|
||||
</div>
|
||||
) : (
|
||||
clips.map((clip, idx) => {
|
||||
/* 转场指示器 */
|
||||
const trans = clip.transition
|
||||
const showTransition = idx > 0 && trans && trans.type !== "none"
|
||||
const transOpt = showTransition
|
||||
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
|
||||
: undefined
|
||||
|
||||
/* 速度徽章 */
|
||||
const speed = clip.speed
|
||||
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01
|
||||
|
||||
/* 裁剪状态 */
|
||||
const hasTrim = !!clip.trim_config
|
||||
const isHovered = hoveredClipId === clip.id
|
||||
|
||||
return (
|
||||
<React.Fragment key={clip.id}>
|
||||
{/* 转场指示器 */}
|
||||
{showTransition && transOpt && (
|
||||
<div
|
||||
className="ep-transition-indicator"
|
||||
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
|
||||
>
|
||||
<span className="ep-trans-icon">{transOpt.icon}</span>
|
||||
<span className="ep-trans-duration">{trans!.duration.toFixed(1)}s</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
|
||||
style={{ width: Math.max(clip.duration * pps, 60) }}
|
||||
draggable={!trimDrag}
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onClick={() => onClipSelect(clip.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, clip.id)}
|
||||
onMouseEnter={() => setHoveredClipId(clip.id)}
|
||||
onMouseLeave={() => setHoveredClipId(null)}
|
||||
>
|
||||
{/* 左裁剪手柄 */}
|
||||
{isHovered && onClipTrim && (
|
||||
<div
|
||||
className="ep-trim-handle ep-trim-handle-left"
|
||||
onMouseDown={(e) => handleTrimHandleMouseDown(e, clip.id, "left")}
|
||||
title="拖动调整入点"
|
||||
>
|
||||
<div className="ep-trim-handle-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型图标 */}
|
||||
<div className="ep-clip-thumbnail">{CLIP_TYPE_ICONS[clip.type] || "🎬"}</div>
|
||||
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<span className="ep-clip-name">
|
||||
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
|
||||
</span>
|
||||
<span className="ep-clip-duration">
|
||||
{clip.duration}s
|
||||
{hasTrim && (
|
||||
<span className="ep-trim-indicator" title="已裁剪">
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 速度徽章 */}
|
||||
{showSpeed && <span className="ep-speed-badge">{speed!.rate.toFixed(1)}x</span>}
|
||||
|
||||
{/* 裁剪徽章 */}
|
||||
{hasTrim && (
|
||||
<span
|
||||
className="ep-trim-badge"
|
||||
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
|
||||
>
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 右裁剪手柄 */}
|
||||
{isHovered && onClipTrim && (
|
||||
<div
|
||||
className="ep-trim-handle ep-trim-handle-right"
|
||||
onMouseDown={(e) => handleTrimHandleMouseDown(e, clip.id, "right")}
|
||||
title="拖动调整出点"
|
||||
>
|
||||
<div className="ep-trim-handle-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="ep-clip-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClipRemove(clip.id)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})
|
||||
clips.map((clip, idx) => (
|
||||
<ClipCard
|
||||
key={clip.id}
|
||||
clip={clip}
|
||||
idx={idx}
|
||||
isSelected={selectedClipId === clip.id}
|
||||
isDragging={dragIdx === idx}
|
||||
isDragOver={dragOverIdx === idx}
|
||||
isHovered={hoveredClipId === clip.id}
|
||||
pps={pps}
|
||||
trimDragActive={!!trimDrag}
|
||||
showTrimHandles={!!onClipTrim}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={handleDrop}
|
||||
onSelect={onClipSelect}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={() => setHoveredClipId(clip.id)}
|
||||
onMouseLeave={() => setHoveredClipId(null)}
|
||||
onTrimHandleMouseDown={handleTrimHandleMouseDown}
|
||||
onRemove={onClipRemove}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* ── 轨道末尾 "+" 添加卡片 ── */}
|
||||
@@ -663,109 +279,40 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
{/* 裁剪预览 tooltip */}
|
||||
{trimPreview && (
|
||||
<div
|
||||
className="ep-trim-preview"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: trimPreview.x + 12,
|
||||
top: trimPreview.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="ep-trim-preview-row">
|
||||
<span className="ep-trim-preview-label">入点</span>
|
||||
<span className="ep-trim-preview-value">{formatTrimTime(trimPreview.startTime)}</span>
|
||||
</div>
|
||||
<div className="ep-trim-preview-row">
|
||||
<span className="ep-trim-preview-label">出点</span>
|
||||
<span className="ep-trim-preview-value">{formatTrimTime(trimPreview.endTime)}</span>
|
||||
</div>
|
||||
<div className="ep-trim-preview-row ep-trim-preview-duration">
|
||||
<span className="ep-trim-preview-label">时长</span>
|
||||
<span className="ep-trim-preview-value">{formatTrimTime(trimPreview.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<TrimPreview
|
||||
startTime={trimPreview.startTime}
|
||||
endTime={trimPreview.endTime}
|
||||
duration={trimPreview.duration}
|
||||
x={trimPreview.x}
|
||||
y={trimPreview.y}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="ep-context-menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
}}
|
||||
>
|
||||
<div className="ep-context-menu-item" onClick={handleContextSplit}>
|
||||
<span className="ep-context-menu-icon">✂️</span>
|
||||
<span>分割片段</span>
|
||||
</div>
|
||||
{clips.find((c) => c.id === contextMenu.clipId)?.trim_config && (
|
||||
<div className="ep-context-menu-item" onClick={handleContextResetTrim}>
|
||||
<span className="ep-context-menu-icon">↩️</span>
|
||||
<span>恢复原始长度</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-context-menu-divider" />
|
||||
<div
|
||||
className="ep-context-menu-item ep-context-menu-item-danger"
|
||||
onClick={handleContextDelete}
|
||||
>
|
||||
<span className="ep-context-menu-icon">🗑️</span>
|
||||
<span>删除片段</span>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
menuRef={contextMenuRef}
|
||||
hasTrim={!!clips.find((c) => c.id === contextMenu.clipId)?.trim_config}
|
||||
onSplit={handleContextSplit}
|
||||
onResetTrim={handleContextResetTrim}
|
||||
onDelete={handleContextDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 类型+时长选择面板 */}
|
||||
{showAddPicker && (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
className="ep-add-clip-picker ep-add-clip-picker--portal"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: pickerPos.top,
|
||||
right: pickerPos.right,
|
||||
}}
|
||||
>
|
||||
<div className="ep-add-clip-picker-title">添加片段</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="ep-add-clip-type-row">
|
||||
<span className="ep-add-clip-type-label">类型:</span>
|
||||
{availableTypes.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
|
||||
onClick={() => setAddType(t)}
|
||||
>
|
||||
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 时长输入 */}
|
||||
<div className="ep-add-clip-duration-row">
|
||||
<span className="ep-add-clip-type-label">时长:</span>
|
||||
<input
|
||||
type="number"
|
||||
className="ep-duration-input"
|
||||
min={1}
|
||||
max={120}
|
||||
value={addDuration}
|
||||
onChange={(e) =>
|
||||
setAddDuration(Math.max(1, Math.min(120, Number(e.target.value) || 1)))
|
||||
}
|
||||
/>
|
||||
<span className="ep-add-clip-duration-unit">秒</span>
|
||||
</div>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<button className="ep-add-clip-confirm-btn" onClick={handleConfirmAdd}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<AddClipPicker
|
||||
pickerRef={pickerRef}
|
||||
position={pickerPos}
|
||||
availableTypes={availableTypes}
|
||||
addType={addType}
|
||||
addDuration={addDuration}
|
||||
onTypeChange={setAddType}
|
||||
onDurationChange={setAddDuration}
|
||||
onConfirm={handleConfirmAdd}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
|
||||
interface TopBarProps {
|
||||
currentTemplate: EditingTemplate | null
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
onUndo: () => void
|
||||
onRedo: () => void
|
||||
onOpenSaveModal: () => void
|
||||
}
|
||||
|
||||
const TopBar: React.FC<TopBarProps> = ({
|
||||
currentTemplate,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onOpenSaveModal,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ep-top-bar">
|
||||
<div className="ep-top-bar-left">
|
||||
<span className="ep-logo">✂️</span>
|
||||
<span className="ep-app-title">模板制作</span>
|
||||
<span className="ep-divider">|</span>
|
||||
<span className="ep-template-name">{currentTemplate?.name || "未选择模板"}</span>
|
||||
</div>
|
||||
<div className="ep-top-bar-right">
|
||||
<button
|
||||
className="ep-btn ep-btn-secondary"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
title="撤销 (Ctrl+Z)"
|
||||
>
|
||||
⬅️ 撤销
|
||||
</button>
|
||||
<button
|
||||
className="ep-btn ep-btn-secondary"
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
title="重做 (Ctrl+Shift+Z)"
|
||||
>
|
||||
➡️ 重做
|
||||
</button>
|
||||
<button className="ep-btn ep-btn-secondary" onClick={onOpenSaveModal}>
|
||||
💾 保存模板
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopBar
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from "react"
|
||||
import type { ClipType } from "../../types"
|
||||
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "../../constants/timeline"
|
||||
|
||||
interface AddClipPickerProps {
|
||||
pickerRef: React.RefObject<HTMLDivElement>
|
||||
position: { top: number; right: number }
|
||||
availableTypes: ClipType[]
|
||||
addType: ClipType
|
||||
addDuration: number
|
||||
onTypeChange: (type: ClipType) => void
|
||||
onDurationChange: (duration: number) => void
|
||||
onConfirm: () => void
|
||||
minDuration?: number
|
||||
maxDuration?: number
|
||||
}
|
||||
|
||||
export const AddClipPicker: React.FC<AddClipPickerProps> = ({
|
||||
pickerRef,
|
||||
position,
|
||||
availableTypes,
|
||||
addType,
|
||||
addDuration,
|
||||
onTypeChange,
|
||||
onDurationChange,
|
||||
onConfirm,
|
||||
minDuration = 1,
|
||||
maxDuration = 120,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
className="ep-add-clip-picker ep-add-clip-picker--portal"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: position.top,
|
||||
right: position.right,
|
||||
}}
|
||||
>
|
||||
<div className="ep-add-clip-picker-title">添加片段</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="ep-add-clip-type-row">
|
||||
<span className="ep-add-clip-type-label">类型:</span>
|
||||
{availableTypes.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
|
||||
onClick={() => onTypeChange(t)}
|
||||
>
|
||||
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 时长输入 */}
|
||||
<div className="ep-add-clip-duration-row">
|
||||
<span className="ep-add-clip-type-label">时长:</span>
|
||||
<input
|
||||
type="number"
|
||||
className="ep-duration-input"
|
||||
min={minDuration}
|
||||
max={maxDuration}
|
||||
value={addDuration}
|
||||
onChange={(e) =>
|
||||
onDurationChange(
|
||||
Math.max(minDuration, Math.min(maxDuration, Number(e.target.value) || minDuration)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="ep-add-clip-duration-unit">秒</span>
|
||||
</div>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<button className="ep-add-clip-confirm-btn" onClick={onConfirm}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import React from "react"
|
||||
import type { ClipData, TrimConfig } from "../../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS, MIN_CLIP_WIDTH } from "../../constants/timeline"
|
||||
|
||||
interface ClipCardProps {
|
||||
clip: ClipData
|
||||
idx: number
|
||||
isSelected: boolean
|
||||
isDragging: boolean
|
||||
isDragOver: boolean
|
||||
isHovered: boolean
|
||||
pps: number
|
||||
trimDragActive: boolean
|
||||
showTrimHandles: boolean
|
||||
onDragStart: (e: React.DragEvent, idx: number) => void
|
||||
onDragOver: (e: React.DragEvent, idx: number) => void
|
||||
onDragEnd: () => void
|
||||
onDrop: (e: React.DragEvent, idx: number) => void
|
||||
onSelect: (clipId: string) => void
|
||||
onContextMenu: (e: React.MouseEvent, clipId: string) => void
|
||||
onMouseEnter: () => void
|
||||
onMouseLeave: () => void
|
||||
onTrimHandleMouseDown: (e: React.MouseEvent, clipId: string, direction: "left" | "right") => void
|
||||
onRemove: (clipId: string) => void
|
||||
}
|
||||
|
||||
export const ClipCard: React.FC<ClipCardProps> = ({
|
||||
clip,
|
||||
idx,
|
||||
isSelected,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
isHovered,
|
||||
pps,
|
||||
trimDragActive,
|
||||
showTrimHandles,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onTrimHandleMouseDown,
|
||||
onRemove,
|
||||
}) => {
|
||||
/* 转场指示器 */
|
||||
const trans = clip.transition
|
||||
const showTransition = idx > 0 && trans && trans.type !== "none"
|
||||
const transOpt = showTransition
|
||||
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
|
||||
: undefined
|
||||
|
||||
/* 速度徽章 */
|
||||
const speed = clip.speed
|
||||
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01
|
||||
|
||||
/* 裁剪状态 */
|
||||
const hasTrim = !!clip.trim_config
|
||||
|
||||
return (
|
||||
<React.Fragment key={clip.id}>
|
||||
{/* 转场指示器 */}
|
||||
{showTransition && transOpt && (
|
||||
<div
|
||||
className="ep-transition-indicator"
|
||||
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
|
||||
>
|
||||
<span className="ep-trans-icon">{transOpt.icon}</span>
|
||||
<span className="ep-trans-duration">{trans!.duration.toFixed(1)}s</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`ep-clip-card ${isSelected ? "selected" : ""} ${isDragging ? "dragging" : ""} ${isDragOver ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
|
||||
style={{ width: Math.max(clip.duration * pps, MIN_CLIP_WIDTH) }}
|
||||
draggable={!trimDragActive}
|
||||
onDragStart={(e) => onDragStart(e, idx)}
|
||||
onDragOver={(e) => onDragOver(e, idx)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={(e) => onDrop(e, idx)}
|
||||
onClick={() => onSelect(clip.id)}
|
||||
onContextMenu={(e) => onContextMenu(e, clip.id)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* 左裁剪手柄 */}
|
||||
{isHovered && showTrimHandles && (
|
||||
<div
|
||||
className="ep-trim-handle ep-trim-handle-left"
|
||||
onMouseDown={(e) => onTrimHandleMouseDown(e, clip.id, "left")}
|
||||
title="拖动调整入点"
|
||||
>
|
||||
<div className="ep-trim-handle-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型图标 */}
|
||||
<div className="ep-clip-thumbnail">{CLIP_TYPE_ICONS[clip.type] || "🎬"}</div>
|
||||
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<span className="ep-clip-name">
|
||||
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
|
||||
</span>
|
||||
<span className="ep-clip-duration">
|
||||
{clip.duration}s
|
||||
{hasTrim && (
|
||||
<span className="ep-trim-indicator" title="已裁剪">
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 速度徽章 */}
|
||||
{showSpeed && <span className="ep-speed-badge">{speed!.rate.toFixed(1)}x</span>}
|
||||
|
||||
{/* 裁剪徽章 */}
|
||||
{hasTrim && (
|
||||
<span
|
||||
className="ep-trim-badge"
|
||||
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
|
||||
>
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 右裁剪手柄 */}
|
||||
{isHovered && showTrimHandles && (
|
||||
<div
|
||||
className="ep-trim-handle ep-trim-handle-right"
|
||||
onMouseDown={(e) => onTrimHandleMouseDown(e, clip.id, "right")}
|
||||
title="拖动调整出点"
|
||||
>
|
||||
<div className="ep-trim-handle-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="ep-clip-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove(clip.id)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react"
|
||||
|
||||
interface ContextMenuProps {
|
||||
x: number
|
||||
y: number
|
||||
menuRef: React.RefObject<HTMLDivElement>
|
||||
hasTrim: boolean
|
||||
onSplit: () => void
|
||||
onResetTrim: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
x,
|
||||
y,
|
||||
menuRef,
|
||||
hasTrim,
|
||||
onSplit,
|
||||
onResetTrim,
|
||||
onDelete,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="ep-context-menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: x,
|
||||
top: y,
|
||||
}}
|
||||
>
|
||||
<div className="ep-context-menu-item" onClick={onSplit}>
|
||||
<span className="ep-context-menu-icon">✂️</span>
|
||||
<span>分割片段</span>
|
||||
</div>
|
||||
{hasTrim && (
|
||||
<div className="ep-context-menu-item" onClick={onResetTrim}>
|
||||
<span className="ep-context-menu-icon">↩️</span>
|
||||
<span>恢复原始长度</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-context-menu-divider" />
|
||||
<div
|
||||
className="ep-context-menu-item ep-context-menu-item-danger"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<span className="ep-context-menu-icon">🗑️</span>
|
||||
<span>删除片段</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react"
|
||||
import { getRulerStep, MIN_TRACK_WIDTH } from "../../constants/timeline"
|
||||
import { generateRulerMarks } from "../../utils/timeline"
|
||||
|
||||
interface TimeRulerProps {
|
||||
totalDuration: number
|
||||
pps: number
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
export const TimeRuler: React.FC<TimeRulerProps> = ({ totalDuration, pps, onClick }) => {
|
||||
const trackWidth = Math.max(totalDuration * pps, MIN_TRACK_WIDTH)
|
||||
const step = getRulerStep(totalDuration)
|
||||
const marks = generateRulerMarks(totalDuration, step)
|
||||
|
||||
return (
|
||||
<div className="ep-time-ruler" onClick={onClick}>
|
||||
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
|
||||
{marks.map((t) => (
|
||||
<span key={t} className="ep-time-mark" style={{ left: `${t * pps}px` }}>
|
||||
{t}s
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react"
|
||||
import { formatTrimTime } from "../../utils/timeline"
|
||||
|
||||
interface TrimPreviewProps {
|
||||
startTime: number
|
||||
endTime: number
|
||||
duration: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export const TrimPreview: React.FC<TrimPreviewProps> = ({ startTime, endTime, duration, x, y }) => {
|
||||
return (
|
||||
<div
|
||||
className="ep-trim-preview"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: x + 12,
|
||||
top: y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="ep-trim-preview-row">
|
||||
<span className="ep-trim-preview-label">入点</span>
|
||||
<span className="ep-trim-preview-value">{formatTrimTime(startTime)}</span>
|
||||
</div>
|
||||
<div className="ep-trim-preview-row">
|
||||
<span className="ep-trim-preview-label">出点</span>
|
||||
<span className="ep-trim-preview-value">{formatTrimTime(endTime)}</span>
|
||||
</div>
|
||||
<div className="ep-trim-preview-row ep-trim-preview-duration">
|
||||
<span className="ep-trim-preview-label">时长</span>
|
||||
<span className="ep-trim-preview-value">{formatTrimTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
export const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
|
||||
{ key: "pip", label: "混剪", icon: "🖼️" },
|
||||
{ key: "voice_over", label: "人物口播", icon: "🎙️" },
|
||||
{ key: "one_take", label: "一镜到底", icon: "🎥" },
|
||||
{ key: "voice_pip", label: "口播+混剪", icon: "🎭" },
|
||||
]
|
||||
|
||||
export const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"]
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ClipType } from "../types"
|
||||
|
||||
/** 片段类型图标 */
|
||||
export const CLIP_TYPE_ICONS: Record<ClipType, string> = {
|
||||
voice: "🎙️",
|
||||
pip: "🖼️",
|
||||
}
|
||||
|
||||
/** 片段类型标签 */
|
||||
export const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "混剪",
|
||||
}
|
||||
|
||||
/** 默认缩放:每秒像素数 */
|
||||
export const DEFAULT_PIXELS_PER_SECOND = 40
|
||||
|
||||
/** 最小缩放 */
|
||||
export const MIN_PIXELS_PER_SECOND = 10
|
||||
|
||||
/** 最大缩放 */
|
||||
export const MAX_PIXELS_PER_SECOND = 120
|
||||
|
||||
/** 缩放步长 */
|
||||
export const ZOOM_STEP = 10
|
||||
|
||||
/** 片段卡片最小宽度(px) */
|
||||
export const MIN_CLIP_WIDTH = 60
|
||||
|
||||
/** 添加面板宽度(px) */
|
||||
export const ADD_PICKER_WIDTH = 240
|
||||
|
||||
/** 轨道间距(px) */
|
||||
export const TRACK_GAP = 6
|
||||
|
||||
/** 最小裁剪时长(秒) */
|
||||
export const MIN_TRIM_DURATION = 1
|
||||
|
||||
/** 默认添加时长(秒) */
|
||||
export const DEFAULT_ADD_DURATION = 5
|
||||
|
||||
/** 最小添加时长(秒) */
|
||||
export const MIN_ADD_DURATION = 1
|
||||
|
||||
/** 最大添加时长(秒) */
|
||||
export const MAX_ADD_DURATION = 120
|
||||
|
||||
/** 轨道最小宽度(px) */
|
||||
export const MIN_TRACK_WIDTH = 300
|
||||
|
||||
/** 时间标尺刻度计算:根据总时长返回刻度步长(秒) */
|
||||
export const getRulerStep = (totalDuration: number): number => {
|
||||
if (totalDuration <= 30) return 5
|
||||
if (totalDuration <= 60) return 10
|
||||
return 15
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* 片段拖拽排序 Hook
|
||||
* 支持 HTML5 原生拖拽,实时高亮拖拽位置
|
||||
*/
|
||||
export const useClipDrag = (
|
||||
onClipReorder: (fromIdx: number, toIdx: number) => void,
|
||||
disabled?: boolean,
|
||||
) => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
if (disabled) return
|
||||
setDragIdx(idx)
|
||||
e.dataTransfer.setData("application/x-clip-drag", String(idx))
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
},
|
||||
[disabled],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = "move"
|
||||
setDragOverIdx(idx)
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, toIdx: number) => {
|
||||
e.preventDefault()
|
||||
setDragOverIdx(null)
|
||||
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
|
||||
if (fromStr !== "") {
|
||||
const fromIdx = Number(fromStr)
|
||||
if (fromIdx !== toIdx) {
|
||||
onClipReorder(fromIdx, toIdx)
|
||||
}
|
||||
}
|
||||
setDragIdx(null)
|
||||
},
|
||||
[onClipReorder],
|
||||
)
|
||||
|
||||
const handleEmptyDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
dragIdx,
|
||||
dragOverIdx,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleDrop,
|
||||
handleEmptyDragOver,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* 编辑器 Drawer 开关管理
|
||||
* 集中管理 11 个抽屉的开关状态 + 3 个目标片段 ID + 快捷打开方法
|
||||
*/
|
||||
export const useEditorDrawers = () => {
|
||||
/* ── 抽屉开关 ── */
|
||||
const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false)
|
||||
const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false)
|
||||
const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false)
|
||||
const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false)
|
||||
const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false)
|
||||
const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false)
|
||||
const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false)
|
||||
const [pipDrawerOpen, setPipDrawerOpen] = useState(false)
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false)
|
||||
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false)
|
||||
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false)
|
||||
|
||||
/* ── 目标片段 ID ── */
|
||||
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
|
||||
const [transitionTargetClipId, setTransitionTargetClipId] = useState<string | null>(null)
|
||||
/** 当前正在调速的片段 ID */
|
||||
const [speedTargetClipId, setSpeedTargetClipId] = useState<string | null>(null)
|
||||
/** 当前正在配置 TTS 的片段 ID */
|
||||
const [ttsTargetClipId, setTtsTargetClipId] = useState<string | null>(null)
|
||||
|
||||
/* ── 快捷打开 ── */
|
||||
const openTransitionDrawer = useCallback((clipId?: string) => {
|
||||
setTransitionTargetClipId(clipId ?? null)
|
||||
setTransitionDrawerOpen(true)
|
||||
}, [])
|
||||
|
||||
const openSpeedDrawer = useCallback((clipId: string) => {
|
||||
setSpeedTargetClipId(clipId)
|
||||
setSpeedDrawerOpen(true)
|
||||
}, [])
|
||||
|
||||
const openTtsDrawer = useCallback((clipId: string) => {
|
||||
setTtsTargetClipId(clipId)
|
||||
setTtsDrawerOpen(true)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 开关 state
|
||||
bgmDrawerOpen,
|
||||
setBgmDrawerOpen,
|
||||
subtitleDrawerOpen,
|
||||
setSubtitleDrawerOpen,
|
||||
transitionDrawerOpen,
|
||||
setTransitionDrawerOpen,
|
||||
speedDrawerOpen,
|
||||
setSpeedDrawerOpen,
|
||||
ttsDrawerOpen,
|
||||
setTtsDrawerOpen,
|
||||
watermarkDrawerOpen,
|
||||
setWatermarkDrawerOpen,
|
||||
introOutroDrawerOpen,
|
||||
setIntroOutroDrawerOpen,
|
||||
pipDrawerOpen,
|
||||
setPipDrawerOpen,
|
||||
filterDrawerOpen,
|
||||
setFilterDrawerOpen,
|
||||
chromaKeyDrawerOpen,
|
||||
setChromaKeyDrawerOpen,
|
||||
stickerDrawerOpen,
|
||||
setStickerDrawerOpen,
|
||||
// 目标 ID
|
||||
transitionTargetClipId,
|
||||
speedTargetClipId,
|
||||
ttsTargetClipId,
|
||||
// 快捷方法
|
||||
openTransitionDrawer,
|
||||
openSpeedDrawer,
|
||||
openTtsDrawer,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
|
||||
/**
|
||||
* 播放控制 Hook
|
||||
* 播放/暂停、rAF 帧推进、时间线缩放、seek
|
||||
*/
|
||||
export const usePlaybackControl = (totalDuration: number) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(40)
|
||||
const prevFrameTimeRef = useRef<number | null>(null)
|
||||
|
||||
/** 播放头跳转 */
|
||||
const handleSeek = useCallback((time: number) => {
|
||||
setCurrentTime(Math.max(0, time))
|
||||
}, [])
|
||||
|
||||
/** 轨道缩放 */
|
||||
const handleZoomChange = useCallback((pps: number) => {
|
||||
setPixelsPerSecond(pps)
|
||||
}, [])
|
||||
|
||||
/** rAF 帧推进 — 播放时平滑更新播放头位置 */
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
prevFrameTimeRef.current = null
|
||||
return
|
||||
}
|
||||
let rafId: number
|
||||
const tick = (timestamp: number) => {
|
||||
if (prevFrameTimeRef.current !== null) {
|
||||
const delta = (timestamp - prevFrameTimeRef.current) / 1000
|
||||
setCurrentTime((prev) => {
|
||||
const next = prev + delta
|
||||
return next >= totalDuration ? totalDuration : next
|
||||
})
|
||||
}
|
||||
prevFrameTimeRef.current = timestamp
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
rafId = requestAnimationFrame(tick)
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId)
|
||||
prevFrameTimeRef.current = null
|
||||
}
|
||||
}, [isPlaying, totalDuration])
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
currentTime,
|
||||
pixelsPerSecond,
|
||||
handleSeek,
|
||||
handleZoomChange,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { useState, useCallback, useEffect, type Dispatch, type SetStateAction } from "react"
|
||||
import { FILTER_CATEGORIES } from "../constants"
|
||||
import { message } from "antd"
|
||||
import type {
|
||||
EditingTemplate,
|
||||
TemplateCategory,
|
||||
TemplateMode,
|
||||
SaveTemplatePayload,
|
||||
} from "@/api/editing-planner"
|
||||
import {
|
||||
getEditingTemplates,
|
||||
getEditingTemplate,
|
||||
createEditingTemplate,
|
||||
updateEditingTemplate,
|
||||
getTemplateCategories,
|
||||
} from "@/api/editing-planner"
|
||||
import type { MediaAsset, TitleConfig, TransitionEffect } from "@/api/template-editor"
|
||||
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/template-editor"
|
||||
import type {
|
||||
ClipData,
|
||||
ClipType,
|
||||
TtsConfig,
|
||||
TtsMode,
|
||||
TrimConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
|
||||
interface UseTemplateManagementParams {
|
||||
urlTemplateId: string
|
||||
urlPlanId: string
|
||||
resetClips: (clips: ClipData[]) => void
|
||||
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
|
||||
setSelectedClipId: (id: string | null) => void
|
||||
setMediaAssets: (assets: MediaAsset[]) => void
|
||||
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
|
||||
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
|
||||
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
|
||||
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
|
||||
// 保存时需要的配置
|
||||
clips: ClipData[]
|
||||
totalDuration: number
|
||||
titleConfig: TitleConfig
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
bgmSettings: BgmMixConfig
|
||||
watermarkSettings: WatermarkConfig
|
||||
introOutroSettings: IntroOutroConfig
|
||||
pipSettings: PipConfig
|
||||
filterSettings: FilterConfig
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
stickerSettings: StickerConfig
|
||||
coverConfig: CoverConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板管理 Hook
|
||||
* 模板列表/分类/加载/保存/模式切换/筛选搜索 + 3 个 useEffect
|
||||
*/
|
||||
export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
const {
|
||||
urlTemplateId,
|
||||
urlPlanId,
|
||||
resetClips,
|
||||
setClips,
|
||||
setSelectedClipId,
|
||||
setMediaAssets,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
clips,
|
||||
totalDuration,
|
||||
titleConfig,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
watermarkSettings,
|
||||
introOutroSettings,
|
||||
pipSettings,
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
} = params
|
||||
|
||||
/* ── 模板列表 ── */
|
||||
const [templates, setTemplates] = useState<EditingTemplate[]>([])
|
||||
const [categories, setCategories] = useState<TemplateCategory[]>([])
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(false)
|
||||
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(urlTemplateId || null)
|
||||
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip")
|
||||
|
||||
/* ── 左栏筛选 ── */
|
||||
const [currentFilter, setCurrentFilter] = useState("全部")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
/* ── 保存弹窗 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false)
|
||||
const [draftName, setDraftName] = useState("")
|
||||
const [draftCategory, setDraftCategory] = useState("")
|
||||
const [draftTags, setDraftTags] = useState("")
|
||||
const [saveLoading, setSaveLoading] = useState(false)
|
||||
|
||||
/* ── 计划 ID(从 URL 传入,不变) ── */
|
||||
const [loadedPlanId] = useState<string | null>(urlPlanId || null)
|
||||
|
||||
/* ── 计算 ── */
|
||||
const filteredTemplates = templates.filter((t) => {
|
||||
if (currentFilter !== "全部" && t.category !== currentFilter) return false
|
||||
if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const currentTemplate = templates.find((t) => t.id === loadedTemplateId) || null
|
||||
|
||||
/* ──────────── 加载 ──────────── */
|
||||
|
||||
/**
|
||||
* 并行加载模板列表、分类、素材库
|
||||
* 首次挂载时调用,三个接口无依赖关系,用 Promise.all 并发
|
||||
*/
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setLoadingTemplates(true)
|
||||
try {
|
||||
const [tpls, cats, assets] = await Promise.all([
|
||||
getEditingTemplates(),
|
||||
getTemplateCategories(),
|
||||
getMediaAssets(),
|
||||
])
|
||||
setTemplates(tpls)
|
||||
setCategories(cats)
|
||||
setMediaAssets(assets)
|
||||
} catch {
|
||||
message.error("加载模板失败")
|
||||
} finally {
|
||||
setLoadingTemplates(false)
|
||||
}
|
||||
}, [setMediaAssets])
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
}, [loadTemplates])
|
||||
|
||||
/**
|
||||
* 加载模板详情并初始化片段列表
|
||||
* 将后端 segments 映射为前端 ClipData,取 duration_min/max 均值作为默认时长
|
||||
* 同时还原标题/字幕/BGM 配置
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!loadedTemplateId) return
|
||||
getEditingTemplate(loadedTemplateId)
|
||||
.then((tpl) => {
|
||||
if (!tpl) return
|
||||
setCurrentMode(tpl.mode)
|
||||
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
|
||||
id: seg.id || `seg-${idx}`,
|
||||
template_segment_id: seg.id || `seg-${idx}`,
|
||||
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
|
||||
duration: (seg.duration_min + seg.duration_max) / 2,
|
||||
startOffset: 0,
|
||||
script_text: "",
|
||||
order: seg.segment_order,
|
||||
}))
|
||||
resetClips(mapped)
|
||||
|
||||
setTitleConfig({
|
||||
ai_auto_select: tpl.title_config.ai_auto_select,
|
||||
content: tpl.title_config.content,
|
||||
position: tpl.title_config.position,
|
||||
font_preset: tpl.title_config.font_preset,
|
||||
font_size: tpl.title_config.font_size,
|
||||
font_color: tpl.title_config.font_color || "#ffffff",
|
||||
})
|
||||
setSubtitleSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.subtitle_config.enabled,
|
||||
position: (tpl.subtitle_config.position || "bottom") as SubtitleStyleConfig["position"],
|
||||
font: tpl.subtitle_config.font,
|
||||
fontSize: tpl.subtitle_config.size,
|
||||
fontColor: tpl.subtitle_config.color || "#ffffff",
|
||||
animation: tpl.subtitle_config.animation,
|
||||
}))
|
||||
setBgmSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.bgm_config.enabled,
|
||||
music_id: tpl.bgm_config.music_id || "",
|
||||
}))
|
||||
setDraftName(tpl.name)
|
||||
setDraftCategory(tpl.category)
|
||||
setDraftTags(tpl.tags.join(", "))
|
||||
})
|
||||
.catch(() => message.error("加载模板详情失败"))
|
||||
}, [loadedTemplateId, resetClips, setTitleConfig, setSubtitleSettings, setBgmSettings])
|
||||
|
||||
/**
|
||||
* 加载已有模板草稿数据到编辑器
|
||||
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!loadedPlanId) return
|
||||
|
||||
// 并行加载计划基本信息 + 片段列表
|
||||
Promise.all([
|
||||
getEditPlan(loadedPlanId),
|
||||
getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
})),
|
||||
])
|
||||
.then(([plan, clipsRes]) => {
|
||||
// 设置关联的模板(触发模板加载 effect)
|
||||
setLoadedTemplateId(plan.template_id)
|
||||
|
||||
// 还原基本信息
|
||||
setDraftName(plan.name)
|
||||
|
||||
// 还原 config 中的编辑器状态
|
||||
const cfg = plan.config
|
||||
if (cfg.title_config) {
|
||||
setTitleConfig({
|
||||
ai_auto_select: cfg.title_config!.ai_auto_select,
|
||||
content: cfg.title_config!.content,
|
||||
position: cfg.title_config!.position,
|
||||
font_preset: cfg.title_config!.font_preset,
|
||||
font_size: cfg.title_config!.font_size,
|
||||
font_color: cfg.title_config!.font_color || "#ffffff",
|
||||
})
|
||||
}
|
||||
if (cfg.subtitle_config) {
|
||||
setSubtitleSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cfg.subtitle_config!.enabled,
|
||||
position: (cfg.subtitle_config!.position ||
|
||||
"bottom") as SubtitleStyleConfig["position"],
|
||||
font: cfg.subtitle_config!.font,
|
||||
fontSize: cfg.subtitle_config!.size,
|
||||
fontColor: cfg.subtitle_config!.color || "#ffffff",
|
||||
animation: cfg.subtitle_config!.animation,
|
||||
}))
|
||||
}
|
||||
if (cfg.bgm_config) {
|
||||
setBgmSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cfg.bgm_config!.enabled,
|
||||
music_id: cfg.bgm_config!.music_id || "",
|
||||
}))
|
||||
}
|
||||
// 还原封面配置
|
||||
if (cfg.cover_config) {
|
||||
setCoverConfig((prev) => ({
|
||||
...prev,
|
||||
enabled: cfg.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
|
||||
thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
}))
|
||||
}
|
||||
|
||||
// 还原片段:优先从后端 clips 表,其次从 config.segments 兜底
|
||||
const backendClips = clipsRes?.items || []
|
||||
if (backendClips.length > 0) {
|
||||
// 从后端 clips 表还原
|
||||
const sorted = [...backendClips].sort((a, b) => a.order - b.order)
|
||||
const mapped: ClipData[] = sorted.map((clip) => ({
|
||||
id: clip.id,
|
||||
template_segment_id: (clip.config?.template_segment_id as string) || "",
|
||||
type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType,
|
||||
duration: clip.duration || 3,
|
||||
startOffset: 0,
|
||||
script_text: clip.text_content || "",
|
||||
order: clip.order,
|
||||
media_asset_id: clip.asset_id || undefined,
|
||||
transition:
|
||||
clip.transition_effect && clip.transition_effect !== "none"
|
||||
? {
|
||||
type: clip.transition_effect as TransitionEffect["type"],
|
||||
duration: clip.transition_duration || 0.3,
|
||||
}
|
||||
: undefined,
|
||||
speed: clip.playback_speed
|
||||
? { rate: clip.playback_speed, pitchCorrection: true }
|
||||
: undefined,
|
||||
tts_config: (clip.config?.tts_config as TtsConfig) || undefined,
|
||||
trim_config: (clip.config?.trim_config as TrimConfig) || undefined,
|
||||
}))
|
||||
setTimeout(() => resetClips(mapped), 100)
|
||||
} else if (cfg.segments && cfg.segments.length > 0) {
|
||||
// 兜底:从 config.segments 还原(老数据兼容)
|
||||
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
|
||||
id: `seg-${idx}`,
|
||||
template_segment_id: `seg-${idx}`,
|
||||
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
|
||||
duration: (seg.duration_min + seg.duration_max) / 2,
|
||||
startOffset: 0,
|
||||
script_text: "",
|
||||
order: seg.segment_order,
|
||||
transition: seg.transition
|
||||
? {
|
||||
type: seg.transition.type as TransitionEffect["type"],
|
||||
duration: seg.transition.duration,
|
||||
}
|
||||
: undefined,
|
||||
speed: seg.playback_speed
|
||||
? { rate: seg.playback_speed, pitchCorrection: true }
|
||||
: undefined,
|
||||
tts_config: seg.tts_config
|
||||
? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode }
|
||||
: undefined,
|
||||
trim_config: seg.trim_config || undefined,
|
||||
}))
|
||||
setTimeout(() => resetClips(mapped), 100)
|
||||
}
|
||||
})
|
||||
.catch(() => message.error("加载模板草稿失败"))
|
||||
}, [
|
||||
loadedPlanId,
|
||||
resetClips,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
])
|
||||
|
||||
/* ──────────── 事件 ──────────── */
|
||||
|
||||
const handleLoadTemplate = (templateId: string) => {
|
||||
setLoadedTemplateId(templateId)
|
||||
setSelectedClipId(null)
|
||||
}
|
||||
|
||||
const handleModeChange = (mode: TemplateMode) => {
|
||||
setCurrentMode(mode)
|
||||
// 切换纯单类型模式时,自动转换所有已有片段的类型
|
||||
if (mode === "voice_over") {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const })))
|
||||
} else if (mode === "pip") {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const })))
|
||||
}
|
||||
// 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型
|
||||
}
|
||||
|
||||
const handleOpenSaveModal = () => {
|
||||
setSaveModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!draftName.trim()) {
|
||||
message.warning("请输入模板名称")
|
||||
return
|
||||
}
|
||||
setSaveLoading(true)
|
||||
try {
|
||||
const payload: SaveTemplatePayload = {
|
||||
name: draftName,
|
||||
mode: currentMode,
|
||||
category: draftCategory,
|
||||
tags: draftTags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
title_config: titleConfig,
|
||||
subtitle_config: {
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
color: subtitleSettings.fontColor,
|
||||
size: subtitleSettings.fontSize,
|
||||
animation: subtitleSettings.animation,
|
||||
},
|
||||
bgm_config: {
|
||||
enabled: bgmSettings.enabled,
|
||||
music_id: bgmSettings.music_id,
|
||||
},
|
||||
estimated_duration: totalDuration,
|
||||
segments: clips.map((c, i) => ({
|
||||
segment_order: i,
|
||||
duration_min: Math.max(1, c.duration - 2),
|
||||
duration_max: c.duration + 2,
|
||||
material_type: c.type === "voice" ? "voiceover" : "video",
|
||||
transition: c.transition
|
||||
? { type: c.transition.type, duration: c.transition.duration }
|
||||
: undefined,
|
||||
playback_speed: c.speed ? c.speed.rate : undefined,
|
||||
tts_config: c.tts_config
|
||||
? {
|
||||
mode: c.tts_config.mode,
|
||||
text: c.tts_config.text,
|
||||
voice_id: c.tts_config.voice_id,
|
||||
speed: c.tts_config.speed,
|
||||
pitch: c.tts_config.pitch,
|
||||
volume: c.tts_config.volume,
|
||||
subtitle_sync: c.tts_config.subtitle_sync,
|
||||
}
|
||||
: undefined,
|
||||
trim_config: c.trim_config
|
||||
? {
|
||||
start_time: c.trim_config.start_time,
|
||||
end_time: c.trim_config.end_time,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
watermark_config: { ...watermarkSettings },
|
||||
intro_outro_config: { ...introOutroSettings },
|
||||
pip_config: { ...pipSettings },
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverConfig },
|
||||
}
|
||||
if (loadedTemplateId) {
|
||||
await updateEditingTemplate(loadedTemplateId, payload)
|
||||
} else {
|
||||
await createEditingTemplate(payload)
|
||||
}
|
||||
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功")
|
||||
setSaveModalOpen(false)
|
||||
loadTemplates()
|
||||
} catch {
|
||||
message.error("保存失败")
|
||||
} finally {
|
||||
setSaveLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
templates,
|
||||
categories,
|
||||
loadingTemplates,
|
||||
loadedTemplateId,
|
||||
setLoadedTemplateId,
|
||||
currentMode,
|
||||
setCurrentMode,
|
||||
currentFilter,
|
||||
setCurrentFilter,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
saveModalOpen,
|
||||
setSaveModalOpen,
|
||||
draftName,
|
||||
setDraftName,
|
||||
draftCategory,
|
||||
setDraftCategory,
|
||||
draftTags,
|
||||
setDraftTags,
|
||||
saveLoading,
|
||||
filteredTemplates,
|
||||
currentTemplate,
|
||||
// methods
|
||||
loadTemplates,
|
||||
handleLoadTemplate,
|
||||
handleModeChange,
|
||||
handleOpenSaveModal,
|
||||
handleSave,
|
||||
}
|
||||
}
|
||||
|
||||
export { FILTER_CATEGORIES }
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
|
||||
import type { ClipData, ClipType, TrimConfig } from "../types"
|
||||
import {
|
||||
DEFAULT_ADD_DURATION,
|
||||
MIN_ADD_DURATION,
|
||||
MAX_ADD_DURATION,
|
||||
ADD_PICKER_WIDTH,
|
||||
TRACK_GAP,
|
||||
} from "../constants/timeline"
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number
|
||||
y: number
|
||||
clipId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间线菜单 Hook
|
||||
* 管理右键菜单和添加片段面板的状态与交互
|
||||
*/
|
||||
export const useTimelineMenus = (
|
||||
clips: ClipData[],
|
||||
currentMode: string,
|
||||
onAddClip: (type: ClipType, duration: number) => void,
|
||||
onClipSplit?: (clipId: string, splitRatio: number) => void,
|
||||
onClipResetTrim?: (clipId: string) => void,
|
||||
onClipRemove?: (clipId: string) => void,
|
||||
) => {
|
||||
/* ── 右键菜单 ── */
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/* ── 添加片段面板 ── */
|
||||
const [showAddPicker, setShowAddPicker] = useState(false)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
const addCardRef = useRef<HTMLDivElement>(null)
|
||||
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
|
||||
|
||||
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
||||
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
|
||||
|
||||
/* ── 根据模式决定可选类型 ── */
|
||||
const availableTypes: ClipType[] = useMemo(
|
||||
() =>
|
||||
currentMode === "voice_over"
|
||||
? ["voice"]
|
||||
: currentMode === "pip"
|
||||
? ["pip"]
|
||||
: ["voice", "pip"],
|
||||
[currentMode],
|
||||
)
|
||||
|
||||
/* ── 默认添加类型:跟随模式 ── */
|
||||
const defaultAddType: ClipType = useMemo(() => {
|
||||
if (currentMode === "voice_over") return "voice"
|
||||
if (currentMode === "pip") return "pip"
|
||||
return "voice"
|
||||
}, [currentMode])
|
||||
|
||||
/* ── "+" 卡片:类型+时长选择状态 ── */
|
||||
const [addType, setAddType] = useState<ClipType>(defaultAddType)
|
||||
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
|
||||
|
||||
/* ── 模式切换时自动同步默认添加类型 ── */
|
||||
useEffect(() => {
|
||||
if (!availableTypes.includes(addType)) {
|
||||
setAddType(defaultAddType)
|
||||
}
|
||||
}, [currentMode, addType, availableTypes, defaultAddType])
|
||||
|
||||
/* ── 计算 picker 初始位置 ── */
|
||||
const updatePickerPosition = useCallback(() => {
|
||||
if (!addCardRef.current) return
|
||||
const rect = addCardRef.current.getBoundingClientRect()
|
||||
const vw = window.innerWidth
|
||||
const roughHeight = 180
|
||||
let top = rect.top - TRACK_GAP - roughHeight
|
||||
if (top < 8) top = 8
|
||||
let right = vw - rect.right
|
||||
if (rect.right - ADD_PICKER_WIDTH < 8) {
|
||||
right = vw - ADD_PICKER_WIDTH - 8
|
||||
}
|
||||
setPickerPos({ top, right })
|
||||
}, [])
|
||||
|
||||
const handleTogglePicker = useCallback(() => {
|
||||
if (!showAddPicker) {
|
||||
setAddType(defaultAddType)
|
||||
updatePickerPosition()
|
||||
}
|
||||
setShowAddPicker((v) => !v)
|
||||
}, [showAddPicker, defaultAddType, updatePickerPosition])
|
||||
|
||||
/* ── 渲染后精确边界校正 ── */
|
||||
useLayoutEffect(() => {
|
||||
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
|
||||
const pickerEl = pickerRef.current
|
||||
const addRect = addCardRef.current.getBoundingClientRect()
|
||||
const pickerH = pickerEl.offsetHeight
|
||||
const vh = window.innerHeight
|
||||
const vw = window.innerWidth
|
||||
|
||||
let top = addRect.top - TRACK_GAP - pickerH
|
||||
if (top < 8) {
|
||||
top = addRect.bottom + TRACK_GAP
|
||||
if (top + pickerH > vh - 8) {
|
||||
top = vh - 8 - pickerH
|
||||
if (top < 8) top = 8
|
||||
}
|
||||
}
|
||||
|
||||
let right = vw - addRect.right
|
||||
const pickerRect = pickerEl.getBoundingClientRect()
|
||||
if (pickerRect.left < 8) {
|
||||
right = vw - ADD_PICKER_WIDTH - 8
|
||||
}
|
||||
|
||||
setPickerPos({ top, right })
|
||||
}, [showAddPicker])
|
||||
|
||||
/* ── 点击外部关闭添加面板 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
||||
setShowAddPicker(false)
|
||||
}
|
||||
}
|
||||
if (showAddPicker) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [showAddPicker])
|
||||
|
||||
/* ── 点击外部关闭右键菜单 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
setContextMenu(null)
|
||||
}
|
||||
}
|
||||
if (contextMenu) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [contextMenu])
|
||||
|
||||
/* ── 确认添加片段 ── */
|
||||
const handleConfirmAdd = useCallback(() => {
|
||||
onAddClip(addType, addDuration)
|
||||
setShowAddPicker(false)
|
||||
}, [onAddClip, addType, addDuration])
|
||||
|
||||
/* ── 右键菜单操作 ── */
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
|
||||
}, [])
|
||||
|
||||
const handleContextSplit = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipSplit?.(contextMenu.clipId, 0.5)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipSplit])
|
||||
|
||||
const handleContextResetTrim = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipResetTrim?.(contextMenu.clipId)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipResetTrim])
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
if (!contextMenu) return
|
||||
onClipRemove?.(contextMenu.clipId)
|
||||
setContextMenu(null)
|
||||
}, [contextMenu, onClipRemove])
|
||||
|
||||
return {
|
||||
// 右键菜单
|
||||
contextMenu,
|
||||
contextMenuRef,
|
||||
handleContextMenu,
|
||||
handleContextSplit,
|
||||
handleContextResetTrim,
|
||||
handleContextDelete,
|
||||
// 添加面板
|
||||
showAddPicker,
|
||||
pickerRef,
|
||||
addCardRef,
|
||||
pickerPos,
|
||||
availableTypes,
|
||||
addType,
|
||||
addDuration,
|
||||
setAddType,
|
||||
setAddDuration,
|
||||
handleTogglePicker,
|
||||
handleConfirmAdd,
|
||||
// 悬停状态
|
||||
hoveredClipId,
|
||||
setHoveredClipId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import type { ClipData, TrimConfig } from "../types"
|
||||
|
||||
interface TrimDragState {
|
||||
clipId: string
|
||||
direction: "left" | "right"
|
||||
startX: number
|
||||
originalTrim: TrimConfig
|
||||
originalDuration: number
|
||||
}
|
||||
|
||||
interface TrimPreviewState {
|
||||
clipId: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
duration: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const MIN_TRIM_DURATION = 1
|
||||
|
||||
/**
|
||||
* 裁剪拖拽 Hook
|
||||
* 拖拽片段两端手柄调整入点/出点,实时显示预览
|
||||
*/
|
||||
export const useTrimDrag = (
|
||||
clips: ClipData[],
|
||||
pps: number,
|
||||
onClipTrim?: (clipId: string, trimConfig: TrimConfig, newDuration: number) => void,
|
||||
) => {
|
||||
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
|
||||
const [trimPreview, setTrimPreview] = useState<TrimPreviewState | null>(null)
|
||||
|
||||
const handleTrimHandleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, clipId: string, direction: "left" | "right") => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const clip = clips.find((c) => c.id === clipId)
|
||||
if (!clip) return
|
||||
|
||||
const trim: TrimConfig = clip.trim_config ?? {
|
||||
start_time: 0,
|
||||
end_time: clip.duration,
|
||||
original_duration: clip.duration,
|
||||
}
|
||||
|
||||
setTrimDrag({
|
||||
clipId,
|
||||
direction,
|
||||
startX: e.clientX,
|
||||
originalTrim: { ...trim },
|
||||
originalDuration: clip.duration,
|
||||
})
|
||||
},
|
||||
[clips],
|
||||
)
|
||||
|
||||
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!trimDrag) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - trimDrag.startX
|
||||
const dtSec = dx / pps
|
||||
const clip = clips.find((c) => c.id === trimDrag.clipId)
|
||||
if (!clip) return
|
||||
|
||||
const origTrim = trimDrag.originalTrim
|
||||
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
|
||||
let newStart = origTrim.start_time
|
||||
let newEnd = origTrim.end_time
|
||||
|
||||
if (trimDrag.direction === "left") {
|
||||
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
|
||||
} else {
|
||||
newEnd = Math.max(origTrim.start_time + MIN_TRIM_DURATION, Math.min(origTrim.end_time + dtSec, origDur))
|
||||
}
|
||||
|
||||
const newDuration = Math.round((newEnd - newStart) * 10) / 10
|
||||
|
||||
setTrimPreview({
|
||||
clipId: trimDrag.clipId,
|
||||
startTime: Math.round(newStart * 10) / 10,
|
||||
endTime: Math.round(newEnd * 10) / 10,
|
||||
duration: newDuration,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
|
||||
const newTrim: TrimConfig = {
|
||||
start_time: trimPreview.startTime,
|
||||
end_time: trimPreview.endTime,
|
||||
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
|
||||
}
|
||||
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
|
||||
}
|
||||
setTrimDrag(null)
|
||||
setTrimPreview(null)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [trimDrag, trimPreview, clips, pps, onClipTrim])
|
||||
|
||||
return {
|
||||
trimDrag,
|
||||
trimPreview,
|
||||
handleTrimHandleMouseDown,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { ClipData } from "../types"
|
||||
|
||||
/**
|
||||
* 计算所有片段的总时长
|
||||
*/
|
||||
export function calculateTotalDuration(clips: ClipData[]): number {
|
||||
return clips.reduce((sum, c) => sum + c.duration, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前选中的模板
|
||||
*/
|
||||
export function getCurrentTemplate(
|
||||
templates: EditingTemplate[],
|
||||
loadedTemplateId: string | null,
|
||||
): EditingTemplate | undefined {
|
||||
return templates.find((t) => t.id === loadedTemplateId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分类和搜索词筛选模板
|
||||
*/
|
||||
export function getFilteredTemplates(
|
||||
templates: EditingTemplate[],
|
||||
currentFilter: string,
|
||||
searchQuery: string,
|
||||
): EditingTemplate[] {
|
||||
return templates.filter((t) => {
|
||||
if (currentFilter !== "全部" && t.category !== currentFilter) return false
|
||||
if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前选中的片段
|
||||
*/
|
||||
export function getSelectedClip(clips: ClipData[], selectedClipId: string | null): ClipData | null {
|
||||
return clips.find((c) => c.id === selectedClipId) || null
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** 格式化时间为 mm:ss */
|
||||
export const formatTime = (sec: number): string => {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化裁剪时间(精确到 0.1 秒) */
|
||||
export const formatTrimTime = (sec: number): string => {
|
||||
return `${sec.toFixed(1)}s`
|
||||
}
|
||||
|
||||
/** 生成时间标尺刻度 */
|
||||
export const generateRulerMarks = (totalDuration: number, step: number): number[] => {
|
||||
const marks: number[] = []
|
||||
for (let t = 0; t <= totalDuration + step; t += step) {
|
||||
marks.push(t)
|
||||
}
|
||||
return marks
|
||||
}
|
||||
Regular → Executable
+56
-810
@@ -2,832 +2,87 @@
|
||||
* 成片库页面 — V21 设计系统
|
||||
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
|
||||
* 使用 useQuery 对接后端真实 API(api/products.ts)
|
||||
*
|
||||
* 代码结构(三阶段重构后):
|
||||
* - types.ts: 类型定义
|
||||
* - constants.ts: 常量配置
|
||||
* - utils/index.ts: 工具函数
|
||||
* - components/ProductCard.tsx: 产品卡片组件
|
||||
* - components/VideoPlayer.tsx: 视频播放器组件
|
||||
* - hooks/useProductList.ts: 列表查询与筛选
|
||||
* - hooks/useProductActions.ts: 单个/批量操作
|
||||
*/
|
||||
import React, { useMemo, useState, useRef, useEffect, useCallback } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { message, Popconfirm } from "antd"
|
||||
import React, { useState } from "react"
|
||||
import { Popconfirm, message } from "antd"
|
||||
import {
|
||||
SearchOutlined,
|
||||
VideoCameraOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
CloseOutlined,
|
||||
CheckOutlined,
|
||||
CloudUploadOutlined,
|
||||
EyeOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button, Input, Select } from "@/components/ui"
|
||||
import {
|
||||
getProducts,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
updateReviewStatus,
|
||||
batchDownload,
|
||||
getBatchDownloadStatus,
|
||||
type ProductItem as ApiProductItem,
|
||||
type ReviewStatus,
|
||||
} from "@/api/products"
|
||||
import type { ProductItem } from "./types"
|
||||
import { ProductCard } from "./components/ProductCard"
|
||||
import { VideoPlayer } from "./components/VideoPlayer"
|
||||
import { useProductList } from "./hooks/useProductList"
|
||||
import { useProductActions } from "./hooks/useProductActions"
|
||||
import "./products.css"
|
||||
|
||||
/* ============================================================
|
||||
* 类型 & 常量
|
||||
* ============================================================ */
|
||||
type ProductStatus = "completed" | "processing" | "review" | "failed"
|
||||
|
||||
interface ProductItem {
|
||||
id: string
|
||||
name: string
|
||||
status: ProductStatus
|
||||
duration: number // 秒
|
||||
date: string
|
||||
gradient: string
|
||||
duplicateRate: number // 查重率百分比
|
||||
isPublished: boolean
|
||||
resolution: string
|
||||
fileSize: number // MB
|
||||
videoUrl?: string
|
||||
thumbnailUrl?: string
|
||||
/** 复核状态 */
|
||||
reviewStatus?: ReviewStatus
|
||||
/** 所属项目 ID */
|
||||
projectId?: string
|
||||
/** 所属项目名称 */
|
||||
projectName?: string
|
||||
}
|
||||
|
||||
/** 将后端 status 映射为前端 ProductStatus */
|
||||
const normalizeStatus = (s: string): ProductStatus => {
|
||||
const map: Record<string, ProductStatus> = {
|
||||
completed: "completed",
|
||||
succeeded: "completed",
|
||||
processing: "processing",
|
||||
running: "processing",
|
||||
review: "review",
|
||||
failed: "failed",
|
||||
error: "failed",
|
||||
}
|
||||
return map[s] ?? "processing"
|
||||
}
|
||||
|
||||
/** 渐变色列表(按 id hash 选取) */
|
||||
const GRADIENTS = [
|
||||
"linear-gradient(135deg, #1e1b4b 0%, #4f46e5 50%, #818cf8 100%)",
|
||||
"linear-gradient(135deg, #831843 0%, #db2777 50%, #f472b6 100%)",
|
||||
"linear-gradient(135deg, #064e3b 0%, #059669 50%, #34d399 100%)",
|
||||
"linear-gradient(135deg, #78350f 0%, #d97706 50%, #fbbf24 100%)",
|
||||
"linear-gradient(135deg, #1e3a5f 0%, #2563eb 50%, #60a5fa 100%)",
|
||||
"linear-gradient(135deg, #581c87 0%, #9333ea 50%, #c084fc 100%)",
|
||||
"linear-gradient(135deg, #7f1d1d 0%, #dc2626 50%, #f87171 100%)",
|
||||
"linear-gradient(135deg, #134e4a 0%, #0d9488 50%, #5eead4 100%)",
|
||||
"linear-gradient(135deg, #1e1b4b 0%, #6366f1 50%, #a5b4fc 100%)",
|
||||
"linear-gradient(135deg, #3b0764 0%, #7c3aed 50%, #a78bfa 100%)",
|
||||
"linear-gradient(135deg, #052e16 0%, #16a34a 50%, #86efac 100%)",
|
||||
"linear-gradient(135deg, #450a0e 0%, #e11d48 50%, #fb7185 100%)",
|
||||
]
|
||||
|
||||
/** 根据 id 生成稳定的渐变色 */
|
||||
const gradientForId = (id: string): string => {
|
||||
let hash = 0
|
||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0
|
||||
return GRADIENTS[Math.abs(hash) % GRADIENTS.length]
|
||||
}
|
||||
|
||||
/** 将后端 ProductItem 映射为前端 ProductItem */
|
||||
const mapApiProduct = (item: ApiProductItem): ProductItem => ({
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
status: normalizeStatus(item.status),
|
||||
duration: item.duration_seconds ?? 0,
|
||||
date: item.created_at ? formatDateTime(item.created_at) : "—",
|
||||
gradient: gradientForId(item.id),
|
||||
duplicateRate: item.duplicate_rate ?? 0,
|
||||
isPublished: false, // TODO: 后端发布状态字段待补齐
|
||||
resolution: item.resolution ?? "1080×1920",
|
||||
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
videoUrl: item.video_url,
|
||||
thumbnailUrl: item.thumbnail_url,
|
||||
reviewStatus: item.review_status,
|
||||
projectId: item.project_id,
|
||||
projectName: item.project_name,
|
||||
})
|
||||
|
||||
/* ============================================================
|
||||
* 工具函数
|
||||
* ============================================================ */
|
||||
const statusConfig: Record<ProductStatus, { text: string; className: string }> = {
|
||||
completed: { text: "已完成", className: "completed" },
|
||||
processing: { text: "处理中", className: "processing" },
|
||||
review: { text: "待复核", className: "review" },
|
||||
failed: { text: "失败", className: "failed" },
|
||||
}
|
||||
|
||||
/** 格式化时间 mm:ss */
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatSize = (mb: number): string => {
|
||||
if (mb <= 0) return "-"
|
||||
if (mb < 1) return `${(mb * 1024).toFixed(0)} KB`
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** 格式化日期时间(MM-DD HH:mm) */
|
||||
const formatDateTime = (isoString: string): string => {
|
||||
const d = new Date(isoString)
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, "0")
|
||||
const day = d.getDate().toString().padStart(2, "0")
|
||||
const hour = d.getHours().toString().padStart(2, "0")
|
||||
const minute = d.getMinutes().toString().padStart(2, "0")
|
||||
return `${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
/** 复核状态配置 */
|
||||
const reviewStatusConfig: Record<ReviewStatus, { text: string; className: string }> = {
|
||||
pending_review: { text: "待复核", className: "review-pending" },
|
||||
approved: { text: "已通过", className: "review-approved" },
|
||||
rejected: { text: "需修改", className: "review-rejected" },
|
||||
}
|
||||
|
||||
/** 复核状态循环顺序 */
|
||||
const REVIEW_STATUS_CYCLE: ReviewStatus[] = ["pending_review", "approved", "rejected"]
|
||||
|
||||
/** 获取下一个复核状态 */
|
||||
const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => {
|
||||
if (!current) return "approved"
|
||||
const idx = REVIEW_STATUS_CYCLE.indexOf(current)
|
||||
return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length]
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* ProductCard 组件
|
||||
* ============================================================ */
|
||||
const ProductCard: React.FC<{
|
||||
product: ProductItem
|
||||
isSelected: boolean
|
||||
batchMode: boolean
|
||||
onToggleSelect: (id: string) => void
|
||||
onPlay: (product: ProductItem) => void
|
||||
onDownload: (product: ProductItem) => void
|
||||
onShare: (product: ProductItem) => void
|
||||
onDelete: (id: string) => void
|
||||
onPublish: (product: ProductItem) => void
|
||||
onReviewStatusChange: (id: string) => void
|
||||
}> = ({
|
||||
product,
|
||||
isSelected,
|
||||
batchMode,
|
||||
onToggleSelect,
|
||||
onPlay,
|
||||
onDownload,
|
||||
onShare,
|
||||
onDelete,
|
||||
onPublish,
|
||||
onReviewStatusChange,
|
||||
}) => {
|
||||
const st = statusConfig[product.status]
|
||||
|
||||
/** 查重率样式 */
|
||||
const dupClass =
|
||||
product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"
|
||||
|
||||
/** 点击卡片 */
|
||||
const handleCardClick = () => {
|
||||
if (batchMode) {
|
||||
onToggleSelect(product.id)
|
||||
} else {
|
||||
onPlay(product)
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击复选框 */
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(product.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* 复选框(左上角) */}
|
||||
<div
|
||||
className={`xx-product-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={handleCheckboxClick}
|
||||
title={isSelected ? "取消选择" : "选择"}
|
||||
>
|
||||
{isSelected && <CheckOutlined />}
|
||||
</div>
|
||||
|
||||
{/* 已发布徽章(右上角) */}
|
||||
{product.isPublished && <div className="xx-product-badge">✅ 已发布</div>}
|
||||
|
||||
{/* 复核状态标签(右上角) */}
|
||||
{product.reviewStatus && (
|
||||
<div
|
||||
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReviewStatusChange(product.id)
|
||||
}}
|
||||
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text})`}
|
||||
>
|
||||
{reviewStatusConfig[product.reviewStatus].text}
|
||||
</div>
|
||||
)}
|
||||
{/* 无复核状态时显示"待复核"入口 */}
|
||||
{!product.reviewStatus && (
|
||||
<div
|
||||
className="xx-product-review-tag review-pending"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReviewStatusChange(product.id)
|
||||
}}
|
||||
title="点击设置复核状态"
|
||||
>
|
||||
待复核
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
{product.thumbnailUrl ? (
|
||||
<img
|
||||
className="xx-product-thumb-bg"
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
|
||||
)}
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
{product.duration > 0 && (
|
||||
<span className="xx-product-duration">{formatTime(product.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-product-info">
|
||||
<h4 className="xx-product-title" title={product.name}>
|
||||
{product.name}
|
||||
</h4>
|
||||
<div className="xx-product-meta">
|
||||
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
|
||||
<span className="xx-product-date">{product.date}</span>
|
||||
</div>
|
||||
{product.duplicateRate > 0 && (
|
||||
<span className={`xx-product-dup-rate ${dupClass}`}>
|
||||
查重率:{product.duplicateRate.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-product-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
onClick={() => onDownload(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="下载"
|
||||
>
|
||||
<DownloadOutlined /> 下载
|
||||
</button>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
onClick={() => onShare(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="分享"
|
||||
>
|
||||
<ShareAltOutlined /> 分享
|
||||
</button>
|
||||
{product.isPublished ? (
|
||||
<span className="xx-product-action-btn success" style={{ cursor: "default" }}>
|
||||
✅ 已发布
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="xx-product-action-btn primary"
|
||||
onClick={() => onPublish(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="发布"
|
||||
>
|
||||
<CloudUploadOutlined /> 发布
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮(不在批量模式下显示) */}
|
||||
{!batchMode && (
|
||||
<div style={{ padding: "0 14px 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<Popconfirm
|
||||
title={`确定删除"${product.name}"?`}
|
||||
onConfirm={() => onDelete(product.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
style={{ width: "100%", color: "#dc2626" }}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined /> 删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* VideoPlayer 弹窗组件
|
||||
* ============================================================ */
|
||||
const VideoPlayer: React.FC<{
|
||||
product: ProductItem
|
||||
onClose: () => void
|
||||
onDownload: (product: ProductItem) => void
|
||||
onShare: (product: ProductItem) => void
|
||||
onViewDetail: (product: ProductItem) => void
|
||||
}> = ({ product, onClose, onDownload, onShare, onViewDetail }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(product.duration)
|
||||
|
||||
const hasVideo = !!product.videoUrl
|
||||
|
||||
/** 播放/暂停 */
|
||||
const handlePlayPause = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (isPlaying) {
|
||||
video.pause()
|
||||
} else {
|
||||
video.play().catch(() => {})
|
||||
}
|
||||
setIsPlaying(!isPlaying)
|
||||
}, [isPlaying])
|
||||
|
||||
/** 视频事件监听 */
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const onTime = () => setCurrentTime(video.currentTime)
|
||||
const onDur = () => setDuration(video.duration || product.duration)
|
||||
const onEnd = () => setIsPlaying(false)
|
||||
video.addEventListener("timeupdate", onTime)
|
||||
video.addEventListener("loadedmetadata", onDur)
|
||||
video.addEventListener("ended", onEnd)
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", onTime)
|
||||
video.removeEventListener("loadedmetadata", onDur)
|
||||
video.removeEventListener("ended", onEnd)
|
||||
}
|
||||
}, [product.duration])
|
||||
|
||||
/** 进度条点击 */
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
const newTime = percent * duration
|
||||
setCurrentTime(newTime)
|
||||
if (videoRef.current) videoRef.current.currentTime = newTime
|
||||
}
|
||||
|
||||
/** ESC 关闭 */
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose()
|
||||
}
|
||||
window.addEventListener("keydown", handleKey)
|
||||
return () => window.removeEventListener("keydown", handleKey)
|
||||
}, [onClose])
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="xx-player-overlay" onClick={onClose}>
|
||||
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 视频区域 */}
|
||||
<div className="xx-player-video-wrap">
|
||||
{hasVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={product.videoUrl}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
) : (
|
||||
/* 无视频 URL 时用渐变占位 */
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: product.gradient,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
fontSize: "64px",
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button className="xx-player-play-btn" onClick={handlePlayPause}>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button className="xx-player-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="xx-player-progress-wrap">
|
||||
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
|
||||
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="xx-player-time">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-player-info">
|
||||
<h3 className="xx-player-title">{product.name}</h3>
|
||||
<div className="xx-player-details">
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">分辨率:</span>
|
||||
<span className="xx-player-detail-value">{product.resolution}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">时长:</span>
|
||||
<span className="xx-player-detail-value">{formatTime(product.duration)}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">文件大小:</span>
|
||||
<span className="xx-player-detail-value">{formatSize(product.fileSize)}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">查重率:</span>
|
||||
<span className="xx-player-detail-value">
|
||||
{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="xx-player-footer">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => onDownload(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
下载视频
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={() => onShare(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => onViewDetail(product)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const ProductLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
/* ── 获取成品列表 ── */
|
||||
const {
|
||||
data: apiProducts = [],
|
||||
products,
|
||||
filteredProducts,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: () => getProducts(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
|
||||
const products = useMemo(() => {
|
||||
const list = (Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct)
|
||||
// 按创建时间倒序(最新的在最前面),无时间的排最后
|
||||
return list.sort((a, b) => {
|
||||
if (!a.date || a.date === "—") return 1
|
||||
if (!b.date || b.date === "—") return -1
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
})
|
||||
}, [apiProducts])
|
||||
|
||||
/* ── 删除 mutation ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProduct,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
message.success("已删除")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除失败")
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 复核状态 mutation ── */
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
|
||||
updateReviewStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
message.success("复核状态已更新")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新复核状态失败")
|
||||
},
|
||||
})
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all")
|
||||
const [filterTime, setFilterTime] = useState<string>("all")
|
||||
const [filterDuration, setFilterDuration] = useState<string>("all")
|
||||
const [filterProject, setFilterProject] = useState<string>("all")
|
||||
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all")
|
||||
|
||||
/* 批量操作 */
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
filterTime,
|
||||
setFilterTime,
|
||||
filterDuration,
|
||||
setFilterDuration,
|
||||
filterProject,
|
||||
setFilterProject,
|
||||
filterReviewStatus,
|
||||
setFilterReviewStatus,
|
||||
projectOptions,
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
handleSelectAll,
|
||||
handleToggleSelect,
|
||||
clearSelection,
|
||||
} = useProductList()
|
||||
|
||||
/* 播放器 */
|
||||
const [playingProduct, setPlayingProduct] = useState<ProductItem | null>(null)
|
||||
|
||||
/* 派生数据 */
|
||||
const batchMode = selectedIds.size > 0
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
let list = products
|
||||
|
||||
/* 状态筛选 */
|
||||
if (filterStatus !== "all") {
|
||||
list = list.filter((p) => p.status === filterStatus)
|
||||
}
|
||||
|
||||
/* 时间筛选 */
|
||||
if (filterTime !== "all") {
|
||||
const now = new Date()
|
||||
list = list.filter((p) => {
|
||||
const d = new Date(p.date)
|
||||
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
||||
switch (filterTime) {
|
||||
case "today":
|
||||
return diff < 1
|
||||
case "week":
|
||||
return diff <= 7
|
||||
case "month":
|
||||
return diff <= 30
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* 时长筛选 */
|
||||
if (filterDuration !== "all") {
|
||||
list = list.filter((p) => {
|
||||
switch (filterDuration) {
|
||||
case "short":
|
||||
return p.duration > 0 && p.duration <= 60
|
||||
case "medium":
|
||||
return p.duration > 60 && p.duration <= 180
|
||||
case "long":
|
||||
return p.duration > 180
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* 项目筛选 */
|
||||
if (filterProject !== "all") {
|
||||
list = list.filter((p) => p.projectId === filterProject)
|
||||
}
|
||||
|
||||
/* 复核状态筛选 */
|
||||
if (filterReviewStatus !== "all") {
|
||||
if (filterReviewStatus === "none") {
|
||||
list = list.filter((p) => !p.reviewStatus)
|
||||
} else {
|
||||
list = list.filter((p) => p.reviewStatus === filterReviewStatus)
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter((p) => p.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
}, [
|
||||
const {
|
||||
handleDownload,
|
||||
handleShare,
|
||||
handleViewDetail,
|
||||
handleDelete,
|
||||
handlePublish,
|
||||
handleReviewStatusChange,
|
||||
handleBatchDownload,
|
||||
handleBatchDelete,
|
||||
handleBatchPublish,
|
||||
batchDownloading,
|
||||
} = useProductActions({
|
||||
selectedIds,
|
||||
clearSelection,
|
||||
products,
|
||||
filterStatus,
|
||||
filterTime,
|
||||
filterDuration,
|
||||
filterProject,
|
||||
filterReviewStatus,
|
||||
searchText,
|
||||
])
|
||||
setPlayingProduct,
|
||||
})
|
||||
|
||||
/* 全选 */
|
||||
const allSelected =
|
||||
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredProducts.map((p) => p.id)))
|
||||
}
|
||||
}
|
||||
|
||||
/* 切换单个选择 */
|
||||
const handleToggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/* 下载 — 调用真实 API 获取下载链接 */
|
||||
const handleDownload = async (product: ProductItem) => {
|
||||
if (product.status !== "completed") return
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(product.id)
|
||||
// 打开下载链接
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = ""
|
||||
a.click()
|
||||
message.success(`正在下载"${product.name}"`)
|
||||
} catch {
|
||||
message.error("获取下载链接失败")
|
||||
}
|
||||
}
|
||||
|
||||
/* 分享 */
|
||||
const handleShare = (product: ProductItem) => {
|
||||
if (product.status !== "completed") return
|
||||
const link = `${window.location.origin}/share/${product.id}`
|
||||
navigator.clipboard?.writeText(link).then(
|
||||
() => message.success(`分享链接已复制:${link}`),
|
||||
() => message.success(`分享链接:${link}(请手动复制)`),
|
||||
)
|
||||
}
|
||||
|
||||
/* 查看详情 — 跳转到产品详情页 */
|
||||
const handleViewDetail = (product: ProductItem) => {
|
||||
setPlayingProduct(null) // 关闭播放器
|
||||
navigate(`/app/products/${product.id}`)
|
||||
}
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = (id: string) => {
|
||||
deleteMutation.mutate(id)
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/* 发布 — TODO: 后端发布 API 待实现 */
|
||||
const handlePublish = (_product: ProductItem) => {
|
||||
// TODO: 对接后端发布 API(当前后端未提供发布接口)
|
||||
message.info("发布功能待后端 API 补齐")
|
||||
}
|
||||
|
||||
/* 切换复核状态 */
|
||||
const handleReviewStatusChange = (id: string) => {
|
||||
const current = products.find((p) => p.id === id)?.reviewStatus
|
||||
const nextStatus = getNextReviewStatus(current)
|
||||
reviewMutation.mutate({ id, status: nextStatus })
|
||||
}
|
||||
|
||||
/* 批量下载 — 使用 batch-download API + 轮询 */
|
||||
const [batchDownloading, setBatchDownloading] = useState(false)
|
||||
|
||||
const handleBatchDownload = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0) return
|
||||
setBatchDownloading(true)
|
||||
try {
|
||||
// 发起批量下载任务
|
||||
const { job_id } = await batchDownload(ids)
|
||||
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`)
|
||||
|
||||
// 轮询下载状态(最多 60 次,每次 2 秒)
|
||||
let attempts = 0
|
||||
const maxAttempts = 60
|
||||
const poll = async (): Promise<void> => {
|
||||
if (attempts >= maxAttempts) {
|
||||
message.warning("打包超时,请稍后在消息中心查看")
|
||||
return
|
||||
}
|
||||
attempts++
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
const status = await getBatchDownloadStatus(job_id)
|
||||
if (status.status === "completed" && status.download_url) {
|
||||
const a = document.createElement("a")
|
||||
a.href = status.download_url
|
||||
a.download = ""
|
||||
a.click()
|
||||
message.success(`已打包下载 ${ids.length} 个视频`)
|
||||
setSelectedIds(new Set())
|
||||
} else if (status.status === "failed") {
|
||||
message.error("批量下载失败,请重试")
|
||||
} else {
|
||||
// 继续轮询
|
||||
await poll()
|
||||
}
|
||||
}
|
||||
await poll()
|
||||
} catch {
|
||||
message.error("发起批量下载失败")
|
||||
} finally {
|
||||
setBatchDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 批量删除 */
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
let successCount = 0
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await deleteProduct(id)
|
||||
successCount++
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
setSelectedIds(new Set())
|
||||
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`)
|
||||
}
|
||||
|
||||
/* 批量发布 */
|
||||
const handleBatchPublish = () => {
|
||||
// TODO: 对接后端批量发布 API
|
||||
message.info("批量发布功能待后端 API 补齐")
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
// ── Loading 状态 ──
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
@@ -939,7 +194,7 @@ const ProductLibrary: React.FC = () => {
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => setSelectedIds(new Set())}>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
@@ -997,16 +252,7 @@ const ProductLibrary: React.FC = () => {
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部项目" },
|
||||
...Array.from(
|
||||
new Map(
|
||||
products
|
||||
.filter((p) => p.projectId && p.projectName)
|
||||
.map((p) => [p.projectId!, p.projectName!] as const),
|
||||
),
|
||||
).map(([id, name]) => ({
|
||||
value: id as string,
|
||||
label: name as string,
|
||||
})),
|
||||
...projectOptions,
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import React from "react"
|
||||
import { Popconfirm } from "antd"
|
||||
import {
|
||||
CheckOutlined,
|
||||
PlayCircleOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
DeleteOutlined,
|
||||
CloudUploadOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { ProductItem } from "../types"
|
||||
import { statusConfig, reviewStatusConfig } from "../constants"
|
||||
import { formatTime } from "../utils"
|
||||
|
||||
interface ProductCardProps {
|
||||
product: ProductItem
|
||||
isSelected: boolean
|
||||
batchMode: boolean
|
||||
onToggleSelect: (id: string) => void
|
||||
onPlay: (product: ProductItem) => void
|
||||
onDownload: (product: ProductItem) => void
|
||||
onShare: (product: ProductItem) => void
|
||||
onDelete: (id: string) => void
|
||||
onPublish: (product: ProductItem) => void
|
||||
onReviewStatusChange: (id: string) => void
|
||||
}
|
||||
|
||||
export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
product,
|
||||
isSelected,
|
||||
batchMode,
|
||||
onToggleSelect,
|
||||
onPlay,
|
||||
onDownload,
|
||||
onShare,
|
||||
onDelete,
|
||||
onPublish,
|
||||
onReviewStatusChange,
|
||||
}) => {
|
||||
const st = statusConfig[product.status]
|
||||
|
||||
/** 查重率样式 */
|
||||
const dupClass =
|
||||
product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"
|
||||
|
||||
/** 点击卡片 */
|
||||
const handleCardClick = () => {
|
||||
if (batchMode) {
|
||||
onToggleSelect(product.id)
|
||||
} else {
|
||||
onPlay(product)
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击复选框 */
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(product.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* 复选框(左上角) */}
|
||||
<div
|
||||
className={`xx-product-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={handleCheckboxClick}
|
||||
title={isSelected ? "取消选择" : "选择"}
|
||||
>
|
||||
{isSelected && <CheckOutlined />}
|
||||
</div>
|
||||
|
||||
{/* 已发布徽章(右上角) */}
|
||||
{product.isPublished && <div className="xx-product-badge">✅ 已发布</div>}
|
||||
|
||||
{/* 复核状态标签(右上角) */}
|
||||
{product.reviewStatus && (
|
||||
<div
|
||||
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReviewStatusChange(product.id)
|
||||
}}
|
||||
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text})`}
|
||||
>
|
||||
{reviewStatusConfig[product.reviewStatus].text}
|
||||
</div>
|
||||
)}
|
||||
{/* 无复核状态时显示"待复核"入口 */}
|
||||
{!product.reviewStatus && (
|
||||
<div
|
||||
className="xx-product-review-tag review-pending"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReviewStatusChange(product.id)
|
||||
}}
|
||||
title="点击设置复核状态"
|
||||
>
|
||||
待复核
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
{product.thumbnailUrl ? (
|
||||
<img
|
||||
className="xx-product-thumb-bg"
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
|
||||
)}
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
{product.duration > 0 && (
|
||||
<span className="xx-product-duration">{formatTime(product.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-product-info">
|
||||
<h4 className="xx-product-title" title={product.name}>
|
||||
{product.name}
|
||||
</h4>
|
||||
<div className="xx-product-meta">
|
||||
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
|
||||
<span className="xx-product-date">{product.date}</span>
|
||||
</div>
|
||||
{product.duplicateRate > 0 && (
|
||||
<span className={`xx-product-dup-rate ${dupClass}`}>
|
||||
查重率:{product.duplicateRate.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-product-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
onClick={() => onDownload(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="下载"
|
||||
>
|
||||
<DownloadOutlined /> 下载
|
||||
</button>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
onClick={() => onShare(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="分享"
|
||||
>
|
||||
<ShareAltOutlined /> 分享
|
||||
</button>
|
||||
{product.isPublished ? (
|
||||
<span className="xx-product-action-btn success" style={{ cursor: "default" }}>
|
||||
✅ 已发布
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="xx-product-action-btn primary"
|
||||
onClick={() => onPublish(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="发布"
|
||||
>
|
||||
<CloudUploadOutlined /> 发布
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮(不在批量模式下显示) */}
|
||||
{!batchMode && (
|
||||
<div style={{ padding: "0 14px 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<Popconfirm
|
||||
title={`确定删除"${product.name}"?`}
|
||||
onConfirm={() => onDelete(product.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
style={{ width: "100%", color: "#dc2626" }}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined /> 删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react"
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
CloseOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
EyeOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { ProductItem } from "../types"
|
||||
import { formatTime, formatSize } from "../utils"
|
||||
|
||||
interface VideoPlayerProps {
|
||||
product: ProductItem
|
||||
onClose: () => void
|
||||
onDownload: (product: ProductItem) => void
|
||||
onShare: (product: ProductItem) => void
|
||||
onViewDetail: (product: ProductItem) => void
|
||||
}
|
||||
|
||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
product,
|
||||
onClose,
|
||||
onDownload,
|
||||
onShare,
|
||||
onViewDetail,
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(product.duration)
|
||||
|
||||
const hasVideo = !!product.videoUrl
|
||||
|
||||
/** 播放/暂停 */
|
||||
const handlePlayPause = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (isPlaying) {
|
||||
video.pause()
|
||||
} else {
|
||||
video.play().catch(() => {})
|
||||
}
|
||||
setIsPlaying(!isPlaying)
|
||||
}, [isPlaying])
|
||||
|
||||
/** 视频事件监听 */
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const onTime = () => setCurrentTime(video.currentTime)
|
||||
const onDur = () => setDuration(video.duration || product.duration)
|
||||
const onEnd = () => setIsPlaying(false)
|
||||
video.addEventListener("timeupdate", onTime)
|
||||
video.addEventListener("loadedmetadata", onDur)
|
||||
video.addEventListener("ended", onEnd)
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", onTime)
|
||||
video.removeEventListener("loadedmetadata", onDur)
|
||||
video.removeEventListener("ended", onEnd)
|
||||
}
|
||||
}, [product.duration])
|
||||
|
||||
/** 进度条点击 */
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
const newTime = percent * duration
|
||||
setCurrentTime(newTime)
|
||||
if (videoRef.current) videoRef.current.currentTime = newTime
|
||||
}
|
||||
|
||||
/** ESC 关闭 */
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose()
|
||||
}
|
||||
window.addEventListener("keydown", handleKey)
|
||||
return () => window.removeEventListener("keydown", handleKey)
|
||||
}, [onClose])
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="xx-player-overlay" onClick={onClose}>
|
||||
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 视频区域 */}
|
||||
<div className="xx-player-video-wrap">
|
||||
{hasVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={product.videoUrl}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
) : (
|
||||
/* 无视频 URL 时用渐变占位 */
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: product.gradient,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
fontSize: "64px",
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button className="xx-player-play-btn" onClick={handlePlayPause}>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button className="xx-player-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="xx-player-progress-wrap">
|
||||
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
|
||||
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="xx-player-time">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-player-info">
|
||||
<h3 className="xx-player-title">{product.name}</h3>
|
||||
<div className="xx-player-details">
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">分辨率:</span>
|
||||
<span className="xx-player-detail-value">{product.resolution}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">时长:</span>
|
||||
<span className="xx-player-detail-value">{formatTime(product.duration)}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">文件大小:</span>
|
||||
<span className="xx-player-detail-value">{formatSize(product.fileSize)}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">查重率:</span>
|
||||
<span className="xx-player-detail-value">
|
||||
{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="xx-player-footer">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => onDownload(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
下载视频
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={() => onShare(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => onViewDetail(product)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReviewStatus } from "@/api/products"
|
||||
import type { ProductStatus } from "./types"
|
||||
|
||||
/** 渐变色列表(按 id hash 选取) */
|
||||
export const GRADIENTS = [
|
||||
"linear-gradient(135deg, #1e1b4b 0%, #4f46e5 50%, #818cf8 100%)",
|
||||
"linear-gradient(135deg, #831843 0%, #db2777 50%, #f472b6 100%)",
|
||||
"linear-gradient(135deg, #064e3b 0%, #059669 50%, #34d399 100%)",
|
||||
"linear-gradient(135deg, #78350f 0%, #d97706 50%, #fbbf24 100%)",
|
||||
"linear-gradient(135deg, #1e3a5f 0%, #2563eb 50%, #60a5fa 100%)",
|
||||
"linear-gradient(135deg, #581c87 0%, #9333ea 50%, #c084fc 100%)",
|
||||
"linear-gradient(135deg, #7f1d1d 0%, #dc2626 50%, #f87171 100%)",
|
||||
"linear-gradient(135deg, #134e4a 0%, #0d9488 50%, #5eead4 100%)",
|
||||
"linear-gradient(135deg, #1e1b4b 0%, #6366f1 50%, #a5b4fc 100%)",
|
||||
"linear-gradient(135deg, #3b0764 0%, #7c3aed 50%, #a78bfa 100%)",
|
||||
"linear-gradient(135deg, #052e16 0%, #16a34a 50%, #86efac 100%)",
|
||||
"linear-gradient(135deg, #450a0e 0%, #e11d48 50%, #fb7185 100%)",
|
||||
]
|
||||
|
||||
export const statusConfig: Record<ProductStatus, { text: string; className: string }> = {
|
||||
completed: { text: "已完成", className: "completed" },
|
||||
processing: { text: "处理中", className: "processing" },
|
||||
review: { text: "待复核", className: "review" },
|
||||
failed: { text: "失败", className: "failed" },
|
||||
}
|
||||
|
||||
export const reviewStatusConfig: Record<ReviewStatus, { text: string; className: string }> = {
|
||||
pending_review: { text: "待复核", className: "review-pending" },
|
||||
approved: { text: "已通过", className: "review-approved" },
|
||||
rejected: { text: "需修改", className: "review-rejected" },
|
||||
}
|
||||
|
||||
/** 复核状态循环顺序 */
|
||||
export const REVIEW_STATUS_CYCLE: ReviewStatus[] = ["pending_review", "approved", "rejected"]
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
updateReviewStatus,
|
||||
batchDownload,
|
||||
getBatchDownloadStatus,
|
||||
type ReviewStatus,
|
||||
} from "@/api/products"
|
||||
import type { ProductItem } from "../types"
|
||||
import { getNextReviewStatus } from "../utils"
|
||||
|
||||
interface UseProductActionsOptions {
|
||||
selectedIds: Set<string>
|
||||
clearSelection: () => void
|
||||
products: ProductItem[]
|
||||
setPlayingProduct: (product: ProductItem | null) => void
|
||||
}
|
||||
|
||||
export const useProductActions = ({
|
||||
selectedIds,
|
||||
clearSelection,
|
||||
products,
|
||||
setPlayingProduct,
|
||||
}: UseProductActionsOptions) => {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
/* ── 删除 mutation ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProduct,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
message.success("已删除")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除失败")
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 复核状态 mutation ── */
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
|
||||
updateReviewStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
message.success("复核状态已更新")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新复核状态失败")
|
||||
},
|
||||
})
|
||||
|
||||
/* 下载 — 调用真实 API 获取下载链接 */
|
||||
const handleDownload = async (product: ProductItem) => {
|
||||
if (product.status !== "completed") return
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(product.id)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = ""
|
||||
a.click()
|
||||
message.success(`正在下载"${product.name}"`)
|
||||
} catch {
|
||||
message.error("获取下载链接失败")
|
||||
}
|
||||
}
|
||||
|
||||
/* 分享 */
|
||||
const handleShare = (product: ProductItem) => {
|
||||
if (product.status !== "completed") return
|
||||
const link = `${window.location.origin}/share/${product.id}`
|
||||
navigator.clipboard?.writeText(link).then(
|
||||
() => message.success(`分享链接已复制:${link}`),
|
||||
() => message.success(`分享链接:${link}(请手动复制)`),
|
||||
)
|
||||
}
|
||||
|
||||
/* 查看详情 — 跳转到产品详情页 */
|
||||
const handleViewDetail = (product: ProductItem) => {
|
||||
setPlayingProduct(null)
|
||||
navigate(`/app/products/${product.id}`)
|
||||
}
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = (id: string) => {
|
||||
deleteMutation.mutate(id)
|
||||
}
|
||||
|
||||
/* 发布 — TODO: 后端发布 API 待实现 */
|
||||
const handlePublish = (_product: ProductItem) => {
|
||||
message.info("发布功能待后端 API 补齐")
|
||||
}
|
||||
|
||||
/* 切换复核状态 */
|
||||
const handleReviewStatusChange = (id: string) => {
|
||||
const current = products.find((p) => p.id === id)?.reviewStatus
|
||||
const nextStatus = getNextReviewStatus(current)
|
||||
reviewMutation.mutate({ id, status: nextStatus })
|
||||
}
|
||||
|
||||
/* 批量下载 */
|
||||
const [batchDownloading, setBatchDownloading] = useState(false)
|
||||
|
||||
const handleBatchDownload = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0) return
|
||||
setBatchDownloading(true)
|
||||
try {
|
||||
const { job_id } = await batchDownload(ids)
|
||||
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`)
|
||||
|
||||
let attempts = 0
|
||||
const maxAttempts = 60
|
||||
const poll = async (): Promise<void> => {
|
||||
if (attempts >= maxAttempts) {
|
||||
message.warning("打包超时,请稍后在消息中心查看")
|
||||
return
|
||||
}
|
||||
attempts++
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
const status = await getBatchDownloadStatus(job_id)
|
||||
if (status.status === "completed" && status.download_url) {
|
||||
const a = document.createElement("a")
|
||||
a.href = status.download_url
|
||||
a.download = ""
|
||||
a.click()
|
||||
message.success(`已打包下载 ${ids.length} 个视频`)
|
||||
clearSelection()
|
||||
} else if (status.status === "failed") {
|
||||
message.error("批量下载失败,请重试")
|
||||
} else {
|
||||
await poll()
|
||||
}
|
||||
}
|
||||
await poll()
|
||||
} catch {
|
||||
message.error("发起批量下载失败")
|
||||
} finally {
|
||||
setBatchDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 批量删除 */
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
let successCount = 0
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await deleteProduct(id)
|
||||
successCount++
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
clearSelection()
|
||||
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`)
|
||||
}
|
||||
|
||||
/* 批量发布 */
|
||||
const handleBatchPublish = () => {
|
||||
message.info("批量发布功能待后端 API 补齐")
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
return {
|
||||
// 单个操作
|
||||
handleDownload,
|
||||
handleShare,
|
||||
handleViewDetail,
|
||||
handleDelete,
|
||||
handlePublish,
|
||||
handleReviewStatusChange,
|
||||
// 批量操作
|
||||
handleBatchDownload,
|
||||
handleBatchDelete,
|
||||
handleBatchPublish,
|
||||
batchDownloading,
|
||||
// mutation 状态
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isUpdatingReview: reviewMutation.isPending,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
|
||||
import type { ProductItem } from "../types"
|
||||
import { mapApiProduct } from "../utils"
|
||||
|
||||
/** 筛选选项类型 */
|
||||
export interface Filters {
|
||||
searchText: string
|
||||
filterStatus: string
|
||||
filterTime: string
|
||||
filterDuration: string
|
||||
filterProject: string
|
||||
filterReviewStatus: string
|
||||
}
|
||||
|
||||
/** 项目选项列表 */
|
||||
const getProjectOptions = (products: ProductItem[]) =>
|
||||
Array.from(
|
||||
new Map(
|
||||
products
|
||||
.filter((p) => p.projectId && p.projectName)
|
||||
.map((p) => [p.projectId!, p.projectName!] as const),
|
||||
),
|
||||
).map(([id, name]) => ({
|
||||
value: id as string,
|
||||
label: name as string,
|
||||
}))
|
||||
|
||||
export const useProductList = () => {
|
||||
/* ── 获取成品列表 ── */
|
||||
const {
|
||||
data: apiProducts = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: () => getProducts(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
|
||||
const products = useMemo(() => {
|
||||
const list = (Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct)
|
||||
// 按创建时间倒序(最新的在最前面),无时间的排最后
|
||||
return list.sort((a, b) => {
|
||||
if (!a.date || a.date === "—") return 1
|
||||
if (!b.date || b.date === "—") return -1
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
})
|
||||
}, [apiProducts])
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all")
|
||||
const [filterTime, setFilterTime] = useState<string>("all")
|
||||
const [filterDuration, setFilterDuration] = useState<string>("all")
|
||||
const [filterProject, setFilterProject] = useState<string>("all")
|
||||
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all")
|
||||
|
||||
/* 批量选择 */
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
/* 派生数据 */
|
||||
const batchMode = selectedIds.size > 0
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
let list = products
|
||||
|
||||
/* 状态筛选 */
|
||||
if (filterStatus !== "all") {
|
||||
list = list.filter((p) => p.status === filterStatus)
|
||||
}
|
||||
|
||||
/* 时间筛选 */
|
||||
if (filterTime !== "all") {
|
||||
const now = new Date()
|
||||
list = list.filter((p) => {
|
||||
const d = new Date(p.date)
|
||||
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
||||
switch (filterTime) {
|
||||
case "today":
|
||||
return diff < 1
|
||||
case "week":
|
||||
return diff <= 7
|
||||
case "month":
|
||||
return diff <= 30
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* 时长筛选 */
|
||||
if (filterDuration !== "all") {
|
||||
list = list.filter((p) => {
|
||||
switch (filterDuration) {
|
||||
case "short":
|
||||
return p.duration > 0 && p.duration <= 60
|
||||
case "medium":
|
||||
return p.duration > 60 && p.duration <= 180
|
||||
case "long":
|
||||
return p.duration > 180
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* 项目筛选 */
|
||||
if (filterProject !== "all") {
|
||||
list = list.filter((p) => p.projectId === filterProject)
|
||||
}
|
||||
|
||||
/* 复核状态筛选 */
|
||||
if (filterReviewStatus !== "all") {
|
||||
if (filterReviewStatus === "none") {
|
||||
list = list.filter((p) => !p.reviewStatus)
|
||||
} else {
|
||||
list = list.filter((p) => p.reviewStatus === filterReviewStatus)
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter((p) => p.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
}, [
|
||||
products,
|
||||
filterStatus,
|
||||
filterTime,
|
||||
filterDuration,
|
||||
filterProject,
|
||||
filterReviewStatus,
|
||||
searchText,
|
||||
])
|
||||
|
||||
const projectOptions = useMemo(() => getProjectOptions(products), [products])
|
||||
|
||||
/* 全选 */
|
||||
const allSelected =
|
||||
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredProducts.map((p) => p.id)))
|
||||
}
|
||||
}
|
||||
|
||||
/* 切换单个选择 */
|
||||
const handleToggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => setSelectedIds(new Set())
|
||||
|
||||
return {
|
||||
// 数据
|
||||
products,
|
||||
filteredProducts,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
// 筛选
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
filterTime,
|
||||
setFilterTime,
|
||||
filterDuration,
|
||||
setFilterDuration,
|
||||
filterProject,
|
||||
setFilterProject,
|
||||
filterReviewStatus,
|
||||
setFilterReviewStatus,
|
||||
projectOptions,
|
||||
// 批量选择
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
handleSelectAll,
|
||||
handleToggleSelect,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReviewStatus } from "@/api/products"
|
||||
|
||||
export type ProductStatus = "completed" | "processing" | "review" | "failed"
|
||||
|
||||
export interface ProductItem {
|
||||
id: string
|
||||
name: string
|
||||
status: ProductStatus
|
||||
duration: number // 秒
|
||||
date: string
|
||||
gradient: string
|
||||
duplicateRate: number // 查重率百分比
|
||||
isPublished: boolean
|
||||
resolution: string
|
||||
fileSize: number // MB
|
||||
videoUrl?: string
|
||||
thumbnailUrl?: string
|
||||
/** 复核状态 */
|
||||
reviewStatus?: ReviewStatus
|
||||
/** 所属项目 ID */
|
||||
projectId?: string
|
||||
/** 所属项目名称 */
|
||||
projectName?: string
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ProductItem as ApiProductItem } from "@/api/products"
|
||||
import type { ProductItem, ProductStatus } from "../types"
|
||||
import { GRADIENTS, REVIEW_STATUS_CYCLE } from "../constants"
|
||||
import type { ReviewStatus } from "@/api/products"
|
||||
|
||||
/** 格式化时间 mm:ss */
|
||||
export const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
export const formatSize = (mb: number): string => {
|
||||
if (mb <= 0) return "-"
|
||||
if (mb < 1) return `${(mb * 1024).toFixed(0)} KB`
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** 格式化日期时间(MM-DD HH:mm) */
|
||||
export const formatDateTime = (isoString: string): string => {
|
||||
const d = new Date(isoString)
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, "0")
|
||||
const day = d.getDate().toString().padStart(2, "0")
|
||||
const hour = d.getHours().toString().padStart(2, "0")
|
||||
const minute = d.getMinutes().toString().padStart(2, "0")
|
||||
return `${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
/** 将后端 status 映射为前端 ProductStatus */
|
||||
export const normalizeStatus = (s: string): ProductStatus => {
|
||||
const map: Record<string, ProductStatus> = {
|
||||
completed: "completed",
|
||||
succeeded: "completed",
|
||||
processing: "processing",
|
||||
running: "processing",
|
||||
review: "review",
|
||||
failed: "failed",
|
||||
error: "failed",
|
||||
}
|
||||
return map[s] ?? "processing"
|
||||
}
|
||||
|
||||
/** 根据 id 生成稳定的渐变色 */
|
||||
export const gradientForId = (id: string): string => {
|
||||
let hash = 0
|
||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0
|
||||
return GRADIENTS[Math.abs(hash) % GRADIENTS.length]
|
||||
}
|
||||
|
||||
/** 将后端 ProductItem 映射为前端 ProductItem */
|
||||
export const mapApiProduct = (item: ApiProductItem): ProductItem => ({
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
status: normalizeStatus(item.status),
|
||||
duration: item.duration_seconds ?? 0,
|
||||
date: item.created_at ? formatDateTime(item.created_at) : "—",
|
||||
gradient: gradientForId(item.id),
|
||||
duplicateRate: item.duplicate_rate ?? 0,
|
||||
isPublished: false, // TODO: 后端发布状态字段待补齐
|
||||
resolution: item.resolution ?? "1080×1920",
|
||||
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
videoUrl: item.video_url,
|
||||
thumbnailUrl: item.thumbnail_url,
|
||||
reviewStatus: item.review_status,
|
||||
projectId: item.project_id,
|
||||
projectName: item.project_name,
|
||||
})
|
||||
|
||||
/** 获取下一个复核状态 */
|
||||
export const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => {
|
||||
if (!current) return "approved"
|
||||
const idx = REVIEW_STATUS_CYCLE.indexOf(current)
|
||||
return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* EditingPlanner 模块 smoke test
|
||||
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
||||
* editing-planner 目录下所有文件的改动(包括子组件、Hook 和工具函数)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// 主组件
|
||||
import "@/pages/editing-planner/EditingPlanner"
|
||||
|
||||
// 常量
|
||||
import "@/pages/editing-planner/constants"
|
||||
|
||||
// 工具函数
|
||||
import "@/pages/editing-planner/utils/selectors"
|
||||
|
||||
// 子组件
|
||||
import "@/pages/editing-planner/components/BgmSelector"
|
||||
import "@/pages/editing-planner/components/ClipPropertiesPanel"
|
||||
import "@/pages/editing-planner/components/CoverSelector"
|
||||
import "@/pages/editing-planner/components/EditorClipList"
|
||||
import "@/pages/editing-planner/components/EditingDrawers"
|
||||
import "@/pages/editing-planner/components/FilterPanel"
|
||||
import "@/pages/editing-planner/components/GenerationHistoryModal"
|
||||
import "@/pages/editing-planner/components/GenerationProgressModal"
|
||||
import "@/pages/editing-planner/components/GreenScreenPanel"
|
||||
import "@/pages/editing-planner/components/IntroOutroPanel"
|
||||
import "@/pages/editing-planner/components/MediaPanel"
|
||||
import "@/pages/editing-planner/components/ModeBar"
|
||||
import "@/pages/editing-planner/components/PipConfigPanel"
|
||||
import "@/pages/editing-planner/components/PreviewPlayer"
|
||||
import "@/pages/editing-planner/components/RightPanel"
|
||||
import "@/pages/editing-planner/components/SaveModal"
|
||||
import "@/pages/editing-planner/components/SpeedPanel"
|
||||
import "@/pages/editing-planner/components/StatusBar"
|
||||
import "@/pages/editing-planner/components/StickerPanel"
|
||||
import "@/pages/editing-planner/components/SubtitleStylePanel"
|
||||
import "@/pages/editing-planner/components/TimelinePanel"
|
||||
import "@/pages/editing-planner/components/TopBar"
|
||||
import "@/pages/editing-planner/components/TransitionSelector"
|
||||
import "@/pages/editing-planner/components/TtsPanel"
|
||||
import "@/pages/editing-planner/components/WatermarkPanel"
|
||||
|
||||
// 类型
|
||||
import "@/pages/editing-planner/types"
|
||||
import "@/pages/editing-planner/types/subtitle"
|
||||
|
||||
// Hooks
|
||||
import "@/pages/editing-planner/hooks/useUndoRedo"
|
||||
import "@/pages/editing-planner/hooks/useEditPlanClips"
|
||||
import "@/pages/editing-planner/hooks/useEditorDrawers"
|
||||
import "@/pages/editing-planner/hooks/usePlaybackControl"
|
||||
import "@/pages/editing-planner/hooks/useClipOperations"
|
||||
import "@/pages/editing-planner/hooks/useTemplateManagement"
|
||||
|
||||
describe("EditingPlanner module smoke test", () => {
|
||||
it("should load all editing-planner modules", () => {
|
||||
// 纯模块加载测试,确保所有组件/工具函数能正常 import
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* ProductLibrary 模块 smoke test
|
||||
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
||||
* products 目录下所有文件的改动(包括子组件、Hook 和工具函数)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// 主组件
|
||||
import "@/pages/products/ProductLibrary"
|
||||
|
||||
// 类型与常量
|
||||
import "@/pages/products/types"
|
||||
import "@/pages/products/constants"
|
||||
|
||||
// 工具函数
|
||||
import "@/pages/products/utils/index"
|
||||
|
||||
// 子组件
|
||||
import "@/pages/products/components/ProductCard"
|
||||
import "@/pages/products/components/VideoPlayer"
|
||||
|
||||
// Hooks
|
||||
import "@/pages/products/hooks/useProductList"
|
||||
import "@/pages/products/hooks/useProductActions"
|
||||
|
||||
describe("ProductLibrary module smoke test", () => {
|
||||
it("should load all product modules", () => {
|
||||
// 纯模块加载测试,确保所有组件/工具函数能正常 import
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,539 +0,0 @@
|
||||
"""CoverGenerator 纯逻辑单测 — 时间钳制 + 智能选帧算法.
|
||||
|
||||
通过 mock run_ffmpeg 和 probe_video_info 验证纯逻辑部分,
|
||||
不实际执行 FFmpeg,确保测试轻量快速。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.cover_generator import (
|
||||
CoverGenerator,
|
||||
DEFAULT_COVER_HEIGHT,
|
||||
DEFAULT_COVER_QUALITY,
|
||||
DEFAULT_COVER_TIME,
|
||||
DEFAULT_COVER_WIDTH,
|
||||
SMART_COVER_FRAME_COUNT,
|
||||
)
|
||||
|
||||
|
||||
class TestCoverGeneratorConstants:
|
||||
"""常量默认值测试."""
|
||||
|
||||
def test_default_cover_time(self):
|
||||
"""默认抽帧时间为 1.0 秒."""
|
||||
assert DEFAULT_COVER_TIME == 1.0
|
||||
|
||||
def test_default_dimensions(self):
|
||||
"""默认封面尺寸 1080x1920 (竖屏)."""
|
||||
assert DEFAULT_COVER_WIDTH == 1080
|
||||
assert DEFAULT_COVER_HEIGHT == 1920
|
||||
|
||||
def test_default_quality(self):
|
||||
"""默认质量为 5 (JPEG q:v, 越小越好)."""
|
||||
assert DEFAULT_COVER_QUALITY == 5
|
||||
|
||||
def test_smart_cover_frame_count(self):
|
||||
"""智能封面默认抽 3 帧."""
|
||||
assert SMART_COVER_FRAME_COUNT == 3
|
||||
|
||||
|
||||
class TestExtractFrameCommand:
|
||||
"""extract_frame 命令构建测试."""
|
||||
|
||||
def _probe_video_info_mock(self, duration=10.0):
|
||||
"""创建 probe_video_info 的 mock."""
|
||||
return {"duration": duration, "width": 1920, "height": 1080, "fps": 25.0}
|
||||
|
||||
def test_default_params_command(self, tmp_path):
|
||||
"""默认参数下 FFmpeg 命令正确."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
# 让 output_path 在 run_ffmpeg 后存在
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
result = CoverGenerator.extract_frame(str(video_file), str(output_file))
|
||||
|
||||
assert result == Path(output_file)
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
# 基本结构
|
||||
assert cmd[0].endswith("ffmpeg") or "ffmpeg" in cmd[0]
|
||||
assert "-y" in cmd
|
||||
assert "-vframes" in cmd
|
||||
assert cmd[cmd.index("-vframes") + 1] == "1"
|
||||
assert "-f" in cmd
|
||||
assert "mjpeg" in cmd[cmd.index("-f") + 1]
|
||||
|
||||
# 时间点
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(DEFAULT_COVER_TIME, abs=0.001)
|
||||
|
||||
# 输入文件
|
||||
i_idx = cmd.index("-i")
|
||||
assert cmd[i_idx + 1] == str(video_file)
|
||||
|
||||
# 输出文件
|
||||
assert cmd[-1] == str(output_file)
|
||||
|
||||
# scale + crop 滤镜
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=" in vf_value
|
||||
assert "crop=" in vf_value
|
||||
assert "force_original_aspect_ratio=increase" in vf_value
|
||||
|
||||
def test_custom_time(self, tmp_path):
|
||||
"""自定义抽帧时间点."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=30.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=5.5)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(5.5, abs=0.001)
|
||||
|
||||
def test_custom_dimensions(self, tmp_path):
|
||||
"""自定义输出尺寸."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), width=1920, height=1080)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=1920:1080:" in vf_value
|
||||
assert "crop=1920:1080" in vf_value
|
||||
|
||||
def test_custom_quality(self, tmp_path):
|
||||
"""自定义 JPEG 质量."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), quality=2)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
q_idx = cmd.index("-q:v")
|
||||
assert cmd[q_idx + 1] == "2"
|
||||
|
||||
def test_time_exceeds_duration_clamps_to_midpoint(self, tmp_path):
|
||||
"""抽帧时间超过视频时长时,钳制到中间帧."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=5.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=10.0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
# 钳制到 duration/2 = 2.5
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(2.5, abs=0.001)
|
||||
|
||||
def test_negative_time_clamps_to_zero(self, tmp_path):
|
||||
"""负时间钳制到 0."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=10.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=-2.0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(0.0, abs=0.001)
|
||||
|
||||
def test_time_equals_duration_clamps_to_midpoint(self, tmp_path):
|
||||
"""时间点等于时长时钳制到中间帧."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=10.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=10.0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(5.0, abs=0.001)
|
||||
|
||||
def test_zero_duration_video(self, tmp_path):
|
||||
"""视频时长为 0 时的行为(不钳制,用原始时间)."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=0.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=0.5)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(0.5, abs=0.001)
|
||||
|
||||
def test_video_not_found_raises(self, tmp_path):
|
||||
"""视频文件不存在时抛出 FileNotFoundError."""
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
CoverGenerator.extract_frame(str(tmp_path / "nonexistent.mp4"), str(output_file))
|
||||
|
||||
def test_output_creates_parent_dir(self, tmp_path):
|
||||
"""输出目录不存在时自动创建."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
out_dir = tmp_path / "deep" / "nested"
|
||||
output_file = out_dir / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file))
|
||||
|
||||
assert out_dir.exists()
|
||||
assert out_dir.is_dir()
|
||||
|
||||
def test_ffmpeg_failure_propagates(self, tmp_path):
|
||||
"""FFmpeg 失败时异常向上传递."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch(
|
||||
"video_processing.cover_generator.run_ffmpeg",
|
||||
side_effect=RuntimeError("FFmpeg error"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="FFmpeg error"):
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file))
|
||||
|
||||
|
||||
class TestSmartCoverTimePoints:
|
||||
"""智能封面时间点计算测试."""
|
||||
|
||||
def test_single_frame_falls_back_to_default(self, tmp_path):
|
||||
"""只有 1 帧时退化为普通抽帧(取 DEFAULT_COVER_TIME 和 midpoint 中较小值)."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 20.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
# frame_count=1 时退化为普通抽帧
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file), frame_count=1)
|
||||
|
||||
# 只调用一次(退化路径)
|
||||
assert mock_run.call_count == 1
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
# min(DEFAULT_COVER_TIME=1.0, duration/2=10.0) = 1.0
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(1.0, abs=0.001)
|
||||
|
||||
def test_zero_duration_falls_back(self, tmp_path):
|
||||
"""视频时长为 0 时退化为普通抽帧."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 0.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
|
||||
|
||||
# 只调用一次(退化路径)
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
def test_three_frames_uniform_distribution(self, tmp_path):
|
||||
"""3 帧均匀分布在 5%~95% 区间."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
call_times = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 100.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
# 记录抽帧时间
|
||||
ss_idx = cmd.index("-ss")
|
||||
call_times.append(float(cmd[ss_idx + 1]))
|
||||
# 在输出路径写文件
|
||||
output_arg = cmd[-1]
|
||||
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
|
||||
# 不同文件大小,让第三帧"最清晰"
|
||||
idx = len(call_times) - 1
|
||||
size = 1000 * (idx + 1) # 递增的文件大小
|
||||
Path(output_arg).write_bytes(b"x" * size)
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
|
||||
|
||||
# 3 帧:5%、50%、95%
|
||||
assert len(call_times) == 3
|
||||
assert call_times[0] == pytest.approx(5.0, abs=0.1) # 5%
|
||||
assert call_times[1] == pytest.approx(50.0, abs=0.1) # 50%
|
||||
assert call_times[2] == pytest.approx(95.0, abs=0.1) # 95%
|
||||
|
||||
def test_five_frames_distribution(self, tmp_path):
|
||||
"""5 帧均匀分布."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
call_times = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 100.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
ss_idx = cmd.index("-ss")
|
||||
call_times.append(float(cmd[ss_idx + 1]))
|
||||
output_arg = cmd[-1]
|
||||
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
|
||||
idx = len(call_times) - 1
|
||||
Path(output_arg).write_bytes(b"x" * (1000 * (idx + 1)))
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file), frame_count=5)
|
||||
|
||||
assert len(call_times) == 5
|
||||
# step = (95-5) / (5-1) = 22.5
|
||||
# times: 5, 27.5, 50, 72.5, 95
|
||||
assert call_times[0] == pytest.approx(5.0, abs=0.1)
|
||||
assert call_times[1] == pytest.approx(27.5, abs=0.1)
|
||||
assert call_times[2] == pytest.approx(50.0, abs=0.1)
|
||||
assert call_times[3] == pytest.approx(72.5, abs=0.1)
|
||||
assert call_times[4] == pytest.approx(95.0, abs=0.1)
|
||||
|
||||
def test_selects_largest_file_as_best(self, tmp_path):
|
||||
"""选择文件最大的帧作为最佳封面(清晰度近似)."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
sizes = [5000, 15000, 8000] # 第二帧最大
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 100.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
call_idx = [0]
|
||||
|
||||
def fake_run(cmd):
|
||||
output_arg = cmd[-1]
|
||||
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
|
||||
idx = call_idx[0]
|
||||
Path(output_arg).write_bytes(b"x" * sizes[idx])
|
||||
call_idx[0] += 1
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
result = CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
|
||||
|
||||
# 第二帧(索引1)应该是最佳
|
||||
assert result == output_file
|
||||
# 输出文件大小应等于第二帧大小
|
||||
assert output_file.stat().st_size == 15000
|
||||
|
||||
|
||||
class TestProcessCustomCover:
|
||||
"""自定义封面处理测试."""
|
||||
|
||||
def test_custom_cover_resize_command(self, tmp_path):
|
||||
"""自定义封面调整尺寸命令正确."""
|
||||
input_file = tmp_path / "upload.jpg"
|
||||
input_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with patch("video_processing.cover_generator.run_ffmpeg") as mock_run:
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.process_custom_cover(str(input_file), str(output_file))
|
||||
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
assert "-i" in cmd
|
||||
assert cmd[cmd.index("-i") + 1] == str(input_file)
|
||||
assert cmd[-1] == str(output_file)
|
||||
|
||||
# scale + crop
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=" in vf_value
|
||||
assert "crop=" in vf_value
|
||||
|
||||
def test_custom_cover_not_found_raises(self, tmp_path):
|
||||
"""自定义封面文件不存在时抛出 FileNotFoundError."""
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
CoverGenerator.process_custom_cover(str(tmp_path / "nonexistent.jpg"), str(output_file))
|
||||
|
||||
def test_custom_cover_custom_dimensions(self, tmp_path):
|
||||
"""自定义封面自定义输出尺寸."""
|
||||
input_file = tmp_path / "upload.jpg"
|
||||
input_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with patch("video_processing.cover_generator.run_ffmpeg") as mock_run:
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.process_custom_cover(str(input_file), str(output_file), width=800, height=600)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=800:600:" in vf_value
|
||||
assert "crop=800:600" in vf_value
|
||||
@@ -10,65 +10,59 @@ import pytest
|
||||
|
||||
# 模块级mock有副作用的依赖(纯算法测试不需要db/celery/cv2)
|
||||
# 注意:必须在 import dedup 前全部 mock 完,避免链式导入触发db连接
|
||||
# ⚠️ 只 mock 具体叶子模块,绝不 mock 整个父包,否则会污染其他测试文件的导入
|
||||
|
||||
|
||||
def _mock_module(**attrs):
|
||||
"""创建带 __spec__ 的 mock 模块,避免导入系统 AttributeError: __spec__"""
|
||||
m = MagicMock()
|
||||
m.__spec__ = None
|
||||
for k, v in attrs.items():
|
||||
setattr(m, k, v)
|
||||
return m
|
||||
|
||||
|
||||
# cv2(视频处理依赖,纯算法测试不需要)
|
||||
sys.modules["cv2"] = MagicMock()
|
||||
# 模块级mock worker_app.db(dedup模块import时会触发数据库初始化,纯算法测试不需要)
|
||||
sys.modules["worker_app.db"] = MagicMock()
|
||||
sys.modules["worker_app.db"].SessionLocal = MagicMock()
|
||||
sys.modules["cv2"] = _mock_module()
|
||||
|
||||
# celery 及其子模块
|
||||
_mock_celery = MagicMock()
|
||||
_mock_celery.Task = MagicMock
|
||||
_mock_celery.Celery = MagicMock
|
||||
_mock_celery.__spec__ = None
|
||||
sys.modules["celery"] = _mock_celery
|
||||
|
||||
# sqlalchemy 作为包结构 mock
|
||||
# sqlalchemy(dedup 导入了 Session 类型)
|
||||
_mock_sqla = MagicMock()
|
||||
_mock_sqla.__path__ = []
|
||||
_mock_sqla.__package__ = "sqlalchemy"
|
||||
_mock_sqla.__spec__ = None
|
||||
_mock_sqla_orm = MagicMock()
|
||||
_mock_sqla_orm.__path__ = []
|
||||
_mock_sqla_orm.__spec__ = None
|
||||
_mock_sqla_orm.Session = MagicMock
|
||||
_mock_sqla_engine = MagicMock()
|
||||
sys.modules["sqlalchemy"] = _mock_sqla
|
||||
sys.modules["sqlalchemy.orm"] = _mock_sqla_orm
|
||||
sys.modules["sqlalchemy.engine"] = _mock_sqla_engine
|
||||
sys.modules["sqlalchemy.ext"] = MagicMock()
|
||||
sys.modules["sqlalchemy.ext.declarative"] = MagicMock()
|
||||
sys.modules["sqlalchemy.engine"] = _mock_module()
|
||||
sys.modules["sqlalchemy.ext"] = _mock_module()
|
||||
sys.modules["sqlalchemy.ext.declarative"] = _mock_module()
|
||||
|
||||
# worker_app 及其子模块(避免导入时触发数据库连接)
|
||||
_mock_worker_app = MagicMock()
|
||||
_mock_worker_app.__path__ = []
|
||||
_mock_worker_db = MagicMock()
|
||||
_mock_worker_db.SessionLocal = MagicMock()
|
||||
_mock_worker_celery = MagicMock()
|
||||
_mock_worker_celery.celery_app = MagicMock()
|
||||
_mock_worker_core = MagicMock()
|
||||
_mock_worker_core.__path__ = []
|
||||
_mock_worker_config = MagicMock()
|
||||
_mock_worker_config.get_settings = MagicMock(return_value=MagicMock())
|
||||
sys.modules["worker_app"] = _mock_worker_app
|
||||
sys.modules["worker_app.db"] = _mock_worker_db
|
||||
sys.modules["worker_app.celery_app"] = _mock_worker_celery
|
||||
sys.modules["worker_app.core"] = _mock_worker_core
|
||||
sys.modules["worker_app.core.config"] = _mock_worker_config
|
||||
# worker_app 子模块(只 mock 具体需要的,不 mock 整个 worker_app 包)
|
||||
sys.modules["worker_app.db"] = _mock_module(SessionLocal=MagicMock())
|
||||
sys.modules["worker_app.celery_app"] = _mock_module(celery_app=MagicMock())
|
||||
sys.modules["worker_app.core.config"] = _mock_module(get_settings=MagicMock(return_value=MagicMock()))
|
||||
|
||||
# packages.adapters.sqlalchemy_impl(整个包mock掉)
|
||||
_mock_sqla_impl = MagicMock()
|
||||
_mock_sqla_impl.__path__ = []
|
||||
sys.modules["packages.adapters.sqlalchemy_impl"] = _mock_sqla_impl
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = MagicMock()
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.schema_guard"] = MagicMock()
|
||||
|
||||
# packages.shared
|
||||
_mock_packages_shared = MagicMock()
|
||||
_mock_packages_shared.__path__ = []
|
||||
_mock_shared_config = MagicMock()
|
||||
_mock_shared_config.get_shared_settings = MagicMock(return_value=MagicMock())
|
||||
sys.modules["packages.shared"] = _mock_packages_shared
|
||||
sys.modules["packages.shared.config"] = _mock_shared_config
|
||||
sys.modules["packages.shared.storage"] = MagicMock()
|
||||
# packages - 只 mock 真正触发副作用的模块,不 mock 整个父包
|
||||
# session 模块是触发数据库连接的元凶(ensure_database_exists),必须 mock 掉
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module(
|
||||
Base=MagicMock(),
|
||||
build_engine=MagicMock(),
|
||||
build_session_factory=MagicMock(),
|
||||
ensure_database_exists=MagicMock(),
|
||||
initialize_database=MagicMock(),
|
||||
)
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _mock_module()
|
||||
sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock()))
|
||||
sys.modules["packages.shared.storage"] = _mock_module()
|
||||
|
||||
from video_processing.dedup import ( # noqa: E402
|
||||
VideoDeduplicator,
|
||||
|
||||
@@ -5,8 +5,8 @@ import pytest
|
||||
from packages.domain.exceptions import (
|
||||
DomainError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
QuotaExceededError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -382,3 +382,297 @@ class TestModuleStatus:
|
||||
assert ModuleStatus.ACTIVE.value == "active"
|
||||
assert ModuleStatus.DISABLED.value == "disabled"
|
||||
assert ModuleStatus.ERROR.value == "error"
|
||||
|
||||
|
||||
# ── Module 更多状态转换测试 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestModuleStateTransitions:
|
||||
"""Module 状态转换补充测试."""
|
||||
|
||||
def test_activate_twice_idempotent(self):
|
||||
"""多次激活不报错."""
|
||||
mod = Module(name="m1")
|
||||
mod.activate()
|
||||
mod.activate()
|
||||
assert mod.status == ModuleStatus.ACTIVE
|
||||
|
||||
def test_disable_twice_idempotent(self):
|
||||
"""多次禁用不报错."""
|
||||
mod = Module(name="m1", status=ModuleStatus.ACTIVE)
|
||||
mod.disable()
|
||||
mod.disable()
|
||||
assert mod.status == ModuleStatus.DISABLED
|
||||
|
||||
def test_disable_from_registered(self):
|
||||
"""从registered状态禁用."""
|
||||
mod = Module(name="m1")
|
||||
mod.disable()
|
||||
assert mod.status == ModuleStatus.DISABLED
|
||||
|
||||
def test_activate_after_disable(self):
|
||||
"""禁用后重新激活."""
|
||||
mod = Module(name="m1")
|
||||
mod.activate()
|
||||
mod.disable()
|
||||
mod.activate()
|
||||
assert mod.status == ModuleStatus.ACTIVE
|
||||
|
||||
def test_error_state_cannot_be_activated(self):
|
||||
"""error状态不能被激活."""
|
||||
mod = Module(name="m1", status=ModuleStatus.ERROR)
|
||||
mod.activate()
|
||||
assert mod.status == ModuleStatus.ERROR
|
||||
|
||||
def test_error_state_can_be_disabled(self):
|
||||
"""error状态可以被禁用(disable 不检查状态)."""
|
||||
mod = Module(name="m1", status=ModuleStatus.ERROR)
|
||||
mod.disable()
|
||||
assert mod.status == ModuleStatus.DISABLED
|
||||
|
||||
|
||||
# ── ModuleRegistry 注册补充测试 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestModuleRegistryRegisterMore:
|
||||
"""模块注册补充场景."""
|
||||
|
||||
def test_register_multiple_modules(self):
|
||||
"""注册多个模块."""
|
||||
registry = ModuleRegistry()
|
||||
for i in range(5):
|
||||
registry.register(Module(name=f"mod_{i}"))
|
||||
assert len(registry.list_modules()) == 5
|
||||
|
||||
def test_register_order_independent_deps(self):
|
||||
"""先注册依赖方,后注册被依赖方,依赖方不会自动激活."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(name="plugin", dependencies=["core"]))
|
||||
assert registry.get("plugin").status == ModuleStatus.REGISTERED
|
||||
# 注册core后,plugin仍然是REGISTERED(不会自动检查)
|
||||
registry.register(Module(name="core"))
|
||||
assert registry.get("core").status == ModuleStatus.ACTIVE
|
||||
|
||||
def test_register_with_multiple_deps_all_satisfied(self):
|
||||
"""所有依赖都满足时自动激活."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(name="dep_a"))
|
||||
registry.register(Module(name="dep_b"))
|
||||
registry.register(Module(name="plugin", dependencies=["dep_a", "dep_b"]))
|
||||
assert registry.get("plugin").status == ModuleStatus.ACTIVE
|
||||
|
||||
def test_register_with_multiple_deps_partial_missing(self):
|
||||
"""部分依赖缺失时不激活."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(name="dep_a"))
|
||||
registry.register(Module(name="plugin", dependencies=["dep_a", "dep_b"]))
|
||||
assert registry.get("plugin").status == ModuleStatus.REGISTERED
|
||||
|
||||
def test_register_self_dependency_handled(self):
|
||||
"""自依赖不会导致死循环(依赖检查时找不到自己)."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(name="self_dep", dependencies=["self_dep"]))
|
||||
# 注册时自己还没加入 _modules,检查依赖时找不到,保持REGISTERED
|
||||
assert registry.get("self_dep").status == ModuleStatus.REGISTERED
|
||||
|
||||
|
||||
# ── ModuleRegistry 能力查询补充 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestModuleRegistryCapabilitiesMore:
|
||||
"""能力查询补充测试."""
|
||||
|
||||
def test_multiple_modules_same_capability_returns_first(self):
|
||||
"""多个模块提供同一能力,get_capability返回第一个."""
|
||||
registry = ModuleRegistry()
|
||||
cap1 = ModuleCapability(name="render", description="渲染器A")
|
||||
cap2 = ModuleCapability(name="render", description="渲染器B")
|
||||
registry.register(Module(name="mod_a", capabilities=[cap1]))
|
||||
registry.register(Module(name="mod_b", capabilities=[cap2]))
|
||||
result = registry.get_capability("render")
|
||||
assert result is not None
|
||||
assert result.name == "render"
|
||||
|
||||
def test_has_capability_case_sensitive(self):
|
||||
"""能力名大小写敏感."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(name="m1", capabilities=[ModuleCapability(name="Render")]))
|
||||
assert registry.has_capability("Render") is True
|
||||
assert registry.has_capability("render") is False
|
||||
|
||||
def test_get_quota_rules_nonexistent_capability(self):
|
||||
"""不存在的能力返回空配额列表."""
|
||||
registry = ModuleRegistry()
|
||||
rules = registry.get_quota_rules("nonexistent")
|
||||
assert rules == []
|
||||
|
||||
def test_get_active_capabilities_empty_registry(self):
|
||||
"""空注册中心返回空字典."""
|
||||
registry = ModuleRegistry()
|
||||
result = registry.get_active_capabilities()
|
||||
assert result == {}
|
||||
|
||||
def test_get_active_capabilities_skips_inactive(self):
|
||||
"""非激活模块的能力不计入."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(name="active_mod", capabilities=[ModuleCapability(name="cap1")]))
|
||||
disabled = Module(
|
||||
name="disabled_mod",
|
||||
status=ModuleStatus.DISABLED,
|
||||
capabilities=[ModuleCapability(name="cap2")],
|
||||
)
|
||||
registry._modules["disabled_mod"] = disabled
|
||||
result = registry.get_active_capabilities()
|
||||
assert "active_mod" in result
|
||||
assert "disabled_mod" not in result
|
||||
|
||||
def test_get_active_capabilities_skips_no_cap_modules(self):
|
||||
"""无能力的模块不出现在结果中."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(name="no_cap_mod"))
|
||||
registry.register(Module(name="has_cap_mod", capabilities=[ModuleCapability(name="cap1")]))
|
||||
result = registry.get_active_capabilities()
|
||||
assert "no_cap_mod" not in result
|
||||
assert "has_cap_mod" in result
|
||||
|
||||
def test_module_with_multiple_capabilities(self):
|
||||
"""单个模块有多个能力."""
|
||||
registry = ModuleRegistry()
|
||||
caps = [
|
||||
ModuleCapability(name="cap_a"),
|
||||
ModuleCapability(name="cap_b"),
|
||||
ModuleCapability(name="cap_c"),
|
||||
]
|
||||
registry.register(Module(name="multi_mod", capabilities=caps))
|
||||
assert registry.has_capability("cap_a")
|
||||
assert registry.has_capability("cap_b")
|
||||
assert registry.has_capability("cap_c")
|
||||
assert len(registry.get_active_capabilities()["multi_mod"]) == 3
|
||||
|
||||
|
||||
# ── ModuleRegistry 依赖检查补充 ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestModuleRegistryDependenciesMore:
|
||||
"""依赖检查补充测试."""
|
||||
|
||||
def test_multiple_dependencies_all_active(self):
|
||||
"""多个依赖都激活."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(name="dep1"))
|
||||
registry.register(Module(name="dep2"))
|
||||
registry.register(Module(name="dep3"))
|
||||
registry.register(Module(name="plugin", dependencies=["dep1", "dep2", "dep3"]))
|
||||
assert registry.check_dependencies("plugin") is True
|
||||
|
||||
def test_multiple_dependencies_one_inactive(self):
|
||||
"""多个依赖中有一个未激活."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(name="dep1"))
|
||||
dep2 = Module(name="dep2", status=ModuleStatus.DISABLED)
|
||||
registry._modules["dep2"] = dep2
|
||||
registry.register(Module(name="plugin", dependencies=["dep1", "dep2"]))
|
||||
# 注册时dep2不是ACTIVE,plugin不会自动激活
|
||||
assert registry.check_dependencies("plugin") is False
|
||||
|
||||
def test_chain_dependencies(self):
|
||||
"""链式依赖 A→B→C."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(name="c"))
|
||||
registry.register(Module(name="b", dependencies=["c"]))
|
||||
registry.register(Module(name="a", dependencies=["b"]))
|
||||
# a 依赖 b(ACTIVE),b 依赖 c(ACTIVE)
|
||||
# check_dependencies 只检查直接依赖,b 是 ACTIVE 的
|
||||
assert registry.check_dependencies("a") is True
|
||||
assert registry.get("a").status == ModuleStatus.ACTIVE
|
||||
|
||||
def test_no_dependencies_always_satisfied(self):
|
||||
"""无依赖的模块总是满足依赖检查."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(name="standalone"))
|
||||
assert registry.check_dependencies("standalone") is True
|
||||
|
||||
|
||||
# ── ModuleRegistry list_modules 补充 ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestModuleRegistryListMore:
|
||||
"""list_modules 补充测试."""
|
||||
|
||||
def test_list_modules_empty(self):
|
||||
"""空注册中心."""
|
||||
registry = ModuleRegistry()
|
||||
assert registry.list_modules() == []
|
||||
|
||||
def test_list_modules_registered_status(self):
|
||||
"""按registered状态过滤."""
|
||||
registry = ModuleRegistry()
|
||||
registry.register(Module(name="active_mod")) # auto ACTIVE
|
||||
pending = Module(name="pending_mod")
|
||||
registry._modules["pending_mod"] = pending # REGISTERED
|
||||
registered = registry.list_modules(status=ModuleStatus.REGISTERED)
|
||||
assert len(registered) == 1
|
||||
assert registered[0].name == "pending_mod"
|
||||
|
||||
def test_list_modules_error_status(self):
|
||||
"""按error状态过滤."""
|
||||
registry = ModuleRegistry()
|
||||
error_mod = Module(name="err", status=ModuleStatus.ERROR)
|
||||
registry._modules["err"] = error_mod
|
||||
errors = registry.list_modules(status=ModuleStatus.ERROR)
|
||||
assert len(errors) == 1
|
||||
assert errors[0].name == "err"
|
||||
|
||||
|
||||
# ── QuotaRule 补充测试 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestQuotaRuleMore:
|
||||
"""QuotaRule 补充测试."""
|
||||
|
||||
def test_zero_per_operation(self):
|
||||
"""零消耗配额规则."""
|
||||
rule = QuotaRule(dimension="free_ops", per_operation=0.0)
|
||||
assert rule.per_operation == 0.0
|
||||
|
||||
def test_fractional_per_operation(self):
|
||||
"""小数消耗配额规则."""
|
||||
rule = QuotaRule(dimension="storage", per_operation=0.001)
|
||||
assert rule.per_operation == 0.001
|
||||
|
||||
def test_large_per_operation(self):
|
||||
"""大数值消耗."""
|
||||
rule = QuotaRule(dimension="tokens", per_operation=10000.0)
|
||||
assert rule.per_operation == 10000.0
|
||||
|
||||
|
||||
# ── ModuleCapability 补充测试 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestModuleCapabilityMore:
|
||||
"""ModuleCapability 补充测试."""
|
||||
|
||||
def test_empty_metadata(self):
|
||||
"""默认metadata为空字典."""
|
||||
cap = ModuleCapability(name="test")
|
||||
assert cap.metadata == {}
|
||||
|
||||
def test_metadata_preserved(self):
|
||||
"""元数据完整保存."""
|
||||
meta = {"model": "v1", "speed": 1.5, "enabled": True}
|
||||
cap = ModuleCapability(name="test", metadata=meta)
|
||||
assert cap.metadata["model"] == "v1"
|
||||
assert cap.metadata["speed"] == 1.5
|
||||
assert cap.metadata["enabled"] is True
|
||||
|
||||
def test_multiple_quota_rules(self):
|
||||
"""多个配额规则."""
|
||||
rules = [
|
||||
QuotaRule("dim1", 1.0),
|
||||
QuotaRule("dim2", 2.0),
|
||||
QuotaRule("dim3", 3.0),
|
||||
]
|
||||
cap = ModuleCapability(name="test", quota_rules=rules)
|
||||
assert len(cap.quota_rules) == 3
|
||||
assert cap.quota_rules[0].dimension == "dim1"
|
||||
assert cap.quota_rules[2].per_operation == 3.0
|
||||
|
||||
@@ -416,6 +416,47 @@ class TestMergeShortSegmentsEdgeCases:
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "一二三四五六七八九"
|
||||
|
||||
def test_min_chars_one_no_merge(self):
|
||||
"""min_chars=1 时每个都够,不合并."""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="一", start=0.0, end=0.5),
|
||||
SubtitleSegment(text="二", start=0.5, end=1.0),
|
||||
SubtitleSegment(text="三", start=1.0, end=1.5),
|
||||
],
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=1)
|
||||
assert result.segment_count == 3
|
||||
|
||||
def test_min_chars_very_large_all_merged(self):
|
||||
"""min_chars 极大,全部合并成一段."""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="第一句", start=0.0, end=1.0),
|
||||
SubtitleSegment(text="第二句", start=1.0, end=2.0),
|
||||
SubtitleSegment(text="第三句", start=2.0, end=3.0),
|
||||
],
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=100)
|
||||
assert result.segment_count == 1
|
||||
assert result.total_chars == tl.total_chars
|
||||
|
||||
def test_merge_preserves_word_level_info(self):
|
||||
"""合并后词级信息完整保留,顺序正确."""
|
||||
w1 = [SubtitleWord(text="你", start=0.0, end=0.3)]
|
||||
w2 = [SubtitleWord(text="好", start=0.3, end=0.6)]
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="你", start=0.0, end=0.3, words=w1),
|
||||
SubtitleSegment(text="好", start=0.3, end=0.6, words=w2),
|
||||
],
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=4)
|
||||
assert result.segment_count == 1
|
||||
assert len(result.segments[0].words) == 2
|
||||
assert result.segments[0].words[0].text == "你"
|
||||
assert result.segments[0].words[1].text == "好"
|
||||
|
||||
|
||||
class TestSplitLongSegmentsEdgeCases:
|
||||
"""split_long_segments 边界情况深度测试."""
|
||||
@@ -474,6 +515,48 @@ class TestSplitLongSegmentsEdgeCases:
|
||||
result = timeline.split_long_segments(max_chars=10)
|
||||
assert result.segment_count == 0
|
||||
|
||||
def test_exactly_max_chars_no_split(self):
|
||||
"""恰好等于 max_chars 不拆分."""
|
||||
text = "一二三四五六七八九十" # 10字
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=5.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count == 1
|
||||
|
||||
def test_one_char_over_triggers_split(self):
|
||||
"""超过1个字符就触发拆分."""
|
||||
text = "一二三四五六七八九十1" # 11字
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=5.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 2
|
||||
|
||||
def test_mixed_short_and_long_segments(self):
|
||||
"""长短片段混合,只拆分超长的."""
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text="短", start=0.0, end=0.5),
|
||||
SubtitleSegment(
|
||||
text="这是一段很长很长需要拆分的字幕内容",
|
||||
start=0.5,
|
||||
end=3.0,
|
||||
),
|
||||
SubtitleSegment(text="短", start=3.0, end=3.5),
|
||||
],
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count > 3
|
||||
assert result.segments[0].text == "短"
|
||||
assert result.segments[-1].text == "短"
|
||||
|
||||
def test_total_chars_preserved_after_split(self):
|
||||
"""拆分后总字数保持不变."""
|
||||
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
|
||||
seg = SubtitleSegment(text=text, start=0.0, end=10.0)
|
||||
tl = SubtitleTimeline(segments=[seg])
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.total_chars == len(text)
|
||||
|
||||
|
||||
class TestSplitTextByPunctuationDeep:
|
||||
"""_split_text_by_punctuation 深度测试."""
|
||||
@@ -516,6 +599,47 @@ class TestSplitTextByPunctuationDeep:
|
||||
assert result[-1].endswith("!")
|
||||
|
||||
|
||||
class TestSplitTextByPunctuationEdgeCases:
|
||||
"""_split_text_by_punctuation 边界场景补充."""
|
||||
|
||||
def test_colon_semicolon_splits(self):
|
||||
"""冒号分号也能触发拆分."""
|
||||
text = "第一段:第二段;第三段"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 5)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_punctuation_at_start(self):
|
||||
"""标点在开头不崩溃,字符完整保留."""
|
||||
text = ",你好世界"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 10)
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_consecutive_punctuation(self):
|
||||
"""连续标点符号,字符完整保留."""
|
||||
text = "你好!!!测试。。。"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 3)
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_single_character_text(self):
|
||||
"""单字符文本不拆分."""
|
||||
result = SubtitleTimeline._split_text_by_punctuation("你", 10)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "你"
|
||||
|
||||
def test_only_punctuation(self):
|
||||
"""纯标点符号文本不崩溃."""
|
||||
result = SubtitleTimeline._split_text_by_punctuation("。。。", 10)
|
||||
assert isinstance(result, list)
|
||||
assert "".join(result) == "。。。"
|
||||
|
||||
def test_mixed_fullwidth_halfwidth_punctuation(self):
|
||||
"""全角半角标点混合."""
|
||||
text = "你好,世界!测试?完成"
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, 5)
|
||||
assert "".join(result) == text
|
||||
assert len(result) >= 2
|
||||
|
||||
|
||||
class TestSubtitleTimelineProperties:
|
||||
"""SubtitleTimeline 属性计算深度测试."""
|
||||
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
"""VideoProcessor 纯逻辑单测 — 数据类 + 输入校验 + 解析逻辑.
|
||||
|
||||
通过 mock ffmpeg-python 库验证纯逻辑部分,
|
||||
不实际执行 FFmpeg,确保测试轻量快速。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import fields
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from video_processing.processor import VideoProcessor, VideoResult
|
||||
|
||||
|
||||
class TestVideoResultDataclass:
|
||||
"""VideoResult 数据类测试."""
|
||||
|
||||
def test_all_fields_exist(self):
|
||||
"""所有字段都存在."""
|
||||
field_names = {f.name for f in fields(VideoResult)}
|
||||
expected = {
|
||||
"output_path",
|
||||
"thumbnail_path",
|
||||
"duration",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
"file_size",
|
||||
}
|
||||
assert expected.issubset(field_names)
|
||||
|
||||
def test_default_construction(self):
|
||||
"""正常构造 VideoResult."""
|
||||
result = VideoResult(
|
||||
output_path="/tmp/out.mp4",
|
||||
thumbnail_path="/tmp/out.jpg",
|
||||
duration=10.5,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=25.0,
|
||||
file_size=1024000,
|
||||
)
|
||||
assert result.output_path == "/tmp/out.mp4"
|
||||
assert result.thumbnail_path == "/tmp/out.jpg"
|
||||
assert result.duration == 10.5
|
||||
assert result.width == 1920
|
||||
assert result.height == 1080
|
||||
assert result.fps == 25.0
|
||||
assert result.file_size == 1024000
|
||||
|
||||
def test_zero_values(self):
|
||||
"""零值/边界值构造."""
|
||||
result = VideoResult(
|
||||
output_path="",
|
||||
thumbnail_path="",
|
||||
duration=0.0,
|
||||
width=0,
|
||||
height=0,
|
||||
fps=0.0,
|
||||
file_size=0,
|
||||
)
|
||||
assert result.duration == 0.0
|
||||
assert result.file_size == 0
|
||||
|
||||
|
||||
class TestVideoProcessorInit:
|
||||
"""VideoProcessor 初始化测试."""
|
||||
|
||||
def test_default_temp_dir(self):
|
||||
"""默认使用系统临时目录."""
|
||||
import tempfile
|
||||
|
||||
vp = VideoProcessor()
|
||||
assert vp.temp_dir == tempfile.gettempdir()
|
||||
|
||||
def test_custom_temp_dir(self):
|
||||
"""自定义临时目录."""
|
||||
vp = VideoProcessor(temp_dir="/my/temp")
|
||||
assert vp.temp_dir == "/my/temp"
|
||||
|
||||
|
||||
class TestVideoProcessorConcatenateValidation:
|
||||
"""concatenate_videos 输入校验测试."""
|
||||
|
||||
def test_empty_input_raises(self):
|
||||
"""空输入列表抛出 ValueError."""
|
||||
vp = VideoProcessor()
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
vp.concatenate_videos([], "/tmp/output.mp4")
|
||||
|
||||
def test_none_input_raises(self):
|
||||
"""None 输入抛出异常."""
|
||||
vp = VideoProcessor()
|
||||
with pytest.raises((ValueError, TypeError)):
|
||||
vp.concatenate_videos(None, "/tmp/output.mp4") # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestVideoProcessorGetVideoInfoParsing:
|
||||
"""get_video_info 解析逻辑测试(mock ffmpeg.probe)."""
|
||||
|
||||
def _mock_probe(self, streams=None, fmt=None):
|
||||
"""创建 ffmpeg.probe 的 mock 返回值."""
|
||||
return {
|
||||
"streams": streams
|
||||
or [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1", "codec_name": "h264"}],
|
||||
"format": fmt or {"duration": "10.5", "bit_rate": "5000000"},
|
||||
}
|
||||
|
||||
def test_basic_info_parsing(self):
|
||||
"""基本视频信息解析正确."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = self._mock_probe()
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
info = vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
assert info["duration"] == 10.5
|
||||
assert info["width"] == 1920
|
||||
assert info["height"] == 1080
|
||||
assert info["fps"] == 25.0
|
||||
assert info["codec"] == "h264"
|
||||
assert info["bitrate"] == 5000000
|
||||
|
||||
def test_fps_fraction_parsing(self):
|
||||
"""分数帧率解析(如 30000/1001 = 29.97)."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = self._mock_probe(
|
||||
streams=[
|
||||
{
|
||||
"codec_type": "video",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"r_frame_rate": "30000/1001",
|
||||
"codec_name": "h264",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
info = vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
assert info["fps"] == pytest.approx(29.97, abs=0.01)
|
||||
|
||||
def test_fps_integer_string(self):
|
||||
"""整数字符串帧率(如 "60")."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = self._mock_probe(
|
||||
streams=[{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "60", "codec_name": "h264"}]
|
||||
)
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
info = vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
assert info["fps"] == 60.0
|
||||
|
||||
def test_missing_r_frame_rate(self):
|
||||
"""缺少 r_frame_rate 时使用默认值."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = self._mock_probe(
|
||||
streams=[{"codec_type": "video", "width": 1920, "height": 1080, "codec_name": "h264"}]
|
||||
)
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
info = vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
assert info["fps"] == 25.0
|
||||
|
||||
def test_no_video_stream(self):
|
||||
"""没有视频流时的行为."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = {
|
||||
"streams": [{"codec_type": "audio", "codec_name": "aac"}],
|
||||
"format": {"duration": "10.0", "bit_rate": "128000"},
|
||||
}
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
with pytest.raises(StopIteration):
|
||||
vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
def test_float_duration(self):
|
||||
"""浮点时长解析."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = self._mock_probe(fmt={"duration": "123.456", "bit_rate": "0"})
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
info = vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
assert info["duration"] == pytest.approx(123.456, abs=0.001)
|
||||
|
||||
def test_bitrate_zero(self):
|
||||
"""码率为 0 时."""
|
||||
vp = VideoProcessor()
|
||||
probe_data = self._mock_probe(fmt={"duration": "10.0", "bit_rate": "0"})
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
|
||||
info = vp.get_video_info("/tmp/test.mp4")
|
||||
|
||||
assert info["bitrate"] == 0
|
||||
|
||||
def test_ffmpeg_probe_error_raises(self):
|
||||
"""ffmpeg.probe 失败时抛出 RuntimeError."""
|
||||
vp = VideoProcessor()
|
||||
|
||||
import ffmpeg
|
||||
|
||||
with patch(
|
||||
"video_processing.processor.ffmpeg.probe",
|
||||
side_effect=ffmpeg.Error([], b"", b"No such file"),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="probe error"):
|
||||
vp.get_video_info("/tmp/nonexistent.mp4")
|
||||
|
||||
|
||||
class TestVideoProcessorGenerateThumbnail:
|
||||
"""generate_thumbnail 测试."""
|
||||
|
||||
def _build_mock_chain(self):
|
||||
"""构建 ffmpeg.input → .output → .overwrite_output → .run 调用链."""
|
||||
mock_input_node = MagicMock()
|
||||
mock_output_node = MagicMock()
|
||||
mock_overwrite_node = MagicMock()
|
||||
mock_input_node.output.return_value = mock_output_node
|
||||
mock_output_node.overwrite_output.return_value = mock_overwrite_node
|
||||
return mock_input_node, mock_output_node, mock_overwrite_node
|
||||
|
||||
def test_default_output_path(self):
|
||||
"""默认输出路径为视频路径 + _thumb.jpg."""
|
||||
vp = VideoProcessor()
|
||||
mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain()
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input:
|
||||
result = vp.generate_thumbnail("/tmp/video.mp4")
|
||||
|
||||
assert result == "/tmp/video_thumb.jpg"
|
||||
mock_ff_input.assert_called_once_with("/tmp/video.mp4", ss=1.0)
|
||||
mock_input_node.output.assert_called_once()
|
||||
# 验证输出路径和参数
|
||||
output_args = mock_input_node.output.call_args
|
||||
assert output_args[0][0] == "/tmp/video_thumb.jpg"
|
||||
assert output_args[1].get("vframes") == 1
|
||||
assert output_args[1].get("format") == "image2"
|
||||
assert output_args[1].get("vcodec") == "mjpeg"
|
||||
|
||||
def test_custom_output_path(self):
|
||||
"""自定义输出路径."""
|
||||
vp = VideoProcessor()
|
||||
mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain()
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input:
|
||||
result = vp.generate_thumbnail("/tmp/video.mp4", output_path="/custom/thumb.jpg")
|
||||
|
||||
assert result == "/custom/thumb.jpg"
|
||||
|
||||
def test_custom_timestamp(self):
|
||||
"""自定义截图时间点."""
|
||||
vp = VideoProcessor()
|
||||
mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain()
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input:
|
||||
vp.generate_thumbnail("/tmp/video.mp4", timestamp=3.5)
|
||||
|
||||
# 验证 ss 参数
|
||||
mock_ff_input.assert_called_once_with("/tmp/video.mp4", ss=3.5)
|
||||
|
||||
def test_ffmpeg_error_raises_runtime(self):
|
||||
"""FFmpeg 失败时抛出 RuntimeError."""
|
||||
vp = VideoProcessor()
|
||||
|
||||
import ffmpeg
|
||||
|
||||
mock_input_node, mock_output, mock_overwrite = self._build_mock_chain()
|
||||
mock_overwrite.run.side_effect = ffmpeg.Error([], b"", b"Output file #0 does not contain any stream")
|
||||
|
||||
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node):
|
||||
with pytest.raises(RuntimeError, match="thumbnail error"):
|
||||
vp.generate_thumbnail("/tmp/video.mp4")
|
||||
|
||||
|
||||
class TestVideoProcessorConcatFileFormat:
|
||||
"""concat 临时文件格式验证."""
|
||||
|
||||
def test_concat_file_format(self, tmp_path):
|
||||
"""concat 临时文件格式符合 FFmpeg concat demuxer 规范."""
|
||||
import os
|
||||
|
||||
vp = VideoProcessor(temp_dir=str(tmp_path))
|
||||
|
||||
written_content = {}
|
||||
|
||||
def fake_input(path, *args, **kwargs):
|
||||
mock_node = MagicMock()
|
||||
mock_output = MagicMock()
|
||||
mock_overwrite = MagicMock()
|
||||
mock_node.output.return_value = mock_output
|
||||
mock_output.overwrite_output.return_value = mock_overwrite
|
||||
|
||||
if kwargs.get("format") == "concat":
|
||||
# 读取 concat 文件内容
|
||||
with open(path) as f:
|
||||
written_content["concat"] = f.read()
|
||||
return mock_node
|
||||
|
||||
mock_probe = MagicMock(
|
||||
return_value={
|
||||
"streams": [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1"}],
|
||||
"format": {"duration": "5.0", "bit_rate": "1000000"},
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("video_processing.processor.ffmpeg.input", side_effect=fake_input),
|
||||
patch("video_processing.processor.ffmpeg.probe", mock_probe),
|
||||
patch("video_processing.processor.os.path.getsize", return_value=1024),
|
||||
):
|
||||
with patch.object(VideoProcessor, "generate_thumbnail", return_value="/tmp/thumb.jpg"):
|
||||
vp.concatenate_videos(
|
||||
["/tmp/a.mp4", "/tmp/b.mp4", "/tmp/c.mp4"],
|
||||
str(tmp_path / "output.mp4"),
|
||||
)
|
||||
|
||||
# 验证 concat 文件格式
|
||||
assert "concat" in written_content
|
||||
lines = written_content["concat"].strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
assert lines[0].startswith("file '")
|
||||
assert "a.mp4'" in lines[0]
|
||||
assert "b.mp4'" in lines[1]
|
||||
assert "c.mp4'" in lines[2]
|
||||
# 使用绝对路径
|
||||
first_path = lines[0].replace("file '", "").rstrip("'")
|
||||
assert os.path.isabs(first_path)
|
||||
|
||||
def test_concat_creates_output_directory(self, tmp_path):
|
||||
"""输出目录不存在时自动创建."""
|
||||
vp = VideoProcessor(temp_dir=str(tmp_path))
|
||||
|
||||
out_dir = tmp_path / "deep" / "output"
|
||||
out_file = out_dir / "result.mp4"
|
||||
|
||||
mock_node = MagicMock()
|
||||
mock_output = MagicMock()
|
||||
mock_overwrite = MagicMock()
|
||||
mock_node.output.return_value = mock_output
|
||||
mock_output.overwrite_output.return_value = mock_overwrite
|
||||
|
||||
mock_probe = MagicMock(
|
||||
return_value={
|
||||
"streams": [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1"}],
|
||||
"format": {"duration": "5.0", "bit_rate": "1000000"},
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("video_processing.processor.ffmpeg.input", return_value=mock_node),
|
||||
patch("video_processing.processor.ffmpeg.probe", mock_probe),
|
||||
patch("video_processing.processor.os.path.getsize", return_value=1024),
|
||||
):
|
||||
with patch.object(VideoProcessor, "generate_thumbnail", return_value=str(out_dir / "thumb.jpg")):
|
||||
vp.concatenate_videos(["/tmp/a.mp4"], str(out_file))
|
||||
|
||||
assert out_dir.exists()
|
||||
assert out_dir.is_dir()
|
||||
@@ -1,260 +0,0 @@
|
||||
"""VoiceExtractor 纯逻辑单测 — 命令构建 + 边界用例.
|
||||
|
||||
通过 mock run_ffmpeg 验证 FFmpeg 命令参数是否正确,
|
||||
不实际执行 FFmpeg,确保测试轻量快速。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from worker_app.tasks.voice_extraction import VoiceExtractor
|
||||
|
||||
|
||||
class TestVoiceExtractorExtractVoiceCommand:
|
||||
"""extract_voice 命令构建测试."""
|
||||
|
||||
def test_default_params_correct_command(self):
|
||||
"""默认参数下 FFmpeg 命令正确."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
result = extractor.extract_voice("/tmp/input.mp4", "/tmp/output.mp3")
|
||||
|
||||
assert result == "/tmp/output.mp3"
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
# 基本结构验证
|
||||
assert cmd[0] == "ffmpeg"
|
||||
assert "-y" in cmd
|
||||
assert cmd[cmd.index("-i") + 1] == "/tmp/input.mp4"
|
||||
assert "-vn" in cmd # 无视频流
|
||||
assert cmd[-1] == "/tmp/output.mp3"
|
||||
|
||||
# 音频滤镜验证
|
||||
af_idx = cmd.index("-af")
|
||||
af_value = cmd[af_idx + 1]
|
||||
assert "highpass=f=200" in af_value
|
||||
assert "afftdn=bn=20" in af_value
|
||||
assert "bandpass=f=300:width_type=h:width=3000" in af_value
|
||||
assert "loudnorm" in af_value
|
||||
|
||||
# 编码验证
|
||||
assert "libmp3lame" in cmd
|
||||
assert "-q:a" in cmd
|
||||
assert cmd[cmd.index("-q:a") + 1] == "2"
|
||||
|
||||
def test_custom_highpass(self):
|
||||
"""自定义 highpass 频率."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", highpass=500)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "highpass=f=500" in af_value
|
||||
|
||||
def test_custom_bandpass_freq(self):
|
||||
"""自定义 bandpass 中心频率."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_freq=500)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "bandpass=f=500:" in af_value
|
||||
|
||||
def test_custom_bandpass_width(self):
|
||||
"""自定义 bandpass 宽度."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_width=5000)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "width=5000" in af_value
|
||||
|
||||
def test_custom_noise_reduction(self):
|
||||
"""自定义降噪强度."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", noise_reduction=30)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "afftdn=bn=30" in af_value
|
||||
|
||||
def test_filter_order_is_correct(self):
|
||||
"""滤镜顺序:highpass → 降噪 → bandpass → loudnorm."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3")
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
|
||||
hp_pos = af_value.index("highpass")
|
||||
dn_pos = af_value.index("afftdn")
|
||||
bp_pos = af_value.index("bandpass")
|
||||
ln_pos = af_value.index("loudnorm")
|
||||
|
||||
assert hp_pos < dn_pos < bp_pos < ln_pos
|
||||
|
||||
def test_creates_output_directory(self, tmp_path):
|
||||
"""输出目录不存在时自动创建."""
|
||||
out_dir = tmp_path / "nested" / "deep"
|
||||
out_file = out_dir / "voice.mp3"
|
||||
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg"):
|
||||
extractor.extract_voice("/tmp/in.mp4", str(out_file))
|
||||
|
||||
assert out_dir.exists()
|
||||
assert out_dir.is_dir()
|
||||
|
||||
def test_returns_output_path(self):
|
||||
"""返回值为输出路径."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg"):
|
||||
result = extractor.extract_voice("/tmp/in.mp4", "/tmp/voice.mp3")
|
||||
|
||||
assert result == "/tmp/voice.mp3"
|
||||
|
||||
|
||||
class TestVoiceExtractorExtractBackgroundCommand:
|
||||
"""extract_background 命令构建测试."""
|
||||
|
||||
def test_default_params_correct_command(self):
|
||||
"""默认参数下 FFmpeg 命令正确."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
result = extractor.extract_background("/tmp/input.mp4", "/tmp/output.mp3")
|
||||
|
||||
assert result == "/tmp/output.mp3"
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
# 基本结构
|
||||
assert cmd[0] == "ffmpeg"
|
||||
assert "-y" in cmd
|
||||
assert cmd[cmd.index("-i") + 1] == "/tmp/input.mp4"
|
||||
assert "-vn" in cmd
|
||||
assert cmd[-1] == "/tmp/output.mp3"
|
||||
|
||||
# 音频滤镜
|
||||
af_idx = cmd.index("-af")
|
||||
af_value = cmd[af_idx + 1]
|
||||
assert "lowpass=f=200" in af_value
|
||||
assert "loudnorm" in af_value
|
||||
|
||||
# 编码
|
||||
assert "libmp3lame" in cmd
|
||||
|
||||
def test_custom_lowpass_freq(self):
|
||||
"""自定义 lowpass 频率."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3", lowpass=500)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "lowpass=f=500" in af_value
|
||||
|
||||
def test_filter_order_background(self):
|
||||
"""背景音滤镜顺序:lowpass → loudnorm."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3")
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
|
||||
lp_pos = af_value.index("lowpass")
|
||||
ln_pos = af_value.index("loudnorm")
|
||||
assert lp_pos < ln_pos
|
||||
|
||||
def test_background_creates_output_directory(self, tmp_path):
|
||||
"""背景音输出目录不存在时自动创建."""
|
||||
out_dir = tmp_path / "bgm" / "tracks"
|
||||
out_file = out_dir / "bg.mp3"
|
||||
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg"):
|
||||
extractor.extract_background("/tmp/in.mp4", str(out_file))
|
||||
|
||||
assert out_dir.exists()
|
||||
|
||||
|
||||
class TestVoiceExtractorEdgeCases:
|
||||
"""边界情况测试."""
|
||||
|
||||
def test_zero_highpass(self):
|
||||
"""highpass=0 时的行为(极端低值)."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", highpass=0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "highpass=f=0" in af_value
|
||||
|
||||
def test_zero_bandpass_freq(self):
|
||||
"""bandpass_freq=0 时的极端情况."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_freq=0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "bandpass=f=0:" in af_value
|
||||
|
||||
def test_very_high_noise_reduction(self):
|
||||
"""极高降噪强度."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", noise_reduction=100)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "afftdn=bn=100" in af_value
|
||||
|
||||
def test_negative_lowpass_allowed(self):
|
||||
"""lowpass 负值(由调用方保证合法性,函数不做校验)."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
||||
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3", lowpass=-10)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
af_value = cmd[cmd.index("-af") + 1]
|
||||
assert "lowpass=f=-10" in af_value
|
||||
|
||||
def test_run_ffmpeg_propagates_error(self):
|
||||
"""_run_ffmpeg 抛出异常时向上传递."""
|
||||
extractor = VoiceExtractor()
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg", side_effect=RuntimeError("FFmpeg failed")):
|
||||
with pytest.raises(RuntimeError, match="FFmpeg failed"):
|
||||
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3")
|
||||
|
||||
def test_voice_extractor_is_static_method(self):
|
||||
"""_run_ffmpeg 是静态方法,可在类上直接调用."""
|
||||
# 验证 VoiceExtractor 可以直接实例化(无需参数)
|
||||
extractor = VoiceExtractor()
|
||||
assert extractor is not None
|
||||
|
||||
def test_multiple_extractions_same_instance(self):
|
||||
"""同一个实例可多次执行提取."""
|
||||
extractor = VoiceExtractor()
|
||||
call_count = 0
|
||||
|
||||
def fake_run(cmd):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
|
||||
with patch.object(VoiceExtractor, "_run_ffmpeg", side_effect=fake_run):
|
||||
extractor.extract_voice("/tmp/a.mp4", "/tmp/a_voice.mp3")
|
||||
extractor.extract_background("/tmp/a.mp4", "/tmp/a_bg.mp3")
|
||||
|
||||
assert call_count == 2
|
||||
@@ -95,91 +95,73 @@ class TestVoiceLibraryItem:
|
||||
assert item.updated_at is not None
|
||||
|
||||
|
||||
class TestVoiceLibraryItemExtended:
|
||||
"""VoiceLibraryItem 深度补充测试"""
|
||||
# ── VoiceLibraryItem 更多边界测试 ──────────────────────────────────────
|
||||
|
||||
def test_zero_duration(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", duration=0)
|
||||
|
||||
class TestVoiceLibraryItemMore:
|
||||
"""VoiceLibraryItem 补充测试."""
|
||||
|
||||
def test_status_pending(self):
|
||||
"""pending状态."""
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status="pending")
|
||||
assert item.status == "pending"
|
||||
|
||||
def test_status_processing(self):
|
||||
"""processing状态."""
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status="processing")
|
||||
assert item.status == "processing"
|
||||
|
||||
def test_status_failed(self):
|
||||
"""failed状态."""
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status="failed")
|
||||
assert item.status == "failed"
|
||||
|
||||
def test_zero_duration_and_size(self):
|
||||
"""零时长零大小."""
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", duration=0, file_size=0)
|
||||
assert item.duration == 0
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负数 duration 领域层不校验"""
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", duration=-1.5)
|
||||
assert item.duration == -1.5
|
||||
|
||||
def test_large_duration(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", duration=9999.99)
|
||||
assert item.duration == 9999.99
|
||||
|
||||
def test_zero_file_size(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", file_size=0)
|
||||
assert item.file_size == 0
|
||||
|
||||
def test_large_file_size(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", file_size=10**9)
|
||||
assert item.file_size == 10**9
|
||||
def test_large_duration_and_size(self):
|
||||
"""大时长大文件."""
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", duration=3600.0, file_size=1024 * 1024 * 100)
|
||||
assert item.duration == 3600.0
|
||||
assert item.file_size == 104857600
|
||||
|
||||
def test_empty_audio_url(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", audio_url="")
|
||||
assert item.audio_url == ""
|
||||
|
||||
def test_tags_independence(self):
|
||||
item1 = VoiceLibraryItem(id="v1", user_id="u1", name="n1")
|
||||
item2 = VoiceLibraryItem(id="v2", user_id="u1", name="n2")
|
||||
item1.tags.append("新标签")
|
||||
assert "新标签" not in item2.tags
|
||||
assert len(item2.tags) == 0
|
||||
|
||||
def test_metadata_independence(self):
|
||||
item1 = VoiceLibraryItem(id="v1", user_id="u1", name="n1")
|
||||
item2 = VoiceLibraryItem(id="v2", user_id="u1", name="n2")
|
||||
item1.metadata_["key"] = "val"
|
||||
assert "key" not in item2.metadata_
|
||||
def test_empty_tags_list(self):
|
||||
"""空标签列表."""
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", tags=[])
|
||||
assert item.tags == []
|
||||
|
||||
def test_many_tags(self):
|
||||
tags = [f"tag_{i}" for i in range(30)]
|
||||
"""多个标签."""
|
||||
tags = ["温柔", "女声", "情感", "治愈", "朗读", "故事"]
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", tags=tags)
|
||||
assert len(item.tags) == 30
|
||||
assert item.tags[0] == "tag_0"
|
||||
assert len(item.tags) == 6
|
||||
assert "治愈" in item.tags
|
||||
|
||||
def test_empty_text(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", text="")
|
||||
assert item.text == ""
|
||||
def test_empty_metadata(self):
|
||||
"""空元数据."""
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
|
||||
assert item.metadata_ == {}
|
||||
|
||||
def test_long_text(self):
|
||||
long_text = "这是一段很长的配音文本" * 100
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", text=long_text)
|
||||
assert item.text == long_text
|
||||
assert len(item.text) == 1100
|
||||
def test_metadata_with_nested_values(self):
|
||||
"""嵌套元数据."""
|
||||
meta = {
|
||||
"settings": {"speed": 1.0, "pitch": 0.5},
|
||||
"source": "upload",
|
||||
"version": 2,
|
||||
}
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", metadata_=meta)
|
||||
assert item.metadata_["settings"]["speed"] == 1.0
|
||||
assert item.metadata_["source"] == "upload"
|
||||
|
||||
def test_empty_voice_id(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", voice_id="")
|
||||
assert item.voice_id == ""
|
||||
|
||||
def test_empty_project_id(self):
|
||||
"""project_id 默认是 None 不是空字符串"""
|
||||
def test_project_id_none_by_default(self):
|
||||
"""默认project_id为None."""
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n")
|
||||
assert item.project_id is None
|
||||
|
||||
def test_project_id_with_string(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", project_id="")
|
||||
# 传空字符串的话就是空字符串
|
||||
assert item.project_id == ""
|
||||
|
||||
def test_status_empty_string(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", status="")
|
||||
assert item.status == ""
|
||||
|
||||
def test_unicode_name(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="🎙️ 专业配音 · 龙小淳")
|
||||
assert "🎙️" in item.name
|
||||
assert "龙小淳" in item.name
|
||||
|
||||
def test_special_characters_in_name(self):
|
||||
special = "配!@#$%^&*()音"
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name=special)
|
||||
assert item.name == special
|
||||
|
||||
def test_empty_user_id(self):
|
||||
item = VoiceLibraryItem(id="v1", user_id="", name="n")
|
||||
assert item.user_id == ""
|
||||
def test_project_id_set(self):
|
||||
"""设置project_id."""
|
||||
item = VoiceLibraryItem(id="v1", user_id="u1", name="n", project_id="proj-abc-123")
|
||||
assert item.project_id == "proj-abc-123"
|
||||
|
||||
Reference in New Issue
Block a user