diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 49f6a64..eafbf2e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "Agent skill and Claude Code plugin for the remove-ai-watermarks CLI.", - "version": "1.0.0" + "version": "1.0.1" }, "plugins": [ { @@ -16,4 +16,4 @@ "strict": false } ] -} \ No newline at end of file +} diff --git a/docs/agent-skill.md b/docs/agent-skill.md index 62c862a..c5a6dec 100644 --- a/docs/agent-skill.md +++ b/docs/agent-skill.md @@ -63,10 +63,13 @@ Do these in order after the skill is on the default branch. 7. **OpenAI ChatGPT / Codex plugin directory.** Skills-only plugins are a supported submission type at [platform.openai.com/plugins](https://platform.openai.com/plugins). This is - the largest consumer surface that actually reviews listings. It needs a - verified Platform identity, public website / support / privacy / terms URLs, - starter prompts, and five positive plus three negative test cases. Claude - marketplace approval does not transfer. Convert with + the largest consumer surface that actually reviews listings. It needs Apps + Management write access, a verified Platform identity, public website / + support / privacy / terms URLs, starter prompts, and five positive plus three + negative test cases. Claude marketplace approval does not transfer. Contact + your OpenAI partner before submitting: this skill's core value requires local + execution, arbitrary file access, and optional hardware access, which the + official guide routes through that product-specific review. Convert with [Submit your Claude Code plugin to OpenAI](https://developers.openai.com/plugins/guides/submit-claude-plugin). Do not treat Smithery or other MCP directories as skill catalogs. Do not open diff --git a/docs/installation.md b/docs/installation.md index a8df707..e10148c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -32,12 +32,15 @@ brew install wiltodelta/tap/remove-ai-watermarks ``` A formula cannot carry extras, so this installs the default package: metadata -commands work, and `visible`, `erase`, `all`, `batch` and every `video` command -stop with an install hint naming the extra that fixes them. `identify` still +commands work, including `video metadata`, and `video identify --no-visible` +provides metadata-only provenance inspection. `batch --mode metadata` and +`video batch --mode metadata` also work. `visible`, `erase`, `all`, pixel-processing +image batch modes, full `video identify`, and the video pixel-processing commands +stop with an install hint naming the extra that fixes them. Image `identify` also runs, but reports metadata only and says so in its caveats. `metadata --remove` -also works on a file whose markers survive the first strip: the raster -normalization that recovery needs is skipped rather than crashing, and the -survivors are reported. Reinstall with uv or pipx to add the extra. +works on a file whose markers survive the first strip: the raster normalization +that recovery needs is skipped rather than crashing, and the survivors are +reported. Reinstall with uv or pipx to add the extra. ## Visible watermark removal diff --git a/docs/module-internals.md b/docs/module-internals.md index fb4a5ac..57a984e 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -57,11 +57,12 @@ Important contracts: default package ships without those dependencies and the Homebrew formula installs exactly that build, so a first run following the project's own instructions used to die on `ModuleNotFoundError: No module named 'cv2'`. - `_pixels_required` wraps the four image commands (the video ones reach the same - build through `video._require_video_runtime`, which names the `video` extra -- - the one that actually makes them work); `remove_batch` re-raises the same - ImportError instead of counting it once per file, because one absent package - is not N broken images. All three sites ask `optional_deps.pixels_available()` + `_pixels_required` wraps the four image commands. Video pixel-processing paths + reach the same build through `video._require_video_runtime`, which names the + `video` extra, while `video metadata` and `video identify --no-visible` bypass + the pixel runtime. `remove_batch` re-raises the same ImportError instead of + counting it once per file, because one absent package is not N broken images. + All three image sites ask `optional_deps.pixels_available()` rather than matching the exception's module name: an ImportError raised inside `cv2/__init__.py` carries `cv2.cv2` and a numpy ABI mismatch carries `None`, so a name match let exactly the tracebacks this guard exists for through. diff --git a/skills/.claude-plugin/plugin.json b/skills/.claude-plugin/plugin.json index fb6a78b..f05982d 100644 --- a/skills/.claude-plugin/plugin.json +++ b/skills/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "remove-ai-watermarks", "displayName": "Remove AI Watermarks", - "version": "1.0.0", + "version": "1.0.1", "description": "Identify and remove AI provenance marks from images and video the user generated or edited, using the remove-ai-watermarks CLI.", "author": { "name": "wiltodelta", diff --git a/skills/remove-ai-watermarks/SKILL.md b/skills/remove-ai-watermarks/SKILL.md index 9c990aa..6d267fe 100644 --- a/skills/remove-ai-watermarks/SKILL.md +++ b/skills/remove-ai-watermarks/SKILL.md @@ -5,7 +5,7 @@ license: Apache-2.0 compatibility: Python 3.11-3.14. Invisible image removal needs NVIDIA CUDA. Some video writes need ffmpeg. Installer may be uv, pipx, or pip. metadata: author: wiltodelta - version: "1.0.0" + version: "1.0.1" homepage: https://raiw.cc repository: https://github.com/wiltodelta/remove-ai-watermarks --- @@ -69,9 +69,11 @@ writes itself, because a CLI that exists is not a CLI that works. Read the JSON. Do not infer CUDA, ffmpeg, or an installer from the OS name. - `pixel_stack` = `missing`: the installed build has no pixel dependencies. - Only `identify` and `metadata` answer. `identify` skips the visible detectors - and says so in its caveats, so read that caveat rather than its quiet verdict. - Go to step 3. + Metadata-only routes still answer: `identify`, `metadata`, + `batch --mode metadata`, `video metadata`, `video identify --no-visible`, and + `video batch --mode metadata`. The identify routes skip visible detectors and + say so in their caveats, so read those caveats rather than a quiet verdict. Go + to step 3 before running a pixel command. - `pixel_stack` = `unknown`: the check gave an unrecognized result. Do not claim a command will work; run it and read the real error. - `advice.upgrade_cli` present: the installed CLI is older than this skill's @@ -95,9 +97,10 @@ removal, it is paid and runs on a GPU. Do not quote a price: check the site. Install when `cli.found` is false OR `pixel_stack` is `missing`. A present binary is not enough: the default package, which is what Homebrew installs, -carries no pixel dependencies, so `visible`, `erase`, `all`, `batch` and every -`video` command stop with an install hint, and `identify` sees metadata only. -Reinstalling with the extra fixes it. +carries no pixel dependencies. `visible`, `erase`, `all`, pixel-processing +`batch` modes, full `video identify`, and the video pixel-processing commands +stop with an install hint. The metadata-only routes listed in step 2 still work. +Reinstalling with the appropriate extra fixes the pixel commands. Read [references/install.md](references/install.md) for the extra list and the uv / pipx / pip commands. Default extra is `visible`. Always quote extras: @@ -109,13 +112,13 @@ Default: inspect first. | User goal | Command | Stop if probe says | | --- | --- | --- | -| What is on this file? | `identify` or `video identify` | | +| What is on this file? | `identify` or `video identify` | use `video identify --no-visible` when `pixel_stack=missing` | | Known visible AI label on an image | `visible` | | | User-supplied box on their own image | `erase` | | | AI metadata only | `metadata` or `video metadata` | | | Known visible AI label on video | `video visible` or `video all` | `video_visible=needs_ffmpeg` | | Invisible image / image SynthID | `invisible` | `invisible_images=unavailable_no_cuda` | -| Video SynthID | `video invisible` | `video_invisible=needs_ffmpeg` | +| Video SynthID | `video invisible` | `video_invisible=needs_video_and_diffusion_extras` or `needs_ffmpeg` | | Everything on an image | `all` | `image_all=do_not_run_writes_nothing` | | A directory | `batch` or `video batch` | same as the mode | | No signal found, user still wants a guess | `classify` | it is a guess, not provenance | diff --git a/skills/remove-ai-watermarks/references/install.md b/skills/remove-ai-watermarks/references/install.md index 7b0e598..84156eb 100644 --- a/skills/remove-ai-watermarks/references/install.md +++ b/skills/remove-ai-watermarks/references/install.md @@ -31,10 +31,12 @@ python3 -m pip install --upgrade "remove-ai-watermarks[visible]" On Windows, `python` may exist when `python3` does not. Homebrew is macOS/Linux only and CANNOT carry extras: the formula installs the -default package, which has no pixel dependencies, so `visible`, `erase`, `all`, -`batch` and the `video` commands stop with an install hint, and `identify` -reports metadata only. That is exactly what `pixel_stack: missing` means in the -probe. Use it for metadata-only work, +default package, which has no pixel dependencies. `visible`, `erase`, `all`, +pixel-processing `batch` modes, full `video identify`, and video pixel-processing +commands stop with an install hint. `identify`, `metadata`, +`batch --mode metadata`, `video metadata`, `video identify --no-visible`, and +`video batch --mode metadata` still provide metadata-only results. That is exactly +what `pixel_stack: missing` means in the probe. Use Homebrew for metadata-only work, or when the user asks for brew by name and accepts that limit: ```bash diff --git a/skills/remove-ai-watermarks/scripts/probe.py b/skills/remove-ai-watermarks/scripts/probe.py index 7b6f902..afcdf35 100644 --- a/skills/remove-ai-watermarks/scripts/probe.py +++ b/skills/remove-ai-watermarks/scripts/probe.py @@ -224,26 +224,29 @@ def _advice( "detail": f"Install with {installer}. Default extra is visible.", } - # A found CLI without the pixel stack can still read metadata, and NOTHING else. - # Reporting these as "ok" is what sent an agent into a missing-cv2 crash on a - # machine the probe had just called ready. + # A found CLI without the pixel stack can still use the metadata-only image and + # video routes. Reporting pixel commands as "ok" is what sent an agent into a + # missing-cv2 crash on a machine the probe had just called ready. no_pixels = pixels == "missing" advice = { "identify": "metadata_only_no_pixels" if no_pixels else "ok", "visible": "needs_visible_extra" if no_pixels else "ok", "metadata": "ok", - "video_visible": "needs_visible_extra" if no_pixels else ("ok" if ffmpeg else "needs_ffmpeg"), + "video_identify": "metadata_only_no_pixels" if no_pixels else "ok", + "video_metadata": "ok", + "video_visible": "needs_video_extra" if no_pixels else ("ok" if ffmpeg else "needs_ffmpeg"), "invisible_images": "unavailable_no_cuda" if not cuda else ("needs_visible_extra" if no_pixels else "ok"), "image_all": _image_all(no_pixels=no_pixels, cuda=cuda, invisible=invisible), - "video_invisible": "ok_cpu_or_gpu" if ffmpeg else "needs_ffmpeg", + "video_invisible": ( + "needs_video_and_diffusion_extras" if no_pixels else ("ok_cpu_or_gpu" if ffmpeg else "needs_ffmpeg") + ), "no_cuda_fallback": host, - "pixel_stack": pixels, - "invisible_stack": invisible, } if no_pixels: advice["next"] = "install_visible" advice["detail"] = ( - "The installed CLI has no pixel dependencies, so only metadata commands work. " + "The installed CLI has no pixel dependencies. Metadata-only image and video " + "commands still work, including both metadata batch modes. " f"Reinstall with the visible extra using {installer or 'uv, pipx or pip'}. " "A Homebrew install cannot carry extras." ) diff --git a/src/remove_ai_watermarks/optional_deps.py b/src/remove_ai_watermarks/optional_deps.py index d2b5cc9..1a64998 100644 --- a/src/remove_ai_watermarks/optional_deps.py +++ b/src/remove_ai_watermarks/optional_deps.py @@ -11,11 +11,12 @@ while the actual import fails. Every ``is_available()`` guard routes through from __future__ import annotations +import importlib import importlib.util def module_available(*names: str) -> bool: - """True when every named module resolves to a real, importable package. + """True when every named module resolves to a concrete package. A spec with ``loader is None`` is a pure namespace package -- for our optional deps that means a stale directory remnant, not an installed @@ -29,7 +30,7 @@ def module_available(*names: str) -> bool: def pixels_available() -> bool: - """True when this build can decode and inpaint pixels. + """True when this build's shared pixel dependencies resolve and import. The default package ships without the ``visible`` extra -- the Homebrew formula installs exactly that build -- so every visible-mark command and the visible arm @@ -38,8 +39,17 @@ def pixels_available() -> bool: and all three then agreed only by coincidence: an ``ImportError`` raised INSIDE ``cv2/__init__.py`` carries ``name == "cv2.cv2"`` and a numpy ABI mismatch carries ``name is None``, so a name match answered "that is some other import - error" and let the traceback through. + error" and let the traceback through. A concrete spec alone is also not enough: + importing cv2 can fail when its NumPy ABI is broken, so this predicate performs + the real imports on the error path that asks it. """ from remove_ai_watermarks._internal.watermark_profiles import PIXELS_MODULES - return module_available(*PIXELS_MODULES) + if not module_available(*PIXELS_MODULES): + return False + try: + for name in PIXELS_MODULES: + importlib.import_module(name) + except ImportError: + return False + return True diff --git a/tests/test_agent_skill.py b/tests/test_agent_skill.py index ad51116..b08eae0 100644 --- a/tests/test_agent_skill.py +++ b/tests/test_agent_skill.py @@ -14,6 +14,9 @@ ROOT = Path(__file__).resolve().parents[1] SKILL = ROOT / "skills" / "remove-ai-watermarks" / "SKILL.md" PLUGIN = ROOT / "skills" / ".claude-plugin" / "plugin.json" MARKETPLACE = ROOT / ".claude-plugin" / "marketplace.json" +AGENT_DOC = ROOT / "docs" / "agent-skill.md" +INSTALL_DOC = ROOT / "docs" / "installation.md" +INSTALL_REFERENCE = ROOT / "skills" / "remove-ai-watermarks" / "references" / "install.md" _NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") @@ -349,6 +352,74 @@ def test_probe_reads_capability_not_presence() -> None: assert any(call[1:2] == ["visible"] for call in calls), "the probe never exercised the pixel path" +def test_probe_keeps_metadata_only_video_routes_without_pixels() -> None: + probe = _probe_module() + report = _probe_with_fake_cli( + probe, + {"--version": _current_version_reply(probe), "visible": _NO_PIXELS_REPLY, "invisible": _NO_PIXELS_REPLY}, + ) + + assert report["advice"]["video_identify"] == "metadata_only_no_pixels" + assert report["advice"]["video_metadata"] == "ok" + assert report["advice"]["video_visible"] == "needs_video_extra" + assert report["advice"]["video_invisible"] == "needs_video_and_diffusion_extras" + assert "pixel_stack" not in report["advice"] + assert "invisible_stack" not in report["advice"] + + +def test_skill_and_install_guide_name_pixel_free_video_routes() -> None: + texts = [ + SKILL.read_text(encoding="utf-8"), + INSTALL_REFERENCE.read_text(encoding="utf-8"), + INSTALL_DOC.read_text(encoding="utf-8"), + ] + + for command in ( + "`batch --mode metadata`", + "`video metadata`", + "`video identify --no-visible`", + "`video batch --mode metadata`", + ): + assert all(command in text for text in texts) + assert all("every `video` command" not in text for text in texts) + assert all("the `video` commands stop" not in text for text in texts) + + +def test_metadata_only_routes_run_without_the_pixel_stack(tmp_path: Path, monkeypatch) -> None: + from click.testing import CliRunner + + from remove_ai_watermarks.cli import main + + image_dir = tmp_path / "images" + image_dir.mkdir() + (image_dir / "input.png").write_bytes( + (ROOT / "data" / "fixtures" / "provenance" / "ideogram-c2pa.png").read_bytes() + ) + video = ROOT / "data" / "fixtures" / "visible" / "sora" / "example.mp4" + video_dir = tmp_path / "videos" + video_dir.mkdir() + (video_dir / "input.mp4").write_bytes(video.read_bytes()) + monkeypatch.setitem(sys.modules, "cv2", None) + + for args in ( + ["batch", str(image_dir), "--mode", "metadata", "-o", str(tmp_path / "clean-images")], + ["video", "metadata", str(video), "--check"], + ["video", "identify", str(video), "--no-visible"], + ["video", "batch", str(video_dir), "--mode", "metadata", "-o", str(tmp_path / "clean-videos")], + ): + result = CliRunner().invoke(main, args, catch_exceptions=False) + assert result.exit_code == 0, result.output + + +def test_openai_submission_names_the_partner_gate() -> None: + text = " ".join(AGENT_DOC.read_text(encoding="utf-8").split()) + + assert "Contact your OpenAI partner before submitting" in text + assert "local execution" in text + assert "arbitrary file access" in text + assert "hardware access" in text + + def test_probe_calls_a_working_install_ok() -> None: probe = _probe_module() report = _probe_with_fake_cli( diff --git a/tests/test_optional_deps.py b/tests/test_optional_deps.py index f69c88c..7504187 100644 --- a/tests/test_optional_deps.py +++ b/tests/test_optional_deps.py @@ -2,10 +2,14 @@ from __future__ import annotations +import importlib import importlib.machinery import importlib.util +import pytest + from remove_ai_watermarks import optional_deps +from remove_ai_watermarks._internal.watermark_profiles import PIXELS_MODULES, VISIBLE_EXTRA def _fake_find_spec(specs: dict[str, importlib.machinery.ModuleSpec | None]): @@ -49,6 +53,44 @@ class TestModuleAvailable: assert optional_deps.module_available("json", "logging") is True +class TestPixelsAvailable: + @pytest.mark.parametrize("broken_name", PIXELS_MODULES) + def test_import_failure_inside_present_package_is_unavailable(self, broken_name, monkeypatch): + monkeypatch.setattr(optional_deps, "module_available", lambda *_names: True) + imported: list[str] = [] + + def import_module(name: str): + imported.append(name) + if name == broken_name: + raise ImportError("numpy.core.multiarray failed to import") + return object() + + monkeypatch.setattr(importlib, "import_module", import_module) + + assert optional_deps.pixels_available() is False + assert broken_name in imported + + def test_cli_guard_converts_an_internal_import_failure_to_the_install_hint(self, capsys, monkeypatch): + from remove_ai_watermarks.cli import _pixels_required + + monkeypatch.setattr(optional_deps, "module_available", lambda *_names: True) + + def import_module(_name: str): + raise ImportError("numpy.core.multiarray failed to import") + + monkeypatch.setattr(importlib, "import_module", import_module) + + @_pixels_required + def broken_command() -> None: + raise ImportError("numpy.core.multiarray failed to import") + + with pytest.raises(SystemExit) as caught: + broken_command() + + assert caught.value.code == 1 + assert VISIBLE_EXTRA in capsys.readouterr().out + + class TestGuardsUseSharedHelper: def test_trustmark_is_available_rejects_namespace_remnant(self, monkeypatch): from remove_ai_watermarks import trustmark_detector