ffmpeg-skill 3.8.21 → 3.8.22

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/docs/contract.md CHANGED
@@ -26,7 +26,7 @@ The contract is derived from the code that runs, not maintained beside it:
26
26
  | Field | Meaning | Changes when |
27
27
  |---|---|---|
28
28
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
29
- | `skill.version` | the npm / package.json version (`3.8.21`) | any release |
29
+ | `skill.version` | the npm / package.json version (`3.8.22`) | any release |
30
30
 
31
31
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
32
32
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -154,7 +154,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
154
154
  "deprecated": [],
155
155
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
156
156
  {"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"}],
157
- "skill": {"id": "ffmpeg-skill", "version": "3.8.21", "execution_mode": "local", "kind": "execution",
157
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.22", "execution_mode": "local", "kind": "execution",
158
158
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
159
159
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
160
160
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.21",
3
+ "version": "3.8.22",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "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.",
6
6
  "keywords": [
@@ -711,20 +711,80 @@ def graph_with_cfr(graph: str, label: str, meta: Optional[Dict[str, Any]],
711
711
  return f"{graph};[{bare}]{f}[{bare}cfr]", f"[{bare}cfr]"
712
712
 
713
713
 
714
- def cfr_at_input(graph: str, meta: Optional[Dict[str, Any]], fps: Optional[float] = None, src: str = "[0:v]") -> str:
714
+ def cfr_at_input(graph: str, meta: Optional[Dict[str, Any]], fps: Optional[float] = None, src: str = "[0:v]",
715
+ origin: Optional[float] = None) -> str:
715
716
  """A -filter_complex graph with cfr_filter() on its main picture as it is decoded (`src`, which
716
717
  the graph reads once), for a graph that composites onto that picture and moves no frame in
717
718
  time. After an overlay the conform comes too late: FFmpeg 6.1's overlay ends its output at its
718
- last frame's start, so an fps filter there lost the last frame (3.963 s of a 4 s picture)."""
719
+ last frame's start, so an fps filter there lost the last frame (3.963 s of a 4 s picture).
720
+ With `origin` (where the picture starts on the graph's clock, stream_starts()) the picture is
721
+ moved to start at 0 ahead of the conform, so the grid is laid from its first frame, and the
722
+ conformed frames get a microsecond time base, for a graph that cuts the picture on that grid
723
+ (freeze.py, #523): the caller counts the picture's times from its own start. The fps
724
+ filter's grid starts at the clock's 0, so a picture that starts 50 ms in was cut a frame off
725
+ the times computed from its start, and its output's time base is one frame, to which `trim`
726
+ rescales its bound (a half-frame bound written to the microsecond, 3.907407 at 27 fps, became
727
+ the frame at 3.888889 and cut it). The picture is not moved back after the conform: from
728
+ FFmpeg 7.0 setpts leaves its output frames no duration and no rate, and a tpad after it (the
729
+ hold) cloned nothing: 109 frames for 135 on 7.1."""
719
730
  f = cfr_filter(meta, fps)
720
731
  if not f:
721
732
  return graph
722
733
  if graph.count(src) != 1:
723
734
  # a graph that read the picture twice, or not at all, would go unconformed or not parse
724
735
  raise ValueError(f"cfr_at_input: the graph reads {src} {graph.count(src)} times, not once")
736
+ if origin is not None:
737
+ f = f"setpts=PTS-{origin:.6f}/TB,{f},settb=AVTB" if origin else f"{f},settb=AVTB"
725
738
  return f"{src}{f}[cfrin];" + graph.replace(src, "[cfrin]")
726
739
 
727
740
 
741
+ def atempo_lead(sample_rate: Any) -> int:
742
+ """How many samples of its input `atempo` leaves out at the start: half its window, which is
743
+ the power of two at or above sample_rate / 24 (2048 at 44.1 and 48 kHz, so 1024 samples, 21 ms
744
+ at 48 kHz). Its output opens on the sample half a window in (measured at 8, 22.05, 32, 44.1,
745
+ 48 and 96 kHz on FFmpeg 6.1.1), so every sound after it is that much early: speedramp.py's beeps
746
+ came 21 ms early at 1x and 40 ms at 0.5x, and fit.py --method speed's the same (#523). 0 when
747
+ the rate is not known (a dry run's pending input)."""
748
+ try:
749
+ rate = int(sample_rate or 0)
750
+ except (TypeError, ValueError):
751
+ return 0
752
+ window = rate // 24
753
+ if window < 1:
754
+ return 0
755
+ pot = 1 << (window.bit_length() - 1)
756
+ return (pot * 2 if pot < window else pot) // 2
757
+
758
+
759
+ def atempo_chain_with_lead(stages: List[str], sample_rate: Any) -> str:
760
+ """`atempo` stages (each value as written) led by silence and trimmed after, so the output
761
+ keeps the input's timing from its first sample: a beep at 0.5 s of a 1x stage came out at
762
+ 0.479 s (FFmpeg 6.1.1). Each stage leaves out atempo_lead() samples of its own input, which
763
+ are the earlier stages' tempo product times that many of the chain's input, so one lead of
764
+ their sum goes in front of the first stage: an `adelay` between two stages made FFmpeg 6.1.1
765
+ fail a graph that joins the result with `concat` ("Invalid data found when processing
766
+ input", speedramp.py with a 0.25x segment). That lead alone put the chain's first window,
767
+ which atempo fades in, over the input's first 21 ms: a 10 ms attack there came out 7 dB down
768
+ at 1x and was gone at 2x (#541 review). So one whole window more of silence goes in front
769
+ (as many of the chain's input samples as leave a window of it ahead of every stage), and
770
+ that much of the output, at the chain's tempo, is cut off again and its timestamps moved
771
+ back by it, so a track that starts after 0 keeps its start. The bare stages when the rate is
772
+ not known (a dry run's pending input)."""
773
+ lead = atempo_lead(sample_rate)
774
+ chain = ",".join(f"atempo={value}" for value in stages)
775
+ if not lead:
776
+ return chain
777
+ total, product, widest = 0.0, 1.0, 1.0
778
+ for value in stages:
779
+ total += lead * product
780
+ product *= float(value)
781
+ widest = max(widest, product)
782
+ extra = round(2 * lead * widest) # a window of silence ahead of the stage that shrinks it most
783
+ cut = round(extra / product)
784
+ return (f"adelay={round(total) + extra}S:all=1,{chain},"
785
+ f"atrim=start_sample={cut},asetpts=PTS-{cut}/SR/TB")
786
+
787
+
728
788
  def delivered_rate(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> Optional[float]:
729
789
  """The constant rate cfr_args() forces on the output (its -r), or None when it forces none and
730
790
  the output keeps the source's rate -- what a start timecode is counted at
package/scripts/audio.py CHANGED
@@ -30,7 +30,7 @@ from typing import Any, Dict, List
30
30
  from _common.probe import SILENT_PEAK_DBFS
31
31
  from _common import STATE, add_common, dry_run_input_pending, require_tool, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, measured_level_dbfs, probe, run, run_keeping_subtitles, fmt_secs
32
32
  from _common.emit import file_origin, stream_end, warn
33
- from _common.decision import LOSSLESS_AUDIO_EXT, conform_filter, conform_to_length
33
+ from _common.decision import LOSSLESS_AUDIO_EXT, atempo_chain_with_lead, conform_filter, conform_to_length
34
34
 
35
35
  # --downmix: ITU-R BS.775 fold-down, renormalised by pan's '<' (see main()).
36
36
  DOWNMIX_PAN = "pan=stereo|FL<FL+0.707*FC+0.707*BL+0.707*SL|FR<FR+0.707*FC+0.707*BR+0.707*SR"
@@ -559,7 +559,9 @@ def main() -> int:
559
559
  fx.append(f"extrastereo=m={1 + 2 * args.stereo_widen:g}")
560
560
  tempo_steps = atempo_steps(args.tempo) if args.tempo is not None and args.tempo != 1.0 else []
561
561
  if tempo_steps:
562
- fx.append(",".join(f"atempo={f:.10g}" for f in tempo_steps))
562
+ # led by the samples atempo leaves out, at the rate the track has there (the source's:
563
+ # --sample-rate resamples after the chain), or it opened 21 ms in (#523)
564
+ fx.append(atempo_chain_with_lead([f"{f:.10g}" for f in tempo_steps], main_track_rate(args, meta)))
563
565
  # the main track's length in the output: the source's, or the source's / --tempo on an audio
564
566
  # output of the input's own sound (beside a picture, or for --replace, it is the picture's)
565
567
  main_dur = dur / args.tempo if dur and tempo_steps and not (keep_video or args.replace) else dur
package/scripts/fit.py CHANGED
@@ -36,9 +36,9 @@ Examples:
36
36
  import argparse
37
37
  import sys
38
38
  from fractions import Fraction
39
- from typing import List
39
+ from typing import Any, List
40
40
 
41
- from _common.decision import SEEK_MARGIN, delivered_rate, frame_durations, frame_rate, grid_at_or_after, length_output_t, stream_starts
41
+ from _common.decision import SEEK_MARGIN, atempo_chain_with_lead, delivered_rate, frame_durations, frame_rate, grid_at_or_after, length_output_t, stream_starts
42
42
  from _common.timeline import exact_rate, output_timecode_args
43
43
  from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, cfr_filter, default_output, die, ffmpeg_base, info, probe, run, run_keeping_subtitles, validate_color, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
44
44
  from _common.emit import input_length
@@ -53,18 +53,21 @@ def parse_aspect(value: str) -> Fraction:
53
53
  return ratio
54
54
 
55
55
 
56
- def atempo_chain(factor: float) -> str:
57
- """atempo accepts 0.5..100 per instance; chain for extreme factors."""
56
+ def atempo_chain(factor: float, sample_rate: Any = 0) -> str:
57
+ """atempo accepts 0.5..100 per instance; chain for extreme factors. The chain is led by the
58
+ samples atempo leaves out (atempo_chain_with_lead, at the track's `sample_rate`), so the
59
+ retimed sound keeps its timing against the retimed picture (#523: about 21 ms early in input
60
+ time without them)."""
58
61
  parts: List[str] = []
59
62
  remaining = factor
60
63
  while remaining < 0.5:
61
- parts.append("atempo=0.5")
64
+ parts.append("0.5")
62
65
  remaining /= 0.5
63
66
  while remaining > 100.0:
64
- parts.append("atempo=100.0")
67
+ parts.append("100.0")
65
68
  remaining /= 100.0
66
- parts.append(f"atempo={remaining:.6f}")
67
- return ",".join(parts)
69
+ parts.append(f"{remaining:.6f}")
70
+ return atempo_chain_with_lead(parts, sample_rate)
68
71
 
69
72
 
70
73
  def picture_output_t(target: float, meta: dict) -> str:
@@ -218,7 +221,7 @@ def main() -> int:
218
221
  vf.append(frame_durations(meta, delivered_rate(meta) or frame_rate(meta) or src_fps))
219
222
  regridded = True
220
223
  if has_audio:
221
- af.append(atempo_chain(factor))
224
+ af.append(atempo_chain(factor, rate))
222
225
  # atempo's sound is not the input's length over the factor (it comes out some
223
226
  # milliseconds short or long), and the millisecond -t stays on a retimed track: padding
224
227
  # it to the target's samples (apad after atempo in -af) never ended on FFmpeg 8.0.1
package/scripts/freeze.py CHANGED
@@ -18,8 +18,9 @@ Examples:
18
18
  """
19
19
  import argparse
20
20
  import sys
21
+ from fractions import Fraction
21
22
 
22
- from _common.decision import av_trim, delivered_rate, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
23
+ from _common.decision import av_trim, cfr_at_input, cfr_filter, delivered_rate, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
23
24
  from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, time_arg, fmt_secs, run
24
25
  from _common.emit import input_length
25
26
 
@@ -63,6 +64,15 @@ def main() -> int:
63
64
  # starts 0.1 s late 0.1 s early (147 frames for 150).
64
65
  v0 = stream_starts(meta)[0]
65
66
  rate = frame_rate(meta) or fps
67
+ if cfr_filter(meta):
68
+ # A VFR source is put on the delivered grid as it is decoded (the graph's cfr_at_input
69
+ # below), and every frame time is on that grid (#523): on the source's own frames the
70
+ # head ended at its last frame's nominal duration, so with --at inside a gap the hold, and
71
+ # everything after it, started the rest of the gap early against the sound, which is cut
72
+ # at --at (56 ms at --at 0.15, 93 ms at --at 2.2 on a 27 fps average clip with gaps from
73
+ # 0.1 and 2.1 s). A hold is then counted in frames of that rate, the rate its frames are
74
+ # cloned at.
75
+ rate = delivered_rate(meta) or rate
66
76
  cut_at = grid_at_or_after(at, v0, rate)
67
77
  # no frame at or after --at (no --at holds at the clip's end): the insert tail would be empty
68
78
  # and the hold lost, so hold the last frame as --mode extend does (#416 review)
@@ -98,8 +108,11 @@ def main() -> int:
98
108
  # the video actually holds for, a permanent A/V drift from this point on. Pad audio by
99
109
  # the same, frame-rounded duration the video actually gets.
100
110
  actual_hold = n / rate
101
- head_v, head_a = av_trim(v0, cut_at, rate)
102
- tail_v, tail_a = av_trim(cut_at, None, rate)
111
+ # a conformed VFR picture starts at 0 on the graph's clock (cfr_at_input's origin), so its
112
+ # trims are counted from its own start; the sound's stay on the input's clock
113
+ vo = v0 if cfr_filter(meta) else 0.0
114
+ head_v, head_a = av_trim(v0 - vo, cut_at - vo, rate)[0], av_trim(v0, cut_at, rate)[1]
115
+ tail_v, tail_a = av_trim(cut_at - vo, None, rate)[0], av_trim(cut_at, None, rate)[1]
103
116
  # The hold is its own segment: the frame at cut_at, cut on the input's clock and cloned to
104
117
  # n frames there (tpad's stop clone), then shifted like the tail. From FFmpeg 7.0 setpts
105
118
  # leaves its output's frame rate unknown and its frames' durations 0, so the earlier tpad
@@ -114,11 +127,27 @@ def main() -> int:
114
127
  half = 0.5 / rate
115
128
  # the graph closes on the rate the encoder line delivers (a VFR source is conformed to its
116
129
  # average): closed on r_frame_rate, -r then resampled it and the picture ran late (#512)
117
- hold_v = (f"trim=start={cut_at - half:.6f},trim=end_frame=1,"
130
+ hold_v = (f"trim=start={cut_at - vo - half:.6f},trim=end_frame=1,"
118
131
  f"tpad=stop_mode=clone:stop={n - 1},setpts=PTS-STARTPTS")
132
+ # Every segment is a whole count of frames on one grid, so the joined picture's timestamps
133
+ # are rebuilt from its running frame count before the closing fps: FFmpeg 6.1.1's video
134
+ # concat gives a one-frame segment no length (it ends a segment at its last frame's
135
+ # timestamp), so a hold of one frame (--hold 0.04 at 30 fps) was dropped and the rest of
136
+ # the picture ran a frame before its sound, as speedramp.py's one-frame segments did
137
+ grid = frame_durations(meta, delivered_rate(meta) or rate)
138
+ q = Fraction(grid[len("fps="):]) if "/" in grid else Fraction(delivered_rate(meta) or rate).limit_denominator(1001)
119
139
  vf = (f"[0:v]split=3[a][b][c];[a]{head_v}[head];"
120
140
  f"[b]{hold_v}[hold];[c]{tail_v}[tail];"
121
- f"[head][hold][tail]concat=n=3:v=1:a=0,{frame_durations(meta, delivered_rate(meta) or rate)}[outv]")
141
+ f"[head][hold][tail]concat=n=3:v=1:a=0,setpts=N*{q.denominator}/({q.numerator}*TB),{grid}[outv]")
142
+ # the grid is laid from the picture's start (the picture moved to 0 ahead of the fps
143
+ # filter, and the trims above counted from there), on a microsecond time base: laid from
144
+ # the clock's 0, a picture that starts 50 ms in came out a frame early after the hold, and
145
+ # on the fps filter's base of one frame the trim's half-frame bound 3.907407 (--at 3.9)
146
+ # became frame 105 itself, which was cut from the head and held in place of frame 106.
147
+ # It is not moved back after the conform: from FFmpeg 7.0 a setpts leaves its frames no
148
+ # duration and no rate, and the hold's tpad after it cloned nothing (109 frames for 135
149
+ # on 7.1)
150
+ vf = cfr_at_input(vf, meta, origin=v0)
122
151
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", "[outv]"]
123
152
  if has_audio:
124
153
  af = (f"[0:a]asplit[aa][ab];[aa]{head_a}[ahead];"
@@ -15,7 +15,7 @@ import argparse
15
15
  import math
16
16
  import sys
17
17
 
18
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
18
+ from _common import add_common, apply_common, aac_args, cfr_args, cfr_filter, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
19
19
  from _common.emit import warn
20
20
  from _common.decision import (HELD_FRAMES_WARN_BYTES, av_clip_length, av_trim, delivered_rate, frame_durations, frame_rate,
21
21
  held_frames_bytes, stream_starts)
@@ -49,6 +49,17 @@ def main() -> int:
49
49
  f"({vdur:.0f}s of {v['width']}x{v['height']}) -- cut the part to reverse first (cut.py) if RAM is tight",
50
50
  "memory_heavy", duration=meta.get("duration"), bytes=held)
51
51
  cmd = ffmpeg_base() + ["-i", args.input]
52
+ # A VFR source is put on the delivered grid before the reversal (#523). `reverse` hands its
53
+ # frames the forward timestamps in order, so a reversed VFR picture kept its gaps where they
54
+ # were in forward time: every flash of the 27 fps average clip came 0.19 s late against the
55
+ # reversed sound (0.23 s without sound). On a constant grid the reversed picture is the forward
56
+ # one mirrored. Each slot takes the last frame that starts before the slot ends (round=down),
57
+ # which is the frame the reversed slot opens on: the nearest frame to its start (the forward
58
+ # conform's choice) dropped a last frame that starts after the last slot's middle, and the
59
+ # reversal then opened on the frame before it. The conformed picture's time base is one frame,
60
+ # and a trim rescales its bound to it (a half-frame bound can round onto a frame and cut it:
61
+ # freeze.py), so a microsecond base follows.
62
+ reversed_conform = f"{cfr_filter(meta)}:round=down,settb=AVTB" if cfr_filter(meta) else ""
52
63
  if has_audio:
53
64
  # Both streams are reversed about one end (#409); each reversed about its own end put a sound
54
65
  # 0.6 s longer than its picture 0.396 s off. As in a join, both start at the first frame (the
@@ -73,13 +84,14 @@ def main() -> int:
73
84
  vlen = meta["video"].get("duration") or length
74
85
  end = math.ceil(length * rate - 1e-6) / rate
75
86
  vtrim, atrim = av_trim(stream_starts(meta)[0], None)
87
+ conform = f"{reversed_conform}," if reversed_conform else ""
76
88
  cmd += ["-filter_complex",
77
89
  f"[0:v:0]tpad=stop_mode=clone:stop_duration={max(0.0, end - vlen) + 2 / rate:.6f},{vtrim},"
78
- f"trim=end={end - 0.5 / max(rate, frame_rate(meta) or rate):.6f},reverse,{frame_durations(meta, rate)}[v];"
90
+ f"{conform}trim=end={end - 0.5 / max(rate, frame_rate(meta) or rate):.6f},reverse,{frame_durations(meta, rate)}[v];"
79
91
  f"[0:a:0]{atrim},apad=whole_dur={end:.6f},atrim=end={end:.6f},areverse,asetpts=N/SR/TB[a]",
80
92
  "-map", "[v]", "-map", "[a]"]
81
93
  else:
82
- cmd += ["-vf", "reverse"]
94
+ cmd += ["-vf", f"{reversed_conform},reverse" if reversed_conform else "reverse"]
83
95
  cmd += video_args(meta, args.crf, args.preset)
84
96
  cmd += cfr_args(meta)
85
97
  if has_audio:
@@ -18,9 +18,9 @@ Examples:
18
18
  import argparse
19
19
  import sys
20
20
  from fractions import Fraction
21
- from typing import List, Tuple
21
+ from typing import Any, List, Tuple
22
22
 
23
- from _common.decision import av_trim, conform_filter, delivered_rate, frame_durations, frame_rate, grid_at_or_after, stream_starts
23
+ from _common.decision import atempo_chain_with_lead, av_trim, conform_filter, delivered_rate, frame_durations, frame_rate, grid_at_or_after, stream_starts
24
24
  from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
25
25
  from _common.emit import input_length
26
26
 
@@ -28,18 +28,20 @@ MAX_SPEED = 20.0
28
28
  MIN_SPEED = 0.05
29
29
 
30
30
 
31
- def atempo_chain(factor: float) -> str:
32
- """atempo accepts 0.5..100 per instance; chain for factors outside that range."""
31
+ def atempo_chain(factor: float, sample_rate: Any = 0) -> str:
32
+ """atempo accepts 0.5..100 per instance; chain for factors outside that range. The chain is
33
+ led by the samples atempo leaves out (atempo_chain_with_lead, at the track's `sample_rate`):
34
+ without them every segment's sound came out 21 ms early at 1x and 40 ms at 0.5x (#523)."""
33
35
  parts: List[str] = []
34
36
  remaining = factor
35
37
  while remaining < 0.5:
36
- parts.append("atempo=0.5")
38
+ parts.append("0.5")
37
39
  remaining /= 0.5
38
40
  while remaining > 100.0:
39
- parts.append("atempo=100.0")
41
+ parts.append("100.0")
40
42
  remaining /= 100.0
41
- parts.append(f"atempo={remaining:.6f}")
42
- return ",".join(parts)
43
+ parts.append(f"{remaining:.6f}")
44
+ return atempo_chain_with_lead(parts, sample_rate)
43
45
 
44
46
 
45
47
  def parse_segment(raw: str) -> Tuple[float, float, float]:
@@ -118,7 +120,8 @@ def main() -> int:
118
120
  # leaves at the unscaled 1/rate, so every 2x segment pushed the rest of the picture two
119
121
  # frames late against the sound (and added those frames)
120
122
  vchain = f"[0:v]{vtrim},setpts=PTS/{factor:.6f},{frame_durations(meta, out_rate)}"
121
- achain = f"[0:a]{atrim},{atempo_chain(factor)}"
123
+ # a 1x segment is not retimed: atempo would place its sound by windows for nothing (#541)
124
+ achain = f"[0:a]{atrim}" if factor == 1.0 else f"[0:a]{atrim},{atempo_chain(factor, srate)}"
122
125
  if out_rate:
123
126
  # Each segment holds the frames (and samples) between its retimed ends on the whole
124
127
  # output's grid, not its own length rounded: eight 0.5 s segments at 27 fps rounded