ffmpeg-skill 3.8.9 → 3.8.11

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.
@@ -564,7 +564,9 @@ def main(force_route: Optional[str] = None) -> int:
564
564
  rise = int(fs * 0.5)
565
565
  pop = f"min(1,(t-{s:.3f})/0.25)"
566
566
  xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
567
- ye = (f"{m_top}+{rise}*(1-{pop})" if "top" in pos else f"h-text_h-{m_bottom}-{rise}*(1-{pop})")
567
+ # every position rises into place, on both routes: a bottom chip started `rise` px above
568
+ # its rest on drawtext and dropped, where the ASS \move below rises (A177)
569
+ ye = (f"{m_top}+{rise}*(1-{pop})" if "top" in pos else f"h-text_h-{m_bottom}+{rise}*(1-{pop})")
568
570
  alpha = f"min({pop},{fade_a})"
569
571
  align = (7 if "left" in pos else 9) if "top" in pos else (1 if "left" in pos else 3)
570
572
  y_rest = m_top if "top" in pos else H - m_bottom
package/scripts/grid.py CHANGED
@@ -26,7 +26,7 @@ import os
26
26
  import sys
27
27
 
28
28
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, X264_PRESETS, fmt_secs, \
29
- as_limited, draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args
29
+ as_limited, draws_chroma, drawn_chain, drawtext_text_opts, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args
30
30
  from _common.fonts import font_choice, record_font_choice
31
31
  from _common import drawtext_fontfile # noqa: E402
32
32
  from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
@@ -173,7 +173,9 @@ def main() -> int:
173
173
  if args.label == "auto":
174
174
  font_file = label_fonts[i]["file"]
175
175
  font_opt = drawtext_fontfile(font_file) if font_file else f"font='{escape_drawtext(args.font)}'"
176
- drawn.append((f"drawtext=text='{escape_drawtext(labels[i])}':{font_opt}:fontsize={args.font_size}:"
176
+ # the textfile= route every other drawn text takes: escape_drawtext() is for a font name,
177
+ # and dropped an apostrophe or a percent sign from a file name's label (A178)
178
+ drawn.append((f"drawtext={drawtext_text_opts(labels[i])}:{font_opt}:fontsize={args.font_size}:"
177
179
  f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
178
180
  f"box=1:boxcolor=black@0.5:boxborderw=4", "null"))
179
181
  gap_r = args.gap if i % args.cols < args.cols - 1 else 0
package/scripts/loop.py CHANGED
@@ -34,7 +34,7 @@ import tempfile
34
34
 
35
35
  from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, 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
- from _common.decision import av_trim, length_output_t
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
39
  from _common.emit import input_length
40
40
 
@@ -60,9 +60,8 @@ def _boomerang_graph(meta, src_dur: float, times) -> str:
60
60
  f"-- cut the clip shorter first (cut.py)")
61
61
  # reverse holds the clip's decoded frames; split, trim and loop pass references to the same
62
62
  # frames, so the peak is about one clip (measured: the same max RSS as a plain reverse)
63
- bytes_per_frame = v["width"] * v["height"] * 1.5 * (2 if (v.get("bit_depth") or 8) > 8 else 1)
64
- held = bytes_per_frame * frames
65
- if held > 2 * 1024 ** 3:
63
+ held = held_frames_bytes(v, frames)
64
+ if held > HELD_FRAMES_WARN_BYTES:
66
65
  warn(f"--boomerang holds about {held / 1024 ** 3:.1f} GiB of decoded frames in memory "
67
66
  f"({frames} frames of {v['width']}x{v['height']}) -- cut the clip shorter first (cut.py) if RAM is tight",
68
67
  "memory_heavy", bytes=int(held))
@@ -160,7 +159,12 @@ def _seamless_unit_cmd(meta, src: str, unit: str, rate: str, frames: int, fade_f
160
159
  if has_audio:
161
160
  # acrossfade writes nothing when its first input is exactly the fade long (FFmpeg 6.1 and
162
161
  # 7.1, measured), so the fade is built by hand: tail faded out + head faded in, summed
163
- graph += (f";[0:a:0]aresample=async=1:first_pts=0,apad,atrim=end={length:.6f},asplit=3[at][ah][ab];"
162
+ # the sound is shifted by the picture's start, the constant setpts=PTS-STARTPTS takes off the
163
+ # picture: rebased to its own start (aresample's first_pts=0 alone), a picture that starts
164
+ # 0.2 s after its sound came out 200 ms behind it in every repeat (A168)
165
+ v0 = stream_starts(meta)[0]
166
+ graph += (f";[0:a:0]atrim=start={v0:.6f},asetpts=PTS-{v0:.6f}/TB,aresample=async=1:first_pts=0,"
167
+ f"apad,atrim=end={length:.6f},asplit=3[at][ah][ab];"
164
168
  f"[at]atrim=start={cut:.6f},asetpts=PTS-STARTPTS,afade=t=out:d={fade:.6f}:curve=tri[ta];"
165
169
  f"[ah]atrim=end={fade:.6f},asetpts=PTS-STARTPTS,afade=t=in:d={fade:.6f}:curve=tri[ha];"
166
170
  f"[ta][ha]amix=inputs=2:normalize=0:duration=longest[xa];"
@@ -35,6 +35,7 @@ from _common import (guard_sidecar, add_common, apply_common, default_output, de
35
35
  die, emit, ffmpeg_base, fmt_chapter_time, info, propose_chapters, time_arg, probe, run,
36
36
  STATE, read_text_or_die)
37
37
  from _common.emit import input_length
38
+ from _common.runner import carried_stream_maps
38
39
 
39
40
  CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
40
41
  TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
@@ -267,6 +268,9 @@ def main() -> int:
267
268
  cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "-1"]
268
269
  else:
269
270
  cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "0"]
271
+ # not the source's MP4/MOV chapter track: the muxer writes one from the chapters it is given,
272
+ # and the copy was a stray data track holding the old titles (A173)
273
+ cmd += carried_stream_maps(args.input, output, attachments=False)
270
274
  for key, value in tags.items():
271
275
  cmd += ["-metadata", f"{key}={value}"]
272
276
  cmd += ["-c", "copy"]
package/scripts/redact.py CHANGED
@@ -101,7 +101,14 @@ def main() -> int:
101
101
  (square + "," if square else "") + f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
102
102
  "-map", "0:v:0"]
103
103
  else:
104
- crop = f"crop={args.width}:{args.height}:{args.x}:{args.y}"
104
+ # exact=1: crop rounds an odd x/y down to the chroma grid of a 4:2:0 frame otherwise, and
105
+ # overlay always does, so an odd rectangle was taken from and put back one pixel up and
106
+ # left (its last column and row left as they were, one outside it changed: A170). The
107
+ # patch is taken exactly, given a transparent first column/row where x/y is odd, and put
108
+ # at the even position before it: the luma inside the rectangle is replaced, the luma
109
+ # outside is not (the chroma pair on an odd edge is shared, so it is half mixed)
110
+ crop = f"crop={args.width}:{args.height}:{args.x}:{args.y}:exact=1"
111
+ ox, oy = args.x % 2, args.y % 2
105
112
  if args.mode == "blur":
106
113
  # boxblur refuses a radius above half the plane: the chroma planes of 4:2:0 are half-size,
107
114
  # so they get their own (halved) radius; a 30 px region with the default 20 used to fail
@@ -110,7 +117,12 @@ def main() -> int:
110
117
  region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
111
118
  else:
112
119
  region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
113
- fc = f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}{overlay_format(meta)}[out]"
120
+ if ox or oy:
121
+ deep = overlay_format(meta)
122
+ region += (f",format=yuva444p{'10le' if deep else ''},"
123
+ f"pad={args.width + 2 * ox}:{args.height + 2 * oy}:{ox}:{oy}:color=black@0")
124
+ fc = (f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];"
125
+ f"[base][patched]overlay={args.x - ox}:{args.y - oy}{overlay_format(meta)}[out]")
114
126
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
115
127
  if has_audio:
116
128
  cmd += ["-map", f"0:a:{args.audio_stream}?"]
package/scripts/render.py CHANGED
@@ -74,7 +74,7 @@ from typing import Any, Dict, List, Optional, Sequence
74
74
 
75
75
  from export import PRESETS, PLATFORM_OF
76
76
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
77
- from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, 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
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
79
  from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
80
80
  from _common.decision import rebase_project_paths
@@ -691,6 +691,59 @@ def check_snap_to(snap: Any, label: str) -> None:
691
691
 
692
692
  _LAST_DOC: Dict[str, Any] = {} # the JSON document the most recent sh() child printed
693
693
 
694
+ # source_carry for a render (A255): the one picture/sound source of a project with one, and what
695
+ # each stage that ran said about the start timecode ("kept", "dropped", None: a stage that compared
696
+ # nothing, "unknown": a cache hit whose entry did not keep it). main() resets both.
697
+ PROJECT_SOURCE: Dict[str, Any] = {"src": None, "timecode": []}
698
+
699
+
700
+ def _timecode_verdict(doc: Any) -> Optional[str]:
701
+ """What a stage's document says about the start timecode: "kept", "dropped", or None when its
702
+ source_carry compared none."""
703
+ carry = doc.get("source_carry") if isinstance(doc, dict) else None
704
+ if not isinstance(carry, dict):
705
+ return None
706
+ return "dropped" if "timecode" in (carry.get("dropped") or []) else "kept" if "timecode" in (carry.get("kept") or []) else None
707
+
708
+
709
+ def project_source(proj: Dict[str, Any], rel) -> Optional[str]:
710
+ """The project's single picture/sound source -- its clips all one file, and no music,
711
+ replacement or effects track, no video layer and no audiogram picture beside it -- or None,
712
+ when the output mixes several (source_carry is then not compared, as for any tool with two
713
+ sources)."""
714
+ srcs = {os.path.realpath(rel(c["src"])) for c in proj.get("clips") or [] if isinstance(c, dict) and isinstance(c.get("src"), str)}
715
+ audio = proj.get("audio") if isinstance(proj.get("audio"), dict) else {}
716
+ others = [audio.get(k) for k in ("music", "replace", "effects") if audio.get(k)]
717
+ overlays = proj.get("overlays") if isinstance(proj.get("overlays"), list) else []
718
+ others += [ov.get("video") for ov in overlays if isinstance(ov, dict) and ov.get("video")]
719
+ if proj.get("audiogram"):
720
+ others.append("audiogram") # the picture is the waveform over an image, not the clip's
721
+ return next(iter(srcs)) if len(srcs) == 1 and not others else None
722
+
723
+
724
+ def emit(output: Optional[str], **extra: Any) -> None:
725
+ """emit(), with render.py's source_carry: the stages run as children, so this process never
726
+ probed the project's source as an input and its result carried no source_carry at all, even
727
+ for a one-clip render that lost the source's subtitle track (A255). The source is probed here
728
+ (after the stages' intermediates, which are not sources), so emit() compares it with the
729
+ delivered file. A stage that moves the picture's start writes the label of the frame it starts
730
+ on, which is not the source's: the timecode counts as kept when every stage that compared it
731
+ kept it, as dropped when one dropped it, and is compared with the source's own label when no
732
+ stage said (nothing ran, or a cache entry written before stages' source_carry was kept)."""
733
+ src = PROJECT_SOURCE.get("src")
734
+ if src and output and not STATE.dry_run and os.path.isfile(src) and os.path.isfile(output) \
735
+ and os.path.realpath(src) != os.path.realpath(output) and "source_carry" not in extra:
736
+ STATE.first_input, STATE.video_inputs, STATE.audio_inputs = None, set(), set()
737
+ probe(src)
738
+ said = PROJECT_SOURCE["timecode"]
739
+ if "dropped" in said:
740
+ STATE.expected_timecode = ""
741
+ elif "kept" in said and "unknown" not in said:
742
+ STATE.expected_timecode = (probe(output, role="output").get("timecode") or {}).get("value") or ""
743
+ else:
744
+ STATE.expected_timecode = None
745
+ _emit(output, **extra)
746
+
694
747
 
695
748
  def _refuse_uncached_earlier_stage(stage: str) -> None:
696
749
  """--from STAGE promises the earlier stages come from the cache. When one does not, say so
@@ -783,6 +836,7 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
783
836
  code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, error_extra=error_extra, **extra_fields)
784
837
  _LAST_DOC.clear()
785
838
  _LAST_DOC.update(doc if isinstance(doc, dict) else {})
839
+ PROJECT_SOURCE["timecode"].append(_timecode_verdict(doc))
786
840
  absorb_stage(Path(script).stem, doc) # a stage's GPU facts (ran on VideoToolbox, fell back and why) and its diagnostics reach this result
787
841
  out_path = str(doc.get("output") or "")
788
842
  if not STATE.dry_run:
@@ -981,6 +1035,10 @@ def cache_lookup(stage: str, key: str, dest: str, tool: Optional[str] = None) ->
981
1035
  shutil.copy2(art, dest) # ... and a copy where it does not. Never a move: the cache
982
1036
  CACHE["saved_seconds"] += float(meta.get("seconds") or 0.0)
983
1037
  result = meta.get("result")
1038
+ # what the stage said about the start timecode, for render's source_carry; an entry written
1039
+ # before its source_carry was kept does not say
1040
+ PROJECT_SOURCE["timecode"].append(_timecode_verdict(result) if isinstance(result, dict) and "source_carry" in result
1041
+ else "unknown")
984
1042
  if isinstance(result, dict):
985
1043
  absorb_stage(tool or stage, dict(result, status="completed", dry_run=False)) # tagged as the stage's tool, as a live run is
986
1044
  else:
@@ -994,7 +1052,9 @@ def _stage_result(doc: Any) -> Dict[str, Any]:
994
1052
  """What a cache entry keeps of the stage's own --json document, replayed on a hit."""
995
1053
  if not isinstance(doc, dict):
996
1054
  return {}
997
- return {k: doc[k] for k in ("verified", "verification", "notes", "diagnostics") if k in doc}
1055
+ kept = {k: doc[k] for k in ("verified", "verification", "notes", "diagnostics") if k in doc}
1056
+ kept["source_carry"] = doc.get("source_carry") # None too: the stage compared nothing (render's source_carry)
1057
+ return kept
998
1058
 
999
1059
 
1000
1060
  def cache_store(stage: str, key: str, produced: str, seconds: float, doc: Any = None) -> None:
@@ -1472,6 +1532,7 @@ def main() -> int:
1472
1532
  add_hw_orchestrator_args(ap)
1473
1533
  args = ap.parse_args()
1474
1534
  apply_common(args)
1535
+ PROJECT_SOURCE.update(src=None, timecode=[])
1475
1536
  STATE.audit_log = os.path.abspath(args.audit_log) if args.audit_log else None
1476
1537
 
1477
1538
  if args.list_templates:
@@ -1650,6 +1711,7 @@ def main() -> int:
1650
1711
  CACHE.clear()
1651
1712
  CACHE.update(_fresh_cache())
1652
1713
  CONTENT_KEYED.clear()
1714
+ PROJECT_SOURCE.update(src=project_source(proj, rel), timecode=[])
1653
1715
  if args.cache:
1654
1716
  cdir = Path(args.cache)
1655
1717
  try:
@@ -17,7 +17,8 @@ import sys
17
17
 
18
18
  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
19
19
  from _common.emit import warn
20
- from _common.decision import av_clip_length, av_trim, frame_durations, frame_rate, stream_starts
20
+ from _common.decision import (HELD_FRAMES_WARN_BYTES, av_clip_length, av_trim, delivered_rate, frame_durations, frame_rate,
21
+ held_frames_bytes, stream_starts)
21
22
 
22
23
 
23
24
  def main() -> int:
@@ -37,10 +38,16 @@ def main() -> int:
37
38
  has_audio = bool(meta.get("audio")) and not args.no_audio
38
39
 
39
40
  output = args.output or default_output(args.input, "reverse")
40
- if (meta.get("duration") or 0) > 60:
41
- warn(f"reverse buffers every decoded frame in memory; {meta['duration']:.0f}s of "
42
- f"{meta['video']['width']}x{meta['video']['height']} can exhaust RAM -- cut the part to reverse first (cut.py)",
43
- "memory_heavy", duration=meta["duration"])
41
+ # keyed on what is held (every decoded frame), not on the length alone (A174): the estimate
42
+ # loop.py --boomerang warns from, over the frames of the picture's length at its rate
43
+ v = meta["video"]
44
+ vdur = v.get("duration") or meta.get("duration") or 0.0
45
+ frames = v.get("nb_frames") or vdur * (frame_rate(meta) or v.get("fps") or 30.0)
46
+ held = held_frames_bytes(v, frames)
47
+ if held > HELD_FRAMES_WARN_BYTES:
48
+ warn(f"reverse holds about {held / 1024 ** 3:.1f} GiB of decoded frames in memory "
49
+ f"({vdur:.0f}s of {v['width']}x{v['height']}) -- cut the part to reverse first (cut.py) if RAM is tight",
50
+ "memory_heavy", duration=meta.get("duration"), bytes=held)
44
51
  cmd = ffmpeg_base() + ["-i", args.input]
45
52
  if has_audio:
46
53
  # Both streams are reversed about one end (#409); each reversed about its own end put a sound
@@ -55,14 +62,20 @@ def main() -> int:
55
62
  # setpts: from FFmpeg 7.0 setpts leaves its output's frame rate unknown and its frames'
56
63
  # durations 0, so a tpad after it clones nothing (the picture stayed 0.6 s short on 7.1);
57
64
  # the closing fps gives the frames their durations back (frame_durations).
58
- rate = frame_rate(meta) or meta["video"].get("fps") or 30.0
65
+ # the rate the encoder line delivers (cfr_args: a VFR source is conformed to its average
66
+ # rate), so the graph's closing fps and the output's -r are one grid: the graph closed on
67
+ # r_frame_rate and -r then resampled it, 51 frames for 49 that ran 81 ms past the sound (A234)
68
+ # The trim that drops the hold's spare clones stays on the source's own grid (half a frame
69
+ # of r_frame_rate before the end): half a delivered frame cut a real last frame that sits
70
+ # nearer the end than that, and reverse then opened on the one before it.
71
+ rate = delivered_rate(meta) or frame_rate(meta) or meta["video"].get("fps") or 30.0
59
72
  length = av_clip_length(meta, rate)
60
73
  vlen = meta["video"].get("duration") or length
61
74
  end = math.ceil(length * rate - 1e-6) / rate
62
75
  vtrim, atrim = av_trim(stream_starts(meta)[0], None)
63
76
  cmd += ["-filter_complex",
64
77
  f"[0:v:0]tpad=stop_mode=clone:stop_duration={max(0.0, end - vlen) + 2 / rate:.6f},{vtrim},"
65
- f"trim=end={end - 0.5 / rate:.6f},reverse,{frame_durations(meta, rate)}[v];"
78
+ f"trim=end={end - 0.5 / max(rate, frame_rate(meta) or rate):.6f},reverse,{frame_durations(meta, rate)}[v];"
66
79
  f"[0:a:0]{atrim},apad=whole_dur={end:.6f},atrim=end={end:.6f},areverse,asetpts=N/SR/TB[a]",
67
80
  "-map", "[v]", "-map", "[a]"]
68
81
  else:
@@ -80,9 +80,20 @@ def main() -> int:
80
80
  dw, dh = display_size(meta["video"])
81
81
  aspect = max(dw / dh, dh / dw) if dw and dh else 1.0
82
82
  s = abs(math.cos(radians)) + aspect * abs(math.sin(radians))
83
- # crop dimensions must round down to even (4:2:0 chroma); trunc(.../2)*2 floors to the
84
- # nearest even value instead of leaving an odd width/height that the encoder would refuse.
85
- rotate = f"scale=iw*{s:.8f}:ih*{s:.8f},rotate={radians:.8f},crop=trunc(iw/{s:.8f}/2)*2:trunc(ih/{s:.8f}/2)*2"
83
+ # The window is the displayed frame's own size, to even (4:2:0 chroma), and the scaled frame
84
+ # is rounded UP to even so it still covers it. Scaling to iw*s and cropping back to
85
+ # trunc(iw/s/2)*2 lost to two roundings: 1920x1080 came out 1918x1078, and the scale's
86
+ # rounding left a non-square SAR such as 6112:6111 (A235).
87
+ cw, ch = (dw // 2 * 2, dh // 2 * 2) if dw and dh else (0, 0)
88
+ if cw and ch:
89
+ # the margin on each axis is a multiple of 4, so the centred crop's offset is even:
90
+ # crop floors an odd one to the chroma grid, which moved the window a pixel up and
91
+ # left and could bring the fill into the top-left corner
92
+ sw, sh = (c + math.ceil(max(0.0, c * s - c) / 4) * 4 for c in (cw, ch))
93
+ rotate = (f"scale={sw}:{sh},setsar=1,"
94
+ f"rotate={radians:.8f},crop={cw}:{ch},setsar=1")
95
+ else: # a dry run's pending input has no size yet: the old expression, planned only
96
+ rotate = f"scale=iw*{s:.8f}:ih*{s:.8f},rotate={radians:.8f},crop=trunc(iw/{s:.8f}/2)*2:trunc(ih/{s:.8f}/2)*2"
86
97
 
87
98
  # a rotation of non-square pixels is a shear on screen: the picture is squared at its
88
99
  # display aspect first (#411)