ffmpeg-skill 0.8.3 → 0.8.5
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/README.md
CHANGED
|
@@ -163,6 +163,10 @@ python3 evals/run.py --list # routing eval prompts (see evals/)
|
|
|
163
163
|
node bin/install.js --dir /tmp/skills # try the installer without touching ~/.claude
|
|
164
164
|
```
|
|
165
165
|
|
|
166
|
+
## Support
|
|
167
|
+
|
|
168
|
+
If this skill saves you time, you can help keep it maintained through [GitHub Sponsors](https://github.com/sponsors/kajisho5). Issues and pull requests are just as welcome.
|
|
169
|
+
|
|
166
170
|
## License
|
|
167
171
|
|
|
168
172
|
[MIT](LICENSE)
|
package/SKILL.md
CHANGED
|
@@ -49,7 +49,8 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
|
|
|
49
49
|
crops keeping the subject, colours not washed out, transitions landing
|
|
50
50
|
where intended. The job is not finished until the report's `Look:` line
|
|
51
51
|
names that PNG; a probe alone cannot see a caption sitting on someone's
|
|
52
|
-
face. Audio-only jobs (sync, loudness, silence
|
|
52
|
+
face. Audio-only jobs (sync, loudness, silence, or any job whose input is
|
|
53
|
+
an audio file) write `Look: not needed`; there is no picture to inspect.
|
|
53
54
|
|
|
54
55
|
|
|
55
56
|
## Before you run anything: what to ask, what to assume
|
|
@@ -112,6 +113,34 @@ Do not ask for things `probe.py` can tell you.
|
|
|
112
113
|
| "it's a phone video with variable frame rate" | nothing extra: every re-encoding script conforms VFR to constant fps automatically; `fit.py --fps 30` to pick the rate |
|
|
113
114
|
|
|
114
115
|
|
|
116
|
+
## Audio-only files
|
|
117
|
+
|
|
118
|
+
Audio files are a first-class input, not a special case. `probe.py`, `cut.py`,
|
|
119
|
+
`silence.py`, `loudness.py`, `audio.py`, `sync.py` and `check.py --platform
|
|
120
|
+
podcast` all accept WAV, FLAC, MP3, M4A/AAC, OGG and Opus (any container ffmpeg
|
|
121
|
+
can read) and write the codec that fits the output extension, so the same
|
|
122
|
+
commands work with `talk.wav` in place of `talk.mp4`. What changes:
|
|
123
|
+
|
|
124
|
+
- The output extension picks the format: `-o out.mp3` converts, `-o out.wav`
|
|
125
|
+
keeps PCM, `-o out.m4a` writes AAC. `audio.py in.wav -o out.mp3` with no
|
|
126
|
+
other flag is a plain conversion.
|
|
127
|
+
- `cut.py` stream-copies audio too, so trims are lossless unless the format
|
|
128
|
+
cannot be cut on a packet boundary.
|
|
129
|
+
- `Look: not needed` in the report; `Check:` still applies for loudness
|
|
130
|
+
(`check.py file.wav --platform podcast` measures LUFS and true peak).
|
|
131
|
+
- Scripts that need a picture (`fit`, `caption`, `overlay`, `graphics`,
|
|
132
|
+
`color`, `export`, `join`, `scenes`, `look`) refuse an audio file with
|
|
133
|
+
"input has no video stream". Say so instead of forcing a video wrapper.
|
|
134
|
+
|
|
135
|
+
| User says (audio file) | Do |
|
|
136
|
+
|-----------|----|
|
|
137
|
+
| "normalise this WAV to -14 LUFS", "podcast levels" | `loudness.py talk.wav -I -14 --tp -1 -o talk_norm.wav` (`-I -16 --tp -1.5` for podcasts) |
|
|
138
|
+
| "remove the silence from this recording" | `silence.py talk.wav -o talk_tight.wav` |
|
|
139
|
+
| "clean up the noise in this M4A" | `audio.py talk.m4a --voice -o talk_clean.m4a` (speech) or `--denoise` |
|
|
140
|
+
| "convert this WAV to MP3" | `audio.py talk.wav -o talk.mp3` |
|
|
141
|
+
| "trim this audio from 00:30 to 02:00" | `cut.py talk.wav --start 0:30 --end 2:00 -o talk_cut.wav` |
|
|
142
|
+
| "is this loud enough for Apple Podcasts?" | `check.py talk.m4a --platform podcast` |
|
|
143
|
+
|
|
115
144
|
## Report format
|
|
116
145
|
|
|
117
146
|
Finish every job with this shape (numbers from `probe.py`/`check.py`, not memory):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: MCP server, batch processing, declarative project rendering, brand kits, motion-graphics templates, HTML delivery reports, scene detection, delivery checks, cut, silence removal, transitions, multicam, captions, sync with drift correction, HDR to SDR, LUTs, audio clean-up and ducking, loudness, platform exports. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": ["ffmpeg", "video", "agent-skill", "claude-code", "cursor", "codex", "skill", "video-editing"],
|
|
6
6
|
"license": "MIT",
|
|
Binary file
|
|
Binary file
|
package/scripts/_common.py
CHANGED
|
@@ -37,6 +37,9 @@ def die(msg: str, code: int = 1) -> "None":
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def info(msg: str) -> None:
|
|
40
|
+
# under --dry-run nothing is written; do not let scripts claim otherwise
|
|
41
|
+
if msg.startswith("wrote ") and STATE.dry_run:
|
|
42
|
+
msg = "[dry-run] would write " + msg[len("wrote "):]
|
|
40
43
|
sys.stderr.write(f"{msg}\n")
|
|
41
44
|
|
|
42
45
|
|
package/scripts/overlay.py
CHANGED
|
@@ -15,7 +15,7 @@ import argparse
|
|
|
15
15
|
import sys
|
|
16
16
|
from typing import List, Optional
|
|
17
17
|
|
|
18
|
-
from _common import load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
18
|
+
from _common import STATE, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
19
19
|
|
|
20
20
|
POS = {
|
|
21
21
|
"top-left": ("{m}", "{m}"),
|
|
@@ -150,11 +150,13 @@ def main() -> int:
|
|
|
150
150
|
chain.append(f"scale={args.scale}:-1")
|
|
151
151
|
if args.opacity < 1:
|
|
152
152
|
chain.append(f"colorchannelmixer=aa={args.opacity:g}")
|
|
153
|
-
if args.fade > 0
|
|
153
|
+
if args.fade > 0:
|
|
154
|
+
# no --start/--end: fade in at 0 and out at the end of the video
|
|
154
155
|
s = start if start is not None else 0.0
|
|
156
|
+
e = end if end is not None else (meta.get("duration") or 0.0)
|
|
155
157
|
chain.append(f"fade=t=in:st={s:.3f}:d={args.fade:g}:alpha=1")
|
|
156
|
-
if
|
|
157
|
-
chain.append(f"fade=t=out:st={
|
|
158
|
+
if e > args.fade:
|
|
159
|
+
chain.append(f"fade=t=out:st={e - args.fade:.3f}:d={args.fade:g}:alpha=1")
|
|
158
160
|
x, y = position_exprs(args.position, args.margin, text_mode=False)
|
|
159
161
|
ov = f"overlay={x}:{y}:format=auto"
|
|
160
162
|
if enable:
|
|
@@ -171,7 +173,8 @@ def main() -> int:
|
|
|
171
173
|
opts.append(f"fontfile={escape_filter_path(args.font_file)}")
|
|
172
174
|
else:
|
|
173
175
|
opts.append(f"font='{args.font}'")
|
|
174
|
-
alpha = alpha_expr(args.opacity, start
|
|
176
|
+
alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None),
|
|
177
|
+
end if end is not None else ((meta.get("duration") or None) if args.fade > 0 else None), args.fade)
|
|
175
178
|
opts.append(f"fontcolor={args.font_color}")
|
|
176
179
|
if alpha != "1":
|
|
177
180
|
opts.append(f"alpha='{alpha}'")
|
|
@@ -185,8 +188,9 @@ def main() -> int:
|
|
|
185
188
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
186
189
|
cmd.append(output)
|
|
187
190
|
run(cmd)
|
|
188
|
-
|
|
189
|
-
|
|
191
|
+
if not STATE.dry_run:
|
|
192
|
+
result = probe(output)
|
|
193
|
+
info(f"wrote {output} ({result['duration']:.3f}s)")
|
|
190
194
|
emit(output)
|
|
191
195
|
return 0
|
|
192
196
|
|