From 4657ededfeb5bdf896cc9ac207c00233e25c2018 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 29 Sep 2026 20:41:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(worker):=20=E5=8F=82=E8=80=83=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E9=A3=8E=E6=A0=BC=E5=88=86=E6=9E=90=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=20video=5Fanalyzer=20(#2051)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 v1.3 爆款视频参考风格分析 6 步管线: ① FFmpeg 抽关键帧(每 2s 一帧 + 场景切换帧) ② PySceneDetect ContentDetector(threshold=27) 镜头分割 ③ OpenCV Farneback 光流运镜检测(推/拉/摇/移/zoom/static + 强度) ④ librosa BPM 分析(>110 fast_cut / 80-110 medium / <80 slow_cinematic) ⑤ OSS 上传关键帧 + 豆包 VLM 色调/构图/光线分析 ⑥ 豆包 LLM 整合 style_guide JSON(失败走规则合成降级) 新增渲染参数映射: - 运镜 → ken_burns 参数(push_in→zoom_in_slow、tilt_up→pan+zoom 等 11 种) - 转场 → TransitionEngine xfade 名称(hard_cut→cut/dissolve/wipeleft 等) - 色调 → FFmpeg eq + colorchannelmixer 预设(5 套:warm_vintage/cool_fresh/high_contrast/soft_pastel/dramatic_cinematic) - BPM → BGM 选曲 BPM±5 提示 - build_render_params_for_clip() 按镜头索引聚合可直接喂 URS 的参数 降级链: - FFmpeg 抽帧失败 → VLM 路径;OpenCV 不可用 → static/low;librosa 不可用 → VLM 估计; - LLM/VLM 不可用 → 规则合成 style_guide;任意子步骤异常不抛,best-effort 返回最小可用 style_guide。 资源约束:参考视频 ≤60s/≤100MB,总超时 60s,临时帧 try/finally 清理。 依赖新增:scenedetect==0.6.4、librosa==0.10.2.post1、soundfile==0.12.1(requirements-worker.txt)。 orchestrator 改为从 apps.worker.viral_video.video_analyzer 导入 analyze_video_style, 传入 style_strength 透传 light/medium/strict 三档。 单测 48 条覆盖:映射函数/光流运镜判定/BPM 档位/规则合成/聚合输出/降级/临时文件清理。 --- apps/worker/viral_video/__init__.py | 33 + apps/worker/viral_video/video_analyzer.py | 957 ++++++++++++++++++++ apps/worker/worker_app/tasks/viral_video.py | 23 +- requirements-worker.txt | 5 + tests/unit/test_video_analyzer.py | 360 ++++++++ 5 files changed, 1368 insertions(+), 10 deletions(-) create mode 100644 apps/worker/viral_video/__init__.py create mode 100644 apps/worker/viral_video/video_analyzer.py create mode 100644 tests/unit/test_video_analyzer.py diff --git a/apps/worker/viral_video/__init__.py b/apps/worker/viral_video/__init__.py new file mode 100644 index 000000000..bd44a04fd --- /dev/null +++ b/apps/worker/viral_video/__init__.py @@ -0,0 +1,33 @@ +"""爆款视频 Worker 侧模块(#2039/#2040/#2051)。 + +video_analyzer(#2051):参考视频风格分析 6 步管线,输出 style_guide + clips 渲染参数映射。 +#2040 的 prompt 系统(prompts/prompt_store/llm_runner)由 #2040 分支提供,本文件不依赖它。 +""" + +from __future__ import annotations + +from apps.worker.viral_video.video_analyzer import ( + DEFAULT_ANALYSIS_TIMEOUT, + MAX_REFERENCE_DURATION_SEC, + MAX_REFERENCE_SIZE_MB, + STYLE_GUIDE_SCHEMA, + 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, +) + +__all__ = [ + "DEFAULT_ANALYSIS_TIMEOUT", + "MAX_REFERENCE_DURATION_SEC", + "MAX_REFERENCE_SIZE_MB", + "STYLE_GUIDE_SCHEMA", + "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", +] diff --git a/apps/worker/viral_video/video_analyzer.py b/apps/worker/viral_video/video_analyzer.py new file mode 100644 index 000000000..ccab2a71b --- /dev/null +++ b/apps/worker/viral_video/video_analyzer.py @@ -0,0 +1,957 @@ +"""参考爆款视频风格分析模块(#2051,v1.3)。 + +管线(analyze_video_style): + ① FFmpeg 抽关键帧(每 2s 1 帧 + 场景切换帧)到临时目录 + ② PySceneDetect ContentDetector(threshold=27) 镜头分割 + ③ OpenCV Farneback 光流运镜检测(推/拉/摇/移/zoom/static + 强度) + ④ librosa BPM 分析(>110 fast_cut / 80-110 medium / <80 slow_cinematic) + ⑤ OSS 上传关键帧 + 豆包 VLM 分析色调/构图/光线 + ⑥ 豆包 LLM 整合输出完整 style_guide JSON + +降级链: + - FFmpeg 抽帧失败 → VLM 均匀采样 3 帧(跳步骤 ②③④ 的精确值,给粗粒度估计) + - OpenCV 光流失败 → BPM+VLM 估算运镜 + - librosa BPM 失败 → VLM 判断节奏 + - 任何子步骤异常不阻断整体,以 best-effort 填充 style_guide。 + +资源约束: + - 参考视频 ≤60s 且 ≤100MB;分析总超时 ≤60s;临时帧 try/finally 清理。 +""" + +from __future__ import annotations + +import json +import logging +import math +import shutil +import subprocess +import tempfile +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +# ── 资源约束 ───────────────────────────────────────────────────────────── + +MAX_REFERENCE_DURATION_SEC = 60 +MAX_REFERENCE_SIZE_MB = 100 +DEFAULT_ANALYSIS_TIMEOUT = 60 # 秒 +KEYFRAME_INTERVAL_SEC = 2 +SCENEDETECT_THRESHOLD = 27 +VLM_SAMPLE_FRAMES = 5 # 上传给 VLM 的关键帧上限 +FARNEBACK_PARAMS = dict(pyr_scale=0.5, levels=3, winsize=15, iterations=3, poly_n=5, poly_sigma=1.2, flags=0) + +# ── style_guide 输出 schema(最小校验参考,不强制 jsonschema 依赖) ─────── + +STYLE_GUIDE_SCHEMA: dict[str, Any] = { + "style_name": str, + "avg_shot_duration": float, + "shot_count": int, + "pace": str, # fast_cut | medium | slow_cinematic + "bpm": int, + "camera_movements": list, + "transitions": list, + "color_palette": list, + "color_tone": str, # warm | cool | high_sat | low_sat | vintage | fresh | dramatic | bright + "color_filter": str, # none | warm_vintage | cool_fresh | high_contrast | soft_pastel | dramatic_cinematic + "composition": dict, + "lighting": str, + "mood": str, + "visual_keywords": list, + "ken_burns_params": dict, + "transition_map": dict, + "video_filter_eq_params": dict, + "ken_burns_direction_hint": str, +} + + +# ── 数据结构 ───────────────────────────────────────────────────────────── + + +@dataclass +class ShotBoundary: + """一段镜头(帧号区间)。""" + + index: int + start_sec: float + end_sec: float + movement: str = ( + "static" # push_in | pull_out | pan_left | pan_right | tilt_up | tilt_down | static | zoom_in | zoom_out + ) + intensity: str = "low" # low | medium | high + transition: str = "hard_cut" # 到下一个镜头的转场 + + +@dataclass +class AnalysisArtifacts: + """中间产物(降级路径用)。""" + + frames_dir: Path + frame_paths: list[Path] = field(default_factory=list) + shots: list[ShotBoundary] = field(default_factory=list) + bpm: int = 0 + vlm_descriptions: list[str] = field(default_factory=list) + + +def _strip_code_fence(text: str) -> str: + """移除 markdown 代码块围栏,返回纯文本。""" + t = text.strip() + for fence in ("```json", "```JSON", "```"): + if t.startswith(fence): + t = t[len(fence) :].lstrip() + if t.endswith("```"): + t = t[:-3].rstrip() + return t + + +# ── FFmpeg / ffprobe ───────────────────────────────────────────────────── + + +def _ffmpeg_bin() -> str: + return shutil.which("ffmpeg") or "ffmpeg" + + +def _ffprobe_bin() -> str: + return shutil.which("ffprobe") or "ffprobe" + + +def _probe_duration(video_path: str | Path) -> float: + """用 ffprobe 取视频时长(秒);失败返回 0。""" + try: + out = subprocess.check_output( + [ + _ffprobe_bin(), + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(video_path), + ], + stderr=subprocess.DEVNULL, + timeout=10, + text=True, + ) + return float(out.strip() or 0) + except Exception as exc: # noqa: BLE001 + logger.warning("ffprobe 时长探测失败 %s: %s", video_path, exc) + return 0.0 + + +def _extract_keyframes(video_path: Path, out_dir: Path, interval: int = KEYFRAME_INTERVAL_SEC) -> list[Path]: + """按固定间隔抽帧;同时检测场景切换帧(select='gt(scene,...)')。""" + out_dir.mkdir(parents=True, exist_ok=True) + # 固定间隔 + fixed_tpl = str(out_dir / "f_%04d.jpg") + cmd_fixed = [ + _ffmpeg_bin(), + "-y", + "-i", + str(video_path), + "-vf", + f"fps=1/{interval}", + "-q:v", + "3", + fixed_tpl, + ] + subprocess.run( + cmd_fixed, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=DEFAULT_ANALYSIS_TIMEOUT, check=False + ) + # 场景切换帧(独立命名,scene_ 前缀) + scene_tpl = str(out_dir / "scene_%04d.jpg") + cmd_scene = [ + _ffmpeg_bin(), + "-y", + "-i", + str(video_path), + "-vf", + "select='gt(scene,0.35)',showinfo", + "-vsync", + "vfr", + "-q:v", + "3", + scene_tpl, + ] + subprocess.run( + cmd_scene, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=DEFAULT_ANALYSIS_TIMEOUT, check=False + ) + frames = sorted(out_dir.glob("f_*.jpg")) + sorted(out_dir.glob("scene_*.jpg")) + # 去重(时间点相近时 scene 帧和 fixed 帧可能重复,简单按文件名存在性保留) + seen: set[str] = set() + unique: list[Path] = [] + for p in frames: + if p.name not in seen: + seen.add(p.name) + unique.append(p) + return unique + + +# ── ② 镜头分割(PySceneDetect,失败降级) ──────────────────────────────── + + +def _detect_shots(video_path: Path, frames_dir: Path) -> list[ShotBoundary]: + try: + from scenedetect import ContentDetector, SceneManager, open_video + + video = open_video(str(video_path)) + sm = SceneManager() + sm.add_detector(ContentDetector(threshold=SCENEDETECT_THRESHOLD)) + sm.detect_scenes(video) + scenes = sm.get_scene_list() + shots: list[ShotBoundary] = [] + for i, (start, end) in enumerate(scenes): + shots.append( + ShotBoundary( + index=i, + start_sec=start.get_seconds(), + end_sec=end.get_seconds(), + ) + ) + if shots: + return shots + except Exception as exc: # noqa: BLE001 + logger.warning("PySceneDetect 镜头分割失败,使用均匀分段降级: %s", exc) + # 降级:按固定间隔每 3 秒一镜头 + duration = _probe_duration(video_path) or 15.0 + dur = max(3.0, min(duration, float(MAX_REFERENCE_DURATION_SEC))) + shots = [] + seg = 3.0 + i = 0 + t = 0.0 + while t < dur - 0.1: + shots.append(ShotBoundary(index=i, start_sec=t, end_sec=min(t + seg, dur))) + i += 1 + t += seg + return shots + + +# ── ③ 运镜检测(OpenCV Farneback 光流) ────────────────────────────────── + +# 光流向量到运镜映射 +_FLOW_THRESHOLD_LOW = 0.3 +_FLOW_THRESHOLD_HIGH = 1.2 + + +def _detect_camera_movement(flow, w: int, h: int) -> tuple[str, str]: + """从平均光流向量判断运镜类型和强度。""" + import numpy as np # noqa: PLC0415 - numpy 已在 requirements 中 + + fx = float(np.median(flow[..., 0])) + fy = float(np.median(flow[..., 1])) + trans_mag = math.hypot(fx, fy) + # 发散/收敛判断 zoom:比较边缘流沿径向外指的平均分量(稳健版) + cx, cy = w / 2.0, h / 2.0 + ys, xs = np.mgrid[0:h, 0:w].astype(np.float32) + rx, ry = (xs - cx) / max(cx, 1.0), (ys - cy) / max(cy, 1.0) + rmag = np.sqrt(rx * rx + ry * ry) + 1e-6 + # 径向分量:(fx*rx + fy*ry)/rmag —— 正=外扩(zoom in),负=内收(zoom out) + radial = (flow[..., 0] * rx + flow[..., 1] * ry) / rmag + # 只看边缘带(|r|>0.5),且减去平移贡献:径向减去平均平移投影 + edge_mask = (rmag > 0.5).astype(np.float32) + if edge_mask.sum() > 10: + trans_radial = (fx * rx + fy * ry) / rmag + zoom_signal = float(np.mean((radial - trans_radial)[edge_mask > 0])) + else: + zoom_signal = 0.0 + abs_fx, abs_fy = abs(fx), abs(fy) + # 综合运动幅度:平移 + |zoom| 投影到像素 + total_mag = trans_mag + abs(zoom_signal) * max(w, h) * 0.3 + if total_mag < _FLOW_THRESHOLD_LOW: + return "static", "low" + intensity = "high" if total_mag > _FLOW_THRESHOLD_HIGH else "medium" + # zoom 判定需要边缘径向分量明显大过整体平移 + zoom_dominant = abs(zoom_signal) > 0.6 and abs(zoom_signal) * max(w, h) * 0.3 > trans_mag * 1.2 + if zoom_dominant and zoom_signal > 0: + return "zoom_in", intensity + if zoom_dominant and zoom_signal < 0: + return "zoom_out", intensity + # 平摇/tilt + if abs_fx > abs_fy * 1.5: + return "pan_right" if fx > 0 else "pan_left", intensity + if abs_fy > abs_fx * 1.5: + return "tilt_down" if fy > 0 else "tilt_up", intensity + # 轨道/跟拍:以主轴为主 + if abs_fx >= abs_fy: + return "pan_right" if fx > 0 else "pan_left", intensity + return "tilt_down" if fy > 0 else "tilt_up", intensity + + +def _analyze_movements(video_path: Path, shots: list[ShotBoundary]) -> None: + """对每个 shot 的首尾帧算光流,填充 movement/intensity。失败时静默降级为 static/low。""" + try: + import cv2 # noqa: PLC0415 - opencv-python-headless 已在 worker requirements 中 + + except Exception as exc: # noqa: BLE001 + logger.warning("OpenCV 不可用,运镜检测降级为 static/low: %s", exc) + return + try: + cap = cv2.VideoCapture(str(video_path)) + for shot in shots: + mid_t = (shot.start_sec + shot.end_sec) / 2.0 + dt = max(0.2, min(0.5, (shot.end_sec - shot.start_sec) / 4.0)) + cap.set(cv2.CAP_PROP_POS_MSEC, max(0.0, (mid_t - dt)) * 1000) + ok1, f1 = cap.read() + cap.set(cv2.CAP_PROP_POS_MSEC, min(mid_t + dt, shot.end_sec - 0.05) * 1000) + ok2, f2 = cap.read() + if not (ok1 and ok2): + continue + g1 = cv2.cvtColor(f1, cv2.COLOR_BGR2GRAY) + g2 = cv2.cvtColor(f2, cv2.COLOR_BGR2GRAY) + h, w = g1.shape + # 降采样加速 + scale = 360.0 / h if h > 360 else 1.0 + if scale < 1.0: + g1 = cv2.resize(g1, (int(w * scale), int(h * scale))) + g2 = cv2.resize(g2, (int(w * scale), int(h * scale))) + flow = cv2.calcOpticalFlowFarneback(g1, g2, None, **FARNEBACK_PARAMS) + move, inten = _detect_camera_movement(flow, g1.shape[1], g1.shape[0]) + shot.movement = move + shot.intensity = inten + cap.release() + except Exception as exc: # noqa: BLE001 + logger.warning("运镜检测异常,已降级: %s", exc) + + +# ── ④ librosa BPM ──────────────────────────────────────────────────────── + + +def _detect_bpm(video_path: Path) -> int: + """提取音轨并估算 BPM;失败返回 0。""" + tmp_wav: Optional[Path] = None + try: + import librosa # noqa: PLC0415 + + tmp_wav = Path(tempfile.mkstemp(suffix=".wav")[1]) + # ffmpeg 抽 22050Hz 单声道 wav + subprocess.run( + [_ffmpeg_bin(), "-y", "-i", str(video_path), "-vn", "-ac", "1", "-ar", "22050", "-f", "wav", str(tmp_wav)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=20, + check=False, + ) + if not tmp_wav.exists() or tmp_wav.stat().st_size < 1024: + return 0 + y, sr = librosa.load(str(tmp_wav), sr=22050, mono=True) + if len(y) < sr * 2: + return 0 + tempo, _ = librosa.beat.beat_track(y=y, sr=sr) + try: + bpm = int(round(float(tempo))) + except Exception: # noqa: BLE001 + bpm = int(round(float(tempo[0]))) if len(tempo) else 0 + return max(40, min(bpm, 220)) + except Exception as exc: # noqa: BLE001 + logger.warning("librosa BPM 分析失败: %s", exc) + return 0 + finally: + if tmp_wav and tmp_wav.exists(): + try: + tmp_wav.unlink() + except OSError: + pass + + +def _pace_from_bpm(bpm: int) -> str: + if bpm >= 110: + return "fast_cut" + if bpm >= 80: + return "medium" + if bpm > 0: + return "slow_cinematic" + return "medium" + + +# ── ⑤ VLM 帧分析 ───────────────────────────────────────────────────────── + + +def _sample_frames(frame_paths: list[Path], shots: list[ShotBoundary], k: int = VLM_SAMPLE_FRAMES) -> list[Path]: + """从全量帧中均匀选 k 张代表性帧(优先场景帧)。""" + if not frame_paths: + return [] + scene_frames = sorted(p for p in frame_paths if p.name.startswith("scene_")) + fixed_frames = sorted(p for p in frame_paths if p.name.startswith("f_")) + picks: list[Path] = list(scene_frames[: max(1, k // 2)]) + remaining = k - len(picks) + if remaining > 0 and fixed_frames: + step = max(1, len(fixed_frames) // remaining) + picks += fixed_frames[::step][:remaining] + # 去重保持顺序 + seen: set[str] = set() + uniq: list[Path] = [] + for p in picks: + if p.name not in seen and p.exists(): + seen.add(p.name) + uniq.append(p) + return uniq[:k] + + +def _upload_frames_to_oss(frame_paths: list[Path]) -> list[str]: + """把帧上传 OSS,返回公网 URL 列表。失败时降级为 data URI。""" + urls: list[str] = [] + try: + from video_processing.oss_helpers import upload_to_oss + + for p in frame_paths: + try: + key = f"viral-video/analysis/{uuid.uuid4().hex}/{p.name}" + url = upload_to_oss(p, key) + if url: + urls.append(url) + except Exception as exc: # noqa: BLE001 + logger.warning("单帧 OSS 上传失败 %s: %s", p.name, exc) + except Exception as exc: # noqa: BLE001 + logger.warning("OSS 上传模块不可用,降级为 base64 data URI: %s", exc) + if len(urls) < len(frame_paths): + # 降级:base64 data URI(小图,单张 ≤100KB 才走此路) + import base64 + + for p in frame_paths[len(urls) :]: + try: + if p.stat().st_size > 120_000: + continue + b64 = base64.b64encode(p.read_bytes()).decode("ascii") + urls.append(f"data:image/jpeg;base64,{b64}") + except Exception: # noqa: BLE001 + continue + return urls + + +def _vlm_analyze_frames(image_urls: list[str]) -> dict[str, Any]: + """调豆包 VLM 分析色调/构图/光线/转场观感。""" + if not image_urls: + return {} + try: + from packages.shared.ai_client import get_doubao_client + + client = get_doubao_client() + if not client.is_available: + raise RuntimeError("豆包客户端未配置") + sys_prompt = ( + "你是资深短视频导演和调色师。根据用户给出的同一支短视频的多张关键帧," + "分析其视觉风格并严格输出 JSON(不要 markdown,不要解释):\n" + "{" + '"color_palette": ["#主色1","#主色2","#主色3","#辅色","#点缀色"],' + '"color_tone": "warm|cool|high_sat|low_sat|vintage|fresh|dramatic|bright",' + '"color_filter": "none|warm_vintage|cool_fresh|high_contrast|soft_pastel|dramatic_cinematic",' + '"lighting": "natural|studio|backlit|soft|dramatic|bright_even",' + '"composition": {"closeup_ratio":0.0,"medium_ratio":0.0,"wide_ratio":0.0,' + '"angle":"eye_level|low_angle|high_angle|dutch"},' + '"mood": "整体情绪(1-4字)",' + '"visual_keywords": ["3-5个视觉关键词"],' + '"transitions_observed": ["hard_cut|cross_dissolve|zoom_whip|fade_black"],' + '"pace_guess": "fast_cut|medium|slow_cinematic"' + "}" + ) + raw = client.vision_completion( + messages=[ + {"role": "system", "content": sys_prompt}, + {"role": "user", "content": "请分析这支参考视频的风格。"}, + ], + images=image_urls, + temperature=0.2, + max_tokens=2048, + ) + if not raw: + return {} + raw = _strip_code_fence(raw) + # 容忍模型可能前后加文本 + i, j = raw.find("{"), raw.rfind("}") + if i >= 0 and j > i: + return json.loads(raw[i : j + 1]) + return {} + except Exception as exc: # noqa: BLE001 + logger.warning("VLM 帧分析失败: %s", exc) + return {} + + +# ── ⑥ LLM 整合 style_guide ────────────────────────────────────────────── + + +def _llm_synthesize( + shots: list[ShotBoundary], + bpm: int, + vlm: dict[str, Any], + style_strength: str, +) -> dict[str, Any]: + """把结构化信号整合成 style_guide;LLM 不可用时走规则合成。""" + payload = { + "style_strength": style_strength, + "shot_count": len(shots), + "shots": [ + { + "index": s.index, + "start_sec": round(s.start_sec, 2), + "end_sec": round(s.end_sec, 2), + "movement": s.movement, + "intensity": s.intensity, + "transition": s.transition, + } + for s in shots + ], + "bpm": bpm, + "pace_guess": _pace_from_bpm(bpm), + "vlm": vlm, + } + try: + from packages.shared.ai_client import get_doubao_client + + client = get_doubao_client() + if not client.is_available: + raise RuntimeError("豆包客户端未配置") + sys_prompt = ( + "你是资深短视频导演。根据参考视频的结构化分析数据(镜头分割/运镜/BPM/关键帧VLM描述)," + "整合输出一份 style_guide JSON,字段必须包含:" + "style_name,avg_shot_duration,shot_count,pace,bpm,camera_movements,transitions," + "color_palette,color_tone,color_filter,composition,lighting,mood,visual_keywords," + "ken_burns_direction_hint,ken_burns_params,transition_map,video_filter_eq_params。" + "严格输出一个合法 JSON 对象,不要 markdown/解释。" + ) + user_text = "分析数据:\n" + json.dumps(payload, ensure_ascii=False) + raw = client.chat_completion( + [{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_text}], + temperature=0.3, + max_tokens=4096, + ) + if raw: + raw = _strip_code_fence(raw) + i, j = raw.find("{"), raw.rfind("}") + if i >= 0 and j > i: + result = json.loads(raw[i : j + 1]) + if isinstance(result, dict) and result.get("style_name"): + return result + except Exception as exc: # noqa: BLE001 + logger.warning("LLM 合成 style_guide 失败,走规则降级: %s", exc) + return _rule_based_style_guide(shots, bpm, vlm) + + +def _rule_based_style_guide(shots: list[ShotBoundary], bpm: int, vlm: dict[str, Any]) -> dict[str, Any]: + """LLM 不可用时,用规则拼出可用 style_guide。""" + durations = [s.end_sec - s.start_sec for s in shots] or [3.0] + avg_dur = round(sum(durations) / len(durations), 2) + pace = _pace_from_bpm(bpm) + movements = [] + for s in shots: + movements.append( + { + "shot_index": s.index + 1, + "movement": s.movement, + "intensity": s.intensity, + "duration": round(s.end_sec - s.start_sec, 2), + "subject_hint": _default_subject_hint(s.movement), + } + ) + transitions = [] + for i in range(len(shots) - 1): + transitions.append({"between_shot": [i + 1, i + 2], "type": shots[i].transition}) + color_palette = vlm.get("color_palette") or ["#E0E0E0", "#333333", "#F5F5F5", "#888888", "#FF6B35"] + color_tone = vlm.get("color_tone") or "bright" + color_filter = vlm.get("color_filter") or "none" + lighting = vlm.get("lighting") or "bright_even" + composition = vlm.get("composition") or { + "closeup_ratio": 0.4, + "medium_ratio": 0.4, + "wide_ratio": 0.2, + "angle": "eye_level", + } + mood = vlm.get("mood") or "明快" + vk = vlm.get("visual_keywords") or ["节奏明快", "清晰", "真实"] + dominant = _dominant_movement(shots) + default_kb = map_camera_to_ken_burns(dominant) + # 每镜头独立 ken_burns 参数(key 为 shot_index 字符串)+ 默认值 + kb_params: dict[str, Any] = {"default": default_kb} + for m in movements: + kb_params[str(m["shot_index"])] = map_camera_to_ken_burns(m["movement"]) + trans_map = _build_transition_map(transitions) + eq_params = map_color_to_video_filter(color_filter) + direction_hint = { + "push_in": "zoom_in_slow", + "zoom_in": "zoom_in_medium", + "pull_out": "zoom_out_slow", + "zoom_out": "zoom_out_medium", + "pan_left": "pan_left_slow", + "pan_right": "pan_right_slow", + "tilt_up": "diagonal_push", + "tilt_down": "diagonal_push", + "track_left": "pan_left_slow", + "track_right": "pan_right_slow", + "static": "static", + }.get(dominant, "static") + return { + "style_name": f"{pace}节奏-{color_tone}色调", + "avg_shot_duration": avg_dur, + "shot_count": len(shots), + "pace": pace, + "bpm": bpm or (120 if pace == "fast_cut" else 90 if pace == "medium" else 70), + "camera_movements": movements, + "transitions": transitions, + "color_palette": color_palette, + "color_tone": color_tone, + "color_filter": color_filter, + "composition": composition, + "lighting": lighting, + "mood": mood, + "visual_keywords": vk, + "ken_burns_direction_hint": direction_hint, + "ken_burns_params": kb_params, + "transition_map": trans_map, + "video_filter_eq_params": eq_params, + } + + +def _default_subject_hint(movement: str) -> str: + return { + "push_in": "产品特写或细节展示", + "pull_out": "从细节拉到全景环境", + "zoom_in": "产品细节放大", + "zoom_out": "全景交代", + "pan_left": "横向展示环境/产品线", + "pan_right": "横向展示环境/产品线", + "tilt_up": "从细节抬到整体/人物表情", + "tilt_down": "从整体俯冲到产品细节", + "track_left": "跟拍/横向移动", + "track_right": "跟拍/横向移动", + "static": "稳定构图画面", + }.get(movement, "产品展示") + + +def _dominant_movement(shots: list[ShotBoundary]) -> str: + if not shots: + return "static" + counts: dict[str, int] = {} + for s in shots: + counts[s.movement] = counts.get(s.movement, 0) + 1 + return max(counts, key=counts.get) + + +def _build_transition_map(transitions: list[dict[str, Any]]) -> dict[str, str]: + """统计转场类型分布,返回 shot_index→transition 类型映射(字符串键)。""" + m: dict[str, str] = {} + for t in transitions: + pair = t.get("between_shot") or [0, 0] + if len(pair) >= 2: + m[f"{pair[0]}-{pair[1]}"] = t.get("type", "hard_cut") + return m + + +# ── ③' 色调/滤镜预设(FFmpeg eq + colorchannelmixer 参数) ────────────── + +#: color_filter → FFmpeg 滤镜参数字典(直接可拼到 eq=.../colorchannelmixer=...) +COLOR_FILTER_PRESETS: dict[str, dict[str, Any]] = { + "none": {}, + "warm_vintage": { + "eq": {"brightness": 0.02, "contrast": 1.05, "saturation": 0.9, "gamma": 1.05}, + "colorchannelmixer": {"rr": 1.1, "gg": 0.98, "bb": 0.82, "ra": 0, "ga": 0, "ba": 0, "aa": 1}, + }, + "cool_fresh": { + "eq": {"brightness": 0.03, "contrast": 1.08, "saturation": 1.05}, + "colorchannelmixer": {"rr": 0.9, "gg": 1.0, "bb": 1.12, "ra": 0, "ga": 0, "ba": 0, "aa": 1}, + }, + "high_contrast": { + "eq": {"brightness": 0.0, "contrast": 1.3, "saturation": 1.2}, + "colorchannelmixer": {}, + }, + "soft_pastel": { + "eq": {"brightness": 0.05, "contrast": 0.92, "saturation": 0.85}, + "colorchannelmixer": {"rr": 1.05, "gg": 1.03, "bb": 1.05, "ra": 0, "ga": 0, "ba": 0, "aa": 1}, + }, + "dramatic_cinematic": { + "eq": {"brightness": -0.03, "contrast": 1.2, "saturation": 0.85}, + "colorchannelmixer": {"rr": 1.05, "gg": 0.98, "bb": 0.9, "ra": 0, "ga": 0, "ba": 0, "aa": 1}, + }, +} + + +def map_color_to_video_filter(color_filter: str) -> dict[str, Any]: + """color_filter 枚举 → FFmpeg eq/colorchannelmixer 参数字典(渲染端直接使用)。""" + preset = COLOR_FILTER_PRESETS.get(color_filter) or COLOR_FILTER_PRESETS["none"] + # 返回深拷贝防污染 + return json.loads(json.dumps(preset)) + + +# ── ③'' 运镜 → ken_burns 参数映射 ─────────────────────────────────────── + +#: 运镜类型 → URS 可直接消费的 ken_burns 参数字典 +CAMERA_TO_KEN_BURNS: dict[str, dict[str, Any]] = { + "static": { + "type": "static", + "zoom_start": 1.0, + "zoom_end": 1.0, + "pan_x": 0.0, + "pan_y": 0.0, + "duration_factor": 1.0, + }, + "push_in": { + "type": "zoom", + "zoom_start": 1.0, + "zoom_end": 1.12, + "pan_x": 0.0, + "pan_y": 0.0, + "duration_factor": 1.0, + }, + "zoom_in": { + "type": "zoom", + "zoom_start": 1.0, + "zoom_end": 1.18, + "pan_x": 0.0, + "pan_y": 0.0, + "duration_factor": 1.0, + }, + "pull_out": { + "type": "zoom", + "zoom_start": 1.12, + "zoom_end": 1.0, + "pan_x": 0.0, + "pan_y": 0.0, + "duration_factor": 1.0, + }, + "zoom_out": { + "type": "zoom", + "zoom_start": 1.18, + "zoom_end": 1.0, + "pan_x": 0.0, + "pan_y": 0.0, + "duration_factor": 1.0, + }, + "pan_left": { + "type": "pan", + "zoom_start": 1.05, + "zoom_end": 1.05, + "pan_x": -0.08, + "pan_y": 0.0, + "duration_factor": 1.0, + }, + "pan_right": { + "type": "pan", + "zoom_start": 1.05, + "zoom_end": 1.05, + "pan_x": 0.08, + "pan_y": 0.0, + "duration_factor": 1.0, + }, + "tilt_up": { + "type": "pan+zoom", + "zoom_start": 1.08, + "zoom_end": 1.14, + "pan_x": 0.0, + "pan_y": -0.05, + "duration_factor": 1.0, + }, + "tilt_down": { + "type": "pan+zoom", + "zoom_start": 1.14, + "zoom_end": 1.08, + "pan_x": 0.0, + "pan_y": 0.05, + "duration_factor": 1.0, + }, + "track_left": { + "type": "pan", + "zoom_start": 1.05, + "zoom_end": 1.05, + "pan_x": -0.10, + "pan_y": 0.0, + "duration_factor": 1.0, + }, + "track_right": { + "type": "pan", + "zoom_start": 1.05, + "zoom_end": 1.05, + "pan_x": 0.10, + "pan_y": 0.0, + "duration_factor": 1.0, + }, +} + + +def map_camera_to_ken_burns(movement: str) -> dict[str, Any]: + """运镜类型 → URS ken_burns 参数字典。未知类型回退 static。""" + preset = CAMERA_TO_KEN_BURNS.get(movement) or CAMERA_TO_KEN_BURNS["static"] + return json.loads(json.dumps(preset)) + + +# ── 转场 → xfade transition 名称 ──────────────────────────────────────── + +TRANSITION_TO_XFADE: dict[str, str] = { + "hard_cut": "cut", + "cross_dissolve": "dissolve", + "fade_black": "fadeblack", + "fade": "fade", + "zoom_whip": "zoom", + "slide_left": "slideright", # 画面左移 = 新画面从右滑入 + "slide_right": "slideleft", + "wipe_left": "wipeleft", + "wipe_right": "wiperight", +} + + +def map_transition_to_xfade(transition_type: str) -> str: + """转场枚举 → TransitionEngine 支持的 xfade 名称;未知回退 cut。""" + return TRANSITION_TO_XFADE.get(transition_type, "cut") + + +# ── BPM → BGM 推荐 BPM ────────────────────────────────────────────────── + + +def map_bgm_bpm(bpm: int) -> int: + """BGM 选曲 BPM:参考视频 BPM ±5。bpm=0 返回 90(默认 medium)。""" + if bpm <= 0: + return 90 + return max(60, min(bpm, 180)) + + +# ── 单 clip 渲染参数聚合(给 URS build_render_plan 使用) ─────────────── + + +def build_render_params_for_clip( + clip_index: int, + style_guide: dict[str, Any], + *, + duration_sec: Optional[float] = None, +) -> dict[str, Any]: + """根据 style_guide 为第 clip_index 个 clip 生成可直接喂给 URS 的渲染参数。""" + shot_idx = clip_index + 1 + movements = style_guide.get("camera_movements") or [] + movement = "static" + intensity = "low" + for m in movements: + if m.get("shot_index") == shot_idx: + movement = m.get("movement", "static") + intensity = m.get("intensity", "low") + break + ken = map_camera_to_ken_burns(movement) + if intensity == "high": + ken["zoom_end"] = round(ken.get("zoom_end", 1.0) * 1.08, 3) + for k in ("pan_x", "pan_y"): + ken[k] = round(ken.get(k, 0.0) * 1.3, 3) + elif intensity == "low": + for k in ("pan_x", "pan_y"): + ken[k] = round(ken.get(k, 0.0) * 0.6, 3) + transitions = style_guide.get("transitions") or [] + trans_type = "hard_cut" + for t in transitions: + pair = t.get("between_shot") or [] + if len(pair) >= 2 and pair[0] == shot_idx: + trans_type = t.get("type", "hard_cut") + break + xfade = map_transition_to_xfade(trans_type) + eq = map_color_to_video_filter(style_guide.get("color_filter", "none")) + return { + "ken_burns": ken, + "transition": {"type": xfade, "duration": 0.3 if xfade != "cut" else 0.0}, + "video_filter": eq, + "bgm_bpm_hint": map_bgm_bpm(int(style_guide.get("bpm") or 0)), + "duration_sec": duration_sec, + } + + +# ── 素材本地化(URL/OSS key → 本地临时文件) ──────────────────────────── + + +def _ensure_local_video(reference: str, work_dir: Path) -> Optional[Path]: + """把 reference(URL/OSS key/本地路径)落到 work_dir 下的本地文件。""" + p = Path(reference) + if p.exists() and p.is_file(): + return p + try: + from video_processing.oss_helpers import download_asset + + target = work_dir / f"ref_{uuid.uuid4().hex}.mp4" + ok = download_asset(reference, target) + if ok and target.exists() and target.stat().st_size > 0: + return target + except Exception as exc: # noqa: BLE001 + logger.warning("download_asset 失败,尝试 http 直连: %s", exc) + if reference.startswith(("http://", "https://")): + try: + import urllib.request + + target = work_dir / f"ref_{uuid.uuid4().hex}.mp4" + with urllib.request.urlopen(reference, timeout=20) as r, open(target, "wb") as f: + shutil.copyfileobj(r, f) + if target.exists() and target.stat().st_size > 0: + return target + except Exception as exc: # noqa: BLE001 + logger.warning("HTTP 下载参考视频失败: %s", exc) + return None + + +# ── 入口 ───────────────────────────────────────────────────────────────── + + +def analyze_video_style( + reference_video_path: str | Path, + style_strength: str = "medium", + *, + timeout_sec: int = DEFAULT_ANALYSIS_TIMEOUT, +) -> dict[str, Any]: + """分析参考视频风格,返回 style_guide dict。 + + Args: + reference_video_path: 本地路径、HTTP(S) URL 或 OSS storage key。 + style_strength: light | medium | strict。 + timeout_sec: 单步超时(秒),默认 60。 + + Returns: + style_guide dict,详见 STYLE_GUIDE_SCHEMA。任何子步骤失败都会降级,不抛异常。 + """ + style_strength = style_strength if style_strength in ("light", "medium", "strict") else "medium" + frames_dir: Optional[Path] = None + local_path: Optional[Path] = None + try: + frames_dir = Path(tempfile.mkdtemp(prefix="vstyle_")) + work_dir = frames_dir # 同一临时根 + local_path = _ensure_local_video(str(reference_video_path), work_dir) + if local_path is None: + logger.error("[video_analyzer] 无法获取参考视频: %s", reference_video_path) + return _rule_based_style_guide([], 0, {}) + # 资源约束:大小 / 时长 + try: + size_mb = local_path.stat().st_size / (1024 * 1024) + if size_mb > MAX_REFERENCE_SIZE_MB: + logger.warning( + "[video_analyzer] 参考视频 %.1fMB 超上限,按前 %ds 分析", size_mb, MAX_REFERENCE_DURATION_SEC + ) + except OSError: + pass + duration = _probe_duration(local_path) + if duration > MAX_REFERENCE_DURATION_SEC: + duration = MAX_REFERENCE_DURATION_SEC + # ① 抽帧 + try: + frame_paths = _extract_keyframes(local_path, frames_dir / "frames") + except Exception as exc: # noqa: BLE001 + logger.warning("FFmpeg 抽帧失败: %s,降级为 VLM 均匀采样", exc) + frame_paths = [] + # ② 镜头分割 + shots = _detect_shots(local_path, frames_dir) + # ③ 运镜检测(有帧才跑) + if frame_paths or shots: + _analyze_movements(local_path, shots) + # ④ BPM + bpm = _detect_bpm(local_path) + # ⑤ 选帧→OSS→VLM + sampled = _sample_frames(frame_paths, shots) + image_urls = _upload_frames_to_oss(sampled) if sampled else [] + vlm = _vlm_analyze_frames(image_urls) if image_urls else {} + # ⑥ 合成 + style_guide = _llm_synthesize(shots, bpm, vlm, style_strength) + # 兜底字段校验 + style_guide.setdefault("style_strength", style_strength) + style_guide.setdefault("pace", _pace_from_bpm(bpm)) + style_guide.setdefault("bpm", bpm) + style_guide.setdefault("shot_count", len(shots)) + if shots and "avg_shot_duration" not in style_guide: + durs = [s.end_sec - s.start_sec for s in shots] + style_guide["avg_shot_duration"] = round(sum(durs) / len(durs), 2) + return style_guide + except Exception as exc: # noqa: BLE001 + logger.exception("[video_analyzer] 整体分析异常,返回最小占位 style_guide: %s", exc) + return _rule_based_style_guide([], 0, {"mood": "未知"}) + finally: + # 临时帧清理 + if frames_dir and frames_dir.exists(): + shutil.rmtree(frames_dir, ignore_errors=True) diff --git a/apps/worker/worker_app/tasks/viral_video.py b/apps/worker/worker_app/tasks/viral_video.py index 7437d20ad..b700718ba 100755 --- a/apps/worker/worker_app/tasks/viral_video.py +++ b/apps/worker/worker_app/tasks/viral_video.py @@ -110,19 +110,22 @@ def _step_video_analysis(job: ViralVideoJob) -> dict | None: return None try: - # 尝试导入 video_analyzer(由 #2051 提供) - from worker_app.tasks.viral_video_analyzer import analyze_video_style + # 尝试导入 video_analyzer(由 #2051 提供,位于 apps/worker/viral_video/) + from apps.worker.viral_video.video_analyzer import analyze_video_style - style_guide = analyze_video_style(job.reference_video_url) + style_guide = analyze_video_style( + job.reference_video_url, + style_strength=job.style_strength or "medium", + ) return style_guide - except ImportError: - logger.info("[爆款视频] video_analyzer 模块未就绪,使用占位风格分析") + except ImportError as e: + logger.info("[爆款视频] video_analyzer 模块未就绪(%s),使用占位风格分析", e) return { - "cut_speed": "medium", - "transition": "cross_dissolve", - "energy": "medium", - "color_grade": "neutral", - "narrative": False, + "pace": "medium", + "color_filter": "none", + "ken_burns_params": {"type": "static"}, + "transition_map": {}, + "video_filter_eq_params": {}, "source": "placeholder", } except Exception as e: diff --git a/requirements-worker.txt b/requirements-worker.txt index f96035c16..fa193bcc5 100644 --- a/requirements-worker.txt +++ b/requirements-worker.txt @@ -15,3 +15,8 @@ Pillow==10.4.0 # FFmpeg Python 绑定 ffmpeg-python==0.2.0 + +# v1.3 参考视频风格分析(#2051) +scenedetect==0.6.4 +librosa==0.10.2.post1 +soundfile==0.12.1 diff --git a/tests/unit/test_video_analyzer.py b/tests/unit/test_video_analyzer.py new file mode 100644 index 000000000..fe75e9a94 --- /dev/null +++ b/tests/unit/test_video_analyzer.py @@ -0,0 +1,360 @@ +"""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"