Files
contentm_agent/extract_frames_v2.py
WorkBuddy df56c2c137 初始化提交:contentm_agent 工作区全量快照
内容分四块:
1、产品规划产出 —— MCN 短视频整合营销工作台的①段五份(1a 需求/1b 竞品/1c 画像/1d 策略/1e 场景)、②段两份(2a 功能/2b 布局)、③段界面(DESIGN.md 契约与令牌表 + mcn-workbench.html 原型 + 实测/会诊/审查三份 + 23 张闸门截图)。
2、开源竞品调研 —— 5 个内容工作台项目的取证原始件与 1b 系列分析文档。
3、参考资料 —— 竞品视频抽帧 1145 张 + 2 个源视频 + 功能点截图。
4、机制侧 —— 协作脚本与状态台账、工作区记忆日志、抽帧/OCR 脚本。

.gitignore 只排运行时日志、脚本备份副本与一次性探针输出,其余按原样入库。
2026-10-08 08:13:02 +08:00

136 lines
5.2 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.
# -*- coding: utf-8 -*-
"""按《视频抽帧与字幕规范》严格重抽关键帧。
与 v1 的差异:
- 字幕区 scene 检测严格使用底部 15%(B 站 1920x1080 -> 162px;按比例适配其他分辨率)。
- 新增字幕区低阈值 0.05 作为安全网,防止缓变换句被漏检。
- 输出 frame_times.json,记录每帧来自哪种检测(便于核对)。
- 保留首帧 0s、尾帧 DUR-0.3s、>5s 长间隔补中点的规范步骤。
用法:
python extract_frames_v2.py --dir <视频目录> --out <帧输出目录> --ff <ffmpeg bin 目录>
"""
import argparse, glob, json, os, re, subprocess, sys
def run(cmd):
return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
def probe(video, ffprobe):
r = run([ffprobe, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height", "-show_entries", "format=duration",
"-of", "default=nk=1:nw=1", video])
vals = [x.strip() for x in r.stdout.split("\n") if x.strip()]
return int(vals[0]), int(vals[1]), float(vals[2])
def scene_times(video, vf, ffmpeg):
"""跑一次 scene 检测并返回所有 pts_time(秒)。"""
r = run([ffmpeg, "-i", video, "-vf", vf + ",showinfo", "-vsync", "vfr", "-f", "null", "-"])
out = set()
for line in r.stderr.splitlines():
m = re.search(r"pts_time:([0-9.]+)", line)
if m:
out.add(round(float(m.group(1)), 4))
return sorted(out)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dir", required=True, help="含 .mp4 的目录")
ap.add_argument("--out", required=True, help="帧输出目录")
ap.add_argument("--ff", default=r"D:\ComfyUI_windows\ffmpeg-8.0.1-full_build\bin",
help="ffmpeg/ffprobe 所在 bin 目录")
args = ap.parse_args()
ffmpeg = os.path.join(args.ff, "ffmpeg.exe")
ffprobe = os.path.join(args.ff, "ffprobe.exe")
vids = sorted(glob.glob(os.path.join(args.dir, "*.mp4")))
if not vids:
print("✗ 目录里没有 .mp4:%s" % args.dir)
sys.exit(1)
video = vids[0]
print("视频:%s" % video)
w, h, dur = probe(video, ffprobe)
print("分辨率=%dx%d 时长=%.3fs" % (w, h, dur))
# 字幕区用于 scene 检测:底部 15%(规范参考值)
sub_h = max(1, int(round(h * 0.15)))
sub_y = h - sub_h
crop_vf = "crop=%d:%d:0:%d" % (w, sub_h, sub_y)
print("字幕区裁剪(检测用):%s" % crop_vf)
# ① 画面变化
t_scene_03 = scene_times(video, "select='gt(scene,0.3)'", ffmpeg)
# ② 字幕变化(规范核心)
t_sub_01 = scene_times(video, "%s,select='gt(scene,0.1)'" % crop_vf, ffmpeg)
# ③ 低阈值补漏(规范标准步骤)
t_scene_005 = scene_times(video, "select='gt(scene,0.05)'", ffmpeg)
# 额外安全网:字幕区低阈值,捕获缓变换句
t_sub_005 = scene_times(video, "%s,select='gt(scene,0.05)'" % crop_vf, ffmpeg)
print("scene 0.3=%d 字幕区 0.1=%d 全局 0.05=%d 字幕区 0.05=%d"
% (len(t_scene_03), len(t_sub_01), len(t_scene_005), len(t_sub_005)))
# 合并 + 首尾
all_times = sorted(set(t_scene_03) | set(t_sub_01) | set(t_scene_005) | set(t_sub_005))
all_times.append(0.0)
all_times.append(max(0.0, round(dur - 0.3, 3)))
all_times = sorted(set(all_times))
# >5s 长间隔补中点
filled = []
prev = None
for t in all_times:
if prev is not None and (t - prev) > 5:
filled.append(round((t + prev) / 2, 3))
filled.append(t)
prev = t
times = sorted(set(filled))
print("最终帧数(含首尾+补帧):%d" % len(times))
# 记录来源(用于调试/核对)
source_map = {}
for t in times:
tags = []
if t == 0.0: tags.append("start")
if abs(t - (dur - 0.3)) < 0.001: tags.append("end")
if t in t_scene_03: tags.append("scene03")
if t in t_sub_01: tags.append("sub01")
if t in t_scene_005: tags.append("scene005")
if t in t_sub_005: tags.append("sub005")
if "start" not in tags and "end" not in tags and all(t not in s for s in (t_scene_03, t_sub_01, t_scene_005, t_sub_005)):
tags.append("fill")
source_map["%.3f" % t] = tags
os.makedirs(args.out, exist_ok=True)
for i, t in enumerate(times, 1):
tt = t if t <= dur else max(0.0, dur - 0.3)
outp = os.path.join(args.out, "c_%04d_%.3fs.jpg" % (i, t))
r = run([ffmpeg, "-y", "-ss", "%.3f" % tt, "-i", video,
"-frames:v", "1", "-q:v", "2", outp])
if r.returncode != 0:
print(" ✗ 帧 %d 失败:%s" % (i, r.stderr[-200:]))
meta = {
"video": video, "width": w, "height": h, "duration": dur,
"subtitle_crop": crop_vf,
"counts": {
"scene_0.3": len(t_scene_03), "sub_0.1": len(t_sub_01),
"scene_0.05": len(t_scene_005), "sub_0.05": len(t_sub_005),
"final": len(times)
},
"frame_times": source_map
}
with open(os.path.join(args.out, "frame_times.json"), "w", encoding="utf-8", newline="\n") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
print("✓ 抽帧完成 → %s" % args.out)
if __name__ == "__main__":
main()