Files
vrsub/nodes/frame_extract.py
cat-shark 7a7212f70c docs: 注释规范要求精简可读,并清理生产代码中的历史叙事
AGENTS.md 的注释规范新增三节可执行约束:

- 只写代码真实逻辑:注释只回答"做什么"与"为什么必须这么做",禁止写决策/
  修改时间、历史版本对比、实测数据与实验结论、事故与缺陷编号(run_xxxx /
  batch_xxxx / R01 等)——这些属 docs/decisions.md 与审查跟踪文件;当前生效
  的约束可以写,但不附带它何时因何变成这样。
- 精简可读:单段连续注释不超过 3 行;docstring 一句话概括职责,不重复函数名
  已表达的信息;不写逐行翻译代码的废话注释,只在非显然处(业务规则、边界、
  易错点、外部约束)加注。
- 覆盖范围:测试注释只说明验证什么行为,回归用例可保留一句溯源;并明确
  参数说明应写在**参数读取处**附近,而不是把多个参数的解释堆在离使用位置
  很远的注释块里。

按此清理生产代码(注释净减 70 行,18 个文件),典型处理:

- nodes/whisper.py:删掉堆在一起、含"用户 2026-08 决定 / 实测 savr-1054"
  等叙事的参数块,把各参数说明移到各自的读取处与 model.transcribe 调用处;
- nodes/llm_filter.py、nodes/subtitle_cleanup.py:模块 docstring 去掉英文
  背景叙事与条数统计,保留"默认只跑规则层""整条删除而非 '-' 占位"等当前
  行为;
- src/wov_app/{batch,db,scheduler}.py 与 routers:去掉 batch_xxx/run_xxx 事故
  编号与"修复前……"对比,改为一句"否则会出现什么问题";
- nodes/ass.py、frame_extract.py:去掉废弃值对比与日期,保留判据本身。

安全验证:用 AST 对比(剥离 docstring 后比较语法树)确认 18 个文件**零逻辑
变更**;`nodes/proper_nouns.py` 的规则表 reason 字段会注入 LLM 提示词,属于
数据而非注释,已恢复原值。全量测试 476 passed。
2026-09-13 16:37:49 +08:00

236 lines
9.0 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.
"""视频抽帧节点。
按**帧间隔**从视频抽取帧:解析视频帧率后,帧间隔 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
import time
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")
# 默认裁切:画面底部 1/4 区域(字幕区,字幕很少出现在画面上半部分)。
DEFAULT_CROP = [0.0, 0.75, 1.0, 0.25]
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 _parse_progress_line(line: str) -> int | None:
"""解析 ffmpeg -progress 输出行,返回 frame=N 的数值;其他行返回 None。
-progress 每 ~0.5s 输出一组 key=valueframe/fps/out_time_ms/progress 等),
这里只关心 frame=(已写出的选中帧数),用于实时进度与速度统计。
"""
stripped = line.strip()
if not stripped.startswith("frame="):
return None
try:
return int(stripped.split("=", 1)[1])
except ValueError:
return None
def _sorted_frame_files(frames_dir: Path) -> list[Path]:
"""按文件名中的帧号数值排序返回帧文件列表(自然排序,非字典序)。
ffmpeg 的 %04d 编号超过 9999 帧后会扩为 5 位,字典序会把 5 位编号排在
4 位之前("frame_10009" < "frame_1009"),导致帧号回退、帧时间与图像
错位。必须解析帧号按数值排序,才能保证“第 k 个文件 = 第 k 个选中帧 =
时间 index*step/fps”成立。
"""
def frame_number(path: Path) -> int:
# 文件名形如 frame_0001.png,取下划线后的数字部分。
return int(path.stem.split("_", 1)[1])
return sorted(frames_dir.glob("frame_*.png"), key=frame_number)
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
# 的重复循环 bugM-RoPE delta),源头规避。
# -progress 把 frame=N 进度写入管道,实时打印抽帧进度与速度。
expected_frames = (int(duration * fps) + step - 1) // step
process = subprocess.Popen(
[
ffmpeg_bin,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-nostats",
"-progress",
"pipe:1",
"-i",
str(video),
"-vf",
(
f"select='not(mod(n\\,{step}))',"
f"crop={w_px}:{h_px}:{x_px}:{y_px},"
# force_divisible_by 仅在较新 ffmpeg 中可用;PNG 抽帧不要求偶数尺寸,
# 保留等比缩小即可兼容系统版 ffmpeg 4.x。
"scale=1280:720:force_original_aspect_ratio=decrease"
),
# 只写出被选中的帧,避免 CFR 补帧产生重复文件。
"-vsync",
"vfr",
str(frames_dir / "frame_%04d.png"),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
extract_started = time.monotonic()
last_logged = 0
progress_step = max(1, expected_frames // 10)
for line in process.stdout:
done = _parse_progress_line(line)
if done is None:
continue
# 每写出约 10 个检查点打一次日志,避免刷屏;同时给出处理速度。
if done - last_logged >= progress_step:
elapsed = time.monotonic() - extract_started
logger.info(
"抽帧进度 %d/%d 帧 (%.1f 帧/s)",
min(done, expected_frames), expected_frames,
done / elapsed if elapsed > 0 else 0.0,
)
last_logged = done
process.wait()
stderr = process.stderr.read()
if process.returncode != 0:
return InvokeResponse(status="failed", error=stderr[-500:] or "ffmpeg failed")
# 第 k 个输出文件对应原始帧号 k×step,时间 = 帧号 / fps(帧精确,无累计偏差)。
files = _sorted_frame_files(frames_dir)
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)},
)