feat(#1754): 渲染期画面扰动——让批量视频本身更不同 #1755

Merged
auto-approve-bot merged 1 commits from fix/visual-perturbation-variant-render into develop 2026-09-07 13:55:52 +08:00
4 changed files with 347 additions and 6 deletions
@@ -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:
@@ -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 静音。"""
+21
View File
@@ -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 {
+252
View File
@@ -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+cropbrightness!=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