ffmpeg-skill 3.8.12 → 3.8.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/SKILL.md +1 -1
- package/docs/contract.md +11 -5
- package/mcp/server.py +0 -1
- package/package.json +5 -2
- package/references/flags.md +2 -2
- package/references/scripts.md +72 -27
- package/scripts/_common/asr.py +1 -1
- package/scripts/_common/color.py +7 -2
- package/scripts/_common/decision.py +40 -8
- package/scripts/_common/emit.py +38 -10
- package/scripts/_common/probe.py +34 -6
- package/scripts/_common/runner.py +76 -7
- package/scripts/_contract.py +24 -10
- package/scripts/_platforms.py +0 -5
- package/scripts/_smartcut.py +23 -5
- package/scripts/audio.py +86 -28
- package/scripts/background.py +1 -1
- package/scripts/caption.py +2 -3
- package/scripts/check.py +3 -3
- package/scripts/color.py +7 -1
- package/scripts/crop.py +3 -2
- package/scripts/cropdetect.py +1 -1
- package/scripts/cut.py +50 -17
- package/scripts/export.py +39 -6
- package/scripts/fit.py +4 -3
- package/scripts/freeze.py +1 -1
- package/scripts/graphics.py +2 -3
- package/scripts/insert.py +1 -1
- package/scripts/look.py +9 -4
- package/scripts/loop.py +3 -3
- package/scripts/loudness.py +20 -12
- package/scripts/multicam.py +56 -15
- package/scripts/overlay.py +1 -1
- package/scripts/probe.py +13 -2
- package/scripts/proxy.py +11 -2
- package/scripts/redact.py +5 -1
- package/scripts/scenes.py +82 -21
- package/scripts/silence.py +62 -16
- package/scripts/sphere.py +23 -3
- package/scripts/stabilize.py +5 -2
- package/scripts/sync.py +11 -6
- package/scripts/verify.py +82 -20
- package/scripts/waveform.py +12 -2
package/scripts/caption.py
CHANGED
|
@@ -37,17 +37,16 @@ import json
|
|
|
37
37
|
import os
|
|
38
38
|
import re
|
|
39
39
|
import sys
|
|
40
|
-
import unicodedata
|
|
41
40
|
from pathlib import Path
|
|
42
41
|
from typing import Any, Dict, List, Optional, Tuple
|
|
43
42
|
|
|
44
43
|
from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
|
|
45
44
|
resolve as resolve_platform)
|
|
46
|
-
from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder,
|
|
45
|
+
from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape_markup,
|
|
47
46
|
strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
|
|
48
47
|
from _common.emit import refuse # noqa: E402
|
|
49
48
|
from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
|
|
50
|
-
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets,
|
|
49
|
+
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, NO_SPACE_SCRIPTS, 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, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
|
|
51
50
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
52
51
|
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
53
52
|
# shares them). They stay caption.py's public names -- every caller and test that reached for
|
package/scripts/check.py
CHANGED
|
@@ -50,10 +50,10 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
50
50
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, upload_limit_note, resolve as resolve_platform
|
|
51
51
|
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,
|
|
52
52
|
detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
|
|
53
|
-
from _common.emit import file_identity
|
|
53
|
+
from _common.emit import file_identity, own_length
|
|
54
54
|
from _common.probe import PROBED_AUDIO
|
|
55
55
|
from _common.probe import display_size as shown_size, shorter_than_a_loudness_block
|
|
56
|
-
from _common import STATE, add_common, apply_common, die, emit,
|
|
56
|
+
from _common import STATE, add_common, apply_common, die, emit, probe, require_tool, run_analysis, dry_run_input_pending
|
|
57
57
|
|
|
58
58
|
# The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
|
|
59
59
|
# render.py templates all read it, so a platform's loudness spec is stated once. Only the
|
|
@@ -618,7 +618,7 @@ def main() -> int:
|
|
|
618
618
|
row("true peak", "WARN", "could not measure", f"<= {spec['tp']:g} dBTP", "re-run check.py, or verify true peak manually",
|
|
619
619
|
reason="ffmpeg's loudness measurement didn't produce a readable result -- this was not actually checked")
|
|
620
620
|
elif (lm and lm["lufs"] == float("-inf") and lm["tp"] > SILENT_PEAK_DBFS
|
|
621
|
-
and shorter_than_a_loudness_block(
|
|
621
|
+
and shorter_than_a_loudness_block(own_length(a) or dur_read)):
|
|
622
622
|
# A257: shorter than one 400 ms gating block: a true peak but no integrated
|
|
623
623
|
# loudness. Not silence, and not a -inf LUFS that FAILs the target. Decided on
|
|
624
624
|
# the sound's length: a longer track whose every block is under the gate reads
|
package/scripts/color.py
CHANGED
|
@@ -581,7 +581,13 @@ def main() -> int:
|
|
|
581
581
|
else:
|
|
582
582
|
vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
|
|
583
583
|
sdr_path = "tonemap"
|
|
584
|
-
|
|
584
|
+
# the note names what hdr_to_sdr_chain read the source as: its own tags, PQ / BT.2020
|
|
585
|
+
# only where a tag is missing. Through 3.8.8 any non-HDR file was called "untagged
|
|
586
|
+
# input as PQ", a BT.709-tagged one included, which the chain linearised as BT.709 (A161)
|
|
587
|
+
read_as = v.get("hdr_format") or (
|
|
588
|
+
f"a non-HDR input read as transfer {v.get('color_transfer') or 'smpte2084 (untagged, taken as PQ)'}, "
|
|
589
|
+
f"primaries {v.get('color_primaries') or 'bt2020 (untagged)'} (--force)")
|
|
590
|
+
sdr_note = f"tone-mapped {read_as} to SDR BT.709 with {args.tonemap}"
|
|
585
591
|
info(f"note: {sdr_note}")
|
|
586
592
|
sdr_extra = {"sdr_path": sdr_path, "notes": [sdr_note]}
|
|
587
593
|
output = args.output or default_output(args.input, "sdr")
|
package/scripts/crop.py
CHANGED
|
@@ -12,7 +12,8 @@ The rectangle must lie entirely inside the source frame after accounting for
|
|
|
12
12
|
any display rotation, and --width/--height must be even (required for 4:2:0
|
|
13
13
|
chroma subsampling, the pixel format every encoder here uses) -- given values
|
|
14
14
|
are validated and refused, never silently rounded, since a caller-specified
|
|
15
|
-
rectangle should do exactly what was asked or fail loudly.
|
|
15
|
+
rectangle should do exactly what was asked or fail loudly. An odd --x/--y is
|
|
16
|
+
taken as given (the crop starts on that pixel), not moved to the chroma grid.
|
|
16
17
|
|
|
17
18
|
Examples:
|
|
18
19
|
python3 crop.py input.mp4 --x 100 --y 0 --width 1080 --height 1920
|
|
@@ -63,7 +64,7 @@ def main() -> int:
|
|
|
63
64
|
|
|
64
65
|
output = args.output or default_output(args.input, "crop")
|
|
65
66
|
# exact=1: without it crop rounds an odd --x/--y down to the chroma grid of a 4:2:0 frame,
|
|
66
|
-
# and the rectangle written was one pixel left of / above the one asked for (A170's sibling)
|
|
67
|
+
# and the rectangle written was one pixel left of / above the one asked for (A170's sibling, A157)
|
|
67
68
|
vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}:exact=1", "setsar=1"]
|
|
68
69
|
if square_pixels(meta["video"]):
|
|
69
70
|
vf.insert(0, square_pixels(meta["video"]))
|
package/scripts/cropdetect.py
CHANGED
|
@@ -33,7 +33,7 @@ import sys
|
|
|
33
33
|
from collections import Counter
|
|
34
34
|
from typing import Dict, List, Optional, Tuple
|
|
35
35
|
|
|
36
|
-
from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames
|
|
36
|
+
from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames
|
|
37
37
|
from _common.emit import note, warn
|
|
38
38
|
from _common.probe import PROBED_VIDEO, display_size, square_pixels, square_pixels_note
|
|
39
39
|
|
package/scripts/cut.py
CHANGED
|
@@ -56,7 +56,7 @@ import os
|
|
|
56
56
|
import sys
|
|
57
57
|
import tempfile
|
|
58
58
|
import zlib
|
|
59
|
-
from typing import List, Tuple
|
|
59
|
+
from typing import List, Optional, Tuple
|
|
60
60
|
|
|
61
61
|
from _common.runner import ffmpeg_failure_extra, JsonTooDeep, fail_if_classified, read_json
|
|
62
62
|
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
@@ -65,7 +65,7 @@ from _common.probe import PROBED_AUDIO, PROBED_VIDEO, TS_EXTS, probed_maps, seve
|
|
|
65
65
|
from _common.runner import subtitle_codec_for, subtitle_container_holds
|
|
66
66
|
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
|
|
67
67
|
from _common.timeline import output_timecode_args
|
|
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,
|
|
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, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
|
|
69
69
|
|
|
70
70
|
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
71
71
|
DROPPED_STREAMS: List[str] = []
|
|
@@ -81,12 +81,27 @@ def _t(value: str, fps, flag: str = "--segments") -> float:
|
|
|
81
81
|
return time_arg(value, flag, fps)
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float, fps=None
|
|
84
|
+
def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float, fps=None,
|
|
85
|
+
picture: Optional[Tuple[float, float]] = None,
|
|
86
|
+
dropped: Optional[List[Tuple[float, float]]] = None) -> List[Tuple[float, float]]:
|
|
85
87
|
"""--remove: what is left of [0, total] once `removed` is dropped (merged where they overlap,
|
|
86
88
|
clamped to the file). A leftover shorter than one frame (10 ms without a frame rate) is not a
|
|
87
|
-
piece anyone asked to keep.
|
|
89
|
+
piece anyone asked to keep. With `picture` (where the video starts and ends, for a video
|
|
90
|
+
output), neither is a leftover that holds less than one frame of it: sound before the first
|
|
91
|
+
frame or after the last (a track that outlasts its picture) has no picture to cut, and was
|
|
92
|
+
kept and then refused as a segment the caller never gave (#462 A51). Such leftovers are added
|
|
93
|
+
to `dropped`. Refuses a removal that would leave nothing."""
|
|
88
94
|
least = 1.0 / fps if fps else 0.01
|
|
89
95
|
keep, cursor = [], 0.0
|
|
96
|
+
|
|
97
|
+
def add(a: float, b: float) -> None:
|
|
98
|
+
if b - a < least:
|
|
99
|
+
return
|
|
100
|
+
if picture is not None and min(b, picture[1]) - max(a, picture[0]) < least - 1e-6:
|
|
101
|
+
if dropped is not None:
|
|
102
|
+
dropped.append((a, b))
|
|
103
|
+
return
|
|
104
|
+
keep.append((a, b))
|
|
90
105
|
outside = [(s, e) for s, e in removed if min(total, e) <= min(total, max(0.0, s))]
|
|
91
106
|
if outside:
|
|
92
107
|
die("--remove " + ", ".join(f"{s:g}-{e:g}" for s, e in outside) + f" lies outside the file (0-{total:.3f}s), "
|
|
@@ -94,12 +109,14 @@ def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float,
|
|
|
94
109
|
for s, e in sorted((min(total, max(0.0, s)), min(total, e)) for s, e in removed):
|
|
95
110
|
if e <= cursor:
|
|
96
111
|
continue
|
|
97
|
-
|
|
98
|
-
keep.append((cursor, s))
|
|
112
|
+
add(cursor, s)
|
|
99
113
|
cursor = max(cursor, e)
|
|
100
|
-
|
|
101
|
-
keep.append((cursor, total))
|
|
114
|
+
add(cursor, total)
|
|
102
115
|
if not keep:
|
|
116
|
+
if dropped:
|
|
117
|
+
die(f"--remove leaves only sound with no picture ({', '.join(f'{a:.3f}-{b:.3f}s' for a, b in dropped)}, "
|
|
118
|
+
f"outside the video's {picture[0]:.3f}-{picture[1]:.3f}s); nothing with a picture would be left to keep. "
|
|
119
|
+
"Write the sound out with an audio -o (e.g. -o part.wav) instead", kind="input")
|
|
103
120
|
die(f"--remove drops the whole file ({total:.3f}s); nothing would be left to keep")
|
|
104
121
|
return keep
|
|
105
122
|
|
|
@@ -277,11 +294,12 @@ def source_skew(src: str, meta: dict, t: float) -> float:
|
|
|
277
294
|
|
|
278
295
|
def video_end(src: str, meta: dict):
|
|
279
296
|
"""Where the video stream ends on the cut's clock (seconds from the file's start), or None when
|
|
280
|
-
the stream's length is unknown.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
297
|
+
the stream's length is unknown. A Matroska/WebM stream states no duration, only its DURATION
|
|
298
|
+
tag, which is already that end (own_end()); read from `duration` alone, a .mkv had no picture
|
|
299
|
+
end and --remove kept a sound-only leftover past it."""
|
|
300
|
+
from _common.emit import own_end
|
|
301
|
+
end = own_end(meta.get("video"))
|
|
302
|
+
return None if end is None else end - file_origin(src)
|
|
285
303
|
|
|
286
304
|
|
|
287
305
|
_SEEK_SHIFTS: dict = {}
|
|
@@ -893,9 +911,12 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
893
911
|
if is_audio_output(dst) or not meta.get("video"):
|
|
894
912
|
# -ss before -i seeks, then decoding discards samples up to the exact start
|
|
895
913
|
# (accurate_seek); atrim bounds the decoded stream to the requested length at sample
|
|
896
|
-
# resolution.
|
|
914
|
+
# resolution. The input seek counts from the file's start, so a sound that starts after
|
|
915
|
+
# it (late against its picture) arrives at its offset from `start`; it is padded with
|
|
916
|
+
# silence from 0, as the --segments re-cut pads it (av_trim), not rebased to its own
|
|
917
|
+
# first sample, which moved it earlier and made the cut shorter than asked (#462 A46)
|
|
897
918
|
cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"] + stream_maps(meta, dst, copy=False)
|
|
898
|
-
cmd += ["-af", f"atrim=end={dur:.6f},
|
|
919
|
+
cmd += ["-af", f"atrim=end={dur:.6f},aresample=async=1:first_pts=0"]
|
|
899
920
|
else:
|
|
900
921
|
# Seek SEEK_MARGIN early, then drop the margin on the output side, which decodes and
|
|
901
922
|
# discards it from both streams alike. An input seek straight to `start` can land (the
|
|
@@ -1515,6 +1536,7 @@ def main() -> int:
|
|
|
1515
1536
|
forced.append("codec")
|
|
1516
1537
|
|
|
1517
1538
|
fps = (meta.get("video") or {}).get("fps")
|
|
1539
|
+
remove_notes: List[str] = []
|
|
1518
1540
|
if args.remove:
|
|
1519
1541
|
if args.segments or args.end or args.duration or args.start is not None:
|
|
1520
1542
|
die("--remove names what to drop from the whole file: it does not combine with --segments, --start, --end or --duration")
|
|
@@ -1525,7 +1547,18 @@ def main() -> int:
|
|
|
1525
1547
|
if not total:
|
|
1526
1548
|
die(f"--remove needs the input's duration, which {args.input} does not report"
|
|
1527
1549
|
+ (" yet (it is an earlier step's output)" if dry_run_input_pending(args.input) else ""))
|
|
1528
|
-
|
|
1550
|
+
picture = None
|
|
1551
|
+
if (meta.get("video") or {}).get("fps") and not is_audio_output(args.output or "") and not dry_run_input_pending(args.input):
|
|
1552
|
+
vend = video_end(args.input, meta)
|
|
1553
|
+
if vend is not None:
|
|
1554
|
+
picture = ((meta["video"].get("start_time") or 0.0) - file_origin(args.input), vend)
|
|
1555
|
+
sound_only: List[Tuple[float, float]] = []
|
|
1556
|
+
args.segments = ",".join(f"{s:.6f}-{e:.6f}" for s, e in keep_ranges_after_removing(
|
|
1557
|
+
parse_segments(args.remove, fps, clamp=True), total, fps, picture, sound_only))
|
|
1558
|
+
for a, b in sound_only:
|
|
1559
|
+
remove_notes.append(f"--remove left {a:.3f}-{b:.3f}s, which holds sound but no frame of the picture "
|
|
1560
|
+
f"(the video runs {picture[0]:.3f}-{picture[1]:.3f}s); it is not kept")
|
|
1561
|
+
info(remove_notes[-1])
|
|
1529
1562
|
elif args.segments and (args.end or args.duration or args.start is not None):
|
|
1530
1563
|
# A50: the single-range flags were accepted beside --segments and not read, where --remove
|
|
1531
1564
|
# refuses the same combination
|
|
@@ -1588,7 +1621,7 @@ def main() -> int:
|
|
|
1588
1621
|
# clean copy). clip_length's rule, as join.py uses it: less than a frame past -> end the
|
|
1589
1622
|
# segment with its picture; more -> hold the last frame for the sound, which only a re-cut
|
|
1590
1623
|
# can do. The last segment is left as the source had it: nothing is placed after it.
|
|
1591
|
-
join_notes: List[str] =
|
|
1624
|
+
join_notes: List[str] = list(remove_notes)
|
|
1592
1625
|
hold_needed = False
|
|
1593
1626
|
vend = (video_end(args.input, meta) if len(segments) > 1 and video.get("fps") and not is_audio_output(output)
|
|
1594
1627
|
and not dry_run_input_pending(args.input) else None)
|
package/scripts/export.py
CHANGED
|
@@ -43,6 +43,7 @@ import re
|
|
|
43
43
|
import os
|
|
44
44
|
import sys
|
|
45
45
|
import tempfile
|
|
46
|
+
from fractions import Fraction
|
|
46
47
|
from pathlib import Path
|
|
47
48
|
from typing import Dict, List, Optional
|
|
48
49
|
|
|
@@ -51,8 +52,8 @@ from _common import STATE, run_analysis, add_common, apply_common, bt709_label_f
|
|
|
51
52
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
52
53
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
53
54
|
from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels, square_pixels_note
|
|
54
|
-
from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling,
|
|
55
|
-
from _common.emit import file_identity, input_length
|
|
55
|
+
from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, subtitle_codec_for
|
|
56
|
+
from _common.emit import file_identity, input_length, own_length
|
|
56
57
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
57
58
|
from _common.probe import SILENT_PEAK_DBFS, shorter_than_a_loudness_block
|
|
58
59
|
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, UPLOAD_LIMITS_AS_OF, resolve as resolve_platform
|
|
@@ -254,7 +255,7 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
|
|
|
254
255
|
pair = {k: [fv.get(k), kv.get(k)] for k in ("width", "height", "nb_frames", "r_frame_rate")}
|
|
255
256
|
# the video streams' lengths: the fill's container also counts its sound, which may run longer
|
|
256
257
|
# than the picture, and the key has none
|
|
257
|
-
pair["duration"] = [round(
|
|
258
|
+
pair["duration"] = [round(own_length(v) or 0, 3) for v in (fv, kv)]
|
|
258
259
|
same = all(a == b and a not in (None, 0) for a, b in pair.values())
|
|
259
260
|
# the two written files against each other: no expected value, so no provenance (#403: it said
|
|
260
261
|
# "output_measurement", which is not one of the contract's values)
|
|
@@ -442,6 +443,32 @@ SIZE_MIN_VIDEO_KBPS = 100 # below this the picture is not worth delivering; re
|
|
|
442
443
|
SIZE_MAX_VIDEO_KBPS = 400_000 # 400 Mb/s: above any delivery line, and libx264 refuses far larger values
|
|
443
444
|
|
|
444
445
|
|
|
446
|
+
def _platform_rate_args(preset: str, meta: Dict) -> List[str]:
|
|
447
|
+
"""A platform preset that keeps the source's frame rate (the youtube family) conforms a source
|
|
448
|
+
faster than the platform's fps_max to the source rate divided by the smallest whole number that
|
|
449
|
+
brings it within the limit (120 -> 60, 119.88 -> 59.94, 100 -> 50), so every frame of the
|
|
450
|
+
output is one of the source's own. Through 3.8.8 a 120 fps source kept 120 fps and the
|
|
451
|
+
platform's own check FAILed the export (audit A115). [] when the preset has no limit or the
|
|
452
|
+
source is within it (cfr_args() then decides, as before)."""
|
|
453
|
+
platform = PLATFORM_OF.get(preset)
|
|
454
|
+
fps_max = (PLATFORMS.get(platform) or {}).get("fps_max") if platform else None
|
|
455
|
+
v = meta.get("video") or {}
|
|
456
|
+
src = v.get("fps")
|
|
457
|
+
if not fps_max or not src or src <= fps_max + 0.01:
|
|
458
|
+
return []
|
|
459
|
+
try:
|
|
460
|
+
exact = Fraction(v.get("avg_frame_rate") or "0")
|
|
461
|
+
except (ValueError, ZeroDivisionError):
|
|
462
|
+
exact = Fraction(0)
|
|
463
|
+
if not exact or abs(float(exact) - src) > 0.01:
|
|
464
|
+
exact = Fraction(src).limit_denominator(1001)
|
|
465
|
+
n = math.ceil(float(exact) / (fps_max + 0.01))
|
|
466
|
+
rate = exact / n
|
|
467
|
+
note(f"source is {src:g} fps, above {platform}'s limit of {fps_max:g}: exported at {float(rate):.6g} fps, "
|
|
468
|
+
f"one source frame in {n}")
|
|
469
|
+
return ["-fps_mode", "cfr", "-r", str(rate.numerator) if rate.denominator == 1 else f"{rate.numerator}/{rate.denominator}"]
|
|
470
|
+
|
|
471
|
+
|
|
445
472
|
def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool) -> List[str]:
|
|
446
473
|
"""--max-mb: the preset's x264 line with its CRF replaced by the bitrate that fits the size,
|
|
447
474
|
for a two-pass encode. Refused where that cannot be done honestly: another codec, --crf, --hw,
|
|
@@ -669,7 +696,7 @@ def main() -> int:
|
|
|
669
696
|
# a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
|
|
670
697
|
# no longer be a copy, and would silently mislabel colour the agent never actually looked at
|
|
671
698
|
if "-r" not in video:
|
|
672
|
-
cmd += cfr_args(meta)
|
|
699
|
+
cmd += _platform_rate_args(args.preset, meta) or cfr_args(meta)
|
|
673
700
|
if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
|
|
674
701
|
cmd += bt709_tag_args(video[video.index("-c:v") + 1])
|
|
675
702
|
if not p.get("intermediate"):
|
|
@@ -741,7 +768,7 @@ def main() -> int:
|
|
|
741
768
|
m = measure_loudness(output)
|
|
742
769
|
written_audio = probe(output, role="output").get("audio") or {}
|
|
743
770
|
if (m and not math.isfinite(m["lufs"]) and math.isfinite(m["tp"]) and m["tp"] > SILENT_PEAK_DBFS
|
|
744
|
-
and shorter_than_a_loudness_block(
|
|
771
|
+
and shorter_than_a_loudness_block(own_length(written_audio))):
|
|
745
772
|
# A257: too short for a gating block is not silent (it has a measured peak); a longer
|
|
746
773
|
# track under the gate is not "too short" and takes the path it took before
|
|
747
774
|
_loudness_unmeasured(extra, notes, platform, spec, tp=m["tp"])
|
|
@@ -767,7 +794,13 @@ def main() -> int:
|
|
|
767
794
|
# a private name: <stem>_loudnorm.<ext> is loudness.py's own default output, so a
|
|
768
795
|
# real file of that name next to the export was overwritten and renamed away (review 7)
|
|
769
796
|
tmp = str(Path(output).with_name(f".{Path(output).stem}.normalize-{os.getpid()}{Path(output).suffix}"))
|
|
770
|
-
|
|
797
|
+
# under --max-mb the sound is re-encoded at the bitrate the size budget was built on
|
|
798
|
+
# (the preset's); loudness.py's own 192k, raised to 256k/320k on an overshoot, put
|
|
799
|
+
# the file over the limit it had been encoded to meet (through 3.8.8, audit A112)
|
|
800
|
+
pinned = (["--audio-bitrate", p["audio"][p["audio"].index("-b:a") + 1]]
|
|
801
|
+
if args.max_mb is not None and "-b:a" in p["audio"] else [])
|
|
802
|
+
proc = run_tool([str(HERE / "loudness.py"), output, "-I", f"{spec['lufs']:g}", "--tp", f"{spec['tp']:g}", "-o", tmp, "--json"]
|
|
803
|
+
+ pinned + child_args())
|
|
771
804
|
try:
|
|
772
805
|
child = json.loads(proc.stdout)
|
|
773
806
|
except ValueError:
|
package/scripts/fit.py
CHANGED
|
@@ -34,14 +34,13 @@ Examples:
|
|
|
34
34
|
python3 fit.py input.mp4 --flip h # mirror horizontally
|
|
35
35
|
"""
|
|
36
36
|
import argparse
|
|
37
|
-
import math
|
|
38
37
|
import sys
|
|
39
38
|
from fractions import Fraction
|
|
40
39
|
from typing import List
|
|
41
40
|
|
|
42
41
|
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, length_output_t, stream_starts
|
|
43
42
|
from _common.timeline import exact_rate, output_timecode_args
|
|
44
|
-
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info,
|
|
43
|
+
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, probe, run, run_keeping_subtitles, validate_color, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
|
|
45
44
|
from _common.emit import input_length
|
|
46
45
|
BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
|
|
47
46
|
|
|
@@ -227,7 +226,9 @@ def main() -> int:
|
|
|
227
226
|
# after it (a B-frame HEVC source) and lose the frames before it, so the first
|
|
228
227
|
# picture would not be the one the start timecode below names
|
|
229
228
|
m = min(SEEK_MARGIN, round(start, 3))
|
|
230
|
-
|
|
229
|
+
# a trim from the start seeks nowhere: no -ss, so the timeline stays where it is and
|
|
230
|
+
# a subtitle/data track is kept below (`-ss 0.000` dropped it, A158)
|
|
231
|
+
pre_input += ["-ss", f"{start - m:.3f}"] if round(start, 3) > 0 else []
|
|
231
232
|
# without sound the -t only counts frames (picture_output_t). Not from the centre:
|
|
232
233
|
# there the output's clock starts at the seek, between two frames, and the encoder
|
|
233
234
|
# rounds each frame to its own tick
|
package/scripts/freeze.py
CHANGED
|
@@ -20,7 +20,7 @@ import argparse
|
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
22
|
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
|
|
23
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS,
|
|
23
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, time_arg, fmt_secs, run
|
|
24
24
|
from _common.emit import input_length
|
|
25
25
|
|
|
26
26
|
|
package/scripts/graphics.py
CHANGED
|
@@ -38,11 +38,11 @@ from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
|
|
|
38
38
|
from _common.drawtext import shaping_refusal
|
|
39
39
|
from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
40
40
|
color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
|
|
41
|
-
ffmpeg_base, info, layer_base_chain, load_brand, overlay_format,
|
|
41
|
+
ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, probe, rgb_chain_to_bt709, run_keeping_subtitles, video_args,
|
|
42
42
|
drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
|
|
43
43
|
LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
|
|
44
44
|
emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
|
|
45
|
-
EMOJI_ASSET_HINT, text_width_em,
|
|
45
|
+
EMOJI_ASSET_HINT, text_width_em, wrap_text, WRAP_MODES,
|
|
46
46
|
SAFE_WIDTH_FRACTION, drawtext_draws_newline_glyph, is_multiline, _DRAWTEXT_PENDING)
|
|
47
47
|
from _common.emit import note, warn
|
|
48
48
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
@@ -429,7 +429,6 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
429
429
|
el["text"] = text
|
|
430
430
|
elements.append(el)
|
|
431
431
|
|
|
432
|
-
extra_inputs: List[str] = []
|
|
433
432
|
fc: List[str] = [] # filter_complex chains (used by templates that need animated boxes)
|
|
434
433
|
if 0 < min(W, H) < 64: # 0x0 is a dry-run probe of an intermediate that does not exist yet
|
|
435
434
|
die(f"the frame is {W}x{H}; the templates are sized from it and need at least 64 px on the short side")
|
package/scripts/insert.py
CHANGED
|
@@ -28,7 +28,7 @@ import math
|
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
30
|
from _common import still_input_args # noqa: E402
|
|
31
|
-
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info,
|
|
31
|
+
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
32
32
|
from _common.emit import note
|
|
33
33
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
34
34
|
|
package/scripts/look.py
CHANGED
|
@@ -19,7 +19,7 @@ from typing import List, Optional
|
|
|
19
19
|
|
|
20
20
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
21
21
|
from _common import still_input_args # noqa: E402
|
|
22
|
-
from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info,
|
|
22
|
+
from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info, probe, run, run_analysis, time_arg
|
|
23
23
|
from _common import drawtext_fontfile # noqa: E402
|
|
24
24
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
25
25
|
from _common.emit import note, record_fallback # noqa: E402
|
|
@@ -30,6 +30,8 @@ from _common.emit import input_length # noqa: E402
|
|
|
30
30
|
# --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
|
|
31
31
|
# Action and title safe are SMPTE ST 2046-1's 93 % and 90 % of a 16:9 picture; the 4:3 centre cut
|
|
32
32
|
# is the 4:3 window of a 16:9 frame with SD's own 90 % action / 80 % title safe inside it.
|
|
33
|
+
# the extensions an -o is taken verbatim with (one picture); any other -o is a stem
|
|
34
|
+
IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp")
|
|
33
35
|
GUIDES = {"action": 0.93, "title": 0.90}
|
|
34
36
|
GUIDE_43 = {"action": 0.90, "title": 0.80}
|
|
35
37
|
GUIDE_COLOURS = {"action": "yellow", "title": "cyan", "4:3": "white"}
|
|
@@ -319,7 +321,7 @@ def main() -> int:
|
|
|
319
321
|
csq = (square_pixels(cmeta.get("video")) + ",") if square_pixels(cmeta.get("video")) else ""
|
|
320
322
|
for idx, t in enumerate(args.at):
|
|
321
323
|
sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
|
|
322
|
-
if args.output and len(args.at) == 1:
|
|
324
|
+
if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in IMAGE_EXTS:
|
|
323
325
|
out = args.output # one frame, one named image file: the caller's -o is the contract
|
|
324
326
|
else:
|
|
325
327
|
# several frames, or -o given as a stem/prefix: an occurrence index keeps every
|
|
@@ -352,7 +354,7 @@ def main() -> int:
|
|
|
352
354
|
sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
|
|
353
355
|
if dur and sec > dur:
|
|
354
356
|
die(f"--at {t} is beyond the duration ({dur:.2f}s)")
|
|
355
|
-
if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in
|
|
357
|
+
if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in IMAGE_EXTS:
|
|
356
358
|
out = args.output # one frame, one named image file: the caller's -o is the contract
|
|
357
359
|
else:
|
|
358
360
|
# several frames, or -o given as a stem/prefix without an image extension
|
|
@@ -382,7 +384,10 @@ def main() -> int:
|
|
|
382
384
|
info(f"only {frames_avail} frame(s) available; sheet reduced to {cols}x{rows}")
|
|
383
385
|
step = dur / n
|
|
384
386
|
tile_w = max(2, (args.width // cols) // 2 * 2)
|
|
385
|
-
|
|
387
|
+
# -o without an image extension is a stem here too (<stem>_sheet.png), as for --at and
|
|
388
|
+
# --compare: given verbatim, ffmpeg could not choose a muxer for it (kind ffmpeg, A159)
|
|
389
|
+
out = (args.output if args.output and Path(args.output).suffix.lower() in IMAGE_EXTS
|
|
390
|
+
else os.path.join(outdir, f"{(args.output and Path(args.output).stem) or stem}_sheet.png"))
|
|
386
391
|
# sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
|
|
387
392
|
# with only a handful of frames a mid-slice seek skips past them all: start at 0 instead
|
|
388
393
|
seek = 0.0 if frames_avail < 4 else step / 2
|
package/scripts/loop.py
CHANGED
|
@@ -32,11 +32,11 @@ import os
|
|
|
32
32
|
import sys
|
|
33
33
|
import tempfile
|
|
34
34
|
|
|
35
|
-
from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info,
|
|
35
|
+
from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
36
36
|
from _common.emit import warn
|
|
37
37
|
from _common.decision import HELD_FRAMES_WARN_BYTES, av_trim, held_frames_bytes, length_output_t, stream_starts
|
|
38
38
|
from _common.probe import decode_gray_frames, decode_pcm_mono
|
|
39
|
-
from _common.emit import input_length
|
|
39
|
+
from _common.emit import input_length, own_length
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
# The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
|
|
@@ -131,7 +131,7 @@ def _conformed_frames(v, rate: float, src_dur: float) -> int:
|
|
|
131
131
|
its length are counted in these, or a repeat is a frame or more off the length planned."""
|
|
132
132
|
# the video stream's length, else Matroska's tag, else the container's (main() refuses a source
|
|
133
133
|
# without one): never the stored frame count, which is what fps= does not keep
|
|
134
|
-
d =
|
|
134
|
+
d = own_length(v) or src_dur
|
|
135
135
|
return int(round(float(d) * rate))
|
|
136
136
|
|
|
137
137
|
|
package/scripts/loudness.py
CHANGED
|
@@ -24,8 +24,9 @@ from _platforms import TP_TOLERANCE_DB
|
|
|
24
24
|
AUDIO_LENGTH_TOLERANCE = 0.03 # seconds: one AAC frame at 48 kHz is 0.021 s
|
|
25
25
|
LUFS_TOLERANCE = 1.0 # LU: a normalised file within this of the requested -I is on target
|
|
26
26
|
from _common.probe import PROBED_AUDIO, probed_maps, shorter_than_a_loudness_block
|
|
27
|
-
from _common.emit import refuse
|
|
27
|
+
from _common.emit import own_length, refuse
|
|
28
28
|
from _common.runner import ffmpeg_failure_extra
|
|
29
|
+
from _common.decision import LOSSLESS_AUDIO_EXT
|
|
29
30
|
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
|
|
30
31
|
|
|
31
32
|
|
|
@@ -113,15 +114,15 @@ def loudnorm_mode(stats: dict, lufs: float, tp: float, lra: float) -> str:
|
|
|
113
114
|
|
|
114
115
|
def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output: str) -> dict:
|
|
115
116
|
"""One normalised file at `lufs` / `tp_max`, measured after: the keys emit() takes for it."""
|
|
116
|
-
af = (
|
|
117
|
-
f"loudnorm=I={lufs}:TP={tp_max}:LRA={args.lra}"
|
|
118
|
-
f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
|
|
119
|
-
f":measured_thresh={stats['input_thresh']}:offset={stats['target_offset']}:linear=true:print_format=summary"
|
|
120
|
-
)
|
|
121
117
|
sr = args.sample_rate or meta["audio"].get("sample_rate") or 48000
|
|
122
118
|
ext = os.path.splitext(output)[1].lower()
|
|
123
119
|
bitrate_pinned = args.audio_bitrate is not None
|
|
124
|
-
|
|
120
|
+
# an AAC audio extension starts at its own rate (256k for .m4a/.aac), which is what it was
|
|
121
|
+
# written at: a pinned --audio-bitrate and the raised retries below never reached it, so the
|
|
122
|
+
# retries re-encoded at 256k and audio_bitrate_used said 192k, then 256k and 320k (A143)
|
|
123
|
+
codec = AUDIO_CODECS.get(ext, [])
|
|
124
|
+
own_rate = codec[codec.index("-b:a") + 1] if "aac" in codec and "-b:a" in codec else None
|
|
125
|
+
bitrate = args.audio_bitrate or own_rate or "192k"
|
|
125
126
|
|
|
126
127
|
dropped_streams = False
|
|
127
128
|
# loudnorm emits its last block whole, and through the AAC encoder that came out as 0.1 s more
|
|
@@ -142,7 +143,9 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
|
|
|
142
143
|
af += f",atrim=duration={float(in_audio):.6f}"
|
|
143
144
|
cmd = ffmpeg_base() + ["-i", args.input, "-af", af, "-ar", str(sr)]
|
|
144
145
|
if ext in AUDIO_CODECS or not meta.get("video"):
|
|
145
|
-
|
|
146
|
+
# the rate reaches an audio extension's codec when it was pinned or is AAC's (an
|
|
147
|
+
# .mp3/.ogg/.opus left alone keeps its own setting, as it always has)
|
|
148
|
+
cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn"] + audio_codec_for(output, bitrate if ext not in AUDIO_CODECS or bitrate_pinned or own_rate else None)
|
|
146
149
|
else:
|
|
147
150
|
cmd += probed_maps(meta) + ["-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
|
|
148
151
|
if ext in (".mp4", ".mov", ".m4v"):
|
|
@@ -185,7 +188,8 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
|
|
|
185
188
|
info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {lufs} LUFS, TP <= {tp_max:g})")
|
|
186
189
|
result = {k: after[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")}
|
|
187
190
|
result["tp_ceiling_used"] = round(ceiling, 2)
|
|
188
|
-
if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext]:
|
|
191
|
+
if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext] or bitrate_pinned:
|
|
192
|
+
# a pinned rate reaches an .mp3/.ogg/.opus codec too (A143); unpinned, those keep their own
|
|
189
193
|
result["audio_bitrate_used"] = bitrate
|
|
190
194
|
result["encodes"] = rounds + 1
|
|
191
195
|
# the mode of the last encode: its ceiling is the one the file was made with
|
|
@@ -277,7 +281,8 @@ def main() -> int:
|
|
|
277
281
|
ap.add_argument("--tp", type=float, default=-1.0, help="true peak ceiling in dBTP (default -1)")
|
|
278
282
|
ap.add_argument("--lra", type=float, default=11.0, help="loudness range target in LU (default 11)")
|
|
279
283
|
ap.add_argument("--measure-only", action="store_true", help="print the measured stats as JSON and exit")
|
|
280
|
-
ap.add_argument("--audio-bitrate", default=None, help="
|
|
284
|
+
ap.add_argument("--audio-bitrate", default=None, help="audio bitrate: AAC's in a video container (default 192k), and the codec's of a lossy audio output (.m4a/.aac default 256k, .mp3/.ogg/.opus their own setting); "
|
|
285
|
+
"refused for .wav/.flac. Raised to 256k/320k for AAC only when the encoder overshoots the true-peak ceiling and you did not pin it")
|
|
281
286
|
ap.add_argument("--versions", metavar="LIST",
|
|
282
287
|
help="write one normalised file per target from this master, each measured after: a comma list of "
|
|
283
288
|
+ ", ".join(f"{n} ({i:g} LUFS / {t:g} dBTP)" for n, (i, t) in VERSION_TARGETS.items())
|
|
@@ -303,6 +308,10 @@ def main() -> int:
|
|
|
303
308
|
meta = probe(args.input)
|
|
304
309
|
if not meta.get("audio"):
|
|
305
310
|
die("input has no audio stream")
|
|
311
|
+
out_ext = os.path.splitext(args.output or default_output(args.input, "loudnorm"))[1].lower()
|
|
312
|
+
if args.audio_bitrate is not None and not args.measure_only and out_ext in LOSSLESS_AUDIO_EXT:
|
|
313
|
+
# A143: the rate was dropped without a word; a lossless codec has none to set
|
|
314
|
+
die(f"--audio-bitrate has no effect on a lossless {out_ext} output; drop it, or write a lossy one (.m4a, .mp3, ...)")
|
|
306
315
|
if plan is not None and not args.measure_only:
|
|
307
316
|
# each version measures the master against its own target (loudnorm's offset depends on
|
|
308
317
|
# it): a pass against -I/--tp first would be one more full decode whose result is unused
|
|
@@ -349,8 +358,7 @@ def audio_length(meta: dict) -> "Optional[float]":
|
|
|
349
358
|
"""The sound's own length: the stream's duration, else Matroska's DURATION tag. Never the
|
|
350
359
|
container's, which is the picture's when the picture runs longer (and would fail a correct
|
|
351
360
|
output against an expected length the sound never had)."""
|
|
352
|
-
|
|
353
|
-
return audio.get("duration") or audio.get("tagged_duration")
|
|
361
|
+
return own_length(meta.get("audio"))
|
|
354
362
|
|
|
355
363
|
|
|
356
364
|
def _kbps(value: str) -> int:
|