9ba036abb3
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 9s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 14s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 12s
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 51s
CI/CD Pipeline / Validate - Style (push) Successful in 5m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m1s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m15s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m10s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m3s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Integration Tests (push) Successful in 4m26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 48s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m3s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m40s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m53s
CI/CD Pipeline / Validate - Security (push) Successful in 10m58s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m40s
CI/CD Pipeline / Unit Tests (push) Successful in 12m39s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
API Base Image Build / Build API Base Image (push) Successful in 31m37s
Worker Base Image Build / Build Worker Base Image (push) Successful in 38m53s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
412 lines
14 KiB
Python
Executable File
412 lines
14 KiB
Python
Executable File
"""字幕样式领域模型 — 纯逻辑,无外部依赖.
|
||
|
||
抽离自 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"
|
||
|
||
# 逐行独立样式覆盖(#2001 爆款标题样式面板)
|
||
# list[dict],每项可选字段: line_index(0-based,支持负数从末尾倒数),
|
||
# color/font/size/bold/italic/stroke_color/stroke_width/shadow_color/shadow_offset_x/shadow_offset_y
|
||
# 渲染时按行索引匹配,用 ASS 内联 override 标签包裹该行。缺省字段继承主样式。
|
||
line_overrides: list[dict[str, Any]] = field(default_factory=list)
|
||
|
||
@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
|
||
|
||
# 逐行覆盖:仅保留 dict 类型项;非 dict 项过滤掉避免渲染崩溃
|
||
raw_overrides = config.get("line_overrides") or []
|
||
line_overrides: list[dict[str, Any]] = []
|
||
if isinstance(raw_overrides, list):
|
||
for item in raw_overrides:
|
||
if isinstance(item, dict):
|
||
line_overrides.append(dict(item))
|
||
|
||
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"),
|
||
line_overrides=line_overrides,
|
||
)
|
||
|
||
@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 build_line_override_tag(self, line_index: int, total_lines: int) -> str:
|
||
r"""按 line_overrides 配置为指定行构造 ASS 内联 override 标签 {\c&HBBGGRR&...}.
|
||
|
||
仅返回大括号包裹的 override 标签串;调用方拼到该行文本前即可。
|
||
未配置该覆盖项时返回空串。缺省字段继承主样式,不生成对应 tag。
|
||
|
||
Args:
|
||
line_index: 行号(0-based);支持负数(-1 为最后一行)。
|
||
total_lines: 总行数(用于解析负数索引)。
|
||
"""
|
||
if not self.line_overrides or total_lines <= 0:
|
||
return ""
|
||
|
||
# 解析负数索引
|
||
resolved = line_index if line_index >= 0 else total_lines + line_index
|
||
if resolved < 0 or resolved >= total_lines:
|
||
return ""
|
||
|
||
override: dict[str, Any] | None = None
|
||
for item in self.line_overrides:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
idx = item.get("line_index")
|
||
try:
|
||
idx_int = int(idx) if idx is not None else None
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if idx_int is None:
|
||
continue
|
||
if idx_int < 0:
|
||
idx_int = total_lines + idx_int
|
||
if idx_int == resolved:
|
||
override = item
|
||
break
|
||
|
||
if not override:
|
||
return ""
|
||
|
||
tags: list[str] = []
|
||
|
||
# 主色(字体颜色):\c&HBBGGRR&
|
||
color_val = override.get("color")
|
||
if isinstance(color_val, str) and color_val:
|
||
tags.append(f"\\c{hex_to_ass_color(color_val)}")
|
||
|
||
# 字号:\fsN
|
||
size_val = override.get("size") or override.get("font_size")
|
||
try:
|
||
size_int = int(size_val) if size_val is not None else None
|
||
if size_int and size_int > 0:
|
||
tags.append(f"\\fs{size_int}")
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
# 字体:\fnFontName
|
||
font_val = override.get("font") or override.get("font_name")
|
||
if isinstance(font_val, str) and font_val:
|
||
tags.append(f"\\fn{font_val}")
|
||
|
||
# 粗体:\b1 / \b0
|
||
bold_val = override.get("bold")
|
||
if isinstance(bold_val, bool):
|
||
tags.append("\\b1" if bold_val else "\\b0")
|
||
|
||
# 斜体:\i1 / \i0
|
||
italic_val = override.get("italic")
|
||
if isinstance(italic_val, bool):
|
||
tags.append("\\i1" if italic_val else "\\i0")
|
||
|
||
# 描边色:\3c&HBBGGRR&
|
||
stroke_c = override.get("stroke_color")
|
||
if isinstance(stroke_c, str) and stroke_c:
|
||
tags.append(f"\\3c{hex_to_ass_color(stroke_c)}")
|
||
|
||
# 描边宽:\bordN
|
||
stroke_w = override.get("stroke_width")
|
||
try:
|
||
sw = float(stroke_w) if stroke_w is not None else None
|
||
if sw is not None and sw >= 0:
|
||
tags.append(f"\\bord{sw:g}")
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
# 阴影色:\4c&HBBGGRR&
|
||
shadow_c = override.get("shadow_color")
|
||
if isinstance(shadow_c, str) and shadow_c:
|
||
tags.append(f"\\4c{hex_to_ass_color(shadow_c)}")
|
||
|
||
# 阴影偏移:\shadN(单值,同时设置 x/y;精细控制用 \xshad/\yshad)
|
||
sx = override.get("shadow_offset_x")
|
||
sy = override.get("shadow_offset_y")
|
||
try:
|
||
sx_i = int(sx) if sx is not None else None
|
||
sy_i = int(sy) if sy is not None else None
|
||
if sx_i is not None:
|
||
tags.append(f"\\xshad{sx_i}")
|
||
if sy_i is not None:
|
||
tags.append(f"\\yshad{sy_i}")
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
if not tags:
|
||
return ""
|
||
return "{" + "".join(tags) + "}"
|
||
|
||
def apply_line_overrides(self, text: str) -> str:
|
||
"""按 line_overrides 对 ASS 文本(已 escape、换行用 \\N 分隔)逐行套 override 标签.
|
||
|
||
仅对换行后的每行首加对应 override 标签;无 override 的行保持原样。
|
||
"""
|
||
if not self.line_overrides or not text:
|
||
return text
|
||
if "\\N" not in text:
|
||
# 单行
|
||
tag = self.build_line_override_tag(0, 1)
|
||
return tag + text if tag else text
|
||
lines = text.split("\\N")
|
||
total = len(lines)
|
||
out: list[str] = []
|
||
for i, ln in enumerate(lines):
|
||
tag = self.build_line_override_tag(i, total)
|
||
out.append(tag + ln if tag else ln)
|
||
return "\\N".join(out)
|
||
|
||
|
||
# ── 字幕片段 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@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
|