ffmpeg-skill 3.8.13 → 3.8.15

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
@@ -331,6 +331,7 @@ rather than drifting silently. There is no separate `input_schema` file to forge
331
331
  ```bash
332
332
  npx ffmpeg-skill contract --json # or: python3 scripts/_contract.py --json
333
333
  npx ffmpeg-skill contract --json --static # without environment detection
334
+ npx ffmpeg-skill contract --json --tool cut # one tool's entry under `tools` (repeatable): ~60 KB, not ~950 KB
334
335
  ```
335
336
 
336
337
  The contract is generated from the code that runs, not maintained beside it. For each of the 42 tools (`ffmpeg-skill/<name>`) it states:
package/SKILL.md CHANGED
@@ -16,7 +16,7 @@ Shared flags (the rest, codec defaults and `--hw`: `references/flags.md`):
16
16
 
17
17
  Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`,
18
18
  `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`, `overlay`, `metadata`, `cut`, `render`,
19
- `batch`) may still run ffmpeg/ffprobe without writing artifacts; `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
19
+ `batch`) may still run ffmpeg/ffprobe without writing artifacts; `verify` ignores the flag. Per tool: `contract --json --tool NAME` `dry_run`.
20
20
 
21
21
  Audio-only files work in the audio tools; picture tools refuse them (`references/workflow.md#audio-only-files`).
22
22
 
package/docs/contract.md CHANGED
@@ -7,6 +7,11 @@ dry-run, input preservation and repeatability. It is the interface a planning ag
7
7
  consumes instead of reading `SKILL.md`, which stays written for a coding agent that
8
8
  follows the workflow by hand.
9
9
 
10
+ The whole document is about 950 KB, almost all of it the 42 `tools` entries. `--tool NAME`
11
+ (repeatable; `cut` or `cut.py`) keeps only the named tools under `tools`, in the order given,
12
+ and every other key as it is: about 55 to 85 KB for one tool. An unknown name is refused (exit
13
+ 2) with the list of tools, and `doctor` does not take the flag.
14
+
10
15
  The contract is derived from the code that runs, not maintained beside it:
11
16
 
12
17
  - the tool list is every script in `scripts/` that does not start with `_`;
@@ -21,7 +26,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
26
  | Field | Meaning | Changes when |
22
27
  |---|---|---|
23
28
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.8.13`) | any release |
29
+ | `skill.version` | the npm / package.json version (`3.8.15`) | any release |
25
30
 
26
31
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
32
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -149,7 +154,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
154
  "deprecated": [],
150
155
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
156
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.8.13", "execution_mode": "local", "kind": "execution",
157
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.15", "execution_mode": "local", "kind": "execution",
153
158
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
159
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
160
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
@@ -951,7 +956,7 @@ A refusal also carries `error.reason`, a stable snake_case code for *why*
951
956
  `output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
952
957
  `output_container_codecs` (a `.webm` output for an H.264/HEVC or AAC encode, which WebM cannot hold),
953
958
  `media_path_is_url` (`subfile,...:` included), `media_path_is_playlist` (an HLS, DASH, IMF or ffconcat playlist given as media, DASH and IMF told by content whatever the file is called), `media_path_has_line_break` (a path with a line break, which a concat list cannot hold),
954
- `output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
959
+ `output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_extension_missing` (a media output with no file extension, which leaves ffmpeg no container to choose), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
955
960
  `render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
956
961
  `encoder_list_unreadable`, and `--transcribe`'s `asr_engine_missing`, `no_speech`, `engine_failed`
957
962
  and `english_only`), and `error.details`, the facts the message is written from, so a
package/mcp/server.py CHANGED
@@ -18,7 +18,6 @@ Claude Desktop / Claude Code config example:
18
18
  """
19
19
  import json
20
20
  import os
21
- import subprocess
22
21
  import sys
23
22
  from pathlib import Path
24
23
  from typing import Any, Dict, List, Optional
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.13",
3
+ "version": "3.8.15",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "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.",
6
6
  "keywords": [
@@ -53,7 +53,10 @@
53
53
  "docs/contract.md",
54
54
  "SKILL.md",
55
55
  "README.md",
56
- "LICENSE"
56
+ "LICENSE",
57
+ "!**/__pycache__/**",
58
+ "!**/*.pyc",
59
+ "!**/*.pyo"
57
60
  ],
58
61
  "scripts": {
59
62
  "test": "python3 tests/run_parallel.py && python3 tests/test_contract.py",
@@ -5,7 +5,7 @@ The long form of SKILL.md's Quick start. `--help` on a script is still its full
5
5
 
6
6
  ## Running the scripts
7
7
 
8
- Scripts live in `scripts/` next to SKILL.md; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. SKILL.md is enough to do a job: SKILL.md's request table routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as SKILL.md; open one only for a question you have: `references/scripts.md` (most flags; `--help` lists all), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end of SKILL.md). MCP: `tools/list` shows only the core 12 by default; the other 30 (per SKILL.md's request table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
8
+ Scripts live in `scripts/` next to SKILL.md; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. SKILL.md is enough to do a job: SKILL.md's request table routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as SKILL.md; open one only for a question you have: `references/scripts.md` (most flags; `--help` lists all), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end of SKILL.md). MCP: `tools/list` shows only the core 12 by default; the other 30 (per SKILL.md's request table) stay callable by name via `tools/call`, described by `contract --json --tool NAME`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
9
9
 
10
10
  ## Shared flags
11
11
 
@@ -13,4 +13,4 @@ Shared flags (every script accepts them; the exceptions are named): `--dry-run`
13
13
 
14
14
  ## What `--dry-run` runs
15
15
 
16
- Writing tools run no ffmpeg under `--dry-run` (ffprobe still reads an input that exists); the measuring tools (`probe` with `--analyze`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0; `metadata` with `--auto-chapters`; `cut` with `--snap beats`) still run their ffmpeg measurements, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`), and `render` and `batch` run each stage or step as its own dry run; `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
16
+ Writing tools run no ffmpeg under `--dry-run` (ffprobe still reads an input that exists); the measuring tools (`probe` with `--analyze`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0; `metadata` with `--auto-chapters`; `cut` with `--snap beats`) still run their ffmpeg measurements, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`), and `render` and `batch` run each stage or step as its own dry run; `verify` ignores the flag. Per tool: `contract --json --tool NAME` `dry_run`.
@@ -131,7 +131,10 @@ JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation
131
131
  (`hdr`, and `hdr_signal` equal to it, is true only for a PQ / HLG transfer or Dolby Vision -- since 2.0;
132
132
  `bt2020_or_hdr` also counts BT.2020 primaries on an SDR transfer, which `hdr_format` names "BT.2020 SDR"),
133
133
  `audio{codec,channels,sample_rate,start_time}` (`start_time`: where the stream starts on the file's
134
- own clock, `null` when unknown). `--compact` gives one line per file.
134
+ own clock, `null` when unknown). `--compact` gives one line per file. `--field` prints one line
135
+ per file: a string as itself and any other value as JSON (`true`, `{"codec": ...}`), an empty
136
+ line for a field that is not there; with `--json` every line is JSON (a string quoted, a
137
+ missing field `null`).
135
138
 
136
139
  ### cut.py — cut / join segments
137
140
  ```
@@ -338,7 +341,7 @@ the rectangle for you. Use this when the rectangle is already known (a
338
341
  face-detection box, a saved crop, a hand-picked region). The rectangle must
339
342
  lie entirely inside the source frame (after accounting for display rotation);
340
343
  `--width`/`--height` must be even (4:2:0 chroma) and are refused, never
341
- rounded, if they aren't.
344
+ rounded, if they aren't; an odd `--x`/`--y` is taken as given.
342
345
 
343
346
  ### cropdetect.py — measure black bars, report the crop rectangle
344
347
  ```
@@ -429,7 +432,9 @@ Wraps FFmpeg's `v360` filter to bake out an ordinary flat clip pointed at a
429
432
  fixed direction — the same "look this way" operation a VR headset or a 360
430
433
  player's viewport does. `--yaw`/`--pitch`/`--roll` aim the camera (each
431
434
  -180..180, default 0); `--h-fov`/`--v-fov` set how wide the view is (each
432
- 1..170, default 90/60). `--input-projection` must match the source's own
435
+ 1..170; `--h-fov` defaults to 90, and without `--v-fov` the vertical view is the
436
+ one that keeps the output's pixels square, 58.7 at 90 and 1920x1080; a pair that
437
+ is not square-pixel is drawn as given and said in `notes`). `--input-projection` must match the source's own
433
438
  projection (default `equirect`, the most common capture/export format) —
434
439
  this tool cannot detect it from the pixels, so a wrong value produces a
435
440
  distorted or garbled output, not an error. There is no subject detection or
@@ -502,7 +507,9 @@ a temporary transforms file (deleted after the run), pass 2 smooths and
502
507
  re-renders. `--shakiness` (default 5) trades analysis time for how much
503
508
  motion it looks for; `--smoothing` (default 15) is how many neighbouring
504
509
  frames the camera path is averaged over; `--zoom` crops in slightly to hide
505
- the black edges stabilizing can introduce. Needs an ffmpeg built with
510
+ the black edges stabilizing can introduce, and is the only zoom applied (0, the
511
+ default, keeps the original framing and lets the edges show: vidstab's own
512
+ "optimal zoom" is off). Needs an ffmpeg built with
506
513
  `--enable-libvidstab`; `doctor` reports this tool `usable: no` when that's
507
514
  missing (a plain Homebrew ffmpeg build, for example) rather than failing at
508
515
  run time.
@@ -1669,14 +1676,17 @@ Each step runs with `--json`, and a failed one keeps its tool's own `kind`,
1669
1676
  completed one carries the tool's `verified`. Steps that do not apply are listed
1670
1677
  under the file's `skipped` (`quick`, `no_video`, `no_audio`, `not_hdr`,
1671
1678
  `not_multichannel`, `input_unreadable`) and as SKIP rows in the report.
1679
+ `--overwrite` and `--fast` given to verify.py reach every step, so a rerun into the same
1680
+ `--out` replaces the earlier outputs. The outputs are kept in `--out`, or with `--keep` alone
1681
+ in a new folder; either is the result's `out_dir` (`null` when they were deleted).
1672
1682
  Run this first when a user hands over footage from a device you have not
1673
1683
  seen before, and fix or report what fails.
1674
1684
 
1675
1685
  ### look.py — see the result
1676
1686
  ```
1677
- look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
1687
+ look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes (-o basename -> basename_sheet.png)
1678
1688
  look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
1679
- look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame
1689
+ look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame (-o basename -> basename_vs_AFTER_0_4.000s.png)
1680
1690
  look.py INPUT --safe tiktok [--at 3] # shade what the app's UI covers
1681
1691
  look.py INPUT --guides title,action,4:3 [--at 3] # broadcast safe-area outlines
1682
1692
  ```
@@ -2370,7 +2380,8 @@ neither.
2370
2380
  proxy.py INPUT [--width W | --scale F] [--quality N] [--fps N] [--no-audio] [-o OUT]
2371
2381
  ```
2372
2382
  Not a delivery preset: resizes to `--width` (default 640) or by `--scale`
2373
- factor, re-encodes at a proxy-grade `--quality` (default 30) with the fastest
2383
+ factor, never wider than the source and never under 2 px (a `notes` line says
2384
+ when either caps the width asked), re-encodes at a proxy-grade `--quality` (default 30) with the fastest
2374
2385
  x264/x265 preset, keeps the source's own dynamic range (HDR stays HDR;
2375
2386
  run `color.py --to-sdr` first if SDR is wanted) and keeps a subtitle/data
2376
2387
  track when the container can hold it (`dropped_non_av_streams`). Only executes the spec
@@ -1098,7 +1098,7 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
1098
1098
  _shutil = SafeShutil(_shutil) # look engines up and launch them by full path, never from the working directory
1099
1099
  import subprocess as _subprocess
1100
1100
  import tempfile as _tempfile
1101
- from _common import require_tool, run_analysis, STATE
1101
+ from _common import require_tool, STATE
1102
1102
  ffmpeg = require_tool("ffmpeg")
1103
1103
  wanted = requested_engine(engine)
1104
1104
  language = engine_language(language)
@@ -395,8 +395,13 @@ def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float = 1000.0, desat: floa
395
395
  prim = v.get("color_primaries") or "bt2020"
396
396
  space = v.get("color_space") or "bt2020nc"
397
397
  source_peak = HLG_PEAK_NITS if trc == "arib-std-b67" else peak
398
- # zscale needs explicit input tags when the file lacks them
399
- chain: List[str] = [
398
+ # zscale needs explicit input tags when the file lacks them, and on 6.1 its `pin` does not
399
+ # reach a frame whose own primaries are unspecified ("no path between colorspaces", kind
400
+ # ffmpeg: --to-sdr --force on an untagged file failed through 3.8.8), so a frame missing a tag
401
+ # is labelled with what the chain reads it as first
402
+ missing = not (v.get("color_transfer") and v.get("color_primaries") and v.get("color_space"))
403
+ label = [f"setparams=color_primaries={prim}:color_trc={trc}:colorspace={space}"] if missing else []
404
+ chain: List[str] = label + [
400
405
  f"zscale=tin={trc}:pin={prim}:min={space}:rin={v.get('color_range') or 'tv'}:t=linear"
401
406
  f":npl={TONEMAP_REFERENCE_NITS:g}",
402
407
  "format=gbrpf32le",
@@ -695,6 +695,16 @@ def _vt_bt709(codec: str) -> List[str]:
695
695
  return ["-bsf:v", f"{codec}_metadata=colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1"]
696
696
 
697
697
 
698
+ def source_transfer(v: Dict[str, Any]) -> str:
699
+ """The transfer a 10-bit re-encode of a BT.2020 / HDR source is tagged with: the source's own
700
+ tag, or, when it has none, what probe() read it as. BT.2020 primaries with no transfer tag are
701
+ `hdr_format` "BT.2020 SDR" (not an HDR signal), so they are tagged bt2020-10, the SDR curve
702
+ BT.2020 defines; through 3.8.8 every such re-encode came out tagged HLG (audit A153). An
703
+ HDR signal with no transfer tag (Dolby Vision whose base layer is untagged) keeps the HLG
704
+ default it always had."""
705
+ return v.get("color_transfer") or ("arib-std-b67" if v.get("hdr_signal") else "bt2020-10")
706
+
707
+
698
708
  def _vt_args(codec: str, crf: int, meta: Optional[Dict[str, Any]], keep_bt709: bool) -> Optional[List[str]]:
699
709
  """VideoToolbox encoder options for `codec`, or None (with a note in STATE.hw_notes) when the
700
710
  request stays on the CPU: no VideoToolbox encoder for the codec, this machine cannot, or the
@@ -716,7 +726,7 @@ def _vt_args(codec: str, crf: int, meta: Optional[Dict[str, Any]], keep_bt709: b
716
726
  v = (meta or {}).get("video") or {}
717
727
  hdr = bool(v.get("bt2020_or_hdr"))
718
728
  hdr_tags = ["-colorspace", v.get("color_space") or "bt2020nc", "-color_primaries", v.get("color_primaries") or "bt2020",
719
- "-color_trc", v.get("color_transfer") or "arib-std-b67"]
729
+ "-color_trc", source_transfer(v)]
720
730
  if codec == "prores":
721
731
  return ["-c:v", name, "-profile:v", "hq", "-pix_fmt", "p210le"] + (hdr_tags if hdr else [])
722
732
  # The HDR curve was fitted on the Main10 HEVC line; an 8-bit H.264 line (an export preset on
@@ -847,7 +857,7 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
847
857
  hdr = bool(v.get("bt2020_or_hdr"))
848
858
  cs = v.get("color_space") or "bt2020nc"
849
859
  prim = v.get("color_primaries") or "bt2020"
850
- trc = v.get("color_transfer") or "arib-std-b67"
860
+ trc = source_transfer(v)
851
861
  hdr_tags = ["-colorspace", cs, "-color_primaries", prim, "-color_trc", trc]
852
862
  if codec == "h264":
853
863
  if hdr:
@@ -1152,7 +1162,7 @@ def source_codec_video_args(meta: Optional[Dict[str, Any]], crf: int = 18, prese
1152
1162
  def _x265_hdr_default(v: Dict[str, Any], crf: int, preset: str) -> List[str]:
1153
1163
  cs = v.get("color_space") or "bt2020nc"
1154
1164
  prim = v.get("color_primaries") or "bt2020"
1155
- trc = v.get("color_transfer") or "arib-std-b67"
1165
+ trc = source_transfer(v)
1156
1166
  x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1{hdr10_x265_params(v)}" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
1157
1167
  return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
1158
1168
  "-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
@@ -10,6 +10,8 @@ import math
10
10
  import os
11
11
  import re
12
12
  import statistics
13
+ import sys
14
+ from array import array
13
15
  from fractions import Fraction
14
16
  from typing import Any, Dict, List, Optional, Sequence, Tuple
15
17
  from _common.emit import die, warn
@@ -70,15 +72,13 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
70
72
  if check:
71
73
  die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr.decode(errors="replace")))
72
74
  return []
73
- import array
74
- import sys
75
75
  data, proc = proc.stdout, None
76
- pcm = array.array("h")
76
+ pcm = array("h")
77
77
  pcm.frombytes(memoryview(data)[: len(data) // 2 * 2])
78
78
  del data
79
79
  if sys.byteorder == "big":
80
80
  pcm.byteswap() # s16le on the pipe
81
- return array.array("f", (v / 32768.0 for v in pcm))
81
+ return array("f", (v / 32768.0 for v in pcm))
82
82
 
83
83
 
84
84
  def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool = False, remove_mean: bool = False) -> List[float]:
@@ -511,6 +511,11 @@ def apply_common(args: "argparse.Namespace") -> None:
511
511
  # --json-brief is a shorter --json, not a second output mode: it implies it, so a caller that
512
512
  # passes only --json-brief still gets a JSON document (and --json --json-brief is the brief one).
513
513
  STATE.json = bool(getattr(args, "json", False)) or STATE.json_brief
514
+ if hasattr(args, "json"):
515
+ # and a tool that branches on args.json reads the same mode: through 3.8.13, probe --field,
516
+ # check, cropdetect, silence --list, report and verify printed text (or a bare document,
517
+ # or a document and then a path) under --json-brief alone (review of #521)
518
+ args.json = STATE.json
514
519
  STATE.progress = bool(getattr(args, "progress", False))
515
520
  STATE.fast = bool(getattr(args, "fast", False))
516
521
  STATE.overwrite = bool(getattr(args, "overwrite", False))
@@ -991,7 +996,7 @@ def _refuse_output_is_input(output: str, inp: str, real: str, why: str) -> None:
991
996
  **details)
992
997
 
993
998
 
994
- def refuse_output_is_input(output: str, *inputs: str) -> None:
999
+ def refuse_output_is_input(output: str, *inputs: str, why: str = "the result would replace the source") -> None:
995
1000
  """Tool-level twin of the run() guard, for tools whose final ffmpeg command does not name
996
1001
  the user's input at all. `cut.py --segments` cuts each part into a temp dir and then concats
997
1002
  a list file: the last command's only `-i` is that list, so `-o` equal to the input sailed
@@ -1009,7 +1014,7 @@ def refuse_output_is_input(output: str, *inputs: str) -> None:
1009
1014
  except OSError:
1010
1015
  continue
1011
1016
  if same:
1012
- _refuse_output_is_input(output, inp, out_real, "the result would replace the source")
1017
+ _refuse_output_is_input(output, inp, out_real, why)
1013
1018
 
1014
1019
 
1015
1020
  def _check_output_path(cmd: Sequence[str]) -> None:
@@ -1040,6 +1045,24 @@ def _check_output_path(cmd: Sequence[str]) -> None:
1040
1045
  refuse("input", "output_dir_not_writable", f"output directory {parent!r} is not writable", output=output, directory=parent)
1041
1046
 
1042
1047
 
1048
+ def _check_output_extension(cmd: Sequence[str]) -> None:
1049
+ """An ffmpeg output with no file extension and no `-f` after its last input leaves the muxer
1050
+ nothing to choose a container by: refused as `kind: input` before anything runs, dry runs
1051
+ included. Through 3.8.8 every tool that writes media handed it on and failed as `kind: ffmpeg`
1052
+ ("Unable to choose an output format", audit A159's sweep). Only the ffmpeg commands run()
1053
+ executes are checked: a sidecar (a report, an SRT) is a plain file and needs no extension,
1054
+ and look.py takes an -o without an image extension as a stem (docs/design-decisions.md)."""
1055
+ output = cmd[-1]
1056
+ if output == "-" or output.startswith("pipe:") or len(os.path.splitext(output)[1]) > 1:
1057
+ return
1058
+ last_input = max((i for i, tok in enumerate(cmd[:-1]) if tok == "-i"), default=-1)
1059
+ if "-f" in cmd[last_input + 2:-1]:
1060
+ return
1061
+ refuse("input", "output_extension_missing",
1062
+ f"output {output!r} has no file extension, so ffmpeg cannot tell which container to write; "
1063
+ f"name it with one (for example {output + '.mp4'!r})", output=output)
1064
+
1065
+
1043
1066
  # What the WebM muxer takes (6.1: "Only VP8 or VP9 or AV1 video and Vorbis or Opus audio and
1044
1067
  # WebVTT subtitles are supported for WebM"), by the encoder a command names (#488).
1045
1068
  _WEBM_VIDEO_ENCODER_RE = re.compile(r"^(?:libvpx(?:-vp9)?|vp8|vp9|libaom-av1|libsvtav1|librav1e|av1|vp8_\w+|vp9_\w+|av1_\w+)$")
@@ -2017,6 +2040,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
2017
2040
  _refuse_url_media(cmd)
2018
2041
  _check_no_overwrite_input(cmd)
2019
2042
  _check_output_path(cmd)
2043
+ _check_output_extension(cmd)
2020
2044
  _check_container_codecs(cmd)
2021
2045
  _check_build_components(cmd)
2022
2046
  _check_existing_output(cmd)
@@ -2615,7 +2639,6 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
2615
2639
  with os.replace() under the lock instead of copied -- a checked `cut.py --segments` join was
2616
2640
  copied whole, about 2 s for 240 MB. A rename that fails (another filesystem) falls back to
2617
2641
  the copy."""
2618
- import shutil
2619
2642
  cmd = ["ffmpeg", dst]
2620
2643
  _check_output_path(cmd)
2621
2644
  _check_existing_output(cmd)
@@ -3,6 +3,7 @@
3
3
 
4
4
  python3 scripts/_contract.py --json # the contract, with detected capabilities
5
5
  python3 scripts/_contract.py --json --static # same, without environment detection
6
+ python3 scripts/_contract.py --json --tool cut # only cut under `tools` (repeatable)
6
7
  python3 scripts/_contract.py doctor [--json] # which required capabilities this machine has
7
8
  ffmpeg-skill contract --json # the same through the npm entry point
8
9
 
@@ -21,7 +22,6 @@ import argparse
21
22
  import functools
22
23
  import importlib.util
23
24
  import json
24
- import os
25
25
  import platform
26
26
  import re
27
27
  import shutil
@@ -442,6 +442,7 @@ ERROR_REASONS = {
442
442
  "output_looks_like_option": "the output starts with '-' (other than '-' for stdout), which ffmpeg would read as an option; ./name writes a file of that name; details: {output}",
443
443
  "media_path_is_url": "an input or output has a protocol prefix (tcp:, http://, file:, ...) that ffmpeg would open as a URL; the skill reads and writes local files only; details: {path, scheme}",
444
444
  "output_dir_missing": "the output's directory does not exist; details: {output, directory}",
445
+ "output_extension_missing": "a media output with no file extension (and no -f naming its format), so ffmpeg could not choose a container; refused before ffmpeg runs, dry runs included; details: {output}",
445
446
  "output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
446
447
  "output_container_codecs": "a .webm output whose command encodes video or sound WebM cannot hold (H.264/HEVC, AAC; WebM takes VP8/VP9/AV1 and Opus/Vorbis), refused before ffmpeg runs; details: {output, container, encoders}",
447
448
  "timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
@@ -775,7 +776,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
775
776
  elif name == "verify":
776
777
  extra = {"report": {"type": ["string", "null"]},
777
778
  "files": {"type": "array", "description": "one entry per file: {file, probe, steps, skipped}. A step is {step, ok, seconds, error (one line, for the table)}, plus the tool's verified when it completed, and on a failure its own kind, reason, message, details and exit_code (reason step_timeout or step_output_unparsed when the tool printed no failure document); skipped lists the steps that do not apply as {step, reason: quick|no_video|no_audio|not_hdr|not_multichannel|input_unreadable, message}"},
778
- "failed": {"type": "integer"}, "total": {"type": "integer"}}
779
+ "failed": {"type": "integer"}, "total": {"type": "integer"},
780
+ "out_dir": {"type": ["string", "null"], "description": "the folder the steps' outputs are in when they are kept: --out, or with --keep alone a new folder that outlives the run; null when they were written to a temporary folder and deleted"}}
779
781
  elif name == "batch":
780
782
  extra = {"results": {"type": "array", "description": "one row per input, every input (an interrupted batch included): ok (every step completed), verified (every step also verified what it wrote; false under --dry-run) and unverified ([{step, tool, failed}] naming the steps that did not); a failed row's error {step, tool, kind, message, code, reason, details, hint, exit_code} from the failing step's own document; diagnostics from its steps (tagged with step); cache_miss {reason: output_missing|output_changed|entry_unverifiable, ...} when a cache entry was not served; skipped \"timeout\" or \"interrupted\" for an item that never started and interrupted true for one stopped while running; under --watch, skipped \"settling\" (ok false, not cached) for a source that changed since the previous poll, or, first seen, was modified within one poll interval: it is processed on a later pass. A row whose recipe ran cut.py carries cut_reencoded (each cut.py call's reencoded) and cut_reencode_reasons (each reason those calls named, once per call)"},
781
783
  "processed": {"type": "integer"}, "total": {"type": "integer"},
@@ -1039,11 +1041,6 @@ def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
1039
1041
  return {"names": names, "status": "parsed", "detail": f"{len(names)} entries"}
1040
1042
 
1041
1043
 
1042
- def _ff_list(binary: str, flag: str) -> List[str]:
1043
- """Names from `ffmpeg -encoders` / `-filters` / `-bsfs` (empty list when ffmpeg is missing or unparsed)."""
1044
- return _ff_listing(binary, flag)["names"]
1045
-
1046
-
1047
1044
  # GPU-backed encoders are named `<codec>_<backend>` by every ffmpeg build (h264_nvenc,
1048
1045
  # hevc_videotoolbox, av1_qsv, h264_vaapi, hevc_amf, ...); recognised by the backend suffix so a
1049
1046
  # new codec in a future ffmpeg build needs no change here.
@@ -2179,7 +2176,19 @@ def main() -> int:
2179
2176
  ap.add_argument("command", nargs="?", choices=["contract", "doctor"], default="contract")
2180
2177
  ap.add_argument("--json", action="store_true", help="JSON on stdout (the contract is always JSON)")
2181
2178
  ap.add_argument("--static", action="store_true", help="omit environment detection (available / missing capabilities)")
2179
+ ap.add_argument("--tool", action="append", metavar="NAME",
2180
+ help="only this tool's entry under `tools` (repeatable; `cut` or `cut.py`); every other key is "
2181
+ "unchanged. The whole contract is about 950 KB; with one tool, 55-85 KB")
2182
2182
  args = ap.parse_args()
2183
+ wanted: List[str] = []
2184
+ for name in args.tool or []:
2185
+ name = name[:-3] if name.endswith(".py") else name
2186
+ if name not in public_tools():
2187
+ ap.error(f"--tool {name}: no such tool (one of: {', '.join(public_tools())})")
2188
+ if name not in wanted:
2189
+ wanted.append(name)
2190
+ if wanted and args.command == "doctor":
2191
+ ap.error("--tool selects from the contract; doctor --json lists every tool under `tools`")
2183
2192
  if args.command == "doctor":
2184
2193
  d = doctor()
2185
2194
  # On stderr in both modes, so `doctor --json`'s stdout stays one JSON document: an
@@ -2225,7 +2234,12 @@ def main() -> int:
2225
2234
  # 1: something required is missing; 2: nothing proven missing but a required capability is unknown
2226
2235
  # an unsupported Python/FFmpeg is a known failure, like a missing capability (not "unknown")
2227
2236
  return 1 if d["missing"] or d.get("unsupported") else 2
2228
- print(json.dumps(build(detect=not args.static), indent=2, sort_keys=True, ensure_ascii=False))
2237
+ doc = build(detect=not args.static)
2238
+ if wanted:
2239
+ # in the order asked; `provides` and `capability_map` stay whole (a few KB): they map ids
2240
+ # to tools across the skill, which one tool's entry does not change (#467 A220)
2241
+ doc["tools"] = sorted((t for t in doc["tools"] if t["name"] in wanted), key=lambda t: wanted.index(t["name"]))
2242
+ print(json.dumps(doc, indent=2, sort_keys=True, ensure_ascii=False))
2229
2243
  return 0
2230
2244
 
2231
2245
 
@@ -230,11 +230,6 @@ ALIASES: Dict[str, str] = {"youtube-shorts": "shorts", "yt-shorts": "shorts", "y
230
230
  PLATFORM_CHOICES: List[str] = sorted({n for n in PLATFORMS if PLATFORMS[n]["check"] == n} | set(ALIASES))
231
231
 
232
232
 
233
- def has_frame(name: str) -> bool:
234
- """True when this destination has a frame, and therefore a safe zone to place text inside."""
235
- return bool(PLATFORMS.get(resolve(name) or "", {}).get("frame"))
236
-
237
-
238
233
  def spec_of(name: str) -> Dict[str, Any]:
239
234
  """check.py's compliance row values for a destination."""
240
235
  return dict(PLATFORMS[name]["spec"])
@@ -19,7 +19,7 @@ import sys
19
19
 
20
20
  from _common.alpha import add_alpha_args, check_alpha_args, finish_chain, report as alpha_report, video_line
21
21
  from _common.emit import note
22
- from _common import STATE, add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info, parse_time, probe, rgb_chain_to_bt709, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
22
+ from _common import STATE, add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info, probe, rgb_chain_to_bt709, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
23
23
 
24
24
 
25
25
  def gradient_source(w: int, h: int, fps: float, c0: str, c1: str, angle: float) -> str:
@@ -37,17 +37,16 @@ import json
37
37
  import os
38
38
  import re
39
39
  import sys
40
- import unicodedata
41
40
  from pathlib import Path
42
41
  from typing import Any, Dict, List, Optional, Tuple
43
42
 
44
43
  from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
45
44
  resolve as resolve_platform)
46
- from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape, ass_escape_markup,
45
+ from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape_markup,
47
46
  strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
48
47
  from _common.emit import refuse # noqa: E402
49
48
  from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
50
- from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, 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, refuse_existing_outputs, guard_sidecar
49
+ from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, NO_SPACE_SCRIPTS, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
51
50
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
52
51
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
53
52
  # shares them). They stay caption.py's public names -- every caller and test that reached for
package/scripts/check.py CHANGED
@@ -53,7 +53,7 @@ from _common.probe import (CHANGE_FLOOR, CHANGE_GRID, change_mask_tiles, change_
53
53
  from _common.emit import file_identity, own_length
54
54
  from _common.probe import PROBED_AUDIO
55
55
  from _common.probe import display_size as shown_size, shorter_than_a_loudness_block
56
- from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
56
+ from _common import STATE, add_common, apply_common, die, emit, probe, require_tool, run_analysis, dry_run_input_pending
57
57
 
58
58
  # The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
59
59
  # render.py templates all read it, so a platform's loudness spec is stated once. Only the
package/scripts/color.py CHANGED
@@ -581,7 +581,13 @@ def main() -> int:
581
581
  else:
582
582
  vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
583
583
  sdr_path = "tonemap"
584
- sdr_note = f"tone-mapped {v.get('hdr_format') or 'untagged input as PQ (--force)'} to SDR BT.709 with {args.tonemap}"
584
+ # the note names what hdr_to_sdr_chain read the source as: its own tags, PQ / BT.2020
585
+ # only where a tag is missing. Through 3.8.8 any non-HDR file was called "untagged
586
+ # input as PQ", a BT.709-tagged one included, which the chain linearised as BT.709 (A161)
587
+ read_as = v.get("hdr_format") or (
588
+ f"a non-HDR input read as transfer {v.get('color_transfer') or 'smpte2084 (untagged, taken as PQ)'}, "
589
+ f"primaries {v.get('color_primaries') or 'bt2020 (untagged)'} (--force)")
590
+ sdr_note = f"tone-mapped {read_as} to SDR BT.709 with {args.tonemap}"
585
591
  info(f"note: {sdr_note}")
586
592
  sdr_extra = {"sdr_path": sdr_path, "notes": [sdr_note]}
587
593
  output = args.output or default_output(args.input, "sdr")
package/scripts/crop.py CHANGED
@@ -12,7 +12,8 @@ The rectangle must lie entirely inside the source frame after accounting for
12
12
  any display rotation, and --width/--height must be even (required for 4:2:0
13
13
  chroma subsampling, the pixel format every encoder here uses) -- given values
14
14
  are validated and refused, never silently rounded, since a caller-specified
15
- rectangle should do exactly what was asked or fail loudly.
15
+ rectangle should do exactly what was asked or fail loudly. An odd --x/--y is
16
+ taken as given (the crop starts on that pixel), not moved to the chroma grid.
16
17
 
17
18
  Examples:
18
19
  python3 crop.py input.mp4 --x 100 --y 0 --width 1080 --height 1920
@@ -63,7 +64,7 @@ def main() -> int:
63
64
 
64
65
  output = args.output or default_output(args.input, "crop")
65
66
  # exact=1: without it crop rounds an odd --x/--y down to the chroma grid of a 4:2:0 frame,
66
- # and the rectangle written was one pixel left of / above the one asked for (A170's sibling)
67
+ # and the rectangle written was one pixel left of / above the one asked for (A170's sibling, A157)
67
68
  vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}:exact=1", "setsar=1"]
68
69
  if square_pixels(meta["video"]):
69
70
  vf.insert(0, square_pixels(meta["video"]))
@@ -33,7 +33,7 @@ import sys
33
33
  from collections import Counter
34
34
  from typing import Dict, List, Optional, Tuple
35
35
 
36
- from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames, frame_flow
36
+ from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames
37
37
  from _common.emit import note, warn
38
38
  from _common.probe import PROBED_VIDEO, display_size, square_pixels, square_pixels_note
39
39
 
package/scripts/cut.py CHANGED
@@ -65,7 +65,7 @@ from _common.probe import PROBED_AUDIO, PROBED_VIDEO, TS_EXTS, probed_maps, seve
65
65
  from _common.runner import subtitle_codec_for, subtitle_container_holds
66
66
  from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
67
67
  from _common.timeline import output_timecode_args
68
- from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
68
+ from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
69
69
 
70
70
  # outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
71
71
  DROPPED_STREAMS: List[str] = []
package/scripts/export.py CHANGED
@@ -43,6 +43,7 @@ import re
43
43
  import os
44
44
  import sys
45
45
  import tempfile
46
+ from fractions import Fraction
46
47
  from pathlib import Path
47
48
  from typing import Dict, List, Optional
48
49
 
@@ -51,7 +52,7 @@ from _common import STATE, run_analysis, add_common, apply_common, bt709_label_f
51
52
  from _common.decision import hw_preset_video, restate_last_swap
52
53
  from _common.emit import note, record_fallback, record_skip, warn
53
54
  from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels, square_pixels_note
54
- from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
55
+ from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, subtitle_codec_for
55
56
  from _common.emit import file_identity, input_length, own_length
56
57
  from check import SPECS as PLATFORMS, measure_loudness
57
58
  from _common.probe import SILENT_PEAK_DBFS, shorter_than_a_loudness_block
@@ -202,7 +203,8 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
202
203
  note(f"--hw: --preset {args.preset} stays on {p['video'][p['video'].index('-c:v') + 1]}: one ffmpeg run writes "
203
204
  "the fill and the key as a pair, and VideoToolbox's ProRes encoder is not used for it")
204
205
  fill_chain = ",".join(vf + (["premultiply=inplace=1"] if args.alpha_mode == "premultiplied" else []) + ["format=yuv422p10le"])
205
- key_chain = ",".join(vf + ["alphaextract", "format=yuv422p10le"])
206
+ # Alpha uses full range even when the source picture is tagged limited range.
207
+ key_chain = ",".join(vf + ["alphaextract", "scale=in_range=pc:out_range=tv", "format=yuv422p10le"])
206
208
  tc = ((meta.get("timecode") or {}).get("value"))
207
209
  tc_args = ["-timecode", tc] if tc else []
208
210
  cmd = cmd + ["-filter_complex", f"[0:{PROBED_VIDEO}]split[f][k];[f]{fill_chain}[fill];[k]{key_chain}[key]"]
@@ -442,6 +444,32 @@ SIZE_MIN_VIDEO_KBPS = 100 # below this the picture is not worth delivering; re
442
444
  SIZE_MAX_VIDEO_KBPS = 400_000 # 400 Mb/s: above any delivery line, and libx264 refuses far larger values
443
445
 
444
446
 
447
+ def _platform_rate_args(preset: str, meta: Dict) -> List[str]:
448
+ """A platform preset that keeps the source's frame rate (the youtube family) conforms a source
449
+ faster than the platform's fps_max to the source rate divided by the smallest whole number that
450
+ brings it within the limit (120 -> 60, 119.88 -> 59.94, 100 -> 50), so every frame of the
451
+ output is one of the source's own. Through 3.8.8 a 120 fps source kept 120 fps and the
452
+ platform's own check FAILed the export (audit A115). [] when the preset has no limit or the
453
+ source is within it (cfr_args() then decides, as before)."""
454
+ platform = PLATFORM_OF.get(preset)
455
+ fps_max = (PLATFORMS.get(platform) or {}).get("fps_max") if platform else None
456
+ v = meta.get("video") or {}
457
+ src = v.get("fps")
458
+ if not fps_max or not src or src <= fps_max + 0.01:
459
+ return []
460
+ try:
461
+ exact = Fraction(v.get("avg_frame_rate") or "0")
462
+ except (ValueError, ZeroDivisionError):
463
+ exact = Fraction(0)
464
+ if not exact or abs(float(exact) - src) > 0.01:
465
+ exact = Fraction(src).limit_denominator(1001)
466
+ n = math.ceil(float(exact) / (fps_max + 0.01))
467
+ rate = exact / n
468
+ note(f"source is {src:g} fps, above {platform}'s limit of {fps_max:g}: exported at {float(rate):.6g} fps, "
469
+ f"one source frame in {n}")
470
+ return ["-fps_mode", "cfr", "-r", str(rate.numerator) if rate.denominator == 1 else f"{rate.numerator}/{rate.denominator}"]
471
+
472
+
445
473
  def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool) -> List[str]:
446
474
  """--max-mb: the preset's x264 line with its CRF replaced by the bitrate that fits the size,
447
475
  for a two-pass encode. Refused where that cannot be done honestly: another codec, --crf, --hw,
@@ -669,7 +697,7 @@ def main() -> int:
669
697
  # a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
670
698
  # no longer be a copy, and would silently mislabel colour the agent never actually looked at
671
699
  if "-r" not in video:
672
- cmd += cfr_args(meta)
700
+ cmd += _platform_rate_args(args.preset, meta) or cfr_args(meta)
673
701
  if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
674
702
  cmd += bt709_tag_args(video[video.index("-c:v") + 1])
675
703
  if not p.get("intermediate"):
@@ -767,7 +795,13 @@ def main() -> int:
767
795
  # a private name: <stem>_loudnorm.<ext> is loudness.py's own default output, so a
768
796
  # real file of that name next to the export was overwritten and renamed away (review 7)
769
797
  tmp = str(Path(output).with_name(f".{Path(output).stem}.normalize-{os.getpid()}{Path(output).suffix}"))
770
- proc = run_tool([str(HERE / "loudness.py"), output, "-I", f"{spec['lufs']:g}", "--tp", f"{spec['tp']:g}", "-o", tmp, "--json"] + child_args())
798
+ # under --max-mb the sound is re-encoded at the bitrate the size budget was built on
799
+ # (the preset's); loudness.py's own 192k, raised to 256k/320k on an overshoot, put
800
+ # the file over the limit it had been encoded to meet (through 3.8.8, audit A112)
801
+ pinned = (["--audio-bitrate", p["audio"][p["audio"].index("-b:a") + 1]]
802
+ if args.max_mb is not None and "-b:a" in p["audio"] else [])
803
+ proc = run_tool([str(HERE / "loudness.py"), output, "-I", f"{spec['lufs']:g}", "--tp", f"{spec['tp']:g}", "-o", tmp, "--json"]
804
+ + pinned + child_args())
771
805
  try:
772
806
  child = json.loads(proc.stdout)
773
807
  except ValueError:
package/scripts/fit.py CHANGED
@@ -34,14 +34,13 @@ Examples:
34
34
  python3 fit.py input.mp4 --flip h # mirror horizontally
35
35
  """
36
36
  import argparse
37
- import math
38
37
  import sys
39
38
  from fractions import Fraction
40
39
  from typing import List
41
40
 
42
41
  from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, length_output_t, stream_starts
43
42
  from _common.timeline import exact_rate, output_timecode_args
44
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, 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, frame_size, display_size, square_pixels, square_pixels_note, note
43
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, probe, run, run_keeping_subtitles, validate_color, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
45
44
  from _common.emit import input_length
46
45
  BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
47
46
 
@@ -227,7 +226,9 @@ def main() -> int:
227
226
  # after it (a B-frame HEVC source) and lose the frames before it, so the first
228
227
  # picture would not be the one the start timecode below names
229
228
  m = min(SEEK_MARGIN, round(start, 3))
230
- pre_input += ["-ss", f"{start - m:.3f}"]
229
+ # a trim from the start seeks nowhere: no -ss, so the timeline stays where it is and
230
+ # a subtitle/data track is kept below (`-ss 0.000` dropped it, A158)
231
+ pre_input += ["-ss", f"{start - m:.3f}"] if round(start, 3) > 0 else []
231
232
  # without sound the -t only counts frames (picture_output_t). Not from the centre:
232
233
  # there the output's clock starts at the seek, between two frames, and the encoder
233
234
  # rounds each frame to its own tick
package/scripts/freeze.py CHANGED
@@ -20,7 +20,7 @@ import argparse
20
20
  import sys
21
21
 
22
22
  from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
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, MissingFpsError, time_arg, fmt_secs, run
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, time_arg, fmt_secs, run
24
24
  from _common.emit import input_length
25
25
 
26
26
 
@@ -38,11 +38,11 @@ from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
38
38
  from _common.drawtext import shaping_refusal
39
39
  from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
40
40
  color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
41
- ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
41
+ ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, probe, rgb_chain_to_bt709, run_keeping_subtitles, video_args,
42
42
  drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
43
43
  LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
44
44
  emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
45
- EMOJI_ASSET_HINT, text_width_em, drawtext_shaping, wrap_text, WRAP_MODES,
45
+ EMOJI_ASSET_HINT, text_width_em, wrap_text, WRAP_MODES,
46
46
  SAFE_WIDTH_FRACTION, drawtext_draws_newline_glyph, is_multiline, _DRAWTEXT_PENDING)
47
47
  from _common.emit import note, warn
48
48
  from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
@@ -429,7 +429,6 @@ def main(force_route: Optional[str] = None) -> int:
429
429
  el["text"] = text
430
430
  elements.append(el)
431
431
 
432
- extra_inputs: List[str] = []
433
432
  fc: List[str] = [] # filter_complex chains (used by templates that need animated boxes)
434
433
  if 0 < min(W, H) < 64: # 0x0 is a dry-run probe of an intermediate that does not exist yet
435
434
  die(f"the frame is {W}x{H}; the templates are sized from it and need at least 64 px on the short side")
package/scripts/insert.py CHANGED
@@ -28,7 +28,7 @@ import math
28
28
  import sys
29
29
 
30
30
  from _common import still_input_args # noqa: E402
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
31
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
32
32
  from _common.emit import note
33
33
  from _common.probe import display_size, square_pixels, square_pixels_note
34
34
 
package/scripts/look.py CHANGED
@@ -19,7 +19,7 @@ from typing import List, Optional
19
19
 
20
20
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
21
21
  from _common import still_input_args # noqa: E402
22
- 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
22
+ from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info, probe, run, run_analysis, time_arg
23
23
  from _common import drawtext_fontfile # noqa: E402
24
24
  from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
25
25
  from _common.emit import note, record_fallback # noqa: E402
@@ -30,6 +30,8 @@ from _common.emit import input_length # noqa: E402
30
30
  # --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
31
31
  # Action and title safe are SMPTE ST 2046-1's 93 % and 90 % of a 16:9 picture; the 4:3 centre cut
32
32
  # is the 4:3 window of a 16:9 frame with SD's own 90 % action / 80 % title safe inside it.
33
+ # the extensions an -o is taken verbatim with (one picture); any other -o is a stem
34
+ IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp")
33
35
  GUIDES = {"action": 0.93, "title": 0.90}
34
36
  GUIDE_43 = {"action": 0.90, "title": 0.80}
35
37
  GUIDE_COLOURS = {"action": "yellow", "title": "cyan", "4:3": "white"}
@@ -319,7 +321,7 @@ def main() -> int:
319
321
  csq = (square_pixels(cmeta.get("video")) + ",") if square_pixels(cmeta.get("video")) else ""
320
322
  for idx, t in enumerate(args.at):
321
323
  sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
322
- if args.output and len(args.at) == 1:
324
+ if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in IMAGE_EXTS:
323
325
  out = args.output # one frame, one named image file: the caller's -o is the contract
324
326
  else:
325
327
  # several frames, or -o given as a stem/prefix: an occurrence index keeps every
@@ -352,7 +354,7 @@ def main() -> int:
352
354
  sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
353
355
  if dur and sec > dur:
354
356
  die(f"--at {t} is beyond the duration ({dur:.2f}s)")
355
- if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
357
+ if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in IMAGE_EXTS:
356
358
  out = args.output # one frame, one named image file: the caller's -o is the contract
357
359
  else:
358
360
  # several frames, or -o given as a stem/prefix without an image extension
@@ -382,7 +384,10 @@ def main() -> int:
382
384
  info(f"only {frames_avail} frame(s) available; sheet reduced to {cols}x{rows}")
383
385
  step = dur / n
384
386
  tile_w = max(2, (args.width // cols) // 2 * 2)
385
- out = args.output or os.path.join(outdir, f"{stem}_sheet.png")
387
+ # -o without an image extension is a stem here too (<stem>_sheet.png), as for --at and
388
+ # --compare: given verbatim, ffmpeg could not choose a muxer for it (kind ffmpeg, A159)
389
+ out = (args.output if args.output and Path(args.output).suffix.lower() in IMAGE_EXTS
390
+ else os.path.join(outdir, f"{(args.output and Path(args.output).stem) or stem}_sheet.png"))
386
391
  # sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
387
392
  # with only a handful of frames a mid-slice seek skips past them all: start at 0 instead
388
393
  seek = 0.0 if frames_avail < 4 else step / 2
package/scripts/loop.py CHANGED
@@ -32,7 +32,7 @@ import os
32
32
  import sys
33
33
  import tempfile
34
34
 
35
- from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
35
+ from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
36
36
  from _common.emit import warn
37
37
  from _common.decision import HELD_FRAMES_WARN_BYTES, av_trim, held_frames_bytes, length_output_t, stream_starts
38
38
  from _common.probe import decode_gray_frames, decode_pcm_mono
@@ -114,11 +114,6 @@ def loudnorm_mode(stats: dict, lufs: float, tp: float, lra: float) -> str:
114
114
 
115
115
  def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output: str) -> dict:
116
116
  """One normalised file at `lufs` / `tp_max`, measured after: the keys emit() takes for it."""
117
- af = (
118
- f"loudnorm=I={lufs}:TP={tp_max}:LRA={args.lra}"
119
- f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
120
- f":measured_thresh={stats['input_thresh']}:offset={stats['target_offset']}:linear=true:print_format=summary"
121
- )
122
117
  sr = args.sample_rate or meta["audio"].get("sample_rate") or 48000
123
118
  ext = os.path.splitext(output)[1].lower()
124
119
  bitrate_pinned = args.audio_bitrate is not None
@@ -32,7 +32,7 @@ import os
32
32
  import sys
33
33
  from typing import Dict, List, Optional, Tuple
34
34
 
35
- 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 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_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
36
36
  from _common.emit import note, record_fallback, record_skip, warn
37
37
  from _common.decision import av_trim, conform_output_t, conform_to_length, rebase_project_paths
38
38
  from _common.probe import display_size, square_pixels, square_pixels_note
@@ -35,7 +35,7 @@ from typing import Any, Dict, List, Optional
35
35
 
36
36
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
37
37
  from _common import still_input_args # noqa: E402
38
- from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
38
+ from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, X264_PRESETS, time_arg, fmt_secs
39
39
  from _common.emit import note, warn
40
40
  from _common.color import is_rgb
41
41
  from _common.decision import length_output_t, window_expr, windowed_overlay_x
package/scripts/probe.py CHANGED
@@ -15,10 +15,11 @@ Examples:
15
15
  points a stream copy can start at, and where cut.py --smart's re-encode around a cut ends.
16
16
  """
17
17
  import argparse
18
+ import json
18
19
  import math
19
20
  import sys
20
21
 
21
- from _common import add_common, analyze_levels, apply_common, die, keyframe_times, print_json, probe, time_arg
22
+ from _common import STATE, add_common, analyze_levels, apply_common, die, keyframe_times, print_json, probe, time_arg
22
23
 
23
24
 
24
25
  def keyframe_candidates(result: dict, times: list, window: float) -> list:
@@ -87,7 +88,17 @@ def main() -> int:
87
88
  cur = r
88
89
  for key in args.field.split("."):
89
90
  cur = cur.get(key) if isinstance(cur, dict) else None
90
- print(cur if cur is not None else "")
91
+ # a string as itself; anything else as JSON (true, null, {"codec": ...}), not Python's
92
+ # repr (True, None, {'codec': ...}), which no caller can parse (through 3.8.8, A123).
93
+ # Under --json (or --json-brief, which implies it) a missing field is null and a string
94
+ # is quoted: every line is JSON. Without either a missing field is an empty line, as it
95
+ # always was, so `[ -z "$(probe.py f --field x)" ]` keeps working
96
+ if isinstance(cur, str) and not STATE.json:
97
+ print(cur)
98
+ elif cur is None and not STATE.json:
99
+ print("")
100
+ else:
101
+ print(json.dumps(cur, ensure_ascii=False))
91
102
  return 0
92
103
 
93
104
  if args.compact:
package/scripts/proxy.py CHANGED
@@ -5,7 +5,8 @@ editing-decision workflows that only need to look at (or feed a model) a
5
5
  much smaller stand-in for the original.
6
6
 
7
7
  Resizes to --width (default 640px, height follows the source aspect) or by
8
- --scale factor, re-encodes at a proxy-grade --quality (default 30 - well above any
8
+ --scale factor, never wider than the source (a proxy is not upscaled) and
9
+ never under 2px, re-encodes at a proxy-grade --quality (default 30 - well above any
9
10
  delivery preset's 18-24 in export.py, since a proxy trades visual quality for
10
11
  size and speed), and always uses the fastest x264/x265 preset. Keeps the
11
12
  source's own dynamic range (an HDR source proxies to HEVC10, same as every
@@ -65,7 +66,15 @@ def main() -> int:
65
66
  note(square_pixels_note(meta["video"]))
66
67
  has_audio = bool(meta.get("audio")) and not args.no_audio
67
68
 
68
- out_w = even(sw * args.scale) if args.scale is not None else even(args.width)
69
+ # A proxy is never wider than its source, and never under 2 px: through 3.8.8 the default
70
+ # --width 640 upscaled a smaller source, and a --scale that rounded to 0 became scale=0, which
71
+ # ffmpeg reads as "the input's width" (a full-size proxy, audit A162)
72
+ asked = even(sw * args.scale) if args.scale is not None else even(args.width)
73
+ ceiling = max(2, sw - sw % 2) if sw else asked
74
+ out_w = max(2, min(asked, ceiling))
75
+ if out_w != asked:
76
+ note(f"proxy width {out_w}px, not {asked}px: "
77
+ + (f"the source is {sw}px wide and a proxy is not upscaled" if asked > ceiling else "2px is the smallest width"))
69
78
  output = args.output or default_output(args.input, "proxy")
70
79
 
71
80
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", (square + "," if square else "") + f"scale={out_w}:-2"]
package/scripts/redact.py CHANGED
@@ -113,7 +113,11 @@ def main() -> int:
113
113
  # boxblur refuses a radius above half the plane: the chroma planes of 4:2:0 are half-size,
114
114
  # so they get their own (halved) radius; a 30 px region with the default 20 used to fail
115
115
  radius = max(1, min(args.blur_strength, min(args.width, args.height) // 2 - 1))
116
- chroma = max(1, min(radius // 2, min(args.width, args.height) // 4 - 1))
116
+ # at least 1 wherever the chroma plane can hold it: radius // 2 alone was 0 at
117
+ # --blur-strength 1, which left colour-only detail readable (review of #521). (FFmpeg
118
+ # 7.1 refuses a chroma radius of 1 on a 2 px chroma plane, a 4 px wide region; 6.1
119
+ # took it.) 0 is only a plane too small to blur: the luma still is
120
+ chroma = max(0, min(max(1, radius // 2), (min(args.width, args.height) // 2 - 1) // 2))
117
121
  region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
118
122
  else:
119
123
  region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
package/scripts/sphere.py CHANGED
@@ -6,7 +6,8 @@ turning an equirectangular (or other spherical-projection) source into an
6
6
  ordinary flat video pointed at a chosen direction -- the same "look this way"
7
7
  operation a VR headset or a 360 video player's viewport does, baked into a
8
8
  real file. --yaw/--pitch/--roll aim the camera; --h-fov/--v-fov set how wide
9
- the view is.
9
+ the view is (without --v-fov, the vertical view is the one that keeps the
10
+ output's pixels square).
10
11
 
11
12
  This tool does not decide WHERE to point the camera -- there is no subject
12
13
  detection or tracking here, only the typed rotation/FOV you give it (see this
@@ -29,9 +30,11 @@ Examples:
29
30
  python3 sphere.py gopro_max.mp4 --input-projection fisheye --yaw -45 --pitch 10 -o angle.mp4
30
31
  """
31
32
  import argparse
33
+ import math
32
34
  import sys
33
35
 
34
36
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
37
+ from _common.emit import note
35
38
 
36
39
  # v360's own AVOption names for the input projections real 360 cameras/exports actually
37
40
  # produce (ffmpeg -h filter=v360 documents 24 total; this is the subset a caller is likely
@@ -59,7 +62,9 @@ def main() -> int:
59
62
  aim.add_argument("--roll", type=float, default=0.0, help="tilt/roll rotation in degrees, -180..180 (default 0)")
60
63
  fov = ap.add_argument_group("field of view")
61
64
  fov.add_argument("--h-fov", type=float, default=90.0, help="output horizontal field of view in degrees, 1..170 (default 90)")
62
- fov.add_argument("--v-fov", type=float, default=60.0, help="output vertical field of view in degrees, 1..170 (default 60)")
65
+ fov.add_argument("--v-fov", type=float, default=None,
66
+ help="output vertical field of view in degrees, 1..170 (default: the one that keeps pixels square at "
67
+ "--h-fov and --width/--height, 58.7 at the defaults)")
63
68
  out = ap.add_argument_group("output frame")
64
69
  out.add_argument("--width", type=int, default=1920, help="output width in px, must be even (default 1920)")
65
70
  out.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
@@ -81,12 +86,27 @@ def main() -> int:
81
86
  die(f"--roll must be -180..180, got {args.roll}")
82
87
  if not 1 <= args.h_fov <= 170:
83
88
  die(f"--h-fov must be 1..170, got {args.h_fov}")
84
- if not 1 <= args.v_fov <= 170:
89
+ if args.v_fov is not None and not 1 <= args.v_fov <= 170:
85
90
  die(f"--v-fov must be 1..170, got {args.v_fov}")
86
91
  if args.width <= 0 or args.height <= 0:
87
92
  die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
88
93
  if args.width % 2 or args.height % 2:
89
94
  die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
95
+ # A rectilinear view has square pixels when tan(v/2) / tan(h/2) = H / W. Through 3.8.8 the
96
+ # default was a fixed 60 degrees, which at 90 x 1920x1080 squashed the view 2.6 % vertically
97
+ # (audit A163); the vertical FOV now follows from the other three unless it is given.
98
+ square_v = math.degrees(2 * math.atan(math.tan(math.radians(args.h_fov) / 2) * args.height / args.width))
99
+ if args.v_fov is None:
100
+ if not 1 <= square_v <= 170:
101
+ die(f"--h-fov {args.h_fov:g} at {args.width}x{args.height} needs a vertical field of view of {square_v:.1f} "
102
+ "degrees for square pixels, outside 1..170", hint="give --v-fov, or a lower --h-fov")
103
+ args.v_fov = round(square_v, 4)
104
+ else:
105
+ stretch = math.tan(math.radians(args.v_fov) / 2) / math.tan(math.radians(square_v) / 2)
106
+ if abs(stretch - 1) > 0.005:
107
+ note(f"--h-fov {args.h_fov:g} / --v-fov {args.v_fov:g} at {args.width}x{args.height} is not square-pixel: the view is "
108
+ f"{'squashed' if stretch > 1 else 'stretched'} {abs(stretch - 1) * 100:.1f} % vertically "
109
+ f"(--v-fov {square_v:.2f} keeps it square; omit --v-fov to get it)")
90
110
 
91
111
  meta = probe(args.input)
92
112
  if not meta.get("video"):
@@ -29,7 +29,7 @@ from pathlib import Path
29
29
 
30
30
  from _common.probe import PROBED_VIDEO
31
31
  from _common.runner import ffmpeg_failure_extra, default_selection_maps, require_filter
32
- from _common import 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, record_fallback, EVEN_SCALE
32
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
33
33
 
34
34
 
35
35
  def main() -> int:
@@ -96,7 +96,10 @@ def main() -> int:
96
96
  die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
97
97
 
98
98
  crop_mode = {"keep": 0, "black": 1}[args.crop]
99
- transform_vf = f"{even}vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
99
+ # optzoom=0: the zoom is --zoom and nothing else. vidstab's default (optzoom=1) adds its own
100
+ # "optimal" zoom on top, so --zoom 0 did not keep the original framing and --crop black
101
+ # rarely had an edge to fill (through 3.8.8, audit A156)
102
+ transform_vf = f"{even}vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=0"
100
103
  if args.tripod:
101
104
  # Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
102
105
  # camera path makes no sense once every frame is locked to one fixed reference.
package/scripts/sync.py CHANGED
@@ -27,15 +27,13 @@ Examples:
27
27
  python3 sync.py cam.mp4 recorder.wav --fix-drift --replace-audio # long takes: fix clock drift too
28
28
  """
29
29
  import argparse
30
- import cmath
31
- import json
32
30
  import math
33
31
  import os
34
32
  import sys
35
33
  from typing import Dict, List
36
34
 
37
35
  from _common.runner import fail_if_classified
38
- from _common import STATE, video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
36
+ from _common import STATE, video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, run, decode_pcm_mono, rms_envelope
39
37
  from _common.emit import record_skip, warn
40
38
  from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, length_output_t, silence_filter, stream_starts
41
39
  from _common.timeline import output_timecode_args
package/scripts/verify.py CHANGED
@@ -18,7 +18,6 @@ import argparse
18
18
  import contextlib
19
19
  import io
20
20
  import json
21
- import os
22
21
  import subprocess
23
22
  import sys
24
23
  import tempfile
@@ -26,10 +25,12 @@ import time
26
25
  from pathlib import Path
27
26
  from typing import Any, Dict, List, Optional, Tuple
28
27
 
29
- from _common import STATE, add_common, apply_common, die, emit, guard_sidecar, info, probe
28
+ from _common import STATE, add_common, apply_common, die, emit, guard_sidecar, info, probe, refuse_output_is_input, MEDIA_EXT as _MEDIA_EXT
30
29
 
31
30
  HERE = Path(__file__).resolve().parent
32
- MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
31
+ # _common's one list less the stills, as batch.py walks a folder: verify kept a list of its own,
32
+ # and a folder walk skipped .ts, .3gp, .wmv, .gif, .ogg, .opus, .aif/.aiff, .caf and .wma (#467 A218)
33
+ MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")}
33
34
 
34
35
 
35
36
  def unique_stems(files: List[Path]) -> Dict[Path, str]:
@@ -39,18 +40,31 @@ def unique_stems(files: List[Path]) -> Dict[Path, str]:
39
40
  resolve to the identical output prefix. Each file's own steps still ran correctly in isolation,
40
41
  but with --keep the second file's outputs silently overwrote the first file's on disk, and the
41
42
  report showed PASS for both without ever flagging the collision (same bug class already fixed
42
- in batch.py). Disambiguate every colliding stem with a stable per-collision index instead."""
43
+ in batch.py). Disambiguate every colliding stem with a stable per-collision index instead.
44
+
45
+ A stem is compared case-folded (clip.mp4 and Clip.MOV share one name on a case-insensitive
46
+ disk), and an indexed name skips any stem already taken, a file's own included: through 3.8.8
47
+ two clip.mp4 and a clip_1.mp4 gave "clip_1" twice (audit A121)."""
43
48
  counts: Dict[str, int] = {}
44
49
  for f in files:
45
- counts[f.stem] = counts.get(f.stem, 0) + 1
50
+ counts[f.stem.casefold()] = counts.get(f.stem.casefold(), 0) + 1
51
+ taken = {f.stem.casefold() for f in files if counts[f.stem.casefold()] == 1}
46
52
  seen: Dict[str, int] = {}
47
53
  stems: Dict[Path, str] = {}
48
54
  for f in files:
49
- if counts[f.stem] > 1:
50
- seen[f.stem] = seen.get(f.stem, 0) + 1
51
- stems[f] = f"{f.stem}_{seen[f.stem]}"
52
- else:
55
+ key = f.stem.casefold()
56
+ if counts[key] == 1:
53
57
  stems[f] = f.stem
58
+ continue
59
+ n = seen.get(key, 0)
60
+ while True:
61
+ n += 1
62
+ name = f"{f.stem}_{n}"
63
+ if name.casefold() not in taken:
64
+ break
65
+ seen[key] = n
66
+ taken.add(name.casefold())
67
+ stems[f] = name
54
68
  return stems
55
69
 
56
70
 
@@ -96,6 +110,20 @@ def _probe_capturing(path: str) -> "Tuple[Optional[Dict[str, Any]], Dict[str, An
96
110
  STATE.json = was_json
97
111
 
98
112
 
113
+ # The shared flags given to verify.py that every step receives as well (set in main())
114
+ FORWARD: Dict[str, bool] = {"overwrite": False, "fast": False}
115
+
116
+
117
+ def _forwarded(argv: List[str]) -> List[str]:
118
+ """--overwrite and --fast as the caller gave them to verify.py: through 3.8.8 neither reached
119
+ a step, so a rerun into the same --out failed every step on its own earlier output (audit
120
+ A119). The steps' --timeout is verify's own per-step limit (step()'s `timeout`)."""
121
+ out = ["--overwrite"] if FORWARD["overwrite"] else []
122
+ if FORWARD["fast"] and "--fast" not in argv:
123
+ out.append("--fast")
124
+ return out
125
+
126
+
99
127
  def step(name: str, argv: List[str], timeout: float) -> Dict:
100
128
  """Run one step and keep what its tool said: `ok` (it completed), and on a failure the tool's
101
129
  own `kind`, `reason` and `message` (its --json failure document), `error` being that message
@@ -113,7 +141,7 @@ def step(name: str, argv: List[str], timeout: float) -> Dict:
113
141
  r.update(kind="verification", reason="hdr_lost", message=r["error"])
114
142
  return r
115
143
  try:
116
- proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:] + ["--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
144
+ proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:] + _forwarded(argv) + ["--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
117
145
  text=True, encoding="utf-8", errors="replace", timeout=timeout or None)
118
146
  except subprocess.TimeoutExpired:
119
147
  msg = f"timeout after {timeout:.0f}s"
@@ -146,7 +174,7 @@ def main() -> int:
146
174
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
147
175
  ap.add_argument("paths", nargs="+", help="media files and/or folders")
148
176
  ap.add_argument("--out", help="directory for outputs (default: temp dir, deleted unless --keep)")
149
- ap.add_argument("--keep", action="store_true", help="keep the outputs")
177
+ ap.add_argument("--keep", action="store_true", help="keep the outputs (without --out: in a new folder, named as out_dir and on stderr)")
150
178
  ap.add_argument("--quick", action="store_true", help="probe, copy cut, fit, caption, export only")
151
179
  ap.add_argument("--seconds", type=float, default=6.0, help="length of the test cut taken from each file (default 6)")
152
180
  ap.add_argument("--timeout", type=float, default=600.0, help="per-step timeout in seconds (default 600; 0 = no limit, as everywhere else)")
@@ -168,6 +196,7 @@ def main() -> int:
168
196
  # fault ("9 of 11 verification steps failed") rather than the flag's
169
197
  die(f"--seconds must be more than 0 (the length of the test cut), got {args.seconds:g}")
170
198
  files = collect(args.paths)
199
+ FORWARD.update(overwrite=STATE.overwrite, fast=STATE.fast)
171
200
  tmp = None
172
201
  if args.out:
173
202
  # made first, so a --report inside a new --out folder passes the guard below (#413 review)
@@ -177,20 +206,27 @@ def main() -> int:
177
206
  # a file this run writes itself (#408): not one of the media it verifies (a folder's
178
207
  # files included), nor an existing file without --overwrite
179
208
  guard_sidecar(args.report, *args.paths, *map(str, files))
180
- if not args.out:
209
+ if not args.out and args.keep:
210
+ # --keep without --out: a folder of its own that outlives the run, named in the document
211
+ # (`out_dir`) and on stderr; through 3.8.8 it was the temporary directory, deleted at exit
212
+ # whatever --keep said, and never named (audit A120)
213
+ outdir = Path(tempfile.mkdtemp(prefix="ffskill_verify_"))
214
+ elif not args.out:
181
215
  tmp = tempfile.TemporaryDirectory(prefix="ffskill_verify_")
182
216
  outdir = Path(tmp.name)
217
+ kept_dir = None if tmp else str(outdir)
183
218
 
184
219
  stem_for = unique_stems(files)
185
- results = []
220
+ # every file's plan is made before any step runs, so the names all of them will write are
221
+ # checked against every input first (below)
222
+ jobs: List[Tuple[Path, Dict, List[Tuple[str, List[str]]], Optional[Path]]] = []
186
223
  for f in files:
187
- info(f"=== {f}")
188
224
  entry: Dict = {"file": str(f), "steps": [], "skipped": []}
189
225
  meta, failure = _probe_capturing(str(f))
190
226
  if meta is None:
191
227
  entry["steps"].append(dict({"step": "probe", "ok": False, "seconds": 0, "error": _cell(failure.get("message", ""))}, **failure))
192
228
  entry["skipped"].append({"step": "*", "reason": "input_unreadable", "message": "no other step runs on a file probe cannot read"})
193
- results.append(entry)
229
+ jobs.append((f, entry, [], None))
194
230
  continue
195
231
  entry["probe"] = {k: meta.get(k) for k in ("duration", "format")}
196
232
  entry["probe"]["video"] = {k: (meta.get("video") or {}).get(k) for k in ("codec", "width", "height", "fps", "pix_fmt", "hdr_format", "rotation", "variable_frame_rate_suspected")}
@@ -202,7 +238,8 @@ def main() -> int:
202
238
  cut = f"{stem}_cut.mp4"
203
239
  seg_end = min(dur, args.seconds) if dur else args.seconds
204
240
  fast = ["--fast"]
205
- plan = []
241
+ plan: List[Tuple[str, List[str]]] = []
242
+ cues: Optional[Path] = None
206
243
  skipped: List[Dict[str, str]] = entry["skipped"]
207
244
 
208
245
  def skip(step_name: str, reason: str, message: str) -> None:
@@ -212,7 +249,6 @@ def main() -> int:
212
249
  plan.append(("cut accurate", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "--accurate", "-o", f"{stem}_acc.mp4"] + fast))
213
250
  plan.append(("fit 9:16", ["fit.py", cut, "--aspect", "9:16", "--width", "720", "-o", f"{stem}_fit.mp4"] + fast))
214
251
  cues = outdir / f"{stem_for[f]}_cues.txt"
215
- cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
216
252
  plan.append(("caption", ["caption.py", cut, "--text", str(cues), "--animate", "pop", "--karaoke", "-o", f"{stem}_cap.mp4"] + fast))
217
253
  if not args.quick:
218
254
  plan.append(("overlay text", ["overlay.py", cut, "--text", "verify", "--position", "top-left", "-o", f"{stem}_ovl.mp4"] + fast))
@@ -245,6 +281,30 @@ def main() -> int:
245
281
  else:
246
282
  for n in ("loudness measure", "silence list", "audio downmix"):
247
283
  skip(n, "quick", "--quick runs probe, copy cut, fit, caption and export only")
284
+ jobs.append((f, entry, plan, cues))
285
+
286
+ # A name verify generates can be another input's (clip.mp4's cut is clip_cut.mp4, which can be
287
+ # a file of the same folder): each step checks its output against its own input only, so with
288
+ # --overwrite that input was replaced and its own steps then read the replacement. Every name
289
+ # the run will write -- step outputs, caption's sidecars, the cue file -- against every input,
290
+ # through a link or another case of the name included, before the first is written.
291
+ every_input = [str(f) for f in files]
292
+ for f, _entry, plan, cues in jobs:
293
+ written = [("cue file", str(cues))] if cues else []
294
+ for name, argv in plan:
295
+ if "-o" in argv:
296
+ out = argv[argv.index("-o") + 1]
297
+ written.append((name, out))
298
+ if argv[0] == "caption.py":
299
+ written += [(name, str(Path(out).with_suffix(ext))) for ext in (".srt", ".ass")]
300
+ for name, out in written:
301
+ refuse_output_is_input(out, *every_input, why=f"it is where verify writes the {name!r} step's output for {str(f)!r}")
302
+
303
+ results = []
304
+ for f, entry, plan, cues in jobs:
305
+ info(f"=== {f}")
306
+ if cues:
307
+ cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
248
308
  for name, argv in plan:
249
309
  r = step(name, argv, args.timeout)
250
310
  entry["steps"].append(r)
@@ -275,14 +335,16 @@ def main() -> int:
275
335
  info(f"wrote {args.report}")
276
336
  if not args.json:
277
337
  print(report)
278
- if tmp and not args.keep:
338
+ if tmp:
279
339
  tmp.cleanup()
340
+ if kept_dir:
341
+ info(f"step outputs kept in {kept_dir}")
280
342
  notes = (["--dry-run is ignored by verify.py: its steps ran and wrote their outputs"] if ignored_dry_run else [])
281
343
  if failed:
282
344
  die(f"{failed} of {total} verification steps failed", kind="verification", output=None, dry_run=STATE.dry_run,
283
- report=args.report, files=results, failed=failed, total=total, **({"notes": notes} if notes else {}))
345
+ report=args.report, files=results, failed=failed, total=total, out_dir=kept_dir, **({"notes": notes} if notes else {}))
284
346
  if args.json:
285
- emit(None, report=args.report, files=results, failed=failed, total=total, **({"notes": notes} if notes else {}))
347
+ emit(None, report=args.report, files=results, failed=failed, total=total, out_dir=kept_dir, **({"notes": notes} if notes else {}))
286
348
  return 0
287
349
 
288
350