ffmpeg-skill 0.2.0 → 0.3.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/README.md CHANGED
@@ -17,6 +17,10 @@ npx ffmpeg-skill
17
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
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
19
  - **Cut & join** segments with `mm:ss` / `hh:mm:ss.ms` times.
20
+ - **Silence removal / jump cuts** — detect dead air, keep a margin around speech, render frame-accurate in one pass; export the cut list for hand editing.
21
+ - **Join with transitions** — crossfade, wipes, fade-to-black between mismatched clips (any size, fps, audio layout).
22
+ - **Agent eyes** — contact sheets, single frames and before/after comparisons as PNG so the agent verifies caption placement, crops and colour visually.
23
+ - **Plan before render** — every script has `--dry-run` (print the ffmpeg commands) and `--json` (structured result with a probe of the output).
20
24
  - **Captions** — burn SRT/ASS with font, size, colour, outline and position control; generate SRT from a plain timed-text file; animated (fade/pop/slide) and word-by-word karaoke highlight styles for short-form video.
21
25
  - **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; motion-interpolated or blended slow motion.
22
26
  - **Real-world footage handling** — variable-frame-rate phone clips are conformed to constant fps automatically, rotation metadata is honoured, 10-bit HEVC and 5.1 sources are handled.
@@ -86,6 +90,9 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
86
90
  |--------|--------------|
87
91
  | `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, bit depth, HDR format, colour space, rotation, audio channels as JSON |
88
92
  | `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact |
93
+ | `silence.py` | Detect and remove silences (jump cuts), list or export the cut list |
94
+ | `join.py` | Concatenate clips with xfade transitions, normalising size, fps and audio |
95
+ | `look.py` | Contact sheet, single frames, side-by-side comparison as PNG for visual checks |
89
96
  | `caption.py` | Burn SRT/ASS (font, size, colour, outline, position); build SRT from timed plain text; animated + karaoke ASS |
90
97
  | `fit.py` | Fit to a duration (speed or trim, smooth slow-mo) and/or aspect ratio (pad or crop), force constant fps |
91
98
  | `sync.py` | Detect offset between two recordings by audio cross-correlation (1 ms), correct clock drift; output aligned video/audio |
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ffmpeg-skill
3
- description: Professional video editing with local FFmpeg — cut, caption (animated/karaoke), fit to duration/aspect, sync multicam audio with drift correction, HDR-to-SDR and LUT colour, denoise/duck/mix audio, normalise loudness, overlay logos and export platform presets, all from Python stdlib scripts with no cloud or API keys.
3
+ description: Professional video editing with local FFmpeg — cut, remove silences, join with transitions, caption (animated/karaoke), fit to duration/aspect, sync multicam audio with drift correction, HDR-to-SDR and LUT colour, denoise/duck/mix audio, normalise loudness, overlay logos, export platform presets, and inspect frames to verify the result; Python stdlib scripts, no cloud or API keys.
4
4
  ---
5
5
 
6
6
  # ffmpeg-skill
@@ -23,15 +23,19 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
23
23
  (plain cuts on keyframes, remuxing, audio-only changes), do not re-encode.
24
24
  `cut.py` and `loudness.py` stream-copy video by default; only pass
25
25
  `--accurate` to `cut.py` when the user needs frame-exact cuts.
26
- 3. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
26
+ 3. **Plan with `--dry-run --json`, then execute.** Every script accepts
27
+ `--dry-run` (prints the ffmpeg commands, runs nothing) and `--json`
28
+ (structured result: output path, probe of the output, commands run). Use
29
+ them to confirm a plan before long encodes and to report exact facts.
30
+ 4. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
27
31
  fit → caption/overlay → sync → audio → loudness → export. Do the destructive/aspect changes before burning
28
32
  text so captions are sized for the final frame. Re-encode as few times as
29
33
  possible: if several re-encoding steps are needed, keep intermediates at
30
34
  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,
35
+ 5. **Verify the output.** Run `probe.py` on each result and confirm duration,
32
36
  resolution, fps and audio match what was requested. Report those numbers to
33
37
  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
38
+ 6. **Keep the user's originals.** Never overwrite the source file. Write new
35
39
  files next to the input or where the user asked.
36
40
 
37
41
  ## Request → script
@@ -51,6 +55,10 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
51
55
  | "fix the audio levels", "normalise to -14 LUFS" | `loudness.py input.mp4` (`-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast) |
52
56
  | "export for YouTube / Reels / X", "give me a ProRes master", "make it HEVC" | `export.py input.mp4 --preset youtube|reels|x|prores|h265` |
53
57
  | "make a GIF preview" | `export.py input.mp4 --preset gif` |
58
+ | "cut out the pauses / dead air", "tighten it up", "jump cuts" | `silence.py input.mp4 [--threshold -40 --min-silence 0.8]` |
59
+ | "stitch these clips together", "add a crossfade between them" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` |
60
+ | "show me what it looks like", "check the captions are readable" | `look.py output.mp4` then view the PNG |
61
+ | "what would you run?", "don't render yet" | any script with `--dry-run` |
54
62
  | "the colours look washed out / it's an iPhone HDR video" | `color.py input.mov --to-sdr` (probe shows `hdr: true`) |
55
63
  | "apply this LUT", "convert the S-Log / V-Log footage" | `color.py input.mp4 --lut grade.cube [--lut-strength 0.7]` |
56
64
  | "the colours are tagged wrong" | `color.py input.mp4 --retag bt709` (no re-encode) |
@@ -96,6 +104,36 @@ refuses factors beyond `--max-speed`. For slow motion add `--smooth blend`
96
104
  the head (or the middle with `--from-center`). `--fps` forces a constant frame
97
105
  rate; VFR sources are conformed automatically even without it.
98
106
 
107
+ ### silence.py — remove dead air / jump cuts
108
+ ```
109
+ silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.15] [--min-keep 0.2] [--list] [--edl keep.txt] [-o OUT]
110
+ ```
111
+ Runs `silencedetect`, keeps `--margin` seconds of air around speech, drops
112
+ gaps shorter than `--min-silence`, and re-encodes once with `select`/`aselect`
113
+ (frame accurate). `--list` prints silences, kept ranges and seconds removed
114
+ without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
115
+ so the user can edit the list by hand. Quiet rooms need `--threshold -40`
116
+ to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
117
+
118
+ ### join.py — concatenate with transitions
119
+ ```
120
+ join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
121
+ [--duration 0.5] [--width W --height H] [--fps N] [--fit pad|crop] [-o OUT]
122
+ ```
123
+ Normalises every clip to one frame size, fps, `yuv420p` and 48 kHz stereo
124
+ (silent track generated for clips without audio), then chains `xfade` +
125
+ `acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
126
+ longer than 2 × the transition. Use `--transition none` for a plain cut.
127
+
128
+ ### look.py — see the result
129
+ ```
130
+ look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
131
+ look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
132
+ look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame
133
+ ```
134
+ Outputs PNG. View it with the Read tool (or any image viewer) and judge the
135
+ frame like an editor would. Use `--compare` to show before/after to the user.
136
+
99
137
  ### caption.py — subtitles (static, animated, karaoke)
100
138
  ```
101
139
  caption.py INPUT --srt FILE | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "0.2.0",
4
- "description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: cut, captions, sync with drift correction, HDR to SDR, LUTs, audio clean-up and ducking, loudness, platform exports. No API keys, no cloud, no dependencies.",
3
+ "version": "0.3.0",
4
+ "description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: cut, silence removal, transitions, 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",
7
7
  "author": "kajisho5",
@@ -55,10 +55,46 @@ def require_tool(name: str) -> str:
55
55
  return "" # unreachable
56
56
 
57
57
 
58
+ STATE: Dict[str, Any] = {"dry_run": False, "json": False, "commands": []}
59
+
60
+
61
+ def add_common(ap: "argparse.ArgumentParser") -> None:
62
+ """Add the flags every script shares."""
63
+ g = ap.add_argument_group("agent options")
64
+ g.add_argument("--dry-run", action="store_true", help="print the ffmpeg commands that would run, run nothing")
65
+ g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
66
+
67
+
68
+ def apply_common(args: "argparse.Namespace") -> None:
69
+ STATE["dry_run"] = bool(getattr(args, "dry_run", False))
70
+ STATE["json"] = bool(getattr(args, "json", False))
71
+
72
+
73
+ def emit(output: Optional[str], **extra: Any) -> None:
74
+ """Final stdout line: the output path, or a JSON document with --json."""
75
+ if STATE["json"]:
76
+ doc: Dict[str, Any] = {"output": output, "dry_run": STATE["dry_run"], "commands": list(STATE["commands"])}
77
+ if output and not STATE["dry_run"] and os.path.exists(output):
78
+ doc["probe"] = probe(output)
79
+ doc.update(extra)
80
+ print_json(doc)
81
+ elif output:
82
+ print(output)
83
+
84
+
58
85
  def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
59
- """Run a command, echoing it to stderr unless quiet. Exits on failure when check=True."""
86
+ """Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
87
+
88
+ With --dry-run, ffmpeg invocations are printed and skipped (ffprobe still runs so
89
+ scripts can plan); a fake successful CompletedProcess is returned.
90
+ """
91
+ is_ffmpeg = os.path.basename(cmd[0]).startswith("ffmpeg")
92
+ if is_ffmpeg:
93
+ STATE["commands"].append(" ".join(shell_quote(c) for c in cmd))
60
94
  if not quiet:
61
- info("$ " + " ".join(shell_quote(c) for c in cmd))
95
+ info(("[dry-run] $ " if STATE["dry_run"] and is_ffmpeg else "$ ") + " ".join(shell_quote(c) for c in cmd))
96
+ if STATE["dry_run"] and is_ffmpeg:
97
+ return subprocess.CompletedProcess(list(cmd), 0, "", "")
62
98
  proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
63
99
  if check and proc.returncode != 0:
64
100
  tail = "\n".join(proc.stderr.strip().splitlines()[-15:])
@@ -81,6 +117,11 @@ def ffmpeg_base(overwrite: bool = True) -> List[str]:
81
117
  def probe(path: str) -> Dict[str, Any]:
82
118
  """Return a compact, script-friendly description of a media file."""
83
119
  if not os.path.exists(path):
120
+ if STATE["dry_run"]:
121
+ return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
122
+ "video": {"codec": None, "width": 0, "height": 0, "fps": None, "pix_fmt": None, "hdr": False,
123
+ "color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
124
+ "audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0}
84
125
  die(f"input not found: {path}")
85
126
  ffprobe = require_tool("ffprobe")
86
127
  proc = run(
package/scripts/audio.py CHANGED
@@ -15,7 +15,7 @@ import argparse
15
15
  import sys
16
16
  from typing import List
17
17
 
18
- from _common import audio_codec_for, default_output, die, ffmpeg_base, info, probe, run
18
+ from _common import add_common, apply_common, emit, audio_codec_for, default_output, die, ffmpeg_base, info, probe, run
19
19
 
20
20
  VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
21
21
 
@@ -43,7 +43,9 @@ def main() -> int:
43
43
  fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo using standard weights")
44
44
  fades.add_argument("--replace", help="replace the audio with this file (trimmed/padded to the video)")
45
45
  ap.add_argument("--bitrate", default="192k")
46
+ add_common(ap)
46
47
  args = ap.parse_args()
48
+ apply_common(args)
47
49
 
48
50
  meta = probe(args.input)
49
51
  dur = meta.get("duration") or 0.0
@@ -120,7 +122,7 @@ def main() -> int:
120
122
  r = probe(output)
121
123
  a = r["audio"]
122
124
  info(f"wrote {output} ({r['duration']:.3f}s, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz)")
123
- print(output)
125
+ emit(output)
124
126
  return 0
125
127
 
126
128
 
@@ -22,7 +22,7 @@ import re
22
22
  import sys
23
23
  from typing import List, Tuple
24
24
 
25
- from _common import aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
25
+ from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
26
26
 
27
27
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
28
28
 
@@ -176,7 +176,9 @@ def main() -> int:
176
176
  enc = ap.add_argument_group("encoding")
177
177
  enc.add_argument("--crf", type=int, default=18)
178
178
  enc.add_argument("--preset", default="medium")
179
+ add_common(ap)
179
180
  args = ap.parse_args()
181
+ apply_common(args)
180
182
 
181
183
  if not (args.srt or args.ass or args.text):
182
184
  die("give one of --srt, --ass or --text")
@@ -240,7 +242,7 @@ def main() -> int:
240
242
  run(cmd)
241
243
  result = probe(output)
242
244
  info(f"wrote {output} ({result.get('duration'):.3f}s)")
243
- print(output)
245
+ emit(output)
244
246
  return 0
245
247
 
246
248
 
package/scripts/color.py CHANGED
@@ -15,7 +15,7 @@ import os
15
15
  import sys
16
16
  from typing import List
17
17
 
18
- from _common import aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, x264_args
18
+ from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, x264_args
19
19
 
20
20
  TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
21
21
 
@@ -52,7 +52,9 @@ def main() -> int:
52
52
  ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is not tagged as HDR (treat as PQ)")
53
53
  ap.add_argument("--crf", type=int, default=18)
54
54
  ap.add_argument("--preset", default="medium")
55
+ add_common(ap)
55
56
  args = ap.parse_args()
57
+ apply_common(args)
56
58
 
57
59
  meta = probe(args.input)
58
60
  if not meta.get("video"):
@@ -82,7 +84,7 @@ def main() -> int:
82
84
  cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
83
85
  run(cmd)
84
86
  info(f"wrote {output} (tags -> {args.retag})")
85
- print(output)
87
+ emit(output)
86
88
  return 0
87
89
 
88
90
  if args.to_sdr:
@@ -109,7 +111,7 @@ def main() -> int:
109
111
  r = probe(output)
110
112
  info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
111
113
  f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
112
- print(output)
114
+ emit(output)
113
115
  return 0
114
116
 
115
117
 
package/scripts/cut.py CHANGED
@@ -16,7 +16,7 @@ import sys
16
16
  import tempfile
17
17
  from typing import List, Tuple
18
18
 
19
- from _common import aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
19
+ from _common import STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
20
20
 
21
21
 
22
22
  def parse_segments(spec: str) -> List[Tuple[float, float]]:
@@ -52,7 +52,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
52
52
  info("stream copy failed, falling back to re-encode")
53
53
  return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
54
54
  die(f"ffmpeg failed:\n{proc.stderr.strip()}")
55
- if not reencode and tolerance >= 0:
55
+ if not reencode and tolerance >= 0 and not STATE["dry_run"]:
56
56
  got = probe(dst).get("duration") or 0.0
57
57
  if abs(got - dur) > tolerance:
58
58
  info(f"stream copy landed on a keyframe {abs(got - dur):.2f}s away from the requested cut "
@@ -74,7 +74,9 @@ def main() -> int:
74
74
  ap.add_argument("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never)")
75
75
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
76
76
  ap.add_argument("--preset", default="medium", help="x264 preset when re-encoding")
77
+ add_common(ap)
77
78
  args = ap.parse_args()
79
+ apply_common(args)
78
80
 
79
81
  meta = probe(args.input)
80
82
  total = meta.get("duration") or 0.0
@@ -131,7 +133,7 @@ def main() -> int:
131
133
  expected = sum(e - s for s, e in segments)
132
134
  info(f"wrote {output} ({result.get('duration'):.3f}s, expected ~{expected:.3f}s, "
133
135
  + ("re-encoded" if reencoded else "lossless stream copy") + ")")
134
- print(output)
136
+ emit(output)
135
137
  return 0
136
138
 
137
139
 
package/scripts/export.py CHANGED
@@ -22,7 +22,7 @@ import argparse
22
22
  import sys
23
23
  from typing import Dict, List
24
24
 
25
- from _common import cfr_args, default_output, die, ffmpeg_base, info, probe, run
25
+ from _common import add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run
26
26
 
27
27
  PRESETS: Dict[str, Dict] = {
28
28
  "youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
@@ -48,7 +48,9 @@ def main() -> int:
48
48
  ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
49
49
  ap.add_argument("--crf", type=int, help="override CRF")
50
50
  ap.add_argument("--list", action="store_true", help="list presets and exit")
51
+ add_common(ap)
51
52
  args = ap.parse_args()
53
+ apply_common(args)
52
54
 
53
55
  if args.list:
54
56
  for name, p in PRESETS.items():
@@ -84,7 +86,7 @@ def main() -> int:
84
86
  cmd += ["-filter_complex", fc, "-loop", "0", output]
85
87
  run(cmd)
86
88
  info(f"wrote {output}")
87
- print(output)
89
+ emit(output)
88
90
  return 0
89
91
 
90
92
  if vf:
@@ -108,7 +110,7 @@ def main() -> int:
108
110
  result = probe(output)
109
111
  v = result["video"]
110
112
  info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['codec']})")
111
- print(output)
113
+ emit(output)
112
114
  return 0
113
115
 
114
116
 
package/scripts/fit.py CHANGED
@@ -19,7 +19,7 @@ import sys
19
19
  from fractions import Fraction
20
20
  from typing import List
21
21
 
22
- from _common import aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
22
+ from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
23
23
 
24
24
  ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
25
25
 
@@ -74,7 +74,9 @@ def main() -> int:
74
74
  e.add_argument("--crf", type=int, default=18)
75
75
  e.add_argument("--preset", default="medium")
76
76
  e.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
77
+ add_common(ap)
77
78
  args = ap.parse_args()
79
+ apply_common(args)
78
80
 
79
81
  if not args.duration and not args.aspect and not args.width and not args.fps:
80
82
  die("nothing to do: give --duration, --aspect, --width and/or --fps")
@@ -164,7 +166,7 @@ def main() -> int:
164
166
  if abs(factor - 1.0) > 1e-4:
165
167
  msg += f", speed {factor:.3f}x"
166
168
  info(msg)
167
- print(output)
169
+ emit(output)
168
170
  return 0
169
171
 
170
172
 
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """Join clips with transitions, normalising resolution, frame rate and audio
3
+ layout so mismatched sources (phone + camera + screen recording) cut together.
4
+
5
+ Transitions (xfade): fade, dissolve, wipeleft, wiperight, wipeup, wipedown,
6
+ slideleft, slideright, circleopen, fadeblack, fadewhite, smoothleft, none.
7
+
8
+ Examples:
9
+ python3 join.py a.mp4 b.mp4 c.mp4 -o final.mp4 # 0.5 s crossfade, size/fps from the first clip
10
+ python3 join.py *.mp4 --transition fadeblack --duration 1 -o reel.mp4
11
+ python3 join.py a.mov b.mp4 --transition none --width 1920 --height 1080 --fps 30
12
+ """
13
+ import argparse
14
+ import sys
15
+ from typing import List
16
+
17
+ from _common import aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, x264_args
18
+
19
+ TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
20
+ "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
21
+
22
+
23
+ def main() -> int:
24
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
25
+ ap.add_argument("inputs", nargs="+", help="two or more clips in order")
26
+ ap.add_argument("-o", "--output", help="output file (default: <first>_joined.mp4)")
27
+ ap.add_argument("--transition", choices=TRANSITIONS, default="fade", help="transition between clips (default fade)")
28
+ ap.add_argument("--duration", type=float, default=0.5, help="transition length in seconds (default 0.5)")
29
+ ap.add_argument("--width", type=int, help="output width (default: first clip)")
30
+ ap.add_argument("--height", type=int, help="output height (default: first clip)")
31
+ ap.add_argument("--fps", type=float, help="output frame rate (default: first clip)")
32
+ ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how clips of another aspect reach the frame (default pad)")
33
+ ap.add_argument("--pad-color", default="black")
34
+ ap.add_argument("--crf", type=int, default=18)
35
+ ap.add_argument("--preset", default="medium")
36
+ add_common(ap)
37
+ args = ap.parse_args()
38
+ apply_common(args)
39
+
40
+ if len(args.inputs) < 2:
41
+ die("give at least two clips")
42
+ metas = [probe(p) for p in args.inputs]
43
+ for p, m in zip(args.inputs, metas):
44
+ if not m.get("video"):
45
+ die(f"{p} has no video stream")
46
+ first = metas[0]["video"]
47
+ w = args.width or first["width"]
48
+ h = args.height or first["height"]
49
+ if first.get("rotation") in (90, -90, 270, -270) and not (args.width or args.height):
50
+ w, h = h, w
51
+ fps = args.fps or first.get("fps") or 30.0
52
+ fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
53
+ w, h = w - (w % 2), h - (h % 2)
54
+ durs = [m.get("duration") or 0.0 for m in metas]
55
+ d = args.duration if args.transition != "none" else 0.0
56
+ for p, dur in zip(args.inputs, durs):
57
+ if d and dur <= d * 2:
58
+ die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
59
+
60
+ cmd = ffmpeg_base()
61
+ extra_inputs: List[str] = []
62
+ parts: List[str] = []
63
+ n = len(args.inputs)
64
+ for i, (p, m) in enumerate(zip(args.inputs, metas)):
65
+ cmd += ["-i", p]
66
+ # silent audio for clips without an audio track
67
+ audio_src: List[str] = []
68
+ for i, m in enumerate(metas):
69
+ if m.get("audio"):
70
+ audio_src.append(f"{i}:a:0")
71
+ else:
72
+ idx = n + len(extra_inputs)
73
+ extra_inputs += ["-f", "lavfi", "-t", f"{durs[i]:.3f}", "-i", "anullsrc=r=48000:cl=stereo"]
74
+ audio_src.append(f"{idx}:a:0")
75
+ cmd += extra_inputs
76
+
77
+ if args.fit == "crop":
78
+ geo = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
79
+ else:
80
+ geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
81
+ for i in range(n):
82
+ parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format=yuv420p,settb=AVTB[v{i}]")
83
+ parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts=stereo,asetpts=PTS-STARTPTS[a{i}]")
84
+
85
+ if args.transition == "none":
86
+ chain = "".join(f"[v{i}][a{i}]" for i in range(n))
87
+ parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
88
+ else:
89
+ vprev, aprev = "v0", "a0"
90
+ offset = 0.0
91
+ for i in range(1, n):
92
+ offset += durs[i - 1] - d
93
+ vout = f"vx{i}" if i < n - 1 else "vout"
94
+ aout = f"ax{i}" if i < n - 1 else "aout"
95
+ parts.append(f"[{vprev}][v{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
96
+ parts.append(f"[{aprev}][a{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
97
+ vprev, aprev = vout, aout
98
+
99
+ output = args.output or default_output(args.inputs[0], "joined", "mp4")
100
+ cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
101
+ cmd += x264_args(args.crf, args.preset) + aac_args() + [output]
102
+ run(cmd)
103
+ expected = sum(durs) - d * (n - 1)
104
+ r = probe(output)
105
+ info(f"wrote {output} ({r['duration']:.3f}s, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
106
+ emit(output, clips=n, transition=args.transition, expected_duration=round(expected, 3))
107
+ return 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ sys.exit(main())
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env python3
2
+ """Give the agent eyes: pull frames or a contact sheet out of a video as PNG so
3
+ the result can be inspected (caption placement, logo position, crop, colour).
4
+
5
+ Examples:
6
+ python3 look.py final.mp4 # 12-tile contact sheet with timecodes -> final_sheet.png
7
+ python3 look.py final.mp4 --tiles 4x5 --width 1600
8
+ python3 look.py final.mp4 --at 2.5 --at 7 # single frames -> final_2.500s.png, final_7.000s.png
9
+ python3 look.py before.mp4 --compare after.mp4 --at 4 # side-by-side frame
10
+ Then view the PNG (Read tool / image viewer) and verify before reporting.
11
+ """
12
+ import argparse
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import List
17
+
18
+ from _common import add_common, apply_common, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run
19
+
20
+ FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
21
+
22
+
23
+ def timecode_filter() -> str:
24
+ return f"drawtext=text='%{{pts\\:hms}}':{FONT}"
25
+
26
+
27
+ def main() -> int:
28
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
29
+ ap.add_argument("input")
30
+ ap.add_argument("-o", "--output", help="output PNG (contact sheet / compare) or basename for --at frames")
31
+ ap.add_argument("--at", action="append", help="time of a frame to extract (repeatable)")
32
+ ap.add_argument("--tiles", default="4x3", help="contact sheet grid COLSxROWS (default 4x3)")
33
+ ap.add_argument("--width", type=int, default=1280, help="total width of the sheet / compare image (default 1280)")
34
+ ap.add_argument("--compare", help="second video: place its frame next to the first (needs --at)")
35
+ ap.add_argument("--no-timecode", action="store_true")
36
+ add_common(ap)
37
+ args = ap.parse_args()
38
+ apply_common(args)
39
+
40
+ meta = probe(args.input)
41
+ if not meta.get("video"):
42
+ die("input has no video stream")
43
+ dur = meta.get("duration") or 0.0
44
+ stem = Path(args.input).stem
45
+ outdir = str(Path(args.output).parent) if args.output else str(Path(args.input).parent)
46
+ tc = "" if args.no_timecode else "," + timecode_filter()
47
+ outputs: List[str] = []
48
+
49
+ if args.compare:
50
+ if not args.at:
51
+ die("--compare needs --at TIME")
52
+ probe(args.compare)
53
+ for t in args.at:
54
+ sec = parse_time(t)
55
+ out = args.output or os.path.join(outdir, f"{stem}_vs_{Path(args.compare).stem}_{sec:.3f}s.png")
56
+ half = args.width // 2
57
+ fc = (f"[0:v]scale={half}:-2{tc}[a];[1:v]scale={half}:-2{tc}[b];"
58
+ f"[a][b]scale2ref=w=iw:h=ih[a2][b2];[a2][b2]hstack=inputs=2[out]")
59
+ cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-ss", f"{sec:.3f}", "-i", args.compare,
60
+ "-filter_complex", fc, "-map", "[out]", "-frames:v", "1", out]
61
+ run(cmd)
62
+ outputs.append(out)
63
+ elif args.at:
64
+ for t in args.at:
65
+ sec = parse_time(t)
66
+ if dur and sec > dur:
67
+ die(f"--at {t} is beyond the duration ({dur:.2f}s)")
68
+ out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
69
+ cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc}", "-frames:v", "1", out]
70
+ run(cmd)
71
+ outputs.append(out)
72
+ else:
73
+ try:
74
+ cols, rows = (int(x) for x in args.tiles.lower().split("x"))
75
+ except ValueError:
76
+ die("--tiles must look like 4x3")
77
+ n = cols * rows
78
+ if not dur:
79
+ die("cannot build a contact sheet without a known duration")
80
+ step = dur / n
81
+ tile_w = max(2, (args.width // cols) // 2 * 2)
82
+ out = args.output or os.path.join(outdir, f"{stem}_sheet.png")
83
+ # sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
84
+ vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc},"
85
+ f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
86
+ cmd = ffmpeg_base() + ["-ss", f"{step / 2:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
87
+ run(cmd)
88
+ outputs.append(out)
89
+ info(f"contact sheet: {n} frames every {step:.2f}s")
90
+
91
+ for o in outputs:
92
+ info(f"wrote {o}")
93
+ emit(outputs[0] if len(outputs) == 1 else None, outputs=outputs)
94
+ if len(outputs) > 1 and not args.json:
95
+ for o in outputs:
96
+ print(o)
97
+ return 0
98
+
99
+
100
+ if __name__ == "__main__":
101
+ sys.exit(main())
@@ -18,7 +18,7 @@ import os
18
18
  import re
19
19
  import sys
20
20
 
21
- from _common import AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
21
+ from _common import add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
22
22
 
23
23
 
24
24
 
@@ -46,7 +46,9 @@ def main() -> int:
46
46
  ap.add_argument("--measure-only", action="store_true", help="print the measured stats as JSON and exit")
47
47
  ap.add_argument("--audio-bitrate", default="192k", help="AAC bitrate when the container is video (default 192k)")
48
48
  ap.add_argument("--sample-rate", type=int, help="output sample rate (default: 48000; loudnorm upsamples internally to 192k)")
49
+ add_common(ap)
49
50
  args = ap.parse_args()
51
+ apply_common(args)
50
52
 
51
53
  meta = probe(args.input)
52
54
  if not meta.get("audio"):
@@ -76,7 +78,7 @@ def main() -> int:
76
78
 
77
79
  after = measure(output, args.lufs, args.tp, args.lra)
78
80
  info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
79
- print(output)
81
+ emit(output)
80
82
  return 0
81
83
 
82
84
 
@@ -15,7 +15,7 @@ import argparse
15
15
  import sys
16
16
  from typing import List, Optional
17
17
 
18
- from _common import 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 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}"),
@@ -100,7 +100,9 @@ def main() -> int:
100
100
  enc = ap.add_argument_group("encoding")
101
101
  enc.add_argument("--crf", type=int, default=18)
102
102
  enc.add_argument("--preset", default="medium")
103
+ add_common(ap)
103
104
  args = ap.parse_args()
105
+ apply_common(args)
104
106
 
105
107
  meta = probe(args.input)
106
108
  if not meta.get("video"):
@@ -163,7 +165,7 @@ def main() -> int:
163
165
  run(cmd)
164
166
  result = probe(output)
165
167
  info(f"wrote {output} ({result['duration']:.3f}s)")
166
- print(output)
168
+ emit(output)
167
169
  return 0
168
170
 
169
171
 
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """Remove silences / dead air (jump-cut editing) or just list them.
3
+
4
+ Detects quiet stretches with ffmpeg's silencedetect, keeps a margin on each
5
+ side so words are not clipped, drops gaps shorter than --min-silence, and
6
+ writes a frame-accurate re-encode in one pass (select/aselect filters).
7
+
8
+ Examples:
9
+ python3 silence.py talk.mp4 # -35 dB, gaps >= 0.6 s, 0.15 s margin
10
+ python3 silence.py talk.mp4 --threshold -40 --min-silence 1 --margin 0.25
11
+ python3 silence.py talk.mp4 --list # print the silences and the resulting cut list, no output
12
+ python3 silence.py talk.mp4 --edl keep.txt # also save the kept ranges (START-END per line, cut.py --segments format)
13
+ """
14
+ import argparse
15
+ import re
16
+ import sys
17
+ from typing import List, Tuple
18
+
19
+ from _common import aac_args, add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run, x264_args
20
+
21
+ SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
22
+
23
+
24
+ def detect(path: str, threshold: float, min_silence: float) -> List[Tuple[float, float]]:
25
+ ffmpeg = require_tool("ffmpeg")
26
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af",
27
+ f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
28
+ proc = run(cmd, quiet=True, check=False)
29
+ if proc.returncode != 0:
30
+ die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}")
31
+ silences: List[Tuple[float, float]] = []
32
+ start = None
33
+ for kind, val in SIL_RE.findall(proc.stderr):
34
+ if kind == "start":
35
+ start = float(val)
36
+ elif start is not None:
37
+ silences.append((start, float(val)))
38
+ start = None
39
+ if start is not None: # silence runs to the end
40
+ silences.append((start, float("inf")))
41
+ return silences
42
+
43
+
44
+ def keep_ranges(silences: List[Tuple[float, float]], duration: float, margin: float, min_keep: float) -> List[Tuple[float, float]]:
45
+ keeps: List[Tuple[float, float]] = []
46
+ cursor = 0.0
47
+ for s, e in silences:
48
+ s_adj = max(cursor, s + margin)
49
+ if s_adj - cursor >= min_keep:
50
+ keeps.append((cursor, s_adj))
51
+ cursor = min(duration, e - margin) if e != float("inf") else duration
52
+ if duration - cursor >= min_keep:
53
+ keeps.append((cursor, duration))
54
+ return keeps
55
+
56
+
57
+ def main() -> int:
58
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
59
+ ap.add_argument("input")
60
+ ap.add_argument("-o", "--output", help="output file (default: <name>_tight.<ext>)")
61
+ ap.add_argument("--threshold", type=float, default=-35.0, help="silence level in dBFS (default -35; use -40..-45 for quiet rooms)")
62
+ ap.add_argument("--min-silence", type=float, default=0.6, help="only remove gaps at least this long in seconds (default 0.6)")
63
+ ap.add_argument("--margin", type=float, default=0.15, help="seconds of silence to keep on each side of speech (default 0.15)")
64
+ ap.add_argument("--min-keep", type=float, default=0.2, help="drop kept pieces shorter than this (default 0.2)")
65
+ ap.add_argument("--list", action="store_true", help="only print silences and the kept ranges")
66
+ ap.add_argument("--edl", help="write the kept ranges to this file, one START-END per line")
67
+ ap.add_argument("--crf", type=int, default=18)
68
+ ap.add_argument("--preset", default="medium")
69
+ add_common(ap)
70
+ args = ap.parse_args()
71
+ apply_common(args)
72
+
73
+ meta = probe(args.input)
74
+ if not meta.get("audio"):
75
+ die("input has no audio stream to analyse")
76
+ duration = meta.get("duration") or 0.0
77
+ silences = detect(args.input, args.threshold, args.min_silence)
78
+ keeps = keep_ranges(silences, duration, args.margin, args.min_keep)
79
+ kept = sum(e - s for s, e in keeps)
80
+ removed = max(0.0, duration - kept)
81
+ summary = {
82
+ "silences": [[round(s, 3), None if e == float("inf") else round(e, 3)] for s, e in silences],
83
+ "keep": [[round(s, 3), round(e, 3)] for s, e in keeps],
84
+ "input_duration": round(duration, 3),
85
+ "kept_duration": round(kept, 3),
86
+ "removed_seconds": round(removed, 3),
87
+ }
88
+ info(f"{len(silences)} silences, keeping {len(keeps)} ranges: {kept:.2f}s of {duration:.2f}s (removing {removed:.2f}s)")
89
+
90
+ if args.edl:
91
+ with open(args.edl, "w", encoding="utf-8") as fh:
92
+ for s, e in keeps:
93
+ fh.write(f"{s:.3f}-{e:.3f}\n")
94
+ info(f"wrote {args.edl}")
95
+
96
+ if args.list:
97
+ if args.json:
98
+ emit(None, **summary)
99
+ else:
100
+ print_json(summary)
101
+ return 0
102
+ if not keeps:
103
+ die("nothing would be kept; raise --threshold (e.g. -45) or check the audio")
104
+ if not silences or removed < 0.05:
105
+ info("no removable silence found; output would equal the input")
106
+
107
+ output = args.output or default_output(args.input, "tight")
108
+ expr = "+".join(f"between(t,{s:.3f},{e:.3f})" for s, e in keeps)
109
+ vf = f"select='{expr}',setpts=N/FRAME_RATE/TB"
110
+ af = f"aselect='{expr}',asetpts=N/SR/TB"
111
+ cmd = ffmpeg_base() + ["-i", args.input]
112
+ if meta.get("video"):
113
+ cmd += ["-vf", vf] + x264_args(args.crf, args.preset) + cfr_args(meta)
114
+ cmd += ["-af", af] + aac_args() + [output]
115
+ run(cmd)
116
+ r = probe(output)
117
+ info(f"wrote {output} ({r['duration']:.3f}s, expected ~{kept:.3f}s)")
118
+ emit(output, **summary)
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ sys.exit(main())
package/scripts/sync.py CHANGED
@@ -26,7 +26,7 @@ import subprocess
26
26
  import sys
27
27
  from typing import List
28
28
 
29
- from _common import aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
29
+ from _common import add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
30
30
 
31
31
  SR = 8000 # decode sample rate
32
32
 
@@ -160,12 +160,13 @@ def main() -> int:
160
160
  ap.add_argument("--fine-ms", type=float, default=1.0, help="fine resolution in ms for the refinement pass, 0 to skip (default 1)")
161
161
  ap.add_argument("--fix-drift", action="store_true", help="also measure the offset near the END and correct clock drift by resampling the second file")
162
162
  ap.add_argument("--drift-window", type=float, default=60.0, help="seconds of audio analysed at each end for drift (default 60)")
163
- ap.add_argument("--json", action="store_true", help="print the result as JSON")
164
163
  mode = ap.add_mutually_exclusive_group()
165
164
  mode.add_argument("--replace-audio", action="store_true", help="write reference video with the second file's audio, aligned")
166
165
  mode.add_argument("--trim-second", action="store_true", help="write the second file shifted so it lines up with the reference")
167
166
  ap.add_argument("--crf", type=int, default=18)
167
+ add_common(ap)
168
168
  args = ap.parse_args()
169
+ apply_common(args)
169
170
 
170
171
  for p in (args.reference, args.second):
171
172
  if not probe(p).get("audio"):
@@ -286,7 +287,7 @@ def main() -> int:
286
287
  info(f"wrote {output}")
287
288
 
288
289
  if args.json:
289
- print(json.dumps(result, indent=2))
290
+ emit(result.get("output"), **{k: v for k, v in result.items() if k != "output"})
290
291
  else:
291
292
  print(f"offset: {result['offset_seconds']:+.3f}s ({result['meaning']}), confidence {result['confidence']:.2f}")
292
293
  if drift_info: