mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-19 23:47:30 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a752bcda33 | ||
|
|
04364efde2 | ||
|
|
303aba6b8a | ||
|
|
f6b7c366e8 | ||
|
|
30423aca5e | ||
|
|
ace71128b9 | ||
|
|
7e167c3c76 | ||
|
|
33a0e6c987 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -109,7 +109,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
|
||||
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+4
-1
@@ -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.
|
||||
|
||||
@@ -50,7 +50,32 @@ 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. The diffusion
|
||||
writer behind `invisible` stages the same way and raises with nothing published
|
||||
(fixed 2026-10-19; it used to leave the marked file at the output path).
|
||||
`_internal.utils.staged_sibling` is the shared stage: it never pre-creates the
|
||||
name, so the published file keeps the default permissions. These writers strip
|
||||
through `metadata.strip_written_output`, which skips the readability probe:
|
||||
the file is the package's own encode, and the probe's full decode doubled the
|
||||
cost of the strip (decided 2026-10-19).
|
||||
- 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 +266,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.
|
||||
|
||||
The in-memory image path (AVIF, HEIF, JPEG-XL and unknown suffixes, through
|
||||
@@ -829,7 +855,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
|
||||
|
||||
+8
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
# on it, including the ComfyUI node package. The console script below carries
|
||||
# the same weight, since users have it on PATH.
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.46.0"
|
||||
version = "0.47.0"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11,<3.15"
|
||||
|
||||
@@ -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. |
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
"name": "io.github.wiltodelta/remove-ai-watermarks",
|
||||
"title": "Remove AI Watermarks",
|
||||
"description": "Identify and remove AI watermarks and provenance metadata from your own images and video.",
|
||||
"version": "0.46.0",
|
||||
"version": "0.47.0",
|
||||
"repository": {
|
||||
"url": "https://github.com/wiltodelta/remove-ai-watermarks",
|
||||
"source": "github"
|
||||
@@ -14,7 +14,7 @@
|
||||
"registryType": "pypi",
|
||||
"registryBaseUrl": "https://pypi.org",
|
||||
"identifier": "remove-ai-watermarks",
|
||||
"version": "0.46.0",
|
||||
"version": "0.47.0",
|
||||
"runtimeHint": "uvx",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ _GPU_MARKERS = ("GPU ", "gpu:")
|
||||
# told an agent its CLI was current right before it typed a value that build
|
||||
# rejects. tests/test_agent_skill.py pins it against the package version and
|
||||
# against every "(since CLI X.Y.Z)" annotation in the skill.
|
||||
MIN_CLI_VERSION = (0, 46, 0)
|
||||
MIN_CLI_VERSION = (0, 47, 0)
|
||||
# Markers a build without the pixel stack prints. The first is the current CLI's own
|
||||
# install hint; the rest are what older builds emit before the guard existed, and the
|
||||
# Homebrew formula ships exactly such a build.
|
||||
|
||||
@@ -35,7 +35,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.46.0"
|
||||
__version__ = "0.47.0"
|
||||
|
||||
__all__ = [
|
||||
"BatchItemResult",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -212,14 +212,18 @@ class WatermarkRemover:
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
from remove_ai_watermarks._internal.utils import staged_sibling
|
||||
from remove_ai_watermarks.metadata import strip_written_output
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
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
|
||||
|
||||
remove_ai_metadata(output_path, output_path, keep_standard=True)
|
||||
# Staged and verified, so a marker that survives fails the run with nothing
|
||||
# published at output_path.
|
||||
with staged_sibling(output_path) as staged:
|
||||
if not image_io.imwrite(str(staged), bgr, display_tags_from=display_tags_from):
|
||||
image.save(staged)
|
||||
if leftover := strip_written_output(staged):
|
||||
raise RuntimeError(f"AI metadata survived the strip: {', '.join(sorted(leftover))}")
|
||||
|
||||
def remove_watermark(
|
||||
self,
|
||||
|
||||
@@ -132,33 +132,35 @@ 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 <INPUT>" 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_written_output
|
||||
|
||||
metadata.remove_ai_metadata(out_path, out_path)
|
||||
if leftover := strip_written_output(staged):
|
||||
raise MetadataStripIncomplete(set(leftover))
|
||||
|
||||
|
||||
def remove_visible(
|
||||
@@ -461,7 +463,7 @@ def remove_all(
|
||||
import tempfile
|
||||
|
||||
from remove_ai_watermarks import image_io, watermark_registry
|
||||
from remove_ai_watermarks.metadata import strip_and_verify
|
||||
from remove_ai_watermarks.metadata import strip_written_output
|
||||
|
||||
def say(stage: str, detail: str) -> None:
|
||||
if progress is not None:
|
||||
@@ -516,8 +518,7 @@ def remove_all(
|
||||
# ── 3. AI metadata ──
|
||||
# Read the pristine ORIGINAL for provenance above and the STAGED file here:
|
||||
# the visible pass has already dropped this file's C2PA.
|
||||
_, leftover = strip_and_verify(staged, staged)
|
||||
if leftover:
|
||||
if leftover := strip_written_output(staged):
|
||||
# Before the write, on purpose -- see MetadataStripIncomplete.
|
||||
raise MetadataStripIncomplete(set(leftover))
|
||||
say("metadata", "stripped")
|
||||
@@ -676,7 +677,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 +688,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)
|
||||
|
||||
@@ -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_written_output
|
||||
|
||||
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_written_output(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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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("<I", head, 4)[0]
|
||||
return None if declared <= size else f"the RIFF header declares {declared} bytes but the file has {size}"
|
||||
mpeg_audio_sync = len(head) > 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,27 @@ 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}")
|
||||
return _strip_and_rescan(source_path, output_path, keep_standard=keep_standard)
|
||||
|
||||
|
||||
def strip_written_output(path: Path, *, keep_standard: bool = True) -> dict[str, str]:
|
||||
"""Strip ``path`` in place and return the markers that survived.
|
||||
|
||||
For a file whose pixels this run has already decoded or written itself, so the
|
||||
readability probe that :func:`strip_and_verify` runs first would only decode
|
||||
them a second time.
|
||||
"""
|
||||
return _strip_and_rescan(path, path, keep_standard=keep_standard)[1]
|
||||
|
||||
|
||||
def _strip_and_rescan(
|
||||
source_path: Path,
|
||||
output_path: Path | None,
|
||||
*,
|
||||
keep_standard: bool,
|
||||
) -> tuple[Path, dict[str, str]]:
|
||||
out = remove_ai_metadata(source_path, output_path, keep_standard=keep_standard)
|
||||
remaining = get_ai_metadata(out)
|
||||
if not remaining:
|
||||
|
||||
@@ -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 '<none>'}; 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
|
||||
|
||||
@@ -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),
|
||||
|
||||
+1
-1
@@ -430,7 +430,7 @@ class TestRemoveAllLibrary:
|
||||
from remove_ai_watermarks import api, invisible_engine, metadata
|
||||
|
||||
monkeypatch.setattr(invisible_engine, "is_available", lambda: False)
|
||||
monkeypatch.setattr(metadata, "strip_and_verify", lambda src, dst: (dst, {"c2pa"}))
|
||||
monkeypatch.setattr(metadata, "strip_written_output", lambda _path: {"c2pa": "survived"})
|
||||
out = tmp_path / "clean.png"
|
||||
|
||||
with pytest.raises(api.MetadataStripIncomplete, match="c2pa"):
|
||||
|
||||
+9
-15
@@ -675,10 +675,7 @@ class TestAllCommand:
|
||||
# The no-signal gate lives in the library now; patch it there, not on the
|
||||
# CLI module, or the diffusion stage runs for real.
|
||||
patch("remove_ai_watermarks.api._SourceEvidence.has_invisible_target", return_value=False),
|
||||
patch(
|
||||
"remove_ai_watermarks.metadata.strip_and_verify",
|
||||
return_value=(tmp_path / "intermediate.png", {"c2pa": True}),
|
||||
),
|
||||
patch("remove_ai_watermarks.metadata.strip_written_output", return_value={"c2pa": "survived"}),
|
||||
):
|
||||
result = runner.invoke(main, ["all", str(sample_png), "-o", str(output)])
|
||||
|
||||
@@ -867,7 +864,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 +1306,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 +1317,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(
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"""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")
|
||||
assert list(tmp_path.iterdir()) == [], "an output that kept its AI metadata was published"
|
||||
|
||||
@pytest.mark.parametrize("command", ["erase", "remove_visible", "invisible_writer"])
|
||||
def test_an_output_the_package_just_wrote_is_not_probed_again(self, tmp_path, clean_png, monkeypatch, command):
|
||||
"""The readability probe decodes every pixel; on our own encode it is pure waste."""
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
||||
from remove_ai_watermarks.api import remove_visible
|
||||
|
||||
probed: list[Path] = []
|
||||
real = metadata.unreadable_reason
|
||||
monkeypatch.setattr(metadata, "unreadable_reason", lambda path: probed.append(path) or real(path))
|
||||
out = tmp_path / "out.png"
|
||||
if command == "erase":
|
||||
result = _invoke(["erase", "--region", "2,2,10,10", str(clean_png), "-o", str(out)])
|
||||
assert result.exit_code == 0, result.output
|
||||
elif command == "remove_visible":
|
||||
remove_visible(clean_png, out, write_noop=True)
|
||||
else:
|
||||
object.__new__(WatermarkRemover)._write_output(Image.new("RGB", (16, 16)), out)
|
||||
assert out.exists()
|
||||
assert [p for p in probed if p.name != clean_png.name] == []
|
||||
|
||||
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"]
|
||||
@@ -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"]
|
||||
|
||||
|
||||
+10
-7
@@ -340,17 +340,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:
|
||||
|
||||
@@ -825,7 +825,7 @@ name = "cuda-bindings"
|
||||
version = "13.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cuda-pathfinder" },
|
||||
{ name = "cuda-pathfinder", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3a/06569edfd5db020c1ff7c0361268a3eaa60ead0c0ccca460fef69d68022f/cuda_bindings-13.4.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df8b3767facca8acde216460df684dfc3826d519096c13adfd3030a55870dc79", size = 6494098, upload-time = "2026-09-23T02:21:50.472Z" },
|
||||
@@ -858,43 +858,43 @@ wheels = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
cublas = [
|
||||
{ name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
]
|
||||
cudart = [
|
||||
{ name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
]
|
||||
cufft = [
|
||||
{ name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
]
|
||||
cufile = [
|
||||
{ name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
]
|
||||
cupti = [
|
||||
{ name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
]
|
||||
curand = [
|
||||
{ name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
]
|
||||
cusolver = [
|
||||
{ name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
]
|
||||
cusparse = [
|
||||
{ name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
]
|
||||
nvjitlink = [
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
]
|
||||
nvrtc = [
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
]
|
||||
nvtx = [
|
||||
{ name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1221,8 +1221,8 @@ name = "httpcore2"
|
||||
version = "2.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
{ name = "truststore" },
|
||||
{ name = "h11", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" }
|
||||
wheels = [
|
||||
@@ -1964,7 +1964,7 @@ name = "nvidia-cublas"
|
||||
version = "13.1.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cuda-nvrtc" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
|
||||
@@ -2003,7 +2003,7 @@ name = "nvidia-cudnn-cu13"
|
||||
version = "9.24.0.43"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas" },
|
||||
{ name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/30/7c257e3d5cb4fecb147b93895c66e29c93f8e76d74b45bb418ff0587c4ec/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:a6812a554a1ff0413e9c52b84c26c050380649ab9615f9c16bded368ce9f421f", size = 650976863, upload-time = "2026-07-02T16:23:39.248Z" },
|
||||
@@ -2015,7 +2015,7 @@ name = "nvidia-cufft"
|
||||
version = "12.0.0.61"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
||||
@@ -2045,9 +2045,9 @@ name = "nvidia-cusolver"
|
||||
version = "12.0.4.66"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas" },
|
||||
{ name = "nvidia-cusparse" },
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "nvidia-cusparse", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
||||
@@ -2059,7 +2059,7 @@ name = "nvidia-cusparse"
|
||||
version = "12.6.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
||||
@@ -3203,7 +3203,7 @@ resolution-markers = [
|
||||
"(python_full_version < '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "numpy", marker = "python_full_version < '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/75/50581633d199812205ea8cdd0f6d52f12a624886b74bf1486335b67f01ff/pywavelets-1.9.0.tar.gz", hash = "sha256:148d12203377772bea452a59211d98649c8ee4a05eff019a9021853a36babdc8", size = 3938340, upload-time = "2025-08-04T16:20:04.978Z" }
|
||||
wheels = [
|
||||
@@ -3279,7 +3279,7 @@ resolution-markers = [
|
||||
"(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/84/a9e80e33eb114e78bd2f1829a90d72d8d4d660109c721a8aa2f0d58d7592/pywavelets-1.10.0.tar.gz", hash = "sha256:f3cb8640225a8f3fdcbc3f6f3bd112c78927c9407cbfa65a5ff303990a6a3e32", size = 3948163, upload-time = "2026-09-07T17:15:15.637Z" }
|
||||
wheels = [
|
||||
@@ -3479,7 +3479,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.46.0"
|
||||
version = "0.47.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "c2pa-python" },
|
||||
@@ -4473,8 +4473,8 @@ name = "uvicorn"
|
||||
version = "0.54.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
{ name = "click", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "h11", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
|
||||
wheels = [
|
||||
|
||||
Reference in new issue
Block a user