ffmpeg-skill 3.8.2 → 3.8.4

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
@@ -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 (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.8.2`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.8.4`) | 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`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"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"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.8.2", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.4", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "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.2",
3
+ "version": "3.8.4",
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": [
@@ -74,7 +74,7 @@ from _common.probe import (
74
74
  display_size, pixel_aspect, square_pixels, square_pixels_note
75
75
  )
76
76
  from _common.decision import (
77
- aac_args, add_pad_fill_args, conform_filter, conform_to_length, conform_output_t, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
77
+ aac_args, add_pad_fill_args, conform_filter, conform_to_length, conform_output_t, length_output_t, silence_filter, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
78
78
  description_block, _evidence_rank, fmt_chapter_time, propose_chapters,
79
79
  frame_flow, label_shot_flow, MOTION_GRID, MOTION_SEARCH, MOTION_STATIC_PX, MOTION_PAN_SPREAD,
80
80
  filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS, FILLER_MAX_WORD,
@@ -456,6 +456,62 @@ def conform_output_t(length: float, rate: int) -> str:
456
456
  return f"{length + 0.5 / rate:.6f}"
457
457
 
458
458
 
459
+ def length_output_t(length: float, rate: Optional[int], runs_on: bool = False) -> str:
460
+ """The output `-t` of a tool that leaves the sound's end to it (overlay.py, waveform.py,
461
+ loop.py, sync.py --replace-audio): the millisecond text they always wrote unless it cuts
462
+ samples off the track at `rate` (`-t 1.234` wrote 59244 samples at 48 kHz as 59232), and then
463
+ the length to the microsecond, which FFmpeg rounds to the nearest sample. `runs_on` is for
464
+ sound that goes on past the length (an endless apad, a loop's next repeat): there the
465
+ millisecond text is also left when it rounds up (`-t 1.235` wrote 59261 as 59280). No half
466
+ sample of slack as in conform_output_t(): this `-t` ends the picture too, and with the slack a
467
+ layer's frame that starts exactly at the end came through on a 1/60000 or 1/90000 time base
468
+ (FFmpeg 6.1.1). A rate of 0 or None (no sound, a dry run's pending input) keeps the
469
+ millisecond text."""
470
+ text = f"{length:.3f}"
471
+ if rate:
472
+ def names(micro: int) -> int:
473
+ # the samples FFmpeg makes of a -t of `micro` microseconds: a rescale that rounds a
474
+ # half up, where Python's round() takes it to the even count (0.005 s at 44.1 kHz is
475
+ # 220.5 samples: 221 written, round() says 220)
476
+ return (micro * rate + 500_000) // 1_000_000
477
+ written, asked = names(round(float(text) * 1_000_000)), round(length * rate)
478
+ if written < asked or (runs_on and written != asked):
479
+ # the microsecond text is a rounding too (2.00001042 s is 96001 samples at 48 kHz and
480
+ # "2.000010" names 96000): the microsecond nearest the length, moved by whole
481
+ # microseconds until it names the count asked. A microsecond is a twentieth of a
482
+ # sample at 48 kHz, so this is a step or two, not conform_output_t()'s half sample
483
+ micro = round(length * 1_000_000)
484
+ while names(micro) < asked:
485
+ micro += 1
486
+ while names(micro) > asked:
487
+ micro -= 1
488
+ return f"{micro // 1_000_000}.{micro % 1_000_000:06d}"
489
+ return text
490
+
491
+
492
+ def silence_filter(rate: Optional[int], lead: Optional[float] = None, tail: Optional[float] = None) -> str:
493
+ """Silence of `lead` seconds ahead of a track and/or `tail` seconds after it (pad.py,
494
+ freeze.py). An amount in whole milliseconds is written as it always was (`adelay=MS:all=1`,
495
+ `apad=pad_dur=S.mmm`), the millisecond rounded, not cut: int(1.001 * 1000) is 1000. One that
496
+ is not is counted in samples at `rate`, pinned by an aformat as in conform_filter():
497
+ `adelay=234` and `apad=pad_dur=0.234` for 0.23425 s left the sound 12 samples short at 48 kHz.
498
+ A rate of 0 or None (a dry run's pending input) keeps the millisecond form."""
499
+ amounts = [v for v in (lead, tail) if v is not None]
500
+ if rate and any(round(v * rate) != round(round(v, 3) * rate) for v in amounts):
501
+ parts = [f"aformat=sample_rates={rate}"]
502
+ if lead:
503
+ parts.append(f"adelay={round(lead * rate)}S:all=1")
504
+ if tail:
505
+ parts.append(f"apad=pad_len={round(tail * rate)}")
506
+ return ",".join(parts)
507
+ parts = []
508
+ if lead is not None:
509
+ parts.append(f"adelay={round(lead * 1000)}:all=1")
510
+ if tail is not None:
511
+ parts.append(f"apad=pad_dur={tail:.3f}")
512
+ return ",".join(parts)
513
+
514
+
459
515
  def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
460
516
  """The one length a clip holds once both streams start at its first frame (av_trim from the
461
517
  video's start): the video's, or where the sound ends when it runs more than a frame past the
package/scripts/broll.py CHANGED
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import Any, Dict, List
23
23
 
24
- from _common.decision import av_trim, conform_output_t, conform_to_length, frame_rate, grid_at_or_after, stream_starts
24
+ from _common.decision import av_trim, conform_output_t, conform_to_length, frame_rate, grid_at_or_after, silence_filter, stream_starts
25
25
  from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
26
26
  from _common.emit import note, record_fallback
27
27
  from _common.probe import display_size, square_pixels, square_pixels_note
@@ -162,8 +162,14 @@ def main() -> int:
162
162
  # sample moved a track that starts 0.3 s late 0.279 s early (#409)
163
163
  v0b = stream_starts(c["meta"])[0]
164
164
  first = max(v0b, grid_at_or_after(c["from"], v0b, frame_rate(c["meta"])))
165
+ # the delay is --at to the sample of the mix's rate: `int(at * 1000)` cut 1.001 to
166
+ # 1000 ms and wrote 1.2346 s as 1234 ms, the cutaway's sound 48 and 29 samples
167
+ # early. A whole millisecond keeps the text it had, rounded (silence_filter)
168
+ delay = silence_filter(MIX_RATE, lead=c["at"])
169
+ if "S:" not in delay:
170
+ delay = f"adelay={round(c['at'] * 1000)}|{round(c['at'] * 1000)}"
165
171
  parts.append(f"[{i + 1}:a:0]{av_trim(first, first + c['length'])[1]},"
166
- f"aformat=sample_rates=48000:channel_layouts=stereo,adelay={int(c['at'] * 1000)}|{int(c['at'] * 1000)}[ab{i}]")
172
+ f"aformat=sample_rates=48000:channel_layouts=stereo,{delay}[ab{i}]")
167
173
  layers.append(f"[ab{i}]")
168
174
  # the mix ends with the A-roll, to the sample (#493): every layer is 48 kHz, so the count
169
175
  # is in that rate. `atrim=duration={:.3f}` and `-t {:.3f}` cut it to the millisecond. A
package/scripts/fit.py CHANGED
@@ -39,7 +39,7 @@ import sys
39
39
  from fractions import Fraction
40
40
  from typing import List
41
41
 
42
- from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
42
+ from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, length_output_t, stream_starts
43
43
  from _common.timeline import exact_rate, output_timecode_args
44
44
  from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, 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, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
45
45
  from _common.emit import input_length
@@ -162,6 +162,12 @@ def main() -> int:
162
162
  # (#491). Not under --dry-run, where render.py plans a clip's speed stage on the source in
163
163
  # place of a cut that does not exist yet and records the stand-in itself (A97)
164
164
  src_dur = (meta.get("duration") or 0.0) if STATE.dry_run else input_length(meta)
165
+ # The output's -t is all that ends the kept track where the source's sound runs on past
166
+ # the target (a trim; a speed change too small to retime), so it is written to the sample
167
+ # of that track's rate, not to the millisecond: `-t 1.234` wrote 59244 samples (48 kHz)
168
+ # as 59232 and `-t 1.235` wrote 59261 as 59280 (length_output_t). No sound, or a dry
169
+ # run's pending input, has no rate and keeps the millisecond.
170
+ rate = (audio_streams[args.audio_stream].get("sample_rate") if has_audio and audio_streams else None)
165
171
  if args.method == "speed":
166
172
  if src_dur <= 0 and STATE.dry_run:
167
173
  src_dur = target # planning against an intermediate that does not exist yet
@@ -178,7 +184,9 @@ def main() -> int:
178
184
  vf.append("tblend=all_mode=average")
179
185
  if has_audio:
180
186
  af.append(atempo_chain(factor))
181
- post += ["-t", f"{target:.3f}"]
187
+ # atempo's sound ends before the target (13 to 62 ms before it on FFmpeg 6.1.1), so the
188
+ # millisecond cuts nothing off a retimed track and stays
189
+ post += ["-t", f"{target:.3f}" if af else length_output_t(target, rate, runs_on=True)]
182
190
  STATE.duration_hint = target
183
191
  else:
184
192
  if target < src_dur:
@@ -189,7 +197,7 @@ def main() -> int:
189
197
  # picture would not be the one the start timecode below names
190
198
  m = min(SEEK_MARGIN, round(start, 3))
191
199
  pre_input += ["-ss", f"{start - m:.3f}"]
192
- post += (["-ss", f"{m:.3f}"] if m > 0 else []) + ["-t", f"{target:.3f}"]
200
+ post += (["-ss", f"{m:.3f}"] if m > 0 else []) + ["-t", length_output_t(target, rate, runs_on=True)]
193
201
  # the output starts on the first frame at or after the seek (a seek before a
194
202
  # picture that starts after the file's origin: on its first frame): its start
195
203
  # timecode is that frame's, not the source's first frame's
package/scripts/freeze.py CHANGED
@@ -19,7 +19,7 @@ Examples:
19
19
  import argparse
20
20
  import sys
21
21
 
22
- from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
22
+ from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts
23
23
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, MissingFpsError, time_arg, fmt_secs, run
24
24
  from _common.emit import input_length
25
25
 
@@ -73,7 +73,7 @@ def main() -> int:
73
73
  vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
74
74
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
75
75
  if has_audio:
76
- cmd += ["-map", "0:a:0?", "-af", f"apad=pad_dur={args.hold:.3f}"]
76
+ cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), tail=args.hold)]
77
77
  elif at == 0 or cut_at <= v0 + 1e-6:
78
78
  # A freeze at the very start has no preceding "head" segment to hold on to (the
79
79
  # split/trim/concat approach below needs a non-empty head, which trim=end=0 can't give
@@ -82,7 +82,7 @@ def main() -> int:
82
82
  vf = f"tpad=start_mode=clone:start_duration={args.hold:.3f}"
83
83
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
84
84
  if has_audio:
85
- cmd += ["-map", "0:a:0?", "-af", f"adelay={int(args.hold * 1000)}:all=1"]
85
+ cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), lead=args.hold)]
86
86
  else:
87
87
  # freeze N frames at `at` by holding on that one source frame for --hold seconds, then
88
88
  # resuming the rest of the clip: split the timeline at `at` and hold the remainder's
package/scripts/loop.py CHANGED
@@ -34,7 +34,7 @@ import tempfile
34
34
 
35
35
  from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
36
36
  from _common.emit import warn
37
- from _common.decision import av_trim
37
+ from _common.decision import av_trim, length_output_t
38
38
  from _common.probe import decode_gray_frames, decode_pcm_mono
39
39
  from _common.emit import input_length
40
40
 
@@ -320,7 +320,8 @@ def main() -> int:
320
320
  else:
321
321
  cmd += ["-map", "0:v:0", "-vf", vtrim]
322
322
  if target is not None:
323
- cmd += ["-t", f"{target:.3f}"]
323
+ # the sound ends at --duration to the sample, not to the millisecond (length_output_t)
324
+ cmd += ["-t", length_output_t(target, (meta.get("audio") or {}).get("sample_rate") if has_audio else None, runs_on=True)]
324
325
  cmd += video_args(meta, args.crf, args.preset)
325
326
  # a crossfade's repeat is already constant at `rate` (its graph's fps=): the loop keeps that rate,
326
327
  # not a variable-rate source's average, which re-timed every repeat by a frame or two
@@ -38,6 +38,7 @@ from _common import still_input_args # noqa: E402
38
38
  from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
39
39
  from _common.emit import note, warn
40
40
  from _common.color import is_rgb
41
+ from _common.decision import length_output_t
41
42
  from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
42
43
  has_alpha_plane, report as alpha_report, source_alpha, source_format, tag_args, video_line)
43
44
  from _common.probe import display_size, square_pixels, square_pixels_note
@@ -362,6 +363,10 @@ def main() -> int:
362
363
  die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
363
364
  if args.audio_stream and not audio_streams:
364
365
  die("--audio-stream needs an input with audio streams")
366
+ # the -t that ends a layer at the main input's length: to the sample of the kept track when the
367
+ # millisecond would cut it (length_output_t); no sound, or a pending input, keeps the millisecond
368
+ kept_track = audio_streams[args.audio_stream] if audio_streams else (meta.get("audio") or {})
369
+ main_t = length_output_t(meta["duration"], kept_track.get("sample_rate")) if meta.get("duration") else None
365
370
  # ffmpeg applies display rotation before the filter graph, so the frame this overlay is
366
371
  # composited on is the displayed one. A 320x180 phone file tagged 90 degrees is 180 wide here.
367
372
  # A non-square-pixel source is made square at its display aspect first (#411), so a logo or
@@ -454,7 +459,7 @@ def main() -> int:
454
459
  # streams -- a preserved subtitle/data stream that ends earlier (run_keeping_subtitles)
455
460
  # must not be allowed to cut the whole output short via -shortest's "stop at whichever
456
461
  # mapped stream finishes first" semantics.
457
- cmd += ["-t", f"{meta['duration']:.3f}"]
462
+ cmd += ["-t", main_t]
458
463
  else:
459
464
  # No known duration to bound by -t (e.g. probe found no video duration): -shortest is
460
465
  # the only thing stopping the looped still from running forever. FFmpeg 7+'s
@@ -567,7 +572,7 @@ def main() -> int:
567
572
  if meta.get("duration"):
568
573
  # See the --image branch above: -t (exact, bounds only the main input) instead of
569
574
  # -shortest (would also stop at a preserved subtitle/data stream that ends earlier).
570
- cmd += ["-t", f"{meta['duration']:.3f}"]
575
+ cmd += ["-t", main_t]
571
576
  else:
572
577
  cmd += ["-shortest"]
573
578
  else:
package/scripts/pad.py CHANGED
@@ -17,6 +17,7 @@ import argparse
17
17
  import sys
18
18
 
19
19
  from _common.emit import record_fallback
20
+ from _common.decision import silence_filter
20
21
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, EVEN_SCALE, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
21
22
 
22
23
 
@@ -64,7 +65,8 @@ def main() -> int:
64
65
  # front (one -filter_complex), so the record is made here, as broll and stabilize do
65
66
  record_fallback(f"{args.input} is {v.get('width')}x{v.get('height')}: scaled to even dimensions first",
66
67
  "odd_dimensions_scaled_even", output=output, scale=EVEN_SCALE)
67
- af = f"adelay={int(args.start * 1000)}:all=1,apad=pad_dur={args.end:.3f}"
68
+ # the silence is counted in samples when --start/--end is not a whole millisecond
69
+ af = silence_filter(meta["audio"].get("sample_rate"), args.start, args.end)
68
70
  cmd += ["-filter_complex", f"[0:v:0]{even}{vf}[v];[0:a:0]{af}[a]", "-map", "[v]", "-map", "[a]"]
69
71
  else:
70
72
  cmd += ["-vf", vf, "-map", "0:v:0"]
package/scripts/sync.py CHANGED
@@ -35,7 +35,7 @@ from typing import Dict, List
35
35
  from _common.runner import fail_if_classified
36
36
  from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
37
37
  from _common.emit import record_skip, warn
38
- from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, stream_starts
38
+ from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, length_output_t, silence_filter, stream_starts
39
39
  from _common.timeline import output_timecode_args
40
40
  from _common.runner import default_selection_maps
41
41
  from _common.emit import input_length
@@ -377,9 +377,17 @@ def main() -> int:
377
377
  # its length instead of -shortest, which cut the reference's tail whenever the second
378
378
  # file started earlier (head_trim > 0) or was simply shorter
379
379
  ref_dur = probe(args.reference).get("duration")
380
- af_parts = on_clock + drift_af + ([f"adelay={delay_ms}:all=1"] if delay_ms > 0 else []) + ["apad"]
380
+ # the delay is the measured offset to the sample of the second's rate: at the default
381
+ # --fine-ms 1 without --fix-drift the offset is a whole millisecond and the text is the
382
+ # one it was; a finer step (0.25 ms: 0.23425 s) or a drift-adjusted offset (kept to
383
+ # 0.1 ms) was rounded to `adelay=234` (silence_filter)
384
+ lead = [silence_filter(sec_sr, lead=offset)] if offset > 0 and round(offset * sec_sr) else []
385
+ af_parts = on_clock + drift_af + lead + ["apad"]
381
386
  cmd += ["-af", ",".join(af_parts)]
382
- cmd += aac_args() + (["-t", f"{ref_dur:.3f}"] if ref_dur else ["-shortest"]) + [output]
387
+ # to the sample of the rate the second's sound is written at, not to the millisecond:
388
+ # the endless apad is ended by this -t alone (length_output_t)
389
+ ref_t = length_output_t(ref_dur, (second_meta.get("audio") or {}).get("sample_rate"), runs_on=True) if ref_dur else None
390
+ cmd += aac_args() + (["-t", ref_t] if ref_dur else ["-shortest"]) + [output]
383
391
  proc = run(cmd, check=False)
384
392
  fail_if_classified(proc) # a cause the re-encode would hit too is reported, not retried (#415)
385
393
  if proc.returncode != 0:
@@ -41,6 +41,7 @@ from _common import (add_common, apply_common, aac_args, default_output, die, dr
41
41
  info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
42
42
  fmt_secs, child_args, run_tool, measured_level_dbfs, sibling_temp)
43
43
  from _common.emit import absorb_stage_hw, note, warn
44
+ from _common.decision import length_output_t
44
45
 
45
46
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
46
47
 
@@ -232,7 +233,9 @@ def main() -> int:
232
233
  # audio ends (a 12 s source came out 14.08 s on 5.1.1, #146), so the output is also capped
233
234
  # at the source's own duration when probe knows it.
234
235
  if meta.get("duration"):
235
- cmd += ["-t", f"{float(meta['duration']):.3f}"]
236
+ # to the sample of the drawn track when the millisecond would cut it (length_output_t)
237
+ drawn_track = audio_streams[args.audio_stream] if audio_streams else meta["audio"]
238
+ cmd += ["-t", length_output_t(float(meta["duration"]), drawn_track.get("sample_rate"))]
236
239
  cmd += ["-shortest", render]
237
240
  titled_tmp = sibling_temp(output, "titled")
238
241
  intermediates = [p for p in (render, titled_tmp) if p != output]