From 1399912095292689ba3cc2b5e949750054c31007 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Mon, 27 Jul 2026 07:18:00 +0800 Subject: [PATCH 01/28] =?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 02/28] =?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 03/28] =?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 04/28] =?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 05/28] 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 06/28] 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 07/28] =?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 08/28] =?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 09/28] =?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 }} + /> + + +