Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7edb66869f | |||
| 90f703e49e | |||
| cafe063859 | |||
| d92ff7aa36 | |||
| cb85b33ee9 | |||
| faeed6f014 | |||
| 6dc388a794 | |||
| 7ca5b3732f | |||
| 7cdf56a1ac |
Regular → Executable
+14
-58
@@ -1,18 +1,16 @@
|
||||
/**
|
||||
* 字幕样式配置面板 — Drawer 形式
|
||||
* 字幕开关(手动 / ASR 自动识别)、字体大小、颜色、描边/阴影、位置、ASR 语言
|
||||
*/
|
||||
import React from "react"
|
||||
import { Drawer, Slider, ColorPicker, Select } from "antd"
|
||||
import type { Color } from "antd/es/color-picker"
|
||||
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
|
||||
import {
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
ANIMATION_OPTIONS,
|
||||
ASR_LANGUAGE_OPTIONS,
|
||||
} from "@/pages/editing-planner/constants/subtitleStyle"
|
||||
import SubtitlePreview from "./subtitle-style/SubtitlePreview"
|
||||
import { SubtitleModeSwitch } from "./subtitle-style/SubtitleModeSwitch"
|
||||
import { SubtitlePositionSelector } from "./subtitle-style/SubtitlePositionSelector"
|
||||
import { SubtitleEffectButtons } from "./subtitle-style/SubtitleEffectButtons"
|
||||
|
||||
interface SubtitleStylePanelProps {
|
||||
open: boolean
|
||||
@@ -40,7 +38,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
onClose={onClose}
|
||||
className="subtitle-style-drawer"
|
||||
>
|
||||
{/* ── 字幕开关 ── */}
|
||||
<div className="sub-field">
|
||||
<div className="sub-toggle-row">
|
||||
<span className="sub-label">启用字幕</span>
|
||||
@@ -55,26 +52,11 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
{/* ── 模式切换 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字幕来源</label>
|
||||
<div className="sub-mode-switch">
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "manual" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "manual" })}
|
||||
>
|
||||
✏️ 手动输入
|
||||
</button>
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "asr" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "asr" })}
|
||||
>
|
||||
🤖 ASR 识别
|
||||
</button>
|
||||
</div>
|
||||
<SubtitleModeSwitch mode={config.mode} onModeChange={(mode) => update({ mode })} />
|
||||
</div>
|
||||
|
||||
{/* ── ASR 语言(仅 ASR 模式) ── */}
|
||||
{config.mode === "asr" && (
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">识别语言</label>
|
||||
@@ -88,7 +70,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 字体大小 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">
|
||||
字体大小 <span className="sub-value">{config.fontSize}px</span>
|
||||
@@ -101,7 +82,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字体颜色 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体颜色</label>
|
||||
<div className="sub-color-row">
|
||||
@@ -113,7 +93,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 字体 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体</label>
|
||||
<Select
|
||||
@@ -125,46 +104,24 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字幕位置 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">位置</label>
|
||||
<div className="sub-position-group">
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sub-position-btn${config.position === opt.value ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
update({
|
||||
position: opt.value as SubtitleStyleConfig["position"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SubtitlePositionSelector
|
||||
position={config.position}
|
||||
onPositionChange={(position) => update({ position })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 描边 / 阴影 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">效果</label>
|
||||
<div className="sub-effect-btns">
|
||||
<button
|
||||
className={`sub-effect-btn${config.stroke ? " active" : ""}`}
|
||||
onClick={() => update({ stroke: !config.stroke })}
|
||||
>
|
||||
S 描边
|
||||
</button>
|
||||
<button
|
||||
className={`sub-effect-btn${config.shadow ? " active" : ""}`}
|
||||
onClick={() => update({ shadow: !config.shadow })}
|
||||
>
|
||||
☁ 阴影
|
||||
</button>
|
||||
</div>
|
||||
<SubtitleEffectButtons
|
||||
stroke={config.stroke}
|
||||
shadow={config.shadow}
|
||||
onStrokeChange={(stroke) => update({ stroke })}
|
||||
onShadowChange={(shadow) => update({ shadow })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 动画 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">动画效果</label>
|
||||
<Select
|
||||
@@ -179,7 +136,6 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 预览 ── */}
|
||||
<SubtitlePreview config={config} />
|
||||
</>
|
||||
)}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import React from "react"
|
||||
|
||||
interface SubtitleEffectButtonsProps {
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
onStrokeChange: (enabled: boolean) => void
|
||||
onShadowChange: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export const SubtitleEffectButtons: React.FC<SubtitleEffectButtonsProps> = ({
|
||||
stroke,
|
||||
shadow,
|
||||
onStrokeChange,
|
||||
onShadowChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="sub-effect-btns">
|
||||
<button
|
||||
className={`sub-effect-btn${stroke ? " active" : ""}`}
|
||||
onClick={() => onStrokeChange(!stroke)}
|
||||
>
|
||||
S 描边
|
||||
</button>
|
||||
<button
|
||||
className={`sub-effect-btn${shadow ? " active" : ""}`}
|
||||
onClick={() => onShadowChange(!shadow)}
|
||||
>
|
||||
☁ 阴影
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import React from "react"
|
||||
|
||||
interface SubtitleModeSwitchProps {
|
||||
mode: "manual" | "asr"
|
||||
onModeChange: (mode: "manual" | "asr") => void
|
||||
}
|
||||
|
||||
export const SubtitleModeSwitch: React.FC<SubtitleModeSwitchProps> = ({ mode, onModeChange }) => {
|
||||
return (
|
||||
<div className="sub-mode-switch">
|
||||
<button
|
||||
className={`sub-mode-btn${mode === "manual" ? " active" : ""}`}
|
||||
onClick={() => onModeChange("manual")}
|
||||
>
|
||||
✏️ 手动输入
|
||||
</button>
|
||||
<button
|
||||
className={`sub-mode-btn${mode === "asr" ? " active" : ""}`}
|
||||
onClick={() => onModeChange("asr")}
|
||||
>
|
||||
🤖 ASR 识别
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
import React from "react"
|
||||
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
|
||||
import { POSITION_OPTIONS } from "@/pages/editing-planner/constants/subtitleStyle"
|
||||
|
||||
interface SubtitlePositionSelectorProps {
|
||||
position: SubtitleStyleConfig["position"]
|
||||
onPositionChange: (position: SubtitleStyleConfig["position"]) => void
|
||||
}
|
||||
|
||||
export const SubtitlePositionSelector: React.FC<SubtitlePositionSelectorProps> = ({
|
||||
position,
|
||||
onPositionChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="sub-position-group">
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sub-position-btn${position === opt.value ? " active" : ""}`}
|
||||
onClick={() => onPositionChange(opt.value as SubtitleStyleConfig["position"])}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useCallback } from "react"
|
||||
import { useRef, useCallback, useEffect } from "react"
|
||||
|
||||
interface UseRowProgressOptions {
|
||||
duration: number
|
||||
@@ -7,6 +7,22 @@ interface UseRowProgressOptions {
|
||||
|
||||
export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const listenersRef = useRef<{ move: ((e: MouseEvent) => void) | null; up: (() => void) | null }>({
|
||||
move: null,
|
||||
up: null,
|
||||
})
|
||||
|
||||
const cleanupListeners = useCallback(() => {
|
||||
const { move, up } = listenersRef.current
|
||||
if (move) {
|
||||
document.removeEventListener("mousemove", move)
|
||||
listenersRef.current.move = null
|
||||
}
|
||||
if (up) {
|
||||
document.removeEventListener("mouseup", up)
|
||||
listenersRef.current.up = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -16,6 +32,7 @@ export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
|
||||
const doSeek = (ev: MouseEvent) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||
onSeek(percent * duration)
|
||||
}
|
||||
@@ -24,15 +41,26 @@ export function useRowProgress({ duration, onSeek }: UseRowProgressOptions) {
|
||||
|
||||
const handleMove = (ev: MouseEvent) => doSeek(ev)
|
||||
const handleUp = () => {
|
||||
document.removeEventListener("mousemove", handleMove)
|
||||
document.removeEventListener("mouseup", handleUp)
|
||||
cleanupListeners()
|
||||
}
|
||||
|
||||
// 先清理旧的,再添加新的
|
||||
cleanupListeners()
|
||||
listenersRef.current.move = handleMove
|
||||
listenersRef.current.up = handleUp
|
||||
|
||||
document.addEventListener("mousemove", handleMove)
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
},
|
||||
[duration, onSeek],
|
||||
[duration, onSeek, cleanupListeners],
|
||||
)
|
||||
|
||||
// 组件卸载时清理事件监听器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupListeners()
|
||||
}
|
||||
}, [cleanupListeners])
|
||||
|
||||
return { progressRef, handleMouseDown }
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ import "@/pages/editing-planner/components/filter/FilterPresetGrid"
|
||||
import "@/pages/editing-planner/components/filter/FilterManualAdjust"
|
||||
import "@/pages/editing-planner/components/intro-outro/IntroOutroBlock"
|
||||
import "@/pages/editing-planner/components/subtitle-style/SubtitlePreview"
|
||||
import "@/pages/editing-planner/components/subtitle-style/SubtitleModeSwitch"
|
||||
import "@/pages/editing-planner/components/subtitle-style/SubtitlePositionSelector"
|
||||
import "@/pages/editing-planner/components/subtitle-style/SubtitleEffectButtons"
|
||||
import "@/pages/editing-planner/components/tts/VoiceSelector"
|
||||
import "@/pages/editing-planner/components/tts/TtsSlider"
|
||||
import "@/pages/editing-planner/components/watermark/WatermarkTypeTabs"
|
||||
|
||||
@@ -20,6 +20,8 @@ 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/voice-material-row/useRowProgress"
|
||||
import "@/pages/voice-materials/components/voice-material-row/TagDisplay"
|
||||
import "@/pages/voice-materials/components/Toolbar"
|
||||
import "@/pages/voice-materials/components/TagFilterBar"
|
||||
import "@/pages/voice-materials/components/BatchBar"
|
||||
|
||||
@@ -569,7 +569,9 @@ class CosyVoiceService:
|
||||
清洗后的 prefix
|
||||
"""
|
||||
# 只保留字母和数字
|
||||
cleaned = "".join(c for c in name if c.isalnum())
|
||||
import re
|
||||
|
||||
cleaned = re.sub(r"[^a-zA-Z0-9]", "", name)
|
||||
# 最多10字符
|
||||
cleaned = cleaned[:10]
|
||||
# 如果清洗后为空,用默认值
|
||||
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
"""模板片段转换器单测.
|
||||
|
||||
纯函数模块,覆盖:枚举安全解析、config过滤、
|
||||
clip→template转换、snapshot双向转换、名称校验。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packages.domain.template_clip_config import (
|
||||
ClipType,
|
||||
TemplateClipConfig,
|
||||
TransitionEffect,
|
||||
)
|
||||
from packages.domain.template_clip_converter import (
|
||||
clip_config_to_snapshot,
|
||||
clip_configs_to_snapshots,
|
||||
clip_to_template_clip_config,
|
||||
clips_to_template_clip_configs,
|
||||
filter_clip_config,
|
||||
filter_plan_config_to_template,
|
||||
safe_parse_clip_type,
|
||||
safe_parse_transition_effect,
|
||||
snapshot_to_template_clip_config,
|
||||
snapshots_to_template_clip_configs,
|
||||
validate_template_name,
|
||||
)
|
||||
|
||||
|
||||
class TestSafeParseTransitionEffect:
|
||||
def test_enum_passthrough(self):
|
||||
result = safe_parse_transition_effect(TransitionEffect.FADE)
|
||||
assert result == TransitionEffect.FADE
|
||||
assert isinstance(result, TransitionEffect)
|
||||
|
||||
def test_valid_string(self):
|
||||
result = safe_parse_transition_effect("fade")
|
||||
assert result == TransitionEffect.FADE
|
||||
|
||||
def test_cut_string(self):
|
||||
result = safe_parse_transition_effect("cut")
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
def test_invalid_string_returns_default(self):
|
||||
result = safe_parse_transition_effect("invalid_effect")
|
||||
assert result == TransitionEffect.CUT # 默认
|
||||
|
||||
def test_invalid_string_custom_default(self):
|
||||
result = safe_parse_transition_effect("bad", default=TransitionEffect.DISSOLVE)
|
||||
assert result == TransitionEffect.DISSOLVE
|
||||
|
||||
def test_none_returns_default(self):
|
||||
result = safe_parse_transition_effect(None)
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
def test_int_value_returns_default(self):
|
||||
result = safe_parse_transition_effect(123)
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
result = safe_parse_transition_effect("")
|
||||
assert result == TransitionEffect.CUT
|
||||
|
||||
|
||||
class TestSafeParseClipType:
|
||||
def test_enum_passthrough(self):
|
||||
result = safe_parse_clip_type(ClipType.SUBTITLE)
|
||||
assert result == ClipType.SUBTITLE
|
||||
assert isinstance(result, ClipType)
|
||||
|
||||
def test_valid_string_main(self):
|
||||
result = safe_parse_clip_type("main")
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
def test_valid_string_text(self):
|
||||
result = safe_parse_clip_type("subtitle")
|
||||
assert result == ClipType.SUBTITLE
|
||||
|
||||
def test_invalid_string_returns_default(self):
|
||||
result = safe_parse_clip_type("unknown_type")
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
def test_invalid_string_custom_default(self):
|
||||
result = safe_parse_clip_type("bad", default=ClipType.TITLE)
|
||||
assert result == ClipType.TITLE
|
||||
|
||||
def test_none_returns_default(self):
|
||||
result = safe_parse_clip_type(None)
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
def test_dict_returns_default(self):
|
||||
result = safe_parse_clip_type({"key": "val"})
|
||||
assert result == ClipType.MAIN
|
||||
|
||||
|
||||
class TestFilterClipConfig:
|
||||
def test_none_config(self):
|
||||
result = filter_clip_config(None)
|
||||
assert result == {}
|
||||
|
||||
def test_empty_dict(self):
|
||||
result = filter_clip_config({})
|
||||
assert result == {}
|
||||
|
||||
def test_basic_config_passthrough(self):
|
||||
cfg = {"font_size": 24, "color": "red"}
|
||||
result = filter_clip_config(cfg)
|
||||
assert result == {"font_size": 24, "color": "red"}
|
||||
|
||||
def test_filters_asset_info(self):
|
||||
cfg = {"font_size": 24, "asset_info": {"id": "123"}}
|
||||
result = filter_clip_config(cfg)
|
||||
assert "asset_info" not in result
|
||||
assert result["font_size"] == 24
|
||||
|
||||
def test_filters_source_asset_id(self):
|
||||
cfg = {"source_asset_id": "asset_1", "text_key": "hi"}
|
||||
result = filter_clip_config(cfg)
|
||||
assert "source_asset_id" not in result
|
||||
assert result["text_key"] == "hi"
|
||||
|
||||
def test_playback_speed_added_when_not_one(self):
|
||||
result = filter_clip_config({}, playback_speed=1.5)
|
||||
assert result["playback_speed"] == 1.5
|
||||
|
||||
def test_playback_speed_one_not_added(self):
|
||||
result = filter_clip_config({}, playback_speed=1.0)
|
||||
assert "playback_speed" not in result
|
||||
|
||||
def test_playback_speed_none_not_added(self):
|
||||
result = filter_clip_config({}, playback_speed=None)
|
||||
assert "playback_speed" not in result
|
||||
|
||||
def test_playback_speed_config_takes_priority(self):
|
||||
"""clip_config中的playback_speed会覆盖参数传入的(因为update在后面)."""
|
||||
cfg = {"playback_speed": 0.5, "other": "val"}
|
||||
result = filter_clip_config(cfg, playback_speed=2.0)
|
||||
assert result["playback_speed"] == 0.5 # config里的覆盖参数的
|
||||
assert result["other"] == "val"
|
||||
|
||||
def test_custom_skip_keys(self):
|
||||
cfg = {"keep_me": 1, "drop_me": 2, "also_drop": 3}
|
||||
skip = frozenset({"drop_me", "also_drop"})
|
||||
result = filter_clip_config(cfg, skip_keys=skip)
|
||||
assert result == {"keep_me": 1}
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
cfg = {"a": 1, "asset_info": "x"}
|
||||
original = dict(cfg)
|
||||
filter_clip_config(cfg)
|
||||
assert cfg == original # 原dict不变
|
||||
|
||||
|
||||
class TestFilterPlanConfigToTemplate:
|
||||
def test_none_config(self):
|
||||
result = filter_plan_config_to_template(None)
|
||||
assert result == {}
|
||||
|
||||
def test_empty_dict(self):
|
||||
result = filter_plan_config_to_template({})
|
||||
assert result == {}
|
||||
|
||||
def test_keeps_template_fields(self):
|
||||
cfg = {"title": "My Template", "aspect_ratio": "9:16"}
|
||||
result = filter_plan_config_to_template(cfg)
|
||||
assert result == cfg
|
||||
|
||||
def test_filters_runtime_fields(self):
|
||||
cfg = {
|
||||
"title": "T",
|
||||
"is_template_draft": True,
|
||||
"asset_ids": ["a1"],
|
||||
"source_edit_plan_id": "ep1",
|
||||
"generation_task_id": "gt1",
|
||||
}
|
||||
result = filter_plan_config_to_template(cfg)
|
||||
assert "is_template_draft" not in result
|
||||
assert "asset_ids" not in result
|
||||
assert "source_edit_plan_id" not in result
|
||||
assert "generation_task_id" not in result
|
||||
assert result["title"] == "T"
|
||||
|
||||
def test_custom_skip_keys(self):
|
||||
cfg = {"keep": 1, "skip_a": 2, "skip_b": 3}
|
||||
skip = frozenset({"skip_a", "skip_b"})
|
||||
result = filter_plan_config_to_template(cfg, skip_keys=skip)
|
||||
assert result == {"keep": 1}
|
||||
|
||||
|
||||
class TestClipToTemplateClipConfig:
|
||||
@dataclass
|
||||
class FakeClip:
|
||||
clip_type: str = "main"
|
||||
order: int = 0
|
||||
duration: float = 5.0
|
||||
text_content: str = ""
|
||||
transition_effect: str = "cut"
|
||||
playback_speed: float | None = None
|
||||
config: dict | None = None
|
||||
|
||||
def test_basic_conversion(self):
|
||||
clip = self.FakeClip(
|
||||
clip_type="subtitle",
|
||||
order=2,
|
||||
duration=3.5,
|
||||
text_content="Hello",
|
||||
transition_effect="fade",
|
||||
)
|
||||
result = clip_to_template_clip_config("tpl_1", clip)
|
||||
assert isinstance(result, TemplateClipConfig)
|
||||
assert result.template_id == "tpl_1"
|
||||
assert result.clip_type == ClipType.SUBTITLE
|
||||
assert result.order == 2
|
||||
assert result.min_duration == 3.5
|
||||
assert result.max_duration == 3.5
|
||||
assert result.text_template == "Hello"
|
||||
assert result.transition_effect == TransitionEffect.FADE
|
||||
|
||||
def test_duration_fixed_min_max_equal(self):
|
||||
"""转换后 min_duration == max_duration == clip.duration."""
|
||||
clip = self.FakeClip(duration=7.2)
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.min_duration == 7.2
|
||||
assert result.max_duration == 7.2
|
||||
|
||||
def test_zero_duration(self):
|
||||
clip = self.FakeClip(duration=0.0)
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.min_duration == 0.0
|
||||
assert result.max_duration == 0.0
|
||||
|
||||
def test_none_duration_defaults_to_zero(self):
|
||||
clip = self.FakeClip()
|
||||
clip.duration = None # type: ignore
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.min_duration == 0.0
|
||||
assert result.max_duration == 0.0
|
||||
|
||||
def test_empty_text_content_becomes_empty_string(self):
|
||||
clip = self.FakeClip(text_content="")
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.text_template == ""
|
||||
|
||||
def test_none_text_content_becomes_empty_string(self):
|
||||
clip = self.FakeClip()
|
||||
clip.text_content = None # type: ignore
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.text_template == ""
|
||||
|
||||
def test_playback_speed_in_config(self):
|
||||
clip = self.FakeClip(playback_speed=1.5, config={"font": "bold"})
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.config["playback_speed"] == 1.5
|
||||
assert result.config["font"] == "bold"
|
||||
|
||||
def test_playback_speed_one_not_in_config(self):
|
||||
clip = self.FakeClip(playback_speed=1.0)
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert "playback_speed" not in result.config
|
||||
|
||||
def test_config_asset_info_filtered(self):
|
||||
clip = self.FakeClip(config={"text_key": "hi", "asset_info": {"id": "a"}})
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert "asset_info" not in result.config
|
||||
assert result.config["text_key"] == "hi"
|
||||
|
||||
def test_invalid_clip_type_falls_back(self):
|
||||
clip = self.FakeClip(clip_type="invalid_type")
|
||||
result = clip_to_template_clip_config("t1", clip)
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
|
||||
def test_missing_attributes(self):
|
||||
"""对象没有某些属性时使用默认值."""
|
||||
|
||||
class MinimalClip:
|
||||
pass
|
||||
|
||||
result = clip_to_template_clip_config("t1", MinimalClip())
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
assert result.order == 0
|
||||
assert result.min_duration == 0.0
|
||||
assert result.text_template == ""
|
||||
assert result.transition_effect == TransitionEffect.CUT
|
||||
|
||||
|
||||
class TestClipsToTemplateClipConfigs:
|
||||
def test_empty_list(self):
|
||||
result = clips_to_template_clip_configs("t1", [])
|
||||
assert result == []
|
||||
|
||||
def test_multiple_clips(self):
|
||||
clip_a = TestClipToTemplateClipConfig.FakeClip(clip_type="subtitle", order=0, duration=3.0, text_content="A")
|
||||
clip_b = TestClipToTemplateClipConfig.FakeClip(clip_type="title", order=1, duration=5.0, text_content="")
|
||||
result = clips_to_template_clip_configs("t1", [clip_a, clip_b])
|
||||
assert len(result) == 2
|
||||
assert result[0].clip_type == ClipType.SUBTITLE
|
||||
assert result[0].order == 0
|
||||
assert result[1].clip_type == ClipType.TITLE
|
||||
assert result[1].order == 1
|
||||
assert all(isinstance(r, TemplateClipConfig) for r in result)
|
||||
|
||||
|
||||
class TestClipConfigToSnapshot:
|
||||
def test_basic_snapshot(self):
|
||||
cfg = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.SUBTITLE,
|
||||
order=2,
|
||||
min_duration=3.0,
|
||||
max_duration=5.0,
|
||||
text_template="Hello",
|
||||
transition_effect=TransitionEffect.FADE,
|
||||
config={"font_size": 20},
|
||||
)
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["clip_type"] == "subtitle"
|
||||
assert snap["order"] == 2
|
||||
assert snap["min_duration"] == 3.0
|
||||
assert snap["max_duration"] == 5.0
|
||||
assert snap["text_template"] == "Hello"
|
||||
assert snap["transition_effect"] == "fade"
|
||||
assert snap["config"] == {"font_size": 20}
|
||||
|
||||
def test_enum_values_are_strings(self):
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0)
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["clip_type"] == "main"
|
||||
assert isinstance(snap["clip_type"], str)
|
||||
assert snap["transition_effect"] == "cut"
|
||||
assert isinstance(snap["transition_effect"], str)
|
||||
|
||||
def test_config_is_copy_not_reference(self):
|
||||
config = {"key": "val"}
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0, config=config)
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
snap["config"]["key"] = "changed"
|
||||
assert config["key"] == "val" # 原config不变
|
||||
|
||||
def test_empty_config(self):
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0, config={})
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["config"] == {}
|
||||
|
||||
def test_none_text_becomes_empty(self):
|
||||
cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0)
|
||||
cfg.text_template = None # type: ignore
|
||||
snap = clip_config_to_snapshot(cfg)
|
||||
assert snap["text_template"] == ""
|
||||
|
||||
|
||||
class TestClipConfigsToSnapshots:
|
||||
def test_empty_list(self):
|
||||
assert clip_configs_to_snapshots([]) == []
|
||||
|
||||
def test_multiple_configs(self):
|
||||
cfg1 = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.SUBTITLE,
|
||||
order=0,
|
||||
min_duration=2.0,
|
||||
max_duration=2.0,
|
||||
)
|
||||
cfg2 = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.TITLE,
|
||||
order=1,
|
||||
min_duration=3.0,
|
||||
max_duration=3.0,
|
||||
)
|
||||
snaps = clip_configs_to_snapshots([cfg1, cfg2])
|
||||
assert len(snaps) == 2
|
||||
assert snaps[0]["clip_type"] == "subtitle"
|
||||
assert snaps[1]["clip_type"] == "title"
|
||||
|
||||
|
||||
class TestSnapshotToTemplateClipConfig:
|
||||
def test_basic_conversion(self):
|
||||
snap = {
|
||||
"clip_type": "subtitle",
|
||||
"order": 3,
|
||||
"min_duration": 2.5,
|
||||
"max_duration": 4.5,
|
||||
"text_template": "World",
|
||||
"transition_effect": "dissolve",
|
||||
"config": {"color": "blue"},
|
||||
}
|
||||
result = snapshot_to_template_clip_config("tpl_2", snap)
|
||||
assert isinstance(result, TemplateClipConfig)
|
||||
assert result.template_id == "tpl_2"
|
||||
assert result.clip_type == ClipType.SUBTITLE
|
||||
assert result.order == 3
|
||||
assert result.min_duration == 2.5
|
||||
assert result.max_duration == 4.5
|
||||
assert result.text_template == "World"
|
||||
assert result.transition_effect == TransitionEffect.DISSOLVE
|
||||
assert result.config == {"color": "blue"}
|
||||
|
||||
def test_empty_snapshot_uses_defaults(self):
|
||||
result = snapshot_to_template_clip_config("t1", {})
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
assert result.order == 0
|
||||
assert result.min_duration == 0.0
|
||||
assert result.max_duration == 0.0
|
||||
assert result.text_template == ""
|
||||
assert result.transition_effect == TransitionEffect.CUT
|
||||
assert result.config == {}
|
||||
|
||||
def test_invalid_clip_type_defaults(self):
|
||||
snap = {"clip_type": "unknown"}
|
||||
result = snapshot_to_template_clip_config("t1", snap)
|
||||
assert result.clip_type == ClipType.MAIN
|
||||
|
||||
def test_invalid_transition_defaults(self):
|
||||
snap = {"transition_effect": "bad_effect"}
|
||||
result = snapshot_to_template_clip_config("t1", snap)
|
||||
assert result.transition_effect == TransitionEffect.CUT
|
||||
|
||||
def test_none_config_becomes_empty(self):
|
||||
snap = {"config": None}
|
||||
result = snapshot_to_template_clip_config("t1", snap)
|
||||
assert result.config == {}
|
||||
|
||||
|
||||
class TestSnapshotsToTemplateClipConfigs:
|
||||
def test_empty_list(self):
|
||||
result = snapshots_to_template_clip_configs("t1", [])
|
||||
assert result == []
|
||||
|
||||
def test_multiple_snapshots(self):
|
||||
snaps = [
|
||||
{"clip_type": "subtitle", "order": 0, "text_template": "A"},
|
||||
{"clip_type": "title", "order": 1},
|
||||
]
|
||||
result = snapshots_to_template_clip_configs("t1", snaps)
|
||||
assert len(result) == 2
|
||||
assert result[0].clip_type == ClipType.SUBTITLE
|
||||
assert result[0].text_template == "A"
|
||||
assert result[1].clip_type == ClipType.TITLE
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
"""clip → config → snapshot → config 双向转换一致性."""
|
||||
|
||||
def test_snapshot_config_round_trip(self):
|
||||
original = TemplateClipConfig.create(
|
||||
template_id="t1",
|
||||
clip_type=ClipType.SUBTITLE,
|
||||
order=5,
|
||||
min_duration=3.0,
|
||||
max_duration=6.0,
|
||||
text_template="Round trip",
|
||||
transition_effect=TransitionEffect.FADE,
|
||||
config={"key": "value"},
|
||||
)
|
||||
snap = clip_config_to_snapshot(original)
|
||||
restored = snapshot_to_template_clip_config("t1", snap)
|
||||
assert restored.clip_type == original.clip_type
|
||||
assert restored.order == original.order
|
||||
assert restored.min_duration == original.min_duration
|
||||
assert restored.max_duration == original.max_duration
|
||||
assert restored.text_template == original.text_template
|
||||
assert restored.transition_effect == original.transition_effect
|
||||
assert restored.config == original.config
|
||||
|
||||
|
||||
class TestValidateTemplateName:
|
||||
def test_valid_name(self):
|
||||
assert validate_template_name("我的模板") == "我的模板"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert validate_template_name(" Hello ") == "Hello"
|
||||
|
||||
def test_empty_string_raises(self):
|
||||
try:
|
||||
validate_template_name("")
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_whitespace_only_raises(self):
|
||||
try:
|
||||
validate_template_name(" ")
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
def test_none_raises(self):
|
||||
try:
|
||||
validate_template_name(None)
|
||||
except ValueError as e:
|
||||
assert "不能为空" in str(e)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
@@ -1,12 +1,12 @@
|
||||
"""视频拼接引擎纯逻辑单元测试."""
|
||||
"""concat_engine_pure 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from video_processing.concat_engine_pure import (
|
||||
from apps.worker.video_processing.concat_engine_pure import (
|
||||
build_concat_filter,
|
||||
build_fps_filter,
|
||||
build_scale_pad_filter,
|
||||
build_setpts_filter,
|
||||
build_single_segment_filter_chain,
|
||||
calculate_scaled_size,
|
||||
can_use_stream_copy,
|
||||
@@ -20,200 +20,203 @@ from video_processing.concat_engine_pure import (
|
||||
validate_video_path,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 帧率解析测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── parse_fps ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseFps:
|
||||
"""parse_fps 测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert parse_fps(30) == 30.0
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
assert parse_fps(29.97) == pytest.approx(29.97)
|
||||
|
||||
def test_string_integer(self):
|
||||
"""字符串整数."""
|
||||
assert parse_fps("30") == 30.0
|
||||
|
||||
def test_string_fraction(self):
|
||||
"""分数字符串(30/1)."""
|
||||
assert parse_fps("30/1") == 30.0
|
||||
|
||||
def test_fraction_24000_1001(self):
|
||||
"""23.976 帧率."""
|
||||
result = parse_fps("24000/1001")
|
||||
assert result == pytest.approx(23.976, rel=0.01)
|
||||
|
||||
def test_none_input(self):
|
||||
"""None 输入返回默认值."""
|
||||
def test_none_returns_default(self):
|
||||
assert parse_fps(None) == 30.0
|
||||
|
||||
def test_empty_string(self):
|
||||
"""空字符串返回默认值."""
|
||||
assert parse_fps("") == 30.0
|
||||
def test_integer_value(self):
|
||||
assert parse_fps(30) == 30.0
|
||||
assert parse_fps(24) == 24.0
|
||||
|
||||
def test_invalid_string(self):
|
||||
"""无效字符串."""
|
||||
assert parse_fps("abc") == 30.0
|
||||
def test_float_value(self):
|
||||
assert parse_fps(29.97) == 29.97
|
||||
|
||||
def test_string_integer(self):
|
||||
assert parse_fps("30") == 30.0
|
||||
assert parse_fps(" 60 ") == 60.0 # 带空格
|
||||
|
||||
def test_string_fraction(self):
|
||||
assert parse_fps("30/1") == 30.0
|
||||
assert abs(parse_fps("24000/1001") - 23.976) < 0.01
|
||||
|
||||
def test_zero_denominator(self):
|
||||
"""分母为 0."""
|
||||
assert parse_fps("30/0") == 30.0
|
||||
|
||||
def test_empty_string(self):
|
||||
assert parse_fps("") == 30.0
|
||||
assert parse_fps(" ") == 30.0
|
||||
|
||||
def test_invalid_string(self):
|
||||
assert parse_fps("abc") == 30.0
|
||||
assert parse_fps("30fps") == 30.0
|
||||
|
||||
def test_negative_fps(self):
|
||||
"""负帧率."""
|
||||
assert parse_fps(-30) == -30.0
|
||||
|
||||
def test_zero_fps(self):
|
||||
assert parse_fps(0) == 0.0
|
||||
|
||||
|
||||
# ── format_fps_filter ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFormatFpsFilter:
|
||||
"""format_fps_filter 测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert format_fps_filter(30.0) == "fps=30"
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
def test_near_integer_fps(self):
|
||||
# 接近整数时用整数形式(注意:int(fps)是截断不是四舍五入)
|
||||
assert format_fps_filter(30.0001) == "fps=30"
|
||||
assert format_fps_filter(30.0005) == "fps=30" # int(30.0005)=30
|
||||
|
||||
def test_non_integer_fps(self):
|
||||
result = format_fps_filter(23.976)
|
||||
assert result.startswith("fps=")
|
||||
assert "23.976" in result
|
||||
|
||||
def test_float_precision(self):
|
||||
result = format_fps_filter(29.97)
|
||||
assert result.startswith("fps=")
|
||||
assert "29.97" in result
|
||||
# 三位小数
|
||||
parts = result.split("=")[1]
|
||||
assert len(parts.split(".")[1]) == 3
|
||||
|
||||
def test_near_integer(self):
|
||||
"""接近整数."""
|
||||
assert format_fps_filter(30.0001) == "fps=30"
|
||||
def test_one_fps(self):
|
||||
assert format_fps_filter(1.0) == "fps=1"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 输出参数计算测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── resolve_output_params ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveOutputParams:
|
||||
"""resolve_output_params 测试."""
|
||||
|
||||
def test_all_specified(self):
|
||||
"""全部显式指定."""
|
||||
def test_config_specified(self):
|
||||
w, h, fps = resolve_output_params(1920, 1080, 60.0)
|
||||
assert w == 1920
|
||||
assert h == 1080
|
||||
assert fps == 60.0
|
||||
|
||||
def test_no_specified_use_defaults(self):
|
||||
"""全部未指定,用默认值."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0)
|
||||
assert w == 1080
|
||||
assert h == 1920
|
||||
assert fps == 30.0
|
||||
|
||||
def test_use_first_video_info(self):
|
||||
"""用第一段视频信息."""
|
||||
def test_fallback_to_first_video_info(self):
|
||||
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
|
||||
w, h, fps = resolve_output_params(0, 0, 0, info)
|
||||
assert w == 1280
|
||||
assert h == 720
|
||||
assert fps == 24.0
|
||||
|
||||
def test_partial_specified(self):
|
||||
"""部分指定,未指定的用探测值."""
|
||||
def test_fallback_to_defaults(self):
|
||||
w, h, fps = resolve_output_params(0, 0, 0)
|
||||
assert w == 1080 # default_width
|
||||
assert h == 1920 # default_height
|
||||
assert fps == 30.0
|
||||
|
||||
def test_partial_config(self):
|
||||
# 宽度配置了,高度和帧率用探测的
|
||||
info = {"width": 1280, "height": 720, "r_frame_rate": "24/1"}
|
||||
w, h, fps = resolve_output_params(1920, 0, 0, info)
|
||||
assert w == 1920 # 指定的
|
||||
assert h == 720 # 探测的
|
||||
assert w == 1920
|
||||
assert h == 720
|
||||
assert fps == 24.0
|
||||
|
||||
def test_zero_size_clamped(self):
|
||||
"""零尺寸被钳制."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0, {})
|
||||
assert w >= 1
|
||||
assert h >= 1
|
||||
assert fps >= 1.0
|
||||
|
||||
def test_custom_defaults(self):
|
||||
"""自定义默认值."""
|
||||
w, h, fps = resolve_output_params(0, 0, 0, None, 640, 480, 25.0)
|
||||
w, h, fps = resolve_output_params(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
default_width=640,
|
||||
default_height=480,
|
||||
default_fps=25.0,
|
||||
)
|
||||
assert w == 640
|
||||
assert h == 480
|
||||
assert fps == 25.0
|
||||
|
||||
def test_minimum_size(self):
|
||||
w, h, fps = resolve_output_params(0, 0, 0, {"width": 0, "height": 0, "r_frame_rate": "0/1"})
|
||||
assert w >= 1
|
||||
assert h >= 1
|
||||
assert fps >= 1.0
|
||||
|
||||
def test_fps_fraction_in_info(self):
|
||||
info = {"width": 1920, "height": 1080, "r_frame_rate": "24000/1001"}
|
||||
_, _, fps = resolve_output_params(0, 0, 0, info)
|
||||
assert abs(fps - 23.976) < 0.01
|
||||
|
||||
|
||||
# ── calculate_scaled_size ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateScaledSize:
|
||||
"""calculate_scaled_size 测试."""
|
||||
|
||||
def test_same_ratio(self):
|
||||
"""比例相同."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
def test_wider_source(self):
|
||||
"""源更宽,上下填黑边."""
|
||||
def test_wider_source_pad_top_bottom(self):
|
||||
# 源是16:9,目标是9:16竖屏 → 上下填黑边
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 1080, 1920)
|
||||
assert sw == 1080 # 以宽度为准
|
||||
assert sh < 1920 # 高度按比例
|
||||
assert sh == 607 # 1080 * 1080 / 1920 = 607.5 → 607
|
||||
assert ox == 0
|
||||
assert oy > 0 # 垂直居中
|
||||
|
||||
def test_taller_source(self):
|
||||
"""源更高,左右填黑边."""
|
||||
def test_taller_source_pad_left_right(self):
|
||||
# 源是9:16竖屏,目标是16:9横屏 → 左右填黑边
|
||||
sw, sh, ox, oy = calculate_scaled_size(1080, 1920, 1920, 1080)
|
||||
assert sh == 1080 # 以高度为准
|
||||
assert sw < 1920 # 宽度按比例
|
||||
assert sw == 607 # 1080 * 1080 / 1920 = 607.5 → 607
|
||||
assert ox > 0 # 水平居中
|
||||
assert oy == 0
|
||||
|
||||
def test_zero_source(self):
|
||||
"""零尺寸源."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(0, 0, 100, 100)
|
||||
assert sw == 100
|
||||
assert sh == 100
|
||||
|
||||
def test_scale_down(self):
|
||||
"""缩小."""
|
||||
sw, sh, ox, oy = calculate_scaled_size(1920, 1080, 640, 360)
|
||||
assert sw == 640
|
||||
assert sh == 360
|
||||
def test_zero_source_size(self):
|
||||
sw, sh, ox, oy = calculate_scaled_size(0, 0, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
def test_scale_up(self):
|
||||
"""放大."""
|
||||
def test_negative_source_size(self):
|
||||
sw, sh, ox, oy = calculate_scaled_size(-1, -1, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
def test_target_same_ratio_different_size(self):
|
||||
# 比例相同,尺寸不同 → 直接缩放到目标大小
|
||||
sw, sh, ox, oy = calculate_scaled_size(640, 360, 1920, 1080)
|
||||
assert sw == 1920
|
||||
assert sh == 1080
|
||||
assert ox == 0
|
||||
assert oy == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# stream copy 判断测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── can_use_stream_copy ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCanUseStreamCopy:
|
||||
"""can_use_stream_copy 测试."""
|
||||
def test_force_reencode_false(self):
|
||||
assert can_use_stream_copy([], 1920, 1080, 30.0, force_reencode=True) is False
|
||||
|
||||
def test_identical_segments(self):
|
||||
"""所有段参数相同,可以 stream copy."""
|
||||
def test_empty_segments(self):
|
||||
assert can_use_stream_copy([], 1920, 1080, 30.0) is False
|
||||
|
||||
def test_single_segment_matching_params(self):
|
||||
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
|
||||
|
||||
def test_multiple_segments_same_params(self):
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
|
||||
|
||||
def test_force_reencode(self):
|
||||
"""强制重编码."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0, force_reencode=True) is False
|
||||
|
||||
def test_different_codec(self):
|
||||
"""编码不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "hevc", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
@@ -221,7 +224,6 @@ class TestCanUseStreamCopy:
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_different_resolution(self):
|
||||
"""分辨率不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1280, "height": 720, "r_frame_rate": "30/1"},
|
||||
@@ -229,306 +231,349 @@ class TestCanUseStreamCopy:
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_different_fps(self):
|
||||
"""帧率不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "60/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is False
|
||||
|
||||
def test_target_differs(self):
|
||||
"""目标参数与源不同."""
|
||||
segs = [
|
||||
{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"},
|
||||
]
|
||||
assert can_use_stream_copy(segs, 1280, 720, 30.0) is False
|
||||
|
||||
def test_empty_segments(self):
|
||||
"""空列表."""
|
||||
assert can_use_stream_copy([], 1920, 1080, 30.0) is False
|
||||
|
||||
def test_single_segment(self):
|
||||
"""单段."""
|
||||
def test_target_differs_from_source(self):
|
||||
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1"}]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 30.0) is True
|
||||
# 目标分辨率不同
|
||||
assert can_use_stream_copy(segs, 1280, 720, 30.0) is False
|
||||
# 目标帧率不同
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 60.0) is False
|
||||
|
||||
def test_fps_fraction_match(self):
|
||||
segs = [{"codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "24000/1001"}]
|
||||
assert can_use_stream_copy(segs, 1920, 1080, 23.976) is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 文件列表生成测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── generate_concat_file_list ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerateConcatFileList:
|
||||
"""generate_concat_file_list 测试."""
|
||||
|
||||
def test_single_file(self):
|
||||
"""单个文件."""
|
||||
result = generate_concat_file_list(["/a.mp4"])
|
||||
assert "file '/a.mp4'" in result
|
||||
assert result.endswith("\n")
|
||||
result = generate_concat_file_list(["/tmp/video.mp4"])
|
||||
assert result == "file '/tmp/video.mp4'\n"
|
||||
|
||||
def test_multiple_files(self):
|
||||
"""多个文件."""
|
||||
result = generate_concat_file_list(["/a.mp4", "/b.mp4", "/c.mp4"])
|
||||
lines = result.strip().split("\n")
|
||||
assert len(lines) == 3
|
||||
assert lines[0] == "file '/a.mp4'"
|
||||
assert lines[1] == "file '/b.mp4'"
|
||||
assert lines[2] == "file '/c.mp4'"
|
||||
assert result.endswith("\n")
|
||||
|
||||
def test_escapes_single_quotes(self):
|
||||
result = generate_concat_file_list(["/path/with'quote.mp4"])
|
||||
# 单引号转义: '\''
|
||||
assert "'\\''" in result
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
result = generate_concat_file_list([])
|
||||
assert result == "\n"
|
||||
|
||||
def test_path_with_single_quote(self):
|
||||
"""路径包含单引号(转义)."""
|
||||
result = generate_concat_file_list(["/path/to/file's.mp4"])
|
||||
# 单引号应该被转义
|
||||
assert "'\\''" in result or file
|
||||
assert "file '" in result
|
||||
|
||||
def test_path_with_spaces(self):
|
||||
"""路径包含空格."""
|
||||
result = generate_concat_file_list(["/path/to/my video.mp4"])
|
||||
assert "my video" in result
|
||||
result = generate_concat_file_list(["/path/to/video file.mp4"])
|
||||
assert "file '/path/to/video file.mp4'" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 滤镜构建测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── build_scale_pad_filter ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildScalePadFilter:
|
||||
"""scale+pad 滤镜测试."""
|
||||
|
||||
def test_contains_scale(self):
|
||||
"""包含 scale."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "scale=" in result
|
||||
|
||||
def test_contains_pad(self):
|
||||
"""包含 pad."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "pad=" in result
|
||||
assert "1920:1080" in result
|
||||
|
||||
def test_force_original_aspect_ratio(self):
|
||||
"""保持宽高比."""
|
||||
def test_basic_filter(self):
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert "scale=1920:1080" in result
|
||||
assert "force_original_aspect_ratio=decrease" in result
|
||||
assert "pad=1920:1080" in result
|
||||
assert "black" in result
|
||||
assert "(ow-iw)/2" in result
|
||||
assert "(oh-ih)/2" in result
|
||||
|
||||
def test_black_padding(self):
|
||||
"""黑边填充."""
|
||||
result = build_scale_pad_filter(1920, 1080)
|
||||
assert ":black" in result
|
||||
def test_different_resolution(self):
|
||||
result = build_scale_pad_filter(1080, 1920)
|
||||
assert "scale=1080:1920" in result
|
||||
assert "pad=1080:1920" in result
|
||||
|
||||
def test_ignores_source_size(self):
|
||||
# src_w/src_h 目前不影响输出,都是用表达式
|
||||
result1 = build_scale_pad_filter(1920, 1080)
|
||||
result2 = build_scale_pad_filter(1920, 1080, src_w=1280, src_h=720)
|
||||
assert result1 == result2
|
||||
|
||||
|
||||
# ── build_fps_filter ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildFpsFilter:
|
||||
"""fps 滤镜测试."""
|
||||
|
||||
def test_integer_fps(self):
|
||||
"""整数帧率."""
|
||||
assert build_fps_filter(30.0) == "fps=30"
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率."""
|
||||
result = build_fps_filter(29.97)
|
||||
assert result.startswith("fps=")
|
||||
|
||||
|
||||
class TestBuildConcatFilter:
|
||||
"""concat 滤镜测试."""
|
||||
# ── build_setpts_filter ─────────────────────────────────────────────────────
|
||||
|
||||
def test_two_inputs_with_audio(self):
|
||||
"""两路输入,有音频."""
|
||||
result = build_concat_filter(2, has_audio=True)
|
||||
assert "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1" in result
|
||||
|
||||
class TestBuildSetptsFilter:
|
||||
def test_returns_correct_string(self):
|
||||
assert build_setpts_filter() == "setpts=PTS-STARTPTS"
|
||||
|
||||
|
||||
# ── build_concat_filter ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildConcatFilter:
|
||||
def test_zero_inputs(self):
|
||||
assert build_concat_filter(0) == ""
|
||||
|
||||
def test_single_input_with_audio(self):
|
||||
result = build_concat_filter(1)
|
||||
assert "[0:v][0:a]" in result
|
||||
assert "concat=n=1:v=1:a=1" in result
|
||||
assert "[concat_v][concat_a]" in result
|
||||
|
||||
def test_three_inputs_video_only(self):
|
||||
"""三路输入,无音频."""
|
||||
result = build_concat_filter(3, has_audio=False)
|
||||
assert "[0:v][1:v][2:v]concat=n=3:v=1:a=0" in result
|
||||
def test_single_input_no_audio(self):
|
||||
result = build_concat_filter(1, has_audio=False)
|
||||
assert "[0:v]" in result
|
||||
assert "concat=n=1:v=1:a=0" in result
|
||||
assert "[concat_v]" in result
|
||||
assert "[concat_a]" not in result
|
||||
|
||||
def test_single_input(self):
|
||||
"""单路输入."""
|
||||
result = build_concat_filter(1, has_audio=True)
|
||||
assert "[0:v][0:a]concat=n=1:v=1:a=1" in result
|
||||
def test_multiple_inputs_with_audio(self):
|
||||
result = build_concat_filter(3)
|
||||
assert "[0:v][0:a][1:v][1:a][2:v][2:a]" in result
|
||||
assert "concat=n=3:v=1:a=1" in result
|
||||
|
||||
def test_zero_inputs(self):
|
||||
"""零输入."""
|
||||
assert build_concat_filter(0) == ""
|
||||
def test_multiple_inputs_no_audio(self):
|
||||
result = build_concat_filter(3, has_audio=False)
|
||||
assert "[0:v][1:v][2:v]" in result
|
||||
assert "concat=n=3:v=1:a=0" in result
|
||||
|
||||
def test_negative_inputs(self):
|
||||
assert build_concat_filter(-1) == ""
|
||||
|
||||
|
||||
# ── build_single_segment_filter_chain ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildSingleSegmentFilterChain:
|
||||
"""单段滤镜链测试."""
|
||||
|
||||
def test_with_audio(self):
|
||||
"""有音频."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 0)
|
||||
assert "scale=" in result
|
||||
assert "fps=" in result
|
||||
assert "setpts=PTS-STARTPTS" in result
|
||||
assert "asetpts=PTS-STARTPTS" in result
|
||||
# 视频链
|
||||
assert "[0:v]" in result
|
||||
assert "[v0]" in result
|
||||
assert "scale=1920:1080" in result
|
||||
assert "fps=30" in result
|
||||
assert "setpts=PTS-STARTPTS" in result
|
||||
# 音频链
|
||||
assert "[0:a]" in result
|
||||
assert "[a0]" in result
|
||||
assert "asetpts=PTS-STARTPTS" in result
|
||||
# 用分号分隔
|
||||
assert ";" in result
|
||||
|
||||
def test_video_only(self):
|
||||
"""无音频."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 1, has_audio=False)
|
||||
assert "scale=" in result
|
||||
assert "setpts=" in result
|
||||
assert "asetpts" not in result
|
||||
assert "[v1]" in result
|
||||
def test_without_audio(self):
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 2, has_audio=False)
|
||||
assert "[2:v]" in result
|
||||
assert "[v2]" in result
|
||||
assert "[2:a]" not in result
|
||||
assert ";" not in result # 没有音频就没有分号
|
||||
|
||||
def test_segment_index_in_labels(self):
|
||||
"""段索引在标签中."""
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, 5)
|
||||
assert "[5:v]" in result
|
||||
assert "[v5]" in result
|
||||
def test_segment_index_propagated(self):
|
||||
for idx in [0, 5, 10]:
|
||||
result = build_single_segment_filter_chain(1920, 1080, 30.0, idx)
|
||||
assert f"[{idx}:v]" in result
|
||||
assert f"[v{idx}]" in result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 配置验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── validate_concat_config ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateConcatConfig:
|
||||
"""配置验证测试."""
|
||||
|
||||
def test_valid_config(self):
|
||||
"""合法配置."""
|
||||
config = {
|
||||
"segments": [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}],
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4"},
|
||||
{"video_path": "/b.mp4"},
|
||||
],
|
||||
"output_width": 1920,
|
||||
"output_height": 1080,
|
||||
"output_fps": 30,
|
||||
}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is True
|
||||
assert len(errors) == 0
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is True
|
||||
assert errors == []
|
||||
|
||||
def test_no_segments(self):
|
||||
valid, errors = validate_concat_config({})
|
||||
assert valid is False
|
||||
assert any("至少需要一个" in e for e in errors)
|
||||
|
||||
def test_empty_segments(self):
|
||||
"""空段列表."""
|
||||
ok, errors = validate_concat_config({"segments": []})
|
||||
assert ok is False
|
||||
assert any("至少需要" in e or "视频段" in e for e in errors)
|
||||
valid, errors = validate_concat_config({"segments": []})
|
||||
assert valid is False
|
||||
assert len(errors) >= 1
|
||||
|
||||
def test_missing_video_path(self):
|
||||
"""缺少 video_path."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}, {}]}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
config = {"segments": [{"video_path": ""}]}
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is False
|
||||
assert any("video_path" in e for e in errors)
|
||||
|
||||
def test_negative_width(self):
|
||||
"""负宽度."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -100}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
def test_multiple_missing_paths(self):
|
||||
config = {
|
||||
"segments": [
|
||||
{"video_path": "/a.mp4"},
|
||||
{"video_path": ""},
|
||||
{"video_path": ""},
|
||||
]
|
||||
}
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is False
|
||||
path_errors = [e for e in errors if "video_path" in e]
|
||||
assert len(path_errors) == 2
|
||||
|
||||
def test_negative_output_width(self):
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_width": -1}
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is False
|
||||
assert any("output_width" in e for e in errors)
|
||||
|
||||
def test_negative_height(self):
|
||||
"""负高度."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -100}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
def test_negative_output_height(self):
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_height": -1}
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is False
|
||||
assert any("output_height" in e for e in errors)
|
||||
|
||||
def test_negative_fps(self):
|
||||
"""负帧率."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -30}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is False
|
||||
def test_negative_output_fps(self):
|
||||
config = {"segments": [{"video_path": "/a.mp4"}], "output_fps": -1}
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is False
|
||||
assert any("output_fps" in e for e in errors)
|
||||
|
||||
def test_zero_output_params_ok(self):
|
||||
"""零输出参数合法(表示自动探测)."""
|
||||
config = {"segments": [{"video_path": "/a.mp4"}]}
|
||||
ok, errors = validate_concat_config(config)
|
||||
assert ok is True
|
||||
def test_zero_output_params_valid(self):
|
||||
# 0值表示未指定,是合法的
|
||||
config = {
|
||||
"segments": [{"video_path": "/a.mp4"}],
|
||||
"output_width": 0,
|
||||
"output_height": 0,
|
||||
"output_fps": 0,
|
||||
}
|
||||
valid, errors = validate_concat_config(config)
|
||||
assert valid is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 路径验证测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── validate_video_path ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateVideoPath:
|
||||
"""视频路径验证测试."""
|
||||
|
||||
def test_empty_path(self):
|
||||
"""空路径."""
|
||||
ok, msg = validate_video_path("", "/work")
|
||||
assert ok is False
|
||||
assert "不能为空" in msg
|
||||
valid, err = validate_video_path("", "/work")
|
||||
assert valid is False
|
||||
assert "不能为空" in err
|
||||
|
||||
def test_path_traversal(self):
|
||||
"""路径遍历."""
|
||||
ok, msg = validate_video_path("../etc/passwd", "/work")
|
||||
assert ok is False
|
||||
assert "回溯" in msg or ".." in msg
|
||||
def test_relative_path_valid(self):
|
||||
valid, err = validate_video_path("video.mp4", "/work")
|
||||
assert valid is True
|
||||
assert err == ""
|
||||
|
||||
def test_valid_relative_path(self):
|
||||
"""相对路径(不检查边界)."""
|
||||
ok, msg = validate_video_path("video.mp4", "/work")
|
||||
assert ok is True
|
||||
def test_relative_path_with_subdir(self):
|
||||
valid, err = validate_video_path("sub/video.mp4", "/work")
|
||||
assert valid is True
|
||||
|
||||
def test_valid_absolute_path(self):
|
||||
"""绝对路径在工作目录内."""
|
||||
ok, msg = validate_video_path("/work/sub/video.mp4", "/work")
|
||||
assert ok is True
|
||||
def test_path_traversal_rejected(self):
|
||||
valid, err = validate_video_path("../secret.mp4", "/work")
|
||||
assert valid is False
|
||||
assert ".." in err
|
||||
|
||||
def test_path_outside_work_dir(self):
|
||||
"""路径在工作目录外."""
|
||||
ok, msg = validate_video_path("/etc/passwd", "/work")
|
||||
assert ok is False
|
||||
assert "工作目录" in msg
|
||||
def test_nested_path_traversal_rejected(self):
|
||||
valid, err = validate_video_path("sub/../../secret.mp4", "/work")
|
||||
assert valid is False
|
||||
|
||||
def test_absolute_path_inside_workdir(self):
|
||||
valid, err = validate_video_path("/work/sub/video.mp4", "/work")
|
||||
assert valid is True
|
||||
|
||||
def test_absolute_path_outside_workdir(self):
|
||||
valid, err = validate_video_path("/etc/passwd", "/work")
|
||||
assert valid is False
|
||||
assert "工作目录内" in err
|
||||
|
||||
def test_path_object_input(self):
|
||||
valid, err = validate_video_path(Path("video.mp4"), Path("/work"))
|
||||
assert valid is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 工具函数测试
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ── estimate_total_duration ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEstimateTotalDuration:
|
||||
"""总时长估算测试."""
|
||||
def test_single_segment(self):
|
||||
assert estimate_total_duration([{"duration": 10.5}]) == 10.5
|
||||
|
||||
def test_multiple_segments(self):
|
||||
"""多段视频."""
|
||||
segs = [{"duration": 10}, {"duration": 20.5}, {"duration": 5}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(35.5)
|
||||
segs = [
|
||||
{"duration": 10},
|
||||
{"duration": 20.5},
|
||||
{"duration": 5.5},
|
||||
]
|
||||
assert estimate_total_duration(segs) == 36.0
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert estimate_total_duration([]) == 0.0
|
||||
|
||||
def test_invalid_duration_skipped(self):
|
||||
"""无效时长跳过."""
|
||||
segs = [{"duration": 10}, {"duration": "abc"}, {"duration": 20}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(30.0)
|
||||
def test_missing_duration_field(self):
|
||||
segs = [{"path": "a.mp4"}, {"duration": 10}]
|
||||
assert estimate_total_duration(segs) == 10.0
|
||||
|
||||
def test_missing_duration(self):
|
||||
"""缺 duration 字段."""
|
||||
segs = [{}, {"duration": 10}]
|
||||
assert estimate_total_duration(segs) == pytest.approx(10.0)
|
||||
def test_invalid_duration_skipped(self):
|
||||
segs = [
|
||||
{"duration": 10},
|
||||
{"duration": "abc"},
|
||||
{"duration": 20},
|
||||
]
|
||||
assert estimate_total_duration(segs) == 30.0
|
||||
|
||||
def test_string_duration(self):
|
||||
segs = [{"duration": "15.5"}]
|
||||
assert estimate_total_duration(segs) == 15.5
|
||||
|
||||
def test_negative_duration(self):
|
||||
segs = [{"duration": -5}]
|
||||
assert estimate_total_duration(segs) == -5.0
|
||||
|
||||
|
||||
# ── count_valid_segments ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCountValidSegments:
|
||||
"""有效段统计测试."""
|
||||
|
||||
def test_all_valid(self):
|
||||
"""全部有效."""
|
||||
segs = [{"video_path": "/a.mp4"}, {"video_path": "/b.mp4"}]
|
||||
segs = [
|
||||
{"video_path": "/a.mp4"},
|
||||
{"video_path": "/b.mp4"},
|
||||
]
|
||||
assert count_valid_segments(segs) == 2
|
||||
|
||||
def test_some_invalid(self):
|
||||
"""部分无效."""
|
||||
segs = [{"video_path": "/a.mp4"}, {}, {"video_path": ""}]
|
||||
assert count_valid_segments(segs) == 1
|
||||
segs = [
|
||||
{"video_path": "/a.mp4"},
|
||||
{"video_path": ""},
|
||||
{"video_path": "/c.mp4"},
|
||||
]
|
||||
assert count_valid_segments(segs) == 2
|
||||
|
||||
def test_none_valid(self):
|
||||
segs = [
|
||||
{"video_path": ""},
|
||||
{"other_field": "x"},
|
||||
]
|
||||
assert count_valid_segments(segs) == 0
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表."""
|
||||
assert count_valid_segments([]) == 0
|
||||
|
||||
+413
-699
File diff suppressed because it is too large
Load Diff
Executable
+453
@@ -0,0 +1,453 @@
|
||||
"""shared.ai_service 单元测试.
|
||||
|
||||
主要测试纯逻辑部分:_parse_recommend_response / _fallback_recommend_clips / _call_ai_cover_service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from shared.ai_service import (
|
||||
_call_ai_cover_service,
|
||||
_fallback_recommend_clips,
|
||||
_parse_recommend_response,
|
||||
)
|
||||
|
||||
# ── _parse_recommend_response 测试 ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseRecommendResponseBasic:
|
||||
"""基础解析测试."""
|
||||
|
||||
def test_parse_valid_json(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [
|
||||
{
|
||||
"clip_type": "intro",
|
||||
"order": 0,
|
||||
"text_content": "开场",
|
||||
"duration": 3.0,
|
||||
"transition_effect": "fade",
|
||||
"asset_id": "asset1",
|
||||
"start_time": 0.0,
|
||||
"config": {},
|
||||
},
|
||||
{
|
||||
"clip_type": "outro",
|
||||
"order": 1,
|
||||
"text_content": "结尾",
|
||||
"duration": 2.0,
|
||||
"transition_effect": "fade",
|
||||
"asset_id": "",
|
||||
"start_time": 0.0,
|
||||
"config": {},
|
||||
},
|
||||
],
|
||||
"title": "测试视频",
|
||||
"confidence": 0.85,
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["asset1"], 30.0)
|
||||
assert result is not None
|
||||
assert len(result["clips"]) == 2
|
||||
assert result["confidence"] == 0.85
|
||||
assert result["total_duration"] == 5.0
|
||||
assert result["config"]["title"]["text"] == "测试视频"
|
||||
assert result["config"]["title"]["ai_auto"] is True
|
||||
|
||||
def test_parse_none_returns_none(self):
|
||||
result = _parse_recommend_response(None, ["a1"], 30.0) # type: ignore[arg-type]
|
||||
assert result is None
|
||||
|
||||
def test_parse_empty_string_returns_none(self):
|
||||
result = _parse_recommend_response("", ["a1"], 30.0)
|
||||
assert result is None
|
||||
|
||||
def test_parse_whitespace_only_returns_none(self):
|
||||
result = _parse_recommend_response(" ", ["a1"], 30.0)
|
||||
assert result is None
|
||||
|
||||
def test_parse_invalid_json_returns_none(self):
|
||||
result = _parse_recommend_response("not json", ["a1"], 30.0)
|
||||
assert result is None
|
||||
|
||||
def test_parse_non_dict_json_returns_none(self):
|
||||
result = _parse_recommend_response("[1, 2, 3]", ["a1"], 30.0)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestParseRecommendResponseClips:
|
||||
"""clips 解析测试."""
|
||||
|
||||
def test_parse_no_clips_returns_none(self):
|
||||
content = json.dumps({"title": "test", "clips": []})
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is None
|
||||
|
||||
def test_parse_clips_not_list_returns_none(self):
|
||||
content = json.dumps({"clips": "not a list"})
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is None
|
||||
|
||||
def test_parse_clips_sorted_by_order(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [
|
||||
{"clip_type": "outro", "order": 2, "duration": 2, "asset_id": "a1"},
|
||||
{"clip_type": "intro", "order": 0, "duration": 3, "asset_id": "a1"},
|
||||
{"clip_type": "showcase", "order": 1, "duration": 5, "asset_id": "a1"},
|
||||
],
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
assert len(result["clips"]) == 3
|
||||
assert result["clips"][0]["clip_type"] == "intro"
|
||||
assert result["clips"][1]["clip_type"] == "showcase"
|
||||
assert result["clips"][2]["clip_type"] == "outro"
|
||||
|
||||
def test_parse_clips_renumbered_continuously(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [
|
||||
{"clip_type": "intro", "order": 10, "duration": 2, "asset_id": "a1"},
|
||||
{"clip_type": "outro", "order": 20, "duration": 2, "asset_id": "a1"},
|
||||
],
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
assert result["clips"][0]["order"] == 0
|
||||
assert result["clips"][1]["order"] == 1
|
||||
|
||||
def test_parse_skips_invalid_clip_dicts(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [
|
||||
{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"},
|
||||
"not a dict",
|
||||
{"clip_type": "outro", "order": 2, "duration": 2, "asset_id": "a1"},
|
||||
],
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
assert len(result["clips"]) == 2
|
||||
|
||||
|
||||
class TestParseRecommendResponseFields:
|
||||
"""各字段解析与边界测试."""
|
||||
|
||||
def test_parse_duration_clamped_min(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [
|
||||
{"clip_type": "intro", "order": 0, "duration": 0.5, "asset_id": "a1"},
|
||||
],
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
assert result["clips"][0]["duration"] == 1.0
|
||||
|
||||
def test_parse_duration_clamped_max(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [
|
||||
{"clip_type": "intro", "order": 0, "duration": 100, "asset_id": "a1"},
|
||||
],
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
assert result["clips"][0]["duration"] == 30.0
|
||||
|
||||
def test_parse_start_time_clamped_min(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [
|
||||
{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1", "start_time": -5.0},
|
||||
],
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
assert result["clips"][0]["start_time"] == 0.0
|
||||
|
||||
def test_parse_asset_id_not_in_list_empty(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [
|
||||
{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "unknown_asset"},
|
||||
],
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1", "a2"], 30.0)
|
||||
assert result is not None
|
||||
assert result["clips"][0]["asset_id"] == ""
|
||||
|
||||
def test_parse_asset_id_in_list_kept(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [
|
||||
{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a2"},
|
||||
],
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1", "a2"], 30.0)
|
||||
assert result is not None
|
||||
assert result["clips"][0]["asset_id"] == "a2"
|
||||
|
||||
def test_parse_default_values(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [
|
||||
{"order": 0},
|
||||
],
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
clip = result["clips"][0]
|
||||
assert clip["clip_type"] == "showcase"
|
||||
assert clip["text_content"] == ""
|
||||
assert clip["duration"] == 3.0
|
||||
assert clip["transition_effect"] == "cut"
|
||||
assert clip["asset_id"] == ""
|
||||
assert clip["start_time"] == 0.0
|
||||
assert clip["config"] == {}
|
||||
|
||||
|
||||
class TestParseRecommendResponseMarkdown:
|
||||
"""Markdown 代码块包裹的 JSON 测试."""
|
||||
|
||||
def test_parse_markdown_json(self):
|
||||
content = (
|
||||
"```json\n"
|
||||
+ json.dumps(
|
||||
{
|
||||
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
|
||||
"title": "md test",
|
||||
}
|
||||
)
|
||||
+ "\n```"
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
assert len(result["clips"]) == 1
|
||||
assert result["config"]["title"]["text"] == "md test"
|
||||
|
||||
def test_parse_backticks_no_language(self):
|
||||
content = (
|
||||
"```\n"
|
||||
+ json.dumps(
|
||||
{
|
||||
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
|
||||
}
|
||||
)
|
||||
+ "\n```"
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
assert len(result["clips"]) == 1
|
||||
|
||||
|
||||
class TestParseRecommendResponseConfidence:
|
||||
"""confidence 解析测试."""
|
||||
|
||||
def test_parse_confidence_normal(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
|
||||
"confidence": 0.85,
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
assert result["confidence"] == 0.85
|
||||
|
||||
def test_parse_confidence_clamped_min(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
|
||||
"confidence": -0.5,
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
assert result["confidence"] == 0.0
|
||||
|
||||
def test_parse_confidence_clamped_max(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
|
||||
"confidence": 1.5,
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
assert result["confidence"] == 1.0
|
||||
|
||||
def test_parse_confidence_default(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
assert result["confidence"] == 0.7
|
||||
|
||||
|
||||
class TestParseRecommendResponseConfig:
|
||||
"""config 生成测试."""
|
||||
|
||||
def test_parse_no_title_no_ai_auto(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
# 没有 title 时,config 的 title.text 保持默认(DEFAULT_EDIT_PLAN_CONFIG 中的值)
|
||||
assert "title" in result["config"]
|
||||
|
||||
def test_parse_config_is_deep_copy(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [{"clip_type": "intro", "order": 0, "duration": 2, "asset_id": "a1"}],
|
||||
"title": "test",
|
||||
}
|
||||
)
|
||||
result1 = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
result2 = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
# 修改其中一个不影响另一个
|
||||
result1["config"]["title"]["text"] = "modified"
|
||||
assert result2["config"]["title"]["text"] != "modified"
|
||||
|
||||
|
||||
class TestParseRecommendResponseTotalDuration:
|
||||
"""total_duration 计算测试."""
|
||||
|
||||
def test_parse_total_duration_sum(self):
|
||||
content = json.dumps(
|
||||
{
|
||||
"clips": [
|
||||
{"clip_type": "intro", "order": 0, "duration": 3.5, "asset_id": "a1"},
|
||||
{"clip_type": "showcase", "order": 1, "duration": 5.2, "asset_id": "a1"},
|
||||
{"clip_type": "outro", "order": 2, "duration": 2.0, "asset_id": "a1"},
|
||||
],
|
||||
}
|
||||
)
|
||||
result = _parse_recommend_response(content, ["a1"], 30.0)
|
||||
assert result is not None
|
||||
assert result["total_duration"] == pytest.approx(10.7, abs=0.01)
|
||||
|
||||
|
||||
# ── _fallback_recommend_clips 测试 ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFallbackRecommendClips:
|
||||
"""本地降级推荐方案测试."""
|
||||
|
||||
def test_fallback_returns_dict_with_clips(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
|
||||
assert "clips" in result
|
||||
assert "config" in result
|
||||
assert "total_duration" in result
|
||||
assert "confidence" in result
|
||||
|
||||
def test_fallback_has_intro_and_outro(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
|
||||
clips = result["clips"]
|
||||
assert clips[0]["clip_type"] == "intro"
|
||||
assert clips[-1]["clip_type"] == "outro"
|
||||
|
||||
def test_fallback_showcase_count_matches_assets(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0)
|
||||
showcase_clips = [c for c in result["clips"] if c["clip_type"] == "showcase"]
|
||||
assert len(showcase_clips) == 3
|
||||
|
||||
def test_fallback_no_assets_still_works(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _fallback_recommend_clips("plan1", "tmpl1", [], "one_take", 30.0)
|
||||
assert len(result["clips"]) >= 2 # 至少有intro和outro
|
||||
|
||||
def test_fallback_intro_uses_first_asset(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
|
||||
assert result["clips"][0]["asset_id"] == "a1"
|
||||
|
||||
def test_fallback_outro_has_empty_asset(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1"], "one_take", 30.0)
|
||||
assert result["clips"][-1]["asset_id"] == ""
|
||||
|
||||
def test_fallback_confidence_in_range(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1"], "one_take", 30.0)
|
||||
assert 0.75 <= result["confidence"] <= 0.95
|
||||
|
||||
def test_fallback_title_contains_asset_count(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0)
|
||||
assert "3" in result["config"]["title"]["text"]
|
||||
assert result["config"]["title"]["ai_auto"] is True
|
||||
|
||||
def test_fallback_total_duration_matches(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2"], "one_take", 30.0)
|
||||
total = sum(c["duration"] for c in result["clips"])
|
||||
assert result["total_duration"] == round(total, 1)
|
||||
|
||||
def test_fallback_orders_are_sequential(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _fallback_recommend_clips("plan1", "tmpl1", ["a1", "a2", "a3"], "one_take", 30.0)
|
||||
orders = [c["order"] for c in result["clips"]]
|
||||
assert orders == list(range(len(result["clips"])))
|
||||
|
||||
|
||||
# ── _call_ai_cover_service 测试 ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAiCoverService:
|
||||
"""AI封面生成服务测试."""
|
||||
|
||||
def test_cover_type_upload(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _call_ai_cover_service("plan1", ["a1"], "upload")
|
||||
assert result["type"] == "upload"
|
||||
assert result["image_url"] == ""
|
||||
|
||||
def test_cover_type_manual_with_frame_time(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _call_ai_cover_service("plan1", ["a1"], "manual", frame_time=5.5)
|
||||
assert result["type"] == "manual"
|
||||
assert result["frame_time"] == 5.5
|
||||
assert "5.5" in result["image_url"]
|
||||
|
||||
def test_cover_type_ai_frame(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
with patch("shared.ai_service.random.uniform", side_effect=[5.0, 0.9]):
|
||||
result = _call_ai_cover_service("plan1", ["a1"], "ai_frame")
|
||||
assert result["type"] == "ai_frame"
|
||||
assert result["frame_time"] == 5.0
|
||||
assert result["confidence"] == 0.9
|
||||
assert "plan1" in result["image_url"]
|
||||
|
||||
def test_cover_type_ai_regenerate(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _call_ai_cover_service("plan1", ["a1"], "ai_regenerate")
|
||||
assert result["type"] == "ai_frame"
|
||||
|
||||
def test_cover_frame_time_in_range(self):
|
||||
with patch("shared.ai_service.time.sleep"):
|
||||
result = _call_ai_cover_service("plan1", ["a1"], "ai_frame")
|
||||
assert 1.0 <= result["frame_time"] <= 10.0
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user