From 949c02ac5ba22e578a7ef8a8d7399b09bdfdd915 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 29 Oct 2026 05:50:16 +0000
Subject: [PATCH 001/191] Add OpenSSF Scorecard and MCP registry manifests
Scorecard is pinned to the commits in ossf/scorecard's own workflow, and it
publishes results and uploads SARIF. server.json and glama.json follow the
current official schemas. npx ffmpeg-skill mcp starts the stdio server, which
is the command the registry package argument names.
Co-authored-by: Shogo Kajiyama
---
.github/scripts/bump_server_json.py | 15 +++++++++
.github/workflows/release.yml | 6 +++-
.github/workflows/scorecard.yml | 50 +++++++++++++++++++++++++++
CONTRIBUTING.md | 22 ++++++++++++
README.md | 1 +
bin/install.js | 12 ++++++-
glama.json | 4 +++
package.json | 1 +
server.json | 29 ++++++++++++++++
tests/test_contract.py | 52 +++++++++++++++++++++++++++++
10 files changed, 190 insertions(+), 2 deletions(-)
create mode 100644 .github/scripts/bump_server_json.py
create mode 100644 .github/workflows/scorecard.yml
create mode 100644 glama.json
create mode 100644 server.json
diff --git a/.github/scripts/bump_server_json.py b/.github/scripts/bump_server_json.py
new file mode 100644
index 00000000..c6230dad
--- /dev/null
+++ b/.github/scripts/bump_server_json.py
@@ -0,0 +1,15 @@
+"""Move both version fields in server.json when a release bumps package.json.
+
+The MCP Registry rejects a republish of an existing version, and it checks the
+published npm package at server.json's package version. Both the server version
+and the npm package version have to move together. A blind replace of the old
+version string is safe here: those two fields are the only places it appears.
+"""
+
+
+def bump_server_json(text: str, old: str, new: str) -> str:
+ needle = f'"version": "{old}"'
+ count = text.count(needle)
+ if count != 2:
+ raise ValueError(f"server.json: expected 2 version fields at {old!r}, found {count}")
+ return text.replace(needle, f'"version": "{new}"')
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6ddd3ab4..a137a532 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -156,6 +156,10 @@ jobs:
# Only the two current-version mentions move; a blind replace of the version string
# also rewrote every historical "since"/"added in" version (see the helper's docstring).
sys.path.insert(0, str(root / ".github" / "scripts"))
+ from bump_server_json import bump_server_json
+ server_path = root / "server.json"
+ server_path.write_text(bump_server_json(server_path.read_text(encoding="utf-8"), old, new), encoding="utf-8")
+
from bump_contract_md import bump_contract_md
contract_path = root / "docs" / "contract.md"
contract = contract_path.read_text(encoding="utf-8")
@@ -218,7 +222,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- git add package.json .claude-plugin/plugin.json docs/contract.md CHANGELOG.md docs/roadmap.md docs/design-decisions.md
+ git add package.json server.json .claude-plugin/plugin.json docs/contract.md CHANGELOG.md docs/roadmap.md docs/design-decisions.md
git commit -m "chore(release): bump version to ${NEW_VERSION} [skip ci]"
# A merge that landed during this step would still make the push non-fast-forward;
# replay the bump on top of it (a conflict here is real and should fail the run).
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
new file mode 100644
index 00000000..af04fe5a
--- /dev/null
+++ b/.github/workflows/scorecard.yml
@@ -0,0 +1,50 @@
+# OpenSSF Scorecard. Pins match ossf/scorecard's own scorecard-analysis.yml
+# (actions/checkout v7.0.1, ossf/scorecard-action v2.5.0, actions/upload-artifact v7.0.2,
+# github/codeql-action/upload-sarif v4.38.3). branch_protection_rule is the trigger the
+# Branch-Protection check documents:
+# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
+name: Scorecard supply-chain security
+on:
+ push:
+ branches: [main]
+ schedule:
+ # Weekly. Same Saturday slot as ossf/scorecard's own workflow.
+ - cron: '30 1 * * 6'
+ branch_protection_rule:
+
+permissions: read-all
+
+jobs:
+ analysis:
+ name: Scorecard analysis
+ runs-on: ubuntu-latest
+ permissions:
+ # Upload the SARIF to code scanning.
+ security-events: write
+ # OIDC token for publish_results.
+ id-token: write
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Run analysis
+ uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ publish_results: true
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: SARIF file
+ path: results.sarif
+ retention-days: 5
+
+ - name: Upload to code-scanning
+ uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
+ with:
+ sarif_file: results.sarif
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 08e67474..61cc99f5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -97,6 +97,28 @@ would have caught the original bug isn't done yet.
planned. A label-driven release (release.yml's auto-bump) moves only the version number and
keeps the hand-written prose, marked with the version it describes
(`.github/scripts/bump_roadmap_md.py`); the next hand-written release PR replaces the sentence.
+ The same bump moves both `version` fields in `server.json` (`.github/scripts/bump_server_json.py`).
+
+## Publishing the MCP listing
+
+These steps are manual. Nothing in CI publishes the server.
+
+The registry checks the **already published** npm package. `mcpName` in `package.json` has to
+equal `server.json`'s `name` (`io.github.kajisho5/ffmpeg-skill`) at that exact version, so publish
+npm first (the release workflow does) and only then the registry. Clients run
+`npx -y ffmpeg-skill mcp`, which is `python3 mcp/server.py` on stdio.
+
+1. Install [`mcp-publisher`](https://modelcontextprotocol.io/registry/quickstart) and run
+ `mcp-publisher login github` as **kajisho5** (GitHub auth only allows the `io.github.kajisho5/`
+ namespace). `mcp-publisher validate`, then `mcp-publisher publish`.
+2. Smithery's current publish docs are a public URL or an `.mcpb` bundle
+ (`smithery auth login`, then `smithery mcp publish`). `smithery.yaml` is not in those docs
+ (`/docs/config` and `/docs/build/project-config` 404; the docs index has no such page), so this
+ repo does not ship one. There is no `.mcpb` bundle here either.
+3. Glama: submit the GitHub repo, then claim it. `glama.json` lists maintainer `kajisho5`.
+ Claiming again is what picks up a change to that file.
+4. Scorecard uploads SARIF to code scanning. That upload needs code scanning enabled on the
+ repository. The badge in the README fills in after a scan with `publish_results` has run.
## Reporting issues
diff --git a/README.md b/README.md
index d35d9937..fdac9ce0 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,7 @@ No cloud · No API keys · Python standard library
+
diff --git a/bin/install.js b/bin/install.js
index 451bbbb3..a0a2d552 100755
--- a/bin/install.js
+++ b/bin/install.js
@@ -14,6 +14,7 @@
* npx ffmpeg-skill --uninstall # remove from the selected targets
* npx ffmpeg-skill contract --json # machine-readable execution contract (see docs/contract.md)
* npx ffmpeg-skill doctor [--json] # which required ffmpeg capabilities this machine has
+ * npx ffmpeg-skill mcp # stdio MCP server (python3 mcp/server.py); registry clients pass `mcp`
*
* Already installed? re-run `npx ffmpeg-skill` to refresh ~/.claude/skills/ffmpeg-skill
* Copies are not updated automatically.
@@ -103,7 +104,8 @@ function warnDeprecatedPython(platform, spawn, warn) {
}
function main() {
-// `contract` / `doctor` are answered by scripts/_contract.py; everything else installs.
+// `contract` / `doctor` are answered by scripts/_contract.py. `mcp` is the stdio server
+// (server.json's packageArguments). Everything else installs.
if (args[0] === 'contract' || args[0] === 'doctor') {
const py = runWithPython([path.join(ROOT, 'scripts', '_contract.py'), ...args], process.platform, spawnSync);
if (py.missing) {
@@ -112,6 +114,14 @@ if (args[0] === 'contract' || args[0] === 'doctor') {
}
process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
}
+if (args[0] === 'mcp') {
+ const py = runWithPython([path.join(ROOT, 'mcp', 'server.py'), ...args.slice(1)], process.platform, spawnSync);
+ if (py.missing) {
+ console.error(`error: could not find a Python 3.9+ interpreter (tried: ${py.candidates.join(', ')}). Install Python 3.9+ and ensure it is on PATH.`);
+ process.exit(127);
+ }
+ process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
+}
const home = os.homedir();
const targets = [];
diff --git a/glama.json b/glama.json
new file mode 100644
index 00000000..0434010c
--- /dev/null
+++ b/glama.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://glama.ai/mcp/schemas/server.json",
+ "maintainers": ["kajisho5"]
+}
diff --git a/package.json b/package.json
index d60d9678..b1ed2a77 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
{
"name": "ffmpeg-skill",
"version": "2.17.3",
+ "mcpName": "io.github.kajisho5/ffmpeg-skill",
"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.",
"keywords": [
"ffmpeg",
diff --git a/server.json b/server.json
new file mode 100644
index 00000000..82874ebd
--- /dev/null
+++ b/server.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
+ "name": "io.github.kajisho5/ffmpeg-skill",
+ "title": "ffmpeg-skill",
+ "description": "Local FFmpeg video and audio tools over stdio. Footage stays on this machine.",
+ "repository": {
+ "url": "https://github.com/kajisho5/ffmpeg-skill",
+ "source": "github",
+ "id": "1356077516"
+ },
+ "version": "2.17.3",
+ "websiteUrl": "https://github.com/kajisho5/ffmpeg-skill",
+ "packages": [
+ {
+ "registryType": "npm",
+ "identifier": "ffmpeg-skill",
+ "version": "2.17.3",
+ "transport": {
+ "type": "stdio"
+ },
+ "packageArguments": [
+ {
+ "type": "positional",
+ "value": "mcp"
+ }
+ ]
+ }
+ ]
+}
diff --git a/tests/test_contract.py b/tests/test_contract.py
index 612380a4..8a34142c 100644
--- a/tests/test_contract.py
+++ b/tests/test_contract.py
@@ -232,6 +232,47 @@ def test_claude_plugin_manifest_matches_package_and_skill(self):
self.assertTrue(set(manifest) <= {"name", "version", "description", "author", "homepage", "repository", "license", "keywords"}, sorted(manifest))
self.assertTrue(re.fullmatch(r"[a-z0-9-]+", manifest["name"]))
+ def test_mcp_registry_manifest_tracks_the_package_version(self):
+ """server.json is the official MCP Registry document (schema 2025-12-11). Its two
+ version fields and package.json's mcpName have to stay on the published npm version,
+ or mcp-publisher rejects the publish. release.yml's auto-bump moves server.json."""
+ import importlib.util
+ pkg = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
+ server = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
+ self.assertEqual(server["$schema"], "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json")
+ self.assertEqual(server["name"], "io.github.kajisho5/ffmpeg-skill")
+ self.assertEqual(pkg["mcpName"], server["name"])
+ self.assertEqual(server["version"], pkg["version"])
+ self.assertLessEqual(len(server["description"]), 100)
+ self.assertEqual(server["repository"]["source"], "github")
+ self.assertEqual(server["repository"]["url"], "https://github.com/kajisho5/ffmpeg-skill")
+ pkg_entry = server["packages"][0]
+ self.assertEqual(pkg_entry["registryType"], "npm")
+ self.assertEqual(pkg_entry["identifier"], "ffmpeg-skill")
+ self.assertEqual(pkg_entry["version"], pkg["version"])
+ self.assertEqual(pkg_entry["transport"], {"type": "stdio"})
+ self.assertEqual(pkg_entry["packageArguments"], [{"type": "positional", "value": "mcp"}])
+ glama = json.loads((ROOT / "glama.json").read_text(encoding="utf-8"))
+ self.assertEqual(glama["$schema"], "https://glama.ai/mcp/schemas/server.json")
+ self.assertEqual(glama["maintainers"], ["kajisho5"])
+ spec = importlib.util.spec_from_file_location(
+ "bump_server_json", ROOT / ".github" / "scripts" / "bump_server_json.py")
+ bump = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(bump)
+ bumped = json.loads(bump.bump_server_json((ROOT / "server.json").read_text(encoding="utf-8"), pkg["version"], "9.9.9"))
+ self.assertEqual(bumped["version"], "9.9.9")
+ self.assertEqual(bumped["packages"][0]["version"], "9.9.9")
+ self.assertEqual(bumped["name"], server["name"])
+ release = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
+ self.assertIn("bump_server_json", release)
+ self.assertIn("server.json", release)
+ scorecard = (ROOT / ".github" / "workflows" / "scorecard.yml").read_text(encoding="utf-8")
+ self.assertIn("ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e", scorecard)
+ self.assertIn("publish_results: true", scorecard)
+ self.assertIn("branch_protection_rule:", scorecard)
+ self.assertIn("security-events: write", scorecard)
+ self.assertIn("id-token: write", scorecard)
+
def test_pages_site_builds_with_no_dead_links(self):
"""The GitHub Pages site (pages.yml) is assembled from the repo's own Markdown by
.github/scripts/build_pages.py, which refuses a relative link whose target does not
@@ -1727,6 +1768,17 @@ def test_installer_payload_matches_contract(self):
doc = json.loads(sh("node", ROOT / "bin" / "install.js", "contract", "--json", "--static").stdout)
self.assertEqual([t["id"] for t in doc["tools"]], [t["id"] for t in self.contract["tools"]])
self.assertEqual(sh("node", ROOT / "bin" / "install.js", "doctor", "--json").returncode, 0)
+ # `npx ffmpeg-skill mcp` is the stdio command server.json publishes. One initialize
+ # and EOF: the installer must not run, and the child must answer as the MCP server.
+ proc = subprocess.run(
+ ["node", str(ROOT / "bin" / "install.js"), "mcp"],
+ input='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n',
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=60)
+ self.assertEqual(proc.returncode, 0, proc.stderr[-400:])
+ self.assertNotIn("installed", proc.stdout.lower())
+ answer = json.loads(proc.stdout.strip().splitlines()[-1])
+ self.assertEqual(answer["result"]["serverInfo"]["name"], "ffmpeg-skill")
+ self.assertEqual(answer["result"]["serverInfo"]["version"], json.loads((ROOT / "package.json").read_text())["version"])
def test_windows_store_python_stub_is_not_treated_as_an_interpreter(self):
"""The Microsoft Store python3 alias is a real executable: spawn succeeds (no ENOENT) and
From 2c1dc564bc852586150b393f13fa643c964d8505 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 31 Oct 2026 04:23:16 +0000
Subject: [PATCH 002/191] Add join.py --copy (stream-copy join of matching
clips) and cut.py --remove
join.py --copy --transition none joins clips by the concat demuxer with -c copy when their first
video and audio streams match (cut.py's stream signature, now in _common.probe), no clip hides
edit-list pre-roll, and each clip's picture and sound end within 50 ms. A mismatch is refused by
name (join_copy_mismatch). The join is measured beside the output -- video packet count and
length against the clips' -- before it is renamed into place.
cut.py --remove A-B,... is the inverse of --segments: the ranges are dropped and the rest is cut
exactly as the equivalent --segments would be.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_013tPrsGXLuaGU7t643QNUZs
---
CHANGELOG.md | 5 +-
README.md | 8 +-
docs/contract.md | 2 +-
docs/design-decisions.md | 25 +++++++
references/scripts.md | 17 ++++-
scripts/_common/probe.py | 48 ++++++++++++
scripts/_contract.py | 3 +-
scripts/cut.py | 78 +++++++++----------
scripts/join.py | 136 +++++++++++++++++++++++++++++++++-
tests/fixtures/mcp_tools.json | 2 +
tests/test_editing.py | 39 ++++++++++
11 files changed, 307 insertions(+), 56 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b403d7ac..d803179e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,10 @@
## Unreleased
-(nothing yet)
+### Added
+
+- **`join.py --copy --transition none` joins clips by stream copy, without re-encoding,** when they already match: the same codec, size, frame rate, pixel format, colour tags, sample rate and channels in the first video and audio stream, no edit-list pre-roll, and picture and sound ending within 50 ms in each clip. Anything else is refused before ffmpeg runs, naming each clip and what differs (`reason: join_copy_mismatch`). The join is measured before it replaces anything: its video packet count must equal the clips' total and its length their summed length.
+- **`cut.py --remove A-B,...` drops ranges and keeps the rest,** the inverse of `--segments`: overlapping ranges merge, a leftover shorter than one frame is not kept, and a removal that leaves nothing is refused.
## 2.22.5
diff --git a/README.md b/README.md
index a80c9057..b3858483 100644
--- a/README.md
+++ b/README.md
@@ -196,7 +196,7 @@ Names, order and `inputSchema` in `tools/list` are translated from each tool's a
These are the rules the skill file gives the agent and the code enforces.
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.
-2. **Lossless when possible.** `cut.py` and `loudness.py` stream-copy what they do not need to touch (`join.py` always re-encodes, so it can crossfade and conform mismatched clips). Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
+2. **Lossless when possible.** `cut.py` and `loudness.py` stream-copy what they do not need to touch (`join.py` re-encodes so it can crossfade and conform mismatched clips; `--copy --transition none` joins clips that already match by stream copy). Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--plan FILE` (the dry run saved as a plan with fingerprinted inputs that `render.py FILE` executes later, refusing if an input changed), `--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.
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`.
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.
@@ -222,8 +222,8 @@ These are the rules the skill file gives the agent and the code enforces.
| Tool | What it does |
|---|---|
-| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio, `--keep-hevc` to re-encode an HEVC source as HEVC, `--edit-list` to keep an `.mp4` copy's start exact, `--vfr-guard` to choose the VFR check; reports `precision`; `--snap beats` moves the in/out points onto a measured beat, or refuses when there is no measurable pulse |
-| `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; `--list parts.txt` reads generated segments (TTS lines) and checks every one first — missing, empty or unreadable parts are all named in one refusal, or skipped and reported with `--on-missing skip` |
+| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio, `--keep-hevc` to re-encode an HEVC source as HEVC, `--edit-list` to keep an `.mp4` copy's start exact, `--vfr-guard` to choose the VFR check; reports `precision`; `--snap beats` moves the in/out points onto a measured beat, or refuses when there is no measurable pulse ; `--remove A-B,...` drops ranges and keeps the rest |
+| `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; `--list parts.txt` reads generated segments (TTS lines) and checks every one first — missing, empty or unreadable parts are all named in one refusal, or skipped and reported with `--on-missing skip` ; `--copy --transition none` joins clips with matching streams by stream copy, measured afterwards, and refuses a mismatch by name |
| `silence.py` | Detect and remove dead air (jump cuts) with a margin around speech (`--margin`, 0.2 s each side); list or export the cut list, or a source-to-output time map (`--timemap`, JSON or CSV); reports the level at every cut and warns where one may land in a breath or a word's tail; `--filler` also removes filler words, but only where a speech engine timed them; `--speech-aware` keeps a breath inside a sentence and cuts only at sentence-boundary pauses, composing with `--filler` through the same `keep_ranges()` |
| `fit.py` | Fit to a duration (pitch-preserving speed change or trim, smooth slow-mo) and/or aspect ratio (pad, crop or `--fit blur`'s blurred, darkened fill, 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 |
| `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 |
@@ -562,7 +562,7 @@ stream copy, and the last row is the whole hour.
| `scenes.py` | 241 s | |
| `check.py --platform youtube --content` | 308 s | the rows, `dropouts` included, cover the full hour |
| `cut.py`, a 10-minute stream-copy segment | 2.4–4.4 s | no re-encode |
-| `join.py`, two 10-minute segments | 624 s | `join.py` always re-encodes, so it can crossfade and conform |
+| `join.py`, two 10-minute segments | 624 s | `join.py` re-encodes by default, so it can crossfade and conform (`--copy` does not, for clips that match) |
| `export.py --preset youtube`, a 10-minute segment | 514 s | about 1.2× real time at the default `medium` preset |
| `export.py --preset youtube --fast`, the same segment | 162 s | about 3.7× real time at `veryfast` |
| `export.py --preset youtube`, the whole hour | killed at 1802 s | `kind: timeout` at the default `--timeout` of 1800 s (below) |
diff --git a/docs/contract.md b/docs/contract.md
index 029f98ee..07ff874d 100644
--- a/docs/contract.md
+++ b/docs/contract.md
@@ -815,7 +815,7 @@ A refusal also carries `error.reason`, a stable snake_case code for *why*
(`contract --json` → `json_output.error_reasons` lists them: `output_exists`, `output_is_input`,
`output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
`media_path_is_url`, `media_path_is_playlist` (an HLS or ffconcat playlist given as media),
-`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`,
+`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`,
`render.py`'s `plan_*` reasons, and `--codec av1`/`prores`'s `encoder_missing` and
`encoder_list_unreadable`), and `error.details`, the facts the message is written from, so a
caller never parses the prose:
diff --git a/docs/design-decisions.md b/docs/design-decisions.md
index 1e391254..b059ce93 100644
--- a/docs/design-decisions.md
+++ b/docs/design-decisions.md
@@ -54,6 +54,7 @@ exists. When a decision changes, edit the entry in the same PR.
- [2.20 — a length or a silence is checked against a yardstick the tool did not make](#220--a-length-or-a-silence-is-checked-against-a-yardstick-the-tool-did-not-make)
- [2.21.1 — a stream that ends early or decodes nothing fails the check](#2211--a-stream-that-ends-early-or-decodes-nothing-fails-the-check)
- [2.22 — silence.py says where it cut, and where to listen](#222--silencepy-says-where-it-cut-and-where-to-listen)
+- [2.23 — join.py --copy and cut.py --remove](#223--joinpy---copy-and-cutpy---remove)
## Time limits
@@ -2306,3 +2307,27 @@ What was decided there:
- **`--timemap` is an artifact, like `--edl`.** It is written beside the cut and not under
`--dry-run`. Its output times are the sums of the kept ranges, the same arithmetic the render
uses. Test: `test_silence_writes_a_map_from_source_to_output_times`.
+
+## 2.23 — join.py --copy and cut.py --remove
+
+- **`--copy` is opt-in, and a mismatch is refused rather than re-encoded.** A join that
+ re-encoded by default and copied when it could would change what an existing call does, which
+ the stability promise allows only for a defect; and a caller who asked for a copy and got a
+ re-encode would learn it only from `mode`. So `--copy` either copies or refuses, naming each
+ clip that differs and how. The comparison is cut.py's `--segments` one
+ (`_common.probe.stream_signature()` / `signatures_match()`, moved there so both use it), on
+ the first video and the first audio stream, which are the streams the join maps. Test:
+ `test_join_copy_joins_matching_clips_and_refuses_a_mismatch`.
+- **Three things a matching signature does not catch are checked too.** An MP4 edit list that
+ hides pre-roll frames (packets flagged discard in the first 300): the concat demuxer ignores
+ edit lists, so a copy join would show them. Picture and sound that end more than 50 ms apart
+ in a clip: the demuxer starts the next clip where this one's container ends, so the shorter
+ stream leaves a held frame or a hole in the sound there. And the result itself: its video
+ packet count against the clips' total, and its video length against their summed length
+ (within 50 ms per clip plus a frame), measured on a file beside the output that is renamed
+ into place only when both hold. Same test.
+- **`cut.py --remove` is `--segments` written the other way.** It builds the keep list and
+ hands it to the same code, so a removal is cut, joined, declicked and verified exactly as the
+ equivalent `--segments` would be. Overlaps merge and the ranges are clamped to the file; a
+ leftover shorter than one frame (10 ms without a frame rate) is not a piece anyone asked to
+ keep. Test: `test_cut_remove_keeps_the_rest`.
diff --git a/references/scripts.md b/references/scripts.md
index 2beccc2a..9b2df8ee 100644
--- a/references/scripts.md
+++ b/references/scripts.md
@@ -127,7 +127,7 @@ own clock, `null` when unknown). `--compact` gives one line per file.
### cut.py — cut / join segments
```
-cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,...] [--accurate] [--declick-ms 20 | --no-declick] [--edit-list] [--keep-hevc] [--vfr-guard average|sampled|off] [-o OUT]
+cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,... | --remove A-B,C-D,...] [--accurate] [--declick-ms 20 | --no-declick] [--edit-list] [--keep-hevc] [--vfr-guard average|sampled|off] [-o OUT]
cut.py INPUT --start T --end T --snap beats [--snap-tolerance 0.12] [--snap-source FILE] [--min-confidence 0.5]
```
Times accept `12.5`, `1:30`, `00:01:30.250`. Default is `-c copy` (snaps to
@@ -138,7 +138,7 @@ keyframes, instant, lossless); if the snapped result deviates more than
`--codec` overrides all of them). `--accurate` always re-encodes; `--tolerance -1` never does for a keyframe snap
(a `--segments` join of open-GOP video, one whose later B-frame `.mp4`/`.mov` segment starts
between keyframes, or one that fails its check, still re-cuts).
-Multiple segments are concatenated in the order given. Since 2.17.2 every join the re-encode makes fades the sound for `--declick-ms` (20 ms) on each side, so butted cuts do not click; `--no-declick` butts them as before, and a stream-copy join (sound copied) is not faded. The result reports `declick_ms` (null when there was no re-encoded join). stderr reports whether
+Multiple segments are concatenated in the order given. `--remove A-B,...` (2.23) is the inverse: the ranges are dropped (overlaps merged, clamped to the file) and what is left is cut as `--segments` would cut it; a leftover shorter than one frame is not kept, and a removal that leaves nothing is refused. It does not combine with `--segments`, `--start`, `--end` or `--duration`. Since 2.17.2 every join the re-encode makes fades the sound for `--declick-ms` (20 ms) on each side, so butted cuts do not click; `--no-declick` butts them as before, and a stream-copy join (sound copied) is not faded. The result reports `declick_ms` (null when there was no re-encoded join). stderr reports whether
the result was "lossless stream copy" or "re-encoded"; when the snap forced a
re-encode, the result's `lossless_alternative` names the nearest keyframe
`--start` that would stream-copy instead, so the trade can be offered.
@@ -768,8 +768,21 @@ writes the resulting cut list exactly as it does today.
```
join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
[--duration 0.5] [--width W --height H] [--fps N] [--fit pad|crop] [-o OUT]
+join.py CLIP1 CLIP2 [...] --copy --transition none [-o OUT]
join.py --list parts.txt [--on-missing fail|skip] [--on-silent warn|fail|skip] [--allow-silent N[,N]] [--silence-threshold -50] [...]
```
+`--copy --transition none` (2.23) joins by stream copy (the concat demuxer, `-c copy`), no
+re-encode, for clips that already match: the first video and first audio stream of every clip must
+have the same codec, profile, size, pixel format, frame rate, time base, sample aspect, colour
+tags, sample rate and channels (and the same codec configuration), no clip may hide frames
+before its start with an edit list, and in each clip picture and sound must end within 50 ms of
+each other (the demuxer starts the next clip where this one's container ends, so the shorter
+stream would leave that gap). Anything else is refused before ffmpeg runs (`kind: input`,
+`reason: join_copy_mismatch`, `details.clips: [{index?, path, differs}]` naming what differs).
+The join is written beside the output and measured before it is renamed into place: its video
+packet count must equal the clips' total and its video length their summed length. `mode` is
+`copy`. `--copy` does not take `--width`, `--height`, `--fps` or a transition, and an
+audio-only join (which resamples every clip to one format) is refused with it.
`--list FILE` (2.2) reads the clips from a file, one per line in order, paths relative to the
file (blank lines and `#` comments ignored; ffmpeg's `file 'x.wav'` lines accepted), for the
segments a TTS or render step wrote. Every input -- listed or given as arguments -- is checked
diff --git a/scripts/_common/probe.py b/scripts/_common/probe.py
index 39a433f3..26b6cf17 100644
--- a/scripts/_common/probe.py
+++ b/scripts/_common/probe.py
@@ -741,3 +741,51 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
if duration - cuts[-1] < min_len and len(cuts) > 1:
cuts.pop()
return cuts
+
+
+# What a stream-copy join compares (cut.py --segments, join.py --copy).
+IN_BAND_CONFIG = ("mp3", "mp2")
+TS_EXTS = (".ts", ".m2ts", ".mts")
+_SIG_FIELDS = {"video": ("codec_name", "profile", "pix_fmt", "width", "height", "r_frame_rate", "time_base",
+ "sample_aspect_ratio", "color_transfer", "color_primaries"),
+ "audio": ("codec_name", "profile", "sample_rate", "channels", "time_base")}
+
+
+def stream_signature(doc: Dict[str, Any]) -> "List[Dict[str, Any]]":
+ """What a stream-copy join needs to be identical across parts, from one ffprobe document
+ (-show_streams with -show_data_hash sha256): each stream's type, codec parameters, rotation
+ and extradata hash. Shared by cut.py's --segments join and join.py --copy."""
+ sig = []
+ for st in doc.get("streams") or []:
+ kind = st.get("codec_type")
+ entry = {"type": kind, "extradata_hash": st.get("extradata_hash")}
+ entry.update({f: st.get(f) for f in _SIG_FIELDS.get(kind, ("codec_name",))})
+ if kind == "video":
+ entry["rotation"] = next((sd.get("rotation") for sd in st.get("side_data_list") or []
+ if "rotation" in sd), None)
+ sig.append(entry)
+ return sig
+
+
+def signatures_match(sigs: list, ext: str) -> bool:
+ """True when every part can be joined by stream copy: same streams in the same order with the
+ same parameters. A missing extradata hash only matches another missing one where the codec (or
+ an MPEG-TS output) carries its configuration in-band -- PCM has none at all."""
+ if not sigs or any(sig is None for sig in sigs):
+ return False
+ first = sigs[0]
+ for sig in sigs[1:]:
+ if len(sig) != len(first):
+ return False
+ for a, b in zip(first, sig):
+ if {k: v for k, v in a.items() if k != "extradata_hash"} != {k: v for k, v in b.items() if k != "extradata_hash"}:
+ return False
+ ha, hb = a.get("extradata_hash"), b.get("extradata_hash")
+ if ha or hb:
+ if ha != hb:
+ return False
+ else:
+ codec = str(a.get("codec_name") or "")
+ if not (codec.startswith("pcm_") or codec in IN_BAND_CONFIG or ext in TS_EXTS):
+ return False
+ return True
diff --git a/scripts/_contract.py b/scripts/_contract.py
index 11e3210c..38535ca1 100644
--- a/scripts/_contract.py
+++ b/scripts/_contract.py
@@ -290,7 +290,7 @@
"audio": dict(video="never", audio="always", note="video stream is always -c:v copy when present; this tool's job is the audio"),
"loudness": dict(video="never", audio="always"),
"silence": dict(video="always", audio="always", note="removing gaps requires cutting on non-keyframe boundaries"),
- "join": dict(video="always", audio="always"),
+ "join": dict(video="conditional", audio="conditional", note="--copy --transition none is -c copy of the first video and audio stream (clips with matching parameters only); every other join re-encodes both"),
"color": dict(video="conditional", audio="conditional", note="--strip-dovi and --retag (when the stream copy succeeds) are -c copy of both streams; --retag falls back to re-encoding only if the copy attempt fails or ffprobe still reads the old tags on the copy (a codec with no metadata bitstream filter here); --to-sdr / --lut / --correct always re-encode both"),
"proxy": dict(video="always", audio="conditional", note="video is always re-encoded at proxy-grade quality; audio is re-encoded when present, dropped entirely with --no-audio or when the source has none"),
"export": dict(video="conditional", audio="conditional", note="--preset copy is -c:v copy -c:a copy (no re-encode); every other preset re-encodes both"),
@@ -417,6 +417,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
"output_is_directory": "the output path is a directory; details: {output}",
"media_path_is_playlist": "an input is a playlist (HLS .m3u8/.m3u or an ffconcat list, by name or by its first bytes) that ffmpeg would follow to the files it names; the skill takes media files only; details: {path}",
"media_path_not_a_file": "an input exists but is not a regular file (a FIFO or a device), which ffmpeg would block on or read as a stream; details: {path}",
+ "join_copy_mismatch": "join.py --copy: a clip's streams do not match the first clip's, it hides pre-roll frames with an edit list, or its picture and sound end more than 50 ms apart; details: {clips: [{index?, path, differs}]}",
"output_path_taken": "two files one run writes (a side file and the output, or two side files) resolve to the same path, so the second would replace the first; details: {output, other}",
"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}",
"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}",
diff --git a/scripts/cut.py b/scripts/cut.py
index a6bc9b11..a19144b3 100755
--- a/scripts/cut.py
+++ b/scripts/cut.py
@@ -57,6 +57,7 @@
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
from _common import require_tool
+from _common.probe import TS_EXTS, signatures_match, stream_signature
from _common import 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
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
@@ -73,6 +74,25 @@ def _t(value: str, fps, flag: str = "--segments") -> float:
return time_arg(value, flag, fps)
+def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float, fps=None) -> List[Tuple[float, float]]:
+ """--remove: what is left of [0, total] once `removed` is dropped (merged where they overlap,
+ clamped to the file). A leftover shorter than one frame (10 ms without a frame rate) is not a
+ piece anyone asked to keep. Refuses a removal that would leave nothing."""
+ least = 1.0 / fps if fps else 0.01
+ keep, cursor = [], 0.0
+ for s, e in sorted((max(0.0, s), min(total, e)) for s, e in removed):
+ if e <= cursor:
+ continue
+ if s - cursor >= least:
+ keep.append((cursor, s))
+ cursor = max(cursor, e)
+ if total - cursor >= least:
+ keep.append((cursor, total))
+ if not keep:
+ die(f"--remove drops the whole file ({total:.3f}s); nothing would be left to keep")
+ return keep
+
+
def parse_segments(spec: str, fps=None) -> List[Tuple[float, float]]:
segs = []
for raw in spec.split(","):
@@ -927,13 +947,6 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
# keyframe still decodes from the keyframe BEFORE it
SEEK_MARGIN = 1.0
# codecs whose configuration travels in the packets, so a part with no extradata is not missing any
-IN_BAND_CONFIG = ("mp3", "mp2")
-TS_EXTS = (".ts", ".m2ts", ".mts")
-_SIG_FIELDS = {"video": ("codec_name", "profile", "pix_fmt", "width", "height", "r_frame_rate", "time_base",
- "sample_aspect_ratio", "color_transfer", "color_primaries"),
- "audio": ("codec_name", "profile", "sample_rate", "channels", "time_base")}
-
-
_PARTS: dict = {}
@@ -952,21 +965,10 @@ def _part_probe(path: str):
def join_signature(path: str):
- """What a stream-copy join needs to be identical across parts: each stream's type, codec
- parameters, rotation and extradata hash. None when ffprobe cannot read the part."""
+ """What a stream-copy join needs to be identical across parts (_common.probe.stream_signature).
+ None when ffprobe cannot read the part."""
doc = _part_probe(path)
- if doc is None:
- return None
- sig = []
- for st in doc["streams"]:
- kind = st.get("codec_type")
- entry = {"type": kind, "extradata_hash": st.get("extradata_hash")}
- entry.update({f: st.get(f) for f in _SIG_FIELDS.get(kind, ("codec_name",))})
- if kind == "video":
- entry["rotation"] = next((sd.get("rotation") for sd in st.get("side_data_list") or []
- if "rotation" in sd), None)
- sig.append(entry)
- return sig
+ return None if doc is None else stream_signature(doc)
# how far a measured join step may be from the predicted one: ffprobe prints the parts' times
@@ -995,30 +997,6 @@ def times(path):
return [None if a is None or b is None else (a[1] - a[3]) + (b[2] - b[0]) for a, b in zip(spans, spans[1:])]
-def signatures_match(sigs: list, ext: str) -> bool:
- """True when every part can be joined by stream copy: same streams in the same order with the
- same parameters. A missing extradata hash only matches another missing one where the codec (or
- an MPEG-TS output) carries its configuration in-band -- PCM has none at all."""
- if not sigs or any(sig is None for sig in sigs):
- return False
- first = sigs[0]
- for sig in sigs[1:]:
- if len(sig) != len(first):
- return False
- for a, b in zip(first, sig):
- if {k: v for k, v in a.items() if k != "extradata_hash"} != {k: v for k, v in b.items() if k != "extradata_hash"}:
- return False
- ha, hb = a.get("extradata_hash"), b.get("extradata_hash")
- if ha or hb:
- if ha != hb:
- return False
- else:
- codec = str(a.get("codec_name") or "")
- if not (codec.startswith("pcm_") or codec in IN_BAND_CONFIG or ext in TS_EXTS):
- return False
- return True
-
-
def _fraction_rate(text: str) -> float:
""""30000/1001" or "30" as a number."""
num, _, den = str(text).partition("/")
@@ -1320,6 +1298,9 @@ def main() -> int:
g.add_argument("--end", help="end time")
g.add_argument("--duration", help="duration instead of --end")
ap.add_argument("--segments", help="comma separated START-END list, e.g. '0:05-0:12,1:00-1:20' (joined in order)")
+ ap.add_argument("--remove", metavar="START-END[,...]",
+ help="the inverse of --segments: drop these ranges and keep the rest, joined in order "
+ "(e.g. '1:20-2:05,10:00-10:30'); overlapping ranges merge")
ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
ap.add_argument("--edit-list", action="store_true", help="a single-segment .mp4/.mov stream copy keeps the MP4 edit list that hides the keyframe's pre-roll, so the picture starts at --start (default: -avoid_negative_ts make_zero, the picture starts at the keyframe); a player that ignores edit lists shows the pre-roll")
ap.add_argument("--keep-hevc", action="store_true", help="a re-encode of an SDR HEVC source stays HEVC (x265 8-bit BT.709; VideoToolbox under --hw) instead of x264 (default: x264 for SDR, x265 Main10 for HDR); --codec overrides it")
@@ -1369,6 +1350,13 @@ def main() -> int:
forced.append("codec")
fps = (meta.get("video") or {}).get("fps")
+ if args.remove:
+ if args.segments or args.end or args.duration or (args.start and _t(args.start, fps, "--start")):
+ die("--remove names what to drop from the whole file: it does not combine with --segments, --start, --end or --duration")
+ if not total:
+ die(f"--remove needs the input's duration, which {args.input} does not report"
+ + (" yet (it is an earlier step's output)" if dry_run_input_pending(args.input) else ""))
+ args.segments = ",".join(f"{s:.6f}-{e:.6f}" for s, e in keep_ranges_after_removing(parse_segments(args.remove, fps), total, fps))
if args.segments:
segments = parse_segments(args.segments, fps)
else:
diff --git a/scripts/join.py b/scripts/join.py
index d01e23aa..b1c84cad 100755
--- a/scripts/join.py
+++ b/scripts/join.py
@@ -35,12 +35,14 @@
audio stream is never silent.
"""
import argparse
+import json
import os
+import tempfile
import sys
from typing import Any, Dict, List, Optional, Tuple
-from _common.probe import SILENT_PEAK_DBFS
-from _common import STATE, AUDIO_MEDIA_EXT, draws_chroma, drawn_chain, label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
+from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
+from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, place_output, refuse, sibling_temp, draws_chroma, drawn_chain, label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
from _common.emit import warn
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
@@ -305,6 +307,128 @@ def parse_allow_silent(value: Optional[str], n: int) -> List[int]:
return out
+# join.py --copy (2.23). The concat demuxer starts each clip where the previous one's container
+# ends, so a stream that ends early leaves a gap at the join: a held frame for the picture, a hole
+# for the sound. A clip whose picture and sound end further apart than this is refused.
+COPY_AV_GAP = 0.05
+COPY_PREROLL_PACKETS = 300 # packets read per clip to find an edit list's hidden pre-roll
+
+
+def _copy_doc(path: str) -> "Optional[Dict[str, Any]]":
+ """ffprobe's first video and first audio stream of `path`, with extradata hashes, as the doc
+ stream_signature() reads; None when it cannot be read."""
+ proc = run([require_tool("ffprobe"), "-v", "error", "-show_data_hash", "sha256", "-show_streams", "-show_format",
+ "-of", "json", path], quiet=True, check=False)
+ try:
+ doc = json.loads(proc.stdout or "") if proc.returncode == 0 else None
+ except ValueError:
+ return None
+ if not isinstance(doc, dict):
+ return None
+ streams = doc.get("streams") or []
+ keep = [next((st for st in streams if st.get("codec_type") == kind), None) for kind in ("video", "audio")]
+ doc["streams"] = [st for st in keep if st]
+ return doc
+
+
+def _video_packets(path: str, limit: "Optional[int]" = None) -> "List[str]":
+ """The flags of the first video stream's packets (all of them, or the first `limit`)."""
+ cmd = [require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries", "packet=flags", "-of", "csv=p=0"]
+ if limit:
+ cmd += ["-read_intervals", f"%+#{limit}"]
+ proc = run(cmd + [path], quiet=True, check=False)
+ return (proc.stdout or "").split() if proc.returncode == 0 else []
+
+
+def join_copy(args, metas: List[Dict[str, Any]]) -> int:
+ """--copy: the concat demuxer with -c copy, after checking that the clips can be joined that
+ way, and measured afterwards (video packet count and length against the clips')."""
+ if args.transition != "none":
+ die("--copy joins without a transition: pass --transition none (a transition re-encodes the frames it blends)")
+ if args.width or args.height or args.fps:
+ die("--copy keeps every clip's own frame and rate: --width, --height and --fps need a re-encode, so drop --copy")
+ output = args.output or default_output(args.inputs[0], "joined", os.path.splitext(args.inputs[0])[1].lstrip(".") or "mp4")
+ guard_sidecar(output, *args.inputs)
+ ext = os.path.splitext(output)[1].lower()
+ n = len(args.inputs)
+ pending = any(m.get("dry_run") for m in metas)
+ if pending:
+ STATE_NOTES.append("--copy: an input does not exist yet, so whether the clips match is checked when a real run has them")
+ else:
+ docs = [_copy_doc(p) for p in args.inputs]
+ unreadable = [p for p, d in zip(args.inputs, docs) if d is None]
+ if unreadable:
+ die(f"--copy: could not read the streams of {', '.join(unreadable)}")
+ sigs = [stream_signature(d) for d in docs]
+ mismatched = [{"index": i, "path": p, "differs": {k: [a.get(k), b.get(k)] for a, b in zip(sigs[0], sig)
+ for k in a if k != "extradata_hash" and a.get(k) != b.get(k)}
+ if len(sig) == len(sigs[0]) else {"streams": [len(sigs[0]), len(sig)]}}
+ for i, (p, sig) in enumerate(zip(args.inputs, sigs)) if i and not signatures_match([sigs[0], sig], ext)]
+ if mismatched:
+ listing = "; ".join(f"#{m['index'] + 1} {m['path']}: " + (", ".join(f"{k} {v[1]} (not {v[0]})" for k, v in m["differs"].items())
+ or "codec configuration (extradata) differs")
+ for m in mismatched)
+ refuse("input", "join_copy_mismatch", f"--copy: {len(mismatched)} clip(s) do not match the first one: {listing}",
+ hint="drop --copy to re-encode them to one format", clips=mismatched)
+ hidden = [p for p in args.inputs if "D" in "".join(_video_packets(p, COPY_PREROLL_PACKETS))]
+ if hidden:
+ refuse("input", "join_copy_mismatch",
+ f"--copy: {', '.join(hidden)} hide(s) frames before their start (an edit list), which a copy join would show",
+ hint="drop --copy, or re-cut those clips with cut.py --accurate", clips=[{"path": p, "differs": {"edit_list": True}} for p in hidden])
+ gaps = []
+ for p, m in zip(args.inputs, metas):
+ vd, ad = (m.get("video") or {}).get("duration"), (m.get("audio") or {}).get("duration")
+ if vd and ad and abs(vd - ad) > COPY_AV_GAP:
+ gaps.append({"path": p, "video": round(vd, 3), "audio": round(ad, 3)})
+ if gaps:
+ listing = "; ".join(f"{g['path']} (video {g['video']:.3f}s, audio {g['audio']:.3f}s)" for g in gaps)
+ refuse("input", "join_copy_mismatch",
+ f"--copy: picture and sound end more than {COPY_AV_GAP * 1000:.0f} ms apart in {listing}; a copy join "
+ "would leave that gap at the next clip", hint="drop --copy, which trims both streams to one length",
+ clips=[{"path": g["path"], "differs": {"video_duration": g["video"], "audio_duration": g["audio"]}} for g in gaps])
+ with tempfile.TemporaryDirectory(prefix="ffskill_join_") as tmp:
+ listfile = os.path.join(tmp, "list.txt")
+ with open(listfile, "w", encoding="utf-8") as fh:
+ for p in args.inputs:
+ fh.write(concat_list_line(os.path.abspath(p)) + "\n")
+ joined = sibling_temp(output, "join") if not STATE.dry_run else output
+ cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-map", "0:v:0", "-map", "0:a:0?", "-c", "copy"]
+ if ext in (".mp4", ".mov", ".m4v"):
+ cmd += ["-movflags", "+faststart"]
+ try:
+ run(cmd + [joined])
+ verification: List[Dict[str, Any]] = []
+ if not STATE.dry_run:
+ want = sum(len(_video_packets(p)) for p in args.inputs)
+ got = len(_video_packets(joined))
+ verification.append({"step": "join", "property": "video_packets", "ok": got == want,
+ "measured": got, "expected": want, "provenance": "input_premeasurement"})
+ r = probe(joined, role="output")
+ measured = (r.get("video") or {}).get("duration") or r.get("duration")
+ expected = round(sum(m.get("duration") or 0.0 for m in metas), 3)
+ fps = (metas[0].get("video") or {}).get("fps") or 30.0
+ tol = round(n * COPY_AV_GAP + 1.0 / fps, 3)
+ verification.append({"step": "join", "property": "video_duration",
+ "ok": measured is not None and abs(measured - expected) <= tol,
+ "measured": None if measured is None else round(measured, 3), "expected": expected,
+ "tolerance": tol, "provenance": "input_premeasurement"})
+ bad = [v for v in verification if not v["ok"]]
+ if bad:
+ die("--copy: the joined file does not hold the clips it was given: "
+ + "; ".join(f"{v['property']} {v['measured']} (expected {v['expected']})" for v in bad),
+ kind="verification", hint="drop --copy to re-encode the join", verification=verification)
+ place_output(joined, output, move=True)
+ finally:
+ if joined != output and os.path.exists(joined):
+ os.remove(joined)
+ r = probe(output, role="output")
+ info(f"wrote {output} ({fmt_secs(r.get('duration'))}, {n} clips, stream copy)")
+ emit(output, mode="copy", **reported(), clips=n, transition="none", reencoded=False,
+ verification=verification, expect_streams={"video": 1, "audio": 1 if all(m.get("audio") for m in metas) else 0},
+ stream_reference=metas)
+ return 0
+
+
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("inputs", nargs="*", help="two or more clips in order (or --list FILE)")
@@ -329,6 +453,10 @@ def main() -> int:
ap.add_argument("--height", type=int, help="output height (default: first clip)")
ap.add_argument("--fps", type=float, help="output frame rate (default: first clip)")
ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how clips of another aspect reach the frame (default pad)")
+ ap.add_argument("--copy", action="store_true",
+ help="join by stream copy, no re-encode (needs --transition none): every clip must carry the same "
+ "video and audio parameters (codec, size, rate, pixel format, sample rate, channels); a clip "
+ "that does not is refused, by name, with what differs")
ap.add_argument("--pad-color", default="black")
ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
@@ -433,6 +561,8 @@ def main() -> int:
for p, m in zip(args.inputs, metas):
if not m.get("audio"):
die(f"{p} has neither a video nor an audio stream")
+ if args.copy:
+ die("--copy joins video clips; an audio-only join resamples every clip to one format, which a copy cannot do")
return join_audio(args, metas)
for p, m, pic in zip(args.inputs, metas, pictured):
if not pic:
@@ -444,6 +574,8 @@ def main() -> int:
die((f"{p} does not exist yet, and its audio extension says it will have no video stream" if m.get("dry_run")
else f"{p} has no video stream")
+ (f" while {other}; join audio with audio or give every clip a picture" if other else ""))
+ if args.copy:
+ return join_copy(args, metas)
first = metas[0]["video"]
fw, fh = first["width"], first["height"]
if first.get("rotation") in (90, -90, 270, -270):
diff --git a/tests/fixtures/mcp_tools.json b/tests/fixtures/mcp_tools.json
index 05303494..5759f3d1 100644
--- a/tests/fixtures/mcp_tools.json
+++ b/tests/fixtures/mcp_tools.json
@@ -333,6 +333,7 @@
"plan",
"preset",
"quality",
+ "remove",
"segments",
"snap",
"snap_source",
@@ -599,6 +600,7 @@
"argv",
"channels",
"codec",
+ "copy",
"dry_run",
"duration",
"fast",
diff --git a/tests/test_editing.py b/tests/test_editing.py
index a8ce8f2f..8e195f03 100644
--- a/tests/test_editing.py
+++ b/tests/test_editing.py
@@ -1140,6 +1140,45 @@ def test_join_with_transition_normalises_mismatched_clips(self):
self.assertClose(probe(str(out2))["duration"], 16.0, 0.3)
script("join.py", self.src, expect_fail=True)
+ def test_join_copy_joins_matching_clips_and_refuses_a_mismatch(self):
+ """2.23: --copy --transition none joins clips with matching streams by stream copy (the
+ joined packets are the clips' packets, nothing re-encoded), and refuses a clip that differs,
+ or a picture and sound that end apart, by name and before anything is written."""
+ clips = [self._av_clip(OUT / f"copy_{i}.mp4", 2, 2, 300 + 200 * i) for i in range(3)]
+ out = OUT / "copy_joined.mp4"
+ doc = json.loads(script("join.py", *clips, "--copy", "--transition", "none", "-o", out, "--overwrite", "--json").stdout)
+ self.assertEqual((doc["status"], doc["mode"], doc["verified"]), ("completed", "copy", True), doc)
+ packets = {v["property"]: v for v in doc["verification"] if v.get("step") == "join"}
+ self.assertTrue(packets["video_packets"]["ok"] and packets["video_packets"]["measured"] == 3 * 60, packets)
+ self.assertNotIn("libx264", " ".join(doc["commands"]))
+ self.assertClose(probe(str(out))["video"]["duration"], 6.0, 0.1)
+ wide = OUT / "copy_wide.mp4"
+ sh("ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=gray:s=96x48:r=30:d=2",
+ "-f", "lavfi", "-i", "sine=d=2", "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", wide)
+ short = self._av_clip(OUT / "copy_short_sound.mp4", 2, 1, 500)
+ for other, what in ((wide, "width 96 (not 64)"), (short, "audio")):
+ bad = OUT / "copy_bad.mp4"
+ proc = script("join.py", clips[0], other, "--copy", "--transition", "none", "-o", bad, "--json", expect_fail=True)
+ err = json.loads(proc.stdout)["error"]
+ self.assertEqual((err["kind"], err["reason"]), ("input", "join_copy_mismatch"), err)
+ self.assertIn(what, err["message"])
+ self.assertFalse(bad.exists())
+ proc = script("join.py", *clips[:2], "--copy", "-o", OUT / "copy_fade.mp4", "--json", expect_fail=True)
+ self.assertIn("--transition none", json.loads(proc.stdout)["error"]["message"])
+
+ def test_cut_remove_keeps_the_rest(self):
+ """2.23: --remove is --segments written the other way: dropping 1-2 and 3.5-4 (overlapping
+ 3.8-4 merged) from 6 s keeps 0-1, 2-3.5 and 4-6."""
+ clip = self._av_clip(OUT / "remove_src.mp4", 6, 6, 440)
+ out = OUT / "removed.mp4"
+ doc = json.loads(script("cut.py", clip, "--remove", "3.5-4,1-2,3.8-4", "--accurate", "-o", out, "--overwrite", "--json").stdout)
+ self.assertEqual(doc["status"], "completed", doc)
+ self.assertClose(probe(str(out))["video"]["duration"], 4.5, 0.1)
+ proc = script("cut.py", clip, "--remove", "0-10", "-o", OUT / "removed_all.mp4", "--json", expect_fail=True)
+ self.assertIn("nothing would be left", json.loads(proc.stdout)["error"]["message"])
+ proc = script("cut.py", clip, "--remove", "1-2", "--segments", "0-1", "-o", OUT / "removed_mix.mp4", "--json", expect_fail=True)
+ self.assertIn("does not combine", json.loads(proc.stdout)["error"]["message"])
+
def _av_clip(self, out, picture, tone, freq, fps=30):
"""A tiny clip: `picture` s of colour at `fps`, `tone` s of a sine at `freq` Hz, AAC audio."""
sh("ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", f"color=c=gray:s=64x48:r={fps}:d={picture}",
From bd139c1bb01fa672e6e452cec2d71325f4a40241 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 31 Oct 2026 04:26:38 +0000
Subject: [PATCH 003/191] Add output_identity, check.py --expect-identity and
check.py --compare
Every writing tool reports output_identity {size, mtime_ns} for the file it wrote; check.py
--expect-identity SIZE:MTIME_NS FAILs an identity row when the file at the path is another one (an
older file a failed run left, or a later run's), and check.py reports input_identity. SKILL.md's
delivery check passes it. check.py --compare REF measures SSIM (mean and worst frame) and PSNR
against a reference in one pass, WARN under a mean SSIM of 0.98.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_013tPrsGXLuaGU7t643QNUZs
---
CHANGELOG.md | 2 ++
README.md | 2 +-
SKILL.md | 4 +--
docs/contract.md | 4 ++-
docs/design-decisions.md | 22 ++++++++++++++++
references/scripts.md | 11 +++++++-
scripts/_common/emit.py | 14 ++++++++++
scripts/_common/probe.py | 36 ++++++++++++++++++++++++++
scripts/_contract.py | 4 ++-
scripts/check.py | 55 +++++++++++++++++++++++++++++++++++++++-
tests/test_analysis.py | 35 +++++++++++++++++++++++++
11 files changed, 182 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d803179e..4f7a09f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@
### Added
+- **Every writing tool reports `output_identity: {size, mtime_ns}`, and `check.py --expect-identity SIZE:MTIME_NS` checks against it.** A check run after a write that failed, leaving an older file at the path, or after another run replaced it, used to grade whatever was there; with the flag an `identity` row FAILs instead. check.py reports the file it graded as `input_identity`. SKILL.md's delivery check passes it.
+- **`check.py --compare REF` measures the picture against a reference:** mean SSIM, the worst frame and its time, and mean PSNR, in one pass; WARN under a mean SSIM of 0.98. Frames of another size are not compared.
- **`join.py --copy --transition none` joins clips by stream copy, without re-encoding,** when they already match: the same codec, size, frame rate, pixel format, colour tags, sample rate and channels in the first video and audio stream, no edit-list pre-roll, and picture and sound ending within 50 ms in each clip. Anything else is refused before ffmpeg runs, naming each clip and what differs (`reason: join_copy_mismatch`). The join is measured before it replaces anything: its video packet count must equal the clips' total and its length their summed length.
- **`cut.py --remove A-B,...` drops ranges and keeps the rest,** the inverse of `--segments`: overlapping ranges merge, a leftover shorter than one frame is not kept, and a removal that leaves nothing is refused.
diff --git a/README.md b/README.md
index b3858483..0ed66fca 100644
--- a/README.md
+++ b/README.md
@@ -270,7 +270,7 @@ These are the rules the skill file gives the agent and the code enforces.
|---|---|
| `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) |
| `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--quality` (default 30), `--fps`, `--no-audio`; not a delivery preset |
-| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence and short audio-dropout rows |
+| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence and short audio-dropout rows ; `--compare REF` measures SSIM/PSNR against a reference; `--expect-identity` checks that the file is the one a run reported writing (`output_identity`) |
| `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
**Orchestration**
diff --git a/SKILL.md b/SKILL.md
index 7af787d7..dd013cc5 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -7,7 +7,7 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
Scripts live in `scripts/` next to this file; run them with `python3 /scripts/.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as this one; 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). MCP: `tools/list` shows only the core 12 by default; the other 30 (per this table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
-Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`; for writing steps); `--fast` (preview quality); `--progress` (%/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800; `0` for hour-long encodes); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses a changed input). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py --keep-hevc`: HEVC source stays HEVC), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
+Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`); `--fast` (preview quality); `--progress` (%/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800; `0` for hour-long encodes); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses a changed input). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py --keep-hevc`: HEVC source stays HEVC), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0) may still run ffmpeg/ffprobe, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
@@ -18,7 +18,7 @@ Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `chec
2. **Prefer lossless.** If the request can be met without re-encoding (plain cuts on keyframes, remuxing, audio-only changes), do not re-encode. `cut.py` and `loudness.py` stream-copy video by default; `--accurate` on `cut.py` only for frame-exact cuts.
3. **Plan with `--dry-run --json`, then execute.** Trust `--json`, not a dry run's summary line, for any number in the plan (a dimension there can be a placeholder). Use before long encodes, to report facts.
4. **Chain in a sensible order.** A delivery request with no other editing is one template run (`render.py --template NAME INPUT`), not a hand-built chain. Otherwise: colour (HDR→SDR / LUT) → cut → join → silence → fit → caption/overlay → sync → audio → loudness → export. Frame changes before captions, so text is sized for the final frame. Re-encode as few times as possible: intermediates at CRF 18, `export.py` last. **Three or more steps: `render.py` with a project.json.**
-5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X` for the destination named (a template run already does). Format rows (codec, pixel format, size, true peak, colour tags, VFR) are safe to fix. Judgement rows change content: duration (cut loses material), aspect (crop loses edges), fps (drops motion), loudness (ambience must not be boosted) — fix only when the request implies the answer, else state the choice and its cost. Mention WARNs; do not chase them.
+5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X --expect-identity S:M` (the writer's `output_identity`; templates already check); never check after a failed write. Format rows (codec, pixel format, size, true peak, colour tags, VFR) are safe to fix. Judgement rows change content: duration (cut loses material), aspect (crop loses edges), fps (drops motion), loudness (ambience must not be boosted) — fix only when the request implies the answer, else state the choice and its cost. Mention WARNs.
6. **Verify the output.** Confirm duration, resolution, fps and audio match the request, from the writing tool's `--json`/`--json-brief` probe or `probe.py`, and report those numbers ("final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo"). A step is done only when the script exited 0 and the output probes as expected: a non-zero exit, a missing or empty file, or a probe that contradicts the request is a failure reported with the script's error message.
7. **Keep the user's originals.** Never overwrite the source; write new files next to input or where asked. An existing output path is refused (`kind: input`); pass `--overwrite` only for a file this job made or the user asked to replace.
8. **Look at the picture.** Whenever the picture changed (captions, overlays, graphics, crop/pad, resize, colour, transitions, a `join.py` scaling a clip to the first clip's frame, `color.py --to-sdr`) run `look.py OUTPUT --tiles 3x2` (or `--at T` for one frame) and view the PNG (4x3 for whole-clip layout). Not finished until `Look:` names that PNG — a probe cannot see a caption on a face. Audio-only: `Look: not needed`. What to look for splits like `check.py`'s rows in step 5:
diff --git a/docs/contract.md b/docs/contract.md
index 07ff874d..9a9a7134 100644
--- a/docs/contract.md
+++ b/docs/contract.md
@@ -206,7 +206,9 @@ rows carry `kind: format` (fix it) or `kind: judgement` (decide with the user).
The `audio` row FAILs (WARN when no loudness target applies) when the track is present but silent (peak at or below -50 dBFS, from
the loudness pass or, when loudness is skipped, one volumedetect pass). `check --content`
(opt-in; render.py `"check": {"content": true}`) adds `black`, `frozen` and `silence` rows
-from one decode pass, and `dropouts` (2.17) from one audio-only pass; without it the row set is unchanged. Thresholds:
+from one decode pass, and `dropouts` (2.17) from one audio-only pass; without it the row set is unchanged.
+`--compare REF` adds `similarity` (SSIM/PSNR against a reference) and `--expect-identity SIZE:MTIME_NS`
+adds `identity`, against the `output_identity` every writing tool reports (2.23). Thresholds:
`docs/design-decisions.md` ("check.py --content").
### Dry run
diff --git a/docs/design-decisions.md b/docs/design-decisions.md
index b059ce93..2a8a2b4d 100644
--- a/docs/design-decisions.md
+++ b/docs/design-decisions.md
@@ -55,6 +55,7 @@ exists. When a decision changes, edit the entry in the same PR.
- [2.21.1 — a stream that ends early or decodes nothing fails the check](#2211--a-stream-that-ends-early-or-decodes-nothing-fails-the-check)
- [2.22 — silence.py says where it cut, and where to listen](#222--silencepy-says-where-it-cut-and-where-to-listen)
- [2.23 — join.py --copy and cut.py --remove](#223--joinpy---copy-and-cutpy---remove)
+- [2.23 — output identity and check.py --compare](#223--output-identity-and-checkpy---compare)
## Time limits
@@ -2331,3 +2332,24 @@ What was decided there:
equivalent `--segments` would be. Overlaps merge and the ranges are clamped to the file; a
leftover shorter than one frame (10 ms without a frame rate) is not a piece anyone asked to
keep. Test: `test_cut_remove_keeps_the_rest`.
+
+## 2.23 — output identity and check.py --compare
+
+- **An output is identified by size and modification time, not a hash.** The point is to tell
+ the file a run wrote from whatever sits at the path later (an older file a failed `--overwrite`
+ run left, a file another run replaced). Size plus `mtime_ns` changes on any rewrite and costs
+ one `stat`; a sha256 of an hour-long master costs seconds and proves nothing more for that
+ purpose. Code: `_common.emit.file_identity()`. Test:
+ `test_check_expect_identity_ties_the_check_to_one_write`.
+- **`--expect-identity` is a FAIL, not a WARN.** The row exists for the case where the file is
+ not the one the caller believes it is checking; passing the other rows on that file would be
+ the false success the flag is there to stop. Without the flag check.py grades the path as
+ before and only reports `input_identity`. Same test.
+- **`--compare` pairs frames in decode order and reports, it does not align.** SSIM and PSNR are
+ ffmpeg's own filters in one pass. Pairing by order is what those filters do; a dropped or added
+ frame shifts every later pair, so a different frame count is named in `notes` rather than
+ hidden. Frames of another size are not compared at all (WARN, "not compared"): scaling one of
+ them first would measure the scaler. The WARN line is a mean SSIM of 0.98, where differences
+ start to be visible on ordinary footage; the worst frame and its time say where to look
+ (`look.py --compare`). Code: `_common.probe.compare_pictures()`. Test:
+ `test_check_compare_measures_against_a_reference`.
diff --git a/references/scripts.md b/references/scripts.md
index 9b2df8ee..2e9c3dcc 100644
--- a/references/scripts.md
+++ b/references/scripts.md
@@ -1129,7 +1129,7 @@ measurements that combine with each other and with `--beats`/`--highlights`:
### check.py — pre-delivery compliance
```
-check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|facebook|broadcast|podcast|custom [--no-loudness] [--content] [--json]
+check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|facebook|broadcast|podcast|custom [--no-loudness] [--content] [--compare REF] [--expect-identity SIZE:MTIME_NS] [--json]
[--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
```
PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
@@ -1149,6 +1149,15 @@ construction, not by two lists agreeing.
The `audio` row FAILs (under a loudness target; WARN without one) on a present-but-silent track (peak <= -50 dBFS, the join/audio
`--silence-threshold` default): measured from the loudness pass, or with one volumedetect
pass when `--no-loudness` (or a spec with no loudness target) skips it.
+`--expect-identity SIZE:MTIME_NS` (2.23) ties the check to one run's output: every writing tool
+reports `output_identity: {size, mtime_ns}` for the file it wrote, and check.py reports the file it
+graded as `input_identity`. With the flag an `identity` row FAILs when they differ, so a check after
+a run that failed (leaving an older file at the path) or that another run overwrote does not pass
+someone else's file. `--compare REF` (2.23) measures the picture against a reference in one pass
+(ffmpeg's `ssim` and `psnr`, frames paired in decode order): a `similarity` row with the mean SSIM,
+the worst frame and its time, and the mean PSNR (`identical` when every pixel matches); WARN under a
+mean SSIM of 0.98. Frames of another size are not compared (WARN, "not compared"); a different frame
+count is compared pair by pair and named in `notes`, since a dropped frame shifts every later pair.
`--content` (opt-in, one decode pass) adds three rows: `black` (share of the duration that is
black: WARN > 10%, FAIL >= 95%), `frozen` (longest frozen span: WARN > max(3 s, 30% of the
duration), FAIL when it covers >= 95%, i.e. the whole video) and `silence` (share below
diff --git a/scripts/_common/emit.py b/scripts/_common/emit.py
index b93ffb07..eb444f3b 100644
--- a/scripts/_common/emit.py
+++ b/scripts/_common/emit.py
@@ -580,6 +580,17 @@ def _framecrc_frames(proc: Any, since: Optional[float] = None) -> int:
return n
+def file_identity(path: str) -> "Optional[Dict[str, int]]":
+ """{size, mtime_ns} of a file this run wrote or measured (2.23): what ties a later check.py
+ --expect-identity to this file rather than to whatever sits at the path then. None when the
+ path is not a regular file."""
+ try:
+ st = os.stat(path)
+ except OSError:
+ return None
+ return {"size": st.st_size, "mtime_ns": st.st_mtime_ns} if os.path.isfile(path) else None
+
+
def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any) -> None:
"""Final stdout line: the output path, or a JSON document with --json.
@@ -594,6 +605,9 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
if meta:
doc["probe"] = meta
+ identity = file_identity(output) if output and not ctx.dry_run else None
+ if identity:
+ doc["output_identity"] = identity
# What this tool itself verified about its artifact (issue #189 C, "verify as part of the
# contract"): the probe every writing tool runs, plus the measurements a tool adds
# (`verification` extra: loudness after the write, a platform check). `verified` is true
diff --git a/scripts/_common/probe.py b/scripts/_common/probe.py
index 26b6cf17..dc8881b7 100644
--- a/scripts/_common/probe.py
+++ b/scripts/_common/probe.py
@@ -789,3 +789,39 @@ def signatures_match(sigs: list, ext: str) -> bool:
if not (codec.startswith("pcm_") or codec in IN_BAND_CONFIG or ext in TS_EXTS):
return False
return True
+
+
+# check.py --compare (2.23): the output's picture against a reference, frame by frame.
+COMPARE_SSIM_WARN = 0.98 # mean SSIM below this: a visible difference somewhere is likely
+_SSIM_SUMMARY = re.compile(r"SSIM Y:[0-9.]+ .*All:([0-9.]+)")
+_PSNR_SUMMARY = re.compile(r"PSNR y:\S+ .*average:(\S+)")
+_SSIM_FRAME = re.compile(r"n:(\d+) .*All:([0-9.]+)")
+
+
+def compare_pictures(path: str, reference: str) -> "Optional[Dict[str, Any]]":
+ """SSIM (mean and the worst frame) and PSNR (mean) of `path`'s first video stream against
+ `reference`'s, frame by frame in decode order, in one pass. ffmpeg's ssim and psnr filters
+ need equal sizes; the caller checks that first. None when the pass fails."""
+ import tempfile
+ from _common.decision import escape_filter_path
+ with tempfile.TemporaryDirectory(prefix="ffskill_compare_") as tmp:
+ stats = os.path.join(tmp, "ssim.log")
+ graph = ("[0:v:0]split[a0][a1];[1:v:0]split[b0][b1];"
+ f"[a0][b0]ssim=stats_file={escape_filter_path(stats, bind=False)};[a1][b1]psnr")
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-i", path, "-i", reference,
+ "-filter_complex", graph, "-f", "null", "-"], check=False, record=True)
+ if proc.returncode != 0:
+ return None
+ ssim, psnr = _SSIM_SUMMARY.search(proc.stderr), _PSNR_SUMMARY.search(proc.stderr)
+ frames = []
+ try:
+ with open(stats, encoding="utf-8") as fh:
+ frames = [(int(m.group(1)), float(m.group(2))) for m in map(_SSIM_FRAME.search, fh) if m]
+ except OSError:
+ pass
+ if not ssim or not frames:
+ return None
+ worst = min(frames, key=lambda f: f[1])
+ mean_psnr = psnr.group(1) if psnr else None
+ return {"ssim": float(ssim.group(1)), "worst_ssim": worst[1], "worst_frame": worst[0] - 1, "frames": len(frames),
+ "psnr": None if mean_psnr is None else (float("inf") if mean_psnr == "inf" else float(mean_psnr))}
diff --git a/scripts/_contract.py b/scripts/_contract.py
index 38535ca1..707e175c 100644
--- a/scripts/_contract.py
+++ b/scripts/_contract.py
@@ -542,6 +542,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
"probe": {"type": "object", "description": "probe of the output when a file was written"},
+ "output_identity": {"type": "object", "description": "2.23: {size, mtime_ns} of the file this run wrote, absent under --dry-run; pass it to check.py --expect-identity SIZE:MTIME_NS so the check grades this file and not whatever later sits at the path"},
"encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
"hw": {"type": "object", "description": "present when VideoToolbox was asked for (--hw, $FFMPEG_SKILL_HW, a render.py stage, a batch.py item), on export.py when $FFMPEG_SKILL_HW=1 is set but its delivery preset does not take it, and when this ffmpeg build has no libx264/libx265 and an encode ran on VideoToolbox instead: {requested: whether this run's encodes were asked onto VideoToolbox (false for that export, and for the build without libx264/libx265), source: flag|env|auto (auto: nobody asked, the build has no libx264/libx265 and this Apple Silicon machine ran VideoToolbox in its place), used: true|false whether the encoder that ran last is VideoToolbox, null under --dry-run (nothing ran) and when this process ran no encode itself (batch.py: its stages are child processes; its top-level hw gathers the items' requested/source/fallback/notes and each results row carries its item's encoder and hw), fallback: a job VideoToolbox refused (encoder open or compression session failure; nothing else is retried) was re-encoded on the CPU, notes: why an encode stayed on or fell back to the CPU, and, when $FFMPEG_SKILL_HW chose the GPU and it ran, what that costs and that --no-hw opts out}. A VideoToolbox encode also adds a top-level notes line: its quality is not CRF-equivalent. A kind: ffmpeg failure under --hw / $FFMPEG_SKILL_HW carries encoder and hw too"},
"plan": {"type": "string", "description": "with --plan FILE: the plan document written (the run itself is a dry run)"},
@@ -552,8 +553,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
extra: Dict[str, Any] = {}
if name == "check":
extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
+ "input_identity": {"type": "object", "description": "2.23: {size, mtime_ns} of the file graded, the same shape as a writing tool's output_identity"},
"notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named: youtube was assumed and judgement rows are WARN"},
- "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
+ "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
elif name == "caption":
extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
"caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input. --ass: ass_malformed -- Dialogue lines that could not be read and are not counted -- and with --offset ass_shifted / ass_unshifted -- lines moved, and lines whose timestamps could not be read, copied as they were (each count above 0 is also a diagnostics.warnings entry)"},
diff --git a/scripts/check.py b/scripts/check.py
index 2f1649a1..8bf554b4 100755
--- a/scripts/check.py
+++ b/scripts/check.py
@@ -35,7 +35,8 @@
from typing import Any, Dict, List
from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
-from _common.probe import SILENT_PEAK_DBFS, detect_content, measured_level_dbfs
+from _common.probe import COMPARE_SSIM_WARN, SILENT_PEAK_DBFS, compare_pictures, detect_content, measured_level_dbfs
+from _common.emit import file_identity
from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
# The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
@@ -68,6 +69,9 @@ def measure_loudness(path: str) -> Dict[str, float]:
SILENCE_WARN, SILENCE_FAIL = 0.50, 0.95 # share of the duration below SILENT_PEAK_DBFS
+STATE_NOTES: List[str] = []
+
+
def _span_total(spans: Any) -> float:
return sum(max(0.0, e - s) for s, e in spans)
@@ -92,6 +96,12 @@ def main() -> int:
ap.add_argument("--no-loudness", action="store_true", help="skip the loudness measurement (faster)")
ap.add_argument("--content", action="store_true",
help="also check the content (one decode pass, plus an audio-only one): black, frozen, silence and dropouts rows")
+ ap.add_argument("--compare", metavar="REFERENCE",
+ help="also measure the picture against REFERENCE, frame by frame (SSIM and PSNR, one pass): a "
+ "similarity row, WARN when the mean SSIM is under 0.98, with the worst frame's time")
+ ap.add_argument("--expect-identity", metavar="SIZE:MTIME_NS",
+ help="the output_identity a writing tool reported ({size}:{mtime_ns}): an identity row FAILs when "
+ "the file at INPUT is not that file (a later run replaced it, or a failed run left an older one)")
add_common(ap)
args = ap.parse_args()
apply_common(args)
@@ -299,6 +309,46 @@ def silent_audio(peak: float) -> None:
"again); a noise gate makes these on purpose" if gaps else "",
reason="a short stretch of digital silence in the middle of the sound is usually lost audio at a join or a glitched decode" if gaps else "")
+ identity = file_identity(args.input) if not STATE.dry_run else None
+ if args.expect_identity:
+ try:
+ size, mtime_ns = (int(x) for x in args.expect_identity.split(":"))
+ except ValueError:
+ die(f"--expect-identity takes SIZE:MTIME_NS from a tool's output_identity, got {args.expect_identity!r}")
+ if identity is not None:
+ same = identity == {"size": size, "mtime_ns": mtime_ns}
+ row("identity", "PASS" if same else "FAIL", f"{identity['size']}:{identity['mtime_ns']}", f"{size}:{mtime_ns}",
+ "" if same else "re-run the tool that writes this file and check what it reports, not what the path holds",
+ reason="" if same else "the file at this path is not the one the run being checked wrote")
+ if args.compare and not STATE.dry_run:
+ ref = probe(args.compare)
+ rv = ref.get("video") or {}
+ if not v or not rv:
+ row("similarity", "WARN", "not compared", "two pictures", "--compare needs a video stream in both files",
+ reason="one of the files has no picture")
+ elif (v.get("width"), v.get("height")) != (rv.get("width"), rv.get("height")):
+ row("similarity", "WARN", f"not compared: {v.get('width')}x{v.get('height')} vs {rv.get('width')}x{rv.get('height')}",
+ "the same frame size", "compare against a reference at the delivery size (fit.py or export.py it first)",
+ reason="SSIM and PSNR compare pixels, so the two frames must be the same size")
+ else:
+ cmp_ = compare_pictures(args.input, args.compare)
+ if cmp_ is None:
+ row("similarity", "WARN", "could not measure", f"mean SSIM >= {COMPARE_SSIM_WARN:g}",
+ "look.py --compare at a few times", reason="the comparison pass failed")
+ else:
+ fps = float(v.get("fps") or 0.0)
+ at = f"{cmp_['worst_frame'] / fps:.2f}s" if fps else f"frame {cmp_['worst_frame']}"
+ psnr = "identical" if cmp_["psnr"] == float("inf") else (f"{cmp_['psnr']:.1f} dB" if cmp_["psnr"] is not None else "?")
+ low = cmp_["ssim"] < COMPARE_SSIM_WARN
+ row("similarity", "WARN" if low else "PASS",
+ f"SSIM {cmp_['ssim']:.4f} (worst {cmp_['worst_ssim']:.4f} at {at}), PSNR {psnr}, {cmp_['frames']} frames",
+ f"mean SSIM >= {COMPARE_SSIM_WARN:g}",
+ f"look.py --compare {args.compare} --at {at.rstrip('s') if fps else 0}" if low else "",
+ reason="the picture differs visibly from the reference somewhere" if low else "")
+ nref, nout = rv.get("nb_frames"), v.get("nb_frames")
+ if nref and nout and int(nref) != int(nout):
+ STATE_NOTES.append(f"--compare: {nout} frames against the reference's {nref}; frames were paired in "
+ f"order, so an added or dropped frame shifts every later pair")
failed = [r for r in rows if r["status"] == "FAIL"]
warned = [r for r in rows if r["status"] == "WARN"]
notes: List[str] = []
@@ -319,7 +369,10 @@ def silent_audio(peak: float) -> None:
print(f" {len(rows)} checks, {len(failed)} failed, {len(warned)} warnings")
for n in notes:
print(f" note: {n}")
+ notes += STATE_NOTES
extra: Dict[str, Any] = {"notes": notes} if notes else {}
+ if identity is not None:
+ extra["input_identity"] = identity
if failed:
die(f"{len(failed)} of {len(rows)} {args.platform} checks failed: {', '.join(r['check'] for r in failed)}",
kind="verification", output=None, dry_run=STATE.dry_run,
diff --git a/tests/test_analysis.py b/tests/test_analysis.py
index d7db1bc0..ae565fcc 100644
--- a/tests/test_analysis.py
+++ b/tests/test_analysis.py
@@ -311,6 +311,41 @@ def test_check_content_rows(self):
r = self._rows(mixed, "--content")
self.assertEqual((r["black"]["status"], r["silence"]["status"]), ("WARN", "WARN"), r)
+ def test_check_expect_identity_ties_the_check_to_one_write(self):
+ """2.23: a writing tool reports output_identity; check.py --expect-identity FAILs on any other
+ file at that path (here: the same path rewritten by a second run), and reports input_identity."""
+ src = self._content_clip("identity_src.mp4", "testsrc2=size=160x90:rate=10", "sine=frequency=440:sample_rate=48000", 2)
+ out = OUT / "identity_out.mp4"
+ first = json.loads(subprocess.run([sys.executable, str(SCRIPTS / "cut.py"), str(src), "--end", "1", "-o", str(out),
+ "--overwrite", "--json"], stdout=subprocess.PIPE, text=True).stdout)
+ ident = first["output_identity"]
+ self.assertEqual(ident["size"], out.stat().st_size)
+ expect = f"{ident['size']}:{ident['mtime_ns']}"
+ self.assertEqual(self._rows(out, "--no-loudness", "--expect-identity", expect)["identity"]["status"], "PASS")
+ subprocess.run([sys.executable, str(SCRIPTS / "cut.py"), str(src), "--end", "1.5", "-o", str(out), "--overwrite"],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
+ proc = subprocess.run([sys.executable, str(SCRIPTS / "check.py"), str(out), "--platform", "custom", "--no-loudness",
+ "--expect-identity", expect, "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ doc = json.loads(proc.stdout)
+ self.assertNotEqual(proc.returncode, 0)
+ self.assertEqual({r["check"]: r["status"] for r in doc["checks"]}["identity"], "FAIL")
+ self.assertEqual(doc["input_identity"]["size"], out.stat().st_size)
+
+ def test_check_compare_measures_against_a_reference(self):
+ """2.23: --compare REF: identical frames PASS as identical, a heavily compressed copy WARNs
+ under a mean SSIM of 0.98 with its worst frame, another frame size is not compared."""
+ ref = self._content_clip("compare_ref.mp4", "testsrc2=size=160x90:rate=10", "sine=frequency=440:sample_rate=48000", 3)
+ poor, small = OUT / "compare_poor.mp4", OUT / "compare_small.mp4"
+ sh("ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", ref, "-c:v", "libx264", "-crf", "45", "-c:a", "copy", poor)
+ sh("ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", ref, "-vf", "scale=80:46", "-c:v", "libx264", "-c:a", "copy", small)
+ same = self._rows(ref, "--no-loudness", "--compare", ref)["similarity"]
+ self.assertEqual(same["status"], "PASS")
+ self.assertIn("identical", same["value"])
+ low = self._rows(poor, "--no-loudness", "--compare", ref)["similarity"]
+ self.assertEqual(low["status"], "WARN", low)
+ self.assertIn("worst", low["value"])
+ self.assertIn("not compared", self._rows(small, "--no-loudness", "--compare", ref)["similarity"]["value"])
+
def test_check_content_dropouts_row(self):
"""2.17: a short run of digital silence with sound on both sides (lost audio at a join) is a
dropouts WARN; the same gap holding -60 dB room tone is a recording's pause and PASSes, and
From c6ae3428004d9848553f851d75d7d22ab7e7364d Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 31 Oct 2026 04:29:50 +0000
Subject: [PATCH 004/191] Add export.py --max-mb: a two-pass x264 encode that
fits a size, measured
The preset's x264 line takes the video bitrate the running time leaves after the audio (97% of the
budget) in place of its CRF and runs in two passes; the written size is a file_size verification
step, with a size_over_limit warning when the encoder overshot. Refused for non-x264 presets and
--hw, with --crf, without a duration, and under 100 kb/s for the picture.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_013tPrsGXLuaGU7t643QNUZs
---
CHANGELOG.md | 1 +
README.md | 2 +-
docs/design-decisions.md | 16 +++++++++
references/scripts.md | 8 ++++-
scripts/_contract.py | 1 +
scripts/export.py | 64 +++++++++++++++++++++++++++++++++--
tests/fixtures/mcp_tools.json | 3 ++
tests/test_delivery.py | 21 ++++++++++++
8 files changed, 111 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4f7a09f7..ba89b10e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
### Added
+- **`export.py --max-mb MB` fits a delivery under a size limit** (an email attachment, a chat app's upload cap): a two-pass x264 encode at the bitrate the running time leaves after the audio, with the written size measured. A size too small for a watchable picture (under 100 kb/s) is refused before anything runs.
- **Every writing tool reports `output_identity: {size, mtime_ns}`, and `check.py --expect-identity SIZE:MTIME_NS` checks against it.** A check run after a write that failed, leaving an older file at the path, or after another run replaced it, used to grade whatever was there; with the flag an `identity` row FAILs instead. check.py reports the file it graded as `input_identity`. SKILL.md's delivery check passes it.
- **`check.py --compare REF` measures the picture against a reference:** mean SSIM, the worst frame and its time, and mean PSNR, in one pass; WARN under a mean SSIM of 0.98. Frames of another size are not compared.
- **`join.py --copy --transition none` joins clips by stream copy, without re-encoding,** when they already match: the same codec, size, frame rate, pixel format, colour tags, sample rate and channels in the first video and audio stream, no edit-list pre-roll, and picture and sound ending within 50 ms in each clip. Anything else is refused before ffmpeg runs, naming each clip and what differs (`reason: join_copy_mismatch`). The join is measured before it replaces anything: its video packet count must equal the clips' total and its length their summed length.
diff --git a/README.md b/README.md
index 0ed66fca..fa556d3c 100644
--- a/README.md
+++ b/README.md
@@ -268,7 +268,7 @@ These are the rules the skill file gives the agent and the code enforces.
| Tool | What it does |
|---|---|
-| `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) |
+| `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) ; `--max-mb MB` fits the file under a size limit by a two-pass encode and measures it |
| `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--quality` (default 30), `--fps`, `--no-audio`; not a delivery preset |
| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence and short audio-dropout rows ; `--compare REF` measures SSIM/PSNR against a reference; `--expect-identity` checks that the file is the one a run reported writing (`output_identity`) |
| `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
diff --git a/docs/design-decisions.md b/docs/design-decisions.md
index 2a8a2b4d..b8b30edb 100644
--- a/docs/design-decisions.md
+++ b/docs/design-decisions.md
@@ -56,6 +56,7 @@ exists. When a decision changes, edit the entry in the same PR.
- [2.22 — silence.py says where it cut, and where to listen](#222--silencepy-says-where-it-cut-and-where-to-listen)
- [2.23 — join.py --copy and cut.py --remove](#223--joinpy---copy-and-cutpy---remove)
- [2.23 — output identity and check.py --compare](#223--output-identity-and-checkpy---compare)
+- [2.23 — export.py --max-mb](#223--exportpy---max-mb)
## Time limits
@@ -2353,3 +2354,18 @@ What was decided there:
start to be visible on ordinary footage; the worst frame and its time say where to look
(`look.py --compare`). Code: `_common.probe.compare_pictures()`. Test:
`test_check_compare_measures_against_a_reference`.
+
+## 2.23 — export.py --max-mb
+
+- **A size limit is a two-pass x264 encode at a computed bitrate, then measured.** The budget is
+ 97% of MB MiB (the rest is the container's overhead), less the preset's own audio bitrate, over
+ the running time the export keeps (the preset's maximum when it trims). One pass at that bitrate
+ misses on content whose complexity changes; two passes are what x264 offers for a size. The
+ size is still measured, because x264's rate control is a target, not a bound: an overshoot is a
+ `file_size` verification failure and a `size_over_limit` warning, not a silent pass. Test:
+ `test_export_max_mb_fits_the_size`.
+- **What it cannot do honestly is refused.** Another encoder (the HEVC, AV1, ProRes, GIF and copy
+ presets; VideoToolbox under `--hw`, which has no two-pass mode), `--crf` (a quality, not a
+ size), a source with no duration, and a budget that leaves under 100 kb/s for the picture. A
+ size that small makes an unwatchable file the user did not ask for, so the refusal says the
+ bitrate it would have been. Same test.
diff --git a/references/scripts.md b/references/scripts.md
index 2e9c3dcc..5ee88a60 100644
--- a/references/scripts.md
+++ b/references/scripts.md
@@ -1880,9 +1880,15 @@ integrated loudness ended more than 1 LU from the target because of it.
### export.py — delivery presets
```
export.py INPUT --preset youtube|youtube4k|reels|tiktok|shorts|linkedin|facebook|x|youtube-hdr|youtube-av1|prores|h265|gif|copy
- [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [--normalize] [-o OUT]
+ [--fit pad|crop] [--no-scale] [--allow-long] [--crf N | --max-mb MB] [--normalize] [-o OUT]
export.py --list
```
+`--max-mb MB` (2.23) fits the file under MB MiB: the preset's x264 line gets the video bitrate the
+running time leaves after the preset's audio bitrate (97% of the budget; the rest is container
+overhead) in place of its CRF, and the encode runs in two passes. The written size is measured: a
+`file_size` verification step, and a `size_over_limit` warning with a `notes` line when the encoder
+overshot. Refused for a preset that does not encode with x264 (and under `--hw`), with `--crf`,
+for a source with no duration, and when the budget leaves under 100 kb/s for the picture.
Scales into the preset frame (pad by default), tags BT.709 (a BT.601-matrix or RGB source is converted to it, as by every re-encode), sets `+faststart`,
trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`, and says
so: `trimmed: {from, to}` and a `notes` line. It
diff --git a/scripts/_contract.py b/scripts/_contract.py
index 707e175c..5ac10633 100644
--- a/scripts/_contract.py
+++ b/scripts/_contract.py
@@ -496,6 +496,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
# degraded or partial results (part 2)
"colour_not_converted": ("warnings", "export.py: an HDR or wide-gamut source written by a preset that tags SDR BT.709 without converting it; {preset, hdr_format}"),
"output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS), so no gain meets the platform's loudness; {platform}"),
+ "size_over_limit": ("warnings", "export.py --max-mb: the written file is over the size asked for (the encoder overshot its bitrate); {size, limit}"),
"loudness_outside_spec": ("warnings", "export.py: the written file's loudness is outside the platform's spec (normalized: after --normalize too); {platform, lufs, tp, normalized}"),
"camera_not_started": ("fallbacks", "multicam.py: a range asked for a camera that had not started; camera 0 played it ({start, end, requested_camera, camera, starts_at}); `cuts` names the camera rendered"),
"loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
diff --git a/scripts/export.py b/scripts/export.py
index 866daaf1..89ea26e1 100755
--- a/scripts/export.py
+++ b/scripts/export.py
@@ -33,6 +33,7 @@
import math
import os
import sys
+import tempfile
from pathlib import Path
from typing import Dict, List
@@ -239,6 +240,42 @@ def _non_av_report(subs: List[Dict], sub_codecs: List[str], data: List[Dict], dr
return {"kept": kept, "dropped": gone}
+MIB = 1024 * 1024
+SIZE_HEADROOM = 0.97 # of the byte budget given to the streams; the rest is container overhead
+SIZE_MIN_VIDEO_KBPS = 100 # below this the picture is not worth delivering; refused instead
+
+
+def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool) -> List[str]:
+ """--max-mb: the preset's x264 line with its CRF replaced by the bitrate that fits the size,
+ for a two-pass encode. Refused where that cannot be done honestly: another codec, --crf, --hw,
+ a source with no duration, or a size too small for a watchable picture."""
+ if args.crf is not None:
+ die("--max-mb sets the bitrate from the size, --crf sets a quality: give one of them")
+ if "-c:v" not in video or video[video.index("-c:v") + 1] != "libx264" or "-crf" not in video:
+ die(f"--max-mb runs a two-pass x264 encode; --preset {args.preset} does not encode with x264"
+ + (" (--hw puts it on VideoToolbox, which has no two-pass mode)" if STATE.hw else ""))
+ if args.max_mb <= 0:
+ die(f"--max-mb must be positive, got {args.max_mb:g}")
+ dur = float(meta.get("duration") or 0.0)
+ if p["max"] and not args.allow_long:
+ dur = min(dur, float(p["max"]))
+ if dur <= 0:
+ die("--max-mb needs the source's duration, which it does not report")
+ audio_kbps = 0.0
+ if has_audio and "-b:a" in p["audio"]:
+ audio_kbps = float(p["audio"][p["audio"].index("-b:a") + 1].rstrip("k"))
+ video_kbps = args.max_mb * MIB * 8 * SIZE_HEADROOM / dur / 1000 - audio_kbps
+ if video_kbps < SIZE_MIN_VIDEO_KBPS:
+ die(f"--max-mb {args.max_mb:g} leaves {max(video_kbps, 0):.0f} kb/s for the picture over {dur:.1f}s "
+ f"(after {audio_kbps:.0f} kb/s of audio); under {SIZE_MIN_VIDEO_KBPS} kb/s it is not watchable",
+ hint="raise --max-mb, or cut the video shorter first")
+ out = list(video)
+ i = out.index("-crf")
+ out[i:i + 2] = ["-b:v", f"{video_kbps:.0f}k"]
+ info(f"--max-mb {args.max_mb:g}: {video_kbps:.0f} kb/s video + {audio_kbps:.0f} kb/s audio over {dur:.1f}s, two-pass")
+ return out
+
+
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("input", nargs="?")
@@ -250,6 +287,9 @@ def main() -> int:
ap.add_argument("--no-scale", action="store_true", help="keep source resolution even for platform presets")
ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
ap.add_argument("--crf", type=int, help="override CRF")
+ ap.add_argument("--max-mb", type=float, metavar="MB",
+ help="fit the file under MB megabytes (MiB): a two-pass x264 encode at the video bitrate the "
+ "running time leaves after the audio, measured afterwards (H.264 presets; not with --crf or --hw)")
ap.add_argument("--normalize", action="store_true", help="youtube/youtube4k/reels/x: when the written file misses the platform's loudness spec, run loudness.py on it (audio re-encoded, video copied) so one export delivers")
ap.add_argument("--list", action="store_true", help="list presets and exit")
add_common(ap, codec=False) # the preset decides the codec; --codec would only be refused
@@ -354,6 +394,8 @@ def main() -> int:
# A build without libx264/libx265 has no other encoder, so it goes there without --hw too
# (hw_preset_video returns the line untouched in every other case)
video = hw_preset_video(video, meta, bt709=tag_bt709)
+ if args.max_mb is not None:
+ video = size_video_args(args, video, p, meta, has_audio)
cmd += video
if args.preset != "copy":
# a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
@@ -379,9 +421,15 @@ def main() -> int:
note(f"trimmed from {trimmed['from']:.3f}s to the {args.preset} maximum of {p['max']:.0f}s "
"(use --allow-long to keep full length)")
cmd += ["-t", f"{p['max']:.3f}"]
- dropped = _run_keeping_non_av(cmd, output, sub_codecs, len(data)) if (subs or data) else []
- if not (subs or data):
- run(cmd + [output])
+ with tempfile.TemporaryDirectory(prefix="ffskill_2pass_") as passdir:
+ if args.max_mb is not None:
+ # pass 1 only writes x264's statistics; pass 2 encodes against them
+ log = os.path.join(passdir, "x264")
+ run(cmd + ["-pass", "1", "-passlogfile", log, "-an", "-sn", "-dn", "-f", "null", "-"])
+ cmd += ["-pass", "2", "-passlogfile", log]
+ dropped = _run_keeping_non_av(cmd, output, sub_codecs, len(data)) if (subs or data) else []
+ if not (subs or data):
+ run(cmd + [output])
result = probe(output, role="output")
v = result["video"]
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
@@ -463,6 +511,16 @@ def main() -> int:
step = {"step": "duration", "property": "duration", "ok": False, "expected": {"max": p["max"]},
"reason": "not_measured: the written file reports no duration"}
extra.setdefault("verification", []).append(step)
+ if args.max_mb is not None and not STATE.dry_run:
+ limit = int(args.max_mb * MIB)
+ size = os.path.getsize(output)
+ extra.setdefault("verification", []).append(
+ {"step": "file_size", "property": "size_bytes", "ok": size <= limit, "expected": {"max": limit},
+ "measured": size, "delta": size - limit, "tolerance": 0, "provenance": "user_specified"})
+ if size > limit:
+ notes.append(f"--max-mb {args.max_mb:g}: the file is {size / MIB:.2f} MiB, over the limit; the encoder overshot "
+ f"its target bitrate on this content: export again with a lower --max-mb")
+ warn(notes[-1], "size_over_limit", size=size, limit=limit)
# read back after --normalize, which rewrote the file
non_av = _non_av_report(subs, sub_codecs, data, dropped, output, "write_failed")
if non_av["dropped"]:
diff --git a/tests/fixtures/mcp_tools.json b/tests/fixtures/mcp_tools.json
index 5759f3d1..661f205e 100644
--- a/tests/fixtures/mcp_tools.json
+++ b/tests/fixtures/mcp_tools.json
@@ -204,8 +204,10 @@
"properties": [
"argv",
"aspect",
+ "compare",
"content",
"dry_run",
+ "expect_identity",
"fast",
"input",
"json_brief",
@@ -409,6 +411,7 @@
"input",
"json_brief",
"list",
+ "max_mb",
"no_hw",
"no_scale",
"normalize",
diff --git a/tests/test_delivery.py b/tests/test_delivery.py
index b855d4ac..74fc501a 100644
--- a/tests/test_delivery.py
+++ b/tests/test_delivery.py
@@ -41,6 +41,27 @@ def test_export_presets(self):
if preset in ("youtube", "reels", "x", "h265"):
self.assertEqual(m["video"]["color_space"], "bt709")
+ def test_export_max_mb_fits_the_size(self):
+ """2.23: --max-mb 1 on a 10 s clip that is several MB at the preset's CRF: two passes, the
+ written file under 1 MiB and measured; a budget too small for the picture, --crf with it,
+ and a non-x264 preset are refused before anything runs."""
+ src = OUT / "max_mb_src.mp4"
+ if not src.exists():
+ sh("ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=640x360:rate=30:d=10",
+ "-f", "lavfi", "-i", "sine=d=10", "-c:v", "libx264", "-crf", "5", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", src)
+ out = OUT / "max_mb_out.mp4"
+ doc = json.loads(script("export.py", src, "--preset", "youtube", "--max-mb", "1", "--no-scale", "-o", out,
+ "--overwrite", "--json").stdout)
+ self.assertLessEqual(out.stat().st_size, 1024 * 1024)
+ size = next(v for v in doc["verification"] if v.get("step") == "file_size")
+ self.assertTrue(size["ok"] and size["measured"] == out.stat().st_size, size)
+ self.assertEqual(sum("-pass" in c for c in doc["commands"]), 2)
+ for extra, said in ((["--max-mb", "0.05"], "not watchable"), (["--max-mb", "1", "--crf", "20"], "give one of them")):
+ proc = script("export.py", src, "--preset", "youtube", *extra, "-o", OUT / "max_mb_bad.mp4", "--json", expect_fail=True)
+ self.assertIn(said, json.loads(proc.stdout)["error"]["message"])
+ proc = script("export.py", src, "--preset", "h265", "--max-mb", "1", "-o", OUT / "max_mb_bad.mp4", "--json", expect_fail=True)
+ self.assertIn("x264", json.loads(proc.stdout)["error"]["message"])
+
def test_export_list(self):
self.assertIn("youtube", script("export.py", "--list").stdout)
From ae26d289bf976afc7c55a7cf7187070b1244446d Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 31 Oct 2026 04:31:26 +0000
Subject: [PATCH 005/191] Add export.py --preset copy --repair: remux a damaged
file and count the damage
Timestamps regenerated, corrupt packets dropped, demuxing past errors, negative timestamps shifted
to zero; the input is only read. Both files are decoded once at -v warning and the timestamp
warnings and other errors counted (repair: {before, after}); the repair step fails if either grew,
and errors a remux cannot fix are a repair_incomplete warning.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_013tPrsGXLuaGU7t643QNUZs
---
CHANGELOG.md | 1 +
README.md | 2 +-
docs/design-decisions.md | 15 +++++++++++++
references/scripts.md | 9 ++++++++
scripts/_contract.py | 4 +++-
scripts/export.py | 40 +++++++++++++++++++++++++++++++++--
tests/fixtures/mcp_tools.json | 1 +
tests/test_delivery.py | 22 +++++++++++++++++++
8 files changed, 90 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba89b10e..3adebf4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
### Added
+- **`export.py --preset copy --repair` remuxes a damaged file** (a recording cut off mid-write, broken timestamps from a screen recorder): timestamps regenerated, corrupt packets dropped, negative timestamps shifted to zero, the input untouched. Decode errors and timestamp warnings are counted before and after; errors a remux cannot fix are named.
- **`export.py --max-mb MB` fits a delivery under a size limit** (an email attachment, a chat app's upload cap): a two-pass x264 encode at the bitrate the running time leaves after the audio, with the written size measured. A size too small for a watchable picture (under 100 kb/s) is refused before anything runs.
- **Every writing tool reports `output_identity: {size, mtime_ns}`, and `check.py --expect-identity SIZE:MTIME_NS` checks against it.** A check run after a write that failed, leaving an older file at the path, or after another run replaced it, used to grade whatever was there; with the flag an `identity` row FAILs instead. check.py reports the file it graded as `input_identity`. SKILL.md's delivery check passes it.
- **`check.py --compare REF` measures the picture against a reference:** mean SSIM, the worst frame and its time, and mean PSNR, in one pass; WARN under a mean SSIM of 0.98. Frames of another size are not compared.
diff --git a/README.md b/README.md
index fa556d3c..dc04936e 100644
--- a/README.md
+++ b/README.md
@@ -268,7 +268,7 @@ These are the rules the skill file gives the agent and the code enforces.
| Tool | What it does |
|---|---|
-| `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) ; `--max-mb MB` fits the file under a size limit by a two-pass encode and measures it |
+| `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) ; `--max-mb MB` fits the file under a size limit by a two-pass encode and measures it ; `--preset copy --repair` remuxes a damaged file (timestamps regenerated, corrupt packets dropped) and counts the errors before and after |
| `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--quality` (default 30), `--fps`, `--no-audio`; not a delivery preset |
| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence and short audio-dropout rows ; `--compare REF` measures SSIM/PSNR against a reference; `--expect-identity` checks that the file is the one a run reported writing (`output_identity`) |
| `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
diff --git a/docs/design-decisions.md b/docs/design-decisions.md
index b8b30edb..b093fe9d 100644
--- a/docs/design-decisions.md
+++ b/docs/design-decisions.md
@@ -57,6 +57,7 @@ exists. When a decision changes, edit the entry in the same PR.
- [2.23 — join.py --copy and cut.py --remove](#223--joinpy---copy-and-cutpy---remove)
- [2.23 — output identity and check.py --compare](#223--output-identity-and-checkpy---compare)
- [2.23 — export.py --max-mb](#223--exportpy---max-mb)
+- [2.23 — export.py --repair](#223--exportpy---repair)
## Time limits
@@ -2369,3 +2370,17 @@ What was decided there:
size), a source with no duration, and a budget that leaves under 100 kb/s for the picture. A
size that small makes an unwatchable file the user did not ask for, so the refusal says the
bitrate it would have been. Same test.
+
+## 2.23 — export.py --repair
+
+- **Repair is a flag on the copy preset, not a tool.** It is a remux with three input flags
+ (`-fflags +genpts+discardcorrupt -err_detect ignore_err`) and `-avoid_negative_ts make_zero`:
+ the copy preset with different demuxing, so a new tool would add a name without adding a job.
+ A re-encoding preset already decodes past damage, so `--repair` with one is refused as
+ meaningless. Test: `test_export_repair_counts_damage_before_and_after`.
+- **It is measured by what a decoder says, before and after.** One full decode of each file at
+ `-v warning`, lines about timestamps counted apart from the rest. The verification step fails
+ only when the output has more of either than the input: a remux that removed every timestamp
+ warning but left decode errors from damaged frames did what a remux can do, and those errors are
+ named (`repair_incomplete`) with the way out (a re-encoding preset). On a TS file with 40 bytes
+ overwritten: 3 timestamp warnings and 57 errors before, 0 and 53 after. Same test.
diff --git a/references/scripts.md b/references/scripts.md
index 5ee88a60..05e31545 100644
--- a/references/scripts.md
+++ b/references/scripts.md
@@ -1881,8 +1881,17 @@ integrated loudness ended more than 1 LU from the target because of it.
```
export.py INPUT --preset youtube|youtube4k|reels|tiktok|shorts|linkedin|facebook|x|youtube-hdr|youtube-av1|prores|h265|gif|copy
[--fit pad|crop] [--no-scale] [--allow-long] [--crf N | --max-mb MB] [--normalize] [-o OUT]
+export.py INPUT --preset copy --repair [-o OUT]
export.py --list
```
+`--repair` (2.23, `--preset copy` only) remuxes a damaged file: timestamps are regenerated
+(`-fflags +genpts`), packets flagged corrupt are dropped (`+discardcorrupt`), demuxing goes on past
+errors (`-err_detect ignore_err`) and negative timestamps are shifted to zero. The input is read,
+never written. Both files are decoded once at `-v warning` and the lines counted:
+`repair: {before, after}`, each `{timestamp_warnings, decode_errors}`, and a `repair` verification
+step that fails when the output has more of either than the input. A remux cannot rebuild a damaged
+frame, so decode errors that remain are a `repair_incomplete` warning and a `notes` line (a
+re-encoding preset decodes past them).
`--max-mb MB` (2.23) fits the file under MB MiB: the preset's x264 line gets the video bitrate the
running time leaves after the preset's audio bitrate (97% of the budget; the rest is container
overhead) in place of its CRF, and the encode runs in two passes. The written size is measured: a
diff --git a/scripts/_contract.py b/scripts/_contract.py
index 5ac10633..1b7327a5 100644
--- a/scripts/_contract.py
+++ b/scripts/_contract.py
@@ -496,6 +496,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
# degraded or partial results (part 2)
"colour_not_converted": ("warnings", "export.py: an HDR or wide-gamut source written by a preset that tags SDR BT.709 without converting it; {preset, hdr_format}"),
"output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS), so no gain meets the platform's loudness; {platform}"),
+ "repair_incomplete": ("warnings", "export.py --repair: decode errors remain after the remux (a damaged frame needs a re-encode); {timestamp_warnings, decode_errors}"),
"size_over_limit": ("warnings", "export.py --max-mb: the written file is over the size asked for (the encoder overshot its bitrate); {size, limit}"),
"loudness_outside_spec": ("warnings", "export.py: the written file's loudness is outside the platform's spec (normalized: after --normalize too); {platform, lufs, tp, normalized}"),
"camera_not_started": ("fallbacks", "multicam.py: a range asked for a camera that had not started; camera 0 played it ({start, end, requested_camera, camera, starts_at}); `cuts` names the camera rendered"),
@@ -616,7 +617,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
elif name == "report":
extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"], "description": "check.py's document for --platform; null when it was not asked for or could not run (a failing compliance verification step then says why)"}}
elif name == "export":
- extra = {"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
+ extra = {"repair": {"type": "object", "description": "--repair (2.23): {before, after}, each {timestamp_warnings, decode_errors} counted over one full decode at -v warning"},
+ "loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
"trimmed": {"type": "object", "description": "present when the source was longer than the preset's platform maximum and the export was cut to it (no --allow-long): {from, to} in seconds; notes says so too"},
"non_av_streams": {"type": "object", "description": "the source's subtitle and data streams: {kept: [{type: subtitle|data, index (n-th of its type), codec, language (subtitle), output_codec}], dropped: [{type, index, codec, language, reason: write_failed|gif|not_in_output}]}, read back from the written file (under --dry-run, as planned). A subtitle track is copied when the container holds it as it is, else converted to the container's text track (mov_text in .mp4/.mov, SubRip in .mkv); a trimmed export cuts it at the same point. Both lists are empty for a source with neither"},
"dropped_non_av_streams": {"type": "boolean", "description": "true when non_av_streams.dropped is not empty; a notes line says so too"},
diff --git a/scripts/export.py b/scripts/export.py
index 89ea26e1..8d446102 100755
--- a/scripts/export.py
+++ b/scripts/export.py
@@ -31,13 +31,14 @@
import argparse
import json
import math
+import re
import os
import sys
import tempfile
from pathlib import Path
from typing import Dict, List
-from _common import STATE, add_common, apply_common, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
+from _common import STATE, run_analysis, add_common, apply_common, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
from _common.decision import hw_preset_video, restate_last_swap
from _common.emit import note, record_fallback, record_skip, warn
from check import SPECS as PLATFORMS, measure_loudness
@@ -240,6 +241,21 @@ def _non_av_report(subs: List[Dict], sub_codecs: List[str], data: List[Dict], dr
return {"kept": kept, "dropped": gone}
+# --repair (2.23): regenerate missing timestamps, drop packets flagged corrupt, keep demuxing past errors
+REPAIR_INPUT_ARGS = ["-fflags", "+genpts+discardcorrupt", "-err_detect", "ignore_err"]
+_TIMESTAMP_WARNING = re.compile(r"(?i)\b(dts|pts)\b|timestamp")
+
+
+def media_damage(path: str) -> Dict[str, int]:
+ """One full decode of `path` at -v warning: lines about timestamps (non-monotonic DTS, invalid
+ PTS) and every other error line, counted. What --repair is measured by, before and after."""
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "warning", "-i", path, "-f", "null", "-"],
+ check=False, record=True)
+ lines = [ln for ln in (proc.stderr or "").splitlines() if ln.strip()]
+ stamps = sum(1 for ln in lines if _TIMESTAMP_WARNING.search(ln))
+ return {"timestamp_warnings": stamps, "decode_errors": len(lines) - stamps}
+
+
MIB = 1024 * 1024
SIZE_HEADROOM = 0.97 # of the byte budget given to the streams; the rest is container overhead
SIZE_MIN_VIDEO_KBPS = 100 # below this the picture is not worth delivering; refused instead
@@ -287,6 +303,10 @@ def main() -> int:
ap.add_argument("--no-scale", action="store_true", help="keep source resolution even for platform presets")
ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
ap.add_argument("--crf", type=int, help="override CRF")
+ ap.add_argument("--repair", action="store_true",
+ help="--preset copy only: remux a damaged file -- timestamps regenerated, corrupt packets dropped, "
+ "negative timestamps shifted to zero -- and count the decode errors and timestamp warnings "
+ "before and after; the input is never touched")
ap.add_argument("--max-mb", type=float, metavar="MB",
help="fit the file under MB megabytes (MiB): a two-pass x264 encode at the video bitrate the "
"running time leaves after the audio, measured afterwards (H.264 presets; not with --crf or --hw)")
@@ -343,7 +363,9 @@ def main() -> int:
vf.append("setsar=1")
else:
vf.append(f"scale={p['w']}:-2")
- cmd = ffmpeg_base() + ["-i", args.input]
+ if args.repair and args.preset != "copy":
+ die("--repair is a remux: use it with --preset copy (a re-encoding preset already rebuilds every frame)")
+ cmd = ffmpeg_base() + (REPAIR_INPUT_ARGS if args.repair else []) + ["-i", args.input]
# Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
# kept where the container can hold them: ffmpeg's default stream selection takes one video
# and one audio stream only, and through 2.22.1 every preset dropped the rest without a word.
@@ -411,6 +433,8 @@ def main() -> int:
cmd += bt709_matrix_args(meta)
if out_ext == "mp4":
cmd += ["-movflags", "+faststart"]
+ if args.repair:
+ cmd += ["-avoid_negative_ts", "make_zero"]
cmd += (p["audio"] if has_audio else ["-an"])
trimmed = None
if p["max"] and not args.allow_long and (meta.get("duration") or 0) > p["max"]:
@@ -421,6 +445,7 @@ def main() -> int:
note(f"trimmed from {trimmed['from']:.3f}s to the {args.preset} maximum of {p['max']:.0f}s "
"(use --allow-long to keep full length)")
cmd += ["-t", f"{p['max']:.3f}"]
+ damage_before = media_damage(args.input) if args.repair and not STATE.dry_run else None
with tempfile.TemporaryDirectory(prefix="ffskill_2pass_") as passdir:
if args.max_mb is not None:
# pass 1 only writes x264's statistics; pass 2 encodes against them
@@ -511,6 +536,17 @@ def main() -> int:
step = {"step": "duration", "property": "duration", "ok": False, "expected": {"max": p["max"]},
"reason": "not_measured: the written file reports no duration"}
extra.setdefault("verification", []).append(step)
+ if damage_before is not None:
+ after = media_damage(output)
+ extra["repair"] = {"before": damage_before, "after": after}
+ worse = any(after[k] > damage_before[k] for k in after)
+ extra.setdefault("verification", []).append(
+ {"step": "repair", "property": "decode_errors", "ok": not worse, "expected": {"max": damage_before},
+ "measured": after, "provenance": "input_premeasurement"})
+ if after["decode_errors"]:
+ notes.append(f"--repair: {after['decode_errors']} decode error(s) remain: a remux drops corrupt packets and "
+ "rewrites timestamps but cannot rebuild a damaged frame; a re-encoding preset decodes past them")
+ warn(notes[-1], "repair_incomplete", **after)
if args.max_mb is not None and not STATE.dry_run:
limit = int(args.max_mb * MIB)
size = os.path.getsize(output)
diff --git a/tests/fixtures/mcp_tools.json b/tests/fixtures/mcp_tools.json
index 661f205e..5f9c6a66 100644
--- a/tests/fixtures/mcp_tools.json
+++ b/tests/fixtures/mcp_tools.json
@@ -422,6 +422,7 @@
"pad_fill",
"plan",
"preset",
+ "repair",
"timeout"
],
"required": []
diff --git a/tests/test_delivery.py b/tests/test_delivery.py
index 74fc501a..8d390542 100644
--- a/tests/test_delivery.py
+++ b/tests/test_delivery.py
@@ -62,6 +62,28 @@ def test_export_max_mb_fits_the_size(self):
proc = script("export.py", src, "--preset", "h265", "--max-mb", "1", "-o", OUT / "max_mb_bad.mp4", "--json", expect_fail=True)
self.assertIn("x264", json.loads(proc.stdout)["error"]["message"])
+ def test_export_repair_counts_damage_before_and_after(self):
+ """2.23: --preset copy --repair on a TS with overwritten bytes: the input is untouched, the
+ counts are reported before and after and do not grow, remaining errors are named; --repair
+ with a re-encoding preset is refused."""
+ src = OUT / "repair_src.ts"
+ sh("ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=320x180:rate=30:d=6",
+ "-f", "lavfi", "-i", "sine=d=6", "-c:v", "libx264", "-c:a", "aac", "-f", "mpegts", src)
+ data = bytearray(src.read_bytes())
+ for k in range(40): # deterministic damage in the middle half
+ data[len(data) // 4 + k * (len(data) // 80)] ^= 0xFF
+ src.write_bytes(bytes(data))
+ before = src.read_bytes()
+ out = OUT / "repair_out.mp4"
+ doc = json.loads(script("export.py", src, "--preset", "copy", "--repair", "-o", out, "--overwrite", "--json").stdout)
+ self.assertEqual(src.read_bytes(), before)
+ rep = doc["repair"]
+ self.assertGreater(sum(rep["before"].values()), 0, rep)
+ self.assertTrue(all(rep["after"][k] <= rep["before"][k] for k in rep["after"]), rep)
+ self.assertTrue(next(v for v in doc["verification"] if v["step"] == "repair")["ok"])
+ proc = script("export.py", src, "--preset", "youtube", "--repair", "-o", OUT / "repair_bad.mp4", "--json", expect_fail=True)
+ self.assertIn("--preset copy", json.loads(proc.stdout)["error"]["message"])
+
def test_export_list(self):
self.assertIn("youtube", script("export.py", "--list").stdout)
From 3284cebaff675d2785540d4c360526371a7b46ba Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 31 Oct 2026 05:01:04 +0000
Subject: [PATCH 006/191] export.py --repair: judge the remux on timestamp
warnings
Decode errors are log lines whose count changes with the container (one
full run logged 51 before and 53 after), so they are reported before and
after but no longer fail the verification step; timestamp warnings, the
part a remux rewrites, do. The test source is encoded single-threaded so
its damage is the same every run.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_013tPrsGXLuaGU7t643QNUZs
---
docs/design-decisions.md | 13 ++++++++-----
references/scripts.md | 5 +++--
scripts/export.py | 10 +++++++---
tests/test_delivery.py | 13 ++++++++-----
4 files changed, 26 insertions(+), 15 deletions(-)
diff --git a/docs/design-decisions.md b/docs/design-decisions.md
index b093fe9d..bd064ccc 100644
--- a/docs/design-decisions.md
+++ b/docs/design-decisions.md
@@ -2379,8 +2379,11 @@ What was decided there:
A re-encoding preset already decodes past damage, so `--repair` with one is refused as
meaningless. Test: `test_export_repair_counts_damage_before_and_after`.
- **It is measured by what a decoder says, before and after.** One full decode of each file at
- `-v warning`, lines about timestamps counted apart from the rest. The verification step fails
- only when the output has more of either than the input: a remux that removed every timestamp
- warning but left decode errors from damaged frames did what a remux can do, and those errors are
- named (`repair_incomplete`) with the way out (a re-encoding preset). On a TS file with 40 bytes
- overwritten: 3 timestamp warnings and 57 errors before, 0 and 53 after. Same test.
+ `-v warning`, lines about timestamps counted apart from the rest. The verification step is
+ judged on timestamp warnings only, the part a remux rewrites, and fails when the output has more
+ than the input. Decode errors are reported before and after but not compared: they are log
+ lines, and the same damaged frame logs a different number of them demuxed from TS than from MP4
+ (one run of this test logged 51 before and 53 after on a remux that dropped nothing it should
+ have kept). Errors that remain are named (`repair_incomplete`) with the way out (a re-encoding
+ preset). On a TS file with 40 bytes overwritten: 3 timestamp warnings and 58 errors before, 0 and
+ 58 after. Same test.
diff --git a/references/scripts.md b/references/scripts.md
index 05e31545..70f575c4 100644
--- a/references/scripts.md
+++ b/references/scripts.md
@@ -1889,8 +1889,9 @@ export.py --list
errors (`-err_detect ignore_err`) and negative timestamps are shifted to zero. The input is read,
never written. Both files are decoded once at `-v warning` and the lines counted:
`repair: {before, after}`, each `{timestamp_warnings, decode_errors}`, and a `repair` verification
-step that fails when the output has more of either than the input. A remux cannot rebuild a damaged
-frame, so decode errors that remain are a `repair_incomplete` warning and a `notes` line (a
+step that fails when the output has more timestamp warnings than the input (decode errors are log
+lines whose count changes with the container, so they are reported, not compared). A remux cannot
+rebuild a damaged frame, so decode errors that remain are a `repair_incomplete` warning and a `notes` line (a
re-encoding preset decodes past them).
`--max-mb MB` (2.23) fits the file under MB MiB: the preset's x264 line gets the video bitrate the
running time leaves after the preset's audio bitrate (97% of the budget; the rest is container
diff --git a/scripts/export.py b/scripts/export.py
index 8d446102..b44dc0e5 100755
--- a/scripts/export.py
+++ b/scripts/export.py
@@ -539,10 +539,14 @@ def main() -> int:
if damage_before is not None:
after = media_damage(output)
extra["repair"] = {"before": damage_before, "after": after}
- worse = any(after[k] > damage_before[k] for k in after)
+ # judged on timestamp warnings, the part a remux rewrites: decode errors are counted in log
+ # lines, and the same damaged frame logs a different number of them demuxed from another
+ # container, so their before/after is reported, not compared
+ stamps = after["timestamp_warnings"]
extra.setdefault("verification", []).append(
- {"step": "repair", "property": "decode_errors", "ok": not worse, "expected": {"max": damage_before},
- "measured": after, "provenance": "input_premeasurement"})
+ {"step": "repair", "property": "timestamp_warnings", "ok": stamps <= damage_before["timestamp_warnings"],
+ "expected": {"max": damage_before["timestamp_warnings"]}, "measured": stamps,
+ "provenance": "input_premeasurement"})
if after["decode_errors"]:
notes.append(f"--repair: {after['decode_errors']} decode error(s) remain: a remux drops corrupt packets and "
"rewrites timestamps but cannot rebuild a damaged frame; a re-encoding preset decodes past them")
diff --git a/tests/test_delivery.py b/tests/test_delivery.py
index 8d390542..c69c423f 100644
--- a/tests/test_delivery.py
+++ b/tests/test_delivery.py
@@ -64,11 +64,11 @@ def test_export_max_mb_fits_the_size(self):
def test_export_repair_counts_damage_before_and_after(self):
"""2.23: --preset copy --repair on a TS with overwritten bytes: the input is untouched, the
- counts are reported before and after and do not grow, remaining errors are named; --repair
- with a re-encoding preset is refused."""
+ counts are reported before and after, timestamp warnings do not grow, remaining decode errors
+ are named; --repair with a re-encoding preset is refused."""
src = OUT / "repair_src.ts"
sh("ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=320x180:rate=30:d=6",
- "-f", "lavfi", "-i", "sine=d=6", "-c:v", "libx264", "-c:a", "aac", "-f", "mpegts", src)
+ "-f", "lavfi", "-i", "sine=d=6", "-c:v", "libx264", "-threads", "1", "-c:a", "aac", "-f", "mpegts", src)
data = bytearray(src.read_bytes())
for k in range(40): # deterministic damage in the middle half
data[len(data) // 4 + k * (len(data) // 80)] ^= 0xFF
@@ -79,8 +79,11 @@ def test_export_repair_counts_damage_before_and_after(self):
self.assertEqual(src.read_bytes(), before)
rep = doc["repair"]
self.assertGreater(sum(rep["before"].values()), 0, rep)
- self.assertTrue(all(rep["after"][k] <= rep["before"][k] for k in rep["after"]), rep)
- self.assertTrue(next(v for v in doc["verification"] if v["step"] == "repair")["ok"])
+ self.assertLessEqual(rep["after"]["timestamp_warnings"], rep["before"]["timestamp_warnings"], rep)
+ step = next(v for v in doc["verification"] if v["step"] == "repair")
+ self.assertTrue(step["ok"] and step["measured"] == rep["after"]["timestamp_warnings"], step)
+ if rep["after"]["decode_errors"]:
+ self.assertIn("repair_incomplete", {w["code"] for w in doc["diagnostics"]["warnings"]}, doc.get("diagnostics"))
proc = script("export.py", src, "--preset", "youtube", "--repair", "-o", OUT / "repair_bad.mp4", "--json", expect_fail=True)
self.assertIn("--preset copy", json.loads(proc.stdout)["error"]["message"])
From 762f1bc7f1fd5a1fd7c1b6f61c7019faa52a673c Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 31 Oct 2026 05:28:48 +0000
Subject: [PATCH 007/191] Label the join copy, identity, compare, max-mb and
repair additions 2.24
2.23.0 shipped with check.py's flashes and first-frame rows; these land in
the next minor.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_013tPrsGXLuaGU7t643QNUZs
---
docs/contract.md | 2 +-
docs/design-decisions.md | 16 ++++++++--------
references/scripts.md | 12 ++++++------
scripts/_common/emit.py | 2 +-
scripts/_common/probe.py | 2 +-
scripts/_contract.py | 6 +++---
scripts/export.py | 2 +-
scripts/join.py | 2 +-
tests/test_analysis.py | 4 ++--
tests/test_delivery.py | 4 ++--
tests/test_editing.py | 4 ++--
11 files changed, 28 insertions(+), 28 deletions(-)
diff --git a/docs/contract.md b/docs/contract.md
index 9a9a7134..a6467841 100644
--- a/docs/contract.md
+++ b/docs/contract.md
@@ -208,7 +208,7 @@ the loudness pass or, when loudness is skipped, one volumedetect pass). `check -
(opt-in; render.py `"check": {"content": true}`) adds `black`, `frozen` and `silence` rows
from one decode pass, and `dropouts` (2.17) from one audio-only pass; without it the row set is unchanged.
`--compare REF` adds `similarity` (SSIM/PSNR against a reference) and `--expect-identity SIZE:MTIME_NS`
-adds `identity`, against the `output_identity` every writing tool reports (2.23). Thresholds:
+adds `identity`, against the `output_identity` every writing tool reports (2.24). Thresholds:
`docs/design-decisions.md` ("check.py --content").
### Dry run
diff --git a/docs/design-decisions.md b/docs/design-decisions.md
index bd064ccc..adfdd652 100644
--- a/docs/design-decisions.md
+++ b/docs/design-decisions.md
@@ -54,10 +54,10 @@ exists. When a decision changes, edit the entry in the same PR.
- [2.20 — a length or a silence is checked against a yardstick the tool did not make](#220--a-length-or-a-silence-is-checked-against-a-yardstick-the-tool-did-not-make)
- [2.21.1 — a stream that ends early or decodes nothing fails the check](#2211--a-stream-that-ends-early-or-decodes-nothing-fails-the-check)
- [2.22 — silence.py says where it cut, and where to listen](#222--silencepy-says-where-it-cut-and-where-to-listen)
-- [2.23 — join.py --copy and cut.py --remove](#223--joinpy---copy-and-cutpy---remove)
-- [2.23 — output identity and check.py --compare](#223--output-identity-and-checkpy---compare)
-- [2.23 — export.py --max-mb](#223--exportpy---max-mb)
-- [2.23 — export.py --repair](#223--exportpy---repair)
+- [2.24 — join.py --copy and cut.py --remove](#224--joinpy---copy-and-cutpy---remove)
+- [2.24 — output identity and check.py --compare](#224--output-identity-and-checkpy---compare)
+- [2.24 — export.py --max-mb](#224--exportpy---max-mb)
+- [2.24 — export.py --repair](#224--exportpy---repair)
## Time limits
@@ -2311,7 +2311,7 @@ What was decided there:
`--dry-run`. Its output times are the sums of the kept ranges, the same arithmetic the render
uses. Test: `test_silence_writes_a_map_from_source_to_output_times`.
-## 2.23 — join.py --copy and cut.py --remove
+## 2.24 — join.py --copy and cut.py --remove
- **`--copy` is opt-in, and a mismatch is refused rather than re-encoded.** A join that
re-encoded by default and copied when it could would change what an existing call does, which
@@ -2335,7 +2335,7 @@ What was decided there:
leftover shorter than one frame (10 ms without a frame rate) is not a piece anyone asked to
keep. Test: `test_cut_remove_keeps_the_rest`.
-## 2.23 — output identity and check.py --compare
+## 2.24 — output identity and check.py --compare
- **An output is identified by size and modification time, not a hash.** The point is to tell
the file a run wrote from whatever sits at the path later (an older file a failed `--overwrite`
@@ -2356,7 +2356,7 @@ What was decided there:
(`look.py --compare`). Code: `_common.probe.compare_pictures()`. Test:
`test_check_compare_measures_against_a_reference`.
-## 2.23 — export.py --max-mb
+## 2.24 — export.py --max-mb
- **A size limit is a two-pass x264 encode at a computed bitrate, then measured.** The budget is
97% of MB MiB (the rest is the container's overhead), less the preset's own audio bitrate, over
@@ -2371,7 +2371,7 @@ What was decided there:
size that small makes an unwatchable file the user did not ask for, so the refusal says the
bitrate it would have been. Same test.
-## 2.23 — export.py --repair
+## 2.24 — export.py --repair
- **Repair is a flag on the copy preset, not a tool.** It is a remux with three input flags
(`-fflags +genpts+discardcorrupt -err_detect ignore_err`) and `-avoid_negative_ts make_zero`:
diff --git a/references/scripts.md b/references/scripts.md
index 70f575c4..4a28d018 100644
--- a/references/scripts.md
+++ b/references/scripts.md
@@ -138,7 +138,7 @@ keyframes, instant, lossless); if the snapped result deviates more than
`--codec` overrides all of them). `--accurate` always re-encodes; `--tolerance -1` never does for a keyframe snap
(a `--segments` join of open-GOP video, one whose later B-frame `.mp4`/`.mov` segment starts
between keyframes, or one that fails its check, still re-cuts).
-Multiple segments are concatenated in the order given. `--remove A-B,...` (2.23) is the inverse: the ranges are dropped (overlaps merged, clamped to the file) and what is left is cut as `--segments` would cut it; a leftover shorter than one frame is not kept, and a removal that leaves nothing is refused. It does not combine with `--segments`, `--start`, `--end` or `--duration`. Since 2.17.2 every join the re-encode makes fades the sound for `--declick-ms` (20 ms) on each side, so butted cuts do not click; `--no-declick` butts them as before, and a stream-copy join (sound copied) is not faded. The result reports `declick_ms` (null when there was no re-encoded join). stderr reports whether
+Multiple segments are concatenated in the order given. `--remove A-B,...` (2.24) is the inverse: the ranges are dropped (overlaps merged, clamped to the file) and what is left is cut as `--segments` would cut it; a leftover shorter than one frame is not kept, and a removal that leaves nothing is refused. It does not combine with `--segments`, `--start`, `--end` or `--duration`. Since 2.17.2 every join the re-encode makes fades the sound for `--declick-ms` (20 ms) on each side, so butted cuts do not click; `--no-declick` butts them as before, and a stream-copy join (sound copied) is not faded. The result reports `declick_ms` (null when there was no re-encoded join). stderr reports whether
the result was "lossless stream copy" or "re-encoded"; when the snap forced a
re-encode, the result's `lossless_alternative` names the nearest keyframe
`--start` that would stream-copy instead, so the trade can be offered.
@@ -771,7 +771,7 @@ join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadebla
join.py CLIP1 CLIP2 [...] --copy --transition none [-o OUT]
join.py --list parts.txt [--on-missing fail|skip] [--on-silent warn|fail|skip] [--allow-silent N[,N]] [--silence-threshold -50] [...]
```
-`--copy --transition none` (2.23) joins by stream copy (the concat demuxer, `-c copy`), no
+`--copy --transition none` (2.24) joins by stream copy (the concat demuxer, `-c copy`), no
re-encode, for clips that already match: the first video and first audio stream of every clip must
have the same codec, profile, size, pixel format, frame rate, time base, sample aspect, colour
tags, sample rate and channels (and the same codec configuration), no clip may hide frames
@@ -1149,11 +1149,11 @@ construction, not by two lists agreeing.
The `audio` row FAILs (under a loudness target; WARN without one) on a present-but-silent track (peak <= -50 dBFS, the join/audio
`--silence-threshold` default): measured from the loudness pass, or with one volumedetect
pass when `--no-loudness` (or a spec with no loudness target) skips it.
-`--expect-identity SIZE:MTIME_NS` (2.23) ties the check to one run's output: every writing tool
+`--expect-identity SIZE:MTIME_NS` (2.24) ties the check to one run's output: every writing tool
reports `output_identity: {size, mtime_ns}` for the file it wrote, and check.py reports the file it
graded as `input_identity`. With the flag an `identity` row FAILs when they differ, so a check after
a run that failed (leaving an older file at the path) or that another run overwrote does not pass
-someone else's file. `--compare REF` (2.23) measures the picture against a reference in one pass
+someone else's file. `--compare REF` (2.24) measures the picture against a reference in one pass
(ffmpeg's `ssim` and `psnr`, frames paired in decode order): a `similarity` row with the mean SSIM,
the worst frame and its time, and the mean PSNR (`identical` when every pixel matches); WARN under a
mean SSIM of 0.98. Frames of another size are not compared (WARN, "not compared"); a different frame
@@ -1884,7 +1884,7 @@ export.py INPUT --preset youtube|youtube4k|reels|tiktok|shorts|linkedin|facebook
export.py INPUT --preset copy --repair [-o OUT]
export.py --list
```
-`--repair` (2.23, `--preset copy` only) remuxes a damaged file: timestamps are regenerated
+`--repair` (2.24, `--preset copy` only) remuxes a damaged file: timestamps are regenerated
(`-fflags +genpts`), packets flagged corrupt are dropped (`+discardcorrupt`), demuxing goes on past
errors (`-err_detect ignore_err`) and negative timestamps are shifted to zero. The input is read,
never written. Both files are decoded once at `-v warning` and the lines counted:
@@ -1893,7 +1893,7 @@ step that fails when the output has more timestamp warnings than the input (deco
lines whose count changes with the container, so they are reported, not compared). A remux cannot
rebuild a damaged frame, so decode errors that remain are a `repair_incomplete` warning and a `notes` line (a
re-encoding preset decodes past them).
-`--max-mb MB` (2.23) fits the file under MB MiB: the preset's x264 line gets the video bitrate the
+`--max-mb MB` (2.24) fits the file under MB MiB: the preset's x264 line gets the video bitrate the
running time leaves after the preset's audio bitrate (97% of the budget; the rest is container
overhead) in place of its CRF, and the encode runs in two passes. The written size is measured: a
`file_size` verification step, and a `size_over_limit` warning with a `notes` line when the encoder
diff --git a/scripts/_common/emit.py b/scripts/_common/emit.py
index eb444f3b..0864cb78 100644
--- a/scripts/_common/emit.py
+++ b/scripts/_common/emit.py
@@ -581,7 +581,7 @@ def _framecrc_frames(proc: Any, since: Optional[float] = None) -> int:
def file_identity(path: str) -> "Optional[Dict[str, int]]":
- """{size, mtime_ns} of a file this run wrote or measured (2.23): what ties a later check.py
+ """{size, mtime_ns} of a file this run wrote or measured (2.24): what ties a later check.py
--expect-identity to this file rather than to whatever sits at the path then. None when the
path is not a regular file."""
try:
diff --git a/scripts/_common/probe.py b/scripts/_common/probe.py
index dc8881b7..d72c8cd0 100644
--- a/scripts/_common/probe.py
+++ b/scripts/_common/probe.py
@@ -791,7 +791,7 @@ def signatures_match(sigs: list, ext: str) -> bool:
return True
-# check.py --compare (2.23): the output's picture against a reference, frame by frame.
+# check.py --compare (2.24): the output's picture against a reference, frame by frame.
COMPARE_SSIM_WARN = 0.98 # mean SSIM below this: a visible difference somewhere is likely
_SSIM_SUMMARY = re.compile(r"SSIM Y:[0-9.]+ .*All:([0-9.]+)")
_PSNR_SUMMARY = re.compile(r"PSNR y:\S+ .*average:(\S+)")
diff --git a/scripts/_contract.py b/scripts/_contract.py
index 1b7327a5..a978bc9a 100644
--- a/scripts/_contract.py
+++ b/scripts/_contract.py
@@ -544,7 +544,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
"probe": {"type": "object", "description": "probe of the output when a file was written"},
- "output_identity": {"type": "object", "description": "2.23: {size, mtime_ns} of the file this run wrote, absent under --dry-run; pass it to check.py --expect-identity SIZE:MTIME_NS so the check grades this file and not whatever later sits at the path"},
+ "output_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file this run wrote, absent under --dry-run; pass it to check.py --expect-identity SIZE:MTIME_NS so the check grades this file and not whatever later sits at the path"},
"encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
"hw": {"type": "object", "description": "present when VideoToolbox was asked for (--hw, $FFMPEG_SKILL_HW, a render.py stage, a batch.py item), on export.py when $FFMPEG_SKILL_HW=1 is set but its delivery preset does not take it, and when this ffmpeg build has no libx264/libx265 and an encode ran on VideoToolbox instead: {requested: whether this run's encodes were asked onto VideoToolbox (false for that export, and for the build without libx264/libx265), source: flag|env|auto (auto: nobody asked, the build has no libx264/libx265 and this Apple Silicon machine ran VideoToolbox in its place), used: true|false whether the encoder that ran last is VideoToolbox, null under --dry-run (nothing ran) and when this process ran no encode itself (batch.py: its stages are child processes; its top-level hw gathers the items' requested/source/fallback/notes and each results row carries its item's encoder and hw), fallback: a job VideoToolbox refused (encoder open or compression session failure; nothing else is retried) was re-encoded on the CPU, notes: why an encode stayed on or fell back to the CPU, and, when $FFMPEG_SKILL_HW chose the GPU and it ran, what that costs and that --no-hw opts out}. A VideoToolbox encode also adds a top-level notes line: its quality is not CRF-equivalent. A kind: ffmpeg failure under --hw / $FFMPEG_SKILL_HW carries encoder and hw too"},
"plan": {"type": "string", "description": "with --plan FILE: the plan document written (the run itself is a dry run)"},
@@ -555,7 +555,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
extra: Dict[str, Any] = {}
if name == "check":
extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
- "input_identity": {"type": "object", "description": "2.23: {size, mtime_ns} of the file graded, the same shape as a writing tool's output_identity"},
+ "input_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file graded, the same shape as a writing tool's output_identity"},
"notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named: youtube was assumed and judgement rows are WARN"},
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
elif name == "caption":
@@ -617,7 +617,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
elif name == "report":
extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"], "description": "check.py's document for --platform; null when it was not asked for or could not run (a failing compliance verification step then says why)"}}
elif name == "export":
- extra = {"repair": {"type": "object", "description": "--repair (2.23): {before, after}, each {timestamp_warnings, decode_errors} counted over one full decode at -v warning"},
+ extra = {"repair": {"type": "object", "description": "--repair (2.24): {before, after}, each {timestamp_warnings, decode_errors} counted over one full decode at -v warning"},
"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
"trimmed": {"type": "object", "description": "present when the source was longer than the preset's platform maximum and the export was cut to it (no --allow-long): {from, to} in seconds; notes says so too"},
"non_av_streams": {"type": "object", "description": "the source's subtitle and data streams: {kept: [{type: subtitle|data, index (n-th of its type), codec, language (subtitle), output_codec}], dropped: [{type, index, codec, language, reason: write_failed|gif|not_in_output}]}, read back from the written file (under --dry-run, as planned). A subtitle track is copied when the container holds it as it is, else converted to the container's text track (mov_text in .mp4/.mov, SubRip in .mkv); a trimmed export cuts it at the same point. Both lists are empty for a source with neither"},
diff --git a/scripts/export.py b/scripts/export.py
index b44dc0e5..b52d3aca 100755
--- a/scripts/export.py
+++ b/scripts/export.py
@@ -241,7 +241,7 @@ def _non_av_report(subs: List[Dict], sub_codecs: List[str], data: List[Dict], dr
return {"kept": kept, "dropped": gone}
-# --repair (2.23): regenerate missing timestamps, drop packets flagged corrupt, keep demuxing past errors
+# --repair (2.24): regenerate missing timestamps, drop packets flagged corrupt, keep demuxing past errors
REPAIR_INPUT_ARGS = ["-fflags", "+genpts+discardcorrupt", "-err_detect", "ignore_err"]
_TIMESTAMP_WARNING = re.compile(r"(?i)\b(dts|pts)\b|timestamp")
diff --git a/scripts/join.py b/scripts/join.py
index b1c84cad..86be73d7 100755
--- a/scripts/join.py
+++ b/scripts/join.py
@@ -307,7 +307,7 @@ def parse_allow_silent(value: Optional[str], n: int) -> List[int]:
return out
-# join.py --copy (2.23). The concat demuxer starts each clip where the previous one's container
+# join.py --copy (2.24). The concat demuxer starts each clip where the previous one's container
# ends, so a stream that ends early leaves a gap at the join: a held frame for the picture, a hole
# for the sound. A clip whose picture and sound end further apart than this is refused.
COPY_AV_GAP = 0.05
diff --git a/tests/test_analysis.py b/tests/test_analysis.py
index ae565fcc..8ae1a4bb 100644
--- a/tests/test_analysis.py
+++ b/tests/test_analysis.py
@@ -312,7 +312,7 @@ def test_check_content_rows(self):
self.assertEqual((r["black"]["status"], r["silence"]["status"]), ("WARN", "WARN"), r)
def test_check_expect_identity_ties_the_check_to_one_write(self):
- """2.23: a writing tool reports output_identity; check.py --expect-identity FAILs on any other
+ """2.24: a writing tool reports output_identity; check.py --expect-identity FAILs on any other
file at that path (here: the same path rewritten by a second run), and reports input_identity."""
src = self._content_clip("identity_src.mp4", "testsrc2=size=160x90:rate=10", "sine=frequency=440:sample_rate=48000", 2)
out = OUT / "identity_out.mp4"
@@ -332,7 +332,7 @@ def test_check_expect_identity_ties_the_check_to_one_write(self):
self.assertEqual(doc["input_identity"]["size"], out.stat().st_size)
def test_check_compare_measures_against_a_reference(self):
- """2.23: --compare REF: identical frames PASS as identical, a heavily compressed copy WARNs
+ """2.24: --compare REF: identical frames PASS as identical, a heavily compressed copy WARNs
under a mean SSIM of 0.98 with its worst frame, another frame size is not compared."""
ref = self._content_clip("compare_ref.mp4", "testsrc2=size=160x90:rate=10", "sine=frequency=440:sample_rate=48000", 3)
poor, small = OUT / "compare_poor.mp4", OUT / "compare_small.mp4"
diff --git a/tests/test_delivery.py b/tests/test_delivery.py
index c69c423f..48e9fe75 100644
--- a/tests/test_delivery.py
+++ b/tests/test_delivery.py
@@ -42,7 +42,7 @@ def test_export_presets(self):
self.assertEqual(m["video"]["color_space"], "bt709")
def test_export_max_mb_fits_the_size(self):
- """2.23: --max-mb 1 on a 10 s clip that is several MB at the preset's CRF: two passes, the
+ """2.24: --max-mb 1 on a 10 s clip that is several MB at the preset's CRF: two passes, the
written file under 1 MiB and measured; a budget too small for the picture, --crf with it,
and a non-x264 preset are refused before anything runs."""
src = OUT / "max_mb_src.mp4"
@@ -63,7 +63,7 @@ def test_export_max_mb_fits_the_size(self):
self.assertIn("x264", json.loads(proc.stdout)["error"]["message"])
def test_export_repair_counts_damage_before_and_after(self):
- """2.23: --preset copy --repair on a TS with overwritten bytes: the input is untouched, the
+ """2.24: --preset copy --repair on a TS with overwritten bytes: the input is untouched, the
counts are reported before and after, timestamp warnings do not grow, remaining decode errors
are named; --repair with a re-encoding preset is refused."""
src = OUT / "repair_src.ts"
diff --git a/tests/test_editing.py b/tests/test_editing.py
index 8e195f03..bcb37be0 100644
--- a/tests/test_editing.py
+++ b/tests/test_editing.py
@@ -1141,7 +1141,7 @@ def test_join_with_transition_normalises_mismatched_clips(self):
script("join.py", self.src, expect_fail=True)
def test_join_copy_joins_matching_clips_and_refuses_a_mismatch(self):
- """2.23: --copy --transition none joins clips with matching streams by stream copy (the
+ """2.24: --copy --transition none joins clips with matching streams by stream copy (the
joined packets are the clips' packets, nothing re-encoded), and refuses a clip that differs,
or a picture and sound that end apart, by name and before anything is written."""
clips = [self._av_clip(OUT / f"copy_{i}.mp4", 2, 2, 300 + 200 * i) for i in range(3)]
@@ -1167,7 +1167,7 @@ def test_join_copy_joins_matching_clips_and_refuses_a_mismatch(self):
self.assertIn("--transition none", json.loads(proc.stdout)["error"]["message"])
def test_cut_remove_keeps_the_rest(self):
- """2.23: --remove is --segments written the other way: dropping 1-2 and 3.5-4 (overlapping
+ """2.24: --remove is --segments written the other way: dropping 1-2 and 3.5-4 (overlapping
3.8-4 merged) from 6 s keeps 0-1, 2-3.5 and 4-6."""
clip = self._av_clip(OUT / "remove_src.mp4", 6, 6, 440)
out = OUT / "removed.mp4"
From 7c2560ea21e41e4ab011aa11ad77cc1916f3cf0b Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 31 Oct 2026 06:27:06 +0000
Subject: [PATCH 008/191] Address review: cover art, timeline times, copy-join
guards, --remove edges, --max-mb
- check.py --compare reads V:0 (not cover art), times the worst frame by
its presentation time, counts frames from packets when nb_frames is
missing, prints that note in plain text, runs under --dry-run when both
files exist, and starts each main() with no leftover notes.
- join.py --copy compares the first non-cover video stream, judges A/V
alignment on each stream's end (start + length), includes colour matrix
and range in the signature, refuses --sample-rate/--channels, fails the
packet check when nothing was counted, and reports short and silent
clips as the re-encoding join does.
- cut.py --remove refuses any explicit --start and --snap beats, and
clamps a range that starts before 0.
- export.py --max-mb refuses nan and inf.
- contract: join is conditional on audio; check notes name --compare.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_013tPrsGXLuaGU7t643QNUZs
---
docs/contract.md | 2 +-
docs/design-decisions.md | 13 ++++++++-----
references/scripts.md | 11 ++++++-----
scripts/_common/probe.py | 4 ++--
scripts/_contract.py | 2 +-
scripts/check.py | 31 +++++++++++++++++++------------
scripts/cut.py | 18 ++++++++++++------
scripts/export.py | 4 ++--
scripts/join.py | 34 +++++++++++++++++++++++-----------
tests/test_analysis.py | 16 ++++++++++++++++
tests/test_delivery.py | 3 ++-
tests/test_editing.py | 29 +++++++++++++++++++++++++++--
12 files changed, 119 insertions(+), 48 deletions(-)
diff --git a/docs/contract.md b/docs/contract.md
index 355b0135..c1138526 100644
--- a/docs/contract.md
+++ b/docs/contract.md
@@ -170,7 +170,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
| `verification` | `{required, tools}`: which tools to run on the output afterwards |
| `requires_visual_verification` | the picture changed; run `ffmpeg-skill/look` and inspect the PNG |
| `unverified_properties` | 2.18: the output properties this tool does not measure, the same list its results carry as `unverified` (empty when the tool names none) |
-| `reencodes_video`, `reencodes_audio` | `"always"` / `"never"` / `"conditional"`, meaning *when that stream is present in the input* — not whether the tool touches the file at all. `"conditional"` tools (`cut`, `export`, `render`, `batch`, `verify`, `caption`, `color`) carry a `reencode_note` explaining what it depends on — for `caption`, `--mode burn` (default) always re-encodes both streams, `--mode mux` copies both untouched; for `color`, `--strip-dovi` and `--retag` are a stream copy of both (retag only re-encodes if the copy attempt fails or ffprobe still reads the old tags on the copy), while `--to-sdr` / `--lut` / `--correct` always re-encode both. Several visual tools (`fit`, `overlay`, `graphics`, `join`, `multicam`, `silence`) are `"always"` on audio too: this codebase never mixes `-c:v` re-encode with `-c:a copy` in one call, so a caller cannot assume the original audio codec survives just because only the picture changed |
+| `reencodes_video`, `reencodes_audio` | `"always"` / `"never"` / `"conditional"`, meaning *when that stream is present in the input* — not whether the tool touches the file at all. `"conditional"` tools (`cut`, `export`, `render`, `batch`, `verify`, `caption`, `color`, `join`) carry a `reencode_note` explaining what it depends on — for `caption`, `--mode burn` (default) always re-encodes both streams, `--mode mux` copies both untouched; for `color`, `--strip-dovi` and `--retag` are a stream copy of both (retag only re-encodes if the copy attempt fails or ffprobe still reads the old tags on the copy), while `--to-sdr` / `--lut` / `--correct` always re-encode both; for `join`, `--copy --transition none` copies both, every other join re-encodes both. Several visual tools (`fit`, `overlay`, `graphics`, `multicam`, `silence`) are `"always"` on audio too: this codebase never mixes `-c:v` re-encode with `-c:a copy` in one call, so a caller cannot assume the original audio codec survives just because only the picture changed |
| `audio_only` | accepts an audio-only input (WAV, MP3, M4A, FLAC, OGG, Opus) |
| `video_required` | refuses an input without a video stream ("input has no video stream") |
| | `render` has `audio_only: true` and `video_required: false` since 2.17.4: `--template podcast` and `--template audiogram` take an audio file. `join` has them since 0.9.1: audio-only inputs are joined as audio (no `look` needed then); mixing audio and video inputs is refused |
diff --git a/docs/design-decisions.md b/docs/design-decisions.md
index 2f80d35e..80196ec6 100644
--- a/docs/design-decisions.md
+++ b/docs/design-decisions.md
@@ -2358,7 +2358,7 @@ What was decided there:
- **Three things a matching signature does not catch are checked too.** An MP4 edit list that
hides pre-roll frames (packets flagged discard in the first 300): the concat demuxer ignores
edit lists, so a copy join would show them. Picture and sound that end more than 50 ms apart
- in a clip: the demuxer starts the next clip where this one's container ends, so the shorter
+ in a clip (each stream's start plus its length, so equal lengths that start apart are caught): the demuxer starts the next clip where this one's container ends, so the shorter
stream leaves a held frame or a hole in the sound there. And the result itself: its video
packet count against the clips' total, and its video length against their summed length
(within 50 ms per clip plus a frame), measured on a file beside the output that is renamed
@@ -2367,7 +2367,9 @@ What was decided there:
hands it to the same code, so a removal is cut, joined, declicked and verified exactly as the
equivalent `--segments` would be. Overlaps merge and the ranges are clamped to the file; a
leftover shorter than one frame (10 ms without a frame rate) is not a piece anyone asked to
- keep. Test: `test_cut_remove_keeps_the_rest`.
+ keep. A range may start before 0 (it is clamped like the end). `--snap beats` is refused with
+ it: a snapped edge of what is kept could move into a range the caller asked to drop, and the
+ flag's promise is that those ranges are gone. Test: `test_cut_remove_keeps_the_rest`.
## 2.24 — output identity and check.py --compare
@@ -2383,11 +2385,12 @@ What was decided there:
before and only reports `input_identity`. Same test.
- **`--compare` pairs frames in decode order and reports, it does not align.** SSIM and PSNR are
ffmpeg's own filters in one pass. Pairing by order is what those filters do; a dropped or added
- frame shifts every later pair, so a different frame count is named in `notes` rather than
- hidden. Frames of another size are not compared at all (WARN, "not compared"): scaling one of
+ frame shifts every later pair, so a different frame count (from `nb_frames`, or counted from
+ the packets when a container does not state it) is named in `notes` rather than hidden. Frames of another size are not compared at all (WARN, "not compared"): scaling one of
them first would measure the scaler. The WARN line is a mean SSIM of 0.98, where differences
start to be visible on ordinary footage; the worst frame and its time say where to look
- (`look.py --compare`). Code: `_common.probe.compare_pictures()`. Test:
+ (`look.py --compare`), the time read as the flashes row reads it (that frame's presentation
+ time, `~` index / rate without one). Cover art is not the picture compared (`V`, not `v`). Code: `_common.probe.compare_pictures()`. Test:
`test_check_compare_measures_against_a_reference`.
## 2.24 — export.py --max-mb
diff --git a/references/scripts.md b/references/scripts.md
index ac0f60ac..e7e9085c 100644
--- a/references/scripts.md
+++ b/references/scripts.md
@@ -138,7 +138,7 @@ keyframes, instant, lossless); if the snapped result deviates more than
`--codec` overrides all of them). `--accurate` always re-encodes; `--tolerance -1` never does for a keyframe snap
(a `--segments` join of open-GOP video, one whose later B-frame `.mp4`/`.mov` segment starts
between keyframes, or one that fails its check, still re-cuts).
-Multiple segments are concatenated in the order given. `--remove A-B,...` (2.24) is the inverse: the ranges are dropped (overlaps merged, clamped to the file) and what is left is cut as `--segments` would cut it; a leftover shorter than one frame is not kept, and a removal that leaves nothing is refused. It does not combine with `--segments`, `--start`, `--end` or `--duration`. Since 2.17.2 every join the re-encode makes fades the sound for `--declick-ms` (20 ms) on each side, so butted cuts do not click; `--no-declick` butts them as before, and a stream-copy join (sound copied) is not faded. The result reports `declick_ms` (null when there was no re-encoded join). stderr reports whether
+Multiple segments are concatenated in the order given. `--remove A-B,...` (2.24) is the inverse: the ranges are dropped (overlaps merged, clamped to the file) and what is left is cut as `--segments` would cut it; a leftover shorter than one frame is not kept, and a removal that leaves nothing is refused. It does not combine with `--segments`, `--start` (even `--start 0`), `--end`, `--duration` or `--snap beats` (a snapped edge could move into a dropped range). Since 2.17.2 every join the re-encode makes fades the sound for `--declick-ms` (20 ms) on each side, so butted cuts do not click; `--no-declick` butts them as before, and a stream-copy join (sound copied) is not faded. The result reports `declick_ms` (null when there was no re-encoded join). stderr reports whether
the result was "lossless stream copy" or "re-encoded"; when the snap forced a
re-encode, the result's `lossless_alternative` names the nearest keyframe
`--start` that would stream-copy instead, so the trade can be offered.
@@ -774,14 +774,15 @@ join.py --list parts.txt [--on-missing fail|skip] [--on-silent warn|fail|skip] [
`--copy --transition none` (2.24) joins by stream copy (the concat demuxer, `-c copy`), no
re-encode, for clips that already match: the first video and first audio stream of every clip must
have the same codec, profile, size, pixel format, frame rate, time base, sample aspect, colour
-tags, sample rate and channels (and the same codec configuration), no clip may hide frames
-before its start with an edit list, and in each clip picture and sound must end within 50 ms of
-each other (the demuxer starts the next clip where this one's container ends, so the shorter
+tags (matrix, range, transfer, primaries), sample rate and channels (cover art is not the video
+stream) (and the same codec configuration), no clip may hide frames
+before its start with an edit list, and in each clip picture and sound must end (start plus
+length) within 50 ms of each other (the demuxer starts the next clip where this one's container ends, so the shorter
stream would leave that gap). Anything else is refused before ffmpeg runs (`kind: input`,
`reason: join_copy_mismatch`, `details.clips: [{index?, path, differs}]` naming what differs).
The join is written beside the output and measured before it is renamed into place: its video
packet count must equal the clips' total and its video length their summed length. `mode` is
-`copy`. `--copy` does not take `--width`, `--height`, `--fps` or a transition, and an
+`copy`. `--copy` does not take `--width`, `--height`, `--fps`, `--sample-rate`, `--channels` or a transition, and an
audio-only join (which resamples every clip to one format) is refused with it.
`--list FILE` (2.2) reads the clips from a file, one per line in order, paths relative to the
file (blank lines and `#` comments ignored; ffmpeg's `file 'x.wav'` lines accepted), for the
diff --git a/scripts/_common/probe.py b/scripts/_common/probe.py
index b2358c15..fe386757 100644
--- a/scripts/_common/probe.py
+++ b/scripts/_common/probe.py
@@ -833,7 +833,7 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
IN_BAND_CONFIG = ("mp3", "mp2")
TS_EXTS = (".ts", ".m2ts", ".mts")
_SIG_FIELDS = {"video": ("codec_name", "profile", "pix_fmt", "width", "height", "r_frame_rate", "time_base",
- "sample_aspect_ratio", "color_transfer", "color_primaries"),
+ "sample_aspect_ratio", "color_space", "color_range", "color_transfer", "color_primaries"),
"audio": ("codec_name", "profile", "sample_rate", "channels", "time_base")}
@@ -892,7 +892,7 @@ def compare_pictures(path: str, reference: str) -> "Optional[Dict[str, Any]]":
from _common.decision import escape_filter_path
with tempfile.TemporaryDirectory(prefix="ffskill_compare_") as tmp:
stats = os.path.join(tmp, "ssim.log")
- graph = ("[0:v:0]split[a0][a1];[1:v:0]split[b0][b1];"
+ graph = ("[0:V:0]split[a0][a1];[1:V:0]split[b0][b1];"
f"[a0][b0]ssim=stats_file={escape_filter_path(stats, bind=False)};[a1][b1]psnr")
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-i", path, "-i", reference,
"-filter_complex", graph, "-f", "null", "-"], check=False, record=True)
diff --git a/scripts/_contract.py b/scripts/_contract.py
index 8587b501..df372549 100644
--- a/scripts/_contract.py
+++ b/scripts/_contract.py
@@ -557,7 +557,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
if name == "check":
extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
"input_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file graded, the same shape as a writing tool's output_identity"},
- "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named: youtube was assumed and judgement rows are WARN"},
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), or when --compare paired a different number of frames than the reference has"},
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
elif name == "caption":
extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
diff --git a/scripts/check.py b/scripts/check.py
index 906de319..5c9706ff 100755
--- a/scripts/check.py
+++ b/scripts/check.py
@@ -81,15 +81,18 @@ def flash_times(flashes: List[int], v: Dict[str, Any], meta: Dict[str, Any], pts
start). A flash frame's own presentation time when `pts` has it (presentation_times(): exact,
gaps in the timestamps included); otherwise index / average rate plus the video's lead over
the container, marked "~" (approximate), and `frame N` when there is no frame rate either."""
+ return ", ".join(frame_time(n, v, meta, pts) for n in flashes[:5]) + (" ..." if len(flashes) > 5 else "")
+
+
+def frame_time(n: int, v: Dict[str, Any], meta: Dict[str, Any], pts: "Optional[List[float]]" = None) -> str:
+ """Frame `n` (decode order) on look.py's timeline, as flash_times() describes: exact from
+ `pts`, else "~" index / average rate plus the video's lead, else `frame N`."""
origin = float(meta.get("start_time") or 0.0)
+ if pts and n < len(pts):
+ return f"{max(0.0, pts[n] - origin):.2f}s"
fps = float(v.get("fps") or 0.0)
lead = max(0.0, float(v.get("start_time") or 0.0) - origin)
-
- def one(n: int) -> str:
- if pts and n < len(pts):
- return f"{max(0.0, pts[n] - origin):.2f}s"
- return f"~{lead + n / fps:.2f}s" if fps else f"frame {n}"
- return ", ".join(one(n) for n in flashes[:5]) + (" ..." if len(flashes) > 5 else "")
+ return f"~{lead + n / fps:.2f}s" if fps else f"frame {n}"
def _span_total(spans: Any) -> float:
@@ -105,6 +108,7 @@ def aspect_name(w: int, h: int) -> str:
def main() -> int:
+ STATE_NOTES.clear() # module state: a second main() in one process starts without the first's notes
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("input")
ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
@@ -367,7 +371,7 @@ def silent_audio(peak: float) -> None:
row("identity", "PASS" if same else "FAIL", f"{identity['size']}:{identity['mtime_ns']}", f"{size}:{mtime_ns}",
"" if same else "re-run the tool that writes this file and check what it reports, not what the path holds",
reason="" if same else "the file at this path is not the one the run being checked wrote")
- if args.compare and not STATE.dry_run:
+ if args.compare and not dry_run_input_pending(args.input) and not dry_run_input_pending(args.compare):
ref = probe(args.compare)
rv = ref.get("video") or {}
if not v or not rv:
@@ -383,16 +387,19 @@ def silent_audio(peak: float) -> None:
row("similarity", "WARN", "could not measure", f"mean SSIM >= {COMPARE_SSIM_WARN:g}",
"look.py --compare at a few times", reason="the comparison pass failed")
else:
- fps = float(v.get("fps") or 0.0)
- at = f"{cmp_['worst_frame'] / fps:.2f}s" if fps else f"frame {cmp_['worst_frame']}"
+ pts = presentation_times(args.input, float(v.get("start_time") or 0.0))
+ at = frame_time(cmp_["worst_frame"], v, meta, pts)
+ seek = "0" if at.startswith("frame") else at.lstrip("~").rstrip("s")
psnr = "identical" if cmp_["psnr"] == float("inf") else (f"{cmp_['psnr']:.1f} dB" if cmp_["psnr"] is not None else "?")
low = cmp_["ssim"] < COMPARE_SSIM_WARN
row("similarity", "WARN" if low else "PASS",
f"SSIM {cmp_['ssim']:.4f} (worst {cmp_['worst_ssim']:.4f} at {at}), PSNR {psnr}, {cmp_['frames']} frames",
f"mean SSIM >= {COMPARE_SSIM_WARN:g}",
- f"look.py --compare {args.compare} --at {at.rstrip('s') if fps else 0}" if low else "",
+ f"look.py --compare {args.compare} --at {seek}" if low else "",
reason="the picture differs visibly from the reference somewhere" if low else "")
- nref, nout = rv.get("nb_frames"), v.get("nb_frames")
+ # frames counted from the packets when a container does not state nb_frames
+ nout = v.get("nb_frames") or len(pts) or None
+ nref = rv.get("nb_frames") or len(presentation_times(args.compare, float(rv.get("start_time") or 0.0))) or None
if nref and nout and int(nref) != int(nout):
STATE_NOTES.append(f"--compare: {nout} frames against the reference's {nref}; frames were paired in "
f"order, so an added or dropped frame shifts every later pair")
@@ -401,6 +408,7 @@ def silent_audio(peak: float) -> None:
notes: List[str] = []
if not named:
notes.append("no --platform given: youtube's spec was assumed, so judgement rows (duration, aspect, fps, resolution, loudness, true peak) are WARN, not FAIL; name a platform to enforce them")
+ notes += STATE_NOTES
if not args.json:
width = max(len(r["check"]) for r in rows)
print(f"{args.input} — {args.platform}" + ("" if named else " (assumed)"))
@@ -416,7 +424,6 @@ def silent_audio(peak: float) -> None:
print(f" {len(rows)} checks, {len(failed)} failed, {len(warned)} warnings")
for n in notes:
print(f" note: {n}")
- notes += STATE_NOTES
extra: Dict[str, Any] = {"notes": notes} if notes else {}
if identity is not None:
extra["input_identity"] = identity
diff --git a/scripts/cut.py b/scripts/cut.py
index a19144b3..6d4cce2a 100755
--- a/scripts/cut.py
+++ b/scripts/cut.py
@@ -93,7 +93,9 @@ def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float,
return keep
-def parse_segments(spec: str, fps=None) -> List[Tuple[float, float]]:
+def parse_segments(spec: str, fps=None, clamp: bool = False) -> List[Tuple[float, float]]:
+ """START-END,... as seconds. `clamp` (--remove) lets a range start before 0, since the file
+ clamps it; --segments refuses that."""
segs = []
for raw in spec.split(","):
raw = raw.strip()
@@ -103,7 +105,7 @@ def parse_segments(spec: str, fps=None) -> List[Tuple[float, float]]:
die(f"segment '{raw}' must look like START-END (e.g. 0:05-0:12)")
a, b = raw.rsplit("-", 1)
start, end = _t(a, fps), _t(b, fps)
- if start < 0:
+ if start < 0 and not clamp:
die(f"segment '{raw}': start must be >= 0")
if end <= start:
die(f"segment '{raw}': end must be after start")
@@ -1294,7 +1296,7 @@ def main() -> int:
ap.add_argument("input")
ap.add_argument("-o", "--output", help="output file (default: _cut.)")
g = ap.add_argument_group("range (single segment)")
- g.add_argument("--start", default="0", help="start time (seconds, mm:ss or hh:mm:ss.ms). default 0")
+ g.add_argument("--start", help="start time (seconds, mm:ss or hh:mm:ss.ms). default 0")
g.add_argument("--end", help="end time")
g.add_argument("--duration", help="duration instead of --end")
ap.add_argument("--segments", help="comma separated START-END list, e.g. '0:05-0:12,1:00-1:20' (joined in order)")
@@ -1351,16 +1353,20 @@ def main() -> int:
fps = (meta.get("video") or {}).get("fps")
if args.remove:
- if args.segments or args.end or args.duration or (args.start and _t(args.start, fps, "--start")):
+ if args.segments or args.end or args.duration or args.start is not None:
die("--remove names what to drop from the whole file: it does not combine with --segments, --start, --end or --duration")
+ if args.snap == "beats":
+ # a snapped edge of what is kept could move into a range the caller asked to drop
+ die("--remove drops exactly the ranges given: --snap beats would move the kept edges, possibly into them; "
+ "snap the ranges first (scenes.py --beats) or cut with --segments --snap beats")
if not total:
die(f"--remove needs the input's duration, which {args.input} does not report"
+ (" yet (it is an earlier step's output)" if dry_run_input_pending(args.input) else ""))
- args.segments = ",".join(f"{s:.6f}-{e:.6f}" for s, e in keep_ranges_after_removing(parse_segments(args.remove, fps), total, fps))
+ args.segments = ",".join(f"{s:.6f}-{e:.6f}" for s, e in keep_ranges_after_removing(parse_segments(args.remove, fps, clamp=True), total, fps))
if args.segments:
segments = parse_segments(args.segments, fps)
else:
- start = _t(args.start, fps, "--start")
+ start = _t(args.start or "0", fps, "--start")
if start < 0:
die(f"--start must not be negative, got {args.start!r}")
if args.end and args.duration:
diff --git a/scripts/export.py b/scripts/export.py
index b52d3aca..1fc512df 100755
--- a/scripts/export.py
+++ b/scripts/export.py
@@ -270,8 +270,8 @@ def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool
if "-c:v" not in video or video[video.index("-c:v") + 1] != "libx264" or "-crf" not in video:
die(f"--max-mb runs a two-pass x264 encode; --preset {args.preset} does not encode with x264"
+ (" (--hw puts it on VideoToolbox, which has no two-pass mode)" if STATE.hw else ""))
- if args.max_mb <= 0:
- die(f"--max-mb must be positive, got {args.max_mb:g}")
+ if not math.isfinite(args.max_mb) or args.max_mb <= 0:
+ die(f"--max-mb must be a positive number of MiB, got {args.max_mb:g}")
dur = float(meta.get("duration") or 0.0)
if p["max"] and not args.allow_long:
dur = min(dur, float(p["max"]))
diff --git a/scripts/join.py b/scripts/join.py
index 86be73d7..3b3c4fc0 100755
--- a/scripts/join.py
+++ b/scripts/join.py
@@ -325,15 +325,17 @@ def _copy_doc(path: str) -> "Optional[Dict[str, Any]]":
return None
if not isinstance(doc, dict):
return None
- streams = doc.get("streams") or []
+ # attached pictures (cover art) are not the picture being joined, as V:0 skips them
+ streams = [st for st in doc.get("streams") or [] if not (st.get("disposition") or {}).get("attached_pic")]
keep = [next((st for st in streams if st.get("codec_type") == kind), None) for kind in ("video", "audio")]
doc["streams"] = [st for st in keep if st]
return doc
def _video_packets(path: str, limit: "Optional[int]" = None) -> "List[str]":
- """The flags of the first video stream's packets (all of them, or the first `limit`)."""
- cmd = [require_tool("ffprobe"), "-v", "error", "-select_streams", "v:0", "-show_entries", "packet=flags", "-of", "csv=p=0"]
+ """The flags of the first video stream's packets (all of them, or the first `limit`); cover art
+ is not that stream."""
+ cmd = [require_tool("ffprobe"), "-v", "error", "-select_streams", "V:0", "-show_entries", "packet=flags", "-of", "csv=p=0"]
if limit:
cmd += ["-read_intervals", f"%+#{limit}"]
proc = run(cmd + [path], quiet=True, check=False)
@@ -347,6 +349,8 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
die("--copy joins without a transition: pass --transition none (a transition re-encodes the frames it blends)")
if args.width or args.height or args.fps:
die("--copy keeps every clip's own frame and rate: --width, --height and --fps need a re-encode, so drop --copy")
+ if args.sample_rate or args.channels:
+ die("--copy keeps every clip's own sound: --sample-rate and --channels need a re-encode, so drop --copy")
output = args.output or default_output(args.inputs[0], "joined", os.path.splitext(args.inputs[0])[1].lstrip(".") or "mp4")
guard_sidecar(output, *args.inputs)
ext = os.path.splitext(output)[1].lower()
@@ -377,22 +381,30 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
hint="drop --copy, or re-cut those clips with cut.py --accurate", clips=[{"path": p, "differs": {"edit_list": True}} for p in hidden])
gaps = []
for p, m in zip(args.inputs, metas):
- vd, ad = (m.get("video") or {}).get("duration"), (m.get("audio") or {}).get("duration")
- if vd and ad and abs(vd - ad) > COPY_AV_GAP:
- gaps.append({"path": p, "video": round(vd, 3), "audio": round(ad, 3)})
+ # where each stream ends on the clip's timeline: its start plus its length
+ vs, as_ = m.get("video") or {}, m.get("audio") or {}
+ vd, ad = vs.get("duration"), as_.get("duration")
+ if vd and ad:
+ ve = float(vs.get("start_time") or 0.0) + vd
+ ae = float(as_.get("start_time") or 0.0) + ad
+ if abs(ve - ae) > COPY_AV_GAP:
+ gaps.append({"path": p, "video": round(ve, 3), "audio": round(ae, 3)})
if gaps:
- listing = "; ".join(f"{g['path']} (video {g['video']:.3f}s, audio {g['audio']:.3f}s)" for g in gaps)
+ listing = "; ".join(f"{g['path']} (video ends {g['video']:.3f}s, audio {g['audio']:.3f}s)" for g in gaps)
refuse("input", "join_copy_mismatch",
f"--copy: picture and sound end more than {COPY_AV_GAP * 1000:.0f} ms apart in {listing}; a copy join "
"would leave that gap at the next clip", hint="drop --copy, which trims both streams to one length",
- clips=[{"path": g["path"], "differs": {"video_duration": g["video"], "audio_duration": g["audio"]}} for g in gaps])
+ clips=[{"path": g["path"], "differs": {"video_end": g["video"], "audio_end": g["audio"]}} for g in gaps])
+ fps = (metas[0].get("video") or {}).get("fps") or 30.0
+ note_short_segments(args.inputs, metas, MIN_SEGMENT_FRAMES / fps, f"{MIN_SEGMENT_FRAMES} frames at {fps:g} fps")
+ place_silent([m.get("duration") or 0.0 for m in metas], 0.0)
with tempfile.TemporaryDirectory(prefix="ffskill_join_") as tmp:
listfile = os.path.join(tmp, "list.txt")
with open(listfile, "w", encoding="utf-8") as fh:
for p in args.inputs:
fh.write(concat_list_line(os.path.abspath(p)) + "\n")
joined = sibling_temp(output, "join") if not STATE.dry_run else output
- cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-map", "0:v:0", "-map", "0:a:0?", "-c", "copy"]
+ cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-map", "0:V:0", "-map", "0:a:0?", "-c", "copy"]
if ext in (".mp4", ".mov", ".m4v"):
cmd += ["-movflags", "+faststart"]
try:
@@ -401,12 +413,12 @@ def join_copy(args, metas: List[Dict[str, Any]]) -> int:
if not STATE.dry_run:
want = sum(len(_video_packets(p)) for p in args.inputs)
got = len(_video_packets(joined))
- verification.append({"step": "join", "property": "video_packets", "ok": got == want,
+ # nothing counted on either side is a failed read, not a match
+ verification.append({"step": "join", "property": "video_packets", "ok": got == want and want > 0,
"measured": got, "expected": want, "provenance": "input_premeasurement"})
r = probe(joined, role="output")
measured = (r.get("video") or {}).get("duration") or r.get("duration")
expected = round(sum(m.get("duration") or 0.0 for m in metas), 3)
- fps = (metas[0].get("video") or {}).get("fps") or 30.0
tol = round(n * COPY_AV_GAP + 1.0 / fps, 3)
verification.append({"step": "join", "property": "video_duration",
"ok": measured is not None and abs(measured - expected) <= tol,
diff --git a/tests/test_analysis.py b/tests/test_analysis.py
index a898d62c..abbbc87b 100644
--- a/tests/test_analysis.py
+++ b/tests/test_analysis.py
@@ -443,6 +443,22 @@ def test_check_compare_measures_against_a_reference(self):
self.assertIn("worst", low["value"])
self.assertIn("not compared", self._rows(small, "--no-loudness", "--compare", ref)["similarity"]["value"])
+ def test_check_compare_times_the_worst_frame_and_counts_frames(self):
+ """2.24: --compare names the worst frame by its presentation time (one damaged frame at
+ 2.5 s), counts frames from the packets when the container states no nb_frames (an .mkv two
+ frames short of the reference), prints that note in plain text too, and keeps the row under
+ --dry-run when both files exist."""
+ ref = self._content_clip("compare_ref.mp4", "testsrc2=size=160x90:rate=10", "sine=frequency=440:sample_rate=48000", 3)
+ hit = OUT / "compare_one_bad_frame.mkv"
+ sh("ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", ref, "-t", "2.8",
+ "-vf", "drawbox=t=fill:color=white:enable='eq(n,25)'", "-c:v", "libx264", "-crf", "12", "-an", hit)
+ row = self._rows(hit, "--no-loudness", "--compare", ref)["similarity"]
+ self.assertIn("at 2.50s", row["value"], row)
+ proc = subprocess.run([sys.executable, str(SCRIPTS / "check.py"), str(hit), "--platform", "custom", "--no-loudness",
+ "--compare", str(ref)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ self.assertIn("note: --compare: 28 frames against the reference's 30", proc.stdout + proc.stderr)
+ self.assertIn("similarity", self._rows(hit, "--no-loudness", "--compare", ref, "--dry-run"))
+
def test_check_content_dropouts_row(self):
"""2.17: a short run of digital silence with sound on both sides (lost audio at a join) is a
dropouts WARN; the same gap holding -60 dB room tone is a recording's pause and PASSes, and
diff --git a/tests/test_delivery.py b/tests/test_delivery.py
index 48e9fe75..dae20e74 100644
--- a/tests/test_delivery.py
+++ b/tests/test_delivery.py
@@ -56,7 +56,8 @@ def test_export_max_mb_fits_the_size(self):
size = next(v for v in doc["verification"] if v.get("step") == "file_size")
self.assertTrue(size["ok"] and size["measured"] == out.stat().st_size, size)
self.assertEqual(sum("-pass" in c for c in doc["commands"]), 2)
- for extra, said in ((["--max-mb", "0.05"], "not watchable"), (["--max-mb", "1", "--crf", "20"], "give one of them")):
+ for extra, said in ((["--max-mb", "0.05"], "not watchable"), (["--max-mb", "1", "--crf", "20"], "give one of them"),
+ (["--max-mb", "nan"], "positive number"), (["--max-mb", "inf"], "positive number")):
proc = script("export.py", src, "--preset", "youtube", *extra, "-o", OUT / "max_mb_bad.mp4", "--json", expect_fail=True)
self.assertIn(said, json.loads(proc.stdout)["error"]["message"])
proc = script("export.py", src, "--preset", "h265", "--max-mb", "1", "-o", OUT / "max_mb_bad.mp4", "--json", expect_fail=True)
diff --git a/tests/test_editing.py b/tests/test_editing.py
index bcb37be0..684bf469 100644
--- a/tests/test_editing.py
+++ b/tests/test_editing.py
@@ -1165,6 +1165,23 @@ def test_join_copy_joins_matching_clips_and_refuses_a_mismatch(self):
self.assertFalse(bad.exists())
proc = script("join.py", *clips[:2], "--copy", "-o", OUT / "copy_fade.mp4", "--json", expect_fail=True)
self.assertIn("--transition none", json.loads(proc.stdout)["error"]["message"])
+ proc = script("join.py", *clips[:2], "--copy", "--transition", "none", "--channels", "1", "-o", OUT / "copy_mono.mp4",
+ "--json", expect_fail=True)
+ self.assertIn("--channels", json.loads(proc.stdout)["error"]["message"])
+ # equal lengths, but the sound starts 0.3 s late: it ends 0.3 s after the picture
+ late = OUT / "copy_late_sound.mp4"
+ sh("ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=gray:s=64x48:r=30:d=2",
+ "-f", "lavfi", "-i", "sine=f=700:d=2", "-filter_complex", "[1]adelay=300:all=1,atrim=0:2.3,asetpts=PTS-STARTPTS[a]",
+ "-map", "0:v", "-map", "[a]", "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-output_ts_offset", "0", late)
+ proc = script("join.py", clips[0], late, "--copy", "--transition", "none", "-o", OUT / "copy_late.mp4", "--json", expect_fail=True)
+ self.assertEqual(json.loads(proc.stdout)["error"]["reason"], "join_copy_mismatch")
+ # a silent clip is placed in the copy join's output, as in a re-encoded one
+ quiet = OUT / "copy_quiet.mp4"
+ sh("ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=gray:s=64x48:r=30:d=2",
+ "-f", "lavfi", "-i", "sine=f=500:d=2", "-af", "volume=0", "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", quiet)
+ doc = json.loads(script("join.py", clips[0], quiet, clips[1], "--copy", "--transition", "none", "-o",
+ OUT / "copy_with_quiet.mp4", "--overwrite", "--json").stdout)
+ self.assertEqual([(s["at"], s["end"]) for s in doc["silent"]], [(2.0, 4.0)], doc.get("silent"))
def test_cut_remove_keeps_the_rest(self):
"""2.24: --remove is --segments written the other way: dropping 1-2 and 3.5-4 (overlapping
@@ -1176,8 +1193,16 @@ def test_cut_remove_keeps_the_rest(self):
self.assertClose(probe(str(out))["video"]["duration"], 4.5, 0.1)
proc = script("cut.py", clip, "--remove", "0-10", "-o", OUT / "removed_all.mp4", "--json", expect_fail=True)
self.assertIn("nothing would be left", json.loads(proc.stdout)["error"]["message"])
- proc = script("cut.py", clip, "--remove", "1-2", "--segments", "0-1", "-o", OUT / "removed_mix.mp4", "--json", expect_fail=True)
- self.assertIn("does not combine", json.loads(proc.stdout)["error"]["message"])
+ for extra in (["--segments", "0-1"], ["--start", "0"]):
+ proc = script("cut.py", clip, "--remove", "1-2", *extra, "-o", OUT / "removed_mix.mp4", "--json", expect_fail=True)
+ self.assertIn("does not combine", json.loads(proc.stdout)["error"]["message"])
+ proc = script("cut.py", clip, "--remove", "1-2", "--snap", "beats", "-o", OUT / "removed_snap.mp4", "--json", expect_fail=True)
+ self.assertIn("--snap beats", json.loads(proc.stdout)["error"]["message"])
+ # a range starting before 0 is clamped to the file, not refused: dropping -1..2 keeps 2-6
+ lead = OUT / "removed_lead.mp4"
+ doc = json.loads(script("cut.py", clip, "--remove=-1-2", "--accurate", "-o", lead, "--overwrite", "--json").stdout)
+ self.assertEqual(doc["status"], "completed", doc)
+ self.assertClose(probe(str(lead))["video"]["duration"], 4.0, 0.1)
def _av_clip(self, out, picture, tone, freq, fps=30):
"""A tiny clip: `picture` s of colour at `fps`, `tone` s of a sine at `freq` Hz, AAC audio."""
From 921799e7623a64767826f7d2f3e27c4e661cd89f Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 31 Oct 2026 06:37:15 +0000
Subject: [PATCH 009/191] Add a cut.py --remove demo; damage only video payload
in the repair test
The repair test overwrote bytes anywhere in the middle of the TS, which on
ffmpeg 5.1.1 hit data its ffprobe could not open the file past; it now
flips one byte in 40 video payload packets, never a TS header, the
PAT/PMT or audio. The demo shows --remove 2-4 on the gallery's clip.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_013tPrsGXLuaGU7t643QNUZs
---
demos/build.py | 14 ++++++++++++++
docs/demos.md | 10 ++++++++++
docs/demos/cut_remove.gif | Bin 0 -> 165570 bytes
docs/design-decisions.md | 5 +++--
tests/test_delivery.py | 10 ++++++++--
5 files changed, 35 insertions(+), 4 deletions(-)
create mode 100644 docs/demos/cut_remove.gif
diff --git a/demos/build.py b/demos/build.py
index e6cf5faf..7b1db79f 100644
--- a/demos/build.py
+++ b/demos/build.py
@@ -831,6 +831,17 @@ def demo_cut_accurate(ctx):
return before, after
+def demo_cut_remove(ctx):
+ """--remove names what to drop and keeps the rest: 2-4 s of the clip are gone."""
+ src = FIX / "mandel.mp4"
+ after = ctx.path("after.mp4")
+ out = ctx.script("cut.py", src, "--remove", "2-4", "--accurate", "--preset", "veryfast", "--json", "-o", after,
+ capture=True)
+ if out:
+ ctx.note("kept %.2f s; 2-4 s of the source removed" % (json.loads(out).get("output_duration") or 0.0))
+ return src, after
+
+
def demo_cut_edit_list(ctx):
"""Two lossless copies of the same request: the default one shifts its timestamps to zero and
shows the keyframe's pre-roll, the --edit-list one keeps the MP4 edit list that hides it, so
@@ -1564,6 +1575,9 @@ def demo_switch_energy(ctx):
("cut_accurate", PICTURE, "Lossless cut vs. accurate cut",
"Both sides asked for the same 2.05 s start. The stream copy could only snap to the nearest keyframe, so its first frame is from earlier in the clip; --accurate re-encodes and starts on the frame that was asked for.",
demo_cut_accurate, "video", None, ("LOSSLESS", "--ACCURATE")),
+ ("cut_remove", PICTURE, "Cut out a range, keep the rest",
+ "cut.py --remove 2-4 drops two seconds from the middle and joins what is left: the 'after' side jumps from 2 s to 4 s of the source and finishes two seconds early.",
+ demo_cut_remove, "video", None, ("SOURCE", "--REMOVE 2-4")),
("cut_edit_list", PICTURE, "Lossless cut that starts where asked",
"Both sides are stream copies of the same 2.05 s request. The default shifts the copy's timestamps to zero, so the keyframe's pre-roll plays first (start_snapped: true); --edit-list keeps the MP4 edit list that hides it, so the picture starts at 2.05 s (start_snapped: false, stored_preroll_seconds says how much is hidden). A player that ignores edit lists shows the pre-roll.",
demo_cut_edit_list, "video", None, ("DEFAULT COPY", "--EDIT-LIST")),
diff --git a/docs/demos.md b/docs/demos.md
index 26afa66a..9112ed04 100644
--- a/docs/demos.md
+++ b/docs/demos.md
@@ -261,6 +261,16 @@ python3 scripts/cut.py demos/out/fixtures/mandel.mp4 --start 2.05 --duration 3 -
**Look for:** Both sides asked for the same 2.05 s start. The stream copy could only snap to the nearest keyframe, so its first frame is from earlier in the clip; --accurate re-encodes and starts on the frame that was asked for.
+### Cut out a range, keep the rest
+
+
+
+```bash
+python3 scripts/cut.py demos/out/fixtures/mandel.mp4 --remove 2-4 --accurate --preset veryfast --json -o demos/out/cut_remove_after.mp4
+```
+
+**Look for:** cut.py --remove 2-4 drops two seconds from the middle and joins what is left: the 'after' side jumps from 2 s to 4 s of the source and finishes two seconds early.
+
### Lossless cut that starts where asked

diff --git a/docs/demos/cut_remove.gif b/docs/demos/cut_remove.gif
new file mode 100644
index 0000000000000000000000000000000000000000..3f84532025fcea31a282310b2f3c5667e480dd70
GIT binary patch
literal 165570
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
z
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