← Reusable skills
Extend kcode

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.py

What 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()