diff --git a/docs/cli.md b/docs/cli.md index 6184790..6f33868 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 diff --git a/docs/module-internals.md b/docs/module-internals.md index a2cc872..0966384 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -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 diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index 1f15a3d..5e475a4 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -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") diff --git a/tests/test_cli.py b/tests/test_cli.py index bb6a259..8bdc26a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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. diff --git a/tests/test_video.py b/tests/test_video.py index 2b2cbb9..9600fb3 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -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):