Files
xiaoxia 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
feat(#2001): 爆款标题样式配置面板后端支持 (#2004)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-21 03:01:24 +08:00

412 lines
14 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""字幕样式领域模型 — 纯逻辑,无外部依赖.
抽离自 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