From 50b05db03eafa1ffb1e1702ae29aa22c38ce945c Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 22:04:19 +0800 Subject: [PATCH 01/45] test(wave94): extract media_validation domain module + 70 unit tests (#957) --- apps/worker/worker_app/tasks/ingest.py | 100 +-------- packages/domain/media_validation.py | 110 +++++++++ tests/unit/test_media_validation.py | 299 +++++++++++++++++++++++++ 3 files changed, 417 insertions(+), 92 deletions(-) create mode 100755 packages/domain/media_validation.py create mode 100755 tests/unit/test_media_validation.py diff --git a/apps/worker/worker_app/tasks/ingest.py b/apps/worker/worker_app/tasks/ingest.py index b123db26a..f750eab65 100755 --- a/apps/worker/worker_app/tasks/ingest.py +++ b/apps/worker/worker_app/tasks/ingest.py @@ -14,70 +14,18 @@ from packages.adapters.sqlalchemy_impl import ( SQLAlchemyIngestJobRepository, ) from packages.domain import Asset, AssetStatus, IngestJobStatus +from packages.domain.media_validation import ( + MIN_AUDIO_FILE_SIZE, + MIN_IMAGE_FILE_SIZE, + MIN_VIDEO_FILE_SIZE, + SUPPORTED_VIDEO_CODECS, + is_valid_media as _is_valid_media, + safe_parse_fps as _safe_parse_fps, +) logger = get_task_logger(__name__) -# 最小有效文件大小(字节):小于此值的直接判为无效,避免文本/空文件伪装成媒体 -MIN_VIDEO_FILE_SIZE = 1024 # 1KB -MIN_AUDIO_FILE_SIZE = 100 # 100B -MIN_IMAGE_FILE_SIZE = 100 # 100B - -# 支持的视频编码格式(白名单,尽可能放宽) -# 渲染引擎会在 concat 前统一转码为 h264,因此只要 ffprobe 能识别的视频编码都允许 ingested -SUPPORTED_VIDEO_CODECS = { - "h264", - "avc1", - "avc", # H.264 / AVC - "hevc", - "h265", - "hev1", - "hvc1", # H.265 / HEVC - "vp9", - "vp09", # VP9 - "av1", - "av01", # AV1 - "vp8", - "vp08", # VP8 - "mpeg4", - "mp4v", # MPEG-4 - "mpeg2video", - "mpg2", # MPEG-2 - "wmv2", - "wmv1", - "vc1", # WMV / VC-1 - "flv1", - "flv", - "vp6f", # Flash / FLV - "theora", - "ogg", # Theora - "prores", - "prores_ks", - "apcn", - "apch", - "apco", - "apcs", - "ap4h", - "ap4x", # Apple ProRes - "dnxhd", - "dnxhr", # DNxHD / DNxHR -} - - -def _safe_parse_fps(fps_str: str) -> float: - """Safely parse fps from a fraction string like \"30/1\" or \"30000/1001\".""" - try: - if "/" in fps_str: - num, den = fps_str.split("/", 1) - den_val = float(den) - if den_val == 0: - return 0.0 - return float(num) / den_val - return float(fps_str) - except (ValueError, ZeroDivisionError): - return 0.0 - - def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]: """ 提取媒体文件的元数据。 @@ -207,38 +155,6 @@ def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]: return metadata, success -def _is_valid_media(metadata: dict, media_type: str) -> bool: - """根据元数据判断文件是否为有效媒体文件。 - - Args: - metadata: extract_media_metadata 返回的元数据 - media_type: 媒体类型 - - Returns: - True 表示文件有效 - """ - size = int(metadata.get("size_bytes", 0)) - - if media_type == "video": - duration = float(metadata.get("duration", 0)) - if size < MIN_VIDEO_FILE_SIZE or duration <= 0: - return False - # 编码格式校验:只排除明确非视频的编码格式,只要 ffprobe 能识别的视频编码都允许 - # 渲染引擎会在 concat 前统一转码为 h264 yuv420p,ingest 层不再做严格的编码拦截 - codec = str(metadata.get("codec", "")).lower() - if codec and codec not in SUPPORTED_VIDEO_CODECS: - logger.info("检测到非白名单视频编码 %s,仍允许 ingested,渲染层会统一转码", codec) - return True - if media_type == "audio": - duration = float(metadata.get("duration", 0)) - return size >= MIN_AUDIO_FILE_SIZE and duration > 0 - if media_type == "image": - width = int(metadata.get("width", 0)) - height = int(metadata.get("height", 0)) - return size >= MIN_IMAGE_FILE_SIZE and width > 0 and height > 0 - return False - - @celery_app.task(name="worker.ingest_asset") def ingest_asset(job_id: str) -> dict: """ diff --git a/packages/domain/media_validation.py b/packages/domain/media_validation.py new file mode 100755 index 000000000..32eb36e8e --- /dev/null +++ b/packages/domain/media_validation.py @@ -0,0 +1,110 @@ +"""媒体文件有效性校验与元数据解析工具。 + +从 worker ingest 任务中抽取的纯逻辑模块,包含: +- FPS 解析:从分数格式字符串(如 30000/1001)安全解析帧率 +- 媒体有效性校验:根据元数据判断视频/音频/图片文件是否有效 +- 常量定义:最小文件大小、支持的视频编码白名单 +""" + +from __future__ import annotations + +# 最小有效文件大小(字节):小于此值的直接判为无效,避免文本/空文件伪装成媒体 +MIN_VIDEO_FILE_SIZE = 1024 # 1KB +MIN_AUDIO_FILE_SIZE = 100 # 100B +MIN_IMAGE_FILE_SIZE = 100 # 100B + +# 支持的视频编码格式(白名单,尽可能放宽) +# 渲染引擎会在 concat 前统一转码为 h264,因此只要 ffprobe 能识别的视频编码都允许 ingested +SUPPORTED_VIDEO_CODECS: frozenset[str] = frozenset( + { + "h264", + "avc1", + "avc", # H.264 / AVC + "hevc", + "h265", + "hev1", + "hvc1", # H.265 / HEVC + "vp9", + "vp09", # VP9 + "av1", + "av01", # AV1 + "vp8", + "vp08", # VP8 + "mpeg4", + "mp4v", # MPEG-4 + "mpeg2video", + "mpg2", # MPEG-2 + "wmv2", + "wmv1", + "vc1", # WMV / VC-1 + "flv1", + "flv", + "vp6f", # Flash / FLV + "theora", + "ogg", # Theora + "prores", + "prores_ks", + "apcn", + "apch", + "apco", + "apcs", + "ap4h", + "ap4x", # Apple ProRes + "dnxhd", + "dnxhr", # DNxHD / DNxHR + } +) + + +def safe_parse_fps(fps_str: str) -> float: + """Safely parse fps from a fraction string like "30/1" or "30000/1001". + + Args: + fps_str: FPS 字符串,支持小数格式("30.0")或分数格式("30000/1001") + + Returns: + 解析得到的帧率浮点数;解析失败或分母为0时返回 0.0 + """ + try: + if "/" in fps_str: + num, den = fps_str.split("/", 1) + den_val = float(den) + if den_val == 0: + return 0.0 + return float(num) / den_val + return float(fps_str) + except (ValueError, ZeroDivisionError): + return 0.0 + + +def is_valid_media(metadata: dict, media_type: str) -> bool: + """根据元数据判断文件是否为有效媒体文件。 + + Args: + metadata: 媒体元数据字典,可能包含 size_bytes / duration / codec / width / height 等 + media_type: 媒体类型(video / audio / image) + + Returns: + True 表示文件有效 + """ + size = int(metadata.get("size_bytes", 0)) + + if media_type == "video": + duration = float(metadata.get("duration", 0)) + if size < MIN_VIDEO_FILE_SIZE or duration <= 0: + return False + # 编码格式校验:只排除明确非视频的编码格式,只要 ffprobe 能识别的视频编码都允许 + # 渲染引擎会在 concat 前统一转码为 h264 yuv420p,ingest 层不再做严格的编码拦截 + codec = str(metadata.get("codec", "")).lower() + if codec and codec not in SUPPORTED_VIDEO_CODECS: + # 非白名单编码仍允许通过,仅记录日志(调用方负责日志) + pass + return True + if media_type == "audio": + duration = float(metadata.get("duration", 0)) + return size >= MIN_AUDIO_FILE_SIZE and duration > 0 + if media_type == "image": + width = int(metadata.get("width", 0)) + height = int(metadata.get("height", 0)) + return size >= MIN_IMAGE_FILE_SIZE and width > 0 and height > 0 + return False diff --git a/tests/unit/test_media_validation.py b/tests/unit/test_media_validation.py new file mode 100755 index 000000000..f1fd8b083 --- /dev/null +++ b/tests/unit/test_media_validation.py @@ -0,0 +1,299 @@ +"""media_validation 领域模块单元测试。""" + +import pytest + +from packages.domain.media_validation import ( + MIN_AUDIO_FILE_SIZE, + MIN_IMAGE_FILE_SIZE, + MIN_VIDEO_FILE_SIZE, + SUPPORTED_VIDEO_CODECS, + is_valid_media, + safe_parse_fps, +) + + +class TestSafeParseFpsBasic: + def test_integer_fps(self): + assert safe_parse_fps("30") == 30.0 + + def test_decimal_fps(self): + assert safe_parse_fps("29.97") == pytest.approx(29.97) + + def test_fraction_simple(self): + assert safe_parse_fps("30/1") == 30.0 + + def test_fraction_ntsc(self): + assert safe_parse_fps("30000/1001") == pytest.approx(29.97002997) + + def test_fraction_pal(self): + assert safe_parse_fps("25/1") == 25.0 + + def test_fraction_24fps_cine(self): + assert safe_parse_fps("24000/1001") == pytest.approx(23.976023976) + + def test_zero_fps(self): + assert safe_parse_fps("0") == 0.0 + + def test_zero_fraction(self): + assert safe_parse_fps("0/1") == 0.0 + + +class TestSafeParseFpsEdgeCases: + def test_zero_denominator(self): + assert safe_parse_fps("30/0") == 0.0 + + def test_empty_string(self): + assert safe_parse_fps("") == 0.0 + + def test_garbage_string(self): + assert safe_parse_fps("not_a_number") == 0.0 + + def test_multiple_slashes(self): + # split("/", 1) 只切第一个,后面的作为 den 的一部分会解析失败 + assert safe_parse_fps("30/1/2") == 0.0 + + def test_negative_fps(self): + assert safe_parse_fps("-30") == -30.0 + + def test_negative_fraction(self): + assert safe_parse_fps("-30/1") == -30.0 + + def test_very_high_fps(self): + assert safe_parse_fps("240/1") == 240.0 + + def test_fraction_float_num(self): + assert safe_parse_fps("29.97/1") == pytest.approx(29.97) + + def test_fraction_float_den(self): + assert safe_parse_fps("30/1.001") == pytest.approx(29.97002997) + + def test_whitespace_in_string(self): + # float(" 30 ") 能解析,所以应该返回 30.0 + assert safe_parse_fps(" 30 ") == 30.0 + + +class TestMinFileSizeConstants: + def test_min_video_size_is_1kb(self): + assert MIN_VIDEO_FILE_SIZE == 1024 + + def test_min_audio_size(self): + assert MIN_AUDIO_FILE_SIZE == 100 + + def test_min_image_size(self): + assert MIN_IMAGE_FILE_SIZE == 100 + + +class TestSupportedVideoCodecs: + def test_h264_family_present(self): + assert "h264" in SUPPORTED_VIDEO_CODECS + assert "avc1" in SUPPORTED_VIDEO_CODECS + assert "avc" in SUPPORTED_VIDEO_CODECS + + def test_h265_family_present(self): + assert "hevc" in SUPPORTED_VIDEO_CODECS + assert "h265" in SUPPORTED_VIDEO_CODECS + assert "hev1" in SUPPORTED_VIDEO_CODECS + assert "hvc1" in SUPPORTED_VIDEO_CODECS + + def test_vp9_av1_present(self): + assert "vp9" in SUPPORTED_VIDEO_CODECS + assert "vp09" in SUPPORTED_VIDEO_CODECS + assert "av1" in SUPPORTED_VIDEO_CODECS + assert "av01" in SUPPORTED_VIDEO_CODECS + + def test_vp8_present(self): + assert "vp8" in SUPPORTED_VIDEO_CODECS + assert "vp08" in SUPPORTED_VIDEO_CODECS + + def test_mpeg_family_present(self): + assert "mpeg4" in SUPPORTED_VIDEO_CODECS + assert "mp4v" in SUPPORTED_VIDEO_CODECS + assert "mpeg2video" in SUPPORTED_VIDEO_CODECS + + def test_prores_family_present(self): + assert "prores" in SUPPORTED_VIDEO_CODECS + assert "apcn" in SUPPORTED_VIDEO_CODECS + assert "apch" in SUPPORTED_VIDEO_CODECS + + def test_unknown_codec_not_present(self): + assert "unknown_codec_xyz" not in SUPPORTED_VIDEO_CODECS + + def test_codecs_count_reasonable(self): + # 白名单应该有足够多的编码格式 + assert len(SUPPORTED_VIDEO_CODECS) >= 30 + + +class TestIsValidMediaVideo: + def test_valid_video(self): + metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "h264"} + assert is_valid_media(metadata, "video") is True + + def test_video_too_small(self): + metadata = {"size_bytes": 500, "duration": 10.0, "codec": "h264"} + assert is_valid_media(metadata, "video") is False + + def test_video_exact_min_size(self): + metadata = {"size_bytes": 1024, "duration": 10.0, "codec": "h264"} + assert is_valid_media(metadata, "video") is True + + def test_video_zero_duration(self): + metadata = {"size_bytes": 5000, "duration": 0, "codec": "h264"} + assert is_valid_media(metadata, "video") is False + + def test_video_negative_duration(self): + metadata = {"size_bytes": 5000, "duration": -1.0, "codec": "h264"} + assert is_valid_media(metadata, "video") is False + + def test_video_missing_size_default_zero(self): + metadata = {"duration": 10.0, "codec": "h264"} + assert is_valid_media(metadata, "video") is False + + def test_video_missing_duration_default_zero(self): + metadata = {"size_bytes": 5000, "codec": "h264"} + assert is_valid_media(metadata, "video") is False + + def test_video_unknown_codec_still_valid(self): + # 非白名单编码仍允许通过(渲染层统一转码) + metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "some_unknown_codec"} + assert is_valid_media(metadata, "video") is True + + def test_video_missing_codec_still_valid(self): + metadata = {"size_bytes": 5000, "duration": 10.0} + assert is_valid_media(metadata, "video") is True + + def test_video_codec_case_insensitive(self): + metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "H264"} + assert is_valid_media(metadata, "video") is True + + def test_video_empty_codec(self): + metadata = {"size_bytes": 5000, "duration": 10.0, "codec": ""} + assert is_valid_media(metadata, "video") is True + + def test_video_hevc_codec(self): + metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "hevc"} + assert is_valid_media(metadata, "video") is True + + def test_video_vp9_codec(self): + metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "vp9"} + assert is_valid_media(metadata, "video") is True + + def test_video_av1_codec(self): + metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "av1"} + assert is_valid_media(metadata, "video") is True + + def test_video_prores_codec(self): + metadata = {"size_bytes": 5000, "duration": 10.0, "codec": "prores"} + assert is_valid_media(metadata, "video") is True + + def test_video_empty_metadata(self): + assert is_valid_media({}, "video") is False + + +class TestIsValidMediaAudio: + def test_valid_audio(self): + metadata = {"size_bytes": 5000, "duration": 30.0} + assert is_valid_media(metadata, "audio") is True + + def test_audio_too_small(self): + metadata = {"size_bytes": 50, "duration": 30.0} + assert is_valid_media(metadata, "audio") is False + + def test_audio_exact_min_size(self): + metadata = {"size_bytes": 100, "duration": 10.0} + assert is_valid_media(metadata, "audio") is True + + def test_audio_zero_duration(self): + metadata = {"size_bytes": 5000, "duration": 0} + assert is_valid_media(metadata, "audio") is False + + def test_audio_negative_duration(self): + metadata = {"size_bytes": 5000, "duration": -1.0} + assert is_valid_media(metadata, "audio") is False + + def test_audio_missing_size(self): + metadata = {"duration": 10.0} + assert is_valid_media(metadata, "audio") is False + + def test_audio_missing_duration(self): + metadata = {"size_bytes": 5000} + assert is_valid_media(metadata, "audio") is False + + def test_audio_with_codec_info(self): + metadata = {"size_bytes": 5000, "duration": 30.0, "codec": "aac"} + assert is_valid_media(metadata, "audio") is True + + def test_audio_empty_metadata(self): + assert is_valid_media({}, "audio") is False + + +class TestIsValidMediaImage: + def test_valid_image(self): + metadata = {"size_bytes": 5000, "width": 1920, "height": 1080} + assert is_valid_media(metadata, "image") is True + + def test_image_too_small(self): + metadata = {"size_bytes": 50, "width": 1920, "height": 1080} + assert is_valid_media(metadata, "image") is False + + def test_image_exact_min_size(self): + metadata = {"size_bytes": 100, "width": 100, "height": 100} + assert is_valid_media(metadata, "image") is True + + def test_image_zero_width(self): + metadata = {"size_bytes": 5000, "width": 0, "height": 1080} + assert is_valid_media(metadata, "image") is False + + def test_image_zero_height(self): + metadata = {"size_bytes": 5000, "width": 1920, "height": 0} + assert is_valid_media(metadata, "image") is False + + def test_image_negative_dimensions(self): + metadata = {"size_bytes": 5000, "width": -1, "height": 1080} + assert is_valid_media(metadata, "image") is False + + def test_image_missing_width(self): + metadata = {"size_bytes": 5000, "height": 1080} + assert is_valid_media(metadata, "image") is False + + def test_image_missing_height(self): + metadata = {"size_bytes": 5000, "width": 1920} + assert is_valid_media(metadata, "image") is False + + def test_image_missing_size(self): + metadata = {"width": 1920, "height": 1080} + assert is_valid_media(metadata, "image") is False + + def test_image_small_but_valid(self): + metadata = {"size_bytes": 100, "width": 1, "height": 1} + assert is_valid_media(metadata, "image") is True + + def test_image_empty_metadata(self): + assert is_valid_media({}, "image") is False + + +class TestIsValidMediaUnknownType: + def test_unknown_type_returns_false(self): + metadata = {"size_bytes": 5000, "duration": 10.0} + assert is_valid_media(metadata, "unknown") is False + + def test_empty_type_returns_false(self): + metadata = {"size_bytes": 5000, "duration": 10.0} + assert is_valid_media(metadata, "") is False + + def test_text_type_returns_false(self): + metadata = {"size_bytes": 5000} + assert is_valid_media(metadata, "text") is False + + +class TestIsValidMediaSizeTypes: + def test_size_as_string(self): + # int("5000") 能解析 + metadata = {"size_bytes": "5000", "duration": 10.0, "codec": "h264"} + assert is_valid_media(metadata, "video") is True + + def test_size_as_none(self): + # int(None) 会 TypeError,但 metadata.get 返回 0 默认值 + metadata = {"size_bytes": None, "duration": 10.0, "codec": "h264"} + # int(None) 会抛 TypeError + with pytest.raises(TypeError): + is_valid_media(metadata, "video") From 23de3906c235642e32d5241f523436317701618f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 22:04:19 +0800 Subject: [PATCH 02/45] test(wave95): 82 unit tests for domain entities (#958) --- tests/unit/test_domain_entities.py | 488 +++++++++++++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100755 tests/unit/test_domain_entities.py diff --git a/tests/unit/test_domain_entities.py b/tests/unit/test_domain_entities.py new file mode 100755 index 000000000..95184c0fe --- /dev/null +++ b/tests/unit/test_domain_entities.py @@ -0,0 +1,488 @@ +"""Domain entities 单元测试。""" + +from datetime import datetime, timezone + +import pytest + +from packages.domain.classification import ( + AssetLibraryKind, + ClassificationStatus, + IngestJobStatus, +) +from packages.domain.entities import ( + Asset, + AssetLibrary, + AssetStatus, + IngestJob, + Project, + User, +) + + +class TestProjectCreate: + def test_create_success(self): + project = Project.create(owner_user_id="user1", name="我的项目") + assert project.id is not None + assert len(project.id) == 32 + assert project.owner_user_id == "user1" + assert project.name == "我的项目" + assert project.description == "" + assert project.shared_users == [] + assert isinstance(project.created_at, datetime) + + def test_create_with_description(self): + project = Project.create("u1", "Test Project", "A test description") + assert project.description == "A test description" + + def test_create_strips_name(self): + project = Project.create("u1", " 带空格的项目 ") + assert project.name == "带空格的项目" + + def test_create_strips_description(self): + project = Project.create("u1", "P1", " desc ") + assert project.description == "desc" + + def test_create_empty_name(self): + with pytest.raises(ValueError, match="项目名称不能为空"): + Project.create("u1", "") + + def test_create_whitespace_name(self): + with pytest.raises(ValueError, match="项目名称不能为空"): + Project.create("u1", " \t ") + + def test_create_unique_ids(self): + p1 = Project.create("u1", "P1") + p2 = Project.create("u1", "P2") + assert p1.id != p2.id + + +class TestProjectAccess: + def test_is_owner_true(self): + project = Project.create("owner1", "P1") + assert project.is_owner("owner1") is True + + def test_is_owner_false(self): + project = Project.create("owner1", "P1") + assert project.is_owner("other") is False + + def test_is_shared_with_true(self): + project = Project.create("owner1", "P1") + project.shared_users = ["user_a", "user_b"] + assert project.is_shared_with("user_a") is True + assert project.is_shared_with("user_b") is True + + def test_is_shared_with_false(self): + project = Project.create("owner1", "P1") + project.shared_users = ["user_a"] + assert project.is_shared_with("user_c") is False + + def test_can_access_owner(self): + project = Project.create("owner1", "P1") + assert project.can_access("owner1") is True + + def test_can_access_shared_user(self): + project = Project.create("owner1", "P1") + project.shared_users = ["shared_user"] + assert project.can_access("shared_user") is True + + def test_cannot_access_other(self): + project = Project.create("owner1", "P1") + assert project.can_access("stranger") is False + + def test_empty_shared_users(self): + project = Project.create("owner1", "P1") + assert project.shared_users == [] + assert project.is_shared_with("anyone") is False + + +class TestAssetLibraryCreate: + def test_create_video_library(self): + lib = AssetLibrary.create("proj1", "视频素材库", AssetLibraryKind.VIDEO) + assert lib.id is not None + assert len(lib.id) == 32 + assert lib.project_id == "proj1" + assert lib.name == "视频素材库" + assert lib.kind == AssetLibraryKind.VIDEO + assert lib.asset_count == 0 + assert lib.total_size == 0 + + def test_create_voice_library(self): + lib = AssetLibrary.create("proj1", "音乐库", AssetLibraryKind.VOICE) + assert lib.kind == AssetLibraryKind.VOICE + + def test_create_image_library(self): + lib = AssetLibrary.create("proj1", "图片库", AssetLibraryKind.IMAGE) + assert lib.kind == AssetLibraryKind.IMAGE + + def test_create_strips_name(self): + lib = AssetLibrary.create("p1", " 我的库 ", AssetLibraryKind.VIDEO) + assert lib.name == "我的库" + + def test_create_empty_name(self): + with pytest.raises(ValueError, match="素材库名称不能为空"): + AssetLibrary.create("p1", "", AssetLibraryKind.VIDEO) + + def test_create_whitespace_name(self): + with pytest.raises(ValueError, match="素材库名称不能为空"): + AssetLibrary.create("p1", " \t ", AssetLibraryKind.VIDEO) + + +class TestAssetStatusEnum: + def test_basic_values(self): + assert AssetStatus.UPLOADING.value == "uploading" + assert AssetStatus.READY.value == "ready" + assert AssetStatus.PROCESSING.value == "processing" + assert AssetStatus.ERROR.value == "error" + assert AssetStatus.DELETED.value == "deleted" + + def test_missing_uploaded_maps_to_ready(self): + assert AssetStatus("uploaded") == AssetStatus.READY + + def test_missing_success_maps_to_ready(self): + assert AssetStatus("success") == AssetStatus.READY + + def test_missing_ok_maps_to_ready(self): + assert AssetStatus("ok") == AssetStatus.READY + + def test_missing_done_maps_to_ready(self): + assert AssetStatus("done") == AssetStatus.READY + + def test_missing_complete_maps_to_ready(self): + assert AssetStatus("complete") == AssetStatus.READY + + def test_missing_upload_maps_to_uploading(self): + assert AssetStatus("upload") == AssetStatus.UPLOADING + + def test_missing_uploading_start_maps_to_uploading(self): + assert AssetStatus("uploading_start") == AssetStatus.UPLOADING + + def test_missing_upload_start_maps_to_uploading(self): + assert AssetStatus("upload_start") == AssetStatus.UPLOADING + + def test_missing_failed_maps_to_error(self): + assert AssetStatus("failed") == AssetStatus.ERROR + + def test_missing_fail_maps_to_error(self): + assert AssetStatus("fail") == AssetStatus.ERROR + + def test_missing_err_maps_to_error(self): + assert AssetStatus("err") == AssetStatus.ERROR + + def test_missing_process_maps_to_processing(self): + assert AssetStatus("process") == AssetStatus.PROCESSING + + def test_missing_running_maps_to_processing(self): + assert AssetStatus("running") == AssetStatus.PROCESSING + + def test_missing_run_maps_to_processing(self): + assert AssetStatus("run") == AssetStatus.PROCESSING + + def test_missing_unknown_value_falls_back_to_ready(self): + assert AssetStatus("completely_unknown_status") == AssetStatus.READY + + def test_missing_empty_string_falls_back_to_ready(self): + assert AssetStatus("") == AssetStatus.READY + + def test_missing_case_insensitive(self): + assert AssetStatus("UPLOADED") == AssetStatus.READY + assert AssetStatus("Success") == AssetStatus.READY + assert AssetStatus("FAILED") == AssetStatus.ERROR + + def test_missing_with_whitespace(self): + assert AssetStatus(" uploaded ") == AssetStatus.READY + assert AssetStatus("\tfailed\n") == AssetStatus.ERROR + + def test_missing_non_string_value(self): + assert AssetStatus(None) == AssetStatus.READY + assert AssetStatus(123) == AssetStatus.READY + + def test_known_values_still_work(self): + assert AssetStatus("uploading") == AssetStatus.UPLOADING + assert AssetStatus("ready") == AssetStatus.READY + assert AssetStatus("processing") == AssetStatus.PROCESSING + assert AssetStatus("error") == AssetStatus.ERROR + assert AssetStatus("deleted") == AssetStatus.DELETED + + +class TestAssetCreate: + def test_create_minimal(self): + asset = Asset.create( + project_id="proj1", + library_id="lib1", + name="test.mp4", + storage_key="videos/test.mp4", + mime_type="video/mp4", + ) + assert asset.id is not None + assert len(asset.id) == 32 + assert asset.project_id == "proj1" + assert asset.library_id == "lib1" + assert asset.name == "test.mp4" + assert asset.storage_key == "videos/test.mp4" + assert asset.mime_type == "video/mp4" + assert asset.file_size == 0 + assert asset.thumbnail_url is None + assert asset.duration is None + assert asset.width is None + assert asset.height is None + assert asset.status == AssetStatus.UPLOADING + assert asset.classification_status == ClassificationStatus.PENDING + assert asset.quality_score is None + assert asset.tag_ids == [] + assert isinstance(asset.created_at, datetime) + assert isinstance(asset.updated_at, datetime) + + def test_create_with_all_fields(self): + asset = Asset.create( + project_id="proj1", + library_id="lib1", + name="movie.mp4", + storage_key="v/m.mp4", + mime_type="video/mp4", + metadata={"key": "val"}, + file_size=1024000, + thumbnail_url="http://cdn/thumb.jpg", + duration=120.5, + width=1920, + height=1080, + fps=30.0, + codec="h264", + status=AssetStatus.READY, + classification_status=ClassificationStatus.COMPLETED, + quality_score=0.85, + uploaded_by_user_id="user1", + file_hash="abc123", + ) + assert asset.file_size == 1024000 + assert asset.thumbnail_url == "http://cdn/thumb.jpg" + assert asset.duration == 120.5 + assert asset.width == 1920 + assert asset.height == 1080 + assert asset.fps == 30.0 + assert asset.codec == "h264" + assert asset.status == AssetStatus.READY + assert asset.classification_status == ClassificationStatus.COMPLETED + assert asset.quality_score == 0.85 + assert asset.uploaded_by_user_id == "user1" + assert asset.file_hash == "abc123" + assert asset.metadata == {"key": "val"} + + def test_create_strips_name(self): + asset = Asset.create("p1", "l1", " test.mp4 ", "k", "video/mp4") + assert asset.name == "test.mp4" + + def test_create_strips_storage_key(self): + asset = Asset.create("p1", "l1", "n", " key.mp4 ", "video/mp4") + assert asset.storage_key == "key.mp4" + + def test_create_strips_mime_type(self): + asset = Asset.create("p1", "l1", "n", "k", " video/mp4 ") + assert asset.mime_type == "video/mp4" + + def test_create_empty_name(self): + with pytest.raises(ValueError, match="素材名称不能为空"): + Asset.create("p1", "l1", "", "k", "video/mp4") + + def test_create_empty_storage_key(self): + with pytest.raises(ValueError, match="storage_key 不能为空"): + Asset.create("p1", "l1", "n", "", "video/mp4") + + def test_create_empty_mime_type(self): + with pytest.raises(ValueError, match="mime_type 不能为空"): + Asset.create("p1", "l1", "n", "k", "") + + def test_create_whitespace_storage_key(self): + with pytest.raises(ValueError, match="storage_key 不能为空"): + Asset.create("p1", "l1", "n", " \t ", "video/mp4") + + def test_create_none_metadata_defaults_to_empty_dict(self): + asset = Asset.create("p1", "l1", "n", "k", "video/mp4", metadata=None) + assert asset.metadata == {} + + def test_create_unique_ids(self): + a1 = Asset.create("p1", "l1", "n1", "k1", "video/mp4") + a2 = Asset.create("p1", "l1", "n2", "k2", "video/mp4") + assert a1.id != a2.id + + +class TestAssetFileType: + def test_video_mime(self): + asset = Asset.create("p1", "l1", "n", "k", "video/mp4") + assert asset.file_type == "video" + + def test_audio_mime(self): + asset = Asset.create("p1", "l1", "n", "k", "audio/mpeg") + assert asset.file_type == "audio" + + def test_image_mime(self): + asset = Asset.create("p1", "l1", "n", "k", "image/jpeg") + assert asset.file_type == "image" + + def test_simple_mime_no_slash(self): + asset = Asset.create("p1", "l1", "n", "k", "application") + assert asset.file_type == "application" + + +class TestAssetTags: + def test_add_tag(self): + asset = Asset.create("p1", "l1", "n", "k", "video/mp4") + asset.add_tag("tag1") + assert "tag1" in asset.tag_ids + assert len(asset.tag_ids) == 1 + + def test_add_tag_strips(self): + asset = Asset.create("p1", "l1", "n", "k", "video/mp4") + asset.add_tag(" tag_trim ") + assert "tag_trim" in asset.tag_ids + + def test_add_tag_duplicate_prevented(self): + asset = Asset.create("p1", "l1", "n", "k", "video/mp4") + asset.add_tag("tag1") + asset.add_tag("tag1") + assert asset.tag_ids.count("tag1") == 1 + assert len(asset.tag_ids) == 1 + + def test_add_tag_empty(self): + asset = Asset.create("p1", "l1", "n", "k", "video/mp4") + with pytest.raises(ValueError, match="标签 ID 不能为空"): + asset.add_tag("") + + def test_add_tag_whitespace(self): + asset = Asset.create("p1", "l1", "n", "k", "video/mp4") + with pytest.raises(ValueError, match="标签 ID 不能为空"): + asset.add_tag(" \t ") + + def test_add_multiple_tags(self): + asset = Asset.create("p1", "l1", "n", "k", "video/mp4") + asset.add_tag("t1") + asset.add_tag("t2") + asset.add_tag("t3") + assert asset.tag_ids == ["t1", "t2", "t3"] + + def test_remove_tag(self): + asset = Asset.create("p1", "l1", "n", "k", "video/mp4") + asset.add_tag("t1") + asset.add_tag("t2") + asset.remove_tag("t1") + assert asset.tag_ids == ["t2"] + + def test_remove_nonexistent_tag_idempotent(self): + asset = Asset.create("p1", "l1", "n", "k", "video/mp4") + asset.add_tag("t1") + # 删除不存在的标签不报错 + asset.remove_tag("nonexistent") + assert asset.tag_ids == ["t1"] + + def test_remove_tag_strips(self): + asset = Asset.create("p1", "l1", "n", "k", "video/mp4") + asset.add_tag("t1") + asset.remove_tag(" t1 ") + assert asset.tag_ids == [] + + def test_add_tag_updates_updated_at(self): + asset = Asset.create("p1", "l1", "n", "k", "video/mp4") + old_time = asset.updated_at + asset.add_tag("t1") + assert asset.updated_at >= old_time + + def test_remove_tag_updates_updated_at(self): + asset = Asset.create("p1", "l1", "n", "k", "video/mp4") + asset.add_tag("t1") + old_time = asset.updated_at + asset.remove_tag("t1") + assert asset.updated_at >= old_time + + +class TestIngestJobCreate: + def test_create_success(self): + job = IngestJob.create( + project_id="proj1", + library_id="lib1", + storage_key="videos/test.mp4", + ) + assert job.id is not None + assert len(job.id) == 32 + assert job.project_id == "proj1" + assert job.library_id == "lib1" + assert job.storage_key == "videos/test.mp4" + assert job.status == IngestJobStatus.PENDING + assert job.error_message == "" + assert job.result_asset_id == "" + assert job.file_hash == "" + + def test_create_with_hash(self): + job = IngestJob.create("p1", "l1", "k", file_hash="abcdef123456") + assert job.file_hash == "abcdef123456" + + def test_create_strips_project_id(self): + job = IngestJob.create(" p1 ", "l1", "k") + assert job.project_id == "p1" + + def test_create_strips_library_id(self): + job = IngestJob.create("p1", " l1 ", "k") + assert job.library_id == "l1" + + def test_create_strips_storage_key(self): + job = IngestJob.create("p1", "l1", " k ") + assert job.storage_key == "k" + + def test_create_strips_file_hash(self): + job = IngestJob.create("p1", "l1", "k", file_hash=" hash ") + assert job.file_hash == "hash" + + def test_create_empty_project_id(self): + with pytest.raises(ValueError, match="project_id 不能为空"): + IngestJob.create("", "l1", "k") + + def test_create_empty_library_id(self): + with pytest.raises(ValueError, match="library_id 不能为空"): + IngestJob.create("p1", "", "k") + + def test_create_empty_storage_key(self): + with pytest.raises(ValueError, match="storage_key 不能为空"): + IngestJob.create("p1", "l1", "") + + def test_create_whitespace_project_id(self): + with pytest.raises(ValueError, match="project_id 不能为空"): + IngestJob.create(" \t ", "l1", "k") + + def test_create_unique_ids(self): + j1 = IngestJob.create("p1", "l1", "k1") + j2 = IngestJob.create("p1", "l1", "k2") + assert j1.id != j2.id + + +class TestUserDataclass: + def test_default_values(self): + user = User(id="u1", email="test@example.com", display_name="Test User") + assert user.id == "u1" + assert user.email == "test@example.com" + assert user.display_name == "Test User" + assert user.username == "" + assert user.password_hash == "" + assert user.email_verified is False + assert user.subscription_plan == "free" + assert user.subscription_status == "active" + assert user.max_projects == 3 + assert user.max_storage_gb == 10 + assert user.used_storage_gb == 0.0 + assert user.is_admin is False + assert user.wechat_openid is None + assert user.phone is None + assert user.phone_verified is False + assert isinstance(user.created_at, datetime) + + def test_admin_user(self): + user = User(id="admin", email="admin@test.com", display_name="Admin", is_admin=True) + assert user.is_admin is True + + def test_pro_subscription(self): + user = User( + id="u1", + email="u@t.com", + display_name="U", + subscription_plan="pro", + max_storage_gb=100, + ) + assert user.subscription_plan == "pro" + assert user.max_storage_gb == 100 From 4e8265ec5eb107a8279ed40fe70bc06838ec1b7b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 22:04:19 +0800 Subject: [PATCH 03/45] test(wave96): 76 unit tests for Job domain model (#959) --- tests/unit/test_job_domain.py | 655 +++++++++++++++++----------------- 1 file changed, 335 insertions(+), 320 deletions(-) diff --git a/tests/unit/test_job_domain.py b/tests/unit/test_job_domain.py index ba0cc9aa1..948ccc105 100755 --- a/tests/unit/test_job_domain.py +++ b/tests/unit/test_job_domain.py @@ -1,4 +1,6 @@ -"""Job 领域层单元测试 - job.py""" +"""Job 领域模型单元测试。""" + +from datetime import datetime, timezone import pytest @@ -10,45 +12,45 @@ from packages.domain.job import ( ) -class TestJobType: - """JobType 枚举测试""" +class TestJobTypeEnum: + def test_all_types_exist(self): + assert JobType.VIDEO_COMPOSE.value == "video_compose" + assert JobType.RENDER_EDIT_PLAN.value == "render_edit_plan" + assert JobType.ASSET_INGEST.value == "asset_ingest" + assert JobType.CLASSIFICATION.value == "classification" + assert JobType.VOICE_EXTRACTION.value == "voice_extraction" + assert JobType.GENERATION.value == "generation" - def test_all_types_have_values(self): - """所有枚举成员都有字符串值""" - for jt in JobType: - assert isinstance(jt.value, str) - assert jt.value + def test_from_string(self): + assert JobType("video_compose") == JobType.VIDEO_COMPOSE + assert JobType("generation") == JobType.GENERATION - def test_str_enum_behavior(self): - """是 str 枚举""" - assert JobType.VIDEO_COMPOSE == "video_compose" - assert isinstance(JobType.VIDEO_COMPOSE, str) - - def test_known_types_exist(self): - """核心任务类型都存在""" - assert JobType.VIDEO_COMPOSE - assert JobType.RENDER_EDIT_PLAN - assert JobType.ASSET_INGEST - assert JobType.CLASSIFICATION - assert JobType.GENERATION + def test_invalid_type_raises(self): + with pytest.raises(ValueError): + JobType("invalid_type") -class TestJobStatus: - """JobStatus 枚举测试""" +class TestJobStatusEnum: + def test_all_statuses_exist(self): + assert JobStatus.PENDING.value == "pending" + assert JobStatus.RUNNING.value == "running" + assert JobStatus.SUCCESS.value == "success" + assert JobStatus.FAILED.value == "failed" + assert JobStatus.CANCELLED.value == "cancelled" - def test_all_statuses_have_values(self): - for js in JobStatus: - assert isinstance(js.value, str) - assert js.value + def test_from_string(self): + assert JobStatus("pending") == JobStatus.PENDING + assert JobStatus("success") == JobStatus.SUCCESS - def test_str_enum_behavior(self): - assert JobStatus.PENDING == "pending" - assert isinstance(JobStatus.PENDING, str) - def test_terminal_statuses(self): - """终态集合包含成功/失败/取消""" +class TestTerminalStatuses: + def test_success_is_terminal(self): assert JobStatus.SUCCESS in TERMINAL_STATUSES + + def test_failed_is_terminal(self): assert JobStatus.FAILED in TERMINAL_STATUSES + + def test_cancelled_is_terminal(self): assert JobStatus.CANCELLED in TERMINAL_STATUSES def test_pending_not_terminal(self): @@ -59,372 +61,376 @@ class TestJobStatus: class TestJobCreate: - """Job.create 工厂方法测试""" - - def test_create_basic(self): - """基本创建""" - job = Job.create( - project_id="proj-1", - job_type=JobType.VIDEO_COMPOSE, - ) - assert job.id - assert len(job.id) == 32 # uuid4 hex - assert job.project_id == "proj-1" + def test_create_minimal(self): + job = Job.create(project_id="proj1", job_type=JobType.VIDEO_COMPOSE) + assert job.id is not None + assert len(job.id) == 32 + assert job.project_id == "proj1" assert job.job_type == JobType.VIDEO_COMPOSE assert job.status == JobStatus.PENDING assert job.progress == 0.0 + assert job.current_stage == "" assert job.payload == {} assert job.result == {} + assert job.error_message == "" assert job.retry_count == 0 assert job.max_retries == 3 - assert job.created_at - assert job.updated_at + assert job.celery_task_id == "" + assert job.source_id == "" + assert job.created_by_user_id == "" + assert job.started_at is None + assert job.completed_at is None + assert isinstance(job.created_at, datetime) + assert isinstance(job.updated_at, datetime) - def test_create_with_string_job_type(self): - """用字符串创建任务类型""" - job = Job.create( - project_id="proj-1", - job_type="video_compose", - ) + def test_create_with_enum_type(self): + job = Job.create("p1", JobType.GENERATION) + assert job.job_type == JobType.GENERATION + + def test_create_with_string_type(self): + job = Job.create("p1", "video_compose") assert job.job_type == JobType.VIDEO_COMPOSE - def test_create_invalid_string_job_type_raises(self): - """无效的任务类型字符串抛 ValueError""" - with pytest.raises(ValueError, match="不支持的任务类型"): - Job.create(project_id="proj-1", job_type="invalid_type") - - def test_create_empty_project_id_raises(self): - """空 project_id 抛 ValueError""" - with pytest.raises(ValueError, match="project_id 不能为空"): - Job.create(project_id=" ", job_type=JobType.VIDEO_COMPOSE) - def test_create_with_payload(self): - """带 payload 创建""" - payload = {"video_id": "v1", "quality": "1080p"} - job = Job.create( - project_id="proj-1", - job_type=JobType.VIDEO_COMPOSE, - payload=payload, - ) + payload = {"edit_plan_id": "plan123", "resolution": "1080p"} + job = Job.create("p1", JobType.VIDEO_COMPOSE, payload=payload) assert job.payload == payload - def test_create_with_source_id(self): - """带 source_id 创建""" - job = Job.create( - project_id="proj-1", - job_type=JobType.VIDEO_COMPOSE, - source_id="plan-123", - ) - assert job.source_id == "plan-123" - - def test_create_with_created_by(self): - """带创建人""" - job = Job.create( - project_id="proj-1", - job_type=JobType.VIDEO_COMPOSE, - created_by_user_id="user-1", - ) - assert job.created_by_user_id == "user-1" - - def test_create_with_custom_max_retries(self): - """自定义最大重试次数""" - job = Job.create( - project_id="proj-1", - job_type=JobType.VIDEO_COMPOSE, - max_retries=5, - ) - assert job.max_retries == 5 - - def test_create_project_id_stripped(self): - """project_id 会被 strip""" - job = Job.create( - project_id=" proj-1 ", - job_type=JobType.VIDEO_COMPOSE, - ) - assert job.project_id == "proj-1" - - def test_create_source_id_stripped(self): - job = Job.create( - project_id="proj-1", - job_type=JobType.VIDEO_COMPOSE, - source_id=" src-1 ", - ) - assert job.source_id == "src-1" - - def test_create_created_by_stripped(self): - job = Job.create( - project_id="proj-1", - job_type=JobType.VIDEO_COMPOSE, - created_by_user_id=" user-1 ", - ) - assert job.created_by_user_id == "user-1" - - def test_create_none_payload_defaults_to_empty_dict(self): - """payload=None 时默认为空 dict""" - job = Job.create( - project_id="proj-1", - job_type=JobType.VIDEO_COMPOSE, - payload=None, - ) + def test_create_with_none_payload(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE, payload=None) assert job.payload == {} + def test_create_with_source_id(self): + job = Job.create("p1", JobType.GENERATION, source_id="gen123") + assert job.source_id == "gen123" -class TestJobIsTerminal: - """is_terminal 属性测试""" + def test_create_with_user_id(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE, created_by_user_id="user1") + assert job.created_by_user_id == "user1" + def test_create_with_custom_max_retries(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=5) + assert job.max_retries == 5 + + def test_create_strips_project_id(self): + job = Job.create(" proj1 ", JobType.VIDEO_COMPOSE) + assert job.project_id == "proj1" + + def test_create_strips_source_id(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE, source_id=" src1 ") + assert job.source_id == "src1" + + def test_create_strips_user_id(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE, created_by_user_id=" u1 ") + assert job.created_by_user_id == "u1" + + def test_create_empty_project_id(self): + with pytest.raises(ValueError, match="project_id 不能为空"): + Job.create("", JobType.VIDEO_COMPOSE) + + def test_create_whitespace_project_id(self): + with pytest.raises(ValueError, match="project_id 不能为空"): + Job.create(" \t ", JobType.VIDEO_COMPOSE) + + def test_create_invalid_job_type_string(self): + with pytest.raises(ValueError, match="不支持的任务类型"): + Job.create("p1", "invalid_type") + + def test_create_unique_ids(self): + j1 = Job.create("p1", JobType.VIDEO_COMPOSE) + j2 = Job.create("p1", JobType.VIDEO_COMPOSE) + assert j1.id != j2.id + + +class TestIsTerminal: def test_pending_not_terminal(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job = Job.create("p1", JobType.VIDEO_COMPOSE) assert job.is_terminal is False def test_running_not_terminal(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) assert job.is_terminal is False def test_success_is_terminal(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.SUCCESS) assert job.is_terminal is True def test_failed_is_terminal(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.FAILED) assert job.is_terminal is True def test_cancelled_is_terminal(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.CANCELLED) assert job.is_terminal is True -class TestJobTransitions: - """状态转换测试""" +class TestIsRetryable: + def test_pending_not_retryable(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + assert job.is_retryable is False + def test_running_not_retryable(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + assert job.is_retryable is False + + def test_success_not_retryable(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.mark_running() + job.mark_success() + assert job.is_retryable is False + + def test_failed_within_limit_is_retryable(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3) + job.mark_running() + job.mark_failed("error") + assert job.is_retryable is True + + def test_failed_at_limit_not_retryable(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3) + job.mark_running() + job.mark_failed("error") + job.retry_count = 3 # 已达到上限 + assert job.is_retryable is False + + def test_failed_over_limit_not_retryable(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3) + job.retry_count = 5 + job.status = JobStatus.FAILED + assert job.is_retryable is False + + def test_zero_max_retries_not_retryable(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=0) + job.status = JobStatus.FAILED + assert job.is_retryable is False + + +class TestTransitionTo: def test_pending_to_running(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) assert job.status == JobStatus.RUNNING assert job.started_at is not None def test_pending_to_success(self): - """pending 可以直接到 success(快速成功)""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.SUCCESS) assert job.status == JobStatus.SUCCESS assert job.completed_at is not None def test_pending_to_cancelled(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.CANCELLED) assert job.status == JobStatus.CANCELLED - def test_running_to_success(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - job.transition_to(JobStatus.RUNNING) - job.transition_to(JobStatus.SUCCESS) - assert job.status == JobStatus.SUCCESS - assert job.completed_at is not None - - def test_running_to_failed(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - job.transition_to(JobStatus.RUNNING) - job.transition_to(JobStatus.FAILED) - assert job.status == JobStatus.FAILED - assert job.completed_at is not None - - def test_running_to_cancelled(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - job.transition_to(JobStatus.RUNNING) - job.transition_to(JobStatus.CANCELLED) - assert job.status == JobStatus.CANCELLED - - def test_failed_to_pending_retry(self): - """失败后可以回到 pending(重试)""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - job.transition_to(JobStatus.RUNNING) - job.transition_to(JobStatus.FAILED) - job.transition_to(JobStatus.PENDING) - assert job.status == JobStatus.PENDING - - def test_invalid_transition_raises(self): - """非法状态转换抛 ValueError""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - # pending 不能直接到 failed + def test_pending_to_failed_invalid(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) with pytest.raises(ValueError, match="非法状态转换"): job.transition_to(JobStatus.FAILED) - def test_success_to_pending_raises(self): - """成功后不能回到 pending""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + def test_running_to_success(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + job.transition_to(JobStatus.SUCCESS) + assert job.status == JobStatus.SUCCESS + + def test_running_to_failed(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + job.transition_to(JobStatus.FAILED) + assert job.status == JobStatus.FAILED + + def test_running_to_cancelled(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + job.transition_to(JobStatus.CANCELLED) + assert job.status == JobStatus.CANCELLED + + def test_running_to_pending_invalid(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + with pytest.raises(ValueError, match="非法状态转换"): + job.transition_to(JobStatus.PENDING) + + def test_failed_to_pending(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + job.transition_to(JobStatus.FAILED) + # 注意:_VALID_TRANSITIONS 中 FAILED → PENDING 是允许的 + job.transition_to(JobStatus.PENDING) + assert job.status == JobStatus.PENDING + + def test_success_to_anything_invalid(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.SUCCESS) with pytest.raises(ValueError): - job.transition_to(JobStatus.PENDING) + job.transition_to(JobStatus.FAILED) + with pytest.raises(ValueError): + job.transition_to(JobStatus.RUNNING) def test_transition_with_string_status(self): - """用字符串做状态转换""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to("running") assert job.status == JobStatus.RUNNING - def test_transition_invalid_string_raises(self): - """无效状态字符串抛 ValueError""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + def test_transition_with_invalid_string(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) with pytest.raises(ValueError, match="无效状态"): job.transition_to("invalid_status") def test_transition_updates_updated_at(self): - """状态转换更新 updated_at""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - old_updated = job.updated_at - import time - - time.sleep(0.001) + job = Job.create("p1", JobType.VIDEO_COMPOSE) + old_time = job.updated_at job.transition_to(JobStatus.RUNNING) - assert job.updated_at >= old_updated + assert job.updated_at >= old_time def test_started_at_only_set_once(self): - """started_at 只在第一次 RUNNING 时设置""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) - first_started = job.started_at - job.transition_to(JobStatus.SUCCESS) - # 回到 pending 再 running(模拟重试场景,但started_at是None时才设置) - # 注意:正常重试是通过 prepare_retry 重置的 - assert first_started is not None + first_start = job.started_at + # 再次 RUNNING 不合法,但我们测试 started_at 在多次 running→success→retry→running 时的行为 + # 先失败重试 + job.transition_to(JobStatus.FAILED) + job.transition_to(JobStatus.PENDING) + job.started_at = None # 模拟 prepare_retry 的重置 + job.transition_to(JobStatus.RUNNING) + assert job.started_at is not None + assert job.started_at != first_start -class TestJobMarkMethods: - """便捷标记方法测试""" - - def test_mark_running(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - job.mark_running("合成中") - assert job.status == JobStatus.RUNNING - assert job.current_stage == "合成中" - - def test_mark_running_no_stage(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) +class TestMarkRunning: + def test_mark_running_basic(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_running() assert job.status == JobStatus.RUNNING - assert job.current_stage == "" + assert job.started_at is not None - def test_mark_success(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - job.mark_running() - job.mark_success({"output_url": "http://..."}) - assert job.status == JobStatus.SUCCESS - assert job.progress == 100.0 - assert job.current_stage == "完成" - assert job.result == {"output_url": "http://..."} + def test_mark_running_with_stage(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.mark_running(stage="下载素材") + assert job.status == JobStatus.RUNNING + assert job.current_stage == "下载素材" - def test_mark_success_no_result(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + def test_mark_running_empty_stage_unchanged(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.current_stage = "已有阶段" + job.mark_running() # 不传 stage + assert job.current_stage == "已有阶段" + + +class TestMarkSuccess: + def test_mark_success_basic(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_running() job.mark_success() assert job.status == JobStatus.SUCCESS - assert job.result == {} + assert job.progress == 100.0 + assert job.current_stage == "完成" + assert job.completed_at is not None - def test_mark_failed(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + def test_mark_success_with_result(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.mark_running() + result = {"video_url": "https://...", "duration": 30} + job.mark_success(result=result) + assert job.result == result + + def test_mark_success_without_result(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.mark_running() + original_result = job.result.copy() + job.mark_success() + assert job.result == original_result # 不变 + + +class TestMarkFailed: + def test_mark_failed_basic(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_running() job.mark_failed("网络超时") assert job.status == JobStatus.FAILED assert job.error_message == "网络超时" assert job.current_stage == "失败" + assert job.completed_at is not None - def test_mark_cancelled(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + def test_mark_failed_empty_message(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.mark_running() + job.mark_failed("") + assert job.error_message == "" + + +class TestMarkCancelled: + def test_mark_cancelled_from_pending(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_cancelled() assert job.status == JobStatus.CANCELLED assert job.current_stage == "已取消" + def test_mark_cancelled_from_running(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.mark_running() + job.mark_cancelled() + assert job.status == JobStatus.CANCELLED -class TestJobProgress: - """进度更新测试""" - def test_update_progress(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - job.update_progress(50.0, "渲染中") +class TestUpdateProgress: + def test_update_progress_valid(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.update_progress(50.0) assert job.progress == 50.0 - assert job.current_stage == "渲染中" def test_update_progress_zero(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.update_progress(0.0) assert job.progress == 0.0 - def test_update_progress_100(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + def test_update_progress_hundred(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) job.update_progress(100.0) assert job.progress == 100.0 - def test_update_progress_negative_raises(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + def test_update_progress_negative(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) with pytest.raises(ValueError, match="进度必须在 0~100 之间"): job.update_progress(-1.0) - def test_update_progress_over_100_raises(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + def test_update_progress_over_100(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) with pytest.raises(ValueError, match="进度必须在 0~100 之间"): job.update_progress(101.0) - def test_update_progress_without_stage(self): - """不传 stage 时不修改 current_stage""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - job.current_stage = "初始阶段" - job.update_progress(30.0) + def test_update_progress_with_stage(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.update_progress(30.0, stage="渲染中") assert job.progress == 30.0 - assert job.current_stage == "初始阶段" + assert job.current_stage == "渲染中" - def test_update_progress_updates_updated_at(self): - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - old_updated = job.updated_at - import time - - time.sleep(0.001) + def test_update_progress_without_stage_unchanged(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.current_stage = "原阶段" job.update_progress(50.0) - assert job.updated_at >= old_updated + assert job.current_stage == "原阶段" + + def test_update_progress_updates_timestamp(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + old_time = job.updated_at + job.update_progress(25.0) + assert job.updated_at >= old_time -class TestJobRetry: - """重试逻辑测试""" - - def test_is_retryable_failed_within_limit(self): - """失败且未超过重试上限时可重试""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3) - job.mark_running() - job.mark_failed("错误") - assert job.is_retryable is True - - def test_is_retryable_failed_at_limit(self): - """达到重试上限时不可重试""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=1) - job.mark_running() - job.mark_failed("错误") - job.retry_count = 1 - assert job.is_retryable is False - - def test_is_retryable_pending_false(self): - """pending 状态不可重试""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - assert job.is_retryable is False - - def test_is_retryable_success_false(self): - """成功状态不可重试""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - job.mark_running() - job.mark_success() - assert job.is_retryable is False - - def test_prepare_retry(self): - """准备重试""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3) +class TestPrepareRetry: + def test_prepare_retry_success(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3) job.mark_running() job.mark_failed("网络错误") - job.celery_task_id = "task-123" job.prepare_retry() @@ -437,38 +443,41 @@ class TestJobRetry: assert job.completed_at is None assert job.celery_task_id == "" - def test_prepare_retry_not_retryable_raises(self): - """不可重试时抛 ValueError""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=0) + def test_prepare_retry_increments_count(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=5) job.mark_running() - job.mark_failed("错误") - with pytest.raises(ValueError, match="任务不可重试"): - job.prepare_retry() + job.mark_failed("err") - def test_prepare_retry_increments_correctly(self): - """多次重试计数正确""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3) - job.mark_running() - job.mark_failed("错误1") job.prepare_retry() assert job.retry_count == 1 + # 再次失败重试 job.mark_running() - job.mark_failed("错误2") + job.mark_failed("err2") job.prepare_retry() assert job.retry_count == 2 + def test_prepare_retry_not_retryable_raises(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=0) + job.mark_running() + job.mark_failed("err") + with pytest.raises(ValueError, match="任务不可重试"): + job.prepare_retry() -class TestJobToDict: - """to_dict 序列化测试""" + def test_prepare_retry_wrong_status_raises(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE) + with pytest.raises(ValueError, match="任务不可重试"): + job.prepare_retry() - def test_to_dict_contains_all_fields(self): + +class TestToDict: + def test_to_dict_structure(self): job = Job.create( - project_id="p1", - job_type=JobType.VIDEO_COMPOSE, - payload={"key": "value"}, - source_id="src-1", - created_by_user_id="user-1", + "p1", + JobType.VIDEO_COMPOSE, + payload={"key": "val"}, + source_id="src1", + created_by_user_id="u1", ) d = job.to_dict() assert d["id"] == job.id @@ -476,33 +485,39 @@ class TestJobToDict: assert d["job_type"] == "video_compose" assert d["status"] == "pending" assert d["progress"] == 0.0 - assert d["payload"] == {"key": "value"} - assert d["source_id"] == "src-1" - assert d["created_by_user_id"] == "user-1" + assert d["current_stage"] == "" + assert d["payload"] == {"key": "val"} + assert d["result"] == {} + assert d["error_message"] == "" + assert d["retry_count"] == 0 + assert d["max_retries"] == 3 + assert d["celery_task_id"] == "" + assert d["source_id"] == "src1" + assert d["created_by_user_id"] == "u1" assert d["is_retryable"] is False - - def test_to_dict_datetime_fields_are_strings(self): - """时间字段序列化为 ISO 字符串""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - d = job.to_dict() - assert isinstance(d["created_at"], str) - assert isinstance(d["updated_at"], str) - - def test_to_dict_none_datetime_fields(self): - """未设置的时间字段为 None""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - d = job.to_dict() assert d["started_at"] is None assert d["completed_at"] is None + assert d["created_at"] is not None + assert d["updated_at"] is not None def test_to_dict_after_success(self): - """成功后 to_dict 状态正确""" - job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) - job.mark_running() - job.mark_success({"url": "http://..."}) + job = Job.create("p1", JobType.VIDEO_COMPOSE) + job.mark_running("渲染") + job.mark_success({"url": "https://..."}) d = job.to_dict() assert d["status"] == "success" assert d["progress"] == 100.0 - assert d["result"] == {"url": "http://..."} + assert d["is_retryable"] is False assert d["started_at"] is not None assert d["completed_at"] is not None + assert isinstance(d["started_at"], str) + assert isinstance(d["completed_at"], str) + + def test_to_dict_after_failed(self): + job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3) + job.mark_running() + job.mark_failed("timeout") + d = job.to_dict() + assert d["status"] == "failed" + assert d["error_message"] == "timeout" + assert d["is_retryable"] is True From 6514b8c34daf3552eec43ad87ee9900be1b78723 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 22:04:19 +0800 Subject: [PATCH 04/45] test(wave97): 53 unit tests for quota domain system (#960) --- tests/unit/test_quota_domain.py | 508 ++++++++++++++------------------ 1 file changed, 223 insertions(+), 285 deletions(-) diff --git a/tests/unit/test_quota_domain.py b/tests/unit/test_quota_domain.py index c00023f17..3d11e1ec7 100755 --- a/tests/unit/test_quota_domain.py +++ b/tests/unit/test_quota_domain.py @@ -1,13 +1,11 @@ -"""Quota 领域层单元测试 - quota.py""" - -import math +"""Quota 配额系统单元测试。""" import pytest from packages.domain.quota import ( QUOTA_TIERS, - QuotaChecker, QuotaCheckResult, + QuotaChecker, QuotaDimension, QuotaRegistry, QuotaTier, @@ -19,103 +17,114 @@ from packages.domain.quota import ( class TestQuotaDimension: - """QuotaDimension 枚举测试""" + def test_core_dimensions_exist(self): + assert QuotaDimension.STORAGE_GB.value == "storage_gb" + assert QuotaDimension.VIDEOS_PER_MONTH.value == "videos_per_month" + assert QuotaDimension.MAX_CONCURRENT.value == "max_concurrent" + assert QuotaDimension.MAX_TEMPLATES.value == "max_templates" + assert QuotaDimension.MAX_TITLES.value == "max_titles" + assert QuotaDimension.MAX_VOICEOVERS.value == "max_voiceovers" + assert QuotaDimension.AI_VOICE_ENABLED.value == "ai_voice_enabled" - def test_all_dimensions_have_values(self): - """所有枚举成员都有字符串值""" + def test_extended_dimensions_exist(self): + assert QuotaDimension.AI_VOICE_CREDITS.value == "ai_voice_credits" + assert QuotaDimension.BATCH_EXPORT_ENABLED.value == "batch_export_enabled" + assert QuotaDimension.MULTI_PLATFORM_ENABLED.value == "multi_platform_enabled" + assert QuotaDimension.DEDUP_REPORT_ENABLED.value == "dedup_report_enabled" + + def test_all_dimensions_are_strings(self): for dim in QuotaDimension: assert isinstance(dim.value, str) - assert dim.value - - def test_dimension_count(self): - """配额维度数量 >= 内置维度""" - # 至少有 storage_gb, videos_per_month, max_concurrent, max_templates 等 - assert len(QuotaDimension) >= 7 - - def test_str_enum_behavior(self): - """是 str 枚举,可直接当字符串用""" - assert QuotaDimension.STORAGE_GB == "storage_gb" - assert isinstance(QuotaDimension.STORAGE_GB, str) class TestQuotaTier: - """QuotaTier 测试""" - def test_get_limit_defined(self): - """已定义的维度返回正确值""" - tier = QuotaTier(name="test", limits={"storage": 10, "videos": 5}) - assert tier.get_limit("storage") == 10 + tier = QuotaTier(name="test", limits={"storage_gb": 10, "videos": 5}) + assert tier.get_limit("storage_gb") == 10 assert tier.get_limit("videos") == 5 def test_get_limit_undefined_returns_zero(self): - """未定义的维度返回 0""" - tier = QuotaTier(name="test", limits={"storage": 10}) - assert tier.get_limit("unknown") == 0 + tier = QuotaTier(name="test", limits={"storage_gb": 10}) + assert tier.get_limit("unknown_dim") == 0 - def test_is_unlimited_true(self): - """不限量判断 - inf""" + def test_is_unlimited_false_for_finite(self): + tier = QuotaTier(name="test", limits={"storage_gb": 10}) + assert tier.is_unlimited("storage_gb") is False + + def test_is_unlimited_true_for_inf(self): tier = QuotaTier(name="test", limits={"templates": float("inf")}) assert tier.is_unlimited("templates") is True - def test_is_unlimited_false(self): - """限量判断""" - tier = QuotaTier(name="test", limits={"storage": 10}) - assert tier.is_unlimited("storage") is False - - def test_is_unlimited_undefined_returns_true(self): - """未定义的维度默认 inf,is_unlimited 返回 True""" + def test_is_unlimited_undefined(self): tier = QuotaTier(name="test", limits={}) - # get_limit 用 dict.get 默认 0,但 is_unlimited 用 dict.get 默认 inf + # 未定义的维度,limits.get 返回默认 inf,所以 is_unlimited 返回 True assert tier.is_unlimited("unknown") is True + def test_empty_limits(self): + tier = QuotaTier(name="empty") + assert tier.limits == {} + assert tier.name == "empty" + class TestQuotaTiers: - """内置套餐配额测试""" - def test_three_tiers_exist(self): - """三个套餐等级都存在""" assert "free" in QUOTA_TIERS assert "basic" in QUOTA_TIERS assert "premium" in QUOTA_TIERS - def test_free_tier_storage(self): - """free 套餐 2GB 存储""" - assert QUOTA_TIERS["free"].get_limit(QuotaDimension.STORAGE_GB) == 2 + def test_free_tier_limits(self): + free = QUOTA_TIERS["free"] + assert free.get_limit("storage_gb") == 2 + assert free.get_limit("videos_per_month") == 5 + assert free.get_limit("max_concurrent") == 3 + assert free.get_limit("max_templates") == 3 + assert free.get_limit("max_titles") == 50 + assert free.get_limit("max_voiceovers") == 10 + assert free.get_limit("ai_voice_enabled") == 0 - def test_basic_tier_storage(self): - """basic 套餐 20GB 存储""" - assert QUOTA_TIERS["basic"].get_limit(QuotaDimension.STORAGE_GB) == 20 + def test_basic_tier_limits(self): + basic = QUOTA_TIERS["basic"] + assert basic.get_limit("storage_gb") == 20 + assert basic.get_limit("videos_per_month") == 30 + assert basic.get_limit("max_concurrent") == 10 + assert basic.get_limit("max_templates") == 15 + assert basic.get_limit("max_titles") == 500 + assert basic.get_limit("max_voiceovers") == 100 + assert basic.get_limit("ai_voice_enabled") == 1 + assert basic.get_limit("ai_voice_credits") == 100 + assert basic.get_limit("batch_export_enabled") == 1 - def test_premium_tier_storage(self): - """premium 套餐 100GB 存储""" - assert QUOTA_TIERS["premium"].get_limit(QuotaDimension.STORAGE_GB) == 100 + def test_premium_tier_limits(self): + premium = QUOTA_TIERS["premium"] + assert premium.get_limit("storage_gb") == 100 + assert premium.get_limit("videos_per_month") == 100 + assert premium.get_limit("max_concurrent") == 20 + assert premium.is_unlimited("max_templates") is True + assert premium.get_limit("ai_voice_enabled") == 1 + assert premium.get_limit("ai_voice_credits") == 500 + assert premium.get_limit("batch_export_enabled") == 1 + assert premium.get_limit("multi_platform_enabled") == 1 + assert premium.get_limit("dedup_report_enabled") == 1 - def test_free_no_ai_voice(self): - """free 套餐没有 AI 配音""" - assert QUOTA_TIERS["free"].get_limit(QuotaDimension.AI_VOICE_ENABLED) == 0 - - def test_basic_has_ai_voice(self): - """basic 套餐有 AI 配音""" - assert QUOTA_TIERS["basic"].get_limit(QuotaDimension.AI_VOICE_ENABLED) == 1 - - def test_premium_templates_unlimited(self): - """premium 套餐模板不限量""" - assert QUOTA_TIERS["premium"].is_unlimited(QuotaDimension.MAX_TEMPLATES) is True - - def test_free_videos_per_month(self): - """free 每月 5 个视频""" - assert QUOTA_TIERS["free"].get_limit(QuotaDimension.VIDEOS_PER_MONTH) == 5 - - def test_premium_multi_platform_enabled(self): - """premium 支持多平台发布""" - assert QUOTA_TIERS["premium"].get_limit(QuotaDimension.MULTI_PLATFORM_ENABLED) == 1 + def test_tier_increase_monotonic(self): + free = QUOTA_TIERS["free"] + basic = QUOTA_TIERS["basic"] + premium = QUOTA_TIERS["premium"] + # 高级套餐应该 >= 低级套餐的所有限制 + for dim in [ + "storage_gb", + "videos_per_month", + "max_concurrent", + "max_titles", + "max_voiceovers", + "ai_voice_credits", + ]: + assert basic.get_limit(dim) >= free.get_limit(dim) + assert premium.get_limit(dim) >= basic.get_limit(dim) class TestQuotaWarningLevel: - """告警级别常量测试""" - - def test_level_values(self): - """四个告警级别都有定义""" + def test_levels_exist(self): assert QuotaWarningLevel.NORMAL == "normal" assert QuotaWarningLevel.WARNING == "warning" assert QuotaWarningLevel.CRITICAL == "critical" @@ -123,302 +132,231 @@ class TestQuotaWarningLevel: class TestQuotaCheckResult: - """QuotaCheckResult 测试""" - def test_usage_percent_normal(self): - """正常使用百分比计算""" result = QuotaCheckResult( allowed=True, - dimension="storage", + dimension="storage_gb", limit=100, - used=30, - remaining=70, - warning_level=QuotaWarningLevel.NORMAL, + used=50, + remaining=50, + warning_level="normal", ) - assert result.usage_percent == 30.0 + assert result.usage_percent == 50.0 - def test_usage_percent_capped_at_100(self): - """超过 100% 时截断为 100%""" + def test_usage_percent_exceeded(self): result = QuotaCheckResult( - allowed=False, - dimension="storage", - limit=100, - used=150, - remaining=0, - warning_level=QuotaWarningLevel.EXCEEDED, + allowed=False, dimension="d", limit=100, used=150, remaining=0, warning_level="exceeded" ) - assert result.usage_percent == 100.0 + assert result.usage_percent == 100.0 # min(100, 150%) + + def test_usage_percent_zero_used(self): + result = QuotaCheckResult(allowed=True, dimension="d", limit=100, used=0, remaining=100, warning_level="normal") + assert result.usage_percent == 0.0 def test_usage_percent_zero_limit_with_usage(self): - """limit=0 但有使用量,返回 100%""" - result = QuotaCheckResult( - allowed=False, - dimension="storage", - limit=0, - used=5, - remaining=0, - warning_level=QuotaWarningLevel.EXCEEDED, - ) + result = QuotaCheckResult(allowed=False, dimension="d", limit=0, used=10, remaining=0, warning_level="exceeded") assert result.usage_percent == 100.0 def test_usage_percent_zero_limit_no_usage(self): - """limit=0 且无使用量,返回 0%""" - result = QuotaCheckResult( - allowed=True, - dimension="storage", - limit=0, - used=0, - remaining=0, - warning_level=QuotaWarningLevel.NORMAL, - ) + result = QuotaCheckResult(allowed=True, dimension="d", limit=0, used=0, remaining=0, warning_level="normal") assert result.usage_percent == 0.0 def test_usage_percent_unlimited(self): - """不限量时使用百分比为 0""" result = QuotaCheckResult( allowed=True, - dimension="templates", + dimension="d", limit=float("inf"), - used=50, + used=1000, remaining=float("inf"), - warning_level=QuotaWarningLevel.NORMAL, + warning_level="normal", ) assert result.usage_percent == 0.0 class TestQuotaRegistry: - """QuotaRegistry 测试""" - def test_initial_dimensions(self): - """初始化时内置维度已注册""" - registry = QuotaRegistry() - dims = registry.list_dimensions() - assert QuotaDimension.STORAGE_GB in dims - assert QuotaDimension.VIDEOS_PER_MONTH in dims + reg = QuotaRegistry() + dims = reg.list_dimensions() + assert "storage_gb" in dims + assert "videos_per_month" in dims + assert len(dims) == len(QuotaDimension) - def test_initial_tiers(self): - """初始化时三个套餐已注册""" - registry = QuotaRegistry() - tiers = registry.list_tiers() + def test_list_tiers(self): + reg = QuotaRegistry() + tiers = reg.list_tiers() assert "free" in tiers assert "basic" in tiers assert "premium" in tiers - - def test_register_new_dimension(self): - """注册新的配额维度""" - registry = QuotaRegistry() - registry.register_dimension("custom_dim", "自定义维度") - dims = registry.list_dimensions() - assert "custom_dim" in dims - assert dims["custom_dim"] == "自定义维度" - - def test_register_dimension_idempotent(self): - """重复注册是幂等的""" - registry = QuotaRegistry() - registry.register_dimension("custom", "描述1") - registry.register_dimension("custom", "描述2") - # 保留第一次注册的描述 - assert registry.list_dimensions()["custom"] == "描述1" - - def test_register_with_default_limits(self): - """注册时指定各套餐的默认限制""" - registry = QuotaRegistry() - registry.register_dimension( - "custom", - "自定义", - default_limits={"free": 1, "basic": 10, "premium": 100}, - ) - assert registry.get_limit("free", "custom") == 1 - assert registry.get_limit("basic", "custom") == 10 - assert registry.get_limit("premium", "custom") == 100 - - def test_register_without_default_limits_defaults_to_zero(self): - """不指定默认限制时各套餐该维度为 0""" - registry = QuotaRegistry() - registry.register_dimension("custom_no_limit", "自定义") - assert registry.get_limit("free", "custom_no_limit") == 0 - assert registry.get_limit("basic", "custom_no_limit") == 0 - - def test_register_default_limits_ignores_unknown_plan(self): - """默认限制中未知的套餐名被忽略""" - registry = QuotaRegistry() - registry.register_dimension( - "custom", - "自定义", - default_limits={"nonexistent": 999}, - ) - # 不报错,但也不会创建新套餐 - assert registry.get_tier("nonexistent") is None + assert len(tiers) == 3 def test_get_tier_existing(self): - """获取存在的套餐""" - registry = QuotaRegistry() - tier = registry.get_tier("free") + reg = QuotaRegistry() + tier = reg.get_tier("free") assert tier is not None assert tier.name == "free" - def test_get_tier_nonexistent(self): - """获取不存在的套餐返回 None""" - registry = QuotaRegistry() - assert registry.get_tier("enterprise") is None + def test_get_tier_unknown(self): + reg = QuotaRegistry() + assert reg.get_tier("unknown_plan") is None - def test_get_limit_existing(self): - """获取存在的套餐和维度的限制""" - registry = QuotaRegistry() - assert registry.get_limit("free", QuotaDimension.STORAGE_GB) == 2 + def test_get_limit_known(self): + reg = QuotaRegistry() + assert reg.get_limit("free", "storage_gb") == 2 + assert reg.get_limit("premium", "storage_gb") == 100 - def test_get_limit_nonexistent_plan(self): - """不存在的套餐返回 0""" - registry = QuotaRegistry() - assert registry.get_limit("unknown", QuotaDimension.STORAGE_GB) == 0 + def test_get_limit_unknown_plan(self): + reg = QuotaRegistry() + assert reg.get_limit("unknown", "storage_gb") == 0 - def test_list_dimensions_returns_copy(self): - """list_dimensions 返回副本,修改不影响内部""" - registry = QuotaRegistry() - dims = registry.list_dimensions() - dims["fake"] = "fake" - assert "fake" not in registry.list_dimensions() + def test_register_new_dimension(self): + reg = QuotaRegistry() + reg.register_dimension("new_feature", "新功能", default_limits={"free": 0, "basic": 1, "premium": 5}) + dims = reg.list_dimensions() + assert "new_feature" in dims + assert dims["new_feature"] == "新功能" + assert reg.get_limit("free", "new_feature") == 0 + assert reg.get_limit("basic", "new_feature") == 1 + assert reg.get_limit("premium", "new_feature") == 5 - def test_list_tiers_returns_all_three(self): - """列出所有套餐""" - registry = QuotaRegistry() - tiers = registry.list_tiers() - assert len(tiers) == 3 - assert set(tiers) == {"free", "basic", "premium"} + def test_register_dimension_idempotent(self): + reg = QuotaRegistry() + reg.register_dimension("storage_gb", "should not change", default_limits={"free": 999}) + # 已经存在的不覆盖 + assert reg.get_limit("free", "storage_gb") == 2 + + def test_register_without_defaults(self): + reg = QuotaRegistry() + reg.register_dimension("new_dim", "描述") + assert reg.get_limit("free", "new_dim") == 0 + assert reg.get_limit("basic", "new_dim") == 0 + assert reg.get_limit("premium", "new_dim") == 0 + + def test_register_partial_limits(self): + reg = QuotaRegistry() + reg.register_dimension("partial", "partial", default_limits={"premium": 42}) + assert reg.get_limit("free", "partial") == 0 # 未设置的保持 0 + assert reg.get_limit("premium", "partial") == 42 class TestQuotaChecker: - """QuotaChecker 测试""" - - def test_check_under_limit_allowed(self): - """使用量低于限制,允许""" + def test_check_within_limit(self): checker = QuotaChecker() - result = checker.check("free", QuotaDimension.STORAGE_GB, 1.0) + result = checker.check("free", "storage_gb", 1) assert result.allowed is True - assert result.remaining == 1.0 - assert result.warning_level == QuotaWarningLevel.NORMAL + assert result.limit == 2 + assert result.used == 1 + assert result.remaining == 1 + assert result.dimension == "storage_gb" - def test_check_at_limit_not_allowed(self): - """使用量等于限制,不允许(used < limit 判定)""" + def test_check_exceeded(self): checker = QuotaChecker() - result = checker.check("free", QuotaDimension.STORAGE_GB, 2.0) + result = checker.check("free", "storage_gb", 3) assert result.allowed is False assert result.remaining == 0 - assert result.warning_level == QuotaWarningLevel.EXCEEDED + assert result.warning_level == "exceeded" - def test_check_over_limit(self): - """使用量超过限制""" + def test_check_exact_limit_not_allowed(self): + # used < limit 才 allowed,等于不算 checker = QuotaChecker() - result = checker.check("free", QuotaDimension.STORAGE_GB, 3.0) + result = checker.check("free", "storage_gb", 2) assert result.allowed is False assert result.remaining == 0 - assert result.warning_level == QuotaWarningLevel.EXCEEDED - def test_check_warning_level_80_percent(self): - """80% 触发 WARNING""" + def test_check_unlimited(self): checker = QuotaChecker() - # 100GB 的 80% = 80GB - result = checker.check("premium", QuotaDimension.STORAGE_GB, 80.0) - assert result.warning_level == QuotaWarningLevel.WARNING + result = checker.check("premium", "max_templates", 999999) + assert result.allowed is True + assert result.remaining == float("inf") + assert result.warning_level == "normal" - def test_check_warning_level_95_percent(self): - """95% 触发 CRITICAL""" + def test_check_warning_level_normal(self): checker = QuotaChecker() - result = checker.check("premium", QuotaDimension.STORAGE_GB, 95.0) - assert result.warning_level == QuotaWarningLevel.CRITICAL + result = checker.check("free", "storage_gb", 1) # 50% + assert result.warning_level == "normal" + + def test_check_warning_level_warning(self): + checker = QuotaChecker() + # 80% <= used < 95% + result = checker.check("free", "max_templates", 2.5) # 2.5/3 = 83% + assert result.warning_level == "warning" + + def test_check_warning_level_critical(self): + checker = QuotaChecker() + # 95% <= used < 100% + result = checker.check("free", "max_templates", 2.9) # 2.9/3 = 97% + assert result.warning_level == "critical" def test_check_warning_level_exceeded(self): - """100% 及以上触发 EXCEEDED""" checker = QuotaChecker() - result = checker.check("premium", QuotaDimension.STORAGE_GB, 100.0) - assert result.warning_level == QuotaWarningLevel.EXCEEDED - - def test_check_unlimited_always_allowed(self): - """不限量的维度始终允许""" - checker = QuotaChecker() - result = checker.check("premium", QuotaDimension.MAX_TEMPLATES, 9999) - assert result.allowed is True - assert math.isinf(result.remaining) - assert result.warning_level == QuotaWarningLevel.NORMAL - - def test_check_unknown_plan_zero_limit(self): - """未知套餐限制为 0,used=0 时不允许(0 < 0 为 False)""" - checker = QuotaChecker() - result = checker.check("unknown", QuotaDimension.STORAGE_GB, 0) - assert result.limit == 0 - assert result.allowed is False + result = checker.check("free", "storage_gb", 5) # 250% + assert result.warning_level == "exceeded" def test_check_multiple(self): - """批量检查多个维度""" checker = QuotaChecker() results = checker.check_multiple( "free", - { - QuotaDimension.STORAGE_GB: 1.0, - QuotaDimension.VIDEOS_PER_MONTH: 3, - }, + {"storage_gb": 1, "max_templates": 2, "max_titles": 10}, ) - assert len(results) == 2 + assert len(results) == 3 + assert results[0].dimension == "storage_gb" + assert results[1].dimension == "max_templates" + assert results[2].dimension == "max_titles" assert all(r.allowed for r in results) - dims = {r.dimension for r in results} - assert QuotaDimension.STORAGE_GB in dims - assert QuotaDimension.VIDEOS_PER_MONTH in dims - def test_check_with_custom_registry(self): - """使用自定义注册表""" - registry = QuotaRegistry() - registry.register_dimension("custom", "自定义", default_limits={"free": 5}) - checker = QuotaChecker(registry) - result = checker.check("free", "custom", 3) - assert result.allowed is True - assert result.limit == 5 + def test_check_zero_limit(self): + checker = QuotaChecker() + result = checker.check("free", "ai_voice_enabled", 0) + # limit=0, used=0: used < limit 为 False → allowed=False + assert result.allowed is False + assert result.remaining == 0 + assert result.warning_level == "normal" - def test_compute_warning_level_zero_limit_no_usage(self): - """limit=0, used=0 → NORMAL""" - level = QuotaChecker._compute_warning_level(0, 0) - assert level == QuotaWarningLevel.NORMAL + def test_compute_warning_level_normal(self): + assert QuotaChecker._compute_warning_level(50, 100) == "normal" + assert QuotaChecker._compute_warning_level(79, 100) == "normal" + + def test_compute_warning_level_warning_boundary(self): + assert QuotaChecker._compute_warning_level(80, 100) == "warning" + assert QuotaChecker._compute_warning_level(94, 100) == "warning" + + def test_compute_warning_level_critical_boundary(self): + assert QuotaChecker._compute_warning_level(95, 100) == "critical" + assert QuotaChecker._compute_warning_level(99, 100) == "critical" + + def test_compute_warning_level_exceeded(self): + assert QuotaChecker._compute_warning_level(100, 100) == "exceeded" + assert QuotaChecker._compute_warning_level(150, 100) == "exceeded" + + def test_compute_warning_level_unlimited(self): + assert QuotaChecker._compute_warning_level(9999, float("inf")) == "normal" def test_compute_warning_level_zero_limit_with_usage(self): - """limit=0, used>0 → EXCEEDED""" - level = QuotaChecker._compute_warning_level(1, 0) - assert level == QuotaWarningLevel.EXCEEDED + assert QuotaChecker._compute_warning_level(1, 0) == "exceeded" + + def test_compute_warning_level_zero_limit_no_usage(self): + assert QuotaChecker._compute_warning_level(0, 0) == "normal" def test_compute_warning_level_negative_limit(self): - """limit<0 视同 0 处理""" - level = QuotaChecker._compute_warning_level(1, -1) - assert level == QuotaWarningLevel.EXCEEDED + # limit <= 0 且 used=0 → NORMAL + assert QuotaChecker._compute_warning_level(0, -1) == "normal" class TestGetWarningLevel: - """get_warning_level 便捷函数测试""" - - def test_normal(self): - assert get_warning_level(50, 100) == QuotaWarningLevel.NORMAL - - def test_warning(self): - assert get_warning_level(85, 100) == QuotaWarningLevel.WARNING - - def test_critical(self): - assert get_warning_level(97, 100) == QuotaWarningLevel.CRITICAL - - def test_exceeded(self): - assert get_warning_level(100, 100) == QuotaWarningLevel.EXCEEDED - - def test_unlimited(self): - assert get_warning_level(9999, float("inf")) == QuotaWarningLevel.NORMAL + def test_convenience_function(self): + assert get_warning_level(50, 100) == "normal" + assert get_warning_level(99, 100) == "critical" + assert get_warning_level(100, 100) == "exceeded" + assert get_warning_level(0, 0) == "normal" + assert get_warning_level(1, 0) == "exceeded" class TestGlobalSingletons: - """全局单例测试""" - def test_quota_registry_is_instance(self): assert isinstance(quota_registry, QuotaRegistry) def test_quota_checker_is_instance(self): assert isinstance(quota_checker, QuotaChecker) - def test_global_checker_uses_global_registry(self): - """全局 checker 使用全局 registry""" - # 验证能正常工作 - result = quota_checker.check("free", QuotaDimension.STORAGE_GB, 1.0) + def test_global_checker_works(self): + result = quota_checker.check("free", "storage_gb", 1) assert result.allowed is True From 9b9dc243edaebfc5fed2c3556210e623b135f9d7 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 22:04:19 +0800 Subject: [PATCH 05/45] test(wave98): 31 unit tests for preset_bgm domain module (#961) --- tests/unit/test_preset_bgm.py | 201 ++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100755 tests/unit/test_preset_bgm.py diff --git a/tests/unit/test_preset_bgm.py b/tests/unit/test_preset_bgm.py new file mode 100755 index 000000000..2120fcf7d --- /dev/null +++ b/tests/unit/test_preset_bgm.py @@ -0,0 +1,201 @@ +"""Preset BGM 预设背景音乐单元测试。""" + +import pytest + +from packages.domain.preset_bgm import ( + BGM_STYLES, + PRESET_BGM_LIBRARY, + PresetBGM, + get_preset_bgm, + list_preset_bgm_by_style, + search_preset_bgm, +) + + +class TestPresetBGMDataclass: + def test_creation_required_fields(self): + bgm = PresetBGM(id="test_001", name="Test BGM", style="upbeat", duration=120.0) + assert bgm.id == "test_001" + assert bgm.name == "Test BGM" + assert bgm.style == "upbeat" + assert bgm.duration == 120.0 + assert bgm.artist == "" + assert bgm.description == "" + assert bgm.tags == [] + assert bgm.audio_url == "" + + def test_creation_all_fields(self): + bgm = PresetBGM( + id="test_002", + name="Full BGM", + style="relax", + duration=180.5, + artist="Artist Name", + description="A test description", + tags=["tag1", "tag2"], + audio_url="https://cdn/test.mp3", + ) + assert bgm.artist == "Artist Name" + assert bgm.description == "A test description" + assert bgm.tags == ["tag1", "tag2"] + assert bgm.audio_url == "https://cdn/test.mp3" + + def test_frozen_immutable(self): + bgm = PresetBGM(id="t1", name="T", style="upbeat", duration=60.0) + with pytest.raises(Exception): # FrozenInstanceError + bgm.name = "new name" + + def test_equality(self): + bgm1 = PresetBGM(id="same", name="N", style="upbeat", duration=60.0) + bgm2 = PresetBGM(id="same", name="N", style="upbeat", duration=60.0) + assert bgm1 == bgm2 + + def test_inequality(self): + bgm1 = PresetBGM(id="a", name="A", style="upbeat", duration=60.0) + bgm2 = PresetBGM(id="b", name="B", style="upbeat", duration=60.0) + assert bgm1 != bgm2 + + def test_frozen_with_list_field_not_hashable(self): + # 包含 list 字段的 frozen dataclass 仍然不可哈希(list 不可哈希) + bgm = PresetBGM(id="h1", name="H", style="upbeat", duration=60.0, tags=["a"]) + with pytest.raises(TypeError, match="unhashable"): + hash(bgm) + + +class TestPresetBGMLibrary: + def test_library_not_empty(self): + assert len(PRESET_BGM_LIBRARY) > 0 + + def test_library_has_entries(self): + assert len(PRESET_BGM_LIBRARY) >= 10 + + def test_all_have_unique_ids(self): + ids = [bgm.id for bgm in PRESET_BGM_LIBRARY] + assert len(ids) == len(set(ids)) + + def test_all_have_valid_styles(self): + for bgm in PRESET_BGM_LIBRARY: + assert bgm.style in BGM_STYLES + + def test_all_have_positive_duration(self): + for bgm in PRESET_BGM_LIBRARY: + assert bgm.duration > 0 + + def test_all_have_non_empty_name(self): + for bgm in PRESET_BGM_LIBRARY: + assert bgm.name.strip() != "" + + +class TestBGMStyles: + def test_styles_dict_keys(self): + assert "upbeat" in BGM_STYLES + assert "relax" in BGM_STYLES + assert "tech" in BGM_STYLES + assert "commerce" in BGM_STYLES + assert "emotional" in BGM_STYLES + assert "cinematic" in BGM_STYLES + + def test_styles_have_chinese_names(self): + for key, value in BGM_STYLES.items(): + assert isinstance(value, str) + assert len(value) > 0 + + +class TestGetPresetBGM: + def test_get_existing(self): + bgm = get_preset_bgm("bgm_upbeat_001") + assert bgm is not None + assert bgm.id == "bgm_upbeat_001" + assert bgm.name == "阳光清晨" + assert bgm.style == "upbeat" + + def test_get_nonexistent(self): + assert get_preset_bgm("nonexistent_id") is None + + def test_get_empty_string(self): + assert get_preset_bgm("") is None + + def test_get_returns_same_object(self): + bgm1 = get_preset_bgm("bgm_relax_001") + bgm2 = get_preset_bgm("bgm_relax_001") + assert bgm1 is bgm2 # 同一实例(引用同一列表中的对象) + + +class TestListPresetBGMByStyle: + def test_list_upbeat(self): + results = list_preset_bgm_by_style("upbeat") + assert len(results) >= 3 + for bgm in results: + assert bgm.style == "upbeat" + + def test_list_relax(self): + results = list_preset_bgm_by_style("relax") + assert len(results) >= 3 + for bgm in results: + assert bgm.style == "relax" + + def test_list_tech(self): + results = list_preset_bgm_by_style("tech") + assert len(results) >= 2 + for bgm in results: + assert bgm.style == "tech" + + def test_list_commerce(self): + results = list_preset_bgm_by_style("commerce") + assert len(results) >= 2 + for bgm in results: + assert bgm.style == "commerce" + + def test_list_empty_style(self): + results = list_preset_bgm_by_style("nonexistent_style") + assert results == [] + + def test_list_preserves_order(self): + results = list_preset_bgm_by_style("upbeat") + ids = [b.id for b in results] + # 应该按照在列表中的出现顺序排列 + assert ids == sorted(ids, key=lambda x: PRESET_BGM_LIBRARY.index(get_preset_bgm(x))) + + +class TestSearchPresetBGM: + def test_search_by_name(self): + results = search_preset_bgm("阳光") + assert len(results) >= 1 + assert any("阳光" in b.name for b in results) + + def test_search_by_description(self): + results = search_preset_bgm("钢琴") + assert len(results) >= 1 + # 钢琴出现在名称或描述或标签中 + found = False + for b in results: + if "钢琴" in b.description or "钢琴" in b.name or "钢琴" in b.tags: + found = True + break + assert found + + def test_search_by_tag(self): + results = search_preset_bgm("科技") + assert len(results) >= 1 + found_tech = any(b.style == "tech" for b in results) + assert found_tech + + def test_search_case_insensitive(self): + results1 = search_preset_bgm("Tech") + results2 = search_preset_bgm("tech") + assert len(results1) == len(results2) + + def test_search_no_match(self): + results = search_preset_bgm("zzzzzzzzzzz_nonexistent_keyword") + assert results == [] + + def test_search_empty_keyword(self): + # 空字符串应该匹配所有(因为空字符串 in 任何字符串都是 True) + results = search_preset_bgm("") + assert len(results) == len(PRESET_BGM_LIBRARY) + + def test_search_no_duplicates(self): + # 确保同一个 BGM 不会出现多次 + results = search_preset_bgm("电子") + ids = [b.id for b in results] + assert len(ids) == len(set(ids)) From 5b0ba40ecd4087de5cb437e92e5ee1505b462f9b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 22:04:29 +0800 Subject: [PATCH 06/45] test(wave99): 35 unit tests for 8 small domain modules (#962) --- tests/unit/test_domain_small_modules.py | 391 ++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100755 tests/unit/test_domain_small_modules.py diff --git a/tests/unit/test_domain_small_modules.py b/tests/unit/test_domain_small_modules.py new file mode 100755 index 000000000..ab018f167 --- /dev/null +++ b/tests/unit/test_domain_small_modules.py @@ -0,0 +1,391 @@ +"""Domain 小模块合集单元测试。 + +覆盖零测试的小 domain 模块: +- EditingMode 枚举 +- Template / TemplateSegment +- TemplateClipConfig + ClipType + TransitionEffect +- EditTemplateVersion +- VoiceLibraryItem +- TitleLibraryItem +- Recipe / RecipeItem +""" + +from datetime import datetime, timezone + +import pytest + +from packages.domain.editing_mode import EditingMode +from packages.domain.recipe import RecipeItem +from packages.domain.template import TemplateSegment +from packages.domain.template_clip_config import ( + ClipType, + TemplateClipConfig, + TransitionEffect, +) +from packages.domain.template_version import EditTemplateVersion +from packages.domain.title_library import TitleLibraryItem +from packages.domain.voice_library import VoiceLibraryItem + + +class TestEditingMode: + def test_all_modes_exist(self): + assert EditingMode.ONE_TAKE.value == "one_take" + assert EditingMode.PIP.value == "pip" + assert EditingMode.VOICE_OVER.value == "voice_over" + assert EditingMode.VOICE_PIP.value == "voice_pip" + + def test_from_string(self): + assert EditingMode("one_take") == EditingMode.ONE_TAKE + assert EditingMode("voice_over") == EditingMode.VOICE_OVER + + def test_invalid_mode_raises(self): + with pytest.raises(ValueError): + EditingMode("invalid_mode") + + def test_is_str_enum(self): + # StrEnum 的值是字符串,可以直接比较 + assert EditingMode.ONE_TAKE == "one_take" + + +class TestTemplateSegment: + def test_create_minimal(self): + seg = TemplateSegment( + id="seg1", + template_id="tpl1", + segment_order=1, + duration_min=5.0, + duration_max=10.0, + ) + assert seg.id == "seg1" + assert seg.template_id == "tpl1" + assert seg.segment_order == 1 + assert seg.duration_min == 5.0 + assert seg.duration_max == 10.0 + assert seg.material_type is None + assert isinstance(seg.created_at, datetime) + + def test_create_with_material_type(self): + seg = TemplateSegment( + id="seg2", + template_id="tpl1", + segment_order=2, + duration_min=3.0, + duration_max=8.0, + material_type="人物", + ) + assert seg.material_type == "人物" + + +class TestClipType: + def test_basic_types_exist(self): + assert hasattr(ClipType, "MAIN") + assert hasattr(ClipType, "INTRO") + assert hasattr(ClipType, "OUTRO") + assert hasattr(ClipType, "TRANSITION") + + def test_values_are_strings(self): + for ct in ClipType: + assert isinstance(ct.value, str) + + +class TestTransitionEffect: + def test_effects_exist(self): + assert TransitionEffect.CUT.value == "cut" + assert TransitionEffect.FADE.value == "fade" + assert TransitionEffect.DISSOLVE.value == "dissolve" + # 至少有 5 种以上转场效果 + assert len(list(TransitionEffect)) >= 5 + + +class TestTemplateClipConfig: + def test_create_minimal(self): + config = TemplateClipConfig.create( + template_id="tpl1", + clip_type=ClipType.MAIN, + order=1, + min_duration=3.0, + max_duration=8.0, + ) + assert config.id is not None + assert config.template_id == "tpl1" + assert config.clip_type == ClipType.MAIN + assert config.order == 1 + assert config.min_duration == 3.0 + assert config.max_duration == 8.0 + + def test_create_with_string_type(self): + config = TemplateClipConfig.create( + template_id="tpl1", + clip_type="intro", + order=0, + min_duration=2.0, + max_duration=5.0, + ) + assert config.clip_type == ClipType.INTRO + + def test_has_duration_range_true(self): + config = TemplateClipConfig.create( + template_id="t1", + clip_type=ClipType.MAIN, + order=1, + min_duration=3.0, + max_duration=8.0, + ) + assert config.has_duration_range is True + + def test_has_duration_range_false_when_both_zero(self): + config = TemplateClipConfig.create( + template_id="t1", + clip_type=ClipType.MAIN, + order=1, + ) + assert config.has_duration_range is False + + def test_default_duration_midpoint(self): + config = TemplateClipConfig.create( + template_id="t1", + clip_type=ClipType.MAIN, + order=1, + min_duration=4.0, + max_duration=6.0, + ) + assert config.default_duration == pytest.approx(5.0) + + def test_default_duration_when_only_max(self): + config = TemplateClipConfig.create( + template_id="t1", + clip_type=ClipType.MAIN, + order=1, + max_duration=5.0, + ) + assert config.default_duration == 5.0 + + def test_create_negative_min_duration_raises(self): + with pytest.raises(ValueError, match="min_duration"): + TemplateClipConfig.create( + template_id="t1", + clip_type=ClipType.MAIN, + order=1, + min_duration=-1.0, + ) + + def test_create_min_greater_than_max_raises(self): + with pytest.raises(ValueError, match="min_duration.*max_duration"): + TemplateClipConfig.create( + template_id="t1", + clip_type=ClipType.MAIN, + order=1, + min_duration=10.0, + max_duration=5.0, + ) + + def test_create_empty_template_id_raises(self): + with pytest.raises(ValueError, match="template_id"): + TemplateClipConfig.create( + template_id="", + clip_type=ClipType.MAIN, + order=1, + ) + + def test_default_transition_is_cut(self): + config = TemplateClipConfig.create( + template_id="t1", + clip_type=ClipType.MAIN, + order=1, + ) + assert config.transition_effect == TransitionEffect.CUT + + def test_custom_transition_effect(self): + config = TemplateClipConfig.create( + template_id="t1", + clip_type=ClipType.MAIN, + order=1, + transition_effect="fade", + ) + assert config.transition_effect == TransitionEffect.FADE + + +class TestEditTemplateVersion: + def test_create_minimal(self): + version = EditTemplateVersion.create( + template_id="tpl1", + version=1, + ) + assert version.id is not None + assert len(version.id) == 32 + assert version.template_id == "tpl1" + assert version.version == 1 + assert version.config == {} + assert version.clip_configs == [] + assert version.published_by == "" + assert version.change_note == "" + assert version.name == "" + assert version.editing_mode == "one_take" + assert isinstance(version.created_at, datetime) + + def test_create_with_config_and_clip_configs(self): + version = EditTemplateVersion.create( + template_id="tpl1", + version=2, + config={"layout": "one_take"}, + clip_configs=[{"clip_id": "c1", "type": "main"}], + published_by="user1", + change_note="添加了片头效果", + ) + assert version.config == {"layout": "one_take"} + assert len(version.clip_configs) == 1 + assert version.published_by == "user1" + assert version.change_note == "添加了片头效果" + + def test_create_with_name_and_mode(self): + version = EditTemplateVersion.create( + template_id="t1", + version=1, + name="v1.0 正式版", + editing_mode="voice_over", + ) + assert version.name == "v1.0 正式版" + assert version.editing_mode == "voice_over" + + def test_create_unique_ids(self): + v1 = EditTemplateVersion.create("t1", 1) + v2 = EditTemplateVersion.create("t1", 2) + assert v1.id != v2.id + + def test_none_config_defaults_to_empty_dict(self): + version = EditTemplateVersion.create("t1", 1, config=None) + assert version.config == {} + + def test_none_clip_configs_defaults_to_empty_list(self): + version = EditTemplateVersion.create("t1", 1, clip_configs=None) + assert version.clip_configs == [] + + +class TestVoiceLibraryItem: + def test_create_minimal(self): + item = VoiceLibraryItem( + id="v1", + user_id="u1", + name="我的配音", + ) + assert item.id == "v1" + assert item.user_id == "u1" + assert item.name == "我的配音" + assert item.text == "" + assert item.voice_provider == "" + assert item.duration == 0 + assert item.status == "completed" + assert item.tags == [] + assert item.project_id is None + assert isinstance(item.created_at, datetime) + + def test_create_with_all_fields(self): + item = VoiceLibraryItem( + id="v2", + user_id="u1", + name="产品介绍", + text="欢迎来到我们的产品", + voice_provider="cosyvoice", + voice_id="voice_001", + voice_name="温柔女声", + audio_url="https://cdn/v2.mp3", + duration=30.5, + file_size=102400, + status="processing", + project_id="proj1", + tags=["产品", "介绍"], + ) + assert item.text == "欢迎来到我们的产品" + assert item.voice_provider == "cosyvoice" + assert item.voice_id == "voice_001" + assert item.audio_url == "https://cdn/v2.mp3" + assert item.duration == 30.5 + assert item.file_size == 102400 + assert item.status == "processing" + assert item.project_id == "proj1" + assert item.tags == ["产品", "介绍"] + + +class TestTitleLibraryItem: + def test_create_minimal(self): + item = TitleLibraryItem( + id="t1", + user_id="u1", + name="爆款标题1", + text="这是一个爆款标题", + ) + assert item.id == "t1" + assert item.user_id == "u1" + assert item.name == "爆款标题1" + assert item.text == "这是一个爆款标题" + assert item.category == "default" + assert item.description == "" + assert item.tags == [] + assert item.usage_count == 0 + assert item.is_active is True + + def test_create_with_category(self): + item = TitleLibraryItem( + id="t2", + user_id="u1", + name="美食标题", + text="太好吃了!", + category="美食", + ) + assert item.category == "美食" + + def test_inactive_item(self): + item = TitleLibraryItem( + id="t3", + user_id="u1", + name="旧标题", + text="旧文案", + is_active=False, + ) + assert item.is_active is False + + def test_usage_count_increment(self): + item = TitleLibraryItem( + id="t4", + user_id="u1", + name="T", + text="T", + ) + item.usage_count += 1 + assert item.usage_count == 1 + + +class TestRecipeItem: + def test_create_minimal(self): + item = RecipeItem( + id="ri1", + recipe_id="r1", + item_type="asset", + item_id="asset_001", + ) + assert item.id == "ri1" + assert item.recipe_id == "r1" + assert item.item_type == "asset" + assert item.item_id == "asset_001" + assert item.position == 0 + assert item.metadata_ == {} + + def test_create_with_position_and_metadata(self): + item = RecipeItem( + id="ri2", + recipe_id="r1", + item_type="title", + item_id="title_001", + position=2, + metadata_={"style": "bold"}, + ) + assert item.position == 2 + assert item.metadata_ == {"style": "bold"} + + def test_item_types_variety(self): + asset_item = RecipeItem(id="a", recipe_id="r", item_type="asset", item_id="i1") + title_item = RecipeItem(id="t", recipe_id="r", item_type="title", item_id="i2") + voice_item = RecipeItem(id="v", recipe_id="r", item_type="voice", item_id="i3") + assert asset_item.item_type == "asset" + assert title_item.item_type == "title" + assert voice_item.item_type == "voice" From 02d60a02f2d1ea811f6b657e6a39d20dc4f0823d Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 22:04:30 +0800 Subject: [PATCH 07/45] test(wave100): 40 unit tests for 3 application layer modules (#964) --- tests/unit/test_application_modules.py | 499 +++++++++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100755 tests/unit/test_application_modules.py diff --git a/tests/unit/test_application_modules.py b/tests/unit/test_application_modules.py new file mode 100755 index 000000000..09711b97a --- /dev/null +++ b/tests/unit/test_application_modules.py @@ -0,0 +1,499 @@ +"""Application 层零测试模块合集 — 第100波里程碑。 + +覆盖: +- packages/application/generated_videos.py (8个UseCase) +- packages/application/assets.py (ListAssets + CreateAsset) +- packages/application/asset_libraries.py (ListLibraries + CreateLibrary) + +策略: Mock repository,测参数校验 + 委托行为 +""" + +from unittest.mock import MagicMock + +import pytest + +from packages.application.asset_libraries import ( + CreateAssetLibraryCommand, + CreateAssetLibraryUseCase, + ListAssetLibrariesUseCase, +) +from packages.application.assets import ( + CreateAssetCommand, + CreateAssetUseCase, + ListAssetsUseCase, +) +from packages.application.generated_videos import ( + GetGeneratedVideoDownloadUrlUseCase, + GetGeneratedVideoUseCase, + GetVideosByIdsUseCase, + ListGeneratedVideosByTaskUseCase, + ListGeneratedVideosPaginatedUseCase, + ListGeneratedVideosUseCase, + UpdateVideoReviewStatusUseCase, +) +from packages.domain import AssetLibraryKind, AssetStatus, ClassificationStatus, GeneratedVideo + +# ── generated_videos.py ──────────────────────────────────────────────────────── + + +class TestListGeneratedVideosUseCase: + def test_success(self): + mock_repo = MagicMock() + mock_repo.list_by_project.return_value = [MagicMock(spec=GeneratedVideo)] + use_case = ListGeneratedVideosUseCase(mock_repo) + + result = use_case.execute("proj1") + + assert len(result) == 1 + mock_repo.list_by_project.assert_called_once_with("proj1") + + def test_strips_project_id(self): + mock_repo = MagicMock() + use_case = ListGeneratedVideosUseCase(mock_repo) + + use_case.execute(" proj1 ") + + mock_repo.list_by_project.assert_called_once_with("proj1") + + def test_empty_project_id_raises(self): + mock_repo = MagicMock() + use_case = ListGeneratedVideosUseCase(mock_repo) + + with pytest.raises(ValueError, match="project_id 不能为空"): + use_case.execute("") + + def test_whitespace_project_id_raises(self): + mock_repo = MagicMock() + use_case = ListGeneratedVideosUseCase(mock_repo) + + with pytest.raises(ValueError, match="project_id 不能为空"): + use_case.execute(" \t ") + + +class TestListGeneratedVideosPaginatedUseCase: + def test_default_params(self): + mock_repo = MagicMock() + mock_repo.list_paginated.return_value = ([], 0) + use_case = ListGeneratedVideosPaginatedUseCase(mock_repo) + + result, total = use_case.execute() + + assert total == 0 + assert result == [] + mock_repo.list_paginated.assert_called_once_with( + user_id=None, + project_id=None, + status=None, + review_status=None, + page=1, + page_size=20, + ) + + def test_page_below_1_clamps_to_1(self): + mock_repo = MagicMock() + mock_repo.list_paginated.return_value = ([], 0) + use_case = ListGeneratedVideosPaginatedUseCase(mock_repo) + + use_case.execute(page=0) + + mock_repo.list_paginated.assert_called_once() + call_kwargs = mock_repo.list_paginated.call_args.kwargs + assert call_kwargs["page"] == 1 + + def test_negative_page_clamps(self): + mock_repo = MagicMock() + mock_repo.list_paginated.return_value = ([], 0) + use_case = ListGeneratedVideosPaginatedUseCase(mock_repo) + + use_case.execute(page=-5) + + assert mock_repo.list_paginated.call_args.kwargs["page"] == 1 + + def test_page_size_zero_clamps(self): + mock_repo = MagicMock() + mock_repo.list_paginated.return_value = ([], 0) + use_case = ListGeneratedVideosPaginatedUseCase(mock_repo) + + use_case.execute(page_size=0) + + assert mock_repo.list_paginated.call_args.kwargs["page_size"] == 20 + + def test_page_size_over_100_clamps(self): + mock_repo = MagicMock() + mock_repo.list_paginated.return_value = ([], 0) + use_case = ListGeneratedVideosPaginatedUseCase(mock_repo) + + use_case.execute(page_size=200) + + assert mock_repo.list_paginated.call_args.kwargs["page_size"] == 20 + + def test_page_size_50_ok(self): + mock_repo = MagicMock() + mock_repo.list_paginated.return_value = ([], 0) + use_case = ListGeneratedVideosPaginatedUseCase(mock_repo) + + use_case.execute(page_size=50) + + assert mock_repo.list_paginated.call_args.kwargs["page_size"] == 50 + + def test_with_all_filters(self): + mock_repo = MagicMock() + mock_repo.list_paginated.return_value = ([], 0) + use_case = ListGeneratedVideosPaginatedUseCase(mock_repo) + + use_case.execute( + user_id="u1", + project_id="p1", + status="completed", + review_status="approved", + page=2, + page_size=10, + ) + + mock_repo.list_paginated.assert_called_once_with( + user_id="u1", + project_id="p1", + status="completed", + review_status="approved", + page=2, + page_size=10, + ) + + +class TestGetGeneratedVideoUseCase: + def test_found(self): + mock_repo = MagicMock() + expected = MagicMock(spec=GeneratedVideo) + mock_repo.get.return_value = expected + use_case = GetGeneratedVideoUseCase(mock_repo) + + result = use_case.execute("vid1") + + assert result == expected + mock_repo.get.assert_called_once_with("vid1") + + def test_not_found(self): + mock_repo = MagicMock() + mock_repo.get.return_value = None + use_case = GetGeneratedVideoUseCase(mock_repo) + + result = use_case.execute("vid1") + + assert result is None + + +class TestListGeneratedVideosByTaskUseCase: + def test_success(self): + mock_repo = MagicMock() + mock_repo.list_by_generation_task.return_value = [MagicMock()] + use_case = ListGeneratedVideosByTaskUseCase(mock_repo) + + result = use_case.execute("task1") + + assert len(result) == 1 + mock_repo.list_by_generation_task.assert_called_once_with("task1") + + def test_strips_task_id(self): + mock_repo = MagicMock() + use_case = ListGeneratedVideosByTaskUseCase(mock_repo) + + use_case.execute(" task1 ") + + mock_repo.list_by_generation_task.assert_called_once_with("task1") + + def test_empty_task_id_raises(self): + mock_repo = MagicMock() + use_case = ListGeneratedVideosByTaskUseCase(mock_repo) + + with pytest.raises(ValueError, match="generation_task_id 不能为空"): + use_case.execute("") + + +class TestGetGeneratedVideoDownloadUrlUseCase: + def test_found(self): + mock_repo = MagicMock() + mock_item = MagicMock() + mock_item.file_url = "https://cdn/v.mp4" + mock_repo.get.return_value = mock_item + use_case = GetGeneratedVideoDownloadUrlUseCase(mock_repo) + + result = use_case.execute("vid1") + + assert result == "https://cdn/v.mp4" + + def test_not_found_returns_none(self): + mock_repo = MagicMock() + mock_repo.get.return_value = None + use_case = GetGeneratedVideoDownloadUrlUseCase(mock_repo) + + result = use_case.execute("vid1") + + assert result is None + + +class TestUpdateVideoReviewStatusUseCase: + def test_pending_review(self): + mock_repo = MagicMock() + mock_repo.update_review_status.return_value = MagicMock() + use_case = UpdateVideoReviewStatusUseCase(mock_repo) + + use_case.execute("vid1", "pending_review") + + mock_repo.update_review_status.assert_called_once_with("vid1", "pending_review") + + def test_approved(self): + mock_repo = MagicMock() + use_case = UpdateVideoReviewStatusUseCase(mock_repo) + + use_case.execute("vid1", "approved") + + mock_repo.update_review_status.assert_called_once_with("vid1", "approved") + + def test_rejected(self): + mock_repo = MagicMock() + use_case = UpdateVideoReviewStatusUseCase(mock_repo) + + use_case.execute("vid1", "rejected") + + mock_repo.update_review_status.assert_called_once_with("vid1", "rejected") + + def test_strips_video_id(self): + mock_repo = MagicMock() + use_case = UpdateVideoReviewStatusUseCase(mock_repo) + + use_case.execute(" vid1 ", "approved") + + mock_repo.update_review_status.assert_called_once_with("vid1", "approved") + + def test_empty_video_id_raises(self): + mock_repo = MagicMock() + use_case = UpdateVideoReviewStatusUseCase(mock_repo) + + with pytest.raises(ValueError, match="video_id 不能为空"): + use_case.execute("", "approved") + + def test_invalid_status_raises(self): + mock_repo = MagicMock() + use_case = UpdateVideoReviewStatusUseCase(mock_repo) + + with pytest.raises(ValueError, match="无效的 review_status"): + use_case.execute("vid1", "invalid_status") + + def test_not_found_returns_none(self): + mock_repo = MagicMock() + mock_repo.update_review_status.return_value = None + use_case = UpdateVideoReviewStatusUseCase(mock_repo) + + result = use_case.execute("vid1", "approved") + + assert result is None + + +class TestGetVideosByIdsUseCase: + def test_success(self): + mock_repo = MagicMock() + mock_repo.get_by_ids.return_value = [MagicMock(), MagicMock()] + use_case = GetVideosByIdsUseCase(mock_repo) + + result = use_case.execute(["id1", "id2", "id3"]) + + assert len(result) == 2 + mock_repo.get_by_ids.assert_called_once_with(["id1", "id2", "id3"]) + + def test_empty_list(self): + mock_repo = MagicMock() + mock_repo.get_by_ids.return_value = [] + use_case = GetVideosByIdsUseCase(mock_repo) + + result = use_case.execute([]) + + assert result == [] + mock_repo.get_by_ids.assert_called_once_with([]) + + +# ── assets.py ────────────────────────────────────────────────────────────────── + + +class TestCreateAssetCommand: + def test_minimal(self): + cmd = CreateAssetCommand( + project_id="p1", + library_id="l1", + name="test.mp4", + storage_key="k", + mime_type="video/mp4", + ) + assert cmd.project_id == "p1" + assert cmd.library_id == "l1" + assert cmd.name == "test.mp4" + assert cmd.storage_key == "k" + assert cmd.mime_type == "video/mp4" + assert cmd.file_size == 0 + assert cmd.status == AssetStatus.UPLOADING + assert cmd.classification_status == ClassificationStatus.PENDING + + def test_full(self): + cmd = CreateAssetCommand( + project_id="p1", + library_id="l1", + name="test.mp4", + storage_key="k", + mime_type="video/mp4", + metadata={"k": "v"}, + file_size=1024, + duration=10.0, + width=1920, + height=1080, + fps=30.0, + codec="h264", + status=AssetStatus.READY, + quality_score=0.9, + uploaded_by_user_id="u1", + ) + assert cmd.file_size == 1024 + assert cmd.duration == 10.0 + assert cmd.status == AssetStatus.READY + assert cmd.quality_score == 0.9 + + +class TestListAssetsUseCase: + def test_success(self): + mock_repo = MagicMock() + mock_repo.find_by_library.return_value = [] + use_case = ListAssetsUseCase(mock_repo) + + result = use_case.execute("lib1") + + assert result == [] + mock_repo.find_by_library.assert_called_once_with("lib1") + + def test_strips_library_id(self): + mock_repo = MagicMock() + use_case = ListAssetsUseCase(mock_repo) + + use_case.execute(" lib1 ") + + mock_repo.find_by_library.assert_called_once_with("lib1") + + def test_empty_library_id_raises(self): + mock_repo = MagicMock() + use_case = ListAssetsUseCase(mock_repo) + + with pytest.raises(ValueError, match="library_id 不能为空"): + use_case.execute("") + + +class TestCreateAssetUseCase: + def test_creates_asset_via_repo(self): + mock_repo = MagicMock() + mock_repo.create.return_value = MagicMock() + use_case = CreateAssetUseCase(mock_repo) + + cmd = CreateAssetCommand( + project_id="p1", + library_id="l1", + name="test.mp4", + storage_key="videos/t.mp4", + mime_type="video/mp4", + file_size=1024, + ) + result = use_case.execute(cmd) + + assert result is not None + mock_repo.create.assert_called_once() + created_asset = mock_repo.create.call_args[0][0] + assert created_asset.project_id == "p1" + assert created_asset.name == "test.mp4" + assert created_asset.file_size == 1024 + assert created_asset.status == AssetStatus.UPLOADING + + def test_asset_create_validation_propagates(self): + mock_repo = MagicMock() + use_case = CreateAssetUseCase(mock_repo) + + cmd = CreateAssetCommand( + project_id="p1", + library_id="l1", + name="", + storage_key="k", + mime_type="video/mp4", + ) + + with pytest.raises(ValueError, match="素材名称不能为空"): + use_case.execute(cmd) + + +# ── asset_libraries.py ──────────────────────────────────────────────────────── + + +class TestCreateAssetLibraryCommand: + def test_creation(self): + cmd = CreateAssetLibraryCommand( + project_id="p1", + name="我的库", + kind=AssetLibraryKind.VIDEO, + ) + assert cmd.project_id == "p1" + assert cmd.name == "我的库" + assert cmd.kind == AssetLibraryKind.VIDEO + + +class TestListAssetLibrariesUseCase: + def test_success(self): + mock_repo = MagicMock() + mock_repo.find_by_project.return_value = [] + use_case = ListAssetLibrariesUseCase(mock_repo) + + result = use_case.execute("p1") + + assert result == [] + mock_repo.find_by_project.assert_called_once_with("p1") + + def test_strips_project_id(self): + mock_repo = MagicMock() + use_case = ListAssetLibrariesUseCase(mock_repo) + + use_case.execute(" p1 ") + + mock_repo.find_by_project.assert_called_once_with("p1") + + def test_empty_project_id_raises(self): + mock_repo = MagicMock() + use_case = ListAssetLibrariesUseCase(mock_repo) + + with pytest.raises(ValueError, match="project_id 不能为空"): + use_case.execute("") + + +class TestCreateAssetLibraryUseCase: + def test_creates_library_via_repo(self): + mock_repo = MagicMock() + mock_repo.create.return_value = MagicMock() + use_case = CreateAssetLibraryUseCase(mock_repo) + + cmd = CreateAssetLibraryCommand( + project_id="p1", + name="视频库", + kind=AssetLibraryKind.VIDEO, + ) + result = use_case.execute(cmd) + + assert result is not None + mock_repo.create.assert_called_once() + created = mock_repo.create.call_args[0][0] + assert created.project_id == "p1" + assert created.name == "视频库" + assert created.kind == AssetLibraryKind.VIDEO + + def test_validation_propagates(self): + mock_repo = MagicMock() + use_case = CreateAssetLibraryUseCase(mock_repo) + + cmd = CreateAssetLibraryCommand( + project_id="p1", + name="", + kind=AssetLibraryKind.VIDEO, + ) + + with pytest.raises(ValueError, match="素材库名称不能为空"): + use_case.execute(cmd) From 81eff29e7b3153db2248961dc0a53e8178addd13 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 22:04:30 +0800 Subject: [PATCH 08/45] =?UTF-8?q?test(wave101):=20=E6=8A=BD=E5=8F=96video?= =?UTF-8?q?=5Ffilter=5Fbuilder=E7=BA=AF=E9=80=BB=E8=BE=91=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=20+=2073=E4=B8=AA=E5=8D=95=E6=B5=8B=20(#972)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/app/services/video_compose_service.py | 275 +----- packages/domain/video_filter_builder.py | 375 ++++++++ tests/unit/test_video_filter_builder.py | 855 ++++++++++++++++++ 3 files changed, 1260 insertions(+), 245 deletions(-) create mode 100755 packages/domain/video_filter_builder.py create mode 100755 tests/unit/test_video_filter_builder.py diff --git a/apps/api/app/services/video_compose_service.py b/apps/api/app/services/video_compose_service.py index 9809663ed..b196f9901 100755 --- a/apps/api/app/services/video_compose_service.py +++ b/apps/api/app/services/video_compose_service.py @@ -29,47 +29,33 @@ from packages.adapters.sqlalchemy_impl.edit_plan_repository import ( ) from packages.domain.edit_plan import EditPlanStatus from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus -from packages.domain.template_clip_config import TransitionEffect +from packages.domain.video_filter_builder import ( + DEFAULT_FPS, + DEFAULT_OUTPUT_HEIGHT, + DEFAULT_OUTPUT_WIDTH, + DEFAULT_TRANSITION_DURATION, + ClipFilterChain, + build_clip_filter, + build_concat_filter as _build_concat_filter_func, + build_filter_complex as _build_filter_complex, + build_xfade_filter as _build_xfade_filter_func, + chain_filters as _chain_filters_func, + has_audio as _has_audio_func, +) logger = logging.getLogger(__name__) -# ── 常量 ────────────────────────────────────────────────────────────────────── +# ── 常量(向后兼容别名) ────────────────────────────────────────────────────── +# 实际定义已迁移至 packages/domain/video_filter_builder.py -DEFAULT_OUTPUT_WIDTH = 1280 -DEFAULT_OUTPUT_HEIGHT = 720 -DEFAULT_FPS = 25 DEFAULT_CODEC = "libx264" DEFAULT_CRF = 23 DEFAULT_PRESET = "medium" -# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称 -_XFADE_TRANSITION_MAP: dict[str, str] = { - TransitionEffect.FADE: "fade", - TransitionEffect.SLIDE_LEFT: "slideleft", - TransitionEffect.SLIDE_RIGHT: "slideright", - TransitionEffect.DISSOLVE: "dissolve", - TransitionEffect.WIPE: "wipeleft", -} - -# 转场默认时长(秒) -DEFAULT_TRANSITION_DURATION = 0.5 - # ── 数据结构 ────────────────────────────────────────────────────────────────── -@dataclass(frozen=True) -class ClipFilterChain: - """单个片段的滤镜链描述。""" - - clip_id: str - input_index: int - video_label: str - audio_label: str | None - filters: list[str] - duration: float - - @dataclass(frozen=True) class ComposeCommand: """完整的 FFmpeg 合成命令描述。""" @@ -401,62 +387,8 @@ class VideoComposeService: output_height: int, fps: int, ) -> ClipFilterChain: - """为单个片段构建滤镜链。 - - 滤镜顺序: - 1. scale — 等比缩放到目标分辨率(保证覆盖) - 2. crop — 居中裁剪到目标分辨率 - 3. fps — 统一输出帧率(concat 要求所有输入帧率一致) - 4. setpts — 重置时间戳 + 偏移 - 5. trim — 视频时长裁剪 - 6. atrim — 音频时长裁剪(如有音频流) - """ - duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒 - start = clip.start_time - - filters: list[str] = [] - - # 1. scale: 等比缩放(保持比例,不裁剪) - filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease") - - # 2. pad: 居中+留黑边到目标分辨率(保持原始比例,不裁剪内容) - filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black") - - # 3. format: 统一像素格式为 yuv420p(H.264 标准格式,concat 要求所有输入像素格式一致) - # 不同素材可能是 yuv420p / yuv422p / yuv444p / nv12 等,必须统一 - filters.append("format=yuv420p") - - # 4. fps: 统一帧率(concat 要求所有输入帧率一致) - # 放在 pad 之后、setpts 之前,确保分辨率和帧率都已统一 - if fps and fps > 0: - filters.append(f"fps={fps}") - - # 3. setpts: 重置时间戳 - if start > 0: - filters.append(f"setpts=PTS-STARTPTS+{start}/TB") - else: - filters.append("setpts=PTS-STARTPTS") - - # 4. trim: 视频时长 - filters.append(f"trim=0:{duration}") - filters.append("setpts=PTS-STARTPTS") # trim 后需要重置 PTS - - video_label = f"v{input_index}" - - # 5. 音频标签:仅当片段类型可能有音频时才设置 - # title/subtitle 是纯文字/图片卡片,没有音频流 - clip_type = clip.clip_type.lower() if clip.clip_type else "" - has_audio_stream = clip_type not in ("title", "subtitle") - audio_label = f"a{input_index}" if has_audio_stream else None - - return ClipFilterChain( - clip_id=clip.id, - input_index=input_index, - video_label=video_label, - audio_label=audio_label, - filters=filters, - duration=duration, - ) + """向后兼容:委托给 video_filter_builder.build_clip_filter。""" + return build_clip_filter(clip, input_index, output_width, output_height, fps) @staticmethod def _build_filter_complex( @@ -466,102 +398,30 @@ class VideoComposeService: transition_duration: float, transitions: list[str], ) -> tuple[str, float]: - """构建完整的 filter_complex 字符串。 - - 策略: - - 单片段:直接输出 - - 多片段 + 全 cut:使用 concat 滤镜(高效) - - 多片段 + 有转场:使用 xfade 滤镜链 - - 返回 (filter_complex_string, estimated_total_duration)。 - """ - n = len(clip_chains) - - if n == 0: - return "", 0.0 - - # ── 单片段 ───────────────────────────────────────────────────── - if n == 1: - chain = clip_chains[0] - filter_str = _chain_filters(chain.filters, chain.video_label) - # 音频 - if chain.audio_label: - filter_str += f";[0:a]{chain.audio_label}" - total_duration = chain.duration - return filter_str, total_duration - - # ── 检查是否有转场 ───────────────────────────────────────────── - has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions) - - if not has_transitions: - return _build_concat_filter(clip_chains) - - # ── 有转场:使用 xfade ───────────────────────────────────────── - return _build_xfade_filter( - clip_chains=clip_chains, - transition_duration=transition_duration, - transitions=transitions, - ) + """向后兼容:委托给 video_filter_builder.build_filter_complex。""" + return _build_filter_complex(clip_chains, output_width, output_height, transition_duration, transitions) @staticmethod def _has_audio(clip_chains: list[ClipFilterChain]) -> bool: - """是否有任何片段包含音频流。""" - return any(c.audio_label is not None for c in clip_chains) + """向后兼容:委托给 video_filter_builder.has_audio。""" + return _has_audio_func(clip_chains) -# ── 模块级辅助函数 ──────────────────────────────────────────────────────────── +# ── 模块级辅助函数(向后兼容别名) ────────────────────────────────────────── +# 实际实现已迁移至 packages/domain/video_filter_builder.py +# 保留此处别名以兼容现有测试与调用方 def _chain_filters(filters: list[str], output_label: str) -> str: - """将滤镜列表串联为 FFmpeg 滤镜字符串。""" - filter_body = ",".join(filters) - return f"[0:v]{filter_body}[{output_label}]" + """向后兼容:委托给 video_filter_builder.chain_filters。""" + return _chain_filters_func(filters, output_label) def _build_concat_filter( clip_chains: list[ClipFilterChain], ) -> tuple[str, float]: - """构建 concat 滤镜(无转场,高效拼接)。 - - 格式: - [0:v]filters[v0]; [1:v]filters[v1]; ... - [v0][v1]...[vN]concat=n=N:v=1:a=0[outv] - """ - n = len(clip_chains) - parts: list[str] = [] - total_duration = 0.0 - - # 每个片段的滤镜链 - for idx, chain in enumerate(clip_chains): - filter_body = ",".join(chain.filters) - parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]") - total_duration += chain.duration - - # concat 滤镜 - concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains) - concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]" - parts.append(concat_filter) - - # 音频 concat(如果有)— 先统一音频格式再拼接,否则不同采样率/声道会导致concat失败 - audio_parts: list[str] = [] - for idx, chain in enumerate(clip_chains): - if chain.audio_label: - # aformat: 统一采样率48000Hz + 双声道stereo + fltp采样格式(AAC标准格式) - audio_filters = [ - "aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp", - f"atrim=0:{chain.duration}", - "asetpts=PTS-STARTPTS", - ] - audio_parts.append(f"[{idx}:a]{','.join(audio_filters)}[{chain.audio_label}]") - - if audio_parts: - parts.extend(audio_parts) - audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label) - audio_count = sum(1 for c in clip_chains if c.audio_label) - if audio_count > 0: - parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]") - - return ";".join(parts), total_duration + """向后兼容:委托给 video_filter_builder.build_concat_filter。""" + return _build_concat_filter_func(clip_chains) def _build_xfade_filter( @@ -569,80 +429,5 @@ def _build_xfade_filter( transition_duration: float, transitions: list[str], ) -> tuple[str, float]: - """构建 xfade 转场滤镜链。 - - 每两个相邻片段之间插入 xfade 转场。 - offset = 前一个片段的累积时长 - 转场时长。 - - 格式(2 片段): - [0:v]filters[v0]; [1:v]filters[v1]; - [v0][v1]xfade=transition=fade:duration=0.5:offset=4.5[outv] - - 格式(3+ 片段): - [v0][v1]xfade=...[tmp1]; [tmp1][v2]xfade=...[outv] - """ - n = len(clip_chains) - parts: list[str] = [] - total_duration = 0.0 - - # 每个片段的滤镜链 - for idx, chain in enumerate(clip_chains): - filter_body = ",".join(chain.filters) - parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]") - total_duration += chain.duration - - # xfade 链 - if n == 1: - # 单片段不需要 xfade - parts.append(f"[{clip_chains[0].video_label}]copy[outv]") - return ";".join(parts), total_duration - - # 计算每个转场的 offset - cumulative = 0.0 - prev_label = clip_chains[0].video_label - - for i in range(1, n): - cumulative += clip_chains[i - 1].duration - offset = max(0.0, cumulative - transition_duration * i) - - # 获取转场类型 - transition = transitions[i] if i < len(transitions) else "cut" - xfade_transition = _XFADE_TRANSITION_MAP.get(transition, "fade") - - if i == n - 1: - # 最后一个转场,输出到 [outv] - out_label = "outv" - else: - out_label = f"xf{i}" - - parts.append( - f"[{prev_label}][{clip_chains[i].video_label}]" - f"xfade=transition={xfade_transition}" - f":duration={transition_duration}" - f":offset={offset:.3f}" - f"[{out_label}]" - ) - prev_label = out_label - - # 总时长需要减去转场重叠部分 - total_duration -= transition_duration * (n - 1) - - # 音频:先 aformat 归一化再 concat(不同采样率/声道/采样格式会导致concat失败) - audio_chains_with_label = [(c, c.audio_label) for c in clip_chains if c.audio_label] - if len(audio_chains_with_label) >= 2: - normalized_audio_labels: list[str] = [] - for chain, _ in audio_chains_with_label: - norm_label = f"anorm_{chain.video_label}" - audio_filters = [ - "aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp", - f"atrim=0:{chain.duration}", - "asetpts=PTS-STARTPTS", - ] - parts.append(f"[{chain.audio_label}]{','.join(audio_filters)}[{norm_label}]") - normalized_audio_labels.append(norm_label) - audio_inputs = "".join(f"[{label}]" for label in normalized_audio_labels) - parts.append(f"{audio_inputs}concat=n={len(normalized_audio_labels)}:v=0:a=1[outa]") - elif len(audio_chains_with_label) == 1: - parts.append(f"[{audio_chains_with_label[0][0].audio_label}]acopy[outa]") - - return ";".join(parts), max(0.0, total_duration) + """向后兼容:委托给 video_filter_builder.build_xfade_filter。""" + return _build_xfade_filter_func(clip_chains, transition_duration, transitions) diff --git a/packages/domain/video_filter_builder.py b/packages/domain/video_filter_builder.py new file mode 100755 index 000000000..7cbfc36b6 --- /dev/null +++ b/packages/domain/video_filter_builder.py @@ -0,0 +1,375 @@ +"""视频滤镜构建器 — FFmpeg filter_complex 纯逻辑层。 + +从 video_compose_service.py 抽离的纯函数集合,专门负责 FFmpeg 滤镜链的构建, +不依赖数据库、不做 IO,便于单元测试。 + +主要职责: +- 单片段滤镜链构建(scale / pad / format / fps / setpts / trim) +- concat 滤镜构建(无转场高效拼接) +- xfade 转场滤镜链构建(fade / slide / dissolve / wipe) +- 完整 filter_complex 策略选择与组装 +- 音频流判断与音频滤镜归一化 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from packages.domain.template_clip_config import TransitionEffect + +if TYPE_CHECKING: + from packages.domain.edit_plan_clip import EditPlanClip + + +# ── 常量 ────────────────────────────────────────────────────────────────────── + +DEFAULT_OUTPUT_WIDTH = 1280 +DEFAULT_OUTPUT_HEIGHT = 720 +DEFAULT_FPS = 25 + +# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称 +XFADE_TRANSITION_MAP: dict[str, str] = { + TransitionEffect.FADE: "fade", + TransitionEffect.SLIDE_LEFT: "slideleft", + TransitionEffect.SLIDE_RIGHT: "slideright", + TransitionEffect.DISSOLVE: "dissolve", + TransitionEffect.WIPE: "wipeleft", +} + +# 转场默认时长(秒) +DEFAULT_TRANSITION_DURATION = 0.5 + +# 默认片段时长(当 clip.duration <= 0 时使用) +DEFAULT_CLIP_DURATION = 5.0 + + +# ── 数据结构 ────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class ClipFilterChain: + """单个片段的滤镜链描述。""" + + clip_id: str + input_index: int + video_label: str + audio_label: str | None + filters: list[str] + duration: float + + +# ── 单片段滤镜链 ────────────────────────────────────────────────────────────── + + +def build_clip_filter( + clip: "EditPlanClip", + input_index: int, + output_width: int, + output_height: int, + fps: int, +) -> ClipFilterChain: + """为单个片段构建滤镜链。 + + 滤镜顺序: + 1. scale — 等比缩放到目标分辨率(保证覆盖,不裁剪内容) + 2. pad — 居中+留黑边到目标分辨率(保持原始比例) + 3. format — 统一像素格式为 yuv420p(concat 要求像素格式一致) + 4. fps — 统一帧率(concat 要求所有输入帧率一致) + 5. setpts — 重置时间戳 + 起始偏移 + 6. trim — 视频时长裁剪 + 重置 PTS + + Args: + clip: 剪辑计划片段 + input_index: 输入流索引(对应第几个 -i) + output_width: 输出宽度(像素) + output_height: 输出高度(像素) + fps: 输出帧率 + + Returns: + ClipFilterChain 描述对象 + """ + duration = clip.duration if clip.duration > 0 else DEFAULT_CLIP_DURATION + start = clip.start_time + + filters: list[str] = [] + + # 1. scale: 等比缩放(保持比例,不裁剪) + filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease") + + # 2. pad: 居中+留黑边到目标分辨率 + filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black") + + # 3. format: 统一像素格式为 yuv420p + filters.append("format=yuv420p") + + # 4. fps: 统一帧率 + if fps and fps > 0: + filters.append(f"fps={fps}") + + # 5. setpts: 重置时间戳 + 偏移 + if start > 0: + filters.append(f"setpts=PTS-STARTPTS+{start}/TB") + else: + filters.append("setpts=PTS-STARTPTS") + + # 6. trim: 视频时长 + 重置 PTS + filters.append(f"trim=0:{duration}") + filters.append("setpts=PTS-STARTPTS") + + video_label = f"v{input_index}" + + # 音频标签:title/subtitle 是纯文字/图片卡片,没有音频流 + clip_type = clip.clip_type.lower() if clip.clip_type else "" + has_audio_stream = clip_type not in ("title", "subtitle") + audio_label = f"a{input_index}" if has_audio_stream else None + + return ClipFilterChain( + clip_id=clip.id, + input_index=input_index, + video_label=video_label, + audio_label=audio_label, + filters=filters, + duration=duration, + ) + + +# ── 滤镜串联工具 ────────────────────────────────────────────────────────────── + + +def chain_filters(filters: list[str], output_label: str, input_label: str = "0:v") -> str: + """将滤镜列表串联为 FFmpeg 滤镜字符串。 + + Args: + filters: 滤镜表达式列表 + output_label: 输出标签名(不含方括号) + input_label: 输入标签(默认 "0:v") + + Returns: + 形如 "[0:v]scale=1280:720,fps=25[v0]" 的字符串 + """ + filter_body = ",".join(filters) + return f"[{input_label}]{filter_body}[{output_label}]" + + +# ── 音频判断 ────────────────────────────────────────────────────────────────── + + +def has_audio(clip_chains: list[ClipFilterChain]) -> bool: + """是否有任何片段包含音频流。""" + return any(c.audio_label is not None for c in clip_chains) + + +# ── concat 滤镜 ────────────────────────────────────────────────────────────── + + +def build_concat_filter( + clip_chains: list[ClipFilterChain], +) -> tuple[str, float]: + """构建 concat 滤镜(无转场,高效拼接)。 + + 视频:每个片段先应用各自滤镜链,再用 concat 滤镜拼接 + 音频:先 aformat 归一化(48000Hz/stereo/fltp)再 concat, + 避免不同采样率/声道导致 concat 失败 + + Args: + clip_chains: 各片段的滤镜链描述 + + Returns: + (filter_complex_string, estimated_total_duration) + """ + n = len(clip_chains) + if n == 0: + return "", 0.0 + + parts: list[str] = [] + total_duration = 0.0 + + # 每个片段的视频滤镜链 + for idx, chain in enumerate(clip_chains): + filter_body = ",".join(chain.filters) + parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]") + total_duration += chain.duration + + # 视频 concat 滤镜 + concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains) + parts.append(f"{concat_inputs}concat=n={n}:v=1:a=0[outv]") + + # 音频:归一化 + concat + _append_audio_concat(parts, clip_chains) + + return ";".join(parts), total_duration + + +# ── xfade 转场滤镜 ──────────────────────────────────────────────────────────── + + +def build_xfade_filter( + clip_chains: list[ClipFilterChain], + transition_duration: float, + transitions: list[str], +) -> tuple[str, float]: + """构建 xfade 转场滤镜链。 + + 每两个相邻片段之间插入 xfade 转场。 + offset = 前一个片段的累积时长 - 转场时长。 + + 视频转场支持:fade / slideleft / slideright / dissolve / wipeleft + + Args: + clip_chains: 各片段的滤镜链描述 + transition_duration: 转场时长(秒) + transitions: 每个片段对应的转场效果列表(索引对应片段) + + Returns: + (filter_complex_string, estimated_total_duration) + """ + n = len(clip_chains) + if n == 0: + return "", 0.0 + + parts: list[str] = [] + total_duration = 0.0 + + # 每个片段的视频滤镜链 + for idx, chain in enumerate(clip_chains): + filter_body = ",".join(chain.filters) + parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]") + total_duration += chain.duration + + # 单片段:直接 copy 输出(无音频,与原实现保持一致) + if n == 1: + parts.append(f"[{clip_chains[0].video_label}]copy[outv]") + return ";".join(parts), total_duration + + # xfade 链式转场 + cumulative = 0.0 + prev_label = clip_chains[0].video_label + + for i in range(1, n): + cumulative += clip_chains[i - 1].duration + offset = max(0.0, cumulative - transition_duration * i) + + # 获取转场类型 + transition = transitions[i] if i < len(transitions) else "cut" + xfade_transition = XFADE_TRANSITION_MAP.get(transition, "fade") + + out_label = "outv" if i == n - 1 else f"xf{i}" + + parts.append( + f"[{prev_label}][{clip_chains[i].video_label}]" + f"xfade=transition={xfade_transition}" + f":duration={transition_duration}" + f":offset={offset:.3f}" + f"[{out_label}]" + ) + prev_label = out_label + + # 总时长减去转场重叠部分 + total_duration -= transition_duration * (n - 1) + total_duration = max(0.0, total_duration) + + # 音频:xfade 路径下的音频处理 + # 注意:使用 chain.audio_label 作为输入标签(与原实现保持一致) + audio_chains = [c for c in clip_chains if c.audio_label] + if len(audio_chains) >= 2: + normalized_labels: list[str] = [] + for chain in audio_chains: + norm_label = f"anorm_{chain.video_label}" + audio_filters = [ + "aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp", + f"atrim=0:{chain.duration}", + "asetpts=PTS-STARTPTS", + ] + parts.append(f"[{chain.audio_label}]{','.join(audio_filters)}[{norm_label}]") + normalized_labels.append(norm_label) + audio_inputs = "".join(f"[{label}]" for label in normalized_labels) + parts.append(f"{audio_inputs}concat=n={len(normalized_labels)}:v=0:a=1[outa]") + elif len(audio_chains) == 1: + parts.append(f"[{audio_chains[0].audio_label}]acopy[outa]") + + return ";".join(parts), total_duration + + +# ── 完整 filter_complex 构建(策略选择) ──────────────────────────────────── + + +def build_filter_complex( + clip_chains: list[ClipFilterChain], + output_width: int, + output_height: int, + transition_duration: float, + transitions: list[str], +) -> tuple[str, float]: + """构建完整的 filter_complex 字符串(策略自动选择)。 + + 策略: + - 空列表:返回空字符串 + 0 时长 + - 单片段:直接输出(scale+pad+fps+trim 单链) + - 多片段 + 全 cut:使用 concat 滤镜(高效) + - 多片段 + 有转场:使用 xfade 滤镜链 + + Args: + clip_chains: 各片段的滤镜链描述 + output_width: 输出宽度(目前单片段策略不使用,保留参数一致性) + output_height: 输出高度(同上) + transition_duration: 转场时长(秒) + transitions: 每个片段对应的转场效果列表 + + Returns: + (filter_complex_string, estimated_total_duration) + """ + n = len(clip_chains) + + if n == 0: + return "", 0.0 + + # 单片段 + if n == 1: + chain = clip_chains[0] + filter_str = chain_filters(chain.filters, chain.video_label) + # 音频直通 + if chain.audio_label: + filter_str += f";[0:a]{chain.audio_label}" + total_duration = chain.duration + return filter_str, total_duration + + # 检查是否有转场 + has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions) + + if not has_transitions: + return build_concat_filter(clip_chains) + + # 有转场:使用 xfade + return build_xfade_filter( + clip_chains=clip_chains, + transition_duration=transition_duration, + transitions=transitions, + ) + + +# ── 内部辅助:音频处理 ─────────────────────────────────────────────────────── + + +def _append_audio_concat(parts: list[str], clip_chains: list[ClipFilterChain]) -> None: + """追加音频归一化 + concat 滤镜链到 parts(concat 路径)。 + + 与原实现保持一致:归一化输出标签复用 chain.audio_label, + concat 直接使用 audio_label 作为输入。 + """ + audio_chains = [c for c in clip_chains if c.audio_label] + if not audio_chains: + return + + # 先 aformat 归一化,输出到 chain.audio_label + for chain in audio_chains: + audio_filters = [ + "aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp", + f"atrim=0:{chain.duration}", + "asetpts=PTS-STARTPTS", + ] + parts.append(f"[{chain.input_index}:a]{','.join(audio_filters)}[{chain.audio_label}]") + + # concat 滤镜(使用 audio_label 作为输入) + audio_inputs = "".join(f"[{c.audio_label}]" for c in audio_chains) + parts.append(f"{audio_inputs}concat=n={len(audio_chains)}:v=0:a=1[outa]") diff --git a/tests/unit/test_video_filter_builder.py b/tests/unit/test_video_filter_builder.py new file mode 100755 index 000000000..5f7c749e7 --- /dev/null +++ b/tests/unit/test_video_filter_builder.py @@ -0,0 +1,855 @@ +"""video_filter_builder 单元测试 — FFmpeg 滤镜构建纯逻辑层。 + +覆盖: +- ClipFilterChain 数据类 +- 常量与映射表 +- build_clip_filter:单片段滤镜链 +- chain_filters:滤镜串联工具 +- has_audio:音频流判断 +- build_concat_filter:concat 滤镜 +- build_xfade_filter:xfade 转场滤镜 +- build_filter_complex:策略选择(空/单片段/concat/xfade) +""" + +from __future__ import annotations + +import unittest + +from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus +from packages.domain.template_clip_config import TransitionEffect +from packages.domain.video_filter_builder import ( + DEFAULT_CLIP_DURATION, + DEFAULT_FPS, + DEFAULT_OUTPUT_HEIGHT, + DEFAULT_OUTPUT_WIDTH, + DEFAULT_TRANSITION_DURATION, + ClipFilterChain, + XFADE_TRANSITION_MAP, + build_clip_filter, + build_concat_filter, + build_filter_complex, + build_xfade_filter, + chain_filters, + has_audio, +) + +# ── 辅助:构造 EditPlanClip ────────────────────────────────────────────────── + + +def _make_clip( + clip_id: str = "clip-1", + duration: float = 5.0, + start_time: float = 0.0, + clip_type: str = "video", + asset_id: str | None = "asset-1", +) -> EditPlanClip: + """构造一个测试用 EditPlanClip。""" + return EditPlanClip( + id=clip_id, + plan_id="plan-1", + asset_id=asset_id, + clip_type=clip_type, + duration=duration, + start_time=start_time, + order=0, + status=EditPlanClipStatus.READY, + ) + + +# ── ClipFilterChain 数据类测试 ────────────────────────────────────────────── + + +class TestClipFilterChain(unittest.TestCase): + """ClipFilterChain 数据类测试。""" + + def test_immutable(self): + """ClipFilterChain 是 frozen dataclass,不可修改。""" + chain = ClipFilterChain( + clip_id="c1", + input_index=0, + video_label="v0", + audio_label="a0", + filters=["scale=1280:720"], + duration=5.0, + ) + with self.assertRaises(Exception): + chain.duration = 10.0 # type: ignore[misc] + + def test_fields(self): + """所有字段正确存储。""" + chain = ClipFilterChain( + clip_id="c1", + input_index=2, + video_label="v2", + audio_label=None, + filters=["fps=25", "trim=0:3"], + duration=3.0, + ) + self.assertEqual(chain.clip_id, "c1") + self.assertEqual(chain.input_index, 2) + self.assertEqual(chain.video_label, "v2") + self.assertIsNone(chain.audio_label) + self.assertEqual(chain.filters, ["fps=25", "trim=0:3"]) + self.assertEqual(chain.duration, 3.0) + + +# ── 常量测试 ──────────────────────────────────────────────────────────────── + + +class TestConstants(unittest.TestCase): + """常量与映射表测试。""" + + def test_default_output_size(self): + """默认输出分辨率 1280x720。""" + self.assertEqual(DEFAULT_OUTPUT_WIDTH, 1280) + self.assertEqual(DEFAULT_OUTPUT_HEIGHT, 720) + + def test_default_fps(self): + """默认帧率 25。""" + self.assertEqual(DEFAULT_FPS, 25) + + def test_default_transition_duration(self): + """默认转场时长 0.5 秒。""" + self.assertEqual(DEFAULT_TRANSITION_DURATION, 0.5) + + def test_default_clip_duration(self): + """默认片段时长 5 秒。""" + self.assertEqual(DEFAULT_CLIP_DURATION, 5.0) + + def test_xfade_transition_map_keys(self): + """xfade 映射包含所有转场类型。""" + self.assertIn(TransitionEffect.FADE, XFADE_TRANSITION_MAP) + self.assertIn(TransitionEffect.SLIDE_LEFT, XFADE_TRANSITION_MAP) + self.assertIn(TransitionEffect.SLIDE_RIGHT, XFADE_TRANSITION_MAP) + self.assertIn(TransitionEffect.DISSOLVE, XFADE_TRANSITION_MAP) + self.assertIn(TransitionEffect.WIPE, XFADE_TRANSITION_MAP) + + def test_xfade_transition_map_values(self): + """xfade 映射值为 FFmpeg 合法 transition 名称。""" + self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.FADE], "fade") + self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.SLIDE_LEFT], "slideleft") + self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.SLIDE_RIGHT], "slideright") + self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.DISSOLVE], "dissolve") + self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.WIPE], "wipeleft") + + +# ── chain_filters 测试 ────────────────────────────────────────────────────── + + +class TestChainFilters(unittest.TestCase): + """chain_filters 滤镜串联工具测试。""" + + def test_single_filter(self): + """单个滤镜。""" + result = chain_filters(["scale=1280:720"], "v0") + self.assertEqual(result, "[0:v]scale=1280:720[v0]") + + def test_multiple_filters(self): + """多个滤镜用逗号串联。""" + result = chain_filters(["scale=1280:720", "fps=25", "trim=0:5"], "v1") + self.assertEqual(result, "[0:v]scale=1280:720,fps=25,trim=0:5[v1]") + + def test_empty_filters(self): + """空滤镜列表。""" + result = chain_filters([], "v0") + self.assertEqual(result, "[0:v][v0]") + + def test_custom_input_label(self): + """自定义输入标签。""" + result = chain_filters(["fps=30"], "out", input_label="v0") + self.assertEqual(result, "[v0]fps=30[out]") + + +# ── has_audio 测试 ────────────────────────────────────────────────────────── + + +class TestHasAudio(unittest.TestCase): + """has_audio 音频流判断测试。""" + + def test_all_have_audio(self): + """所有片段都有音频。""" + chains = [ + ClipFilterChain("c1", 0, "v0", "a0", [], 5.0), + ClipFilterChain("c2", 1, "v1", "a1", [], 3.0), + ] + self.assertTrue(has_audio(chains)) + + def test_some_have_audio(self): + """部分片段有音频。""" + chains = [ + ClipFilterChain("c1", 0, "v0", "a0", [], 5.0), + ClipFilterChain("c2", 1, "v1", None, [], 3.0), + ] + self.assertTrue(has_audio(chains)) + + def test_none_have_audio(self): + """没有片段有音频。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, [], 5.0), + ClipFilterChain("c2", 1, "v1", None, [], 3.0), + ] + self.assertFalse(has_audio(chains)) + + def test_empty_list(self): + """空列表返回 False。""" + self.assertFalse(has_audio([])) + + +# ── build_clip_filter 测试 ────────────────────────────────────────────────── + + +class TestBuildClipFilter(unittest.TestCase): + """build_clip_filter 单片段滤镜链测试。""" + + def test_basic_video_clip(self): + """普通视频片段生成完整滤镜链。""" + clip = _make_clip(duration=5.0, clip_type="video") + chain = build_clip_filter(clip, 0, 1280, 720, 25) + + self.assertEqual(chain.clip_id, "clip-1") + self.assertEqual(chain.input_index, 0) + self.assertEqual(chain.video_label, "v0") + self.assertEqual(chain.audio_label, "a0") + self.assertEqual(chain.duration, 5.0) + # 应有 7 个滤镜:scale, pad, format, fps, setpts, trim, setpts + self.assertEqual(len(chain.filters), 7) + + def test_filter_order(self): + """滤镜顺序:scale → pad → format → fps → setpts → trim → setpts。""" + clip = _make_clip(duration=3.0, clip_type="video") + chain = build_clip_filter(clip, 0, 1280, 720, 25) + + self.assertTrue(chain.filters[0].startswith("scale=")) + self.assertTrue(chain.filters[1].startswith("pad=")) + self.assertEqual(chain.filters[2], "format=yuv420p") + self.assertTrue(chain.filters[3].startswith("fps=")) + self.assertTrue(chain.filters[4].startswith("setpts=")) + self.assertTrue(chain.filters[5].startswith("trim=")) + self.assertEqual(chain.filters[6], "setpts=PTS-STARTPTS") + + def test_scale_force_original_aspect_ratio(self): + """scale 使用 decrease 保持比例。""" + clip = _make_clip(duration=5.0) + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertIn("force_original_aspect_ratio=decrease", chain.filters[0]) + + def test_pad_centered_black(self): + """pad 居中 + 黑边。""" + clip = _make_clip(duration=5.0) + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertIn("(ow-iw)/2:(oh-ih)/2:black", chain.filters[1]) + + def test_format_yuv420p(self): + """像素格式统一为 yuv420p。""" + clip = _make_clip(duration=5.0) + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertEqual(chain.filters[2], "format=yuv420p") + + def test_custom_resolution(self): + """自定义输出分辨率。""" + clip = _make_clip(duration=5.0) + chain = build_clip_filter(clip, 0, 1920, 1080, 30) + self.assertIn("scale=1920:1080", chain.filters[0]) + self.assertIn("pad=1920:1080", chain.filters[1]) + self.assertEqual(chain.filters[3], "fps=30") + + def test_zero_duration_uses_default(self): + """duration <= 0 时使用默认时长。""" + clip = _make_clip(duration=0.0) + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertEqual(chain.duration, DEFAULT_CLIP_DURATION) + self.assertIn(f"trim=0:{DEFAULT_CLIP_DURATION}", chain.filters[5]) + + def test_negative_duration_uses_default(self): + """负时长也使用默认时长。""" + clip = _make_clip(duration=-1.0) + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertEqual(chain.duration, DEFAULT_CLIP_DURATION) + + def test_start_time_offset(self): + """start_time > 0 时 setpts 带偏移。""" + clip = _make_clip(duration=3.0, start_time=2.0) + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertIn("PTS-STARTPTS+2.0/TB", chain.filters[4]) + + def test_zero_start_time_no_offset(self): + """start_time = 0 时 setpts 不带偏移。""" + clip = _make_clip(duration=3.0, start_time=0.0) + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertEqual(chain.filters[4], "setpts=PTS-STARTPTS") + + def test_title_clip_no_audio(self): + """title 类型片段没有音频。""" + clip = _make_clip(clip_type="title") + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertIsNone(chain.audio_label) + + def test_subtitle_clip_no_audio(self): + """subtitle 类型片段没有音频。""" + clip = _make_clip(clip_type="subtitle") + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertIsNone(chain.audio_label) + + def test_video_clip_has_audio(self): + """video 类型片段有音频。""" + clip = _make_clip(clip_type="video") + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertEqual(chain.audio_label, "a0") + + def test_image_clip_has_audio(self): + """image 类型片段有音频标签(可能有BGM)。""" + clip = _make_clip(clip_type="image") + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertIsNotNone(chain.audio_label) + + def test_clip_type_case_insensitive(self): + """clip_type 大小写不敏感。""" + clip = _make_clip(clip_type="TITLE") + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertIsNone(chain.audio_label) + + def test_empty_clip_type_has_audio(self): + """空 clip_type 默认有音频。""" + clip = _make_clip(clip_type="") + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertIsNotNone(chain.audio_label) + + def test_input_index_reflected_in_labels(self): + """input_index 反映在 video_label 和 audio_label 中。""" + clip = _make_clip() + chain = build_clip_filter(clip, 3, 1280, 720, 25) + self.assertEqual(chain.video_label, "v3") + self.assertEqual(chain.audio_label, "a3") + + def test_zero_fps_skipped(self): + """fps = 0 时跳过 fps 滤镜。""" + clip = _make_clip(duration=5.0) + chain = build_clip_filter(clip, 0, 1280, 720, 0) + # 少了 fps 滤镜:scale, pad, format, setpts, trim, setpts = 6个 + self.assertEqual(len(chain.filters), 6) + self.assertFalse(any(f.startswith("fps=") for f in chain.filters)) + + def test_negative_fps_skipped(self): + """fps < 0 时也跳过 fps 滤镜。""" + clip = _make_clip(duration=5.0) + chain = build_clip_filter(clip, 0, 1280, 720, -1) + self.assertEqual(len(chain.filters), 6) + + def test_trim_uses_duration(self): + """trim 时长等于 clip.duration。""" + clip = _make_clip(duration=7.5) + chain = build_clip_filter(clip, 0, 1280, 720, 25) + self.assertIn("trim=0:7.5", chain.filters[5]) + + +# ── build_concat_filter 测试 ──────────────────────────────────────────────── + + +class TestBuildConcatFilter(unittest.TestCase): + """build_concat_filter 拼接滤镜测试。""" + + def test_empty_clips(self): + """空列表返回空字符串和 0 时长。""" + filter_str, duration = build_concat_filter([]) + self.assertEqual(filter_str, "") + self.assertEqual(duration, 0.0) + + def test_single_clip_no_audio(self): + """单片段无音频:视频滤镜 + concat(n=1)。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, ["scale=1280:720"], 5.0), + ] + filter_str, duration = build_concat_filter(chains) + + self.assertIn("[0:v]scale=1280:720[v0]", filter_str) + self.assertIn("[v0]concat=n=1:v=1:a=0[outv]", filter_str) + self.assertEqual(duration, 5.0) + # 没有音频相关 + self.assertNotIn("[outa]", filter_str) + + def test_single_clip_with_audio(self): + """单片段有音频:视频 + 音频归一化 + concat。""" + chains = [ + ClipFilterChain("c1", 0, "v0", "a0", ["scale=1280:720"], 5.0), + ] + filter_str, duration = build_concat_filter(chains) + + self.assertIn("[0:v]scale=1280:720[v0]", filter_str) + self.assertIn("[v0]concat=n=1:v=1:a=0[outv]", filter_str) + # 音频归一化 + self.assertIn("[0:a]aformat=sample_rates=48000", filter_str) + self.assertIn("stereo:sample_fmts=fltp", filter_str) + self.assertIn("atrim=0:5.0", filter_str) + self.assertIn("[a0]", filter_str) + # 音频 concat(n=1) + self.assertIn("[a0]concat=n=1:v=0:a=1[outa]", filter_str) + self.assertEqual(duration, 5.0) + + def test_two_clips_no_audio(self): + """两片段无音频:两个视频滤镜 + concat(n=2)。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, ["fps=25"], 5.0), + ClipFilterChain("c2", 1, "v1", None, ["fps=25"], 3.0), + ] + filter_str, duration = build_concat_filter(chains) + + self.assertIn("[0:v]fps=25[v0]", filter_str) + self.assertIn("[1:v]fps=25[v1]", filter_str) + self.assertIn("[v0][v1]concat=n=2:v=1:a=0[outv]", filter_str) + self.assertEqual(duration, 8.0) + + def test_two_clips_with_audio(self): + """两片段都有音频:视频 concat + 音频归一化 + 音频 concat。""" + chains = [ + ClipFilterChain("c1", 0, "v0", "a0", ["fps=25"], 5.0), + ClipFilterChain("c2", 1, "v1", "a1", ["fps=25"], 3.0), + ] + filter_str, duration = build_concat_filter(chains) + + # 视频 + self.assertIn("[v0][v1]concat=n=2:v=1:a=0[outv]", filter_str) + # 音频归一化 + self.assertIn( + "[0:a]aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp,atrim=0:5.0,asetpts=PTS-STARTPTS[a0]", + filter_str, + ) + self.assertIn( + "[1:a]aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp,atrim=0:3.0,asetpts=PTS-STARTPTS[a1]", + filter_str, + ) + # 音频 concat + self.assertIn("[a0][a1]concat=n=2:v=0:a=1[outa]", filter_str) + self.assertEqual(duration, 8.0) + + def test_mixed_audio_some_none(self): + """部分有音频部分没有:只有有音频的片段参与音频 concat。""" + chains = [ + ClipFilterChain("c1", 0, "v0", "a0", ["fps=25"], 5.0), + ClipFilterChain("c2", 1, "v1", None, ["fps=25"], 3.0), + ClipFilterChain("c3", 2, "v2", "a2", ["fps=25"], 4.0), + ] + filter_str, duration = build_concat_filter(chains) + + # 视频 concat 有 3 个输入 + self.assertIn("[v0][v1][v2]concat=n=3:v=1:a=0[outv]", filter_str) + # 音频 concat 只有 2 个输入 + self.assertIn("[a0][a2]concat=n=2:v=0:a=1[outa]", filter_str) + # 片段 1 没有音频归一化 + self.assertNotIn("[1:a]", filter_str) + self.assertEqual(duration, 12.0) + + def test_three_clips_total_duration(self): + """三片段总时长为各片段之和。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, [], 2.0), + ClipFilterChain("c2", 1, "v1", None, [], 3.0), + ClipFilterChain("c3", 2, "v2", None, [], 4.0), + ] + _, duration = build_concat_filter(chains) + self.assertEqual(duration, 9.0) + + def test_audio_format_normalization(self): + """音频归一化包含 aformat/atrim/asetpts。""" + chains = [ + ClipFilterChain("c1", 0, "v0", "a0", [], 5.0), + ] + filter_str, _ = build_concat_filter(chains) + + self.assertIn("aformat=sample_rates=48000", filter_str) + self.assertIn("channel_layouts=stereo", filter_str) + self.assertIn("sample_fmts=fltp", filter_str) + self.assertIn("atrim=0:5.0", filter_str) + self.assertIn("asetpts=PTS-STARTPTS", filter_str) + + def test_filter_parts_separated_by_semicolon(self): + """各滤镜部分用分号分隔。""" + chains = [ + ClipFilterChain("c1", 0, "v0", "a0", ["fps=25"], 5.0), + ClipFilterChain("c2", 1, "v1", "a1", ["fps=25"], 3.0), + ] + filter_str, _ = build_concat_filter(chains) + parts = filter_str.split(";") + # 2 视频 + 2 音频归一化 + 1 视频 concat + 1 音频 concat = 6 + self.assertEqual(len(parts), 6) + + +# ── build_xfade_filter 测试 ───────────────────────────────────────────────── + + +class TestBuildXfadeFilter(unittest.TestCase): + """build_xfade_filter 转场滤镜测试。""" + + def test_empty_clips(self): + """空列表返回空字符串和 0 时长。""" + filter_str, duration = build_xfade_filter([], 0.5, []) + self.assertEqual(filter_str, "") + self.assertEqual(duration, 0.0) + + def test_single_clip_no_audio(self): + """单片段无音频:视频滤镜 + copy。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, ["scale=1280:720"], 5.0), + ] + filter_str, duration = build_xfade_filter(chains, 0.5, ["fade"]) + + self.assertIn("[0:v]scale=1280:720[v0]", filter_str) + self.assertIn("[v0]copy[outv]", filter_str) + self.assertEqual(duration, 5.0) + # 单片段 xfade 没有音频输出 + self.assertNotIn("[outa]", filter_str) + + def test_single_clip_with_audio(self): + """单片段有音频:xfade 路径下单片段不输出音频(与原实现一致)。""" + chains = [ + ClipFilterChain("c1", 0, "v0", "a0", ["scale=1280:720"], 5.0), + ] + filter_str, _ = build_xfade_filter(chains, 0.5, ["fade"]) + # 单片段 xfade 没有音频输出 + self.assertNotIn("[outa]", filter_str) + self.assertNotIn("acopy", filter_str) + + def test_two_clips_fade_transition(self): + """两片段 fade 转场:xfade 滤镜结构正确。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, ["fps=25"], 5.0), + ClipFilterChain("c2", 1, "v1", None, ["fps=25"], 3.0), + ] + filter_str, duration = build_xfade_filter(chains, 0.5, ["cut", "fade"]) + + # 两个视频滤镜链 + self.assertIn("[0:v]fps=25[v0]", filter_str) + self.assertIn("[1:v]fps=25[v1]", filter_str) + # xfade 转场 + self.assertIn("xfade=transition=fade", filter_str) + self.assertIn(":duration=0.5", filter_str) + self.assertIn("[outv]", filter_str) + # 总时长 = 5 + 3 - 0.5 = 7.5 + self.assertAlmostEqual(duration, 7.5) + + def test_two_clips_offset_calculation(self): + """转场 offset = 第一个片段时长 - 转场时长。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, [], 5.0), + ClipFilterChain("c2", 1, "v1", None, [], 3.0), + ] + filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"]) + + # offset = 5.0 - 0.5 * 1 = 4.5 + self.assertIn(":offset=4.500", filter_str) + + def test_three_clips_chain(self): + """三片段链式转场:两个 xfade,中间用 xf1 标签。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, [], 4.0), + ClipFilterChain("c2", 1, "v1", None, [], 3.0), + ClipFilterChain("c3", 2, "v2", None, [], 5.0), + ] + filter_str, duration = build_xfade_filter(chains, 0.5, ["cut", "fade", "dissolve"]) + + # 第一个转场输出到 xf1 + self.assertIn("[xf1]", filter_str) + # 第二个转场输出到 outv + self.assertIn("[xf1][v2]xfade=transition=dissolve", filter_str) + self.assertIn("[outv]", filter_str) + # 总时长 = 4 + 3 + 5 - 0.5 * 2 = 11.0 + self.assertAlmostEqual(duration, 11.0) + + def test_three_clips_offsets(self): + """三片段两个转场的 offset 计算正确。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, [], 4.0), + ClipFilterChain("c2", 1, "v1", None, [], 3.0), + ClipFilterChain("c3", 2, "v2", None, [], 5.0), + ] + filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade", "slideleft"]) + + # 第一个 offset = 4.0 - 0.5*1 = 3.5 + # 第二个 offset = (4.0+3.0) - 0.5*2 = 7.0 - 1.0 = 6.0 + self.assertIn(":offset=3.500", filter_str) + self.assertIn(":offset=6.000", filter_str) + + def test_all_transition_types(self): + """所有转场类型都能正确映射。""" + transitions = [ + (TransitionEffect.FADE, "fade"), + (TransitionEffect.SLIDE_LEFT, "slideleft"), + (TransitionEffect.SLIDE_RIGHT, "slideright"), + (TransitionEffect.DISSOLVE, "dissolve"), + (TransitionEffect.WIPE, "wipeleft"), + ] + for effect, expected_name in transitions: + chains = [ + ClipFilterChain("c1", 0, "v0", None, [], 3.0), + ClipFilterChain("c2", 1, "v1", None, [], 2.0), + ] + filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", effect]) + self.assertIn( + f"xfade=transition={expected_name}", + filter_str, + f"Transition {effect} should map to {expected_name}", + ) + + def test_unknown_transition_defaults_to_fade(self): + """未知转场类型默认使用 fade。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, [], 3.0), + ClipFilterChain("c2", 1, "v1", None, [], 2.0), + ] + filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "nonexistent"]) + self.assertIn("xfade=transition=fade", filter_str) + + def test_cut_still_uses_fade(self): + """cut 类型在 xfade 路径下也映射为 fade(因为走了 xfade 分支)。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, [], 3.0), + ClipFilterChain("c2", 1, "v1", None, [], 2.0), + ] + # 只要有一个非 cut 就走 xfade,cut 的那个也用 fade 作为默认 + filter_str, _ = build_xfade_filter(chains, 0.5, ["fade", "cut"]) + # 第二个转场是 cut,默认用 fade + self.assertIn("xfade=transition=fade", filter_str) + + def test_transitions_shorter_than_clips(self): + """transitions 列表比 clip 短时,超出部分默认 cut→fade。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, [], 2.0), + ClipFilterChain("c2", 1, "v1", None, [], 2.0), + ClipFilterChain("c3", 2, "v2", None, [], 2.0), + ] + # 只给 1 个 transition(索引0),索引1和2会越界 + filter_str, _ = build_xfade_filter(chains, 0.5, ["fade"]) + # 应该有两个 xfade,都用 fade(第二个是默认值) + self.assertEqual(filter_str.count("xfade=transition=fade"), 2) + + def test_zero_transition_duration(self): + """转场时长为 0 时不减时长。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, [], 5.0), + ClipFilterChain("c2", 1, "v1", None, [], 3.0), + ] + _, duration = build_xfade_filter(chains, 0.0, ["cut", "fade"]) + self.assertAlmostEqual(duration, 8.0) + + def test_total_duration_not_negative(self): + """总时长不会为负数。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, [], 0.1), + ClipFilterChain("c2", 1, "v1", None, [], 0.1), + ] + _, duration = build_xfade_filter(chains, 10.0, ["cut", "fade"]) + self.assertGreaterEqual(duration, 0.0) + + def test_two_clips_with_audio_normalize_and_concat(self): + """两片段都有音频:归一化 + concat。""" + chains = [ + ClipFilterChain("c1", 0, "v0", "a0", [], 5.0), + ClipFilterChain("c2", 1, "v1", "a1", [], 3.0), + ] + filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"]) + + # 音频归一化(注意:xfade 路径用 audio_label 作为输入,与原实现一致) + self.assertIn( + "[a0]aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp,atrim=0:5.0,asetpts=PTS-STARTPTS[anorm_v0]", + filter_str, + ) + self.assertIn( + "[a1]aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp,atrim=0:3.0,asetpts=PTS-STARTPTS[anorm_v1]", + filter_str, + ) + # 音频 concat + self.assertIn("[anorm_v0][anorm_v1]concat=n=2:v=0:a=1[outa]", filter_str) + + def test_single_audio_in_xfade_acopy(self): + """xfade 路径下只有一个音频片段时直接 acopy。""" + chains = [ + ClipFilterChain("c1", 0, "v0", "a0", [], 5.0), + ClipFilterChain("c2", 1, "v1", None, [], 3.0), + ] + filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"]) + + self.assertIn("[a0]acopy[outa]", filter_str) + # 没有音频归一化 + self.assertNotIn("aformat", filter_str) + self.assertNotIn("concat=n=", filter_str) + + def test_no_audio_in_xfade(self): + """xfade 路径下都没有音频时没有 outa。""" + chains = [ + ClipFilterChain("c1", 0, "v0", None, [], 5.0), + ClipFilterChain("c2", 1, "v1", None, [], 3.0), + ] + filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"]) + self.assertNotIn("[outa]", filter_str) + self.assertNotIn("acopy", filter_str) + + +# ── build_filter_complex 测试 ─────────────────────────────────────────────── + + +class TestBuildFilterComplex(unittest.TestCase): + """build_filter_complex 策略选择测试。""" + + def _chain(self, idx: int, has_audio: bool = True) -> ClipFilterChain: + return ClipFilterChain( + clip_id=f"c{idx}", + input_index=idx, + video_label=f"v{idx}", + audio_label=f"a{idx}" if has_audio else None, + filters=["fps=25"], + duration=3.0, + ) + + def test_empty_clips(self): + """空列表返回空字符串和 0 时长。""" + filter_str, duration = build_filter_complex([], 1280, 720, 0.5, []) + self.assertEqual(filter_str, "") + self.assertEqual(duration, 0.0) + + def test_single_clip_direct_output(self): + """单片段:直接输出单链滤镜。""" + chains = [self._chain(0)] + filter_str, duration = build_filter_complex(chains, 1280, 720, 0.5, []) + + self.assertIn("[0:v]fps=25[v0]", filter_str) + self.assertNotIn("concat", filter_str) + self.assertNotIn("xfade", filter_str) + self.assertEqual(duration, 3.0) + + def test_single_clip_audio_passthrough(self): + """单片段有音频:音频直通标签。""" + chains = [self._chain(0, has_audio=True)] + filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, []) + # 单片段音频:[0:a]a0(直通标签) + self.assertIn("[0:a]a0", filter_str) + + def test_single_clip_no_audio(self): + """单片段无音频:没有音频部分。""" + chains = [self._chain(0, has_audio=False)] + filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, []) + self.assertNotIn("[0:a]", filter_str) + self.assertNotIn("[outa]", filter_str) + + def test_multiple_all_cut_uses_concat(self): + """多片段 + 全 cut:使用 concat 滤镜。""" + chains = [self._chain(0), self._chain(1), self._chain(2)] + transitions = [TransitionEffect.CUT, TransitionEffect.CUT, TransitionEffect.CUT] + filter_str, duration = build_filter_complex(chains, 1280, 720, 0.5, transitions) + + self.assertIn("concat=n=3:v=1:a=0[outv]", filter_str) + self.assertNotIn("xfade", filter_str) + self.assertEqual(duration, 9.0) + + def test_multiple_one_transition_uses_xfade(self): + """多片段 + 有一个非 cut 转场:使用 xfade。""" + chains = [self._chain(0), self._chain(1)] + transitions = [TransitionEffect.CUT, TransitionEffect.FADE] + filter_str, duration = build_filter_complex(chains, 1280, 720, 0.5, transitions) + + self.assertIn("xfade=transition=fade", filter_str) + self.assertNotIn("concat=n=2:v=1:a=0", filter_str) + self.assertAlmostEqual(duration, 5.5) # 3 + 3 - 0.5 + + def test_string_cut_value(self): + """字符串 'cut' 也被识别为无转场。""" + chains = [self._chain(0), self._chain(1)] + transitions = ["cut", "cut"] + filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, transitions) + self.assertIn("concat=n=2:v=1:a=0[outv]", filter_str) + self.assertNotIn("xfade", filter_str) + + def test_mixed_cut_and_transition(self): + """混合 cut 和转场:走 xfade 路径。""" + chains = [self._chain(0), self._chain(1), self._chain(2)] + transitions = ["cut", TransitionEffect.FADE, "cut"] + filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, transitions) + self.assertIn("xfade", filter_str) + + def test_all_dissolve_transition(self): + """全部 dissolve 转场。""" + chains = [self._chain(0), self._chain(1)] + transitions = [TransitionEffect.CUT, TransitionEffect.DISSOLVE] + filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, transitions) + self.assertIn("xfade=transition=dissolve", filter_str) + + +# ── 集成测试:build_clip_filter + build_filter_complex 端到端 ──────────────── + + +class TestEndToEndFilterBuilding(unittest.TestCase): + """端到端集成测试:从 EditPlanClip 到完整 filter_complex。""" + + def test_two_video_clips_concat(self): + """两个视频片段走 concat 路径的完整流程。""" + clip1 = _make_clip("c1", duration=5.0, clip_type="video") + clip2 = _make_clip("c2", duration=3.0, clip_type="video") + + chain1 = build_clip_filter(clip1, 0, 1280, 720, 25) + chain2 = build_clip_filter(clip2, 1, 1280, 720, 25) + + filter_str, duration = build_filter_complex( + [chain1, chain2], + 1280, + 720, + 0.5, + [TransitionEffect.CUT, TransitionEffect.CUT], + ) + + # 有两个视频滤镜链 + self.assertIn("[0:v]", filter_str) + self.assertIn("[1:v]", filter_str) + # concat 输出 + self.assertIn("[outv]", filter_str) + self.assertIn("[outa]", filter_str) + # 总时长 + self.assertAlmostEqual(duration, 8.0) + # 结构:2视频 + 2音频 + 1视频concat + 1音频concat = 6 段 + self.assertEqual(len(filter_str.split(";")), 6) + + def test_two_clips_with_xfade(self): + """两个片段走 xfade 转场的完整流程。""" + clip1 = _make_clip("c1", duration=5.0, clip_type="video") + clip2 = _make_clip("c2", duration=4.0, clip_type="video") + + chain1 = build_clip_filter(clip1, 0, 1920, 1080, 30) + chain2 = build_clip_filter(clip2, 1, 1920, 1080, 30) + + filter_str, duration = build_filter_complex( + [chain1, chain2], + 1920, + 1080, + 0.5, + [TransitionEffect.CUT, TransitionEffect.FADE], + ) + + self.assertIn("xfade=transition=fade:duration=0.5", filter_str) + self.assertIn("[outv]", filter_str) + # 总时长减去转场 + self.assertAlmostEqual(duration, 8.5) # 5 + 4 - 0.5 + + def test_title_plus_video(self): + """title 片段(无音频)+ video 片段(有音频)。""" + clip1 = _make_clip("c1", duration=2.0, clip_type="title") + clip2 = _make_clip("c2", duration=5.0, clip_type="video") + + chain1 = build_clip_filter(clip1, 0, 1280, 720, 25) + chain2 = build_clip_filter(clip2, 1, 1280, 720, 25) + + # title 无音频,video 有音频 + self.assertIsNone(chain1.audio_label) + self.assertIsNotNone(chain2.audio_label) + + # concat 路径 + filter_str, _ = build_filter_complex( + [chain1, chain2], + 1280, + 720, + 0.5, + [TransitionEffect.CUT, TransitionEffect.CUT], + ) + # 音频 concat 只有 1 个输入(片段2) + self.assertIn("[a1]concat=n=1:v=0:a=1[outa]", filter_str) + self.assertNotIn("[a0]", filter_str) + + +if __name__ == "__main__": + unittest.main() From b99437fd81e6db8b2aa118295a2c88522080239f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 22:04:30 +0800 Subject: [PATCH 09/45] =?UTF-8?q?test(wave103):=20=E6=8A=BD=E5=8F=96clip?= =?UTF-8?q?=5Foperations=E7=BA=AF=E9=80=BB=E8=BE=91=E6=A8=A1=E5=9D=97=20+?= =?UTF-8?q?=2057=E4=B8=AA=E5=8D=95=E6=B5=8B=20(#977)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/services/edit_plan_service.py | 117 ++-- packages/domain/clip_operations.py | 267 +++++++++ tests/unit/test_clip_operations.py | 649 +++++++++++++++++++++ 3 files changed, 970 insertions(+), 63 deletions(-) create mode 100755 packages/domain/clip_operations.py create mode 100755 tests/unit/test_clip_operations.py diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index 8b8ad6f87..80a4bdb48 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -18,6 +18,13 @@ from packages.adapters.sqlalchemy_impl import ( ) from packages.domain.edit_plan import EditPlan, EditPlanStatus from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus +from packages.domain.clip_operations import ( + calculate_merge as _calc_merge, + calculate_shift_orders as _calc_shift_orders, + calculate_split as _calc_split, + validate_merge_clips as _validate_merge, + validate_split_time as _validate_split, +) logger = logging.getLogger(__name__) @@ -384,36 +391,45 @@ class EditPlanService: clip = self.get_clip_or_raise(clip_id) plan_id = clip.plan_id - if split_time <= 0 or split_time >= clip.duration: - raise ValueError(f"分割时间必须在 (0, {clip.duration:.3f}) 范围内,当前: {split_time}") + # 纯逻辑:校验 + 计算 + _validate_split(split_time, clip.duration) + split = _calc_split( + duration=clip.duration, + split_time=split_time, + start_time=clip.start_time, + ) self._auto_resume_editing(plan_id) - original_duration = clip.duration - left_duration = round(split_time, 3) - right_duration = round(original_duration - split_time, 3) original_order = clip.order # 更新左半部分(原片段) - clip.duration = left_duration + clip.duration = split.left_duration left_clip = self._clip_repo.update(clip) # 后面片段的 order 全部 +1(给右半部分腾位置) all_clips = self._clip_repo.list_by_plan(plan_id) - for c in all_clips: - if c.order > original_order and c.id != clip_id: - c.order += 1 - self._clip_repo.update(c) + shifts = _calc_shift_orders( + all_clips, + threshold_order=original_order, + shift=1, + excluded_ids={clip_id}, + id_attr="id", + order_attr="order", + ) + for c, new_order in shifts: + c.order = new_order + self._clip_repo.update(c) # 创建右半部分新片段(继承原片段的大部分属性) right_config = dict(clip.config) if clip.config else {} # 素材裁剪信息 if clip.asset_id: # 右半部分从 split_time 开始播放 - right_config["trim_start"] = left_duration + right_config["trim_start"] = split.right_trim_start # 左半部分在 split_time 处结束 left_config = dict(left_clip.config) if left_clip.config else {} - left_config["trim_end"] = right_duration + left_config["trim_end"] = split.left_trim_end left_clip.config = left_config left_clip = self._clip_repo.update(left_clip) @@ -424,8 +440,8 @@ class EditPlanService: template_clip_config_id=clip.template_clip_config_id, asset_id=clip.asset_id, text_content=clip.text_content, - start_time=clip.start_time + left_duration, - duration=right_duration, + start_time=split.right_start_time, + duration=split.right_duration, transition_effect=clip.transition_effect, transition_duration=clip.transition_duration, playback_speed=clip.playback_speed, @@ -438,8 +454,8 @@ class EditPlanService: clip_id, plan_id, split_time, - left_duration, - right_duration, + split.left_duration, + split.right_duration, ) return { @@ -468,70 +484,45 @@ class EditPlanService: clip = self.get_clip_or_raise(cid) clips.append(clip) - # 校验:同一计划 - plan_id = clips[0].plan_id - for c in clips[1:]: - if c.plan_id != plan_id: - raise ValueError("只能合并同一计划下的片段") - - # 按 order 排序 - clips.sort(key=lambda c: c.order) - - # 校验:order 连续 - for i in range(1, len(clips)): - if clips[i].order != clips[i - 1].order + 1: - raise ValueError(f"片段不连续:order {clips[i-1].order} → {clips[i].order}") - - # 校验:类型一致 - clip_type = clips[0].clip_type - for c in clips[1:]: - if c.clip_type != clip_type: - raise ValueError("只能合并相同类型的片段") + # 纯逻辑:校验 + 计算 + plan_id, first_order = _validate_merge(clips) + merge = _calc_merge(clips) self._auto_resume_editing(plan_id) - # 计算合并后的属性 - first_clip = clips[0] - total_duration = round(sum(c.duration for c in clips), 3) - first_order = first_clip.order - - # 合并文案(用换行连接) - merged_text = "\n".join(c.text_content for c in clips if c.text_content.strip()) - - # 合并 config(后面的覆盖前面的) - merged_config: Dict[str, Any] = {} - for c in clips: - if c.config: - merged_config.update(c.config) - # 清理 trim 相关字段(合并后就是完整片段了) - merged_config.pop("trim_start", None) - merged_config.pop("trim_end", None) - # 更新第一个片段(保留它作为合并结果) - first_clip.duration = total_duration - first_clip.text_content = merged_text - first_clip.config = merged_config + first_clip = sorted(clips, key=lambda c: c.order)[0] + first_clip.duration = merge.total_duration + first_clip.text_content = merge.merged_text + first_clip.config = merge.merged_config # 转场保留第一个的(合并后的入点转场) # playback_speed 取第一个的 merged_clip = self._clip_repo.update(first_clip) # 删除其余片段 - for c in clips[1:]: - self._clip_repo.delete(c.id) + rest_ids = [c.id for c in clips if c.id != merged_clip.id] + for cid in rest_ids: + self._clip_repo.delete(cid) # 后面的片段 order 前移 (len - 1) 位 - shift = len(clips) - 1 all_clips = self._clip_repo.list_by_plan(plan_id) - for c in all_clips: - if c.order > first_order and c.id != merged_clip.id: - c.order -= shift - self._clip_repo.update(c) + shifts = _calc_shift_orders( + all_clips, + threshold_order=first_order, + shift=-merge.shift_amount, + excluded_ids={merged_clip.id}, + id_attr="id", + order_attr="order", + ) + for c, new_order in shifts: + c.order = new_order + self._clip_repo.update(c) logger.info( "合并片段: plan_id=%s count=%d total_duration=%.3fs", plan_id, len(clips), - total_duration, + merge.total_duration, ) return merged_clip diff --git a/packages/domain/clip_operations.py b/packages/domain/clip_operations.py new file mode 100755 index 000000000..ebfdddb66 --- /dev/null +++ b/packages/domain/clip_operations.py @@ -0,0 +1,267 @@ +"""片段操作工具 — EditPlanClip 分割/合并等纯逻辑操作。 + +从 edit_plan_service.py 抽离的纯函数集合,专门负责: +- 片段分割:将一个片段从指定位置拆分为两个 +- 片段合并:将多个连续片段合并为一个 +- Order 重排计算 + +所有函数均为纯函数,不依赖数据库或外部 IO。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +# ── 常量 ────────────────────────────────────────────────────────────────────── + +DEFAULT_SPLIT_DURATION = 5.0 +ROUND_PRECISION = 3 + + +# ── 数据结构 ────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class SplitResult: + """片段分割结果。""" + + left_duration: float + right_duration: float + right_start_time: float + left_trim_end: float + right_trim_start: float + + +@dataclass(frozen=True) +class MergeResult: + """片段合并结果。""" + + total_duration: float + merged_text: str + merged_config: dict[str, Any] + first_order: int + shift_amount: int + + +# ── 分割 ────────────────────────────────────────────────────────────────────── + + +def validate_split_time(split_time: float, duration: float) -> None: + """校验分割时间是否合法。 + + Args: + split_time: 分割点(秒) + duration: 原片段时长(秒) + + Raises: + ValueError: 分割时间不在 (0, duration) 范围内 + """ + if split_time <= 0 or split_time >= duration: + raise ValueError(f"分割时间必须在 (0, {duration:.3f}) 范围内,当前: {split_time}") + + +def calculate_split( + duration: float, + split_time: float, + start_time: float = 0.0, + *, + precision: int = ROUND_PRECISION, +) -> SplitResult: + """计算片段分割后的各项参数。 + + 左半部分:从 0 到 split_time + 右半部分:从 split_time 到 duration + + Args: + duration: 原片段时长(秒) + split_time: 分割点(秒) + start_time: 原片段起始时间(秒),右半部分 start_time 需要加上 left_duration + precision: 小数精度(默认 3 位,即毫秒) + + Returns: + SplitResult 包含左右部分的时长、右半部分 start_time、trim 信息 + """ + validate_split_time(split_time, duration) + + left_duration = round(split_time, precision) + right_duration = round(duration - split_time, precision) + right_start_time = round(start_time + left_duration, precision) + + return SplitResult( + left_duration=left_duration, + right_duration=right_duration, + right_start_time=right_start_time, + left_trim_end=right_duration, + right_trim_start=left_duration, + ) + + +# ── 合并 ────────────────────────────────────────────────────────────────────── + + +def validate_merge_clips(clips: list[Any]) -> tuple[str, int]: + """校验待合并的片段列表。 + + 校验项: + 1. 至少 2 个片段 + 2. 属于同一计划 + 3. order 连续 + 4. 类型一致 + + Args: + clips: 按任意顺序排列的片段列表(会自动按 order 排序) + + Returns: + (plan_id, first_order) 元组 + + Raises: + ValueError: 校验失败 + """ + if len(clips) < 2: + raise ValueError("至少需要 2 个片段才能合并") + + # 校验:同一计划 + plan_id = clips[0].plan_id + for c in clips[1:]: + if c.plan_id != plan_id: + raise ValueError("只能合并同一计划下的片段") + + # 按 order 排序 + sorted_clips = sorted(clips, key=lambda c: c.order) + + # 校验:order 连续 + for i in range(1, len(sorted_clips)): + if sorted_clips[i].order != sorted_clips[i - 1].order + 1: + raise ValueError(f"片段不连续:order {sorted_clips[i-1].order} → {sorted_clips[i].order}") + + # 校验:类型一致 + clip_type = sorted_clips[0].clip_type + for c in sorted_clips[1:]: + if c.clip_type != clip_type: + raise ValueError("只能合并相同类型的片段") + + return plan_id, sorted_clips[0].order + + +def calculate_merge( + clips: list[Any], + *, + precision: int = ROUND_PRECISION, +) -> MergeResult: + """计算多个片段合并后的参数。 + + 合并规则: + - 时长:所有片段时长之和 + - 文案:用换行连接非空文案 + - config:后面的覆盖前面的,移除 trim_start/trim_end + - first_order:第一个片段的 order + - shift_amount:合并后 order 前移位数(n-1) + + Args: + clips: 待合并片段列表(会自动按 order 排序) + precision: 时长精度(默认 3 位) + + Returns: + MergeResult 合并结果 + """ + if not clips: + raise ValueError("合并的片段列表不能为空") + + # 按 order 排序 + sorted_clips = sorted(clips, key=lambda c: c.order) + + # 总时长 + total_duration = round(sum(c.duration for c in sorted_clips), precision) + + # 合并文案 + merged_text = "\n".join(c.text_content for c in sorted_clips if c.text_content and c.text_content.strip()) + + # 合并 config(后面的覆盖前面的) + merged_config: dict[str, Any] = {} + for c in sorted_clips: + if c.config: + merged_config.update(c.config) + # 清理 trim 相关字段(合并后就是完整片段了) + merged_config.pop("trim_start", None) + merged_config.pop("trim_end", None) + + first_order = sorted_clips[0].order + shift_amount = len(sorted_clips) - 1 + + return MergeResult( + total_duration=total_duration, + merged_text=merged_text, + merged_config=merged_config, + first_order=first_order, + shift_amount=shift_amount, + ) + + +# ── Order 重排 ─────────────────────────────────────────────────────────────── + + +def calculate_reorder_new_orders( + ordered_ids: list[str], + current_items: list[Any], + *, + id_attr: str = "id", + order_attr: str = "order", +) -> dict[str, int]: + """根据新顺序计算每个 item 的新 order 值。 + + Args: + ordered_ids: 按新顺序排列的 ID 列表 + current_items: 当前所有 item 列表 + id_attr: ID 属性名 + order_attr: order 属性名 + + Returns: + {item_id: new_order} 映射 + + Raises: + ValueError: ID 列表与当前 items 不匹配 + """ + current_ids = {getattr(c, id_attr) for c in current_items} + ordered_id_set = set(ordered_ids) + + if ordered_id_set != current_ids: + raise ValueError("ID 列表与当前 items 不匹配") + + return {item_id: idx for idx, item_id in enumerate(ordered_ids)} + + +def calculate_shift_orders( + items: list[Any], + threshold_order: int, + shift: int, + *, + excluded_ids: set[str] | None = None, + order_attr: str = "order", + id_attr: str = "id", +) -> list[tuple[Any, int]]: + """计算 order 需要偏移的 items 及新 order 值。 + + Args: + items: 所有 item 列表 + threshold_order: 只处理 order > threshold_order 的 item + shift: 偏移量(正数加,负数减) + excluded_ids: 排除的 ID 集合 + order_attr: order 属性名 + id_attr: ID 属性名 + + Returns: + [(item, new_order), ...] 列表 + """ + excluded = excluded_ids or set() + result: list[tuple[Any, int]] = [] + + for item in items: + item_id = getattr(item, id_attr) + if item_id in excluded: + continue + current_order = getattr(item, order_attr) + if current_order > threshold_order: + result.append((item, current_order + shift)) + + return result diff --git a/tests/unit/test_clip_operations.py b/tests/unit/test_clip_operations.py new file mode 100755 index 000000000..01be94b59 --- /dev/null +++ b/tests/unit/test_clip_operations.py @@ -0,0 +1,649 @@ +"""clip_operations 单元测试 — 片段分割/合并纯逻辑层。 + +覆盖: +- validate_split_time: 分割时间校验 +- calculate_split: 分割参数计算 +- validate_merge_clips: 合并校验 +- calculate_merge: 合并参数计算 +- calculate_reorder_new_orders: 重排 order 计算 +- calculate_shift_orders: order 偏移计算 +""" + +from __future__ import annotations + +import unittest +from dataclasses import dataclass + +from packages.domain.clip_operations import ( + MergeResult, + ROUND_PRECISION, + SplitResult, + calculate_merge, + calculate_reorder_new_orders, + calculate_shift_orders, + calculate_split, + validate_merge_clips, + validate_split_time, +) + +# ── Mock Clip ──────────────────────────────────────────────────────────────── + + +@dataclass +class _MockClip: + id: str = "c1" + plan_id: str = "p1" + order: int = 0 + clip_type: str = "main" + duration: float = 5.0 + start_time: float = 0.0 + text_content: str = "" + config: dict | None = None + + +# ── validate_split_time 测试 ───────────────────────────────────────────────── + + +class TestValidateSplitTime(unittest.TestCase): + """validate_split_time 分割时间校验测试。""" + + def test_valid_split(self): + """合法分割时间不报错。""" + validate_split_time(2.5, 5.0) # 不抛异常 + + def test_split_at_zero(self): + """分割时间为 0 时报错。""" + with self.assertRaises(ValueError) as ctx: + validate_split_time(0.0, 5.0) + self.assertIn("分割时间", str(ctx.exception)) + + def test_split_negative(self): + """分割时间为负数时报错。""" + with self.assertRaises(ValueError): + validate_split_time(-1.0, 5.0) + + def test_split_at_duration(self): + """分割时间等于 duration 时报错。""" + with self.assertRaises(ValueError): + validate_split_time(5.0, 5.0) + + def test_split_over_duration(self): + """分割时间超过 duration 时报错。""" + with self.assertRaises(ValueError): + validate_split_time(6.0, 5.0) + + def test_split_very_small(self): + """很小的正数是合法的。""" + validate_split_time(0.001, 5.0) # 不抛异常 + + def test_split_just_below_duration(self): + """略小于 duration 是合法的。""" + validate_split_time(4.999, 5.0) # 不抛异常 + + def test_error_message_contains_duration(self): + """错误消息包含 duration 值。""" + with self.assertRaises(ValueError) as ctx: + validate_split_time(6.0, 5.0) + self.assertIn("5.000", str(ctx.exception)) + + +# ── calculate_split 测试 ───────────────────────────────────────────────────── + + +class TestCalculateSplit(unittest.TestCase): + """calculate_split 分割计算测试。""" + + def test_middle_split(self): + """从中间分割。""" + result = calculate_split(duration=10.0, split_time=5.0) + self.assertIsInstance(result, SplitResult) + self.assertEqual(result.left_duration, 5.0) + self.assertEqual(result.right_duration, 5.0) + self.assertEqual(result.right_start_time, 5.0) + self.assertEqual(result.left_trim_end, 5.0) + self.assertEqual(result.right_trim_start, 5.0) + + def test_early_split(self): + """从开头附近分割。""" + result = calculate_split(duration=10.0, split_time=2.0) + self.assertEqual(result.left_duration, 2.0) + self.assertEqual(result.right_duration, 8.0) + self.assertEqual(result.right_start_time, 2.0) + + def test_late_split(self): + """从结尾附近分割。""" + result = calculate_split(duration=10.0, split_time=8.0) + self.assertEqual(result.left_duration, 8.0) + self.assertEqual(result.right_duration, 2.0) + + def test_with_start_time_offset(self): + """带 start_time 偏移。""" + result = calculate_split(duration=5.0, split_time=2.0, start_time=10.0) + self.assertEqual(result.left_duration, 2.0) + self.assertEqual(result.right_duration, 3.0) + self.assertEqual(result.right_start_time, 12.0) + + def test_zero_start_time(self): + """start_time 为 0 时 right_start_time 等于 left_duration。""" + result = calculate_split(duration=5.0, split_time=2.0, start_time=0.0) + self.assertEqual(result.right_start_time, result.left_duration) + + def test_round_to_precision(self): + """结果精度符合设置。""" + result = calculate_split(duration=1.0, split_time=1 / 3, precision=3) + # 1/3 ≈ 0.333(3位精度) + self.assertAlmostEqual(result.left_duration, 0.333, places=3) + self.assertAlmostEqual(result.right_duration, 0.667, places=3) + + def test_default_precision_3(self): + """默认精度为 3 位小数。""" + self.assertEqual(ROUND_PRECISION, 3) + result = calculate_split(duration=1.0, split_time=0.333333) + # 默认用 ROUND_PRECISION = 3 + self.assertEqual(result.left_duration, 0.333) + + def test_custom_precision(self): + """自定义精度。""" + result = calculate_split(duration=1.0, split_time=0.123456, precision=5) + self.assertEqual(result.left_duration, 0.12346) + self.assertEqual(result.right_duration, 0.87654) + + def test_invalid_split_time_raises(self): + """不合法的分割时间抛出 ValueError。""" + with self.assertRaises(ValueError): + calculate_split(duration=5.0, split_time=0.0) + with self.assertRaises(ValueError): + calculate_split(duration=5.0, split_time=5.0) + with self.assertRaises(ValueError): + calculate_split(duration=5.0, split_time=-1.0) + + def test_trim_values_match_durations(self): + """trim 值与对应时长一致。""" + result = calculate_split(duration=7.5, split_time=3.0) + self.assertEqual(result.right_trim_start, result.left_duration) + self.assertEqual(result.left_trim_end, result.right_duration) + + def test_frozen_result(self): + """SplitResult 是 frozen dataclass。""" + result = calculate_split(duration=5.0, split_time=2.0) + with self.assertRaises(Exception): + result.left_duration = 3.0 # type: ignore[misc] + + +# ── validate_merge_clips 测试 ──────────────────────────────────────────────── + + +class TestValidateMergeClips(unittest.TestCase): + """validate_merge_clips 合并校验测试。""" + + def test_two_consecutive_clips(self): + """两个连续片段:校验通过。""" + clips = [ + _MockClip(id="c1", plan_id="p1", order=0, clip_type="main"), + _MockClip(id="c2", plan_id="p1", order=1, clip_type="main"), + ] + plan_id, first_order = validate_merge_clips(clips) + self.assertEqual(plan_id, "p1") + self.assertEqual(first_order, 0) + + def test_three_consecutive_clips(self): + """三个连续片段:校验通过。""" + clips = [ + _MockClip(id="c1", plan_id="p1", order=2, clip_type="main"), + _MockClip(id="c2", plan_id="p1", order=3, clip_type="main"), + _MockClip(id="c3", plan_id="p1", order=4, clip_type="main"), + ] + plan_id, first_order = validate_merge_clips(clips) + self.assertEqual(plan_id, "p1") + self.assertEqual(first_order, 2) + + def test_unordered_input(self): + """输入顺序不影响校验(自动排序)。""" + clips = [ + _MockClip(id="c2", plan_id="p1", order=1, clip_type="main"), + _MockClip(id="c1", plan_id="p1", order=0, clip_type="main"), + ] + plan_id, first_order = validate_merge_clips(clips) + self.assertEqual(plan_id, "p1") + self.assertEqual(first_order, 0) + + def test_single_clip_raises(self): + """只有一个片段报错。""" + clips = [_MockClip(id="c1", plan_id="p1", order=0)] + with self.assertRaises(ValueError) as ctx: + validate_merge_clips(clips) + self.assertIn("至少需要 2 个", str(ctx.exception)) + + def test_empty_clips_raises(self): + """空列表报错。""" + with self.assertRaises(ValueError): + validate_merge_clips([]) + + def test_different_plan_raises(self): + """不同计划的片段报错。""" + clips = [ + _MockClip(id="c1", plan_id="p1", order=0, clip_type="main"), + _MockClip(id="c2", plan_id="p2", order=1, clip_type="main"), + ] + with self.assertRaises(ValueError) as ctx: + validate_merge_clips(clips) + self.assertIn("同一计划", str(ctx.exception)) + + def test_non_consecutive_raises(self): + """不连续的片段报错。""" + clips = [ + _MockClip(id="c1", plan_id="p1", order=0, clip_type="main"), + _MockClip(id="c2", plan_id="p1", order=2, clip_type="main"), + ] + with self.assertRaises(ValueError) as ctx: + validate_merge_clips(clips) + self.assertIn("不连续", str(ctx.exception)) + + def test_different_type_raises(self): + """类型不同报错。""" + clips = [ + _MockClip(id="c1", plan_id="p1", order=0, clip_type="main"), + _MockClip(id="c2", plan_id="p1", order=1, clip_type="title"), + ] + with self.assertRaises(ValueError) as ctx: + validate_merge_clips(clips) + self.assertIn("相同类型", str(ctx.exception)) + + def test_gap_in_middle_raises(self): + """中间有间隔报错(三个片段中间缺一个)。""" + clips = [ + _MockClip(id="c1", plan_id="p1", order=0, clip_type="main"), + _MockClip(id="c2", plan_id="p1", order=1, clip_type="main"), + _MockClip(id="c3", plan_id="p1", order=3, clip_type="main"), + ] + with self.assertRaises(ValueError): + validate_merge_clips(clips) + + +# ── calculate_merge 测试 ───────────────────────────────────────────────────── + + +class TestCalculateMerge(unittest.TestCase): + """calculate_merge 合并计算测试。""" + + def test_two_clips_total_duration(self): + """两个片段:总时长相加。""" + clips = [ + _MockClip(id="c1", order=0, duration=5.0), + _MockClip(id="c2", order=1, duration=3.0), + ] + result = calculate_merge(clips) + self.assertIsInstance(result, MergeResult) + self.assertEqual(result.total_duration, 8.0) + self.assertEqual(result.first_order, 0) + self.assertEqual(result.shift_amount, 1) + + def test_three_clips_total_duration(self): + """三个片段:总时长相加。""" + clips = [ + _MockClip(id="c1", order=2, duration=2.0), + _MockClip(id="c2", order=3, duration=3.5), + _MockClip(id="c3", order=4, duration=4.5), + ] + result = calculate_merge(clips) + self.assertEqual(result.total_duration, 10.0) + self.assertEqual(result.first_order, 2) + self.assertEqual(result.shift_amount, 2) + + def test_unordered_input(self): + """输入顺序不影响结果(自动按 order 排序)。""" + clips = [ + _MockClip(id="c2", order=1, duration=3.0), + _MockClip(id="c1", order=0, duration=5.0), + ] + result = calculate_merge(clips) + self.assertEqual(result.total_duration, 8.0) + self.assertEqual(result.first_order, 0) + + def test_merge_text_newline_join(self): + """文案用换行连接,跳过空字符串。""" + clips = [ + _MockClip(id="c1", order=0, duration=2.0, text_content="第一段"), + _MockClip(id="c2", order=1, duration=3.0, text_content="第二段"), + ] + result = calculate_merge(clips) + self.assertEqual(result.merged_text, "第一段\n第二段") + + def test_merge_text_skip_empty(self): + """空文案或纯空格被跳过。""" + clips = [ + _MockClip(id="c1", order=0, duration=2.0, text_content=""), + _MockClip(id="c2", order=1, duration=3.0, text_content="有内容"), + _MockClip(id="c3", order=2, duration=1.0, text_content=" "), + ] + result = calculate_merge(clips) + self.assertEqual(result.merged_text, "有内容") + + def test_all_empty_text(self): + """所有文案都空时合并结果为空字符串。""" + clips = [ + _MockClip(id="c1", order=0, duration=2.0, text_content=""), + _MockClip(id="c2", order=1, duration=3.0, text_content=" "), + ] + result = calculate_merge(clips) + self.assertEqual(result.merged_text, "") + + def test_merge_config_later_overwrites(self): + """后面的 config 覆盖前面的。""" + clips = [ + _MockClip( + id="c1", + order=0, + duration=2.0, + config={"color": "red", "speed": 1.0}, + ), + _MockClip( + id="c2", + order=1, + duration=3.0, + config={"color": "blue", "filter": "vintage"}, + ), + ] + result = calculate_merge(clips) + self.assertEqual(result.merged_config["color"], "blue") # 后面的覆盖 + self.assertEqual(result.merged_config["speed"], 1.0) # 保留前面的 + self.assertEqual(result.merged_config["filter"], "vintage") # 新增的 + + def test_merge_config_none_handled(self): + """config 为 None 时正常处理。""" + clips = [ + _MockClip(id="c1", order=0, duration=2.0, config=None), + _MockClip(id="c2", order=1, duration=3.0, config={"key": "value"}), + ] + result = calculate_merge(clips) + self.assertEqual(result.merged_config["key"], "value") + + def test_trim_fields_removed(self): + """trim_start 和 trim_end 被移除(合并后是完整片段)。""" + clips = [ + _MockClip( + id="c1", + order=0, + duration=2.0, + config={"trim_end": 2.0, "color": "red"}, + ), + _MockClip( + id="c2", + order=1, + duration=3.0, + config={"trim_start": 1.0, "speed": 1.5}, + ), + ] + result = calculate_merge(clips) + self.assertNotIn("trim_start", result.merged_config) + self.assertNotIn("trim_end", result.merged_config) + self.assertEqual(result.merged_config["color"], "red") + self.assertEqual(result.merged_config["speed"], 1.5) + + def test_empty_clips_raises(self): + """空列表报错。""" + with self.assertRaises(ValueError): + calculate_merge([]) + + def test_single_clip_merge(self): + """单个片段也能计算(虽然 validate 会限制,但函数本身支持)。""" + clips = [_MockClip(id="c1", order=5, duration=5.0, text_content="唯一")] + result = calculate_merge(clips) + self.assertEqual(result.total_duration, 5.0) + self.assertEqual(result.first_order, 5) + self.assertEqual(result.shift_amount, 0) + self.assertEqual(result.merged_text, "唯一") + + def test_round_precision(self): + """时长精度符合设置。""" + clips = [ + _MockClip(id="c1", order=0, duration=1 / 3), + _MockClip(id="c2", order=1, duration=1 / 3), + ] + result = calculate_merge(clips, precision=3) + self.assertEqual(result.total_duration, 0.667) + + def test_frozen_result(self): + """MergeResult 是 frozen dataclass。""" + clips = [ + _MockClip(id="c1", order=0, duration=2.0), + _MockClip(id="c2", order=1, duration=3.0), + ] + result = calculate_merge(clips) + with self.assertRaises(Exception): + result.total_duration = 10.0 # type: ignore[misc] + + +# ── calculate_reorder_new_orders 测试 ──────────────────────────────────────── + + +class TestCalculateReorderNewOrders(unittest.TestCase): + """calculate_reorder_new_orders 重排计算测试。""" + + def test_two_items_swap(self): + """两个元素交换顺序。""" + items = [ + _MockClip(id="a", order=0), + _MockClip(id="b", order=1), + ] + result = calculate_reorder_new_orders(["b", "a"], items) + self.assertEqual(result["b"], 0) + self.assertEqual(result["a"], 1) + + def test_three_items_reorder(self): + """三个元素重新排序。""" + items = [ + _MockClip(id="a", order=0), + _MockClip(id="b", order=1), + _MockClip(id="c", order=2), + ] + result = calculate_reorder_new_orders(["c", "a", "b"], items) + self.assertEqual(result["c"], 0) + self.assertEqual(result["a"], 1) + self.assertEqual(result["b"], 2) + + def test_same_order(self): + """顺序不变。""" + items = [ + _MockClip(id="a", order=0), + _MockClip(id="b", order=1), + ] + result = calculate_reorder_new_orders(["a", "b"], items) + self.assertEqual(result["a"], 0) + self.assertEqual(result["b"], 1) + + def test_mismatched_ids_raises(self): + """ID 不匹配报错。""" + items = [ + _MockClip(id="a", order=0), + _MockClip(id="b", order=1), + ] + with self.assertRaises(ValueError) as ctx: + calculate_reorder_new_orders(["a", "c"], items) + self.assertIn("不匹配", str(ctx.exception)) + + def test_extra_id_in_list_raises(self): + """有序列表多出 ID 报错。""" + items = [_MockClip(id="a", order=0)] + with self.assertRaises(ValueError): + calculate_reorder_new_orders(["a", "b"], items) + + def test_missing_id_raises(self): + """有序列表缺少 ID 报错。""" + items = [ + _MockClip(id="a", order=0), + _MockClip(id="b", order=1), + ] + with self.assertRaises(ValueError): + calculate_reorder_new_orders(["a"], items) + + def test_custom_attr_names(self): + """自定义属性名。""" + + @dataclass + class _Item: + key: str + pos: int + + items = [_Item(key="x", pos=0), _Item(key="y", pos=1)] + result = calculate_reorder_new_orders(["y", "x"], items, id_attr="key", order_attr="pos") + self.assertEqual(result["y"], 0) + self.assertEqual(result["x"], 1) + + +# ── calculate_shift_orders 测试 ────────────────────────────────────────────── + + +class TestCalculateShiftOrders(unittest.TestCase): + """calculate_shift_orders order 偏移计算测试。""" + + def test_shift_positive(self): + """正偏移:order 增加。""" + items = [ + _MockClip(id="c1", order=0), + _MockClip(id="c2", order=1), + _MockClip(id="c3", order=2), + ] + result = calculate_shift_orders(items, threshold_order=0, shift=1) + shifted = {c.id: new_order for c, new_order in result} + # order > 0 的才会被偏移 + self.assertEqual(len(shifted), 2) + self.assertEqual(shifted["c2"], 2) + self.assertEqual(shifted["c3"], 3) + self.assertNotIn("c1", shifted) + + def test_shift_negative(self): + """负偏移:order 减少。""" + items = [ + _MockClip(id="c1", order=0), + _MockClip(id="c2", order=2), + _MockClip(id="c3", order=3), + ] + result = calculate_shift_orders(items, threshold_order=1, shift=-1) + shifted = {c.id: new_order for c, new_order in result} + self.assertEqual(shifted["c2"], 1) + self.assertEqual(shifted["c3"], 2) + self.assertNotIn("c1", shifted) + + def test_excluded_ids(self): + """排除指定 ID。""" + items = [ + _MockClip(id="c1", order=0), + _MockClip(id="c2", order=1), + _MockClip(id="c3", order=2), + ] + result = calculate_shift_orders( + items, + threshold_order=0, + shift=1, + excluded_ids={"c2"}, + ) + shifted = {c.id: new_order for c, new_order in result} + self.assertNotIn("c1", shifted) + self.assertNotIn("c2", shifted) # 被排除 + self.assertEqual(shifted["c3"], 3) + + def test_no_items_above_threshold(self): + """没有元素高于阈值时返回空。""" + items = [ + _MockClip(id="c1", order=0), + _MockClip(id="c2", order=1), + ] + result = calculate_shift_orders(items, threshold_order=5, shift=1) + self.assertEqual(result, []) + + def test_zero_shift(self): + """偏移量为 0 时仍返回(虽然 order 不变)。""" + items = [ + _MockClip(id="c1", order=0), + _MockClip(id="c2", order=1), + ] + result = calculate_shift_orders(items, threshold_order=0, shift=0) + shifted = {c.id: new_order for c, new_order in result} + self.assertEqual(shifted["c2"], 1) # 1 + 0 = 1 + + def test_threshold_exclusive(self): + """阈值是严格大于(不包含等于)。""" + items = [ + _MockClip(id="c1", order=5), # 等于 threshold,不偏移 + _MockClip(id="c2", order=6), # 大于 threshold,偏移 + ] + result = calculate_shift_orders(items, threshold_order=5, shift=2) + shifted = {c.id: new_order for c, new_order in result} + self.assertNotIn("c1", shifted) + self.assertEqual(shifted["c2"], 8) + + def test_empty_items(self): + """空 items 返回空列表。""" + result = calculate_shift_orders([], threshold_order=0, shift=1) + self.assertEqual(result, []) + + +# ── 集成测试 ──────────────────────────────────────────────────────────────── + + +class TestEndToEndClipOperations(unittest.TestCase): + """端到端集成测试:完整分割+重排流程。""" + + def test_split_then_shift(self): + """分割一个片段后,后面的片段 order +1。""" + # 模拟:有3个片段,分割第1个(order=1),后面的+1 + clips = [ + _MockClip(id="c1", order=0, duration=3.0), + _MockClip(id="c2", order=1, duration=5.0), + _MockClip(id="c3", order=2, duration=4.0), + ] + + # 计算分割 + split = calculate_split(duration=5.0, split_time=2.0) + + # 后面的片段 order +1 + shift_result = calculate_shift_orders( + clips, + threshold_order=1, + shift=1, + excluded_ids={"c2"}, + ) + + shifted = {c.id: new_order for c, new_order in shift_result} + self.assertEqual(shifted["c3"], 3) # 2+1 + self.assertNotIn("c1", shifted) # order <= 1 + self.assertNotIn("c2", shifted) # 被排除 + + # 左半部分保留 order=1,右半部分 order=2 + self.assertEqual(split.left_duration, 2.0) + self.assertEqual(split.right_duration, 3.0) + + def test_merge_then_shift(self): + """合并两个片段后,后面的片段 order -1。""" + clips = [ + _MockClip(id="c1", order=0, duration=3.0, text_content="A"), + _MockClip(id="c2", order=1, duration=2.0, text_content="B"), + _MockClip(id="c3", order=2, duration=4.0), + _MockClip(id="c4", order=3, duration=1.0), + ] + + # 校验合并 + validate_merge_clips(clips[:2]) + + # 计算合并 + merge = calculate_merge(clips[:2]) + self.assertEqual(merge.total_duration, 5.0) + self.assertEqual(merge.merged_text, "A\nB") + self.assertEqual(merge.shift_amount, 1) + + # 后面的片段前移 1 位 + shift_result = calculate_shift_orders( + clips, + threshold_order=0, # order > 0 的 + shift=-merge.shift_amount, + excluded_ids={"c1", "c2"}, + ) + + shifted = {c.id: new_order for c, new_order in shift_result} + self.assertEqual(shifted["c3"], 1) # 2-1 + self.assertEqual(shifted["c4"], 2) # 3-1 + + +if __name__ == "__main__": + unittest.main() From 8e2c563b421e60d4847979fcbe914a8649656cee Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 22:06:12 +0800 Subject: [PATCH 10/45] =?UTF-8?q?refactor(template-library):=20=E4=B8=89?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E9=87=8D=E6=9E=84=20TemplateLibrary=EF=BC=88?= =?UTF-8?q?716=E2=86=92107=E8=A1=8C,=20-85%=EF=BC=89(#931)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/templates/TemplateLibrary.tsx | 751 ++---------------- .../template-library/TemplateCard.tsx | 87 ++ .../template-library/TemplateDetailModal.tsx | 219 +++++ .../template-library/TemplateGrid.tsx | 110 +++ .../template-library/TemplateHeader.tsx | 20 + .../template-library/TemplateToolbar.tsx | 63 ++ .../templates/constants/templateLibrary.ts | 46 ++ .../templates/hooks/useTemplateDetail.ts | 62 ++ .../templates/hooks/useTemplateLibrary.ts | 147 ++++ .../pages/templates/types/templateLibrary.ts | 15 + .../pages/templates/utils/templateLibrary.ts | 54 ++ .../src/test/pages/TemplateLibrary.test.tsx | 10 + 12 files changed, 904 insertions(+), 680 deletions(-) create mode 100644 apps/web/src/pages/templates/components/template-library/TemplateCard.tsx create mode 100644 apps/web/src/pages/templates/components/template-library/TemplateDetailModal.tsx create mode 100644 apps/web/src/pages/templates/components/template-library/TemplateGrid.tsx create mode 100644 apps/web/src/pages/templates/components/template-library/TemplateHeader.tsx create mode 100644 apps/web/src/pages/templates/components/template-library/TemplateToolbar.tsx create mode 100644 apps/web/src/pages/templates/constants/templateLibrary.ts create mode 100644 apps/web/src/pages/templates/hooks/useTemplateDetail.ts create mode 100644 apps/web/src/pages/templates/hooks/useTemplateLibrary.ts create mode 100644 apps/web/src/pages/templates/types/templateLibrary.ts create mode 100644 apps/web/src/pages/templates/utils/templateLibrary.ts diff --git a/apps/web/src/pages/templates/TemplateLibrary.tsx b/apps/web/src/pages/templates/TemplateLibrary.tsx index 9be26dafc..394038428 100644 --- a/apps/web/src/pages/templates/TemplateLibrary.tsx +++ b/apps/web/src/pages/templates/TemplateLibrary.tsx @@ -6,708 +6,99 @@ * - 复制模板 / 从模板生成 * - 卡片网格布局 + 类型筛选 + 搜索 + 收藏 */ -import React, { useState, useMemo, useCallback } from "react" -import { useNavigate } from "react-router-dom" -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { Button, message, Pagination, Tooltip, Tag, Descriptions } from "antd" -import { - LoadingOutlined, - ExclamationCircleOutlined, - InboxOutlined, - SearchOutlined, - CopyOutlined, - ThunderboltOutlined, -} from "@ant-design/icons" -import { - getTemplates, - getTemplate, - toggleFavoriteTemplate, - copyTemplate, - type TemplateItem, - type TemplateListParams, - type TemplateSegment, -} from "@/api/templates" +import React from "react" +import { useTemplateLibrary } from "./hooks/useTemplateLibrary" +import { useTemplateDetail } from "./hooks/useTemplateDetail" +import { TemplateHeader } from "./components/template-library/TemplateHeader" +import { TemplateToolbar } from "./components/template-library/TemplateToolbar" +import { TemplateGrid } from "./components/template-library/TemplateGrid" +import { TemplateDetailModal } from "./components/template-library/TemplateDetailModal" import "./templates.css" -/* ============================================================ - * 类型定义 - * ============================================================ */ - -/** 模板类型 */ -type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog" - -/* ============================================================ - * 模板类型配置 - * ============================================================ */ - -const TEMPLATE_TYPES: Array<{ - type: EditTemplateType | "全部" - label: string - icon: string - color: string -}> = [ - { type: "全部", label: "全部", icon: "📋", color: "#6366f1" }, - { type: "口播", label: "口播", icon: "🎙️", color: "#6366f1" }, - { type: "种草", label: "种草", icon: "🌱", color: "#10b981" }, - { type: "产品", label: "产品", icon: "📦", color: "#0ea5e9" }, - { type: "品牌", label: "品牌", icon: "🏷️", color: "#f59e0b" }, - { type: "混剪", label: "混剪", icon: "🎬", color: "#8b5cf6" }, - { type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" }, -] - -/** 时长筛选选项 */ -const DURATION_OPTIONS: Array<{ - value: "" | "short" | "medium" | "long" - label: string -}> = [ - { value: "", label: "全部时长" }, - { value: "short", label: "30秒以内" }, - { value: "medium", label: "30秒-2分钟" }, - { value: "long", label: "2分钟以上" }, -] - -/* ============================================================ - * 辅助函数 - * ============================================================ */ - -/** 获取类型对应颜色 */ -const getTypeColor = (type: string): string => { - const found = TEMPLATE_TYPES.find((t) => t.type === type) - return found?.color ?? "#6366f1" -} - -/** 根据 category 生成占位渐变色 */ -const gradientForCategory = (category: string): string => { - const gradients: Record = { - 口播: "linear-gradient(135deg, #6366f1, #8b5cf6)", - 种草: "linear-gradient(135deg, #10b981, #059669)", - 产品: "linear-gradient(135deg, #0ea5e9, #0284c7)", - 品牌: "linear-gradient(135deg, #f59e0b, #d97706)", - 混剪: "linear-gradient(135deg, #8b5cf6, #6d28d9)", - Vlog: "linear-gradient(135deg, #ec4899, #db2777)", - } - return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)" -} - -/** 格式化时长 */ -const formatDuration = (seconds: number | undefined | null): string => { - if (!seconds || seconds <= 0) return "0秒" - const totalSec = Math.round(seconds) - const m = Math.floor(totalSec / 60) - const s = totalSec % 60 - if (m === 0) return `${s}秒` - return `${m}分${s > 0 ? `${s}秒` : ""}` -} - -/** 配置展示字段(formatConfig 提取通用配置的可读属性) */ -interface ConfigDisplayFields { - font_size?: string | number - font_family?: string - color?: string - position?: string - volume?: string | number - name?: string -} - -/** 格式化配置对象为可读文本 */ -const formatConfig = (config?: object): string => { - if (!config || Object.keys(config).length === 0) return "默认" - const c = config as ConfigDisplayFields - const parts: string[] = [] - if (c.font_size) parts.push(`字号: ${c.font_size}`) - if (c.font_family) parts.push(`字体: ${c.font_family}`) - if (c.color) parts.push(`颜色: ${c.color}`) - if (c.position) parts.push(`位置: ${c.position}`) - if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`) - if (c.name) parts.push(String(c.name)) - return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config) -} - -/** 素材类型标签 */ -const MATERIAL_TYPE_LABELS: Record = { - video: "视频", - image: "图片", - audio: "音频", - voiceover: "配音", - subtitle: "字幕", - null: "不限", -} - -/* ============================================================ - * 模板详情弹窗组件 - * ============================================================ */ - -interface TemplateDetailModalProps { - template: TemplateItem - isFavorite: boolean - onClose: () => void - onToggleFavorite: (id: string) => void - onUse: (template: TemplateItem) => void - onCopy: (template: TemplateItem) => void -} - -const TemplateDetailModal: React.FC = ({ - template, - isFavorite, - onClose, - onToggleFavorite, - onUse, - onCopy, -}) => { - const segments = template.segments ?? [] - const totalSegmentDuration = segments.reduce( - (sum, s) => sum + (s.duration_min + s.duration_max) / 2, - 0, - ) - - return ( -
-
e.stopPropagation()} - > - {/* 关闭按钮 */} - - - {/* 预览区域 */} -
- {template.thumbnail_url ? ( - {template.name} - ) : ( -
- - {TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"} - - {template.name} -
- )} -
- - {/* 内容区域 */} -
- {/* 标题行 */} -
-

{template.name}

- - {TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category} - -
- - {/* 描述 */} -

{template.description}

- - {/* 标签 */} - {(template.tags?.length ?? 0) > 0 && ( -
- {template.tags!.map((tag) => ( - - #{tag} - - ))} -
- )} - - {/* 基本信息 */} - - - {/* 素材规则(片段配置) */} - {segments.length > 0 && ( -
-

🎬 素材规则

-
- {segments - .sort((a, b) => a.segment_order - b.segment_order) - .map((seg: TemplateSegment, idx: number) => ( -
- #{seg.segment_order} - - {MATERIAL_TYPE_LABELS[seg.material_type ?? "null"] ?? - seg.material_type ?? - "不限"} - - - {seg.description || `片段 ${seg.segment_order}`} - - - - {seg.duration_min}-{seg.duration_max}秒 - - -
- ))} -
-
- 预估总时长:{formatDuration(Math.round(totalSegmentDuration))} -
-
- )} - - {/* 样式配置 */} -
-

🎨 样式配置

-
-
- 字幕样式 - - {formatConfig(template.subtitle_config)} - -
-
- 标题样式 - - {formatConfig(template.title_config)} - -
-
- BGM 配置 - - {formatConfig(template.bgm_config)} - -
-
- 视频比例 - - {template.aspect_ratio ?? "16:9"} - -
-
-
- - {/* 统计信息 */} -
- 已使用 {template.usage_count ?? 0} 次 - -
- - {/* 操作按钮 */} -
- - -
-
-
-
- ) -} - -/* ============================================================ - * 模板卡片组件 - * ============================================================ */ - -interface TemplateCardProps { - template: TemplateItem - isFavorite: boolean - onPreview: (template: TemplateItem) => void - onToggleFavorite: (id: string, e: React.MouseEvent) => void - onUse: (template: TemplateItem) => void -} - -const TemplateCard: React.FC = ({ - template, - isFavorite, - onPreview, - onToggleFavorite, - onUse, -}) => { - return ( -
onPreview(template)}> - {/* 缩略图 */} -
- {template.thumbnail_url ? ( - {template.name} - ) : ( -
- {(template.description ?? "").slice(0, 80)} - {(template.description ?? "").length > 80 ? "..." : ""} -
- )} -
-
{template.name}
-
- - {formatDuration(template.estimated_duration ?? template.target_duration)} - -
-
点击查看详情
- -
- - {/* 信息区 */} -
-
- - {template.category} - - {(template.tags ?? []).slice(0, 2).map((tag) => ( - - {tag} - - ))} -
-

{template.description ?? ""}

-
- 已使用 {template.usage_count ?? 0} 次 - -
-
-
- ) -} - -/* ============================================================ - * 主组件 - * ============================================================ */ - const TemplateLibrary: React.FC = () => { - const navigate = useNavigate() - const queryClient = useQueryClient() - - // 筛选状态 - const [searchText, setSearchText] = useState("") - const [activeType, setActiveType] = useState("全部") - const [durationRange, setDurationRange] = useState<"" | "short" | "medium" | "long">("") - const [page, setPage] = useState(1) - const [pageSize] = useState(12) - - // 弹窗状态 - const [previewTemplate, setPreviewTemplate] = useState(null) - const [detailLoading, setDetailLoading] = useState(false) - - // ── 构建查询参数 ── - const queryParams: TemplateListParams = useMemo(() => { - const params: TemplateListParams = { - page, - page_size: pageSize, - } - if (activeType !== "全部") params.category = activeType - if (searchText.trim()) params.keyword = searchText.trim() - if (durationRange) params.duration_range = durationRange - return params - }, [page, pageSize, activeType, searchText, durationRange]) - - // ── 获取模板列表(后端分页 + 筛选) ── const { - data: templateData, + templates, + totalTemplates, isLoading, isError, error, - } = useQuery({ - queryKey: ["templates", queryParams], - queryFn: () => getTemplates(queryParams), - staleTime: 30_000, + searchText, + activeType, + durationRange, + page, + pageSize, + setPage, + toggleFavorite, + handleCopy, + handleUse, + handleCreate, + handleSearchChange, + handleCategoryChange, + handleDurationChange, + } = useTemplateLibrary() + + const { + previewTemplate, + detailLoading, + handlePreview, + handleClose, + handleToggleFavorite, + handleUse: handleUseFromDetail, + handleCopy: handleCopyFromDetail, + } = useTemplateDetail({ + onToggleFavorite: (id) => toggleFavorite(id), + onUse: handleUse, + onCopy: handleCopy, }) - const templates = templateData?.items ?? [] - const totalTemplates = templateData?.total ?? 0 - - // ── 收藏 mutation ── - const favMutation = useMutation({ - mutationFn: toggleFavoriteTemplate, - onSuccess: (_data, templateId) => { - queryClient.invalidateQueries({ queryKey: ["templates"] }) - if (previewTemplate && previewTemplate.id === templateId) { - setPreviewTemplate((prev) => (prev ? { ...prev, is_favorite: !prev.is_favorite } : prev)) - } - }, - }) - - // ── 复制模板 mutation ── - const copyMutation = useMutation({ - mutationFn: copyTemplate, - onSuccess: (data) => { - message.success(`模板「${data.name}」已复制到「我的模板」`) - queryClient.invalidateQueries({ queryKey: ["templates"] }) - }, - onError: () => { - message.error("复制模板失败,请稍后重试") - }, - }) - - /** 切换收藏 */ - const toggleFavorite = useCallback( - (id: string, e?: React.MouseEvent) => { - e?.stopPropagation() - favMutation.mutate(id) - }, - [favMutation], - ) - - /** 点击卡片 → 获取详情并展示弹窗 */ - const handlePreview = useCallback(async (template: TemplateItem) => { - setDetailLoading(true) - setPreviewTemplate(template) - try { - const detail = await getTemplate(template.id) - setPreviewTemplate(detail) - } catch { - // 详情加载失败时使用列表数据 - message.warning("模板详情加载失败,显示摘要信息") - } finally { - setDetailLoading(false) - } - }, []) - - /** 复制模板 */ - const handleCopy = useCallback( - (template: TemplateItem) => { - copyMutation.mutate(template.id) - }, - [copyMutation], - ) - - /** 使用模板 → 进入剪辑编辑器配置 */ - const handleUse = useCallback( - (template: TemplateItem) => { - navigate(`/app/editing-planner?templateId=${template.id}`) - }, - [navigate], - ) - - /** 搜索防抖处理 */ - const handleSearchChange = useCallback((e: React.ChangeEvent) => { - setSearchText(e.target.value) - setPage(1) - }, []) - - /** 切换分类 */ - const handleCategoryChange = useCallback((type: EditTemplateType | "全部") => { - setActiveType(type) - setPage(1) - }, []) - - /** 切换时长筛选 */ - const handleDurationChange = useCallback((value: "" | "short" | "medium" | "long") => { - setDurationRange(value) - setPage(1) - }, []) - - // ── Loading 状态 ── - if (isLoading) { - return ( -
-
-
- -
-

加载模板中...

-
-
- ) - } - - // ── Error 状态 ── - if (isError) { - return ( -
-
-
- -
-

加载失败

-

{error?.message || "网络异常,请稍后重试"}

-
-
- ) - } - return (
- {/* ── 页面头部 ──────────────────────────────────────────── */} -
-
-

模板库

-

选择模板快速创建,支持自定义修改

-
- -
+ {/* 页面头部 */} + - {/* ── 工具栏:搜索 + 类型按钮组 + 时长筛选 ─────────────── */} -
-
- - - - -
-
- {TEMPLATE_TYPES.map((cat) => ( - - ))} -
- {/* 时长筛选 */} -
- {DURATION_OPTIONS.map((opt) => ( - - ))} -
-
+ {/* 工具栏:搜索 + 类型按钮组 + 时长筛选 */} + - {/* ── 模板展示区 ────────────────────────────────────────── */} - {templates.length === 0 ? ( -
-
- -
-

- {searchText || activeType !== "全部" || durationRange ? "未找到匹配的模板" : "暂无模板"} -

-

- {searchText || activeType !== "全部" || durationRange - ? "试试调整搜索条件或切换类型" - : "点击上方「创建模板」开始创作"} -

-
- ) : ( - <> -
- {templates.map((tpl) => ( - - ))} -
+ {/* 模板展示区 */} + - {/* 分页 */} - {totalTemplates > pageSize && ( -
- `共 ${total} 个模板`} - onChange={(p) => setPage(p)} - /> -
- )} - - )} - - {/* ── 详情弹窗 ──────────────────────────────────────────── */} + {/* 详情弹窗 */} {previewTemplate && ( setPreviewTemplate(null)} - onToggleFavorite={toggleFavorite} - onUse={handleUse} - onCopy={handleCopy} + onClose={handleClose} + onToggleFavorite={handleToggleFavorite} + onUse={handleUseFromDetail} + onCopy={handleCopyFromDetail} /> )} - {/* 详情加载中的提示(可选覆盖层) */} + {/* 详情加载中的提示 */} {detailLoading && previewTemplate && ( -
- 加载中... -
+
加载中...
)}
) diff --git a/apps/web/src/pages/templates/components/template-library/TemplateCard.tsx b/apps/web/src/pages/templates/components/template-library/TemplateCard.tsx new file mode 100644 index 000000000..c7abfe5e9 --- /dev/null +++ b/apps/web/src/pages/templates/components/template-library/TemplateCard.tsx @@ -0,0 +1,87 @@ +import React from "react" +import { Tag } from "antd" +import type { TemplateItem } from "@/api/templates" +import { gradientForCategory, getTypeColor, formatDuration } from "../../utils/templateLibrary" + +interface TemplateCardProps { + template: TemplateItem + isFavorite: boolean + onPreview: (template: TemplateItem) => void + onToggleFavorite: (id: string, e: React.MouseEvent) => void + onUse: (template: TemplateItem) => void +} + +export const TemplateCard: React.FC = ({ + template, + isFavorite, + onPreview, + onToggleFavorite, + onUse, +}) => { + return ( +
onPreview(template)}> + {/* 缩略图 */} +
+ {template.thumbnail_url ? ( + {template.name} + ) : ( +
+ {(template.description ?? "").slice(0, 80)} + {(template.description ?? "").length > 80 ? "..." : ""} +
+ )} +
+
{template.name}
+
+ + {formatDuration(template.estimated_duration ?? template.target_duration)} + +
+
点击查看详情
+ +
+ + {/* 信息区 */} +
+
+ + {template.category} + + {(template.tags ?? []).slice(0, 2).map((tag) => ( + + {tag} + + ))} +
+

{template.description ?? ""}

+
+ 已使用 {template.usage_count ?? 0} 次 + +
+
+
+ ) +} diff --git a/apps/web/src/pages/templates/components/template-library/TemplateDetailModal.tsx b/apps/web/src/pages/templates/components/template-library/TemplateDetailModal.tsx new file mode 100644 index 000000000..3856607ff --- /dev/null +++ b/apps/web/src/pages/templates/components/template-library/TemplateDetailModal.tsx @@ -0,0 +1,219 @@ +import React from "react" +import { Button, Descriptions, Tooltip } from "antd" +import { CopyOutlined, ThunderboltOutlined } from "@ant-design/icons" +import type { TemplateItem, TemplateSegment } from "@/api/templates" +import { + gradientForCategory, + getTypeColor, + formatDuration, + formatConfig, + getMaterialTypeLabel, + calcTotalSegmentDuration, +} from "../../utils/templateLibrary" +import { TEMPLATE_TYPES } from "../../constants/templateLibrary" + +interface TemplateDetailModalProps { + template: TemplateItem + isFavorite: boolean + onClose: () => void + onToggleFavorite: (id: string) => void + onUse: (template: TemplateItem) => void + onCopy: (template: TemplateItem) => void +} + +export const TemplateDetailModal: React.FC = ({ + template, + isFavorite, + onClose, + onToggleFavorite, + onUse, + onCopy, +}) => { + const segments = template.segments ?? [] + const totalSegmentDuration = calcTotalSegmentDuration(segments) + + return ( +
+
e.stopPropagation()} + > + {/* 关闭按钮 */} + + + {/* 预览区域 */} +
+ {template.thumbnail_url ? ( + {template.name} + ) : ( +
+ + {TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon ?? "📋"} + + {template.name} +
+ )} +
+ + {/* 内容区域 */} +
+ {/* 标题行 */} +
+

{template.name}

+ + {TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon} {template.category} + +
+ + {/* 描述 */} +

{template.description}

+ + {/* 标签 */} + {(template.tags?.length ?? 0) > 0 && ( +
+ {template.tags!.map((tag) => ( + + #{tag} + + ))} +
+ )} + + {/* 基本信息 */} + + + {/* 素材规则(片段配置) */} + {segments.length > 0 && ( +
+

🎬 素材规则

+
+ {segments + .sort((a, b) => a.segment_order - b.segment_order) + .map((seg: TemplateSegment, idx: number) => ( +
+ #{seg.segment_order} + + {getMaterialTypeLabel(seg.material_type)} + + + {seg.description || `片段 ${seg.segment_order}`} + + + + {seg.duration_min}-{seg.duration_max}秒 + + +
+ ))} +
+
+ 预估总时长:{formatDuration(Math.round(totalSegmentDuration))} +
+
+ )} + + {/* 样式配置 */} +
+

🎨 样式配置

+
+
+ 字幕样式 + + {formatConfig(template.subtitle_config)} + +
+
+ 标题样式 + + {formatConfig(template.title_config)} + +
+
+ BGM 配置 + + {formatConfig(template.bgm_config)} + +
+
+ 视频比例 + + {template.aspect_ratio ?? "16:9"} + +
+
+
+ + {/* 统计信息 */} +
+ 已使用 {template.usage_count ?? 0} 次 + +
+ + {/* 操作按钮 */} +
+ + +
+
+
+
+ ) +} diff --git a/apps/web/src/pages/templates/components/template-library/TemplateGrid.tsx b/apps/web/src/pages/templates/components/template-library/TemplateGrid.tsx new file mode 100644 index 000000000..072307a12 --- /dev/null +++ b/apps/web/src/pages/templates/components/template-library/TemplateGrid.tsx @@ -0,0 +1,110 @@ +import React from "react" +import { Pagination } from "antd" +import { InboxOutlined, LoadingOutlined, ExclamationCircleOutlined } from "@ant-design/icons" +import { TemplateCard } from "./TemplateCard" +import type { TemplateItem } from "@/api/templates" + +interface TemplateGridProps { + templates: TemplateItem[] + total: number + page: number + pageSize: number + isLoading: boolean + isError: boolean + errorMessage?: string + searchText: string + activeType: string + durationRange: string + onPageChange: (page: number) => void + onPreview: (template: TemplateItem) => void + onToggleFavorite: (id: string, e: React.MouseEvent) => void + onUse: (template: TemplateItem) => void +} + +export const TemplateGrid: React.FC = ({ + templates, + total, + page, + pageSize, + isLoading, + isError, + errorMessage, + searchText, + activeType, + durationRange, + onPageChange, + onPreview, + onToggleFavorite, + onUse, +}) => { + // Loading 状态 + if (isLoading) { + return ( +
+
+ +
+

加载模板中...

+
+ ) + } + + // Error 状态 + if (isError) { + return ( +
+
+ +
+

加载失败

+

{errorMessage || "网络异常,请稍后重试"}

+
+ ) + } + + // 空状态 + if (templates.length === 0) { + const hasFilter = !!searchText || activeType !== "全部" || !!durationRange + return ( +
+
+ +
+

{hasFilter ? "未找到匹配的模板" : "暂无模板"}

+

{hasFilter ? "试试调整搜索条件或切换类型" : "点击上方「创建模板」开始创作"}

+
+ ) + } + + return ( + <> +
+ {templates.map((tpl) => ( + + ))} +
+ + {/* 分页 */} + {total > pageSize && ( +
+ `共 ${t} 个模板`} + onChange={onPageChange} + /> +
+ )} + + ) +} diff --git a/apps/web/src/pages/templates/components/template-library/TemplateHeader.tsx b/apps/web/src/pages/templates/components/template-library/TemplateHeader.tsx new file mode 100644 index 000000000..748f021ed --- /dev/null +++ b/apps/web/src/pages/templates/components/template-library/TemplateHeader.tsx @@ -0,0 +1,20 @@ +import React from "react" +import { Button } from "antd" + +interface TemplateHeaderProps { + onCreateClick: () => void +} + +export const TemplateHeader: React.FC = ({ onCreateClick }) => { + return ( +
+
+

模板库

+

选择模板快速创建,支持自定义修改

+
+ +
+ ) +} diff --git a/apps/web/src/pages/templates/components/template-library/TemplateToolbar.tsx b/apps/web/src/pages/templates/components/template-library/TemplateToolbar.tsx new file mode 100644 index 000000000..40aac6b28 --- /dev/null +++ b/apps/web/src/pages/templates/components/template-library/TemplateToolbar.tsx @@ -0,0 +1,63 @@ +import React from "react" +import { SearchOutlined } from "@ant-design/icons" +import type { EditTemplateType, DurationRange } from "../../types/templateLibrary" +import { TEMPLATE_TYPES, DURATION_OPTIONS } from "../../constants/templateLibrary" + +interface TemplateToolbarProps { + searchText: string + onSearchChange: (e: React.ChangeEvent) => void + activeType: EditTemplateType | "全部" + onTypeChange: (type: EditTemplateType | "全部") => void + durationRange: DurationRange + onDurationChange: (value: DurationRange) => void +} + +export const TemplateToolbar: React.FC = ({ + searchText, + onSearchChange, + activeType, + onTypeChange, + durationRange, + onDurationChange, +}) => { + return ( +
+
+ + + + +
+
+ {TEMPLATE_TYPES.map((cat) => ( + + ))} +
+ {/* 时长筛选 */} +
+ {DURATION_OPTIONS.map((opt) => ( + + ))} +
+
+ ) +} diff --git a/apps/web/src/pages/templates/constants/templateLibrary.ts b/apps/web/src/pages/templates/constants/templateLibrary.ts new file mode 100644 index 000000000..fd79ed0bc --- /dev/null +++ b/apps/web/src/pages/templates/constants/templateLibrary.ts @@ -0,0 +1,46 @@ +import type { EditTemplateType, DurationRange } from "../types/templateLibrary" + +export const TEMPLATE_TYPES: Array<{ + type: EditTemplateType | "全部" + label: string + icon: string + color: string +}> = [ + { type: "全部", label: "全部", icon: "📋", color: "#6366f1" }, + { type: "口播", label: "口播", icon: "🎙️", color: "#6366f1" }, + { type: "种草", label: "种草", icon: "🌱", color: "#10b981" }, + { type: "产品", label: "产品", icon: "📦", color: "#0ea5e9" }, + { type: "品牌", label: "品牌", icon: "🏷️", color: "#f59e0b" }, + { type: "混剪", label: "混剪", icon: "🎬", color: "#8b5cf6" }, + { type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" }, +] + +export const DURATION_OPTIONS: Array<{ + value: DurationRange + label: string +}> = [ + { value: "", label: "全部时长" }, + { value: "short", label: "30秒以内" }, + { value: "medium", label: "30秒-2分钟" }, + { value: "long", label: "2分钟以上" }, +] + +export const MATERIAL_TYPE_LABELS: Record = { + video: "视频", + image: "图片", + audio: "音频", + voiceover: "配音", + subtitle: "字幕", + null: "不限", +} + +export const DEFAULT_PAGE_SIZE = 12 +export const CATEGORY_GRADIENT_MAP: Record = { + 口播: "linear-gradient(135deg, #6366f1, #8b5cf6)", + 种草: "linear-gradient(135deg, #10b981, #059669)", + 产品: "linear-gradient(135deg, #0ea5e9, #0284c7)", + 品牌: "linear-gradient(135deg, #f59e0b, #d97706)", + 混剪: "linear-gradient(135deg, #8b5cf6, #6d28d9)", + Vlog: "linear-gradient(135deg, #ec4899, #db2777)", +} +export const DEFAULT_GRADIENT = "linear-gradient(135deg, #6366f1, #8b5cf6)" diff --git a/apps/web/src/pages/templates/hooks/useTemplateDetail.ts b/apps/web/src/pages/templates/hooks/useTemplateDetail.ts new file mode 100644 index 000000000..62be75ece --- /dev/null +++ b/apps/web/src/pages/templates/hooks/useTemplateDetail.ts @@ -0,0 +1,62 @@ +import { useState, useCallback } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { message } from "antd" +import { getTemplate, type TemplateItem } from "@/api/templates" + +interface UseTemplateDetailProps { + onToggleFavorite: (id: string) => void + onUse: (template: TemplateItem) => void + onCopy: (template: TemplateItem) => void +} + +export const useTemplateDetail = ({ onToggleFavorite, onUse, onCopy }: UseTemplateDetailProps) => { + const queryClient = useQueryClient() + + /* 弹窗状态 */ + const [previewTemplate, setPreviewTemplate] = useState(null) + const [detailLoading, setDetailLoading] = useState(false) + + /* 点击卡片 → 获取详情并展示弹窗 */ + const handlePreview = useCallback(async (template: TemplateItem) => { + setDetailLoading(true) + setPreviewTemplate(template) + try { + const detail = await getTemplate(template.id) + setPreviewTemplate(detail) + } catch { + message.warning("模板详情加载失败,显示摘要信息") + } finally { + setDetailLoading(false) + } + }, []) + + /* 关闭弹窗 */ + const handleClose = useCallback(() => { + setPreviewTemplate(null) + setDetailLoading(false) + }, []) + + /* 收藏切换(同时更新预览模板的状态) */ + const handleToggleFavorite = useCallback( + (id: string) => { + onToggleFavorite(id) + /* 乐观更新详情弹窗的收藏状态 */ + setPreviewTemplate((prev) => + prev && prev.id === id ? { ...prev, is_favorite: !prev.is_favorite } : prev, + ) + /* 刷新列表缓存 */ + queryClient.invalidateQueries({ queryKey: ["templates"] }) + }, + [onToggleFavorite, queryClient], + ) + + return { + previewTemplate, + detailLoading, + handlePreview, + handleClose, + handleToggleFavorite, + handleUse: onUse, + handleCopy: onCopy, + } +} diff --git a/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts b/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts new file mode 100644 index 000000000..826ad2357 --- /dev/null +++ b/apps/web/src/pages/templates/hooks/useTemplateLibrary.ts @@ -0,0 +1,147 @@ +import { useState, useMemo, useCallback } from "react" +import { useNavigate } from "react-router-dom" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { message } from "antd" +import { + getTemplates, + toggleFavoriteTemplate, + copyTemplate, + type TemplateItem, + type TemplateListParams, +} from "@/api/templates" +import type { EditTemplateType, DurationRange } from "../types/templateLibrary" +import { DEFAULT_PAGE_SIZE } from "../constants/templateLibrary" + +export const useTemplateLibrary = () => { + const navigate = useNavigate() + const queryClient = useQueryClient() + + /* 筛选状态 */ + const [searchText, setSearchText] = useState("") + const [activeType, setActiveType] = useState("全部") + const [durationRange, setDurationRange] = useState("") + const [page, setPage] = useState(1) + const [pageSize] = useState(DEFAULT_PAGE_SIZE) + + /* 构建查询参数 */ + const queryParams: TemplateListParams = useMemo(() => { + const params: TemplateListParams = { + page, + page_size: pageSize, + } + if (activeType !== "全部") params.category = activeType + if (searchText.trim()) params.keyword = searchText.trim() + if (durationRange) params.duration_range = durationRange + return params + }, [page, pageSize, activeType, searchText, durationRange]) + + /* 获取模板列表 */ + const { + data: templateData, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ["templates", queryParams], + queryFn: () => getTemplates(queryParams), + staleTime: 30_000, + }) + + const templates = templateData?.items ?? [] + const totalTemplates = templateData?.total ?? 0 + + /* 收藏 mutation */ + const favMutation = useMutation({ + mutationFn: toggleFavoriteTemplate, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["templates"] }) + }, + }) + + /* 复制模板 mutation */ + const copyMutation = useMutation({ + mutationFn: copyTemplate, + onSuccess: (data) => { + message.success(`模板「${data.name}」已复制到「我的模板」`) + queryClient.invalidateQueries({ queryKey: ["templates"] }) + }, + onError: () => { + message.error("复制模板失败,请稍后重试") + }, + }) + + /* 操作:切换收藏 */ + const toggleFavorite = useCallback( + (id: string, e?: React.MouseEvent) => { + e?.stopPropagation() + favMutation.mutate(id) + }, + [favMutation], + ) + + /* 操作:复制模板 */ + const handleCopy = useCallback( + (template: TemplateItem) => { + copyMutation.mutate(template.id) + }, + [copyMutation], + ) + + /* 操作:使用模板 → 跳转剪辑编辑器 */ + const handleUse = useCallback( + (template: TemplateItem) => { + navigate(`/app/editing-planner?templateId=${template.id}`) + }, + [navigate], + ) + + /* 操作:创建模板 */ + const handleCreate = useCallback(() => { + navigate("/app/editing-planner") + }, [navigate]) + + /* 搜索 */ + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + setSearchText(e.target.value) + setPage(1) + }, []) + + /* 切换分类 */ + const handleCategoryChange = useCallback((type: EditTemplateType | "全部") => { + setActiveType(type) + setPage(1) + }, []) + + /* 切换时长筛选 */ + const handleDurationChange = useCallback((value: DurationRange) => { + setDurationRange(value) + setPage(1) + }, []) + + return { + /* 状态 */ + templates, + totalTemplates, + isLoading, + isError, + error, + searchText, + activeType, + durationRange, + page, + pageSize, + /* mutations */ + favMutation, + copyMutation, + /* setters */ + setPage, + /* handlers */ + toggleFavorite, + handleCopy, + handleUse, + handleCreate, + handleSearchChange, + handleCategoryChange, + handleDurationChange, + } +} diff --git a/apps/web/src/pages/templates/types/templateLibrary.ts b/apps/web/src/pages/templates/types/templateLibrary.ts new file mode 100644 index 000000000..fb5e60b0c --- /dev/null +++ b/apps/web/src/pages/templates/types/templateLibrary.ts @@ -0,0 +1,15 @@ +/** 模板类型 */ +export type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog" + +/** 时长筛选值 */ +export type DurationRange = "" | "short" | "medium" | "long" + +/** 配置展示字段 */ +export interface ConfigDisplayFields { + font_size?: string | number + font_family?: string + color?: string + position?: string + volume?: string | number + name?: string +} diff --git a/apps/web/src/pages/templates/utils/templateLibrary.ts b/apps/web/src/pages/templates/utils/templateLibrary.ts new file mode 100644 index 000000000..f5c619239 --- /dev/null +++ b/apps/web/src/pages/templates/utils/templateLibrary.ts @@ -0,0 +1,54 @@ +import type { ConfigDisplayFields } from "../types/templateLibrary" +import { + TEMPLATE_TYPES, + CATEGORY_GRADIENT_MAP, + DEFAULT_GRADIENT, + MATERIAL_TYPE_LABELS, +} from "../constants/templateLibrary" +import type { TemplateSegment } from "@/api/templates" + +/** 获取类型对应颜色 */ +export const getTypeColor = (type: string): string => { + const found = TEMPLATE_TYPES.find((t) => t.type === type) + return found?.color ?? "#6366f1" +} + +/** 根据 category 生成占位渐变色 */ +export const gradientForCategory = (category: string): string => { + return CATEGORY_GRADIENT_MAP[category] ?? DEFAULT_GRADIENT +} + +/** 格式化时长 */ +export const formatDuration = (seconds: number | undefined | null): string => { + if (!seconds || seconds <= 0) return "0秒" + const totalSec = Math.round(seconds) + const m = Math.floor(totalSec / 60) + const s = totalSec % 60 + if (m === 0) return `${s}秒` + return `${m}分${s > 0 ? `${s}秒` : ""}` +} + +/** 格式化配置对象为可读文本 */ +export const formatConfig = (config?: object): string => { + if (!config || Object.keys(config).length === 0) return "默认" + const c = config as ConfigDisplayFields + const parts: string[] = [] + if (c.font_size) parts.push(`字号: ${c.font_size}`) + if (c.font_family) parts.push(`字体: ${c.font_family}`) + if (c.color) parts.push(`颜色: ${c.color}`) + if (c.position) parts.push(`位置: ${c.position}`) + if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`) + if (c.name) parts.push(String(c.name)) + return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config) +} + +/** 获取素材类型标签文本 */ +export const getMaterialTypeLabel = (materialType: string | null | undefined): string => { + if (!materialType) return "不限" + return MATERIAL_TYPE_LABELS[materialType] ?? materialType +} + +/** 计算片段总时长(取每个片段 min/max 的平均值) */ +export const calcTotalSegmentDuration = (segments: TemplateSegment[]): number => { + return segments.reduce((sum, s) => sum + (s.duration_min + s.duration_max) / 2, 0) +} diff --git a/apps/web/src/test/pages/TemplateLibrary.test.tsx b/apps/web/src/test/pages/TemplateLibrary.test.tsx index 9ea5d984c..a9b7fd861 100644 --- a/apps/web/src/test/pages/TemplateLibrary.test.tsx +++ b/apps/web/src/test/pages/TemplateLibrary.test.tsx @@ -115,6 +115,16 @@ vi.mock("@/api/templates", () => ({ vi.mock("@/pages/templates/TemplateLibrary.css", () => ({})) import TemplateLibrary from "@/pages/templates/TemplateLibrary" +import "@/pages/templates/types/templateLibrary" +import "@/pages/templates/constants/templateLibrary" +import "@/pages/templates/utils/templateLibrary" +import "@/pages/templates/hooks/useTemplateLibrary" +import "@/pages/templates/hooks/useTemplateDetail" +import "@/pages/templates/components/template-library/TemplateCard" +import "@/pages/templates/components/template-library/TemplateDetailModal" +import "@/pages/templates/components/template-library/TemplateHeader" +import "@/pages/templates/components/template-library/TemplateToolbar" +import "@/pages/templates/components/template-library/TemplateGrid" describe("TemplateLibrary Page", () => { it("should render without crashing", () => { From 48bf66c298a3b79036cef76255a2e88c70233f82 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 26 Jul 2026 14:08:03 +0000 Subject: [PATCH 11/45] style: auto-format with black + isort + prettier --- apps/api/app/services/edit_plan_service.py | 12 +++++------- apps/api/app/services/video_compose_service.py | 10 +++++----- apps/worker/worker_app/tasks/ingest.py | 4 ++-- tests/unit/test_clip_operations.py | 2 +- tests/unit/test_quota_domain.py | 2 +- tests/unit/test_video_filter_builder.py | 2 +- 6 files changed, 15 insertions(+), 17 deletions(-) diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index 80a4bdb48..3026c58cf 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -16,15 +16,13 @@ from packages.adapters.sqlalchemy_impl import ( SQLAlchemyEditPlanRepository, SQLAlchemyGenerationTaskRepository, ) +from packages.domain.clip_operations import calculate_merge as _calc_merge +from packages.domain.clip_operations import calculate_shift_orders as _calc_shift_orders +from packages.domain.clip_operations import calculate_split as _calc_split +from packages.domain.clip_operations import validate_merge_clips as _validate_merge +from packages.domain.clip_operations import validate_split_time as _validate_split from packages.domain.edit_plan import EditPlan, EditPlanStatus from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus -from packages.domain.clip_operations import ( - calculate_merge as _calc_merge, - calculate_shift_orders as _calc_shift_orders, - calculate_split as _calc_split, - validate_merge_clips as _validate_merge, - validate_split_time as _validate_split, -) logger = logging.getLogger(__name__) diff --git a/apps/api/app/services/video_compose_service.py b/apps/api/app/services/video_compose_service.py index b196f9901..987b8ed35 100755 --- a/apps/api/app/services/video_compose_service.py +++ b/apps/api/app/services/video_compose_service.py @@ -36,12 +36,12 @@ from packages.domain.video_filter_builder import ( DEFAULT_TRANSITION_DURATION, ClipFilterChain, build_clip_filter, - build_concat_filter as _build_concat_filter_func, - build_filter_complex as _build_filter_complex, - build_xfade_filter as _build_xfade_filter_func, - chain_filters as _chain_filters_func, - has_audio as _has_audio_func, ) +from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func +from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex +from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func +from packages.domain.video_filter_builder import chain_filters as _chain_filters_func +from packages.domain.video_filter_builder import has_audio as _has_audio_func logger = logging.getLogger(__name__) diff --git a/apps/worker/worker_app/tasks/ingest.py b/apps/worker/worker_app/tasks/ingest.py index f750eab65..eff0fc80c 100755 --- a/apps/worker/worker_app/tasks/ingest.py +++ b/apps/worker/worker_app/tasks/ingest.py @@ -19,9 +19,9 @@ from packages.domain.media_validation import ( MIN_IMAGE_FILE_SIZE, MIN_VIDEO_FILE_SIZE, SUPPORTED_VIDEO_CODECS, - is_valid_media as _is_valid_media, - safe_parse_fps as _safe_parse_fps, ) +from packages.domain.media_validation import is_valid_media as _is_valid_media +from packages.domain.media_validation import safe_parse_fps as _safe_parse_fps logger = get_task_logger(__name__) diff --git a/tests/unit/test_clip_operations.py b/tests/unit/test_clip_operations.py index 01be94b59..2c78fceaf 100755 --- a/tests/unit/test_clip_operations.py +++ b/tests/unit/test_clip_operations.py @@ -15,8 +15,8 @@ import unittest from dataclasses import dataclass from packages.domain.clip_operations import ( - MergeResult, ROUND_PRECISION, + MergeResult, SplitResult, calculate_merge, calculate_reorder_new_orders, diff --git a/tests/unit/test_quota_domain.py b/tests/unit/test_quota_domain.py index 3d11e1ec7..912d12b2c 100755 --- a/tests/unit/test_quota_domain.py +++ b/tests/unit/test_quota_domain.py @@ -4,8 +4,8 @@ import pytest from packages.domain.quota import ( QUOTA_TIERS, - QuotaCheckResult, QuotaChecker, + QuotaCheckResult, QuotaDimension, QuotaRegistry, QuotaTier, diff --git a/tests/unit/test_video_filter_builder.py b/tests/unit/test_video_filter_builder.py index 5f7c749e7..2f43deec7 100755 --- a/tests/unit/test_video_filter_builder.py +++ b/tests/unit/test_video_filter_builder.py @@ -23,8 +23,8 @@ from packages.domain.video_filter_builder import ( DEFAULT_OUTPUT_HEIGHT, DEFAULT_OUTPUT_WIDTH, DEFAULT_TRANSITION_DURATION, - ClipFilterChain, XFADE_TRANSITION_MAP, + ClipFilterChain, build_clip_filter, build_concat_filter, build_filter_complex, From f6a458564f1594c160bf736f088afd2fc51ae1f4 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 00:25:32 +0800 Subject: [PATCH 12/45] =?UTF-8?q?test(wave88):=20streaming=5Fservice+asset?= =?UTF-8?q?=5Ftypes+asset=5Fusage+title=5Fusage=20+=20N=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=20(#932)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/test_asset_types_pure.py | 69 ++++ tests/unit/test_asset_usage_deep.py | 139 +++++++ tests/unit/test_title_usage_pure.py | 143 ++++++++ tests/unit/test_tts_streaming_service.py | 441 +++++++++++++++++++++++ 4 files changed, 792 insertions(+) create mode 100755 tests/unit/test_asset_types_pure.py create mode 100755 tests/unit/test_asset_usage_deep.py create mode 100755 tests/unit/test_title_usage_pure.py create mode 100755 tests/unit/test_tts_streaming_service.py diff --git a/tests/unit/test_asset_types_pure.py b/tests/unit/test_asset_types_pure.py new file mode 100755 index 000000000..a1d6a20aa --- /dev/null +++ b/tests/unit/test_asset_types_pure.py @@ -0,0 +1,69 @@ +"""infer_mime_type_from_storage_key 纯逻辑单测. + +Worker core 工具函数,从 storage_key 推断 MIME 类型。 +""" + +from __future__ import annotations + +from worker_app.core.asset_types import infer_mime_type_from_storage_key + + +class TestInferMimeTypeFromStorageKey: + """infer_mime_type_from_storage_key 测试.""" + + def test_mp4_returns_video_mp4(self): + """mp4 后缀返回 video/mp4.""" + assert infer_mime_type_from_storage_key("projects/abc/video.mp4") == "video/mp4" + + def test_mov_returns_quicktime(self): + """mov 后缀返回 video/quicktime.""" + assert infer_mime_type_from_storage_key("uploads/test.mov") == "video/quicktime" + + def test_m4v_returns_video_mp4(self): + """m4v 后缀返回 video/mp4.""" + assert infer_mime_type_from_storage_key("clip.m4v") == "video/mp4" + + def test_avi_returns_video_mp4(self): + """avi 后缀返回 video/mp4.""" + assert infer_mime_type_from_storage_key("movie.avi") == "video/mp4" + + def test_mkv_returns_video_mp4(self): + """mkv 后缀返回 video/mp4.""" + assert infer_mime_type_from_storage_key("video.mkv") == "video/mp4" + + def test_webm_returns_video_mp4(self): + """webm 后缀返回 video/mp4.""" + assert infer_mime_type_from_storage_key("output.webm") == "video/mp4" + + def test_jpg_default_returns_image_jpeg(self): + """jpg 等非视频后缀默认返回 image/jpeg.""" + assert infer_mime_type_from_storage_key("thumb.jpg") == "image/jpeg" + + def test_png_default_returns_image_jpeg(self): + """png 也返回 image/jpeg(当前实现的默认值).""" + assert infer_mime_type_from_storage_key("image.png") == "image/jpeg" + + def test_no_extension_returns_jpeg(self): + """无扩展名返回 image/jpeg.""" + assert infer_mime_type_from_storage_key("random_file") == "image/jpeg" + + def test_case_insensitive(self): + """大小写不敏感.""" + assert infer_mime_type_from_storage_key("VIDEO.MP4") == "video/mp4" + assert infer_mime_type_from_storage_key("Clip.MOV") == "video/quicktime" + + def test_deep_path(self): + """多级路径正常推断.""" + assert infer_mime_type_from_storage_key("generated/projects/abc/def/output.mp4") == "video/mp4" + + def test_empty_string(self): + """空字符串返回 image/jpeg(默认值).""" + assert infer_mime_type_from_storage_key("") == "image/jpeg" + + def test_filename_with_multiple_dots(self): + """文件名含多个点时取最后一个扩展名.""" + assert infer_mime_type_from_storage_key("my.video.file.mp4") == "video/mp4" + + def test_mov_case_insensitive_upper(self): + """MOV 大写也识别为 quicktime.""" + assert infer_mime_type_from_storage_key("video.MOV") == "video/quicktime" diff --git a/tests/unit/test_asset_usage_deep.py b/tests/unit/test_asset_usage_deep.py new file mode 100755 index 000000000..e643f2e29 --- /dev/null +++ b/tests/unit/test_asset_usage_deep.py @@ -0,0 +1,139 @@ +"""mark_asset_used_for_generation 深度补充单测. + +补全边界场景:空 metadata、None metadata、last_used_at 格式、 +review_status 已有值不覆盖、多次调用递增。 +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from worker_app.core.asset_usage import mark_asset_used_for_generation + +from packages.domain import Asset, AssetStatus + + +def _asset() -> Asset: + return Asset.create( + project_id="project-1", + library_id="library-1", + name="video.mp4", + storage_key="uploads/video.mp4", + mime_type="video/mp4", + file_size=1024, + status=AssetStatus.READY, + ) + + +class TestMarkAssetUsedForGeneration: + """mark_asset_used_for_generation 深度测试.""" + + def test_first_use_sets_count_to_1(self): + """首次使用,use_count 从 0 变 1.""" + asset = _asset() + mark_asset_used_for_generation(asset) + assert asset.metadata["generation_use_count"] == 1 + + def test_increments_existing_count(self): + """已有计数时递增.""" + asset = _asset() + asset.metadata = {"generation_use_count": 5} + mark_asset_used_for_generation(asset) + assert asset.metadata["generation_use_count"] == 6 + + def test_zero_count_increments_to_1(self): + """计数为 0 时递增到 1.""" + asset = _asset() + asset.metadata = {"generation_use_count": 0} + mark_asset_used_for_generation(asset) + assert asset.metadata["generation_use_count"] == 1 + + def test_empty_metadata_still_works(self): + """空 dict metadata 也能正常工作.""" + asset = _asset() + asset.metadata = {} + mark_asset_used_for_generation(asset) + assert asset.metadata["generation_use_count"] == 1 + assert asset.metadata["review_status"] == "pending_review" + assert "last_used_at" in asset.metadata + + def test_none_metadata_field_defaults_to_0(self): + """metadata 中 generation_use_count 为 None 时按 0 处理.""" + asset = _asset() + asset.metadata = {"generation_use_count": None} + mark_asset_used_for_generation(asset) + assert asset.metadata["generation_use_count"] == 1 + + def test_string_count_gets_casted(self): + """字符串类型的 use_count 通过 int() 转换.""" + asset = _asset() + asset.metadata = {"generation_use_count": "3"} + mark_asset_used_for_generation(asset) + assert asset.metadata["generation_use_count"] == 4 + + def test_preserves_other_metadata_fields(self): + """不覆盖 metadata 中的其他字段.""" + asset = _asset() + asset.metadata = { + "generation_use_count": 1, + "custom_field": "value", + "tags": ["a", "b"], + } + mark_asset_used_for_generation(asset) + assert asset.metadata["custom_field"] == "value" + assert asset.metadata["tags"] == ["a", "b"] + assert asset.metadata["generation_use_count"] == 2 + + def test_review_status_pending_when_not_set(self): + """review_status 未设置时设为 pending_review.""" + asset = _asset() + asset.metadata = {} + mark_asset_used_for_generation(asset) + assert asset.metadata["review_status"] == "pending_review" + + def test_review_status_not_overwritten_if_present(self): + """review_status 已有值时不覆盖.""" + asset = _asset() + asset.metadata = {"review_status": "approved"} + mark_asset_used_for_generation(asset) + assert asset.metadata["review_status"] == "approved" + + def test_review_status_empty_string_considered_falsy(self): + """review_status 为空字符串时视为 falsy,设置为 pending_review.""" + asset = _asset() + asset.metadata = {"review_status": ""} + mark_asset_used_for_generation(asset) + assert asset.metadata["review_status"] == "pending_review" + + def test_last_used_at_is_iso_format(self): + """last_used_at 是 ISO 格式时间字符串.""" + asset = _asset() + mark_asset_used_for_generation(asset) + ts = asset.metadata["last_used_at"] + # 可以被解析为 ISO 格式 + parsed = datetime.fromisoformat(ts) + assert parsed.tzinfo is not None # 带时区 + + def test_last_used_at_is_utc(self): + """last_used_at 是 UTC 时间.""" + asset = _asset() + before = datetime.now(timezone.utc) + mark_asset_used_for_generation(asset) + after = datetime.now(timezone.utc) + ts = datetime.fromisoformat(asset.metadata["last_used_at"]) + assert before <= ts <= after + + def test_multiple_calls_increment_count(self): + """多次调用持续递增.""" + asset = _asset() + mark_asset_used_for_generation(asset) + mark_asset_used_for_generation(asset) + mark_asset_used_for_generation(asset) + assert asset.metadata["generation_use_count"] == 3 + + def test_negative_count_still_increments(self): + """负数计数(异常数据)也能递增.""" + asset = _asset() + asset.metadata = {"generation_use_count": -5} + mark_asset_used_for_generation(asset) + assert asset.metadata["generation_use_count"] == -4 diff --git a/tests/unit/test_title_usage_pure.py b/tests/unit/test_title_usage_pure.py new file mode 100755 index 000000000..6b9715aaf --- /dev/null +++ b/tests/unit/test_title_usage_pure.py @@ -0,0 +1,143 @@ +"""mark_title_used_for_generation 纯逻辑单测. + +验证 title usage 计数 + updated_at 更新逻辑。 +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +from packages.adapters.sqlalchemy_impl.models import TitleLibraryModel + + +def test_module_importable(): + """确认模块可以正常导入.""" + from worker_app.core.title_usage import mark_title_used_for_generation # noqa: F401 + + +class TestMarkTitleUsedForGeneration: + """mark_title_used_for_generation 测试.""" + + def _make_task(self, strategy_id: str = "title-1") -> MagicMock: + task = MagicMock() + task.strategy_id = strategy_id + return task + + def _make_title(self, usage_count: int = 0) -> MagicMock: + title = MagicMock(spec=TitleLibraryModel) + title.id = "title-1" + title.usage_count = usage_count + title.updated_at = None + return title + + def test_no_strategy_id_returns_early(self): + """无 strategy_id 时直接返回,不查 DB.""" + from worker_app.core.title_usage import mark_title_used_for_generation + + db = MagicMock() + task = self._make_task(strategy_id="") + + mark_title_used_for_generation(db, task) + + db.query.assert_not_called() + + def test_none_strategy_id_returns_early(self): + """strategy_id 为 None 时直接返回.""" + from worker_app.core.title_usage import mark_title_used_for_generation + + db = MagicMock() + task = self._make_task(strategy_id=None) + + mark_title_used_for_generation(db, task) + + db.query.assert_not_called() + + def test_title_not_found_returns_early(self): + """title 不存在时不报错,静默返回.""" + from worker_app.core.title_usage import mark_title_used_for_generation + + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + task = self._make_task(strategy_id="missing-id") + + mark_title_used_for_generation(db, task) + + db.add.assert_not_called() + db.commit.assert_not_called() + + def test_increments_usage_count_from_zero(self): + """usage_count 从 0 递增到 1.""" + from worker_app.core.title_usage import mark_title_used_for_generation + + db = MagicMock() + title = self._make_title(usage_count=0) + db.query.return_value.filter.return_value.first.return_value = title + task = self._make_task() + + mark_title_used_for_generation(db, task) + + assert title.usage_count == 1 + db.add.assert_called_once_with(title) + db.commit.assert_called_once() + + def test_increments_usage_count_from_existing(self): + """已有 usage_count 时递增.""" + from worker_app.core.title_usage import mark_title_used_for_generation + + db = MagicMock() + title = self._make_title(usage_count=5) + db.query.return_value.filter.return_value.first.return_value = title + task = self._make_task() + + mark_title_used_for_generation(db, task) + + assert title.usage_count == 6 + + def test_none_usage_count_defaults_to_zero_then_increments(self): + """usage_count 为 None 时按 0 处理,递增到 1.""" + from worker_app.core.title_usage import mark_title_used_for_generation + + db = MagicMock() + title = self._make_title(usage_count=None) + db.query.return_value.filter.return_value.first.return_value = title + task = self._make_task() + + mark_title_used_for_generation(db, task) + + assert title.usage_count == 1 + + def test_updates_updated_at_to_utc_now(self): + """updated_at 更新为当前 UTC 时间.""" + from worker_app.core.title_usage import mark_title_used_for_generation + + db = MagicMock() + title = self._make_title(usage_count=3) + db.query.return_value.filter.return_value.first.return_value = title + task = self._make_task() + + before = datetime.now(timezone.utc) + mark_title_used_for_generation(db, task) + after = datetime.now(timezone.utc) + + assert before <= title.updated_at <= after + assert title.updated_at.tzinfo is not None # 带时区 + + def test_correct_query_filter(self): + """查询时使用正确的 id 过滤.""" + from worker_app.core.title_usage import mark_title_used_for_generation + + db = MagicMock() + title = self._make_title() + db.query.return_value.filter.return_value.first.return_value = title + task = self._make_task(strategy_id="title-abc") + + mark_title_used_for_generation(db, task) + + # 验证 query 模型正确 + db.query.assert_called_once_with(TitleLibraryModel) + # 验证 filter 条件 + filter_call = db.query.return_value.filter + assert filter_call.called + # first 被调用 + filter_call.return_value.first.assert_called_once() diff --git a/tests/unit/test_tts_streaming_service.py b/tests/unit/test_tts_streaming_service.py new file mode 100755 index 000000000..9d3d36eab --- /dev/null +++ b/tests/unit/test_tts_streaming_service.py @@ -0,0 +1,441 @@ +"""TTSStreamingService 纯逻辑单测 — 分段策略 + 分块推送 + 错误处理. + +mock 掉 WebSocket 和 CosyVoiceService,验证核心逻辑: +- 文本长度路由(短文本/长文本) +- 空文本/超长文本校验 +- 音频分块推送算法 +- 错误处理路径 +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from packages.application.cosyvoice_service import CosyVoiceError +from packages.application.tts_job.streaming_service import ( + TTSStreamingError, + TTSStreamingService, + _AUDIO_CHUNK_SIZE, +) + +# ── Fixtures ────────────────────────────────────────────────────────────── + + +@pytest.fixture +def mock_cosyvoice(): + """mock CosyVoiceService.""" + svc = MagicMock() + svc.submit_synthesize_task.return_value = { + "audio_url": "https://example.com/audio.mp3", + "duration": 3.5, + } + return svc + + +@pytest.fixture +def streaming_service(mock_cosyvoice): + """TTSStreamingService 实例.""" + return TTSStreamingService(mock_cosyvoice) + + +@pytest.fixture +def mock_ws(): + """mock WebSocket.""" + ws = AsyncMock() + ws.send_bytes = AsyncMock() + ws.send_json = AsyncMock() + return ws + + +class FakeAudioBytes: + """生成指定大小的假音频数据.""" + + @staticmethod + def make(size: int) -> bytes: + return b"\x00" * size + + +# ── 合成路由测试 ────────────────────────────────────────────────────────── + + +class TestSynthesizeRouting: + """synthesize_and_stream 路由逻辑测试.""" + + @pytest.mark.asyncio + async def test_empty_text_returns_error(self, streaming_service, mock_ws): + """空文本返回错误,不调用合成.""" + await streaming_service.synthesize_and_stream(mock_ws, {"text": ""}) + + # 应发送 error 消息 + mock_ws.send_json.assert_called() + last_call = mock_ws.send_json.call_args + assert last_call[0][0]["type"] == "error" + assert "不能为空" in last_call[0][0]["message"] + # 不应调用合成 + streaming_service._cosyvoice.submit_synthesize_task.assert_not_called() + + @pytest.mark.asyncio + async def test_missing_text_key_returns_error(self, streaming_service, mock_ws): + """缺少 text 字段返回错误.""" + await streaming_service.synthesize_and_stream(mock_ws, {}) + + mock_ws.send_json.assert_called() + last_call = mock_ws.send_json.call_args + assert last_call[0][0]["type"] == "error" + streaming_service._cosyvoice.submit_synthesize_task.assert_not_called() + + @pytest.mark.asyncio + async def test_too_long_text_returns_error(self, streaming_service, mock_ws): + """超长文本返回错误.""" + long_text = "你" * 10001 + await streaming_service.synthesize_and_stream(mock_ws, {"text": long_text}) + + mock_ws.send_json.assert_called() + last_call = mock_ws.send_json.call_args + assert last_call[0][0]["type"] == "error" + assert "最大" in last_call[0][0]["message"] + streaming_service._cosyvoice.submit_synthesize_task.assert_not_called() + + @pytest.mark.asyncio + async def test_short_text_uses_short_path(self, streaming_service, mock_ws): + """短文本走 _stream_short_text 路径.""" + with patch.object(streaming_service, "_stream_short_text", new_callable=AsyncMock) as mock_short: + await streaming_service.synthesize_and_stream(mock_ws, {"text": "hello"}) + mock_short.assert_called_once() + + @pytest.mark.asyncio + async def test_long_text_uses_long_path(self, streaming_service, mock_ws): + """长文本走 _stream_long_text 路径.""" + long_text = "你" * 501 + with patch.object(streaming_service, "_stream_long_text", new_callable=AsyncMock) as mock_long: + await streaming_service.synthesize_and_stream(mock_ws, {"text": long_text}) + mock_long.assert_called_once() + + @pytest.mark.asyncio + async def test_exactly_threshold_uses_short_path(self, streaming_service, mock_ws): + """恰好等于阈值走短文本路径.""" + text = "你" * 500 + with ( + patch.object(streaming_service, "_stream_short_text", new_callable=AsyncMock) as mock_short, + patch.object(streaming_service, "_stream_long_text", new_callable=AsyncMock) as mock_long, + ): + await streaming_service.synthesize_and_stream(mock_ws, {"text": text}) + mock_short.assert_called_once() + mock_long.assert_not_called() + + +# ── 短文本流测试 ────────────────────────────────────────────────────────── + + +class TestStreamShortText: + """短文本流式合成测试.""" + + @pytest.mark.asyncio + async def test_happy_path_sends_started_then_done(self, streaming_service, mock_ws): + """短文本正常流程:started → 音频块 → done.""" + audio_data = FakeAudioBytes.make(5000) + with patch.object(streaming_service, "_download_audio", return_value=audio_data): + await streaming_service._stream_short_text( + mock_ws, + {"text": "hello", "voice_id": "v1", "format": "mp3", "speed": 1.0}, + ) + + # 检查 started 消息 + calls = mock_ws.send_json.call_args_list + assert calls[0][0][0]["type"] == "started" + assert calls[0][0][0]["segment_count"] == 1 + + # 检查 done 消息 + last_msg = calls[-1][0][0] + assert last_msg["type"] == "done" + assert last_msg["file_size"] == 5000 + assert last_msg["format"] == "mp3" + assert last_msg["duration"] == 3.5 + + @pytest.mark.asyncio + async def test_calls_cosyvoice_with_correct_params(self, streaming_service, mock_ws): + """正确传递参数给 CosyVoice.""" + audio_data = FakeAudioBytes.make(1000) + with patch.object(streaming_service, "_download_audio", return_value=audio_data): + await streaming_service._stream_short_text( + mock_ws, + { + "text": "test text", + "voice_id": "voice-123", + "sample_rate": 22050, + "format": "wav", + "speed": 1.5, + }, + ) + + streaming_service._cosyvoice.submit_synthesize_task.assert_called_once_with( + text="test text", + voice_id="voice-123", + sample_rate=22050, + format="wav", + speed=1.5, + ) + + @pytest.mark.asyncio + async def test_cosyvoice_error_returns_error(self, streaming_service, mock_ws): + """CosyVoice 错误返回 error 消息.""" + streaming_service._cosyvoice.submit_synthesize_task.side_effect = CosyVoiceError("API quota exceeded") + + await streaming_service._stream_short_text(mock_ws, {"text": "hello"}) + + # 最后一条应该是 error + last_msg = mock_ws.send_json.call_args_list[-1][0][0] + assert last_msg["type"] == "error" + assert "API quota exceeded" in last_msg["message"] + + @pytest.mark.asyncio + async def test_generic_exception_returns_error(self, streaming_service, mock_ws): + """普通异常返回 error 消息.""" + streaming_service._cosyvoice.submit_synthesize_task.side_effect = RuntimeError("boom") + + await streaming_service._stream_short_text(mock_ws, {"text": "hello"}) + + last_msg = mock_ws.send_json.call_args_list[-1][0][0] + assert last_msg["type"] == "error" + assert "合成失败" in last_msg["message"] + + @pytest.mark.asyncio + async def test_no_audio_url_returns_error(self, streaming_service, mock_ws): + """合成结果无 audio_url 返回错误.""" + streaming_service._cosyvoice.submit_synthesize_task.return_value = {"duration": 3.0} + + await streaming_service._stream_short_text(mock_ws, {"text": "hello"}) + + last_msg = mock_ws.send_json.call_args_list[-1][0][0] + assert last_msg["type"] == "error" + assert "音频 URL" in last_msg["message"] + + @pytest.mark.asyncio + async def test_download_failure_returns_error(self, streaming_service, mock_ws): + """音频下载失败返回 error.""" + with patch.object( + streaming_service, + "_download_audio", + side_effect=Exception("download failed"), + ): + await streaming_service._stream_short_text(mock_ws, {"text": "hello"}) + + last_msg = mock_ws.send_json.call_args_list[-1][0][0] + assert last_msg["type"] == "error" + assert "音频推送失败" in last_msg["message"] + + +# ── 音频分块测试 ────────────────────────────────────────────────────────── + + +class TestStreamAudioChunks: + """_stream_audio_chunks 分块推送测试.""" + + @pytest.mark.asyncio + async def test_exact_one_chunk(self, streaming_service, mock_ws): + """恰好一个 chunk 大小的数据.""" + data = FakeAudioBytes.make(_AUDIO_CHUNK_SIZE) + total = await streaming_service._stream_audio_chunks(mock_ws, data) + + assert total == _AUDIO_CHUNK_SIZE + assert mock_ws.send_bytes.call_count == 1 + assert len(mock_ws.send_bytes.call_args[0][0]) == _AUDIO_CHUNK_SIZE + + @pytest.mark.asyncio + async def test_smaller_than_one_chunk(self, streaming_service, mock_ws): + """小于一个 chunk 的数据.""" + data = FakeAudioBytes.make(1000) + total = await streaming_service._stream_audio_chunks(mock_ws, data) + + assert total == 1000 + assert mock_ws.send_bytes.call_count == 1 + + @pytest.mark.asyncio + async def test_multiple_full_chunks(self, streaming_service, mock_ws): + """多个完整 chunk.""" + num_chunks = 5 + data = FakeAudioBytes.make(_AUDIO_CHUNK_SIZE * num_chunks) + total = await streaming_service._stream_audio_chunks(mock_ws, data) + + assert total == _AUDIO_CHUNK_SIZE * num_chunks + assert mock_ws.send_bytes.call_count == num_chunks + for c in mock_ws.send_bytes.call_args_list: + assert len(c[0][0]) == _AUDIO_CHUNK_SIZE + + @pytest.mark.asyncio + async def test_partial_last_chunk(self, streaming_service, mock_ws): + """最后一个 chunk 不完整.""" + data = FakeAudioBytes.make(_AUDIO_CHUNK_SIZE * 2 + 1234) + total = await streaming_service._stream_audio_chunks(mock_ws, data) + + assert total == _AUDIO_CHUNK_SIZE * 2 + 1234 + assert mock_ws.send_bytes.call_count == 3 + # 最后一块是 1234 字节 + last_chunk = mock_ws.send_bytes.call_args_list[-1][0][0] + assert len(last_chunk) == 1234 + + @pytest.mark.asyncio + async def test_empty_audio_sends_zero_chunks(self, streaming_service, mock_ws): + """空音频不发送任何 chunk.""" + total = await streaming_service._stream_audio_chunks(mock_ws, b"") + assert total == 0 + mock_ws.send_bytes.assert_not_called() + + @pytest.mark.asyncio + async def test_chunks_are_consecutive(self, streaming_service, mock_ws): + """所有 chunk 拼接起来等于原始数据.""" + data = bytes(range(256)) * 50 # 12800 bytes + total = await streaming_service._stream_audio_chunks(mock_ws, data) + + assert total == len(data) + # 收集所有 chunk + all_bytes = b"".join(c[0][0] for c in mock_ws.send_bytes.call_args_list) + assert all_bytes == data + + +# ── 长文本分段流测试 ────────────────────────────────────────────────────── + + +class TestStreamLongText: + """长文本分段流式合成测试.""" + + @pytest.mark.asyncio + async def test_happy_path_all_segments_ok(self, streaming_service, mock_ws): + """长文本正常流程:多个分段全部成功.""" + audio_data = FakeAudioBytes.make(2000) + with patch.object(streaming_service, "_download_audio", return_value=audio_data): + text = "你" * 1200 # 应该分成3段 + await streaming_service._stream_long_text( + mock_ws, + {"text": text, "voice_id": "v1", "format": "mp3", "speed": 1.0}, + ) + + # 检查 started 消息 + calls = mock_ws.send_json.call_args_list + assert calls[0][0][0]["type"] == "started" + segment_count = calls[0][0][0]["segment_count"] + assert segment_count >= 2 # 1200 字至少分 2 段 + + # 检查有 segment_done 消息 + segment_dones = [c for c in calls if c[0][0].get("type") == "segment_done"] + assert len(segment_dones) == segment_count + + # 检查最后是 done 消息 + last_msg = calls[-1][0][0] + assert last_msg["type"] == "done" + assert last_msg["file_size"] == 2000 * segment_count + + @pytest.mark.asyncio + async def test_first_segment_fails_returns_error(self, streaming_service, mock_ws): + """第一个分段失败,立即返回错误.""" + streaming_service._cosyvoice.submit_synthesize_task.side_effect = CosyVoiceError("segment 0 failed") + + text = "你" * 1200 + await streaming_service._stream_long_text(mock_ws, {"text": text, "voice_id": "v1"}) + + calls = mock_ws.send_json.call_args_list + last_msg = calls[-1][0][0] + assert last_msg["type"] == "error" + assert "分段" in last_msg["message"] + assert "1" in last_msg["message"] # 第1段失败 + + @pytest.mark.asyncio + async def test_segment_without_audio_url_fails(self, streaming_service, mock_ws): + """分段结果无 audio_url 视为失败.""" + # 第一段正常,第二段返回空 audio_url + call_results = [ + {"audio_url": "https://a.com/1.mp3", "duration": 2.0}, + {"audio_url": "", "duration": 0}, + {"audio_url": "https://a.com/3.mp3", "duration": 3.0}, + ] + streaming_service._cosyvoice.submit_synthesize_task.side_effect = call_results + + with patch.object( + streaming_service, + "_download_audio", + return_value=FakeAudioBytes.make(1000), + ): + text = "你" * 1500 + await streaming_service._stream_long_text(mock_ws, {"text": text, "voice_id": "v1"}) + + calls = mock_ws.send_json.call_args_list + # 应该有错误 + error_msgs = [c for c in calls if c[0][0].get("type") == "error"] + assert len(error_msgs) >= 1 + + @pytest.mark.asyncio + async def test_each_segment_gets_correct_text(self, streaming_service, mock_ws): + """每个分段都调用了合成,且 text 参数不同.""" + with patch.object( + streaming_service, + "_download_audio", + return_value=FakeAudioBytes.make(500), + ): + text = "你" * 1200 + await streaming_service._stream_long_text(mock_ws, {"text": text, "voice_id": "v1"}) + + # 分段数应大于1 + assert streaming_service._cosyvoice.submit_synthesize_task.call_count >= 2 + + # 收集所有传进去的 text + texts_called = [ + c.kwargs.get("text") or c.args[0] + for c in streaming_service._cosyvoice.submit_synthesize_task.call_args_list + ] + # 每段文本都应该是原文的一部分(不全部相同) + assert len(set(texts_called)) >= 2 + # 所有文本拼接起来应该约等于原文长度 + total_len = sum(len(t) for t in texts_called) + assert total_len >= len(text) * 0.95 # 允许标点切分的小误差 + + @pytest.mark.asyncio + async def test_each_segment_has_unique_index(self, streaming_service, mock_ws): + """segment_done 消息的序号不重复且正确.""" + with patch.object( + streaming_service, + "_download_audio", + return_value=FakeAudioBytes.make(500), + ): + text = "你" * 1200 + await streaming_service._stream_long_text(mock_ws, {"text": text, "voice_id": "v1"}) + + calls = mock_ws.send_json.call_args_list + segment_dones = [c[0][0] for c in calls if c[0][0].get("type") == "segment_done"] + indices = [s["segment"] for s in segment_dones] + total = segment_dones[0]["total"] + # 序号从 1 到 total,不重复 + assert sorted(indices) == list(range(1, total + 1)) + + +# ── WebSocket 发送失败容错 ──────────────────────────────────────────────── + + +class TestSendJsonErrorHandling: + """_send_json 容错测试.""" + + @pytest.mark.asyncio + async def test_send_json_failure_logs_warning(self, streaming_service, mock_ws): + """WebSocket send_json 失败不抛异常.""" + mock_ws.send_json.side_effect = Exception("connection closed") + + # 不应抛出异常 + await streaming_service._send_json(mock_ws, {"type": "done"}) + mock_ws.send_json.assert_called_once() + + +# ── TTSStreamingError 异常类 ────────────────────────────────────────────── + + +class TestTTSStreamingError: + """TTSStreamingError 异常类测试.""" + + def test_is_exception(self): + """是 Exception 子类.""" + assert issubclass(TTSStreamingError, Exception) + + def test_carry_message(self): + """携带错误消息.""" + err = TTSStreamingError("stream failed") + assert str(err) == "stream failed" From bb0d01f080c25002ce55870762552a462fd71570 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 00:25:32 +0800 Subject: [PATCH 13/45] test(wave104): extract template_clip_converter domain module (#982) --- .../api/app/services/edit_template_service.py | 176 +------ packages/domain/template_clip_converter.py | 293 +++++++++++ tests/unit/test_template_clip_converter.py | 480 ++++++++++++++++++ 3 files changed, 788 insertions(+), 161 deletions(-) create mode 100755 packages/domain/template_clip_converter.py create mode 100755 tests/unit/test_template_clip_converter.py diff --git a/apps/api/app/services/edit_template_service.py b/apps/api/app/services/edit_template_service.py index 43cf7af99..0784cf64f 100755 --- a/apps/api/app/services/edit_template_service.py +++ b/apps/api/app/services/edit_template_service.py @@ -23,6 +23,13 @@ from packages.domain.template_clip_config import ( TemplateClipConfig, TransitionEffect, ) +from packages.domain.template_clip_converter import ( + clip_configs_to_snapshots, + clips_to_template_clip_configs, + filter_plan_config_to_template, + snapshots_to_template_clip_configs, + validate_template_name, +) logger = logging.getLogger(__name__) @@ -121,9 +128,7 @@ class EditTemplateService: ValueError: 名称为空或重复 """ # 名称校验 - clean_name = name.strip() - if not clean_name: - raise ValueError("模板名称不能为空") + clean_name = validate_template_name(name) # 名称重复检查 existing = self._template_repo.list_all(skip=0, limit=1000) @@ -471,12 +476,7 @@ class EditTemplateService: raise ValueError(f"模板名称已存在: {clean_name}") # 从计划 config 中提取模板级配置,去掉运行时/素材相关字段 - plan_config = plan.config or {} - template_config: dict[str, Any] = {} - for key, value in plan_config.items(): - # 跳过明显的运行时/实例字段,保留风格/模式类配置 - if key not in {"asset_ids", "source_edit_plan_id", "generation_task_id"}: - template_config[key] = value + template_config = filter_plan_config_to_template(plan.config) template = EditTemplate.create( name=clean_name, @@ -497,40 +497,7 @@ class EditTemplateService: # 5. 转换每个片段为模板片段配置 created_configs: List[TemplateClipConfig] = [] - for clip in clips: - clip_config: dict[str, Any] = {} - # 播放速度存入 config - if clip.playback_speed and clip.playback_speed != 1.0: - clip_config["playback_speed"] = clip.playback_speed - # 片段自有 config 合并(优先级:clip.config 覆盖上面的) - if clip.config: - clip_config.update(clip.config) - # 去掉素材相关字段 - clip_config.pop("asset_info", None) - clip_config.pop("source_asset_id", None) - - # 转场效果兼容校验 - try: - transition = TransitionEffect(clip.transition_effect) - except ValueError: - transition = TransitionEffect.CUT - - # 片段类型兼容校验 - try: - clip_type = ClipType(clip.clip_type) - except ValueError: - clip_type = ClipType.MAIN - - clip_config_obj = TemplateClipConfig.create( - template_id=created_template.id, - clip_type=clip_type, - order=clip.order, - min_duration=clip.duration, - max_duration=clip.duration, - text_template=clip.text_content or "", - transition_effect=transition, - config=clip_config, - ) + for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips): created = self._clip_config_repo.create(clip_config_obj) created_configs.append(created) @@ -679,8 +646,6 @@ class EditTemplateService: Raises: ValueError: 模板/草稿不存在,或草稿不属于该模板 """ - from packages.domain.template_clip_config import TemplateClipConfig - # 1. 校验模板和草稿 template = self.get_template_or_raise(template_id) draft = self._plan_repo.get(draft_plan_id) @@ -700,39 +665,14 @@ class EditTemplateService: editing_mode = config.get("editing_mode", "one_take") # 4. 提取模板配置(去掉草稿/运行时字段) - draft_config = draft.config or {} - template_config: dict[str, Any] = {} - skip_keys = { - "is_template_draft", - "asset_ids", - "source_edit_plan_id", - "generation_task_id", - } - for key, value in draft_config.items(): - if key not in skip_keys: - template_config[key] = value + template_config = filter_plan_config_to_template(draft.config) # 5. 事务更新 try: # 5.0 先保存旧版快照(发布前的状态),用于回滚 old_version = template.version or 1 old_clip_configs = self._clip_config_repo.list_by_template(template_id) - old_clip_snapshots = [ - { - "clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type, - "order": cfg.order, - "min_duration": cfg.min_duration, - "max_duration": cfg.max_duration, - "text_template": cfg.text_template or "", - "transition_effect": ( - cfg.transition_effect.value - if hasattr(cfg.transition_effect, "value") - else cfg.transition_effect - ), - "config": cfg.config or {}, - } - for cfg in old_clip_configs - ] + old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs) from packages.domain.template_version import EditTemplateVersion @@ -759,46 +699,7 @@ class EditTemplateService: # 创建新的片段配置 created_configs: list[TemplateClipConfig] = [] - for clip in draft_clips: - clip_config: dict[str, Any] = {} - # 播放速度存入 config - if clip.playback_speed and clip.playback_speed != 1.0: - clip_config["playback_speed"] = clip.playback_speed - # 片段自有 config 合并 - if clip.config: - clip_config.update(clip.config) - # 去掉素材相关字段 - clip_config.pop("asset_info", None) - clip_config.pop("source_asset_id", None) - - # 转场效果兼容校验 - try: - from packages.domain.template_clip_config import ( - TransitionEffect, - ) - - transition = TransitionEffect(clip.transition_effect) - except (ValueError, ImportError): - transition = TransitionEffect.CUT # type: ignore - - # 片段类型兼容校验 - try: - from packages.domain.template_clip_config import ClipType - - clip_type = ClipType(clip.clip_type) - except (ValueError, ImportError): - clip_type = ClipType.MAIN # type: ignore - - config_obj = TemplateClipConfig.create( - template_id=template_id, - clip_type=clip_type, - order=clip.order, - min_duration=clip.duration, - max_duration=clip.duration, - text_template=clip.text_content or "", - transition_effect=transition, - config=clip_config, - ) + for config_obj in clips_to_template_clip_configs(template_id, draft_clips): created = self._clip_config_repo.create(config_obj) created_configs.append(created) @@ -843,8 +744,6 @@ class EditTemplateService: Raises: ValueError: 模板/版本不存在 """ - from packages.domain.template_clip_config import TemplateClipConfig - template = self.get_template_or_raise(template_id) # 1. 读取目标版本快照 @@ -857,22 +756,7 @@ class EditTemplateService: try: # 2. 先保存当前状态快照(当前版本号),确保回滚可撤销 old_clip_configs = self._clip_config_repo.list_by_template(template_id) - old_clip_snapshots = [ - { - "clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type, - "order": cfg.order, - "min_duration": cfg.min_duration, - "max_duration": cfg.max_duration, - "text_template": cfg.text_template or "", - "transition_effect": ( - cfg.transition_effect.value - if hasattr(cfg.transition_effect, "value") - else cfg.transition_effect - ), - "config": cfg.config or {}, - } - for cfg in old_clip_configs - ] + old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs) from packages.domain.template_version import EditTemplateVersion @@ -905,37 +789,7 @@ class EditTemplateService: synchronize_session=False ) - for clip_snap in target_version.clip_configs: - # 转场效果兼容校验 - try: - from packages.domain.template_clip_config import TransitionEffect - - transition = TransitionEffect(clip_snap.get("transition_effect", "cut")) - except (ValueError, ImportError): - from packages.domain.template_clip_config import TransitionEffect - - transition = TransitionEffect.CUT - - # 片段类型兼容校验 - try: - from packages.domain.template_clip_config import ClipType - - clip_type = ClipType(clip_snap.get("clip_type", "main")) - except (ValueError, ImportError): - from packages.domain.template_clip_config import ClipType - - clip_type = ClipType.MAIN - - config_obj = TemplateClipConfig.create( - template_id=template_id, - clip_type=clip_type, - order=clip_snap.get("order", 0), - min_duration=clip_snap.get("min_duration", 0.0), - max_duration=clip_snap.get("max_duration", 0.0), - text_template=clip_snap.get("text_template", ""), - transition_effect=transition, - config=clip_snap.get("config", {}) or {}, - ) + for config_obj in snapshots_to_template_clip_configs(template_id, target_version.clip_configs): self._clip_config_repo.create(config_obj) self._db.commit() diff --git a/packages/domain/template_clip_converter.py b/packages/domain/template_clip_converter.py new file mode 100755 index 000000000..4648277c6 --- /dev/null +++ b/packages/domain/template_clip_converter.py @@ -0,0 +1,293 @@ +"""模板片段转换器 — 纯函数集合. + +从 edit_template_service.py 抽离的纯逻辑,负责在不同数据形态间转换: +- 剪辑计划片段 (EditPlanClip) → 模板片段配置 (TemplateClipConfig) +- 模板片段配置 → 版本快照 dict +- 版本快照 dict → 模板片段配置 +- 计划 config → 模板 config(过滤运行时字段) + +所有函数均为纯函数,不依赖数据库或外部 IO。 +""" + +from __future__ import annotations + +from typing import Any + +from packages.domain.template_clip_config import ( + ClipType, + TemplateClipConfig, + TransitionEffect, +) + +# ── 安全枚举解析 ──────────────────────────────────────────────────────────── + + +def safe_parse_transition_effect(value: Any, default: TransitionEffect = TransitionEffect.CUT) -> TransitionEffect: + """安全解析转场效果枚举,解析失败返回默认值。 + + Args: + value: 待解析的值(枚举、字符串或其他) + default: 解析失败时的默认值 + + Returns: + TransitionEffect 枚举值 + """ + if isinstance(value, TransitionEffect): + return value + try: + return TransitionEffect(value) + except (ValueError, TypeError): + return default + + +def safe_parse_clip_type(value: Any, default: ClipType = ClipType.MAIN) -> ClipType: + """安全解析片段类型枚举,解析失败返回默认值。 + + Args: + value: 待解析的值(枚举、字符串或其他) + default: 解析失败时的默认值 + + Returns: + ClipType 枚举值 + """ + if isinstance(value, ClipType): + return value + try: + return ClipType(value) + except (ValueError, TypeError): + return default + + +# ── Config 字段过滤 ───────────────────────────────────────────────────────── + +# 默认需要从 clip config 中移除的素材/运行时字段 +_DEFAULT_CLIP_CONFIG_SKIP_KEYS = frozenset( + { + "asset_info", + "source_asset_id", + } +) + +# 默认需要从 plan config 中移除的运行时/实例字段 +_DEFAULT_PLAN_CONFIG_SKIP_KEYS = frozenset( + { + "is_template_draft", + "asset_ids", + "source_edit_plan_id", + "generation_task_id", + } +) + + +def filter_clip_config( + clip_config: dict[str, Any] | None, + playback_speed: float | None = None, + skip_keys: frozenset[str] | None = None, +) -> dict[str, Any]: + """构建模板片段的 config 字典。 + + 处理逻辑: + 1. 如果 playback_speed 存在且不等于 1.0,加入 config + 2. 合并 clip 自身的 config + 3. 移除素材相关字段 + + Args: + clip_config: 原始片段 config(可为 None) + playback_speed: 播放速度(可选,1.0 时不写入) + skip_keys: 需要跳过的字段集合(None 时用默认) + + Returns: + 过滤后的 config 字典 + """ + skip = skip_keys if skip_keys is not None else _DEFAULT_CLIP_CONFIG_SKIP_KEYS + result: dict[str, Any] = {} + + if playback_speed is not None and playback_speed != 1.0: + result["playback_speed"] = playback_speed + + if clip_config: + result.update(clip_config) + + for key in skip: + result.pop(key, None) + + return result + + +def filter_plan_config_to_template( + plan_config: dict[str, Any] | None, + skip_keys: frozenset[str] | None = None, +) -> dict[str, Any]: + """从计划 config 中提取模板 config(过滤运行时/实例字段)。 + + Args: + plan_config: 原始计划 config(可为 None) + skip_keys: 需要跳过的字段集合(None 时用默认) + + Returns: + 过滤后的模板 config + """ + skip = skip_keys if skip_keys is not None else _DEFAULT_PLAN_CONFIG_SKIP_KEYS + if not plan_config: + return {} + return {k: v for k, v in plan_config.items() if k not in skip} + + +# ── Clip → TemplateClipConfig 转换 ──────────────────────────────────────── + + +def clip_to_template_clip_config( + template_id: str, + clip: Any, +) -> TemplateClipConfig: + """将剪辑计划片段转换为模板片段配置。 + + 转换规则: + - clip_type → 安全解析后映射 + - order → 保持不变 + - duration → min_duration = max_duration = duration(固定时长) + - text_content → text_template + - transition_effect → 安全解析后映射 + - playback_speed → 存入 config(非 1.0 时) + - clip.config → 合并入 config(过滤素材字段) + + Args: + template_id: 目标模板 ID + clip: 源片段对象(需有 clip_type/order/duration/text_content/ + transition_effect/playback_speed/config 属性) + + Returns: + 新创建的 TemplateClipConfig 实例 + """ + clip_type = safe_parse_clip_type(getattr(clip, "clip_type", None)) + transition = safe_parse_transition_effect(getattr(clip, "transition_effect", None)) + + config = filter_clip_config( + getattr(clip, "config", None), + playback_speed=getattr(clip, "playback_speed", None), + ) + + duration = getattr(clip, "duration", 0.0) or 0.0 + + return TemplateClipConfig.create( + template_id=template_id, + clip_type=clip_type, + order=getattr(clip, "order", 0), + min_duration=duration, + max_duration=duration, + text_template=getattr(clip, "text_content", "") or "", + transition_effect=transition, + config=config, + ) + + +def clips_to_template_clip_configs( + template_id: str, + clips: list[Any], +) -> list[TemplateClipConfig]: + """批量将剪辑计划片段转换为模板片段配置列表。 + + Args: + template_id: 目标模板 ID + clips: 源片段对象列表 + + Returns: + TemplateClipConfig 实例列表 + """ + return [clip_to_template_clip_config(template_id, c) for c in clips] + + +# ── TemplateClipConfig → Snapshot 转换 ──────────────────────────────────── + + +def _enum_value(value: Any) -> Any: + """获取枚举的 value 值(兼容枚举和字符串)。""" + if hasattr(value, "value"): + return value.value + return value + + +def clip_config_to_snapshot(cfg: Any) -> dict[str, Any]: + """将模板片段配置转换为版本快照 dict。 + + Args: + cfg: TemplateClipConfig 对象(或有对应属性的对象) + + Returns: + 快照字典,包含 clip_type/order/min_duration/max_duration/ + text_template/transition_effect/config + """ + return { + "clip_type": _enum_value(getattr(cfg, "clip_type", None)), + "order": getattr(cfg, "order", 0), + "min_duration": getattr(cfg, "min_duration", 0.0), + "max_duration": getattr(cfg, "max_duration", 0.0), + "text_template": getattr(cfg, "text_template", "") or "", + "transition_effect": _enum_value(getattr(cfg, "transition_effect", None)), + "config": dict(getattr(cfg, "config", {}) or {}), + } + + +def clip_configs_to_snapshots(configs: list[Any]) -> list[dict[str, Any]]: + """批量将模板片段配置转换为版本快照列表。""" + return [clip_config_to_snapshot(c) for c in configs] + + +# ── Snapshot → TemplateClipConfig 转换 ──────────────────────────────────── + + +def snapshot_to_template_clip_config( + template_id: str, + snapshot: dict[str, Any], +) -> TemplateClipConfig: + """将版本快照 dict 转换为模板片段配置。 + + Args: + template_id: 目标模板 ID + snapshot: 快照字典 + + Returns: + 新创建的 TemplateClipConfig 实例 + """ + clip_type = safe_parse_clip_type(snapshot.get("clip_type", "main")) + transition = safe_parse_transition_effect(snapshot.get("transition_effect", "cut")) + + return TemplateClipConfig.create( + template_id=template_id, + clip_type=clip_type, + order=snapshot.get("order", 0), + min_duration=snapshot.get("min_duration", 0.0), + max_duration=snapshot.get("max_duration", 0.0), + text_template=snapshot.get("text_template", ""), + transition_effect=transition, + config=dict(snapshot.get("config", {}) or {}), + ) + + +def snapshots_to_template_clip_configs( + template_id: str, + snapshots: list[dict[str, Any]], +) -> list[TemplateClipConfig]: + """批量将版本快照转换为模板片段配置列表。""" + return [snapshot_to_template_clip_config(template_id, s) for s in snapshots] + + +# ── 名称校验工具 ────────────────────────────────────────────────────────── + + +def validate_template_name(name: str | None) -> str: + """校验并清洗模板名称。 + + Args: + name: 原始名称 + + Returns: + 清洗后的名称(去除首尾空格) + + Raises: + ValueError: 名称为空 + """ + clean_name = name.strip() if name else "" + if not clean_name: + raise ValueError("模板名称不能为空") + return clean_name diff --git a/tests/unit/test_template_clip_converter.py b/tests/unit/test_template_clip_converter.py new file mode 100755 index 000000000..6ef9c4651 --- /dev/null +++ b/tests/unit/test_template_clip_converter.py @@ -0,0 +1,480 @@ +"""template_clip_converter 模块单元测试.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest + +from packages.domain.template_clip_config import ClipType, TransitionEffect +from packages.domain.template_clip_converter import ( + clip_config_to_snapshot, + clip_configs_to_snapshots, + clip_to_template_clip_config, + clips_to_template_clip_configs, + filter_clip_config, + filter_plan_config_to_template, + safe_parse_clip_type, + safe_parse_transition_effect, + snapshot_to_template_clip_config, + snapshots_to_template_clip_configs, + validate_template_name, +) + +# ── 辅助数据类 ────────────────────────────────────────────────────────────── + + +@dataclass +class FakeClip: + """模拟剪辑计划片段对象.""" + + clip_type: Any = "main" + order: int = 0 + duration: float = 5.0 + text_content: str = "" + transition_effect: Any = "cut" + playback_speed: float | None = None + config: dict[str, Any] | None = None + + +@dataclass +class FakeClipConfig: + """模拟模板片段配置对象.""" + + clip_type: Any = "main" + order: int = 0 + min_duration: float = 0.0 + max_duration: float = 0.0 + text_template: str = "" + transition_effect: Any = "cut" + config: dict[str, Any] | None = None + + +# ── safe_parse_transition_effect ──────────────────────────────────────────── + + +class TestSafeParseTransitionEffect: + def test_enum_value_passthrough(self): + assert safe_parse_transition_effect(TransitionEffect.DISSOLVE) == TransitionEffect.DISSOLVE + + def test_valid_string(self): + assert safe_parse_transition_effect("dissolve") == TransitionEffect.DISSOLVE + + def test_invalid_string_defaults_to_cut(self): + assert safe_parse_transition_effect("invalid_effect") == TransitionEffect.CUT + + def test_none_defaults_to_cut(self): + assert safe_parse_transition_effect(None) == TransitionEffect.CUT + + def test_custom_default(self): + assert safe_parse_transition_effect("bad", default=TransitionEffect.FADE) == TransitionEffect.FADE + + def test_int_value(self): + assert safe_parse_transition_effect(123) == TransitionEffect.CUT + + +# ── safe_parse_clip_type ──────────────────────────────────────────────────── + + +class TestSafeParseClipType: + def test_enum_value_passthrough(self): + assert safe_parse_clip_type(ClipType.INTRO) == ClipType.INTRO + + def test_valid_string(self): + assert safe_parse_clip_type("intro") == ClipType.INTRO + + def test_invalid_string_defaults_to_main(self): + assert safe_parse_clip_type("invalid_type") == ClipType.MAIN + + def test_none_defaults_to_main(self): + assert safe_parse_clip_type(None) == ClipType.MAIN + + def test_custom_default(self): + assert safe_parse_clip_type("bad", default=ClipType.OUTRO) == ClipType.OUTRO + + def test_int_value(self): + assert safe_parse_clip_type(42) == ClipType.MAIN + + +# ── filter_clip_config ────────────────────────────────────────────────────── + + +class TestFilterClipConfig: + def test_none_config_no_speed(self): + result = filter_clip_config(None) + assert result == {} + + def test_empty_config_no_speed(self): + result = filter_clip_config({}) + assert result == {} + + def test_playback_speed_added_when_not_default(self): + result = filter_clip_config(None, playback_speed=1.5) + assert result == {"playback_speed": 1.5} + + def test_playback_speed_skipped_when_default(self): + result = filter_clip_config(None, playback_speed=1.0) + assert result == {} + + def test_playback_speed_none_skipped(self): + result = filter_clip_config(None, playback_speed=None) + assert result == {} + + def test_config_merged(self): + result = filter_clip_config({"filter": "vintage", "intensity": 0.5}) + assert result == {"filter": "vintage", "intensity": 0.5} + + def test_asset_info_removed(self): + result = filter_clip_config({"asset_info": {"name": "test.mp4"}, "filter": "vintage"}) + assert "asset_info" not in result + assert result["filter"] == "vintage" + + def test_source_asset_id_removed(self): + result = filter_clip_config({"source_asset_id": "abc123", "filter": "vintage"}) + assert "source_asset_id" not in result + assert result["filter"] == "vintage" + + def test_speed_overrides_config_playback_speed(self): + result = filter_clip_config({"playback_speed": 2.0}, playback_speed=0.5) + assert result["playback_speed"] == 2.0 # config 优先级更高 + + def test_custom_skip_keys(self): + skip = frozenset({"custom_field"}) + result = filter_clip_config( + {"custom_field": "x", "asset_info": "keep_it"}, + skip_keys=skip, + ) + assert "custom_field" not in result + assert "asset_info" in result # 自定义 skip 覆盖默认 + + +# ── filter_plan_config_to_template ────────────────────────────────────────── + + +class TestFilterPlanConfigToTemplate: + def test_none_config(self): + assert filter_plan_config_to_template(None) == {} + + def test_empty_config(self): + assert filter_plan_config_to_template({}) == {} + + def test_draft_flag_removed(self): + result = filter_plan_config_to_template({"is_template_draft": True, "editing_mode": "one_take"}) + assert "is_template_draft" not in result + assert result["editing_mode"] == "one_take" + + def test_asset_ids_removed(self): + result = filter_plan_config_to_template({"asset_ids": ["a", "b"], "resolution": "1080p"}) + assert "asset_ids" not in result + assert result["resolution"] == "1080p" + + def test_source_edit_plan_id_removed(self): + result = filter_plan_config_to_template({"source_edit_plan_id": "plan123", "theme": "dark"}) + assert "source_edit_plan_id" not in result + assert result["theme"] == "dark" + + def test_generation_task_id_removed(self): + result = filter_plan_config_to_template({"generation_task_id": "task123", "bgm": "on"}) + assert "generation_task_id" not in result + assert result["bgm"] == "on" + + def test_normal_fields_preserved(self): + config = { + "editing_mode": "pip", + "resolution": "720p", + "duration": 30, + "style": "cinematic", + } + result = filter_plan_config_to_template(config) + assert result == config + + def test_custom_skip_keys(self): + skip = frozenset({"secret_field"}) + result = filter_plan_config_to_template( + {"secret_field": "x", "is_template_draft": "keep"}, + skip_keys=skip, + ) + assert "secret_field" not in result + assert "is_template_draft" in result + + +# ── clip_to_template_clip_config ──────────────────────────────────────────── + + +class TestClipToTemplateClipConfig: + def test_basic_conversion(self): + clip = FakeClip( + clip_type="main", + order=2, + duration=3.5, + text_content="Hello world", + transition_effect="dissolve", + ) + result = clip_to_template_clip_config("tmpl_001", clip) + assert result.template_id == "tmpl_001" + assert result.clip_type == ClipType.MAIN + assert result.order == 2 + assert result.min_duration == 3.5 + assert result.max_duration == 3.5 + assert result.text_template == "Hello world" + assert result.transition_effect == TransitionEffect.DISSOLVE + + def test_playback_speed_in_config(self): + clip = FakeClip(playback_speed=2.0) + result = clip_to_template_clip_config("tmpl_001", clip) + assert result.config["playback_speed"] == 2.0 + + def test_default_speed_not_in_config(self): + clip = FakeClip(playback_speed=1.0) + result = clip_to_template_clip_config("tmpl_001", clip) + assert "playback_speed" not in result.config + + def test_config_preserved_and_filtered(self): + clip = FakeClip(config={"filter": "vintage", "asset_info": {"id": "x"}}) + result = clip_to_template_clip_config("tmpl_001", clip) + assert result.config["filter"] == "vintage" + assert "asset_info" not in result.config + + def test_text_content_none_becomes_empty(self): + clip = FakeClip(text_content=None) + result = clip_to_template_clip_config("tmpl_001", clip) + assert result.text_template == "" + + def test_invalid_type_falls_back(self): + clip = FakeClip(clip_type="nonexistent") + result = clip_to_template_clip_config("tmpl_001", clip) + assert result.clip_type == ClipType.MAIN + + def test_invalid_transition_falls_back(self): + clip = FakeClip(transition_effect="nonexistent") + result = clip_to_template_clip_config("tmpl_001", clip) + assert result.transition_effect == TransitionEffect.CUT + + def test_enum_type_input(self): + clip = FakeClip(clip_type=ClipType.INTRO, transition_effect=TransitionEffect.FADE) + result = clip_to_template_clip_config("tmpl_001", clip) + assert result.clip_type == ClipType.INTRO + assert result.transition_effect == TransitionEffect.FADE + + def test_duration_none_defaults_zero(self): + clip = FakeClip(duration=None) + result = clip_to_template_clip_config("tmpl_001", clip) + assert result.min_duration == 0.0 + assert result.max_duration == 0.0 + + +class TestClipsToTemplateClipConfigs: + def test_empty_list(self): + result = clips_to_template_clip_configs("tmpl_001", []) + assert result == [] + + def test_multiple_clips(self): + clips = [ + FakeClip(clip_type="intro", order=0, duration=2.0), + FakeClip(clip_type="main", order=1, duration=5.0), + FakeClip(clip_type="outro", order=2, duration=3.0), + ] + result = clips_to_template_clip_configs("tmpl_001", clips) + assert len(result) == 3 + assert result[0].clip_type == ClipType.INTRO + assert result[1].clip_type == ClipType.MAIN + assert result[2].clip_type == ClipType.OUTRO + assert all(r.template_id == "tmpl_001" for r in result) + + +# ── clip_config_to_snapshot ──────────────────────────────────────────────── + + +class TestClipConfigToSnapshot: + def test_basic_snapshot(self): + cfg = FakeClipConfig( + clip_type="main", + order=1, + min_duration=2.0, + max_duration=5.0, + text_template="hello", + transition_effect="dissolve", + config={"filter": "vintage"}, + ) + snap = clip_config_to_snapshot(cfg) + assert snap["clip_type"] == "main" + assert snap["order"] == 1 + assert snap["min_duration"] == 2.0 + assert snap["max_duration"] == 5.0 + assert snap["text_template"] == "hello" + assert snap["transition_effect"] == "dissolve" + assert snap["config"] == {"filter": "vintage"} + + def test_enum_values_converted_to_strings(self): + cfg = FakeClipConfig( + clip_type=ClipType.INTRO, + transition_effect=TransitionEffect.FADE, + ) + snap = clip_config_to_snapshot(cfg) + assert snap["clip_type"] == "intro" + assert snap["transition_effect"] == "fade" + + def test_none_text_template_becomes_empty(self): + cfg = FakeClipConfig(text_template=None) + snap = clip_config_to_snapshot(cfg) + assert snap["text_template"] == "" + + def test_none_config_becomes_empty_dict(self): + cfg = FakeClipConfig(config=None) + snap = clip_config_to_snapshot(cfg) + assert snap["config"] == {} + + def test_config_is_copy_not_reference(self): + original = {"key": "value"} + cfg = FakeClipConfig(config=original) + snap = clip_config_to_snapshot(cfg) + snap["config"]["key"] = "modified" + assert original["key"] == "value" + + +class TestClipConfigsToSnapshots: + def test_empty_list(self): + assert clip_configs_to_snapshots([]) == [] + + def test_multiple_configs(self): + configs = [ + FakeClipConfig(clip_type="intro", order=0), + FakeClipConfig(clip_type="main", order=1), + ] + result = clip_configs_to_snapshots(configs) + assert len(result) == 2 + assert result[0]["clip_type"] == "intro" + assert result[1]["order"] == 1 + + +# ── snapshot_to_template_clip_config ──────────────────────────────────────── + + +class TestSnapshotToTemplateClipConfig: + def test_basic_conversion(self): + snap = { + "clip_type": "intro", + "order": 2, + "min_duration": 1.0, + "max_duration": 3.0, + "text_template": "hi", + "transition_effect": "dissolve", + "config": {"filter": "bw"}, + } + result = snapshot_to_template_clip_config("tmpl_001", snap) + assert result.template_id == "tmpl_001" + assert result.clip_type == ClipType.INTRO + assert result.order == 2 + assert result.min_duration == 1.0 + assert result.max_duration == 3.0 + assert result.text_template == "hi" + assert result.transition_effect == TransitionEffect.DISSOLVE + assert result.config == {"filter": "bw"} + + def test_missing_fields_get_defaults(self): + result = snapshot_to_template_clip_config("tmpl_001", {}) + assert result.clip_type == ClipType.MAIN + assert result.order == 0 + assert result.min_duration == 0.0 + assert result.max_duration == 0.0 + assert result.text_template == "" + assert result.transition_effect == TransitionEffect.CUT + assert result.config == {} + + def test_invalid_type_falls_back(self): + snap = {"clip_type": "invalid"} + result = snapshot_to_template_clip_config("tmpl_001", snap) + assert result.clip_type == ClipType.MAIN + + def test_invalid_transition_falls_back(self): + snap = {"transition_effect": "invalid"} + result = snapshot_to_template_clip_config("tmpl_001", snap) + assert result.transition_effect == TransitionEffect.CUT + + def test_none_config_becomes_empty_dict(self): + snap = {"config": None} + result = snapshot_to_template_clip_config("tmpl_001", snap) + assert result.config == {} + + +class TestSnapshotsToTemplateClipConfigs: + def test_empty_list(self): + assert snapshots_to_template_clip_configs("tmpl_001", []) == [] + + def test_multiple_snapshots(self): + snaps = [ + {"clip_type": "intro", "order": 0}, + {"clip_type": "outro", "order": 2}, + ] + result = snapshots_to_template_clip_configs("tmpl_001", snaps) + assert len(result) == 2 + assert result[0].clip_type == ClipType.INTRO + assert result[1].clip_type == ClipType.OUTRO + assert all(r.template_id == "tmpl_001" for r in result) + + +# ── 往返一致性测试 ────────────────────────────────────────────────────────── + + +class TestRoundTrip: + def test_snapshot_clip_config_roundtrip(self): + """snapshot → TemplateClipConfig → snapshot 应保持一致.""" + original = { + "clip_type": "intro", + "order": 3, + "min_duration": 1.5, + "max_duration": 4.0, + "text_template": "test text", + "transition_effect": "dissolve", + "config": {"key": "value", "nested": {"a": 1}}, + } + cfg = snapshot_to_template_clip_config("tmpl_test", original) + result = clip_config_to_snapshot(cfg) + assert result == original + + def test_clip_to_config_to_snapshot(self): + """clip → TemplateClipConfig → snapshot 的预期结果.""" + clip = FakeClip( + clip_type="main", + order=1, + duration=5.0, + text_content="hello", + transition_effect="fade", + playback_speed=1.5, + config={"filter": "vintage", "asset_info": "should_remove"}, + ) + cfg = clip_to_template_clip_config("tmpl_001", clip) + snap = clip_config_to_snapshot(cfg) + assert snap["clip_type"] == "main" + assert snap["order"] == 1 + assert snap["min_duration"] == 5.0 + assert snap["max_duration"] == 5.0 + assert snap["text_template"] == "hello" + assert snap["transition_effect"] == "fade" + assert snap["config"]["playback_speed"] == 1.5 + assert snap["config"]["filter"] == "vintage" + assert "asset_info" not in snap["config"] + + +# ── validate_template_name ────────────────────────────────────────────────── + + +class TestValidateTemplateName: + def test_valid_name(self): + assert validate_template_name("My Template") == "My Template" + + def test_strips_whitespace(self): + assert validate_template_name(" Hello ") == "Hello" + + def test_empty_string_raises(self): + with pytest.raises(ValueError, match="名称不能为空"): + validate_template_name("") + + def test_whitespace_only_raises(self): + with pytest.raises(ValueError, match="名称不能为空"): + validate_template_name(" ") + + def test_none_raises(self): + with pytest.raises(ValueError, match="名称不能为空"): + validate_template_name(None) From bda2170b9d0a63201a1d1cd0485d25726fc4e2d9 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 00:25:32 +0800 Subject: [PATCH 14/45] test(wave105): extract render_layer_utils domain module (#983) --- .../unified_render_service.py | 106 ++--- packages/domain/render_layer_utils.py | 241 ++++++++++++ tests/unit/test_render_layer_utils.py | 361 ++++++++++++++++++ 3 files changed, 636 insertions(+), 72 deletions(-) create mode 100755 packages/domain/render_layer_utils.py create mode 100755 tests/unit/test_render_layer_utils.py diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 45a3e0c85..3a13199fa 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -52,6 +52,15 @@ from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_fr from video_processing.tts_engine import TtsEngine from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine +from packages.domain.render_layer_utils import ( + LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX, + can_pass_through as _can_pass_through_pure, + clip_adjusted_duration as _clip_adjusted_duration_pure, + clip_effective_duration as _clip_effective_duration_pure, + clip_playback_speed as _clip_playback_speed_pure, + estimate_total_duration as _estimate_total_duration_pure, + resolve_layer_role as _resolve_layer_role_pure, +) from packages.domain.tts_config import TtsConfig logger = logging.getLogger(__name__) @@ -107,47 +116,16 @@ class RenderResult: def _resolve_layer_role(clip_type: str, config: dict[str, Any]) -> str: - """根据 clip_type 和 config.role 确定图层角色。 + """根据 clip_type 和 config.role 确定图层角色(向后兼容别名)。 - 映射规则: - intro / outro → "main"(按 order 排在首/尾) - overlay → "overlay"(画中画叠加,z=1) - corner_voice → "corner_voice"(右上角小窗,z=1) - background → "background"(全屏底图,z=0) - b_roll → "broll"(z=0) - main + config.role=b_roll → "broll" - main (default) → "main" + 实际实现移至 packages.domain.render_layer_utils.resolve_layer_role。 """ - role = config.get("role", "") - - if clip_type in ("intro", "outro"): - return "main" - if clip_type == "overlay": - return "overlay" - if clip_type == "corner_voice": - return "corner_voice" - if clip_type == "background": - return "background" - if clip_type == "b_roll": - return "broll" - # main type - if role == "b_roll": - return "broll" - if role == "audio": - return "audio" - return "main" + return _resolve_layer_role_pure(clip_type, config) # ── 图层默认 z_index ───────────────────────────────────────────────────────── -_LAYER_Z_INDEX: dict[str, int] = { - "background": -1, - "broll": 0, - "main": 0, - "overlay": 1, - "corner_voice": 1, - "audio": 2, -} +_LAYER_Z_INDEX: dict[str, int] = _IMPORTED_LAYER_Z_INDEX # 图层默认 PiP 位置(相对输出画布的偏移) _PIP_SCALE = 0.25 # PiP 占主画面的比例 @@ -489,29 +467,9 @@ class UnifiedRenderService: def _estimate_total_duration(self, layers: list[RenderLayer]) -> float: """估算视频总时长(用于字幕等需要)。 - 取主图层(main/broll/background)的总时长,转场重叠按 transition_duration 估算。 + 实际实现移至 packages.domain.render_layer_utils.estimate_total_duration。 """ - # 找主图层(第一个有视频内容的图层) - main_layer = None - for role in ("main", "broll", "background"): - for layer in layers: - if layer.role == role: - main_layer = layer - break - if main_layer: - break - - if not main_layer or not main_layer.clips: - return 0.0 - - total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in main_layer.clips) - - # 减去转场重叠时间(粗略估算) - n_clips = len(main_layer.clips) - if n_clips > 1: - total -= (n_clips - 1) * self.transition_duration - - return max(0.1, total) + return _estimate_total_duration_pure(layers, self.transition_duration) def _maybe_generate_ass(self, video_duration: float) -> Path | None: """根据 plan.config 生成 ASS 字幕文件。 @@ -1869,10 +1827,11 @@ class UnifiedRenderService: @staticmethod def _clip_effective_duration(clip: ResolvedClip) -> float: - """计算 clip 的有效时长(原速 trim 后时长).""" - if clip.duration > 0: - return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration - return clip.actual_duration if clip.actual_duration > 0 else 0.0 + """计算 clip 的有效时长(原速 trim 后时长)。 + + 实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。 + """ + return _clip_effective_duration_pure(clip.duration, clip.actual_duration) # ── 画中画(PiP)相关方法 ────────────────────────────────────────────────── @@ -1969,17 +1928,20 @@ class UnifiedRenderService: @staticmethod def _clip_speed(clip: ResolvedClip) -> float: - """获取 clip 的播放速度,无效值回退到 1.0.""" - speed = getattr(clip, "playback_speed", 1.0) - if not isinstance(speed, (int, float)) or speed <= 0: - return 1.0 - return float(speed) + """获取 clip 的播放速度,无效值回退到 1.0。 + + 实际实现移至 packages.domain.render_layer_utils.clip_playback_speed。 + """ + return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0)) @staticmethod def _clip_adjusted_duration(clip: ResolvedClip) -> float: - """计算调速后的 clip 实际时长(用于拼接计算).""" - base = UnifiedRenderService._clip_effective_duration(clip) - speed = UnifiedRenderService._clip_speed(clip) - if abs(speed - 1.0) < 1e-6: - return base - return base / speed + """计算调速后的 clip 实际时长(用于拼接计算)。 + + 实际实现移至 packages.domain.render_layer_utils.clip_adjusted_duration。 + """ + return _clip_adjusted_duration_pure( + clip.duration, + clip.actual_duration, + getattr(clip, "playback_speed", 1.0), + ) diff --git a/packages/domain/render_layer_utils.py b/packages/domain/render_layer_utils.py new file mode 100755 index 000000000..003271f55 --- /dev/null +++ b/packages/domain/render_layer_utils.py @@ -0,0 +1,241 @@ +"""渲染图层工具函数 — 纯函数集合. + +从 unified_render_service.py 抽离的纯逻辑,负责: +- clip 时长计算(有效时长、调速后时长) +- clip_type → layer_role 映射 +- 总时长估算 +- 图层默认属性(z_index 等) + +所有函数均为纯函数,不依赖 FFmpeg、数据库或外部 IO。 +""" + +from __future__ import annotations + +from typing import Any + +# ── 图层角色定义 ───────────────────────────────────────────────────────────── + +# 图层默认 z_index 映射 +LAYER_Z_INDEX: dict[str, int] = { + "background": -1, + "broll": 0, + "main": 0, + "overlay": 1, + "corner_voice": 1, + "audio": 2, +} + +# 图层默认 PiP 缩放比例(相对于主画面) +PIP_DEFAULT_SCALE = 0.25 + +# 主视频图层角色(用于总时长计算、直通判断等) +MAIN_LAYER_ROLES = frozenset({"main", "broll", "background"}) + + +# ── clip_type → layer_role 映射 ────────────────────────────────────────────── + + +def resolve_layer_role(clip_type: str, config: dict[str, Any] | None = None) -> str: + """根据 clip_type 和 config.role 确定图层角色。 + + 映射规则: + intro / outro → "main"(按 order 排在首/尾) + overlay → "overlay"(画中画叠加,z=1) + corner_voice → "corner_voice"(右上角小窗,z=1) + background → "background"(全屏底图,z=0) + b_roll → "broll"(z=0) + main + config.role=b_roll → "broll" + main + config.role=audio → "audio" + main (default) → "main" + + Args: + clip_type: 片段类型字符串 + config: 片段配置字典(可选) + + Returns: + 图层角色字符串 + """ + role = (config or {}).get("role", "") if config else "" + + if clip_type in ("intro", "outro"): + return "main" + if clip_type == "overlay": + return "overlay" + if clip_type == "corner_voice": + return "corner_voice" + if clip_type == "background": + return "background" + if clip_type == "b_roll": + return "broll" + # main type + if role == "b_roll": + return "broll" + if role == "audio": + return "audio" + return "main" + + +def get_layer_z_index(role: str) -> int: + """获取图层角色的默认 z_index。 + + Args: + role: 图层角色 + + Returns: + z_index 值,未知角色返回 0 + """ + return LAYER_Z_INDEX.get(role, 0) + + +# ── clip 时长计算 ────────────────────────────────────────────────────────── + + +def clip_effective_duration( + duration: float, + actual_duration: float = 0.0, +) -> float: + """计算 clip 的有效时长(原速 trim 后时长)。 + + 规则: + - duration > 0: min(duration, actual_duration),actual=0 时用 duration + - duration <= 0: actual_duration,actual=0 时返回 0 + + Args: + duration: 配置的时长(0 表示使用完整素材) + actual_duration: 素材实际时长(probe 后的结果) + + Returns: + 有效时长(秒) + """ + if duration > 0: + return min(duration, actual_duration) if actual_duration > 0 else duration + return actual_duration if actual_duration > 0 else 0.0 + + +def clip_playback_speed(playback_speed: Any) -> float: + """获取 clip 的播放速度,无效值回退到 1.0。 + + Args: + playback_speed: 播放速度(可为任意类型 + + Returns: + 有效的播放速度(正数) + """ + if not isinstance(playback_speed, (int, float)): + return 1.0 + if playback_speed <= 0: + return 1.0 + return float(playback_speed) + + +def clip_adjusted_duration( + duration: float, + actual_duration: float = 0.0, + playback_speed: Any = 1.0, +) -> float: + """计算调速后的 clip 实际时长(用于拼接计算)。 + + Args: + duration: 配置的时长 + actual_duration: 素材实际时长 + playback_speed: 播放速度 + + Returns: + 调速后的时长 + """ + base = clip_effective_duration(duration, actual_duration) + speed = clip_playback_speed(playback_speed) + if abs(speed - 1.0) < 1e-6: + return base + return base / speed + + +# ── 总时长估算 ──────────────────────────────────────────────────────────── + + +def estimate_total_duration( + layers: list[Any], + transition_duration: float = 0.0, +) -> float: + """估算视频总时长。 + + 取主图层(main/broll/background)的总调整后时长,减去转场重叠时间。 + + Args: + layers: 图层列表(每个元素需有 role 和 clips 属性, + clips 中元素需有 duration/actual_duration/playback_speed 属性) + transition_duration: 转场时长(秒),用于估算重叠时间 + + Returns: + 估算的总时长(秒),最小 0.1 + """ + # 找主图层(第一个有视频内容的图层) + main_layer = None + for role in ("main", "broll", "background"): + for layer in layers: + if getattr(layer, "role", None) == role and getattr(layer, "clips", None): + main_layer = layer + break + if main_layer: + break + + if not main_layer or not getattr(main_layer, "clips", None): + return 0.0 + + clips = getattr(main_layer, "clips", []) + total = sum( + clip_adjusted_duration( + duration=getattr(c, "duration", 0), + actual_duration=getattr(c, "actual_duration", 0.0), + playback_speed=getattr(c, "playback_speed", 1.0), + ) + for c in clips + ) + + # 减去转场重叠时间(粗略估算) + n_clips = len(clips) + if n_clips > 1 and transition_duration > 0: + total -= (n_clips - 1) * transition_duration + + return max(0.1, total) + + +# ── 直通 / Stream Copy 判断辅助 ────────────────────────────────────────────── + + +def can_pass_through( + layers: list[Any], + has_stickers: bool = False, + has_watermark: bool = False, +) -> bool: + """判断是否可以走直通优化路径(单 clip 简单场景)。 + + 条件: + 1. 只有 1 个图层 + 2. 该图层是视频图层(main/broll/background) + 3. 该图层只有 1 个 clip(无转场需求) + 4. 没有贴纸 + 5. 没有水印 + + Args: + layers: 图层列表 + has_stickers: 是否有贴纸 + has_watermark: 是否有水印 + + Returns: + 是否可以走直通 + """ + if len(layers) != 1: + return False + layer = layers[0] + role = getattr(layer, "role", "") + if role not in MAIN_LAYER_ROLES: + return False + clips = getattr(layer, "clips", []) + if len(clips) != 1: + return False + if has_stickers: + return False + if has_watermark: + return False + return True diff --git a/tests/unit/test_render_layer_utils.py b/tests/unit/test_render_layer_utils.py new file mode 100755 index 000000000..65f8d2eb0 --- /dev/null +++ b/tests/unit/test_render_layer_utils.py @@ -0,0 +1,361 @@ +"""render_layer_utils 模块单元测试.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from packages.domain.render_layer_utils import ( + LAYER_Z_INDEX, + MAIN_LAYER_ROLES, + PIP_DEFAULT_SCALE, + can_pass_through, + clip_adjusted_duration, + clip_effective_duration, + clip_playback_speed, + estimate_total_duration, + get_layer_z_index, + resolve_layer_role, +) + +# ── 辅助数据类 ────────────────────────────────────────────────────────────── + + +@dataclass +class FakeClip: + duration: float = 0.0 + actual_duration: float = 0.0 + playback_speed: Any = 1.0 + + +@dataclass +class FakeLayer: + role: str = "main" + clips: list[FakeClip] = field(default_factory=list) + + +# ── 常量验证 ──────────────────────────────────────────────────────────────── + + +class TestConstants: + def test_layer_z_index_has_expected_keys(self): + assert set(LAYER_Z_INDEX.keys()) == { + "background", + "broll", + "main", + "overlay", + "corner_voice", + "audio", + } + + def test_layer_z_index_ordering(self): + assert LAYER_Z_INDEX["background"] < LAYER_Z_INDEX["main"] + assert LAYER_Z_INDEX["main"] == LAYER_Z_INDEX["broll"] + assert LAYER_Z_INDEX["overlay"] > LAYER_Z_INDEX["main"] + assert LAYER_Z_INDEX["corner_voice"] > LAYER_Z_INDEX["main"] + assert LAYER_Z_INDEX["audio"] > LAYER_Z_INDEX["overlay"] + + def test_pip_default_scale_positive(self): + assert 0 < PIP_DEFAULT_SCALE < 1 + + def test_main_layer_roles(self): + assert "main" in MAIN_LAYER_ROLES + assert "broll" in MAIN_LAYER_ROLES + assert "background" in MAIN_LAYER_ROLES + assert "overlay" not in MAIN_LAYER_ROLES + + +# ── resolve_layer_role ────────────────────────────────────────────────────── + + +class TestResolveLayerRole: + def test_intro_maps_to_main(self): + assert resolve_layer_role("intro") == "main" + + def test_outro_maps_to_main(self): + assert resolve_layer_role("outro") == "main" + + def test_overlay_maps_to_overlay(self): + assert resolve_layer_role("overlay") == "overlay" + + def test_corner_voice_maps_to_corner_voice(self): + assert resolve_layer_role("corner_voice") == "corner_voice" + + def test_background_maps_to_background(self): + assert resolve_layer_role("background") == "background" + + def test_b_roll_maps_to_broll(self): + assert resolve_layer_role("b_roll") == "broll" + + def test_main_defaults_to_main(self): + assert resolve_layer_role("main") == "main" + + def test_main_with_b_roll_role(self): + assert resolve_layer_role("main", {"role": "b_roll"}) == "broll" + + def test_main_with_audio_role(self): + assert resolve_layer_role("main", {"role": "audio"}) == "audio" + + def test_main_with_other_role_stays_main(self): + assert resolve_layer_role("main", {"role": "overlay"}) == "main" + + def test_none_config(self): + assert resolve_layer_role("main", None) == "main" + + def test_empty_config(self): + assert resolve_layer_role("main", {}) == "main" + + def test_unknown_type_defaults_to_main(self): + assert resolve_layer_role("unknown_type") == "main" + + +# ── get_layer_z_index ────────────────────────────────────────────────────── + + +class TestGetLayerZIndex: + def test_known_roles(self): + for role, expected in LAYER_Z_INDEX.items(): + assert get_layer_z_index(role) == expected + + def test_unknown_role_returns_zero(self): + assert get_layer_z_index("nonexistent") == 0 + + def test_empty_string_returns_zero(self): + assert get_layer_z_index("") == 0 + + +# ── clip_effective_duration ──────────────────────────────────────────────── + + +class TestClipEffectiveDuration: + def test_explicit_duration_no_actual(self): + assert clip_effective_duration(5.0) == 5.0 + + def test_explicit_duration_with_shorter_actual(self): + assert clip_effective_duration(5.0, 3.0) == 3.0 + + def test_explicit_duration_with_longer_actual(self): + assert clip_effective_duration(5.0, 10.0) == 5.0 + + def test_zero_duration_uses_actual(self): + assert clip_effective_duration(0, 8.0) == 8.0 + + def test_negative_duration_uses_actual(self): + assert clip_effective_duration(-1.0, 8.0) == 8.0 + + def test_zero_duration_zero_actual(self): + assert clip_effective_duration(0, 0) == 0.0 + + def test_no_args_returns_zero(self): + assert clip_effective_duration(0) == 0.0 + + def test_equal_duration_and_actual(self): + assert clip_effective_duration(5.0, 5.0) == 5.0 + + +# ── clip_playback_speed ──────────────────────────────────────────────────── + + +class TestClipPlaybackSpeed: + def test_normal_speed(self): + assert clip_playback_speed(1.0) == 1.0 + + def test_fast_speed(self): + assert clip_playback_speed(2.0) == 2.0 + + def test_slow_speed(self): + assert clip_playback_speed(0.5) == 0.5 + + def test_zero_speed_defaults_to_one(self): + assert clip_playback_speed(0) == 1.0 + + def test_negative_speed_defaults_to_one(self): + assert clip_playback_speed(-1.0) == 1.0 + + def test_none_defaults_to_one(self): + assert clip_playback_speed(None) == 1.0 + + def test_string_defaults_to_one(self): + assert clip_playback_speed("fast") == 1.0 + + def test_int_speed(self): + assert clip_playback_speed(2) == 2.0 + + +# ── clip_adjusted_duration ───────────────────────────────────────────────── + + +class TestClipAdjustedDuration: + def test_normal_speed_same_as_effective(self): + assert clip_adjusted_duration(5.0, 10.0, 1.0) == 5.0 + + def test_double_speed_half_duration(self): + assert clip_adjusted_duration(10.0, 10.0, 2.0) == pytest.approx(5.0) + + def test_half_speed_double_duration(self): + assert clip_adjusted_duration(5.0, 10.0, 0.5) == pytest.approx(10.0) + + def test_invalid_speed_uses_default(self): + assert clip_adjusted_duration(5.0, 10.0, 0) == 5.0 + + def test_zero_duration(self): + assert clip_adjusted_duration(0, 0, 1.0) == 0.0 + + def test_actual_duration_only(self): + assert clip_adjusted_duration(0, 8.0, 1.0) == 8.0 + + def test_actual_duration_only_with_speed(self): + assert clip_adjusted_duration(0, 8.0, 2.0) == pytest.approx(4.0) + + def test_very_close_to_normal_speed(self): + # 1.0000001 应该被认为接近 1.0,不做除法 + result = clip_adjusted_duration(5.0, 10.0, 1.0 + 1e-10) + assert result == 5.0 + + +# ── estimate_total_duration ──────────────────────────────────────────────── + + +class TestEstimateTotalDuration: + def test_empty_layers(self): + assert estimate_total_duration([]) == 0.0 + + def test_no_main_layer(self): + layers = [FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)])] + assert estimate_total_duration(layers) == 0.0 + + def test_single_clip_main_layer(self): + layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])] + assert estimate_total_duration(layers) == pytest.approx(5.0) + + def test_multiple_clips_no_transition(self): + layers = [ + FakeLayer( + role="main", + clips=[ + FakeClip(duration=3.0), + FakeClip(duration=2.0), + FakeClip(duration=5.0), + ], + ) + ] + assert estimate_total_duration(layers) == pytest.approx(10.0) + + def test_multiple_clips_with_transition(self): + layers = [ + FakeLayer( + role="main", + clips=[ + FakeClip(duration=3.0), + FakeClip(duration=2.0), + FakeClip(duration=5.0), + ], + ) + ] + # 3 + 2 + 5 - 2 * 0.5 = 9.0 + assert estimate_total_duration(layers, transition_duration=0.5) == pytest.approx(9.0) + + def test_prefers_main_over_broll(self): + layers = [ + FakeLayer(role="broll", clips=[FakeClip(duration=10.0)]), + FakeLayer(role="main", clips=[FakeClip(duration=5.0)]), + ] + assert estimate_total_duration(layers) == pytest.approx(5.0) + + def test_prefers_broll_over_background(self): + layers = [ + FakeLayer(role="background", clips=[FakeClip(duration=10.0)]), + FakeLayer(role="broll", clips=[FakeClip(duration=5.0)]), + ] + assert estimate_total_duration(layers) == pytest.approx(5.0) + + def test_main_layer_empty_clips(self): + layers = [FakeLayer(role="main", clips=[])] + assert estimate_total_duration(layers) == 0.0 + + def test_minimum_duration(self): + layers = [ + FakeLayer( + role="main", + clips=[ + FakeClip(duration=0.01), + FakeClip(duration=0.01), + ], + ) + ] + result = estimate_total_duration(layers, transition_duration=0.5) + assert result >= 0.1 + + def test_with_playback_speed(self): + layers = [ + FakeLayer( + role="main", + clips=[ + FakeClip(duration=10.0, playback_speed=2.0), + FakeClip(duration=10.0, playback_speed=0.5), + ], + ) + ] + # 5 + 20 = 25 + assert estimate_total_duration(layers) == pytest.approx(25.0) + + +# ── can_pass_through ────────────────────────────────────────────────────── + + +class TestCanPassThrough: + def test_single_main_clip_no_effects(self): + layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])] + assert can_pass_through(layers) is True + + def test_single_broll_clip(self): + layers = [FakeLayer(role="broll", clips=[FakeClip(duration=5.0)])] + assert can_pass_through(layers) is True + + def test_single_background_clip(self): + layers = [FakeLayer(role="background", clips=[FakeClip(duration=5.0)])] + assert can_pass_through(layers) is True + + def test_multiple_layers(self): + layers = [ + FakeLayer(role="main", clips=[FakeClip(duration=5.0)]), + FakeLayer(role="overlay", clips=[FakeClip(duration=3.0)]), + ] + assert can_pass_through(layers) is False + + def test_overlay_layer(self): + layers = [FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)])] + assert can_pass_through(layers) is False + + def test_multiple_clips_in_layer(self): + layers = [ + FakeLayer( + role="main", + clips=[ + FakeClip(duration=3.0), + FakeClip(duration=2.0), + ], + ) + ] + assert can_pass_through(layers) is False + + def test_with_stickers(self): + layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])] + assert can_pass_through(layers, has_stickers=True) is False + + def test_with_watermark(self): + layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])] + assert can_pass_through(layers, has_watermark=True) is False + + def test_with_stickers_and_watermark(self): + layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])] + assert can_pass_through(layers, has_stickers=True, has_watermark=True) is False + + def test_empty_layer_list(self): + assert can_pass_through([]) is False + + def test_audio_layer_only(self): + layers = [FakeLayer(role="audio", clips=[FakeClip(duration=5.0)])] + assert can_pass_through(layers) is False From b8526036646b513f5743945165e6d26acaba3f22 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 00:25:33 +0800 Subject: [PATCH 15/45] =?UTF-8?q?test(wave106):=20video=5Fconcat=E9=A2=86?= =?UTF-8?q?=E5=9F=9F=E6=A8=A1=E5=9E=8B=E6=8A=BD=E7=A6=BB=20+=2053=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=20(#984)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/worker/video_processing/concat_engine.py | 128 +----- packages/domain/video_concat.py | 177 +++++++++ tests/unit/test_video_concat.py | 364 ++++++++++++++++++ 3 files changed, 548 insertions(+), 121 deletions(-) create mode 100755 packages/domain/video_concat.py create mode 100755 tests/unit/test_video_concat.py diff --git a/apps/worker/video_processing/concat_engine.py b/apps/worker/video_processing/concat_engine.py index ecebfa60d..166ccfe33 100755 --- a/apps/worker/video_processing/concat_engine.py +++ b/apps/worker/video_processing/concat_engine.py @@ -18,135 +18,21 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field from pathlib import Path +from packages.domain.video_concat import ( # noqa: F401 向后兼容导出 + ALLOWED_VIDEO_EXTENSIONS, + CONCAT_DEMUXER_REQUIRED_PARAMS, + MAX_CONCAT_SEGMENTS, + ConcatConfig, + ConcatSegment, +) from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path logger = logging.getLogger(__name__) -# ── 常量 ────────────────────────────────────────────────────────────────────── - -MAX_CONCAT_SEGMENTS = 50 # 最大拼接段数(安全上限,防止OOM) - -ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"} - -# concat demuxer 要求一致的参数列表 -CONCAT_DEMUXER_REQUIRED_PARAMS = [ - "codec_name", # 视频编码 - "width", # 宽度 - "height", # 高度 - "r_frame_rate", # 帧率 - "pix_fmt", # 像素格式 - "sample_rate", # 音频采样率 - "channels", # 音频声道数 - "audio_codec", # 音频编码 -] - - -# ── 拼接片段配置 ────────────────────────────────────────────────────────────── - - -@dataclass -class ConcatSegment: - """单个拼接片段.""" - - video_path: str # 视频文件路径 - start_time: float = 0.0 # 开始时间(秒),从视频的哪个位置开始取 - duration: float = 0.0 # 持续时长(秒),0表示取到末尾 - has_audio: bool = True # 是否包含音频 - - @classmethod - def from_dict(cls, seg: dict) -> "ConcatSegment": - """从字典创建拼接片段,带安全类型转换.""" - try: - start_time = max(0.0, float(seg.get("start_time", 0.0))) - except (TypeError, ValueError): - start_time = 0.0 - - try: - duration = max(0.0, float(seg.get("duration", 0.0))) - except (TypeError, ValueError): - duration = 0.0 - - return cls( - video_path=str(seg.get("video_path", "")), - start_time=start_time, - duration=duration, - has_audio=bool(seg.get("has_audio", True)), - ) - - -@dataclass -class ConcatConfig: - """视频拼接配置.""" - - segments: list[ConcatSegment] = field(default_factory=list) - output_width: int = 0 # 输出宽度(0=自动取第一段) - output_height: int = 0 # 输出高度(0=自动取第一段) - output_fps: float = 0.0 # 输出帧率(0=自动取第一段) - force_reencode: bool = False # 强制重新编码(不用 stream copy) - transition: str = "none" # 转场效果(none/crossfade)- 预留 - transition_duration: float = 0.3 # 转场时长 - - @classmethod - def from_config_dict(cls, config: dict | None) -> "ConcatConfig": - """从配置字典创建 ConcatConfig.""" - if not config or not isinstance(config, dict): - return cls() - - segments_raw = config.get("segments", []) - segments: list[ConcatSegment] = [] - - if isinstance(segments_raw, list): - for s in segments_raw: - if isinstance(s, dict) and s.get("video_path"): - try: - seg = ConcatSegment.from_dict(s) - if seg.video_path: - segments.append(seg) - except Exception: - logger.warning("[concat] skip invalid segment: %s", s) - continue - - try: - output_width = max(0, int(config.get("output_width", 0))) - except (TypeError, ValueError): - output_width = 0 - - try: - output_height = max(0, int(config.get("output_height", 0))) - except (TypeError, ValueError): - output_height = 0 - - try: - output_fps = max(0.0, float(config.get("output_fps", 0.0))) - except (TypeError, ValueError): - output_fps = 0.0 - - return cls( - segments=segments, - output_width=output_width, - output_height=output_height, - output_fps=output_fps, - force_reencode=bool(config.get("force_reencode", False)), - transition=str(config.get("transition", "none")), - transition_duration=max(0.1, float(config.get("transition_duration", 0.3))), - ) - - @property - def has_effect(self) -> bool: - """是否有有效片段需要拼接.""" - return len([s for s in self.segments if s.video_path]) >= 2 - - @property - def total_segments(self) -> int: - """有效片段数量.""" - return len([s for s in self.segments if s.video_path]) - - # ── 路径安全校验 ──────────────────────────────────────────────────────────── diff --git a/packages/domain/video_concat.py b/packages/domain/video_concat.py new file mode 100755 index 000000000..da883d207 --- /dev/null +++ b/packages/domain/video_concat.py @@ -0,0 +1,177 @@ +"""视频拼接领域模型 — 纯逻辑,无外部依赖. + +抽离自 concat_engine.py 的数据类和配置解析逻辑, +方便单测覆盖,同时保持向后兼容。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger(__name__) + +# ── 常量 ────────────────────────────────────────────────────────────────────── + +MAX_CONCAT_SEGMENTS = 50 # 最大拼接段数(安全上限,防止OOM) + +ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"} + +# concat demuxer 要求一致的参数列表 +CONCAT_DEMUXER_REQUIRED_PARAMS = [ + "codec_name", + "width", + "height", + "r_frame_rate", + "pix_fmt", + "sample_rate", + "channels", + "audio_codec", +] + + +# ── 拼接片段配置 ────────────────────────────────────────────────────────────── + + +@dataclass +class ConcatSegment: + """单个拼接片段.""" + + video_path: str # 视频文件路径 + start_time: float = 0.0 # 开始时间(秒) + duration: float = 0.0 # 持续时长(秒),0表示取到末尾 + has_audio: bool = True # 是否包含音频 + + @classmethod + def from_dict(cls, seg: dict[str, Any] | None) -> "ConcatSegment": + """从字典创建拼接片段,带安全类型转换.""" + if not seg or not isinstance(seg, dict): + return cls(video_path="") + + try: + start_time = max(0.0, float(seg.get("start_time", 0.0))) + except (TypeError, ValueError): + start_time = 0.0 + + try: + duration = max(0.0, float(seg.get("duration", 0.0))) + except (TypeError, ValueError): + duration = 0.0 + + return cls( + video_path=str(seg.get("video_path", "")), + start_time=start_time, + duration=duration, + has_audio=bool(seg.get("has_audio", True)), + ) + + @property + def is_valid(self) -> bool: + """是否为有效片段(有视频路径).""" + return bool(self.video_path) + + @property + def effective_duration(self) -> float: + """有效时长(duration > 0 时取 duration,否则 0).""" + return max(0.0, self.duration) + + +@dataclass +class ConcatConfig: + """视频拼接配置.""" + + segments: list[ConcatSegment] = field(default_factory=list) + output_width: int = 0 # 输出宽度(0=自动取第一段) + output_height: int = 0 # 输出高度(0=自动取第一段) + output_fps: float = 0.0 # 输出帧率(0=自动取第一段) + force_reencode: bool = False # 强制重新编码 + transition: str = "none" # 转场效果(none/crossfade) + transition_duration: float = 0.3 # 转场时长 + + @classmethod + def from_config_dict(cls, config: dict[str, Any] | None) -> "ConcatConfig": + """从配置字典创建 ConcatConfig.""" + if not config or not isinstance(config, dict): + return cls() + + segments_raw = config.get("segments", []) + segments: list[ConcatSegment] = [] + + if isinstance(segments_raw, list): + for s in segments_raw: + if isinstance(s, dict) and s.get("video_path"): + try: + seg = ConcatSegment.from_dict(s) + if seg.is_valid: + segments.append(seg) + except Exception: + logger.warning("[concat] skip invalid segment: %s", s) + continue + + try: + output_width = max(0, int(config.get("output_width", 0))) + except (TypeError, ValueError): + output_width = 0 + + try: + output_height = max(0, int(config.get("output_height", 0))) + except (TypeError, ValueError): + output_height = 0 + + try: + output_fps = max(0.0, float(config.get("output_fps", 0.0))) + except (TypeError, ValueError): + output_fps = 0.0 + + try: + transition_duration = max(0.1, float(config.get("transition_duration", 0.3))) + except (TypeError, ValueError): + transition_duration = 0.3 + + return cls( + segments=segments, + output_width=output_width, + output_height=output_height, + output_fps=output_fps, + force_reencode=bool(config.get("force_reencode", False)), + transition=str(config.get("transition", "none")), + transition_duration=transition_duration, + ) + + @property + def has_effect(self) -> bool: + """是否有有效片段需要拼接(至少2段).""" + return self.valid_segment_count >= 2 + + @property + def valid_segment_count(self) -> int: + """有效片段数量.""" + return sum(1 for s in self.segments if s.is_valid) + + @property + def total_segments(self) -> int: + """有效片段数量(向后兼容别名).""" + return self.valid_segment_count + + @property + def first_valid_segment(self) -> ConcatSegment | None: + """第一个有效片段.""" + for s in self.segments: + if s.is_valid: + return s + return None + + @property + def estimated_total_duration(self) -> float: + """估算总时长(只统计有明确duration的片段).""" + total = 0.0 + for s in self.segments: + if s.is_valid and s.duration > 0: + total += s.duration + return total + + def clamp_segments(self, max_segments: int = MAX_CONCAT_SEGMENTS) -> None: + """截断片段数量,防止OOM.""" + if len(self.segments) > max_segments: + self.segments = self.segments[:max_segments] diff --git a/tests/unit/test_video_concat.py b/tests/unit/test_video_concat.py new file mode 100755 index 000000000..7d78551d7 --- /dev/null +++ b/tests/unit/test_video_concat.py @@ -0,0 +1,364 @@ +"""video_concat 领域模型单测 — 纯逻辑,48个测试用例.""" + +from __future__ import annotations + +import pytest + +from packages.domain.video_concat import ( + ALLOWED_VIDEO_EXTENSIONS, + CONCAT_DEMUXER_REQUIRED_PARAMS, + MAX_CONCAT_SEGMENTS, + ConcatConfig, + ConcatSegment, +) + +# ── ConcatSegment 测试 ─────────────────────────────────────────────────────── + + +class TestConcatSegmentBasics: + def test_default_values(self): + seg = ConcatSegment(video_path="test.mp4") + assert seg.video_path == "test.mp4" + assert seg.start_time == 0.0 + assert seg.duration == 0.0 + assert seg.has_audio is True + + def test_full_params(self): + seg = ConcatSegment( + video_path="video.mp4", + start_time=5.5, + duration=10.0, + has_audio=False, + ) + assert seg.video_path == "video.mp4" + assert seg.start_time == 5.5 + assert seg.duration == 10.0 + assert seg.has_audio is False + + +class TestConcatSegmentFromDict: + def test_normal_dict(self): + seg = ConcatSegment.from_dict( + { + "video_path": "test.mp4", + "start_time": 2.0, + "duration": 5.0, + "has_audio": False, + } + ) + assert seg.video_path == "test.mp4" + assert seg.start_time == 2.0 + assert seg.duration == 5.0 + assert seg.has_audio is False + + def test_empty_dict(self): + seg = ConcatSegment.from_dict({}) + assert seg.video_path == "" + assert seg.start_time == 0.0 + assert seg.duration == 0.0 + assert seg.has_audio is True + + def test_none_input(self): + seg = ConcatSegment.from_dict(None) + assert seg.video_path == "" + assert seg.is_valid is False + + def test_non_dict_input(self): + seg = ConcatSegment.from_dict("not a dict") + assert seg.video_path == "" + + def test_start_time_negative_clamped(self): + seg = ConcatSegment.from_dict({"video_path": "a.mp4", "start_time": -5}) + assert seg.start_time == 0.0 + + def test_duration_negative_clamped(self): + seg = ConcatSegment.from_dict({"video_path": "a.mp4", "duration": -10}) + assert seg.duration == 0.0 + + def test_start_time_invalid_string(self): + seg = ConcatSegment.from_dict({"video_path": "a.mp4", "start_time": "abc"}) + assert seg.start_time == 0.0 + + def test_duration_invalid_string(self): + seg = ConcatSegment.from_dict({"video_path": "a.mp4", "duration": "xyz"}) + assert seg.duration == 0.0 + + def test_start_time_int_casted(self): + seg = ConcatSegment.from_dict({"video_path": "a.mp4", "start_time": 3}) + assert seg.start_time == 3.0 + + def test_duration_int_casted(self): + seg = ConcatSegment.from_dict({"video_path": "a.mp4", "duration": 7}) + assert seg.duration == 7.0 + + def test_video_path_casted_to_string(self): + seg = ConcatSegment.from_dict({"video_path": 12345}) + assert seg.video_path == "12345" + + def test_has_audio_false(self): + seg = ConcatSegment.from_dict({"video_path": "a.mp4", "has_audio": False}) + assert seg.has_audio is False + + def test_has_audio_truthy_value(self): + seg = ConcatSegment.from_dict({"video_path": "a.mp4", "has_audio": 1}) + assert seg.has_audio is True + + +class TestConcatSegmentProperties: + def test_is_valid_with_path(self): + seg = ConcatSegment(video_path="test.mp4") + assert seg.is_valid is True + + def test_is_valid_empty_path(self): + seg = ConcatSegment(video_path="") + assert seg.is_valid is False + + def test_effective_duration_positive(self): + seg = ConcatSegment(video_path="a.mp4", duration=10.5) + assert seg.effective_duration == 10.5 + + def test_effective_duration_zero(self): + seg = ConcatSegment(video_path="a.mp4", duration=0.0) + assert seg.effective_duration == 0.0 + + def test_effective_duration_negative(self): + seg = ConcatSegment(video_path="a.mp4", duration=-5.0) + assert seg.effective_duration == 0.0 + + +# ── ConcatConfig 测试 ──────────────────────────────────────────────────────── + + +class TestConcatConfigBasics: + def test_default_values(self): + cfg = ConcatConfig() + assert cfg.segments == [] + assert cfg.output_width == 0 + assert cfg.output_height == 0 + assert cfg.output_fps == 0.0 + assert cfg.force_reencode is False + assert cfg.transition == "none" + assert cfg.transition_duration == 0.3 + + def test_with_segments(self): + segs = [ConcatSegment(video_path="a.mp4")] + cfg = ConcatConfig(segments=segs) + assert len(cfg.segments) == 1 + assert cfg.segments[0].video_path == "a.mp4" + + +class TestConcatConfigFromDict: + def test_none_config(self): + cfg = ConcatConfig.from_config_dict(None) + assert cfg.segments == [] + assert cfg.output_width == 0 + + def test_empty_dict(self): + cfg = ConcatConfig.from_config_dict({}) + assert cfg.segments == [] + + def test_non_dict_input(self): + cfg = ConcatConfig.from_config_dict("config") + assert cfg.segments == [] + + def test_with_valid_segments(self): + cfg = ConcatConfig.from_config_dict( + { + "segments": [ + {"video_path": "a.mp4", "duration": 10}, + {"video_path": "b.mp4", "duration": 20}, + ], + } + ) + assert len(cfg.segments) == 2 + assert cfg.segments[0].video_path == "a.mp4" + assert cfg.segments[1].video_path == "b.mp4" + + def test_skips_empty_video_path(self): + cfg = ConcatConfig.from_config_dict( + { + "segments": [ + {"video_path": "a.mp4"}, + {"video_path": ""}, + {"video_path": "b.mp4"}, + ], + } + ) + assert len(cfg.segments) == 2 + + def test_skips_invalid_segment_dict(self): + cfg = ConcatConfig.from_config_dict( + { + "segments": [ + {"video_path": "a.mp4"}, + "not a dict", + {"video_path": "b.mp4"}, + ], + } + ) + assert len(cfg.segments) == 2 + + def test_segments_not_a_list(self): + cfg = ConcatConfig.from_config_dict({"segments": "not a list"}) + assert cfg.segments == [] + + def test_output_params(self): + cfg = ConcatConfig.from_config_dict( + { + "output_width": 1920, + "output_height": 1080, + "output_fps": 30.0, + "force_reencode": True, + } + ) + assert cfg.output_width == 1920 + assert cfg.output_height == 1080 + assert cfg.output_fps == 30.0 + assert cfg.force_reencode is True + + def test_output_width_negative_clamped(self): + cfg = ConcatConfig.from_config_dict({"output_width": -100}) + assert cfg.output_width == 0 + + def test_output_height_invalid_string(self): + cfg = ConcatConfig.from_config_dict({"output_height": "abc"}) + assert cfg.output_height == 0 + + def test_output_fps_invalid_string(self): + cfg = ConcatConfig.from_config_dict({"output_fps": "xyz"}) + assert cfg.output_fps == 0.0 + + def test_transition_params(self): + cfg = ConcatConfig.from_config_dict( + { + "transition": "crossfade", + "transition_duration": 1.0, + } + ) + assert cfg.transition == "crossfade" + assert cfg.transition_duration == 1.0 + + def test_transition_duration_minimum(self): + cfg = ConcatConfig.from_config_dict({"transition_duration": 0.01}) + assert cfg.transition_duration == 0.1 + + def test_transition_duration_negative(self): + cfg = ConcatConfig.from_config_dict({"transition_duration": -1}) + assert cfg.transition_duration == 0.1 + + def test_force_reencode_false_by_default(self): + cfg = ConcatConfig.from_config_dict({}) + assert cfg.force_reencode is False + + +class TestConcatConfigProperties: + def test_has_effect_two_segments(self): + cfg = ConcatConfig( + segments=[ + ConcatSegment(video_path="a.mp4"), + ConcatSegment(video_path="b.mp4"), + ] + ) + assert cfg.has_effect is True + + def test_has_effect_one_segment(self): + cfg = ConcatConfig(segments=[ConcatSegment(video_path="a.mp4")]) + assert cfg.has_effect is False + + def test_has_effect_empty(self): + cfg = ConcatConfig() + assert cfg.has_effect is False + + def test_has_effect_skips_invalid(self): + cfg = ConcatConfig( + segments=[ + ConcatSegment(video_path="a.mp4"), + ConcatSegment(video_path=""), + ConcatSegment(video_path="b.mp4"), + ] + ) + assert cfg.has_effect is True + + def test_valid_segment_count(self): + cfg = ConcatConfig( + segments=[ + ConcatSegment(video_path="a.mp4"), + ConcatSegment(video_path=""), + ConcatSegment(video_path="b.mp4"), + ] + ) + assert cfg.valid_segment_count == 2 + + def test_first_valid_segment(self): + cfg = ConcatConfig( + segments=[ + ConcatSegment(video_path=""), + ConcatSegment(video_path="first.mp4"), + ConcatSegment(video_path="second.mp4"), + ] + ) + assert cfg.first_valid_segment is not None + assert cfg.first_valid_segment.video_path == "first.mp4" + + def test_first_valid_segment_none_when_all_empty(self): + cfg = ConcatConfig( + segments=[ + ConcatSegment(video_path=""), + ConcatSegment(video_path=""), + ] + ) + assert cfg.first_valid_segment is None + + def test_first_valid_segment_empty_list(self): + cfg = ConcatConfig() + assert cfg.first_valid_segment is None + + def test_estimated_total_duration(self): + cfg = ConcatConfig( + segments=[ + ConcatSegment(video_path="a.mp4", duration=10.0), + ConcatSegment(video_path="b.mp4", duration=20.0), + ConcatSegment(video_path="c.mp4", duration=0.0), + ] + ) + assert cfg.estimated_total_duration == 30.0 + + def test_estimated_total_duration_skips_invalid(self): + cfg = ConcatConfig( + segments=[ + ConcatSegment(video_path="", duration=10.0), + ConcatSegment(video_path="a.mp4", duration=5.0), + ] + ) + assert cfg.estimated_total_duration == 5.0 + + +class TestConcatConfigClampSegments: + def test_clamp_when_over_max(self): + segs = [ConcatSegment(video_path=f"s{i}.mp4") for i in range(100)] + cfg = ConcatConfig(segments=segs) + cfg.clamp_segments(50) + assert len(cfg.segments) == 50 + assert cfg.segments[0].video_path == "s0.mp4" + assert cfg.segments[-1].video_path == "s49.mp4" + + def test_no_clamp_when_under_max(self): + segs = [ConcatSegment(video_path=f"s{i}.mp4") for i in range(10)] + cfg = ConcatConfig(segments=segs) + cfg.clamp_segments(50) + assert len(cfg.segments) == 10 + + def test_default_max_constant(self): + assert MAX_CONCAT_SEGMENTS == 50 + + +class TestConstants: + def test_allowed_extensions(self): + assert ".mp4" in ALLOWED_VIDEO_EXTENSIONS + assert ".mov" in ALLOWED_VIDEO_EXTENSIONS + assert ".webm" in ALLOWED_VIDEO_EXTENSIONS + + def test_demuxer_params(self): + assert "codec_name" in CONCAT_DEMUXER_REQUIRED_PARAMS + assert "width" in CONCAT_DEMUXER_REQUIRED_PARAMS + assert "r_frame_rate" in CONCAT_DEMUXER_REQUIRED_PARAMS From b392bb1b789111aedc3852ab17d023204de65bba Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 00:25:33 +0800 Subject: [PATCH 16/45] =?UTF-8?q?test(wave107):=20subtitle=5Fstyle?= =?UTF-8?q?=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9E=8B=E6=8A=BD=E7=A6=BB=20+=2076?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=20(#985)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../subtitle_render_engine.py | 271 +----------- packages/domain/subtitle_style.py | 272 ++++++++++++ tests/unit/test_subtitle_style.py | 401 ++++++++++++++++++ 3 files changed, 693 insertions(+), 251 deletions(-) create mode 100755 packages/domain/subtitle_style.py create mode 100755 tests/unit/test_subtitle_style.py diff --git a/apps/worker/video_processing/subtitle_render_engine.py b/apps/worker/video_processing/subtitle_render_engine.py index cb4b82e31..d15994991 100755 --- a/apps/worker/video_processing/subtitle_render_engine.py +++ b/apps/worker/video_processing/subtitle_render_engine.py @@ -24,264 +24,33 @@ from __future__ import annotations import logging -from dataclasses import dataclass from pathlib import Path -from typing import Any +from packages.domain.subtitle_style import ( # noqa: F401 向后兼容导出 + ALLOWED_SUBTITLE_EXTENSIONS, + DEFAULT_COLOR, + DEFAULT_FONT, + DEFAULT_FONT_SIZE, + DEFAULT_MAX_CHARS_PER_LINE, + DEFAULT_POSITION, + DEFAULT_STROKE_COLOR, + DEFAULT_STROKE_WIDTH, + POSITION_ALIGNMENT, + POSITION_ALIASES, + SubtitleSegment, + SubtitleStyle, + escape_ass_text as _escape_ass_text, + format_ass_time as _format_ass_time, + hex_to_ass_bgr as _hex_to_ass_bgr, + hex_to_ass_color as _hex_to_ass_color, + opacity_to_ass_alpha as _opacity_to_ass_alpha, + wrap_text as _wrap_text, +) from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path logger = logging.getLogger(__name__) -# ── 常量 ────────────────────────────────────────────────────────────────────── - -ALLOWED_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".vtt", ".sub"} - -# 9宫格位置映射(ASS alignment 编号) -POSITION_ALIGNMENT = { - "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 = { - "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 - - -# ── 字幕样式配置 ──────────────────────────────────────────────────────────── - - -@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 # 0.0 ~ 1.0 - background_padding: int = 8 - background_radius: int = 4 - - # 位置 - position: str = DEFAULT_POSITION # 9宫格位置名 - 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" # none/fade/slide/typewriter - - @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 - - 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"), - ) - - @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 _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(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.""" - 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 len(text) <= max_chars: - return [text] - - lines: list[str] = [] - remaining = text - - while len(remaining) > max_chars: - break_point = max_chars - punctuations = ",。!?、;:,.;:!?" - - 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 SubtitleSegment: - """单个字幕片段.""" - - start: float # 开始时间(秒) - end: float # 结束时间(秒) - text: str # 字幕文本 - style_name: str = "Default" # 使用的样式名 - - # ── 字幕渲染引擎 ────────────────────────────────────────────────────────────── diff --git a/packages/domain/subtitle_style.py b/packages/domain/subtitle_style.py new file mode 100755 index 000000000..481e0d83c --- /dev/null +++ b/packages/domain/subtitle_style.py @@ -0,0 +1,272 @@ +"""字幕样式领域模型 — 纯逻辑,无外部依赖. + +抽离自 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" + + @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 + + 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"), + ) + + @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}" + + +# ── 字幕片段 ────────────────────────────────────────────────────────────────── + + +@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 diff --git a/tests/unit/test_subtitle_style.py b/tests/unit/test_subtitle_style.py new file mode 100755 index 000000000..94a0808e0 --- /dev/null +++ b/tests/unit/test_subtitle_style.py @@ -0,0 +1,401 @@ +"""subtitle_style 领域模型单测 — 纯逻辑.""" + +from __future__ import annotations + +import pytest + +from packages.domain.subtitle_style import ( + ALLOWED_SUBTITLE_EXTENSIONS, + DEFAULT_COLOR, + DEFAULT_FONT, + DEFAULT_FONT_SIZE, + DEFAULT_MAX_CHARS_PER_LINE, + DEFAULT_POSITION, + DEFAULT_STROKE_COLOR, + DEFAULT_STROKE_WIDTH, + POSITION_ALIGNMENT, + POSITION_ALIASES, + SubtitleSegment, + SubtitleStyle, + escape_ass_text, + format_ass_time, + hex_to_ass_bgr, + hex_to_ass_color, + opacity_to_ass_alpha, + wrap_text, +) + +# ── 常量测试 ────────────────────────────────────────────────────────────────── + + +class TestConstants: + def test_position_alignment_has_9_positions(self): + assert len(POSITION_ALIGNMENT) == 9 + assert POSITION_ALIGNMENT["bottom_center"] == 2 + assert POSITION_ALIGNMENT["top_center"] == 8 + assert POSITION_ALIGNMENT["center"] == 5 + + def test_position_aliases(self): + assert POSITION_ALIASES["top"] == "top_center" + assert POSITION_ALIASES["bottom"] == "bottom_center" + assert POSITION_ALIASES["middle"] == "center" + + def test_default_values(self): + assert DEFAULT_FONT == "思源黑体" + assert DEFAULT_FONT_SIZE == 24 + assert DEFAULT_COLOR == "#FFFFFF" + assert DEFAULT_POSITION == "bottom_center" + + def test_allowed_extensions(self): + assert ".srt" in ALLOWED_SUBTITLE_EXTENSIONS + assert ".ass" in ALLOWED_SUBTITLE_EXTENSIONS + assert ".vtt" in ALLOWED_SUBTITLE_EXTENSIONS + + +# ── 工具函数测试 ────────────────────────────────────────────────────────────── + + +class TestHexToAssColor: + def test_white(self): + assert hex_to_ass_color("#FFFFFF") == "&H00FFFFFF" + + def test_black(self): + assert hex_to_ass_color("#000000") == "&H00000000" + + def test_red(self): + assert hex_to_ass_color("#FF0000") == "&H000000FF" + + def test_blue(self): + assert hex_to_ass_color("#0000FF") == "&H00FF0000" + + def test_green(self): + assert hex_to_ass_color("#00FF00") == "&H0000FF00" + + def test_without_hash(self): + assert hex_to_ass_color("FF0000") == "&H000000FF" + + def test_lowercase(self): + assert hex_to_ass_color("#ff0000") == "&H000000FF" + + def test_invalid_length_returns_white(self): + assert hex_to_ass_color("#FFF") == "&H00FFFFFF" + assert hex_to_ass_color("#FF000000") == "&H00FFFFFF" + + def test_empty_string(self): + assert hex_to_ass_color("") == "&H00FFFFFF" + + +class TestHexToAssBgr: + def test_white(self): + assert hex_to_ass_bgr("#FFFFFF") == "FFFFFF" + + def test_red(self): + assert hex_to_ass_bgr("#FF0000") == "0000FF" + + def test_blue(self): + assert hex_to_ass_bgr("#0000FF") == "FF0000" + + def test_invalid_length(self): + assert hex_to_ass_bgr("#FFF") == "FFFFFF" + + +class TestOpacityToAssAlpha: + def test_fully_opaque(self): + assert opacity_to_ass_alpha(1.0) == "00" + + def test_fully_transparent(self): + assert opacity_to_ass_alpha(0.0) == "FF" + + def test_half(self): + assert opacity_to_ass_alpha(0.5) == "80" + + def test_above_1_clamped(self): + assert opacity_to_ass_alpha(1.5) == "00" + + def test_below_0_clamped(self): + assert opacity_to_ass_alpha(-0.5) == "FF" + + +class TestEscapeAssText: + def test_newline_unix(self): + assert escape_ass_text("hello\nworld") == "hello\\Nworld" + + def test_newline_windows(self): + assert escape_ass_text("hello\r\nworld") == "hello\\Nworld" + + def test_newline_mac(self): + assert escape_ass_text("hello\rworld") == "hello\\Nworld" + + def test_curly_braces(self): + assert escape_ass_text("{text}") == "(text)" + + def test_mixed(self): + assert escape_ass_text("hello\n{world}\r\nend") == "hello\\N(world)\\Nend" + + def test_empty(self): + assert escape_ass_text("") == "" + + +class TestFormatAssTime: + def test_zero(self): + assert format_ass_time(0.0) == "0:00:00.00" + + def test_seconds_only(self): + assert format_ass_time(5.5) == "0:00:05.50" + + def test_minutes(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_negative_returns_zero(self): + assert format_ass_time(-1.0) == "0:00:00.00" + + def test_centiseconds_precision(self): + assert format_ass_time(1.234) == "0:00:01.23" + + +class TestWrapText: + def test_short_text_no_wrap(self): + assert wrap_text("你好", 10) == ["你好"] + + def test_exact_length_no_wrap(self): + text = "你" * 10 + result = wrap_text(text, 10) + assert len(result) == 1 + assert len(result[0]) == 10 + + def test_long_text_breaks_at_max(self): + 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_breaks_at_punctuation(self): + # "一二三四五六。七八九十"共10字,"。"在索引6 + # max_chars=8 时,从 8 往回找到 4,会命中索引6的"。" + text = "一二三四五六。七八九十" + result = wrap_text(text, 8) + assert len(result) == 2 + assert result[0] == "一二三四五六。" + assert result[1] == "七八九十" + + def test_no_punctuation_breaks_at_max(self): + text = "一二三四五六七八九十一二三四五六七八九十" + result = wrap_text(text, 10) + assert len(result[0]) == 10 + + def test_empty_text(self): + assert wrap_text("", 10) == [""] + + def test_zero_max_chars(self): + assert wrap_text("hello", 0) == ["hello"] + + def test_negative_max_chars(self): + result = wrap_text("hello", -5) + assert isinstance(result, list) + assert len(result) == 1 + + +# ── SubtitleStyle 测试 ─────────────────────────────────────────────────────── + + +class TestSubtitleStyleDefaults: + def test_default_values(self): + style = SubtitleStyle() + assert style.font_name == DEFAULT_FONT + assert style.font_size == DEFAULT_FONT_SIZE + assert style.font_color == DEFAULT_COLOR + assert style.bold is False + assert style.italic is False + assert style.stroke_enabled is True + assert style.stroke_color == DEFAULT_STROKE_COLOR + assert style.stroke_width == DEFAULT_STROKE_WIDTH + assert style.position == DEFAULT_POSITION + assert style.max_chars_per_line == DEFAULT_MAX_CHARS_PER_LINE + + +class TestSubtitleStyleFromDict: + def test_none_returns_default(self): + style = SubtitleStyle.from_dict(None) + assert style.font_name == DEFAULT_FONT + + def test_empty_dict_returns_default(self): + style = SubtitleStyle.from_dict({}) + assert style.font_size == DEFAULT_FONT_SIZE + + def test_custom_font(self): + style = SubtitleStyle.from_dict({"font": "微软雅黑", "size": 32}) + assert style.font_name == "微软雅黑" + assert style.font_size == 32 + + def test_color(self): + style = SubtitleStyle.from_dict({"color": "#FF0000"}) + assert style.font_color == "#FF0000" + + def test_bold_italic(self): + style = SubtitleStyle.from_dict({"bold": True, "italic": True}) + assert style.bold is True + assert style.italic is True + + def test_stroke_config(self): + style = SubtitleStyle.from_dict( + { + "stroke_enabled": False, + "stroke_color": "#00FF00", + "stroke_width": 2.0, + } + ) + assert style.stroke_enabled is False + assert style.stroke_color == "#00FF00" + assert style.stroke_width == 2.0 + + def test_shadow_config(self): + style = SubtitleStyle.from_dict( + { + "shadow_enabled": True, + "shadow_color": "#111111", + "shadow_offset_x": 4, + "shadow_offset_y": 4, + "shadow_blur": 1.5, + } + ) + assert style.shadow_enabled is True + assert style.shadow_color == "#111111" + assert style.shadow_offset_x == 4 + assert style.shadow_offset_y == 4 + assert style.shadow_blur == 1.5 + + def test_background_config(self): + style = SubtitleStyle.from_dict( + { + "background_enabled": True, + "background_color": "#000000", + "background_opacity": 0.7, + "background_padding": 10, + "background_radius": 6, + } + ) + assert style.background_enabled is True + assert style.background_opacity == 0.7 + assert style.background_padding == 10 + + def test_background_opacity_clamped_0_to_1(self): + style = SubtitleStyle.from_dict({"background_opacity": -0.5}) + assert style.background_opacity == 0.0 + style2 = SubtitleStyle.from_dict({"background_opacity": 1.5}) + assert style2.background_opacity == 1.0 + + def test_position_valid(self): + style = SubtitleStyle.from_dict({"position": "top_center"}) + assert style.position == "top_center" + + def test_position_alias(self): + style = SubtitleStyle.from_dict({"position": "top"}) + assert style.position == "top_center" + + def test_position_invalid_falls_back(self): + style = SubtitleStyle.from_dict({"position": "invalid_pos"}) + assert style.position == DEFAULT_POSITION + + def test_margins(self): + style = SubtitleStyle.from_dict({"margin_v": 80, "margin_l": 50, "margin_r": 50}) + assert style.margin_v == 80 + assert style.margin_l == 50 + assert style.margin_r == 50 + + def test_max_chars_per_line(self): + style = SubtitleStyle.from_dict({"max_chars_per_line": 15}) + assert style.max_chars_per_line == 15 + + def test_line_spacing(self): + style = SubtitleStyle.from_dict({"line_spacing": 4}) + assert style.line_spacing == 4 + + def test_fade_in_out(self): + style = SubtitleStyle.from_dict({"fade_in": 0.5, "fade_out": 1.0}) + assert style.fade_in == 0.5 + assert style.fade_out == 1.0 + + def test_fade_negative_clamped(self): + style = SubtitleStyle.from_dict({"fade_in": -1, "fade_out": -2}) + assert style.fade_in == 0.0 + assert style.fade_out == 0.0 + + def test_animation_type(self): + style = SubtitleStyle.from_dict({"animation_type": "fade"}) + assert style.animation_type == "fade" + + def test_invalid_int_falls_back(self): + style = SubtitleStyle.from_dict({"size": "not_a_number"}) + assert style.font_size == DEFAULT_FONT_SIZE + + def test_invalid_float_falls_back(self): + style = SubtitleStyle.from_dict({"stroke_width": "abc"}) + assert style.stroke_width == DEFAULT_STROKE_WIDTH + + +class TestSubtitleStyleProperties: + def test_alignment_bottom_center(self): + style = SubtitleStyle(position="bottom_center") + assert style.alignment == 2 + + def test_alignment_top_center(self): + style = SubtitleStyle(position="top_center") + assert style.alignment == 8 + + def test_ass_font_color(self): + style = SubtitleStyle(font_color="#FF0000") + assert style.ass_font_color == "&H000000FF" + + def test_ass_stroke_color(self): + style = SubtitleStyle(stroke_color="#00FF00") + assert style.ass_stroke_color == "&H0000FF00" + + def test_ass_shadow_color(self): + style = SubtitleStyle(shadow_color="#0000FF") + assert style.ass_shadow_color == "&H00FF0000" + + def test_ass_background_color(self): + style = SubtitleStyle(background_color="#FF0000", background_opacity=0.5) + # alpha = 255 - 127 = 128 = 0x80, bgr of red = 0000FF + assert style.ass_background_color == "&H800000FF" + + +# ── SubtitleSegment 测试 ───────────────────────────────────────────────────── + + +class TestSubtitleSegment: + def test_basic(self): + seg = SubtitleSegment(start=1.0, end=3.0, text="你好") + assert seg.start == 1.0 + assert seg.end == 3.0 + assert seg.text == "你好" + assert seg.style_name == "Default" + + def test_custom_style(self): + seg = SubtitleSegment(start=0, end=2, text="hi", style_name="Title") + assert seg.style_name == "Title" + + def test_duration(self): + seg = SubtitleSegment(start=1.5, end=4.0, text="test") + assert seg.duration == 2.5 + + def test_duration_zero_when_end_before_start(self): + seg = SubtitleSegment(start=5.0, end=3.0, text="test") + assert seg.duration == 0.0 + + def test_is_valid_true(self): + seg = SubtitleSegment(start=0, end=2, text="hello") + assert seg.is_valid is True + + def test_is_valid_empty_text(self): + seg = SubtitleSegment(start=0, end=2, text="") + assert seg.is_valid is False + + def test_is_valid_zero_duration(self): + seg = SubtitleSegment(start=1, end=1, text="hello") + assert seg.is_valid is False From 9f153eec5439c6ce0347dff71af70648cbce7f5b Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sun, 26 Jul 2026 16:28:12 +0000 Subject: [PATCH 17/45] style: auto-format with black + isort + prettier --- apps/worker/video_processing/concat_engine.py | 5 +++-- .../subtitle_render_engine.py | 19 ++++++++++--------- .../unified_render_service.py | 16 +++++++--------- tests/unit/test_subtitle_style.py | 2 +- tests/unit/test_tts_streaming_service.py | 2 +- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/worker/video_processing/concat_engine.py b/apps/worker/video_processing/concat_engine.py index 166ccfe33..3bf049412 100755 --- a/apps/worker/video_processing/concat_engine.py +++ b/apps/worker/video_processing/concat_engine.py @@ -20,6 +20,9 @@ from __future__ import annotations import logging from pathlib import Path +from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg +from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path + from packages.domain.video_concat import ( # noqa: F401 向后兼容导出 ALLOWED_VIDEO_EXTENSIONS, CONCAT_DEMUXER_REQUIRED_PARAMS, @@ -27,8 +30,6 @@ from packages.domain.video_concat import ( # noqa: F401 向后兼容导出 ConcatConfig, ConcatSegment, ) -from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg -from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path logger = logging.getLogger(__name__) diff --git a/apps/worker/video_processing/subtitle_render_engine.py b/apps/worker/video_processing/subtitle_render_engine.py index d15994991..a227205aa 100755 --- a/apps/worker/video_processing/subtitle_render_engine.py +++ b/apps/worker/video_processing/subtitle_render_engine.py @@ -26,7 +26,9 @@ from __future__ import annotations import logging from pathlib import Path -from packages.domain.subtitle_style import ( # noqa: F401 向后兼容导出 +from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path + +from packages.domain.subtitle_style import ( ALLOWED_SUBTITLE_EXTENSIONS, DEFAULT_COLOR, DEFAULT_FONT, @@ -35,18 +37,17 @@ from packages.domain.subtitle_style import ( # noqa: F401 向后兼容导出 DEFAULT_POSITION, DEFAULT_STROKE_COLOR, DEFAULT_STROKE_WIDTH, - POSITION_ALIGNMENT, POSITION_ALIASES, + POSITION_ALIGNMENT, SubtitleSegment, SubtitleStyle, - escape_ass_text as _escape_ass_text, - format_ass_time as _format_ass_time, - hex_to_ass_bgr as _hex_to_ass_bgr, - hex_to_ass_color as _hex_to_ass_color, - opacity_to_ass_alpha as _opacity_to_ass_alpha, - wrap_text as _wrap_text, ) -from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path +from packages.domain.subtitle_style import escape_ass_text as _escape_ass_text # noqa: F401 向后兼容导出 +from packages.domain.subtitle_style import format_ass_time as _format_ass_time +from packages.domain.subtitle_style import hex_to_ass_bgr as _hex_to_ass_bgr +from packages.domain.subtitle_style import hex_to_ass_color as _hex_to_ass_color +from packages.domain.subtitle_style import opacity_to_ass_alpha as _opacity_to_ass_alpha +from packages.domain.subtitle_style import wrap_text as _wrap_text logger = logging.getLogger(__name__) diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 3a13199fa..3a35dda9a 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -52,15 +52,13 @@ from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_fr from video_processing.tts_engine import TtsEngine from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine -from packages.domain.render_layer_utils import ( - LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX, - can_pass_through as _can_pass_through_pure, - clip_adjusted_duration as _clip_adjusted_duration_pure, - clip_effective_duration as _clip_effective_duration_pure, - clip_playback_speed as _clip_playback_speed_pure, - estimate_total_duration as _estimate_total_duration_pure, - resolve_layer_role as _resolve_layer_role_pure, -) +from packages.domain.render_layer_utils import LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX +from packages.domain.render_layer_utils import can_pass_through as _can_pass_through_pure +from packages.domain.render_layer_utils import clip_adjusted_duration as _clip_adjusted_duration_pure +from packages.domain.render_layer_utils import clip_effective_duration as _clip_effective_duration_pure +from packages.domain.render_layer_utils import clip_playback_speed as _clip_playback_speed_pure +from packages.domain.render_layer_utils import estimate_total_duration as _estimate_total_duration_pure +from packages.domain.render_layer_utils import resolve_layer_role as _resolve_layer_role_pure from packages.domain.tts_config import TtsConfig logger = logging.getLogger(__name__) diff --git a/tests/unit/test_subtitle_style.py b/tests/unit/test_subtitle_style.py index 94a0808e0..ddf397671 100755 --- a/tests/unit/test_subtitle_style.py +++ b/tests/unit/test_subtitle_style.py @@ -13,8 +13,8 @@ from packages.domain.subtitle_style import ( DEFAULT_POSITION, DEFAULT_STROKE_COLOR, DEFAULT_STROKE_WIDTH, - POSITION_ALIGNMENT, POSITION_ALIASES, + POSITION_ALIGNMENT, SubtitleSegment, SubtitleStyle, escape_ass_text, diff --git a/tests/unit/test_tts_streaming_service.py b/tests/unit/test_tts_streaming_service.py index 9d3d36eab..ae7e14b06 100755 --- a/tests/unit/test_tts_streaming_service.py +++ b/tests/unit/test_tts_streaming_service.py @@ -15,9 +15,9 @@ import pytest from packages.application.cosyvoice_service import CosyVoiceError from packages.application.tts_job.streaming_service import ( + _AUDIO_CHUNK_SIZE, TTSStreamingError, TTSStreamingService, - _AUDIO_CHUNK_SIZE, ) # ── Fixtures ────────────────────────────────────────────────────────────── From 1399912095292689ba3cc2b5e949750054c31007 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 07:18:00 +0800 Subject: [PATCH 18/45] =?UTF-8?q?refactor(wave110):=20=E6=8A=BD=E7=A6=BBpi?= =?UTF-8?q?p=5Fconfig=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9E=8B=20+=2064?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=20(#995)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/worker/video_processing/pip_engine.py | 230 ++-------- packages/domain/pip_config.py | 265 +++++++++++ tests/unit/test_pip_config.py | 489 +++++++++++++++++++++ 3 files changed, 793 insertions(+), 191 deletions(-) create mode 100755 packages/domain/pip_config.py create mode 100755 tests/unit/test_pip_config.py diff --git a/apps/worker/video_processing/pip_engine.py b/apps/worker/video_processing/pip_engine.py index bc231b2cb..535dddbc0 100755 --- a/apps/worker/video_processing/pip_engine.py +++ b/apps/worker/video_processing/pip_engine.py @@ -14,174 +14,33 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field from pathlib import Path -from typing import Any -logger = logging.getLogger(__name__) - - -# ── 位置常量 ────────────────────────────────────────────────────────────────── - -# 9宫格位置枚举 -POSITION_TOP_LEFT = "top_left" -POSITION_TOP_CENTER = "top_center" -POSITION_TOP_RIGHT = "top_right" -POSITION_CENTER_LEFT = "center_left" -POSITION_CENTER = "center" -POSITION_CENTER_RIGHT = "center_right" -POSITION_BOTTOM_LEFT = "bottom_left" -POSITION_BOTTOM_CENTER = "bottom_center" -POSITION_BOTTOM_RIGHT = "bottom_right" - -_VALID_POSITIONS = { - POSITION_TOP_LEFT, - POSITION_TOP_CENTER, - POSITION_TOP_RIGHT, - POSITION_CENTER_LEFT, - POSITION_CENTER, - POSITION_CENTER_RIGHT, - POSITION_BOTTOM_LEFT, - POSITION_BOTTOM_CENTER, - POSITION_BOTTOM_RIGHT, -} - -# 动画类型 -ANIMATION_FADE = "fade" # 淡入淡出 -ANIMATION_SLIDE_LEFT = "slide_left" # 从左滑入 -ANIMATION_SLIDE_RIGHT = "slide_right" # 从右滑入 -ANIMATION_SLIDE_TOP = "slide_top" # 从上滑入 -ANIMATION_SLIDE_BOTTOM = "slide_bottom" # 从下滑入 - -_VALID_ANIMATIONS = { +from packages.domain.pip_config import ( # noqa: F401 — 向后兼容:保留模块级导出 ANIMATION_FADE, + ANIMATION_SCALE, + ANIMATION_SLIDE_BOTTOM, ANIMATION_SLIDE_LEFT, ANIMATION_SLIDE_RIGHT, ANIMATION_SLIDE_TOP, - ANIMATION_SLIDE_BOTTOM, -} + PiPConfig, + PiPLayerConfig, + POSITION_BOTTOM_LEFT, + POSITION_BOTTOM_RIGHT, + POSITION_CENTER, + POSITION_CENTER_LEFT, + POSITION_CENTER_RIGHT, + POSITION_TOP_CENTER, + POSITION_TOP_LEFT, + POSITION_TOP_RIGHT, + calculate_pip_position as _calculate_pip_position_base, + parse_size_value as _parse_size_value_base, +) +# 向后兼容:POSITION_BOTTOM_CENTER 也从 pip_config 再导出 +from packages.domain.pip_config import POSITION_BOTTOM_CENTER # noqa: E402, F401 -# ── 数据模型 ────────────────────────────────────────────────────────────────── - - -@dataclass -class PiPLayerConfig: - """单个画中画图层配置.""" - - # 素材来源 - source: str = "" # 素材ID或视频URL - source_type: str = "asset_id" # "asset_id" | "url" | "local_path" - - # 位置配置 - position: str = POSITION_BOTTOM_RIGHT # 9宫格位置或 "custom" - x: int | str = 0 # 自定义x坐标(像素或百分比如 "30%") - y: int | str = 0 # 自定义y坐标 - margin: int = 20 # 9宫格模式下的边距(像素) - - # 大小配置 - width: int | str = "25%" # 宽度(像素或百分比) - height: int | str = "" # 高度(空则按比例自适应) - - # 样式 - opacity: float = 1.0 # 透明度 0.0-1.0 - corner_radius: int = 0 # 圆角半径(像素),0表示无圆角 - border_width: int = 0 # 边框宽度 - border_color: str = "white" # 边框颜色 - - # 时间控制 - start_time: float = 0.0 # 开始显示时间(秒) - duration: float = 0.0 # 持续时长(秒),0表示全程显示 - - # 动画 - animation_in: str = "" # 入场动画类型 - animation_out: str = "" # 出场动画类型 - animation_duration: float = 0.5 # 动画时长(秒) - - # 层级 - z_index: int = 1 # 图层顺序,数字越大越在上层 - - def validate(self) -> tuple[bool, str]: - """校验配置合法性,返回 (是否合法, 错误信息).""" - if not self.source: - return False, "source不能为空" - - if self.position != "custom" and self.position not in _VALID_POSITIONS: - return False, f"无效的position: {self.position}" - - if self.opacity < 0 or self.opacity > 1: - return False, "opacity必须在0-1之间" - - if self.corner_radius < 0: - return False, "corner_radius不能为负数" - - if self.start_time < 0: - return False, "start_time不能为负数" - - if self.duration < 0: - return False, "duration不能为负数" - - if self.animation_in and self.animation_in not in _VALID_ANIMATIONS: - return False, f"无效的入场动画: {self.animation_in}" - - if self.animation_out and self.animation_out not in _VALID_ANIMATIONS: - return False, f"无效的出场动画: {self.animation_out}" - - if self.animation_duration < 0: - return False, "animation_duration不能为负数" - - return True, "" - - -@dataclass -class PiPConfig: - """画中画整体配置.""" - - enabled: bool = False - layers: list[PiPLayerConfig] = field(default_factory=list) - - @classmethod - def from_dict(cls, data: dict[str, Any] | None) -> "PiPConfig": - """从字典解析配置.""" - if not data or not data.get("enabled", False): - return cls(enabled=False) - - layers_data = data.get("layers", []) - layers = [] - for layer_data in layers_data: - try: - layer = PiPLayerConfig( - source=layer_data.get("source", ""), - source_type=layer_data.get("source_type", "asset_id"), - position=layer_data.get("position", POSITION_BOTTOM_RIGHT), - x=layer_data.get("x", 0), - y=layer_data.get("y", 0), - margin=int(layer_data.get("margin", 20)), - width=layer_data.get("width", "25%"), - height=layer_data.get("height", ""), - opacity=float(layer_data.get("opacity", 1.0)), - corner_radius=int(layer_data.get("corner_radius", 0)), - border_width=int(layer_data.get("border_width", 0)), - border_color=layer_data.get("border_color", "white"), - start_time=float(layer_data.get("start_time", 0.0)), - duration=float(layer_data.get("duration", 0.0)), - animation_in=layer_data.get("animation_in", ""), - animation_out=layer_data.get("animation_out", ""), - animation_duration=float(layer_data.get("animation_duration", 0.5)), - z_index=int(layer_data.get("z_index", 1)), - ) - valid, err = layer.validate() - if valid: - layers.append(layer) - else: - logger.warning("PiP图层配置无效,跳过: %s", err) - except (ValueError, TypeError) as e: - logger.warning("PiP图层解析失败,跳过: %s", e) - - # 按 z_index 排序 - layers.sort(key=lambda layer: layer.z_index) - - return cls(enabled=bool(layers), layers=layers) +logger = logging.getLogger(__name__) # ── PiP 引擎 ────────────────────────────────────────────────────────────────── @@ -201,16 +60,12 @@ class PiPEngine: self.output_fps = output_fps def _parse_size(self, value: int | str, base: int) -> int: - """解析尺寸值(像素或百分比).""" - if isinstance(value, int): - return max(1, value) - if isinstance(value, str) and value.endswith("%"): - pct = float(value.rstrip("%")) / 100.0 - return max(1, int(base * pct)) - try: - return max(1, int(value)) - except (ValueError, TypeError): - return int(base * 0.25) # 默认25% + """解析尺寸值(像素或百分比). + + 委托给 packages.domain.pip_config.parse_size_value 纯逻辑函数, + 薄包装保留在类内以维持向后兼容。 + """ + return _parse_size_value_base(value, base) def _parse_position( self, @@ -218,28 +73,21 @@ class PiPEngine: pip_width: int, pip_height: int, ) -> tuple[int, int]: - """计算画中画的实际位置 (x, y).""" - W = self.output_width - H = self.output_height - m = layer.margin + """计算画中画的实际位置 (x, y). - if layer.position == "custom": - x = self._parse_size(layer.x, W) - y = self._parse_size(layer.y, H) - return (x, y) - - pos_map = { - POSITION_TOP_LEFT: (m, m), - POSITION_TOP_CENTER: ((W - pip_width) // 2, m), - POSITION_TOP_RIGHT: (W - pip_width - m, m), - POSITION_CENTER_LEFT: (m, (H - pip_height) // 2), - POSITION_CENTER: ((W - pip_width) // 2, (H - pip_height) // 2), - POSITION_CENTER_RIGHT: (W - pip_width - m, (H - pip_height) // 2), - POSITION_BOTTOM_LEFT: (m, H - pip_height - m), - POSITION_BOTTOM_CENTER: ((W - pip_width) // 2, H - pip_height - m), - POSITION_BOTTOM_RIGHT: (W - pip_width - m, H - pip_height - m), - } - return pos_map.get(layer.position, pos_map[POSITION_BOTTOM_RIGHT]) + 委托给 packages.domain.pip_config.calculate_pip_position 纯逻辑函数, + 薄包装保留在类内以维持向后兼容。 + """ + return _calculate_pip_position_base( + position=layer.position, + output_width=self.output_width, + output_height=self.output_height, + pip_width=pip_width, + pip_height=pip_height, + margin=layer.margin, + custom_x=layer.x, + custom_y=layer.y, + ) def _build_pip_pre_filter( self, diff --git a/packages/domain/pip_config.py b/packages/domain/pip_config.py new file mode 100755 index 000000000..e843504cb --- /dev/null +++ b/packages/domain/pip_config.py @@ -0,0 +1,265 @@ +"""画中画(PiP)配置领域模型 — 纯逻辑,无外部依赖. + +抽离自 pip_engine.py 的数据类和纯逻辑函数, +方便单测覆盖,同时保持向后兼容。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger(__name__) + + +# ── 位置常量 ────────────────────────────────────────────────────────────────── + +POSITION_TOP_LEFT = "top_left" +POSITION_TOP_CENTER = "top_center" +POSITION_TOP_RIGHT = "top_right" +POSITION_CENTER_LEFT = "center_left" +POSITION_CENTER = "center" +POSITION_CENTER_RIGHT = "center_right" +POSITION_BOTTOM_LEFT = "bottom_left" +POSITION_BOTTOM_CENTER = "bottom_center" +POSITION_BOTTOM_RIGHT = "bottom_right" + +_VALID_POSITIONS = { + POSITION_TOP_LEFT, + POSITION_TOP_CENTER, + POSITION_TOP_RIGHT, + POSITION_CENTER_LEFT, + POSITION_CENTER, + POSITION_CENTER_RIGHT, + POSITION_BOTTOM_LEFT, + POSITION_BOTTOM_CENTER, + POSITION_BOTTOM_RIGHT, +} + +# 动画类型 +ANIMATION_FADE = "fade" +ANIMATION_SLIDE_LEFT = "slide_left" +ANIMATION_SLIDE_RIGHT = "slide_right" +ANIMATION_SLIDE_TOP = "slide_top" +ANIMATION_SLIDE_BOTTOM = "slide_bottom" +ANIMATION_SCALE = "scale" + +_VALID_ANIMATIONS = { + ANIMATION_FADE, + ANIMATION_SLIDE_LEFT, + ANIMATION_SLIDE_RIGHT, + ANIMATION_SLIDE_TOP, + ANIMATION_SLIDE_BOTTOM, + ANIMATION_SCALE, +} + + +# ── 数据模型 ────────────────────────────────────────────────────────────────── + + +@dataclass +class PiPLayerConfig: + """单个画中画图层配置.""" + + # 素材来源 + source: str = "" + source_type: str = "asset_id" # "asset_id" | "url" | "local_path" + + # 位置配置 + position: str = POSITION_BOTTOM_RIGHT + x: int | str = 0 + y: int | str = 0 + margin: int = 20 + + # 大小配置 + width: int | str = "25%" + height: int | str = "" # 空则按比例自适应 + + # 样式 + opacity: float = 1.0 + corner_radius: int = 0 + border_width: int = 0 + border_color: str = "white" + + # 时间控制 + start_time: float = 0.0 + duration: float = 0.0 # 0表示全程显示 + + # 动画 + animation_in: str = "" + animation_out: str = "" + animation_duration: float = 0.5 + + # 层级 + z_index: int = 1 + + def validate(self) -> tuple[bool, str]: + """校验配置合法性,返回 (是否合法, 错误信息).""" + if not self.source: + return False, "source不能为空" + + if self.position != "custom" and self.position not in _VALID_POSITIONS: + return False, f"无效的position: {self.position}" + + if self.opacity < 0 or self.opacity > 1: + return False, "opacity必须在0-1之间" + + if self.corner_radius < 0: + return False, "corner_radius不能为负数" + + if self.start_time < 0: + return False, "start_time不能为负数" + + if self.duration < 0: + return False, "duration不能为负数" + + if self.animation_in and self.animation_in not in _VALID_ANIMATIONS: + return False, f"无效的入场动画: {self.animation_in}" + + if self.animation_out and self.animation_out not in _VALID_ANIMATIONS: + return False, f"无效的出场动画: {self.animation_out}" + + if self.animation_duration < 0: + return False, "animation_duration不能为负数" + + return True, "" + + +@dataclass +class PiPConfig: + """画中画整体配置.""" + + enabled: bool = False + layers: list[PiPLayerConfig] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "PiPConfig": + """从字典解析配置.""" + if not data or not data.get("enabled", False): + return cls(enabled=False) + + layers_data = data.get("layers", []) + layers: list[PiPLayerConfig] = [] + for layer_data in layers_data: + try: + layer = PiPLayerConfig( + source=layer_data.get("source", ""), + source_type=layer_data.get("source_type", "asset_id"), + position=layer_data.get("position", POSITION_BOTTOM_RIGHT), + x=layer_data.get("x", 0), + y=layer_data.get("y", 0), + margin=int(layer_data.get("margin", 20)), + width=layer_data.get("width", "25%"), + height=layer_data.get("height", ""), + opacity=float(layer_data.get("opacity", 1.0)), + corner_radius=int(layer_data.get("corner_radius", 0)), + border_width=int(layer_data.get("border_width", 0)), + border_color=layer_data.get("border_color", "white"), + start_time=float(layer_data.get("start_time", 0.0)), + duration=float(layer_data.get("duration", 0.0)), + animation_in=layer_data.get("animation_in", ""), + animation_out=layer_data.get("animation_out", ""), + animation_duration=float(layer_data.get("animation_duration", 0.5)), + z_index=int(layer_data.get("z_index", 1)), + ) + valid, err = layer.validate() + if valid: + layers.append(layer) + else: + logger.warning("PiP图层配置无效,跳过: %s", err) + except (ValueError, TypeError) as e: + logger.warning("PiP图层解析失败,跳过: %s", e) + + # 按 z_index 排序 + layers.sort(key=lambda layer: layer.z_index) + + return cls(enabled=bool(layers), layers=layers) + + @property + def layer_count(self) -> int: + """有效图层数量.""" + return len(self.layers) + + @property + def max_z_index(self) -> int: + """最大 z_index.""" + if not self.layers: + return 0 + return max(l.z_index for l in self.layers) + + +# ── 纯逻辑工具函数 ─────────────────────────────────────────────────────────── + + +def parse_size_value(value: int | str, base: int, default_pct: float = 0.25) -> int: + """解析尺寸值(像素或百分比). + + Args: + value: 尺寸值,int(像素)或 str(如 "30%") + base: 基准尺寸(用于百分比计算) + default_pct: 解析失败时的默认百分比 + + Returns: + 像素尺寸,>= 1 + """ + if isinstance(value, int): + return max(1, value) + if isinstance(value, str) and value.endswith("%"): + try: + pct = float(value.rstrip("%")) / 100.0 + return max(1, int(base * pct)) + except (ValueError, TypeError): + return max(1, int(base * default_pct)) + try: + return max(1, int(value)) + except (ValueError, TypeError): + return max(1, int(base * default_pct)) + + +def calculate_pip_position( + position: str, + output_width: int, + output_height: int, + pip_width: int, + pip_height: int, + margin: int = 20, + custom_x: int | str = 0, + custom_y: int | str = 0, +) -> tuple[int, int]: + """计算画中画的实际像素位置 (x, y). + + Args: + position: 9宫格位置或 "custom" + output_width: 画布宽度 + output_height: 画布高度 + pip_width: 画中画宽度 + pip_height: 画中画高度 + margin: 9宫格边距 + custom_x: 自定义x(position=custom时有效) + custom_y: 自定义y(position=custom时有效) + + Returns: + (x, y) 像素坐标 + """ + W = output_width + H = output_height + m = margin + + if position == "custom": + x = parse_size_value(custom_x, W) + y = parse_size_value(custom_y, H) + return (x, y) + + pos_map = { + POSITION_TOP_LEFT: (m, m), + POSITION_TOP_CENTER: ((W - pip_width) // 2, m), + POSITION_TOP_RIGHT: (W - pip_width - m, m), + POSITION_CENTER_LEFT: (m, (H - pip_height) // 2), + POSITION_CENTER: ((W - pip_width) // 2, (H - pip_height) // 2), + POSITION_CENTER_RIGHT: (W - pip_width - m, (H - pip_height) // 2), + POSITION_BOTTOM_LEFT: (m, H - pip_height - m), + POSITION_BOTTOM_CENTER: ((W - pip_width) // 2, H - pip_height - m), + POSITION_BOTTOM_RIGHT: (W - pip_width - m, H - pip_height - m), + } + return pos_map.get(position, pos_map[POSITION_BOTTOM_RIGHT]) diff --git a/tests/unit/test_pip_config.py b/tests/unit/test_pip_config.py new file mode 100755 index 000000000..6249b0e33 --- /dev/null +++ b/tests/unit/test_pip_config.py @@ -0,0 +1,489 @@ +"""pip_config 模块单测 — 纯逻辑,无外部依赖.""" + +from __future__ import annotations + +import pytest + +from packages.domain.pip_config import ( + ANIMATION_FADE, + ANIMATION_SCALE, + ANIMATION_SLIDE_BOTTOM, + ANIMATION_SLIDE_LEFT, + ANIMATION_SLIDE_RIGHT, + ANIMATION_SLIDE_TOP, + POSITION_BOTTOM_LEFT, + POSITION_BOTTOM_RIGHT, + POSITION_CENTER, + POSITION_CENTER_LEFT, + POSITION_CENTER_RIGHT, + POSITION_TOP_CENTER, + POSITION_TOP_LEFT, + POSITION_TOP_RIGHT, + PiPConfig, + PiPLayerConfig, + calculate_pip_position, + parse_size_value, +) + +# ── PiPLayerConfig 默认值 ──────────────────────────────────────────────────── + + +class TestPiPLayerConfigDefaults: + def test_default_values(self): + cfg = PiPLayerConfig() + assert cfg.source == "" + assert cfg.source_type == "asset_id" + assert cfg.position == POSITION_BOTTOM_RIGHT + assert cfg.x == 0 + assert cfg.y == 0 + assert cfg.margin == 20 + assert cfg.width == "25%" + assert cfg.height == "" + assert cfg.opacity == 1.0 + assert cfg.corner_radius == 0 + assert cfg.border_width == 0 + assert cfg.border_color == "white" + assert cfg.start_time == 0.0 + assert cfg.duration == 0.0 + assert cfg.animation_in == "" + assert cfg.animation_out == "" + assert cfg.animation_duration == 0.5 + assert cfg.z_index == 1 + + +# ── PiPLayerConfig.validate ────────────────────────────────────────────────── + + +class TestPiPLayerConfigValidate: + def test_valid_config(self): + cfg = PiPLayerConfig(source="asset_123") + ok, err = cfg.validate() + assert ok is True + assert err == "" + + def test_empty_source_invalid(self): + cfg = PiPLayerConfig(source="") + ok, err = cfg.validate() + assert ok is False + assert "source" in err + + def test_invalid_position(self): + cfg = PiPLayerConfig(source="a", position="invalid_pos") + ok, err = cfg.validate() + assert ok is False + assert "position" in err + + def test_custom_position_valid(self): + cfg = PiPLayerConfig(source="a", position="custom", x=10, y=20) + ok, err = cfg.validate() + assert ok is True + + def test_opacity_too_low(self): + cfg = PiPLayerConfig(source="a", opacity=-0.1) + ok, err = cfg.validate() + assert ok is False + assert "opacity" in err + + def test_opacity_too_high(self): + cfg = PiPLayerConfig(source="a", opacity=1.5) + ok, err = cfg.validate() + assert ok is False + assert "opacity" in err + + def test_opacity_boundary_zero(self): + cfg = PiPLayerConfig(source="a", opacity=0.0) + ok, _ = cfg.validate() + assert ok is True + + def test_opacity_boundary_one(self): + cfg = PiPLayerConfig(source="a", opacity=1.0) + ok, _ = cfg.validate() + assert ok is True + + def test_negative_corner_radius(self): + cfg = PiPLayerConfig(source="a", corner_radius=-5) + ok, err = cfg.validate() + assert ok is False + assert "corner_radius" in err + + def test_negative_start_time(self): + cfg = PiPLayerConfig(source="a", start_time=-1.0) + ok, err = cfg.validate() + assert ok is False + assert "start_time" in err + + def test_negative_duration(self): + cfg = PiPLayerConfig(source="a", duration=-2.0) + ok, err = cfg.validate() + assert ok is False + assert "duration" in err + + def test_zero_duration_valid(self): + cfg = PiPLayerConfig(source="a", duration=0.0) + ok, _ = cfg.validate() + assert ok is True + + def test_invalid_animation_in(self): + cfg = PiPLayerConfig(source="a", animation_in="invalid") + ok, err = cfg.validate() + assert ok is False + assert "入场动画" in err + + def test_invalid_animation_out(self): + cfg = PiPLayerConfig(source="a", animation_out="invalid") + ok, err = cfg.validate() + assert ok is False + assert "出场动画" in err + + def test_valid_animation_fade(self): + cfg = PiPLayerConfig(source="a", animation_in=ANIMATION_FADE, animation_out=ANIMATION_FADE) + ok, _ = cfg.validate() + assert ok is True + + def test_valid_animation_slide(self): + cfg = PiPLayerConfig( + source="a", + animation_in=ANIMATION_SLIDE_LEFT, + animation_out=ANIMATION_SLIDE_RIGHT, + ) + ok, _ = cfg.validate() + assert ok is True + + def test_valid_animation_scale(self): + cfg = PiPLayerConfig(source="a", animation_in=ANIMATION_SCALE) + ok, _ = cfg.validate() + assert ok is True + + def test_empty_animation_valid(self): + cfg = PiPLayerConfig(source="a", animation_in="", animation_out="") + ok, _ = cfg.validate() + assert ok is True + + def test_negative_animation_duration(self): + cfg = PiPLayerConfig(source="a", animation_duration=-0.5) + ok, err = cfg.validate() + assert ok is False + assert "animation_duration" in err + + +# ── PiPConfig.from_dict ────────────────────────────────────────────────────── + + +class TestPiPConfigFromDict: + def test_none_data_disabled(self): + cfg = PiPConfig.from_dict(None) + assert cfg.enabled is False + assert cfg.layers == [] + + def test_empty_dict_disabled(self): + cfg = PiPConfig.from_dict({}) + assert cfg.enabled is False + assert cfg.layers == [] + + def test_enabled_false(self): + cfg = PiPConfig.from_dict({"enabled": False, "layers": [{"source": "a"}]}) + assert cfg.enabled is False + assert cfg.layers == [] + + def test_single_layer(self): + cfg = PiPConfig.from_dict( + { + "enabled": True, + "layers": [{"source": "asset_1"}], + } + ) + assert cfg.enabled is True + assert cfg.layer_count == 1 + assert cfg.layers[0].source == "asset_1" + + def test_multiple_layers_sorted_by_z_index(self): + cfg = PiPConfig.from_dict( + { + "enabled": True, + "layers": [ + {"source": "top", "z_index": 10}, + {"source": "bottom", "z_index": 1}, + {"source": "mid", "z_index": 5}, + ], + } + ) + assert cfg.layer_count == 3 + assert [l.source for l in cfg.layers] == ["bottom", "mid", "top"] + + def test_invalid_layer_skipped(self): + cfg = PiPConfig.from_dict( + { + "enabled": True, + "layers": [ + {"source": "valid"}, + {"source": ""}, # 无效:空source + ], + } + ) + assert cfg.layer_count == 1 + assert cfg.layers[0].source == "valid" + + def test_all_invalid_layers_disabled(self): + cfg = PiPConfig.from_dict( + { + "enabled": True, + "layers": [ + {"source": ""}, + {"source": "", "opacity": 2.0}, + ], + } + ) + assert cfg.enabled is False + assert cfg.layer_count == 0 + + def test_layer_parse_error_skipped(self): + cfg = PiPConfig.from_dict( + { + "enabled": True, + "layers": [ + {"source": "valid"}, + {"source": "bad_margin", "margin": "not_a_number"}, + ], + } + ) + assert cfg.layer_count == 1 + + def test_layer_full_fields(self): + cfg = PiPConfig.from_dict( + { + "enabled": True, + "layers": [ + { + "source": "asset_1", + "source_type": "url", + "position": "top_left", + "x": 10, + "y": 20, + "margin": 30, + "width": "30%", + "height": "20%", + "opacity": 0.8, + "corner_radius": 10, + "border_width": 2, + "border_color": "black", + "start_time": 1.5, + "duration": 5.0, + "animation_in": "fade", + "animation_out": "slide_right", + "animation_duration": 0.8, + "z_index": 3, + } + ], + } + ) + assert cfg.layer_count == 1 + layer = cfg.layers[0] + assert layer.source == "asset_1" + assert layer.source_type == "url" + assert layer.position == "top_left" + assert layer.margin == 30 + assert layer.width == "30%" + assert layer.opacity == 0.8 + assert layer.corner_radius == 10 + assert layer.start_time == 1.5 + assert layer.duration == 5.0 + assert layer.animation_in == "fade" + assert layer.z_index == 3 + + def test_empty_layers_list(self): + cfg = PiPConfig.from_dict({"enabled": True, "layers": []}) + assert cfg.enabled is False + assert cfg.layer_count == 0 + + +# ── PiPConfig 属性 ─────────────────────────────────────────────────────────── + + +class TestPiPConfigProperties: + def test_layer_count_empty(self): + cfg = PiPConfig() + assert cfg.layer_count == 0 + + def test_max_z_index_empty(self): + cfg = PiPConfig() + assert cfg.max_z_index == 0 + + def test_max_z_index_multiple(self): + cfg = PiPConfig( + layers=[ + PiPLayerConfig(source="a", z_index=3), + PiPLayerConfig(source="b", z_index=7), + PiPLayerConfig(source="c", z_index=2), + ] + ) + assert cfg.max_z_index == 7 + + +# ── parse_size_value ───────────────────────────────────────────────────────── + + +class TestParseSizeValue: + def test_int_value(self): + assert parse_size_value(100, 1920) == 100 + + def test_int_value_zero_bumped_to_one(self): + assert parse_size_value(0, 1920) == 1 + + def test_int_negative_bumped_to_one(self): + assert parse_size_value(-5, 1920) == 1 + + def test_percentage_string(self): + assert parse_size_value("50%", 1920) == 960 + + def test_percentage_25pct(self): + assert parse_size_value("25%", 1920) == 480 + + def test_percentage_small(self): + assert parse_size_value("1%", 1920) == 19 + + def test_percentage_zero_bumped(self): + assert parse_size_value("0%", 1920) == 1 + + def test_invalid_percentage_fallback(self): + assert parse_size_value("abc%", 1920) == 480 # 25% default + + def test_numeric_string(self): + assert parse_size_value("200", 1920) == 200 + + def test_invalid_string_fallback(self): + assert parse_size_value("invalid", 1920) == 480 + + def test_custom_default_pct(self): + assert parse_size_value("bad", 1000, default_pct=0.5) == 500 + + def test_none_fallback(self): + assert parse_size_value(None, 1920) == 480 # type: ignore[arg-type] + + def test_float_int_conversion(self): + # float 不是 int,会走到 try int(value) 分支 + result = parse_size_value(150.0, 1920) # type: ignore[arg-type] + assert result == 150 + + +# ── calculate_pip_position ─────────────────────────────────────────────────── + + +class TestCalculatePipPosition: + W = 1920 + H = 1080 + PW = 300 # pip width + PH = 200 # pip height + M = 20 # margin + + def test_top_left(self): + x, y = calculate_pip_position(POSITION_TOP_LEFT, self.W, self.H, self.PW, self.PH, self.M) + assert (x, y) == (20, 20) + + def test_top_center(self): + x, y = calculate_pip_position(POSITION_TOP_CENTER, self.W, self.H, self.PW, self.PH, self.M) + assert x == (self.W - self.PW) // 2 + assert y == self.M + + def test_top_right(self): + x, y = calculate_pip_position(POSITION_TOP_RIGHT, self.W, self.H, self.PW, self.PH, self.M) + assert x == self.W - self.PW - self.M + assert y == self.M + + def test_center_left(self): + x, y = calculate_pip_position(POSITION_CENTER_LEFT, self.W, self.H, self.PW, self.PH, self.M) + assert x == self.M + assert y == (self.H - self.PH) // 2 + + def test_center(self): + x, y = calculate_pip_position(POSITION_CENTER, self.W, self.H, self.PW, self.PH, self.M) + assert x == (self.W - self.PW) // 2 + assert y == (self.H - self.PH) // 2 + + def test_center_right(self): + x, y = calculate_pip_position(POSITION_CENTER_RIGHT, self.W, self.H, self.PW, self.PH, self.M) + assert x == self.W - self.PW - self.M + assert y == (self.H - self.PH) // 2 + + def test_bottom_left(self): + x, y = calculate_pip_position(POSITION_BOTTOM_LEFT, self.W, self.H, self.PW, self.PH, self.M) + assert x == self.M + assert y == self.H - self.PH - self.M + + def test_bottom_center(self): + x, y = calculate_pip_position(POSITION_BOTTOM_RIGHT, self.W, self.H, self.PW, self.PH, self.M) + # bottom_right 用作 fallback 默认值 + assert x == self.W - self.PW - self.M + assert y == self.H - self.PH - self.M + + def test_bottom_right(self): + x, y = calculate_pip_position(POSITION_BOTTOM_RIGHT, self.W, self.H, self.PW, self.PH, self.M) + assert x == self.W - self.PW - self.M + assert y == self.H - self.PH - self.M + + def test_invalid_position_falls_back_to_bottom_right(self): + x, y = calculate_pip_position("unknown_pos", self.W, self.H, self.PW, self.PH, self.M) + assert x == self.W - self.PW - self.M + assert y == self.H - self.PH - self.M + + def test_custom_int_coordinates(self): + x, y = calculate_pip_position("custom", self.W, self.H, self.PW, self.PH, custom_x=100, custom_y=200) + assert (x, y) == (100, 200) + + def test_custom_percentage_coordinates(self): + x, y = calculate_pip_position("custom", self.W, self.H, self.PW, self.PH, custom_x="10%", custom_y="20%") + assert x == int(1920 * 0.1) + assert y == int(1080 * 0.2) + + def test_custom_zero_margin_ignored(self): + # custom 模式下 margin 参数不影响 + x, y = calculate_pip_position("custom", self.W, self.H, self.PW, self.PH, margin=100, custom_x=50, custom_y=60) + assert (x, y) == (50, 60) + + def test_default_margin(self): + # margin 不传默认为 20 + x, y = calculate_pip_position(POSITION_TOP_LEFT, self.W, self.H, self.PW, self.PH) + assert (x, y) == (20, 20) + + def test_large_margin(self): + x, y = calculate_pip_position(POSITION_TOP_LEFT, self.W, self.H, self.PW, self.PH, margin=50) + assert (x, y) == (50, 50) + + def test_small_output_large_pip(self): + # 极端情况:pip比输出还大,位置计算仍能给出值 + x, y = calculate_pip_position(POSITION_CENTER, 100, 100, 200, 200, 10) + assert x == (100 - 200) // 2 + assert y == (100 - 200) // 2 + + +# ── 常量导出验证 ───────────────────────────────────────────────────────────── + + +class TestConstants: + def test_nine_position_constants_exist(self): + positions = [ + POSITION_TOP_LEFT, + POSITION_TOP_CENTER, + POSITION_TOP_RIGHT, + POSITION_CENTER_LEFT, + POSITION_CENTER, + POSITION_CENTER_RIGHT, + POSITION_BOTTOM_LEFT, + POSITION_BOTTOM_RIGHT, + ] + # bottom_center 也存在 + from packages.domain.pip_config import POSITION_BOTTOM_CENTER + + positions.append(POSITION_BOTTOM_CENTER) + assert len(positions) == 9 + assert len(set(positions)) == 9 # 互不相同 + + def test_animation_constants_exist(self): + animations = [ + ANIMATION_FADE, + ANIMATION_SLIDE_LEFT, + ANIMATION_SLIDE_RIGHT, + ANIMATION_SLIDE_TOP, + ANIMATION_SLIDE_BOTTOM, + ANIMATION_SCALE, + ] + assert len(set(animations)) == 6 From c0af8e7c43b107118b211100b668e62724649894 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 07:18:00 +0800 Subject: [PATCH 19/45] =?UTF-8?q?refactor(wave111):=20=E6=8A=BD=E7=A6=BBau?= =?UTF-8?q?dio=5Ftrack=5Fconfig=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9E=8B=20+=20?= =?UTF-8?q?67=E5=8D=95=E6=B5=8B=20(#997)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../video_processing/multi_track_mixer.py | 143 +---- packages/domain/audio_track_config.py | 214 ++++++++ tests/unit/test_audio_track_config.py | 488 ++++++++++++++++++ 3 files changed, 714 insertions(+), 131 deletions(-) create mode 100755 packages/domain/audio_track_config.py create mode 100755 tests/unit/test_audio_track_config.py diff --git a/apps/worker/video_processing/multi_track_mixer.py b/apps/worker/video_processing/multi_track_mixer.py index 8e76828c4..997521f3e 100755 --- a/apps/worker/video_processing/multi_track_mixer.py +++ b/apps/worker/video_processing/multi_track_mixer.py @@ -16,10 +16,21 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING +from packages.domain.audio_track_config import ( # noqa: F401 — 向后兼容 + ALLOWED_AUDIO_EXTENSIONS, + DEFAULT_VOLUMES, + MAX_AUDIO_TRACKS, + AudioTrack, + MultiTrackMixConfig, + TRACK_TYPE_AMBIENT, + TRACK_TYPE_BGM, + TRACK_TYPE_MAIN, + TRACK_TYPE_SFX, + TRACK_TYPE_VOICEOVER, +) from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path @@ -29,139 +40,9 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -# ── 常量 ────────────────────────────────────────────────────────────────────── - -TRACK_TYPE_MAIN = "main" # 原音(视频原声) -TRACK_TYPE_BGM = "bgm" # 背景音乐 -TRACK_TYPE_VOICEOVER = "voiceover" # 配音(TTS/人声) -TRACK_TYPE_SFX = "sfx" # 音效 -TRACK_TYPE_AMBIENT = "ambient" # 环境音 - -MAX_AUDIO_TRACKS = 8 # 最大混音轨道数(安全上限,防止资源耗尽) - -# 各轨道默认音量(相对主音频) -DEFAULT_VOLUMES = { - TRACK_TYPE_MAIN: 1.0, - TRACK_TYPE_BGM: 0.3, - TRACK_TYPE_VOICEOVER: 1.0, - TRACK_TYPE_SFX: 0.7, - TRACK_TYPE_AMBIENT: 0.2, -} - - -@dataclass -class AudioTrack: - """单条音频轨道配置.""" - - track_id: str # 轨道唯一标识 - track_type: str # 轨道类型(main/bgm/voiceover/sfx/ambient) - audio_path: str # 音频文件路径 - volume: float = 1.0 # 音量 0.0 ~ 2.0 - fade_in: float = 0.0 # 淡入时长(秒) - fade_out: float = 0.0 # 淡出时长(秒) - start_time: float = 0.0 # 开始时间(相对于视频起点,秒) - duration: float = 0.0 # 持续时长(0表示到文件末尾) - enabled: bool = True # 是否启用 - - @classmethod - def from_dict(cls, track: dict) -> "AudioTrack": - """从字典创建 AudioTrack,带安全类型转换.""" - track_type = str(track.get("track_type", TRACK_TYPE_SFX)) - default_vol = DEFAULT_VOLUMES.get(track_type, 1.0) - - try: - volume = float(track.get("volume", default_vol)) - except (TypeError, ValueError): - volume = default_vol - volume = max(0.0, min(2.0, volume)) - - try: - fade_in = max(0.0, float(track.get("fade_in", 0.0))) - except (TypeError, ValueError): - fade_in = 0.0 - - try: - fade_out = max(0.0, float(track.get("fade_out", 0.0))) - except (TypeError, ValueError): - fade_out = 0.0 - - try: - start_time = max(0.0, float(track.get("start_time", 0.0))) - except (TypeError, ValueError): - start_time = 0.0 - - try: - duration = max(0.0, float(track.get("duration", 0.0))) - except (TypeError, ValueError): - duration = 0.0 - - return cls( - track_id=str(track.get("track_id", "")), - track_type=track_type, - audio_path=str(track.get("audio_path", "")), - volume=volume, - fade_in=fade_in, - fade_out=fade_out, - start_time=start_time, - duration=duration, - enabled=bool(track.get("enabled", True)), - ) - - -@dataclass -class MultiTrackMixConfig: - """多轨道混音配置.""" - - tracks: list[AudioTrack] = field(default_factory=list) - master_volume: float = 1.0 # 主输出音量 - normalize: bool = True # 是否自动归一化补偿 - max_output_volume: float = 1.5 # 最大输出音量(防止爆音) - - @classmethod - def from_config_dict(cls, config: dict | None) -> "MultiTrackMixConfig": - """从 plan.config.audio_tracks 字典创建配置.""" - if not config or not isinstance(config, dict): - return cls() - - tracks_raw = config.get("tracks", []) - tracks: list[AudioTrack] = [] - - if isinstance(tracks_raw, list): - for t in tracks_raw: - if isinstance(t, dict) and t.get("audio_path"): - try: - track = AudioTrack.from_dict(t) - if track.enabled and track.audio_path: - tracks.append(track) - except Exception: - logger.warning("[multi-track] skip invalid track config: %s", t) - continue - - try: - master_volume = float(config.get("master_volume", 1.0)) - master_volume = max(0.0, min(2.0, master_volume)) - except (TypeError, ValueError): - master_volume = 1.0 - - return cls( - tracks=tracks, - master_volume=master_volume, - normalize=bool(config.get("normalize", True)), - max_output_volume=float(config.get("max_output_volume", 1.5)), - ) - - @property - def has_effect(self) -> bool: - """是否有有效轨道需要混音.""" - return len([t for t in self.tracks if t.enabled and t.audio_path]) > 0 - - # ── 路径安全校验 ──────────────────────────────────────────────────────────── -ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"} - - def _validate_audio_path(audio_path: str, work_dir: Path) -> None: """校验音频文件路径安全性. diff --git a/packages/domain/audio_track_config.py b/packages/domain/audio_track_config.py new file mode 100755 index 000000000..e0fac11e1 --- /dev/null +++ b/packages/domain/audio_track_config.py @@ -0,0 +1,214 @@ +"""多轨道音频配置领域模型 — 纯逻辑,无外部依赖. + +抽离自 multi_track_mixer.py 的数据类、常量和纯逻辑函数, +方便单测覆盖,同时保持向后兼容。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path + +logger = logging.getLogger(__name__) + + +# ── 常量 ────────────────────────────────────────────────────────────────────── + +TRACK_TYPE_MAIN = "main" # 原音(视频原声) +TRACK_TYPE_BGM = "bgm" # 背景音乐 +TRACK_TYPE_VOICEOVER = "voiceover" # 配音(TTS/人声) +TRACK_TYPE_SFX = "sfx" # 音效 +TRACK_TYPE_AMBIENT = "ambient" # 环境音 + +MAX_AUDIO_TRACKS = 8 # 最大混音轨道数(安全上限,防止资源耗尽) + +# 各轨道默认音量(相对主音频) +DEFAULT_VOLUMES = { + TRACK_TYPE_MAIN: 1.0, + TRACK_TYPE_BGM: 0.3, + TRACK_TYPE_VOICEOVER: 1.0, + TRACK_TYPE_SFX: 0.7, + TRACK_TYPE_AMBIENT: 0.2, +} + +ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"} + +_VALID_TRACK_TYPES = { + TRACK_TYPE_MAIN, + TRACK_TYPE_BGM, + TRACK_TYPE_VOICEOVER, + TRACK_TYPE_SFX, + TRACK_TYPE_AMBIENT, +} + + +# ── 数据模型 ────────────────────────────────────────────────────────────────── + + +@dataclass +class AudioTrack: + """单条音频轨道配置.""" + + track_id: str = "" # 轨道唯一标识 + track_type: str = TRACK_TYPE_SFX # 轨道类型 + audio_path: str = "" # 音频文件路径 + volume: float = 1.0 # 音量 0.0 ~ 2.0 + fade_in: float = 0.0 # 淡入时长(秒) + fade_out: float = 0.0 # 淡出时长(秒) + start_time: float = 0.0 # 开始时间(相对于视频起点,秒) + duration: float = 0.0 # 持续时长(0表示到文件末尾) + enabled: bool = True # 是否启用 + + @classmethod + def from_dict(cls, track: dict) -> "AudioTrack": + """从字典创建 AudioTrack,带安全类型转换.""" + track_type = str(track.get("track_type", TRACK_TYPE_SFX)) + default_vol = DEFAULT_VOLUMES.get(track_type, 1.0) + + try: + volume = float(track.get("volume", default_vol)) + except (TypeError, ValueError): + volume = default_vol + volume = max(0.0, min(2.0, volume)) + + try: + fade_in = max(0.0, float(track.get("fade_in", 0.0))) + except (TypeError, ValueError): + fade_in = 0.0 + + try: + fade_out = max(0.0, float(track.get("fade_out", 0.0))) + except (TypeError, ValueError): + fade_out = 0.0 + + try: + start_time = max(0.0, float(track.get("start_time", 0.0))) + except (TypeError, ValueError): + start_time = 0.0 + + try: + duration = max(0.0, float(track.get("duration", 0.0))) + except (TypeError, ValueError): + duration = 0.0 + + return cls( + track_id=str(track.get("track_id", "")), + track_type=track_type, + audio_path=str(track.get("audio_path", "")), + volume=volume, + fade_in=fade_in, + fade_out=fade_out, + start_time=start_time, + duration=duration, + enabled=bool(track.get("enabled", True)), + ) + + def validate(self) -> tuple[bool, str]: + """校验配置合法性,返回 (是否合法, 错误信息).""" + if not self.audio_path: + return False, "audio_path不能为空" + + if self.volume < 0.0 or self.volume > 2.0: + return False, f"volume必须在0-2之间: {self.volume}" + + if self.fade_in < 0: + return False, f"fade_in不能为负数: {self.fade_in}" + + if self.fade_out < 0: + return False, f"fade_out不能为负数: {self.fade_out}" + + if self.start_time < 0: + return False, f"start_time不能为负数: {self.start_time}" + + if self.duration < 0: + return False, f"duration不能为负数: {self.duration}" + + return True, "" + + @property + def is_effective(self) -> bool: + """是否为有效轨道(启用+有路径).""" + return self.enabled and bool(self.audio_path) + + +@dataclass +class MultiTrackMixConfig: + """多轨道混音配置.""" + + tracks: list[AudioTrack] = field(default_factory=list) + master_volume: float = 1.0 # 主输出音量 + normalize: bool = True # 是否自动归一化补偿 + max_output_volume: float = 1.5 # 最大输出音量(防止爆音) + + @classmethod + def from_config_dict(cls, config: dict | None) -> "MultiTrackMixConfig": + """从 plan.config.audio_tracks 字典创建配置.""" + if not config or not isinstance(config, dict): + return cls() + + tracks_raw = config.get("tracks", []) + tracks: list[AudioTrack] = [] + + if isinstance(tracks_raw, list): + for t in tracks_raw: + if isinstance(t, dict) and t.get("audio_path"): + try: + track = AudioTrack.from_dict(t) + if track.enabled and track.audio_path: + tracks.append(track) + except Exception: + logger.warning("[multi-track] skip invalid track config: %s", t) + continue + + try: + master_volume = float(config.get("master_volume", 1.0)) + master_volume = max(0.0, min(2.0, master_volume)) + except (TypeError, ValueError): + master_volume = 1.0 + + try: + max_output_volume = float(config.get("max_output_volume", 1.5)) + except (TypeError, ValueError): + max_output_volume = 1.5 + + return cls( + tracks=tracks, + master_volume=master_volume, + normalize=bool(config.get("normalize", True)), + max_output_volume=max_output_volume, + ) + + @property + def has_effect(self) -> bool: + """是否有有效轨道需要混音.""" + return len([t for t in self.tracks if t.is_effective]) > 0 + + @property + def effective_track_count(self) -> int: + """有效轨道数量.""" + return len([t for t in self.tracks if t.is_effective]) + + @property + def main_tracks(self) -> list[AudioTrack]: + """主音轨列表.""" + return [t for t in self.tracks if t.track_type == TRACK_TYPE_MAIN and t.is_effective] + + @property + def bgm_tracks(self) -> list[AudioTrack]: + """BGM轨道列表.""" + return [t for t in self.tracks if t.track_type == TRACK_TYPE_BGM and t.is_effective] + + +# ── 纯逻辑工具函数 ─────────────────────────────────────────────────────────── + + +def is_valid_audio_extension(filename: str) -> bool: + """检查文件扩展名是否为支持的音频格式.""" + ext = Path(filename).suffix.lower() + return ext in ALLOWED_AUDIO_EXTENSIONS + + +def clamp_volume(volume: float, min_vol: float = 0.0, max_vol: float = 2.0) -> float: + """限制音量在合法范围内.""" + return max(min_vol, min(max_vol, volume)) diff --git a/tests/unit/test_audio_track_config.py b/tests/unit/test_audio_track_config.py new file mode 100755 index 000000000..3264c08bd --- /dev/null +++ b/tests/unit/test_audio_track_config.py @@ -0,0 +1,488 @@ +"""audio_track_config 模块单测 — 纯逻辑,无外部依赖.""" + +from __future__ import annotations + +import pytest + +from packages.domain.audio_track_config import ( + ALLOWED_AUDIO_EXTENSIONS, + DEFAULT_VOLUMES, + MAX_AUDIO_TRACKS, + AudioTrack, + MultiTrackMixConfig, + TRACK_TYPE_AMBIENT, + TRACK_TYPE_BGM, + TRACK_TYPE_MAIN, + TRACK_TYPE_SFX, + TRACK_TYPE_VOICEOVER, + clamp_volume, + is_valid_audio_extension, +) + +# ── 常量 ────────────────────────────────────────────────────────────────────── + + +class TestConstants: + def test_track_type_constants(self): + assert TRACK_TYPE_MAIN == "main" + assert TRACK_TYPE_BGM == "bgm" + assert TRACK_TYPE_VOICEOVER == "voiceover" + assert TRACK_TYPE_SFX == "sfx" + assert TRACK_TYPE_AMBIENT == "ambient" + + def test_default_volumes_keys(self): + assert set(DEFAULT_VOLUMES.keys()) == { + TRACK_TYPE_MAIN, + TRACK_TYPE_BGM, + TRACK_TYPE_VOICEOVER, + TRACK_TYPE_SFX, + TRACK_TYPE_AMBIENT, + } + + def test_default_volumes_values(self): + assert DEFAULT_VOLUMES[TRACK_TYPE_MAIN] == 1.0 + assert DEFAULT_VOLUMES[TRACK_TYPE_BGM] == 0.3 + assert DEFAULT_VOLUMES[TRACK_TYPE_VOICEOVER] == 1.0 + assert DEFAULT_VOLUMES[TRACK_TYPE_SFX] == 0.7 + assert DEFAULT_VOLUMES[TRACK_TYPE_AMBIENT] == 0.2 + + def test_max_audio_tracks(self): + assert MAX_AUDIO_TRACKS == 8 + + def test_allowed_extensions(self): + assert ".mp3" in ALLOWED_AUDIO_EXTENSIONS + assert ".wav" in ALLOWED_AUDIO_EXTENSIONS + assert ".aac" in ALLOWED_AUDIO_EXTENSIONS + assert ".ogg" in ALLOWED_AUDIO_EXTENSIONS + assert ".flac" in ALLOWED_AUDIO_EXTENSIONS + assert ".m4a" in ALLOWED_AUDIO_EXTENSIONS + assert ".wma" in ALLOWED_AUDIO_EXTENSIONS + assert ".mp4" not in ALLOWED_AUDIO_EXTENSIONS + assert ".txt" not in ALLOWED_AUDIO_EXTENSIONS + + +# ── AudioTrack 默认值 ──────────────────────────────────────────────────────── + + +class TestAudioTrackDefaults: + def test_default_values(self): + track = AudioTrack() + assert track.track_id == "" + assert track.track_type == TRACK_TYPE_SFX + assert track.audio_path == "" + assert track.volume == 1.0 + assert track.fade_in == 0.0 + assert track.fade_out == 0.0 + assert track.start_time == 0.0 + assert track.duration == 0.0 + assert track.enabled is True + + +# ── AudioTrack.from_dict ───────────────────────────────────────────────────── + + +class TestAudioTrackFromDict: + def test_full_fields(self): + track = AudioTrack.from_dict( + { + "track_id": "t1", + "track_type": "bgm", + "audio_path": "/tmp/a.mp3", + "volume": 0.5, + "fade_in": 1.5, + "fade_out": 2.0, + "start_time": 3.0, + "duration": 10.0, + "enabled": True, + } + ) + assert track.track_id == "t1" + assert track.track_type == "bgm" + assert track.audio_path == "/tmp/a.mp3" + assert track.volume == 0.5 + assert track.fade_in == 1.5 + assert track.fade_out == 2.0 + assert track.start_time == 3.0 + assert track.duration == 10.0 + assert track.enabled is True + + def test_empty_dict_defaults(self): + track = AudioTrack.from_dict({}) + assert track.track_type == TRACK_TYPE_SFX + assert track.volume == DEFAULT_VOLUMES[TRACK_TYPE_SFX] + assert track.fade_in == 0.0 + assert track.enabled is True + + def test_volume_clamped_to_zero(self): + track = AudioTrack.from_dict({"volume": -0.5}) + assert track.volume == 0.0 + + def test_volume_clamped_to_two(self): + track = AudioTrack.from_dict({"volume": 3.0}) + assert track.volume == 2.0 + + def test_invalid_volume_fallback_to_default(self): + track = AudioTrack.from_dict({"track_type": "bgm", "volume": "invalid"}) + assert track.volume == DEFAULT_VOLUMES[TRACK_TYPE_BGM] + + def test_negative_fade_clamped_to_zero(self): + track = AudioTrack.from_dict({"fade_in": -1.0, "fade_out": -2.0}) + assert track.fade_in == 0.0 + assert track.fade_out == 0.0 + + def test_invalid_fade_fallback(self): + track = AudioTrack.from_dict({"fade_in": "bad", "fade_out": "bad"}) + assert track.fade_in == 0.0 + assert track.fade_out == 0.0 + + def test_negative_start_time_clamped(self): + track = AudioTrack.from_dict({"start_time": -5.0}) + assert track.start_time == 0.0 + + def test_invalid_start_time_fallback(self): + track = AudioTrack.from_dict({"start_time": "bad"}) + assert track.start_time == 0.0 + + def test_negative_duration_clamped(self): + track = AudioTrack.from_dict({"duration": -3.0}) + assert track.duration == 0.0 + + def test_invalid_duration_fallback(self): + track = AudioTrack.from_dict({"duration": "bad"}) + assert track.duration == 0.0 + + def test_enabled_false(self): + track = AudioTrack.from_dict({"enabled": False}) + assert track.enabled is False + + def test_bgm_default_volume(self): + track = AudioTrack.from_dict({"track_type": "bgm"}) + assert track.volume == 0.3 + + def test_unknown_track_type_default_volume(self): + track = AudioTrack.from_dict({"track_type": "unknown_type"}) + assert track.volume == 1.0 + + def test_string_numeric_values(self): + track = AudioTrack.from_dict( + { + "volume": "0.8", + "fade_in": "1.0", + "start_time": "2.5", + } + ) + assert track.volume == 0.8 + assert track.fade_in == 1.0 + assert track.start_time == 2.5 + + +# ── AudioTrack.validate ────────────────────────────────────────────────────── + + +class TestAudioTrackValidate: + def test_valid_track(self): + track = AudioTrack(audio_path="/tmp/a.mp3") + ok, err = track.validate() + assert ok is True + assert err == "" + + def test_empty_audio_path_invalid(self): + track = AudioTrack(audio_path="") + ok, err = track.validate() + assert ok is False + assert "audio_path" in err + + def test_volume_below_zero_invalid(self): + # from_dict 会 clamp,但直接构造可以测试 + track = AudioTrack(audio_path="a.mp3") + object.__setattr__(track, "volume", -0.1) + ok, err = track.validate() + assert ok is False + assert "volume" in err + + def test_volume_above_two_invalid(self): + track = AudioTrack(audio_path="a.mp3") + object.__setattr__(track, "volume", 2.1) + ok, err = track.validate() + assert ok is False + assert "volume" in err + + def test_volume_boundary_zero_valid(self): + track = AudioTrack(audio_path="a.mp3") + object.__setattr__(track, "volume", 0.0) + ok, _ = track.validate() + assert ok is True + + def test_volume_boundary_two_valid(self): + track = AudioTrack(audio_path="a.mp3") + object.__setattr__(track, "volume", 2.0) + ok, _ = track.validate() + assert ok is True + + def test_negative_fade_in_invalid(self): + track = AudioTrack(audio_path="a.mp3") + object.__setattr__(track, "fade_in", -1.0) + ok, err = track.validate() + assert ok is False + assert "fade_in" in err + + def test_negative_fade_out_invalid(self): + track = AudioTrack(audio_path="a.mp3") + object.__setattr__(track, "fade_out", -1.0) + ok, err = track.validate() + assert ok is False + assert "fade_out" in err + + def test_negative_start_time_invalid(self): + track = AudioTrack(audio_path="a.mp3") + object.__setattr__(track, "start_time", -0.5) + ok, err = track.validate() + assert ok is False + assert "start_time" in err + + def test_negative_duration_invalid(self): + track = AudioTrack(audio_path="a.mp3") + object.__setattr__(track, "duration", -1.0) + ok, err = track.validate() + assert ok is False + assert "duration" in err + + +# ── AudioTrack.is_effective ────────────────────────────────────────────────── + + +class TestAudioTrackIsEffective: + def test_enabled_with_path(self): + track = AudioTrack(audio_path="/tmp/a.mp3", enabled=True) + assert track.is_effective is True + + def test_disabled_with_path(self): + track = AudioTrack(audio_path="/tmp/a.mp3", enabled=False) + assert track.is_effective is False + + def test_enabled_empty_path(self): + track = AudioTrack(audio_path="", enabled=True) + assert track.is_effective is False + + def test_disabled_empty_path(self): + track = AudioTrack(audio_path="", enabled=False) + assert track.is_effective is False + + +# ── MultiTrackMixConfig.from_config_dict ───────────────────────────────────── + + +class TestMultiTrackMixConfigFromDict: + def test_none_config_empty(self): + cfg = MultiTrackMixConfig.from_config_dict(None) + assert cfg.tracks == [] + assert cfg.master_volume == 1.0 + assert cfg.normalize is True + assert cfg.max_output_volume == 1.5 + + def test_empty_dict_defaults(self): + cfg = MultiTrackMixConfig.from_config_dict({}) + assert cfg.tracks == [] + assert cfg.master_volume == 1.0 + assert cfg.normalize is True + + def test_single_track(self): + cfg = MultiTrackMixConfig.from_config_dict( + { + "tracks": [{"audio_path": "a.mp3", "track_type": "bgm", "volume": 0.5}], + } + ) + assert len(cfg.tracks) == 1 + assert cfg.tracks[0].audio_path == "a.mp3" + assert cfg.tracks[0].track_type == "bgm" + assert cfg.tracks[0].volume == 0.5 + + def test_multiple_tracks(self): + cfg = MultiTrackMixConfig.from_config_dict( + { + "tracks": [ + {"audio_path": "main.wav", "track_type": "main"}, + {"audio_path": "bgm.mp3", "track_type": "bgm"}, + {"audio_path": "sfx.wav", "track_type": "sfx"}, + ], + } + ) + assert len(cfg.tracks) == 3 + types = [t.track_type for t in cfg.tracks] + assert "main" in types + assert "bgm" in types + assert "sfx" in types + + def test_skip_disabled_track(self): + cfg = MultiTrackMixConfig.from_config_dict( + { + "tracks": [ + {"audio_path": "a.mp3", "enabled": True}, + {"audio_path": "b.mp3", "enabled": False}, + ], + } + ) + assert len(cfg.tracks) == 1 + assert cfg.tracks[0].audio_path == "a.mp3" + + def test_skip_missing_audio_path(self): + cfg = MultiTrackMixConfig.from_config_dict( + { + "tracks": [ + {"audio_path": "a.mp3"}, + {"track_type": "bgm"}, # 无audio_path + ], + } + ) + assert len(cfg.tracks) == 1 + + def test_invalid_track_skipped(self): + cfg = MultiTrackMixConfig.from_config_dict( + { + "tracks": [ + {"audio_path": "a.mp3"}, + "not_a_dict", + {"audio_path": 123, "volume": "bad"}, # 类型不对 + ], + } + ) + # 第二个不是dict跳过,第三个audio_path会被转成字符串"123" + # 但 track_type 非dict的话在 isinstance(t, dict) 判断就被跳过 + assert len(cfg.tracks) >= 1 + + def test_master_volume_clamped(self): + cfg = MultiTrackMixConfig.from_config_dict( + { + "tracks": [], + "master_volume": 3.0, + } + ) + assert cfg.master_volume == 2.0 + + def test_master_volume_negative_clamped(self): + cfg = MultiTrackMixConfig.from_config_dict( + { + "master_volume": -1.0, + } + ) + assert cfg.master_volume == 0.0 + + def test_invalid_master_volume_fallback(self): + cfg = MultiTrackMixConfig.from_config_dict( + { + "master_volume": "invalid", + } + ) + assert cfg.master_volume == 1.0 + + def test_normalize_false(self): + cfg = MultiTrackMixConfig.from_config_dict({"normalize": False}) + assert cfg.normalize is False + + def test_custom_max_output_volume(self): + cfg = MultiTrackMixConfig.from_config_dict({"max_output_volume": 2.0}) + assert cfg.max_output_volume == 2.0 + + def test_invalid_max_output_volume_fallback(self): + cfg = MultiTrackMixConfig.from_config_dict({"max_output_volume": "bad"}) + assert cfg.max_output_volume == 1.5 + + def test_not_dict_config(self): + cfg = MultiTrackMixConfig.from_config_dict("not a dict") + assert cfg.tracks == [] + assert cfg.master_volume == 1.0 + + +# ── MultiTrackMixConfig 属性 ───────────────────────────────────────────────── + + +class TestMultiTrackMixConfigProperties: + def test_has_effect_empty(self): + cfg = MultiTrackMixConfig() + assert cfg.has_effect is False + + def test_has_effect_with_tracks(self): + cfg = MultiTrackMixConfig( + tracks=[ + AudioTrack(audio_path="a.mp3", enabled=True), + ] + ) + assert cfg.has_effect is True + + def test_has_effect_all_disabled(self): + cfg = MultiTrackMixConfig( + tracks=[ + AudioTrack(audio_path="a.mp3", enabled=False), + ] + ) + assert cfg.has_effect is False + + def test_effective_track_count(self): + cfg = MultiTrackMixConfig( + tracks=[ + AudioTrack(audio_path="a.mp3", enabled=True), + AudioTrack(audio_path="b.mp3", enabled=False), + AudioTrack(audio_path="c.mp3", enabled=True), + AudioTrack(audio_path="", enabled=True), + ] + ) + assert cfg.effective_track_count == 2 + + def test_main_tracks(self): + cfg = MultiTrackMixConfig( + tracks=[ + AudioTrack(audio_path="m1.mp3", track_type=TRACK_TYPE_MAIN), + AudioTrack(audio_path="b1.mp3", track_type=TRACK_TYPE_BGM), + AudioTrack(audio_path="m2.mp3", track_type=TRACK_TYPE_MAIN, enabled=False), + ] + ) + mains = cfg.main_tracks + assert len(mains) == 1 + assert mains[0].audio_path == "m1.mp3" + + def test_bgm_tracks(self): + cfg = MultiTrackMixConfig( + tracks=[ + AudioTrack(audio_path="b1.mp3", track_type=TRACK_TYPE_BGM), + AudioTrack(audio_path="b2.mp3", track_type=TRACK_TYPE_BGM), + AudioTrack(audio_path="v1.mp3", track_type=TRACK_TYPE_VOICEOVER), + ] + ) + assert len(cfg.bgm_tracks) == 2 + + +# ── 工具函数 ───────────────────────────────────────────────────────────────── + + +class TestUtils: + @pytest.mark.parametrize( + "name,expected", + [ + ("song.mp3", True), + ("audio.WAV", True), + ("track.m4a", True), + ("video.mp4", False), + ("text.txt", False), + ("", False), + ("/path/to/music.flac", True), + ("sound.OGG", True), + ], + ) + def test_is_valid_audio_extension(self, name, expected): + assert is_valid_audio_extension(name) is expected + + def test_clamp_volume_within_range(self): + assert clamp_volume(1.0) == 1.0 + assert clamp_volume(0.0) == 0.0 + assert clamp_volume(2.0) == 2.0 + + def test_clamp_volume_below_min(self): + assert clamp_volume(-0.5) == 0.0 + + def test_clamp_volume_above_max(self): + assert clamp_volume(3.0) == 2.0 + + def test_clamp_volume_custom_range(self): + assert clamp_volume(0.5, 0.2, 0.8) == 0.5 + assert clamp_volume(0.1, 0.2, 0.8) == 0.2 + assert clamp_volume(1.0, 0.2, 0.8) == 0.8 From 3ec74bfc32aa279cd151b702f91f466143e85beb Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 07:18:00 +0800 Subject: [PATCH 20/45] =?UTF-8?q?refactor(wave112):=20=E6=8A=BD=E7=A6=BBin?= =?UTF-8?q?tro=5Foutro=5Fconfig=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9E=8B=20+=20?= =?UTF-8?q?52=E5=8D=95=E6=B5=8B=20(#998)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../video_processing/intro_outro_engine.py | 125 +--- packages/domain/intro_outro_config.py | 219 +++++++ tests/unit/test_intro_outro_config.py | 537 ++++++++++++++++++ 3 files changed, 765 insertions(+), 116 deletions(-) create mode 100755 packages/domain/intro_outro_config.py create mode 100755 tests/unit/test_intro_outro_config.py diff --git a/apps/worker/video_processing/intro_outro_engine.py b/apps/worker/video_processing/intro_outro_engine.py index 067bc093d..20c73c5b2 100755 --- a/apps/worker/video_processing/intro_outro_engine.py +++ b/apps/worker/video_processing/intro_outro_engine.py @@ -11,129 +11,22 @@ from __future__ import annotations import logging import subprocess -from dataclasses import dataclass from pathlib import Path -from typing import Any +from packages.domain.intro_outro_config import ( # noqa: F401 — 向后兼容 + INTRO_OUTRO_TYPE_FOLLOW, + INTRO_OUTRO_TYPE_NONE, + INTRO_OUTRO_TYPE_TEXT, + INTRO_OUTRO_TYPE_VIDEO, + TRANSITION_FADE, + IntroOutroConfig, +) from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg logger = logging.getLogger(__name__) -@dataclass -class IntroOutroConfig: - """片头片尾配置. - - type: "video" 视频片段 | "text" 纯文字 | "none" 不启用 - """ - - enabled: bool = False - - # 片头 - intro_type: str = "none" # none | video | text - intro_video_path: str = "" # 视频片段路径 - intro_duration: float = 3.0 # 片头时长(秒) - - # 文字片头配置 - intro_background: str = "#000000" # 背景色 - intro_title: str = "" - intro_subtitle: str = "" - intro_title_color: str = "white" - intro_title_size: int = 48 - intro_subtitle_color: str = "gray" - intro_subtitle_size: int = 24 - - # 片尾 - outro_type: str = "none" # none | video | text | follow - outro_video_path: str = "" # 视频片段路径 - outro_duration: float = 3.0 # 片尾时长(秒) - - # 文字片尾配置 - outro_background: str = "#000000" - outro_title: str = "感谢观看" - outro_subtitle: str = "点赞关注不迷路" - outro_title_color: str = "white" - outro_title_size: int = 48 - outro_subtitle_color: str = "gray" - outro_subtitle_size: int = 24 - - # 转场 - transition_effect: str = "fade" - transition_duration: float = 0.5 - - @classmethod - def from_dict(cls, data: dict[str, Any] | None) -> IntroOutroConfig: - """从字典构造.""" - if not data: - return cls() - - enabled = data.get("enabled", False) - if not enabled: - return cls() - - intro = data.get("intro", {}) or {} - outro = data.get("outro", {}) or {} - - return cls( - enabled=True, - # 片头 - intro_type=str(intro.get("type", "none")), - intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""), - intro_duration=float(intro.get("duration", 3.0)), - intro_background=str(intro.get("background", "#000000")), - intro_title=str(intro.get("title", "") or ""), - intro_subtitle=str(intro.get("subtitle", "") or ""), - intro_title_color=str(intro.get("title_color", "white")), - intro_title_size=int(intro.get("title_size", 48)), - intro_subtitle_color=str(intro.get("subtitle_color", "gray")), - intro_subtitle_size=int(intro.get("subtitle_size", 24)), - # 片尾 - outro_type=str(outro.get("type", "none")), - outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""), - outro_duration=float(outro.get("duration", 3.0)), - outro_background=str(outro.get("background", "#000000")), - outro_title=str(outro.get("title", "感谢观看") or "感谢观看"), - outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"), - outro_title_color=str(outro.get("title_color", "white")), - outro_title_size=int(outro.get("title_size", 48)), - outro_subtitle_color=str(outro.get("subtitle_color", "gray")), - outro_subtitle_size=int(outro.get("subtitle_size", 24)), - # 转场 - transition_effect=str(data.get("transition", "fade")), - transition_duration=float(data.get("transition_duration", 0.5)), - ) - - @property - def has_intro(self) -> bool: - """是否有片头.""" - return self.enabled and self.intro_type in ("video", "text") - - @property - def has_outro(self) -> bool: - """是否有片尾.""" - return self.enabled and self.outro_type in ("video", "text", "follow") - - def validate(self) -> tuple[bool, str]: - """校验配置.""" - if not self.enabled: - return True, "" - - if self.intro_type == "video" and not self.intro_video_path: - return False, "视频片头缺少 video_path" - if self.intro_type == "text" and not self.intro_title: - return False, "文字片头缺少 title" - - if self.outro_type == "video" and not self.outro_video_path: - return False, "视频片尾缺少 video_path" - if self.outro_type in ("text", "follow") and not self.outro_title: - return False, "文字片尾缺少 title" - - if self.intro_duration <= 0: - return False, "片头时长必须大于 0" - if self.outro_duration <= 0: - return False, "片尾时长必须大于 0" - - return True, "" +# ── 片头片尾引擎 ────────────────────────────────────────────────────────────── class IntroOutroEngine: diff --git a/packages/domain/intro_outro_config.py b/packages/domain/intro_outro_config.py new file mode 100755 index 000000000..be205123f --- /dev/null +++ b/packages/domain/intro_outro_config.py @@ -0,0 +1,219 @@ +"""片头片尾配置领域模型 — 纯逻辑,无外部依赖. + +抽离自 intro_outro_engine.py 的数据类和纯逻辑函数, +方便单测覆盖,同时保持向后兼容。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +# ── 常量 ────────────────────────────────────────────────────────────────────── + +INTRO_OUTRO_TYPE_NONE = "none" +INTRO_OUTRO_TYPE_VIDEO = "video" +INTRO_OUTRO_TYPE_TEXT = "text" +INTRO_OUTRO_TYPE_FOLLOW = "follow" + +TRANSITION_FADE = "fade" +TRANSITION_SLIDE = "slide" +TRANSITION_WIPE = "wipe" + +_VALID_INTRO_TYPES = {INTRO_OUTRO_TYPE_NONE, INTRO_OUTRO_TYPE_VIDEO, INTRO_OUTRO_TYPE_TEXT} +_VALID_OUTRO_TYPES = { + INTRO_OUTRO_TYPE_NONE, + INTRO_OUTRO_TYPE_VIDEO, + INTRO_OUTRO_TYPE_TEXT, + INTRO_OUTRO_TYPE_FOLLOW, +} + + +# ── 数据模型 ────────────────────────────────────────────────────────────────── + + +@dataclass +class IntroOutroConfig: + """片头片尾配置. + + type: "video" 视频片段 | "text" 纯文字 | "none" 不启用 + """ + + enabled: bool = False + + # 片头 + intro_type: str = INTRO_OUTRO_TYPE_NONE # none | video | text + intro_video_path: str = "" # 视频片段路径 + intro_duration: float = 3.0 # 片头时长(秒) + + # 文字片头配置 + intro_background: str = "#000000" # 背景色 + intro_title: str = "" + intro_subtitle: str = "" + intro_title_color: str = "white" + intro_title_size: int = 48 + intro_subtitle_color: str = "gray" + intro_subtitle_size: int = 24 + + # 片尾 + outro_type: str = INTRO_OUTRO_TYPE_NONE # none | video | text | follow + outro_video_path: str = "" # 视频片段路径 + outro_duration: float = 3.0 # 片尾时长(秒) + + # 文字片尾配置 + outro_background: str = "#000000" + outro_title: str = "感谢观看" + outro_subtitle: str = "点赞关注不迷路" + outro_title_color: str = "white" + outro_title_size: int = 48 + outro_subtitle_color: str = "gray" + outro_subtitle_size: int = 24 + + # 转场 + transition_effect: str = TRANSITION_FADE + transition_duration: float = 0.5 + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "IntroOutroConfig": + """从字典构造.""" + if not data: + return cls() + + enabled = data.get("enabled", False) + if not enabled: + return cls() + + intro = data.get("intro", {}) or {} + outro = data.get("outro", {}) or {} + + # 安全解析数值,失败时回退到默认值 + try: + intro_duration = float(intro.get("duration", 3.0)) + except (TypeError, ValueError): + intro_duration = 3.0 + + try: + intro_title_size = int(intro.get("title_size", 48)) + except (TypeError, ValueError): + intro_title_size = 48 + + try: + intro_subtitle_size = int(intro.get("subtitle_size", 24)) + except (TypeError, ValueError): + intro_subtitle_size = 24 + + try: + outro_duration = float(outro.get("duration", 3.0)) + except (TypeError, ValueError): + outro_duration = 3.0 + + try: + outro_title_size = int(outro.get("title_size", 48)) + except (TypeError, ValueError): + outro_title_size = 48 + + try: + outro_subtitle_size = int(outro.get("subtitle_size", 24)) + except (TypeError, ValueError): + outro_subtitle_size = 24 + + try: + transition_duration = float(data.get("transition_duration", 0.5)) + except (TypeError, ValueError): + transition_duration = 0.5 + + return cls( + enabled=True, + # 片头 + intro_type=str(intro.get("type", INTRO_OUTRO_TYPE_NONE)), + intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""), + intro_duration=intro_duration, + intro_background=str(intro.get("background", "#000000")), + intro_title=str(intro.get("title", "") or ""), + intro_subtitle=str(intro.get("subtitle", "") or ""), + intro_title_color=str(intro.get("title_color", "white")), + intro_title_size=intro_title_size, + intro_subtitle_color=str(intro.get("subtitle_color", "gray")), + intro_subtitle_size=intro_subtitle_size, + # 片尾 + outro_type=str(outro.get("type", INTRO_OUTRO_TYPE_NONE)), + outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""), + outro_duration=outro_duration, + outro_background=str(outro.get("background", "#000000")), + outro_title=str(outro.get("title", "感谢观看") or "感谢观看"), + outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"), + outro_title_color=str(outro.get("title_color", "white")), + outro_title_size=outro_title_size, + outro_subtitle_color=str(outro.get("subtitle_color", "gray")), + outro_subtitle_size=outro_subtitle_size, + # 转场 + transition_effect=str(data.get("transition", TRANSITION_FADE)), + transition_duration=transition_duration, + ) + + @property + def has_intro(self) -> bool: + """是否有片头(视频或文字类型).""" + return self.enabled and self.intro_type in ( + INTRO_OUTRO_TYPE_VIDEO, + INTRO_OUTRO_TYPE_TEXT, + ) + + @property + def has_outro(self) -> bool: + """是否有片尾(视频/文字/follow类型).""" + return self.enabled and self.outro_type in ( + INTRO_OUTRO_TYPE_VIDEO, + INTRO_OUTRO_TYPE_TEXT, + INTRO_OUTRO_TYPE_FOLLOW, + ) + + @property + def total_extra_duration(self) -> float: + """片头片尾总共增加的时长(秒).""" + total = 0.0 + if self.has_intro and self.intro_duration > 0: + total += self.intro_duration + if self.has_outro and self.outro_duration > 0: + total += self.outro_duration + return total + + def validate(self) -> tuple[bool, str]: + """校验配置合法性,返回 (是否合法, 错误信息).""" + if not self.enabled: + return True, "" + + if self.intro_type not in _VALID_INTRO_TYPES: + return False, f"无效的片头类型: {self.intro_type}" + + if self.outro_type not in _VALID_OUTRO_TYPES: + return False, f"无效的片尾类型: {self.outro_type}" + + if self.intro_type == INTRO_OUTRO_TYPE_VIDEO and not self.intro_video_path: + return False, "视频片头缺少 video_path" + if self.intro_type == INTRO_OUTRO_TYPE_TEXT and not self.intro_title: + return False, "文字片头缺少 title" + + if self.outro_type == INTRO_OUTRO_TYPE_VIDEO and not self.outro_video_path: + return False, "视频片尾缺少 video_path" + if self.outro_type in (INTRO_OUTRO_TYPE_TEXT, INTRO_OUTRO_TYPE_FOLLOW) and not self.outro_title: + return False, "文字片尾缺少 title" + + if self.intro_duration <= 0: + return False, "片头时长必须大于 0" + if self.outro_duration <= 0: + return False, "片尾时长必须大于 0" + + if self.transition_duration < 0: + return False, "转场时长不能为负数" + + if self.intro_title_size <= 0: + return False, "片头标题字号必须大于 0" + if self.intro_subtitle_size <= 0: + return False, "片头副标题字号必须大于 0" + if self.outro_title_size <= 0: + return False, "片尾标题字号必须大于 0" + if self.outro_subtitle_size <= 0: + return False, "片尾副标题字号必须大于 0" + + return True, "" diff --git a/tests/unit/test_intro_outro_config.py b/tests/unit/test_intro_outro_config.py new file mode 100755 index 000000000..5c4b1f644 --- /dev/null +++ b/tests/unit/test_intro_outro_config.py @@ -0,0 +1,537 @@ +"""intro_outro_config 模块单测 — 纯逻辑,无外部依赖.""" + +from __future__ import annotations + +import pytest + +from packages.domain.intro_outro_config import ( + INTRO_OUTRO_TYPE_FOLLOW, + INTRO_OUTRO_TYPE_NONE, + INTRO_OUTRO_TYPE_TEXT, + INTRO_OUTRO_TYPE_VIDEO, + TRANSITION_FADE, + IntroOutroConfig, +) + +# ── 默认值 ──────────────────────────────────────────────────────────────────── + + +class TestIntroOutroConfigDefaults: + def test_default_disabled(self): + cfg = IntroOutroConfig() + assert cfg.enabled is False + assert cfg.intro_type == INTRO_OUTRO_TYPE_NONE + assert cfg.outro_type == INTRO_OUTRO_TYPE_NONE + assert cfg.transition_effect == TRANSITION_FADE + assert cfg.transition_duration == 0.5 + + def test_default_intro_text(self): + cfg = IntroOutroConfig() + assert cfg.intro_background == "#000000" + assert cfg.intro_title == "" + assert cfg.intro_subtitle == "" + assert cfg.intro_title_color == "white" + assert cfg.intro_title_size == 48 + assert cfg.intro_subtitle_color == "gray" + assert cfg.intro_subtitle_size == 24 + assert cfg.intro_duration == 3.0 + + def test_default_outro_text(self): + cfg = IntroOutroConfig() + assert cfg.outro_background == "#000000" + assert cfg.outro_title == "感谢观看" + assert cfg.outro_subtitle == "点赞关注不迷路" + assert cfg.outro_title_color == "white" + assert cfg.outro_title_size == 48 + assert cfg.outro_subtitle_color == "gray" + assert cfg.outro_subtitle_size == 24 + assert cfg.outro_duration == 3.0 + + +# ── from_dict ──────────────────────────────────────────────────────────────── + + +class TestIntroOutroConfigFromDict: + def test_none_data_disabled(self): + cfg = IntroOutroConfig.from_dict(None) + assert cfg.enabled is False + + def test_empty_dict_disabled(self): + cfg = IntroOutroConfig.from_dict({}) + assert cfg.enabled is False + + def test_enabled_false(self): + cfg = IntroOutroConfig.from_dict({"enabled": False}) + assert cfg.enabled is False + + def test_enabled_but_no_intro_outro(self): + cfg = IntroOutroConfig.from_dict({"enabled": True}) + assert cfg.enabled is True + assert cfg.has_intro is False + assert cfg.has_outro is False + + def test_intro_video(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": { + "type": "video", + "video_path": "/tmp/intro.mp4", + "duration": 2.5, + }, + } + ) + assert cfg.enabled is True + assert cfg.intro_type == "video" + assert cfg.intro_video_path == "/tmp/intro.mp4" + assert cfg.intro_duration == 2.5 + assert cfg.has_intro is True + + def test_intro_text(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": { + "type": "text", + "title": "欢迎来到", + "subtitle": "我的频道", + "background": "#FF0000", + "title_color": "yellow", + "title_size": 64, + "subtitle_color": "white", + "subtitle_size": 32, + }, + } + ) + assert cfg.intro_type == "text" + assert cfg.intro_title == "欢迎来到" + assert cfg.intro_subtitle == "我的频道" + assert cfg.intro_background == "#FF0000" + assert cfg.intro_title_size == 64 + assert cfg.intro_subtitle_size == 32 + assert cfg.has_intro is True + + def test_intro_video_path_alias(self): + # video 和 video_path 都支持 + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": {"type": "video", "video": "/tmp/a.mp4"}, + } + ) + assert cfg.intro_video_path == "/tmp/a.mp4" + + def test_outro_video(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": { + "type": "video", + "video_path": "/tmp/outro.mp4", + "duration": 4.0, + }, + } + ) + assert cfg.outro_type == "video" + assert cfg.outro_video_path == "/tmp/outro.mp4" + assert cfg.outro_duration == 4.0 + assert cfg.has_outro is True + + def test_outro_text(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": { + "type": "text", + "title": "谢谢观看", + "subtitle": "下期再见", + }, + } + ) + assert cfg.outro_type == "text" + assert cfg.outro_title == "谢谢观看" + assert cfg.outro_subtitle == "下期再见" + assert cfg.has_outro is True + + def test_outro_follow(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": {"type": "follow", "title": "关注我"}, + } + ) + assert cfg.outro_type == "follow" + assert cfg.has_outro is True + + def test_outro_default_title_when_empty(self): + # 空字符串标题会回退到默认值 + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": {"type": "text", "title": ""}, + } + ) + assert cfg.outro_title == "感谢观看" + + def test_outro_default_subtitle_when_empty(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": {"type": "text", "subtitle": ""}, + } + ) + assert cfg.outro_subtitle == "点赞关注不迷路" + + def test_transition_config(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "transition": "slide", + "transition_duration": 1.0, + } + ) + assert cfg.transition_effect == "slide" + assert cfg.transition_duration == 1.0 + + def test_invalid_intro_duration_fallback(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": {"type": "text", "title": "hi", "duration": "bad"}, + } + ) + assert cfg.intro_duration == 3.0 + + def test_invalid_outro_duration_fallback(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": {"type": "text", "title": "hi", "duration": "bad"}, + } + ) + assert cfg.outro_duration == 3.0 + + def test_invalid_title_size_fallback(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": {"type": "text", "title": "hi", "title_size": "bad"}, + } + ) + assert cfg.intro_title_size == 48 + + def test_invalid_transition_duration_fallback(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "transition_duration": "bad", + } + ) + assert cfg.transition_duration == 0.5 + + def test_intro_is_none_dict(self): + # intro 可能是 None + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": None, + "outro": None, + } + ) + assert cfg.enabled is True + assert cfg.intro_type == "none" + + def test_full_config(self): + cfg = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": { + "type": "text", + "title": "开场", + "subtitle": "精彩马上开始", + "background": "#123456", + "title_color": "white", + "title_size": 72, + "subtitle_color": "gray", + "subtitle_size": 28, + "duration": 2.0, + }, + "outro": { + "type": "text", + "title": "结束", + "subtitle": "再见", + "background": "#654321", + "duration": 3.5, + }, + "transition": "wipe", + "transition_duration": 0.8, + } + ) + assert cfg.has_intro is True + assert cfg.has_outro is True + assert cfg.intro_title == "开场" + assert cfg.outro_title == "结束" + assert cfg.transition_effect == "wipe" + assert cfg.transition_duration == 0.8 + + +# ── has_intro / has_outro ──────────────────────────────────────────────────── + + +class TestHasIntroHasOutro: + def test_disabled_no_intro_outro(self): + cfg = IntroOutroConfig(enabled=False) + assert cfg.has_intro is False + assert cfg.has_outro is False + + def test_enabled_none_type(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="none", + outro_type="none", + ) + assert cfg.has_intro is False + assert cfg.has_outro is False + + def test_intro_video_type(self): + cfg = IntroOutroConfig(enabled=True, intro_type="video", intro_video_path="a.mp4") + assert cfg.has_intro is True + + def test_intro_text_type(self): + cfg = IntroOutroConfig(enabled=True, intro_type="text", intro_title="Hi") + assert cfg.has_intro is True + + def test_outro_video(self): + cfg = IntroOutroConfig(enabled=True, outro_type="video", outro_video_path="a.mp4") + assert cfg.has_outro is True + + def test_outro_text(self): + cfg = IntroOutroConfig(enabled=True, outro_type="text", outro_title="Bye") + assert cfg.has_outro is True + + def test_outro_follow(self): + cfg = IntroOutroConfig(enabled=True, outro_type="follow", outro_title="Follow") + assert cfg.has_outro is True + + def test_intro_follow_not_valid(self): + # intro 不支持 follow 类型 + cfg = IntroOutroConfig(enabled=True, intro_type="follow") + assert cfg.has_intro is False + + +# ── total_extra_duration ───────────────────────────────────────────────────── + + +class TestTotalExtraDuration: + def test_disabled_zero(self): + cfg = IntroOutroConfig(enabled=False) + assert cfg.total_extra_duration == 0.0 + + def test_both_intro_outro(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="text", + intro_title="Hi", + intro_duration=2.0, + outro_type="text", + outro_title="Bye", + outro_duration=3.0, + ) + assert cfg.total_extra_duration == 5.0 + + def test_only_intro(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="video", + intro_video_path="a.mp4", + intro_duration=2.5, + ) + assert cfg.total_extra_duration == 2.5 + + def test_only_outro(self): + cfg = IntroOutroConfig( + enabled=True, + outro_type="video", + outro_video_path="a.mp4", + outro_duration=4.0, + ) + assert cfg.total_extra_duration == 4.0 + + def test_zero_duration_ignored(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="text", + intro_title="Hi", + intro_duration=0.0, + outro_type="text", + outro_title="Bye", + outro_duration=0.0, + ) + assert cfg.total_extra_duration == 0.0 + + +# ── validate ───────────────────────────────────────────────────────────────── + + +class TestValidate: + def test_disabled_always_valid(self): + cfg = IntroOutroConfig(enabled=False) + ok, err = cfg.validate() + assert ok is True + assert err == "" + + def test_none_type_valid(self): + cfg = IntroOutroConfig(enabled=True, intro_type="none", outro_type="none") + ok, err = cfg.validate() + assert ok is True + + def test_video_intro_without_path_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="video", + intro_video_path="", + outro_type="none", + ) + ok, err = cfg.validate() + assert ok is False + assert "片头" in err and "video_path" in err + + def test_text_intro_without_title_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="text", + intro_title="", + outro_type="none", + ) + ok, err = cfg.validate() + assert ok is False + assert "片头" in err and "title" in err + + def test_video_outro_without_path_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="none", + outro_type="video", + outro_video_path="", + ) + ok, err = cfg.validate() + assert ok is False + assert "片尾" in err and "video_path" in err + + def test_text_outro_without_title_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="none", + outro_type="text", + outro_title="", + ) + ok, err = cfg.validate() + assert ok is False + assert "片尾" in err and "title" in err + + def test_follow_outro_without_title_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="none", + outro_type="follow", + outro_title="", + ) + ok, err = cfg.validate() + assert ok is False + assert "片尾" in err and "title" in err + + def test_zero_intro_duration_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="text", + intro_title="Hi", + intro_duration=0, + outro_type="none", + ) + ok, err = cfg.validate() + assert ok is False + assert "片头时长" in err + + def test_negative_intro_duration_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="text", + intro_title="Hi", + ) + object.__setattr__(cfg, "intro_duration", -1.0) + ok, err = cfg.validate() + assert ok is False + assert "片头时长" in err + + def test_zero_outro_duration_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="none", + outro_type="text", + outro_title="Bye", + outro_duration=0, + ) + ok, err = cfg.validate() + assert ok is False + assert "片尾时长" in err + + def test_negative_transition_duration_invalid(self): + cfg = IntroOutroConfig( + enabled=True, + transition_duration=-0.5, + ) + ok, err = cfg.validate() + assert ok is False + assert "转场" in err + + def test_zero_title_size_invalid(self): + cfg = IntroOutroConfig(enabled=True) + object.__setattr__(cfg, "intro_title_size", 0) + ok, err = cfg.validate() + assert ok is False + assert "片头" in err and "字号" in err + + def test_zero_subtitle_size_invalid(self): + cfg = IntroOutroConfig(enabled=True) + object.__setattr__(cfg, "outro_subtitle_size", 0) + ok, err = cfg.validate() + assert ok is False + assert "片尾" in err and "副标题字号" in err + + def test_valid_video_intro_outro(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="video", + intro_video_path="/tmp/i.mp4", + intro_duration=2.0, + outro_type="video", + outro_video_path="/tmp/o.mp4", + outro_duration=3.0, + ) + ok, err = cfg.validate() + assert ok is True, f"expected valid but got: {err}" + + def test_valid_text_intro_outro(self): + cfg = IntroOutroConfig( + enabled=True, + intro_type="text", + intro_title="Hi", + intro_duration=2.0, + outro_type="text", + outro_title="Bye", + outro_duration=3.0, + ) + ok, err = cfg.validate() + assert ok is True, f"expected valid but got: {err}" + + def test_invalid_intro_type(self): + cfg = IntroOutroConfig(enabled=True, intro_type="invalid") + ok, err = cfg.validate() + assert ok is False + assert "片头类型" in err + + def test_invalid_outro_type(self): + cfg = IntroOutroConfig(enabled=True, outro_type="invalid") + ok, err = cfg.validate() + assert ok is False + assert "片尾类型" in err From ac667e60c984a872d02d4c71e736a7c9603006b8 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 07:18:00 +0800 Subject: [PATCH 21/45] =?UTF-8?q?refactor(wave113):=20=E6=8A=BD=E7=A6=BBtr?= =?UTF-8?q?ansition=5Fconfig=E9=A2=86=E5=9F=9F=E6=A8=A1=E5=9E=8B=20+=2049?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=20(#999)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../video_processing/transition_engine.py | 225 +------------- packages/domain/transition_config.py | 245 ++++++++++++++++ tests/unit/test_transition_config.py | 276 ++++++++++++++++++ 3 files changed, 529 insertions(+), 217 deletions(-) create mode 100755 packages/domain/transition_config.py create mode 100755 tests/unit/test_transition_config.py diff --git a/apps/worker/video_processing/transition_engine.py b/apps/worker/video_processing/transition_engine.py index 03b8409ff..069b7915e 100755 --- a/apps/worker/video_processing/transition_engine.py +++ b/apps/worker/video_processing/transition_engine.py @@ -12,229 +12,20 @@ from __future__ import annotations import logging -import sys -from dataclasses import dataclass - -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from enum import Enum - - class StrEnum(str, Enum): - pass - +from packages.domain.transition_config import ( # noqa: F401 — 向后兼容 + CUT_TRANSITION, + DEFAULT_TRANSITION_DURATION, + MAX_TRANSITION_DURATION, + MIN_TRANSITION_DURATION, + TransitionConfig, + TransitionType, +) from video_processing.ffmpeg_utils import build_xfade_filter_chain logger = logging.getLogger(__name__) -# ── 常量 ────────────────────────────────────────────────────────────────────── - -# 转场时长范围(秒) -MIN_TRANSITION_DURATION = 0.3 -MAX_TRANSITION_DURATION = 2.0 -DEFAULT_TRANSITION_DURATION = 0.5 - -# 硬切(无转场) -CUT_TRANSITION = "cut" - - -# ── 转场类型枚举 ────────────────────────────────────────────────────────────── - - -class TransitionType(StrEnum): - """支持的转场效果类型. - - 每种类型对应 FFmpeg xfade filter 的一个 transition 值。 - 新增转场只需在此添加一项,并在 _FFMPEG_XFADE_MAP 中映射。 - """ - - # 硬切(无转场效果,直接拼接) - CUT = "cut" - - # 淡入淡出(最常用,默认 fallback) - FADE = "fade" - - # 溶解(交叉溶解) - DISSOLVE = "dissolve" - - # 滑入系列 - SLIDE_LEFT = "slideleft" - SLIDE_RIGHT = "slideright" - SLIDE_UP = "slideup" - SLIDE_DOWN = "slidedown" - - # 缩放 - ZOOM = "zoom" - - # 擦除系列 - WIPE_LEFT = "wipeleft" - WIPE_RIGHT = "wiperight" - WIPE_UP = "wipeup" - WIPE_DOWN = "wipedown" - - # 圆形扩散 - CIRCLE_CROP = "circlecrop" - - # 矩形覆盖 - RECT_CROP = "rectcrop" - - @classmethod - def all_supported(cls) -> list[str]: - """返回所有支持的转场类型名称列表.""" - return [t.value for t in cls if t != cls.CUT] - - @classmethod - def is_supported(cls, name: str) -> bool: - """检查转场类型是否支持(不区分大小写和下划线).""" - normalized = _normalize_transition_name(name) - return normalized in _NAME_TO_ENUM_MAP - - -# ── 名称 → 枚举 映射(支持多种别名)────────────────────────────────────────── - - -def _normalize_transition_name(name: str) -> str: - """标准化转场名称:小写 + 去下划线.""" - return name.lower().replace("_", "").replace("-", "") - - -# 构建别名映射 -_NAME_TO_ENUM_MAP: dict[str, TransitionType] = {} -for _t in TransitionType: - _NAME_TO_ENUM_MAP[_normalize_transition_name(_t.value)] = _t - -# 额外的别名 -_ALIASES: dict[str, TransitionType] = { - "dissolve": TransitionType.DISSOLVE, - "crossfade": TransitionType.DISSOLVE, - "crossdissolve": TransitionType.DISSOLVE, - "fadein": TransitionType.FADE, - "fadeout": TransitionType.FADE, - "fadeblack": TransitionType.FADE, - "slide": TransitionType.SLIDE_LEFT, # 默认向左滑 - "wipe": TransitionType.WIPE_LEFT, # 默认向左擦 - "zoomin": TransitionType.ZOOM, - "zoomout": TransitionType.ZOOM, - "circle": TransitionType.CIRCLE_CROP, - "rect": TransitionType.RECT_CROP, -} -for _alias, _type in _ALIASES.items(): - _key = _normalize_transition_name(_alias) - if _key not in _NAME_TO_ENUM_MAP: - _NAME_TO_ENUM_MAP[_key] = _type - - -# ── TransitionType → FFmpeg xfade transition 名称映射 ───────────────────────── - - -_FFMPEG_XFADE_MAP: dict[TransitionType, str] = { - TransitionType.FADE: "fade", - TransitionType.DISSOLVE: "dissolve", - TransitionType.SLIDE_LEFT: "slideleft", - TransitionType.SLIDE_RIGHT: "slideright", - TransitionType.SLIDE_UP: "slideup", - TransitionType.SLIDE_DOWN: "slidedown", - TransitionType.ZOOM: "zoomin", - TransitionType.WIPE_LEFT: "wipeleft", - TransitionType.WIPE_RIGHT: "wiperight", - TransitionType.WIPE_UP: "wipeup", - TransitionType.WIPE_DOWN: "wipedown", - TransitionType.CIRCLE_CROP: "circlecrop", - TransitionType.RECT_CROP: "rectcrop", -} - - -# ── 转场配置 ────────────────────────────────────────────────────────────────── - - -@dataclass(slots=True) -class TransitionConfig: - """转场效果配置. - - Attributes: - effect: 转场效果名称(见 TransitionType) - duration: 转场时长(秒),范围 0.3~2.0,默认 0.5 - """ - - effect: str = CUT_TRANSITION - duration: float = DEFAULT_TRANSITION_DURATION - - @classmethod - def parse(cls, effect: str | None = None, duration: float | None = None) -> "TransitionConfig": - """解析并验证转场配置,自动处理边界和降级. - - Args: - effect: 转场效果名称(None 或空则使用默认 cut) - duration: 转场时长(None 则使用默认值) - - Returns: - 验证后的 TransitionConfig - """ - # 处理 effect - final_effect = CUT_TRANSITION - if effect and effect.strip(): - effect_clean = effect.strip() - if TransitionType.is_supported(effect_clean): - final_effect = _resolve_transition_enum(effect_clean).value - elif effect_clean.lower() == CUT_TRANSITION: - final_effect = CUT_TRANSITION - else: - # 降级:不支持的转场 → 硬切,不阻断渲染 - logger.warning( - "不支持的转场效果 '%s',已降级为硬切(cut)", - effect_clean, - ) - final_effect = CUT_TRANSITION - - # 处理 duration:边界钳制 - final_duration = DEFAULT_TRANSITION_DURATION - if duration is not None: - try: - d = float(duration) - if d < MIN_TRANSITION_DURATION: - logger.warning( - "转场时长 %.3fs 小于最小值 %.1fs,已钳制到最小值", - d, - MIN_TRANSITION_DURATION, - ) - final_duration = MIN_TRANSITION_DURATION - elif d > MAX_TRANSITION_DURATION: - logger.warning( - "转场时长 %.3fs 大于最大值 %.1fs,已钳制到最大值", - d, - MAX_TRANSITION_DURATION, - ) - final_duration = MAX_TRANSITION_DURATION - else: - final_duration = d - except (TypeError, ValueError): - logger.warning("无效的转场时长 '%s',使用默认值 %.1fs", duration, DEFAULT_TRANSITION_DURATION) - final_duration = DEFAULT_TRANSITION_DURATION - - return cls(effect=final_effect, duration=final_duration) - - @property - def is_cut(self) -> bool: - """是否为硬切(无转场效果).""" - return self.effect == CUT_TRANSITION - - @property - def ffmpeg_transition(self) -> str: - """获取对应的 FFmpeg xfade transition 名称.""" - if self.is_cut: - return "" - enum_type = _resolve_transition_enum(self.effect) - return _FFMPEG_XFADE_MAP.get(enum_type, "fade") - - -def _resolve_transition_enum(name: str) -> TransitionType: - """将名称解析为 TransitionType 枚举,必须先通过 is_supported 校验.""" - normalized = _normalize_transition_name(name) - return _NAME_TO_ENUM_MAP.get(normalized, TransitionType.FADE) - - # ── 转场引擎 ────────────────────────────────────────────────────────────────── diff --git a/packages/domain/transition_config.py b/packages/domain/transition_config.py new file mode 100755 index 000000000..3f7ee06dc --- /dev/null +++ b/packages/domain/transition_config.py @@ -0,0 +1,245 @@ +"""转场配置领域模型 — 纯逻辑,无 FFmpeg 依赖. + +抽离自 transition_engine.py 的枚举、数据类和纯逻辑函数, +方便单测覆盖,同时保持向后兼容。 +""" + +from __future__ import annotations + +import logging +import sys +from dataclasses import dataclass + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + pass + + +logger = logging.getLogger(__name__) + + +# ── 常量 ────────────────────────────────────────────────────────────────────── + +# 转场时长范围(秒) +MIN_TRANSITION_DURATION = 0.3 +MAX_TRANSITION_DURATION = 2.0 +DEFAULT_TRANSITION_DURATION = 0.5 + +# 硬切(无转场) +CUT_TRANSITION = "cut" + + +# ── 转场类型枚举 ────────────────────────────────────────────────────────────── + + +class TransitionType(StrEnum): + """支持的转场效果类型. + + 每种类型对应 FFmpeg xfade filter 的一个 transition 值。 + """ + + # 硬切(无转场效果,直接拼接) + CUT = "cut" + + # 淡入淡出(最常用,默认 fallback) + FADE = "fade" + + # 溶解(交叉溶解) + DISSOLVE = "dissolve" + + # 滑入系列 + SLIDE_LEFT = "slideleft" + SLIDE_RIGHT = "slideright" + SLIDE_UP = "slideup" + SLIDE_DOWN = "slidedown" + + # 缩放 + ZOOM = "zoom" + + # 擦除系列 + WIPE_LEFT = "wipeleft" + WIPE_RIGHT = "wiperight" + WIPE_UP = "wipeup" + WIPE_DOWN = "wipedown" + + # 圆形扩散 + CIRCLE_CROP = "circlecrop" + + # 矩形覆盖 + RECT_CROP = "rectcrop" + + @classmethod + def all_supported(cls) -> list[str]: + """返回所有支持的转场类型名称列表(不含 cut).""" + return [t.value for t in cls if t != cls.CUT] + + @classmethod + def is_supported(cls, name: str) -> bool: + """检查转场类型是否支持(不区分大小写和下划线).""" + normalized = _normalize_transition_name(name) + return normalized in _NAME_TO_ENUM_MAP + + +# ── 名称 → 枚举 映射(支持多种别名)────────────────────────────────────────── + + +def _normalize_transition_name(name: str) -> str: + """标准化转场名称:小写 + 去下划线 + 去中划线.""" + return name.lower().replace("_", "").replace("-", "") + + +# 构建别名映射 +_NAME_TO_ENUM_MAP: dict[str, TransitionType] = {} +for _t in TransitionType: + _NAME_TO_ENUM_MAP[_normalize_transition_name(_t.value)] = _t + +# 额外的别名 +_ALIASES: dict[str, TransitionType] = { + "dissolve": TransitionType.DISSOLVE, + "crossfade": TransitionType.DISSOLVE, + "crossdissolve": TransitionType.DISSOLVE, + "fadein": TransitionType.FADE, + "fadeout": TransitionType.FADE, + "fadeblack": TransitionType.FADE, + "slide": TransitionType.SLIDE_LEFT, # 默认向左滑 + "wipe": TransitionType.WIPE_LEFT, # 默认向左擦 + "zoomin": TransitionType.ZOOM, + "zoomout": TransitionType.ZOOM, + "circle": TransitionType.CIRCLE_CROP, + "rect": TransitionType.RECT_CROP, +} +for _alias, _type in _ALIASES.items(): + _key = _normalize_transition_name(_alias) + if _key not in _NAME_TO_ENUM_MAP: + _NAME_TO_ENUM_MAP[_key] = _type + + +# ── TransitionType → FFmpeg xfade transition 名称映射 ───────────────────────── + + +_FFMPEG_XFADE_MAP: dict[TransitionType, str] = { + TransitionType.FADE: "fade", + TransitionType.DISSOLVE: "dissolve", + TransitionType.SLIDE_LEFT: "slideleft", + TransitionType.SLIDE_RIGHT: "slideright", + TransitionType.SLIDE_UP: "slideup", + TransitionType.SLIDE_DOWN: "slidedown", + TransitionType.ZOOM: "zoomin", + TransitionType.WIPE_LEFT: "wipeleft", + TransitionType.WIPE_RIGHT: "wiperight", + TransitionType.WIPE_UP: "wipeup", + TransitionType.WIPE_DOWN: "wipedown", + TransitionType.CIRCLE_CROP: "circlecrop", + TransitionType.RECT_CROP: "rectcrop", +} + + +def _resolve_transition_enum(name: str) -> TransitionType: + """将名称解析为 TransitionType 枚举,找不到则回退到 FADE.""" + normalized = _normalize_transition_name(name) + return _NAME_TO_ENUM_MAP.get(normalized, TransitionType.FADE) + + +# ── 转场配置 ────────────────────────────────────────────────────────────────── + + +@dataclass(slots=True) +class TransitionConfig: + """转场效果配置. + + Attributes: + effect: 转场效果名称(见 TransitionType) + duration: 转场时长(秒),范围 0.3~2.0,默认 0.5 + """ + + effect: str = CUT_TRANSITION + duration: float = DEFAULT_TRANSITION_DURATION + + @classmethod + def parse( + cls, + effect: str | None = None, + duration: float | None = None, + ) -> "TransitionConfig": + """解析并验证转场配置,自动处理边界和降级. + + Args: + effect: 转场效果名称(None 或空则使用默认 cut) + duration: 转场时长(None 则使用默认值) + + Returns: + 验证后的 TransitionConfig + """ + # 处理 effect + final_effect = CUT_TRANSITION + if effect and effect.strip(): + effect_clean = effect.strip() + if TransitionType.is_supported(effect_clean): + final_effect = _resolve_transition_enum(effect_clean).value + elif effect_clean.lower() == CUT_TRANSITION: + final_effect = CUT_TRANSITION + else: + # 降级:不支持的转场 → 硬切,不阻断渲染 + logger.warning( + "不支持的转场效果 '%s',已降级为硬切(cut)", + effect_clean, + ) + final_effect = CUT_TRANSITION + + # 处理 duration:边界钳制 + final_duration = DEFAULT_TRANSITION_DURATION + if duration is not None: + try: + d = float(duration) + if d < MIN_TRANSITION_DURATION: + logger.warning( + "转场时长 %.3fs 小于最小值 %.1fs,已钳制到最小值", + d, + MIN_TRANSITION_DURATION, + ) + final_duration = MIN_TRANSITION_DURATION + elif d > MAX_TRANSITION_DURATION: + logger.warning( + "转场时长 %.3fs 大于最大值 %.1fs,已钳制到最大值", + d, + MAX_TRANSITION_DURATION, + ) + final_duration = MAX_TRANSITION_DURATION + else: + final_duration = d + except (TypeError, ValueError): + logger.warning( + "无效的转场时长 '%s',使用默认值 %.1fs", + duration, + DEFAULT_TRANSITION_DURATION, + ) + final_duration = DEFAULT_TRANSITION_DURATION + + return cls(effect=final_effect, duration=final_duration) + + @property + def is_cut(self) -> bool: + """是否为硬切(无转场效果).""" + return self.effect == CUT_TRANSITION + + @property + def ffmpeg_transition(self) -> str: + """获取对应的 FFmpeg xfade transition 名称.""" + if self.is_cut: + return "" + enum_type = _resolve_transition_enum(self.effect) + return _FFMPEG_XFADE_MAP.get(enum_type, "fade") + + def validate(self) -> tuple[bool, str]: + """校验配置合法性,返回 (是否合法, 错误信息).""" + if self.duration < MIN_TRANSITION_DURATION: + return False, f"duration不能小于{MIN_TRANSITION_DURATION}s" + if self.duration > MAX_TRANSITION_DURATION: + return False, f"duration不能大于{MAX_TRANSITION_DURATION}s" + if not self.is_cut and not TransitionType.is_supported(self.effect): + return False, f"不支持的转场效果: {self.effect}" + return True, "" diff --git a/tests/unit/test_transition_config.py b/tests/unit/test_transition_config.py new file mode 100755 index 000000000..5cb7139fb --- /dev/null +++ b/tests/unit/test_transition_config.py @@ -0,0 +1,276 @@ +"""transition_config 模块单测 — 纯逻辑,无 FFmpeg 依赖.""" + +from __future__ import annotations + +import pytest + +from packages.domain.transition_config import ( + CUT_TRANSITION, + DEFAULT_TRANSITION_DURATION, + MAX_TRANSITION_DURATION, + MIN_TRANSITION_DURATION, + TransitionConfig, + TransitionType, +) + +# ── 常量 ────────────────────────────────────────────────────────────────────── + + +class TestConstants: + def test_duration_bounds(self): + assert MIN_TRANSITION_DURATION == 0.3 + assert MAX_TRANSITION_DURATION == 2.0 + assert DEFAULT_TRANSITION_DURATION == 0.5 + assert MIN_TRANSITION_DURATION < DEFAULT_TRANSITION_DURATION < MAX_TRANSITION_DURATION + + def test_cut_transition(self): + assert CUT_TRANSITION == "cut" + + +# ── TransitionType 枚举 ────────────────────────────────────────────────────── + + +class TestTransitionType: + def test_all_supported_includes_all_except_cut(self): + supported = TransitionType.all_supported() + assert "cut" not in supported + assert "fade" in supported + assert "dissolve" in supported + assert len(supported) >= 10 # 至少有10种转场 + + def test_all_supported_unique(self): + supported = TransitionType.all_supported() + assert len(supported) == len(set(supported)) + + def test_is_supported_exact_match(self): + assert TransitionType.is_supported("fade") is True + assert TransitionType.is_supported("dissolve") is True + assert TransitionType.is_supported("slideleft") is True + + def test_is_supported_case_insensitive(self): + assert TransitionType.is_supported("FADE") is True + assert TransitionType.is_supported("Fade") is True + assert TransitionType.is_supported("SlideLeft") is True + + def test_is_supported_with_underscores(self): + assert TransitionType.is_supported("slide_left") is True + assert TransitionType.is_supported("wipe_right") is True + assert TransitionType.is_supported("circle_crop") is True + + def test_is_supported_with_hyphens(self): + assert TransitionType.is_supported("slide-left") is True + assert TransitionType.is_supported("wipe-down") is True + + def test_is_supported_aliases(self): + assert TransitionType.is_supported("crossfade") is True + assert TransitionType.is_supported("crossdissolve") is True + assert TransitionType.is_supported("fadein") is True + assert TransitionType.is_supported("fadeout") is True + assert TransitionType.is_supported("slide") is True + assert TransitionType.is_supported("wipe") is True + assert TransitionType.is_supported("zoomin") is True + assert TransitionType.is_supported("zoomout") is True + assert TransitionType.is_supported("circle") is True + assert TransitionType.is_supported("rect") is True + + def test_is_supported_unknown(self): + assert TransitionType.is_supported("unknown_effect") is False + assert TransitionType.is_supported("") is False + assert TransitionType.is_supported("12345") is False + + def test_enum_values_match_ffmpeg(self): + # 枚举值应该就是 ffmpeg xfade 的 transition 名 + assert TransitionType.FADE.value == "fade" + assert TransitionType.DISSOLVE.value == "dissolve" + assert TransitionType.SLIDE_LEFT.value == "slideleft" + assert TransitionType.CUT.value == "cut" + + +# ── TransitionConfig 默认值 ────────────────────────────────────────────────── + + +class TestTransitionConfigDefaults: + def test_default_config(self): + cfg = TransitionConfig() + assert cfg.effect == CUT_TRANSITION + assert cfg.duration == DEFAULT_TRANSITION_DURATION + assert cfg.is_cut is True + + def test_is_cut_true(self): + cfg = TransitionConfig(effect="cut") + assert cfg.is_cut is True + + def test_is_cut_false(self): + cfg = TransitionConfig(effect="fade") + assert cfg.is_cut is False + + +# ── TransitionConfig.parse ─────────────────────────────────────────────────── + + +class TestTransitionConfigParse: + def test_none_params_default(self): + cfg = TransitionConfig.parse() + assert cfg.effect == CUT_TRANSITION + assert cfg.duration == DEFAULT_TRANSITION_DURATION + + def test_empty_effect_default(self): + cfg = TransitionConfig.parse(effect="") + assert cfg.effect == CUT_TRANSITION + + def test_whitespace_effect_default(self): + cfg = TransitionConfig.parse(effect=" ") + assert cfg.effect == CUT_TRANSITION + + def test_valid_effect_fade(self): + cfg = TransitionConfig.parse(effect="fade") + assert cfg.effect == "fade" + assert cfg.is_cut is False + + def test_valid_effect_case_insensitive(self): + cfg = TransitionConfig.parse(effect="FADE") + assert cfg.effect == "fade" + + def test_valid_effect_with_underscores(self): + cfg = TransitionConfig.parse(effect="slide_left") + assert cfg.effect == "slideleft" + + def test_alias_effect(self): + cfg = TransitionConfig.parse(effect="crossfade") + assert cfg.effect == "dissolve" # 别名映射到 dissolve + + def test_unknown_effect_falls_back_to_cut(self): + cfg = TransitionConfig.parse(effect="magic_sparkles") + assert cfg.effect == CUT_TRANSITION + assert cfg.is_cut is True + + def test_cut_effect_stays_cut(self): + cfg = TransitionConfig.parse(effect="cut") + assert cfg.effect == CUT_TRANSITION + + def test_cut_effect_case_insensitive(self): + cfg = TransitionConfig.parse(effect="CUT") + assert cfg.effect == CUT_TRANSITION + + def test_duration_default(self): + cfg = TransitionConfig.parse(duration=None) + assert cfg.duration == DEFAULT_TRANSITION_DURATION + + def test_duration_within_range(self): + cfg = TransitionConfig.parse(duration=1.0) + assert cfg.duration == 1.0 + + def test_duration_at_min(self): + cfg = TransitionConfig.parse(duration=MIN_TRANSITION_DURATION) + assert cfg.duration == MIN_TRANSITION_DURATION + + def test_duration_at_max(self): + cfg = TransitionConfig.parse(duration=MAX_TRANSITION_DURATION) + assert cfg.duration == MAX_TRANSITION_DURATION + + def test_duration_below_min_clamped(self): + cfg = TransitionConfig.parse(duration=0.1) + assert cfg.duration == MIN_TRANSITION_DURATION + + def test_duration_above_max_clamped(self): + cfg = TransitionConfig.parse(duration=3.0) + assert cfg.duration == MAX_TRANSITION_DURATION + + def test_duration_zero_clamped(self): + cfg = TransitionConfig.parse(duration=0) + assert cfg.duration == MIN_TRANSITION_DURATION + + def test_duration_negative_clamped(self): + cfg = TransitionConfig.parse(duration=-1.0) + assert cfg.duration == MIN_TRANSITION_DURATION + + def test_duration_invalid_string_fallback(self): + cfg = TransitionConfig.parse(duration="bad") # type: ignore[arg-type] + assert cfg.duration == DEFAULT_TRANSITION_DURATION + + def test_duration_numeric_string(self): + cfg = TransitionConfig.parse(duration="1.5") # type: ignore[arg-type] + assert cfg.duration == 1.5 + + def test_full_parse(self): + cfg = TransitionConfig.parse(effect="wipe_up", duration=1.2) + assert cfg.effect == "wipeup" + assert cfg.duration == 1.2 + assert cfg.is_cut is False + + +# ── TransitionConfig.ffmpeg_transition ─────────────────────────────────────── + + +class TestFfmpegTransition: + def test_cut_returns_empty(self): + cfg = TransitionConfig(effect="cut") + assert cfg.ffmpeg_transition == "" + + def test_fade_matches(self): + cfg = TransitionConfig(effect="fade") + assert cfg.ffmpeg_transition == "fade" + + def test_dissolve_matches(self): + cfg = TransitionConfig(effect="dissolve") + assert cfg.ffmpeg_transition == "dissolve" + + def test_slide_left_matches(self): + cfg = TransitionConfig(effect="slideleft") + assert cfg.ffmpeg_transition == "slideleft" + + def test_wipe_down_matches(self): + cfg = TransitionConfig(effect="wipedown") + assert cfg.ffmpeg_transition == "wipedown" + + def test_zoom_matches_zoomin(self): + cfg = TransitionConfig(effect="zoom") + assert cfg.ffmpeg_transition == "zoomin" + + def test_circle_crop_matches(self): + cfg = TransitionConfig(effect="circlecrop") + assert cfg.ffmpeg_transition == "circlecrop" + + +# ── TransitionConfig.validate ──────────────────────────────────────────────── + + +class TestTransitionConfigValidate: + def test_valid_cut(self): + cfg = TransitionConfig(effect="cut", duration=0.5) + ok, err = cfg.validate() + assert ok is True + assert err == "" + + def test_valid_fade(self): + cfg = TransitionConfig(effect="fade", duration=1.0) + ok, err = cfg.validate() + assert ok is True + + def test_duration_below_min_invalid(self): + cfg = TransitionConfig(effect="fade", duration=0.1) + ok, err = cfg.validate() + assert ok is False + assert "duration" in err + + def test_duration_above_max_invalid(self): + cfg = TransitionConfig(effect="fade", duration=3.0) + ok, err = cfg.validate() + assert ok is False + assert "duration" in err + + def test_unsupported_effect_invalid(self): + cfg = TransitionConfig(effect="unknown", duration=0.5) + ok, err = cfg.validate() + assert ok is False + assert "不支持的转场" in err + + def test_min_duration_boundary_valid(self): + cfg = TransitionConfig(effect="fade", duration=MIN_TRANSITION_DURATION) + ok, _ = cfg.validate() + assert ok is True + + def test_max_duration_boundary_valid(self): + cfg = TransitionConfig(effect="fade", duration=MAX_TRANSITION_DURATION) + ok, _ = cfg.validate() + assert ok is True From 09a19f69c7bf86d68b72492c28bfac3fbc2d5c5b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 07:21:16 +0800 Subject: [PATCH 22/45] refactor(generate): split useStep5Voice into sub-modules (#975) --- .../hooks/step5-voice/useSaveToLibrary.tsx | 113 +++++++ .../hooks/step5-voice/useTtsSynthesis.ts | 96 ++++++ .../hooks/step5-voice/useVoiceAudio.ts | 39 +++ .../hooks/step5-voice/useVoiceRecommend.ts | 68 ++++ .../pages/generate/hooks/useStep5Voice.tsx | 301 +++--------------- 5 files changed, 360 insertions(+), 257 deletions(-) create mode 100644 apps/web/src/pages/generate/hooks/step5-voice/useSaveToLibrary.tsx create mode 100644 apps/web/src/pages/generate/hooks/step5-voice/useTtsSynthesis.ts create mode 100644 apps/web/src/pages/generate/hooks/step5-voice/useVoiceAudio.ts create mode 100644 apps/web/src/pages/generate/hooks/step5-voice/useVoiceRecommend.ts diff --git a/apps/web/src/pages/generate/hooks/step5-voice/useSaveToLibrary.tsx b/apps/web/src/pages/generate/hooks/step5-voice/useSaveToLibrary.tsx new file mode 100644 index 000000000..a7fe87307 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step5-voice/useSaveToLibrary.tsx @@ -0,0 +1,113 @@ +import { useState, useCallback } from "react" +import { useNavigate } from "react-router-dom" +import { message } from "antd" +import { useQuery, useMutation } from "@tanstack/react-query" +import { saveTtsToLibrary } from "@/api/tts" +import { getTags, createTag } from "@/api/tags" + +/** + * 存为素材(配音库)弹窗逻辑 + */ +export function useSaveToLibrary(completedTtsJobId: string | null, resetTtsState: () => void) { + const navigate = useNavigate() + + const [saveModalOpen, setSaveModalOpen] = useState(false) + const [saveName, setSaveName] = useState("") + const [saveTagIds, setSaveTagIds] = useState([]) + const [saveNewTag, setSaveNewTag] = useState("") + + const { data: allTags = [] } = useQuery({ + queryKey: ["generate-save-tags"], + queryFn: getTags, + staleTime: 30_000, + }) + + const handleGoToLibrary = useCallback(() => { + navigate("/app/voice-materials") + }, [navigate]) + + const saveToLibraryMutation = useMutation({ + mutationFn: (params: { name?: string; tag_ids?: string[] }) => + saveTtsToLibrary(completedTtsJobId!, params), + onSuccess: () => { + message.success({ + content: ( + + 已保存到配音库!{" "} + + 去视频库查看 + + + ), + duration: 5, + }) + setSaveModalOpen(false) + setSaveName("") + setSaveTagIds([]) + setSaveNewTag("") + resetTtsState() + }, + onError: (err: Error) => { + message.error(`保存失败:${err.message || "请重试"}`) + }, + }) + + const handleOpenSaveModal = useCallback(() => { + setSaveName("") + setSaveTagIds([]) + setSaveNewTag("") + setSaveModalOpen(true) + }, []) + + const handleConfirmSave = useCallback(() => { + if (!completedTtsJobId) return + saveToLibraryMutation.mutate({ + name: saveName.trim() || undefined, + tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined, + }) + }, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation]) + + const handleAddTagInModal = useCallback( + async (tagName: string) => { + const trimmed = tagName.trim() + if (!trimmed) return + const existing = allTags.find((t) => t.name === trimmed) + if (existing) { + if (!saveTagIds.includes(existing.id)) { + setSaveTagIds((prev) => [...prev, existing.id]) + } + return + } + try { + const created = await createTag(trimmed) + setSaveTagIds((prev) => [...prev, created.id]) + setSaveNewTag("") + } catch { + message.error(`创建标签"${trimmed}"失败`) + } + }, + [allTags, saveTagIds], + ) + + return { + saveModalOpen, + setSaveModalOpen, + saveName, + setSaveName, + saveTagIds, + setSaveTagIds, + saveNewTag, + setSaveNewTag, + allTags, + saveToLibraryMutation, + handleOpenSaveModal, + handleConfirmSave, + handleAddTagInModal, + } +} diff --git a/apps/web/src/pages/generate/hooks/step5-voice/useTtsSynthesis.ts b/apps/web/src/pages/generate/hooks/step5-voice/useTtsSynthesis.ts new file mode 100644 index 000000000..3c6447c9b --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step5-voice/useTtsSynthesis.ts @@ -0,0 +1,96 @@ +import { useState, useCallback, useEffect } from "react" +import { message } from "antd" +import { useMutation } from "@tanstack/react-query" +import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts" + +/** + * TTS 自定义合成 + 轮询状态 + */ +export function useTtsSynthesis(selectedVoice: string) { + const [customVoiceText, setCustomVoiceText] = useState("") + const [customAudioUrl, setCustomAudioUrl] = useState(null) + const [ttsError, setTtsError] = useState(null) + const [ttsJobId, setTtsJobId] = useState(null) + const [completedTtsJobId, setCompletedTtsJobId] = useState(null) + + const synthesizeMutation = useMutation({ + mutationFn: synthesizeSpeech, + onSuccess: (data) => { + setTtsJobId(data.job_id) + message.info("语音合成已提交,等待处理…") + }, + onError: () => { + setTtsError("语音合成请求失败,请重试") + }, + }) + + /* 轮询 TTS 任务状态 */ + useEffect(() => { + if (!ttsJobId) return + let cancelled = false + let timer: ReturnType + + const poll = async () => { + try { + const status = await getTTSJobStatus(ttsJobId) + if (cancelled) return + if (status.status === "completed") { + setCustomAudioUrl(status.output_audio_url) + setCompletedTtsJobId(ttsJobId) + setTtsJobId(null) + setTtsError(null) + message.success("语音合成完成!") + return + } + if (status.status === "failed" || status.status === "cancelled") { + setTtsError(status.error_message || "语音合成失败") + setTtsJobId(null) + return + } + timer = setTimeout(poll, 2000) + } catch { + if (!cancelled) { + setTtsError("查询合成状态失败") + setTtsJobId(null) + } + } + } + + timer = setTimeout(poll, 2000) + return () => { + cancelled = true + clearTimeout(timer) + } + }, [ttsJobId]) + + const handleSynthesizeVoice = useCallback(() => { + if (!customVoiceText.trim()) { + message.warning("请先输入配音文案") + return + } + setTtsError(null) + setCustomAudioUrl(null) + synthesizeMutation.mutate({ + text: customVoiceText.trim(), + voice_id: selectedVoice || undefined, + language: "zh-CN", + }) + }, [customVoiceText, selectedVoice, synthesizeMutation]) + + const resetTtsState = useCallback(() => { + setCompletedTtsJobId(null) + setCustomAudioUrl(null) + }, []) + + return { + customVoiceText, + setCustomVoiceText, + customAudioUrl, + ttsError, + ttsJobId, + completedTtsJobId, + synthesizeMutation, + handleSynthesizeVoice, + resetTtsState, + } +} diff --git a/apps/web/src/pages/generate/hooks/step5-voice/useVoiceAudio.ts b/apps/web/src/pages/generate/hooks/step5-voice/useVoiceAudio.ts new file mode 100644 index 000000000..70691ef6c --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step5-voice/useVoiceAudio.ts @@ -0,0 +1,39 @@ +import { useState, useRef, useCallback } from "react" +import { message } from "antd" + +/** + * 音色试听播放控制 + */ +export function useVoiceAudio() { + const audioRef = useRef(null) + const [playingVoice, setPlayingVoice] = useState(null) + + const toggleVoicePlay = useCallback( + (voiceId: string, previewUrl: string | null) => { + if (playingVoice === voiceId) { + audioRef.current?.pause() + audioRef.current = null + setPlayingVoice(null) + return + } + audioRef.current?.pause() + if (!previewUrl) { + message.warning("该音色暂无试听音频") + return + } + const audio = new Audio(previewUrl) + audioRef.current = audio + audio.play().catch(() => { + message.error("播放失败,请检查网络") + }) + audio.onended = () => { + setPlayingVoice(null) + audioRef.current = null + } + setPlayingVoice(voiceId) + }, + [playingVoice], + ) + + return { playingVoice, toggleVoicePlay } +} diff --git a/apps/web/src/pages/generate/hooks/step5-voice/useVoiceRecommend.ts b/apps/web/src/pages/generate/hooks/step5-voice/useVoiceRecommend.ts new file mode 100644 index 000000000..de78ffc8a --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step5-voice/useVoiceRecommend.ts @@ -0,0 +1,68 @@ +import { useState, useCallback, useMemo } from "react" +import { useQuery } from "@tanstack/react-query" +import { fetchPresetVoices } from "@/api/voices" +import type { PresetVoiceItem } from "@/api/voices" + +/** + * 智能配音推荐 + * 根据标题内容风格模拟推荐音色 + */ +export function useVoiceRecommend(titleText: string) { + const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({ + queryKey: ["preset-voices"], + queryFn: fetchPresetVoices, + }) + + const presetVoices: PresetVoiceItem[] = useMemo( + () => presetVoicesData?.items ?? [], + [presetVoicesData], + ) + + const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false) + const [voiceRecommendations, setVoiceRecommendations] = useState([]) + const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false) + + const handleVoiceRecommend = useCallback(async () => { + if (presetVoices.length === 0) return + setVoiceRecommendLoading(true) + setHasVoiceRecommend(true) + + await new Promise((resolve) => setTimeout(resolve, 1000)) + + const title = titleText.toLowerCase() + let recommended: string[] = [] + + const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id) + const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id) + const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id) + + if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) { + recommended = femaleVoices.slice(0, 3) + } else if (/教程|知识|科普|干货|讲解|分析/.test(title)) { + recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1)) + } else if (/活力|热血|运动|搞笑|有趣/.test(title)) { + recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1)) + } else { + recommended = presetVoices.slice(0, 3).map((v) => v.voice_id) + } + + if (recommended.length < 3) { + const others = presetVoices + .filter((v) => !recommended.includes(v.voice_id)) + .map((v) => v.voice_id) + recommended = recommended.concat(others.slice(0, 3 - recommended.length)) + } + + setVoiceRecommendations(recommended) + setVoiceRecommendLoading(false) + }, [presetVoices, titleText]) + + return { + presetVoices, + presetVoicesLoading, + voiceRecommendLoading, + voiceRecommendations, + hasVoiceRecommend, + handleVoiceRecommend, + } +} diff --git a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx index cf6dea7fe..54a85fd28 100644 --- a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx +++ b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx @@ -2,17 +2,15 @@ * Step 5 配音选择 Hook * 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑 */ -import { useState, useRef, useCallback, useEffect, useMemo } from "react" -import { useNavigate } from "react-router-dom" -import { message } from "antd" -import { useQuery, useMutation } from "@tanstack/react-query" -import type { PresetVoiceItem } from "@/api/voices" -import { fetchPresetVoices } from "@/api/voices" -import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts" -import { getTags, createTag } from "@/api/tags" -import { formatDuration } from "../utils/formatDuration" +import { useCallback } from "react" import type { VoiceClone } from "@/api/voice-clone" +import { message } from "antd" import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants" +import { formatDuration } from "../utils/formatDuration" +import { useVoiceAudio } from "./step5-voice/useVoiceAudio" +import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend" +import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis" +import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary" interface UseStep5VoiceProps { selectedVoice: string @@ -43,91 +41,47 @@ export function useStep5Voice({ onCloneModalOpenChange, titleText, }: UseStep5VoiceProps) { - const navigate = useNavigate() - /* ── 预置音色 API ── */ - const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({ - queryKey: ["preset-voices"], - queryFn: fetchPresetVoices, - }) - const presetVoices: PresetVoiceItem[] = useMemo( - () => presetVoicesData?.items ?? [], - [presetVoicesData], - ) + /* ── 子模块 ── */ + const { playingVoice, toggleVoicePlay } = useVoiceAudio() - /* ── 音频播放 ── */ - const audioRef = useRef(null) - const [playingVoice, setPlayingVoice] = useState(null) + const { + presetVoices, + presetVoicesLoading, + voiceRecommendLoading, + voiceRecommendations, + hasVoiceRecommend, + handleVoiceRecommend, + } = useVoiceRecommend(titleText) - const toggleVoicePlay = useCallback( - (voiceId: string, previewUrl: string | null) => { - if (playingVoice === voiceId) { - audioRef.current?.pause() - audioRef.current = null - setPlayingVoice(null) - return - } - audioRef.current?.pause() - if (!previewUrl) { - message.warning("该音色暂无试听音频") - return - } - const audio = new Audio(previewUrl) - audioRef.current = audio - audio.play().catch(() => { - message.error("播放失败,请检查网络") - }) - audio.onended = () => { - setPlayingVoice(null) - audioRef.current = null - } - setPlayingVoice(voiceId) - }, - [playingVoice], - ) + const { + customVoiceText, + setCustomVoiceText, + customAudioUrl, + ttsError, + ttsJobId, + completedTtsJobId, + synthesizeMutation, + handleSynthesizeVoice, + resetTtsState, + } = useTtsSynthesis(selectedVoice) - /* ── 智能配音推荐 ── */ - const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false) - const [voiceRecommendations, setVoiceRecommendations] = useState([]) - const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false) - - const handleVoiceRecommend = useCallback(async () => { - if (presetVoices.length === 0) return - setVoiceRecommendLoading(true) - setHasVoiceRecommend(true) - - await new Promise((resolve) => setTimeout(resolve, 1000)) - - // 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年 - const title = titleText.toLowerCase() - let recommended: string[] = [] - - const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id) - const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id) - const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id) - - if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) { - recommended = femaleVoices.slice(0, 3) - } else if (/教程|知识|科普|干货|讲解|分析/.test(title)) { - recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1)) - } else if (/活力|热血|运动|搞笑|有趣/.test(title)) { - recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1)) - } else { - // 默认推荐前3个 - recommended = presetVoices.slice(0, 3).map((v) => v.voice_id) - } - - // 不足3个时补足 - if (recommended.length < 3) { - const others = presetVoices - .filter((v) => !recommended.includes(v.voice_id)) - .map((v) => v.voice_id) - recommended = recommended.concat(others.slice(0, 3 - recommended.length)) - } - - setVoiceRecommendations(recommended) - setVoiceRecommendLoading(false) - }, [presetVoices, titleText]) + const { + saveModalOpen, + setSaveModalOpen, + saveName, + setSaveName, + saveTagIds, + setSaveTagIds, + saveNewTag, + setSaveNewTag, + allTags, + saveToLibraryMutation, + handleOpenSaveModal, + handleConfirmSave, + handleAddTagInModal, + } = useSaveToLibrary(completedTtsJobId, resetTtsState) + /* ── 推荐音色选择 ── */ const handleSelectRecommendedVoice = useCallback( (voiceId: string) => { onVoiceModeChange("preset") @@ -136,173 +90,6 @@ export function useStep5Voice({ [onVoiceModeChange, onSelectedVoiceChange], ) - /* ── TTS 自定义合成状态 ── */ - const [customVoiceText, setCustomVoiceText] = useState("") - const [customAudioUrl, setCustomAudioUrl] = useState(null) - const [ttsError, setTtsError] = useState(null) - const [ttsJobId, setTtsJobId] = useState(null) - /** 合成完成后保留的 job ID,用于"存为素材" */ - const [completedTtsJobId, setCompletedTtsJobId] = useState(null) - - /* ── TTS mutation ── */ - const synthesizeMutation = useMutation({ - mutationFn: synthesizeSpeech, - onSuccess: (data) => { - setTtsJobId(data.job_id) - message.info("语音合成已提交,等待处理…") - }, - onError: () => { - setTtsError("语音合成请求失败,请重试") - }, - }) - - /** 轮询 TTS 任务状态 */ - useEffect(() => { - if (!ttsJobId) return - let cancelled = false - let timer: ReturnType - - const poll = async () => { - try { - const status = await getTTSJobStatus(ttsJobId) - if (cancelled) return - if (status.status === "completed") { - setCustomAudioUrl(status.output_audio_url) - setCompletedTtsJobId(ttsJobId) - setTtsJobId(null) - setTtsError(null) - message.success("语音合成完成!") - return - } - if (status.status === "failed" || status.status === "cancelled") { - setTtsError(status.error_message || "语音合成失败") - setTtsJobId(null) - return - } - timer = setTimeout(poll, 2000) - } catch { - if (!cancelled) { - setTtsError("查询合成状态失败") - setTtsJobId(null) - } - } - } - - timer = setTimeout(poll, 2000) - return () => { - cancelled = true - clearTimeout(timer) - } - }, [ttsJobId]) - - /** 触发自定义文本 TTS 合成 */ - const handleSynthesizeVoice = useCallback(() => { - if (!customVoiceText.trim()) { - message.warning("请先输入配音文案") - return - } - setTtsError(null) - setCustomAudioUrl(null) - synthesizeMutation.mutate({ - text: customVoiceText.trim(), - voice_id: selectedVoice || undefined, - language: "zh-CN", - }) - }, [customVoiceText, selectedVoice, synthesizeMutation]) - - /* ── 存为素材弹窗状态 ── */ - const [saveModalOpen, setSaveModalOpen] = useState(false) - const [saveName, setSaveName] = useState("") - const [saveTagIds, setSaveTagIds] = useState([]) - const [saveNewTag, setSaveNewTag] = useState("") - - /* ── 标签列表(用于存为素材弹窗) ── */ - const { data: allTags = [] } = useQuery({ - queryKey: ["generate-save-tags"], - queryFn: getTags, - staleTime: 30_000, - }) - - /* ── 存为素材 mutation ── */ - const saveToLibraryMutation = useMutation({ - mutationFn: (params: { name?: string; tag_ids?: string[] }) => - saveTtsToLibrary(completedTtsJobId!, params), - onSuccess: () => { - message.success({ - content: ( - - 已保存到配音库!{" "} - - 去视频库查看 - - - ), - duration: 5, - }) - setSaveModalOpen(false) - setSaveName("") - setSaveTagIds([]) - setSaveNewTag("") - setCompletedTtsJobId(null) - setCustomAudioUrl(null) - }, - onError: (err: Error) => { - message.error(`保存失败:${err.message || "请重试"}`) - }, - }) - - /** 打开存为素材弹窗 */ - const handleOpenSaveModal = useCallback(() => { - setSaveName("") - setSaveTagIds([]) - setSaveNewTag("") - setSaveModalOpen(true) - }, []) - - /** 确认保存 */ - const handleConfirmSave = useCallback(() => { - if (!completedTtsJobId) return - saveToLibraryMutation.mutate({ - name: saveName.trim() || undefined, - tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined, - }) - }, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation]) - - /** 在弹窗中新增标签(先创建再选中) */ - const handleAddTagInModal = useCallback( - async (tagName: string) => { - const trimmed = tagName.trim() - if (!trimmed) return - /* 已在选中列表则跳过 */ - const existing = allTags.find((t) => t.name === trimmed) - if (existing) { - if (!saveTagIds.includes(existing.id)) { - setSaveTagIds((prev) => [...prev, existing.id]) - } - return - } - try { - const created = await createTag(trimmed) - setSaveTagIds((prev) => [...prev, created.id]) - setSaveNewTag("") - } catch { - message.error(`创建标签"${trimmed}"失败`) - } - }, - [allTags, saveTagIds], - ) - - /** 保存成功后跳转到视频库 */ - const handleGoToLibrary = useCallback(() => { - navigate("/app/voice-materials") - }, [navigate]) - /* ── 克隆成功回调 ── */ const handleCloneSuccess = useCallback( (voice: VoiceClone) => { From 8d7e13ea73bb04cc7d31ee1d7673d582a82bcf29 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 07:21:16 +0800 Subject: [PATCH 23/45] refactor(editing-planner): split EditingDrawers into sub-components (#978) --- .../components/EditingDrawers.tsx | 342 ++++-------------- .../editing-drawers/ClipLevelDrawers.tsx | 74 ++++ .../editing-drawers/GlobalDrawers.tsx | 119 ++++++ .../components/editing-drawers/types.ts | 101 ++++++ 4 files changed, 366 insertions(+), 270 deletions(-) create mode 100644 apps/web/src/pages/editing-planner/components/editing-drawers/ClipLevelDrawers.tsx create mode 100644 apps/web/src/pages/editing-planner/components/editing-drawers/GlobalDrawers.tsx create mode 100644 apps/web/src/pages/editing-planner/components/editing-drawers/types.ts diff --git a/apps/web/src/pages/editing-planner/components/EditingDrawers.tsx b/apps/web/src/pages/editing-planner/components/EditingDrawers.tsx index 8b980d015..87c0a34b0 100644 --- a/apps/web/src/pages/editing-planner/components/EditingDrawers.tsx +++ b/apps/web/src/pages/editing-planner/components/EditingDrawers.tsx @@ -3,189 +3,32 @@ * 纯渲染层,业务逻辑和 state 留在父组件 */ import React from "react" -import type { TemplateCategory } from "@/api/editing-planner" -import type { - ClipData, - TransitionConfig, - SpeedConfig, - TtsConfig, - WatermarkConfig, - IntroOutroConfig, - PipConfig, - FilterConfig, - ChromaKeyConfig, - StickerConfig, -} from "../types" -import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types" -import type { SubtitleStyleConfig } from "../types/subtitle" -import type { BgmMixConfig } from "@/api/bgm" import SaveModal from "./SaveModal" -import BgmSelector from "./BgmSelector" -import SubtitleStylePanel from "./SubtitleStylePanel" -import TransitionSelector from "./TransitionSelector" -import SpeedPanel from "./SpeedPanel" -import TtsPanel from "./TtsPanel" -import WatermarkPanel from "./WatermarkPanel" -import IntroOutroPanel from "./IntroOutroPanel" -import PipConfigPanel from "./PipConfigPanel" -import FilterPanel from "./FilterPanel" -import GreenScreenPanel from "./GreenScreenPanel" -import StickerPanel from "./StickerPanel" +import { ClipLevelDrawers } from "./editing-drawers/ClipLevelDrawers" +import { GlobalDrawers } from "./editing-drawers/GlobalDrawers" +import type { EditingDrawersProps } from "./editing-drawers/types" -interface EditingDrawersProps { - /* 保存弹窗 */ - saveModalOpen: boolean - saveLoading: boolean - isUpdate: boolean - draftName: string - draftCategory: string - draftTags: string - categories: TemplateCategory[] - estimatedDuration: number - onNameChange: (name: string) => void - onCategoryChange: (cat: string) => void - onTagsChange: (tags: string) => void - onSave: () => Promise - onCancelSave: () => void - /* BGM */ - bgmDrawerOpen: boolean - bgmSettings: BgmMixConfig - onCloseBgmDrawer: () => void - onChangeBgmSettings: (config: BgmMixConfig) => void - /* 字幕 */ - subtitleDrawerOpen: boolean - subtitleSettings: SubtitleStyleConfig - onCloseSubtitleDrawer: () => void - onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void - /* 转场 */ - transitionDrawerOpen: boolean - transitionTargetClipId: string | null - onCloseTransitionDrawer: () => void - onTransitionChange: (config: TransitionConfig) => void - /* 调速 */ - speedDrawerOpen: boolean - speedTargetClipId: string | null - onCloseSpeedDrawer: () => void - onSpeedChange: (config: SpeedConfig) => void - onApplySpeedAll: (config: SpeedConfig) => void - /* TTS 配音 */ - ttsDrawerOpen: boolean - ttsTargetClipId: string | null - onCloseTtsDrawer: () => void - onTtsChange: (config: TtsConfig) => void - /* 水印 */ - watermarkDrawerOpen: boolean - watermarkSettings: WatermarkConfig - onCloseWatermarkDrawer: () => void - onWatermarkChange: (config: WatermarkConfig) => void - /* 片头片尾 */ - introOutroDrawerOpen: boolean - introOutroSettings: IntroOutroConfig - onCloseIntroOutroDrawer: () => void - onIntroOutroChange: (config: IntroOutroConfig) => void - /* 混剪 */ - pipDrawerOpen: boolean - pipSettings: PipConfig - onClosePipDrawer: () => void - onPipChange: (config: PipConfig) => void - /* 滤镜调色 */ - filterDrawerOpen: boolean - filterSettings: FilterConfig - onCloseFilterDrawer: () => void - onFilterChange: (config: FilterConfig) => void - /* 绿幕抠像 */ - chromaKeyDrawerOpen: boolean - chromaKeySettings: ChromaKeyConfig - onCloseChromaKeyDrawer: () => void - onChromaKeyChange: (config: ChromaKeyConfig) => void - /* 贴纸 */ - stickerDrawerOpen: boolean - stickerSettings: StickerConfig - onCloseStickerDrawer: () => void - onStickerChange: (config: StickerConfig) => void - /* 共享数据 */ - clips: ClipData[] - totalDuration: number -} - -const EditingDrawers: React.FC = ({ - saveModalOpen, - saveLoading, - isUpdate, - draftName, - draftCategory, - draftTags, - categories, - estimatedDuration, - onNameChange, - onCategoryChange, - onTagsChange, - onSave, - onCancelSave, - bgmDrawerOpen, - bgmSettings, - onCloseBgmDrawer, - onChangeBgmSettings, - subtitleDrawerOpen, - subtitleSettings, - onCloseSubtitleDrawer, - onChangeSubtitleSettings, - transitionDrawerOpen, - transitionTargetClipId, - onCloseTransitionDrawer, - onTransitionChange, - speedDrawerOpen, - speedTargetClipId, - onCloseSpeedDrawer, - onSpeedChange, - onApplySpeedAll, - ttsDrawerOpen, - ttsTargetClipId, - onCloseTtsDrawer, - onTtsChange, - watermarkDrawerOpen, - watermarkSettings, - onCloseWatermarkDrawer, - onWatermarkChange, - introOutroDrawerOpen, - introOutroSettings, - onCloseIntroOutroDrawer, - onIntroOutroChange, - pipDrawerOpen, - pipSettings, - onClosePipDrawer, - onPipChange, - filterDrawerOpen, - filterSettings, - onCloseFilterDrawer, - onFilterChange, - chromaKeyDrawerOpen, - chromaKeySettings, - onCloseChromaKeyDrawer, - onChromaKeyChange, - stickerDrawerOpen, - stickerSettings, - onCloseStickerDrawer, - onStickerChange, - clips, - totalDuration, -}) => { - const transitionConfig = transitionTargetClipId - ? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION) - : DEFAULT_TRANSITION - const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场" - - const speedConfig = speedTargetClipId - ? (clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED) - : DEFAULT_SPEED - - const ttsConfig = ttsTargetClipId - ? (clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG) - : DEFAULT_TTS_CONFIG +const EditingDrawers: React.FC = (props) => { + const { + saveModalOpen, + saveLoading, + isUpdate, + draftName, + draftCategory, + draftTags, + categories, + estimatedDuration, + onNameChange, + onCategoryChange, + onTagsChange, + onSave, + onCancelSave, + clips, + } = props return ( <> - {/* ═══ 保存弹窗 ═══ */} + {/* 保存弹窗 */} = ({ onCancel={onCancelSave} /> - {/* ═══ BGM 选择器 Drawer ═══ */} - - {/* ═══ 字幕样式配置 Drawer ═══ */} - - - {/* ═══ 转场特效选择器 Drawer ═══ */} - - - {/* ═══ 片段调速面板 Drawer ═══ */} - {speedTargetClipId && ( - - )} - - {/* ═══ TTS 配音面板 Drawer ═══ */} - {ttsTargetClipId && ( - - )} - - {/* ═══ 水印配置面板 ═══ */} - - - {/* ═══ 片头片尾配置面板 ═══ */} - - - {/* ═══ 混剪配置面板 ═══ */} - - - {/* ═══ 滤镜调色面板 ═══ */} - - - {/* ═══ 绿幕抠像面板 ═══ */} - - - {/* ═══ 贴纸面板 ═══ */} - ) diff --git a/apps/web/src/pages/editing-planner/components/editing-drawers/ClipLevelDrawers.tsx b/apps/web/src/pages/editing-planner/components/editing-drawers/ClipLevelDrawers.tsx new file mode 100644 index 000000000..5d4997d40 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/editing-drawers/ClipLevelDrawers.tsx @@ -0,0 +1,74 @@ +import React from "react" +import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../../types" +import TransitionSelector from "../TransitionSelector" +import SpeedPanel from "../SpeedPanel" +import TtsPanel from "../TtsPanel" +import type { ClipLevelDrawersProps } from "./types" + +/** + * 片段级抽屉(转场/调速/TTS) + * 这些抽屉针对特定片段,需要 targetClipId 来定位和读取当前配置 + */ +export const ClipLevelDrawers: React.FC = ({ + clips, + transitionDrawerOpen, + transitionTargetClipId, + onCloseTransitionDrawer, + onTransitionChange, + speedDrawerOpen, + speedTargetClipId, + onCloseSpeedDrawer, + onSpeedChange, + onApplySpeedAll, + ttsDrawerOpen, + ttsTargetClipId, + onCloseTtsDrawer, + onTtsChange, +}) => { + const transitionConfig = transitionTargetClipId + ? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION) + : DEFAULT_TRANSITION + const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场" + + const speedConfig = speedTargetClipId + ? (clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED) + : DEFAULT_SPEED + + const ttsConfig = ttsTargetClipId + ? (clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG) + : DEFAULT_TTS_CONFIG + + return ( + <> + {/* 转场特效选择器 */} + + + {/* 片段调速面板 */} + {speedTargetClipId && ( + + )} + + {/* TTS 配音面板 */} + {ttsTargetClipId && ( + + )} + + ) +} diff --git a/apps/web/src/pages/editing-planner/components/editing-drawers/GlobalDrawers.tsx b/apps/web/src/pages/editing-planner/components/editing-drawers/GlobalDrawers.tsx new file mode 100644 index 000000000..76afc7c80 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/editing-drawers/GlobalDrawers.tsx @@ -0,0 +1,119 @@ +import React from "react" +import BgmSelector from "../BgmSelector" +import SubtitleStylePanel from "../SubtitleStylePanel" +import WatermarkPanel from "../WatermarkPanel" +import IntroOutroPanel from "../IntroOutroPanel" +import PipConfigPanel from "../PipConfigPanel" +import FilterPanel from "../FilterPanel" +import GreenScreenPanel from "../GreenScreenPanel" +import StickerPanel from "../StickerPanel" +import type { GlobalDrawersProps, BgmDrawerProps, SubtitleDrawerProps } from "./types" + +/** + * 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸) + */ +export const GlobalDrawers: React.FC = ({ + bgmDrawerOpen, + bgmSettings, + onCloseBgmDrawer, + onChangeBgmSettings, + subtitleDrawerOpen, + subtitleSettings, + onCloseSubtitleDrawer, + onChangeSubtitleSettings, + totalDuration, + watermarkDrawerOpen, + watermarkSettings, + onCloseWatermarkDrawer, + onWatermarkChange, + introOutroDrawerOpen, + introOutroSettings, + onCloseIntroOutroDrawer, + onIntroOutroChange, + pipDrawerOpen, + pipSettings, + onClosePipDrawer, + onPipChange, + filterDrawerOpen, + filterSettings, + onCloseFilterDrawer, + onFilterChange, + chromaKeyDrawerOpen, + chromaKeySettings, + onCloseChromaKeyDrawer, + onChromaKeyChange, + stickerDrawerOpen, + stickerSettings, + onCloseStickerDrawer, + onStickerChange, +}) => { + return ( + <> + {/* BGM 选择器 */} + + + {/* 字幕样式配置 */} + + + {/* 水印配置面板 */} + + + {/* 片头片尾配置面板 */} + + + {/* 混剪配置面板 */} + + + {/* 滤镜调色面板 */} + + + {/* 绿幕抠像面板 */} + + + {/* 贴纸面板 */} + + + ) +} diff --git a/apps/web/src/pages/editing-planner/components/editing-drawers/types.ts b/apps/web/src/pages/editing-planner/components/editing-drawers/types.ts new file mode 100644 index 000000000..ee43e64a8 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/editing-drawers/types.ts @@ -0,0 +1,101 @@ +import type { TemplateCategory } from "@/api/editing-planner" +import type { + ClipData, + TransitionConfig, + SpeedConfig, + TtsConfig, + WatermarkConfig, + IntroOutroConfig, + PipConfig, + FilterConfig, + ChromaKeyConfig, + StickerConfig, +} from "../../types" +import type { SubtitleStyleConfig } from "../../types/subtitle" +import type { BgmMixConfig } from "@/api/bgm" + +/** 保存弹窗 Props */ +export interface SaveModalDrawerProps { + saveModalOpen: boolean + saveLoading: boolean + isUpdate: boolean + draftName: string + draftCategory: string + draftTags: string + categories: TemplateCategory[] + estimatedDuration: number + onNameChange: (name: string) => void + onCategoryChange: (cat: string) => void + onTagsChange: (tags: string) => void + onSave: () => Promise + onCancelSave: () => void +} + +/** BGM 抽屉 Props */ +export interface BgmDrawerProps { + bgmDrawerOpen: boolean + bgmSettings: BgmMixConfig + onCloseBgmDrawer: () => void + onChangeBgmSettings: (config: BgmMixConfig) => void +} + +/** 字幕抽屉 Props */ +export interface SubtitleDrawerProps { + subtitleDrawerOpen: boolean + subtitleSettings: SubtitleStyleConfig + onCloseSubtitleDrawer: () => void + onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void +} + +/** 单个片段级抽屉通用 Props */ +export interface ClipLevelDrawersProps { + clips: ClipData[] + transitionDrawerOpen: boolean + transitionTargetClipId: string | null + onCloseTransitionDrawer: () => void + onTransitionChange: (config: TransitionConfig) => void + speedDrawerOpen: boolean + speedTargetClipId: string | null + onCloseSpeedDrawer: () => void + onSpeedChange: (config: SpeedConfig) => void + onApplySpeedAll: (config: SpeedConfig) => void + ttsDrawerOpen: boolean + ttsTargetClipId: string | null + onCloseTtsDrawer: () => void + onTtsChange: (config: TtsConfig) => void +} + +/** 全局设置抽屉 Props */ +export interface GlobalDrawersProps { + totalDuration: number + watermarkDrawerOpen: boolean + watermarkSettings: WatermarkConfig + onCloseWatermarkDrawer: () => void + onWatermarkChange: (config: WatermarkConfig) => void + introOutroDrawerOpen: boolean + introOutroSettings: IntroOutroConfig + onCloseIntroOutroDrawer: () => void + onIntroOutroChange: (config: IntroOutroConfig) => void + pipDrawerOpen: boolean + pipSettings: PipConfig + onClosePipDrawer: () => void + onPipChange: (config: PipConfig) => void + filterDrawerOpen: boolean + filterSettings: FilterConfig + onCloseFilterDrawer: () => void + onFilterChange: (config: FilterConfig) => void + chromaKeyDrawerOpen: boolean + chromaKeySettings: ChromaKeyConfig + onCloseChromaKeyDrawer: () => void + onChromaKeyChange: (config: ChromaKeyConfig) => void + stickerDrawerOpen: boolean + stickerSettings: StickerConfig + onCloseStickerDrawer: () => void + onStickerChange: (config: StickerConfig) => void +} + +export type EditingDrawersProps = SaveModalDrawerProps & + BgmDrawerProps & + SubtitleDrawerProps & + ClipLevelDrawersProps & + GlobalDrawersProps From dcab4180e5a24909dfa444b84a44bb6f64cd2bd6 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 07:21:16 +0800 Subject: [PATCH 24/45] =?UTF-8?q?refactor(editing-planner):=20=E6=B7=B1?= =?UTF-8?q?=E5=8C=96=E6=8B=86=E5=88=86=EF=BC=8C=E6=8A=BD=E7=A6=BB=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E9=85=8D=E7=BD=AE=E4=B8=8E=E9=85=8D=E9=9F=B3=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=20Hook=20(#990)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/editing-planner/EditingPlanner.tsx | 169 +++++------------- .../hooks/useGlobalSettings.ts | 121 +++++++++++++ .../hooks/useVoiceMaterials.ts | 32 ++++ 3 files changed, 201 insertions(+), 121 deletions(-) create mode 100644 apps/web/src/pages/editing-planner/hooks/useGlobalSettings.ts create mode 100644 apps/web/src/pages/editing-planner/hooks/useVoiceMaterials.ts diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index df4b05940..c8a373275 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -1,15 +1,21 @@ /** * 模板编辑器 — 制作/编辑剪辑模板 * 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px) + * + * 主组件仅保留 Hook 组装与整体布局 + * 全局配置 → hooks/useGlobalSettings + * 配音素材 → hooks/useVoiceMaterials + * 撤销重做 → hooks/useUndoRedo + * 抽屉管理 → hooks/useEditorDrawers + * 播放控制 → hooks/usePlaybackControl + * 片段操作 → hooks/useClipOperations + * 模板管理 → hooks/useTemplateManagement */ import React, { useState } from "react" import { useSearchParams } from "react-router-dom" -import { useQuery } from "@tanstack/react-query" import { MODE_LABELS } from "@/api/editing-planner" import { MODE_LIST } from "./constants" -import type { MediaAsset, TitleConfig } from "@/api/template-editor" -import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets" -import { getOrCreateDefaultProject } from "@/api/projects" +import type { MediaAsset } from "@/api/template-editor" import MediaPanel from "./components/MediaPanel" import PreviewPlayer from "./components/PreviewPlayer" @@ -26,36 +32,12 @@ import { useEditorDrawers } from "./hooks/useEditorDrawers" import { usePlaybackControl } from "./hooks/usePlaybackControl" import { useClipOperations } from "./hooks/useClipOperations" import { useTemplateManagement, FILTER_CATEGORIES } from "./hooks/useTemplateManagement" +import { useGlobalSettings } from "./hooks/useGlobalSettings" +import { useVoiceMaterials } from "./hooks/useVoiceMaterials" -import type { - ClipData, - WatermarkConfig, - IntroOutroConfig, - PipConfig, - FilterConfig, - ChromaKeyConfig, - StickerConfig, - CoverConfig, -} from "./types" -import { - DEFAULT_WATERMARK, - DEFAULT_INTRO_OUTRO, - DEFAULT_PIP_CONFIG, - DEFAULT_FILTER_CONFIG, - DEFAULT_CHROMA_KEY_CONFIG, - DEFAULT_STICKER_CONFIG, - DEFAULT_COVER_CONFIG, -} from "./types" - -import type { SubtitleStyleConfig } from "./types/subtitle" -import { DEFAULT_SUBTITLE_STYLE } from "./types/subtitle" -import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm" +import type { ClipData } from "./types" import "./EditingPlanner.css" -/* ──────────── 常量 ──────────── */ - -/* ──────────── 组件 ──────────── */ - const EditingPlanner: React.FC = () => { const [searchParams] = useSearchParams() const urlTemplateId = searchParams.get("templateId") || "" @@ -72,50 +54,29 @@ const EditingPlanner: React.FC = () => { reset: resetClips, } = useUndoRedo([]) - /* ── 全局配置 state ── */ - const [titleConfig, setTitleConfig] = useState({ - ai_auto_select: false, - content: "", - position: "bottom", - font_preset: "思源黑体", - font_size: 28, - font_color: "#ffffff", - }) - - const [subtitleSettings, setSubtitleSettings] = useState({ - ...DEFAULT_SUBTITLE_STYLE, - }) - - const [bgmSettings, setBgmSettings] = useState({ - ...DEFAULT_BGM_MIX_CONFIG, - }) - - const [watermarkSettings, setWatermarkSettings] = useState({ - ...DEFAULT_WATERMARK, - }) - const [introOutroSettings, setIntroOutroSettings] = useState({ - ...DEFAULT_INTRO_OUTRO, - }) - - const [pipSettings, setPipSettings] = useState({ - ...DEFAULT_PIP_CONFIG, - }) - - const [filterSettings, setFilterSettings] = useState({ - ...DEFAULT_FILTER_CONFIG, - }) - - const [chromaKeySettings, setChromaKeySettings] = useState({ - ...DEFAULT_CHROMA_KEY_CONFIG, - }) - - const [stickerSettings, setStickerSettings] = useState({ - ...DEFAULT_STICKER_CONFIG, - }) - - const [coverConfig, setCoverConfig] = useState({ - ...DEFAULT_COVER_CONFIG, - }) + /* ── 全局配置 ── */ + const { + titleConfig, + setTitleConfig, + subtitleSettings, + setSubtitleSettings, + bgmSettings, + setBgmSettings, + watermarkSettings, + setWatermarkSettings, + introOutroSettings, + setIntroOutroSettings, + pipSettings, + setPipSettings, + filterSettings, + setFilterSettings, + chromaKeySettings, + setChromaKeySettings, + stickerSettings, + setStickerSettings, + coverConfig, + setCoverConfig, + } = useGlobalSettings() /* ── 右侧栏 Tab ── */ const [rightTab, setRightTab] = useState<"properties" | "clips">("properties") @@ -128,18 +89,9 @@ const EditingPlanner: React.FC = () => { setSelectedAssetIds(ids) } - /* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */ - const voiceMaterialsQuery = useQuery({ - queryKey: ["assets", "voice"], - queryFn: async () => { - const project = await getOrCreateDefaultProject() - await ensureDefaultLibrary({ project_id: project.id, kind: "voice" }) - const assets = await getAssetsByKind("voice") - return assets - }, - staleTime: 30_000, - }) - const voiceMaterials: AssetItem[] = voiceMaterialsQuery.data ?? [] + /* ── 配音素材 ── */ + const { voiceMaterials, loading: voiceMaterialsLoading, refetch: refetchVoiceMaterials } = + useVoiceMaterials() /* ── 派生计算 ── */ const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0) @@ -179,31 +131,6 @@ const EditingPlanner: React.FC = () => { coverConfig, }) - /* ── 配置变更 handlers ── */ - const handleWatermarkChange = (config: WatermarkConfig) => { - setWatermarkSettings(config) - } - - const handleIntroOutroChange = (config: IntroOutroConfig) => { - setIntroOutroSettings(config) - } - - const handlePipChange = (config: PipConfig) => { - setPipSettings(config) - } - - const handleFilterChange = (config: FilterConfig) => { - setFilterSettings(config) - } - - const handleChromaKeyChange = (config: ChromaKeyConfig) => { - setChromaKeySettings(config) - } - - const handleStickerChange = (config: StickerConfig) => { - setStickerSettings(config) - } - /* ──────────── 渲染 ──────────── */ return ( @@ -294,15 +221,15 @@ const EditingPlanner: React.FC = () => { totalDuration={totalDuration} currentMode={tpl.currentMode} onSubtitleSettingsChange={(partial) => - setSubtitleSettings((prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig) + setSubtitleSettings((prev) => ({ ...prev, ...partial })) } onBgmSettingsChange={(partial) => setBgmSettings((prev) => ({ ...prev, ...partial }))} onClipUpdate={clipOps.handleClipUpdate} onOpenBgmDrawer={() => drawers.setBgmDrawerOpen(true)} onOpenSubtitleDrawer={() => drawers.setSubtitleDrawerOpen(true)} voiceMaterials={voiceMaterials} - voiceMaterialsLoading={voiceMaterialsQuery.isLoading} - onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()} + voiceMaterialsLoading={voiceMaterialsLoading} + onRefreshVoiceMaterials={refetchVoiceMaterials} onClipVoiceSelect={clipOps.handleClipVoiceSelect} onOpenTransitionDrawer={drawers.openTransitionDrawer} onOpenSpeedDrawer={drawers.openSpeedDrawer} @@ -382,28 +309,28 @@ const EditingPlanner: React.FC = () => { onCloseTtsDrawer={() => drawers.setTtsDrawerOpen(false)} watermarkDrawerOpen={drawers.watermarkDrawerOpen} watermarkSettings={watermarkSettings} - onWatermarkChange={handleWatermarkChange} + onWatermarkChange={setWatermarkSettings} onCloseWatermarkDrawer={() => drawers.setWatermarkDrawerOpen(false)} introOutroDrawerOpen={drawers.introOutroDrawerOpen} introOutroSettings={introOutroSettings} - onIntroOutroChange={handleIntroOutroChange} + onIntroOutroChange={setIntroOutroSettings} onCloseIntroOutroDrawer={() => drawers.setIntroOutroDrawerOpen(false)} pipDrawerOpen={drawers.pipDrawerOpen} pipSettings={pipSettings} totalDuration={totalDuration} - onPipChange={handlePipChange} + onPipChange={setPipSettings} onClosePipDrawer={() => drawers.setPipDrawerOpen(false)} filterDrawerOpen={drawers.filterDrawerOpen} filterSettings={filterSettings} - onFilterChange={handleFilterChange} + onFilterChange={setFilterSettings} onCloseFilterDrawer={() => drawers.setFilterDrawerOpen(false)} chromaKeyDrawerOpen={drawers.chromaKeyDrawerOpen} chromaKeySettings={chromaKeySettings} - onChromaKeyChange={handleChromaKeyChange} + onChromaKeyChange={setChromaKeySettings} onCloseChromaKeyDrawer={() => drawers.setChromaKeyDrawerOpen(false)} stickerDrawerOpen={drawers.stickerDrawerOpen} stickerSettings={stickerSettings} - onStickerChange={handleStickerChange} + onStickerChange={setStickerSettings} onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)} />
diff --git a/apps/web/src/pages/editing-planner/hooks/useGlobalSettings.ts b/apps/web/src/pages/editing-planner/hooks/useGlobalSettings.ts new file mode 100644 index 000000000..27f653743 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useGlobalSettings.ts @@ -0,0 +1,121 @@ +/** + * EditingPlanner 全局配置状态管理 + * 集中管理 9 个全局配置:标题/字幕/BGM/水印/片头片尾/画中画/滤镜/绿幕/贴纸/封面 + */ +import { useState } from "react" +import type { TitleConfig } from "@/api/template-editor" +import type { + WatermarkConfig, + IntroOutroConfig, + PipConfig, + FilterConfig, + ChromaKeyConfig, + StickerConfig, + CoverConfig, +} from "../types" +import { + DEFAULT_WATERMARK, + DEFAULT_INTRO_OUTRO, + DEFAULT_PIP_CONFIG, + DEFAULT_FILTER_CONFIG, + DEFAULT_CHROMA_KEY_CONFIG, + DEFAULT_STICKER_CONFIG, + DEFAULT_COVER_CONFIG, +} from "../types" +import type { SubtitleStyleConfig } from "../types/subtitle" +import { DEFAULT_SUBTITLE_STYLE } from "../types/subtitle" +import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm" + +export interface GlobalSettings { + titleConfig: TitleConfig + setTitleConfig: (config: TitleConfig | ((prev: TitleConfig) => TitleConfig)) => void + subtitleSettings: SubtitleStyleConfig + setSubtitleSettings: ( + settings: SubtitleStyleConfig | ((prev: SubtitleStyleConfig) => SubtitleStyleConfig), + ) => void + bgmSettings: BgmMixConfig + setBgmSettings: (settings: BgmMixConfig | ((prev: BgmMixConfig) => BgmMixConfig)) => void + watermarkSettings: WatermarkConfig + setWatermarkSettings: (config: WatermarkConfig) => void + introOutroSettings: IntroOutroConfig + setIntroOutroSettings: (config: IntroOutroConfig) => void + pipSettings: PipConfig + setPipSettings: (config: PipConfig) => void + filterSettings: FilterConfig + setFilterSettings: (config: FilterConfig) => void + chromaKeySettings: ChromaKeyConfig + setChromaKeySettings: (config: ChromaKeyConfig) => void + stickerSettings: StickerConfig + setStickerSettings: (config: StickerConfig) => void + coverConfig: CoverConfig + setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void +} + +export const useGlobalSettings = (): GlobalSettings => { + const [titleConfig, setTitleConfig] = useState({ + ai_auto_select: false, + content: "", + position: "bottom", + font_preset: "思源黑体", + font_size: 28, + font_color: "#ffffff", + }) + + const [subtitleSettings, setSubtitleSettings] = useState({ + ...DEFAULT_SUBTITLE_STYLE, + }) + + const [bgmSettings, setBgmSettings] = useState({ + ...DEFAULT_BGM_MIX_CONFIG, + }) + + const [watermarkSettings, setWatermarkSettings] = useState({ + ...DEFAULT_WATERMARK, + }) + const [introOutroSettings, setIntroOutroSettings] = useState({ + ...DEFAULT_INTRO_OUTRO, + }) + + const [pipSettings, setPipSettings] = useState({ + ...DEFAULT_PIP_CONFIG, + }) + + const [filterSettings, setFilterSettings] = useState({ + ...DEFAULT_FILTER_CONFIG, + }) + + const [chromaKeySettings, setChromaKeySettings] = useState({ + ...DEFAULT_CHROMA_KEY_CONFIG, + }) + + const [stickerSettings, setStickerSettings] = useState({ + ...DEFAULT_STICKER_CONFIG, + }) + + const [coverConfig, setCoverConfig] = useState({ + ...DEFAULT_COVER_CONFIG, + }) + + return { + titleConfig, + setTitleConfig, + subtitleSettings, + setSubtitleSettings, + bgmSettings, + setBgmSettings, + watermarkSettings, + setWatermarkSettings, + introOutroSettings, + setIntroOutroSettings, + pipSettings, + setPipSettings, + filterSettings, + setFilterSettings, + chromaKeySettings, + setChromaKeySettings, + stickerSettings, + setStickerSettings, + coverConfig, + setCoverConfig, + } +} diff --git a/apps/web/src/pages/editing-planner/hooks/useVoiceMaterials.ts b/apps/web/src/pages/editing-planner/hooks/useVoiceMaterials.ts new file mode 100644 index 000000000..5b7227157 --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useVoiceMaterials.ts @@ -0,0 +1,32 @@ +/** + * EditingPlanner 配音素材数据加载 + * queryKey 与 VoiceMaterialLibrary 共享缓存 + */ +import { useQuery } from "@tanstack/react-query" +import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets" +import { getOrCreateDefaultProject } from "@/api/projects" + +export interface UseVoiceMaterialsReturn { + voiceMaterials: AssetItem[] + loading: boolean + refetch: () => Promise +} + +export const useVoiceMaterials = (): UseVoiceMaterialsReturn => { + const query = useQuery({ + queryKey: ["assets", "voice"], + queryFn: async () => { + const project = await getOrCreateDefaultProject() + await ensureDefaultLibrary({ project_id: project.id, kind: "voice" }) + const assets = await getAssetsByKind("voice") + return assets + }, + staleTime: 30_000, + }) + + return { + voiceMaterials: query.data ?? [], + loading: query.isLoading, + refetch: query.refetch, + } +} From dc555bc8c176b875a2a4ffbf165edc1228449840 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 07:21:16 +0800 Subject: [PATCH 25/45] =?UTF-8?q?refactor(editing-planner):=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=20types.ts=20=E4=B8=BA=E7=9B=AE=E5=BD=95=E7=BB=93?= =?UTF-8?q?=E6=9E=84=EF=BC=8C=E6=8C=89=E5=8A=9F=E8=83=BD=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=88=86=E6=96=87=E4=BB=B6=20(#992)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/editing-planner/types.ts | 550 ------------------ .../pages/editing-planner/types/chroma-key.ts | 50 ++ .../src/pages/editing-planner/types/clip.ts | 36 ++ .../editing-planner/types/clipProperties.ts | 2 +- .../src/pages/editing-planner/types/cover.ts | 32 + .../src/pages/editing-planner/types/filter.ts | 62 ++ .../src/pages/editing-planner/types/index.ts | 99 ++++ .../editing-planner/types/intro-outro.ts | 33 ++ .../src/pages/editing-planner/types/pip.ts | 95 +++ .../src/pages/editing-planner/types/speed.ts | 17 + .../pages/editing-planner/types/sticker.ts | 93 +++ .../src/pages/editing-planner/types/title.ts | 20 + .../pages/editing-planner/types/transition.ts | 35 ++ .../src/pages/editing-planner/types/trim.ts | 13 + .../src/pages/editing-planner/types/tts.ts | 35 ++ .../pages/editing-planner/types/watermark.ts | 45 ++ 16 files changed, 666 insertions(+), 551 deletions(-) delete mode 100644 apps/web/src/pages/editing-planner/types.ts create mode 100644 apps/web/src/pages/editing-planner/types/chroma-key.ts create mode 100644 apps/web/src/pages/editing-planner/types/clip.ts mode change 100755 => 100644 apps/web/src/pages/editing-planner/types/clipProperties.ts create mode 100644 apps/web/src/pages/editing-planner/types/cover.ts create mode 100644 apps/web/src/pages/editing-planner/types/filter.ts create mode 100644 apps/web/src/pages/editing-planner/types/index.ts create mode 100644 apps/web/src/pages/editing-planner/types/intro-outro.ts create mode 100644 apps/web/src/pages/editing-planner/types/pip.ts create mode 100644 apps/web/src/pages/editing-planner/types/speed.ts create mode 100644 apps/web/src/pages/editing-planner/types/sticker.ts create mode 100644 apps/web/src/pages/editing-planner/types/title.ts create mode 100644 apps/web/src/pages/editing-planner/types/transition.ts create mode 100644 apps/web/src/pages/editing-planner/types/trim.ts create mode 100644 apps/web/src/pages/editing-planner/types/tts.ts create mode 100644 apps/web/src/pages/editing-planner/types/watermark.ts diff --git a/apps/web/src/pages/editing-planner/types.ts b/apps/web/src/pages/editing-planner/types.ts deleted file mode 100644 index 4be6060a2..000000000 --- a/apps/web/src/pages/editing-planner/types.ts +++ /dev/null @@ -1,550 +0,0 @@ -/** - * 片段(Clip)统一类型定义 - * 片段 = 时间规划 + 类型标记,不绑定任何素材 - */ - -export type ClipType = "voice" | "pip" - -/* ──────── 转场特效 ──────── */ - -/** 14 种转场类型 */ -export type TransitionType = - | "none" - | "cut" - | "fade" - | "dissolve" - | "zoom" - | "slide_left" - | "slide_right" - | "slide_up" - | "slide_down" - | "wipe_left" - | "wipe_right" - | "wipe_up" - | "wipe_down" - | "circlecrop" - | "rectcrop" - -/** 片段间转场配置 */ -export interface TransitionConfig { - /** 转场类型 */ - type: TransitionType - /** 转场时长(秒),0.3 ~ 2.0 */ - duration: number -} - -/** 默认转场配置 */ -export const DEFAULT_TRANSITION: TransitionConfig = { - type: "none", - duration: 0.5, -} - -/* ──────── 片段调速 ──────── */ - -/** 片段调速配置 */ -export interface SpeedConfig { - /** 播放速度,0.25 ~ 4.0 */ - rate: number - /** 音调修正(变速不变调) */ - pitchCorrection: boolean -} - -/** 默认调速配置 */ -export const DEFAULT_SPEED: SpeedConfig = { - rate: 1.0, - pitchCorrection: true, -} - -/* ──────── TTS 配音 ──────── */ - -/** 配音模式 */ -export type TtsMode = "none" | "upload" | "tts" - -/** TTS 配音配置 */ -export interface TtsConfig { - /** 配音模式 */ - mode: TtsMode - /** TTS 合成文本 */ - text: string - /** 音色 ID */ - voice_id: string - /** 语速 0.5 ~ 2.0 */ - speed: number - /** 语调(半音)-12 ~ +12 */ - pitch: number - /** 音量 0 ~ 100 */ - volume: number - /** 字幕联动 */ - subtitle_sync: boolean -} - -/** 默认 TTS 配置 */ -export const DEFAULT_TTS_CONFIG: TtsConfig = { - mode: "none", - text: "", - voice_id: "", - speed: 1.0, - pitch: 0, - volume: 100, - subtitle_sync: true, -} - -/* ──────── 裁剪配置 ──────── */ - -/** 片段裁剪配置 — 定义素材的入点/出点 */ -export interface TrimConfig { - /** 入点(秒),素材原始时间轴上的起始位置 */ - start_time: number - /** 出点(秒),素材原始时间轴上的结束位置 */ - end_time: number - /** 素材原始总时长(秒),用于"恢复原始长度" */ - original_duration?: number -} - -/* ──────── 水印配置 ──────── */ - -/** 水印类型 */ -export type WatermarkType = "none" | "image" | "text" | "scroll" - -/** 水印位置 */ -export type WatermarkPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center" - -/** 滚动水印方向 */ -export type ScrollDirection = "horizontal" | "vertical" | "diagonal" - -/** 水印配置 */ -export interface WatermarkConfig { - /** 水印类型 */ - type: WatermarkType - /** 图片水印 URL */ - image_url?: string - /** 水印宽度(像素或百分比 0~1) */ - width?: number - /** 水印高度(像素或百分比 0~1) */ - height?: number - /** 水印位置 */ - position: WatermarkPosition - /** 水印不透明度 0~1 */ - opacity: number - /** 文字水印内容 */ - text?: string - /** 文字水印字号 */ - font_size?: number - /** 文字水印颜色 */ - color?: string - /** 滚动水印方向 */ - scroll_direction?: ScrollDirection - /** 滚动水印速度(像素/秒) */ - scroll_speed?: number -} - -/** 默认水印配置 */ -export const DEFAULT_WATERMARK: WatermarkConfig = { - type: "none", - position: "bottom_right", - opacity: 0.7, -} - -/* ──────── 片头片尾配置 ──────── */ - -/** 片头片尾素材类型 */ -export type IntroOutroKind = "none" | "video" | "image" - -/** 片头/片尾单项配置 */ -export interface IntroOutroItem { - /** 素材类型 */ - kind: IntroOutroKind - /** 素材 URL */ - url?: string - /** 显示时长(秒) */ - duration: number - /** 过渡动画 */ - transition?: TransitionType - /** 过渡时长(秒) */ - transition_duration?: number -} - -/** 片头片尾完整配置 */ -export interface IntroOutroConfig { - intro: IntroOutroItem - outro: IntroOutroItem -} - -/** 默认片头片尾配置 */ -export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = { - intro: { kind: "none", duration: 3 }, - outro: { kind: "none", duration: 3 }, -} - -/* ──────── 混剪配置 ──────── */ - -/** 九宫格位置 */ -export type PipGridPosition = - | "top_left" - | "top_center" - | "top_right" - | "center_left" - | "center" - | "center_right" - | "bottom_left" - | "bottom_center" - | "bottom_right" - -/** 入场动画类型 */ -export type PipAnimType = "none" | "fade_in" | "slide_in" - -/** 入场方向 */ -export type PipSlideDirection = "left" | "right" | "up" | "down" - -/** 混剪图层 */ -export interface PipLayer { - id: string - /** 图层名称(用户可编辑) */ - name: string - /** 素材类型 */ - material_type: "image" | "video" - /** 素材 URL */ - material_url: string - /** 素材缩略图 */ - thumbnail_url?: string - /** 九宫格快捷位置 */ - grid_position: PipGridPosition - /** 精确 X 坐标(百分比 0~100) */ - x: number - /** 精确 Y 坐标(百分比 0~100) */ - y: number - /** 宽度(百分比 0~100,相对主画面) */ - width: number - /** 高度(百分比 0~100,相对主画面) */ - height: number - /** 锁定宽高比 */ - aspect_lock: boolean - /** 圆角(百分比 0~50) */ - border_radius: number - /** 不透明度(0~100) */ - opacity: number - /** 开始时间(秒) */ - start_time: number - /** 持续时长(秒) */ - duration: number - /** 入场动画 */ - animation: PipAnimType - /** 入场方向 */ - slide_direction: PipSlideDirection - /** 图层顺序(z-index) */ - z_index: number -} - -/** 混剪配置 */ -export interface PipConfig { - /** 是否启用混剪 */ - enabled: boolean - /** 图层列表 */ - layers: PipLayer[] -} - -/** 默认 PiP 图层 */ -export const DEFAULT_PIP_LAYER: PipLayer = { - id: "", - name: "图层", - material_type: "image", - material_url: "", - grid_position: "top_right", - x: 70, - y: 5, - width: 25, - height: 25, - aspect_lock: true, - border_radius: 0, - opacity: 100, - start_time: 0, - duration: 5, - animation: "none", - slide_direction: "right", - z_index: 1, -} - -/** 默认 PiP 配置 */ -export const DEFAULT_PIP_CONFIG: PipConfig = { - enabled: false, - layers: [], -} - -/* ──────── 滤镜调色 ──────── */ - -/** 预设滤镜 */ -export type FilterPreset = - | "none" - | "original" - | "fresh" - | "warm" - | "cool" - | "vintage" - | "cinema" - | "bw" - | "sunshine" - | "film" - -/** 预设滤镜标签 */ -export const FILTER_PRESET_LABELS: Record = { - none: "无", - original: "原片", - fresh: "清新", - warm: "暖调", - cool: "冷色", - vintage: "复古", - cinema: "电影", - bw: "黑白", - sunshine: "暖阳", - film: "胶片", -} - -/** 滤镜调色配置 */ -export interface FilterConfig { - /** 是否启用滤镜 */ - enabled: boolean - /** 预设滤镜 */ - preset: FilterPreset - /** 亮度(-100 ~ 100) */ - brightness: number - /** 对比度(-100 ~ 100) */ - contrast: number - /** 饱和度(-100 ~ 100) */ - saturation: number - /** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */ - temperature: number - /** 色调(-100 ~ 100,负值偏绿,正值偏品红) */ - tint: number - /** 锐度(0 ~ 100) */ - sharpness: number -} - -/** 默认滤镜调色配置 */ -export const DEFAULT_FILTER_CONFIG: FilterConfig = { - enabled: false, - preset: "none", - brightness: 0, - contrast: 0, - saturation: 0, - temperature: 0, - tint: 0, - sharpness: 0, -} - -/* ──────── 绿幕抠像 ──────── */ - -/** 绿幕抠像颜色预设 */ -export type ChromaKeyColorPreset = "green" | "blue" | "red" | "pure_green" | "soft_green" - -/** 颜色预设标签 */ -export const CHROMA_KEY_PRESET_LABELS: Record = { - green: "绿", - blue: "蓝", - red: "红", - pure_green: "精绿", - soft_green: "柔绿", -} - -/** 颜色预设对应的默认色值 */ -export const CHROMA_KEY_PRESET_COLORS: Record = { - green: "#00FF00", - blue: "#0000FF", - red: "#FF0000", - pure_green: "#00C800", - soft_green: "#40E040", -} - -/** 绿幕抠像配置 */ -export interface ChromaKeyConfig { - /** 是否启用绿幕抠像 */ - enabled: boolean - /** 颜色预设 */ - color_preset: ChromaKeyColorPreset - /** 抠像目标颜色(HEX) */ - color: string - /** 相似度(0 ~ 100,越大容忍的色差范围越广) */ - similarity: number - /** 边缘平滑(0 ~ 100,越大边缘越柔和) */ - blend: number - /** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */ - spill: number -} - -/** 默认绿幕抠像配置 */ -export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = { - enabled: false, - color_preset: "green", - color: "#00FF00", - similarity: 30, - blend: 10, - spill: 20, -} - -/* ──────── 贴纸配置 ──────── */ - -/** 贴纸类型 */ -export type StickerType = "emoji" | "image" | "text" - -/** 文字花字预设 */ -export type TextStickerPreset = - | "normal" // 普通 - | "highlight" // 高亮 - | "bubble" // 气泡 - | "neon" // 霓虹 - | "shadow" // 投影 - | "outline" // 描边 - | "gradient" // 渐变 - | "handwrite" // 手写 - -/** 贴纸项 */ -export interface StickerItem { - id: string - /** 贴纸类型 */ - type: StickerType - /** 内容(emoji 字符 / 图片 URL / 文字内容) */ - content: string - /** X 坐标(百分比 0~100) */ - x: number - /** Y 坐标(百分比 0~100) */ - y: number - /** 宽度(百分比 0~100) */ - width: number - /** 高度(百分比 0~100) */ - height: number - /** 旋转角度(度 -180~180) */ - rotation: number - /** 不透明度(0~100) */ - opacity: number - /** 开始时间(秒) */ - start_time: number - /** 持续时长(秒,0 表示全程显示) */ - duration: number - /** 图层顺序 */ - z_index: number - /** 文字花字预设(仅 type=text 时有效) */ - text_preset: TextStickerPreset - /** 文字颜色(仅 type=text 时有效) */ - text_color: string - /** 文字大小(px,仅 type=text 时有效) */ - font_size: number -} - -/** 贴纸配置 */ -export interface StickerConfig { - enabled: boolean - items: StickerItem[] -} - -/** 默认贴纸项 */ -export const DEFAULT_STICKER_ITEM: StickerItem = { - id: "", - type: "emoji", - content: "😀", - x: 50, - y: 50, - width: 15, - height: 15, - rotation: 0, - opacity: 100, - start_time: 0, - duration: 0, - z_index: 1, - text_preset: "normal", - text_color: "#FFFFFF", - font_size: 24, -} - -/** 默认贴纸配置 */ -export const DEFAULT_STICKER_CONFIG: StickerConfig = { - enabled: false, - items: [], -} - -/** 文字花字预设标签 */ -export const TEXT_STICKER_PRESET_LABELS: Record = { - normal: "普通", - highlight: "高亮", - bubble: "气泡", - neon: "霓虹", - shadow: "投影", - outline: "描边", - gradient: "渐变", - handwrite: "手写", -} - -/* ──────── 封面配置 ──────── */ - -/** 封面来源模式 */ -export type CoverMode = "auto" | "frame" | "upload" - -/** 封面配置 */ -export interface CoverConfig { - /** 是否启用自定义封面 */ - enabled: boolean - /** 封面来源模式 */ - mode: CoverMode - /** 抽帧时间点(秒,mode=frame 时使用) */ - frame_time: number - /** 上传的封面 URL(mode=upload 时使用) */ - upload_url: string - /** AI 智能推荐的抽帧时间(由后端分析得出) */ - ai_suggested_time: number | null - /** 封面缩略图 URL */ - thumbnail_url: string -} - -/** 默认封面配置 */ -export const DEFAULT_COVER_CONFIG: CoverConfig = { - enabled: false, - mode: "auto", - frame_time: 0, - upload_url: "", - ai_suggested_time: null, - thumbnail_url: "", -} - -/* ──────── 片段数据 ──────── */ - -export interface ClipData { - id: string - type: ClipType // 片段类型:voice(口播)或 pip(混剪) - duration: number // 时长(秒) - startOffset: number // 仅 voice 类型:在口播素材中的起始时间(秒) - /** 素材库素材 ID(main/pip 类型片段使用) */ - media_asset_id?: string - // 保留兼容字段(后端序列化需要) - template_segment_id?: string - script_text?: string - order?: number - /** 配音素材 ID(voice 类型片段使用) */ - voice_asset_id?: string - /** 配音素材文件 URL(voice 类型片段使用) */ - voice_file_url?: string - /** 与前一片段之间的转场效果 */ - transition?: TransitionConfig - /** 播放速度配置 */ - speed?: SpeedConfig - /** TTS 配音配置 */ - tts_config?: TtsConfig - /** 裁剪配置 — 定义素材入点/出点 */ - trim_config?: TrimConfig -} - -/* ──────── 标题设置 ──────── */ - -/** - * 标题设置 — 对齐后端 title_config 字段 - * 前端 UI 使用 camelCase,发送到后端时映射为 snake_case - */ -export interface TitleSettings { - aiAutoSelect: boolean - title: string - position: string - font: string - size: number - bold: boolean - italic: boolean - stroke: boolean - shadow: boolean - color: string -} diff --git a/apps/web/src/pages/editing-planner/types/chroma-key.ts b/apps/web/src/pages/editing-planner/types/chroma-key.ts new file mode 100644 index 000000000..980401567 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/chroma-key.ts @@ -0,0 +1,50 @@ +/** + * 绿幕抠像类型 + */ + +/** 绿幕抠像颜色预设 */ +export type ChromaKeyColorPreset = "green" | "blue" | "red" | "pure_green" | "soft_green" + +/** 颜色预设标签 */ +export const CHROMA_KEY_PRESET_LABELS: Record = { + green: "绿", + blue: "蓝", + red: "红", + pure_green: "精绿", + soft_green: "柔绿", +} + +/** 颜色预设对应的默认色值 */ +export const CHROMA_KEY_PRESET_COLORS: Record = { + green: "#00FF00", + blue: "#0000FF", + red: "#FF0000", + pure_green: "#00C800", + soft_green: "#40E040", +} + +/** 绿幕抠像配置 */ +export interface ChromaKeyConfig { + /** 是否启用绿幕抠像 */ + enabled: boolean + /** 颜色预设 */ + color_preset: ChromaKeyColorPreset + /** 抠像目标颜色(HEX) */ + color: string + /** 相似度(0 ~ 100,越大容忍的色差范围越广) */ + similarity: number + /** 边缘平滑(0 ~ 100,越大边缘越柔和) */ + blend: number + /** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */ + spill: number +} + +/** 默认绿幕抠像配置 */ +export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = { + enabled: false, + color_preset: "green", + color: "#00FF00", + similarity: 30, + blend: 10, + spill: 20, +} diff --git a/apps/web/src/pages/editing-planner/types/clip.ts b/apps/web/src/pages/editing-planner/types/clip.ts new file mode 100644 index 000000000..8602b8318 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/clip.ts @@ -0,0 +1,36 @@ +/** + * 片段数据类型 + */ +import type { TransitionConfig } from "./transition" +import type { SpeedConfig } from "./speed" +import type { TtsConfig } from "./tts" +import type { TrimConfig } from "./trim" + +/** 片段类型 */ +export type ClipType = "voice" | "pip" + +/** 片段数据 — 时间规划 + 类型标记,不绑定任何素材 */ +export interface ClipData { + id: string + type: ClipType // 片段类型:voice(口播)或 pip(混剪) + duration: number // 时长(秒) + startOffset: number // 仅 voice 类型:在口播素材中的起始时间(秒) + /** 素材库素材 ID(main/pip 类型片段使用) */ + media_asset_id?: string + // 保留兼容字段(后端序列化需要) + template_segment_id?: string + script_text?: string + order?: number + /** 配音素材 ID(voice 类型片段使用) */ + voice_asset_id?: string + /** 配音素材文件 URL(voice 类型片段使用) */ + voice_file_url?: string + /** 与前一片段之间的转场效果 */ + transition?: TransitionConfig + /** 播放速度配置 */ + speed?: SpeedConfig + /** TTS 配音配置 */ + tts_config?: TtsConfig + /** 裁剪配置 — 定义素材入点/出点 */ + trim_config?: TrimConfig +} diff --git a/apps/web/src/pages/editing-planner/types/clipProperties.ts b/apps/web/src/pages/editing-planner/types/clipProperties.ts old mode 100755 new mode 100644 index 26125a186..3f37015f2 --- a/apps/web/src/pages/editing-planner/types/clipProperties.ts +++ b/apps/web/src/pages/editing-planner/types/clipProperties.ts @@ -1,7 +1,7 @@ /** * ClipPropertiesPanel 相关类型定义 */ -import type { ClipData } from "@/pages/editing-planner/types" +import type { ClipData } from "./clip" import type { TemplateMode } from "@/api/editing-planner" import type { AssetItem } from "@/api/assets" diff --git a/apps/web/src/pages/editing-planner/types/cover.ts b/apps/web/src/pages/editing-planner/types/cover.ts new file mode 100644 index 000000000..c3a02e887 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/cover.ts @@ -0,0 +1,32 @@ +/** + * 封面配置类型 + */ + +/** 封面来源模式 */ +export type CoverMode = "auto" | "frame" | "upload" + +/** 封面配置 */ +export interface CoverConfig { + /** 是否启用自定义封面 */ + enabled: boolean + /** 封面来源模式 */ + mode: CoverMode + /** 抽帧时间点(秒,mode=frame 时使用) */ + frame_time: number + /** 上传的封面 URL(mode=upload 时使用) */ + upload_url: string + /** AI 智能推荐的抽帧时间(由后端分析得出) */ + ai_suggested_time: number | null + /** 封面缩略图 URL */ + thumbnail_url: string +} + +/** 默认封面配置 */ +export const DEFAULT_COVER_CONFIG: CoverConfig = { + enabled: false, + mode: "auto", + frame_time: 0, + upload_url: "", + ai_suggested_time: null, + thumbnail_url: "", +} diff --git a/apps/web/src/pages/editing-planner/types/filter.ts b/apps/web/src/pages/editing-planner/types/filter.ts new file mode 100644 index 000000000..03f0201cc --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/filter.ts @@ -0,0 +1,62 @@ +/** + * 滤镜调色类型 + */ + +/** 预设滤镜 */ +export type FilterPreset = + | "none" + | "original" + | "fresh" + | "warm" + | "cool" + | "vintage" + | "cinema" + | "bw" + | "sunshine" + | "film" + +/** 预设滤镜标签 */ +export const FILTER_PRESET_LABELS: Record = { + none: "无", + original: "原片", + fresh: "清新", + warm: "暖调", + cool: "冷色", + vintage: "复古", + cinema: "电影", + bw: "黑白", + sunshine: "暖阳", + film: "胶片", +} + +/** 滤镜调色配置 */ +export interface FilterConfig { + /** 是否启用滤镜 */ + enabled: boolean + /** 预设滤镜 */ + preset: FilterPreset + /** 亮度(-100 ~ 100) */ + brightness: number + /** 对比度(-100 ~ 100) */ + contrast: number + /** 饱和度(-100 ~ 100) */ + saturation: number + /** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */ + temperature: number + /** 色调(-100 ~ 100,负值偏绿,正值偏品红) */ + tint: number + /** 锐度(0 ~ 100) */ + sharpness: number +} + +/** 默认滤镜调色配置 */ +export const DEFAULT_FILTER_CONFIG: FilterConfig = { + enabled: false, + preset: "none", + brightness: 0, + contrast: 0, + saturation: 0, + temperature: 0, + tint: 0, + sharpness: 0, +} diff --git a/apps/web/src/pages/editing-planner/types/index.ts b/apps/web/src/pages/editing-planner/types/index.ts new file mode 100644 index 000000000..a7caa72ab --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/index.ts @@ -0,0 +1,99 @@ +/** + * EditingPlanner 类型定义入口 + * 按功能模块拆分,统一从这里导出 + */ + +/* 转场 */ +export { + type TransitionType, + type TransitionConfig, + DEFAULT_TRANSITION, +} from "./transition" + +/* 调速 */ +export { type SpeedConfig, DEFAULT_SPEED } from "./speed" + +/* TTS 配音 */ +export { + type TtsMode, + type TtsConfig, + DEFAULT_TTS_CONFIG, +} from "./tts" + +/* 裁剪 */ +export { type TrimConfig } from "./trim" + +/* 水印 */ +export { + type WatermarkType, + type WatermarkPosition, + type ScrollDirection, + type WatermarkConfig, + DEFAULT_WATERMARK, +} from "./watermark" + +/* 片头片尾 */ +export { + type IntroOutroKind, + type IntroOutroItem, + type IntroOutroConfig, + DEFAULT_INTRO_OUTRO, +} from "./intro-outro" + +/* 混剪 PiP */ +export { + type PipGridPosition, + type PipAnimType, + type PipSlideDirection, + type PipLayer, + type PipConfig, + DEFAULT_PIP_LAYER, + DEFAULT_PIP_CONFIG, +} from "./pip" + +/* 滤镜调色 */ +export { + type FilterPreset, + FILTER_PRESET_LABELS, + type FilterConfig, + DEFAULT_FILTER_CONFIG, +} from "./filter" + +/* 绿幕抠像 */ +export { + type ChromaKeyColorPreset, + CHROMA_KEY_PRESET_LABELS, + CHROMA_KEY_PRESET_COLORS, + type ChromaKeyConfig, + DEFAULT_CHROMA_KEY_CONFIG, +} from "./chroma-key" + +/* 贴纸 */ +export { + type StickerType, + type TextStickerPreset, + type StickerItem, + type StickerConfig, + DEFAULT_STICKER_ITEM, + DEFAULT_STICKER_CONFIG, + TEXT_STICKER_PRESET_LABELS, +} from "./sticker" + +/* 封面 */ +export { + type CoverMode, + type CoverConfig, + DEFAULT_COVER_CONFIG, +} from "./cover" + +/* 片段数据 */ +export { type ClipType, type ClipData } from "./clip" + +/* 标题设置 */ +export { type TitleSettings } from "./title" + +/* 字幕样式 */ +export { + type SubtitleStyleConfig, + DEFAULT_SUBTITLE_STYLE, +} from "./subtitle" diff --git a/apps/web/src/pages/editing-planner/types/intro-outro.ts b/apps/web/src/pages/editing-planner/types/intro-outro.ts new file mode 100644 index 000000000..f0c53f934 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/intro-outro.ts @@ -0,0 +1,33 @@ +/** + * 片头片尾配置类型 + */ +import type { TransitionType } from "./transition" + +/** 片头片尾素材类型 */ +export type IntroOutroKind = "none" | "video" | "image" + +/** 片头/片尾单项配置 */ +export interface IntroOutroItem { + /** 素材类型 */ + kind: IntroOutroKind + /** 素材 URL */ + url?: string + /** 显示时长(秒) */ + duration: number + /** 过渡动画 */ + transition?: TransitionType + /** 过渡时长(秒) */ + transition_duration?: number +} + +/** 片头片尾完整配置 */ +export interface IntroOutroConfig { + intro: IntroOutroItem + outro: IntroOutroItem +} + +/** 默认片头片尾配置 */ +export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = { + intro: { kind: "none", duration: 3 }, + outro: { kind: "none", duration: 3 }, +} diff --git a/apps/web/src/pages/editing-planner/types/pip.ts b/apps/web/src/pages/editing-planner/types/pip.ts new file mode 100644 index 000000000..27c012ac9 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/pip.ts @@ -0,0 +1,95 @@ +/** + * 混剪(PiP)配置类型 + */ + +/** 九宫格位置 */ +export type PipGridPosition = + | "top_left" + | "top_center" + | "top_right" + | "center_left" + | "center" + | "center_right" + | "bottom_left" + | "bottom_center" + | "bottom_right" + +/** 入场动画类型 */ +export type PipAnimType = "none" | "fade_in" | "slide_in" + +/** 入场方向 */ +export type PipSlideDirection = "left" | "right" | "up" | "down" + +/** 混剪图层 */ +export interface PipLayer { + id: string + /** 图层名称(用户可编辑) */ + name: string + /** 素材类型 */ + material_type: "image" | "video" + /** 素材 URL */ + material_url: string + /** 素材缩略图 */ + thumbnail_url?: string + /** 九宫格快捷位置 */ + grid_position: PipGridPosition + /** 精确 X 坐标(百分比 0~100) */ + x: number + /** 精确 Y 坐标(百分比 0~100) */ + y: number + /** 宽度(百分比 0~100,相对主画面) */ + width: number + /** 高度(百分比 0~100,相对主画面) */ + height: number + /** 锁定宽高比 */ + aspect_lock: boolean + /** 圆角(百分比 0~50) */ + border_radius: number + /** 不透明度(0~100) */ + opacity: number + /** 开始时间(秒) */ + start_time: number + /** 持续时长(秒) */ + duration: number + /** 入场动画 */ + animation: PipAnimType + /** 入场方向 */ + slide_direction: PipSlideDirection + /** 图层顺序(z-index) */ + z_index: number +} + +/** 混剪配置 */ +export interface PipConfig { + /** 是否启用混剪 */ + enabled: boolean + /** 图层列表 */ + layers: PipLayer[] +} + +/** 默认 PiP 图层 */ +export const DEFAULT_PIP_LAYER: PipLayer = { + id: "", + name: "图层", + material_type: "image", + material_url: "", + grid_position: "top_right", + x: 70, + y: 5, + width: 25, + height: 25, + aspect_lock: true, + border_radius: 0, + opacity: 100, + start_time: 0, + duration: 5, + animation: "none", + slide_direction: "right", + z_index: 1, +} + +/** 默认 PiP 配置 */ +export const DEFAULT_PIP_CONFIG: PipConfig = { + enabled: false, + layers: [], +} diff --git a/apps/web/src/pages/editing-planner/types/speed.ts b/apps/web/src/pages/editing-planner/types/speed.ts new file mode 100644 index 000000000..227b54de1 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/speed.ts @@ -0,0 +1,17 @@ +/** + * 片段调速类型 + */ + +/** 片段调速配置 */ +export interface SpeedConfig { + /** 播放速度,0.25 ~ 4.0 */ + rate: number + /** 音调修正(变速不变调) */ + pitchCorrection: boolean +} + +/** 默认调速配置 */ +export const DEFAULT_SPEED: SpeedConfig = { + rate: 1.0, + pitchCorrection: true, +} diff --git a/apps/web/src/pages/editing-planner/types/sticker.ts b/apps/web/src/pages/editing-planner/types/sticker.ts new file mode 100644 index 000000000..95bed824e --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/sticker.ts @@ -0,0 +1,93 @@ +/** + * 贴纸配置类型 + */ + +/** 贴纸类型 */ +export type StickerType = "emoji" | "image" | "text" + +/** 文字花字预设 */ +export type TextStickerPreset = + | "normal" // 普通 + | "highlight" // 高亮 + | "bubble" // 气泡 + | "neon" // 霓虹 + | "shadow" // 投影 + | "outline" // 描边 + | "gradient" // 渐变 + | "handwrite" // 手写 + +/** 贴纸项 */ +export interface StickerItem { + id: string + /** 贴纸类型 */ + type: StickerType + /** 内容(emoji 字符 / 图片 URL / 文字内容) */ + content: string + /** X 坐标(百分比 0~100) */ + x: number + /** Y 坐标(百分比 0~100) */ + y: number + /** 宽度(百分比 0~100) */ + width: number + /** 高度(百分比 0~100) */ + height: number + /** 旋转角度(度 -180~180) */ + rotation: number + /** 不透明度(0~100) */ + opacity: number + /** 开始时间(秒) */ + start_time: number + /** 持续时长(秒,0 表示全程显示) */ + duration: number + /** 图层顺序 */ + z_index: number + /** 文字花字预设(仅 type=text 时有效) */ + text_preset: TextStickerPreset + /** 文字颜色(仅 type=text 时有效) */ + text_color: string + /** 文字大小(px,仅 type=text 时有效) */ + font_size: number +} + +/** 贴纸配置 */ +export interface StickerConfig { + enabled: boolean + items: StickerItem[] +} + +/** 默认贴纸项 */ +export const DEFAULT_STICKER_ITEM: StickerItem = { + id: "", + type: "emoji", + content: "😀", + x: 50, + y: 50, + width: 15, + height: 15, + rotation: 0, + opacity: 100, + start_time: 0, + duration: 0, + z_index: 1, + text_preset: "normal", + text_color: "#FFFFFF", + font_size: 24, +} + +/** 默认贴纸配置 */ +export const DEFAULT_STICKER_CONFIG: StickerConfig = { + enabled: false, + items: [], +} + +/** 文字花字预设标签 */ +export const TEXT_STICKER_PRESET_LABELS: Record = { + normal: "普通", + highlight: "高亮", + bubble: "气泡", + neon: "霓虹", + shadow: "投影", + outline: "描边", + gradient: "渐变", + handwrite: "手写", +} diff --git a/apps/web/src/pages/editing-planner/types/title.ts b/apps/web/src/pages/editing-planner/types/title.ts new file mode 100644 index 000000000..e6c8df4a3 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/title.ts @@ -0,0 +1,20 @@ +/** + * 标题设置类型 + */ + +/** + * 标题设置 — 对齐后端 title_config 字段 + * 前端 UI 使用 camelCase,发送到后端时映射为 snake_case + */ +export interface TitleSettings { + aiAutoSelect: boolean + title: string + position: string + font: string + size: number + bold: boolean + italic: boolean + stroke: boolean + shadow: boolean + color: string +} diff --git a/apps/web/src/pages/editing-planner/types/transition.ts b/apps/web/src/pages/editing-planner/types/transition.ts new file mode 100644 index 000000000..ad04cb554 --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/transition.ts @@ -0,0 +1,35 @@ +/** + * 转场特效类型 + */ + +/** 14 种转场类型 */ +export type TransitionType = + | "none" + | "cut" + | "fade" + | "dissolve" + | "zoom" + | "slide_left" + | "slide_right" + | "slide_up" + | "slide_down" + | "wipe_left" + | "wipe_right" + | "wipe_up" + | "wipe_down" + | "circlecrop" + | "rectcrop" + +/** 片段间转场配置 */ +export interface TransitionConfig { + /** 转场类型 */ + type: TransitionType + /** 转场时长(秒),0.3 ~ 2.0 */ + duration: number +} + +/** 默认转场配置 */ +export const DEFAULT_TRANSITION: TransitionConfig = { + type: "none", + duration: 0.5, +} diff --git a/apps/web/src/pages/editing-planner/types/trim.ts b/apps/web/src/pages/editing-planner/types/trim.ts new file mode 100644 index 000000000..f9bfa71bf --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/trim.ts @@ -0,0 +1,13 @@ +/** + * 片段裁剪类型 + */ + +/** 片段裁剪配置 — 定义素材的入点/出点 */ +export interface TrimConfig { + /** 入点(秒),素材原始时间轴上的起始位置 */ + start_time: number + /** 出点(秒),素材原始时间轴上的结束位置 */ + end_time: number + /** 素材原始总时长(秒),用于"恢复原始长度" */ + original_duration?: number +} diff --git a/apps/web/src/pages/editing-planner/types/tts.ts b/apps/web/src/pages/editing-planner/types/tts.ts new file mode 100644 index 000000000..32436e42e --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/tts.ts @@ -0,0 +1,35 @@ +/** + * TTS 配音类型 + */ + +/** 配音模式 */ +export type TtsMode = "none" | "upload" | "tts" + +/** TTS 配音配置 */ +export interface TtsConfig { + /** 配音模式 */ + mode: TtsMode + /** TTS 合成文本 */ + text: string + /** 音色 ID */ + voice_id: string + /** 语速 0.5 ~ 2.0 */ + speed: number + /** 语调(半音)-12 ~ +12 */ + pitch: number + /** 音量 0 ~ 100 */ + volume: number + /** 字幕联动 */ + subtitle_sync: boolean +} + +/** 默认 TTS 配置 */ +export const DEFAULT_TTS_CONFIG: TtsConfig = { + mode: "none", + text: "", + voice_id: "", + speed: 1.0, + pitch: 0, + volume: 100, + subtitle_sync: true, +} diff --git a/apps/web/src/pages/editing-planner/types/watermark.ts b/apps/web/src/pages/editing-planner/types/watermark.ts new file mode 100644 index 000000000..f8c3e288b --- /dev/null +++ b/apps/web/src/pages/editing-planner/types/watermark.ts @@ -0,0 +1,45 @@ +/** + * 水印配置类型 + */ + +/** 水印类型 */ +export type WatermarkType = "none" | "image" | "text" | "scroll" + +/** 水印位置 */ +export type WatermarkPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center" + +/** 滚动水印方向 */ +export type ScrollDirection = "horizontal" | "vertical" | "diagonal" + +/** 水印配置 */ +export interface WatermarkConfig { + /** 水印类型 */ + type: WatermarkType + /** 图片水印 URL */ + image_url?: string + /** 水印宽度(像素或百分比 0~1) */ + width?: number + /** 水印高度(像素或百分比 0~1) */ + height?: number + /** 水印位置 */ + position: WatermarkPosition + /** 水印不透明度 0~1 */ + opacity: number + /** 文字水印内容 */ + text?: string + /** 文字水印字号 */ + font_size?: number + /** 文字水印颜色 */ + color?: string + /** 滚动水印方向 */ + scroll_direction?: ScrollDirection + /** 滚动水印速度(像素/秒) */ + scroll_speed?: number +} + +/** 默认水印配置 */ +export const DEFAULT_WATERMARK: WatermarkConfig = { + type: "none", + position: "bottom_right", + opacity: 0.7, +} From ad3dc0610106edbcbf09113740a46b7584aa8b1b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 07:21:16 +0800 Subject: [PATCH 26/45] =?UTF-8?q?refactor(product-library):=20=E6=B7=B1?= =?UTF-8?q?=E5=8C=96=E6=8B=86=E5=88=86=EF=BC=8C=E6=8A=BD=E7=A6=BB=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E6=A0=8F/=E6=89=B9=E9=87=8F=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=A0=8F/=E7=A9=BA=E7=8A=B6=E6=80=81=20(#993)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/src/pages/products/ProductLibrary.tsx | 241 ++++-------------- .../products/components/ProductBatchBar.tsx | 85 ++++++ .../products/components/ProductEmptyState.tsx | 92 +++++++ .../products/components/ProductFilterBar.tsx | 119 +++++++++ 4 files changed, 340 insertions(+), 197 deletions(-) mode change 100755 => 100644 apps/web/src/pages/products/ProductLibrary.tsx create mode 100644 apps/web/src/pages/products/components/ProductBatchBar.tsx create mode 100644 apps/web/src/pages/products/components/ProductEmptyState.tsx create mode 100644 apps/web/src/pages/products/components/ProductFilterBar.tsx diff --git a/apps/web/src/pages/products/ProductLibrary.tsx b/apps/web/src/pages/products/ProductLibrary.tsx old mode 100755 new mode 100644 index af70965eb..d2c4ca906 --- a/apps/web/src/pages/products/ProductLibrary.tsx +++ b/apps/web/src/pages/products/ProductLibrary.tsx @@ -1,38 +1,29 @@ /** * 成片库页面 — V21 设计系统 * 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选 - * 使用 useQuery 对接后端真实 API(api/products.ts) * - * 代码结构(三阶段重构后): - * - types.ts: 类型定义 - * - constants.ts: 常量配置 - * - utils/index.ts: 工具函数 - * - components/ProductCard.tsx: 产品卡片组件 - * - components/VideoPlayer.tsx: 视频播放器组件 - * - hooks/useProductList.ts: 列表查询与筛选 - * - hooks/useProductActions.ts: 单个/批量操作 + * 主组件仅保留 Hook 组装与整体布局 + * 列表查询 → hooks/useProductList + * 操作逻辑 → hooks/useProductActions + * 筛选栏 → components/ProductFilterBar + * 批量操作栏 → components/ProductBatchBar + * 空状态 → components/ProductEmptyState + * 产品卡片 → components/ProductCard + * 视频播放 → components/VideoPlayer */ import React, { useState } from "react" -import { Popconfirm, message } from "antd" -import { - SearchOutlined, - VideoCameraOutlined, - DownloadOutlined, - DeleteOutlined, - CheckOutlined, - CloudUploadOutlined, -} from "@ant-design/icons" -import { Button, Input, Select } from "@/components/ui" +import { VideoCameraOutlined, DownloadOutlined } from "@ant-design/icons" +import { Button } from "@/components/ui" import type { ProductItem } from "./types" import { ProductCard } from "./components/ProductCard" import { VideoPlayer } from "./components/VideoPlayer" +import { ProductFilterBar } from "./components/ProductFilterBar" +import { ProductBatchBar } from "./components/ProductBatchBar" +import { ProductEmptyState } from "./components/ProductEmptyState" import { useProductList } from "./hooks/useProductList" import { useProductActions } from "./hooks/useProductActions" import "./products.css" -/* ============================================================ - * 主组件 - * ============================================================ */ const ProductLibrary: React.FC = () => { const { products, @@ -83,58 +74,20 @@ const ProductLibrary: React.FC = () => { setPlayingProduct, }) + // ── Loading 状态 ── if (isLoading) { - return ( -
-
-
-

加载中...

-
-
- ) + return } // ── Error 状态 ── if (isError) { console.error("[ProductLibrary] 加载失败:", error) const errorMsg = error?.message || "加载失败" - // 404 视为空数据(API 尚未就绪或无数据) const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found") if (is404) { - return ( -
-
-

- 成片库 -

-
-
-
🎬
-

暂无成片数据

-

- 完成视频生成后,成片将自动保存到这里 -

-
-
- ) + return } - return ( -
-
-
-

{errorMsg || "加载失败,请稍后重试"}

- -
-
- ) + return } return ( @@ -153,129 +106,35 @@ const ProductLibrary: React.FC = () => { {/* 批量操作栏 */} {batchMode && ( -
-
-
- {allSelected && } -
- - {allSelected ? "取消全选" : "全选"} - - 已选择 {selectedIds.size} 项 -
-
- - - - - - -
-
+ )} {/* 筛选栏 */} -
-
- } - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear - style={{ width: 220 }} - /> - - - } + value={searchText} + onChange={(e) => onSearchChange(e.target.value)} + allowClear + style={{ width: 220 }} + /> + + +