Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4765d83c8b | |||
| a6e147ed30 | |||
| 0d46d71b2d |
Regular → Executable
+35
-208
@@ -1,45 +1,12 @@
|
||||
import React, { useRef } from "react"
|
||||
import {
|
||||
AudioOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CheckOutlined,
|
||||
SoundOutlined,
|
||||
MutedOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Tooltip } from "antd"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
import { type VoiceMaterial } from "../types"
|
||||
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../constants"
|
||||
import {
|
||||
genderClass,
|
||||
genderIcon,
|
||||
genderLabel,
|
||||
formatDuration,
|
||||
formatFileSize,
|
||||
formatDate,
|
||||
} from "../utils/format"
|
||||
|
||||
export interface VoiceCardProps {
|
||||
material: VoiceMaterial
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
isSelected: boolean
|
||||
batchMode: boolean
|
||||
volume: number
|
||||
tagMap: Map<string, TagItem>
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onSeek: (time: number) => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onToggleSelect: (id: string) => void
|
||||
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onToggleMute: () => void
|
||||
}
|
||||
import React from "react"
|
||||
import { type VoiceCardProps } from "./voice-material-card/types"
|
||||
import BatchCheckbox from "./voice-material-card/BatchCheckbox"
|
||||
import CardActions from "./voice-material-card/CardActions"
|
||||
import CardHeader from "./voice-material-card/CardHeader"
|
||||
import CardTags from "./voice-material-card/CardTags"
|
||||
import CardMeta from "./voice-material-card/CardMeta"
|
||||
import CardPlayer from "./voice-material-card/CardPlayer"
|
||||
import { genderClass } from "../utils/format"
|
||||
|
||||
const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
|
||||
material,
|
||||
@@ -58,29 +25,6 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
|
||||
onVolumeChange,
|
||||
onToggleMute,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
e.preventDefault()
|
||||
const doSeek = (ev: MouseEvent) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||
onSeek(percent * material.duration)
|
||||
}
|
||||
doSeek(e.nativeEvent)
|
||||
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
||||
const handleUp = () => {
|
||||
document.removeEventListener("mousemove", handleMove)
|
||||
document.removeEventListener("mouseup", handleUp)
|
||||
}
|
||||
document.addEventListener("mousemove", handleMove)
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
}
|
||||
|
||||
const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (batchMode) {
|
||||
onToggleSelect(material.id)
|
||||
@@ -92,154 +36,37 @@ const VoiceMaterialCard: React.FC<VoiceCardProps> = ({
|
||||
className={`vmat-card ${genderClass(material.gender)}${isPlaying ? " playing" : ""}${isSelected ? " selected" : ""}${batchMode ? " batch-mode" : ""}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* 批量选择 checkbox */}
|
||||
{(batchMode || isSelected) && (
|
||||
<div
|
||||
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(material.id)
|
||||
}}
|
||||
>
|
||||
{isSelected && <CheckOutlined />}
|
||||
</div>
|
||||
)}
|
||||
<BatchCheckbox
|
||||
isSelected={isSelected}
|
||||
visible={batchMode || isSelected}
|
||||
onToggle={() => onToggleSelect(material.id)}
|
||||
/>
|
||||
<CardActions onEdit={onEdit} onDelete={onDelete} />
|
||||
<CardHeader material={material} />
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="vmat-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-card-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
title="编辑"
|
||||
>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-card-action-btn vmat-card-action-btn--danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 头部:图标 + 名称 + 性别 */}
|
||||
<div className="vmat-card-header">
|
||||
<div className="vmat-card-avatar">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div className="vmat-card-title-area">
|
||||
<h4 className="vmat-card-name" title={material.name}>
|
||||
{material.name}
|
||||
</h4>
|
||||
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
|
||||
{genderIcon(material.gender)}
|
||||
{genderLabel(material.gender)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
{material.description && <p className="vmat-card-desc">{material.description}</p>}
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="vmat-card-tags">
|
||||
{material.tagIds.length === 0 ? (
|
||||
<span
|
||||
className="vmat-tag-empty"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
添加标签
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{material.tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
|
||||
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
|
||||
{tagMap.get(tagId)?.name ?? tagId}
|
||||
</Tag>
|
||||
))}
|
||||
{material.tagIds.length > MAX_CARD_TAGS && (
|
||||
<Tooltip
|
||||
title={material.tagIds
|
||||
.slice(MAX_CARD_TAGS)
|
||||
.map((id) => tagMap.get(id)?.name ?? id)
|
||||
.join("、")}
|
||||
>
|
||||
<Tag className="vmat-tag-overflow">+{material.tagIds.length - MAX_CARD_TAGS}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 元信息 */}
|
||||
<div className="vmat-card-meta">
|
||||
<span>{formatDuration(material.duration)}</span>
|
||||
<span>{formatFileSize(material.fileSize)}</span>
|
||||
<span>{formatDate(material.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* 播放控制 */}
|
||||
<div className="vmat-card-player">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-play-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
disabled={!material.fileUrl}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
|
||||
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
|
||||
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
|
||||
</div>
|
||||
<span className="vmat-time">
|
||||
{isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)}
|
||||
</span>
|
||||
{/* 音量控制 */}
|
||||
<div className="vmat-volume">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-volume-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleMute()
|
||||
}}
|
||||
title={volume === 0 ? "取消静音" : "静音"}
|
||||
>
|
||||
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="vmat-volume-slider"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
onVolumeChange(e)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CardTags tagIds={material.tagIds} tagMap={tagMap} onEdit={onEdit} />
|
||||
<CardMeta
|
||||
duration={material.duration}
|
||||
fileSize={material.fileSize}
|
||||
createdAt={material.createdAt}
|
||||
/>
|
||||
<CardPlayer
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
duration={material.duration}
|
||||
volume={volume}
|
||||
fileUrl={material.fileUrl}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
onSeek={onSeek}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onToggleMute={onToggleMute}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceMaterialCard
|
||||
export type { VoiceCardProps }
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
import { CheckOutlined } from "@ant-design/icons"
|
||||
|
||||
interface BatchCheckboxProps {
|
||||
isSelected: boolean
|
||||
visible: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
/** 批量选择 checkbox */
|
||||
const BatchCheckbox: React.FC<BatchCheckboxProps> = ({ isSelected, visible, onToggle }) => {
|
||||
if (!visible) return null
|
||||
return (
|
||||
<div
|
||||
className={`vmat-card-checkbox vmat-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle()
|
||||
}}
|
||||
>
|
||||
{isSelected && <CheckOutlined />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BatchCheckbox
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
import { EditOutlined, DeleteOutlined } from "@ant-design/icons"
|
||||
|
||||
interface CardActionsProps {
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
/** 卡片操作按钮:编辑 / 删除 */
|
||||
const CardActions: React.FC<CardActionsProps> = ({ onEdit, onDelete }) => {
|
||||
return (
|
||||
<div className="vmat-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-card-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
title="编辑"
|
||||
>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-card-action-btn vmat-card-action-btn--danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardActions
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import React from "react"
|
||||
import { AudioOutlined } from "@ant-design/icons"
|
||||
import { type VoiceMaterial } from "../../types"
|
||||
import { genderClass, genderIcon, genderLabel } from "../../utils/format"
|
||||
|
||||
interface CardHeaderProps {
|
||||
material: VoiceMaterial
|
||||
}
|
||||
|
||||
/** 卡片头部:头像 + 名称 + 性别标签 */
|
||||
const CardHeader: React.FC<CardHeaderProps> = ({ material }) => {
|
||||
return (
|
||||
<div className="vmat-card-header">
|
||||
<div className="vmat-card-avatar">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div className="vmat-card-title-area">
|
||||
<h4 className="vmat-card-name" title={material.name}>
|
||||
{material.name}
|
||||
</h4>
|
||||
<span className={`vmat-card-gender ${genderClass(material.gender)}`}>
|
||||
{genderIcon(material.gender)}
|
||||
{genderLabel(material.gender)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardHeader
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import React from "react"
|
||||
import { formatDuration, formatFileSize, formatDate } from "../../utils/format"
|
||||
|
||||
interface CardMetaProps {
|
||||
duration: number
|
||||
fileSize: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 元信息:时长 / 文件大小 / 创建日期 */
|
||||
const CardMeta: React.FC<CardMetaProps> = ({ duration, fileSize, createdAt }) => {
|
||||
return (
|
||||
<div className="vmat-card-meta">
|
||||
<span>{formatDuration(duration)}</span>
|
||||
<span>{formatFileSize(fileSize)}</span>
|
||||
<span>{formatDate(createdAt)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardMeta
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import React, { useRef } from "react"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
SoundOutlined,
|
||||
MutedOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { formatDuration } from "../../utils/format"
|
||||
|
||||
interface CardPlayerProps {
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
duration: number
|
||||
volume: number
|
||||
fileUrl?: string
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onSeek: (time: number) => void
|
||||
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onToggleMute: () => void
|
||||
}
|
||||
|
||||
/** 播放控制区:播放按钮 + 进度条 + 时间 + 音量 */
|
||||
const CardPlayer: React.FC<CardPlayerProps> = ({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
fileUrl,
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onVolumeChange,
|
||||
onToggleMute,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
e.preventDefault()
|
||||
const doSeek = (ev: MouseEvent) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||
onSeek(percent * duration)
|
||||
}
|
||||
doSeek(e.nativeEvent)
|
||||
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
||||
const handleUp = () => {
|
||||
document.removeEventListener("mousemove", handleMove)
|
||||
document.removeEventListener("mouseup", handleUp)
|
||||
}
|
||||
document.addEventListener("mousemove", handleMove)
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="vmat-card-player">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-play-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
isPlaying ? onPause() : onPlay()
|
||||
}}
|
||||
disabled={!fileUrl}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
<div ref={progressRef} className="vmat-progress" onMouseDown={handleProgressMouseDown}>
|
||||
<div className="vmat-progress-bar" style={{ width: `${progress}%` }} />
|
||||
{isPlaying && <div className="vmat-progress-thumb" style={{ left: `${progress}%` }} />}
|
||||
</div>
|
||||
<span className="vmat-time">
|
||||
{isPlaying ? formatDuration(currentTime) : formatDuration(duration)}
|
||||
</span>
|
||||
{/* 音量控制 */}
|
||||
<div className="vmat-volume">
|
||||
<button
|
||||
type="button"
|
||||
className="vmat-volume-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleMute()
|
||||
}}
|
||||
title={volume === 0 ? "取消静音" : "静音"}
|
||||
>
|
||||
{volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="vmat-volume-slider"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
onVolumeChange(e)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardPlayer
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import React from "react"
|
||||
import { Tooltip } from "antd"
|
||||
import { Tag } from "@/components/ui"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
import { MAX_CARD_TAGS, TAG_VARIANTS } from "../../constants"
|
||||
|
||||
interface CardTagsProps {
|
||||
tagIds: string[]
|
||||
tagMap: Map<string, TagItem>
|
||||
onEdit: () => void
|
||||
}
|
||||
|
||||
/** 标签展示区 */
|
||||
const CardTags: React.FC<CardTagsProps> = ({ tagIds, tagMap, onEdit }) => {
|
||||
if (tagIds.length === 0) {
|
||||
return (
|
||||
<div className="vmat-card-tags">
|
||||
<span
|
||||
className="vmat-tag-empty"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
添加标签
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vmat-card-tags">
|
||||
{tagIds.slice(0, MAX_CARD_TAGS).map((tagId, i) => (
|
||||
<Tag key={tagId} variant={TAG_VARIANTS[i % TAG_VARIANTS.length]}>
|
||||
{tagMap.get(tagId)?.name ?? tagId}
|
||||
</Tag>
|
||||
))}
|
||||
{tagIds.length > MAX_CARD_TAGS && (
|
||||
<Tooltip
|
||||
title={tagIds
|
||||
.slice(MAX_CARD_TAGS)
|
||||
.map((id) => tagMap.get(id)?.name ?? id)
|
||||
.join("、")}
|
||||
>
|
||||
<Tag className="vmat-tag-overflow">+{tagIds.length - MAX_CARD_TAGS}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardTags
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default } from "../VoiceMaterialCard"
|
||||
export * from "./types"
|
||||
export { default as CardHeader } from "./CardHeader"
|
||||
export { default as CardTags } from "./CardTags"
|
||||
export { default as CardMeta } from "./CardMeta"
|
||||
export { default as CardPlayer } from "./CardPlayer"
|
||||
export { default as CardActions } from "./CardActions"
|
||||
export { default as BatchCheckbox } from "./BatchCheckbox"
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from "react"
|
||||
import { type VoiceMaterial } from "../../types"
|
||||
import { type TagItem } from "@/api/tags"
|
||||
|
||||
export interface VoiceCardProps {
|
||||
material: VoiceMaterial
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
isSelected: boolean
|
||||
batchMode: boolean
|
||||
volume: number
|
||||
tagMap: Map<string, TagItem>
|
||||
onPlay: () => void
|
||||
onPause: () => void
|
||||
onSeek: (time: number) => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onToggleSelect: (id: string) => void
|
||||
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onToggleMute: () => void
|
||||
}
|
||||
Regular → Executable
+7
@@ -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
@@ -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)
|
||||
Executable
+966
@@ -0,0 +1,966 @@
|
||||
"""PiP Engine 纯逻辑单测.
|
||||
|
||||
测试 pip_engine_pure.py 中的所有纯函数,
|
||||
0 FFmpeg 依赖,快速轻量。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from video_processing.pip_engine_pure import (
|
||||
build_animation_filters,
|
||||
build_enable_expr,
|
||||
build_overlay_expr,
|
||||
build_pip_filters,
|
||||
build_pip_pre_filter,
|
||||
compute_pip_position,
|
||||
compute_pip_size,
|
||||
count_visible_layers,
|
||||
sort_layers_by_z_index,
|
||||
validate_pip_layer,
|
||||
)
|
||||
|
||||
from packages.domain.pip_config import (
|
||||
ANIMATION_FADE,
|
||||
ANIMATION_SLIDE_BOTTOM,
|
||||
ANIMATION_SLIDE_LEFT,
|
||||
ANIMATION_SLIDE_RIGHT,
|
||||
ANIMATION_SLIDE_TOP,
|
||||
PiPLayerConfig,
|
||||
)
|
||||
|
||||
# ── 常量与工具 ────────────────────────────────────────────────────────────────
|
||||
|
||||
OUTPUT_W = 1080
|
||||
OUTPUT_H = 1920
|
||||
|
||||
|
||||
def _make_layer(**kwargs) -> PiPLayerConfig:
|
||||
"""快速创建图层配置."""
|
||||
defaults = dict(
|
||||
source_type="local_path",
|
||||
source="/tmp/test.mp4",
|
||||
width="25%",
|
||||
height=None,
|
||||
position="bottom_right",
|
||||
margin=20,
|
||||
opacity=1.0,
|
||||
corner_radius=0,
|
||||
border_width=0,
|
||||
border_color="black",
|
||||
z_index=0,
|
||||
start_time=0.0,
|
||||
duration=None,
|
||||
animation_in=None,
|
||||
animation_out=None,
|
||||
animation_duration=0.5,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return PiPLayerConfig(**defaults)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# compute_pip_size
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestComputePipSize:
|
||||
"""尺寸计算测试."""
|
||||
|
||||
def test_percentage_width_auto_height(self):
|
||||
"""百分比宽度,自动高度(16:9)."""
|
||||
layer = _make_layer(width="25%")
|
||||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||||
assert w == 270 # 1080 * 25%
|
||||
assert h == 151 # 270 * 9 / 16 = 151.875 → 151
|
||||
|
||||
def test_pixel_width_and_height(self):
|
||||
"""像素宽高."""
|
||||
layer = _make_layer(width=300, height=200)
|
||||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||||
assert w == 300
|
||||
assert h == 200
|
||||
|
||||
def test_pixel_width_percent_height(self):
|
||||
"""像素宽 + 百分比高."""
|
||||
layer = _make_layer(width=200, height="10%")
|
||||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||||
assert w == 200
|
||||
assert h == 192 # 1920 * 10%
|
||||
|
||||
def test_full_width_clamped(self):
|
||||
"""超过输出尺寸时钳制到输出范围内."""
|
||||
layer = _make_layer(width="200%")
|
||||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||||
assert w == OUTPUT_W
|
||||
assert h <= OUTPUT_H # 按比例后高度不超过输出
|
||||
|
||||
def test_zero_width_minimum(self):
|
||||
"""极小尺寸钳制到至少 1 像素."""
|
||||
layer = _make_layer(width="0%")
|
||||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||||
assert w >= 1
|
||||
assert h >= 1
|
||||
|
||||
def test_pixel_int_width(self):
|
||||
"""整数像素宽度."""
|
||||
layer = _make_layer(width=500, height=300)
|
||||
w, h = compute_pip_size(layer, OUTPUT_W, OUTPUT_H)
|
||||
assert w == 500
|
||||
assert h == 300
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# compute_pip_position
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestComputePipPosition:
|
||||
"""位置计算测试."""
|
||||
|
||||
def test_bottom_right(self):
|
||||
"""右下角位置."""
|
||||
layer = _make_layer(position="bottom_right", margin=20)
|
||||
pip_w, pip_h = 200, 150
|
||||
x, y = compute_pip_position(layer, pip_w, pip_h, OUTPUT_W, OUTPUT_H)
|
||||
assert x == OUTPUT_W - pip_w - 20
|
||||
assert y == OUTPUT_H - pip_h - 20
|
||||
|
||||
def test_top_left(self):
|
||||
"""左上角."""
|
||||
layer = _make_layer(position="top_left", margin=10)
|
||||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
assert x == 10
|
||||
assert y == 10
|
||||
|
||||
def test_top_center(self):
|
||||
"""顶部居中."""
|
||||
layer = _make_layer(position="top_center", margin=20)
|
||||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
assert x == (OUTPUT_W - 200) // 2
|
||||
assert y == 20
|
||||
|
||||
def test_center(self):
|
||||
"""正中心."""
|
||||
layer = _make_layer(position="center")
|
||||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
assert x == (OUTPUT_W - 200) // 2
|
||||
assert y == (OUTPUT_H - 150) // 2
|
||||
|
||||
def test_custom_position(self):
|
||||
"""自定义坐标."""
|
||||
layer = _make_layer(position="custom", x=100, y=200)
|
||||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
assert x == 100
|
||||
assert y == 200
|
||||
|
||||
def test_margin_effect(self):
|
||||
"""不同 margin 值影响位置."""
|
||||
layer1 = _make_layer(position="bottom_right", margin=0)
|
||||
layer2 = _make_layer(position="bottom_right", margin=50)
|
||||
x1, y1 = compute_pip_position(layer1, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
x2, y2 = compute_pip_position(layer2, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
assert x1 > x2
|
||||
assert y1 > y2
|
||||
|
||||
def test_clamped_when_outside(self):
|
||||
"""自定义坐标超出画面时钳制到边界内."""
|
||||
layer = _make_layer(position="custom", x=-50, y=99999)
|
||||
x, y = compute_pip_position(layer, 200, 150, OUTPUT_W, OUTPUT_H)
|
||||
assert x >= 0
|
||||
assert x <= OUTPUT_W - 200
|
||||
assert y >= 0
|
||||
assert y == OUTPUT_H - 150 # y 超出底部,钳制到底部
|
||||
|
||||
def test_bottom_center(self):
|
||||
"""底部居中."""
|
||||
layer = _make_layer(position="bottom_center", margin=30)
|
||||
x, y = compute_pip_position(layer, 300, 200, OUTPUT_W, OUTPUT_H)
|
||||
assert x == (OUTPUT_W - 300) // 2
|
||||
assert y == OUTPUT_H - 200 - 30
|
||||
|
||||
def test_center_left(self):
|
||||
"""左侧居中."""
|
||||
layer = _make_layer(position="center_left", margin=15)
|
||||
x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == 15
|
||||
assert y == (OUTPUT_H - 100) // 2
|
||||
|
||||
def test_center_right(self):
|
||||
"""右侧居中."""
|
||||
layer = _make_layer(position="center_right", margin=15)
|
||||
x, y = compute_pip_position(layer, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == OUTPUT_W - 150 - 15
|
||||
assert y == (OUTPUT_H - 100) // 2
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_pip_pre_filter
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildPipPreFilter:
|
||||
"""预处理滤镜构建测试."""
|
||||
|
||||
def test_basic_scale_setsar(self):
|
||||
"""基础:scale + setsar."""
|
||||
layer = _make_layer()
|
||||
result = build_pip_pre_filter("[1:v]", layer, 200, 150, "pip_pre_0")
|
||||
assert result.startswith("[1:v]")
|
||||
assert "scale=200:150" in result
|
||||
assert "setsar=1" in result
|
||||
assert result.endswith("[pip_pre_0]")
|
||||
|
||||
def test_corner_radius_filter(self):
|
||||
"""圆角裁剪滤镜."""
|
||||
layer = _make_layer(corner_radius=20)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "geq=" in result
|
||||
assert "format=yuva420p" in result
|
||||
# 圆角半径应钳制到 min(r, w//2, h//2)
|
||||
assert "hypot(" in result
|
||||
|
||||
def test_corner_radius_clamped(self):
|
||||
"""圆角半径超过尺寸一半时自动钳制."""
|
||||
layer = _make_layer(corner_radius=1000) # 超大
|
||||
result = build_pip_pre_filter("[0:v]", layer, 100, 80, "pre")
|
||||
# 钳制后 r = min(1000, 50, 40) = 40
|
||||
# 检查 geq 表达式中的 r 值
|
||||
import re
|
||||
|
||||
r_matches = re.findall(r"lt\(X,(\d+)\)\*lt\(Y,\1\)", result)
|
||||
assert r_matches
|
||||
assert int(r_matches[0]) <= 50 # 不超过宽的一半
|
||||
|
||||
def test_border_filter(self):
|
||||
"""边框滤镜."""
|
||||
layer = _make_layer(border_width=5, border_color="red")
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "pad=210:160:5:5:red" in result
|
||||
|
||||
def test_zero_border_no_pad(self):
|
||||
"""border_width=0 时不加 pad."""
|
||||
layer = _make_layer(border_width=0)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "pad=" not in result
|
||||
|
||||
def test_opacity_filter(self):
|
||||
"""透明度滤镜."""
|
||||
layer = _make_layer(opacity=0.5)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "colorchannelmixer=aa=0.5" in result
|
||||
assert "format=yuva420p" in result
|
||||
|
||||
def test_full_opacity_no_alpha(self):
|
||||
"""opacity=1.0 时不加透明度滤镜."""
|
||||
layer = _make_layer(opacity=1.0)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "colorchannelmixer" not in result
|
||||
|
||||
def test_opacity_clamped_high(self):
|
||||
"""opacity > 1.0 时钳制."""
|
||||
layer = _make_layer(opacity=2.0)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
# 钳制到 1.0,不加透明度滤镜
|
||||
assert "colorchannelmixer=aa=1" not in result
|
||||
assert "colorchannelmixer" not in result
|
||||
|
||||
def test_opacity_clamped_low(self):
|
||||
"""opacity < 0 时钳制到 0."""
|
||||
layer = _make_layer(opacity=-0.5)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "colorchannelmixer=aa=0.0" in result
|
||||
|
||||
def test_fade_in_animation(self):
|
||||
"""淡入动画."""
|
||||
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.3)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "fade=t=in:st=0:d=0.3:alpha=1" in result
|
||||
|
||||
def test_fade_out_animation(self):
|
||||
"""淡出动画(需要 duration)."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_FADE,
|
||||
animation_duration=0.5,
|
||||
duration=5.0,
|
||||
)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "fade=t=out:st=4.5:d=0.5:alpha=1" in result
|
||||
|
||||
def test_fade_out_no_duration(self):
|
||||
"""淡出无 duration 时不加."""
|
||||
layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5, duration=None)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 200, 150, "pre")
|
||||
assert "fade=t=out" not in result
|
||||
|
||||
def test_combined_effects(self):
|
||||
"""多个效果组合:圆角 + 边框 + 透明度."""
|
||||
layer = _make_layer(
|
||||
corner_radius=15,
|
||||
border_width=3,
|
||||
border_color="white",
|
||||
opacity=0.8,
|
||||
)
|
||||
result = build_pip_pre_filter("[0:v]", layer, 300, 200, "pre")
|
||||
assert "geq=" in result # 圆角
|
||||
assert "pad=306:206:3:3:white" in result # 边框
|
||||
assert "colorchannelmixer=aa=0.8" in result # 透明度
|
||||
|
||||
def test_output_label(self):
|
||||
"""输出标签正确."""
|
||||
layer = _make_layer()
|
||||
result = build_pip_pre_filter("[2:v]", layer, 100, 80, "my_label")
|
||||
assert result.endswith("[my_label]")
|
||||
|
||||
def test_input_label(self):
|
||||
"""输入标签正确."""
|
||||
layer = _make_layer()
|
||||
result = build_pip_pre_filter("[5:v]", layer, 100, 80, "out")
|
||||
assert result.startswith("[5:v]")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_animation_filters
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildAnimationFilters:
|
||||
"""动画滤镜构建测试."""
|
||||
|
||||
def test_no_animation(self):
|
||||
"""无动画返回空列表."""
|
||||
layer = _make_layer()
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert result == []
|
||||
|
||||
def test_fade_in_only(self):
|
||||
"""仅淡入."""
|
||||
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0.5)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert len(result) == 1
|
||||
assert "fade=t=in" in result[0]
|
||||
|
||||
def test_fade_out_with_duration(self):
|
||||
"""淡出(有 duration)."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_FADE,
|
||||
animation_duration=0.3,
|
||||
duration=10.0,
|
||||
)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert len(result) == 1
|
||||
assert "fade=t=out:st=9.7:d=0.3" in result[0]
|
||||
|
||||
def test_fade_out_no_duration_skipped(self):
|
||||
"""淡出无 duration 时跳过."""
|
||||
layer = _make_layer(animation_out=ANIMATION_FADE, animation_duration=0.5)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert result == []
|
||||
|
||||
def test_fade_in_and_out(self):
|
||||
"""淡入 + 淡出."""
|
||||
layer = _make_layer(
|
||||
animation_in=ANIMATION_FADE,
|
||||
animation_out=ANIMATION_FADE,
|
||||
animation_duration=0.5,
|
||||
duration=3.0,
|
||||
)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert len(result) == 2
|
||||
assert any("fade=t=in" in f for f in result)
|
||||
assert any("fade=t=out" in f for f in result)
|
||||
|
||||
def test_slide_in_not_here(self):
|
||||
"""slide 动画不在此函数处理."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert result == []
|
||||
|
||||
def test_zero_duration_no_animation(self):
|
||||
"""动画时长为 0 时不加."""
|
||||
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=0)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert result == []
|
||||
|
||||
def test_negative_duration_clamped(self):
|
||||
"""负动画时长钳制为 0."""
|
||||
layer = _make_layer(animation_in=ANIMATION_FADE, animation_duration=-1)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert result == []
|
||||
|
||||
def test_fade_out_start_clamped_to_zero(self):
|
||||
"""淡出开始时间不为负."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_FADE,
|
||||
animation_duration=2.0,
|
||||
duration=1.0, # 比动画时长短
|
||||
)
|
||||
result = build_animation_filters(layer, 200, 150)
|
||||
assert len(result) == 1
|
||||
# start = max(0, 1.0 - 2.0) = 0
|
||||
assert "st=0.0:d=2.0" in result[0]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_overlay_expr
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildOverlayExpr:
|
||||
"""overlay 表达式构建测试."""
|
||||
|
||||
def test_no_animation_static_position(self):
|
||||
"""无动画时返回静态坐标."""
|
||||
layer = _make_layer()
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert y == "200"
|
||||
|
||||
def test_slide_in_from_left(self):
|
||||
"""从左侧滑入."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert "if(lt(t,0.5)" in x
|
||||
assert "-150" in x # 起始位置 = -pip_width
|
||||
assert y == "200" # y 不变
|
||||
|
||||
def test_slide_in_from_right(self):
|
||||
"""从右侧滑入."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_RIGHT, animation_duration=0.5)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert str(OUTPUT_W) in x
|
||||
assert y == "200"
|
||||
|
||||
def test_slide_in_from_top(self):
|
||||
"""从顶部滑入."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_TOP, animation_duration=0.3)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert "if(lt(t,0.3)" in y
|
||||
assert "-100" in y
|
||||
|
||||
def test_slide_in_from_bottom(self):
|
||||
"""从底部滑入."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_BOTTOM, animation_duration=0.3)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert str(OUTPUT_H) in y
|
||||
|
||||
def test_slide_out_to_left(self):
|
||||
"""向左滑出."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_SLIDE_LEFT,
|
||||
animation_duration=0.5,
|
||||
duration=3.0,
|
||||
)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert "gt(t,2.5)" in x
|
||||
assert y == "200"
|
||||
|
||||
def test_slide_out_to_right(self):
|
||||
"""向右滑出."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_SLIDE_RIGHT,
|
||||
animation_duration=0.5,
|
||||
duration=3.0,
|
||||
)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert "gt(t,2.5)" in x
|
||||
assert y == "200"
|
||||
# 向右滑出:结束时 x > base_x(值变大)
|
||||
# 检查表达式中含增大方向的计算
|
||||
assert "+(t-2.5)/0.5*" in x
|
||||
|
||||
def test_slide_out_to_top(self):
|
||||
"""向上滑出."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_SLIDE_TOP,
|
||||
animation_duration=0.5,
|
||||
duration=5.0,
|
||||
)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert "gt(t,4.5)" in y
|
||||
|
||||
def test_slide_out_to_bottom(self):
|
||||
"""向下滑出."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_SLIDE_BOTTOM,
|
||||
animation_duration=0.5,
|
||||
duration=5.0,
|
||||
)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert "gt(t,4.5)" in y
|
||||
# 向下滑出:y 值增大
|
||||
assert "+(t-4.5)/0.5*" in y
|
||||
|
||||
def test_slide_in_and_out_different_axes(self):
|
||||
"""滑入(x方向) + 滑出(y方向),两个轴都有动画."""
|
||||
layer = _make_layer(
|
||||
animation_in=ANIMATION_SLIDE_LEFT,
|
||||
animation_out=ANIMATION_SLIDE_BOTTOM,
|
||||
animation_duration=0.5,
|
||||
duration=4.0,
|
||||
)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert "lt(t,0.5)" in x # x 方向入场
|
||||
assert "gt(t,3.5)" in y # y 方向出场
|
||||
|
||||
def test_zero_animation_duration_no_effect(self):
|
||||
"""动画时长为 0 时无效果."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert y == "200"
|
||||
|
||||
def test_no_duration_skip_outro(self):
|
||||
"""无 duration 时跳过滑出."""
|
||||
layer = _make_layer(
|
||||
animation_out=ANIMATION_SLIDE_LEFT,
|
||||
animation_duration=0.5,
|
||||
duration=None,
|
||||
)
|
||||
x, y = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "100"
|
||||
assert y == "200"
|
||||
|
||||
def test_expression_format_quoted(self):
|
||||
"""有动画时表达式带单引号."""
|
||||
layer = _make_layer(animation_in=ANIMATION_SLIDE_LEFT, animation_duration=0.5)
|
||||
x, _ = build_overlay_expr(layer, 100, 200, 150, 100, OUTPUT_W, OUTPUT_H)
|
||||
assert x.startswith("'")
|
||||
assert x.endswith("'")
|
||||
|
||||
def test_static_position_unquoted(self):
|
||||
"""无动画时纯数字,不带引号."""
|
||||
layer = _make_layer()
|
||||
x, y = build_overlay_expr(layer, 50, 60, 100, 80, OUTPUT_W, OUTPUT_H)
|
||||
assert x == "50"
|
||||
assert y == "60"
|
||||
assert "'" not in x
|
||||
assert "'" not in y
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_enable_expr
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildEnableExpr:
|
||||
"""enable 表达式构建测试."""
|
||||
|
||||
def test_no_time_restriction(self):
|
||||
"""无时间限制返回空."""
|
||||
layer = _make_layer()
|
||||
assert build_enable_expr(layer) == ""
|
||||
|
||||
def test_start_time_only(self):
|
||||
"""只有开始时间."""
|
||||
layer = _make_layer(start_time=5.0)
|
||||
result = build_enable_expr(layer)
|
||||
assert result == ":enable='gte(t,5.0)'"
|
||||
|
||||
def test_duration_only(self):
|
||||
"""只有 duration(从 0 开始)."""
|
||||
layer = _make_layer(duration=10.0)
|
||||
result = build_enable_expr(layer)
|
||||
assert result == ":enable='between(t,0.0,10.0)'"
|
||||
|
||||
def test_start_and_duration(self):
|
||||
"""开始时间 + 时长."""
|
||||
layer = _make_layer(start_time=2.0, duration=5.0)
|
||||
result = build_enable_expr(layer)
|
||||
assert "between(t,2.0,7.0)" in result
|
||||
|
||||
def test_zero_start_with_duration(self):
|
||||
"""0 开始 + 时长."""
|
||||
layer = _make_layer(start_time=0, duration=3.5)
|
||||
result = build_enable_expr(layer)
|
||||
assert "between(t,0.0,3.5)" in result
|
||||
|
||||
def test_negative_start_clamped(self):
|
||||
"""负开始时间钳制为 0."""
|
||||
layer = _make_layer(start_time=-1.0, duration=5.0)
|
||||
result = build_enable_expr(layer)
|
||||
assert "between(t,0.0,5.0)" in result
|
||||
|
||||
def test_none_duration(self):
|
||||
"""duration=None 视为无限."""
|
||||
layer = _make_layer(start_time=3.0, duration=None)
|
||||
result = build_enable_expr(layer)
|
||||
assert "gte(t,3.0)" in result
|
||||
assert "between" not in result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# build_pip_filters
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBuildPipFilters:
|
||||
"""完整滤镜链构建测试."""
|
||||
|
||||
def test_empty_layers(self):
|
||||
"""空图层列表返回空."""
|
||||
filters, inputs, label = build_pip_filters(
|
||||
"base",
|
||||
[],
|
||||
[],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
assert filters == []
|
||||
assert inputs == []
|
||||
assert label == "base"
|
||||
|
||||
def test_single_layer(self):
|
||||
"""单个图层."""
|
||||
layer = _make_layer(width="20%", position="bottom_right")
|
||||
path = Path("/tmp/clip1.mp4")
|
||||
|
||||
filters, inputs, label = build_pip_filters(
|
||||
"v0",
|
||||
[layer],
|
||||
[path],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
|
||||
# 2 个滤镜片段:预处理 + overlay
|
||||
assert len(filters) == 2
|
||||
# 1 个输入
|
||||
assert inputs == ["-i", str(path)]
|
||||
# 最终标签
|
||||
assert label == "pip_combined_0"
|
||||
|
||||
def test_multiple_layers(self):
|
||||
"""多个图层."""
|
||||
layers = [
|
||||
_make_layer(width="30%", position="bottom_left"),
|
||||
_make_layer(width="25%", position="top_right"),
|
||||
_make_layer(width="20%", position="top_left"),
|
||||
]
|
||||
paths = [Path("/tmp/a.mp4"), Path("/tmp/b.mp4"), Path("/tmp/c.mp4")]
|
||||
|
||||
filters, inputs, label = build_pip_filters(
|
||||
"base",
|
||||
layers,
|
||||
paths,
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
|
||||
# 每个图层 2 个滤镜(预处理 + overlay)
|
||||
assert len(filters) == 6
|
||||
# 3 个输入
|
||||
assert len(inputs) == 6 # -i path × 3
|
||||
assert inputs[0::2] == ["-i", "-i", "-i"]
|
||||
# 最终标签是最后一个 combined
|
||||
assert label == "pip_combined_2"
|
||||
|
||||
def test_base_input_idx_offset(self):
|
||||
"""base_input_idx 偏移."""
|
||||
layer = _make_layer(width="20%")
|
||||
filters, inputs, label = build_pip_filters(
|
||||
"base",
|
||||
[layer],
|
||||
[Path("/tmp/x.mp4")],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
base_input_idx=5,
|
||||
)
|
||||
# 预处理滤镜引用 [5:v]
|
||||
assert "[5:v]" in filters[0]
|
||||
|
||||
def test_layer_count_mismatch_raises(self):
|
||||
"""图层和路径数量不一致时报错."""
|
||||
with pytest.raises(ValueError, match="长度不一致"):
|
||||
build_pip_filters(
|
||||
"base",
|
||||
[_make_layer()],
|
||||
[],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
|
||||
def test_filter_chaining(self):
|
||||
"""多图层时滤镜链正确串联."""
|
||||
layers = [_make_layer(width="10%"), _make_layer(width="10%")]
|
||||
paths = [Path("/tmp/1.mp4"), Path("/tmp/2.mp4")]
|
||||
|
||||
filters, _, _ = build_pip_filters(
|
||||
"base",
|
||||
layers,
|
||||
paths,
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
|
||||
# 第一个 overlay 的输入是 base + pip_pre_0
|
||||
# 输出是 pip_combined_0
|
||||
assert "[base]" in filters[1]
|
||||
assert "[pip_combined_0]" in filters[1]
|
||||
|
||||
# 第二个 overlay 的输入是 pip_combined_0 + pip_pre_1
|
||||
# 输出是 pip_combined_1
|
||||
assert "[pip_combined_0]" in filters[3]
|
||||
assert "[pip_combined_1]" in filters[3]
|
||||
|
||||
def test_with_animation_layer(self):
|
||||
"""带动画的图层生成正确表达式."""
|
||||
layer = _make_layer(
|
||||
width="30%",
|
||||
animation_in=ANIMATION_SLIDE_BOTTOM,
|
||||
animation_duration=0.5,
|
||||
)
|
||||
filters, inputs, _ = build_pip_filters(
|
||||
"v0",
|
||||
[layer],
|
||||
[Path("/tmp/a.mp4")],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
# overlay 滤镜中包含滑动表达式
|
||||
overlay_filter = filters[1]
|
||||
assert "overlay=" in overlay_filter
|
||||
assert str(OUTPUT_H) in overlay_filter # 从底部滑入
|
||||
|
||||
def test_with_enable_time(self):
|
||||
"""带时间控制的图层."""
|
||||
layer = _make_layer(width="20%", start_time=2.0, duration=5.0)
|
||||
filters, _, _ = build_pip_filters(
|
||||
"v0",
|
||||
[layer],
|
||||
[Path("/tmp/a.mp4")],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
overlay_filter = filters[1]
|
||||
assert "enable=" in overlay_filter
|
||||
assert "between" in overlay_filter
|
||||
|
||||
def test_string_paths(self):
|
||||
"""路径可以是字符串."""
|
||||
layer = _make_layer(width="10%")
|
||||
filters, inputs, label = build_pip_filters(
|
||||
"v0",
|
||||
[layer],
|
||||
["/tmp/s.mp4"],
|
||||
output_width=OUTPUT_W,
|
||||
output_height=OUTPUT_H,
|
||||
)
|
||||
assert inputs == ["-i", "/tmp/s.mp4"]
|
||||
assert len(filters) == 2
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# validate_pip_layer
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestValidatePipLayer:
|
||||
"""配置验证测试."""
|
||||
|
||||
def test_valid_layer(self):
|
||||
"""合法配置."""
|
||||
layer = _make_layer()
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
|
||||
def test_empty_source_type(self):
|
||||
"""空 source_type."""
|
||||
layer = _make_layer(source_type="")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "source_type" in err
|
||||
|
||||
def test_invalid_source_type(self):
|
||||
"""不支持的 source_type."""
|
||||
layer = _make_layer(source_type="ftp")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "source_type" in err
|
||||
|
||||
def test_empty_source(self):
|
||||
"""空 source."""
|
||||
layer = _make_layer(source="")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "source" in err
|
||||
|
||||
def test_invalid_position(self):
|
||||
"""不支持的 position."""
|
||||
layer = _make_layer(position="middle")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "position" in err
|
||||
|
||||
def test_opacity_too_high(self):
|
||||
"""opacity > 1."""
|
||||
layer = _make_layer(opacity=1.5)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "opacity" in err
|
||||
|
||||
def test_opacity_negative(self):
|
||||
"""opacity < 0."""
|
||||
layer = _make_layer(opacity=-0.1)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "opacity" in err
|
||||
|
||||
def test_negative_corner_radius(self):
|
||||
"""负圆角."""
|
||||
layer = _make_layer(corner_radius=-5)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "corner_radius" in err
|
||||
|
||||
def test_negative_border_width(self):
|
||||
"""负边框."""
|
||||
layer = _make_layer(border_width=-2)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "border_width" in err
|
||||
|
||||
def test_negative_start_time(self):
|
||||
"""负开始时间."""
|
||||
layer = _make_layer(start_time=-1.0)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "start_time" in err
|
||||
|
||||
def test_negative_duration(self):
|
||||
"""负时长."""
|
||||
layer = _make_layer(duration=-5.0)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "duration" in err
|
||||
|
||||
def test_invalid_animation_in(self):
|
||||
"""不支持的入场动画."""
|
||||
layer = _make_layer(animation_in="zoom")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "animation_in" in err
|
||||
|
||||
def test_invalid_animation_out(self):
|
||||
"""不支持的出场动画."""
|
||||
layer = _make_layer(animation_out="spin")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert "animation_out" in err
|
||||
|
||||
def test_multiple_errors_combined(self):
|
||||
"""多个错误合并."""
|
||||
layer = _make_layer(source_type="", source="", opacity=2.0, position="xxx")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is False
|
||||
assert err.count(";") >= 2 # 至少 2 个错误
|
||||
|
||||
def test_valid_url_source(self):
|
||||
"""URL 类型 source 合法."""
|
||||
layer = _make_layer(source_type="url", source="https://example.com/v.mp4")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is True
|
||||
|
||||
def test_valid_asset_id(self):
|
||||
"""asset_id 类型合法."""
|
||||
layer = _make_layer(source_type="asset_id", source="asset_123")
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is True
|
||||
|
||||
def test_zero_values_valid(self):
|
||||
"""0 值合法(不是负数)."""
|
||||
layer = _make_layer(
|
||||
corner_radius=0,
|
||||
border_width=0,
|
||||
start_time=0,
|
||||
animation_duration=0,
|
||||
)
|
||||
ok, err = validate_pip_layer(layer)
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# count_visible_layers
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCountVisibleLayers:
|
||||
"""可见图层统计测试."""
|
||||
|
||||
def test_all_visible(self):
|
||||
"""全部可见."""
|
||||
layers = [_make_layer(opacity=1.0), _make_layer(opacity=0.5)]
|
||||
assert count_visible_layers(layers) == 2
|
||||
|
||||
def test_all_invisible(self):
|
||||
"""全部不可见."""
|
||||
layers = [_make_layer(opacity=0.0), _make_layer(opacity=0.0)]
|
||||
assert count_visible_layers(layers) == 0
|
||||
|
||||
def test_mixed(self):
|
||||
"""混合."""
|
||||
layers = [
|
||||
_make_layer(opacity=1.0),
|
||||
_make_layer(opacity=0.0),
|
||||
_make_layer(opacity=0.001),
|
||||
]
|
||||
assert count_visible_layers(layers) == 2
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert count_visible_layers([]) == 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# sort_layers_by_z_index
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSortLayersByZIndex:
|
||||
"""图层排序测试."""
|
||||
|
||||
def test_sorted_by_z_index(self):
|
||||
"""按 z_index 从小到大排序."""
|
||||
layers = [
|
||||
_make_layer(z_index=5, source="/tmp/a.mp4"),
|
||||
_make_layer(z_index=1, source="/tmp/b.mp4"),
|
||||
_make_layer(z_index=3, source="/tmp/c.mp4"),
|
||||
]
|
||||
sorted_layers = sort_layers_by_z_index(layers)
|
||||
assert [l.z_index for l in sorted_layers] == [1, 3, 5]
|
||||
|
||||
def test_same_z_index_stable(self):
|
||||
"""相同 z_index 保持相对顺序."""
|
||||
layers = [
|
||||
_make_layer(z_index=2, source="/tmp/1.mp4"),
|
||||
_make_layer(z_index=2, source="/tmp/2.mp4"),
|
||||
]
|
||||
sorted_layers = sort_layers_by_z_index(layers)
|
||||
assert sorted_layers[0].source == "/tmp/1.mp4"
|
||||
assert sorted_layers[1].source == "/tmp/2.mp4"
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert sort_layers_by_z_index([]) == []
|
||||
|
||||
def test_single_layer(self):
|
||||
"""单个图层."""
|
||||
layers = [_make_layer(z_index=0)]
|
||||
assert len(sort_layers_by_z_index(layers)) == 1
|
||||
|
||||
def test_negative_z_index(self):
|
||||
"""负 z_index."""
|
||||
layers = [
|
||||
_make_layer(z_index=0, source="/tmp/0.mp4"),
|
||||
_make_layer(z_index=-5, source="/tmp/-5.mp4"),
|
||||
_make_layer(z_index=3, source="/tmp/3.mp4"),
|
||||
]
|
||||
sorted_layers = sort_layers_by_z_index(layers)
|
||||
assert [l.z_index for l in sorted_layers] == [-5, 0, 3]
|
||||
@@ -1,6 +1,6 @@
|
||||
from dataclasses import FrozenInstanceError
|
||||
"""转场预设库单元测试."""
|
||||
|
||||
"""transition_presets 领域层单元测试 - 转场预设库"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -12,198 +12,384 @@ from packages.domain.transition_presets import (
|
||||
list_transition_presets,
|
||||
)
|
||||
|
||||
# ── 数据类测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionPreset:
|
||||
"""TransitionPreset 数据类测试"""
|
||||
"""TransitionPreset 数据类测试."""
|
||||
|
||||
def test_create_minimal(self):
|
||||
preset = TransitionPreset(id="test", name="测试", category="basic", transition="fade")
|
||||
assert preset.id == "test"
|
||||
assert preset.name == "测试"
|
||||
assert preset.category == "basic"
|
||||
assert preset.transition == "fade"
|
||||
assert preset.description == ""
|
||||
assert preset.tags == []
|
||||
assert preset.default_duration == 0.5
|
||||
assert preset.min_duration == 0.1
|
||||
assert preset.max_duration == 3.0
|
||||
assert preset.has_custom_params is False
|
||||
|
||||
def test_create_with_all_params(self):
|
||||
preset = TransitionPreset(
|
||||
id="custom",
|
||||
name="自定义转场",
|
||||
category="special",
|
||||
description="炫酷特效",
|
||||
tags=["炫酷", "特效"],
|
||||
transition="custom",
|
||||
default_duration=1.0,
|
||||
min_duration=0.5,
|
||||
max_duration=5.0,
|
||||
has_custom_params=True,
|
||||
def test_basic_attributes(self):
|
||||
"""基础属性可访问."""
|
||||
p = TransitionPreset(
|
||||
id="test_id",
|
||||
name="测试转场",
|
||||
category="fade",
|
||||
description="测试描述",
|
||||
tags=["tag1", "tag2"],
|
||||
transition="fade",
|
||||
default_duration=0.5,
|
||||
min_duration=0.1,
|
||||
max_duration=3.0,
|
||||
has_custom_params=False,
|
||||
)
|
||||
assert preset.description == "炫酷特效"
|
||||
assert preset.tags == ["炫酷", "特效"]
|
||||
assert preset.default_duration == 1.0
|
||||
assert preset.min_duration == 0.5
|
||||
assert preset.max_duration == 5.0
|
||||
assert preset.has_custom_params is True
|
||||
assert p.id == "test_id"
|
||||
assert p.name == "测试转场"
|
||||
assert p.category == "fade"
|
||||
assert p.description == "测试描述"
|
||||
assert p.tags == ["tag1", "tag2"]
|
||||
assert p.transition == "fade"
|
||||
assert p.default_duration == 0.5
|
||||
assert p.min_duration == 0.1
|
||||
assert p.max_duration == 3.0
|
||||
assert p.has_custom_params is False
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确."""
|
||||
p = TransitionPreset(id="t", name="T", category="basic")
|
||||
assert p.description == ""
|
||||
assert p.tags == []
|
||||
assert p.transition == "fade"
|
||||
assert p.default_duration == 0.5
|
||||
assert p.min_duration == 0.1
|
||||
assert p.max_duration == 3.0
|
||||
assert p.has_custom_params is False
|
||||
|
||||
def test_frozen_immutable(self):
|
||||
"""frozen dataclass 不可修改"""
|
||||
preset = TransitionPreset(id="test", name="测试", category="basic")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
preset.name = "改名"
|
||||
"""frozen dataclass 不可修改."""
|
||||
p = TransitionPreset(id="t", name="T", category="basic")
|
||||
with pytest.raises(Exception): # FrozenInstanceError
|
||||
p.name = "新名字"
|
||||
|
||||
def test_tags_default_empty_list(self):
|
||||
preset = TransitionPreset(id="t1", name="t1", category="basic")
|
||||
preset2 = TransitionPreset(id="t2", name="t2", category="basic")
|
||||
assert preset.tags == []
|
||||
assert preset.tags is not preset2.tags
|
||||
def test_not_hashable_due_to_list(self):
|
||||
"""含list字段(tags)的frozen dataclass不可哈希(list可变)."""
|
||||
p = TransitionPreset(id="t", name="T", category="basic")
|
||||
with pytest.raises(TypeError):
|
||||
hash(p)
|
||||
|
||||
def test_default_transition_is_fade(self):
|
||||
preset = TransitionPreset(id="test", name="测试", category="basic")
|
||||
assert preset.transition == "fade"
|
||||
def test_equality(self):
|
||||
"""相同属性的实例相等."""
|
||||
p1 = TransitionPreset(id="t", name="T", category="basic")
|
||||
p2 = TransitionPreset(id="t", name="T", category="basic")
|
||||
assert p1 == p2
|
||||
|
||||
def test_inequality(self):
|
||||
"""不同属性的实例不等."""
|
||||
p1 = TransitionPreset(id="t1", name="T", category="basic")
|
||||
p2 = TransitionPreset(id="t2", name="T", category="basic")
|
||||
assert p1 != p2
|
||||
|
||||
|
||||
# ── 预设库完整性测试 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTransitionPresetLibrary:
|
||||
"""TRANSITION_PRESET_LIBRARY 预设库测试"""
|
||||
"""TRANSITION_PRESET_LIBRARY 预设库完整性测试."""
|
||||
|
||||
def test_library_not_empty(self):
|
||||
"""预设库不为空."""
|
||||
assert len(TRANSITION_PRESET_LIBRARY) > 0
|
||||
|
||||
def test_all_presets_have_unique_ids(self):
|
||||
"""所有预设 ID 唯一"""
|
||||
def test_preset_count(self):
|
||||
"""预设数量应大于20."""
|
||||
assert len(TRANSITION_PRESET_LIBRARY) >= 20
|
||||
|
||||
def test_all_ids_unique(self):
|
||||
"""所有预设ID唯一."""
|
||||
ids = [p.id for p in TRANSITION_PRESET_LIBRARY]
|
||||
assert len(ids) == len(set(ids))
|
||||
assert len(ids) == len(set(ids)), f"存在重复ID: {[i for i in ids if ids.count(i) > 1]}"
|
||||
|
||||
def test_all_presets_have_required_fields(self):
|
||||
"""所有预设都有必填字段"""
|
||||
for preset in TRANSITION_PRESET_LIBRARY:
|
||||
assert preset.id, "missing id"
|
||||
assert preset.name, f"{preset.id} missing name"
|
||||
assert preset.category, f"{preset.id} missing category"
|
||||
assert preset.transition, f"{preset.id} missing transition"
|
||||
def test_all_have_required_fields(self):
|
||||
"""所有预设都有必填字段."""
|
||||
for p in TRANSITION_PRESET_LIBRARY:
|
||||
assert p.id, f"预设缺少id: {p}"
|
||||
assert p.name, f"预设 {p.id} 缺少name"
|
||||
assert p.category, f"预设 {p.id} 缺少category"
|
||||
assert p.transition, f"预设 {p.id} 缺少transition"
|
||||
|
||||
def test_transition_none_exists(self):
|
||||
"""无转场预设存在"""
|
||||
none_preset = next((p for p in TRANSITION_PRESET_LIBRARY if p.id == "transition_none"), None)
|
||||
def test_duration_range_valid(self):
|
||||
"""每个预设的时长范围合理: min <= default <= max."""
|
||||
for p in TRANSITION_PRESET_LIBRARY:
|
||||
assert (
|
||||
p.min_duration <= p.default_duration
|
||||
), f"{p.id}: min({p.min_duration}) > default({p.default_duration})"
|
||||
assert (
|
||||
p.default_duration <= p.max_duration
|
||||
), f"{p.id}: default({p.default_duration}) > max({p.max_duration})"
|
||||
|
||||
def test_min_duration_non_negative(self):
|
||||
"""最小时长不能为负."""
|
||||
for p in TRANSITION_PRESET_LIBRARY:
|
||||
assert p.min_duration >= 0, f"{p.id}: min_duration为负"
|
||||
|
||||
def test_categories_are_valid(self):
|
||||
"""分类都在预期集合内."""
|
||||
valid_categories = {"basic", "fade", "slide", "zoom", "warp", "special"}
|
||||
for p in TRANSITION_PRESET_LIBRARY:
|
||||
assert p.category in valid_categories, f"{p.id}: 未知分类 {p.category}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"category,expected_min",
|
||||
[
|
||||
("basic", 2),
|
||||
("fade", 4),
|
||||
("slide", 4),
|
||||
("zoom", 2),
|
||||
("warp", 6),
|
||||
("special", 2),
|
||||
],
|
||||
)
|
||||
def test_category_min_count(self, category: str, expected_min: int):
|
||||
"""每个分类至少有预期数量的预设."""
|
||||
count = sum(1 for p in TRANSITION_PRESET_LIBRARY if p.category == category)
|
||||
assert count >= expected_min, f"分类 {category} 只有 {count} 个,预期至少 {expected_min}"
|
||||
|
||||
def test_tags_is_list(self):
|
||||
"""tags字段是列表."""
|
||||
for p in TRANSITION_PRESET_LIBRARY:
|
||||
assert isinstance(p.tags, list), f"{p.id}: tags不是列表"
|
||||
|
||||
def test_none_transition_has_zero_duration(self):
|
||||
"""无转场预设时长为0."""
|
||||
none_preset = get_transition_preset("transition_none")
|
||||
assert none_preset is not None
|
||||
assert none_preset.name == "无转场"
|
||||
assert none_preset.min_duration == 0.0
|
||||
assert none_preset.max_duration == 0.0
|
||||
assert none_preset.default_duration == 0.0
|
||||
assert none_preset.transition == "none"
|
||||
|
||||
def test_transition_random_exists(self):
|
||||
"""随机转场预设存在"""
|
||||
random_preset = next((p for p in TRANSITION_PRESET_LIBRARY if p.id == "transition_random"), None)
|
||||
assert random_preset is not None
|
||||
assert random_preset.name == "随机"
|
||||
|
||||
def test_fade_category_exists(self):
|
||||
"""淡入淡出分类有预设"""
|
||||
fade_presets = [p for p in TRANSITION_PRESET_LIBRARY if p.category == "fade"]
|
||||
assert len(fade_presets) >= 2
|
||||
|
||||
def test_duration_constraints_valid(self):
|
||||
"""时长约束:min <= default <= max"""
|
||||
for preset in TRANSITION_PRESET_LIBRARY:
|
||||
assert preset.min_duration <= preset.default_duration, f"{preset.id}: min > default"
|
||||
assert preset.default_duration <= preset.max_duration, f"{preset.id}: default > max"
|
||||
assert preset.min_duration >= 0, f"{preset.id}: min < 0"
|
||||
|
||||
def test_known_categories_exist(self):
|
||||
"""已知分类都有预设"""
|
||||
categories = {p.category for p in TRANSITION_PRESET_LIBRARY}
|
||||
assert "basic" in categories
|
||||
assert "fade" in categories
|
||||
# ── get_transition_preset 测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetTransitionPreset:
|
||||
"""get_transition_preset 函数测试"""
|
||||
"""get_transition_preset 函数测试."""
|
||||
|
||||
def test_get_existing_preset(self):
|
||||
preset = get_transition_preset("transition_none")
|
||||
assert preset is not None
|
||||
assert preset.id == "transition_none"
|
||||
|
||||
def test_get_fade_preset(self):
|
||||
preset = get_transition_preset("transition_fade")
|
||||
assert preset is not None
|
||||
assert preset.transition == "fade"
|
||||
"""获取存在的预设."""
|
||||
p = get_transition_preset("transition_fade")
|
||||
assert p is not None
|
||||
assert p.id == "transition_fade"
|
||||
assert p.name == "淡入淡出"
|
||||
assert p.category == "fade"
|
||||
|
||||
def test_get_nonexistent_preset(self):
|
||||
assert get_transition_preset("nonexistent_transition") is None
|
||||
"""获取不存在的预设返回None."""
|
||||
p = get_transition_preset("nonexistent_id")
|
||||
assert p is None
|
||||
|
||||
def test_returns_transitionpreset_type(self):
|
||||
preset = get_transition_preset("transition_fade")
|
||||
assert isinstance(preset, TransitionPreset)
|
||||
def test_get_none_preset(self):
|
||||
"""获取无转场预设."""
|
||||
p = get_transition_preset("transition_none")
|
||||
assert p is not None
|
||||
assert p.transition == "none"
|
||||
|
||||
def test_get_random_preset(self):
|
||||
"""获取随机预设."""
|
||||
p = get_transition_preset("transition_random")
|
||||
assert p is not None
|
||||
assert p.transition == "random"
|
||||
|
||||
def test_case_sensitive(self):
|
||||
"""ID区分大小写."""
|
||||
p = get_transition_preset("TRANSITION_FADE")
|
||||
assert p is None
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串返回None."""
|
||||
p = get_transition_preset("")
|
||||
assert p is None
|
||||
|
||||
def test_returns_same_instance(self):
|
||||
"""多次调用返回同一个对象(库引用)."""
|
||||
p1 = get_transition_preset("transition_fade")
|
||||
p2 = get_transition_preset("transition_fade")
|
||||
assert p1 is p2
|
||||
|
||||
def test_all_presets_accessible_by_id(self):
|
||||
"""所有预设都可通过ID获取."""
|
||||
for p in TRANSITION_PRESET_LIBRARY:
|
||||
fetched = get_transition_preset(p.id)
|
||||
assert fetched is not None, f"无法通过ID获取: {p.id}"
|
||||
assert fetched.id == p.id
|
||||
|
||||
|
||||
# ── list_transition_presets 测试 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListTransitionPresets:
|
||||
"""list_transition_presets 函数测试"""
|
||||
"""list_transition_presets 函数测试."""
|
||||
|
||||
def test_list_all(self):
|
||||
"""不带参数返回所有预设"""
|
||||
all_presets = list_transition_presets()
|
||||
assert len(all_presets) == len(TRANSITION_PRESET_LIBRARY)
|
||||
def test_no_filter_returns_all(self):
|
||||
"""无筛选参数返回全部预设."""
|
||||
results = list_transition_presets()
|
||||
assert len(results) == len(TRANSITION_PRESET_LIBRARY)
|
||||
|
||||
def test_filter_by_category(self):
|
||||
"""按分类筛选"""
|
||||
fade_presets = list_transition_presets(category="fade")
|
||||
assert len(fade_presets) > 0
|
||||
assert all(p.category == "fade" for p in fade_presets)
|
||||
def test_filter_by_category_fade(self):
|
||||
"""按fade分类筛选."""
|
||||
results = list_transition_presets(category="fade")
|
||||
assert len(results) > 0
|
||||
assert all(p.category == "fade" for p in results)
|
||||
|
||||
def test_filter_by_basic_category(self):
|
||||
basic_presets = list_transition_presets(category="basic")
|
||||
assert len(basic_presets) >= 2
|
||||
def test_filter_by_category_slide(self):
|
||||
"""按slide分类筛选."""
|
||||
results = list_transition_presets(category="slide")
|
||||
assert len(results) == 4
|
||||
assert all(p.category == "slide" for p in results)
|
||||
|
||||
def test_filter_by_nonexistent_category(self):
|
||||
result = list_transition_presets(category="nonexistent")
|
||||
assert result == []
|
||||
def test_filter_by_category_basic(self):
|
||||
"""按basic分类筛选."""
|
||||
results = list_transition_presets(category="basic")
|
||||
assert len(results) == 2 # none + random
|
||||
|
||||
def test_search_by_name(self):
|
||||
"""按名称搜索"""
|
||||
result = list_transition_presets(keyword="淡入")
|
||||
assert len(result) >= 1
|
||||
assert any("淡入" in p.name for p in result)
|
||||
def test_filter_by_invalid_category(self):
|
||||
"""无效分类返回空列表."""
|
||||
results = list_transition_presets(category="nonexistent")
|
||||
assert results == []
|
||||
|
||||
def test_search_by_tag(self):
|
||||
"""按标签搜索"""
|
||||
tagged = [p for p in TRANSITION_PRESET_LIBRARY if p.tags]
|
||||
if tagged:
|
||||
tag = tagged[0].tags[0]
|
||||
result = list_transition_presets(keyword=tag)
|
||||
assert len(result) >= 1
|
||||
def test_keyword_search_in_name(self):
|
||||
"""关键词搜索name字段."""
|
||||
results = list_transition_presets(keyword="淡入淡出")
|
||||
assert len(results) >= 1
|
||||
assert any(p.id == "transition_fade" for p in results)
|
||||
|
||||
def test_search_empty_returns_all(self):
|
||||
result = list_transition_presets(keyword="")
|
||||
assert len(result) == len(TRANSITION_PRESET_LIBRARY)
|
||||
def test_keyword_search_in_description(self):
|
||||
"""关键词搜索description字段."""
|
||||
results = list_transition_presets(keyword="硬切")
|
||||
assert len(results) >= 1
|
||||
assert any(p.id == "transition_none" for p in results)
|
||||
|
||||
def test_combined_category_and_search(self):
|
||||
result = list_transition_presets(category="fade", keyword="淡入")
|
||||
assert all(p.category == "fade" for p in result)
|
||||
def test_keyword_search_in_tags(self):
|
||||
"""关键词搜索tags字段."""
|
||||
results = list_transition_presets(keyword="模糊")
|
||||
assert len(results) >= 2 # hblur + wipeblur
|
||||
ids = [p.id for p in results]
|
||||
assert "transition_hblur" in ids
|
||||
assert "transition_wipeblur" in ids
|
||||
|
||||
def test_returns_list_of_transitionpreset(self):
|
||||
result = list_transition_presets()
|
||||
assert all(isinstance(p, TransitionPreset) for p in result)
|
||||
def test_keyword_case_insensitive(self):
|
||||
"""关键词搜索不区分大小写(英文)."""
|
||||
results1 = list_transition_presets(keyword="fade")
|
||||
results2 = list_transition_presets(keyword="FADE")
|
||||
assert len(results1) == len(results2)
|
||||
|
||||
def test_keyword_chinese(self):
|
||||
"""中文关键词搜索."""
|
||||
results = list_transition_presets(keyword="滑")
|
||||
assert len(results) >= 4 # 4个slide
|
||||
assert all("滑" in p.name for p in results)
|
||||
|
||||
def test_keyword_no_match(self):
|
||||
"""无匹配关键词返回空."""
|
||||
results = list_transition_presets(keyword="完全不存在的关键词xyz")
|
||||
assert results == []
|
||||
|
||||
def test_keyword_empty_string(self):
|
||||
"""空关键词返回全部."""
|
||||
results = list_transition_presets(keyword="")
|
||||
assert len(results) == len(TRANSITION_PRESET_LIBRARY)
|
||||
|
||||
def test_category_and_keyword_combined(self):
|
||||
"""分类+关键词组合筛选."""
|
||||
results = list_transition_presets(category="warp", keyword="擦除")
|
||||
assert len(results) >= 4 # 4个wipe
|
||||
assert all(p.category == "warp" for p in results)
|
||||
assert all("擦除" in p.name for p in results)
|
||||
|
||||
def test_category_and_keyword_no_match(self):
|
||||
"""分类+关键词不匹配返回空."""
|
||||
results = list_transition_presets(category="fade", keyword="滑动")
|
||||
assert results == []
|
||||
|
||||
def test_preserve_order(self):
|
||||
"""保持预设库的顺序."""
|
||||
results = list_transition_presets()
|
||||
for i, p in enumerate(TRANSITION_PRESET_LIBRARY):
|
||||
assert results[i].id == p.id
|
||||
|
||||
def test_filtered_results_are_all_valid(self):
|
||||
"""筛选结果的每个预设都有完整属性."""
|
||||
results = list_transition_presets(category="slide")
|
||||
for p in results:
|
||||
assert p.id
|
||||
assert p.name
|
||||
assert p.category == "slide"
|
||||
assert isinstance(p.tags, list)
|
||||
|
||||
|
||||
# ── get_default_transition 测试 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetDefaultTransition:
|
||||
"""get_default_transition 函数测试"""
|
||||
|
||||
def test_returns_preset(self):
|
||||
preset = get_default_transition()
|
||||
assert preset is not None
|
||||
assert isinstance(preset, TransitionPreset)
|
||||
"""get_default_transition 函数测试."""
|
||||
|
||||
def test_default_is_none(self):
|
||||
"""默认转场是无转场(硬切)"""
|
||||
preset = get_default_transition()
|
||||
assert preset.id == "transition_none"
|
||||
assert preset.transition == "none"
|
||||
"""默认转场是无转场."""
|
||||
p = get_default_transition()
|
||||
assert p.id == "transition_none"
|
||||
assert p.transition == "none"
|
||||
|
||||
def test_default_has_zero_duration(self):
|
||||
"""无转场默认时长为 0"""
|
||||
preset = get_default_transition()
|
||||
assert preset.default_duration == 0.0
|
||||
assert preset.min_duration == 0.0
|
||||
assert preset.max_duration == 0.0
|
||||
def test_default_zero_duration(self):
|
||||
"""默认转场时长为0."""
|
||||
p = get_default_transition()
|
||||
assert p.default_duration == 0.0
|
||||
assert p.min_duration == 0.0
|
||||
assert p.max_duration == 0.0
|
||||
|
||||
def test_default_category_basic(self):
|
||||
"""默认转场属于basic分类."""
|
||||
p = get_default_transition()
|
||||
assert p.category == "basic"
|
||||
|
||||
def test_default_same_instance(self):
|
||||
"""多次调用返回同一实例."""
|
||||
p1 = get_default_transition()
|
||||
p2 = get_default_transition()
|
||||
assert p1 is p2
|
||||
|
||||
def test_default_matches_get_preset(self):
|
||||
"""默认转场与通过ID获取的一致."""
|
||||
default = get_default_transition()
|
||||
by_id = get_transition_preset("transition_none")
|
||||
assert default is by_id
|
||||
|
||||
|
||||
# ── 预设个体属性抽样测试 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPresetSamples:
|
||||
"""典型预设的属性验证."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"preset_id,expected_name,expected_category,expected_transition",
|
||||
[
|
||||
("transition_none", "无转场", "basic", "none"),
|
||||
("transition_random", "随机", "basic", "random"),
|
||||
("transition_fade", "淡入淡出", "fade", "fade"),
|
||||
("transition_fadeblack", "黑场过渡", "fade", "fadeblack"),
|
||||
("transition_fadewhite", "白场过渡", "fade", "fadewhite"),
|
||||
("transition_slideleft", "左滑", "slide", "slideleft"),
|
||||
("transition_slideright", "右滑", "slide", "slideright"),
|
||||
("transition_slideup", "上滑", "slide", "slideup"),
|
||||
("transition_slidedown", "下滑", "slide", "slidedown"),
|
||||
("transition_zoomin", "放大进入", "zoom", "zoomin"),
|
||||
("transition_zoomout", "缩小退出", "zoom", "zoomout"),
|
||||
("transition_dissolve", "溶解", "warp", "dissolve"),
|
||||
("transition_circlecrop", "圆形展开", "warp", "circlecrop"),
|
||||
("transition_hblur", "水平模糊", "special", "hblur"),
|
||||
],
|
||||
)
|
||||
def test_preset_attributes(
|
||||
self, preset_id: str, expected_name: str, expected_category: str, expected_transition: str
|
||||
):
|
||||
"""典型预设属性验证."""
|
||||
p = get_transition_preset(preset_id)
|
||||
assert p is not None
|
||||
assert p.name == expected_name
|
||||
assert p.category == expected_category
|
||||
assert p.transition == expected_transition
|
||||
|
||||
def test_dissolve_longer_default(self):
|
||||
"""溶解效果默认时长较长(0.8s)."""
|
||||
p = get_transition_preset("transition_dissolve")
|
||||
assert p is not None
|
||||
assert p.default_duration == 0.8
|
||||
|
||||
Reference in New Issue
Block a user