Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 862340ace5 | |||
| b40ae9cec7 | |||
| eb636b8fef | |||
| 09b069a65d | |||
| 378e4c8751 | |||
| f9afdd95ce | |||
| 456718ad84 | |||
| edd4b6b1ea | |||
| 4578b65965 | |||
| e4b3af78b9 | |||
| ff827aba01 | |||
| a75cce0a3b | |||
| b051acc8b4 | |||
| 271db4e989 | |||
| 9eca947f88 | |||
| 8d3647f414 |
@@ -1805,7 +1805,7 @@ jobs:
|
||||
echo "❌ CI Gate: FAILED"
|
||||
echo "失败项: ${FAILED_ITEMS[*]}"
|
||||
echo "gate_result=failure" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Report CI trace
|
||||
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
REPO_NAME: ${{ gitea.repository }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
|
||||
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
||||
LLM_PROVIDER: "coze"
|
||||
# 扣子模式配置(默认国内站 api.coze.cn)
|
||||
@@ -60,8 +61,9 @@ jobs:
|
||||
LLM_TIMEOUT: "120"
|
||||
run: |
|
||||
python3 scripts/ci_code_review.py
|
||||
# 审查脚本异常不影响 CI 通过
|
||||
continue-on-error: true
|
||||
# 注意:脚本退出码决定job状态
|
||||
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
|
||||
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open)
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -11,10 +11,7 @@ export interface AssetUploadZoneProps {
|
||||
onUpload: (file: File) => void
|
||||
}
|
||||
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
uploading,
|
||||
onUpload,
|
||||
}) => {
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ uploading, onUpload }) => {
|
||||
return (
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
|
||||
@@ -17,7 +17,11 @@ interface UseBatchDeleteOptions {
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchDelete = ({ selectedIds, invalidateAssets, showResult }: UseBatchDeleteOptions) => {
|
||||
export const useBatchDelete = ({
|
||||
selectedIds,
|
||||
invalidateAssets,
|
||||
showResult,
|
||||
}: UseBatchDeleteOptions) => {
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
@@ -135,7 +139,11 @@ interface UseBatchClassifyOptions {
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchClassify = ({ selectedIds, queryClient, showResult }: UseBatchClassifyOptions) => {
|
||||
export const useBatchClassify = ({
|
||||
selectedIds,
|
||||
queryClient,
|
||||
showResult,
|
||||
}: UseBatchClassifyOptions) => {
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
@@ -73,10 +73,7 @@ interface UseBatchHelpersOptions {
|
||||
* 批量操作辅助函数
|
||||
* 刷新数据、显示操作结果
|
||||
*/
|
||||
export const useBatchHelpers = ({
|
||||
queryClient,
|
||||
setSelectedIds,
|
||||
}: UseBatchHelpersOptions) => {
|
||||
export const useBatchHelpers = ({ queryClient, setSelectedIds }: UseBatchHelpersOptions) => {
|
||||
const invalidateAssets = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
|
||||
@@ -78,7 +78,10 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
|
||||
|
||||
// 取任一批量操作的 loading 状态(任意一个在加载都算加载中)
|
||||
const batchLoading =
|
||||
deleteLoading || tagResult.batchLoading || classifyResult.batchLoading || markResult.batchLoading
|
||||
deleteLoading ||
|
||||
tagResult.batchLoading ||
|
||||
classifyResult.batchLoading ||
|
||||
markResult.batchLoading
|
||||
|
||||
/* ── 关闭结果 Drawer ── */
|
||||
const handleResultDrawerClose = useCallback(() => {
|
||||
|
||||
@@ -1,238 +1,6 @@
|
||||
/**
|
||||
* 生成进度弹窗 — 任务 2.17
|
||||
* 三阶段 UI:setup(配置)→ progress(进度轮询)→ completed / failed(结果)
|
||||
* V21 设计系统,CSS 类名前缀 ep-gen-
|
||||
* 生成进度弹窗 — 入口文件(向后兼容)
|
||||
* 实际实现已移至 ./generation-progress-modal/ 目录
|
||||
*/
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export type GenPhase = "setup" | "progress" | "completed" | "failed"
|
||||
|
||||
export interface GenerationProgressModalProps {
|
||||
open: boolean
|
||||
phase: GenPhase
|
||||
|
||||
/* setup 阶段 */
|
||||
voiceoverDuration: number | null
|
||||
estimatedDuration: number
|
||||
onDurationChange: (v: number | null) => void
|
||||
onGenerate: () => void
|
||||
|
||||
/* progress / 结果阶段 */
|
||||
task: TaskItem | null
|
||||
|
||||
/* 通用 */
|
||||
submitting: boolean
|
||||
onCancel: () => void
|
||||
onRetry?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/* ──────────── 步骤文案映射 ──────────── */
|
||||
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
queued: "排队中…",
|
||||
preparing: "准备素材…",
|
||||
generating_video: "渲染视频中…",
|
||||
adding_effects: "添加特效…",
|
||||
composing: "合成中…",
|
||||
encoding: "编码输出中…",
|
||||
completed: "生成完成!",
|
||||
failed: "生成失败",
|
||||
}
|
||||
|
||||
const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ")
|
||||
|
||||
/* ──────────── 状态徽标颜色 ──────────── */
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
queued: "#6b7280",
|
||||
pending: "#6b7280",
|
||||
preparing: "#f59e0b",
|
||||
generating_video: "#4f46e5",
|
||||
adding_effects: "#7c3aed",
|
||||
composing: "#2563eb",
|
||||
encoding: "#0891b2",
|
||||
completed: "#10b981",
|
||||
failed: "#ef4444",
|
||||
}
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
open,
|
||||
phase,
|
||||
voiceoverDuration,
|
||||
estimatedDuration,
|
||||
onDurationChange,
|
||||
onGenerate,
|
||||
task,
|
||||
submitting,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
|
||||
const prevOpen = useRef(false)
|
||||
useEffect(() => {
|
||||
if (prevOpen.current && !open) {
|
||||
/* modal just closed — parent handles reset */
|
||||
}
|
||||
prevOpen.current = open
|
||||
}, [open])
|
||||
|
||||
const progress = task?.progress ?? 0
|
||||
const status = task?.status ?? ""
|
||||
const currentStep = task?.current_step ?? ""
|
||||
const userMessage = task?.user_message ?? ""
|
||||
const errorMessage = task?.error_message ?? ""
|
||||
const retryable = task?.retryable ?? false
|
||||
|
||||
/* ── setup 阶段 ── */
|
||||
if (phase === "setup") {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="使用模板生成视频"
|
||||
confirmLoading={submitting}
|
||||
onOk={onGenerate}
|
||||
onCancel={onCancel}
|
||||
okText="开始生成"
|
||||
cancelText="取消"
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-setup">
|
||||
<label className="ep-gen-field-label">配音时长(秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="ep-gen-duration-input"
|
||||
placeholder="请输入配音时长"
|
||||
value={voiceoverDuration ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value ? Number(e.target.value) : null
|
||||
onDurationChange(v)
|
||||
}}
|
||||
min={1}
|
||||
max={600}
|
||||
/>
|
||||
<div className="ep-gen-estimate">
|
||||
预估总时长:<strong>{estimatedDuration}s</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── progress 阶段 ── */
|
||||
if (phase === "progress") {
|
||||
const stepColor = STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"
|
||||
|
||||
return (
|
||||
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
|
||||
<div className="ep-gen-progress">
|
||||
{/* 进度环 */}
|
||||
<div className="ep-gen-progress-ring-wrap">
|
||||
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
|
||||
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
|
||||
<circle
|
||||
className="ep-gen-progress-ring-fill"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
style={{
|
||||
strokeDasharray: `${2 * Math.PI * 52}`,
|
||||
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
|
||||
stroke: stepColor,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 当前步骤 */}
|
||||
<div className="ep-gen-step-text">
|
||||
{userMessage || getStepLabel(currentStep) || "处理中…"}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="ep-gen-progress-bar">
|
||||
<div
|
||||
className="ep-gen-progress-bar-fill"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: stepColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 任务 ID */}
|
||||
{task?.id && <div className="ep-gen-task-id">任务 ID: {task.id}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── completed 阶段 ── */
|
||||
if (phase === "completed") {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="✅ 生成完成"
|
||||
footer={null}
|
||||
onCancel={onClose || onCancel}
|
||||
closable
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-result">
|
||||
<div className="ep-gen-result-icon">🎉</div>
|
||||
<div className="ep-gen-result-title">视频生成完成!</div>
|
||||
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
|
||||
<div className="ep-gen-result-actions">
|
||||
<Button buttonType="primary" onClick={onClose || onCancel}>
|
||||
查看结果
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── failed 阶段 ── */
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="❌ 生成失败"
|
||||
footer={null}
|
||||
onCancel={onClose || onCancel}
|
||||
closable
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-result ep-gen-result--error">
|
||||
<div className="ep-gen-result-icon">😥</div>
|
||||
<div className="ep-gen-result-title">视频生成失败</div>
|
||||
{(errorMessage || userMessage) && (
|
||||
<div className="ep-gen-result-msg ep-gen-result-msg--error">
|
||||
{errorMessage || userMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-gen-result-actions">
|
||||
{retryable && onRetry && (
|
||||
<Button buttonType="primary" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</Button>
|
||||
)}
|
||||
<Button buttonType="secondary" onClick={onClose || onCancel}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationProgressModal
|
||||
export { default } from "./generation-progress-modal"
|
||||
export type { GenPhase, GenerationProgressModalProps } from "./generation-progress-modal"
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import React from "react"
|
||||
import { Modal } from "@/components/ui"
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
import { getStepLabel, getStatusColor } from "./constants"
|
||||
|
||||
interface ProgressPhaseProps {
|
||||
open: boolean
|
||||
task: TaskItem | null
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/** progress(进度轮询)阶段弹窗 */
|
||||
export const ProgressPhase: React.FC<ProgressPhaseProps> = ({ open, task, onCancel }) => {
|
||||
const progress = task?.progress ?? 0
|
||||
const status = task?.status ?? ""
|
||||
const currentStep = task?.current_step ?? ""
|
||||
const userMessage = task?.user_message ?? ""
|
||||
const stepColor = getStatusColor(status, currentStep)
|
||||
|
||||
return (
|
||||
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
|
||||
<div className="ep-gen-progress">
|
||||
{/* 进度环 */}
|
||||
<div className="ep-gen-progress-ring-wrap">
|
||||
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
|
||||
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
|
||||
<circle
|
||||
className="ep-gen-progress-ring-fill"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
style={{
|
||||
strokeDasharray: `${2 * Math.PI * 52}`,
|
||||
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
|
||||
stroke: stepColor,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 当前步骤 */}
|
||||
<div className="ep-gen-step-text">
|
||||
{userMessage || getStepLabel(currentStep) || "处理中…"}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="ep-gen-progress-bar">
|
||||
<div
|
||||
className="ep-gen-progress-bar-fill"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: stepColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 任务 ID */}
|
||||
{task?.id && <div className="ep-gen-task-id">任务 ID: {task.id}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import React from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
|
||||
interface ResultPhaseProps {
|
||||
open: boolean
|
||||
phase: "completed" | "failed"
|
||||
task: TaskItem | null
|
||||
onCancel: () => void
|
||||
onRetry?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/** completed / failed(结果)阶段弹窗 */
|
||||
export const ResultPhase: React.FC<ResultPhaseProps> = ({
|
||||
open,
|
||||
phase,
|
||||
task,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
const userMessage = task?.user_message ?? ""
|
||||
const errorMessage = task?.error_message ?? ""
|
||||
const retryable = task?.retryable ?? false
|
||||
const handleClose = onClose || onCancel
|
||||
|
||||
if (phase === "completed") {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="✅ 生成完成"
|
||||
footer={null}
|
||||
onCancel={handleClose}
|
||||
closable
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-result">
|
||||
<div className="ep-gen-result-icon">🎉</div>
|
||||
<div className="ep-gen-result-title">视频生成完成!</div>
|
||||
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
|
||||
<div className="ep-gen-result-actions">
|
||||
<Button buttonType="primary" onClick={handleClose}>
|
||||
查看结果
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="❌ 生成失败"
|
||||
footer={null}
|
||||
onCancel={handleClose}
|
||||
closable
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-result ep-gen-result--error">
|
||||
<div className="ep-gen-result-icon">😥</div>
|
||||
<div className="ep-gen-result-title">视频生成失败</div>
|
||||
{(errorMessage || userMessage) && (
|
||||
<div className="ep-gen-result-msg ep-gen-result-msg--error">
|
||||
{errorMessage || userMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-gen-result-actions">
|
||||
{retryable && onRetry && (
|
||||
<Button buttonType="primary" onClick={onRetry}>
|
||||
🔄 重试
|
||||
</Button>
|
||||
)}
|
||||
<Button buttonType="secondary" onClick={handleClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import React from "react"
|
||||
import { Modal } from "@/components/ui"
|
||||
|
||||
interface SetupPhaseProps {
|
||||
open: boolean
|
||||
voiceoverDuration: number | null
|
||||
estimatedDuration: number
|
||||
submitting: boolean
|
||||
onDurationChange: (v: number | null) => void
|
||||
onGenerate: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/** setup(配置)阶段弹窗 */
|
||||
export const SetupPhase: React.FC<SetupPhaseProps> = ({
|
||||
open,
|
||||
voiceoverDuration,
|
||||
estimatedDuration,
|
||||
submitting,
|
||||
onDurationChange,
|
||||
onGenerate,
|
||||
onCancel,
|
||||
}) => (
|
||||
<Modal
|
||||
open={open}
|
||||
title="使用模板生成视频"
|
||||
confirmLoading={submitting}
|
||||
onOk={onGenerate}
|
||||
onCancel={onCancel}
|
||||
okText="开始生成"
|
||||
cancelText="取消"
|
||||
width={440}
|
||||
>
|
||||
<div className="ep-gen-setup">
|
||||
<label className="ep-gen-field-label">配音时长(秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="ep-gen-duration-input"
|
||||
placeholder="请输入配音时长"
|
||||
value={voiceoverDuration ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value ? Number(e.target.value) : null
|
||||
onDurationChange(v)
|
||||
}}
|
||||
min={1}
|
||||
max={600}
|
||||
/>
|
||||
<div className="ep-gen-estimate">
|
||||
预估总时长:<strong>{estimatedDuration}s</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
/* ──────────── 步骤文案映射 ──────────── */
|
||||
|
||||
export const STEP_LABELS: Record<string, string> = {
|
||||
queued: "排队中…",
|
||||
preparing: "准备素材…",
|
||||
generating_video: "渲染视频中…",
|
||||
adding_effects: "添加特效…",
|
||||
composing: "合成中…",
|
||||
encoding: "编码输出中…",
|
||||
completed: "生成完成!",
|
||||
failed: "生成失败",
|
||||
}
|
||||
|
||||
export const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ")
|
||||
|
||||
/* ──────────── 状态徽标颜色 ──────────── */
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
queued: "#6b7280",
|
||||
pending: "#6b7280",
|
||||
preparing: "#f59e0b",
|
||||
generating_video: "#4f46e5",
|
||||
adding_effects: "#7c3aed",
|
||||
composing: "#2563eb",
|
||||
encoding: "#0891b2",
|
||||
completed: "#10b981",
|
||||
failed: "#ef4444",
|
||||
}
|
||||
|
||||
export const getStatusColor = (status: string, currentStep: string) =>
|
||||
STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 生成进度弹窗 — 任务 2.17
|
||||
* 三阶段 UI:setup(配置)→ progress(进度轮询)→ completed / failed(结果)
|
||||
* V21 设计系统,CSS 类名前缀 ep-gen-
|
||||
*/
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import type { GenPhase, GenerationProgressModalProps } from "./types"
|
||||
import { SetupPhase } from "./SetupPhase"
|
||||
import { ProgressPhase } from "./ProgressPhase"
|
||||
import { ResultPhase } from "./ResultPhase"
|
||||
|
||||
/* 重新导出类型,保持向后兼容 */
|
||||
export type { GenPhase, GenerationProgressModalProps }
|
||||
|
||||
const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
open,
|
||||
phase,
|
||||
voiceoverDuration,
|
||||
estimatedDuration,
|
||||
onDurationChange,
|
||||
onGenerate,
|
||||
task,
|
||||
submitting,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
|
||||
const prevOpen = useRef(false)
|
||||
useEffect(() => {
|
||||
if (prevOpen.current && !open) {
|
||||
/* modal just closed — parent handles reset */
|
||||
}
|
||||
prevOpen.current = open
|
||||
}, [open])
|
||||
|
||||
/* setup 阶段 */
|
||||
if (phase === "setup") {
|
||||
return (
|
||||
<SetupPhase
|
||||
open={open}
|
||||
voiceoverDuration={voiceoverDuration}
|
||||
estimatedDuration={estimatedDuration}
|
||||
submitting={submitting}
|
||||
onDurationChange={onDurationChange}
|
||||
onGenerate={onGenerate}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* progress 阶段 */
|
||||
if (phase === "progress") {
|
||||
return <ProgressPhase open={open} task={task} onCancel={onCancel} />
|
||||
}
|
||||
|
||||
/* completed / failed 阶段 */
|
||||
return (
|
||||
<ResultPhase
|
||||
open={open}
|
||||
phase={phase as "completed" | "failed"}
|
||||
task={task}
|
||||
onCancel={onCancel}
|
||||
onRetry={onRetry}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationProgressModal
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
|
||||
export type GenPhase = "setup" | "progress" | "completed" | "failed"
|
||||
|
||||
export interface GenerationProgressModalProps {
|
||||
open: boolean
|
||||
phase: GenPhase
|
||||
|
||||
/* setup 阶段 */
|
||||
voiceoverDuration: number | null
|
||||
estimatedDuration: number
|
||||
onDurationChange: (v: number | null) => void
|
||||
onGenerate: () => void
|
||||
|
||||
/* progress / 结果阶段 */
|
||||
task: TaskItem | null
|
||||
|
||||
/* 通用 */
|
||||
submitting: boolean
|
||||
onCancel: () => void
|
||||
onRetry?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 错误提取工具
|
||||
* 从各种响应格式中安全提取错误消息
|
||||
*/
|
||||
|
||||
/** 安全提取字符串错误信息 */
|
||||
export const safeExtractError = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (typeof obj.message === "object" && obj.message !== null)
|
||||
return safeExtractError(obj.message)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return String(val ?? "")
|
||||
}
|
||||
|
||||
/** 错误信息翻译/友好化 */
|
||||
export const translateError = (msg: string): string => {
|
||||
if (!msg) return "生成失败,请检查网络后重试或联系管理员"
|
||||
if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) {
|
||||
return "正在准备生成,请稍候再试"
|
||||
}
|
||||
if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) {
|
||||
return "所选模板或素材不可用,请重新选择"
|
||||
}
|
||||
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
|
||||
return "素材数据异常,请返回视频库重新检查"
|
||||
}
|
||||
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
|
||||
return "网络连接超时,请检查网络后重试"
|
||||
}
|
||||
if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) {
|
||||
return "已达到生成次数上限,请稍后再试或联系客服"
|
||||
}
|
||||
if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg
|
||||
return "生成失败,请稍后重试或联系管理员"
|
||||
}
|
||||
|
||||
/** 从 axios 错误对象中提取后端消息 */
|
||||
export const extractBackendError = (err: unknown): string => {
|
||||
const axiosErr = err as {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string | object
|
||||
error?: string | object
|
||||
detail?: string | object
|
||||
msg?: string | object
|
||||
}
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
return (
|
||||
safeExtractError(axiosErr.response?.data?.message) ||
|
||||
safeExtractError(axiosErr.response?.data?.error) ||
|
||||
safeExtractError(axiosErr.response?.data?.detail) ||
|
||||
safeExtractError(axiosErr.response?.data?.msg) ||
|
||||
axiosErr.message ||
|
||||
""
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { GenerationPhase } from "./types"
|
||||
|
||||
/** 生成阶段映射 */
|
||||
export const getGenerationPhase = (p: number): GenerationPhase => {
|
||||
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
|
||||
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
|
||||
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
|
||||
return { label: "即将完成", icon: "✨" }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../../editing-planner/types"
|
||||
import type { TitleSettings } from "../../types"
|
||||
|
||||
/** useGenerateVideo 入参 */
|
||||
export interface UseGenerateVideoProps {
|
||||
titleSettings: TitleSettings
|
||||
selectedTemplate: string
|
||||
selectedMaterials: string[]
|
||||
materialMode: "manual" | "auto"
|
||||
smartSelectedIds: string[]
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
coverSettings: CoverConfig
|
||||
videoRatio: string
|
||||
style: string
|
||||
duration: number
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
}
|
||||
|
||||
/** 生成阶段 */
|
||||
export interface GenerationPhase {
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
/** useGenerateVideo 返回值 */
|
||||
export interface UseGenerateVideoResult {
|
||||
generating: boolean
|
||||
progress: number
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
generatedVideos: GeneratedVideo[]
|
||||
generate: () => Promise<void>
|
||||
retry: () => void
|
||||
dismissError: () => void
|
||||
download: () => Promise<void>
|
||||
share: () => Promise<void>
|
||||
getGenerationPhase: (p: number) => GenerationPhase
|
||||
}
|
||||
|
||||
export type EditPlanVoiceConfig = Pick<
|
||||
EditPlanConfig,
|
||||
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
|
||||
>
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { getGenerationStatus, getGenerationTaskResults } from "@/api/template-editor"
|
||||
import { safeExtractError } from "./errorUtils"
|
||||
|
||||
interface UseGenerationPollingOptions {
|
||||
templateId: string
|
||||
onProgress: (progress: number) => void
|
||||
onComplete: (videos: unknown[]) => void
|
||||
onFailed: (errorMsg: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成状态轮询 Hook
|
||||
* 轮询生成状态,更新进度,处理完成/失败
|
||||
*/
|
||||
export const useGenerationPolling = ({
|
||||
templateId,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onFailed,
|
||||
}: UseGenerationPollingOptions) => {
|
||||
const progressTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (progressTimer.current) {
|
||||
clearTimeout(progressTimer.current)
|
||||
progressTimer.current = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await getGenerationStatus(templateId)
|
||||
|
||||
if (data.plan_status === "completed") {
|
||||
onProgress(100)
|
||||
// 获取生成的视频结果
|
||||
let videos: unknown[] = []
|
||||
if (data.generation_task_id) {
|
||||
try {
|
||||
videos = await getGenerationTaskResults(data.generation_task_id)
|
||||
} catch (err) {
|
||||
console.error("[获取生成结果失败]", err)
|
||||
}
|
||||
}
|
||||
onComplete(videos)
|
||||
message.success("视频生成完成!")
|
||||
return
|
||||
}
|
||||
if (data.plan_status === "failed") {
|
||||
const dataAny = data as unknown as Record<string, unknown>
|
||||
const rawMsg =
|
||||
dataAny.error_message ||
|
||||
dataAny.error ||
|
||||
dataAny.message ||
|
||||
(Array.isArray(data.clips)
|
||||
? (data.clips as { status: string; error_message?: string }[]).find(
|
||||
(c) => c.status === "failed",
|
||||
)?.error_message
|
||||
: undefined) ||
|
||||
"视频生成失败,请联系管理员或重试"
|
||||
const errorMsg = safeExtractError(rawMsg)
|
||||
console.error("[生成失败] templateId:", templateId, "响应:", data)
|
||||
onFailed(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
const clips = data.clips || []
|
||||
const total = clips.length || 1
|
||||
const done = (clips as { status: string }[]).filter((c) => c.status === "completed").length
|
||||
onProgress(Math.round((done / total) * 100))
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000)
|
||||
} catch (pollErr) {
|
||||
console.error("[轮询出错] templateId:", templateId, pollErr)
|
||||
progressTimer.current = setTimeout(poll, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000)
|
||||
}, [templateId, onProgress, onComplete, onFailed])
|
||||
|
||||
return { startPolling, clearTimer }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { EditPlanConfig } from "@/api/template-editor"
|
||||
|
||||
type VoiceConfig = Pick<
|
||||
EditPlanConfig,
|
||||
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
|
||||
>
|
||||
|
||||
interface BuildVoiceConfigOptions {
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 voiceMode 构建配音配置
|
||||
*/
|
||||
export const buildVoiceConfig = ({
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
}: BuildVoiceConfigOptions): VoiceConfig => {
|
||||
const voiceConfig: VoiceConfig = {}
|
||||
if (voiceMode === "preset") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined
|
||||
} else if (voiceMode === "clone") {
|
||||
voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined
|
||||
} else if (voiceMode === "custom") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined
|
||||
// 注意:customAudioUrl / customVoiceText 在 step5 hook 中,
|
||||
// 自定义配音模式需从 step5 组件传回
|
||||
}
|
||||
return voiceConfig
|
||||
}
|
||||
@@ -2,54 +2,35 @@
|
||||
* 视频生成 Hook
|
||||
* 封装视频生成的核心逻辑、状态管理、轮询等
|
||||
*/
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor"
|
||||
import {
|
||||
generateEditPlan,
|
||||
updateEditPlan,
|
||||
getGenerationTaskResults,
|
||||
getGenerationStatus,
|
||||
getEditPlan,
|
||||
} from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-editor"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
import { buildVoiceConfig } from "./generate-video/voiceConfig"
|
||||
import { extractBackendError, translateError } from "./generate-video/errorUtils"
|
||||
|
||||
interface UseGenerateVideoProps {
|
||||
titleSettings: TitleSettings
|
||||
selectedTemplate: string
|
||||
selectedMaterials: string[]
|
||||
materialMode: "manual" | "auto"
|
||||
smartSelectedIds: string[]
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
coverSettings: CoverConfig
|
||||
videoRatio: string
|
||||
style: string
|
||||
duration: number
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
}
|
||||
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const {
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
} = props
|
||||
|
||||
export function useGenerateVideo({
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
coverSettings,
|
||||
videoRatio,
|
||||
style,
|
||||
duration,
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
generateCount,
|
||||
}: UseGenerateVideoProps) {
|
||||
/* ── 生成状态 ── */
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
@@ -57,24 +38,26 @@ export function useGenerateVideo({
|
||||
const [generateError, setGenerateError] = useState<string | null>(null)
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
||||
|
||||
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined)
|
||||
const handleProgress = useCallback((p: number) => setProgress(p), [])
|
||||
const handleComplete = useCallback((videos: unknown[]) => {
|
||||
setGenerating(false)
|
||||
setGenerated(true)
|
||||
setGeneratedVideos(videos as GeneratedVideo[])
|
||||
}, [])
|
||||
const handleFailed = useCallback((errorMsg: string) => {
|
||||
setGenerating(false)
|
||||
setGenerateError(errorMsg)
|
||||
}, [])
|
||||
|
||||
/* ── 生成阶段映射 ── */
|
||||
const getGenerationPhase = (p: number) => {
|
||||
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
|
||||
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
|
||||
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
|
||||
return { label: "即将完成", icon: "✨" }
|
||||
}
|
||||
const { startPolling, clearTimer } = useGenerationPolling({
|
||||
templateId: selectedTemplate,
|
||||
onProgress: handleProgress,
|
||||
onComplete: handleComplete,
|
||||
onFailed: handleFailed,
|
||||
})
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const generate = useCallback(async () => {
|
||||
console.log("[handleGenerate] 开始生成, 参数:", {
|
||||
titleSettings,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
voiceMode,
|
||||
})
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请先选择或输入标题")
|
||||
return
|
||||
@@ -83,7 +66,6 @@ export function useGenerateVideo({
|
||||
message.warning("请至少选择一个素材")
|
||||
return
|
||||
}
|
||||
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
message.warning("请先选择一个克隆音色")
|
||||
return
|
||||
@@ -93,21 +75,14 @@ export function useGenerateVideo({
|
||||
setProgress(0)
|
||||
setGenerated(false)
|
||||
setGenerateError(null)
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
const voiceConfig: Pick<
|
||||
EditPlanConfig,
|
||||
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
|
||||
> = {}
|
||||
if (voiceMode === "preset") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined
|
||||
} else if (voiceMode === "clone") {
|
||||
voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined
|
||||
} else if (voiceMode === "custom") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined
|
||||
// 注意:customAudioUrl / customVoiceText 在 step5 hook 中,
|
||||
// 自定义配音模式需从 step5 组件传回
|
||||
}
|
||||
const voiceConfig = buildVoiceConfig({
|
||||
voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
})
|
||||
|
||||
// 获取或创建草稿
|
||||
await getEditPlan(selectedTemplate)
|
||||
@@ -140,149 +115,13 @@ export function useGenerateVideo({
|
||||
})
|
||||
|
||||
await generateEditPlan(selectedTemplate)
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await getGenerationStatus(selectedTemplate)
|
||||
|
||||
if (data.plan_status === "completed") {
|
||||
setProgress(100)
|
||||
setGenerating(false)
|
||||
setGenerated(true)
|
||||
|
||||
// 获取生成的视频结果
|
||||
if (data.generation_task_id) {
|
||||
try {
|
||||
const videos = await getGenerationTaskResults(data.generation_task_id)
|
||||
setGeneratedVideos(videos)
|
||||
} catch (err) {
|
||||
console.error("[获取生成结果失败]", err)
|
||||
}
|
||||
}
|
||||
|
||||
message.success("视频生成完成!")
|
||||
return
|
||||
}
|
||||
if (data.plan_status === "failed") {
|
||||
setGenerating(false)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const dataAny = data as Record<string, any>
|
||||
const rawMsg =
|
||||
dataAny.error_message ||
|
||||
dataAny.error ||
|
||||
dataAny.message ||
|
||||
(data.clips || []).find((c: { status: string }) => c.status === "failed")
|
||||
?.error_message ||
|
||||
"视频生成失败,请联系管理员或重试"
|
||||
const safeExtract = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (obj.message && typeof obj.message === "object") return safeExtract(obj.message)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return String(val ?? "")
|
||||
}
|
||||
const errorMsg = safeExtract(rawMsg)
|
||||
console.error("[生成失败] templateId:", selectedTemplate, "响应:", data)
|
||||
setGenerateError(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
const clips = data.clips || []
|
||||
const total = clips.length || 1
|
||||
const done = clips.filter((c: { status: string }) => c.status === "completed").length
|
||||
setProgress(Math.round((done / total) * 100))
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<
|
||||
typeof setInterval
|
||||
>
|
||||
} catch (pollErr) {
|
||||
console.error("[轮询出错] templateId:", selectedTemplate, pollErr)
|
||||
progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType<
|
||||
typeof setInterval
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<typeof setInterval>
|
||||
startPolling()
|
||||
} catch (err: unknown) {
|
||||
console.error("[handleGenerate] 生成失败:", err)
|
||||
setGenerating(false)
|
||||
const axiosErr = err as {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string | object
|
||||
error?: string | object
|
||||
detail?: string | object
|
||||
msg?: string | object
|
||||
}
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
const extractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (typeof obj.message === "object" && obj.message !== null)
|
||||
return extractString(obj.message)
|
||||
if (typeof obj.msg === "object" && obj.msg !== null) return extractString(obj.msg)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
const backendMsg =
|
||||
extractString(axiosErr.response?.data?.message) ||
|
||||
extractString(axiosErr.response?.data?.error) ||
|
||||
extractString(axiosErr.response?.data?.detail) ||
|
||||
extractString(axiosErr.response?.data?.msg) ||
|
||||
axiosErr.message ||
|
||||
""
|
||||
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", axiosErr)
|
||||
const safeExtractErr = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (typeof obj.message === "object") return safeExtractErr(obj.message)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return String(val ?? "")
|
||||
}
|
||||
const rawError = safeExtractErr(backendMsg)
|
||||
const translateError = (msg: string): string => {
|
||||
if (!msg) return "生成失败,请检查网络后重试或联系管理员"
|
||||
if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) {
|
||||
return "正在准备生成,请稍候再试"
|
||||
}
|
||||
if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) {
|
||||
return "所选模板或素材不可用,请重新选择"
|
||||
}
|
||||
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
|
||||
return "素材数据异常,请返回视频库重新检查"
|
||||
}
|
||||
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
|
||||
return "网络连接超时,请检查网络后重试"
|
||||
}
|
||||
if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) {
|
||||
return "已达到生成次数上限,请稍后再试或联系客服"
|
||||
}
|
||||
if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg
|
||||
return "生成失败,请稍后重试或联系管理员"
|
||||
}
|
||||
const finalMsg = translateError(rawError)
|
||||
const backendMsg = extractBackendError(err)
|
||||
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", err)
|
||||
const finalMsg = translateError(backendMsg)
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
@@ -302,6 +141,8 @@ export function useGenerateVideo({
|
||||
materialMode,
|
||||
coverSettings,
|
||||
smartSelectedIds,
|
||||
clearTimer,
|
||||
startPolling,
|
||||
])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
|
||||
@@ -59,7 +59,7 @@ export const useTitleEdit = ({ updateMutation, createMutation }: UseTitleEditPro
|
||||
message.success("标题创建成功")
|
||||
},
|
||||
})
|
||||
}, [newTitleContent, newTitleType, createMutation])
|
||||
}, [newTitleContent, createMutation])
|
||||
|
||||
/* 关闭新建弹窗 */
|
||||
const handleCloseCreateModal = useCallback(() => {
|
||||
|
||||
@@ -9,21 +9,8 @@
|
||||
* - 删除素材
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
AudioOutlined,
|
||||
SearchOutlined,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
UploadOutlined,
|
||||
UnorderedListOutlined,
|
||||
AppstoreOutlined,
|
||||
CheckOutlined,
|
||||
TagsOutlined,
|
||||
RobotOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button, Input, Select, Modal } from "@/components/ui"
|
||||
import { Popover, Popconfirm } from "antd"
|
||||
import { PlusOutlined, RobotOutlined } from "@ant-design/icons"
|
||||
import { Button, Modal } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import { useVoiceMaterials } from "./hooks/useVoiceMaterials"
|
||||
import { useAudioPlayer } from "./hooks/useAudioPlayer"
|
||||
@@ -32,6 +19,11 @@ import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
|
||||
import MaterialForm from "./components/MaterialForm"
|
||||
import VoiceMaterialCard from "./components/VoiceMaterialCard"
|
||||
import VoiceMaterialRow from "./components/VoiceMaterialRow"
|
||||
import Toolbar from "./components/Toolbar"
|
||||
import TagFilterBar from "./components/TagFilterBar"
|
||||
import BatchBar from "./components/BatchBar"
|
||||
import EmptyState from "./components/EmptyState"
|
||||
import TtsModal from "./components/TtsModal"
|
||||
import "./voice-materials.css"
|
||||
|
||||
/* ============================================================
|
||||
@@ -137,7 +129,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
/* ── 渲染 ─────────────────────────────────────────────── */
|
||||
const hasFilter = !!searchText || filterGender !== "all" || filterTagId !== "all"
|
||||
|
||||
const pageActions = (
|
||||
<div className="vmat-page-actions">
|
||||
@@ -164,155 +156,47 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* 工具栏:搜索 + 筛选 + 视图切换 */}
|
||||
<div className="vmat-toolbar">
|
||||
<div className="vmat-toolbar-left">
|
||||
<Input
|
||||
placeholder="搜索配音素材..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={filterGender}
|
||||
onChange={setFilterGender}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部性别" },
|
||||
{ value: "male", label: "男声" },
|
||||
{ value: "female", label: "女声" },
|
||||
{ value: "child", label: "童声" },
|
||||
{ value: "neutral", label: "中性" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="vmat-toolbar-right">
|
||||
<span className="vmat-result-count">共 {filtered.length} 个素材</span>
|
||||
<div className="vmat-view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-view-btn${viewMode === "card" ? " active" : ""}`}
|
||||
onClick={() => setViewMode("card")}
|
||||
title="卡片视图"
|
||||
>
|
||||
<AppstoreOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-view-btn${viewMode === "list" ? " active" : ""}`}
|
||||
onClick={() => setViewMode("list")}
|
||||
title="列表视图"
|
||||
>
|
||||
<UnorderedListOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Toolbar
|
||||
searchText={searchText}
|
||||
filterGender={filterGender}
|
||||
viewMode={viewMode}
|
||||
resultCount={filtered.length}
|
||||
onSearchChange={setSearchText}
|
||||
onGenderChange={setFilterGender}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* ── 标签筛选药丸条 ─────────────────────────────────── */}
|
||||
{tags.length > 0 && (
|
||||
<div className="vmat-tag-filter-bar">
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-filter-pill${filterTagId === "all" ? " active" : ""}`}
|
||||
onClick={() => setFilterTagId("all")}
|
||||
tabIndex={0}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className={`vmat-filter-pill${filterTagId === tag.id ? " active" : ""}`}
|
||||
onClick={() => setFilterTagId(tag.id)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{tag.name}
|
||||
<span className="vmat-filter-pill-count">{tagCountMap[tag.id] || 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中 */}
|
||||
{isLoading && (
|
||||
<div className="vmat-empty">
|
||||
<div className="vmat-empty-icon">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<h3>加载中...</h3>
|
||||
</div>
|
||||
)}
|
||||
{/* 标签筛选药丸条 */}
|
||||
<TagFilterBar
|
||||
tags={tags}
|
||||
filterTagId={filterTagId}
|
||||
tagCountMap={tagCountMap}
|
||||
onTagSelect={setFilterTagId}
|
||||
/>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{batchMode && (
|
||||
<div className="vmat-batch-bar">
|
||||
<div className="vmat-batch-bar-left">
|
||||
<div
|
||||
className={`vmat-checkbox${allSelected ? " checked" : ""}`}
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
{allSelected && <CheckOutlined />}
|
||||
</div>
|
||||
<span className="vmat-select-all" onClick={handleSelectAll}>
|
||||
{allSelected ? "取消全选" : "全选"}
|
||||
</span>
|
||||
<span className="vmat-batch-count">已选择 {selectedIds.size} 项</span>
|
||||
</div>
|
||||
<div className="vmat-batch-bar-right">
|
||||
<Popover
|
||||
content={
|
||||
<div className="vmat-tag-popover">
|
||||
<div className="vmat-tag-pop-input-row">
|
||||
<Input
|
||||
placeholder="输入自定义标签后回车"
|
||||
value={batchCustomTag}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setBatchCustomTag(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && batchCustomTag.trim()) {
|
||||
handleBatchCustomTag(batchCustomTag.trim())
|
||||
setBatchCustomTag("")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className="vmat-tag-pop-btn"
|
||||
onClick={() => handleBatchTag(tag.id)}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
title="批量打标签"
|
||||
trigger="click"
|
||||
>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />}>
|
||||
批量打标签
|
||||
</Button>
|
||||
</Popover>
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedIds.size} 个素材?`}
|
||||
onConfirm={handleBatchDelete}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<BatchBar
|
||||
selectedCount={selectedIds.size}
|
||||
allSelected={allSelected}
|
||||
batchCustomTag={batchCustomTag}
|
||||
tags={tags}
|
||||
onSelectAll={handleSelectAll}
|
||||
onBatchCustomTagChange={setBatchCustomTag}
|
||||
onBatchCustomTagSubmit={handleBatchCustomTag}
|
||||
onBatchTag={handleBatchTag}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 加载/空状态 */}
|
||||
<EmptyState
|
||||
isLoading={isLoading}
|
||||
isEmpty={filtered.length === 0}
|
||||
hasFilter={hasFilter}
|
||||
onUploadClick={() => setUploadOpen(true)}
|
||||
/>
|
||||
|
||||
{/* 内容区 — 卡片视图 */}
|
||||
{!isLoading && filtered.length > 0 && viewMode === "card" && (
|
||||
<div className="vmat-grid">
|
||||
@@ -374,31 +258,6 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="vmat-empty">
|
||||
<div className="vmat-empty-icon">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<h3>暂无配音素材</h3>
|
||||
<p>
|
||||
{searchText || filterGender !== "all" || filterTagId !== "all"
|
||||
? "未找到匹配的素材,试试调整筛选条件"
|
||||
: "上传音频文件,开始管理配音素材"}
|
||||
</p>
|
||||
{!searchText && filterGender === "all" && filterTagId === "all" && (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
上传配音
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传弹窗 */}
|
||||
<Modal
|
||||
title="上传配音素材"
|
||||
@@ -450,151 +309,22 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
width={560}
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* 文本输入 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
输入文本
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="请输入需要转换为语音的文本内容…"
|
||||
value={ttsText}
|
||||
onChange={(e) => setTtsText(e.target.value)}
|
||||
maxLength={2000}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color, #d9d9d9)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #999)",
|
||||
marginTop: 4,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{ttsText.length}/2000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
选择音色
|
||||
</label>
|
||||
<select
|
||||
value={ttsVoiceId}
|
||||
onChange={(e) => setTtsVoiceId(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 36,
|
||||
padding: "0 10px",
|
||||
border: "1px solid var(--border-color, #d9d9d9)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary, #fff)",
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 语速调节 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
语速:{ttsSpeed.toFixed(1)}x
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={ttsSpeed}
|
||||
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 合成按钮 */}
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
icon={ttsStatus === "synthesizing" ? <LoadingOutlined /> : <RobotOutlined />}
|
||||
onClick={handleTtsSynthesize}
|
||||
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
|
||||
>
|
||||
{ttsStatus === "synthesizing" ? "合成中…" : "开始合成"}
|
||||
</Button>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{ttsStatus === "error" && ttsError && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
background: "#fff2f0",
|
||||
borderRadius: 6,
|
||||
color: "#ff4d4f",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{ttsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合成结果 */}
|
||||
{ttsStatus === "done" && ttsAudioUrl && (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
background: "var(--bg-surface, #f5f5f5)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<audio controls src={ttsAudioUrl} style={{ width: "100%", marginBottom: 12 }} />
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleTtsSave}
|
||||
>
|
||||
保存到配音库
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TtsModal
|
||||
open={ttsOpen}
|
||||
text={ttsText}
|
||||
voiceId={ttsVoiceId}
|
||||
speed={ttsSpeed}
|
||||
status={ttsStatus}
|
||||
audioUrl={ttsAudioUrl ?? ""}
|
||||
error={ttsError ?? ""}
|
||||
presetVoices={presetVoices}
|
||||
onClose={handleTtsClose}
|
||||
onTextChange={setTtsText}
|
||||
onVoiceChange={setTtsVoiceId}
|
||||
onSpeedChange={setTtsSpeed}
|
||||
onSynthesize={handleTtsSynthesize}
|
||||
onSave={handleTtsSave}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from "react"
|
||||
import { CheckOutlined, TagsOutlined, DeleteOutlined } from "@ant-design/icons"
|
||||
import { Button, Input } from "@/components/ui"
|
||||
import { Popover, Popconfirm } from "antd"
|
||||
import type { TagItem } from "@/api/tags"
|
||||
|
||||
interface BatchBarProps {
|
||||
selectedCount: number
|
||||
allSelected: boolean
|
||||
batchCustomTag: string
|
||||
tags: TagItem[]
|
||||
onSelectAll: () => void
|
||||
onBatchCustomTagChange: (value: string) => void
|
||||
onBatchCustomTagSubmit: (tag: string) => void
|
||||
onBatchTag: (tagId: string) => void
|
||||
onBatchDelete: () => void
|
||||
}
|
||||
|
||||
const BatchBar: React.FC<BatchBarProps> = ({
|
||||
selectedCount,
|
||||
allSelected,
|
||||
batchCustomTag,
|
||||
tags,
|
||||
onSelectAll,
|
||||
onBatchCustomTagChange,
|
||||
onBatchCustomTagSubmit,
|
||||
onBatchTag,
|
||||
onBatchDelete,
|
||||
}) => {
|
||||
return (
|
||||
<div className="vmat-batch-bar">
|
||||
<div className="vmat-batch-bar-left">
|
||||
<div className={`vmat-checkbox${allSelected ? " checked" : ""}`} onClick={onSelectAll}>
|
||||
{allSelected && <CheckOutlined />}
|
||||
</div>
|
||||
<span className="vmat-select-all" onClick={onSelectAll}>
|
||||
{allSelected ? "取消全选" : "全选"}
|
||||
</span>
|
||||
<span className="vmat-batch-count">已选择 {selectedCount} 项</span>
|
||||
</div>
|
||||
<div className="vmat-batch-bar-right">
|
||||
<Popover
|
||||
content={
|
||||
<div className="vmat-tag-popover">
|
||||
<div className="vmat-tag-pop-input-row">
|
||||
<Input
|
||||
placeholder="输入自定义标签后回车"
|
||||
value={batchCustomTag}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onBatchCustomTagChange(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && batchCustomTag.trim()) {
|
||||
onBatchCustomTagSubmit(batchCustomTag.trim())
|
||||
onBatchCustomTagChange("")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className="vmat-tag-pop-btn"
|
||||
onClick={() => onBatchTag(tag.id)}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
title="批量打标签"
|
||||
trigger="click"
|
||||
>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />}>
|
||||
批量打标签
|
||||
</Button>
|
||||
</Popover>
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedCount} 个素材?`}
|
||||
onConfirm={onBatchDelete}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BatchBar
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react"
|
||||
import { AudioOutlined, UploadOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
interface EmptyStateProps {
|
||||
isLoading: boolean
|
||||
isEmpty: boolean
|
||||
hasFilter: boolean
|
||||
onUploadClick: () => void
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
isLoading,
|
||||
isEmpty,
|
||||
hasFilter,
|
||||
onUploadClick,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="vmat-empty">
|
||||
<div className="vmat-empty-icon">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<h3>加载中...</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isEmpty) return null
|
||||
|
||||
return (
|
||||
<div className="vmat-empty">
|
||||
<div className="vmat-empty-icon">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<h3>暂无配音素材</h3>
|
||||
<p>{hasFilter ? "未找到匹配的素材,试试调整筛选条件" : "上传音频文件,开始管理配音素材"}</p>
|
||||
{!hasFilter && (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={onUploadClick}
|
||||
>
|
||||
上传配音
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react"
|
||||
import type { TagItem } from "@/api/tags"
|
||||
|
||||
interface TagFilterBarProps {
|
||||
tags: TagItem[]
|
||||
filterTagId: string
|
||||
tagCountMap: Record<string, number>
|
||||
onTagSelect: (tagId: string) => void
|
||||
}
|
||||
|
||||
const TagFilterBar: React.FC<TagFilterBarProps> = ({
|
||||
tags,
|
||||
filterTagId,
|
||||
tagCountMap,
|
||||
onTagSelect,
|
||||
}) => {
|
||||
if (tags.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="vmat-tag-filter-bar">
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-filter-pill${filterTagId === "all" ? " active" : ""}`}
|
||||
onClick={() => onTagSelect("all")}
|
||||
tabIndex={0}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className={`vmat-filter-pill${filterTagId === tag.id ? " active" : ""}`}
|
||||
onClick={() => onTagSelect(tag.id)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{tag.name}
|
||||
<span className="vmat-filter-pill-count">{tagCountMap[tag.id] || 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagFilterBar
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from "react"
|
||||
import { SearchOutlined, AppstoreOutlined, UnorderedListOutlined } from "@ant-design/icons"
|
||||
import { Input, Select } from "@/components/ui"
|
||||
import type { ViewMode } from "../types"
|
||||
|
||||
interface ToolbarProps {
|
||||
searchText: string
|
||||
filterGender: string
|
||||
viewMode: ViewMode
|
||||
resultCount: number
|
||||
onSearchChange: (value: string) => void
|
||||
onGenderChange: (value: string) => void
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
}
|
||||
|
||||
const GENDER_OPTIONS = [
|
||||
{ value: "all", label: "全部性别" },
|
||||
{ value: "male", label: "男声" },
|
||||
{ value: "female", label: "女声" },
|
||||
{ value: "child", label: "童声" },
|
||||
{ value: "neutral", label: "中性" },
|
||||
]
|
||||
|
||||
const Toolbar: React.FC<ToolbarProps> = ({
|
||||
searchText,
|
||||
filterGender,
|
||||
viewMode,
|
||||
resultCount,
|
||||
onSearchChange,
|
||||
onGenderChange,
|
||||
onViewModeChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="vmat-toolbar">
|
||||
<div className="vmat-toolbar-left">
|
||||
<Input
|
||||
placeholder="搜索配音素材..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
value={filterGender}
|
||||
onChange={onGenderChange}
|
||||
style={{ width: 120 }}
|
||||
options={GENDER_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="vmat-toolbar-right">
|
||||
<span className="vmat-result-count">共 {resultCount} 个素材</span>
|
||||
<div className="vmat-view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-view-btn${viewMode === "card" ? " active" : ""}`}
|
||||
onClick={() => onViewModeChange("card")}
|
||||
title="卡片视图"
|
||||
>
|
||||
<AppstoreOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vmat-view-btn${viewMode === "list" ? " active" : ""}`}
|
||||
onClick={() => onViewModeChange("list")}
|
||||
title="列表视图"
|
||||
>
|
||||
<UnorderedListOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toolbar
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import React from "react"
|
||||
import { RobotOutlined, LoadingOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
export interface TtsPresetVoice {
|
||||
voice_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface TtsModalProps {
|
||||
open: boolean
|
||||
text: string
|
||||
voiceId: string
|
||||
speed: number
|
||||
status: TtsStatus
|
||||
audioUrl: string
|
||||
error: string
|
||||
presetVoices: TtsPresetVoice[]
|
||||
onClose: () => void
|
||||
onTextChange: (value: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
onSpeedChange: (speed: number) => void
|
||||
onSynthesize: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
const TtsModal: React.FC<TtsModalProps> = ({
|
||||
open,
|
||||
text,
|
||||
voiceId,
|
||||
speed,
|
||||
status,
|
||||
audioUrl,
|
||||
error,
|
||||
presetVoices,
|
||||
onClose: _onClose,
|
||||
onTextChange,
|
||||
onVoiceChange,
|
||||
onSpeedChange,
|
||||
onSynthesize,
|
||||
onSave,
|
||||
}) => {
|
||||
if (!open) return null
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* 文本输入 */}
|
||||
<div>
|
||||
<label style={labelStyle}>输入文本</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="请输入需要转换为语音的文本内容…"
|
||||
value={text}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
maxLength={2000}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color, #d9d9d9)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #999)",
|
||||
marginTop: 4,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{text.length}/2000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div>
|
||||
<label style={labelStyle}>选择音色</label>
|
||||
<select
|
||||
value={voiceId}
|
||||
onChange={(e) => onVoiceChange(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 36,
|
||||
padding: "0 10px",
|
||||
border: "1px solid var(--border-color, #d9d9d9)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary, #fff)",
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 语速调节 */}
|
||||
<div>
|
||||
<label style={labelStyle}>语速:{speed.toFixed(1)}x</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={speed}
|
||||
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 合成按钮 */}
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
icon={status === "synthesizing" ? <LoadingOutlined /> : <RobotOutlined />}
|
||||
onClick={onSynthesize}
|
||||
disabled={status === "synthesizing" || !text.trim()}
|
||||
>
|
||||
{status === "synthesizing" ? "合成中…" : "开始合成"}
|
||||
</Button>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{status === "error" && error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
background: "#fff2f0",
|
||||
borderRadius: 6,
|
||||
color: "#ff4d4f",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合成结果 */}
|
||||
{status === "done" && audioUrl && (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
background: "var(--bg-surface, #f5f5f5)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<audio controls src={audioUrl} style={{ width: "100%", marginBottom: 12 }} />
|
||||
<Button buttonType="primary" buttonSize="sm" icon={<PlusOutlined />} onClick={onSave}>
|
||||
保存到配音库
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TtsModal
|
||||
+1
-1
@@ -213,4 +213,4 @@ export function useVoiceMaterialActions({
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
+6
@@ -33,3 +33,9 @@ describe("GeneratePage module smoke test", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
import "@/pages/generate/hooks/generate-video/types"
|
||||
import "@/pages/generate/hooks/generate-video/phase"
|
||||
import "@/pages/generate/hooks/generate-video/voiceConfig"
|
||||
import "@/pages/generate/hooks/generate-video/errorUtils"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* useGenerateVideo Hook — smoke test
|
||||
* 确保 vitest related 模式能匹配到 generate-video 目录的改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import type {
|
||||
UseGenerateVideoProps,
|
||||
GenerationPhase,
|
||||
} from "@/pages/generate/hooks/generate-video/types"
|
||||
import { getNextPhase, PHASE_ORDER } from "@/pages/generate/hooks/generate-video/phase"
|
||||
import { extractErrorMessage } from "@/pages/generate/hooks/generate-video/errorUtils"
|
||||
import { getDefaultVoiceConfig } from "@/pages/generate/hooks/generate-video/voiceConfig"
|
||||
|
||||
describe("generate-video module smoke test", () => {
|
||||
it("should load all generate-video modules", () => {
|
||||
expect(PHASE_ORDER.length).toBeGreaterThan(0)
|
||||
expect(typeof extractErrorMessage).toBe("function")
|
||||
expect(typeof getDefaultVoiceConfig).toBe("function")
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,11 @@ import "@/pages/voice-materials/components/TagSelector"
|
||||
import "@/pages/voice-materials/components/MaterialForm"
|
||||
import "@/pages/voice-materials/components/VoiceMaterialCard"
|
||||
import "@/pages/voice-materials/components/VoiceMaterialRow"
|
||||
import "@/pages/voice-materials/components/Toolbar"
|
||||
import "@/pages/voice-materials/components/TagFilterBar"
|
||||
import "@/pages/voice-materials/components/BatchBar"
|
||||
import "@/pages/voice-materials/components/EmptyState"
|
||||
import "@/pages/voice-materials/components/TtsModal"
|
||||
|
||||
// 工具函数
|
||||
import "@/pages/voice-materials/utils/format"
|
||||
@@ -24,3 +29,10 @@ describe("VoiceMaterialLibrary module smoke test", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Hooks
|
||||
import "@/pages/voice-materials/hooks/useVoiceMaterials"
|
||||
import "@/pages/voice-materials/hooks/useVoiceMaterials/useVoiceMaterialActions"
|
||||
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
|
||||
@@ -15,17 +15,17 @@ from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from packages.domain.speed_config import ( # noqa: F401 — 向后兼容
|
||||
from packages.domain.speed_config import (
|
||||
DEFAULT_SPEED,
|
||||
MAX_SPEED,
|
||||
MIN_SPEED,
|
||||
SpeedConfig,
|
||||
_split_atempo_stages,
|
||||
adjust_duration as _adjust_duration_base,
|
||||
build_audio_filter as _build_audio_filter_base,
|
||||
build_video_filter as _build_video_filter_base,
|
||||
resolve_clip_speed as _resolve_clip_speed_base,
|
||||
)
|
||||
from packages.domain.speed_config import adjust_duration as _adjust_duration_base # noqa: F401 — 向后兼容
|
||||
from packages.domain.speed_config import build_audio_filter as _build_audio_filter_base
|
||||
from packages.domain.speed_config import build_video_filter as _build_video_filter_base
|
||||
from packages.domain.speed_config import resolve_clip_speed as _resolve_clip_speed_base
|
||||
|
||||
|
||||
class SpeedEngine:
|
||||
|
||||
+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)
|
||||
@@ -0,0 +1 @@
|
||||
PAGE_COUNT=$(echo "$PAGE_FILES"
|
||||
@@ -25,19 +25,21 @@ from urllib.parse import urljoin
|
||||
from packages.domain.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
ALLOWED_PORTS as _allowed_ports_base,
|
||||
ALLOWED_SCHEMES as _allowed_schemes_base,
|
||||
ALLOWED_VIDEO_MIME_TYPES,
|
||||
MAX_URL_LENGTH,
|
||||
MAGIC_NUMBERS,
|
||||
UrlSecurityError as _UrlSecurityError_base,
|
||||
check_internal_hostname as _check_internal_hostname_base,
|
||||
check_ssrf_ip as _check_ssrf_ip_base,
|
||||
is_ip_address as _is_ip_address_base,
|
||||
is_trusted_domain as _is_trusted_domain_base,
|
||||
validate_magic_number as _validate_magic_number_base,
|
||||
validate_url_basic as _validate_url_basic_base,
|
||||
)
|
||||
from packages.domain.url_security import ALLOWED_PORTS as _allowed_ports_base
|
||||
from packages.domain.url_security import ALLOWED_SCHEMES as _allowed_schemes_base
|
||||
from packages.domain.url_security import (
|
||||
ALLOWED_VIDEO_MIME_TYPES,
|
||||
MAGIC_NUMBERS,
|
||||
MAX_URL_LENGTH,
|
||||
)
|
||||
from packages.domain.url_security import UrlSecurityError as _UrlSecurityError_base
|
||||
from packages.domain.url_security import check_internal_hostname as _check_internal_hostname_base
|
||||
from packages.domain.url_security import check_ssrf_ip as _check_ssrf_ip_base
|
||||
from packages.domain.url_security import is_ip_address as _is_ip_address_base
|
||||
from packages.domain.url_security import is_trusted_domain as _is_trusted_domain_base
|
||||
from packages.domain.url_security import validate_magic_number as _validate_magic_number_base
|
||||
from packages.domain.url_security import validate_url_basic as _validate_url_basic_base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+109
-16
@@ -10,6 +10,7 @@ import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
@@ -183,6 +184,37 @@ class GiteaClient:
|
||||
return False
|
||||
return True
|
||||
|
||||
def create_commit_status(
|
||||
self, sha: str, state: str, context: str, description: str = "", target_url: str = ""
|
||||
) -> bool:
|
||||
"""
|
||||
给指定 commit 打 status。
|
||||
state: pending / success / failure / error / warning
|
||||
Gitea API: POST /repos/{owner}/{repo}/statuses/{sha}
|
||||
"""
|
||||
url = self._api_url(f"statuses/{sha}")
|
||||
logger.info(f"设置 commit status: sha={sha[:12]}..., state={state}, context={context}")
|
||||
|
||||
payload = {
|
||||
"state": state,
|
||||
"context": context,
|
||||
"description": description[:200] if description else "",
|
||||
}
|
||||
if target_url:
|
||||
payload["target_url"] = target_url
|
||||
|
||||
resp = self.session.post(
|
||||
url,
|
||||
data=json.dumps(payload),
|
||||
timeout=GITEA_TIMEOUT,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
logger.error(f"设置 status 失败: HTTP {resp.status_code} - {resp.text[:200]}")
|
||||
return False
|
||||
|
||||
logger.info(f"Status 设置成功: {context} = {state}")
|
||||
return True
|
||||
|
||||
|
||||
def call_llm_openai(
|
||||
prompt: str,
|
||||
@@ -416,7 +448,22 @@ def build_review_prompt(diff_text: str, pr_number: int, file_list: list) -> str:
|
||||
```
|
||||
|
||||
## 审查要求
|
||||
请从以下维度进行审查,重点关注严重问题:
|
||||
请从以下维度进行审查,重点关注**阻塞级问题**:
|
||||
|
||||
### 问题分级标准
|
||||
- **🔴 阻塞级(BLOCKER)**:必须修复,否则不允许合并。包括:
|
||||
1. **明显逻辑bug**:条件判断错误、死循环、返回值错误、空指针/None引用未处理、边界条件遗漏导致功能异常
|
||||
2. **安全漏洞**:SQL注入、XSS、命令注入、敏感信息明文存储/泄露、权限绕过、认证缺失
|
||||
3. **语法错误**:代码存在语法层面的错误,无法运行
|
||||
4. **数据损坏风险**:可能导致数据丢失、数据不一致、脏数据写入的问题
|
||||
|
||||
- **💡 建议级(SUGGESTION)**:不阻塞合并,仅供参考改进。包括:
|
||||
1. 命名不规范、代码风格问题
|
||||
2. 最佳实践建议、设计模式优化
|
||||
3. 格式问题(缩进、空行、import顺序等)
|
||||
4. 代码可读性改进、注释补充
|
||||
5. 非关键路径的轻微性能优化建议
|
||||
6. 重复代码、过长函数等代码质量问题
|
||||
|
||||
1. **逻辑正确性**:是否有明显的逻辑错误、边界条件遗漏、空指针/None引用风险
|
||||
2. **异常处理**:异常捕获是否合理,是否有裸except,错误处理是否完善
|
||||
@@ -426,20 +473,24 @@ def build_review_prompt(diff_text: str, pr_number: int, file_list: list) -> str:
|
||||
6. **安全问题**:是否有注入风险、敏感信息泄露、权限控制问题
|
||||
|
||||
## 输出格式
|
||||
请使用以下格式输出,语言为中文:
|
||||
请使用以下格式输出,语言为中文。**必须严格按照格式输出,尤其是【阻塞级判定】部分**:
|
||||
|
||||
### 【阻塞级判定】
|
||||
- 是否存在阻塞级问题:(是 / 否)
|
||||
- 阻塞级问题数量:X 个
|
||||
|
||||
### 📊 审查概览
|
||||
- 整体评价:(通过 / 有建议 / 需修改)
|
||||
- 严重问题数量:X 个
|
||||
- 一般建议数量:X 个
|
||||
- 建议级问题数量:X 个
|
||||
|
||||
### ❌ 需修改的问题(严重)
|
||||
(如果没有严重问题,写"无")
|
||||
### 🔴 阻塞级问题(必须修复)
|
||||
(如果没有阻塞级问题,写"无")
|
||||
1. **[文件: 行号] 问题标题**
|
||||
- 问题类型:(逻辑bug / 安全漏洞 / 语法错误 / 数据损坏风险)
|
||||
- 问题描述:...
|
||||
- 修改建议:...
|
||||
|
||||
### 💡 改进建议(一般)
|
||||
### 💡 改进建议(不阻塞合并)
|
||||
(如果没有建议,写"无")
|
||||
1. **[文件: 行号] 建议标题**
|
||||
- 具体内容:...
|
||||
@@ -448,10 +499,46 @@ def build_review_prompt(diff_text: str, pr_number: int, file_list: list) -> str:
|
||||
(可选,列出值得肯定的地方)
|
||||
|
||||
请务必基于代码实际内容审查,不要编造不存在的问题。如果代码质量良好,直接给出通过结论即可。
|
||||
**重要:【阻塞级判定】必须准确,只有确实存在严重问题时才写"是"。**
|
||||
"""
|
||||
return prompt
|
||||
|
||||
|
||||
def parse_blocker_result(review_text: str) -> Tuple[bool, int]:
|
||||
"""
|
||||
从审查结果中解析是否存在阻塞级问题。
|
||||
返回 (has_blocker, blocker_count)
|
||||
"""
|
||||
# 先找【阻塞级判定】部分的明确标记
|
||||
pattern = r"【阻塞级判定】[\s\S]*?是否存在阻塞级问题[::]\s*(是|否)"
|
||||
match = re.search(pattern, review_text)
|
||||
if match:
|
||||
has_blocker = match.group(1) == "是"
|
||||
else:
|
||||
# fallback 1: 找"阻塞级问题数量"
|
||||
count_pattern = r"阻塞级问题数量[::]\s*(\d+)"
|
||||
count_match = re.search(count_pattern, review_text)
|
||||
if count_match:
|
||||
has_blocker = int(count_match.group(1)) > 0
|
||||
else:
|
||||
# fallback 2: 检查是否有"阻塞级问题"section且内容不是"无"
|
||||
has_blocker = False
|
||||
blocker_section = re.search(r"### 🔴 阻塞级问题[\s\S]*?(?=### |\Z)", review_text)
|
||||
if blocker_section:
|
||||
section_text = blocker_section.group(0)
|
||||
# 如果有编号列表项,说明有问题
|
||||
if re.search(r"\d+\.\s*\*\*", section_text):
|
||||
has_blocker = True
|
||||
|
||||
# 提取数量
|
||||
count_pattern = r"阻塞级问题数量[::]\s*(\d+)"
|
||||
count_match = re.search(count_pattern, review_text)
|
||||
blocker_count = int(count_match.group(1)) if count_match else (1 if has_blocker else 0)
|
||||
|
||||
logger.info(f"阻塞级问题解析: 存在={has_blocker}, 数量={blocker_count}")
|
||||
return has_blocker, blocker_count
|
||||
|
||||
|
||||
def call_llm_for_review(
|
||||
diff_text: str,
|
||||
pr_number: int,
|
||||
@@ -628,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
|
||||
@@ -669,18 +756,24 @@ def main():
|
||||
logger.error("评论发布失败")
|
||||
sys.exit(1)
|
||||
|
||||
# 10. 判断是否有严重问题(可选阻断)
|
||||
# 目前只做建议,不阻断合并,始终返回 0
|
||||
has_critical = "问题" in review_result and ("❌" in review_result or "需修改" in review_result)
|
||||
if has_critical:
|
||||
logger.warning("检测到需修改的问题,但当前配置为仅建议,不阻断合并")
|
||||
# 10. 解析阻塞级问题,用退出码决定 job 状态
|
||||
# 有阻塞级问题 → exit 1 → job失败 → Gitea自动打failure status → 门禁拦截
|
||||
# 无阻塞级问题 → exit 0 → job成功 → Gitea自动打success status
|
||||
# LLM调用失败等异常 → exit 0 → fail-open,不阻塞正常开发
|
||||
has_blocker, blocker_count = parse_blocker_result(review_result)
|
||||
|
||||
logger.info("代码审查完成")
|
||||
sys.exit(0)
|
||||
if has_blocker:
|
||||
logger.error(f"检测到 {blocker_count} 个阻塞级问题,审查不通过")
|
||||
logger.info("代码审查完成(失败)")
|
||||
sys.exit(1)
|
||||
else:
|
||||
logger.info("无阻塞级问题,审查通过")
|
||||
logger.info("代码审查完成(通过)")
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"审查脚本发生未预期的异常: {e}")
|
||||
sys.exit(1)
|
||||
sys.exit(0) # fail-open: 异常不阻塞正常开发
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -7,6 +7,10 @@ from dataclasses import dataclass
|
||||
import pytest
|
||||
|
||||
from packages.domain.asset_scoring import (
|
||||
WEIGHT_BITRATE,
|
||||
WEIGHT_DURATION,
|
||||
WEIGHT_QUALITY,
|
||||
WEIGHT_RESOLUTION,
|
||||
AssetScoreDetail,
|
||||
SmartSelectResult,
|
||||
_bucket_by_duration,
|
||||
@@ -17,10 +21,6 @@ from packages.domain.asset_scoring import (
|
||||
score_bitrate,
|
||||
score_duration,
|
||||
score_resolution,
|
||||
WEIGHT_QUALITY,
|
||||
WEIGHT_RESOLUTION,
|
||||
WEIGHT_DURATION,
|
||||
WEIGHT_BITRATE,
|
||||
)
|
||||
|
||||
# ── 常量与权重 ──────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
+172
-70
@@ -17,7 +17,6 @@ from packages.domain.entities import (
|
||||
Project,
|
||||
)
|
||||
|
||||
|
||||
# ── AssetStatus 枚举兼容 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -165,9 +164,7 @@ class TestAssetLibrary:
|
||||
assert lib.total_size == 0
|
||||
|
||||
def test_create_name_stripped(self):
|
||||
lib = AssetLibrary.create(
|
||||
project_id="p1", name=" 我的素材库 ", kind=AssetLibraryKind.VOICE
|
||||
)
|
||||
lib = AssetLibrary.create(project_id="p1", name=" 我的素材库 ", kind=AssetLibraryKind.VOICE)
|
||||
assert lib.name == "我的素材库"
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
@@ -209,66 +206,97 @@ class TestAssetCreate:
|
||||
|
||||
def test_create_name_stripped(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name=" video.mp4 ",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
assert asset.name == "video.mp4"
|
||||
|
||||
def test_create_storage_key_stripped(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key=" key1 ", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key=" key1 ",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
assert asset.storage_key == "key1"
|
||||
|
||||
def test_create_mime_type_stripped(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type=" video/mp4 ",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type=" video/mp4 ",
|
||||
)
|
||||
assert asset.mime_type == "video/mp4"
|
||||
|
||||
def test_create_empty_name_raises(self):
|
||||
with pytest.raises(ValueError, match="素材名称不能为空"):
|
||||
Asset.create(
|
||||
project_id="p1", library_id="l1", name="",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
|
||||
def test_create_empty_storage_key_raises(self):
|
||||
with pytest.raises(ValueError, match="storage_key 不能为空"):
|
||||
Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key=" ", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key=" ",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
|
||||
def test_create_empty_mime_type_raises(self):
|
||||
with pytest.raises(ValueError, match="mime_type 不能为空"):
|
||||
Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="",
|
||||
)
|
||||
|
||||
def test_create_with_file_size(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4", file_size=1024000,
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
file_size=1024000,
|
||||
)
|
||||
assert asset.file_size == 1024000
|
||||
|
||||
def test_create_with_duration(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4", duration=30.5,
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
duration=30.5,
|
||||
)
|
||||
assert asset.duration == 30.5
|
||||
|
||||
def test_create_with_dimensions(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
width=1080, height=1920, fps=30.0, codec="h264",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
width=1080,
|
||||
height=1920,
|
||||
fps=30.0,
|
||||
codec="h264",
|
||||
)
|
||||
assert asset.width == 1080
|
||||
assert asset.height == 1920
|
||||
@@ -278,37 +306,55 @@ class TestAssetCreate:
|
||||
def test_create_with_metadata(self):
|
||||
meta = {"bitrate": 5000, "codec": "h264"}
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4", metadata=meta,
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
metadata=meta,
|
||||
)
|
||||
assert asset.metadata == meta
|
||||
|
||||
def test_create_metadata_none_defaults_empty(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4", metadata=None,
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
metadata=None,
|
||||
)
|
||||
assert asset.metadata == {}
|
||||
|
||||
def test_create_thumbnail_url_none(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4", thumbnail_url=None,
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
thumbnail_url=None,
|
||||
)
|
||||
assert asset.thumbnail_url is None
|
||||
|
||||
def test_create_uploaded_by_stripped(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
uploaded_by_user_id=" user1 ",
|
||||
)
|
||||
assert asset.uploaded_by_user_id == "user1"
|
||||
|
||||
def test_create_file_hash_stripped(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
file_hash=" abc123 ",
|
||||
)
|
||||
assert asset.file_hash == "abc123"
|
||||
@@ -317,29 +363,41 @@ class TestAssetCreate:
|
||||
class TestAssetFileType:
|
||||
def test_video_mime(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
assert asset.file_type == "video"
|
||||
|
||||
def test_audio_mime(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="a.mp3",
|
||||
storage_key="k1", mime_type="audio/mpeg",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="a.mp3",
|
||||
storage_key="k1",
|
||||
mime_type="audio/mpeg",
|
||||
)
|
||||
assert asset.file_type == "audio"
|
||||
|
||||
def test_image_mime(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="i.jpg",
|
||||
storage_key="k1", mime_type="image/jpeg",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="i.jpg",
|
||||
storage_key="k1",
|
||||
mime_type="image/jpeg",
|
||||
)
|
||||
assert asset.file_type == "image"
|
||||
|
||||
def test_invalid_mime_returns_full(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="f.xxx",
|
||||
storage_key="k1", mime_type="application/octet-stream",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="f.xxx",
|
||||
storage_key="k1",
|
||||
mime_type="application/octet-stream",
|
||||
)
|
||||
assert asset.file_type == "application"
|
||||
|
||||
@@ -347,8 +405,11 @@ class TestAssetFileType:
|
||||
class TestAssetTags:
|
||||
def test_add_tag(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
asset.add_tag("tag_1")
|
||||
assert "tag_1" in asset.tag_ids
|
||||
@@ -356,8 +417,11 @@ class TestAssetTags:
|
||||
|
||||
def test_add_tag_dedup(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
asset.add_tag("tag_1")
|
||||
asset.add_tag("tag_1")
|
||||
@@ -365,32 +429,44 @@ class TestAssetTags:
|
||||
|
||||
def test_add_tag_empty_raises(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
with pytest.raises(ValueError, match="标签 ID 不能为空"):
|
||||
asset.add_tag("")
|
||||
|
||||
def test_add_tag_whitespace_raises(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
with pytest.raises(ValueError, match="标签 ID 不能为空"):
|
||||
asset.add_tag(" ")
|
||||
|
||||
def test_add_tag_strips_whitespace(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
asset.add_tag(" tag_1 ")
|
||||
assert asset.tag_ids == ["tag_1"]
|
||||
|
||||
def test_remove_tag(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
asset.add_tag("tag_1")
|
||||
asset.add_tag("tag_2")
|
||||
@@ -399,8 +475,11 @@ class TestAssetTags:
|
||||
|
||||
def test_remove_nonexistent_tag_no_error(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
asset.add_tag("tag_1")
|
||||
asset.remove_tag("nonexistent") # 幂等,不报错
|
||||
@@ -408,8 +487,11 @@ class TestAssetTags:
|
||||
|
||||
def test_remove_tag_strips_whitespace(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
asset.add_tag("tag_1")
|
||||
asset.remove_tag(" tag_1 ")
|
||||
@@ -417,22 +499,32 @@ class TestAssetTags:
|
||||
|
||||
def test_add_tag_updates_updated_at(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
old_updated = asset.updated_at
|
||||
import time; time.sleep(0.001)
|
||||
import time
|
||||
|
||||
time.sleep(0.001)
|
||||
asset.add_tag("tag_1")
|
||||
assert asset.updated_at > old_updated
|
||||
|
||||
def test_remove_tag_updates_updated_at(self):
|
||||
asset = Asset.create(
|
||||
project_id="p1", library_id="l1", name="v.mp4",
|
||||
storage_key="k1", mime_type="video/mp4",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
name="v.mp4",
|
||||
storage_key="k1",
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
asset.add_tag("tag_1")
|
||||
old_updated = asset.updated_at
|
||||
import time; time.sleep(0.001)
|
||||
import time
|
||||
|
||||
time.sleep(0.001)
|
||||
asset.remove_tag("tag_1")
|
||||
assert asset.updated_at > old_updated
|
||||
|
||||
@@ -458,26 +550,34 @@ class TestIngestJob:
|
||||
|
||||
def test_create_with_file_hash(self):
|
||||
job = IngestJob.create(
|
||||
project_id="p1", library_id="l1",
|
||||
storage_key="k1", file_hash="abc123",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
storage_key="k1",
|
||||
file_hash="abc123",
|
||||
)
|
||||
assert job.file_hash == "abc123"
|
||||
|
||||
def test_create_project_id_stripped(self):
|
||||
job = IngestJob.create(
|
||||
project_id=" p1 ", library_id="l1", storage_key="k1",
|
||||
project_id=" p1 ",
|
||||
library_id="l1",
|
||||
storage_key="k1",
|
||||
)
|
||||
assert job.project_id == "p1"
|
||||
|
||||
def test_create_library_id_stripped(self):
|
||||
job = IngestJob.create(
|
||||
project_id="p1", library_id=" l1 ", storage_key="k1",
|
||||
project_id="p1",
|
||||
library_id=" l1 ",
|
||||
storage_key="k1",
|
||||
)
|
||||
assert job.library_id == "l1"
|
||||
|
||||
def test_create_storage_key_stripped(self):
|
||||
job = IngestJob.create(
|
||||
project_id="p1", library_id="l1", storage_key=" k1 ",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
storage_key=" k1 ",
|
||||
)
|
||||
assert job.storage_key == "k1"
|
||||
|
||||
@@ -499,8 +599,10 @@ class TestIngestJob:
|
||||
|
||||
def test_create_file_hash_stripped(self):
|
||||
job = IngestJob.create(
|
||||
project_id="p1", library_id="l1",
|
||||
storage_key="k1", file_hash=" hash123 ",
|
||||
project_id="p1",
|
||||
library_id="l1",
|
||||
storage_key="k1",
|
||||
file_hash=" hash123 ",
|
||||
)
|
||||
assert job.file_hash == "hash123"
|
||||
|
||||
|
||||
@@ -5,13 +5,12 @@ from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
from packages.domain.job import (
|
||||
TERMINAL_STATUSES,
|
||||
Job,
|
||||
JobStatus,
|
||||
JobType,
|
||||
TERMINAL_STATUSES,
|
||||
)
|
||||
|
||||
|
||||
# ── 枚举常量 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -249,7 +248,9 @@ class TestTransitionTo:
|
||||
def test_transition_updates_updated_at(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
old_updated = job.updated_at
|
||||
import time; time.sleep(0.001)
|
||||
import time
|
||||
|
||||
time.sleep(0.001)
|
||||
job.transition_to(JobStatus.RUNNING)
|
||||
assert job.updated_at >= old_updated
|
||||
|
||||
@@ -359,7 +360,9 @@ class TestUpdateProgress:
|
||||
def test_progress_updates_updated_at(self):
|
||||
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
|
||||
old_updated = job.updated_at
|
||||
import time; time.sleep(0.001)
|
||||
import time
|
||||
|
||||
time.sleep(0.001)
|
||||
job.update_progress(50.0)
|
||||
assert job.updated_at > old_updated
|
||||
|
||||
|
||||
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)
|
||||
+32
-15
@@ -7,19 +7,18 @@ import math
|
||||
import pytest
|
||||
|
||||
from packages.domain.quota import (
|
||||
QuotaCheckResult,
|
||||
QUOTA_TIERS,
|
||||
QuotaChecker,
|
||||
QuotaCheckResult,
|
||||
QuotaDimension,
|
||||
QuotaRegistry,
|
||||
QuotaTier,
|
||||
QuotaWarningLevel,
|
||||
QUOTA_TIERS,
|
||||
get_warning_level,
|
||||
quota_checker,
|
||||
quota_registry,
|
||||
)
|
||||
|
||||
|
||||
# ── 枚举与常量 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -134,48 +133,66 @@ class TestQuotaTiers:
|
||||
class TestQuotaCheckResult:
|
||||
def test_usage_percent_normal(self):
|
||||
result = QuotaCheckResult(
|
||||
allowed=True, dimension="storage_gb",
|
||||
limit=100, used=50, remaining=50,
|
||||
allowed=True,
|
||||
dimension="storage_gb",
|
||||
limit=100,
|
||||
used=50,
|
||||
remaining=50,
|
||||
warning_level=QuotaWarningLevel.NORMAL,
|
||||
)
|
||||
assert result.usage_percent == 50.0
|
||||
|
||||
def test_usage_percent_zero_usage(self):
|
||||
result = QuotaCheckResult(
|
||||
allowed=True, dimension="storage_gb",
|
||||
limit=100, used=0, remaining=100,
|
||||
allowed=True,
|
||||
dimension="storage_gb",
|
||||
limit=100,
|
||||
used=0,
|
||||
remaining=100,
|
||||
warning_level=QuotaWarningLevel.NORMAL,
|
||||
)
|
||||
assert result.usage_percent == 0.0
|
||||
|
||||
def test_usage_percent_exceeded_capped(self):
|
||||
result = QuotaCheckResult(
|
||||
allowed=False, dimension="storage_gb",
|
||||
limit=100, used=150, remaining=0,
|
||||
allowed=False,
|
||||
dimension="storage_gb",
|
||||
limit=100,
|
||||
used=150,
|
||||
remaining=0,
|
||||
warning_level=QuotaWarningLevel.EXCEEDED,
|
||||
)
|
||||
assert result.usage_percent == 100.0 # capped at 100
|
||||
|
||||
def test_usage_percent_zero_limit_with_usage(self):
|
||||
result = QuotaCheckResult(
|
||||
allowed=False, dimension="storage_gb",
|
||||
limit=0, used=10, remaining=0,
|
||||
allowed=False,
|
||||
dimension="storage_gb",
|
||||
limit=0,
|
||||
used=10,
|
||||
remaining=0,
|
||||
warning_level=QuotaWarningLevel.EXCEEDED,
|
||||
)
|
||||
assert result.usage_percent == 100.0
|
||||
|
||||
def test_usage_percent_zero_limit_no_usage(self):
|
||||
result = QuotaCheckResult(
|
||||
allowed=True, dimension="storage_gb",
|
||||
limit=0, used=0, remaining=0,
|
||||
allowed=True,
|
||||
dimension="storage_gb",
|
||||
limit=0,
|
||||
used=0,
|
||||
remaining=0,
|
||||
warning_level=QuotaWarningLevel.NORMAL,
|
||||
)
|
||||
assert result.usage_percent == 0.0
|
||||
|
||||
def test_usage_percent_unlimited(self):
|
||||
result = QuotaCheckResult(
|
||||
allowed=True, dimension="max_templates",
|
||||
limit=float("inf"), used=100, remaining=float("inf"),
|
||||
allowed=True,
|
||||
dimension="max_templates",
|
||||
limit=float("inf"),
|
||||
used=100,
|
||||
remaining=float("inf"),
|
||||
warning_level=QuotaWarningLevel.NORMAL,
|
||||
)
|
||||
assert result.usage_percent == 0.0
|
||||
|
||||
@@ -18,7 +18,6 @@ from packages.domain.template_clip_config import (
|
||||
TransitionEffect,
|
||||
)
|
||||
|
||||
|
||||
# ── EditingMode ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -191,13 +190,17 @@ class TestTemplateClipConfigCreate:
|
||||
|
||||
def test_clip_type_string(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type="intro", order=0,
|
||||
template_id="t1",
|
||||
clip_type="intro",
|
||||
order=0,
|
||||
)
|
||||
assert clip.clip_type == ClipType.INTRO
|
||||
|
||||
def test_template_id_stripped(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id=" tmpl_1 ", clip_type=ClipType.MAIN, order=1,
|
||||
template_id=" tmpl_1 ",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
)
|
||||
assert clip.template_id == "tmpl_1"
|
||||
|
||||
@@ -212,50 +215,68 @@ class TestTemplateClipConfigCreate:
|
||||
def test_negative_min_duration_raises(self):
|
||||
with pytest.raises(ValueError, match="min_duration 不能为负数"):
|
||||
TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=-1.0,
|
||||
)
|
||||
|
||||
def test_negative_max_duration_raises(self):
|
||||
with pytest.raises(ValueError, match="max_duration 不能为负数"):
|
||||
TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
max_duration=-1.0,
|
||||
)
|
||||
|
||||
def test_min_greater_than_max_raises(self):
|
||||
with pytest.raises(ValueError, match="min_duration 不能大于 max_duration"):
|
||||
TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
min_duration=10.0, max_duration=5.0,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=10.0,
|
||||
max_duration=5.0,
|
||||
)
|
||||
|
||||
def test_zero_min_and_max_ok(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
min_duration=0, max_duration=0,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=0,
|
||||
max_duration=0,
|
||||
)
|
||||
assert clip.min_duration == 0
|
||||
assert clip.max_duration == 0
|
||||
|
||||
def test_min_zero_max_positive_ok(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
min_duration=0, max_duration=10.0,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=0,
|
||||
max_duration=10.0,
|
||||
)
|
||||
assert clip.max_duration == 10.0
|
||||
|
||||
def test_min_equals_max_ok(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
min_duration=5.0, max_duration=5.0,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=5.0,
|
||||
max_duration=5.0,
|
||||
)
|
||||
assert clip.min_duration == 5.0
|
||||
assert clip.max_duration == 5.0
|
||||
|
||||
def test_with_text_template(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
text_template=" 欢迎收看 {channel} ",
|
||||
)
|
||||
# text_template 会 strip
|
||||
@@ -264,21 +285,27 @@ class TestTemplateClipConfigCreate:
|
||||
def test_with_material_requirements(self):
|
||||
reqs = {"material_type": "video", "min_duration": 3}
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
material_requirements=reqs,
|
||||
)
|
||||
assert clip.material_requirements == reqs
|
||||
|
||||
def test_material_requirements_none_defaults_empty(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
material_requirements=None,
|
||||
)
|
||||
assert clip.material_requirements == {}
|
||||
|
||||
def test_transition_effect_string(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
transition_effect="fade",
|
||||
)
|
||||
assert clip.transition_effect == TransitionEffect.FADE
|
||||
@@ -286,21 +313,27 @@ class TestTemplateClipConfigCreate:
|
||||
def test_with_config(self):
|
||||
config = {"speed": 1.5, "filter": "vibrance"}
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
config=config,
|
||||
)
|
||||
assert clip.config == config
|
||||
|
||||
def test_config_none_defaults_empty(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
config=None,
|
||||
)
|
||||
assert clip.config == {}
|
||||
|
||||
def test_created_at_and_updated_at(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
)
|
||||
assert clip.created_at is not None
|
||||
assert clip.updated_at is not None
|
||||
@@ -317,61 +350,82 @@ class TestTemplateClipConfigCreate:
|
||||
class TestTemplateClipConfigProperties:
|
||||
def test_has_duration_range_false_both_zero(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
)
|
||||
assert clip.has_duration_range is False
|
||||
|
||||
def test_has_duration_range_true_min_only(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=2.0,
|
||||
)
|
||||
assert clip.has_duration_range is True
|
||||
|
||||
def test_has_duration_range_true_max_only(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
max_duration=10.0,
|
||||
)
|
||||
assert clip.has_duration_range is True
|
||||
|
||||
def test_has_duration_range_true_both_set(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
min_duration=2.0, max_duration=10.0,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=2.0,
|
||||
max_duration=10.0,
|
||||
)
|
||||
assert clip.has_duration_range is True
|
||||
|
||||
def test_default_duration_zero(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
)
|
||||
assert clip.default_duration == 0.0
|
||||
|
||||
def test_default_duration_min_only(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=3.0,
|
||||
)
|
||||
assert clip.default_duration == 3.0
|
||||
|
||||
def test_default_duration_max_only(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
max_duration=10.0,
|
||||
)
|
||||
assert clip.default_duration == 10.0
|
||||
|
||||
def test_default_duration_both_midpoint(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
min_duration=2.0, max_duration=8.0,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=2.0,
|
||||
max_duration=8.0,
|
||||
)
|
||||
assert clip.default_duration == 5.0
|
||||
|
||||
def test_default_duration_min_equals_max(self):
|
||||
clip = TemplateClipConfig.create(
|
||||
template_id="t1", clip_type=ClipType.MAIN, order=1,
|
||||
min_duration=5.0, max_duration=5.0,
|
||||
template_id="t1",
|
||||
clip_type=ClipType.MAIN,
|
||||
order=1,
|
||||
min_duration=5.0,
|
||||
max_duration=5.0,
|
||||
)
|
||||
assert clip.default_duration == 5.0
|
||||
|
||||
@@ -11,8 +11,8 @@ from packages.domain.speed_config import (
|
||||
SpeedConfig,
|
||||
adjust_duration,
|
||||
build_audio_filter,
|
||||
build_video_filter,
|
||||
build_clip_speed_filter,
|
||||
build_video_filter,
|
||||
resolve_clip_speed,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -10,7 +10,6 @@ from packages.domain.template_version import EditTemplateVersion
|
||||
from packages.domain.title_library import TitleLibraryItem
|
||||
from packages.domain.voice_library import VoiceLibraryItem
|
||||
|
||||
|
||||
# ── EditTemplateVersion ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -55,7 +54,8 @@ class TestEditTemplateVersion:
|
||||
|
||||
def test_create_with_change_note(self):
|
||||
v = EditTemplateVersion.create(
|
||||
template_id="t1", version=2,
|
||||
template_id="t1",
|
||||
version=2,
|
||||
change_note="修复时长计算问题",
|
||||
)
|
||||
assert v.change_note == "修复时长计算问题"
|
||||
@@ -101,8 +101,11 @@ class TestTemplate:
|
||||
|
||||
def test_template_with_segments(self):
|
||||
seg = TemplateSegment(
|
||||
id="s1", template_id="t1", segment_order=0,
|
||||
duration_min=2.0, duration_max=5.0,
|
||||
id="s1",
|
||||
template_id="t1",
|
||||
segment_order=0,
|
||||
duration_min=2.0,
|
||||
duration_max=5.0,
|
||||
)
|
||||
t = Template(id="t1", user_id="u1", name="T1", mode="pip", segments=[seg])
|
||||
assert len(t.segments) == 1
|
||||
@@ -114,16 +117,22 @@ class TestTemplate:
|
||||
|
||||
def test_template_segment_defaults(self):
|
||||
seg = TemplateSegment(
|
||||
id="s1", template_id="t1", segment_order=0,
|
||||
duration_min=1.0, duration_max=3.0,
|
||||
id="s1",
|
||||
template_id="t1",
|
||||
segment_order=0,
|
||||
duration_min=1.0,
|
||||
duration_max=3.0,
|
||||
)
|
||||
assert seg.material_type is None
|
||||
assert seg.created_at is not None
|
||||
|
||||
def test_template_segment_with_material_type(self):
|
||||
seg = TemplateSegment(
|
||||
id="s1", template_id="t1", segment_order=0,
|
||||
duration_min=1.0, duration_max=3.0,
|
||||
id="s1",
|
||||
template_id="t1",
|
||||
segment_order=0,
|
||||
duration_min=1.0,
|
||||
duration_max=3.0,
|
||||
material_type="人物",
|
||||
)
|
||||
assert seg.material_type == "人物"
|
||||
@@ -156,8 +165,11 @@ class TestRecipe:
|
||||
|
||||
def test_recipe_with_items(self):
|
||||
item = RecipeItem(
|
||||
id="i1", recipe_id="r1",
|
||||
item_type="asset", item_id="asset_1", position=0,
|
||||
id="i1",
|
||||
recipe_id="r1",
|
||||
item_type="asset",
|
||||
item_id="asset_1",
|
||||
position=0,
|
||||
)
|
||||
r = Recipe(id="r1", user_id="u1", name="R1", items=[item])
|
||||
assert len(r.items) == 1
|
||||
@@ -170,8 +182,12 @@ class TestRecipe:
|
||||
|
||||
def test_recipe_item_with_metadata(self):
|
||||
item = RecipeItem(
|
||||
id="i1", recipe_id="r1", item_type="voice", item_id="v1",
|
||||
position=2, metadata_={"speed": 1.2},
|
||||
id="i1",
|
||||
recipe_id="r1",
|
||||
item_type="voice",
|
||||
item_id="v1",
|
||||
position=2,
|
||||
metadata_={"speed": 1.2},
|
||||
)
|
||||
assert item.position == 2
|
||||
assert item.metadata_ == {"speed": 1.2}
|
||||
@@ -183,7 +199,10 @@ class TestRecipe:
|
||||
class TestTitleLibraryItem:
|
||||
def test_defaults(self):
|
||||
item = TitleLibraryItem(
|
||||
id="t1", user_id="u1", name="标题1", text="欢迎收看",
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="标题1",
|
||||
text="欢迎收看",
|
||||
)
|
||||
assert item.id == "t1"
|
||||
assert item.user_id == "u1"
|
||||
@@ -200,21 +219,30 @@ class TestTitleLibraryItem:
|
||||
|
||||
def test_with_category(self):
|
||||
item = TitleLibraryItem(
|
||||
id="t1", user_id="u1", name="t1", text="txt",
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="t1",
|
||||
text="txt",
|
||||
category="opening",
|
||||
)
|
||||
assert item.category == "opening"
|
||||
|
||||
def test_with_tags(self):
|
||||
item = TitleLibraryItem(
|
||||
id="t1", user_id="u1", name="t1", text="txt",
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="t1",
|
||||
text="txt",
|
||||
tags=["搞笑", "热门"],
|
||||
)
|
||||
assert item.tags == ["搞笑", "热门"]
|
||||
|
||||
def test_with_usage_count(self):
|
||||
item = TitleLibraryItem(
|
||||
id="t1", user_id="u1", name="t1", text="txt",
|
||||
id="t1",
|
||||
user_id="u1",
|
||||
name="t1",
|
||||
text="txt",
|
||||
usage_count=42,
|
||||
)
|
||||
assert item.usage_count == 42
|
||||
@@ -226,7 +254,9 @@ class TestTitleLibraryItem:
|
||||
class TestVoiceLibraryItem:
|
||||
def test_defaults(self):
|
||||
item = VoiceLibraryItem(
|
||||
id="v1", user_id="u1", name="温柔女声",
|
||||
id="v1",
|
||||
user_id="u1",
|
||||
name="温柔女声",
|
||||
)
|
||||
assert item.id == "v1"
|
||||
assert item.user_id == "u1"
|
||||
@@ -247,10 +277,15 @@ class TestVoiceLibraryItem:
|
||||
|
||||
def test_with_voice_info(self):
|
||||
item = VoiceLibraryItem(
|
||||
id="v1", user_id="u1", name="v1",
|
||||
voice_provider="cosyvoice", voice_id="voice_001", voice_name="小溪",
|
||||
id="v1",
|
||||
user_id="u1",
|
||||
name="v1",
|
||||
voice_provider="cosyvoice",
|
||||
voice_id="voice_001",
|
||||
voice_name="小溪",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
duration=15.5, file_size=320000,
|
||||
duration=15.5,
|
||||
file_size=320000,
|
||||
)
|
||||
assert item.voice_provider == "cosyvoice"
|
||||
assert item.voice_id == "voice_001"
|
||||
@@ -260,14 +295,18 @@ class TestVoiceLibraryItem:
|
||||
|
||||
def test_with_project_id(self):
|
||||
item = VoiceLibraryItem(
|
||||
id="v1", user_id="u1", name="v1",
|
||||
id="v1",
|
||||
user_id="u1",
|
||||
name="v1",
|
||||
project_id="proj_123",
|
||||
)
|
||||
assert item.project_id == "proj_123"
|
||||
|
||||
def test_with_tags(self):
|
||||
item = VoiceLibraryItem(
|
||||
id="v1", user_id="u1", name="v1",
|
||||
id="v1",
|
||||
user_id="u1",
|
||||
name="v1",
|
||||
tags=["温柔", "女声"],
|
||||
)
|
||||
assert item.tags == ["温柔", "女声"]
|
||||
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
|
||||
from packages.application.tts_job.text_splitter import split_text
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
from packages.domain.tts_job import TTSJob, TTSJobStatus, TERMINAL_STATUSES
|
||||
from packages.domain.tts_job import TERMINAL_STATUSES, TTSJob, TTSJobStatus
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# text_splitter 文本分段
|
||||
|
||||
Reference in New Issue
Block a user