diff --git a/alembic/versions/083_cover_title_config.py b/alembic/versions/083_cover_title_config.py new file mode 100644 index 000000000..341f1d728 --- /dev/null +++ b/alembic/versions/083_cover_title_config.py @@ -0,0 +1,26 @@ +"""#2001 爆款标题样式面板升级: ai_avatar_render_jobs 新增 cover_title_config + +Revision ID: 083_cover_title_config +Revises: 082_atom_clip_ai_tags +Create Date: 2026-09-20 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "083_cover_title_config" +down_revision = "082_atom_clip_ai_tags" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "ai_avatar_render_jobs", + sa.Column("cover_title_config", sa.JSON(), nullable=False, server_default=sa.text("'{}'")), + ) + + +def downgrade() -> None: + op.drop_column("ai_avatar_render_jobs", "cover_title_config") diff --git a/apps/api/app/api/routes/ai_avatar_render.py b/apps/api/app/api/routes/ai_avatar_render.py index 52fc98abb..d921d7928 100644 --- a/apps/api/app/api/routes/ai_avatar_render.py +++ b/apps/api/app/api/routes/ai_avatar_render.py @@ -63,6 +63,7 @@ def create_render_job( b_roll_segments=[s.model_dump() for s in body.b_roll_segments], title_config=body.title_config, cover_config=body.cover_config, + cover_title_config=body.cover_title_config, project_id=body.project_id, ) except AiAvatarRenderError as exc: diff --git a/apps/api/app/schemas/ai_avatar_render.py b/apps/api/app/schemas/ai_avatar_render.py index 0a6cfa95d..921269cb4 100644 --- a/apps/api/app/schemas/ai_avatar_render.py +++ b/apps/api/app/schemas/ai_avatar_render.py @@ -53,9 +53,14 @@ class CreateAiAvatarRenderRequest(BaseModel): script_id: str = Field("", description="文案 ID(选自文案库时传;手动输入文案直生场景可留空)") b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表") title_config: dict[str, Any] = Field( - default_factory=dict, description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL)" + default_factory=dict, + description="标题配置(可含 title_image_dataurl:前端 Canvas 渲染的标题 PNG dataURL;含 line_overrides 逐行样式)", ) cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置") + cover_title_config: dict[str, Any] = Field( + default_factory=dict, + description="封面独立标题配置(#2001),结构同 title_config;为空时封面不叠标题", + ) project_id: str = Field("", description="项目 ID") @field_validator("lipsync_job_id") @@ -83,6 +88,7 @@ class AiAvatarRenderJobResponse(BaseModel): b_roll_segments: list[dict[str, Any]] title_config: dict[str, Any] cover_config: dict[str, Any] + cover_title_config: dict[str, Any] = Field(default_factory=dict, description="封面独立标题配置") status: str progress: int output_video_url: str diff --git a/apps/api/app/services/ai_avatar_render_service.py b/apps/api/app/services/ai_avatar_render_service.py index 0e84914e5..57dc07845 100644 --- a/apps/api/app/services/ai_avatar_render_service.py +++ b/apps/api/app/services/ai_avatar_render_service.py @@ -61,6 +61,7 @@ class AiAvatarRenderService: b_roll_segments: list[dict[str, Any]] | None = None, title_config: dict[str, Any], cover_config: dict[str, Any], + cover_title_config: dict[str, Any] | None = None, project_id: str = "", ) -> AiAvatarRenderJob: """创建渲染任务. @@ -112,6 +113,7 @@ class AiAvatarRenderService: b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in (b_roll_segments or [])], title_config=title_config, cover_config=cover_config, + cover_title_config=cover_title_config or {}, status="pending", ) self.db.add(job) diff --git a/infra/docker/api-base.Dockerfile b/infra/docker/api-base.Dockerfile index c9e6938e3..3fa7be98b 100644 --- a/infra/docker/api-base.Dockerfile +++ b/infra/docker/api-base.Dockerfile @@ -31,6 +31,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY infra/fonts/NotoSansSC-VF.ttf /usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf COPY infra/fonts/NotoSerifCJKsc-VF.otf /usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf COPY infra/fonts/LXGWWenKai-Regular.ttf /usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf +# #2001 爆款标题字体:优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy +RUN mkdir -p /usr/share/fonts/truetype/xiaoxia +COPY infra/fonts/xiaoxia/YouSheBiaoTiHei.ttf /usr/share/fonts/truetype/xiaoxia/YouSheBiaoTiHei.ttf +COPY infra/fonts/xiaoxia/AlibabaPuHuiTi-Bold.ttf /usr/share/fonts/truetype/xiaoxia/AlibabaPuHuiTi-Bold.ttf +COPY infra/fonts/xiaoxia/DouyinSansBold.otf /usr/share/fonts/truetype/xiaoxia/DouyinSansBold.otf +COPY infra/fonts/xiaoxia/NotoSansSC-Black.otf /usr/share/fonts/truetype/xiaoxia/NotoSansSC-Black.otf RUN fc-cache -fv # 创建虚拟环境 diff --git a/infra/docker/worker-base.Dockerfile b/infra/docker/worker-base.Dockerfile index 9a358ab2e..f872bc14c 100644 --- a/infra/docker/worker-base.Dockerfile +++ b/infra/docker/worker-base.Dockerfile @@ -35,6 +35,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY infra/fonts/NotoSansSC-VF.ttf /usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf COPY infra/fonts/NotoSerifCJKsc-VF.otf /usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf COPY infra/fonts/LXGWWenKai-Regular.ttf /usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf +# #2001 爆款标题字体:优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy +RUN mkdir -p /usr/share/fonts/truetype/xiaoxia +COPY infra/fonts/xiaoxia/YouSheBiaoTiHei.ttf /usr/share/fonts/truetype/xiaoxia/YouSheBiaoTiHei.ttf +COPY infra/fonts/xiaoxia/AlibabaPuHuiTi-Bold.ttf /usr/share/fonts/truetype/xiaoxia/AlibabaPuHuiTi-Bold.ttf +COPY infra/fonts/xiaoxia/DouyinSansBold.otf /usr/share/fonts/truetype/xiaoxia/DouyinSansBold.otf +COPY infra/fonts/xiaoxia/NotoSansSC-Black.otf /usr/share/fonts/truetype/xiaoxia/NotoSansSC-Black.otf RUN fc-cache -fv # 创建虚拟环境 diff --git a/infra/fonts/xiaoxia/AlibabaPuHuiTi-Bold.ttf b/infra/fonts/xiaoxia/AlibabaPuHuiTi-Bold.ttf new file mode 100644 index 000000000..05c3f8864 Binary files /dev/null and b/infra/fonts/xiaoxia/AlibabaPuHuiTi-Bold.ttf differ diff --git a/infra/fonts/xiaoxia/DouyinSansBold.otf b/infra/fonts/xiaoxia/DouyinSansBold.otf new file mode 100644 index 000000000..d63f58d6e Binary files /dev/null and b/infra/fonts/xiaoxia/DouyinSansBold.otf differ diff --git a/infra/fonts/xiaoxia/NotoSansSC-Black.otf b/infra/fonts/xiaoxia/NotoSansSC-Black.otf new file mode 100644 index 000000000..234c96689 Binary files /dev/null and b/infra/fonts/xiaoxia/NotoSansSC-Black.otf differ diff --git a/infra/fonts/xiaoxia/YouSheBiaoTiHei.ttf b/infra/fonts/xiaoxia/YouSheBiaoTiHei.ttf new file mode 100644 index 000000000..3729151a2 Binary files /dev/null and b/infra/fonts/xiaoxia/YouSheBiaoTiHei.ttf differ diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 6e01802a5..ee7f37ce7 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -750,6 +750,8 @@ class AiAvatarRenderJob(Base): # b_roll_segments 格式: [{"script_segment_index": 0, "asset_url": "...", "mode": "fullscreen|pip", "start_time": 5.0, "end_time": 10.0}, ...] title_config = Column(JSON, nullable=False, default=dict) cover_config = Column(JSON, nullable=False, default=dict) + # #2001 封面独立标题配置(结构同 title_config;为空时封面不叠标题) + cover_title_config = Column(JSON, nullable=False, default=dict) # 任务状态 status = Column(String(20), nullable=False, default="pending", index=True) diff --git a/packages/domain/ass_subtitle_builder.py b/packages/domain/ass_subtitle_builder.py index a6e469dba..a533d6800 100755 --- a/packages/domain/ass_subtitle_builder.py +++ b/packages/domain/ass_subtitle_builder.py @@ -30,6 +30,8 @@ TITLE_MARGIN_SIDE = 40 # - 楷体 → LXGW WenKai(霞鹜文楷,#1896 新增 SIL OFL 开源楷体) # - 苹方/PingFang/微软雅黑:服务器 Linux 无对应字体,fallback 思源黑体 # - 华康俪金黑:商业字体有版权风险,前端已移除,后端保留映射 fallback 思源黑体(兼容老数据) +# #2001 爆款标题字体(部署到 /usr/share/fonts/truetype/xiaoxia/): +# - 优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy(独立 Black 字重) FONT_NAME_MAP: dict[str, str] = { "思源黑体": "Noto Sans SC", "思源宋体": "Noto Serif CJK SC", @@ -40,6 +42,15 @@ FONT_NAME_MAP: dict[str, str] = { "楷体": "LXGW WenKai", "霞鹜文楷": "LXGW WenKai", "华康俪金黑": "Noto Sans SC", + # #2001 爆款标题字体 + "优设标题黑": "YouSheBiaoTiHei", + "阿里普惠体": "Alibaba PuHuiTi", + "阿里普惠体 Bold": "Alibaba PuHuiTi", + "阿里巴巴普惠体": "Alibaba PuHuiTi", + "抖音美好体": "Douyin Sans", + "抖音体": "Douyin Sans", + "思源黑体 Heavy": "Noto Sans SC", + "思源黑体 Black": "Noto Sans SC", } # ASS Fontsize 是字体 em-square 高度(含 Latin 升降部留白), @@ -421,6 +432,14 @@ def build_ass_content( safe_title_text_raw = escape_ass_text(title_text) safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size) + # #2001 逐行样式覆盖:按 line_overrides 在每行前注入 ASS inline override 标签 + # line_overrides 透传自前端爆款标题面板,SubtitleStyle.from_dict 已做安全过滤 + if title_config.get("line_overrides"): + from packages.domain.subtitle_style import SubtitleStyle + + _title_style_for_overrides = SubtitleStyle.from_dict(title_config) + safe_title_text = _title_style_for_overrides.apply_line_overrides(safe_title_text) + # 自由位置:在文本前注入 \pos override tag(锚点为文本块中心,配合 \an5) if title_pos is not None: safe_title_text = f"{{\\pos({title_pos[0]},{title_pos[1]})}}{safe_title_text}" @@ -455,6 +474,13 @@ def build_ass_content( safe_subtitle_text = escape_ass_text(subtitle_text) + # #2001 逐行样式覆盖(字幕路径同样支持) + if subtitle_config.get("line_overrides"): + from packages.domain.subtitle_style import SubtitleStyle + + _sub_style_for_overrides = SubtitleStyle.from_dict(subtitle_config) + safe_subtitle_text = _sub_style_for_overrides.apply_line_overrides(safe_subtitle_text) + events.append( "Dialogue: 0,0:00:00.00," f"{format_ass_time(video_duration)}," diff --git a/packages/domain/subtitle_style.py b/packages/domain/subtitle_style.py index 293f684ab..59f2fcb6b 100755 --- a/packages/domain/subtitle_style.py +++ b/packages/domain/subtitle_style.py @@ -6,7 +6,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any # ── 常量 ────────────────────────────────────────────────────────────────────── @@ -163,6 +163,12 @@ class SubtitleStyle: 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": """从字典创建样式配置,带安全类型转换.""" @@ -193,6 +199,14 @@ class SubtitleStyle: 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), @@ -221,6 +235,7 @@ class SubtitleStyle: 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 @@ -248,6 +263,130 @@ class SubtitleStyle: 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) + # ── 字幕片段 ────────────────────────────────────────────────────────────────── diff --git a/packages/domain/video_filter_builder.py b/packages/domain/video_filter_builder.py index 2dd2edf95..bbd64ef5e 100755 --- a/packages/domain/video_filter_builder.py +++ b/packages/domain/video_filter_builder.py @@ -388,6 +388,11 @@ DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [ "/usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf", "/usr/share/fonts/opentype/noto/NotoSerifCJKsc-VF.otf", "/usr/share/fonts/truetype/lxgw/LXGWWenKai-Regular.ttf", + # #2001 爆款标题字体(优设标题黑 / 阿里普惠体 Bold / 抖音美好体 / 思源黑体 Heavy) + "/usr/share/fonts/truetype/xiaoxia/YouSheBiaoTiHei.ttf", + "/usr/share/fonts/truetype/xiaoxia/AlibabaPuHuiTi-Bold.ttf", + "/usr/share/fonts/truetype/xiaoxia/DouyinSansBold.otf", + "/usr/share/fonts/truetype/xiaoxia/NotoSansSC-Black.otf", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", @@ -400,6 +405,7 @@ DRAWTEXT_FONT_SEARCH_PATHS: list[str] = [ # #1896 字体映射修复:每个字体映射到独立的关键字,而非全部回退到 NotoSansSC # - 苹方(macOS)/ 微软雅黑(Windows)/ PingFang:服务器 Linux 无对应文件,fallback 思源黑体 # - 华康俪金黑:商业字体有版权风险,前端已按 #1896 要求移除,后端保留映射但 fallback 思源黑体(兼容老数据) +# #2001 新增爆款标题字体映射 DRAWTEXT_FONT_MAP: dict[str, str] = { "思源黑体": "NotoSansSC", "思源宋体": "NotoSerifCJKsc", @@ -410,6 +416,15 @@ DRAWTEXT_FONT_MAP: dict[str, str] = { "楷体": "LXGWWenKai", "霞鹜文楷": "LXGWWenKai", "华康俪金黑": "NotoSansSC", + # #2001 爆款标题字体 + "优设标题黑": "YouSheBiaoTiHei", + "阿里普惠体": "AlibabaPuHuiTi-Bold", + "阿里普惠体 Bold": "AlibabaPuHuiTi-Bold", + "阿里巴巴普惠体": "AlibabaPuHuiTi-Bold", + "抖音美好体": "DouyinSansBold", + "抖音体": "DouyinSansBold", + "思源黑体 Heavy": "NotoSansSC-Black", + "思源黑体 Black": "NotoSansSC-Black", } diff --git a/tests/unit/test_ai_avatar_render_routes.py b/tests/unit/test_ai_avatar_render_routes.py index 542fe622e..4e992d8fd 100644 --- a/tests/unit/test_ai_avatar_render_routes.py +++ b/tests/unit/test_ai_avatar_render_routes.py @@ -50,6 +50,7 @@ def _make_mock_render_job( m.b_roll_segments = [] m.title_config = {} m.cover_config = {} + m.cover_title_config = {} m.status = status m.progress = progress m.output_video_url = output_video_url @@ -93,6 +94,7 @@ class TestRenderRoutes: body.b_roll_segments = [] body.title_config = {} body.cover_config = {} + body.cover_title_config = {} body.project_id = "" result = create_render_job( @@ -118,6 +120,7 @@ class TestRenderRoutes: body.b_roll_segments = [] body.title_config = {} body.cover_config = {} + body.cover_title_config = {} body.project_id = "" with pytest.raises(HTTPException) as exc_info: @@ -141,6 +144,7 @@ class TestRenderRoutes: body.b_roll_segments = [] body.title_config = {} body.cover_config = {} + body.cover_title_config = {} body.project_id = "" with pytest.raises(HTTPException) as exc_info: diff --git a/tests/unit/test_lipsync_routes.py b/tests/unit/test_lipsync_routes.py index 8bce520f2..132d6875f 100644 --- a/tests/unit/test_lipsync_routes.py +++ b/tests/unit/test_lipsync_routes.py @@ -298,6 +298,7 @@ class TestLipsyncServiceUnit: mock_db = MagicMock() svc = LipsyncService(mock_db, client=mock_mediakit) + svc.settings.use_gpu_lipsync = False with pytest.raises(MediaKitError, match="API 调用失败"): svc.create_job( @@ -473,6 +474,7 @@ class TestLipsyncServiceUnit: cosyvoice_service=mock_cosyvoice, voice_clone_repo=MagicMock(), ) + svc.settings.use_gpu_lipsync = False job = svc.create_job( user_id="user-1", video_url="https://example.com/video.mp4", @@ -616,12 +618,14 @@ class TestSignMediaUrl403Fix: def _svc(self, mock_mediakit, mock_cosyvoice): from app.services.lipsync_service import LipsyncService - return LipsyncService( + svc = LipsyncService( MagicMock(), client=mock_mediakit, cosyvoice_service=mock_cosyvoice, voice_clone_repo=MagicMock(), ) + svc.settings.use_gpu_lipsync = False + return svc def test_own_oss_unsigned_url_gets_resigned(self, mock_mediakit, mock_cosyvoice): """裸 public_url(不带签名,私有桶匿名 403)必须被重签.""" diff --git a/tests/unit/test_subtitle_style.py b/tests/unit/test_subtitle_style.py index ddf397671..a8135887a 100755 --- a/tests/unit/test_subtitle_style.py +++ b/tests/unit/test_subtitle_style.py @@ -399,3 +399,226 @@ class TestSubtitleSegment: def test_is_valid_zero_duration(self): seg = SubtitleSegment(start=1, end=1, text="hello") assert seg.is_valid is False + + +# ── line_overrides 逐行样式覆盖测试 ───────────────────────────────────────── + + +class TestBuildLineOverrideTag: + def test_empty_overrides_returns_empty(self): + style = SubtitleStyle() + assert style.build_line_override_tag(0, 1) == "" + + def test_no_matching_line_returns_empty(self): + style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 1, "color": "#FF0000"}]}) + assert style.build_line_override_tag(0, 2) == "" + + def test_out_of_range_index_returns_empty(self): + style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 5, "color": "#FF0000"}]}) + assert style.build_line_override_tag(0, 2) == "" + + def test_negative_index_resolves_from_end(self): + style = SubtitleStyle.from_dict( + { + "line_overrides": [{"line_index": -1, "color": "#FF0000", "font_size": 48}], + } + ) + tag = style.build_line_override_tag(-1, 3) # last line, 3 lines total + assert tag + assert "\\c&H000000FF" in tag # red + assert "\\fs48" in tag + assert tag.startswith("{") and tag.endswith("}") + + def test_color_override(self): + style = SubtitleStyle.from_dict( + { + "line_overrides": [{"line_index": 0, "color": "#00FF00"}], + } + ) + tag = style.build_line_override_tag(0, 1) + assert "\\c&H0000FF00" in tag # green = 00FF00 → bgr 00FF00 + + def test_font_size_override(self): + style = SubtitleStyle.from_dict( + { + "line_overrides": [{"line_index": 0, "font_size": 60}], + } + ) + tag = style.build_line_override_tag(0, 1) + assert "\\fs60" in tag + + def test_font_override(self): + style = SubtitleStyle.from_dict( + { + "line_overrides": [{"line_index": 0, "font": "优设标题黑"}], + } + ) + tag = style.build_line_override_tag(0, 1) + assert "\\fn优设标题黑" in tag + + def test_bold_on_off(self): + style_bold = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "bold": True}]}) + assert "\\b1" in style_bold.build_line_override_tag(0, 1) + style_nobold = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "bold": False}]}) + assert "\\b0" in style_nobold.build_line_override_tag(0, 1) + + def test_italic_on_off(self): + style_italic = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "italic": True}]}) + assert "\\i1" in style_italic.build_line_override_tag(0, 1) + style_noitalic = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "italic": False}]}) + assert "\\i0" in style_noitalic.build_line_override_tag(0, 1) + + def test_stroke_override(self): + style = SubtitleStyle.from_dict( + { + "line_overrides": [{"line_index": 0, "stroke_color": "#0000FF", "stroke_width": 3.0}], + } + ) + tag = style.build_line_override_tag(0, 1) + assert "\\3c&H00FF0000" in tag # blue bgr + assert "\\bord3" in tag + + def test_shadow_override(self): + style = SubtitleStyle.from_dict( + { + "line_overrides": [ + {"line_index": 0, "shadow_color": "#000000", "shadow_offset_x": 2, "shadow_offset_y": 3} + ], + } + ) + tag = style.build_line_override_tag(0, 1) + assert "\\4c&H00000000" in tag # black + assert "\\xshad2" in tag + assert "\\yshad3" in tag + + def test_full_combo(self): + style = SubtitleStyle.from_dict( + { + "line_overrides": [ + { + "line_index": 0, + "color": "#FF0000", + "font_size": 72, + "font": "抖音美好体", + "bold": True, + "italic": False, + "stroke_color": "#FFFFFF", + "stroke_width": 2, + "shadow_color": "#000000", + "shadow_offset_x": 0, + "shadow_offset_y": 4, + } + ], + } + ) + tag = style.build_line_override_tag(0, 1) + assert "\\c&H000000FF" in tag + assert "\\fs72" in tag + assert "\\fn抖音美好体" in tag + assert "\\b1" in tag + assert "\\i0" in tag + assert "\\3c&H00FFFFFF" in tag + assert "\\bord2" in tag + assert "\\4c&H00000000" in tag + assert "\\xshad0" in tag + assert "\\yshad4" in tag + + def test_empty_override_dict_returns_empty(self): + style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0}]}) + assert style.build_line_override_tag(0, 1) == "" + + def test_non_dict_items_filtered(self): + # 从 dict 解析时已过滤,这里手动构造测试 + style = SubtitleStyle(line_overrides=["not a dict", {"line_index": 0, "color": "#FF0000"}]) + tag = style.build_line_override_tag(0, 1) + assert "\\c&H000000FF" in tag + + def test_total_lines_zero_returns_empty(self): + style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000"}]}) + assert style.build_line_override_tag(0, 0) == "" + + def test_line_index_none_skipped(self): + style = SubtitleStyle.from_dict({"line_overrides": [{"color": "#FF0000"}]}) # 无 line_index + assert style.build_line_override_tag(0, 1) == "" + + +class TestApplyLineOverrides: + def test_empty_text_returns_empty(self): + style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000"}]}) + assert style.apply_line_overrides("") == "" + + def test_no_overrides_returns_original(self): + style = SubtitleStyle() + assert style.apply_line_overrides("hello\\Nworld") == "hello\\Nworld" + + def test_single_line(self): + style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000", "font_size": 60}]}) + result = style.apply_line_overrides("单行标题") + assert result.startswith("{") + assert "单行标题" in result + assert "\\c&H000000FF" in result + + def test_multi_line_only_first_line_tagged(self): + style = SubtitleStyle.from_dict({"line_overrides": [{"line_index": 0, "color": "#FF0000", "font_size": 72}]}) + result = style.apply_line_overrides("第一行\\N第二行\\N第三行") + lines = result.split("\\N") + assert len(lines) == 3 + assert lines[0].startswith("{\\c&H000000FF") + assert "第一行" in lines[0] + assert lines[1] == "第二行" # 无标签 + assert lines[2] == "第三行" + + def test_multi_line_middle_and_last(self): + style = SubtitleStyle.from_dict( + { + "line_overrides": [ + {"line_index": 0, "font_size": 72, "bold": True}, + {"line_index": -1, "color": "#00FF00", "font_size": 36}, + ], + } + ) + result = style.apply_line_overrides("主标题\\N副标题\\N脚注") + lines = result.split("\\N") + assert lines[0].startswith("{\\fs72\\b1}") + assert lines[1] == "副标题" + assert "\\c&H0000FF00" in lines[2] + assert "\\fs36" in lines[2] + + def test_line_without_override_kept_verbatim(self): + style = SubtitleStyle.from_dict( + { + "line_overrides": [{"line_index": 1, "bold": True}], + } + ) + result = style.apply_line_overrides("第一行\\N第二行") + lines = result.split("\\N") + assert lines[0] == "第一行" + assert lines[1].startswith("{\\b1}") + assert "第二行" in lines[1] + + +class TestFromDictLineOverrides: + def test_from_dict_parses_line_overrides(self): + cfg = { + "line_overrides": [ + {"line_index": 0, "color": "#FF0000", "bold": True}, + {"line_index": 1, "font_size": 36}, + ], + } + style = SubtitleStyle.from_dict(cfg) + assert len(style.line_overrides) == 2 + assert style.line_overrides[0]["line_index"] == 0 + assert style.line_overrides[1]["font_size"] == 36 + + def test_from_dict_filters_non_dict_items(self): + cfg = {"line_overrides": [{"line_index": 0, "color": "#FF0000"}, "bad", None, 123]} + style = SubtitleStyle.from_dict(cfg) + assert len(style.line_overrides) == 1 + + def test_from_dict_no_field_defaults_empty(self): + style = SubtitleStyle.from_dict({"font": "微软雅黑"}) + assert style.line_overrides == [] + + def test_default_has_empty_list(self): + style = SubtitleStyle() + assert style.line_overrides == []