From 73b924bb387845ccafcc18ff25e9a5adb3fce369 Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Mon, 7 Sep 2026 13:49:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(#1754):=20=E6=B8=B2=E6=9F=93=E6=9C=9F?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E6=89=B0=E5=8A=A8=E2=80=94=E2=80=94=E8=AE=A9?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E8=A7=86=E9=A2=91=E6=9C=AC=E8=BA=AB=E6=9B=B4?= =?UTF-8?q?=E4=B8=8D=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:批量生成 N=3 视频画面高度相似,仅靠素材顺序区分不够。 修复方案: 1. variant_plan_selector.py 新增 generate_visual_perturbation(rng): 为每个变体生成随机视觉扰动参数(hflip/zoom_ratio/speed_factor/ brightness_shift),参数范围经校准确保肉眼可感知但不影响质量。 2. edit_plan_service.py ensure_variant_plans: 每个变体 plan 创建后调用 generate_visual_perturbation 并存入 plan.config.visual_perturbation(变体 0 不做 hflip 保持预览方向)。 3. unified_render_service.py 两条渲染路径均应用扰动: - _build_filter_complex(多 clip 路径) - _render_pass_through(单 clip 直通路径) 扰动滤镜顺序:hflip(scale前) → scale+pad → zoom+crop → eq brightness 4. 17 个新单测覆盖:参数范围/概率分布/滤镜生成/plan config 注入。 --- apps/api/app/services/edit_plan_service.py | 14 + .../unified_render_service.py | 66 ++++- packages/domain/variant_plan_selector.py | 21 ++ tests/unit/test_1754_visual_perturbation.py | 252 ++++++++++++++++++ 4 files changed, 347 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_1754_visual_perturbation.py diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index de2f201d7..b5d055add 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -907,6 +907,20 @@ class EditPlanService: ) plan_ids.append(variant.id) + # 为每个变体生成独立视觉扰动参数(让批量视频画面本身更不同) + from packages.domain.variant_plan_selector import generate_visual_perturbation + + for idx, pid in enumerate(plan_ids): + try: + perturbation = generate_visual_perturbation(rng) + # 变体 0 不做 hflip(保持预览 plan 原始画面方向) + if idx == 0: + perturbation["hflip"] = False + self.update_plan_config(pid, {"visual_perturbation": perturbation}) + logger.info("变体 %d 视觉扰动: plan=%s perturbation=%s", idx, pid, perturbation) + except Exception: + logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid) + # 标记所有变体 plan 的 clips 为 ready(已分配素材+起点,语义上就是 ready) for pid in plan_ids: try: diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 9f20ed041..c40a713b9 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -1308,10 +1308,15 @@ class UnifiedRenderService: if freeze_seconds > 0: filters.append(f"tpad=stop_mode=clone:stop_duration={freeze_seconds:.3f}") - # 调速 — 与 filter_complex 路径一致 + # 视觉扰动(plan 级别,直通模式同样适用) + vp = self._get_visual_perturbation() + + # 调速 — 与 filter_complex 路径一致(叠加视觉扰动 speed_factor) speed = UnifiedRenderService._clip_speed(clip) - if abs(speed - 1.0) >= 1e-6: - filters.append(f"setpts=PTS/{speed:.4f}") + vp_speed = vp.get("speed_factor", 1.0) if vp else 1.0 + effective_speed = speed * vp_speed + if abs(effective_speed - 1.0) >= 1e-6: + filters.append(f"setpts=PTS/{effective_speed:.4f}") # 倒放滤镜 reverse_config = ReverseConfig.from_dict(clip.config.get("reverse")) @@ -1320,6 +1325,10 @@ class UnifiedRenderService: if reverse_filter: filters.append(reverse_filter) + # 视觉扰动:hflip(在 scale 之前) + if vp: + self._apply_visual_perturbation_pre_scale(filters, vp) + # scale + pad(等比缩放+留黑边) if role in ("overlay", "corner_voice"): pip_w = int(self.output_width * _PIP_SCALE) @@ -1334,6 +1343,10 @@ class UnifiedRenderService: filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease") filters.append(f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black") + # 视觉扰动:zoom + brightness(在 scale+pad 之后、调色之前) + if vp: + self._apply_visual_perturbation_post_scale(filters, vp) + # 调色滤镜 color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade")) if color_grade.enabled and color_grade.has_effect(): @@ -1729,6 +1742,8 @@ class UnifiedRenderService: # Step 1: 预处理每个 clip — trim + scale + setpts # 为每个 clip 生成预处理后的标签 [v0], [v1], ... preprocessed_labels: list[str] = [] + # 视觉扰动(plan 级别,所有 clip 共享同一套扰动参数) + vp = self._get_visual_perturbation() for i, clip in enumerate(all_clips): label = f"v{i}" role = _resolve_layer_role(clip.clip_type, clip.config) @@ -1749,10 +1764,12 @@ class UnifiedRenderService: filters.append(f"trim=duration={trim_dur:.3f}") filters.append("setpts=PTS-STARTPTS") - # 调速 — 基于 setpts 改变播放速度 + # 调速 — 基于 setpts 改变播放速度(叠加视觉扰动 speed_factor) speed = UnifiedRenderService._clip_speed(clip) - if abs(speed - 1.0) >= 1e-6: - filters.append(f"setpts=PTS/{speed:.4f}") + vp_speed = vp.get("speed_factor", 1.0) if vp else 1.0 + effective_speed = speed * vp_speed + if abs(effective_speed - 1.0) >= 1e-6: + filters.append(f"setpts=PTS/{effective_speed:.4f}") # 倒放滤镜(在 trim 之后、scale 之前应用) reverse_config = ReverseConfig.from_dict(clip.config.get("reverse")) @@ -1761,6 +1778,10 @@ class UnifiedRenderService: if reverse_filter: filters.append(reverse_filter) + # 视觉扰动:hflip(在 scale 之前,翻转原始画面) + if vp: + self._apply_visual_perturbation_pre_scale(filters, vp) + # scale if role in ("overlay", "corner_voice"): pip_w = int(self.output_width * _PIP_SCALE) @@ -1775,6 +1796,10 @@ class UnifiedRenderService: filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease") filters.append(f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black") + # 视觉扰动:zoom + brightness(在 scale+pad 之后、调色之前) + if vp: + self._apply_visual_perturbation_post_scale(filters, vp) + # 调色滤镜(每个 clip 独立的 color grade 配置) color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade")) if color_grade.enabled and color_grade.has_effect(): @@ -2190,6 +2215,35 @@ class UnifiedRenderService: """ return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0)) + def _get_visual_perturbation(self) -> dict: + # 读取当前 plan 的视觉扰动参数(plan.config.visual_perturbation) + perturbation = (self.plan.config or {}).get("visual_perturbation") or {} + if not perturbation: + return {} + return { + "hflip": bool(perturbation.get("hflip", False)), + "zoom_ratio": max(1.0, min(1.2, float(perturbation.get("zoom_ratio", 1.0) or 1.0))), + "speed_factor": max(0.8, min(1.2, float(perturbation.get("speed_factor", 1.0) or 1.0))), + "brightness_shift": max(-30, min(30, int(perturbation.get("brightness_shift", 0) or 0))), + } + + def _apply_visual_perturbation_pre_scale(self, filters: list[str], perturbation: dict) -> None: + # scale+pad 之前的扰动(hflip),就地修改 filters + if perturbation.get("hflip"): + filters.append("hflip") + + def _apply_visual_perturbation_post_scale(self, filters: list[str], perturbation: dict) -> None: + # scale+pad 之后的扰动(zoom/brightness),就地修改 filters + zoom = perturbation.get("zoom_ratio", 1.0) + if zoom > 1.0 + 1e-6: + zoomed_w = int(self.output_width * zoom) + zoomed_h = int(self.output_height * zoom) + filters.append(f"scale={zoomed_w}:{zoomed_h}") + filters.append(f"crop={self.output_width}:{self.output_height}") + brightness = perturbation.get("brightness_shift", 0) + if brightness != 0: + filters.append(f"eq=brightness={brightness / 100.0:.3f}") + @staticmethod def _clip_volume(clip: ResolvedClip) -> float: """获取 clip 的音量(config.volume)。缺省 1.0 原声,0.0 静音。""" diff --git a/packages/domain/variant_plan_selector.py b/packages/domain/variant_plan_selector.py index 12bc641cf..b497c95dd 100644 --- a/packages/domain/variant_plan_selector.py +++ b/packages/domain/variant_plan_selector.py @@ -300,6 +300,27 @@ def _pick_asset_and_start( return asset_id, 0.0, eff_dur +def generate_visual_perturbation(rng: random.Random | None = None) -> dict: + """为一个变体生成随机视觉扰动参数(让批量视频画面本身更不同)。 + + 返回 dict,可直接存入 plan.config["visual_perturbation"]。 + 渲染侧读取后应用到 ffmpeg filter chain。 + + 参数范围经过校准: + - hflip: 30% 概率水平翻转(画面左右镜像,肉眼立即可见) + - zoom_ratio: 1.0~1.08 随机缩放(最多放大 8%,裁剪后画面略有差异) + - speed_factor: 0.95~1.05 速度微调(±5%,肉眼不太敏感但时间轴不同) + - brightness_shift: -10~+10 亮度偏移(eq=brightness,画面明暗差异) + """ + rng = rng or random.Random() + return { + "hflip": rng.random() < 0.3, + "zoom_ratio": round(1.0 + rng.uniform(0, 0.08), 4), + "speed_factor": round(1.0 + rng.uniform(-0.05, 0.05), 4), + "brightness_shift": rng.choice([-10, -5, 0, 0, 0, 5, 10]), + } + + def _base_clip_data(src: dict, *, asset_id: str, start: float, duration: float | None = None) -> dict: """从源片段构造落库 dict(保留骨架/转场/文案/速度,替换素材与起点)。""" return { diff --git a/tests/unit/test_1754_visual_perturbation.py b/tests/unit/test_1754_visual_perturbation.py new file mode 100644 index 000000000..aacd56e0c --- /dev/null +++ b/tests/unit/test_1754_visual_perturbation.py @@ -0,0 +1,252 @@ +"""#1754 渲染期画面扰动——让批量视频本身更不同。 + +测试覆盖: +1. generate_visual_perturbation 生成合法参数 +2. _build_filter_complex 中视觉扰动滤镜正确应用 +3. _render_pass_through 中视觉扰动滤镜正确应用 +4. ensure_variant_plans 为每个变体生成并存储扰动参数 +""" + +from __future__ import annotations + +import random +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# ── 1. generate_visual_perturbation 单元测试 ────────────────────────────────── + + +class TestGenerateVisualPerturbation: + """测试 generate_visual_perturbation 函数。""" + + def test_returns_dict_with_expected_keys(self): + """返回 dict 包含 hflip/zoom_ratio/speed_factor/brightness_shift。""" + from packages.domain.variant_plan_selector import generate_visual_perturbation + + result = generate_visual_perturbation(random.Random(42)) + assert set(result.keys()) == {"hflip", "zoom_ratio", "speed_factor", "brightness_shift"} + + def test_hflip_is_bool(self): + """hflip 是 bool 类型。""" + from packages.domain.variant_plan_selector import generate_visual_perturbation + + result = generate_visual_perturbation(random.Random(42)) + assert isinstance(result["hflip"], bool) + + def test_zoom_ratio_range(self): + """zoom_ratio 在 1.0~1.08 范围内。""" + from packages.domain.variant_plan_selector import generate_visual_perturbation + + for seed in range(100): + result = generate_visual_perturbation(random.Random(seed)) + assert 1.0 <= result["zoom_ratio"] <= 1.08, f"seed={seed}: {result['zoom_ratio']}" + + def test_speed_factor_range(self): + """speed_factor 在 0.95~1.05 范围内。""" + from packages.domain.variant_plan_selector import generate_visual_perturbation + + for seed in range(100): + result = generate_visual_perturbation(random.Random(seed)) + assert 0.95 <= result["speed_factor"] <= 1.05, f"seed={seed}: {result['speed_factor']}" + + def test_brightness_shift_values(self): + """brightness_shift 只能取特定值。""" + from packages.domain.variant_plan_selector import generate_visual_perturbation + + allowed = {-10, -5, 0, 5, 10} + for seed in range(100): + result = generate_visual_perturbation(random.Random(seed)) + assert result["brightness_shift"] in allowed, f"seed={seed}: {result['brightness_shift']}" + + def test_different_seeds_produce_different_results(self): + """不同种子产生不同扰动参数。""" + from packages.domain.variant_plan_selector import generate_visual_perturbation + + results = [generate_visual_perturbation(random.Random(seed)) for seed in range(20)] + # 至少有一些不同的 zoom_ratio + zooms = {r["zoom_ratio"] for r in results} + assert len(zooms) > 1, "所有种子产生了相同的 zoom_ratio" + + def test_hflip_probability_approximately_30_percent(self): + """hflip 概率约 30%(大样本验证)。""" + from packages.domain.variant_plan_selector import generate_visual_perturbation + + n = 1000 + flips = sum(1 for seed in range(n) if generate_visual_perturbation(random.Random(seed))["hflip"]) + ratio = flips / n + assert 0.20 <= ratio <= 0.40, f"hflip 比例 {ratio:.2%} 偏离 30%" + + +# ── 2. 渲染侧滤镜应用测试 ──────────────────────────────────────────────────── + + +class TestVisualPerturbationFilters: + """测试 _get_visual_perturbation 和滤镜应用方法。""" + + def _make_render_service(self, perturbation: dict | None = None): + """构造一个带 mock plan 的 UnifiedRenderService 实例。""" + # 避免实际初始化 + from video_processing.unified_render_service import UnifiedRenderService + + svc = object.__new__(UnifiedRenderService) + svc.output_width = 1920 + svc.output_height = 1080 + svc.plan = MagicMock() + svc.plan.config = {"visual_perturbation": perturbation} if perturbation else {} + return svc + + def test_get_visual_perturbation_empty_when_no_config(self): + """plan.config 无 visual_perturbation 时返回空 dict。""" + svc = self._make_render_service(None) + assert svc._get_visual_perturbation() == {} + + def test_get_visual_perturbation_parses_values(self): + """正确解析 perturbation dict 中的各字段。""" + perturbation = { + "hflip": True, + "zoom_ratio": 1.05, + "speed_factor": 0.97, + "brightness_shift": 5, + } + svc = self._make_render_service(perturbation) + result = svc._get_visual_perturbation() + assert result["hflip"] is True + assert result["zoom_ratio"] == 1.05 + assert result["speed_factor"] == 0.97 + assert result["brightness_shift"] == 5 + + def test_get_visual_perturbation_clamps_zoom_ratio(self): + """zoom_ratio 被限制在 1.0~1.2 范围内。""" + svc = self._make_render_service({"zoom_ratio": 2.0}) + assert svc._get_visual_perturbation()["zoom_ratio"] == 1.2 + + svc2 = self._make_render_service({"zoom_ratio": 0.5}) + assert svc2._get_visual_perturbation()["zoom_ratio"] == 1.0 + + def test_get_visual_perturbation_clamps_brightness(self): + """brightness_shift 被限制在 -30~30 范围内。""" + svc = self._make_render_service({"brightness_shift": 50}) + assert svc._get_visual_perturbation()["brightness_shift"] == 30 + + svc2 = self._make_render_service({"brightness_shift": -50}) + assert svc2._get_visual_perturbation()["brightness_shift"] == -30 + + def test_pre_scale_hflip(self): + """hflip=True 时 filters 列表追加 hflip。""" + svc = self._make_render_service({"hflip": True}) + filters: list[str] = [] + svc._apply_visual_perturbation_pre_scale(filters, svc._get_visual_perturbation()) + assert "hflip" in filters + + def test_pre_scale_no_hflip(self): + """hflip=False 时 filters 列表不变。""" + svc = self._make_render_service({"hflip": False}) + filters: list[str] = ["existing_filter"] + svc._apply_visual_perturbation_pre_scale(filters, svc._get_visual_perturbation()) + assert filters == ["existing_filter"] + + def test_post_scale_zoom_and_brightness(self): + """zoom_ratio>1 时追加 scale+crop,brightness!=0 时追加 eq。""" + svc = self._make_render_service({"zoom_ratio": 1.05, "brightness_shift": 10}) + perturbation = svc._get_visual_perturbation() + filters: list[str] = [] + svc._apply_visual_perturbation_post_scale(filters, perturbation) + # zoom: scale=2016:1134 (1920*1.05=2016, 1080*1.05=1134) + assert "scale=2016:1134" in filters + assert "crop=1920:1080" in filters + # brightness: 10/100 = 0.1 + assert "eq=brightness=0.100" in filters + + def test_post_scale_no_perturbation_when_defaults(self): + """zoom_ratio=1.0 且 brightness_shift=0 时不追加滤镜。""" + svc = self._make_render_service({"zoom_ratio": 1.0, "brightness_shift": 0}) + perturbation = svc._get_visual_perturbation() + filters: list[str] = [] + svc._apply_visual_perturbation_post_scale(filters, perturbation) + assert filters == [] + + +# ── 3. ensure_variant_plans 扰动注入测试 ────────────────────────────────────── + + +class TestEnsureVariantPlansPerturbation: + """测试 ensure_variant_plans 为每个变体注入视觉扰动。""" + + def test_variant_plans_get_perturbation_in_config(self): + """每个变体 plan 的 config 中包含 visual_perturbation。""" + from app.services.edit_plan_service import EditPlanService + + db = MagicMock() + svc = EditPlanService(db) + + # Mock 所有依赖方法 + mock_plan0 = MagicMock() + mock_plan0.id = "plan-0" + mock_variant = MagicMock() + mock_variant.id = "plan-1" + + svc.clone_plan_for_variant = MagicMock(return_value=mock_plan0) + svc.reselect_plan_for_variant = MagicMock(return_value=mock_variant) + svc.apply_voice_duration_to_plan = MagicMock() + svc.mark_clips_ready = MagicMock() + svc.update_plan_config = MagicMock() + + rng = random.Random(42) + plan_ids = svc.ensure_variant_plans( + "source-plan-id", + count=3, + candidate_asset_ids=["asset-1", "asset-2", "asset-3"], + rng=rng, + ) + + assert len(plan_ids) == 3 + + # update_plan_config 应该被调用 3 次(每个变体一次) + assert svc.update_plan_config.call_count == 3 + + # 检查每次调用都传入了 visual_perturbation + for call in svc.update_plan_config.call_args_list: + args, kwargs = call + plan_id = args[0] + config_updates = args[1] + assert "visual_perturbation" in config_updates + perturbation = config_updates["visual_perturbation"] + assert "hflip" in perturbation + assert "zoom_ratio" in perturbation + assert "speed_factor" in perturbation + assert "brightness_shift" in perturbation + + def test_variant_0_no_hflip(self): + """变体 0(预览 plan)不做 hflip。""" + from app.services.edit_plan_service import EditPlanService + + db = MagicMock() + svc = EditPlanService(db) + + mock_plan0 = MagicMock() + mock_plan0.id = "plan-0" + mock_variant = MagicMock() + mock_variant.id = "plan-1" + + svc.clone_plan_for_variant = MagicMock(return_value=mock_plan0) + svc.reselect_plan_for_variant = MagicMock(return_value=mock_variant) + svc.apply_voice_duration_to_plan = MagicMock() + svc.mark_clips_ready = MagicMock() + svc.update_plan_config = MagicMock() + + rng = random.Random(42) + svc.ensure_variant_plans( + "source-plan-id", + count=2, + candidate_asset_ids=["asset-1", "asset-2"], + rng=rng, + ) + + # 第一次调用(变体 0)的 hflip 应该是 False + first_call_args = svc.update_plan_config.call_args_list[0] + perturbation_0 = first_call_args[0][1]["visual_perturbation"] + assert perturbation_0["hflip"] is False -- 2.54.0