Compare commits

..

1 Commits

Author SHA1 Message Date
CI Bot 8f99774620 test(wave105): extract render_layer_utils domain module + 65 unit tests
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 22s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 37s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m10s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 54s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m13s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
AI Code Review / AI Code Review (pull_request) Successful in 3m36s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m56s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m20s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 24s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1241h3m35s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1241h3m41s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1241h3m37s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1241h3m43s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1241h4m58s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1241h3m45s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1241h5m0s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1241h6m48s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1241h6m50s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1241h5m2s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1241h6m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1241h37m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1241h36m21s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1241h37m46s
从 unified_render_service.py (1985→1947行) 抽出渲染图层纯逻辑工具:
- resolve_layer_role / get_layer_z_index: 图层角色解析
- clip_effective_duration / clip_playback_speed / clip_adjusted_duration: 时长计算
- estimate_total_duration: 总时长估算
- can_pass_through: 直通路径判断
- LAYER_Z_INDEX / MAIN_LAYER_ROLES 等常量

保留向后兼容: 模块级函数 + 类静态方法均委托到新模块
验证: 88 passed (23原有 + 65新增),全套render 373 passed
2026-07-26 22:19:12 +08:00
6 changed files with 887 additions and 765 deletions
@@ -24,33 +24,264 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from packages.domain.subtitle_style import ( # noqa: F401 向后兼容导出
ALLOWED_SUBTITLE_EXTENSIONS,
DEFAULT_COLOR,
DEFAULT_FONT,
DEFAULT_FONT_SIZE,
DEFAULT_MAX_CHARS_PER_LINE,
DEFAULT_POSITION,
DEFAULT_STROKE_COLOR,
DEFAULT_STROKE_WIDTH,
POSITION_ALIGNMENT,
POSITION_ALIASES,
SubtitleSegment,
SubtitleStyle,
escape_ass_text as _escape_ass_text,
format_ass_time as _format_ass_time,
hex_to_ass_bgr as _hex_to_ass_bgr,
hex_to_ass_color as _hex_to_ass_color,
opacity_to_ass_alpha as _opacity_to_ass_alpha,
wrap_text as _wrap_text,
)
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
ALLOWED_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".vtt", ".sub"}
# 9宫格位置映射(ASS alignment 编号)
POSITION_ALIGNMENT = {
"top_left": 7,
"top_center": 8,
"top_right": 9,
"middle_left": 4,
"center": 5,
"middle_right": 6,
"bottom_left": 1,
"bottom_center": 2,
"bottom_right": 3,
}
# 位置简称兼容
POSITION_ALIASES = {
"top": "top_center",
"bottom": "bottom_center",
"middle": "center",
"left": "middle_left",
"right": "middle_right",
}
DEFAULT_FONT = "思源黑体"
DEFAULT_FONT_SIZE = 24
DEFAULT_COLOR = "#FFFFFF"
DEFAULT_STROKE_COLOR = "#000000"
DEFAULT_STROKE_WIDTH = 1.5
DEFAULT_POSITION = "bottom_center"
DEFAULT_MAX_CHARS_PER_LINE = 20
# ── 字幕样式配置 ────────────────────────────────────────────────────────────
@dataclass
class SubtitleStyle:
"""字幕样式配置."""
font_name: str = DEFAULT_FONT
font_size: int = DEFAULT_FONT_SIZE
font_color: str = DEFAULT_COLOR
bold: bool = False
italic: bool = False
# 描边
stroke_enabled: bool = True
stroke_color: str = DEFAULT_STROKE_COLOR
stroke_width: float = DEFAULT_STROKE_WIDTH
# 阴影
shadow_enabled: bool = False
shadow_color: str = "#000000"
shadow_offset_x: int = 2
shadow_offset_y: int = 2
shadow_blur: float = 0.0
# 背景框
background_enabled: bool = False
background_color: str = "#000000"
background_opacity: float = 0.5 # 0.0 ~ 1.0
background_padding: int = 8
background_radius: int = 4
# 位置
position: str = DEFAULT_POSITION # 9宫格位置名
margin_v: int = 60 # 垂直边距
margin_l: int = 40 # 左边距
margin_r: int = 40 # 右边距
# 多行
max_chars_per_line: int = DEFAULT_MAX_CHARS_PER_LINE
line_spacing: int = 0 # 行间距
# 动画
fade_in: float = 0.0 # 淡入时长(秒)
fade_out: float = 0.0 # 淡出时长(秒)
animation_type: str = "none" # none/fade/slide/typewriter
@classmethod
def from_dict(cls, config: dict[str, Any] | None) -> "SubtitleStyle":
"""从字典创建样式配置,带安全类型转换."""
if not config or not isinstance(config, dict):
return cls()
def safe_str(key: str, default: str) -> str:
val = config.get(key, default)
return str(val) if val is not None else default
def safe_int(key: str, default: int) -> int:
try:
return int(config.get(key, default))
except (TypeError, ValueError):
return default
def safe_float(key: str, default: float) -> float:
try:
return float(config.get(key, default))
except (TypeError, ValueError):
return default
def safe_bool(key: str, default: bool) -> bool:
return bool(config.get(key, default))
position = safe_str("position", DEFAULT_POSITION)
position = POSITION_ALIASES.get(position, position)
if position not in POSITION_ALIGNMENT:
position = DEFAULT_POSITION
return cls(
font_name=safe_str("font", DEFAULT_FONT),
font_size=safe_int("size", DEFAULT_FONT_SIZE),
font_color=safe_str("color", DEFAULT_COLOR),
bold=safe_bool("bold", False),
italic=safe_bool("italic", False),
stroke_enabled=safe_bool("stroke_enabled", True),
stroke_color=safe_str("stroke_color", DEFAULT_STROKE_COLOR),
stroke_width=safe_float("stroke_width", DEFAULT_STROKE_WIDTH),
shadow_enabled=safe_bool("shadow_enabled", False),
shadow_color=safe_str("shadow_color", "#000000"),
shadow_offset_x=safe_int("shadow_offset_x", 2),
shadow_offset_y=safe_int("shadow_offset_y", 2),
shadow_blur=safe_float("shadow_blur", 0.0),
background_enabled=safe_bool("background_enabled", False),
background_color=safe_str("background_color", "#000000"),
background_opacity=max(0.0, min(1.0, safe_float("background_opacity", 0.5))),
background_padding=safe_int("background_padding", 8),
background_radius=safe_int("background_radius", 4),
position=position,
margin_v=safe_int("margin_v", 60),
margin_l=safe_int("margin_l", 40),
margin_r=safe_int("margin_r", 40),
max_chars_per_line=safe_int("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE),
line_spacing=safe_int("line_spacing", 0),
fade_in=max(0.0, safe_float("fade_in", 0.0)),
fade_out=max(0.0, safe_float("fade_out", 0.0)),
animation_type=safe_str("animation_type", "none"),
)
@property
def alignment(self) -> int:
"""获取 ASS alignment 编号."""
return POSITION_ALIGNMENT.get(self.position, 2)
@property
def ass_font_color(self) -> str:
"""ASS 格式颜色 &HAABBGGRR."""
return _hex_to_ass_color(self.font_color)
@property
def ass_stroke_color(self) -> str:
return _hex_to_ass_color(self.stroke_color)
@property
def ass_shadow_color(self) -> str:
return _hex_to_ass_color(self.shadow_color)
@property
def ass_background_color(self) -> str:
"""背景框颜色(ASS BackColour),带透明度."""
alpha_hex = _opacity_to_ass_alpha(self.background_opacity)
color_bgr = _hex_to_ass_bgr(self.background_color)
return f"&H{alpha_hex}{color_bgr}"
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def _hex_to_ass_color(hex_color: str) -> str:
"""HEX → ASS 颜色 &HAABBGGRR(默认不透明)."""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "&H00FFFFFF"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"&H00{b.upper()}{g.upper()}{r.upper()}"
def _hex_to_ass_bgr(hex_color: str) -> str:
"""HEX → ASS BGR 部分(不含 alpha)."""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "FFFFFF"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"{b.upper()}{g.upper()}{r.upper()}"
def _opacity_to_ass_alpha(opacity: float) -> str:
"""不透明度 → ASS alpha(00=不透明,FF=完全透明)."""
alpha = 255 - int(opacity * 255)
return f"{alpha:02X}"
def _escape_ass_text(text: str) -> str:
"""转义 ASS 文本特殊字符."""
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
text = text.replace("{", "(").replace("}", ")")
return text
def _format_ass_time(seconds: float) -> str:
"""秒 → ASS 时间格式 H:MM:SS.cc."""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:05.2f}"
def _wrap_text(text: str, max_chars: int) -> list[str]:
"""按字数换行,优先标点断开."""
if len(text) <= max_chars:
return [text]
lines: list[str] = []
remaining = text
while len(remaining) > max_chars:
break_point = max_chars
punctuations = ",。!?、;:,.;:!?"
for i in range(max_chars, max_chars // 2, -1):
if i < len(remaining) and remaining[i] in punctuations:
break_point = i + 1
break
lines.append(remaining[:break_point])
remaining = remaining[break_point:]
if remaining:
lines.append(remaining)
return lines
# ── 字幕片段 ──────────────────────────────────────────────────────────────────
@dataclass
class SubtitleSegment:
"""单个字幕片段."""
start: float # 开始时间(秒)
end: float # 结束时间(秒)
text: str # 字幕文本
style_name: str = "Default" # 使用的样式名
# ── 字幕渲染引擎 ──────────────────────────────────────────────────────────────
@@ -52,6 +52,15 @@ from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_fr
from video_processing.tts_engine import TtsEngine
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
from packages.domain.render_layer_utils import (
LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX,
can_pass_through as _can_pass_through_pure,
clip_adjusted_duration as _clip_adjusted_duration_pure,
clip_effective_duration as _clip_effective_duration_pure,
clip_playback_speed as _clip_playback_speed_pure,
estimate_total_duration as _estimate_total_duration_pure,
resolve_layer_role as _resolve_layer_role_pure,
)
from packages.domain.tts_config import TtsConfig
logger = logging.getLogger(__name__)
@@ -107,47 +116,16 @@ class RenderResult:
def _resolve_layer_role(clip_type: str, config: dict[str, Any]) -> str:
"""根据 clip_type 和 config.role 确定图层角色。
"""根据 clip_type 和 config.role 确定图层角色(向后兼容别名)。
映射规则:
intro / outro → "main"(按 order 排在首/尾)
overlay → "overlay"(画中画叠加,z=1)
corner_voice → "corner_voice"(右上角小窗,z=1)
background → "background"(全屏底图,z=0)
b_roll → "broll"(z=0)
main + config.role=b_roll → "broll"
main (default) → "main"
实际实现移至 packages.domain.render_layer_utils.resolve_layer_role。
"""
role = config.get("role", "")
if clip_type in ("intro", "outro"):
return "main"
if clip_type == "overlay":
return "overlay"
if clip_type == "corner_voice":
return "corner_voice"
if clip_type == "background":
return "background"
if clip_type == "b_roll":
return "broll"
# main type
if role == "b_roll":
return "broll"
if role == "audio":
return "audio"
return "main"
return _resolve_layer_role_pure(clip_type, config)
# ── 图层默认 z_index ─────────────────────────────────────────────────────────
_LAYER_Z_INDEX: dict[str, int] = {
"background": -1,
"broll": 0,
"main": 0,
"overlay": 1,
"corner_voice": 1,
"audio": 2,
}
_LAYER_Z_INDEX: dict[str, int] = _IMPORTED_LAYER_Z_INDEX
# 图层默认 PiP 位置(相对输出画布的偏移)
_PIP_SCALE = 0.25 # PiP 占主画面的比例
@@ -489,29 +467,9 @@ class UnifiedRenderService:
def _estimate_total_duration(self, layers: list[RenderLayer]) -> float:
"""估算视频总时长(用于字幕等需要)。
取主图层(main/broll/background)的总时长,转场重叠按 transition_duration 估算。
实际实现移至 packages.domain.render_layer_utils.estimate_total_duration。
"""
# 找主图层(第一个有视频内容的图层)
main_layer = None
for role in ("main", "broll", "background"):
for layer in layers:
if layer.role == role:
main_layer = layer
break
if main_layer:
break
if not main_layer or not main_layer.clips:
return 0.0
total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in main_layer.clips)
# 减去转场重叠时间(粗略估算)
n_clips = len(main_layer.clips)
if n_clips > 1:
total -= (n_clips - 1) * self.transition_duration
return max(0.1, total)
return _estimate_total_duration_pure(layers, self.transition_duration)
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
"""根据 plan.config 生成 ASS 字幕文件。
@@ -1869,10 +1827,11 @@ class UnifiedRenderService:
@staticmethod
def _clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长(原速 trim 后时长)."""
if clip.duration > 0:
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
return clip.actual_duration if clip.actual_duration > 0 else 0.0
"""计算 clip 的有效时长(原速 trim 后时长)。
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。
"""
return _clip_effective_duration_pure(clip.duration, clip.actual_duration)
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
@@ -1969,17 +1928,20 @@ class UnifiedRenderService:
@staticmethod
def _clip_speed(clip: ResolvedClip) -> float:
"""获取 clip 的播放速度,无效值回退到 1.0."""
speed = getattr(clip, "playback_speed", 1.0)
if not isinstance(speed, (int, float)) or speed <= 0:
return 1.0
return float(speed)
"""获取 clip 的播放速度,无效值回退到 1.0。
实际实现移至 packages.domain.render_layer_utils.clip_playback_speed。
"""
return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0))
@staticmethod
def _clip_adjusted_duration(clip: ResolvedClip) -> float:
"""计算调速后的 clip 实际时长(用于拼接计算)."""
base = UnifiedRenderService._clip_effective_duration(clip)
speed = UnifiedRenderService._clip_speed(clip)
if abs(speed - 1.0) < 1e-6:
return base
return base / speed
"""计算调速后的 clip 实际时长(用于拼接计算)。
实际实现移至 packages.domain.render_layer_utils.clip_adjusted_duration。
"""
return _clip_adjusted_duration_pure(
clip.duration,
clip.actual_duration,
getattr(clip, "playback_speed", 1.0),
)
+241
View File
@@ -0,0 +1,241 @@
"""渲染图层工具函数 — 纯函数集合.
从 unified_render_service.py 抽离的纯逻辑,负责:
- clip 时长计算(有效时长、调速后时长)
- clip_type → layer_role 映射
- 总时长估算
- 图层默认属性(z_index 等)
所有函数均为纯函数,不依赖 FFmpeg、数据库或外部 IO。
"""
from __future__ import annotations
from typing import Any
# ── 图层角色定义 ─────────────────────────────────────────────────────────────
# 图层默认 z_index 映射
LAYER_Z_INDEX: dict[str, int] = {
"background": -1,
"broll": 0,
"main": 0,
"overlay": 1,
"corner_voice": 1,
"audio": 2,
}
# 图层默认 PiP 缩放比例(相对于主画面)
PIP_DEFAULT_SCALE = 0.25
# 主视频图层角色(用于总时长计算、直通判断等)
MAIN_LAYER_ROLES = frozenset({"main", "broll", "background"})
# ── clip_type → layer_role 映射 ──────────────────────────────────────────────
def resolve_layer_role(clip_type: str, config: dict[str, Any] | None = None) -> str:
"""根据 clip_type 和 config.role 确定图层角色。
映射规则:
intro / outro → "main"(按 order 排在首/尾)
overlay → "overlay"(画中画叠加,z=1)
corner_voice → "corner_voice"(右上角小窗,z=1)
background → "background"(全屏底图,z=0)
b_roll → "broll"(z=0)
main + config.role=b_roll → "broll"
main + config.role=audio → "audio"
main (default) → "main"
Args:
clip_type: 片段类型字符串
config: 片段配置字典(可选)
Returns:
图层角色字符串
"""
role = (config or {}).get("role", "") if config else ""
if clip_type in ("intro", "outro"):
return "main"
if clip_type == "overlay":
return "overlay"
if clip_type == "corner_voice":
return "corner_voice"
if clip_type == "background":
return "background"
if clip_type == "b_roll":
return "broll"
# main type
if role == "b_roll":
return "broll"
if role == "audio":
return "audio"
return "main"
def get_layer_z_index(role: str) -> int:
"""获取图层角色的默认 z_index。
Args:
role: 图层角色
Returns:
z_index 值,未知角色返回 0
"""
return LAYER_Z_INDEX.get(role, 0)
# ── clip 时长计算 ──────────────────────────────────────────────────────────
def clip_effective_duration(
duration: float,
actual_duration: float = 0.0,
) -> float:
"""计算 clip 的有效时长(原速 trim 后时长)。
规则:
- duration > 0: min(duration, actual_duration),actual=0 时用 duration
- duration <= 0: actual_duration,actual=0 时返回 0
Args:
duration: 配置的时长(0 表示使用完整素材)
actual_duration: 素材实际时长(probe 后的结果)
Returns:
有效时长(秒)
"""
if duration > 0:
return min(duration, actual_duration) if actual_duration > 0 else duration
return actual_duration if actual_duration > 0 else 0.0
def clip_playback_speed(playback_speed: Any) -> float:
"""获取 clip 的播放速度,无效值回退到 1.0。
Args:
playback_speed: 播放速度(可为任意类型
Returns:
有效的播放速度(正数)
"""
if not isinstance(playback_speed, (int, float)):
return 1.0
if playback_speed <= 0:
return 1.0
return float(playback_speed)
def clip_adjusted_duration(
duration: float,
actual_duration: float = 0.0,
playback_speed: Any = 1.0,
) -> float:
"""计算调速后的 clip 实际时长(用于拼接计算)。
Args:
duration: 配置的时长
actual_duration: 素材实际时长
playback_speed: 播放速度
Returns:
调速后的时长
"""
base = clip_effective_duration(duration, actual_duration)
speed = clip_playback_speed(playback_speed)
if abs(speed - 1.0) < 1e-6:
return base
return base / speed
# ── 总时长估算 ────────────────────────────────────────────────────────────
def estimate_total_duration(
layers: list[Any],
transition_duration: float = 0.0,
) -> float:
"""估算视频总时长。
取主图层(main/broll/background)的总调整后时长,减去转场重叠时间。
Args:
layers: 图层列表(每个元素需有 role 和 clips 属性,
clips 中元素需有 duration/actual_duration/playback_speed 属性)
transition_duration: 转场时长(秒),用于估算重叠时间
Returns:
估算的总时长(秒),最小 0.1
"""
# 找主图层(第一个有视频内容的图层)
main_layer = None
for role in ("main", "broll", "background"):
for layer in layers:
if getattr(layer, "role", None) == role and getattr(layer, "clips", None):
main_layer = layer
break
if main_layer:
break
if not main_layer or not getattr(main_layer, "clips", None):
return 0.0
clips = getattr(main_layer, "clips", [])
total = sum(
clip_adjusted_duration(
duration=getattr(c, "duration", 0),
actual_duration=getattr(c, "actual_duration", 0.0),
playback_speed=getattr(c, "playback_speed", 1.0),
)
for c in clips
)
# 减去转场重叠时间(粗略估算)
n_clips = len(clips)
if n_clips > 1 and transition_duration > 0:
total -= (n_clips - 1) * transition_duration
return max(0.1, total)
# ── 直通 / Stream Copy 判断辅助 ──────────────────────────────────────────────
def can_pass_through(
layers: list[Any],
has_stickers: bool = False,
has_watermark: bool = False,
) -> bool:
"""判断是否可以走直通优化路径(单 clip 简单场景)。
条件:
1. 只有 1 个图层
2. 该图层是视频图层(main/broll/background)
3. 该图层只有 1 个 clip(无转场需求)
4. 没有贴纸
5. 没有水印
Args:
layers: 图层列表
has_stickers: 是否有贴纸
has_watermark: 是否有水印
Returns:
是否可以走直通
"""
if len(layers) != 1:
return False
layer = layers[0]
role = getattr(layer, "role", "")
if role not in MAIN_LAYER_ROLES:
return False
clips = getattr(layer, "clips", [])
if len(clips) != 1:
return False
if has_stickers:
return False
if has_watermark:
return False
return True
-272
View File
@@ -1,272 +0,0 @@
"""字幕样式领域模型 — 纯逻辑,无外部依赖.
抽离自 subtitle_render_engine.py 的数据类和工具函数,
方便单测覆盖,同时保持向后兼容。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
# ── 常量 ──────────────────────────────────────────────────────────────────────
# 9宫格位置映射(ASS alignment 编号)
POSITION_ALIGNMENT: dict[str, int] = {
"top_left": 7,
"top_center": 8,
"top_right": 9,
"middle_left": 4,
"center": 5,
"middle_right": 6,
"bottom_left": 1,
"bottom_center": 2,
"bottom_right": 3,
}
# 位置简称兼容
POSITION_ALIASES: dict[str, str] = {
"top": "top_center",
"bottom": "bottom_center",
"middle": "center",
"left": "middle_left",
"right": "middle_right",
}
DEFAULT_FONT = "思源黑体"
DEFAULT_FONT_SIZE = 24
DEFAULT_COLOR = "#FFFFFF"
DEFAULT_STROKE_COLOR = "#000000"
DEFAULT_STROKE_WIDTH = 1.5
DEFAULT_POSITION = "bottom_center"
DEFAULT_MAX_CHARS_PER_LINE = 20
ALLOWED_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".vtt", ".sub"}
# ── 工具函数 ──────────────────────────────────────────────────────────────────
def hex_to_ass_color(hex_color: str) -> str:
"""HEX → ASS 颜色 &HAABBGGRR(默认不透明)."""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "&H00FFFFFF"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"&H00{b.upper()}{g.upper()}{r.upper()}"
def hex_to_ass_bgr(hex_color: str) -> str:
"""HEX → ASS BGR 部分(不含 alpha)."""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "FFFFFF"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"{b.upper()}{g.upper()}{r.upper()}"
def opacity_to_ass_alpha(opacity: float) -> str:
"""不透明度 → ASS alpha(00=不透明,FF=完全透明)."""
alpha = 255 - int(max(0.0, min(1.0, opacity)) * 255)
return f"{alpha:02X}"
def escape_ass_text(text: str) -> str:
"""转义 ASS 文本特殊字符."""
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
text = text.replace("{", "(").replace("}", ")")
return text
def format_ass_time(seconds: float) -> str:
"""秒 → ASS 时间格式 H:MM:SS.cc."""
if seconds < 0:
seconds = 0.0
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:05.2f}"
def wrap_text(text: str, max_chars: int) -> list[str]:
"""按字数换行,优先标点断开."""
if max_chars <= 0:
return [text]
if not text or len(text) <= max_chars:
return [text]
lines: list[str] = []
remaining = text
punctuations = ",。!?、;:,.;:!?"
while len(remaining) > max_chars:
break_point = max_chars
# 在 max_chars 到 max_chars//2 之间寻找标点断点
for i in range(max_chars, max_chars // 2, -1):
if i < len(remaining) and remaining[i] in punctuations:
break_point = i + 1
break
lines.append(remaining[:break_point])
remaining = remaining[break_point:]
if remaining:
lines.append(remaining)
return lines
# ── 字幕样式配置 ────────────────────────────────────────────────────────────
@dataclass
class SubtitleStyle:
"""字幕样式配置."""
font_name: str = DEFAULT_FONT
font_size: int = DEFAULT_FONT_SIZE
font_color: str = DEFAULT_COLOR
bold: bool = False
italic: bool = False
# 描边
stroke_enabled: bool = True
stroke_color: str = DEFAULT_STROKE_COLOR
stroke_width: float = DEFAULT_STROKE_WIDTH
# 阴影
shadow_enabled: bool = False
shadow_color: str = "#000000"
shadow_offset_x: int = 2
shadow_offset_y: int = 2
shadow_blur: float = 0.0
# 背景框
background_enabled: bool = False
background_color: str = "#000000"
background_opacity: float = 0.5
background_padding: int = 8
background_radius: int = 4
# 位置
position: str = DEFAULT_POSITION
margin_v: int = 60
margin_l: int = 40
margin_r: int = 40
# 多行
max_chars_per_line: int = DEFAULT_MAX_CHARS_PER_LINE
line_spacing: int = 0
# 动画
fade_in: float = 0.0
fade_out: float = 0.0
animation_type: str = "none"
@classmethod
def from_dict(cls, config: dict[str, Any] | None) -> "SubtitleStyle":
"""从字典创建样式配置,带安全类型转换."""
if not config or not isinstance(config, dict):
return cls()
def safe_str(key: str, default: str) -> str:
val = config.get(key, default)
return str(val) if val is not None else default
def safe_int(key: str, default: int) -> int:
try:
return int(config.get(key, default))
except (TypeError, ValueError):
return default
def safe_float(key: str, default: float) -> float:
try:
return float(config.get(key, default))
except (TypeError, ValueError):
return default
def safe_bool(key: str, default: bool) -> bool:
return bool(config.get(key, default))
position = safe_str("position", DEFAULT_POSITION)
position = POSITION_ALIASES.get(position, position)
if position not in POSITION_ALIGNMENT:
position = DEFAULT_POSITION
return cls(
font_name=safe_str("font", DEFAULT_FONT),
font_size=safe_int("size", DEFAULT_FONT_SIZE),
font_color=safe_str("color", DEFAULT_COLOR),
bold=safe_bool("bold", False),
italic=safe_bool("italic", False),
stroke_enabled=safe_bool("stroke_enabled", True),
stroke_color=safe_str("stroke_color", DEFAULT_STROKE_COLOR),
stroke_width=safe_float("stroke_width", DEFAULT_STROKE_WIDTH),
shadow_enabled=safe_bool("shadow_enabled", False),
shadow_color=safe_str("shadow_color", "#000000"),
shadow_offset_x=safe_int("shadow_offset_x", 2),
shadow_offset_y=safe_int("shadow_offset_y", 2),
shadow_blur=safe_float("shadow_blur", 0.0),
background_enabled=safe_bool("background_enabled", False),
background_color=safe_str("background_color", "#000000"),
background_opacity=max(0.0, min(1.0, safe_float("background_opacity", 0.5))),
background_padding=safe_int("background_padding", 8),
background_radius=safe_int("background_radius", 4),
position=position,
margin_v=safe_int("margin_v", 60),
margin_l=safe_int("margin_l", 40),
margin_r=safe_int("margin_r", 40),
max_chars_per_line=safe_int("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE),
line_spacing=safe_int("line_spacing", 0),
fade_in=max(0.0, safe_float("fade_in", 0.0)),
fade_out=max(0.0, safe_float("fade_out", 0.0)),
animation_type=safe_str("animation_type", "none"),
)
@property
def alignment(self) -> int:
"""获取 ASS alignment 编号."""
return POSITION_ALIGNMENT.get(self.position, 2)
@property
def ass_font_color(self) -> str:
"""ASS 格式颜色 &HAABBGGRR."""
return hex_to_ass_color(self.font_color)
@property
def ass_stroke_color(self) -> str:
return hex_to_ass_color(self.stroke_color)
@property
def ass_shadow_color(self) -> str:
return hex_to_ass_color(self.shadow_color)
@property
def ass_background_color(self) -> str:
"""背景框颜色(ASS BackColour),带透明度."""
alpha_hex = opacity_to_ass_alpha(self.background_opacity)
color_bgr = hex_to_ass_bgr(self.background_color)
return f"&H{alpha_hex}{color_bgr}"
# ── 字幕片段 ──────────────────────────────────────────────────────────────────
@dataclass
class SubtitleSegment:
"""单个字幕片段."""
start: float # 开始时间(秒)
end: float # 结束时间(秒)
text: str # 字幕文本
style_name: str = "Default" # 使用的样式名
@property
def duration(self) -> float:
"""字幕时长."""
return max(0.0, self.end - self.start)
@property
def is_valid(self) -> bool:
"""是否有效(有文本且时长>0)."""
return bool(self.text) and self.end > self.start
+361
View File
@@ -0,0 +1,361 @@
"""render_layer_utils 模块单元测试."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import pytest
from packages.domain.render_layer_utils import (
LAYER_Z_INDEX,
MAIN_LAYER_ROLES,
PIP_DEFAULT_SCALE,
can_pass_through,
clip_adjusted_duration,
clip_effective_duration,
clip_playback_speed,
estimate_total_duration,
get_layer_z_index,
resolve_layer_role,
)
# ── 辅助数据类 ──────────────────────────────────────────────────────────────
@dataclass
class FakeClip:
duration: float = 0.0
actual_duration: float = 0.0
playback_speed: Any = 1.0
@dataclass
class FakeLayer:
role: str = "main"
clips: list[FakeClip] = field(default_factory=list)
# ── 常量验证 ────────────────────────────────────────────────────────────────
class TestConstants:
def test_layer_z_index_has_expected_keys(self):
assert set(LAYER_Z_INDEX.keys()) == {
"background",
"broll",
"main",
"overlay",
"corner_voice",
"audio",
}
def test_layer_z_index_ordering(self):
assert LAYER_Z_INDEX["background"] < LAYER_Z_INDEX["main"]
assert LAYER_Z_INDEX["main"] == LAYER_Z_INDEX["broll"]
assert LAYER_Z_INDEX["overlay"] > LAYER_Z_INDEX["main"]
assert LAYER_Z_INDEX["corner_voice"] > LAYER_Z_INDEX["main"]
assert LAYER_Z_INDEX["audio"] > LAYER_Z_INDEX["overlay"]
def test_pip_default_scale_positive(self):
assert 0 < PIP_DEFAULT_SCALE < 1
def test_main_layer_roles(self):
assert "main" in MAIN_LAYER_ROLES
assert "broll" in MAIN_LAYER_ROLES
assert "background" in MAIN_LAYER_ROLES
assert "overlay" not in MAIN_LAYER_ROLES
# ── resolve_layer_role ──────────────────────────────────────────────────────
class TestResolveLayerRole:
def test_intro_maps_to_main(self):
assert resolve_layer_role("intro") == "main"
def test_outro_maps_to_main(self):
assert resolve_layer_role("outro") == "main"
def test_overlay_maps_to_overlay(self):
assert resolve_layer_role("overlay") == "overlay"
def test_corner_voice_maps_to_corner_voice(self):
assert resolve_layer_role("corner_voice") == "corner_voice"
def test_background_maps_to_background(self):
assert resolve_layer_role("background") == "background"
def test_b_roll_maps_to_broll(self):
assert resolve_layer_role("b_roll") == "broll"
def test_main_defaults_to_main(self):
assert resolve_layer_role("main") == "main"
def test_main_with_b_roll_role(self):
assert resolve_layer_role("main", {"role": "b_roll"}) == "broll"
def test_main_with_audio_role(self):
assert resolve_layer_role("main", {"role": "audio"}) == "audio"
def test_main_with_other_role_stays_main(self):
assert resolve_layer_role("main", {"role": "overlay"}) == "main"
def test_none_config(self):
assert resolve_layer_role("main", None) == "main"
def test_empty_config(self):
assert resolve_layer_role("main", {}) == "main"
def test_unknown_type_defaults_to_main(self):
assert resolve_layer_role("unknown_type") == "main"
# ── get_layer_z_index ──────────────────────────────────────────────────────
class TestGetLayerZIndex:
def test_known_roles(self):
for role, expected in LAYER_Z_INDEX.items():
assert get_layer_z_index(role) == expected
def test_unknown_role_returns_zero(self):
assert get_layer_z_index("nonexistent") == 0
def test_empty_string_returns_zero(self):
assert get_layer_z_index("") == 0
# ── clip_effective_duration ────────────────────────────────────────────────
class TestClipEffectiveDuration:
def test_explicit_duration_no_actual(self):
assert clip_effective_duration(5.0) == 5.0
def test_explicit_duration_with_shorter_actual(self):
assert clip_effective_duration(5.0, 3.0) == 3.0
def test_explicit_duration_with_longer_actual(self):
assert clip_effective_duration(5.0, 10.0) == 5.0
def test_zero_duration_uses_actual(self):
assert clip_effective_duration(0, 8.0) == 8.0
def test_negative_duration_uses_actual(self):
assert clip_effective_duration(-1.0, 8.0) == 8.0
def test_zero_duration_zero_actual(self):
assert clip_effective_duration(0, 0) == 0.0
def test_no_args_returns_zero(self):
assert clip_effective_duration(0) == 0.0
def test_equal_duration_and_actual(self):
assert clip_effective_duration(5.0, 5.0) == 5.0
# ── clip_playback_speed ────────────────────────────────────────────────────
class TestClipPlaybackSpeed:
def test_normal_speed(self):
assert clip_playback_speed(1.0) == 1.0
def test_fast_speed(self):
assert clip_playback_speed(2.0) == 2.0
def test_slow_speed(self):
assert clip_playback_speed(0.5) == 0.5
def test_zero_speed_defaults_to_one(self):
assert clip_playback_speed(0) == 1.0
def test_negative_speed_defaults_to_one(self):
assert clip_playback_speed(-1.0) == 1.0
def test_none_defaults_to_one(self):
assert clip_playback_speed(None) == 1.0
def test_string_defaults_to_one(self):
assert clip_playback_speed("fast") == 1.0
def test_int_speed(self):
assert clip_playback_speed(2) == 2.0
# ── clip_adjusted_duration ─────────────────────────────────────────────────
class TestClipAdjustedDuration:
def test_normal_speed_same_as_effective(self):
assert clip_adjusted_duration(5.0, 10.0, 1.0) == 5.0
def test_double_speed_half_duration(self):
assert clip_adjusted_duration(10.0, 10.0, 2.0) == pytest.approx(5.0)
def test_half_speed_double_duration(self):
assert clip_adjusted_duration(5.0, 10.0, 0.5) == pytest.approx(10.0)
def test_invalid_speed_uses_default(self):
assert clip_adjusted_duration(5.0, 10.0, 0) == 5.0
def test_zero_duration(self):
assert clip_adjusted_duration(0, 0, 1.0) == 0.0
def test_actual_duration_only(self):
assert clip_adjusted_duration(0, 8.0, 1.0) == 8.0
def test_actual_duration_only_with_speed(self):
assert clip_adjusted_duration(0, 8.0, 2.0) == pytest.approx(4.0)
def test_very_close_to_normal_speed(self):
# 1.0000001 应该被认为接近 1.0,不做除法
result = clip_adjusted_duration(5.0, 10.0, 1.0 + 1e-10)
assert result == 5.0
# ── estimate_total_duration ────────────────────────────────────────────────
class TestEstimateTotalDuration:
def test_empty_layers(self):
assert estimate_total_duration([]) == 0.0
def test_no_main_layer(self):
layers = [FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)])]
assert estimate_total_duration(layers) == 0.0
def test_single_clip_main_layer(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert estimate_total_duration(layers) == pytest.approx(5.0)
def test_multiple_clips_no_transition(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=3.0),
FakeClip(duration=2.0),
FakeClip(duration=5.0),
],
)
]
assert estimate_total_duration(layers) == pytest.approx(10.0)
def test_multiple_clips_with_transition(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=3.0),
FakeClip(duration=2.0),
FakeClip(duration=5.0),
],
)
]
# 3 + 2 + 5 - 2 * 0.5 = 9.0
assert estimate_total_duration(layers, transition_duration=0.5) == pytest.approx(9.0)
def test_prefers_main_over_broll(self):
layers = [
FakeLayer(role="broll", clips=[FakeClip(duration=10.0)]),
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
]
assert estimate_total_duration(layers) == pytest.approx(5.0)
def test_prefers_broll_over_background(self):
layers = [
FakeLayer(role="background", clips=[FakeClip(duration=10.0)]),
FakeLayer(role="broll", clips=[FakeClip(duration=5.0)]),
]
assert estimate_total_duration(layers) == pytest.approx(5.0)
def test_main_layer_empty_clips(self):
layers = [FakeLayer(role="main", clips=[])]
assert estimate_total_duration(layers) == 0.0
def test_minimum_duration(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=0.01),
FakeClip(duration=0.01),
],
)
]
result = estimate_total_duration(layers, transition_duration=0.5)
assert result >= 0.1
def test_with_playback_speed(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=10.0, playback_speed=2.0),
FakeClip(duration=10.0, playback_speed=0.5),
],
)
]
# 5 + 20 = 25
assert estimate_total_duration(layers) == pytest.approx(25.0)
# ── can_pass_through ──────────────────────────────────────────────────────
class TestCanPassThrough:
def test_single_main_clip_no_effects(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is True
def test_single_broll_clip(self):
layers = [FakeLayer(role="broll", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is True
def test_single_background_clip(self):
layers = [FakeLayer(role="background", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is True
def test_multiple_layers(self):
layers = [
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
FakeLayer(role="overlay", clips=[FakeClip(duration=3.0)]),
]
assert can_pass_through(layers) is False
def test_overlay_layer(self):
layers = [FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is False
def test_multiple_clips_in_layer(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=3.0),
FakeClip(duration=2.0),
],
)
]
assert can_pass_through(layers) is False
def test_with_stickers(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers, has_stickers=True) is False
def test_with_watermark(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers, has_watermark=True) is False
def test_with_stickers_and_watermark(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers, has_stickers=True, has_watermark=True) is False
def test_empty_layer_list(self):
assert can_pass_through([]) is False
def test_audio_layer_only(self):
layers = [FakeLayer(role="audio", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is False
-401
View File
@@ -1,401 +0,0 @@
"""subtitle_style 领域模型单测 — 纯逻辑."""
from __future__ import annotations
import pytest
from packages.domain.subtitle_style import (
ALLOWED_SUBTITLE_EXTENSIONS,
DEFAULT_COLOR,
DEFAULT_FONT,
DEFAULT_FONT_SIZE,
DEFAULT_MAX_CHARS_PER_LINE,
DEFAULT_POSITION,
DEFAULT_STROKE_COLOR,
DEFAULT_STROKE_WIDTH,
POSITION_ALIGNMENT,
POSITION_ALIASES,
SubtitleSegment,
SubtitleStyle,
escape_ass_text,
format_ass_time,
hex_to_ass_bgr,
hex_to_ass_color,
opacity_to_ass_alpha,
wrap_text,
)
# ── 常量测试 ──────────────────────────────────────────────────────────────────
class TestConstants:
def test_position_alignment_has_9_positions(self):
assert len(POSITION_ALIGNMENT) == 9
assert POSITION_ALIGNMENT["bottom_center"] == 2
assert POSITION_ALIGNMENT["top_center"] == 8
assert POSITION_ALIGNMENT["center"] == 5
def test_position_aliases(self):
assert POSITION_ALIASES["top"] == "top_center"
assert POSITION_ALIASES["bottom"] == "bottom_center"
assert POSITION_ALIASES["middle"] == "center"
def test_default_values(self):
assert DEFAULT_FONT == "思源黑体"
assert DEFAULT_FONT_SIZE == 24
assert DEFAULT_COLOR == "#FFFFFF"
assert DEFAULT_POSITION == "bottom_center"
def test_allowed_extensions(self):
assert ".srt" in ALLOWED_SUBTITLE_EXTENSIONS
assert ".ass" in ALLOWED_SUBTITLE_EXTENSIONS
assert ".vtt" in ALLOWED_SUBTITLE_EXTENSIONS
# ── 工具函数测试 ──────────────────────────────────────────────────────────────
class TestHexToAssColor:
def test_white(self):
assert hex_to_ass_color("#FFFFFF") == "&H00FFFFFF"
def test_black(self):
assert hex_to_ass_color("#000000") == "&H00000000"
def test_red(self):
assert hex_to_ass_color("#FF0000") == "&H000000FF"
def test_blue(self):
assert hex_to_ass_color("#0000FF") == "&H00FF0000"
def test_green(self):
assert hex_to_ass_color("#00FF00") == "&H0000FF00"
def test_without_hash(self):
assert hex_to_ass_color("FF0000") == "&H000000FF"
def test_lowercase(self):
assert hex_to_ass_color("#ff0000") == "&H000000FF"
def test_invalid_length_returns_white(self):
assert hex_to_ass_color("#FFF") == "&H00FFFFFF"
assert hex_to_ass_color("#FF000000") == "&H00FFFFFF"
def test_empty_string(self):
assert hex_to_ass_color("") == "&H00FFFFFF"
class TestHexToAssBgr:
def test_white(self):
assert hex_to_ass_bgr("#FFFFFF") == "FFFFFF"
def test_red(self):
assert hex_to_ass_bgr("#FF0000") == "0000FF"
def test_blue(self):
assert hex_to_ass_bgr("#0000FF") == "FF0000"
def test_invalid_length(self):
assert hex_to_ass_bgr("#FFF") == "FFFFFF"
class TestOpacityToAssAlpha:
def test_fully_opaque(self):
assert opacity_to_ass_alpha(1.0) == "00"
def test_fully_transparent(self):
assert opacity_to_ass_alpha(0.0) == "FF"
def test_half(self):
assert opacity_to_ass_alpha(0.5) == "80"
def test_above_1_clamped(self):
assert opacity_to_ass_alpha(1.5) == "00"
def test_below_0_clamped(self):
assert opacity_to_ass_alpha(-0.5) == "FF"
class TestEscapeAssText:
def test_newline_unix(self):
assert escape_ass_text("hello\nworld") == "hello\\Nworld"
def test_newline_windows(self):
assert escape_ass_text("hello\r\nworld") == "hello\\Nworld"
def test_newline_mac(self):
assert escape_ass_text("hello\rworld") == "hello\\Nworld"
def test_curly_braces(self):
assert escape_ass_text("{text}") == "(text)"
def test_mixed(self):
assert escape_ass_text("hello\n{world}\r\nend") == "hello\\N(world)\\Nend"
def test_empty(self):
assert escape_ass_text("") == ""
class TestFormatAssTime:
def test_zero(self):
assert format_ass_time(0.0) == "0:00:00.00"
def test_seconds_only(self):
assert format_ass_time(5.5) == "0:00:05.50"
def test_minutes(self):
assert format_ass_time(65.25) == "0:01:05.25"
def test_hours(self):
assert format_ass_time(3661.5) == "1:01:01.50"
def test_negative_returns_zero(self):
assert format_ass_time(-1.0) == "0:00:00.00"
def test_centiseconds_precision(self):
assert format_ass_time(1.234) == "0:00:01.23"
class TestWrapText:
def test_short_text_no_wrap(self):
assert wrap_text("你好", 10) == ["你好"]
def test_exact_length_no_wrap(self):
text = "你" * 10
result = wrap_text(text, 10)
assert len(result) == 1
assert len(result[0]) == 10
def test_long_text_breaks_at_max(self):
text = "你" * 25
result = wrap_text(text, 10)
assert len(result) == 3
assert len(result[0]) == 10
assert len(result[1]) == 10
assert len(result[2]) == 5
def test_breaks_at_punctuation(self):
# "一二三四五六。七八九十"共10字,"。"在索引6
# max_chars=8 时,从 8 往回找到 4,会命中索引6的"。"
text = "一二三四五六。七八九十"
result = wrap_text(text, 8)
assert len(result) == 2
assert result[0] == "一二三四五六。"
assert result[1] == "七八九十"
def test_no_punctuation_breaks_at_max(self):
text = "一二三四五六七八九十一二三四五六七八九十"
result = wrap_text(text, 10)
assert len(result[0]) == 10
def test_empty_text(self):
assert wrap_text("", 10) == [""]
def test_zero_max_chars(self):
assert wrap_text("hello", 0) == ["hello"]
def test_negative_max_chars(self):
result = wrap_text("hello", -5)
assert isinstance(result, list)
assert len(result) == 1
# ── SubtitleStyle 测试 ───────────────────────────────────────────────────────
class TestSubtitleStyleDefaults:
def test_default_values(self):
style = SubtitleStyle()
assert style.font_name == DEFAULT_FONT
assert style.font_size == DEFAULT_FONT_SIZE
assert style.font_color == DEFAULT_COLOR
assert style.bold is False
assert style.italic is False
assert style.stroke_enabled is True
assert style.stroke_color == DEFAULT_STROKE_COLOR
assert style.stroke_width == DEFAULT_STROKE_WIDTH
assert style.position == DEFAULT_POSITION
assert style.max_chars_per_line == DEFAULT_MAX_CHARS_PER_LINE
class TestSubtitleStyleFromDict:
def test_none_returns_default(self):
style = SubtitleStyle.from_dict(None)
assert style.font_name == DEFAULT_FONT
def test_empty_dict_returns_default(self):
style = SubtitleStyle.from_dict({})
assert style.font_size == DEFAULT_FONT_SIZE
def test_custom_font(self):
style = SubtitleStyle.from_dict({"font": "微软雅黑", "size": 32})
assert style.font_name == "微软雅黑"
assert style.font_size == 32
def test_color(self):
style = SubtitleStyle.from_dict({"color": "#FF0000"})
assert style.font_color == "#FF0000"
def test_bold_italic(self):
style = SubtitleStyle.from_dict({"bold": True, "italic": True})
assert style.bold is True
assert style.italic is True
def test_stroke_config(self):
style = SubtitleStyle.from_dict(
{
"stroke_enabled": False,
"stroke_color": "#00FF00",
"stroke_width": 2.0,
}
)
assert style.stroke_enabled is False
assert style.stroke_color == "#00FF00"
assert style.stroke_width == 2.0
def test_shadow_config(self):
style = SubtitleStyle.from_dict(
{
"shadow_enabled": True,
"shadow_color": "#111111",
"shadow_offset_x": 4,
"shadow_offset_y": 4,
"shadow_blur": 1.5,
}
)
assert style.shadow_enabled is True
assert style.shadow_color == "#111111"
assert style.shadow_offset_x == 4
assert style.shadow_offset_y == 4
assert style.shadow_blur == 1.5
def test_background_config(self):
style = SubtitleStyle.from_dict(
{
"background_enabled": True,
"background_color": "#000000",
"background_opacity": 0.7,
"background_padding": 10,
"background_radius": 6,
}
)
assert style.background_enabled is True
assert style.background_opacity == 0.7
assert style.background_padding == 10
def test_background_opacity_clamped_0_to_1(self):
style = SubtitleStyle.from_dict({"background_opacity": -0.5})
assert style.background_opacity == 0.0
style2 = SubtitleStyle.from_dict({"background_opacity": 1.5})
assert style2.background_opacity == 1.0
def test_position_valid(self):
style = SubtitleStyle.from_dict({"position": "top_center"})
assert style.position == "top_center"
def test_position_alias(self):
style = SubtitleStyle.from_dict({"position": "top"})
assert style.position == "top_center"
def test_position_invalid_falls_back(self):
style = SubtitleStyle.from_dict({"position": "invalid_pos"})
assert style.position == DEFAULT_POSITION
def test_margins(self):
style = SubtitleStyle.from_dict({"margin_v": 80, "margin_l": 50, "margin_r": 50})
assert style.margin_v == 80
assert style.margin_l == 50
assert style.margin_r == 50
def test_max_chars_per_line(self):
style = SubtitleStyle.from_dict({"max_chars_per_line": 15})
assert style.max_chars_per_line == 15
def test_line_spacing(self):
style = SubtitleStyle.from_dict({"line_spacing": 4})
assert style.line_spacing == 4
def test_fade_in_out(self):
style = SubtitleStyle.from_dict({"fade_in": 0.5, "fade_out": 1.0})
assert style.fade_in == 0.5
assert style.fade_out == 1.0
def test_fade_negative_clamped(self):
style = SubtitleStyle.from_dict({"fade_in": -1, "fade_out": -2})
assert style.fade_in == 0.0
assert style.fade_out == 0.0
def test_animation_type(self):
style = SubtitleStyle.from_dict({"animation_type": "fade"})
assert style.animation_type == "fade"
def test_invalid_int_falls_back(self):
style = SubtitleStyle.from_dict({"size": "not_a_number"})
assert style.font_size == DEFAULT_FONT_SIZE
def test_invalid_float_falls_back(self):
style = SubtitleStyle.from_dict({"stroke_width": "abc"})
assert style.stroke_width == DEFAULT_STROKE_WIDTH
class TestSubtitleStyleProperties:
def test_alignment_bottom_center(self):
style = SubtitleStyle(position="bottom_center")
assert style.alignment == 2
def test_alignment_top_center(self):
style = SubtitleStyle(position="top_center")
assert style.alignment == 8
def test_ass_font_color(self):
style = SubtitleStyle(font_color="#FF0000")
assert style.ass_font_color == "&H000000FF"
def test_ass_stroke_color(self):
style = SubtitleStyle(stroke_color="#00FF00")
assert style.ass_stroke_color == "&H0000FF00"
def test_ass_shadow_color(self):
style = SubtitleStyle(shadow_color="#0000FF")
assert style.ass_shadow_color == "&H00FF0000"
def test_ass_background_color(self):
style = SubtitleStyle(background_color="#FF0000", background_opacity=0.5)
# alpha = 255 - 127 = 128 = 0x80, bgr of red = 0000FF
assert style.ass_background_color == "&H800000FF"
# ── SubtitleSegment 测试 ─────────────────────────────────────────────────────
class TestSubtitleSegment:
def test_basic(self):
seg = SubtitleSegment(start=1.0, end=3.0, text="你好")
assert seg.start == 1.0
assert seg.end == 3.0
assert seg.text == "你好"
assert seg.style_name == "Default"
def test_custom_style(self):
seg = SubtitleSegment(start=0, end=2, text="hi", style_name="Title")
assert seg.style_name == "Title"
def test_duration(self):
seg = SubtitleSegment(start=1.5, end=4.0, text="test")
assert seg.duration == 2.5
def test_duration_zero_when_end_before_start(self):
seg = SubtitleSegment(start=5.0, end=3.0, text="test")
assert seg.duration == 0.0
def test_is_valid_true(self):
seg = SubtitleSegment(start=0, end=2, text="hello")
assert seg.is_valid is True
def test_is_valid_empty_text(self):
seg = SubtitleSegment(start=0, end=2, text="")
assert seg.is_valid is False
def test_is_valid_zero_duration(self):
seg = SubtitleSegment(start=1, end=1, text="hello")
assert seg.is_valid is False