Say metadata check and strip results are not a clean verdict

This commit is contained in:
Victor Kuznetsov
2026-08-25 13:22:38 -07:00
parent 0d11e4099c
commit 4beb61463a
5 changed files with 37 additions and 0 deletions
+9
View File
@@ -157,6 +157,11 @@ remove-ai-watermarks metadata image.png --remove -o clean.png
When `-o` is omitted, removal overwrites the source. Standard metadata is kept
unless you pass `--remove-all`.
A quiet `--check` or a successful `--remove` is not a clean verdict. The command
only inspects and strips embedded AI metadata; a pixel watermark such as SynthID
has no local decoder once that metadata proxy is gone. `identify` reports the
same limit.
The command also supports the audio and video containers listed in
[supported signals](supported-signals.md). ffmpeg must be available for the
non-ISOBMFF audio and video path.
@@ -225,6 +230,10 @@ remove-ai-watermarks video metadata input.mp4 --check
remove-ai-watermarks video metadata input.mp4 --remove -o clean.mp4
```
As with the generic `metadata` command, a quiet check or a successful strip is
not a clean verdict: video SynthID is not decoded locally after the metadata
proxy is gone.
Supported containers are MP4, MOV, M4V, WebM, MKV, AVI, and FLV. The operation
delegates to the same verified metadata scanner and stripper as the generic
`metadata` command, so detection and removal stay in parity. Video and audio
+4
View File
@@ -42,6 +42,10 @@ Important contracts:
- `invisible` writes no output when no supported local signal is found, unless
`--force` is supplied.
- The two no-signal conditions currently share exit code `2`.
- 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
metadata proxy is gone, so neither outcome is a clean verdict.
- Hard processing and write failures exit with code `1`.
- `all` can still write the completed visible and metadata stages when the
diffusion dependencies are unavailable, but exits with code `1` so the
+19
View File
@@ -904,10 +904,27 @@ def cmd_invisible(
# ── Metadata operations ──
def _print_metadata_not_a_clean_verdict() -> None:
"""Repeat the identify empty-scan limit on metadata check and strip success.
``metadata --check`` and ``metadata --remove`` answer a narrower question than
``identify``: they report embedded AI metadata only. A quiet result used to
stop at "No AI metadata found" / "AI metadata stripped", which readers treat
as a clean-image verdict. The pixel channel is unchanged, and this project has
no local SynthID decoder, so the command must say so in the same words
``identify`` already uses.
"""
console.print(
" This is not the same as 'clean': a pixel watermark such as SynthID cannot be\n"
" detected here once its metadata proxy is absent."
)
def _print_metadata_report(source: Path, has_ai: bool, metadata: dict[str, str]) -> None:
"""Render one metadata inspection result for the generic and video commands."""
if not has_ai:
console.print(f" No AI metadata found in {source.name}")
_print_metadata_not_a_clean_verdict()
return
console.print(f" Warning: AI metadata detected in {source.name}:")
@@ -974,6 +991,7 @@ def cmd_metadata(
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()
# ── Video pipeline ──
@@ -1115,6 +1133,7 @@ def cmd_video_metadata(
console.print(f" still present: {', '.join(sorted(result.remaining))}")
raise SystemExit(1)
console.print(f" AI metadata stripped -> {result.output}")
_print_metadata_not_a_clean_verdict()
@cmd_video.command("invisible")
+3
View File
@@ -629,11 +629,13 @@ class TestMetadataCommand:
result = runner.invoke(main, ["metadata", str(tmp_clean_png), "--check"])
assert result.exit_code == 0
assert "No AI metadata" in result.output
assert "not the same as 'clean'" in result.output
def test_metadata_check_ai(self, runner, tmp_png_with_ai_metadata):
result = runner.invoke(main, ["metadata", str(tmp_png_with_ai_metadata), "--check"])
assert result.exit_code == 0
assert "AI metadata detected" in result.output
assert "not the same as 'clean'" not in result.output
def test_metadata_remove(self, runner, tmp_png_with_ai_metadata, tmp_path):
output = tmp_path / "stripped.png"
@@ -649,6 +651,7 @@ class TestMetadataCommand:
)
assert result.exit_code == 0
assert "stripped" in result.output
assert "not the same as 'clean'" in result.output
def test_metadata_remove_reports_failure_when_the_strip_was_a_no_op(self, runner, tmp_path):
"""A file PIL cannot decode is copied through UNCHANGED by the fail-safe.
+2
View File
@@ -951,6 +951,7 @@ class TestVideoMetadataCli:
assert result.exit_code == 0, result.output
assert "AI metadata detected" in result.output
assert "not the same as 'clean'" not in result.output
def test_remove_reports_output(self, tmp_path: Path):
runner = CliRunner()
@@ -961,6 +962,7 @@ class TestVideoMetadataCli:
assert result.exit_code == 0, result.output
assert "AI metadata stripped" in result.output
assert "not the same as 'clean'" in result.output
assert C2PA_UUID not in output.read_bytes()
def test_rejects_image_input(self, tmp_clean_png: Path):