ffmpeg-skill 3.1.4 → 3.1.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/scripts/grid.py CHANGED
@@ -26,15 +26,34 @@ 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
- draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
29
+ as_limited, draws_chroma, drawn_chain, 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
33
33
  from _common.emit import note, record_fallback # noqa: E402
34
+ from _common.fonts import script_font_for_text # noqa: E402
35
+ from _common.probe import square_pixels # noqa: E402
36
+ from _common.wrap import text_width_em # noqa: E402
34
37
 
35
38
  LABEL_MARGIN = 10
36
39
 
37
40
 
41
+ def _fit_label(stem: str, cell_width: int, font_size: int) -> str:
42
+ """The label as drawn: `stem` when it fits the cell (less the margin and the box's border on
43
+ each side), else its head with an ellipsis. It is right-aligned, so a label wider than the
44
+ cell used to lose its start off the left edge and show only its tail (#411). The width is the
45
+ averaged advance table's estimate."""
46
+ room = (cell_width - 2 * LABEL_MARGIN - 8) / float(max(1, font_size))
47
+ if text_width_em(stem) <= room:
48
+ return stem
49
+ if room <= 1:
50
+ return stem[:1] # no room for an ellipsis: keep the start, as a shortened label does (#416 review)
51
+ head = stem
52
+ while head and text_width_em(head + "\u2026") > room:
53
+ head = head[:-1]
54
+ return (head.rstrip() + "\u2026") if head else stem[:1]
55
+
56
+
38
57
  def main() -> int:
39
58
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
40
59
  ap.add_argument("inputs", nargs="+", help="video clips, filled into the grid left-to-right, top-to-bottom")
@@ -69,6 +88,9 @@ def main() -> int:
69
88
  n = args.cols * args.rows
70
89
  if args.cols <= 0 or args.rows <= 0:
71
90
  die(f"--cols/--rows must be > 0, got cols={args.cols} rows={args.rows}")
91
+ if n == 1:
92
+ # xstack takes two inputs or more: a 1x1 grid used to reach ffmpeg and fail there (exit 222)
93
+ die("--cols 1 --rows 1 is a single cell; a grid needs at least two (fit.py letterboxes one clip)")
72
94
  if len(args.inputs) != n:
73
95
  die(f"--cols {args.cols} --rows {args.rows} needs exactly {n} inputs, got {len(args.inputs)}")
74
96
  if args.cell_width <= 0 or args.cell_height <= 0:
@@ -95,10 +117,26 @@ def main() -> int:
95
117
  durations = [m.get("duration") or 0.0 for m in metas]
96
118
  target_duration = max(durations) if args.pad else min(durations)
97
119
 
98
- font_used = font_choice(args.font) # the label font, reported as `font_used` when labels are drawn
99
- font_file = font_used["file"]
120
+ # each label is drawn with a font that covers its own script, as every other text-drawing tool
121
+ # picks one (through 3.1.3 a Japanese or Thai filename came out as tofu, #411); `font_used` is
122
+ # the first label's choice, and a grid whose labels needed different fonts says so in a note
123
+ stems = [os.path.splitext(os.path.basename(p))[0] for p in args.inputs]
124
+ font_explicit = args.font != ap.get_default("font")
125
+ label_fonts = []
126
+ for stem in (stems if args.label == "auto" else []):
127
+ _sc, script_file, script_family = script_font_for_text(stem, font=args.font, font_explicit=font_explicit)
128
+ label_fonts.append(font_choice(args.font, script_file=script_file, script_family=script_family))
129
+ font_used = label_fonts[0] if label_fonts else font_choice(args.font)
100
130
  if args.label == "auto":
101
131
  record_font_choice(font_used)
132
+ if len({f.get("file") for f in label_fonts}) > 1:
133
+ note("cell labels drawn with more than one font, by script: "
134
+ + "; ".join(sorted({str(f.get("family") or f.get("file")) for f in label_fonts})))
135
+ labels = [_fit_label(stem, args.cell_width, args.font_size) for stem in stems]
136
+ cut = [stem for stem, lab in zip(stems, labels) if lab != stem]
137
+ if args.label == "auto" and cut:
138
+ note(f"{len(cut)} cell label(s) wider than the {args.cell_width} px cell at --font-size "
139
+ f"{args.font_size} were shortened from the end with an ellipsis")
102
140
  output = args.output or default_output(args.inputs[0], "grid")
103
141
 
104
142
  cmd = ffmpeg_base()
@@ -117,6 +155,8 @@ def main() -> int:
117
155
  convert = [matrix_chain(m, None) for m in metas]
118
156
  if any(convert) or chroma:
119
157
  convert = [c or label_chain(m, None) for c, m in zip(convert, metas)]
158
+ # xstack mixes samples: a full-range cell beside a limited one is taken to limited range (#410)
159
+ convert = [c + range_chain(m, None) for c, m in zip(convert, metas)]
120
160
  parts = []
121
161
  for i, p in enumerate(args.inputs):
122
162
  drawn = [ # (with this colour, with black)
@@ -129,17 +169,19 @@ def main() -> int:
129
169
  elif not args.pad:
130
170
  drawn.append((f"trim=duration={target_duration:.3f}",) * 2)
131
171
  if args.label == "auto":
132
- stem = os.path.splitext(os.path.basename(p))[0]
172
+ font_file = label_fonts[i]["file"]
133
173
  font_opt = drawtext_fontfile(font_file) if font_file else f"font='{escape_drawtext(args.font)}'"
134
- drawn.append((f"drawtext=text='{escape_drawtext(stem)}':{font_opt}:fontsize={args.font_size}:"
174
+ drawn.append((f"drawtext=text='{escape_drawtext(labels[i])}':{font_opt}:fontsize={args.font_size}:"
135
175
  f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
136
176
  f"box=1:boxcolor=black@0.5:boxborderw=4", "null"))
137
177
  gap_r = args.gap if i % args.cols < args.cols - 1 else 0
138
178
  gap_b = args.gap if i // args.cols < args.rows - 1 else 0
139
179
  if chroma and (gap_r or gap_b):
140
180
  drawn.append((f"pad=iw+{gap_r}:ih+{gap_b}:0:0:color={args.background}", f"pad=iw+{gap_r}:ih+{gap_b}:0:0:color=black"))
141
- chain = f"fps={args.fps:g},scale={args.cell_width}:{args.cell_height}:force_original_aspect_ratio=decrease,"
142
- chain += drawn_chain(",".join(c for c, _n in drawn), metas[i], colours, ",".join(n for _c, n in drawn),
181
+ # a non-square-pixel clip is squared at its display aspect before it is fitted (#411)
182
+ sq = (square_pixels(metas[i]["video"]) + ",") if square_pixels(metas[i]["video"]) else ""
183
+ chain = f"fps={args.fps:g},{sq}scale={args.cell_width}:{args.cell_height}:force_original_aspect_ratio=decrease,"
184
+ chain += drawn_chain(",".join(c for c, _n in drawn), as_limited(metas[i]), colours, ",".join(n for _c, n in drawn),
143
185
  line_meta=None, tag=f"c{i}", labelled=True)
144
186
  parts.append(f"[{i}:v]{convert[i]}{chain}[v{i}]")
145
187
 
package/scripts/insert.py CHANGED
@@ -27,7 +27,10 @@ import argparse
27
27
  import math
28
28
  import sys
29
29
 
30
+ from _common import still_input_args # noqa: E402
30
31
  from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
32
+ from _common.emit import note
33
+ from _common.probe import display_size, square_pixels, square_pixels_note
31
34
 
32
35
 
33
36
  def even(n: float) -> int:
@@ -68,7 +71,11 @@ def main() -> int:
68
71
  if (meta.get("duration") or 0) > 0.5 or meta.get("audio"):
69
72
  die(f"{args.input} is a video, not a still image; insert.py animates a still (Ken Burns). "
70
73
  f"For a clip use broll.py (cutaway) or cut.py/join.py")
71
- sw, sh = meta["video"]["width"], meta["video"]["height"]
74
+ # the displayed picture: ffmpeg turns an EXIF-rotated still upright on decode (probe reads
75
+ # the Orientation off the decoded frame), and non-square pixels are squared (#411)
76
+ sw, sh = display_size(meta["video"])
77
+ if square_pixels(meta["video"]):
78
+ note(square_pixels_note(meta["video"]))
72
79
  ratio = sw / sh
73
80
 
74
81
  if args.width and args.height:
@@ -114,8 +121,10 @@ def main() -> int:
114
121
  f"fps={args.fps:g}",
115
122
  ]
116
123
 
124
+ if square_pixels(meta["video"]):
125
+ vf.insert(0, square_pixels(meta["video"])) # the scale and crop above size the squared picture
117
126
  output = args.output or default_output(args.input, "insert", "mp4")
118
- cmd = ffmpeg_base() + ["-loop", "1", "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
127
+ cmd = ffmpeg_base() + ["-loop", "1", *still_input_args(args.input), "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
119
128
  # the SDR line, whatever the image is; its RGB (PNG) or BT.601 (JPEG) pixels are converted to
120
129
  # the BT.709 that line is tagged with
121
130
  cmd += video_args(None, args.crf, args.preset, source=meta)
package/scripts/join.py CHANGED
@@ -42,8 +42,10 @@ import sys
42
42
  from typing import Any, Dict, List, Optional, Tuple
43
43
 
44
44
  from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
45
- from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, place_output, refuse, sibling_temp, draws_chroma, drawn_chain, label_chain, matrix_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
46
- from _common.emit import warn
45
+ 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
46
+ from _common.emit import note, warn
47
+ from _common.decision import av_clip_length, av_trim, stream_starts
48
+ from _common.probe import display_size, square_pixels, square_pixels_note
47
49
 
48
50
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
49
51
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -103,16 +105,11 @@ def overlap_report(durs: List[float], expected: Optional[float]) -> Dict[str, An
103
105
 
104
106
  def clip_length(meta: Dict[str, Any], fps: float) -> float:
105
107
  """The one length a clip holds in a join, for its picture and its sound alike: the
106
- video stream's, or the audio stream's when the sound runs more than a frame past the picture
107
- (narration is never cut; an AAC tail adds no frame). The container duration (audio priming,
108
- a longer track) is only the fallback when a stream length is unknown (a pending input)."""
109
- v = (meta.get("video") or {}).get("duration")
110
- a = (meta.get("audio") or {}).get("duration")
111
- if not v:
112
- return meta.get("duration") or 0.0
113
- if a and fps and a > v + 1.0 / fps:
114
- return a
115
- return v
108
+ video stream's, or up to where the sound ends when it runs more than a frame past the picture
109
+ (narration is never cut; an AAC tail adds no frame), counted from the first frame. The container
110
+ duration (audio priming, a longer track) is only the fallback when a stream length is unknown
111
+ (a pending input). Shared with reverse.py as av_clip_length."""
112
+ return av_clip_length(meta, fps)
116
113
 
117
114
 
118
115
  def find_duplicates(paths: List[str]) -> List[Dict[str, Any]]:
@@ -655,9 +652,8 @@ def main() -> int:
655
652
  if args.copy:
656
653
  return join_copy(args, metas)
657
654
  first = metas[0]["video"]
658
- fw, fh = first["width"], first["height"]
659
- if first.get("rotation") in (90, -90, 270, -270):
660
- fw, fh = fh, fw
655
+ # the first clip's displayed frame: rotation applied, non-square pixels squared (#411)
656
+ fw, fh = display_size(first)
661
657
  if args.width and args.height:
662
658
  w, h = args.width, args.height
663
659
  elif args.width:
@@ -721,13 +717,22 @@ def main() -> int:
721
717
  chroma_pad = args.fit != "crop" and draws_chroma([args.pad_color], line)
722
718
  if any(convert) or chroma_pad:
723
719
  convert = [c or label_chain(m, line) for c, m in zip(convert, metas)]
720
+ # concat/xfade mix samples: a full-range clip (VP9/AV1 full range, 10-bit pc) beside a limited
721
+ # one is taken to limited range first, or it was shown with the other's range (#410)
722
+ convert = [c + range_chain(m, line) for c, m in zip(convert, metas)]
723
+
724
+ for p, m in zip(args.inputs, metas):
725
+ if square_pixels(m.get("video")):
726
+ note(square_pixels_note(m["video"], p))
724
727
 
725
728
  def geo(i: int) -> str:
729
+ # every clip is squared at its display aspect before it is fitted (#411)
730
+ sq = (square_pixels(metas[i].get("video")) + ",") if square_pixels(metas[i].get("video")) else ""
726
731
  if args.fit == "crop":
727
- return f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
732
+ return f"{sq}scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
728
733
  pad = f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color="
729
- return (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
730
- + drawn_chain(pad + args.pad_color, metas[i], [args.pad_color], pad + "black", line_meta=line,
734
+ return (f"{sq}scale={w}:{h}:force_original_aspect_ratio=decrease,"
735
+ + drawn_chain(pad + args.pad_color, as_limited(metas[i]), [args.pad_color], pad + "black", line_meta=line,
731
736
  tag=f"pad{i}", labelled=True))
732
737
  # the audio-only join keeps the widest layout; the video join used to force stereo and
733
738
  # silently dropped the centre/LFE of 5.1 material. A pending input's stub has no measured
@@ -741,7 +746,12 @@ def main() -> int:
741
746
  info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
742
747
  for i in range(n):
743
748
  parts.append(f"[{i}:v]{convert[i]}{geo(i)},setsar=1,setpts=PTS-STARTPTS,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
744
- parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
749
+ # the sound is shifted by the picture's start, not to its own (#409): #338 started each
750
+ # clip's picture at 0, and a per-stream PTS-STARTPTS then moved a sound that starts 0.3 s
751
+ # after its picture 0.3 s early. Sound before the first frame is dropped and a late sound
752
+ # is padded, as cut.py's re-cut does; a generated silent track starts at 0 already.
753
+ shift = (av_trim(stream_starts(metas[i])[0], None)[1] + ",") if metas[i].get("audio") else ""
754
+ parts.append(f"[{audio_src[i]}]{shift}aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
745
755
 
746
756
  # pad the shorter stream to the clip's length: the audio with silence, the picture by holding
747
757
  # its last frame, then cut both there -- each clip boundary is then the same for both chains
package/scripts/look.py CHANGED
@@ -17,10 +17,13 @@ from pathlib import Path
17
17
  from typing import List, Optional
18
18
 
19
19
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
20
+ from _common import still_input_args # noqa: E402
20
21
  from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_analysis, time_arg
21
22
  from _common import drawtext_fontfile # noqa: E402
22
23
  from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
23
24
  from _common.emit import note, record_fallback # noqa: E402
25
+ from _common.color import bt709_label_filter # noqa: E402
26
+ from _common.probe import display_size, square_pixels, square_pixels_note # noqa: E402
24
27
 
25
28
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
26
29
 
@@ -40,6 +43,12 @@ def timecode_filter(font_prefix: str, offset: float = 0.0) -> str:
40
43
  return f"drawtext=text='%{{pts\\:hms}}':{font_prefix}{FONT}"
41
44
 
42
45
 
46
+ def _half_height(half: int, size: "tuple") -> int:
47
+ """The even height a `half`-wide picture of displayed `size` scales to (16:9 when unknown)."""
48
+ w, h = size
49
+ return max(2, int(round(half * (h / w if w and h else 9 / 16) / 2)) * 2)
50
+
51
+
43
52
  def _png_size(png_path: str) -> "Optional[tuple]":
44
53
  # run_analysis(): the same wall-clock ceiling and #234 UTF-8 decoding as every other
45
54
  # ffprobe/ffmpeg measurement in this codebase, check=False since a probe failure here is
@@ -188,10 +197,7 @@ def main() -> int:
188
197
  if args.safe:
189
198
  z = PLATFORMS[args.safe]["safe"]
190
199
  frame = PLATFORMS[args.safe]["frame"]
191
- src = meta["video"]
192
- dw, dh = src.get("width") or 0, src.get("height") or 0
193
- if src.get("rotation") in (90, -90, 270, -270):
194
- dw, dh = dh, dw
200
+ dw, dh = display_size(meta["video"])
195
201
  if dw and dh and abs(dw / dh - frame["w"] / frame["h"]) > 0.02:
196
202
  info(f"--safe {args.safe}: this source is {dw}x{dh}, not {args.safe}'s "
197
203
  f"{frame['w']}x{frame['h']} -- the zones are drawn as fractions of the frame you gave, "
@@ -220,11 +226,25 @@ def main() -> int:
220
226
  tc = "," + tm.rstrip(",") + tc
221
227
  info("HDR source: frames are tone-mapped to SDR for display")
222
228
  outputs: List[str] = []
229
+ # An untagged SDR source is BT.709 (what players and every encoder line here take it as), but the
230
+ # conversion to the PNG's RGB reads "unknown" as BT.601: C03028 came out B31F28 (#410). Its
231
+ # frames are labelled first (tags only), so that conversion reads them as BT.709.
232
+ lab = bt709_label_filter(meta)
233
+ lab = lab + "," if lab else ""
234
+ # every picture is squared at its display aspect before it is scaled (#411): a DV or HDV
235
+ # frame's stored size is not the picture a player shows
236
+ square = square_pixels(meta["video"])
237
+ sq = square + "," if square else ""
238
+ if square:
239
+ note(square_pixels_note(meta["video"]))
223
240
 
224
241
  if args.compare:
225
242
  if not args.at:
226
243
  die("--compare needs --at TIME")
227
- probe(args.compare)
244
+ cmeta = probe(args.compare)
245
+ lab2 = bt709_label_filter(cmeta)
246
+ lab2 = lab2 + "," if lab2 else ""
247
+ csq = (square_pixels(cmeta.get("video")) + ",") if square_pixels(cmeta.get("video")) else ""
228
248
  for idx, t in enumerate(args.at):
229
249
  sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
230
250
  if args.output and len(args.at) == 1:
@@ -237,10 +257,17 @@ def main() -> int:
237
257
  half = args.width // 2
238
258
  stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
239
259
  tcs = tc.replace("," + timecode_filter(font_prefix), "") + stamp
240
- fc = (f"[0:v]scale={half}:-2{tcs}[a];[1:v]scale={half}:-2{tcs}[b];"
241
- f"[a][b]scale2ref=w=iw:h=ih[a2][b2];[a2][b2]hstack=inputs=2[out]")
242
- cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-ss", f"{sec:.3f}", "-i", args.compare,
243
- "-filter_complex", fc, "-map", "[out]", "-frames:v", "1", out]
260
+ # each picture keeps its own aspect inside a half-width box as tall as the taller of the
261
+ # two (letterboxed, not stretched): through 3.1.3 scale2ref forced the first to the
262
+ # second's size, so a 4:3 beside a 16:9 came out squashed (#411)
263
+ box_h = max(_half_height(half, display_size(meta["video"])),
264
+ _half_height(half, display_size(cmeta.get("video"))))
265
+ fit = (f"scale={half}:{box_h}:force_original_aspect_ratio=decrease,"
266
+ f"pad={half}:{box_h}:(ow-iw)/2:(oh-ih)/2:color=0x202020,setsar=1")
267
+ fc = (f"[0:v]{lab}{sq}{fit}{tcs}[a];[1:v]{lab2}{csq}{fit}{tcs}[b];"
268
+ f"[a][b]hstack=inputs=2[out]")
269
+ 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,
270
+ "-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
244
271
  run(cmd)
245
272
  outputs.append(out)
246
273
  elif args.at:
@@ -254,7 +281,7 @@ def main() -> int:
254
281
  # several frames, or -o given as a stem/prefix without an image extension
255
282
  out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
256
283
  stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
257
- cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", out]
284
+ 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]
258
285
  run(cmd)
259
286
  outputs.append(out)
260
287
  else:
@@ -277,11 +304,14 @@ def main() -> int:
277
304
  # sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
278
305
  # with only a handful of frames a mid-slice seek skips past them all: start at 0 instead
279
306
  seek = 0.0 if frames_avail < 4 else step / 2
280
- # the seek resets pts to 0, so the burned-in timecode gets the seek added back
281
- tc_sheet = tc.replace(timecode_filter(font_prefix), timecode_filter(font_prefix, seek)) if seek else tc
282
- vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc_sheet},"
307
+ # -copyts -start_at_zero keeps each frame's own timestamp through the seek, counted from
308
+ # the file's start, so the stamp is the frame's pts as it is. What an input -ss alone
309
+ # resets pts to differs by container: an MPEG-TS (which starts at 1.4 s) came out
310
+ # labelled one seek late when the seek was added back (#411)
311
+ tc_sheet = tc
312
+ vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',{lab}{sq}scale={tile_w}:-2{tc_sheet},"
283
313
  f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
284
- cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
314
+ 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]
285
315
  run(cmd)
286
316
  outputs.append(out)
287
317
  info(f"contact sheet: {n} frames every {step:.2f}s")
package/scripts/loop.py CHANGED
@@ -21,11 +21,15 @@ Examples:
21
21
  python3 loop.py wave.mp4 --boomerang --times 4
22
22
  """
23
23
  import argparse
24
+ import json
24
25
  import math
26
+ import os
25
27
  import sys
28
+ import tempfile
26
29
 
27
- from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
30
+ from _common import 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_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
28
31
  from _common.emit import warn
32
+ from _common.decision import av_trim
29
33
 
30
34
 
31
35
  # The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
@@ -95,6 +99,21 @@ def _nominal_rate(v) -> str:
95
99
  return f"{counted:g}" if 0 < counted <= MAX_PLAUSIBLE_FPS else "30"
96
100
 
97
101
 
102
+ def concat_first_frame(listfile: str) -> float:
103
+ """Where the first frame sits on the clock ffmpeg reads a concat list on: the list's first video
104
+ start minus its start_time. The concat demuxer counts an MP4's hidden AAC priming packet into its
105
+ start (-0.021 s) where the MP4 demuxer does not, so ffmpeg moves both streams 21 ms later, and a
106
+ frame-rate time base then rounded the picture's move to 33 ms (12 ms off). 0.0 when unmeasured."""
107
+ proc = run([require_tool("ffprobe"), "-v", "error", "-f", "concat", "-safe", "0", "-i", listfile,
108
+ "-select_streams", "v:0", "-show_entries", "stream=start_time:format=start_time", "-of", "json"],
109
+ quiet=True, check=False)
110
+ try:
111
+ doc = json.loads(proc.stdout or "")
112
+ return float(doc["streams"][0]["start_time"]) - float(doc["format"]["start_time"])
113
+ except (ValueError, KeyError, IndexError, TypeError):
114
+ return 0.0
115
+
116
+
98
117
  def main() -> int:
99
118
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
100
119
  ap.add_argument("input")
@@ -119,6 +138,8 @@ def main() -> int:
119
138
  has_audio = bool(meta.get("audio")) and not args.boomerang
120
139
  notes = []
121
140
  output = args.output or default_output(args.input, "loop")
141
+ # the concat list is the only -i, so run() never sees the source next to -o (#416 review)
142
+ refuse_output_is_input(output, args.input)
122
143
 
123
144
  if args.times is not None:
124
145
  if args.times < (1 if args.boomerang else 2):
@@ -137,10 +158,24 @@ def main() -> int:
137
158
  if meta.get("audio"):
138
159
  notes.append("audio dropped: a boomerang plays half its sound backwards -- add a bed with audio.py")
139
160
  else:
140
- # -stream_loop repeats the whole input read (video and audio together) at the demuxer level
141
- # -- exact and lossless-in-intent for a re-encode target, unlike a filter-graph loop that
142
- # would need separate video/audio filters kept in lockstep by hand.
143
- cmd = ffmpeg_base() + ["-stream_loop", str(stream_loop), "-i", args.input]
161
+ # The concat demuxer reads the clip once per repeat and starts each repeat at the previous
162
+ # one's start plus the file's duration (#409): both streams keep their offset from the
163
+ # file's start in every repeat. -stream_loop took the repeat's length from its streams'
164
+ # spans without the last video frame, so a sound that starts 0.3 s late shortened every
165
+ # repeat by a frame and slid against the picture (346 frames for 360, 68 ms off).
166
+ # Both streams start at the first frame, shifted by one constant (av_trim), and the gap a
167
+ # late sound leaves at each repeat's start is filled with silence, at any length.
168
+ workdir = tempfile.TemporaryDirectory(prefix="ffskill_loop_")
169
+ listfile = os.path.join(workdir.name, "loop.txt")
170
+ with open(listfile, "w", encoding="utf-8") as fh:
171
+ fh.write((concat_list_line(os.path.abspath(args.input)) + "\n") * (stream_loop + 1))
172
+ vtrim, atrim = av_trim(concat_first_frame(listfile), None, fill_gaps=True)
173
+ cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
174
+ if has_audio:
175
+ cmd += ["-filter_complex", f"[0:v:0]{vtrim}[v];[0:a:0]{atrim}[a]",
176
+ "-map", "[v]", "-map", "[a]"]
177
+ else:
178
+ cmd += ["-map", "0:v:0", "-vf", vtrim]
144
179
  if target is not None:
145
180
  cmd += ["-t", f"{target:.3f}"]
146
181
  cmd += video_args(meta, args.crf, args.preset)
@@ -150,7 +185,11 @@ def main() -> int:
150
185
  else:
151
186
  cmd += ["-an"]
152
187
  cmd.append(output)
153
- run(cmd)
188
+ try:
189
+ run(cmd)
190
+ finally:
191
+ if not args.boomerang:
192
+ workdir.cleanup()
154
193
 
155
194
  result = probe(output, role="output")
156
195
  v = result["video"]
@@ -31,8 +31,10 @@ import math
31
31
  import sys
32
32
  from typing import Dict, List, Tuple
33
33
 
34
- from _common import guard_sidecar, label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
35
- from _common.emit import record_fallback, warn
34
+ from _common import guard_sidecar, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
35
+ from _common.emit import note, record_fallback, warn
36
+ from _common.decision import av_trim
37
+ from _common.probe import display_size, square_pixels, square_pixels_note
36
38
  from sync import measure_offset
37
39
 
38
40
  ENERGY_WINDOW_S = 0.25 # --switch energy: loudness measured over this window on the reference timeline
@@ -263,14 +265,16 @@ def main() -> int:
263
265
  info(f"wrote {args.edl}")
264
266
 
265
267
  v0 = metas[0]["video"]
266
- w, h = args.width or v0["width"], args.height or v0["height"]
267
- if v0.get("rotation") in (90, -90, 270, -270) and not (args.width or args.height):
268
- w, h = h, w
268
+ # camera 0's displayed frame: rotation applied, non-square pixels squared (#411)
269
+ w, h = args.width or display_size(v0)[0], args.height or display_size(v0)[1]
269
270
  fps = args.fps or v0.get("fps") or 30.0
270
271
  fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
271
272
  pixfmt = "yuv420p10le" if v0.get("bt2020_or_hdr") else "yuv420p"
272
273
  geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps:g},format={pixfmt}"
273
274
 
275
+ for p, m in zip(args.inputs, metas):
276
+ if square_pixels(m.get("video")):
277
+ note(square_pixels_note(m["video"], p))
274
278
  cmd = ffmpeg_base()
275
279
  for p in args.inputs:
276
280
  cmd += ["-i", p]
@@ -283,6 +287,8 @@ def main() -> int:
283
287
  convert = [matrix_chain(m, metas[0]) for m in metas]
284
288
  if any(convert):
285
289
  convert = [c or label_chain(m, metas[0]) for c, m in zip(convert, metas)]
290
+ # concat mixes samples: a full-range camera beside a limited one is taken to limited range (#410)
291
+ convert = [c + range_chain(m, metas[0]) for c, m in zip(convert, metas)]
286
292
  # what is actually rendered: a range whose camera has not started yet plays camera 0, and the
287
293
  # result says so (`cuts` names the camera on screen, diagnostics.fallbacks the one asked for)
288
294
  rendered: List[Tuple[float, float, int]] = []
@@ -296,7 +302,8 @@ def main() -> int:
296
302
  requested_camera=c, camera=0, starts_at=round(offsets[c], 3))
297
303
  c, src_s, src_e = 0, s, e
298
304
  rendered.append((s, e, c))
299
- parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{convert[c]}{geo}[v{i}]")
305
+ sq = (square_pixels(metas[c]["video"]) + ",") if square_pixels(metas[c]["video"]) else ""
306
+ parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{convert[c]}{sq}{geo}[v{i}]")
300
307
  labels.append(f"[v{i}]")
301
308
  project_clips.append({"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)})
302
309
  parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
@@ -306,7 +313,9 @@ def main() -> int:
306
313
  # applied before asetrate/aresample rescale that axis -- otherwise the trim lands at the wrong
307
314
  # point once the stream's timebase has already been stretched/compressed by the drift ratio
308
315
  # (mirrors sync.py, which seeks with -ss, an input-level operation, before its drift_af filters).
309
- afx = [f"atrim=start={a_start:.4f}", "asetpts=PTS-STARTPTS"]
316
+ # shifted by a_start, not to its own first sample: a sound that starts after its file's start
317
+ # keeps that offset (padded with silence), as the offset was measured on the file's clock (#409)
318
+ afx = [av_trim(a_start, None)[1]]
310
319
  if abs(ratios[a] - 1.0) > 1e-7:
311
320
  sr = metas[a]["audio"].get("sample_rate") or 48000
312
321
  afx += [f"asetrate={sr * ratios[a]:.6f}", f"aresample={sr}"]