""" composer/frames.py — 视频抽帧工具(首尾帧衔接用) 用途:`chain_from_prev` 的镜头需要「上一镜的尾帧」作为生成参考, 这里提供 ffmpeg 抽帧(零成本、本地处理)。 """ import subprocess import sys from pathlib import Path _SCRIPT_ROOT = Path(__file__).resolve().parent.parent if str(_SCRIPT_ROOT) not in sys.path: sys.path.insert(0, str(_SCRIPT_ROOT)) from lib.logger import get_logger log = get_logger("composer.frames") def _ffmpeg() -> str: try: from lib.ffmpeg import FFMPEG return FFMPEG except Exception: return "ffmpeg" def extract_tail_frame(video_path: str, out_path: str, offset: float = 0.1) -> str: """抽取视频**最后一帧**(默认取结束前 0.1 秒处,避开黑帧/抖动) 用于首尾帧衔接:本镜的起手画面 = 上一镜的收尾画面 → 视觉连续。 """ src = Path(video_path) if not src.exists(): raise FileNotFoundError(f"视频不存在: {video_path}") dst = Path(out_path) dst.parent.mkdir(parents=True, exist_ok=True) r = subprocess.run( [_ffmpeg(), "-y", "-sseof", f"-{offset}", "-i", str(src), "-frames:v", "1", "-q:v", "2", str(dst)], capture_output=True, text=True, timeout=90) if r.returncode != 0 or not dst.exists() or dst.stat().st_size < 500: raise RuntimeError(f"抽尾帧失败: {r.stderr.strip()[-160:]}") log.debug(" 抽尾帧: %s → %s", src.name, dst.name) return str(dst) def extract_head_frame(video_path: str, out_path: str, offset: float = 0.0) -> str: """抽取视频**第一帧**(用于把"上一镜的收尾"作为本镜的起手参考)""" src = Path(video_path) if not src.exists(): raise FileNotFoundError(f"视频不存在: {video_path}") dst = Path(out_path) dst.parent.mkdir(parents=True, exist_ok=True) cmd = [_ffmpeg(), "-y"] if offset: cmd += ["-ss", str(offset)] cmd += ["-i", str(src), "-frames:v", "1", "-q:v", "2", str(dst)] r = subprocess.run(cmd, capture_output=True, text=True, timeout=90) if r.returncode != 0 or not dst.exists() or dst.stat().st_size < 500: raise RuntimeError(f"抽首帧失败: {r.stderr.strip()[-160:]}") return str(dst)