|
|
|
@@ -176,3 +176,582 @@ class TestGenerateAssFromTimeline:
|
|
|
|
|
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):
|
|
|
|
|
"""单段字幕"""
|
|
|
|
|
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
|
|
|
|
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
"""多段字幕"""
|
|
|
|
|
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
|
|
|
|
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
"""长字幕自动换行"""
|
|
|
|
|
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
|
|
|
|
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
"""自定义颜色正确转换"""
|
|
|
|
|
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
|
|
|
|
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
"""顶部位置"""
|
|
|
|
|
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
|
|
|
|
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
"""居中位置"""
|
|
|
|
|
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
|
|
|
|
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
"""自定义字体大小"""
|
|
|
|
|
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
|
|
|
|
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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分辨率"""
|
|
|
|
|
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
|
|
|
|
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
"""字幕文本含特殊字符(花括号、换行)"""
|
|
|
|
|
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
|
|
|
|
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
"""自动创建父目录"""
|
|
|
|
|
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
|
|
|
|
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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):
|
|
|
|
|
"""返回值等于输出路径"""
|
|
|
|
|
from apps.worker.video_processing.subtitle_generator import generate_ass_from_timeline
|
|
|
|
|
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|