ffmpeg-skill 3.8.0 → 3.8.1

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.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.8.1`) | 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.0", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.1", "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.0",
3
+ "version": "3.8.1",
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": [
@@ -63,7 +63,7 @@ from _common.runner import (
63
63
  STATE, _timed_out, _unwatch, _watch, X264_PRESETS
64
64
  )
65
65
  from _common.emit import (
66
- absorb_stage, audit, _brief, _BRIEF_DROP, _brief_summary, _CURRENT_CTX, die, emit, info, note, _plan_at_exit, _plan_inputs,
66
+ absorb_stage, audit, _brief, input_length, _BRIEF_DROP, _brief_summary, _CURRENT_CTX, die, emit, info, note, _plan_at_exit, _plan_inputs,
67
67
  _plan_unbound_inputs, _PLAN_STRIP, PLAN_VERSION, print_json, record_fallback, record_skip, refuse, _set_current_ctx, warn,
68
68
  write_plan
69
69
  )
@@ -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, 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, 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,
@@ -15,7 +15,7 @@ from pathlib import Path
15
15
  from typing import Any, Dict, List, Optional, Sequence, Tuple
16
16
  from _common.color import (as_limited, bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
17
17
  is_neutral_color, limited_range_filter, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
18
- from _common.emit import die, record_fallback, refuse, warn
18
+ from _common.emit import die, input_length, record_fallback, refuse, warn
19
19
  from _common.runner import read_json
20
20
  from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_list_error, ffmpeg_version, hw_platform_reason
21
21
 
@@ -431,6 +431,30 @@ def av_trim(start: float, end: Optional[float], rate: float = 0.0, fill_gaps: bo
431
431
  f"atrim=start={start:.6f}{aend},asetpts=PTS-{start:.6f}/TB,{pad}")
432
432
 
433
433
 
434
+ def conform_filter(samples: int, rate: int) -> str:
435
+ """Pad with silence or trim to exactly `samples` at `rate` (#478): counted in samples, not in
436
+ seconds written to the millisecond, which left a result up to 24 samples (48 kHz) off. The
437
+ aformat pins the rate the count is in, so a resampler FFmpeg inserts sits ahead of the count."""
438
+ head = f"aformat=sample_rates={rate}," if rate else ""
439
+ return f"{head}apad=whole_len={samples},atrim=end_sample={samples}"
440
+
441
+
442
+ def conform_to_length(length: float, rate: int) -> str:
443
+ """Pad with silence or trim a track to `length` seconds of a timeline that starts at 0, to the
444
+ sample (#478, #493). The count is of samples, and sound that starts after 0 keeps its start
445
+ (apad and atrim=end_sample do not move timestamps): N samples from 0.3 s end 0.3 s past
446
+ `length`. The trim on the timestamp ends it there; half a sample of slack keeps the Nth sample
447
+ of a track that starts at 0. audio.py, grid.py, broll.py and multicam.py end their sound with
448
+ this; `atrim=duration={length:.3f}` cut 59244 samples (48 kHz) to 59232."""
449
+ return conform_filter(round(length * rate), rate) + f",atrim=end={length + 0.5 / rate:.6f}"
450
+
451
+
452
+ def conform_output_t(length: float, rate: int) -> str:
453
+ """The output `-t` beside conform_to_length(): `-t {length:.3f}` would cut the conformed sound
454
+ back to the millisecond, so the limit sits half a sample past the last one."""
455
+ return f"{length + 0.5 / rate:.6f}"
456
+
457
+
434
458
  def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
435
459
  """The one length a clip holds once both streams start at its first frame (av_trim from the
436
460
  video's start): the video's, or where the sound ends when it runs more than a frame past the
@@ -440,7 +464,7 @@ def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
440
464
  v = (meta.get("video") or {}).get("duration")
441
465
  a = (meta.get("audio") or {}).get("duration")
442
466
  if not v:
443
- return meta.get("duration") or 0.0
467
+ return input_length(meta) # a file that states no length at all is refused, not 0 s long (#491)
444
468
  if a:
445
469
  v0, a0 = stream_starts(meta)
446
470
  a = a + a0 - v0 # where the sound ends, counted from the first frame
@@ -647,6 +647,29 @@ def stream_length(meta: Dict[str, Any], kind: str) -> Optional[float]:
647
647
  return None
648
648
 
649
649
 
650
+ LENGTH_UNREAD_HINT = ("remux the file so its container states a length (export.py INPUT --preset copy -o remuxed.mkv), "
651
+ "then run this on the remux")
652
+
653
+
654
+ def input_length(meta: Optional[Dict[str, Any]], path: Optional[str] = None) -> float:
655
+ """An input's length in seconds, for a tool that computes with it (#491). A file that states
656
+ no length (Matroska written to a pipe: ffprobe prints N/A for the container and every stream)
657
+ probes as duration None, and `meta.get("duration") or 0.0` read that as a clip 0 s long: a
658
+ speed factor of 0.00x, "--at 1 is outside the clip (0..0.000)", a reverse of no frames. Unread
659
+ is not zero, so this refuses, as check.py's duration row says "could not measure". A dry run's
660
+ pending input (probe()'s stub, duration 0.0), a stated length of 0 and a still picture (one
661
+ image read by an image demuxer has no length to state) pass through as before."""
662
+ meta = meta or {}
663
+ length = meta.get("duration")
664
+ fmt = meta.get("format") or ""
665
+ still = fmt == "image2" or fmt.endswith("_pipe") # probe._is_still()'s rule, on the probe document
666
+ if length is None and not meta.get("dry_run") and not still:
667
+ die(f"the length of {path or meta.get('file') or 'the input'} could not be read: neither its container nor "
668
+ "its streams state one, and this operation is computed from the input's length",
669
+ kind="input", hint=LENGTH_UNREAD_HINT)
670
+ return float(length or 0.0)
671
+
672
+
650
673
  def stream_end(meta: Dict[str, Any], kind: str) -> Optional[float]:
651
674
  """Where one stream ends on the file's timeline: its start plus its own length."""
652
675
  length = stream_length(meta, kind)
package/scripts/audio.py CHANGED
@@ -30,6 +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 warn
33
+ from _common.decision import conform_filter, conform_to_length
33
34
 
34
35
  # --downmix: ITU-R BS.775 fold-down, renormalised by pan's '<' (see main()).
35
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"
@@ -254,14 +255,6 @@ def property_step(name: str, expected: int, measured: Any) -> Dict[str, Any]:
254
255
  return {"step": name, "expected": expected, "measured": value, "ok": value == expected}
255
256
 
256
257
 
257
- def conform_filter(samples: int, rate: int) -> str:
258
- """Pad with silence or trim to exactly `samples` at `rate` (#478): counted in samples, not in
259
- seconds written to the millisecond, which left a result up to 24 samples (48 kHz) off. The
260
- aformat pins the rate the count is in, so a resampler FFmpeg inserts sits ahead of the count."""
261
- head = f"aformat=sample_rates={rate}," if rate else ""
262
- return f"{head}apad=whole_len={samples},atrim=end_sample={samples}"
263
-
264
-
265
258
  def main() -> int:
266
259
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
267
260
  ap.add_argument("input")
@@ -564,12 +557,9 @@ def main() -> int:
564
557
  elif main_dur and (args.replace or args.music or keep_video):
565
558
  if rate:
566
559
  conform_samples = round(main_dur * rate)
567
- # The count is of samples, and sound that starts after the picture keeps its start
568
- # (apad and atrim=end_sample do not move timestamps): N samples from 0.3 s end 0.3 s
569
- # past the picture. The trim on the timestamp, which is what this was before #478,
570
- # ends it with the picture; half a sample of slack keeps the Nth sample of a track
571
- # that starts at 0.
572
- post.append(conform_filter(conform_samples, rate) + f",atrim=end={main_dur + 0.5 / rate:.6f}")
560
+ # counted in samples and ended on the timestamp, so sound that starts after the picture
561
+ # still ends with it (conform_to_length, shared with grid, broll and multicam since #493)
562
+ post.append(conform_to_length(main_dur, rate))
573
563
  else:
574
564
  # a dry run's pending input: no rate to count in yet; the real run counts samples
575
565
  post.append(f"apad,atrim=0:{main_dur:.6f}")
package/scripts/broll.py CHANGED
@@ -21,10 +21,14 @@ import argparse
21
21
  import sys
22
22
  from typing import Any, Dict, List
23
23
 
24
- from _common.decision import av_trim, 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, 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
28
+ from _common.emit import input_length
29
+
30
+
31
+ MIX_RATE = 48000 # every layer of a b/mix sound is resampled to it before the mix
28
32
 
29
33
 
30
34
  def main() -> int:
@@ -57,7 +61,7 @@ def main() -> int:
57
61
  meta_a = probe(args.input)
58
62
  if not meta_a.get("video"):
59
63
  die("A-roll has no video stream")
60
- dur_a = meta_a.get("duration") or 0.0
64
+ dur_a = input_length(meta_a)
61
65
  # the cutaway is scaled onto the frame ffmpeg has already rotated, not the stored one, and
62
66
  # onto a non-square-pixel A-roll squared at its display aspect (#411)
63
67
  w, h = display_size(meta_a["video"])
@@ -98,7 +102,7 @@ def main() -> int:
98
102
  die(f"cutaway {i + 1}: --at {at:g}s is past the end of the A-roll ({dur_a:.3f}s)")
99
103
  if dur_a and at + length > dur_a + 0.01:
100
104
  die(f"cutaway {i + 1}: {at:g}s + {length:g}s runs past the end of the A-roll ({dur_a:.3f}s)")
101
- dur_b = meta_b.get("duration") or 0.0
105
+ dur_b = input_length(meta_b)
102
106
  if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
103
107
  die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
104
108
  if cutaways and at < cutaways[-1]["at"] + cutaways[-1]["length"]:
@@ -161,7 +165,11 @@ def main() -> int:
161
165
  parts.append(f"[{i + 1}:a:0]{av_trim(first, first + c['length'])[1]},"
162
166
  f"aformat=sample_rates=48000:channel_layouts=stereo,adelay={int(c['at'] * 1000)}|{int(c['at'] * 1000)}[ab{i}]")
163
167
  layers.append(f"[ab{i}]")
164
- parts.append(f"{''.join(layers)}amix=inputs={len(layers)}:normalize=0:dropout_transition=0,atrim=duration={dur_a:.3f}[aout]")
168
+ # the mix ends with the A-roll, to the sample (#493): every layer is 48 kHz, so the count
169
+ # is in that rate. `atrim=duration={:.3f}` and `-t {:.3f}` cut it to the millisecond. A
170
+ # dry run's pending A-roll has no length to count, and keeps the old filter.
171
+ tail = conform_to_length(dur_a, MIX_RATE) if dur_a > 0 else f"atrim=duration={dur_a:.3f}"
172
+ parts.append(f"{''.join(layers)}amix=inputs={len(layers)}:normalize=0:dropout_transition=0,{tail}[aout]")
165
173
  aout = "[aout]"
166
174
 
167
175
  cmd += ["-filter_complex", ";".join(parts), "-map", vout]
@@ -174,7 +182,7 @@ def main() -> int:
174
182
  cmd += aac_args()
175
183
  else:
176
184
  cmd += ["-an"]
177
- cmd += ["-t", f"{dur_a:.3f}", output]
185
+ cmd += ["-t", conform_output_t(dur_a, MIX_RATE) if aout == "[aout]" and dur_a > 0 else f"{dur_a:.3f}", output]
178
186
  run(cmd)
179
187
 
180
188
  result = probe(output, role="output")
package/scripts/export.py CHANGED
@@ -52,7 +52,7 @@ from _common.decision import hw_preset_video, restate_last_swap
52
52
  from _common.emit import note, record_fallback, record_skip, warn
53
53
  from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels, square_pixels_note
54
54
  from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
55
- from _common.emit import file_identity
55
+ from _common.emit import file_identity, input_length
56
56
  from check import SPECS as PLATFORMS, measure_loudness
57
57
  from _common.probe import SILENT_PEAK_DBFS, shorter_than_a_loudness_block
58
58
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, UPLOAD_LIMITS_AS_OF, resolve as resolve_platform
@@ -443,7 +443,7 @@ def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool
443
443
  + (" (--hw puts it on VideoToolbox, which has no two-pass mode)" if STATE.hw else ""))
444
444
  if not math.isfinite(args.max_mb) or args.max_mb <= 0:
445
445
  die(f"--max-mb must be a positive number of MiB, got {args.max_mb:g}")
446
- dur = float(meta.get("duration") or 0.0)
446
+ dur = input_length(meta) # the bitrate is the size over the length: no stated length is refused (#491)
447
447
  if p["max"] and not args.allow_long:
448
448
  dur = min(dur, float(p["max"]))
449
449
  if dur <= 0:
package/scripts/fit.py CHANGED
@@ -42,6 +42,7 @@ from typing import List
42
42
  from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, 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
+ from _common.emit import input_length
45
46
  BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
46
47
 
47
48
 
@@ -157,6 +158,10 @@ def main() -> int:
157
158
  target = time_arg(args.duration, "--duration", meta["video"].get("fps") if meta.get("video") else None)
158
159
  if target <= 0:
159
160
  die("target duration must be > 0")
161
+ # speed and trim are both computed from the source's length: one that states none is refused
162
+ # (#491). Not under --dry-run, where render.py plans a clip's speed stage on the source in
163
+ # place of a cut that does not exist yet and records the stand-in itself (A97)
164
+ src_dur = (meta.get("duration") or 0.0) if STATE.dry_run else input_length(meta)
160
165
  if args.method == "speed":
161
166
  if src_dur <= 0 and STATE.dry_run:
162
167
  src_dur = target # planning against an intermediate that does not exist yet
package/scripts/freeze.py CHANGED
@@ -21,6 +21,7 @@ import sys
21
21
 
22
22
  from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, 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
+ from _common.emit import input_length
24
25
 
25
26
 
26
27
  def main() -> int:
@@ -43,7 +44,7 @@ def main() -> int:
43
44
  meta = probe(args.input)
44
45
  if not meta.get("video"):
45
46
  die("input has no video stream")
46
- dur = meta.get("duration") or 0.0
47
+ dur = input_length(meta)
47
48
  fps = meta["video"].get("fps") or 30.0
48
49
  if args.at is not None:
49
50
  at = time_arg(args.at, "--at", (meta.get("video") or {}).get("fps"))
@@ -49,6 +49,7 @@ from _common.fonts import font_choice, record_font_choice
49
49
  from _common.probe import display_size, square_pixels, square_pixels_note
50
50
  from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
51
51
  from _common import drawtext_fontfile # noqa: E402
52
+ from _common.emit import input_length # noqa: E402
52
53
 
53
54
  # where the template's text chain goes in a filter_complex it builds (graphics.main fills it in)
54
55
  TEXT = "@TEXT@"
@@ -201,7 +202,7 @@ def main(force_route: Optional[str] = None) -> int:
201
202
  info(f"--platform {args.platform}: safe margins top {m_top} / bottom {m_bottom} / left {m_left} / right {m_right} px")
202
203
  fps = meta["video"].get("fps")
203
204
  s = time_arg(args.start, "--start", fps) if args.start else 0.0
204
- e = time_arg(args.end, "--end", fps) if args.end else dur
205
+ e = time_arg(args.end, "--end", fps) if args.end else input_length(meta) # no --end is the clip's end (#491)
205
206
  # the picture's own length first: sound that runs on keeps the container open past the last frame
206
207
  picture_len = meta["video"].get("duration") or dur
207
208
  if picture_len and s >= picture_len:
package/scripts/grid.py CHANGED
@@ -34,6 +34,8 @@ from _common.emit import note, record_fallback # noqa: E402
34
34
  from _common.fonts import script_font_for_text # noqa: E402
35
35
  from _common.probe import square_pixels # noqa: E402
36
36
  from _common.wrap import text_width_em # noqa: E402
37
+ from _common.emit import input_length # noqa: E402
38
+ from _common.decision import conform_output_t, conform_to_length # noqa: E402
37
39
 
38
40
  LABEL_MARGIN = 10
39
41
 
@@ -114,7 +116,7 @@ def main() -> int:
114
116
  if args.audio_from is not None and not metas[args.audio_from].get("audio"):
115
117
  die(f"--audio-from {args.audio_from}: {args.inputs[args.audio_from]} has no audio stream")
116
118
 
117
- durations = [m.get("duration") or 0.0 for m in metas]
119
+ durations = [input_length(m) for m in metas]
118
120
  target_duration = max(durations) if args.pad else min(durations)
119
121
 
120
122
  # each label is drawn with a font that covers its own script, as every other text-drawing tool
@@ -188,12 +190,24 @@ def main() -> int:
188
190
  cw, ch = args.cell_width + args.gap, args.cell_height + args.gap
189
191
  layout = "|".join(f"{c * cw}_{r * ch}" for r in range(args.rows) for c in range(args.cols))
190
192
  parts.append("".join(f"[v{i}]" for i in range(n)) + f"xstack=inputs={n}:layout={layout}:fill={args.background}[out]")
191
- if args.audio_from is not None and args.pad and durations[args.audio_from] < target_duration:
193
+ # The sound ends with the grid, to the sample (#493): padded when its clip is shorter than a
194
+ # --pad grid, trimmed when it is longer. `apad,atrim=duration={:.3f}` and the output's
195
+ # `-t {:.3f}` both cut it to the millisecond (59244 samples at 48 kHz came out as 59232). A dry
196
+ # run's pending input has no rate to count in yet, and keeps the old filter, as audio.py does.
197
+ audio_rate = (metas[args.audio_from]["audio"].get("sample_rate") or 0) if args.audio_from is not None else 0
198
+ conformed = bool(audio_rate and target_duration > 0)
199
+ audio_source = None
200
+ if conformed:
201
+ parts.append(f"[{args.audio_from}:a:0]{conform_to_length(target_duration, audio_rate)}[aout]")
202
+ audio_source = "[aout]"
203
+ elif args.audio_from is not None and args.pad and durations[args.audio_from] < target_duration:
192
204
  parts.append(f"[{args.audio_from}:a:0]apad,atrim=duration={target_duration:.3f}[aout]")
205
+ audio_source = "[aout]"
206
+ elif args.audio_from is not None:
207
+ audio_source = f"{args.audio_from}:a:0"
193
208
  cmd += ["-filter_complex", ";".join(parts), "-map", "[out]"]
194
209
 
195
- if args.audio_from is not None:
196
- audio_source = "[aout]" if (args.pad and durations[args.audio_from] < target_duration) else f"{args.audio_from}:a:0"
210
+ if audio_source:
197
211
  cmd += ["-map", audio_source]
198
212
  # A grid is an 8-bit SDR composite by design (a comparison/contact artefact, not a
199
213
  # deliverable); an HDR input is flattened like look.py's contact sheet flattens it. Say so
@@ -206,7 +220,7 @@ def main() -> int:
206
220
  cmd += aac_args()
207
221
  else:
208
222
  cmd += ["-an"]
209
- cmd += ["-t", f"{target_duration:.3f}", output]
223
+ cmd += ["-t", conform_output_t(target_duration, audio_rate) if conformed else f"{target_duration:.3f}", output]
210
224
  run(cmd)
211
225
 
212
226
  result = probe(output, role="output")
package/scripts/join.py CHANGED
@@ -50,6 +50,7 @@ from _common.emit import note, warn
50
50
  from _common.decision import av_clip_length, av_trim, stream_starts
51
51
  from _common.probe import display_size, square_pixels, square_pixels_note
52
52
  from _common.timeline import exact_rate
53
+ from _common.emit import input_length
53
54
 
54
55
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
55
56
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -704,6 +705,8 @@ def main() -> int:
704
705
  skipped=skipped)
705
706
  validate_color(args.pad_color, "--pad-color")
706
707
  metas = [probe(p) for p in args.inputs]
708
+ for p, m in zip(args.inputs, metas):
709
+ input_length(m, p) # every clip is placed by its length: one that states none is refused, not 0 s (#491)
707
710
  # A pending input's probe is the dry-run stub, whose (0x0) video stream says nothing about the
708
711
  # file an earlier step will write. Its extension stands in: a step names its output for what
709
712
  # it holds, so an audio extension (.wav, .m4a, .aiff, ... -- every audio file this skill
package/scripts/look.py CHANGED
@@ -25,6 +25,7 @@ from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
25
25
  from _common.emit import note, record_fallback # noqa: E402
26
26
  from _common.color import bt709_label_filter, bt2020_sdr_to_bt709_chain, hdr_to_sdr_chain, is_bt2020_sdr # noqa: E402
27
27
  from _common.probe import PROBED_VIDEO, display_size, square_pixels, square_pixels_note # noqa: E402
28
+ from _common.emit import input_length # noqa: E402
28
29
 
29
30
  # --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
30
31
  # Action and title safe are SMPTE ST 2046-1's 93 % and 90 % of a 16:9 picture; the 4:3 centre cut
@@ -363,6 +364,7 @@ def main() -> int:
363
364
  except ValueError:
364
365
  die("--tiles must look like 4x3")
365
366
  n = cols * rows
367
+ dur = input_length(meta) # the sheet is spread over the length: none stated is refused with the way out (#491)
366
368
  if not dur:
367
369
  die("cannot build a contact sheet without a known duration")
368
370
  frames_avail = int((meta["video"].get("fps") or 0) * dur) or 1
package/scripts/loop.py CHANGED
@@ -36,6 +36,7 @@ from _common import STATE, concat_list_line, add_common, aac_args, apply_common,
36
36
  from _common.emit import warn
37
37
  from _common.decision import av_trim
38
38
  from _common.probe import decode_gray_frames, decode_pcm_mono
39
+ from _common.emit import input_length
39
40
 
40
41
 
41
42
  # The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
@@ -248,7 +249,7 @@ def main() -> int:
248
249
  meta = probe(args.input)
249
250
  if not meta.get("video"):
250
251
  die("input has no video stream")
251
- src_dur = meta.get("duration") or 0.0
252
+ src_dur = input_length(meta) # no stated length: refused with the way out (#491)
252
253
  if src_dur <= 0:
253
254
  die("input has no measurable duration to loop")
254
255
  has_audio = bool(meta.get("audio")) and not args.boomerang
@@ -33,6 +33,7 @@ from typing import Any, Dict, List, Optional
33
33
  from _common import (guard_sidecar, add_common, apply_common, default_output, description_block, detect_scenes, detect_silences,
34
34
  die, emit, ffmpeg_base, fmt_chapter_time, info, propose_chapters, time_arg, probe, run,
35
35
  STATE, read_text_or_die)
36
+ from _common.emit import input_length
36
37
 
37
38
  CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
38
39
  TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
@@ -230,7 +231,8 @@ def main() -> int:
230
231
  die(f"{out_ext or 'this'} container cannot hold chapter markers; write to one of "
231
232
  f"{', '.join(sorted(CHAPTER_CONTAINERS))} (the streams are copied, so choose the matching family: .mp4/.mov/.m4a for MPEG-4, .mkv/.mka/.webm for Matroska)")
232
233
 
233
- duration = meta.get("duration") or 0.0
234
+ # the last chapter ends at the file's end and --auto-chapters spaces them over its length (#491)
235
+ duration = input_length(meta) if (args.chapters or args.auto_chapters) else (meta.get("duration") or 0.0)
234
236
  chapters: Optional[List[Dict[str, Any]]] = parse_chapters(args.chapters, duration) if args.chapters else None
235
237
  auto: Optional[Dict[str, Any]] = None
236
238
  notes: List[str] = []
@@ -34,8 +34,9 @@ from typing import Dict, List, Tuple
34
34
 
35
35
  from _common import guard_sidecar, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
36
36
  from _common.emit import note, record_fallback, record_skip, warn
37
- from _common.decision import av_trim, rebase_project_paths
37
+ from _common.decision import av_trim, conform_output_t, conform_to_length, rebase_project_paths
38
38
  from _common.probe import display_size, square_pixels, square_pixels_note
39
+ from _common.emit import input_length
39
40
  from sync import measure_offset
40
41
 
41
42
  ENERGY_WINDOW_S = 0.25 # --switch energy: loudness measured over this window on the reference timeline
@@ -187,8 +188,8 @@ def main() -> int:
187
188
  ratio = 1.0
188
189
  skipped_reason = None
189
190
  if args.fix_drift:
190
- ref_dur = metas[0]["duration"] or 0.0
191
- sec_dur = probe(p)["duration"] or 0.0
191
+ ref_dur = input_length(metas[0])
192
+ sec_dur = input_length(probe(p))
192
193
  overlap_end = min(ref_dur, sec_dur + off)
193
194
  window = 60.0
194
195
  head_len = min(args.analyze_seconds, overlap_end)
@@ -242,7 +243,7 @@ def main() -> int:
242
243
 
243
244
  if args.min_shot <= 0:
244
245
  die(f"--min-shot must be positive, got {args.min_shot:g}")
245
- ref_dur = metas[0]["duration"] or 0.0
246
+ ref_dur = input_length(metas[0])
246
247
  energy_mode = args.switch == "energy"
247
248
  if energy_mode:
248
249
  cuts = energy_switch(args.inputs, metas, offsets, ratios, ref_dur, args.min_shot)
@@ -339,12 +340,17 @@ def main() -> int:
339
340
  afx += [f"asetrate={sr * ratios[a]:.6f}", f"aresample={sr}"]
340
341
  if offsets[a] > 0:
341
342
  afx.append(f"adelay={int(round(offsets[a] * 1000))}:all=1")
342
- afx += [f"atrim=0:{ref_dur:.3f}", "aformat=sample_rates=48000:channel_layouts=stereo"]
343
+ # the sound ends with the reference, to the sample (#493), counted at the 48 kHz it is written
344
+ # in; `atrim=0:{:.3f}` and `-t {:.3f}` cut it to the millisecond. A dry run's pending reference
345
+ # has no length to count, and keeps the old filter.
346
+ conformed = ref_dur > 0
347
+ afx += [conform_to_length(ref_dur, 48000) if conformed else f"atrim=0:{ref_dur:.3f}",
348
+ "aformat=sample_rates=48000:channel_layouts=stereo"]
343
349
  parts.append(f"[{a}:a]{','.join(afx)}[aout]")
344
350
 
345
351
  output = args.output or default_output(args.inputs[0], "multicam", "mp4")
346
352
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
347
- cmd += video_args(metas[0], args.crf, args.preset, convert_matrix=False) + aac_args() + ["-t", f"{ref_dur:.3f}", output]
353
+ cmd += video_args(metas[0], args.crf, args.preset, convert_matrix=False) + aac_args() + ["-t", conform_output_t(ref_dur, 48000) if conformed else f"{ref_dur:.3f}", output]
348
354
 
349
355
  if args.write_project:
350
356
  # Same cut decision as the combined render above (project_clips was built alongside the
package/scripts/scenes.py CHANGED
@@ -32,6 +32,7 @@ from _common.emit import note, record_fallback # noqa: E402
32
32
  from _common.color import bt709_label_filter # noqa: E402
33
33
  from _common.probe import PROBED_VIDEO, square_pixels # noqa: E402
34
34
  from _common import dry_run_input_pending # noqa: E402
35
+ from _common.emit import input_length # noqa: E402
35
36
 
36
37
  SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
37
38
  SHOT_W, SHOT_H = 48, 27 # decode size for --shots (16:9-ish; enough blocks, still a few KB/shot)
@@ -176,7 +177,7 @@ def main() -> int:
176
177
  die("--beats needs an audio stream; this file has none", kind="input")
177
178
  if args.beat_step <= 0:
178
179
  die("--beat-step must be greater than 0", kind="input")
179
- dur = meta.get("duration") or 0.0
180
+ dur = input_length(meta)
180
181
  cuts = [0.0] if (audio_only or pending) else detect_scenes(args.input, args.threshold, args.min_scene, dur, args.ratio)
181
182
  has_audio = bool(meta.get("audio")) and not pending
182
183
  bounds = cuts + [dur]
@@ -35,6 +35,7 @@ from _common.emit import refuse
35
35
  from _common import detect_silences as detect, STATE, add_common, apply_common, default_output, die, emit, info, is_audio_output, print_json, probe, X264_PRESETS, measured_level_dbfs, fmt_secs
36
36
  from _common.decision import frame_rate, stream_starts
37
37
  from _common.probe import PROBED_AUDIO
38
+ from _common.emit import input_length
38
39
 
39
40
 
40
41
 
@@ -657,7 +658,7 @@ def main() -> int:
657
658
  meta = probe(args.input)
658
659
  if not meta.get("audio"):
659
660
  die("input has no audio stream to analyse")
660
- duration = meta.get("duration") or 0.0
661
+ duration = input_length(meta)
661
662
  breaths: List[Tuple[float, float]] = []
662
663
  if args.speech_aware:
663
664
  silences, breaths = speech_aware_silences(args.input, args.threshold, args.min_silence)
@@ -21,6 +21,7 @@ from typing import List, Tuple
21
21
 
22
22
  from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
23
23
  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
24
+ from _common.emit import input_length
24
25
 
25
26
  MAX_SPEED = 20.0
26
27
  MIN_SPEED = 0.05
@@ -72,7 +73,7 @@ def main() -> int:
72
73
  meta = probe(args.input)
73
74
  if not meta.get("video"):
74
75
  die("input has no video stream")
75
- dur = meta.get("duration") or 0.0
76
+ dur = input_length(meta)
76
77
  if abs(segments[0][0] - 0.0) > 0.01:
77
78
  die(f"segments must start at 0, first segment starts at {segments[0][0]:g}")
78
79
  if abs(segments[-1][1] - dur) > 0.5:
package/scripts/sync.py CHANGED
@@ -38,6 +38,7 @@ from _common.emit import record_skip, warn
38
38
  from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, stream_starts
39
39
  from _common.timeline import output_timecode_args
40
40
  from _common.runner import default_selection_maps
41
+ from _common.emit import input_length
41
42
 
42
43
  SR = 8000 # decode sample rate
43
44
 
@@ -227,8 +228,8 @@ def measure_one(reference: str, path: str, args) -> Dict:
227
228
  # the head to measure over) or low_confidence (the tail did not correlate); None when applied
228
229
  skipped_reason = None
229
230
  if args.fix_drift:
230
- ref_dur = probe(reference)["duration"] or 0.0
231
- sec_dur = probe(path)["duration"] or 0.0
231
+ ref_dur = input_length(probe(reference))
232
+ sec_dur = input_length(probe(path))
232
233
  overlap_end = min(ref_dur, sec_dur + offset)
233
234
  head_len = min(args.analyze_seconds, overlap_end)
234
235
  tail_start = overlap_end - args.drift_window