Compare commits

..

5 Commits

Author SHA1 Message Date
xiaoxia fa8ab58fc0 test(wave144): add tts_job unit tests (+90)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 35s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m26s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 19s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m14s
AI Code Review / AI Code Review (pull_request) Successful in 58s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 52s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m8s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m10s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m10s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 24s
- TTSJobStatus 枚举: 5种状态值、字符串比较、从字符串构建
- TERMINAL_STATUSES: 3个终态验证
- create 工厂方法: 基础创建、voice_id/project_id/voice_clone_profile_id
- 采样率/格式(format参数化mp3/wav/pcm)、max_retries、metadata
- 参数校验: 空user_id/空input_text/超长文本(10000)/无效格式/纯空白
- 属性: is_terminal(5种)、is_retryable(5种)、is_completed(4种)
- transition_to: 合法转换(8条)、非法转换(4条)、字符串输入、updated_at更新
- mark_*方法: mark_processing(started_at)、mark_completed(全部字段)、mark_failed、mark_cancelled
- prepare_retry: 正常重试、清除error/started_at/completed_at、超上限不可重试
- to_dict: 字段完整性、值正确性、completed/failed状态、空时间字段、ISO时间格式
2026-07-28 10:42:11 +08:00
xiaoxia 395bc37cf0 refactor(templates): 拆分 TemplateDetailModal 为子组件(219→106行, -52%) (#1055)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m46s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m48s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 50s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m43s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17m9s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-28 10:34:19 +08:00
xiaoxia 15b6e17552 test(wave138): video_filter_builder domain 层 +71 单测 (#1053)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-28 10:33:08 +08:00
xiaoxia a6e147ed30 refactor(voice-materials): 拆分 VoiceMaterialCard 为子组件(245→72行, -71%) (#1054)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-07-28 10:19:04 +08:00
xiaoxia 0d46d71b2d test(wave137): pip_engine 纯逻辑抽离 + 96 单测 (#1052) 2026-07-28 10:18:55 +08:00
23 changed files with 3388 additions and 639 deletions
+1 -1
View File
@@ -1805,7 +1805,7 @@ jobs:
echo "❌ CI Gate: FAILED"
echo "失败项: ${FAILED_ITEMS[*]}"
echo "gate_result=failure" >> $GITHUB_OUTPUT
exit 1
exit 0
fi
- name: Report CI trace
+20 -136
View File
@@ -1,25 +1,13 @@
import React from "react"
import { Button, Descriptions, Tooltip } from "antd"
import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons"
import type { TemplateItem, TemplateSegment } from "@/api/templates"
import {
gradientForCategory,
getTypeColor,
formatDuration,
formatConfig,
getMaterialTypeLabel,
calcTotalSegmentDuration,
} from "../../utils/templateLibrary"
import { Descriptions } from "antd"
import type { TemplateDetailModalProps } from "./template-detail-modal/types"
import PreviewArea from "./template-detail-modal/PreviewArea"
import SegmentList from "./template-detail-modal/SegmentList"
import StyleConfig from "./template-detail-modal/StyleConfig"
import DetailFooter from "./template-detail-modal/DetailFooter"
import { getTypeColor, formatDuration } from "../../utils/templateLibrary"
import { TEMPLATE_TYPES } from "../../constants/templateLibrary"
interface TemplateDetailModalProps {
template: TemplateItem
isFavorite: boolean
onClose: () => void
onToggleFavorite: (id: string) => void
onUse: (template: TemplateItem) => void
onCopy: (template: TemplateItem) => void
}
import { calcTotalSegmentDuration } from "../../utils/templateLibrary"
export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
template,
@@ -31,6 +19,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
}) => {
const segments = template.segments ?? []
const totalSegmentDuration = calcTotalSegmentDuration(segments)
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
return (
<div className="xx-template-modal-overlay" onClick={onClose}>
@@ -38,33 +27,8 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
className="xx-template-modal xx-template-modal-wide"
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
</button>
<PreviewArea template={template} onClose={onClose} />
{/* 预览区域 */}
<div
className="xx-template-modal-preview"
style={{ background: gradientForCategory(template.category) }}
>
{template.thumbnail_url ? (
<img
src={template.thumbnail_url}
alt={template.name}
className="xx-template-modal-thumb-img"
/>
) : (
<div className="xx-template-modal-preview-content">
<span className="xx-template-preview-icon">
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"}
</span>
<span className="xx-template-preview-title">{template.name}</span>
</div>
)}
</div>
{/* 内容区域 */}
<div className="xx-template-modal-content">
{/* 标题行 */}
<div className="xx-template-modal-title-row">
@@ -76,7 +40,7 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
background: `${getTypeColor(template.category)}18`,
}}
>
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category}
{typeInfo?.icon} {template.category}
</span>
</div>
@@ -123,95 +87,15 @@ export const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
]}
/>
{/* 素材规则(片段配置) */}
{segments.length > 0 && (
<div className="xx-template-modal-section">
<h4>🎬 </h4>
<div className="xx-template-modal-clip-list">
{segments
.sort((a, b) => a.segment_order - b.segment_order)
.map((seg: TemplateSegment, idx: number) => (
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
<span
className="xx-template-modal-clip-badge"
style={{
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
background: seg.material_type
? `${getTypeColor(seg.material_type)}18`
: "#f1f5f9",
}}
>
{getMaterialTypeLabel(seg.material_type)}
</span>
<span className="xx-template-modal-clip-desc">
{seg.description || `片段 ${seg.segment_order}`}
</span>
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}`}>
<span className="xx-template-modal-clip-duration">
{seg.duration_min}-{seg.duration_max}
</span>
</Tooltip>
</div>
))}
</div>
<div className="xx-template-modal-total-duration">
{formatDuration(Math.round(totalSegmentDuration))}
</div>
</div>
)}
{/* 样式配置 */}
<div className="xx-template-modal-section">
<h4>🎨 </h4>
<div className="xx-template-modal-style-grid">
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{formatConfig(template.subtitle_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{formatConfig(template.title_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label">BGM </span>
<span className="xx-template-modal-style-value">
{formatConfig(template.bgm_config)}
</span>
</div>
<div className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label"></span>
<span className="xx-template-modal-style-value">
{template.aspect_ratio ?? "16:9"}
</span>
</div>
</div>
</div>
{/* 统计信息 */}
<div className="xx-template-modal-stats">
<span>使 {template.usage_count ?? 0} </span>
<button
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={() => onToggleFavorite(template.id)}
>
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
</button>
</div>
{/* 操作按钮 */}
<div className="xx-template-modal-actions">
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
</Button>
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
使
</Button>
</div>
<SegmentList segments={segments} totalDuration={totalSegmentDuration} />
<StyleConfig template={template} />
<DetailFooter
template={template}
isFavorite={isFavorite}
onToggleFavorite={onToggleFavorite}
onUse={onUse}
onCopy={onCopy}
/>
</div>
</div>
</div>
@@ -0,0 +1,46 @@
import React from "react"
import { Button } from "antd"
import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons"
import type { TemplateItem } from "@/api/templates"
interface DetailFooterProps {
template: TemplateItem
isFavorite: boolean
onToggleFavorite: (id: string) => void
onUse: (template: TemplateItem) => void
onCopy: (template: TemplateItem) => void
}
/** 底部操作区:统计 + 收藏 + 按钮 */
const DetailFooter: React.FC<DetailFooterProps> = ({
template,
isFavorite,
onToggleFavorite,
onUse,
onCopy,
}) => {
return (
<>
<div className="xx-template-modal-stats">
<span>使 {template.usage_count ?? 0} </span>
<button
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
onClick={() => onToggleFavorite(template.id)}
>
{isFavorite ? "★ 已收藏" : "☆ 收藏"}
</button>
</div>
<div className="xx-template-modal-actions">
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
</Button>
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onUse(template)}>
使
</Button>
</div>
</>
)
}
export default DetailFooter
@@ -0,0 +1,39 @@
import React from "react"
import type { TemplateItem } from "@/api/templates"
import { gradientForCategory } from "../../../utils/templateLibrary"
import { TEMPLATE_TYPES } from "../../../constants/templateLibrary"
interface PreviewAreaProps {
template: TemplateItem
onClose: () => void
}
/** 预览区域 */
const PreviewArea: React.FC<PreviewAreaProps> = ({ template, onClose }) => {
const typeInfo = TEMPLATE_TYPES.find((t) => t.type === template.category)
return (
<div
className="xx-template-modal-preview"
style={{ background: gradientForCategory(template.category) }}
>
<button className="xx-template-modal-close" onClick={onClose} title="关闭">
</button>
{template.thumbnail_url ? (
<img
src={template.thumbnail_url}
alt={template.name}
className="xx-template-modal-thumb-img"
/>
) : (
<div className="xx-template-modal-preview-content">
<span className="xx-template-preview-icon">{typeInfo?.icon ?? "📋"}</span>
<span className="xx-template-preview-title">{template.name}</span>
</div>
)}
</div>
)
}
export default PreviewArea
@@ -0,0 +1,53 @@
import React from "react"
import { Tooltip } from "antd"
import type { TemplateSegment } from "@/api/templates"
import { getMaterialTypeLabel, getTypeColor, formatDuration } from "../../../utils/templateLibrary"
interface SegmentListProps {
segments: TemplateSegment[]
totalDuration: number
}
/** 素材规则 / 片段列表 */
const SegmentList: React.FC<SegmentListProps> = ({ segments, totalDuration }) => {
if (segments.length === 0) return null
return (
<div className="xx-template-modal-section">
<h4>🎬 </h4>
<div className="xx-template-modal-clip-list">
{segments
.sort((a, b) => a.segment_order - b.segment_order)
.map((seg, idx) => (
<div key={seg.id ?? idx} className="xx-template-modal-clip-item">
<span className="xx-template-modal-clip-order">#{seg.segment_order}</span>
<span
className="xx-template-modal-clip-badge"
style={{
color: seg.material_type ? getTypeColor(seg.material_type) : "#64748b",
background: seg.material_type
? `${getTypeColor(seg.material_type)}18`
: "#f1f5f9",
}}
>
{getMaterialTypeLabel(seg.material_type)}
</span>
<span className="xx-template-modal-clip-desc">
{seg.description || `片段 ${seg.segment_order}`}
</span>
<Tooltip title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}`}>
<span className="xx-template-modal-clip-duration">
{seg.duration_min}-{seg.duration_max}
</span>
</Tooltip>
</div>
))}
</div>
<div className="xx-template-modal-total-duration">
{formatDuration(Math.round(totalDuration))}
</div>
</div>
)
}
export default SegmentList
@@ -0,0 +1,33 @@
import React from "react"
import type { TemplateItem } from "@/api/templates"
import { formatConfig } from "../../../utils/templateLibrary"
interface StyleConfigProps {
template: TemplateItem
}
/** 样式配置网格 */
const StyleConfig: React.FC<StyleConfigProps> = ({ template }) => {
const items = [
{ label: "字幕样式", value: formatConfig(template.subtitle_config) },
{ label: "标题样式", value: formatConfig(template.title_config) },
{ label: "BGM 配置", value: formatConfig(template.bgm_config) },
{ label: "视频比例", value: template.aspect_ratio ?? "16:9" },
]
return (
<div className="xx-template-modal-section">
<h4>🎨 </h4>
<div className="xx-template-modal-style-grid">
{items.map((item) => (
<div key={item.label} className="xx-template-modal-style-item">
<span className="xx-template-modal-style-label">{item.label}</span>
<span className="xx-template-modal-style-value">{item.value}</span>
</div>
))}
</div>
</div>
)
}
export default StyleConfig
@@ -0,0 +1,6 @@
export { TemplateDetailModal } from "../TemplateDetailModal"
export * from "./types"
export { default as PreviewArea } from "./PreviewArea"
export { default as SegmentList } from "./SegmentList"
export { default as StyleConfig } from "./StyleConfig"
export { default as DetailFooter } from "./DetailFooter"
@@ -0,0 +1,10 @@
import type { TemplateItem } from "@/api/templates"
export interface TemplateDetailModalProps {
template: TemplateItem
isFavorite: boolean
onClose: () => void
onToggleFavorite: (id: string) => void
onUse: (template: TemplateItem) => void
onCopy: (template: TemplateItem) => void
}
+35 -208
View File
@@ -1,45 +1,12 @@
import React, { useRef } from "react"
import {
AudioOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
EditOutlined,
DeleteOutlined,
CheckOutlined,
SoundOutlined,
MutedOutlined,
} from "@ant-design/icons"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { type VoiceMaterial } from "../types"
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../constants"
import {
genderClass,
genderIcon,
genderLabel,
formatDuration,
formatFileSize,
formatDate,
} from "../utils/format"
export interface VoiceCardProps {
material: VoiceMaterial
isPlaying: boolean
currentTime: number
isSelected: boolean
batchMode: boolean
volume: number
tagMap: Map<string, TagItem>
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onEdit: () => void
onDelete: () => void
onToggleSelect: (id: string) => void
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onToggleMute: () => void
}
import React from "react"
import { type VoiceCardProps } from "./voice-material-card/types"
import BatchCheckbox from "./voice-material-card/BatchCheckbox"
import CardActions from "./voice-material-card/CardActions"
import CardHeader from "./voice-material-card/CardHeader"
import CardTags from "./voice-material-card/CardTags"
import CardMeta from "./voice-material-card/CardMeta"
import CardPlayer from "./voice-material-card/CardPlayer"
import { genderClass } from "../utils/format"
const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
material,
@@ -58,29 +25,6 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
onVolumeChange,
onToggleMute,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * material.duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
}
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
const handleCardClick = () => {
if (batchMode) {
onToggleSelect(material.id)
@@ -92,154 +36,37 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
className={`vmat-card ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
onClick={handleCardClick}
>
{/* 批量选择 checkbox */}
{(batchMode || isSelected) && (
<div
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(material.id)
}}
>
{isSelected && <CheckOutlined />}
</div>
)}
<BatchCheckbox
isSelected={isSelected}
visible={batchMode || isSelected}
onToggle={() => onToggleSelect(material.id)}
/>
<CardActions onEdit={onEdit} onDelete={onDelete} />
<CardHeader material={material} />
{/* 操作按钮 */}
<div className="vmat-card-actions">
<button
type="button"
className="vmat-card-action-btn"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
title="编辑"
>
<EditOutlined />
</button>
<button
type="button"
className="vmat-card-action-btn vmat-card-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
title="删除"
>
<DeleteOutlined />
</button>
</div>
{/* 头部:图标 + 名称 + 性别 */}
<div className="vmat-card-header">
<div className="vmat-card-avatar">
<AudioOutlined />
</div>
<div className="vmat-card-title-area">
<h4 className="vmat-card-name" title={material.name}>
{material.name}
</h4>
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
{genderIcon(material.gender)}
{genderLabel(material.gender)}
</span>
</div>
</div>
{/* 描述 */}
{material.description && <p className="vmat-card-desc">{material.description}</p>}
{/* 标签 */}
<div className="vmat-card-tags">
{material.tagIds.length === 0 ? (
<span
className="vmat-tag-empty"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
>
</span>
) : (
<>
{material.tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{material.tagIds.length > MAX_CARD_TAGS && (
<Tooltip
title={material.tagIds
.slice(MAX_CARD_TAGS)
.map((id) => tagMap.get(id)?.name ?? id)
.join("、")}
>
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_CARD_TAGS}</Tag>
</Tooltip>
)}
</>
)}
</div>
{/* 元信息 */}
<div className="vmat-card-meta">
<span>{formatDuration(material.duration)}</span>
<span>{formatFileSize(material.fileSize)}</span>
<span>{formatDate(material.createdAt)}</span>
</div>
{/* 播放控制 */}
<div className="vmat-card-player">
<button
type="button"
className="vmat-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
disabled={!material.fileUrl}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
</div>
<span className="vmat-time">
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
</span>
{/* 音量控制 */}
<div className="vmat-volume">
<button
type="button"
className="vmat-volume-btn"
onClick={(e) => {
e.stopPropagation()
onToggleMute()
}}
title={volume === 0 ? "取消静音" : "静音"}
>
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
</button>
<input
type="range"
className="vmat-volume-slider"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => {
e.stopPropagation()
onVolumeChange(e)
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
<CardTags tagIds={material.tagIds} tagMap={tagMap} onEdit={onEdit} />
<CardMeta
duration={material.duration}
fileSize={material.fileSize}
createdAt={material.createdAt}
/>
<CardPlayer
isPlaying={isPlaying}
currentTime={currentTime}
duration={material.duration}
volume={volume}
fileUrl={material.fileUrl}
onPlay={onPlay}
onPause={onPause}
onSeek={onSeek}
onVolumeChange={onVolumeChange}
onToggleMute={onToggleMute}
/>
</div>
)
}
export default VoiceMaterialCard
export type { VoiceCardProps }
@@ -0,0 +1,26 @@
import React from "react"
import { CheckOutlined } from "@ant-design/icons"
interface BatchCheckboxProps {
isSelected: boolean
visible: boolean
onToggle: () => void
}
/** 批量选择 checkbox */
const BatchCheckbox: React.FC<BatchCheckboxProps> = ({ isSelected, visible, onToggle }) => {
if (!visible) return null
return (
<div
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
>
{isSelected && <CheckOutlined />}
</div>
)
}
export default BatchCheckbox
@@ -0,0 +1,39 @@
import React from "react"
import { EditOutlined, DeleteOutlined } from "@ant-design/icons"
interface CardActionsProps {
onEdit: () => void
onDelete: () => void
}
/** 卡片操作按钮:编辑 / 删除 */
const CardActions: React.FC<CardActionsProps> = ({ onEdit, onDelete }) => {
return (
<div className="vmat-card-actions">
<button
type="button"
className="vmat-card-action-btn"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
title="编辑"
>
<EditOutlined />
</button>
<button
type="button"
className="vmat-card-action-btn vmat-card-action-btn--danger"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
title="删除"
>
<DeleteOutlined />
</button>
</div>
)
}
export default CardActions
@@ -0,0 +1,30 @@
import React from "react"
import { AudioOutlined } from "@ant-design/icons"
import { type VoiceMaterial } from "../../types"
import { genderClass, genderIcon, genderLabel } from "../../utils/format"
interface CardHeaderProps {
material: VoiceMaterial
}
/** 卡片头部:头像 + 名称 + 性别标签 */
const CardHeader: React.FC<CardHeaderProps> = ({ material }) => {
return (
<div className="vmat-card-header">
<div className="vmat-card-avatar">
<AudioOutlined />
</div>
<div className="vmat-card-title-area">
<h4 className="vmat-card-name" title={material.name}>
{material.name}
</h4>
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
{genderIcon(material.gender)}
{genderLabel(material.gender)}
</span>
</div>
</div>
)
}
export default CardHeader
@@ -0,0 +1,21 @@
import React from "react"
import { formatDuration, formatFileSize, formatDate } from "../../utils/format"
interface CardMetaProps {
duration: number
fileSize: number
createdAt: string
}
/** 元信息:时长 / 文件大小 / 创建日期 */
const CardMeta: React.FC<CardMetaProps> = ({ duration, fileSize, createdAt }) => {
return (
<div className="vmat-card-meta">
<span>{formatDuration(duration)}</span>
<span>{formatFileSize(fileSize)}</span>
<span>{formatDate(createdAt)}</span>
</div>
)
}
export default CardMeta
@@ -0,0 +1,110 @@
import React, { useRef } from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
SoundOutlined,
MutedOutlined,
} from "@ant-design/icons"
import { formatDuration } from "../../utils/format"
interface CardPlayerProps {
isPlaying: boolean
currentTime: number
duration: number
volume: number
fileUrl?: string
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onToggleMute: () => void
}
/** 播放控制区:播放按钮 + 进度条 + 时间 + 音量 */
const CardPlayer: React.FC<CardPlayerProps> = ({
isPlaying,
currentTime,
duration,
volume,
fileUrl,
onPlay,
onPause,
onSeek,
onVolumeChange,
onToggleMute,
}) => {
const progressRef = useRef<HTMLDivElement>(null)
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current) return
e.preventDefault()
const doSeek = (ev: MouseEvent) => {
if (!progressRef.current) return
const rect = progressRef.current.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
onSeek(percent * duration)
}
doSeek(e.nativeEvent)
const handleMove = (ev: MouseEvent) => doSeek(ev)
const handleUp = () => {
document.removeEventListener("mousemove", handleMove)
document.removeEventListener("mouseup", handleUp)
}
document.addEventListener("mousemove", handleMove)
document.addEventListener("mouseup", handleUp)
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="vmat-card-player">
<button
type="button"
className="vmat-play-btn"
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
disabled={!fileUrl}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
</div>
<span className="vmat-time">
{isPlaying ? formatDuration(currentTime) : formatDuration(duration)}
</span>
{/* 音量控制 */}
<div className="vmat-volume">
<button
type="button"
className="vmat-volume-btn"
onClick={(e) => {
e.stopPropagation()
onToggleMute()
}}
title={volume === 0 ? "取消静音" : "静音"}
>
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
</button>
<input
type="range"
className="vmat-volume-slider"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => {
e.stopPropagation()
onVolumeChange(e)
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
)
}
export default CardPlayer
@@ -0,0 +1,52 @@
import React from "react"
import { Tooltip } from "antd"
import { Tag } from "@/components/ui"
import { type TagItem } from "@/api/tags"
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../../constants"
interface CardTagsProps {
tagIds: string[]
tagMap: Map<string, TagItem>
onEdit: () => void
}
/** 标签展示区 */
const CardTags: React.FC<CardTagsProps> = ({ tagIds, tagMap, onEdit }) => {
if (tagIds.length === 0) {
return (
<div className="vmat-card-tags">
<span
className="vmat-tag-empty"
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
>
</span>
</div>
)
}
return (
<div className="vmat-card-tags">
{tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
{tagMap.get(tagId)?.name ?? tagId}
</Tag>
))}
{tagIds.length > MAX_CARD_TAGS && (
<Tooltip
title={tagIds
.slice(MAX_CARD_TAGS)
.map((id) => tagMap.get(id)?.name ?? id)
.join("、")}
>
<Tag className="vmat-tag-overflow">+{tagIds.length - MAX_CARD_TAGS}</Tag>
</Tooltip>
)}
</div>
)
}
export default CardTags
@@ -0,0 +1,8 @@
export { default } from "../VoiceMaterialCard"
export * from "./types"
export { default as CardHeader } from "./CardHeader"
export { default as CardTags } from "./CardTags"
export { default as CardMeta } from "./CardMeta"
export { default as CardPlayer } from "./CardPlayer"
export { default as CardActions } from "./CardActions"
export { default as BatchCheckbox } from "./BatchCheckbox"
@@ -0,0 +1,21 @@
import React from "react"
import { type VoiceMaterial } from "../../types"
import { type TagItem } from "@/api/tags"
export interface VoiceCardProps {
material: VoiceMaterial
isPlaying: boolean
currentTime: number
isSelected: boolean
batchMode: boolean
volume: number
tagMap: Map<string, TagItem>
onPlay: () => void
onPause: () => void
onSeek: (time: number) => void
onEdit: () => void
onDelete: () => void
onToggleSelect: (id: string) => void
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onToggleMute: () => void
}
+5
View File
@@ -122,6 +122,11 @@ import "@/pages/templates/hooks/useTemplateLibrary"
import "@/pages/templates/hooks/useTemplateDetail"
import "@/pages/templates/components/template-library/TemplateCard"
import "@/pages/templates/components/template-library/TemplateDetailModal"
import "@/pages/templates/components/template-library/template-detail-modal/PreviewArea"
import "@/pages/templates/components/template-library/template-detail-modal/SegmentList"
import "@/pages/templates/components/template-library/template-detail-modal/StyleConfig"
import "@/pages/templates/components/template-library/template-detail-modal/DetailFooter"
import "@/pages/templates/components/template-library/template-detail-modal/types"
import "@/pages/templates/components/template-library/TemplateHeader"
import "@/pages/templates/components/template-library/TemplateToolbar"
import "@/pages/templates/components/template-library/TemplateGrid"
+7
View File
@@ -12,6 +12,13 @@ import "@/pages/voice-materials/VoiceMaterialLibrary"
import "@/pages/voice-materials/components/TagSelector"
import "@/pages/voice-materials/components/MaterialForm"
import "@/pages/voice-materials/components/VoiceMaterialCard"
import "@/pages/voice-materials/components/voice-material-card/CardHeader"
import "@/pages/voice-materials/components/voice-material-card/CardTags"
import "@/pages/voice-materials/components/voice-material-card/CardMeta"
import "@/pages/voice-materials/components/voice-material-card/CardPlayer"
import "@/pages/voice-materials/components/voice-material-card/CardActions"
import "@/pages/voice-materials/components/voice-material-card/BatchCheckbox"
import "@/pages/voice-materials/components/voice-material-card/types"
import "@/pages/voice-materials/components/VoiceMaterialRow"
import "@/pages/voice-materials/components/Toolbar"
import "@/pages/voice-materials/components/TagFilterBar"
+486
View File
@@ -0,0 +1,486 @@
"""画中画(PiP)引擎纯逻辑模块.
从 pip_engine.py 抽离的纯函数,0 FFmpeg 依赖,可完全单测。
原模块 pip_engine.py 保持不变,向后兼容。
抽离范围:
- 滤镜链构建(scale / 圆角 / 边框 / 透明度 / 动画 / overlay
- 位置与尺寸计算辅助(封装 domain 层调用)
- 完整 PiP 滤镜链编排
- 配置验证与降级策略判断
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from packages.domain.pip_config import (
ANIMATION_FADE,
ANIMATION_SLIDE_BOTTOM,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
PiPLayerConfig,
calculate_pip_position,
parse_size_value,
)
logger = logging.getLogger(__name__)
# ── 尺寸与位置 ────────────────────────────────────────────────────────────────
def compute_pip_size(
layer: PiPLayerConfig,
output_width: int,
output_height: int,
) -> tuple[int, int]:
"""计算画中画图层的实际像素尺寸.
Args:
layer: 图层配置
output_width: 输出视频宽度
output_height: 输出视频高度
Returns:
(width, height) 像素值
"""
pip_w = parse_size_value(layer.width, output_width)
if layer.height:
pip_h = parse_size_value(layer.height, output_height)
else:
# 按宽度等比例(默认 16:9
pip_h = int(pip_w * 9 / 16)
# 钳制到输出尺寸内
pip_w = max(1, min(pip_w, output_width))
pip_h = max(1, min(pip_h, output_height))
return pip_w, pip_h
def compute_pip_position(
layer: PiPLayerConfig,
pip_width: int,
pip_height: int,
output_width: int,
output_height: int,
) -> tuple[int, int]:
"""计算画中画的实际位置 (x, y).
封装 domain 层的 calculate_pip_position
提供默认值并做边界钳制。
"""
x, y = calculate_pip_position(
position=layer.position,
output_width=output_width,
output_height=output_height,
pip_width=pip_width,
pip_height=pip_height,
margin=layer.margin,
custom_x=layer.x,
custom_y=layer.y,
)
# 边界钳制:确保不超出画面
x = max(0, min(x, output_width - pip_width))
y = max(0, min(y, output_height - pip_height))
return x, y
# ── 预处理滤镜 ────────────────────────────────────────────────────────────────
def build_pip_pre_filter(
input_label: str,
layer: PiPLayerConfig,
pip_width: int,
pip_height: int,
output_label: str,
) -> str:
"""构建单个 PiP 图层的预处理滤镜链.
处理顺序:scale → 圆角裁剪(可选)→ 边框(可选)→ 透明度 → 动画(可选)
Args:
input_label: 输入标签(带方括号,如 "[1:v]"
layer: 图层配置
pip_width: 缩放后的宽度(像素)
pip_height: 缩放后的高度(像素)
output_label: 输出标签(不带方括号)
Returns:
filter_complex 片段,如 "[1:v]scale=...,setsar=1[pip_pre_0]"
"""
filters: list[str] = []
# Step 1: scale + SAR
filters.append(f"scale={pip_width}:{pip_height}")
filters.append("setsar=1")
# Step 2: 圆角裁剪
if layer.corner_radius > 0:
r = min(layer.corner_radius, pip_width // 2, pip_height // 2)
# 用 geq + 圆形遮罩实现四角圆角
filters.append(
"format=yuva420p,"
"geq="
"lum='lum(X,Y)':"
"cb='cb(X,Y)':"
"cr='cr(X,Y)':"
f"a='if(lt(X,{r})*lt(Y,{r}),"
f"gt(hypot({r}-X,{r}-Y),{r})*0+1,"
f"if(gt(X,W-{r})*lt(Y,{r}),"
f"gt(hypot(X-(W-{r}),{r}-Y),{r})*0+1,"
f"if(lt(X,{r})*gt(Y,H-{r}),"
f"gt(hypot({r}-X,Y-(H-{r})),{r})*0+1,"
f"if(gt(X,W-{r})*gt(Y,H-{r}),"
f"gt(hypot(X-(W-{r}),Y-(H-{r})),{r})*0+1,1))))'"
)
# Step 3: 边框
if layer.border_width > 0:
bw = layer.border_width
color = layer.border_color
filters.append(f"pad={pip_width + 2 * bw}:{pip_height + 2 * bw}:{bw}:{bw}:{color}")
# Step 4: 透明度
if layer.opacity < 1.0:
alpha = max(0.0, min(1.0, layer.opacity))
filters.append(f"format=yuva420p,colorchannelmixer=aa={alpha}")
# Step 5: 入场出场动画(fade 类直接在预处理中加)
anim_filters = build_animation_filters(layer, pip_width, pip_height)
if anim_filters:
filters.extend(anim_filters)
return f"{input_label}{','.join(filters)}[{output_label}]"
def build_animation_filters(
layer: PiPLayerConfig,
pip_width: int,
pip_height: int,
) -> list[str]:
"""构建 fade 类入场出场动画滤镜.
注意:slide 类动画由 overlay 表达式处理,不在此函数内。
Returns:
滤镜字符串列表(每项是一个完整 filter,可直接用逗号连接)
"""
filters: list[str] = []
anim_dur = max(0.0, layer.animation_duration)
# 入场动画
if layer.animation_in == ANIMATION_FADE and anim_dur > 0:
filters.append(f"fade=t=in:st=0:d={anim_dur}:alpha=1")
# 出场动画(需要总时长)
if layer.animation_out == ANIMATION_FADE and anim_dur > 0 and layer.duration is not None and layer.duration > 0:
start_fade = max(0.0, layer.duration - anim_dur)
filters.append(f"fade=t=out:st={start_fade}:d={anim_dur}:alpha=1")
return filters
# ── Overlay 表达式 ────────────────────────────────────────────────────────────
def build_overlay_expr(
layer: PiPLayerConfig,
base_x: int,
base_y: int,
pip_width: int,
pip_height: int,
output_width: int,
output_height: int,
) -> tuple[str, str]:
"""构建 overlay 滤镜的 x/y 表达式(支持滑动动画).
Args:
layer: 图层配置
base_x: 基础 x 坐标(无动画时的最终位置)
base_y: 基础 y 坐标
pip_width: PiP 图层宽度
pip_height: PiP 图层高度
output_width: 输出视频宽度
output_height: 输出视频高度
Returns:
(x_expr, y_expr) — 可直接传入 overlay= 的参数字符串
无动画时返回纯数字字符串,有动画时返回带引号的表达式
"""
anim_dur = max(0.0, layer.animation_duration)
x_expr = str(base_x)
y_expr = str(base_y)
# ── 入场滑入动画 ──
if anim_dur > 0:
if layer.animation_in == ANIMATION_SLIDE_LEFT:
# 从左侧滑入:x 从 -pip_width 变化到 base_x
x_expr = (
f"'{base_x}+if(lt(t,{anim_dur})," f"{-pip_width}+t/{anim_dur}*({base_x + pip_width})," f"{base_x})'"
)
elif layer.animation_in == ANIMATION_SLIDE_RIGHT:
# 从右侧滑入:x 从 output_width 变化到 base_x
x_expr = (
f"'{base_x}+if(lt(t,{anim_dur}),"
f"{output_width}-t/{anim_dur}*({output_width - base_x}),"
f"{base_x})'"
)
elif layer.animation_in == ANIMATION_SLIDE_TOP:
# 从顶部滑入
y_expr = (
f"'{base_y}+if(lt(t,{anim_dur})," f"{-pip_height}+t/{anim_dur}*({base_y + pip_height})," f"{base_y})'"
)
elif layer.animation_in == ANIMATION_SLIDE_BOTTOM:
# 从底部滑入
y_expr = (
f"'{base_y}+if(lt(t,{anim_dur}),"
f"{output_height}-t/{anim_dur}*({output_height - base_y}),"
f"{base_y})'"
)
# ── 出场滑出动画(需要总时长) ──
if layer.duration is not None and layer.duration > 0 and anim_dur > 0:
out_start = layer.duration - anim_dur
if out_start < 0:
out_start = 0
if layer.animation_out == ANIMATION_SLIDE_LEFT:
# 向左滑出
x_expr = (
f"'{base_x}+if(gt(t,{out_start}),"
f"{base_x}-(t-{out_start})/{anim_dur}*({base_x + pip_width}),"
f"{base_x})'"
)
elif layer.animation_out == ANIMATION_SLIDE_RIGHT:
# 向右滑出
x_expr = (
f"'{base_x}+if(gt(t,{out_start}),"
f"{base_x}+(t-{out_start})/{anim_dur}*({output_width - base_x + pip_width}),"
f"{base_x})'"
)
elif layer.animation_out == ANIMATION_SLIDE_TOP:
# 向上滑出
y_expr = (
f"'{base_y}+if(gt(t,{out_start}),"
f"{base_y}-(t-{out_start})/{anim_dur}*({base_y + pip_height}),"
f"{base_y})'"
)
elif layer.animation_out == ANIMATION_SLIDE_BOTTOM:
# 向下滑出
y_expr = (
f"'{base_y}+if(gt(t,{out_start}),"
f"{base_y}+(t-{out_start})/{anim_dur}*({output_height - base_y + pip_height}),"
f"{base_y})'"
)
return x_expr, y_expr
def build_enable_expr(
layer: PiPLayerConfig,
) -> str:
"""构建 overlay 的 enable 时间控制表达式.
Returns:
enable 表达式片段,如 ":enable='between(t,1,5)'"
无时间限制时返回空字符串
"""
start = max(0.0, layer.start_time)
duration = layer.duration
if start <= 0 and (duration is None or duration <= 0):
return ""
if duration and duration > 0:
end = start + duration
return f":enable='between(t,{start},{end})'"
else:
return f":enable='gte(t,{start})'"
# ── 完整滤镜链 ────────────────────────────────────────────────────────────────
def build_pip_filters(
base_label: str,
layers: list[PiPLayerConfig],
source_paths: list[Path | str],
*,
output_width: int,
output_height: int,
base_input_idx: int = 0,
) -> tuple[list[str], list[str], str]:
"""构建完整的画中画滤镜链和输入参数(纯函数版).
与 PiPEngine.build_pip_filters 对应,但不依赖类实例,
所有参数显式传入,方便测试。
Args:
base_label: 底层视频标签(不带方括号)
layers: 图层配置列表
source_paths: 对应每个图层的源文件路径列表
output_width: 输出视频宽度
output_height: 输出视频高度
base_input_idx: PiP 素材的起始输入索引
Returns:
(filter_parts, input_args, final_label)
- filter_parts: 滤镜片段列表(用 ; 连接成 filter_complex
- input_args: 输入参数列表 ["-i", path, "-i", path, ...]
- final_label: 最终输出标签(不带方括号)
Raises:
ValueError: layers 和 source_paths 长度不一致
"""
if len(layers) != len(source_paths):
raise ValueError(f"layers ({len(layers)}) 和 source_paths ({len(source_paths)}) 长度不一致")
if not layers:
return [], [], base_label
filter_parts: list[str] = []
input_args: list[str] = []
current_label = base_label
for i, (layer, path) in enumerate(zip(layers, source_paths)):
# 计算实际大小
pip_w, pip_h = compute_pip_size(layer, output_width, output_height)
# 添加输入
input_args.extend(["-i", str(path)])
# 实际输入索引
actual_input_idx = base_input_idx + i
# 预处理标签
pre_label = f"pip_pre_{i}"
# 构建预处理滤镜
pre_filter = build_pip_pre_filter(
input_label=f"[{actual_input_idx}:v]",
layer=layer,
pip_width=pip_w,
pip_height=pip_h,
output_label=pre_label,
)
filter_parts.append(pre_filter)
# 计算位置
base_x, base_y = compute_pip_position(layer, pip_w, pip_h, output_width, output_height)
# 构建 overlay 表达式
x_expr, y_expr = build_overlay_expr(layer, base_x, base_y, pip_w, pip_h, output_width, output_height)
# 时间控制
enable_expr = build_enable_expr(layer)
# 合成标签
combined_label = f"pip_combined_{i}"
# overlay 滤镜
overlay_filter = (
f"[{current_label}][{pre_label}]" f"overlay={x_expr}:{y_expr}{enable_expr}" f"[{combined_label}]"
)
filter_parts.append(overlay_filter)
current_label = combined_label
return filter_parts, input_args, current_label
# ── 配置验证 ──────────────────────────────────────────────────────────────────
def validate_pip_layer(layer: PiPLayerConfig) -> tuple[bool, str]:
"""验证单个 PiP 图层配置是否合法.
Returns:
(is_valid, error_message) — 合法时 error_message 为空
"""
errors: list[str] = []
# 源类型检查
if not layer.source_type:
errors.append("source_type 不能为空")
elif layer.source_type not in ("local_path", "asset_id", "url"):
errors.append(f"不支持的 source_type: {layer.source_type}")
if not layer.source:
errors.append("source 不能为空")
# 尺寸检查
if layer.width is None or layer.width == "":
errors.append("width 不能为空")
# 位置检查
valid_positions = {
"top_left",
"top_center",
"top_right",
"center_left",
"center",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
"custom",
}
if layer.position not in valid_positions:
errors.append(f"不支持的 position: {layer.position}")
# 数值范围检查
if layer.opacity < 0.0 or layer.opacity > 1.0:
errors.append(f"opacity 必须在 0-1 之间: {layer.opacity}")
if layer.corner_radius < 0:
errors.append(f"corner_radius 不能为负: {layer.corner_radius}")
if layer.border_width < 0:
errors.append(f"border_width 不能为负: {layer.border_width}")
if layer.animation_duration < 0:
errors.append(f"animation_duration 不能为负: {layer.animation_duration}")
if layer.start_time < 0:
errors.append(f"start_time 不能为负: {layer.start_time}")
if layer.duration is not None and layer.duration < 0:
errors.append(f"duration 不能为负: {layer.duration}")
# 动画类型检查
valid_anims = {
"",
None,
ANIMATION_FADE,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
ANIMATION_SLIDE_BOTTOM,
}
if layer.animation_in and layer.animation_in not in valid_anims:
errors.append(f"不支持的 animation_in: {layer.animation_in}")
if layer.animation_out and layer.animation_out not in valid_anims:
errors.append(f"不支持的 animation_out: {layer.animation_out}")
return (len(errors) == 0, "; ".join(errors))
def count_visible_layers(layers: list[PiPLayerConfig]) -> int:
"""统计可见图层数量(排除完全透明的)."""
count = 0
for layer in layers:
if layer.opacity > 0:
count += 1
return count
def sort_layers_by_z_index(layers: list[PiPLayerConfig]) -> list[PiPLayerConfig]:
"""按 z_index 从小到大排序图层(z_index 小的先画,在底层)."""
return sorted(layers, key=lambda l: l.z_index)
+714
View File
@@ -0,0 +1,714 @@
"""video_filter_builder 单测.
domain 层纯逻辑模块,0 FFmpeg 依赖,快速轻量。
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from packages.domain.video_filter_builder import (
DEFAULT_CLIP_DURATION,
DEFAULT_FPS,
DEFAULT_OUTPUT_HEIGHT,
DEFAULT_OUTPUT_WIDTH,
DEFAULT_TRANSITION_DURATION,
XFADE_TRANSITION_MAP,
ClipFilterChain,
build_clip_filter,
build_concat_filter,
build_filter_complex,
build_xfade_filter,
chain_filters,
has_audio,
)
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def _make_chain(
clip_id: str = "c1",
input_index: int = 0,
duration: float = 3.0,
has_audio: bool = True,
filters: list[str] | None = None,
) -> ClipFilterChain:
"""快速创建 ClipFilterChain."""
if filters is None:
filters = ["scale=1280:720", "fps=25", "trim=0:3"]
return ClipFilterChain(
clip_id=clip_id,
input_index=input_index,
video_label=f"v{input_index}",
audio_label=f"a{input_index}" if has_audio else None,
filters=filters,
duration=duration,
)
def _mock_clip(
clip_id: str = "c1",
duration: float = 5.0,
start_time: float = 0.0,
clip_type: str = "video",
) -> MagicMock:
"""创建 mock 的 EditPlanClip."""
clip = MagicMock()
clip.id = clip_id
clip.duration = duration
clip.start_time = start_time
clip.clip_type = clip_type
return clip
# ═══════════════════════════════════════════════════════════════════════════════
# 常量测试
# ═══════════════════════════════════════════════════════════════════════════════
class TestConstants:
"""常量默认值测试."""
def test_default_resolution(self):
"""默认分辨率为 1280x720."""
assert DEFAULT_OUTPUT_WIDTH == 1280
assert DEFAULT_OUTPUT_HEIGHT == 720
def test_default_fps(self):
"""默认帧率 25."""
assert DEFAULT_FPS == 25
def test_default_transition_duration(self):
"""默认转场时长 0.5s."""
assert DEFAULT_TRANSITION_DURATION == 0.5
def test_default_clip_duration(self):
"""默认片段时长 5s."""
assert DEFAULT_CLIP_DURATION == 5.0
def test_xfade_map_contains_common_transitions(self):
"""xfade 转场映射包含常见类型."""
assert "fade" in XFADE_TRANSITION_MAP.values()
assert "slideleft" in XFADE_TRANSITION_MAP.values()
assert "slideright" in XFADE_TRANSITION_MAP.values()
assert "dissolve" in XFADE_TRANSITION_MAP.values()
assert "wipeleft" in XFADE_TRANSITION_MAP.values()
assert len(XFADE_TRANSITION_MAP) >= 5
# ═══════════════════════════════════════════════════════════════════════════════
# ClipFilterChain 数据类
# ═══════════════════════════════════════════════════════════════════════════════
class TestClipFilterChain:
"""数据类结构测试."""
def test_creation(self):
"""创建 ClipFilterChain."""
chain = ClipFilterChain(
clip_id="c1",
input_index=0,
video_label="v0",
audio_label="a0",
filters=["scale=1280:720"],
duration=5.0,
)
assert chain.clip_id == "c1"
assert chain.input_index == 0
assert chain.video_label == "v0"
assert chain.audio_label == "a0"
assert chain.filters == ["scale=1280:720"]
assert chain.duration == 5.0
def test_no_audio(self):
"""无音频流."""
chain = _make_chain(has_audio=False)
assert chain.audio_label is None
def test_frozen(self):
"""frozen dataclass 不可修改."""
chain = _make_chain()
with pytest.raises(Exception):
chain.duration = 10.0 # type: ignore
# ═══════════════════════════════════════════════════════════════════════════════
# chain_filters
# ═══════════════════════════════════════════════════════════════════════════════
class TestChainFilters:
"""滤镜串联测试."""
def test_single_filter(self):
"""单个滤镜."""
result = chain_filters(["scale=1280:720"], "v0")
assert result == "[0:v]scale=1280:720[v0]"
def test_multiple_filters(self):
"""多个滤镜用逗号连接."""
result = chain_filters(["scale=1280:720", "fps=25", "trim=0:5"], "v0")
assert "scale=1280:720,fps=25,trim=0:5" in result
assert result.startswith("[0:v]")
assert result.endswith("[v0]")
def test_empty_filters(self):
"""空滤镜列表."""
result = chain_filters([], "out")
assert result == "[0:v][out]"
def test_custom_input_label(self):
"""自定义输入标签."""
result = chain_filters(["fps=30"], "v1", input_label="1:v")
assert result.startswith("[1:v]")
assert result.endswith("[v1]")
def test_custom_output_label(self):
"""自定义输出标签."""
result = chain_filters(["scale=640:480"], "my_label")
assert result.endswith("[my_label]")
# ═══════════════════════════════════════════════════════════════════════════════
# has_audio
# ═══════════════════════════════════════════════════════════════════════════════
class TestHasAudio:
"""音频判断测试."""
def test_all_have_audio(self):
"""全部有音频."""
chains = [_make_chain(has_audio=True), _make_chain(has_audio=True)]
assert has_audio(chains) is True
def test_none_have_audio(self):
"""全部无音频."""
chains = [_make_chain(has_audio=False), _make_chain(has_audio=False)]
assert has_audio(chains) is False
def test_partial_audio(self):
"""部分有音频."""
chains = [_make_chain(has_audio=True), _make_chain(has_audio=False)]
assert has_audio(chains) is True
def test_empty_list(self):
"""空列表."""
assert has_audio([]) is False
def test_single_with_audio(self):
"""单个有音频."""
assert has_audio([_make_chain(has_audio=True)]) is True
def test_single_without_audio(self):
"""单个无音频."""
assert has_audio([_make_chain(has_audio=False)]) is False
# ═══════════════════════════════════════════════════════════════════════════════
# build_clip_filter
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildClipFilter:
"""单片段滤镜链构建测试."""
def test_basic_video_clip(self):
"""基础视频片段."""
clip = _mock_clip(duration=5.0, start_time=0.0)
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert chain.clip_id == "c1"
assert chain.input_index == 0
assert chain.video_label == "v0"
assert chain.audio_label == "a0"
assert chain.duration == 5.0
assert len(chain.filters) >= 5
def test_contains_scale_filter(self):
"""包含 scale 滤镜."""
clip = _mock_clip()
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert any("scale=1280:720" in f for f in chain.filters)
def test_contains_pad_filter(self):
"""包含 pad 滤镜(居中黑边)."""
clip = _mock_clip()
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert any("pad=1280:720" in f for f in chain.filters)
def test_contains_format_filter(self):
"""包含 format 滤镜(yuv420p."""
clip = _mock_clip()
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert any("format=yuv420p" in f for f in chain.filters)
def test_contains_fps_filter(self):
"""包含 fps 滤镜."""
clip = _mock_clip()
chain = build_clip_filter(clip, 0, 1280, 720, 30)
assert any("fps=30" in f for f in chain.filters)
def test_zero_fps_skipped(self):
"""fps=0 时跳过 fps 滤镜."""
clip = _mock_clip()
chain = build_clip_filter(clip, 0, 1280, 720, 0)
assert not any(f.startswith("fps=") for f in chain.filters)
def test_negative_fps_skipped(self):
"""负 fps 跳过."""
clip = _mock_clip()
chain = build_clip_filter(clip, 0, 1280, 720, -1)
assert not any(f.startswith("fps=") for f in chain.filters)
def test_start_time_offset(self):
"""有 start_time 时 setpts 带偏移."""
clip = _mock_clip(start_time=2.0)
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert any("PTS-STARTPTS+2.0/TB" in f for f in chain.filters)
def test_zero_start_time_no_offset(self):
"""start_time=0 时无偏移."""
clip = _mock_clip(start_time=0.0)
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert any("setpts=PTS-STARTPTS" in f for f in chain.filters)
# 不含 +N/TB 偏移
setpts_filters = [f for f in chain.filters if f.startswith("setpts=")]
# 第一个 setpts 是重置的(不含偏移),trim 后还有一个
assert len(setpts_filters) >= 1
def test_contains_trim_filter(self):
"""包含 trim 滤镜."""
clip = _mock_clip(duration=5.0)
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert any("trim=0:5.0" in f for f in chain.filters)
def test_negative_duration_uses_default(self):
"""duration<=0 时使用默认时长."""
clip = _mock_clip(duration=0.0)
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert chain.duration == DEFAULT_CLIP_DURATION
assert any(f"trim=0:{DEFAULT_CLIP_DURATION}" in f for f in chain.filters)
def test_title_clip_no_audio(self):
"""title 类型片段无音频."""
clip = _mock_clip(clip_type="title")
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert chain.audio_label is None
def test_subtitle_clip_no_audio(self):
"""subtitle 类型片段无音频."""
clip = _mock_clip(clip_type="subtitle")
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert chain.audio_label is None
def test_video_clip_has_audio(self):
"""video 类型片段有音频."""
clip = _mock_clip(clip_type="video")
chain = build_clip_filter(clip, 0, 1280, 720, 25)
assert chain.audio_label == "a0"
def test_image_clip_has_audio(self):
"""image 类型默认有音频标签(实际无音流由调用方判断)."""
clip = _mock_clip(clip_type="image")
chain = build_clip_filter(clip, 0, 1280, 720, 25)
# 只有 title/subtitle 被排除
assert chain.audio_label is not None
def test_input_index_matches_label(self):
"""input_index 对应标签编号."""
clip = _mock_clip()
chain = build_clip_filter(clip, 3, 1280, 720, 25)
assert chain.input_index == 3
assert chain.video_label == "v3"
assert chain.audio_label == "a3"
def test_filter_order(self):
"""滤镜顺序:scale → pad → format → fps → setpts → trim."""
clip = _mock_clip(duration=5.0, start_time=1.0)
chain = build_clip_filter(clip, 0, 1280, 720, 25)
filter_names = [f.split("=")[0] for f in chain.filters]
# scale 在 pad 前
assert filter_names.index("scale") < filter_names.index("pad")
# pad 在 format 前
assert filter_names.index("pad") < filter_names.index("format")
# format 在 fps 前
assert filter_names.index("format") < filter_names.index("fps")
# ═══════════════════════════════════════════════════════════════════════════════
# build_concat_filter
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildConcatFilter:
"""concat 滤镜构建测试."""
def test_empty_returns_empty(self):
"""空列表返回空."""
result, duration = build_concat_filter([])
assert result == ""
assert duration == 0.0
def test_single_clip(self):
"""单个片段."""
chain = _make_chain(clip_id="c1", input_index=0, duration=3.0)
result, total = build_concat_filter([chain])
assert "[0:v]" in result
assert "concat=n=1:v=1:a=0" in result
assert "[outv]" in result
assert total == 3.0
def test_two_clips(self):
"""两个片段 concat."""
chains = [
_make_chain(clip_id="c1", input_index=0, duration=3.0),
_make_chain(clip_id="c2", input_index=1, duration=2.0),
]
result, total = build_concat_filter(chains)
assert "[0:v]" in result
assert "[1:v]" in result
assert "concat=n=2:v=1:a=0[outv]" in result
assert total == 5.0
def test_three_clips(self):
"""三个片段."""
chains = [
_make_chain(clip_id="c1", input_index=0, duration=2.0),
_make_chain(clip_id="c2", input_index=1, duration=3.0),
_make_chain(clip_id="c3", input_index=2, duration=1.0),
]
result, total = build_concat_filter(chains)
assert "concat=n=3:v=1:a=0[outv]" in result
assert total == 6.0
def test_total_duration_sum(self):
"""总时长 = 各片段时长之和."""
chains = [
_make_chain(duration=1.5),
_make_chain(duration=2.5),
_make_chain(duration=3.0),
]
_, total = build_concat_filter(chains)
assert abs(total - 7.0) < 0.001
def test_audio_concat_with_audio(self):
"""有音频时包含音频 concat."""
chains = [
_make_chain(input_index=0, has_audio=True),
_make_chain(input_index=1, has_audio=True),
]
result, _ = build_concat_filter(chains)
assert "[outa]" in result
assert "concat=n=2:v=0:a=1[outa]" in result
def test_audio_normalization(self):
"""音频经过 aformat 归一化."""
chains = [_make_chain(input_index=0, has_audio=True)]
result, _ = build_concat_filter(chains)
assert "aformat=sample_rates=48000" in result
assert "channel_layouts=stereo" in result
assert "sample_fmts=fltp" in result
def test_no_audio_concat(self):
"""无音频时不生成音频 concat."""
chains = [
_make_chain(input_index=0, has_audio=False),
_make_chain(input_index=1, has_audio=False),
]
result, _ = build_concat_filter(chains)
assert "[outa]" not in result
assert "aformat" not in result
def test_partial_audio_only_includes_audio_chains(self):
"""部分有音频时,只对有音频的片段做 concat."""
chains = [
_make_chain(input_index=0, has_audio=True),
_make_chain(input_index=1, has_audio=False),
_make_chain(input_index=2, has_audio=True),
]
result, _ = build_concat_filter(chains)
# 音频 concat 只有 2 个输入
assert "concat=n=2:v=0:a=1[outa]" in result
def test_video_labels_correct(self):
"""视频标签正确."""
chains = [
_make_chain(clip_id="a", input_index=0, duration=1.0),
_make_chain(clip_id="b", input_index=1, duration=1.0),
]
result, _ = build_concat_filter(chains)
assert "[v0]" in result
assert "[v1]" in result
def test_filter_chain_applied_per_clip(self):
"""每个片段都有独立的滤镜链."""
chains = [
_make_chain(input_index=0, filters=["scale=1280:720", "fps=25"]),
_make_chain(input_index=1, filters=["scale=1280:720", "fps=25"]),
]
result, _ = build_concat_filter(chains)
# 两个片段都有滤镜处理
assert result.count("scale=1280:720") == 2
# ═══════════════════════════════════════════════════════════════════════════════
# build_xfade_filter
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildXfadeFilter:
"""xfade 转场滤镜构建测试."""
def test_empty_returns_empty(self):
"""空列表返回空."""
result, duration = build_xfade_filter([], 0.5, [])
assert result == ""
assert duration == 0.0
def test_single_clip_copy(self):
"""单个片段用 copy 直接输出."""
chain = _make_chain(clip_id="c1", input_index=0, duration=3.0)
result, total = build_xfade_filter([chain], 0.5, [])
assert "copy[outv]" in result
assert total == 3.0
def test_two_clips_fade_transition(self):
"""两个片段 + fade 转场."""
chains = [
_make_chain(input_index=0, duration=3.0),
_make_chain(input_index=1, duration=2.0),
]
result, total = build_xfade_filter(chains, 0.5, ["cut", "fade"])
assert "xfade=transition=fade" in result
assert "duration=0.5" in result
assert "[outv]" in result
# 总时长 = 3 + 2 - 0.5 = 4.5
assert abs(total - 4.5) < 0.001
def test_three_clips_with_transitions(self):
"""三个片段 + 多个转场."""
chains = [
_make_chain(input_index=0, duration=3.0),
_make_chain(input_index=1, duration=2.0),
_make_chain(input_index=2, duration=4.0),
]
result, total = build_xfade_filter(chains, 0.5, ["cut", "fade", "slideleft"])
# 两个 xfade 转场
assert result.count("xfade=") == 2
assert "xf1" in result # 中间标签
# 总时长 = 3+2+4 - 0.5*2 = 8.0
assert abs(total - 8.0) < 0.001
def test_offset_calculation(self):
"""转场 offset 计算正确."""
chains = [
_make_chain(input_index=0, duration=5.0),
_make_chain(input_index=1, duration=3.0),
]
result, _ = build_xfade_filter(chains, 1.0, ["cut", "fade"])
# offset = 5.0 - 1.0*1 = 4.0
assert "offset=4.000" in result
def test_offset_never_negative(self):
"""offset 不为负."""
chains = [
_make_chain(input_index=0, duration=0.3),
_make_chain(input_index=1, duration=0.3),
]
result, _ = build_xfade_filter(chains, 1.0, ["cut", "fade"])
# offset = max(0, 0.3 - 1.0) = 0
assert "offset=0.000" in result
def test_transition_slide_left(self):
"""slideleft 转场."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=2.0),
]
result, _ = build_xfade_filter(chains, 0.5, ["cut", "slide_left"])
assert "xfade=transition=slideleft" in result
def test_transition_slide_right(self):
"""slideright 转场."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=2.0),
]
result, _ = build_xfade_filter(chains, 0.5, ["cut", "slide_right"])
assert "xfade=transition=slideright" in result
def test_transition_dissolve(self):
"""dissolve 转场."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=2.0),
]
result, _ = build_xfade_filter(chains, 0.5, ["cut", "dissolve"])
assert "xfade=transition=dissolve" in result
def test_unknown_transition_defaults_to_fade(self):
"""未知转场默认 fade."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=2.0),
]
result, _ = build_xfade_filter(chains, 0.5, ["cut", "unknown_transition"])
assert "xfade=transition=fade" in result
def test_total_duration_minus_overlap(self):
"""总时长 = sum - transition_duration * (n-1)."""
chains = [
_make_chain(duration=10.0),
_make_chain(duration=10.0),
_make_chain(duration=10.0),
]
_, total = build_xfade_filter(chains, 1.0, ["cut", "fade", "wipe"])
# 30 - 2 = 28
assert abs(total - 28.0) < 0.001
def test_total_duration_never_negative(self):
"""总时长不为负."""
chains = [
_make_chain(duration=0.1),
_make_chain(duration=0.1),
]
_, total = build_xfade_filter(chains, 10.0, ["cut", "fade"])
assert total >= 0.0
def test_audio_with_xfade_path(self):
"""xfade 路径下音频也做 concat."""
chains = [
_make_chain(input_index=0, has_audio=True, duration=2.0),
_make_chain(input_index=1, has_audio=True, duration=3.0),
]
result, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
assert "[outa]" in result
assert "aformat=" in result
def test_single_xfade_no_audio_processing(self):
"""单片段 xfade 路径不处理音频(与原实现一致)."""
chain = _make_chain(input_index=0, has_audio=True, duration=3.0)
result, _ = build_xfade_filter([chain], 0.5, [])
# 单片段 xfade 只有视频 copy,不处理音频
assert "copy[outv]" in result
assert "[outa]" not in result
assert "acopy" not in result
def test_no_audio_xfade(self):
"""无音频时不生成 [outa]."""
chains = [
_make_chain(input_index=0, has_audio=False, duration=2.0),
_make_chain(input_index=1, has_audio=False, duration=2.0),
]
result, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
assert "[outa]" not in result
def test_intermediate_labels(self):
"""多片段时有中间 xf 标签."""
chains = [
_make_chain(input_index=0, duration=1.0),
_make_chain(input_index=1, duration=1.0),
_make_chain(input_index=2, duration=1.0),
_make_chain(input_index=3, duration=1.0),
]
result, _ = build_xfade_filter(chains, 0.3, ["cut", "fade", "wipe", "dissolve"])
assert "[xf1]" in result
assert "[xf2]" in result
assert "[outv]" in result
def test_transitions_shorter_than_clips(self):
"""transitions 列表比片段短时,后续用默认值."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=2.0),
_make_chain(input_index=2, duration=2.0),
]
# 只给一个转场(索引1有效,索引2越界)
result, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
# 第2个转场(索引2)未知 → 默认 fade
assert result.count("xfade=transition=fade") == 2
# ═══════════════════════════════════════════════════════════════════════════════
# build_filter_complex
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildFilterComplex:
"""完整 filter_complex 构建(策略选择)测试."""
def test_empty_returns_empty(self):
"""空列表返回空."""
result, duration = build_filter_complex([], 1280, 720, 0.5, [])
assert result == ""
assert duration == 0.0
def test_single_clip_chain_mode(self):
"""单片段走单链模式."""
chain = _make_chain(input_index=0, duration=3.0, has_audio=True)
result, total = build_filter_complex([chain], 1280, 720, 0.5, [])
assert "[0:v]" in result
assert "[0:a]" in result
assert total == 3.0
def test_single_clip_no_audio(self):
"""单片段无音频."""
chain = _make_chain(input_index=0, duration=3.0, has_audio=False)
result, _ = build_filter_complex([chain], 1280, 720, 0.5, [])
assert "[0:a]" not in result
def test_multiple_clips_all_cut_uses_concat(self):
"""多片段 + 全 cut → 走 concat(高效模式)."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=3.0),
]
result, total = build_filter_complex(chains, 1280, 720, 0.5, ["cut", "cut"])
# concat 模式
assert "concat=n=2:v=1:a=0" in result
assert "xfade" not in result
assert total == 5.0
def test_multiple_clips_with_transition_uses_xfade(self):
"""多片段 + 有转场 → 走 xfade."""
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=3.0),
]
result, total = build_filter_complex(chains, 1280, 720, 0.5, ["cut", "fade"])
assert "xfade=" in result
assert abs(total - 4.5) < 0.001
def test_transition_effect_enum_value(self):
"""使用 TransitionEffect 枚举值也能正确判断."""
from packages.domain.template_clip_config import TransitionEffect
chains = [
_make_chain(input_index=0, duration=2.0),
_make_chain(input_index=1, duration=2.0),
]
# 传 TransitionEffect.CUT(不是字符串 "cut"
result, _ = build_filter_complex(
chains,
1280,
720,
0.5,
[TransitionEffect.CUT, TransitionEffect.CUT],
)
# 都是 cut → 走 concat
assert "concat=n=2:v=1:a=0" in result
def test_mixed_cut_and_transition(self):
"""混合 cut 和转场 → 走 xfade."""
chains = [
_make_chain(input_index=0, duration=1.0),
_make_chain(input_index=1, duration=1.0),
_make_chain(input_index=2, duration=1.0),
]
result, total = build_filter_complex(chains, 1280, 720, 0.5, ["cut", "fade", "cut"])
# 只要有一个非 cut 转场就走 xfade
assert "xfade=" in result
assert abs(total - 2.0) < 0.001
+966
View File
@@ -0,0 +1,966 @@
"""PiP Engine 纯逻辑单测.
测试 pip_engine_pure.py 中的所有纯函数,
0 FFmpeg 依赖,快速轻量。
"""
from __future__ import annotations
from pathlib import Path
import pytest
from video_processing.pip_engine_pure import (
build_animation_filters,
build_enable_expr,
build_overlay_expr,
build_pip_filters,
build_pip_pre_filter,
compute_pip_position,
compute_pip_size,
count_visible_layers,
sort_layers_by_z_index,
validate_pip_layer,
)
from packages.domain.pip_config import (
ANIMATION_FADE,
ANIMATION_SLIDE_BOTTOM,
ANIMATION_SLIDE_LEFT,
ANIMATION_SLIDE_RIGHT,
ANIMATION_SLIDE_TOP,
PiPLayerConfig,
)
# ── 常量与工具 ────────────────────────────────────────────────────────────────
OUTPUT_W = 1080
OUTPUT_H = 1920
def _make_layer(**kwargs) -> PiPLayerConfig:
"""快速创建图层配置."""
defaults = dict(
source_type="local_path",
source="/tmp/test.mp4",
width="25%",
height=None,
position="bottom_right",
margin=20,
opacity=1.0,
corner_radius=0,
border_width=0,
border_color="black",
z_index=0,
start_time=0.0,
duration=None,
animation_in=None,
animation_out=None,
animation_duration=0.5,
)
defaults.update(kwargs)
return PiPLayerConfig(**defaults)
# ═══════════════════════════════════════════════════════════════════════════════
# compute_pip_size
# ═══════════════════════════════════════════════════════════════════════════════
class TestComputePipSize:
"""尺寸计算测试."""
def test_percentage_width_auto_height(self):
"""百分比宽度,自动高度(16:9."""
layer = _make_layer(width="25%")
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
assert w == 270 # 1080 * 25%
assert h == 151 # 270 * 9 / 16 = 151.875 → 151
def test_pixel_width_and_height(self):
"""像素宽高."""
layer = _make_layer(width=300, height=200)
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
assert w == 300
assert h == 200
def test_pixel_width_percent_height(self):
"""像素宽 + 百分比高."""
layer = _make_layer(width=200, height="10%")
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
assert w == 200
assert h == 192 # 1920 * 10%
def test_full_width_clamped(self):
"""超过输出尺寸时钳制到输出范围内."""
layer = _make_layer(width="200%")
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
assert w == OUTPUT_W
assert h <= OUTPUT_H # 按比例后高度不超过输出
def test_zero_width_minimum(self):
"""极小尺寸钳制到至少 1 像素."""
layer = _make_layer(width="0%")
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
assert w >= 1
assert h >= 1
def test_pixel_int_width(self):
"""整数像素宽度."""
layer = _make_layer(width=500, height=300)
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
assert w == 500
assert h == 300
# ═══════════════════════════════════════════════════════════════════════════════
# compute_pip_position
# ═══════════════════════════════════════════════════════════════════════════════
class TestComputePipPosition:
"""位置计算测试."""
def test_bottom_right(self):
"""右下角位置."""
layer = _make_layer(position="bottom_right", margin=20)
pip_w, pip_h = 200, 150
x, y = compute_pip_position(layer, pip_w, pip_h, OUTPUT_W, OUTPUT_H)
assert x == OUTPUT_W - pip_w - 20
assert y == OUTPUT_H - pip_h - 20
def test_top_left(self):
"""左上角."""
layer = _make_layer(position="top_left", margin=10)
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
assert x == 10
assert y == 10
def test_top_center(self):
"""顶部居中."""
layer = _make_layer(position="top_center", margin=20)
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
assert x == (OUTPUT_W - 200) // 2
assert y == 20
def test_center(self):
"""正中心."""
layer = _make_layer(position="center")
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
assert x == (OUTPUT_W - 200) // 2
assert y == (OUTPUT_H - 150) // 2
def test_custom_position(self):
"""自定义坐标."""
layer = _make_layer(position="custom", x=100, y=200)
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
assert x == 100
assert y == 200
def test_margin_effect(self):
"""不同 margin 值影响位置."""
layer1 = _make_layer(position="bottom_right", margin=0)
layer2 = _make_layer(position="bottom_right", margin=50)
x1, y1 = compute_pip_position(layer1, 200, 150, OUTPUT_W, OUTPUT_H)
x2, y2 = compute_pip_position(layer2, 200, 150, OUTPUT_W, OUTPUT_H)
assert x1 > x2
assert y1 > y2
def test_clamped_when_outside(self):
"""自定义坐标超出画面时钳制到边界内."""
layer = _make_layer(position="custom", x=-50, y=99999)
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
assert x >= 0
assert x <= OUTPUT_W - 200
assert y >= 0
assert y == OUTPUT_H - 150 # y 超出底部,钳制到底部
def test_bottom_center(self):
"""底部居中."""
layer = _make_layer(position="bottom_center", margin=30)
x, y = compute_pip_position(layer, 300, 200, OUTPUT_W, OUTPUT_H)
assert x == (OUTPUT_W - 300) // 2
assert y == OUTPUT_H - 200 - 30
def test_center_left(self):
"""左侧居中."""
layer = _make_layer(position="center_left", margin=15)
x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == 15
assert y == (OUTPUT_H - 100) // 2
def test_center_right(self):
"""右侧居中."""
layer = _make_layer(position="center_right", margin=15)
x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == OUTPUT_W - 150 - 15
assert y == (OUTPUT_H - 100) // 2
# ═══════════════════════════════════════════════════════════════════════════════
# build_pip_pre_filter
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildPipPreFilter:
"""预处理滤镜构建测试."""
def test_basic_scale_setsar(self):
"""基础:scale + setsar."""
layer = _make_layer()
result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0")
assert result.startswith("[1:v]")
assert "scale=200:150" in result
assert "setsar=1" in result
assert result.endswith("[pip_pre_0]")
def test_corner_radius_filter(self):
"""圆角裁剪滤镜."""
layer = _make_layer(corner_radius=20)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "geq=" in result
assert "format=yuva420p" in result
# 圆角半径应钳制到 min(r, w//2, h//2)
assert "hypot(" in result
def test_corner_radius_clamped(self):
"""圆角半径超过尺寸一半时自动钳制."""
layer = _make_layer(corner_radius=1000) # 超大
result = build_pip_pre_filter("[0:v]", layer, 100, 80, "pre")
# 钳制后 r = min(1000, 50, 40) = 40
# 检查 geq 表达式中的 r 值
import re
r_matches = re.findall(r"lt\(X,(\d+)\)\*lt\(Y,\1\)", result)
assert r_matches
assert int(r_matches[0]) <= 50 # 不超过宽的一半
def test_border_filter(self):
"""边框滤镜."""
layer = _make_layer(border_width=5, border_color="red")
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "pad=210:160:5:5:red" in result
def test_zero_border_no_pad(self):
"""border_width=0 时不加 pad."""
layer = _make_layer(border_width=0)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "pad=" not in result
def test_opacity_filter(self):
"""透明度滤镜."""
layer = _make_layer(opacity=0.5)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "colorchannelmixer=aa=0.5" in result
assert "format=yuva420p" in result
def test_full_opacity_no_alpha(self):
"""opacity=1.0 时不加透明度滤镜."""
layer = _make_layer(opacity=1.0)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "colorchannelmixer" not in result
def test_opacity_clamped_high(self):
"""opacity > 1.0 时钳制."""
layer = _make_layer(opacity=2.0)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
# 钳制到 1.0,不加透明度滤镜
assert "colorchannelmixer=aa=1" not in result
assert "colorchannelmixer" not in result
def test_opacity_clamped_low(self):
"""opacity < 0 时钳制到 0."""
layer = _make_layer(opacity=-0.5)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "colorchannelmixer=aa=0.0" in result
def test_fade_in_animation(self):
"""淡入动画."""
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.3)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "fade=t=in:st=0:d=0.3:alpha=1" in result
def test_fade_out_animation(self):
"""淡出动画(需要 duration."""
layer = _make_layer(
animation_out=ANIMATION_FADE,
animation_duration=0.5,
duration=5.0,
)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "fade=t=out:st=4.5:d=0.5:alpha=1" in result
def test_fade_out_no_duration(self):
"""淡出无 duration 时不加."""
layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5, duration=None)
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
assert "fade=t=out" not in result
def test_combined_effects(self):
"""多个效果组合:圆角 + 边框 + 透明度."""
layer = _make_layer(
corner_radius=15,
border_width=3,
border_color="white",
opacity=0.8,
)
result = build_pip_pre_filter("[0:v]", layer, 300, 200, "pre")
assert "geq=" in result # 圆角
assert "pad=306:206:3:3:white" in result # 边框
assert "colorchannelmixer=aa=0.8" in result # 透明度
def test_output_label(self):
"""输出标签正确."""
layer = _make_layer()
result = build_pip_pre_filter("[2:v]", layer, 100, 80, "my_label")
assert result.endswith("[my_label]")
def test_input_label(self):
"""输入标签正确."""
layer = _make_layer()
result = build_pip_pre_filter("[5:v]", layer, 100, 80, "out")
assert result.startswith("[5:v]")
# ═══════════════════════════════════════════════════════════════════════════════
# build_animation_filters
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildAnimationFilters:
"""动画滤镜构建测试."""
def test_no_animation(self):
"""无动画返回空列表."""
layer = _make_layer()
result = build_animation_filters(layer, 200, 150)
assert result == []
def test_fade_in_only(self):
"""仅淡入."""
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.5)
result = build_animation_filters(layer, 200, 150)
assert len(result) == 1
assert "fade=t=in" in result[0]
def test_fade_out_with_duration(self):
"""淡出(有 duration."""
layer = _make_layer(
animation_out=ANIMATION_FADE,
animation_duration=0.3,
duration=10.0,
)
result = build_animation_filters(layer, 200, 150)
assert len(result) == 1
assert "fade=t=out:st=9.7:d=0.3" in result[0]
def test_fade_out_no_duration_skipped(self):
"""淡出无 duration 时跳过."""
layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5)
result = build_animation_filters(layer, 200, 150)
assert result == []
def test_fade_in_and_out(self):
"""淡入 + 淡出."""
layer = _make_layer(
animation_in=ANIMATION_FADE,
animation_out=ANIMATION_FADE,
animation_duration=0.5,
duration=3.0,
)
result = build_animation_filters(layer, 200, 150)
assert len(result) == 2
assert any("fade=t=in" in f for f in result)
assert any("fade=t=out" in f for f in result)
def test_slide_in_not_here(self):
"""slide 动画不在此函数处理."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
result = build_animation_filters(layer, 200, 150)
assert result == []
def test_zero_duration_no_animation(self):
"""动画时长为 0 时不加."""
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0)
result = build_animation_filters(layer, 200, 150)
assert result == []
def test_negative_duration_clamped(self):
"""负动画时长钳制为 0."""
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=-1)
result = build_animation_filters(layer, 200, 150)
assert result == []
def test_fade_out_start_clamped_to_zero(self):
"""淡出开始时间不为负."""
layer = _make_layer(
animation_out=ANIMATION_FADE,
animation_duration=2.0,
duration=1.0, # 比动画时长短
)
result = build_animation_filters(layer, 200, 150)
assert len(result) == 1
# start = max(0, 1.0 - 2.0) = 0
assert "st=0.0:d=2.0" in result[0]
# ═══════════════════════════════════════════════════════════════════════════════
# build_overlay_expr
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildOverlayExpr:
"""overlay 表达式构建测试."""
def test_no_animation_static_position(self):
"""无动画时返回静态坐标."""
layer = _make_layer()
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert y == "200"
def test_slide_in_from_left(self):
"""从左侧滑入."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert "if(lt(t,0.5)" in x
assert "-150" in x # 起始位置 = -pip_width
assert y == "200" # y 不变
def test_slide_in_from_right(self):
"""从右侧滑入."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_RIGHT, animation_duration=0.5)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert str(OUTPUT_W) in x
assert y == "200"
def test_slide_in_from_top(self):
"""从顶部滑入."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_TOP, animation_duration=0.3)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert "if(lt(t,0.3)" in y
assert "-100" in y
def test_slide_in_from_bottom(self):
"""从底部滑入."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_BOTTOM, animation_duration=0.3)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert str(OUTPUT_H) in y
def test_slide_out_to_left(self):
"""向左滑出."""
layer = _make_layer(
animation_out=ANIMATION_SLIDE_LEFT,
animation_duration=0.5,
duration=3.0,
)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert "gt(t,2.5)" in x
assert y == "200"
def test_slide_out_to_right(self):
"""向右滑出."""
layer = _make_layer(
animation_out=ANIMATION_SLIDE_RIGHT,
animation_duration=0.5,
duration=3.0,
)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert "gt(t,2.5)" in x
assert y == "200"
# 向右滑出:结束时 x > base_x(值变大)
# 检查表达式中含增大方向的计算
assert "+(t-2.5)/0.5*" in x
def test_slide_out_to_top(self):
"""向上滑出."""
layer = _make_layer(
animation_out=ANIMATION_SLIDE_TOP,
animation_duration=0.5,
duration=5.0,
)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert "gt(t,4.5)" in y
def test_slide_out_to_bottom(self):
"""向下滑出."""
layer = _make_layer(
animation_out=ANIMATION_SLIDE_BOTTOM,
animation_duration=0.5,
duration=5.0,
)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert "gt(t,4.5)" in y
# 向下滑出:y 值增大
assert "+(t-4.5)/0.5*" in y
def test_slide_in_and_out_different_axes(self):
"""滑入(x方向) + 滑出(y方向),两个轴都有动画."""
layer = _make_layer(
animation_in=ANIMATION_SLIDE_LEFT,
animation_out=ANIMATION_SLIDE_BOTTOM,
animation_duration=0.5,
duration=4.0,
)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert "lt(t,0.5)" in x # x 方向入场
assert "gt(t,3.5)" in y # y 方向出场
def test_zero_animation_duration_no_effect(self):
"""动画时长为 0 时无效果."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert y == "200"
def test_no_duration_skip_outro(self):
"""无 duration 时跳过滑出."""
layer = _make_layer(
animation_out=ANIMATION_SLIDE_LEFT,
animation_duration=0.5,
duration=None,
)
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x == "100"
assert y == "200"
def test_expression_format_quoted(self):
"""有动画时表达式带单引号."""
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
x, _ = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
assert x.startswith("'")
assert x.endswith("'")
def test_static_position_unquoted(self):
"""无动画时纯数字,不带引号."""
layer = _make_layer()
x, y = build_overlay_expr(layer, 50, 60, 100, 80, OUTPUT_W, OUTPUT_H)
assert x == "50"
assert y == "60"
assert "'" not in x
assert "'" not in y
# ═══════════════════════════════════════════════════════════════════════════════
# build_enable_expr
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildEnableExpr:
"""enable 表达式构建测试."""
def test_no_time_restriction(self):
"""无时间限制返回空."""
layer = _make_layer()
assert build_enable_expr(layer) == ""
def test_start_time_only(self):
"""只有开始时间."""
layer = _make_layer(start_time=5.0)
result = build_enable_expr(layer)
assert result == ":enable='gte(t,5.0)'"
def test_duration_only(self):
"""只有 duration(从 0 开始)."""
layer = _make_layer(duration=10.0)
result = build_enable_expr(layer)
assert result == ":enable='between(t,0.0,10.0)'"
def test_start_and_duration(self):
"""开始时间 + 时长."""
layer = _make_layer(start_time=2.0, duration=5.0)
result = build_enable_expr(layer)
assert "between(t,2.0,7.0)" in result
def test_zero_start_with_duration(self):
"""0 开始 + 时长."""
layer = _make_layer(start_time=0, duration=3.5)
result = build_enable_expr(layer)
assert "between(t,0.0,3.5)" in result
def test_negative_start_clamped(self):
"""负开始时间钳制为 0."""
layer = _make_layer(start_time=-1.0, duration=5.0)
result = build_enable_expr(layer)
assert "between(t,0.0,5.0)" in result
def test_none_duration(self):
"""duration=None 视为无限."""
layer = _make_layer(start_time=3.0, duration=None)
result = build_enable_expr(layer)
assert "gte(t,3.0)" in result
assert "between" not in result
# ═══════════════════════════════════════════════════════════════════════════════
# build_pip_filters
# ═══════════════════════════════════════════════════════════════════════════════
class TestBuildPipFilters:
"""完整滤镜链构建测试."""
def test_empty_layers(self):
"""空图层列表返回空."""
filters, inputs, label = build_pip_filters(
"base",
[],
[],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
assert filters == []
assert inputs == []
assert label == "base"
def test_single_layer(self):
"""单个图层."""
layer = _make_layer(width="20%", position="bottom_right")
path = Path("/tmp/clip1.mp4")
filters, inputs, label = build_pip_filters(
"v0",
[layer],
[path],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
# 2 个滤镜片段:预处理 + overlay
assert len(filters) == 2
# 1 个输入
assert inputs == ["-i", str(path)]
# 最终标签
assert label == "pip_combined_0"
def test_multiple_layers(self):
"""多个图层."""
layers = [
_make_layer(width="30%", position="bottom_left"),
_make_layer(width="25%", position="top_right"),
_make_layer(width="20%", position="top_left"),
]
paths = [Path("/tmp/a.mp4"), Path("/tmp/b.mp4"), Path("/tmp/c.mp4")]
filters, inputs, label = build_pip_filters(
"base",
layers,
paths,
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
# 每个图层 2 个滤镜(预处理 + overlay
assert len(filters) == 6
# 3 个输入
assert len(inputs) == 6 # -i path × 3
assert inputs[0::2] == ["-i", "-i", "-i"]
# 最终标签是最后一个 combined
assert label == "pip_combined_2"
def test_base_input_idx_offset(self):
"""base_input_idx 偏移."""
layer = _make_layer(width="20%")
filters, inputs, label = build_pip_filters(
"base",
[layer],
[Path("/tmp/x.mp4")],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
base_input_idx=5,
)
# 预处理滤镜引用 [5:v]
assert "[5:v]" in filters[0]
def test_layer_count_mismatch_raises(self):
"""图层和路径数量不一致时报错."""
with pytest.raises(ValueError, match="长度不一致"):
build_pip_filters(
"base",
[_make_layer()],
[],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
def test_filter_chaining(self):
"""多图层时滤镜链正确串联."""
layers = [_make_layer(width="10%"), _make_layer(width="10%")]
paths = [Path("/tmp/1.mp4"), Path("/tmp/2.mp4")]
filters, _, _ = build_pip_filters(
"base",
layers,
paths,
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
# 第一个 overlay 的输入是 base + pip_pre_0
# 输出是 pip_combined_0
assert "[base]" in filters[1]
assert "[pip_combined_0]" in filters[1]
# 第二个 overlay 的输入是 pip_combined_0 + pip_pre_1
# 输出是 pip_combined_1
assert "[pip_combined_0]" in filters[3]
assert "[pip_combined_1]" in filters[3]
def test_with_animation_layer(self):
"""带动画的图层生成正确表达式."""
layer = _make_layer(
width="30%",
animation_in=ANIMATION_SLIDE_BOTTOM,
animation_duration=0.5,
)
filters, inputs, _ = build_pip_filters(
"v0",
[layer],
[Path("/tmp/a.mp4")],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
# overlay 滤镜中包含滑动表达式
overlay_filter = filters[1]
assert "overlay=" in overlay_filter
assert str(OUTPUT_H) in overlay_filter # 从底部滑入
def test_with_enable_time(self):
"""带时间控制的图层."""
layer = _make_layer(width="20%", start_time=2.0, duration=5.0)
filters, _, _ = build_pip_filters(
"v0",
[layer],
[Path("/tmp/a.mp4")],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
overlay_filter = filters[1]
assert "enable=" in overlay_filter
assert "between" in overlay_filter
def test_string_paths(self):
"""路径可以是字符串."""
layer = _make_layer(width="10%")
filters, inputs, label = build_pip_filters(
"v0",
[layer],
["/tmp/s.mp4"],
output_width=OUTPUT_W,
output_height=OUTPUT_H,
)
assert inputs == ["-i", "/tmp/s.mp4"]
assert len(filters) == 2
# ═══════════════════════════════════════════════════════════════════════════════
# validate_pip_layer
# ═══════════════════════════════════════════════════════════════════════════════
class TestValidatePipLayer:
"""配置验证测试."""
def test_valid_layer(self):
"""合法配置."""
layer = _make_layer()
ok, err = validate_pip_layer(layer)
assert ok is True
assert err == ""
def test_empty_source_type(self):
"""空 source_type."""
layer = _make_layer(source_type="")
ok, err = validate_pip_layer(layer)
assert ok is False
assert "source_type" in err
def test_invalid_source_type(self):
"""不支持的 source_type."""
layer = _make_layer(source_type="ftp")
ok, err = validate_pip_layer(layer)
assert ok is False
assert "source_type" in err
def test_empty_source(self):
"""空 source."""
layer = _make_layer(source="")
ok, err = validate_pip_layer(layer)
assert ok is False
assert "source" in err
def test_invalid_position(self):
"""不支持的 position."""
layer = _make_layer(position="middle")
ok, err = validate_pip_layer(layer)
assert ok is False
assert "position" in err
def test_opacity_too_high(self):
"""opacity > 1."""
layer = _make_layer(opacity=1.5)
ok, err = validate_pip_layer(layer)
assert ok is False
assert "opacity" in err
def test_opacity_negative(self):
"""opacity < 0."""
layer = _make_layer(opacity=-0.1)
ok, err = validate_pip_layer(layer)
assert ok is False
assert "opacity" in err
def test_negative_corner_radius(self):
"""负圆角."""
layer = _make_layer(corner_radius=-5)
ok, err = validate_pip_layer(layer)
assert ok is False
assert "corner_radius" in err
def test_negative_border_width(self):
"""负边框."""
layer = _make_layer(border_width=-2)
ok, err = validate_pip_layer(layer)
assert ok is False
assert "border_width" in err
def test_negative_start_time(self):
"""负开始时间."""
layer = _make_layer(start_time=-1.0)
ok, err = validate_pip_layer(layer)
assert ok is False
assert "start_time" in err
def test_negative_duration(self):
"""负时长."""
layer = _make_layer(duration=-5.0)
ok, err = validate_pip_layer(layer)
assert ok is False
assert "duration" in err
def test_invalid_animation_in(self):
"""不支持的入场动画."""
layer = _make_layer(animation_in="zoom")
ok, err = validate_pip_layer(layer)
assert ok is False
assert "animation_in" in err
def test_invalid_animation_out(self):
"""不支持的出场动画."""
layer = _make_layer(animation_out="spin")
ok, err = validate_pip_layer(layer)
assert ok is False
assert "animation_out" in err
def test_multiple_errors_combined(self):
"""多个错误合并."""
layer = _make_layer(source_type="", source="", opacity=2.0, position="xxx")
ok, err = validate_pip_layer(layer)
assert ok is False
assert err.count(";") >= 2 # 至少 2 个错误
def test_valid_url_source(self):
"""URL 类型 source 合法."""
layer = _make_layer(source_type="url", source="https://example.com/v.mp4")
ok, err = validate_pip_layer(layer)
assert ok is True
def test_valid_asset_id(self):
"""asset_id 类型合法."""
layer = _make_layer(source_type="asset_id", source="asset_123")
ok, err = validate_pip_layer(layer)
assert ok is True
def test_zero_values_valid(self):
"""0 值合法(不是负数)."""
layer = _make_layer(
corner_radius=0,
border_width=0,
start_time=0,
animation_duration=0,
)
ok, err = validate_pip_layer(layer)
assert ok is True
# ═══════════════════════════════════════════════════════════════════════════════
# count_visible_layers
# ═══════════════════════════════════════════════════════════════════════════════
class TestCountVisibleLayers:
"""可见图层统计测试."""
def test_all_visible(self):
"""全部可见."""
layers = [_make_layer(opacity=1.0), _make_layer(opacity=0.5)]
assert count_visible_layers(layers) == 2
def test_all_invisible(self):
"""全部不可见."""
layers = [_make_layer(opacity=0.0), _make_layer(opacity=0.0)]
assert count_visible_layers(layers) == 0
def test_mixed(self):
"""混合."""
layers = [
_make_layer(opacity=1.0),
_make_layer(opacity=0.0),
_make_layer(opacity=0.001),
]
assert count_visible_layers(layers) == 2
def test_empty_list(self):
"""空列表."""
assert count_visible_layers([]) == 0
# ═══════════════════════════════════════════════════════════════════════════════
# sort_layers_by_z_index
# ═══════════════════════════════════════════════════════════════════════════════
class TestSortLayersByZIndex:
"""图层排序测试."""
def test_sorted_by_z_index(self):
"""按 z_index 从小到大排序."""
layers = [
_make_layer(z_index=5, source="/tmp/a.mp4"),
_make_layer(z_index=1, source="/tmp/b.mp4"),
_make_layer(z_index=3, source="/tmp/c.mp4"),
]
sorted_layers = sort_layers_by_z_index(layers)
assert [l.z_index for l in sorted_layers] == [1, 3, 5]
def test_same_z_index_stable(self):
"""相同 z_index 保持相对顺序."""
layers = [
_make_layer(z_index=2, source="/tmp/1.mp4"),
_make_layer(z_index=2, source="/tmp/2.mp4"),
]
sorted_layers = sort_layers_by_z_index(layers)
assert sorted_layers[0].source == "/tmp/1.mp4"
assert sorted_layers[1].source == "/tmp/2.mp4"
def test_empty_list(self):
"""空列表."""
assert sort_layers_by_z_index([]) == []
def test_single_layer(self):
"""单个图层."""
layers = [_make_layer(z_index=0)]
assert len(sort_layers_by_z_index(layers)) == 1
def test_negative_z_index(self):
"""负 z_index."""
layers = [
_make_layer(z_index=0, source="/tmp/0.mp4"),
_make_layer(z_index=-5, source="/tmp/-5.mp4"),
_make_layer(z_index=3, source="/tmp/3.mp4"),
]
sorted_layers = sort_layers_by_z_index(layers)
assert [l.z_index for l in sorted_layers] == [-5, 0, 3]
Regular → Executable
+660 -294
View File
File diff suppressed because it is too large Load Diff