Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ This section will list available skills as they are added.
| [cn-punctuation-checker](skills/cn-punctuation-checker/SKILL.md) | Checks Chinese text for incorrect English punctuation marks and supports batch fixing. | Chinese Copy Editing, Punctuation Correction | Stable |
| [wechat-mini-program-development](skills/wechat-mini-program-development/SKILL.md) | WeChat mini-program development skill with standard project structure, request wrapper, and API management. | WeChat Mini-Program Development, Project Scaffolding | Stable |
| [kz-article-deep-analysis](skills/kz-article-deep-analysis/SKILL.md) | Deeply interpret non-academic articles (blogs, essays, commentary) and output a structured analysis report (core issue, thesis, argument map, cognitive gains). | Reading, Article Analysis | Stable |
| [video-to-keyframes](skills/video-to-keyframes/SKILL.md) | Extracts video frames, detects cuts/segments, selects candidate keyframes, and generates review HTML galleries. | Video Analysis, Keyframe Selection, Storyboard Screening | Stable |
| [trae-claw-install](skills/trae-claw-install/SKILL.md) | Repository-driven OpenClaw deployment workflow with platform routing, acceptance checks, and unified troubleshooting steps. | OpenClaw Deployment, DevOps Workflow, Troubleshooting | Stable |


Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ description: 简要描述这个技能的功能和使用场景
| [cn-punctuation-checker](skills/cn-punctuation-checker/SKILL.md) | 检查中文文案中错误使用的英文标点符号,并支持批量修复。 | 中文文案润色, 标点纠错 | Stable |
| [wechat-mini-program-development](skills/wechat-mini-program-development/SKILL.md) | 微信小程序开发专用技能,提供标准项目结构、请求封装和 API 管理。 | 微信小程序开发, 项目脚手架 | Stable |
| [kz-article-deep-analysis](skills/kz-article-deep-analysis/SKILL.md) | 深度解读非学术类文章(博客、随笔、评论),输出结构化分析报告(核心议题、核心主张、论证拓扑、认知增量)。 | 深度阅读, 文章分析 | Stable |
| [video-to-keyframes](skills/video-to-keyframes/SKILL.md) | 抽取视频帧、检测转场与分段、筛选候选关键帧,并生成可复筛的 HTML 画廊。 | 视频分析, 关键帧筛选, 分镜初筛 | Stable |

> 提示:要把你的技能加入此目录,请在 PR 中更新此表格。

Expand Down
57 changes: 57 additions & 0 deletions skills/video-to-keyframes/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
name: "video-to-keyframes"
description: "Extracts video frames, detects cuts/segments, selects candidate keyframes, and generates review HTML galleries. Invoke when users ask for keyframes/cuts/segmentation/storyboard screening."
---

# 视频转关键帧(video-to-keyframes)

把用户提供的视频转成“候选帧池 → 转场/分段 → 候选关键帧集 → 复筛画廊页”,并把产物落盘到当天文件夹,方便后续分镜与生成。

## 何时调用

- 用户提供视频并说:抽帧/拆帧/关键帧/候选关键帧/镜头拆分/转场点/分段/分镜初筛
- 用户希望按固定工作流落盘,需要可复现的目录与文件(frames.json、cuts.json、segments.json、gallery.html 等)

## 依赖

- Python 3.10+
- numpy
- opencv-python

## 输入

- 视频文件路径(必填)
- 当天文件夹路径(可选,默认用视频所在目录;推荐 YYYY-MM-DD)
- 抽帧间隔(建议:30s≈1fps;变化快≈2fps)

## 输出(固定规范)

在 `<当天文件夹>` 下生成:

- `<当天文件夹>\_frames_<视频名>_<间隔>\`:候选帧池目录
- `f_*.jpg`:抽帧图片
- `frames.csv / frames.json / top_keep.json / meta.json`
- `\_keyframe_candidates\`:候选关键帧集目录
- `cuts.json`:转场点
- `segments.json`:分段与每段代表帧
- `segments_gallery.html`:分段可视化(每段1张代表帧)
- `gallery.html`:候选关键帧画廊(逐个复筛)
- `candidates.csv / candidates.json`
- `selected.txt`:人工/AI复筛后的最终候选ID(每行一个 cand_id)
- `prompt_pack.html`:复筛+提示词协作页(夜间模式,一键复制)
- `<当天文件夹>\<视频名>_拆分.txt`:汇总(转场点、分段、每段代表帧文件名)

## 一键运行(推荐)

```powershell
python .\skills\video-to-keyframes\resources\scripts\run_video_workflow.py "<视频路径>" --day-folder "<当天文件夹>" --every-seconds 0.5 --max-frames 600
```

注意:一键运行只负责产出文件,不等于完成复筛;必须打开 `gallery.html` 做人工/AI语义复筛,并把最终选择写入 `selected.txt`。

## 复筛要点(简版)

- 先看 `segments_gallery.html`:确认每段代表帧是否合理、分段是否过碎
- 再看 `gallery.html`:挑 6-12 张最“代表内容且可复现”的帧(不要只挑清晰但信息弱的帧)
- 将 cand_id 写入 `selected.txt`(每行一个三位数字或逗号分隔均可)

2 changes: 2 additions & 0 deletions skills/video-to-keyframes/resources/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
numpy
opencv-python
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import argparse
import csv
import json
import math
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any

import cv2
import numpy as np


@dataclass
class FrameInfo:
index: int
timestamp_s: float
file: str
width: int
height: int
sharpness: float
brightness: float
contrast: float
saturation: float
motion: float | None
suggested_keep: bool
description: str


def _ensure_dir(p: Path) -> None:
p.mkdir(parents=True, exist_ok=True)


def _save_image(out_dir: Path, stem: str, frame, jpeg_params: list[int]) -> Path:
out_file = out_dir / f"{stem}.jpg"
ok, buf = cv2.imencode(".jpg", frame, jpeg_params)
if not ok:
out_file = out_dir / f"{stem}.png"
ok, buf = cv2.imencode(".png", frame)
if not ok:
raise SystemExit("写入图片失败")
data = np.asarray(buf).tobytes()
out_file.write_bytes(data)
return out_file


def _fmt_ts(seconds: float) -> str:
if seconds < 0:
seconds = 0
ms = int(round((seconds - int(seconds)) * 1000))
s = int(seconds) % 60
m = (int(seconds) // 60) % 60
h = int(seconds) // 3600
return f"{h:02d}-{m:02d}-{s:02d}-{ms:03d}"


def _lap_var(gray) -> float:
return float(cv2.Laplacian(gray, cv2.CV_64F).var())


def _mean_std(gray) -> tuple[float, float]:
m, s = cv2.meanStdDev(gray)
return float(m[0][0]), float(s[0][0])


def _mean_saturation(bgr) -> float:
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
s = hsv[:, :, 1]
return float(s.mean())


def _motion_score(prev_gray, gray) -> float:
diff = cv2.absdiff(prev_gray, gray)
return float(diff.mean())


def _desc(brightness: float, contrast: float, sharpness: float, saturation: float, motion: float | None) -> str:
parts: list[str] = []

if brightness < 70:
parts.append("偏暗")
elif brightness > 180:
parts.append("偏亮")
else:
parts.append("曝光正常")

if contrast < 25:
parts.append("对比偏低")
elif contrast > 70:
parts.append("对比偏高")
else:
parts.append("对比适中")

if sharpness < 60:
parts.append("偏糊")
elif sharpness > 200:
parts.append("很清晰")
else:
parts.append("清晰度正常")

if saturation < 40:
parts.append("色彩寡淡")
elif saturation > 140:
parts.append("色彩浓烈")
else:
parts.append("色彩适中")

if motion is not None:
if motion < 2.0:
parts.append("画面稳定")
elif motion > 10.0:
parts.append("运动幅度大")
else:
parts.append("有一定运动")

return ",".join(parts)


def _bool_keep(sharpness: float, brightness: float, contrast: float, min_sharpness: float, bmin: float, bmax: float) -> bool:
if sharpness < min_sharpness:
return False
if brightness < bmin or brightness > bmax:
return False
if contrast < 10:
return False
return True


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("video", help="视频路径")
parser.add_argument("--out", default=None, help="输出目录,默认在视频同目录下创建 _frames_<文件名>")
parser.add_argument("--every-seconds", type=float, default=0.5, help="按时间间隔抽帧")
parser.add_argument("--max-frames", type=int, default=600, help="最多抽取帧数,防止视频过长")
parser.add_argument("--start", type=float, default=0.0, help="起始秒")
parser.add_argument("--end", type=float, default=None, help="结束秒(不含)")
parser.add_argument("--jpeg-quality", type=int, default=92, help="JPEG质量 0-100")
parser.add_argument("--min-sharpness", type=float, default=80.0, help="最小清晰度阈值(拉普拉斯方差)")
parser.add_argument("--brightness-min", type=float, default=60.0, help="亮度下限")
parser.add_argument("--brightness-max", type=float, default=200.0, help="亮度上限")
args = parser.parse_args()

video_path = Path(args.video).expanduser().resolve()
if not video_path.exists():
raise SystemExit(f"视频不存在:{video_path}")

out_dir = Path(args.out).expanduser().resolve() if args.out else (video_path.parent / f"_frames_{video_path.stem}")
_ensure_dir(out_dir)

cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
raise SystemExit("无法打开视频")

fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
duration_s = (frame_count / fps) if fps > 0 else 0.0

start_s = max(float(args.start), 0.0)
end_s = float(args.end) if args.end is not None else duration_s
end_s = min(end_s, duration_s) if duration_s > 0 else end_s
if end_s <= start_s:
raise SystemExit("end 必须大于 start")

step_s = max(float(args.every_seconds), 0.05)
target_ts = start_s
extracted = 0
prev_gray = None
rows: list[FrameInfo] = []

jpeg_params = [int(cv2.IMWRITE_JPEG_QUALITY), int(max(0, min(100, args.jpeg_quality)))]

while extracted < int(args.max_frames) and target_ts < end_s:
if fps > 0:
cap.set(cv2.CAP_PROP_POS_MSEC, target_ts * 1000.0)
ret, frame = cap.read()
if not ret or frame is None:
break

gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
sharpness = _lap_var(gray)
brightness, contrast = _mean_std(gray)
saturation = _mean_saturation(frame)
motion = _motion_score(prev_gray, gray) if prev_gray is not None else None

keep = _bool_keep(
sharpness=sharpness,
brightness=brightness,
contrast=contrast,
min_sharpness=float(args.min_sharpness),
bmin=float(args.brightness_min),
bmax=float(args.brightness_max),
)
description = _desc(brightness, contrast, sharpness, saturation, motion)

stem = f"f_{extracted:05d}_t{_fmt_ts(target_ts)}"
out_file = _save_image(out_dir, stem, frame, jpeg_params)

rows.append(
FrameInfo(
index=extracted,
timestamp_s=float(target_ts),
file=str(out_file),
width=width,
height=height,
sharpness=sharpness,
brightness=brightness,
contrast=contrast,
saturation=saturation,
motion=motion,
suggested_keep=bool(keep),
description=description,
)
)

prev_gray = gray
extracted += 1
target_ts += step_s

cap.release()

meta: dict[str, Any] = {
"video": str(video_path),
"fps": fps,
"frame_count": frame_count,
"width": width,
"height": height,
"duration_s": duration_s,
"start_s": start_s,
"end_s": end_s,
"every_seconds": step_s,
"max_frames": int(args.max_frames),
"extracted": extracted,
"out_dir": str(out_dir),
"columns": [f.name for f in FrameInfo.__dataclass_fields__.values()],
}

(out_dir / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
(out_dir / "frames.json").write_text(
json.dumps([asdict(r) for r in rows], ensure_ascii=False, indent=2), encoding="utf-8"
)

with (out_dir / "frames.csv").open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=meta["columns"])
w.writeheader()
for r in rows:
w.writerow(asdict(r))

kept = [r for r in rows if r.suggested_keep]
kept_sorted = sorted(kept, key=lambda r: (r.sharpness, -abs(128 - r.brightness), r.contrast), reverse=True)
top = kept_sorted[: min(30, len(kept_sorted))]
(out_dir / "top_keep.json").write_text(json.dumps([asdict(r) for r in top], ensure_ascii=False, indent=2), encoding="utf-8")

print(str(out_dir))


if __name__ == "__main__":
main()

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import argparse
from datetime import datetime
from pathlib import Path


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("base_dir", nargs="?", default=".")
parser.add_argument("--format", default="%Y-%m-%d")
args = parser.parse_args()

base_dir = Path(args.base_dir).expanduser().resolve()
folder_name = datetime.now().strftime(args.format)
target_dir = base_dir / folder_name
target_dir.mkdir(parents=True, exist_ok=True)
print(str(target_dir))


if __name__ == "__main__":
main()
Loading
Loading