ffmpeg-skill 2.17.0 → 2.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -392,6 +392,7 @@ The short list for humans. The agent-facing version, with the reasoning, is the
392
392
  - **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
393
  - **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
394
  - **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.
395
+ - **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
396
  - **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
397
 
397
398
  ## FAQ
@@ -485,6 +486,36 @@ python3 tests/bench_scenes.py
485
486
 
486
487
  Benchmarks live in `tests/bench_*.py`, agent evals in [evals/](evals/), results by iteration in `evals/results/`.
487
488
 
489
+ ### A one-hour recording
490
+
491
+ Measured at 2.17.0 (2026-10-25) on one 60-minute 1080p30 H.264 file (4.3 GB, AAC mono 48 kHz,
492
+ synthetic `testsrc2` picture and a tone). The machine was a 4-vCPU Xeon at 2.1 GHz with FFmpeg
493
+ 6.1.1 on Linux, and each tool ran alone. The analysis rows read the whole hour
494
+ (`silence.py`, `scenes.py` and `check.py` report 3600 s). The edit and export rows used 10-minute segments cut from it with
495
+ stream copy, and the last row is the whole hour.
496
+
497
+ | Step | Wall time | Note |
498
+ |---|---|---|
499
+ | `probe.py` | 0.3 s | |
500
+ | `silence.py --list` | 14 s | one audio decode |
501
+ | `loudness.py --measure-only` | 66 s | |
502
+ | `look.py` (contact sheet) | 97 s | |
503
+ | `scenes.py` | 241 s | |
504
+ | `check.py --platform youtube --content` | 308 s | the rows, `dropouts` included, cover the full hour |
505
+ | `cut.py`, a 10-minute stream-copy segment | 2.4–4.4 s | no re-encode |
506
+ | `join.py`, two 10-minute segments | 624 s | `join.py` always re-encodes, so it can crossfade and conform |
507
+ | `export.py --preset youtube`, a 10-minute segment | 514 s | about 1.2× real time at the default `medium` preset |
508
+ | `export.py --preset youtube --fast`, the same segment | 162 s | about 3.7× real time at `veryfast` |
509
+ | `export.py --preset youtube`, the whole hour | killed at 1802 s | `kind: timeout` at the default `--timeout` of 1800 s (below) |
510
+
511
+ **A full-length re-encode of an hour does not fit in the default `--timeout`.** The limit is
512
+ 1800 s per ffmpeg call. On this machine `export.py --preset youtube` on the whole hour was
513
+ killed at 1802 s with `kind: timeout` (exit 124), and nothing was written. At the 10-minute
514
+ segment's rate it would have needed about 50 minutes. A whole-hour `join.py` would land at the
515
+ limit. The error message names the fix. For a long master, pass `--timeout 0` (no limit) or a
516
+ larger value, or `--fast`, which at the measured rate takes about 16 minutes for the hour.
517
+ Analysis tools and stream-copy cuts stay far below the limit.
518
+
488
519
  ## Install
489
520
 
490
521
  ```bash
@@ -497,13 +528,15 @@ npx ffmpeg-skill --dir ./my-skills
497
528
  npx ffmpeg-skill --uninstall # remove from the selected targets (--codex also clears the older ~/.codex/skills location)
498
529
  ```
499
530
 
500
- As a Claude Code plugin (no Node needed, updates with `claude plugin update`):
531
+ As a Claude Code plugin (no Node needed, updates with `claude plugin update`). This repository is its own plugin marketplace:
501
532
 
502
533
  ```bash
503
- claude plugin install kajisho5/ffmpeg-skill
534
+ /plugin install ffmpeg-skill --marketplace kajisho5/ffmpeg-skill # in Claude Code 2.1.275 or later
535
+ claude plugin marketplace add kajisho5/ffmpeg-skill # older versions: add the marketplace,
536
+ claude plugin install ffmpeg-skill@ffmpeg-skill # then install from it
504
537
  ```
505
538
 
506
- The plugin namespaces the skill as `ffmpeg-skill:ffmpeg-skill`; the manifest is [.claude-plugin/plugin.json](.claude-plugin/plugin.json) and its version follows every release automatically.
539
+ The plugin namespaces the skill as `ffmpeg-skill:ffmpeg-skill`. The manifests are [.claude-plugin/plugin.json](.claude-plugin/plugin.json), whose version follows every release automatically, and [.claude-plugin/marketplace.json](.claude-plugin/marketplace.json), which lists the plugin from the repository root. Before 2.17.1 the README's one-line `claude plugin install kajisho5/ffmpeg-skill` could not work, because there was no marketplace file.
507
540
 
508
541
  Without Node: clone this repository and copy `SKILL.md`, `scripts/`, `references/` and `mcp/` into your agent's skills directory.
509
542
 
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.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.17.2`) | 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.0", "execution_mode": "local", "kind": "execution",
134
+ "skill": {"id": "ffmpeg-skill", "version": "2.17.2", "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.0",
3
+ "version": "2.17.2",
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",
@@ -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.
@@ -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) -> List[str]:
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")