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
- 新建 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
361 lines
13 KiB
Python
361 lines
13 KiB
Python
"""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"
|