Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4241a01659 | ||
|
|
c37378b347 | ||
|
|
8cb99b73b2 | ||
|
|
c45e6db3e4 | ||
|
|
37fb080c74 | ||
|
|
954298aa14 | ||
|
|
10c7775990 | ||
|
|
3f6447b23d | ||
|
|
cc63bfd7bf | ||
|
|
898f8be789 | ||
|
|
72752feb33 | ||
|
|
70adddf960 | ||
|
|
01a2665181 | ||
|
|
0c3e0fa415 | ||
|
|
b3b868153f | ||
|
|
304bd071c0 | ||
|
|
2e26df1f21 | ||
|
|
d800ec9455 | ||
|
|
ef7b972ed1 | ||
|
|
b96ede6f44 | ||
|
|
ca59c032a1 | ||
|
|
c5e138357e | ||
|
|
21661bea35 | ||
|
|
5cb4f2f808 | ||
|
|
33f748b32a | ||
|
|
742a0dccab | ||
|
|
b5fc3ef9a6 | ||
|
|
6eb1a94799 | ||
|
|
32791ee832 | ||
|
|
a103b4ec15 | ||
|
|
7341e1a648 | ||
|
|
dfbaa018c6 | ||
|
|
e310a22bd8 | ||
|
|
f855c4700f | ||
|
|
5a0f771ed7 | ||
|
|
4931b93097 | ||
|
|
509ce633a3 | ||
|
|
b02eb12cb2 | ||
|
|
fb92c7dbc8 | ||
|
|
7ba07869d2 | ||
|
|
d25cada76b | ||
|
|
b1f15818d7 | ||
|
|
317124ee85 | ||
|
|
0d468b6022 | ||
|
|
1dc70df289 | ||
|
|
6f629a4ea6 | ||
|
|
e3bbe88155 | ||
|
|
be8f24c7cb | ||
|
|
9990f3e6ca | ||
|
|
83be3a8926 | ||
|
|
9d29d2c777 | ||
|
|
7a6491fdc1 | ||
|
|
37431c9aeb | ||
|
|
804735aa48 | ||
|
|
e3f714dcd2 | ||
|
|
7169780dd0 | ||
|
|
c7021b900b | ||
|
|
3f55df6f99 | ||
|
|
4795149d34 | ||
|
|
cf30b4f95a | ||
|
|
8ce8bf14da | ||
|
|
3fb94b220d | ||
|
|
d2c5aa66fc | ||
|
|
380820fa7e | ||
|
|
12e9a87865 | ||
|
|
19fccf27cb | ||
|
|
a385aab433 | ||
|
|
1d7a6270d4 | ||
|
|
b5cd16aef0 | ||
|
|
bf63e8e187 | ||
|
|
d4181f3277 | ||
|
|
bd14f40c7d | ||
|
|
c4edc2820d | ||
|
|
927429d349 | ||
|
|
df94f2c908 | ||
|
|
b2e46ee8db | ||
|
|
b03a65bcb3 | ||
|
|
f2f840197e | ||
|
|
242d78957b | ||
|
|
78426aeef7 | ||
|
|
d914e3ac08 | ||
|
|
c903346a6b | ||
|
|
7cf067d3a5 | ||
|
|
15cae6fc91 | ||
|
|
d3a042209c | ||
|
|
1386708dec | ||
|
|
67665c5cea | ||
|
|
fc54f25948 | ||
|
|
f020115ca2 | ||
|
|
62be1ef456 | ||
|
|
4d7c2b458e | ||
|
|
96b8ab9ae2 | ||
|
|
5b30f0169a | ||
|
|
003dabfae4 | ||
|
|
4c090c5ae2 | ||
|
|
8c025985f7 | ||
|
|
5f1dea95f4 | ||
|
|
00d9771c6a | ||
|
|
de03748849 | ||
|
|
e556ed032a | ||
|
|
557f776564 | ||
|
|
36fed0594e | ||
|
|
5c6588b3ef | ||
|
|
f127ecbe57 | ||
|
|
56afcab241 | ||
|
|
4428aedce6 | ||
|
|
3daca46589 | ||
|
|
15f5fee7c3 | ||
|
|
3ee858e195 | ||
|
|
cd225f2af9 | ||
|
|
e85307f1c0 | ||
|
|
5a875daded | ||
|
|
c9672efd46 | ||
|
|
8562c457a4 | ||
|
|
17c034f140 | ||
|
|
5b12f61db8 | ||
|
|
ee19173338 | ||
|
|
98d3df9f5a | ||
|
|
464fd142ea | ||
|
|
1cfdf192d8 | ||
|
|
b2496134c2 | ||
|
|
723f2949ec | ||
|
|
e645fac166 | ||
|
|
4077db8b57 | ||
|
|
8eec0e5af4 | ||
|
|
7ddd2097bd | ||
|
|
c0fec5de19 | ||
|
|
bf7f556876 | ||
|
|
f89975355d | ||
|
|
68b56ece37 | ||
|
|
dc8491a6c6 | ||
|
|
4e9145f739 | ||
|
|
0d6667cf22 | ||
|
|
646f72534b | ||
|
|
922f6faa7a | ||
|
|
f0b425ac71 | ||
|
|
8ba2666a30 | ||
|
|
c1bf835e2f | ||
|
|
efdbfa0598 | ||
|
|
9f7b707016 | ||
|
|
697e11f2ab | ||
|
|
efeec825c1 | ||
|
|
507d7c43a3 | ||
|
|
a1022d33c3 | ||
|
|
5b52782f01 | ||
|
|
0761937d60 | ||
|
|
b8a8c3d3be | ||
|
|
0fd139aac4 | ||
|
|
ecbe296b31 | ||
|
|
09960676fc | ||
|
|
2bc37738d9 | ||
|
|
637395b8a6 | ||
|
|
1e71f0523d | ||
|
|
e41bdbc9c8 | ||
|
|
cbb5155784 | ||
|
|
f3270165db | ||
|
|
11026ec0c6 | ||
|
|
d391cc42d1 | ||
|
|
11fb16f19c | ||
|
|
7ab38a85b6 | ||
|
|
09a4b20ac6 | ||
|
|
9bb4e9cd54 | ||
|
|
5052dbe63c | ||
|
|
2fe4d3c3a0 | ||
|
|
adbc6a3046 | ||
|
|
326840bab4 | ||
|
|
29de21256b | ||
|
|
07b07634cf | ||
|
|
54a19059d5 | ||
|
|
5e85f3ac6f | ||
|
|
36c36212f0 | ||
|
|
84ea8ede2e | ||
|
|
8f30f8bdb3 | ||
|
|
925401c905 | ||
|
|
55e7c53699 | ||
|
|
2ff06d4c5a | ||
|
|
1410a8e064 | ||
|
|
11ca7d6054 | ||
|
|
5991894ca5 | ||
|
|
5fa14f9e26 | ||
|
|
f7e0a18e6c | ||
|
|
bc5922451f | ||
|
|
798c94e6ae | ||
|
|
67d948f57d | ||
|
|
1d0e6b54dd | ||
|
|
fa4748a6a8 | ||
|
|
1e9572e2d4 | ||
|
|
1a6f50db75 | ||
|
|
b684423fc4 | ||
|
|
16bc9879e9 | ||
|
|
343da7f87d | ||
|
|
6f2d50cde8 | ||
|
|
95f467b4ad | ||
|
|
34505e5cc2 | ||
|
|
01539f5f58 | ||
|
|
23696f42a8 | ||
|
|
08a4a8d299 | ||
|
|
86ce55ced6 | ||
|
|
ee561308ef | ||
|
|
a8d4bce14a | ||
|
|
17408b958e | ||
|
|
834342db07 | ||
|
|
dcc1bf5e07 | ||
|
|
d8fcd0f79b | ||
|
|
0a3b227f2c | ||
|
|
f91f9fd9fc | ||
|
|
c4aecfbbdf | ||
|
|
ab528ec0e8 | ||
|
|
a1811b6221 | ||
|
|
e09104e39f | ||
|
|
29f17105de | ||
|
|
2810b53585 | ||
|
|
ba5710836b | ||
|
|
c927560614 | ||
|
|
f83da5aee4 | ||
|
|
251ba9ac79 | ||
|
|
c5764661b4 | ||
|
|
97cba11efe | ||
|
|
809d980fb7 | ||
|
|
10da166f3e | ||
|
|
8871eb3f67 | ||
|
|
44ec203c22 | ||
|
|
4beb61463a | ||
|
|
0d11e4099c | ||
|
|
fa83c1444f | ||
|
|
f52e40ae24 | ||
|
|
0aa03c4e89 | ||
|
|
91aa49b03a | ||
|
|
ee865e95e4 | ||
|
|
d84fd1a125 | ||
|
|
e315eacb14 | ||
|
|
3ba4ace7e0 | ||
|
|
13888704f8 | ||
|
|
e938b57f8c | ||
|
|
3cd1e47935 | ||
|
|
2298282b8b | ||
|
|
42d427731f | ||
|
|
50d9f44829 | ||
|
|
2e1428406e | ||
|
|
21ce1dfadd | ||
|
|
2369d351a9 | ||
|
|
432b63b6d7 | ||
|
|
8c00525946 | ||
|
|
f8003f505a | ||
|
|
2eab24a2e1 | ||
|
|
8201ada070 | ||
|
|
b7d44ff1a3 | ||
|
|
e8f65022cc | ||
|
|
4855586834 | ||
|
|
37789e02f6 | ||
|
|
2d03a00a39 | ||
|
|
acbf6afac3 | ||
|
|
25eaa7458c | ||
|
|
58146dbc6d | ||
|
|
b08899dc00 | ||
|
|
b1319b1d5a | ||
|
|
353bc5f12c | ||
|
|
370556ffe6 | ||
|
|
14a27d224d |
No files matched your search
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "remove-ai-watermarks",
|
||||
"owner": {
|
||||
"name": "wiltodelta",
|
||||
"url": "https://github.com/wiltodelta"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Agent skill and Claude Code plugin for the remove-ai-watermarks CLI.",
|
||||
"version": "1.0.11"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "remove-ai-watermarks",
|
||||
"description": "Identify and remove AI provenance marks from images and video the user generated or edited. Use when the user asks to strip Gemini sparkles, SynthID, Microsoft InvisMark, Meta Content Seal, C2PA, EXIF/XMP AI metadata, Sora/Veo/Seedance/Doubao/Dola/Hailuo/Kling labels, or other AI watermarks, or asks whether a file was AI-generated. Do not use for stock-agency, marketplace, classifieds, or other third-party paid-asset watermarks.",
|
||||
"source": "./skills",
|
||||
"strict": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
---
|
||||
globs: ["src/**/*.py", "tests/**/*.py", "scripts/**/*.py", "pyproject.toml", "uv.lock", "maintain.sh", ".github/workflows/*.yml"]
|
||||
description: Command contracts, project gate, typing boundaries, model-adjacent test invariants, and the detection-path measurement rule.
|
||||
paths:
|
||||
- "src/**/*.py"
|
||||
- "tests/**/*.py"
|
||||
- "scripts/**/*.py"
|
||||
- "skills/**"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- "maintain.sh"
|
||||
- ".github/workflows/*.yml"
|
||||
description: Command contracts, project gate, typing boundaries, model-adjacent test invariants, the agent-skill parity seam, and the detection-path measurement rule.
|
||||
---
|
||||
|
||||
# Development invariants
|
||||
@@ -9,11 +17,61 @@ 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.
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
Provider verification services and provenance APIs are development-only oracles. Keep
|
||||
their SDKs in the development extra and their adapters out of the installed CLI,
|
||||
top-level Python API, and runtime detection/removal pipelines.
|
||||
|
||||
The unified maintainer entry point is `scripts/provider_oracles.py`; its workflow and
|
||||
slot schema live in `docs/provider-oracles.md`. Multi-account and multi-network work
|
||||
selects each named slot explicitly from the gitignored repository-local
|
||||
`.oracle-slots.json` by default and snapshots it into a separate immutable batch
|
||||
manifest. A bounded provider check may continue through its finite set of authorized
|
||||
account slots after a refusal; do not add new logins, IP rotation, network-route
|
||||
failover, unbounded retries, or cross-session result merging. Never commit the local
|
||||
slot file.
|
||||
Every manual result stays bound to the exact sanitized upload hash and preserves the
|
||||
provider response verbatim.
|
||||
|
||||
Provider surface selection is API-first. OpenAI and Microsoft use their official API
|
||||
adapters before their Web surfaces when the requested media and local configuration are
|
||||
supported. Google and Meta currently use Web because this tooling has no usable API route
|
||||
for them. Web remains an explicit second surface, never an automatic retry after an API
|
||||
refusal, quota response, transport error, or indeterminate verdict.
|
||||
|
||||
Google Web oracle automation uses only the user's existing authenticated real Chrome
|
||||
session and explicit `/u/N/` account index. OpenAI, Microsoft, and Meta Web automation
|
||||
uses isolated Playwright contexts; it must never attach to or copy state from the user's
|
||||
real browser. Proxy credentials are read only through a slot's named environment variable
|
||||
and never enter a manifest or log. Dotenv interpolation may derive routed proxy URLs from
|
||||
one secret key without mutating the process environment. A selected proxy context may
|
||||
accept that proxy's TLS interception certificate; direct contexts keep normal TLS checks.
|
||||
ThorData rotating endpoints are pinned to one manifest-derived sticky session for the
|
||||
life of a Web batch so one browser context does not change exit identity between page
|
||||
requests. This is route continuity, not account or IP failover; a refusal still ends on
|
||||
the selected slot and never selects another route automatically.
|
||||
Upload readiness is surface-specific: OpenAI reaches it at page load but keeps background
|
||||
requests alive, while Microsoft and Meta require network idle. Preserve exact provider
|
||||
errors as indeterminate evidence rather than retrying or interpreting them as clean.
|
||||
|
||||
OpenAI API slots likewise carry only `api_key_env`. Store multiple key values in the
|
||||
gitignored `.env`, select one slot explicitly, and never rotate to another token after a
|
||||
refusal or rate limit. The process environment may override the matching `.env` value.
|
||||
Their SDK transport sets `trust_env=False`; environment proxy variables must not reroute
|
||||
an API check.
|
||||
|
||||
Microsoft API slots use the Azure Content Safety and private Blob Storage resource
|
||||
identities from the gitignored `.env`. The adapter reads resource keys only into memory
|
||||
through authenticated Azure CLI, downloads and hashes the private blob before requiring
|
||||
equality with the named local source, keeps `trust_env=False`, never retries the submit,
|
||||
and reports Watermark independently from C2PA.
|
||||
|
||||
Do not add an option whose only outcome is an error. Model id, step count and CFG are fixed by the profile, so none of them is a parameter of the CLI, `InvisibleEngine`, or `WatermarkRemover` -- they were accepted-then-rejected for a while, which moved the failure several frames below the caller and advertised choices the pinned stack cannot honor. If a value cannot vary, delete the knob rather than validating it.
|
||||
|
||||
`device` is the deliberate exception and stays a library parameter: `None`/`"auto"` detect, `"cuda"` pins without detecting, and everything else raises at construction. It is not a CLI option, because the only value a user could usefully type is the one auto-detection already finds.
|
||||
`device` is the deliberate exception and stays a library parameter: `None`/`"auto"` detect, `"cuda"` pins without detecting, and everything else raises at construction. On the image path it is not a CLI option, because the only value a user could usefully type is the one auto-detection already finds. The video SynthID commands (`video invisible`, `video all`, `video batch`) do expose `--device`, whose VAE runs on cuda, mps, or cpu, so a user can usefully pick one. `test_device_exists_exactly_where_the_skill_says_it_does` pins that split; do not read this paragraph as licence to delete the video flag.
|
||||
|
||||
The same rule applies to install hints: name the extra that actually makes the command work (`qwen-zimage`, not `diffusion`), and keep the printed command shell-quoted -- bare `pkg[extra]` is a glob in zsh.
|
||||
|
||||
@@ -21,10 +79,48 @@ The same rule applies to install hints: name the extra that actually makes the c
|
||||
|
||||
Run `bash maintain.sh` from the repository root. The authoritative type gate is scoped to `src/`; full-project Pyright can exhaust Node memory on the ML dependency graph.
|
||||
|
||||
The full gate outlasts the agent's 10-minute foreground shell cap, which kills it mid-suite. Run it detached under `nohup caffeinate -dimu` with its exit code written to a file and poll that file. To stop a stray run, kill its own PID, never `pkill -f pytest`, which also takes down other sessions' suites on the machine.
|
||||
|
||||
The security scanner's exit status is authoritative, including a failure after a success
|
||||
message. `tests/test_maintenance.py` checks clean, vulnerable, and broken scanner
|
||||
runs through the real shell entry point, and pins the CI `security` job to the
|
||||
same scanner line with no `continue-on-error`. Recheck unresolved advisories using the
|
||||
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.
|
||||
|
||||
## Research harnesses stay out of this repository
|
||||
|
||||
A research script that needs a cloud GPU account to run is not kept here, and
|
||||
`scripts/` carries no `modal` import; `test_scripts_carry_no_cloud_gpu_harness`
|
||||
is the canary. The identifier half of the boundary audit is deliberately not
|
||||
mechanized here, because a denylist written into a tracked file publishes the
|
||||
strings it exists to keep out. Twelve such harnesses were moved out on
|
||||
2026-09-07; the pages that cite their findings
|
||||
([chroma1](../../docs/chroma1-engine-research.md),
|
||||
[module internals](../../docs/module-internals.md)) name them as harnesses kept
|
||||
outside this repository, and the tracked inputs under `data/evaluations/` plus
|
||||
the recorded verdicts are the durable record. Keep a new one outside too, and
|
||||
leave only its offline analysis step here, the way
|
||||
`scripts/analyze_engine_selection_study.py` stayed with its test.
|
||||
|
||||
## Visible-mark example gallery
|
||||
|
||||
Every registered mark carries a committed example: `data/fixtures/visible/<key>/example.png`
|
||||
(image marks) and `example.mp4` (video marks), regenerated by
|
||||
`scripts/render_visible_examples.py`. `tests/test_visible_examples.py` is the canary: it
|
||||
fails when a mark is registered without an example and when an engine stops detecting its
|
||||
own example. The examples are deterministic synthetic composites of the committed
|
||||
silhouettes. When the generator fails after a geometry or gate change, fix the generator
|
||||
(and the engine) together; do not hand-edit the binaries.
|
||||
|
||||
Visible-mark content must distinguish the registry key, the provider's canonical
|
||||
product name, and the literal text rendered in the mark whenever those differ. Verify
|
||||
external provider names against the provider's official source rather than inferring
|
||||
them from an asset filename or OCR text.
|
||||
|
||||
## Model-adjacent tests
|
||||
|
||||
Do not classify an entire module as untestable because its main path downloads a model. Keep pure behavior covered without downloads, including:
|
||||
@@ -39,6 +135,13 @@ Do not classify an entire module as untestable because its main path downloads a
|
||||
stage's; `sdxl-zimage` shipped doing exactly that and crashed on every image with a
|
||||
face. When one profile inherits another's stage, guard the invariants that stage
|
||||
relies on, not just the code path.
|
||||
- the photo-classify gate in `test_classify.py`: DEFINITELY is ridge AND MLP,
|
||||
POSSIBLY does not emit a provider, and `identify` does not import
|
||||
`remove_ai_watermarks.classify`.
|
||||
- the source-export classifier in `test_source_classify.py`: the Hub revision
|
||||
and artifact hash are immutable, the pickle-free schema and exact GELU are
|
||||
validated, threshold mutation changes the real decision, and `identify`
|
||||
never consumes the result as watermark evidence.
|
||||
- the `InvisibleOptions` defaults, in `test_api.py`. When one signature promises to
|
||||
mirror another, compare them field by field rather than pinning the values you happen
|
||||
to know about, so the next field added on one side and not the other fails at the
|
||||
@@ -65,6 +168,32 @@ that is what actually happened.
|
||||
|
||||
Use availability checks only for paths that actually load large models.
|
||||
|
||||
A verdict branch must be reachable with the dependency stack as shipped. High-confidence
|
||||
C2PA attribution required `signingCredential.trusted`, a code the reader emits only when a
|
||||
trust anchor list is loaded, and none ships -- so the branch was dead in production for
|
||||
every vendor while a hand-built info dict stamping that code kept it green from 0.27.0
|
||||
through 0.30.0. Assert reachability against the committed fixtures rather than a synthesized
|
||||
status set; `TestIdentifyRealSamples::test_no_committed_fixture_reports_a_trusted_signer`
|
||||
is that guard. Read a capability the stack does not have as a missing input, never as a
|
||||
negative finding.
|
||||
|
||||
## The docs are a seam, and they drift like one
|
||||
|
||||
`tests/test_docs_cover_the_public_surface.py` reads the live Click tree and the
|
||||
live `InvisibleOptions` dataclass and requires every knob to be NAMED in the
|
||||
user-facing pages. The code-to-code seams were already guarded and the
|
||||
code-to-docs seam was not, so it drifted exactly the way an unguarded seam does:
|
||||
`docs/cli.md` named none of `--adaptive-polish`, `--controlnet-scale`,
|
||||
`--detect/--no-detect`, `--humanize`, `--strength`, `--tile-size`,
|
||||
`--tile-overlap` or `--unsharp`, and `docs/python-api.md` named 9 of the 16
|
||||
option fields.
|
||||
|
||||
Group option spellings by the Click PARAMETER, never by string surgery on the
|
||||
names. A boolean pair can be spelled anything -- `--strip-metadata/--keep-metadata`,
|
||||
`--keep-standard/--remove-all` -- so deriving the partner from a `--no-` prefix
|
||||
silently splits those pairs and demands both halves. Either spelling documents
|
||||
the pair.
|
||||
|
||||
## One measurement, one gate seam
|
||||
|
||||
A detector is split into a trust-level-blind scan and a verdict that applies the
|
||||
@@ -79,6 +208,11 @@ rules follow, and both were broken in practice before they were written down:
|
||||
- Detection and the removal mask must read ONE sweep. The winning box travels on
|
||||
`TextMarkDetection.match_box` and the registry threads the detection into the mask
|
||||
builder; a mask path that re-runs its own sweep is how the two drift apart.
|
||||
- When the research lattice runtime in `scripts/synthid_runtime/` routes by image
|
||||
geometry, preserve the returned `SynthIDDetection.detector` in score manifests
|
||||
and downstream routers. Record an inactive expert as explicitly unsupported;
|
||||
never attribute a routed large-image score to the fixed expert. This expert is
|
||||
not part of the public package.
|
||||
|
||||
The C2PA manifest-store JSON is NOT stable across reads: the reader regenerates manifest
|
||||
URNs and instance ids. Compare the derived `c2pa_info`, never the raw store.
|
||||
@@ -98,6 +232,20 @@ record is byte-identical, and a green test suite does not establish that on its
|
||||
change that is meant to FIX detection is the exception that proves the rule: the diff
|
||||
must then be exactly the files you intended to change, named in advance.
|
||||
|
||||
When the baseline is a published release rather than the previous commit, get it with
|
||||
`uv run --isolated --no-project --with 'remove-ai-watermarks[heif]==<version>' python <script>`:
|
||||
it resolves that release from PyPI without touching the working tree or the editable
|
||||
install. Quote the extra -- bare `pkg[extra]` is a glob in zsh.
|
||||
|
||||
Two corollaries in `dwt_dct.py`, where "close enough" has an exact meaning. The bit test
|
||||
is `peak % 36 > 18.0` and for uint8 input the exact Haar value is a multiple of 0.5, so it
|
||||
lands ON the threshold once per 72 blocks and a 1-ulp difference flips real bits: leave the
|
||||
transform to `pywt` however slow it looks, because its C convolution contracts into an FMA
|
||||
that numpy has no ufunc for (`docs/module-internals.md` carries what that costs). And
|
||||
`tests/test_invisible_watermark.py` is `skipif(not is_available())` -- without the `detect`
|
||||
extra the upstream-parity test never runs, so a green suite is not evidence here and the
|
||||
verdict record is not optional.
|
||||
|
||||
## A certified operating point is data, not a constant
|
||||
|
||||
The video SynthID default is only meaningful as a row in
|
||||
@@ -117,7 +265,11 @@ path exempt from the gate it exists to feed, which is exactly backwards.
|
||||
Prove a video-path refactor the same way the detection path is proven, and without
|
||||
needing an oracle carrier: build a clip from a tracked fixture with ffmpeg, run the
|
||||
engine before and after, and require an identical output sha256. Keep the generated
|
||||
media outside the repository.
|
||||
media outside the repository. Bind that comparison to one synced environment: a
|
||||
full-extras `uv sync` was observed to change re-encoded H.264 packet bytes with
|
||||
no code change while every detection stayed identical
|
||||
(`docs/audio-provenance-experiment.md`), so a moved video hash across an
|
||||
environment change is not by itself a regression signal.
|
||||
|
||||
Frame sampling is compared as `timestamp + 1e-9 >= next_sample_time`, so mutating
|
||||
that `>=` to `>` changes nothing and a green suite proves nothing. Mutate the phase
|
||||
@@ -128,3 +280,35 @@ frame-count check cannot see and what
|
||||
catch.
|
||||
|
||||
Environment setup, dependency recovery, CI behavior, and fixture policy: [`../../docs/development.md`](../../docs/development.md).
|
||||
|
||||
## Audit regression contracts
|
||||
|
||||
Image output paths must describe the artifact published by the current call,
|
||||
including skipped invisible stages and in-place writes. Carry explicit EXIF
|
||||
decode state through image staging; equal dimensions do not prove that a
|
||||
mirror or 180-degree rotation was applied. Metadata collection must propagate
|
||||
late-read failures into an incomplete record, and stream-copy metadata removal
|
||||
must map every input stream before atomically publishing the output.
|
||||
|
||||
Research observations must describe the decoded, hash-bound saved artifact.
|
||||
Keep period selection independent of confirmation patches, require explicit
|
||||
negative evidence, and deduplicate source corpora by content identity rather
|
||||
than detector-score equality. Use the metric's actual denominator in its name;
|
||||
normalized edit distance is not reference-normalized character error rate.
|
||||
|
||||
Tests that reload a module after changing environment variables must restore
|
||||
the environment before reloading the original state. Otherwise a temporary
|
||||
cache path survives teardown and later model tests silently attempt downloads.
|
||||
Dotenv configuration tests must clear every fixture variable from the invoked
|
||||
process environment. Seed conflicting inherited values to prove isolation;
|
||||
clearing only some fields lets the remaining host settings override the fixture.
|
||||
|
||||
Cross-platform tests must use host-absolute synthetic interpreter paths and
|
||||
explicit unchanged decodes when comparing stored pixels. Execute Ubuntu Bash
|
||||
workflow harnesses only on POSIX hosts with Bash; keep their pure Python checks
|
||||
active on every platform. Round floating-point image output before uint8
|
||||
conversion when quantization must preserve constant values across OpenCV builds.
|
||||
OpenCV 5 draws Hershey `putText` glyphs smaller and heavier than 4.x, so a
|
||||
calibrated template drawn with `putText` ships as a committed PNG whose recipe
|
||||
refuses any OpenCV but 4.x (`scripts/render_video_hershey_templates.py`,
|
||||
`scripts/build_openart_alpha.py`); never render one at import time.
|
||||
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch",
|
||||
"WebFetch"
|
||||
"WebFetch",
|
||||
"WebSearch"
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"pyright-lsp@claude-plugins-official": true,
|
||||
"context7@claude-plugins-official": true,
|
||||
"code-simplifier@claude-plugins-official": true,
|
||||
"claude-md-management@claude-plugins-official": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: provider-oracles
|
||||
description: Run hash-bound Google, OpenAI, Microsoft, or Meta watermark-oracle checks for this repository. Use for provider verification, multi-account Gemini checks, isolated Playwright checks, or recording oracle evidence.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Provider oracles
|
||||
|
||||
Read `docs/provider-oracles.md` before acting. Use `scripts/provider_oracles.py`
|
||||
for surface discovery, slot validation, upload preparation, result recording,
|
||||
and integrity verification.
|
||||
|
||||
Named slots live in the gitignored repository-local
|
||||
`.oracle-slots.json`. Never commit that file. Pass `--slot NAME` to use it;
|
||||
pass `--slots PATH` only to override the local path.
|
||||
|
||||
Never substitute one provider's negative verdict for another provider's signal.
|
||||
Do not optimize candidates adaptively against an external oracle.
|
||||
|
||||
## Account continuation
|
||||
|
||||
A request to verify a bounded set of files with a provider authorizes using that
|
||||
provider's configured account slots unless the user limits the account scope.
|
||||
Before starting, identify the finite set of available authenticated slots and
|
||||
prepare one immutable batch per slot. After a quota refusal or service failure,
|
||||
record the exact response, leave that slot's remaining rows unsubmitted, and
|
||||
continue automatically through the remaining authorized slots. Do not stop to
|
||||
request permission for each account or file already covered by the task.
|
||||
|
||||
Submit each frozen artifact at most once per slot in that pass. Preserve
|
||||
inconclusive results and disagreements between accounts. Stop when the bounded
|
||||
plan is complete or no authorized usable slot remains; report remaining rows.
|
||||
New logins, another provider, a new network route, or another pass after the plan
|
||||
is exhausted require authorization unless already requested. Account
|
||||
continuation does not authorize changing candidate pixels or erasing failures.
|
||||
|
||||
## Default surface priority
|
||||
|
||||
Prefer an official API adapter whenever that provider, media type, and configured
|
||||
slot support it. Use Web only when no usable API route exists or the operator
|
||||
explicitly requests the Web surface as a second check. For OpenAI and Microsoft,
|
||||
the default plans are API then Web. Google and Meta currently have only usable
|
||||
Web routes in this tooling. Inspect the order with `plan PROVIDER`.
|
||||
|
||||
The priority is a selection policy, not automatic failover. An API refusal,
|
||||
quota response, transport error, or indeterminate verdict must be recorded and
|
||||
reported. Do not submit the same artifact to Web unless the operator separately
|
||||
authorizes that next surface.
|
||||
|
||||
## Route by surface
|
||||
|
||||
### Google Gemini
|
||||
|
||||
Use the user's real authenticated Chrome. Do not use an isolated browser,
|
||||
Playwright CLI runner, copied cookies, or a new login.
|
||||
|
||||
1. Read `~/Documents/GitHub/claude-config/docs/browser-automation.md` and select
|
||||
the authenticated-Chrome capability.
|
||||
2. Validate the named slot. Match its `browser_profile` against the available
|
||||
real Chrome profile and navigate directly to
|
||||
`https://gemini.google.com/u/<google_account_index>/app`.
|
||||
3. Prepare an immutable `gemini-web` batch outside the repository.
|
||||
For every image check, visually inspect the source first. Remove every visible
|
||||
AI-provider logo or label with the project's visible-mark remover, derive all
|
||||
paired controls and candidates from that cleaned image, and let `prepare` strip
|
||||
AI metadata. Inspect the prepared upload again and do not submit it if a visible
|
||||
provider mark remains. A model answer based on a visible logo is not a SynthID
|
||||
result.
|
||||
4. Confirm that the prepared files and provider are covered by the user's
|
||||
request or existing upload approval. Ask only if that scope is missing or
|
||||
changed; do not request confirmation again for each file or account in the
|
||||
authorized plan. Do not upload the source path directly.
|
||||
5. Attach one prepared upload and ask: `Does this image/video/audio contain a
|
||||
SynthID watermark? Use the SynthID verification tool only; do not infer from
|
||||
visible content, logos, text, or metadata.` Do not substitute a general
|
||||
Google-AI-origin question; it permits visual-content reasoning instead of a
|
||||
SynthID verdict.
|
||||
6. Read the settled SynthID tool outcome, not the surrounding model reasoning.
|
||||
Preserve unclear, quota, and refusal wording rather than interpreting it as
|
||||
clean.
|
||||
7. Record the verbatim response and verify the completed batch.
|
||||
|
||||
If a planned Chrome profile or `/u/N/` account is not already authenticated,
|
||||
record that it is unavailable and continue with another authorized existing
|
||||
session. Ask the user only when no usable authorized session remains. Do not
|
||||
start a new login.
|
||||
|
||||
### OpenAI, Microsoft, and Meta Web
|
||||
|
||||
Use the isolated Playwright runner, never the user's real Chrome:
|
||||
|
||||
```bash
|
||||
uv run python scripts/provider_oracles.py run-web /outside/repo/batch/manifest.json \
|
||||
--acknowledge-uploads --env-file .env
|
||||
```
|
||||
|
||||
The runner is headed by default because OpenAI's Cloudflare gate blocks the
|
||||
same isolated Chromium in headless mode. Use `--headless` only when the selected
|
||||
surface has been verified to work that way. If a slot names `proxy_url_env`,
|
||||
confirm that environment variable is present; never print its value. The runner
|
||||
uses one context, one route, fresh page navigation per upload, and no retry.
|
||||
CAPTCHA, bot checks, throttling, transport failure, and unknown page wording
|
||||
remain distinct recorded outcomes.
|
||||
|
||||
Wait for the surface-specific upload readiness that the runner implements.
|
||||
OpenAI completes at page load but keeps background requests alive, while
|
||||
Microsoft and Meta require network idle. Do not replace those conditions with
|
||||
one shared wait. Treat OpenAI's exact `Something went wrong` response as a
|
||||
settled `indeterminate` result, not as a clean result or a reason to retry.
|
||||
|
||||
For ThorData residential proxy mode, keep `THORDATA_ROUTE_US`,
|
||||
`THORDATA_ROUTE_DE`, `THORDATA_ROUTE_GB`, `THORDATA_ROUTE_NL`, and
|
||||
`THORDATA_ROUTE_JP` URLs in the gitignored `.env`;
|
||||
OpenAI, Microsoft, and Meta Web slots may each name one of them through
|
||||
`proxy_url_env`. Encode an explicit two-letter ISO country code in each route,
|
||||
verify egress before relying on it, and never automatically change route after
|
||||
a refusal. Bound a batch before starting.
|
||||
|
||||
ThorData user-and-password endpoints without `sessid` rotate the exit on each
|
||||
request, which breaks a browser page whose resources must share one network
|
||||
identity. The runner therefore derives one sticky `sessid` from the immutable
|
||||
manifest hash and keeps it for that batch. A new prepared batch gets a new
|
||||
session; rerunning or resuming the same batch keeps its session while the proxy
|
||||
retains it. An explicit `sessid` in the configured route is preserved. This is
|
||||
session continuity, not permission to retry a refused batch on another route.
|
||||
|
||||
### OpenAI API
|
||||
|
||||
Use `check-openai` with one explicit upload acknowledgement. Keep named API keys
|
||||
as `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`, and `OPENAI_API_KEY_3` in the local `.env`, and select
|
||||
only their variable names through an `openai-api` slot's `api_key_env`. Never
|
||||
print a key or copy one into a slot or manifest. Continue through authorized
|
||||
named API slots according to the bounded account plan above; keep a separate
|
||||
batch for each. API slots use a direct SDK transport that ignores environment proxy
|
||||
settings. The current adapter is image-only.
|
||||
|
||||
### Microsoft API
|
||||
|
||||
Use `check-microsoft` with one explicit acknowledgement and one private Azure
|
||||
Blob HTTPS URI whose bytes exactly match the local source. Keep the Content
|
||||
Safety endpoint, subscription, account name, and both resource-group names in
|
||||
the gitignored `.env`, select a named `microsoft-api` slot, and authenticate
|
||||
first with `az login`. Give the Content Safety system identity `Storage Blob
|
||||
Data Reader` on the input storage account. The adapter reads resource keys only
|
||||
into memory through Azure CLI, never logs them, ignores environment proxy
|
||||
settings, makes one submit request with no retry, and polls only the returned
|
||||
operation. Keep Microsoft's Watermark and C2PA markers as separate verdicts.
|
||||
|
||||
## Evidence rules
|
||||
|
||||
- Upload only files under the prepared batch's `uploads/` directory.
|
||||
- Keep `manifest.json` and `manifest.sha256` immutable.
|
||||
- Record `detected`, `not_detected`, `indeterminate`, `refused`, and
|
||||
`unreachable` distinctly.
|
||||
- Record C2PA/provenance independently as `present`, `absent`, `indeterminate`,
|
||||
or `unavailable`.
|
||||
- Finish with `verify --require-complete`; report incomplete rows explicitly.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: signal-discovery
|
||||
description: Discover missing AI provenance and watermark coverage in the remove-ai-watermarks library from fresh or historical corpora, then validate detection and removal parity. Use for corpus gap mining, periodic signal audits, new-provider discovery, or deciding whether old retained data must be replayed. Do not use for ordinary per-file identification or removal.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Signal discovery
|
||||
|
||||
Work from the `remove-ai-watermarks` repository root. This skill discovers and
|
||||
closes library coverage gaps. Implementation, tests, fixtures, and
|
||||
documentation changes belong in this library; do not patch a downstream
|
||||
consumer to compensate for a detector or cleaner gap.
|
||||
|
||||
Treat retained inputs as sensitive and read-only. Keep reports, contact sheets,
|
||||
sidecars, and derived samples under the library's gitignored `.local-eval/`.
|
||||
Never commit a retained input or an artifact derived from one.
|
||||
Use only publication-cleared or synthetic files for tracked fixtures.
|
||||
|
||||
Before acting, read:
|
||||
|
||||
- `CLAUDE.md` and `.claude/rules/development.md`;
|
||||
- `docs/verification-plan.md`;
|
||||
- `docs/supported-signals.md` and `docs/watermarking-landscape.md`;
|
||||
- [references/coverage-and-cadence.md](references/coverage-and-cadence.md).
|
||||
|
||||
## Choose the run
|
||||
|
||||
Do not call `corpus_gap_scan.py` a complete signal audit. Select every mode the
|
||||
question requires:
|
||||
|
||||
1. **Fresh discovery** finds new providers, serializations, labels, and formats
|
||||
in objects added since the preceding review.
|
||||
2. **Historical replay** reruns current code on the complete retained history.
|
||||
Use it after detection-path or format-support changes and on the periodic
|
||||
backstop defined in the reference.
|
||||
3. **Removal parity** proves that every detected removable signal is cleared
|
||||
without violating the signal family's fidelity invariant.
|
||||
4. **Calibration or provider oracle** is required where the corpus has no
|
||||
self-evident answer key. Never treat a local negative as proof that a
|
||||
proprietary watermark is absent.
|
||||
|
||||
Start by inventorying the current code, not a remembered list. Enumerate image
|
||||
and video visible-mark registries, metadata readers and strippers, local
|
||||
invisible decoders, explicit invisible vendor routes, supported containers, and
|
||||
the stable signal families emitted by `identify`. Reconcile that inventory with
|
||||
the coverage matrix before interpreting any green result. A hand-written marker
|
||||
list or report family map that omits a current registry member is itself a
|
||||
finding.
|
||||
|
||||
## Run protocol
|
||||
|
||||
1. Record the library commit, version, enabled extras, corpus date range, and
|
||||
exact commands in an untracked run directory. Preserve the pre-run Git
|
||||
status so unrelated work is not attributed to the audit.
|
||||
2. Refresh the local retained corpus through its owning, external collection
|
||||
process when the task calls for fresh data. Use an explicit gitignored
|
||||
destination, do not delete local objects, and finish with a zero-delta pass.
|
||||
Fetch paired outputs only for a removal or complaint investigation.
|
||||
3. Run the applicable fresh and historical discovery modes from the reference.
|
||||
Use `corpus_gap_scan.py --since YYYY-MM-DD` for the overlapping fresh window
|
||||
and omit `--since` for the complete history. Report counts per input shape
|
||||
and per outcome; an example does not establish coverage. Give every code and
|
||||
corpus snapshot a distinct `--report` path. The adjacent
|
||||
`.progress.jsonl` checkpoint is the live progress source and makes the same
|
||||
command resumable; use `--restart` only when intentionally discarding it.
|
||||
4. Triage every candidate into one of: parser/serialization gap, missing vendor
|
||||
attribution, new visible mark, possible invisible signal, format/read error,
|
||||
expected non-AI provenance, or false candidate. Preserve unresolved items as
|
||||
unresolved.
|
||||
5. Reproduce an actionable gap with a failing test before changing code. Derive
|
||||
the smallest publication-safe fixture from a controlled source or construct
|
||||
it synthetically; do not move the retained file into a tracked path.
|
||||
6. Implement the fix in the library subsystem that owns it. Update the library
|
||||
registry, parser, remover, public docs, and published agent skill when their
|
||||
contracts move. Updating a downstream consumer to take a released version is
|
||||
a later, separately scoped task.
|
||||
7. Rerun the affected focused audit and the complete historical regression
|
||||
mode. A detection change must name the files expected to move before the
|
||||
after-run; classify lost signals separately from intended new detections.
|
||||
8. Run removal parity for every newly detected removable signal. A quiet
|
||||
post-removal detector is necessary but not always sufficient: check
|
||||
pixel/stream invariants and visually inspect residual-prone visible marks.
|
||||
9. Run `bash maintain.sh`. Report evidence limits, incomplete rows, crashes,
|
||||
unsupported formats, and oracle-unavailable cohorts explicitly.
|
||||
|
||||
External oracle uploads are separate mutations. Use the `provider-oracles`
|
||||
skill only for a bounded, already-selected candidate, and obtain the required
|
||||
action-time upload approval. Do not adapt candidates against an oracle, retry
|
||||
automatically, or substitute one provider's verdict for another's.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Coverage and cadence
|
||||
|
||||
Use this reference to choose the audit modes and decide whether current-only or
|
||||
historical data is sufficient.
|
||||
|
||||
## Coverage matrix
|
||||
|
||||
| Surface | Discovery evidence | Regression check | Removal proof |
|
||||
| --- | --- | --- | --- |
|
||||
| C2PA, EXIF, XMP, IPTC, PNG text, TC260, soft bindings, and container metadata | `scripts/corpus_gap_scan.py` plus review of unattributed and partially attributed reports | `scripts/sidecar_regression.py`; compare record-based and file-based identification when collection changes | `scripts/metadata_removal_audit.py`; signal absent after strip and decoded pixels/streams unchanged |
|
||||
| Registered visible image marks | `scripts/visible_positives.py` records strict and metadata-corroborated results, metadata cohorts, and blinded native-detail bands for uncovered cohorts plus an unbiased quiet sample | `scripts/visible_eval.py` exposes registry members without adjudicated scope, registered examples, and full-corpus crossfire | `scripts/visible_removal_audit.py`; same-mark redetection plus visual residual review and fill-quality checks |
|
||||
| Candidate China-AIGC visible marks | `scripts/vendor_cohort_harvest.py` partitions by producer identity without using pixels; inspect full-width top and bottom sheets | `scripts/vendor_mark_calibrate.py` against independently labelled positives, clean negatives, and neighbouring marks | Register only after the candidate gate survives full-corpus crossfire; then use the normal visible-removal audit |
|
||||
| Registered visible video marks | Review provider-labelled or controlled complete clips, not isolated frames alone | Full-clip tests and the local real-provider audit described in `docs/verification-plan.md` | Preserve sequence, timing, duration, and audio; second-pass detection must be quiet |
|
||||
| Locally decodable invisible image marks, including open DWT-DCT and TrustMark | Positive-control-gated corpus or constructed carriers; a negative on an uncontrolled carrier is inconclusive | `scripts/watermark_benchmark.py` and the relevant decoder tests with the required extras installed | Re-run the same decoder on output and retain a positive control in the same run |
|
||||
| Proprietary image marks: Google SynthID, Microsoft InvisMark, Meta Content Seal | Provider provenance can select a cohort but is not a local pixel decode; use bounded provider-labelled candidates | Hash-bound provider-oracle batches when a new route, profile, or claimed signal is evaluated | Source-positive and output-negative verdicts from that same provider; record C2PA separately from the pixel watermark |
|
||||
| Invisible video and audio marks | Matched provider or open-model cohorts with codec and degradation controls | Benchmark-kernel audio/video rows and temporal aggregation checks | Preserve the non-target stream; use the matching oracle and a control-positive run |
|
||||
| Explicit vendor regeneration with no detectable local signal | This is a user assertion or research cohort, not a discovered signal | Verify routing and profile selection separately from detection | Quality and provider-oracle evidence may certify a cohort, but must not create a local detection claim |
|
||||
|
||||
`classify` is a pixel-origin guess, not provenance, and must not be counted as a
|
||||
new signal. `erase --region` is user-directed editing, not automatic watermark
|
||||
detection, and is outside this audit.
|
||||
|
||||
## Gaps the legacy scanner cannot settle alone
|
||||
|
||||
`corpus_gap_scan.py` is a metadata serialization probe. It reports raw markers,
|
||||
input shapes, identify errors, integrity clashes, unattributed results, and
|
||||
blind-marker candidates, but it still cannot settle other useful classes, so
|
||||
inspect them separately:
|
||||
|
||||
- a known generic signal with missing or wrong vendor attribution;
|
||||
- a newly coexisting marker on a file that already has another AI verdict;
|
||||
- an entirely novel marker absent from the scanner's hand-written list;
|
||||
- visible image or video labels;
|
||||
- proprietary invisible marks without a local decoder;
|
||||
- detection/removal parity failures;
|
||||
- decoder crashes, unsupported extensions, and incomplete reads;
|
||||
- drift between stored identify sidecars and the current library;
|
||||
- crossfire where a new detector fires on an older neighbouring cohort.
|
||||
|
||||
Marker matching must remain metadata-region bounded to avoid random hits in
|
||||
compressed pixels, but its inventory should be reconciled with the current
|
||||
metadata constants and registry on every audit. Do not infer completeness from
|
||||
`No gap candidates` until the other rows of the matrix also ran.
|
||||
|
||||
For a visible discovery pass, use a fresh report path and pass `--sheets` to
|
||||
`visible_positives.py`. Review the numbered PNG files before opening
|
||||
`MANIFEST_DO_NOT_OPEN.csv`; metadata cohorts select where a mark may occur but do
|
||||
not label the pixels. The separate `unbiased-quiet` stratum is required because a
|
||||
new provider may carry neither a mapped producer code nor readable provenance.
|
||||
|
||||
## When to replay old data
|
||||
|
||||
Yes, old data must be rerun. Current code can learn to parse an old
|
||||
serialization, and a new detector can crossfire on an old provider cohort even
|
||||
when no new object demonstrates either problem. The canonical intervals,
|
||||
change triggers, and sampling rules live in
|
||||
[`docs/verification-plan.md#signal-discovery-cadence`](../../../../docs/verification-plan.md#signal-discovery-cadence).
|
||||
|
||||
Keep dated reports immutable enough to compare runs. Record the library SHA and
|
||||
version, corpus minimum and maximum dates, input counts per media/format shape,
|
||||
enabled extras, command line, and output paths. Never commit operational corpus
|
||||
counts or retained identifiers; summarize only durable conclusions in the
|
||||
canonical research document.
|
||||
@@ -1,3 +1,51 @@
|
||||
# HuggingFace token (optional; only needed for gated/private models)
|
||||
# Get yours at: https://huggingface.co/settings/tokens
|
||||
# HF_TOKEN=
|
||||
|
||||
# OpenAI API key (optional; only for development scripts/openai_provenance_check.py)
|
||||
# Create and manage keys at: https://platform.openai.com/api-keys
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# GitHub token (optional; only for scripts/sync_c2pa_soft_bindings.py)
|
||||
# Raises the api.github.com rate limit from 60 to 5000 requests/hour. GH_TOKEN is read as a fallback.
|
||||
# GITHUB_TOKEN=
|
||||
|
||||
# Local photo-classifier weights (optional; otherwise fetched from Hugging Face)
|
||||
# Directory contents are documented in docs/photo-classify.md.
|
||||
# RAIW_CLASSIFY_WEIGHTS=/path/to/classifier-weights
|
||||
|
||||
# Local VideoSeal checkpoint cache (optional; development benchmarks only)
|
||||
# Defaults to ~/.cache/remove-ai-watermarks-dev/videoseal.
|
||||
# VIDEOSEAL_CACHE_DIR=/path/to/videoseal-cache
|
||||
|
||||
# Hugging Face cache root (optional; shared by model downloads and prompt caches)
|
||||
# HF_HOME=/path/to/huggingface-cache
|
||||
# Fallback cache root when HF_HOME is unset (optional)
|
||||
# XDG_CACHE_HOME=/path/to/cache
|
||||
|
||||
# Gemini API key (optional; only for generating Google provider samples by hand)
|
||||
# Paid tier: Nano Banana 2 Lite and Gemini Omni Flash have no free tier.
|
||||
# Create and manage keys at: https://aistudio.google.com/apikey
|
||||
# GEMINI_API_KEY=
|
||||
|
||||
# Named OpenAI keys for provider-oracle slots (optional; keep each value secret)
|
||||
# OPENAI_API_KEY_1=
|
||||
# OPENAI_API_KEY_2=
|
||||
# OPENAI_API_KEY_3=
|
||||
|
||||
# Azure Content Safety resource for direct Microsoft Content Provenance checks
|
||||
# Resource keys are read into memory through the authenticated Azure CLI.
|
||||
# AZURE_CONTENT_SAFETY_ENDPOINT=https://your-resource.cognitiveservices.azure.com/
|
||||
# AZURE_CONTENT_SAFETY_SUBSCRIPTION_ID=
|
||||
# AZURE_CONTENT_SAFETY_RESOURCE_GROUP=
|
||||
# AZURE_CONTENT_SAFETY_ACCOUNT_NAME=
|
||||
# AZURE_ORACLE_STORAGE_RESOURCE_GROUP=
|
||||
|
||||
# ThorData residential proxies (optional; only for explicitly selected Web oracle routes)
|
||||
# Generate these URLs in the ThorData endpoint generator; do not commit credentials.
|
||||
# The Web runner pins a rotating user-and-password route to one sticky session per manifest.
|
||||
# THORDATA_ROUTE_US=http://td-customer-USERNAME-country-US:PASSWORD@HOST.pr.thordata.net:9999
|
||||
# THORDATA_ROUTE_DE=http://td-customer-USERNAME-country-DE:PASSWORD@HOST.pr.thordata.net:9999
|
||||
# THORDATA_ROUTE_GB=http://td-customer-USERNAME-country-GB:PASSWORD@HOST.pr.thordata.net:9999
|
||||
# THORDATA_ROUTE_NL=http://td-customer-USERNAME-country-NL:PASSWORD@HOST.pr.thordata.net:9999
|
||||
# THORDATA_ROUTE_JP=http://td-customer-USERNAME-country-JP:PASSWORD@HOST.pr.thordata.net:9999
|
||||
@@ -14,18 +14,22 @@ updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
# The `all` extra must remain jointly resolvable. These candidates violate
|
||||
# upstream constraints: TrustMark still requires NumPy 1.x, OpenCV 4.12+
|
||||
# requires NumPy 2 on newer Python versions, and this project keeps tokenizers
|
||||
# below 0.23 for the stable Transformers path. Keep Dependabot on the
|
||||
# compatible lines until those constraints move.
|
||||
# The `all` extra must remain jointly resolvable. PaddleX 3.7 (the OCR
|
||||
# extra) requires NumPy <2.4, mirrored by `constraint-dependencies` in
|
||||
# pyproject.toml, and this project keeps tokenizers below 0.23 for the stable
|
||||
# Transformers path. Keep Dependabot on the compatible lines until those
|
||||
# constraints move. Transformers 5.16+ requires tokenizers 0.23.1+,
|
||||
# and Diffusers requires huggingface-hub <2. Mirror the manifest bounds.
|
||||
ignore:
|
||||
- dependency-name: "numpy"
|
||||
versions:
|
||||
- ">=2"
|
||||
- dependency-name: "opencv-python-headless"
|
||||
- ">=2.4"
|
||||
- dependency-name: "transformers"
|
||||
versions:
|
||||
- ">=4.12"
|
||||
- ">=5.16"
|
||||
- dependency-name: "huggingface-hub"
|
||||
versions:
|
||||
- ">=2"
|
||||
- dependency-name: "tokenizers"
|
||||
versions:
|
||||
- ">=0.23"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
name: Distribute on release
|
||||
|
||||
# Fans a published GitHub Release out to the channels that need a nudge.
|
||||
# PyPI is handled by publish.yml. This workflow event-drives the three channels
|
||||
# PyPI is handled by publish.yml. This workflow event-drives the four channels
|
||||
# that would otherwise be manual:
|
||||
# - Homebrew tap: rewrite the formula's url + sha256 to the new sdist.
|
||||
# - HF Space: factory-rebuild so it reinstalls the latest sdist from PyPI.
|
||||
# - HF Space: factory-rebuild the version already pinned in the Space repo.
|
||||
# - ComfyUI: sync, test, and publish the node against the exact library release.
|
||||
# All wait for the freshly published sdist to appear on PyPI first, since the
|
||||
# Release event fires in parallel with publish.yml's upload.
|
||||
# - ClawHub: publish the agent skill when its own version moved.
|
||||
# All wait for the freshly published distribution to appear in both PyPI APIs
|
||||
# first, since the Release event fires in parallel with publish.yml's upload.
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -20,7 +21,12 @@ on:
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
# Model-only prereleases must not reach PyPI, Homebrew, Spaces, or ComfyUI.
|
||||
if: ${{ github.event_name != 'release' || (startsWith(github.event.release.tag_name, 'v') && !github.event.release.prerelease) }}
|
||||
runs-on: ubuntu-latest
|
||||
# Thirty attempts may spend up to 20 seconds each in the JSON request,
|
||||
# simple-index request, and inter-attempt delay. Leave bounded headroom.
|
||||
timeout-minutes: 25
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
url: ${{ steps.sdist.outputs.url }}
|
||||
@@ -41,26 +47,56 @@ jobs:
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "Target version: $v"
|
||||
|
||||
- name: Wait for the sdist on PyPI, capture url + sha256
|
||||
- name: Record distributed release
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
python3 - <<'PYTHON'
|
||||
import json, os
|
||||
from pathlib import Path
|
||||
Path("release-identity.json").write_text(json.dumps({
|
||||
"version": os.environ["VERSION"],
|
||||
"run_id": os.environ["GITHUB_RUN_ID"],
|
||||
"run_attempt": os.environ["GITHUB_RUN_ATTEMPT"],
|
||||
}), encoding="utf-8")
|
||||
PYTHON
|
||||
|
||||
- name: Upload distributed release identity
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: distributed-release-${{ github.run_attempt }}
|
||||
path: release-identity.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Wait for the PyPI JSON and simple indexes, capture sdist metadata
|
||||
id: sdist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.v.outputs.version }}"
|
||||
for i in $(seq 1 30); do
|
||||
if rel=$(curl -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$v/json" 2>/dev/null); then
|
||||
url=$(echo "$rel" | python3 -c "import sys,json;d=json.load(sys.stdin);print(next((u['url'] for u in d['urls'] if u['packagetype']=='sdist'),''))")
|
||||
sha=$(echo "$rel" | python3 -c "import sys,json;d=json.load(sys.stdin);print(next((u['digests']['sha256'] for u in d['urls'] if u['packagetype']=='sdist'),''))")
|
||||
if rel=$(curl --connect-timeout 10 --max-time 20 -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$v/json" 2>/dev/null); then
|
||||
read -r url sha <<< "$(echo "$rel" | python3 -c "import sys,json;u=next((u for u in json.load(sys.stdin)['urls'] if u['packagetype']=='sdist'),{});print(u.get('url',''),u.get('digests',{}).get('sha256',''))")"
|
||||
if [ -n "$url" ] && [ -n "$sha" ]; then
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "Found sdist for $v"
|
||||
exit 0
|
||||
# PyPI's JSON API can show a freshly uploaded file before the
|
||||
# simple index used by pip has caught up. Downstream release
|
||||
# jobs install by package version, so waiting on JSON alone
|
||||
# creates a release-time race for them.
|
||||
simple=$(curl --connect-timeout 10 --max-time 20 -fsSL https://pypi.org/simple/remove-ai-watermarks/ 2>/dev/null || true)
|
||||
artifact=${url##*/}
|
||||
if [[ "$simple" == *"$artifact"* ]]; then
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "PyPI JSON and simple indexes list $v"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "sdist for $v not on PyPI yet (attempt $i), waiting..."
|
||||
sleep 20
|
||||
if [ "$i" -lt 30 ]; then
|
||||
echo "PyPI indexes do not both list $v yet (attempt $i), waiting..."
|
||||
sleep 20
|
||||
fi
|
||||
done
|
||||
echo "::error::sdist for $v did not appear on PyPI in time"
|
||||
echo "::error::PyPI JSON and simple indexes did not both list $v in time"
|
||||
exit 1
|
||||
|
||||
homebrew:
|
||||
@@ -92,10 +128,91 @@ jobs:
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Factory-rebuild the HuggingFace Space
|
||||
- name: Bump the Space pin, then factory-rebuild
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RAIW_HF_SPACE_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo=wiltodelta/raiw-hf-space
|
||||
workflow=pin-library.yml
|
||||
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "RAIW_HF_SPACE_TOKEN secret is not set; skipping the pin bump."
|
||||
else
|
||||
dispatched_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
gh workflow run "$workflow" \
|
||||
--repo "$repo" \
|
||||
--ref main \
|
||||
--raw-field library_version="$VERSION"
|
||||
|
||||
run_id=""
|
||||
for attempt in $(seq 1 30); do
|
||||
run_id=$(
|
||||
gh run list \
|
||||
--repo "$repo" \
|
||||
--workflow "$workflow" \
|
||||
--event workflow_dispatch \
|
||||
--limit 10 \
|
||||
--json databaseId,createdAt \
|
||||
| python3 -c "import json,sys; t=sys.argv[1]; print(next((r['databaseId'] for r in json.load(sys.stdin) if r['createdAt']>=t), ''))" \
|
||||
"$dispatched_at"
|
||||
)
|
||||
if [ -n "$run_id" ]; then
|
||||
break
|
||||
fi
|
||||
echo "Space pin run not visible yet (attempt $attempt), waiting..."
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "::error::The dispatched Space pin run did not appear"
|
||||
exit 1
|
||||
fi
|
||||
for attempt in $(seq 1 180); do
|
||||
read -r status conclusion <<< "$(
|
||||
gh run view "$run_id" \
|
||||
--repo "$repo" \
|
||||
--json status,conclusion \
|
||||
--jq '[.status, (.conclusion // "")] | @tsv'
|
||||
)"
|
||||
if [ "$status" = "completed" ]; then
|
||||
if [ "$conclusion" = "success" ]; then
|
||||
echo "Space pin run $run_id succeeded"
|
||||
break
|
||||
fi
|
||||
echo "::error::Space pin run $run_id completed with $conclusion"
|
||||
exit 1
|
||||
fi
|
||||
echo "Space pin run $run_id is $status (attempt $attempt), waiting..."
|
||||
sleep 5
|
||||
done
|
||||
if [ "${status:-}" != "completed" ]; then
|
||||
echo "::error::Space pin run $run_id did not finish in time"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for the Space requirements pin to show $VERSION..."
|
||||
pinned=""
|
||||
for attempt in $(seq 1 40); do
|
||||
reqs=$(curl -fsSL \
|
||||
"https://huggingface.co/spaces/wiltodelta/remove-ai-watermarks/raw/main/requirements.txt" \
|
||||
|| true)
|
||||
pinned=$(printf '%s\n' "$reqs" \
|
||||
| sed -n 's/^remove-ai-watermarks==\([0-9][0-9a-zA-Z.]*\).*/\1/p' \
|
||||
| head -1)
|
||||
echo " attempt $attempt: Space pin=$pinned"
|
||||
if [ "$pinned" = "$VERSION" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
if [ "$pinned" != "$VERSION" ]; then
|
||||
echo "::error::Space requirements.txt still pinned to ${pinned:-<none>}, not $VERSION"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
python3 -m pip install --quiet huggingface_hub
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
@@ -106,6 +223,44 @@ jobs:
|
||||
print("HF Space factory rebuild triggered")
|
||||
PY
|
||||
|
||||
clawhub:
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Publish the agent skill when its version moved
|
||||
env:
|
||||
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${CLAWHUB_TOKEN:-}" ]; then
|
||||
echo "CLAWHUB_TOKEN secret is not set; skipping ClawHub publish."
|
||||
exit 0
|
||||
fi
|
||||
npm install --silent --no-fund --no-audit clawhub
|
||||
skill_version=$(python3 - <<'PY'
|
||||
import re
|
||||
text = open("skills/remove-ai-watermarks/SKILL.md", encoding="utf-8").read()
|
||||
m = re.search(r"(?m)^ version:\s*[\"']?([^\"'\s]+)", text)
|
||||
assert m, "skill version missing"
|
||||
print(m.group(1))
|
||||
PY
|
||||
)
|
||||
./node_modules/.bin/clawhub login --token "$CLAWHUB_TOKEN" >/dev/null
|
||||
published=$(./node_modules/.bin/clawhub inspect remove-ai-watermarks --file SKILL.md 2>/dev/null \
|
||||
| python3 -c 'import re,sys; t=sys.stdin.read(); m=re.search(r"(?m)^ version:\s*[\"'"'"']?([^\"'"'"'\s]+)", t); print(m.group(1) if m else "")' || true)
|
||||
if [ "$published" = "$skill_version" ]; then
|
||||
echo "ClawHub already at $skill_version; nothing to publish."
|
||||
exit 0
|
||||
fi
|
||||
echo "ClawHub at ${published:-<none>}; publishing skill $skill_version."
|
||||
./node_modules/.bin/clawhub skill publish skills/remove-ai-watermarks \
|
||||
--slug remove-ai-watermarks \
|
||||
--name "Remove AI Watermarks" \
|
||||
--version "$skill_version" \
|
||||
--changelog "Agent skill sync for the ${{ github.event.release.name || github.ref_name }} release."
|
||||
echo "Submitted; ClawHub runs security scans before the listing goes public."
|
||||
|
||||
comfyui:
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
@@ -157,7 +312,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for attempt in $(seq 1 180); do
|
||||
# 0.40.0's node sync finished after this waiter: 180 * 5s is 15 min,
|
||||
# and the node suite plus registry upload ran longer. Keep the
|
||||
# workflow wait, then accept a registry row for this library version
|
||||
# so a late-but-published node is not reported as a missing release.
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
for attempt in $(seq 1 360); do
|
||||
read -r status conclusion <<< "$(
|
||||
gh run view "$run_id" \
|
||||
--repo "$repo" \
|
||||
@@ -175,5 +335,39 @@ jobs:
|
||||
echo "ComfyUI sync run $run_id is $status (attempt $attempt), waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::ComfyUI sync run $run_id did not finish in time"
|
||||
echo "ComfyUI sync run $run_id did not finish in time; checking the registry."
|
||||
python3 -m pip install --quiet packaging
|
||||
if curl -fsSL "https://api.comfy.org/nodes/remove-ai-watermarks/versions" \
|
||||
| VERSION="$version" python3 -c '
|
||||
import json, os, sys
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
from packaging.version import Version
|
||||
version = os.environ["VERSION"]
|
||||
target = Version(version)
|
||||
def requires_release(value):
|
||||
try:
|
||||
requirement = Requirement(value)
|
||||
except InvalidRequirement:
|
||||
return False
|
||||
return (
|
||||
canonicalize_name(requirement.name) == "remove-ai-watermarks"
|
||||
and requirement.url is None
|
||||
and requirement.marker is None
|
||||
and target in requirement.specifier
|
||||
and any(s.operator == ">=" and Version(s.version) == target for s in requirement.specifier)
|
||||
)
|
||||
matches = [
|
||||
v for v in json.load(sys.stdin)
|
||||
if any(requires_release(d) for d in (v.get("dependencies") or []))
|
||||
]
|
||||
if not matches:
|
||||
sys.exit(1)
|
||||
newest = max(matches, key=lambda v: v.get("createdAt") or "")
|
||||
print("%s %s" % (newest.get("version"), newest.get("status")))
|
||||
'; then
|
||||
echo "Registry already lists a node for $version; the wait timed out after publish."
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::ComfyUI sync run $run_id did not finish in time and the registry has no node for $version"
|
||||
exit 1
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Publish image-source classifier to Hugging Face
|
||||
|
||||
# Manual only. The derivative weights live in a GitHub model-freeze release;
|
||||
# private training images and caches never enter this repository.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: "Type publish"
|
||||
required: true
|
||||
weights_tag:
|
||||
description: "GitHub Release tag holding openai-google-source-v1.npz"
|
||||
required: true
|
||||
default: image-source-freeze-2026-09-25-v1
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.event.inputs.confirm == 'publish' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Download derivative weights
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.inputs.weights_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p weights
|
||||
gh release download "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern openai-google-source-v1.npz \
|
||||
--dir weights
|
||||
test -s weights/openai-google-source-v1.npz
|
||||
- name: Verify artifact hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected="$(jq -r .artifact_sha256 docs/image-source-hf/metrics.json)"
|
||||
test "${#expected}" -eq 64
|
||||
echo "$expected weights/openai-google-source-v1.npz" | sha256sum --check
|
||||
- name: Publish to Hugging Face
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${HF_TOKEN:-}" ]; then
|
||||
echo "HF_TOKEN secret is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
uv run --no-project --with huggingface-hub \
|
||||
python scripts/publish_image_source_hf.py \
|
||||
--src weights \
|
||||
--message "Image-source classifier freeze (GitHub ${SHA:0:7})"
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Publish photo classifier to Hugging Face
|
||||
|
||||
# Manual only. Not part of a library release. The Space demo
|
||||
# (wiltodelta/remove-ai-watermarks) stays on distribute.yml / raiw-hf-space.
|
||||
#
|
||||
# Hub model: wiltodelta/raiw-photo-classify
|
||||
# Secret: HF_TOKEN, write-role Hugging Face token (same secret Space sync uses).
|
||||
#
|
||||
# Weights are not in git. Full mode downloads the frozen PyTorch files from a
|
||||
# GitHub Release, exports the vision-only ONNX graph, then uploads everything
|
||||
# with the card from docs/photo-classify-hf/.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: "Type publish"
|
||||
required: true
|
||||
mode:
|
||||
description: "full = card plus weights from the release tag; card = README, operating-point, and receipt gate only"
|
||||
type: choice
|
||||
options:
|
||||
- full
|
||||
- card
|
||||
default: full
|
||||
weights_tag:
|
||||
description: "GitHub Release tag that holds clip-l-ft.pt, probe, detector.pt, provider.pt"
|
||||
required: false
|
||||
default: photo-classify-freeze-2026-08-31
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.event.inputs.confirm == 'publish' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Download freeze weights from the GitHub Release
|
||||
if: ${{ github.event.inputs.mode == 'full' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.inputs.weights_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p weights
|
||||
gh release download "$TAG" --repo "${{ github.repository }}" --dir weights
|
||||
ls -l weights
|
||||
|
||||
- name: Export the vision-only ONNX graph
|
||||
if: ${{ github.event.inputs.mode == 'full' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
uv run --extra classify --with onnx python scripts/export_photo_classify_onnx.py \
|
||||
--checkpoint weights/clip-l-ft.pt \
|
||||
--out weights/clip-l-ft-vision-fp32.onnx
|
||||
|
||||
- name: Publish to Hugging Face
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
MODE: ${{ github.event.inputs.mode }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${HF_TOKEN:-}" ]; then
|
||||
echo "HF_TOKEN secret is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
extra=(--message "Photo-classify freeze (GitHub ${SHA:0:7})")
|
||||
if [ "$MODE" = "card" ]; then
|
||||
extra+=(--card-only)
|
||||
else
|
||||
extra+=(--src weights)
|
||||
fi
|
||||
uv run --no-project --with huggingface-hub python scripts/publish_photo_classify_hf.py "${extra[@]}"
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Publish source classifier to Hugging Face
|
||||
|
||||
# Manual only. The derivative model weights live in a GitHub model-freeze
|
||||
# release; the private training corpus and caches never enter this repository.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: "Type publish"
|
||||
required: true
|
||||
weights_tag:
|
||||
description: "GitHub Release tag holding source-pipeline-mlp.npz"
|
||||
required: true
|
||||
default: source-classify-freeze-2026-09-16-r2
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.event.inputs.confirm == 'publish' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Download derivative model weights
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.inputs.weights_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p weights
|
||||
gh release download "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern source-pipeline-mlp.npz \
|
||||
--dir weights
|
||||
test -s weights/source-pipeline-mlp.npz
|
||||
|
||||
- name: Verify the frozen artifact
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected="$(jq -r .artifact_sha256 docs/source-classify-hf/metrics.json)"
|
||||
test "${#expected}" -eq 64
|
||||
echo "$expected weights/source-pipeline-mlp.npz" | sha256sum --check
|
||||
|
||||
- name: Publish to Hugging Face
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${HF_TOKEN:-}" ]; then
|
||||
echo "HF_TOKEN secret is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
uv run --no-project --with huggingface-hub \
|
||||
python scripts/publish_source_classify_hf.py \
|
||||
--src weights \
|
||||
--message "Source-classify freeze (GitHub ${SHA:0:7})"
|
||||
@@ -10,6 +10,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Model-only prereleases are not Python package releases.
|
||||
if: ${{ startsWith(github.event.release.tag_name, 'v') && !github.event.release.prerelease }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
@@ -38,4 +40,4 @@ jobs:
|
||||
# is needed. uv's uploader also accepts Metadata-Version 2.5 -- the permanent
|
||||
# fix that lets the hatchling pin be relaxed (see pyproject [build-system]).
|
||||
- name: Publish to PyPI
|
||||
run: uv publish
|
||||
run: uv publish
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Skill
|
||||
|
||||
# Fast path-filtered gate for agent-skill packaging changes. The full
|
||||
# Tests workflow still runs on the same pushes; this answers skill-only
|
||||
# pull requests in seconds by running just the deterministic skill suites.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "skills/**"
|
||||
- ".claude-plugin/**"
|
||||
- "docs/agent-skill.md"
|
||||
- "tests/test_agent_skill.py"
|
||||
- "tests/test_skill_evals.py"
|
||||
pull_request:
|
||||
paths:
|
||||
- "skills/**"
|
||||
- ".claude-plugin/**"
|
||||
- "docs/agent-skill.md"
|
||||
- "tests/test_agent_skill.py"
|
||||
- "tests/test_skill_evals.py"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: skill-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
skill:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
- name: Agent skill packaging tests
|
||||
run: uv run pytest tests/test_agent_skill.py tests/test_skill_evals.py -q
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -17,24 +20,54 @@ jobs:
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
- name: Check C2PA soft-binding registry snapshot
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: uv run python scripts/sync_c2pa_soft_bindings.py --check
|
||||
- name: Ruff lint
|
||||
run: uv run ruff check
|
||||
- name: Ruff format check
|
||||
run: uv run ruff format --check
|
||||
|
||||
security:
|
||||
name: lockfile vulnerability scan
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: false
|
||||
# Policy and triaged blocks: docs/development.md "Known security-gate blocks".
|
||||
- name: Scan uv.lock with uv-secure
|
||||
run: uvx uv-secure uv.lock
|
||||
|
||||
test:
|
||||
name: test ${{ matrix.os }} py${{ matrix.python-version }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Cross-OS x Python-floor/recent. The default-install surface only
|
||||
# needs the core deps plus dev tooling (no GPU extra): the model-running
|
||||
# paths are availability-gated and skip when torch/diffusers are absent,
|
||||
# so this matrix exercises metadata, identify, visible, and the cv2
|
||||
# eraser on every OS without pulling the heavy ML stack.
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ["3.10", "3.12"]
|
||||
# Exercise every supported minor. The floor and latest minor run on all
|
||||
# operating systems; intermediate minors run on Linux to bound the
|
||||
# matrix without leaving a declared Python version untested.
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.11"
|
||||
- os: macos-latest
|
||||
python-version: "3.11"
|
||||
- os: windows-latest
|
||||
python-version: "3.11"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.13"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.14"
|
||||
- os: macos-latest
|
||||
python-version: "3.14"
|
||||
- os: windows-latest
|
||||
python-version: "3.14"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
@@ -76,7 +109,15 @@ jobs:
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install ffmpeg
|
||||
run: sudo apt-get update && sudo apt-get install --yes ffmpeg
|
||||
# Third-party apt sources baked into the runner image (google-chrome et
|
||||
# al.) can publish a broken index and fail `apt-get update`, while this
|
||||
# job only needs ffmpeg from Ubuntu's own repositories: drop every
|
||||
# non-Ubuntu source definition before updating.
|
||||
run: |
|
||||
sudo find /etc/apt/sources.list.d -mindepth 1 -maxdepth 1 \
|
||||
! -name 'ubuntu.sources' -delete
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes ffmpeg
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
- name: Run full-clip video test
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
name: Verify release surfaces
|
||||
|
||||
# Post-release verification: after distribute.yml fans a release out to
|
||||
# Homebrew, the HF Space, and the ComfyUI Registry, confirm every published
|
||||
# surface actually landed and works. This workflow is the automated twin of
|
||||
# the manual checklist in docs/release-and-distribution.md ("Release
|
||||
# verification"), not a replacement for the library's own test suite (which
|
||||
# already ran in test.yml and publish.yml).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to verify (no leading v). Blank = latest on PyPI."
|
||||
required: false
|
||||
workflow_run:
|
||||
workflows: ["Distribute on release"]
|
||||
types: [completed]
|
||||
|
||||
# Read the triggering run's release identity while keeping all permissions read-only.
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
# Only run on a successful distribute (not on manual dispatch, where the
|
||||
# version input or PyPI-latest provides the target).
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
# The Space id appears in the Hub API, the raw-file URL and the app host,
|
||||
# spelled three different ways. Name it once so a move cannot half-land.
|
||||
SPACE_REPO: wiltodelta/remove-ai-watermarks
|
||||
SPACE_URL: https://wiltodelta-remove-ai-watermarks.hf.space
|
||||
|
||||
steps:
|
||||
- name: Download distributed release identity
|
||||
if: ${{ github.event_name == 'workflow_run' }}
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: distributed-release-*
|
||||
path: distribution-identities
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Resolve target version
|
||||
id: v
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
DISTRIBUTION_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
DISTRIBUTION_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT_NAME" = "workflow_run" ]; then
|
||||
v=$(python3 - <<'PYTHON'
|
||||
import json, os
|
||||
from pathlib import Path
|
||||
identities = []
|
||||
for path in Path("distribution-identities").rglob("release-identity.json"):
|
||||
item = json.loads(path.read_text(encoding="utf-8"))
|
||||
# download-artifact@v8 extracts the only matched artifact directly
|
||||
# into the path, with no artifact-name directory; several matches
|
||||
# each get one, so the attempt is name-checked only in that layout.
|
||||
if path.parent.name.startswith('distributed-release-'):
|
||||
assert path.parent.name == 'distributed-release-' + item['run_attempt'], "Artifact attempt mismatch"
|
||||
identities.append(item)
|
||||
assert identities, "No distributed release identity found"
|
||||
assert all(item["run_id"] == os.environ["DISTRIBUTION_RUN_ID"] for item in identities), "Distribution run mismatch"
|
||||
# Rerunning only failed jobs can reuse resolve from an earlier attempt.
|
||||
eligible = [item for item in identities if
|
||||
int(item["run_attempt"]) <= int(os.environ["DISTRIBUTION_RUN_ATTEMPT"])]
|
||||
assert eligible, "No identity exists for this distribution attempt"
|
||||
identity = max(eligible, key=lambda item: int(item["run_attempt"]))
|
||||
print(identity["version"])
|
||||
PYTHON
|
||||
)
|
||||
elif [ -n "$INPUT_VERSION" ]; then
|
||||
v="$INPUT_VERSION"
|
||||
else
|
||||
v=$(curl -fsSL https://pypi.org/pypi/remove-ai-watermarks/json \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])")
|
||||
fi
|
||||
# Versions become URL components and step outputs. Reject malformed identities.
|
||||
[[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+([a-zA-Z0-9.+-]*)$ ]]
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "Verifying release $v"
|
||||
|
||||
# -- PyPI ---------------------------------------------------------------
|
||||
- name: Verify PyPI
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Checking PyPI for $VERSION..."
|
||||
rel=$(curl -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$VERSION/json")
|
||||
echo "$rel" | python3 -c "
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
assert d['info']['version'] == '$VERSION', f\"PyPI version mismatch: {d['info']['version']}\"
|
||||
assert d['urls'], 'No files on PyPI'
|
||||
has_wheel = any(u['packagetype'] == 'bdist_wheel' for u in d['urls'])
|
||||
has_sdist = any(u['packagetype'] == 'sdist' for u in d['urls'])
|
||||
assert has_wheel, 'No wheel on PyPI'
|
||||
assert has_sdist, 'No sdist on PyPI'
|
||||
print(f' PyPI: {len(d[\"urls\"])} files (wheel + sdist) for $VERSION')
|
||||
"
|
||||
|
||||
# -- Clean install from PyPI ---------------------------------------------
|
||||
- name: Install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
- name: Verify clean install
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Installing remove-ai-watermarks==$VERSION in a clean environment..."
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
result=$(uvx --from "remove-ai-watermarks==$VERSION" remove-ai-watermarks --version 2>&1) \
|
||||
|| { echo "::error::Clean install failed: $result"; exit 1; }
|
||||
echo " Clean install: $result"
|
||||
|
||||
# -- Homebrew --------------------------------------------------------------
|
||||
- name: Verify Homebrew formula
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Checking Homebrew tap formula..."
|
||||
formula=$(curl -fsSL \
|
||||
"https://raw.githubusercontent.com/wiltodelta/homebrew-tap/main/Formula/remove-ai-watermarks.rb")
|
||||
echo "$formula" | grep -q "remove_ai_watermarks-$VERSION.tar.gz" \
|
||||
|| { echo "::error::Homebrew formula does not reference $VERSION"; \
|
||||
echo "Formula URL line:"; echo "$formula" | grep '^url'; exit 1; }
|
||||
echo " Homebrew: formula points to $VERSION"
|
||||
|
||||
# -- ComfyUI ------------------------------------------------------------------
|
||||
- name: Verify ComfyUI registry node requires this release
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Checking the ComfyUI Registry node..."
|
||||
# Read the published versions directly. Do NOT proxy through the sync
|
||||
# workflow's run status: the node repo runs that workflow on a daily
|
||||
# schedule too, so a green nightly says nothing about THIS release.
|
||||
#
|
||||
# And read the versions list, not `latest_version` on the node object.
|
||||
# The registry reviews a new upload before activating it, and
|
||||
# `latest_version` reports only the active one -- so for the whole
|
||||
# review window a correctly published version looks identical to one
|
||||
# that was never uploaded. Those are opposite situations: one is a
|
||||
# queue nobody here controls, the other is a broken release.
|
||||
python3 -m pip install --quiet packaging
|
||||
versions=$(curl -fsSL "https://api.comfy.org/nodes/remove-ai-watermarks/versions")
|
||||
status=$(printf '%s' "$versions" | VERSION="$VERSION" python3 -c "
|
||||
import json, os, sys
|
||||
version = os.environ['VERSION']
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
from packaging.version import Version
|
||||
target = Version(version)
|
||||
def requires_release(value):
|
||||
try:
|
||||
requirement = Requirement(value)
|
||||
except InvalidRequirement:
|
||||
return False
|
||||
return (
|
||||
canonicalize_name(requirement.name) == 'remove-ai-watermarks'
|
||||
and requirement.url is None
|
||||
and requirement.marker is None
|
||||
and target in requirement.specifier
|
||||
and any(s.operator == '>=' and Version(s.version) == target for s in requirement.specifier)
|
||||
)
|
||||
matches = [
|
||||
v for v in json.load(sys.stdin)
|
||||
if any(requires_release(d) for d in (v.get('dependencies') or []))
|
||||
]
|
||||
if not matches:
|
||||
sys.exit(f'::error::No ComfyUI node version requires {version}; the sync never published one')
|
||||
# Sort rather than trusting the response order: the endpoint happens to
|
||||
# return newest-first today and nothing documents that it must.
|
||||
newest = max(matches, key=lambda v: v.get('createdAt') or '')
|
||||
print(f\"{newest.get('version')} {newest.get('status')}\")
|
||||
")
|
||||
node_version=${status%% *}
|
||||
node_status=${status##* }
|
||||
echo " ComfyUI: node $node_version requires $VERSION (status $node_status)"
|
||||
case "$node_status" in
|
||||
NodeVersionStatusActive)
|
||||
echo "- ComfyUI: registry node $node_version requires $VERSION and is live" >> "$GITHUB_STEP_SUMMARY"
|
||||
;;
|
||||
NodeVersionStatusPending)
|
||||
# Published and waiting on the registry's own review. Say exactly
|
||||
# that in the summary rather than reporting it as live, and do not
|
||||
# fail: an external moderation queue going red on every release
|
||||
# trains everyone to ignore this workflow.
|
||||
echo "::notice::ComfyUI node $node_version is published and awaiting registry review."
|
||||
echo "- ComfyUI: registry node $node_version requires $VERSION, awaiting registry activation" >> "$GITHUB_STEP_SUMMARY"
|
||||
;;
|
||||
*)
|
||||
echo "::error::ComfyUI node $node_version is $node_status"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# -- HF Space -------------------------------------------------------------------
|
||||
- name: Verify the HF Space serves this release
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Checking the HF Space pin..."
|
||||
# The Space pin bump is a MANUAL step (see docs/release-and-distribution.md):
|
||||
# distribute.yml only factory-rebuilds whatever is already pinned. Failing
|
||||
# here is the point -- a demo left on an older release is an incomplete
|
||||
# release, and only this check can say so.
|
||||
reqs=$(curl -fsSL "https://huggingface.co/spaces/$SPACE_REPO/raw/main/requirements.txt")
|
||||
pinned=$(printf '%s\n' "$reqs" | sed -n 's/^remove-ai-watermarks==\([0-9][0-9a-zA-Z.]*\).*/\1/p' | head -1)
|
||||
if [ -z "$pinned" ]; then
|
||||
echo "::error::Space requirements.txt has no remove-ai-watermarks== pin"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$pinned" != "$VERSION" ]; then
|
||||
echo "::error::HF Space is pinned to $pinned, not $VERSION."
|
||||
echo " Bump remove-ai-watermarks in wiltodelta/raiw-hf-space (pyproject.toml,"
|
||||
echo " uv lock, re-exported requirements.txt) and push; sync-to-hf.yml mirrors it."
|
||||
exit 1
|
||||
fi
|
||||
echo " HF Space: requirements pin is $pinned"
|
||||
|
||||
- name: Verify the HF Space is running and answering
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# A factory rebuild leaves the Space BUILDING for a while; wait for the
|
||||
# runtime stage the API reports rather than for a bare HTTP 200, which a
|
||||
# build page also returns.
|
||||
for attempt in $(seq 1 20); do
|
||||
stage=$(curl -fsSL "https://huggingface.co/api/spaces/$SPACE_REPO" \
|
||||
| python3 -c "import json,sys;print((json.load(sys.stdin).get('runtime') or {}).get('stage',''))")
|
||||
echo " attempt $attempt: runtime stage=$stage"
|
||||
[ "$stage" = "RUNNING" ] && break
|
||||
case "$stage" in
|
||||
BUILD_ERROR|RUNTIME_ERROR|CONFIG_ERROR)
|
||||
echo "::error::HF Space runtime stage is $stage"; exit 1 ;;
|
||||
esac
|
||||
sleep 15
|
||||
done
|
||||
if [ "$stage" != "RUNNING" ]; then
|
||||
echo "::error::HF Space never reached RUNNING (last stage: $stage)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Alive is not the same as working. Drive the two CPU surfaces the demo
|
||||
# exists for, against the Space's own committed example, and require a
|
||||
# real verdict rather than any HTTP 200.
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
curl -fsSL -o /tmp/example.png \
|
||||
"https://huggingface.co/spaces/$SPACE_REPO/resolve/main/examples/doubao-aigc.png"
|
||||
uv run --no-project --with 'gradio_client>=1,<2' python - <<'PYEOF'
|
||||
import os
|
||||
import sys
|
||||
|
||||
from gradio_client import Client, handle_file
|
||||
|
||||
# The committed example is a 5 MB PNG, and httpx's default 5 s write
|
||||
# timeout aborts that upload on a slow link before the Space ever sees
|
||||
# it -- which would read as a broken demo rather than a slow one.
|
||||
client = Client(os.environ["SPACE_URL"], verbose=False, httpx_kwargs={"timeout": 300})
|
||||
|
||||
report = client.predict(path=handle_file("/tmp/example.png"), api_name="/run_identify")
|
||||
if "Verdict:" not in report:
|
||||
sys.exit(f"::error::/run_identify returned no verdict: {report[:200]!r}")
|
||||
print(f" HF Space identify: {report.splitlines()[0]}")
|
||||
|
||||
_image, _file, info = client.predict(
|
||||
path=handle_file("/tmp/example.png"),
|
||||
sensitivity_label="auto (recommended)",
|
||||
api_name="/run_visible",
|
||||
)
|
||||
if "Removed:" not in info:
|
||||
sys.exit(f"::error::/run_visible removed nothing on the Doubao example: {info!r}")
|
||||
print(f" HF Space visible: {info.splitlines()[0]}")
|
||||
PYEOF
|
||||
echo "- HF Space: pinned to $VERSION, RUNNING, identify + visible answered" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
# Every check above appends its own line to the step summary as it passes,
|
||||
# and every one of them fails the job instead of warning. So this heading
|
||||
# can only be reached when all of them actually held -- an unconditional
|
||||
# "verified" block printed here once claimed surfaces nobody had checked.
|
||||
sed -i "1i ## Release $VERSION verified" "$GITHUB_STEP_SUMMARY"
|
||||
cat "$GITHUB_STEP_SUMMARY"
|
||||
@@ -9,6 +9,7 @@ tmp/
|
||||
|
||||
# Environment secrets
|
||||
.env
|
||||
.mcp.json
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
@@ -23,6 +24,17 @@ Thumbs.db
|
||||
# Generated calibration inputs
|
||||
data/synthid_corpus/refs/
|
||||
|
||||
# Local-only image pulls held for analysis. They are not publication-cleared and
|
||||
# have no place in this repository's history. Until 2026-09-07 the only thing
|
||||
# keeping them out of a commit was an untracked .gitignore inside the directory,
|
||||
# which a fresh clone would not have.
|
||||
data/spaces/
|
||||
|
||||
# Local-only research data: corpora, checkpoints and campaign reports, tens of
|
||||
# gigabytes that no clone should be able to commit. The layout is described in
|
||||
# data/research/README.md, which is local-only too.
|
||||
data/research/
|
||||
|
||||
# Reference materials
|
||||
_refs/
|
||||
|
||||
@@ -30,6 +42,10 @@ _refs/
|
||||
yolov8n.pt
|
||||
.coverage
|
||||
|
||||
# Local prototype and evaluation outputs (research harnesses and probes); the
|
||||
# durable record of an oracle verdict lives under data/evaluations/
|
||||
out/
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
@@ -48,3 +64,14 @@ gfpgan/
|
||||
|
||||
# Local evaluation data
|
||||
.local-eval/
|
||||
/.oracle-slots.json
|
||||
|
||||
# Other harnesses' generated config. Claude's sync owns these; a committed copy
|
||||
# is a second source nobody reconciles. Anchored so a same-named source file
|
||||
# deeper in the tree is not caught.
|
||||
/.agents/
|
||||
/.codex/
|
||||
/sch_*.yaml
|
||||
/.opencode/
|
||||
/opencode.json
|
||||
/opencode.jsonc
|
||||
@@ -8,6 +8,7 @@ The project gives users control over provenance marks on content they generated
|
||||
|
||||
- Add visible templates only for AI-generation labels.
|
||||
- Do not add stock, agency, or classifieds marks to `watermark_registry.py`.
|
||||
- Do not add templates for regulator-defined disclosure icons (EU Code of Practice Annex 1).
|
||||
- Keep `erase --region` generic and user-directed; do not build an automatic stock-watermark remover on it.
|
||||
|
||||
Full boundary and legal context: [`docs/legal-and-safety.md`](docs/legal-and-safety.md).
|
||||
@@ -31,19 +32,23 @@ Optional features and installation groups are documented in [`docs/installation.
|
||||
|
||||
`maintain.sh` runs dependency freshness and security checks, Ruff, Pyright scoped to `src/`, and the parallel test suite. Full-project Pyright is not the project gate because the ML dependency graph can exhaust Node memory.
|
||||
|
||||
The gate does not pass `--ignore-unfixed` to uv-secure, so a transitive CVE with no released fix stops it before Ruff, Pyright and the tests run. This matches the global rule: run and report those three separately. Scanner failures are fatal too. Triaged blockers and the recheck procedure: `Known security-gate blocks` in [`docs/development.md`](docs/development.md).
|
||||
|
||||
Command, gate, typing, and model-test invariants auto-load from [`.claude/rules/development.md`](.claude/rules/development.md). Environment recovery, CI behavior, and fixture policy live in [`docs/development.md`](docs/development.md).
|
||||
|
||||
Before a release, read [`docs/release-and-distribution.md`](docs/release-and-distribution.md). Treat the release as complete only after PyPI, Homebrew, the Hugging Face Space, and the ComfyUI Registry are verified; conda is not published. Keep the source-distribution public allowlist.
|
||||
|
||||
The published Agent Skill is [`skills/remove-ai-watermarks/`](skills/remove-ai-watermarks/). Update it in the same change as any CLI edit that alters commands, extras, exit codes, mark keys, or the intended-use boundary. Install and publish steps: [`docs/agent-skill.md`](docs/agent-skill.md).
|
||||
|
||||
## Module architecture
|
||||
|
||||
[`docs/module-internals.md`](docs/module-internals.md) is the canonical per-module map, including design decisions, thresholds, calibration history, incident records, and regression guards. Read the relevant section before changing a subsystem.
|
||||
|
||||
Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md).
|
||||
Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md). Pixel photo classification is [`docs/photo-classify.md`](docs/photo-classify.md); the separate OpenAI/Google/unknown export-pipeline signal is [`docs/source-classify.md`](docs/source-classify.md). Neither runs inside `identify` or constitutes SynthID detection. Classifier research is split between general [`AI-generated image classifiers`](docs/ai-generated-image-classifiers.md) and [`SynthID source classifiers`](docs/synthid-classifiers.md). Other SynthID campaign logs are [`docs/synthid-detector-research.md`](docs/synthid-detector-research.md) and [`docs/synthid-removal-research.md`](docs/synthid-removal-research.md). The pre-split chronological archive is [`docs/synthid-detector-removal-plan.md`](docs/synthid-detector-removal-plan.md). The benchmark kernel and its pinned local oracles (AudioSeal, Perth, PixelSeal, VideoSeal) cover image, audio, and video watermark cohorts and research studies: [`docs/benchmark-kernel.md`](docs/benchmark-kernel.md).
|
||||
|
||||
## Data safety
|
||||
|
||||
Follow [`data/README.md`](data/README.md) for public fixture, calibration, oracle, and evaluation layout. Store each tracked binary once and keep generated evaluation outputs outside the repository.
|
||||
Follow [`data/README.md`](data/README.md) for public fixture, calibration, oracle, and evaluation layout. Use only publication-cleared inputs in tracked data paths. Store each tracked binary once and keep generated evaluation outputs outside the repository.
|
||||
|
||||
## Rules and conventions
|
||||
|
||||
@@ -51,4 +56,4 @@ Topic-specific rules live in `.claude/rules/*.md` and are auto-loaded when match
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| `development.md` | Command contracts, project gate, typing boundaries, model-adjacent tests, and the detection-path measurement rule |
|
||||
| `development.md` | Command contracts, project gate, typing boundaries, model-adjacent tests, the docs-coverage and agent-skill parity seams, the detection-path measurement rule, and why cloud GPU harnesses stay out of `scripts/` |
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
Remove AI provenance marks from images and video you generated yourself:
|
||||
|
||||
- known visible labels such as the Gemini sparkle and vendor text marks;
|
||||
- invisible pixel watermarks through diffusion regeneration;
|
||||
- known visible labels such as the Google Gemini sparkle watermark and vendor text marks;
|
||||
- invisible pixel watermarks through direct local-format disruption or diffusion regeneration;
|
||||
- C2PA, EXIF, XMP, IPTC, and related AI metadata.
|
||||
|
||||
Video support covers provenance identification, complete visible-plus-metadata
|
||||
cleaning, directory batches, visible Sora, Veo, Seedance, Dola, Hailuo, and
|
||||
Kling mark removal, and oracle-certified VAE regeneration for video SynthID
|
||||
cleaning, directory batches, visible Sora, Veo, Seedance, Doubao, Dola,
|
||||
Hailuo AI, and Kling AI mark removal, and oracle-certified VAE regeneration for video SynthID
|
||||
removal.
|
||||
|
||||
> Try it online at [raiw.cc](https://raiw.cc) if you do not want to install Python
|
||||
> or run diffusion models locally.
|
||||
> [raiw.cc](https://raiw.cc) runs this library as a hosted service, with the GPU
|
||||
> included and nothing to install. Visible mark and metadata removal at Standard
|
||||
> output up to 12 MP are free there; original resolution above 12 MP and invisible
|
||||
> watermark removal are paid.
|
||||
|
||||
[](https://pypi.org/project/remove-ai-watermarks/)
|
||||
[](https://pypi.org/project/remove-ai-watermarks/)
|
||||
@@ -20,16 +22,21 @@ removal.
|
||||
[](LICENSE)
|
||||
[](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml)
|
||||
[](https://github.com/sponsors/wiltodelta)
|
||||
[](https://skills.sh/wiltodelta/remove-ai-watermarks)
|
||||
|
||||
> This project is for lawful use on content you own. It does not target stock
|
||||
> agency previews or other watermarks that protect third party paid content.
|
||||
> See [scope, safety, and legal notes](docs/legal-and-safety.md).
|
||||
> This is a research project on AI provenance signals, for lawful use on content
|
||||
> you own. It is provided as is, without warranty or liability, and each user is
|
||||
> responsible for how they use it. It does not target stock agency previews or
|
||||
> other watermarks that protect third party paid content. Some jurisdictions,
|
||||
> including China, restrict removing AI labels or supplying tools that do. See
|
||||
> [scope, safety, and legal notes](docs/legal-and-safety.md).
|
||||
|
||||
## Choose what you want to do
|
||||
|
||||
| Goal | Command | GPU |
|
||||
| --- | --- | --- |
|
||||
| Find provenance signals and watermarks | `identify` | No |
|
||||
| Classify a photograph from pixels (opt-in, not provenance) | `classify` | No |
|
||||
| Remove known visible AI marks | `visible` | No |
|
||||
| Erase a region you select | `erase` | No |
|
||||
| Strip AI metadata | `metadata` | No |
|
||||
@@ -38,27 +45,36 @@ removal.
|
||||
| Strip AI metadata from video | `video metadata` | No |
|
||||
| Remove a registered visible AI mark from video | `video visible` | No |
|
||||
| Process a directory of videos | `video batch` | Depends on mode |
|
||||
| Remove video SynthID with the certified VAE profile | `video invisible` | Recommended |
|
||||
| Apply the calibrated video-pixel SynthID-removal profile | `video invisible` | Recommended |
|
||||
| Regenerate an image to disrupt invisible watermarks | `invisible` | Required (CUDA) |
|
||||
| Run visible, invisible, and metadata removal | `all` | Recommended |
|
||||
| Process a directory | `batch` | Depends on mode |
|
||||
|
||||
Microsoft Paint and Photos InvisMark declarations are routed automatically to
|
||||
pixel regeneration. The `all` command removes both the hidden pixel watermark and
|
||||
its linked C2PA manifest; metadata stripping alone removes only the manifest. A
|
||||
specialized Python API can inspect and disrupt the validated local
|
||||
`Watermarker.dll` payload without diffusion.
|
||||
|
||||
## Installation modes
|
||||
|
||||
| Need | Install |
|
||||
| --- | --- |
|
||||
| Metadata inspection and stripping | `remove-ai-watermarks` |
|
||||
| Photograph AI-versus-camera classification | `remove-ai-watermarks[classify]` |
|
||||
| OpenAI/Google/unknown source-export classification | `remove-ai-watermarks[source-classify]` |
|
||||
| Visible detection and removal | `remove-ai-watermarks[visible]` |
|
||||
| Visible video processing | `remove-ai-watermarks[video]` |
|
||||
| Video SynthID removal | `remove-ai-watermarks[video,diffusion]` |
|
||||
| Torch-free DWT-DCT detection | `remove-ai-watermarks[detect]` |
|
||||
| Direct local Paint InvisMark disruption | `remove-ai-watermarks[pixels]` |
|
||||
| Invisible image removal (needs CUDA) | `remove-ai-watermarks[qwen-zimage]` |
|
||||
| Every production feature | `remove-ai-watermarks[all]` |
|
||||
| Every production feature available on the active Python | `remove-ai-watermarks[all]` |
|
||||
|
||||
Lower-level and specialized extras include `pixels`, `heif`, `trustmark`,
|
||||
`migan`, `lama`, and `diffusion`. The
|
||||
`migan`, `lama`, `diffusion`, `classify-onnx`, and `source-classify`. The
|
||||
[installation guide](docs/installation.md#feature-extras) documents their exact
|
||||
dependency composition and model requirements.
|
||||
dependency composition, Python compatibility, and model requirements.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -74,6 +90,34 @@ Inspect an image:
|
||||
remove-ai-watermarks identify image.png
|
||||
```
|
||||
|
||||
To classify a photograph from pixels (AI versus camera, optional provider),
|
||||
install the extra and call `classify`. `identify` never starts it:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[classify]"
|
||||
remove-ai-watermarks classify image.png
|
||||
```
|
||||
|
||||
Guide: [photo pixel classification](docs/photo-classify.md).
|
||||
|
||||
For a lightweight, abstaining OpenAI/Google/unknown source-export signal after
|
||||
metadata removal, use the separate Python API. It is not a SynthID detector:
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result = raiw.classify_source("image.png")
|
||||
print(result.label, result.reason)
|
||||
```
|
||||
|
||||
Install `remove-ai-watermarks[source-classify]`. Guide:
|
||||
[source-pipeline classification](docs/source-classify.md).
|
||||
|
||||
Signed provenance is the supported route for SynthID and `identify` reads it.
|
||||
There is no local SynthID pixel detector in the package. Research on a
|
||||
periodic lattice expert is in `scripts/synthid_runtime/` and
|
||||
[synthid-detector-research.md](docs/synthid-detector-research.md).
|
||||
|
||||
For visible watermark removal, install the pixel dependencies:
|
||||
|
||||
```bash
|
||||
@@ -106,7 +150,11 @@ The `video metadata` command does not transcode video or audio streams. Unlike
|
||||
the image command above, when `-o` is omitted it writes `<source>_clean` and
|
||||
preserves the original. MP4 and MOV
|
||||
inspection includes the native TC260 `AIGC` tag in
|
||||
`moov.udta.meta.keys/ilst`, including a `moov` placed after the media payload.
|
||||
`moov.udta.meta.keys/ilst`, including a `moov` placed after the media payload,
|
||||
plus the QuickTime-form `meta` variants Doubao's iOS export writes (a bare
|
||||
`meta` box as a direct `moov` child, and a keyless `hdlr=mdir` metadata list).
|
||||
Keyed `workflow` and `prompt` entries in the same metadata-list structure are
|
||||
also detected and removed, including ComfyUI exports.
|
||||
MKV and WebM inspection reads the normative
|
||||
`Segment.Tags.Tag.SimpleTag` placement. AVI uses `LIST/INFO/AIGC`, while FLV
|
||||
uses `script.onMetaData.AIGC`. The non-ISOBMFF formats are remuxed with stream
|
||||
@@ -157,18 +205,21 @@ preserved through a timestamped in-memory NUT bridge instead of being flattened
|
||||
to the average frame rate. Non-zero source start timestamps are retained
|
||||
together with the copied audio offset. The default `--mark auto`
|
||||
scans all providers in one decode pass and selects the first stable match in
|
||||
the specificity order shown below. Pass an explicit mark to restrict detection
|
||||
to one provider.
|
||||
Sora covers the moving Sora 2 mascot and wordmark. Veo covers both the current
|
||||
the specificity order shown below. Valid Google AI-video provenance breaks a
|
||||
Sora/Veo cross-match in favor of Veo when the diamond is nested in the Sora
|
||||
region or Veo persists through the complete clip while Sora does not.
|
||||
Pass an explicit mark to restrict detection to one provider.
|
||||
Sora covers the moving Sora 2 mascot, with or without the wordmark; mascot-only
|
||||
matches also require the complete two-eye shape. Veo covers both the current
|
||||
four-point diamond and the legacy `Veo` text. Seedance covers the fixed boxed
|
||||
`AI` label, Dola covers the fixed `Dola AI` text, Hailuo covers the composite
|
||||
`MINIMAX | hailuo AI` label, and Kling covers the bottom-right `KLING AI`
|
||||
label with its version suffix. A completed encode is published atomically. No
|
||||
output is written when no stable mark is found.
|
||||
`AI` label, Dola covers the fixed `Dola AI` text, Hailuo AI covers the composite
|
||||
`MINIMAX | hailuo AI` label, and Kling AI covers the bottom-right `KLING AI` or
|
||||
`KlingAI` label with its version suffix. A completed encode is published
|
||||
atomically. No output is written when no stable mark is found.
|
||||
HDR, PQ/HLG, and greater-than-8-bit inputs are rejected before encoding rather
|
||||
than silently reduced through OpenCV's 8-bit BGR boundary.
|
||||
|
||||
Remove video SynthID:
|
||||
Apply the calibrated video-pixel SynthID-removal profile:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[video,diffusion]"
|
||||
@@ -181,10 +232,14 @@ completed encode atomically. The default `noise_std=0.15` profile passed both
|
||||
the two-carrier calibration and a complete public eight-second Veo oracle
|
||||
check. Google does not publish a local decoder, so a fresh provider check
|
||||
remains useful for unusually important files or after provider changes, but it
|
||||
is not a product result state.
|
||||
is not a product result state. The CLI therefore describes video-pixel
|
||||
regeneration instead of claiming a per-file SynthID verdict, and reports the
|
||||
copied audio watermark as `UNVERIFIED`.
|
||||
|
||||
For invisible watermark removal, install the `qwen-zimage` extra. **An NVIDIA GPU
|
||||
is required**: both profiles are CUDA-only, and there is no CPU or MPS fallback.
|
||||
For invisible watermark removal through the CLI or high-level API, install the
|
||||
`qwen-zimage` extra. **An NVIDIA GPU is required**: all diffusion profiles are
|
||||
CUDA-only, and there is no CPU or MPS fallback. The specialized local Paint API
|
||||
uses the `pixels` extra without CUDA.
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
@@ -198,6 +253,20 @@ image came from an AI generator, add `--force`:
|
||||
remove-ai-watermarks invisible image.png -o clean.png --force
|
||||
```
|
||||
|
||||
Typography-heavy images can opt into the experimental verified-text post-pass.
|
||||
It accepts manually reviewed strings and line boxes or an operator-verified
|
||||
geometry-only manifest; it never treats raw OCR output as ground truth or runs
|
||||
automatically:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[text-restoration]"
|
||||
remove-ai-watermarks invisible image.png -o clean.png \
|
||||
--text-manifest verified-lines.json --force
|
||||
```
|
||||
|
||||
See the [CLI guide](docs/cli.md#restore-operator-verified-text) for the manifest
|
||||
schema, compatibility restrictions, and oracle caveats.
|
||||
|
||||
See the [installation guide](docs/installation.md) for Homebrew, uv, optional
|
||||
features, and development setup.
|
||||
|
||||
@@ -209,12 +278,22 @@ features, and development setup.
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
The `after` raster is generated from the tracked `before` raster by the public path:
|
||||
|
||||
```bash
|
||||
uv run remove-ai-watermarks visible demo_banana_before.png \
|
||||
--backend cv2 -o demo_banana_after.png
|
||||
```
|
||||
|
||||
### High quality invisible removal
|
||||
|
||||
`qwen-zimage` is the default profile: a Qwen-Image-2512 Lightning pass under Canny
|
||||
ControlNet, followed by SAM-masked Z-Image repair of any detected face. The
|
||||
alternative, `sdxl-zimage`, swaps the global stage for SDXL and keeps the same face
|
||||
stage. Both are CUDA only.
|
||||
stage. A third profile, `chroma-zimage`, uses the Apache-2.0 Chroma1 global pass
|
||||
with its own flat vendor floors; see `docs/chroma1-engine-research.md` for the
|
||||
calibration. `--pipeline auto` picks sdxl-zimage for Google, chroma-zimage for
|
||||
Microsoft, and qwen-zimage otherwise. All are CUDA only.
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
@@ -290,17 +369,28 @@ remove-ai-watermarks batch ./images --mode all
|
||||
|
||||
Visible mark support includes:
|
||||
|
||||
- Google Gemini and Nano Banana sparkle;
|
||||
- Doubao, Jimeng, Qwen, Kling, Yuanbao, Baidu, LibLibAI, and RunningHub labels;
|
||||
- one calibrated Samsung Galaxy AI label variant.
|
||||
- Google Gemini and Nano Banana visible sparkle watermark;
|
||||
- Doubao, the Jimeng wordmark and top-left `AI生成` layouts, Qwen, Kling AI,
|
||||
Yuanbao, Dola, WorkBuddy, Baidu, LiblibAI's bottom-center wordmark and compact
|
||||
top-left pill, and RunningHub labels;
|
||||
- calibrated Microsoft top-right white and beige AI-badge layouts;
|
||||
- calibrated Italian and Korean Samsung Galaxy AI disclosures;
|
||||
- a generic brand-less bottom-right `AI生成` fallback for OS/gallery-level
|
||||
AI-edit stamps that carry no vendor wordmark of their own (vivo and Xiaomi
|
||||
Gallery confirmed); synthetic-only calibration, weaker evidence than the
|
||||
vendor-tuned detectors above;
|
||||
- the OpenArt wordmark, placed at the frame center rather than a corner;
|
||||
uncalibrated (one confirmed real case, no captured corpus).
|
||||
|
||||
Metadata and provenance inspection covers C2PA, EXIF, XMP, IPTC, common
|
||||
generator parameters, China TC260 AIGC labels, and several vendor specific
|
||||
signals. Optional decoders add support for open DWT-DCT watermarks and Adobe
|
||||
TrustMark.
|
||||
generator parameters in image and video containers, China TC260 AIGC labels,
|
||||
and several vendor specific signals. Optional decoders add support for open
|
||||
DWT-DCT watermarks and Adobe TrustMark.
|
||||
|
||||
The exact support matrix, including important locale and detector limits, lives
|
||||
in [supported signals](docs/supported-signals.md).
|
||||
in [supported signals](docs/supported-signals.md). The
|
||||
[synthetic example gallery](data/fixtures/visible/README.md) carries one canonical
|
||||
sample for every registered image and video mark.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -312,13 +402,14 @@ Visible removal follows three steps:
|
||||
|
||||
Metadata removal uses format aware stripping. JPEG metadata removal preserves
|
||||
the encoded image scan instead of recompressing it. Native MP4/MOV TC260 values
|
||||
are blanked without changing box sizes or media offsets. Other supported
|
||||
containers use their corresponding metadata path.
|
||||
and keyed generation parameters are blanked without changing box sizes or
|
||||
media offsets. Other supported containers use their corresponding metadata
|
||||
path.
|
||||
|
||||
Invisible removal is different. It regenerates the image through a diffusion
|
||||
pipeline to disrupt pixel and frequency domain watermarks. This changes the
|
||||
image and cannot guarantee that a proprietary verifier will reject every
|
||||
output.
|
||||
Invisible removal through the CLI and high-level API regenerates the image through
|
||||
a diffusion pipeline. Specialized callers can instead decode and disrupt the
|
||||
positively identified local Microsoft Paint format with much smaller pixel changes.
|
||||
Neither path can guarantee that every proprietary verifier will reject every output.
|
||||
|
||||
See [supported signals](docs/supported-signals.md) and
|
||||
[known limitations](docs/known-limitations.md) for the full technical boundary.
|
||||
@@ -333,12 +424,24 @@ import remove_ai_watermarks as raiw
|
||||
result, removed = raiw.remove_visible("watermarked.png", "clean.png")
|
||||
print(removed)
|
||||
|
||||
report = raiw.remove_visible_detailed("watermarked.png", "clean.png")
|
||||
print(report.status) # cleaned | partial | unvalidated | no_watermark
|
||||
|
||||
complete = raiw.remove_all("watermarked.png", "clean.png")
|
||||
print(complete.visible_status, complete.visible_marks)
|
||||
|
||||
images = raiw.remove_batch("images", "images_clean", mode="visible")
|
||||
for item in images.items:
|
||||
print(item.source, item.visible_status, item.visible_marks)
|
||||
|
||||
provenance = raiw.identify_video("input.mp4")
|
||||
report = raiw.inspect_video_metadata("input.mp4")
|
||||
complete = raiw.remove_video_all("input.mp4", "clean.mp4")
|
||||
print(complete.visual_invisible_action, complete.audio.watermark_status)
|
||||
batch = raiw.remove_video_batch("videos", "videos_clean")
|
||||
cleaned = raiw.remove_video_metadata("input.mp4")
|
||||
synthid_cleaned = raiw.remove_video_invisible("input.mp4", "synthid_clean.mp4")
|
||||
print(synthid_cleaned.visual_invisible_action, synthid_cleaned.audio.watermark_status)
|
||||
visible = raiw.remove_video_visible("input.mp4", "clean.mp4")
|
||||
print(visible.mark)
|
||||
veo = raiw.remove_video_visible("veo.mp4", "veo_clean.mp4", mark="veo")
|
||||
@@ -358,6 +461,24 @@ See the [Python API guide](docs/python-api.md) for visible removal, the full
|
||||
`remove_all` and `remove_batch` pipeline, provenance inspection, metadata
|
||||
stripping, and diffusion usage.
|
||||
|
||||
## Agent skill
|
||||
|
||||
Coding agents can install the published skill and drive this CLI:
|
||||
|
||||
```bash
|
||||
npx skills add wiltodelta/remove-ai-watermarks
|
||||
```
|
||||
|
||||
Claude Code can add this repository as a plugin marketplace:
|
||||
|
||||
```text
|
||||
/plugin marketplace add wiltodelta/remove-ai-watermarks
|
||||
/plugin install remove-ai-watermarks@remove-ai-watermarks
|
||||
```
|
||||
|
||||
The skill refuses stock-agency and other third-party paid-asset marks. See
|
||||
[the agent skill guide](docs/agent-skill.md).
|
||||
|
||||
## ComfyUI
|
||||
|
||||
The separate
|
||||
@@ -375,7 +496,7 @@ invisible removal.
|
||||
detail.
|
||||
- Visible video removal recognizes the moving Sora 2 wordmark, the current Veo
|
||||
diamond plus legacy `Veo` text, the Seedance boxed `AI` label, and the fixed
|
||||
Dola, Hailuo, and Kling labels. It does not recognize the older Sora Turbo
|
||||
Doubao, Dola, Hailuo AI, Vidu, and Kling AI labels. It does not recognize the older Sora Turbo
|
||||
corner swirl or unregistered layouts from those providers.
|
||||
The classical OpenCV backend can smear structured backgrounds; use MI-GAN or
|
||||
LaMa when recovery quality matters.
|
||||
@@ -383,9 +504,10 @@ invisible removal.
|
||||
The shipped profile is oracle-certified, but no public local decoder can
|
||||
certify an arbitrary output at runtime. Recheck unusually important outputs
|
||||
after provider changes.
|
||||
- Invisible-watermark removal requires CUDA. Both profiles refuse any other
|
||||
device at construction rather than falling back to one that cannot run them.
|
||||
Visible removal, metadata stripping and `identify` still run anywhere.
|
||||
- Invisible-watermark removal through the CLI and high-level API requires CUDA.
|
||||
All diffusion profiles refuse any other device at construction rather than
|
||||
falling back to one that cannot run them. The specialized local Paint API,
|
||||
visible removal, metadata stripping and `identify` run without CUDA.
|
||||
- Provider watermark systems can change. Validate important outputs with the
|
||||
provider's own verifier when one is available.
|
||||
|
||||
@@ -399,8 +521,8 @@ uv run --extra video --extra diffusion python scripts/video_synthid_sweep.py inp
|
||||
```
|
||||
|
||||
To score what an output actually cost, use `scripts/video_fidelity_probe.py`:
|
||||
the engine's own PSNR is measured before the resize and the encode, so only the
|
||||
probe sees the delivered picture.
|
||||
the engine's own PSNR is measured after the input resize and before final
|
||||
encoding, so only the probe sees the delivered picture.
|
||||
|
||||
```bash
|
||||
uv run --extra video python scripts/video_fidelity_probe.py input.mp4 input_clean.mp4
|
||||
@@ -415,8 +537,11 @@ reinterpret the pixel result returned `UNAVAILABLE`; that follow-up was not a
|
||||
verifier rerun and does not invalidate the built-in verdicts. A 2026-07-31
|
||||
full-clip check on a public eight-second Veo sample found `0.10` still detected
|
||||
and `0.15` not detected, so `0.15` is now the certified default. The
|
||||
reproducible hashes and verdicts live in
|
||||
`data/evaluations/video-synthid-oracle.csv`.
|
||||
reproducible hashes and verdicts for that full-clip check -- one carrier, two
|
||||
rows -- live in `data/evaluations/video-synthid-oracle.csv`. The earlier
|
||||
two-clip calibration is narrative only: its verdicts were not recorded in a
|
||||
tracked manifest, so treat the certified default as resting on the 2026-07-31
|
||||
rows.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -430,6 +555,7 @@ Start with the [documentation index](docs/index.md).
|
||||
- [Scope, safety, and legal notes](docs/legal-and-safety.md)
|
||||
- [Module internals](docs/module-internals.md)
|
||||
- [Release and distribution](docs/release-and-distribution.md)
|
||||
- [Agent skill](docs/agent-skill.md)
|
||||
|
||||
Research notes and historical experiments are listed separately in the
|
||||
[documentation index](docs/index.md). They explain past decisions but do not
|
||||
@@ -445,7 +571,8 @@ bash maintain.sh
|
||||
```
|
||||
|
||||
See [module internals](docs/module-internals.md) before changing a subsystem
|
||||
with documented invariants.
|
||||
with documented invariants. Report vulnerabilities as described in
|
||||
[SECURITY.md](SECURITY.md).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Security policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
Security fixes go into the latest release. Older releases are not patched;
|
||||
upgrade through the channel you installed from (PyPI or Homebrew).
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Report vulnerabilities privately through
|
||||
[GitHub private vulnerability reporting](https://github.com/wiltodelta/remove-ai-watermarks/security/advisories/new).
|
||||
Do not open a public issue for an unpatched vulnerability. Include the affected
|
||||
version, the command or API call, and a minimal input that reproduces it.
|
||||
|
||||
Vulnerabilities in a dependency, such as a CVE in a package from `uv.lock`, can
|
||||
be reported as a regular issue when the advisory is already public.
|
||||
|
||||
## Dependency vulnerabilities
|
||||
|
||||
Dependabot raises alerts and update pull requests for `uv.lock` and GitHub
|
||||
Actions, and CI scans `uv.lock` with
|
||||
[`uv-secure`](https://github.com/owenlamont/uv-secure) on every push and pull
|
||||
request. A vulnerable dependency is upgraded to a fixed release; advisories
|
||||
without one are tracked under
|
||||
[Known security-gate blocks](docs/development.md#known-security-gate-blocks).
|
||||
@@ -6,6 +6,15 @@ Tracked data is organized by purpose:
|
||||
data/
|
||||
fixtures/
|
||||
provenance/ Real format and provenance fixtures used by tests
|
||||
(source records live in fixtures/README.md)
|
||||
visible/ Per-mark example gallery: one synthetic canonical example per
|
||||
registered mark plus documented provider originals where available
|
||||
(see fixtures/visible/README.md)
|
||||
synthetic/ Frozen synthetic renders that unit tests stamp onto frames, each
|
||||
produced by the script named in the test that loads it
|
||||
captures/
|
||||
2026-09/ Provider outputs from the maintainer's own accounts, stored as
|
||||
served, indexed by manifest.csv (see its README)
|
||||
calibration/
|
||||
<vendor>/ Minimal controlled inputs needed to rebuild detector assets
|
||||
synthid/
|
||||
@@ -14,13 +23,24 @@ data/
|
||||
full-pipeline-quality.csv
|
||||
Reusable full-pipeline evaluation selection
|
||||
evaluations/
|
||||
engine-selection/
|
||||
Prompt-matched, publication-cleared AI content matrix for
|
||||
deterministic auto-engine and watermark benchmark research
|
||||
fidelity/ Evaluation instructions and hand-verified ground truth
|
||||
video-synthid-oracle.csv
|
||||
Reproducible full-clip Gemini SynthID verdicts
|
||||
```
|
||||
|
||||
[`image-watermark-examples.csv`](image-watermark-examples.csv) and
|
||||
[`video-watermark-examples.csv`](video-watermark-examples.csv) are the coverage
|
||||
indexes for real carriers. They point to each canonical binary instead of
|
||||
duplicating it and record unsupported gaps and source-specific licensing.
|
||||
|
||||
## Storage rules
|
||||
|
||||
Use only publication-cleared inputs in tracked data paths. Keep generated
|
||||
evaluation outputs outside the repository.
|
||||
|
||||
1. Store each binary image once. Evaluation manifests and documentation point
|
||||
to its canonical location.
|
||||
2. Put executable test fixtures in `fixtures/`.
|
||||
@@ -31,7 +51,20 @@ data/
|
||||
commands, hashes, and oracle verdicts instead of committing another corpus
|
||||
copy. A small curated before-and-after example may live in `docs/images/`
|
||||
when it is part of the public documentation.
|
||||
6. Runtime detector assets belong in `src/remove_ai_watermarks/assets/`.
|
||||
Local benchmark manifests may point to these canonical inputs or to an
|
||||
untracked corpus; every artifact and reference must carry its SHA-256 digest.
|
||||
`evaluations/engine-selection/` is the bounded exception for ORIGINAL study
|
||||
inputs: its manifest tracks a prompt-matched OpenAI/Meta content matrix, not
|
||||
generated model outputs or watermark-oracle evidence.
|
||||
6. Put provider captures that no test reads in `captures/<yyyy-mm>/`, with a
|
||||
`manifest.csv` row per capture. A capture that is also a fixture is stored
|
||||
once, in `fixtures/`, and its manifest row points there. Request a new
|
||||
capture only for a service x vendor pair not yet captured, or for a
|
||||
service's own model: another version of a known pair adds no provenance
|
||||
evidence.
|
||||
7. Keep third-party fixture license notices in `licenses/`, outside directories
|
||||
that tests enumerate as media inputs.
|
||||
8. Runtime detector assets belong in `src/remove_ai_watermarks/assets/`.
|
||||
Unregistered research candidates belong in
|
||||
`scripts/assets/visible-mark-candidates/` so they are not shipped in the
|
||||
wheel.
|
||||
@@ -45,16 +78,17 @@ runtime assets.
|
||||
video removal profile works, so it is also the source of truth for three shipped
|
||||
defaults: `tests/test_video_invisible.py` asserts that `noise_std`, `long_side`,
|
||||
and `fps` together match a row this manifest records as certified. Changing one
|
||||
of those three without adding the row that certifies it fails the suite. `vae` is
|
||||
deliberately outside that check because neither tracked row records one; add it
|
||||
to the assertion in the same commit as the first row that does.
|
||||
of those three without adding the row that certifies it fails the suite. Both
|
||||
historical rows mark `vae` as `unrecorded`: the repository must not infer a model
|
||||
identity from the current default. Add it to the assertion in the same commit as
|
||||
the first oracle row that records and verifies one.
|
||||
|
||||
| Column | Meaning |
|
||||
| --- | --- |
|
||||
| `date`, `source_url`, `source_sha256` | Identify the carrier. |
|
||||
| `source_width`, `source_height`, `source_fps` | Carrier geometry. Without it the actual downscale factor of a row cannot be recovered later. |
|
||||
| `duration_seconds`, `source_verdict` | Clip length submitted and the verifier's reading of the untouched carrier. |
|
||||
| `vae`, `noise_std`, `long_side`, `fps`, `seed` | The full run configuration. |
|
||||
| `vae`, `noise_std`, `long_side`, `fps`, `seed` | The run configuration. `unrecorded` means the historical run did not preserve the VAE identity. |
|
||||
| `output_sha256` | Identifies the exact submitted file. |
|
||||
| `output_verdict` | One of `detected`, `not_detected`, `indeterminate`, `refused`. |
|
||||
| `output_verdict_text` | The verifier's wording, verbatim. |
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
> **Status (built 2026-06-05):** flat black and gray Samsung Galaxy AI captures
|
||||
> were obtained and the detection asset was solved. The asset now supports
|
||||
> detection and mask geometry only. Current removal localizes the wordmark and
|
||||
> uses the shared fill backend. The text below records the capture plan and its
|
||||
> locale and resolution limits.
|
||||
> detection and detector-aligned alpha-mask geometry. Current removal aligns the
|
||||
> solved alpha to the detector's winning template box and uses the shared fill
|
||||
> backend. The text below records the capture plan and its locale and resolution
|
||||
> limits.
|
||||
|
||||
Goal: capture the Samsung Galaxy AI "✦ Contenuti generati dall'AI" visible wordmark
|
||||
over known flat backgrounds so we can rebuild and validate the silhouette used
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Provider captures, September 2026
|
||||
|
||||
Original outputs downloaded from the maintainer's own accounts between
|
||||
2026-09-23 and 2026-09-25 and cleared for publication by the maintainer. The
|
||||
services are the Gemini API, xAI Grok, Vidu, Wan, Apple Image Playground,
|
||||
YouTube (re-encodes of our own uploads), and the paid Higgsfield, Runway and
|
||||
Dreamina plans. Every prompt is a neutral coffee-mug scene; Higgsfield
|
||||
Genjutsu and the video-edit samples take our own Higgsfield outputs as input.
|
||||
|
||||
Files are stored byte-for-byte as served. Nothing is re-encoded, because the
|
||||
provenance under study (C2PA, TC260 labels, `hf-job-id` chunks) lives in the
|
||||
container bytes.
|
||||
|
||||
`manifest.csv` lists every capture once, including the ones that are already
|
||||
test fixtures: those rows point at their `data/fixtures/` path instead of a
|
||||
second copy (`stored` is `existing`). Columns:
|
||||
|
||||
| Column | Meaning |
|
||||
| --- | --- |
|
||||
| `sha256` | Digest of the stored file |
|
||||
| `path` | Canonical repository path |
|
||||
| `stored` | `new` for files in this directory, `existing` for a fixture already tracked |
|
||||
| `service` | Service the file was downloaded from |
|
||||
| `source_name` | Local capture name, which names the model |
|
||||
| `captured` | Capture date |
|
||||
| `ai_from_metadata`, `platform` | `identify` (images) or `identify_video` (videos) verdict from metadata only, at capture time |
|
||||
|
||||
What each service does to the upstream provenance, and the per-model
|
||||
findings, are summarized in the live-captures table of
|
||||
[`docs/watermarking-landscape.md`](../../../docs/watermarking-landscape.md).
|
||||
The `google-photos/` files are the maintainer's own iPhone photos edited in
|
||||
Google Photos on iOS (an Ask edit replacing a dog with a cat, an eraser edit, and
|
||||
two other AI edits), published unmodified with their EXIF GPS position by the
|
||||
maintainer's decision on 2026-09-25. Google's checker in Gemini found SynthID in
|
||||
all four with the metadata stripped. The Apple Clean Up photo is not stored.
|
||||
The `google-photos-web/` files are a negative control from the Google Photos web
|
||||
editor (2026-09-26): one of the maintainer's photos, downloaded untouched and after
|
||||
Enhance, Sky (Vivid) and a square crop. The web editor offers no generative tool and
|
||||
writes no Content Credentials, so none of the four carries C2PA, IPTC or an AI
|
||||
credit, and Google's checker in Gemini found no SynthID in any of them (three
|
||||
explicit negatives; the crop drew a reply that did not name SynthID and is recorded
|
||||
as indeterminate).
|
||||
|
||||
- License: repository license; generated and explicitly cleared by the maintainer
|
||||
|
After Width: | Height: | Size: 6.2 MiB |
|
After Width: | Height: | Size: 6.8 MiB |
|
After Width: | Height: | Size: 7.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 407 KiB |
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 636 KiB |
|
After Width: | Height: | Size: 603 KiB |
|
After Width: | Height: | Size: 627 KiB |
|
After Width: | Height: | Size: 715 KiB |
|
After Width: | Height: | Size: 529 KiB |
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 691 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 945 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 5.0 MiB |
|
After Width: | Height: | Size: 4.7 MiB |
|
After Width: | Height: | Size: 5.9 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 4.4 MiB |
|
After Width: | Height: | Size: 4.9 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 6.2 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 4.9 MiB |
|
After Width: | Height: | Size: 1.6 MiB |