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) write `Look: not needed`.
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",
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",
@@ -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
 
@@ -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 and (start is not None or end is not None):
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 end is not None:
157
- chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
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, end, args.fade)
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
- result = probe(output)
189
- info(f"wrote {output} ({result['duration']:.3f}s)")
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