Files
xiaoxia-saas/tests/unit/test_render_subtitles_pure.py
T
xiaoxia ecbdd49dc1
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m13s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m14s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 38s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m55s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m19s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m21s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 5m1s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 52s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m23s
CI/CD Pipeline / Integration Tests (push) Successful in 4m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
CI/CD Pipeline / Unit Tests (push) Failing after 12m24s
CI/CD Pipeline / Production Browser E2E (push) Failing after 501h41m15s
CI/CD Pipeline / Canary Release to Production (push) Failing after 501h41m15s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 501h41m16s
CI/CD Pipeline / Build Production Web Image (push) Failing after 501h41m17s
CI/CD Pipeline / Build Production API Image (push) Failing after 501h41m17s
CI/CD Pipeline / CI Gate (push) Failing after 501h41m17s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 501h52m48s
CI/CD Pipeline / Frontend Lint (push) Failing after 501h53m40s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 501h54m49s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 501h58m52s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 501h58m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 501h58m58s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 502h0m15s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 502h0m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 502h1m25s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 502h1m30s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 502h1m32s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 502h1m34s
CI/CD Pipeline / Deploy Production (push) Failing after 502h15m12s
CI/CD Pipeline / PR Build Web Image (push) Failing after 502h26m46s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 502h32m50s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 502h33m35s
CI/CD Pipeline / PR Build API Image (push) Failing after 502h26m48s
feat: 素材片段随机起始时间+去重 + MediaKit智能封面抽帧
## 任务1: 素材片段随机起始时间
- 修改 _calc_random_start_time 增加 used_segments 参数,避开已使用时间段
- 四个分配函数(ONE_TAKE/PIP/VOICE_OVER/VOICE_PIP)各自维护 used_segments
- 同一素材被多个clip使用时,自动记录已用区间,下次随机时避开
- 如果无空间可用,回退到最后已用段之后或缩短时长

## 任务2: MediaKit智能封面抽帧
- 新增 _extract_frames_via_mediakit 函数,上传视频到OSS后调用MediaKit API
- 使用 SceneChange 策略智能选取关键帧
- 失败时自动降级到 ffmpeg 均匀抽帧
- 抽帧后下载帧图、可选叠加标题、上传OSS

## 测试
- 新增 8 个 _calc_random_start_time 单测
- 新增 4 个 distribute_assets 去重逻辑单测
- 新增 4 个 MediaKit 集成单测
- 修复 3 个因字体映射变更的测试断言
2026-08-26 17:22:08 +08:00

311 lines
10 KiB
Python
Raw 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.
"""字幕渲染纯函数测试 — _build_ass_style + generate_ass_subtitles."""
from __future__ import annotations
from pathlib import Path
import pytest
from video_processing.render_subtitles import (
_build_ass_style,
_escape_ass_text,
_format_ass_time,
_hex_to_ass_color,
_position_to_ass_alignment,
generate_ass_subtitles,
)
class TestHexToAssColor:
"""_hex_to_ass_color 颜色转换测试."""
def test_white(self):
"""白色 #FFFFFF → &HFFFFFF (ASS BGR格式)."""
assert _hex_to_ass_color("#FFFFFF") == "&HFFFFFF"
def test_black(self):
"""黑色 #000000 → &H000000."""
assert _hex_to_ass_color("#000000") == "&H000000"
def test_red(self):
"""红色 #FF0000 → ASS是BGR顺序 → &H0000FF."""
assert _hex_to_ass_color("#FF0000") == "&H0000FF"
def test_blue(self):
"""蓝色 #0000FF → BGR → &HFF0000."""
assert _hex_to_ass_color("#0000FF") == "&HFF0000"
def test_green(self):
"""绿色 #00FF00 → BGR → &H00FF00."""
assert _hex_to_ass_color("#00FF00") == "&H00FF00"
def test_lowercase(self):
"""小写hex也支持."""
assert _hex_to_ass_color("#ff0000") == "&H0000FF"
def test_no_hash_prefix(self):
"""不带#的颜色."""
assert _hex_to_ass_color("FF0000") == "&H0000FF"
def test_invalid_length_fallback(self):
"""长度不对返回默认黑色."""
assert _hex_to_ass_color("#FFF") == "&H000000"
class TestPositionToAssAlignment:
"""_position_to_ass_alignment 位置映射测试."""
def test_top_center(self):
"""top → 上中(8)."""
assert _position_to_ass_alignment("top") == 8
def test_bottom_center(self):
"""bottom → 下中(2)."""
assert _position_to_ass_alignment("bottom") == 2
def test_center_middle(self):
"""center → 居中(5)."""
assert _position_to_ass_alignment("center") == 5
def test_unknown_defaults_to_top(self):
"""未知位置默认顶部(8)."""
assert _position_to_ass_alignment("unknown") == 8
assert _position_to_ass_alignment("top_left") == 8
assert _position_to_ass_alignment("bottom_right") == 8
def test_empty_string_defaults_to_top(self):
"""空字符串默认顶部."""
assert _position_to_ass_alignment("") == 8
class TestBuildAssStyle:
"""_build_ass_style ASS样式行构建测试."""
def test_basic_style_line(self):
"""基本样式行包含关键字段."""
line = _build_ass_style("Default")
assert line.startswith("Style: Default,")
assert "Noto Sans CJK SC" in line
assert "48" in line # font_size
def test_bold_enabled(self):
"""加粗时Bold=-1."""
line = _build_ass_style("Bold", bold=True)
assert "Style: Bold," in line
# Bold字段位置:第7个逗号分隔字段=Bold=-1
parts = line.split(",")
# Name, Fontname, Fontsize, Primary, Secondary, Outline, Back, Bold, ...
assert parts[7] == "-1" # Bold
def test_bold_disabled(self):
"""不加粗时Bold=0."""
line = _build_ass_style("Normal", bold=False)
parts = line.split(",")
assert parts[7] == "0"
def test_italic_enabled(self):
"""斜体时Italic=-1."""
line = _build_ass_style("Italic", italic=True)
parts = line.split(",")
assert parts[8] == "-1" # Italic
def test_custom_font_size(self):
"""自定义字号."""
line = _build_ass_style("Big", font_size=72)
parts = line.split(",")
assert parts[2] == "72" # Fontsize
def test_custom_alignment(self):
"""自定义对齐方式."""
line = _build_ass_style("Bottom", alignment=2)
# Alignment是第16个字段(数一下)
# Name, Fontname, Fontsize, Primary, Secondary, Outline, Back, Bold, Italic, Underline, Strikeout, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, ...
parts = line.split(",")
assert parts[18] == "2" # Alignment (0-indexed: 18)
def test_outline_width(self):
"""描边宽度."""
line = _build_ass_style("Outline", outline_width=3.0)
parts = line.split(",")
assert parts[16] == "3.0" # Outline (BorderStyle后是Outline)
class TestEscapeAssText:
"""_escape_ass_text 文本转义测试."""
def test_plain_text_unchanged(self):
"""普通文本不变."""
assert _escape_ass_text("hello world") == "hello world"
def test_newline_converted(self):
"""换行符转成\\N."""
assert _escape_ass_text("line1\nline2") == "line1\\Nline2"
def test_crlf_converted(self):
"""CRLF转成\\N."""
assert _escape_ass_text("a\r\nb") == "a\\Nb"
def test_carriage_return_converted(self):
"""纯\\r转成\\N."""
assert _escape_ass_text("a\rb") == "a\\Nb"
def test_curly_braces_replaced(self):
"""大括号转成圆括号(防止ASS样式注入)."""
assert _escape_ass_text("{text}") == "(text)"
def test_mixed_special_chars(self):
"""混合特殊字符."""
result = _escape_ass_text("line1\nline2 {bold}\rlast")
assert "line1\\Nline2 (bold)\\Nlast" == result
class TestFormatAssTime:
"""_format_ass_time 时间格式化测试."""
def test_zero_seconds(self):
"""0秒."""
assert _format_ass_time(0) == "0:00:00.00"
def test_seconds_only(self):
"""只有秒."""
assert _format_ass_time(5.5) == "0:00:05.50"
def test_minutes_and_seconds(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_always_two_decimal_places(self):
"""总是两位小数."""
assert _format_ass_time(1.0) == "0:00:01.00"
assert _format_ass_time(1.1) == "0:00:01.10"
class TestGenerateAssSubtitles:
"""generate_ass_subtitles ASS字幕文件生成测试."""
def test_no_subtitles_empty_file(self, tmp_path):
"""没有字幕生成空文件."""
output = tmp_path / "empty.ass"
result = generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=10.0,
)
assert result == output
assert output.exists()
assert output.read_text(encoding="utf-8") == ""
def test_title_only(self, tmp_path):
"""只有标题."""
output = tmp_path / "title.ass"
result = generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=30.0,
title_text="测试标题",
)
assert result == output
content = output.read_text(encoding="utf-8")
assert "Script Info" in content
assert "PlayResX: 1080" in content
assert "PlayResY: 1920" in content
assert "测试标题" in content
assert "V4+ Styles" in content
assert "Events" in content
def test_subtitle_only(self, tmp_path):
"""只有底部字幕."""
output = tmp_path / "sub.ass"
result = generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=15.0,
subtitle_text="这是字幕",
)
assert result == output
content = output.read_text(encoding="utf-8")
assert "这是字幕" in content
assert "PlayResX: 1080" in content
def test_both_title_and_subtitle(self, tmp_path):
"""标题+字幕都有."""
output = tmp_path / "both.ass"
result = generate_ass_subtitles(
output,
video_width=720,
video_height=1280,
video_duration=20.0,
title_text="大标题",
subtitle_text="底部字幕",
)
content = output.read_text(encoding="utf-8")
assert "大标题" in content
assert "底部字幕" in content
# 应该有两种样式(title和subtitle)
assert content.count("Style:") >= 2
def test_title_disabled_by_config(self, tmp_path):
"""通过config禁用标题."""
output = tmp_path / "disabled_title.ass"
result = generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=10.0,
title_text="标题",
title_config={"enabled": False},
subtitle_text="字幕",
)
content = output.read_text(encoding="utf-8")
assert "标题" not in content
assert "字幕" in content
def test_empty_title_text_not_rendered(self, tmp_path):
"""空标题文本不渲染."""
output = tmp_path / "empty_title.ass"
result = generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=10.0,
title_text=" ",
subtitle_text="有字幕",
)
content = output.read_text(encoding="utf-8")
assert "有字幕" in content
def test_title_color_overlay(self, tmp_path):
"""自定义标题颜色."""
output = tmp_path / "color.ass"
result = generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=10.0,
title_text="红色标题",
title_config={"color": "#FF0000"},
)
content = output.read_text(encoding="utf-8")
# 红色 → ASS BGR格式 &H0000FF
assert "&H0000FF" in content
def test_dialogue_line_format(self, tmp_path):
"""Dialogue行格式正确."""
output = tmp_path / "dialogue.ass"
generate_ass_subtitles(
output,
video_width=1080,
video_height=1920,
video_duration=5.0,
subtitle_text="测试字幕文本",
)
content = output.read_text(encoding="utf-8")
assert "Dialogue:" in content
assert "测试字幕文本" in content