ffmpeg-skill 3.1.5 → 3.1.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.
Files changed (42) hide show
  1. package/docs/contract.md +11 -7
  2. package/package.json +1 -1
  3. package/references/ci-platform-pitfalls.md +3 -2
  4. package/references/gotchas.md +5 -1
  5. package/references/scripts.md +12 -6
  6. package/scripts/_common/__init__.py +7 -5
  7. package/scripts/_common/asr.py +49 -10
  8. package/scripts/_common/color.py +40 -1
  9. package/scripts/_common/decision.py +177 -10
  10. package/scripts/_common/emit.py +2 -0
  11. package/scripts/_common/fonts.py +26 -0
  12. package/scripts/_common/probe.py +115 -5
  13. package/scripts/_common/runner.py +38 -1
  14. package/scripts/_common/timeline.py +123 -27
  15. package/scripts/_contract.py +34 -3
  16. package/scripts/broll.py +35 -14
  17. package/scripts/caption.py +105 -19
  18. package/scripts/color.py +37 -8
  19. package/scripts/crop.py +8 -3
  20. package/scripts/cropdetect.py +13 -9
  21. package/scripts/cut.py +4 -4
  22. package/scripts/export.py +16 -15
  23. package/scripts/fit.py +8 -4
  24. package/scripts/freeze.py +38 -10
  25. package/scripts/graphics.py +104 -67
  26. package/scripts/grid.py +49 -7
  27. package/scripts/insert.py +9 -1
  28. package/scripts/join.py +29 -19
  29. package/scripts/look.py +41 -12
  30. package/scripts/loop.py +45 -6
  31. package/scripts/multicam.py +16 -7
  32. package/scripts/overlay.py +68 -14
  33. package/scripts/proxy.py +8 -4
  34. package/scripts/redact.py +10 -5
  35. package/scripts/reverse.py +27 -2
  36. package/scripts/scenes.py +16 -5
  37. package/scripts/sequence.py +6 -2
  38. package/scripts/silence.py +91 -35
  39. package/scripts/speedramp.py +17 -5
  40. package/scripts/straighten.py +17 -5
  41. package/scripts/sync.py +8 -2
  42. package/scripts/waveform.py +8 -3
@@ -276,7 +276,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
276
276
  "speedramp": dict(video="always", audio="always", note="setpts/atempo per segment always forces a re-encode of both streams"),
277
277
  "broll": dict(video="always", audio="conditional", note="the overlay graph always re-encodes the video stream; A's audio is stream-copied under --audio a and re-encoded to AAC under --audio b/mix"),
278
278
  "metadata": dict(video="never", audio="never", note="-c copy on every stream; only the container's chapters and tags change -- --auto-chapters decodes to measure, but still writes with -c copy"),
279
- "loop": dict(video="always", audio="conditional", note="-stream_loop always re-encodes both streams; the audio codec is always AAC when present. --boomerang re-encodes the video through reverse/loop filters and drops the audio"),
279
+ "loop": dict(video="always", audio="conditional", note="the repeats (the concat demuxer, the file listed once per repeat) always re-encode both streams; the audio codec is always AAC when present. --boomerang re-encodes the video through reverse/loop filters and drops the audio"),
280
280
  "insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
281
281
  "background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
282
282
  "reverse": dict(video="always", audio="conditional", note="video always re-encodes (reverse buffers and re-emits every frame); audio re-encodes to AAC when present and not dropped by --no-audio"),
@@ -440,6 +440,9 @@ ERROR_REASONS = {
440
440
  "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)",
441
441
  "asr_engine_missing": "kind missing_tool: --transcribe (caption.py, silence.py --filler) found no local speech-to-text engine; the message lists the install lines; details: {}",
442
442
  "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}",
443
+ "no_speech": "kind input: --transcribe (caption.py, silence.py --filler) ran a local speech engine that heard no speech in the input; details: {engine}; also top-level reason and engine",
444
+ "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",
445
+ "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",
443
446
  }
444
447
 
445
448
  # The codes of the collector's records (docs/design-decisions.md, "Nothing happens silently"):
@@ -454,7 +457,7 @@ DIAGNOSTIC_CODES = {
454
457
  "audit_log_unwritable": ("warnings", "an audit record could not be appended; never fails the run; {path, event, error}"),
455
458
  "plan_unbound_inputs": ("warnings", "render.py PLAN: inputs that did not exist when the plan was made, so were not checked; {paths}"),
456
459
  # the runner
457
- "odd_dimensions_scaled_even": ("fallbacks", "an encode that failed on odd dimensions was run again scaled to even ones (the frame loses a row or column); {output, scale}"),
460
+ "odd_dimensions_scaled_even": ("fallbacks", "an encode that failed on odd dimensions was run again scaled to even ones, or a graph the retry cannot reach (broll.py's filter_complex) was scaled to even ones up front (the frame loses a row or column); {output, scale}"),
458
461
  "measurement_input_pending": ("skipped", "--dry-run: a measurement skipped because its input is an earlier stage's output that does not exist yet; {path}"),
459
462
  "non_av_streams_dropped": ("fallbacks", "the subtitle/data streams could not be copied, so the encode ran again without them (dropped_non_av_streams: true); {output, returncode, error: the first attempt's last stderr lines}; export.py adds dropped: the stream types this retry leaves out (data alone first, then subtitle too)"),
460
463
  "ffmpeg_list_unreadable": ("warnings", "`ffmpeg -encoders` / `-bsfs` could not be read; what needs the list says so instead of 'the build lacks it'; {list, tool, error}"),
@@ -547,13 +550,23 @@ FONT_USED_SCHEMA = {
547
550
  }
548
551
 
549
552
 
553
+ # the tools whose --json carries `dropped_non_av_streams` (a test reads the sources)
554
+ NON_AV_STREAM_TOOLS = frozenset({"audio", "broll", "color", "cut", "deinterlace", "denoise", "export", "fit", "freeze",
555
+ "graphics", "join", "loudness", "overlay", "pad", "proxy", "redact", "speedramp",
556
+ "sphere", "straighten"})
557
+
558
+
550
559
  def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str, Any]:
551
560
  """What the tool prints on stdout with --json (keys observed in the implementation). `hw`: the
552
561
  tool takes --hw, so its `notes` can also carry a VideoToolbox encode's quality line."""
553
562
  if name == "probe":
554
563
  return {"type": "object", "description": "one probe document, or an array of them for several inputs",
555
564
  "properties": {"file": {"type": "string"}, "format": {"type": "string"}, "duration": {"type": "number"}, "size_bytes": {"type": "integer"},
556
- "video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]}}, "additionalProperties": True}
565
+ "video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]},
566
+ "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"},
567
+ "chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
568
+ "subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title}]"}},
569
+ "additionalProperties": True}
557
570
  base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
558
571
  "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
559
572
  "probe": {"type": "object", "description": "probe of the output when a file was written"},
@@ -594,6 +607,13 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
594
607
  "speech": {"type": "array", "description": "--speech (1.18): [{time, speech_music_ratio}]"}}
595
608
  elif name == "silence":
596
609
  extra = {"silences": {"type": "array"}, "keep": {"type": "array"}, "input_duration": {"type": "number"}, "kept_duration": {"type": "number"}, "removed_seconds": {"type": "number"},
610
+ "removed_seconds_total": {"type": "number", "description": "--filler: seconds removed in all, silence plus filler words (removed_seconds stays the silence alone)"},
611
+ "cut_points": {"type": ["array", "null"], "description": "2.22: per cut {source_time, output_time, edge (out|in), rms_dbfs (the loudest channel 50 ms either side on the source; null for digital silence), filler (true on a removed filler word's edge)}; null when the source could not be decoded for it (cut_levels_unmeasured warning)"},
612
+ "cut_warnings": {"type": ["array", "null"], "description": "the cut_points (not filler cuts) within 10 dB of --threshold: where to listen (a cut_near_sound warning); verified is unchanged"},
613
+ "timemap": {"type": "string", "description": "--timemap FILE: the source-to-output time map written (JSON or CSV)"},
614
+ "silence_kinds": {"type": "array", "description": "2.20: per detected silence, digital (at or below -90 dBFS) or below_threshold"},
615
+ "silence_breakdown": {"type": "object", "description": "2.20: {digital, below_threshold}: the count and seconds of each, with the level that defines it"},
616
+ "hint": {"type": "string", "description": "when nothing sat below --threshold: the track's measured level and the --threshold that would find its pauses"},
597
617
  "speech_aware": {"type": "object", "description": "--speech-aware (1.18): {min_silence, floor, breaths_kept, breaths_kept_seconds, breaths}"},
598
618
  "filler": {"type": "object", "description": "--filler: {lang, source (whisper-json:FILE | whisper:ENGINE | parakeet:ENGINE), engine, transcription (with --transcribe: {engine, model, language, routing}), removed, removed_count, matched_untimed (listed words in the transcript with no usable timing, not removed), matched_too_long (listed words longer than a filler, kept), ...}"},
599
619
  "notes": {"type": "array", "items": {"type": "string"}, "description": "--filler --transcribe under --engine auto: present when the English-only Parakeet model ran on English assumed (no --filler-lang, no language detected, no Whisper engine installed or none that succeeded); the transcript is wrong if the speech is not English"}}
@@ -651,6 +671,15 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
651
671
  "notes": {"type": "array", "items": {"type": "string"}, "description": "--boomerang on a source with audio: the audio was dropped"}}
652
672
  elif name == "cut":
653
673
  extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
674
+ "mode": {"enum": ["copy", "hybrid", "accurate"], "description": "copy: nothing re-encoded at any stage, the join included; accurate: --accurate was asked for or forced; hybrid: asked for lossless but a segment or the join re-encoded anyway (reencode_reason says why)"},
675
+ "requested_start": {"type": ["number", "null"], "description": "one segment: the requested start, clamped to the media's duration (null for --segments)"},
676
+ "requested_end": {"type": ["number", "null"], "description": "one segment: the requested end, clamped to the media's duration (null for --segments)"},
677
+ "requested_segments": {"type": ["array", "null"], "description": "--segments: [[start, end], ...] requested, clamped to the media's duration (null for one segment)"},
678
+ "requested_duration": {"type": "number", "description": "the requested length, as expected_duration"},
679
+ "output_duration": {"type": ["number", "null"], "description": "the written file's length, measured (null under --dry-run)"},
680
+ "duration_delta_seconds": {"type": ["number", "null"], "description": "output_duration minus requested_duration (null under --dry-run)"},
681
+ "nearest_keyframes": {"type": ["array", "null"], "description": "the keyframes within 5 s of a start whose stream copy snapped past --tolerance and was re-encoded (null otherwise)"},
682
+ "snap": {"type": ["object", "null"], "description": "--snap beats: {mode, tolerance, confidence, tempo_bpm, grid, grid_points, moved, snapped, unchanged, source}; null otherwise"},
654
683
  "dropped_audio_streams": {"type": "integer", "description": "audio tracks of the source this cut did not write (cut writes one, on a stream copy as on a re-encode); 0 for a source with one or none. A notes line and an audio_tracks_dropped warning say so too"},
655
684
  "duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
656
685
  "precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
@@ -696,6 +725,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
696
725
  "audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
697
726
  if name in ("overlay", "graphics", "caption", "grid"):
698
727
  extra["font_used"] = FONT_USED_SCHEMA
728
+ if name in NON_AV_STREAM_TOOLS and "dropped_non_av_streams" not in extra:
729
+ extra["dropped_non_av_streams"] = {"type": "boolean", "description": "true when the source's subtitle or data streams are not in the output: either the run leaves them out by design (a path whose output they would no longer match, such as speedramp.py's, join.py's or freeze.py's), or they could not be copied and the encode ran again without them (then a non_av_streams_dropped fallback says why); export.py's non_av_streams lists them"}
699
730
  if name == "cropdetect":
700
731
  extra.update({"windows": {"type": "integer", "description": "sampled windows (--samples)"},
701
732
  "windows_failed": {"type": "integer", "description": "windows ffmpeg could not decode and that were skipped (a cropdetect_windows_failed warning when > 0); every window failing is a kind ffmpeg failure"}})
package/scripts/broll.py CHANGED
@@ -21,7 +21,10 @@ import argparse
21
21
  import sys
22
22
  from typing import Any, Dict, List
23
23
 
24
- from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
24
+ from _common.decision import av_trim, frame_rate, grid_at_or_after, stream_starts
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, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
26
+ from _common.emit import note, record_fallback
27
+ from _common.probe import display_size, square_pixels, square_pixels_note
25
28
 
26
29
 
27
30
  def main() -> int:
@@ -55,10 +58,20 @@ def main() -> int:
55
58
  if not meta_a.get("video"):
56
59
  die("A-roll has no video stream")
57
60
  dur_a = meta_a.get("duration") or 0.0
58
- w, h = meta_a["video"]["width"], meta_a["video"]["height"]
59
- # the cutaway is scaled onto the frame ffmpeg has already rotated, not the stored one
60
- if meta_a["video"].get("rotation") in (90, -90, 270, -270):
61
- w, h = h, w
61
+ # the cutaway is scaled onto the frame ffmpeg has already rotated, not the stored one, and
62
+ # onto a non-square-pixel A-roll squared at its display aspect (#411)
63
+ w, h = display_size(meta_a["video"])
64
+ a_chain = square_pixels(meta_a["video"])
65
+ if a_chain:
66
+ note(square_pixels_note(meta_a["video"], "the A-roll"))
67
+ if (w % 2 or h % 2) and w and h:
68
+ # an odd-sized A-roll cannot be encoded 4:2:0 at its own size, and the run-time even-scale
69
+ # retry does not reach a filter_complex: through 3.1.3 this failed as kind "ffmpeg" (#411)
70
+ ew, eh = max(2, w - w % 2), max(2, h - h % 2) # never 0, which scale reads as "keep" (#416 review)
71
+ record_fallback(f"the A-roll is {w}x{h}, which 4:2:0 cannot hold: scaled to {ew}x{eh}",
72
+ "odd_dimensions_scaled_even", output=args.output or default_output(args.input, "broll"), scale=f"scale={ew}:{eh}")
73
+ a_chain = (a_chain + "," if a_chain else "") + f"scale={ew}:{eh},setsar=1"
74
+ w, h = ew, eh
62
75
  fps = meta_a["video"].get("fps") or 30.0
63
76
  has_audio_a = bool(meta_a.get("audio"))
64
77
 
@@ -72,13 +85,13 @@ def main() -> int:
72
85
  meta_b = probe(path)
73
86
  if not meta_b.get("video"):
74
87
  die(f"{path} has no video stream")
75
- at = time_arg(args.at[i], "--at", fps)
76
- start_b = time_arg(per(args.from_, i, "0"), "--from", fps)
88
+ at = time_arg(args.at[i], "--at", fps, source=meta_a)
89
+ start_b = time_arg(per(args.from_, i, "0"), "--from", fps, source=meta_b)
77
90
  if args.end:
78
- end = time_arg(per(args.end, i, "0"), "--end", fps)
91
+ end = time_arg(per(args.end, i, "0"), "--end", fps, source=meta_a)
79
92
  length = end - at
80
93
  else:
81
- length = time_arg(per(args.duration, i, "4"), "--duration", fps)
94
+ length = time_arg(per(args.duration, i, "4"), "--duration", fps, source=meta_a)
82
95
  if length <= 0:
83
96
  die(f"cutaway {i + 1}: length must be > 0 (at {at:g}s, got {length:g}s)")
84
97
  if dur_a and at >= dur_a:
@@ -110,16 +123,19 @@ def main() -> int:
110
123
  convert = [matrix_chain(m, meta_a) for m in [meta_a] + [c["meta"] for c in cutaways]]
111
124
  if any(convert) or draws_chroma([args.pad_color], meta_a):
112
125
  convert = [cv or label_chain(m, meta_a) for cv, m in zip(convert, [meta_a] + [c["meta"] for c in cutaways])]
113
- if convert[0]:
114
- parts.append(f"[0:v]{convert[0]}null[a709]")
126
+ # overlay mixes samples: a full-range clip over or under a limited one is taken to limited range (#410)
127
+ convert = [cv + range_chain(m, meta_a) for cv, m in zip(convert, [meta_a] + [c["meta"] for c in cutaways])]
128
+ if convert[0] or a_chain:
129
+ parts.append(f"[0:v]{convert[0]}{a_chain or 'null'}[a709]")
115
130
  cur = "[a709]"
116
131
  pad = f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color="
117
132
  for i, c in enumerate(cutaways):
118
133
  geo = (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
119
- + drawn_chain(pad + args.pad_color, c["meta"], [args.pad_color], pad + "black", line_meta=meta_a,
134
+ + drawn_chain(pad + args.pad_color, as_limited(c["meta"]), [args.pad_color], pad + "black", line_meta=meta_a,
120
135
  tag=f"pad{i}", labelled=True))
136
+ b_sq = (square_pixels(c["meta"]["video"]) + ",") if square_pixels(c["meta"]["video"]) else ""
121
137
  parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
122
- f"{convert[i + 1]}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
138
+ f"{convert[i + 1]}{b_sq}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
123
139
  parts.append(f"{cur}[b{i}]overlay=0:0:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
124
140
  cur = f"[v{i}]"
125
141
  vout = cur
@@ -137,7 +153,12 @@ def main() -> int:
137
153
  parts.append(f"[0:a:0]aformat=sample_rates=48000:channel_layouts=stereo{gate}[a0]")
138
154
  layers.append("[a0]")
139
155
  for i, c in enumerate(cutaways):
140
- parts.append(f"[{i + 1}:a:0]atrim=start={c['from']:.3f}:duration={c['length']:.3f},asetpts=PTS-STARTPTS,"
156
+ # B's sound is shifted by the same constant as its picture, whose first frame (the
157
+ # first at or after --from) lands on --at; rebasing the sound to its own first
158
+ # sample moved a track that starts 0.3 s late 0.279 s early (#409)
159
+ v0b = stream_starts(c["meta"])[0]
160
+ first = max(v0b, grid_at_or_after(c["from"], v0b, frame_rate(c["meta"])))
161
+ parts.append(f"[{i + 1}:a:0]{av_trim(first, first + c['length'])[1]},"
141
162
  f"aformat=sample_rates=48000:channel_layouts=stereo,adelay={int(c['at'] * 1000)}|{int(c['at'] * 1000)}[ab{i}]")
142
163
  layers.append(f"[ab{i}]")
143
164
  parts.append(f"{''.join(layers)}amix=inputs={len(layers)}:normalize=0:dropout_transition=0,atrim=duration={dur_a:.3f}[aout]")
@@ -45,7 +45,7 @@ from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_unit
45
45
  resolve as resolve_platform)
46
46
  from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
47
47
  from _common.emit import refuse # noqa: E402
48
- from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
48
+ from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, layer_base_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
49
49
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
50
50
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
51
51
  # shares them). They stay caption.py's public names -- every caller and test that reached for
@@ -53,6 +53,8 @@ from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_
53
53
  from _common import asr as _asr
54
54
  from _common.asr import ENGINE_CHOICES
55
55
  from _common.emit import warn
56
+ from _common.probe import display_size, square_pixels, square_pixels_note
57
+ from _common.runner import subtitle_codec_for, subtitle_container_holds
56
58
  from _common.fonts import font_choice, record_font_choice
57
59
  from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
58
60
  write_srt)
@@ -144,7 +146,9 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
144
146
  return [total_cs]
145
147
  ffmpeg = require_tool("ffmpeg")
146
148
  cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", video,
147
- "-map", f"0:a:{audio_stream}", "-t", f"{end - start:.3f}", "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
149
+ "-map", f"0:a:{audio_stream}", "-t", f"{end - start:.3f}", "-vn",
150
+ # a sound that starts late is padded to its place on the file's clock (#414)
151
+ "-af", "aresample=async=1:first_pts=0", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
148
152
  proc = run_analysis(cmd, check=False, text=False) # under --timeout like every other measurement
149
153
  n = len(proc.stdout) // 2
150
154
  if proc.returncode != 0 or n < 800:
@@ -886,6 +890,23 @@ def track_title_for(code: Optional[str], given: Optional[str]) -> Optional[str]:
886
890
  return LANG_TITLES.get(code.split("-")[0].lower(), code)
887
891
 
888
892
 
893
+ # a timing line in the grammar ffmpeg's SRT demuxer and parse_srt() read alike: hours, minutes,
894
+ # seconds and three-digit milliseconds on both sides (ffmpeg allows position fields after it)
895
+ _SHARED_SRT_TIMING = re.compile(r"^\s*\d{2,}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{2,}:\d{2}:\d{2}[,.]\d{3}(\s.*)?$")
896
+
897
+
898
+ def srt_timings_shared_grammar(path: str) -> bool:
899
+ """True when every timing line of the SRT at `path` reads the same to ffmpeg's SRT demuxer as
900
+ to parse_srt(). ffmpeg refuses `00:01,000` (no hours) and reads `00:00:01,5` as 1.005 s, where
901
+ parse_srt() reads 1.5 s (#414); such a file is handed to ffmpeg as a rewritten copy."""
902
+ text = read_text_or_die(path, "--srt").lstrip("\ufeff").replace("\r\n", "\n")
903
+ # a cue's timing line is its first line with "-->", as parse_srt() reads it (and ffmpeg too);
904
+ # a later line holding "-->" is dialogue, not a timing this check should judge
905
+ blocks = [[ln for ln in b.split("\n") if ln.strip()] for b in re.split(r"\n\s*\n", text)]
906
+ timings = [next((ln for ln in b if "-->" in ln), None) for b in blocks]
907
+ return all(_SHARED_SRT_TIMING.match(line) for line in timings if line is not None)
908
+
909
+
889
910
  def mux_subtitle_codec(output: str) -> str:
890
911
  ext = Path(output).suffix.lower()
891
912
  if ext in (".mp4", ".m4v", ".mov"):
@@ -1129,6 +1150,11 @@ def main() -> int:
1129
1150
  die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
1130
1151
  if args.audio_stream and not audio_streams:
1131
1152
  die("--audio-stream needs an input with audio streams")
1153
+ if args.transcribe and not audio_streams:
1154
+ # refused before any engine runs, as silence.py refuses it, not an unclassified
1155
+ # "Stream map '0:a:0' matches no streams" from the WAV extraction (#414)
1156
+ die(f"--transcribe needs speech to transcribe, and {args.input} has no audio stream",
1157
+ kind="input", hint="pick the file that carries the sound, or write the cues with --text")
1132
1158
  if args.mode != "mux":
1133
1159
  # before any transcription or sidecar: a build without libass fails these at parse time
1134
1160
  # with a message that names no filter (#393), so ask the build first, dry run included
@@ -1139,9 +1165,9 @@ def main() -> int:
1139
1165
 
1140
1166
  play_w = play_h = None
1141
1167
  if meta and meta.get("video"):
1142
- play_w, play_h = meta["video"]["width"], meta["video"]["height"]
1143
- if meta["video"].get("rotation") in (90, -90, 270, -270):
1144
- play_w, play_h = play_h, play_w
1168
+ # the displayed frame: rotation applied, non-square pixels squared (#411) -- the burn
1169
+ # below draws on the square-pixel picture, so a cue is not stretched by the player's SAR
1170
+ play_w, play_h = display_size(meta["video"])
1145
1171
  # A --plan or --dry-run written before the input exists has no geometry to fit against, and a
1146
1172
  # plan that describes a different FontSize from the run that executes it is not a plan. When
1147
1173
  # --platform names a destination, that destination's frame IS the geometry the real run will
@@ -1379,9 +1405,15 @@ def main() -> int:
1379
1405
  side_notes: List[str] = list(getattr(args, "_asr_notes", None) or [])
1380
1406
  ass_counts: dict = {} # --ass: ass_shifted / ass_unshifted (with --offset) and ass_malformed, into `caption`
1381
1407
  ass_sample_path = args.ass
1408
+ normalised_only = False # srt_path is a copy of the caller's SRT that only rewrites its timings
1382
1409
  if args.srt and not (args.text or args.transcribe) and os.path.exists(srt_path or ""):
1383
1410
  adjusted, changed = lay_out(parse_srt(srt_path))
1384
- if changed:
1411
+ # ffmpeg's own SRT reader takes a narrower grammar than parse_srt(): a time without hours
1412
+ # is refused and ",5" is read as 5 ms where this tool reads 500. A file outside the grammar
1413
+ # both share goes to ffmpeg as the copy this tool writes, so it gets the cues counted
1414
+ # here (#414)
1415
+ normalised_only = not changed and not srt_timings_shared_grammar(srt_path)
1416
+ if changed or normalised_only:
1385
1417
  new_srt = os.path.splitext(output)[0] + "_adjusted.srt"
1386
1418
  claim([(new_srt, "adjusted SRT")])
1387
1419
  if STATE.dry_run:
@@ -1391,8 +1423,10 @@ def main() -> int:
1391
1423
  info(f"wrote {new_srt} ({len(adjusted)} cues, adjusted from {os.path.basename(srt_path)})")
1392
1424
  srt_path = new_srt
1393
1425
  planned_cues = adjusted
1394
- side_notes.append(f"the burned subtitles are {new_srt}, the adjusted copy of "
1395
- f"{os.path.basename(args.srt)} this run writes (--offset/--max-lines/--min-duration)"
1426
+ why = ("--offset/--max-lines/--min-duration" if changed else
1427
+ "its timings rewritten as hh:mm:ss,mmm, which ffmpeg reads as this tool does")
1428
+ side_notes.append(f"the {'muxed' if args.mode == 'mux' else 'burned'} subtitles are {new_srt}, the adjusted copy of "
1429
+ f"{os.path.basename(args.srt)} this run writes ({why})"
1396
1430
  + ("; re-run this command without --dry-run to produce it" if STATE.dry_run else ""))
1397
1431
  if args.ass and args.offset and os.path.exists(args.ass):
1398
1432
  shifted = os.path.splitext(output)[0] + "_offset.ass"
@@ -1423,10 +1457,34 @@ def main() -> int:
1423
1457
  and not (STATE.dry_run and (args.text or args.transcribe))):
1424
1458
  die(f"SRT file not found: {srt_path}")
1425
1459
  codec = mux_subtitle_codec(output)
1460
+ out_ext = Path(output).suffix.lower().lstrip(".")
1461
+ # The picture and the sound are copied: a WebM holds only VP8/VP9/AV1 and Opus/Vorbis, so
1462
+ # anything else is refused here, not left to fail unclassified in the muxer (#414)
1463
+ if out_ext == "webm":
1464
+ vcodec = (meta.get("video") or {}).get("codec")
1465
+ # the stream -map 0:a:N copies (--audio-stream), not always the first one
1466
+ audio_streams = meta.get("audio_streams") or []
1467
+ acodec = audio_streams[args.audio_stream].get("codec") if args.audio_stream < len(audio_streams) else None
1468
+ bad = [c for c in (vcodec,) if c and c not in ("vp8", "vp9", "av1")] + \
1469
+ [c for c in (acodec,) if c and c not in ("opus", "vorbis")]
1470
+ if bad:
1471
+ die(f"--mode mux copies the picture and the sound, and a .webm holds only VP8/VP9/AV1 video and "
1472
+ f"Opus/Vorbis audio; {args.input} carries {' and '.join(bad)}", kind="input",
1473
+ hint="write .mkv (any codec) or .mp4 instead, or re-encode first with export.py")
1426
1474
  # Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
1427
- # language to build a multi-language set) -- copied byte-identical, distinct from the
1428
- # newly-added SRTs' own codec below.
1475
+ # language to build a multi-language set): copied where the output container holds them as
1476
+ # they are, else converted to its own text format as export.py converts them (#380, #414);
1477
+ # a track the container cannot hold at all (a bitmap one in MPEG-4 or WebM) is refused.
1429
1478
  existing_subs = meta.get("subtitle_streams") or 0
1479
+ existing_details = list(meta.get("subtitle_stream_details") or [])
1480
+ existing_codecs = [(existing_details[i] if i < len(existing_details) else {}).get("codec") or ""
1481
+ for i in range(existing_subs)]
1482
+ unheld = sorted({c or "unknown" for c in existing_codecs if not subtitle_container_holds(c, out_ext)})
1483
+ if unheld:
1484
+ die(f"{args.input} already carries a subtitle track ({', '.join(unheld)}) that a .{out_ext} file cannot "
1485
+ "hold, and --mode mux keeps every existing track", kind="input",
1486
+ hint="write .mkv, which holds any subtitle codec")
1487
+ existing_out = [subtitle_codec_for(c, out_ext) for c in existing_codecs]
1430
1488
  # the first entry's path is srt_path, which --offset/--max-lines may have repointed at an
1431
1489
  # adjusted copy; the rest are taken as written
1432
1490
  added = [(srt_path, srt_tracks[0][1] if srt_tracks else args.language)] + \
@@ -1434,6 +1492,18 @@ def main() -> int:
1434
1492
  for path, _lang in added[1:]:
1435
1493
  if not os.path.exists(path) and not STATE.dry_run:
1436
1494
  die(f"SRT file not found: {path}")
1495
+ for n in range(1, len(added)):
1496
+ path, lang = added[n]
1497
+ if os.path.exists(path) and not srt_timings_shared_grammar(path):
1498
+ # the same rule as the first track (#414): ffmpeg is given what parse_srt() reads
1499
+ copy = f"{os.path.splitext(output)[0]}_track{n}.srt"
1500
+ claim([(copy, f"normalised SRT {n}")])
1501
+ if not STATE.dry_run:
1502
+ write_srt(parse_srt(path), copy)
1503
+ info(f"wrote {copy} (timings of {os.path.basename(path)} rewritten as hh:mm:ss,mmm)")
1504
+ side_notes.append(f"subtitle track {n} is {copy}, a copy of {os.path.basename(path)} with its timings "
1505
+ "rewritten as hh:mm:ss,mmm, which ffmpeg reads as this tool does")
1506
+ added[n] = (copy, lang)
1437
1507
  titles = list(args.track_title or [])
1438
1508
  mp4_family = Path(output).suffix.lower() in (".mp4", ".m4v", ".mov")
1439
1509
  dropped_titles: List[str] = []
@@ -1452,13 +1522,16 @@ def main() -> int:
1452
1522
  if mp4_family:
1453
1523
  cmd += ["-movflags", "+faststart"]
1454
1524
  for i in range(existing_subs):
1455
- cmd += [f"-c:s:{i}", "copy"]
1525
+ cmd += [f"-c:s:{i}", existing_out[i]]
1456
1526
  tracks: List[Dict[str, Any]] = []
1527
+ converted: List[str] = []
1457
1528
  for i in range(existing_subs):
1458
- kept = (meta.get("subtitle_stream_details") or [])
1459
- detail = kept[i] if i < len(kept) else {}
1529
+ detail = existing_details[i] if i < len(existing_details) else {}
1530
+ written = detail.get("codec") if existing_out[i] == "copy" else existing_out[i]
1531
+ if existing_out[i] != "copy":
1532
+ converted.append(f"{detail.get('codec')} -> {existing_out[i]}")
1460
1533
  tracks.append({"index": i, "file": None, "language": detail.get("language"),
1461
- "title": detail.get("title"), "codec": detail.get("codec"),
1534
+ "title": detail.get("title"), "codec": written,
1462
1535
  "default": False, "cues": None, "kept_from_input": True})
1463
1536
  for n, (path, lang) in enumerate(added):
1464
1537
  idx = existing_subs + n
@@ -1509,6 +1582,9 @@ def main() -> int:
1509
1582
  notes.append(f"{total} subtitle tracks in an MPEG-4 container: the tracks are all there, "
1510
1583
  "but many players only ever show the first -- write to .mkv for a "
1511
1584
  "deliverable a viewer can actually switch")
1585
+ if converted:
1586
+ notes.append(f"the input's own subtitle track(s) were converted to the .{out_ext} container's text format "
1587
+ f"({', '.join(converted)}): their text and timing are kept, styling a format cannot carry is not")
1512
1588
  if dropped_titles:
1513
1589
  notes.append("an MPEG-4 container has no per-track title this tool can write back "
1514
1590
  f"({', '.join(dropped_titles)} would be dropped), so the tracks are "
@@ -1520,6 +1596,14 @@ def main() -> int:
1520
1596
  "as given and may not survive")
1521
1597
  run(cmd)
1522
1598
  result = probe(output, role="output")
1599
+ # the language and title each track carries in the written file: a converted track keeps
1600
+ # only what the container can store (MPEG-4 drops a two-letter code and every title)
1601
+ written = result.get("subtitle_stream_details") or []
1602
+ if len(written) == len(tracks):
1603
+ for track, detail in zip(tracks, written):
1604
+ track["language"] = detail.get("language")
1605
+ if track["kept_from_input"]:
1606
+ track["title"] = detail.get("title")
1523
1607
  info(f"wrote {output} ({fmt_secs(result.get('duration'))}, mux, {len(added)} "
1524
1608
  f"subtitle track(s) added, codec {codec})")
1525
1609
  emit(output, tracks=tracks, subtitle_tracks=total, **({"notes": notes} if notes else {}),
@@ -1586,12 +1670,11 @@ def main() -> int:
1586
1670
  cues if (args.text or args.transcribe) else (
1587
1671
  planned_cues if planned_cues is not None else parse_srt(srt_path)))
1588
1672
  if args.karaoke and not getattr(args, "_word_timings", None):
1589
- args._word_timings = whisper_word_timings(srt_path)
1673
+ # a copy that only rewrote the timings keeps the caller's word-timing JSON beside it
1674
+ args._word_timings = whisper_word_timings(args.srt if normalised_only else srt_path)
1590
1675
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
1591
1676
  claim([(ass_path, "ASS")])
1592
- w, h = meta["video"]["width"], meta["video"]["height"]
1593
- if meta["video"].get("rotation") in (90, -90, 270, -270):
1594
- w, h = h, w
1677
+ w, h = display_size(meta["video"])
1595
1678
  if not STATE.dry_run: # the generated ASS is an artifact of this run: a plan writes nothing
1596
1679
  write_ass(cues_for_ass, ass_path, args, w, h, video=args.input if meta.get("audio") else None)
1597
1680
  info(f"wrote {ass_path} ({len(cues_for_ass)} cues, animate={args.animate}, karaoke={args.karaoke})")
@@ -1681,9 +1764,12 @@ def main() -> int:
1681
1764
  vf = drawn_chain(vf, meta, [args.color, args.outline_color]
1682
1765
  + ([args.highlight_color, args.upcoming_color] if args.karaoke else []))
1683
1766
 
1767
+ if square_pixels((meta or {}).get("video")):
1768
+ vf = square_pixels(meta["video"]) + "," + vf
1769
+ side_notes.append(square_pixels_note(meta["video"]))
1684
1770
  cmd = ffmpeg_base() + ["-i", args.input]
1685
1771
  chains, emoji_inputs = (emoji_filter_chain(emoji_plan or {}, "vsub", "vout", to_yuv=rgb_chain_to_bt709(meta),
1686
- base_chain=label_chain(meta, meta) if rgb_chain_to_bt709(meta) else "")
1772
+ base_chain=layer_base_chain(meta))
1687
1773
  if emoji_plan else ([], []))
1688
1774
  if chains:
1689
1775
  for spec in emoji_inputs:
package/scripts/color.py CHANGED
@@ -43,7 +43,7 @@ CORRECTION = {
43
43
  "exposure": (0.0, -3.0, 3.0, "stops"), # exposure filter's own full range (linear-domain stops)
44
44
  "contrast": (1.0, 0.0, 2.0, "x"), # eq filter; 0=flat grey, 1=unchanged, 2=double contrast
45
45
  "saturation": (1.0, 0.0, 2.0, "x"), # eq filter; 0=grayscale, 1=unchanged, 2=double saturation
46
- "temperature": (6500.0, 2000.0, 12000.0, "K"), # colortemperature filter; 6500=unchanged (its own default)
46
+ "temperature": (6500.0, 2000.0, 12000.0, "K"), # colortemperature filter; 6500=unchanged (no term: the filter itself tints at 6500)
47
47
  "tint": (0.0, -1.0, 1.0, "x"), # mapped to colorbalance midtones, see correction_chain()
48
48
  "gamma": (1.0, 0.1, 10.0, "x"), # eq filter's own gamma option; 1=unchanged (its own default)
49
49
  "lift": (0.0, -1.0, 1.0, "x"), # colorbalance shadows (rs=gs=bs); 0=unchanged
@@ -116,11 +116,13 @@ def correction_chain(args: argparse.Namespace) -> str:
116
116
  die(f"--levels-out-black {out_black} must be less than --levels-out-white {out_white}")
117
117
 
118
118
  gm, rm, bm = -tint, tint / 2.0, tint / 2.0
119
- rgb = [
120
- f"exposure=exposure={exposure:g}",
121
- f"colortemperature=temperature={temperature:g}",
122
- f"colorbalance=rs={lift:g}:gs={lift:g}:bs={lift:g}:rm={rm:g}:gm={gm:g}:bm={bm:g}:rh={gain:g}:gh={gain:g}:bh={gain:g}",
123
- ]
119
+ rgb = [f"exposure=exposure={exposure:g}"]
120
+ if temperature != CORRECTION["temperature"][0]:
121
+ # colortemperature at 6500 K is not the identity it is documented as here: it multiplies by
122
+ # its own approximation of that white point, which tinted grey 808080 to 7E7E7B (#410), so
123
+ # "unchanged" adds no term at all
124
+ rgb.append(f"colortemperature=temperature={temperature:g}")
125
+ rgb.append(f"colorbalance=rs={lift:g}:gs={lift:g}:bs={lift:g}:rm={rm:g}:gm={gm:g}:bm={bm:g}:rh={gain:g}:gh={gain:g}:bh={gain:g}")
124
126
  terms = [_rgb_stage(rgb), f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}"]
125
127
  rgb2 = []
126
128
  if (in_black, in_white, out_black, out_white) != (0, 255, 0, 255):
@@ -156,6 +158,31 @@ def _rgb_stage(filters: list) -> str:
156
158
  + ",scale=out_color_matrix=bt601,format=yuv420p")
157
159
 
158
160
 
161
+ # swscale's names for the non-BT.709 matrices a --codec prores re-encode keeps (it does not convert)
162
+ _SWS_MATRIX = {"smpte170m": "bt601", "bt470bg": "bt601", "smpte240m": "smpte240m", "fcc": "fcc"}
163
+
164
+
165
+ def lut_legs(meta=None) -> "tuple":
166
+ """(to RGB, back to YUV) around --lut's lut3d: explicit BT.709 conversions through float RGB,
167
+ the matrix the picture holds when it reaches them (an untagged or BT.709 source as it is, a
168
+ BT.601 or RGB one after x264_args(source=...)'s conversion), limited range on the way out.
169
+
170
+ lut3d takes RGB, and left to libavfilter the way in honoured the frame's BT.709 tag while the
171
+ way back used swscale's BT.601 default, so even an identity LUT shifted every colour (2FAE4E to
172
+ 249A4B, #410); --correct had its legs pinned for the same reason in #159. A LUT maps the RGB the
173
+ picture really shows, so unlike --correct's working-space pair (bt601 both ways, see
174
+ _rgb_stage) these legs use the BT.709 the picture is: an identity LUT then returns every sample
175
+ as --lut-strength 0, which applies none, does (6.1, BT.709/BT.601/untagged, limited and full
176
+ range, 8- and 10-bit). The input range is read from the frames, as every scale here reads it."""
177
+ # --codec prores keeps a BT.601 (or other non-BT.709) source's matrix and tags instead of
178
+ # converting it (decision.video_args), so the legs use that matrix (#416 review)
179
+ matrix = "bt709"
180
+ if STATE.codec == "prores":
181
+ matrix = _SWS_MATRIX.get(((meta or {}).get("video") or {}).get("color_space"), "bt709")
182
+ return (f"scale=in_color_matrix={matrix},format=gbrpf32le",
183
+ f"scale=out_color_matrix={matrix}:out_range=tv,format=yuv420p")
184
+
185
+
159
186
  def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
160
187
  v = meta["video"]
161
188
  trc = v.get("color_transfer") or "smpte2084"
@@ -442,6 +469,7 @@ def main() -> int:
442
469
  if not (0.0 <= args.lut_strength <= 1.0):
443
470
  die(f"--lut-strength {args.lut_strength:g} is outside 0..1")
444
471
  lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"
472
+ to_rgb, to_yuv = lut_legs(meta)
445
473
  if args.lut_strength == 0.0:
446
474
  # 0 means "no LUT at all" -- without this branch, 0 (falling outside the open interval
447
475
  # below) landed in the same "apply the LUT at full strength" fallback as an out-of-range
@@ -450,9 +478,10 @@ def main() -> int:
450
478
  vf = "format=yuv420p"
451
479
  elif args.lut_strength < 1.0:
452
480
  # blend graded and original
453
- vf = f"split[o][g];[g]{lut}[g2];[o][g2]blend=all_mode=normal:all_opacity={args.lut_strength:g},format=yuv420p"
481
+ # blended in RGB, both pictures through the same two legs
482
+ vf = f"{to_rgb},split[o][g];[g]{lut}[g2];[o][g2]blend=all_mode=normal:all_opacity={args.lut_strength:g},{to_yuv}"
454
483
  else:
455
- vf = f"{lut},format=yuv420p"
484
+ vf = f"{to_rgb},{lut},{to_yuv}"
456
485
  output = args.output or default_output(args.input, "lut")
457
486
  tag = "lut"
458
487
 
package/scripts/crop.py CHANGED
@@ -22,6 +22,8 @@ import argparse
22
22
  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
+ from _common.probe import display_size, square_pixels, square_pixels_note
26
+ from _common.emit import note
25
27
 
26
28
 
27
29
  def main() -> int:
@@ -51,15 +53,18 @@ def main() -> int:
51
53
  meta = probe(args.input)
52
54
  if not meta.get("video"):
53
55
  die("input has no video stream")
54
- sw, sh = meta["video"]["width"], meta["video"]["height"]
55
- if meta["video"].get("rotation") in (90, -90, 270, -270):
56
- sw, sh = sh, sw
56
+ # the rectangle is in the displayed frame cropdetect.py reports: rotation applied, and a
57
+ # non-square-pixel source squared at its display aspect first (#411)
58
+ sw, sh = display_size(meta["video"])
57
59
  if args.x + args.width > sw or args.y + args.height > sh:
58
60
  die(f"crop rectangle ({args.x},{args.y},{args.width}x{args.height}) exceeds the source frame ({sw}x{sh})")
59
61
  has_audio = bool(meta.get("audio"))
60
62
 
61
63
  output = args.output or default_output(args.input, "crop")
62
64
  vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}", "setsar=1"]
65
+ if square_pixels(meta["video"]):
66
+ vf.insert(0, square_pixels(meta["video"]))
67
+ note(square_pixels_note(meta["video"]))
63
68
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", ",".join(vf)]
64
69
  cmd += video_args(meta, args.crf, args.preset)
65
70
  cmd += cfr_args(meta, args.fps)
@@ -34,7 +34,8 @@ from collections import Counter
34
34
  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
- from _common.emit import warn
37
+ from _common.emit import note, warn
38
+ from _common.probe import display_size, square_pixels, square_pixels_note
38
39
 
39
40
  MOTION_CENTRE_FPS = 2.0
40
41
  MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
@@ -43,10 +44,11 @@ CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
43
44
 
44
45
 
45
46
  def detect(path: str, seconds: float, samples: int, limit: float, round_to: int, duration: float,
46
- failed: "Optional[List[Dict]]" = None) -> List[Tuple[int, int, int, int]]:
47
+ failed: "Optional[List[Dict]]" = None, square: str = "") -> List[Tuple[int, int, int, int]]:
47
48
  """Crop rectangles from `samples` windows spread over the file. A window ffmpeg cannot decode
48
49
  is skipped, and appended to `failed` as {start, seconds, error} when the caller passes a list,
49
- so the result can say how many of the readings it stands on."""
50
+ so the result can say how many of the readings it stands on. `square` is the source's
51
+ square_pixels() scale, run first so the rectangle is in the frame crop.py crops (#411)."""
50
52
  ffmpeg = require_tool("ffmpeg")
51
53
  per_window = max(0.5, seconds / max(1, samples))
52
54
  rects: List[Tuple[int, int, int, int]] = []
@@ -55,7 +57,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
55
57
  start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
56
58
  start = max(0.0, start)
57
59
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
58
- "-vf", f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
60
+ "-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
59
61
  proc = run_analysis(cmd, check=False)
60
62
  if proc.returncode != 0:
61
63
  # One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
@@ -144,16 +146,18 @@ def main() -> int:
144
146
  meta = probe(args.input)
145
147
  if not meta.get("video"):
146
148
  die("input has no video stream")
147
- sw, sh = meta["video"]["width"], meta["video"]["height"]
148
149
  # cropdetect and the grey decode both see the frame after ffmpeg applies rotation, so the
149
150
  # rectangle and the motion points are in that frame. Reporting the stored size made a
150
- # 176x320 crop look taller than a 320x180 source.
151
- if meta["video"].get("rotation") in (90, -90, 270, -270):
152
- sw, sh = sh, sw
151
+ # 176x320 crop look taller than a 320x180 source. A non-square-pixel source is measured
152
+ # squared at its display aspect, the frame crop.py then crops (#411).
153
+ sw, sh = display_size(meta["video"])
154
+ if square_pixels_note(meta["video"]):
155
+ note(square_pixels_note(meta["video"]))
153
156
  duration = meta.get("duration") or 0.0
154
157
 
155
158
  failed: List[Dict] = []
156
- rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration, failed)
159
+ rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration, failed,
160
+ square_pixels(meta["video"]))
157
161
  # windows_failed: a window ffmpeg could not decode was skipped without a word, so a
158
162
  # crop measured on one window of five read as well-founded as one measured on all five
159
163
  result: Dict = {"file": args.input, "source_width": sw, "source_height": sh,