A portable Agent Skill that converts explicitly selected timestamp ranges from
local video files into one or more optimized animated GIFs. It runs inside
Claude Code and OpenAI Codex from the same shared implementation, driving a
deterministic Python engine on top of ffmpeg/ffprobe.
The agent handles the conversation (which video, which timestamps, which quality profile, approvals). The Python engine does the deterministic work: parsing and validating timestamps, inspecting media, planning outputs, running FFmpeg's two-pass palette pipeline, and returning structured results. It never prompts.
Normative specification:
versioned_technical_spec.md(VTG-TS-001, v0.3.0-draft.1). When this README and the spec disagree, the spec wins.
Versions 0.1.0 and 0.2.0 are released. Version 0.3.0 — transformations
(crop, explicit resize, playback speed, dithering) and PNG preview frames — is
feature-complete and pinned at 0.3.0 throughout this repository, awaiting its
release tag. Its CLI flags, schemas, and exit codes are settled and are what
the spec describes; a few non-interface items remain open decisions (exact
profile values and others — spec §26). Until the v0.3.0 tag is published,
install from this branch expecting the released artifact to match it, not to
differ from it.
Upgrading from 0.1.0/0.2.0 is safe: 0.3.0 changes no output. Every earlier
invocation produces byte-comparable GIFs, verified by executing the released
0.2.0 engine alongside 0.3.0. The one rejected-input change is that a manifest
width outside 2–8192 is now an error; see CHANGELOG.md.
Conversion is local by default. Version 0.2.0 added opt-in, download-only
remote source acquisition (direct http/https media URLs, plus an optional
never-bundled yt-dlp adapter for video-page URLs). It is disabled by default:
with the default configuration a URL is rejected with REMOTE_DISABLED and no
network access occurs (spec §25.2, FR-018). See Remote sources.
Version 0.3.0 adds per-clip transformations and a preview command
(spec §25.3, FR-024..FR-030). Every transformation parameter is an integer, a
bounded decimal, or a member of a fixed enumeration — no user-supplied text ever
reaches an FFmpeg filter graph (SEC-018). Captions and subtitle burn-in are
deliberately excluded and deferred to 0.4.0. See
Transformations.
- Python 3.10+ (standard library only — no third-party runtime packages)
- ffmpeg and ffprobe on
PATHpip install ffmpegdoes not install the FFmpeg executables. Install the real binaries (e.g.brew install ffmpeg,apt-get install ffmpeg,choco install ffmpeg).
Run the built-in environment check at any time:
python scripts/video_to_gif.py doctor --json
The canonical skill lives at src/skill/video-to-gif/. You can install it
directly as an Agent Skill, or (once published) install the packaged plugin from
a marketplace.
Releases: packaged archives (
video-to-gif-claude-<version>.{tar.gz,zip}andvideo-to-gif-codex-<version>.{tar.gz,zip}) plus aSHA256SUMSchecksum file are attached to each GitHub Release.
Copy src/skill/video-to-gif/ into one of:
| Scope | Location |
|---|---|
| Project | .claude/skills/video-to-gif/ |
| Personal | ~/.claude/skills/video-to-gif/ |
Copy src/skill/video-to-gif/ into one of:
| Scope | Location |
|---|---|
| Project | .agents/skills/video-to-gif/ |
| Personal | ~/.agents/skills/video-to-gif/ |
For versioned, discoverable distribution the skill is wrapped in thin plugins:
- Claude Code — add this repository as a marketplace
(
claude plugin marketplace add Krishna2709/giffify— served from the root.claude-plugin/marketplace.json), then install thevideo-to-gifplugin from it. - Codex — the repository's root
.agents/plugins/marketplace.jsonserves the Codex marketplace; add it and install thevideo-to-gifplugin from it.
Platform packages under packages/ are generated from the canonical source by
tools/build_packages.py — never edit them by hand.
The command-line contract below matches spec §12 exactly. The examples assume you
are running from inside the skill directory (where scripts/video_to_gif.py
lives); from the repository root, prefix the path with
src/skill/video-to-gif/.
With --json, the final JSON document is written to stdout and progress
events stream as JSON Lines on stderr (spec §13.3). Exit codes 0–14 are
part of the contract (spec §14).
python scripts/video_to_gif.py doctor --json
python scripts/video_to_gif.py inspect \
--input "./videos/demo.mp4" \
--json
python scripts/video_to_gif.py create \
--input "./videos/demo.mp4" \
--start "00:01:00" \
--end "00:01:05" \
--profile balanced \
--output-directory "./output" \
--collision-policy fail \
--json
python scripts/video_to_gif.py create \
--input "./videos/demo.mp4" \
--start "00:01:00" \
--duration 5 \
--profile balanced \
--json
--output-name must be a bare filename with no path separators. It is sanitized
(FR-011) and resolved inside the effective output directory.
python scripts/video_to_gif.py create \
--input "./videos/demo.mp4" \
--start "00:01:00" \
--end "00:01:05" \
--output-name "opening.gif" \
--json
python scripts/video_to_gif.py batch \
--manifest "./clips.json" \
--collision-policy fail \
--json
python scripts/video_to_gif.py batch \
--manifest "./clips.json" \
--dry-run \
--json
python scripts/video_to_gif.py validate-config \
--config "./.video-to-gif.json" \
--json
python scripts/video_to_gif.py validate-manifest \
--manifest "./clips.json" \
--json
Timestamps accept 75, 75.5, MM:SS, HH:MM:SS, each with optional .mmm
fractional seconds. A clip is defined by start plus exactly one of end or
duration.
Remote acquisition is disabled by default. Set remoteSources to ask or
enabled in .video-to-gif.json, or pass --allow-remote to enable it for a
single invocation. With the default configuration a URL is rejected with
REMOTE_DISABLED (exit 8) and no network access occurs.
Before fetching, the agent obtains your approval for network access and a
one-per-source confirmation that you have a lawful basis to use the video. The
source is downloaded to a secure temporary directory, converted by the local
pipeline, and the download is deleted afterward unless you pass
--keep-remote-source. https is preferred (http warns; other schemes are
rejected), and any URL echoed in output has its query string and credentials
redacted (spec §12.8, FR-018..023).
python scripts/video_to_gif.py create \
--input "https://cdn.example.com/media/demo.mp4" \
--start "00:01:00" \
--end "00:01:05" \
--profile balanced \
--allow-remote \
--json
Video-page (watch) URLs require the optional, never-bundled yt-dlp adapter via
--remote-adapter ytdlp; when it is absent the engine reports YTDLP_MISSING
(exit 3). See references/remote-sources.md.
create, batch, and preview accept the same additive transformation flags
(spec §12.10):
| Flag | Value | Meaning |
|---|---|---|
--crop <x>:<y>:<w>:<h> |
Four unsigned integers | Crop rectangle in orientation-normalized source pixels, applied before scaling |
--width <pixels> |
2 to 8192 | Maximum output width (aspect ratio preserved) |
--height <pixels> |
2 to 8192 | Maximum output height (aspect ratio preserved) |
--speed <multiplier> |
0.25 to 4.0 | Playback multiplier; retimes only, never re-cuts the range |
--dither <mode> |
none, bayer, floyd_steinberg, sierra2, sierra2_4a |
Palette-use dither mode |
--bayer-scale <n> |
0 to 5 | Bayer matrix scale, for --dither bayer |
An invalid value is rejected in preflight — before any FFmpeg process starts —
with INVALID_CROP, INVALID_DIMENSIONS, INVALID_SPEED, or INVALID_DITHER
and exit code 6.
python scripts/video_to_gif.py create \
--input "./videos/demo.mp4" \
--start "00:01:00" \
--end "00:01:05" \
--crop 320:180:1280:720 \
--width 640 \
--json
On a 1920x1080 source this keeps a 1280x720 region and writes a 640x360 GIF. Because the crop is applied first, the profile maximum, aspect-ratio preservation, and the no-upscale rule all apply to the cropped rectangle.
python scripts/video_to_gif.py create \
--input "./videos/demo.mp4" \
--start "00:00:04" \
--duration 4 \
--speed 2.0 \
--json
The selected source range is unchanged; the GIF duration becomes
round(durationMs / speed) — here a 4000 ms range yields an ~2000 ms GIF. No
frames are interpolated: speeding up drops frames, slowing down duplicates them.
python scripts/video_to_gif.py preview \
--input "./videos/demo.mp4" \
--at "00:01:02.500" \
--crop 320:180:1280:720 \
--width 640 \
--json
preview writes a single full-colour PNG (never palette-quantized) so
framing can be confirmed before committing to a conversion.
preview --manifest "./clips.json" produces one still per clip at that clip's
start timestamp. A preview is reported in a separate previews array, never
counted as a created GIF. Temporal and palette settings (speed, fps, loop,
colors, dither, bayerScale) are accepted but ignored for a still and
produce a TRANSFORMATION_NOT_APPLICABLE warning.
Every transformation field also works at the top level and per clip in a JSON or
CSV manifest. For transformations, a clip-level field beats a batch-wide
command-line flag (spec FR-024), so batch --speed 0.5 applies only to clips
that do not set their own speed. See
references/transformations.md
and examples/.
Widths are maximums, not forced widths. The engine preserves source aspect ratio and does not upscale by default. When the source frame rate is below the target, the effective frame rate does not exceed the source (spec §8, FR-014).
| Profile | Max width | Target FPS | Max colors | Default dither | Intended use |
|---|---|---|---|---|---|
| small | 480 px | 10 | 128 | bayer (scale 5) |
Documentation and messaging |
| balanced | 640 px | 15 | 256 | sierra2_4a |
General default |
| high | 960 px | 20 | 256 | sierra2_4a |
Detailed product or UI motion |
| custom | user-set | user-set | user-set | sierra2_4a |
Advanced control |
An explicit --width or --height overrides the profile's maximum width — a
profile maximum is a default bound, not a ceiling on explicit requests — and is
honored exactly, odd values included (GIF is palette-based, so there is no
even-dimension constraint); a dimension derived from another is rounded to the
nearest integer and may itself be odd — the same rule 0.1.0 used, on every path,
so derived dimensions are unchanged from 0.1.0/0.2.0. Upscaling stays gated by
--allow-upscale; without it an
oversized request is clamped back to the effective source size and warns with
UPSCALE_NOT_ALLOWED. Profile dither defaults reproduce 0.1.0/0.2.0 output, so a
job that specifies no dither is unchanged from earlier releases (spec §15.5).
Exact profile values are provisional pending benchmark testing (spec §26 open decision 5).
The skill is designed to be safe on untrusted media and hostile manifests.
Full detail: docs/security.md and SECURITY.md.
- Local by default; nothing uploaded. No source video, GIF, frame, metadata, or filename is uploaded anywhere. Telemetry is disabled (spec §18).
- Opt-in, download-only remote sources (0.2.0). Remote acquisition is
disabled by default; with defaults a URL returns
REMOTE_DISABLED(exit 8) and no network access occurs. When enabled and approved, access is download-only: onlyhttps/httpschemes are allowed (others rejected asUNSUPPORTED_URL_SCHEME), private-network/loopback/metadata hosts are blocked (PRIVATE_NETWORK_BLOCKED), downloads are size- and time-capped (2 GiB / 900 s), and URLs are redacted in all output (SEC-012..SEC-017). - Network isolation enforced at the FFmpeg layer.
ffmpeg/ffprobeare invoked with-protocol_whitelist file,pipe, and reference-following containers (HLS, DASH, concat scripts) are rejected asUNSUPPORTED_MEDIA_CONTAINER(exit 5) so a hostile local playlist — or a downloaded file — cannot reach the network (SEC-010). - No shell. Subprocesses are invoked with argument arrays, never
shell=True; manifest and config values are treated purely as data (SEC-001, SEC-009). - Resource limits. A per-clip wall-clock timeout (default 600 s) and a
temporary-disk ceiling (default 2 GiB) are enforced; exceeding either yields
RESOURCE_LIMIT_EXCEEDED(exit 13) and cleans up (SEC-011). - Overwrite protection. The engine never overwrites an existing file by
default (collision policy
fail); writing outside the project root requires explicit authorization (SEC-002, SEC-003, SEC-004).
docs/architecture.md— layers and responsibility boundariesdocs/security.md— SEC-001..SEC-011 and the threat modeldocs/release-process.md— build integrity, publishing, and versioningversioned_technical_spec.md— the normative specificationCONTRIBUTING.md— development setup and workflowSECURITY.md— supported versions and vulnerability reportingCHANGELOG.md— release notes
MIT — see LICENSE.