ffmpeg-skill 0.8.1 → 0.8.2

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/SKILL.md CHANGED
@@ -30,20 +30,26 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
30
30
  at CRF 18 (the default) and only use `export.py` for the last step; for
31
31
  anything with more than two steps use `render.py` with a project.json.
32
32
  5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X`
33
- for the destination the user named. Fix FAILs about format (aspect, fps,
34
- codec, size, true peak, colour). A loudness FAIL is a judgement call: fix
35
- it for speech and music, but not for ambience or near-silence (see the
36
- pitfalls below). Mention WARNs; do not chase them.
33
+ for the destination the user named. Each row is marked `format` or
34
+ `judgement`. Format rows (codec, pixel format, size, true peak, colour
35
+ tags, VFR) are safe to fix mechanically. Judgement rows change the content:
36
+ duration (cut loses material, speed changes motion), aspect (crop loses
37
+ edges), fps (drops motion), loudness (ambience must not be boosted). Fix
38
+ those only when the user's request already implies the answer, otherwise
39
+ state the choice and its cost in one line. Mention WARNs; do not chase them.
37
40
  6. **Verify the output.** Run `probe.py` on each result and confirm duration,
38
41
  resolution, fps and audio match what was requested. Report those numbers to
39
42
  the user (e.g. "final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo").
40
43
  7. **Keep the user's originals.** Never overwrite the source file. Write new
41
44
  files next to the input or where the user asked.
42
- 8. **Look at the picture.** After captioning, overlaying, cropping or colour
43
- work run `look.py OUTPUT` (contact sheet) or `look.py OUTPUT --at T` and
44
- view the PNG: text inside the frame and not over faces, logos where asked,
45
- crops keeping the subject, colours not washed out. Fix and re-run before
46
- reporting. Numbers from probe are not enough.
45
+ 8. **Look at the picture.** Whenever the picture changed (captions, overlays,
46
+ graphics, crop/pad, resize, colour, transitions) run `look.py OUTPUT`
47
+ (contact sheet) or `look.py OUTPUT --at T`, view the PNG, and judge it like
48
+ an editor: text inside the frame and not over faces, logos where asked,
49
+ crops keeping the subject, colours not washed out, transitions landing
50
+ where intended. The job is not finished until the report's `Look:` line
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`.
47
53
 
48
54
 
49
55
  ## Before you run anything: what to ask, what to assume
@@ -54,6 +60,7 @@ Ask one short question only when the answer changes the output materially and th
54
60
  - **Duration** ("make it 60 s") without a method: speed up for ≤1.5× changes, trim otherwise, and state which you chose. Ask if the content is a talk (trimming loses words) and the change is large.
55
61
  - **Captions** without a text source: use `--transcribe` if a local whisper exists, otherwise ask for the text or a timed file; never invent dialogue.
56
62
  - **Fonts and brand**: if the user mentions a brand, colours or "our font", ask for or create `brand.json` once and reuse it.
63
+ - **CJK / non-Latin text**: check that a font exists before rendering (`fc-list :lang=ja file` / `:lang=ko` / `:lang=zh`); pass it with `--font "Name"` or `--font-file /path.ttf`. Tofu boxes are a failed job, not a style.
57
64
  - Anything else (crop position, transition type, caption style): pick the conventional default, say what you picked, and offer the alternative in one line.
58
65
 
59
66
  Do not ask for things `probe.py` can tell you.
@@ -130,6 +137,9 @@ Keep it to those five lines plus anything the user must decide. Attach the conta
130
137
  noise, not the content. Leave the level, say so, and offer music or narration.
131
138
  - Captions burned before a crop/resize: text lands off-frame. Frame changes first, then text.
132
139
  - Anything chained by hand through three re-encodes: use `render.py` so the plan is one file and the user can change one number.
140
+ - `--fit crop` to reach 9:16 from 16:9 throws away 70 % of the width: a wide shot loses people at the edges. Check the sheet; pad (bars) or a reframe is often the honest answer.
141
+ - Conforming 60 fps to 30 halves the motion samples: fine for a talking head, visibly choppy for sports, gaming, drone pans. Keep 60 when the platform allows it.
142
+ - "Make it 60 seconds" on a 3-minute talk by speed change is unwatchable (3×); by trim it drops two thirds of the words. Ask which, or propose a highlight cut with `scenes.py`.
133
143
 
134
144
  ## Gotchas
135
145
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
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",
@@ -25,6 +25,7 @@
25
25
  ],
26
26
  "scripts": {
27
27
  "test": "python3 tests/test_all.py",
28
+ "release-check": "bash tests/release_check.sh",
28
29
  "demo": "bash examples/make_demo.sh"
29
30
  },
30
31
  "engines": {
package/scripts/audio.py CHANGED
@@ -6,7 +6,7 @@ Examples:
6
6
  python3 audio.py interview.mp4 --denoise # FFT noise reduction
7
7
  python3 audio.py interview.mp4 --voice # highpass + de-esser + compressor + denoise
8
8
  python3 audio.py talk.mp4 --music bed.mp3 --duck # music under speech, auto-ducked
9
- python3 audio.py talk.mp4 --music bed.mp3 --music-volume -18 --fade-out 3
9
+ python3 audio.py talk.mp4 --music bed.mp3 --music-volume -18 --music-fade-out 3 # bed fades, voice does not
10
10
  python3 audio.py clip.mp4 --fade-in 0.5 --fade-out 1 --stereo
11
11
  python3 audio.py surround.mov --downmix # 5.1 -> stereo with proper centre/LFE weights
12
12
  python3 audio.py clip.mp4 --replace narration.wav # swap the audio track entirely
@@ -37,7 +37,8 @@ def main() -> int:
37
37
  music.add_argument("--music-loop", action="store_true", help="loop the music if shorter than the video")
38
38
  fades = ap.add_argument_group("fades / layout")
39
39
  fades.add_argument("--fade-in", type=float, default=0.0, help="seconds")
40
- fades.add_argument("--fade-out", type=float, default=0.0, help="seconds")
40
+ fades.add_argument("--fade-out", type=float, default=0.0, help="seconds; fades the whole final mix (voice included)")
41
+ music.add_argument("--music-fade-out", type=float, default=0.0, help="seconds; fades only the music bed at the end, voice untouched")
41
42
  fades.add_argument("--stereo", action="store_true", help="force 2-channel output (mono is duplicated to both sides)")
42
43
  fades.add_argument("--mono", action="store_true", help="force 1-channel output")
43
44
  fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo using standard weights")
@@ -90,8 +91,8 @@ def main() -> int:
90
91
  m = f"{idx}:a:0"
91
92
  idx += 1
92
93
  mfx = [f"volume={args.music_volume:g}dB", f"atrim=0:{dur:.3f}" if dur else "anull"]
93
- if args.fade_out:
94
- mfx.append(f"afade=t=out:st={max(0.0, dur - args.fade_out):.3f}:d={args.fade_out:g}")
94
+ if args.music_fade_out and dur:
95
+ mfx.append(f"afade=t=out:st={max(0.0, dur - args.music_fade_out):.3f}:d={args.music_fade_out:g}")
95
96
  graph.append(f"[{m}]{','.join(mfx)}[music]")
96
97
  if args.duck:
97
98
  graph.append("[main]asplit=2[mainA][sc]")
@@ -344,7 +344,14 @@ def main() -> int:
344
344
  args.text = None
345
345
  if args.text:
346
346
  cues = parse_text_cues(args.text, args.auto_seconds, args.gap)
347
- srt_path = args.write_srt or os.path.splitext(args.text)[0] + ".srt"
347
+ if args.write_srt:
348
+ srt_path = args.write_srt
349
+ elif args.input:
350
+ # keep generated files next to the output, not in the user's source folder
351
+ out_guess = args.output or default_output(args.input, "captioned")
352
+ srt_path = os.path.splitext(out_guess)[0] + ".srt"
353
+ else:
354
+ srt_path = os.path.splitext(args.text)[0] + ".srt"
348
355
  write_srt(cues, srt_path)
349
356
  info(f"wrote {srt_path} ({len(cues)} cues)")
350
357
  if not args.input:
package/scripts/check.py CHANGED
@@ -86,12 +86,17 @@ def main() -> int:
86
86
  v, a = meta.get("video") or {}, meta.get("audio") or {}
87
87
  rows: List[Dict[str, Any]] = []
88
88
 
89
+ JUDGEMENT = {"duration", "aspect", "loudness", "fps", "resolution"}
90
+
89
91
  def row(name: str, status: str, value: Any, expect: Any, fix: str = "") -> None:
90
- rows.append({"check": name, "status": status, "value": value, "expected": expect, "fix": fix})
92
+ # "format" rows are safe to fix mechanically; "judgement" rows change the content
93
+ # (what is cut, what is cropped, how loud ambience gets) and need a decision
94
+ rows.append({"check": name, "status": status, "value": value, "expected": expect, "fix": fix,
95
+ "kind": "judgement" if name in JUDGEMENT else "format"})
91
96
 
92
97
  dur = meta.get("duration") or 0.0
93
98
  if spec["max_duration"]:
94
- row("duration", "PASS" if dur <= spec["max_duration"] else "FAIL", f"{dur:.2f}s", f"<= {spec['max_duration']:g}s", "fit.py --duration N or cut.py")
99
+ row("duration", "PASS" if dur <= spec["max_duration"] else "FAIL", f"{dur:.2f}s", f"<= {spec['max_duration']:g}s", "decide with the user: cut.py keeps quality but drops content; fit.py --duration speeds up (audio pitch-preserved, motion faster)")
95
100
  else:
96
101
  row("duration", "PASS", f"{dur:.2f}s", "any")
97
102
 
@@ -101,7 +106,7 @@ def main() -> int:
101
106
  w, h = h, w
102
107
  asp = aspect_name(w, h)
103
108
  if spec["aspects"]:
104
- row("aspect", "PASS" if asp in spec["aspects"] else "FAIL", asp, "/".join(spec["aspects"]), f"fit.py --aspect {spec['aspects'][0]} --fit pad|crop")
109
+ row("aspect", "PASS" if asp in spec["aspects"] else "FAIL", asp, "/".join(spec["aspects"]), f"fit.py --aspect {spec['aspects'][0]} --fit pad (keeps everything, adds bars) or crop (fills the frame, loses the edges: check the subject with look.py)")
105
110
  else:
106
111
  row("aspect", "PASS", asp, "any")
107
112
  short = min(w, h)
@@ -109,7 +114,7 @@ def main() -> int:
109
114
  row("resolution", "PASS" if short >= spec["min_height"] else "WARN", f"{w}x{h}", f"short side >= {spec['min_height']}", "upscaling will not add detail; re-export from the master")
110
115
  fps = v.get("fps") or 0
111
116
  if spec["fps_max"]:
112
- row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30")
117
+ row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30 (drops half the frames of 60 fps motion; fine for talking heads, visible on sports/gaming)")
113
118
  row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
114
119
  if spec["codecs"]:
115
120
  row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.replace("shorts", "reels").replace("tiktok", "reels").replace("linkedin", "youtube"))
@@ -143,7 +148,7 @@ def main() -> int:
143
148
  lm = measure_loudness(args.input)
144
149
  if lm:
145
150
  diff = abs(lm["lufs"] - spec["lufs"])
146
- row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g}")
151
+ row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g} for speech or music; leave ambience/near-silence (<= -40 LUFS) alone and say so")
147
152
  row("true peak", "PASS" if lm["tp"] <= spec["tp"] + 0.05 else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP", f"loudness.py --tp {spec['tp']:g}")
148
153
  elif args.platform in ("podcast",):
149
154
  row("audio", "FAIL", "none", "audio stream", "audio.py --replace")
@@ -157,6 +162,8 @@ def main() -> int:
157
162
  print(f"{args.input} — {args.platform}")
158
163
  for r in rows:
159
164
  line = f" {r['status']:4s} {r['check']:{width}s} {r['value']} (expected {r['expected']})"
165
+ if r["status"] != "PASS" and r["kind"] == "judgement":
166
+ line += " [judgement]"
160
167
  if r["status"] != "PASS" and r["fix"]:
161
168
  line += f" -> {r['fix']}"
162
169
  print(line)
package/scripts/probe.py CHANGED
@@ -12,7 +12,7 @@ Examples:
12
12
  import argparse
13
13
  import sys
14
14
 
15
- from _common import analyze_levels, print_json, probe
15
+ from _common import add_common, analyze_levels, apply_common, print_json, probe
16
16
 
17
17
 
18
18
  def main() -> int:
@@ -21,7 +21,9 @@ def main() -> int:
21
21
  ap.add_argument("--compact", action="store_true", help="one human-readable line per file instead of JSON")
22
22
  ap.add_argument("--field", help="print only this top-level field (e.g. duration) or dotted path (video.fps)")
23
23
  ap.add_argument("--analyze", action="store_true", help="also sample picture levels (first 20 s) and flag Log-looking footage")
24
+ add_common(ap) # --json / --dry-run / --fast / --progress accepted for uniformity; output is JSON already
24
25
  args = ap.parse_args()
26
+ apply_common(args)
25
27
 
26
28
  results = [probe(p) for p in args.inputs]
27
29
  if args.analyze:
package/scripts/render.py CHANGED
@@ -24,7 +24,7 @@ Project format (all keys optional except clips):
24
24
  {"logo": true},
25
25
  {"text": "Episode 12", "position": "bottom", "start": 1, "end": 5, "fade": 0.3, "box": true}
26
26
  ],
27
- "audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "fade_out": 2},
27
+ "audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "music_fade_out": 2},
28
28
  "loudness": {"lufs": -14, "tp": -1},
29
29
  "fit": {"duration": 60},
30
30
  "export": {"preset": "reels"},
@@ -289,7 +289,7 @@ def main() -> int:
289
289
  for k, flag in (("music", "--music"), ("replace", "--replace")):
290
290
  if au.get(k):
291
291
  argv += [flag, rel(au[k])]
292
- for k, flag in (("music_volume", "--music-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount")):
292
+ for k, flag in (("music_volume", "--music-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("music_fade_out", "--music-fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount")):
293
293
  if au.get(k) is not None:
294
294
  argv += [flag, str(au[k])]
295
295
  for k, flag in (("voice", "--voice"), ("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):