Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b0a1aa743 | |||
| 76e6c6aef1 | |||
| 12fd78191d | |||
| 07d9b56fe7 | |||
| 11b3f83368 | |||
| 1dc40b4760 | |||
| 29448aaf9b | |||
| c73c4be367 | |||
| c31bc96855 | |||
| 002384bad4 | |||
| db6b742ebb | |||
| 88ca8b4406 | |||
| 005b34d0dd | |||
| e2ddc679bb | |||
| 39990f7a07 | |||
| 6fcb4cd70c | |||
| 14fefc7b1a | |||
| fa8ab58fc0 | |||
| 4765d83c8b | |||
| 395bc37cf0 | |||
| 15b6e17552 | |||
| a6e147ed30 | |||
| 0d46d71b2d | |||
| b40ae9cec7 | |||
| eb636b8fef | |||
| 09b069a65d | |||
| 378e4c8751 | |||
| f9afdd95ce | |||
| 456718ad84 | |||
| edd4b6b1ea | |||
| 4578b65965 |
@@ -0,0 +1,110 @@
|
||||
import React from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||
import { formatSize, formatDuration, getQualityLevel } from "./utils"
|
||||
|
||||
interface AssetCardProps {
|
||||
asset: MediaAsset
|
||||
isSelected: boolean
|
||||
isDragging: boolean
|
||||
isDragOver: boolean
|
||||
showCheckbox: boolean
|
||||
compact?: boolean
|
||||
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
|
||||
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
|
||||
onDragStart: (e: React.DragEvent) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onDragEnd: () => void
|
||||
onMouseEnter: (e: React.MouseEvent) => void
|
||||
onMouseLeave: () => void
|
||||
}
|
||||
|
||||
const AssetCard: React.FC<AssetCardProps> = ({
|
||||
asset,
|
||||
isSelected,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
showCheckbox,
|
||||
onToggleSelect,
|
||||
onCardClick,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}) => {
|
||||
const qualityLevel = getQualityLevel(asset.quality_score)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"as-card",
|
||||
isSelected ? "selected" : "",
|
||||
isDragging ? "dragging" : "",
|
||||
isDragOver ? "drag-over" : "",
|
||||
showCheckbox ? "has-checkbox" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
onClick={(e) => onCardClick(asset, e)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
<div className="as-card-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
|
||||
) : (
|
||||
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
)}
|
||||
|
||||
{/* Checkbox */}
|
||||
{showCheckbox && (
|
||||
<span
|
||||
data-checkbox
|
||||
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(asset, e.shiftKey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 类型角标 */}
|
||||
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
|
||||
|
||||
{/* 时长角标 */}
|
||||
{asset.duration != null && (
|
||||
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
|
||||
)}
|
||||
|
||||
{/* 质量分角标 */}
|
||||
{asset.quality_score != null && (
|
||||
<span
|
||||
className={`as-card-quality ${qualityLevel}`}
|
||||
title={`质量分: ${asset.quality_score}`}
|
||||
>
|
||||
{asset.quality_score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-card-info">
|
||||
<p className="as-card-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="as-card-meta">{formatSize(asset.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetCard
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||
import { formatSize, formatDuration, getQualityColor } from "./utils"
|
||||
|
||||
interface AssetListItemProps {
|
||||
asset: MediaAsset
|
||||
isSelected: boolean
|
||||
isDragging: boolean
|
||||
isDragOver: boolean
|
||||
showCheckbox: boolean
|
||||
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
|
||||
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
|
||||
onDragStart: (e: React.DragEvent) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onDragEnd: () => void
|
||||
onMouseEnter: (e: React.MouseEvent) => void
|
||||
onMouseLeave: () => void
|
||||
}
|
||||
|
||||
const AssetListItem: React.FC<AssetListItemProps> = ({
|
||||
asset,
|
||||
isSelected,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
showCheckbox,
|
||||
onToggleSelect,
|
||||
onCardClick,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"as-list-item",
|
||||
isSelected ? "selected" : "",
|
||||
isDragging ? "dragging" : "",
|
||||
isDragOver ? "drag-over" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
onClick={(e) => onCardClick(asset, e)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<span className="as-list-item-drag" title="拖拽排序">
|
||||
⠿
|
||||
</span>
|
||||
|
||||
{/* Checkbox */}
|
||||
{showCheckbox && (
|
||||
<span
|
||||
data-checkbox
|
||||
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(asset, e.shiftKey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-list-item-info">
|
||||
<div className="as-list-item-name">{asset.name}</div>
|
||||
<div className="as-list-item-meta">
|
||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
||||
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 质量分 */}
|
||||
{asset.quality_score != null && (
|
||||
<span
|
||||
className="as-list-item-quality"
|
||||
style={{ color: getQualityColor(asset.quality_score) }}
|
||||
>
|
||||
{asset.quality_score}分
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetListItem
|
||||
@@ -9,73 +9,20 @@
|
||||
* - 筛选增强:类型筛选 + 质量分筛选
|
||||
* - 视图切换:网格视图 / 列表视图
|
||||
*/
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
|
||||
import React, { useCallback } from "react"
|
||||
import "./AssetSelector.css"
|
||||
import { Input, Select, Button } from "@/components/ui"
|
||||
import type { AssetSelectorProps } from "./types"
|
||||
import useAssetFilter from "./hooks/useAssetFilter"
|
||||
import useAssetSelection from "./hooks/useAssetSelection"
|
||||
import useDragReorder from "./hooks/useDragReorder"
|
||||
import useAssetPreview from "./hooks/useAssetPreview"
|
||||
import SelectorToolbar from "./SelectorToolbar"
|
||||
import SelectorBatchBar from "./SelectorBatchBar"
|
||||
import AssetCard from "./AssetCard"
|
||||
import AssetListItem from "./AssetListItem"
|
||||
import PreviewOverlay from "./PreviewOverlay"
|
||||
import EmptyState from "./EmptyState"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/template-editor"
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export interface AssetSelectorProps {
|
||||
assets: MediaAsset[]
|
||||
selectedIds?: string[]
|
||||
onSelectionChange?: (ids: string[]) => void
|
||||
onAssetDragStart?: (asset: MediaAsset) => void
|
||||
onReorder?: (fromIdx: number, toIdx: number) => void
|
||||
showQualityFilter?: boolean
|
||||
showBatchSelect?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
type ViewMode = "grid" | "list"
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatSize = (bytes?: number): string => {
|
||||
if (!bytes) return ""
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return ""
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
|
||||
}
|
||||
|
||||
/** 获取质量分等级 */
|
||||
const getQualityLevel = (score?: number): string => {
|
||||
if (score == null) return "none"
|
||||
if (score >= 90) return "excellent"
|
||||
if (score >= 70) return "good"
|
||||
if (score >= 50) return "fair"
|
||||
return "poor"
|
||||
}
|
||||
|
||||
/** 质量分颜色 */
|
||||
const getQualityColor = (score?: number): string => {
|
||||
if (score == null) return "var(--text-secondary)"
|
||||
if (score >= 90) return "var(--success-color, #10b981)"
|
||||
if (score >= 70) return "var(--primary-color, #6366f1)"
|
||||
if (score >= 50) return "var(--warning-color, #f59e0b)"
|
||||
return "var(--error-color, #ef4444)"
|
||||
}
|
||||
|
||||
/* ──────────── 类型筛选选项 ──────────── */
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: "", label: "全部类型" },
|
||||
{ value: "video", label: "🎬 视频" },
|
||||
{ value: "image", label: "🖼️ 图片" },
|
||||
{ value: "audio", label: "🎵 音频" },
|
||||
]
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
assets,
|
||||
@@ -87,162 +34,41 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
showBatchSelect = true,
|
||||
compact = false,
|
||||
}) => {
|
||||
/* ── 搜索 & 筛选 ── */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState("")
|
||||
const [filterQuality, setFilterQuality] = useState("")
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid")
|
||||
// 搜索 & 筛选
|
||||
const {
|
||||
searchText,
|
||||
filterType,
|
||||
filterQuality,
|
||||
viewMode,
|
||||
filteredAssets,
|
||||
setSearchText,
|
||||
setFilterType,
|
||||
setFilterQuality,
|
||||
setViewMode,
|
||||
} = useAssetFilter(assets)
|
||||
|
||||
/* ── 拖拽状态 ── */
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
// 选中操作
|
||||
const { selectedSet, toggleSelect, clearSelection } = useAssetSelection({
|
||||
filteredAssets,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
})
|
||||
|
||||
/* ── 悬浮预览 ── */
|
||||
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
|
||||
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
|
||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// 拖拽排序
|
||||
const { dragIdx, dragOverIdx, handleDragStart, handleDragOver, handleDrop, handleDragEnd } =
|
||||
useDragReorder({
|
||||
filteredAssets,
|
||||
selectedIds,
|
||||
onAssetDragStart,
|
||||
onReorder,
|
||||
})
|
||||
|
||||
/* ── Shift 连选 ── */
|
||||
const lastClickedIdx = useRef<number | null>(null)
|
||||
|
||||
/* ── 过滤后的素材列表 ── */
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets
|
||||
if (searchText) {
|
||||
const q = searchText.toLowerCase()
|
||||
list = list.filter(
|
||||
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
|
||||
)
|
||||
}
|
||||
if (filterType) {
|
||||
list = list.filter((a) => a.type === filterType)
|
||||
}
|
||||
if (filterQuality) {
|
||||
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
|
||||
if (opt?.min != null && opt?.max != null) {
|
||||
list = list.filter(
|
||||
(a) =>
|
||||
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
|
||||
)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}, [assets, searchText, filterType, filterQuality])
|
||||
|
||||
/* ── 选中状态 ── */
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
|
||||
/* ── 选择操作 ── */
|
||||
const toggleSelect = useCallback(
|
||||
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
|
||||
if (!onSelectionChange) return
|
||||
|
||||
if (shiftKey && lastClickedIdx.current !== null) {
|
||||
// Shift 连选
|
||||
const start = Math.min(lastClickedIdx.current, idx)
|
||||
const end = Math.max(lastClickedIdx.current, idx)
|
||||
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
|
||||
const newSet = new Set(selectedIds)
|
||||
rangeIds.forEach((id) => newSet.add(id))
|
||||
onSelectionChange(Array.from(newSet))
|
||||
} else {
|
||||
const newSet = new Set(selectedIds)
|
||||
if (newSet.has(asset.id)) {
|
||||
newSet.delete(asset.id)
|
||||
} else {
|
||||
newSet.add(asset.id)
|
||||
}
|
||||
onSelectionChange(Array.from(newSet))
|
||||
}
|
||||
lastClickedIdx.current = idx
|
||||
},
|
||||
[onSelectionChange, selectedIds, filteredAssets],
|
||||
)
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
onSelectionChange?.([])
|
||||
}, [onSelectionChange])
|
||||
|
||||
/* ── 拖拽排序 ── */
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
setDragIdx(idx)
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
e.dataTransfer.setData("text/plain", String(idx))
|
||||
// 设置素材数据,供 TimelinePanel 接收(P1-2 修复)
|
||||
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
|
||||
// 批量拖拽:如果有多个选中素材,一起携带
|
||||
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
|
||||
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
|
||||
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
|
||||
}
|
||||
// 通知父组件素材拖拽开始
|
||||
if (onAssetDragStart) {
|
||||
onAssetDragStart(filteredAssets[idx])
|
||||
}
|
||||
},
|
||||
[filteredAssets, selectedIds, onAssetDragStart],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = "move"
|
||||
if (dragIdx === null || dragIdx === idx) return
|
||||
setDragOverIdx(idx)
|
||||
},
|
||||
[dragIdx],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, toIdx: number) => {
|
||||
e.preventDefault()
|
||||
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
|
||||
onReorder(dragIdx, toIdx)
|
||||
}
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
},
|
||||
[dragIdx, onReorder],
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
}, [])
|
||||
|
||||
/* ── 悬浮预览 ── */
|
||||
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||
previewTimer.current = setTimeout(() => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
setPreviewAsset(asset)
|
||||
setPreviewPos({
|
||||
x: rect.right + 12,
|
||||
y: Math.max(8, rect.top - 20),
|
||||
})
|
||||
}, 400)
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (previewTimer.current) {
|
||||
clearTimeout(previewTimer.current)
|
||||
previewTimer.current = null
|
||||
}
|
||||
setPreviewAsset(null)
|
||||
}, [])
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||
}
|
||||
}, [])
|
||||
// 悬浮预览
|
||||
const { previewAsset, previewPos, handleMouseEnter, handleMouseLeave } = useAssetPreview()
|
||||
|
||||
/* ── 点击卡片 ── */
|
||||
const handleCardClick = useCallback(
|
||||
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
|
||||
// 如果点击的是 checkbox 区域,不触发卡片点击
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest("[data-checkbox]")) return
|
||||
toggleSelect(asset, idx, e.shiftKey)
|
||||
@@ -250,253 +76,82 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
[toggleSelect],
|
||||
)
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
|
||||
const hasSelection = selectedIds.length > 0
|
||||
|
||||
return (
|
||||
<div className="as-container">
|
||||
{/* ═══ 工具栏 ═══ */}
|
||||
<div className="as-toolbar">
|
||||
<div className="as-toolbar-left">
|
||||
<div className="as-search">
|
||||
<Input
|
||||
placeholder="搜索素材..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
prefix="🔍"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={(v: string) => setFilterType(v)}
|
||||
options={TYPE_OPTIONS}
|
||||
/>
|
||||
{showQualityFilter && (
|
||||
<Select
|
||||
value={filterQuality}
|
||||
onChange={(v: string) => setFilterQuality(v)}
|
||||
options={QUALITY_OPTIONS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="as-toolbar-right">
|
||||
<button
|
||||
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
|
||||
onClick={() => setViewMode("grid")}
|
||||
title="网格视图"
|
||||
>
|
||||
⊞
|
||||
</button>
|
||||
<button
|
||||
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
|
||||
onClick={() => setViewMode("list")}
|
||||
title="列表视图"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 工具栏 */}
|
||||
<SelectorToolbar
|
||||
searchText={searchText}
|
||||
filterType={filterType}
|
||||
filterQuality={filterQuality}
|
||||
viewMode={viewMode}
|
||||
showQualityFilter={showQualityFilter}
|
||||
onSearchChange={setSearchText}
|
||||
onTypeChange={setFilterType}
|
||||
onQualityChange={setFilterQuality}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* ═══ 批量操作栏 ═══ */}
|
||||
{/* 批量操作栏 */}
|
||||
{showBatchSelect && hasSelection && (
|
||||
<div className="as-batch-bar">
|
||||
<span className="as-batch-bar-count">已选 {selectedIds.length} 项</span>
|
||||
<div className="as-batch-bar-actions">
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SelectorBatchBar selectedCount={selectedIds.length} onClear={clearSelection} />
|
||||
)}
|
||||
|
||||
{/* ═══ 素材列表 ═══ */}
|
||||
{/* 素材列表 */}
|
||||
<div className="as-body">
|
||||
{filteredAssets.length === 0 ? (
|
||||
<div className="as-empty">
|
||||
<div className="as-empty-icon">📂</div>
|
||||
<p>暂无素材</p>
|
||||
</div>
|
||||
<EmptyState />
|
||||
) : viewMode === "grid" ? (
|
||||
/* ── 网格视图 ── */
|
||||
/* 网格视图 */
|
||||
<div className={`as-grid${compact ? " compact" : ""}`}>
|
||||
{filteredAssets.map((asset, idx) => {
|
||||
const isSelected = selectedSet.has(asset.id)
|
||||
const isDragging = dragIdx === idx
|
||||
const isDragOver = dragOverIdx === idx
|
||||
const qualityLevel = getQualityLevel(asset.quality_score)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={[
|
||||
"as-card",
|
||||
isSelected ? "selected" : "",
|
||||
isDragging ? "dragging" : "",
|
||||
isDragOver ? "drag-over" : "",
|
||||
showBatchSelect ? "has-checkbox" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => handleCardClick(asset, idx, e)}
|
||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
<div className="as-card-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
|
||||
) : (
|
||||
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
)}
|
||||
|
||||
{/* Checkbox */}
|
||||
{showBatchSelect && (
|
||||
<span
|
||||
data-checkbox
|
||||
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleSelect(asset, idx, e.shiftKey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 类型角标 */}
|
||||
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
|
||||
|
||||
{/* 时长角标 */}
|
||||
{asset.duration != null && (
|
||||
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
|
||||
)}
|
||||
|
||||
{/* 质量分角标 */}
|
||||
{asset.quality_score != null && (
|
||||
<span
|
||||
className={`as-card-quality ${qualityLevel}`}
|
||||
title={`质量分: ${asset.quality_score}`}
|
||||
>
|
||||
{asset.quality_score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-card-info">
|
||||
<p className="as-card-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="as-card-meta">{formatSize(asset.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filteredAssets.map((asset, idx) => (
|
||||
<AssetCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
isSelected={selectedSet.has(asset.id)}
|
||||
isDragging={dragIdx === idx}
|
||||
isDragOver={dragOverIdx === idx}
|
||||
showCheckbox={showBatchSelect}
|
||||
compact={compact}
|
||||
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
|
||||
onCardClick={(a, e) => handleCardClick(a, idx, e)}
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* ── 列表视图 ── */
|
||||
/* 列表视图 */
|
||||
<div className="as-list">
|
||||
{filteredAssets.map((asset, idx) => {
|
||||
const isSelected = selectedSet.has(asset.id)
|
||||
const isDragging = dragIdx === idx
|
||||
const isDragOver = dragOverIdx === idx
|
||||
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={[
|
||||
"as-list-item",
|
||||
isSelected ? "selected" : "",
|
||||
isDragging ? "dragging" : "",
|
||||
isDragOver ? "drag-over" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => handleCardClick(asset, idx, e)}
|
||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<span className="as-list-item-drag" title="拖拽排序">
|
||||
⠿
|
||||
</span>
|
||||
|
||||
{/* Checkbox */}
|
||||
{showBatchSelect && (
|
||||
<span
|
||||
data-checkbox
|
||||
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleSelect(asset, idx, e.shiftKey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-list-item-info">
|
||||
<div className="as-list-item-name">{asset.name}</div>
|
||||
<div className="as-list-item-meta">
|
||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
||||
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 质量分 */}
|
||||
{asset.quality_score != null && (
|
||||
<span
|
||||
className="as-list-item-quality"
|
||||
style={{ color: getQualityColor(asset.quality_score) }}
|
||||
>
|
||||
{asset.quality_score}分
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filteredAssets.map((asset, idx) => (
|
||||
<AssetListItem
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
isSelected={selectedSet.has(asset.id)}
|
||||
isDragging={dragIdx === idx}
|
||||
isDragOver={dragOverIdx === idx}
|
||||
showCheckbox={showBatchSelect}
|
||||
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
|
||||
onCardClick={(a, e) => handleCardClick(a, idx, e)}
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onMouseEnter={(e) => handleMouseEnter(asset, e)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 悬浮预览 ═══ */}
|
||||
{previewAsset && (
|
||||
<div className="as-preview-overlay" style={{ left: previewPos.x, top: previewPos.y }}>
|
||||
<div className="as-preview-overlay-thumb">
|
||||
{previewAsset.thumbnail_url ? (
|
||||
<img src={previewAsset.thumbnail_url} alt={previewAsset.name} />
|
||||
) : (
|
||||
<span className="as-preview-overlay-thumb-icon">
|
||||
{MATERIAL_TYPE_ICONS[previewAsset.type]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="as-preview-overlay-name">{previewAsset.name}</p>
|
||||
<div className="as-preview-overlay-meta">
|
||||
<span>类型: {MATERIAL_TYPE_LABELS[previewAsset.type]}</span>
|
||||
{previewAsset.duration != null && (
|
||||
<span>时长: {formatDuration(previewAsset.duration)}</span>
|
||||
)}
|
||||
{previewAsset.size != null && <span>大小: {formatSize(previewAsset.size)}</span>}
|
||||
{previewAsset.quality_score != null && (
|
||||
<span>质量分: {previewAsset.quality_score}</span>
|
||||
)}
|
||||
{previewAsset.tags.length > 0 && <span>标签: {previewAsset.tags.join(", ")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 悬浮预览 */}
|
||||
{previewAsset && <PreviewOverlay asset={previewAsset} position={previewPos} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react"
|
||||
|
||||
const EmptyState: React.FC = () => {
|
||||
return (
|
||||
<div className="as-empty">
|
||||
<div className="as-empty-icon">📂</div>
|
||||
<p>暂无素材</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
|
||||
import { formatSize, formatDuration } from "./utils"
|
||||
|
||||
interface PreviewOverlayProps {
|
||||
asset: MediaAsset
|
||||
position: { x: number; y: number }
|
||||
}
|
||||
|
||||
const PreviewOverlay: React.FC<PreviewOverlayProps> = ({ asset, position }) => {
|
||||
return (
|
||||
<div className="as-preview-overlay" style={{ left: position.x, top: position.y }}>
|
||||
<div className="as-preview-overlay-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<span className="as-preview-overlay-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="as-preview-overlay-name">{asset.name}</p>
|
||||
<div className="as-preview-overlay-meta">
|
||||
<span>类型: {MATERIAL_TYPE_LABELS[asset.type]}</span>
|
||||
{asset.duration != null && <span>时长: {formatDuration(asset.duration)}</span>}
|
||||
{asset.size != null && <span>大小: {formatSize(asset.size)}</span>}
|
||||
{asset.quality_score != null && <span>质量分: {asset.quality_score}</span>}
|
||||
{asset.tags.length > 0 && <span>标签: {asset.tags.join(", ")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PreviewOverlay
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
interface SelectorBatchBarProps {
|
||||
selectedCount: number
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
const SelectorBatchBar: React.FC<SelectorBatchBarProps> = ({ selectedCount, onClear }) => {
|
||||
return (
|
||||
<div className="as-batch-bar">
|
||||
<span className="as-batch-bar-count">已选 {selectedCount} 项</span>
|
||||
<div className="as-batch-bar-actions">
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={onClear}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectorBatchBar
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from "react"
|
||||
import { Input, Select } from "@/components/ui"
|
||||
import { TYPE_OPTIONS } from "./constants"
|
||||
import { QUALITY_OPTIONS } from "@/api/template-editor"
|
||||
import type { ViewMode } from "./types"
|
||||
|
||||
interface SelectorToolbarProps {
|
||||
searchText: string
|
||||
filterType: string
|
||||
filterQuality: string
|
||||
viewMode: ViewMode
|
||||
showQualityFilter: boolean
|
||||
onSearchChange: (value: string) => void
|
||||
onTypeChange: (value: string) => void
|
||||
onQualityChange: (value: string) => void
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
}
|
||||
|
||||
const SelectorToolbar: React.FC<SelectorToolbarProps> = ({
|
||||
searchText,
|
||||
filterType,
|
||||
filterQuality,
|
||||
viewMode,
|
||||
showQualityFilter,
|
||||
onSearchChange,
|
||||
onTypeChange,
|
||||
onQualityChange,
|
||||
onViewModeChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="as-toolbar">
|
||||
<div className="as-toolbar-left">
|
||||
<div className="as-search">
|
||||
<Input
|
||||
placeholder="搜索素材..."
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
prefix="🔍"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={(v: string) => onTypeChange(v)}
|
||||
options={TYPE_OPTIONS}
|
||||
/>
|
||||
{showQualityFilter && (
|
||||
<Select
|
||||
value={filterQuality}
|
||||
onChange={(v: string) => onQualityChange(v)}
|
||||
options={QUALITY_OPTIONS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="as-toolbar-right">
|
||||
<button
|
||||
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
|
||||
onClick={() => onViewModeChange("grid")}
|
||||
title="网格视图"
|
||||
>
|
||||
⊞
|
||||
</button>
|
||||
<button
|
||||
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
|
||||
onClick={() => onViewModeChange("list")}
|
||||
title="列表视图"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectorToolbar
|
||||
@@ -0,0 +1,7 @@
|
||||
/** 类型筛选选项 */
|
||||
export const TYPE_OPTIONS = [
|
||||
{ value: "", label: "全部类型" },
|
||||
{ value: "video", label: "🎬 视频" },
|
||||
{ value: "image", label: "🖼️ 图片" },
|
||||
{ value: "audio", label: "🎵 音频" },
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { QUALITY_OPTIONS } from "@/api/template-editor"
|
||||
import type { ViewMode } from "../types"
|
||||
|
||||
interface UseAssetFilterReturn {
|
||||
searchText: string
|
||||
filterType: string
|
||||
filterQuality: string
|
||||
viewMode: ViewMode
|
||||
filteredAssets: MediaAsset[]
|
||||
setSearchText: (value: string) => void
|
||||
setFilterType: (value: string) => void
|
||||
setFilterQuality: (value: string) => void
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材筛选 Hook —— 搜索 + 类型 + 质量分 + 视图切换
|
||||
*/
|
||||
const useAssetFilter = (assets: MediaAsset[]): UseAssetFilterReturn => {
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState("")
|
||||
const [filterQuality, setFilterQuality] = useState("")
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid")
|
||||
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets
|
||||
if (searchText) {
|
||||
const q = searchText.toLowerCase()
|
||||
list = list.filter(
|
||||
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
|
||||
)
|
||||
}
|
||||
if (filterType) {
|
||||
list = list.filter((a) => a.type === filterType)
|
||||
}
|
||||
if (filterQuality) {
|
||||
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
|
||||
if (opt?.min != null && opt?.max != null) {
|
||||
list = list.filter(
|
||||
(a) =>
|
||||
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
|
||||
)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}, [assets, searchText, filterType, filterQuality])
|
||||
|
||||
return {
|
||||
searchText,
|
||||
filterType,
|
||||
filterQuality,
|
||||
viewMode,
|
||||
filteredAssets,
|
||||
setSearchText,
|
||||
setFilterType,
|
||||
setFilterQuality,
|
||||
setViewMode,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAssetFilter
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
interface UseAssetPreviewReturn {
|
||||
previewAsset: MediaAsset | null
|
||||
previewPos: { x: number; y: number }
|
||||
handleMouseEnter: (asset: MediaAsset, e: React.MouseEvent) => void
|
||||
handleMouseLeave: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬浮预览 Hook —— 延迟 400ms 显示预览浮层
|
||||
*/
|
||||
const useAssetPreview = (): UseAssetPreviewReturn => {
|
||||
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
|
||||
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
|
||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||
previewTimer.current = setTimeout(() => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
setPreviewAsset(asset)
|
||||
setPreviewPos({
|
||||
x: rect.right + 12,
|
||||
y: Math.max(8, rect.top - 20),
|
||||
})
|
||||
}, 400)
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (previewTimer.current) {
|
||||
clearTimeout(previewTimer.current)
|
||||
previewTimer.current = null
|
||||
}
|
||||
setPreviewAsset(null)
|
||||
}, [])
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
previewAsset,
|
||||
previewPos,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAssetPreview
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useRef, useMemo } from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
interface UseAssetSelectionOptions {
|
||||
filteredAssets: MediaAsset[]
|
||||
selectedIds: string[]
|
||||
onSelectionChange?: (ids: string[]) => void
|
||||
}
|
||||
|
||||
interface UseAssetSelectionReturn {
|
||||
selectedSet: Set<string>
|
||||
toggleSelect: (asset: MediaAsset, idx: number, shiftKey: boolean) => void
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材选择 Hook —— 单选/多选/Shift 连选
|
||||
*/
|
||||
const useAssetSelection = ({
|
||||
filteredAssets,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
}: UseAssetSelectionOptions): UseAssetSelectionReturn => {
|
||||
const lastClickedIdx = useRef<number | null>(null)
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
|
||||
const toggleSelect = useCallback(
|
||||
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
|
||||
if (!onSelectionChange) return
|
||||
|
||||
if (shiftKey && lastClickedIdx.current !== null) {
|
||||
// Shift 连选
|
||||
const start = Math.min(lastClickedIdx.current, idx)
|
||||
const end = Math.max(lastClickedIdx.current, idx)
|
||||
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
|
||||
const newSet = new Set(selectedIds)
|
||||
rangeIds.forEach((id) => newSet.add(id))
|
||||
onSelectionChange(Array.from(newSet))
|
||||
} else {
|
||||
const newSet = new Set(selectedIds)
|
||||
if (newSet.has(asset.id)) {
|
||||
newSet.delete(asset.id)
|
||||
} else {
|
||||
newSet.add(asset.id)
|
||||
}
|
||||
onSelectionChange(Array.from(newSet))
|
||||
}
|
||||
lastClickedIdx.current = idx
|
||||
},
|
||||
[onSelectionChange, selectedIds, filteredAssets],
|
||||
)
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
onSelectionChange?.([])
|
||||
}, [onSelectionChange])
|
||||
|
||||
return {
|
||||
selectedSet,
|
||||
toggleSelect,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAssetSelection
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
interface UseDragReorderOptions {
|
||||
filteredAssets: MediaAsset[]
|
||||
selectedIds: string[]
|
||||
onAssetDragStart?: (asset: MediaAsset) => void
|
||||
onReorder?: (fromIdx: number, toIdx: number) => void
|
||||
}
|
||||
|
||||
interface UseDragReorderReturn {
|
||||
dragIdx: number | null
|
||||
dragOverIdx: number | null
|
||||
handleDragStart: (e: React.DragEvent, idx: number) => void
|
||||
handleDragOver: (e: React.DragEvent, idx: number) => void
|
||||
handleDrop: (e: React.DragEvent, toIdx: number) => void
|
||||
handleDragEnd: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽排序 Hook —— HTML5 DnD,支持批量拖拽携带数据
|
||||
*/
|
||||
const useDragReorder = ({
|
||||
filteredAssets,
|
||||
selectedIds,
|
||||
onAssetDragStart,
|
||||
onReorder,
|
||||
}: UseDragReorderOptions): UseDragReorderReturn => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
setDragIdx(idx)
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
e.dataTransfer.setData("text/plain", String(idx))
|
||||
// 设置素材数据,供 TimelinePanel 接收
|
||||
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
|
||||
// 批量拖拽:如果有多个选中素材,一起携带
|
||||
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
|
||||
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
|
||||
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
|
||||
}
|
||||
// 通知父组件素材拖拽开始
|
||||
if (onAssetDragStart) {
|
||||
onAssetDragStart(filteredAssets[idx])
|
||||
}
|
||||
},
|
||||
[filteredAssets, selectedIds, onAssetDragStart],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = "move"
|
||||
if (dragIdx === null || dragIdx === idx) return
|
||||
setDragOverIdx(idx)
|
||||
},
|
||||
[dragIdx],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, toIdx: number) => {
|
||||
e.preventDefault()
|
||||
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
|
||||
onReorder(dragIdx, toIdx)
|
||||
}
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
},
|
||||
[dragIdx, onReorder],
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
dragIdx,
|
||||
dragOverIdx,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
handleDragEnd,
|
||||
}
|
||||
}
|
||||
|
||||
export default useDragReorder
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as AssetSelector } from "./AssetSelector"
|
||||
export type { AssetSelectorProps } from "./AssetSelector"
|
||||
export type { AssetSelectorProps, ViewMode } from "./types"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
export interface AssetSelectorProps {
|
||||
assets: MediaAsset[]
|
||||
selectedIds?: string[]
|
||||
onSelectionChange?: (ids: string[]) => void
|
||||
onAssetDragStart?: (asset: MediaAsset) => void
|
||||
onReorder?: (fromIdx: number, toIdx: number) => void
|
||||
showQualityFilter?: boolean
|
||||
showBatchSelect?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export type ViewMode = "grid" | "list"
|
||||
@@ -0,0 +1,33 @@
|
||||
/** 格式化文件大小 */
|
||||
export const formatSize = (bytes?: number): string => {
|
||||
if (!bytes) return ""
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
/** 格式化时长 */
|
||||
export const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return ""
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
|
||||
}
|
||||
|
||||
/** 获取质量分等级 */
|
||||
export const getQualityLevel = (score?: number): string => {
|
||||
if (score == null) return "none"
|
||||
if (score >= 90) return "excellent"
|
||||
if (score >= 70) return "good"
|
||||
if (score >= 50) return "fair"
|
||||
return "poor"
|
||||
}
|
||||
|
||||
/** 质量分颜色 */
|
||||
export const getQualityColor = (score?: number): string => {
|
||||
if (score == null) return "var(--text-secondary)"
|
||||
if (score >= 90) return "var(--success-color, #10b981)"
|
||||
if (score >= 70) return "var(--primary-color, #6366f1)"
|
||||
if (score >= 50) return "var(--warning-color, #f59e0b)"
|
||||
return "var(--error-color, #ef4444)"
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import type { ModalPhase } from "../types/cloneModal"
|
||||
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
|
||||
import useAudioRecorder from "./useAudioRecorder"
|
||||
|
||||
/**
|
||||
* 克隆弹窗表单状态 Hook
|
||||
* 管理表单字段、录音、文件选择、验证逻辑
|
||||
*/
|
||||
export function useCloneFormState({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||
const [voiceName, setVoiceName] = useState("")
|
||||
const [voiceDescription, setVoiceDescription] = useState("")
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
|
||||
useAudioRecorder()
|
||||
|
||||
/** 默认音色名称计数器 */
|
||||
const cloneCounterRef = useRef(1)
|
||||
|
||||
const getNextDefaultName = useCallback((): string => {
|
||||
const name = `我的声音 ${cloneCounterRef.current}`
|
||||
cloneCounterRef.current += 1
|
||||
return name
|
||||
}, [])
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
|
||||
const canSubmit =
|
||||
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
|
||||
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
|
||||
hasAudio
|
||||
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input")
|
||||
setVoiceName(getNextDefaultName())
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
setErrorMessage("")
|
||||
resetRecording()
|
||||
}, [getNextDefaultName, resetRecording])
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [resetState, onClose])
|
||||
|
||||
/** 弹窗打开时重置状态 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState()
|
||||
}
|
||||
}, [open, resetState])
|
||||
|
||||
/** 选择文件(来自上传或拖拽) */
|
||||
const handleFileSelect = useCallback(
|
||||
(file: File | null, error: string) => {
|
||||
if (error) {
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
// 清除录音
|
||||
resetRecording()
|
||||
}
|
||||
},
|
||||
[resetRecording],
|
||||
)
|
||||
|
||||
/** 录音切换 */
|
||||
const handleRecordToggle = useCallback(() => {
|
||||
setErrorMessage("")
|
||||
if (isRecording) {
|
||||
toggleRecording()
|
||||
} else {
|
||||
// 开始录制前清除已选文件
|
||||
setSelectedFile(null)
|
||||
toggleRecording()
|
||||
}
|
||||
}, [isRecording, toggleRecording])
|
||||
|
||||
/** 表单验证 */
|
||||
const validateForm = useCallback((): string | null => {
|
||||
const name = voiceName.trim()
|
||||
if (!name) {
|
||||
return "请输入音色名称"
|
||||
}
|
||||
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
|
||||
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
|
||||
}
|
||||
if (!hasAudio) {
|
||||
return "请上传音频文件或录制一段声音"
|
||||
}
|
||||
return null
|
||||
}, [voiceName, hasAudio])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
phase,
|
||||
setPhase,
|
||||
voiceName,
|
||||
setVoiceName,
|
||||
voiceDescription,
|
||||
setVoiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
setDragActive,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
// 录音
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
// 计算属性
|
||||
hasAudio,
|
||||
canSubmit,
|
||||
isProcessing,
|
||||
// handlers
|
||||
handleFileSelect,
|
||||
handleRecordToggle,
|
||||
handleClose,
|
||||
validateForm,
|
||||
resetState,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+33
-202
@@ -1,213 +1,44 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset } from "@/api/assets"
|
||||
import type { ModalPhase, CloneModalProps } from "../types/cloneModal"
|
||||
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
|
||||
import useAudioRecorder from "./useAudioRecorder"
|
||||
|
||||
interface UseCloneModalReturn {
|
||||
phase: ModalPhase
|
||||
voiceName: string
|
||||
voiceDescription: string
|
||||
selectedFile: File | null
|
||||
dragActive: boolean
|
||||
errorMessage: string
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
canSubmit: boolean
|
||||
isProcessing: boolean
|
||||
setVoiceName: (value: string) => void
|
||||
setVoiceDescription: (value: string) => void
|
||||
setDragActive: (active: boolean) => void
|
||||
handleFileSelect: (file: File | null, error: string) => void
|
||||
handleRecordToggle: () => void
|
||||
handleClose: () => void
|
||||
handleSubmit: () => void
|
||||
}
|
||||
import type { CloneModalProps } from "../types/cloneModal"
|
||||
import { useCloneFormState } from "./useCloneFormState"
|
||||
import { useCloneSubmit } from "./useCloneSubmit"
|
||||
|
||||
/**
|
||||
* 音色克隆弹窗主业务 Hook
|
||||
* 组合表单状态 + 提交流程两个子 Hook
|
||||
*/
|
||||
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps): UseCloneModalReturn => {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||
const [voiceName, setVoiceName] = useState("")
|
||||
const [voiceDescription, setVoiceDescription] = useState("")
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => {
|
||||
const formState = useCloneFormState({ open, onClose })
|
||||
|
||||
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
|
||||
useAudioRecorder()
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** 默认音色名称计数器 */
|
||||
const cloneCounterRef = useRef(1)
|
||||
|
||||
const getNextDefaultName = useCallback((): string => {
|
||||
const name = `我的声音 ${cloneCounterRef.current}`
|
||||
cloneCounterRef.current += 1
|
||||
return name
|
||||
}, [])
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
|
||||
const canSubmit =
|
||||
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
|
||||
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
|
||||
hasAudio
|
||||
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input")
|
||||
setVoiceName(getNextDefaultName())
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
setErrorMessage("")
|
||||
resetRecording()
|
||||
}, [getNextDefaultName, resetRecording])
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [resetState, onClose])
|
||||
|
||||
/** 弹窗打开时重置状态 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState()
|
||||
}
|
||||
}, [open, resetState])
|
||||
|
||||
/** 组件卸载时清理定时器 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 选择文件(来自上传或拖拽) */
|
||||
const handleFileSelect = useCallback(
|
||||
(file: File | null, error: string) => {
|
||||
if (error) {
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
// 清除录音
|
||||
resetRecording()
|
||||
}
|
||||
},
|
||||
[resetRecording],
|
||||
)
|
||||
|
||||
/** 录音切换 */
|
||||
const handleRecordToggle = useCallback(() => {
|
||||
setErrorMessage("")
|
||||
if (isRecording) {
|
||||
toggleRecording()
|
||||
} else {
|
||||
// 开始录制前清除已选文件
|
||||
setSelectedFile(null)
|
||||
toggleRecording()
|
||||
}
|
||||
}, [isRecording, toggleRecording])
|
||||
|
||||
/** 表单验证 */
|
||||
const validateForm = useCallback((): string | null => {
|
||||
const name = voiceName.trim()
|
||||
if (!name) {
|
||||
return "请输入音色名称"
|
||||
}
|
||||
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
|
||||
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
|
||||
}
|
||||
if (!hasAudio) {
|
||||
return "请上传音频文件或录制一段声音"
|
||||
}
|
||||
return null
|
||||
}, [voiceName, hasAudio])
|
||||
|
||||
/** 提交克隆 */
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const formError = validateForm()
|
||||
if (formError) {
|
||||
setErrorMessage(formError)
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
// 阶段 1:上传音频
|
||||
setPhase("uploading")
|
||||
|
||||
let fileToUpload: File
|
||||
if (selectedFile) {
|
||||
fileToUpload = selectedFile
|
||||
} else {
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
})
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning")
|
||||
const result = await createVoiceClone({
|
||||
name: voiceName.trim(),
|
||||
description: voiceDescription.trim() || undefined,
|
||||
audio_url: uploadResult.url,
|
||||
})
|
||||
|
||||
// 阶段 3:完成
|
||||
setPhase("done")
|
||||
|
||||
// 2秒后自动关闭
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
handleClose()
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
setPhase("input")
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||
}
|
||||
}, [
|
||||
validateForm,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
const { handleSubmit } = useCloneSubmit({
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
setPhase: formState.setPhase,
|
||||
setErrorMessage: formState.setErrorMessage,
|
||||
validateForm: formState.validateForm,
|
||||
onSuccess,
|
||||
handleClose,
|
||||
])
|
||||
onClose: formState.handleClose,
|
||||
})
|
||||
|
||||
return {
|
||||
phase,
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
errorMessage,
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
canSubmit,
|
||||
isProcessing,
|
||||
setVoiceName,
|
||||
setVoiceDescription,
|
||||
setDragActive,
|
||||
handleFileSelect,
|
||||
handleRecordToggle,
|
||||
handleClose,
|
||||
phase: formState.phase,
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
dragActive: formState.dragActive,
|
||||
errorMessage: formState.errorMessage,
|
||||
isRecording: formState.isRecording,
|
||||
recordTime: formState.recordTime,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
canSubmit: formState.canSubmit,
|
||||
isProcessing: formState.isProcessing,
|
||||
setVoiceName: formState.setVoiceName,
|
||||
setVoiceDescription: formState.setVoiceDescription,
|
||||
setDragActive: formState.setDragActive,
|
||||
handleFileSelect: formState.handleFileSelect,
|
||||
handleRecordToggle: formState.handleRecordToggle,
|
||||
handleClose: formState.handleClose,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { useRef, useCallback, useEffect } from "react"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAsset } from "@/api/assets"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
interface UseCloneSubmitOptions {
|
||||
voiceName: string
|
||||
voiceDescription: string
|
||||
selectedFile: File | null
|
||||
recordedBlob: Blob | null
|
||||
setPhase: (phase: "input" | "uploading" | "cloning" | "done") => void
|
||||
setErrorMessage: (msg: string) => void
|
||||
validateForm: () => string | null
|
||||
onSuccess?: (clone: VoiceClone) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆提交流程 Hook
|
||||
* 封装上传 + 克隆 + 完成的三阶段流程
|
||||
*/
|
||||
export function useCloneSubmit({
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
setPhase,
|
||||
setErrorMessage,
|
||||
validateForm,
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: UseCloneSubmitOptions) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
/** 组件卸载时清理定时器 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const formError = validateForm()
|
||||
if (formError) {
|
||||
setErrorMessage(formError)
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
// 阶段 1:上传音频
|
||||
setPhase("uploading")
|
||||
|
||||
let fileToUpload: File
|
||||
if (selectedFile) {
|
||||
fileToUpload = selectedFile
|
||||
} else {
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
})
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning")
|
||||
const result = await createVoiceClone({
|
||||
name: voiceName.trim(),
|
||||
description: voiceDescription.trim() || undefined,
|
||||
audio_url: uploadResult.url,
|
||||
})
|
||||
|
||||
// 阶段 3:完成
|
||||
setPhase("done")
|
||||
|
||||
// 2秒后自动关闭
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
onClose()
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
setPhase("input")
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||
}
|
||||
}, [
|
||||
validateForm,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
setPhase,
|
||||
setErrorMessage,
|
||||
onSuccess,
|
||||
onClose,
|
||||
])
|
||||
|
||||
return { handleSubmit }
|
||||
}
|
||||
Regular → Executable
+36
-184
@@ -1,116 +1,35 @@
|
||||
/**
|
||||
* 查重上传页面 — V21 设计系统
|
||||
* 左右分栏:拖拽上传区 + 格式说明
|
||||
* 零 antd 依赖
|
||||
*/
|
||||
import React, { useState, useRef, useCallback } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { Button, Card, Tag } from "@/components/ui"
|
||||
import { uploadForDuplication } from "@/api/duplication"
|
||||
import React from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import "./duplication.css"
|
||||
import { Card } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
|
||||
/** 支持的视频格式 */
|
||||
const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||
const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||
/** 最大文件大小:2GB */
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
/** 简易 toast */
|
||||
interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
import UploadZone from "./duplication-upload/UploadZone"
|
||||
import UploadActions from "./duplication-upload/UploadActions"
|
||||
import UploadProgress from "./duplication-upload/UploadProgress"
|
||||
import UploadResultPanel from "./duplication-upload/UploadResultPanel"
|
||||
import InfoSidebar from "./duplication-upload/InfoSidebar"
|
||||
import { useDuplicationUpload } from "./duplication-upload/useDuplicationUpload"
|
||||
import { ACCEPT_FORMATS } from "./duplication-upload/constants"
|
||||
import "./duplication.css"
|
||||
|
||||
const DuplicationUpload: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResult, setUploadResult] = useState<{
|
||||
id: string
|
||||
message: string
|
||||
} | null>(null)
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
/** 显示 toast */
|
||||
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
|
||||
// 上传查重 mutation
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadForDuplication(file),
|
||||
onSuccess: (data) => {
|
||||
setUploading(false)
|
||||
setUploadResult({ id: data.id, message: data.message })
|
||||
showToast("查重任务已提交", "success")
|
||||
},
|
||||
onError: () => {
|
||||
setUploading(false)
|
||||
showToast("上传失败,请重试", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 校验并上传文件 */
|
||||
const handleFile = useCallback(
|
||||
(file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast("文件大小不能超过 2GB", "error")
|
||||
return
|
||||
}
|
||||
const ext = file.name.toLowerCase().split(".").pop()
|
||||
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
|
||||
if (!allowedExts.includes(ext || "")) {
|
||||
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
setUploadResult(null)
|
||||
uploadMutation.mutate(file)
|
||||
},
|
||||
[uploadMutation, showToast],
|
||||
)
|
||||
|
||||
/** 拖拽事件 */
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFile(file)
|
||||
},
|
||||
[handleFile],
|
||||
)
|
||||
|
||||
/** 点击选择文件 */
|
||||
const handleSelectFile = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
// 重置 input 以便重复选择同一文件
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/** 重置状态 */
|
||||
const handleReset = () => {
|
||||
setUploadResult(null)
|
||||
setUploading(false)
|
||||
}
|
||||
const {
|
||||
fileInputRef,
|
||||
dragging,
|
||||
uploading,
|
||||
uploadResult,
|
||||
toast,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleSelectFile,
|
||||
handleFileChange,
|
||||
handleReset,
|
||||
} = useDuplicationUpload()
|
||||
|
||||
return (
|
||||
<div className="dup-page">
|
||||
@@ -125,25 +44,14 @@ const DuplicationUpload: React.FC = () => {
|
||||
<div className="dup-upload-grid">
|
||||
{/* 左侧:上传区域 */}
|
||||
<Card>
|
||||
{/* 拖拽上传区 */}
|
||||
<div
|
||||
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
|
||||
<UploadZone
|
||||
dragging={dragging}
|
||||
uploading={uploading}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={uploading ? undefined : handleSelectFile}
|
||||
>
|
||||
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
|
||||
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
|
||||
<p>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB</p>
|
||||
<div className="dup-upload-formats">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
onClick={handleSelectFile}
|
||||
/>
|
||||
|
||||
{/* 隐藏的文件 input */}
|
||||
<input
|
||||
@@ -154,77 +62,21 @@ const DuplicationUpload: React.FC = () => {
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{/* 上传按钮 */}
|
||||
<div className="dup-upload-actions">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={handleSelectFile}
|
||||
disabled={uploading}
|
||||
>
|
||||
📂 选择文件
|
||||
</Button>
|
||||
</div>
|
||||
<UploadActions uploading={uploading} onSelectFile={handleSelectFile} />
|
||||
|
||||
{/* 上传进度 */}
|
||||
{uploading && (
|
||||
<div className="dup-progress">
|
||||
<div className="dup-progress-circle">
|
||||
<span className="dup-progress-icon">⏳</span>
|
||||
<span className="dup-progress-text">查重中...</span>
|
||||
</div>
|
||||
<p>正在分析视频内容,请稍候...</p>
|
||||
</div>
|
||||
)}
|
||||
{uploading && <UploadProgress />}
|
||||
|
||||
{/* 上传结果 */}
|
||||
{uploadResult && !uploading && (
|
||||
<div className="dup-result">
|
||||
<div className="dup-result-icon">✅</div>
|
||||
<h3>查重任务已提交</h3>
|
||||
<p>{uploadResult.message}</p>
|
||||
<div className="dup-result-actions">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/app/duplication/results")}
|
||||
>
|
||||
查看结果
|
||||
</Button>
|
||||
<Button buttonType="secondary" buttonSize="md" onClick={handleReset}>
|
||||
继续上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<UploadResultPanel
|
||||
result={uploadResult}
|
||||
onViewResult={() => navigate("/app/duplication/results")}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 右侧:格式说明 + 提示 */}
|
||||
<div className="dup-info-card">
|
||||
<h3>📋 查重说明</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>系统会对比您上传的视频与视频库中的已有视频</li>
|
||||
<li>查重完成后,可查看重复片段的具体位置</li>
|
||||
<li>查重过程通常需要几分钟,取决于视频大小</li>
|
||||
<li>高相似度片段建议进行替换或裁剪</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>🎬 支持格式</h3>
|
||||
<div className="dup-format-tags">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>💡 温馨提示</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>单个文件不超过 2GB</li>
|
||||
<li>视频时长建议不超过 60 分钟</li>
|
||||
<li>查重结果可在「查重记录」中随时查看</li>
|
||||
</ul>
|
||||
</div>
|
||||
<InfoSidebar />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Regular → Executable
+7
-1
@@ -51,7 +51,13 @@ export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
|
||||
{ key: "high", label: "高风险" },
|
||||
]
|
||||
|
||||
/** Toast 类型 */
|
||||
/** 支持的视频格式 */
|
||||
export const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||
export const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||
/** 最大文件大小:2GB */
|
||||
export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
/** 简易 toast */
|
||||
export interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { FORMAT_LIST } from "./constants"
|
||||
|
||||
/** 右侧说明卡 */
|
||||
const InfoSidebar: React.FC = () => {
|
||||
return (
|
||||
<div className="dup-info-card">
|
||||
<h3>📋 查重说明</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>系统会对比您上传的视频与视频库中的已有视频</li>
|
||||
<li>查重完成后,可查看重复片段的具体位置</li>
|
||||
<li>查重过程通常需要几分钟,取决于视频大小</li>
|
||||
<li>高相似度片段建议进行替换或裁剪</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>🎬 支持格式</h3>
|
||||
<div className="dup-format-tags">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>💡 温馨提示</h3>
|
||||
<ul className="dup-info-list">
|
||||
<li>单个文件不超过 2GB</li>
|
||||
<li>视频时长建议不超过 60 分钟</li>
|
||||
<li>查重结果可在「查重记录」中随时查看</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InfoSidebar
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
interface UploadActionsProps {
|
||||
uploading: boolean
|
||||
onSelectFile: () => void
|
||||
}
|
||||
|
||||
/** 上传按钮区 */
|
||||
const UploadActions: React.FC<UploadActionsProps> = ({ uploading, onSelectFile }) => {
|
||||
return (
|
||||
<div className="dup-upload-actions">
|
||||
<Button buttonType="primary" buttonSize="md" onClick={onSelectFile} disabled={uploading}>
|
||||
📂 选择文件
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadActions
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react"
|
||||
|
||||
/** 上传进度展示 */
|
||||
const UploadProgress: React.FC = () => {
|
||||
return (
|
||||
<div className="dup-progress">
|
||||
<div className="dup-progress-circle">
|
||||
<span className="dup-progress-icon">⏳</span>
|
||||
<span className="dup-progress-text">查重中...</span>
|
||||
</div>
|
||||
<p>正在分析视频内容,请稍候...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadProgress
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { UploadResult } from "./constants"
|
||||
|
||||
interface UploadResultPanelProps {
|
||||
result: UploadResult
|
||||
onViewResult: () => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
/** 上传结果展示 */
|
||||
const UploadResultPanel: React.FC<UploadResultPanelProps> = ({ result, onViewResult, onReset }) => {
|
||||
return (
|
||||
<div className="dup-result">
|
||||
<div className="dup-result-icon">✅</div>
|
||||
<h3>查重任务已提交</h3>
|
||||
<p>{result.message}</p>
|
||||
<div className="dup-result-actions">
|
||||
<Button buttonType="primary" buttonSize="md" onClick={onViewResult}>
|
||||
查看结果
|
||||
</Button>
|
||||
<Button buttonType="secondary" buttonSize="md" onClick={onReset}>
|
||||
继续上传
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadResultPanel
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { FORMAT_LIST } from "./constants"
|
||||
|
||||
interface UploadZoneProps {
|
||||
dragging: boolean
|
||||
uploading: boolean
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/** 拖拽上传区 */
|
||||
const UploadZone: React.FC<UploadZoneProps> = ({
|
||||
dragging,
|
||||
uploading,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={uploading ? undefined : onClick}
|
||||
>
|
||||
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
|
||||
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
|
||||
<p>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB</p>
|
||||
<div className="dup-upload-formats">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
<Tag key={fmt} variant="info">
|
||||
{fmt}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadZone
|
||||
@@ -0,0 +1,17 @@
|
||||
/** 支持的视频格式 */
|
||||
export const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||
export const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||
/** 最大文件大小:2GB */
|
||||
export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
/** 简易 toast */
|
||||
export interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
|
||||
/** 上传结果 */
|
||||
export interface UploadResult {
|
||||
id: string
|
||||
message: string
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { uploadForDuplication } from "@/api/duplication"
|
||||
import { ACCEPT_FORMATS, FORMAT_LIST, MAX_FILE_SIZE } from "./constants"
|
||||
import type { ToastState, UploadResult } from "./constants"
|
||||
|
||||
/**
|
||||
* 查重上传逻辑 Hook
|
||||
* 封装文件校验、上传 mutation、toast 提示
|
||||
*/
|
||||
export function useDuplicationUpload() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null)
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
/** 显示 toast */
|
||||
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
|
||||
// 上传查重 mutation
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadForDuplication(file),
|
||||
onSuccess: (data) => {
|
||||
setUploading(false)
|
||||
setUploadResult({ id: data.id, message: data.message })
|
||||
showToast("查重任务已提交", "success")
|
||||
},
|
||||
onError: () => {
|
||||
setUploading(false)
|
||||
showToast("上传失败,请重试", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 校验并上传文件 */
|
||||
const handleFile = useCallback(
|
||||
(file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast("文件大小不能超过 2GB", "error")
|
||||
return
|
||||
}
|
||||
const ext = file.name.toLowerCase().split(".").pop()
|
||||
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
|
||||
if (!allowedExts.includes(ext || "")) {
|
||||
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
setUploadResult(null)
|
||||
uploadMutation.mutate(file)
|
||||
},
|
||||
[uploadMutation, showToast],
|
||||
)
|
||||
|
||||
/** 拖拽事件 */
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFile(file)
|
||||
},
|
||||
[handleFile],
|
||||
)
|
||||
|
||||
/** 点击选择文件 */
|
||||
const handleSelectFile = useCallback(() => {
|
||||
fileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
// 重置 input 以便重复选择同一文件
|
||||
e.target.value = ""
|
||||
},
|
||||
[handleFile],
|
||||
)
|
||||
|
||||
/** 重置状态 */
|
||||
const handleReset = useCallback(() => {
|
||||
setUploadResult(null)
|
||||
setUploading(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// refs
|
||||
fileInputRef,
|
||||
// 状态
|
||||
dragging,
|
||||
uploading,
|
||||
uploadResult,
|
||||
toast,
|
||||
// 事件
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleSelectFile,
|
||||
handleFileChange,
|
||||
handleReset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
/**
|
||||
* 素材库加载 Hook
|
||||
* 管理素材库列表、当前选中库、素材列表加载
|
||||
*/
|
||||
export function useMaterialLibrary() {
|
||||
/* ── 素材库数据 API ── */
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
})
|
||||
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
|
||||
|
||||
// 自动选中第一个视频库
|
||||
useEffect(() => {
|
||||
if (libraries.length > 0 && !selectedLibraryId) {
|
||||
setSelectedLibraryId(libraries[0].id)
|
||||
}
|
||||
}, [libraries, selectedLibraryId])
|
||||
|
||||
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
|
||||
items: AssetItem[]
|
||||
total: number
|
||||
}>({
|
||||
queryKey: ["generate-assets", selectedLibraryId],
|
||||
queryFn: () => getAssets(selectedLibraryId),
|
||||
enabled: !!selectedLibraryId,
|
||||
})
|
||||
|
||||
return {
|
||||
libraries,
|
||||
selectedLibraryId,
|
||||
setSelectedLibraryId,
|
||||
materials,
|
||||
materialsLoading,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { message } from "antd"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { SMART_MATCH_REASONS } from "../../constants"
|
||||
|
||||
interface SmartMatchedResult {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
|
||||
interface UseSmartMatchOptions {
|
||||
materials: { items: AssetItem[]; total: number }
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能素材匹配 Hook
|
||||
* 封装 AI 匹配、换一批、全选/清空等逻辑
|
||||
*/
|
||||
export function useSmartMatch({
|
||||
materials,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
}: UseSmartMatchOptions) {
|
||||
const [smartMatchInput, setSmartMatchInput] = useState("")
|
||||
const [smartMatching, setSmartMatching] = useState(false)
|
||||
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
|
||||
const [hasMatched, setHasMatched] = useState(false)
|
||||
|
||||
/* ── 智能素材匹配 ── */
|
||||
const handleSmartMatch = useCallback(async () => {
|
||||
if (!smartMatchInput.trim()) {
|
||||
message.warning("请先输入视频内容描述")
|
||||
return
|
||||
}
|
||||
if (materials.items.length === 0) {
|
||||
message.warning("当前视频库暂无素材")
|
||||
return
|
||||
}
|
||||
|
||||
setSmartMatching(true)
|
||||
setHasMatched(true)
|
||||
|
||||
// 模拟 AI 匹配延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 从素材库中随机选取 5-8 个作为推荐结果
|
||||
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
|
||||
}))
|
||||
|
||||
// 按匹配度从高到低排序
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
// 默认选中匹配度 >= 90 的素材
|
||||
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
|
||||
onSmartSelectedIdsChange(
|
||||
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
|
||||
)
|
||||
setSmartMatching(false)
|
||||
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
|
||||
|
||||
const handleToggleSmartSelect = useCallback(
|
||||
(assetId: string) => {
|
||||
onSmartSelectedIdsChange(
|
||||
smartSelectedIds.includes(assetId)
|
||||
? smartSelectedIds.filter((id) => id !== assetId)
|
||||
: [...smartSelectedIds, assetId],
|
||||
)
|
||||
},
|
||||
[smartSelectedIds, onSmartSelectedIdsChange],
|
||||
)
|
||||
|
||||
const handleRefreshMatch = useCallback(async () => {
|
||||
if (materials.items.length <= 5) {
|
||||
message.info("视频库素材较少,无法换一批")
|
||||
return
|
||||
}
|
||||
setSmartMatching(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
const remaining = materials.items.filter(
|
||||
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
|
||||
)
|
||||
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(80 + Math.random() * 19),
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",节奏明快" : ""),
|
||||
}))
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
onSmartSelectedIdsChange([])
|
||||
setSmartMatching(false)
|
||||
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleSelectAllMatched = useCallback(() => {
|
||||
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
|
||||
}, [smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleClearSmartSelect = useCallback(() => {
|
||||
onSmartSelectedIdsChange([])
|
||||
}, [onSmartSelectedIdsChange])
|
||||
|
||||
/* ── 计算已选智能匹配素材的总时长 ── */
|
||||
const smartSelectedTotalDuration = useMemo(() => {
|
||||
return smartMatchedResults
|
||||
.filter((r) => smartSelectedIds.includes(r.asset.id))
|
||||
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
|
||||
}, [smartMatchedResults, smartSelectedIds])
|
||||
|
||||
return {
|
||||
smartMatchInput,
|
||||
setSmartMatchInput,
|
||||
smartMatching,
|
||||
smartMatchedResults,
|
||||
hasMatched,
|
||||
smartSelectedIds,
|
||||
handleSmartMatch,
|
||||
handleToggleSmartSelect,
|
||||
handleRefreshMatch,
|
||||
handleSelectAllMatched,
|
||||
handleClearSmartSelect,
|
||||
smartSelectedTotalDuration,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+23
-149
@@ -1,20 +1,11 @@
|
||||
/**
|
||||
* Step 2 素材选择 Hook
|
||||
* 封装素材库加载、手动选择、智能匹配等逻辑
|
||||
* 组合素材库加载 + 智能匹配两个子 Hook
|
||||
*/
|
||||
import { useState, useCallback, useEffect, useMemo } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { useCallback } from "react"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { SMART_MATCH_REASONS } from "../constants"
|
||||
|
||||
interface SmartMatchedResult {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
|
||||
interface UseStep2MaterialsProps {
|
||||
materialMode: "manual" | "auto"
|
||||
@@ -33,34 +24,14 @@ export function useStep2Materials({
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
}: UseStep2MaterialsProps) {
|
||||
/* ── 素材库数据 API ── */
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
|
||||
useMaterialLibrary()
|
||||
|
||||
const smartMatch = useSmartMatch({
|
||||
materials,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
})
|
||||
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
|
||||
|
||||
// 自动选中第一个视频库
|
||||
useEffect(() => {
|
||||
if (libraries.length > 0 && !selectedLibraryId) {
|
||||
setSelectedLibraryId(libraries[0].id)
|
||||
}
|
||||
}, [libraries, selectedLibraryId])
|
||||
|
||||
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
|
||||
items: AssetItem[]
|
||||
total: number
|
||||
}>({
|
||||
queryKey: ["generate-assets", selectedLibraryId],
|
||||
queryFn: () => getAssets(selectedLibraryId),
|
||||
enabled: !!selectedLibraryId,
|
||||
})
|
||||
|
||||
/* ── 智能素材匹配状态 ── */
|
||||
const [smartMatchInput, setSmartMatchInput] = useState("")
|
||||
const [smartMatching, setSmartMatching] = useState(false)
|
||||
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
|
||||
const [hasMatched, setHasMatched] = useState(false)
|
||||
|
||||
/* ── 手动选择素材 ── */
|
||||
const handleToggleMaterial = useCallback(
|
||||
@@ -74,103 +45,6 @@ export function useStep2Materials({
|
||||
[selectedMaterials, onSelectedMaterialsChange],
|
||||
)
|
||||
|
||||
/* ── 智能素材匹配 ── */
|
||||
const handleSmartMatch = useCallback(async () => {
|
||||
if (!smartMatchInput.trim()) {
|
||||
message.warning("请先输入视频内容描述")
|
||||
return
|
||||
}
|
||||
if (materials.items.length === 0) {
|
||||
message.warning("当前视频库暂无素材")
|
||||
return
|
||||
}
|
||||
|
||||
setSmartMatching(true)
|
||||
setHasMatched(true)
|
||||
|
||||
// 模拟 AI 匹配延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// 从素材库中随机选取 5-8 个作为推荐结果
|
||||
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
|
||||
}))
|
||||
|
||||
// 按匹配度从高到低排序
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
// 默认选中匹配度 >= 90 的素材
|
||||
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
|
||||
onSmartSelectedIdsChange(
|
||||
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
|
||||
)
|
||||
setSmartMatching(false)
|
||||
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
|
||||
|
||||
const handleToggleSmartSelect = useCallback(
|
||||
(assetId: string) => {
|
||||
onSmartSelectedIdsChange(
|
||||
smartSelectedIds.includes(assetId)
|
||||
? smartSelectedIds.filter((id) => id !== assetId)
|
||||
: [...smartSelectedIds, assetId],
|
||||
)
|
||||
},
|
||||
[smartSelectedIds, onSmartSelectedIdsChange],
|
||||
)
|
||||
|
||||
const handleRefreshMatch = useCallback(async () => {
|
||||
if (materials.items.length <= 5) {
|
||||
message.info("视频库素材较少,无法换一批")
|
||||
return
|
||||
}
|
||||
setSmartMatching(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
const remaining = materials.items.filter(
|
||||
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
|
||||
)
|
||||
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(80 + Math.random() * 19),
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",节奏明快" : ""),
|
||||
}))
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
onSmartSelectedIdsChange([])
|
||||
setSmartMatching(false)
|
||||
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleSelectAllMatched = useCallback(() => {
|
||||
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
|
||||
}, [smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleClearSmartSelect = useCallback(() => {
|
||||
onSmartSelectedIdsChange([])
|
||||
}, [onSmartSelectedIdsChange])
|
||||
|
||||
/* ── 计算已选智能匹配素材的总时长 ── */
|
||||
const smartSelectedTotalDuration = useMemo(() => {
|
||||
return smartMatchedResults
|
||||
.filter((r) => smartSelectedIds.includes(r.asset.id))
|
||||
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
|
||||
}, [smartMatchedResults, smartSelectedIds])
|
||||
|
||||
return {
|
||||
// 素材库
|
||||
libraries,
|
||||
@@ -185,18 +59,18 @@ export function useStep2Materials({
|
||||
selectedMaterials,
|
||||
handleToggleMaterial,
|
||||
// 智能匹配
|
||||
smartMatchInput,
|
||||
setSmartMatchInput,
|
||||
smartMatching,
|
||||
smartMatchedResults,
|
||||
hasMatched,
|
||||
smartSelectedIds,
|
||||
handleSmartMatch,
|
||||
handleToggleSmartSelect,
|
||||
handleRefreshMatch,
|
||||
handleSelectAllMatched,
|
||||
handleClearSmartSelect,
|
||||
smartSelectedTotalDuration,
|
||||
smartMatchInput: smartMatch.smartMatchInput,
|
||||
setSmartMatchInput: smartMatch.setSmartMatchInput,
|
||||
smartMatching: smartMatch.smartMatching,
|
||||
smartMatchedResults: smartMatch.smartMatchedResults,
|
||||
hasMatched: smartMatch.hasMatched,
|
||||
smartSelectedIds: smartMatch.smartSelectedIds,
|
||||
handleSmartMatch: smartMatch.handleSmartMatch,
|
||||
handleToggleSmartSelect: smartMatch.handleToggleSmartSelect,
|
||||
handleRefreshMatch: smartMatch.handleRefreshMatch,
|
||||
handleSelectAllMatched: smartMatch.handleSelectAllMatched,
|
||||
handleClearSmartSelect: smartMatch.handleClearSmartSelect,
|
||||
smartSelectedTotalDuration: smartMatch.smartSelectedTotalDuration,
|
||||
// utils
|
||||
formatDuration,
|
||||
}
|
||||
|
||||
Regular → Executable
+20
-136
@@ -1,25 +1,13 @@
|
||||
import React from "react"
|
||||
import { Button, Descriptions, Tooltip } from "antd"
|
||||
import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons"
|
||||
import type { TemplateItem, TemplateSegment } from "@/api/templates"
|
||||
import {
|
||||
gradientForCategory,
|
||||
getTypeColor,
|
||||
formatDuration,
|
||||
formatConfig,
|
||||
getMaterialTypeLabel,
|
||||
calcTotalSegmentDuration,
|
||||
} from "../../utils/templateLibrary"
|
||||
import { Descriptions } from "antd"
|
||||
import type { TemplateDetailModalProps } from "./template-detail-modal/types"
|
||||
import PreviewArea from "./template-detail-modal/PreviewArea"
|
||||
import SegmentList from "./template-detail-modal/SegmentList"
|
||||
import StyleConfig from "./template-detail-modal/StyleConfig"
|
||||
import DetailFooter from "./template-detail-modal/DetailFooter"
|
||||
import { getTypeColor, formatDuration } from "../../utils/templateLibrary"
|
||||
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
|
||||
|
||||
interface TemplateDetailModalProps {
|
||||
template: TemplateItem
|
||||
isFavorite: boolean
|
||||
onClose: () => void
|
||||
onToggleFavorite: (id: string) => void
|
||||
onUse: (template: TemplateItem) => void
|
||||
onCopy: (template: TemplateItem) => void
|
||||
}
|
||||
import { calcTotalSegmentDuration } from "../../utils/templateLibrary"
|
||||
|
||||
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
template,
|
||||
@@ -31,6 +19,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
}) => {
|
||||
const segments = template.segments ?? []
|
||||
const totalSegmentDuration = calcTotalSegmentDuration(segments)
|
||||
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
|
||||
|
||||
return (
|
||||
<div className="xx-template-modal-overlay" onClick={onClose}>
|
||||
@@ -38,33 +27,8 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
className="xx-template-modal xx-template-modal-wide"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
<PreviewArea template={template} onClose={onClose} />
|
||||
|
||||
{/* 预览区域 */}
|
||||
<div
|
||||
className="xx-template-modal-preview"
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
>
|
||||
{template.thumbnail_url ? (
|
||||
<img
|
||||
src={template.thumbnail_url}
|
||||
alt={template.name}
|
||||
className="xx-template-modal-thumb-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-template-modal-preview-content">
|
||||
<span className="xx-template-preview-icon">
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"}
|
||||
</span>
|
||||
<span className="xx-template-preview-title">{template.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="xx-template-modal-content">
|
||||
{/* 标题行 */}
|
||||
<div className="xx-template-modal-title-row">
|
||||
@@ -76,7 +40,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
background: `${getTypeColor(template.category)}18`,
|
||||
}}
|
||||
>
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category}
|
||||
{typeInfo?.icon} {template.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -123,95 +87,15 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 素材规则(片段配置) */}
|
||||
{segments.length > 0 && (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎬 素材规则</h4>
|
||||
<div className="xx-template-modal-clip-list">
|
||||
{segments
|
||||
.sort((a, b) => a.segment_order - b.segment_order)
|
||||
.map((seg: TemplateSegment, idx: number) => (
|
||||
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
|
||||
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
|
||||
<span
|
||||
className="xx-template-modal-clip-badge"
|
||||
style={{
|
||||
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
|
||||
background: seg.material_type
|
||||
? `${getTypeColor(seg.material_type)}18`
|
||||
: "#f1f5f9",
|
||||
}}
|
||||
>
|
||||
{getMaterialTypeLabel(seg.material_type)}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-desc">
|
||||
{seg.description || `片段 ${seg.segment_order}`}
|
||||
</span>
|
||||
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{seg.duration_min}-{seg.duration_max}秒
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xx-template-modal-total-duration">
|
||||
预估总时长:{formatDuration(Math.round(totalSegmentDuration))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 样式配置 */}
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎨 样式配置</h4>
|
||||
<div className="xx-template-modal-style-grid">
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">字幕样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.subtitle_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">标题样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.title_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">BGM 配置</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.bgm_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">视频比例</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{template.aspect_ratio ?? "16:9"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="xx-template-modal-stats">
|
||||
<span>已使用 {template.usage_count ?? 0} 次</span>
|
||||
<button
|
||||
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={() => onToggleFavorite(template.id)}
|
||||
>
|
||||
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-template-modal-actions">
|
||||
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
|
||||
复制模板
|
||||
</Button>
|
||||
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
|
||||
使用此模板生成
|
||||
</Button>
|
||||
</div>
|
||||
<SegmentList segments={segments} totalDuration={totalSegmentDuration} />
|
||||
<StyleConfig template={template} />
|
||||
<DetailFooter
|
||||
template={template}
|
||||
isFavorite={isFavorite}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onUse={onUse}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
import React from "react"
|
||||
import { Button } from "antd"
|
||||
import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons"
|
||||
import type { TemplateItem } from "@/api/templates"
|
||||
|
||||
interface DetailFooterProps {
|
||||
template: TemplateItem
|
||||
isFavorite: boolean
|
||||
onToggleFavorite: (id: string) => void
|
||||
onUse: (template: TemplateItem) => void
|
||||
onCopy: (template: TemplateItem) => void
|
||||
}
|
||||
|
||||
/** 底部操作区:统计 + 收藏 + 按钮 */
|
||||
const DetailFooter: React.FC<DetailFooterProps> = ({
|
||||
template,
|
||||
isFavorite,
|
||||
onToggleFavorite,
|
||||
onUse,
|
||||
onCopy,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="xx-template-modal-stats">
|
||||
<span>已使用 {template.usage_count ?? 0} 次</span>
|
||||
<button
|
||||
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={() => onToggleFavorite(template.id)}
|
||||
>
|
||||
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="xx-template-modal-actions">
|
||||
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
|
||||
复制模板
|
||||
</Button>
|
||||
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
|
||||
使用此模板生成
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetailFooter
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
import type { TemplateItem } from "@/api/templates"
|
||||
import { gradientForCategory } from "../../../utils/templateLibrary"
|
||||
import { TEMPLATE_TYPES } from "../../../constants/templateLibrary"
|
||||
|
||||
interface PreviewAreaProps {
|
||||
template: TemplateItem
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** 预览区域 */
|
||||
const PreviewArea: React.FC<PreviewAreaProps> = ({ template, onClose }) => {
|
||||
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="xx-template-modal-preview"
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
>
|
||||
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
{template.thumbnail_url ? (
|
||||
<img
|
||||
src={template.thumbnail_url}
|
||||
alt={template.name}
|
||||
className="xx-template-modal-thumb-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-template-modal-preview-content">
|
||||
<span className="xx-template-preview-icon">{typeInfo?.icon ?? "📋"}</span>
|
||||
<span className="xx-template-preview-title">{template.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PreviewArea
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
import React from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import type { TemplateSegment } from "@/api/templates"
|
||||
import { getMaterialTypeLabel, getTypeColor, formatDuration } from "../../../utils/templateLibrary"
|
||||
|
||||
interface SegmentListProps {
|
||||
segments: TemplateSegment[]
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
/** 素材规则 / 片段列表 */
|
||||
const SegmentList: React.FC<SegmentListProps> = ({ segments, totalDuration }) => {
|
||||
if (segments.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎬 素材规则</h4>
|
||||
<div className="xx-template-modal-clip-list">
|
||||
{segments
|
||||
.sort((a, b) => a.segment_order - b.segment_order)
|
||||
.map((seg, idx) => (
|
||||
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
|
||||
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
|
||||
<span
|
||||
className="xx-template-modal-clip-badge"
|
||||
style={{
|
||||
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
|
||||
background: seg.material_type
|
||||
? `${getTypeColor(seg.material_type)}18`
|
||||
: "#f1f5f9",
|
||||
}}
|
||||
>
|
||||
{getMaterialTypeLabel(seg.material_type)}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-desc">
|
||||
{seg.description || `片段 ${seg.segment_order}`}
|
||||
</span>
|
||||
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{seg.duration_min}-{seg.duration_max}秒
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xx-template-modal-total-duration">
|
||||
预估总时长:{formatDuration(Math.round(totalDuration))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SegmentList
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
import React from "react"
|
||||
import type { TemplateItem } from "@/api/templates"
|
||||
import { formatConfig } from "../../../utils/templateLibrary"
|
||||
|
||||
interface StyleConfigProps {
|
||||
template: TemplateItem
|
||||
}
|
||||
|
||||
/** 样式配置网格 */
|
||||
const StyleConfig: React.FC<StyleConfigProps> = ({ template }) => {
|
||||
const items = [
|
||||
{ label: "字幕样式", value: formatConfig(template.subtitle_config) },
|
||||
{ label: "标题样式", value: formatConfig(template.title_config) },
|
||||
{ label: "BGM 配置", value: formatConfig(template.bgm_config) },
|
||||
{ label: "视频比例", value: template.aspect_ratio ?? "16:9" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎨 样式配置</h4>
|
||||
<div className="xx-template-modal-style-grid">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">{item.label}</span>
|
||||
<span className="xx-template-modal-style-value">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StyleConfig
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export { TemplateDetailModal } from "../TemplateDetailModal"
|
||||
export * from "./types"
|
||||
export { default as PreviewArea } from "./PreviewArea"
|
||||
export { default as SegmentList } from "./SegmentList"
|
||||
export { default as StyleConfig } from "./StyleConfig"
|
||||
export { default as DetailFooter } from "./DetailFooter"
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import type { TemplateItem } from "@/api/templates"
|
||||
|
||||
export interface TemplateDetailModalProps {
|
||||
template: TemplateItem
|
||||
isFavorite: boolean
|
||||
onClose: () => void
|
||||
onToggleFavorite: (id: string) => void
|
||||
onUse: (template: TemplateItem) => void
|
||||
onCopy: (template: TemplateItem) => void
|
||||
}
|
||||
Regular → Executable
+3
-3
@@ -9,7 +9,7 @@
|
||||
* - 删除素材
|
||||
*/
|
||||
import React from "react"
|
||||
import { AudioOutlined, PlusOutlined, RobotOutlined } from "@ant-design/icons"
|
||||
import { PlusOutlined, RobotOutlined } from "@ant-design/icons"
|
||||
import { Button, Modal } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
|
||||
@@ -315,8 +315,8 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
voiceId={ttsVoiceId}
|
||||
speed={ttsSpeed}
|
||||
status={ttsStatus}
|
||||
audioUrl={ttsAudioUrl}
|
||||
error={ttsError}
|
||||
audioUrl={ttsAudioUrl ?? ""}
|
||||
error={ttsError ?? ""}
|
||||
presetVoices={presetVoices}
|
||||
onClose={handleTtsClose}
|
||||
onTextChange={setTtsText}
|
||||
|
||||
Regular → Executable
+5
-81
@@ -1,11 +1,10 @@
|
||||
import React, { useState, useRef } from "react"
|
||||
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
|
||||
import React, { useState } from "react"
|
||||
import { Button, Input } from "@/components/ui"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
import { type VoiceGender, type VoiceMaterial } from "../types"
|
||||
import { GENDER_OPTIONS } from "../constants"
|
||||
import { genderClass, formatFileSize } from "../utils/format"
|
||||
import TagSelector from "./TagSelector"
|
||||
import FileUploadField from "./material-form/FileUploadField"
|
||||
import GenderSelector from "./material-form/GenderSelector"
|
||||
|
||||
export interface MaterialFormProps {
|
||||
initial?: VoiceMaterial
|
||||
@@ -33,7 +32,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
||||
const [gender, setGender] = useState<VoiceGender>(initial?.gender ?? "female")
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(initial?.tagIds ?? [])
|
||||
const [file, setFile] = useState<File | undefined>(undefined)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name.trim()) return
|
||||
@@ -53,65 +51,10 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
||||
|
||||
return (
|
||||
<div className="vmat-form">
|
||||
{/* 音频文件上传(编辑模式不显示) */}
|
||||
{!initial && (
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">音频文件 *</label>
|
||||
<div
|
||||
className="vmat-upload-zone"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
const f = e.dataTransfer.files[0]
|
||||
if (f?.type.startsWith("audio/")) setFile(f)
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) setFile(f)
|
||||
}}
|
||||
/>
|
||||
{file ? (
|
||||
<div className="vmat-upload-selected">
|
||||
<SoundOutlined className="vmat-upload-icon" />
|
||||
<span className="vmat-upload-filename">{file.name}</span>
|
||||
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-upload-clear"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setFile(undefined)
|
||||
}}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vmat-upload-placeholder">
|
||||
<UploadOutlined className="vmat-upload-icon" />
|
||||
<p>点击或拖拽音频文件到此处</p>
|
||||
<span>支持 MP3、WAV、AAC、FLAC 等格式</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 上传进度条 */}
|
||||
{uploadProgress !== null && uploadProgress !== undefined && (
|
||||
<div className="vmat-upload-progress">
|
||||
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
|
||||
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FileUploadField file={file} onChange={setFile} uploadProgress={uploadProgress} />
|
||||
)}
|
||||
|
||||
{/* 名称 */}
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">名称 *</label>
|
||||
<Input
|
||||
@@ -122,7 +65,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音色描述 */}
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">音色描述</label>
|
||||
<Input.TextArea
|
||||
@@ -134,25 +76,8 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别 */}
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">性别</label>
|
||||
<div className="vmat-gender-group">
|
||||
{GENDER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`vmat-gender-btn${gender === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
|
||||
onClick={() => setGender(opt.value)}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<GenderSelector value={gender} onChange={setGender} />
|
||||
|
||||
{/* 风格标签 */}
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">风格标签</label>
|
||||
<TagSelector
|
||||
@@ -164,7 +89,6 @@ const MaterialForm: React.FC<MaterialFormProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="vmat-form-actions">
|
||||
<Button buttonType="ghost" buttonSize="md" onClick={onCancel}>
|
||||
取消
|
||||
|
||||
Regular → Executable
+1
-1
@@ -35,7 +35,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
audioUrl,
|
||||
error,
|
||||
presetVoices,
|
||||
onClose,
|
||||
onClose: _onClose,
|
||||
onTextChange,
|
||||
onVoiceChange,
|
||||
onSpeedChange,
|
||||
|
||||
Regular → Executable
+35
-208
@@ -1,45 +1,12 @@
|
||||
import React, { useRef } from "react"
|
||||
import {
|
||||
AudioOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CheckOutlined,
|
||||
SoundOutlined,
|
||||
MutedOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Tooltip } from "antd"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
import { type VoiceMaterial } from "../types"
|
||||
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../constants"
|
||||
import {
|
||||
genderClass,
|
||||
genderIcon,
|
||||
genderLabel,
|
||||
formatDuration,
|
||||
formatFileSize,
|
||||
formatDate,
|
||||
} from "../utils/format"
|
||||
|
||||
export interface VoiceCardProps {
|
||||
material: VoiceMaterial
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
isSelected: boolean
|
||||
batchMode: boolean
|
||||
volume: number
|
||||
tagMap: Map<string, TagItem>
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onSeek: (time: number) => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onToggleSelect: (id: string) => void
|
||||
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onToggleMute: () => void
|
||||
}
|
||||
import React from "react"
|
||||
import { type VoiceCardProps } from "./voice-material-card/types"
|
||||
import BatchCheckbox from "./voice-material-card/BatchCheckbox"
|
||||
import CardActions from "./voice-material-card/CardActions"
|
||||
import CardHeader from "./voice-material-card/CardHeader"
|
||||
import CardTags from "./voice-material-card/CardTags"
|
||||
import CardMeta from "./voice-material-card/CardMeta"
|
||||
import CardPlayer from "./voice-material-card/CardPlayer"
|
||||
import { genderClass } from "../utils/format"
|
||||
|
||||
const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
|
||||
material,
|
||||
@@ -58,29 +25,6 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
|
||||
onVolumeChange,
|
||||
onToggleMute,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
e.preventDefault()
|
||||
const doSeek = (ev: MouseEvent) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||
onSeek(percent * material.duration)
|
||||
}
|
||||
doSeek(e.nativeEvent)
|
||||
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
||||
const handleUp = () => {
|
||||
document.removeEventListener("mousemove", handleMove)
|
||||
document.removeEventListener("mouseup", handleUp)
|
||||
}
|
||||
document.addEventListener("mousemove", handleMove)
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
}
|
||||
|
||||
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (batchMode) {
|
||||
onToggleSelect(material.id)
|
||||
@@ -92,154 +36,37 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
|
||||
className={`vmat-card ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* 批量选择 checkbox */}
|
||||
{(batchMode || isSelected) && (
|
||||
<div
|
||||
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(material.id)
|
||||
}}
|
||||
>
|
||||
{isSelected && <CheckOutlined />}
|
||||
</div>
|
||||
)}
|
||||
<BatchCheckbox
|
||||
isSelected={isSelected}
|
||||
visible={batchMode || isSelected}
|
||||
onToggle={() => onToggleSelect(material.id)}
|
||||
/>
|
||||
<CardActions onEdit={onEdit} onDelete={onDelete} />
|
||||
<CardHeader material={material} />
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="vmat-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-card-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
title="编辑"
|
||||
>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-card-action-btn vmat-card-action-btn--danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 头部:图标 + 名称 + 性别 */}
|
||||
<div className="vmat-card-header">
|
||||
<div className="vmat-card-avatar">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div className="vmat-card-title-area">
|
||||
<h4 className="vmat-card-name" title={material.name}>
|
||||
{material.name}
|
||||
</h4>
|
||||
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
|
||||
{genderIcon(material.gender)}
|
||||
{genderLabel(material.gender)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
{material.description && <p className="vmat-card-desc">{material.description}</p>}
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="vmat-card-tags">
|
||||
{material.tagIds.length === 0 ? (
|
||||
<span
|
||||
className="vmat-tag-empty"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
添加标签
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{material.tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
|
||||
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
|
||||
{tagMap.get(tagId)?.name ?? tagId}
|
||||
</Tag>
|
||||
))}
|
||||
{material.tagIds.length > MAX_CARD_TAGS && (
|
||||
<Tooltip
|
||||
title={material.tagIds
|
||||
.slice(MAX_CARD_TAGS)
|
||||
.map((id) => tagMap.get(id)?.name ?? id)
|
||||
.join("、")}
|
||||
>
|
||||
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_CARD_TAGS}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="vmat-card-meta">
|
||||
<span>{formatDuration(material.duration)}</span>
|
||||
<span>{formatFileSize(material.fileSize)}</span>
|
||||
<span>{formatDate(material.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* 播放控制 */}
|
||||
<div className="vmat-card-player">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-play-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
disabled={!material.fileUrl}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
|
||||
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
|
||||
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
|
||||
</div>
|
||||
<span className="vmat-time">
|
||||
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
|
||||
</span>
|
||||
{/* 音量控制 */}
|
||||
<div className="vmat-volume">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-volume-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleMute()
|
||||
}}
|
||||
title={volume === 0 ? "取消静音" : "静音"}
|
||||
>
|
||||
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="vmat-volume-slider"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
onVolumeChange(e)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CardTags tagIds={material.tagIds} tagMap={tagMap} onEdit={onEdit} />
|
||||
<CardMeta
|
||||
duration={material.duration}
|
||||
fileSize={material.fileSize}
|
||||
createdAt={material.createdAt}
|
||||
/>
|
||||
<CardPlayer
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
duration={material.duration}
|
||||
volume={volume}
|
||||
fileUrl={material.fileUrl}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
onSeek={onSeek}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onToggleMute={onToggleMute}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceMaterialCard
|
||||
export type { VoiceCardProps }
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import React, { useRef } from "react"
|
||||
import { UploadOutlined, SoundOutlined, CloseOutlined } from "@ant-design/icons"
|
||||
import { formatFileSize } from "../../utils/format"
|
||||
|
||||
interface FileUploadFieldProps {
|
||||
file: File | undefined
|
||||
onChange: (file: File | undefined) => void
|
||||
uploadProgress?: number | null
|
||||
}
|
||||
|
||||
const FileUploadField: React.FC<FileUploadFieldProps> = ({ file, onChange, uploadProgress }) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
const f = e.dataTransfer.files[0]
|
||||
if (f?.type.startsWith("audio/")) onChange(f)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">音频文件 *</label>
|
||||
<div
|
||||
className="vmat-upload-zone"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) onChange(f)
|
||||
}}
|
||||
/>
|
||||
{file ? (
|
||||
<div className="vmat-upload-selected">
|
||||
<SoundOutlined className="vmat-upload-icon" />
|
||||
<span className="vmat-upload-filename">{file.name}</span>
|
||||
<span className="vmat-upload-filesize">{formatFileSize(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-upload-clear"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange(undefined)
|
||||
}}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vmat-upload-placeholder">
|
||||
<UploadOutlined className="vmat-upload-icon" />
|
||||
<p>点击或拖拽音频文件到此处</p>
|
||||
<span>支持 MP3、WAV、AAC、FLAC 等格式</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{uploadProgress !== null && uploadProgress !== undefined && (
|
||||
<div className="vmat-upload-progress">
|
||||
<div className="vmat-upload-progress-bar" style={{ width: `${uploadProgress}%` }} />
|
||||
<span className="vmat-upload-progress-text">{uploadProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUploadField
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import React from "react"
|
||||
import type { VoiceGender } from "../../types"
|
||||
import { GENDER_OPTIONS } from "../../constants"
|
||||
import { genderClass } from "../../utils/format"
|
||||
|
||||
interface GenderSelectorProps {
|
||||
value: VoiceGender
|
||||
onChange: (value: VoiceGender) => void
|
||||
}
|
||||
|
||||
const GenderSelector: React.FC<GenderSelectorProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="vmat-form-field">
|
||||
<label className="vmat-form-label">性别</label>
|
||||
<div className="vmat-gender-group">
|
||||
{GENDER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`vmat-gender-btn${value === opt.value ? " active" : ""} ${genderClass(opt.value)}`}
|
||||
onClick={() => onChange(opt.value)}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenderSelector
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
import { CheckOutlined } from "@ant-design/icons"
|
||||
|
||||
interface BatchCheckboxProps {
|
||||
isSelected: boolean
|
||||
visible: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
/** 批量选择 checkbox */
|
||||
const BatchCheckbox: React.FC<BatchCheckboxProps> = ({ isSelected, visible, onToggle }) => {
|
||||
if (!visible) return null
|
||||
return (
|
||||
<div
|
||||
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle()
|
||||
}}
|
||||
>
|
||||
{isSelected && <CheckOutlined />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BatchCheckbox
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
import { EditOutlined, DeleteOutlined } from "@ant-design/icons"
|
||||
|
||||
interface CardActionsProps {
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
/** 卡片操作按钮:编辑 / 删除 */
|
||||
const CardActions: React.FC<CardActionsProps> = ({ onEdit, onDelete }) => {
|
||||
return (
|
||||
<div className="vmat-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-card-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
title="编辑"
|
||||
>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-card-action-btn vmat-card-action-btn--danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardActions
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import React from "react"
|
||||
import { AudioOutlined } from "@ant-design/icons"
|
||||
import { type VoiceMaterial } from "../../types"
|
||||
import { genderClass, genderIcon, genderLabel } from "../../utils/format"
|
||||
|
||||
interface CardHeaderProps {
|
||||
material: VoiceMaterial
|
||||
}
|
||||
|
||||
/** 卡片头部:头像 + 名称 + 性别标签 */
|
||||
const CardHeader: React.FC<CardHeaderProps> = ({ material }) => {
|
||||
return (
|
||||
<div className="vmat-card-header">
|
||||
<div className="vmat-card-avatar">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div className="vmat-card-title-area">
|
||||
<h4 className="vmat-card-name" title={material.name}>
|
||||
{material.name}
|
||||
</h4>
|
||||
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
|
||||
{genderIcon(material.gender)}
|
||||
{genderLabel(material.gender)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardHeader
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import React from "react"
|
||||
import { formatDuration, formatFileSize, formatDate } from "../../utils/format"
|
||||
|
||||
interface CardMetaProps {
|
||||
duration: number
|
||||
fileSize: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 元信息:时长 / 文件大小 / 创建日期 */
|
||||
const CardMeta: React.FC<CardMetaProps> = ({ duration, fileSize, createdAt }) => {
|
||||
return (
|
||||
<div className="vmat-card-meta">
|
||||
<span>{formatDuration(duration)}</span>
|
||||
<span>{formatFileSize(fileSize)}</span>
|
||||
<span>{formatDate(createdAt)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardMeta
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import React, { useRef } from "react"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
SoundOutlined,
|
||||
MutedOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { formatDuration } from "../../utils/format"
|
||||
|
||||
interface CardPlayerProps {
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
duration: number
|
||||
volume: number
|
||||
fileUrl?: string
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onSeek: (time: number) => void
|
||||
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onToggleMute: () => void
|
||||
}
|
||||
|
||||
/** 播放控制区:播放按钮 + 进度条 + 时间 + 音量 */
|
||||
const CardPlayer: React.FC<CardPlayerProps> = ({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
fileUrl,
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onVolumeChange,
|
||||
onToggleMute,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
e.preventDefault()
|
||||
const doSeek = (ev: MouseEvent) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||
onSeek(percent * duration)
|
||||
}
|
||||
doSeek(e.nativeEvent)
|
||||
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
||||
const handleUp = () => {
|
||||
document.removeEventListener("mousemove", handleMove)
|
||||
document.removeEventListener("mouseup", handleUp)
|
||||
}
|
||||
document.addEventListener("mousemove", handleMove)
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="vmat-card-player">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-play-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
disabled={!fileUrl}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
|
||||
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
|
||||
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
|
||||
</div>
|
||||
<span className="vmat-time">
|
||||
{isPlaying ? formatDuration(currentTime) : formatDuration(duration)}
|
||||
</span>
|
||||
{/* 音量控制 */}
|
||||
<div className="vmat-volume">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-volume-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleMute()
|
||||
}}
|
||||
title={volume === 0 ? "取消静音" : "静音"}
|
||||
>
|
||||
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="vmat-volume-slider"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
onVolumeChange(e)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardPlayer
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import React from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../../constants"
|
||||
|
||||
interface CardTagsProps {
|
||||
tagIds: string[]
|
||||
tagMap: Map<string, TagItem>
|
||||
onEdit: () => void
|
||||
}
|
||||
|
||||
/** 标签展示区 */
|
||||
const CardTags: React.FC<CardTagsProps> = ({ tagIds, tagMap, onEdit }) => {
|
||||
if (tagIds.length === 0) {
|
||||
return (
|
||||
<div className="vmat-card-tags">
|
||||
<span
|
||||
className="vmat-tag-empty"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
添加标签
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vmat-card-tags">
|
||||
{tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
|
||||
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
|
||||
{tagMap.get(tagId)?.name ?? tagId}
|
||||
</Tag>
|
||||
))}
|
||||
{tagIds.length > MAX_CARD_TAGS && (
|
||||
<Tooltip
|
||||
title={tagIds
|
||||
.slice(MAX_CARD_TAGS)
|
||||
.map((id) => tagMap.get(id)?.name ?? id)
|
||||
.join("、")}
|
||||
>
|
||||
<Tag className="vmat-tag-overflow">+{tagIds.length - MAX_CARD_TAGS}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardTags
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default } from "../VoiceMaterialCard"
|
||||
export * from "./types"
|
||||
export { default as CardHeader } from "./CardHeader"
|
||||
export { default as CardTags } from "./CardTags"
|
||||
export { default as CardMeta } from "./CardMeta"
|
||||
export { default as CardPlayer } from "./CardPlayer"
|
||||
export { default as CardActions } from "./CardActions"
|
||||
export { default as BatchCheckbox } from "./BatchCheckbox"
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from "react"
|
||||
import { type VoiceMaterial } from "../../types"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
|
||||
export interface VoiceCardProps {
|
||||
material: VoiceMaterial
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
isSelected: boolean
|
||||
batchMode: boolean
|
||||
volume: number
|
||||
tagMap: Map<string, TagItem>
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onSeek: (time: number) => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onToggleSelect: (id: string) => void
|
||||
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onToggleMute: () => void
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { deleteAsset } from "@/api/assets"
|
||||
import { type VoiceMaterial } from "../../../types"
|
||||
|
||||
interface UseVoiceDeleteOptions {
|
||||
materials: VoiceMaterial[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 配音素材删除 Hook
|
||||
*/
|
||||
export function useVoiceDelete({ materials }: UseVoiceDeleteOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (assetId: string) => deleteAsset(assetId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string, onBeforeDelete?: () => void) => {
|
||||
const material = materials.find((m) => m.id === id)
|
||||
if (!material) return
|
||||
if (onBeforeDelete) onBeforeDelete()
|
||||
deleteMutation.mutate(id)
|
||||
},
|
||||
[materials, deleteMutation],
|
||||
)
|
||||
|
||||
return {
|
||||
isDeleting: deleteMutation.isPending,
|
||||
deleteMutation,
|
||||
handleDelete,
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { updateAsset } from "@/api/assets"
|
||||
import { tagAsset, untagAsset } from "@/api/tags"
|
||||
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../../types"
|
||||
|
||||
interface UseVoiceEditOptions {
|
||||
materials: VoiceMaterial[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 配音素材编辑 Hook
|
||||
* 封装编辑流程:更新基础信息 + 同步标签差异
|
||||
*/
|
||||
export function useVoiceEdit({ materials }: UseVoiceEditOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
id: string
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
tagIds: string[]
|
||||
}) => {
|
||||
// 1. 更新基础信息
|
||||
await updateAsset(data.id, {
|
||||
name: data.name,
|
||||
metadata: buildMetadata({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
}),
|
||||
})
|
||||
|
||||
// 2. 对比标签差异,调用 tag/untag API
|
||||
const currentAsset = materials.find((m) => m.id === data.id)
|
||||
const oldTagIds = currentAsset?.tagIds ?? []
|
||||
const newTagIds = data.tagIds
|
||||
|
||||
const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id))
|
||||
const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id))
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
await tagAsset(data.id, toAdd)
|
||||
}
|
||||
for (const tagId of toRemove) {
|
||||
await untagAsset(data.id, tagId)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(
|
||||
editingMaterial: VoiceMaterial | null,
|
||||
data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File },
|
||||
) => {
|
||||
if (!editingMaterial) return
|
||||
editMutation.mutate({
|
||||
id: editingMaterial.id,
|
||||
name: data.name,
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
tagIds: data.tagIds,
|
||||
})
|
||||
},
|
||||
[editMutation],
|
||||
)
|
||||
|
||||
return {
|
||||
isEditing: editMutation.isPending,
|
||||
editMutation,
|
||||
handleEdit,
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
createAsset,
|
||||
uploadAssetDirect,
|
||||
getAssetLibraries,
|
||||
type AssetLibraryItem,
|
||||
} from "@/api/assets"
|
||||
import { tagAsset } from "@/api/tags"
|
||||
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../../types"
|
||||
import { getAudioDuration } from "../../../utils/audio"
|
||||
|
||||
interface UseVoiceUploadOptions {
|
||||
voiceLibrary?: { id: string; kind: string }
|
||||
createLibMutation: { mutateAsync: () => Promise<AssetLibraryItem>; isPending: boolean }
|
||||
}
|
||||
|
||||
/**
|
||||
* 配音素材上传 Hook
|
||||
* 封装上传流程:获取库 → 上传文件 → 获取时长 → 创建记录 → 打标签
|
||||
*/
|
||||
export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUploadOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
file: File
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
tagIds: string[]
|
||||
}) => {
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
// 1. 获取或等待 voice library
|
||||
let lib = voiceLibrary
|
||||
if (!lib) {
|
||||
if (createLibMutation.isPending) {
|
||||
await createLibMutation.mutateAsync()
|
||||
}
|
||||
const libs = await queryClient.fetchQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
})
|
||||
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
|
||||
if (!lib) throw new Error("无法创建配音库")
|
||||
}
|
||||
|
||||
// 2. 上传文件(带进度)
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
// 3. 获取音频时长
|
||||
const duration = await getAudioDuration(data.file)
|
||||
|
||||
// 4. 创建素材记录
|
||||
const asset = await createAsset({
|
||||
library_id: lib.id,
|
||||
name: data.name,
|
||||
storage_key,
|
||||
mime_type: data.file.type || "audio/mpeg",
|
||||
metadata: buildMetadata({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration,
|
||||
}),
|
||||
})
|
||||
|
||||
// 5. 打标签(标签走独立 API)
|
||||
if (data.tagIds.length > 0) {
|
||||
await tagAsset(asset.id, data.tagIds)
|
||||
}
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] })
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
message.error(err.message || "上传失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
const handleUpload = useCallback(
|
||||
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
|
||||
if (!data.file) return
|
||||
uploadMutation.mutate({
|
||||
file: data.file,
|
||||
name: data.name,
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
tagIds: data.tagIds,
|
||||
})
|
||||
},
|
||||
[uploadMutation],
|
||||
)
|
||||
|
||||
return {
|
||||
uploadProgress,
|
||||
isUploading: uploadMutation.isPending,
|
||||
uploadMutation,
|
||||
handleUpload,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+38
-174
@@ -1,17 +1,9 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
createAsset,
|
||||
updateAsset,
|
||||
deleteAsset,
|
||||
uploadAssetDirect,
|
||||
getAssetLibraries,
|
||||
type AssetLibraryItem,
|
||||
} from "@/api/assets"
|
||||
import { tagAsset, untagAsset } from "@/api/tags"
|
||||
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../types"
|
||||
import { getAudioDuration } from "../../utils/audio"
|
||||
import { useState } from "react"
|
||||
import { type VoiceMaterial } from "../../types"
|
||||
import { type AssetLibraryItem } from "@/api/assets"
|
||||
import { useVoiceUpload } from "./actions/useVoiceUpload"
|
||||
import { useVoiceEdit } from "./actions/useVoiceEdit"
|
||||
import { useVoiceDelete } from "./actions/useVoiceDelete"
|
||||
|
||||
interface UseVoiceMaterialActionsOptions {
|
||||
voiceLibrary?: { id: string; kind: string }
|
||||
@@ -21,184 +13,56 @@ interface UseVoiceMaterialActionsOptions {
|
||||
|
||||
/**
|
||||
* 配音素材操作 Hook
|
||||
* 封装上传、编辑、删除等变更操作及相关 UI 状态
|
||||
* 组合上传、编辑、删除三个子 Hook,统一管理弹窗状态
|
||||
*/
|
||||
export function useVoiceMaterialActions({
|
||||
voiceLibrary,
|
||||
materials,
|
||||
createLibMutation,
|
||||
}: UseVoiceMaterialActionsOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// ── 弹窗状态 ──────────────────────────────────────────────
|
||||
// ── 弹窗状态 ──
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [editingMaterial, setEditingMaterial] = useState<VoiceMaterial | null>(null)
|
||||
|
||||
// ── 上传进度 ──────────────────────────────────────────────
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
// ── 子领域 Hooks ──
|
||||
const { uploadProgress, uploadMutation } = useVoiceUpload({ voiceLibrary, createLibMutation })
|
||||
const { editMutation } = useVoiceEdit({ materials })
|
||||
const { handleDelete } = useVoiceDelete({ materials })
|
||||
|
||||
// ── 上传 mutation ─────────────────────────────────────────
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
file: File
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
tagIds: string[]
|
||||
}) => {
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
// 1. 获取或等待 voice library
|
||||
let lib = voiceLibrary
|
||||
if (!lib) {
|
||||
if (createLibMutation.isPending) {
|
||||
await createLibMutation.mutateAsync()
|
||||
}
|
||||
const libs = await queryClient.fetchQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
})
|
||||
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
|
||||
if (!lib) throw new Error("无法创建配音库")
|
||||
}
|
||||
/* ── 操作 handlers(关联弹窗状态) ── */
|
||||
|
||||
// 2. 上传文件(带进度)
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
// 3. 获取音频时长
|
||||
const duration = await getAudioDuration(data.file)
|
||||
|
||||
// 4. 创建素材记录
|
||||
const asset = await createAsset({
|
||||
library_id: lib.id,
|
||||
name: data.name,
|
||||
storage_key,
|
||||
mime_type: data.file.type || "audio/mpeg",
|
||||
metadata: buildMetadata({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration,
|
||||
}),
|
||||
})
|
||||
|
||||
// 5. 打标签(标签走独立 API)
|
||||
if (data.tagIds.length > 0) {
|
||||
await tagAsset(asset.id, data.tagIds)
|
||||
}
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] })
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
message.error(err.message || "上传失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
// ── 编辑 mutation ─────────────────────────────────────────
|
||||
const editMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
id: string
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
tagIds: string[]
|
||||
}) => {
|
||||
// 1. 更新基础信息
|
||||
await updateAsset(data.id, {
|
||||
name: data.name,
|
||||
metadata: buildMetadata({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
}),
|
||||
})
|
||||
|
||||
// 2. 对比标签差异,调用 tag/untag API
|
||||
const currentAsset = materials.find((m) => m.id === data.id)
|
||||
const oldTagIds = currentAsset?.tagIds ?? []
|
||||
const newTagIds = data.tagIds
|
||||
|
||||
const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id))
|
||||
const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id))
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
await tagAsset(data.id, toAdd)
|
||||
}
|
||||
for (const tagId of toRemove) {
|
||||
await untagAsset(data.id, tagId)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] })
|
||||
},
|
||||
})
|
||||
|
||||
// ── 删除 mutation ─────────────────────────────────────────
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (assetId: string) => deleteAsset(assetId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 数据操作 handlers ──────────────────────────────────── */
|
||||
|
||||
const handleUpload = useCallback(
|
||||
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
|
||||
if (!data.file) return
|
||||
uploadMutation.mutate(
|
||||
{
|
||||
file: data.file,
|
||||
name: data.name,
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
tagIds: data.tagIds,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUploadOpen(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
[uploadMutation],
|
||||
)
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
|
||||
if (!editingMaterial) return
|
||||
editMutation.mutate({
|
||||
id: editingMaterial.id,
|
||||
const handleUpload = (data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
|
||||
if (!data.file) return
|
||||
uploadMutation.mutate(
|
||||
{
|
||||
file: data.file,
|
||||
name: data.name,
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
tagIds: data.tagIds,
|
||||
})
|
||||
setEditingMaterial(null)
|
||||
},
|
||||
[editingMaterial, editMutation],
|
||||
)
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUploadOpen(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string, onBeforeDelete?: () => void) => {
|
||||
const material = materials.find((m) => m.id === id)
|
||||
if (!material) return
|
||||
if (onBeforeDelete) onBeforeDelete()
|
||||
deleteMutation.mutate(id)
|
||||
},
|
||||
[materials, deleteMutation],
|
||||
)
|
||||
const handleEdit = (data: Omit<VoiceMaterial, "id" | "createdAt"> & { file?: File }) => {
|
||||
if (!editingMaterial) return
|
||||
editMutation.mutate({
|
||||
id: editingMaterial.id,
|
||||
name: data.name,
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
tagIds: data.tagIds,
|
||||
})
|
||||
setEditingMaterial(null)
|
||||
}
|
||||
|
||||
return {
|
||||
// 上传 & 编辑状态
|
||||
// 上传 & 编辑 loading 状态
|
||||
uploadProgress,
|
||||
isUploading: uploadMutation.isPending,
|
||||
isEditing: editMutation.isPending,
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
import React from "react"
|
||||
import { RobotOutlined } from "@ant-design/icons"
|
||||
import { Modal, message } from "antd"
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
import { genderLabel } from "@/pages/voices/utils/format"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
export interface TtsModalProps {
|
||||
open: boolean
|
||||
ttsText: string
|
||||
ttsVoiceId: string
|
||||
ttsSpeed: number
|
||||
ttsStatus: TtsStatus
|
||||
ttsAudioUrl: string | null
|
||||
ttsError: string | null
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
onClose: () => void
|
||||
onTextChange: (text: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
onSpeedChange: (speed: number) => void
|
||||
onSynthesize: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
import { Modal } from "antd"
|
||||
import { type TtsModalProps, type TtsStatus } from "./tts-modal/types"
|
||||
import TextInputSection from "./tts-modal/TextInputSection"
|
||||
import VoiceSelector from "./tts-modal/VoiceSelector"
|
||||
import SpeedControl from "./tts-modal/SpeedControl"
|
||||
import SynthesizeButton from "./tts-modal/SynthesizeButton"
|
||||
import ErrorAlert from "./tts-modal/ErrorAlert"
|
||||
import ResultPanel from "./tts-modal/ResultPanel"
|
||||
|
||||
/** AI 配音弹窗 */
|
||||
const TtsModal: React.FC<TtsModalProps> = ({
|
||||
@@ -50,205 +35,13 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
{/* 文本输入 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
输入文本
|
||||
</div>
|
||||
<textarea
|
||||
value={ttsText}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
placeholder="输入要配音的文本内容..."
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary)",
|
||||
textAlign: "right",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{ttsText.length}/2000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
选择音色
|
||||
</div>
|
||||
<select
|
||||
value={ttsVoiceId}
|
||||
onChange={(e) => onVoiceChange(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} — {genderLabel(v.gender)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 语速 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
语速:{ttsSpeed.toFixed(1)}x
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={ttsSpeed}
|
||||
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
|
||||
style={{ width: "100%", accentColor: "var(--primary-color)" }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary)",
|
||||
}}
|
||||
>
|
||||
<span>0.5x</span>
|
||||
<span>1.0x</span>
|
||||
<span>2.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 合成按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!ttsText.trim()) {
|
||||
message.warning("请输入要合成的文本")
|
||||
return
|
||||
}
|
||||
onSynthesize()
|
||||
}}
|
||||
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 0",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background:
|
||||
ttsStatus === "synthesizing" || !ttsText.trim()
|
||||
? "var(--text-tertiary)"
|
||||
: "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: ttsStatus === "synthesizing" || !ttsText.trim() ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<RobotOutlined />
|
||||
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
|
||||
</button>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{ttsError && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--error-soft, #fff2f0)",
|
||||
borderRadius: 8,
|
||||
color: "var(--error-color, #ff4d4f)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{ttsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合成结果 */}
|
||||
<TextInputSection value={ttsText} onChange={onTextChange} />
|
||||
<VoiceSelector value={ttsVoiceId} onChange={onVoiceChange} presetVoices={presetVoices} />
|
||||
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
|
||||
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
|
||||
{ttsError && <ErrorAlert error={ttsError} />}
|
||||
{ttsStatus === "done" && ttsAudioUrl && (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "var(--success-color, #52c41a)",
|
||||
}}
|
||||
>
|
||||
✅ 合成完成
|
||||
</div>
|
||||
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--primary-color)",
|
||||
background: "var(--primary-soft)",
|
||||
color: "var(--primary-color)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
保存到配音库
|
||||
</button>
|
||||
</div>
|
||||
<ResultPanel audioUrl={ttsAudioUrl} onSave={onSave} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -256,3 +49,5 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
}
|
||||
|
||||
export default TtsModal
|
||||
|
||||
export type { TtsModalProps, TtsStatus }
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import React from "react"
|
||||
import { UploadOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import { Modal, Upload, message } from "antd"
|
||||
import { type VoiceGender } from "@/pages/voices/types"
|
||||
import { formatFileSize } from "@/pages/voices/utils/format"
|
||||
|
||||
export interface UploadVoiceModalProps {
|
||||
open: boolean
|
||||
uploadFile: File | null
|
||||
uploadName: string
|
||||
uploadGender: VoiceGender
|
||||
uploadDesc: string
|
||||
uploadProgress: number | null
|
||||
onClose: () => void
|
||||
onFileSelect: (file: File) => void
|
||||
onFileRemove: () => void
|
||||
onNameChange: (name: string) => void
|
||||
onGenderChange: (gender: VoiceGender) => void
|
||||
onDescChange: (desc: string) => void
|
||||
onUpload: () => void
|
||||
}
|
||||
import { Modal } from "antd"
|
||||
import {
|
||||
FileUploadZone,
|
||||
FileInfoCard,
|
||||
UploadProgress,
|
||||
FormFields,
|
||||
ActionButtons,
|
||||
} from "./upload-voice-modal"
|
||||
import type { UploadVoiceModalProps } from "./upload-voice-modal"
|
||||
|
||||
/** 上传音频弹窗 */
|
||||
const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
@@ -36,17 +25,20 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
onDescChange,
|
||||
onUpload,
|
||||
}) => {
|
||||
const uploading = uploadProgress !== null
|
||||
const canUpload = !!uploadFile && !!uploadName.trim()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="上传音频"
|
||||
open={open}
|
||||
onCancel={() => {
|
||||
if (uploadProgress !== null) return // 上传中不可关闭
|
||||
if (uploading) return // 上传中不可关闭
|
||||
onClose()
|
||||
}}
|
||||
footer={null}
|
||||
width={520}
|
||||
maskClosable={uploadProgress === null}
|
||||
maskClosable={!uploading}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -57,270 +49,36 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
}}
|
||||
>
|
||||
{/* 拖拽上传区 */}
|
||||
<Upload.Dragger
|
||||
accept="audio/*"
|
||||
maxCount={1}
|
||||
beforeUpload={(file) => {
|
||||
onFileSelect(file)
|
||||
return false
|
||||
}}
|
||||
onRemove={() => {
|
||||
onFileRemove()
|
||||
}}
|
||||
showUploadList={false}
|
||||
disabled={uploadProgress !== null}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: "var(--primary-color)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
|
||||
点击或拖拽音频文件到此处
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 200MB
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
<FileUploadZone
|
||||
disabled={uploading}
|
||||
onFileSelect={onFileSelect}
|
||||
onFileRemove={onFileRemove}
|
||||
/>
|
||||
|
||||
{/* 已选文件信息 */}
|
||||
{uploadFile && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{uploadFile.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
|
||||
{formatFileSize(uploadFile.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{uploadFile && <FileInfoCard file={uploadFile} />}
|
||||
|
||||
{/* 上传进度 */}
|
||||
{uploadProgress !== null && (
|
||||
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "var(--primary-color)",
|
||||
}}
|
||||
>
|
||||
{uploadProgress}%
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{uploadProgress < 100 ? "上传中..." : "处理中..."}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: 2,
|
||||
marginTop: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${uploadProgress}%`,
|
||||
background: "var(--primary-color)",
|
||||
borderRadius: 2,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{uploadProgress !== null && <UploadProgress progress={uploadProgress} />}
|
||||
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
素材名称
|
||||
</div>
|
||||
<input
|
||||
value={uploadName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="输入素材名称"
|
||||
maxLength={100}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别选择 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
音色性别
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
onClick={() => onGenderChange(g)}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 0",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
|
||||
background: uploadGender === g ? "var(--primary-soft)" : "transparent",
|
||||
color: uploadGender === g ? "var(--primary-color)" : "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: uploadGender === g ? 600 : 400,
|
||||
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
音色描述(可选)
|
||||
</div>
|
||||
<textarea
|
||||
value={uploadDesc}
|
||||
onChange={(e) => onDescChange(e.target.value)}
|
||||
placeholder="描述这个音色的特点..."
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* 表单字段 */}
|
||||
<FormFields
|
||||
name={uploadName}
|
||||
gender={uploadGender}
|
||||
desc={uploadDesc}
|
||||
disabled={uploading}
|
||||
onNameChange={onNameChange}
|
||||
onGenderChange={onGenderChange}
|
||||
onDescChange={onDescChange}
|
||||
/>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 10,
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border-color)",
|
||||
background: "transparent",
|
||||
fontSize: 13,
|
||||
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!uploadFile) {
|
||||
message.warning("请先选择音频文件")
|
||||
return
|
||||
}
|
||||
if (!uploadName.trim()) {
|
||||
message.warning("请输入素材名称")
|
||||
return
|
||||
}
|
||||
onUpload()
|
||||
}}
|
||||
disabled={!uploadFile || !uploadName.trim() || uploadProgress !== null}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background:
|
||||
!uploadFile || !uploadName.trim() || uploadProgress !== null
|
||||
? "var(--text-tertiary)"
|
||||
: "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor:
|
||||
!uploadFile || !uploadName.trim() || uploadProgress !== null
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
}}
|
||||
>
|
||||
{uploadProgress !== null ? "上传中..." : "开始上传"}
|
||||
</button>
|
||||
</div>
|
||||
<ActionButtons
|
||||
uploading={uploading}
|
||||
canUpload={canUpload}
|
||||
onCancel={onClose}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from "react"
|
||||
|
||||
interface ErrorAlertProps {
|
||||
error: string
|
||||
}
|
||||
|
||||
/** 错误提示 */
|
||||
const ErrorAlert: React.FC<ErrorAlertProps> = ({ error }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--error-soft, #fff2f0)",
|
||||
borderRadius: 8,
|
||||
color: "var(--error-color, #ff4d4f)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorAlert
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react"
|
||||
|
||||
interface ResultPanelProps {
|
||||
audioUrl: string
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
/** 合成结果展示 */
|
||||
const ResultPanel: React.FC<ResultPanelProps> = ({ audioUrl, onSave }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "var(--success-color, #52c41a)",
|
||||
}}
|
||||
>
|
||||
✅ 合成完成
|
||||
</div>
|
||||
<audio controls src={audioUrl} style={{ width: "100%" }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--primary-color)",
|
||||
background: "var(--primary-soft)",
|
||||
color: "var(--primary-color)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
保存到配音库
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResultPanel
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react"
|
||||
import { TTS_CONFIG } from "./types"
|
||||
|
||||
interface SpeedControlProps {
|
||||
speed: number
|
||||
onChange: (speed: number) => void
|
||||
}
|
||||
|
||||
/** 语速调节滑块 */
|
||||
const SpeedControl: React.FC<SpeedControlProps> = ({ speed, onChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
语速:{speed.toFixed(1)}x
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={TTS_CONFIG.MIN_SPEED}
|
||||
max={TTS_CONFIG.MAX_SPEED}
|
||||
step={TTS_CONFIG.SPEED_STEP}
|
||||
value={speed}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
style={{ width: "100%", accentColor: "var(--primary-color)" }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary)",
|
||||
}}
|
||||
>
|
||||
<span>{TTS_CONFIG.MIN_SPEED}x</span>
|
||||
<span>{TTS_CONFIG.DEFAULT_SPEED}x</span>
|
||||
<span>{TTS_CONFIG.MAX_SPEED}x</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SpeedControl
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react"
|
||||
import { RobotOutlined } from "@ant-design/icons"
|
||||
import { message } from "antd"
|
||||
import { type TtsStatus } from "./types"
|
||||
|
||||
interface SynthesizeButtonProps {
|
||||
status: TtsStatus
|
||||
text: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/** 合成按钮 */
|
||||
const SynthesizeButton: React.FC<SynthesizeButtonProps> = ({ status, text, onClick }) => {
|
||||
const disabled = status === "synthesizing" || !text.trim()
|
||||
|
||||
const handleClick = () => {
|
||||
if (!text.trim()) {
|
||||
message.warning("请输入要合成的文本")
|
||||
return
|
||||
}
|
||||
onClick()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 0",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: disabled ? "var(--text-tertiary)" : "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<RobotOutlined />
|
||||
{status === "synthesizing" ? "合成中..." : "开始合成"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default SynthesizeButton
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from "react"
|
||||
import { TTS_CONFIG } from "./types"
|
||||
|
||||
interface TextInputSectionProps {
|
||||
value: string
|
||||
onChange: (text: string) => void
|
||||
}
|
||||
|
||||
/** 文本输入区 */
|
||||
const TextInputSection: React.FC<TextInputSectionProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
输入文本
|
||||
</div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="输入要配音的文本内容..."
|
||||
maxLength={TTS_CONFIG.MAX_TEXT_LENGTH}
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary)",
|
||||
textAlign: "right",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{value.length}/{TTS_CONFIG.MAX_TEXT_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextInputSection
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react"
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
import { genderLabel } from "@/pages/voices/utils/format"
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
value: string
|
||||
onChange: (voiceId: string) => void
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
}
|
||||
|
||||
/** 音色选择下拉 */
|
||||
const VoiceSelector: React.FC<VoiceSelectorProps> = ({ value, onChange, presetVoices }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
选择音色
|
||||
</div>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} — {genderLabel(v.gender)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceSelector
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default } from "../TtsModal"
|
||||
export * from "./types"
|
||||
export { default as TextInputSection } from "./TextInputSection"
|
||||
export { default as VoiceSelector } from "./VoiceSelector"
|
||||
export { default as SpeedControl } from "./SpeedControl"
|
||||
export { default as SynthesizeButton } from "./SynthesizeButton"
|
||||
export { default as ErrorAlert } from "./ErrorAlert"
|
||||
export { default as ResultPanel } from "./ResultPanel"
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
export interface TtsModalProps {
|
||||
open: boolean
|
||||
ttsText: string
|
||||
ttsVoiceId: string
|
||||
ttsSpeed: number
|
||||
ttsStatus: TtsStatus
|
||||
ttsAudioUrl: string | null
|
||||
ttsError: string | null
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
onClose: () => void
|
||||
onTextChange: (text: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
onSpeedChange: (speed: number) => void
|
||||
onSynthesize: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
/** TTS 常量配置 */
|
||||
export const TTS_CONFIG = {
|
||||
MAX_TEXT_LENGTH: 2000,
|
||||
DEFAULT_SPEED: 1.0,
|
||||
MIN_SPEED: 0.5,
|
||||
MAX_SPEED: 2.0,
|
||||
SPEED_STEP: 0.1,
|
||||
} as const
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from "react"
|
||||
|
||||
interface ActionButtonsProps {
|
||||
uploading: boolean
|
||||
canUpload: boolean
|
||||
onCancel: () => void
|
||||
onUpload: () => void
|
||||
}
|
||||
|
||||
const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
uploading,
|
||||
canUpload,
|
||||
onCancel,
|
||||
onUpload,
|
||||
}) => {
|
||||
const disabled = uploading || !canUpload
|
||||
|
||||
const handleUpload = () => {
|
||||
onUpload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 10,
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={uploading}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border-color)",
|
||||
background: "transparent",
|
||||
fontSize: 13,
|
||||
cursor: uploading ? "not-allowed" : "pointer",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: disabled ? "var(--text-tertiary)" : "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{uploading ? "上传中..." : "开始上传"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionButtons
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react"
|
||||
import { SoundOutlined } from "@ant-design/icons"
|
||||
import { formatFileSize } from "@/pages/voices/utils/format"
|
||||
|
||||
interface FileInfoCardProps {
|
||||
file: File
|
||||
}
|
||||
|
||||
const FileInfoCard: React.FC<FileInfoCardProps> = ({ file }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileInfoCard
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from "react"
|
||||
import { UploadOutlined } from "@ant-design/icons"
|
||||
import { Upload } from "antd"
|
||||
import { UPLOAD_CONFIG } from "./types"
|
||||
|
||||
interface FileUploadZoneProps {
|
||||
disabled: boolean
|
||||
onFileSelect: (file: File) => void
|
||||
onFileRemove: () => void
|
||||
}
|
||||
|
||||
const FileUploadZone: React.FC<FileUploadZoneProps> = ({
|
||||
disabled,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
}) => {
|
||||
return (
|
||||
<Upload.Dragger
|
||||
accept={UPLOAD_CONFIG.accept}
|
||||
maxCount={1}
|
||||
beforeUpload={(file) => {
|
||||
onFileSelect(file)
|
||||
return false
|
||||
}}
|
||||
onRemove={() => {
|
||||
onFileRemove()
|
||||
}}
|
||||
showUploadList={false}
|
||||
disabled={disabled}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: "var(--primary-color)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>点击或拖拽音频文件到此处</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 {UPLOAD_CONFIG.maxSizeMB}MB
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUploadZone
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from "react"
|
||||
import type { VoiceGender } from "@/pages/voices/types"
|
||||
import { GENDER_OPTIONS, UPLOAD_CONFIG } from "./types"
|
||||
|
||||
interface FormFieldsProps {
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
desc: string
|
||||
disabled: boolean
|
||||
onNameChange: (name: string) => void
|
||||
onGenderChange: (gender: VoiceGender) => void
|
||||
onDescChange: (desc: string) => void
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}
|
||||
|
||||
const FormFields: React.FC<FormFieldsProps> = ({
|
||||
name,
|
||||
gender,
|
||||
desc,
|
||||
disabled,
|
||||
onNameChange,
|
||||
onGenderChange,
|
||||
onDescChange,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<div style={labelStyle}>素材名称</div>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="输入素材名称"
|
||||
maxLength={UPLOAD_CONFIG.maxNameLength}
|
||||
disabled={disabled}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别选择 */}
|
||||
<div>
|
||||
<div style={labelStyle}>音色性别</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{GENDER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onGenderChange(opt.value)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 0",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${gender === opt.value ? "var(--primary-color)" : "var(--border-color)"}`,
|
||||
background: gender === opt.value ? "var(--primary-soft)" : "transparent",
|
||||
color: gender === opt.value ? "var(--primary-color)" : "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: gender === opt.value ? 600 : 400,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<div style={labelStyle}>音色描述(可选)</div>
|
||||
<textarea
|
||||
value={desc}
|
||||
onChange={(e) => onDescChange(e.target.value)}
|
||||
placeholder="描述这个音色的特点..."
|
||||
maxLength={UPLOAD_CONFIG.maxDescLength}
|
||||
rows={2}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
...inputStyle,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FormFields
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react"
|
||||
|
||||
interface UploadProgressProps {
|
||||
progress: number
|
||||
}
|
||||
|
||||
const UploadProgress: React.FC<UploadProgressProps> = ({ progress }) => {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "var(--primary-color)",
|
||||
}}
|
||||
>
|
||||
{progress}%
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{progress < 100 ? "上传中..." : "处理中..."}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: 2,
|
||||
marginTop: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progress}%`,
|
||||
background: "var(--primary-color)",
|
||||
borderRadius: 2,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadProgress
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as FileUploadZone } from "./FileUploadZone"
|
||||
export { default as FileInfoCard } from "./FileInfoCard"
|
||||
export { default as UploadProgress } from "./UploadProgress"
|
||||
export { default as FormFields } from "./FormFields"
|
||||
export { default as ActionButtons } from "./ActionButtons"
|
||||
export * from "./types"
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { VoiceGender } from "@/pages/voices/types"
|
||||
|
||||
export interface UploadVoiceModalProps {
|
||||
open: boolean
|
||||
uploadFile: File | null
|
||||
uploadName: string
|
||||
uploadGender: VoiceGender
|
||||
uploadDesc: string
|
||||
uploadProgress: number | null
|
||||
onClose: () => void
|
||||
onFileSelect: (file: File) => void
|
||||
onFileRemove: () => void
|
||||
onNameChange: (name: string) => void
|
||||
onGenderChange: (gender: VoiceGender) => void
|
||||
onDescChange: (desc: string) => void
|
||||
onUpload: () => void
|
||||
}
|
||||
|
||||
export const GENDER_OPTIONS: { value: VoiceGender; label: string }[] = [
|
||||
{ value: "female", label: "女声" },
|
||||
{ value: "male", label: "男声" },
|
||||
{ value: "child", label: "童声" },
|
||||
]
|
||||
|
||||
export const UPLOAD_CONFIG = {
|
||||
maxSizeMB: 200,
|
||||
maxNameLength: 100,
|
||||
maxDescLength: 500,
|
||||
accept: "audio/*",
|
||||
} as const
|
||||
Regular → Executable
+1
@@ -1,3 +1,4 @@
|
||||
// 重构:DuplicationUpload 页面已拆分为子组件(UploadZone/InfoSidebar/UploadProgress等)
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
uploadForDuplication,
|
||||
|
||||
Regular → Executable
+1
@@ -1,4 +1,5 @@
|
||||
import React from "react"
|
||||
// 重构:useCloneModal Hook 已拆分为 useCloneFormState + useCloneSubmit 子 Hook
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
|
||||
Regular → Executable
+5
@@ -122,6 +122,11 @@ import "@/pages/templates/hooks/useTemplateLibrary"
|
||||
import "@/pages/templates/hooks/useTemplateDetail"
|
||||
import "@/pages/templates/components/template-library/TemplateCard"
|
||||
import "@/pages/templates/components/template-library/TemplateDetailModal"
|
||||
import "@/pages/templates/components/template-library/template-detail-modal/PreviewArea"
|
||||
import "@/pages/templates/components/template-library/template-detail-modal/SegmentList"
|
||||
import "@/pages/templates/components/template-library/template-detail-modal/StyleConfig"
|
||||
import "@/pages/templates/components/template-library/template-detail-modal/DetailFooter"
|
||||
import "@/pages/templates/components/template-library/template-detail-modal/types"
|
||||
import "@/pages/templates/components/template-library/TemplateHeader"
|
||||
import "@/pages/templates/components/template-library/TemplateToolbar"
|
||||
import "@/pages/templates/components/template-library/TemplateGrid"
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
* GeneratePage 模块 smoke test
|
||||
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
||||
* generate 目录下所有文件的改动(包括 Phase 3 子组件)
|
||||
*
|
||||
* 重构记录:
|
||||
* - useStep2Materials 拆分为 useMaterialLibrary + useSmartMatch 子 Hook
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
|
||||
Regular → Executable
+10
@@ -12,6 +12,13 @@ import "@/pages/voice-materials/VoiceMaterialLibrary"
|
||||
import "@/pages/voice-materials/components/TagSelector"
|
||||
import "@/pages/voice-materials/components/MaterialForm"
|
||||
import "@/pages/voice-materials/components/VoiceMaterialCard"
|
||||
import "@/pages/voice-materials/components/voice-material-card/CardHeader"
|
||||
import "@/pages/voice-materials/components/voice-material-card/CardTags"
|
||||
import "@/pages/voice-materials/components/voice-material-card/CardMeta"
|
||||
import "@/pages/voice-materials/components/voice-material-card/CardPlayer"
|
||||
import "@/pages/voice-materials/components/voice-material-card/CardActions"
|
||||
import "@/pages/voice-materials/components/voice-material-card/BatchCheckbox"
|
||||
import "@/pages/voice-materials/components/voice-material-card/types"
|
||||
import "@/pages/voice-materials/components/VoiceMaterialRow"
|
||||
import "@/pages/voice-materials/components/Toolbar"
|
||||
import "@/pages/voice-materials/components/TagFilterBar"
|
||||
@@ -33,6 +40,9 @@ describe("VoiceMaterialLibrary module smoke test", () => {
|
||||
// Hooks
|
||||
import "@/pages/voice-materials/hooks/useVoiceMaterials"
|
||||
import "@/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialActions"
|
||||
import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload"
|
||||
import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceEdit"
|
||||
import "@/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceDelete"
|
||||
import "@/pages/voice-materials/hooks/useTtsSynthesize"
|
||||
import "@/pages/voice-materials/hooks/useAudioPlayer"
|
||||
import "@/pages/voice-materials/hooks/useBatchOperations"
|
||||
|
||||
@@ -14,7 +14,20 @@ import "@/pages/voices/components/CloneVoiceCard"
|
||||
import "@/pages/voices/components/CloneDetailModal"
|
||||
import "@/pages/voices/components/CloneCardSkeleton"
|
||||
import "@/pages/voices/components/UploadVoiceModal"
|
||||
import "@/pages/voices/components/upload-voice-modal/FileUploadZone"
|
||||
import "@/pages/voices/components/upload-voice-modal/FileInfoCard"
|
||||
import "@/pages/voices/components/upload-voice-modal/UploadProgress"
|
||||
import "@/pages/voices/components/upload-voice-modal/FormFields"
|
||||
import "@/pages/voices/components/upload-voice-modal/ActionButtons"
|
||||
import "@/pages/voices/components/upload-voice-modal/types"
|
||||
import "@/pages/voices/components/TtsModal"
|
||||
import "@/pages/voices/components/tts-modal/TextInputSection"
|
||||
import "@/pages/voices/components/tts-modal/VoiceSelector"
|
||||
import "@/pages/voices/components/tts-modal/SpeedControl"
|
||||
import "@/pages/voices/components/tts-modal/SynthesizeButton"
|
||||
import "@/pages/voices/components/tts-modal/ErrorAlert"
|
||||
import "@/pages/voices/components/tts-modal/ResultPanel"
|
||||
import "@/pages/voices/components/tts-modal/types"
|
||||
import "@/pages/voices/components/VoiceFilterBar"
|
||||
import "@/pages/voices/components/MaterialVoiceCard"
|
||||
|
||||
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
"""BGM 混音纯逻辑模块.
|
||||
|
||||
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
|
||||
便于单元测试,也方便被其他模块复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class BGMPureConfig:
|
||||
"""BGM 混音配置(纯数据类)."""
|
||||
|
||||
volume: float = 0.3
|
||||
fade_in: float = 0.0
|
||||
fade_out: float = 0.0
|
||||
loop_enabled: bool = True
|
||||
sidechain_enabled: bool = False
|
||||
sidechain_ratio: float = 0.3
|
||||
sidechain_attack: float = 0.02
|
||||
sidechain_release: float = 0.5
|
||||
sidechain_threshold: float = -25.0
|
||||
|
||||
|
||||
# ── 循环判断与计算 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def should_loop_bgm(
|
||||
bgm_duration: float,
|
||||
target_duration: float,
|
||||
loop_enabled: bool = True,
|
||||
) -> bool:
|
||||
"""判断是否需要循环 BGM.
|
||||
|
||||
当 BGM 时长小于目标时长的 90% 时才循环,
|
||||
避免 BGM 只差一点点就铺满还要循环一次的情况。
|
||||
|
||||
Args:
|
||||
bgm_duration: BGM 原始时长(秒)
|
||||
target_duration: 目标时长(秒)
|
||||
loop_enabled: 是否允许循环
|
||||
|
||||
Returns:
|
||||
是否需要循环
|
||||
"""
|
||||
if not loop_enabled:
|
||||
return False
|
||||
if bgm_duration <= 0:
|
||||
return False
|
||||
if target_duration <= 0:
|
||||
return False
|
||||
return bgm_duration < target_duration * 0.9
|
||||
|
||||
|
||||
def calculate_loop_count(bgm_duration: float, target_duration: float) -> int:
|
||||
"""计算需要循环的次数.
|
||||
|
||||
多算 2 次作为余量,避免末尾因为精度问题不够长。
|
||||
|
||||
Args:
|
||||
bgm_duration: BGM 原始时长(秒)
|
||||
target_duration: 目标时长(秒)
|
||||
|
||||
Returns:
|
||||
循环次数,至少 1
|
||||
"""
|
||||
if bgm_duration <= 0:
|
||||
return 1
|
||||
if target_duration <= 0:
|
||||
return 1
|
||||
if bgm_duration >= target_duration:
|
||||
return 1
|
||||
return max(1, int(target_duration / bgm_duration) + 2)
|
||||
|
||||
|
||||
# ── BGM 预处理滤镜链构建 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_bgm_filter_chain(
|
||||
bgm_duration: float,
|
||||
target_duration: float,
|
||||
volume: float = 0.3,
|
||||
fade_in: float = 0.0,
|
||||
fade_out: float = 0.0,
|
||||
loop_enabled: bool = True,
|
||||
) -> str:
|
||||
"""构建 BGM 预处理滤镜链.
|
||||
|
||||
处理顺序:循环 → 音量 → 淡入 → 淡出 → 截断 → 重置时间戳
|
||||
|
||||
Args:
|
||||
bgm_duration: BGM 原始时长(秒)
|
||||
target_duration: 目标时长(秒)
|
||||
volume: 音量 0.0~1.0
|
||||
fade_in: 淡入时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
loop_enabled: 是否允许循环
|
||||
|
||||
Returns:
|
||||
FFmpeg filter_complex 字符串(逗号分隔)
|
||||
"""
|
||||
# 兜底:目标时长不能为 0 或负数
|
||||
safe_target = max(5.0, target_duration) if target_duration <= 0 else target_duration
|
||||
|
||||
filter_parts: list[str] = []
|
||||
|
||||
# 1. 循环
|
||||
needs_loop = should_loop_bgm(bgm_duration, safe_target, loop_enabled)
|
||||
if needs_loop:
|
||||
loop_count = calculate_loop_count(bgm_duration, safe_target)
|
||||
filter_parts.append(f"aloop=loop={loop_count}:size=0")
|
||||
|
||||
# 2. 音量调节(钳制到 0~1)
|
||||
safe_volume = max(0.0, min(1.0, volume))
|
||||
if abs(safe_volume - 1.0) > 0.001:
|
||||
filter_parts.append(f"volume={safe_volume:.3f}")
|
||||
|
||||
# 3. 淡入
|
||||
if fade_in > 0:
|
||||
filter_parts.append(f"afade=t=in:st=0:d={fade_in:.3f}")
|
||||
|
||||
# 4. 淡出(从 target_duration - fade_out 开始)
|
||||
if fade_out > 0 and safe_target > fade_out:
|
||||
fade_start = safe_target - fade_out
|
||||
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={fade_out:.3f}")
|
||||
|
||||
# 5. 截断到目标时长
|
||||
filter_parts.append(f"atrim=0:{safe_target:.3f}")
|
||||
|
||||
# 6. 重置时间戳
|
||||
filter_parts.append("asetpts=N/SR/TB")
|
||||
|
||||
return ",".join(filter_parts)
|
||||
|
||||
|
||||
# ── 混音滤镜构建 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def calculate_sidechain_ratio(sidechain_ratio: float) -> float:
|
||||
"""计算 sidechain 压缩比.
|
||||
|
||||
sidechain_ratio 表示闪避时 BGM 音量降低比例(0~1),
|
||||
映射到 FFmpeg sidechaincompress 的 ratio 参数(2:1 ~ 10:1)。
|
||||
|
||||
ratio = 1 / (1 - sidechain_ratio)
|
||||
|
||||
Args:
|
||||
sidechain_ratio: 闪避比例 0.0~1.0
|
||||
|
||||
Returns:
|
||||
FFmpeg ratio 值(2.0 ~ 10.0)
|
||||
"""
|
||||
if sidechain_ratio <= 0:
|
||||
return 2.0
|
||||
if sidechain_ratio >= 1.0:
|
||||
return 10.0
|
||||
raw_ratio = 1.0 / (1.0 - sidechain_ratio)
|
||||
return max(2.0, min(10.0, raw_ratio))
|
||||
|
||||
|
||||
def build_simple_mix_filter() -> str:
|
||||
"""构建普通 amix 混音滤镜.
|
||||
|
||||
两路输入:[0:a] 主音频,[1:a] BGM
|
||||
主音频权重 1.0,BGM 已在预处理阶段调好音量。
|
||||
amix 会自动归一化,用 volume=2 补偿衰减。
|
||||
|
||||
Returns:
|
||||
filter_complex 字符串
|
||||
"""
|
||||
return "[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=0[outa];" "[outa]volume=2[final]"
|
||||
|
||||
|
||||
def build_sidechain_mix_filter(
|
||||
threshold: float = -25.0,
|
||||
ratio: float = 0.3,
|
||||
attack: float = 0.02,
|
||||
release: float = 0.5,
|
||||
) -> str:
|
||||
"""构建 sidechain 人声闪避混音滤镜.
|
||||
|
||||
流程:
|
||||
1. BGM[1:a] 经过 sidechaincompress,用主音频[0:a]做触发
|
||||
2. 主音频 + 压缩后的 BGM amix 混音
|
||||
3. volume=1.5 轻微补偿
|
||||
|
||||
Args:
|
||||
threshold: 触发阈值(dB)
|
||||
ratio: 闪避比例 0.0~1.0(会被转换为 FFmpeg ratio)
|
||||
attack: 攻击时间(秒)
|
||||
release: 释放时间(秒)
|
||||
|
||||
Returns:
|
||||
filter_complex 字符串
|
||||
"""
|
||||
ffmpeg_ratio = calculate_sidechain_ratio(ratio)
|
||||
|
||||
return (
|
||||
f"[1:a][0:a]sidechaincompress="
|
||||
f"threshold={threshold}dB:"
|
||||
f"ratio={ffmpeg_ratio:.1f}:"
|
||||
f"attack={attack:.3f}:"
|
||||
f"release={release:.3f}:"
|
||||
f"knee=6[bgm_comp];"
|
||||
f"[0:a][bgm_comp]amix=inputs=2:duration=first:dropout_transition=0[outa];"
|
||||
f"[outa]volume=1.5[final]"
|
||||
)
|
||||
|
||||
|
||||
# ── 配置验证与规范化 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def normalize_bgm_config(config: dict) -> dict:
|
||||
"""规范化 BGM 配置字典.
|
||||
|
||||
将各种类型的输入值转换为正确的类型,
|
||||
并进行边界钳制。
|
||||
|
||||
Args:
|
||||
config: 原始配置字典
|
||||
|
||||
Returns:
|
||||
规范化后的配置字典
|
||||
"""
|
||||
result: dict = {}
|
||||
|
||||
# volume: 0.0 ~ 1.0
|
||||
result["volume"] = max(0.0, min(1.0, float(config.get("volume", 0.3))))
|
||||
|
||||
# fade_in: >= 0
|
||||
result["fade_in"] = max(0.0, float(config.get("fade_in", 0.0)))
|
||||
|
||||
# fade_out: >= 0
|
||||
result["fade_out"] = max(0.0, float(config.get("fade_out", 0.0)))
|
||||
|
||||
# loop_enabled: bool
|
||||
result["loop_enabled"] = bool(config.get("loop_enabled", True))
|
||||
|
||||
# sidechain_enabled: bool
|
||||
result["sidechain_enabled"] = bool(config.get("sidechain_enabled", False))
|
||||
|
||||
# sidechain_ratio: 0.0 ~ 1.0
|
||||
result["sidechain_ratio"] = max(0.0, min(1.0, float(config.get("sidechain_ratio", 0.3))))
|
||||
|
||||
# sidechain_attack: > 0
|
||||
result["sidechain_attack"] = max(0.001, float(config.get("sidechain_attack", 0.02)))
|
||||
|
||||
# sidechain_release: > 0
|
||||
result["sidechain_release"] = max(0.01, float(config.get("sidechain_release", 0.5)))
|
||||
|
||||
# sidechain_threshold: dB
|
||||
result["sidechain_threshold"] = float(config.get("sidechain_threshold", -25.0))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def validate_bgm_config(config: dict) -> tuple[bool, list[str]]:
|
||||
"""验证 BGM 配置是否合法.
|
||||
|
||||
Args:
|
||||
config: 配置字典
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
volume = config.get("volume", 0.3)
|
||||
if not isinstance(volume, (int, float)):
|
||||
errors.append("volume 必须是数字")
|
||||
elif volume < 0 or volume > 1:
|
||||
errors.append("volume 必须在 0~1 之间")
|
||||
|
||||
fade_in = config.get("fade_in", 0)
|
||||
if not isinstance(fade_in, (int, float)):
|
||||
errors.append("fade_in 必须是数字")
|
||||
elif fade_in < 0:
|
||||
errors.append("fade_in 不能为负数")
|
||||
|
||||
fade_out = config.get("fade_out", 0)
|
||||
if not isinstance(fade_out, (int, float)):
|
||||
errors.append("fade_out 必须是数字")
|
||||
elif fade_out < 0:
|
||||
errors.append("fade_out 不能为负数")
|
||||
|
||||
sidechain_ratio = config.get("sidechain_ratio", 0.3)
|
||||
if not isinstance(sidechain_ratio, (int, float)):
|
||||
errors.append("sidechain_ratio 必须是数字")
|
||||
elif sidechain_ratio < 0 or sidechain_ratio > 1:
|
||||
errors.append("sidechain_ratio 必须在 0~1 之间")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
# ── 时长相关工具 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def calculate_fade_out_start(
|
||||
target_duration: float,
|
||||
fade_out: float,
|
||||
) -> Optional[float]:
|
||||
"""计算淡出开始时间.
|
||||
|
||||
如果淡出时长大于等于目标时长,返回 None(不做淡出)。
|
||||
|
||||
Args:
|
||||
target_duration: 目标时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
|
||||
Returns:
|
||||
淡出开始时间(秒),如果不需要淡出返回 None
|
||||
"""
|
||||
if fade_out <= 0:
|
||||
return None
|
||||
if target_duration <= 0:
|
||||
return None
|
||||
if fade_out >= target_duration:
|
||||
return None
|
||||
return target_duration - fade_out
|
||||
|
||||
|
||||
def estimate_bgm_processing_duration(
|
||||
bgm_duration: float,
|
||||
target_duration: float,
|
||||
loop_enabled: bool = True,
|
||||
) -> float:
|
||||
"""估算 BGM 预处理后的实际输出时长.
|
||||
|
||||
正常情况下应该等于 target_duration,
|
||||
但在某些边界情况下可能不同。
|
||||
|
||||
Args:
|
||||
bgm_duration: BGM 原始时长
|
||||
target_duration: 目标时长
|
||||
loop_enabled: 是否允许循环
|
||||
|
||||
Returns:
|
||||
预估输出时长(秒)
|
||||
"""
|
||||
if target_duration <= 0:
|
||||
return 5.0 # 兜底时长
|
||||
|
||||
# 不需要循环的情况:如果 BGM 够长,截断到 target_duration
|
||||
if not loop_enabled and bgm_duration >= target_duration:
|
||||
return target_duration
|
||||
|
||||
# 需要循环或 BGM 太短:截断到 target_duration
|
||||
return target_duration
|
||||
+493
@@ -0,0 +1,493 @@
|
||||
"""视频拼接引擎纯逻辑模块.
|
||||
|
||||
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
|
||||
便于单元测试,也方便被其他模块复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── 帧率解析 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_fps(fps_value: Any) -> float:
|
||||
"""解析帧率字符串/数值.
|
||||
|
||||
支持格式:
|
||||
- 数字: 30 → 30.0
|
||||
- 分数: "30/1" → 30.0, "24000/1001" → 23.976...
|
||||
- 字符串数字: "30" → 30.0
|
||||
|
||||
Args:
|
||||
fps_value: 帧率值(字符串、数字等)
|
||||
|
||||
Returns:
|
||||
帧率(fps),失败返回 30.0
|
||||
"""
|
||||
if fps_value is None:
|
||||
return 30.0
|
||||
|
||||
try:
|
||||
fps_str = str(fps_value).strip()
|
||||
if not fps_str:
|
||||
return 30.0
|
||||
|
||||
if "/" in fps_str:
|
||||
num_str, den_str = fps_str.split("/", 1)
|
||||
num = float(num_str)
|
||||
den = float(den_str)
|
||||
if den == 0:
|
||||
return 30.0
|
||||
return num / den
|
||||
|
||||
return float(fps_str)
|
||||
except (ValueError, TypeError, ZeroDivisionError):
|
||||
return 30.0
|
||||
|
||||
|
||||
def format_fps_filter(fps: float) -> str:
|
||||
"""格式化 fps 滤镜参数.
|
||||
|
||||
Args:
|
||||
fps: 帧率
|
||||
|
||||
Returns:
|
||||
fps 滤镜字符串
|
||||
"""
|
||||
# 接近整数时用整数形式
|
||||
if abs(fps - round(fps)) < 0.001:
|
||||
return f"fps={int(fps)}"
|
||||
return f"fps={fps:.3f}"
|
||||
|
||||
|
||||
# ── 输出参数计算 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_output_params(
|
||||
config_width: int,
|
||||
config_height: int,
|
||||
config_fps: float,
|
||||
first_video_info: Optional[dict] = None,
|
||||
default_width: int = 1080,
|
||||
default_height: int = 1920,
|
||||
default_fps: float = 30.0,
|
||||
) -> tuple[int, int, float]:
|
||||
"""计算输出视频参数.
|
||||
|
||||
优先级:
|
||||
1. config 中显式指定的(非 0 值)
|
||||
2. 第一段视频的探测参数
|
||||
3. 默认值
|
||||
|
||||
Args:
|
||||
config_width: 配置的宽度(0 表示未指定)
|
||||
config_height: 配置的高度(0 表示未指定)
|
||||
config_fps: 配置的帧率(0 表示未指定)
|
||||
first_video_info: 第一段视频的探测信息字典
|
||||
default_width: 默认宽度
|
||||
default_height: 默认高度
|
||||
default_fps: 默认帧率
|
||||
|
||||
Returns:
|
||||
(宽度, 高度, 帧率)
|
||||
"""
|
||||
width = config_width
|
||||
height = config_height
|
||||
fps = config_fps
|
||||
|
||||
info = first_video_info or {}
|
||||
|
||||
# 宽度:用配置 → 探测 → 默认
|
||||
if width == 0:
|
||||
width = int(info.get("width", default_width))
|
||||
|
||||
# 高度
|
||||
if height == 0:
|
||||
height = int(info.get("height", default_height))
|
||||
|
||||
# 帧率
|
||||
if fps == 0:
|
||||
fps_str = info.get("r_frame_rate", f"{int(default_fps)}/1")
|
||||
fps = parse_fps(fps_str)
|
||||
|
||||
# 确保都是有效值
|
||||
width = max(1, width)
|
||||
height = max(1, height)
|
||||
fps = max(1.0, fps)
|
||||
|
||||
return width, height, fps
|
||||
|
||||
|
||||
def calculate_scaled_size(
|
||||
src_w: int,
|
||||
src_h: int,
|
||||
target_w: int,
|
||||
target_h: int,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""计算等比缩放后的尺寸和填充偏移.
|
||||
|
||||
保持宽高比,不足的部分用黑边填充。
|
||||
|
||||
Args:
|
||||
src_w: 原始宽度
|
||||
src_h: 原始高度
|
||||
target_w: 目标宽度
|
||||
target_h: 目标高度
|
||||
|
||||
Returns:
|
||||
(缩放后宽度, 缩放后高度, X偏移, Y偏移)
|
||||
"""
|
||||
if src_w <= 0 or src_h <= 0:
|
||||
return (target_w, target_h, 0, 0)
|
||||
|
||||
src_ratio = src_w / src_h
|
||||
target_ratio = target_w / target_h
|
||||
|
||||
if abs(src_ratio - target_ratio) < 0.001:
|
||||
# 比例相同,直接缩放
|
||||
return (target_w, target_h, 0, 0)
|
||||
elif src_ratio > target_ratio:
|
||||
# 源更宽,以宽度为准,上下填充
|
||||
scaled_w = target_w
|
||||
scaled_h = int(target_w / src_ratio)
|
||||
offset_x = 0
|
||||
offset_y = (target_h - scaled_h) // 2
|
||||
return (scaled_w, scaled_h, offset_x, offset_y)
|
||||
else:
|
||||
# 源更高,以高度为准,左右填充
|
||||
scaled_h = target_h
|
||||
scaled_w = int(target_h * src_ratio)
|
||||
offset_x = (target_w - scaled_w) // 2
|
||||
offset_y = 0
|
||||
return (scaled_w, scaled_h, offset_x, offset_y)
|
||||
|
||||
|
||||
# ── stream copy 判断 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def can_use_stream_copy(
|
||||
segments: list[dict],
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
target_fps: float,
|
||||
force_reencode: bool = False,
|
||||
) -> bool:
|
||||
"""判断是否可以使用 stream copy(无损拼接).
|
||||
|
||||
stream copy 条件:
|
||||
1. force_reencode 为 False
|
||||
2. 所有视频段的编码格式、分辨率、帧率均相同
|
||||
3. 目标参数与源参数一致(不需要转码)
|
||||
|
||||
Args:
|
||||
segments: 视频段列表,每个元素包含 codec_name/width/height/fps
|
||||
target_width: 目标宽度
|
||||
target_height: 目标高度
|
||||
target_fps: 目标帧率
|
||||
force_reencode: 是否强制重编码
|
||||
|
||||
Returns:
|
||||
是否可以用 stream copy
|
||||
"""
|
||||
if force_reencode:
|
||||
return False
|
||||
|
||||
if not segments:
|
||||
return False
|
||||
|
||||
# 用第一段作为基准
|
||||
first = segments[0]
|
||||
base_codec = first.get("codec_name", "")
|
||||
base_width = int(first.get("width", 0))
|
||||
base_height = int(first.get("height", 0))
|
||||
base_fps = parse_fps(first.get("r_frame_rate", "30/1"))
|
||||
|
||||
# 目标参数必须与基准一致
|
||||
if target_width != base_width or target_height != base_height:
|
||||
return False
|
||||
|
||||
if abs(target_fps - base_fps) > 0.01:
|
||||
return False
|
||||
|
||||
# 所有段必须参数一致
|
||||
for seg in segments[1:]:
|
||||
if seg.get("codec_name", "") != base_codec:
|
||||
return False
|
||||
if int(seg.get("width", 0)) != base_width:
|
||||
return False
|
||||
if int(seg.get("height", 0)) != base_height:
|
||||
return False
|
||||
seg_fps = parse_fps(seg.get("r_frame_rate", "30/1"))
|
||||
if abs(seg_fps - base_fps) > 0.01:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ── 文件列表生成(demuxer 模式) ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_concat_file_list(
|
||||
video_paths: list[str],
|
||||
) -> str:
|
||||
"""生成 concat demuxer 模式的文件列表内容.
|
||||
|
||||
格式:
|
||||
file '/path/to/video1.mp4'
|
||||
file '/path/to/video2.mp4'
|
||||
|
||||
Args:
|
||||
video_paths: 视频文件路径列表
|
||||
|
||||
Returns:
|
||||
文件列表文本内容
|
||||
"""
|
||||
lines = []
|
||||
for path in video_paths:
|
||||
# 转义单引号
|
||||
escaped = path.replace("'", "'\\''")
|
||||
lines.append(f"file '{escaped}'")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# ── 滤镜链构建 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_scale_pad_filter(
|
||||
target_w: int,
|
||||
target_h: int,
|
||||
src_w: int = 0,
|
||||
src_h: int = 0,
|
||||
) -> str:
|
||||
"""构建 scale + pad 滤镜(等比缩放+黑边填充).
|
||||
|
||||
Args:
|
||||
target_w: 目标宽度
|
||||
target_h: 目标高度
|
||||
src_w: 源宽度(0 表示未知,用 iw/ih)
|
||||
src_h: 源高度(0 表示未知)
|
||||
|
||||
Returns:
|
||||
滤镜字符串
|
||||
"""
|
||||
# 使用 FFmpeg 表达式,动态计算
|
||||
return (
|
||||
f"scale={target_w}:{target_h}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={target_w}:{target_h}:(ow-iw)/2:(oh-ih)/2:black"
|
||||
)
|
||||
|
||||
|
||||
def build_fps_filter(fps: float) -> str:
|
||||
"""构建 fps 滤镜.
|
||||
|
||||
Args:
|
||||
fps: 目标帧率
|
||||
|
||||
Returns:
|
||||
fps 滤镜字符串
|
||||
"""
|
||||
return format_fps_filter(fps)
|
||||
|
||||
|
||||
def build_setpts_filter() -> str:
|
||||
"""构建 setpts 滤镜(重置时间戳).
|
||||
|
||||
Returns:
|
||||
setpts 滤镜字符串
|
||||
"""
|
||||
return "setpts=PTS-STARTPTS"
|
||||
|
||||
|
||||
def build_concat_filter(
|
||||
num_inputs: int,
|
||||
has_audio: bool = True,
|
||||
) -> str:
|
||||
"""构建 concat 滤镜.
|
||||
|
||||
Args:
|
||||
num_inputs: 输入数量
|
||||
has_audio: 是否包含音频轨
|
||||
|
||||
Returns:
|
||||
concat 滤镜字符串(包含输入标签)
|
||||
"""
|
||||
if num_inputs <= 0:
|
||||
return ""
|
||||
|
||||
n = num_inputs
|
||||
v = 1 # 视频轨数
|
||||
a = 1 if has_audio else 0 # 音频轨数
|
||||
|
||||
# 构建输入标签
|
||||
input_labels = "".join(f"[{i}:v][{i}:a]" if has_audio else f"[{i}:v]" for i in range(n))
|
||||
|
||||
output_label = "[concat_v]" + ("[concat_a]" if has_audio else "")
|
||||
|
||||
return f"{input_labels}concat=n={n}:v={v}:a={a}{output_label}"
|
||||
|
||||
|
||||
def build_single_segment_filter_chain(
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
target_fps: float,
|
||||
segment_index: int,
|
||||
has_audio: bool = True,
|
||||
) -> str:
|
||||
"""构建单段视频的预处理滤镜链.
|
||||
|
||||
每段视频需要:缩放填充 → 帧率统一 → 重置时间戳
|
||||
|
||||
Args:
|
||||
target_width: 目标宽度
|
||||
target_height: 目标高度
|
||||
target_fps: 目标帧率
|
||||
segment_index: 段索引(用于生成标签)
|
||||
has_audio: 是否包含音频
|
||||
|
||||
Returns:
|
||||
滤镜字符串
|
||||
"""
|
||||
scale_pad = build_scale_pad_filter(target_width, target_height)
|
||||
fps = build_fps_filter(target_fps)
|
||||
setpts = build_setpts_filter()
|
||||
|
||||
input_v = f"[{segment_index}:v]"
|
||||
output_v = f"[v{segment_index}]"
|
||||
|
||||
video_chain = f"{input_v}{scale_pad},{fps},{setpts}{output_v}"
|
||||
|
||||
if has_audio:
|
||||
input_a = f"[{segment_index}:a]"
|
||||
output_a = f"[a{segment_index}]"
|
||||
# 音频也需要重置时间戳
|
||||
audio_chain = f"{input_a}asetpts=PTS-STARTPTS{output_a}"
|
||||
return f"{video_chain};{audio_chain}"
|
||||
|
||||
return video_chain
|
||||
|
||||
|
||||
# ── 配置验证 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_concat_config(config: dict) -> tuple[bool, list[str]]:
|
||||
"""验证拼接配置.
|
||||
|
||||
Args:
|
||||
config: 配置字典
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
segments = config.get("segments", [])
|
||||
if not segments:
|
||||
errors.append("至少需要一个视频段")
|
||||
return (False, errors)
|
||||
|
||||
if len(segments) < 1:
|
||||
errors.append("视频段数量不能少于 1")
|
||||
|
||||
# 检查每个段
|
||||
for i, seg in enumerate(segments):
|
||||
video_path = seg.get("video_path", "")
|
||||
if not video_path:
|
||||
errors.append(f"第 {i+1} 段缺少 video_path")
|
||||
|
||||
# 输出参数
|
||||
output_width = config.get("output_width", 0)
|
||||
output_height = config.get("output_height", 0)
|
||||
if output_width < 0:
|
||||
errors.append("output_width 不能为负数")
|
||||
if output_height < 0:
|
||||
errors.append("output_height 不能为负数")
|
||||
|
||||
output_fps = config.get("output_fps", 0)
|
||||
if output_fps < 0:
|
||||
errors.append("output_fps 不能为负数")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
# ── 路径验证 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_video_path(video_path: str, work_dir: str | Path) -> tuple[bool, str]:
|
||||
"""验证视频路径是否安全.
|
||||
|
||||
检查:
|
||||
1. 路径不为空
|
||||
2. 路径不包含 .. 回溯
|
||||
3. 路径在 work_dir 内(安全边界)
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
work_dir: 工作目录
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息)
|
||||
"""
|
||||
if not video_path:
|
||||
return (False, "视频路径不能为空")
|
||||
|
||||
path_str = str(video_path)
|
||||
work_str = str(work_dir)
|
||||
|
||||
# 检查路径遍历
|
||||
if ".." in Path(path_str).parts:
|
||||
return (False, "视频路径不能包含 .. 回溯")
|
||||
|
||||
# 绝对路径才做边界检查;相对路径默认相对于 work_dir
|
||||
if not Path(path_str).is_absolute():
|
||||
return (True, "")
|
||||
|
||||
# 绝对路径检查是否在工作目录内
|
||||
try:
|
||||
video_abs = Path(path_str).resolve()
|
||||
work_abs = Path(work_str).resolve()
|
||||
if work_abs.is_absolute() and not str(video_abs).startswith(str(work_abs)):
|
||||
return (False, "视频路径必须在工作目录内")
|
||||
except (OSError, ValueError):
|
||||
pass # 解析失败时跳过边界检查
|
||||
|
||||
return (True, "")
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def estimate_total_duration(segments: list[dict]) -> float:
|
||||
"""估算总时长.
|
||||
|
||||
Args:
|
||||
segments: 视频段列表,每个元素包含 duration 字段
|
||||
|
||||
Returns:
|
||||
总时长(秒)
|
||||
"""
|
||||
total = 0.0
|
||||
for seg in segments:
|
||||
dur = seg.get("duration", 0)
|
||||
try:
|
||||
total += float(dur)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
def count_valid_segments(segments: list[dict]) -> int:
|
||||
"""统计有效视频段数量(有 video_path 的).
|
||||
|
||||
Args:
|
||||
segments: 视频段列表
|
||||
|
||||
Returns:
|
||||
有效段数量
|
||||
"""
|
||||
count = 0
|
||||
for seg in segments:
|
||||
if seg.get("video_path"):
|
||||
count += 1
|
||||
return count
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
"""多轨混音纯逻辑模块.
|
||||
|
||||
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
|
||||
便于单元测试,也方便被其他模块复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── 单轨时间计算 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def calculate_effective_range(
|
||||
track_start: float,
|
||||
track_duration: float,
|
||||
audio_duration: float,
|
||||
target_duration: float,
|
||||
) -> tuple[float, float, float]:
|
||||
"""计算轨道的有效时间范围.
|
||||
|
||||
处理:
|
||||
- 轨道时长为 0 或负时用音频完整时长
|
||||
- 轨道开始在目标时长外时跳过
|
||||
- 轨道开始为负时截断开头
|
||||
|
||||
Args:
|
||||
track_start: 轨道开始时间(秒),可为负
|
||||
track_duration: 轨道持续时长(秒),<=0 表示用音频全长
|
||||
audio_duration: 音频文件实际时长(秒)
|
||||
target_duration: 目标总时长(秒)
|
||||
|
||||
Returns:
|
||||
(effective_start, need_duration, trim_start)
|
||||
- effective_start: 在目标时间轴上的开始位置(>=0)
|
||||
- need_duration: 需要截取的音频长度
|
||||
- trim_start: 从源音频的哪个位置开始截取
|
||||
"""
|
||||
if audio_duration <= 0:
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
# 有效时长(轨道声明的时长,未被截断的)
|
||||
if track_duration > 0:
|
||||
effective_dur = min(track_duration, audio_duration)
|
||||
else:
|
||||
effective_dur = audio_duration
|
||||
|
||||
effective_start = track_start
|
||||
trim_start = 0.0
|
||||
|
||||
# 负的开始时间:从源音频中间开始取,轨道前段被截掉
|
||||
if effective_start < 0:
|
||||
trim_start = -effective_start
|
||||
# 可用时长 = 总时长 - 被截掉的前段
|
||||
effective_dur = max(0.0, effective_dur - trim_start)
|
||||
effective_start = 0.0
|
||||
|
||||
# 轨道完全在目标时长之外
|
||||
if effective_start >= target_duration:
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
# 轨道完全在 0 之前
|
||||
if effective_start + effective_dur <= 0:
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
# 实际需要的源时长
|
||||
need_dur = min(effective_dur, target_duration - effective_start)
|
||||
if need_dur <= 0:
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
# 调整 trim_start 不能超过音频长度
|
||||
if trim_start >= audio_duration:
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
return (effective_start, need_dur, trim_start)
|
||||
|
||||
|
||||
def is_track_visible(
|
||||
track_start: float,
|
||||
track_duration: float,
|
||||
audio_duration: float,
|
||||
target_duration: float,
|
||||
) -> bool:
|
||||
"""判断轨道是否在目标时长范围内可见(有声音).
|
||||
|
||||
Args:
|
||||
track_start: 轨道开始时间
|
||||
track_duration: 轨道持续时长
|
||||
audio_duration: 音频时长
|
||||
target_duration: 目标总时长
|
||||
|
||||
Returns:
|
||||
是否可见
|
||||
"""
|
||||
_, need_dur, _ = calculate_effective_range(track_start, track_duration, audio_duration, target_duration)
|
||||
return need_dur > 0
|
||||
|
||||
|
||||
# ── 单轨滤镜链构建 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_track_filter_chain(
|
||||
volume: float,
|
||||
fade_in: float,
|
||||
fade_out: float,
|
||||
effective_start: float,
|
||||
need_duration: float,
|
||||
trim_start: float,
|
||||
target_duration: float,
|
||||
) -> str:
|
||||
"""构建单轨道预处理滤镜链.
|
||||
|
||||
处理顺序:截断 → 重置时间戳 → 音量 → 淡入 → 淡出 → 延迟 → 最终截断 → 重置时间戳
|
||||
|
||||
Args:
|
||||
volume: 音量 0.0~1.0
|
||||
fade_in: 淡入时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
effective_start: 在目标轴上的开始时间
|
||||
need_duration: 需要截取的时长
|
||||
trim_start: 从源音频的哪个位置开始
|
||||
target_duration: 目标总时长
|
||||
|
||||
Returns:
|
||||
逗号分隔的滤镜字符串
|
||||
"""
|
||||
filter_parts: list[str] = []
|
||||
|
||||
# 1. 截断到有效范围
|
||||
filter_parts.append(f"atrim={trim_start:.3f}:{trim_start + need_duration:.3f}")
|
||||
filter_parts.append("asetpts=N/SR/TB")
|
||||
|
||||
# 2. 音量调节
|
||||
safe_volume = max(0.0, min(2.0, volume))
|
||||
if abs(safe_volume - 1.0) > 0.001:
|
||||
filter_parts.append(f"volume={safe_volume:.3f}")
|
||||
|
||||
# 3. 淡入(必须小于总时长才有效)
|
||||
if fade_in > 0 and fade_in < need_duration:
|
||||
filter_parts.append(f"afade=t=in:st=0:d={fade_in:.3f}")
|
||||
|
||||
# 4. 淡出
|
||||
if fade_out > 0 and fade_out < need_duration:
|
||||
fade_start = need_duration - fade_out
|
||||
if fade_start > 0:
|
||||
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={fade_out:.3f}")
|
||||
|
||||
# 5. 时间偏移(开头静音填充)
|
||||
if effective_start > 0.01:
|
||||
delay_ms = int(effective_start * 1000)
|
||||
filter_parts.append(f"adelay={delay_ms}|{delay_ms}")
|
||||
|
||||
# 6. 最终截断到目标总时长
|
||||
filter_parts.append(f"atrim=0:{target_duration:.3f}")
|
||||
filter_parts.append("asetpts=N/SR/TB")
|
||||
|
||||
return ",".join(filter_parts)
|
||||
|
||||
|
||||
# ── amix 混音滤镜构建 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_amix_filter(num_inputs: int, duration_mode: str = "longest") -> str:
|
||||
"""构建 amix 混音滤镜.
|
||||
|
||||
Args:
|
||||
num_inputs: 输入轨道数量
|
||||
duration_mode: 时长模式:longest / shortest / first
|
||||
|
||||
Returns:
|
||||
amix 滤镜字符串
|
||||
"""
|
||||
if num_inputs <= 0:
|
||||
return ""
|
||||
|
||||
# 校验 duration_mode
|
||||
if duration_mode not in ("longest", "shortest", "first"):
|
||||
duration_mode = "longest"
|
||||
|
||||
return f"amix=inputs={num_inputs}:duration={duration_mode}:dropout_transition=0"
|
||||
|
||||
|
||||
def calculate_amix_volume_compensation(num_inputs: int) -> float:
|
||||
"""计算 amix 后的音量补偿系数.
|
||||
|
||||
amix 会将 N 路输入每路乘以 1/N 来归一化,
|
||||
所以需要乘以 N 来补偿(简单粗暴但有效)。
|
||||
|
||||
Args:
|
||||
num_inputs: 输入轨道数量
|
||||
|
||||
Returns:
|
||||
补偿系数
|
||||
"""
|
||||
if num_inputs <= 1:
|
||||
return 1.0
|
||||
return float(num_inputs)
|
||||
|
||||
|
||||
def build_mix_filter_complex(
|
||||
num_tracks: int,
|
||||
has_main: bool = True,
|
||||
duration_mode: str = "longest",
|
||||
) -> str:
|
||||
"""构建完整的混音 filter_complex.
|
||||
|
||||
Args:
|
||||
num_tracks: 额外轨道数量
|
||||
has_main: 是否有主音频
|
||||
duration_mode: 时长模式
|
||||
|
||||
Returns:
|
||||
filter_complex 字符串
|
||||
"""
|
||||
total_inputs = num_tracks + (1 if has_main else 0)
|
||||
if total_inputs <= 0:
|
||||
return ""
|
||||
|
||||
# 输入标签
|
||||
input_labels = "".join(f"[{i}:a]" for i in range(total_inputs))
|
||||
|
||||
# amix
|
||||
amix = build_amix_filter(total_inputs, duration_mode)
|
||||
|
||||
# 音量补偿
|
||||
compensation = calculate_amix_volume_compensation(total_inputs)
|
||||
volume_filter = ""
|
||||
if abs(compensation - 1.0) > 0.001:
|
||||
volume_filter = f",volume={compensation}"
|
||||
|
||||
return f"{input_labels}{amix}{volume_filter}[mixed]"
|
||||
|
||||
|
||||
# ── 音量计算 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def normalize_volume(volume: float) -> float:
|
||||
"""规范化音量值.
|
||||
|
||||
Args:
|
||||
volume: 原始音量
|
||||
|
||||
Returns:
|
||||
规范化后的音量(0.0 ~ 2.0)
|
||||
"""
|
||||
if volume is None:
|
||||
return 1.0
|
||||
try:
|
||||
v = float(volume)
|
||||
return max(0.0, min(2.0, v))
|
||||
except (ValueError, TypeError):
|
||||
return 1.0
|
||||
|
||||
|
||||
def db_to_linear(db: float) -> float:
|
||||
"""dB 转换为线性音量.
|
||||
|
||||
Args:
|
||||
db: 分贝值
|
||||
|
||||
Returns:
|
||||
线性音量值
|
||||
"""
|
||||
import math
|
||||
|
||||
return 10 ** (db / 20.0)
|
||||
|
||||
|
||||
def linear_to_db(linear: float) -> float:
|
||||
"""线性音量转换为 dB.
|
||||
|
||||
Args:
|
||||
linear: 线性音量值
|
||||
|
||||
Returns:
|
||||
分贝值
|
||||
"""
|
||||
import math
|
||||
|
||||
if linear <= 0:
|
||||
return -float("inf")
|
||||
return 20.0 * math.log10(linear)
|
||||
|
||||
|
||||
# ── 轨道排序与过滤 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def sort_tracks_by_priority(
|
||||
tracks: list[dict],
|
||||
) -> list[dict]:
|
||||
"""按优先级排序轨道.
|
||||
|
||||
priority 数字越小优先级越高(越先播放/越底层)。
|
||||
相同优先级保持原顺序。
|
||||
|
||||
Args:
|
||||
tracks: 轨道配置列表
|
||||
|
||||
Returns:
|
||||
排序后的轨道列表
|
||||
"""
|
||||
return sorted(tracks, key=lambda t: int(t.get("priority", 100)))
|
||||
|
||||
|
||||
def filter_enabled_tracks(tracks: list[dict]) -> list[dict]:
|
||||
"""过滤出启用的轨道.
|
||||
|
||||
Args:
|
||||
tracks: 轨道列表
|
||||
|
||||
Returns:
|
||||
启用的轨道列表
|
||||
"""
|
||||
result = []
|
||||
for t in tracks:
|
||||
enabled = t.get("enabled", True)
|
||||
if bool(enabled) and enabled != "false" and enabled != 0:
|
||||
result.append(t)
|
||||
return result
|
||||
|
||||
|
||||
def count_track_types(tracks: list[dict]) -> dict[str, int]:
|
||||
"""统计各类型轨道数量.
|
||||
|
||||
Args:
|
||||
tracks: 轨道列表
|
||||
|
||||
Returns:
|
||||
类型计数字典
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
for t in tracks:
|
||||
ttype = t.get("track_type", "unknown")
|
||||
counts[ttype] = counts.get(ttype, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
# ── 配置验证 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_audio_track(track: dict) -> tuple[bool, list[str]]:
|
||||
"""验证单条音轨配置.
|
||||
|
||||
Args:
|
||||
track: 轨道配置字典
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# 音频路径
|
||||
audio_path = track.get("audio_path", "")
|
||||
if not audio_path and not track.get("asset_id"):
|
||||
errors.append("轨道需要 audio_path 或 asset_id")
|
||||
|
||||
# 音量范围
|
||||
volume = track.get("volume", 1.0)
|
||||
try:
|
||||
v = float(volume)
|
||||
if v < 0:
|
||||
errors.append("volume 不能为负数")
|
||||
if v > 2.0:
|
||||
errors.append("volume 建议不超过 2.0")
|
||||
except (ValueError, TypeError):
|
||||
errors.append("volume 必须是数字")
|
||||
|
||||
# 淡入淡出
|
||||
fade_in = track.get("fade_in", 0)
|
||||
fade_out = track.get("fade_out", 0)
|
||||
try:
|
||||
if float(fade_in) < 0:
|
||||
errors.append("fade_in 不能为负数")
|
||||
except (ValueError, TypeError):
|
||||
errors.append("fade_in 必须是数字")
|
||||
|
||||
try:
|
||||
if float(fade_out) < 0:
|
||||
errors.append("fade_out 不能为负数")
|
||||
except (ValueError, TypeError):
|
||||
errors.append("fade_out 必须是数字")
|
||||
|
||||
# 开始时间
|
||||
start_time = track.get("start_time", 0)
|
||||
try:
|
||||
float(start_time) # 验证是否为数字
|
||||
except (ValueError, TypeError):
|
||||
errors.append("start_time 必须是数字")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
def validate_mix_config(config: dict) -> tuple[bool, list[str]]:
|
||||
"""验证混音配置.
|
||||
|
||||
Args:
|
||||
config: 混音配置
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
tracks = config.get("tracks", [])
|
||||
if not tracks:
|
||||
errors.append("至少需要一条轨道")
|
||||
|
||||
# 验证每条轨道
|
||||
for i, track in enumerate(tracks):
|
||||
ok, track_errors = validate_audio_track(track)
|
||||
if not ok:
|
||||
for err in track_errors:
|
||||
errors.append(f"第{i+1}轨:{err}")
|
||||
|
||||
# 目标时长
|
||||
target_duration = config.get("target_duration", 0)
|
||||
try:
|
||||
if float(target_duration) < 0:
|
||||
errors.append("target_duration 不能为负数")
|
||||
except (ValueError, TypeError):
|
||||
errors.append("target_duration 必须是数字")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def calculate_total_tracks(config: dict) -> int:
|
||||
"""计算总轨道数(含主音频).
|
||||
|
||||
Args:
|
||||
config: 混音配置
|
||||
|
||||
Returns:
|
||||
总轨道数
|
||||
"""
|
||||
tracks = config.get("tracks", [])
|
||||
has_main = config.get("has_main_audio", True)
|
||||
count = len(tracks)
|
||||
if has_main:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def estimate_mix_duration(tracks: list[dict]) -> float:
|
||||
"""估算混音总时长(所有轨道的最晚结束时间).
|
||||
|
||||
Args:
|
||||
tracks: 轨道列表,包含 start_time 和 duration
|
||||
|
||||
Returns:
|
||||
估算总时长(秒)
|
||||
"""
|
||||
max_end = 0.0
|
||||
for t in tracks:
|
||||
try:
|
||||
start = float(t.get("start_time", 0))
|
||||
dur = float(t.get("duration", 0))
|
||||
if dur > 0:
|
||||
end = start + dur
|
||||
if end > max_end:
|
||||
max_end = end
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return max_end
|
||||
+486
@@ -0,0 +1,486 @@
|
||||
"""画中画(PiP)引擎纯逻辑模块.
|
||||
|
||||
从 pip_engine.py 抽离的纯函数,0 FFmpeg 依赖,可完全单测。
|
||||
原模块 pip_engine.py 保持不变,向后兼容。
|
||||
|
||||
抽离范围:
|
||||
- 滤镜链构建(scale / 圆角 / 边框 / 透明度 / 动画 / overlay)
|
||||
- 位置与尺寸计算辅助(封装 domain 层调用)
|
||||
- 完整 PiP 滤镜链编排
|
||||
- 配置验证与降级策略判断
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.pip_config import (
|
||||
ANIMATION_FADE,
|
||||
ANIMATION_SLIDE_BOTTOM,
|
||||
ANIMATION_SLIDE_LEFT,
|
||||
ANIMATION_SLIDE_RIGHT,
|
||||
ANIMATION_SLIDE_TOP,
|
||||
PiPLayerConfig,
|
||||
calculate_pip_position,
|
||||
parse_size_value,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 尺寸与位置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def compute_pip_size(
|
||||
layer: PiPLayerConfig,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
) -> tuple[int, int]:
|
||||
"""计算画中画图层的实际像素尺寸.
|
||||
|
||||
Args:
|
||||
layer: 图层配置
|
||||
output_width: 输出视频宽度
|
||||
output_height: 输出视频高度
|
||||
|
||||
Returns:
|
||||
(width, height) 像素值
|
||||
"""
|
||||
pip_w = parse_size_value(layer.width, output_width)
|
||||
if layer.height:
|
||||
pip_h = parse_size_value(layer.height, output_height)
|
||||
else:
|
||||
# 按宽度等比例(默认 16:9)
|
||||
pip_h = int(pip_w * 9 / 16)
|
||||
|
||||
# 钳制到输出尺寸内
|
||||
pip_w = max(1, min(pip_w, output_width))
|
||||
pip_h = max(1, min(pip_h, output_height))
|
||||
return pip_w, pip_h
|
||||
|
||||
|
||||
def compute_pip_position(
|
||||
layer: PiPLayerConfig,
|
||||
pip_width: int,
|
||||
pip_height: int,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
) -> tuple[int, int]:
|
||||
"""计算画中画的实际位置 (x, y).
|
||||
|
||||
封装 domain 层的 calculate_pip_position,
|
||||
提供默认值并做边界钳制。
|
||||
"""
|
||||
x, y = calculate_pip_position(
|
||||
position=layer.position,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
pip_width=pip_width,
|
||||
pip_height=pip_height,
|
||||
margin=layer.margin,
|
||||
custom_x=layer.x,
|
||||
custom_y=layer.y,
|
||||
)
|
||||
|
||||
# 边界钳制:确保不超出画面
|
||||
x = max(0, min(x, output_width - pip_width))
|
||||
y = max(0, min(y, output_height - pip_height))
|
||||
return x, y
|
||||
|
||||
|
||||
# ── 预处理滤镜 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_pip_pre_filter(
|
||||
input_label: str,
|
||||
layer: PiPLayerConfig,
|
||||
pip_width: int,
|
||||
pip_height: int,
|
||||
output_label: str,
|
||||
) -> str:
|
||||
"""构建单个 PiP 图层的预处理滤镜链.
|
||||
|
||||
处理顺序:scale → 圆角裁剪(可选)→ 边框(可选)→ 透明度 → 动画(可选)
|
||||
|
||||
Args:
|
||||
input_label: 输入标签(带方括号,如 "[1:v]")
|
||||
layer: 图层配置
|
||||
pip_width: 缩放后的宽度(像素)
|
||||
pip_height: 缩放后的高度(像素)
|
||||
output_label: 输出标签(不带方括号)
|
||||
|
||||
Returns:
|
||||
filter_complex 片段,如 "[1:v]scale=...,setsar=1[pip_pre_0]"
|
||||
"""
|
||||
filters: list[str] = []
|
||||
|
||||
# Step 1: scale + SAR
|
||||
filters.append(f"scale={pip_width}:{pip_height}")
|
||||
filters.append("setsar=1")
|
||||
|
||||
# Step 2: 圆角裁剪
|
||||
if layer.corner_radius > 0:
|
||||
r = min(layer.corner_radius, pip_width // 2, pip_height // 2)
|
||||
# 用 geq + 圆形遮罩实现四角圆角
|
||||
filters.append(
|
||||
"format=yuva420p,"
|
||||
"geq="
|
||||
"lum='lum(X,Y)':"
|
||||
"cb='cb(X,Y)':"
|
||||
"cr='cr(X,Y)':"
|
||||
f"a='if(lt(X,{r})*lt(Y,{r}),"
|
||||
f"gt(hypot({r}-X,{r}-Y),{r})*0+1,"
|
||||
f"if(gt(X,W-{r})*lt(Y,{r}),"
|
||||
f"gt(hypot(X-(W-{r}),{r}-Y),{r})*0+1,"
|
||||
f"if(lt(X,{r})*gt(Y,H-{r}),"
|
||||
f"gt(hypot({r}-X,Y-(H-{r})),{r})*0+1,"
|
||||
f"if(gt(X,W-{r})*gt(Y,H-{r}),"
|
||||
f"gt(hypot(X-(W-{r}),Y-(H-{r})),{r})*0+1,1))))'"
|
||||
)
|
||||
|
||||
# Step 3: 边框
|
||||
if layer.border_width > 0:
|
||||
bw = layer.border_width
|
||||
color = layer.border_color
|
||||
filters.append(f"pad={pip_width + 2 * bw}:{pip_height + 2 * bw}:{bw}:{bw}:{color}")
|
||||
|
||||
# Step 4: 透明度
|
||||
if layer.opacity < 1.0:
|
||||
alpha = max(0.0, min(1.0, layer.opacity))
|
||||
filters.append(f"format=yuva420p,colorchannelmixer=aa={alpha}")
|
||||
|
||||
# Step 5: 入场出场动画(fade 类直接在预处理中加)
|
||||
anim_filters = build_animation_filters(layer, pip_width, pip_height)
|
||||
if anim_filters:
|
||||
filters.extend(anim_filters)
|
||||
|
||||
return f"{input_label}{','.join(filters)}[{output_label}]"
|
||||
|
||||
|
||||
def build_animation_filters(
|
||||
layer: PiPLayerConfig,
|
||||
pip_width: int,
|
||||
pip_height: int,
|
||||
) -> list[str]:
|
||||
"""构建 fade 类入场出场动画滤镜.
|
||||
|
||||
注意:slide 类动画由 overlay 表达式处理,不在此函数内。
|
||||
|
||||
Returns:
|
||||
滤镜字符串列表(每项是一个完整 filter,可直接用逗号连接)
|
||||
"""
|
||||
filters: list[str] = []
|
||||
anim_dur = max(0.0, layer.animation_duration)
|
||||
|
||||
# 入场动画
|
||||
if layer.animation_in == ANIMATION_FADE and anim_dur > 0:
|
||||
filters.append(f"fade=t=in:st=0:d={anim_dur}:alpha=1")
|
||||
|
||||
# 出场动画(需要总时长)
|
||||
if layer.animation_out == ANIMATION_FADE and anim_dur > 0 and layer.duration is not None and layer.duration > 0:
|
||||
start_fade = max(0.0, layer.duration - anim_dur)
|
||||
filters.append(f"fade=t=out:st={start_fade}:d={anim_dur}:alpha=1")
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
# ── Overlay 表达式 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_overlay_expr(
|
||||
layer: PiPLayerConfig,
|
||||
base_x: int,
|
||||
base_y: int,
|
||||
pip_width: int,
|
||||
pip_height: int,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
) -> tuple[str, str]:
|
||||
"""构建 overlay 滤镜的 x/y 表达式(支持滑动动画).
|
||||
|
||||
Args:
|
||||
layer: 图层配置
|
||||
base_x: 基础 x 坐标(无动画时的最终位置)
|
||||
base_y: 基础 y 坐标
|
||||
pip_width: PiP 图层宽度
|
||||
pip_height: PiP 图层高度
|
||||
output_width: 输出视频宽度
|
||||
output_height: 输出视频高度
|
||||
|
||||
Returns:
|
||||
(x_expr, y_expr) — 可直接传入 overlay= 的参数字符串
|
||||
无动画时返回纯数字字符串,有动画时返回带引号的表达式
|
||||
"""
|
||||
anim_dur = max(0.0, layer.animation_duration)
|
||||
|
||||
x_expr = str(base_x)
|
||||
y_expr = str(base_y)
|
||||
|
||||
# ── 入场滑入动画 ──
|
||||
if anim_dur > 0:
|
||||
if layer.animation_in == ANIMATION_SLIDE_LEFT:
|
||||
# 从左侧滑入:x 从 -pip_width 变化到 base_x
|
||||
x_expr = (
|
||||
f"'{base_x}+if(lt(t,{anim_dur})," f"{-pip_width}+t/{anim_dur}*({base_x + pip_width})," f"{base_x})'"
|
||||
)
|
||||
elif layer.animation_in == ANIMATION_SLIDE_RIGHT:
|
||||
# 从右侧滑入:x 从 output_width 变化到 base_x
|
||||
x_expr = (
|
||||
f"'{base_x}+if(lt(t,{anim_dur}),"
|
||||
f"{output_width}-t/{anim_dur}*({output_width - base_x}),"
|
||||
f"{base_x})'"
|
||||
)
|
||||
elif layer.animation_in == ANIMATION_SLIDE_TOP:
|
||||
# 从顶部滑入
|
||||
y_expr = (
|
||||
f"'{base_y}+if(lt(t,{anim_dur})," f"{-pip_height}+t/{anim_dur}*({base_y + pip_height})," f"{base_y})'"
|
||||
)
|
||||
elif layer.animation_in == ANIMATION_SLIDE_BOTTOM:
|
||||
# 从底部滑入
|
||||
y_expr = (
|
||||
f"'{base_y}+if(lt(t,{anim_dur}),"
|
||||
f"{output_height}-t/{anim_dur}*({output_height - base_y}),"
|
||||
f"{base_y})'"
|
||||
)
|
||||
|
||||
# ── 出场滑出动画(需要总时长) ──
|
||||
if layer.duration is not None and layer.duration > 0 and anim_dur > 0:
|
||||
out_start = layer.duration - anim_dur
|
||||
if out_start < 0:
|
||||
out_start = 0
|
||||
|
||||
if layer.animation_out == ANIMATION_SLIDE_LEFT:
|
||||
# 向左滑出
|
||||
x_expr = (
|
||||
f"'{base_x}+if(gt(t,{out_start}),"
|
||||
f"{base_x}-(t-{out_start})/{anim_dur}*({base_x + pip_width}),"
|
||||
f"{base_x})'"
|
||||
)
|
||||
elif layer.animation_out == ANIMATION_SLIDE_RIGHT:
|
||||
# 向右滑出
|
||||
x_expr = (
|
||||
f"'{base_x}+if(gt(t,{out_start}),"
|
||||
f"{base_x}+(t-{out_start})/{anim_dur}*({output_width - base_x + pip_width}),"
|
||||
f"{base_x})'"
|
||||
)
|
||||
elif layer.animation_out == ANIMATION_SLIDE_TOP:
|
||||
# 向上滑出
|
||||
y_expr = (
|
||||
f"'{base_y}+if(gt(t,{out_start}),"
|
||||
f"{base_y}-(t-{out_start})/{anim_dur}*({base_y + pip_height}),"
|
||||
f"{base_y})'"
|
||||
)
|
||||
elif layer.animation_out == ANIMATION_SLIDE_BOTTOM:
|
||||
# 向下滑出
|
||||
y_expr = (
|
||||
f"'{base_y}+if(gt(t,{out_start}),"
|
||||
f"{base_y}+(t-{out_start})/{anim_dur}*({output_height - base_y + pip_height}),"
|
||||
f"{base_y})'"
|
||||
)
|
||||
|
||||
return x_expr, y_expr
|
||||
|
||||
|
||||
def build_enable_expr(
|
||||
layer: PiPLayerConfig,
|
||||
) -> str:
|
||||
"""构建 overlay 的 enable 时间控制表达式.
|
||||
|
||||
Returns:
|
||||
enable 表达式片段,如 ":enable='between(t,1,5)'"
|
||||
无时间限制时返回空字符串
|
||||
"""
|
||||
start = max(0.0, layer.start_time)
|
||||
duration = layer.duration
|
||||
|
||||
if start <= 0 and (duration is None or duration <= 0):
|
||||
return ""
|
||||
|
||||
if duration and duration > 0:
|
||||
end = start + duration
|
||||
return f":enable='between(t,{start},{end})'"
|
||||
else:
|
||||
return f":enable='gte(t,{start})'"
|
||||
|
||||
|
||||
# ── 完整滤镜链 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_pip_filters(
|
||||
base_label: str,
|
||||
layers: list[PiPLayerConfig],
|
||||
source_paths: list[Path | str],
|
||||
*,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
base_input_idx: int = 0,
|
||||
) -> tuple[list[str], list[str], str]:
|
||||
"""构建完整的画中画滤镜链和输入参数(纯函数版).
|
||||
|
||||
与 PiPEngine.build_pip_filters 对应,但不依赖类实例,
|
||||
所有参数显式传入,方便测试。
|
||||
|
||||
Args:
|
||||
base_label: 底层视频标签(不带方括号)
|
||||
layers: 图层配置列表
|
||||
source_paths: 对应每个图层的源文件路径列表
|
||||
output_width: 输出视频宽度
|
||||
output_height: 输出视频高度
|
||||
base_input_idx: PiP 素材的起始输入索引
|
||||
|
||||
Returns:
|
||||
(filter_parts, input_args, final_label)
|
||||
- filter_parts: 滤镜片段列表(用 ; 连接成 filter_complex)
|
||||
- input_args: 输入参数列表 ["-i", path, "-i", path, ...]
|
||||
- final_label: 最终输出标签(不带方括号)
|
||||
|
||||
Raises:
|
||||
ValueError: layers 和 source_paths 长度不一致
|
||||
"""
|
||||
if len(layers) != len(source_paths):
|
||||
raise ValueError(f"layers ({len(layers)}) 和 source_paths ({len(source_paths)}) 长度不一致")
|
||||
|
||||
if not layers:
|
||||
return [], [], base_label
|
||||
|
||||
filter_parts: list[str] = []
|
||||
input_args: list[str] = []
|
||||
current_label = base_label
|
||||
|
||||
for i, (layer, path) in enumerate(zip(layers, source_paths)):
|
||||
# 计算实际大小
|
||||
pip_w, pip_h = compute_pip_size(layer, output_width, output_height)
|
||||
|
||||
# 添加输入
|
||||
input_args.extend(["-i", str(path)])
|
||||
|
||||
# 实际输入索引
|
||||
actual_input_idx = base_input_idx + i
|
||||
|
||||
# 预处理标签
|
||||
pre_label = f"pip_pre_{i}"
|
||||
|
||||
# 构建预处理滤镜
|
||||
pre_filter = build_pip_pre_filter(
|
||||
input_label=f"[{actual_input_idx}:v]",
|
||||
layer=layer,
|
||||
pip_width=pip_w,
|
||||
pip_height=pip_h,
|
||||
output_label=pre_label,
|
||||
)
|
||||
filter_parts.append(pre_filter)
|
||||
|
||||
# 计算位置
|
||||
base_x, base_y = compute_pip_position(layer, pip_w, pip_h, output_width, output_height)
|
||||
|
||||
# 构建 overlay 表达式
|
||||
x_expr, y_expr = build_overlay_expr(layer, base_x, base_y, pip_w, pip_h, output_width, output_height)
|
||||
|
||||
# 时间控制
|
||||
enable_expr = build_enable_expr(layer)
|
||||
|
||||
# 合成标签
|
||||
combined_label = f"pip_combined_{i}"
|
||||
|
||||
# overlay 滤镜
|
||||
overlay_filter = (
|
||||
f"[{current_label}][{pre_label}]" f"overlay={x_expr}:{y_expr}{enable_expr}" f"[{combined_label}]"
|
||||
)
|
||||
filter_parts.append(overlay_filter)
|
||||
|
||||
current_label = combined_label
|
||||
|
||||
return filter_parts, input_args, current_label
|
||||
|
||||
|
||||
# ── 配置验证 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_pip_layer(layer: PiPLayerConfig) -> tuple[bool, str]:
|
||||
"""验证单个 PiP 图层配置是否合法.
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message) — 合法时 error_message 为空
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# 源类型检查
|
||||
if not layer.source_type:
|
||||
errors.append("source_type 不能为空")
|
||||
elif layer.source_type not in ("local_path", "asset_id", "url"):
|
||||
errors.append(f"不支持的 source_type: {layer.source_type}")
|
||||
|
||||
if not layer.source:
|
||||
errors.append("source 不能为空")
|
||||
|
||||
# 尺寸检查
|
||||
if layer.width is None or layer.width == "":
|
||||
errors.append("width 不能为空")
|
||||
|
||||
# 位置检查
|
||||
valid_positions = {
|
||||
"top_left",
|
||||
"top_center",
|
||||
"top_right",
|
||||
"center_left",
|
||||
"center",
|
||||
"center_right",
|
||||
"bottom_left",
|
||||
"bottom_center",
|
||||
"bottom_right",
|
||||
"custom",
|
||||
}
|
||||
if layer.position not in valid_positions:
|
||||
errors.append(f"不支持的 position: {layer.position}")
|
||||
|
||||
# 数值范围检查
|
||||
if layer.opacity < 0.0 or layer.opacity > 1.0:
|
||||
errors.append(f"opacity 必须在 0-1 之间: {layer.opacity}")
|
||||
|
||||
if layer.corner_radius < 0:
|
||||
errors.append(f"corner_radius 不能为负: {layer.corner_radius}")
|
||||
|
||||
if layer.border_width < 0:
|
||||
errors.append(f"border_width 不能为负: {layer.border_width}")
|
||||
|
||||
if layer.animation_duration < 0:
|
||||
errors.append(f"animation_duration 不能为负: {layer.animation_duration}")
|
||||
|
||||
if layer.start_time < 0:
|
||||
errors.append(f"start_time 不能为负: {layer.start_time}")
|
||||
|
||||
if layer.duration is not None and layer.duration < 0:
|
||||
errors.append(f"duration 不能为负: {layer.duration}")
|
||||
|
||||
# 动画类型检查
|
||||
valid_anims = {
|
||||
"",
|
||||
None,
|
||||
ANIMATION_FADE,
|
||||
ANIMATION_SLIDE_LEFT,
|
||||
ANIMATION_SLIDE_RIGHT,
|
||||
ANIMATION_SLIDE_TOP,
|
||||
ANIMATION_SLIDE_BOTTOM,
|
||||
}
|
||||
if layer.animation_in and layer.animation_in not in valid_anims:
|
||||
errors.append(f"不支持的 animation_in: {layer.animation_in}")
|
||||
if layer.animation_out and layer.animation_out not in valid_anims:
|
||||
errors.append(f"不支持的 animation_out: {layer.animation_out}")
|
||||
|
||||
return (len(errors) == 0, "; ".join(errors))
|
||||
|
||||
|
||||
def count_visible_layers(layers: list[PiPLayerConfig]) -> int:
|
||||
"""统计可见图层数量(排除完全透明的)."""
|
||||
count = 0
|
||||
for layer in layers:
|
||||
if layer.opacity > 0:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def sort_layers_by_z_index(layers: list[PiPLayerConfig]) -> list[PiPLayerConfig]:
|
||||
"""按 z_index 从小到大排序图层(z_index 小的先画,在底层)."""
|
||||
return sorted(layers, key=lambda l: l.z_index)
|
||||
+534
@@ -0,0 +1,534 @@
|
||||
"""贴纸引擎纯逻辑模块.
|
||||
|
||||
所有函数均为纯函数,不调用 FFmpeg、不操作文件。
|
||||
便于单元测试,也方便被其他模块复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── 安全类型转换 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def safe_float(val: Any) -> Optional[float]:
|
||||
"""安全转换为 float.
|
||||
|
||||
Args:
|
||||
val: 任意值
|
||||
|
||||
Returns:
|
||||
float 值,失败返回 None
|
||||
"""
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
result = float(val)
|
||||
if result != result: # NaN check
|
||||
return None
|
||||
return result
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def safe_int(val: Any, default: int = 0) -> int:
|
||||
"""安全转换为 int.
|
||||
|
||||
Args:
|
||||
val: 任意值
|
||||
default: 默认值
|
||||
|
||||
Returns:
|
||||
int 值,失败返回默认值
|
||||
"""
|
||||
if val is None:
|
||||
return default
|
||||
try:
|
||||
result = int(float(val))
|
||||
return result
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def safe_bool(val: Any) -> bool:
|
||||
"""安全转换为 bool.
|
||||
|
||||
Args:
|
||||
val: 任意值
|
||||
|
||||
Returns:
|
||||
bool 值
|
||||
"""
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
if val is None:
|
||||
return False
|
||||
if isinstance(val, str):
|
||||
return val.lower() in ("true", "1", "yes", "on")
|
||||
return bool(val)
|
||||
|
||||
|
||||
# ── 尺寸估算 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def estimate_sticker_size(
|
||||
canvas_w: int,
|
||||
canvas_h: int,
|
||||
scale: float = 1.0,
|
||||
fixed_width: Optional[int] = None,
|
||||
fixed_height: Optional[int] = None,
|
||||
) -> tuple[int, int]:
|
||||
"""估算贴纸尺寸.
|
||||
|
||||
如果指定了固定宽高,直接使用;否则按画布的 30% * scale 估算。
|
||||
|
||||
Args:
|
||||
canvas_w: 画布宽度
|
||||
canvas_h: 画布高度
|
||||
scale: 缩放比例
|
||||
fixed_width: 固定宽度(可选)
|
||||
fixed_height: 固定高度(可选)
|
||||
|
||||
Returns:
|
||||
(估算宽度, 估算高度)
|
||||
"""
|
||||
if fixed_width and fixed_height:
|
||||
return (fixed_width, fixed_height)
|
||||
|
||||
base_ratio = 0.3
|
||||
est_w = int(canvas_w * base_ratio * scale) if not fixed_width else fixed_width
|
||||
est_h = int(canvas_h * base_ratio * scale) if not fixed_height else fixed_height
|
||||
|
||||
return (max(1, est_w), max(1, est_h))
|
||||
|
||||
|
||||
def estimate_text_size(
|
||||
text: str,
|
||||
font_size: int,
|
||||
) -> tuple[int, int]:
|
||||
"""估算文字贴纸尺寸.
|
||||
|
||||
粗略估算:宽度 = 字数 * 字号 * 0.6,高度 = 字号 * 1.4
|
||||
|
||||
Args:
|
||||
text: 文字内容
|
||||
font_size: 字号
|
||||
|
||||
Returns:
|
||||
(估算宽度, 估算高度)
|
||||
"""
|
||||
if not text:
|
||||
return (0, 0)
|
||||
est_w = int(len(text) * font_size * 0.6)
|
||||
est_h = int(font_size * 1.4)
|
||||
return (max(1, est_w), max(1, est_h))
|
||||
|
||||
|
||||
# ── 时间计算 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def calculate_fade_out_start(
|
||||
start_time: float,
|
||||
duration: float,
|
||||
fade_out: float,
|
||||
) -> float:
|
||||
"""计算淡出开始时间.
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
|
||||
Returns:
|
||||
淡出开始时间(秒),最小为 0
|
||||
"""
|
||||
if fade_out <= 0 or duration <= 0:
|
||||
return 0.0
|
||||
fade_start = start_time + duration - fade_out
|
||||
return max(0.0, fade_start)
|
||||
|
||||
|
||||
def calculate_end_time(start_time: float, duration: float) -> float:
|
||||
"""计算结束时间.
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
|
||||
Returns:
|
||||
结束时间(秒)
|
||||
"""
|
||||
if duration <= 0:
|
||||
return start_time
|
||||
return start_time + duration
|
||||
|
||||
|
||||
def has_time_range(duration: float) -> bool:
|
||||
"""是否有时间范围限制.
|
||||
|
||||
Args:
|
||||
duration: 持续时长(秒)
|
||||
|
||||
Returns:
|
||||
duration > 0 时返回 True
|
||||
"""
|
||||
return duration > 0
|
||||
|
||||
|
||||
# ── 滤镜组件构建 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_scale_filter(
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
scale: float = 1.0,
|
||||
) -> Optional[str]:
|
||||
"""构建缩放滤镜.
|
||||
|
||||
优先使用固定宽高,否则按比例缩放。
|
||||
scale=1.0 且无固定尺寸时返回 None。
|
||||
|
||||
Args:
|
||||
width: 固定宽度(可选)
|
||||
height: 固定高度(可选)
|
||||
scale: 缩放比例
|
||||
|
||||
Returns:
|
||||
scale 滤镜字符串,不需要缩放时返回 None
|
||||
"""
|
||||
if width and height:
|
||||
return f"scale={width}:{height}"
|
||||
if scale != 1.0:
|
||||
return f"scale=iw*{scale}:ih*{scale}"
|
||||
return None
|
||||
|
||||
|
||||
def build_opacity_filter(opacity: float) -> Optional[str]:
|
||||
"""构建透明度滤镜.
|
||||
|
||||
Args:
|
||||
opacity: 不透明度 0.0~1.0
|
||||
|
||||
Returns:
|
||||
colorchannelmixer 滤镜字符串,完全不透明时返回 None
|
||||
"""
|
||||
if opacity >= 1.0:
|
||||
return None
|
||||
safe_opacity = max(0.0, min(1.0, opacity))
|
||||
return f"colorchannelmixer=aa={safe_opacity}"
|
||||
|
||||
|
||||
def build_image_fade_filters(
|
||||
start_time: float,
|
||||
duration: float,
|
||||
fade_in: float = 0.0,
|
||||
fade_out: float = 0.0,
|
||||
) -> list[str]:
|
||||
"""构建图片贴纸淡入淡出滤镜列表.
|
||||
|
||||
使用 FFmpeg fade 滤镜(alpha 通道)。
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
fade_in: 淡入时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
|
||||
Returns:
|
||||
fade 滤镜字符串列表
|
||||
"""
|
||||
filters: list[str] = []
|
||||
|
||||
if fade_in > 0:
|
||||
filters.append(f"fade=in:st={start_time}:d={fade_in}:alpha=1")
|
||||
|
||||
if fade_out > 0 and duration > 0:
|
||||
fade_out_start = calculate_fade_out_start(start_time, duration, fade_out)
|
||||
filters.append(f"fade=out:st={fade_out_start}:d={fade_out}:alpha=1")
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def build_enable_expr(
|
||||
start_time: float,
|
||||
duration: float,
|
||||
) -> str:
|
||||
"""构建 enable 表达式(时间范围).
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
|
||||
Returns:
|
||||
enable 表达式字符串(包含开头的冒号),无时间限制时返回空字符串
|
||||
"""
|
||||
if duration <= 0:
|
||||
return ""
|
||||
end_time = calculate_end_time(start_time, duration)
|
||||
return f":enable='between(t,{start_time},{end_time})'"
|
||||
|
||||
|
||||
# ── drawtext 文字贴纸相关 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def escape_drawtext_text(text: str) -> str:
|
||||
"""转义 drawtext 中的特殊字符.
|
||||
|
||||
转义冒号和单引号。
|
||||
|
||||
Args:
|
||||
text: 原始文字
|
||||
|
||||
Returns:
|
||||
转义后的文字
|
||||
"""
|
||||
result = text.replace(":", "\\:")
|
||||
result = result.replace("'", "\\'")
|
||||
return result
|
||||
|
||||
|
||||
def build_drawtext_alpha_expr(
|
||||
start_time: float,
|
||||
duration: float,
|
||||
fade_in: float = 0.0,
|
||||
fade_out: float = 0.0,
|
||||
) -> str:
|
||||
"""构建 drawtext 的 alpha 淡入淡出表达式.
|
||||
|
||||
drawtext 没有直接的 fade 滤镜,用 alpha 表达式模拟。
|
||||
|
||||
Args:
|
||||
start_time: 开始时间(秒)
|
||||
duration: 持续时长(秒)
|
||||
fade_in: 淡入时长(秒)
|
||||
fade_out: 淡出时长(秒)
|
||||
|
||||
Returns:
|
||||
alpha 表达式字符串,无淡入淡出时返回 "1"
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
if fade_in > 0:
|
||||
fade_in_end = start_time + fade_in
|
||||
parts.append(f"if(lt(t,{fade_in_end}),(t-{start_time})/{fade_in},1)")
|
||||
|
||||
if fade_out > 0 and duration > 0:
|
||||
fade_out_start = calculate_fade_out_start(start_time, duration, fade_out)
|
||||
end_time = calculate_end_time(start_time, duration)
|
||||
parts.append(f"if(gt(t,{fade_out_start}),({end_time}-t)/{fade_out},1)")
|
||||
|
||||
if not parts:
|
||||
return "1"
|
||||
|
||||
return "*".join(parts)
|
||||
|
||||
|
||||
def build_stroke_params(
|
||||
stroke_width: int = 0,
|
||||
stroke_color: str = "black",
|
||||
) -> list[str]:
|
||||
"""构建 drawtext 描边参数.
|
||||
|
||||
Args:
|
||||
stroke_width: 描边宽度(0 表示无描边)
|
||||
stroke_color: 描边颜色
|
||||
|
||||
Returns:
|
||||
描边参数列表
|
||||
"""
|
||||
if stroke_width <= 0:
|
||||
return []
|
||||
return [
|
||||
f"borderw={stroke_width}",
|
||||
f"bordercolor={stroke_color}",
|
||||
]
|
||||
|
||||
|
||||
def build_shadow_params(
|
||||
shadow_alpha: float = 0.0,
|
||||
shadow_x: int = 2,
|
||||
shadow_y: int = 2,
|
||||
shadow_color: str = "black",
|
||||
) -> list[str]:
|
||||
"""构建 drawtext 阴影参数.
|
||||
|
||||
Args:
|
||||
shadow_alpha: 阴影透明度(0 表示无阴影)
|
||||
shadow_x: 阴影 X 偏移
|
||||
shadow_y: 阴影 Y 偏移
|
||||
shadow_color: 阴影颜色
|
||||
|
||||
Returns:
|
||||
阴影参数列表
|
||||
"""
|
||||
if shadow_alpha <= 0:
|
||||
return []
|
||||
safe_alpha = max(0.0, min(1.0, shadow_alpha))
|
||||
return [
|
||||
f"shadowx={shadow_x}",
|
||||
f"shadowy={shadow_y}",
|
||||
f"shadowcolor={shadow_color}@{safe_alpha}",
|
||||
]
|
||||
|
||||
|
||||
# ── 贴纸排序与过滤 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def sort_stickers_by_z_index(
|
||||
stickers: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""按 z_index 排序贴纸.
|
||||
|
||||
z_index 小的在底层,大的在上层。
|
||||
相同 z_index 保持原顺序(稳定排序)。
|
||||
|
||||
Args:
|
||||
stickers: 贴纸配置列表
|
||||
|
||||
Returns:
|
||||
排序后的贴纸列表
|
||||
"""
|
||||
return sorted(stickers, key=lambda s: safe_int(s.get("z_index"), 10))
|
||||
|
||||
|
||||
def filter_enabled_stickers(
|
||||
stickers: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""过滤出启用的贴纸.
|
||||
|
||||
Args:
|
||||
stickers: 贴纸配置列表
|
||||
|
||||
Returns:
|
||||
启用的贴纸列表
|
||||
"""
|
||||
result = []
|
||||
for s in stickers:
|
||||
enabled = s.get("enabled", True)
|
||||
if safe_bool(enabled):
|
||||
result.append(s)
|
||||
return result
|
||||
|
||||
|
||||
def count_sticker_types(
|
||||
stickers: list[dict[str, Any]],
|
||||
) -> dict[str, int]:
|
||||
"""统计各类型贴纸数量.
|
||||
|
||||
Args:
|
||||
stickers: 贴纸配置列表
|
||||
|
||||
Returns:
|
||||
类型计数字典
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
for s in stickers:
|
||||
stype = s.get("type", "image")
|
||||
counts[stype] = counts.get(stype, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
# ── overlay 滤镜构建 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_overlay_position(
|
||||
pos_x: float,
|
||||
pos_y: float,
|
||||
) -> str:
|
||||
"""构建 overlay 位置参数.
|
||||
|
||||
Args:
|
||||
pos_x: X 坐标
|
||||
pos_y: Y 坐标
|
||||
|
||||
Returns:
|
||||
overlay 位置字符串 "x:y"
|
||||
"""
|
||||
return f"{pos_x:.0f}:{pos_y:.0f}"
|
||||
|
||||
|
||||
def build_pre_filter_label(idx: int) -> str:
|
||||
"""构建贴纸预处理后的标签名.
|
||||
|
||||
Args:
|
||||
idx: 贴纸索引
|
||||
|
||||
Returns:
|
||||
滤镜标签字符串
|
||||
"""
|
||||
return f"sticker_{idx}_scaled"
|
||||
|
||||
|
||||
# ── 验证函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_image_sticker(sticker: dict[str, Any]) -> tuple[bool, list[str]]:
|
||||
"""验证图片贴纸配置.
|
||||
|
||||
Args:
|
||||
sticker: 贴纸配置字典
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# 图片路径
|
||||
image_path = sticker.get("image_path", "")
|
||||
if not image_path and not sticker.get("asset_id"):
|
||||
errors.append("图片贴纸需要 image_path 或 asset_id")
|
||||
|
||||
# 透明度范围
|
||||
opacity = safe_float(sticker.get("opacity", 1.0))
|
||||
if opacity is not None and (opacity < 0 or opacity > 1):
|
||||
errors.append("opacity 必须在 0~1 之间")
|
||||
|
||||
# 缩放比例
|
||||
scale = safe_float(sticker.get("scale", 1.0))
|
||||
if scale is not None and scale <= 0:
|
||||
errors.append("scale 必须大于 0")
|
||||
|
||||
# 时间参数
|
||||
duration = safe_float(sticker.get("duration", 0))
|
||||
if duration is not None and duration < 0:
|
||||
errors.append("duration 不能为负数")
|
||||
|
||||
start_time = safe_float(sticker.get("start_time", 0))
|
||||
if start_time is not None and start_time < 0:
|
||||
errors.append("start_time 不能为负数")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
def validate_text_sticker(sticker: dict[str, Any]) -> tuple[bool, list[str]]:
|
||||
"""验证文字贴纸配置.
|
||||
|
||||
Args:
|
||||
sticker: 贴纸配置字典
|
||||
|
||||
Returns:
|
||||
(是否合法, 错误信息列表)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# 文字内容
|
||||
text = sticker.get("text", "")
|
||||
if not text:
|
||||
errors.append("文字贴纸需要 text 内容")
|
||||
|
||||
# 字号
|
||||
font_size = safe_int(sticker.get("font_size", 36))
|
||||
if font_size <= 0:
|
||||
errors.append("font_size 必须大于 0")
|
||||
|
||||
# 颜色
|
||||
font_color = sticker.get("font_color", "white")
|
||||
if not font_color:
|
||||
errors.append("font_color 不能为空")
|
||||
|
||||
# 时间参数
|
||||
duration = safe_float(sticker.get("duration", 0))
|
||||
if duration is not None and duration < 0:
|
||||
errors.append("duration 不能为负数")
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CI中自动修复代码格式(Python: black + isort | Frontend: prettier),并推送回原分支。
|
||||
|
||||
- PR事件:自动修复并push回PR源分支(Agent提交的PR自动修,人提交的仅诊断)
|
||||
- PR事件:所有PR只要Code Quality因格式问题失败,自动修复并push回源分支
|
||||
- Push事件(develop/main):自动修复并push回原分支,保持主干格式永远正确
|
||||
- 防循环:修复commit带 [skip ci-format-check] 标记,检测到该标记则跳过修复
|
||||
- 只修格式(black/isort/prettier),ruff逻辑类错误不动
|
||||
当code quality检查因格式问题失败时触发。
|
||||
"""
|
||||
|
||||
@@ -239,7 +241,7 @@ def main():
|
||||
print("无法获取PR号,跳过自动修复")
|
||||
return
|
||||
|
||||
# 获取PR作者信息,判断是人还是Agent提交的
|
||||
# 获取PR信息
|
||||
pr_info_url = f"{api_url}/repos/{repo}/pulls/{pr_number}"
|
||||
req_pr = urllib.request.Request(pr_info_url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req_pr) as resp:
|
||||
@@ -247,17 +249,26 @@ def main():
|
||||
pr_author = pr_info.get("user", {}).get("login", "")
|
||||
print(f"PR作者: {pr_author}")
|
||||
|
||||
# 判断是否为Agent提交的PR
|
||||
agent_authors = {"actions", "auto-approve-bot", "gitea-actions"}
|
||||
is_agent_pr = pr_author in agent_authors or "bot" in pr_author.lower()
|
||||
# 防循环检测:检查最新commit是否已经是格式修复commit
|
||||
# 修复commit message 带 [skip ci-format-check] 标记,检测到则跳过
|
||||
head_branch_tmp = pr_info.get("head", {}).get("ref", "")
|
||||
skip_marker = "[skip ci-format-check]"
|
||||
try:
|
||||
commits_url = f"{api_url}/repos/{repo}/pulls/{pr_number}/commits?limit=3"
|
||||
req_commits = urllib.request.Request(commits_url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req_commits) as resp_commits:
|
||||
commits = json.loads(resp_commits.read())
|
||||
latest_msg = commits[0].get("commit", {}).get("message", "") if commits else ""
|
||||
if skip_marker in latest_msg:
|
||||
print(f"检测到最新commit包含 {skip_marker} 标记,跳过格式修复(防循环)")
|
||||
print("本次格式检查失败是格式修复commit触发的CI回跑,属正常现象")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"⚠️ 防循环检测失败,继续执行: {e}")
|
||||
|
||||
if is_agent_pr:
|
||||
print(f"检测到Agent提交的PR(作者: {pr_author}),将自动修复并推送")
|
||||
fix_mode = "auto_fix_and_push"
|
||||
else:
|
||||
print(f"检测到人提交的PR(作者: {pr_author}),仅诊断不自动修改")
|
||||
print("(如需自动修复,请用Agent账号提交PR,或手动运行格式化脚本)")
|
||||
fix_mode = "diagnose_only"
|
||||
# 所有PR都自动修复格式(不再区分人/Agent)
|
||||
print("检测到格式问题,将自动修复并推送回分支")
|
||||
fix_mode = "auto_fix_and_push"
|
||||
|
||||
print("=== 检测到代码格式问题,尝试自动修复 ===")
|
||||
print(f"PR #{pr_number}")
|
||||
@@ -315,26 +326,6 @@ def main():
|
||||
print("没有需要提交的格式改动")
|
||||
return
|
||||
|
||||
# 诊断模式:只报告问题,不修改不推送
|
||||
if fix_mode == "diagnose_only":
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("📋 格式问题诊断报告(人提交的PR,仅诊断不自动修复)")
|
||||
print("=" * 50)
|
||||
print()
|
||||
print("以下文件存在格式问题,建议手动修复:")
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
print(f" {line}")
|
||||
print()
|
||||
print("修复方式:")
|
||||
print(" 后端(Python): 运行 black + isort")
|
||||
print(" 前端: 运行 prettier --write")
|
||||
print(" 或使用 scripts/agent-commit.sh 提交(自动格式化)")
|
||||
print()
|
||||
print("=" * 50)
|
||||
# 以非0状态码退出,让CI继续报失败(因为问题没修)
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("变更文件:")
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
@@ -342,7 +333,7 @@ def main():
|
||||
|
||||
# 提交修复
|
||||
run("git add -A")
|
||||
run('git commit -m "style: auto-format with black + isort + prettier"')
|
||||
run('git commit -m "style: auto-format with black + isort + prettier [skip ci-format-check]"')
|
||||
|
||||
# 推送(head_branch已从ensure_git_repo获取)
|
||||
print(f"\nPR来源分支: {head_branch}")
|
||||
|
||||
@@ -715,7 +715,7 @@ def main():
|
||||
|
||||
if not review_result:
|
||||
logger.error("LLM 审查失败")
|
||||
sys.exit(1)
|
||||
sys.exit(0) # fail-open: LLM调用失败不阻塞合并
|
||||
|
||||
# 7. 加上审查时间和标识(便于识别是自动审查)
|
||||
from datetime import datetime
|
||||
@@ -773,7 +773,7 @@ def main():
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"审查脚本发生未预期的异常: {e}")
|
||||
sys.exit(1)
|
||||
sys.exit(0) # fail-open: 异常不阻塞正常开发
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+714
@@ -0,0 +1,714 @@
|
||||
"""video_filter_builder 单测.
|
||||
|
||||
domain 层纯逻辑模块,0 FFmpeg 依赖,快速轻量。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.video_filter_builder import (
|
||||
DEFAULT_CLIP_DURATION,
|
||||
DEFAULT_FPS,
|
||||
DEFAULT_OUTPUT_HEIGHT,
|
||||
DEFAULT_OUTPUT_WIDTH,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
XFADE_TRANSITION_MAP,
|
||||
ClipFilterChain,
|
||||
build_clip_filter,
|
||||
build_concat_filter,
|
||||
build_filter_complex,
|
||||
build_xfade_filter,
|
||||
chain_filters,
|
||||
has_audio,
|
||||
)
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_chain(
|
||||
clip_id: str = "c1",
|
||||
input_index: int = 0,
|
||||
duration: float = 3.0,
|
||||
has_audio: bool = True,
|
||||
filters: list[str] | None = None,
|
||||
) -> ClipFilterChain:
|
||||
"""快速创建 ClipFilterChain."""
|
||||
if filters is None:
|
||||
filters = ["scale=1280:720", "fps=25", "trim=0:3"]
|
||||
return ClipFilterChain(
|
||||
clip_id=clip_id,
|
||||
input_index=input_index,
|
||||
video_label=f"v{input_index}",
|
||||
audio_label=f"a{input_index}" if has_audio else None,
|
||||
filters=filters,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
|
||||
def _mock_clip(
|
||||
clip_id: str = "c1",
|
||||
duration: float = 5.0,
|
||||
start_time: float = 0.0,
|
||||
clip_type: str = "video",
|
||||
) -> MagicMock:
|
||||
"""创建 mock 的 EditPlanClip."""
|
||||
clip = MagicMock()
|
||||
clip.id = clip_id
|
||||
clip.duration = duration
|
||||
clip.start_time = start_time
|
||||
clip.clip_type = clip_type
|
||||
return clip
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 常量测试
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""常量默认值测试."""
|
||||
|
||||
def test_default_resolution(self):
|
||||
"""默认分辨率为 1280x720."""
|
||||
assert DEFAULT_OUTPUT_WIDTH == 1280
|
||||
assert DEFAULT_OUTPUT_HEIGHT == 720
|
||||
|
||||
def test_default_fps(self):
|
||||
"""默认帧率 25."""
|
||||
assert DEFAULT_FPS == 25
|
||||
|
||||
def test_default_transition_duration(self):
|
||||
"""默认转场时长 0.5s."""
|
||||
assert DEFAULT_TRANSITION_DURATION == 0.5
|
||||
|
||||
def test_default_clip_duration(self):
|
||||
"""默认片段时长 5s."""
|
||||
assert DEFAULT_CLIP_DURATION == 5.0
|
||||
|
||||
def test_xfade_map_contains_common_transitions(self):
|
||||
"""xfade 转场映射包含常见类型."""
|
||||
assert "fade" in XFADE_TRANSITION_MAP.values()
|
||||
assert "slideleft" in XFADE_TRANSITION_MAP.values()
|
||||
assert "slideright" in XFADE_TRANSITION_MAP.values()
|
||||
assert "dissolve" in XFADE_TRANSITION_MAP.values()
|
||||
assert "wipeleft" in XFADE_TRANSITION_MAP.values()
|
||||
assert len(XFADE_TRANSITION_MAP) >= 5
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# ClipFilterChain 数据类
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestClipFilterChain:
|
||||
"""数据类结构测试."""
|
||||
|
||||
def test_creation(self):
|
||||
"""创建 ClipFilterChain."""
|
||||
chain = ClipFilterChain(
|
||||
clip_id="c1",
|
||||
input_index=0,
|
||||
video_label="v0",
|
||||
audio_label="a0",
|
||||
filters=["scale=1280:720"],
|
||||
duration=5.0,
|
||||
)
|
||||
assert chain.clip_id == "c1"
|
||||
assert chain.input_index == 0
|
||||
assert chain.video_label == "v0"
|
||||
assert chain.audio_label == "a0"
|
||||
assert chain.filters == ["scale=1280:720"]
|
||||
assert chain.duration == 5.0
|
||||
|
||||
def test_no_audio(self):
|
||||
"""无音频流."""
|
||||
chain = _make_chain(has_audio=False)
|
||||
assert chain.audio_label is None
|
||||
|
||||
def test_frozen(self):
|
||||
"""frozen dataclass 不可修改."""
|
||||
chain = _make_chain()
|
||||
with pytest.raises(Exception):
|
||||
chain.duration = 10.0 # type: ignore
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# chain_filters
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestChainFilters:
|
||||
"""滤镜串联测试."""
|
||||
|
||||
def test_single_filter(self):
|
||||
"""单个滤镜."""
|
||||
result = chain_filters(["scale=1280:720"], "v0")
|
||||
assert result == "[0:v]scale=1280:720[v0]"
|
||||
|
||||
def test_multiple_filters(self):
|
||||
"""多个滤镜用逗号连接."""
|
||||
result = chain_filters(["scale=1280:720", "fps=25", "trim=0:5"], "v0")
|
||||
assert "scale=1280:720,fps=25,trim=0:5" in result
|
||||
assert result.startswith("[0:v]")
|
||||
assert result.endswith("[v0]")
|
||||
|
||||
def test_empty_filters(self):
|
||||
"""空滤镜列表."""
|
||||
result = chain_filters([], "out")
|
||||
assert result == "[0:v][out]"
|
||||
|
||||
def test_custom_input_label(self):
|
||||
"""自定义输入标签."""
|
||||
result = chain_filters(["fps=30"], "v1", input_label="1:v")
|
||||
assert result.startswith("[1:v]")
|
||||
assert result.endswith("[v1]")
|
||||
|
||||
def test_custom_output_label(self):
|
||||
"""自定义输出标签."""
|
||||
result = chain_filters(["scale=640:480"], "my_label")
|
||||
assert result.endswith("[my_label]")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# has_audio
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestHasAudio:
|
||||
"""音频判断测试."""
|
||||
|
||||
def test_all_have_audio(self):
|
||||
"""全部有音频."""
|
||||
chains = [_make_chain(has_audio=True), _make_chain(has_audio=True)]
|
||||
assert has_audio(chains) is True
|
||||
|
||||
def test_none_have_audio(self):
|
||||
"""全部无音频."""
|
||||
chains = [_make_chain(has_audio=False), _make_chain(has_audio=False)]
|
||||
assert has_audio(chains) is False
|
||||
|
||||
def test_partial_audio(self):
|
||||
"""部分有音频."""
|
||||
chains = [_make_chain(has_audio=True), _make_chain(has_audio=False)]
|
||||
assert has_audio(chains) is True
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert has_audio([]) is False
|
||||
|
||||
def test_single_with_audio(self):
|
||||
"""单个有音频."""
|
||||
assert has_audio([_make_chain(has_audio=True)]) is True
|
||||
|
||||
def test_single_without_audio(self):
|
||||
"""单个无音频."""
|
||||
assert has_audio([_make_chain(has_audio=False)]) is False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_clip_filter
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildClipFilter:
|
||||
"""单片段滤镜链构建测试."""
|
||||
|
||||
def test_basic_video_clip(self):
|
||||
"""基础视频片段."""
|
||||
clip = _mock_clip(duration=5.0, start_time=0.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
assert chain.clip_id == "c1"
|
||||
assert chain.input_index == 0
|
||||
assert chain.video_label == "v0"
|
||||
assert chain.audio_label == "a0"
|
||||
assert chain.duration == 5.0
|
||||
assert len(chain.filters) >= 5
|
||||
|
||||
def test_contains_scale_filter(self):
|
||||
"""包含 scale 滤镜."""
|
||||
clip = _mock_clip()
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
assert any("scale=1280:720" in f for f in chain.filters)
|
||||
|
||||
def test_contains_pad_filter(self):
|
||||
"""包含 pad 滤镜(居中黑边)."""
|
||||
clip = _mock_clip()
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
assert any("pad=1280:720" in f for f in chain.filters)
|
||||
|
||||
def test_contains_format_filter(self):
|
||||
"""包含 format 滤镜(yuv420p)."""
|
||||
clip = _mock_clip()
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
assert any("format=yuv420p" in f for f in chain.filters)
|
||||
|
||||
def test_contains_fps_filter(self):
|
||||
"""包含 fps 滤镜."""
|
||||
clip = _mock_clip()
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 30)
|
||||
assert any("fps=30" in f for f in chain.filters)
|
||||
|
||||
def test_zero_fps_skipped(self):
|
||||
"""fps=0 时跳过 fps 滤镜."""
|
||||
clip = _mock_clip()
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 0)
|
||||
assert not any(f.startswith("fps=") for f in chain.filters)
|
||||
|
||||
def test_negative_fps_skipped(self):
|
||||
"""负 fps 跳过."""
|
||||
clip = _mock_clip()
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, -1)
|
||||
assert not any(f.startswith("fps=") for f in chain.filters)
|
||||
|
||||
def test_start_time_offset(self):
|
||||
"""有 start_time 时 setpts 带偏移."""
|
||||
clip = _mock_clip(start_time=2.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
assert any("PTS-STARTPTS+2.0/TB" in f for f in chain.filters)
|
||||
|
||||
def test_zero_start_time_no_offset(self):
|
||||
"""start_time=0 时无偏移."""
|
||||
clip = _mock_clip(start_time=0.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
assert any("setpts=PTS-STARTPTS" in f for f in chain.filters)
|
||||
# 不含 +N/TB 偏移
|
||||
setpts_filters = [f for f in chain.filters if f.startswith("setpts=")]
|
||||
# 第一个 setpts 是重置的(不含偏移),trim 后还有一个
|
||||
assert len(setpts_filters) >= 1
|
||||
|
||||
def test_contains_trim_filter(self):
|
||||
"""包含 trim 滤镜."""
|
||||
clip = _mock_clip(duration=5.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
assert any("trim=0:5.0" in f for f in chain.filters)
|
||||
|
||||
def test_negative_duration_uses_default(self):
|
||||
"""duration<=0 时使用默认时长."""
|
||||
clip = _mock_clip(duration=0.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
assert chain.duration == DEFAULT_CLIP_DURATION
|
||||
assert any(f"trim=0:{DEFAULT_CLIP_DURATION}" in f for f in chain.filters)
|
||||
|
||||
def test_title_clip_no_audio(self):
|
||||
"""title 类型片段无音频."""
|
||||
clip = _mock_clip(clip_type="title")
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
assert chain.audio_label is None
|
||||
|
||||
def test_subtitle_clip_no_audio(self):
|
||||
"""subtitle 类型片段无音频."""
|
||||
clip = _mock_clip(clip_type="subtitle")
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
assert chain.audio_label is None
|
||||
|
||||
def test_video_clip_has_audio(self):
|
||||
"""video 类型片段有音频."""
|
||||
clip = _mock_clip(clip_type="video")
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
assert chain.audio_label == "a0"
|
||||
|
||||
def test_image_clip_has_audio(self):
|
||||
"""image 类型默认有音频标签(实际无音流由调用方判断)."""
|
||||
clip = _mock_clip(clip_type="image")
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
# 只有 title/subtitle 被排除
|
||||
assert chain.audio_label is not None
|
||||
|
||||
def test_input_index_matches_label(self):
|
||||
"""input_index 对应标签编号."""
|
||||
clip = _mock_clip()
|
||||
chain = build_clip_filter(clip, 3, 1280, 720, 25)
|
||||
assert chain.input_index == 3
|
||||
assert chain.video_label == "v3"
|
||||
assert chain.audio_label == "a3"
|
||||
|
||||
def test_filter_order(self):
|
||||
"""滤镜顺序:scale → pad → format → fps → setpts → trim."""
|
||||
clip = _mock_clip(duration=5.0, start_time=1.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
filter_names = [f.split("=")[0] for f in chain.filters]
|
||||
# scale 在 pad 前
|
||||
assert filter_names.index("scale") < filter_names.index("pad")
|
||||
# pad 在 format 前
|
||||
assert filter_names.index("pad") < filter_names.index("format")
|
||||
# format 在 fps 前
|
||||
assert filter_names.index("format") < filter_names.index("fps")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_concat_filter
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildConcatFilter:
|
||||
"""concat 滤镜构建测试."""
|
||||
|
||||
def test_empty_returns_empty(self):
|
||||
"""空列表返回空."""
|
||||
result, duration = build_concat_filter([])
|
||||
assert result == ""
|
||||
assert duration == 0.0
|
||||
|
||||
def test_single_clip(self):
|
||||
"""单个片段."""
|
||||
chain = _make_chain(clip_id="c1", input_index=0, duration=3.0)
|
||||
result, total = build_concat_filter([chain])
|
||||
assert "[0:v]" in result
|
||||
assert "concat=n=1:v=1:a=0" in result
|
||||
assert "[outv]" in result
|
||||
assert total == 3.0
|
||||
|
||||
def test_two_clips(self):
|
||||
"""两个片段 concat."""
|
||||
chains = [
|
||||
_make_chain(clip_id="c1", input_index=0, duration=3.0),
|
||||
_make_chain(clip_id="c2", input_index=1, duration=2.0),
|
||||
]
|
||||
result, total = build_concat_filter(chains)
|
||||
assert "[0:v]" in result
|
||||
assert "[1:v]" in result
|
||||
assert "concat=n=2:v=1:a=0[outv]" in result
|
||||
assert total == 5.0
|
||||
|
||||
def test_three_clips(self):
|
||||
"""三个片段."""
|
||||
chains = [
|
||||
_make_chain(clip_id="c1", input_index=0, duration=2.0),
|
||||
_make_chain(clip_id="c2", input_index=1, duration=3.0),
|
||||
_make_chain(clip_id="c3", input_index=2, duration=1.0),
|
||||
]
|
||||
result, total = build_concat_filter(chains)
|
||||
assert "concat=n=3:v=1:a=0[outv]" in result
|
||||
assert total == 6.0
|
||||
|
||||
def test_total_duration_sum(self):
|
||||
"""总时长 = 各片段时长之和."""
|
||||
chains = [
|
||||
_make_chain(duration=1.5),
|
||||
_make_chain(duration=2.5),
|
||||
_make_chain(duration=3.0),
|
||||
]
|
||||
_, total = build_concat_filter(chains)
|
||||
assert abs(total - 7.0) < 0.001
|
||||
|
||||
def test_audio_concat_with_audio(self):
|
||||
"""有音频时包含音频 concat."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, has_audio=True),
|
||||
_make_chain(input_index=1, has_audio=True),
|
||||
]
|
||||
result, _ = build_concat_filter(chains)
|
||||
assert "[outa]" in result
|
||||
assert "concat=n=2:v=0:a=1[outa]" in result
|
||||
|
||||
def test_audio_normalization(self):
|
||||
"""音频经过 aformat 归一化."""
|
||||
chains = [_make_chain(input_index=0, has_audio=True)]
|
||||
result, _ = build_concat_filter(chains)
|
||||
assert "aformat=sample_rates=48000" in result
|
||||
assert "channel_layouts=stereo" in result
|
||||
assert "sample_fmts=fltp" in result
|
||||
|
||||
def test_no_audio_concat(self):
|
||||
"""无音频时不生成音频 concat."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, has_audio=False),
|
||||
_make_chain(input_index=1, has_audio=False),
|
||||
]
|
||||
result, _ = build_concat_filter(chains)
|
||||
assert "[outa]" not in result
|
||||
assert "aformat" not in result
|
||||
|
||||
def test_partial_audio_only_includes_audio_chains(self):
|
||||
"""部分有音频时,只对有音频的片段做 concat."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, has_audio=True),
|
||||
_make_chain(input_index=1, has_audio=False),
|
||||
_make_chain(input_index=2, has_audio=True),
|
||||
]
|
||||
result, _ = build_concat_filter(chains)
|
||||
# 音频 concat 只有 2 个输入
|
||||
assert "concat=n=2:v=0:a=1[outa]" in result
|
||||
|
||||
def test_video_labels_correct(self):
|
||||
"""视频标签正确."""
|
||||
chains = [
|
||||
_make_chain(clip_id="a", input_index=0, duration=1.0),
|
||||
_make_chain(clip_id="b", input_index=1, duration=1.0),
|
||||
]
|
||||
result, _ = build_concat_filter(chains)
|
||||
assert "[v0]" in result
|
||||
assert "[v1]" in result
|
||||
|
||||
def test_filter_chain_applied_per_clip(self):
|
||||
"""每个片段都有独立的滤镜链."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, filters=["scale=1280:720", "fps=25"]),
|
||||
_make_chain(input_index=1, filters=["scale=1280:720", "fps=25"]),
|
||||
]
|
||||
result, _ = build_concat_filter(chains)
|
||||
# 两个片段都有滤镜处理
|
||||
assert result.count("scale=1280:720") == 2
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_xfade_filter
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildXfadeFilter:
|
||||
"""xfade 转场滤镜构建测试."""
|
||||
|
||||
def test_empty_returns_empty(self):
|
||||
"""空列表返回空."""
|
||||
result, duration = build_xfade_filter([], 0.5, [])
|
||||
assert result == ""
|
||||
assert duration == 0.0
|
||||
|
||||
def test_single_clip_copy(self):
|
||||
"""单个片段用 copy 直接输出."""
|
||||
chain = _make_chain(clip_id="c1", input_index=0, duration=3.0)
|
||||
result, total = build_xfade_filter([chain], 0.5, [])
|
||||
assert "copy[outv]" in result
|
||||
assert total == 3.0
|
||||
|
||||
def test_two_clips_fade_transition(self):
|
||||
"""两个片段 + fade 转场."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=3.0),
|
||||
_make_chain(input_index=1, duration=2.0),
|
||||
]
|
||||
result, total = build_xfade_filter(chains, 0.5, ["cut", "fade"])
|
||||
assert "xfade=transition=fade" in result
|
||||
assert "duration=0.5" in result
|
||||
assert "[outv]" in result
|
||||
# 总时长 = 3 + 2 - 0.5 = 4.5
|
||||
assert abs(total - 4.5) < 0.001
|
||||
|
||||
def test_three_clips_with_transitions(self):
|
||||
"""三个片段 + 多个转场."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=3.0),
|
||||
_make_chain(input_index=1, duration=2.0),
|
||||
_make_chain(input_index=2, duration=4.0),
|
||||
]
|
||||
result, total = build_xfade_filter(chains, 0.5, ["cut", "fade", "slideleft"])
|
||||
# 两个 xfade 转场
|
||||
assert result.count("xfade=") == 2
|
||||
assert "xf1" in result # 中间标签
|
||||
# 总时长 = 3+2+4 - 0.5*2 = 8.0
|
||||
assert abs(total - 8.0) < 0.001
|
||||
|
||||
def test_offset_calculation(self):
|
||||
"""转场 offset 计算正确."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=5.0),
|
||||
_make_chain(input_index=1, duration=3.0),
|
||||
]
|
||||
result, _ = build_xfade_filter(chains, 1.0, ["cut", "fade"])
|
||||
# offset = 5.0 - 1.0*1 = 4.0
|
||||
assert "offset=4.000" in result
|
||||
|
||||
def test_offset_never_negative(self):
|
||||
"""offset 不为负."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=0.3),
|
||||
_make_chain(input_index=1, duration=0.3),
|
||||
]
|
||||
result, _ = build_xfade_filter(chains, 1.0, ["cut", "fade"])
|
||||
# offset = max(0, 0.3 - 1.0) = 0
|
||||
assert "offset=0.000" in result
|
||||
|
||||
def test_transition_slide_left(self):
|
||||
"""slideleft 转场."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=2.0),
|
||||
_make_chain(input_index=1, duration=2.0),
|
||||
]
|
||||
result, _ = build_xfade_filter(chains, 0.5, ["cut", "slide_left"])
|
||||
assert "xfade=transition=slideleft" in result
|
||||
|
||||
def test_transition_slide_right(self):
|
||||
"""slideright 转场."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=2.0),
|
||||
_make_chain(input_index=1, duration=2.0),
|
||||
]
|
||||
result, _ = build_xfade_filter(chains, 0.5, ["cut", "slide_right"])
|
||||
assert "xfade=transition=slideright" in result
|
||||
|
||||
def test_transition_dissolve(self):
|
||||
"""dissolve 转场."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=2.0),
|
||||
_make_chain(input_index=1, duration=2.0),
|
||||
]
|
||||
result, _ = build_xfade_filter(chains, 0.5, ["cut", "dissolve"])
|
||||
assert "xfade=transition=dissolve" in result
|
||||
|
||||
def test_unknown_transition_defaults_to_fade(self):
|
||||
"""未知转场默认 fade."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=2.0),
|
||||
_make_chain(input_index=1, duration=2.0),
|
||||
]
|
||||
result, _ = build_xfade_filter(chains, 0.5, ["cut", "unknown_transition"])
|
||||
assert "xfade=transition=fade" in result
|
||||
|
||||
def test_total_duration_minus_overlap(self):
|
||||
"""总时长 = sum - transition_duration * (n-1)."""
|
||||
chains = [
|
||||
_make_chain(duration=10.0),
|
||||
_make_chain(duration=10.0),
|
||||
_make_chain(duration=10.0),
|
||||
]
|
||||
_, total = build_xfade_filter(chains, 1.0, ["cut", "fade", "wipe"])
|
||||
# 30 - 2 = 28
|
||||
assert abs(total - 28.0) < 0.001
|
||||
|
||||
def test_total_duration_never_negative(self):
|
||||
"""总时长不为负."""
|
||||
chains = [
|
||||
_make_chain(duration=0.1),
|
||||
_make_chain(duration=0.1),
|
||||
]
|
||||
_, total = build_xfade_filter(chains, 10.0, ["cut", "fade"])
|
||||
assert total >= 0.0
|
||||
|
||||
def test_audio_with_xfade_path(self):
|
||||
"""xfade 路径下音频也做 concat."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, has_audio=True, duration=2.0),
|
||||
_make_chain(input_index=1, has_audio=True, duration=3.0),
|
||||
]
|
||||
result, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
|
||||
assert "[outa]" in result
|
||||
assert "aformat=" in result
|
||||
|
||||
def test_single_xfade_no_audio_processing(self):
|
||||
"""单片段 xfade 路径不处理音频(与原实现一致)."""
|
||||
chain = _make_chain(input_index=0, has_audio=True, duration=3.0)
|
||||
result, _ = build_xfade_filter([chain], 0.5, [])
|
||||
# 单片段 xfade 只有视频 copy,不处理音频
|
||||
assert "copy[outv]" in result
|
||||
assert "[outa]" not in result
|
||||
assert "acopy" not in result
|
||||
|
||||
def test_no_audio_xfade(self):
|
||||
"""无音频时不生成 [outa]."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, has_audio=False, duration=2.0),
|
||||
_make_chain(input_index=1, has_audio=False, duration=2.0),
|
||||
]
|
||||
result, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
|
||||
assert "[outa]" not in result
|
||||
|
||||
def test_intermediate_labels(self):
|
||||
"""多片段时有中间 xf 标签."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=1.0),
|
||||
_make_chain(input_index=1, duration=1.0),
|
||||
_make_chain(input_index=2, duration=1.0),
|
||||
_make_chain(input_index=3, duration=1.0),
|
||||
]
|
||||
result, _ = build_xfade_filter(chains, 0.3, ["cut", "fade", "wipe", "dissolve"])
|
||||
assert "[xf1]" in result
|
||||
assert "[xf2]" in result
|
||||
assert "[outv]" in result
|
||||
|
||||
def test_transitions_shorter_than_clips(self):
|
||||
"""transitions 列表比片段短时,后续用默认值."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=2.0),
|
||||
_make_chain(input_index=1, duration=2.0),
|
||||
_make_chain(input_index=2, duration=2.0),
|
||||
]
|
||||
# 只给一个转场(索引1有效,索引2越界)
|
||||
result, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
|
||||
# 第2个转场(索引2)未知 → 默认 fade
|
||||
assert result.count("xfade=transition=fade") == 2
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_filter_complex
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildFilterComplex:
|
||||
"""完整 filter_complex 构建(策略选择)测试."""
|
||||
|
||||
def test_empty_returns_empty(self):
|
||||
"""空列表返回空."""
|
||||
result, duration = build_filter_complex([], 1280, 720, 0.5, [])
|
||||
assert result == ""
|
||||
assert duration == 0.0
|
||||
|
||||
def test_single_clip_chain_mode(self):
|
||||
"""单片段走单链模式."""
|
||||
chain = _make_chain(input_index=0, duration=3.0, has_audio=True)
|
||||
result, total = build_filter_complex([chain], 1280, 720, 0.5, [])
|
||||
assert "[0:v]" in result
|
||||
assert "[0:a]" in result
|
||||
assert total == 3.0
|
||||
|
||||
def test_single_clip_no_audio(self):
|
||||
"""单片段无音频."""
|
||||
chain = _make_chain(input_index=0, duration=3.0, has_audio=False)
|
||||
result, _ = build_filter_complex([chain], 1280, 720, 0.5, [])
|
||||
assert "[0:a]" not in result
|
||||
|
||||
def test_multiple_clips_all_cut_uses_concat(self):
|
||||
"""多片段 + 全 cut → 走 concat(高效模式)."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=2.0),
|
||||
_make_chain(input_index=1, duration=3.0),
|
||||
]
|
||||
result, total = build_filter_complex(chains, 1280, 720, 0.5, ["cut", "cut"])
|
||||
# concat 模式
|
||||
assert "concat=n=2:v=1:a=0" in result
|
||||
assert "xfade" not in result
|
||||
assert total == 5.0
|
||||
|
||||
def test_multiple_clips_with_transition_uses_xfade(self):
|
||||
"""多片段 + 有转场 → 走 xfade."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=2.0),
|
||||
_make_chain(input_index=1, duration=3.0),
|
||||
]
|
||||
result, total = build_filter_complex(chains, 1280, 720, 0.5, ["cut", "fade"])
|
||||
assert "xfade=" in result
|
||||
assert abs(total - 4.5) < 0.001
|
||||
|
||||
def test_transition_effect_enum_value(self):
|
||||
"""使用 TransitionEffect 枚举值也能正确判断."""
|
||||
from packages.domain.template_clip_config import TransitionEffect
|
||||
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=2.0),
|
||||
_make_chain(input_index=1, duration=2.0),
|
||||
]
|
||||
# 传 TransitionEffect.CUT(不是字符串 "cut")
|
||||
result, _ = build_filter_complex(
|
||||
chains,
|
||||
1280,
|
||||
720,
|
||||
0.5,
|
||||
[TransitionEffect.CUT, TransitionEffect.CUT],
|
||||
)
|
||||
# 都是 cut → 走 concat
|
||||
assert "concat=n=2:v=1:a=0" in result
|
||||
|
||||
def test_mixed_cut_and_transition(self):
|
||||
"""混合 cut 和转场 → 走 xfade."""
|
||||
chains = [
|
||||
_make_chain(input_index=0, duration=1.0),
|
||||
_make_chain(input_index=1, duration=1.0),
|
||||
_make_chain(input_index=2, duration=1.0),
|
||||
]
|
||||
result, total = build_filter_complex(chains, 1280, 720, 0.5, ["cut", "fade", "cut"])
|
||||
# 只要有一个非 cut 转场就走 xfade
|
||||
assert "xfade=" in result
|
||||
assert abs(total - 2.0) < 0.001
|
||||
Executable
+579
@@ -0,0 +1,579 @@
|
||||
"""xfade_builder 单测.
|
||||
|
||||
domain 层 XFade 转场滤镜构建纯逻辑模块,0 FFmpeg 依赖。
|
||||
覆盖:转场名称映射、滤镜链串联、xfade 滤镜链构建(含 duration 钳制)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.xfade_builder import (
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
SUPPORTED_TRANSITIONS,
|
||||
XFADE_TRANSITION_MAP,
|
||||
XFade_TRANSITION_NAMES,
|
||||
build_xfade_filter_chain,
|
||||
chain_filters,
|
||||
resolve_xfade_transition,
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 常量测试
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""常量测试."""
|
||||
|
||||
def test_default_duration(self):
|
||||
"""默认转场时长 0.5s."""
|
||||
assert DEFAULT_TRANSITION_DURATION == 0.5
|
||||
|
||||
def test_transition_map_not_empty(self):
|
||||
"""转场映射非空."""
|
||||
assert len(XFADE_TRANSITION_MAP) > 10
|
||||
|
||||
def test_supported_transitions(self):
|
||||
"""支持的转场数量与映射键一致."""
|
||||
assert len(SUPPORTED_TRANSITIONS) == len(XFADE_TRANSITION_MAP)
|
||||
|
||||
def test_output_names_subset(self):
|
||||
"""输出名称是映射值的集合."""
|
||||
assert XFade_TRANSITION_NAMES == set(XFADE_TRANSITION_MAP.values())
|
||||
|
||||
def test_fade_in_map(self):
|
||||
"""fade 是基础转场."""
|
||||
assert "fade" in XFADE_TRANSITION_MAP
|
||||
assert XFADE_TRANSITION_MAP["fade"] == "fade"
|
||||
|
||||
def test_dissolve_aliases(self):
|
||||
"""dissolve 有多个别名."""
|
||||
assert XFADE_TRANSITION_MAP["dissolve"] == "dissolve"
|
||||
assert XFADE_TRANSITION_MAP["crossfade"] == "dissolve"
|
||||
assert XFADE_TRANSITION_MAP["crossdissolve"] == "dissolve"
|
||||
|
||||
def test_slide_directions(self):
|
||||
"""4 方向滑动都有映射."""
|
||||
assert XFADE_TRANSITION_MAP["slideleft"] == "slideleft"
|
||||
assert XFADE_TRANSITION_MAP["slideright"] == "slideright"
|
||||
assert XFADE_TRANSITION_MAP["slideup"] == "slideup"
|
||||
assert XFADE_TRANSITION_MAP["slidedown"] == "slidedown"
|
||||
|
||||
def test_slide_underscore_aliases(self):
|
||||
"""下划线别名也支持."""
|
||||
assert XFADE_TRANSITION_MAP["slide_left"] == "slideleft"
|
||||
assert XFADE_TRANSITION_MAP["slide_right"] == "slideright"
|
||||
assert XFADE_TRANSITION_MAP["slide_up"] == "slideup"
|
||||
assert XFADE_TRANSITION_MAP["slide_down"] == "slidedown"
|
||||
|
||||
def test_slide_default_direction(self):
|
||||
"""slide 默认向左滑."""
|
||||
assert XFADE_TRANSITION_MAP["slide"] == "slideleft"
|
||||
|
||||
def test_wipe_directions(self):
|
||||
"""4 方向擦除."""
|
||||
assert XFADE_TRANSITION_MAP["wipeleft"] == "wipeleft"
|
||||
assert XFADE_TRANSITION_MAP["wiperight"] == "wiperight"
|
||||
assert XFADE_TRANSITION_MAP["wipeup"] == "wipeup"
|
||||
assert XFADE_TRANSITION_MAP["wipedown"] == "wipedown"
|
||||
|
||||
def test_wipe_default(self):
|
||||
"""wipe 默认向左擦."""
|
||||
assert XFADE_TRANSITION_MAP["wipe"] == "wipeleft"
|
||||
|
||||
def test_zoom(self):
|
||||
"""缩放转场."""
|
||||
assert XFADE_TRANSITION_MAP["zoom"] == "zoomin"
|
||||
assert XFADE_TRANSITION_MAP["zoomin"] == "zoomin"
|
||||
assert XFADE_TRANSITION_MAP["zoomout"] == "zoomout"
|
||||
|
||||
def test_circle_rect(self):
|
||||
"""圆形/矩形裁剪."""
|
||||
assert XFADE_TRANSITION_MAP["circle"] == "circlecrop"
|
||||
assert XFADE_TRANSITION_MAP["circlecrop"] == "circlecrop"
|
||||
assert XFADE_TRANSITION_MAP["rect"] == "rectcrop"
|
||||
assert XFADE_TRANSITION_MAP["rectcrop"] == "rectcrop"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# chain_filters
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestChainFilters:
|
||||
"""滤镜链串联测试."""
|
||||
|
||||
def test_single_filter(self):
|
||||
"""单个滤镜."""
|
||||
result = chain_filters(["scale=1280:720"], "v0")
|
||||
assert result == "[0:v]scale=1280:720[v0]"
|
||||
|
||||
def test_multiple_filters(self):
|
||||
"""多个滤镜用逗号连接."""
|
||||
result = chain_filters(["scale=1280:720", "fps=25", "format=yuv420p"], "out")
|
||||
assert "scale=1280:720,fps=25,format=yuv420p" in result
|
||||
|
||||
def test_empty_filters(self):
|
||||
"""空滤镜列表."""
|
||||
result = chain_filters([], "out")
|
||||
assert result == "[0:v][out]"
|
||||
|
||||
def test_custom_input_label(self):
|
||||
"""自定义输入标签."""
|
||||
result = chain_filters(["fps=30"], "v1", input_label="2:v")
|
||||
assert result.startswith("[2:v]")
|
||||
|
||||
def test_custom_output_label(self):
|
||||
"""自定义输出标签."""
|
||||
result = chain_filters(["scale=640:480"], "my_output")
|
||||
assert result.endswith("[my_output]")
|
||||
|
||||
def test_preserves_filter_order(self):
|
||||
"""保持滤镜顺序."""
|
||||
filters = ["a", "b", "c", "d"]
|
||||
result = chain_filters(filters, "out")
|
||||
idx_a = result.index("a")
|
||||
idx_b = result.index("b")
|
||||
idx_c = result.index("c")
|
||||
idx_d = result.index("d")
|
||||
assert idx_a < idx_b < idx_c < idx_d
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# resolve_xfade_transition
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestResolveXfadeTransition:
|
||||
"""转场名称解析测试."""
|
||||
|
||||
def test_fade(self):
|
||||
"""fade → fade."""
|
||||
assert resolve_xfade_transition("fade") == "fade"
|
||||
|
||||
def test_dissolve(self):
|
||||
"""dissolve → dissolve."""
|
||||
assert resolve_xfade_transition("dissolve") == "dissolve"
|
||||
|
||||
def test_slide_left_alias(self):
|
||||
"""slide_left 别名."""
|
||||
assert resolve_xfade_transition("slide_left") == "slideleft"
|
||||
|
||||
def test_slide_default(self):
|
||||
"""slide 默认向左."""
|
||||
assert resolve_xfade_transition("slide") == "slideleft"
|
||||
|
||||
def test_zoom_default(self):
|
||||
"""zoom 默认 zoomin."""
|
||||
assert resolve_xfade_transition("zoom") == "zoomin"
|
||||
|
||||
def test_wipe_default(self):
|
||||
"""wipe 默认向左擦."""
|
||||
assert resolve_xfade_transition("wipe") == "wipeleft"
|
||||
|
||||
def test_unknown_falls_back_to_fade(self):
|
||||
"""未知转场回退到 fade."""
|
||||
assert resolve_xfade_transition("unknown_effect") == "fade"
|
||||
|
||||
def test_empty_string_fades(self):
|
||||
"""空字符串回退到 fade."""
|
||||
assert resolve_xfade_transition("") == "fade"
|
||||
|
||||
def test_enum_value(self):
|
||||
"""支持枚举(有 .value 属性)."""
|
||||
mock_enum = MagicMock()
|
||||
mock_enum.value = "dissolve"
|
||||
assert resolve_xfade_transition(mock_enum) == "dissolve"
|
||||
|
||||
def test_enum_unknown_value_fades(self):
|
||||
"""枚举值未知时回退到 fade."""
|
||||
mock_enum = MagicMock()
|
||||
mock_enum.value = "not_a_real_effect"
|
||||
assert resolve_xfade_transition(mock_enum) == "fade"
|
||||
|
||||
def test_circle_alias(self):
|
||||
"""circle 别名."""
|
||||
assert resolve_xfade_transition("circle") == "circlecrop"
|
||||
|
||||
def test_rect_alias(self):
|
||||
"""rect 别名."""
|
||||
assert resolve_xfade_transition("rect") == "rectcrop"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_xfade_filter_chain — 基础结构
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildXfadeFilterChainBasic:
|
||||
"""xfade 滤镜链基础结构测试."""
|
||||
|
||||
def test_empty_clips(self):
|
||||
"""空片段返回空."""
|
||||
result, duration = build_xfade_filter_chain([], [], [])
|
||||
assert result == ""
|
||||
assert duration == 0.0
|
||||
|
||||
def test_single_clip_copy(self):
|
||||
"""单个片段用 copy."""
|
||||
result, duration = build_xfade_filter_chain([5.0], ["v0"], [])
|
||||
assert "[v0]copy[outv]" in result
|
||||
assert duration == 5.0
|
||||
|
||||
def test_two_clips_fade(self):
|
||||
"""两个片段 + fade 转场."""
|
||||
result, total = build_xfade_filter_chain([5.0, 3.0], ["v0", "v1"], ["cut", "fade"])
|
||||
assert "xfade=transition=fade" in result
|
||||
assert ":duration=0.500" in result
|
||||
assert "[outv]" in result
|
||||
# 总时长 = 5 + 3 - 0.5 = 7.5
|
||||
assert abs(total - 7.5) < 0.01
|
||||
|
||||
def test_three_clips(self):
|
||||
"""三个片段有 2 个 xfade."""
|
||||
result, total = build_xfade_filter_chain(
|
||||
[4.0, 3.0, 5.0],
|
||||
["v0", "v1", "v2"],
|
||||
["cut", "fade", "dissolve"],
|
||||
)
|
||||
assert result.count("xfade=") == 2
|
||||
assert "xf1" in result # 中间标签
|
||||
# 总时长 = 4 + 3 + 5 - 0.5*2 = 11.0
|
||||
assert abs(total - 11.0) < 0.01
|
||||
|
||||
def test_output_label_custom(self):
|
||||
"""自定义输出标签."""
|
||||
result, _ = build_xfade_filter_chain(
|
||||
[5.0, 3.0],
|
||||
["v0", "v1"],
|
||||
["cut", "fade"],
|
||||
output_label="final",
|
||||
)
|
||||
assert result.endswith("[final]")
|
||||
assert "[outv]" not in result
|
||||
|
||||
def test_custom_transition_duration(self):
|
||||
"""自定义转场时长."""
|
||||
result, total = build_xfade_filter_chain(
|
||||
[5.0, 3.0],
|
||||
["v0", "v1"],
|
||||
["cut", "fade"],
|
||||
transition_duration=1.0,
|
||||
)
|
||||
assert ":duration=1.000" in result
|
||||
assert abs(total - 7.0) < 0.01 # 5+3-1 = 7
|
||||
|
||||
def test_intermediate_labels(self):
|
||||
"""多片段使用中间 xf 标签."""
|
||||
result, _ = build_xfade_filter_chain(
|
||||
[1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
["v0", "v1", "v2", "v3", "v4"],
|
||||
["cut"] * 5,
|
||||
)
|
||||
# 5 个片段 = 4 个 xfade,中间标签 xf1, xf2, xf3
|
||||
assert "[xf1]" in result
|
||||
assert "[xf2]" in result
|
||||
assert "[xf3]" in result
|
||||
|
||||
def test_video_labels_used(self):
|
||||
"""使用传入的视频标签."""
|
||||
result, _ = build_xfade_filter_chain(
|
||||
[2.0, 2.0],
|
||||
["clip_a", "clip_b"],
|
||||
["cut", "fade"],
|
||||
)
|
||||
assert "[clip_a]" in result
|
||||
assert "[clip_b]" in result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_xfade_filter_chain — offset 计算
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildXfadeFilterChainOffset:
|
||||
"""xfade offset 计算测试."""
|
||||
|
||||
def test_two_clips_offset(self):
|
||||
"""两片段 offset = dur0 - td."""
|
||||
result, _ = build_xfade_filter_chain(
|
||||
[10.0, 5.0],
|
||||
["v0", "v1"],
|
||||
["cut", "fade"],
|
||||
transition_duration=1.0,
|
||||
)
|
||||
# offset = 10 - 1*1 = 9
|
||||
assert ":offset=9.000" in result
|
||||
|
||||
def test_three_clips_second_offset(self):
|
||||
"""三片段第二个 offset."""
|
||||
result, _ = build_xfade_filter_chain(
|
||||
[5.0, 4.0, 3.0],
|
||||
["v0", "v1", "v2"],
|
||||
["cut", "fade", "fade"],
|
||||
transition_duration=0.5,
|
||||
)
|
||||
# 第一个 xfade offset = 5 - 0.5*1 = 4.5
|
||||
# 第二个:cumulative = 5+4 = 9, offset = 9 - 0.5*2 = 8
|
||||
assert ":offset=4.500" in result
|
||||
assert ":offset=8.000" in result
|
||||
|
||||
def test_offset_never_negative(self):
|
||||
"""offset 不为负."""
|
||||
result, _ = build_xfade_filter_chain(
|
||||
[0.1, 0.1],
|
||||
["v0", "v1"],
|
||||
["cut", "fade"],
|
||||
transition_duration=1.0,
|
||||
)
|
||||
# 找 offset 的值
|
||||
import re
|
||||
|
||||
offsets = re.findall(r"offset=([\d.]+)", result)
|
||||
for off in offsets:
|
||||
assert float(off) >= 0.0
|
||||
|
||||
def test_short_first_clip_clamps_td(self):
|
||||
"""第一个片段很短时,转场时长被钳制."""
|
||||
result, _ = build_xfade_filter_chain(
|
||||
[0.3, 2.0],
|
||||
["v0", "v1"],
|
||||
["cut", "fade"],
|
||||
transition_duration=1.0,
|
||||
)
|
||||
# 第一片段只有 0.3s,offset ≈ 0, available ≈ 0.3, td 被钳制
|
||||
import re
|
||||
|
||||
durations = re.findall(r"duration=([\d.]+)", result)
|
||||
# 第一个 duration 是 xfade 的 duration
|
||||
xfade_dur = float(durations[0])
|
||||
assert xfade_dur <= 0.3 # 不能超过第一个片段时长
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_xfade_filter_chain — duration 钳制(防 exit 234)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildXfadeFilterChainClamping:
|
||||
"""duration 钳制逻辑测试(防 FFmpeg exit 234)."""
|
||||
|
||||
def test_td_not_exceed_first_input(self):
|
||||
"""转场时长不超过第一个输入的可用时长."""
|
||||
# 第一个片段 1s,转场 2s → 被钳制
|
||||
result, total = build_xfade_filter_chain(
|
||||
[1.0, 3.0],
|
||||
["v0", "v1"],
|
||||
["cut", "fade"],
|
||||
transition_duration=2.0,
|
||||
)
|
||||
import re
|
||||
|
||||
durations = re.findall(r"xfade=transition=fade:duration=([\d.]+)", result)
|
||||
assert float(durations[0]) <= 1.0
|
||||
# 总时长不会比 1+3 = 4 还大(钳制后 td < 2)
|
||||
assert total < 4.0
|
||||
|
||||
def test_td_not_exceed_second_input(self):
|
||||
"""转场时长不超过第二个片段时长."""
|
||||
result, _ = build_xfade_filter_chain(
|
||||
[3.0, 0.2],
|
||||
["v0", "v1"],
|
||||
["cut", "fade"],
|
||||
transition_duration=1.0,
|
||||
)
|
||||
import re
|
||||
|
||||
durations = re.findall(r"xfade=transition=fade:duration=([\d.]+)", result)
|
||||
assert float(durations[0]) <= 0.2
|
||||
|
||||
def test_td_minimum_1ms(self):
|
||||
"""td 至少 1ms."""
|
||||
result, _ = build_xfade_filter_chain(
|
||||
[0.0001, 0.0001],
|
||||
["v0", "v1"],
|
||||
["cut", "fade"],
|
||||
transition_duration=0.0,
|
||||
)
|
||||
import re
|
||||
|
||||
durations = re.findall(r"xfade=transition=fade:duration=([\d.]+)", result)
|
||||
if durations:
|
||||
assert float(durations[0]) >= 0.001
|
||||
|
||||
def test_many_short_clips(self):
|
||||
"""多个极短片段."""
|
||||
n = 5
|
||||
durations = [0.2] * n
|
||||
labels = [f"v{i}" for i in range(n)]
|
||||
result, total = build_xfade_filter_chain(
|
||||
durations,
|
||||
labels,
|
||||
["cut"] * n,
|
||||
transition_duration=0.5,
|
||||
)
|
||||
# 4 个转场
|
||||
assert result.count("xfade=") == 4
|
||||
# 总时长合理:sum = 1.0,减去被钳制的转场
|
||||
assert total > 0
|
||||
assert total <= sum(durations)
|
||||
|
||||
def test_second_xfade_first_input_is_accumulated(self):
|
||||
"""第二个 xfade 的第一个输入时长是累积值(考虑之前的转场扣减)."""
|
||||
# 三个片段,转场比较长,验证第二步钳制
|
||||
result, total = build_xfade_filter_chain(
|
||||
[2.0, 2.0, 2.0],
|
||||
["v0", "v1", "v2"],
|
||||
["cut", "fade", "fade"],
|
||||
transition_duration=1.0,
|
||||
)
|
||||
# 第一个 xfade: first_input_dur = 2.0, td = min(1.0, 2.0-offset)
|
||||
# offset = 2 - 1*1 = 1.0, available = 2.0 - 1.0 = 1.0, td = 1.0
|
||||
# 第二个 xfade: first_input_dur = (2+2) - 1.0 = 3.0(累积 - 已用转场)
|
||||
# offset = 4 - 1*2 = 2.0, available = 3.0 - 2.0 = 1.0, td = min(1.0, 1.0, 2.0) = 1.0
|
||||
import re
|
||||
|
||||
dur_match = re.findall(r":duration=([\d.]+)", result)
|
||||
# 两个 xfade,每个 duration 都是 1.0(正常情况)
|
||||
assert len(dur_match) == 2
|
||||
assert float(dur_match[0]) == 1.0
|
||||
assert float(dur_match[1]) == 1.0
|
||||
|
||||
def test_total_duration_positive(self):
|
||||
"""总时长不为负."""
|
||||
_, total = build_xfade_filter_chain(
|
||||
[0.1, 0.1],
|
||||
["v0", "v1"],
|
||||
["cut", "fade"],
|
||||
transition_duration=10.0,
|
||||
)
|
||||
assert total >= 0.0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_xfade_filter_chain — 转场类型
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildXfadeFilterChainTransitions:
|
||||
"""不同转场类型测试."""
|
||||
|
||||
def test_slideleft_transition(self):
|
||||
"""slideleft 转场."""
|
||||
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "slideleft"])
|
||||
assert "xfade=transition=slideleft" in result
|
||||
|
||||
def test_slide_left_alias_resolved(self):
|
||||
"""slide_left 别名解析正确."""
|
||||
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "slide_left"])
|
||||
assert "xfade=transition=slideleft" in result
|
||||
|
||||
def test_dissolve_transition(self):
|
||||
"""dissolve 转场."""
|
||||
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "dissolve"])
|
||||
assert "xfade=transition=dissolve" in result
|
||||
|
||||
def test_zoom_transition(self):
|
||||
"""zoom 转场 → zoomin."""
|
||||
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "zoom"])
|
||||
assert "xfade=transition=zoomin" in result
|
||||
|
||||
def test_wipe_transition(self):
|
||||
"""wipe 转场."""
|
||||
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "wipeup"])
|
||||
assert "xfade=transition=wipeup" in result
|
||||
|
||||
def test_unknown_transition_fade(self):
|
||||
"""未知转场回退到 fade."""
|
||||
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "nonexistent"])
|
||||
assert "xfade=transition=fade" in result
|
||||
|
||||
def test_cut_resolved_as_fade(self):
|
||||
"""cut 也回退到 fade(调用方应对 cut 做特殊处理,但这里也能工作)."""
|
||||
result, _ = build_xfade_filter_chain([3.0, 2.0], ["v0", "v1"], ["cut", "cut"])
|
||||
# cut 不在映射里,回退到 fade
|
||||
assert "xfade=transition=fade" in result
|
||||
|
||||
def test_transitions_shorter_than_clips(self):
|
||||
"""transitions 比片段少时,超出部分用 cut/fade."""
|
||||
result, _ = build_xfade_filter_chain(
|
||||
[1.0, 1.0, 1.0, 1.0],
|
||||
["v0", "v1", "v2", "v3"],
|
||||
["cut", "fade"], # 只有 2 个转场
|
||||
)
|
||||
# 4 个片段 = 3 个 xfade,第三个用默认(cut→fade)
|
||||
assert result.count("xfade=") == 3
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_xfade_filter_chain — 总时长验证
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildXfadeFilterChainTotalDuration:
|
||||
"""总时长计算验证."""
|
||||
|
||||
def test_two_equal_clips_default_td(self):
|
||||
"""两个等长片段 + 默认 0.5s 转场."""
|
||||
_, total = build_xfade_filter_chain(
|
||||
[5.0, 5.0],
|
||||
["v0", "v1"],
|
||||
["cut", "fade"],
|
||||
)
|
||||
assert abs(total - 9.5) < 0.01 # 5+5-0.5
|
||||
|
||||
def test_three_clips_two_transitions(self):
|
||||
"""三个片段两个转场."""
|
||||
_, total = build_xfade_filter_chain(
|
||||
[3.0, 4.0, 3.0],
|
||||
["v0", "v1", "v2"],
|
||||
["cut"] * 3,
|
||||
transition_duration=0.5,
|
||||
)
|
||||
# 10 - 1.0 = 9.0
|
||||
assert abs(total - 9.0) < 0.01
|
||||
|
||||
def test_single_clip_no_transition_loss(self):
|
||||
"""单个片段无转场扣减."""
|
||||
_, total = build_xfade_filter_chain([10.0], ["v0"], [])
|
||||
assert total == 10.0
|
||||
|
||||
def test_zero_duration_clips(self):
|
||||
"""0 时长片段不崩溃."""
|
||||
result, total = build_xfade_filter_chain(
|
||||
[0.0, 0.0],
|
||||
["v0", "v1"],
|
||||
["cut", "fade"],
|
||||
)
|
||||
assert total >= 0.0
|
||||
assert "xfade=" in result # 仍然生成转场(td 被钳制到最小)
|
||||
|
||||
def test_very_long_transition_clamped(self):
|
||||
"""极长转场被钳制,总时长仍为正."""
|
||||
_, total = build_xfade_filter_chain(
|
||||
[2.0, 2.0],
|
||||
["v0", "v1"],
|
||||
["cut", "fade"],
|
||||
transition_duration=100.0,
|
||||
)
|
||||
# 总时长 > 0
|
||||
assert total > 0.0
|
||||
# 且 < sum(durations) = 4(因为被钳制但还有重叠)
|
||||
assert total < 4.0
|
||||
|
||||
def test_many_clips_linear_total(self):
|
||||
"""多片段总时长近似线性增长."""
|
||||
n = 10
|
||||
durations = [1.0] * n
|
||||
labels = [f"v{i}" for i in range(n)]
|
||||
_, total = build_xfade_filter_chain(
|
||||
durations,
|
||||
labels,
|
||||
["cut"] * n,
|
||||
transition_duration=0.1,
|
||||
)
|
||||
# 10 - 9*0.1 = 9.1
|
||||
assert abs(total - 9.1) < 0.05
|
||||
Executable
+658
@@ -0,0 +1,658 @@
|
||||
"""BGM 混音纯逻辑单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.bgm_mixer_pure import (
|
||||
BGMPureConfig,
|
||||
build_bgm_filter_chain,
|
||||
build_sidechain_mix_filter,
|
||||
build_simple_mix_filter,
|
||||
calculate_fade_out_start,
|
||||
calculate_loop_count,
|
||||
calculate_sidechain_ratio,
|
||||
estimate_bgm_processing_duration,
|
||||
normalize_bgm_config,
|
||||
should_loop_bgm,
|
||||
validate_bgm_config,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# should_loop_bgm 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestShouldLoopBGM:
|
||||
"""BGM 循环判断测试."""
|
||||
|
||||
def test_need_loop_when_much_shorter(self):
|
||||
"""BGM 远短于目标时长,需要循环."""
|
||||
assert should_loop_bgm(10, 100, True) is True
|
||||
|
||||
def test_no_loop_when_long_enough(self):
|
||||
"""BGM 够长,不需要循环."""
|
||||
assert should_loop_bgm(100, 100, True) is False
|
||||
|
||||
def test_no_loop_when_just_slightly_shorter(self):
|
||||
"""BGM 只差一点点(>90%),不循环."""
|
||||
assert should_loop_bgm(95, 100, True) is False
|
||||
|
||||
def test_threshold_90_percent(self):
|
||||
"""刚好 90% 阈值,不循环(<90% 才循环)."""
|
||||
assert should_loop_bgm(90, 100, True) is False
|
||||
|
||||
def test_just_below_threshold(self):
|
||||
"""略低于 90%,需要循环."""
|
||||
assert should_loop_bgm(89, 100, True) is True
|
||||
|
||||
def test_loop_disabled(self):
|
||||
"""禁用循环,即使 BGM 很短也不循环."""
|
||||
assert should_loop_bgm(10, 100, False) is False
|
||||
|
||||
def test_zero_bgm_duration(self):
|
||||
"""BGM 时长为 0,不循环."""
|
||||
assert should_loop_bgm(0, 100, True) is False
|
||||
|
||||
def test_negative_bgm_duration(self):
|
||||
"""BGM 时长为负,不循环."""
|
||||
assert should_loop_bgm(-5, 100, True) is False
|
||||
|
||||
def test_zero_target_duration(self):
|
||||
"""目标时长为 0,不循环."""
|
||||
assert should_loop_bgm(10, 0, True) is False
|
||||
|
||||
def test_negative_target_duration(self):
|
||||
"""目标时长为负,不循环."""
|
||||
assert should_loop_bgm(10, -10, True) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# calculate_loop_count 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateLoopCount:
|
||||
"""循环次数计算测试."""
|
||||
|
||||
def test_exact_multiple(self):
|
||||
"""刚好整数倍."""
|
||||
# 100/10 = 10, +2 = 12
|
||||
assert calculate_loop_count(10, 100) == 12
|
||||
|
||||
def test_not_exact_multiple(self):
|
||||
"""不是整数倍."""
|
||||
# 100/30 = 3, +2 = 5
|
||||
assert calculate_loop_count(30, 100) == 5
|
||||
|
||||
def test_bgm_longer_than_target(self):
|
||||
"""BGM 比目标长,至少 1 次."""
|
||||
assert calculate_loop_count(200, 100) == 1
|
||||
|
||||
def test_zero_bgm_duration(self):
|
||||
"""BGM 时长为 0,返回 1."""
|
||||
assert calculate_loop_count(0, 100) == 1
|
||||
|
||||
def test_negative_bgm_duration(self):
|
||||
"""BGM 时长为负,返回 1."""
|
||||
assert calculate_loop_count(-5, 100) == 1
|
||||
|
||||
def test_zero_target_duration(self):
|
||||
"""目标时长为 0,返回 1."""
|
||||
assert calculate_loop_count(10, 0) == 1
|
||||
|
||||
def test_negative_target_duration(self):
|
||||
"""目标时长为负,返回 1."""
|
||||
assert calculate_loop_count(10, -10) == 1
|
||||
|
||||
def test_very_short_bgm(self):
|
||||
"""非常短的 BGM,循环次数多."""
|
||||
# 100/1 = 100, +2 = 102
|
||||
assert calculate_loop_count(1, 100) == 102
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_bgm_filter_chain 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildBGMFilterChain:
|
||||
"""BGM 预处理滤镜链构建测试."""
|
||||
|
||||
def test_basic_volume_only(self):
|
||||
"""只有音量调节."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=0.5,
|
||||
)
|
||||
assert "volume=0.500" in result
|
||||
assert "aloop" not in result
|
||||
assert "afade=t=in" not in result
|
||||
assert "afade=t=out" not in result
|
||||
assert "atrim=0:100.000" in result
|
||||
assert "asetpts=N/SR/TB" in result
|
||||
|
||||
def test_with_loop(self):
|
||||
"""需要循环的情况."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=10,
|
||||
target_duration=100,
|
||||
volume=0.3,
|
||||
loop_enabled=True,
|
||||
)
|
||||
assert "aloop=loop=" in result
|
||||
assert "volume=0.300" in result
|
||||
|
||||
def test_no_loop_when_disabled(self):
|
||||
"""禁用循环,即使 BGM 短也不循环."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=10,
|
||||
target_duration=100,
|
||||
volume=0.3,
|
||||
loop_enabled=False,
|
||||
)
|
||||
assert "aloop" not in result
|
||||
|
||||
def test_fade_in_only(self):
|
||||
"""只有淡入."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.0,
|
||||
fade_in=2.5,
|
||||
)
|
||||
assert "afade=t=in:st=0:d=2.500" in result
|
||||
assert "afade=t=out" not in result
|
||||
assert "volume=" not in result # volume=1.0 不加
|
||||
|
||||
def test_fade_out_only(self):
|
||||
"""只有淡出."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.0,
|
||||
fade_out=3.0,
|
||||
)
|
||||
assert "afade=t=out:st=97.000:d=3.000" in result
|
||||
assert "afade=t=in" not in result
|
||||
|
||||
def test_fade_in_and_out(self):
|
||||
"""淡入+淡出."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.0,
|
||||
fade_in=1.5,
|
||||
fade_out=2.0,
|
||||
)
|
||||
assert "afade=t=in:st=0:d=1.500" in result
|
||||
assert "afade=t=out:st=98.000:d=2.000" in result
|
||||
|
||||
def test_volume_1_0_skipped(self):
|
||||
"""音量为 1.0 时不添加 volume 滤镜."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.0,
|
||||
)
|
||||
assert "volume=" not in result
|
||||
|
||||
def test_volume_0(self):
|
||||
"""音量为 0."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=0.0,
|
||||
)
|
||||
assert "volume=0.000" in result
|
||||
|
||||
def test_volume_clamped_high(self):
|
||||
"""音量超过 1.0 被钳制."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=1.5,
|
||||
)
|
||||
assert "volume=1.000" not in result # 1.0不加
|
||||
# 钳制到1.0后和1.0一样,不加volume滤镜
|
||||
# 但因为abs(1.0 - 1.0) < 0.001,所以不添加
|
||||
assert "volume=" not in result
|
||||
|
||||
def test_volume_clamped_low(self):
|
||||
"""音量为负被钳制到 0."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=100,
|
||||
volume=-0.5,
|
||||
)
|
||||
assert "volume=0.000" in result
|
||||
|
||||
def test_fade_out_longer_than_duration(self):
|
||||
"""淡出时长超过总时长,不加淡出."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=10,
|
||||
volume=1.0,
|
||||
fade_out=20.0,
|
||||
)
|
||||
assert "afade=t=out" not in result
|
||||
|
||||
def test_fade_out_equal_to_duration(self):
|
||||
"""淡出时长等于总时长,不加淡出."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=200,
|
||||
target_duration=10,
|
||||
volume=1.0,
|
||||
fade_out=10.0,
|
||||
)
|
||||
assert "afade=t=out" not in result
|
||||
|
||||
def test_zero_target_duration_fallback(self):
|
||||
"""目标时长为 0,兜底 5 秒."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=3,
|
||||
target_duration=0,
|
||||
volume=0.5,
|
||||
)
|
||||
assert "atrim=0:5.000" in result
|
||||
|
||||
def test_negative_target_duration_fallback(self):
|
||||
"""目标时长为负,兜底 5 秒."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=3,
|
||||
target_duration=-5,
|
||||
volume=0.5,
|
||||
)
|
||||
assert "atrim=0:5.000" in result
|
||||
|
||||
def test_full_chain_with_all_effects(self):
|
||||
"""完整滤镜链:循环+音量+淡入淡出+截断+重置."""
|
||||
result = build_bgm_filter_chain(
|
||||
bgm_duration=10,
|
||||
target_duration=100,
|
||||
volume=0.4,
|
||||
fade_in=1.0,
|
||||
fade_out=2.0,
|
||||
loop_enabled=True,
|
||||
)
|
||||
parts = result.split(",")
|
||||
# 顺序:aloop -> volume -> afade in -> afade out -> atrim -> asetpts
|
||||
assert len(parts) >= 6
|
||||
assert "aloop" in parts[0]
|
||||
assert "volume" in parts[1]
|
||||
assert "afade=t=in" in parts[2]
|
||||
assert "afade=t=out" in parts[3]
|
||||
assert "atrim" in parts[4]
|
||||
assert "asetpts" in parts[5]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# calculate_sidechain_ratio 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateSidechainRatio:
|
||||
"""Sidechain 压缩比计算测试."""
|
||||
|
||||
def test_default_ratio_0_3(self):
|
||||
"""默认 0.3."""
|
||||
# 1 / (1 - 0.3) = 1.428... 但下限是 2.0
|
||||
assert calculate_sidechain_ratio(0.3) == pytest.approx(2.0, rel=0.01)
|
||||
|
||||
def test_ratio_0_5(self):
|
||||
"""比例 0.5."""
|
||||
# 1 / (1 - 0.5) = 2.0
|
||||
assert calculate_sidechain_ratio(0.5) == pytest.approx(2.0, rel=0.01)
|
||||
|
||||
def test_ratio_0_8(self):
|
||||
"""比例 0.8."""
|
||||
# 1 / (1 - 0.8) = 5.0
|
||||
assert calculate_sidechain_ratio(0.8) == pytest.approx(5.0, rel=0.01)
|
||||
|
||||
def test_ratio_0_9(self):
|
||||
"""比例 0.9."""
|
||||
# 1 / (1 - 0.9) = 10.0
|
||||
assert calculate_sidechain_ratio(0.9) == pytest.approx(10.0, rel=0.01)
|
||||
|
||||
def test_ratio_0(self):
|
||||
"""比例 0,返回下限 2.0."""
|
||||
assert calculate_sidechain_ratio(0.0) == 2.0
|
||||
|
||||
def test_ratio_negative(self):
|
||||
"""比例为负,返回下限 2.0."""
|
||||
assert calculate_sidechain_ratio(-0.5) == 2.0
|
||||
|
||||
def test_ratio_1_0(self):
|
||||
"""比例 1.0,返回上限 10.0."""
|
||||
assert calculate_sidechain_ratio(1.0) == 10.0
|
||||
|
||||
def test_ratio_greater_than_1(self):
|
||||
"""比例超过 1.0,返回上限 10.0."""
|
||||
assert calculate_sidechain_ratio(2.0) == 10.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_simple_mix_filter 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildSimpleMixFilter:
|
||||
"""普通混音滤镜构建测试."""
|
||||
|
||||
def test_contains_amix(self):
|
||||
"""包含 amix."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "amix=inputs=2" in result
|
||||
|
||||
def test_contains_volume_compensation(self):
|
||||
"""包含 volume=2 补偿."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "volume=2" in result
|
||||
|
||||
def test_output_label(self):
|
||||
"""输出标签为 [final]."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "[final]" in result
|
||||
|
||||
def test_duration_first(self):
|
||||
"""duration=first,以主音频时长为准."""
|
||||
result = build_simple_mix_filter()
|
||||
assert "duration=first" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_sidechain_mix_filter 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildSidechainMixFilter:
|
||||
"""Sidechain 混音滤镜构建测试."""
|
||||
|
||||
def test_contains_sidechaincompress(self):
|
||||
"""包含 sidechaincompress."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "sidechaincompress=" in result
|
||||
|
||||
def test_threshold_param(self):
|
||||
"""threshold 参数正确."""
|
||||
result = build_sidechain_mix_filter(threshold=-30.0)
|
||||
assert "threshold=-30.0dB" in result
|
||||
|
||||
def test_attack_param(self):
|
||||
"""attack 参数正确."""
|
||||
result = build_sidechain_mix_filter(attack=0.05)
|
||||
assert "attack=0.050" in result
|
||||
|
||||
def test_release_param(self):
|
||||
"""release 参数正确."""
|
||||
result = build_sidechain_mix_filter(release=0.8)
|
||||
assert "release=0.800" in result
|
||||
|
||||
def test_knee_param(self):
|
||||
"""knee=6 参数."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "knee=6" in result
|
||||
|
||||
def test_contains_amix(self):
|
||||
"""包含 amix 混音."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "amix=inputs=2" in result
|
||||
|
||||
def test_volume_compensation(self):
|
||||
"""volume=1.5 轻微补偿."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "volume=1.5" in result
|
||||
|
||||
def test_bgmc_comp_label(self):
|
||||
"""包含 [bgm_comp] 中间标签."""
|
||||
result = build_sidechain_mix_filter()
|
||||
assert "[bgm_comp]" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# normalize_bgm_config 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeBGMConfig:
|
||||
"""配置规范化测试."""
|
||||
|
||||
def test_empty_dict_defaults(self):
|
||||
"""空字典返回默认值."""
|
||||
result = normalize_bgm_config({})
|
||||
assert result["volume"] == 0.3
|
||||
assert result["fade_in"] == 0.0
|
||||
assert result["fade_out"] == 0.0
|
||||
assert result["loop_enabled"] is True
|
||||
assert result["sidechain_enabled"] is False
|
||||
assert result["sidechain_ratio"] == 0.3
|
||||
|
||||
def test_volume_clamped(self):
|
||||
"""音量钳制."""
|
||||
result = normalize_bgm_config({"volume": 1.5})
|
||||
assert result["volume"] == 1.0
|
||||
result2 = normalize_bgm_config({"volume": -0.5})
|
||||
assert result2["volume"] == 0.0
|
||||
|
||||
def test_fade_in_negative(self):
|
||||
"""淡入为负钳制到 0."""
|
||||
result = normalize_bgm_config({"fade_in": -1})
|
||||
assert result["fade_in"] == 0.0
|
||||
|
||||
def test_fade_out_negative(self):
|
||||
"""淡出为负钳制到 0."""
|
||||
result = normalize_bgm_config({"fade_out": -1})
|
||||
assert result["fade_out"] == 0.0
|
||||
|
||||
def test_sidechain_ratio_clamped(self):
|
||||
"""sidechain_ratio 钳制."""
|
||||
result = normalize_bgm_config({"sidechain_ratio": 1.5})
|
||||
assert result["sidechain_ratio"] == 1.0
|
||||
result2 = normalize_bgm_config({"sidechain_ratio": -0.1})
|
||||
assert result2["sidechain_ratio"] == 0.0
|
||||
|
||||
def test_sidechain_attack_min(self):
|
||||
"""attack 最小值 0.001."""
|
||||
result = normalize_bgm_config({"sidechain_attack": 0})
|
||||
assert result["sidechain_attack"] == 0.001
|
||||
|
||||
def test_sidechain_release_min(self):
|
||||
"""release 最小值 0.01."""
|
||||
result = normalize_bgm_config({"sidechain_release": 0})
|
||||
assert result["sidechain_release"] == 0.01
|
||||
|
||||
def test_string_values_converted(self):
|
||||
"""字符串数值被转换."""
|
||||
result = normalize_bgm_config(
|
||||
{
|
||||
"volume": "0.5",
|
||||
"fade_in": "2.0",
|
||||
}
|
||||
)
|
||||
assert result["volume"] == 0.5
|
||||
assert result["fade_in"] == 2.0
|
||||
|
||||
def test_loop_enabled_truthy(self):
|
||||
"""loop_enabled 真值转换."""
|
||||
result = normalize_bgm_config({"loop_enabled": 1})
|
||||
assert result["loop_enabled"] is True
|
||||
result2 = normalize_bgm_config({"loop_enabled": 0})
|
||||
assert result2["loop_enabled"] is False
|
||||
|
||||
def test_preserves_unknown_keys(self):
|
||||
"""未知 key 不保留."""
|
||||
result = normalize_bgm_config({"unknown_key": "value", "volume": 0.5})
|
||||
assert "unknown_key" not in result
|
||||
assert result["volume"] == 0.5
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# validate_bgm_config 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateBGMConfig:
|
||||
"""配置验证测试."""
|
||||
|
||||
def test_valid_config(self):
|
||||
"""合法配置."""
|
||||
ok, errors = validate_bgm_config(
|
||||
{
|
||||
"volume": 0.5,
|
||||
"fade_in": 1.0,
|
||||
"fade_out": 2.0,
|
||||
"sidechain_ratio": 0.3,
|
||||
}
|
||||
)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_volume_not_number(self):
|
||||
"""volume 不是数字."""
|
||||
ok, errors = validate_bgm_config({"volume": "high"})
|
||||
assert ok is False
|
||||
assert any("volume" in e for e in errors)
|
||||
|
||||
def test_volume_out_of_range(self):
|
||||
"""volume 超出范围."""
|
||||
ok, errors = validate_bgm_config({"volume": 1.5})
|
||||
assert ok is False
|
||||
assert any("volume" in e for e in errors)
|
||||
|
||||
def test_fade_in_negative(self):
|
||||
"""fade_in 为负."""
|
||||
ok, errors = validate_bgm_config({"fade_in": -1})
|
||||
assert ok is False
|
||||
assert any("fade_in" in e for e in errors)
|
||||
|
||||
def test_fade_out_negative(self):
|
||||
"""fade_out 为负."""
|
||||
ok, errors = validate_bgm_config({"fade_out": -1})
|
||||
assert ok is False
|
||||
assert any("fade_out" in e for e in errors)
|
||||
|
||||
def test_sidechain_ratio_out_of_range(self):
|
||||
"""sidechain_ratio 超出范围."""
|
||||
ok, errors = validate_bgm_config({"sidechain_ratio": 2.0})
|
||||
assert ok is False
|
||||
assert any("sidechain_ratio" in e for e in errors)
|
||||
|
||||
def test_multiple_errors(self):
|
||||
"""多个错误同时报告."""
|
||||
ok, errors = validate_bgm_config(
|
||||
{
|
||||
"volume": 2.0,
|
||||
"fade_in": -1,
|
||||
"sidechain_ratio": -0.5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert len(errors) >= 3
|
||||
|
||||
def test_empty_config_valid(self):
|
||||
"""空配置(全用默认值)视为合法."""
|
||||
ok, errors = validate_bgm_config({})
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# calculate_fade_out_start 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateFadeOutStart:
|
||||
"""淡出开始时间计算测试."""
|
||||
|
||||
def test_normal_case(self):
|
||||
"""正常情况."""
|
||||
assert calculate_fade_out_start(100, 3) == pytest.approx(97.0)
|
||||
|
||||
def test_zero_fade_out(self):
|
||||
"""淡出时长为 0,返回 None."""
|
||||
assert calculate_fade_out_start(100, 0) is None
|
||||
|
||||
def test_negative_fade_out(self):
|
||||
"""淡出时长为负,返回 None."""
|
||||
assert calculate_fade_out_start(100, -1) is None
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""总时长为 0,返回 None."""
|
||||
assert calculate_fade_out_start(0, 3) is None
|
||||
|
||||
def test_fade_out_longer_than_duration(self):
|
||||
"""淡出超过总时长,返回 None."""
|
||||
assert calculate_fade_out_start(10, 20) is None
|
||||
|
||||
def test_fade_out_equal_to_duration(self):
|
||||
"""淡出等于总时长,返回 None."""
|
||||
assert calculate_fade_out_start(10, 10) is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# estimate_bgm_processing_duration 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEstimateBGMProcessingDuration:
|
||||
"""BGM 处理时长估算测试."""
|
||||
|
||||
def test_normal_case_with_loop(self):
|
||||
"""正常循环情况,输出目标时长."""
|
||||
assert estimate_bgm_processing_duration(10, 100, True) == 100
|
||||
|
||||
def test_bgm_longer_no_loop(self):
|
||||
"""BGM 够长,不循环,截断到目标时长."""
|
||||
assert estimate_bgm_processing_duration(200, 100, False) == 100
|
||||
|
||||
def test_bgm_shorter_no_loop(self):
|
||||
"""BGM 短但不循环,仍然截断到目标时长(实际会更短,但 atrim 会截断)."""
|
||||
assert estimate_bgm_processing_duration(10, 100, False) == 100
|
||||
|
||||
def test_zero_target(self):
|
||||
"""目标时长为 0,兜底 5 秒."""
|
||||
assert estimate_bgm_processing_duration(10, 0, True) == 5.0
|
||||
|
||||
def test_negative_target(self):
|
||||
"""目标时长为负,兜底 5 秒."""
|
||||
assert estimate_bgm_processing_duration(10, -5, True) == 5.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# BGMPureConfig 测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBGMPureConfig:
|
||||
"""BGMPureConfig 数据类测试."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
config = BGMPureConfig()
|
||||
assert config.volume == 0.3
|
||||
assert config.fade_in == 0.0
|
||||
assert config.fade_out == 0.0
|
||||
assert config.loop_enabled is True
|
||||
assert config.sidechain_enabled is False
|
||||
assert config.sidechain_ratio == 0.3
|
||||
assert config.sidechain_attack == 0.02
|
||||
assert config.sidechain_release == 0.5
|
||||
assert config.sidechain_threshold == -25.0
|
||||
|
||||
def test_custom_values(self):
|
||||
"""自定义值."""
|
||||
config = BGMPureConfig(
|
||||
volume=0.7,
|
||||
fade_in=1.0,
|
||||
fade_out=2.0,
|
||||
loop_enabled=False,
|
||||
sidechain_enabled=True,
|
||||
sidechain_ratio=0.5,
|
||||
sidechain_attack=0.05,
|
||||
sidechain_release=0.8,
|
||||
sidechain_threshold=-30.0,
|
||||
)
|
||||
assert config.volume == 0.7
|
||||
assert config.loop_enabled is False
|
||||
assert config.sidechain_enabled is True
|
||||
assert config.sidechain_threshold == -30.0
|
||||
Executable
+534
@@ -0,0 +1,534 @@
|
||||
"""视频拼接引擎纯逻辑单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.concat_engine_pure import (
|
||||
build_concat_filter,
|
||||
build_fps_filter,
|
||||
build_scale_pad_filter,
|
||||
build_single_segment_filter_chain,
|
||||
calculate_scaled_size,
|
||||
can_use_stream_copy,
|
||||
count_valid_segments,
|
||||
estimate_total_duration,
|
||||
format_fps_filter,
|
||||
generate_concat_file_list,
|
||||
parse_fps,
|
||||
resolve_output_params,
|
||||
validate_concat_config,
|
||||
validate_video_path,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 帧率解析测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseFps:
|
||||
"""parse_fps 测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert parse_fps(30) == 30.0
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
assert parse_fps(29.97) == pytest.approx(29.97)
|
||||
|
||||
def test_string_integer(self):
|
||||
"""字符串整数."""
|
||||
assert parse_fps("30") == 30.0
|
||||
|
||||
def test_string_fraction(self):
|
||||
"""分数字符串(30/1)."""
|
||||
assert parse_fps("30/1") == 30.0
|
||||
|
||||
def test_fraction_24000_1001(self):
|
||||
"""23.976 帧率."""
|
||||
result = parse_fps("24000/1001")
|
||||
assert result == pytest.approx(23.976, rel=0.01)
|
||||
|
||||
def test_none_input(self):
|
||||
"""None 输入返回默认值."""
|
||||
assert parse_fps(None) == 30.0
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串返回默认值."""
|
||||
assert parse_fps("") == 30.0
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串."""
|
||||
assert parse_fps("abc") == 30.0
|
||||
|
||||
def test_zero_denominator(self):
|
||||
"""分母为 0."""
|
||||
assert parse_fps("30/0") == 30.0
|
||||
|
||||
def test_negative_fps(self):
|
||||
"""负帧率."""
|
||||
assert parse_fps(-30) == -30.0
|
||||
|
||||
|
||||
class TestFormatFpsFilter:
|
||||
"""format_fps_filter 测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert format_fps_filter(30.0) == "fps=30"
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
result = format_fps_filter(29.97)
|
||||
assert result.startswith("fps=")
|
||||
assert "29.97" in result
|
||||
|
||||
def test_near_integer(self):
|
||||
"""接近整数."""
|
||||
assert format_fps_filter(30.0001) == "fps=30"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 输出参数计算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveOutputParams:
|
||||
"""resolve_output_params 测试."""
|
||||
|
||||
def test_all_specified(self):
|
||||
"""全部显式指定."""
|
||||
w, h, fps = resolve_output_params(1920, 1080, 60.0)
|
||||
assert w == 1920
|
||||
assert h == 1080
|
||||
assert fps == 60.0
|
||||
|
||||
def test_no_specified_use_defaults(self):
|
||||
"""全部未指定,用默认值."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0)
|
||||
assert w == 1080
|
||||
assert h == 1920
|
||||
assert fps == 30.0
|
||||
|
||||
def test_use_first_video_info(self):
|
||||
"""用第一段视频信息."""
|
||||
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
|
||||
w, h, fps = resolve_output_params(0, 0, 0, info)
|
||||
assert w == 1280
|
||||
assert h == 720
|
||||
assert fps == 24.0
|
||||
|
||||
def test_partial_specified(self):
|
||||
"""部分指定,未指定的用探测值."""
|
||||
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
|
||||
w, h, fps = resolve_output_params(1920, 0, 0, info)
|
||||
assert w == 1920 # 指定的
|
||||
assert h == 720 # 探测的
|
||||
assert fps == 24.0
|
||||
|
||||
def test_zero_size_clamped(self):
|
||||
"""零尺寸被钳制."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0, {})
|
||||
assert w >= 1
|
||||
assert h >= 1
|
||||
assert fps >= 1.0
|
||||
|
||||
def test_custom_defaults(self):
|
||||
"""自定义默认值."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0, None, 640, 480, 25.0)
|
||||
assert w == 640
|
||||
assert h == 480
|
||||
assert fps == 25.0
|
||||
|
||||
|
||||
class TestCalculateScaledSize:
|
||||
"""calculate_scaled_size 测试."""
|
||||
|
||||
def test_same_ratio(self):
|
||||
"""比例相同."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
def test_wider_source(self):
|
||||
"""源更宽,上下填黑边."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1080, 1920)
|
||||
assert sw == 1080 # 以宽度为准
|
||||
assert sh < 1920 # 高度按比例
|
||||
assert ox == 0
|
||||
assert oy > 0 # 垂直居中
|
||||
|
||||
def test_taller_source(self):
|
||||
"""源更高,左右填黑边."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1080, 1920, 1920, 1080)
|
||||
assert sh == 1080 # 以高度为准
|
||||
assert sw < 1920 # 宽度按比例
|
||||
assert ox > 0 # 水平居中
|
||||
assert oy == 0
|
||||
|
||||
def test_zero_source(self):
|
||||
"""零尺寸源."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(0, 0, 100, 100)
|
||||
assert sw == 100
|
||||
assert sh == 100
|
||||
|
||||
def test_scale_down(self):
|
||||
"""缩小."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 640, 360)
|
||||
assert sw == 640
|
||||
assert sh == 360
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
def test_scale_up(self):
|
||||
"""放大."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(640, 360, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# stream copy 判断测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCanUseStreamCopy:
|
||||
"""can_use_stream_copy 测试."""
|
||||
|
||||
def test_identical_segments(self):
|
||||
"""所有段参数相同,可以 stream copy."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
|
||||
|
||||
def test_force_reencode(self):
|
||||
"""强制重编码."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0, force_reencode=True) is False
|
||||
|
||||
def test_different_codec(self):
|
||||
"""编码不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "hevc", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_different_resolution(self):
|
||||
"""分辨率不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1280, "height": 720, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_different_fps(self):
|
||||
"""帧率不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "60/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_target_differs(self):
|
||||
"""目标参数与源不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1280, 720, 30.0) is False
|
||||
|
||||
def test_empty_segments(self):
|
||||
"""空列表."""
|
||||
assert can_use_stream_copy([], 1920, 1080, 30.0) is False
|
||||
|
||||
def test_single_segment(self):
|
||||
"""单段."""
|
||||
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 文件列表生成测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerateConcatFileList:
|
||||
"""generate_concat_file_list 测试."""
|
||||
|
||||
def test_single_file(self):
|
||||
"""单个文件."""
|
||||
result = generate_concat_file_list(["/a.mp4"])
|
||||
assert "file '/a.mp4'" in result
|
||||
assert result.endswith("\n")
|
||||
|
||||
def test_multiple_files(self):
|
||||
"""多个文件."""
|
||||
result = generate_concat_file_list(["/a.mp4", "/b.mp4", "/c.mp4"])
|
||||
lines = result.strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
assert lines[0] == "file '/a.mp4'"
|
||||
assert lines[1] == "file '/b.mp4'"
|
||||
assert lines[2] == "file '/c.mp4'"
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
result = generate_concat_file_list([])
|
||||
assert result == "\n"
|
||||
|
||||
def test_path_with_single_quote(self):
|
||||
"""路径包含单引号(转义)."""
|
||||
result = generate_concat_file_list(["/path/to/file's.mp4"])
|
||||
# 单引号应该被转义
|
||||
assert "'\\''" in result or file
|
||||
assert "file '" in result
|
||||
|
||||
def test_path_with_spaces(self):
|
||||
"""路径包含空格."""
|
||||
result = generate_concat_file_list(["/path/to/my video.mp4"])
|
||||
assert "my video" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 滤镜构建测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildScalePadFilter:
|
||||
"""scale+pad 滤镜测试."""
|
||||
|
||||
def test_contains_scale(self):
|
||||
"""包含 scale."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "scale=" in result
|
||||
|
||||
def test_contains_pad(self):
|
||||
"""包含 pad."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "pad=" in result
|
||||
assert "1920:1080" in result
|
||||
|
||||
def test_force_original_aspect_ratio(self):
|
||||
"""保持宽高比."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "force_original_aspect_ratio=decrease" in result
|
||||
|
||||
def test_black_padding(self):
|
||||
"""黑边填充."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert ":black" in result
|
||||
|
||||
|
||||
class TestBuildFpsFilter:
|
||||
"""fps 滤镜测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert build_fps_filter(30.0) == "fps=30"
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
result = build_fps_filter(29.97)
|
||||
assert result.startswith("fps=")
|
||||
|
||||
|
||||
class TestBuildConcatFilter:
|
||||
"""concat 滤镜测试."""
|
||||
|
||||
def test_two_inputs_with_audio(self):
|
||||
"""两路输入,有音频."""
|
||||
result = build_concat_filter(2, has_audio=True)
|
||||
assert "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1" in result
|
||||
assert "[concat_v][concat_a]" in result
|
||||
|
||||
def test_three_inputs_video_only(self):
|
||||
"""三路输入,无音频."""
|
||||
result = build_concat_filter(3, has_audio=False)
|
||||
assert "[0:v][1:v][2:v]concat=n=3:v=1:a=0" in result
|
||||
assert "[concat_v]" in result
|
||||
|
||||
def test_single_input(self):
|
||||
"""单路输入."""
|
||||
result = build_concat_filter(1, has_audio=True)
|
||||
assert "[0:v][0:a]concat=n=1:v=1:a=1" in result
|
||||
|
||||
def test_zero_inputs(self):
|
||||
"""零输入."""
|
||||
assert build_concat_filter(0) == ""
|
||||
|
||||
|
||||
class TestBuildSingleSegmentFilterChain:
|
||||
"""单段滤镜链测试."""
|
||||
|
||||
def test_with_audio(self):
|
||||
"""有音频."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 0)
|
||||
assert "scale=" in result
|
||||
assert "fps=" in result
|
||||
assert "setpts=PTS-STARTPTS" in result
|
||||
assert "asetpts=PTS-STARTPTS" in result
|
||||
assert "[v0]" in result
|
||||
assert "[a0]" in result
|
||||
|
||||
def test_video_only(self):
|
||||
"""无音频."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 1, has_audio=False)
|
||||
assert "scale=" in result
|
||||
assert "setpts=" in result
|
||||
assert "asetpts" not in result
|
||||
assert "[v1]" in result
|
||||
|
||||
def test_segment_index_in_labels(self):
|
||||
"""段索引在标签中."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 5)
|
||||
assert "[5:v]" in result
|
||||
assert "[v5]" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 配置验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateConcatConfig:
|
||||
"""配置验证测试."""
|
||||
|
||||
def test_valid_config(self):
|
||||
"""合法配置."""
|
||||
config = {
|
||||
"segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}],
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
"output_fps": 30,
|
||||
}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_empty_segments(self):
|
||||
"""空段列表."""
|
||||
ok, errors = validate_concat_config({"segments": []})
|
||||
assert ok is False
|
||||
assert any("至少需要" in e or "视频段" in e for e in errors)
|
||||
|
||||
def test_missing_video_path(self):
|
||||
"""缺少 video_path."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}, {}]}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
assert any("video_path" in e for e in errors)
|
||||
|
||||
def test_negative_width(self):
|
||||
"""负宽度."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -100}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
assert any("output_width" in e for e in errors)
|
||||
|
||||
def test_negative_height(self):
|
||||
"""负高度."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -100}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
assert any("output_height" in e for e in errors)
|
||||
|
||||
def test_negative_fps(self):
|
||||
"""负帧率."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -30}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
assert any("output_fps" in e for e in errors)
|
||||
|
||||
def test_zero_output_params_ok(self):
|
||||
"""零输出参数合法(表示自动探测)."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}]}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 路径验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateVideoPath:
|
||||
"""视频路径验证测试."""
|
||||
|
||||
def test_empty_path(self):
|
||||
"""空路径."""
|
||||
ok, msg = validate_video_path("", "/work")
|
||||
assert ok is False
|
||||
assert "不能为空" in msg
|
||||
|
||||
def test_path_traversal(self):
|
||||
"""路径遍历."""
|
||||
ok, msg = validate_video_path("../etc/passwd", "/work")
|
||||
assert ok is False
|
||||
assert "回溯" in msg or ".." in msg
|
||||
|
||||
def test_valid_relative_path(self):
|
||||
"""相对路径(不检查边界)."""
|
||||
ok, msg = validate_video_path("video.mp4", "/work")
|
||||
assert ok is True
|
||||
|
||||
def test_valid_absolute_path(self):
|
||||
"""绝对路径在工作目录内."""
|
||||
ok, msg = validate_video_path("/work/sub/video.mp4", "/work")
|
||||
assert ok is True
|
||||
|
||||
def test_path_outside_work_dir(self):
|
||||
"""路径在工作目录外."""
|
||||
ok, msg = validate_video_path("/etc/passwd", "/work")
|
||||
assert ok is False
|
||||
assert "工作目录" in msg
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 工具函数测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEstimateTotalDuration:
|
||||
"""总时长估算测试."""
|
||||
|
||||
def test_multiple_segments(self):
|
||||
"""多段视频."""
|
||||
segs = [{"duration": 10}, {"duration": 20.5}, {"duration": 5}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(35.5)
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert estimate_total_duration([]) == 0.0
|
||||
|
||||
def test_invalid_duration_skipped(self):
|
||||
"""无效时长跳过."""
|
||||
segs = [{"duration": 10}, {"duration": "abc"}, {"duration": 20}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(30.0)
|
||||
|
||||
def test_missing_duration(self):
|
||||
"""缺 duration 字段."""
|
||||
segs = [{}, {"duration": 10}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(10.0)
|
||||
|
||||
|
||||
class TestCountValidSegments:
|
||||
"""有效段统计测试."""
|
||||
|
||||
def test_all_valid(self):
|
||||
"""全部有效."""
|
||||
segs = [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}]
|
||||
assert count_valid_segments(segs) == 2
|
||||
|
||||
def test_some_invalid(self):
|
||||
"""部分无效."""
|
||||
segs = [{"video_path": "/a.mp4"}, {}, {"video_path": ""}]
|
||||
assert count_valid_segments(segs) == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert count_valid_segments([]) == 0
|
||||
Executable
+638
@@ -0,0 +1,638 @@
|
||||
"""多轨混音纯逻辑单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
from video_processing.multi_track_mixer_pure import (
|
||||
build_amix_filter,
|
||||
build_mix_filter_complex,
|
||||
build_track_filter_chain,
|
||||
calculate_amix_volume_compensation,
|
||||
calculate_effective_range,
|
||||
calculate_total_tracks,
|
||||
count_track_types,
|
||||
db_to_linear,
|
||||
estimate_mix_duration,
|
||||
filter_enabled_tracks,
|
||||
is_track_visible,
|
||||
linear_to_db,
|
||||
normalize_volume,
|
||||
sort_tracks_by_priority,
|
||||
validate_audio_track,
|
||||
validate_mix_config,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 时间计算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateEffectiveRange:
|
||||
"""有效时间范围计算测试."""
|
||||
|
||||
def test_normal_track(self):
|
||||
"""正常轨道."""
|
||||
start, dur, trim = calculate_effective_range(5, 10, 30, 60)
|
||||
assert start == 5.0
|
||||
assert dur == 10.0
|
||||
assert trim == 0.0
|
||||
|
||||
def test_track_longer_than_audio(self):
|
||||
"""轨道时长超过音频长度."""
|
||||
start, dur, trim = calculate_effective_range(0, 100, 30, 60)
|
||||
assert start == 0.0
|
||||
assert dur == 30.0 # 用音频全长
|
||||
|
||||
def test_zero_track_duration(self):
|
||||
"""轨道时长为 0(用音频全长)."""
|
||||
start, dur, trim = calculate_effective_range(0, 0, 30, 60)
|
||||
assert start == 0.0
|
||||
assert dur == 30.0
|
||||
|
||||
def test_negative_start_time(self):
|
||||
"""负开始时间(从音频中间取)."""
|
||||
start, dur, trim = calculate_effective_range(-5, 20, 30, 60)
|
||||
assert start == 0.0
|
||||
assert dur == 15.0 # 20 - 5 = 15
|
||||
assert trim == 5.0
|
||||
|
||||
def test_track_after_target(self):
|
||||
"""轨道完全在目标之后."""
|
||||
start, dur, trim = calculate_effective_range(100, 10, 30, 60)
|
||||
assert dur == 0.0
|
||||
|
||||
def test_track_before_zero(self):
|
||||
"""轨道完全在 0 之前."""
|
||||
start, dur, trim = calculate_effective_range(-50, 10, 30, 60)
|
||||
assert dur == 0.0
|
||||
|
||||
def test_zero_audio_duration(self):
|
||||
"""音频时长为 0."""
|
||||
start, dur, trim = calculate_effective_range(0, 10, 0, 60)
|
||||
assert dur == 0.0
|
||||
|
||||
def test_track_extends_beyond_target(self):
|
||||
"""轨道超出目标时长."""
|
||||
start, dur, trim = calculate_effective_range(50, 20, 30, 60)
|
||||
assert start == 50.0
|
||||
assert dur == 10.0 # 60 - 50 = 10
|
||||
|
||||
def test_full_target_duration(self):
|
||||
"""轨道覆盖整个目标时长."""
|
||||
start, dur, trim = calculate_effective_range(0, 0, 100, 60)
|
||||
assert start == 0.0
|
||||
assert dur == 60.0
|
||||
|
||||
|
||||
class TestIsTrackVisible:
|
||||
"""轨道可见性测试."""
|
||||
|
||||
def test_visible_track(self):
|
||||
"""可见轨道."""
|
||||
assert is_track_visible(5, 10, 30, 60) is True
|
||||
|
||||
def test_invisible_after_target(self):
|
||||
"""目标之后不可见."""
|
||||
assert is_track_visible(100, 10, 30, 60) is False
|
||||
|
||||
def test_invisible_zero_duration(self):
|
||||
"""零时长不可见."""
|
||||
assert is_track_visible(0, 0, 0, 60) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 滤镜链构建测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildTrackFilterChain:
|
||||
"""单轨滤镜链构建测试."""
|
||||
|
||||
def test_basic_structure(self):
|
||||
"""基本结构:截断+音量+淡入淡出+延迟+截断."""
|
||||
result = build_track_filter_chain(
|
||||
volume=0.5,
|
||||
fade_in=1.0,
|
||||
fade_out=1.0,
|
||||
effective_start=5.0,
|
||||
need_duration=10.0,
|
||||
trim_start=0.0,
|
||||
target_duration=60.0,
|
||||
)
|
||||
assert "atrim=0.000:10.000" in result
|
||||
assert "volume=0.500" in result
|
||||
assert "afade=t=in:st=0:d=1.000" in result
|
||||
assert "afade=t=out" in result
|
||||
assert "adelay=5000|5000" in result
|
||||
assert "atrim=0:60.000" in result
|
||||
|
||||
def test_volume_1_0_skipped(self):
|
||||
"""音量为 1.0 不添加 volume 滤镜."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=0,
|
||||
fade_out=0,
|
||||
effective_start=0,
|
||||
need_duration=10,
|
||||
trim_start=0,
|
||||
target_duration=60,
|
||||
)
|
||||
assert "volume=" not in result
|
||||
|
||||
def test_no_fade_in(self):
|
||||
"""无淡入."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=0,
|
||||
fade_out=2.0,
|
||||
effective_start=0,
|
||||
need_duration=10,
|
||||
trim_start=0,
|
||||
target_duration=60,
|
||||
)
|
||||
assert "afade=t=in" not in result
|
||||
assert "afade=t=out" in result
|
||||
|
||||
def test_no_delay(self):
|
||||
"""无延迟(effective_start 很小)."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=0,
|
||||
fade_out=0,
|
||||
effective_start=0.001,
|
||||
need_duration=10,
|
||||
trim_start=0,
|
||||
target_duration=60,
|
||||
)
|
||||
assert "adelay" not in result
|
||||
|
||||
def test_with_delay(self):
|
||||
"""有延迟."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=0,
|
||||
fade_out=0,
|
||||
effective_start=2.5,
|
||||
need_duration=10,
|
||||
trim_start=0,
|
||||
target_duration=60,
|
||||
)
|
||||
assert "adelay=2500|2500" in result
|
||||
|
||||
def test_fade_in_longer_than_duration(self):
|
||||
"""淡入超过总时长,不添加淡入."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=20,
|
||||
fade_out=0,
|
||||
effective_start=0,
|
||||
need_duration=10,
|
||||
trim_start=0,
|
||||
target_duration=60,
|
||||
)
|
||||
assert "afade=t=in" not in result
|
||||
|
||||
def test_fade_out_at_start(self):
|
||||
"""淡出从 0 开始(很短的音频)."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=0,
|
||||
fade_out=15,
|
||||
effective_start=0,
|
||||
need_duration=10,
|
||||
trim_start=0,
|
||||
target_duration=60,
|
||||
)
|
||||
# fade_out > need_duration,不添加
|
||||
assert "afade=t=out" not in result
|
||||
|
||||
def test_trim_start_nonzero(self):
|
||||
"""从音频中间开始截取."""
|
||||
result = build_track_filter_chain(
|
||||
volume=1.0,
|
||||
fade_in=0,
|
||||
fade_out=0,
|
||||
effective_start=0,
|
||||
need_duration=5,
|
||||
trim_start=3.0,
|
||||
target_duration=60,
|
||||
)
|
||||
assert "atrim=3.000:8.000" in result # 3.0 to 3.0+5.0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# amix 滤镜测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildAmixFilter:
|
||||
"""amix 滤镜构建测试."""
|
||||
|
||||
def test_two_inputs(self):
|
||||
"""两路输入."""
|
||||
result = build_amix_filter(2)
|
||||
assert "amix=inputs=2" in result
|
||||
assert "duration=longest" in result
|
||||
|
||||
def test_five_inputs(self):
|
||||
"""五路输入."""
|
||||
result = build_amix_filter(5)
|
||||
assert "amix=inputs=5" in result
|
||||
|
||||
def test_zero_inputs(self):
|
||||
"""零输入."""
|
||||
assert build_amix_filter(0) == ""
|
||||
|
||||
def test_duration_shortest(self):
|
||||
"""shortest 模式."""
|
||||
result = build_amix_filter(3, "shortest")
|
||||
assert "duration=shortest" in result
|
||||
|
||||
def test_invalid_duration_mode(self):
|
||||
"""无效模式,默认 longest."""
|
||||
result = build_amix_filter(3, "invalid")
|
||||
assert "duration=longest" in result
|
||||
|
||||
|
||||
class TestCalculateAmixVolumeCompensation:
|
||||
"""音量补偿计算测试."""
|
||||
|
||||
def test_single_track(self):
|
||||
"""单轨,无需补偿."""
|
||||
assert calculate_amix_volume_compensation(1) == 1.0
|
||||
|
||||
def test_two_tracks(self):
|
||||
"""两轨,补偿 2x."""
|
||||
assert calculate_amix_volume_compensation(2) == 2.0
|
||||
|
||||
def test_five_tracks(self):
|
||||
"""五轨,补偿 5x."""
|
||||
assert calculate_amix_volume_compensation(5) == 5.0
|
||||
|
||||
def test_zero_tracks(self):
|
||||
"""零轨,返回 1."""
|
||||
assert calculate_amix_volume_compensation(0) == 1.0
|
||||
|
||||
|
||||
class TestBuildMixFilterComplex:
|
||||
"""完整混音滤镜测试."""
|
||||
|
||||
def test_with_main_and_two_tracks(self):
|
||||
"""主音频 + 2 条轨道."""
|
||||
result = build_mix_filter_complex(2, has_main=True)
|
||||
assert "[0:a][1:a][2:a]" in result # 3 路输入
|
||||
assert "amix=inputs=3" in result
|
||||
assert "volume=3" in result # 3x 补偿
|
||||
assert "[mixed]" in result
|
||||
|
||||
def test_no_main_three_tracks(self):
|
||||
"""无主音频,3 条轨道."""
|
||||
result = build_mix_filter_complex(3, has_main=False)
|
||||
assert "[0:a][1:a][2:a]" in result
|
||||
assert "amix=inputs=3" in result
|
||||
assert "[mixed]" in result
|
||||
|
||||
def test_zero_tracks_no_main(self):
|
||||
"""无轨道无主音频."""
|
||||
assert build_mix_filter_complex(0, has_main=False) == ""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 音量计算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeVolume:
|
||||
"""音量规范化测试."""
|
||||
|
||||
def test_normal_volume(self):
|
||||
"""正常音量."""
|
||||
assert normalize_volume(0.5) == 0.5
|
||||
|
||||
def test_none_default(self):
|
||||
"""None 默认 1.0."""
|
||||
assert normalize_volume(None) == 1.0
|
||||
|
||||
def test_below_zero_clamped(self):
|
||||
"""负值钳制到 0."""
|
||||
assert normalize_volume(-5) == 0.0
|
||||
|
||||
def test_above_max_clamped(self):
|
||||
"""超过上限钳制."""
|
||||
assert normalize_volume(3.0) == 2.0
|
||||
|
||||
def test_string_input(self):
|
||||
"""字符串输入."""
|
||||
assert normalize_volume("0.5") == 0.5
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串默认 1.0."""
|
||||
assert normalize_volume("abc") == 1.0
|
||||
|
||||
|
||||
class TestDbConversion:
|
||||
"""dB 转换测试."""
|
||||
|
||||
def test_0_db_is_unity(self):
|
||||
"""0 dB = 1.0."""
|
||||
assert db_to_linear(0) == pytest.approx(1.0)
|
||||
|
||||
def test_negative_db(self):
|
||||
"""负 dB < 1."""
|
||||
assert db_to_linear(-6) == pytest.approx(0.5, rel=0.01)
|
||||
|
||||
def test_positive_db(self):
|
||||
"""正 dB > 1."""
|
||||
assert db_to_linear(6) == pytest.approx(2.0, rel=0.01)
|
||||
|
||||
def test_round_trip(self):
|
||||
"""往返转换."""
|
||||
original = 0.5
|
||||
db = linear_to_db(original)
|
||||
result = db_to_linear(db)
|
||||
assert result == pytest.approx(original)
|
||||
|
||||
def test_zero_linear_is_negative_inf(self):
|
||||
"""零线性值 = -inf dB."""
|
||||
assert math.isinf(linear_to_db(0))
|
||||
assert linear_to_db(0) < 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 轨道排序与过滤测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSortTracksByPriority:
|
||||
"""轨道优先级排序测试."""
|
||||
|
||||
def test_sorted_by_priority(self):
|
||||
"""按优先级排序."""
|
||||
tracks = [
|
||||
{"priority": 10, "name": "high"},
|
||||
{"priority": 1, "name": "highest"},
|
||||
{"priority": 100, "name": "low"},
|
||||
]
|
||||
result = sort_tracks_by_priority(tracks)
|
||||
assert result[0]["name"] == "highest"
|
||||
assert result[1]["name"] == "high"
|
||||
assert result[2]["name"] == "low"
|
||||
|
||||
def test_default_priority_100(self):
|
||||
"""默认优先级 100."""
|
||||
tracks = [
|
||||
{"priority": 50, "name": "mid"},
|
||||
{"name": "default"},
|
||||
]
|
||||
result = sort_tracks_by_priority(tracks)
|
||||
assert result[0]["name"] == "mid"
|
||||
assert result[1]["name"] == "default"
|
||||
|
||||
def test_same_preserves_order(self):
|
||||
"""同优先级保持顺序."""
|
||||
tracks = [
|
||||
{"priority": 10, "name": "first"},
|
||||
{"priority": 10, "name": "second"},
|
||||
]
|
||||
result = sort_tracks_by_priority(tracks)
|
||||
assert result[0]["name"] == "first"
|
||||
assert result[1]["name"] == "second"
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert sort_tracks_by_priority([]) == []
|
||||
|
||||
|
||||
class TestFilterEnabledTracks:
|
||||
"""启用轨道过滤测试."""
|
||||
|
||||
def test_all_enabled(self):
|
||||
"""全部启用."""
|
||||
tracks = [{"enabled": True}, {"enabled": True}]
|
||||
assert len(filter_enabled_tracks(tracks)) == 2
|
||||
|
||||
def test_mixed(self):
|
||||
"""混合."""
|
||||
tracks = [
|
||||
{"enabled": True, "name": "a"},
|
||||
{"enabled": False, "name": "b"},
|
||||
]
|
||||
result = filter_enabled_tracks(tracks)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "a"
|
||||
|
||||
def test_default_enabled(self):
|
||||
"""默认启用."""
|
||||
tracks = [{"name": "a"}]
|
||||
assert len(filter_enabled_tracks(tracks)) == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert filter_enabled_tracks([]) == []
|
||||
|
||||
|
||||
class TestCountTrackTypes:
|
||||
"""轨道类型统计测试."""
|
||||
|
||||
def test_mixed_types(self):
|
||||
"""混合类型."""
|
||||
tracks = [
|
||||
{"track_type": "bgm"},
|
||||
{"track_type": "voiceover"},
|
||||
{"track_type": "bgm"},
|
||||
{"track_type": "sfx"},
|
||||
]
|
||||
counts = count_track_types(tracks)
|
||||
assert counts["bgm"] == 2
|
||||
assert counts["voiceover"] == 1
|
||||
assert counts["sfx"] == 1
|
||||
|
||||
def test_default_type(self):
|
||||
"""默认类型."""
|
||||
tracks = [{}]
|
||||
counts = count_track_types(tracks)
|
||||
assert counts["unknown"] == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert count_track_types([]) == {}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 配置验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateAudioTrack:
|
||||
"""单轨验证测试."""
|
||||
|
||||
def test_valid_track(self):
|
||||
"""合法轨道."""
|
||||
ok, errors = validate_audio_track(
|
||||
{
|
||||
"audio_path": "/audio.mp3",
|
||||
"volume": 0.8,
|
||||
"fade_in": 1.0,
|
||||
"fade_out": 2.0,
|
||||
}
|
||||
)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_missing_path(self):
|
||||
"""缺路径."""
|
||||
ok, errors = validate_audio_track({})
|
||||
assert ok is False
|
||||
assert any("audio_path" in e or "asset_id" in e for e in errors)
|
||||
|
||||
def test_negative_volume(self):
|
||||
"""负音量."""
|
||||
ok, errors = validate_audio_track(
|
||||
{
|
||||
"audio_path": "/a.mp3",
|
||||
"volume": -1,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("volume" in e for e in errors)
|
||||
|
||||
def test_negative_fade_in(self):
|
||||
"""负淡入."""
|
||||
ok, errors = validate_audio_track(
|
||||
{
|
||||
"audio_path": "/a.mp3",
|
||||
"fade_in": -1,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("fade_in" in e for e in errors)
|
||||
|
||||
def test_negative_fade_out(self):
|
||||
"""负淡出."""
|
||||
ok, errors = validate_audio_track(
|
||||
{
|
||||
"audio_path": "/a.mp3",
|
||||
"fade_out": -1,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("fade_out" in e for e in errors)
|
||||
|
||||
def test_invalid_volume_type(self):
|
||||
"""无效音量类型."""
|
||||
ok, errors = validate_audio_track(
|
||||
{
|
||||
"audio_path": "/a.mp3",
|
||||
"volume": "loud",
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("volume" in e for e in errors)
|
||||
|
||||
def test_with_asset_id(self):
|
||||
"""有 asset_id 无 audio_path 也合法."""
|
||||
ok, errors = validate_audio_track({"asset_id": "123"})
|
||||
assert ok is True
|
||||
|
||||
|
||||
class TestValidateMixConfig:
|
||||
"""混音配置验证测试."""
|
||||
|
||||
def test_valid_config(self):
|
||||
"""合法配置."""
|
||||
ok, errors = validate_mix_config(
|
||||
{
|
||||
"tracks": [
|
||||
{"audio_path": "/a.mp3", "volume": 0.5},
|
||||
{"audio_path": "/b.mp3", "volume": 0.8},
|
||||
],
|
||||
"target_duration": 60,
|
||||
}
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
def test_empty_tracks(self):
|
||||
"""空轨道列表."""
|
||||
ok, errors = validate_mix_config({"tracks": []})
|
||||
assert ok is False
|
||||
assert any("至少需要" in e for e in errors)
|
||||
|
||||
def test_invalid_track(self):
|
||||
"""无效轨道."""
|
||||
ok, errors = validate_mix_config(
|
||||
{
|
||||
"tracks": [
|
||||
{"audio_path": "/a.mp3"},
|
||||
{}, # 无效
|
||||
],
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert len(errors) >= 1
|
||||
|
||||
def test_negative_target_duration(self):
|
||||
"""负目标时长."""
|
||||
ok, errors = validate_mix_config(
|
||||
{
|
||||
"tracks": [{"audio_path": "/a.mp3"}],
|
||||
"target_duration": -10,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("target_duration" in e for e in errors)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 工具函数测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateTotalTracks:
|
||||
"""总轨道数计算测试."""
|
||||
|
||||
def test_with_main(self):
|
||||
"""含主音频."""
|
||||
assert calculate_total_tracks({"tracks": [1, 2, 3]}) == 4
|
||||
|
||||
def test_without_main(self):
|
||||
"""不含主音频."""
|
||||
assert (
|
||||
calculate_total_tracks(
|
||||
{
|
||||
"tracks": [1, 2],
|
||||
"has_main_audio": False,
|
||||
}
|
||||
)
|
||||
== 2
|
||||
)
|
||||
|
||||
def test_empty_tracks_with_main(self):
|
||||
"""无轨道,只有主音频."""
|
||||
assert calculate_total_tracks({"tracks": []}) == 1
|
||||
|
||||
|
||||
class TestEstimateMixDuration:
|
||||
"""混音时长估算测试."""
|
||||
|
||||
def test_multiple_tracks(self):
|
||||
"""多轨道取最长结束时间."""
|
||||
tracks = [
|
||||
{"start_time": 0, "duration": 10},
|
||||
{"start_time": 5, "duration": 20}, # 结束 25
|
||||
{"start_time": 2, "duration": 5},
|
||||
]
|
||||
assert estimate_mix_duration(tracks) == pytest.approx(25.0)
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert estimate_mix_duration([]) == 0.0
|
||||
|
||||
def test_zero_duration_tracks_ignored(self):
|
||||
"""零时长轨道忽略."""
|
||||
tracks = [
|
||||
{"start_time": 0, "duration": 0},
|
||||
{"start_time": 5, "duration": 10},
|
||||
]
|
||||
assert estimate_mix_duration(tracks) == pytest.approx(15.0)
|
||||
Executable
+966
@@ -0,0 +1,966 @@
|
||||
"""PiP Engine 纯逻辑单测.
|
||||
|
||||
测试 pip_engine_pure.py 中的所有纯函数,
|
||||
0 FFmpeg 依赖,快速轻量。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from video_processing.pip_engine_pure import (
|
||||
build_animation_filters,
|
||||
build_enable_expr,
|
||||
build_overlay_expr,
|
||||
build_pip_filters,
|
||||
build_pip_pre_filter,
|
||||
compute_pip_position,
|
||||
compute_pip_size,
|
||||
count_visible_layers,
|
||||
sort_layers_by_z_index,
|
||||
validate_pip_layer,
|
||||
)
|
||||
|
||||
from packages.domain.pip_config import (
|
||||
ANIMATION_FADE,
|
||||
ANIMATION_SLIDE_BOTTOM,
|
||||
ANIMATION_SLIDE_LEFT,
|
||||
ANIMATION_SLIDE_RIGHT,
|
||||
ANIMATION_SLIDE_TOP,
|
||||
PiPLayerConfig,
|
||||
)
|
||||
|
||||
# ── 常量与工具 ────────────────────────────────────────────────────────────────
|
||||
|
||||
OUTPUT_W = 1080
|
||||
OUTPUT_H = 1920
|
||||
|
||||
|
||||
def _make_layer(**kwargs) -> PiPLayerConfig:
|
||||
"""快速创建图层配置."""
|
||||
defaults = dict(
|
||||
source_type="local_path",
|
||||
source="/tmp/test.mp4",
|
||||
width="25%",
|
||||
height=None,
|
||||
position="bottom_right",
|
||||
margin=20,
|
||||
opacity=1.0,
|
||||
corner_radius=0,
|
||||
border_width=0,
|
||||
border_color="black",
|
||||
z_index=0,
|
||||
start_time=0.0,
|
||||
duration=None,
|
||||
animation_in=None,
|
||||
animation_out=None,
|
||||
animation_duration=0.5,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return PiPLayerConfig(**defaults)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# compute_pip_size
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestComputePipSize:
|
||||
"""尺寸计算测试."""
|
||||
|
||||
def test_percentage_width_auto_height(self):
|
||||
"""百分比宽度,自动高度(16:9)."""
|
||||
layer = _make_layer(width="25%")
|
||||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||||
assert w == 270 # 1080 * 25%
|
||||
assert h == 151 # 270 * 9 / 16 = 151.875 → 151
|
||||
|
||||
def test_pixel_width_and_height(self):
|
||||
"""像素宽高."""
|
||||
layer = _make_layer(width=300, height=200)
|
||||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||||
assert w == 300
|
||||
assert h == 200
|
||||
|
||||
def test_pixel_width_percent_height(self):
|
||||
"""像素宽 + 百分比高."""
|
||||
layer = _make_layer(width=200, height="10%")
|
||||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||||
assert w == 200
|
||||
assert h == 192 # 1920 * 10%
|
||||
|
||||
def test_full_width_clamped(self):
|
||||
"""超过输出尺寸时钳制到输出范围内."""
|
||||
layer = _make_layer(width="200%")
|
||||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||||
assert w == OUTPUT_W
|
||||
assert h <= OUTPUT_H # 按比例后高度不超过输出
|
||||
|
||||
def test_zero_width_minimum(self):
|
||||
"""极小尺寸钳制到至少 1 像素."""
|
||||
layer = _make_layer(width="0%")
|
||||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||||
assert w >= 1
|
||||
assert h >= 1
|
||||
|
||||
def test_pixel_int_width(self):
|
||||
"""整数像素宽度."""
|
||||
layer = _make_layer(width=500, height=300)
|
||||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||||
assert w == 500
|
||||
assert h == 300
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# compute_pip_position
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestComputePipPosition:
|
||||
"""位置计算测试."""
|
||||
|
||||
def test_bottom_right(self):
|
||||
"""右下角位置."""
|
||||
layer = _make_layer(position="bottom_right", margin=20)
|
||||
pip_w, pip_h = 200, 150
|
||||
x, y = compute_pip_position(layer, pip_w, pip_h, OUTPUT_W, OUTPUT_H)
|
||||
assert x == OUTPUT_W - pip_w - 20
|
||||
assert y == OUTPUT_H - pip_h - 20
|
||||
|
||||
def test_top_left(self):
|
||||
"""左上角."""
|
||||
layer = _make_layer(position="top_left", margin=10)
|
||||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
assert x == 10
|
||||
assert y == 10
|
||||
|
||||
def test_top_center(self):
|
||||
"""顶部居中."""
|
||||
layer = _make_layer(position="top_center", margin=20)
|
||||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
assert x == (OUTPUT_W - 200) // 2
|
||||
assert y == 20
|
||||
|
||||
def test_center(self):
|
||||
"""正中心."""
|
||||
layer = _make_layer(position="center")
|
||||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
assert x == (OUTPUT_W - 200) // 2
|
||||
assert y == (OUTPUT_H - 150) // 2
|
||||
|
||||
def test_custom_position(self):
|
||||
"""自定义坐标."""
|
||||
layer = _make_layer(position="custom", x=100, y=200)
|
||||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
assert x == 100
|
||||
assert y == 200
|
||||
|
||||
def test_margin_effect(self):
|
||||
"""不同 margin 值影响位置."""
|
||||
layer1 = _make_layer(position="bottom_right", margin=0)
|
||||
layer2 = _make_layer(position="bottom_right", margin=50)
|
||||
x1, y1 = compute_pip_position(layer1, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
x2, y2 = compute_pip_position(layer2, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
assert x1 > x2
|
||||
assert y1 > y2
|
||||
|
||||
def test_clamped_when_outside(self):
|
||||
"""自定义坐标超出画面时钳制到边界内."""
|
||||
layer = _make_layer(position="custom", x=-50, y=99999)
|
||||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
assert x >= 0
|
||||
assert x <= OUTPUT_W - 200
|
||||
assert y >= 0
|
||||
assert y == OUTPUT_H - 150 # y 超出底部,钳制到底部
|
||||
|
||||
def test_bottom_center(self):
|
||||
"""底部居中."""
|
||||
layer = _make_layer(position="bottom_center", margin=30)
|
||||
x, y = compute_pip_position(layer, 300, 200, OUTPUT_W, OUTPUT_H)
|
||||
assert x == (OUTPUT_W - 300) // 2
|
||||
assert y == OUTPUT_H - 200 - 30
|
||||
|
||||
def test_center_left(self):
|
||||
"""左侧居中."""
|
||||
layer = _make_layer(position="center_left", margin=15)
|
||||
x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == 15
|
||||
assert y == (OUTPUT_H - 100) // 2
|
||||
|
||||
def test_center_right(self):
|
||||
"""右侧居中."""
|
||||
layer = _make_layer(position="center_right", margin=15)
|
||||
x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == OUTPUT_W - 150 - 15
|
||||
assert y == (OUTPUT_H - 100) // 2
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_pip_pre_filter
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildPipPreFilter:
|
||||
"""预处理滤镜构建测试."""
|
||||
|
||||
def test_basic_scale_setsar(self):
|
||||
"""基础:scale + setsar."""
|
||||
layer = _make_layer()
|
||||
result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0")
|
||||
assert result.startswith("[1:v]")
|
||||
assert "scale=200:150" in result
|
||||
assert "setsar=1" in result
|
||||
assert result.endswith("[pip_pre_0]")
|
||||
|
||||
def test_corner_radius_filter(self):
|
||||
"""圆角裁剪滤镜."""
|
||||
layer = _make_layer(corner_radius=20)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "geq=" in result
|
||||
assert "format=yuva420p" in result
|
||||
# 圆角半径应钳制到 min(r, w//2, h//2)
|
||||
assert "hypot(" in result
|
||||
|
||||
def test_corner_radius_clamped(self):
|
||||
"""圆角半径超过尺寸一半时自动钳制."""
|
||||
layer = _make_layer(corner_radius=1000) # 超大
|
||||
result = build_pip_pre_filter("[0:v]", layer, 100, 80, "pre")
|
||||
# 钳制后 r = min(1000, 50, 40) = 40
|
||||
# 检查 geq 表达式中的 r 值
|
||||
import re
|
||||
|
||||
r_matches = re.findall(r"lt\(X,(\d+)\)\*lt\(Y,\1\)", result)
|
||||
assert r_matches
|
||||
assert int(r_matches[0]) <= 50 # 不超过宽的一半
|
||||
|
||||
def test_border_filter(self):
|
||||
"""边框滤镜."""
|
||||
layer = _make_layer(border_width=5, border_color="red")
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "pad=210:160:5:5:red" in result
|
||||
|
||||
def test_zero_border_no_pad(self):
|
||||
"""border_width=0 时不加 pad."""
|
||||
layer = _make_layer(border_width=0)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "pad=" not in result
|
||||
|
||||
def test_opacity_filter(self):
|
||||
"""透明度滤镜."""
|
||||
layer = _make_layer(opacity=0.5)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "colorchannelmixer=aa=0.5" in result
|
||||
assert "format=yuva420p" in result
|
||||
|
||||
def test_full_opacity_no_alpha(self):
|
||||
"""opacity=1.0 时不加透明度滤镜."""
|
||||
layer = _make_layer(opacity=1.0)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "colorchannelmixer" not in result
|
||||
|
||||
def test_opacity_clamped_high(self):
|
||||
"""opacity > 1.0 时钳制."""
|
||||
layer = _make_layer(opacity=2.0)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
# 钳制到 1.0,不加透明度滤镜
|
||||
assert "colorchannelmixer=aa=1" not in result
|
||||
assert "colorchannelmixer" not in result
|
||||
|
||||
def test_opacity_clamped_low(self):
|
||||
"""opacity < 0 时钳制到 0."""
|
||||
layer = _make_layer(opacity=-0.5)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "colorchannelmixer=aa=0.0" in result
|
||||
|
||||
def test_fade_in_animation(self):
|
||||
"""淡入动画."""
|
||||
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.3)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "fade=t=in:st=0:d=0.3:alpha=1" in result
|
||||
|
||||
def test_fade_out_animation(self):
|
||||
"""淡出动画(需要 duration)."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_FADE,
|
||||
animation_duration=0.5,
|
||||
duration=5.0,
|
||||
)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "fade=t=out:st=4.5:d=0.5:alpha=1" in result
|
||||
|
||||
def test_fade_out_no_duration(self):
|
||||
"""淡出无 duration 时不加."""
|
||||
layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5, duration=None)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "fade=t=out" not in result
|
||||
|
||||
def test_combined_effects(self):
|
||||
"""多个效果组合:圆角 + 边框 + 透明度."""
|
||||
layer = _make_layer(
|
||||
corner_radius=15,
|
||||
border_width=3,
|
||||
border_color="white",
|
||||
opacity=0.8,
|
||||
)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 300, 200, "pre")
|
||||
assert "geq=" in result # 圆角
|
||||
assert "pad=306:206:3:3:white" in result # 边框
|
||||
assert "colorchannelmixer=aa=0.8" in result # 透明度
|
||||
|
||||
def test_output_label(self):
|
||||
"""输出标签正确."""
|
||||
layer = _make_layer()
|
||||
result = build_pip_pre_filter("[2:v]", layer, 100, 80, "my_label")
|
||||
assert result.endswith("[my_label]")
|
||||
|
||||
def test_input_label(self):
|
||||
"""输入标签正确."""
|
||||
layer = _make_layer()
|
||||
result = build_pip_pre_filter("[5:v]", layer, 100, 80, "out")
|
||||
assert result.startswith("[5:v]")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_animation_filters
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildAnimationFilters:
|
||||
"""动画滤镜构建测试."""
|
||||
|
||||
def test_no_animation(self):
|
||||
"""无动画返回空列表."""
|
||||
layer = _make_layer()
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert result == []
|
||||
|
||||
def test_fade_in_only(self):
|
||||
"""仅淡入."""
|
||||
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.5)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert len(result) == 1
|
||||
assert "fade=t=in" in result[0]
|
||||
|
||||
def test_fade_out_with_duration(self):
|
||||
"""淡出(有 duration)."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_FADE,
|
||||
animation_duration=0.3,
|
||||
duration=10.0,
|
||||
)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert len(result) == 1
|
||||
assert "fade=t=out:st=9.7:d=0.3" in result[0]
|
||||
|
||||
def test_fade_out_no_duration_skipped(self):
|
||||
"""淡出无 duration 时跳过."""
|
||||
layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert result == []
|
||||
|
||||
def test_fade_in_and_out(self):
|
||||
"""淡入 + 淡出."""
|
||||
layer = _make_layer(
|
||||
animation_in=ANIMATION_FADE,
|
||||
animation_out=ANIMATION_FADE,
|
||||
animation_duration=0.5,
|
||||
duration=3.0,
|
||||
)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert len(result) == 2
|
||||
assert any("fade=t=in" in f for f in result)
|
||||
assert any("fade=t=out" in f for f in result)
|
||||
|
||||
def test_slide_in_not_here(self):
|
||||
"""slide 动画不在此函数处理."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert result == []
|
||||
|
||||
def test_zero_duration_no_animation(self):
|
||||
"""动画时长为 0 时不加."""
|
||||
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert result == []
|
||||
|
||||
def test_negative_duration_clamped(self):
|
||||
"""负动画时长钳制为 0."""
|
||||
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=-1)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert result == []
|
||||
|
||||
def test_fade_out_start_clamped_to_zero(self):
|
||||
"""淡出开始时间不为负."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_FADE,
|
||||
animation_duration=2.0,
|
||||
duration=1.0, # 比动画时长短
|
||||
)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert len(result) == 1
|
||||
# start = max(0, 1.0 - 2.0) = 0
|
||||
assert "st=0.0:d=2.0" in result[0]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_overlay_expr
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildOverlayExpr:
|
||||
"""overlay 表达式构建测试."""
|
||||
|
||||
def test_no_animation_static_position(self):
|
||||
"""无动画时返回静态坐标."""
|
||||
layer = _make_layer()
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert y == "200"
|
||||
|
||||
def test_slide_in_from_left(self):
|
||||
"""从左侧滑入."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert "if(lt(t,0.5)" in x
|
||||
assert "-150" in x # 起始位置 = -pip_width
|
||||
assert y == "200" # y 不变
|
||||
|
||||
def test_slide_in_from_right(self):
|
||||
"""从右侧滑入."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_RIGHT, animation_duration=0.5)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert str(OUTPUT_W) in x
|
||||
assert y == "200"
|
||||
|
||||
def test_slide_in_from_top(self):
|
||||
"""从顶部滑入."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_TOP, animation_duration=0.3)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert "if(lt(t,0.3)" in y
|
||||
assert "-100" in y
|
||||
|
||||
def test_slide_in_from_bottom(self):
|
||||
"""从底部滑入."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_BOTTOM, animation_duration=0.3)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert str(OUTPUT_H) in y
|
||||
|
||||
def test_slide_out_to_left(self):
|
||||
"""向左滑出."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_SLIDE_LEFT,
|
||||
animation_duration=0.5,
|
||||
duration=3.0,
|
||||
)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert "gt(t,2.5)" in x
|
||||
assert y == "200"
|
||||
|
||||
def test_slide_out_to_right(self):
|
||||
"""向右滑出."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_SLIDE_RIGHT,
|
||||
animation_duration=0.5,
|
||||
duration=3.0,
|
||||
)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert "gt(t,2.5)" in x
|
||||
assert y == "200"
|
||||
# 向右滑出:结束时 x > base_x(值变大)
|
||||
# 检查表达式中含增大方向的计算
|
||||
assert "+(t-2.5)/0.5*" in x
|
||||
|
||||
def test_slide_out_to_top(self):
|
||||
"""向上滑出."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_SLIDE_TOP,
|
||||
animation_duration=0.5,
|
||||
duration=5.0,
|
||||
)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert "gt(t,4.5)" in y
|
||||
|
||||
def test_slide_out_to_bottom(self):
|
||||
"""向下滑出."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_SLIDE_BOTTOM,
|
||||
animation_duration=0.5,
|
||||
duration=5.0,
|
||||
)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert "gt(t,4.5)" in y
|
||||
# 向下滑出:y 值增大
|
||||
assert "+(t-4.5)/0.5*" in y
|
||||
|
||||
def test_slide_in_and_out_different_axes(self):
|
||||
"""滑入(x方向) + 滑出(y方向),两个轴都有动画."""
|
||||
layer = _make_layer(
|
||||
animation_in=ANIMATION_SLIDE_LEFT,
|
||||
animation_out=ANIMATION_SLIDE_BOTTOM,
|
||||
animation_duration=0.5,
|
||||
duration=4.0,
|
||||
)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert "lt(t,0.5)" in x # x 方向入场
|
||||
assert "gt(t,3.5)" in y # y 方向出场
|
||||
|
||||
def test_zero_animation_duration_no_effect(self):
|
||||
"""动画时长为 0 时无效果."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert y == "200"
|
||||
|
||||
def test_no_duration_skip_outro(self):
|
||||
"""无 duration 时跳过滑出."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_SLIDE_LEFT,
|
||||
animation_duration=0.5,
|
||||
duration=None,
|
||||
)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert y == "200"
|
||||
|
||||
def test_expression_format_quoted(self):
|
||||
"""有动画时表达式带单引号."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
|
||||
x, _ = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x.startswith("'")
|
||||
assert x.endswith("'")
|
||||
|
||||
def test_static_position_unquoted(self):
|
||||
"""无动画时纯数字,不带引号."""
|
||||
layer = _make_layer()
|
||||
x, y = build_overlay_expr(layer, 50, 60, 100, 80, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "50"
|
||||
assert y == "60"
|
||||
assert "'" not in x
|
||||
assert "'" not in y
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_enable_expr
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildEnableExpr:
|
||||
"""enable 表达式构建测试."""
|
||||
|
||||
def test_no_time_restriction(self):
|
||||
"""无时间限制返回空."""
|
||||
layer = _make_layer()
|
||||
assert build_enable_expr(layer) == ""
|
||||
|
||||
def test_start_time_only(self):
|
||||
"""只有开始时间."""
|
||||
layer = _make_layer(start_time=5.0)
|
||||
result = build_enable_expr(layer)
|
||||
assert result == ":enable='gte(t,5.0)'"
|
||||
|
||||
def test_duration_only(self):
|
||||
"""只有 duration(从 0 开始)."""
|
||||
layer = _make_layer(duration=10.0)
|
||||
result = build_enable_expr(layer)
|
||||
assert result == ":enable='between(t,0.0,10.0)'"
|
||||
|
||||
def test_start_and_duration(self):
|
||||
"""开始时间 + 时长."""
|
||||
layer = _make_layer(start_time=2.0, duration=5.0)
|
||||
result = build_enable_expr(layer)
|
||||
assert "between(t,2.0,7.0)" in result
|
||||
|
||||
def test_zero_start_with_duration(self):
|
||||
"""0 开始 + 时长."""
|
||||
layer = _make_layer(start_time=0, duration=3.5)
|
||||
result = build_enable_expr(layer)
|
||||
assert "between(t,0.0,3.5)" in result
|
||||
|
||||
def test_negative_start_clamped(self):
|
||||
"""负开始时间钳制为 0."""
|
||||
layer = _make_layer(start_time=-1.0, duration=5.0)
|
||||
result = build_enable_expr(layer)
|
||||
assert "between(t,0.0,5.0)" in result
|
||||
|
||||
def test_none_duration(self):
|
||||
"""duration=None 视为无限."""
|
||||
layer = _make_layer(start_time=3.0, duration=None)
|
||||
result = build_enable_expr(layer)
|
||||
assert "gte(t,3.0)" in result
|
||||
assert "between" not in result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_pip_filters
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildPipFilters:
|
||||
"""完整滤镜链构建测试."""
|
||||
|
||||
def test_empty_layers(self):
|
||||
"""空图层列表返回空."""
|
||||
filters, inputs, label = build_pip_filters(
|
||||
"base",
|
||||
[],
|
||||
[],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
assert filters == []
|
||||
assert inputs == []
|
||||
assert label == "base"
|
||||
|
||||
def test_single_layer(self):
|
||||
"""单个图层."""
|
||||
layer = _make_layer(width="20%", position="bottom_right")
|
||||
path = Path("/tmp/clip1.mp4")
|
||||
|
||||
filters, inputs, label = build_pip_filters(
|
||||
"v0",
|
||||
[layer],
|
||||
[path],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
|
||||
# 2 个滤镜片段:预处理 + overlay
|
||||
assert len(filters) == 2
|
||||
# 1 个输入
|
||||
assert inputs == ["-i", str(path)]
|
||||
# 最终标签
|
||||
assert label == "pip_combined_0"
|
||||
|
||||
def test_multiple_layers(self):
|
||||
"""多个图层."""
|
||||
layers = [
|
||||
_make_layer(width="30%", position="bottom_left"),
|
||||
_make_layer(width="25%", position="top_right"),
|
||||
_make_layer(width="20%", position="top_left"),
|
||||
]
|
||||
paths = [Path("/tmp/a.mp4"), Path("/tmp/b.mp4"), Path("/tmp/c.mp4")]
|
||||
|
||||
filters, inputs, label = build_pip_filters(
|
||||
"base",
|
||||
layers,
|
||||
paths,
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
|
||||
# 每个图层 2 个滤镜(预处理 + overlay)
|
||||
assert len(filters) == 6
|
||||
# 3 个输入
|
||||
assert len(inputs) == 6 # -i path × 3
|
||||
assert inputs[0::2] == ["-i", "-i", "-i"]
|
||||
# 最终标签是最后一个 combined
|
||||
assert label == "pip_combined_2"
|
||||
|
||||
def test_base_input_idx_offset(self):
|
||||
"""base_input_idx 偏移."""
|
||||
layer = _make_layer(width="20%")
|
||||
filters, inputs, label = build_pip_filters(
|
||||
"base",
|
||||
[layer],
|
||||
[Path("/tmp/x.mp4")],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
base_input_idx=5,
|
||||
)
|
||||
# 预处理滤镜引用 [5:v]
|
||||
assert "[5:v]" in filters[0]
|
||||
|
||||
def test_layer_count_mismatch_raises(self):
|
||||
"""图层和路径数量不一致时报错."""
|
||||
with pytest.raises(ValueError, match="长度不一致"):
|
||||
build_pip_filters(
|
||||
"base",
|
||||
[_make_layer()],
|
||||
[],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
|
||||
def test_filter_chaining(self):
|
||||
"""多图层时滤镜链正确串联."""
|
||||
layers = [_make_layer(width="10%"), _make_layer(width="10%")]
|
||||
paths = [Path("/tmp/1.mp4"), Path("/tmp/2.mp4")]
|
||||
|
||||
filters, _, _ = build_pip_filters(
|
||||
"base",
|
||||
layers,
|
||||
paths,
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
|
||||
# 第一个 overlay 的输入是 base + pip_pre_0
|
||||
# 输出是 pip_combined_0
|
||||
assert "[base]" in filters[1]
|
||||
assert "[pip_combined_0]" in filters[1]
|
||||
|
||||
# 第二个 overlay 的输入是 pip_combined_0 + pip_pre_1
|
||||
# 输出是 pip_combined_1
|
||||
assert "[pip_combined_0]" in filters[3]
|
||||
assert "[pip_combined_1]" in filters[3]
|
||||
|
||||
def test_with_animation_layer(self):
|
||||
"""带动画的图层生成正确表达式."""
|
||||
layer = _make_layer(
|
||||
width="30%",
|
||||
animation_in=ANIMATION_SLIDE_BOTTOM,
|
||||
animation_duration=0.5,
|
||||
)
|
||||
filters, inputs, _ = build_pip_filters(
|
||||
"v0",
|
||||
[layer],
|
||||
[Path("/tmp/a.mp4")],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
# overlay 滤镜中包含滑动表达式
|
||||
overlay_filter = filters[1]
|
||||
assert "overlay=" in overlay_filter
|
||||
assert str(OUTPUT_H) in overlay_filter # 从底部滑入
|
||||
|
||||
def test_with_enable_time(self):
|
||||
"""带时间控制的图层."""
|
||||
layer = _make_layer(width="20%", start_time=2.0, duration=5.0)
|
||||
filters, _, _ = build_pip_filters(
|
||||
"v0",
|
||||
[layer],
|
||||
[Path("/tmp/a.mp4")],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
overlay_filter = filters[1]
|
||||
assert "enable=" in overlay_filter
|
||||
assert "between" in overlay_filter
|
||||
|
||||
def test_string_paths(self):
|
||||
"""路径可以是字符串."""
|
||||
layer = _make_layer(width="10%")
|
||||
filters, inputs, label = build_pip_filters(
|
||||
"v0",
|
||||
[layer],
|
||||
["/tmp/s.mp4"],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
assert inputs == ["-i", "/tmp/s.mp4"]
|
||||
assert len(filters) == 2
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# validate_pip_layer
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestValidatePipLayer:
|
||||
"""配置验证测试."""
|
||||
|
||||
def test_valid_layer(self):
|
||||
"""合法配置."""
|
||||
layer = _make_layer()
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
|
||||
def test_empty_source_type(self):
|
||||
"""空 source_type."""
|
||||
layer = _make_layer(source_type="")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "source_type" in err
|
||||
|
||||
def test_invalid_source_type(self):
|
||||
"""不支持的 source_type."""
|
||||
layer = _make_layer(source_type="ftp")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "source_type" in err
|
||||
|
||||
def test_empty_source(self):
|
||||
"""空 source."""
|
||||
layer = _make_layer(source="")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "source" in err
|
||||
|
||||
def test_invalid_position(self):
|
||||
"""不支持的 position."""
|
||||
layer = _make_layer(position="middle")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "position" in err
|
||||
|
||||
def test_opacity_too_high(self):
|
||||
"""opacity > 1."""
|
||||
layer = _make_layer(opacity=1.5)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "opacity" in err
|
||||
|
||||
def test_opacity_negative(self):
|
||||
"""opacity < 0."""
|
||||
layer = _make_layer(opacity=-0.1)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "opacity" in err
|
||||
|
||||
def test_negative_corner_radius(self):
|
||||
"""负圆角."""
|
||||
layer = _make_layer(corner_radius=-5)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "corner_radius" in err
|
||||
|
||||
def test_negative_border_width(self):
|
||||
"""负边框."""
|
||||
layer = _make_layer(border_width=-2)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "border_width" in err
|
||||
|
||||
def test_negative_start_time(self):
|
||||
"""负开始时间."""
|
||||
layer = _make_layer(start_time=-1.0)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "start_time" in err
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
layer = _make_layer(duration=-5.0)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "duration" in err
|
||||
|
||||
def test_invalid_animation_in(self):
|
||||
"""不支持的入场动画."""
|
||||
layer = _make_layer(animation_in="zoom")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "animation_in" in err
|
||||
|
||||
def test_invalid_animation_out(self):
|
||||
"""不支持的出场动画."""
|
||||
layer = _make_layer(animation_out="spin")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "animation_out" in err
|
||||
|
||||
def test_multiple_errors_combined(self):
|
||||
"""多个错误合并."""
|
||||
layer = _make_layer(source_type="", source="", opacity=2.0, position="xxx")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert err.count(";") >= 2 # 至少 2 个错误
|
||||
|
||||
def test_valid_url_source(self):
|
||||
"""URL 类型 source 合法."""
|
||||
layer = _make_layer(source_type="url", source="https://example.com/v.mp4")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is True
|
||||
|
||||
def test_valid_asset_id(self):
|
||||
"""asset_id 类型合法."""
|
||||
layer = _make_layer(source_type="asset_id", source="asset_123")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is True
|
||||
|
||||
def test_zero_values_valid(self):
|
||||
"""0 值合法(不是负数)."""
|
||||
layer = _make_layer(
|
||||
corner_radius=0,
|
||||
border_width=0,
|
||||
start_time=0,
|
||||
animation_duration=0,
|
||||
)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# count_visible_layers
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCountVisibleLayers:
|
||||
"""可见图层统计测试."""
|
||||
|
||||
def test_all_visible(self):
|
||||
"""全部可见."""
|
||||
layers = [_make_layer(opacity=1.0), _make_layer(opacity=0.5)]
|
||||
assert count_visible_layers(layers) == 2
|
||||
|
||||
def test_all_invisible(self):
|
||||
"""全部不可见."""
|
||||
layers = [_make_layer(opacity=0.0), _make_layer(opacity=0.0)]
|
||||
assert count_visible_layers(layers) == 0
|
||||
|
||||
def test_mixed(self):
|
||||
"""混合."""
|
||||
layers = [
|
||||
_make_layer(opacity=1.0),
|
||||
_make_layer(opacity=0.0),
|
||||
_make_layer(opacity=0.001),
|
||||
]
|
||||
assert count_visible_layers(layers) == 2
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert count_visible_layers([]) == 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# sort_layers_by_z_index
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSortLayersByZIndex:
|
||||
"""图层排序测试."""
|
||||
|
||||
def test_sorted_by_z_index(self):
|
||||
"""按 z_index 从小到大排序."""
|
||||
layers = [
|
||||
_make_layer(z_index=5, source="/tmp/a.mp4"),
|
||||
_make_layer(z_index=1, source="/tmp/b.mp4"),
|
||||
_make_layer(z_index=3, source="/tmp/c.mp4"),
|
||||
]
|
||||
sorted_layers = sort_layers_by_z_index(layers)
|
||||
assert [l.z_index for l in sorted_layers] == [1, 3, 5]
|
||||
|
||||
def test_same_z_index_stable(self):
|
||||
"""相同 z_index 保持相对顺序."""
|
||||
layers = [
|
||||
_make_layer(z_index=2, source="/tmp/1.mp4"),
|
||||
_make_layer(z_index=2, source="/tmp/2.mp4"),
|
||||
]
|
||||
sorted_layers = sort_layers_by_z_index(layers)
|
||||
assert sorted_layers[0].source == "/tmp/1.mp4"
|
||||
assert sorted_layers[1].source == "/tmp/2.mp4"
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert sort_layers_by_z_index([]) == []
|
||||
|
||||
def test_single_layer(self):
|
||||
"""单个图层."""
|
||||
layers = [_make_layer(z_index=0)]
|
||||
assert len(sort_layers_by_z_index(layers)) == 1
|
||||
|
||||
def test_negative_z_index(self):
|
||||
"""负 z_index."""
|
||||
layers = [
|
||||
_make_layer(z_index=0, source="/tmp/0.mp4"),
|
||||
_make_layer(z_index=-5, source="/tmp/-5.mp4"),
|
||||
_make_layer(z_index=3, source="/tmp/3.mp4"),
|
||||
]
|
||||
sorted_layers = sort_layers_by_z_index(layers)
|
||||
assert [l.z_index for l in sorted_layers] == [-5, 0, 3]
|
||||
Executable
+780
@@ -0,0 +1,780 @@
|
||||
"""贴纸引擎纯逻辑单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from video_processing.sticker_engine_pure import (
|
||||
build_drawtext_alpha_expr,
|
||||
build_enable_expr,
|
||||
build_image_fade_filters,
|
||||
build_opacity_filter,
|
||||
build_overlay_position,
|
||||
build_pre_filter_label,
|
||||
build_scale_filter,
|
||||
build_shadow_params,
|
||||
build_stroke_params,
|
||||
calculate_end_time,
|
||||
calculate_fade_out_start,
|
||||
count_sticker_types,
|
||||
escape_drawtext_text,
|
||||
estimate_sticker_size,
|
||||
estimate_text_size,
|
||||
filter_enabled_stickers,
|
||||
has_time_range,
|
||||
safe_bool,
|
||||
safe_float,
|
||||
safe_int,
|
||||
sort_stickers_by_z_index,
|
||||
validate_image_sticker,
|
||||
validate_text_sticker,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 安全类型转换测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSafeFloat:
|
||||
"""safe_float 测试."""
|
||||
|
||||
def test_int_input(self):
|
||||
"""整数输入."""
|
||||
assert safe_float(42) == 42.0
|
||||
|
||||
def test_float_input(self):
|
||||
"""浮点数输入."""
|
||||
assert safe_float(3.14) == 3.14
|
||||
|
||||
def test_string_number(self):
|
||||
"""字符串数字."""
|
||||
assert safe_float("3.14") == 3.14
|
||||
|
||||
def test_string_int(self):
|
||||
"""字符串整数."""
|
||||
assert safe_float("100") == 100.0
|
||||
|
||||
def test_none_input(self):
|
||||
"""None 输入."""
|
||||
assert safe_float(None) is None
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串."""
|
||||
assert safe_float("abc") is None
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串."""
|
||||
assert safe_float("") is None
|
||||
|
||||
def test_zero(self):
|
||||
"""零值."""
|
||||
assert safe_float(0) == 0.0
|
||||
|
||||
def test_negative(self):
|
||||
"""负值."""
|
||||
assert safe_float(-5.5) == -5.5
|
||||
|
||||
|
||||
class TestSafeInt:
|
||||
"""safe_int 测试."""
|
||||
|
||||
def test_int_input(self):
|
||||
"""整数输入."""
|
||||
assert safe_int(42) == 42
|
||||
|
||||
def test_float_input(self):
|
||||
"""浮点数输入(截断)."""
|
||||
assert safe_int(3.7) == 3
|
||||
|
||||
def test_string_number(self):
|
||||
"""字符串数字."""
|
||||
assert safe_int("42") == 42
|
||||
|
||||
def test_none_input(self):
|
||||
"""None 输入用默认值."""
|
||||
assert safe_int(None) == 0
|
||||
|
||||
def test_none_custom_default(self):
|
||||
"""None 输入自定义默认值."""
|
||||
assert safe_int(None, default=10) == 10
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串."""
|
||||
assert safe_int("abc") == 0
|
||||
|
||||
def test_negative(self):
|
||||
"""负值."""
|
||||
assert safe_int(-5) == -5
|
||||
|
||||
def test_zero(self):
|
||||
"""零值."""
|
||||
assert safe_int(0) == 0
|
||||
|
||||
|
||||
class TestSafeBool:
|
||||
"""safe_bool 测试."""
|
||||
|
||||
def test_true_bool(self):
|
||||
"""True."""
|
||||
assert safe_bool(True) is True
|
||||
|
||||
def test_false_bool(self):
|
||||
"""False."""
|
||||
assert safe_bool(False) is False
|
||||
|
||||
def test_none(self):
|
||||
"""None -> False."""
|
||||
assert safe_bool(None) is False
|
||||
|
||||
def test_string_true(self):
|
||||
"""字符串 true."""
|
||||
assert safe_bool("true") is True
|
||||
|
||||
def test_string_yes(self):
|
||||
"""字符串 yes."""
|
||||
assert safe_bool("yes") is True
|
||||
|
||||
def test_string_one(self):
|
||||
"""字符串 1."""
|
||||
assert safe_bool("1") is True
|
||||
|
||||
def test_string_false(self):
|
||||
"""字符串 false."""
|
||||
assert safe_bool("false") is False
|
||||
|
||||
def test_int_one(self):
|
||||
"""整数 1 -> True."""
|
||||
assert safe_bool(1) is True
|
||||
|
||||
def test_int_zero(self):
|
||||
"""整数 0 -> False."""
|
||||
assert safe_bool(0) is False
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表 -> False."""
|
||||
assert safe_bool([]) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 尺寸估算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEstimateStickerSize:
|
||||
"""贴纸尺寸估算测试."""
|
||||
|
||||
def test_default_scale(self):
|
||||
"""默认 scale=1.0."""
|
||||
w, h = estimate_sticker_size(1000, 1000)
|
||||
assert w == 300 # 1000 * 0.3 * 1.0
|
||||
assert h == 300
|
||||
|
||||
def test_custom_scale(self):
|
||||
"""自定义缩放."""
|
||||
w, h = estimate_sticker_size(1000, 1000, scale=0.5)
|
||||
assert w == 150
|
||||
assert h == 150
|
||||
|
||||
def test_fixed_width_height(self):
|
||||
"""固定宽高."""
|
||||
w, h = estimate_sticker_size(1000, 1000, fixed_width=200, fixed_height=100)
|
||||
assert w == 200
|
||||
assert h == 100
|
||||
|
||||
def test_scale_2x(self):
|
||||
"""2倍缩放."""
|
||||
w, h = estimate_sticker_size(800, 600, scale=2.0)
|
||||
assert w == 480 # 800 * 0.3 * 2
|
||||
assert h == 360 # 600 * 0.3 * 2
|
||||
|
||||
def test_zero_canvas(self):
|
||||
"""零画布尺寸,返回最小 1."""
|
||||
w, h = estimate_sticker_size(0, 0)
|
||||
assert w >= 1
|
||||
assert h >= 1
|
||||
|
||||
|
||||
class TestEstimateTextSize:
|
||||
"""文字尺寸估算测试."""
|
||||
|
||||
def test_normal_text(self):
|
||||
"""普通文字."""
|
||||
w, h = estimate_text_size("Hello", 36)
|
||||
assert w == int(5 * 36 * 0.6)
|
||||
assert h == int(36 * 1.4)
|
||||
|
||||
def test_empty_text(self):
|
||||
"""空文字."""
|
||||
w, h = estimate_text_size("", 36)
|
||||
assert w == 0
|
||||
assert h == 0
|
||||
|
||||
def test_large_font(self):
|
||||
"""大字号."""
|
||||
w, h = estimate_text_size("A", 72)
|
||||
assert w == int(1 * 72 * 0.6)
|
||||
assert h == int(72 * 1.4)
|
||||
|
||||
def test_chinese_chars(self):
|
||||
"""中文字符."""
|
||||
w, h = estimate_text_size("你好世界", 48)
|
||||
assert w == int(4 * 48 * 0.6)
|
||||
assert h == int(48 * 1.4)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 时间计算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateFadeOutStart:
|
||||
"""淡出开始时间计算测试."""
|
||||
|
||||
def test_normal_case(self):
|
||||
"""正常情况."""
|
||||
assert calculate_fade_out_start(10, 30, 2) == pytest.approx(38.0)
|
||||
|
||||
def test_no_fade_out(self):
|
||||
"""无淡出."""
|
||||
assert calculate_fade_out_start(10, 30, 0) == 0.0
|
||||
|
||||
def test_negative_fade_out(self):
|
||||
"""负淡出."""
|
||||
assert calculate_fade_out_start(10, 30, -1) == 0.0
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""零时长."""
|
||||
assert calculate_fade_out_start(10, 0, 2) == 0.0
|
||||
|
||||
def test_fade_out_longer_than_duration(self):
|
||||
"""淡出超过时长,返回 0."""
|
||||
# start=10, dur=5, fade=10 -> 10+5-10 = 5 > 0
|
||||
assert calculate_fade_out_start(10, 5, 10) == pytest.approx(5.0)
|
||||
|
||||
def test_fade_out_starts_before_zero(self):
|
||||
"""淡出开始时间在 0 之前,钳制到 0."""
|
||||
# start=0, dur=3, fade=5 -> 0+3-5 = -2 -> 0
|
||||
assert calculate_fade_out_start(0, 3, 5) == 0.0
|
||||
|
||||
|
||||
class TestCalculateEndTime:
|
||||
"""结束时间计算测试."""
|
||||
|
||||
def test_normal_case(self):
|
||||
"""正常情况."""
|
||||
assert calculate_end_time(10, 30) == 40.0
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""零时长."""
|
||||
assert calculate_end_time(10, 0) == 10.0
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
assert calculate_end_time(10, -5) == 10.0
|
||||
|
||||
def test_zero_start(self):
|
||||
"""零开始."""
|
||||
assert calculate_end_time(0, 100) == 100.0
|
||||
|
||||
|
||||
class TestHasTimeRange:
|
||||
"""时间范围判断测试."""
|
||||
|
||||
def test_positive_duration(self):
|
||||
"""正时长."""
|
||||
assert has_time_range(30) is True
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""零时长."""
|
||||
assert has_time_range(0) is False
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
assert has_time_range(-5) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 滤镜构建测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildScaleFilter:
|
||||
"""缩放滤镜构建测试."""
|
||||
|
||||
def test_fixed_width_height(self):
|
||||
"""固定宽高."""
|
||||
result = build_scale_filter(width=200, height=100)
|
||||
assert result == "scale=200:100"
|
||||
|
||||
def test_scale_only(self):
|
||||
"""仅缩放."""
|
||||
result = build_scale_filter(scale=0.5)
|
||||
assert result == "scale=iw*0.5:ih*0.5"
|
||||
|
||||
def test_no_scaling_needed(self):
|
||||
"""无需缩放."""
|
||||
result = build_scale_filter(scale=1.0)
|
||||
assert result is None
|
||||
|
||||
def test_scale_2x(self):
|
||||
"""2倍缩放."""
|
||||
result = build_scale_filter(scale=2.0)
|
||||
assert result == "scale=iw*2.0:ih*2.0"
|
||||
|
||||
def test_fixed_overrides_scale(self):
|
||||
"""固定宽高优先于 scale."""
|
||||
result = build_scale_filter(width=100, height=50, scale=0.5)
|
||||
assert result == "scale=100:50"
|
||||
|
||||
|
||||
class TestBuildOpacityFilter:
|
||||
"""透明度滤镜构建测试."""
|
||||
|
||||
def test_partial_opacity(self):
|
||||
"""部分透明."""
|
||||
result = build_opacity_filter(0.5)
|
||||
assert result == "colorchannelmixer=aa=0.5"
|
||||
|
||||
def test_fully_opaque(self):
|
||||
"""完全不透明."""
|
||||
result = build_opacity_filter(1.0)
|
||||
assert result is None
|
||||
|
||||
def test_fully_transparent(self):
|
||||
"""完全透明."""
|
||||
result = build_opacity_filter(0.0)
|
||||
assert result == "colorchannelmixer=aa=0.0"
|
||||
|
||||
def test_opacity_above_1_clamped(self):
|
||||
"""超过 1 被钳制."""
|
||||
result = build_opacity_filter(1.5)
|
||||
assert result is None
|
||||
|
||||
def test_opacity_below_0_clamped(self):
|
||||
"""低于 0 被钳制."""
|
||||
result = build_opacity_filter(-0.5)
|
||||
assert result == "colorchannelmixer=aa=0.0"
|
||||
|
||||
|
||||
class TestBuildImageFadeFilters:
|
||||
"""图片淡入淡出滤镜测试."""
|
||||
|
||||
def test_fade_in_only(self):
|
||||
"""仅淡入."""
|
||||
result = build_image_fade_filters(10, 30, fade_in=1.0)
|
||||
assert len(result) == 1
|
||||
assert "fade=in:st=10:d=1.0:alpha=1" in result[0]
|
||||
|
||||
def test_fade_out_only(self):
|
||||
"""仅淡出."""
|
||||
result = build_image_fade_filters(10, 30, fade_out=2.0)
|
||||
assert len(result) == 1
|
||||
assert "fade=out" in result[0]
|
||||
assert "st=38.0" in result[0] # 10 + 30 - 2 = 38
|
||||
|
||||
def test_fade_in_and_out(self):
|
||||
"""淡入+淡出."""
|
||||
result = build_image_fade_filters(0, 10, fade_in=1.0, fade_out=1.0)
|
||||
assert len(result) == 2
|
||||
assert "fade=in" in result[0]
|
||||
assert "fade=out" in result[1]
|
||||
|
||||
def test_no_fade(self):
|
||||
"""无淡入淡出."""
|
||||
result = build_image_fade_filters(10, 30)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_zero_duration_no_fade_out(self):
|
||||
"""零时长不生成淡出."""
|
||||
result = build_image_fade_filters(10, 0, fade_out=1.0)
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestBuildEnableExpr:
|
||||
"""enable 表达式构建测试."""
|
||||
|
||||
def test_normal_duration(self):
|
||||
"""正常时长."""
|
||||
result = build_enable_expr(10, 30)
|
||||
assert "between(t,10,40" in result
|
||||
assert "enable" in result
|
||||
|
||||
def test_zero_duration(self):
|
||||
"""零时长返回空."""
|
||||
result = build_enable_expr(10, 0)
|
||||
assert result == ""
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长返回空."""
|
||||
result = build_enable_expr(10, -5)
|
||||
assert result == ""
|
||||
|
||||
def test_zero_start(self):
|
||||
"""从零开始."""
|
||||
result = build_enable_expr(0, 100)
|
||||
assert "t,0,100" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# drawtext 相关测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEscapeDrawtextText:
|
||||
"""文字转义测试."""
|
||||
|
||||
def test_no_special_chars(self):
|
||||
"""无特殊字符."""
|
||||
assert escape_drawtext_text("Hello") == "Hello"
|
||||
|
||||
def test_colon_escaped(self):
|
||||
"""冒号转义."""
|
||||
assert escape_drawtext_text("a:b") == "a\\:b"
|
||||
|
||||
def test_quote_escaped(self):
|
||||
"""单引号转义."""
|
||||
assert escape_drawtext_text("it's") == "it\\'s"
|
||||
|
||||
def test_multiple_special_chars(self):
|
||||
"""多个特殊字符."""
|
||||
assert escape_drawtext_text("a:b:c'd") == "a\\:b\\:c\\'d"
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串."""
|
||||
assert escape_drawtext_text("") == ""
|
||||
|
||||
|
||||
class TestBuildDrawtextAlphaExpr:
|
||||
"""drawtext alpha 表达式测试."""
|
||||
|
||||
def test_no_fade(self):
|
||||
"""无淡入淡出."""
|
||||
assert build_drawtext_alpha_expr(10, 30) == "1"
|
||||
|
||||
def test_fade_in_only(self):
|
||||
"""仅淡入."""
|
||||
result = build_drawtext_alpha_expr(10, 30, fade_in=2.0)
|
||||
assert "if(lt(t,12.0)" in result
|
||||
assert "(t-10)/2.0" in result
|
||||
|
||||
def test_fade_out_only(self):
|
||||
"""仅淡出."""
|
||||
result = build_drawtext_alpha_expr(10, 30, fade_out=3.0)
|
||||
assert "if(gt(t,37" in result
|
||||
assert "-t)/3.0" in result
|
||||
|
||||
def test_fade_in_and_out(self):
|
||||
"""淡入+淡出(相乘)."""
|
||||
result = build_drawtext_alpha_expr(0, 10, fade_in=1.0, fade_out=1.0)
|
||||
assert "*" in result
|
||||
assert result.count("if(") == 2
|
||||
|
||||
def test_zero_duration_no_fade_out(self):
|
||||
"""零时长不生成淡出."""
|
||||
result = build_drawtext_alpha_expr(10, 0, fade_out=1.0)
|
||||
assert result == "1"
|
||||
|
||||
|
||||
class TestBuildStrokeParams:
|
||||
"""描边参数测试."""
|
||||
|
||||
def test_no_stroke(self):
|
||||
"""无描边."""
|
||||
result = build_stroke_params(0)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_with_stroke(self):
|
||||
"""有描边."""
|
||||
result = build_stroke_params(2, "red")
|
||||
assert len(result) == 2
|
||||
assert "borderw=2" in result
|
||||
assert "bordercolor=red" in result
|
||||
|
||||
def test_negative_width(self):
|
||||
"""负宽度."""
|
||||
result = build_stroke_params(-1)
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestBuildShadowParams:
|
||||
"""阴影参数测试."""
|
||||
|
||||
def test_no_shadow(self):
|
||||
"""无阴影."""
|
||||
result = build_shadow_params(0)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_with_shadow(self):
|
||||
"""有阴影."""
|
||||
result = build_shadow_params(0.5, 3, 4, "black")
|
||||
assert len(result) == 3
|
||||
assert "shadowx=3" in result
|
||||
assert "shadowy=4" in result
|
||||
assert "shadowcolor=black@0.5" in result
|
||||
|
||||
def test_shadow_alpha_clamped(self):
|
||||
"""透明度钳制."""
|
||||
result = build_shadow_params(1.5)
|
||||
assert "shadowcolor=black@1.0" in result[2]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 贴纸排序与过滤测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSortStickersByZIndex:
|
||||
"""贴纸排序测试."""
|
||||
|
||||
def test_sorted_by_z_index(self):
|
||||
"""按 z_index 排序."""
|
||||
stickers = [
|
||||
{"z_index": 20, "name": "top"},
|
||||
{"z_index": 5, "name": "bottom"},
|
||||
{"z_index": 10, "name": "middle"},
|
||||
]
|
||||
result = sort_stickers_by_z_index(stickers)
|
||||
assert result[0]["name"] == "bottom"
|
||||
assert result[1]["name"] == "middle"
|
||||
assert result[2]["name"] == "top"
|
||||
|
||||
def test_same_z_index_preserves_order(self):
|
||||
"""相同 z_index 保持原顺序."""
|
||||
stickers = [
|
||||
{"z_index": 10, "name": "first"},
|
||||
{"z_index": 10, "name": "second"},
|
||||
]
|
||||
result = sort_stickers_by_z_index(stickers)
|
||||
assert result[0]["name"] == "first"
|
||||
assert result[1]["name"] == "second"
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert sort_stickers_by_z_index([]) == []
|
||||
|
||||
def test_default_z_index_10(self):
|
||||
"""无 z_index 默认 10."""
|
||||
stickers = [
|
||||
{"z_index": 5, "name": "low"},
|
||||
{"name": "default"},
|
||||
]
|
||||
result = sort_stickers_by_z_index(stickers)
|
||||
assert result[0]["name"] == "low"
|
||||
assert result[1]["name"] == "default"
|
||||
|
||||
|
||||
class TestFilterEnabledStickers:
|
||||
"""启用贴纸过滤测试."""
|
||||
|
||||
def test_all_enabled(self):
|
||||
"""全部启用."""
|
||||
stickers = [{"enabled": True}, {"enabled": True}]
|
||||
assert len(filter_enabled_stickers(stickers)) == 2
|
||||
|
||||
def test_mixed(self):
|
||||
"""混合."""
|
||||
stickers = [
|
||||
{"enabled": True, "name": "a"},
|
||||
{"enabled": False, "name": "b"},
|
||||
{"enabled": True, "name": "c"},
|
||||
]
|
||||
result = filter_enabled_stickers(stickers)
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "a"
|
||||
|
||||
def test_default_enabled(self):
|
||||
"""默认启用."""
|
||||
stickers = [{"name": "a"}]
|
||||
result = filter_enabled_stickers(stickers)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert filter_enabled_stickers([]) == []
|
||||
|
||||
|
||||
class TestCountStickerTypes:
|
||||
"""贴纸类型统计测试."""
|
||||
|
||||
def test_mixed_types(self):
|
||||
"""混合类型."""
|
||||
stickers = [
|
||||
{"type": "image"},
|
||||
{"type": "text"},
|
||||
{"type": "image"},
|
||||
]
|
||||
counts = count_sticker_types(stickers)
|
||||
assert counts["image"] == 2
|
||||
assert counts["text"] == 1
|
||||
|
||||
def test_default_type(self):
|
||||
"""默认 image."""
|
||||
stickers = [{}]
|
||||
counts = count_sticker_types(stickers)
|
||||
assert counts["image"] == 1
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert count_sticker_types([]) == {}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# overlay 相关测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildOverlayPosition:
|
||||
"""overlay 位置构建测试."""
|
||||
|
||||
def test_integer_position(self):
|
||||
"""整数位置."""
|
||||
assert build_overlay_position(100, 200) == "100:200"
|
||||
|
||||
def test_float_position_rounded(self):
|
||||
"""浮点取整."""
|
||||
assert build_overlay_position(100.6, 200.4) == "101:200"
|
||||
|
||||
def test_zero_position(self):
|
||||
"""零位置."""
|
||||
assert build_overlay_position(0, 0) == "0:0"
|
||||
|
||||
def test_negative_position(self):
|
||||
"""负位置."""
|
||||
assert build_overlay_position(-10, -20) == "-10:-20"
|
||||
|
||||
|
||||
class TestBuildPreFilterLabel:
|
||||
"""预处理标签构建测试."""
|
||||
|
||||
def test_normal_idx(self):
|
||||
"""正常索引."""
|
||||
assert build_pre_filter_label(3) == "sticker_3_scaled"
|
||||
|
||||
def test_zero_idx(self):
|
||||
"""零索引."""
|
||||
assert build_pre_filter_label(0) == "sticker_0_scaled"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 验证函数测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateImageSticker:
|
||||
"""图片贴纸验证测试."""
|
||||
|
||||
def test_valid_with_image_path(self):
|
||||
"""有 image_path,合法."""
|
||||
ok, errors = validate_image_sticker({"image_path": "/a.png"})
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_valid_with_asset_id(self):
|
||||
"""有 asset_id,合法."""
|
||||
ok, errors = validate_image_sticker({"asset_id": "123"})
|
||||
assert ok is True
|
||||
|
||||
def test_missing_image_source(self):
|
||||
"""缺图片来源."""
|
||||
ok, errors = validate_image_sticker({})
|
||||
assert ok is False
|
||||
assert any("image_path" in e or "asset_id" in e for e in errors)
|
||||
|
||||
def test_opacity_out_of_range(self):
|
||||
"""透明度超范围."""
|
||||
ok, errors = validate_image_sticker(
|
||||
{
|
||||
"image_path": "/a.png",
|
||||
"opacity": 1.5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("opacity" in e for e in errors)
|
||||
|
||||
def test_negative_scale(self):
|
||||
"""负缩放."""
|
||||
ok, errors = validate_image_sticker(
|
||||
{
|
||||
"image_path": "/a.png",
|
||||
"scale": -0.5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("scale" in e for e in errors)
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
ok, errors = validate_image_sticker(
|
||||
{
|
||||
"image_path": "/a.png",
|
||||
"duration": -10,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("duration" in e for e in errors)
|
||||
|
||||
def test_multiple_errors(self):
|
||||
"""多个错误."""
|
||||
ok, errors = validate_image_sticker(
|
||||
{
|
||||
"opacity": 1.5,
|
||||
"duration": -1,
|
||||
"start_time": -5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert len(errors) >= 3
|
||||
|
||||
|
||||
class TestValidateTextSticker:
|
||||
"""文字贴纸验证测试."""
|
||||
|
||||
def test_valid(self):
|
||||
"""合法配置."""
|
||||
ok, errors = validate_text_sticker(
|
||||
{
|
||||
"text": "Hello",
|
||||
"font_size": 36,
|
||||
"font_color": "white",
|
||||
}
|
||||
)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_empty_text(self):
|
||||
"""空文字."""
|
||||
ok, errors = validate_text_sticker({"text": ""})
|
||||
assert ok is False
|
||||
assert any("text" in e for e in errors)
|
||||
|
||||
def test_zero_font_size(self):
|
||||
"""零字号."""
|
||||
ok, errors = validate_text_sticker(
|
||||
{
|
||||
"text": "Hi",
|
||||
"font_size": 0,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("font_size" in e for e in errors)
|
||||
|
||||
def test_empty_font_color(self):
|
||||
"""空颜色."""
|
||||
ok, errors = validate_text_sticker(
|
||||
{
|
||||
"text": "Hi",
|
||||
"font_color": "",
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("font_color" in e for e in errors)
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
ok, errors = validate_text_sticker(
|
||||
{
|
||||
"text": "Hi",
|
||||
"duration": -5,
|
||||
}
|
||||
)
|
||||
assert ok is False
|
||||
assert any("duration" in e for e in errors)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
"""transition_config 模块单测 — 纯逻辑,无 FFmpeg 依赖."""
|
||||
"""transition_config 转场配置领域模型单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,264 +13,454 @@ from packages.domain.transition_config import (
|
||||
TransitionType,
|
||||
)
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
# ── 常量测试 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_duration_bounds(self):
|
||||
"""常量测试."""
|
||||
|
||||
def test_min_duration(self):
|
||||
"""最小转场时长."""
|
||||
assert MIN_TRANSITION_DURATION == 0.3
|
||||
|
||||
def test_max_duration(self):
|
||||
"""最大转场时长."""
|
||||
assert MAX_TRANSITION_DURATION == 2.0
|
||||
|
||||
def test_default_duration(self):
|
||||
"""默认转场时长."""
|
||||
assert DEFAULT_TRANSITION_DURATION == 0.5
|
||||
assert MIN_TRANSITION_DURATION < DEFAULT_TRANSITION_DURATION < MAX_TRANSITION_DURATION
|
||||
|
||||
def test_duration_range_valid(self):
|
||||
"""时长范围合理:min < default < max."""
|
||||
assert MIN_TRANSITION_DURATION < DEFAULT_TRANSITION_DURATION
|
||||
assert DEFAULT_TRANSITION_DURATION < MAX_TRANSITION_DURATION
|
||||
|
||||
def test_cut_transition(self):
|
||||
"""硬切常量."""
|
||||
assert CUT_TRANSITION == "cut"
|
||||
|
||||
|
||||
# ── TransitionType 枚举 ──────────────────────────────────────────────────────
|
||||
# ── TransitionType 枚举测试 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionType:
|
||||
def test_all_supported_includes_all_except_cut(self):
|
||||
"""TransitionType 枚举测试."""
|
||||
|
||||
def test_has_cut(self):
|
||||
"""有CUT类型."""
|
||||
assert TransitionType.CUT.value == "cut"
|
||||
|
||||
def test_has_fade(self):
|
||||
"""有FADE类型."""
|
||||
assert TransitionType.FADE.value == "fade"
|
||||
|
||||
def test_has_dissolve(self):
|
||||
"""有DISSOLVE类型."""
|
||||
assert TransitionType.DISSOLVE.value == "dissolve"
|
||||
|
||||
def test_slide_types(self):
|
||||
"""滑动系列四种."""
|
||||
assert TransitionType.SLIDE_LEFT.value == "slideleft"
|
||||
assert TransitionType.SLIDE_RIGHT.value == "slideright"
|
||||
assert TransitionType.SLIDE_UP.value == "slideup"
|
||||
assert TransitionType.SLIDE_DOWN.value == "slidedown"
|
||||
|
||||
def test_wipe_types(self):
|
||||
"""擦除系列四种."""
|
||||
assert TransitionType.WIPE_LEFT.value == "wipeleft"
|
||||
assert TransitionType.WIPE_RIGHT.value == "wiperight"
|
||||
assert TransitionType.WIPE_UP.value == "wipeup"
|
||||
assert TransitionType.WIPE_DOWN.value == "wipedown"
|
||||
|
||||
def test_zoom_type(self):
|
||||
"""缩放类型."""
|
||||
assert TransitionType.ZOOM.value == "zoom"
|
||||
|
||||
def test_circle_crop_type(self):
|
||||
"""圆形扩散."""
|
||||
assert TransitionType.CIRCLE_CROP.value == "circlecrop"
|
||||
|
||||
def test_rect_crop_type(self):
|
||||
"""矩形覆盖."""
|
||||
assert TransitionType.RECT_CROP.value == "rectcrop"
|
||||
|
||||
def test_total_types(self):
|
||||
"""共14种转场类型."""
|
||||
assert len(TransitionType) == 14
|
||||
|
||||
def test_all_supported_excludes_cut(self):
|
||||
"""all_supported()不含cut."""
|
||||
supported = TransitionType.all_supported()
|
||||
assert "cut" not in supported
|
||||
assert "fade" in supported
|
||||
assert "dissolve" in supported
|
||||
assert len(supported) >= 10 # 至少有10种转场
|
||||
assert len(supported) == 13
|
||||
|
||||
def test_all_supported_unique(self):
|
||||
def test_all_supported_returns_strings(self):
|
||||
"""all_supported()返回字符串列表."""
|
||||
supported = TransitionType.all_supported()
|
||||
assert len(supported) == len(set(supported))
|
||||
assert all(isinstance(s, str) for s in supported)
|
||||
|
||||
def test_is_supported_exact_match(self):
|
||||
def test_is_supported_valid(self):
|
||||
"""支持的转场类型."""
|
||||
assert TransitionType.is_supported("fade") is True
|
||||
assert TransitionType.is_supported("dissolve") is True
|
||||
assert TransitionType.is_supported("slideleft") is True
|
||||
|
||||
def test_is_supported_invalid(self):
|
||||
"""不支持的转场类型."""
|
||||
assert TransitionType.is_supported("invalid_effect") is False
|
||||
assert TransitionType.is_supported("") is False
|
||||
|
||||
def test_is_supported_case_insensitive(self):
|
||||
"""不区分大小写."""
|
||||
assert TransitionType.is_supported("FADE") is True
|
||||
assert TransitionType.is_supported("Fade") is True
|
||||
assert TransitionType.is_supported("SlideLeft") is True
|
||||
|
||||
def test_is_supported_with_underscores(self):
|
||||
"""下划线会被忽略."""
|
||||
assert TransitionType.is_supported("slide_left") is True
|
||||
assert TransitionType.is_supported("wipe_right") is True
|
||||
assert TransitionType.is_supported("circle_crop") is True
|
||||
|
||||
def test_is_supported_with_hyphens(self):
|
||||
"""中划线会被忽略."""
|
||||
assert TransitionType.is_supported("slide-left") is True
|
||||
assert TransitionType.is_supported("wipe-down") is True
|
||||
|
||||
def test_is_supported_aliases(self):
|
||||
assert TransitionType.is_supported("crossfade") is True
|
||||
assert TransitionType.is_supported("crossdissolve") is True
|
||||
assert TransitionType.is_supported("fadein") is True
|
||||
assert TransitionType.is_supported("fadeout") is True
|
||||
assert TransitionType.is_supported("slide") is True
|
||||
assert TransitionType.is_supported("wipe") is True
|
||||
assert TransitionType.is_supported("zoomin") is True
|
||||
assert TransitionType.is_supported("zoomout") is True
|
||||
assert TransitionType.is_supported("circle") is True
|
||||
assert TransitionType.is_supported("rect") is True
|
||||
|
||||
def test_is_supported_unknown(self):
|
||||
assert TransitionType.is_supported("unknown_effect") is False
|
||||
assert TransitionType.is_supported("") is False
|
||||
assert TransitionType.is_supported("12345") is False
|
||||
|
||||
def test_enum_values_match_ffmpeg(self):
|
||||
# 枚举值应该就是 ffmpeg xfade 的 transition 名
|
||||
assert TransitionType.FADE.value == "fade"
|
||||
assert TransitionType.DISSOLVE.value == "dissolve"
|
||||
assert TransitionType.SLIDE_LEFT.value == "slideleft"
|
||||
assert TransitionType.CUT.value == "cut"
|
||||
def test_is_supported_cut(self):
|
||||
"""cut不被算作supported(all_supported不含cut)."""
|
||||
# is_supported是检查是否在支持的xfade效果里,cut是特殊值
|
||||
# 看实现:is_supported检查_NAME_TO_ENUM_MAP,cut应该也在里面
|
||||
pass
|
||||
|
||||
|
||||
# ── TransitionConfig 默认值 ──────────────────────────────────────────────────
|
||||
# ── TransitionConfig.parse - effect 测试 ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionConfigDefaults:
|
||||
def test_default_config(self):
|
||||
cfg = TransitionConfig()
|
||||
class TestTransitionConfigParseEffect:
|
||||
"""TransitionConfig.parse effect参数测试."""
|
||||
|
||||
def test_none_effect_defaults_to_cut(self):
|
||||
"""None effect → cut."""
|
||||
cfg = TransitionConfig.parse(effect=None)
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_empty_effect_defaults_to_cut(self):
|
||||
"""空字符串 → cut."""
|
||||
cfg = TransitionConfig.parse(effect="")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_whitespace_effect_defaults_to_cut(self):
|
||||
"""纯空白 → cut."""
|
||||
cfg = TransitionConfig.parse(effect=" ")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_cut_effect(self):
|
||||
"""显式cut."""
|
||||
cfg = TransitionConfig.parse(effect="cut")
|
||||
assert cfg.effect == "cut"
|
||||
|
||||
def test_cut_case_insensitive(self):
|
||||
"""CUT不区分大小写."""
|
||||
cfg = TransitionConfig.parse(effect="CUT")
|
||||
assert cfg.effect == "cut"
|
||||
|
||||
def test_valid_fade_effect(self):
|
||||
"""有效的fade效果."""
|
||||
cfg = TransitionConfig.parse(effect="fade")
|
||||
assert cfg.effect == "fade"
|
||||
|
||||
def test_valid_dissolve_effect(self):
|
||||
"""有效的dissolve效果."""
|
||||
cfg = TransitionConfig.parse(effect="dissolve")
|
||||
assert cfg.effect == "dissolve"
|
||||
|
||||
def test_effect_stripped(self):
|
||||
"""effect去除空白."""
|
||||
cfg = TransitionConfig.parse(effect=" fade ")
|
||||
assert cfg.effect == "fade"
|
||||
|
||||
def test_invalid_effect_falls_back_to_cut(self):
|
||||
"""无效效果 → 降级为cut."""
|
||||
cfg = TransitionConfig.parse(effect="super_fancy_effect")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_alias_crossfade(self):
|
||||
"""别名crossfade → dissolve."""
|
||||
cfg = TransitionConfig.parse(effect="crossfade")
|
||||
assert cfg.effect == "dissolve"
|
||||
|
||||
def test_alias_crossdissolve(self):
|
||||
"""别名crossdissolve → dissolve."""
|
||||
cfg = TransitionConfig.parse(effect="crossdissolve")
|
||||
assert cfg.effect == "dissolve"
|
||||
|
||||
def test_alias_fadein(self):
|
||||
"""别名fadein → fade."""
|
||||
cfg = TransitionConfig.parse(effect="fadein")
|
||||
assert cfg.effect == "fade"
|
||||
|
||||
def test_alias_fadeout(self):
|
||||
"""别名fadeout → fade."""
|
||||
cfg = TransitionConfig.parse(effect="fadeout")
|
||||
assert cfg.effect == "fade"
|
||||
|
||||
def test_alias_slide(self):
|
||||
"""别名slide → slideleft."""
|
||||
cfg = TransitionConfig.parse(effect="slide")
|
||||
assert cfg.effect == "slideleft"
|
||||
|
||||
def test_alias_wipe(self):
|
||||
"""别名wipe → wipeleft."""
|
||||
cfg = TransitionConfig.parse(effect="wipe")
|
||||
assert cfg.effect == "wipeleft"
|
||||
|
||||
def test_alias_zoomin(self):
|
||||
"""别名zoomin → zoom(ZOOM枚举value为zoom)."""
|
||||
cfg = TransitionConfig.parse(effect="zoomin")
|
||||
assert cfg.effect == "zoom"
|
||||
|
||||
def test_alias_circle(self):
|
||||
"""别名circle → circlecrop."""
|
||||
cfg = TransitionConfig.parse(effect="circle")
|
||||
assert cfg.effect == "circlecrop"
|
||||
|
||||
def test_alias_rect(self):
|
||||
"""别名rect → rectcrop."""
|
||||
cfg = TransitionConfig.parse(effect="rect")
|
||||
assert cfg.effect == "rectcrop"
|
||||
|
||||
def test_underscore_format(self):
|
||||
"""下划线格式能解析."""
|
||||
cfg = TransitionConfig.parse(effect="slide_left")
|
||||
assert cfg.effect == "slideleft"
|
||||
|
||||
def test_case_insensitive_alias(self):
|
||||
"""别名不区分大小写."""
|
||||
cfg = TransitionConfig.parse(effect="CrossFade")
|
||||
assert cfg.effect == "dissolve"
|
||||
|
||||
def test_slide_left_direct(self):
|
||||
"""直接用slideleft."""
|
||||
cfg = TransitionConfig.parse(effect="slideleft")
|
||||
assert cfg.effect == "slideleft"
|
||||
|
||||
|
||||
# ── TransitionConfig.parse - duration 测试 ───────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionConfigParseDuration:
|
||||
"""TransitionConfig.parse duration参数测试."""
|
||||
|
||||
def test_none_duration_uses_default(self):
|
||||
"""None duration → 默认值."""
|
||||
cfg = TransitionConfig.parse(duration=None)
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
assert cfg.is_cut is True
|
||||
|
||||
def test_normal_duration(self):
|
||||
"""正常范围内的时长."""
|
||||
cfg = TransitionConfig.parse(duration=1.0)
|
||||
assert cfg.duration == 1.0
|
||||
|
||||
def test_min_duration(self):
|
||||
"""刚好等于最小值."""
|
||||
cfg = TransitionConfig.parse(duration=MIN_TRANSITION_DURATION)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_max_duration(self):
|
||||
"""刚好等于最大值."""
|
||||
cfg = TransitionConfig.parse(duration=MAX_TRANSITION_DURATION)
|
||||
assert cfg.duration == MAX_TRANSITION_DURATION
|
||||
|
||||
def test_below_min_clamped(self):
|
||||
"""低于最小值钳制到最小值."""
|
||||
cfg = TransitionConfig.parse(duration=0.1)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_zero_duration_clamped(self):
|
||||
"""0时长钳制到最小值."""
|
||||
cfg = TransitionConfig.parse(duration=0)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_negative_duration_clamped(self):
|
||||
"""负时长钳制到最小值."""
|
||||
cfg = TransitionConfig.parse(duration=-1)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_above_max_clamped(self):
|
||||
"""超过最大值钳制到最大值."""
|
||||
cfg = TransitionConfig.parse(duration=5.0)
|
||||
assert cfg.duration == MAX_TRANSITION_DURATION
|
||||
|
||||
def test_string_duration(self):
|
||||
"""字符串形式的duration."""
|
||||
cfg = TransitionConfig.parse(duration="1.5")
|
||||
assert cfg.duration == 1.5
|
||||
|
||||
def test_invalid_string_duration_uses_default(self):
|
||||
"""无效字符串duration → 默认值."""
|
||||
cfg = TransitionConfig.parse(duration="abc")
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
def test_int_duration(self):
|
||||
"""整数时长."""
|
||||
cfg = TransitionConfig.parse(duration=1)
|
||||
assert cfg.duration == 1.0
|
||||
|
||||
def test_default_duration_when_no_args(self):
|
||||
"""无参时duration为默认值."""
|
||||
cfg = TransitionConfig.parse()
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
|
||||
# ── TransitionConfig 属性测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionConfigProperties:
|
||||
"""TransitionConfig 属性测试."""
|
||||
|
||||
def test_is_cut_true(self):
|
||||
"""cut是硬切."""
|
||||
cfg = TransitionConfig(effect="cut")
|
||||
assert cfg.is_cut is True
|
||||
|
||||
def test_is_cut_false(self):
|
||||
"""非cut不是硬切."""
|
||||
cfg = TransitionConfig(effect="fade")
|
||||
assert cfg.is_cut is False
|
||||
|
||||
def test_is_cut_default(self):
|
||||
"""默认配置是硬切."""
|
||||
cfg = TransitionConfig()
|
||||
assert cfg.is_cut is True
|
||||
|
||||
# ── TransitionConfig.parse ───────────────────────────────────────────────────
|
||||
def test_ffmpeg_transition_cut_returns_empty(self):
|
||||
"""硬切返回空字符串(无xfade)."""
|
||||
cfg = TransitionConfig(effect="cut")
|
||||
assert cfg.ffmpeg_transition == ""
|
||||
|
||||
def test_ffmpeg_transition_fade(self):
|
||||
"""fade → fade."""
|
||||
cfg = TransitionConfig(effect="fade")
|
||||
assert cfg.ffmpeg_transition == "fade"
|
||||
|
||||
def test_ffmpeg_transition_dissolve(self):
|
||||
"""dissolve → dissolve."""
|
||||
cfg = TransitionConfig(effect="dissolve")
|
||||
assert cfg.ffmpeg_transition == "dissolve"
|
||||
|
||||
def test_ffmpeg_transition_slideleft(self):
|
||||
"""slideleft → slideleft."""
|
||||
cfg = TransitionConfig(effect="slideleft")
|
||||
assert cfg.ffmpeg_transition == "slideleft"
|
||||
|
||||
def test_ffmpeg_transition_zoom(self):
|
||||
"""zoom → zoomin(FFmpeg中叫zoomin)."""
|
||||
cfg = TransitionConfig(effect="zoom")
|
||||
assert cfg.ffmpeg_transition == "zoomin"
|
||||
|
||||
def test_ffmpeg_transition_circlecrop(self):
|
||||
"""circlecrop → circlecrop."""
|
||||
cfg = TransitionConfig(effect="circlecrop")
|
||||
assert cfg.ffmpeg_transition == "circlecrop"
|
||||
|
||||
def test_ffmpeg_transition_default_fade_fallback(self):
|
||||
"""未知效果回退到fade."""
|
||||
# 直接构造一个不支持的effect(绕过parse)
|
||||
cfg = TransitionConfig(effect="unknown_effect", duration=0.5)
|
||||
# 会回退到fade
|
||||
assert cfg.ffmpeg_transition == "fade"
|
||||
|
||||
|
||||
class TestTransitionConfigParse:
|
||||
def test_none_params_default(self):
|
||||
# ── TransitionConfig.validate 测试 ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionConfigValidate:
|
||||
"""TransitionConfig.validate 测试."""
|
||||
|
||||
def test_valid_default_cut(self):
|
||||
"""默认cut配置是合法的."""
|
||||
cfg = TransitionConfig()
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
|
||||
def test_valid_fade(self):
|
||||
"""fade配置是合法的."""
|
||||
cfg = TransitionConfig(effect="fade", duration=1.0)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is True
|
||||
assert msg == ""
|
||||
|
||||
def test_valid_min_duration(self):
|
||||
"""最小时长是合法的."""
|
||||
cfg = TransitionConfig(effect="fade", duration=MIN_TRANSITION_DURATION)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
def test_valid_max_duration(self):
|
||||
"""最大时长是合法的."""
|
||||
cfg = TransitionConfig(effect="fade", duration=MAX_TRANSITION_DURATION)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
def test_invalid_duration_too_low(self):
|
||||
"""时长小于最小值不合法."""
|
||||
cfg = TransitionConfig(effect="fade", duration=0.1)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "不能小于" in msg
|
||||
|
||||
def test_invalid_duration_too_high(self):
|
||||
"""时长大于最大值不合法."""
|
||||
cfg = TransitionConfig(effect="fade", duration=5.0)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "不能大于" in msg
|
||||
|
||||
def test_invalid_unknown_effect(self):
|
||||
"""未知效果不合法."""
|
||||
cfg = TransitionConfig(effect="super_fancy", duration=0.5)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is False
|
||||
assert "不支持" in msg
|
||||
|
||||
def test_cut_always_valid(self):
|
||||
"""cut效果总是合法的(即使duration异常...要看实现)."""
|
||||
# cut的话is_cut为True,validate里会跳过effect检查
|
||||
cfg = TransitionConfig(effect="cut", duration=1.0)
|
||||
ok, msg = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ── 默认值测试 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionConfigDefaults:
|
||||
"""TransitionConfig 默认值测试."""
|
||||
|
||||
def test_default_effect_is_cut(self):
|
||||
"""默认effect是cut."""
|
||||
cfg = TransitionConfig()
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_default_duration(self):
|
||||
"""默认duration."""
|
||||
cfg = TransitionConfig()
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
def test_parse_no_args(self):
|
||||
"""parse无参 → 默认值."""
|
||||
cfg = TransitionConfig.parse()
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
def test_empty_effect_default(self):
|
||||
cfg = TransitionConfig.parse(effect="")
|
||||
def test_parse_both_none(self):
|
||||
"""两个参数都None → 默认值."""
|
||||
cfg = TransitionConfig.parse(effect=None, duration=None)
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_whitespace_effect_default(self):
|
||||
cfg = TransitionConfig.parse(effect=" ")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_valid_effect_fade(self):
|
||||
cfg = TransitionConfig.parse(effect="fade")
|
||||
assert cfg.effect == "fade"
|
||||
assert cfg.is_cut is False
|
||||
|
||||
def test_valid_effect_case_insensitive(self):
|
||||
cfg = TransitionConfig.parse(effect="FADE")
|
||||
assert cfg.effect == "fade"
|
||||
|
||||
def test_valid_effect_with_underscores(self):
|
||||
cfg = TransitionConfig.parse(effect="slide_left")
|
||||
assert cfg.effect == "slideleft"
|
||||
|
||||
def test_alias_effect(self):
|
||||
cfg = TransitionConfig.parse(effect="crossfade")
|
||||
assert cfg.effect == "dissolve" # 别名映射到 dissolve
|
||||
|
||||
def test_unknown_effect_falls_back_to_cut(self):
|
||||
cfg = TransitionConfig.parse(effect="magic_sparkles")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
assert cfg.is_cut is True
|
||||
|
||||
def test_cut_effect_stays_cut(self):
|
||||
cfg = TransitionConfig.parse(effect="cut")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_cut_effect_case_insensitive(self):
|
||||
cfg = TransitionConfig.parse(effect="CUT")
|
||||
assert cfg.effect == CUT_TRANSITION
|
||||
|
||||
def test_duration_default(self):
|
||||
cfg = TransitionConfig.parse(duration=None)
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
def test_duration_within_range(self):
|
||||
cfg = TransitionConfig.parse(duration=1.0)
|
||||
assert cfg.duration == 1.0
|
||||
def test_is_dataclass(self):
|
||||
"""是dataclass."""
|
||||
from dataclasses import is_dataclass
|
||||
|
||||
def test_duration_at_min(self):
|
||||
cfg = TransitionConfig.parse(duration=MIN_TRANSITION_DURATION)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_duration_at_max(self):
|
||||
cfg = TransitionConfig.parse(duration=MAX_TRANSITION_DURATION)
|
||||
assert cfg.duration == MAX_TRANSITION_DURATION
|
||||
|
||||
def test_duration_below_min_clamped(self):
|
||||
cfg = TransitionConfig.parse(duration=0.1)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_duration_above_max_clamped(self):
|
||||
cfg = TransitionConfig.parse(duration=3.0)
|
||||
assert cfg.duration == MAX_TRANSITION_DURATION
|
||||
|
||||
def test_duration_zero_clamped(self):
|
||||
cfg = TransitionConfig.parse(duration=0)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_duration_negative_clamped(self):
|
||||
cfg = TransitionConfig.parse(duration=-1.0)
|
||||
assert cfg.duration == MIN_TRANSITION_DURATION
|
||||
|
||||
def test_duration_invalid_string_fallback(self):
|
||||
cfg = TransitionConfig.parse(duration="bad") # type: ignore[arg-type]
|
||||
assert cfg.duration == DEFAULT_TRANSITION_DURATION
|
||||
|
||||
def test_duration_numeric_string(self):
|
||||
cfg = TransitionConfig.parse(duration="1.5") # type: ignore[arg-type]
|
||||
assert cfg.duration == 1.5
|
||||
|
||||
def test_full_parse(self):
|
||||
cfg = TransitionConfig.parse(effect="wipe_up", duration=1.2)
|
||||
assert cfg.effect == "wipeup"
|
||||
assert cfg.duration == 1.2
|
||||
assert cfg.is_cut is False
|
||||
|
||||
|
||||
# ── TransitionConfig.ffmpeg_transition ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestFfmpegTransition:
|
||||
def test_cut_returns_empty(self):
|
||||
cfg = TransitionConfig(effect="cut")
|
||||
assert cfg.ffmpeg_transition == ""
|
||||
|
||||
def test_fade_matches(self):
|
||||
cfg = TransitionConfig(effect="fade")
|
||||
assert cfg.ffmpeg_transition == "fade"
|
||||
|
||||
def test_dissolve_matches(self):
|
||||
cfg = TransitionConfig(effect="dissolve")
|
||||
assert cfg.ffmpeg_transition == "dissolve"
|
||||
|
||||
def test_slide_left_matches(self):
|
||||
cfg = TransitionConfig(effect="slideleft")
|
||||
assert cfg.ffmpeg_transition == "slideleft"
|
||||
|
||||
def test_wipe_down_matches(self):
|
||||
cfg = TransitionConfig(effect="wipedown")
|
||||
assert cfg.ffmpeg_transition == "wipedown"
|
||||
|
||||
def test_zoom_matches_zoomin(self):
|
||||
cfg = TransitionConfig(effect="zoom")
|
||||
assert cfg.ffmpeg_transition == "zoomin"
|
||||
|
||||
def test_circle_crop_matches(self):
|
||||
cfg = TransitionConfig(effect="circlecrop")
|
||||
assert cfg.ffmpeg_transition == "circlecrop"
|
||||
|
||||
|
||||
# ── TransitionConfig.validate ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionConfigValidate:
|
||||
def test_valid_cut(self):
|
||||
cfg = TransitionConfig(effect="cut", duration=0.5)
|
||||
ok, err = cfg.validate()
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
|
||||
def test_valid_fade(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=1.0)
|
||||
ok, err = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
def test_duration_below_min_invalid(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=0.1)
|
||||
ok, err = cfg.validate()
|
||||
assert ok is False
|
||||
assert "duration" in err
|
||||
|
||||
def test_duration_above_max_invalid(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=3.0)
|
||||
ok, err = cfg.validate()
|
||||
assert ok is False
|
||||
assert "duration" in err
|
||||
|
||||
def test_unsupported_effect_invalid(self):
|
||||
cfg = TransitionConfig(effect="unknown", duration=0.5)
|
||||
ok, err = cfg.validate()
|
||||
assert ok is False
|
||||
assert "不支持的转场" in err
|
||||
|
||||
def test_min_duration_boundary_valid(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=MIN_TRANSITION_DURATION)
|
||||
ok, _ = cfg.validate()
|
||||
assert ok is True
|
||||
|
||||
def test_max_duration_boundary_valid(self):
|
||||
cfg = TransitionConfig(effect="fade", duration=MAX_TRANSITION_DURATION)
|
||||
ok, _ = cfg.validate()
|
||||
assert ok is True
|
||||
assert is_dataclass(TransitionConfig)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from dataclasses import FrozenInstanceError
|
||||
"""转场预设库单元测试."""
|
||||
|
||||
"""transition_presets 领域层单元测试 - 转场预设库"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -12,198 +12,384 @@ from packages.domain.transition_presets import (
|
||||
list_transition_presets,
|
||||
)
|
||||
|
||||
# ── 数据类测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionPreset:
|
||||
"""TransitionPreset 数据类测试"""
|
||||
"""TransitionPreset 数据类测试."""
|
||||
|
||||
def test_create_minimal(self):
|
||||
preset = TransitionPreset(id="test", name="测试", category="basic", transition="fade")
|
||||
assert preset.id == "test"
|
||||
assert preset.name == "测试"
|
||||
assert preset.category == "basic"
|
||||
assert preset.transition == "fade"
|
||||
assert preset.description == ""
|
||||
assert preset.tags == []
|
||||
assert preset.default_duration == 0.5
|
||||
assert preset.min_duration == 0.1
|
||||
assert preset.max_duration == 3.0
|
||||
assert preset.has_custom_params is False
|
||||
|
||||
def test_create_with_all_params(self):
|
||||
preset = TransitionPreset(
|
||||
id="custom",
|
||||
name="自定义转场",
|
||||
category="special",
|
||||
description="炫酷特效",
|
||||
tags=["炫酷", "特效"],
|
||||
transition="custom",
|
||||
default_duration=1.0,
|
||||
min_duration=0.5,
|
||||
max_duration=5.0,
|
||||
has_custom_params=True,
|
||||
def test_basic_attributes(self):
|
||||
"""基础属性可访问."""
|
||||
p = TransitionPreset(
|
||||
id="test_id",
|
||||
name="测试转场",
|
||||
category="fade",
|
||||
description="测试描述",
|
||||
tags=["tag1", "tag2"],
|
||||
transition="fade",
|
||||
default_duration=0.5,
|
||||
min_duration=0.1,
|
||||
max_duration=3.0,
|
||||
has_custom_params=False,
|
||||
)
|
||||
assert preset.description == "炫酷特效"
|
||||
assert preset.tags == ["炫酷", "特效"]
|
||||
assert preset.default_duration == 1.0
|
||||
assert preset.min_duration == 0.5
|
||||
assert preset.max_duration == 5.0
|
||||
assert preset.has_custom_params is True
|
||||
assert p.id == "test_id"
|
||||
assert p.name == "测试转场"
|
||||
assert p.category == "fade"
|
||||
assert p.description == "测试描述"
|
||||
assert p.tags == ["tag1", "tag2"]
|
||||
assert p.transition == "fade"
|
||||
assert p.default_duration == 0.5
|
||||
assert p.min_duration == 0.1
|
||||
assert p.max_duration == 3.0
|
||||
assert p.has_custom_params is False
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
p = TransitionPreset(id="t", name="T", category="basic")
|
||||
assert p.description == ""
|
||||
assert p.tags == []
|
||||
assert p.transition == "fade"
|
||||
assert p.default_duration == 0.5
|
||||
assert p.min_duration == 0.1
|
||||
assert p.max_duration == 3.0
|
||||
assert p.has_custom_params is False
|
||||
|
||||
def test_frozen_immutable(self):
|
||||
"""frozen dataclass 不可修改"""
|
||||
preset = TransitionPreset(id="test", name="测试", category="basic")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
preset.name = "改名"
|
||||
"""frozen dataclass 不可修改."""
|
||||
p = TransitionPreset(id="t", name="T", category="basic")
|
||||
with pytest.raises(Exception): # FrozenInstanceError
|
||||
p.name = "新名字"
|
||||
|
||||
def test_tags_default_empty_list(self):
|
||||
preset = TransitionPreset(id="t1", name="t1", category="basic")
|
||||
preset2 = TransitionPreset(id="t2", name="t2", category="basic")
|
||||
assert preset.tags == []
|
||||
assert preset.tags is not preset2.tags
|
||||
def test_not_hashable_due_to_list(self):
|
||||
"""含list字段(tags)的frozen dataclass不可哈希(list可变)."""
|
||||
p = TransitionPreset(id="t", name="T", category="basic")
|
||||
with pytest.raises(TypeError):
|
||||
hash(p)
|
||||
|
||||
def test_default_transition_is_fade(self):
|
||||
preset = TransitionPreset(id="test", name="测试", category="basic")
|
||||
assert preset.transition == "fade"
|
||||
def test_equality(self):
|
||||
"""相同属性的实例相等."""
|
||||
p1 = TransitionPreset(id="t", name="T", category="basic")
|
||||
p2 = TransitionPreset(id="t", name="T", category="basic")
|
||||
assert p1 == p2
|
||||
|
||||
def test_inequality(self):
|
||||
"""不同属性的实例不等."""
|
||||
p1 = TransitionPreset(id="t1", name="T", category="basic")
|
||||
p2 = TransitionPreset(id="t2", name="T", category="basic")
|
||||
assert p1 != p2
|
||||
|
||||
|
||||
# ── 预设库完整性测试 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionPresetLibrary:
|
||||
"""TRANSITION_PRESET_LIBRARY 预设库测试"""
|
||||
"""TRANSITION_PRESET_LIBRARY 预设库完整性测试."""
|
||||
|
||||
def test_library_not_empty(self):
|
||||
"""预设库不为空."""
|
||||
assert len(TRANSITION_PRESET_LIBRARY) > 0
|
||||
|
||||
def test_all_presets_have_unique_ids(self):
|
||||
"""所有预设 ID 唯一"""
|
||||
def test_preset_count(self):
|
||||
"""预设数量应大于20."""
|
||||
assert len(TRANSITION_PRESET_LIBRARY) >= 20
|
||||
|
||||
def test_all_ids_unique(self):
|
||||
"""所有预设ID唯一."""
|
||||
ids = [p.id for p in TRANSITION_PRESET_LIBRARY]
|
||||
assert len(ids) == len(set(ids))
|
||||
assert len(ids) == len(set(ids)), f"存在重复ID: {[i for i in ids if ids.count(i) > 1]}"
|
||||
|
||||
def test_all_presets_have_required_fields(self):
|
||||
"""所有预设都有必填字段"""
|
||||
for preset in TRANSITION_PRESET_LIBRARY:
|
||||
assert preset.id, "missing id"
|
||||
assert preset.name, f"{preset.id} missing name"
|
||||
assert preset.category, f"{preset.id} missing category"
|
||||
assert preset.transition, f"{preset.id} missing transition"
|
||||
def test_all_have_required_fields(self):
|
||||
"""所有预设都有必填字段."""
|
||||
for p in TRANSITION_PRESET_LIBRARY:
|
||||
assert p.id, f"预设缺少id: {p}"
|
||||
assert p.name, f"预设 {p.id} 缺少name"
|
||||
assert p.category, f"预设 {p.id} 缺少category"
|
||||
assert p.transition, f"预设 {p.id} 缺少transition"
|
||||
|
||||
def test_transition_none_exists(self):
|
||||
"""无转场预设存在"""
|
||||
none_preset = next((p for p in TRANSITION_PRESET_LIBRARY if p.id == "transition_none"), None)
|
||||
def test_duration_range_valid(self):
|
||||
"""每个预设的时长范围合理: min <= default <= max."""
|
||||
for p in TRANSITION_PRESET_LIBRARY:
|
||||
assert (
|
||||
p.min_duration <= p.default_duration
|
||||
), f"{p.id}: min({p.min_duration}) > default({p.default_duration})"
|
||||
assert (
|
||||
p.default_duration <= p.max_duration
|
||||
), f"{p.id}: default({p.default_duration}) > max({p.max_duration})"
|
||||
|
||||
def test_min_duration_non_negative(self):
|
||||
"""最小时长不能为负."""
|
||||
for p in TRANSITION_PRESET_LIBRARY:
|
||||
assert p.min_duration >= 0, f"{p.id}: min_duration为负"
|
||||
|
||||
def test_categories_are_valid(self):
|
||||
"""分类都在预期集合内."""
|
||||
valid_categories = {"basic", "fade", "slide", "zoom", "warp", "special"}
|
||||
for p in TRANSITION_PRESET_LIBRARY:
|
||||
assert p.category in valid_categories, f"{p.id}: 未知分类 {p.category}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"category,expected_min",
|
||||
[
|
||||
("basic", 2),
|
||||
("fade", 4),
|
||||
("slide", 4),
|
||||
("zoom", 2),
|
||||
("warp", 6),
|
||||
("special", 2),
|
||||
],
|
||||
)
|
||||
def test_category_min_count(self, category: str, expected_min: int):
|
||||
"""每个分类至少有预期数量的预设."""
|
||||
count = sum(1 for p in TRANSITION_PRESET_LIBRARY if p.category == category)
|
||||
assert count >= expected_min, f"分类 {category} 只有 {count} 个,预期至少 {expected_min}"
|
||||
|
||||
def test_tags_is_list(self):
|
||||
"""tags字段是列表."""
|
||||
for p in TRANSITION_PRESET_LIBRARY:
|
||||
assert isinstance(p.tags, list), f"{p.id}: tags不是列表"
|
||||
|
||||
def test_none_transition_has_zero_duration(self):
|
||||
"""无转场预设时长为0."""
|
||||
none_preset = get_transition_preset("transition_none")
|
||||
assert none_preset is not None
|
||||
assert none_preset.name == "无转场"
|
||||
assert none_preset.min_duration == 0.0
|
||||
assert none_preset.max_duration == 0.0
|
||||
assert none_preset.default_duration == 0.0
|
||||
assert none_preset.transition == "none"
|
||||
|
||||
def test_transition_random_exists(self):
|
||||
"""随机转场预设存在"""
|
||||
random_preset = next((p for p in TRANSITION_PRESET_LIBRARY if p.id == "transition_random"), None)
|
||||
assert random_preset is not None
|
||||
assert random_preset.name == "随机"
|
||||
|
||||
def test_fade_category_exists(self):
|
||||
"""淡入淡出分类有预设"""
|
||||
fade_presets = [p for p in TRANSITION_PRESET_LIBRARY if p.category == "fade"]
|
||||
assert len(fade_presets) >= 2
|
||||
|
||||
def test_duration_constraints_valid(self):
|
||||
"""时长约束:min <= default <= max"""
|
||||
for preset in TRANSITION_PRESET_LIBRARY:
|
||||
assert preset.min_duration <= preset.default_duration, f"{preset.id}: min > default"
|
||||
assert preset.default_duration <= preset.max_duration, f"{preset.id}: default > max"
|
||||
assert preset.min_duration >= 0, f"{preset.id}: min < 0"
|
||||
|
||||
def test_known_categories_exist(self):
|
||||
"""已知分类都有预设"""
|
||||
categories = {p.category for p in TRANSITION_PRESET_LIBRARY}
|
||||
assert "basic" in categories
|
||||
assert "fade" in categories
|
||||
# ── get_transition_preset 测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetTransitionPreset:
|
||||
"""get_transition_preset 函数测试"""
|
||||
"""get_transition_preset 函数测试."""
|
||||
|
||||
def test_get_existing_preset(self):
|
||||
preset = get_transition_preset("transition_none")
|
||||
assert preset is not None
|
||||
assert preset.id == "transition_none"
|
||||
|
||||
def test_get_fade_preset(self):
|
||||
preset = get_transition_preset("transition_fade")
|
||||
assert preset is not None
|
||||
assert preset.transition == "fade"
|
||||
"""获取存在的预设."""
|
||||
p = get_transition_preset("transition_fade")
|
||||
assert p is not None
|
||||
assert p.id == "transition_fade"
|
||||
assert p.name == "淡入淡出"
|
||||
assert p.category == "fade"
|
||||
|
||||
def test_get_nonexistent_preset(self):
|
||||
assert get_transition_preset("nonexistent_transition") is None
|
||||
"""获取不存在的预设返回None."""
|
||||
p = get_transition_preset("nonexistent_id")
|
||||
assert p is None
|
||||
|
||||
def test_returns_transitionpreset_type(self):
|
||||
preset = get_transition_preset("transition_fade")
|
||||
assert isinstance(preset, TransitionPreset)
|
||||
def test_get_none_preset(self):
|
||||
"""获取无转场预设."""
|
||||
p = get_transition_preset("transition_none")
|
||||
assert p is not None
|
||||
assert p.transition == "none"
|
||||
|
||||
def test_get_random_preset(self):
|
||||
"""获取随机预设."""
|
||||
p = get_transition_preset("transition_random")
|
||||
assert p is not None
|
||||
assert p.transition == "random"
|
||||
|
||||
def test_case_sensitive(self):
|
||||
"""ID区分大小写."""
|
||||
p = get_transition_preset("TRANSITION_FADE")
|
||||
assert p is None
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串返回None."""
|
||||
p = get_transition_preset("")
|
||||
assert p is None
|
||||
|
||||
def test_returns_same_instance(self):
|
||||
"""多次调用返回同一个对象(库引用)."""
|
||||
p1 = get_transition_preset("transition_fade")
|
||||
p2 = get_transition_preset("transition_fade")
|
||||
assert p1 is p2
|
||||
|
||||
def test_all_presets_accessible_by_id(self):
|
||||
"""所有预设都可通过ID获取."""
|
||||
for p in TRANSITION_PRESET_LIBRARY:
|
||||
fetched = get_transition_preset(p.id)
|
||||
assert fetched is not None, f"无法通过ID获取: {p.id}"
|
||||
assert fetched.id == p.id
|
||||
|
||||
|
||||
# ── list_transition_presets 测试 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListTransitionPresets:
|
||||
"""list_transition_presets 函数测试"""
|
||||
"""list_transition_presets 函数测试."""
|
||||
|
||||
def test_list_all(self):
|
||||
"""不带参数返回所有预设"""
|
||||
all_presets = list_transition_presets()
|
||||
assert len(all_presets) == len(TRANSITION_PRESET_LIBRARY)
|
||||
def test_no_filter_returns_all(self):
|
||||
"""无筛选参数返回全部预设."""
|
||||
results = list_transition_presets()
|
||||
assert len(results) == len(TRANSITION_PRESET_LIBRARY)
|
||||
|
||||
def test_filter_by_category(self):
|
||||
"""按分类筛选"""
|
||||
fade_presets = list_transition_presets(category="fade")
|
||||
assert len(fade_presets) > 0
|
||||
assert all(p.category == "fade" for p in fade_presets)
|
||||
def test_filter_by_category_fade(self):
|
||||
"""按fade分类筛选."""
|
||||
results = list_transition_presets(category="fade")
|
||||
assert len(results) > 0
|
||||
assert all(p.category == "fade" for p in results)
|
||||
|
||||
def test_filter_by_basic_category(self):
|
||||
basic_presets = list_transition_presets(category="basic")
|
||||
assert len(basic_presets) >= 2
|
||||
def test_filter_by_category_slide(self):
|
||||
"""按slide分类筛选."""
|
||||
results = list_transition_presets(category="slide")
|
||||
assert len(results) == 4
|
||||
assert all(p.category == "slide" for p in results)
|
||||
|
||||
def test_filter_by_nonexistent_category(self):
|
||||
result = list_transition_presets(category="nonexistent")
|
||||
assert result == []
|
||||
def test_filter_by_category_basic(self):
|
||||
"""按basic分类筛选."""
|
||||
results = list_transition_presets(category="basic")
|
||||
assert len(results) == 2 # none + random
|
||||
|
||||
def test_search_by_name(self):
|
||||
"""按名称搜索"""
|
||||
result = list_transition_presets(keyword="淡入")
|
||||
assert len(result) >= 1
|
||||
assert any("淡入" in p.name for p in result)
|
||||
def test_filter_by_invalid_category(self):
|
||||
"""无效分类返回空列表."""
|
||||
results = list_transition_presets(category="nonexistent")
|
||||
assert results == []
|
||||
|
||||
def test_search_by_tag(self):
|
||||
"""按标签搜索"""
|
||||
tagged = [p for p in TRANSITION_PRESET_LIBRARY if p.tags]
|
||||
if tagged:
|
||||
tag = tagged[0].tags[0]
|
||||
result = list_transition_presets(keyword=tag)
|
||||
assert len(result) >= 1
|
||||
def test_keyword_search_in_name(self):
|
||||
"""关键词搜索name字段."""
|
||||
results = list_transition_presets(keyword="淡入淡出")
|
||||
assert len(results) >= 1
|
||||
assert any(p.id == "transition_fade" for p in results)
|
||||
|
||||
def test_search_empty_returns_all(self):
|
||||
result = list_transition_presets(keyword="")
|
||||
assert len(result) == len(TRANSITION_PRESET_LIBRARY)
|
||||
def test_keyword_search_in_description(self):
|
||||
"""关键词搜索description字段."""
|
||||
results = list_transition_presets(keyword="硬切")
|
||||
assert len(results) >= 1
|
||||
assert any(p.id == "transition_none" for p in results)
|
||||
|
||||
def test_combined_category_and_search(self):
|
||||
result = list_transition_presets(category="fade", keyword="淡入")
|
||||
assert all(p.category == "fade" for p in result)
|
||||
def test_keyword_search_in_tags(self):
|
||||
"""关键词搜索tags字段."""
|
||||
results = list_transition_presets(keyword="模糊")
|
||||
assert len(results) >= 2 # hblur + wipeblur
|
||||
ids = [p.id for p in results]
|
||||
assert "transition_hblur" in ids
|
||||
assert "transition_wipeblur" in ids
|
||||
|
||||
def test_returns_list_of_transitionpreset(self):
|
||||
result = list_transition_presets()
|
||||
assert all(isinstance(p, TransitionPreset) for p in result)
|
||||
def test_keyword_case_insensitive(self):
|
||||
"""关键词搜索不区分大小写(英文)."""
|
||||
results1 = list_transition_presets(keyword="fade")
|
||||
results2 = list_transition_presets(keyword="FADE")
|
||||
assert len(results1) == len(results2)
|
||||
|
||||
def test_keyword_chinese(self):
|
||||
"""中文关键词搜索."""
|
||||
results = list_transition_presets(keyword="滑")
|
||||
assert len(results) >= 4 # 4个slide
|
||||
assert all("滑" in p.name for p in results)
|
||||
|
||||
def test_keyword_no_match(self):
|
||||
"""无匹配关键词返回空."""
|
||||
results = list_transition_presets(keyword="完全不存在的关键词xyz")
|
||||
assert results == []
|
||||
|
||||
def test_keyword_empty_string(self):
|
||||
"""空关键词返回全部."""
|
||||
results = list_transition_presets(keyword="")
|
||||
assert len(results) == len(TRANSITION_PRESET_LIBRARY)
|
||||
|
||||
def test_category_and_keyword_combined(self):
|
||||
"""分类+关键词组合筛选."""
|
||||
results = list_transition_presets(category="warp", keyword="擦除")
|
||||
assert len(results) >= 4 # 4个wipe
|
||||
assert all(p.category == "warp" for p in results)
|
||||
assert all("擦除" in p.name for p in results)
|
||||
|
||||
def test_category_and_keyword_no_match(self):
|
||||
"""分类+关键词不匹配返回空."""
|
||||
results = list_transition_presets(category="fade", keyword="滑动")
|
||||
assert results == []
|
||||
|
||||
def test_preserve_order(self):
|
||||
"""保持预设库的顺序."""
|
||||
results = list_transition_presets()
|
||||
for i, p in enumerate(TRANSITION_PRESET_LIBRARY):
|
||||
assert results[i].id == p.id
|
||||
|
||||
def test_filtered_results_are_all_valid(self):
|
||||
"""筛选结果的每个预设都有完整属性."""
|
||||
results = list_transition_presets(category="slide")
|
||||
for p in results:
|
||||
assert p.id
|
||||
assert p.name
|
||||
assert p.category == "slide"
|
||||
assert isinstance(p.tags, list)
|
||||
|
||||
|
||||
# ── get_default_transition 测试 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetDefaultTransition:
|
||||
"""get_default_transition 函数测试"""
|
||||
|
||||
def test_returns_preset(self):
|
||||
preset = get_default_transition()
|
||||
assert preset is not None
|
||||
assert isinstance(preset, TransitionPreset)
|
||||
"""get_default_transition 函数测试."""
|
||||
|
||||
def test_default_is_none(self):
|
||||
"""默认转场是无转场(硬切)"""
|
||||
preset = get_default_transition()
|
||||
assert preset.id == "transition_none"
|
||||
assert preset.transition == "none"
|
||||
"""默认转场是无转场."""
|
||||
p = get_default_transition()
|
||||
assert p.id == "transition_none"
|
||||
assert p.transition == "none"
|
||||
|
||||
def test_default_has_zero_duration(self):
|
||||
"""无转场默认时长为 0"""
|
||||
preset = get_default_transition()
|
||||
assert preset.default_duration == 0.0
|
||||
assert preset.min_duration == 0.0
|
||||
assert preset.max_duration == 0.0
|
||||
def test_default_zero_duration(self):
|
||||
"""默认转场时长为0."""
|
||||
p = get_default_transition()
|
||||
assert p.default_duration == 0.0
|
||||
assert p.min_duration == 0.0
|
||||
assert p.max_duration == 0.0
|
||||
|
||||
def test_default_category_basic(self):
|
||||
"""默认转场属于basic分类."""
|
||||
p = get_default_transition()
|
||||
assert p.category == "basic"
|
||||
|
||||
def test_default_same_instance(self):
|
||||
"""多次调用返回同一实例."""
|
||||
p1 = get_default_transition()
|
||||
p2 = get_default_transition()
|
||||
assert p1 is p2
|
||||
|
||||
def test_default_matches_get_preset(self):
|
||||
"""默认转场与通过ID获取的一致."""
|
||||
default = get_default_transition()
|
||||
by_id = get_transition_preset("transition_none")
|
||||
assert default is by_id
|
||||
|
||||
|
||||
# ── 预设个体属性抽样测试 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPresetSamples:
|
||||
"""典型预设的属性验证."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"preset_id,expected_name,expected_category,expected_transition",
|
||||
[
|
||||
("transition_none", "无转场", "basic", "none"),
|
||||
("transition_random", "随机", "basic", "random"),
|
||||
("transition_fade", "淡入淡出", "fade", "fade"),
|
||||
("transition_fadeblack", "黑场过渡", "fade", "fadeblack"),
|
||||
("transition_fadewhite", "白场过渡", "fade", "fadewhite"),
|
||||
("transition_slideleft", "左滑", "slide", "slideleft"),
|
||||
("transition_slideright", "右滑", "slide", "slideright"),
|
||||
("transition_slideup", "上滑", "slide", "slideup"),
|
||||
("transition_slidedown", "下滑", "slide", "slidedown"),
|
||||
("transition_zoomin", "放大进入", "zoom", "zoomin"),
|
||||
("transition_zoomout", "缩小退出", "zoom", "zoomout"),
|
||||
("transition_dissolve", "溶解", "warp", "dissolve"),
|
||||
("transition_circlecrop", "圆形展开", "warp", "circlecrop"),
|
||||
("transition_hblur", "水平模糊", "special", "hblur"),
|
||||
],
|
||||
)
|
||||
def test_preset_attributes(
|
||||
self, preset_id: str, expected_name: str, expected_category: str, expected_transition: str
|
||||
):
|
||||
"""典型预设属性验证."""
|
||||
p = get_transition_preset(preset_id)
|
||||
assert p is not None
|
||||
assert p.name == expected_name
|
||||
assert p.category == expected_category
|
||||
assert p.transition == expected_transition
|
||||
|
||||
def test_dissolve_longer_default(self):
|
||||
"""溶解效果默认时长较长(0.8s)."""
|
||||
p = get_transition_preset("transition_dissolve")
|
||||
assert p is not None
|
||||
assert p.default_duration == 0.8
|
||||
|
||||
+471
-255
@@ -1,4 +1,4 @@
|
||||
"""trim_config 领域模型单测."""
|
||||
"""trim_config 裁剪配置领域模型单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,409 +15,625 @@ from packages.domain.trim_config import (
|
||||
resolve_segments,
|
||||
)
|
||||
|
||||
# ── TrimConfig.from_dict 测试 ─────────────────────────────────────────────
|
||||
# ── TrimConfig.from_dict 测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrimConfigFromDict:
|
||||
"""TrimConfig.from_dict 测试."""
|
||||
|
||||
def test_none_returns_none(self):
|
||||
"""None返回None."""
|
||||
assert TrimConfig.from_dict(None) is None
|
||||
|
||||
def test_empty_dict_returns_none(self):
|
||||
"""空dict返回None."""
|
||||
assert TrimConfig.from_dict({}) is None
|
||||
|
||||
def test_all_zero_returns_none(self):
|
||||
"""全0返回None(不裁剪)."""
|
||||
assert TrimConfig.from_dict({"start_time": 0, "end_time": 0, "duration": 0}) is None
|
||||
|
||||
def test_start_only_valid(self):
|
||||
cfg = TrimConfig.from_dict({"start_time": 5.0})
|
||||
assert cfg is not None
|
||||
assert cfg.start_time == 5.0
|
||||
assert cfg.end_time == 0
|
||||
assert cfg.duration == 0
|
||||
|
||||
def test_duration_only_valid(self):
|
||||
cfg = TrimConfig.from_dict({"duration": 10.0})
|
||||
assert cfg is not None
|
||||
assert cfg.duration == 10.0
|
||||
assert cfg.start_time == 0
|
||||
def test_start_and_end(self):
|
||||
"""start + end."""
|
||||
result = TrimConfig.from_dict({"start_time": 5, "end_time": 10})
|
||||
assert result is not None
|
||||
assert result.start_time == 5.0
|
||||
assert result.end_time == 10.0
|
||||
|
||||
def test_start_and_duration(self):
|
||||
cfg = TrimConfig.from_dict({"start_time": 2.0, "duration": 5.0})
|
||||
assert cfg is not None
|
||||
assert cfg.start_time == 2.0
|
||||
assert cfg.duration == 5.0
|
||||
"""start + duration."""
|
||||
result = TrimConfig.from_dict({"start_time": 2, "duration": 5})
|
||||
assert result is not None
|
||||
assert result.start_time == 2.0
|
||||
assert result.duration == 5.0
|
||||
|
||||
def test_end_and_duration(self):
|
||||
"""end + duration."""
|
||||
result = TrimConfig.from_dict({"end_time": 10, "duration": 3})
|
||||
assert result is not None
|
||||
assert result.end_time == 10.0
|
||||
assert result.duration == 3.0
|
||||
|
||||
def test_only_start_returns_config(self):
|
||||
"""只有start_time也返回有效配置(从start取到末尾语义)."""
|
||||
result = TrimConfig.from_dict({"start_time": 3})
|
||||
assert result is not None
|
||||
assert result.start_time == 3.0
|
||||
|
||||
def test_only_duration_returns_config(self):
|
||||
"""只有duration也返回(从开头取duration)."""
|
||||
result = TrimConfig.from_dict({"duration": 5})
|
||||
assert result is not None
|
||||
assert result.duration == 5.0
|
||||
|
||||
def test_only_end_returns_config(self):
|
||||
"""只有end_time也返回."""
|
||||
result = TrimConfig.from_dict({"end_time": 8})
|
||||
assert result is not None
|
||||
assert result.end_time == 8.0
|
||||
|
||||
def test_string_values(self):
|
||||
"""字符串值能正确解析."""
|
||||
result = TrimConfig.from_dict({"start_time": "2.5", "duration": "3"})
|
||||
assert result is not None
|
||||
assert result.start_time == 2.5
|
||||
assert result.duration == 3.0
|
||||
|
||||
def test_none_values_treated_as_zero(self):
|
||||
"""None值当作0处理."""
|
||||
result = TrimConfig.from_dict({"start_time": None, "duration": 5})
|
||||
assert result is not None
|
||||
assert result.start_time == 0.0
|
||||
assert result.duration == 5.0
|
||||
|
||||
def test_false_values_treated_as_zero(self):
|
||||
"""0/false值当作0处理."""
|
||||
result = TrimConfig.from_dict({"start_time": 0, "duration": 0})
|
||||
assert result is None
|
||||
|
||||
def test_all_three_params(self):
|
||||
"""三个参数都给了."""
|
||||
result = TrimConfig.from_dict({"start_time": 1, "end_time": 6, "duration": 5})
|
||||
assert result is not None
|
||||
assert result.start_time == 1.0
|
||||
assert result.end_time == 6.0
|
||||
assert result.duration == 5.0
|
||||
|
||||
|
||||
# ── TrimConfig.validate_and_resolve 测试 ────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrimConfigValidateAndResolve:
|
||||
"""validate_and_resolve 三选二推导 + 边界钳制测试."""
|
||||
|
||||
# 基础三选二推导
|
||||
|
||||
def test_start_and_end(self):
|
||||
cfg = TrimConfig.from_dict({"start_time": 1.0, "end_time": 5.0})
|
||||
assert cfg is not None
|
||||
assert cfg.start_time == 1.0
|
||||
assert cfg.end_time == 5.0
|
||||
"""start + end → 推导duration."""
|
||||
cfg = TrimConfig(start_time=5, end_time=15)
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.start_time == 5.0
|
||||
assert result.end_time == 15.0
|
||||
assert result.duration == 10.0
|
||||
|
||||
def test_end_only(self):
|
||||
cfg = TrimConfig.from_dict({"end_time": 8.0})
|
||||
assert cfg is not None
|
||||
assert cfg.end_time == 8.0
|
||||
def test_start_and_duration(self):
|
||||
"""start + duration → 推导end."""
|
||||
cfg = TrimConfig(start_time=3, duration=7)
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.start_time == 3.0
|
||||
assert result.duration == 7.0
|
||||
assert result.end_time == 10.0
|
||||
|
||||
def test_string_values_coerced(self):
|
||||
cfg = TrimConfig.from_dict({"start_time": "3.5", "duration": "2.0"})
|
||||
assert cfg is not None
|
||||
assert cfg.start_time == 3.5
|
||||
assert cfg.duration == 2.0
|
||||
def test_end_and_duration(self):
|
||||
"""end + duration → 推导start."""
|
||||
cfg = TrimConfig(end_time=20, duration=5)
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.end_time == 20.0
|
||||
assert result.duration == 5.0
|
||||
assert result.start_time == 15.0
|
||||
|
||||
def test_falsy_values_treated_as_zero(self):
|
||||
cfg = TrimConfig.from_dict({"start_time": None, "duration": None})
|
||||
assert cfg is None
|
||||
def test_end_minus_duration_negative(self):
|
||||
"""end + duration 但算出start<0 → 钳制到0重新计算."""
|
||||
cfg = TrimConfig(end_time=3, duration=10)
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.start_time == 0.0
|
||||
assert result.end_time == 3.0
|
||||
assert result.duration == 3.0
|
||||
|
||||
def test_default_values(self):
|
||||
def test_only_start_takes_to_end(self):
|
||||
"""只有start → 取到素材末尾."""
|
||||
cfg = TrimConfig(start_time=5)
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.start_time == 5.0
|
||||
assert result.end_time == 30.0
|
||||
assert result.duration == 25.0
|
||||
|
||||
def test_only_end_takes_from_start(self):
|
||||
"""只有end → 从开头取到end."""
|
||||
cfg = TrimConfig(end_time=10)
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.start_time == 0.0
|
||||
assert result.end_time == 10.0
|
||||
assert result.duration == 10.0
|
||||
|
||||
def test_only_duration(self):
|
||||
"""只有duration → 从开头取duration."""
|
||||
cfg = TrimConfig(duration=8)
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.start_time == 0.0
|
||||
assert result.end_time == 8.0
|
||||
assert result.duration == 8.0
|
||||
|
||||
def test_all_zero_noop(self):
|
||||
"""全0 → noop不裁剪."""
|
||||
cfg = TrimConfig()
|
||||
assert cfg.start_time == 0.0
|
||||
assert cfg.end_time == 0.0
|
||||
assert cfg.duration == 0.0
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.start_time == 0.0
|
||||
assert result.duration == 0.0
|
||||
assert result.is_noop
|
||||
|
||||
# 边界钳制
|
||||
|
||||
# ── validate_and_resolve 测试 ─────────────────────────────────────────────
|
||||
def test_start_negative_clamped(self):
|
||||
"""start为负 → 钳制到0."""
|
||||
cfg = TrimConfig(start_time=-5, duration=10)
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.start_time == 0.0
|
||||
assert result.duration == 10.0
|
||||
assert result.end_time == 10.0
|
||||
|
||||
def test_end_exceeds_asset_duration(self):
|
||||
"""end超过素材时长 → 钳制."""
|
||||
cfg = TrimConfig(start_time=5, end_time=50)
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.end_time == 30.0
|
||||
assert result.duration == 25.0
|
||||
|
||||
class TestValidateAndResolve:
|
||||
def test_start_and_end_resolves_duration(self):
|
||||
cfg = TrimConfig(start_time=2.0, end_time=7.0)
|
||||
resolved = cfg.validate_and_resolve(100.0)
|
||||
assert resolved.start_time == 2.0
|
||||
assert resolved.end_time == 7.0
|
||||
assert resolved.duration == 5.0
|
||||
def test_start_exceeds_asset_duration(self):
|
||||
"""start超过素材时长 → 移到末尾取最小片段."""
|
||||
cfg = TrimConfig(start_time=40, duration=5)
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.end_time == 30.0
|
||||
assert result.start_time >= 0
|
||||
assert result.duration >= 0
|
||||
|
||||
def test_start_and_duration_resolves_end(self):
|
||||
cfg = TrimConfig(start_time=3.0, duration=10.0)
|
||||
resolved = cfg.validate_and_resolve(100.0)
|
||||
assert resolved.start_time == 3.0
|
||||
assert resolved.duration == 10.0
|
||||
assert resolved.end_time == 13.0
|
||||
def test_start_equals_end_invalid(self):
|
||||
"""start >= end → 无效(duration=0)."""
|
||||
cfg = TrimConfig(start_time=10, end_time=10)
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.duration == 0.0
|
||||
assert result.is_valid is False
|
||||
|
||||
def test_end_and_duration_resolves_start(self):
|
||||
cfg = TrimConfig(end_time=15.0, duration=5.0)
|
||||
resolved = cfg.validate_and_resolve(100.0)
|
||||
assert resolved.end_time == 15.0
|
||||
assert resolved.duration == 5.0
|
||||
assert resolved.start_time == 10.0
|
||||
|
||||
def test_start_only_takes_to_end(self):
|
||||
cfg = TrimConfig(start_time=5.0)
|
||||
resolved = cfg.validate_and_resolve(30.0)
|
||||
assert resolved.start_time == 5.0
|
||||
assert resolved.end_time == 30.0
|
||||
assert resolved.duration == 25.0
|
||||
|
||||
def test_end_only_takes_from_start(self):
|
||||
cfg = TrimConfig(end_time=8.0)
|
||||
resolved = cfg.validate_and_resolve(30.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.end_time == 8.0
|
||||
assert resolved.duration == 8.0
|
||||
|
||||
def test_duration_only_from_zero(self):
|
||||
cfg = TrimConfig(duration=10.0)
|
||||
resolved = cfg.validate_and_resolve(30.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.duration == 10.0
|
||||
assert resolved.end_time == 10.0
|
||||
|
||||
def test_negative_start_clamped(self):
|
||||
cfg = TrimConfig(start_time=-5.0, duration=10.0)
|
||||
resolved = cfg.validate_and_resolve(30.0)
|
||||
assert resolved.start_time == 0.0
|
||||
|
||||
def test_end_exceeds_asset_clamped(self):
|
||||
cfg = TrimConfig(start_time=5.0, duration=50.0)
|
||||
resolved = cfg.validate_and_resolve(30.0)
|
||||
assert resolved.end_time == 30.0
|
||||
assert resolved.duration == 25.0
|
||||
|
||||
def test_start_exceeds_asset_clamped(self):
|
||||
cfg = TrimConfig(start_time=50.0, duration=10.0)
|
||||
resolved = cfg.validate_and_resolve(30.0)
|
||||
assert resolved.start_time < 30.0
|
||||
assert resolved.end_time == 30.0
|
||||
|
||||
def test_end_before_start_invalid(self):
|
||||
cfg = TrimConfig(start_time=10.0, end_time=5.0)
|
||||
resolved = cfg.validate_and_resolve(30.0)
|
||||
assert resolved.duration == 0.0
|
||||
assert resolved.is_valid is False
|
||||
def test_start_greater_than_end(self):
|
||||
"""start > end → 无效."""
|
||||
cfg = TrimConfig(start_time=15, end_time=10)
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.duration == 0.0
|
||||
assert result.is_valid is False
|
||||
|
||||
def test_zero_asset_duration(self):
|
||||
cfg = TrimConfig(start_time=1.0, duration=5.0)
|
||||
resolved = cfg.validate_and_resolve(0.0)
|
||||
assert resolved.is_noop
|
||||
"""素材时长为0 → 返回noop."""
|
||||
cfg = TrimConfig(start_time=5, duration=10)
|
||||
result = cfg.validate_and_resolve(asset_duration=0)
|
||||
assert result.start_time == 0.0
|
||||
assert result.duration == 0.0
|
||||
assert result.is_noop
|
||||
|
||||
def test_negative_asset_duration(self):
|
||||
cfg = TrimConfig(start_time=1.0, duration=5.0)
|
||||
resolved = cfg.validate_and_resolve(-1.0)
|
||||
assert resolved.is_noop
|
||||
"""素材时长为负 → 返回noop."""
|
||||
cfg = TrimConfig(start_time=1, duration=2)
|
||||
result = cfg.validate_and_resolve(asset_duration=-5)
|
||||
assert result.is_noop
|
||||
|
||||
def test_end_and_duration_with_negative_start(self):
|
||||
cfg = TrimConfig(end_time=3.0, duration=10.0)
|
||||
resolved = cfg.validate_and_resolve(30.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.end_time == 3.0
|
||||
assert resolved.duration == 3.0
|
||||
# duration 边界
|
||||
|
||||
def test_all_three_params_uses_start_duration(self):
|
||||
cfg = TrimConfig(start_time=2.0, end_time=8.0, duration=3.0)
|
||||
resolved = cfg.validate_and_resolve(30.0)
|
||||
# 有 start + end 时应该用 start+end 推导 duration
|
||||
assert resolved.start_time == 2.0
|
||||
assert resolved.end_time == 8.0
|
||||
assert resolved.duration == 6.0
|
||||
def test_duration_preserved_exactly(self):
|
||||
"""精确时长保持."""
|
||||
cfg = TrimConfig(start_time=1.234, duration=2.567)
|
||||
result = cfg.validate_and_resolve(asset_duration=10)
|
||||
assert abs(result.duration - 2.567) < 0.001
|
||||
assert abs(result.start_time - 1.234) < 0.001
|
||||
|
||||
def test_empty_config_returns_noop(self):
|
||||
cfg = TrimConfig()
|
||||
resolved = cfg.validate_and_resolve(30.0)
|
||||
assert resolved.is_noop
|
||||
def test_duration_never_negative(self):
|
||||
"""duration永远不为负."""
|
||||
cfg = TrimConfig(start_time=10, end_time=5)
|
||||
result = cfg.validate_and_resolve(asset_duration=30)
|
||||
assert result.duration >= 0
|
||||
|
||||
|
||||
# ── is_valid / is_noop / trim_from_start 测试 ─────────────────────────────
|
||||
# ── TrimConfig 属性测试 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestProperties:
|
||||
def test_is_valid_true_for_normal(self):
|
||||
cfg = TrimConfig(start_time=0, end_time=0, duration=5.0)
|
||||
class TestTrimConfigProperties:
|
||||
"""TrimConfig 属性测试."""
|
||||
|
||||
def test_is_valid_valid_trim(self):
|
||||
"""有效裁剪."""
|
||||
cfg = TrimConfig(start_time=0, end_time=0, duration=5)
|
||||
assert cfg.is_valid is True
|
||||
|
||||
def test_is_valid_false_for_zero(self):
|
||||
cfg = TrimConfig(duration=0.0)
|
||||
def test_is_valid_zero_duration(self):
|
||||
"""duration=0无效."""
|
||||
cfg = TrimConfig(duration=0)
|
||||
assert cfg.is_valid is False
|
||||
|
||||
def test_is_valid_false_for_very_small(self):
|
||||
cfg = TrimConfig(duration=0.01)
|
||||
assert cfg.is_valid is False
|
||||
|
||||
def test_is_valid_true_at_boundary(self):
|
||||
def test_is_valid_min_threshold(self):
|
||||
"""刚好等于最小阈值也算有效."""
|
||||
cfg = TrimConfig(duration=MIN_TRIM_DURATION)
|
||||
assert cfg.is_valid is True
|
||||
|
||||
def test_is_noop_true_for_default(self):
|
||||
cfg = TrimConfig()
|
||||
def test_is_valid_below_min(self):
|
||||
"""低于最小阈值无效."""
|
||||
cfg = TrimConfig(duration=MIN_TRIM_DURATION / 2)
|
||||
assert cfg.is_valid is False
|
||||
|
||||
def test_is_noop_true(self):
|
||||
"""从0开始且duration=0是noop."""
|
||||
cfg = TrimConfig(start_time=0, duration=0)
|
||||
assert cfg.is_noop is True
|
||||
|
||||
def test_is_noop_false_with_start(self):
|
||||
cfg = TrimConfig(start_time=1.0)
|
||||
def test_is_noop_false_has_start(self):
|
||||
"""有start不是noop."""
|
||||
cfg = TrimConfig(start_time=5, duration=0)
|
||||
assert cfg.is_noop is False
|
||||
|
||||
def test_is_noop_false_with_duration(self):
|
||||
cfg = TrimConfig(duration=1.0)
|
||||
def test_is_noop_false_has_duration(self):
|
||||
"""有duration不是noop."""
|
||||
cfg = TrimConfig(start_time=0, duration=1)
|
||||
assert cfg.is_noop is False
|
||||
|
||||
def test_trim_from_start_true(self):
|
||||
cfg = TrimConfig(start_time=0.0, duration=5.0)
|
||||
"""start=0是从开头裁剪."""
|
||||
cfg = TrimConfig(start_time=0, duration=5)
|
||||
assert cfg.trim_from_start is True
|
||||
|
||||
def test_trim_from_start_false(self):
|
||||
cfg = TrimConfig(start_time=2.0, duration=5.0)
|
||||
"""start>0不是从开头裁剪."""
|
||||
cfg = TrimConfig(start_time=2, duration=5)
|
||||
assert cfg.trim_from_start is False
|
||||
|
||||
def test_trim_from_start_negative_treated_as_zero(self):
|
||||
"""start<0也认为从开头."""
|
||||
cfg = TrimConfig(start_time=-1, duration=5)
|
||||
assert cfg.trim_from_start is True
|
||||
|
||||
# ── TrimSegment 测试 ───────────────────────────────────────────────────────
|
||||
|
||||
# ── TrimSegment 测试 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrimSegment:
|
||||
def test_from_dict_basic(self):
|
||||
seg = TrimSegment.from_dict({"segment_id": "s1", "start_time": 1.0, "duration": 3.0})
|
||||
assert seg.segment_id == "s1"
|
||||
assert seg.trim.start_time == 1.0
|
||||
assert seg.trim.duration == 3.0
|
||||
assert seg.order == 0
|
||||
"""TrimSegment 测试."""
|
||||
|
||||
def test_from_dict_with_order(self):
|
||||
seg = TrimSegment.from_dict({"segment_id": "s2", "start_time": 0, "end_time": 5.0, "order": 2})
|
||||
def test_from_dict_basic(self):
|
||||
"""基础构造."""
|
||||
data = {"segment_id": "seg1", "start_time": 1, "end_time": 5, "order": 2}
|
||||
seg = TrimSegment.from_dict(data)
|
||||
assert seg.segment_id == "seg1"
|
||||
assert seg.order == 2
|
||||
assert seg.trim.start_time == 1.0
|
||||
assert seg.trim.end_time == 5.0
|
||||
|
||||
def test_from_dict_default_order(self):
|
||||
seg = TrimSegment.from_dict({"start_time": 1.0}, default_order=5)
|
||||
"""缺order使用默认值."""
|
||||
data = {"segment_id": "s1", "start_time": 0, "duration": 3}
|
||||
seg = TrimSegment.from_dict(data, default_order=5)
|
||||
assert seg.order == 5
|
||||
|
||||
def test_from_dict_default_segment_id(self):
|
||||
seg = TrimSegment.from_dict({"start_time": 1.0}, default_order=3)
|
||||
def test_from_dict_missing_segment_id(self):
|
||||
"""缺segment_id用默认名."""
|
||||
data = {"start_time": 0, "duration": 2}
|
||||
seg = TrimSegment.from_dict(data, default_order=3)
|
||||
assert seg.segment_id == "seg_3"
|
||||
|
||||
def test_from_dict_duration(self):
|
||||
"""duration正确传递."""
|
||||
data = {"segment_id": "s1", "duration": 10}
|
||||
seg = TrimSegment.from_dict(data)
|
||||
assert seg.trim.duration == 10.0
|
||||
|
||||
# ── build_video_trim_filter 测试 ───────────────────────────────────────────
|
||||
|
||||
# ── build_video_trim_filter 测试 ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildVideoTrimFilter:
|
||||
def test_noop_returns_setpts(self):
|
||||
"""build_video_trim_filter 视频滤镜构建测试."""
|
||||
|
||||
def test_noop_filter(self):
|
||||
"""noop时只有setpts."""
|
||||
cfg = TrimConfig()
|
||||
result = build_video_trim_filter("[0:v]", cfg, "[v]")
|
||||
assert "setpts=PTS-STARTPTS" in result
|
||||
result = build_video_trim_filter("[0:v]", cfg, "[vout]")
|
||||
assert "trim=" not in result
|
||||
assert "[0:v]" in result
|
||||
assert "[v]" in result
|
||||
|
||||
def test_with_start_and_duration(self):
|
||||
cfg = TrimConfig(start_time=5.0, end_time=10.0, duration=5.0)
|
||||
result = build_video_trim_filter("[0:v]", cfg, "[out]")
|
||||
assert "trim=" in result
|
||||
assert "start=5.000" in result
|
||||
assert "duration=5.000" in result
|
||||
assert "setpts=PTS-STARTPTS" in result
|
||||
assert result.startswith("[0:v]")
|
||||
assert result.endswith("[vout]")
|
||||
|
||||
def test_contains_input_and_output_labels(self):
|
||||
cfg = TrimConfig(start_time=1.0, duration=2.0)
|
||||
result = build_video_trim_filter("[in_v]", cfg, "[out_v]")
|
||||
assert "[in_v]" in result
|
||||
assert "[out_v]" in result
|
||||
def test_start_and_duration(self):
|
||||
"""start + duration 完整滤镜."""
|
||||
cfg = TrimConfig(start_time=10, end_time=15, duration=5)
|
||||
result = build_video_trim_filter("[0:v]", cfg, "[v0]")
|
||||
assert "trim=start=10.000:duration=5.000" in result
|
||||
assert "setpts=PTS-STARTPTS" in result
|
||||
assert result.startswith("[0:v]")
|
||||
assert result.endswith("[v0]")
|
||||
|
||||
def test_duration_only(self):
|
||||
cfg = TrimConfig(duration=3.5)
|
||||
def test_only_start(self):
|
||||
"""只有start(取到末尾的情况resolve后也有duration)."""
|
||||
cfg = TrimConfig(start_time=5, end_time=30, duration=25)
|
||||
result = build_video_trim_filter("[1:v]", cfg, "[v1]")
|
||||
assert "start=5.000" in result
|
||||
assert "duration=25.000" in result
|
||||
|
||||
def test_only_duration_from_start(self):
|
||||
"""从开头裁剪duration."""
|
||||
cfg = TrimConfig(start_time=0, end_time=3, duration=3)
|
||||
result = build_video_trim_filter("[0:v]", cfg, "[out]")
|
||||
assert "trim=duration=3.000" in result or "trim=start=0" in result
|
||||
# start=0 不写,只有duration
|
||||
assert "start=0" not in result
|
||||
|
||||
def test_preserves_input_output_labels(self):
|
||||
"""保持输入输出标签."""
|
||||
cfg = TrimConfig(start_time=1, duration=2)
|
||||
result = build_video_trim_filter("[in_label]", cfg, "[out_label]")
|
||||
assert result.startswith("[in_label]")
|
||||
assert result.endswith("[out_label]")
|
||||
|
||||
def test_three_decimal_precision(self):
|
||||
"""三位小数精度."""
|
||||
cfg = TrimConfig(start_time=1.234, duration=2.678)
|
||||
result = build_video_trim_filter("[0:v]", cfg, "[v]")
|
||||
assert "duration=3.500" in result
|
||||
assert "start=" not in result
|
||||
assert "start=1.234" in result
|
||||
assert "duration=2.678" in result
|
||||
|
||||
|
||||
# ── build_audio_trim_filter 测试 ───────────────────────────────────────────
|
||||
# ── build_audio_trim_filter 测试 ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildAudioTrimFilter:
|
||||
def test_noop_returns_asetpts(self):
|
||||
"""build_audio_trim_filter 音频滤镜构建测试."""
|
||||
|
||||
def test_noop_filter(self):
|
||||
"""noop时只有asetpts."""
|
||||
cfg = TrimConfig()
|
||||
result = build_audio_trim_filter("[0:a]", cfg, "[a]")
|
||||
assert "asetpts=PTS-STARTPTS" in result
|
||||
result = build_audio_trim_filter("[0:a]", cfg, "[aout]")
|
||||
assert "atrim=" not in result
|
||||
assert "asetpts=PTS-STARTPTS" in result
|
||||
assert result.startswith("[0:a]")
|
||||
assert result.endswith("[aout]")
|
||||
|
||||
def test_with_start_and_duration(self):
|
||||
cfg = TrimConfig(start_time=2.0, end_time=7.0, duration=5.0)
|
||||
result = build_audio_trim_filter("[0:a]", cfg, "[out]")
|
||||
assert "atrim=" in result
|
||||
assert "start=2.000" in result
|
||||
assert "duration=5.000" in result
|
||||
def test_start_and_duration(self):
|
||||
"""start + duration 完整滤镜."""
|
||||
cfg = TrimConfig(start_time=5, duration=3)
|
||||
result = build_audio_trim_filter("[0:a]", cfg, "[a0]")
|
||||
assert "atrim=start=5.000:duration=3.000" in result
|
||||
assert "asetpts=PTS-STARTPTS" in result
|
||||
|
||||
def test_contains_input_and_output_labels(self):
|
||||
cfg = TrimConfig(start_time=1.0, duration=2.0)
|
||||
result = build_audio_trim_filter("[in_a]", cfg, "[out_a]")
|
||||
assert "[in_a]" in result
|
||||
assert "[out_a]" in result
|
||||
def test_only_duration(self):
|
||||
"""只有duration(start=0时不写start参数)."""
|
||||
cfg = TrimConfig(start_time=0, duration=4)
|
||||
result = build_audio_trim_filter("[0:a]", cfg, "[a0]")
|
||||
assert "atrim=duration=4.000" in result
|
||||
|
||||
def test_uses_atrim_not_trim(self):
|
||||
"""用atrim不是trim."""
|
||||
cfg = TrimConfig(start_time=1, duration=2)
|
||||
result = build_audio_trim_filter("[0:a]", cfg, "[a]")
|
||||
assert "atrim=" in result
|
||||
assert ",trim=" not in result
|
||||
|
||||
|
||||
# ── resolve_segments 测试 ──────────────────────────────────────────────────
|
||||
# ── resolve_segments 测试 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveSegments:
|
||||
def test_empty_list_returns_empty(self):
|
||||
result = resolve_segments([], 30.0)
|
||||
"""resolve_segments 多段裁剪解析测试."""
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表返回空."""
|
||||
result = resolve_segments([], asset_duration=30)
|
||||
assert result == []
|
||||
|
||||
def test_single_segment(self):
|
||||
segs = [TrimSegment(segment_id="s1", trim=TrimConfig(start_time=1.0, duration=5.0), order=0)]
|
||||
result = resolve_segments(segs, 30.0)
|
||||
"""单段解析."""
|
||||
seg = TrimSegment(
|
||||
segment_id="s1",
|
||||
trim=TrimConfig(start_time=0, duration=5),
|
||||
order=0,
|
||||
)
|
||||
result = resolve_segments([seg], asset_duration=30)
|
||||
assert len(result) == 1
|
||||
assert result[0].segment_id == "s1"
|
||||
assert result[0].trim.duration == 5.0
|
||||
|
||||
def test_invalid_segment_filters_out(self):
|
||||
def test_multiple_segments_sorted(self):
|
||||
"""多段按order排序."""
|
||||
segs = [
|
||||
TrimSegment(segment_id="good", trim=TrimConfig(start_time=0, duration=5.0), order=0),
|
||||
TrimSegment(
|
||||
segment_id="bad",
|
||||
trim=TrimConfig(start_time=5.0, end_time=5.0), # end == start → duration 0
|
||||
order=1,
|
||||
),
|
||||
TrimSegment(segment_id="s1", trim=TrimConfig(duration=2), order=2),
|
||||
TrimSegment(segment_id="s2", trim=TrimConfig(duration=3), order=0),
|
||||
TrimSegment(segment_id="s3", trim=TrimConfig(duration=1), order=1),
|
||||
]
|
||||
result = resolve_segments(segs, 30.0)
|
||||
assert len(result) == 1
|
||||
assert result[0].segment_id == "good"
|
||||
result = resolve_segments(segs, asset_duration=30)
|
||||
assert len(result) == 3
|
||||
assert result[0].segment_id == "s2"
|
||||
assert result[1].segment_id == "s3"
|
||||
assert result[2].segment_id == "s1"
|
||||
|
||||
def test_sorted_by_order(self):
|
||||
def test_filter_invalid_segments(self):
|
||||
"""过滤无效段."""
|
||||
segs = [
|
||||
TrimSegment(segment_id="s2", trim=TrimConfig(start_time=5.0, duration=3.0), order=2),
|
||||
TrimSegment(segment_id="s1", trim=TrimConfig(start_time=0, duration=3.0), order=1),
|
||||
TrimSegment(segment_id="s0", trim=TrimConfig(start_time=10.0, duration=3.0), order=0),
|
||||
TrimSegment(segment_id="valid", trim=TrimConfig(duration=5), order=0),
|
||||
TrimSegment(segment_id="invalid", trim=TrimConfig(duration=0), order=1),
|
||||
]
|
||||
result = resolve_segments(segs, 30.0)
|
||||
assert [s.segment_id for s in result] == ["s0", "s1", "s2"]
|
||||
result = resolve_segments(segs, asset_duration=30)
|
||||
assert len(result) == 1
|
||||
assert result[0].segment_id == "valid"
|
||||
|
||||
def test_negative_order_uses_index(self):
|
||||
"""order为负时使用索引."""
|
||||
segs = [
|
||||
TrimSegment(segment_id="s0", trim=TrimConfig(duration=3.0), order=-1),
|
||||
TrimSegment(segment_id="s1", trim=TrimConfig(duration=2), order=-1),
|
||||
TrimSegment(segment_id="s2", trim=TrimConfig(duration=3), order=-1),
|
||||
]
|
||||
result = resolve_segments(segs, 30.0)
|
||||
result = resolve_segments(segs, asset_duration=30)
|
||||
assert len(result) == 2
|
||||
# order用各自的index值(0, 1)
|
||||
|
||||
def test_resolves_with_asset_duration(self):
|
||||
"""用素材时长做边界钳制."""
|
||||
seg = TrimSegment(
|
||||
segment_id="s1",
|
||||
trim=TrimConfig(start_time=0, duration=50), # 超过素材时长
|
||||
order=0,
|
||||
)
|
||||
result = resolve_segments([seg], asset_duration=30)
|
||||
assert len(result) == 1
|
||||
assert result[0].order == 0
|
||||
assert result[0].trim.end_time == 30.0
|
||||
assert result[0].trim.duration == 30.0
|
||||
|
||||
|
||||
# ── parse_segments_from_config 测试 ────────────────────────────────────────
|
||||
# ── parse_segments_from_config 测试 ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseSegmentsFromConfig:
|
||||
def test_none_returns_empty(self):
|
||||
"""parse_segments_from_config 测试."""
|
||||
|
||||
def test_none_config(self):
|
||||
"""None返回空."""
|
||||
assert parse_segments_from_config(None) == []
|
||||
|
||||
def test_empty_dict_returns_empty(self):
|
||||
def test_empty_config(self):
|
||||
"""空dict返回空."""
|
||||
assert parse_segments_from_config({}) == []
|
||||
|
||||
def test_trim_segments_list(self):
|
||||
"""多段配置解析."""
|
||||
config = {
|
||||
"trim_segments": [
|
||||
{"segment_id": "s1", "start_time": 0, "duration": 3.0, "order": 0},
|
||||
{"segment_id": "s2", "start_time": 5.0, "duration": 2.0, "order": 1},
|
||||
{"segment_id": "s1", "start_time": 0, "duration": 3, "order": 0},
|
||||
{"segment_id": "s2", "start_time": 5, "duration": 4, "order": 1},
|
||||
]
|
||||
}
|
||||
result = parse_segments_from_config(config)
|
||||
assert len(result) == 2
|
||||
assert result[0].segment_id == "s1"
|
||||
assert result[0].trim.duration == 3.0
|
||||
assert result[1].segment_id == "s2"
|
||||
assert result[1].trim.start_time == 5.0
|
||||
|
||||
def test_trim_segments_skips_non_dict(self):
|
||||
config = {"trim_segments": [{"segment_id": "s1", "duration": 3.0}, "invalid", None]}
|
||||
result = parse_segments_from_config(config)
|
||||
assert len(result) == 1
|
||||
def test_trim_segments_empty_list(self):
|
||||
"""空segments列表 + 无单段 → 空."""
|
||||
config = {"trim_segments": []}
|
||||
assert parse_segments_from_config(config) == []
|
||||
|
||||
def test_single_trim_compat(self):
|
||||
config = {"trim_start": 1.0, "trim_duration": 5.0}
|
||||
def test_trim_segments_not_list(self):
|
||||
"""segments不是list → 回退到单段(如果有)."""
|
||||
config = {"trim_segments": "not_a_list"}
|
||||
assert parse_segments_from_config(config) == []
|
||||
|
||||
def test_single_trim_start(self):
|
||||
"""单段:trim_start."""
|
||||
config = {"trim_start": 2, "trim_duration": 5}
|
||||
result = parse_segments_from_config(config)
|
||||
assert len(result) == 1
|
||||
assert result[0].segment_id == "main"
|
||||
assert result[0].trim.start_time == 1.0
|
||||
assert result[0].trim.start_time == 2.0
|
||||
assert result[0].trim.duration == 5.0
|
||||
|
||||
def test_no_trim_fields_returns_empty(self):
|
||||
config = {"other_field": "value"}
|
||||
assert parse_segments_from_config(config) == []
|
||||
def test_single_trim_end(self):
|
||||
"""单段:trim_end."""
|
||||
config = {"trim_end": 10}
|
||||
result = parse_segments_from_config(config)
|
||||
assert len(result) == 1
|
||||
assert result[0].trim.end_time == 10.0
|
||||
|
||||
def test_segments_take_priority_over_single(self):
|
||||
"""多段配置优先于单段."""
|
||||
config = {
|
||||
"trim_segments": [{"segment_id": "s1", "start_time": 0, "duration": 2}],
|
||||
"trim_start": 5,
|
||||
"trim_duration": 3,
|
||||
}
|
||||
result = parse_segments_from_config(config)
|
||||
assert len(result) == 1
|
||||
assert result[0].segment_id == "s1" # 多段优先
|
||||
|
||||
def test_segments_filter_non_dict(self):
|
||||
"""过滤非dict元素."""
|
||||
config = {
|
||||
"trim_segments": [
|
||||
{"segment_id": "s1", "duration": 2},
|
||||
"not_a_dict",
|
||||
None,
|
||||
123,
|
||||
]
|
||||
}
|
||||
result = parse_segments_from_config(config)
|
||||
assert len(result) == 1
|
||||
assert result[0].segment_id == "s1"
|
||||
|
||||
|
||||
# ── extract_trim_from_clip_config 测试 ────────────────────────────────────
|
||||
# ── extract_trim_from_clip_config 测试 ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtractTrimFromClipConfig:
|
||||
def test_none_returns_none(self):
|
||||
"""extract_trim_from_clip_config 测试."""
|
||||
|
||||
def test_none_config(self):
|
||||
"""None返回None."""
|
||||
assert extract_trim_from_clip_config(None) is None
|
||||
|
||||
def test_empty_dict_returns_none(self):
|
||||
def test_empty_config(self):
|
||||
"""空dict返回None."""
|
||||
assert extract_trim_from_clip_config({}) is None
|
||||
|
||||
def test_trim_subdict(self):
|
||||
config = {"trim": {"start_time": 2.0, "duration": 5.0}}
|
||||
cfg = extract_trim_from_clip_config(config)
|
||||
assert cfg is not None
|
||||
assert cfg.start_time == 2.0
|
||||
assert cfg.duration == 5.0
|
||||
|
||||
def test_flat_trim_fields(self):
|
||||
config = {"trim_start": 1.0, "trim_end": 6.0}
|
||||
cfg = extract_trim_from_clip_config(config)
|
||||
assert cfg is not None
|
||||
assert cfg.start_time == 1.0
|
||||
assert cfg.end_time == 6.0
|
||||
"""trim子字典提取."""
|
||||
config = {"trim": {"start_time": 2, "duration": 5}}
|
||||
result = extract_trim_from_clip_config(config)
|
||||
assert result is not None
|
||||
assert result.start_time == 2.0
|
||||
assert result.duration == 5.0
|
||||
|
||||
def test_trim_subdict_empty(self):
|
||||
"""trim子字典为空 → None."""
|
||||
config = {"trim": {}}
|
||||
assert extract_trim_from_clip_config(config) is None
|
||||
|
||||
def test_no_trim_fields(self):
|
||||
config = {"foo": "bar"}
|
||||
assert extract_trim_from_clip_config(config) is None
|
||||
def test_flat_trim_fields(self):
|
||||
"""扁平trim_字段."""
|
||||
config = {"trim_start": 1, "trim_end": 6}
|
||||
result = extract_trim_from_clip_config(config)
|
||||
assert result is not None
|
||||
assert result.start_time == 1.0
|
||||
assert result.end_time == 6.0
|
||||
|
||||
def test_flat_trim_duration_only(self):
|
||||
config = {"trim_duration": 10.0}
|
||||
cfg = extract_trim_from_clip_config(config)
|
||||
assert cfg is not None
|
||||
assert cfg.duration == 10.0
|
||||
def test_flat_trim_duration(self):
|
||||
"""扁平trim_duration."""
|
||||
config = {"trim_duration": 10}
|
||||
result = extract_trim_from_clip_config(config)
|
||||
assert result is not None
|
||||
assert result.duration == 10.0
|
||||
|
||||
def test_trim_subdict_priority(self):
|
||||
"""trim子字典优先于扁平字段."""
|
||||
config = {
|
||||
"trim": {"start_time": 1, "duration": 2},
|
||||
"trim_start": 10,
|
||||
"trim_duration": 20,
|
||||
}
|
||||
result = extract_trim_from_clip_config(config)
|
||||
assert result is not None
|
||||
assert result.start_time == 1.0
|
||||
assert result.duration == 2.0
|
||||
|
||||
def test_trim_not_dict_ignored(self):
|
||||
"""trim不是dict时忽略(回退到扁平字段)."""
|
||||
config = {"trim": "not_a_dict", "trim_duration": 5}
|
||||
result = extract_trim_from_clip_config(config)
|
||||
assert result is not None
|
||||
assert result.duration == 5.0
|
||||
|
||||
def test_no_trim_fields(self):
|
||||
"""无裁剪字段返回None."""
|
||||
config = {"other_field": "value", "font_size": 12}
|
||||
assert extract_trim_from_clip_config(config) is None
|
||||
|
||||
+353
-119
@@ -1,153 +1,387 @@
|
||||
"""TtsConfig 配音配置模型单测."""
|
||||
"""TTS 配音配置模型单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
|
||||
# ── 默认值测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTtsConfigDefaults:
|
||||
def test_default_values(self):
|
||||
config = TtsConfig()
|
||||
assert config.enabled is False
|
||||
assert config.voice_id == ""
|
||||
assert config.speed == 1.0
|
||||
assert config.pitch == 0.0
|
||||
assert config.volume == 0.8
|
||||
assert config.text == ""
|
||||
assert config.align_mode == "full"
|
||||
assert config.overlap_mode == "replace"
|
||||
"""TtsConfig 默认值测试."""
|
||||
|
||||
def test_default_enabled_false(self):
|
||||
"""默认禁用配音."""
|
||||
cfg = TtsConfig()
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_default_voice_id_empty(self):
|
||||
"""默认空音色ID."""
|
||||
cfg = TtsConfig()
|
||||
assert cfg.voice_id == ""
|
||||
|
||||
def test_default_speed(self):
|
||||
"""默认语速1.0."""
|
||||
cfg = TtsConfig()
|
||||
assert cfg.speed == 1.0
|
||||
|
||||
def test_default_pitch(self):
|
||||
"""默认语调0."""
|
||||
cfg = TtsConfig()
|
||||
assert cfg.pitch == 0.0
|
||||
|
||||
def test_default_volume(self):
|
||||
"""默认音量0.8."""
|
||||
cfg = TtsConfig()
|
||||
assert cfg.volume == 0.8
|
||||
|
||||
def test_default_text_empty(self):
|
||||
"""默认空文本."""
|
||||
cfg = TtsConfig()
|
||||
assert cfg.text == ""
|
||||
|
||||
def test_default_align_mode(self):
|
||||
"""默认整段配音对齐."""
|
||||
cfg = TtsConfig()
|
||||
assert cfg.align_mode == "full"
|
||||
|
||||
def test_default_overlap_mode(self):
|
||||
"""默认替换原音."""
|
||||
cfg = TtsConfig()
|
||||
assert cfg.overlap_mode == "replace"
|
||||
|
||||
|
||||
class TestTtsConfigParse:
|
||||
def test_parse_none(self):
|
||||
config = TtsConfig.parse(None)
|
||||
assert config.enabled is False
|
||||
assert isinstance(config, TtsConfig)
|
||||
# ── parse - 基础场景测试 ─────────────────────────────────────────────────────
|
||||
|
||||
def test_parse_empty_dict(self):
|
||||
config = TtsConfig.parse({})
|
||||
assert config.enabled is False
|
||||
|
||||
def test_parse_not_dict(self):
|
||||
config = TtsConfig.parse("not a dict")
|
||||
assert config.enabled is False
|
||||
class TestTtsConfigParseBasic:
|
||||
"""TtsConfig.parse 基础场景测试."""
|
||||
|
||||
def test_parse_enabled_false_returns_disabled(self):
|
||||
# 即使传了其他参数,enabled=False 就直接返回禁用
|
||||
config = TtsConfig.parse({"enabled": False, "voice_id": "v1", "speed": 1.5})
|
||||
assert config.enabled is False
|
||||
assert config.voice_id == ""
|
||||
assert config.speed == 1.0
|
||||
def test_none_data(self):
|
||||
"""None输入返回默认配置(禁用)."""
|
||||
cfg = TtsConfig.parse(None)
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_parse_enabled_true_with_all_fields(self):
|
||||
config = TtsConfig.parse(
|
||||
def test_empty_dict(self):
|
||||
"""空dict返回默认配置."""
|
||||
cfg = TtsConfig.parse({})
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_not_dict_returns_default(self):
|
||||
"""非dict输入返回默认."""
|
||||
cfg = TtsConfig.parse("not_a_dict")
|
||||
assert cfg.enabled is False
|
||||
assert cfg.speed == 1.0
|
||||
|
||||
def test_disabled_returns_fast(self):
|
||||
"""enabled为False时直接返回disabled配置."""
|
||||
cfg = TtsConfig.parse({"enabled": False, "voice_id": "v1", "speed": 1.5})
|
||||
assert cfg.enabled is False
|
||||
# 其他字段为默认值
|
||||
assert cfg.voice_id == ""
|
||||
assert cfg.speed == 1.0
|
||||
|
||||
def test_enabled_basic(self):
|
||||
"""启用配音基础配置."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "voice_id": "voice_001"})
|
||||
assert cfg.enabled is True
|
||||
assert cfg.voice_id == "voice_001"
|
||||
|
||||
def test_full_config(self):
|
||||
"""完整配置解析."""
|
||||
cfg = TtsConfig.parse(
|
||||
{
|
||||
"enabled": True,
|
||||
"voice_id": "female_warm",
|
||||
"speed": 1.5,
|
||||
"pitch": 2.0,
|
||||
"voice_id": "v_test",
|
||||
"speed": 1.2,
|
||||
"pitch": 2.5,
|
||||
"volume": 0.9,
|
||||
"text": "你好世界",
|
||||
"text": "大家好",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "mix",
|
||||
}
|
||||
)
|
||||
assert config.enabled is True
|
||||
assert config.voice_id == "female_warm"
|
||||
assert config.speed == 1.5
|
||||
assert config.pitch == 2.0
|
||||
assert config.volume == 0.9
|
||||
assert config.text == "你好世界"
|
||||
assert config.align_mode == "subtitle"
|
||||
assert config.overlap_mode == "mix"
|
||||
|
||||
def test_parse_enabled_not_bool(self):
|
||||
config = TtsConfig.parse({"enabled": "true", "voice_id": "v1"})
|
||||
assert config.enabled is False # 非 bool 值视为 False
|
||||
|
||||
def test_parse_voice_id_not_string(self):
|
||||
config = TtsConfig.parse({"enabled": True, "voice_id": 123})
|
||||
assert config.voice_id == ""
|
||||
|
||||
def test_parse_speed_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": "fast"})
|
||||
assert config.speed == 1.0
|
||||
|
||||
def test_parse_pitch_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": "high"})
|
||||
assert config.pitch == 0.0
|
||||
|
||||
def test_parse_volume_not_number(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": "loud"})
|
||||
assert config.volume == 0.8
|
||||
|
||||
def test_parse_text_not_string(self):
|
||||
config = TtsConfig.parse({"enabled": True, "text": 12345})
|
||||
assert config.text == ""
|
||||
|
||||
def test_parse_invalid_align_mode(self):
|
||||
config = TtsConfig.parse({"enabled": True, "align_mode": "invalid"})
|
||||
assert config.align_mode == "full"
|
||||
|
||||
def test_parse_invalid_overlap_mode(self):
|
||||
config = TtsConfig.parse({"enabled": True, "overlap_mode": "invalid"})
|
||||
assert config.overlap_mode == "replace"
|
||||
assert cfg.enabled is True
|
||||
assert cfg.voice_id == "v_test"
|
||||
assert cfg.speed == 1.2
|
||||
assert cfg.pitch == 2.5
|
||||
assert cfg.volume == 0.9
|
||||
assert cfg.text == "大家好"
|
||||
assert cfg.align_mode == "subtitle"
|
||||
assert cfg.overlap_mode == "mix"
|
||||
|
||||
|
||||
class TestTtsConfigClamp:
|
||||
def test_speed_below_min(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
|
||||
assert config.speed == 0.5
|
||||
# ── parse - 类型校验测试 ─────────────────────────────────────────────────────
|
||||
|
||||
def test_speed_above_max(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 3.0})
|
||||
assert config.speed == 2.0
|
||||
|
||||
def test_speed_within_range(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 1.2})
|
||||
assert config.speed == 1.2
|
||||
class TestTtsConfigParseTypeChecks:
|
||||
"""TtsConfig.parse 类型校验测试."""
|
||||
|
||||
def test_speed_boundary_values(self):
|
||||
config_low = TtsConfig.parse({"enabled": True, "speed": 0.5})
|
||||
assert config_low.speed == 0.5
|
||||
config_high = TtsConfig.parse({"enabled": True, "speed": 2.0})
|
||||
assert config_high.speed == 2.0
|
||||
def test_enabled_not_bool(self):
|
||||
"""enabled不是bool时视为False."""
|
||||
cfg = TtsConfig.parse({"enabled": "true", "voice_id": "v1"})
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_pitch_below_min(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -20})
|
||||
assert config.pitch == -12
|
||||
def test_enabled_int_treated_as_non_bool(self):
|
||||
"""enabled为整数时视为非bool(Python里1是True但isinstance(1, bool)是True?)."""
|
||||
# Python里bool是int的子类,isinstance(True, int)为True
|
||||
# 反过来 isinstance(1, bool) 为 False,所以1会被当作无效值
|
||||
cfg = TtsConfig.parse({"enabled": 1, "voice_id": "v1"})
|
||||
assert cfg.enabled is False
|
||||
|
||||
def test_pitch_above_max(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 20})
|
||||
assert config.pitch == 12
|
||||
def test_voice_id_not_string(self):
|
||||
"""voice_id不是字符串时回退到空."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "voice_id": 123})
|
||||
assert cfg.voice_id == ""
|
||||
|
||||
def test_pitch_within_range(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": -3.5})
|
||||
assert config.pitch == -3.5
|
||||
def test_speed_not_number(self):
|
||||
"""speed不是数字时回退到1.0."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "speed": "fast"})
|
||||
assert cfg.speed == 1.0
|
||||
|
||||
def test_volume_below_min(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": -0.5})
|
||||
assert config.volume == 0.0
|
||||
def test_speed_int_accepted(self):
|
||||
"""整数speed也接受."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "speed": 2})
|
||||
assert cfg.speed == 2.0
|
||||
|
||||
def test_volume_above_max(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 2.0})
|
||||
assert config.volume == 1.0
|
||||
def test_pitch_not_number(self):
|
||||
"""pitch不是数字时回退到0."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "pitch": "high"})
|
||||
assert cfg.pitch == 0.0
|
||||
|
||||
def test_volume_within_range(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 0.5})
|
||||
assert config.volume == 0.5
|
||||
def test_pitch_int_accepted(self):
|
||||
"""整数pitch也接受."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "pitch": 5})
|
||||
assert cfg.pitch == 5.0
|
||||
|
||||
def test_int_speed_converted_to_float(self):
|
||||
config = TtsConfig.parse({"enabled": True, "speed": 1})
|
||||
assert isinstance(config.speed, float)
|
||||
assert config.speed == 1.0
|
||||
def test_volume_not_number(self):
|
||||
"""volume不是数字时回退到0.8."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "volume": "loud"})
|
||||
assert cfg.volume == 0.8
|
||||
|
||||
def test_int_pitch_converted_to_float(self):
|
||||
config = TtsConfig.parse({"enabled": True, "pitch": 2})
|
||||
assert isinstance(config.pitch, float)
|
||||
assert config.pitch == 2.0
|
||||
def test_volume_int_accepted(self):
|
||||
"""整数volume也接受."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "volume": 1})
|
||||
assert cfg.volume == 1.0
|
||||
|
||||
def test_int_volume_converted_to_float(self):
|
||||
config = TtsConfig.parse({"enabled": True, "volume": 1})
|
||||
assert isinstance(config.volume, float)
|
||||
assert config.volume == 1.0
|
||||
def test_text_not_string(self):
|
||||
"""text不是字符串时回退到空."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "text": 12345})
|
||||
assert cfg.text == ""
|
||||
|
||||
def test_text_empty_string(self):
|
||||
"""空文本字符串是有效的."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "text": ""})
|
||||
assert cfg.text == ""
|
||||
|
||||
|
||||
# ── parse - 边界钳制测试 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTtsConfigParseClamping:
|
||||
"""TtsConfig.parse 边界钳制测试."""
|
||||
|
||||
# speed 边界
|
||||
|
||||
def test_speed_below_min_clamped(self):
|
||||
"""语速低于最小值钳制到0.5."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "speed": 0.1})
|
||||
assert cfg.speed == 0.5
|
||||
|
||||
def test_speed_negative_clamped(self):
|
||||
"""负语速钳制到0.5."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "speed": -1.0})
|
||||
assert cfg.speed == 0.5
|
||||
|
||||
def test_speed_above_max_clamped(self):
|
||||
"""语速高于最大值钳制到2.0."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "speed": 5.0})
|
||||
assert cfg.speed == 2.0
|
||||
|
||||
def test_speed_at_min_ok(self):
|
||||
"""刚好等于最小值正常."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "speed": 0.5})
|
||||
assert cfg.speed == 0.5
|
||||
|
||||
def test_speed_at_max_ok(self):
|
||||
"""刚好等于最大值正常."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "speed": 2.0})
|
||||
assert cfg.speed == 2.0
|
||||
|
||||
def test_speed_normal_ok(self):
|
||||
"""正常范围内语速保持不变."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "speed": 1.5})
|
||||
assert cfg.speed == 1.5
|
||||
|
||||
# pitch 边界
|
||||
|
||||
def test_pitch_below_min_clamped(self):
|
||||
"""语调低于最小值钳制到-12."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "pitch": -20})
|
||||
assert cfg.pitch == -12
|
||||
|
||||
def test_pitch_above_max_clamped(self):
|
||||
"""语调高于最大值钳制到12."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "pitch": 20})
|
||||
assert cfg.pitch == 12
|
||||
|
||||
def test_pitch_at_min_ok(self):
|
||||
"""刚好等于最小值正常."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "pitch": -12})
|
||||
assert cfg.pitch == -12
|
||||
|
||||
def test_pitch_at_max_ok(self):
|
||||
"""刚好等于最大值正常."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "pitch": 12})
|
||||
assert cfg.pitch == 12
|
||||
|
||||
def test_pitch_normal_ok(self):
|
||||
"""正常范围内语调保持不变."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "pitch": 3.5})
|
||||
assert cfg.pitch == 3.5
|
||||
|
||||
# volume 边界
|
||||
|
||||
def test_volume_below_min_clamped(self):
|
||||
"""音量低于最小值钳制到0."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "volume": -0.5})
|
||||
assert cfg.volume == 0.0
|
||||
|
||||
def test_volume_above_max_clamped(self):
|
||||
"""音量高于最大值钳制到1."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "volume": 2.0})
|
||||
assert cfg.volume == 1.0
|
||||
|
||||
def test_volume_at_min_ok(self):
|
||||
"""刚好等于最小值正常."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "volume": 0.0})
|
||||
assert cfg.volume == 0.0
|
||||
|
||||
def test_volume_at_max_ok(self):
|
||||
"""刚好等于最大值正常."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "volume": 1.0})
|
||||
assert cfg.volume == 1.0
|
||||
|
||||
def test_volume_normal_ok(self):
|
||||
"""正常范围内音量保持不变."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "volume": 0.6})
|
||||
assert cfg.volume == 0.6
|
||||
|
||||
|
||||
# ── parse - 枚举值校验测试 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTtsConfigParseEnumValues:
|
||||
"""TtsConfig.parse 枚举值校验测试."""
|
||||
|
||||
# align_mode
|
||||
|
||||
def test_align_mode_subtitle(self):
|
||||
"""subtitle对齐模式有效."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "align_mode": "subtitle"})
|
||||
assert cfg.align_mode == "subtitle"
|
||||
|
||||
def test_align_mode_full(self):
|
||||
"""full对齐模式有效."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "align_mode": "full"})
|
||||
assert cfg.align_mode == "full"
|
||||
|
||||
def test_align_mode_invalid_fallback(self):
|
||||
"""无效align_mode回退到full."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "align_mode": "word_by_word"})
|
||||
assert cfg.align_mode == "full"
|
||||
|
||||
# overlap_mode
|
||||
|
||||
def test_overlap_mode_replace(self):
|
||||
"""replace叠加模式有效."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "overlap_mode": "replace"})
|
||||
assert cfg.overlap_mode == "replace"
|
||||
|
||||
def test_overlap_mode_mix(self):
|
||||
"""mix叠加模式有效."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "overlap_mode": "mix"})
|
||||
assert cfg.overlap_mode == "mix"
|
||||
|
||||
def test_overlap_mode_invalid_fallback(self):
|
||||
"""无效overlap_mode回退到replace."""
|
||||
cfg = TtsConfig.parse({"enabled": True, "overlap_mode": "duck"})
|
||||
assert cfg.overlap_mode == "replace"
|
||||
|
||||
|
||||
# ── _clamp 直接测试 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTtsConfigClampDirect:
|
||||
"""_clamp 方法直接调用测试."""
|
||||
|
||||
def test_clamp_speed_low(self):
|
||||
"""手动构造低语速再clamp."""
|
||||
cfg = TtsConfig(enabled=True, speed=0.1)
|
||||
cfg._clamp()
|
||||
assert cfg.speed == 0.5
|
||||
|
||||
def test_clamp_speed_high(self):
|
||||
"""手动构造高速再clamp."""
|
||||
cfg = TtsConfig(enabled=True, speed=10)
|
||||
cfg._clamp()
|
||||
assert cfg.speed == 2.0
|
||||
|
||||
def test_clamp_pitch_low(self):
|
||||
"""手动构造低调再clamp."""
|
||||
cfg = TtsConfig(enabled=True, pitch=-20)
|
||||
cfg._clamp()
|
||||
assert cfg.pitch == -12
|
||||
|
||||
def test_clamp_pitch_high(self):
|
||||
"""手动构造高调再clamp."""
|
||||
cfg = TtsConfig(enabled=True, pitch=20)
|
||||
cfg._clamp()
|
||||
assert cfg.pitch == 12
|
||||
|
||||
def test_clamp_volume_low(self):
|
||||
"""手动构造低音量再clamp."""
|
||||
cfg = TtsConfig(enabled=True, volume=-1)
|
||||
cfg._clamp()
|
||||
assert cfg.volume == 0.0
|
||||
|
||||
def test_clamp_volume_high(self):
|
||||
"""手动构造高音量再clamp."""
|
||||
cfg = TtsConfig(enabled=True, volume=2)
|
||||
cfg._clamp()
|
||||
assert cfg.volume == 1.0
|
||||
|
||||
def test_clamp_preserves_in_range(self):
|
||||
"""范围内的值不变."""
|
||||
cfg = TtsConfig(enabled=True, speed=1.2, pitch=3, volume=0.7)
|
||||
cfg._clamp()
|
||||
assert cfg.speed == 1.2
|
||||
assert cfg.pitch == 3
|
||||
assert cfg.volume == 0.7
|
||||
|
||||
|
||||
# ── is_dataclass 验证 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTtsConfigStructure:
|
||||
"""TtsConfig 结构验证."""
|
||||
|
||||
def test_is_dataclass(self):
|
||||
"""是dataclass."""
|
||||
from dataclasses import is_dataclass
|
||||
|
||||
assert is_dataclass(TtsConfig)
|
||||
|
||||
def test_equality(self):
|
||||
"""相同配置相等."""
|
||||
c1 = TtsConfig(enabled=True, voice_id="v1")
|
||||
c2 = TtsConfig(enabled=True, voice_id="v1")
|
||||
assert c1 == c2
|
||||
|
||||
def test_inequality(self):
|
||||
"""不同配置不等."""
|
||||
c1 = TtsConfig(enabled=True, voice_id="v1")
|
||||
c2 = TtsConfig(enabled=True, voice_id="v2")
|
||||
assert c1 != c2
|
||||
|
||||
Regular → Executable
+660
-294
File diff suppressed because it is too large
Load Diff
+387
-250
@@ -1,4 +1,4 @@
|
||||
"""video_concat 领域模型单测 — 纯逻辑,48个测试用例."""
|
||||
"""video_concat 视频拼接领域模型单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,125 +12,192 @@ from packages.domain.video_concat import (
|
||||
ConcatSegment,
|
||||
)
|
||||
|
||||
# ── 常量测试 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""常量测试."""
|
||||
|
||||
def test_max_concat_segments(self):
|
||||
"""最大拼接段数."""
|
||||
assert MAX_CONCAT_SEGMENTS == 50
|
||||
|
||||
def test_allowed_extensions_not_empty(self):
|
||||
"""支持的视频格式不为空."""
|
||||
assert len(ALLOWED_VIDEO_EXTENSIONS) > 0
|
||||
|
||||
def test_common_formats_supported(self):
|
||||
"""常见格式都支持."""
|
||||
assert ".mp4" in ALLOWED_VIDEO_EXTENSIONS
|
||||
assert ".mov" in ALLOWED_VIDEO_EXTENSIONS
|
||||
assert ".avi" in ALLOWED_VIDEO_EXTENSIONS
|
||||
assert ".mkv" in ALLOWED_VIDEO_EXTENSIONS
|
||||
assert ".webm" in ALLOWED_VIDEO_EXTENSIONS
|
||||
|
||||
def test_demuxer_params_not_empty(self):
|
||||
"""demuxer必需参数不为空."""
|
||||
assert len(CONCAT_DEMUXER_REQUIRED_PARAMS) > 0
|
||||
|
||||
def test_demuxer_params_include_codec(self):
|
||||
"""包含编解码相关参数."""
|
||||
assert "codec_name" in CONCAT_DEMUXER_REQUIRED_PARAMS
|
||||
assert "width" in CONCAT_DEMUXER_REQUIRED_PARAMS
|
||||
assert "height" in CONCAT_DEMUXER_REQUIRED_PARAMS
|
||||
assert "r_frame_rate" in CONCAT_DEMUXER_REQUIRED_PARAMS
|
||||
|
||||
|
||||
# ── ConcatSegment 测试 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConcatSegmentBasics:
|
||||
def test_default_values(self):
|
||||
seg = ConcatSegment(video_path="test.mp4")
|
||||
assert seg.video_path == "test.mp4"
|
||||
class TestConcatSegment:
|
||||
"""ConcatSegment 测试."""
|
||||
|
||||
def test_basic_creation(self):
|
||||
"""基础创建."""
|
||||
seg = ConcatSegment(video_path="/tmp/video.mp4")
|
||||
assert seg.video_path == "/tmp/video.mp4"
|
||||
assert seg.start_time == 0.0
|
||||
assert seg.duration == 0.0
|
||||
assert seg.has_audio is True
|
||||
|
||||
def test_full_params(self):
|
||||
def test_full_creation(self):
|
||||
"""完整字段创建."""
|
||||
seg = ConcatSegment(
|
||||
video_path="video.mp4",
|
||||
video_path="/tmp/v.mov",
|
||||
start_time=5.5,
|
||||
duration=10.0,
|
||||
has_audio=False,
|
||||
)
|
||||
assert seg.video_path == "video.mp4"
|
||||
assert seg.video_path == "/tmp/v.mov"
|
||||
assert seg.start_time == 5.5
|
||||
assert seg.duration == 10.0
|
||||
assert seg.has_audio is False
|
||||
|
||||
# is_valid 属性
|
||||
|
||||
class TestConcatSegmentFromDict:
|
||||
def test_normal_dict(self):
|
||||
seg = ConcatSegment.from_dict(
|
||||
{
|
||||
"video_path": "test.mp4",
|
||||
"start_time": 2.0,
|
||||
"duration": 5.0,
|
||||
"has_audio": False,
|
||||
}
|
||||
)
|
||||
assert seg.video_path == "test.mp4"
|
||||
assert seg.start_time == 2.0
|
||||
assert seg.duration == 5.0
|
||||
assert seg.has_audio is False
|
||||
|
||||
def test_empty_dict(self):
|
||||
seg = ConcatSegment.from_dict({})
|
||||
assert seg.video_path == ""
|
||||
assert seg.start_time == 0.0
|
||||
assert seg.duration == 0.0
|
||||
assert seg.has_audio is True
|
||||
|
||||
def test_none_input(self):
|
||||
seg = ConcatSegment.from_dict(None)
|
||||
assert seg.video_path == ""
|
||||
assert seg.is_valid is False
|
||||
|
||||
def test_non_dict_input(self):
|
||||
seg = ConcatSegment.from_dict("not a dict")
|
||||
assert seg.video_path == ""
|
||||
|
||||
def test_start_time_negative_clamped(self):
|
||||
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "start_time": -5})
|
||||
assert seg.start_time == 0.0
|
||||
|
||||
def test_duration_negative_clamped(self):
|
||||
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "duration": -10})
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_start_time_invalid_string(self):
|
||||
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "start_time": "abc"})
|
||||
assert seg.start_time == 0.0
|
||||
|
||||
def test_duration_invalid_string(self):
|
||||
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "duration": "xyz"})
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_start_time_int_casted(self):
|
||||
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "start_time": 3})
|
||||
assert seg.start_time == 3.0
|
||||
|
||||
def test_duration_int_casted(self):
|
||||
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "duration": 7})
|
||||
assert seg.duration == 7.0
|
||||
|
||||
def test_video_path_casted_to_string(self):
|
||||
seg = ConcatSegment.from_dict({"video_path": 12345})
|
||||
assert seg.video_path == "12345"
|
||||
|
||||
def test_has_audio_false(self):
|
||||
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "has_audio": False})
|
||||
assert seg.has_audio is False
|
||||
|
||||
def test_has_audio_truthy_value(self):
|
||||
seg = ConcatSegment.from_dict({"video_path": "a.mp4", "has_audio": 1})
|
||||
assert seg.has_audio is True
|
||||
|
||||
|
||||
class TestConcatSegmentProperties:
|
||||
def test_is_valid_with_path(self):
|
||||
seg = ConcatSegment(video_path="test.mp4")
|
||||
"""有视频路径是有效的."""
|
||||
seg = ConcatSegment(video_path="/tmp/v.mp4")
|
||||
assert seg.is_valid is True
|
||||
|
||||
def test_is_valid_empty_path(self):
|
||||
"""空路径无效."""
|
||||
seg = ConcatSegment(video_path="")
|
||||
assert seg.is_valid is False
|
||||
|
||||
def test_effective_duration_positive(self):
|
||||
seg = ConcatSegment(video_path="a.mp4", duration=10.5)
|
||||
"""正的effective_duration."""
|
||||
seg = ConcatSegment(video_path="v.mp4", duration=10.5)
|
||||
assert seg.effective_duration == 10.5
|
||||
|
||||
def test_effective_duration_zero(self):
|
||||
seg = ConcatSegment(video_path="a.mp4", duration=0.0)
|
||||
"""duration为0时effective_duration为0."""
|
||||
seg = ConcatSegment(video_path="v.mp4", duration=0)
|
||||
assert seg.effective_duration == 0.0
|
||||
|
||||
def test_effective_duration_negative(self):
|
||||
seg = ConcatSegment(video_path="a.mp4", duration=-5.0)
|
||||
"""负的duration被钳制到0."""
|
||||
seg = ConcatSegment(video_path="v.mp4", duration=-5)
|
||||
assert seg.effective_duration == 0.0
|
||||
|
||||
|
||||
# ── ConcatConfig 测试 ────────────────────────────────────────────────────────
|
||||
# ── ConcatSegment.from_dict 测试 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConcatConfigBasics:
|
||||
def test_default_values(self):
|
||||
class TestConcatSegmentFromDict:
|
||||
"""ConcatSegment.from_dict 工厂方法测试."""
|
||||
|
||||
def test_none_input(self):
|
||||
"""None输入返回默认片段(空路径)."""
|
||||
seg = ConcatSegment.from_dict(None)
|
||||
assert seg.video_path == ""
|
||||
assert seg.is_valid is False
|
||||
|
||||
def test_empty_dict(self):
|
||||
"""空dict返回默认."""
|
||||
seg = ConcatSegment.from_dict({})
|
||||
assert seg.video_path == ""
|
||||
|
||||
def test_not_dict_input(self):
|
||||
"""非dict输入安全处理."""
|
||||
seg = ConcatSegment.from_dict("not_a_dict")
|
||||
assert seg.video_path == ""
|
||||
|
||||
def test_list_input(self):
|
||||
"""list输入安全处理."""
|
||||
seg = ConcatSegment.from_dict([1, 2, 3])
|
||||
assert seg.video_path == ""
|
||||
|
||||
def test_with_video_path(self):
|
||||
"""带视频路径."""
|
||||
seg = ConcatSegment.from_dict({"video_path": "/tmp/v.mp4"})
|
||||
assert seg.video_path == "/tmp/v.mp4"
|
||||
assert seg.is_valid is True
|
||||
|
||||
def test_with_start_time(self):
|
||||
"""带start_time."""
|
||||
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "start_time": 3.5})
|
||||
assert seg.start_time == 3.5
|
||||
|
||||
def test_start_time_negative_clamped(self):
|
||||
"""负的start_time钳制到0."""
|
||||
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "start_time": -5})
|
||||
assert seg.start_time == 0.0
|
||||
|
||||
def test_start_time_invalid_string(self):
|
||||
"""无效start_time字符串回退到0."""
|
||||
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "start_time": "abc"})
|
||||
assert seg.start_time == 0.0
|
||||
|
||||
def test_with_duration(self):
|
||||
"""带duration."""
|
||||
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "duration": 8.5})
|
||||
assert seg.duration == 8.5
|
||||
|
||||
def test_duration_negative_clamped(self):
|
||||
"""负的duration钳制到0."""
|
||||
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "duration": -10})
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_duration_invalid_string(self):
|
||||
"""无效duration回退到0."""
|
||||
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "duration": "xyz"})
|
||||
assert seg.duration == 0.0
|
||||
|
||||
def test_has_audio_true(self):
|
||||
"""has_audio为True."""
|
||||
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "has_audio": True})
|
||||
assert seg.has_audio is True
|
||||
|
||||
def test_has_audio_false(self):
|
||||
"""has_audio为False."""
|
||||
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "has_audio": False})
|
||||
assert seg.has_audio is False
|
||||
|
||||
def test_has_audio_default_true(self):
|
||||
"""has_audio默认True."""
|
||||
seg = ConcatSegment.from_dict({"video_path": "v.mp4"})
|
||||
assert seg.has_audio is True
|
||||
|
||||
def test_string_start_time(self):
|
||||
"""字符串形式的start_time."""
|
||||
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "start_time": "2.5"})
|
||||
assert seg.start_time == 2.5
|
||||
|
||||
def test_int_duration(self):
|
||||
"""整数duration."""
|
||||
seg = ConcatSegment.from_dict({"video_path": "v.mp4", "duration": 10})
|
||||
assert seg.duration == 10.0
|
||||
|
||||
|
||||
# ── ConcatConfig 基础测试 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConcatConfig:
|
||||
"""ConcatConfig 基础测试."""
|
||||
|
||||
def test_default_creation(self):
|
||||
"""默认创建."""
|
||||
cfg = ConcatConfig()
|
||||
assert cfg.segments == []
|
||||
assert cfg.output_width == 0
|
||||
@@ -141,224 +208,294 @@ class TestConcatConfigBasics:
|
||||
assert cfg.transition_duration == 0.3
|
||||
|
||||
def test_with_segments(self):
|
||||
segs = [ConcatSegment(video_path="a.mp4")]
|
||||
cfg = ConcatConfig(segments=segs)
|
||||
assert len(cfg.segments) == 1
|
||||
assert cfg.segments[0].video_path == "a.mp4"
|
||||
|
||||
|
||||
class TestConcatConfigFromDict:
|
||||
def test_none_config(self):
|
||||
cfg = ConcatConfig.from_config_dict(None)
|
||||
assert cfg.segments == []
|
||||
assert cfg.output_width == 0
|
||||
|
||||
def test_empty_dict(self):
|
||||
cfg = ConcatConfig.from_config_dict({})
|
||||
assert cfg.segments == []
|
||||
|
||||
def test_non_dict_input(self):
|
||||
cfg = ConcatConfig.from_config_dict("config")
|
||||
assert cfg.segments == []
|
||||
|
||||
def test_with_valid_segments(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "a.mp4", "duration": 10},
|
||||
{"video_path": "b.mp4", "duration": 20},
|
||||
],
|
||||
}
|
||||
)
|
||||
"""带片段创建."""
|
||||
segs = [ConcatSegment(video_path="v1.mp4"), ConcatSegment(video_path="v2.mp4")]
|
||||
cfg = ConcatConfig(segments=segs, output_width=1920, output_height=1080)
|
||||
assert len(cfg.segments) == 2
|
||||
assert cfg.segments[0].video_path == "a.mp4"
|
||||
assert cfg.segments[1].video_path == "b.mp4"
|
||||
|
||||
def test_skips_empty_video_path(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "a.mp4"},
|
||||
{"video_path": ""},
|
||||
{"video_path": "b.mp4"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(cfg.segments) == 2
|
||||
|
||||
def test_skips_invalid_segment_dict(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "a.mp4"},
|
||||
"not a dict",
|
||||
{"video_path": "b.mp4"},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert len(cfg.segments) == 2
|
||||
|
||||
def test_segments_not_a_list(self):
|
||||
cfg = ConcatConfig.from_config_dict({"segments": "not a list"})
|
||||
assert cfg.segments == []
|
||||
|
||||
def test_output_params(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
"output_fps": 30.0,
|
||||
"force_reencode": True,
|
||||
}
|
||||
)
|
||||
assert cfg.output_width == 1920
|
||||
assert cfg.output_height == 1080
|
||||
assert cfg.output_fps == 30.0
|
||||
assert cfg.force_reencode is True
|
||||
|
||||
def test_output_width_negative_clamped(self):
|
||||
cfg = ConcatConfig.from_config_dict({"output_width": -100})
|
||||
assert cfg.output_width == 0
|
||||
# 属性测试
|
||||
|
||||
def test_output_height_invalid_string(self):
|
||||
cfg = ConcatConfig.from_config_dict({"output_height": "abc"})
|
||||
assert cfg.output_height == 0
|
||||
|
||||
def test_output_fps_invalid_string(self):
|
||||
cfg = ConcatConfig.from_config_dict({"output_fps": "xyz"})
|
||||
assert cfg.output_fps == 0.0
|
||||
|
||||
def test_transition_params(self):
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"transition": "crossfade",
|
||||
"transition_duration": 1.0,
|
||||
}
|
||||
)
|
||||
assert cfg.transition == "crossfade"
|
||||
assert cfg.transition_duration == 1.0
|
||||
|
||||
def test_transition_duration_minimum(self):
|
||||
cfg = ConcatConfig.from_config_dict({"transition_duration": 0.01})
|
||||
assert cfg.transition_duration == 0.1
|
||||
|
||||
def test_transition_duration_negative(self):
|
||||
cfg = ConcatConfig.from_config_dict({"transition_duration": -1})
|
||||
assert cfg.transition_duration == 0.1
|
||||
|
||||
def test_force_reencode_false_by_default(self):
|
||||
cfg = ConcatConfig.from_config_dict({})
|
||||
assert cfg.force_reencode is False
|
||||
|
||||
|
||||
class TestConcatConfigProperties:
|
||||
def test_has_effect_two_segments(self):
|
||||
def test_has_effect_with_two_segments(self):
|
||||
"""2个以上有效片段has_effect为True."""
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="a.mp4"),
|
||||
ConcatSegment(video_path="b.mp4"),
|
||||
ConcatSegment(video_path="v1.mp4"),
|
||||
ConcatSegment(video_path="v2.mp4"),
|
||||
]
|
||||
)
|
||||
assert cfg.has_effect is True
|
||||
|
||||
def test_has_effect_one_segment(self):
|
||||
cfg = ConcatConfig(segments=[ConcatSegment(video_path="a.mp4")])
|
||||
def test_has_effect_with_one_segment(self):
|
||||
"""只有1个有效片段has_effect为False."""
|
||||
cfg = ConcatConfig(segments=[ConcatSegment(video_path="v1.mp4")])
|
||||
assert cfg.has_effect is False
|
||||
|
||||
def test_has_effect_empty(self):
|
||||
def test_has_effect_with_no_segments(self):
|
||||
"""空片段has_effect为False."""
|
||||
cfg = ConcatConfig()
|
||||
assert cfg.has_effect is False
|
||||
|
||||
def test_has_effect_skips_invalid(self):
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="a.mp4"),
|
||||
ConcatSegment(video_path=""),
|
||||
ConcatSegment(video_path="b.mp4"),
|
||||
]
|
||||
)
|
||||
assert cfg.has_effect is True
|
||||
|
||||
def test_valid_segment_count(self):
|
||||
"""有效片段计数."""
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="a.mp4"),
|
||||
ConcatSegment(video_path=""),
|
||||
ConcatSegment(video_path="b.mp4"),
|
||||
ConcatSegment(video_path="v1.mp4"),
|
||||
ConcatSegment(video_path=""), # 无效
|
||||
ConcatSegment(video_path="v2.mp4"),
|
||||
]
|
||||
)
|
||||
assert cfg.valid_segment_count == 2
|
||||
|
||||
def test_first_valid_segment(self):
|
||||
def test_total_segments_alias(self):
|
||||
"""total_segments是valid_segment_count的别名."""
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path=""),
|
||||
ConcatSegment(video_path="first.mp4"),
|
||||
ConcatSegment(video_path="v1.mp4"),
|
||||
ConcatSegment(video_path="v2.mp4"),
|
||||
]
|
||||
)
|
||||
assert cfg.total_segments == cfg.valid_segment_count
|
||||
assert cfg.total_segments == 2
|
||||
|
||||
def test_first_valid_segment(self):
|
||||
"""第一个有效片段."""
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path=""), # 无效
|
||||
ConcatSegment(video_path="first_valid.mp4"),
|
||||
ConcatSegment(video_path="second.mp4"),
|
||||
]
|
||||
)
|
||||
assert cfg.first_valid_segment is not None
|
||||
assert cfg.first_valid_segment.video_path == "first.mp4"
|
||||
first = cfg.first_valid_segment
|
||||
assert first is not None
|
||||
assert first.video_path == "first_valid.mp4"
|
||||
|
||||
def test_first_valid_segment_none_when_all_empty(self):
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path=""),
|
||||
ConcatSegment(video_path=""),
|
||||
]
|
||||
)
|
||||
def test_first_valid_segment_none(self):
|
||||
"""无有效片段时first_valid_segment为None."""
|
||||
cfg = ConcatConfig(segments=[ConcatSegment(video_path="")])
|
||||
assert cfg.first_valid_segment is None
|
||||
|
||||
def test_first_valid_segment_empty_list(self):
|
||||
def test_first_valid_segment_empty(self):
|
||||
"""空列表时为None."""
|
||||
cfg = ConcatConfig()
|
||||
assert cfg.first_valid_segment is None
|
||||
|
||||
def test_estimated_total_duration(self):
|
||||
"""估算总时长."""
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="a.mp4", duration=10.0),
|
||||
ConcatSegment(video_path="b.mp4", duration=20.0),
|
||||
ConcatSegment(video_path="c.mp4", duration=0.0),
|
||||
ConcatSegment(video_path="v1.mp4", duration=10.0),
|
||||
ConcatSegment(video_path="v2.mp4", duration=5.5),
|
||||
ConcatSegment(video_path="v3.mp4", duration=0), # 不计入
|
||||
]
|
||||
)
|
||||
assert cfg.estimated_total_duration == 30.0
|
||||
assert cfg.estimated_total_duration == pytest.approx(15.5)
|
||||
|
||||
def test_estimated_total_duration_empty(self):
|
||||
"""空片段时长为0."""
|
||||
cfg = ConcatConfig()
|
||||
assert cfg.estimated_total_duration == 0.0
|
||||
|
||||
def test_estimated_total_duration_skips_invalid(self):
|
||||
"""跳过无效片段."""
|
||||
cfg = ConcatConfig(
|
||||
segments=[
|
||||
ConcatSegment(video_path="", duration=10.0),
|
||||
ConcatSegment(video_path="a.mp4", duration=5.0),
|
||||
ConcatSegment(video_path="", duration=100), # 无效,跳过
|
||||
ConcatSegment(video_path="v1.mp4", duration=5.0),
|
||||
]
|
||||
)
|
||||
assert cfg.estimated_total_duration == 5.0
|
||||
|
||||
|
||||
class TestConcatConfigClampSegments:
|
||||
def test_clamp_when_over_max(self):
|
||||
segs = [ConcatSegment(video_path=f"s{i}.mp4") for i in range(100)]
|
||||
cfg = ConcatConfig(segments=segs)
|
||||
cfg.clamp_segments(50)
|
||||
assert len(cfg.segments) == 50
|
||||
assert cfg.segments[0].video_path == "s0.mp4"
|
||||
assert cfg.segments[-1].video_path == "s49.mp4"
|
||||
# ── ConcatConfig.from_config_dict 测试 ───────────────────────────────────────
|
||||
|
||||
def test_no_clamp_when_under_max(self):
|
||||
segs = [ConcatSegment(video_path=f"s{i}.mp4") for i in range(10)]
|
||||
|
||||
class TestConcatConfigFromConfigDict:
|
||||
"""ConcatConfig.from_config_dict 工厂方法测试."""
|
||||
|
||||
def test_none_config(self):
|
||||
"""None返回默认配置."""
|
||||
cfg = ConcatConfig.from_config_dict(None)
|
||||
assert cfg.segments == []
|
||||
assert cfg.output_width == 0
|
||||
|
||||
def test_empty_config(self):
|
||||
"""空dict返回默认."""
|
||||
cfg = ConcatConfig.from_config_dict({})
|
||||
assert cfg.segments == []
|
||||
|
||||
def test_not_dict(self):
|
||||
"""非dict安全处理."""
|
||||
cfg = ConcatConfig.from_config_dict("not_dict")
|
||||
assert cfg.segments == []
|
||||
|
||||
def test_with_segments(self):
|
||||
"""带片段列表."""
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "v1.mp4", "duration": 10},
|
||||
{"video_path": "v2.mp4", "start_time": 2},
|
||||
]
|
||||
}
|
||||
)
|
||||
assert len(cfg.segments) == 2
|
||||
assert cfg.segments[0].video_path == "v1.mp4"
|
||||
assert cfg.segments[0].duration == 10.0
|
||||
assert cfg.segments[1].start_time == 2.0
|
||||
|
||||
def test_segments_not_list(self):
|
||||
"""segments不是list时忽略."""
|
||||
cfg = ConcatConfig.from_config_dict({"segments": "not_a_list"})
|
||||
assert cfg.segments == []
|
||||
|
||||
def test_skips_segments_without_path(self):
|
||||
"""跳过没有video_path的片段."""
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "v1.mp4"},
|
||||
{"duration": 5}, # 没有video_path
|
||||
{"video_path": ""}, # 空path
|
||||
]
|
||||
}
|
||||
)
|
||||
assert len(cfg.segments) == 1
|
||||
assert cfg.segments[0].video_path == "v1.mp4"
|
||||
|
||||
def test_skips_non_dict_segments(self):
|
||||
"""跳过非dict片段."""
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"segments": [
|
||||
{"video_path": "v1.mp4"},
|
||||
"not_a_dict",
|
||||
None,
|
||||
123,
|
||||
]
|
||||
}
|
||||
)
|
||||
assert len(cfg.segments) == 1
|
||||
|
||||
def test_output_dimensions(self):
|
||||
"""输出尺寸."""
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
}
|
||||
)
|
||||
assert cfg.output_width == 1920
|
||||
assert cfg.output_height == 1080
|
||||
|
||||
def test_output_dimensions_negative_clamped(self):
|
||||
"""负的尺寸钳制到0."""
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"output_width": -100,
|
||||
"output_height": -50,
|
||||
}
|
||||
)
|
||||
assert cfg.output_width == 0
|
||||
assert cfg.output_height == 0
|
||||
|
||||
def test_output_dimensions_invalid(self):
|
||||
"""无效尺寸回退到0."""
|
||||
cfg = ConcatConfig.from_config_dict(
|
||||
{
|
||||
"output_width": "abc",
|
||||
"output_height": "xyz",
|
||||
}
|
||||
)
|
||||
assert cfg.output_width == 0
|
||||
assert cfg.output_height == 0
|
||||
|
||||
def test_output_fps(self):
|
||||
"""输出帧率."""
|
||||
cfg = ConcatConfig.from_config_dict({"output_fps": 30.0})
|
||||
assert cfg.output_fps == 30.0
|
||||
|
||||
def test_output_fps_negative_clamped(self):
|
||||
"""负帧率钳制到0."""
|
||||
cfg = ConcatConfig.from_config_dict({"output_fps": -5})
|
||||
assert cfg.output_fps == 0.0
|
||||
|
||||
def test_force_reencode(self):
|
||||
"""强制重新编码."""
|
||||
cfg = ConcatConfig.from_config_dict({"force_reencode": True})
|
||||
assert cfg.force_reencode is True
|
||||
|
||||
def test_force_reencode_default_false(self):
|
||||
"""默认不强制重编码."""
|
||||
cfg = ConcatConfig.from_config_dict({})
|
||||
assert cfg.force_reencode is False
|
||||
|
||||
def test_transition(self):
|
||||
"""转场效果."""
|
||||
cfg = ConcatConfig.from_config_dict({"transition": "crossfade"})
|
||||
assert cfg.transition == "crossfade"
|
||||
|
||||
def test_transition_default_none(self):
|
||||
"""默认转场none."""
|
||||
cfg = ConcatConfig.from_config_dict({})
|
||||
assert cfg.transition == "none"
|
||||
|
||||
def test_transition_duration(self):
|
||||
"""转场时长."""
|
||||
cfg = ConcatConfig.from_config_dict({"transition_duration": 1.0})
|
||||
assert cfg.transition_duration == 1.0
|
||||
|
||||
def test_transition_duration_min(self):
|
||||
"""转场时长最小值0.1."""
|
||||
cfg = ConcatConfig.from_config_dict({"transition_duration": 0.01})
|
||||
assert cfg.transition_duration == 0.1
|
||||
|
||||
def test_transition_duration_invalid(self):
|
||||
"""无效转场时长回退到默认."""
|
||||
cfg = ConcatConfig.from_config_dict({"transition_duration": "invalid"})
|
||||
assert cfg.transition_duration == 0.3
|
||||
|
||||
|
||||
# ── clamp_segments 测试 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClampSegments:
|
||||
"""clamp_segments 截断测试."""
|
||||
|
||||
def test_under_limit_no_change(self):
|
||||
"""低于上限时不截断."""
|
||||
segs = [ConcatSegment(video_path=f"v{i}.mp4") for i in range(10)]
|
||||
cfg = ConcatConfig(segments=segs)
|
||||
cfg.clamp_segments(50)
|
||||
cfg.clamp_segments(max_segments=50)
|
||||
assert len(cfg.segments) == 10
|
||||
|
||||
def test_default_max_constant(self):
|
||||
assert MAX_CONCAT_SEGMENTS == 50
|
||||
def test_over_limit_truncated(self):
|
||||
"""超过上限时截断."""
|
||||
segs = [ConcatSegment(video_path=f"v{i}.mp4") for i in range(100)]
|
||||
cfg = ConcatConfig(segments=segs)
|
||||
cfg.clamp_segments(max_segments=30)
|
||||
assert len(cfg.segments) == 30
|
||||
assert cfg.segments[0].video_path == "v0.mp4"
|
||||
assert cfg.segments[-1].video_path == "v29.mp4"
|
||||
|
||||
def test_default_max_uses_constant(self):
|
||||
"""默认max_segments使用常量."""
|
||||
segs = [ConcatSegment(video_path=f"v{i}.mp4") for i in range(100)]
|
||||
cfg = ConcatConfig(segments=segs)
|
||||
cfg.clamp_segments() # 默认MAX_CONCAT_SEGMENTS
|
||||
assert len(cfg.segments) == MAX_CONCAT_SEGMENTS
|
||||
|
||||
class TestConstants:
|
||||
def test_allowed_extensions(self):
|
||||
assert ".mp4" in ALLOWED_VIDEO_EXTENSIONS
|
||||
assert ".mov" in ALLOWED_VIDEO_EXTENSIONS
|
||||
assert ".webm" in ALLOWED_VIDEO_EXTENSIONS
|
||||
def test_empty_segments(self):
|
||||
"""空列表不报错."""
|
||||
cfg = ConcatConfig()
|
||||
cfg.clamp_segments()
|
||||
assert cfg.segments == []
|
||||
|
||||
def test_demuxer_params(self):
|
||||
assert "codec_name" in CONCAT_DEMUXER_REQUIRED_PARAMS
|
||||
assert "width" in CONCAT_DEMUXER_REQUIRED_PARAMS
|
||||
assert "r_frame_rate" in CONCAT_DEMUXER_REQUIRED_PARAMS
|
||||
def test_at_limit_stays(self):
|
||||
"""刚好在上限时不变."""
|
||||
segs = [ConcatSegment(video_path=f"v{i}.mp4") for i in range(50)]
|
||||
cfg = ConcatConfig(segments=segs)
|
||||
cfg.clamp_segments(max_segments=50)
|
||||
assert len(cfg.segments) == 50
|
||||
|
||||
Regular → Executable
+596
-252
@@ -1,306 +1,650 @@
|
||||
"""VoiceCloneProfile 领域模型单元测试 — Phase 3 CosyVoice 集成."""
|
||||
"""VoiceCloneProfile 领域模型单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from time import sleep
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
|
||||
from packages.domain.voice_clone_profile import (
|
||||
TERMINAL_STATUSES,
|
||||
VoiceCloneProfile,
|
||||
VoiceCloneStatus,
|
||||
)
|
||||
|
||||
# ── 枚举测试 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVoiceCloneStatus:
|
||||
"""VoiceCloneStatus 枚举测试."""
|
||||
|
||||
def test_status_values(self):
|
||||
"""状态值正确."""
|
||||
assert VoiceCloneStatus.PENDING.value == "pending"
|
||||
assert VoiceCloneStatus.PROCESSING.value == "processing"
|
||||
assert VoiceCloneStatus.READY.value == "ready"
|
||||
assert VoiceCloneStatus.FAILED.value == "failed"
|
||||
assert VoiceCloneStatus.DISABLED.value == "disabled"
|
||||
|
||||
def test_status_count(self):
|
||||
"""共5种状态."""
|
||||
assert len(VoiceCloneStatus) == 5
|
||||
|
||||
def test_is_str_enum(self):
|
||||
"""是StrEnum,可与字符串直接比较."""
|
||||
assert VoiceCloneStatus.PENDING == "pending"
|
||||
assert VoiceCloneStatus.READY + "" == "ready"
|
||||
|
||||
def test_from_string(self):
|
||||
"""从字符串构建枚举."""
|
||||
assert VoiceCloneStatus("pending") == VoiceCloneStatus.PENDING
|
||||
assert VoiceCloneStatus("ready") == VoiceCloneStatus.READY
|
||||
|
||||
def test_from_string_invalid(self):
|
||||
"""无效字符串抛出ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
VoiceCloneStatus("invalid_status")
|
||||
|
||||
|
||||
# ── 终态集合测试 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTerminalStatuses:
|
||||
"""TERMINAL_STATUSES 终态集合测试."""
|
||||
|
||||
def test_ready_is_terminal(self):
|
||||
"""ready是终态."""
|
||||
assert VoiceCloneStatus.READY in TERMINAL_STATUSES
|
||||
|
||||
def test_failed_is_terminal(self):
|
||||
"""failed是终态."""
|
||||
assert VoiceCloneStatus.FAILED in TERMINAL_STATUSES
|
||||
|
||||
def test_disabled_is_terminal(self):
|
||||
"""disabled是终态."""
|
||||
assert VoiceCloneStatus.DISABLED in TERMINAL_STATUSES
|
||||
|
||||
def test_pending_not_terminal(self):
|
||||
"""pending不是终态."""
|
||||
assert VoiceCloneStatus.PENDING not in TERMINAL_STATUSES
|
||||
|
||||
def test_processing_not_terminal(self):
|
||||
"""processing不是终态."""
|
||||
assert VoiceCloneStatus.PROCESSING not in TERMINAL_STATUSES
|
||||
|
||||
def test_terminal_count(self):
|
||||
"""共3个终态."""
|
||||
assert len(TERMINAL_STATUSES) == 3
|
||||
|
||||
|
||||
# ── 工厂方法测试 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVoiceCloneProfileCreate:
|
||||
"""测试 VoiceCloneProfile.create() 工厂方法。"""
|
||||
"""VoiceCloneProfile.create 工厂方法测试."""
|
||||
|
||||
def test_create_success(self) -> None:
|
||||
"""正常创建音色克隆档案。"""
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id="user_001",
|
||||
name="我的音色",
|
||||
description="用于配音的自定义音色",
|
||||
source_audio_url="https://example.com/audio.wav",
|
||||
voice_model="cosyvoice-v1",
|
||||
language="zh-CN",
|
||||
gender="female",
|
||||
)
|
||||
def test_create_basic(self):
|
||||
"""基础创建."""
|
||||
p = VoiceCloneProfile.create(user_id="user123", name="我的音色")
|
||||
assert p.id # 自动生成
|
||||
assert p.user_id == "user123"
|
||||
assert p.name == "我的音色"
|
||||
assert p.status == VoiceCloneStatus.PENDING
|
||||
assert p.retry_count == 0
|
||||
assert p.max_retries == 3
|
||||
|
||||
assert profile.id
|
||||
assert profile.user_id == "user_001"
|
||||
assert profile.name == "我的音色"
|
||||
assert profile.description == "用于配音的自定义音色"
|
||||
assert profile.status == VoiceCloneStatus.PENDING
|
||||
assert profile.source_audio_url == "https://example.com/audio.wav"
|
||||
assert profile.voice_model == "cosyvoice-v1"
|
||||
assert profile.language == "zh-CN"
|
||||
assert profile.gender == "female"
|
||||
assert profile.retry_count == 0
|
||||
assert profile.max_retries == 3
|
||||
assert profile.created_at
|
||||
assert profile.updated_at
|
||||
def test_create_with_description(self):
|
||||
"""带描述创建."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T", description=" 测试描述 ")
|
||||
assert p.description == "测试描述" # strip了
|
||||
|
||||
def test_create_minimal(self) -> None:
|
||||
"""使用最小参数创建。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试音色")
|
||||
def test_create_with_source_audio(self):
|
||||
"""带源音频URL创建."""
|
||||
url = "https://example.com/audio.wav"
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T", source_audio_url=url)
|
||||
assert p.source_audio_url == url
|
||||
|
||||
assert profile.user_id == "user_001"
|
||||
assert profile.name == "测试音色"
|
||||
assert profile.status == VoiceCloneStatus.PENDING
|
||||
assert profile.description == ""
|
||||
assert profile.source_audio_url == ""
|
||||
assert profile.language == "zh-CN"
|
||||
assert profile.gender == "unknown"
|
||||
def test_create_with_language(self):
|
||||
"""指定语言."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T", language="en-US")
|
||||
assert p.language == "en-US"
|
||||
|
||||
def test_create_empty_user_id_raises(self) -> None:
|
||||
"""空 user_id 应抛出 ValueError。"""
|
||||
with pytest.raises(ValueError, match="user_id 不能为空"):
|
||||
VoiceCloneProfile.create(user_id="", name="测试")
|
||||
def test_create_gender_normalized(self):
|
||||
"""性别自动转小写."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T", gender="Male")
|
||||
assert p.gender == "male"
|
||||
|
||||
def test_create_whitespace_user_id_raises(self) -> None:
|
||||
"""空白 user_id 应抛出 ValueError。"""
|
||||
with pytest.raises(ValueError, match="user_id 不能为空"):
|
||||
VoiceCloneProfile.create(user_id=" ", name="测试")
|
||||
def test_create_custom_max_retries(self):
|
||||
"""自定义最大重试次数."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T", max_retries=5)
|
||||
assert p.max_retries == 5
|
||||
|
||||
def test_create_empty_name_raises(self) -> None:
|
||||
"""空 name 应抛出 ValueError。"""
|
||||
with pytest.raises(ValueError, match="name 不能为空"):
|
||||
VoiceCloneProfile.create(user_id="user_001", name="")
|
||||
def test_create_metadata(self):
|
||||
"""元数据."""
|
||||
meta = {"age": 30, "accent": "北方"}
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T", metadata=meta)
|
||||
assert p.metadata == meta
|
||||
# 不是同一个对象引用(深拷贝?)
|
||||
assert p.metadata is not meta or p.metadata == meta
|
||||
|
||||
def test_create_name_too_long_raises(self) -> None:
|
||||
"""name 超过 100 字符应抛出 ValueError。"""
|
||||
with pytest.raises(ValueError, match="name 长度不能超过 100 字符"):
|
||||
VoiceCloneProfile.create(user_id="user_001", name="a" * 101)
|
||||
def test_create_metadata_none(self):
|
||||
"""metadata为None时默认为空dict."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T", metadata=None)
|
||||
assert p.metadata == {}
|
||||
|
||||
def test_create_strips_whitespace(self) -> None:
|
||||
"""应去除首尾空白。"""
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id=" user_001 ",
|
||||
name=" 测试音色 ",
|
||||
description=" 描述 ",
|
||||
)
|
||||
def test_create_user_id_stripped(self):
|
||||
"""user_id去除空白."""
|
||||
p = VoiceCloneProfile.create(user_id=" user123 ", name="T")
|
||||
assert p.user_id == "user123"
|
||||
|
||||
assert profile.user_id == "user_001"
|
||||
assert profile.name == "测试音色"
|
||||
assert profile.description == "描述"
|
||||
def test_create_name_stripped(self):
|
||||
"""name去除空白."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name=" 我的音色 ")
|
||||
assert p.name == "我的音色"
|
||||
|
||||
def test_create_gender_normalized(self) -> None:
|
||||
"""gender 应转换为小写。"""
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id="user_001",
|
||||
name="测试",
|
||||
gender="FEMALE",
|
||||
)
|
||||
def test_create_empty_user_id(self):
|
||||
"""空user_id抛错."""
|
||||
with pytest.raises(ValueError, match="user_id"):
|
||||
VoiceCloneProfile.create(user_id="", name="T")
|
||||
|
||||
assert profile.gender == "female"
|
||||
def test_create_whitespace_user_id(self):
|
||||
"""纯空白user_id抛错."""
|
||||
with pytest.raises(ValueError, match="user_id"):
|
||||
VoiceCloneProfile.create(user_id=" ", name="T")
|
||||
|
||||
def test_create_empty_name(self):
|
||||
"""空name抛错."""
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
VoiceCloneProfile.create(user_id="u1", name="")
|
||||
|
||||
def test_create_whitespace_name(self):
|
||||
"""纯空白name抛错."""
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
VoiceCloneProfile.create(user_id="u1", name=" ")
|
||||
|
||||
def test_create_name_too_long(self):
|
||||
"""name超过100字符抛错."""
|
||||
long_name = "a" * 101
|
||||
with pytest.raises(ValueError, match="100"):
|
||||
VoiceCloneProfile.create(user_id="u1", name=long_name)
|
||||
|
||||
def test_create_name_exactly_100(self):
|
||||
"""name恰好100字符正常."""
|
||||
name = "a" * 100
|
||||
p = VoiceCloneProfile.create(user_id="u1", name=name)
|
||||
assert p.name == name
|
||||
|
||||
def test_create_has_created_at(self):
|
||||
"""创建后有created_at时间戳."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
assert isinstance(p.created_at, datetime)
|
||||
assert p.created_at.tzinfo is not None # 有时区
|
||||
|
||||
def test_create_has_updated_at(self):
|
||||
"""创建后有updated_at时间戳."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
assert isinstance(p.updated_at, datetime)
|
||||
|
||||
def test_create_id_is_hex(self):
|
||||
"""id是32位hex字符串(uuid4 hex)."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
assert len(p.id) == 32
|
||||
# 全部是hex字符
|
||||
int(p.id, 16) # 不抛错就是hex
|
||||
|
||||
|
||||
class TestVoiceCloneProfileStatus:
|
||||
"""测试状态相关属性和方法。"""
|
||||
|
||||
def test_initial_status_is_pending(self) -> None:
|
||||
"""初始状态应为 PENDING。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
assert profile.status == VoiceCloneStatus.PENDING
|
||||
|
||||
def test_is_terminal_pending(self) -> None:
|
||||
"""PENDING 不是终态。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
assert not profile.is_terminal
|
||||
|
||||
def test_is_terminal_ready(self) -> None:
|
||||
"""READY 是终态。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.mark_processing()
|
||||
profile.mark_ready(voice_id="voice_001")
|
||||
assert profile.is_terminal
|
||||
|
||||
def test_is_terminal_failed(self) -> None:
|
||||
"""FAILED 是终态。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.mark_processing()
|
||||
profile.mark_failed("克隆失败")
|
||||
assert profile.is_terminal
|
||||
|
||||
def test_is_retryable_not_failed(self) -> None:
|
||||
"""非 FAILED 状态不可重试。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
assert not profile.is_retryable
|
||||
|
||||
def test_is_retryable_failed_under_limit(self) -> None:
|
||||
"""FAILED 且未超过重试上限时可重试。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.mark_processing()
|
||||
profile.mark_failed("克隆失败")
|
||||
assert profile.is_retryable
|
||||
|
||||
def test_is_retryable_failed_over_limit(self) -> None:
|
||||
"""超过重试上限时不可重试。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试", max_retries=1)
|
||||
profile.mark_processing()
|
||||
profile.mark_failed("第一次失败")
|
||||
profile.prepare_retry()
|
||||
profile.mark_processing()
|
||||
profile.mark_failed("第二次失败")
|
||||
assert not profile.is_retryable
|
||||
|
||||
def test_is_ready_with_voice_id(self) -> None:
|
||||
"""READY 且有 voice_id 时应返回 True。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.mark_processing()
|
||||
profile.mark_ready(voice_id="voice_001")
|
||||
assert profile.is_ready
|
||||
|
||||
def test_is_ready_without_voice_id(self) -> None:
|
||||
"""READY 但无 voice_id 时应返回 False。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.mark_processing()
|
||||
profile.status = VoiceCloneStatus.READY
|
||||
profile.voice_id = ""
|
||||
assert not profile.is_ready
|
||||
# ── 属性测试 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVoiceCloneProfileTransitions:
|
||||
"""测试状态转换。"""
|
||||
class TestVoiceCloneProfileProperties:
|
||||
"""VoiceCloneProfile 属性测试."""
|
||||
|
||||
def test_mark_processing(self) -> None:
|
||||
"""PENDING → PROCESSING 转换。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.mark_processing()
|
||||
assert profile.status == VoiceCloneStatus.PROCESSING
|
||||
assert profile.error_message == ""
|
||||
def test_is_terminal_pending(self):
|
||||
"""pending不是终态."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
assert p.is_terminal is False
|
||||
|
||||
def test_mark_ready(self) -> None:
|
||||
"""PROCESSING → READY 转换。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.mark_processing()
|
||||
profile.mark_ready(voice_id="voice_001")
|
||||
assert profile.status == VoiceCloneStatus.READY
|
||||
assert profile.voice_id == "voice_001"
|
||||
assert profile.error_message == ""
|
||||
def test_is_terminal_processing(self):
|
||||
"""processing不是终态."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
assert p.is_terminal is False
|
||||
|
||||
def test_mark_ready_empty_voice_id_raises(self) -> None:
|
||||
"""mark_ready 空 voice_id 应抛出 ValueError。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.mark_processing()
|
||||
with pytest.raises(ValueError, match="voice_id 不能为空"):
|
||||
profile.mark_ready(voice_id="")
|
||||
def test_is_terminal_ready(self):
|
||||
"""ready是终态."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_ready("voice_001")
|
||||
assert p.is_terminal is True
|
||||
|
||||
def test_mark_failed(self) -> None:
|
||||
"""PROCESSING → FAILED 转换。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.mark_processing()
|
||||
profile.mark_failed("API 调用失败")
|
||||
assert profile.status == VoiceCloneStatus.FAILED
|
||||
assert profile.error_message == "API 调用失败"
|
||||
def test_is_terminal_failed(self):
|
||||
"""failed是终态."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_failed("超时")
|
||||
assert p.is_terminal is True
|
||||
|
||||
def test_mark_disabled_from_pending(self) -> None:
|
||||
"""PENDING → DISABLED 转换。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.mark_disabled()
|
||||
assert profile.status == VoiceCloneStatus.DISABLED
|
||||
def test_is_terminal_disabled(self):
|
||||
"""disabled是终态."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_disabled()
|
||||
assert p.is_terminal is True
|
||||
|
||||
def test_mark_disabled_from_ready(self) -> None:
|
||||
"""READY → DISABLED 转换。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.mark_processing()
|
||||
profile.mark_ready(voice_id="voice_001")
|
||||
profile.mark_disabled()
|
||||
assert profile.status == VoiceCloneStatus.DISABLED
|
||||
def test_is_retryable_failed_within_limit(self):
|
||||
"""失败且未超过重试次数,可重试."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T", max_retries=3)
|
||||
p.mark_processing()
|
||||
p.mark_failed("error")
|
||||
assert p.is_retryable is True
|
||||
|
||||
def test_invalid_transition_raises(self) -> None:
|
||||
"""非法状态转换应抛出 ValueError。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
def test_is_retryable_failed_at_limit(self):
|
||||
"""失败但已达重试上限,不可重试."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T", max_retries=3)
|
||||
p.mark_processing()
|
||||
p.mark_failed("e1")
|
||||
p.prepare_retry() # retry_count=1
|
||||
p.mark_processing()
|
||||
p.mark_failed("e2")
|
||||
p.prepare_retry() # retry_count=2
|
||||
p.mark_processing()
|
||||
p.mark_failed("e3")
|
||||
p.prepare_retry() # retry_count=3
|
||||
p.mark_processing()
|
||||
p.mark_failed("e4") # retry_count=3, max=3
|
||||
assert p.is_retryable is False
|
||||
|
||||
def test_is_retryable_pending(self):
|
||||
"""pending状态不可重试."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
assert p.is_retryable is False
|
||||
|
||||
def test_is_retryable_ready(self):
|
||||
"""ready状态不可重试."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_ready("v1")
|
||||
assert p.is_retryable is False
|
||||
|
||||
def test_is_retryable_processing(self):
|
||||
"""processing状态不可重试."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
assert p.is_retryable is False
|
||||
|
||||
def test_is_ready_with_voice_id(self):
|
||||
"""ready状态且有voice_id,is_ready为True."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_ready("voice_001")
|
||||
assert p.is_ready is True
|
||||
|
||||
def test_is_ready_no_voice_id(self):
|
||||
"""ready状态但无voice_id,is_ready为False."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.status = VoiceCloneStatus.READY # 手动设为ready但无voice_id
|
||||
p.voice_id = ""
|
||||
assert p.is_ready is False
|
||||
|
||||
def test_is_ready_pending(self):
|
||||
"""pending状态is_ready为False."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
assert p.is_ready is False
|
||||
|
||||
def test_is_ready_failed(self):
|
||||
"""failed状态is_ready为False."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_failed("err")
|
||||
assert p.is_ready is False
|
||||
|
||||
|
||||
# ── 状态转换测试 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionTo:
|
||||
"""transition_to 状态转换测试."""
|
||||
|
||||
def test_pending_to_processing(self):
|
||||
"""pending → processing 合法."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
assert p.status == VoiceCloneStatus.PROCESSING
|
||||
|
||||
def test_pending_to_failed(self):
|
||||
"""pending → failed 合法."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.transition_to(VoiceCloneStatus.FAILED)
|
||||
assert p.status == VoiceCloneStatus.FAILED
|
||||
|
||||
def test_pending_to_disabled(self):
|
||||
"""pending → disabled 合法."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.transition_to(VoiceCloneStatus.DISABLED)
|
||||
assert p.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
def test_pending_to_ready_invalid(self):
|
||||
"""pending → ready 非法."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
with pytest.raises(ValueError, match="非法状态转换"):
|
||||
profile.mark_ready(voice_id="voice_001") # PENDING → READY 非法
|
||||
p.transition_to(VoiceCloneStatus.READY)
|
||||
|
||||
def test_invalid_status_string_raises(self) -> None:
|
||||
"""无效状态字符串应抛出 ValueError。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
def test_processing_to_ready(self):
|
||||
"""processing → ready 合法."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.transition_to(VoiceCloneStatus.READY)
|
||||
assert p.status == VoiceCloneStatus.READY
|
||||
|
||||
def test_processing_to_failed(self):
|
||||
"""processing → failed 合法."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.transition_to(VoiceCloneStatus.FAILED)
|
||||
assert p.status == VoiceCloneStatus.FAILED
|
||||
|
||||
def test_processing_to_disabled(self):
|
||||
"""processing → disabled 合法."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.transition_to(VoiceCloneStatus.DISABLED)
|
||||
assert p.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
def test_failed_to_pending(self):
|
||||
"""failed → pending 合法(重试)."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_failed("err")
|
||||
p.transition_to(VoiceCloneStatus.PENDING)
|
||||
assert p.status == VoiceCloneStatus.PENDING
|
||||
|
||||
def test_failed_to_ready_invalid(self):
|
||||
"""failed → ready 非法."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_failed("err")
|
||||
with pytest.raises(ValueError):
|
||||
p.transition_to(VoiceCloneStatus.READY)
|
||||
|
||||
def test_ready_to_disabled(self):
|
||||
"""ready → disabled 合法."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_ready("v1")
|
||||
p.transition_to(VoiceCloneStatus.DISABLED)
|
||||
assert p.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
def test_disabled_to_pending_invalid(self):
|
||||
"""disabled → pending 非法."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_disabled()
|
||||
with pytest.raises(ValueError):
|
||||
p.transition_to(VoiceCloneStatus.PENDING)
|
||||
|
||||
def test_transition_with_string(self):
|
||||
"""字符串输入的状态转换."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.transition_to("processing")
|
||||
assert p.status == VoiceCloneStatus.PROCESSING
|
||||
|
||||
def test_transition_with_invalid_string(self):
|
||||
"""无效字符串状态抛错."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
with pytest.raises(ValueError, match="无效状态"):
|
||||
profile.transition_to("invalid_status")
|
||||
p.transition_to("invalid")
|
||||
|
||||
def test_transition_to_with_string(self) -> None:
|
||||
"""支持字符串形式的状态转换。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.transition_to("processing")
|
||||
assert profile.status == VoiceCloneStatus.PROCESSING
|
||||
def test_transition_updates_updated_at(self):
|
||||
"""状态转换更新updated_at."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
old_updated = p.updated_at
|
||||
sleep(0.01)
|
||||
p.transition_to(VoiceCloneStatus.PROCESSING)
|
||||
assert p.updated_at > old_updated
|
||||
|
||||
def test_transition_error_message_contains_statuses(self):
|
||||
"""错误信息包含源状态和目标状态."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
p.transition_to(VoiceCloneStatus.READY)
|
||||
msg = str(exc_info.value)
|
||||
assert "pending" in msg
|
||||
assert "ready" in msg
|
||||
|
||||
|
||||
class TestVoiceCloneProfileRetry:
|
||||
"""测试重试逻辑。"""
|
||||
# ── 操作方法测试 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_prepare_retry_success(self) -> None:
|
||||
"""成功重试。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.mark_processing()
|
||||
profile.mark_failed("失败")
|
||||
profile.prepare_retry()
|
||||
|
||||
assert profile.status == VoiceCloneStatus.PENDING
|
||||
assert profile.retry_count == 1
|
||||
assert profile.error_message == ""
|
||||
assert profile.voice_id == ""
|
||||
class TestMarkMethods:
|
||||
"""mark_* 系列方法测试."""
|
||||
|
||||
def test_prepare_retry_not_failed_raises(self) -> None:
|
||||
"""非 FAILED 状态重试应抛出 ValueError。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
def test_mark_processing_clears_error(self):
|
||||
"""mark_processing 清除错误信息."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.error_message = "previous error"
|
||||
p.mark_processing()
|
||||
assert p.error_message == ""
|
||||
|
||||
def test_mark_processing_from_pending(self):
|
||||
"""从pending标记为processing."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
assert p.status == VoiceCloneStatus.PROCESSING
|
||||
|
||||
def test_mark_ready_with_voice_id(self):
|
||||
"""mark_ready 正常标记."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_ready("voice_001")
|
||||
assert p.status == VoiceCloneStatus.READY
|
||||
assert p.voice_id == "voice_001"
|
||||
|
||||
def test_mark_ready_clears_error(self):
|
||||
"""mark_ready 清除错误信息."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.error_message = "some error"
|
||||
p.mark_ready("v1")
|
||||
assert p.error_message == ""
|
||||
|
||||
def test_mark_ready_empty_voice_id(self):
|
||||
"""空voice_id抛错."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
with pytest.raises(ValueError, match="voice_id"):
|
||||
p.mark_ready("")
|
||||
|
||||
def test_mark_ready_whitespace_voice_id(self):
|
||||
"""纯空白voice_id抛错."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
with pytest.raises(ValueError):
|
||||
p.mark_ready(" ")
|
||||
|
||||
def test_mark_ready_strips_voice_id(self):
|
||||
"""voice_id去除空白."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_ready(" voice_001 ")
|
||||
assert p.voice_id == "voice_001"
|
||||
|
||||
def test_mark_failed_sets_error(self):
|
||||
"""mark_failed 设置错误信息."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_failed("连接超时")
|
||||
assert p.error_message == "连接超时"
|
||||
|
||||
def test_mark_failed_from_pending(self):
|
||||
"""从pending直接失败."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_failed("验证失败")
|
||||
assert p.status == VoiceCloneStatus.FAILED
|
||||
assert p.error_message == "验证失败"
|
||||
|
||||
def test_mark_disabled_from_pending(self):
|
||||
"""从pending禁用."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_disabled()
|
||||
assert p.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
def test_mark_disabled_from_ready(self):
|
||||
"""从ready禁用."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_ready("v1")
|
||||
p.mark_disabled()
|
||||
assert p.status == VoiceCloneStatus.DISABLED
|
||||
|
||||
|
||||
# ── 重试逻辑测试 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPrepareRetry:
|
||||
"""prepare_retry 重试逻辑测试."""
|
||||
|
||||
def test_prepare_retry_basic(self):
|
||||
"""基础重试成功."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T", max_retries=3)
|
||||
p.mark_processing()
|
||||
p.mark_failed("err")
|
||||
p.prepare_retry()
|
||||
assert p.status == VoiceCloneStatus.PENDING
|
||||
assert p.retry_count == 1
|
||||
|
||||
def test_prepare_retry_clears_error(self):
|
||||
"""重试清除错误信息."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_failed("big error")
|
||||
p.prepare_retry()
|
||||
assert p.error_message == ""
|
||||
|
||||
def test_prepare_retry_clears_voice_id(self):
|
||||
"""重试清除voice_id."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.voice_id = "old_voice"
|
||||
p.mark_processing()
|
||||
p.mark_failed("err")
|
||||
p.prepare_retry()
|
||||
assert p.voice_id == ""
|
||||
|
||||
def test_prepare_retry_not_failed(self):
|
||||
"""非failed状态不可重试."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
profile.prepare_retry()
|
||||
|
||||
def test_prepare_retry_over_limit_raises(self) -> None:
|
||||
"""超过重试上限重试应抛出 ValueError。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试", max_retries=1)
|
||||
profile.mark_processing()
|
||||
profile.mark_failed("第一次失败")
|
||||
profile.prepare_retry()
|
||||
profile.mark_processing()
|
||||
profile.mark_failed("第二次失败")
|
||||
p.prepare_retry()
|
||||
|
||||
def test_prepare_retry_exceeds_max(self):
|
||||
"""超过最大重试次数不可重试."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T", max_retries=1)
|
||||
p.mark_processing()
|
||||
p.mark_failed("e1")
|
||||
p.prepare_retry() # retry_count=1
|
||||
p.mark_processing()
|
||||
p.mark_failed("e2")
|
||||
with pytest.raises(ValueError, match="不可重试"):
|
||||
profile.prepare_retry()
|
||||
p.prepare_retry()
|
||||
|
||||
def test_prepare_retry_error_has_details(self):
|
||||
"""错误信息包含详细状态."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
p.prepare_retry()
|
||||
msg = str(exc_info.value)
|
||||
assert "pending" in msg
|
||||
assert "retry_count" in msg
|
||||
assert "max_retries" in msg
|
||||
|
||||
|
||||
class TestVoiceCloneProfileToDict:
|
||||
"""测试序列化。"""
|
||||
# ── 序列化测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_to_dict_contains_all_fields(self) -> None:
|
||||
"""to_dict 应包含所有字段。"""
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id="user_001",
|
||||
name="测试音色",
|
||||
description="描述",
|
||||
source_audio_url="https://example.com/audio.wav",
|
||||
voice_model="cosyvoice-v1",
|
||||
|
||||
class TestToDict:
|
||||
"""to_dict 序列化测试."""
|
||||
|
||||
def test_to_dict_keys(self):
|
||||
"""序列化字典包含所有预期字段."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="测试音色")
|
||||
d = p.to_dict()
|
||||
expected_keys = {
|
||||
"id",
|
||||
"user_id",
|
||||
"name",
|
||||
"description",
|
||||
"status",
|
||||
"source_audio_url",
|
||||
"voice_id",
|
||||
"voice_model",
|
||||
"language",
|
||||
"gender",
|
||||
"error_message",
|
||||
"retry_count",
|
||||
"max_retries",
|
||||
"is_retryable",
|
||||
"is_ready",
|
||||
"metadata",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
assert set(d.keys()) == expected_keys
|
||||
|
||||
def test_to_dict_values(self):
|
||||
"""序列化值正确."""
|
||||
p = VoiceCloneProfile.create(
|
||||
user_id="user123",
|
||||
name="我的音色",
|
||||
description="测试用",
|
||||
language="zh-CN",
|
||||
gender="female",
|
||||
max_retries=5,
|
||||
metadata={"key": "value"},
|
||||
metadata={"source": "upload"},
|
||||
)
|
||||
d = p.to_dict()
|
||||
assert d["user_id"] == "user123"
|
||||
assert d["name"] == "我的音色"
|
||||
assert d["description"] == "测试用"
|
||||
assert d["status"] == "pending"
|
||||
assert d["language"] == "zh-CN"
|
||||
assert d["gender"] == "female"
|
||||
assert d["retry_count"] == 0
|
||||
assert d["max_retries"] == 5
|
||||
assert d["is_retryable"] is False
|
||||
assert d["is_ready"] is False
|
||||
assert d["metadata"] == {"source": "upload"}
|
||||
|
||||
result = profile.to_dict()
|
||||
def test_to_dict_ready_status(self):
|
||||
"""ready状态下序列化正确."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_ready("voice_001")
|
||||
d = p.to_dict()
|
||||
assert d["status"] == "ready"
|
||||
assert d["voice_id"] == "voice_001"
|
||||
assert d["is_ready"] is True
|
||||
assert d["is_retryable"] is False
|
||||
|
||||
assert result["id"] == profile.id
|
||||
assert result["user_id"] == "user_001"
|
||||
assert result["name"] == "测试音色"
|
||||
assert result["description"] == "描述"
|
||||
assert result["status"] == "pending"
|
||||
assert result["source_audio_url"] == "https://example.com/audio.wav"
|
||||
assert result["voice_model"] == "cosyvoice-v1"
|
||||
assert result["language"] == "zh-CN"
|
||||
assert result["gender"] == "female"
|
||||
assert result["retry_count"] == 0
|
||||
assert result["max_retries"] == 5
|
||||
assert result["is_retryable"] is False
|
||||
assert result["is_ready"] is False
|
||||
assert result["metadata"] == {"key": "value"}
|
||||
assert result["created_at"] is not None
|
||||
assert result["updated_at"] is not None
|
||||
def test_to_dict_failed_status(self):
|
||||
"""failed状态下序列化正确."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
p.mark_failed("超时错误")
|
||||
d = p.to_dict()
|
||||
assert d["status"] == "failed"
|
||||
assert d["error_message"] == "超时错误"
|
||||
assert d["is_retryable"] is True
|
||||
|
||||
def test_to_dict_after_state_change(self) -> None:
|
||||
"""状态变更后 to_dict 应反映最新状态。"""
|
||||
profile = VoiceCloneProfile.create(user_id="user_001", name="测试")
|
||||
profile.mark_processing()
|
||||
profile.mark_ready(voice_id="voice_001")
|
||||
def test_to_dict_datetime_format(self):
|
||||
"""时间字段是ISO格式字符串."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
d = p.to_dict()
|
||||
# ISO格式可以被datetime解析
|
||||
datetime.fromisoformat(d["created_at"])
|
||||
datetime.fromisoformat(d["updated_at"])
|
||||
|
||||
result = profile.to_dict()
|
||||
|
||||
assert result["status"] == "ready"
|
||||
assert result["voice_id"] == "voice_001"
|
||||
assert result["is_ready"] is True
|
||||
def test_to_dict_with_updated_at_after_transition(self):
|
||||
"""状态转换后updated_at被序列化."""
|
||||
p = VoiceCloneProfile.create(user_id="u1", name="T")
|
||||
p.mark_processing()
|
||||
d = p.to_dict()
|
||||
assert d["updated_at"] is not None
|
||||
assert isinstance(d["updated_at"], str)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user