mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-20 16:07:17 +02:00
Compare commits
16
Commits
No files matched your search
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Agent skill and Claude Code plugin for the remove-ai-watermarks CLI.",
|
||||
"version": "1.0.12"
|
||||
"version": "1.0.13"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ 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. 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.
|
||||
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. The dev `.venv` always carries the pixel stack, so it cannot show what the default install does. To check a no-signal or fail-closed change there, build a throwaway venv outside the repository (`uv venv /tmp/bare && VIRTUAL_ENV=/tmp/bare uv pip install .`) and run `/tmp/bare/bin/remove-ai-watermarks` against 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.
|
||||
|
||||
@@ -29,9 +29,15 @@ The unified maintainer entry point is `scripts/provider_oracles.py`; its workflo
|
||||
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 provider check may use the finite set of configured account slots and surfaces
|
||||
after a refusal. Do not add new logins, IP rotation, network-route failover,
|
||||
unbounded retries, or cross-session result merging. Never commit the local
|
||||
manifest. A provider check walks that provider's existing order: the official API
|
||||
surface, then its Web oracle, then the remaining configured account slots.
|
||||
After a refusal, quota response, transport error, or indeterminate verdict,
|
||||
record the response and continue with the next item as its own command. Do not
|
||||
ask for confirmation before the switch. Anyone doing the task may run the
|
||||
configured checks. Do not add new logins, IP rotation, or unbounded retries.
|
||||
Do not walk a prepared route list or rotate addresses inside one call; the next
|
||||
run is a new command and picks a new session. Do not merge results from
|
||||
different sessions into one response. Never commit the local
|
||||
slot file.
|
||||
Every manual result stays bound to the exact sanitized upload hash and preserves the
|
||||
provider response verbatim.
|
||||
@@ -39,38 +45,49 @@ 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. A request to use a provider covers every configured surface, including Web,
|
||||
without a second confirmation. An API refusal, quota response, transport error, or
|
||||
indeterminate verdict stays on that surface and never silently submits the file to Web.
|
||||
for them. Google's Web surfaces are `gemini-web` and `synthid-web`
|
||||
(`https://synthid.com/`). After the current surface is exhausted, record the
|
||||
response and continue with the next surface as its own command. One call does
|
||||
not upload the same file to the next surface, and the verdicts stay separate.
|
||||
|
||||
Google Web oracle automation uses only the user's existing authenticated real Chrome
|
||||
session. Gemini uses an explicit `/u/N/` account index. The SynthID Detector at
|
||||
`https://synthid.com/` (`synthid-web`) uses the same Chrome profile and does not take a
|
||||
`/u/N/` index; its terms forbid driving the page as an automated API. 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
|
||||
A signed-in account is required only for `gemini-web`. That surface uses the user's
|
||||
existing authenticated real Chrome session and an explicit `/u/N/` account index.
|
||||
Every other Web surface, including `synthid-web`, uses an isolated Playwright
|
||||
context and may name a proxy. Those contexts must never attach to or copy state
|
||||
from the user's real browser. `synthid-web` has no `/u/N/` index. Its terms forbid
|
||||
bulk verification and quota evasion, so one run submits the prepared rows once
|
||||
and does not start a sign-in. If the page says `Please sign in before detection.`
|
||||
and returns no watermark label, keep that row and continue the same prepared
|
||||
upload in the user's existing real Chrome. That Chrome session stays on the
|
||||
direct route: no proxy, no new login, and no cookie copy into Playwright. If
|
||||
that Chrome also asks for sign-in, leave its row unsubmitted. API calls
|
||||
stay on the direct SDK transport and do not take `proxy_url_env`. 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.
|
||||
A ThorData exit is not prepared ahead of time. Each unauthenticated Web run picks
|
||||
a new session and a country in Europe or the United States, and keeps that one exit
|
||||
for the browser context so page requests do not change identity. A country or
|
||||
`sessid` already written into the proxy URL is discarded. The next run picks again.
|
||||
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.
|
||||
requests alive, while Microsoft, Meta, and SynthID Detector require network idle. Preserve exact provider
|
||||
errors as indeterminate evidence, then continue with the next configured route or surface.
|
||||
|
||||
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.
|
||||
gitignored `.env` and select one slot explicitly. The process environment may override
|
||||
the matching `.env` value. Their SDK transport sets `trust_env=False`, so ambient proxy
|
||||
variables do not reroute an API check, and the slot does not take `proxy_url_env`.
|
||||
After a refusal or rate limit, continue with the next configured slot in a new call,
|
||||
then with the Web surface.
|
||||
|
||||
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.
|
||||
equality with the named local source, keeps `trust_env=False`, and makes one submit
|
||||
inside that call. Ambient proxy settings do not apply, and the slot does not take
|
||||
`proxy_url_env`. The next slot or the Web surface is a new call and runs after a
|
||||
refusal, quota response, transport error, or indeterminate verdict. Report
|
||||
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.
|
||||
|
||||
@@ -92,7 +109,7 @@ 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.
|
||||
From a worktree without its own `.venv`, `uv run` imports the package from the MAIN checkout, because that is where the editable install points; a worktree with its own `.venv` imports its own `src`. Either way, a script measuring a worktree's edit must assert `module.__file__` resolves inside that worktree (insert its `src` at `sys.path[0]` when it does not), or it silently compares unmodified code against itself.
|
||||
|
||||
## Research harnesses stay out of this repository
|
||||
|
||||
|
||||
@@ -1,54 +1,65 @@
|
||||
---
|
||||
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.
|
||||
description: Run hash-bound Google, OpenAI, Microsoft, or Meta watermark-oracle checks for this repository. Use for provider verification, multi-account Gemini checks, SynthID Detector checks at synthid.com, isolated Playwright checks, or recording oracle evidence. Walk the existing plan automatically when a task needs a provider verdict.
|
||||
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.
|
||||
Read `docs/provider-oracles.md` for surface keys, slot schema, preparation, and
|
||||
recording commands. 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.
|
||||
Do not optimize candidates adaptively against an external oracle. Keep each
|
||||
submitted artifact frozen inside its own batch and bind the result to that
|
||||
artifact's hash. Do not erase failures.
|
||||
|
||||
Anyone doing the task may run the configured checks. Do not ask who is allowed
|
||||
to use the oracle, and do not ask for confirmation before moving to the next
|
||||
surface, slot, or route.
|
||||
|
||||
## Order
|
||||
|
||||
Walk `plan PROVIDER`. That command only prints the order. The order itself stays:
|
||||
|
||||
1. The official API surface, when this tooling has one for that provider, media
|
||||
type, and configured slot. OpenAI is `openai-api`. Microsoft is
|
||||
`microsoft-api`.
|
||||
2. That provider's Web oracle. OpenAI is `openai-web` and Microsoft is
|
||||
`microsoft-web`, both through isolated Playwright. Meta is `meta-web`
|
||||
through isolated Playwright. Google is `gemini-web` on the user's existing
|
||||
real Chrome, then `synthid-web` at `https://synthid.com/` through isolated
|
||||
Playwright. Meta and Google have no API surface, so their first Web oracle is
|
||||
the first row.
|
||||
3. The remaining configured account slots of those surfaces. A signed-in
|
||||
account is required only for Gemini, and that slot stays on the direct
|
||||
route. Every other Web surface may use isolated Playwright and a proxy.
|
||||
That proxy exit is chosen when the run starts.
|
||||
|
||||
When the current surface, slot, or route is exhausted by a refusal, quota
|
||||
response, transport error, or indeterminate verdict, record the exact response
|
||||
and run the next item as its own command. One adapter call still makes one
|
||||
request.
|
||||
|
||||
## 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.
|
||||
Before starting, identify the finite set of configured 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
|
||||
through the remaining slots in the order above.
|
||||
|
||||
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, or a new network route require authorization.
|
||||
The oracles and slots already configured for the requested provider may be used
|
||||
again, including their Web surfaces, without a second confirmation. 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. For OpenAI and Microsoft, the default plans are API then Web.
|
||||
Google and Meta currently have only usable Web routes in this tooling. Google's
|
||||
routes are `gemini-web` and `synthid-web`. Inspect the order with `plan PROVIDER`.
|
||||
The same request may use every configured surface.
|
||||
|
||||
The priority is a selection policy, not automatic failover. An API refusal,
|
||||
quota response, transport error, or indeterminate verdict must be recorded and
|
||||
reported. The same request already covers every configured surface of that
|
||||
provider, including Web. Run Web as its own acknowledged check and keep its
|
||||
verdict separate. Do not replace the API result with the Web result.
|
||||
inconclusive results and disagreements between accounts. Stop when the plan is
|
||||
complete or no configured usable slot or route remains, and report remaining
|
||||
rows. Do not start a new login. Do not change candidate pixels. An API result
|
||||
is not replaced by the Web result. Run Web as its own acknowledged check and
|
||||
keep its verdict separate.
|
||||
|
||||
## Route by surface
|
||||
|
||||
@@ -69,10 +80,8 @@ Playwright CLI runner, copied cookies, or a new login.
|
||||
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.
|
||||
4. Upload the prepared files for the task. Pass the acknowledgement flag where
|
||||
the command requires one. 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
|
||||
@@ -84,34 +93,43 @@ Playwright CLI runner, copied cookies, or a new login.
|
||||
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.
|
||||
record that it is unavailable and continue with another configured existing
|
||||
session. When no usable Gemini session remains, record that and continue with
|
||||
`synthid-web`. Do not start a new login.
|
||||
|
||||
### SynthID Detector
|
||||
|
||||
Use the user's real authenticated Chrome. Do not use isolated Playwright: the
|
||||
portal's additional terms forbid using the page as an automated API, bulk
|
||||
verification, and quota evasion.
|
||||
Use the isolated Playwright runner at `https://synthid.com/`. The page does not
|
||||
require an account login, so the slot may name a proxy. Do not start a sign-in.
|
||||
The portal's terms forbid bulk verification and quota evasion, so one invocation
|
||||
submits the prepared rows once and does not retry.
|
||||
|
||||
1. Validate a `synthid-web` slot. It names `browser_profile` and `account_label`.
|
||||
It does not use `google_account_index` or a proxy variable.
|
||||
1. Validate a `synthid-web` slot. It does not name `account_label`,
|
||||
`browser_profile`, or `google_account_index`. It may name `proxy_url_env`.
|
||||
2. Prepare an immutable batch outside the repository. Inspect images the same
|
||||
way as for Gemini, and do not submit a file that still shows a provider logo.
|
||||
3. Open `https://synthid.com/` in the named Chrome profile. Accept the terms
|
||||
dialog when it is shown. Do not start a new login. If the page requires
|
||||
sign-in and that profile has no session, leave the row unsubmitted.
|
||||
4. Attach one prepared upload through the page's own file control.
|
||||
5. Read the settled label. `Watermark detected` is `detected`. `Watermark not
|
||||
detected` is `not_detected`. Quota, sign-in, and unclear wording stay
|
||||
`refused` or `indeterminate`.
|
||||
3. Run `run-web` with the upload acknowledgement. The runner attaches one
|
||||
prepared upload through the page's file input.
|
||||
4. Read the settled label. `Watermark detected` is `detected`. `Watermark not
|
||||
detected` and `SynthID was not detected` are `not_detected`. Quota and
|
||||
unclear wording stay `refused` or `indeterminate`. Do not click a sign-in
|
||||
button.
|
||||
5. If the anonymous page says `Please sign in before detection.` and returns no
|
||||
watermark label, keep that row. Open the same prepared upload in the user's
|
||||
existing real Chrome, where a session is already signed in. Read
|
||||
`~/Documents/GitHub/claude-config/docs/browser-automation.md` and use that
|
||||
authenticated Chrome. Do not start a new login, do not copy cookies into
|
||||
Playwright, and do not send that Chrome session through a proxy. If that
|
||||
Chrome also asks for sign-in, leave its row unsubmitted.
|
||||
6. Record the verbatim response. The result covers partner SynthID from Google
|
||||
DeepMind, Nvidia, OpenAI, and Kakao, and it does not name which partner. Do
|
||||
not record it as a Gemini-only or OpenAI-only verdict.
|
||||
|
||||
### OpenAI, Microsoft, and Meta Web
|
||||
### OpenAI, Microsoft, Meta, and SynthID Detector Web
|
||||
|
||||
Use the isolated Playwright runner, never the user's real Chrome:
|
||||
Use the isolated Playwright runner. Do not attach that runner to the user's
|
||||
real Chrome. The sign-in fallback in the SynthID Detector section is a separate
|
||||
session in the existing Chrome, not a proxy through this runner:
|
||||
|
||||
```bash
|
||||
uv run python scripts/provider_oracles.py run-web /outside/repo/batch/manifest.json \
|
||||
@@ -122,41 +140,43 @@ 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.
|
||||
uses one context, one route, and fresh page navigation per upload. One
|
||||
invocation does not retry internally. CAPTCHA, bot checks, throttling, transport
|
||||
failure, and unknown page wording remain distinct recorded outcomes. After any
|
||||
of those, or after a refusal, run the next configured route or surface.
|
||||
|
||||
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
|
||||
Microsoft, Meta, and SynthID Detector 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.
|
||||
settled `indeterminate` result, not as a clean result. Do not resubmit that
|
||||
same page in the same batch; continue with the next configured route or surface.
|
||||
|
||||
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.
|
||||
A signed-in account is required only for Gemini. Every other Web surface,
|
||||
including SynthID Detector, starts in isolated Playwright and may use a proxy.
|
||||
When SynthID Detector asks for a sign-in, continue that one file in the user's
|
||||
existing real Chrome, on the direct route. An
|
||||
API call stays on the direct SDK transport and does not take a proxy.
|
||||
|
||||
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.
|
||||
A ThorData credential may live in one gitignored environment variable. Do not
|
||||
prepare a country or a session in that URL or in a slot. Each run of an
|
||||
unauthenticated Web oracle discards a country or `sessid` already present and
|
||||
picks a new session in a country in Europe or the United States. That one
|
||||
session stays for the browser context, because a ThorData endpoint without
|
||||
`sessid` rotates the exit on each request and breaks a page whose resources
|
||||
must share one network identity. The next run picks again. After a refusal,
|
||||
start that next run; do not walk a list of prepared country routes.
|
||||
|
||||
### 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.
|
||||
print a key or copy one into a slot or manifest. Continue through the configured
|
||||
named API slots, with a separate batch for each, and then continue to
|
||||
`openai-web`. API slots use a direct SDK transport that ignores environment
|
||||
proxy settings. The current adapter is image-only. One adapter call does not
|
||||
switch tokens; the next slot is a new call.
|
||||
|
||||
### Microsoft API
|
||||
|
||||
@@ -167,8 +187,10 @@ 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.
|
||||
settings, makes one submit request with no retry inside that call, and polls
|
||||
only the returned operation. After that call is refused, hits quota, fails, or
|
||||
returns indeterminate, continue with another `microsoft-api` slot or with
|
||||
`microsoft-web`. Keep Microsoft's Watermark and C2PA markers as separate verdicts.
|
||||
|
||||
## Evidence rules
|
||||
|
||||
|
||||
@@ -84,7 +84,13 @@ finding.
|
||||
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.
|
||||
External oracle uploads use the `provider-oracles` skill. When a cohort needs a
|
||||
provider verdict, walk that skill's plan automatically: API first, then the Web
|
||||
oracle, including `synthid-web` at synthid.com for Google, then the remaining
|
||||
configured account slots. A signed-in account is required only for Gemini.
|
||||
Every other Web surface, including `synthid-web`, may use isolated Playwright
|
||||
and a proxy, and each proxied run picks a new session in Europe or the United
|
||||
States. If SynthID Detector asks for a sign-in, continue that file in the user's
|
||||
existing real Chrome on the direct route, and do not start a new login. Do not ask for
|
||||
confirmation before switching, and do not adapt candidates against an oracle.
|
||||
Do not substitute one provider's verdict for another's.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "remove-ai-watermarks",
|
||||
"version": "1.0.12",
|
||||
"version": "1.0.13",
|
||||
"description": "Identify and remove AI provenance marks from images and video the user generated or edited, using the remove-ai-watermarks CLI.",
|
||||
"author": {
|
||||
"name": "wiltodelta",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
name: Publish to MCP registry
|
||||
|
||||
# Lists the released PyPI package in the official MCP registry
|
||||
# (registry.modelcontextprotocol.io) from server.json at the release tag.
|
||||
# GitHub OIDC proves ownership of the io.github.wiltodelta namespace, so no
|
||||
# interactive login or stored secret is involved. The registry validates the
|
||||
# PyPI release itself (the mcp-name marker in its description), and the Release
|
||||
# event fires in parallel with publish.yml's upload, so this waits until PyPI
|
||||
# serves the version first. A version the registry already lists is skipped.
|
||||
# It is a workflow of its own, not a distribute.yml job, so a manual dispatch
|
||||
# re-lists one version without re-running Homebrew, the Space, or ComfyUI, and
|
||||
# a registry outage never fails release verification.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Released version to list (no leading v), e.g. 0.46.0"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Model-only prereleases are not Python package releases.
|
||||
if: ${{ github.event_name != 'release' || (startsWith(github.event.release.tag_name, 'v') && !github.event.release.prerelease) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
SERVER_NAME: io.github.wiltodelta/remove-ai-watermarks
|
||||
# The same name percent-encoded, as the registry's path segment takes it.
|
||||
SERVER_PATH: https://registry.modelcontextprotocol.io/v0/servers/io.github.wiltodelta%2Fremove-ai-watermarks
|
||||
# Pinned publisher release; the digest is GitHub's sha256 for the asset.
|
||||
PUBLISHER_URL: https://github.com/modelcontextprotocol/registry/releases/download/v1.8.1/mcp-publisher_linux_amd64.tar.gz
|
||||
PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc
|
||||
|
||||
steps:
|
||||
- name: Resolve target version
|
||||
id: v
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT_NAME" = "release" ]; then
|
||||
v="${RELEASE_TAG#v}"
|
||||
else
|
||||
v="$INPUT_VERSION"
|
||||
fi
|
||||
# The version becomes a git ref and a URL component. Reject malformed input.
|
||||
[[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+([a-zA-Z0-9.+-]*)$ ]]
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Skip a version the registry already lists
|
||||
id: listed
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status=$(curl -sS --connect-timeout 10 --max-time 20 -o /dev/null -w '%{http_code}' "$SERVER_PATH/versions/$VERSION")
|
||||
case "$status" in
|
||||
200) echo "listed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "- MCP registry: $SERVER_NAME $VERSION was already listed" >> "$GITHUB_STEP_SUMMARY" ;;
|
||||
404) echo "listed=false" >> "$GITHUB_OUTPUT" ;;
|
||||
*) echo "::error::Registry lookup returned HTTP $status"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# mcp-publisher reads only ./server.json, so fetch nothing else.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
if: ${{ steps.listed.outputs.listed == 'false' }}
|
||||
with:
|
||||
ref: v${{ steps.v.outputs.version }}
|
||||
sparse-checkout: server.json
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Check server.json matches the release
|
||||
if: ${{ steps.listed.outputs.listed == 'false' }}
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PYTHON'
|
||||
import json, os
|
||||
manifest = json.load(open("server.json", encoding="utf-8"))
|
||||
version = os.environ["VERSION"]
|
||||
assert manifest["name"] == os.environ["SERVER_NAME"], manifest["name"]
|
||||
found = [manifest["version"], *(p["version"] for p in manifest["packages"])]
|
||||
assert all(v == version for v in found), f"server.json versions {found} != {version}"
|
||||
PYTHON
|
||||
|
||||
- name: Wait for PyPI to serve the release
|
||||
if: ${{ steps.listed.outputs.listed == 'false' }}
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 40); do
|
||||
status=$(curl -sS --connect-timeout 10 --max-time 20 -o /dev/null -w '%{http_code}' \
|
||||
"https://pypi.org/pypi/remove-ai-watermarks/$VERSION/json" || true)
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "PyPI serves $VERSION"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $attempt: PyPI returned HTTP $status for $VERSION"
|
||||
sleep 30
|
||||
done
|
||||
echo "::error::PyPI never served $VERSION"
|
||||
exit 1
|
||||
|
||||
- name: Publish with a verified mcp-publisher
|
||||
if: ${{ steps.listed.outputs.listed == 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o mcp-publisher.tar.gz "$PUBLISHER_URL"
|
||||
echo "$PUBLISHER_SHA256 mcp-publisher.tar.gz" | sha256sum -c -
|
||||
tar xzf mcp-publisher.tar.gz mcp-publisher
|
||||
./mcp-publisher login github-oidc
|
||||
./mcp-publisher publish
|
||||
|
||||
- name: Read the entry back
|
||||
if: ${{ steps.listed.outputs.listed == 'false' }}
|
||||
env:
|
||||
VERSION: ${{ steps.v.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 10); do
|
||||
status=$(curl -sS --connect-timeout 10 --max-time 20 -o /dev/null -w '%{http_code}' \
|
||||
"$SERVER_PATH/versions/$VERSION" || true)
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "- MCP registry: $SERVER_NAME $VERSION is listed" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $attempt: registry returned HTTP $status"
|
||||
sleep 15
|
||||
done
|
||||
echo "::error::The registry does not list $SERVER_NAME $VERSION"
|
||||
exit 1
|
||||
@@ -6,9 +6,10 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
|
||||
|
||||
The project gives users control over provenance marks on content they generated or edited themselves. It does not automatically remove stock-agency, marketplace, classifieds, tiled-preview, or other marks that protect a third party's paid or copyrighted asset.
|
||||
|
||||
- Add visible templates only for AI-generation labels.
|
||||
- Add visible templates only for marks the vendor declares as AI-generation labels, whatever plan hides them; plan-removable product branding ("Made with Gamma") is not one.
|
||||
- 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).
|
||||
- LLM text watermarks are out of scope.
|
||||
- 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).
|
||||
@@ -32,11 +33,11 @@ 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).
|
||||
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. Scanner failures are fatal too. The gate also runs `scripts/sync_c2pa_soft_bindings.py --check`, which fails when the packaged C2PA soft-binding registry snapshot is stale; rerun it without `--check` to refresh the snapshot from upstream. 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.
|
||||
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. The MCP registry listing publishes itself from `publish-mcp-registry.yml` and does not block a release. 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).
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ removal.
|
||||
[](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml)
|
||||
[](https://github.com/sponsors/wiltodelta)
|
||||
[](https://skills.sh/wiltodelta/remove-ai-watermarks)
|
||||
[](https://glama.ai/mcp/servers/wiltodelta/remove-ai-watermarks)
|
||||
|
||||
> 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
|
||||
|
||||
+3
-2
@@ -705,8 +705,9 @@ the tool contract are in the [MCP server guide](mcp-server.md).
|
||||
|
||||
The CLI uses nonzero exit codes for meaningful incomplete outcomes, including
|
||||
no detected target on commands that would otherwise regenerate or create a
|
||||
misleading unchanged result, processing errors, and a required invisible step
|
||||
that could not run.
|
||||
misleading unchanged result, a batch directory with nothing to process,
|
||||
processing errors, an input that is not a readable image or video, and a
|
||||
required invisible step that could not run.
|
||||
|
||||
Scripts should check the process exit code and the output path. The detailed
|
||||
per-command contract is maintained in
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Read this reference for environment setup, dependency recovery, CI behavior, and
|
||||
- On an unreliable connection, sync `dev` plus only the required feature extras, such as `diffusion`, and run the checks directly instead of downloading every optional learned backend.
|
||||
- Run `uv` from the repository root or it may create a bare environment without the project dependencies.
|
||||
|
||||
`maintain.sh` starts with `uv sync --all-extras`, so a lean environment is replaced whenever the gate runs. Syncing back down can leave `opencv-python-headless` half-removed: `import cv2` then succeeds against an empty namespace while `cv2.INTER_LINEAR` is gone, which surfaces as a suite that stalls or reports hundreds of unrelated failures. Repair it with `uv sync --frozen --extra dev --reinstall-package opencv-python-headless` rather than re-reading the diff.
|
||||
`maintain.sh` starts with `uv sync --all-extras`, so a lean environment is replaced whenever the gate runs. Syncing back down can leave `opencv-python-headless` half-removed: `import cv2` then succeeds against an empty namespace while `cv2.INTER_LINEAR` is gone, which surfaces as a suite that stalls or reports hundreds of unrelated failures. Repair it with `uv sync --frozen --extra dev --reinstall-package opencv-python-headless` rather than re-reading the diff. A stale worktree environment can fail differently: `import cv2` raises `partially initialized module 'cv2' has no attribute 'gapi_wip_gst_GStreamerPipeline'`, because the lock installs `opencv-contrib-python` 4.x beside `opencv-python` 5.x into one `cv2` directory. Reinstalling the three OpenCV packages did not fix that on 2026-10-17; deleting the worktree's `.venv` and running `uv sync --frozen --all-extras` did.
|
||||
|
||||
Pyright is only meaningful with the extras its imports need. Scoped to `src/`, a lean environment reports `TrustMark`, `transformers` and `huggingface_hub` as unresolved, which reads as a type error and is not one; add `--extra trustmark --extra classify` before believing it.
|
||||
|
||||
|
||||
@@ -621,8 +621,9 @@ stream safely.
|
||||
raising. User facing callers must use `strip_and_verify` and inspect its
|
||||
surviving marker mapping before reporting success. `strip_and_verify` recovers
|
||||
when `image_io` can still decode the raster by normalizing the container and
|
||||
checking again. A truly undecodable file still reports the surviving markers.
|
||||
The CLI uses this verified path.
|
||||
checking again. A truly undecodable file raises `ValueError` before anything is
|
||||
written (since 2026-10-18; it used to be copied through with its surviving
|
||||
markers). The CLI uses this verified path.
|
||||
|
||||
### Sixteen bit PNG metadata stripping
|
||||
|
||||
|
||||
@@ -46,6 +46,21 @@ It also does not add templates for regulator-defined disclosure icons, such as
|
||||
the EU icons in Annex 1 of the Code of Practice on marking and labelling of
|
||||
AI-generated content, which deployers use to disclose deepfakes.
|
||||
|
||||
It does not remove product branding that a paid plan of the same product turns
|
||||
off, such as the "Made with Gamma" badge. A visible mark stays in scope when its
|
||||
vendor describes it as identifying AI-generated content, whichever plan turns it
|
||||
off: the Gemini sparkle, or the
|
||||
[visible watermark](https://support.google.com/gemininotebook/answer/16213268)
|
||||
that Gemini Notebook, formerly NotebookLM, adds to generated images and videos.
|
||||
|
||||
Watermarks in LLM-generated text are out of scope. The Claude and OpenAI text
|
||||
marks change which words the model chooses and add no hidden characters
|
||||
([Anthropic](https://www.anthropic.com/news/claude-text-watermark),
|
||||
[OpenAI](https://help.openai.com/en/articles/8912793-provenance-signals-in-openai-generated-content)).
|
||||
Stripping invisible Unicode does not touch them, only a substantial rewrite
|
||||
removes them, and their detectors are not public, so no removal could be
|
||||
verified.
|
||||
|
||||
The `erase` command is a generic region tool. Users are responsible for having
|
||||
the right to edit the selected content.
|
||||
|
||||
|
||||
+7
-3
@@ -58,7 +58,10 @@ writes over an input: an `output` equal to the source, or an `output_dir` equal
|
||||
to the input directory, is refused before anything runs.
|
||||
|
||||
Exit codes keep their CLI meaning. `0` and `2` are results, not tool errors, and
|
||||
`2` means no supported signal was found, which is unknown rather than clean.
|
||||
`2` means no supported signal was found, or a `batch` directory held nothing to
|
||||
process, which is unknown rather than clean. An
|
||||
input that is not a readable image or video exits `1`, so `identify` on it is a
|
||||
tool error even though the command still printed a report.
|
||||
Click also exits `2` on a usage error such as a missing file; its `Usage:` line on
|
||||
stderr marks that case as a tool error. Every other nonzero exit code is returned
|
||||
with `isError: true` and the command's stderr.
|
||||
@@ -87,5 +90,6 @@ default mode, receives method-not-found and falls back to the handshake.
|
||||
`io.github.wiltodelta/remove-ai-watermarks`. The registry verifies ownership by
|
||||
the `mcp-name:` marker in `README.md`, which becomes the PyPI description, so it
|
||||
can only verify a release whose README carries that marker.
|
||||
[`glama.json`](../glama.json) names the maintainer for Glama. Publishing steps
|
||||
are in [release and distribution](release-and-distribution.md#mcp-registry).
|
||||
[`glama.json`](../glama.json) names the maintainer for Glama. Each release is
|
||||
published automatically; the workflow is described in
|
||||
[release and distribution](release-and-distribution.md#mcp-registry).
|
||||
@@ -50,7 +50,32 @@ Important contracts:
|
||||
It decodes the source before the scrub gate and exits `1` when it cannot,
|
||||
because the gate reads undecodable bytes as "no target" and would otherwise
|
||||
report the no-signal code for a file it never read (fixed 2026-10-17).
|
||||
- The two no-signal conditions currently share exit code `2`.
|
||||
- The two no-signal conditions currently share exit code `2`, and so does a
|
||||
`batch` or `video batch` directory with no supported file, which writes
|
||||
nothing (decided 2026-10-18).
|
||||
- An input that is not a readable container exits `1` from `identify`,
|
||||
`metadata` and every `video` command, and fails its row in `batch` and `video
|
||||
batch` (fixed 2026-10-18). The metadata scanners read raw bytes, so random
|
||||
bytes, an empty file or a truncated PNG or MP4 used to print the same "No AI
|
||||
metadata found" or "AI metadata stripped" a real file gets, with exit `0`.
|
||||
`metadata.unreadable_reason` is the one probe: rasters must decode in Pillow or
|
||||
OpenCV, ISOBMFF boxes and EBML elements must tile the file, and a RIFF file must
|
||||
hold its declared size. `strip_and_verify` and `video._video_source` ask it, and
|
||||
`identify` prints its report first, so an AI signal read from a truncated file
|
||||
is still shown. Absent optional detectors are not unreadable input: without the
|
||||
pixel stack `identify` keeps exit `0` and its caveat (decided 2026-10-18).
|
||||
Regression coverage: `tests/test_fail_closed.py`.
|
||||
- `visible` and `erase` strip and verify AI metadata on a staged sibling and exit
|
||||
`1` with nothing published when a marker survives; they used to swallow a failed
|
||||
strip unless `--verbose` was set. The library `remove_visible` does the same and
|
||||
raises `MetadataStripIncomplete`, as `remove_all` already did. The diffusion
|
||||
writer behind `invisible` stages the same way and raises with nothing published
|
||||
(fixed 2026-10-19; it used to leave the marked file at the output path).
|
||||
`_internal.utils.staged_sibling` is the shared stage: it never pre-creates the
|
||||
name, so the published file keeps the default permissions. These writers strip
|
||||
through `metadata.strip_written_output`, which skips the readability probe:
|
||||
the file is the package's own encode, and the probe's full decode doubled the
|
||||
cost of the strip (decided 2026-10-19).
|
||||
- A quiet `metadata --check` and a successful `metadata --remove` (same for
|
||||
`video metadata`) end by repeating the `identify` limit: the pixel channel is
|
||||
untouched and a watermark such as SynthID has no local decoder once its
|
||||
@@ -241,9 +266,17 @@ as `workflow` and `prompt` uses the same structural walk; removal blanks the
|
||||
complete key and value in place. This preserves every box size,
|
||||
`stco`/`co64` offset, encoded stream byte apart from the Kling SEI tag below, and
|
||||
source-sized memory bound.
|
||||
Publication is atomic, and a malformed top-level walk is copied unchanged. A
|
||||
Publication is atomic, and a malformed top-level walk is copied unchanged,
|
||||
although `strip_and_verify` refuses such a source before the stripper runs. A
|
||||
generic `AIGC` key whose value has no TC260 field is ignored.
|
||||
|
||||
The in-memory image path (AVIF, HEIF, JPEG-XL and unknown suffixes, through
|
||||
`strip_c2pa_boxes`) blanks the same top-level boxes to same-size `free` boxes.
|
||||
It used to drop them, which moved `mdat` under an unchanged `iloc`: a phone HEIC
|
||||
whose C2PA `uuid` box preceded `mdat` came out undecodable (2026-10-13).
|
||||
`tests/test_metadata.py::TestIsobmffStripKeepsOffsets` decodes a cleaned HEIC to
|
||||
guard it.
|
||||
|
||||
App-export JSON in a keyed or keyless metadata item is blanked the same way.
|
||||
Dreamina's MP4 export writes it twice, under `LvMetaInfo` and
|
||||
`com.apple.quicktime.artwork`. The file-level scan read both from the raw head
|
||||
@@ -822,7 +855,7 @@ Key contracts:
|
||||
written output for surviving markers. If the metadata-preserving decoder
|
||||
rejected the container but `image_io` can still decode its raster,
|
||||
`strip_and_verify` normalizes that raster and scans again. A truly undecodable
|
||||
file keeps the surviving-marker result.
|
||||
file raises `ValueError` before anything is written.
|
||||
|
||||
Detection and removal must stay in parity. A new marker is incomplete until the
|
||||
scanner can find it, the remover can reach every supported placement, and a
|
||||
|
||||
+89
-102
@@ -1,9 +1,9 @@
|
||||
# Provider oracle tooling
|
||||
|
||||
`scripts/provider_oracles.py` is the development-only entry point for the four
|
||||
remote watermark-oracle families used by this project. It drives Google through
|
||||
the user's real authenticated Chrome and drives the other public Web surfaces
|
||||
through isolated Playwright. It keeps provider scope, execution identity,
|
||||
remote watermark-oracle families used by this project. It drives Gemini through
|
||||
the user's real authenticated Chrome and drives every other public Web surface,
|
||||
including SynthID Detector, through isolated Playwright. It keeps provider scope, execution identity,
|
||||
immutable media, and verbatim results in one format without exposing any oracle
|
||||
through the installed CLI or public Python API.
|
||||
|
||||
@@ -13,8 +13,8 @@ through the installed CLI or public Python API.
|
||||
| --- | --- | --- | --- |
|
||||
| `openai-api` | OpenAI SynthID | Official Content Provenance API | Images |
|
||||
| `microsoft-api` | Microsoft InvisMark | Azure Content Provenance Detection API | Images |
|
||||
| `gemini-web` | Google SynthID | Signed-in Gemini web account | Images and videos |
|
||||
| `synthid-web` | Partner SynthID | SynthID Detector at synthid.com, real Chrome | None recorded |
|
||||
| `gemini-web` | Google SynthID | User's authenticated Chrome | Images and videos |
|
||||
| `synthid-web` | Partner SynthID | Anonymous Playwright page at synthid.com | None recorded |
|
||||
| `meta-web` | Meta Content Seal | Anonymous Meta identification page | Images |
|
||||
| `microsoft-web` | Microsoft InvisMark | Public Azure AI validation page | Images |
|
||||
| `openai-web` | OpenAI SynthID | Public web verifier | Images |
|
||||
@@ -25,7 +25,7 @@ The default selection order is API first, then Web. OpenAI therefore plans
|
||||
`openai-api` before `openai-web`, and Microsoft plans `microsoft-api` before
|
||||
`microsoft-web`. Google and Meta currently have no usable API adapter in this
|
||||
tooling, so each of their Web surfaces is primary. Google has two: `gemini-web`
|
||||
and `synthid-web`.
|
||||
and `synthid-web` at `https://synthid.com/`.
|
||||
|
||||
Inspect the machine-readable order before preparing a batch:
|
||||
|
||||
@@ -35,12 +35,15 @@ uv run python scripts/provider_oracles.py plan microsoft
|
||||
uv run python scripts/provider_oracles.py plan google
|
||||
```
|
||||
|
||||
A request to use a provider covers every configured surface of that provider,
|
||||
including Web, without a second confirmation. API-first is still the order to
|
||||
try, not a lock on the other surfaces. An API refusal, quota response, transport
|
||||
failure, or indeterminate verdict is recorded as-is. It does not silently
|
||||
upload the same file to Web; run that surface as its own acknowledged check and
|
||||
keep the two verdicts separate.
|
||||
API-first is the default order. Run the API surface when this tooling has a
|
||||
usable route for the provider, media, and local configuration. Google and Meta
|
||||
have no API surface, so each of their Web oracles is primary. For Google that
|
||||
is `gemini-web`, then `synthid-web`. After a refusal, quota response, transport
|
||||
failure, or indeterminate verdict, record that result and continue with the next
|
||||
surface as its own acknowledged command, then the remaining configured account
|
||||
slots of those surfaces. One call does not upload the same file to the next
|
||||
surface. Keep the verdicts separate. Do not ask for confirmation before the
|
||||
switch. Anyone doing the task may run the configured checks.
|
||||
|
||||
The old Google SynthID Detector portal at `labs.google/synthid` does not work.
|
||||
On 2026-09-07 that entry point redirected a signed-in browser to a Google 404;
|
||||
@@ -60,10 +63,14 @@ and Kakao, and it did not list Apple. It defined two settled labels:
|
||||
`.tif`, `.gif`, `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`, `.mp4`,
|
||||
`.mov`, and `.webm`. This tooling still prepares only the project's existing
|
||||
JPEG, PNG, WebP, MP4, MP3, and WAV suffixes. The portal's additional terms
|
||||
forbid using the page as an automated API, bulk verification, and quota evasion,
|
||||
so `run-web` does not drive it. Use the real Chrome workflow below. A
|
||||
`Watermark detected` result is partner SynthID, not a Gemini-only or OpenAI-only
|
||||
verdict.
|
||||
forbid bulk verification and quota evasion. `run-web` may submit one prepared
|
||||
upload through isolated Playwright, with a proxy when the slot names one, and
|
||||
it does not start a sign-in. If the page says `Please sign in before detection.`
|
||||
and returns no watermark label, keep that row and open the same prepared upload
|
||||
in the user's existing real Chrome. That session stays on the direct route, with
|
||||
no proxy and no new login. If that Chrome also asks for sign-in, leave its row
|
||||
unsubmitted. A `Watermark detected` result is partner SynthID, not a Gemini-only
|
||||
or OpenAI-only verdict.
|
||||
Google's newer Search verification entry point did not provide an independent
|
||||
oracle allowance in the session tested on 2026-09-13. Lens accepted the
|
||||
metadata-stripped, previously Gemini-positive image
|
||||
@@ -218,43 +225,20 @@ default; `--slots PATH` explicitly overrides it.
|
||||
"network_label": "office"
|
||||
},
|
||||
{
|
||||
"name": "synthid-personal",
|
||||
"name": "synthid-proxy",
|
||||
"surface": "synthid-web",
|
||||
"account_label": "google-account-a",
|
||||
"browser_profile": "Your Chrome",
|
||||
"network_label": "home"
|
||||
"account_label": null,
|
||||
"browser_profile": null,
|
||||
"network_label": "thordata",
|
||||
"proxy_url_env": "THORDATA_PROXY"
|
||||
},
|
||||
{
|
||||
"name": "openai-web-thordata-us",
|
||||
"name": "openai-web-proxy",
|
||||
"surface": "openai-web",
|
||||
"account_label": null,
|
||||
"browser_profile": null,
|
||||
"network_label": "thordata-us-residential",
|
||||
"proxy_url_env": "THORDATA_ROUTE_US"
|
||||
},
|
||||
{
|
||||
"name": "microsoft-web-thordata-de",
|
||||
"surface": "microsoft-web",
|
||||
"account_label": null,
|
||||
"browser_profile": null,
|
||||
"network_label": "thordata-de-residential",
|
||||
"proxy_url_env": "THORDATA_ROUTE_DE"
|
||||
},
|
||||
{
|
||||
"name": "meta-web-thordata-us",
|
||||
"surface": "meta-web",
|
||||
"account_label": null,
|
||||
"browser_profile": null,
|
||||
"network_label": "thordata-us-residential",
|
||||
"proxy_url_env": "THORDATA_ROUTE_US"
|
||||
},
|
||||
{
|
||||
"name": "openai-web-thordata-de",
|
||||
"surface": "openai-web",
|
||||
"account_label": null,
|
||||
"browser_profile": null,
|
||||
"network_label": "thordata-de-residential",
|
||||
"proxy_url_env": "THORDATA_ROUTE_DE"
|
||||
"network_label": "thordata",
|
||||
"proxy_url_env": "THORDATA_PROXY"
|
||||
},
|
||||
{
|
||||
"name": "openai-api-1",
|
||||
@@ -292,30 +276,34 @@ default; `--slots PATH` explicitly overrides it.
|
||||
```
|
||||
|
||||
A routed batch selects one slot explicitly. A Gemini slot must name the Google
|
||||
account, its real Chrome profile, and its nonnegative `/u/N/` account index. A
|
||||
SynthID Detector slot names the account and the same real Chrome profile, and it
|
||||
does not use `google_account_index`. An
|
||||
API slot must name its account or project and cannot name a browser profile. The
|
||||
account, its real Chrome profile, and its nonnegative `/u/N/` account index. An
|
||||
API slot must name its account or project and cannot name a browser profile. A
|
||||
Playwright slot for `openai-web`, `microsoft-web`, `meta-web`, or `synthid-web`
|
||||
does not name an account or a browser profile. The
|
||||
tool records the network route that the operator has already established.
|
||||
Non-Google Web slots may name `proxy_url_env`; Playwright reads the secret proxy
|
||||
URL from that environment variable without writing or logging it. The tool does
|
||||
not merge sessions or automatically change network routes. Account continuation
|
||||
is orchestrated by the skill, not by an individual adapter call. A request to
|
||||
verify a bounded set of files with a provider covers its configured account slots
|
||||
unless the operator restricts the account scope. Identify the finite set of
|
||||
available authenticated slots before starting and prepare one immutable batch
|
||||
per slot. Record quota refusals as `refused` and service failures according to
|
||||
their actual response, leave remaining rows in that slot unsubmitted, and
|
||||
continue automatically with the next authorized slot. Do not request permission
|
||||
again for files or accounts already covered by the task.
|
||||
Those four pages may name `proxy_url_env`; Playwright reads the
|
||||
secret proxy URL from that environment variable without writing or logging it.
|
||||
The country and session are not part of that preparation. Each run replaces
|
||||
them with a new session in a country in Europe or the United States.
|
||||
A signed-in account is required only for `gemini-web`. API calls stay on the
|
||||
direct SDK transport and do not take `proxy_url_env`. Every other Web surface
|
||||
uses isolated Playwright and may use a proxy. The tool does
|
||||
not merge sessions inside one adapter call. Account continuation is orchestrated
|
||||
by the skill, not by an individual adapter call. A request to verify files with
|
||||
a provider covers its configured account slots, surfaces, and network routes.
|
||||
Anyone doing the task may run those checks. Identify the finite set of
|
||||
configured slots before starting and prepare one immutable batch per slot.
|
||||
Record quota refusals as `refused` and service failures according to their
|
||||
actual response, leave remaining rows in that slot unsubmitted, and continue
|
||||
with the next slot, surface, or configured route. Do not ask for confirmation
|
||||
before that switch.
|
||||
|
||||
Submit each frozen artifact at most once per slot in that pass. Keep every
|
||||
Submit each frozen artifact at most once per slot in one pass. Keep every
|
||||
verbatim refusal, inconclusive reply, and disagreement between accounts. Stop
|
||||
when the planned checks finish or no authorized usable slot remains, and report
|
||||
unsubmitted rows. A new login, another provider, or a new network route needs
|
||||
authorization. The oracles and slots already configured for the requested
|
||||
provider may be used again, including their Web surfaces, without a second
|
||||
confirmation. Candidate pixels remain frozen throughout account continuation.
|
||||
when the plan is complete or no configured usable slot, surface, or route
|
||||
remains, and report anything still open. Do not start a new login. Each
|
||||
submitted artifact stays frozen inside its own batch. Do not change candidate
|
||||
pixels during the continuation.
|
||||
|
||||
Each `openai-api` slot must name exactly one `api_key_env`. Put the corresponding
|
||||
values in the gitignored local `.env`, never in the slot JSON:
|
||||
@@ -324,29 +312,27 @@ values in the gitignored local `.env`, never in the slot JSON:
|
||||
OPENAI_API_KEY_1=replace-with-first-key
|
||||
OPENAI_API_KEY_2=replace-with-second-key
|
||||
OPENAI_API_KEY_3=replace-with-third-key
|
||||
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
|
||||
THORDATA_PROXY=http://td-customer-USERNAME:PASSWORD@HOST.pr.thordata.net:9999
|
||||
```
|
||||
|
||||
The tool reads only the variable selected by the requested slot. A nonempty
|
||||
process environment value overrides the matching `.env` entry. It never prints
|
||||
the value or falls through to another token after authentication, entitlement,
|
||||
quota, or transport failure. The no-slot compatibility path reads
|
||||
the value. One call reads only its selected slot. After authentication,
|
||||
entitlement, quota, or transport failure, continue with the next configured
|
||||
slot in a new call. The no-slot compatibility path reads
|
||||
`OPENAI_API_KEY`.
|
||||
|
||||
Several Google accounts may share one real Chrome profile: give them distinct
|
||||
slot names and `google_account_index` values while keeping `browser_profile` the
|
||||
same.
|
||||
|
||||
The ThorData examples use its [residential proxy country parameter](https://doc.thordata.com/doc/proxies/residential-proxies/location-settings/country-region).
|
||||
The proxy username encodes one explicit two-letter ISO country code. Route
|
||||
selection remains explicit and never changes after a refusal. OpenAI,
|
||||
Microsoft, and Meta Web slots may reuse any route variable; the surface remains
|
||||
part of each distinct slot. OpenAI API slots never accept `proxy_url_env` and
|
||||
use the current direct network.
|
||||
ThorData encodes the exit in the username with its [country parameter](https://doc.thordata.com/doc/proxies/residential-proxies/location-settings/country-region)
|
||||
and a [session id](https://doc.thordata.com/doc/proxies/residential-proxies/session-control).
|
||||
A stored `country-XX` or `sessid` is not the exit this run will use. The runner
|
||||
discards both and appends one new session and one country from Europe or the
|
||||
United States. That pair stays for the browser context of the run. The next run
|
||||
chooses again, including after a refusal. Gemini and API slots never accept
|
||||
`proxy_url_env`. Anonymous Playwright slots may.
|
||||
|
||||
Validate and inspect the slots:
|
||||
|
||||
@@ -419,16 +405,18 @@ uv sync --extra dev
|
||||
uv run playwright install chromium
|
||||
```
|
||||
|
||||
Google is intentionally excluded from the standalone runner. Both `gemini-web`
|
||||
and `synthid-web` use the user's existing real Chrome profile, and the SynthID
|
||||
Detector terms forbid driving that page as an automated API. Invoke the project
|
||||
`provider-oracles` skill to prepare the batch and record the settled outcome.
|
||||
For Gemini, select the named profile and `/u/N/` account. For SynthID Detector,
|
||||
open `https://synthid.com/` in that profile. The portal has no `/u/N/` index.
|
||||
Accept the terms dialog when it is shown, attach one prepared upload, and read
|
||||
the explicit `Watermark detected` or `Watermark not detected` label. If the page
|
||||
requires sign-in and that profile has no session, record the row unsubmitted
|
||||
and do not start a new login.
|
||||
Gemini is the only surface excluded from the standalone runner, because it is
|
||||
the only one that requires a signed-in account. Invoke the project
|
||||
`provider-oracles` skill to drive that existing Chrome profile at the named
|
||||
`/u/N/` account. `synthid-web` is an anonymous page: `run-web` opens
|
||||
`https://synthid.com/` in isolated Playwright, attaches one prepared upload
|
||||
through the page's file input, and reads the explicit `Watermark detected`,
|
||||
`Watermark not detected`, or `SynthID was not detected` label. The portal has
|
||||
no `/u/N/` index. If the page says `Please sign in before detection.` and
|
||||
returns no watermark label, keep that row and continue the same prepared upload
|
||||
in the user's existing real Chrome. That session is direct: no proxy, no new
|
||||
login, and no cookie copy into the isolated browser. If that Chrome also asks
|
||||
for sign-in, leave its row unsubmitted.
|
||||
|
||||
For Gemini, ask the direct question stored in the batch manifest: `Does this
|
||||
image/video/audio contain a SynthID watermark? Use the SynthID verification
|
||||
@@ -437,8 +425,8 @@ general question about whether Google AI created or edited the media is not
|
||||
equivalent: it may elicit origin or visual-content reasoning without a SynthID
|
||||
verdict. SynthID Detector has no prompt; its settled labels are the verdict.
|
||||
|
||||
OpenAI Web, Microsoft Web, and Meta run entirely through a fresh isolated
|
||||
Playwright browser. A direct-route example:
|
||||
OpenAI Web, Microsoft Web, Meta, and SynthID Detector run through a fresh
|
||||
isolated Playwright browser. A direct-route example:
|
||||
|
||||
```bash
|
||||
uv run python scripts/provider_oracles.py prepare meta-web input.png \
|
||||
@@ -449,8 +437,9 @@ uv run python scripts/provider_oracles.py run-web \
|
||||
/tmp/meta-check-001/manifest.json --acknowledge-uploads
|
||||
```
|
||||
|
||||
For a ThorData-routed OpenAI, Microsoft, or Meta Web slot, keep the generated
|
||||
proxy URLs in the gitignored `.env`, then select one route explicitly:
|
||||
For an OpenAI, Microsoft, Meta, or SynthID Detector slot that names a ThorData
|
||||
credential, keep that URL in the gitignored `.env`. The run chooses the country
|
||||
and the session:
|
||||
|
||||
```bash
|
||||
uv run python scripts/provider_oracles.py run-web \
|
||||
@@ -467,12 +456,10 @@ ThorData [user-and-password endpoints without a `sessid` rotate the exit on each
|
||||
request](https://doc.thordata.com/doc/proxies/residential-proxies/session-control).
|
||||
That is unsuitable inside one browser context: page resources can
|
||||
arrive through different exits and trigger the provider's bot checks. For a
|
||||
ThorData hostname, the runner derives a non-secret sticky session id from the
|
||||
immutable manifest hash and appends a 90-minute session window when the
|
||||
configured username has no explicit session. The same batch therefore keeps
|
||||
one route while the provider retains it, a new prepared batch gets a distinct
|
||||
route, and an operator-supplied `sessid` remains unchanged. This pins a selected
|
||||
route; it does not retry or fail over after a refusal.
|
||||
ThorData hostname, each run therefore discards a prepared country and session,
|
||||
then keeps one new session and one country in Europe or the United States for
|
||||
that browser context. The next invocation, including a retry after a refusal,
|
||||
chooses again.
|
||||
For a selected proxy route it enables certificate handling in that isolated
|
||||
Chromium launch and context; direct launches keep Playwright's normal TLS
|
||||
verification. A 2026-09-07 preflight sent one read-only request through each
|
||||
@@ -489,7 +476,7 @@ quota refusal is `refused`, a transport failure is `unreachable`, and unknown or
|
||||
changed page wording is `indeterminate`.
|
||||
|
||||
Each provider mounts its file input before the upload handler is necessarily
|
||||
ready. Microsoft and Meta reach a usable handler at network idle. OpenAI keeps
|
||||
ready. Microsoft, Meta, and SynthID Detector reach a usable handler at network idle. OpenAI keeps
|
||||
background requests alive and never reaches network idle, so its observable
|
||||
readiness seam is the page `load` state instead. The adapter waits for the
|
||||
surface-specific state before assigning the file. OpenAI's static FAQ contains
|
||||
|
||||
+8
-4
@@ -79,7 +79,8 @@ For a path input, `remove_visible` and `remove_visible_detailed`:
|
||||
- reads metadata provenance for the default `auto` sensitivity;
|
||||
- preserves a separate alpha channel;
|
||||
- writes the output when an output path is supplied;
|
||||
- strips AI metadata from the written output by default;
|
||||
- strips AI metadata from the written output by default, and raises
|
||||
`MetadataStripIncomplete` without publishing it when a marker survives;
|
||||
- preserves the original bytes for a same-format no-op copy.
|
||||
|
||||
```python
|
||||
@@ -226,8 +227,9 @@ The complete field set, with the shipped defaults:
|
||||
and not to this table, because this seam had already drifted by eight fields
|
||||
before anything checked it.
|
||||
|
||||
If AI metadata survives the strip, `remove_all` raises `MetadataStripIncomplete`
|
||||
**before** writing anything: an AI-readable output is worse than no output.
|
||||
If AI metadata survives the strip, `remove_all` and a path-input `remove_visible`
|
||||
raise `MetadataStripIncomplete` **before** publishing anything: an AI-readable
|
||||
output is worse than no output.
|
||||
|
||||
`remove_batch` runs one mode over a directory and never lets a single bad file
|
||||
end the run:
|
||||
@@ -525,7 +527,9 @@ It checks the written output and returns `(output_path, surviving_markers)`.
|
||||
When the first strip leaves markers in a malformed but raster-decodable image,
|
||||
it normalizes the container through `image_io` and checks again. That recovery
|
||||
path preserves the pixels but drops standard metadata. Treat a nonempty
|
||||
`surviving_markers` mapping as a failure.
|
||||
`surviving_markers` mapping as a failure. A source that is not a readable image,
|
||||
video or audio container raises `ValueError` before anything is written;
|
||||
`metadata.unreadable_reason(path)` returns the same reason without stripping.
|
||||
|
||||
`remove_ai_metadata` is the lower level fail-safe transformer. It may copy an
|
||||
undecodable input through unchanged, so its return alone must not be presented
|
||||
|
||||
@@ -227,14 +227,13 @@ submission state and links live in
|
||||
`uvx remove-ai-watermarks mcp`. The registry accepts it only after PyPI serves a
|
||||
release whose description, the README, carries the `mcp-name:` marker, and
|
||||
`tests/test_mcp_server.py` pins `server.json` to the package version. The listing
|
||||
is not a release surface that blocks a release; after PyPI publishes a version:
|
||||
is not a release surface that blocks a release.
|
||||
|
||||
1. `mcp-publisher login github` with an account that owns `wiltodelta`;
|
||||
2. `mcp-publisher validate` and then `mcp-publisher publish` from the
|
||||
repository root;
|
||||
3. read the entry back from
|
||||
`https://registry.modelcontextprotocol.io/v0/servers?search=io.github.wiltodelta/remove-ai-watermarks`
|
||||
and check its version.
|
||||
[`publish-mcp-registry.yml`](../.github/workflows/publish-mcp-registry.yml)
|
||||
publishes it on every published GitHub Release, once PyPI serves that version.
|
||||
GitHub OIDC proves ownership of the `io.github.wiltodelta` namespace, so no login
|
||||
or secret is stored. To list a version already on PyPI, dispatch the workflow
|
||||
with that version; a version the registry already lists is skipped.
|
||||
|
||||
[`glama.json`](../glama.json) names the maintainer for Glama's claim flow.
|
||||
Client setup and the tool contract are in [the MCP server guide](mcp-server.md).
|
||||
|
||||
+3
-1
@@ -579,7 +579,9 @@ provides verification in Gemini and, since the public SynthID Detector opened,
|
||||
at [synthid.com](https://synthid.com/). That site is the `synthid-web` oracle
|
||||
surface in [`provider-oracles.md`](provider-oracles.md). On 2026-10-17 its FAQ
|
||||
named Google DeepMind, Nvidia, OpenAI, and Kakao, and its settled labels were
|
||||
`Watermark detected` and `Watermark not detected`. The old limited portal does
|
||||
`Watermark detected` and `Watermark not detected`. On 2026-10-19 UTC the
|
||||
signed-in page also used the status line `SynthID was not detected`, which this
|
||||
tooling records as `not_detected`. The old limited portal does
|
||||
not work: on 2026-09-07, its official `labs.google/synthid` entry point
|
||||
redirected a signed-in browser to a Google 404. On 2026-09-23 the same entry
|
||||
point redirected to `deepmind.google.com/frontiers/synthid`, which also returned
|
||||
|
||||
@@ -92,8 +92,8 @@ the row count is host-dependent rather than the fixed 68 recorded here before. E
|
||||
(~500 images spanning format x provenance x aspect ratio), asserting exit-code semantics
|
||||
rather than just absence of crash.
|
||||
|
||||
Known trap to encode: **exit 2 is triply overloaded** (no-visible-mark, no-invisible-signal,
|
||||
Click usage error). A wrapper cannot distinguish them without parsing stderr. Either the
|
||||
Known trap to encode: **exit 2 is overloaded four ways** (no-visible-mark, no-invisible-signal,
|
||||
empty batch directory, Click usage error). A wrapper cannot distinguish them without parsing stderr. Either the
|
||||
sweep asserts on stderr, or the codes get split -- the latter is the better fix.
|
||||
|
||||
#### Coverage method
|
||||
@@ -280,6 +280,12 @@ protocol is in [`synthid-detector-removal-plan.md`](synthid-detector-removal-pla
|
||||
Scope honestly: this tier certifies strength floors on a handful of images per vendor, and
|
||||
that is all it can do. See `docs/synthid.md`.
|
||||
|
||||
The project therefore publishes no aggregate pass rate per model or resolution: a large
|
||||
enough sample needs bulk automated checks, which the OpenAI restriction above and the
|
||||
SynthID Detector's [additional terms](https://synthid.com/terms) both forbid. Public
|
||||
removal claims rest on this method and on individual hash-bound rows such as
|
||||
`data/evaluations/video-synthid-oracle.csv`, never on an extrapolated rate.
|
||||
|
||||
### D1. Sampling frame
|
||||
|
||||
Select a representative local sample without committing images, identifiers, dataset sizes, or oracle results. Stratify by signal family and preserve the sampling method outside the public repository.
|
||||
|
||||
@@ -134,6 +134,14 @@ against primary sources before adding jurisdiction-specific claims.
|
||||
|
||||
**Google Gemini visible "sparkle" -- tier-dependent, and spec-undocumented by Google.** Google primary sources (the Nano Banana Pro blog and the gemini.google image-generation page, both WebFetch-verified) confirm Gemini images carry BOTH the invisible SynthID (on ALL Google-AI media) AND a visible sparkle, but the visible mark is **tier-gated**: applied for FREE and Google AI **Pro** users, and **REMOVED** for Google AI **Ultra** subscribers, inside **Google AI Studio**, and on **API / dev** output. Update 2026-09-23: since 2026-08-14 a Gemini "Media watermark" setting lets any consumer tier switch the visible mark off for images, Omni video and Lyria music, except work accounts and India, South Korea and Vietnam without Ultra (Gemini help 17405358; the setting was observed in the Gemini web app on 2026-09-23). The tier-gating above is therefore no longer the only reason a sparkle is missing. So a Google-C2PA image with NO visible sparkle is expected (setting off, Ultra, API), not evidence it is clean -- this reinforces the `identify` "no visible mark != clean" rule. The ONLY official verifier is the SynthID flow: upload to the Gemini app and ask directly whether the file contains a SynthID watermark, requiring the SynthID verification tool and prohibiting inference from visible content, logos, text, or metadata. A generic question about AI generation is not an oracle result. The tool reads the INVISIBLE mark; there is **no official visible-sparkle detector**, and Google publishes **no** glyph geometry / size / opacity / color / locale / placement spec. So our capture-based sparkle template is the only source of truth and cannot be validated against a vendor spec -- keep reverse-engineering from real captures (do not expect a published spec).
|
||||
|
||||
**Gemini Notebook, formerly NotebookLM, adds its own visible watermark.** Google's
|
||||
[plan help](https://support.google.com/gemininotebook/answer/16213268) says the mark
|
||||
is added to generated images and videos "to further help you identify AI-generated
|
||||
content", and AI Pro and Ultra subscribers can turn it off. That makes it an
|
||||
AI-generation label under the [project scope](legal-and-safety.md#out-of-scope). No
|
||||
template is planned (decided 2026-10-18), so the mark stays undetected; slide
|
||||
decks export as PDF or PPTX, which the project does not process.
|
||||
|
||||
**Google Veo video marks use two incompatible visible designs.** Public raw
|
||||
clips verify the current four-point diamond and the legacy bottom-right `Veo`
|
||||
text. The independent
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
# on it, including the ComfyUI node package. The console script below carries
|
||||
# the same weight, since users have it on PATH.
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.46.0"
|
||||
version = "0.47.0"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11,<3.15"
|
||||
|
||||
@@ -22,7 +22,7 @@ from the provider's canonical name, the provider is named separately.
|
||||
| --- | --- | --- | --- |
|
||||
| `samsung_en_alpha.png` | Samsung Galaxy AI label, English locale ("AI-generated content", bottom-left) | 5 corpus files | POS 0.11-0.30 vs NEG max 0.40 (binary); tophat/gray tried, no separation. The registered Italian engine scores 0.18-0.31 on the same files -- same layout class, wrong glyph template. |
|
||||
| `gemini_text_alpha.png` | "Generated with Gemini" text label (bottom-right; the registered gemini mark is the sparkle icon) | 3 corpus files | POS 0.07-0.22 vs NEG max 0.32; coverage gate finds the blob (0.29-0.35) but the Arial silhouette misses Google's letterforms. |
|
||||
| `notebooklm_alpha.png` | NotebookLM wordmark (bottom-right) | 12 corpus files | locate geometry not yet fitted; POS max 0.12. |
|
||||
| `notebooklm_alpha.png` | NotebookLM wordmark (bottom-right; the product is now Gemini Notebook) | 12 corpus files | parked: a template is not planned (decided 2026-10-18). Last measured POS max 0.12, locate geometry never fitted. |
|
||||
| `mindvideo_alpha.png` | MindVideo.AI (top-right) | 11 corpus files | POS 0.29-0.32 vs NEG max 0.30 -- borderline overlap, not shippable. |
|
||||
| `higgsfield_alpha.png` | `HIGGSFIELD AI` wordmark (Higgsfield AI, bottom-right; the boxed `AI` variant shares the cohort) | 5 wordmark files (16 in the boxed-AI OCR cluster) | POS max 0.26 vs NEG max 0.22 -- no separation; the mark may be two-part (wordmark + boxed AI) and needs a composed template. |
|
||||
| `jianying_alpha.png` | `剪映AI` wordmark (Jianying / 剪映, the China product in the CapCut family; bottom-right) | 2 corpus files | POS 0.29 vs NEG max 0.35. |
|
||||
|
||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from time import monotonic
|
||||
@@ -22,7 +24,35 @@ if TYPE_CHECKING:
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PLAYWRIGHT_SURFACES = frozenset({"openai-web", "microsoft-web", "meta-web"})
|
||||
PLAYWRIGHT_SURFACES = frozenset({"openai-web", "microsoft-web", "meta-web", "synthid-web"})
|
||||
# A proxy exit is chosen when the run starts. These are the countries that
|
||||
# choice may use: the United States and European ISO codes.
|
||||
EUROPE_OR_US_COUNTRIES = (
|
||||
"AT",
|
||||
"BE",
|
||||
"CH",
|
||||
"CZ",
|
||||
"DE",
|
||||
"DK",
|
||||
"ES",
|
||||
"FI",
|
||||
"FR",
|
||||
"GB",
|
||||
"GR",
|
||||
"HU",
|
||||
"IE",
|
||||
"IT",
|
||||
"NL",
|
||||
"NO",
|
||||
"PL",
|
||||
"PT",
|
||||
"RO",
|
||||
"SE",
|
||||
"US",
|
||||
)
|
||||
_THORDATA_COUNTRY = re.compile(r"-country-[A-Za-z]{2}", re.IGNORECASE)
|
||||
_THORDATA_SESSION = re.compile(r"-sessid-[A-Za-z0-9]+", re.IGNORECASE)
|
||||
_THORDATA_SESSION_TIME = re.compile(r"-sesstime-\d+", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -51,6 +81,11 @@ _RESULT_MARKERS = {
|
||||
"No AI signatures from Meta were found",
|
||||
"No AI signatures were found",
|
||||
),
|
||||
"synthid-web": (
|
||||
"Watermark detected",
|
||||
"Watermark not detected",
|
||||
"SynthID was not detected",
|
||||
),
|
||||
}
|
||||
|
||||
_REFUSAL_MARKERS = (
|
||||
@@ -63,7 +98,10 @@ _REFUSAL_MARKERS = (
|
||||
"access denied",
|
||||
)
|
||||
|
||||
_INDETERMINATE_MARKERS = ("Something went wrong. Please try again. If the issue persists, contact support.",)
|
||||
_INDETERMINATE_MARKERS = (
|
||||
"Something went wrong. Please try again. If the issue persists, contact support.",
|
||||
"Please sign in before detection.",
|
||||
)
|
||||
|
||||
|
||||
def _contains_any(text: str, markers: tuple[str, ...]) -> bool:
|
||||
@@ -111,6 +149,16 @@ def parse_provider_result(surface: str, page_text: str) -> WebVerdict:
|
||||
return WebVerdict("detected", provenance, response)
|
||||
if "inconclusive" in lowered or "no provenance detected" in lowered:
|
||||
return WebVerdict("not_detected", provenance, response)
|
||||
elif surface == "synthid-web":
|
||||
# The exact labels are the verdict. A page that shows both, or that
|
||||
# asks for a sign-in, has no single watermark result. The sign-in
|
||||
# sentence is kept; the skill then continues in the user's existing Chrome.
|
||||
detected = "Watermark detected" in response
|
||||
not_detected = "Watermark not detected" in response or "SynthID was not detected" in response
|
||||
if not_detected and not detected:
|
||||
return WebVerdict("not_detected", "unavailable", response)
|
||||
if detected and not not_detected:
|
||||
return WebVerdict("detected", "unavailable", response)
|
||||
else:
|
||||
if "no ai signatures from meta were found" in lowered or "no ai signatures were found" in lowered:
|
||||
return WebVerdict("not_detected", "unavailable", response)
|
||||
@@ -123,13 +171,37 @@ def parse_provider_result(surface: str, page_text: str) -> WebVerdict:
|
||||
return WebVerdict("indeterminate", provenance, response or "Provider page returned no readable result")
|
||||
|
||||
|
||||
def choose_proxy_country(rng: random.Random | None = None) -> str:
|
||||
"""Pick one exit country from Europe or the United States."""
|
||||
picker = random.SystemRandom() if rng is None else rng
|
||||
return picker.choice(EUROPE_OR_US_COUNTRIES)
|
||||
|
||||
|
||||
def new_proxy_session() -> str:
|
||||
"""Return a new ThorData session id. A later call does not reuse it."""
|
||||
return "s" + secrets.token_hex(8)
|
||||
|
||||
|
||||
def fresh_thordata_username(username: str, *, country: str, session_id: str) -> str:
|
||||
"""Drop a prepared country and session, then attach the ones for this run."""
|
||||
if country not in EUROPE_OR_US_COUNTRIES:
|
||||
raise ValueError("proxy country must be in Europe or the US")
|
||||
if re.fullmatch(r"[A-Za-z0-9]{1,32}", session_id) is None:
|
||||
raise ValueError("proxy session id must be 1-32 ASCII letters or digits")
|
||||
base = _THORDATA_SESSION_TIME.sub("", username)
|
||||
base = _THORDATA_SESSION.sub("", base)
|
||||
base = _THORDATA_COUNTRY.sub("", base).strip("-")
|
||||
return f"{base}-country-{country}-sessid-{session_id}-sesstime-90"
|
||||
|
||||
|
||||
def proxy_settings(
|
||||
slot: oracles.ExecutionSlot,
|
||||
environ: Mapping[str, str] = os.environ,
|
||||
*,
|
||||
sticky_session_id: str | None = None,
|
||||
country: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> ProxySettings | None:
|
||||
"""Resolve a secret proxy URL and pin rotating ThorData routes per batch."""
|
||||
"""Resolve a secret proxy URL. A ThorData exit is chosen for this call."""
|
||||
variable = slot.get("proxy_url_env")
|
||||
if variable is None:
|
||||
return None
|
||||
@@ -147,10 +219,11 @@ def proxy_settings(
|
||||
if parsed.username is not None:
|
||||
username = unquote(parsed.username)
|
||||
is_thordata = parsed.hostname.endswith((".thordata.net", ".thordata.online"))
|
||||
if is_thordata and sticky_session_id is not None and "-sessid-" not in username.casefold():
|
||||
if re.fullmatch(r"[A-Za-z0-9]{1,32}", sticky_session_id) is None:
|
||||
raise ValueError("sticky_session_id must be 1-32 ASCII letters or digits")
|
||||
username = f"{username}-sessid-{sticky_session_id}-sesstime-90"
|
||||
if is_thordata:
|
||||
chosen_country = choose_proxy_country() if country is None else country
|
||||
chosen_session = new_proxy_session() if session_id is None else session_id
|
||||
username = fresh_thordata_username(username, country=chosen_country, session_id=chosen_session)
|
||||
log.info("ThorData proxy exit country=%s session=%s", chosen_country, chosen_session)
|
||||
settings["username"] = username
|
||||
if parsed.password is not None:
|
||||
settings["password"] = unquote(parsed.password)
|
||||
@@ -198,7 +271,7 @@ def _set_upload_file(page: Page, surface: str, upload: Path, timeout_seconds: fl
|
||||
# OpenAI keeps background requests alive, so network-idle is
|
||||
# unreachable. Its visible input becomes handler-ready at page load.
|
||||
page.wait_for_load_state("load", timeout=timeout_ms)
|
||||
elif surface in {"meta-web", "microsoft-web"}:
|
||||
elif surface in {"meta-web", "microsoft-web", "synthid-web"}:
|
||||
# These pages mount hidden inputs before their selection handlers are
|
||||
# active. Network-idle is the observable handler-readiness seam.
|
||||
page.wait_for_load_state("networkidle", timeout=timeout_ms)
|
||||
@@ -296,11 +369,7 @@ def run_web_batch(
|
||||
if slot is None:
|
||||
raise ValueError("Playwright batches require an explicit execution slot")
|
||||
secret_values = dict(environ) if env_file is None else oracles.secret_environment(env_file, environ=environ)
|
||||
proxy = proxy_settings(
|
||||
slot,
|
||||
secret_values,
|
||||
sticky_session_id=f"oracle{results['manifest_sha256'][:16]}",
|
||||
)
|
||||
proxy = proxy_settings(slot, secret_values)
|
||||
pending = [
|
||||
(manifest_row, result_row)
|
||||
for manifest_row, result_row in zip(manifest["rows"], results["rows"], strict=True)
|
||||
|
||||
@@ -175,18 +175,18 @@ SURFACES: dict[str, OracleSurface] = {
|
||||
provider="Google",
|
||||
signal_family="synthid",
|
||||
surface="SynthID Detector",
|
||||
automation="real_browser",
|
||||
automation="playwright",
|
||||
url="https://synthid.com/",
|
||||
media_types=("image", "video", "audio"),
|
||||
project_evidence=(),
|
||||
prompt=None,
|
||||
clean_result_note=(
|
||||
"Record detected only for the explicit label Watermark detected, and not_detected only for "
|
||||
"Watermark not detected. The portal reports partner SynthID from Google DeepMind, Nvidia, "
|
||||
"OpenAI, and Kakao without naming which partner. A not_detected result is not proof of "
|
||||
"human authorship. Preserve quota, sign-in, and unclear wording."
|
||||
"Record detected only for the explicit label Watermark detected, and not_detected for "
|
||||
"Watermark not detected or SynthID was not detected. The portal reports partner SynthID from "
|
||||
"Google DeepMind, Nvidia, OpenAI, and Kakao without naming which partner. A not_detected "
|
||||
"result is not proof of human authorship. Preserve quota, sign-in, and unclear wording."
|
||||
),
|
||||
account_slots=True,
|
||||
account_slots=False,
|
||||
network_slots=True,
|
||||
),
|
||||
"meta-web": OracleSurface(
|
||||
@@ -444,6 +444,7 @@ def load_slots(path: Path) -> dict[str, ExecutionSlot]:
|
||||
raise ValueError(f"API surface {surface_key} does not use a browser_profile")
|
||||
if surface_key not in real_browser and browser_profile is not None:
|
||||
raise ValueError("only a real-browser surface uses a browser_profile")
|
||||
# A proxy belongs to Playwright pages. Gemini and API calls stay direct.
|
||||
if proxy_url_env is not None and surface_key not in playwright:
|
||||
raise ValueError(f"surface {surface_key} does not use proxy_url_env")
|
||||
if surface_key == "openai-api" and api_key_env is None:
|
||||
@@ -894,6 +895,8 @@ def plan(provider: str, as_json: bool) -> None:
|
||||
return
|
||||
for row in rows:
|
||||
click.echo(f"{row['preference']}\t{row['key']}\t{row['automation']}\t{row['surface']}")
|
||||
if len(rows) > 1:
|
||||
click.echo("This command only prints the order. After a failure, run the next surface as its own command.")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@@ -1035,7 +1038,7 @@ def run_web(
|
||||
timeout_seconds: float,
|
||||
env_file: Path,
|
||||
) -> None:
|
||||
"""Submit a prepared non-Google Web batch through isolated Playwright."""
|
||||
"""Submit a prepared anonymous Web batch through isolated Playwright."""
|
||||
from provider_oracle_web import run_web_batch
|
||||
|
||||
report = run_web_batch(
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
"name": "io.github.wiltodelta/remove-ai-watermarks",
|
||||
"title": "Remove AI Watermarks",
|
||||
"description": "Identify and remove AI watermarks and provenance metadata from your own images and video.",
|
||||
"version": "0.46.0",
|
||||
"version": "0.47.0",
|
||||
"repository": {
|
||||
"url": "https://github.com/wiltodelta/remove-ai-watermarks",
|
||||
"source": "github"
|
||||
@@ -14,7 +14,7 @@
|
||||
"registryType": "pypi",
|
||||
"registryBaseUrl": "https://pypi.org",
|
||||
"identifier": "remove-ai-watermarks",
|
||||
"version": "0.46.0",
|
||||
"version": "0.47.0",
|
||||
"runtimeHint": "uvx",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remove-ai-watermarks",
|
||||
"displayName": "Remove AI Watermarks",
|
||||
"version": "1.0.12",
|
||||
"version": "1.0.13",
|
||||
"description": "Identify and remove AI provenance marks from images and video the user generated or edited, using the remove-ai-watermarks CLI.",
|
||||
"author": {
|
||||
"name": "wiltodelta",
|
||||
|
||||
@@ -5,7 +5,7 @@ license: Apache-2.0
|
||||
compatibility: Python 3.11-3.14. Invisible image removal needs NVIDIA CUDA. Some video writes need ffmpeg. Installer may be uv, pipx, or pip.
|
||||
metadata:
|
||||
author: wiltodelta
|
||||
version: "1.0.12"
|
||||
version: "1.0.13"
|
||||
homepage: https://raiw.cc
|
||||
repository: https://github.com/wiltodelta/remove-ai-watermarks
|
||||
---
|
||||
@@ -27,8 +27,12 @@ change.
|
||||
|
||||
Refuse automatic removal of stock-agency, marketplace, classifieds,
|
||||
tiled-preview, or other marks that protect a third party's paid or copyrighted
|
||||
asset. `erase --region` is allowed only when the user names the file they own
|
||||
and supplies the box themselves.
|
||||
asset, or product branding a paid plan removes, such as "Made with Gamma". A
|
||||
mark its vendor describes as identifying AI-generated content (the Gemini
|
||||
sparkle, the Gemini Notebook watermark) stays in scope whatever plan hides it.
|
||||
`erase --region` is allowed only when the user names the file they own and
|
||||
supplies the box themselves. LLM text watermarks are out of scope: they live in
|
||||
word choice, not characters, so do not offer to strip characters from text.
|
||||
|
||||
Removing a local signal does not prove the file is human-made, does not erase
|
||||
server-side generation history, and does not make a deceptive origin claim
|
||||
@@ -153,8 +157,8 @@ Read the process exit code and whether `-o` exists.
|
||||
| Exit | Meaning | What to tell the user |
|
||||
| --- | --- | --- |
|
||||
| 0 | Requested work completed | The output path. Do not add "this is clean". |
|
||||
| 2 | No targeted signal | Unknown, not clean. No new file from `visible`. |
|
||||
| 1 | Error or partial `all` | Quote the error. Say which stage ran, if any. |
|
||||
| 2 | No targeted signal, or a `batch` directory with no supported file | Unknown, not clean. No new file from `visible`. |
|
||||
| 1 | Error, unreadable input, or partial `all` | Quote the error. Say which stage ran, if any. |
|
||||
|
||||
Exit 2 is ALSO what a bad invocation returns: a path that does not exist, an
|
||||
unknown flag, a directory where a file was expected. Read the output before
|
||||
@@ -163,7 +167,9 @@ means the command never ran, so fix it and rerun; only a command that reached
|
||||
the image can say "no targeted signal".
|
||||
|
||||
`identify` with no signal reports origin unknown. Never rephrase that as "no
|
||||
watermark" or "this is clean".
|
||||
watermark" or "this is clean". When `identify` or `metadata` exits 1 with
|
||||
`cannot read`, the file is damaged or not an image or video: any report above
|
||||
that line is incomplete, so say the file could not be read.
|
||||
|
||||
`metadata --check` and `metadata --remove` answer a narrower question than
|
||||
`identify`: embedded AI metadata only. Both print that this is not a clean
|
||||
|
||||
@@ -150,8 +150,8 @@ commands above directly.
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| 0 | Requested work completed |
|
||||
| 2 | No targeted signal (`visible` / no-signal `invisible`) |
|
||||
| 1 | Hard failure, or `all` skipped a required invisible stage |
|
||||
| 2 | No targeted signal (`visible` / no-signal `invisible`), or a `batch` directory with no supported file |
|
||||
| 1 | Hard failure, an input that is not a readable image or video (`identify` and `metadata` included), or `all` skipped a required invisible stage |
|
||||
|
||||
A written file plus exit `1` from `all` is a partial result.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ _GPU_MARKERS = ("GPU ", "gpu:")
|
||||
# told an agent its CLI was current right before it typed a value that build
|
||||
# rejects. tests/test_agent_skill.py pins it against the package version and
|
||||
# against every "(since CLI X.Y.Z)" annotation in the skill.
|
||||
MIN_CLI_VERSION = (0, 46, 0)
|
||||
MIN_CLI_VERSION = (0, 47, 0)
|
||||
# Markers a build without the pixel stack prints. The first is the current CLI's own
|
||||
# install hint; the rest are what older builds emit before the guard existed, and the
|
||||
# Homebrew formula ships exactly such a build.
|
||||
|
||||
@@ -35,7 +35,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.46.0"
|
||||
__version__ = "0.47.0"
|
||||
|
||||
__all__ = [
|
||||
"BatchItemResult",
|
||||
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from remove_ai_watermarks.metadata import MAX_TC260_VALUE_BYTES, parse_tc260_aigc_json
|
||||
|
||||
_EBML_MAGIC = b"\x1aE\xdf\xa3"
|
||||
EBML_MAGIC = b"\x1aE\xdf\xa3"
|
||||
_SEGMENT_ID = 0x18538067
|
||||
_TAGS_ID = 0x1254C367
|
||||
_TAG_ID = 0x7373
|
||||
@@ -95,6 +95,16 @@ def _iter_elements(
|
||||
pos = element_end
|
||||
|
||||
|
||||
def iter_top_level_ends(stream: BinaryIO, size: int) -> Iterator[int]:
|
||||
"""End offset of each valid top-level element; stops early where a header fails.
|
||||
|
||||
An element whose declared size runs past ``size`` fails its header, so a truncated
|
||||
file yields a last end short of ``size``. An unknown-size Segment runs to ``size``.
|
||||
"""
|
||||
for _element_id, _payload_start, element_end in _iter_elements(stream, 0, size):
|
||||
yield element_end
|
||||
|
||||
|
||||
def _read_bounded(
|
||||
stream: BinaryIO,
|
||||
start: int,
|
||||
@@ -133,7 +143,7 @@ def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]:
|
||||
found: list[bytes] = []
|
||||
try:
|
||||
with open(path, "rb") as stream:
|
||||
if stream.read(4) != _EBML_MAGIC:
|
||||
if stream.read(4) != EBML_MAGIC:
|
||||
return ()
|
||||
stream.seek(0, 2)
|
||||
file_size = stream.tell()
|
||||
|
||||
@@ -736,10 +736,17 @@ def strip_isobmff_media_file(
|
||||
return len(targets), len(tc260_key_spans | ai_metadata_key_spans | app_key_spans) + bool(kling_sei_offsets)
|
||||
|
||||
|
||||
def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
"""Return ``(cleaned_bytes, stripped_count)`` with AI-provenance boxes removed.
|
||||
def _blank_box(data: bytes, start: int, end: int, payload_off: int) -> bytes:
|
||||
"""Return ``data[start:end]`` as a ``free`` box of the same length and size
|
||||
encoding, with its payload zeroed, so no later file offset moves."""
|
||||
return data[start : start + 4] + b"free" + data[start + 8 : payload_off] + bytes(end - payload_off)
|
||||
|
||||
Walks top-level boxes and drops:
|
||||
|
||||
def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
"""Return ``(cleaned_bytes, stripped_count)`` with AI-provenance boxes blanked.
|
||||
|
||||
Walks top-level boxes and turns each of these into a same-length ``free`` box
|
||||
with a zeroed payload:
|
||||
- any ``uuid`` box whose UUID equals ``C2PA_UUID`` (a C2PA manifest);
|
||||
- any ``uuid`` box whose payload carries an AI-label marker (an XMP packet
|
||||
with a TC260 / IPTC / IPTC-2025.1 AI field -- caught by content, not by the
|
||||
@@ -747,10 +754,15 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
non-AI XMP intact);
|
||||
- any ``jumb`` box (JPEG-XL JUMBF container).
|
||||
|
||||
All other boxes (incl. ``mdat`` / codestream) are emitted verbatim, so pixel
|
||||
and audio data is preserved bit-for-bit. Non-ISOBMFF input is returned
|
||||
The box is blanked rather than dropped because HEIF ``iloc`` and MP4
|
||||
``stco``/``co64`` locate the coded data by absolute file offset: dropping a
|
||||
box that precedes ``mdat`` moved the image without rewriting them, and a
|
||||
phone HEIC cleaned that way no longer decoded (2026-10-13). This matches
|
||||
:func:`strip_isobmff_media_file`. All other boxes (incl. ``mdat`` /
|
||||
codestream) are emitted verbatim, so pixel and audio data is preserved
|
||||
bit-for-bit. Non-ISOBMFF input is returned
|
||||
unchanged. Despite the name this also covers MP4/MOV/M4A video and audio
|
||||
(all ISOBMFF). NOTE: this drops only top-level boxes. AI metadata stored as an
|
||||
(all ISOBMFF). NOTE: this blanks only top-level boxes. AI metadata stored as an
|
||||
*item inside the ``meta`` box* (typical for AVIF/HEIF) is handled separately and
|
||||
in place (same length, no offset rewrite): AI-label XMP by
|
||||
:func:`blank_ai_xmp_packets`, and AI-generator tokens in an ``Exif`` item by
|
||||
@@ -764,17 +776,16 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
consumed = 0
|
||||
for start, end, box_type, payload_off in _iter_top_level_boxes(data):
|
||||
consumed = end
|
||||
provenance = box_type == b"jumb"
|
||||
if box_type == b"uuid":
|
||||
# uuid boxes carry the 16-byte UUID immediately after the type.
|
||||
is_c2pa = payload_off + 16 <= end and data[payload_off : payload_off + 16] == C2PA_UUID
|
||||
has_ai_label = any(marker in data[payload_off:end] for marker in _AI_LABEL_MARKERS)
|
||||
if is_c2pa or has_ai_label:
|
||||
stripped += 1
|
||||
continue
|
||||
elif box_type == b"jumb":
|
||||
provenance = is_c2pa or any(marker in data[payload_off:end] for marker in _AI_LABEL_MARKERS)
|
||||
if provenance:
|
||||
stripped += 1
|
||||
continue
|
||||
out.extend(data[start:end])
|
||||
out.extend(_blank_box(data, start, end, payload_off))
|
||||
else:
|
||||
out.extend(data[start:end])
|
||||
|
||||
# Fail-safe: the walker returns early on a malformed box (bad size, or a box
|
||||
# that runs past EOF), so anything after it was never visited. Emitting `out`
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import secrets
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from remove_ai_watermarks._internal.constants import JPEG_SUFFIXES, SUPPORTED_FORMATS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -22,3 +26,19 @@ def get_image_format(file_path: Path) -> str:
|
||||
inputs therefore use its PNG fallback, matching the established API contract.
|
||||
"""
|
||||
return "JPEG" if file_path.suffix.casefold() in JPEG_SUFFIXES else "PNG"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def staged_sibling(output: Path) -> Generator[Path]:
|
||||
"""Yield an unused sibling of ``output``, published over it only if the block succeeds.
|
||||
|
||||
A strip that leaves AI metadata behind raises inside the block, so nothing reaches
|
||||
``output``. The name is not pre-created (``mkstemp`` would), so the writer gives the
|
||||
published file the default permissions rather than ``mkstemp``'s 0600.
|
||||
"""
|
||||
staged = output.with_name(f".{output.stem}-{secrets.token_hex(8)}{output.suffix}")
|
||||
try:
|
||||
yield staged
|
||||
os.replace(staged, output)
|
||||
finally:
|
||||
staged.unlink(missing_ok=True)
|
||||
@@ -212,14 +212,18 @@ class WatermarkRemover:
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
from remove_ai_watermarks._internal.utils import staged_sibling
|
||||
from remove_ai_watermarks.metadata import strip_written_output
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
bgr = np.ascontiguousarray(np.asarray(image.convert("RGB"))[:, :, ::-1])
|
||||
if not image_io.imwrite(str(output_path), bgr, display_tags_from=display_tags_from):
|
||||
image.save(output_path)
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
remove_ai_metadata(output_path, output_path, keep_standard=True)
|
||||
# Staged and verified, so a marker that survives fails the run with nothing
|
||||
# published at output_path.
|
||||
with staged_sibling(output_path) as staged:
|
||||
if not image_io.imwrite(str(staged), bgr, display_tags_from=display_tags_from):
|
||||
image.save(staged)
|
||||
if leftover := strip_written_output(staged):
|
||||
raise RuntimeError(f"AI metadata survived the strip: {', '.join(sorted(leftover))}")
|
||||
|
||||
def remove_watermark(
|
||||
self,
|
||||
|
||||
@@ -132,33 +132,35 @@ def _write_visible_result(
|
||||
if not removed and not write_noop:
|
||||
return
|
||||
|
||||
import shutil
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
from remove_ai_watermarks._internal.utils import staged_sibling
|
||||
|
||||
out_path = Path(output)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source_path = loaded.path
|
||||
if not removed and source_path is not None and source_path.suffix.lower() == out_path.suffix.lower():
|
||||
# Copy the ORIGINAL bytes instead of lossily re-encoding a no-op. An in-place
|
||||
# call needs no copy and would otherwise raise shutil.SameFileError.
|
||||
if source_path.resolve() != out_path.resolve():
|
||||
import shutil
|
||||
|
||||
shutil.copyfile(source_path, out_path)
|
||||
else:
|
||||
# Staged, so a strip that leaves AI metadata behind publishes nothing -- the
|
||||
# MetadataStripIncomplete contract.
|
||||
with staged_sibling(out_path) as staged:
|
||||
if not removed and source_path is not None and source_path.suffix.lower() == out_path.suffix.lower():
|
||||
# Copy the ORIGINAL bytes instead of lossily re-encoding a no-op.
|
||||
shutil.copyfile(source_path, staged)
|
||||
# imwrite is contractually NON-RAISING, so this bool is the only signal the file
|
||||
# was not created. Unchecked, the metadata strip below ran on a nonexistent path
|
||||
# and surfaced as a confusing "cannot read image <INPUT>" naming the OUTPUT path
|
||||
# (Tier E, 2026-07-20). Raise here so a library caller and the CLI both get an
|
||||
# accurate message about the write.
|
||||
if not image_io.write_bgr_with_alpha(
|
||||
out_path, result, loaded.alpha, display_tags_from=source_path, orientation_applied=False
|
||||
elif not image_io.write_bgr_with_alpha(
|
||||
staged, result, loaded.alpha, display_tags_from=source_path, orientation_applied=False
|
||||
):
|
||||
raise OSError(f"failed to write output (is the destination writable?): {out_path}")
|
||||
|
||||
if strip_metadata:
|
||||
from remove_ai_watermarks import metadata
|
||||
if strip_metadata:
|
||||
from remove_ai_watermarks.metadata import strip_written_output
|
||||
|
||||
metadata.remove_ai_metadata(out_path, out_path)
|
||||
if leftover := strip_written_output(staged):
|
||||
raise MetadataStripIncomplete(set(leftover))
|
||||
|
||||
|
||||
def remove_visible(
|
||||
@@ -461,7 +463,7 @@ def remove_all(
|
||||
import tempfile
|
||||
|
||||
from remove_ai_watermarks import image_io, watermark_registry
|
||||
from remove_ai_watermarks.metadata import strip_and_verify
|
||||
from remove_ai_watermarks.metadata import strip_written_output
|
||||
|
||||
def say(stage: str, detail: str) -> None:
|
||||
if progress is not None:
|
||||
@@ -516,8 +518,7 @@ def remove_all(
|
||||
# ── 3. AI metadata ──
|
||||
# Read the pristine ORIGINAL for provenance above and the STAGED file here:
|
||||
# the visible pass has already dropped this file's C2PA.
|
||||
_, leftover = strip_and_verify(staged, staged)
|
||||
if leftover:
|
||||
if leftover := strip_written_output(staged):
|
||||
# Before the write, on purpose -- see MetadataStripIncomplete.
|
||||
raise MetadataStripIncomplete(set(leftover))
|
||||
say("metadata", "stripped")
|
||||
@@ -676,7 +677,9 @@ def remove_batch(
|
||||
from remove_ai_watermarks.optional_deps import pixels_available
|
||||
|
||||
src_dir, out_dir = Path(directory), Path(output_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
sources = sorted(p for p in src_dir.iterdir() if is_supported_format(p))
|
||||
if sources: # an empty run leaves no empty output directory behind
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def say(path: Path, stage: str, detail: str) -> None:
|
||||
if progress is not None:
|
||||
@@ -685,7 +688,7 @@ def remove_batch(
|
||||
unavailable: list[Path] = []
|
||||
errors: list[tuple[Path, str]] = []
|
||||
items: list[BatchItemResult] = []
|
||||
for img_path in sorted(p for p in src_dir.iterdir() if is_supported_format(p)):
|
||||
for img_path in sources:
|
||||
out_path = out_dir / img_path.name
|
||||
try:
|
||||
result = _run_batch_one(img_path, out_path, mode, backend, sensitivity, invisible, force, engine, say)
|
||||
|
||||
@@ -125,6 +125,27 @@ def _validate_image(path: Path) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def _unreadable_input_exit(source: Path, *, after_report: bool, to_stderr: bool = False) -> None:
|
||||
"""Exit 1 when ``source`` is not a readable container; return otherwise.
|
||||
|
||||
The metadata scanners read raw bytes, so on bytes that are not an image or video
|
||||
they find nothing, and "nothing" used to print as the same no-signal answer a
|
||||
real file gets, with exit 0. ``after_report`` says whether a partial report was
|
||||
already printed above. ``to_stderr`` keeps a ``--json`` stdout parseable.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import unreadable_reason
|
||||
|
||||
if (reason := unreadable_reason(source)) is None:
|
||||
return
|
||||
scanned = "The scan above is incomplete" if after_report else "Nothing was scanned"
|
||||
message = f"Error: cannot read {source.name}: {reason}. {scanned}, not a no-signal result."
|
||||
if to_stderr:
|
||||
click.echo(message, err=True)
|
||||
else:
|
||||
console.print(f" {message}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _resolved_strength_for_display(
|
||||
source: Path,
|
||||
strength: float | None,
|
||||
@@ -427,6 +448,8 @@ def _write_output_or_exit(
|
||||
bgr: NDArray[Any],
|
||||
alpha: NDArray[Any] | None,
|
||||
display_source: Path | None = None,
|
||||
*,
|
||||
strip_metadata: bool = False,
|
||||
) -> None:
|
||||
"""Write the final image, or fail with a readable error instead of a traceback.
|
||||
|
||||
@@ -438,11 +461,29 @@ def _write_output_or_exit(
|
||||
Regression: `tests/test_cli_robustness.py::TestFailedWriteIsReported`.
|
||||
|
||||
`display_source` is the file whose decode produced `bgr`; its ICC profile and EXIF
|
||||
orientation ride along into the re-encoded output (issue #98)."""
|
||||
orientation ride along into the re-encoded output (issue #98).
|
||||
|
||||
``strip_metadata`` strips and verifies AI metadata on a staged copy before it is
|
||||
published, so a marker that survives exits 1 with nothing written. Both commands
|
||||
using this used to swallow a failed strip unless ``--verbose`` was set."""
|
||||
from remove_ai_watermarks._internal.utils import staged_sibling
|
||||
from remove_ai_watermarks.metadata import strip_written_output
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not image_io.write_bgr_with_alpha(output, bgr, alpha, display_tags_from=display_source):
|
||||
console.print(f" Error: failed to write output (is the destination writable?): {output}")
|
||||
raise SystemExit(1)
|
||||
with staged_sibling(output) as staged:
|
||||
if not image_io.write_bgr_with_alpha(staged, bgr, alpha, display_tags_from=display_source):
|
||||
console.print(f" Error: failed to write output (is the destination writable?): {output}")
|
||||
raise SystemExit(1)
|
||||
if not strip_metadata:
|
||||
return
|
||||
try:
|
||||
leftover = strip_written_output(staged)
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
console.print(f" Error: failed to strip AI metadata from {output}: {e}")
|
||||
raise SystemExit(1) from e
|
||||
if leftover:
|
||||
console.print(f" Error: AI metadata survived in {output}: {', '.join(sorted(leftover))}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _no_visible_mark_exit(source: Path) -> NoReturn:
|
||||
@@ -511,6 +552,10 @@ def _no_visible_mark_exit(source: Path) -> NoReturn:
|
||||
# instead of treating an unchanged image as a completed removal.
|
||||
EXIT_NO_INVISIBLE_SIGNAL = 2
|
||||
|
||||
# Also 2: a batch directory with no supported file processed nothing, which is a
|
||||
# no-signal outcome a wrapper must not read as a cleaned directory.
|
||||
EXIT_NOTHING_PROCESSED = EXIT_NO_INVISIBLE_SIGNAL
|
||||
|
||||
|
||||
def _no_invisible_signal_exit(source: Path) -> NoReturn:
|
||||
"""Explain why the diffusion scrub was skipped, then exit non-zero.
|
||||
@@ -636,7 +681,6 @@ def _run_visible_auto(
|
||||
|
||||
|
||||
def _run_visible_explicit(
|
||||
ctx: click.Context,
|
||||
source: Path,
|
||||
output: Path,
|
||||
*,
|
||||
@@ -685,15 +729,7 @@ def _run_visible_explicit(
|
||||
raise SystemExit(1) from e
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
_write_output_or_exit(output, result, alpha, display_source=source)
|
||||
if strip_metadata:
|
||||
try:
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
remove_ai_metadata(output, output)
|
||||
except Exception as e:
|
||||
if ctx.obj.get("verbose"):
|
||||
console.print(f" Warning: Failed to strip metadata: {e}")
|
||||
_write_output_or_exit(output, result, alpha, display_source=source, strip_metadata=strip_metadata)
|
||||
|
||||
size_kb = output.stat().st_size / 1024
|
||||
console.print(f" Saved: {output} ({size_kb:.0f} KB, {elapsed:.2f}s)")
|
||||
@@ -713,10 +749,8 @@ def _run_visible_explicit(
|
||||
@_visible_backend_option
|
||||
@_visible_sensitivity_option
|
||||
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
|
||||
@click.pass_context
|
||||
@_pixels_required
|
||||
def cmd_visible(
|
||||
ctx: click.Context,
|
||||
source: Path,
|
||||
output: Path | None,
|
||||
detect: bool,
|
||||
@@ -754,7 +788,6 @@ def cmd_visible(
|
||||
return
|
||||
|
||||
_run_visible_explicit(
|
||||
ctx,
|
||||
source,
|
||||
output,
|
||||
detect=detect,
|
||||
@@ -795,10 +828,8 @@ def _parse_region(spec: str) -> tuple[int, int, int, int]:
|
||||
@click.option("--inpaint-method", type=click.Choice(["telea", "ns"]), default="telea", help="cv2 inpaint method.")
|
||||
@click.option("--dilate", type=int, default=3, help="Grow the box by this many px before inpainting.")
|
||||
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
|
||||
@click.pass_context
|
||||
@_pixels_required
|
||||
def cmd_erase(
|
||||
ctx: click.Context,
|
||||
source: Path,
|
||||
regions: tuple[str, ...],
|
||||
output: Path | None,
|
||||
@@ -839,16 +870,7 @@ def cmd_erase(
|
||||
raise SystemExit(1) from e
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
_write_output_or_exit(output, result, alpha, display_source=source)
|
||||
|
||||
if strip_metadata:
|
||||
try:
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
remove_ai_metadata(output, output)
|
||||
except Exception as e:
|
||||
if ctx.obj.get("verbose"):
|
||||
console.print(f" Warning: Failed to strip metadata: {e}")
|
||||
_write_output_or_exit(output, result, alpha, display_source=source, strip_metadata=strip_metadata)
|
||||
|
||||
size_kb = output.stat().st_size / 1024
|
||||
console.print(f" Erased {len(boxes)} region(s) -> {output} ({size_kb:.0f} KB, {elapsed:.2f}s)")
|
||||
@@ -937,13 +959,17 @@ def cmd_invisible(
|
||||
def progress_cb(msg: str) -> None:
|
||||
console.print(f" {msg}")
|
||||
|
||||
engine = InvisibleEngine(
|
||||
pipeline=pipeline,
|
||||
hf_token=hf_token,
|
||||
progress_callback=progress_cb,
|
||||
controlnet_conditioning_scale=controlnet_scale,
|
||||
cpu_offload=cpu_offload,
|
||||
)
|
||||
try:
|
||||
engine = InvisibleEngine(
|
||||
pipeline=pipeline,
|
||||
hf_token=hf_token,
|
||||
progress_callback=progress_cb,
|
||||
controlnet_conditioning_scale=controlnet_scale,
|
||||
cpu_offload=cpu_offload,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as exc: # e.g. no CUDA device
|
||||
console.print(f" Error: {exc}")
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
# Detect the SynthID vendor from the ORIGINAL (before processing strips C2PA) so the
|
||||
# displayed and executed strength agree on the vendor-adaptive default. An explicit
|
||||
@@ -1052,6 +1078,7 @@ def cmd_metadata(
|
||||
# accepts video/audio containers, so the image-format warning would misfire.
|
||||
# click's `exists=True` on the argument already enforces the file exists.
|
||||
_banner()
|
||||
_unreadable_input_exit(source, after_report=False)
|
||||
|
||||
if check or (not remove):
|
||||
has_ai = has_ai_metadata(source)
|
||||
@@ -1064,14 +1091,13 @@ def cmd_metadata(
|
||||
# Remove
|
||||
try:
|
||||
out, leftover = strip_and_verify(source, output, keep_standard=keep_standard)
|
||||
except (OSError, ValueError) as e: # unreadable / truncated / non-image (PIL raises OSError subclasses)
|
||||
except (OSError, RuntimeError, ValueError) as e: # unreadable input, or ffmpeg absent or failing
|
||||
console.print(f" Error: cannot process {source.name}: {e}")
|
||||
raise SystemExit(1) from e
|
||||
|
||||
if leftover:
|
||||
console.print(f" FAILED: {len(leftover)} AI metadata marker(s) survived in {out}")
|
||||
console.print(f" still present: {', '.join(sorted(leftover))}")
|
||||
console.print(" the file could not be decoded, so it was copied through unchanged")
|
||||
raise SystemExit(1)
|
||||
console.print(f" AI metadata stripped -> {out}")
|
||||
_print_metadata_not_a_clean_verdict()
|
||||
@@ -1440,6 +1466,9 @@ def cmd_video_batch(
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
raise click.ClickException(str(e)) from e
|
||||
|
||||
if not result.items:
|
||||
console.print(f" No supported videos found in {directory}; nothing was processed")
|
||||
raise SystemExit(EXIT_NOTHING_PROCESSED)
|
||||
for item in result.items:
|
||||
if item.error is not None:
|
||||
console.print(f" FAILED {item.source.name}: {item.error}")
|
||||
@@ -1488,6 +1517,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
|
||||
|
||||
if as_json:
|
||||
click.echo(json.dumps(asdict(report), default=str, indent=2))
|
||||
_unreadable_input_exit(source, after_report=True, to_stderr=True)
|
||||
return
|
||||
|
||||
_banner()
|
||||
@@ -1534,6 +1564,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
|
||||
console.print("\n Caveats:")
|
||||
for c in report.caveats:
|
||||
console.print(f" - {c}")
|
||||
_unreadable_input_exit(source, after_report=True)
|
||||
|
||||
|
||||
# ── Metadata-free photo classification (opt-in, never a tail of identify) ──
|
||||
@@ -1824,13 +1855,15 @@ def cmd_batch(
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = directory.parent / (directory.name + "_clean")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
images = sorted(p for p in directory.iterdir() if is_supported_format(p))
|
||||
|
||||
if not images:
|
||||
console.print(f"No supported images found in {directory}")
|
||||
return
|
||||
# Nothing processed is the no-signal code, not success: a wrapper must not read
|
||||
# an empty run as a cleaned directory.
|
||||
console.print(f"No supported images found in {directory}; nothing was processed")
|
||||
raise SystemExit(EXIT_NOTHING_PROCESSED)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
console.print(f" Found {len(images)} images in {directory}")
|
||||
console.print(f" Output -> {output_dir}")
|
||||
@@ -1859,6 +1892,11 @@ def cmd_batch(
|
||||
if ctx.obj.get("verbose"):
|
||||
console.print(f" {img.name}: {stage}{f' {detail}' if detail else ''}")
|
||||
|
||||
try:
|
||||
engine = _batch_engine(mode, invisible_options)
|
||||
except (OSError, RuntimeError, ValueError) as e: # e.g. no CUDA device
|
||||
console.print(f" Error: {e}")
|
||||
raise SystemExit(1) from e
|
||||
summary = api_remove_batch(
|
||||
directory,
|
||||
output_dir,
|
||||
@@ -1867,7 +1905,7 @@ def cmd_batch(
|
||||
sensitivity=_parse_sensitivity(sensitivity),
|
||||
invisible=invisible_options,
|
||||
force=force,
|
||||
engine=_batch_engine(mode, invisible_options),
|
||||
engine=engine,
|
||||
progress=on_progress,
|
||||
)
|
||||
|
||||
|
||||
@@ -207,7 +207,11 @@ def call_tool(name: str, arguments: dict[str, Any], runner: Runner = run_cli) ->
|
||||
args, output = _COMMANDS[name](_validated(name, arguments))
|
||||
except ToolInputError as exc:
|
||||
return {"content": [{"type": "text", "text": f"Invalid arguments: {exc}"}], "isError": True}
|
||||
completed = runner(args)
|
||||
try:
|
||||
completed = runner(args)
|
||||
except OSError as exc: # the child never started, so there is no exit code to relay
|
||||
log.warning("CLI command could not start: %s: %s", args, exc)
|
||||
return {"content": [{"type": "text", "text": f"The command could not start: {exc}"}], "isError": True}
|
||||
code = completed.returncode
|
||||
text = completed.stdout.strip()
|
||||
stderr = completed.stderr.strip()[-4000:]
|
||||
@@ -219,13 +223,14 @@ def call_tool(name: str, arguments: dict[str, Any], runner: Runner = run_cli) ->
|
||||
structured["stdout"] = text
|
||||
if stderr:
|
||||
structured["stderr"] = stderr
|
||||
# The CLI's own exit 2 means no supported signal, a result. Click also exits 2 on
|
||||
# The CLI's own exit 2 means no supported signal, or a batch directory with nothing
|
||||
# to process, both results. Click also exits 2 on
|
||||
# a usage error, such as a missing file, and prints its usage line on stderr.
|
||||
no_signal = code == 2 and "Usage:" not in stderr
|
||||
is_error = code != 0 and not no_signal
|
||||
summary = f"exit code {code}"
|
||||
if no_signal:
|
||||
summary += " (no supported signal found; this is unknown, not proof the file is clean)"
|
||||
summary += " (no supported signal found, or nothing to process; this is unknown, not proof of a clean file)"
|
||||
body = "\n\n".join(part for part in (summary, text, stderr if is_error else "") if part)
|
||||
return {"content": [{"type": "text", "text": body}], "structuredContent": structured, "isError": is_error}
|
||||
|
||||
|
||||
@@ -1856,6 +1856,92 @@ def _sniff_image_format(head: bytes) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
_JXL_CONTAINER_MAGIC = b"\x00\x00\x00\x0cJXL \r\n\x87\n"
|
||||
# Accepted on their magic alone: no local parser walks them, and the ffmpeg strip path
|
||||
# fails on its own when one is unreadable. JPEG XL codestream, FLV, ID3, FLAC, Ogg.
|
||||
_MAGIC_ONLY: tuple[bytes, ...] = (b"\xff\x0a", b"FLV", b"ID3", b"fLaC", b"OggS")
|
||||
_PILLOW_RASTERS = frozenset({"png", "jpeg", "webp", "gif", "bmp", "tiff"})
|
||||
|
||||
|
||||
def _tiling_reason(kind: str, ends: Iterable[int], size: int) -> str | None:
|
||||
"""None when the last top-level element ends at EOF; a truncation leaves a gap."""
|
||||
end = max(ends, default=0) # top-level end offsets only increase
|
||||
if end == size:
|
||||
return None
|
||||
return f"the {kind} structure stops at byte {end} of {size} (truncated or corrupt)"
|
||||
|
||||
|
||||
def _pixel_stack_decodes(path: Path) -> bool:
|
||||
"""Whether the optional pixel stack decodes ``path``; False when it is absent."""
|
||||
from remove_ai_watermarks.optional_deps import pixels_available
|
||||
|
||||
if not pixels_available():
|
||||
return False
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
return image_io.imread(path) is not None
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8)
|
||||
def _unreadable_reason_cached(path_str: str, _mtime_ns: int, size: int) -> str | None:
|
||||
from pathlib import Path as _Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.ebml import EBML_MAGIC, iter_top_level_ends
|
||||
from remove_ai_watermarks._internal.isobmff import iter_file_boxes
|
||||
from remove_ai_watermarks.forensic_metadata import sniff_format
|
||||
|
||||
path = _Path(path_str)
|
||||
if size == 0:
|
||||
return "the file is empty"
|
||||
with path.open("rb") as stream:
|
||||
head = stream.read(32)
|
||||
kind = sniff_format(head)
|
||||
if kind.startswith("isobmff:") or head.startswith(_JXL_CONTAINER_MAGIC):
|
||||
return _tiling_reason("ISOBMFF box", (end for _s, end, _t, _p in iter_file_boxes(stream, 0, size)), size)
|
||||
if head.startswith(EBML_MAGIC):
|
||||
return _tiling_reason("EBML element", iter_top_level_ends(stream, size), size)
|
||||
if kind in _PILLOW_RASTERS:
|
||||
try:
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
except Image.DecompressionBombError:
|
||||
return None # readable, merely too large for a precautionary full decode
|
||||
except Exception as exc:
|
||||
# The pixel stack decodes some rasters Pillow rejects (a PNG with a bad eXIf
|
||||
# CRC), and strip_and_verify recovers those through it, so they are readable.
|
||||
return None if _pixel_stack_decodes(path) else f"the image cannot be decoded: {exc}"
|
||||
return None
|
||||
if head.startswith(b"RIFF") and len(head) >= 8:
|
||||
declared = 8 + struct.unpack_from("<I", head, 4)[0]
|
||||
return None if declared <= size else f"the RIFF header declares {declared} bytes but the file has {size}"
|
||||
mpeg_audio_sync = len(head) > 1 and head[0] == 0xFF and head[1] & 0xE0 == 0xE0
|
||||
if head.startswith(_MAGIC_ONLY) or mpeg_audio_sync:
|
||||
return None
|
||||
return "the content is not a recognized image, video or audio container"
|
||||
|
||||
|
||||
def unreadable_reason(path: Path) -> str | None:
|
||||
"""Why ``path`` is not a readable image, video or audio container, or None.
|
||||
|
||||
The metadata scanners read raw bytes, so on a file that is not a container at all
|
||||
they find nothing and say so, which is the same answer a real file without metadata
|
||||
gets. Callers that report "no metadata" or "stripped" ask this first, so an
|
||||
unreadable input fails instead of reading as clean. Rasters must fully decode in
|
||||
Pillow or, when it is installed, the pixel stack; ISOBMFF boxes and EBML elements
|
||||
must tile the file, which is what a truncation breaks; a RIFF file must hold its
|
||||
declared size. The formats in ``_MAGIC_ONLY`` and MPEG audio frames have no local
|
||||
walker and pass on their magic. Memoized on (path, mtime, size): one command asks
|
||||
several times about the same input.
|
||||
"""
|
||||
try:
|
||||
stat = path.stat()
|
||||
return _unreadable_reason_cached(str(path), stat.st_mtime_ns, stat.st_size)
|
||||
except OSError as exc:
|
||||
return f"cannot be read: {exc}"
|
||||
|
||||
|
||||
def strip_and_verify(
|
||||
source_path: Path,
|
||||
output_path: Path | None = None,
|
||||
@@ -1866,10 +1952,12 @@ def strip_and_verify(
|
||||
|
||||
:func:`remove_ai_metadata` is deliberately fail-safe: a file PIL cannot decode is
|
||||
copied through UNCHANGED rather than crashing a caller, and the path it returns is
|
||||
indistinguishable from a real strip. Any caller that reports an outcome to a user
|
||||
therefore cannot tell a no-op from a success. A Samsung C2PA compatibility case
|
||||
exposed this when `metadata --remove` reported success while the output still read
|
||||
as AI.
|
||||
indistinguishable from a real strip. This wrapper is what a caller reporting an
|
||||
outcome uses instead: it refuses a source :func:`unreadable_reason` rejects with
|
||||
``ValueError`` before writing anything, because a copy-through of bytes nothing can
|
||||
parse may carry no marker a re-scan sees, and then re-scans what it wrote. A
|
||||
Samsung C2PA compatibility case exposed the original gap when `metadata --remove`
|
||||
reported success while the output still read as AI.
|
||||
|
||||
If markers survive but OpenCV can still decode the raster, the verified path
|
||||
normalizes the container through :mod:`image_io` and scans once more. This
|
||||
@@ -1878,6 +1966,27 @@ def strip_and_verify(
|
||||
|
||||
Returns ``(output_path, surviving_markers)``; an empty mapping means a real strip.
|
||||
"""
|
||||
if (reason := unreadable_reason(source_path)) is not None:
|
||||
raise ValueError(f"cannot read {source_path.name}: {reason}")
|
||||
return _strip_and_rescan(source_path, output_path, keep_standard=keep_standard)
|
||||
|
||||
|
||||
def strip_written_output(path: Path, *, keep_standard: bool = True) -> dict[str, str]:
|
||||
"""Strip ``path`` in place and return the markers that survived.
|
||||
|
||||
For a file whose pixels this run has already decoded or written itself, so the
|
||||
readability probe that :func:`strip_and_verify` runs first would only decode
|
||||
them a second time.
|
||||
"""
|
||||
return _strip_and_rescan(path, path, keep_standard=keep_standard)[1]
|
||||
|
||||
|
||||
def _strip_and_rescan(
|
||||
source_path: Path,
|
||||
output_path: Path | None,
|
||||
*,
|
||||
keep_standard: bool,
|
||||
) -> tuple[Path, dict[str, str]]:
|
||||
out = remove_ai_metadata(source_path, output_path, keep_standard=keep_standard)
|
||||
remaining = get_ai_metadata(out)
|
||||
if not remaining:
|
||||
|
||||
@@ -32,7 +32,6 @@ _EBML_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".webm", ".mkv"})
|
||||
_RIFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".avi"})
|
||||
_FLV_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".flv"})
|
||||
_REGENERATED_VIDEO_EXTENSIONS: frozenset[str] = _ISOBMFF_VIDEO_EXTENSIONS
|
||||
_EBML_MAGIC = b"\x1aE\xdf\xa3"
|
||||
|
||||
|
||||
def _require_video_runtime() -> None:
|
||||
@@ -237,17 +236,23 @@ def _video_source(source: str | Path) -> Path:
|
||||
if path.suffix.lower() not in VIDEO_EXTENSIONS:
|
||||
supported = ", ".join(sorted(VIDEO_EXTENSIONS))
|
||||
raise ValueError(f"Unsupported video format {path.suffix or '<none>'}; expected one of: {supported}")
|
||||
from remove_ai_watermarks._internal.ebml import EBML_MAGIC
|
||||
|
||||
with path.open("rb") as stream:
|
||||
head = stream.read(12)
|
||||
suffix = path.suffix.lower()
|
||||
matches_container = (
|
||||
(suffix in _ISOBMFF_VIDEO_EXTENSIONS and len(head) >= 8 and head[4:8] == b"ftyp")
|
||||
or (suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(_EBML_MAGIC))
|
||||
or (suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(EBML_MAGIC))
|
||||
or (suffix in _RIFF_VIDEO_EXTENSIONS and len(head) >= 12 and head[:4] == b"RIFF" and head[8:12] == b"AVI ")
|
||||
or (suffix in _FLV_VIDEO_EXTENSIONS and head.startswith(b"FLV"))
|
||||
)
|
||||
if not matches_container:
|
||||
raise ValueError(f"Video content does not match its {suffix} extension: {path}")
|
||||
from remove_ai_watermarks.metadata import unreadable_reason
|
||||
|
||||
if (reason := unreadable_reason(path)) is not None:
|
||||
raise ValueError(f"Cannot read video {path}: {reason}")
|
||||
return path
|
||||
|
||||
|
||||
@@ -714,13 +719,13 @@ def remove_video_batch(
|
||||
)
|
||||
if output_path.resolve() == directory_path.resolve():
|
||||
raise ValueError("Video batch output directory must differ from the source directory")
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sources = tuple(
|
||||
path
|
||||
for path in sorted(directory_path.iterdir(), key=lambda candidate: candidate.name.lower())
|
||||
if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS
|
||||
)
|
||||
if sources:
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
items: list[VideoBatchItem] = []
|
||||
invisible_runtime: VideoVaeRuntime | None = None
|
||||
invisible_runtime_error: str | None = None
|
||||
|
||||
@@ -347,7 +347,7 @@ def test_missing_pixel_stack_names_the_extra_instead_of_a_traceback(
|
||||
source = tmp_path / "image.png"
|
||||
source.write_bytes((ROOT / "data" / "fixtures" / "provenance" / "chatgpt-1.png").read_bytes())
|
||||
clip = tmp_path / "clip.mp4"
|
||||
clip.write_bytes(b"\x00\x00\x00\x18ftypmp42")
|
||||
clip.write_bytes(b"\x00\x00\x00\x10ftypmp42\x00\x00\x00\x00") # one whole box, so the input reads
|
||||
substitutions = {
|
||||
"SOURCE": str(source),
|
||||
"CLIP": str(clip),
|
||||
|
||||
+1
-1
@@ -430,7 +430,7 @@ class TestRemoveAllLibrary:
|
||||
from remove_ai_watermarks import api, invisible_engine, metadata
|
||||
|
||||
monkeypatch.setattr(invisible_engine, "is_available", lambda: False)
|
||||
monkeypatch.setattr(metadata, "strip_and_verify", lambda src, dst: (dst, {"c2pa"}))
|
||||
monkeypatch.setattr(metadata, "strip_written_output", lambda _path: {"c2pa": "survived"})
|
||||
out = tmp_path / "clean.png"
|
||||
|
||||
with pytest.raises(api.MetadataStripIncomplete, match="c2pa"):
|
||||
|
||||
+9
-15
@@ -675,10 +675,7 @@ class TestAllCommand:
|
||||
# The no-signal gate lives in the library now; patch it there, not on the
|
||||
# CLI module, or the diffusion stage runs for real.
|
||||
patch("remove_ai_watermarks.api._SourceEvidence.has_invisible_target", return_value=False),
|
||||
patch(
|
||||
"remove_ai_watermarks.metadata.strip_and_verify",
|
||||
return_value=(tmp_path / "intermediate.png", {"c2pa": True}),
|
||||
),
|
||||
patch("remove_ai_watermarks.metadata.strip_written_output", return_value={"c2pa": "survived"}),
|
||||
):
|
||||
result = runner.invoke(main, ["all", str(sample_png), "-o", str(output)])
|
||||
|
||||
@@ -867,7 +864,7 @@ class TestBatchCommand:
|
||||
empty_dir = tmp_path / "empty"
|
||||
empty_dir.mkdir()
|
||||
result = runner.invoke(main, ["batch", str(empty_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert result.exit_code == 2 # nothing processed is the no-signal code (2026-10-18)
|
||||
assert "No supported images" in result.output
|
||||
|
||||
def test_batch_visible_mode(self, runner, tmp_path):
|
||||
@@ -1309,10 +1306,10 @@ def test_visible_backend_runtime_error_exits_cleanly(runner, tmp_path, monkeypat
|
||||
@pytest.mark.parametrize("cmd", [["metadata", "--remove"], ["visible", "--backend", "cv2"]])
|
||||
def test_unreadable_input_exits_cleanly(runner, tmp_path, name, content, cmd):
|
||||
"""Regression: a corrupt / empty / non-image file (real prod uploads include ~0.2%
|
||||
truncated files) must NEVER leak a raw PIL/OSError/ValueError traceback. `metadata
|
||||
--remove` is fail-safe -- an undecodable file is copied through unchanged (exit 0),
|
||||
a strip that cannot parse the file is a no-op, not a crash; `visible` must decode to
|
||||
remove a mark, so it is a clean error (exit 1). Found by the runtime mode fuzz."""
|
||||
truncated files) must NEVER leak a raw PIL/OSError/ValueError traceback, and both
|
||||
commands fail with a clean exit 1. `metadata --remove` used to copy the bytes
|
||||
through and exit 0, which read as a completed strip; since 2026-10-18 it writes
|
||||
nothing (`tests/test_fail_closed.py`). Found by the runtime mode fuzz."""
|
||||
bad = tmp_path / name
|
||||
bad.write_bytes(content)
|
||||
out = tmp_path / "out.png"
|
||||
@@ -1320,12 +1317,9 @@ def test_unreadable_input_exits_cleanly(runner, tmp_path, name, content, cmd):
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit), (
|
||||
f"leaked a raw traceback: {result.exception!r}"
|
||||
)
|
||||
if cmd[0] == "metadata":
|
||||
assert result.exit_code == 0, result.output # fail-safe copy-through
|
||||
assert out.read_bytes() == content # input passed through unchanged
|
||||
else:
|
||||
assert result.exit_code == 1, result.output # cannot remove a mark from unreadable input
|
||||
assert "Error" in result.output
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "Error" in result.output
|
||||
assert not out.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -31,7 +31,7 @@ def test_manifest_carries_every_field_the_catalog_validator_requires() -> None:
|
||||
def test_manifest_describes_the_same_plugin_as_the_claude_manifest() -> None:
|
||||
manifest = _manifest()
|
||||
claude = json.loads(CLAUDE_PLUGIN.read_text(encoding="utf-8"))
|
||||
for field in ("name", "description", "repository", "license", "homepage"):
|
||||
for field in ("name", "version", "description", "repository", "license", "homepage"):
|
||||
assert manifest[field] == claude[field], field
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Every command fails closed: an unreadable input or a skipped step never exits 0.
|
||||
|
||||
Found 2026-10-18 by a sweep that ran each command over random bytes, an empty file
|
||||
and a truncated copy of a tracked fixture. The pixel commands already refused all
|
||||
three. The metadata-only paths did not: they scan raw bytes, a scan of bytes that
|
||||
are not an image finds nothing, and "nothing" printed as "No AI metadata found"
|
||||
with exit 0, the same answer a real file without metadata gets. `metadata
|
||||
--remove` then copied the garbage through and reported "AI metadata stripped".
|
||||
A truncated MP4 without its `moov` box got the same treatment from the video
|
||||
metadata commands. The rule pinned here: the exit code of a scan that could not
|
||||
read its input is never the exit code of a scan that read it and found nothing.
|
||||
|
||||
Absent OPTIONAL detectors are not in this set. `identify` on the default install,
|
||||
which has no pixel stack, still answers with exit 0 and a caveat naming what did
|
||||
not run (decided 2026-10-18); see `test_optional_deps.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from remove_ai_watermarks import image_io, metadata
|
||||
from remove_ai_watermarks._internal.constants import SUPPORTED_FORMATS
|
||||
from remove_ai_watermarks.cli import main
|
||||
from remove_ai_watermarks.video import VIDEO_EXTENSIONS
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
C2PA_PNG = ROOT / "data/fixtures/provenance/ideogram-c2pa.png"
|
||||
MP4 = ROOT / "data/fixtures/provenance/runway-gen45-video.mp4"
|
||||
|
||||
|
||||
def _unreadable_images(tmp_path: Path) -> dict[str, Path]:
|
||||
rng = np.random.default_rng(0)
|
||||
garbage = tmp_path / "garbage.png"
|
||||
garbage.write_bytes(rng.integers(0, 256, 4000, dtype=np.uint8).tobytes())
|
||||
empty = tmp_path / "empty.png"
|
||||
empty.write_bytes(b"")
|
||||
truncated = tmp_path / "truncated.png"
|
||||
truncated.write_bytes(C2PA_PNG.read_bytes()[:300])
|
||||
return {"garbage": garbage, "empty": empty, "truncated": truncated}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_png(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "clean.png"
|
||||
gradient = np.tile(np.linspace(0, 255, 64, dtype=np.uint8), (64, 1))
|
||||
assert image_io.imwrite(path, np.dstack([gradient, gradient.T, gradient]))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def truncated_mp4(tmp_path: Path) -> Path:
|
||||
"""The ftyp box survives, so the suffix sniff passes; the box walk does not."""
|
||||
path = tmp_path / "truncated.mp4"
|
||||
path.write_bytes(MP4.read_bytes()[:3000])
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cuda_refused(monkeypatch):
|
||||
"""The invisible engine is installed but cannot start, as on a host without CUDA."""
|
||||
import remove_ai_watermarks.invisible_engine as invisible_engine
|
||||
|
||||
def refuse(*_args, **_kwargs):
|
||||
raise ValueError("Invisible-watermark removal is CUDA-only")
|
||||
|
||||
monkeypatch.setattr(invisible_engine, "is_available", lambda: True)
|
||||
monkeypatch.setattr(invisible_engine, "InvisibleEngine", refuse)
|
||||
|
||||
|
||||
def _invoke(args: list[str]):
|
||||
return CliRunner().invoke(main, args)
|
||||
|
||||
|
||||
class TestUnreadableImageInputs:
|
||||
@pytest.mark.parametrize("kind", ["garbage", "empty", "truncated"])
|
||||
@pytest.mark.parametrize("flags", [[], ["--no-visible"], ["--json"]])
|
||||
def test_identify_exits_nonzero(self, tmp_path, kind, flags):
|
||||
result = _invoke(["identify", *flags, str(_unreadable_images(tmp_path)[kind])])
|
||||
assert result.exit_code == 1, result.output
|
||||
assert isinstance(result.exception, SystemExit)
|
||||
|
||||
def test_identify_keeps_exit_zero_on_a_readable_file(self, clean_png):
|
||||
result = _invoke(["identify", str(clean_png)])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
@pytest.mark.parametrize("kind", ["garbage", "empty", "truncated"])
|
||||
def test_metadata_check_does_not_report_no_metadata(self, tmp_path, kind):
|
||||
result = _invoke(["metadata", "--check", str(_unreadable_images(tmp_path)[kind])])
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "No AI metadata found" not in result.output
|
||||
assert "Nothing was scanned" in result.output, "the probe runs before any scan"
|
||||
|
||||
@pytest.mark.parametrize("kind", ["garbage", "empty", "truncated"])
|
||||
def test_metadata_remove_writes_nothing_and_exits_nonzero(self, tmp_path, kind):
|
||||
out = tmp_path / "out.png"
|
||||
result = _invoke(["metadata", "--remove", str(_unreadable_images(tmp_path)[kind]), "-o", str(out)])
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "stripped" not in result.output
|
||||
assert not out.exists()
|
||||
|
||||
def test_metadata_remove_keeps_working_on_a_readable_file(self, clean_png, tmp_path):
|
||||
result = _invoke(["metadata", "--remove", str(clean_png), "-o", str(tmp_path / "out.png")])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
def test_batch_metadata_counts_an_unreadable_file_as_failed(self, tmp_path, clean_png):
|
||||
indir = tmp_path / "in"
|
||||
indir.mkdir()
|
||||
shutil.copy(clean_png, indir / "clean.png")
|
||||
shutil.copy(_unreadable_images(tmp_path)["garbage"], indir / "garbage.png")
|
||||
result = _invoke(["batch", str(indir), "--mode", "metadata", "-o", str(tmp_path / "out")])
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "1 processed" in result.output
|
||||
assert "1 errors" in result.output
|
||||
|
||||
|
||||
class TestTruncatedVideo:
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
["video", "identify", "--no-visible"],
|
||||
["video", "metadata", "--check"],
|
||||
["video", "metadata", "--remove"],
|
||||
["metadata", "--check"],
|
||||
],
|
||||
)
|
||||
def test_metadata_paths_exit_nonzero(self, truncated_mp4, args):
|
||||
result = _invoke([*args, str(truncated_mp4)])
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "No AI metadata found" not in result.output
|
||||
assert "stripped" not in result.output
|
||||
|
||||
def test_video_batch_metadata_counts_it_as_failed(self, tmp_path, truncated_mp4):
|
||||
indir = tmp_path / "in"
|
||||
indir.mkdir()
|
||||
shutil.copy(truncated_mp4, indir / "truncated.mp4")
|
||||
result = _invoke(["video", "batch", str(indir), "--mode", "metadata", "-o", str(tmp_path / "out")])
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "0 processed, 1 failed" in result.output
|
||||
|
||||
|
||||
class TestReadabilityProbe:
|
||||
"""The probe must pass every tracked fixture, or the fix trades one lie for another."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
sorted(p for p in (ROOT / "data/fixtures").rglob("*") if p.is_file() and p.suffix.lower() != ".md"),
|
||||
ids=lambda p: str(p.relative_to(ROOT / "data/fixtures")),
|
||||
)
|
||||
def test_every_tracked_fixture_is_readable(self, path):
|
||||
if path.suffix.lower() not in SUPPORTED_FORMATS | VIDEO_EXTENSIONS:
|
||||
pytest.skip("not a container the metadata commands accept")
|
||||
assert metadata.unreadable_reason(path) is None
|
||||
|
||||
def test_unreadable_inputs_name_a_reason(self, tmp_path, truncated_mp4):
|
||||
for path in [*_unreadable_images(tmp_path).values(), truncated_mp4]:
|
||||
assert metadata.unreadable_reason(path), path
|
||||
|
||||
def test_a_png_cut_inside_its_pixel_data_is_unreadable(self, tmp_path):
|
||||
"""The header parses, so only a full decode sees the missing IDAT bytes."""
|
||||
path = tmp_path / "cut.png"
|
||||
data = (ROOT / "data/fixtures/provenance/chatgpt-1.png").read_bytes()
|
||||
path.write_bytes(data[: len(data) * 3 // 5])
|
||||
assert metadata.unreadable_reason(path)
|
||||
|
||||
def test_a_trailing_partial_box_is_unreadable(self, tmp_path):
|
||||
"""Cut inside the LAST box, so ftyp and moov both survive and only the walk sees it."""
|
||||
data = MP4.read_bytes()
|
||||
path = tmp_path / "cut.mp4"
|
||||
path.write_bytes(data[:-100])
|
||||
assert metadata.unreadable_reason(path)
|
||||
|
||||
def test_a_truncated_ebml_segment_is_unreadable(self, tmp_path):
|
||||
"""A Segment that declares more bytes than the file holds, as a cut WebM does."""
|
||||
path = tmp_path / "x.webm"
|
||||
ebml_header = b"\x1a\x45\xdf\xa3\x80" # empty EBML header element
|
||||
segment = b"\x18\x53\x80\x67\x88" + bytes(4) # declares 8 payload bytes, holds 4
|
||||
path.write_bytes(ebml_header + segment)
|
||||
assert metadata.unreadable_reason(path)
|
||||
path.write_bytes(ebml_header + segment + bytes(4))
|
||||
assert metadata.unreadable_reason(path) is None
|
||||
|
||||
def test_a_truncated_riff_is_unreadable(self, tmp_path):
|
||||
path = tmp_path / "x.wav"
|
||||
path.write_bytes(b"RIFF" + (100).to_bytes(4, "little") + b"WAVE" + bytes(20))
|
||||
assert metadata.unreadable_reason(path)
|
||||
path.write_bytes(b"RIFF" + (100).to_bytes(4, "little") + b"WAVE" + bytes(96))
|
||||
assert metadata.unreadable_reason(path) is None
|
||||
|
||||
|
||||
class TestSkippedStepsAreFailures:
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
["visible", "--mark", "doubao", "--no-detect"],
|
||||
["erase", "--region", "2,2,10,10"],
|
||||
],
|
||||
)
|
||||
def test_a_metadata_strip_that_left_markers_is_not_success(self, tmp_path, clean_png, monkeypatch, args):
|
||||
"""Both commands used to swallow the strip and report only under --verbose."""
|
||||
monkeypatch.setattr(metadata, "get_ai_metadata", lambda _path: {"c2pa": "survived"})
|
||||
out = tmp_path / "out.png"
|
||||
result = _invoke([*args, str(clean_png), "-o", str(out)])
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "survived" in result.output
|
||||
assert not out.exists(), "an output that kept its AI metadata was published"
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
|
||||
def test_a_staged_output_keeps_the_default_permissions(self, tmp_path, clean_png):
|
||||
"""The staged sibling is not pre-created, so it is not mkstemp's 0600."""
|
||||
out = tmp_path / "out.png"
|
||||
reference = tmp_path / "reference"
|
||||
reference.write_bytes(b"")
|
||||
result = _invoke(["erase", "--region", "2,2,10,10", str(clean_png), "-o", str(out)])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert out.stat().st_mode & 0o777 == reference.stat().st_mode & 0o777
|
||||
|
||||
def test_the_library_visible_strip_is_verified(self, tmp_path, clean_png, monkeypatch):
|
||||
from remove_ai_watermarks.api import MetadataStripIncomplete, remove_visible
|
||||
|
||||
monkeypatch.setattr(metadata, "get_ai_metadata", lambda _path: {"c2pa": "survived"})
|
||||
with pytest.raises(MetadataStripIncomplete):
|
||||
remove_visible(clean_png, tmp_path / "out.png")
|
||||
assert list(tmp_path.iterdir()) == [clean_png], "the staged output leaked"
|
||||
|
||||
def test_the_invisible_output_strip_is_verified(self, tmp_path, monkeypatch):
|
||||
"""The diffusion writer used to strip without re-scanning what it wrote."""
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
||||
|
||||
monkeypatch.setattr(metadata, "get_ai_metadata", lambda _path: {"c2pa": "survived"})
|
||||
remover = object.__new__(WatermarkRemover) # the writer needs no model state
|
||||
with pytest.raises(RuntimeError, match="survived"):
|
||||
remover._write_output(Image.new("RGB", (16, 16)), tmp_path / "out.png")
|
||||
assert list(tmp_path.iterdir()) == [], "an output that kept its AI metadata was published"
|
||||
|
||||
@pytest.mark.parametrize("command", ["erase", "remove_visible", "invisible_writer"])
|
||||
def test_an_output_the_package_just_wrote_is_not_probed_again(self, tmp_path, clean_png, monkeypatch, command):
|
||||
"""The readability probe decodes every pixel; on our own encode it is pure waste."""
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
||||
from remove_ai_watermarks.api import remove_visible
|
||||
|
||||
probed: list[Path] = []
|
||||
real = metadata.unreadable_reason
|
||||
monkeypatch.setattr(metadata, "unreadable_reason", lambda path: probed.append(path) or real(path))
|
||||
out = tmp_path / "out.png"
|
||||
if command == "erase":
|
||||
result = _invoke(["erase", "--region", "2,2,10,10", str(clean_png), "-o", str(out)])
|
||||
assert result.exit_code == 0, result.output
|
||||
elif command == "remove_visible":
|
||||
remove_visible(clean_png, out, write_noop=True)
|
||||
else:
|
||||
object.__new__(WatermarkRemover)._write_output(Image.new("RGB", (16, 16)), out)
|
||||
assert out.exists()
|
||||
assert [p for p in probed if p.name != clean_png.name] == []
|
||||
|
||||
def test_metadata_remove_reports_a_missing_ffmpeg_without_a_traceback(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda _name: None)
|
||||
source = tmp_path / "x.webm"
|
||||
source.write_bytes(b"\x1a\x45\xdf\xa3\x80") # an empty EBML header tiles the file
|
||||
result = _invoke(["metadata", "--remove", str(source), "-o", str(tmp_path / "out.webm")])
|
||||
assert result.exit_code == 1, result.output
|
||||
assert isinstance(result.exception, SystemExit), result.exception
|
||||
assert "ffmpeg" in result.output
|
||||
|
||||
@pytest.mark.usefixtures("cuda_refused")
|
||||
def test_invisible_reports_an_engine_that_cannot_start(self, clean_png):
|
||||
result = _invoke(["invisible", "--force", str(clean_png)])
|
||||
assert result.exit_code == 1, result.output
|
||||
assert isinstance(result.exception, SystemExit), result.exception
|
||||
assert "CUDA-only" in result.output
|
||||
|
||||
@pytest.mark.usefixtures("cuda_refused")
|
||||
def test_batch_reports_an_engine_that_cannot_start(self, tmp_path, clean_png):
|
||||
indir = tmp_path / "in"
|
||||
indir.mkdir()
|
||||
shutil.copy(clean_png, indir / "clean.png")
|
||||
result = _invoke(["batch", str(indir), "--mode", "invisible", "-o", str(tmp_path / "out")])
|
||||
assert result.exit_code == 1, result.output
|
||||
assert isinstance(result.exception, SystemExit), result.exception
|
||||
assert "CUDA-only" in result.output
|
||||
|
||||
|
||||
class TestEmptyBatch:
|
||||
"""Nothing processed is the no-signal code, not success (decided 2026-10-18)."""
|
||||
|
||||
def test_image_batch(self, tmp_path):
|
||||
indir = tmp_path / "in"
|
||||
indir.mkdir()
|
||||
(indir / "notes.txt").write_text("not an image")
|
||||
result = _invoke(["batch", str(indir), "-o", str(tmp_path / "out")])
|
||||
assert result.exit_code == 2, result.output
|
||||
assert not (tmp_path / "out").exists()
|
||||
|
||||
def test_video_batch(self, tmp_path):
|
||||
indir = tmp_path / "in"
|
||||
indir.mkdir()
|
||||
result = _invoke(["video", "batch", str(indir), "--mode", "metadata", "-o", str(tmp_path / "out")])
|
||||
assert result.exit_code == 2, result.output
|
||||
assert not (tmp_path / "out").exists()
|
||||
|
||||
|
||||
def test_mcp_reports_a_child_that_could_not_start():
|
||||
from remove_ai_watermarks import mcp_server
|
||||
|
||||
def broken(_args):
|
||||
raise OSError("exec format error")
|
||||
|
||||
result = mcp_server.call_tool("identify", {"path": "x.png"}, runner=broken)
|
||||
assert result["isError"] is True
|
||||
assert "exec format error" in result["content"][0]["text"]
|
||||
|
||||
|
||||
def test_mcp_does_not_call_an_empty_batch_a_scanned_file():
|
||||
"""An empty batch directory exits 2 like no-signal, but nothing was read."""
|
||||
import subprocess
|
||||
|
||||
from remove_ai_watermarks import mcp_server
|
||||
|
||||
def empty_batch(args):
|
||||
return subprocess.CompletedProcess(args, 2, stdout="No supported images found", stderr="")
|
||||
|
||||
result = mcp_server.call_tool("batch", {"directory": "in"}, runner=empty_batch)
|
||||
assert result["isError"] is False
|
||||
assert "nothing to process" in result["content"][0]["text"]
|
||||
@@ -185,7 +185,7 @@ def test_only_failures_are_tool_errors(returncode: int, stderr: str, is_error: b
|
||||
|
||||
assert result["isError"] is is_error
|
||||
assert result["structuredContent"]["exit_code"] == returncode
|
||||
assert ("not proof the file is clean" in result["content"][0]["text"]) is (returncode == 2 and not is_error)
|
||||
assert ("not proof of a clean file" in result["content"][0]["text"]) is (returncode == 2 and not is_error)
|
||||
if is_error:
|
||||
assert stderr.splitlines()[-1] in result["content"][0]["text"]
|
||||
|
||||
|
||||
+105
-14
@@ -29,6 +29,12 @@ from remove_ai_watermarks.metadata import (
|
||||
xai_signature,
|
||||
)
|
||||
|
||||
|
||||
def _blanked(box: bytes) -> bytes:
|
||||
"""The same-length ``free`` box ``strip_c2pa_boxes`` leaves in place of a 32-bit-size box."""
|
||||
return box[:4] + b"free" + bytes(len(box) - 8)
|
||||
|
||||
|
||||
# Real, committed C2PA sample images used to ground the SynthID-source tests.
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
|
||||
|
||||
@@ -123,7 +129,7 @@ class TestHasAiMetadata:
|
||||
assert has_ai_metadata(path)
|
||||
|
||||
def test_strip_c2pa_boxes_removes_uuid_box(self, tmp_path: Path):
|
||||
"""ISOBMFF strip should drop the C2PA uuid box and keep everything else."""
|
||||
"""ISOBMFF strip should blank the C2PA uuid box in place and keep everything else."""
|
||||
from remove_ai_watermarks._internal.isobmff import strip_c2pa_boxes
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
|
||||
@@ -133,7 +139,7 @@ class TestHasAiMetadata:
|
||||
mdat = b"\x00\x00\x00\x10mdat" + b"pixeldat"
|
||||
cleaned, stripped = strip_c2pa_boxes(ftyp + uuid_box + mdat)
|
||||
assert stripped == 1
|
||||
assert cleaned == ftyp + mdat
|
||||
assert cleaned == ftyp + _blanked(uuid_box) + mdat
|
||||
|
||||
def test_strip_c2pa_boxes_passthrough_for_non_isobmff(self):
|
||||
"""Non-ISOBMFF input must be returned unchanged."""
|
||||
@@ -145,7 +151,7 @@ class TestHasAiMetadata:
|
||||
assert cleaned == data
|
||||
|
||||
def test_remove_ai_metadata_strips_c2pa_in_avif(self, tmp_path: Path):
|
||||
"""End-to-end: ``remove_ai_metadata`` on a fake .avif drops the C2PA box."""
|
||||
"""End-to-end: ``remove_ai_metadata`` on a fake .avif blanks the C2PA box."""
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID, remove_ai_metadata
|
||||
|
||||
src = tmp_path / "in.avif"
|
||||
@@ -157,7 +163,7 @@ class TestHasAiMetadata:
|
||||
out = tmp_path / "out.avif"
|
||||
result = remove_ai_metadata(src, out)
|
||||
assert result == out
|
||||
assert out.read_bytes() == ftyp + mdat
|
||||
assert out.read_bytes() == ftyp + _blanked(uuid_box) + mdat
|
||||
# And after stripping, detection must no longer flag the cleaned file.
|
||||
from remove_ai_watermarks.metadata import has_ai_metadata
|
||||
|
||||
@@ -334,17 +340,20 @@ class TestHasAiMetadata:
|
||||
assert after is not None
|
||||
assert np.array_equal(after, before)
|
||||
|
||||
def test_strip_and_verify_reports_markers_in_undecodable_copy_through(self, tmp_path: Path):
|
||||
"""A truly undecodable file keeps the established fail-safe result."""
|
||||
def test_strip_and_verify_refuses_an_undecodable_file(self, tmp_path: Path):
|
||||
"""A truly undecodable file raises before anything is written.
|
||||
|
||||
It used to be copied through and reported with its surviving markers, but a
|
||||
copy-through of bytes no scanner can parse may carry nothing a re-scan sees,
|
||||
and then it verified as a real strip (`tests/test_fail_closed.py`).
|
||||
"""
|
||||
src = _corrupt_c2pa_png(tmp_path / "source.png")
|
||||
src.write_bytes(src.read_bytes()[: src.stat().st_size // 2])
|
||||
out = tmp_path / "copy.png"
|
||||
|
||||
result, remaining = strip_and_verify(src, out)
|
||||
|
||||
assert result == out
|
||||
assert "c2pa_manifest" in remaining
|
||||
assert out.read_bytes() == src.read_bytes()
|
||||
with pytest.raises(ValueError, match=r"cannot read source\.png"):
|
||||
strip_and_verify(src, out)
|
||||
assert not out.exists()
|
||||
|
||||
|
||||
class TestC2paMarkerIn:
|
||||
@@ -2084,7 +2093,7 @@ class TestIsobmffMetadataRemoval:
|
||||
"""Container-level AI-provenance stripping across ISOBMFF image/video/audio."""
|
||||
|
||||
def test_strips_ai_xmp_uuid_box(self):
|
||||
# A uuid box carrying a TC260 AIGC label is dropped by content match,
|
||||
# A uuid box carrying a TC260 AIGC label is blanked by content match,
|
||||
# regardless of the (non-C2PA) XMP UUID's byte order.
|
||||
from remove_ai_watermarks._internal.isobmff import strip_c2pa_boxes
|
||||
|
||||
@@ -2093,7 +2102,7 @@ class TestIsobmffMetadataRemoval:
|
||||
box = (24 + len(payload)).to_bytes(4, "big") + b"uuid" + xmp_uuid + payload
|
||||
cleaned, stripped = strip_c2pa_boxes(_MP4_FTYP + box + _MP4_MDAT)
|
||||
assert stripped == 1
|
||||
assert cleaned == _MP4_FTYP + _MP4_MDAT
|
||||
assert cleaned == _MP4_FTYP + _blanked(box) + _MP4_MDAT
|
||||
|
||||
def test_keeps_plain_non_ai_xmp(self):
|
||||
# A uuid box with ordinary (non-AI) XMP must be preserved.
|
||||
@@ -2125,7 +2134,7 @@ class TestIsobmffMetadataRemoval:
|
||||
src.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
|
||||
out = tmp_path / "out.bin"
|
||||
remove_ai_metadata(src, out)
|
||||
assert out.read_bytes() == _MP4_FTYP + _MP4_MDAT
|
||||
assert out.read_bytes() == _MP4_FTYP + _blanked(uuid_box) + _MP4_MDAT
|
||||
|
||||
def test_unparseable_audio_raises(self, tmp_path: Path):
|
||||
# Garbage that ffmpeg can't parse must raise a clear error, not crash in
|
||||
@@ -2557,3 +2566,85 @@ class TestTc260AudioPlacements:
|
||||
cleaned = tmp_path / f"clean{suffix}"
|
||||
remove_ai_metadata(source, cleaned)
|
||||
assert "aigc_label" not in get_ai_metadata(cleaned)
|
||||
|
||||
|
||||
def _shift_heif_iloc(data: bytes, delta: int) -> bytes:
|
||||
"""Add ``delta`` to every file-offset extent in the HEIF ``iloc`` box.
|
||||
|
||||
Lets a test insert a box ahead of ``mdat`` the way a C2PA signer does, which
|
||||
moves the coded image by ``delta`` bytes and so needs ``iloc`` rewritten.
|
||||
"""
|
||||
out = bytearray(data)
|
||||
meta = data.index(b"meta") - 4
|
||||
iloc = data.index(b"iloc", meta) - 4
|
||||
version = data[iloc + 8]
|
||||
pos = iloc + 12
|
||||
offset_size, length_size = data[pos] >> 4, data[pos] & 15
|
||||
base_size = data[pos + 1] >> 4
|
||||
index_size = data[pos + 1] & 15 if version in (1, 2) else 0
|
||||
pos += 2
|
||||
id_size = 2 if version < 2 else 4
|
||||
count = int.from_bytes(data[pos : pos + id_size], "big")
|
||||
pos += id_size
|
||||
for _ in range(count):
|
||||
pos += id_size
|
||||
method = 0
|
||||
if version in (1, 2):
|
||||
method = data[pos + 1] & 15
|
||||
pos += 2
|
||||
pos += 2 # data_reference_index
|
||||
base = int.from_bytes(data[pos : pos + base_size], "big") if base_size else 0
|
||||
if method == 0 and base_size:
|
||||
out[pos : pos + base_size] = (base + delta).to_bytes(base_size, "big")
|
||||
pos += base_size
|
||||
extents = int.from_bytes(data[pos : pos + 2], "big")
|
||||
pos += 2
|
||||
for _ in range(extents):
|
||||
pos += index_size
|
||||
if method == 0 and not base_size:
|
||||
offset = int.from_bytes(data[pos : pos + offset_size], "big")
|
||||
out[pos : pos + offset_size] = (offset + delta).to_bytes(offset_size, "big")
|
||||
pos += offset_size + length_size
|
||||
return bytes(out)
|
||||
|
||||
|
||||
class TestIsobmffStripKeepsOffsets:
|
||||
"""A HEIF locates its coded image through absolute ``iloc`` offsets, and an MP4
|
||||
through ``stco``/``co64``. Dropping a C2PA ``uuid`` box that sits ahead of
|
||||
``mdat`` moved the image without rewriting them, so the cleaned HEIC no longer
|
||||
decoded (a phone HEIC, 2026-10-13)."""
|
||||
|
||||
def test_c2pa_box_before_mdat_keeps_heic_decodable(self, tmp_path: Path):
|
||||
pillow_heif = pytest.importorskip("pillow_heif")
|
||||
pillow_heif.register_heif_opener()
|
||||
plain = tmp_path / "plain.heic"
|
||||
Image.new("RGB", (64, 48), (200, 30, 30)).save(plain, format="HEIF")
|
||||
data = plain.read_bytes()
|
||||
c2pa_box = struct.pack(">I", 8 + 16 + 64) + b"uuid" + C2PA_UUID + b"\x07" * 64
|
||||
mdat = data.index(b"mdat") - 4
|
||||
signed = _shift_heif_iloc(data[:mdat], len(c2pa_box)) + c2pa_box + data[mdat:]
|
||||
source = tmp_path / "signed.heic"
|
||||
source.write_bytes(signed)
|
||||
with Image.open(source) as im:
|
||||
expected = im.convert("RGB").tobytes()
|
||||
|
||||
cleaned = remove_ai_metadata(source, tmp_path / "clean.heic")
|
||||
|
||||
assert C2PA_UUID not in cleaned.read_bytes()
|
||||
with Image.open(cleaned) as im:
|
||||
assert im.convert("RGB").tobytes() == expected
|
||||
|
||||
def test_blanked_box_keeps_every_later_offset(self):
|
||||
from remove_ai_watermarks._internal.isobmff import strip_c2pa_boxes
|
||||
|
||||
ftyp = b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1heic"
|
||||
c2pa_box = struct.pack(">I", 8 + 16 + 8) + b"uuid" + C2PA_UUID + b"manifest"
|
||||
mdat = b"\x00\x00\x00\x10mdat" + b"pixeldat"
|
||||
data = ftyp + c2pa_box + mdat
|
||||
|
||||
cleaned, stripped = strip_c2pa_boxes(data)
|
||||
|
||||
assert stripped == 1
|
||||
assert len(cleaned) == len(data)
|
||||
assert cleaned.index(b"mdat") == data.index(b"mdat")
|
||||
assert cleaned == ftyp + _blanked(c2pa_box) + mdat
|
||||
@@ -595,11 +595,11 @@ class TestISOBMFF:
|
||||
assert not is_isobmff(b"abc")
|
||||
|
||||
def test_strips_jpegxl_jumb_box(self):
|
||||
"""JPEG-XL stores JUMBF in a ``jumb`` box, always stripped."""
|
||||
"""JPEG-XL stores JUMBF in a ``jumb`` box, always blanked to ``free``."""
|
||||
jumb = struct.pack(">I", 8 + 5) + b"jumb" + b"hello"
|
||||
cleaned, stripped = strip_c2pa_boxes(FTYP + jumb)
|
||||
assert stripped == 1
|
||||
assert cleaned == FTYP
|
||||
assert cleaned == FTYP + struct.pack(">I", 8 + 5) + b"free" + bytes(5)
|
||||
|
||||
def test_keeps_non_c2pa_box_with_64bit_size(self):
|
||||
"""size==1 means a 64-bit largesize follows; non-C2PA box is kept."""
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import provider_oracle_web as web
|
||||
@@ -76,6 +77,36 @@ def _thordata_slot() -> oracles.ExecutionSlot:
|
||||
"not_detected",
|
||||
"unavailable",
|
||||
),
|
||||
(
|
||||
"synthid-web",
|
||||
"Watermark detected",
|
||||
"detected",
|
||||
"unavailable",
|
||||
),
|
||||
(
|
||||
"synthid-web",
|
||||
"Watermark not detected",
|
||||
"not_detected",
|
||||
"unavailable",
|
||||
),
|
||||
(
|
||||
"synthid-web",
|
||||
"SynthID was not detected\nIt is unlikely that this media was made with AI from a SynthID partner",
|
||||
"not_detected",
|
||||
"unavailable",
|
||||
),
|
||||
(
|
||||
"synthid-web",
|
||||
"Please sign in before detection.",
|
||||
"indeterminate",
|
||||
"unavailable",
|
||||
),
|
||||
(
|
||||
"synthid-web",
|
||||
"Watermark detected\nWatermark not detected",
|
||||
"indeterminate",
|
||||
"unavailable",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parse_provider_result(
|
||||
@@ -122,7 +153,12 @@ def test_openai_provider_error_is_a_settled_indeterminate_result() -> None:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("surface", "ready_state"),
|
||||
[("meta-web", "networkidle"), ("microsoft-web", "networkidle"), ("openai-web", "load")],
|
||||
[
|
||||
("meta-web", "networkidle"),
|
||||
("microsoft-web", "networkidle"),
|
||||
("openai-web", "load"),
|
||||
("synthid-web", "networkidle"),
|
||||
],
|
||||
)
|
||||
def test_react_uploads_wait_for_surface_readiness_before_assigning_the_file(
|
||||
tmp_path: Path,
|
||||
@@ -183,29 +219,44 @@ def test_proxy_settings_are_loaded_from_an_environment_reference() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_thordata_proxy_settings_pin_one_exit_for_the_browser_session() -> None:
|
||||
def test_thordata_proxy_settings_take_a_fresh_europe_or_us_session() -> None:
|
||||
proxy = web.proxy_settings(
|
||||
_thordata_slot(),
|
||||
{"THORDATA_ROUTE_US": "http://route:secret@gateway.pr.thordata.net:9999"},
|
||||
sticky_session_id="oracle0123456789abcdef",
|
||||
{
|
||||
"THORDATA_ROUTE_US": "http://td-customer-user-country-JP-sessid-prepared-sesstime-15:secret@gateway.pr.thordata.net:9999"
|
||||
},
|
||||
country="DE",
|
||||
session_id="freshsession01",
|
||||
)
|
||||
|
||||
assert proxy == {
|
||||
"server": "http://gateway.pr.thordata.net:9999",
|
||||
"username": "route-sessid-oracle0123456789abcdef-sesstime-90",
|
||||
"username": "td-customer-user-country-DE-sessid-freshsession01-sesstime-90",
|
||||
"password": "secret",
|
||||
}
|
||||
|
||||
|
||||
def test_thordata_proxy_settings_preserve_an_explicit_session() -> None:
|
||||
proxy = web.proxy_settings(
|
||||
_thordata_slot(),
|
||||
{"THORDATA_ROUTE_US": ("http://route-sessid-manual-sesstime-15:secret@gateway.pr.thordata.net:9999")},
|
||||
sticky_session_id="oracle0123456789abcdef",
|
||||
)
|
||||
def test_thordata_proxy_settings_reject_a_country_outside_europe_or_the_us() -> None:
|
||||
assert "US" in web.EUROPE_OR_US_COUNTRIES
|
||||
assert "DE" in web.EUROPE_OR_US_COUNTRIES
|
||||
assert "JP" not in web.EUROPE_OR_US_COUNTRIES
|
||||
|
||||
assert proxy is not None
|
||||
assert proxy["username"] == "route-sessid-manual-sesstime-15"
|
||||
with pytest.raises(ValueError, match="Europe or the US"):
|
||||
web.proxy_settings(
|
||||
_thordata_slot(),
|
||||
{"THORDATA_ROUTE_US": "http://route:secret@gateway.pr.thordata.net:9999"},
|
||||
country="JP",
|
||||
session_id="freshsession01",
|
||||
)
|
||||
|
||||
|
||||
def test_new_proxy_session_changes_between_calls() -> None:
|
||||
first = web.new_proxy_session()
|
||||
second = web.new_proxy_session()
|
||||
|
||||
assert first != second
|
||||
assert re.fullmatch(r"[A-Za-z0-9]{1,32}", first)
|
||||
assert re.fullmatch(r"[A-Za-z0-9]{1,32}", second)
|
||||
|
||||
|
||||
def test_proxy_settings_do_not_treat_a_missing_secret_as_direct_access() -> None:
|
||||
@@ -356,6 +407,8 @@ def test_run_web_batch_loads_the_selected_proxy_from_dotenv(
|
||||
proxies.append(proxy)
|
||||
return [web.WebVerdict("not_detected", "unavailable", "No OpenAI signals detected")]
|
||||
|
||||
monkeypatch.setattr(web, "choose_proxy_country", lambda rng=None: "NL")
|
||||
monkeypatch.setattr(web, "new_proxy_session", lambda: "freshsession01")
|
||||
monkeypatch.setattr(web, "_submit_batch", submit)
|
||||
report = web.run_web_batch(
|
||||
manifest_path,
|
||||
@@ -367,11 +420,10 @@ def test_run_web_batch_loads_the_selected_proxy_from_dotenv(
|
||||
)
|
||||
|
||||
assert report["watermark_results"] == {"not_detected": 1}
|
||||
manifest_sha256 = json.loads((manifest_path.parent / "results.json").read_text(encoding="utf-8"))["manifest_sha256"]
|
||||
assert proxies == [
|
||||
{
|
||||
"server": "http://proxy.pr.thordata.net:9999",
|
||||
"username": (f"td-customer-user-country-US-sessid-oracle{manifest_sha256[:16]}-sesstime-90"),
|
||||
"username": "td-customer-user-country-NL-sessid-freshsession01-sesstime-90",
|
||||
"password": "test-thordata-password",
|
||||
}
|
||||
]
|
||||
@@ -392,14 +444,12 @@ def test_run_web_batch_requires_explicit_upload_acknowledgement(
|
||||
web.run_web_batch(manifest_path, acknowledge_uploads=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("surface", ["gemini-web", "synthid-web"])
|
||||
def test_run_web_batch_refuses_google_real_browser_workflow(
|
||||
def test_run_web_batch_refuses_gemini_real_browser_workflow(
|
||||
tmp_path: Path,
|
||||
tmp_clean_png: Path,
|
||||
surface: str,
|
||||
) -> None:
|
||||
manifest_path = oracles.prepare_batch(
|
||||
surface,
|
||||
"gemini-web",
|
||||
[tmp_clean_png],
|
||||
output_dir=tmp_path / "oracle-batch",
|
||||
repository_root=oracles.REPOSITORY_ROOT,
|
||||
@@ -407,3 +457,18 @@ def test_run_web_batch_refuses_google_real_browser_workflow(
|
||||
|
||||
with pytest.raises(ValueError, match="real Chrome"):
|
||||
web.run_web_batch(manifest_path, acknowledge_uploads=True)
|
||||
|
||||
|
||||
def test_run_web_batch_requires_a_slot_for_synthid(
|
||||
tmp_path: Path,
|
||||
tmp_clean_png: Path,
|
||||
) -> None:
|
||||
manifest_path = oracles.prepare_batch(
|
||||
"synthid-web",
|
||||
[tmp_clean_png],
|
||||
output_dir=tmp_path / "oracle-batch",
|
||||
repository_root=oracles.REPOSITORY_ROOT,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="explicit execution slot"):
|
||||
web.run_web_batch(manifest_path, acknowledge_uploads=True)
|
||||
@@ -36,7 +36,8 @@ def test_catalog_covers_every_project_vendor_oracle_surface() -> None:
|
||||
"do not infer from visible content, logos, text, or metadata."
|
||||
)
|
||||
synthid = oracles.SURFACES["synthid-web"]
|
||||
assert synthid.automation == "real_browser"
|
||||
assert synthid.automation == "playwright"
|
||||
assert synthid.account_slots is False
|
||||
assert synthid.provider_key == "google"
|
||||
assert synthid.url == "https://synthid.com/"
|
||||
assert synthid.media_types == ("image", "video", "audio")
|
||||
@@ -84,9 +85,9 @@ def test_plan_json_keeps_every_google_web_surface_primary() -> None:
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert [(row["key"], row["preference"]) for row in payload] == [
|
||||
("gemini-web", "primary"),
|
||||
("synthid-web", "primary"),
|
||||
assert [(row["key"], row["preference"], row["url"]) for row in payload] == [
|
||||
("gemini-web", "primary", "https://gemini.google.com/"),
|
||||
("synthid-web", "primary", "https://synthid.com/"),
|
||||
]
|
||||
|
||||
|
||||
@@ -101,6 +102,16 @@ def test_plan_json_marks_web_as_an_explicit_fallback_when_api_exists() -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_plan_text_tells_the_caller_to_run_the_next_surface() -> None:
|
||||
result = CliRunner().invoke(oracles.cli, ["plan", "openai"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "primary\topenai-api\tapi\tContent Provenance API" in result.output
|
||||
assert "fallback\topenai-web\tplaywright\tContent Provenance web verifier" in result.output
|
||||
assert "run the next surface as its own command" in result.output
|
||||
assert "separate operator decision" not in result.output
|
||||
|
||||
|
||||
def test_slots_support_multiple_accounts_and_networks(tmp_path: Path) -> None:
|
||||
config = tmp_path / "slots.json"
|
||||
config.write_text(
|
||||
@@ -140,6 +151,14 @@ def test_slots_support_multiple_accounts_and_networks(tmp_path: Path) -> None:
|
||||
"network_label": "home",
|
||||
"api_key_env": "OPENAI_API_KEY_1",
|
||||
},
|
||||
{
|
||||
"name": "synthid-proxy",
|
||||
"surface": "synthid-web",
|
||||
"account_label": None,
|
||||
"browser_profile": None,
|
||||
"network_label": "thordata",
|
||||
"proxy_url_env": "THORDATA_PROXY",
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
@@ -153,7 +172,12 @@ def test_slots_support_multiple_accounts_and_networks(tmp_path: Path) -> None:
|
||||
"gemini-research",
|
||||
"openai-api-1",
|
||||
"openai-west",
|
||||
"synthid-proxy",
|
||||
}
|
||||
assert slots["synthid-proxy"]["browser_profile"] is None
|
||||
assert slots["synthid-proxy"]["account_label"] is None
|
||||
assert slots["synthid-proxy"]["proxy_url_env"] == "THORDATA_PROXY"
|
||||
assert slots["synthid-proxy"]["google_account_index"] is None
|
||||
assert slots["gemini-research"]["account_label"] == "research-b"
|
||||
assert slots["gemini-research"]["google_account_index"] == 2
|
||||
assert slots["openai-west"]["network_label"] == "west-egress"
|
||||
@@ -192,6 +216,33 @@ def test_named_slot_uses_repository_local_config_by_default(
|
||||
assert slot["api_key_env"] == "OPENAI_API_KEY_1"
|
||||
|
||||
|
||||
def test_proxy_is_only_for_an_unauthenticated_oracle(tmp_path: Path) -> None:
|
||||
for surface in oracles.SURFACES.values():
|
||||
slot: dict[str, str | int | None] = {
|
||||
"name": "probe",
|
||||
"surface": surface.key,
|
||||
"account_label": "account" if surface.account_slots else None,
|
||||
"browser_profile": "Profile 1" if surface.automation == "real_browser" else None,
|
||||
"network_label": "route",
|
||||
"proxy_url_env": "THORDATA_ROUTE_US",
|
||||
}
|
||||
if surface.key == "gemini-web":
|
||||
slot["google_account_index"] = 0
|
||||
if surface.key == "openai-api":
|
||||
slot["api_key_env"] = "OPENAI_API_KEY_1"
|
||||
config = tmp_path / f"{surface.key}.json"
|
||||
config.write_text(json.dumps({"format_version": 1, "slots": [slot]}), encoding="utf-8")
|
||||
|
||||
if surface.account_slots:
|
||||
with pytest.raises(ValueError, match="does not use proxy_url_env"):
|
||||
oracles.load_slots(config)
|
||||
continue
|
||||
|
||||
loaded = oracles.load_slots(config)
|
||||
assert loaded["probe"]["proxy_url_env"] == "THORDATA_ROUTE_US"
|
||||
assert loaded["probe"]["account_label"] is None
|
||||
|
||||
|
||||
def test_slots_override_requires_a_named_slot(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValueError, match="--slots requires --slot"):
|
||||
oracles.resolve_slot("openai-api", slots_path=tmp_path / "slots.json", slot_name=None)
|
||||
@@ -278,25 +329,35 @@ def test_slots_override_requires_a_named_slot(tmp_path: Path) -> None:
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": "synthid-no-profile",
|
||||
"name": "synthid-with-profile",
|
||||
"surface": "synthid-web",
|
||||
"account_label": "personal-a",
|
||||
"browser_profile": None,
|
||||
"account_label": None,
|
||||
"browser_profile": "Profile 2",
|
||||
"network_label": "home",
|
||||
},
|
||||
"requires a browser_profile",
|
||||
"only a real-browser surface uses a browser_profile",
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": "synthid-with-account-index",
|
||||
"surface": "synthid-web",
|
||||
"account_label": "personal-a",
|
||||
"browser_profile": "Profile 2",
|
||||
"account_label": None,
|
||||
"browser_profile": None,
|
||||
"google_account_index": 0,
|
||||
"network_label": "home",
|
||||
},
|
||||
"only gemini-web uses google_account_index",
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": "synthid-with-account",
|
||||
"surface": "synthid-web",
|
||||
"account_label": "personal-a",
|
||||
"browser_profile": None,
|
||||
"network_label": "home",
|
||||
},
|
||||
"does not use account slots",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_slots_require_the_identity_needed_by_the_surface(
|
||||
|
||||
@@ -214,3 +214,61 @@ def test_clawhub_uses_local_install(tmp_path: Path) -> None:
|
||||
"login --token synthetic",
|
||||
"inspect remove-ai-watermarks --file SKILL.md",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix" or shutil.which("bash") is None, reason="Executes an Ubuntu Bash workflow")
|
||||
@pytest.mark.parametrize(("status", "listed"), [("200", "true"), ("404", "false"), ("503", None)])
|
||||
def test_mcp_registry_lookup_skips_listed_versions_and_fails_on_other_replies(
|
||||
tmp_path: Path, status: str, listed: str | None
|
||||
) -> None:
|
||||
workflow = _workflow("publish-mcp-registry.yml")
|
||||
script = _step("publish-mcp-registry.yml", "publish", "Skip a version the registry already lists")["run"]
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
curl = bin_dir / "curl"
|
||||
curl.write_text(f'#!/bin/sh\nprintf "%s\\n" "$*" >> "$CALL_LOG"\nprintf {status}\n')
|
||||
curl.chmod(0o755)
|
||||
env = {
|
||||
**os.environ,
|
||||
**workflow["jobs"]["publish"]["env"],
|
||||
"PATH": str(bin_dir) + os.pathsep + os.environ["PATH"],
|
||||
"VERSION": "0.46.0",
|
||||
"GITHUB_OUTPUT": str(tmp_path / "output"),
|
||||
"GITHUB_STEP_SUMMARY": str(tmp_path / "summary"),
|
||||
"CALL_LOG": str(tmp_path / "calls"),
|
||||
}
|
||||
result = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=env, capture_output=True, text=True)
|
||||
# The server name carries a slash, so it must reach the registry percent-encoded.
|
||||
assert "/v0/servers/io.github.wiltodelta%2Fremove-ai-watermarks/versions/0.46.0" in (tmp_path / "calls").read_text()
|
||||
if listed is None:
|
||||
assert result.returncode != 0
|
||||
return
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert f"listed={listed}" in (tmp_path / "output").read_text()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "posix" or shutil.which("bash") is None, reason="Executes an Ubuntu Bash workflow")
|
||||
@pytest.mark.parametrize(
|
||||
("release", "package", "accepted"),
|
||||
[("1.0.0", "1.0.0", True), ("1.0.1", "1.0.0", False), ("1.0.0", "1.0.1", False)],
|
||||
)
|
||||
def test_mcp_registry_publishes_only_a_server_json_that_matches_the_release(
|
||||
tmp_path: Path, release: str, package: str, accepted: bool
|
||||
) -> None:
|
||||
workflow = _workflow("publish-mcp-registry.yml")
|
||||
script = _step("publish-mcp-registry.yml", "publish", "Check server.json matches the release")["run"]
|
||||
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
|
||||
manifest["version"] = "1.0.0"
|
||||
# A stale package entry under a bumped server version must not publish either.
|
||||
manifest["packages"][0]["version"] = package
|
||||
(tmp_path / "server.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
env = {**os.environ, **workflow["jobs"]["publish"]["env"], "VERSION": release}
|
||||
result = subprocess.run(["bash", "-c", script], cwd=tmp_path, env=env, capture_output=True, text=True)
|
||||
assert (result.returncode == 0) == accepted, result.stderr
|
||||
|
||||
|
||||
def test_mcp_registry_publishes_on_release_with_an_oidc_token() -> None:
|
||||
workflow = _workflow("publish-mcp-registry.yml")
|
||||
assert workflow["permissions"]["id-token"] == "write"
|
||||
# PyYAML reads the bare `on:` key as the boolean True.
|
||||
assert set(workflow[True]) == {"release", "workflow_dispatch"}
|
||||
@@ -117,7 +117,7 @@ class TestIsobmffStripFailSafe:
|
||||
|
||||
cleaned, stripped = isobmff.strip_c2pa_boxes(data)
|
||||
assert stripped == 1
|
||||
assert len(cleaned) == len(data) - len(c2pa_box)
|
||||
assert len(cleaned) == len(data)
|
||||
assert isobmff.C2PA_UUID not in cleaned
|
||||
|
||||
def test_malformed_box_does_not_truncate_tail(self):
|
||||
|
||||
@@ -56,7 +56,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.14.3"
|
||||
version = "3.14.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
@@ -68,108 +68,109 @@ dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/f3/8997f18890a92c79f77fcfbb4f78ca17c2d4ab109e9eb6d31b4e9de194a0/aiohttp-3.14.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d51db97c96384fbfcaf8f4c65922183a68b94f891c3c10c862ef5f6df2adbb1f", size = 832358, upload-time = "2026-10-11T00:59:42.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/42/084651e9efb5cadb99265f786df60f2a7b353cead7bfd2c87003cb4867ad/aiohttp-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ae53924aa853a7a2ca20ed4142c7c6b56338e4d4cd999e2980075b9efc2e257a", size = 559636, upload-time = "2026-10-11T00:59:43.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/fe/92838944e601f0fe195fd3f3ada37e29fbee3d19bfd323fe15937ec79d36/aiohttp-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2c473a355f9239efcb72c92d5abfd8fcdb0cc78c8e9af607e72ca12dbb36593", size = 552588, upload-time = "2026-10-11T00:59:45.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b8/dd9b95c5c20ceae1b47738e656bd79e9883e3117a9cc8b143901eb604e9a/aiohttp-3.14.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e8fa6644e541fcd7e02430588c7fc93b602c1778ea0bc345505db76b61cfb4", size = 1928224, upload-time = "2026-10-11T00:59:47.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/47/70010cd2ba8746968d53d433ff328c33f06a66c99da5f3de759c64a6b2eb/aiohttp-3.14.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:579f97d5120f2971876d2ddca2968135f6944d00c44c3a6590ad7d86ca9b403f", size = 1877674, upload-time = "2026-10-11T00:59:49.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a1/b026f071dbd87f0bdba86b92d47e46f3899e3ad7143b6e0fe7e7887090b4/aiohttp-3.14.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:24409db442e2fb6e766bc7f3943851a8381dec3098140e43bb2e843b79e31b12", size = 1985850, upload-time = "2026-10-11T00:59:51.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/d1/f7b6f6f8c3cb5a71b53baeddc2e70c224c28b12a23a55c42994c504019b7/aiohttp-3.14.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e8f97c0488ffda3082766ac0f2c8150a9a58c4d05788330e479cfd449b37939", size = 2105471, upload-time = "2026-10-11T00:59:53.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/74/2a2b22953de15c6c8a80debf26db3047e4e8a5a5db6dd7c2c6c01eec0605/aiohttp-3.14.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50a195903119008fe9cc68710535eb37f556ffffd6a7759afe70a2c145587045", size = 1929915, upload-time = "2026-10-11T00:59:55.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/ad/f80e8d33933d0eacd0217efa3b7fb32c9f48ed480ed0db53cf9948bf474f/aiohttp-3.14.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0133c3c3b54a0bf1e71fa5c1ad95c93f07fd54e24ef1fe182f5122e1573d2bf1", size = 1773832, upload-time = "2026-10-11T00:59:57.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/a4/0273d239f3e3bb69438f209c64c82e1f98dc60d5db264795e41f7dd05dcb/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c172db893e516e1358e65a95ee20b7ce7173963eefe318b6ab2a2220688b999e", size = 1901001, upload-time = "2026-10-11T01:00:00.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/ed/7dd439d26c654645bc34ca3c9a822fc11c1c51801fed1cefdfe1cc41c1f6/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f2a7966bda23dd85051f1661ce0ace38d6890e05ec6c357ecae9d2479cba377e", size = 1907218, upload-time = "2026-10-11T01:00:02.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/dd/86249c3b8248562a17fd3e22ee0c164378d65766f01aceef62cf52783710/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4887d130a7bbfed3a85493bb5a25e5b5b558d40c1d986dd16970d2bb26d63793", size = 1958650, upload-time = "2026-10-11T01:00:04.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/0b/ca4d53d68f683ce195807fd0aeb063bf6d1c6b39343fff234c9524e8d5a9/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:225c579c23b68b343cccea27a7e06e3bd8ec23a09c30b427eb3f1e4ca6239b20", size = 1760057, upload-time = "2026-10-11T01:00:06.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/42/005437ffd7fa56c2ce3347add40404b654a32754ce91f2e2a14b9e5ab2bc/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ab52d8f1fc1b64821c1fbad64a647ed6203627004059a6d1ed4f0858a1499703", size = 1978081, upload-time = "2026-10-11T01:00:08.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/71/3a5b66fe1b7b23d3c6524350817ed55de5feb7fbb4cccba7cbe044d118dd/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb131d775a1573c1aee66656bd78b023577bbdb6cb8349a07773bd4f73e68a6e", size = 1922954, upload-time = "2026-10-11T01:00:11.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/84/d08a554f281d42fd7c6b2b6dd7738f3e3a7a9f657ddd87e11ca2091cdfe3/aiohttp-3.14.5-cp311-cp311-win32.whl", hash = "sha256:e87046c8ff77a8decdb6a41d8ab25824b47531b2da933aeab0c1e21c7acff329", size = 488101, upload-time = "2026-10-11T01:00:12.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/15/52b5e65f02b33686ae49f1518548ae4f5a7adaf31d9d3c54fa12a143137b/aiohttp-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:6f275c11d1aa6d4c458e05a68be084efe3c55a113d99e3f46a318098e52948fc", size = 512575, upload-time = "2026-10-11T01:00:14.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b9/bb75012635f3defb0f7cbb7c4ae391e36bb9cb3fbdca3b9944e2ebebf743/aiohttp-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:b032a0023eb41d768ce77d83210ab2a3c389bc0b09313273c7e1eca48c10a755", size = 493179, upload-time = "2026-10-11T01:00:16.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/e1/2841e020ebb7aefae5513586193e011e06313d9a6bdbd296622afbbce204/aiohttp-3.14.5-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a", size = 538932, upload-time = "2026-10-11T01:00:54.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/a6/7fb8ea8fe96bcc7b7c7a36d10f021d99d01a8dc8a4b3f0ddacecfad9a80e/aiohttp-3.14.5-cp313-cp313-android_24_x86_64.whl", hash = "sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91", size = 553329, upload-time = "2026-10-11T01:00:56.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/64/d056e3c27647dc25af1a592cf356245382ea7c808171b9dac7677afedfc8/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec", size = 521367, upload-time = "2026-10-11T01:00:58.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/e4/95226147e11d4db916fd1d495dcf85af8e3816e38333e42718241196e848/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7", size = 528322, upload-time = "2026-10-11T01:01:00.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/cd/1d3c9192cafdb51cad62b2d3ded96cff9cc8af51893210aadd448a325389/aiohttp-3.14.5-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a", size = 539874, upload-time = "2026-10-11T01:01:02.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/0c/dfa33aecc7d4d1dc75e05248f5eac5a0edf4d09e7b44d93ab62529b0c1db/aiohttp-3.14.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02", size = 813836, upload-time = "2026-10-11T01:01:04.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/17/4a63738052d20567d55529d6daa1b9480d906fd52930fbcf6d3fbed618f0/aiohttp-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603", size = 548070, upload-time = "2026-10-11T01:01:06.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/e5/b57e58695a757fd4c02497c033fced96a69c631b13866c43d336530c9670/aiohttp-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a", size = 545786, upload-time = "2026-10-11T01:01:07.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/68/8c2c67a3aedf46e00f3c42f04fbc6983de80d4ed5786151e33681ba45883/aiohttp-3.14.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d", size = 1886492, upload-time = "2026-10-11T01:01:09.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/4b/74aab5e8d28c62e8f795b4fe8f38cf5586fd264a9a27bd2141ef6490333d/aiohttp-3.14.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae", size = 1870057, upload-time = "2026-10-11T01:01:12.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/f7/eafc3b1988302b1815d9fd4a21071be5c360d616c0a430d02fd92dc97688/aiohttp-3.14.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d", size = 1925119, upload-time = "2026-10-11T01:01:14.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/04/78d8f294f74dd570f3898ff20402349fce524176045df98ba727d6846a68/aiohttp-3.14.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155", size = 2053137, upload-time = "2026-10-11T01:01:16.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/51/395d225ef36f5a50d8e548dcd3141bfdbcd31fb6eed859022c573d2c4d66/aiohttp-3.14.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6", size = 1911038, upload-time = "2026-10-11T01:01:18.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/a4/2aec1aa06d82e8a244843b5dae31d78061e5e76744270a86dd0ee051c889/aiohttp-3.14.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c", size = 1702205, upload-time = "2026-10-11T01:01:20.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/27/6051bfde7b6f418f70edd60d655fa426abb3355fa0981764739d87ecf160/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421", size = 1848588, upload-time = "2026-10-11T01:01:22.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/44/55efc06fc26c4e6e1c095f231b4c222bf2d86d64a8eebbc24b2bb5958ea8/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3", size = 1887540, upload-time = "2026-10-11T01:01:25.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/dc/1502bfdc2a65760d386ac6a00090b0addaa8a3c9c60b3f8127fad3a9afb2/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec", size = 1895030, upload-time = "2026-10-11T01:01:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/7e/44174bb6288264418c9eec07a5e35180969c0d5a796c7af544db3cb8a33a/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1", size = 1693244, upload-time = "2026-10-11T01:01:29.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/dd/b507d64e50db23888582fff08eda13998b12f9dea70c072edaac19218380/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600", size = 1926308, upload-time = "2026-10-11T01:01:31.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/4b/5b51b4f63e3f2793151f4aea49c48fe1e00baeb7cec9c7a206de499f8de0/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3", size = 1888406, upload-time = "2026-10-11T01:01:33.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/13/d5e818a5eaba9f822016727299f41c0e1075799f82a6433ec508a93ab867/aiohttp-3.14.5-cp313-cp313-win32.whl", hash = "sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c", size = 479970, upload-time = "2026-10-11T01:01:35.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/d0/8eca2c65aa467320990d78fb2005f38ed3588944280c39ff9deb6423fef1/aiohttp-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319", size = 505985, upload-time = "2026-10-11T01:01:37.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f8/4cdd65305d2fca14b886bea9ed2abb1fe726287872524e56e3d26692b47d/aiohttp-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09", size = 487629, upload-time = "2026-10-11T01:01:39.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/be/3184a1d34a8be665569eadb7e9e764b4629e4f3413e241cb2e4d6fecf3b3/aiohttp-3.14.5-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:939042d5cda21d41a6f512e7cc8b8e33a2aebff863352251da495fbd91b673b5", size = 540673, upload-time = "2026-10-11T01:01:41.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/2a/d35f3ba4cf157b072e3b674bf9983047ca5ea5173c995d32d877e1191d36/aiohttp-3.14.5-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6da32b5ff3fd78d244e37300463434c7145162bfd2b6e9e915ab164da37f7343", size = 554087, upload-time = "2026-10-11T01:01:43.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d2/61a33880ca4eaca95a9c60ca3f6beed15555af1028652dfaac601627787b/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b438b73c38111818d0c9d6a5c2bfed8584c8e503a49ef085d70e874ec846738", size = 522613, upload-time = "2026-10-11T01:01:46.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1d/de579b299d2225dc2c6fd99d579d91f16c02a913fb5af9a3cf2fe9bd88ba/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:755933b107ea7a6a9ac916f635a70595a5b1a32fac10a8ff0b9f2ab88555550c", size = 529682, upload-time = "2026-10-11T01:01:48.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/4a/ddb923564e15e053b6e060b0036e1694dcadcb13aa476c5a87dcad20e336/aiohttp-3.14.5-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b3cc509327c7b27f6f4727a8830f4004f6df7766e179f2f4b8e54e65c0bec5d3", size = 540120, upload-time = "2026-10-11T01:01:50.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/36/a640fbecaa53727a5900b892bdbe17b5f3e8cc88903e22864fe41b654def/aiohttp-3.14.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7bd8ac754ebd6733a3e2a0dd1674c4d8ab086196803fd8dcd776f07b4e2607d9", size = 818795, upload-time = "2026-10-11T01:01:52.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/b6/d52ca608859e271b5fa7944074802dc45f60e52c318a4ddc34edbf73586e/aiohttp-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e724a7b6091f0b1ac064f9d1b15ff9ec52e6033a86cdae649e5f086e32a3c0db", size = 549667, upload-time = "2026-10-11T01:01:54.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/b5/05b8ac39a76ff4bca89f42a4c2471560c71f894ff6e4bc16158c951874e3/aiohttp-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c32e26310cc10e547f53cd13d39a369034f69dcb7d749d5cb0e5f67bc196b6ba", size = 548922, upload-time = "2026-10-11T01:01:56.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/b0/5aa186d56ce2334dabe29b70bd99dc8ae926ee44184c0de64a53d415a4f3/aiohttp-3.14.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1eb8167961ec4dfcc8cb9dd50bd0ee72519f7ef496be95203e49e27b01618382", size = 1882422, upload-time = "2026-10-11T01:01:59.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/f7/7d5c91bb9620db300c8ddb05337a9014301acb626223faff3abcb8ea47d7/aiohttp-3.14.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c1d60eafd9c7e8e74abd03a5b00df44e7febfe6d9b89b559c0a6551eef0699d4", size = 1843621, upload-time = "2026-10-11T01:02:01.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/0a/b208953b96d8f24b75f6da704f508e6c5cf3022f52b60c61933df082e89c/aiohttp-3.14.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:137351bf20bbed9a65e839f4a4452ac377389bdb2f2857d2acffef38f5e9f2d1", size = 1927915, upload-time = "2026-10-11T01:02:03.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/79/90ebcccb55e2d1e11a1fed581d83bb966e38fb35fb4b2577fdc980f8707a/aiohttp-3.14.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fba47bc2c3d7303c3d027c6cf4d07626c37b1314ac81f5820c31032e0ca1f677", size = 2036339, upload-time = "2026-10-11T01:02:06.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/66/55a8904b3a129fafdf94f9cc0a2e4ca09a3c914650be52355db7ad0bbdb6/aiohttp-3.14.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94684b879ac1d71e4238850c99b62dc1b28d9086b156a2555f082010b85a865c", size = 1888041, upload-time = "2026-10-11T01:02:08.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b8/96b25da7329a52e42c812b1e8b076386039ec4fc312afa043d173d8147fc/aiohttp-3.14.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56572c42e3ecd636de8d2c3dd54cf5fc939cb5c32eb56297f176a0d366fac622", size = 1703849, upload-time = "2026-10-11T01:02:10.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/43/fbf976e3ae4c038d6f5c84945ab2150298c2d71201674d4e53d158063e75/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a95529a92a446db351675f4aab518feaf5e99842f63f5dd17160c2b74f382db3", size = 1849703, upload-time = "2026-10-11T01:02:13.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/7d/218e912f4c1d89bde7ac551409be57ad2f6121638e56942d395a7cb1fa58/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:3edbece0379b8b4aaa67619b8aa2399bb66fce372cd5911098a434ea77220aa0", size = 1852695, upload-time = "2026-10-11T01:02:15.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/42/252a1b9287e3b6e393a1c3bd1776f36af30f5f25f071bbf2b0cb7eba9116/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:56d9828f204331a5ca8850fcfe2bcce95a149f1f223f60cc7216e5524978e480", size = 1893493, upload-time = "2026-10-11T01:02:17.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/97/71cae83d5100556fad1521684f7cd1e3e578432644f850245ed3bd969310/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:20064a177a070d789ee64a50b01a9161d3468e989baacfc6c714aa685c4b332f", size = 1693468, upload-time = "2026-10-11T01:02:20.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/69/73d88e97a8b5f0ca7a946d0011c0de99fb188b1687c7948ecd0553dc5bf0/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:81c2b3dfd56c62bee6108e4852d5970b4cf9086390b6983f52b666e878c1f115", size = 1915398, upload-time = "2026-10-11T01:02:23.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/f0/881644bcb15d4b258daea9b720a0af9dc4330496cc8d6ade9090cdd0cffc/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:09ec102b4b8c9a920275733bbc11fdbb615efe6f9231a06007c0218d336fb77a", size = 1871470, upload-time = "2026-10-11T01:02:25.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/de/19d9ebbcce5aedaa3242d8a99ff8816a60bdb7629fb0084bf4a45bfd1f62/aiohttp-3.14.5-cp314-cp314-win32.whl", hash = "sha256:9c428eb2bd8817588d16a0ab898aa4eb5d141f896aa2b394cc79a4cf61d9a8e2", size = 485517, upload-time = "2026-10-11T01:02:27.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/74/8cdaf0e58c2588371670d5a9a8215bbb971d36940b6e5d051967dd05c07d/aiohttp-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:6f967dde489ca6a8c02d093ab245d2cbf50ccb5c36adf0188b17b0ca39d24b67", size = 511755, upload-time = "2026-10-11T01:02:29.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/6b/e0100e25502430a531c7cf1482a378d0b65bf728ab60c01ee270e56bc469/aiohttp-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:1d2d981b53dd09a319e3570ef8cc3bbc3ef86f5a7abef0f6b2bff3867db3a9e7", size = 495151, upload-time = "2026-10-11T01:02:32.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/c2/ca2ead7b655688c53c03aeeb6e96e6851c9ff08d6be7b13802f53a6ae8fd/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9ce66feae6ac65327379460380549bf1b8df8e17c4e25df2a2bcf168272e3bed", size = 855396, upload-time = "2026-10-11T01:02:34.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/70/22206fea409255a240c926ce11de48de354ae2bb90ca44f709c05497585d/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:27c2322e03f66101acb09869ce1cf1efc04994ee95e1735b69827bf8c8b9d781", size = 567734, upload-time = "2026-10-11T01:02:36.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/e5/79a36c118308b56f8667d67e05d2fb6dc638ab45985704cdb199631bedaa/aiohttp-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff75a7537413a86e7cafe98e0e1d6e3dc4b15c6349896e7d5c6b881bfdb6d550", size = 567379, upload-time = "2026-10-11T01:02:38.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/a3/2ebec7dece3b1f02c30d2e484647f6f7b13952b1bb40a4cb285b849e8432/aiohttp-3.14.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c061aa954daaf57d2a4b8374f9fca621ef0e1b603584431c220c22458c59b6d", size = 2050686, upload-time = "2026-10-11T01:02:41.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/d2/7e4d093db2f4450482652e7ef19a9e19919028f5135aa52bc4078c3beb80/aiohttp-3.14.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1612fa5857b37bf32e5c1eaeefb96e3b01e9c70679eec81f0934e8a600080863", size = 1915077, upload-time = "2026-10-11T01:02:43.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/88/bd40d09958442a0a1df67da81de496361d6e2afc04f9db2950d835da750b/aiohttp-3.14.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adbeee7d6fd4cf5fe0aece2fb3edc4243615d3180430ba8149d01a90670cac99", size = 2027204, upload-time = "2026-10-11T01:02:46.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/eb/3a601c1f8d3103c1a60ea981f20924855da9f575d2007fc38f8898b792fb/aiohttp-3.14.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2966998927d7bed9db12c0a4647b0c7b179755878fc9c357fe1ffd3e3b0c1a5", size = 2149520, upload-time = "2026-10-11T01:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/d0/4e41bfe1b1ce1cb6f6d2e59fa7a88ef5cf92c402b2d07e9018778ba9edbc/aiohttp-3.14.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e317e0fb6b16212c881d2205a7d87414c29acd69320b3aa6dce9d9c7b86fe4f", size = 2002039, upload-time = "2026-10-11T01:02:51.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/5e/72067019545c502b881b031153c437752ecef48d7d213bc0218ccebb4bfb/aiohttp-3.14.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50343c1757b4b6f6708eeaf24534b32f19dfb99fb1b762c00420867a62fc81e0", size = 1786164, upload-time = "2026-10-11T01:02:53.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/fe/7741efd6119bfb7a00827fe6f7b84b4409de58d888ae21adc5a9a6824992/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:083673c7a94c3ea035caaa5ca04288bdb44887abfe1f5ba23294e6a4b03efd2d", size = 1970689, upload-time = "2026-10-11T01:02:55.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e7/342a13bf67f34d269bf2f7e870ecd72b99c832cc6f0a271a9210c0ebfb84/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2528cb4c6b92008c76ac9ac6298624069bb2db91ff4929905512d1d84485f658", size = 1951967, upload-time = "2026-10-11T01:02:58.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/d4/fdb3b27340617e5e64df18a70fa89097778652ea5c7c7e2f79def76999c3/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b1b8ece1e71132d2afba4dbc0c3d62c766e25165990b25db1196c04969eb3d84", size = 1968511, upload-time = "2026-10-11T01:03:00.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/b2/e8f88298de78d1a951f36f9f966d38ec6ed1d4721303ba02064a545e8aa6/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fce9523df31cea6284f3e2c479876750d7687cf671d7b25d32b19effc0e86441", size = 1778497, upload-time = "2026-10-11T01:03:03.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/68/9ccdb93d664345c546be7f34480b921774d8c0d98f47e70d7e03b115d475/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:09e0eb18c7e0c8777e2f9149de63799195b9b3ca1b5c81ba6f32f2c6b8628210", size = 1999676, upload-time = "2026-10-11T01:03:05.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/4a/a33cfa6dcb00e94194ae4fe710432ca4ed111e016356b4ba4d2f4c3a124c/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6774814fd5c338e72ee0da5cbb9432816df450e69c019f72b5d29bdec2a1792d", size = 1963437, upload-time = "2026-10-11T01:03:08.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/20/eacbecfea3b5c3dcbfc9b023e3a5460f43e5dda16d06a2877ebe7184c3f3/aiohttp-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:33f706574e32c6e694f352a856e05caf18f7f2c871b3e87b41c55ea452b409ab", size = 503973, upload-time = "2026-10-11T01:03:10.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/78/18eec294f6c8c5dc845dcf6d730a0147d8d0f17e86138a7bdb85e43a30fa/aiohttp-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5ba14a839fbe87cf7c12a6b5661c05f324a296eb8363141edb3944ba63d4c9d3", size = 531190, upload-time = "2026-10-11T01:03:12.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/39/e53f8169acc85271ebd12b5b32ad7f1541b35639ccbe0f49034c64785d10/aiohttp-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:1061b364556e8172e8d46b0b183adeeb73e8c42d30ebc745591e1bd89acad52e", size = 510360, upload-time = "2026-10-11T01:03:15.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3478,7 +3479,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.46.0"
|
||||
version = "0.47.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "c2pa-python" },
|
||||
|
||||
Reference in new issue
Block a user