Files
xiaoxia-saas/tests/unit/test_subtitle_generator.py
CI Bot 5db88d0325
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m18s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 5m46s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m23s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m32s
CI/CD Pipeline / Unit Tests (push) Failing after 4m48s
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m29s
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 / Build Staging API Image (push) Successful in 11m33s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m0s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m23s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 32s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m34s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 8m56s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
style: auto-format with black + isort + prettier
2026-07-25 14:16:40 +00:00

769 lines
29 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.
"""字幕生成器 + Mock ASR 单元测试。"""
import tempfile
from pathlib import Path
import pytest
from apps.worker.video_processing.subtitle_generator import (
_wrap_text,
generate_ass_from_timeline,
)
from packages.adapters.asr.mock_asr_service import MockASRService
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
from packages.ports.asr_service import ASRServiceError
class TestMockASRService:
def test_transcribe_with_mock_text(self):
service = MockASRService(mock_text="你好世界!这是一段测试语音识别的文字。用来验证Mock ASR是否正常工作。")
# 创建一个假的音频文件(mock不真的读内容)
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
f.write(b"fake audio data")
audio_path = Path(f.name)
try:
timeline = service.transcribe(audio_path, language="zh")
assert timeline is not None
assert timeline.language == "zh"
assert timeline.segment_count > 0
assert timeline.total_duration > 0
# 总字数应该对得上
assert timeline.total_chars == len("你好世界!这是一段测试语音识别的文字。用来验证Mock ASR是否正常工作。")
finally:
audio_path.unlink()
def test_transcribe_file_not_found(self):
service = MockASRService()
with pytest.raises(ASRServiceError):
service.transcribe(Path("/nonexistent/audio.wav"))
def test_transcribe_with_word_timestamps(self):
service = MockASRService(mock_text="你好世界!")
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
f.write(b"fake")
audio_path = Path(f.name)
try:
timeline = service.transcribe(audio_path, with_word_timestamps=True)
# 每段应该有词级时间戳
for seg in timeline.segments:
if seg.words:
assert len(seg.words) > 0
assert seg.words[0].start >= seg.start
assert seg.words[-1].end <= seg.end
finally:
audio_path.unlink()
def test_auto_detect_language(self):
service = MockASRService(mock_text="Hello world. This is a test.")
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
f.write(b"fake")
audio_path = Path(f.name)
try:
timeline = service.transcribe(audio_path, language=None)
# None 时默认 zh
assert timeline.language == "zh"
finally:
audio_path.unlink()
class TestWrapText:
def test_short_text_no_wrap(self):
result = _wrap_text("你好世界", 20)
assert result == ["你好世界"]
def test_wrap_at_punctuation(self):
result = _wrap_text("你好世界!这是一段很长的测试文字。", 10)
assert len(result) == 2
assert "" in result[0]
def test_hard_wrap_no_punctuation(self):
result = _wrap_text("一二三四五六七八九十一二三四五六七八九十", 10)
assert len(result) == 2
assert len(result[0]) == 10
assert len(result[1]) == 10
def test_exact_length(self):
result = _wrap_text("一二三四五六七八九十", 10)
assert len(result) == 1
class TestGenerateAssFromTimeline:
def test_generate_basic(self):
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="你好世界", start=0.0, end=2.0),
SubtitleSegment(text="这是测试", start=2.0, end=4.0),
],
language="zh",
total_duration=4.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "test.ass"
result = generate_ass_from_timeline(
output_path,
timeline,
video_width=1920,
video_height=1080,
)
assert result.exists()
content = result.read_text(encoding="utf-8")
assert "[Script Info]" in content
assert "[V4+ Styles]" in content
assert "[Events]" in content
assert "你好世界" in content
assert "这是测试" in content
assert "PlayResX: 1920" in content
assert "PlayResY: 1080" in content
def test_empty_timeline(self):
timeline = SubtitleTimeline(segments=[])
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "empty.ass"
result = generate_ass_from_timeline(output_path, timeline, video_width=1920, video_height=1080)
assert result.exists()
assert result.read_text(encoding="utf-8") == ""
def test_with_custom_style(self):
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="测试", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "style.ass"
generate_ass_from_timeline(
output_path,
timeline,
video_width=1280,
video_height=720,
subtitle_config={
"font": "微软雅黑",
"size": 32,
"color": "#ff0000",
"position": "bottom",
},
)
content = output_path.read_text(encoding="utf-8")
assert "微软雅黑" in content
assert "32" in content
def test_time_format(self):
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(text="测试", start=0.5, end=1.25),
SubtitleSegment(text="长字幕", start=3661.0, end=3662.5), # 超过1小时
],
total_duration=3662.5,
)
with tempfile.TemporaryDirectory() as tmpdir:
output_path = Path(tmpdir) / "time.ass"
generate_ass_from_timeline(output_path, timeline, video_width=1920, video_height=1080)
content = output_path.read_text(encoding="utf-8")
# 0:00:00.50 格式
assert "0:00:00.50" in content
assert "0:00:01.25" in content
# 1:01:01.00 格式(3661秒 = 1小时1分1秒)
assert "1:01:01.00" in content
# ── _hex_to_ass_color 深度测试 ──────────────────────────────────────────────
class TestHexToAssColor:
"""_hex_to_ass_color 颜色转换测试"""
def test_standard_hex_with_hash(self):
"""带#号的标准6位HEX"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
# #RRGGBB → &HBBGGRR
assert _hex_to_ass_color("#FF0000") == "&H0000FF" # 红
assert _hex_to_ass_color("#00FF00") == "&H00FF00" # 绿
assert _hex_to_ass_color("#0000FF") == "&HFF0000" # 蓝
def test_standard_hex_without_hash(self):
"""不带#号的6位HEX"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("FF0000") == "&H0000FF"
assert _hex_to_ass_color("00FF00") == "&H00FF00"
assert _hex_to_ass_color("0000FF") == "&HFF0000"
def test_white_and_black(self):
"""白色和黑色"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("#FFFFFF") == "&HFFFFFF" # 白
assert _hex_to_ass_color("#000000") == "&H000000" # 黑
def test_lowercase_hex(self):
"""小写字母HEX"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
result = _hex_to_ass_color("#aabbcc")
# 输出是大写的
assert result == "&HCCBBAA"
def test_mixed_case_hex(self):
"""大小写混合HEX"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
result = _hex_to_ass_color("#AaBbCc")
assert result == "&HCCBBAA"
def test_short_hex_returns_default(self):
"""长度不足6位返回默认白色"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("#FFF") == "&H00FFFFFF" # 3位
assert _hex_to_ass_color("FF") == "&H00FFFFFF" # 2位
def test_long_hex_returns_default(self):
"""长度超过6位返回默认白色"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("#AABBCCDD") == "&H00FFFFFF"
def test_empty_string_returns_default(self):
"""空字符串返回默认白色"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("") == "&H00FFFFFF"
def test_gray_color(self):
"""灰色调"""
from apps.worker.video_processing.subtitle_generator import _hex_to_ass_color
assert _hex_to_ass_color("#808080") == "&H808080"
# ── _position_to_ass_alignment 测试 ────────────────────────────────────────
class TestPositionToAssAlignment:
"""_position_to_ass_alignment 位置映射测试"""
def test_top_maps_to_8(self):
"""顶部 → 8"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("top") == 8
def test_center_maps_to_5(self):
"""居中 → 5"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("center") == 5
def test_bottom_maps_to_2(self):
"""底部 → 2"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("bottom") == 2
def test_unknown_position_defaults_to_bottom(self):
"""未知位置默认底部(2)"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("left") == 2
assert _position_to_ass_alignment("right") == 2
assert _position_to_ass_alignment("middle") == 2
def test_empty_string_defaults_to_bottom(self):
"""空字符串默认底部"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("") == 2
def test_uppercase_not_matched(self):
"""大写不匹配,走默认"""
from apps.worker.video_processing.subtitle_generator import _position_to_ass_alignment
assert _position_to_ass_alignment("TOP") == 2
assert _position_to_ass_alignment("CENTER") == 2
# ── _format_ass_time 测试 ──────────────────────────────────────────────────
class TestFormatAssTime:
"""_format_ass_time 时间格式化测试"""
def test_zero_seconds(self):
"""0秒"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(0.0) == "0:00:00.00"
def test_fractional_seconds(self):
"""小数秒"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(0.5) == "0:00:00.50"
assert _format_ass_time(1.25) == "0:00:01.25"
def test_whole_seconds(self):
"""整秒"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(5.0) == "0:00:05.00"
assert _format_ass_time(30.0) == "0:00:30.00"
def test_minutes_level(self):
"""分钟级"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(60.0) == "0:01:00.00"
assert _format_ass_time(90.5) == "0:01:30.50"
assert _format_ass_time(599.0) == "0:09:59.00"
def test_hours_level(self):
"""小时级"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(3600.0) == "1:00:00.00"
assert _format_ass_time(3661.5) == "1:01:01.50"
assert _format_ass_time(7384.0) == "2:03:04.00"
def test_centisecond_precision(self):
"""百分秒精度"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
assert _format_ass_time(1.01) == "0:00:01.01"
assert _format_ass_time(1.99) == "0:00:01.99"
def test_sub_centisecond_truncated(self):
"""毫秒级精度会被格式化截断到百分秒"""
from apps.worker.video_processing.subtitle_generator import _format_ass_time
# Python 的 %05.2f 会四舍五入
result = _format_ass_time(1.123)
assert result.startswith("0:00:01.")
assert len(result.split(".")[-1]) == 2
# ── _escape_ass_text 测试 ──────────────────────────────────────────────────
class TestEscapeAssText:
"""_escape_ass_text 转义测试"""
def test_plain_text_unchanged(self):
"""普通文本不改变"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("你好世界") == "你好世界"
assert _escape_ass_text("Hello World") == "Hello World"
def test_newline_to_ass_newline(self):
"""\\n 转 \\N"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("第一行\n第二行") == "第一行\\N第二行"
def test_crlf_to_ass_newline(self):
"""\\r\\n 转 \\N"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("第一行\r\n第二行") == "第一行\\N第二行"
def test_cr_to_ass_newline(self):
"""\\r 转 \\N"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("第一行\r第二行") == "第一行\\N第二行"
def test_curly_braces_escaped(self):
"""花括号转圆括号(防止ASS标签注入)"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("{text}") == "(text)"
assert _escape_ass_text("{{double}}") == "((double))"
def test_mixed_escapes(self):
"""混合转义"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
text = "你好\n世界{测试}\r\n结束"
result = _escape_ass_text(text)
assert "\\N" in result
assert "(测试)" in result
assert "\n" not in result
assert "{" not in result
def test_empty_string(self):
"""空字符串"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("") == ""
def test_only_braces(self):
"""只有花括号"""
from apps.worker.video_processing.subtitle_generator import _escape_ass_text
assert _escape_ass_text("{}") == "()"
# ── _wrap_text 补充测试 ────────────────────────────────────────────────────
class TestWrapTextMore:
"""_wrap_text 补充边界测试"""
def test_empty_string_returns_empty_list(self):
"""空字符串返回空列表"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
assert _wrap_text("", 10) == [""]
def test_single_character(self):
"""单字符"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
assert _wrap_text("好", 10) == ["好"]
def test_max_chars_equals_one(self):
"""max_chars=1 每个字一行"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
result = _wrap_text("一二三四", 1)
assert len(result) == 4
assert result[0] == "一"
assert result[1] == "二"
def test_punctuation_in_middle(self):
"""标点在正中间优先从标点断开"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
# 前10字内有句号,优先在句号后断开
text = "一二三四五六七八九十。后面的内容继续写下去"
result = _wrap_text(text, 15)
# 第一行应该包含句号
assert "。" in result[0]
def test_punctuation_at_start_ignored(self):
"""标点在开头位置(前半部分)不会触发断开"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "。一二三四五六七八九十"
result = _wrap_text(text, 10)
# 标点在第0位,不会在max_chars//2到max_chars范围内
assert len(result) == 2
assert len(result[0]) == 10
def test_no_punctuation_long_text(self):
"""完全没有标点的长文本硬切"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
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_exactly_two_lines(self):
"""恰好两行"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "一" * 20
result = _wrap_text(text, 10)
assert len(result) == 2
assert len(result[0]) == 10
assert len(result[1]) == 10
def test_three_lines(self):
"""三行"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
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_multiple_punctuation_points(self):
"""多个标点,选择最后一个合适的"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "你好。再见。谢谢。抱歉。好的不行了"
result = _wrap_text(text, 12)
# 应该在最靠后的(在范围内的)标点处断开
assert "。" in result[0]
def test_english_punctuation_wrap(self):
"""英文标点也会触发换行"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "Hello world. This is a test sentence."
result = _wrap_text(text, 20)
assert len(result) >= 2
assert result[0].endswith(".") or "." in result[0]
def test_total_length_preserved(self):
"""换行后总字符数不变"""
from apps.worker.video_processing.subtitle_generator import _wrap_text
text = "这是一段用于测试换行功能的中文文本,包含了各种标点符号。看看效果如何?"
result = _wrap_text(text, 10)
assert "".join(result) == text
# ── generate_ass_from_timeline 补充测试 ────────────────────────────────────
class TestGenerateAssFromTimelineMore:
"""generate_ass_from_timeline 补充测试"""
def test_single_segment(self):
"""单段字幕"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="你好", start=0.0, end=1.0)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "single.ass"
generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
content = output.read_text(encoding="utf-8")
assert "Dialogue:" in content
assert "你好" in content
assert content.count("Dialogue:") == 1
def test_multiple_segments(self):
"""多段字幕"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text=f"第{i}段", start=float(i), end=float(i + 1)) for i in range(5)],
total_duration=5.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "multi.ass"
generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
content = output.read_text(encoding="utf-8")
assert content.count("Dialogue:") == 5
def test_long_text_auto_wrap(self):
"""长字幕自动换行"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
long_text = "这是一段非常非常长的字幕文本,用来测试自动换行功能是否正常工作。"
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text=long_text, start=0, end=5)],
total_duration=5.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "wrap.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"max_chars_per_line": 10},
)
content = output.read_text(encoding="utf-8")
# 应该包含 \N 换行符
assert "\\N" in content
def test_custom_color_hex(self):
"""自定义颜色正确转换"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="红", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "red.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"color": "#FF0000"},
)
content = output.read_text(encoding="utf-8")
# 红色 #FF0000 → &H0000FF (BBGGRR)
assert "&H0000FF" in content
def test_position_top(self):
"""顶部位置"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="顶部", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "top.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"position": "top"},
)
content = output.read_text(encoding="utf-8")
# 顶部对齐是 \\an8 → Style 中 Alignment=8
assert "1,1.5,0,8," in content or ",0,8," in content
def test_position_center(self):
"""居中位置"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="居中", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "center.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"position": "center"},
)
content = output.read_text(encoding="utf-8")
# 居中对齐是 Alignment=5
assert ",0,5," in content
def test_custom_font_size(self):
"""自定义字体大小"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="大", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "big.ass"
generate_ass_from_timeline(
output,
timeline,
video_width=1920,
video_height=1080,
subtitle_config={"size": 48},
)
content = output.read_text(encoding="utf-8")
# Style 行中字号应该是48
assert "Default,思源黑体,48," in content
def test_720p_resolution(self):
"""720p分辨率"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="720p", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "720p.ass"
generate_ass_from_timeline(output, timeline, video_width=1280, video_height=720)
content = output.read_text(encoding="utf-8")
assert "PlayResX: 1280" in content
assert "PlayResY: 720" in content
def test_special_characters_in_text(self):
"""字幕文本含特殊字符(花括号、换行)"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="你好{tag}\n世界", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "special.ass"
generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
content = output.read_text(encoding="utf-8")
# 花括号被转义
assert "(tag)" in content
# 换行被转义成 \N
assert "\\N" in content
def test_output_path_creates_parent_dirs(self):
"""自动创建父目录"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="测试", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "nested" / "deep" / "out.ass"
result = generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
assert result.exists()
assert result.parent.exists()
def test_returns_output_path(self):
"""返回值等于输出路径"""
import tempfile
from pathlib import Path
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[SubtitleSegment(text="返回", start=0, end=1)],
total_duration=1.0,
)
with tempfile.TemporaryDirectory() as tmpdir:
output = Path(tmpdir) / "ret.ass"
result = generate_ass_from_timeline(output, timeline, video_width=1920, video_height=1080)
assert result == output