One video. A frame every X seconds.
A complete reusable skill that turns a local video into timestamped PNG screenshots, ready for another workflow to consume. It does not summarize the video or decide what matters in it.
How the files cooperate
SKILL.md tells the agent when to use this capability, validates its inputs, and explains the method. extract.py probes the video and extracts frames. test_extract.py creates real test media and checks output, filenames, failure cases, and overwrite protection.
Install and use
Put these three files together in <project>/.kcode/skills/video-frames/. Install Python 3, FFmpeg, and FFprobe. Ask the agent to extract frames from a video, specifying an interval and a new output directory.
The agent invokes run_skill with video, interval_seconds, and output_dir, reads the returned guide, and runs the bundled script through its normal shell tool. Shell permissions still apply. The manifest lists each image and timestamp for the next step.
Test it yourself
python3 -B test_extract.pyWhat kcode supplies, and what it does not
The existing loader discovers the bundle, validates the input schema, prepares a state directory, and substitutes the bundle path into the guide. The current agent drives the method; this is not a separate agent or an automatic workflow engine.
This example deliberately uses the guide-driven path. Executable Lua skills receive turn-bound host ports, including permission-checked tool calls and cancellation. This guide-driven example does not use an executable entrypoint. Guide-driven skills are advisory: kcode validates declared inputs and supplies the guide, but does not hold an execution lock or automatically validate the final result. Execution locks and automatic output-schema validation apply only to executable skills. This skill avoids shared output by requiring a fresh directory per run.
video-frames/SKILL.md
---
name: video-frames
description: Extract timestamped screenshots from a video at a fixed interval for summarization, visual QA, or storyboarding.
tool-dependencies: [bash]
parallelizable: true
input_schema:
type: object
required: [video, interval_seconds, output_dir]
properties:
video: {type: string, minLength: 1}
interval_seconds: {type: number, exclusiveMinimum: 0}
output_dir: {type: string, minLength: 1}
---
# Extract video frames
Produce one reusable result: screenshots at 0, X, 2X, … seconds, with a JSON manifest linking each timestamp to its image. Use this when a workflow needs still images from a video; do not summarize, classify, or edit the video here.
## Requirements
Python 3, ffmpeg, and ffprobe must be installed. The input must be a local video. Choose a new output directory inside the project; existing directories are refused so repeated runs cannot overwrite results. Separate output directories make concurrent runs safe.
## Run
Use the session's bash tool to execute the bundled script. Quote every argument as a shell token; never interpolate unquoted user input.
```sh
python3 "${CLAUDE_SKILL_DIR}/extract.py" --video "<video>" --interval "<interval_seconds>" --output "<output_dir>"
```
The script uses subprocess argument arrays, not a shell, for FFmpeg. It stages output privately and publishes it only after all frames succeed. It refuses more than 500 frames.
## Return
Read the JSON printed to stdout. Return its `manifest`, `output_dir`, `interval_seconds`, and `frames` fields to the calling workflow. Each frame has `timestamp_seconds` and `file`. Report a failed command as a failure, not an empty successful extraction. Do not invent missing frames.
This is a guide-driven skill: kcode loads the guide; the current agent invokes the bundled Python code using the permission-checked shell. It does not use the executable-skill `entry` path.video-frames/extract.py
"""Extract a bounded set of timestamped video frames without shell interpolation."""
import argparse
import json
import math
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
def extract(video, interval, output):
video = Path(video).resolve(strict=True)
output = Path(output).absolute()
if not video.is_file():
raise ValueError("video must be a file")
if not math.isfinite(interval) or interval <= 0:
raise ValueError("interval must be a finite positive number")
if output.exists():
raise ValueError("output directory already exists; choose a new directory")
for program in ("ffmpeg", "ffprobe"):
if not shutil.which(program):
raise ValueError(f"{program} is not installed")
probe = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_type:format=duration", "-of", "json", str(video)], check=True, capture_output=True, text=True, timeout=30)
info = json.loads(probe.stdout)
if not info.get("streams"):
raise ValueError("input contains no video stream")
duration = float(info["format"]["duration"])
if not math.isfinite(duration) or duration <= 0:
raise ValueError("video duration is unavailable")
count = math.ceil(duration / interval)
if count > 500:
raise ValueError("extraction exceeds 500 frames; increase the interval")
output.parent.mkdir(parents=True, exist_ok=True)
stage = Path(tempfile.mkdtemp(prefix=".video-frames-", dir=output.parent))
try:
frames = []
for i in range(count):
timestamp = i * interval
name = f"frame-{i:05d}.png"
subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-ss", str(timestamp), "-i", str(video), "-map", "0:v:0", "-frames:v", "1", str(stage / name)], check=True, capture_output=True, timeout=60)
if not (stage / name).is_file():
raise ValueError(f"no frame decoded at {timestamp} seconds")
frames.append({"timestamp_seconds": timestamp, "file": str(output / name)})
result = {"video": str(video), "output_dir": str(output), "manifest": str(output / "frames.json"), "interval_seconds": interval, "frames": frames}
(stage / "frames.json").write_text(json.dumps(result, indent=2) + "\n")
# Reserve the destination exclusively before publishing files.
output.mkdir()
try:
for file in stage.iterdir():
os.replace(file, output / file.name)
except BaseException:
shutil.rmtree(output)
raise
return result
finally:
shutil.rmtree(stage)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--video", required=True)
parser.add_argument("--interval", type=float, required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
try:
print(json.dumps(extract(args.video, args.interval, args.output)))
except (ValueError, OSError, subprocess.SubprocessError, KeyError) as error:
parser.exit(1, f"video-frames: {error}\n")video-frames/test_extract.py
import importlib.util
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
spec = importlib.util.spec_from_file_location("extract", Path(__file__).with_name("extract.py"))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class VideoFramesTests(unittest.TestCase):
def test_extracts_manifest_and_refuses_overwrite(self):
with tempfile.TemporaryDirectory() as root:
root = Path(root)
video = root / "video with 'quotes'.mp4"
subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=160x90:rate=10:duration=5", "-c:v", "mpeg4", str(video)], check=True)
output = root / "frames with spaces"
result = module.extract(video, 2, output)
self.assertEqual([f["timestamp_seconds"] for f in result["frames"]], [0, 2, 4])
self.assertEqual(json.loads(Path(result["manifest"]).read_text()), result)
for frame in result["frames"]:
self.assertEqual(Path(frame["file"]).read_bytes()[:8], b"\x89PNG\r\n\x1a\n")
with self.assertRaises(ValueError):
module.extract(video, 2, output)
for interval in (0, -1, float("nan"), float("inf"), .001):
with self.assertRaises(ValueError):
module.extract(video, interval, root / "bad")
self.assertFalse((root / "bad").exists())
def test_rejects_invalid_video_without_partial_output(self):
with tempfile.TemporaryDirectory() as root:
root = Path(root)
video = root / "invalid.mp4"
video.write_text("not a video")
with self.assertRaises(subprocess.CalledProcessError):
module.extract(video, 2, root / "frames")
self.assertFalse((root / "frames").exists())
if __name__ == "__main__":
unittest.main()