ffmpeg-skill 1.4.8 → 1.4.9
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 +15 -0
- package/SKILL.md +1 -1
- package/docs/contract.md +6 -5
- package/package.json +1 -1
- package/scripts/_common.py +33 -0
- package/scripts/background.py +2 -2
- package/scripts/caption.py +10 -3
- package/scripts/cut.py +2 -1
- package/scripts/fit.py +2 -2
- package/scripts/graphics.py +4 -3
- package/scripts/insert.py +2 -2
- package/scripts/look.py +3 -3
- package/scripts/loop.py +2 -2
- package/scripts/metadata.py +1 -1
- package/scripts/overlay.py +4 -3
- package/scripts/pad.py +5 -3
- package/scripts/scenes.py +5 -4
- package/scripts/silence.py +4 -3
package/README.md
CHANGED
|
@@ -316,6 +316,21 @@ npx ffmpeg-skill doctor --json # available / missing / missing_optional / unkn
|
|
|
316
316
|
|
|
317
317
|
`doctor --json`'s `gpu_encoders` reports which GPU-backed encoders (`nvenc`, `videotoolbox`, `qsv`, `vaapi`, `amf`) this ffmpeg *build* was compiled with — read from `-encoders` alone, so it proves the capability shipped, not that the GPU/driver on this machine will actually accept a job (that needs a real encode, which `doctor`'s introspection never runs). No tool here uses one yet — every tool still assumes CPU x264/x265 — so this is purely informational and never affects `ok` or any tool's `usable`. GPU-accelerated encoding stays deliberately off the roadmap until there's a real-hardware-verified design for it (build-presence alone is not proof a job will succeed) — not a promised feature, just an honest "not yet, and not without proof it actually works."
|
|
318
318
|
|
|
319
|
+
## Gotchas and best practices
|
|
320
|
+
|
|
321
|
+
The short list for humans. The agent-facing version, with the reasoning, is the "Things that look right but are wrong" and "Gotchas" sections of [SKILL.md](SKILL.md).
|
|
322
|
+
|
|
323
|
+
- **Variable frame rate (phone and screen recordings).** `probe.py` flags it; every re-encoding tool conforms to a constant rate automatically, and `cut.py` switches to frame-accurate mode on its own because copy-cuts on VFR land on the wrong frame. Choose the rate yourself with `fit.py input.mp4 --fps 30` when the measured average is odd.
|
|
324
|
+
- **Lossless cuts snap to keyframes.** A stream-copy cut can start up to one GOP earlier than asked. `cut.py` re-encodes when the snap exceeds 0.5 s (`--tolerance` changes the limit). For a strictly lossless file pass `--tolerance -1`, and expect the cut to land on the nearest earlier keyframe; the JSON result lists them under `nearest_keyframes`.
|
|
325
|
+
- **HDR stays HDR.** When the probe reports HDR (HDR10, HLG, Dolby Vision, BT.2020), the tools keep it rather than flatten it. Convert deliberately with `color.py --to-sdr` before H.264 deliverables or LUT work. `export.py` platform presets are SDR and warn on HDR input.
|
|
326
|
+
- **Loudness targets.** −14 LUFS / −1 dBTP for YouTube and social platforms (the `loudness.py` default), `-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast. A clip measured at −40 LUFS or below is room tone, not content; raising it raises the noise. Check true peak as well as LUFS: `check.py file --platform podcast` measures both.
|
|
327
|
+
- **Frame changes first, text second.** Captions and overlays burned before a crop or resize end up off-frame. Reframe, then caption.
|
|
328
|
+
- **Cropping 16:9 to 9:16 discards 70 % of the width.** `fit.py --fit crop` centres by default; pass `--crop-x`/`--crop-y` toward the subject, or pad with `--fit pad --pad-fill blur`. Look at the contact sheet before deciding.
|
|
329
|
+
- **Non-Latin captions need a font with the glyphs.** Without one you get boxes, not an error. Name it (`caption.py --font "Noto Sans CJK JP"`) or point at the file (`overlay.py --font-file /path/to/NotoSansCJK-Regular.ttc`).
|
|
330
|
+
- **Silence detection finds nothing?** The default threshold is −35 dBFS. The tool prints a hint with the track's measured level; raise the threshold (`silence.py --threshold -25`) or shorten `--min-silence`.
|
|
331
|
+
- **Sync results carry a confidence.** Below 0.3, or an offset near the edge of the analysis window, is probably wrong: enlarge `--analyze-seconds` or find a clap. Recordings over ten minutes from separate devices need `sync.py --fix-drift`.
|
|
332
|
+
- **Long chains belong in a plan.** Three hand-chained re-encodes lose quality and are hard to change; `render.py` runs the whole edit from one JSON file, and `--dry-run` shows every ffmpeg command before anything is written.
|
|
333
|
+
|
|
319
334
|
## FFmpeg compatibility
|
|
320
335
|
|
|
321
336
|
The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only). What CI actually exercises on every pull request is FFmpeg 5.1.1 (static build), 6.1 (Ubuntu apt), 7.1 (Debian trixie apt), 8.x (macOS Homebrew) and 9.x (Windows gyan.dev), on Python 3.9 and 3.13 (the two ends of the supported range). The capability parser has been run against the listings of these builds:
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
|
|
|
5
5
|
|
|
6
6
|
# ffmpeg-skill
|
|
7
7
|
|
|
8
|
-
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact; `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
|
|
8
|
+
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
|
|
9
9
|
|
|
10
10
|
## Workflow (always follow this order)
|
|
11
11
|
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`1.4.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.4.9`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -83,7 +83,7 @@ on, the line says so.
|
|
|
83
83
|
```json
|
|
84
84
|
{
|
|
85
85
|
"contract_version": "1.0",
|
|
86
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.4.
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.4.9", "execution_mode": "local", "kind": "execution",
|
|
87
87
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
88
88
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
89
89
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -156,9 +156,10 @@ with `--dry-run` behind a fake `ffmpeg` that records any call, and asserts that
|
|
|
156
156
|
call happened and no file appeared. Under `--dry-run` a tool prints the command lines
|
|
157
157
|
it would run, reports `dry_run: true`, and never reports an output probe. The
|
|
158
158
|
exceptions are stated per tool in the contract's `dry_run` field: `probe` and `check` are
|
|
159
|
-
read-only (ffprobe still runs)
|
|
160
|
-
run their ffmpeg/ffprobe measurements (the analysis is the
|
|
161
|
-
|
|
159
|
+
read-only (ffprobe still runs); `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`,
|
|
160
|
+
`loudness` and `stabilize` still run their ffmpeg/ffprobe measurements (the analysis is the
|
|
161
|
+
tool's job; only the artifact is skipped, including side files such as `--edl`, `--sheet` or a
|
|
162
|
+
generated `.ass`), and `verify` does not support dry-run (its steps run). `SKILL.md` and
|
|
162
163
|
`references/scripts.md` repeat the same list; the contract is the authority.
|
|
163
164
|
|
|
164
165
|
### Repeatability
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.9",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/scripts/_common.py
CHANGED
|
@@ -356,6 +356,26 @@ def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
|
|
|
356
356
|
continue
|
|
357
357
|
|
|
358
358
|
|
|
359
|
+
def refuse_output_is_input(output: str, *inputs: str) -> None:
|
|
360
|
+
"""Tool-level twin of the run() guard, for tools whose final ffmpeg command does not name
|
|
361
|
+
the user's input at all. `cut.py --segments` cuts each part into a temp dir and then concats
|
|
362
|
+
a list file: the last command's only `-i` is that list, so `-o` equal to the input sailed
|
|
363
|
+
through _check_no_overwrite_input() and replaced the source with the join (fourth audit,
|
|
364
|
+
P0). Call it once the output path is known, before any part of the input is consumed."""
|
|
365
|
+
try:
|
|
366
|
+
out_real = os.path.realpath(output)
|
|
367
|
+
except OSError:
|
|
368
|
+
return
|
|
369
|
+
for inp in inputs:
|
|
370
|
+
try:
|
|
371
|
+
same = os.path.realpath(inp) == out_real
|
|
372
|
+
except OSError:
|
|
373
|
+
continue
|
|
374
|
+
if same:
|
|
375
|
+
die(f"refusing to run: output {output!r} is the same file as input {inp!r} "
|
|
376
|
+
f"(the result would replace the source) -- choose a different --output/-o path", kind="input")
|
|
377
|
+
|
|
378
|
+
|
|
359
379
|
def _check_existing_output(cmd: Sequence[str]) -> None:
|
|
360
380
|
"""An output path that already exists is someone's file: a previous result, a source the
|
|
361
381
|
agent mis-named, a deliverable from another run. ffmpeg's -y (which every command carries so
|
|
@@ -954,6 +974,19 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
|
954
974
|
return total
|
|
955
975
|
|
|
956
976
|
|
|
977
|
+
def time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
|
|
978
|
+
"""parse_time() for a command-line flag: SMPTE hh:mm:ss:ff resolves with the input's fps when
|
|
979
|
+
the caller has one, and every parse failure is a `kind: input` refusal naming the flag (so
|
|
980
|
+
`--json` callers get a failure document, never a traceback)."""
|
|
981
|
+
try:
|
|
982
|
+
return parse_time(value, fps)
|
|
983
|
+
except MissingFpsError as e:
|
|
984
|
+
die(f"{flag} {value!r}: {e}")
|
|
985
|
+
except ValueError as e:
|
|
986
|
+
die(f"{flag} {value!r}: {e} (use seconds, mm:ss, hh:mm:ss.ms or, with a known fps, hh:mm:ss:ff)")
|
|
987
|
+
return 0.0 # unreachable
|
|
988
|
+
|
|
989
|
+
|
|
957
990
|
def fmt_srt_time(seconds: float) -> str:
|
|
958
991
|
if seconds < 0:
|
|
959
992
|
seconds = 0.0
|
package/scripts/background.py
CHANGED
|
@@ -14,7 +14,7 @@ import argparse
|
|
|
14
14
|
import math
|
|
15
15
|
import sys
|
|
16
16
|
|
|
17
|
-
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS
|
|
17
|
+
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS, time_arg
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def main() -> int:
|
|
@@ -34,7 +34,7 @@ def main() -> int:
|
|
|
34
34
|
args = ap.parse_args()
|
|
35
35
|
apply_common(args)
|
|
36
36
|
|
|
37
|
-
target =
|
|
37
|
+
target = time_arg(args.duration, "--duration", args.fps)
|
|
38
38
|
if target <= 0:
|
|
39
39
|
die("--duration must be > 0")
|
|
40
40
|
if args.fps <= 0:
|
package/scripts/caption.py
CHANGED
|
@@ -200,7 +200,10 @@ def parse_srt(path: str) -> List[Tuple[float, float, str]]:
|
|
|
200
200
|
if times:
|
|
201
201
|
a, b = times.split("-->")
|
|
202
202
|
text = "\n".join(block[block.index(times) + 1:]).strip()
|
|
203
|
-
|
|
203
|
+
try:
|
|
204
|
+
cues.append((parse_time(a), parse_time(b), text))
|
|
205
|
+
except ValueError as e: # includes MissingFpsError: SRT timings are hh:mm:ss,ms, never frames
|
|
206
|
+
die(f"{path}: cannot read the timing line {times.strip()!r}: {e}")
|
|
204
207
|
block = []
|
|
205
208
|
if not cues:
|
|
206
209
|
die(f"no cues found in {path}")
|
|
@@ -531,12 +534,16 @@ def main() -> int:
|
|
|
531
534
|
w, h = meta["video"]["width"], meta["video"]["height"]
|
|
532
535
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
533
536
|
w, h = h, w
|
|
534
|
-
|
|
537
|
+
if not STATE.dry_run: # the generated ASS is an artifact of this run: a plan writes nothing
|
|
538
|
+
write_ass(cues_for_ass, ass_path, args, w, h, video=args.input if meta.get("audio") else None)
|
|
535
539
|
info(f"wrote {ass_path} ({len(cues_for_ass)} cues, animate={args.animate}, karaoke={args.karaoke})")
|
|
536
540
|
args.ass = ass_path
|
|
541
|
+
generated_ass = True
|
|
542
|
+
else:
|
|
543
|
+
generated_ass = False
|
|
537
544
|
|
|
538
545
|
if args.ass:
|
|
539
|
-
if not os.path.exists(args.ass):
|
|
546
|
+
if not generated_ass and not os.path.exists(args.ass):
|
|
540
547
|
die(f"ASS file not found: {args.ass}")
|
|
541
548
|
vf = f"ass={escape_filter_path(args.ass)}"
|
|
542
549
|
if args.fonts_dir:
|
package/scripts/cut.py
CHANGED
|
@@ -30,7 +30,7 @@ import sys
|
|
|
30
30
|
import tempfile
|
|
31
31
|
from typing import List, Tuple
|
|
32
32
|
|
|
33
|
-
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line
|
|
33
|
+
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input
|
|
34
34
|
|
|
35
35
|
# keyframe timestamps found next to a requested cut that the tolerance turned into a re-encode
|
|
36
36
|
# (reported so the caller can choose a lossless cut at one of them next time)
|
|
@@ -192,6 +192,7 @@ def main() -> int:
|
|
|
192
192
|
segments = [(s, min(e, total) if total else e) for s, e in segments]
|
|
193
193
|
|
|
194
194
|
output = args.output or default_output(args.input, "cut")
|
|
195
|
+
refuse_output_is_input(output, args.input)
|
|
195
196
|
ext = os.path.splitext(output)[1] or ".mp4"
|
|
196
197
|
|
|
197
198
|
reencoded = False
|
package/scripts/fit.py
CHANGED
|
@@ -38,7 +38,7 @@ import sys
|
|
|
38
38
|
from fractions import Fraction
|
|
39
39
|
from typing import List
|
|
40
40
|
|
|
41
|
-
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS
|
|
41
|
+
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg
|
|
42
42
|
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)}
|
|
43
43
|
|
|
44
44
|
|
|
@@ -155,7 +155,7 @@ def main() -> int:
|
|
|
155
155
|
|
|
156
156
|
# ---- duration
|
|
157
157
|
if args.duration:
|
|
158
|
-
target =
|
|
158
|
+
target = time_arg(args.duration, "--duration", meta["video"].get("fps") if meta.get("video") else None)
|
|
159
159
|
if target <= 0:
|
|
160
160
|
die("target duration must be > 0")
|
|
161
161
|
if args.method == "speed":
|
package/scripts/graphics.py
CHANGED
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import List, Optional
|
|
23
23
|
|
|
24
|
-
from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS
|
|
24
|
+
from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS, time_arg
|
|
25
25
|
|
|
26
26
|
TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
|
|
27
27
|
|
|
@@ -86,8 +86,9 @@ def main() -> int:
|
|
|
86
86
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
87
87
|
W, H = H, W
|
|
88
88
|
dur = meta.get("duration") or 0.0
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
fps = meta["video"].get("fps")
|
|
90
|
+
s = time_arg(args.start, "--start", fps) if args.start else 0.0
|
|
91
|
+
e = time_arg(args.end, "--end", fps) if args.end else dur
|
|
91
92
|
if e <= s:
|
|
92
93
|
die("--end must be after --start")
|
|
93
94
|
en = f"enable='between(t,{s:.3f},{e:.3f})'"
|
package/scripts/insert.py
CHANGED
|
@@ -27,7 +27,7 @@ import argparse
|
|
|
27
27
|
import math
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
|
-
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS
|
|
30
|
+
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def even(n: float) -> int:
|
|
@@ -52,7 +52,7 @@ def main() -> int:
|
|
|
52
52
|
args = ap.parse_args()
|
|
53
53
|
apply_common(args)
|
|
54
54
|
|
|
55
|
-
target =
|
|
55
|
+
target = time_arg(args.duration, "--duration", args.fps)
|
|
56
56
|
if target <= 0:
|
|
57
57
|
die("--duration must be > 0")
|
|
58
58
|
if args.fps <= 0:
|
package/scripts/look.py
CHANGED
|
@@ -15,7 +15,7 @@ import sys
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import List
|
|
17
17
|
|
|
18
|
-
from _common import add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run
|
|
18
|
+
from _common import add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
|
|
19
19
|
|
|
20
20
|
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
21
21
|
|
|
@@ -69,7 +69,7 @@ def main() -> int:
|
|
|
69
69
|
die("--compare needs --at TIME")
|
|
70
70
|
probe(args.compare)
|
|
71
71
|
for t in args.at:
|
|
72
|
-
sec =
|
|
72
|
+
sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
|
|
73
73
|
out = args.output or os.path.join(outdir, f"{stem}_vs_{Path(args.compare).stem}_{sec:.3f}s.png")
|
|
74
74
|
half = args.width // 2
|
|
75
75
|
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
@@ -82,7 +82,7 @@ def main() -> int:
|
|
|
82
82
|
outputs.append(out)
|
|
83
83
|
elif args.at:
|
|
84
84
|
for t in args.at:
|
|
85
|
-
sec =
|
|
85
|
+
sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
|
|
86
86
|
if dur and sec > dur:
|
|
87
87
|
die(f"--at {t} is beyond the duration ({dur:.2f}s)")
|
|
88
88
|
if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
|
package/scripts/loop.py
CHANGED
|
@@ -18,7 +18,7 @@ import argparse
|
|
|
18
18
|
import math
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS
|
|
21
|
+
from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def main() -> int:
|
|
@@ -49,7 +49,7 @@ def main() -> int:
|
|
|
49
49
|
target = None
|
|
50
50
|
stream_loop = args.times - 1
|
|
51
51
|
else:
|
|
52
|
-
target =
|
|
52
|
+
target = time_arg(args.duration, "--duration", meta["video"].get("fps") if meta.get("video") else None)
|
|
53
53
|
if target <= src_dur:
|
|
54
54
|
die(f"--duration ({target:g}s) must be longer than the source ({src_dur:.3f}s) -- use cut.py to trim instead")
|
|
55
55
|
stream_loop = math.ceil(target / src_dur) - 1
|
package/scripts/metadata.py
CHANGED
|
@@ -48,7 +48,7 @@ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
|
|
|
48
48
|
parts = line.split(None, 1)
|
|
49
49
|
try:
|
|
50
50
|
start = parse_time(parts[0])
|
|
51
|
-
except ValueError:
|
|
51
|
+
except ValueError: # MissingFpsError is a ValueError: chapter files carry no fps
|
|
52
52
|
die(f"{path}:{n}: cannot read the time in {line!r} (use seconds, mm:ss or hh:mm:ss.ms)")
|
|
53
53
|
title = parts[1].strip() if len(parts) > 1 else f"Chapter {len(entries) + 1}"
|
|
54
54
|
if entries and start <= entries[-1]["start"]:
|
package/scripts/overlay.py
CHANGED
|
@@ -24,7 +24,7 @@ import argparse
|
|
|
24
24
|
import sys
|
|
25
25
|
from typing import List, Optional
|
|
26
26
|
|
|
27
|
-
from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS
|
|
27
|
+
from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg
|
|
28
28
|
|
|
29
29
|
POS = {
|
|
30
30
|
"top-left": ("{m}", "{m}"),
|
|
@@ -155,8 +155,9 @@ def main() -> int:
|
|
|
155
155
|
if args.audio_stream and not audio_streams:
|
|
156
156
|
die("--audio-stream needs an input with audio streams")
|
|
157
157
|
vw = meta["video"]["width"]
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
fps = meta["video"].get("fps")
|
|
159
|
+
start = time_arg(args.start, "--start", fps) if args.start else None
|
|
160
|
+
end = time_arg(args.end, "--end", fps) if args.end else None
|
|
160
161
|
if start is not None and end is not None and end <= start:
|
|
161
162
|
die("--end must be after --start")
|
|
162
163
|
if not 0 <= args.opacity <= 1:
|
package/scripts/pad.py
CHANGED
|
@@ -16,15 +16,15 @@ Examples:
|
|
|
16
16
|
import argparse
|
|
17
17
|
import sys
|
|
18
18
|
|
|
19
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS
|
|
19
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def main() -> int:
|
|
23
23
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
24
24
|
ap.add_argument("input")
|
|
25
25
|
ap.add_argument("-o", "--output", help="output file (default: <name>_pad.<ext>)")
|
|
26
|
-
ap.add_argument("--start",
|
|
27
|
-
ap.add_argument("--end",
|
|
26
|
+
ap.add_argument("--start", default="0", help="padding to add before the clip: seconds or mm:ss (default 0)")
|
|
27
|
+
ap.add_argument("--end", default="0", help="padding to add after the clip: seconds or mm:ss (default 0)")
|
|
28
28
|
ap.add_argument("--color", default="black", help="padding colour (default black)")
|
|
29
29
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
30
30
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
@@ -32,6 +32,8 @@ def main() -> int:
|
|
|
32
32
|
args = ap.parse_args()
|
|
33
33
|
apply_common(args)
|
|
34
34
|
|
|
35
|
+
args.start = time_arg(args.start, "--start")
|
|
36
|
+
args.end = time_arg(args.end, "--end")
|
|
35
37
|
if args.start < 0 or args.end < 0:
|
|
36
38
|
die(f"--start/--end must be >= 0, got start={args.start:g} end={args.end:g}")
|
|
37
39
|
if args.start == 0 and args.end == 0:
|
package/scripts/scenes.py
CHANGED
|
@@ -24,7 +24,7 @@ import re
|
|
|
24
24
|
import sys
|
|
25
25
|
from typing import Dict, List, Tuple
|
|
26
26
|
|
|
27
|
-
from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run, run_analysis, decode_pcm_mono, rms_envelope
|
|
27
|
+
from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run, run_analysis, decode_pcm_mono, rms_envelope
|
|
28
28
|
|
|
29
29
|
SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
|
|
30
30
|
|
|
@@ -168,9 +168,10 @@ def main() -> int:
|
|
|
168
168
|
result["highlights_rank_by"] = args.rank_by
|
|
169
169
|
info(f"proposed {len(picks)} highlight ranges totalling {result['highlights_total']:.1f}s")
|
|
170
170
|
if args.edl:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
171
|
+
if not STATE.dry_run: # the contract says --edl is not written under --dry-run
|
|
172
|
+
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
173
|
+
for s, e in picks:
|
|
174
|
+
fh.write(f"{s:.2f}-{e:.2f}\n")
|
|
174
175
|
info(f"wrote {args.edl}")
|
|
175
176
|
|
|
176
177
|
if args.sheet:
|
package/scripts/silence.py
CHANGED
|
@@ -104,9 +104,10 @@ def main() -> int:
|
|
|
104
104
|
info("hint: " + summary["hint"])
|
|
105
105
|
|
|
106
106
|
if args.edl:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
if not STATE.dry_run: # the EDL is an artifact like the cut itself: a plan writes nothing
|
|
108
|
+
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
109
|
+
for s, e in keeps:
|
|
110
|
+
fh.write(f"{s:.3f}-{e:.3f}\n")
|
|
110
111
|
info(f"wrote {args.edl}")
|
|
111
112
|
|
|
112
113
|
if args.list:
|