test(wave122): 抽离trim_config领域模型 + 51单测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m32s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m2s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m10s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 40s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
AI Code Review / AI Code Review (pull_request) Successful in 4m11s
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request) [backend-only]
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m22s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m5s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1221h25m10s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1221h26m45s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1221h26m45s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1221h26m47s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1221h26m49s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1223h28m25s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1223h28m29s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1224h1m13s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m32s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m2s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m10s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 40s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 47s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m12s
AI Code Review / AI Code Review (pull_request) Successful in 4m11s
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request) [backend-only]
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m22s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m5s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1221h25m10s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1221h26m45s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1221h26m45s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1221h26m47s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1221h26m49s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1223h28m25s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1223h28m29s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1224h1m13s
- 抽离 TrimConfig/TrimSegment 到 packages/domain/trim_config.py - 三选二推导 + 边界钳制 + 有效性判断纯逻辑全覆盖 - trim_engine.py 保留薄包装 + 滤镜构建,向后兼容 - 51个单测全绿,原有55个trim测试无回归
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
- 边界自动钳制(超出素材时长自动修正,不阻断渲染)
|
||||
- 多段裁剪(一个素材裁剪出多段)
|
||||
- 音画同步(视频 + 音频同步裁剪)
|
||||
|
||||
领域模型已抽离到 packages/domain/trim_config.py,本模块保留薄包装。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,157 +15,50 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.trim_config import ( # noqa: F401 — 向后兼容
|
||||
MIN_TRIM_DURATION as _min_trim_duration_base,
|
||||
TrimConfig,
|
||||
TrimSegment,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 最小裁剪时长(秒),低于此值视为无效
|
||||
MIN_TRIM_DURATION = 0.1
|
||||
# 向后兼容:MIN_TRIM_DURATION 从 domain 层导出
|
||||
MIN_TRIM_DURATION = _min_trim_duration_base
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrimConfig:
|
||||
"""裁剪配置.
|
||||
# 向后兼容:extract_trim_from_clip_config 保留在本模块
|
||||
def extract_trim_from_clip_config(config: dict[str, Any] | None) -> TrimConfig | None:
|
||||
"""从 clip.config 中提取裁剪配置.
|
||||
|
||||
三选二规则:start_time / end_time / duration 中必须至少给出两个,
|
||||
第三个会被自动推导。如果三个都给了,以 start_time + duration 为准。
|
||||
|
||||
边界保护:
|
||||
- start_time < 0 → 钳制到 0
|
||||
- end_time > 素材时长 → 钳制到素材时长
|
||||
- 计算出的 duration < 最小阈值 → 标记为无效
|
||||
兼容多种字段命名:
|
||||
- trim: { start_time, end_time, duration }
|
||||
- 直接使用 start_time / end_time / duration 字段
|
||||
"""
|
||||
if not config:
|
||||
return None
|
||||
|
||||
start_time: float = 0.0 # 入点(素材内时间,秒)
|
||||
end_time: float = 0.0 # 出点(素材内时间,秒),0 表示未指定
|
||||
duration: float = 0.0 # 裁剪时长(秒),0 表示未指定
|
||||
# 优先使用 trim 子对象
|
||||
trim_data = config.get("trim")
|
||||
if trim_data and isinstance(trim_data, dict):
|
||||
return TrimConfig.from_dict(trim_data)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> TrimConfig | None:
|
||||
"""从字典构造,无有效裁剪参数时返回 None(不裁剪)."""
|
||||
if not data:
|
||||
return None
|
||||
# 兼容:直接从 config 读取裁剪字段
|
||||
has_trim_field = any(
|
||||
config.get(k) not in (None, 0, 0.0, "")
|
||||
for k in ("trim_start", "trim_end", "trim_duration", "start_time", "end_time", "duration")
|
||||
)
|
||||
if not has_trim_field:
|
||||
return None
|
||||
|
||||
start = float(data.get("start_time", 0) or 0)
|
||||
end = float(data.get("end_time", 0) or 0)
|
||||
dur = float(data.get("duration", 0) or 0)
|
||||
# 映射字段名(支持 trim_ 前缀和无前缀两种)
|
||||
start = config.get("trim_start") or config.get("start_time") or 0
|
||||
end = config.get("trim_end") or config.get("end_time") or 0
|
||||
dur = config.get("trim_duration") or config.get("duration") or 0
|
||||
|
||||
# 三个参数都没有 → 不裁剪
|
||||
if start <= 0 and end <= 0 and dur <= 0:
|
||||
return None
|
||||
|
||||
# 至少有两个参数(或一个合理的 start/duration)
|
||||
# 兼容:只传了 start_time → 从 start 开始取到末尾
|
||||
# 兼容:只传了 duration → 从 0 开始取 duration
|
||||
if start > 0 and end <= 0 and dur <= 0:
|
||||
# 只有 start,取到末尾 → 这是"从某点开始"的语义,算有效
|
||||
pass
|
||||
elif dur > 0 and start <= 0 and end <= 0:
|
||||
# 只有 duration → 从开头取 duration,算有效
|
||||
pass
|
||||
elif start <= 0 and end <= 0 and dur <= 0:
|
||||
return None
|
||||
|
||||
return cls(start_time=start, end_time=end, duration=dur)
|
||||
|
||||
def validate_and_resolve(self, asset_duration: float) -> TrimConfig:
|
||||
"""根据素材实际时长,解析并钳制裁剪参数.
|
||||
|
||||
返回一个新的 TrimConfig,其中 start_time / end_time / duration 都已确定。
|
||||
如果裁剪无效(时长为0或负数),仍返回但调用方应检查 is_valid。
|
||||
"""
|
||||
start = self.start_time
|
||||
end = self.end_time
|
||||
dur = self.duration
|
||||
|
||||
# 边界:start 不能为负
|
||||
if start < 0:
|
||||
start = 0.0
|
||||
|
||||
# 边界:asset_duration 为 0 时保守处理(不裁剪,取全部)
|
||||
if asset_duration <= 0:
|
||||
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
||||
|
||||
# 三选二推导
|
||||
# 判断顺序很重要:先判断需要两个显式值的组合,最后判断含默认值的
|
||||
# 情况1:start + end 都有显式值
|
||||
if start > 0 and end > 0:
|
||||
if end <= start:
|
||||
# 出点 <= 入点,无效 → 返回 start 处一个极短片段(调用方会判无效)
|
||||
return TrimConfig(start_time=start, end_time=start, duration=0.0)
|
||||
dur = end - start
|
||||
# 情况2:end + duration 都有显式值
|
||||
elif end > 0 and dur > 0:
|
||||
start = end - dur
|
||||
if start < 0:
|
||||
start = 0.0
|
||||
dur = end # 重新计算
|
||||
# 情况3:start + duration 都有值(start 可以是 0)
|
||||
elif dur > 0:
|
||||
end = start + dur
|
||||
# 情况4:只有 start → 取到素材末尾
|
||||
elif start > 0 and end <= 0 and dur <= 0:
|
||||
end = asset_duration
|
||||
dur = end - start
|
||||
# 情况5:只有 end → 从开头取到 end
|
||||
elif end > 0 and start <= 0 and dur <= 0:
|
||||
start = 0.0
|
||||
dur = end
|
||||
else:
|
||||
# 都没有 → 不裁剪
|
||||
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
||||
|
||||
# 边界钳制:end 不能超过素材时长
|
||||
if end > asset_duration:
|
||||
end = asset_duration
|
||||
dur = end - start
|
||||
|
||||
# 边界钳制:start 不能超过素材时长
|
||||
if start >= asset_duration:
|
||||
start = max(0.0, asset_duration - MIN_TRIM_DURATION)
|
||||
dur = asset_duration - start
|
||||
end = asset_duration
|
||||
|
||||
# 保证 duration 不为负
|
||||
if dur < 0:
|
||||
dur = 0.0
|
||||
|
||||
return TrimConfig(start_time=start, end_time=end, duration=dur)
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
"""裁剪是否有效(时长大于最小阈值)."""
|
||||
return self.duration >= MIN_TRIM_DURATION
|
||||
|
||||
@property
|
||||
def is_noop(self) -> bool:
|
||||
"""是否等价于不裁剪(从0开始取全部)."""
|
||||
return self.start_time <= 0 and self.duration <= 0
|
||||
|
||||
@property
|
||||
def trim_from_start(self) -> bool:
|
||||
"""是否从开头裁剪(start_time == 0)."""
|
||||
return self.start_time <= 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrimSegment:
|
||||
"""多段裁剪中的一段."""
|
||||
|
||||
segment_id: str # 段 ID(用于生成唯一标签)
|
||||
trim: TrimConfig # 裁剪配置
|
||||
order: int = 0 # 排序
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any], default_order: int = 0) -> TrimSegment:
|
||||
"""从字典构造."""
|
||||
return cls(
|
||||
segment_id=str(data.get("segment_id", "") or f"seg_{default_order}"),
|
||||
trim=TrimConfig(
|
||||
start_time=float(data.get("start_time", 0) or 0),
|
||||
end_time=float(data.get("end_time", 0) or 0),
|
||||
duration=float(data.get("duration", 0) or 0),
|
||||
),
|
||||
order=int(data.get("order", default_order)),
|
||||
)
|
||||
return TrimConfig.from_dict(
|
||||
{"start_time": start, "end_time": end, "duration": dur}
|
||||
)
|
||||
|
||||
|
||||
class TrimEngine:
|
||||
@@ -308,32 +203,3 @@ class TrimEngine:
|
||||
|
||||
return []
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def extract_trim_from_clip_config(config: dict[str, Any] | None) -> TrimConfig | None:
|
||||
"""从 clip config 中提取单段裁剪配置.
|
||||
|
||||
兼容以下字段名:
|
||||
- trim_start / trim_end / trim_duration
|
||||
- start_time / end_time / duration(在 trim 子字典里)
|
||||
"""
|
||||
if not config:
|
||||
return None
|
||||
|
||||
# trim 子字典
|
||||
if "trim" in config and isinstance(config["trim"], dict):
|
||||
return TrimConfig.from_dict(config["trim"])
|
||||
|
||||
# 扁平字段
|
||||
has_any = any(k in config for k in ("trim_start", "trim_end", "trim_duration"))
|
||||
if not has_any:
|
||||
return None
|
||||
|
||||
data = {
|
||||
"start_time": config.get("trim_start", 0),
|
||||
"end_time": config.get("trim_end", 0),
|
||||
"duration": config.get("trim_duration", 0),
|
||||
}
|
||||
return TrimConfig.from_dict(data)
|
||||
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
"""裁剪配置领域模型 — 纯逻辑,无FFmpeg依赖.
|
||||
|
||||
抽离自 trim_engine.py,包含:
|
||||
- TrimConfig 数据类(三选二推导 + 边界钳制 + 有效性判断)
|
||||
- TrimSegment 数据类(多段裁剪)
|
||||
- 常量定义
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 最小裁剪时长(秒),低于此值视为无效
|
||||
MIN_TRIM_DURATION = 0.1
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrimConfig:
|
||||
"""裁剪配置.
|
||||
|
||||
三选二规则:start_time / end_time / duration 中必须至少给出两个,
|
||||
第三个会被自动推导。如果三个都给了,以 start_time + duration 为准。
|
||||
|
||||
边界保护:
|
||||
- start_time < 0 → 钳制到 0
|
||||
- end_time > 素材时长 → 钳制到素材时长
|
||||
- 计算出的 duration < 最小阈值 → 标记为无效
|
||||
"""
|
||||
|
||||
start_time: float = 0.0 # 入点(素材内时间,秒)
|
||||
end_time: float = 0.0 # 出点(素材内时间,秒),0 表示未指定
|
||||
duration: float = 0.0 # 裁剪时长(秒),0 表示未指定
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "TrimConfig | None":
|
||||
"""从字典构造,无有效裁剪参数时返回 None(不裁剪)."""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
start = float(data.get("start_time", 0) or 0)
|
||||
end = float(data.get("end_time", 0) or 0)
|
||||
dur = float(data.get("duration", 0) or 0)
|
||||
|
||||
# 三个参数都没有 → 不裁剪
|
||||
if start <= 0 and end <= 0 and dur <= 0:
|
||||
return None
|
||||
|
||||
# 至少有两个参数(或一个合理的 start/duration)
|
||||
# 兼容:只传了 start_time → 从 start 开始取到末尾
|
||||
# 兼容:只传了 duration → 从 0 开始取 duration
|
||||
if start > 0 and end <= 0 and dur <= 0:
|
||||
# 只有 start,取到末尾 → 这是"从某点开始"的语义,算有效
|
||||
pass
|
||||
elif dur > 0 and start <= 0 and end <= 0:
|
||||
# 只有 duration → 从开头取 duration,算有效
|
||||
pass
|
||||
elif start <= 0 and end <= 0 and dur <= 0:
|
||||
return None
|
||||
|
||||
return cls(start_time=start, end_time=end, duration=dur)
|
||||
|
||||
def validate_and_resolve(self, asset_duration: float) -> "TrimConfig":
|
||||
"""根据素材实际时长,解析并钳制裁剪参数.
|
||||
|
||||
返回一个新的 TrimConfig,其中 start_time / end_time / duration 都已确定。
|
||||
如果裁剪无效(时长为0或负数),仍返回但调用方应检查 is_valid。
|
||||
"""
|
||||
start = self.start_time
|
||||
end = self.end_time
|
||||
dur = self.duration
|
||||
|
||||
# 边界:start 不能为负
|
||||
if start < 0:
|
||||
start = 0.0
|
||||
|
||||
# 边界:asset_duration 为 0 时保守处理(不裁剪,取全部)
|
||||
if asset_duration <= 0:
|
||||
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
||||
|
||||
# 三选二推导
|
||||
# 判断顺序很重要:先判断需要两个显式值的组合,最后判断含默认值的
|
||||
# 情况1:start + end 都有显式值
|
||||
if start > 0 and end > 0:
|
||||
if end <= start:
|
||||
# 出点 <= 入点,无效 → 返回 start 处一个极短片段(调用方会判无效)
|
||||
return TrimConfig(start_time=start, end_time=start, duration=0.0)
|
||||
dur = end - start
|
||||
# 情况2:end + duration 都有显式值
|
||||
elif end > 0 and dur > 0:
|
||||
start = end - dur
|
||||
if start < 0:
|
||||
start = 0.0
|
||||
dur = end # 重新计算
|
||||
# 情况3:start + duration 都有值(start 可以是 0)
|
||||
elif dur > 0:
|
||||
end = start + dur
|
||||
# 情况4:只有 start → 取到素材末尾
|
||||
elif start > 0 and end <= 0 and dur <= 0:
|
||||
end = asset_duration
|
||||
dur = end - start
|
||||
# 情况5:只有 end → 从开头取到 end
|
||||
elif end > 0 and start <= 0 and dur <= 0:
|
||||
start = 0.0
|
||||
dur = end
|
||||
else:
|
||||
# 都没有 → 不裁剪
|
||||
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
||||
|
||||
# 边界钳制:end 不能超过素材时长
|
||||
if end > asset_duration:
|
||||
end = asset_duration
|
||||
dur = end - start
|
||||
|
||||
# 边界钳制:start 不能超过素材时长
|
||||
if start >= asset_duration:
|
||||
start = max(0.0, asset_duration - MIN_TRIM_DURATION)
|
||||
dur = asset_duration - start
|
||||
end = asset_duration
|
||||
|
||||
# 保证 duration 不为负
|
||||
if dur < 0:
|
||||
dur = 0.0
|
||||
|
||||
return TrimConfig(start_time=start, end_time=end, duration=dur)
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
"""裁剪是否有效(时长大于最小阈值)."""
|
||||
return self.duration >= MIN_TRIM_DURATION
|
||||
|
||||
@property
|
||||
def is_noop(self) -> bool:
|
||||
"""是否等价于不裁剪(从0开始取全部)."""
|
||||
return self.start_time <= 0 and self.duration <= 0
|
||||
|
||||
@property
|
||||
def trim_from_start(self) -> bool:
|
||||
"""是否从开头裁剪(start_time == 0)."""
|
||||
return self.start_time <= 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrimSegment:
|
||||
"""多段裁剪中的一段."""
|
||||
|
||||
segment_id: str # 段 ID(用于生成唯一标签)
|
||||
trim: TrimConfig # 裁剪配置
|
||||
order: int = 0 # 排序
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any], default_order: int = 0) -> "TrimSegment":
|
||||
"""从字典构造."""
|
||||
return cls(
|
||||
segment_id=str(data.get("segment_id", "") or f"seg_{default_order}"),
|
||||
trim=TrimConfig(
|
||||
start_time=float(data.get("start_time", 0) or 0),
|
||||
end_time=float(data.get("end_time", 0) or 0),
|
||||
duration=float(data.get("duration", 0) or 0),
|
||||
),
|
||||
order=int(data.get("order", default_order)),
|
||||
)
|
||||
Executable
+350
@@ -0,0 +1,350 @@
|
||||
"""裁剪配置领域模型单测 — 纯逻辑,无外部依赖."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.trim_config import (
|
||||
MIN_TRIM_DURATION,
|
||||
TrimConfig,
|
||||
TrimSegment,
|
||||
)
|
||||
|
||||
|
||||
# ── 常量 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_min_trim_duration_positive(self):
|
||||
assert MIN_TRIM_DURATION > 0
|
||||
|
||||
def test_min_trim_duration_value(self):
|
||||
assert MIN_TRIM_DURATION == 0.1
|
||||
|
||||
|
||||
# ── from_dict ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrimConfigFromDict:
|
||||
def test_none_returns_none(self):
|
||||
assert TrimConfig.from_dict(None) is None
|
||||
|
||||
def test_empty_dict_returns_none(self):
|
||||
assert TrimConfig.from_dict({}) is None
|
||||
|
||||
def test_all_zeros_returns_none(self):
|
||||
assert TrimConfig.from_dict({"start_time": 0, "end_time": 0, "duration": 0}) is None
|
||||
|
||||
def test_start_only_returns_config(self):
|
||||
result = TrimConfig.from_dict({"start_time": 5.0})
|
||||
assert result is not None
|
||||
assert result.start_time == 5.0
|
||||
assert result.end_time == 0
|
||||
assert result.duration == 0
|
||||
|
||||
def test_duration_only_returns_config(self):
|
||||
result = TrimConfig.from_dict({"duration": 3.0})
|
||||
assert result is not None
|
||||
assert result.start_time == 0
|
||||
assert result.end_time == 0
|
||||
assert result.duration == 3.0
|
||||
|
||||
def test_end_only_returns_config(self):
|
||||
# 只有 end_time → from_dict 阶段保留,resolve 时推导为从开头取到 end
|
||||
result = TrimConfig.from_dict({"end_time": 5.0})
|
||||
assert result is not None
|
||||
assert result.end_time == 5.0
|
||||
assert result.start_time == 0
|
||||
assert result.duration == 0
|
||||
|
||||
def test_start_and_duration(self):
|
||||
result = TrimConfig.from_dict({"start_time": 2.0, "duration": 5.0})
|
||||
assert result is not None
|
||||
assert result.start_time == 2.0
|
||||
assert result.duration == 5.0
|
||||
|
||||
def test_start_and_end(self):
|
||||
result = TrimConfig.from_dict({"start_time": 1.0, "end_time": 4.0})
|
||||
assert result is not None
|
||||
assert result.start_time == 1.0
|
||||
assert result.end_time == 4.0
|
||||
|
||||
def test_all_three_params(self):
|
||||
result = TrimConfig.from_dict({"start_time": 1.0, "end_time": 4.0, "duration": 3.0})
|
||||
assert result is not None
|
||||
assert result.start_time == 1.0
|
||||
assert result.end_time == 4.0
|
||||
assert result.duration == 3.0
|
||||
|
||||
def test_string_values_converted(self):
|
||||
result = TrimConfig.from_dict({"start_time": "2.5", "duration": "3.0"})
|
||||
assert result is not None
|
||||
assert result.start_time == 2.5
|
||||
assert result.duration == 3.0
|
||||
|
||||
def test_zero_start_with_duration(self):
|
||||
# start=0 + duration>0 → 有效(从开头取一段)
|
||||
result = TrimConfig.from_dict({"start_time": 0, "duration": 5.0})
|
||||
assert result is not None
|
||||
assert result.start_time == 0
|
||||
assert result.duration == 5.0
|
||||
|
||||
def test_negative_values_treated_as_zero(self):
|
||||
result = TrimConfig.from_dict({"start_time": -1.0, "duration": -2.0})
|
||||
# 两个值都 <= 0 → 返回 None
|
||||
assert result is None
|
||||
|
||||
def test_invalid_type_none_values(self):
|
||||
result = TrimConfig.from_dict({"start_time": None, "duration": None})
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── validate_and_resolve: 三选二推导 ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateResolve_DeriveDuration:
|
||||
"""start + end → 推导 duration."""
|
||||
|
||||
def test_start_end_derive_duration(self):
|
||||
cfg = TrimConfig(start_time=2.0, end_time=5.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.start_time == 2.0
|
||||
assert resolved.end_time == 5.0
|
||||
assert resolved.duration == 3.0
|
||||
assert resolved.is_valid
|
||||
|
||||
def test_start_at_zero_end_derive_duration(self):
|
||||
cfg = TrimConfig(start_time=0.0, end_time=4.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.duration == 4.0
|
||||
assert resolved.start_time == 0.0
|
||||
|
||||
def test_end_equals_start_zero_duration(self):
|
||||
cfg = TrimConfig(start_time=3.0, end_time=3.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.duration == 0.0
|
||||
assert not resolved.is_valid
|
||||
|
||||
def test_end_less_than_start_invalid(self):
|
||||
cfg = TrimConfig(start_time=5.0, end_time=2.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.duration == 0.0
|
||||
assert not resolved.is_valid
|
||||
assert resolved.start_time == 5.0
|
||||
|
||||
|
||||
class TestValidateResolve_DeriveEnd:
|
||||
"""start + duration → 推导 end."""
|
||||
|
||||
def test_start_duration_derive_end(self):
|
||||
cfg = TrimConfig(start_time=2.0, duration=3.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.start_time == 2.0
|
||||
assert resolved.duration == 3.0
|
||||
assert resolved.end_time == 5.0
|
||||
assert resolved.is_valid
|
||||
|
||||
def test_zero_start_duration_derive_end(self):
|
||||
cfg = TrimConfig(start_time=0.0, duration=5.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.end_time == 5.0
|
||||
assert resolved.duration == 5.0
|
||||
|
||||
def test_start_at_zero_implied(self):
|
||||
# duration > 0, start = 0 → end = duration
|
||||
cfg = TrimConfig(duration=4.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.duration == 4.0
|
||||
assert resolved.end_time == 4.0
|
||||
|
||||
|
||||
class TestValidateResolve_DeriveStart:
|
||||
"""end + duration → 推导 start."""
|
||||
|
||||
def test_end_duration_derive_start(self):
|
||||
cfg = TrimConfig(end_time=8.0, duration=3.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.end_time == 8.0
|
||||
assert resolved.duration == 3.0
|
||||
assert resolved.start_time == 5.0
|
||||
assert resolved.is_valid
|
||||
|
||||
def test_end_duration_start_negative_clamped(self):
|
||||
# end - duration < 0 → start 钳制到 0,duration 重新计算
|
||||
cfg = TrimConfig(end_time=3.0, duration=5.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.end_time == 3.0
|
||||
assert resolved.duration == 3.0
|
||||
assert resolved.is_valid
|
||||
|
||||
|
||||
class TestValidateResolve_SingleParam:
|
||||
"""只有一个参数时的推导."""
|
||||
|
||||
def test_start_only_takes_to_end(self):
|
||||
cfg = TrimConfig(start_time=3.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.start_time == 3.0
|
||||
assert resolved.end_time == 10.0
|
||||
assert resolved.duration == 7.0
|
||||
assert resolved.is_valid
|
||||
|
||||
def test_end_only_takes_from_start(self):
|
||||
cfg = TrimConfig(end_time=5.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.end_time == 5.0
|
||||
assert resolved.duration == 5.0
|
||||
assert resolved.is_valid
|
||||
|
||||
def test_duration_only_from_start(self):
|
||||
cfg = TrimConfig(duration=4.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.end_time == 4.0
|
||||
assert resolved.duration == 4.0
|
||||
|
||||
|
||||
class TestValidateResolve_BoundaryClamp:
|
||||
"""边界钳制测试."""
|
||||
|
||||
def test_end_exceeds_asset_duration(self):
|
||||
cfg = TrimConfig(start_time=2.0, duration=20.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.end_time == 10.0
|
||||
assert resolved.duration == 8.0
|
||||
assert resolved.is_valid
|
||||
|
||||
def test_start_exceeds_asset_duration(self):
|
||||
cfg = TrimConfig(start_time=15.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
# start >= asset_duration → 钳制到末尾附近
|
||||
assert resolved.start_time <= 10.0
|
||||
assert resolved.end_time == 10.0
|
||||
assert resolved.duration == pytest.approx(MIN_TRIM_DURATION, abs=0.001)
|
||||
|
||||
def test_negative_start_clamped(self):
|
||||
cfg = TrimConfig(start_time=-2.0, duration=5.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.duration == 5.0
|
||||
assert resolved.end_time == 5.0
|
||||
|
||||
def test_negative_start_with_end(self):
|
||||
cfg = TrimConfig(start_time=-1.0, end_time=4.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.end_time == 4.0
|
||||
assert resolved.duration == 4.0
|
||||
|
||||
def test_asset_duration_zero_returns_noop(self):
|
||||
cfg = TrimConfig(start_time=1.0, duration=2.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=0.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.duration == 0.0
|
||||
assert not resolved.is_valid
|
||||
|
||||
def test_asset_duration_negative_returns_noop(self):
|
||||
cfg = TrimConfig(start_time=1.0, duration=2.0)
|
||||
resolved = cfg.validate_and_resolve(asset_duration=-5.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.duration == 0.0
|
||||
|
||||
def test_no_params_returns_noop(self):
|
||||
cfg = TrimConfig()
|
||||
resolved = cfg.validate_and_resolve(asset_duration=10.0)
|
||||
assert resolved.start_time == 0.0
|
||||
assert resolved.duration == 0.0
|
||||
assert resolved.is_noop
|
||||
|
||||
|
||||
# ── 属性判断 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrimConfigProperties:
|
||||
def test_is_valid_true(self):
|
||||
cfg = TrimConfig(start_time=1.0, end_time=3.0, duration=2.0)
|
||||
assert cfg.is_valid
|
||||
|
||||
def test_is_valid_zero_duration(self):
|
||||
cfg = TrimConfig(duration=0.0)
|
||||
assert not cfg.is_valid
|
||||
|
||||
def test_is_valid_below_min(self):
|
||||
cfg = TrimConfig(duration=MIN_TRIM_DURATION / 2)
|
||||
assert not cfg.is_valid
|
||||
|
||||
def test_is_valid_exact_min(self):
|
||||
cfg = TrimConfig(duration=MIN_TRIM_DURATION)
|
||||
assert cfg.is_valid
|
||||
|
||||
def test_is_noop_true(self):
|
||||
cfg = TrimConfig()
|
||||
assert cfg.is_noop
|
||||
|
||||
def test_is_noop_false_with_start(self):
|
||||
cfg = TrimConfig(start_time=1.0)
|
||||
assert not cfg.is_noop
|
||||
|
||||
def test_is_noop_false_with_duration(self):
|
||||
cfg = TrimConfig(duration=1.0)
|
||||
assert not cfg.is_noop
|
||||
|
||||
def test_trim_from_start_true(self):
|
||||
cfg = TrimConfig(duration=3.0)
|
||||
assert cfg.trim_from_start
|
||||
|
||||
def test_trim_from_start_false(self):
|
||||
cfg = TrimConfig(start_time=2.0, duration=3.0)
|
||||
assert not cfg.trim_from_start
|
||||
|
||||
def test_trim_from_start_zero_start(self):
|
||||
cfg = TrimConfig(start_time=0.0, duration=3.0)
|
||||
assert cfg.trim_from_start
|
||||
|
||||
|
||||
# ── TrimSegment ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrimSegment:
|
||||
def test_from_dict_basic(self):
|
||||
seg = TrimSegment.from_dict({
|
||||
"segment_id": "seg1",
|
||||
"start_time": 1.0,
|
||||
"end_time": 4.0,
|
||||
"order": 0,
|
||||
})
|
||||
assert seg.segment_id == "seg1"
|
||||
assert seg.trim.start_time == 1.0
|
||||
assert seg.trim.end_time == 4.0
|
||||
assert seg.order == 0
|
||||
|
||||
def test_from_dict_default_order(self):
|
||||
seg = TrimSegment.from_dict({"segment_id": "seg1"}, default_order=3)
|
||||
assert seg.order == 3
|
||||
|
||||
def test_from_dict_order_overrides_default(self):
|
||||
seg = TrimSegment.from_dict({"segment_id": "seg1", "order": 5}, default_order=3)
|
||||
assert seg.order == 5
|
||||
|
||||
def test_from_dict_default_segment_id(self):
|
||||
seg = TrimSegment.from_dict({}, default_order=2)
|
||||
assert seg.segment_id == "seg_2"
|
||||
|
||||
def test_from_dict_empty_segment_id_fallback(self):
|
||||
seg = TrimSegment.from_dict({"segment_id": ""}, default_order=0)
|
||||
assert seg.segment_id == "seg_0"
|
||||
|
||||
def test_from_dict_duration_only(self):
|
||||
seg = TrimSegment.from_dict({"segment_id": "s1", "duration": 5.0})
|
||||
assert seg.trim.duration == 5.0
|
||||
assert seg.trim.start_time == 0.0
|
||||
|
||||
def test_trim_segment_dataclass_fields(self):
|
||||
cfg = TrimConfig(start_time=1.0, duration=2.0)
|
||||
seg = TrimSegment(segment_id="s1", trim=cfg, order=2)
|
||||
assert seg.segment_id == "s1"
|
||||
assert seg.trim is cfg
|
||||
assert seg.order == 2
|
||||
Reference in New Issue
Block a user