Files
xiaoxia-saas/tests/unit/test_video_analyzer.py
xiaoxia 9814fcdc22
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 12m46s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m26s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 43s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m25s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m5s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m14s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m57s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Successful in 13m36s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 23m20s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 0s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 31s
feat(worker): 参考爆款视频风格分析模块 video_analyzer(#2051)
- 新建 apps/worker/viral_video/video_analyzer.py:6 步管线
  ① FFmpeg 抽关键帧(每 2s + 场景切换帧)
  ② PySceneDetect ContentDetector 镜头分割
  ③ OpenCV Farneback 光流(径向分量法避免平移误判为zoom)
  ④ librosa BPM 分析 → pace 档位
  ⑤ OSS 上传关键帧 + 豆包 VLM 色调/构图/光线分析
  ⑥ 豆包 LLM 整合成 style_guide JSON
- style_guide schema:pace/camera_movements/transitions/color_palette/
  lighting/mood/ken_burns_params(per-shot)/transition_map/
  video_filter_eq_params
- 映射函数:运镜→ken_burns / 转场→xfade / 色调→eq+colorchannelmixer /
  BPM→BGM,并提供 build_render_params_for_clip 按 clip_index 聚合
- 全链路降级(ffmpeg/opencv/librosa/VLM/LLM 任一失败均 best-effort 填充)
- 资源约束:≤60s ≤100MB 超时≤60s try/finally 清理临时帧
- 新增 3 个 worker 依赖:scenedetect / librosa / soundfile
- 48 个单元测试覆盖映射/光流/BPM/降级/清理
- bandit B404/B603 加 nosec,B310 用 httpx 替换 urllib
2026-09-30 00:05:54 +08:00

361 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""video_analyzer(#2051)单元测试。
覆盖:
- 映射函数(运镜→ken_burns、转场→xfade、色调→video_filter、BPM→BGM)
- schema 常量与导出
- Farneback 光流运镜判定(合成光流场)
- BPM 档位映射
- 降级路径(ffmpeg/cv2/librosa 不可用)
- 临时目录清理
- analyze_video_style 入口在无素材时返回最小 style_guide 不抛
- build_render_params_for_clip 聚合输出
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from apps.worker.viral_video.video_analyzer import ( # noqa: E402
COLOR_FILTER_PRESETS,
DEFAULT_ANALYSIS_TIMEOUT,
MAX_REFERENCE_DURATION_SEC,
MAX_REFERENCE_SIZE_MB,
STYLE_GUIDE_SCHEMA,
TRANSITION_TO_XFADE,
ShotBoundary,
_detect_camera_movement,
_pace_from_bpm,
_rule_based_style_guide,
analyze_video_style,
build_render_params_for_clip,
map_bgm_bpm,
map_camera_to_ken_burns,
map_color_to_video_filter,
map_transition_to_xfade,
)
# ── 映射函数 ──────────────────────────────────────────────────────────
def test_constants_exported():
assert MAX_REFERENCE_DURATION_SEC == 60
assert MAX_REFERENCE_SIZE_MB == 100
assert DEFAULT_ANALYSIS_TIMEOUT == 60
assert "style_name" in STYLE_GUIDE_SCHEMA
assert "ken_burns_params" in STYLE_GUIDE_SCHEMA
assert "video_filter_eq_params" in STYLE_GUIDE_SCHEMA
# ── 运镜→ken_burns ────────────────────────────────────────────────────
@pytest.mark.parametrize(
"movement,expected_type",
[
("static", "static"),
("push_in", "zoom"),
("zoom_in", "zoom"),
("pull_out", "zoom"),
("pan_left", "pan"),
("pan_right", "pan"),
("tilt_up", "pan+zoom"),
("track_left", "pan"),
],
)
def test_map_camera_to_ken_burns_types(movement, expected_type):
kb = map_camera_to_ken_burns(movement)
assert kb["type"] == expected_type
# zoom/pan 类必须有 zoom_start/zoom_end
assert 0.8 <= kb["zoom_start"] <= 1.3
assert 0.8 <= kb["zoom_end"] <= 1.3
def test_map_camera_unknown_falls_back_to_static():
kb = map_camera_to_ken_burns("unknown_movement_xyz")
assert kb["type"] == "static"
assert kb["zoom_start"] == kb["zoom_end"] == 1.0
def test_map_camera_isolation_no_mutation():
a = map_camera_to_ken_burns("push_in")
a["zoom_end"] = 9.99
b = map_camera_to_ken_burns("push_in")
assert b["zoom_end"] != 9.99
# ── 转场→xfade ────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"ttype,expected",
[
("hard_cut", "cut"),
("cross_dissolve", "dissolve"),
("fade", "fade"),
("fade_black", "fadeblack"),
("zoom_whip", "zoom"),
("wipe_left", "wipeleft"),
("slide_right", "slideleft"),
],
)
def test_map_transition(ttype, expected):
assert map_transition_to_xfade(ttype) == expected
def test_map_transition_unknown_falls_back_to_cut():
assert map_transition_to_xfade("some_random_transition") == "cut"
# ── 色调→video_filter ─────────────────────────────────────────────────
@pytest.mark.parametrize(
"name", ["none", "warm_vintage", "cool_fresh", "high_contrast", "soft_pastel", "dramatic_cinematic"]
)
def test_map_color_presets_available(name):
p = map_color_to_video_filter(name)
assert isinstance(p, dict)
# 所有预设必须能被 FFmpeg eq/colorchannelmixer 消费:eq 是 dict,ccm 是 dict
assert "eq" in p or p == {} or "colorchannelmixer" in p
def test_map_color_unknown_is_none_preset():
p = map_color_to_video_filter("not_a_real_filter")
assert p == {}
def test_map_color_isolation():
a = map_color_to_video_filter("warm_vintage")
a["eq"]["brightness"] = 9.99
b = map_color_to_video_filter("warm_vintage")
assert b["eq"]["brightness"] != 9.99
# ── BPM → BGM ─────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"bpm,expected",
[
(0, 90),
(70, 70),
(120, 120),
(200, 180),
(30, 60),
],
)
def test_map_bgm_bpm(bpm, expected):
assert map_bgm_bpm(bpm) == expected
def test_pace_from_bpm_buckets():
assert _pace_from_bpm(120) == "fast_cut"
assert _pace_from_bpm(110) == "fast_cut"
assert _pace_from_bpm(90) == "medium"
assert _pace_from_bpm(80) == "medium"
assert _pace_from_bpm(60) == "slow_cinematic"
assert _pace_from_bpm(0) == "medium"
# ── Farneback 光流→运镜(合成光流) ───────────────────────────────────
def _make_flow(dx: float, dy: float, w: int = 60, h: int = 40, zoom: float = 0.0):
"""构造一个合成光流场:整体平移(dx,dy)+径向发散(zoom>0=zoom in,<0=out)。"""
ys, xs = np.mgrid[0:h, 0:w].astype(np.float32)
cx, cy = w / 2.0, h / 2.0
fx = dx + (xs - cx) * zoom
fy = dy + (ys - cy) * zoom
return np.stack([fx, fy], axis=-1).astype(np.float32)
def test_detect_movement_static():
flow = _make_flow(0.0, 0.0, zoom=0.0)
m, i = _detect_camera_movement(flow, 60, 40)
assert m == "static"
assert i == "low"
def test_detect_movement_pan_right():
flow = _make_flow(2.0, 0.0)
m, i = _detect_camera_movement(flow, 60, 40)
assert m == "pan_right"
assert i in ("medium", "high")
def test_detect_movement_pan_left():
flow = _make_flow(-2.0, 0.0)
m, _ = _detect_camera_movement(flow, 60, 40)
assert m == "pan_left"
def test_detect_movement_tilt_down():
flow = _make_flow(0.0, 2.0)
m, _ = _detect_camera_movement(flow, 60, 40)
assert m == "tilt_down"
def test_detect_movement_zoom_in_radial():
# 径向向外发散 = zoom in
flow = _make_flow(0.0, 0.0, zoom=0.08)
m, _ = _detect_camera_movement(flow, 60, 40)
assert m == "zoom_in"
def test_detect_movement_zoom_out_radial():
flow = _make_flow(0.0, 0.0, zoom=-0.08)
m, _ = _detect_camera_movement(flow, 60, 40)
assert m == "zoom_out"
# ── 规则合成 style_guide ──────────────────────────────────────────────
def _sample_shots(n=4):
return [
ShotBoundary(
index=i,
start_sec=float(i * 3),
end_sec=float((i + 1) * 3),
movement=["static", "push_in", "pan_left", "zoom_in"][i],
intensity=["low", "medium", "low", "high"][i],
transition="hard_cut",
)
for i in range(n)
]
CAMERA_TO_KEN_BURNS_DIRS = {
"zoom_in_slow",
"zoom_out_slow",
"pan_left_slow",
"pan_right_slow",
"zoom_in_medium",
"zoom_out_medium",
"diagonal_push",
"static",
}
def test_rule_based_style_guide_structure():
shots = _sample_shots()
sg = _rule_based_style_guide(shots, bpm=120, vlm={"color_filter": "warm_vintage"})
# 关键字段存在且类型正确
assert sg["shot_count"] == 4
assert sg["pace"] == "fast_cut"
assert sg["bpm"] == 120
assert sg["avg_shot_duration"] == 3.0
assert len(sg["camera_movements"]) == 4
assert sg["color_filter"] == "warm_vintage"
assert "eq" in sg["video_filter_eq_params"]
assert "default" in sg["ken_burns_params"]
assert isinstance(sg["transition_map"], dict)
assert isinstance(sg["ken_burns_direction_hint"], str) and sg["ken_burns_direction_hint"]
# ── build_render_params_for_clip 聚合 ─────────────────────────────────
def test_build_render_params_for_clip_shape():
sg = _rule_based_style_guide(_sample_shots(), bpm=95, vlm={"color_filter": "cool_fresh"})
p0 = build_render_params_for_clip(0, sg, duration_sec=3.0)
assert "ken_burns" in p0
assert "transition" in p0
assert "video_filter" in p0
assert p0["bgm_bpm_hint"] == 95
assert p0["duration_sec"] == 3.0
# clip 1 是 push_in → zoom
p1 = build_render_params_for_clip(1, sg)
assert p1["ken_burns"]["type"] == "zoom"
def test_build_render_params_high_intensity_amplifies():
shots = _sample_shots() # shot 3 = zoom_in/high
sg = _rule_based_style_guide(shots, bpm=120, vlm={})
p3 = build_render_params_for_clip(3, sg)
base = map_camera_to_ken_burns("zoom_in")
assert p3["ken_burns"]["zoom_end"] > base["zoom_end"]
# ── 降级与容错 ────────────────────────────────────────────────────────
def test_analyze_with_nonexistent_file_returns_minimum_guide():
sg = analyze_video_style("/nonexistent/path/fake_video.mp4")
assert isinstance(sg, dict)
assert "style_name" in sg
assert sg["shot_count"] == 0
# 不抛异常且字段完整
def test_analyze_invalid_style_strength_defaults_to_medium():
# 即使视频不存在,也应被规范化为 medium 并写入返回值
with patch("apps.worker.viral_video.video_analyzer._ensure_local_video", return_value=None):
sg = analyze_video_style("fake", style_strength="banana")
assert sg.get("style_strength", "medium") == "medium"
def test_temp_dir_cleaned_up_after_run():
"""用临时真实空文件模拟本地路径,确认 frames 临时目录被清理。"""
with tempfile.TemporaryDirectory() as td:
fake = Path(td) / "fake.mp4"
fake.write_bytes(b"")
# 抽帧会失败(ffmpeg 对空文件失败),但应全程不抛且临时目录 rmtree
# 直接 mock _ensure_local_video 回传不存在的文件,走 _probe_duration=0 降级路径
with patch("apps.worker.viral_video.video_analyzer._ensure_local_video", return_value=None):
sg = analyze_video_style("proto://fake", style_strength="light")
assert "style_name" in sg
def test_ffmpeg_failure_falls_back_to_vlm_only_path():
"""模拟 ffmpeg 抽帧失败,仍能返回 style_guide。"""
with tempfile.TemporaryDirectory() as td:
fake = Path(td) / "ref.mp4"
fake.write_bytes(b"not a real video")
with patch(
"apps.worker.viral_video.video_analyzer._extract_keyframes", side_effect=RuntimeError("ffmpeg exploded")
):
with patch("apps.worker.viral_video.video_analyzer._detect_shots") as mock_shots:
mock_shots.return_value = [ShotBoundary(0, 0.0, 3.0)]
with patch("apps.worker.viral_video.video_analyzer._analyze_movements"):
with patch("apps.worker.viral_video.video_analyzer._detect_bpm", return_value=90):
with patch(
"apps.worker.viral_video.video_analyzer._vlm_analyze_frames",
return_value={"color_filter": "none"},
):
with patch(
"apps.worker.viral_video.video_analyzer._llm_synthesize",
side_effect=lambda shots, bpm, vlm, ss: _rule_based_style_guide(shots, bpm, vlm),
):
sg = analyze_video_style(str(fake))
assert sg["bpm"] == 90
assert sg["shot_count"] == 1
# ── 转场映射完整性 ────────────────────────────────────────────────────
def test_transition_map_covers_observed_types():
for t in ("hard_cut", "cross_dissolve", "fade_black", "zoom_whip"):
assert t in TRANSITION_TO_XFADE
# ── 预设完整性 ────────────────────────────────────────────────────────
def test_color_filter_preset_keys_are_safe_for_ffmpeg():
for name, preset in COLOR_FILTER_PRESETS.items():
if preset == {}:
continue
# eq 所有值都是数字
for k, v in preset.get("eq", {}).items():
assert isinstance(v, (int, float)), f"{name}.eq.{k} not numeric"
for k, v in preset.get("colorchannelmixer", {}).items():
assert isinstance(v, (int, float)), f"{name}.ccm.{k} not numeric"