ffmpeg-skill 3.8.15 → 3.8.17

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.
@@ -29,13 +29,14 @@ A VP9 --video layer stored with an alpha layer (a WebM from export.py --preset v
29
29
  with libvpx-vp9, so its transparency reaches the composite (FFmpeg's own VP9 decoder drops it).
30
30
  """
31
31
  import argparse
32
+ import ast
32
33
  import re
33
34
  import sys
34
35
  from typing import Any, Dict, List, Optional
35
36
 
36
37
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
37
38
  from _common import still_input_args # noqa: E402
38
- from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, X264_PRESETS, time_arg, fmt_secs
39
+ from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, cfr_filter, cfr_at_input, with_cfr, default_output, die, escape_drawtext, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, X264_PRESETS, time_arg, fmt_secs
39
40
  from _common.emit import note, warn
40
41
  from _common.color import is_rgb
41
42
  from _common.decision import length_output_t, window_expr, windowed_overlay_x
@@ -86,6 +87,56 @@ def position_exprs(pos: str, margin: int, text_mode: bool, margins: Optional[dic
86
87
  return x, y
87
88
 
88
89
 
90
+ def _scaled_height(w: int, src_w: int, src_h: int, factor: int) -> int:
91
+ """The height scale=W:-1 (factor 1) or scale=W:-2 (factor 2) gives a src_w x src_h picture:
92
+ av_rescale(w, ih, iw * factor) * factor, rounded half away from zero as FFmpeg rounds it."""
93
+ den = src_w * factor
94
+ return max(factor, (2 * w * src_h + den) // (2 * den) * factor)
95
+
96
+
97
+ def on_grid(x: str, y: str, main: "tuple", layer: "tuple") -> "Optional[tuple]":
98
+ """Where the layer goes so its top-left pixel is at (x, y) in luma, x and y being the overlay
99
+ expressions position_exprs() gives, as overlay evaluates them (the integer part).
100
+
101
+ overlay places a layer on a 4:2:0 picture on the chroma grid: an odd x or y is taken down to
102
+ the even one before it (#512: --position 11,13 drew the logo at 10,12, and a centred or
103
+ right-aligned layer whose position came out odd moved the same way). So the layer gets one
104
+ transparent column/row on the left/top where x/y is odd, and is overlaid at the even position
105
+ before it: its luma lands where it was asked, and the chroma sample on that edge is shared
106
+ with the pixel outside it. Returns (x_even, y_even, pad filter or ""), or None when a size is
107
+ unknown (a dry run's pending input): the overlay then gets the expressions, as before."""
108
+ (mw, mh), (lw, lh) = main, layer
109
+ if not (mw and mh and lw and lh):
110
+ return None
111
+
112
+ def arith(node: Any) -> float:
113
+ # the four operations on whole numbers position_exprs() writes, and nothing else
114
+ if isinstance(node, ast.Expression):
115
+ return arith(node.body)
116
+ if isinstance(node, ast.Constant) and isinstance(node.value, int):
117
+ return float(node.value)
118
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
119
+ return -arith(node.operand) if isinstance(node.op, ast.USub) else arith(node.operand)
120
+ if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)):
121
+ a, b = arith(node.left), arith(node.right)
122
+ return {ast.Add: a + b, ast.Sub: a - b, ast.Mult: a * b}.get(type(node.op), a / b if b else 0.0)
123
+ raise ValueError(f"not a position: {ast.dump(node)}")
124
+
125
+ def value(expr: str, big: int, small: int, axis: str) -> Optional[int]:
126
+ num = re.sub(axis.upper(), str(big), re.sub(axis, str(small), expr))
127
+ try:
128
+ return int(arith(ast.parse(num, mode="eval"))) # the integer part, as overlay's (int) cast takes it
129
+ except (SyntaxError, ValueError):
130
+ return None
131
+
132
+ px, py = value(x, mw, lw, "w"), value(y, mh, lh, "h")
133
+ if px is None or py is None:
134
+ return None
135
+ ox, oy = px & 1, py & 1 # two's complement: -3 is one column right of -4
136
+ pad = f"pad=iw+{ox}:ih+{oy}:{ox}:{oy}:color=black@0" if ox or oy else ""
137
+ return px - ox, py - oy, pad
138
+
139
+
89
140
  def _wrap_to_frame(args, frame_w: int, margins: Optional[dict], frame_h: int = 0) -> str:
90
141
  """--text broken into lines that fit between the frame's side margins at --font-size, by the
91
142
  same phrase rules graphics.py and caption.py wrap with; text that fits comes back unchanged.
@@ -443,11 +494,22 @@ def main() -> int:
443
494
  cmd = ffmpeg_base() + ["-i", args.input]
444
495
 
445
496
  if args.image:
446
- refuse_not_a_still(args.image, probe(args.image), "--image",
497
+ image_meta = probe(args.image)
498
+ refuse_not_a_still(args.image, image_meta, "--image",
447
499
  "--video composites a moving layer; `look.py FILE --at T` writes one frame of it as a PNG")
448
500
  chain: List[str] = ["format=rgba"]
501
+ # a still that stores non-square pixels (a PNG's pHYs, a JPEG's density) is made square at
502
+ # its display aspect first, as a --video layer is: its size and position are computed in
503
+ # that frame, and placed from it at its stored size a 100x100 PNG at SAR 2:1 drew 100 px
504
+ # wide at x=120 of a 320 px main for top-right, 100 px short of the edge
505
+ if square_pixels(image_meta.get("video")):
506
+ chain.append(square_pixels(image_meta.get("video")))
507
+ note(square_pixels_note(image_meta.get("video"), "the --image"))
508
+ iw, ih = display_size(image_meta.get("video"))
509
+ lw = int(vw * args.scale_percent / 100) if args.scale_percent else args.scale or iw
510
+ lh = _scaled_height(lw, iw, ih, 1) if lw != iw and iw and ih else ih
449
511
  if args.scale_percent:
450
- chain.append(f"scale={int(vw * args.scale_percent / 100)}:-1")
512
+ chain.append(f"scale={lw}:-1")
451
513
  elif args.scale:
452
514
  chain.append(f"scale={args.scale}:-1")
453
515
  if args.opacity < 1:
@@ -461,6 +523,11 @@ def main() -> int:
461
523
  if end is not None and end > args.fade:
462
524
  chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
463
525
  x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
526
+ grid = on_grid(x, y, (vw, vh), (lw, lh))
527
+ if grid:
528
+ # padded in RGBA, where a one-pixel offset is exact, before the layer is made YUV
529
+ x, y = grid[0], grid[1]
530
+ chain += [grid[2]] if grid[2] else []
464
531
  # a 10-bit main on a 10-bit line is composited at 10 bits in YUV: format=auto with an RGBA
465
532
  # still took the whole picture through 8-bit RGB (overlay_format)
466
533
  # a --start/--end window is in the layer's position, which is evaluated on the main
@@ -510,8 +577,11 @@ def main() -> int:
510
577
  if main_dur and at >= main_dur:
511
578
  die(f"--place {a:g}-{b:g}@{at:g}: starts after the main video ends ({main_dur:.3f} s)")
512
579
  chain = [square_pixels(pip_meta["video"])] if square_pixels(pip_meta["video"]) else []
580
+ iw, ih = display_size(pip_meta["video"])
581
+ lw = int(vw * args.scale_percent / 100) if args.scale_percent else args.scale or iw
582
+ lh = _scaled_height(lw, iw, ih, 2) if (args.scale_percent or args.scale) and iw and ih else ih
513
583
  if args.scale_percent:
514
- chain.append(f"scale={int(vw * args.scale_percent / 100)}:-2")
584
+ chain.append(f"scale={lw}:-2")
515
585
  elif args.scale:
516
586
  chain.append(f"scale={args.scale}:-2")
517
587
  # an alpha line composites at 4:4:4: the layer is not taken through 4:2:0 on the way
@@ -544,6 +614,15 @@ def main() -> int:
544
614
  # which came back BT.601 under the BT.709 tag (#333's defect, on this path)
545
615
  chain.append(f"lut=y=val:u=val:v=val:a=val*{args.opacity:g}")
546
616
  x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
617
+ grid = on_grid(x, y, (vw, vh), (lw, lh))
618
+ if grid:
619
+ x, y = grid[0], grid[1]
620
+ if grid[2]:
621
+ # padded at 4:4:4, where a one-pixel offset is exact; overlay takes the layer back
622
+ # to the main's 4:2:0 on its own grid, as the layer is now aligned with it
623
+ if not any(c.endswith("yuva444p") or c.endswith("yuva444p10le") for c in chain):
624
+ chain.append("format=yuva444p10le" if deep else "format=yuva444p")
625
+ chain.append(grid[2])
547
626
  # a --start/--end window is in the layer's position, which is evaluated on the main
548
627
  # picture's frames; overlay's enable is evaluated on the layer's frames too
549
628
  ov = f"overlay={windowed_overlay_x(x, enable) if enable else x}:{y}" + (overlay_format(meta) or ":format=auto")
@@ -646,6 +725,15 @@ def main() -> int:
646
725
  else:
647
726
  cmd += video_args(meta, args.crf, args.preset, convert_matrix=not args.video) + cfr_args(meta)
648
727
  cmd += aac_args() if meta.get("audio") else ["-an"]
728
+ if cfr_filter(meta):
729
+ # the conform of a VFR source is the fps filter (in a graph, on the main picture as it is
730
+ # decoded): the encoder line's -fps_mode cfr alone let a frame run up to two frames late (#512)
731
+ if "-filter_complex" in cmd:
732
+ i = cmd.index("-filter_complex") + 1
733
+ cmd[i] = cfr_at_input(cmd[i], meta)
734
+ else:
735
+ i = cmd.index("-vf") + 1
736
+ cmd[i] = with_cfr(cmd[i], meta)
649
737
  dropped_streams = run_keeping_subtitles(cmd, output)
650
738
  if not STATE.dry_run:
651
739
  result = probe(output, role="output")
package/scripts/pad.py CHANGED
@@ -18,7 +18,7 @@ import sys
18
18
 
19
19
  from _common.emit import note, record_fallback
20
20
  from _common.decision import silence_filter, tpad_length
21
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, EVEN_SCALE, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
21
+ from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, drawn_chain, emit, EVEN_SCALE, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
22
22
 
23
23
 
24
24
  def main() -> int:
@@ -51,7 +51,12 @@ def main() -> int:
51
51
  tpad = f"tpad=start_duration={args.start:.3f}:stop_duration={args.end:.3f}:color="
52
52
  # a --color with chroma lands in the BT.709 the output is tagged with (tpad's own YUV is BT.601
53
53
  # before FFmpeg 7.1, and for an untagged picture after it); black is the same under every matrix
54
- vf = drawn_chain(tpad + args.color, meta, [args.color], tpad + "black")
54
+ # the conform of a VFR source is the fps filter right after tpad, which pads by time: before
55
+ # it, the padding was rounded to the delivered grid on top of the frames' own rounding (37 ms
56
+ # late), and after a drawn chain's maskedmerge the last frame was lost (#512)
57
+ conform = with_cfr("", meta)
58
+ vf = drawn_chain(tpad + args.color + ("," + conform if conform else ""), meta, [args.color],
59
+ tpad + "black" + ("," + conform if conform else ""))
55
60
  cmd = ffmpeg_base() + ["-i", args.input]
56
61
  if has_audio:
57
62
  # One -filter_complex, not a -vf and an -af: the ffmpeg scheduler of Ubuntu 26.04's 8.0.1 and
package/scripts/proxy.py CHANGED
@@ -26,7 +26,7 @@ Examples:
26
26
  import argparse
27
27
  import sys
28
28
 
29
- from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, fmt_secs
29
+ from _common import add_common, apply_common, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, fmt_secs
30
30
  from _common.emit import note
31
31
  from _common.probe import display_size, square_pixels, square_pixels_note
32
32
 
@@ -77,7 +77,7 @@ def main() -> int:
77
77
  + (f"the source is {sw}px wide and a proxy is not upscaled" if asked > ceiling else "2px is the smallest width"))
78
78
  output = args.output or default_output(args.input, "proxy")
79
79
 
80
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", (square + "," if square else "") + f"scale={out_w}:-2"]
80
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", with_cfr((square + "," if square else "") + f"scale={out_w}:-2", meta, args.fps)]
81
81
  cmd += video_args(meta, args.crf, "veryfast")
82
82
  cmd += cfr_args(meta, args.fps)
83
83
  cmd += ["-map", "0:v:0"] + (["-map", "0:a:0", "-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"])
package/scripts/redact.py CHANGED
@@ -27,7 +27,7 @@ Examples:
27
27
  import argparse
28
28
  import sys
29
29
 
30
- from _common import add_common, overlay_format, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
30
+ from _common import add_common, overlay_format, apply_common, aac_args, cfr_args, cfr_at_input, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
31
31
  from _common.emit import note
32
32
  from _common.probe import display_size, square_pixels, square_pixels_note
33
33
 
@@ -98,7 +98,8 @@ def main() -> int:
98
98
  output = args.output or default_output(args.input, "redact")
99
99
  if args.mode == "delogo":
100
100
  cmd = ffmpeg_base() + ["-i", args.input, "-vf",
101
- (square + "," if square else "") + f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
101
+ with_cfr((square + "," if square else "") + f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
102
+ meta, args.fps),
102
103
  "-map", "0:v:0"]
103
104
  else:
104
105
  # exact=1: crop rounds an odd x/y down to the chroma grid of a 4:2:0 frame otherwise, and
@@ -127,6 +128,8 @@ def main() -> int:
127
128
  f"pad={args.width + 2 * ox}:{args.height + 2 * oy}:{ox}:{oy}:color=black@0")
128
129
  fc = (f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];"
129
130
  f"[base][patched]overlay={args.x - ox}:{args.y - oy}{overlay_format(meta)}[out]")
131
+ # a VFR source (or --fps) is conformed by the fps filter as it is decoded (#512)
132
+ fc = cfr_at_input(fc, meta, args.fps)
130
133
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
131
134
  if has_audio:
132
135
  cmd += ["-map", f"0:a:{args.audio_stream}?"]
package/scripts/render.py CHANGED
@@ -76,7 +76,7 @@ from export import PRESETS, PLATFORM_OF
76
76
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
77
77
  from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit as _emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
78
78
  from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, owned_identity, read_json, remember_written
79
- from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
79
+ from _common.emit import absorb_stage, audit, is_output_shape_flag, record_fallback, record_skip, refuse, stderr_records, warn
80
80
  from _common.decision import rebase_project_paths
81
81
  import subprocess
82
82
  from _contract import CONTRACT_VERSION
@@ -405,9 +405,7 @@ def render_pack(names: List[str], args) -> int:
405
405
  doc = {}
406
406
  # the child ran with --json, so its planned commands come back in the document: the pack
407
407
  # is the one path that writes seven files, and --dry-run has to show all of them
408
- for line in proc.stderr.splitlines():
409
- if line.startswith("$ ") or line.startswith("[dry-run]"):
410
- STATE.commands.append(line[2:] if line.startswith("$ ") else line)
408
+ STATE.commands.extend(text for is_command, text in stderr_records(proc.stderr) if is_command)
411
409
  STATE.commands.extend(str(c) for c in (doc.get("commands") or []))
412
410
  absorb_stage(name, doc)
413
411
  out = doc.get("output") or dest_out
@@ -806,9 +804,9 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
806
804
  cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + own + child_args() + ["--json"]
807
805
  info("→ " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd[:-1])))
808
806
  proc = run_tool(cmd)
809
- for line in proc.stderr.splitlines():
810
- if line.startswith("$ ") or line.startswith("[dry-run]"):
811
- STATE.commands.append(line[2:] if line.startswith("$ ") else line)
807
+ for is_command, line in stderr_records(proc.stderr):
808
+ if is_command:
809
+ STATE.commands.append(line)
812
810
  elif line.strip():
813
811
  info(" " + line)
814
812
  try:
@@ -1212,7 +1210,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1212
1210
  os.chdir(plan["cwd"])
1213
1211
  if not isinstance(plan.get("argv"), list):
1214
1212
  _reject_plan(path, digest, "plan_argv_invalid", f"{path}: argv must be a list", argv_type=type(plan.get("argv")).__name__)
1215
- argv = [str(a) for a in plan["argv"]]
1213
+ # a plan an earlier version recorded with --json-brief (A181): the tool is run for its full
1214
+ # document, as every plan written now asks for
1215
+ argv = [str(a) for a in plan["argv"] if not is_output_shape_flag(str(a))]
1216
1216
  if STATE.dry_run:
1217
1217
  for c in plan.get("commands") or []:
1218
1218
  STATE.commands.append(c)
@@ -1226,9 +1226,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1226
1226
  return 0
1227
1227
  info(f"executing plan {path}: {tool} " + " ".join(argv))
1228
1228
  proc = run_tool([str(script)] + argv + child_args() + ["--json"])
1229
- for line in proc.stderr.splitlines():
1230
- if line.startswith("$ "):
1231
- STATE.commands.append(line[2:])
1229
+ for is_command, line in stderr_records(proc.stderr):
1230
+ if is_command and not line.startswith("[dry-run]"):
1231
+ STATE.commands.append(line)
1232
1232
  elif line.strip():
1233
1233
  info(" " + line)
1234
1234
  try:
@@ -1326,7 +1326,19 @@ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
1326
1326
  export = proj.get("export")
1327
1327
  frame_from_preset(frame, export if isinstance(export, dict) else {})
1328
1328
  fit = proj.get("fit")
1329
- if isinstance(fit, dict) and fit.get("aspect") and frame.get("aspect") and aspect_ratio(frame["aspect"]) is None:
1329
+ if isinstance(fit, dict) and len(proj.get("clips") or []) == 1:
1330
+ # one clip: the render's fit stage reframes the clip's own picture with the fit object's
1331
+ # aspect, mode, sides and rate, the frame's only where the fit object has none (the
1332
+ # render's setdefault), so the sequence takes them by the same rule (#506: frame 16:9 with
1333
+ # fit 1:1 rendered a square and exported 1920x1080). Several clips are fitted inside the
1334
+ # join, which join.py sizes from the frame first; their sequence stays the frame's
1335
+ if fit.get("aspect") and aspect_ratio(str(fit["aspect"])) is None:
1336
+ die(f"--export-timeline: fit.aspect {fit['aspect']!r}: use W:H with whole numbers, like 16:9 "
1337
+ "(fit.py refuses it too)", kind="input")
1338
+ if fit.get("fit") is not None and fit["fit"] not in ("pad", "crop", "blur"):
1339
+ die(f"--export-timeline: fit.fit {fit['fit']!r}: use pad, crop or blur (fit.py's --fit)", kind="input")
1340
+ frame.update({k: fit[k] for k in ("aspect", "fit", "width", "height", "fps") if fit.get(k) is not None})
1341
+ elif isinstance(fit, dict) and fit.get("aspect") and frame.get("aspect") and aspect_ratio(frame["aspect"]) is None:
1330
1342
  # the render hands fit.py the fit object's own aspect, never this one (only the preset
1331
1343
  # match above reads it), and completes: the sequence is the frame's size without it
1332
1344
  del frame["aspect"]
@@ -29,7 +29,7 @@ from _common import (filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOU
29
29
  from _common import asr as _asr
30
30
  from _common.emit import warn
31
31
  from _common.asr import _dicts
32
- from _common.runner import parse_json, replace_checked
32
+ from _common.runner import parse_json, replace_checked, same_file
33
33
  from _common import guard_sidecar, refuse_existing_outputs, refuse_output_is_input, _check_output_path
34
34
  from _common.emit import refuse
35
35
  from _common import detect_silences as detect, STATE, add_common, apply_common, default_output, die, emit, info, is_audio_output, print_json, probe, X264_PRESETS, measured_level_dbfs, fmt_secs
@@ -672,7 +672,7 @@ def main() -> int:
672
672
  # --overwrite allows replacing an old map, never the source, the cut or the EDL
673
673
  refuse_output_is_input(args.timemap, *[p for p in (args.input, args.words, args.filler_words) if p])
674
674
  for flag, other in (("-o", args.output), ("--edl", args.edl), ("--plan", getattr(args, "plan", None))):
675
- if other and os.path.realpath(other) == os.path.realpath(args.timemap):
675
+ if other and same_file(other, args.timemap):
676
676
  refuse("input", "timemap_path_taken",
677
677
  f"--timemap {args.timemap} is the same file as {flag}; give the map its own path",
678
678
  timemap=args.timemap, flag=flag, path=other)
@@ -17,9 +17,10 @@ Examples:
17
17
  """
18
18
  import argparse
19
19
  import sys
20
+ from fractions import Fraction
20
21
  from typing import List, Tuple
21
22
 
22
- from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
23
+ from _common.decision import av_trim, conform_filter, delivered_rate, frame_durations, frame_rate, grid_at_or_after, stream_starts
23
24
  from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
24
25
  from _common.emit import input_length
25
26
 
@@ -85,7 +86,7 @@ def main() -> int:
85
86
  has_audio = bool(meta.get("audio"))
86
87
  output = args.output or default_output(args.input, "ramp")
87
88
 
88
- vparts, aparts, labels = [], [], []
89
+ vparts, aparts = [], []
89
90
  # The coverage check above allows the last end to sit up to 0.5 s off the probed duration.
90
91
  # That slack is for a caller who rounded the duration, not a reason to redraw the tail at
91
92
  # the last factor. The end bound is omitted only when the stated end is the file's end
@@ -97,7 +98,12 @@ def main() -> int:
97
98
  # the factor scaled that (+570 ms at 0.5x). The first segment starts at the first frame.
98
99
  v0 = stream_starts(meta)[0]
99
100
  rate = frame_rate(meta)
101
+ # on a VFR source the picture is delivered at the rate the encoder line forces (its average):
102
+ # closed on r_frame_rate, -r then resampled the joined picture and it drifted 113 ms late (#512)
103
+ out_rate = delivered_rate(meta) or rate
104
+ srate = int((meta.get("audio") or {}).get("sample_rate") or 0)
100
105
  bounds = [v0] + [grid_at_or_after(end, v0, rate) for _, end, _ in segments[:-1]]
106
+ retimed = 0.0 # where the segment starts on the output's clock
101
107
  for i, (start, end, factor) in enumerate(segments):
102
108
  vlabel, alabel = f"v{i}", f"a{i}"
103
109
  to_eof = i == len(segments) - 1 and last_runs_to_eof
@@ -107,23 +113,52 @@ def main() -> int:
107
113
  die(f"--segment {start:g}-{end:g}:{factor:g} holds no frame: both ends fall on the frame at "
108
114
  f"{bounds[i]:.3f}s; widen it or merge it into a neighbour")
109
115
  vtrim, atrim = av_trim(bounds[i], seg_end, rate)
110
- # the closing fps puts the sped-up frames back on the source's grid with its frame
116
+ # the closing fps puts the sped-up frames back on the delivered grid with its frame
111
117
  # duration: concat starts the next segment after the last frame's duration, which setpts
112
118
  # leaves at the unscaled 1/rate, so every 2x segment pushed the rest of the picture two
113
119
  # frames late against the sound (and added those frames)
114
- vparts.append(f"[0:v]{vtrim},setpts=PTS/{factor:.6f},{frame_durations(meta, rate)}[{vlabel}]")
115
- labels.append(f"[{vlabel}]")
120
+ vchain = f"[0:v]{vtrim},setpts=PTS/{factor:.6f},{frame_durations(meta, out_rate)}"
121
+ achain = f"[0:a]{atrim},{atempo_chain(factor)}"
122
+ if out_rate:
123
+ # Each segment holds the frames (and samples) between its retimed ends on the whole
124
+ # output's grid, not its own length rounded: eight 0.5 s segments at 27 fps rounded
125
+ # to 14 frames each, concat advanced 14/27 s per segment and the last flash landed
126
+ # 130 ms late. The picture is sampled on the output's grid (an offset of under half
127
+ # a frame, and fps's start_time) and, but for a last segment that runs to the end,
128
+ # padded by a frame or two and cut to its count; the sound is padded or cut to its
129
+ # own count, so neither stream drifts across segments.
130
+ first = round(retimed * out_rate)
131
+ shift = first / out_rate - retimed
132
+ vchain = (f"[0:v]{vtrim},setpts=PTS/{factor:.6f}{-shift:+.6f}/TB,"
133
+ f"{frame_durations(meta, out_rate)}:start_time=0")
134
+ if seg_end is not None:
135
+ after = retimed + (seg_end - bounds[i]) / factor
136
+ vchain += f",tpad=stop=2:stop_mode=clone,trim=end_frame={round(after * out_rate) - first}"
137
+ if srate:
138
+ achain += f",{conform_filter(round(after * srate) - round(retimed * srate), srate)}"
139
+ retimed = after
140
+ vparts.append(f"{vchain}[{vlabel}]")
116
141
  if has_audio:
117
- aparts.append(f"[0:a]{atrim},{atempo_chain(factor)}[{alabel}]")
118
-
142
+ aparts.append(f"{achain}[{alabel}]")
143
+
144
+ # the two streams are joined by two concats: one concat over both started each segment after
145
+ # the longer of its two streams, so a picture a frame longer than its sound moved both late
146
+ n = len(segments)
147
+ # Every segment's picture is a whole count of frames on one grid, so the joined picture's
148
+ # timestamps are rebuilt from the running frame count: FFmpeg 6.1.1's video-only concat gave a
149
+ # one-frame segment no length (it takes a segment's end from its last frame's timestamp), so
150
+ # three one-frame segments put the rest of the picture a frame before its sound. The closing
151
+ # fps gives the frames their durations back (from 7.0 setpts leaves them 0, frame_durations)
152
+ regrid = ""
153
+ if out_rate:
154
+ grid = frame_durations(meta, out_rate)
155
+ q = Fraction(grid[len("fps="):]) if "/" in grid else Fraction(out_rate).limit_denominator(1001)
156
+ regrid = f",setpts=N*{q.denominator}/({q.numerator}*TB),{grid}"
157
+ fc = ";".join(vparts) + ";" + "".join(f"[v{i}]" for i in range(n)) + f"concat=n={n}:v=1:a=0{regrid}[outv]"
158
+ maps = ["-map", "[outv]"]
119
159
  if has_audio:
120
- concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(len(segments)))
121
- fc = ";".join(vparts + aparts) + f";{concat_inputs}concat=n={len(segments)}:v=1:a=1[outv][outa]"
122
- maps = ["-map", "[outv]", "-map", "[outa]"]
123
- else:
124
- concat_inputs = "".join(f"[v{i}]" for i in range(len(segments)))
125
- fc = ";".join(vparts) + f";{concat_inputs}concat=n={len(segments)}:v=1:a=0[outv]"
126
- maps = ["-map", "[outv]"]
160
+ fc += ";" + ";".join(aparts) + ";" + "".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[outa]"
161
+ maps += ["-map", "[outa]"]
127
162
 
128
163
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc] + maps
129
164
  cmd += video_args(meta, args.crf, args.preset)
package/scripts/sphere.py CHANGED
@@ -33,7 +33,7 @@ import argparse
33
33
  import math
34
34
  import sys
35
35
 
36
- 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, fmt_secs
36
+ from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
37
37
  from _common.emit import note
38
38
 
39
39
  # v360's own AVOption names for the input projections real 360 cameras/exports actually
@@ -124,7 +124,7 @@ def main() -> int:
124
124
  f"yaw={args.yaw:g}:pitch={args.pitch:g}:roll={args.roll:g}:"
125
125
  f"h_fov={args.h_fov:g}:v_fov={args.v_fov:g}:"
126
126
  f"w={args.width}:h={args.height}:interp={args.interp}")
127
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", v360, "-map", "0:v:0"]
127
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", with_cfr(v360, meta, args.fps), "-map", "0:v:0"]
128
128
  if has_audio:
129
129
  cmd += ["-map", f"0:a:{args.audio_stream}?"]
130
130
  cmd += video_args(meta, args.crf, args.preset)
@@ -29,7 +29,7 @@ from pathlib import Path
29
29
 
30
30
  from _common.probe import PROBED_VIDEO
31
31
  from _common.runner import ffmpeg_failure_extra, default_selection_maps, require_filter
32
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
32
+ from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
33
33
 
34
34
 
35
35
  def main() -> int:
@@ -104,7 +104,7 @@ def main() -> int:
104
104
  # Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
105
105
  # camera path makes no sense once every frame is locked to one fixed reference.
106
106
  transform_vf += ":tripod=1"
107
- cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", transform_vf]
107
+ cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", with_cfr(transform_vf, meta)]
108
108
  cmd += video_args(meta, args.crf, args.preset)
109
109
  cmd += cfr_args(meta)
110
110
  if has_audio:
@@ -22,7 +22,7 @@ import argparse
22
22
  import math
23
23
  import sys
24
24
 
25
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
25
+ from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, drawn_chain, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
26
26
  from _common.emit import note
27
27
  from _common.probe import display_size, square_pixels, square_pixels_note
28
28
 
@@ -100,7 +100,7 @@ def main() -> int:
100
100
  if square_pixels(meta["video"]):
101
101
  rotate = square_pixels(meta["video"]) + "," + rotate
102
102
  note(square_pixels_note(meta["video"]))
103
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", rotate, "-map", "0:v:0"]
103
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", with_cfr(rotate, meta, args.fps), "-map", "0:v:0"]
104
104
  if has_audio:
105
105
  cmd += ["-map", f"0:a:{args.audio_stream}?"]
106
106
  cmd += video_args(meta, args.crf, args.preset)
package/scripts/verify.py CHANGED
@@ -26,6 +26,7 @@ from pathlib import Path
26
26
  from typing import Any, Dict, List, Optional, Tuple
27
27
 
28
28
  from _common import STATE, add_common, apply_common, die, emit, guard_sidecar, info, probe, refuse_output_is_input, MEDIA_EXT as _MEDIA_EXT
29
+ from _common.runner import _stop_child, _unwatch, _watch
29
30
 
30
31
  HERE = Path(__file__).resolve().parent
31
32
  # _common's one list less the stills, as batch.py walks a folder: verify kept a list of its own,
@@ -140,13 +141,24 @@ def step(name: str, argv: List[str], timeout: float) -> Dict:
140
141
  if not ok:
141
142
  r.update(kind="verification", reason="hdr_lost", message=r["error"])
142
143
  return r
144
+ child = subprocess.Popen([sys.executable, str(HERE / argv[0])] + argv[1:] + _forwarded(argv) + ["--json"], stdout=subprocess.PIPE,
145
+ stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
146
+ # watched like run_tool()'s child (review of #530): a SIGTERM or Ctrl-C to verify.py stops the
147
+ # step through stop_children() -- its own handler removes its partial output and lock --
148
+ # instead of leaving it running and writing after verify.py has exited
149
+ _watch(child, [])
143
150
  try:
144
- proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:] + _forwarded(argv) + ["--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
145
- text=True, encoding="utf-8", errors="replace", timeout=timeout or None)
151
+ out, err = child.communicate(timeout=timeout or None)
152
+ proc = subprocess.CompletedProcess(child.args, child.returncode, out, err)
146
153
  except subprocess.TimeoutExpired:
154
+ # stopped as run_tool() stops a sibling script past its ceiling (#465 A183's sweep): the
155
+ # SIGKILL subprocess.run() sent left the step's partial output and output lock in --out
156
+ _stop_child(child)
147
157
  msg = f"timeout after {timeout:.0f}s"
148
158
  return {"step": name, "ok": False, "seconds": round(time.time() - t0, 1), "error": msg,
149
159
  "kind": "timeout", "reason": "step_timeout", "message": msg}
160
+ finally:
161
+ _unwatch(child)
150
162
  try:
151
163
  doc = json.loads(proc.stdout)
152
164
  except ValueError:
@@ -198,10 +210,27 @@ def main() -> int:
198
210
  files = collect(args.paths)
199
211
  FORWARD.update(overwrite=STATE.overwrite, fast=STATE.fast)
200
212
  tmp = None
213
+ started = {"step": False}
201
214
  if args.out:
202
215
  # made first, so a --report inside a new --out folder passes the guard below (#413 review)
203
216
  outdir = Path(args.out)
217
+ made = [d for d in (outdir, *outdir.parents) if not d.exists()]
204
218
  outdir.mkdir(parents=True, exist_ok=True)
219
+ if made:
220
+ import atexit
221
+
222
+ def _remove_unused_dirs() -> None:
223
+ # a run refused before its first step (a --report guard) leaves no empty --out
224
+ # folder behind, as batch.py leaves no out/ (#465 A187's sibling); once a step has
225
+ # run the folder is the caller's, empty or not
226
+ if started["step"]:
227
+ return
228
+ for d in made: # deepest first
229
+ try:
230
+ d.rmdir()
231
+ except OSError:
232
+ pass
233
+ atexit.register(_remove_unused_dirs)
205
234
  if args.report:
206
235
  # a file this run writes itself (#408): not one of the media it verifies (a folder's
207
236
  # files included), nor an existing file without --overwrite
@@ -301,6 +330,7 @@ def main() -> int:
301
330
  refuse_output_is_input(out, *every_input, why=f"it is where verify writes the {name!r} step's output for {str(f)!r}")
302
331
 
303
332
  results = []
333
+ started["step"] = True
304
334
  for f, entry, plan, cues in jobs:
305
335
  info(f"=== {f}")
306
336
  if cues: