ffmpeg-skill 2.17.1 → 2.17.3
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 +37 -0
- package/SKILL.md +1 -1
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +2 -2
- package/scripts/_contract.py +1 -0
- package/scripts/cut.py +30 -3
package/README.md
CHANGED
|
@@ -93,6 +93,12 @@ An agent that "knows FFmpeg" still guesses: it assumes a frame rate, picks a cod
|
|
|
93
93
|
- **Verification after execution.** The result is probed, checked against the destination's spec and, when the picture changed, looked at as a contact sheet.
|
|
94
94
|
- **Local first.** No cloud, no API keys, no Python dependencies. Optional local transcription is used when a speech engine (Parakeet or a whisper) is installed, never required — by `caption.py --transcribe` and, since 1.17, by `silence.py --filler --transcribe`; both take a transcript you already have instead, and both refuse with the install lines rather than guessing. Both take `--engine` and `--model`; `FFMPEG_SKILL_ASR_ENGINE` and `FFMPEG_SKILL_ASR_MODEL` set this machine's defaults (e.g. `large-v3-turbo` where that whisper.cpp model is installed; without either, auto and `base`). The engine runs on your machine and the audio is never uploaded; parakeet-mlx, faster-whisper and openai-whisper download their model weights the first time they run (parakeet-mlx's default from Hugging Face).
|
|
95
95
|
|
|
96
|
+
## Paper
|
|
97
|
+
|
|
98
|
+
The design discipline behind this skill — per-operation verification for AI agents — is written up as a position paper. The paper scores ffmpeg-skill itself as only partially conformant with the discipline it defines.
|
|
99
|
+
|
|
100
|
+
Kajiyama, S. (2026). *What Is Verification-Driven Coding? — A Discipline for Not Trusting Generation.* Zenodo. https://doi.org/10.5281/zenodo.23720216
|
|
101
|
+
|
|
96
102
|
## Quick start
|
|
97
103
|
|
|
98
104
|
```bash
|
|
@@ -392,6 +398,7 @@ The short list for humans. The agent-facing version, with the reasoning, is the
|
|
|
392
398
|
- **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`.
|
|
393
399
|
- **Outputs are never overwritten silently.** An existing output path is refused (`kind: input`, `error.reason: output_exists`, before anything runs); pass `--overwrite` where a replacement is intended.
|
|
394
400
|
- **Nothing happens silently.** A refusal names its `error.reason` and `error.details`; a stale output lock taken over, a run without a lock, a plan input nobody could fingerprint, a fallback (an odd-sized frame scaled to even, a substituted font, libaom for SVT-AV1), a skipped step and every warning are recorded in `diagnostics` and on stderr (codes: `json_output.diagnostic_codes`); tools that draw text name the font they drew with (`font_used`). A refused plan and a lock takeover also append a line to an audit log (`<plan>.audit.jsonl`, or `render.py --audit-log FILE`). A degraded or partial result says so: `render.py`'s `verified` is every stage's, an unmeasured loudness or a report section that could not be built is a failing verification step, `export.py` reports `trimmed`, `multicam.py`'s `cuts` name the camera rendered, and `batch.py` rows carry the failing step's `error`, `verified`, and a row for every input after Ctrl-C.
|
|
401
|
+
- **Long masters need a longer `--timeout`.** The default is 1800 s per ffmpeg call, which is there to catch a hang. A full re-encode of an hour of 1080p at the default preset ran past it on a 4-core machine (see [A one-hour recording](#a-one-hour-recording)). Pass `--timeout 0` or a larger value, or `--fast`. Analysis and stream-copy cuts are far below it.
|
|
395
402
|
- **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.
|
|
396
403
|
|
|
397
404
|
## FAQ
|
|
@@ -485,6 +492,36 @@ python3 tests/bench_scenes.py
|
|
|
485
492
|
|
|
486
493
|
Benchmarks live in `tests/bench_*.py`, agent evals in [evals/](evals/), results by iteration in `evals/results/`.
|
|
487
494
|
|
|
495
|
+
### A one-hour recording
|
|
496
|
+
|
|
497
|
+
Measured at 2.17.0 (2026-10-25) on one 60-minute 1080p30 H.264 file (4.3 GB, AAC mono 48 kHz,
|
|
498
|
+
synthetic `testsrc2` picture and a tone). The machine was a 4-vCPU Xeon at 2.1 GHz with FFmpeg
|
|
499
|
+
6.1.1 on Linux, and each tool ran alone. The analysis rows read the whole hour
|
|
500
|
+
(`silence.py`, `scenes.py` and `check.py` report 3600 s). The edit and export rows used 10-minute segments cut from it with
|
|
501
|
+
stream copy, and the last row is the whole hour.
|
|
502
|
+
|
|
503
|
+
| Step | Wall time | Note |
|
|
504
|
+
|---|---|---|
|
|
505
|
+
| `probe.py` | 0.3 s | |
|
|
506
|
+
| `silence.py --list` | 14 s | one audio decode |
|
|
507
|
+
| `loudness.py --measure-only` | 66 s | |
|
|
508
|
+
| `look.py` (contact sheet) | 97 s | |
|
|
509
|
+
| `scenes.py` | 241 s | |
|
|
510
|
+
| `check.py --platform youtube --content` | 308 s | the rows, `dropouts` included, cover the full hour |
|
|
511
|
+
| `cut.py`, a 10-minute stream-copy segment | 2.4–4.4 s | no re-encode |
|
|
512
|
+
| `join.py`, two 10-minute segments | 624 s | `join.py` always re-encodes, so it can crossfade and conform |
|
|
513
|
+
| `export.py --preset youtube`, a 10-minute segment | 514 s | about 1.2× real time at the default `medium` preset |
|
|
514
|
+
| `export.py --preset youtube --fast`, the same segment | 162 s | about 3.7× real time at `veryfast` |
|
|
515
|
+
| `export.py --preset youtube`, the whole hour | killed at 1802 s | `kind: timeout` at the default `--timeout` of 1800 s (below) |
|
|
516
|
+
|
|
517
|
+
**A full-length re-encode of an hour does not fit in the default `--timeout`.** The limit is
|
|
518
|
+
1800 s per ffmpeg call. On this machine `export.py --preset youtube` on the whole hour was
|
|
519
|
+
killed at 1802 s with `kind: timeout` (exit 124), and nothing was written. At the 10-minute
|
|
520
|
+
segment's rate it would have needed about 50 minutes. A whole-hour `join.py` would land at the
|
|
521
|
+
limit. The error message names the fix. For a long master, pass `--timeout 0` (no limit) or a
|
|
522
|
+
larger value, or `--fast`, which at the measured rate takes about 16 minutes for the hour.
|
|
523
|
+
Analysis tools and stream-copy cuts stay far below the limit.
|
|
524
|
+
|
|
488
525
|
## Install
|
|
489
526
|
|
|
490
527
|
```bash
|
package/SKILL.md
CHANGED
|
@@ -7,7 +7,7 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
|
|
|
7
7
|
|
|
8
8
|
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as this one; open one only for a question you have: `references/scripts.md` (every flag of all 42 scripts), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end). MCP: `tools/list` shows only the core 12 by default; the other 30 (per this table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
|
|
9
9
|
|
|
10
|
-
Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`; prefer on writing steps); `--fast` (preview quality); `--progress` (percent/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses if an input changed). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py --keep-hevc`: HEVC source stays HEVC), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
|
|
10
|
+
Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`; prefer on writing steps); `--fast` (preview quality); `--progress` (percent/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800; `0` for hour-long encodes); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses if an input changed). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py --keep-hevc`: HEVC source stays HEVC), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
|
|
11
11
|
|
|
12
12
|
Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0) may still run ffmpeg/ffprobe, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
|
|
13
13
|
|
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 (`2.17.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.17.3`) | 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`
|
|
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
131
131
|
"deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
|
|
132
132
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
133
133
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
134
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.17.
|
|
134
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.17.3", "execution_mode": "local", "kind": "execution",
|
|
135
135
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
136
136
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
137
137
|
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.17.
|
|
3
|
+
"version": "2.17.3",
|
|
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/references/scripts.md
CHANGED
|
@@ -64,7 +64,7 @@ own clock, `null` when unknown). `--compact` gives one line per file.
|
|
|
64
64
|
|
|
65
65
|
### cut.py — cut / join segments
|
|
66
66
|
```
|
|
67
|
-
cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,...] [--accurate] [--edit-list] [--keep-hevc] [--vfr-guard average|sampled|off] [-o OUT]
|
|
67
|
+
cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,...] [--accurate] [--declick-ms 20 | --no-declick] [--edit-list] [--keep-hevc] [--vfr-guard average|sampled|off] [-o OUT]
|
|
68
68
|
cut.py INPUT --start T --end T --snap beats [--snap-tolerance 0.12] [--snap-source FILE] [--min-confidence 0.5]
|
|
69
69
|
```
|
|
70
70
|
Times accept `12.5`, `1:30`, `00:01:30.250`. Default is `-c copy` (snaps to
|
|
@@ -75,7 +75,7 @@ keyframes, instant, lossless); if the snapped result deviates more than
|
|
|
75
75
|
`--codec` overrides all of them). `--accurate` always re-encodes; `--tolerance -1` never does for a keyframe snap
|
|
76
76
|
(a `--segments` join of open-GOP video, one whose later B-frame `.mp4`/`.mov` segment starts
|
|
77
77
|
between keyframes, or one that fails its check, still re-cuts).
|
|
78
|
-
Multiple segments are concatenated in the order given. stderr reports whether
|
|
78
|
+
Multiple segments are concatenated in the order given. Since 2.17.2 every join the re-encode makes fades the sound for `--declick-ms` (20 ms) on each side, so butted cuts do not click; `--no-declick` butts them as before, and a stream-copy join (sound copied) is not faded. The result reports `declick_ms` (null when there was no re-encoded join). stderr reports whether
|
|
79
79
|
the result was "lossless stream copy" or "re-encoded"; when the snap forced a
|
|
80
80
|
re-encode, the result's `lossless_alternative` names the nearest keyframe
|
|
81
81
|
`--start` that would stream-copy instead, so the trade can be offered.
|
package/scripts/_contract.py
CHANGED
|
@@ -637,6 +637,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
637
637
|
"vfr_check": {"type": "object", "description": "the sampled frame timing of a video input that could still stream-copy: under --vfr-guard average (the default) only when its nominal and average frame rates differ, which re-encodes it (reason vfr) whatever the sample says; under sampled or off on every such input (absent for audio-only input or output, --accurate, --codec, or a --dry-run input a plan has not written yet): {heuristic: true, measured, method: \"sampled\", guard, windows, deltas}. measured is sampled_cfr (every packet interval in up to five 6 s windows within a tick, at least 1 ms and at most half a frame, of its window's median, and the windows agree; the file-start window's first interval is ignored), vfr (under sampled, forces a re-encode, reason vfr) or inconclusive (fewer than 20 intervals or ffprobe failed; under sampled, reason vfr_inconclusive). --vfr-guard off keeps the copy either way, with a note. Variable timing between the windows is not seen"},
|
|
638
638
|
"lossless_alternative": {"type": ["string", "null"], "description": "when a lossless cut re-encoded because of the keyframe snap: the --start that would stream-copy instead, and how far it is from the request"},
|
|
639
639
|
"join_check": {"type": ["object", "null"], "description": "--segments video stream-copy join: the written file measured by demuxing, {packets, expected_packets, max_step_seconds, audio_offset_ms, audio_parts_checked, ok}. expected_packets counts the source's video packets from each part's start keyframe to its end (or the end of the video); max_step_seconds is the largest presentation step (null when vfr_check measured anything but sampled_cfr, e.g. a --vfr-guard off join of variable timing, which checks the count only), and each step at a join must be the step its parts predict within 1 ms; audio_offset_ms is, per part, its sound's shift minus its picture's (+ = sound late) from audio packets matched by CRC32 to the source stream shaped like the joined audio (FFmpeg's default selection may copy one other than a:0; an MPEG-TS source's AAC is hashed without its ADTS headers) (null for a part with no unique match, e.g. digital silence; null as a whole when the source has no audio or the video check failed), and audio_parts_checked counts the measured parts; ok is the conjunction (counts, steps, every measured part within 5 ms), and false means the join was re-cut from the source (concat_fallback) and this is the failed measurement. null for a single segment, audio, --dry-run, --accurate/--codec/a forced re-encode, a segment that re-encoded, or a join that fell back before a copy join was attempted (open GOPs, a later B-frame segment starting between keyframes, parts that differ, a concat error)"},
|
|
640
|
+
"declick_ms": {"type": ["number", "null"], "description": "--segments joined by the re-encode (--accurate, a forced re-encode or concat_fallback): the fade in ms asked for on each side of every join, so butted cuts do not click (20 by default, --declick-ms; 0 under --no-declick; the file's own start and end are not faded). Each fade is capped at half its segment, so a segment shorter than twice this is faded for half its length. null when there is no join to fade: a single segment, a source with no audio, or a stream-copy join, whose sound is copied and not faded"},
|
|
640
641
|
"segment_end_snap_seconds": {"type": ["array", "null"], "items": {"type": ["number", "null"]}, "description": "--segments stream-copy join: per segment, the signed seconds its end moved to the keyframe the copied part stops at (the first keyframe at or after the requested end, never an earlier one; null for a part that runs to the end of the video, a part of a source with no B-frames, which is cut to its requested end as in every 2.x release, or a Matroska/MPEG-TS part, which is not snapped). null as a whole unless the output is those copied parts: a single segment, audio, --dry-run, or any re-encode or fallback"}}
|
|
641
642
|
elif name == "multicam":
|
|
642
643
|
extra = {"cuts": {"type": "array", "description": "[[start, end, camera], ...] on the reference timeline: the camera actually rendered. A range whose camera had not started yet plays camera 0, and diagnostics.fallbacks names the camera asked for (code camera_not_started)"},
|
package/scripts/cut.py
CHANGED
|
@@ -48,6 +48,7 @@ Examples:
|
|
|
48
48
|
import argparse
|
|
49
49
|
import contextlib
|
|
50
50
|
import json
|
|
51
|
+
import math
|
|
51
52
|
import os
|
|
52
53
|
import sys
|
|
53
54
|
import tempfile
|
|
@@ -908,6 +909,11 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
908
909
|
# The most segments one fallback ffmpeg call opens: every segment is its own input (a file handle,
|
|
909
910
|
# a demuxer and a decoder each), and macOS shells default to 256 open files.
|
|
910
911
|
JOIN_CHUNK = 32
|
|
912
|
+
# Seconds of fade on each side of every --segments join the re-encode makes (--declick-ms / --no-declick).
|
|
913
|
+
# Butting two cuts together leaves a step in the waveform wherever they were not both at zero, which
|
|
914
|
+
# plays as a click: measured on a 440 Hz tone, the step at the join was 14x the tone's own largest
|
|
915
|
+
# sample-to-sample change. A 20 ms fade is too short to hear as a dip and long enough to remove it.
|
|
916
|
+
DECLICK_S = 0.02
|
|
911
917
|
# how far before a segment the fallback seeks, so a start inside the B-frame reorder delay before a
|
|
912
918
|
# keyframe still decodes from the keyframe BEFORE it
|
|
913
919
|
SEEK_MARGIN = 1.0
|
|
@@ -1017,7 +1023,8 @@ def _grid_at_or_after(t: float, v0: float, rate: float) -> float:
|
|
|
1017
1023
|
|
|
1018
1024
|
|
|
1019
1025
|
def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: dict, crf: int, preset: str,
|
|
1020
|
-
has_v: bool, intermediate: bool = False, vend=None, hold=frozenset(), first: int = 0
|
|
1026
|
+
has_v: bool, intermediate: bool = False, vend=None, hold=frozenset(), first: int = 0,
|
|
1027
|
+
total=None) -> List[str]:
|
|
1021
1028
|
"""Re-cut `segments` from the source into one file through the concat filter, returning the codec
|
|
1022
1029
|
arguments it encoded with. Each segment is
|
|
1023
1030
|
its own seeked input, so both of its streams start at the segment's origin (0); neither is
|
|
@@ -1072,8 +1079,16 @@ def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: d
|
|
|
1072
1079
|
graph.append(f"[{i}:v:0]{pad}trim=start={a - half:.6f}:end={b - half:.6f},setpts=PTS-{a:.6f}/TB[v{i}]")
|
|
1073
1080
|
pads += f"[v{i}]"
|
|
1074
1081
|
if has_a:
|
|
1082
|
+
# fade only the sides that meet another segment: the file's own start and end stay as cut
|
|
1083
|
+
n = first + i
|
|
1084
|
+
fade = min(DECLICK_S, (b - a) / 2)
|
|
1085
|
+
fades = ""
|
|
1086
|
+
if fade > 0 and n > 0:
|
|
1087
|
+
fades += f",afade=t=in:st=0:d={fade:.6f}"
|
|
1088
|
+
if fade > 0 and n < (len(segments) if total is None else total) - 1:
|
|
1089
|
+
fades += f",afade=t=out:st={b - a - fade:.6f}:d={fade:.6f}"
|
|
1075
1090
|
graph.append(f"[{i}:a:0]atrim=start={a:.6f}:end={b:.6f},asetpts=PTS-{a:.6f}/TB,"
|
|
1076
|
-
f"aresample=async=1:first_pts=0[a{i}]")
|
|
1091
|
+
f"aresample=async=1:first_pts=0{fades}[a{i}]")
|
|
1077
1092
|
pads += f"[a{i}]"
|
|
1078
1093
|
outs = ("[v]" if has_v else "") + ("[a]" if has_a else "")
|
|
1079
1094
|
graph.append(f"{pads}concat=n={len(segments)}:v={int(has_v)}:a={int(has_a)}{outs}")
|
|
@@ -1120,7 +1135,7 @@ def join_from_source(src: str, segments: List[Tuple[float, float]], dst: str, me
|
|
|
1120
1135
|
for k in range(0, len(segments), JOIN_CHUNK):
|
|
1121
1136
|
chunk = os.path.join(tmp, f"chunk{k // JOIN_CHUNK:03d}.mkv")
|
|
1122
1137
|
args = _join_chunk(src, segments[k:k + JOIN_CHUNK], chunk, meta, crf, preset, has_v, intermediate=True,
|
|
1123
|
-
vend=vend, hold=hold, first=k) or []
|
|
1138
|
+
vend=vend, hold=hold, first=k, total=len(segments)) or []
|
|
1124
1139
|
if "-tag:v" in args:
|
|
1125
1140
|
tag[:] = args[args.index("-tag:v"):args.index("-tag:v") + 2]
|
|
1126
1141
|
out.append(chunk)
|
|
@@ -1287,6 +1302,7 @@ def _join_workspace():
|
|
|
1287
1302
|
|
|
1288
1303
|
|
|
1289
1304
|
def main() -> int:
|
|
1305
|
+
global DECLICK_S
|
|
1290
1306
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1291
1307
|
ap.add_argument("input")
|
|
1292
1308
|
ap.add_argument("-o", "--output", help="output file (default: <name>_cut.<ext>)")
|
|
@@ -1306,6 +1322,11 @@ def main() -> int:
|
|
|
1306
1322
|
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 for a "
|
|
1307
1323
|
"keyframe snap; a --segments join is still re-cut for open GOPs, a later B-frame .mp4/.mov "
|
|
1308
1324
|
"segment starting between keyframes, or a failed join check)")
|
|
1325
|
+
dc = ap.add_mutually_exclusive_group()
|
|
1326
|
+
dc.add_argument("--declick-ms", type=float, default=DECLICK_S * 1000,
|
|
1327
|
+
help="--segments: fade the sound this many ms on each side of every join the re-encode makes, so "
|
|
1328
|
+
"butted cuts do not click (default %(default)g; a stream-copy join is not faded)")
|
|
1329
|
+
dc.add_argument("--no-declick", action="store_true", help="--segments: butt the joins with no fade")
|
|
1309
1330
|
snap = ap.add_argument_group("beat snapping")
|
|
1310
1331
|
snap.add_argument("--snap", choices=["none", "beats"], default="none",
|
|
1311
1332
|
help="move each in/out point to the nearest measured beat (default none)")
|
|
@@ -1456,6 +1477,9 @@ def main() -> int:
|
|
|
1456
1477
|
args.accurate = True
|
|
1457
1478
|
forced.append("vfr" if measured == "vfr" else "vfr_inconclusive")
|
|
1458
1479
|
|
|
1480
|
+
if not math.isfinite(args.declick_ms) or args.declick_ms < 0:
|
|
1481
|
+
die(f"--declick-ms must be a finite number of ms, 0 or more, got {args.declick_ms:g}")
|
|
1482
|
+
DECLICK_S = 0.0 if args.no_declick else args.declick_ms / 1000
|
|
1459
1483
|
outcomes: List[dict] = []
|
|
1460
1484
|
join_reencoded = False
|
|
1461
1485
|
join_check = None
|
|
@@ -1665,6 +1689,9 @@ def main() -> int:
|
|
|
1665
1689
|
segment_precision=report["segment_precision"],
|
|
1666
1690
|
edit_list=bool(single.get("edit_list")), stored_preroll_seconds=stored, av_start_skew_seconds=skew,
|
|
1667
1691
|
join_check=join_check, segment_end_snap_seconds=end_snaps,
|
|
1692
|
+
# the joins were faded only where the concat filter re-encoded them
|
|
1693
|
+
declick_ms=(DECLICK_S * 1000 if len(segments) > 1 and (join_reencoded or args.accurate) and meta.get("audio")
|
|
1694
|
+
else None),
|
|
1668
1695
|
notes=notes, **({"vfr_check": vfr_check} if vfr_check else {}),
|
|
1669
1696
|
nearest_keyframes=sorted(NEAREST_KEYFRAMES) if NEAREST_KEYFRAMES else None,
|
|
1670
1697
|
# the trade the caller can offer instead of a re-encode (eval e02: "without losing quality")
|