ffmpeg-skill 3.6.4 → 3.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/scripts/_common/asr.py +78 -39
- package/scripts/_common/color.py +4 -3
- package/scripts/_common/decision.py +21 -5
- package/scripts/_common/probe.py +38 -8
- package/scripts/_common/runner.py +73 -3
- package/scripts/_contract.py +2 -0
- package/scripts/audio.py +22 -2
- package/scripts/batch.py +40 -6
- package/scripts/caption.py +31 -17
- package/scripts/check.py +5 -1
- package/scripts/crop.py +3 -1
- package/scripts/cropdetect.py +2 -2
- package/scripts/cut.py +40 -9
- package/scripts/export.py +13 -8
- package/scripts/join.py +5 -5
- package/scripts/look.py +4 -4
- package/scripts/loudness.py +8 -3
- package/scripts/metadata.py +1 -1
- package/scripts/render.py +35 -5
- package/scripts/report.py +4 -2
- package/scripts/scenes.py +2 -2
- package/scripts/silence.py +12 -7
- package/scripts/stabilize.py +5 -2
- package/scripts/sync.py +7 -3
package/scripts/batch.py
CHANGED
|
@@ -33,7 +33,7 @@ from pathlib import Path
|
|
|
33
33
|
from typing import Any, Dict, List, Optional
|
|
34
34
|
|
|
35
35
|
from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, MEDIA_EXT as _MEDIA_EXT
|
|
36
|
-
from _common.runner import (HW_FORCED_ENV, OWNED_ENV, _OutputLock, add_hw_orchestrator_args, copy_to_sibling, env_hw,
|
|
36
|
+
from _common.runner import (HW_FORCED_ENV, OWNED_ENV, _OutputLock, add_hw_orchestrator_args, copy_to_sibling, env_hw, read_json,
|
|
37
37
|
owned_identity, stop_children)
|
|
38
38
|
from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
|
|
39
39
|
from _common.decision import rebase_project_paths
|
|
@@ -235,6 +235,39 @@ def intermediate_ext(src: Path) -> str:
|
|
|
235
235
|
return src.suffix.lstrip(".") if src.suffix.lower() in (".wav", ".mp3", ".m4a", ".flac") else "mp4"
|
|
236
236
|
|
|
237
237
|
|
|
238
|
+
# the recipe keys the run reads as a string: a path, a glob, a file-name part
|
|
239
|
+
RECIPE_STRINGS = ("glob", "project", "output_dir", "suffix", "ext")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def check_recipe_types(recipe: Dict[str, Any]) -> None:
|
|
243
|
+
"""Refuse a recipe value of the wrong JSON type before anything runs (A18): a number where a
|
|
244
|
+
path or glob goes, an absolute glob (pathlib refuses it), a step that is not a list. Each was
|
|
245
|
+
a traceback, or a failure of every item naming the step's first character."""
|
|
246
|
+
for key in RECIPE_STRINGS:
|
|
247
|
+
value = recipe.get(key)
|
|
248
|
+
if value is not None and not isinstance(value, str):
|
|
249
|
+
die(f'recipe "{key}" must be a string, got {type(value).__name__} {value!r:.60}', kind="input")
|
|
250
|
+
glob = recipe.get("glob")
|
|
251
|
+
if glob and (os.path.isabs(glob) or glob.startswith(("/", "\\"))):
|
|
252
|
+
die(f'recipe "glob" {glob!r} is an absolute path: it matches names inside the batch folder, e.g. "*.mp4" '
|
|
253
|
+
'or "sub/*.mov"', kind="input")
|
|
254
|
+
key = recipe.get("clip_key")
|
|
255
|
+
try:
|
|
256
|
+
index = int(key) if key is not None and not isinstance(key, (bool, float)) else (0 if key is None else -1)
|
|
257
|
+
except (TypeError, ValueError):
|
|
258
|
+
index = -1
|
|
259
|
+
if index < 0:
|
|
260
|
+
die(f'recipe "clip_key" must be a clip index (0, 1, ...), got {key!r:.60}', kind="input")
|
|
261
|
+
steps = recipe.get("steps")
|
|
262
|
+
if steps is not None and not isinstance(steps, list):
|
|
263
|
+
die(f'recipe "steps" must be a list of steps, each ["script.py", args...], got {type(steps).__name__}',
|
|
264
|
+
kind="input")
|
|
265
|
+
for i, st in enumerate(steps or []):
|
|
266
|
+
if not isinstance(st, list) or not st:
|
|
267
|
+
die(f'recipe step {i} must be a list ["script.py", args...], got {type(st).__name__} {st!r:.60}',
|
|
268
|
+
kind="input")
|
|
269
|
+
|
|
270
|
+
|
|
238
271
|
def step_writes(step: Any) -> bool:
|
|
239
272
|
"""Whether a recipe step writes a file: it names {out} (check.py and probe.py do not)."""
|
|
240
273
|
return isinstance(step, list) and any("{out}" in str(a) for a in step[1:])
|
|
@@ -382,14 +415,14 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
|
|
|
382
415
|
return max(1.0, deadline - time.monotonic())
|
|
383
416
|
if recipe.get("project"):
|
|
384
417
|
try:
|
|
385
|
-
proj =
|
|
418
|
+
proj = read_json(str(recipe["project"])) # too deep is a ValueError too (A20)
|
|
386
419
|
except (OSError, ValueError) as e:
|
|
387
420
|
raise ItemError({"step": None, "tool": "render.py", "kind": "input", "reason": "recipe_project_unreadable",
|
|
388
421
|
"message": f"recipe.project: {recipe['project']} could not be read as JSON: {e}"})
|
|
389
422
|
if not isinstance(proj, dict):
|
|
390
423
|
raise ItemError({"step": None, "tool": "render.py", "kind": "input", "reason": "recipe_project_unreadable",
|
|
391
424
|
"message": f"recipe.project: {recipe['project']} is not a JSON object"})
|
|
392
|
-
idx = int(recipe.get("clip_key"
|
|
425
|
+
idx = int(recipe.get("clip_key") or 0) # null is clip 0, as check_recipe_types() reads it
|
|
393
426
|
proj.setdefault("clips", [{}])
|
|
394
427
|
while len(proj["clips"]) <= idx:
|
|
395
428
|
proj["clips"].append({})
|
|
@@ -539,11 +572,12 @@ def main() -> int:
|
|
|
539
572
|
if not folder.is_dir():
|
|
540
573
|
die(f"not a folder: {folder}")
|
|
541
574
|
try:
|
|
542
|
-
recipe =
|
|
575
|
+
recipe = read_json(args.recipe) # too deep is a ValueError too (A20)
|
|
543
576
|
except (OSError, ValueError) as exc:
|
|
544
|
-
die(f"cannot read recipe: {exc}")
|
|
577
|
+
die(f"cannot read recipe: {exc}", kind="input")
|
|
545
578
|
if not isinstance(recipe, dict):
|
|
546
|
-
die("cannot read recipe: not a JSON object")
|
|
579
|
+
die("cannot read recipe: not a JSON object", kind="input")
|
|
580
|
+
check_recipe_types(recipe)
|
|
547
581
|
if recipe.get("project") and not os.path.isabs(recipe["project"]):
|
|
548
582
|
recipe["project"] = str((Path(args.recipe).resolve().parent / recipe["project"]))
|
|
549
583
|
# a recipe error is the same for every item: refuse it once, before any item runs, rather than
|
package/scripts/caption.py
CHANGED
|
@@ -59,6 +59,8 @@ from _common.runner import subtitle_codec_for, subtitle_container_holds
|
|
|
59
59
|
from _common.fonts import font_choice, record_font_choice
|
|
60
60
|
from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
|
|
61
61
|
write_srt)
|
|
62
|
+
from _common.asr import srt_blocks
|
|
63
|
+
from _common.runner import normalize_text
|
|
62
64
|
from _common import (SAFE_WIDTH_FRACTION, ORPHAN_MIN_EM, WRAP_MODES, wrap_text, wrap_variants, best_break,
|
|
63
65
|
fit_size, line_em_for_size, MIN_CAPTION_FRACTION,
|
|
64
66
|
break_penalty, _is_weak_line, _atoms, _join, _break_spaced, _bare_word, _function_words,
|
|
@@ -83,10 +85,21 @@ ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3
|
|
|
83
85
|
FORCE_STYLE_ALIGN = {"bottom": 2, "top": 6, "center": 10, "bottom-left": 1, "bottom-right": 3, "top-left": 5, "top-right": 7}
|
|
84
86
|
|
|
85
87
|
TIME_RE = re.compile(
|
|
86
|
-
r"^\s*(?P<a>[\d:.,@]+)\s*(
|
|
88
|
+
r"^\s*(?P<a>[\d:.,@]+)\s*(?P<sep>-->|-|–|to)\s*(?P<b>[\d:.,@]+)\s+(?P<text>.+)$" # @ = the 1.9 @fps suffix
|
|
87
89
|
)
|
|
88
90
|
|
|
89
91
|
|
|
92
|
+
def cue_times_match(line: str) -> "Optional[re.Match[str]]":
|
|
93
|
+
"""TIME_RE's match of a --text line, or None when the line is caption text. The documented
|
|
94
|
+
separators are "-" and "-->", and with them bare seconds are times ("0-1.5 Hello"). "to" is
|
|
95
|
+
also an English word: "3 to 5 apples" is text, so with "to" both sides must be clock times
|
|
96
|
+
(a ':' in each, "0:03 to 0:05 Hello")."""
|
|
97
|
+
m = TIME_RE.match(line)
|
|
98
|
+
if m and m.group("sep") == "to" and not (":" in m.group("a") and ":" in m.group("b")):
|
|
99
|
+
return None
|
|
100
|
+
return m
|
|
101
|
+
|
|
102
|
+
|
|
90
103
|
# an SRT timing line: the times alone, the cue text on the lines after it
|
|
91
104
|
DROP_FRAME_RE = re.compile(r"^\s*\d+:\d{2}:\d{2};\d+")
|
|
92
105
|
SRT_TIMING_RE = re.compile(r"^\s*\d+:\d{2}:\d{2}[,.]\d+\s*-->\s*\d+:\d{2}:\d{2}[,.]\d+\s*$")
|
|
@@ -95,9 +108,11 @@ SRT_TIMING_RE = re.compile(r"^\s*\d+:\d{2}:\d{2}[,.]\d+\s*-->\s*\d+:\d{2}:\d{2}[
|
|
|
95
108
|
def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
|
|
96
109
|
cues: List[Tuple[float, float, str]] = []
|
|
97
110
|
cursor = 0.0
|
|
98
|
-
|
|
111
|
+
# split at "\n" only: splitlines() also breaks a cue at U+2028, U+0085, \v and \f, which
|
|
112
|
+
# made the rest of the line an auto-timed cue of its own
|
|
113
|
+
lines = read_text_or_die(path, "--text").split("\n")
|
|
99
114
|
for n, raw in enumerate(lines, 1):
|
|
100
|
-
if SRT_TIMING_RE.match(raw
|
|
115
|
+
if SRT_TIMING_RE.match(raw):
|
|
101
116
|
# a cue file puts the text on the line with its times; this line has the times alone, so
|
|
102
117
|
# the file is an SRT. Read as a cue file, its numbers and timing lines would be burned in
|
|
103
118
|
# as captions, auto-timed 3 s apart (#396)
|
|
@@ -107,11 +122,10 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[fl
|
|
|
107
122
|
# TIME_RE has no ';', so the line would be burned as caption text, auto-timed
|
|
108
123
|
die(f"--text: line {n} has a drop-frame timecode (';'), which is not read: write it as "
|
|
109
124
|
"hh:mm:ss:ff@29.97 or in seconds", kind="input")
|
|
110
|
-
for
|
|
111
|
-
line = raw.rstrip("\n")
|
|
125
|
+
for line in lines:
|
|
112
126
|
if not line.strip():
|
|
113
127
|
continue
|
|
114
|
-
m =
|
|
128
|
+
m = cue_times_match(line)
|
|
115
129
|
if m:
|
|
116
130
|
try:
|
|
117
131
|
start, end = parse_time(m.group("a"), fps), parse_time(m.group("b"), fps)
|
|
@@ -535,7 +549,7 @@ def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[
|
|
|
535
549
|
def parse_ass_dialogue(path: str) -> str:
|
|
536
550
|
"""The spoken text of an ASS file, for script detection -- style/override blocks stripped."""
|
|
537
551
|
text = []
|
|
538
|
-
for line in read_text_or_die(path, "--ass").
|
|
552
|
+
for line in read_text_or_die(path, "--ass").split("\n"):
|
|
539
553
|
if not line.startswith("Dialogue:"):
|
|
540
554
|
continue
|
|
541
555
|
fields = line.split(",", 9)
|
|
@@ -557,7 +571,7 @@ def parse_ass_cues(path: str, stats: Optional[dict] = None) -> List[Tuple[float,
|
|
|
557
571
|
# the [Events] Format line names the columns; Text is always last (libass reads it the same
|
|
558
572
|
# way), and a file without one gets the standard ten
|
|
559
573
|
names = ["layer", "start", "end", "style", "name", "marginl", "marginr", "marginv", "effect", "text"]
|
|
560
|
-
for line in read_text_or_die(path, "--ass").
|
|
574
|
+
for line in read_text_or_die(path, "--ass").split("\n"):
|
|
561
575
|
if line.startswith("Format:") and "text" in line.lower():
|
|
562
576
|
names = [n.strip().lower() for n in line.partition(":")[2].split(",")]
|
|
563
577
|
continue
|
|
@@ -601,7 +615,8 @@ def count_visible_cues(cues, duration) -> Tuple[int, int]:
|
|
|
601
615
|
def shift_ass_file(src: str, dst: str, offset: float) -> Tuple[int, int]:
|
|
602
616
|
"""Copy an ASS file with every Dialogue start/end moved by `offset` seconds; (lines shifted,
|
|
603
617
|
Dialogue lines copied unshifted because their timestamps could not be read)."""
|
|
604
|
-
|
|
618
|
+
# read as the dry run reads it: a file that is not UTF-8 is refused as kind input on both
|
|
619
|
+
text, n, unshifted = shift_ass_text(read_text_or_die(src, "--ass"), offset)
|
|
605
620
|
Path(dst).write_text(text, encoding="utf-8-sig")
|
|
606
621
|
return n, unshifted
|
|
607
622
|
|
|
@@ -621,7 +636,9 @@ def shift_ass_text(source: str, offset: float) -> Tuple[str, int, int]:
|
|
|
621
636
|
|
|
622
637
|
n = unshifted = 0
|
|
623
638
|
out = []
|
|
624
|
-
|
|
639
|
+
# lines end at "\n" only (libass reads them so); a U+2028 inside a Dialogue's text is text
|
|
640
|
+
body = normalize_text(source)
|
|
641
|
+
for line in (body[:-1] if body.endswith("\n") else body).split("\n"):
|
|
625
642
|
if line.startswith("Dialogue:"):
|
|
626
643
|
head, sep, rest = line.partition(":")
|
|
627
644
|
fields = rest.split(",")
|
|
@@ -929,12 +946,9 @@ def srt_timings_shared_grammar(path: str) -> bool:
|
|
|
929
946
|
"""True when every timing line of the SRT at `path` reads the same to ffmpeg's SRT demuxer as
|
|
930
947
|
to parse_srt(). ffmpeg refuses `00:01,000` (no hours) and reads `00:00:01,5` as 1.005 s, where
|
|
931
948
|
parse_srt() reads 1.5 s (#414); such a file is handed to ffmpeg as a rewritten copy."""
|
|
932
|
-
|
|
933
|
-
#
|
|
934
|
-
|
|
935
|
-
blocks = [[ln for ln in b.split("\n") if ln.strip()] for b in re.split(r"\n\s*\n", text)]
|
|
936
|
-
timings = [next((ln for ln in b if "-->" in ln), None) for b in blocks]
|
|
937
|
-
return all(_SHARED_SRT_TIMING.match(line) for line in timings if line is not None)
|
|
949
|
+
# the timing lines parse_srt() reads, grouped as it groups them (srt_blocks()): a later line
|
|
950
|
+
# that only holds "-->" is dialogue, not a timing this check should judge
|
|
951
|
+
return all(_SHARED_SRT_TIMING.match(times) for times, _body in srt_blocks(read_text_or_die(path, "--srt")))
|
|
938
952
|
|
|
939
953
|
|
|
940
954
|
def mux_subtitle_codec(output: str) -> str:
|
|
@@ -1461,7 +1475,7 @@ def main() -> int:
|
|
|
1461
1475
|
if args.ass and args.offset and os.path.exists(args.ass):
|
|
1462
1476
|
shifted = os.path.splitext(output)[0] + "_offset.ass"
|
|
1463
1477
|
if STATE.dry_run:
|
|
1464
|
-
_text, n, unshifted = shift_ass_text(read_text_or_die(args.ass, "--ass")
|
|
1478
|
+
_text, n, unshifted = shift_ass_text(read_text_or_die(args.ass, "--ass"), args.offset)
|
|
1465
1479
|
info(f"[dry-run] would write {shifted} ({n} cues shifted by {args.offset:+g} s)")
|
|
1466
1480
|
else:
|
|
1467
1481
|
n, unshifted = shift_ass_file(args.ass, shifted, args.offset)
|
package/scripts/check.py
CHANGED
|
@@ -47,6 +47,7 @@ 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
|
|
50
51
|
from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
|
|
51
52
|
|
|
52
53
|
# The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
|
|
@@ -61,7 +62,10 @@ def measure_loudness(path: str) -> Dict[str, float]:
|
|
|
61
62
|
if dry_run_input_pending(path):
|
|
62
63
|
return {}
|
|
63
64
|
ffmpeg = require_tool("ffmpeg")
|
|
64
|
-
|
|
65
|
+
# the first audio track, the one the audio row's probe facts describe (and the one export and
|
|
66
|
+
# loudness write): default selection measured the track with the most channels
|
|
67
|
+
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_AUDIO}", "-vn",
|
|
68
|
+
"-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], check=False, record=True)
|
|
65
69
|
m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
|
|
66
70
|
if not m:
|
|
67
71
|
return {}
|
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
|
-
|
|
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:
|
package/scripts/cropdetect.py
CHANGED
|
@@ -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
|
@@ -58,10 +58,11 @@ import tempfile
|
|
|
58
58
|
import zlib
|
|
59
59
|
from typing import List, Tuple
|
|
60
60
|
|
|
61
|
-
from _common.runner import fail_if_classified
|
|
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}"] +
|
|
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
|
-
+
|
|
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}:
|
|
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}:
|
|
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 "")
|
|
@@ -1199,11 +1226,15 @@ def _grid_from_source(path: str, min_confidence: float) -> "dict":
|
|
|
1199
1226
|
"""The beat grid of `path`: a scenes.py --json document if that is what it is, otherwise a
|
|
1200
1227
|
media file to measure. Reading a document is how a caller avoids a second decode."""
|
|
1201
1228
|
try:
|
|
1202
|
-
|
|
1203
|
-
|
|
1229
|
+
doc = read_json(path) # a media file is not UTF-8 or not JSON: a ValueError, then measured
|
|
1230
|
+
except JsonTooDeep as exc:
|
|
1231
|
+
die(f"--snap-source {path}: {exc}", kind="input")
|
|
1204
1232
|
except (OSError, ValueError):
|
|
1205
1233
|
doc = None
|
|
1206
1234
|
if isinstance(doc, dict) and doc.get("beat_grid"):
|
|
1235
|
+
if not isinstance(doc["beat_grid"], dict) or not isinstance(doc.get("beats") or [], list):
|
|
1236
|
+
die(f"--snap-source {path}: beat_grid must be an object and beats a list "
|
|
1237
|
+
"(regenerate it with `scenes.py MUSIC --beats --json`)", kind="input")
|
|
1207
1238
|
grid = dict(doc["beat_grid"])
|
|
1208
1239
|
grid["beats"] = doc.get("beats") or []
|
|
1209
1240
|
# A scenes.py document carries the supported subset since 1.17; one written by an older
|
package/scripts/export.py
CHANGED
|
@@ -50,7 +50,7 @@ from _common.runner import child_exit_code, fail_if_classified, kept_clause, mar
|
|
|
50
50
|
from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
51
51
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
52
52
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
53
|
-
from _common.probe import square_pixels, square_pixels_note
|
|
53
|
+
from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels, square_pixels_note
|
|
54
54
|
from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
|
|
55
55
|
from _common.emit import file_identity
|
|
56
56
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
@@ -204,8 +204,8 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
|
|
|
204
204
|
key_chain = ",".join(vf + ["alphaextract", "format=yuv422p10le"])
|
|
205
205
|
tc = ((meta.get("timecode") or {}).get("value"))
|
|
206
206
|
tc_args = ["-timecode", tc] if tc else []
|
|
207
|
-
cmd = cmd + ["-filter_complex", f"[0:
|
|
208
|
-
cmd += ["-map", "[fill]"] + (
|
|
207
|
+
cmd = cmd + ["-filter_complex", f"[0:{PROBED_VIDEO}]split[f][k];[f]{fill_chain}[fill];[k]{key_chain}[key]"]
|
|
208
|
+
cmd += ["-map", "[fill]"] + probed_maps(meta, video=False, audio=has_audio) + p["video"] + tc_args + cfr_args(meta)
|
|
209
209
|
# the fill is always written to a hidden sibling and moved into place only after the encode
|
|
210
210
|
# succeeds: run() stages and cleans up only ffmpeg's last output (the key), so a failed encode
|
|
211
211
|
# left a partial new fill (the retry was refused as output_exists) or truncated an existing one
|
|
@@ -410,7 +410,9 @@ def media_damage(path: str) -> Dict[str, int]:
|
|
|
410
410
|
PTS) and every other error line, counted. What --repair is measured by, before and after."""
|
|
411
411
|
# repeat+: every occurrence on its own line, not "Last message repeated N times", which hid
|
|
412
412
|
# N timestamp warnings and was itself counted as a decode error
|
|
413
|
-
|
|
413
|
+
# the streams a copy export writes (probed_maps), not the ones default selection would decode
|
|
414
|
+
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "repeat+warning", "-i", path,
|
|
415
|
+
"-map", f"0:{PROBED_VIDEO}?", "-map", f"0:{PROBED_AUDIO}?", "-f", "null", "-"],
|
|
414
416
|
check=False, record=True)
|
|
415
417
|
lines = [ln for ln in (proc.stderr or "").splitlines() if ln.strip()]
|
|
416
418
|
# "Packet corrupt (stream = 0, dts = ...)" names a dts but is damage, not a timestamp problem
|
|
@@ -585,16 +587,19 @@ def main() -> int:
|
|
|
585
587
|
# Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
|
|
586
588
|
# kept where the container can hold them: ffmpeg's default stream selection takes one video
|
|
587
589
|
# and one audio stream only, and through 2.22.1 every preset dropped the rest without a word.
|
|
588
|
-
#
|
|
590
|
+
# The picture and the sound are mapped on every export, the same streams probe() describes:
|
|
591
|
+
# until A118 they were mapped only beside a subtitle or data stream, and without one ffmpeg's
|
|
592
|
+
# default selection took the larger picture and the track with the default disposition or the
|
|
593
|
+
# most channels, so which audio track an export kept depended on whether it had subtitles.
|
|
589
594
|
subs = meta.get("subtitle_stream_details") or []
|
|
590
595
|
data = _data_streams(args.input) if meta.get("data_streams") else []
|
|
591
596
|
sub_codecs = [_subtitle_codec(s.get("codec") or "", out_ext) for s in subs]
|
|
592
|
-
if args.preset != "gif" and not p.get("keyfill")
|
|
593
|
-
cmd +=
|
|
597
|
+
if args.preset != "gif" and not p.get("keyfill"):
|
|
598
|
+
cmd += probed_maps(meta, audio=has_audio)
|
|
594
599
|
|
|
595
600
|
if args.preset == "gif":
|
|
596
601
|
chain = ",".join(([gif_label] if gif_label else []) + ["fps=12"] + vf)
|
|
597
|
-
fc = f"[0:
|
|
602
|
+
fc = f"[0:{PROBED_VIDEO}]{chain},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"
|
|
598
603
|
cmd += ["-filter_complex", fc, "-loop", "0", output]
|
|
599
604
|
run(cmd)
|
|
600
605
|
info(f"wrote {output}")
|
package/scripts/join.py
CHANGED
|
@@ -44,6 +44,7 @@ from fractions import Fraction
|
|
|
44
44
|
from typing import Any, Dict, List, Optional, Tuple
|
|
45
45
|
|
|
46
46
|
from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
|
|
47
|
+
from _common.runner import read_text_or_die
|
|
47
48
|
from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, place_output, refuse, sibling_temp, as_limited, draws_chroma, drawn_chain, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
48
49
|
from _common.emit import note, warn
|
|
49
50
|
from _common.decision import av_clip_length, av_trim, stream_starts
|
|
@@ -280,13 +281,12 @@ def read_list(path: str) -> List[str]:
|
|
|
280
281
|
"""--list FILE: one segment per line, in order, relative to the list file's own folder.
|
|
281
282
|
Blank lines and # comments are ignored, and ffmpeg's concat-demuxer spelling
|
|
282
283
|
(`file 'part 01.wav'`) is accepted, so a list written for `ffmpeg -f concat` works as is."""
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
die(f"cannot read --list {path}: {exc}")
|
|
284
|
+
# the shared reader: a BOM, CRLF and a file that is not UTF-8 are read or refused as every
|
|
285
|
+
# other text input is, and a line ends at "\n" only
|
|
286
|
+
text = read_text_or_die(path, "--list")
|
|
287
287
|
base = os.path.dirname(os.path.abspath(path))
|
|
288
288
|
entries: List[str] = []
|
|
289
|
-
for line in text.
|
|
289
|
+
for line in text.split("\n"):
|
|
290
290
|
line = line.strip()
|
|
291
291
|
if not line or line.startswith("#"):
|
|
292
292
|
continue
|
package/scripts/look.py
CHANGED
|
@@ -24,7 +24,7 @@ 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
|
|
26
26
|
from _common.color import bt709_label_filter, bt2020_sdr_to_bt709_chain, hdr_to_sdr_chain, is_bt2020_sdr # noqa: E402
|
|
27
|
-
from _common.probe import display_size, square_pixels, square_pixels_note # noqa: E402
|
|
27
|
+
from _common.probe import PROBED_VIDEO, display_size, square_pixels, square_pixels_note # noqa: E402
|
|
28
28
|
|
|
29
29
|
# --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
|
|
30
30
|
# Action and title safe are SMPTE ST 2046-1's 93 % and 90 % of a 16:9 picture; the 4:3 centre cut
|
|
@@ -337,7 +337,7 @@ def main() -> int:
|
|
|
337
337
|
conv = "," + conv if conv else ""
|
|
338
338
|
return (f"scale={half}:{box_h}:force_original_aspect_ratio=decrease{conv}{pre},"
|
|
339
339
|
f"pad={half}:{box_h}:(ow-iw)/2:(oh-ih)/2:color=0x202020,setsar=1{stamp}")
|
|
340
|
-
fc = (f"[0:
|
|
340
|
+
fc = (f"[0:{PROBED_VIDEO}]{lab}{sq}{fit(tm)}[a];[1:{PROBED_VIDEO}]{lab2}{csq}{fit(tm2)}[b];"
|
|
341
341
|
f"[a][b]hstack=inputs=2[out]")
|
|
342
342
|
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-ss", f"{sec:.3f}", *still_input_args(args.compare), "-i", args.compare,
|
|
343
343
|
"-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
|
|
@@ -354,7 +354,7 @@ def main() -> int:
|
|
|
354
354
|
# several frames, or -o given as a stem/prefix without an image extension
|
|
355
355
|
out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
|
|
356
356
|
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
357
|
-
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-vf", f"{lab}{sq}scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", "-update", "1", out]
|
|
357
|
+
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-map", f"0:{PROBED_VIDEO}", "-vf", f"{lab}{sq}scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", "-update", "1", out]
|
|
358
358
|
run(cmd)
|
|
359
359
|
outputs.append(out)
|
|
360
360
|
else:
|
|
@@ -384,7 +384,7 @@ def main() -> int:
|
|
|
384
384
|
tc_sheet = tc
|
|
385
385
|
vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',{lab}{sq}scale={tile_w}:-2{tc_sheet},"
|
|
386
386
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
387
|
-
cmd = ffmpeg_base() + ["-copyts", "-start_at_zero", "-ss", f"{seek:.6f}", *still_input_args(args.input), "-i", args.input, "-vf", vf, "-frames:v", "1", "-update", "1", out]
|
|
387
|
+
cmd = ffmpeg_base() + ["-copyts", "-start_at_zero", "-ss", f"{seek:.6f}", *still_input_args(args.input), "-i", args.input, "-map", f"0:{PROBED_VIDEO}", "-vf", vf, "-frames:v", "1", "-update", "1", out]
|
|
388
388
|
run(cmd)
|
|
389
389
|
outputs.append(out)
|
|
390
390
|
info(f"contact sheet: {n} frames every {step:.2f}s")
|
package/scripts/loudness.py
CHANGED
|
@@ -23,6 +23,7 @@ from _platforms import TP_TOLERANCE_DB
|
|
|
23
23
|
|
|
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
|
+
from _common.probe import PROBED_AUDIO, probed_maps
|
|
26
27
|
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
|
|
27
28
|
|
|
28
29
|
|
|
@@ -65,7 +66,11 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
|
|
|
65
66
|
if dry_run_input_pending(path):
|
|
66
67
|
return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0", "silent": False, "placeholder": True}
|
|
67
68
|
ffmpeg = require_tool("ffmpeg")
|
|
68
|
-
|
|
69
|
+
# the first audio track, the one pass 2 normalises and probe() describes: without a -map,
|
|
70
|
+
# FFmpeg measured the track with the default disposition or the most channels, and the gain
|
|
71
|
+
# for that track was applied to a:0 (A110)
|
|
72
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_AUDIO}", "-vn",
|
|
73
|
+
"-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
|
|
69
74
|
# Pass 1 is a measurement: it runs under --dry-run too, so the planned pass-2 command and
|
|
70
75
|
# the reported input_i are real (before 1.4.6 a dry run returned a made-up -20 LUFS).
|
|
71
76
|
proc = run_analysis(cmd, check=False, record=True)
|
|
@@ -112,9 +117,9 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
|
|
|
112
117
|
af += f",atrim=duration={float(in_audio):.6f}"
|
|
113
118
|
cmd = ffmpeg_base() + ["-i", args.input, "-af", af, "-ar", str(sr)]
|
|
114
119
|
if ext in AUDIO_CODECS or not meta.get("video"):
|
|
115
|
-
cmd += ["-vn"] + audio_codec_for(output, bitrate)
|
|
120
|
+
cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn"] + audio_codec_for(output, bitrate)
|
|
116
121
|
else:
|
|
117
|
-
cmd +=
|
|
122
|
+
cmd += probed_maps(meta) + ["-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
|
|
118
123
|
if ext in (".mp4", ".mov", ".m4v"):
|
|
119
124
|
cmd += ["-movflags", "+faststart"]
|
|
120
125
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
package/scripts/metadata.py
CHANGED
|
@@ -43,7 +43,7 @@ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
|
|
|
43
43
|
start inside the file, the last chapter running to the file's end."""
|
|
44
44
|
text = read_text_or_die(path, "--chapters")
|
|
45
45
|
entries: List[Dict[str, Any]] = []
|
|
46
|
-
for n, raw in enumerate(text.
|
|
46
|
+
for n, raw in enumerate(text.split("\n"), start=1): # not splitlines(): a title may hold U+2028
|
|
47
47
|
line = raw.strip()
|
|
48
48
|
if not line or line.startswith("#"):
|
|
49
49
|
continue
|