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 +7 -0
- package/SKILL.md +42 -4
- package/package.json +2 -2
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/_common.py +43 -2
- package/scripts/audio.py +4 -2
- package/scripts/caption.py +4 -2
- package/scripts/color.py +5 -3
- package/scripts/cut.py +5 -3
- package/scripts/export.py +5 -3
- package/scripts/fit.py +4 -2
- package/scripts/join.py +111 -0
- package/scripts/look.py +101 -0
- package/scripts/loudness.py +4 -2
- package/scripts/overlay.py +4 -2
- package/scripts/silence.py +123 -0
- package/scripts/sync.py +4 -3
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
|
|
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. **
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/_common.py
CHANGED
|
@@ -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
|
-
|
|
125
|
+
emit(output)
|
|
124
126
|
return 0
|
|
125
127
|
|
|
126
128
|
|
package/scripts/caption.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
+
emit(output)
|
|
168
170
|
return 0
|
|
169
171
|
|
|
170
172
|
|
package/scripts/join.py
ADDED
|
@@ -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())
|
package/scripts/look.py
ADDED
|
@@ -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())
|
package/scripts/loudness.py
CHANGED
|
@@ -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
|
-
|
|
81
|
+
emit(output)
|
|
80
82
|
return 0
|
|
81
83
|
|
|
82
84
|
|
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 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
|
-
|
|
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
|
-
|
|
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:
|