diff --git a/.claude/rules/development.md b/.claude/rules/development.md
index cb36882..905dfb6 100644
--- a/.claude/rules/development.md
+++ b/.claude/rules/development.md
@@ -17,7 +17,7 @@ description: Command contracts, project gate, typing boundaries, model-adjacent
Every single-image command declares `source` with `dir_okay=False`; `batch` declares its directory with `file_okay=False`. Keep `tests/test_cli_robustness.py::TestDirectoryInputIsRejected` as the regression guard.
-Exit-code and no-signal behavior is a public contract. Read the command-line section of [`../../docs/module-internals.md`](../../docs/module-internals.md) before changing it. Keep [`../../skills/remove-ai-watermarks/`](../../skills/remove-ai-watermarks/) in the same change when command routing, extras, exit codes, mark keys, or the intended-use boundary move.
+Exit-code and no-signal behavior is a public contract. Read the command-line section of [`../../docs/module-internals.md`](../../docs/module-internals.md) before changing it. The dev `.venv` always carries the pixel stack, so it cannot show what the default install does. To check a no-signal or fail-closed change there, build a throwaway venv outside the repository (`uv venv /tmp/bare && VIRTUAL_ENV=/tmp/bare uv pip install .`) and run `/tmp/bare/bin/remove-ai-watermarks` against it. Keep [`../../skills/remove-ai-watermarks/`](../../skills/remove-ai-watermarks/) in the same change when command routing, extras, exit codes, mark keys, or the intended-use boundary move.
`tests/test_agent_skill.py` enforces that by comparing the skill's prose against the live Click tree. Guard the SET, not the instances: per-flag comparisons only check what the skill already documents, so `--vendor`, `--pipeline auto` and then the whole `classify` and `verify-openai-synthid` commands each shipped with a green suite. `test_every_cli_command_is_named_somewhere_in_the_skill` closes that; raise `MIN_CLI_VERSION` in the skill's probe whenever a reference names a command, flag or value the previous release lacked, or the probe tells an agent its CLI is current right before it types something that build rejects.
@@ -92,7 +92,7 @@ procedure in `docs/development.md`; do not revive the old text-matching bypass.
Boundary modules for cv2, Torch, and Diffusers may carry narrow per-file relaxations for unknown third-party types. Keep pure-logic files strict, preserve the local piexif stub, and fix real errors before widening a pragma.
-From a worktree, `uv run` imports the package from the MAIN checkout -- that is where the editable install points. A script measuring a worktree's edit must insert that worktree's `src` at `sys.path[0]` and assert `module.__file__` resolves inside it, or it silently compares unmodified code against itself.
+From a worktree without its own `.venv`, `uv run` imports the package from the MAIN checkout, because that is where the editable install points; a worktree with its own `.venv` imports its own `src`. Either way, a script measuring a worktree's edit must assert `module.__file__` resolves inside that worktree (insert its `src` at `sys.path[0]` when it does not), or it silently compares unmodified code against itself.
## Research harnesses stay out of this repository
diff --git a/docs/cli.md b/docs/cli.md
index ff9ee7a..a414493 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -705,8 +705,9 @@ the tool contract are in the [MCP server guide](mcp-server.md).
The CLI uses nonzero exit codes for meaningful incomplete outcomes, including
no detected target on commands that would otherwise regenerate or create a
-misleading unchanged result, processing errors, and a required invisible step
-that could not run.
+misleading unchanged result, a batch directory with nothing to process,
+processing errors, an input that is not a readable image or video, and a
+required invisible step that could not run.
Scripts should check the process exit code and the output path. The detailed
per-command contract is maintained in
diff --git a/docs/known-limitations.md b/docs/known-limitations.md
index dd08ab3..3a59eac 100644
--- a/docs/known-limitations.md
+++ b/docs/known-limitations.md
@@ -621,8 +621,9 @@ stream safely.
raising. User facing callers must use `strip_and_verify` and inspect its
surviving marker mapping before reporting success. `strip_and_verify` recovers
when `image_io` can still decode the raster by normalizing the container and
-checking again. A truly undecodable file still reports the surviving markers.
-The CLI uses this verified path.
+checking again. A truly undecodable file raises `ValueError` before anything is
+written (since 2026-10-18; it used to be copied through with its surviving
+markers). The CLI uses this verified path.
### Sixteen bit PNG metadata stripping
diff --git a/docs/mcp-server.md b/docs/mcp-server.md
index 9812432..d28298b 100644
--- a/docs/mcp-server.md
+++ b/docs/mcp-server.md
@@ -58,7 +58,10 @@ writes over an input: an `output` equal to the source, or an `output_dir` equal
to the input directory, is refused before anything runs.
Exit codes keep their CLI meaning. `0` and `2` are results, not tool errors, and
-`2` means no supported signal was found, which is unknown rather than clean.
+`2` means no supported signal was found, or a `batch` directory held nothing to
+process, which is unknown rather than clean. An
+input that is not a readable image or video exits `1`, so `identify` on it is a
+tool error even though the command still printed a report.
Click also exits `2` on a usage error such as a missing file; its `Usage:` line on
stderr marks that case as a tool error. Every other nonzero exit code is returned
with `isError: true` and the command's stderr.
diff --git a/docs/module-internals.md b/docs/module-internals.md
index c3bdc91..6298238 100644
--- a/docs/module-internals.md
+++ b/docs/module-internals.md
@@ -50,7 +50,28 @@ Important contracts:
It decodes the source before the scrub gate and exits `1` when it cannot,
because the gate reads undecodable bytes as "no target" and would otherwise
report the no-signal code for a file it never read (fixed 2026-10-17).
-- The two no-signal conditions currently share exit code `2`.
+- The two no-signal conditions currently share exit code `2`, and so does a
+ `batch` or `video batch` directory with no supported file, which writes
+ nothing (decided 2026-10-18).
+- An input that is not a readable container exits `1` from `identify`,
+ `metadata` and every `video` command, and fails its row in `batch` and `video
+ batch` (fixed 2026-10-18). The metadata scanners read raw bytes, so random
+ bytes, an empty file or a truncated PNG or MP4 used to print the same "No AI
+ metadata found" or "AI metadata stripped" a real file gets, with exit `0`.
+ `metadata.unreadable_reason` is the one probe: rasters must decode in Pillow or
+ OpenCV, ISOBMFF boxes and EBML elements must tile the file, and a RIFF file must
+ hold its declared size. `strip_and_verify` and `video._video_source` ask it, and
+ `identify` prints its report first, so an AI signal read from a truncated file
+ is still shown. Absent optional detectors are not unreadable input: without the
+ pixel stack `identify` keeps exit `0` and its caveat (decided 2026-10-18).
+ Regression coverage: `tests/test_fail_closed.py`.
+- `visible` and `erase` strip and verify AI metadata on a staged sibling and exit
+ `1` with nothing published when a marker survives; they used to swallow a failed
+ strip unless `--verbose` was set. The library `remove_visible` does the same and
+ raises `MetadataStripIncomplete`, as `remove_all` already did, and the diffusion
+ writer behind `invisible` raises when its own strip leaves a marker.
+ `_internal.utils.staged_sibling` is the shared stage: it never pre-creates the
+ name, so the published file keeps the default permissions.
- A quiet `metadata --check` and a successful `metadata --remove` (same for
`video metadata`) end by repeating the `identify` limit: the pixel channel is
untouched and a watermark such as SynthID has no local decoder once its
@@ -241,7 +262,8 @@ as `workflow` and `prompt` uses the same structural walk; removal blanks the
complete key and value in place. This preserves every box size,
`stco`/`co64` offset, encoded stream byte apart from the Kling SEI tag below, and
source-sized memory bound.
-Publication is atomic, and a malformed top-level walk is copied unchanged. A
+Publication is atomic, and a malformed top-level walk is copied unchanged,
+although `strip_and_verify` refuses such a source before the stripper runs. A
generic `AIGC` key whose value has no TC260 field is ignored.
App-export JSON in a keyed or keyless metadata item is blanked the same way.
@@ -822,7 +844,7 @@ Key contracts:
written output for surviving markers. If the metadata-preserving decoder
rejected the container but `image_io` can still decode its raster,
`strip_and_verify` normalizes that raster and scans again. A truly undecodable
- file keeps the surviving-marker result.
+ file raises `ValueError` before anything is written.
Detection and removal must stay in parity. A new marker is incomplete until the
scanner can find it, the remover can reach every supported placement, and a
diff --git a/docs/python-api.md b/docs/python-api.md
index 69cbc14..ff6751c 100644
--- a/docs/python-api.md
+++ b/docs/python-api.md
@@ -79,7 +79,8 @@ For a path input, `remove_visible` and `remove_visible_detailed`:
- reads metadata provenance for the default `auto` sensitivity;
- preserves a separate alpha channel;
- writes the output when an output path is supplied;
-- strips AI metadata from the written output by default;
+- strips AI metadata from the written output by default, and raises
+ `MetadataStripIncomplete` without publishing it when a marker survives;
- preserves the original bytes for a same-format no-op copy.
```python
@@ -226,8 +227,9 @@ The complete field set, with the shipped defaults:
and not to this table, because this seam had already drifted by eight fields
before anything checked it.
-If AI metadata survives the strip, `remove_all` raises `MetadataStripIncomplete`
-**before** writing anything: an AI-readable output is worse than no output.
+If AI metadata survives the strip, `remove_all` and a path-input `remove_visible`
+raise `MetadataStripIncomplete` **before** publishing anything: an AI-readable
+output is worse than no output.
`remove_batch` runs one mode over a directory and never lets a single bad file
end the run:
@@ -525,7 +527,9 @@ It checks the written output and returns `(output_path, surviving_markers)`.
When the first strip leaves markers in a malformed but raster-decodable image,
it normalizes the container through `image_io` and checks again. That recovery
path preserves the pixels but drops standard metadata. Treat a nonempty
-`surviving_markers` mapping as a failure.
+`surviving_markers` mapping as a failure. A source that is not a readable image,
+video or audio container raises `ValueError` before anything is written;
+`metadata.unreadable_reason(path)` returns the same reason without stripping.
`remove_ai_metadata` is the lower level fail-safe transformer. It may copy an
undecodable input through unchanged, so its return alone must not be presented
diff --git a/docs/verification-plan.md b/docs/verification-plan.md
index 6fd704f..87ca6fe 100644
--- a/docs/verification-plan.md
+++ b/docs/verification-plan.md
@@ -92,8 +92,8 @@ the row count is host-dependent rather than the fixed 68 recorded here before. E
(~500 images spanning format x provenance x aspect ratio), asserting exit-code semantics
rather than just absence of crash.
-Known trap to encode: **exit 2 is triply overloaded** (no-visible-mark, no-invisible-signal,
-Click usage error). A wrapper cannot distinguish them without parsing stderr. Either the
+Known trap to encode: **exit 2 is overloaded four ways** (no-visible-mark, no-invisible-signal,
+empty batch directory, Click usage error). A wrapper cannot distinguish them without parsing stderr. Either the
sweep asserts on stderr, or the codes get split -- the latter is the better fix.
#### Coverage method
diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md
index 0d761d7..5c3cbc0 100644
--- a/docs/watermarking-landscape.md
+++ b/docs/watermarking-landscape.md
@@ -139,8 +139,8 @@ against primary sources before adding jurisdiction-specific claims.
is added to generated images and videos "to further help you identify AI-generated
content", and AI Pro and Ultra subscribers can turn it off. That makes it an
AI-generation label under the [project scope](legal-and-safety.md#out-of-scope). No
-template is registered, because no publication-cleared export has been captured;
-slide decks export as PDF or PPTX, which the project does not process.
+template is planned (decided 2026-10-18), so the mark stays undetected; slide
+decks export as PDF or PPTX, which the project does not process.
**Google Veo video marks use two incompatible visible designs.** Public raw
clips verify the current four-point diamond and the legacy bottom-right `Veo`
diff --git a/scripts/assets/visible-mark-candidates/README.md b/scripts/assets/visible-mark-candidates/README.md
index dcc5830..cb40958 100644
--- a/scripts/assets/visible-mark-candidates/README.md
+++ b/scripts/assets/visible-mark-candidates/README.md
@@ -22,7 +22,7 @@ from the provider's canonical name, the provider is named separately.
| --- | --- | --- | --- |
| `samsung_en_alpha.png` | Samsung Galaxy AI label, English locale ("AI-generated content", bottom-left) | 5 corpus files | POS 0.11-0.30 vs NEG max 0.40 (binary); tophat/gray tried, no separation. The registered Italian engine scores 0.18-0.31 on the same files -- same layout class, wrong glyph template. |
| `gemini_text_alpha.png` | "Generated with Gemini" text label (bottom-right; the registered gemini mark is the sparkle icon) | 3 corpus files | POS 0.07-0.22 vs NEG max 0.32; coverage gate finds the blob (0.29-0.35) but the Arial silhouette misses Google's letterforms. |
-| `notebooklm_alpha.png` | NotebookLM wordmark (bottom-right) | 12 corpus files | locate geometry not yet fitted; POS max 0.12. |
+| `notebooklm_alpha.png` | NotebookLM wordmark (bottom-right; the product is now Gemini Notebook) | 12 corpus files | parked: a template is not planned (decided 2026-10-18). Last measured POS max 0.12, locate geometry never fitted. |
| `mindvideo_alpha.png` | MindVideo.AI (top-right) | 11 corpus files | POS 0.29-0.32 vs NEG max 0.30 -- borderline overlap, not shippable. |
| `higgsfield_alpha.png` | `HIGGSFIELD AI` wordmark (Higgsfield AI, bottom-right; the boxed `AI` variant shares the cohort) | 5 wordmark files (16 in the boxed-AI OCR cluster) | POS max 0.26 vs NEG max 0.22 -- no separation; the mark may be two-part (wordmark + boxed AI) and needs a composed template. |
| `jianying_alpha.png` | `剪映AI` wordmark (Jianying / 剪映, the China product in the CapCut family; bottom-right) | 2 corpus files | POS 0.29 vs NEG max 0.35. |
diff --git a/skills/remove-ai-watermarks/SKILL.md b/skills/remove-ai-watermarks/SKILL.md
index 080628a..de29ceb 100644
--- a/skills/remove-ai-watermarks/SKILL.md
+++ b/skills/remove-ai-watermarks/SKILL.md
@@ -157,8 +157,8 @@ Read the process exit code and whether `-o` exists.
| Exit | Meaning | What to tell the user |
| --- | --- | --- |
| 0 | Requested work completed | The output path. Do not add "this is clean". |
-| 2 | No targeted signal | Unknown, not clean. No new file from `visible`. |
-| 1 | Error or partial `all` | Quote the error. Say which stage ran, if any. |
+| 2 | No targeted signal, or a `batch` directory with no supported file | Unknown, not clean. No new file from `visible`. |
+| 1 | Error, unreadable input, or partial `all` | Quote the error. Say which stage ran, if any. |
Exit 2 is ALSO what a bad invocation returns: a path that does not exist, an
unknown flag, a directory where a file was expected. Read the output before
@@ -167,7 +167,9 @@ means the command never ran, so fix it and rerun; only a command that reached
the image can say "no targeted signal".
`identify` with no signal reports origin unknown. Never rephrase that as "no
-watermark" or "this is clean".
+watermark" or "this is clean". When `identify` or `metadata` exits 1 with
+`cannot read`, the file is damaged or not an image or video: any report above
+that line is incomplete, so say the file could not be read.
`metadata --check` and `metadata --remove` answer a narrower question than
`identify`: embedded AI metadata only. Both print that this is not a clean
diff --git a/skills/remove-ai-watermarks/references/commands.md b/skills/remove-ai-watermarks/references/commands.md
index f4e1938..6e10f6f 100644
--- a/skills/remove-ai-watermarks/references/commands.md
+++ b/skills/remove-ai-watermarks/references/commands.md
@@ -150,8 +150,8 @@ commands above directly.
| Code | Meaning |
| --- | --- |
| 0 | Requested work completed |
-| 2 | No targeted signal (`visible` / no-signal `invisible`) |
-| 1 | Hard failure, or `all` skipped a required invisible stage |
+| 2 | No targeted signal (`visible` / no-signal `invisible`), or a `batch` directory with no supported file |
+| 1 | Hard failure, an input that is not a readable image or video (`identify` and `metadata` included), or `all` skipped a required invisible stage |
A written file plus exit `1` from `all` is a partial result.
diff --git a/src/remove_ai_watermarks/_internal/ebml.py b/src/remove_ai_watermarks/_internal/ebml.py
index d5a2c21..fd303dd 100644
--- a/src/remove_ai_watermarks/_internal/ebml.py
+++ b/src/remove_ai_watermarks/_internal/ebml.py
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
from remove_ai_watermarks.metadata import MAX_TC260_VALUE_BYTES, parse_tc260_aigc_json
-_EBML_MAGIC = b"\x1aE\xdf\xa3"
+EBML_MAGIC = b"\x1aE\xdf\xa3"
_SEGMENT_ID = 0x18538067
_TAGS_ID = 0x1254C367
_TAG_ID = 0x7373
@@ -95,6 +95,16 @@ def _iter_elements(
pos = element_end
+def iter_top_level_ends(stream: BinaryIO, size: int) -> Iterator[int]:
+ """End offset of each valid top-level element; stops early where a header fails.
+
+ An element whose declared size runs past ``size`` fails its header, so a truncated
+ file yields a last end short of ``size``. An unknown-size Segment runs to ``size``.
+ """
+ for _element_id, _payload_start, element_end in _iter_elements(stream, 0, size):
+ yield element_end
+
+
def _read_bounded(
stream: BinaryIO,
start: int,
@@ -133,7 +143,7 @@ def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]:
found: list[bytes] = []
try:
with open(path, "rb") as stream:
- if stream.read(4) != _EBML_MAGIC:
+ if stream.read(4) != EBML_MAGIC:
return ()
stream.seek(0, 2)
file_size = stream.tell()
diff --git a/src/remove_ai_watermarks/_internal/utils.py b/src/remove_ai_watermarks/_internal/utils.py
index 63164dd..04ef256 100644
--- a/src/remove_ai_watermarks/_internal/utils.py
+++ b/src/remove_ai_watermarks/_internal/utils.py
@@ -2,11 +2,15 @@
from __future__ import annotations
+import contextlib
+import os
+import secrets
from typing import TYPE_CHECKING
from remove_ai_watermarks._internal.constants import JPEG_SUFFIXES, SUPPORTED_FORMATS
if TYPE_CHECKING:
+ from collections.abc import Generator
from pathlib import Path
@@ -22,3 +26,19 @@ def get_image_format(file_path: Path) -> str:
inputs therefore use its PNG fallback, matching the established API contract.
"""
return "JPEG" if file_path.suffix.casefold() in JPEG_SUFFIXES else "PNG"
+
+
+@contextlib.contextmanager
+def staged_sibling(output: Path) -> Generator[Path]:
+ """Yield an unused sibling of ``output``, published over it only if the block succeeds.
+
+ A strip that leaves AI metadata behind raises inside the block, so nothing reaches
+ ``output``. The name is not pre-created (``mkstemp`` would), so the writer gives the
+ published file the default permissions rather than ``mkstemp``'s 0600.
+ """
+ staged = output.with_name(f".{output.stem}-{secrets.token_hex(8)}{output.suffix}")
+ try:
+ yield staged
+ os.replace(staged, output)
+ finally:
+ staged.unlink(missing_ok=True)
diff --git a/src/remove_ai_watermarks/_internal/watermark_remover.py b/src/remove_ai_watermarks/_internal/watermark_remover.py
index 7c949a9..06a5ef2 100644
--- a/src/remove_ai_watermarks/_internal/watermark_remover.py
+++ b/src/remove_ai_watermarks/_internal/watermark_remover.py
@@ -217,9 +217,12 @@ class WatermarkRemover:
bgr = np.ascontiguousarray(np.asarray(image.convert("RGB"))[:, :, ::-1])
if not image_io.imwrite(str(output_path), bgr, display_tags_from=display_tags_from):
image.save(output_path)
- from remove_ai_watermarks.metadata import remove_ai_metadata
+ from remove_ai_watermarks.metadata import strip_and_verify
- remove_ai_metadata(output_path, output_path, keep_standard=True)
+ # Verified, so a marker that survives fails the run instead of shipping with it.
+ _, leftover = strip_and_verify(output_path, output_path, keep_standard=True)
+ if leftover:
+ raise RuntimeError(f"AI metadata survived the strip: {', '.join(sorted(leftover))}")
def remove_watermark(
self,
diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py
index 31fbda5..39f7ec4 100644
--- a/src/remove_ai_watermarks/api.py
+++ b/src/remove_ai_watermarks/api.py
@@ -132,33 +132,36 @@ def _write_visible_result(
if not removed and not write_noop:
return
+ import shutil
+
from remove_ai_watermarks import image_io
+ from remove_ai_watermarks._internal.utils import staged_sibling
out_path = Path(output)
out_path.parent.mkdir(parents=True, exist_ok=True)
source_path = loaded.path
- if not removed and source_path is not None and source_path.suffix.lower() == out_path.suffix.lower():
- # Copy the ORIGINAL bytes instead of lossily re-encoding a no-op. An in-place
- # call needs no copy and would otherwise raise shutil.SameFileError.
- if source_path.resolve() != out_path.resolve():
- import shutil
-
- shutil.copyfile(source_path, out_path)
- else:
+ # Staged, so a strip that leaves AI metadata behind publishes nothing -- the
+ # MetadataStripIncomplete contract.
+ with staged_sibling(out_path) as staged:
+ if not removed and source_path is not None and source_path.suffix.lower() == out_path.suffix.lower():
+ # Copy the ORIGINAL bytes instead of lossily re-encoding a no-op.
+ shutil.copyfile(source_path, staged)
# imwrite is contractually NON-RAISING, so this bool is the only signal the file
# was not created. Unchecked, the metadata strip below ran on a nonexistent path
# and surfaced as a confusing "cannot read image " naming the OUTPUT path
# (Tier E, 2026-07-20). Raise here so a library caller and the CLI both get an
# accurate message about the write.
- if not image_io.write_bgr_with_alpha(
- out_path, result, loaded.alpha, display_tags_from=source_path, orientation_applied=False
+ elif not image_io.write_bgr_with_alpha(
+ staged, result, loaded.alpha, display_tags_from=source_path, orientation_applied=False
):
raise OSError(f"failed to write output (is the destination writable?): {out_path}")
- if strip_metadata:
- from remove_ai_watermarks import metadata
+ if strip_metadata:
+ from remove_ai_watermarks.metadata import strip_and_verify
- metadata.remove_ai_metadata(out_path, out_path)
+ _, leftover = strip_and_verify(staged, staged)
+ if leftover:
+ raise MetadataStripIncomplete(set(leftover))
def remove_visible(
@@ -676,7 +679,9 @@ def remove_batch(
from remove_ai_watermarks.optional_deps import pixels_available
src_dir, out_dir = Path(directory), Path(output_dir)
- out_dir.mkdir(parents=True, exist_ok=True)
+ sources = sorted(p for p in src_dir.iterdir() if is_supported_format(p))
+ if sources: # an empty run leaves no empty output directory behind
+ out_dir.mkdir(parents=True, exist_ok=True)
def say(path: Path, stage: str, detail: str) -> None:
if progress is not None:
@@ -685,7 +690,7 @@ def remove_batch(
unavailable: list[Path] = []
errors: list[tuple[Path, str]] = []
items: list[BatchItemResult] = []
- for img_path in sorted(p for p in src_dir.iterdir() if is_supported_format(p)):
+ for img_path in sources:
out_path = out_dir / img_path.name
try:
result = _run_batch_one(img_path, out_path, mode, backend, sensitivity, invisible, force, engine, say)
diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py
index c8a93f3..b1af397 100644
--- a/src/remove_ai_watermarks/cli.py
+++ b/src/remove_ai_watermarks/cli.py
@@ -125,6 +125,27 @@ def _validate_image(path: Path) -> Path:
return path
+def _unreadable_input_exit(source: Path, *, after_report: bool, to_stderr: bool = False) -> None:
+ """Exit 1 when ``source`` is not a readable container; return otherwise.
+
+ The metadata scanners read raw bytes, so on bytes that are not an image or video
+ they find nothing, and "nothing" used to print as the same no-signal answer a
+ real file gets, with exit 0. ``after_report`` says whether a partial report was
+ already printed above. ``to_stderr`` keeps a ``--json`` stdout parseable.
+ """
+ from remove_ai_watermarks.metadata import unreadable_reason
+
+ if (reason := unreadable_reason(source)) is None:
+ return
+ scanned = "The scan above is incomplete" if after_report else "Nothing was scanned"
+ message = f"Error: cannot read {source.name}: {reason}. {scanned}, not a no-signal result."
+ if to_stderr:
+ click.echo(message, err=True)
+ else:
+ console.print(f" {message}")
+ raise SystemExit(1)
+
+
def _resolved_strength_for_display(
source: Path,
strength: float | None,
@@ -427,6 +448,8 @@ def _write_output_or_exit(
bgr: NDArray[Any],
alpha: NDArray[Any] | None,
display_source: Path | None = None,
+ *,
+ strip_metadata: bool = False,
) -> None:
"""Write the final image, or fail with a readable error instead of a traceback.
@@ -438,11 +461,29 @@ def _write_output_or_exit(
Regression: `tests/test_cli_robustness.py::TestFailedWriteIsReported`.
`display_source` is the file whose decode produced `bgr`; its ICC profile and EXIF
- orientation ride along into the re-encoded output (issue #98)."""
+ orientation ride along into the re-encoded output (issue #98).
+
+ ``strip_metadata`` strips and verifies AI metadata on a staged copy before it is
+ published, so a marker that survives exits 1 with nothing written. Both commands
+ using this used to swallow a failed strip unless ``--verbose`` was set."""
+ from remove_ai_watermarks._internal.utils import staged_sibling
+ from remove_ai_watermarks.metadata import strip_and_verify
+
output.parent.mkdir(parents=True, exist_ok=True)
- if not image_io.write_bgr_with_alpha(output, bgr, alpha, display_tags_from=display_source):
- console.print(f" Error: failed to write output (is the destination writable?): {output}")
- raise SystemExit(1)
+ with staged_sibling(output) as staged:
+ if not image_io.write_bgr_with_alpha(staged, bgr, alpha, display_tags_from=display_source):
+ console.print(f" Error: failed to write output (is the destination writable?): {output}")
+ raise SystemExit(1)
+ if not strip_metadata:
+ return
+ try:
+ _, leftover = strip_and_verify(staged, staged)
+ except (OSError, RuntimeError, ValueError) as e:
+ console.print(f" Error: failed to strip AI metadata from {output}: {e}")
+ raise SystemExit(1) from e
+ if leftover:
+ console.print(f" Error: AI metadata survived in {output}: {', '.join(sorted(leftover))}")
+ raise SystemExit(1)
def _no_visible_mark_exit(source: Path) -> NoReturn:
@@ -511,6 +552,10 @@ def _no_visible_mark_exit(source: Path) -> NoReturn:
# instead of treating an unchanged image as a completed removal.
EXIT_NO_INVISIBLE_SIGNAL = 2
+# Also 2: a batch directory with no supported file processed nothing, which is a
+# no-signal outcome a wrapper must not read as a cleaned directory.
+EXIT_NOTHING_PROCESSED = EXIT_NO_INVISIBLE_SIGNAL
+
def _no_invisible_signal_exit(source: Path) -> NoReturn:
"""Explain why the diffusion scrub was skipped, then exit non-zero.
@@ -636,7 +681,6 @@ def _run_visible_auto(
def _run_visible_explicit(
- ctx: click.Context,
source: Path,
output: Path,
*,
@@ -685,15 +729,7 @@ def _run_visible_explicit(
raise SystemExit(1) from e
elapsed = time.monotonic() - t0
- _write_output_or_exit(output, result, alpha, display_source=source)
- if strip_metadata:
- try:
- from remove_ai_watermarks.metadata import remove_ai_metadata
-
- remove_ai_metadata(output, output)
- except Exception as e:
- if ctx.obj.get("verbose"):
- console.print(f" Warning: Failed to strip metadata: {e}")
+ _write_output_or_exit(output, result, alpha, display_source=source, strip_metadata=strip_metadata)
size_kb = output.stat().st_size / 1024
console.print(f" Saved: {output} ({size_kb:.0f} KB, {elapsed:.2f}s)")
@@ -713,10 +749,8 @@ def _run_visible_explicit(
@_visible_backend_option
@_visible_sensitivity_option
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
-@click.pass_context
@_pixels_required
def cmd_visible(
- ctx: click.Context,
source: Path,
output: Path | None,
detect: bool,
@@ -754,7 +788,6 @@ def cmd_visible(
return
_run_visible_explicit(
- ctx,
source,
output,
detect=detect,
@@ -795,10 +828,8 @@ def _parse_region(spec: str) -> tuple[int, int, int, int]:
@click.option("--inpaint-method", type=click.Choice(["telea", "ns"]), default="telea", help="cv2 inpaint method.")
@click.option("--dilate", type=int, default=3, help="Grow the box by this many px before inpainting.")
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
-@click.pass_context
@_pixels_required
def cmd_erase(
- ctx: click.Context,
source: Path,
regions: tuple[str, ...],
output: Path | None,
@@ -839,16 +870,7 @@ def cmd_erase(
raise SystemExit(1) from e
elapsed = time.monotonic() - t0
- _write_output_or_exit(output, result, alpha, display_source=source)
-
- if strip_metadata:
- try:
- from remove_ai_watermarks.metadata import remove_ai_metadata
-
- remove_ai_metadata(output, output)
- except Exception as e:
- if ctx.obj.get("verbose"):
- console.print(f" Warning: Failed to strip metadata: {e}")
+ _write_output_or_exit(output, result, alpha, display_source=source, strip_metadata=strip_metadata)
size_kb = output.stat().st_size / 1024
console.print(f" Erased {len(boxes)} region(s) -> {output} ({size_kb:.0f} KB, {elapsed:.2f}s)")
@@ -937,13 +959,17 @@ def cmd_invisible(
def progress_cb(msg: str) -> None:
console.print(f" {msg}")
- engine = InvisibleEngine(
- pipeline=pipeline,
- hf_token=hf_token,
- progress_callback=progress_cb,
- controlnet_conditioning_scale=controlnet_scale,
- cpu_offload=cpu_offload,
- )
+ try:
+ engine = InvisibleEngine(
+ pipeline=pipeline,
+ hf_token=hf_token,
+ progress_callback=progress_cb,
+ controlnet_conditioning_scale=controlnet_scale,
+ cpu_offload=cpu_offload,
+ )
+ except (OSError, RuntimeError, ValueError) as exc: # e.g. no CUDA device
+ console.print(f" Error: {exc}")
+ raise SystemExit(1) from exc
# Detect the SynthID vendor from the ORIGINAL (before processing strips C2PA) so the
# displayed and executed strength agree on the vendor-adaptive default. An explicit
@@ -1052,6 +1078,7 @@ def cmd_metadata(
# accepts video/audio containers, so the image-format warning would misfire.
# click's `exists=True` on the argument already enforces the file exists.
_banner()
+ _unreadable_input_exit(source, after_report=False)
if check or (not remove):
has_ai = has_ai_metadata(source)
@@ -1064,14 +1091,13 @@ def cmd_metadata(
# Remove
try:
out, leftover = strip_and_verify(source, output, keep_standard=keep_standard)
- except (OSError, ValueError) as e: # unreadable / truncated / non-image (PIL raises OSError subclasses)
+ except (OSError, RuntimeError, ValueError) as e: # unreadable input, or ffmpeg absent or failing
console.print(f" Error: cannot process {source.name}: {e}")
raise SystemExit(1) from e
if leftover:
console.print(f" FAILED: {len(leftover)} AI metadata marker(s) survived in {out}")
console.print(f" still present: {', '.join(sorted(leftover))}")
- console.print(" the file could not be decoded, so it was copied through unchanged")
raise SystemExit(1)
console.print(f" AI metadata stripped -> {out}")
_print_metadata_not_a_clean_verdict()
@@ -1440,6 +1466,9 @@ def cmd_video_batch(
except (OSError, RuntimeError, ValueError) as e:
raise click.ClickException(str(e)) from e
+ if not result.items:
+ console.print(f" No supported videos found in {directory}; nothing was processed")
+ raise SystemExit(EXIT_NOTHING_PROCESSED)
for item in result.items:
if item.error is not None:
console.print(f" FAILED {item.source.name}: {item.error}")
@@ -1488,6 +1517,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
if as_json:
click.echo(json.dumps(asdict(report), default=str, indent=2))
+ _unreadable_input_exit(source, after_report=True, to_stderr=True)
return
_banner()
@@ -1534,6 +1564,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
console.print("\n Caveats:")
for c in report.caveats:
console.print(f" - {c}")
+ _unreadable_input_exit(source, after_report=True)
# ── Metadata-free photo classification (opt-in, never a tail of identify) ──
@@ -1824,13 +1855,15 @@ def cmd_batch(
if output_dir is None:
output_dir = directory.parent / (directory.name + "_clean")
- output_dir.mkdir(parents=True, exist_ok=True)
images = sorted(p for p in directory.iterdir() if is_supported_format(p))
if not images:
- console.print(f"No supported images found in {directory}")
- return
+ # Nothing processed is the no-signal code, not success: a wrapper must not read
+ # an empty run as a cleaned directory.
+ console.print(f"No supported images found in {directory}; nothing was processed")
+ raise SystemExit(EXIT_NOTHING_PROCESSED)
+ output_dir.mkdir(parents=True, exist_ok=True)
console.print(f" Found {len(images)} images in {directory}")
console.print(f" Output -> {output_dir}")
@@ -1859,6 +1892,11 @@ def cmd_batch(
if ctx.obj.get("verbose"):
console.print(f" {img.name}: {stage}{f' {detail}' if detail else ''}")
+ try:
+ engine = _batch_engine(mode, invisible_options)
+ except (OSError, RuntimeError, ValueError) as e: # e.g. no CUDA device
+ console.print(f" Error: {e}")
+ raise SystemExit(1) from e
summary = api_remove_batch(
directory,
output_dir,
@@ -1867,7 +1905,7 @@ def cmd_batch(
sensitivity=_parse_sensitivity(sensitivity),
invisible=invisible_options,
force=force,
- engine=_batch_engine(mode, invisible_options),
+ engine=engine,
progress=on_progress,
)
diff --git a/src/remove_ai_watermarks/mcp_server.py b/src/remove_ai_watermarks/mcp_server.py
index 2236960..e55834c 100644
--- a/src/remove_ai_watermarks/mcp_server.py
+++ b/src/remove_ai_watermarks/mcp_server.py
@@ -207,7 +207,11 @@ def call_tool(name: str, arguments: dict[str, Any], runner: Runner = run_cli) ->
args, output = _COMMANDS[name](_validated(name, arguments))
except ToolInputError as exc:
return {"content": [{"type": "text", "text": f"Invalid arguments: {exc}"}], "isError": True}
- completed = runner(args)
+ try:
+ completed = runner(args)
+ except OSError as exc: # the child never started, so there is no exit code to relay
+ log.warning("CLI command could not start: %s: %s", args, exc)
+ return {"content": [{"type": "text", "text": f"The command could not start: {exc}"}], "isError": True}
code = completed.returncode
text = completed.stdout.strip()
stderr = completed.stderr.strip()[-4000:]
@@ -219,13 +223,14 @@ def call_tool(name: str, arguments: dict[str, Any], runner: Runner = run_cli) ->
structured["stdout"] = text
if stderr:
structured["stderr"] = stderr
- # The CLI's own exit 2 means no supported signal, a result. Click also exits 2 on
+ # The CLI's own exit 2 means no supported signal, or a batch directory with nothing
+ # to process, both results. Click also exits 2 on
# a usage error, such as a missing file, and prints its usage line on stderr.
no_signal = code == 2 and "Usage:" not in stderr
is_error = code != 0 and not no_signal
summary = f"exit code {code}"
if no_signal:
- summary += " (no supported signal found; this is unknown, not proof the file is clean)"
+ summary += " (no supported signal found, or nothing to process; this is unknown, not proof of a clean file)"
body = "\n\n".join(part for part in (summary, text, stderr if is_error else "") if part)
return {"content": [{"type": "text", "text": body}], "structuredContent": structured, "isError": is_error}
diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py
index 7ed335a..c846c4f 100644
--- a/src/remove_ai_watermarks/metadata.py
+++ b/src/remove_ai_watermarks/metadata.py
@@ -1856,6 +1856,92 @@ def _sniff_image_format(head: bytes) -> str | None:
return None
+_JXL_CONTAINER_MAGIC = b"\x00\x00\x00\x0cJXL \r\n\x87\n"
+# Accepted on their magic alone: no local parser walks them, and the ffmpeg strip path
+# fails on its own when one is unreadable. JPEG XL codestream, FLV, ID3, FLAC, Ogg.
+_MAGIC_ONLY: tuple[bytes, ...] = (b"\xff\x0a", b"FLV", b"ID3", b"fLaC", b"OggS")
+_PILLOW_RASTERS = frozenset({"png", "jpeg", "webp", "gif", "bmp", "tiff"})
+
+
+def _tiling_reason(kind: str, ends: Iterable[int], size: int) -> str | None:
+ """None when the last top-level element ends at EOF; a truncation leaves a gap."""
+ end = max(ends, default=0) # top-level end offsets only increase
+ if end == size:
+ return None
+ return f"the {kind} structure stops at byte {end} of {size} (truncated or corrupt)"
+
+
+def _pixel_stack_decodes(path: Path) -> bool:
+ """Whether the optional pixel stack decodes ``path``; False when it is absent."""
+ from remove_ai_watermarks.optional_deps import pixels_available
+
+ if not pixels_available():
+ return False
+ from remove_ai_watermarks import image_io
+
+ return image_io.imread(path) is not None
+
+
+@functools.lru_cache(maxsize=8)
+def _unreadable_reason_cached(path_str: str, _mtime_ns: int, size: int) -> str | None:
+ from pathlib import Path as _Path
+
+ from PIL import Image
+
+ from remove_ai_watermarks._internal.ebml import EBML_MAGIC, iter_top_level_ends
+ from remove_ai_watermarks._internal.isobmff import iter_file_boxes
+ from remove_ai_watermarks.forensic_metadata import sniff_format
+
+ path = _Path(path_str)
+ if size == 0:
+ return "the file is empty"
+ with path.open("rb") as stream:
+ head = stream.read(32)
+ kind = sniff_format(head)
+ if kind.startswith("isobmff:") or head.startswith(_JXL_CONTAINER_MAGIC):
+ return _tiling_reason("ISOBMFF box", (end for _s, end, _t, _p in iter_file_boxes(stream, 0, size)), size)
+ if head.startswith(EBML_MAGIC):
+ return _tiling_reason("EBML element", iter_top_level_ends(stream, size), size)
+ if kind in _PILLOW_RASTERS:
+ try:
+ with Image.open(path) as image:
+ image.load()
+ except Image.DecompressionBombError:
+ return None # readable, merely too large for a precautionary full decode
+ except Exception as exc:
+ # The pixel stack decodes some rasters Pillow rejects (a PNG with a bad eXIf
+ # CRC), and strip_and_verify recovers those through it, so they are readable.
+ return None if _pixel_stack_decodes(path) else f"the image cannot be decoded: {exc}"
+ return None
+ if head.startswith(b"RIFF") and len(head) >= 8:
+ declared = 8 + struct.unpack_from(" 1 and head[0] == 0xFF and head[1] & 0xE0 == 0xE0
+ if head.startswith(_MAGIC_ONLY) or mpeg_audio_sync:
+ return None
+ return "the content is not a recognized image, video or audio container"
+
+
+def unreadable_reason(path: Path) -> str | None:
+ """Why ``path`` is not a readable image, video or audio container, or None.
+
+ The metadata scanners read raw bytes, so on a file that is not a container at all
+ they find nothing and say so, which is the same answer a real file without metadata
+ gets. Callers that report "no metadata" or "stripped" ask this first, so an
+ unreadable input fails instead of reading as clean. Rasters must fully decode in
+ Pillow or, when it is installed, the pixel stack; ISOBMFF boxes and EBML elements
+ must tile the file, which is what a truncation breaks; a RIFF file must hold its
+ declared size. The formats in ``_MAGIC_ONLY`` and MPEG audio frames have no local
+ walker and pass on their magic. Memoized on (path, mtime, size): one command asks
+ several times about the same input.
+ """
+ try:
+ stat = path.stat()
+ return _unreadable_reason_cached(str(path), stat.st_mtime_ns, stat.st_size)
+ except OSError as exc:
+ return f"cannot be read: {exc}"
+
+
def strip_and_verify(
source_path: Path,
output_path: Path | None = None,
@@ -1866,10 +1952,12 @@ def strip_and_verify(
:func:`remove_ai_metadata` is deliberately fail-safe: a file PIL cannot decode is
copied through UNCHANGED rather than crashing a caller, and the path it returns is
- indistinguishable from a real strip. Any caller that reports an outcome to a user
- therefore cannot tell a no-op from a success. A Samsung C2PA compatibility case
- exposed this when `metadata --remove` reported success while the output still read
- as AI.
+ indistinguishable from a real strip. This wrapper is what a caller reporting an
+ outcome uses instead: it refuses a source :func:`unreadable_reason` rejects with
+ ``ValueError`` before writing anything, because a copy-through of bytes nothing can
+ parse may carry no marker a re-scan sees, and then re-scans what it wrote. A
+ Samsung C2PA compatibility case exposed the original gap when `metadata --remove`
+ reported success while the output still read as AI.
If markers survive but OpenCV can still decode the raster, the verified path
normalizes the container through :mod:`image_io` and scans once more. This
@@ -1878,6 +1966,8 @@ def strip_and_verify(
Returns ``(output_path, surviving_markers)``; an empty mapping means a real strip.
"""
+ if (reason := unreadable_reason(source_path)) is not None:
+ raise ValueError(f"cannot read {source_path.name}: {reason}")
out = remove_ai_metadata(source_path, output_path, keep_standard=keep_standard)
remaining = get_ai_metadata(out)
if not remaining:
diff --git a/src/remove_ai_watermarks/video.py b/src/remove_ai_watermarks/video.py
index 79ffb6b..d58418a 100644
--- a/src/remove_ai_watermarks/video.py
+++ b/src/remove_ai_watermarks/video.py
@@ -32,7 +32,6 @@ _EBML_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".webm", ".mkv"})
_RIFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".avi"})
_FLV_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".flv"})
_REGENERATED_VIDEO_EXTENSIONS: frozenset[str] = _ISOBMFF_VIDEO_EXTENSIONS
-_EBML_MAGIC = b"\x1aE\xdf\xa3"
def _require_video_runtime() -> None:
@@ -237,17 +236,23 @@ def _video_source(source: str | Path) -> Path:
if path.suffix.lower() not in VIDEO_EXTENSIONS:
supported = ", ".join(sorted(VIDEO_EXTENSIONS))
raise ValueError(f"Unsupported video format {path.suffix or ''}; expected one of: {supported}")
+ from remove_ai_watermarks._internal.ebml import EBML_MAGIC
+
with path.open("rb") as stream:
head = stream.read(12)
suffix = path.suffix.lower()
matches_container = (
(suffix in _ISOBMFF_VIDEO_EXTENSIONS and len(head) >= 8 and head[4:8] == b"ftyp")
- or (suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(_EBML_MAGIC))
+ or (suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(EBML_MAGIC))
or (suffix in _RIFF_VIDEO_EXTENSIONS and len(head) >= 12 and head[:4] == b"RIFF" and head[8:12] == b"AVI ")
or (suffix in _FLV_VIDEO_EXTENSIONS and head.startswith(b"FLV"))
)
if not matches_container:
raise ValueError(f"Video content does not match its {suffix} extension: {path}")
+ from remove_ai_watermarks.metadata import unreadable_reason
+
+ if (reason := unreadable_reason(path)) is not None:
+ raise ValueError(f"Cannot read video {path}: {reason}")
return path
@@ -714,13 +719,13 @@ def remove_video_batch(
)
if output_path.resolve() == directory_path.resolve():
raise ValueError("Video batch output directory must differ from the source directory")
- output_path.mkdir(parents=True, exist_ok=True)
-
sources = tuple(
path
for path in sorted(directory_path.iterdir(), key=lambda candidate: candidate.name.lower())
if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS
)
+ if sources:
+ output_path.mkdir(parents=True, exist_ok=True)
items: list[VideoBatchItem] = []
invisible_runtime: VideoVaeRuntime | None = None
invisible_runtime_error: str | None = None
diff --git a/tests/test_agent_skill.py b/tests/test_agent_skill.py
index a52f883..653b6f2 100644
--- a/tests/test_agent_skill.py
+++ b/tests/test_agent_skill.py
@@ -347,7 +347,7 @@ def test_missing_pixel_stack_names_the_extra_instead_of_a_traceback(
source = tmp_path / "image.png"
source.write_bytes((ROOT / "data" / "fixtures" / "provenance" / "chatgpt-1.png").read_bytes())
clip = tmp_path / "clip.mp4"
- clip.write_bytes(b"\x00\x00\x00\x18ftypmp42")
+ clip.write_bytes(b"\x00\x00\x00\x10ftypmp42\x00\x00\x00\x00") # one whole box, so the input reads
substitutions = {
"SOURCE": str(source),
"CLIP": str(clip),
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 0211614..be0e12d 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -867,7 +867,7 @@ class TestBatchCommand:
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
result = runner.invoke(main, ["batch", str(empty_dir)])
- assert result.exit_code == 0
+ assert result.exit_code == 2 # nothing processed is the no-signal code (2026-10-18)
assert "No supported images" in result.output
def test_batch_visible_mode(self, runner, tmp_path):
@@ -1309,10 +1309,10 @@ def test_visible_backend_runtime_error_exits_cleanly(runner, tmp_path, monkeypat
@pytest.mark.parametrize("cmd", [["metadata", "--remove"], ["visible", "--backend", "cv2"]])
def test_unreadable_input_exits_cleanly(runner, tmp_path, name, content, cmd):
"""Regression: a corrupt / empty / non-image file (real prod uploads include ~0.2%
- truncated files) must NEVER leak a raw PIL/OSError/ValueError traceback. `metadata
- --remove` is fail-safe -- an undecodable file is copied through unchanged (exit 0),
- a strip that cannot parse the file is a no-op, not a crash; `visible` must decode to
- remove a mark, so it is a clean error (exit 1). Found by the runtime mode fuzz."""
+ truncated files) must NEVER leak a raw PIL/OSError/ValueError traceback, and both
+ commands fail with a clean exit 1. `metadata --remove` used to copy the bytes
+ through and exit 0, which read as a completed strip; since 2026-10-18 it writes
+ nothing (`tests/test_fail_closed.py`). Found by the runtime mode fuzz."""
bad = tmp_path / name
bad.write_bytes(content)
out = tmp_path / "out.png"
@@ -1320,12 +1320,9 @@ def test_unreadable_input_exits_cleanly(runner, tmp_path, name, content, cmd):
assert result.exception is None or isinstance(result.exception, SystemExit), (
f"leaked a raw traceback: {result.exception!r}"
)
- if cmd[0] == "metadata":
- assert result.exit_code == 0, result.output # fail-safe copy-through
- assert out.read_bytes() == content # input passed through unchanged
- else:
- assert result.exit_code == 1, result.output # cannot remove a mark from unreadable input
- assert "Error" in result.output
+ assert result.exit_code == 1, result.output
+ assert "Error" in result.output
+ assert not out.exists()
@pytest.mark.parametrize(
diff --git a/tests/test_fail_closed.py b/tests/test_fail_closed.py
new file mode 100644
index 0000000..f04fdfa
--- /dev/null
+++ b/tests/test_fail_closed.py
@@ -0,0 +1,311 @@
+"""Every command fails closed: an unreadable input or a skipped step never exits 0.
+
+Found 2026-10-18 by a sweep that ran each command over random bytes, an empty file
+and a truncated copy of a tracked fixture. The pixel commands already refused all
+three. The metadata-only paths did not: they scan raw bytes, a scan of bytes that
+are not an image finds nothing, and "nothing" printed as "No AI metadata found"
+with exit 0, the same answer a real file without metadata gets. `metadata
+--remove` then copied the garbage through and reported "AI metadata stripped".
+A truncated MP4 without its `moov` box got the same treatment from the video
+metadata commands. The rule pinned here: the exit code of a scan that could not
+read its input is never the exit code of a scan that read it and found nothing.
+
+Absent OPTIONAL detectors are not in this set. `identify` on the default install,
+which has no pixel stack, still answers with exit 0 and a caveat naming what did
+not run (decided 2026-10-18); see `test_optional_deps.py`.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+from pathlib import Path
+
+import numpy as np
+import pytest
+from click.testing import CliRunner
+
+from remove_ai_watermarks import image_io, metadata
+from remove_ai_watermarks._internal.constants import SUPPORTED_FORMATS
+from remove_ai_watermarks.cli import main
+from remove_ai_watermarks.video import VIDEO_EXTENSIONS
+
+ROOT = Path(__file__).resolve().parents[1]
+C2PA_PNG = ROOT / "data/fixtures/provenance/ideogram-c2pa.png"
+MP4 = ROOT / "data/fixtures/provenance/runway-gen45-video.mp4"
+
+
+def _unreadable_images(tmp_path: Path) -> dict[str, Path]:
+ rng = np.random.default_rng(0)
+ garbage = tmp_path / "garbage.png"
+ garbage.write_bytes(rng.integers(0, 256, 4000, dtype=np.uint8).tobytes())
+ empty = tmp_path / "empty.png"
+ empty.write_bytes(b"")
+ truncated = tmp_path / "truncated.png"
+ truncated.write_bytes(C2PA_PNG.read_bytes()[:300])
+ return {"garbage": garbage, "empty": empty, "truncated": truncated}
+
+
+@pytest.fixture
+def clean_png(tmp_path: Path) -> Path:
+ path = tmp_path / "clean.png"
+ gradient = np.tile(np.linspace(0, 255, 64, dtype=np.uint8), (64, 1))
+ assert image_io.imwrite(path, np.dstack([gradient, gradient.T, gradient]))
+ return path
+
+
+@pytest.fixture
+def truncated_mp4(tmp_path: Path) -> Path:
+ """The ftyp box survives, so the suffix sniff passes; the box walk does not."""
+ path = tmp_path / "truncated.mp4"
+ path.write_bytes(MP4.read_bytes()[:3000])
+ return path
+
+
+@pytest.fixture
+def cuda_refused(monkeypatch):
+ """The invisible engine is installed but cannot start, as on a host without CUDA."""
+ import remove_ai_watermarks.invisible_engine as invisible_engine
+
+ def refuse(*_args, **_kwargs):
+ raise ValueError("Invisible-watermark removal is CUDA-only")
+
+ monkeypatch.setattr(invisible_engine, "is_available", lambda: True)
+ monkeypatch.setattr(invisible_engine, "InvisibleEngine", refuse)
+
+
+def _invoke(args: list[str]):
+ return CliRunner().invoke(main, args)
+
+
+class TestUnreadableImageInputs:
+ @pytest.mark.parametrize("kind", ["garbage", "empty", "truncated"])
+ @pytest.mark.parametrize("flags", [[], ["--no-visible"], ["--json"]])
+ def test_identify_exits_nonzero(self, tmp_path, kind, flags):
+ result = _invoke(["identify", *flags, str(_unreadable_images(tmp_path)[kind])])
+ assert result.exit_code == 1, result.output
+ assert isinstance(result.exception, SystemExit)
+
+ def test_identify_keeps_exit_zero_on_a_readable_file(self, clean_png):
+ result = _invoke(["identify", str(clean_png)])
+ assert result.exit_code == 0, result.output
+
+ @pytest.mark.parametrize("kind", ["garbage", "empty", "truncated"])
+ def test_metadata_check_does_not_report_no_metadata(self, tmp_path, kind):
+ result = _invoke(["metadata", "--check", str(_unreadable_images(tmp_path)[kind])])
+ assert result.exit_code == 1, result.output
+ assert "No AI metadata found" not in result.output
+ assert "Nothing was scanned" in result.output, "the probe runs before any scan"
+
+ @pytest.mark.parametrize("kind", ["garbage", "empty", "truncated"])
+ def test_metadata_remove_writes_nothing_and_exits_nonzero(self, tmp_path, kind):
+ out = tmp_path / "out.png"
+ result = _invoke(["metadata", "--remove", str(_unreadable_images(tmp_path)[kind]), "-o", str(out)])
+ assert result.exit_code == 1, result.output
+ assert "stripped" not in result.output
+ assert not out.exists()
+
+ def test_metadata_remove_keeps_working_on_a_readable_file(self, clean_png, tmp_path):
+ result = _invoke(["metadata", "--remove", str(clean_png), "-o", str(tmp_path / "out.png")])
+ assert result.exit_code == 0, result.output
+
+ def test_batch_metadata_counts_an_unreadable_file_as_failed(self, tmp_path, clean_png):
+ indir = tmp_path / "in"
+ indir.mkdir()
+ shutil.copy(clean_png, indir / "clean.png")
+ shutil.copy(_unreadable_images(tmp_path)["garbage"], indir / "garbage.png")
+ result = _invoke(["batch", str(indir), "--mode", "metadata", "-o", str(tmp_path / "out")])
+ assert result.exit_code == 1, result.output
+ assert "1 processed" in result.output
+ assert "1 errors" in result.output
+
+
+class TestTruncatedVideo:
+ @pytest.mark.parametrize(
+ "args",
+ [
+ ["video", "identify", "--no-visible"],
+ ["video", "metadata", "--check"],
+ ["video", "metadata", "--remove"],
+ ["metadata", "--check"],
+ ],
+ )
+ def test_metadata_paths_exit_nonzero(self, truncated_mp4, args):
+ result = _invoke([*args, str(truncated_mp4)])
+ assert result.exit_code == 1, result.output
+ assert "No AI metadata found" not in result.output
+ assert "stripped" not in result.output
+
+ def test_video_batch_metadata_counts_it_as_failed(self, tmp_path, truncated_mp4):
+ indir = tmp_path / "in"
+ indir.mkdir()
+ shutil.copy(truncated_mp4, indir / "truncated.mp4")
+ result = _invoke(["video", "batch", str(indir), "--mode", "metadata", "-o", str(tmp_path / "out")])
+ assert result.exit_code == 1, result.output
+ assert "0 processed, 1 failed" in result.output
+
+
+class TestReadabilityProbe:
+ """The probe must pass every tracked fixture, or the fix trades one lie for another."""
+
+ @pytest.mark.parametrize(
+ "path",
+ sorted(p for p in (ROOT / "data/fixtures").rglob("*") if p.is_file() and p.suffix.lower() != ".md"),
+ ids=lambda p: str(p.relative_to(ROOT / "data/fixtures")),
+ )
+ def test_every_tracked_fixture_is_readable(self, path):
+ if path.suffix.lower() not in SUPPORTED_FORMATS | VIDEO_EXTENSIONS:
+ pytest.skip("not a container the metadata commands accept")
+ assert metadata.unreadable_reason(path) is None
+
+ def test_unreadable_inputs_name_a_reason(self, tmp_path, truncated_mp4):
+ for path in [*_unreadable_images(tmp_path).values(), truncated_mp4]:
+ assert metadata.unreadable_reason(path), path
+
+ def test_a_png_cut_inside_its_pixel_data_is_unreadable(self, tmp_path):
+ """The header parses, so only a full decode sees the missing IDAT bytes."""
+ path = tmp_path / "cut.png"
+ data = (ROOT / "data/fixtures/provenance/chatgpt-1.png").read_bytes()
+ path.write_bytes(data[: len(data) * 3 // 5])
+ assert metadata.unreadable_reason(path)
+
+ def test_a_trailing_partial_box_is_unreadable(self, tmp_path):
+ """Cut inside the LAST box, so ftyp and moov both survive and only the walk sees it."""
+ data = MP4.read_bytes()
+ path = tmp_path / "cut.mp4"
+ path.write_bytes(data[:-100])
+ assert metadata.unreadable_reason(path)
+
+ def test_a_truncated_ebml_segment_is_unreadable(self, tmp_path):
+ """A Segment that declares more bytes than the file holds, as a cut WebM does."""
+ path = tmp_path / "x.webm"
+ ebml_header = b"\x1a\x45\xdf\xa3\x80" # empty EBML header element
+ segment = b"\x18\x53\x80\x67\x88" + bytes(4) # declares 8 payload bytes, holds 4
+ path.write_bytes(ebml_header + segment)
+ assert metadata.unreadable_reason(path)
+ path.write_bytes(ebml_header + segment + bytes(4))
+ assert metadata.unreadable_reason(path) is None
+
+ def test_a_truncated_riff_is_unreadable(self, tmp_path):
+ path = tmp_path / "x.wav"
+ path.write_bytes(b"RIFF" + (100).to_bytes(4, "little") + b"WAVE" + bytes(20))
+ assert metadata.unreadable_reason(path)
+ path.write_bytes(b"RIFF" + (100).to_bytes(4, "little") + b"WAVE" + bytes(96))
+ assert metadata.unreadable_reason(path) is None
+
+
+class TestSkippedStepsAreFailures:
+ @pytest.mark.parametrize(
+ "args",
+ [
+ ["visible", "--mark", "doubao", "--no-detect"],
+ ["erase", "--region", "2,2,10,10"],
+ ],
+ )
+ def test_a_metadata_strip_that_left_markers_is_not_success(self, tmp_path, clean_png, monkeypatch, args):
+ """Both commands used to swallow the strip and report only under --verbose."""
+ monkeypatch.setattr(metadata, "get_ai_metadata", lambda _path: {"c2pa": "survived"})
+ out = tmp_path / "out.png"
+ result = _invoke([*args, str(clean_png), "-o", str(out)])
+ assert result.exit_code == 1, result.output
+ assert "survived" in result.output
+ assert not out.exists(), "an output that kept its AI metadata was published"
+
+ @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
+ def test_a_staged_output_keeps_the_default_permissions(self, tmp_path, clean_png):
+ """The staged sibling is not pre-created, so it is not mkstemp's 0600."""
+ out = tmp_path / "out.png"
+ reference = tmp_path / "reference"
+ reference.write_bytes(b"")
+ result = _invoke(["erase", "--region", "2,2,10,10", str(clean_png), "-o", str(out)])
+ assert result.exit_code == 0, result.output
+ assert out.stat().st_mode & 0o777 == reference.stat().st_mode & 0o777
+
+ def test_the_library_visible_strip_is_verified(self, tmp_path, clean_png, monkeypatch):
+ from remove_ai_watermarks.api import MetadataStripIncomplete, remove_visible
+
+ monkeypatch.setattr(metadata, "get_ai_metadata", lambda _path: {"c2pa": "survived"})
+ with pytest.raises(MetadataStripIncomplete):
+ remove_visible(clean_png, tmp_path / "out.png")
+ assert list(tmp_path.iterdir()) == [clean_png], "the staged output leaked"
+
+ def test_the_invisible_output_strip_is_verified(self, tmp_path, monkeypatch):
+ """The diffusion writer used to strip without re-scanning what it wrote."""
+ from PIL import Image
+
+ from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
+
+ monkeypatch.setattr(metadata, "get_ai_metadata", lambda _path: {"c2pa": "survived"})
+ remover = object.__new__(WatermarkRemover) # the writer needs no model state
+ with pytest.raises(RuntimeError, match="survived"):
+ remover._write_output(Image.new("RGB", (16, 16)), tmp_path / "out.png")
+
+ def test_metadata_remove_reports_a_missing_ffmpeg_without_a_traceback(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(shutil, "which", lambda _name: None)
+ source = tmp_path / "x.webm"
+ source.write_bytes(b"\x1a\x45\xdf\xa3\x80") # an empty EBML header tiles the file
+ result = _invoke(["metadata", "--remove", str(source), "-o", str(tmp_path / "out.webm")])
+ assert result.exit_code == 1, result.output
+ assert isinstance(result.exception, SystemExit), result.exception
+ assert "ffmpeg" in result.output
+
+ @pytest.mark.usefixtures("cuda_refused")
+ def test_invisible_reports_an_engine_that_cannot_start(self, clean_png):
+ result = _invoke(["invisible", "--force", str(clean_png)])
+ assert result.exit_code == 1, result.output
+ assert isinstance(result.exception, SystemExit), result.exception
+ assert "CUDA-only" in result.output
+
+ @pytest.mark.usefixtures("cuda_refused")
+ def test_batch_reports_an_engine_that_cannot_start(self, tmp_path, clean_png):
+ indir = tmp_path / "in"
+ indir.mkdir()
+ shutil.copy(clean_png, indir / "clean.png")
+ result = _invoke(["batch", str(indir), "--mode", "invisible", "-o", str(tmp_path / "out")])
+ assert result.exit_code == 1, result.output
+ assert isinstance(result.exception, SystemExit), result.exception
+ assert "CUDA-only" in result.output
+
+
+class TestEmptyBatch:
+ """Nothing processed is the no-signal code, not success (decided 2026-10-18)."""
+
+ def test_image_batch(self, tmp_path):
+ indir = tmp_path / "in"
+ indir.mkdir()
+ (indir / "notes.txt").write_text("not an image")
+ result = _invoke(["batch", str(indir), "-o", str(tmp_path / "out")])
+ assert result.exit_code == 2, result.output
+ assert not (tmp_path / "out").exists()
+
+ def test_video_batch(self, tmp_path):
+ indir = tmp_path / "in"
+ indir.mkdir()
+ result = _invoke(["video", "batch", str(indir), "--mode", "metadata", "-o", str(tmp_path / "out")])
+ assert result.exit_code == 2, result.output
+ assert not (tmp_path / "out").exists()
+
+
+def test_mcp_reports_a_child_that_could_not_start():
+ from remove_ai_watermarks import mcp_server
+
+ def broken(_args):
+ raise OSError("exec format error")
+
+ result = mcp_server.call_tool("identify", {"path": "x.png"}, runner=broken)
+ assert result["isError"] is True
+ assert "exec format error" in result["content"][0]["text"]
+
+
+def test_mcp_does_not_call_an_empty_batch_a_scanned_file():
+ """An empty batch directory exits 2 like no-signal, but nothing was read."""
+ import subprocess
+
+ from remove_ai_watermarks import mcp_server
+
+ def empty_batch(args):
+ return subprocess.CompletedProcess(args, 2, stdout="No supported images found", stderr="")
+
+ result = mcp_server.call_tool("batch", {"directory": "in"}, runner=empty_batch)
+ assert result["isError"] is False
+ assert "nothing to process" in result["content"][0]["text"]
diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py
index 6d4b0af..eacc1b6 100644
--- a/tests/test_mcp_server.py
+++ b/tests/test_mcp_server.py
@@ -185,7 +185,7 @@ def test_only_failures_are_tool_errors(returncode: int, stderr: str, is_error: b
assert result["isError"] is is_error
assert result["structuredContent"]["exit_code"] == returncode
- assert ("not proof the file is clean" in result["content"][0]["text"]) is (returncode == 2 and not is_error)
+ assert ("not proof of a clean file" in result["content"][0]["text"]) is (returncode == 2 and not is_error)
if is_error:
assert stderr.splitlines()[-1] in result["content"][0]["text"]
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
index e45364b..8bdd0d5 100644
--- a/tests/test_metadata.py
+++ b/tests/test_metadata.py
@@ -334,17 +334,20 @@ class TestHasAiMetadata:
assert after is not None
assert np.array_equal(after, before)
- def test_strip_and_verify_reports_markers_in_undecodable_copy_through(self, tmp_path: Path):
- """A truly undecodable file keeps the established fail-safe result."""
+ def test_strip_and_verify_refuses_an_undecodable_file(self, tmp_path: Path):
+ """A truly undecodable file raises before anything is written.
+
+ It used to be copied through and reported with its surviving markers, but a
+ copy-through of bytes no scanner can parse may carry nothing a re-scan sees,
+ and then it verified as a real strip (`tests/test_fail_closed.py`).
+ """
src = _corrupt_c2pa_png(tmp_path / "source.png")
src.write_bytes(src.read_bytes()[: src.stat().st_size // 2])
out = tmp_path / "copy.png"
- result, remaining = strip_and_verify(src, out)
-
- assert result == out
- assert "c2pa_manifest" in remaining
- assert out.read_bytes() == src.read_bytes()
+ with pytest.raises(ValueError, match=r"cannot read source\.png"):
+ strip_and_verify(src, out)
+ assert not out.exists()
class TestC2paMarkerIn: