ffmpeg-skill 3.6.5 → 3.6.7

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.
@@ -241,12 +241,16 @@ DRY_RUN_ANALYSIS = {
241
241
  "sync": "audio is decoded to find the offset; the aligned output is not written",
242
242
  "multicam": "audio is decoded to align the cameras; the switched output is not written",
243
243
  "scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
244
- "report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
244
+ "report": "probe and loudness measurements run (and check.py, with --platform); contact sheets are not drawn under --dry-run (a report_section_dry_run skip); the HTML is not written",
245
245
  "cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
246
246
  "silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written. --filler --transcribe also runs the speech engine (its word timings are what the filler spans are planned on), which may download a model on first use; so does --transcribe with --sentence-min-silence or --sentence-margin-tail",
247
247
  "loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
248
248
  "check": "read-only tool; the loudness measurement (or, when it is skipped, the audio row's volumedetect) and the --content decode run under --dry-run too, so every row is present",
249
249
  "stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
250
+ "metadata": "only with --auto-chapters: its silence and scene measurement decodes the input so the proposed chapters are real; otherwise no ffmpeg runs, and the tagged output is never written",
251
+ "cut": "only with --snap beats (and no --snap-source): the audio is decoded to find the beat grid the in/out points snap to; otherwise no ffmpeg runs, and the cut is never written",
252
+ "render": "each stage runs as its own --dry-run, so a stage whose input exists measures it as that tool does under --dry-run (a clip's --snap beats cut, a loudness or silence pass on a source); a stage whose input an earlier stage would write is not measured (measurement_input_pending); nothing is written",
253
+ "batch": "each recipe step runs as its own --dry-run on the item's source, so a step that measures (loudness, silence, check, a --snap beats cut, a render project's stages) measures it; nothing is written",
250
254
  "overlay": "only for a multi-line --text on FFmpeg 8.0, 8.0.1 or 8.1.0: one drawtext measures each line on a generated one-frame picture (the input is not read), so the planned per-line positions are the ones a run uses; otherwise no ffmpeg runs and the composited output is never written",
251
255
  }
252
256
  DRY_RUN_NOTES = {
@@ -449,6 +453,7 @@ ERROR_REASONS = {
449
453
  "encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or ffmpeg itself reported \"Unknown encoder\" for the encoder the command named (kind ffmpeg; details: {name}; the hint points at doctor)",
450
454
  "asr_engine_missing": "kind missing_tool: --transcribe (caption.py; silence.py with --filler or a sentence flag) found no local speech-to-text engine; the message lists the install lines; details: {}",
451
455
  "encoder_list_unreadable": "--codec av1/prores: the build's encoder list could not be read, so whether it has the encoder is unknown (kind ffmpeg); details: {codec, encoders, error}",
456
+ "audio_too_short": "kind input: loudness.py: the audio has a true peak but is shorter than one 400 ms gating block, so it has no integrated loudness to normalise from (not silence); details: {input_tp}",
452
457
  "no_speech": "kind input: --transcribe (caption.py; silence.py with --filler or a sentence flag) ran a local speech engine that heard no speech in the input; details: {engine}; also top-level reason and engine",
453
458
  "engine_failed": "kind input: --transcribe: an installed speech engine ran and failed (a named --engine, or every engine --engine auto found); details: {engine, detail | engines: [{engine, reason, detail}]}; also top-level reason, engine and detail/engines",
454
459
  "english_only": "kind input: --transcribe under --engine auto found only Parakeet engines, which it runs for English speech only, and the language is not English; details: {engine, engines}; also top-level reason, engine and engines",
@@ -500,7 +505,8 @@ DIAGNOSTIC_CODES = {
500
505
  "cues_outside_video": ("warnings", "caption.py: cues wholly outside the video are not drawn; {count, duration}"),
501
506
  "asr_language_assumed": ("warnings", "--transcribe: the language was assumed (English) and the transcript is wrong if it is not"),
502
507
  # analysis
503
- "drift_correction_skipped": ("skipped", "sync.py --fix-drift not applied for a source (drift_skipped_reason); {path, reason: too_short | low_confidence, ...}"),
508
+ "speed_length_placeholder": ("skipped", "render.py --dry-run: a clip's length could not be read before its cut exists, so its speed stage was planned on a stand-in 10 s; {stage, clip, path, placeholder_seconds}"),
509
+ "drift_correction_skipped": ("skipped", "sync.py / multicam.py --fix-drift not applied for a source (drift_skipped_reason); {path, reason: too_short | low_confidence, ...}"),
504
510
  "sync_low_confidence": ("warnings", "sync.py / multicam.py: a source's offset has a correlation confidence below 0.1; {path, confidence}"),
505
511
  "cropdetect_windows_failed": ("warnings", "cropdetect.py: sampled windows ffmpeg could not decode (windows_failed); {failed, windows, starts}"),
506
512
  "filler_words_untimed": ("warnings", "silence.py --filler: listed words in the transcript with no usable timing were not removed (filler.matched_untimed); {count}"),
@@ -521,7 +527,7 @@ DIAGNOSTIC_CODES = {
521
527
  "normalize_on_by_default": ("warnings", "render.py: a platform export preset with no loudness stage got export.py --normalize by default; {preset}"),
522
528
  # degraded or partial results (part 2)
523
529
  "colour_not_converted": ("warnings", "export.py: an HDR or wide-gamut source written by a preset that tags SDR BT.709 without converting it; {preset, hdr_format}"),
524
- "output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS), so no gain meets the platform's loudness; {platform}"),
530
+ "output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS and no true peak above the silence floor), so no gain meets the platform's loudness; {platform}"),
525
531
  "flash_pass_failed": ("warnings", "check.py --content: the flash pass failed, so the flashes row is not measured (WARN, could not measure); the other rows stand"),
526
532
  "repair_incomplete": ("warnings", "export.py --repair: decode errors remain after the remux (a damaged frame needs a re-encode); {timestamp_warnings, decode_errors}"),
527
533
  "legalize_residual": ("warnings", "color.py --legalize: samples of the encoded file are still outside the legal range after the clamp (the encoder's error around clamped areas); the shares are in legalize.output"),
@@ -529,7 +535,7 @@ DIAGNOSTIC_CODES = {
529
535
  "loudness_outside_spec": ("warnings", "export.py: the written file's loudness is outside the platform's spec (normalized: after --normalize too); {platform, lufs, tp, normalized}"),
530
536
  "smart_fallback": ("fallbacks", "cut.py --smart could not make or verify the smart cut, so the whole cut was re-encoded as --accurate would (smart.reason says why); {reason_code}"),
531
537
  "camera_not_started": ("fallbacks", "multicam.py: a range asked for a camera that had not started; camera 0 played it ({start, end, requested_camera, camera, starts_at}); `cuts` names the camera rendered"),
532
- "loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
538
+ "loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, or the audio is shorter than one 400 ms gating block (loudness.too_short), so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
533
539
  "loudness_no_audio": ("skipped", "export.py: a platform preset's loudness check, on a source with no audio"),
534
540
  "report_section_missing": ("warnings", "report.py: a loudness, compliance or contact-sheet section could not be built (also a failing verification step naming the section)"),
535
541
  "report_section_dry_run": ("skipped", "report.py --dry-run draws no contact sheet"),
@@ -608,11 +614,11 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
608
614
  "video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]},
609
615
  "timecode": {"type": "object", "description": "3.0.0: the source's start timecode {value, drop_frame, source}: the first HH:MM:SS[:;.]FF timecode tag from the video stream, then a tmcd data stream, then the container; all three null when none carries one"},
610
616
  "chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
611
- "subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title}]"},
617
+ "subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title, default (the stream's default disposition)}]"},
612
618
  "keyframes": {"type": "array", "description": "--keyframes-near only: one entry per time [{at, window, keyframes (the video keyframes within window seconds of it), before (the nearest of those at or before it, or null when none is within the window), after (at or after it, likewise)}], seconds from the file's start; a failed keyframe read fails the run (kind ffmpeg)"}},
613
619
  "additionalProperties": True}
614
620
  base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
615
- "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
621
+ "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "the ffmpeg command lines this run planned or ran to write its output (render.py and waveform.py: their stages' too); a measurement pass is listed only where the tool records it (e.g. loudness.py pass 1, check.py, stabilize.py pass 1), and batch.py lists none of its steps' (each results row's step documents carry their own)"},
616
622
  "probe": {"type": "object", "description": "probe of the output when a file was written"},
617
623
  "output_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file this run wrote, absent under --dry-run; pass it to check.py --expect-identity SIZE:MTIME_NS so the check grades this file and not whatever later sits at the path"},
618
624
  "encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
@@ -630,7 +636,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
630
636
  "levels": {"type": "object", "description": "--levels only: {standard, reference, bit_depth, legal {y, c}: inclusive legal code ranges at the file's depth, window_seconds, frames, out_of_range_pct {y, u, v, chroma}: share of samples outside them (chroma: Cb and Cr samples together), brng_pct (signalstats BRNG: pixels with any component outside the fixed BT.601/709 nominal range), worst_frame {time, y_pct, chroma_pct}, min {y, u, v}, max {y, u, v}, windows: the same per --levels-window seconds ({start, end, ...})}"},
631
637
  "change_scope": {"type": "object", "description": "--expect-changed only, when the two files could be compared: {declared [{start, end, rect}], observed [{start, end, frames, rect [x, y, w, h], peak}]: every span with a tile above the floor, undeclared: the same for the tiles no declaration covers, declared_unchanged: declared spans in which nothing changed, floor, tile [columns, rows], frames, quiet_peak: the largest tile at or under the floor}. Times are this file's presentation times in seconds, rectangles are displayed pixels"},
632
638
  "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), when --compare paired a different number of frames than the reference has, or when --expect-changed compared two files that are not encoded alike"},
633
- "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
639
+ "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS). A value that could not be read is \"could not measure\", never a PASS: duration FAILs (WARN without a length limit) when the file states no length, and black, frozen and silence are then WARN rows; loudness is WARN for audio shorter than one 400 ms block; --tp without --lufs (custom) measures the true peak row"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
634
640
  elif name == "caption":
635
641
  extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
636
642
  "caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input. --ass: ass_malformed -- Dialogue lines that could not be read and are not counted -- and with --offset ass_shifted / ass_unshifted -- lines moved, and lines whose timestamps could not be read, copied as they were (each count above 0 is also a diagnostics.warnings entry)"},
@@ -705,7 +711,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
705
711
  extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"], "description": "check.py's document for --platform; null when it was not asked for or could not run (a failing compliance verification step then says why)"}}
706
712
  elif name == "export":
707
713
  extra = {"repair": {"type": "object", "description": "--repair (2.24): {before, after}, each {timestamp_warnings, decode_errors} counted over one full decode at -v warning"},
708
- "loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
714
+ "loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read, and with too_short true (lufs and ok null, tp measured) when the audio is shorter than one 400 ms gating block, which is not silence"},
709
715
  "alpha_mode": {"enum": ["straight", "premultiplied"], "description": "alpha presets only (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha): how the colour was written, from --alpha-mode (default straight). The `alpha` verification step compares the written file's alpha with the source's frame by frame, tolerance 2 of 255 per frame (comparison: {ok, paired, worst_delta, worst_frame}; with a different frame count the mean and the most transparent frame, paired false; frames: {expected, measured}, each {mean, min, frames}); `codec` compares the written codec and profile with the preset's"},
710
716
  "key": {"type": "object", "description": "--preset key-fill only: the key file {output (<out stem>_key.mov), identity, sha256, timecode (the source's start timecode written on both, or null)}. Verification adds key_fill_pair (width, height, frame count, rate and the video streams' lengths equal on both files) and key_level (the key's level against the source's alpha frame by frame, tolerance 2 of 255); the source's subtitle/data streams are not written (non_av_streams.dropped, reason key_fill)"},
711
717
  "fill_sha256": {"type": "string", "description": "--preset key-fill only: the fill file's SHA-256 (the key's is key.sha256)"},
@@ -714,9 +720,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
714
720
  "dropped_non_av_streams": {"type": "boolean", "description": "true when non_av_streams.dropped is not empty; a notes line says so too"},
715
721
  "notes": {"type": "array", "items": {"type": "string"}}}
716
722
  elif name == "loudness":
717
- extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
723
+ extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result. silent true (input_i \"-inf\") when the audio has no sound; too_short true (input_i null, input_tp measured) when it is shorter than one 400 ms gating block: not silence, and a normalisation is refused with error.reason audio_too_short"},
718
724
  "targets": {"type": "object", "description": "the requested lufs / tp / lra"},
719
- "result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"},
725
+ "result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used, encodes and loudnorm_mode (linear, or dynamic when loudnorm could not apply one linear gain: the input's true peak plus the gain passes the ceiling, or its range is 0 or above --lra); note says why the loudness missed the target, naming dynamic mode when that is the cause. A loudness step on an output with no integrated loudness is ok false with reason not_measured"},
720
726
  "versions": {"type": "array", "description": "--versions only (output is then null): one row per target, {target, output, targets, result}; every row's loudness and duration steps are in verification with a `version` key, and verified is true only when every version met its own target"},
721
727
  "outputs": {"type": "array", "items": {"type": "string"}, "description": "--versions only: the files written, in the order asked"}}
722
728
  elif name == "color":
@@ -768,7 +774,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
768
774
  "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"}}
769
775
  elif name == "multicam":
770
776
  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)"},
771
- "offsets_seconds": {"type": "array"}, "confidence": {"type": "array"}}
777
+ "offsets_seconds": {"type": "array"}, "confidence": {"type": "array"},
778
+ "drift_ppm": {"type": "array", "items": {"type": ["number", "null"]}, "description": "--fix-drift: per input, the drift measured against the reference (the reference itself 0, the clock); null for a camera whose drift was not measured, with the reason in drift_skipped_reason (also a diagnostics.skipped entry, drift_correction_skipped)"},
779
+ "drift_skipped_reason": {"type": "array", "items": {"enum": ["too_short", "low_confidence", None]}, "description": "--fix-drift: per input, why its drift was not measured (too_short: no tail window far enough from the head; low_confidence: the tail did not correlate), null when it was and for the reference"}}
772
780
  elif name == "join":
773
781
  extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
774
782
  "expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
package/scripts/audio.py CHANGED
@@ -157,6 +157,22 @@ def find_silent_tracks(args: argparse.Namespace, problems: List[Dict[str, Any]])
157
157
  return silent
158
158
 
159
159
 
160
+ def main_track_channels(args: argparse.Namespace, meta: Dict[str, Any]) -> int:
161
+ """Channel count of the track audio.py processes: --replace's first audio track, else the
162
+ input's --audio-stream track; 0 when it is not known (a dry run's pending input)."""
163
+ if args.replace:
164
+ if dry_run_input_pending(args.replace) or not os.path.isfile(args.replace):
165
+ return 0
166
+ proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "a:0", "-show_entries",
167
+ "stream=channels", "-of", "csv=p=0", args.replace], quiet=True, check=False)
168
+ first = (proc.stdout or "").strip().splitlines()[:1]
169
+ return int(first[0]) if first and first[0].strip().isdigit() else 0
170
+ streams = meta.get("audio_streams") or []
171
+ if 0 <= args.audio_stream < len(streams):
172
+ return streams[args.audio_stream].get("channels") or 0
173
+ return (meta.get("audio") or {}).get("channels") or 0
174
+
175
+
160
176
  def main() -> int:
161
177
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
162
178
  ap.add_argument("input")
@@ -279,7 +295,10 @@ def main() -> int:
279
295
  die(f"--audio-stream {args.audio_stream}: input has {len(streams)} audio stream(s), 0..{len(streams) - 1}")
280
296
  if args.audio_stream and not streams and not STATE.dry_run:
281
297
  die("--audio-stream needs an input with audio streams")
282
- in_channels = (meta.get("audio") or {}).get("channels") or 0
298
+ # the channels of the track the graph processes (main_src below): the --audio-stream track,
299
+ # or --replace's first track. meta["audio"] is the input's first track, so a mono a:0 left a
300
+ # stereo --audio-stream 1 unfolded by --mono, and a stereo a:0 halved a mono one (A128)
301
+ in_channels = main_track_channels(args, meta)
283
302
  if args.stereo_widen is not None:
284
303
  if args.mono:
285
304
  die("--stereo-widen and --mono contradict each other: there is no stereo image in a 1-channel output")
@@ -324,7 +343,8 @@ def main() -> int:
324
343
  if args.limit:
325
344
  fx.append(dynamics_filter("alimiter", args))
326
345
  if args.mono:
327
- in_ch = (meta.get("audio") or {}).get("channels") or 2
346
+ # after --downmix the track is the stereo fold-down, whatever the source had
347
+ in_ch = 2 if args.downmix else (in_channels or 2)
328
348
  if in_ch == 2:
329
349
  fx.append("pan=mono|c0=0.5*c0+0.5*c1")
330
350
  elif in_ch > 2:
@@ -1229,7 +1229,9 @@ def main() -> int:
1229
1229
  args._fit_floor = args.min_size if args.min_size is not None else ass_units(MIN_CAPTION_FRACTION)
1230
1230
  fit_unmeasurable = not (play_w and play_h)
1231
1231
  fit_stats: dict = {"fit_size": args.fit_size, "size_requested": args.size,
1232
- "size_used": None if fit_unmeasurable else args.size,
1232
+ # A76: null until a fit (or a stated size) decides it; a --transcribe
1233
+ # dry run has no cues, so nothing is fitted and nothing is claimed
1234
+ "size_used": args.size if (args.fit_size == "off" or (args.fit_size == "auto" and args._size_explicit)) else None,
1233
1235
  "size_floor": args._fit_floor,
1234
1236
  "size_pct_height": round(args.size * 100.0 / ASS_SCRIPT_HEIGHT, 2),
1235
1237
  "shrunk": 0, "fit_scope": args.fit_size_scope, "fit_exhausted": False}
@@ -1569,14 +1571,23 @@ def main() -> int:
1569
1571
  cmd += [f"-c:s:{i}", existing_out[i]]
1570
1572
  tracks: List[Dict[str, Any]] = []
1571
1573
  converted: List[str] = []
1574
+ # A60: a kept track is reported with the disposition the input gives it (the muxer copies
1575
+ # it), not False whatever the file says; when --default-track makes a new track the
1576
+ # default, the kept ones are cleared so the file does not carry two defaults
1577
+ new_default = bool(args.default_track and any(
1578
+ lang and lang.lower() == args.default_track.lower() for _p, lang in added))
1572
1579
  for i in range(existing_subs):
1573
1580
  detail = existing_details[i] if i < len(existing_details) else {}
1574
1581
  written = detail.get("codec") if existing_out[i] == "copy" else existing_out[i]
1575
1582
  if existing_out[i] != "copy":
1576
1583
  converted.append(f"{detail.get('codec')} -> {existing_out[i]}")
1584
+ kept_default = bool(detail.get("default")) and not new_default
1585
+ if new_default and detail.get("default"):
1586
+ # "-default" takes that one flag off; "0" would clear forced and the rest with it
1587
+ cmd += [f"-disposition:s:{i}", "-default"]
1577
1588
  tracks.append({"index": i, "file": None, "language": detail.get("language"),
1578
1589
  "title": detail.get("title"), "codec": written,
1579
- "default": False, "cues": None, "kept_from_input": True})
1590
+ "default": kept_default, "cues": None, "kept_from_input": True})
1580
1591
  for n, (path, lang) in enumerate(added):
1581
1592
  idx = existing_subs + n
1582
1593
  cmd += [f"-c:s:{idx}", codec]
@@ -1608,7 +1619,9 @@ def main() -> int:
1608
1619
  tracks.append({"index": idx, "file": path, "language": stored, "title": title,
1609
1620
  "codec": codec, "default": is_default, "cues": cues_n,
1610
1621
  "kept_from_input": False})
1611
- if args.default_track and not any(t["default"] for t in tracks):
1622
+ # asked of the tracks this run adds: a default the input already carried does not make
1623
+ # --default-track fr true when no --srt was tagged fr
1624
+ if args.default_track and not new_default:
1612
1625
  die(f"--default-track {args.default_track}: no --srt was tagged with that language",
1613
1626
  kind="input")
1614
1627
  # MPEG-4 has no way to say "no default subtitle track": the muxer sets the track-header
@@ -1726,6 +1739,19 @@ def main() -> int:
1726
1739
  generated_ass = True
1727
1740
  else:
1728
1741
  generated_ass = False
1742
+ if not generated_ass and caption_stats.get("fit_scope") == "cue":
1743
+ # A67: one size per cue is a {\fsN} override in the generated ASS; the plain SRT burn
1744
+ # draws every cue at one FontSize, so the scope that was applied is the file's, and
1745
+ # saying "cue" described sizes nobody drew. A caller's own --ass sets its sizes itself
1746
+ # (styles, per-cue overrides): this tool decided none of them, so it claims no scope.
1747
+ if args.ass:
1748
+ caption_stats["fit_scope"] = None
1749
+ side_notes.append("--fit-size-scope cue was not applied: the --ass file sets its own sizes, "
1750
+ "which this tool does not read or change")
1751
+ else:
1752
+ caption_stats["fit_scope"] = "file"
1753
+ side_notes.append("--fit-size-scope cue applies on the generated-ASS route (--animate, --karaoke, emoji "
1754
+ "overlays); this burn draws every cue at one size (size_used), so the fit is file-scoped")
1729
1755
 
1730
1756
  if args.ass:
1731
1757
  if not generated_ass and not os.path.exists(args.ass) and not planned_ass:
package/scripts/check.py CHANGED
@@ -47,6 +47,8 @@ from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, re
47
47
  from _common.probe import (CHANGE_FLOOR, CHANGE_GRID, change_mask_tiles, change_tiles, LEGAL_RANGE_CHOICES, measure_signal_range, COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
48
48
  detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
49
49
  from _common.emit import file_identity
50
+ from _common.probe import PROBED_AUDIO
51
+ from _common.probe import display_size as shown_size, shorter_than_a_loudness_block
50
52
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
51
53
 
52
54
  # The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
@@ -61,7 +63,10 @@ def measure_loudness(path: str) -> Dict[str, float]:
61
63
  if dry_run_input_pending(path):
62
64
  return {}
63
65
  ffmpeg = require_tool("ffmpeg")
64
- proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], check=False, record=True)
66
+ # the first audio track, the one the audio row's probe facts describe (and the one export and
67
+ # loudness write): default selection measured the track with the most channels
68
+ proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_AUDIO}", "-vn",
69
+ "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], check=False, record=True)
65
70
  m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
66
71
  if not m:
67
72
  return {}
@@ -455,8 +460,17 @@ def main() -> int:
455
460
  "reason": reason if status != "PASS" else "",
456
461
  "kind": "judgement" if name in JUDGEMENT else "format"})
457
462
 
458
- dur = meta.get("duration") or 0.0
459
- if spec["max_duration"]:
463
+ # A113: a length ffprobe did not report is not a length of 0 s that fits under every limit.
464
+ # The row says it was not measured (FAIL under a length limit, WARN without one), and the
465
+ # --content rows that are shares of the running time say so too instead of disappearing.
466
+ dur_read = meta.get("duration")
467
+ dur = dur_read or 0.0
468
+ if dur_read is None:
469
+ row("duration", "FAIL" if spec["max_duration"] else "WARN", "could not measure",
470
+ f"<= {spec['max_duration']:g}s" if spec["max_duration"] else "any",
471
+ "remux the file (export.py --preset copy) so the container states its length, then check again",
472
+ reason="the file does not state its length, so whether it is within the limit is unknown")
473
+ elif spec["max_duration"]:
460
474
  row("duration", "PASS" if dur <= spec["max_duration"] else "FAIL", f"{dur:.2f}s", f"<= {spec['max_duration']:g}s", "decide with the user: cut.py keeps quality but drops content; fit.py --duration speeds up (audio pitch-preserved, motion faster)")
461
475
  else:
462
476
  row("duration", "PASS", f"{dur:.2f}s", "any")
@@ -465,7 +479,9 @@ def main() -> int:
465
479
  w, h = v["width"], v["height"]
466
480
  if v.get("rotation") in (90, -90, 270, -270):
467
481
  w, h = h, w
468
- asp = aspect_name(w, h)
482
+ # A114: the aspect a viewer sees is the displayed frame's (rotation and SAR applied), not
483
+ # the stored grid's: 1440x1080 at SAR 4:3 is a 16:9 picture
484
+ asp = aspect_name(*shown_size(v)) if all(shown_size(v)) else aspect_name(w, h)
469
485
  if spec["aspects"]:
470
486
  row("aspect", "PASS" if asp in spec["aspects"] else "FAIL", asp, "/".join(spec["aspects"]), f"fit.py --aspect {spec['aspects'][0]} --fit pad (keeps everything, adds bars) or crop (fills the frame, loses the edges: check the subject with look.py)")
471
487
  else:
@@ -474,7 +490,11 @@ def main() -> int:
474
490
  if spec["min_height"]:
475
491
  row("resolution", "PASS" if short >= spec["min_height"] else "WARN", f"{w}x{h}", f"short side >= {spec['min_height']}", "upscaling will not add detail; re-export from the master")
476
492
  fps = v.get("fps") or 0
477
- if spec["fps_max"]:
493
+ if spec["fps_max"] and not fps:
494
+ # a rate ffprobe did not report is not 0 fps under every limit (the class of A113)
495
+ row("fps", "WARN", "could not measure", f"<= {spec['fps_max']}", "re-encode at a fixed rate (fit.py --fps N)",
496
+ reason="the stream states no frame rate, so whether it is within the limit is unknown")
497
+ elif spec["fps_max"]:
478
498
  row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30 (drops half the frames of 60 fps motion; fine for talking heads, visible on sports/gaming)")
479
499
  row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
480
500
  if spec["codecs"]:
@@ -513,7 +533,10 @@ def main() -> int:
513
533
  row("video", "FAIL", "none", "video stream", "")
514
534
 
515
535
  size = meta.get("size_bytes") or 0
516
- if spec["max_bytes"]:
536
+ if spec["max_bytes"] and meta.get("size_bytes") is None:
537
+ row("file size", "FAIL", "could not measure", f"<= {spec['max_bytes'] / 1024 / 1024:.0f} MB", "",
538
+ reason="the file's size could not be read, so whether it is within the limit is unknown")
539
+ elif spec["max_bytes"]:
517
540
  row("file size", "PASS" if size <= spec["max_bytes"] else "FAIL", f"{size / 1024 / 1024:.1f} MB", f"<= {spec['max_bytes'] / 1024 / 1024:.0f} MB", "export.py --crf 24 or lower resolution")
518
541
 
519
542
  if a:
@@ -565,12 +588,30 @@ def main() -> int:
565
588
  loudness_ran = False
566
589
  if a.get("sample_rate") and a["sample_rate"] not in (44100, 48000):
567
590
  row("sample rate", "WARN", a["sample_rate"], "44100 or 48000", "loudness.py --sample-rate 48000")
568
- if not args.no_loudness and spec["lufs"] is not None:
591
+ # A117: --tp alone (custom has no loudness target) asks for the true-peak row, which
592
+ # needs the same measurement pass; without it the run reported 0 failed having measured nothing
593
+ tp_only = spec["lufs"] is None and args.tp is not None
594
+ if not args.no_loudness and (spec["lufs"] is not None or tp_only):
569
595
  lm = measure_loudness(args.input)
570
596
  loudness_ran = bool(lm)
571
597
  if lm and lm["tp"] <= SILENT_PEAK_DBFS:
572
598
  silent_audio(lm["tp"])
573
- if lm:
599
+ if lm and tp_only:
600
+ row("true peak", "PASS" if lm["tp"] <= spec["tp"] + TP_TOLERANCE_DB else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP (+{TP_TOLERANCE_DB:g} dB tolerance)", f"loudness.py --tp {spec['tp']:g}")
601
+ elif not lm and tp_only:
602
+ row("true peak", "WARN", "could not measure", f"<= {spec['tp']:g} dBTP", "re-run check.py, or verify true peak manually",
603
+ reason="ffmpeg's loudness measurement didn't produce a readable result -- this was not actually checked")
604
+ elif (lm and lm["lufs"] == float("-inf") and lm["tp"] > SILENT_PEAK_DBFS
605
+ and shorter_than_a_loudness_block(a.get("duration") or a.get("tagged_duration") or dur_read)):
606
+ # A257: shorter than one 400 ms gating block: a true peak but no integrated
607
+ # loudness. Not silence, and not a -inf LUFS that FAILs the target. Decided on
608
+ # the sound's length: a longer track whose every block is under the gate reads
609
+ # -inf too, and that one still FAILs the target below
610
+ row("loudness", "WARN", "could not measure (shorter than one 400 ms loudness block)",
611
+ f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", "",
612
+ reason="integrated loudness is gated over 400 ms blocks; a clip this short has none")
613
+ row("true peak", "PASS" if lm["tp"] <= spec["tp"] + TP_TOLERANCE_DB else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP (+{TP_TOLERANCE_DB:g} dB tolerance)", f"loudness.py --tp {spec['tp']:g}")
614
+ elif lm:
574
615
  diff = abs(lm["lufs"] - spec["lufs"])
575
616
  row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g} for speech or music; leave ambience/near-silence (<= -40 LUFS) alone and say so",
576
617
  reason="the platform will auto-normalise it to its own target anyway, which can pump or duck the mix in ways you did not choose")
@@ -596,6 +637,14 @@ def main() -> int:
596
637
  if args.content:
597
638
  cd = detect_content(args.input, dur, video=bool(v), audio=bool(a))
598
639
  span = dur if dur > 0 else 0.0
640
+ if not span and not dry_run_input_pending(args.input):
641
+ # A113: black, frozen and silence are shares of the running time; with no length
642
+ # read there is nothing to divide by, and dropping the rows read as nothing to report
643
+ for name, stream in (("black", v), ("frozen", v), ("silence", a)):
644
+ if stream:
645
+ row(name, "WARN", "could not measure", "a share of the running time",
646
+ "remux the file (export.py --preset copy) so the container states its length, then check again",
647
+ reason="the file does not state its length, so this share of it could not be worked out")
599
648
  if "black" in cd and span:
600
649
  share = _span_total(cd["black"]) / span
601
650
  row("black", "FAIL" if share >= BLACK_FAIL else ("WARN" if share > BLACK_WARN else "PASS"),
package/scripts/crop.py CHANGED
@@ -23,6 +23,7 @@ import sys
23
23
 
24
24
  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
25
25
  from _common.probe import display_size, square_pixels, square_pixels_note
26
+ from _common.runner import default_selection_maps
26
27
  from _common.emit import note
27
28
 
28
29
 
@@ -65,7 +66,8 @@ def main() -> int:
65
66
  if square_pixels(meta["video"]):
66
67
  vf.insert(0, square_pixels(meta["video"]))
67
68
  note(square_pixels_note(meta["video"]))
68
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", ",".join(vf)]
69
+ # the picture the rectangle was checked against, not the largest one default selection takes
70
+ cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", ",".join(vf)]
69
71
  cmd += video_args(meta, args.crf, args.preset)
70
72
  cmd += cfr_args(meta, args.fps)
71
73
  if has_audio:
@@ -35,7 +35,7 @@ from typing import Dict, List, Optional, Tuple
35
35
 
36
36
  from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames, frame_flow
37
37
  from _common.emit import note, warn
38
- from _common.probe import display_size, square_pixels, square_pixels_note
38
+ from _common.probe import PROBED_VIDEO, display_size, square_pixels, square_pixels_note
39
39
 
40
40
  MOTION_CENTRE_FPS = 2.0
41
41
  MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
@@ -57,7 +57,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
57
57
  start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
58
58
  start = max(0.0, start)
59
59
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
60
- "-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
60
+ "-map", f"0:{PROBED_VIDEO}", "-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
61
61
  proc = run_analysis(cmd, check=False)
62
62
  if proc.returncode != 0:
63
63
  # One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
package/scripts/cut.py CHANGED
@@ -61,7 +61,8 @@ from typing import List, Tuple
61
61
  from _common.runner import JsonTooDeep, fail_if_classified, read_json
62
62
  from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
63
63
  from _common import require_tool
64
- from _common.probe import TS_EXTS, signatures_match, stream_signature
64
+ from _common.probe import PROBED_AUDIO, PROBED_VIDEO, TS_EXTS, probed_maps, several_av_streams, signatures_match, stream_signature
65
+ from _common.runner import subtitle_codec_for, subtitle_container_holds
65
66
  from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
66
67
  from _common.timeline import output_timecode_args
67
68
  from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
@@ -147,6 +148,29 @@ def copy_args(meta: dict, dst: str) -> List[str]:
147
148
  return args
148
149
 
149
150
 
151
+ def stream_maps(meta: dict, dst: str, copy: bool) -> List[str]:
152
+ """The -map arguments of a cut: the picture and the sound probe() describes, which every
153
+ measurement here (keyframes, start skew, timecode, length) is taken from. ffmpeg's default
154
+ selection wrote the largest picture and the track with the default disposition or the most
155
+ channels instead (A258). A stream copy of a file with one picture and one sound keeps the
156
+ default selection, command for command (it also carries a subtitle stream into a container
157
+ whose muxer names a subtitle codec, such as Matroska). With several, the first subtitle stream
158
+ is mapped when the container holds it as it is, and is otherwise left out and reported
159
+ (DROPPED_STREAMS)."""
160
+ audio_only = is_audio_output(dst) or not meta.get("video")
161
+ if copy and not several_av_streams(meta):
162
+ return []
163
+ maps = probed_maps(meta, video=not audio_only)
164
+ subs = meta.get("subtitle_stream_details") or []
165
+ if copy and not audio_only and subs:
166
+ codec, ext = subs[0].get("codec") or "", os.path.splitext(dst)[1].lstrip(".").lower()
167
+ if subtitle_container_holds(codec, ext) and subtitle_codec_for(codec, ext) == "copy":
168
+ maps += ["-map", "0:s:0"]
169
+ else:
170
+ DROPPED_STREAMS.append(dst)
171
+ return maps
172
+
173
+
150
174
  LOSSLESS_AUDIO = {"pcm_s16le", "pcm_s24le", "pcm_s32le", "pcm_f32le", "flac"}
151
175
 
152
176
 
@@ -870,7 +894,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
870
894
  # -ss before -i seeks, then decoding discards samples up to the exact start
871
895
  # (accurate_seek); atrim bounds the decoded stream to the requested length at sample
872
896
  # resolution.
873
- cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"]
897
+ cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"] + stream_maps(meta, dst, copy=False)
874
898
  cmd += ["-af", f"atrim=end={dur:.6f},asetpts=PTS-STARTPTS"]
875
899
  else:
876
900
  # Seek SEEK_MARGIN early, then drop the margin on the output side, which decodes and
@@ -879,6 +903,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
879
903
  # `start` and lose the frames before it (-ss 9.9 on a B-frame HEVC file began at 10.0).
880
904
  m = min(SEEK_MARGIN, start)
881
905
  cmd = ffmpeg_base() + ["-ss", f"{start - m:.6f}", "-i", src, "-ss", f"{m:.6f}", "-t", f"{dur:.6f}"]
906
+ cmd += stream_maps(meta, dst, copy=False)
882
907
  # ffmpeg's default stream selection also picks one subtitle stream; a re-encode cannot
883
908
  # trim it (the cues kept their timestamps and the container grew to 2 s for a 1 s cut,
884
909
  # sweep F2), so the re-encode carries video/audio only and the result says so
@@ -901,7 +926,8 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
901
926
  elif audio_only:
902
927
  # output-side seek: an input seek on a video file lands on the previous video keyframe and on
903
928
  # FLAC/MP3 on a coarse index; reading from the start and dropping packets is exact to the packet
904
- cmd = ffmpeg_base() + ["-i", src, "-ss", f"{start:.6f}", "-t", f"{dur:.6f}"] + copy_args(meta, dst) + ["-avoid_negative_ts", "make_zero", dst]
929
+ cmd = (ffmpeg_base() + ["-i", src, "-ss", f"{start:.6f}", "-t", f"{dur:.6f}"] + stream_maps(meta, dst, copy=True)
930
+ + copy_args(meta, dst) + ["-avoid_negative_ts", "make_zero", dst])
905
931
  else:
906
932
  # With --edit-list a single MP4/MOV output keeps the edit list the plain copy writes: the
907
933
  # keyframe's pre-roll is stored but hidden, so the picture and the sound start at `start`.
@@ -913,7 +939,8 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
913
939
  # Other concat parts (Matroska, MPEG-TS) keep make_zero.
914
940
  edit_list = (edit_list_ok or copy_t is not None) and os.path.splitext(dst)[1].lower() in EDIT_LIST_EXTS
915
941
  cmd = (ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur if copy_t is None else copy_t:.6f}"]
916
- + copy_args(meta, dst) + ([] if edit_list else ["-avoid_negative_ts", "make_zero"]) + [dst])
942
+ + stream_maps(meta, dst, copy=True) + copy_args(meta, dst)
943
+ + ([] if edit_list else ["-avoid_negative_ts", "make_zero"]) + [dst])
917
944
  if not audio_only and (meta.get("timecode") or {}).get("value"):
918
945
  cmd[-1:-1] = output_timecode_args(meta, first_frame_from(src, meta, start, reencode, edit_list and copy_t is None),
919
946
  delivered_rate(meta) if reencode else None)
@@ -1098,7 +1125,7 @@ def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: d
1098
1125
  # concat filter starts the next segment after the longer stream, so a short picture
1099
1126
  # would leave a hole in the video there
1100
1127
  pad = f"tpad=stop_mode=clone:stop_duration={e - vend + 1.0:.6f}," if first + i in hold else ""
1101
- graph.append(f"[{i}:v:0]{pad}trim=start={a - half:.6f}:end={b - half:.6f},setpts=PTS-{a:.6f}/TB[v{i}]")
1128
+ graph.append(f"[{i}:{PROBED_VIDEO}]{pad}trim=start={a - half:.6f}:end={b - half:.6f},setpts=PTS-{a:.6f}/TB[v{i}]")
1102
1129
  pads += f"[v{i}]"
1103
1130
  if has_a:
1104
1131
  # fade only the sides that meet another segment: the file's own start and end stay as cut
@@ -1109,7 +1136,7 @@ def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: d
1109
1136
  fades += f",afade=t=in:st=0:d={fade:.6f}"
1110
1137
  if fade > 0 and n < (len(segments) if total is None else total) - 1:
1111
1138
  fades += f",afade=t=out:st={b - a - fade:.6f}:d={fade:.6f}"
1112
- graph.append(f"[{i}:a:0]atrim=start={a:.6f}:end={b:.6f},asetpts=PTS-{a:.6f}/TB,"
1139
+ graph.append(f"[{i}:{PROBED_AUDIO}]atrim=start={a:.6f}:end={b:.6f},asetpts=PTS-{a:.6f}/TB,"
1113
1140
  f"aresample=async=1:first_pts=0{fades}[a{i}]")
1114
1141
  pads += f"[a{i}]"
1115
1142
  outs = ("[v]" if has_v else "") + ("[a]" if has_a else "")