"""视频抽帧节点。 按**帧间隔**从视频抽取帧:解析视频帧率后,帧间隔 step = round(间隔秒 × fps), 用 ffmpeg 的 select 过滤器按帧号(n mod step == 0)精确取帧——每帧都是真实 视频帧,帧时间 = 帧号 / fps,避免按时间 seek(-ss 秒)造成的取整漂移。 再按 crop 相对比例 [x,y,w,h](0~1)裁切出字幕区域,供 vlm-ocr 等下游节点 使用。产物为 frames.json 清单:[{"time": 帧时间秒, "image_uri": 帧图片路径}, ...]。 """ from __future__ import annotations import json import re import subprocess from pathlib import Path from nodes.ffmpeg import _ffmpeg_bin from wov_app.logging import get_logger from wov_sdk.models import InvokeRequest, InvokeResponse logger = get_logger("frame-extract") # 默认裁切:画面底部 18% 区域(常见字幕位置)。 DEFAULT_CROP = [0.0, 0.82, 1.0, 0.18] def _parse_crop(raw) -> list[float] | None: """解析并校验 crop 相对比例 [x,y,w,h](0~1 且不越出画面)。""" try: crop = [float(value) for value in raw] except (TypeError, ValueError): return None if len(crop) != 4: return None x, y, w, h = crop if not (0 <= x <= 1 and 0 <= y <= 1 and 0 <= w <= 1 and 0 <= h <= 1): return None if x + w > 1.001 or y + h > 1.001: return None return crop def _video_size(video: Path, ffmpeg_bin: str) -> tuple[int, int] | None: """从 ffmpeg -i 输出解析视频分辨率,避免依赖 ffprobe。""" result = subprocess.run( [ffmpeg_bin, "-i", str(video)], capture_output=True, text=True, ) for line in (result.stderr or "").splitlines(): if "Video:" not in line: continue match = re.search(r"(\d{2,5})x(\d{2,5})", line) if match: return int(match.group(1)), int(match.group(2)) return None def _video_duration(video: Path, ffmpeg_bin: str) -> float | None: """从 ffmpeg -i 输出解析总时长(秒)。""" result = subprocess.run( [ffmpeg_bin, "-i", str(video)], capture_output=True, text=True, ) match = re.search(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)", result.stderr or "") if match: hours = int(match.group(1)) minutes = int(match.group(2)) seconds = float(match.group(3)) return hours * 3600 + minutes * 60 + seconds return None def _video_fps(video: Path, ffmpeg_bin: str) -> float | None: """从 ffmpeg -i 输出解析帧率,支持小数(29.97 fps)与有理数(30000/1001 fps)。""" result = subprocess.run( [ffmpeg_bin, "-i", str(video)], capture_output=True, text=True, ) for line in (result.stderr or "").splitlines(): if "Video:" not in line: continue match = re.search(r"(\d+)/(\d+)\s*fps", line) if match: return int(match.group(1)) / int(match.group(2)) match = re.search(r"(\d+(?:\.\d+)?)\s*fps", line) if match: return float(match.group(1)) return None def _frame_step(fps: float, interval: float) -> int: """帧间隔换算:每 step 帧取一帧(step=round(间隔秒×fps),至少为 1)。""" return max(1, int(round(interval * fps))) def invoke(request: InvokeRequest) -> InvokeResponse: """按帧间隔抽取并裁切视频帧,输出 frames.json 清单。""" video_uri = request.inputs.get("video_uri") if not video_uri: return InvokeResponse(status="failed", error="video_uri is required") video = Path(video_uri) if not video.is_file(): return InvokeResponse(status="failed", error="video file not found") interval = float(request.params.get("interval_seconds", 0.5)) crop = _parse_crop(request.params.get("crop", DEFAULT_CROP)) if crop is None: return InvokeResponse(status="failed", error="invalid crop") if interval <= 0: return InvokeResponse(status="failed", error="invalid interval_seconds") ffmpeg_bin = _ffmpeg_bin() size = _video_size(video, ffmpeg_bin) if size is None: return InvokeResponse(status="failed", error="cannot read video size") duration = _video_duration(video, ffmpeg_bin) if duration is None or duration <= 0: return InvokeResponse(status="failed", error="cannot read video duration") fps = _video_fps(video, ffmpeg_bin) if fps is None or fps <= 0: return InvokeResponse(status="failed", error="cannot read video fps") # 把"每多少秒一帧"换算为"每多少帧取一帧",按帧号取帧是帧精确的。 step = _frame_step(fps, interval) width, height = size x_px = int(round(crop[0] * width)) y_px = int(round(crop[1] * height)) w_px = max(1, int(round(crop[2] * width))) h_px = max(1, int(round(crop[3] * height))) output_dir = Path(request.output_dir) frames_dir = output_dir / "frames" frames_dir.mkdir(parents=True, exist_ok=True) # 单次解码全片:select 按帧号(n mod step == 0)精确取帧,随后对选中帧 # 裁切字幕区域并压缩到 720p 内(仅缩小)——过大的输入图会触发 glm-ocr # 的重复循环 bug(M-RoPE delta),源头规避。 result = subprocess.run( [ ffmpeg_bin, "-y", "-hide_banner", "-loglevel", "error", "-i", str(video), "-vf", ( f"select='not(mod(n\\,{step}))'," f"crop={w_px}:{h_px}:{x_px}:{y_px}," "scale=1280:720:force_original_aspect_ratio=decrease:force_divisible_by=2" ), # 只写出被选中的帧,避免 CFR 补帧产生重复文件。 "-vsync", "vfr", str(frames_dir / "frame_%04d.png"), ], capture_output=True, text=True, ) if result.returncode != 0: return InvokeResponse(status="failed", error=result.stderr[-500:] or "ffmpeg failed") # 第 k 个输出文件对应原始帧号 k×step,时间 = 帧号 / fps(帧精确,无累计偏差)。 files = sorted(frames_dir.glob("frame_*.png")) manifest = [ {"time": round((index * step) / fps, 3), "image_uri": str(path)} for index, path in enumerate(files) ] manifest_path = output_dir / "frames.json" manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") logger.info("抽帧完成: %d 帧, 帧间隔 %d, 裁切 %dx%d+%d+%d", len(files), step, w_px, h_px, x_px, y_px) return InvokeResponse( status="completed", outputs={"frames_manifest": str(manifest_path), "frame_count": len(files)}, )