Compare commits

..
Author SHA1 Message Date
Victor Kuznetsov a752bcda33 Merge pull request #127 from wiltodelta/fix/verified-output-publish
Publish the invisible output only after a clean strip; release 0.47.0
2026-10-19 14:47:14 -07:00
Victor KuznetsovandClaude Opus 5.5 04364efde2 Release 0.47.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:29:43 -07:00
Victor KuznetsovandClaude Opus 5.5 303aba6b8a Publish the invisible output only after a clean strip, and skip re-probing own encodes
The diffusion writer stripped its output in place and then raised on a
surviving marker, which left the AI-readable file at the output path. It now
writes to a staged sibling and publishes only when nothing survives, as
`visible`, `erase` and `remove_all` already do.

The package's own writers strip through the new
`metadata.strip_written_output`, which skips the readability probe:
`strip_and_verify` decoded every pixel of a file this run had just encoded,
doubling the cost of the strip. `strip_and_verify` keeps the probe for
user-supplied sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:29:43 -07:00
Victor Kuznetsov f6b7c366e8 Merge pull request #126 from wiltodelta/feat/watermark-library-followups
Fail closed on unreadable inputs, empty batches and failed strips
2026-10-19 12:32:39 -07:00
Victor Kuznetsov 30423aca5e Merge pull request #125 from wiltodelta/fix/isobmff-strip-keeps-offsets
Blank ISOBMFF provenance boxes in place so HEIF iloc offsets stay valid
2026-10-19 12:16:14 -07:00
Victor KuznetsovandClaude Opus 5.5 ace71128b9 Bump aiohttp to 3.14.5 for GHSA-5wr8-gcqf-gqf3, GHSA-2g87-pp6c-29x5 and GHSA-494j-54xm-rwp3
The CI lockfile scan failed on all three advisories against 3.14.3; each is
fixed in 3.14.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:16:10 -07:00
Victor KuznetsovandClaude Opus 5.5 7e167c3c76 Fail closed on unreadable inputs, empty batches and failed strips
The metadata scanners read raw bytes, so random bytes, an empty file or a
truncated PNG or MP4 printed the same "No AI metadata found" or "AI metadata
stripped" a real file gets, with exit 0. `metadata.unreadable_reason` is now
the one readability probe: `identify`, `metadata` and every `video` command
exit 1 on such input, and `batch` and `video batch` fail its row.
`strip_and_verify` refuses it before writing anything.

A `batch` or `video batch` directory with no supported file exits 2 and
creates no output directory. `visible` and `erase` strip and verify on a
staged sibling and publish nothing when a marker survives, as the library
`remove_visible` now does; the diffusion writer raises on a surviving
marker. An invisible engine that cannot start exits 1 without a traceback,
and the MCP server reports a child that could not start.

NotebookLM and Gemini slide-deck templates are recorded as not planned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:59 -07:00
Victor KuznetsovandClaude Opus 5.5 33a0e6c987 CLAUDE.md: document the C2PA soft-binding snapshot check in the gate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:12:51 -07:00
30 changed files with 726 additions and 164 deletions

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -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.
+29 -3
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+5 -3
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+12 -2
View File
@@ -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,
+21 -18
View File
@@ -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)
+80 -42
View File
@@ -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,
)
+8 -3
View File
@@ -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}
+113 -4
View File
@@ -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:
+9 -4
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+334
View File
@@ -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"]
+1 -1
View File
@@ -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
View File
@@ -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:
Generated
+32 -32
View File
@@ -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 = [