ffmpeg-skill 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/SKILL.md +165 -0
- package/bin/install.js +103 -0
- package/package.json +31 -0
- package/scripts/_common.py +271 -0
- package/scripts/caption.py +157 -0
- package/scripts/cut.py +135 -0
- package/scripts/export.py +112 -0
- package/scripts/fit.py +161 -0
- package/scripts/loudness.py +84 -0
- package/scripts/overlay.py +171 -0
- package/scripts/probe.py +58 -0
- package/scripts/sync.py +205 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kajisho5
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# ffmpeg-skill
|
|
2
|
+
|
|
3
|
+
**Give your coding agent a video editor.** Local FFmpeg, Python standard library, nothing else.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx ffmpeg-skill
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`ffmpeg-skill` is an [Agent Skill](https://docs.anthropic.com/en/docs/agents-and-tools/agent-skills) for Claude Code, Cursor, Codex and any other agent that reads `SKILL.md`. It teaches the agent a fixed editing workflow (probe → edit losslessly where possible → verify) and ships eight small CLI scripts that do the actual work with `ffmpeg`/`ffprobe`. Think of it as the fully local FFmpeg counterpart to cloud video-agent tools such as browser-use/video-use.
|
|
12
|
+
|
|
13
|
+
**No API keys. No cloud. No dependencies.** If `ffmpeg` and `python3` are on your PATH, it works — offline, on any footage you'd rather not upload.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **Probe first, verify last** — the skill forces the agent to read real duration/fps/resolution before editing and to check the result after, so you get "final.mp4: 59.98 s, 1080×1920, 30 fps" instead of guesses.
|
|
18
|
+
- **Lossless when possible** — cuts and joins use stream copy by default; re-encoding only happens when it must (frame-accurate cuts, filters, format changes).
|
|
19
|
+
- **Cut & join** segments with `mm:ss` / `hh:mm:ss.ms` times.
|
|
20
|
+
- **Captions** — burn SRT/ASS with font, size, colour, outline and position control; generate SRT from a plain timed-text file.
|
|
21
|
+
- **Fit** to an exact duration (pitch-preserving speed change or trim) and to 16:9 / 9:16 / 1:1 / 4:5 by padding or cropping.
|
|
22
|
+
- **Multicam / external-audio sync** — offset detection by cross-correlation implemented in pure Python (no numpy).
|
|
23
|
+
- **Loudness** — two-pass EBU R128 normalisation to −14 LUFS (or any target) with true-peak ceiling.
|
|
24
|
+
- **Overlays** — logos, watermarks and titles with position, time range, opacity and fades.
|
|
25
|
+
- **Export presets** — YouTube, Instagram Reels/Shorts/TikTok, X, ProRes 422 HQ master, H.265, GIF — all tagged BT.709.
|
|
26
|
+
- **Agent-friendly CLI** — every script has `--help`, prints the output path on stdout, exits non-zero with a reason on stderr, and names outputs `<input>_<operation>.<ext>` by default.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Claude Code (default) → ~/.claude/skills/ffmpeg-skill
|
|
32
|
+
npx ffmpeg-skill
|
|
33
|
+
|
|
34
|
+
# Cursor → ~/.cursor/skills/ffmpeg-skill
|
|
35
|
+
npx ffmpeg-skill --cursor
|
|
36
|
+
|
|
37
|
+
# Codex → ~/.codex/skills/ffmpeg-skill
|
|
38
|
+
npx ffmpeg-skill --codex
|
|
39
|
+
|
|
40
|
+
# everything, or a project-local copy, or a custom directory
|
|
41
|
+
npx ffmpeg-skill --all
|
|
42
|
+
npx ffmpeg-skill --project
|
|
43
|
+
npx ffmpeg-skill --dir ./my-skills
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or without Node: clone this repo and copy `SKILL.md` and `scripts/` into your agent's skills directory.
|
|
47
|
+
|
|
48
|
+
You also need FFmpeg:
|
|
49
|
+
|
|
50
|
+
| OS | Command |
|
|
51
|
+
|----|---------|
|
|
52
|
+
| macOS | `brew install ffmpeg` |
|
|
53
|
+
| Ubuntu / Debian | `sudo apt install ffmpeg` |
|
|
54
|
+
| Windows | `winget install Gyan.FFmpeg` |
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
Once installed, just talk to your agent. Five things you can say to Claude Code:
|
|
59
|
+
|
|
60
|
+
1. **"Take `interview.mp4`, keep 0:45–3:10 and 5:00–6:30, and make it exactly 60 seconds for Reels."**
|
|
61
|
+
→ `probe.py` → `cut.py --segments 0:45-3:10,5:00-6:30` → `fit.py --duration 60 --aspect 9:16 --fit crop` → `export.py --preset reels` → `probe.py` to confirm 60.0 s at 1080×1920.
|
|
62
|
+
2. **"Burn these captions in, white text with a black outline at the top, in Japanese."**
|
|
63
|
+
→ `caption.py --text cues.txt --font "Noto Sans CJK JP" --position top --outline 3`.
|
|
64
|
+
3. **"The lav mic recording is out of sync with the camera — fix it and normalise to −14 LUFS."**
|
|
65
|
+
→ `sync.py camera.mp4 lav.wav --replace-audio` → `loudness.py` → report the detected offset and final LUFS.
|
|
66
|
+
4. **"Put our logo in the top-right corner for the whole video at 80% opacity, and a title card for the first 4 seconds."**
|
|
67
|
+
→ `overlay.py --image logo.png --position top-right --scale 220 --opacity 0.8` → `overlay.py --text "…" --start 0 --end 4 --fade 0.4`.
|
|
68
|
+
5. **"Give me a ProRes master and an H.265 copy of the final cut."**
|
|
69
|
+
→ `export.py --preset prores` and `export.py --preset h265`.
|
|
70
|
+
|
|
71
|
+
The scripts also work on their own:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python3 ~/.claude/skills/ffmpeg-skill/scripts/probe.py input.mp4 --compact
|
|
75
|
+
python3 ~/.claude/skills/ffmpeg-skill/scripts/fit.py input.mp4 --duration 60 --aspect 9:16
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
More examples: [examples/README.md](examples/README.md). To see everything run end-to-end on generated footage: `bash examples/make_demo.sh`.
|
|
79
|
+
|
|
80
|
+
## Scripts
|
|
81
|
+
|
|
82
|
+
| Script | What it does |
|
|
83
|
+
|--------|--------------|
|
|
84
|
+
| `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, colour space, audio channels as JSON |
|
|
85
|
+
| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact |
|
|
86
|
+
| `caption.py` | Burn SRT/ASS (font, size, colour, outline, position); build SRT from timed plain text |
|
|
87
|
+
| `fit.py` | Fit to a duration (speed or trim) and/or aspect ratio (pad or crop), force constant fps |
|
|
88
|
+
| `sync.py` | Detect offset between two recordings by audio cross-correlation; output aligned video/audio |
|
|
89
|
+
| `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP (or custom), video stream-copied |
|
|
90
|
+
| `overlay.py` | Composite image/logo or drawtext title with position, time range, opacity, fade |
|
|
91
|
+
| `export.py` | Presets: `youtube`, `youtube4k`, `reels`, `x`, `prores`, `h265`, `gif` |
|
|
92
|
+
|
|
93
|
+
All scripts: Python 3.9+, standard library only, `--help`, non-zero exit + stderr message on failure.
|
|
94
|
+
|
|
95
|
+
## Requirements
|
|
96
|
+
|
|
97
|
+
- FFmpeg 5.0+ with `libx264`, `libx265`, `libass` and `prores_ks` (the default builds from Homebrew, apt and gyan.dev include all of them)
|
|
98
|
+
- Python 3.9+
|
|
99
|
+
- Node 16+ only for the `npx` installer
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
bash examples/make_demo.sh # generates footage, runs every script, rebuilds assets/demo.gif
|
|
105
|
+
python3 tests/test_all.py # end-to-end tests (needs ffmpeg)
|
|
106
|
+
node bin/install.js --dir /tmp/skills # try the installer without touching ~/.claude
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
[MIT](LICENSE)
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ffmpeg-skill
|
|
3
|
+
description: Professional video editing with local FFmpeg — cut, caption, fit to duration/aspect, sync multicam audio, normalise loudness, overlay logos and export platform presets, all from Python stdlib scripts with no cloud or API keys.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ffmpeg-skill
|
|
7
|
+
|
|
8
|
+
You are editing video for the user with FFmpeg through the scripts in `scripts/`.
|
|
9
|
+
Everything runs locally. Nothing is uploaded, no keys are needed, and the only
|
|
10
|
+
requirements are `ffmpeg`/`ffprobe` on PATH and Python 3.9+.
|
|
11
|
+
|
|
12
|
+
Run scripts with `python3 <skill-dir>/scripts/<name>.py ...`. Every script has
|
|
13
|
+
`--help`, exits non-zero on failure with the reason on stderr, prints the output
|
|
14
|
+
path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
|
|
15
|
+
|
|
16
|
+
## Workflow (always follow this order)
|
|
17
|
+
|
|
18
|
+
1. **Probe first.** Run `probe.py` on every input before touching it. Read the
|
|
19
|
+
duration, fps, resolution, codecs, audio channels and the
|
|
20
|
+
`variable_frame_rate_suspected` flag. Plan the edit from real numbers, never
|
|
21
|
+
from assumptions about the file.
|
|
22
|
+
2. **Prefer lossless.** If the request can be satisfied without re-encoding
|
|
23
|
+
(plain cuts on keyframes, remuxing, audio-only changes), do not re-encode.
|
|
24
|
+
`cut.py` and `loudness.py` stream-copy video by default; only pass
|
|
25
|
+
`--accurate` to `cut.py` when the user needs frame-exact cuts.
|
|
26
|
+
3. **Chain operations in a sensible order.** Cut → fit → caption/overlay →
|
|
27
|
+
sync/loudness → export. Do the destructive/aspect changes before burning
|
|
28
|
+
text so captions are sized for the final frame. Re-encode as few times as
|
|
29
|
+
possible: if several re-encoding steps are needed, keep intermediates at
|
|
30
|
+
CRF 18 (the default) and only use `export.py` for the last step.
|
|
31
|
+
4. **Verify the output.** Run `probe.py` on each result and confirm duration,
|
|
32
|
+
resolution, fps and audio match what was requested. Report those numbers to
|
|
33
|
+
the user (e.g. "final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo").
|
|
34
|
+
5. **Keep the user's originals.** Never overwrite the source file. Write new
|
|
35
|
+
files next to the input or where the user asked.
|
|
36
|
+
|
|
37
|
+
## Request → script
|
|
38
|
+
|
|
39
|
+
| User says | Do |
|
|
40
|
+
|-----------|----|
|
|
41
|
+
| "what's in this file", "how long is it", "is it 4K" | `probe.py input.mp4` |
|
|
42
|
+
| "cut from 1:20 to 2:05", "trim the first 10 seconds" | `cut.py input.mp4 --start 1:20 --end 2:05` |
|
|
43
|
+
| "keep only these parts", "remove the middle" | `cut.py input.mp4 --segments 0-1:00,1:30-2:00` |
|
|
44
|
+
| "make it exactly 60 seconds", "fit it in 30s" | `fit.py input.mp4 --duration 60` (speed) or `--method trim` |
|
|
45
|
+
| "make it vertical / for TikTok / 9:16", "square for Instagram" | `fit.py input.mp4 --aspect 9:16 --fit pad` (or `--fit crop`) |
|
|
46
|
+
| "add subtitles from this SRT", "burn in captions" | `caption.py input.mp4 --srt subs.srt` |
|
|
47
|
+
| "caption it with these lines" (plain text with times) | `caption.py input.mp4 --text cues.txt` |
|
|
48
|
+
| "put our logo top-right", "add a watermark" | `overlay.py input.mp4 --image logo.png --position top-right --scale 200` |
|
|
49
|
+
| "add a title for the first 4 seconds" | `overlay.py input.mp4 --text "Title" --position top --start 0 --end 4 --fade 0.4` |
|
|
50
|
+
| "sync the lav mic to the camera", "line up the two cameras" | `sync.py camera.mp4 mic.wav --replace-audio` / `sync.py camA.mp4 camB.mp4 --trim-second` |
|
|
51
|
+
| "fix the audio levels", "normalise to -14 LUFS" | `loudness.py input.mp4` (`-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast) |
|
|
52
|
+
| "export for YouTube / Reels / X", "give me a ProRes master", "make it HEVC" | `export.py input.mp4 --preset youtube|reels|x|prores|h265` |
|
|
53
|
+
| "make a GIF preview" | `export.py input.mp4 --preset gif` |
|
|
54
|
+
|
|
55
|
+
## Scripts
|
|
56
|
+
|
|
57
|
+
### probe.py — inspect
|
|
58
|
+
```
|
|
59
|
+
probe.py INPUT... [--compact] [--field duration|video.fps|...]
|
|
60
|
+
```
|
|
61
|
+
JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation,variable_frame_rate_suspected}`,
|
|
62
|
+
`audio{codec,channels,sample_rate}`. `--compact` gives one line per file.
|
|
63
|
+
|
|
64
|
+
### cut.py — cut / join segments
|
|
65
|
+
```
|
|
66
|
+
cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,...] [--accurate] [-o OUT]
|
|
67
|
+
```
|
|
68
|
+
Times accept `12.5`, `1:30`, `00:01:30.250`. Default is `-c copy` (snaps to
|
|
69
|
+
keyframes, instant, lossless); if the snapped result deviates more than
|
|
70
|
+
`--tolerance` (0.5 s) from the request, that segment is re-encoded automatically
|
|
71
|
+
(x264 CRF 18). `--accurate` always re-encodes; `--tolerance -1` never does.
|
|
72
|
+
Multiple segments are concatenated in the order given. stderr reports whether
|
|
73
|
+
the result was "lossless stream copy" or "re-encoded".
|
|
74
|
+
|
|
75
|
+
### fit.py — target duration and/or aspect
|
|
76
|
+
```
|
|
77
|
+
fit.py INPUT [--duration T --method speed|trim [--from-center] [--max-speed 4]]
|
|
78
|
+
[--aspect 16:9|9:16|1:1|4:5|W:H --fit pad|crop [--width W] [--pad-color black]]
|
|
79
|
+
[--fps N] [-o OUT]
|
|
80
|
+
```
|
|
81
|
+
`speed` retimes video and audio together (pitch-preserving `atempo`); it
|
|
82
|
+
refuses factors beyond `--max-speed`. `trim` keeps the head (or the middle with
|
|
83
|
+
`--from-center`). `--fps` forces constant frame rate — use it on VFR sources.
|
|
84
|
+
|
|
85
|
+
### caption.py — subtitles
|
|
86
|
+
```
|
|
87
|
+
caption.py INPUT --srt FILE | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
|
|
88
|
+
[--font NAME] [--fonts-dir DIR] [--size N] [--color RRGGBB] [--outline N] [--outline-color RRGGBB]
|
|
89
|
+
[--bold] [--box] [--position bottom|top|center|top-left|...] [--margin N] [-o OUT]
|
|
90
|
+
caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
|
|
91
|
+
```
|
|
92
|
+
Text cue format, one per line: `0:00-0:03 Hello`, `00:00:03.500 --> 00:00:06 Two | lines`.
|
|
93
|
+
Lines without a time run for `--auto-seconds` (3 s) after the previous cue. `|` is a line break.
|
|
94
|
+
|
|
95
|
+
### overlay.py — logo, image, title
|
|
96
|
+
```
|
|
97
|
+
overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--font-file F.ttf] [--font-size N] [--box]
|
|
98
|
+
[--position top-right|bottom-left|center|X,Y] [--margin N] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
|
|
99
|
+
```
|
|
100
|
+
Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps playing.
|
|
101
|
+
|
|
102
|
+
### sync.py — offset detection and alignment
|
|
103
|
+
```
|
|
104
|
+
sync.py REFERENCE SECOND [--json] [--max-offset 30] [--analyze-seconds 120]
|
|
105
|
+
[--replace-audio | --trim-second] [-o OUT]
|
|
106
|
+
```
|
|
107
|
+
Cross-correlates loudness envelopes (pure Python FFT, ~5 ms resolution).
|
|
108
|
+
Positive offset = the second recording started later. `--replace-audio` writes
|
|
109
|
+
the reference video with the second file's audio aligned (video stream copied).
|
|
110
|
+
`--trim-second` writes the second file shifted to the reference timeline.
|
|
111
|
+
Check `confidence` (0–1); below ~0.3 the match is doubtful — try a longer
|
|
112
|
+
`--analyze-seconds` or a window that contains a clear event (clap).
|
|
113
|
+
|
|
114
|
+
### loudness.py — EBU R128 normalisation
|
|
115
|
+
```
|
|
116
|
+
loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
|
|
117
|
+
```
|
|
118
|
+
Two-pass `loudnorm`: measure, then apply with measured values (linear mode when
|
|
119
|
+
the true-peak ceiling allows). Video is stream-copied; audio becomes AAC in
|
|
120
|
+
video containers or the codec matching the extension (.wav → PCM, .flac, .mp3).
|
|
121
|
+
|
|
122
|
+
### export.py — delivery presets
|
|
123
|
+
```
|
|
124
|
+
export.py INPUT --preset youtube|youtube4k|reels|x|prores|h265|gif [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [-o OUT]
|
|
125
|
+
export.py --list
|
|
126
|
+
```
|
|
127
|
+
Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
|
|
128
|
+
trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
|
|
129
|
+
|
|
130
|
+
## Gotchas
|
|
131
|
+
|
|
132
|
+
- **Variable frame rate (phone/screen recordings).** `probe.py` sets
|
|
133
|
+
`variable_frame_rate_suspected` when `r_frame_rate` and `avg_frame_rate`
|
|
134
|
+
disagree. VFR causes audio drift and bad concat joins. Fix it early with
|
|
135
|
+
`fit.py --fps 30` (or 60) before cutting with `--accurate`, captioning or
|
|
136
|
+
syncing; add `--accurate` to `cut.py` because copy-cuts on VFR are unreliable.
|
|
137
|
+
- **Audio drift / sync.** Don't mix files with different frame rates or sample
|
|
138
|
+
rates in one `cut.py --segments` join without re-encoding (`--accurate`).
|
|
139
|
+
After `sync.py`, verify by running it again on the output: the offset should
|
|
140
|
+
be ~0. If dialogue drifts over a long recording, the clocks differ; align the
|
|
141
|
+
start with `sync.py`, then fix drift with `fit.py --duration` on the audio-only
|
|
142
|
+
track to match the reference length.
|
|
143
|
+
- **Colour.** All H.264/H.265 outputs are tagged BT.709 (`-colorspace`,
|
|
144
|
+
`-color_primaries`, `-color_trc`) and `yuv420p`. If `probe.py` shows a
|
|
145
|
+
BT.2020/HDR source (`color_transfer` smpte2084 or arib-std-b67), tell the
|
|
146
|
+
user the output will be tagged as SDR BT.709 without tone mapping, and add
|
|
147
|
+
`-vf zscale=t=linear:npl=100,tonemap=hable,zscale=p=bt709:t=bt709:m=bt709`
|
|
148
|
+
manually with ffmpeg if they need a proper SDR conversion.
|
|
149
|
+
- **CJK and other non-Latin text.** libass and drawtext need a font that has
|
|
150
|
+
the glyphs. Check with `fc-list | grep -i cjk`. Then either name it
|
|
151
|
+
(`caption.py --font "Noto Sans CJK JP"`) or point at the file
|
|
152
|
+
(`overlay.py --font-file /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc`,
|
|
153
|
+
`caption.py --fonts-dir ./fonts --font "Noto Sans CJK JP"`). Without a
|
|
154
|
+
matching font you get boxes, not an error. Install: `apt install fonts-noto-cjk`,
|
|
155
|
+
`brew install --cask font-noto-sans-cjk`.
|
|
156
|
+
- **Keyframe cuts.** A lossless `cut.py` result may start up to one GOP (often
|
|
157
|
+
1–10 s) earlier than requested; the script re-encodes automatically when the
|
|
158
|
+
deviation exceeds 0.5 s. If the user insists on lossless output, pass
|
|
159
|
+
`--tolerance -1` and tell them the cut lands on the nearest earlier keyframe.
|
|
160
|
+
- **Rotation metadata.** Phone footage often has a `rotation` tag; `probe.py`
|
|
161
|
+
reports it and `fit.py` accounts for it when computing the output frame.
|
|
162
|
+
- **Odd dimensions.** `yuv420p` needs even width/height; `fit.py` and
|
|
163
|
+
`export.py` round to even values automatically.
|
|
164
|
+
- **Speed.** Re-encodes use x264 `medium`. For long files add `--preset veryfast`
|
|
165
|
+
to intermediates and keep the default for the final export.
|
package/bin/install.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ffmpeg-skill installer.
|
|
4
|
+
*
|
|
5
|
+
* Copies SKILL.md and scripts/ into the skills directory of one or more
|
|
6
|
+
* coding agents. Default target is Claude Code (~/.claude/skills/ffmpeg-skill).
|
|
7
|
+
*
|
|
8
|
+
* npx ffmpeg-skill # Claude Code
|
|
9
|
+
* npx ffmpeg-skill --cursor # Cursor (~/.cursor/skills/ffmpeg-skill)
|
|
10
|
+
* npx ffmpeg-skill --codex # Codex (~/.codex/skills/ffmpeg-skill)
|
|
11
|
+
* npx ffmpeg-skill --all # all of the above
|
|
12
|
+
* npx ffmpeg-skill --dir ./skills # custom parent directory
|
|
13
|
+
* npx ffmpeg-skill --project # ./.claude/skills/ffmpeg-skill in the current project
|
|
14
|
+
* npx ffmpeg-skill --uninstall # remove from the selected targets
|
|
15
|
+
*/
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { spawnSync } = require('child_process');
|
|
22
|
+
|
|
23
|
+
const SKILL_NAME = 'ffmpeg-skill';
|
|
24
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
25
|
+
const PAYLOAD = ['SKILL.md', 'scripts'];
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const has = (flag) => args.includes(flag);
|
|
29
|
+
const optValue = (flag) => {
|
|
30
|
+
const i = args.indexOf(flag);
|
|
31
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (has('--help') || has('-h')) {
|
|
35
|
+
console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0].replace(/^\/\*\*?\s?|^\s\*\s?/gm, ''));
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const home = os.homedir();
|
|
40
|
+
const targets = [];
|
|
41
|
+
const want = { claude: has('--claude'), cursor: has('--cursor'), codex: has('--codex') };
|
|
42
|
+
if (has('--all')) want.claude = want.cursor = want.codex = true;
|
|
43
|
+
const customDir = optValue('--dir');
|
|
44
|
+
const project = has('--project');
|
|
45
|
+
|
|
46
|
+
if (!want.claude && !want.cursor && !want.codex && !customDir && !project) want.claude = true;
|
|
47
|
+
|
|
48
|
+
if (want.claude) targets.push({ label: 'Claude Code', dir: path.join(home, '.claude', 'skills', SKILL_NAME) });
|
|
49
|
+
if (want.cursor) targets.push({ label: 'Cursor', dir: path.join(home, '.cursor', 'skills', SKILL_NAME) });
|
|
50
|
+
if (want.codex) targets.push({ label: 'Codex', dir: path.join(home, '.codex', 'skills', SKILL_NAME) });
|
|
51
|
+
if (project) targets.push({ label: 'project (.claude/skills)', dir: path.join(process.cwd(), '.claude', 'skills', SKILL_NAME) });
|
|
52
|
+
if (customDir) targets.push({ label: 'custom', dir: path.join(path.resolve(customDir), SKILL_NAME) });
|
|
53
|
+
|
|
54
|
+
function copyRecursive(src, dst) {
|
|
55
|
+
const stat = fs.statSync(src);
|
|
56
|
+
if (stat.isDirectory()) {
|
|
57
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
58
|
+
for (const entry of fs.readdirSync(src)) {
|
|
59
|
+
if (entry === '__pycache__') continue;
|
|
60
|
+
copyRecursive(path.join(src, entry), path.join(dst, entry));
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
fs.copyFileSync(src, dst);
|
|
64
|
+
if (src.endsWith('.py')) fs.chmodSync(dst, 0o755);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function checkFfmpeg() {
|
|
69
|
+
const r = spawnSync('ffmpeg', ['-version'], { encoding: 'utf8' });
|
|
70
|
+
if (r.error || r.status !== 0) {
|
|
71
|
+
console.warn('\n warning: ffmpeg was not found on PATH. The skill needs FFmpeg to run:');
|
|
72
|
+
console.warn(' macOS: brew install ffmpeg');
|
|
73
|
+
console.warn(' Ubuntu: sudo apt install ffmpeg');
|
|
74
|
+
console.warn(' Windows: winget install Gyan.FFmpeg\n');
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
console.log(` found ${r.stdout.split('\n')[0]}`);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let failed = false;
|
|
82
|
+
for (const t of targets) {
|
|
83
|
+
try {
|
|
84
|
+
if (has('--uninstall')) {
|
|
85
|
+
fs.rmSync(t.dir, { recursive: true, force: true });
|
|
86
|
+
console.log(`removed ${t.label}: ${t.dir}`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
fs.rmSync(t.dir, { recursive: true, force: true });
|
|
90
|
+
fs.mkdirSync(t.dir, { recursive: true });
|
|
91
|
+
for (const item of PAYLOAD) copyRecursive(path.join(ROOT, item), path.join(t.dir, item));
|
|
92
|
+
console.log(`installed ${t.label}: ${t.dir}`);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
failed = true;
|
|
95
|
+
console.error(`failed for ${t.label} (${t.dir}): ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!has('--uninstall')) {
|
|
100
|
+
checkFfmpeg();
|
|
101
|
+
console.log('\nDone. Ask your agent things like "make this video 60 seconds and add captions".');
|
|
102
|
+
}
|
|
103
|
+
process.exit(failed ? 1 : 0);
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ffmpeg-skill",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg. No API keys, no cloud, no dependencies.",
|
|
5
|
+
"keywords": ["ffmpeg", "video", "agent-skill", "claude-code", "cursor", "codex", "skill", "video-editing"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "kajisho5",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kajisho5/ffmpeg-skill.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/kajisho5/ffmpeg-skill#readme",
|
|
13
|
+
"bugs": "https://github.com/kajisho5/ffmpeg-skill/issues",
|
|
14
|
+
"bin": {
|
|
15
|
+
"ffmpeg-skill": "bin/install.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin/",
|
|
19
|
+
"scripts/",
|
|
20
|
+
"SKILL.md",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "python3 tests/test_all.py",
|
|
26
|
+
"demo": "bash examples/make_demo.sh"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=16"
|
|
30
|
+
}
|
|
31
|
+
}
|