ffmpeg-skill 1.4.11 → 1.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -143,7 +143,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
143
143
 
144
144
  1. **Probe first.** No tool decides from the file name. `probe.py` measures duration, fps (with variable-frame-rate detection), resolution, rotation, bit depth, HDR format including Dolby Vision, colour tags and every audio stream before anything is cut.
145
145
  2. **Lossless when possible.** `cut.py`, `join.py` and `loudness.py` stream-copy what they do not need to touch. Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
146
- 3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
146
+ 3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever; Ctrl-C or SIGTERM likewise stops the running ffmpeg, removes its partial output and reports `kind: interrupted`) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
147
147
  4. **Machine-readable contract.** `contract --json` describes all 42 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 42 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
148
148
  5. **Contract-derived MCP.** `mcp/server.py` builds its `tools/list` from the contract. Tool names, order and `inputSchema` cannot drift from the scripts; a test keeps the two byte-identical.
149
149
  6. **Capability detection.** `doctor` reads `ffmpeg -encoders / -filters / -bsfs` and reports which of the components the tools need are present on this build (libx264, libass, zscale, loudnorm, xfade, …), before a job fails inside ffmpeg.
@@ -168,7 +168,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
168
168
  | Tool | What it does |
169
169
  |---|---|
170
170
  | `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio; reports `precision` |
171
- | `join.py` | Concatenate clips with xfade transitions, normalising size, fps and audio; audio-only inputs are joined as audio |
171
+ | `join.py` | Concatenate clips with xfade transitions, normalising size, fps, sample rate and channel layout (the widest clip's, or `--channels`); audio-only inputs are joined as audio |
172
172
  | `silence.py` | Detect and remove dead air (jump cuts) with a margin around speech; list or export the cut list |
173
173
  | `fit.py` | Fit to a duration (pitch-preserving speed change or trim, smooth slow-mo) and/or aspect ratio (pad or crop, with `--crop-x`/`--crop-y` to keep an off-centre subject) and/or exact `--width`/`--height`; rotate 90/180/270, flip h/v; force constant fps |
174
174
  | `crop.py` | Crop to an exact pixel rectangle (`--x --y --width --height`) — distinct from `fit.py --fit crop`, which crops to an aspect ratio it computes itself |
@@ -198,7 +198,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
198
198
  |---|---|
199
199
  | `audio.py` | Voice clean-up chain, FFT denoise, typed compressor / limiter / gate, music bed with sidechain ducking, fades, 5.1 → stereo, track replacement, extraction (`-o out.wav`), `--audio-stream N` |
200
200
  | `sync.py` | Offset between two recordings by audio cross-correlation (1 ms, pure Python), clock-drift correction; aligned video or audio out (audio-to-audio only — no lip-sync/face detection) |
201
- | `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP or any target, video stream-copied; `--measure-only` |
201
+ | `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP or any target, video stream-copied; the written file is measured again and re-encoded until it meets `--tp` (lossy encoders overshoot); `--measure-only` |
202
202
 
203
203
  **Picture**
204
204
 
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`1.4.11`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.4.12`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -83,7 +83,7 @@ on, the line says so.
83
83
  ```json
84
84
  {
85
85
  "contract_version": "1.0",
86
- "skill": {"id": "ffmpeg-skill", "version": "1.4.11", "execution_mode": "local", "kind": "execution",
86
+ "skill": {"id": "ffmpeg-skill", "version": "1.4.12", "execution_mode": "local", "kind": "execution",
87
87
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
88
88
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
89
89
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.4.11",
3
+ "version": "1.4.12",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -225,7 +225,8 @@ run time.
225
225
  sequence.py --dir DIR --pattern "frame_%04d.png"|"*.png" --fps N
226
226
  [--start-number N] [--width W] [--height H] [-o OUT]
227
227
  ```
228
- Turns a numbered or glob-matched set of still images into a video. The match
228
+ Turns a numbered or glob-matched set of still images into a video. Glob
229
+ matches are ordered naturally (`img2` before `img10`). The match
229
230
  is checked on disk before ffmpeg runs (an empty match or a missing first
230
231
  frame is refused here, not discovered from an opaque ffmpeg error).
231
232
 
@@ -251,7 +252,10 @@ how long the freeze lasts. `--mode insert` (default) inserts the hold at
251
252
  extend` only works with `--at` at (or past) the clip's end and just makes
252
253
  the last frame last `--hold` seconds longer, with nothing pushed. Audio is
253
254
  silent during the held frame in `--mode insert` (there is no source audio
254
- for a frozen moment that didn't exist before).
255
+ for a frozen moment that didn't exist before). `--mode insert` drops a
256
+ subtitle/data track rather than copy it with timestamps that no longer match
257
+ the pushed picture (`dropped_non_av_streams: true` in the result), as
258
+ `fit.py --method speed` does; `--mode extend` keeps it.
255
259
 
256
260
  ### pad.py — add black/silent padding at the start/end
257
261
  ```
@@ -260,7 +264,7 @@ pad.py INPUT [--start T] [--end T] [--color C] [-o OUT]
260
264
  Distinct from `fit.py --fit pad`, which pads the *frame* (letterbox/
261
265
  pillarbox bars around each existing frame) -- this pads the *timeline*:
262
266
  extra seconds of solid colour and silence before and/or after the clip's
263
- existing content. At least one of `--start`/`--end` must be > 0.
267
+ existing content (seconds or `mm:ss`). At least one of `--start`/`--end` must be > 0.
264
268
 
265
269
  ### speedramp.py — step through different speeds across a clip
266
270
  ```
@@ -272,7 +276,8 @@ pieces (repeatable) covering the clip start to end with no gaps or
272
276
  overlaps, each played at its own constant speed (pitch-preserving audio,
273
277
  matching `fit.py`), then concatenates them: "speed up, then slow way down
274
278
  for the punch, then speed back up," built from a few constant segments
275
- rather than a continuous curve. `FACTOR` is 0.05..20 (2.0 = twice as fast,
279
+ rather than a continuous curve. `START`/`END` take seconds or `mm:ss`;
280
+ `FACTOR` is 0.05..20 (2.0 = twice as fast,
276
281
  0.5 = half speed). Picking exactly where a ramp should ease in or out is a
277
282
  judgement call for the calling agent, made concrete here as the segment
278
283
  boundaries it supplies.
@@ -352,8 +357,9 @@ to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
352
357
  join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
353
358
  [--duration 0.5] [--width W --height H] [--fps N] [--fit pad|crop] [-o OUT]
354
359
  ```
355
- Normalises every clip to one frame size, fps, `yuv420p` and 48 kHz stereo
356
- (silent track generated for clips without audio), then chains `xfade` +
360
+ Normalises every clip to one frame size, fps, `yuv420p`, 48 kHz and one
361
+ channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
362
+ silent track generated for clips without audio), then chains `xfade` +
357
363
  `acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
358
364
  longer than 2 × the transition. Use `--transition none` for a plain cut.
359
365
 
@@ -529,7 +535,9 @@ sync.py REFERENCE SECOND [--json] [--max-offset 30] [--analyze-seconds 120] [--f
529
535
  Cross-correlates loudness envelopes: coarse FFT search (20 ms), then a direct
530
536
  1 ms refinement (pure Python, a 2-minute window takes ~1-3 s). Positive offset
531
537
  = the second recording started later. `--replace-audio` writes the reference
532
- video with the second file's audio aligned (video stream copied).
538
+ video with the second file's audio aligned (video stream copied); the output
539
+ keeps the reference's full length -- a shorter or head-trimmed second file is
540
+ padded with silence, never allowed to cut the picture.
533
541
  `--trim-second` writes the second file shifted to the reference timeline.
534
542
  `--fix-drift` measures the offset again near the end of the overlap, reports
535
543
  the clock difference in ppm, and resamples the second file so a 60-minute
@@ -586,7 +594,9 @@ audio.py INPUT [--voice | --denoise [--denoise-strength 25]] [--gain dB]
586
594
  `--voice` = highpass 80 Hz → de-esser → FFT denoise → gentle compressor, the
587
595
  standard talking-head chain. `--duck` uses a sidechain compressor keyed by the
588
596
  speech so music dips under dialogue and swells in pauses. `--downmix` uses the
589
- ITU centre/LFE weights for 5.1/7.1 → stereo. Video is always stream-copied.
597
+ ITU centre/LFE weights for 5.1/7.1 → stereo. `--mono` averages a stereo pair,
598
+ leaves a 1-channel input untouched and downmixes >2 channels through
599
+ swresample. Video is always stream-copied.
590
600
  Run `loudness.py` after this for final levels.
591
601
 
592
602
  ### loudness.py — EBU R128 normalisation
@@ -43,12 +43,11 @@ INSTALL_HINTS = {
43
43
  }
44
44
 
45
45
 
46
- # `kind` (below) has been the only machine-readable failure axis since 0.1: a flat, 4-value
47
- # vocabulary (input / missing_tool / ffmpeg / output) set at the ~7 call sites that ever pass one
48
- # explicitly, defaulting to "input" everywhere else. `ERROR_CODE` is an additive, purely
49
- # informational refinement layered on top for agents that want a stable enum to switch on instead
50
- # of pattern-matching `kind` strings -- it is a static 1:1 relabelling of the exact same 4 buckets,
51
- # not a new taxonomy. It intentionally does NOT introduce categories this codebase cannot actually
46
+ # `kind` (below) is the machine-readable failure axis: input / missing_tool / ffmpeg / output
47
+ # since 0.1, plus timeout (1.3), verification (1.4.3) and interrupted (1.4.10). `ERROR_CODE` is an
48
+ # additive, purely informational refinement layered on top for agents that want a stable enum to
49
+ # switch on instead of pattern-matching `kind` strings -- a static 1:1 relabelling of the same
50
+ # buckets, not a new taxonomy. It intentionally does NOT introduce categories this codebase cannot actually
52
51
  # distinguish today (e.g. a separate ffprobe-vs-ffmpeg code, or an environment-vs-content-cause
53
52
  # split of ffmpeg failures): every ffmpeg subprocess failure is currently one undifferentiated
54
53
  # bucket regardless of whether ffmpeg rejected a bad filter argument or died from a full disk,
@@ -113,6 +112,17 @@ def ffmpeg_version() -> "Tuple[int, int]":
113
112
  m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
114
113
  if m:
115
114
  _FFMPEG_VERSION = (int(m.group(1)), int(m.group(2)))
115
+ else:
116
+ # git / vendor builds print "N-115000-g..." or a date, never major.minor; the
117
+ # libavutil major is still there and maps one-to-one onto the FFmpeg major
118
+ # (56=4, 57=5, 58=6, 59=7, 60=8). Without this every version branch took the
119
+ # oldest spelling on such builds: on 7.1 that skipped bt709_tag_args()'s
120
+ # workaround and an untagged source got a real matrix conversion.
121
+ m = re.search(r"^libavutil\s+(\d+)\.", out, re.M)
122
+ if m:
123
+ major = int(m.group(1)) - 52
124
+ if major >= 4:
125
+ _FFMPEG_VERSION = (major, 0)
116
126
  except (OSError, subprocess.TimeoutExpired):
117
127
  # (0, 0) = unknown: every version branch then takes the older, universally accepted
118
128
  # spelling, the same "unknown is not missing" stance doctor takes.
@@ -139,7 +149,9 @@ def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int) -> str
139
149
  then boxblur'ed, the other scaled to fit, overlaid centred. Only `filter:boxblur` is
140
150
  needed beyond the usual scale/pad set, and that is already required by redact.py."""
141
151
  if fill == "blur":
142
- radius = max(1, int(blur))
152
+ # boxblur rejects a radius larger than half the smaller dimension ("radius 20, must be
153
+ # <= 8" on a 16 px target); clamp instead of failing an otherwise valid request
154
+ radius = max(1, min(int(blur), max(1, min(out_w, out_h) // 2 - 1)))
143
155
  return (f"split[__fitfg][__fitbg];"
144
156
  f"[__fitbg]scale={out_w}:{out_h}:force_original_aspect_ratio=increase,crop={out_w}:{out_h},"
145
157
  f"boxblur={radius}:2[__fitbgb];"
@@ -839,7 +851,8 @@ def ffmpeg_base(overwrite: bool = True) -> List[str]:
839
851
  return cmd
840
852
 
841
853
 
842
- MEDIA_EXT = {".mp4", ".mov", ".mkv", ".webm", ".m4v", ".avi", ".ts", ".mts", ".gif", ".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".png", ".jpg", ".jpeg"}
854
+ MEDIA_EXT = {".mp4", ".mov", ".mkv", ".webm", ".m4v", ".avi", ".ts", ".mts", ".m2ts", ".mxf", ".3gp", ".wmv", ".gif",
855
+ ".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".aif", ".aiff", ".caf", ".wma", ".png", ".jpg", ".jpeg", ".webp"}
843
856
 
844
857
 
845
858
  def _output_failed(path: str, why: str) -> "None":
@@ -902,7 +915,12 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
902
915
  if role == "output":
903
916
  _output_failed(path, f"ffprobe cannot read it:\n{proc.stderr.strip()}")
904
917
  die(f"ffprobe failed on {path}:\n{proc.stderr.strip()}")
905
- raw = json.loads(proc.stdout or "{}")
918
+ try:
919
+ raw = json.loads(proc.stdout or "{}")
920
+ except ValueError as e:
921
+ if role == "output":
922
+ _output_failed(path, f"ffprobe printed unreadable JSON: {e}")
923
+ die(f"ffprobe printed unreadable JSON for {path}: {e}", kind="ffmpeg")
906
924
  fmt = raw.get("format", {})
907
925
  streams = raw.get("streams", [])
908
926
  video = next((s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0), None)
@@ -981,7 +999,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
981
999
  "avg_frame_rate": video.get("avg_frame_rate"),
982
1000
  "variable_frame_rate_suspected": vfr,
983
1001
  "pix_fmt": video.get("pix_fmt"),
984
- "bit_depth": 10 if "10" in pix else (12 if "12" in pix else 8),
1002
+ "bit_depth": _bit_depth(pix),
985
1003
  "hdr": hdr,
986
1004
  "hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
987
1005
  "HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
@@ -1037,6 +1055,53 @@ def concat_list_line(path: str) -> str:
1037
1055
  return f"file '{escaped}'"
1038
1056
 
1039
1057
 
1058
+ def _bit_depth(pix_fmt: Optional[str]) -> int:
1059
+ """Bits per component from a pixel format name. `"10" in pix` used to read yuv410p (4:1:0
1060
+ chroma) as 10-bit; the depth is the number that ends the name (before an le/be suffix):
1061
+ yuv420p10le -> 10, gbrp12be -> 12, gray16le -> 16, yuv410p / yuv420p / rgb24 -> 8."""
1062
+ m = re.search(r"(\d{1,2})(?:le|be)?$", pix_fmt or "")
1063
+ if not m:
1064
+ return 8
1065
+ n = int(m.group(1))
1066
+ if n in (24, 32): # packed 8-bit rgb24/bgr32/rgb0 etc.
1067
+ return 8
1068
+ if n in (48, 64): # packed 16-bit rgb48/rgba64
1069
+ return 16
1070
+ return n if 8 <= n <= 16 else 8
1071
+
1072
+
1073
+ def fmt_secs(value: Optional[float]) -> str:
1074
+ """`12.345s`, or `?s` when the probe had no duration (MPEG-TS without a duration tag, a
1075
+ stream whose container and streams all omit it). Every writing tool prints the duration
1076
+ of what it wrote; formatting None with :.3f used to raise TypeError after a successful
1077
+ encode, in 25+ scripts."""
1078
+ return "?s" if value is None else f"{value:.3f}s"
1079
+
1080
+
1081
+ def place_output(src: str, dst: str) -> None:
1082
+ """Deliver an already-rendered file to `dst` under the same rules as an ffmpeg output:
1083
+ the path is checked, an existing file is only replaced through a sibling temp so a
1084
+ failed copy never costs the caller what was there, and the result is remembered as ours.
1085
+ render.py's final `copyfile()` used to bypass all three."""
1086
+ import shutil
1087
+ cmd = ["ffmpeg", dst]
1088
+ _check_output_path(cmd)
1089
+ _check_existing_output(cmd)
1090
+ d, base = os.path.split(dst)
1091
+ stem, ext = os.path.splitext(base)
1092
+ tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
1093
+ try:
1094
+ shutil.copyfile(src, tmp)
1095
+ os.replace(tmp, dst)
1096
+ except OSError as e:
1097
+ try:
1098
+ os.remove(tmp)
1099
+ except OSError:
1100
+ pass
1101
+ die(f"could not place {dst}: {e}", kind="output")
1102
+ _remember_output(cmd)
1103
+
1104
+
1040
1105
  def parse_time(value: str, fps: Optional[float] = None) -> float:
1041
1106
  """Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
1042
1107
  or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
@@ -1468,7 +1533,7 @@ def color_hex(value: str) -> str:
1468
1533
  return v.upper()
1469
1534
 
1470
1535
 
1471
- _COLOR_TOKEN_RE = re.compile(r"^(0[xX][0-9A-Fa-f]{6,8}|#[0-9A-Fa-f]{6,8}|[A-Za-z][A-Za-z0-9]*)(@[0-9.]+)?$")
1536
+ _COLOR_TOKEN_RE = re.compile(r"^(0[xX][0-9A-Fa-f]{6,8}|#[0-9A-Fa-f]{6,8}|[A-Za-z][A-Za-z0-9]*)(@(?:0(?:\.\d+)?|1(?:\.0+)?|\.\d+))?$") # alpha is 0..1; "red@2" used to reach ffmpeg
1472
1537
 
1473
1538
 
1474
1539
  def validate_color(value: str, flag: str = "--color") -> str:
package/scripts/audio.py CHANGED
@@ -20,7 +20,7 @@ import argparse
20
20
  import sys
21
21
  from typing import List
22
22
 
23
- from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run
23
+ from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, fmt_secs
24
24
 
25
25
  VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
26
26
 
@@ -164,7 +164,12 @@ def main() -> int:
164
164
  if args.limit:
165
165
  fx.append(dynamics_filter("alimiter", args))
166
166
  if args.mono:
167
- fx.append("pan=mono|c0=0.5*c0+0.5*c1")
167
+ in_ch = (meta.get("audio") or {}).get("channels") or 2
168
+ if in_ch == 2:
169
+ fx.append("pan=mono|c0=0.5*c0+0.5*c1")
170
+ elif in_ch > 2:
171
+ fx.append("aformat=channel_layouts=mono") # swresample's standard downmix (centre/LFE weighted)
172
+ # 1 channel: already mono; the stereo pan used to halve it (-6 dB) because c1 was silence
168
173
  elif args.stereo:
169
174
  fx.append("aformat=channel_layouts=stereo")
170
175
 
@@ -227,7 +232,7 @@ def main() -> int:
227
232
  a = r["audio"]
228
233
  if r.get("video") and audio_out and not STATE.dry_run:
229
234
  die(f"{output} unexpectedly contains a video stream")
230
- info(f"wrote {output} ({r['duration']:.3f}s, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz"
235
+ info(f"wrote {output} ({fmt_secs(r['duration'])}, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz"
231
236
  + (", video stream-copied" if has_video and not audio_out else ", video dropped" if has_video else "") + ")")
232
237
  emit(output, video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
233
238
  dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f])
@@ -14,7 +14,7 @@ import argparse
14
14
  import math
15
15
  import sys
16
16
 
17
- from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS, time_arg
17
+ from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
18
18
 
19
19
 
20
20
  def main() -> int:
@@ -73,7 +73,7 @@ def main() -> int:
73
73
 
74
74
  result = probe(output, role="output")
75
75
  v = result["video"]
76
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['fps']:g}fps)")
76
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['fps']:g}fps)")
77
77
  emit(output)
78
78
  return 0
79
79
 
package/scripts/batch.py CHANGED
@@ -31,11 +31,10 @@ import time
31
31
  from pathlib import Path
32
32
  from typing import Any, Dict, List
33
33
 
34
- from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, read_text_or_die
34
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, read_text_or_die, MEDIA_EXT as _MEDIA_EXT
35
35
 
36
36
  HERE = Path(__file__).resolve().parent
37
- MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".ts", ".gif",
38
- ".wav", ".m4a", ".mp3", ".flac", ".aac", ".ogg", ".opus", ".aif", ".aiff"}
37
+ MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
39
38
  # recipe steps name the script to run as plain, untrusted JSON -- run_step() joins it onto HERE
40
39
  # with the `/` operator, which silently ignores the left side when the right side is itself an
41
40
  # absolute path (Path("/scripts") / "/tmp/evil.py" == Path("/tmp/evil.py")), and does nothing to
package/scripts/broll.py CHANGED
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import Any, Dict, List
23
23
 
24
- from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS
24
+ from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
25
25
 
26
26
 
27
27
  def main() -> int:
@@ -143,7 +143,7 @@ def main() -> int:
143
143
 
144
144
  result = probe(output, role="output")
145
145
  if not STATE.dry_run and dur_a and abs((result.get("duration") or 0.0) - dur_a) > max(0.1, 1.5 / fps):
146
- die(f"output is {result.get('duration'):.3f}s but the A-roll is {dur_a:.3f}s -- a cutaway must not change the length", kind="output")
146
+ die(f"output is {fmt_secs(result.get('duration'))} but the A-roll is {dur_a:.3f}s -- a cutaway must not change the length", kind="output")
147
147
  info(f"wrote {output} ({result.get('duration', 0):.3f}s, {len(cutaways)} cutaway(s), audio={args.audio})")
148
148
  emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio)
149
149
  return 0
@@ -36,7 +36,7 @@ import sys
36
36
  from pathlib import Path
37
37
  from typing import List, Optional, Tuple
38
38
 
39
- from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die
39
+ from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs
40
40
 
41
41
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
42
42
 
@@ -231,15 +231,14 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
231
231
  Falls back to an even split when the window is silent or too short.
232
232
  """
233
233
  import struct
234
- import subprocess as sp
235
- from _common import require_tool
234
+ from _common import require_tool, run_analysis
236
235
  total_cs = max(1, int(round((end - start) * 100)))
237
236
  if n_words <= 1:
238
237
  return [total_cs]
239
238
  ffmpeg = require_tool("ffmpeg")
240
239
  cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", video,
241
240
  "-map", f"0:a:{audio_stream}", "-t", f"{end - start:.3f}", "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
242
- proc = sp.run(cmd, stdout=sp.PIPE, stderr=sp.PIPE)
241
+ proc = run_analysis(cmd, check=False, text=False) # under --timeout like every other measurement
243
242
  n = len(proc.stdout) // 2
244
243
  if proc.returncode != 0 or n < 800:
245
244
  per = total_cs // n_words
@@ -274,10 +273,23 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
274
273
  out.append(cs)
275
274
  prev = b
276
275
  out.append(max(5, total_cs - sum(out)))
277
- # normalise to the exact cue length
276
+ # normalise to the exact cue length. A cue too short for 5 cs per word (0.5 s, 20 words)
277
+ # used to push the remainder into the last word as a negative \kf; split evenly instead,
278
+ # and never let the last word go below 1 cs.
279
+ if total_cs < 5 * n_words:
280
+ per = max(1, total_cs // n_words)
281
+ out = [per] * (n_words - 1) + [max(1, total_cs - per * (n_words - 1))]
282
+ return out
278
283
  scale = total_cs / max(1, sum(out))
279
284
  out = [max(5, int(round(x * scale))) for x in out]
280
285
  out[-1] += total_cs - sum(out)
286
+ while out[-1] < 1:
287
+ i = max(range(len(out) - 1), key=lambda j: out[j])
288
+ take = min(out[i] - 5, 1 - out[-1]) if out[i] > 5 else 0
289
+ if take <= 0:
290
+ break
291
+ out[i] -= take
292
+ out[-1] += take
281
293
  return out
282
294
 
283
295
 
@@ -524,7 +536,7 @@ def main() -> int:
524
536
  cmd += [output]
525
537
  run(cmd)
526
538
  result = probe(output, role="output")
527
- info(f"wrote {output} ({result.get('duration'):.3f}s, mux, subtitle codec {codec})")
539
+ info(f"wrote {output} ({fmt_secs(result.get('duration'))}, mux, subtitle codec {codec})")
528
540
  emit(output)
529
541
  return 0
530
542
 
@@ -576,7 +588,7 @@ def main() -> int:
576
588
  cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
577
589
  run(cmd)
578
590
  result = probe(output, role="output")
579
- info(f"wrote {output} ({result.get('duration'):.3f}s)")
591
+ info(f"wrote {output} ({fmt_secs(result.get('duration'))})")
580
592
  emit(output)
581
593
  return 0
582
594
 
package/scripts/color.py CHANGED
@@ -22,7 +22,7 @@ import os
22
22
  import sys
23
23
  from typing import List
24
24
 
25
- from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args, X264_PRESETS
25
+ from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
26
26
 
27
27
  TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
28
28
 
@@ -315,7 +315,7 @@ def main() -> int:
315
315
  cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else [])
316
316
  dropped_streams = run_keeping_subtitles(cmd, output)
317
317
  r = probe(output, role="output")
318
- info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
318
+ info(f"wrote {output} ({fmt_secs(r['duration'])}, {r['video']['width']}x{r['video']['height']}, "
319
319
  f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
320
320
  extra = {"dropped_non_av_streams": dropped_streams}
321
321
  if measurements is not None:
package/scripts/crop.py CHANGED
@@ -21,7 +21,7 @@ Examples:
21
21
  import argparse
22
22
  import sys
23
23
 
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
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
 
26
26
 
27
27
  def main() -> int:
@@ -72,7 +72,7 @@ def main() -> int:
72
72
 
73
73
  result = probe(output, role="output")
74
74
  v = result["video"]
75
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']})")
75
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']})")
76
76
  emit(output)
77
77
  return 0
78
78
 
package/scripts/cut.py CHANGED
@@ -30,7 +30,7 @@ import sys
30
30
  import tempfile
31
31
  from typing import List, Tuple
32
32
 
33
- from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input
33
+ from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input, fmt_secs
34
34
 
35
35
  # keyframe timestamps found next to a requested cut that the tolerance turned into a re-encode
36
36
  # (reported so the caller can choose a lossless cut at one of them next time)
@@ -56,6 +56,8 @@ def parse_segments(spec: str, fps=None) -> List[Tuple[float, float]]:
56
56
  die(f"segment '{raw}' must look like START-END (e.g. 0:05-0:12)")
57
57
  a, b = raw.rsplit("-", 1)
58
58
  start, end = _t(a, fps), _t(b, fps)
59
+ if start < 0:
60
+ die(f"segment '{raw}': start must be >= 0")
59
61
  if end <= start:
60
62
  die(f"segment '{raw}': end must be after start")
61
63
  segs.append((start, end))
@@ -225,7 +227,7 @@ def main() -> int:
225
227
  # lossless but the keyframe snap exceeded --tolerance so this segment silently re-encoded instead)
226
228
  mode = "copy" if not reencoded else ("accurate" if args.accurate else "hybrid")
227
229
  keyframe_snapped = precision == "packet"
228
- info(f"wrote {output} ({got:.3f}s, expected ~{expected:.3f}s, "
230
+ info(f"wrote {output} ({fmt_secs(got)}, expected ~{expected:.3f}s, "
229
231
  + ("re-encoded" if reencoded else "lossless stream copy") + f", {precision} precision)")
230
232
  emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
231
233
  requested_start=round(segments[0][0], 6) if len(segments) == 1 else None,
@@ -22,7 +22,7 @@ Examples:
22
22
  import argparse
23
23
  import sys
24
24
 
25
- 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
25
+ 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
26
26
 
27
27
  MODES = {"frame": 0, "field": 1}
28
28
  PARITIES = {"auto": -1, "tff": 0, "bff": 1}
@@ -76,7 +76,7 @@ def main() -> int:
76
76
 
77
77
  result = probe(output, role="output")
78
78
  v = result["video"]
79
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['fps']:g}fps, mode={args.mode})")
79
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['fps']:g}fps, mode={args.mode})")
80
80
  emit(output, dropped_non_av_streams=dropped_streams)
81
81
  return 0
82
82
 
@@ -18,7 +18,7 @@ Examples:
18
18
  import argparse
19
19
  import sys
20
20
 
21
- 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
21
+ 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
22
22
 
23
23
  # hqdn3d's own AVOptions default to 0 (off); these tested presets are the light/medium/heavy
24
24
  # starting points its own documentation and common usage recommend (spatial then temporal,
@@ -85,7 +85,7 @@ def main() -> int:
85
85
 
86
86
  result = probe(output, role="output")
87
87
  v = result["video"]
88
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, strength={args.strength})")
88
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, strength={args.strength})")
89
89
  emit(output, dropped_non_av_streams=dropped_streams)
90
90
  return 0
91
91
 
package/scripts/export.py CHANGED
@@ -26,7 +26,7 @@ import sys
26
26
  from pathlib import Path
27
27
  from typing import Dict, List
28
28
 
29
- from _common import STATE, add_common, apply_common, bt709_tag_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color, pad_filters, add_pad_fill_args
29
+ from _common import STATE, add_common, apply_common, bt709_tag_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color, pad_filters, add_pad_fill_args, fmt_secs
30
30
  PRESETS: Dict[str, Dict] = {
31
31
  "youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
32
32
  "youtube4k": {"w": 3840, "h": 2160, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "2160p H.264, AAC 192k"},
@@ -120,7 +120,7 @@ def main() -> int:
120
120
  run(cmd)
121
121
  result = probe(output, role="output")
122
122
  v = result["video"]
123
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['codec']})")
123
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
124
124
  emit(output)
125
125
  return 0
126
126
 
package/scripts/fit.py CHANGED
@@ -38,7 +38,7 @@ import sys
38
38
  from fractions import Fraction
39
39
  from typing import List
40
40
 
41
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg
41
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs
42
42
  ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
43
43
 
44
44
 
@@ -251,7 +251,7 @@ def main() -> int:
251
251
  dropped_streams = run_keeping_subtitles(cmd, output)
252
252
 
253
253
  result = probe(output, role="output")
254
- msg = f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})"
254
+ msg = f"wrote {output} ({fmt_secs(result['duration'])}, {result['video']['width']}x{result['video']['height']})"
255
255
  if abs(factor - 1.0) > 1e-4:
256
256
  msg += f", speed {factor:.3f}x"
257
257
  info(msg)
package/scripts/freeze.py CHANGED
@@ -19,7 +19,7 @@ Examples:
19
19
  import argparse
20
20
  import sys
21
21
 
22
- 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, MissingFpsError, parse_time
22
+ 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, MissingFpsError, parse_time, fmt_secs, run
23
23
 
24
24
 
25
25
  def main() -> int:
@@ -101,11 +101,18 @@ def main() -> int:
101
101
  cmd += aac_args()
102
102
  else:
103
103
  cmd += ["-an"]
104
- dropped_streams = run_keeping_subtitles(cmd, output)
104
+ if args.mode == "insert":
105
+ # the picture gains `hold` seconds at `at`; a stream-copied subtitle track would keep its
106
+ # old timestamps and every cue after the freeze would fire early (same reasoning as
107
+ # fit.py --method speed). Drop it rather than ship captions that lie.
108
+ run(cmd + [output])
109
+ dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
110
+ else:
111
+ dropped_streams = run_keeping_subtitles(cmd, output)
105
112
 
106
113
  result = probe(output, role="output")
107
114
  v = result["video"]
108
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, froze {args.hold:g}s at {at:g}s, mode={args.mode})")
115
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, froze {args.hold:g}s at {at:g}s, mode={args.mode})")
109
116
  emit(output, dropped_non_av_streams=dropped_streams)
110
117
  return 0
111
118
 
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import List, Optional
23
23
 
24
- from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS, time_arg
24
+ from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS, time_arg, fmt_secs
25
25
 
26
26
  TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
27
27
 
@@ -169,7 +169,7 @@ def main() -> int:
169
169
  cmd += aac_args() if meta.get("audio") else ["-an"]
170
170
  dropped_streams = run_keeping_subtitles(cmd, output)
171
171
  r = probe(output, role="output")
172
- info(f"wrote {output} ({r['duration']:.3f}s, {args.template})")
172
+ info(f"wrote {output} ({fmt_secs(r['duration'])}, {args.template})")
173
173
  emit(output, template=args.template, dropped_non_av_streams=dropped_streams)
174
174
  return 0
175
175
 
package/scripts/grid.py CHANGED
@@ -25,7 +25,7 @@ import argparse
25
25
  import os
26
26
  import sys
27
27
 
28
- from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, X264_PRESETS, \
28
+ from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, X264_PRESETS, fmt_secs, \
29
29
  escape_drawtext, escape_filter_path, ffmpeg_base, info, probe, run, validate_color, video_args
30
30
 
31
31
  LABEL_MARGIN = 10
@@ -137,7 +137,7 @@ def main() -> int:
137
137
 
138
138
  result = probe(output, role="output")
139
139
  v = result["video"]
140
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {args.cols}x{args.rows} grid, "
140
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {args.cols}x{args.rows} grid, "
141
141
  f"{n} clips, {'padded to longest' if args.pad else 'stopped at shortest'})")
142
142
  emit(output, cols=args.cols, rows=args.rows, clips=n)
143
143
  return 0
package/scripts/insert.py CHANGED
@@ -27,7 +27,7 @@ import argparse
27
27
  import math
28
28
  import sys
29
29
 
30
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg
30
+ 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
31
31
 
32
32
 
33
33
  def even(n: float) -> int:
@@ -119,7 +119,7 @@ def main() -> int:
119
119
 
120
120
  result = probe(output, role="output")
121
121
  v = result["video"]
122
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['fps']:g}fps)")
122
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['fps']:g}fps)")
123
123
  emit(output)
124
124
  return 0
125
125
 
package/scripts/join.py CHANGED
@@ -23,7 +23,7 @@ import argparse
23
23
  import sys
24
24
  from typing import List
25
25
 
26
- from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, validate_color, X264_PRESETS
26
+ from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, validate_color, X264_PRESETS, fmt_secs
27
27
 
28
28
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
29
29
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -75,7 +75,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
75
75
  die(f"{output} unexpectedly contains a video stream")
76
76
  if a.get("sample_rate") != rate or a.get("channels") != channels:
77
77
  die(f"{output} is {a.get('sample_rate')} Hz {a.get('channels')} ch, expected {rate} Hz {channels} ch")
78
- info(f"wrote {output} ({r['duration']:.3f}s, expected ~{expected:.3f}s, audio {a.get('codec')} {channels}ch {rate}Hz, {n} clips, "
78
+ info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, audio {a.get('codec')} {channels}ch {rate}Hz, {n} clips, "
79
79
  + ("crossfade" if d else "butt join") + ")")
80
80
  emit(output, mode="audio", clips=n, transition=args.transition if d else "none", expected_duration=round(expected, 3),
81
81
  sample_rate=rate, channels=channels, video=False)
@@ -172,9 +172,18 @@ def main() -> int:
172
172
  # first clip used to drag an HDR second clip down to 8-bit without a tone map.
173
173
  hdr_meta = next((m for m in metas if (m.get("video") or {}).get("hdr")), None)
174
174
  pixfmt = "yuv420p10le" if hdr_meta else "yuv420p"
175
+ # the audio-only join keeps the widest layout; the video join used to force stereo and
176
+ # silently dropped the centre/LFE of 5.1 material
177
+ chans = [(m.get("audio") or {}).get("channels") or 2 for m in metas]
178
+ channels = args.channels or max(chans)
179
+ layout = LAYOUTS.get(channels)
180
+ if layout is None:
181
+ die(f"{channels}-channel output has no standard layout here (1, 2, 6 or 8); pass --channels")
182
+ if len(set(chans)) > 1:
183
+ info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
175
184
  for i in range(n):
176
185
  parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
177
- parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts=stereo,asetpts=PTS-STARTPTS[a{i}]")
186
+ parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
178
187
 
179
188
  if args.transition == "none":
180
189
  chain = "".join(f"[v{i}][a{i}]" for i in range(n))
@@ -196,7 +205,7 @@ def main() -> int:
196
205
  run(cmd)
197
206
  expected = sum(durs) - d * (n - 1)
198
207
  r = probe(output, role="output")
199
- info(f"wrote {output} ({r['duration']:.3f}s, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
208
+ info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
200
209
  emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3))
201
210
  return 0
202
211
 
package/scripts/loop.py CHANGED
@@ -18,7 +18,7 @@ import argparse
18
18
  import math
19
19
  import sys
20
20
 
21
- 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
21
+ 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
22
22
 
23
23
 
24
24
  def main() -> int:
@@ -71,7 +71,7 @@ def main() -> int:
71
71
 
72
72
  result = probe(output, role="output")
73
73
  v = result["video"]
74
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, source {src_dur:.3f}s looped)")
74
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, source {src_dur:.3f}s looped)")
75
75
  emit(output)
76
76
  return 0
77
77
 
@@ -29,7 +29,7 @@ import argparse
29
29
  import sys
30
30
  from typing import List, Tuple
31
31
 
32
- from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args, X264_PRESETS
32
+ from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args, X264_PRESETS, fmt_secs
33
33
  from sync import measure_offset
34
34
 
35
35
 
@@ -203,10 +203,10 @@ def main() -> int:
203
203
 
204
204
  output = args.output or default_output(args.inputs[0], "multicam", "mp4")
205
205
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
206
- cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + ["-shortest", output]
206
+ cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + ["-t", f"{ref_dur:.3f}", output]
207
207
  run(cmd)
208
208
  r = probe(output, role="output")
209
- info(f"wrote {output} ({r['duration']:.3f}s, {len(filled)} cuts, audio from input {a})")
209
+ info(f"wrote {output} ({fmt_secs(r['duration'])}, {len(filled)} cuts, audio from input {a})")
210
210
  emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in filled], **report)
211
211
  return 0
212
212
 
@@ -24,7 +24,7 @@ import argparse
24
24
  import sys
25
25
  from typing import List, Optional
26
26
 
27
- from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg
27
+ from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
28
28
 
29
29
  POS = {
30
30
  "top-left": ("{m}", "{m}"),
@@ -267,7 +267,7 @@ def main() -> int:
267
267
  dropped_streams = run_keeping_subtitles(cmd, output)
268
268
  if not STATE.dry_run:
269
269
  result = probe(output, role="output")
270
- info(f"wrote {output} ({result['duration']:.3f}s)")
270
+ info(f"wrote {output} ({fmt_secs(result['duration'])})")
271
271
  emit(output, dropped_non_av_streams=dropped_streams)
272
272
  return 0
273
273
 
package/scripts/pad.py CHANGED
@@ -16,7 +16,7 @@ Examples:
16
16
  import argparse
17
17
  import sys
18
18
 
19
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg
19
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
20
20
 
21
21
 
22
22
  def main() -> int:
@@ -61,7 +61,7 @@ def main() -> int:
61
61
 
62
62
  result = probe(output, role="output")
63
63
  v = result["video"]
64
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, +{args.start:g}s start / +{args.end:g}s end)")
64
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, +{args.start:g}s start / +{args.end:g}s end)")
65
65
  emit(output, dropped_non_av_streams=dropped_streams)
66
66
  return 0
67
67
 
package/scripts/proxy.py CHANGED
@@ -25,7 +25,7 @@ Examples:
25
25
  import argparse
26
26
  import sys
27
27
 
28
- from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
28
+ from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, fmt_secs
29
29
 
30
30
 
31
31
  def even(n: float) -> int:
@@ -73,7 +73,7 @@ def main() -> int:
73
73
 
74
74
  result = probe(output)
75
75
  v = result["video"]
76
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['codec']}, crf {args.crf})")
76
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']}, crf {args.crf})")
77
77
  emit(output)
78
78
  return 0
79
79
 
package/scripts/redact.py CHANGED
@@ -20,7 +20,7 @@ Examples:
20
20
  import argparse
21
21
  import sys
22
22
 
23
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
23
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
24
24
 
25
25
 
26
26
  def main() -> int:
@@ -91,7 +91,7 @@ def main() -> int:
91
91
 
92
92
  result = probe(output, role="output")
93
93
  v = result["video"]
94
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {args.mode} at x={args.x} y={args.y} {args.width}x{args.height})")
94
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {args.mode} at x={args.x} y={args.y} {args.width}x{args.height})")
95
95
  emit(output, dropped_non_av_streams=dropped_streams)
96
96
  return 0
97
97
 
package/scripts/render.py CHANGED
@@ -55,7 +55,7 @@ import sys
55
55
  from pathlib import Path
56
56
  from typing import Any, Dict, List
57
57
 
58
- from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool
58
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output
59
59
 
60
60
  HERE = Path(__file__).resolve().parent
61
61
 
@@ -350,8 +350,7 @@ def main() -> int:
350
350
  stages_done.append("export")
351
351
  else:
352
352
  if not STATE.dry_run:
353
- import shutil
354
- shutil.copyfile(current, output)
353
+ place_output(current, output)
355
354
  info(f"copied final stage to {output}")
356
355
  current = output
357
356
 
@@ -14,7 +14,7 @@ Examples:
14
14
  import argparse
15
15
  import sys
16
16
 
17
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
17
+ 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
18
18
 
19
19
 
20
20
  def main() -> int:
@@ -47,7 +47,7 @@ def main() -> int:
47
47
  run(cmd)
48
48
 
49
49
  result = probe(output, role="output")
50
- info(f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})")
50
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {result['video']['width']}x{result['video']['height']})")
51
51
  emit(output)
52
52
  return 0
53
53
 
@@ -1,3 +1,4 @@
1
+ import re
1
2
  #!/usr/bin/env python3
2
3
  """Turn a numbered image sequence into a video.
3
4
 
@@ -19,7 +20,7 @@ import sys
19
20
  import tempfile
20
21
  from pathlib import Path
21
22
 
22
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, concat_list_line
23
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, concat_list_line, fmt_secs
23
24
 
24
25
 
25
26
  def even(n: float) -> int:
@@ -31,6 +32,11 @@ def _concat_list_line(path: Path) -> str:
31
32
  return concat_list_line(str(path))
32
33
 
33
34
 
35
+ def natural_key(name: str) -> list:
36
+ """img2 < img10: digit runs compare as numbers, the rest case-insensitively."""
37
+ return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", name)]
38
+
39
+
34
40
  def main() -> int:
35
41
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
36
42
  ap.add_argument("--dir", required=True, help="directory containing the frames")
@@ -54,7 +60,9 @@ def main() -> int:
54
60
 
55
61
  is_glob = "%" not in args.pattern
56
62
  if is_glob:
57
- frames = sorted(directory.glob(args.pattern))
63
+ # natural order: img2 before img10 (a lexical sort put every img1x before img2 and the
64
+ # frames came out shuffled); a printf pattern never had this problem
65
+ frames = sorted(directory.glob(args.pattern), key=lambda p: natural_key(p.name))
58
66
  if not frames:
59
67
  die(f"no files in {args.dir} match glob '{args.pattern}'")
60
68
  info(f"found {len(frames)} frames matching '{args.pattern}'")
@@ -113,7 +121,7 @@ def main() -> int:
113
121
 
114
122
  result = probe(output, role="output")
115
123
  v = result["video"]
116
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['fps']:g}fps)")
124
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['fps']:g}fps)")
117
125
  emit(output)
118
126
  return 0
119
127
 
@@ -17,7 +17,7 @@ import re
17
17
  import sys
18
18
  from typing import List, Tuple
19
19
 
20
- from _common import STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args, X264_PRESETS, measured_level_dbfs, run_analysis, dry_run_input_pending
20
+ from _common import STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args, X264_PRESETS, measured_level_dbfs, run_analysis, dry_run_input_pending, fmt_secs
21
21
 
22
22
  SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
23
23
 
@@ -134,7 +134,7 @@ def main() -> int:
134
134
  cmd += ["-af", af] + audio_codec_for(output) + [output]
135
135
  run(cmd)
136
136
  r = probe(output, role="output")
137
- info(f"wrote {output} ({r['duration']:.3f}s, expected ~{kept:.3f}s)")
137
+ info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{kept:.3f}s)")
138
138
  emit(output, **summary)
139
139
  return 0
140
140
 
@@ -19,7 +19,7 @@ import argparse
19
19
  import sys
20
20
  from typing import List, Tuple
21
21
 
22
- from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
22
+ from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, parse_time
23
23
 
24
24
  MAX_SPEED = 20.0
25
25
  MIN_SPEED = 0.05
@@ -41,11 +41,11 @@ def atempo_chain(factor: float) -> str:
41
41
 
42
42
  def parse_segment(raw: str) -> Tuple[float, float, float]:
43
43
  try:
44
- span, factor_s = raw.split(":")
45
- start_s, end_s = span.split("-")
46
- start, end, factor = float(start_s), float(end_s), float(factor_s)
44
+ span, factor_s = raw.rsplit(":", 1)
45
+ start_s, end_s = span.rsplit("-", 1)
46
+ start, end, factor = parse_time(start_s), parse_time(end_s), float(factor_s)
47
47
  except ValueError:
48
- die(f"--segment must look like START-END:FACTOR, got '{raw}'")
48
+ die(f"--segment must look like START-END:FACTOR (times in seconds or mm:ss), got '{raw}'")
49
49
  if end <= start:
50
50
  die(f"--segment {raw}: END must be after START")
51
51
  if not MIN_SPEED <= factor <= MAX_SPEED:
@@ -114,7 +114,7 @@ def main() -> int:
114
114
 
115
115
  result = probe(output, role="output")
116
116
  v = result["video"]
117
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {len(segments)} speed segments)")
117
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {len(segments)} speed segments)")
118
118
  emit(output)
119
119
  return 0
120
120
 
package/scripts/sphere.py CHANGED
@@ -31,7 +31,7 @@ Examples:
31
31
  import argparse
32
32
  import sys
33
33
 
34
- 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
34
+ 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
35
35
 
36
36
  # v360's own AVOption names for the input projections real 360 cameras/exports actually
37
37
  # produce (ffmpeg -h filter=v360 documents 24 total; this is the subset a caller is likely
@@ -117,7 +117,7 @@ def main() -> int:
117
117
 
118
118
  result = probe(output, role="output")
119
119
  v = result["video"]
120
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, yaw={args.yaw:g} pitch={args.pitch:g})")
120
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, yaw={args.yaw:g} pitch={args.pitch:g})")
121
121
  emit(output, dropped_non_av_streams=dropped_streams)
122
122
  return 0
123
123
 
@@ -27,7 +27,7 @@ import sys
27
27
  import tempfile
28
28
  from pathlib import Path
29
29
 
30
- from _common import STATE, 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
30
+ from _common import STATE, 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
31
31
 
32
32
 
33
33
  def main() -> int:
@@ -94,7 +94,7 @@ def main() -> int:
94
94
  run(cmd)
95
95
 
96
96
  result = probe(output, role="output")
97
- info(f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})")
97
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {result['video']['width']}x{result['video']['height']})")
98
98
  emit(output)
99
99
  return 0
100
100
 
@@ -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, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS
25
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
26
26
 
27
27
 
28
28
  def main() -> int:
@@ -88,7 +88,7 @@ def main() -> int:
88
88
 
89
89
  result = probe(output, role="output")
90
90
  v = result["video"]
91
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, degrees={args.degrees:g}, fit={args.fit})")
91
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, degrees={args.degrees:g}, fit={args.fit})")
92
92
  emit(output, dropped_non_av_streams=dropped_streams)
93
93
  return 0
94
94
 
package/scripts/sync.py CHANGED
@@ -291,10 +291,13 @@ def main() -> int:
291
291
  if head_trim > 0:
292
292
  cmd += ["-ss", f"{head_trim:.4f}"]
293
293
  cmd += ["-i", args.second, "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy"]
294
- af_parts = drift_af + ([f"adelay={delay_ms}:all=1"] if delay_ms > 0 else [])
295
- if af_parts:
296
- cmd += ["-af", ",".join(af_parts)]
297
- cmd += aac_args() + ["-shortest", output]
294
+ # the reference picture is the deliverable: pad the (possibly trimmed) second audio to
295
+ # its length instead of -shortest, which cut the reference's tail whenever the second
296
+ # file started earlier (head_trim > 0) or was simply shorter
297
+ ref_dur = probe(args.reference).get("duration")
298
+ af_parts = drift_af + ([f"adelay={delay_ms}:all=1"] if delay_ms > 0 else []) + ["apad"]
299
+ cmd += ["-af", ",".join(af_parts)]
300
+ cmd += aac_args() + (["-t", f"{ref_dur:.3f}"] if ref_dur else ["-shortest"]) + [output]
298
301
  proc = run(cmd, check=False)
299
302
  if proc.returncode != 0:
300
303
  cmd = [c for c in cmd if c != "copy"]
@@ -22,7 +22,7 @@ Examples:
22
22
  import argparse
23
23
  import sys
24
24
 
25
- from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color, X264_PRESETS
25
+ from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, validate_color, X264_PRESETS, fmt_secs
26
26
 
27
27
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
28
28
 
@@ -88,7 +88,7 @@ def main() -> int:
88
88
 
89
89
  result = probe(output, role="output")
90
90
  v = result["video"]
91
- info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {args.style})")
91
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {args.style})")
92
92
  emit(output)
93
93
  return 0
94
94