mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 05:20:46 +02:00
Fix agent skill dependency checks and metadata guidance
This commit is contained in:
11 files changed
+180
-42
No files matched your search
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user