video-capture:入仓(加入 .gitignore 白名单)—— 取视频 + 抽帧的全局技能

- 用户 2026-10-12 令「可以加入」。
- 依据:product-planning 第③段的交接说明已把它当**依赖**指向 ⇒ 仓里有引用、仓里就该有实体,
  ⛔ 否则从本仓还原到别的机器会拿到一个「指向装不出来的技能」的悬空引用。
- 收录 5 个:SKILL.md、scripts/extract_frames.py、scripts/fetch_video.py、
  references/视频抽帧与字幕规范.md、ATTRIBUTION.md。
- 目录内 env/ 与 logs/ 仍按本机配置规则挡在仓外(各机器自建)。
This commit is contained in:
admin committed 2026-10-12 09:56:59 +08:00
1 parent 2666a611b1
commit 051482168f
6 files changed
+567

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
video-capture · 抽帧(双因素 + 首尾 + 补帧)
铁律来源:aigc-idea-impression 的《视频抽帧与字幕规范》(2026-08-30 实测沉淀)。
算法:① 画面 scene 0.3 ② 字幕区 crop 后 scene 0.1(漏这条 = 画面静止但字幕换句的帧全丢)
③ 低阈值 scene 0.05 ④ 首帧/尾帧 ⑤ 相邻帧间隔 >5s 补中点;逐点 -ss 抽帧(禁 eq(t))。
依赖:ffmpeg / ffprobe(本机已就绪)。仅 Python 标准库。
用法:
python extract_frames.py --video a.mp4 --out frames/ [--max-gap 5] [--crop "720:240:0:720"] [--thresholds 0.3,0.1,0.05]
"""
import argparse
import json
import os
import re
import subprocess
import sys
def run(cmd):
return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
def ffprobe_meta(video):
r = run([
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-show_entries", "stream=codec_type,width,height",
"-of", "json", video,
])
info = {"duration": 0.0, "width": 0, "height": 0}
try:
j = json.loads(r.stdout or "{}")
info["duration"] = float((j.get("format") or {}).get("duration") or 0.0)
for s in (j.get("streams") or []):
if s.get("codec_type") == "video":
info["width"] = int(s.get("width") or 0)
info["height"] = int(s.get("height") or 0)
break
except Exception:
pass
return info
FPS_MODE_ARGS = ("-fps_mode", "vfr") # ffmpeg ≥ 5.1 的新写法(9.x 已移除 -vsync)
LEGACY_FPS_ARGS = ("-vsync", "vfr") # 老 ffmpeg 的写法,兜底用
def _scene_cmd(video, vf, fps_args):
return ["ffmpeg", "-hide_banner", "-i", video, "-vf", vf, *fps_args, "-f", "null", "-"]
def scene_times(video, vf, label):
"""跑一次 scene 检测,返回时间点列表。
🔴 2026-10-11 修(实测踩到):本机 ffmpeg 9.0.1 **已移除 `-vsync`**
⇒ 原来的 `-vsync vfr` 写法让三路 scene 检测**全部失败**、整支视频只出 3 帧
(当时执行会话只能另存一份「逐字同算法的本地副本」才跑通 ⇒ 两套口径,正是要避免的)。
⇒ 现在优先用 `-fps_mode vfr`,没跑出 `pts_time` 再退回 `-vsync vfr`,新旧 ffmpeg 都能跑。
"""
r = run(_scene_cmd(video, vf, FPS_MODE_ARGS))
out = (r.stderr or "") + (r.stdout or "")
if "pts_time:" not in out: # 新写法没跑出来 ⇒ 退回老写法
r2 = run(_scene_cmd(video, vf, LEGACY_FPS_ARGS))
out2 = (r2.stderr or "") + (r2.stdout or "")
if "pts_time:" in out2:
out = out2
times = [float(t) for t in re.findall(r"pts_time:([0-9.]+)", out)]
times = sorted(set(t for t in times if t >= 0))
return times
def default_subtitle_crop(w, h):
"""字幕区默认取底部 15%。"""
if w <= 0 or h <= 0:
return None
ch = max(1, int(h * 0.15))
y = h - ch
return f"crop={w}:{ch}:0:{y}"
def extract(video, out_dir, max_gap, crop, thresholds):
meta = ffprobe_meta(video)
dur = meta["duration"]
if dur <= 0:
print(json.dumps({"ok": False, "error": "无法读取视频时长(文件损坏或非视频)"}), file=sys.stderr)
sys.exit(2)
t_pic, t_sub, t_low = thresholds
times = set()
# ① 画面变化
times.update(scene_times(video, f"select='gt(scene,{t_pic})',showinfo", "pic"))
# ② 字幕区变化(crop 后检测)—— 漏这条会丢"画面静止但字幕换句"
sub_crop = crop or default_subtitle_crop(meta["width"], meta["height"])
if sub_crop:
times.update(scene_times(video, f"{sub_crop},select='gt(scene,{t_sub})',showinfo", "sub"))
# ③ 低阈值补漏
times.update(scene_times(video, f"select='gt(scene,{t_low})',showinfo", "low"))
# ④ 首尾帧
times.add(0.0)
times.add(max(0.0, dur - 0.3))
ordered = sorted(times)
# ⑤ 长间隔补中点
with_gap = []
prev = None
for t in ordered:
if prev is not None and (t - prev) > max_gap:
with_gap.append((t + prev) / 2.0)
with_gap.append(t)
prev = t
ordered = sorted(set(round(t, 3) for t in (ordered + with_gap)))
os.makedirs(out_dir, exist_ok=True)
frames = []
for i, t in enumerate(ordered, 1):
t = min(t, max(0.0, dur - 0.05))
name = f"c_{i:04d}_{t}s.jpg"
path = os.path.join(out_dir, name)
run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-ss", f"{t}", "-i", video, "-frames:v", "1", "-q:v", "2", path,
])
frames.append({"index": i, "time": t, "file": name})
manifest = {
"video": os.path.abspath(video),
"duration": round(dur, 3),
"resolution": f'{meta["width"]}x{meta["height"]}',
"subtitleCrop": sub_crop,
"thresholds": {"picture": t_pic, "subtitle": t_sub, "low": t_low},
"maxGap": max_gap,
"frameCount": len(frames),
"frames": frames,
}
with open(os.path.join(out_dir, "frame_times.json"), "w", encoding="utf-8", newline="\n") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
return manifest
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--video", required=True)
ap.add_argument("--out", default="frames")
ap.add_argument("--max-gap", type=float, default=5.0)
ap.add_argument("--crop", default="", help='字幕区裁剪,如 "720:240:0:720";缺省按分辨率底部 15%')
ap.add_argument("--thresholds", default="0.3,0.1,0.05", help="画面,字幕,低阈值")
args = ap.parse_args()
if not os.path.exists(args.video):
print(json.dumps({"ok": False, "error": f"找不到视频:{args.video}"}), file=sys.stderr)
sys.exit(2)
try:
t = [float(x) for x in args.thresholds.split(",")]
assert len(t) == 3
except Exception:
print("thresholds 需为 3 个数值,如 0.3,0.1,0.05", file=sys.stderr)
sys.exit(2)
m = extract(args.video, args.out, args.max_gap, args.crop.strip(), t)
print(json.dumps({
"ok": True, "out": os.path.abspath(args.out),
"frameCount": m["frameCount"], "duration": m["duration"],
"resolution": m["resolution"], "subtitleCrop": m["subtitleCrop"],
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
video-capture · 取视频(最小、合规集)
只做两件事,用于把「一个视频」落到本地待抽帧:
1) 本地文件:直接登记(--file)
2) 直链媒体 URL:下载(--url,需 .mp4/.mov/.webm/.m4v 等直链)
⛔ 不做:平台页内抓取(抖音/B站/YouTube 等页面解析)、⛔ 不调任何付费接口。
—— 如需平台视频,请先用浏览器工具把视频下到本地,再用 --file 传入(本工具不代抓)。
依赖:ffprobe(校验)+ Python 标准库。可选 curl(更快)。
用法:
python fetch_video.py --file D:/a.mp4 --out ./video
python fetch_video.py --url https://host/x.mp4 --out ./video
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import urllib.request
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0 Safari/537.36"
def probe(path):
try:
r = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-show_entries", "stream=codec_type,width,height", "-of", "json", path],
capture_output=True, text=True, encoding="utf-8", errors="replace")
j = json.loads(r.stdout or "{}")
dur = float((j.get("format") or {}).get("duration") or 0)
w = h = 0
for s in (j.get("streams") or []):
if s.get("codec_type") == "video":
w, h = int(s.get("width") or 0), int(s.get("height") or 0)
break
return {"duration": round(dur, 3), "resolution": f"{w}x{h}"}
except Exception:
return {}
def download(url, dest):
req = urllib.request.Request(url, headers={"User-Agent": UA, "Referer": url})
with urllib.request.urlopen(req, timeout=90) as r, open(dest, "wb") as f:
shutil.copyfileobj(r, f)
def main():
ap = argparse.ArgumentParser()
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--file", help="本地视频路径")
g.add_argument("--url", help="直链媒体 URL(.mp4/.mov/.webm/.m4v)")
ap.add_argument("--out", default="video", help="输出目录")
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
if args.file:
if not os.path.exists(args.file):
print(json.dumps({"ok": False, "error": f"找不到文件:{args.file}"}), file=sys.stderr)
sys.exit(2)
dest = os.path.join(args.out, os.path.basename(args.file))
if os.path.abspath(dest) != os.path.abspath(args.file):
shutil.copy2(args.file, dest)
else:
if not os.path.splitext(args.url.split("?")[0])[1].lower() in (".mp4", ".mov", ".webm", ".m4v", ".mkv"):
print(json.dumps({"ok": False, "error": "仅支持直链媒体 URL(.mp4/.mov/.webm/.m4v/.mkv);平台页内抓取不在本工具范围"}), file=sys.stderr)
sys.exit(2)
dest = os.path.join(args.out, "video" + os.path.splitext(args.url.split("?")[0])[1].lower())
try:
download(args.url, dest)
except Exception as e:
print(json.dumps({"ok": False, "error": f"下载失败:{e}"}), file=sys.stderr)
sys.exit(1)
meta = probe(dest)
print(json.dumps({"ok": True, "video": os.path.abspath(dest), **meta}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()