Compare commits

...
Author SHA1 Message Date
Victor KuznetsovandClaude Opus 5.5 4f107b8334 Update awesome-list PR statuses in the agent skill guide
VoltAgent and hashgraph lines predated both submissions; record PR 1225
(open), PR 541 (merged), the punkpeye MCP list PR 16924, the heilcheng
on-hold label and a 2026-10-19 recheck of the Composio PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:43:17 -07:00
Victor Kuznetsov f6b7c366e8 Merge pull request #126 from wiltodelta/feat/watermark-library-followups
Fail closed on unreadable inputs, empty batches and failed strips
2026-10-19 12:32:39 -07:00
Victor Kuznetsov 30423aca5e Merge pull request #125 from wiltodelta/fix/isobmff-strip-keeps-offsets
Blank ISOBMFF provenance boxes in place so HEIF iloc offsets stay valid
2026-10-19 12:16:14 -07:00
Victor KuznetsovandClaude Opus 5.5 ace71128b9 Bump aiohttp to 3.14.5 for GHSA-5wr8-gcqf-gqf3, GHSA-2g87-pp6c-29x5 and GHSA-494j-54xm-rwp3
The CI lockfile scan failed on all three advisories against 3.14.3; each is
fixed in 3.14.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:16:10 -07:00
Victor KuznetsovandClaude Opus 5.5 de6fe3d549 Bump aiohttp to 3.14.5 for GHSA-5wr8-gcqf-gqf3, GHSA-2g87-pp6c-29x5 and GHSA-494j-54xm-rwp3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:58:05 -07:00
Victor KuznetsovandClaude Opus 5.5 7e167c3c76 Fail closed on unreadable inputs, empty batches and failed strips
The metadata scanners read raw bytes, so random bytes, an empty file or a
truncated PNG or MP4 printed the same "No AI metadata found" or "AI metadata
stripped" a real file gets, with exit 0. `metadata.unreadable_reason` is now
the one readability probe: `identify`, `metadata` and every `video` command
exit 1 on such input, and `batch` and `video batch` fail its row.
`strip_and_verify` refuses it before writing anything.

A `batch` or `video batch` directory with no supported file exits 2 and
creates no output directory. `visible` and `erase` strip and verify on a
staged sibling and publish nothing when a marker survives, as the library
`remove_visible` now does; the diffusion writer raises on a surviving
marker. An invisible engine that cannot start exits 1 without a traceback,
and the MCP server reports a child that could not start.

NotebookLM and Gemini slide-deck templates are recorded as not planned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:59 -07:00
Victor KuznetsovandClaude Opus 5.5 7e3563b879 Blank ISOBMFF provenance boxes in place so HEIF iloc offsets stay valid
strip_c2pa_boxes dropped a top-level C2PA uuid, AI-label XMP uuid or jumb
box. When that box precedes mdat, dropping it moves the coded data while
HEIF iloc and MP4 stco/co64 still hold the old absolute offsets, so a phone
HEIC laid out as ftyp, uuid (C2PA), mdat, meta came out undecodable.
Turn the box into a same-size free box with a zeroed payload instead, as
strip_isobmff_media_file already does for MP4/MOV.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:08:39 -07:00
Victor Kuznetsov 76462f9916 Drive login-free web oracles through Playwright with a fresh proxy session
Gemini stays on the existing Chrome account. SynthID Detector starts as an
anonymous Playwright page and continues in that Chrome only when the page
asks for sign-in. Each ThorData run picks a new Europe or US session.
2026-10-18 17:44:21 -07:00
Victor KuznetsovandClaude Opus 5.5 3818f40096 CLAUDE.md: document the C2PA soft-binding snapshot check in the gate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:44:21 -07:00
Victor Kuznetsov 577d1bd953 Merge pull request #124 from wiltodelta/docs/glama-badge
Add the Glama MCP server badge to the README
2026-10-18 17:31:21 -07:00
Victor KuznetsovandClaude Opus 5.5 7376d278fd Add the Glama MCP server badge to the README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:16:11 -07:00
Victor KuznetsovandClaude Opus 5.5 33a0e6c987 CLAUDE.md: document the C2PA soft-binding snapshot check in the gate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:12:51 -07:00
Victor KuznetsovandClaude Opus 5.5 521831b972 Scope out plan-removable branding and LLM text watermarks
A visible mark is in scope when its vendor describes it as identifying
AI-generated content, whichever plan turns it off: the Gemini sparkle,
or the visible watermark Gemini Notebook (formerly NotebookLM) adds to
generated images and videos. Product branding that a paid plan of the
same product removes, such as "Made with Gamma", is out of scope.

Watermarks in LLM-generated text are out of scope: the Claude and
OpenAI marks live in word choice with no hidden characters, only a
substantial rewrite removes them, and their detectors are not public.

The skill states both boundaries, including the vendor-declared
exception so the Gemini sparkle is never refused, and moves to 1.0.13.
The Codex plugin version is now pinned to the Claude plugin's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:33:51 -07:00
Victor KuznetsovandClaude Opus 5.5 66d793d1a6 State why no aggregate oracle pass rate is published
Tier D of the verification plan now says the project publishes no
pass rate per model or resolution: a sample that large needs bulk
automated checks, which OpenAI's API terms and the SynthID Detector's
additional terms (synthid.com/terms, read 2026-10-17) both forbid.
Removal claims rest on the method and on individual hash-bound rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:49:16 -07:00
Victor KuznetsovandClaude Opus 5.5 f0b2c2406e Publish to the MCP registry from CI with GitHub OIDC
publish-mcp-registry.yml lists server.json in the official MCP registry
on every published release, and on dispatch for a version already on
PyPI. GitHub OIDC proves the io.github.wiltodelta namespace, so no login
or stored secret is involved. It skips a version the registry already
lists, checks out only server.json at the release tag, requires both of
its versions to equal the release, waits for PyPI to serve it, runs a
pinned, checksum-verified mcp-publisher, and reads the entry back.

Tests run the lookup and manifest steps against stubbed replies; every
targeted mutation fails them. docs/development.md records a stale
worktree venv that mixes OpenCV 4.x and 5.x into one cv2 package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:14:55 -07:00
Victor KuznetsovandClaude Opus 5.5 5677c22a6b Release 0.46.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:31:59 -07:00
Victor KuznetsovandClaude Opus 5.5 a4eb16ad77 Build the expected MCP output path with the platform separator
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:27:35 -07:00
Victor KuznetsovandClaude Opus 5.5 06c0cb1bbe Add Codex plugin manifest, stdlib MCP server, SHA-pinned Actions
- .codex-plugin/plugin.json and assets/icon.svg package the existing skill
  tree for Codex plugin catalogs; skill version 1.0.12 across all manifests
- `remove-ai-watermarks mcp` serves identify, remove and batch over stdio,
  one CLI command per tool call, standard library only; server.json and
  glama.json for the MCP registry and Glama
- Pin every GitHub Action to a commit SHA
- Call module.train(False) instead of module.eval(), which plugin scanners
  read as dynamic code execution

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:13:04 -07:00
Victor Kuznetsov db006f034f Merge pull request #123 from wiltodelta/docs/readme-first-screen
Move the before/after demo and install lines to the README first screen
2026-10-17 12:55:01 -07:00
Victor KuznetsovandClaude Opus 5.5 604a8a8f52 Move the before/after demo and install lines to the README first screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:30:46 -07:00
Victor KuznetsovandClaude Opus 5.5 19ae5d1b0a Strip app-export and Kling SEI provenance from MP4, refresh watermark docs
video metadata --remove now blanks app-export JSON in keyed and keyless
metadata items (Dreamina writes it twice), which it previously left in
place while exiting 1.

Kling writes an H.264 user_data_unregistered SEI message (a fixed UUID
plus "kling-ai") into the first video sample. get_ai_metadata reports
it as kling_sei, identify_video names Kling from it when no TC260 label
does, and the streaming stripper blanks every copy inside mdat during
its existing copy pass. Lengths stay, the SEI stays well-formed, and
ffmpeg framemd5 hashes of every frame match before and after on both
tracked Kling fixtures. Across 255 local files only the seven Kling
videos changed verdict.

invisible now exits 1 on an undecodable source instead of reporting the
no-signal code for a file it never read.

Docs: a per-command "what each path leaves behind" table, the October
literature sweep, Suno's announced audio watermark, Anthropic's
SynthID-Text-based Claude watermark, and an independent TrustMark
false-positive benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:30:01 -07:00
Victor Kuznetsov 21f4621eb7 Add SynthID Detector surface, drop extra web confirmation 2026-10-17 11:47:53 -07:00
Victor Kuznetsov f1d4d4afba Merge pull request #121 from dukagjina/fix/preserve-invisible-transparency
Preserve alpha in invisible image processing
2026-10-16 18:06:56 -07:00
Victor Kuznetsov 2430689f96 Merge branch 'main' into fix/preserve-invisible-transparency 2026-10-16 12:36:43 -07:00
Victor KuznetsovandClaude Opus 5.5 cf695de57f Refresh the C2PA soft-binding snapshot and fix Ruff ICN001
The upstream registry gained be.immostory.videoseal.1 at revision d5960248,
so `sync_c2pa_soft_bindings.py --check` failed lint on main and on every pull
request. That step runs ahead of Ruff, which hid an ICN001 error in
scripts/_corpus_scan.py; import `date` directly, as the sibling scripts do.
Document the check's live-upstream behavior next to the refresh command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:15:03 -07:00
dukagjina a054185fa5 Preserve source transparency during invisible image processing 2026-10-16 02:04:54 -04:00
Victor Kuznetsov 3e20bb0b92 Merge pull request #120 from wiltodelta/dependabot/uv/minor-and-patch-7fed723961 2026-10-14 22:27:16 -07:00
dependabot[bot] 0b4170ae98 Bump the minor-and-patch group with 6 updates
Bumps the minor-and-patch group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [pillow-heif](https://github.com/bigcat88/pillow_heif) | `1.8.0` | `1.8.1` |
| [diffusers](https://github.com/huggingface/diffusers) | `0.40.0` | `0.41.0` |
| [diffsynth](https://github.com/modelscope/DiffSynth-Studio) | `2.1.8` | `2.1.9` |
| [onnxruntime](https://github.com/microsoft/onnxruntime) | `1.30.0` | `1.31.0` |
| [openai](https://github.com/openai/openai-python) | `3.24.0` | `3.29.0` |
| [ruff](https://github.com/astral-sh/ruff) | `0.16.10` | `0.17.0` |


Updates `pillow-heif` from 1.8.0 to 1.8.1
- [Release notes](https://github.com/bigcat88/pillow_heif/releases)
- [Changelog](https://github.com/bigcat88/pillow_heif/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bigcat88/pillow_heif/compare/v1.8.0...v1.8.1)

Updates `diffusers` from 0.40.0 to 0.41.0
- [Release notes](https://github.com/huggingface/diffusers/releases)
- [Commits](https://github.com/huggingface/diffusers/compare/v0.40.0...v0.41.0)

Updates `diffsynth` from 2.1.8 to 2.1.9
- [Release notes](https://github.com/modelscope/DiffSynth-Studio/releases)
- [Commits](https://github.com/modelscope/DiffSynth-Studio/commits)

Updates `onnxruntime` from 1.30.0 to 1.31.0
- [Release notes](https://github.com/microsoft/onnxruntime/releases)
- [Changelog](https://github.com/microsoft/onnxruntime/blob/main/docs/ReleaseNotesWorkflow.md)
- [Commits](https://github.com/microsoft/onnxruntime/compare/v1.30.0...v1.31.0)

Updates `openai` from 3.24.0 to 3.29.0
- [Release notes](https://github.com/openai/openai-python/releases)
- [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-python/compare/v3.24.0...v3.29.0)

Updates `ruff` from 0.16.10 to 0.17.0
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.16.10...0.17.0)

---
updated-dependencies:
- dependency-name: pillow-heif
  dependency-version: 1.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: diffusers
  dependency-version: 0.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: diffsynth
  dependency-version: 2.1.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: onnxruntime
  dependency-version: 1.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: openai
  dependency-version: 3.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-14 20:05:51 +00:00
84 changed files with 3627 additions and 692 deletions

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Agent skill and Claude Code plugin for the remove-ai-watermarks CLI.",
"version": "1.0.11"
"version": "1.0.13"
},
"plugins": [
{
+43 -23
View File
@@ -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 bounded provider check may continue through its finite set of authorized
account slots after a refusal; do not add new logins, IP rotation, network-route
failover, unbounded retries, or cross-session result merging. Never commit the local
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,35 +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. Web remains an explicit second surface, never an automatic retry after an API
refusal, quota response, transport error, or indeterminate verdict.
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 and explicit `/u/N/` account index. OpenAI, Microsoft, and Meta Web automation
uses isolated Playwright contexts; it must never attach to or copy state from the user's
real browser. Proxy credentials are read only through a slot's named environment variable
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.
@@ -89,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
+109 -64
View File
@@ -1,52 +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, a new network route, or another pass after the plan
is exhausted require authorization unless already requested. Account
continuation does not authorize changing candidate pixels or erasing failures.
## Default surface priority
Prefer an official API adapter whenever that provider, media type, and configured
slot support it. Use Web only when no usable API route exists or the operator
explicitly requests the Web surface as a second check. For OpenAI and Microsoft,
the default plans are API then Web. Google and Meta currently have only usable
Web routes in this tooling. Inspect the order with `plan PROVIDER`.
The priority is a selection policy, not automatic failover. An API refusal,
quota response, transport error, or indeterminate verdict must be recorded and
reported. Do not submit the same artifact to Web unless the operator separately
authorizes that next surface.
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
@@ -67,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
@@ -82,13 +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.
### OpenAI, Microsoft, and Meta Web
### SynthID Detector
Use the isolated Playwright runner, never the user's real Chrome:
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 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. 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, Meta, and SynthID Detector Web
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 \
@@ -99,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
@@ -144,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
+10 -4
View File
@@ -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.
+39
View File
@@ -0,0 +1,39 @@
{
"name": "remove-ai-watermarks",
"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",
"url": "https://github.com/wiltodelta"
},
"homepage": "https://raiw.cc",
"repository": "https://github.com/wiltodelta/remove-ai-watermarks",
"license": "Apache-2.0",
"keywords": [
"ai-watermark",
"watermark-removal",
"c2pa",
"synthid",
"provenance",
"gemini",
"sora"
],
"skills": "./skills/",
"interface": {
"displayName": "Remove AI Watermarks",
"shortDescription": "Identify and remove AI watermarks and provenance metadata",
"longDescription": "Runs the open-source remove-ai-watermarks CLI locally to identify C2PA, IPTC, EXIF and TC260 provenance, visible AI-generator labels and supported invisible watermarks, then removes them from images and video the user generated or edited. It does not remove stock, agency or other third-party marks.",
"developerName": "wiltodelta",
"category": "Productivity",
"capabilities": ["Read", "Write"],
"websiteURL": "https://github.com/wiltodelta/remove-ai-watermarks",
"defaultPrompt": [
"Check this image for AI watermarks and provenance metadata",
"Remove the AI metadata from my generated video without re-encoding it",
"Remove the visible AI label from the corner of my image"
],
"brandColor": "#A78BFA",
"composerIcon": "./assets/icon.svg",
"logo": "./assets/icon.svg"
}
}
+15
View File
@@ -0,0 +1,15 @@
# Local state and secrets never belong in a plugin bundle.
.env
.env.*
!.env.example
.oracle-slots.json
.local-eval/
.venv/
__pycache__/
*.pyc
# Generated media and model artifacts.
data/
*.mp4
*.onnx
*.safetensors
*.pt
+3 -3
View File
@@ -62,7 +62,7 @@ jobs:
PYTHON
- name: Upload distributed release identity
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: distributed-release-${{ github.run_attempt }}
path: release-identity.json
@@ -104,7 +104,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the tap
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: wiltodelta/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
@@ -227,7 +227,7 @@ jobs:
needs: resolve
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Publish the agent skill when its version moved
env:
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
@@ -21,8 +21,8 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
python-version: "3.12"
- name: Download derivative weights
+142
View File
@@ -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
@@ -36,9 +36,9 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@v7
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
python-version: "3.12"
@@ -22,9 +22,9 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@v7
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
python-version: "3.12"
+2 -2
View File
@@ -15,9 +15,9 @@ jobs:
runs-on: ubuntu-latest
environment: pypi
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@v7
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Verify release tag matches package version
run: |
+2 -2
View File
@@ -32,8 +32,8 @@ jobs:
skill:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Sync dev environment
run: uv sync --frozen --extra dev
- name: Agent skill packaging tests
+10 -10
View File
@@ -16,8 +16,8 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Sync dev environment
run: uv sync --frozen --extra dev
- name: Check C2PA soft-binding registry snapshot
@@ -34,8 +34,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: false
# Policy and triaged blocks: docs/development.md "Known security-gate blocks".
@@ -69,8 +69,8 @@ jobs:
- os: windows-latest
python-version: "3.14"
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
python-version: ${{ matrix.python-version }}
- name: Sync dev environment
@@ -82,8 +82,8 @@ jobs:
name: versioned transport contracts py3.14
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
python-version: "3.14"
- name: Create clean runtime contract environment
@@ -104,8 +104,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
python-version: "3.12"
- name: Install ffmpeg
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Download distributed release identity
if: ${{ github.event_name == 'workflow_run' }}
uses: actions/download-artifact@v8
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: distributed-release-*
path: distribution-identities
+6 -3
View File
@@ -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,14 +33,16 @@ 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).
The same skill tree is the Codex plugin (`.codex-plugin/plugin.json`). `remove-ai-watermarks mcp` is a standard-library MCP server that runs one CLI command per tool call; keep it dependency-free, because the MCP registry launches it as a bare `uvx remove-ai-watermarks mcp` that cannot add extras ([`docs/mcp-server.md`](docs/mcp-server.md)).
## Module architecture
[`docs/module-internals.md`](docs/module-internals.md) is the canonical per-module map, including design decisions, thresholds, calibration history, incident records, and regression guards. Read the relevant section before changing a subsystem.
+28 -5
View File
@@ -6,6 +6,15 @@ Remove AI provenance marks from images and video you generated yourself:
- invisible pixel watermarks through direct local-format disruption or diffusion regeneration;
- C2PA, EXIF, XMP, IPTC, and related AI metadata.
| Before | After |
| --- | --- |
| ![Image with a visible Gemini watermark](demo_banana_before.png) | ![Image after visible watermark removal](demo_banana_after.png) |
```bash
uv tool install "remove-ai-watermarks[visible]" # CLI for visible marks and metadata
npx skills add wiltodelta/remove-ai-watermarks # agent skill for Claude Code, Codex, Cursor, and other agents
```
Video support covers provenance identification, complete visible-plus-metadata
cleaning, directory batches, visible Sora, Veo, Seedance, Doubao, Dola,
Hailuo AI, and Kling AI mark removal, and oracle-certified VAE regeneration for video SynthID
@@ -23,6 +32,7 @@ removal.
[![Tests](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml/badge.svg)](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml)
[![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-db61a2?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/wiltodelta)
[![skills.sh](https://skills.sh/b/wiltodelta/remove-ai-watermarks)](https://skills.sh/wiltodelta/remove-ai-watermarks)
[![Glama MCP server](https://glama.ai/mcp/servers/wiltodelta/remove-ai-watermarks/badges/score.svg)](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
@@ -274,11 +284,8 @@ features, and development setup.
### Visible Gemini mark
| Before | After |
| --- | --- |
| ![Image with a visible Gemini watermark](demo_banana_before.png) | ![Image after visible watermark removal](demo_banana_after.png) |
The `after` raster is generated from the tracked `before` raster by the public path:
The `after` raster at the top of this page is generated from the tracked `before`
raster by the public path:
```bash
uv run remove-ai-watermarks visible demo_banana_before.png \
@@ -476,9 +483,23 @@ Claude Code can add this repository as a plugin marketplace:
/plugin install remove-ai-watermarks@remove-ai-watermarks
```
Codex reads the same skill through [`.codex-plugin/plugin.json`](.codex-plugin/plugin.json).
The skill refuses stock-agency and other third-party paid-asset marks. See
[the agent skill guide](docs/agent-skill.md).
## MCP server
`remove-ai-watermarks mcp` serves `identify`, `remove` and `batch` as Model
Context Protocol tools over stdio, each a thin wrapper around one CLI command:
```json
{"mcpServers": {"remove-ai-watermarks": {"command": "uvx", "args": ["remove-ai-watermarks", "mcp"]}}}
```
See [the MCP server guide](docs/mcp-server.md).
<!-- mcp-name: io.github.wiltodelta/remove-ai-watermarks -->
## ComfyUI
The separate
@@ -490,6 +511,8 @@ invisible removal.
- A missing local signal means unknown, not clean. Proprietary pixel
watermarks may remain after metadata has been stripped.
[What each path leaves behind](docs/known-limitations.md#what-each-path-leaves-behind)
lists, per command, what may remain and how to check it.
- Visible removal reconstructs a small region. Results depend on the background
and selected fill backend.
- Invisible removal changes the whole image and may alter faces, text, or fine
+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="512" height="512" fill="none" stroke="url(#lg)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!--
The raiw.cc brand mark: the Lucide "sparkle" and "x" glyphs.
Lucide, https://lucide.dev, ISC License, Copyright (c) Lucide Contributors.
Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.
-->
<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z" fill="url(#lg)" fill-opacity="0.15"/>
<g transform="translate(1 17) scale(0.3)" stroke-width="5">
<path d="M18 6 6 18"/>
<path d="m6 6 12 12"/>
</g>
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="24" y2="24">
<stop stop-color="#a78bfa"/>
<stop offset="1" stop-color="#06b6d4"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1 -1
View File
@@ -5,4 +5,4 @@ seedance,ByteDance Seedance,visible boxed AI label,data/fixtures/visible/seedanc
doubao,ByteDance Doubao,visible fixed text,data/fixtures/visible/doubao/example.mp4,partial,"synthetic detector fixture available; a real public carrier is retained locally but is not publication-cleared",repository
dola,ByteDance Dola,visible fixed text,data/fixtures/visible/dola/example.mp4,partial,synthetic detector fixture available; no redistributable provider original found,repository
hailuo,MiniMax Hailuo AI,visible composite label,data/fixtures/visible/hailuo/provider-original.mp4,available,"authenticated Hailuo AI MiniMax H3 original downloaded with With Watermark selected; detected by Hailuo engine on 124/124 frames; TC260 AIGC label names MiniMax",repository
kling,Kuaishou Kling AI,visible fixed logo and wordmark,data/fixtures/visible/kling/provider-original.mp4,available,"authenticated Kling AI VIDEO 3.0 original; detected by Kling engine on 121/121 frames; no locally readable provenance metadata",Kling AI terms; noncommercial fixture
kling,Kuaishou Kling AI,visible fixed logo and wordmark,data/fixtures/visible/kling/provider-original.mp4,available,"authenticated Kling AI VIDEO 3.0 original; detected by Kling engine on 121/121 frames; no container provenance metadata, but the Kling H.264 SEI tag (kling_sei) is readable",Kling AI terms; noncommercial fixture
1 key provider watermark_type path status verification license
5 doubao ByteDance Doubao visible fixed text data/fixtures/visible/doubao/example.mp4 partial synthetic detector fixture available; a real public carrier is retained locally but is not publication-cleared repository
6 dola ByteDance Dola visible fixed text data/fixtures/visible/dola/example.mp4 partial synthetic detector fixture available; no redistributable provider original found repository
7 hailuo MiniMax Hailuo AI visible composite label data/fixtures/visible/hailuo/provider-original.mp4 available authenticated Hailuo AI MiniMax H3 original downloaded with With Watermark selected; detected by Hailuo engine on 124/124 frames; TC260 AIGC label names MiniMax repository
8 kling Kuaishou Kling AI visible fixed logo and wordmark data/fixtures/visible/kling/provider-original.mp4 available authenticated Kling AI VIDEO 3.0 original; detected by Kling engine on 121/121 frames; no locally readable provenance metadata authenticated Kling AI VIDEO 3.0 original; detected by Kling engine on 121/121 frames; no container provenance metadata, but the Kling H.264 SEI tag (kling_sei) is readable Kling AI terms; noncommercial fixture
+28 -11
View File
@@ -55,21 +55,38 @@ Do these in order after the skill is on the default branch.
today. One accurate PR per list, with the scope sentence intact.
- `ComposioHQ/awesome-claude-skills`: submitted 2026-09-05 as PR 1841
(Creative & Media); no maturity bar in its contribution rules. Rechecked
2026-09-11: still open, CI green, no review comments, waiting on the
list maintainers.
2026-10-19: still open, CI green, labelled `ready-to-merge`, no maintainer
comment since the 2026-09-11 check-in.
- `heilcheng/awesome-agent-skills`: submitted 2026-09-09 as PR 483 under
Community Skills > Media. The skill's three concrete input/output examples
satisfy the list's working-example requirement. Rechecked 2026-09-11:
still open; the only check failure is their Vercel deploy authorization,
which a contributor cannot grant.
- `VoltAgent/awesome-agent-skills`: DEFERRED. Its CONTRIBUTING rejects
brand-new skills ("give your skill time to mature and gain users before
submitting"). The skill now has real installs and shipped releases, but
adoption remains too small to make the list's maturity claim comfortably.
satisfy the list's working-example requirement. On 2026-10-15 the
maintainer labelled it `on hold`: "Tool strips AI provenance
(SynthID/C2PA); policy call for maintainer". The only check failure is
their Vercel deploy authorization, which a contributor cannot grant.
- `VoltAgent/awesome-agent-skills`: submitted 2026-10-17 as PR 1225, a
one-line entry. Its CONTRIBUTING requires "real community usage" ("Give
your skill time to mature and gain users before submitting"). Rechecked
2026-10-19: open, no review yet.
- `VoltAgent/awesome-openclaw-skills`: DEFERRED on adoption only. Entries
are indexed via clawskills.sh, and the ClawHub prerequisite in step 6 is
now met. The list still requires proven real-world usage. Re-attempt once
the ClawHub listing shows organic installs.
- `hashgraph-online/awesome-codex-plugins`: submitted 2026-10-18 as PR 541
and merged 2026-10-19, so the entry is live. It lists
only Codex plugins, so it needs `.codex-plugin/plugin.json` and the icon in
`assets/icon.svg`. Its contribution CI runs `scripts/validate-plugin-pr.py`
against the repository's `HEAD.zip`, then the HOL plugin scanner with a
minimum score of 80 and no high finding; that scan job is
`continue-on-error`, so it reports rather than blocks. The scanner's `\beval\s*\(`
rule also matches PyTorch's `.eval()`, which is why the code calls
`.train(False)`. On 2026-10-17 `plugin-scanner` 3.49.0 scored the tree 86
with no high finding. The remaining loss is a tracked video over the
scanner's 16 MiB file budget. Recheck with
`uvx --from plugin-scanner==3.49.0 plugin-scanner scan . --min-score 80 --fail-on-severity high`.
- `punkpeye/awesome-mcp-servers`: submitted 2026-10-18 as PR 16924 for the
MCP server. The list requires a Glama listing and the Glama score badge;
its bot labelled the PR `has-glama`. Rechecked 2026-10-19: open, every
check green, no maintainer review yet.
5. **Claude community marketplace.** Form at
[platform.claude.com/plugins/submit](https://platform.claude.com/plugins/submit)
(individuals) or the claude.ai directory form (Team/Enterprise). Validate
@@ -191,8 +208,8 @@ that names a command, flag or value the previous release lacked. Left behind, it
CLI is current right before it types an option that build rejects.
Bump `metadata.version` in `SKILL.md` plus `version` in
`skills/.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` when
the skill instructions change. That version is independent of the PyPI package
`skills/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` and
`.codex-plugin/plugin.json` when the skill instructions change. That version is independent of the PyPI package
version.
## Behavior evals
+19 -2
View File
@@ -28,6 +28,7 @@ defaults. This page focuses on choosing the right command.
| `video invisible` and `video all --invisible` | `remove-ai-watermarks[video,diffusion]` plus ffmpeg on PATH |
| HEIC/HEIF/AVIF pixel input | Add `remove-ai-watermarks[heif]` |
| Every production command and Python-compatible backend | `remove-ai-watermarks[all]` |
| `mcp` | Default package; each tool needs the extra of the command it runs |
`batch` requires the same extra as its selected mode. Extras can be combined in
one installation, for example `remove-ai-watermarks[visible,detect,heif]`.
@@ -469,6 +470,11 @@ detected. Use `--force` when you know the image should be processed:
remove-ai-watermarks invisible image.png -o clean.png --force
```
For transparent inputs, use PNG or WebP output to retain transparency. The engine
preserves the original alpha channel, including partially transparent edges,
while regenerating RGB pixels. JPEG output cannot preserve transparency. This
also applies to `batch --mode invisible`.
### Choose a strength cohort
`--vendor` selects the cohort the default strength resolves from. `auto`
@@ -685,12 +691,23 @@ For `visible` and `all` modes, the final summary includes counts for
validation results are printed explicitly, but the once-filled outputs are kept
and the batch does not retry them automatically.
## Serve the CLI to agent clients
```bash
remove-ai-watermarks mcp
```
Runs a Model Context Protocol server on stdin and stdout whose `identify`,
`remove` and `batch` tools each run one command of this CLI. Client setup and
the tool contract are in the [MCP server guide](mcp-server.md).
## Exit behavior
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
View File
@@ -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.
+2 -1
View File
@@ -27,7 +27,8 @@ to run the tool. Use the maintainer references only when changing the code.
| [Code provenance](code-provenance.md) | Required notices for licensed derivative work. |
| [Verification plan](verification-plan.md) | Verification methods, completed measurements, and remaining validation gaps. |
| [Release and distribution](release-and-distribution.md) | PyPI, Homebrew, Hugging Face Space, photo-classify Hub model, ComfyUI Registry, and the release workflow. |
| [Agent skill](agent-skill.md) | Install and maintain the published Agent Skill and Claude plugin. |
| [Agent skill](agent-skill.md) | Install and maintain the published Agent Skill, the Claude plugin and the Codex plugin. |
| [MCP server](mcp-server.md) | Connect an MCP client to `remove-ai-watermarks mcp`, or publish it to the MCP registry. |
| [Watermarking landscape](watermarking-landscape.md) | Vendor signals and detection approaches. |
| [SynthID technical reference](synthid.md) | Mechanism, provenance, robustness, regeneration. |
+21 -3
View File
@@ -4,6 +4,21 @@ This page describes current product limits. Historical measurements and
superseded experiments live in the research archive listed in
[the documentation index](index.md).
## What each path leaves behind
No command produces a "clean" verdict. Each one removes a specific layer, and
the remaining layers need their own check.
| Path | Removes | May remain | How to check what remains |
| --- | --- | --- | --- |
| `metadata --remove`, `video metadata --remove` | Supported AI metadata: C2PA manifests, TC260 labels, IPTC AI fields, app-export JSON, generation parameters | Every pixel and audio watermark (SynthID, Meta Content Seal, InvisMark, TrustMark, DWT-DCT). A C2PA soft binding can still lead a resolver to a manifest stored remotely. Strings inside encoded video streams, such as H.264 SEI messages, stay too, except Kling's SEI tag, which `video metadata --remove` blanks | `identify` re-reads the metadata. Only the provider's verifier can confirm a pixel watermark. The pixels are not regenerated, so an AI-image classifier scores the output the same as the input |
| `visible`, `erase`, `video visible` | The selected label region, refilled. AI metadata too, unless `--keep-metadata` is given | Any invisible watermark across the rest of the frame. The fill can leave a visible patch on structured backgrounds | Look at the output. Use `identify` for metadata and the provider's verifier for invisible marks |
| `invisible`, `all`, `video invisible` | Regenerates pixels to disrupt invisible watermarks. `all` also removes visible marks and metadata | No local decoder confirms that SynthID is gone from a given file, and the outcome depends on content and seed. `video invisible` copies the audio track unchanged, so an audio watermark survives | The provider's verifier, such as Google's SynthID Detector or OpenAI's provenance check. A pass there says nothing about AI-image classifiers |
| `identify`, `video identify` | Nothing | Not applicable | `unknown` means no supported local signal was found. It is not a clean verdict |
AI-image classifiers are a separate question from provenance. They score how
the pixels look rather than read a mark, and none of these paths targets them.
## Pixel classification
`classify` / `classify_pixels` is AI versus camera, not a universal AI detector.
@@ -263,7 +278,9 @@ guarantee, and check the audio separately when it matters. The question is no
longer Google-only: since 2026 OpenAI (ChatGPT and API audio) and ElevenLabs
also embed SynthID in generated audio, and Microsoft lists provenance for its
Azure speech and GPT audio models, so voice-over or music from those services
muxed into any video survives this path unchanged.
muxed into any video survives this path unchanged. Suno announced audio
watermarking and fingerprinting on 2026-08-06 without naming the technology, so
Suno music belongs on the same list.
The public video results make that boundary explicit without decoding audio:
`visual_invisible_action` records whether video pixels were regenerated, while
@@ -604,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
+15
View File
@@ -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.
+95
View File
@@ -0,0 +1,95 @@
# MCP server
`remove-ai-watermarks mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io/)
server on stdin and stdout, so an agent client can identify and clean the
user's own images and video without a shell. It is a thin wrapper: each tool
runs one command of this CLI in a child process and returns that command's exit
code, output path and text.
The subcommand ships since 0.46.0. The server uses only the Python standard
library, so the default package runs it. The commands behind a tool need the
same extras they need on the command line
([CLI guide](cli.md#command-dependency-map)). A missing extra comes back as a
tool error carrying the CLI's install hint.
## Client configuration
Most clients take a command and arguments. With [uv](https://docs.astral.sh/uv/):
```json
{
"mcpServers": {
"remove-ai-watermarks": {
"command": "uvx",
"args": ["remove-ai-watermarks", "mcp"]
}
}
}
```
To make visible-mark removal and pixel detectors available, name the extras:
```json
{
"mcpServers": {
"remove-ai-watermarks": {
"command": "uvx",
"args": ["--from", "remove-ai-watermarks[visible,video]", "remove-ai-watermarks", "mcp"]
}
}
}
```
An existing installation can be started directly as `remove-ai-watermarks mcp`.
Paths in tool calls are read and written on the machine that runs the server.
## Tools
| Tool | Runs | Writes |
| --- | --- | --- |
| `identify` | `identify --json`, or `video identify --json` for a video suffix; `check_visible: false` adds `--no-visible` | Nothing |
| `remove` | `metadata --remove`, `visible` or `all` for an image; `video metadata --remove`, `video visible` or `video all` for a video | One output file, `<name>_clean` beside the source unless `output` is given |
| `batch` | `batch --mode` for images or `video batch --mode` for video | A separate output directory, `<dir>_clean` unless `output_dir` is given |
`mode` is `metadata` (the default), `visible` or `all`. For an image, `all`
adds the invisible-watermark scrub where the installed extras and a CUDA GPU
allow it; `video all` removes visible marks and metadata only. The server never
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, 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.
## Scope
The server carries the project's intended-use boundary in its `instructions`:
it is for content the user generated or edited themselves, and not for stock,
agency, marketplace or other marks that protect a third party's asset. See
[legal and safety](legal-and-safety.md).
## Protocol
The server answers the initialize handshake for protocol versions 2025-11-25,
2025-06-18, 2025-03-26 and 2024-11-05, and lists its tools with input schemas
and annotations: `identify` is read-only, and `remove` and `batch` write new
files without destroying inputs. It does not implement `server/discover`. A
client that probes it first, such as the official Python SDK's `Client` in its
default mode, receives method-not-found and falls back to the handshake.
`tests/test_mcp_server.py` runs that client against the real subcommand.
## Registry listing
[`server.json`](../server.json) describes the PyPI package for the
[official MCP registry](https://registry.modelcontextprotocol.io/) under
`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. Each release is
published automatically; the workflow is described in
[release and distribution](release-and-distribution.md#mcp-registry).
+92 -4
View File
@@ -47,7 +47,31 @@ Important contracts:
`EXIT_NO_VISIBLE_MARK`.
- `invisible` writes no output when no supported local signal is found, unless
`--force` is supplied.
- The two no-signal conditions currently share exit code `2`.
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`, 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, and the diffusion
writer behind `invisible` raises when its own strip leaves a marker.
`_internal.utils.staged_sibling` is the shared stage: it never pre-creates the
name, so the published file keeps the default permissions.
- 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
@@ -236,10 +260,54 @@ payloads; TC260 removal changes the four-byte key to `free` and blanks only the
validated JSON value with same-length spaces. Keyed generation metadata such
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, and source-sized memory bound.
Publication is atomic, and a malformed top-level walk is copied unchanged. A
`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,
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
bytes while the stripper walked only TC260 and generation keys, so
`video metadata --remove` exited 1 with both markers still present on the
tracked Dreamina fixture (found 2026-10-17). Its `UserComment`
(`{"aigc_label_type":0,"source_info":"dreamina"}`) and `comment` (`vid:...`)
items carry no marker the scan counts, so they stay.
`test_no_flagged_metadata_value_survives_in_the_output_bytes` strips every
tracked MP4 fixture and searches the output byte for byte for each flagged
source value, after the pattern of byte-level leftover checks in public video
scrubbers. The walk reaches container metadata only. Strings inside the encoded
streams are out of its reach with one exception: x264 writes its version and
options as an H.264 SEI message, which is not AI provenance and survives the
stream copy; only a path that re-encodes the video stream (`video visible`,
`video invisible`) replaces it.
The exception is Kling's encoder tag. Every Kling video in the tracked fixtures
and local captures (Kling 2.6, 3.0, 3.0 Omni and O3, through Higgsfield, Runway
and Kling itself) carries one H.264 `user_data_unregistered` SEI message, payload
type 5, whose 25-byte payload is UUID `91ca6061-4aee-3854-8614-2d5f73f4ae2e`,
`kling-ai` and a NUL. `get_ai_metadata` reports it as `kling_sei` from the head
window, and `identify_video` names Kling from it when no TC260 label does, as on
`data/fixtures/visible/kling/provider-original.mp4`, which carries no other
signal. The streaming stripper finds every copy of the UUID plus `kling-ai` in
each top-level `mdat` during the copy pass it already makes, so the file is still
read once, and overwrites those 24 bytes with spaces. The NAL length,
SEI header and payload size stay, and a run of 0x20 cannot form an
emulation-prevention sequence, so the message stays well-formed and no decoder
reads it: `ffmpeg -f framemd5` hashes of every frame match before and after on
both tracked Kling fixtures (found and fixed 2026-10-17). The QuickTime `©cmt` item in
the Grok fixture (`Signature: <base64>`) also survives, because the xAI
signature rule requires the UUID half of the pair and that half is absent here.
[`_internal/ebml.py`](../src/remove_ai_watermarks/_internal/ebml.py) provides the
corresponding bounded Matroska/WebM reader. It seeks over clusters and accepts
only a `Segment.Tags.Tag.SimpleTag` pairing `TagName=AIGC` with a JSON
@@ -637,6 +705,10 @@ the official C2PA registry. Refresh it with
upstream schema invariants, records the exact source revision and writes a
deterministic module. Runtime inspection remains offline, and registry membership
is name-only evidence rather than proof that a compatible decoder ships.
`--check`, run by `maintain.sh` and the CI lint job ahead of Ruff, compares the
snapshot with the live upstream revision, so an upstream registry commit fails
lint on `main` and on every pull request until the snapshot is refreshed, and the
Ruff step behind it does not run until then.
The GitHub API revision lookup sends `GITHUB_TOKEN` (or `GH_TOKEN`) when set,
because the unauthenticated 60 requests/hour per IP limit is exhausted on
shared CI runners; `.env.example` documents the variable.
@@ -779,7 +851,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
@@ -1501,6 +1573,15 @@ schemas 0-2 count as positives. Schema 3 is below the precision threshold: all
same false payload after re-encoding. The official Adobe Variant P schema-1
fixture in `data/fixtures/provenance/` is the positive regression control.
An independent benchmark supports gating beyond a valid codeword. Kazempourradi
et al. ([arXiv:2610.04151](https://arxiv.org/abs/2610.04151), section 4.4)
found that the blind detectors of TrustMark B, C, P and Q in BCH_5 mode reported
a valid codeword on at least one of four decodes for 5.9% (P) to 15.3% (C) of 85
unmarked images, and on 3 of 8 images in a 16-bit HDR subset. Testing the same
decodes against the expected payload produced no false match. This detector is
blind and has no expected payload, so the re-encode stability and schema
restriction above stand in for that test.
#### Why the DWT-DCT decoder looks the way it does
Two things in `dwt_dct.py` are load-bearing and neither is obvious from the code.
@@ -2156,6 +2237,13 @@ image sizing, postprocessing, and the public engine
interface. It delegates model execution to
[`_internal/watermark_remover.py`](../src/remove_ai_watermarks/_internal/watermark_remover.py).
The engine preserves source transparency for alpha-capable outputs. It captures
alpha after EXIF orientation is applied and before resizing, then rejoins the
unchanged alpha plane after restoring the RGB result to the original dimensions
and applying postprocessing. This also handles palette and colour-key PNG
transparency; diffusion itself still receives RGB. Regression coverage lives in
`test_invisible_engine.py::TestTransparency`.
`get_device` in that module answers only `cuda` or `cpu`. An `mps` or `xpu` answer
would travel one frame to the same CUDA-only refusal while costing a device probe,
and reporting it implied an Apple-silicon or Intel-GPU path that does not exist.
+120 -94
View File
@@ -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,7 +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 |
| `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 |
@@ -23,26 +24,53 @@ through the installed CLI or public Python API.
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 their Web surfaces are primary rather than fallback routes.
tooling, so each of their Web surfaces is primary. Google has two: `gemini-web`
and `synthid-web` at `https://synthid.com/`.
Inspect the machine-readable order before preparing a batch:
```bash
uv run python scripts/provider_oracles.py plan openai --json
uv run python scripts/provider_oracles.py plan microsoft
uv run python scripts/provider_oracles.py plan google
```
API-first is a selection policy, not automatic failover. Use Web only when the
provider, requested media, or local configuration has no usable API route, or
when the operator explicitly requests a second surface. An API refusal, quota
response, transport failure, or indeterminate verdict is recorded as-is and
never silently triggers a Web upload.
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 does not work. On 2026-09-07, the
official `labs.google/synthid` entry point redirected a signed-in browser to a
Google 404; on 2026-09-23 it redirected to
`deepmind.google.com/frontiers/synthid`, which also returned 404. Historical corpus rows may still name `synthid-portal`; that value
describes how an old result was obtained and is not an operational surface.
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;
on 2026-09-23 it redirected to `deepmind.google.com/frontiers/synthid`, which
also returned 404. Historical corpus rows may still name `synthid-portal`; that
value describes how an old result was obtained and is not an operational surface.
The public SynthID Detector is a different site, [synthid.com](https://synthid.com/),
and it is the `synthid-web` surface. On 2026-10-17 the page title was
`SynthID Detector`. After the terms dialog, the uploader was present without a
completed sign-in in that session. The Account menu still offered Sign in, and
no file was submitted, so this check does not show whether a result requires an
account. The live FAQ named the partners as Google DeepMind, Nvidia, OpenAI,
and Kakao, and it did not list Apple. It defined two settled labels:
`Watermark detected` and `Watermark not detected`. The file input accepted
`.jpg`, `.jpeg`, `.png`, `.bmp`, `.webp`, `.avif`, `.heic`, `.heif`, `.tiff`,
`.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 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
@@ -161,9 +189,12 @@ Adobe Inspect and local C2PA, DWT-DCT, TrustMark, and research detectors are
useful evidence readers, not interchangeable provider pixel-watermark oracles.
The surface capability and this project's evidence coverage are separate. The
current Google, Microsoft, and Meta pages accept image, video, and audio inputs;
the OpenAI API accepts image and audio. A media type does not become certified
merely because a surface accepts it. Run `list --json` to read both fields.
current Google, Microsoft, and Meta pages, including `https://synthid.com/`,
accept image, video, and audio inputs; the OpenAI API accepts image and audio.
A media type does not become certified merely because a surface accepts it.
`synthid-web` therefore lists image, video, and audio as accepted media and
records no project evidence until a batch is verified. Run `list --json` to read
both fields.
## Execution slots
@@ -194,36 +225,20 @@ default; `--slots PATH` explicitly overrides it.
"network_label": "office"
},
{
"name": "openai-web-thordata-us",
"name": "synthid-proxy",
"surface": "synthid-web",
"account_label": null,
"browser_profile": null,
"network_label": "thordata",
"proxy_url_env": "THORDATA_PROXY"
},
{
"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",
@@ -261,27 +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; 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, a new network route, or a new
pass after the bounded plan is exhausted needs authorization unless already
requested. 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:
@@ -290,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:
@@ -385,21 +405,28 @@ uv sync --extra dev
uv run playwright install chromium
```
Google is intentionally excluded from the standalone runner because its oracle
requires the user's existing authenticated Chrome session. Invoke the project
`provider-oracles` skill to prepare the batch, select the named real Chrome
profile and `/u/N/` Google account, upload each prepared artifact, capture the
settled SynthID tool outcome, and record it.
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.
Ask the direct question stored in the batch manifest: `Does this
For Gemini, ask the direct question stored in the batch manifest: `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.` A
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.
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 \
@@ -410,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 \
@@ -428,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
@@ -450,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
View File
@@ -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
+25 -2
View File
@@ -24,9 +24,10 @@ The package version appears in:
- `pyproject.toml`;
- `src/remove_ai_watermarks/__init__.py`;
- both `version` fields in `server.json`;
- the root package entry generated in `uv.lock`.
Update the first two, then refresh the lock file with uv. Do not edit a
Update the first three, then refresh the lock file with uv. Do not edit a
line-number-specific location in `uv.lock`; its package order changes.
Before choosing a version, verify it is absent from git tags, GitHub Releases,
@@ -159,7 +160,10 @@ The Agent Skill and Claude plugin live in this repository:
is the Claude plugin manifest, with source `./skills` so an install copies
only the skill tree;
- [`.claude-plugin/marketplace.json`](../.claude-plugin/marketplace.json)
makes the repository a Claude Code marketplace.
makes the repository a Claude Code marketplace;
- [`.codex-plugin/plugin.json`](../.codex-plugin/plugin.json) is the Codex
plugin manifest, pointing at the same `./skills/` tree, with its icon in
[`assets/icon.svg`](../assets/icon.svg).
They are not a fifth required release surface. A CLI change that affects
command routing, extras, exit codes, mark keys, or the intended-use boundary
@@ -215,6 +219,25 @@ the catalog has not accepted the initial submission. The catalog-specific
submission state and links live in
[`docs/agent-skill.md`](agent-skill.md#where-the-skill-can-actually-be-listed).
## MCP registry
[`server.json`](../server.json) lists the PyPI package in the
[official MCP registry](https://registry.modelcontextprotocol.io/) as
`io.github.wiltodelta/remove-ai-watermarks`, started as
`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.
[`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).
## Release verification
Forensic transports are versioned independently from the package. Before publishing
+1 -1
View File
@@ -151,7 +151,7 @@ discarding the measured results.
now exists, but its 2026-08-08 README is still a release placeholder: the
authors say they will publish code after their SynthID testing and Google
Security Bounty submission. There is no implementation to evaluate as of
2026-10-01 (arXiv:2601.08832).
2026-10-17, when the repository still held only that README (arXiv:2601.08832).
MiniMax-Remover (CC-BY-NC-4.0 weights) and ProPainter (S-Lab License,
non-commercial) are deliberately excluded on license and are not backlog
items.
+3 -1
View File
@@ -95,6 +95,8 @@ The inspection and stripping code handles signals in these groups:
- exact app-export provenance and AIGC disclosures from supported
ByteDance-family products, with product-only provenance excluded from the
generated-image verdict;
- Kling's H.264 `user_data_unregistered` SEI tag (`kling-ai`) in MP4/MOV
video, blanked in place by `video metadata --remove` without re-encoding;
- IPTC AI disclosure fields;
- PNG text chunks and embedded generation parameters, including keyed
`workflow` and `prompt` tags in MP4/MOV metadata lists;
@@ -300,7 +302,7 @@ not a universal clean verdict.
| Luma AI | None registered | No registered pixel decoder | PNG text generator tags (Uni-1) |
| ByteDance generators | Doubao and Jimeng marks | No registered pixel decoder | TC260 AIGC, supported C2PA, and exact app-export AIGC disclosures |
| Qwen | Qwen mark | No registered pixel decoder | TC260 AIGC |
| Kling AI | Kling AI image and video marks | No registered pixel decoder | TC260 AIGC |
| Kling AI | Kling AI image and video marks | No registered pixel decoder | TC260 AIGC and the video SEI tag |
| Vidu (ShengShu) | Vidu video mark | No registered pixel decoder | TC260 AIGC in the MP4 `AIGC` key |
| Runway | None measured on paid-plan output | No registered pixel decoder | C2PA signed `RUNWAY AI, INC.` on its own models; third-party models keep their vendor's manifest |
| Amazon Bedrock (Nova Canvas, Nova Reel) | None | Invisible watermark readable only through the Bedrock API | C2PA signed `Amazon Web Services, Inc.`, claim generator `Amazon Bedrock` |
+1 -1
View File
@@ -200,7 +200,7 @@ route in this campaign. Mechanism detail stays in
| --- | --- | --- |
| Gowal et al., [arXiv:2510.09263](https://arxiv.org/abs/2510.09263) | Post-hoc encoder `f` / decoder `g`. Detection logit is not payload recovery. SynthID-O (partner variant) 136 bits at 512x512. TPR at 0.1% FPR 99.98% aggregated, 99.97% on the hardest spatial crop+resize. Trains against sampled semantics-preserving transforms, including weak VAE regeneration. Production decoder unpublished | Matches the architecture we treat as keyed `x' = x + g(x)`. Explains why a two-pixel crop kills `pipeline_lattice` but not the official oracle, and why a 22.3 dB foreign VAE still reads `detected` |
| DeepMind [US12094474B1](https://patents.google.com/patent/US12094474B1/en) and continuation [US20250149048A1](https://patents.google.com/patent/US20250149048A1/en) | Residual U-Net encoder, separate decoder, optional key, encoder/decoder ensembles that need not recognize each other | Constraint, not a recipe. Ensemble non-recognition is why one recovered Google tile cannot be reused as an OpenAI detector (S4) |
| Dathathri et al., [Nature 634:818-823 (2024)](https://www.nature.com/articles/s41586-024-08025-4) | SynthID-Text: tournament sampling of LLM tokens, open-source | Different system. Image/audio/video remain proprietary |
| Dathathri et al., [Nature 634:818-823 (2024)](https://www.nature.com/articles/s41586-024-08025-4) | SynthID-Text: tournament sampling of LLM tokens, open-source. Anthropic's [Claude text watermark](https://www.anthropic.com/news/claude-text-watermark) (announced 2026-08-14 for future Claude models, updated 2026-09-01) "is a version of the SynthID-Text approach", with its detection API in private preview | Different system. Image/audio/video remain proprietary |
| OpenAI, [advancing content provenance](https://openai.com/index/advancing-content-provenance/) (2026-05-19, audio 2026-07-31) and [content provenance API](https://developers.openai.com/api/docs/guides/content-provenance) | ChatGPT / API / Codex images carry C2PA plus SynthID. Audio from 2026-07-31. `POST /v1/content_provenance_checks`. `not_detected` does not rule out another vendor | This is the oracle. C2PA and SynthID are independent entries. Do not abuse the endpoint as an adaptive reverse-engineering loop |
### Keyless presence detectors in the literature
+9 -1
View File
@@ -229,12 +229,20 @@ regeneration is already in [synthid.md](synthid.md#23-removal-attacks-and-forens
| Goonatilake and Ateniese, [arXiv:2605.09203](https://arxiv.org/abs/2605.09203) | Six removers all leave a forensic residue a ResNet-50 sees at >98% TPR @ 1% FPR | Applies to UnMarker, Zhao's WatermarkAttacker, CtrlRegen+ | Defeating a provider oracle is not deniability. This is the product remainder |
| An et al., [arXiv:2401.08573](https://arxiv.org/abs/2401.08573) (WAVES, ICML 2024) | 26 attacks on StegaStamp, Stable Signature, Tree-Ring. Regeneration, not JPEG, is the attack that matters. StegaStamp TPR@1%FPR 1.00 to 0.01; Tree-Ring 0.99 to 0.12 | Open watermarks | Protocol. Our blur-sigma-7 and 16-32 scramble are closer to WAVES "distortion" than to regeneration |
| Bulychev et al., [arXiv:2605.16796](https://arxiv.org/abs/2605.16796) (Watermarks Attack Watermarks) | Apply a second watermark, usually with the victim's own post-processing encoder and a different message. On MS-COCO, its end-to-end pipeline lowers Video Seal victim bit accuracy from 0.998 to 0.520 (TPR@1%FPR 0.046) and Pixel Seal from 0.999 to 0.514 (TPR 0.029); the same-method attacker message recovers at 0.99 bit accuracy for both schemes. Quality is reported as an eight-metric normalized composite, not a raw PSNR operating point | Eight open image schemes, not production SynthID; Video Seal is tested on images | Directly motivated the [local open-encoder replication](#re-watermarking-on-open-encoders-2026-09-27). Its MIT [code](https://github.com/MariaBulychev/Watermarks-Attack-Watermarks/tree/0444f52ad972da9e009555f2dc1028d9abe0610e) calls external encoder packages and weights; the top-level license does not license those dependencies |
| Yang et al., [arXiv:2609.01249](https://arxiv.org/abs/2609.01249) (One Prompt Is Enough) | One reconstruction prompt to a closed image editor (GPT Image 1, 1.5 and 2; Nano Banana, Pro and 2) launders the payload over 1,800 outputs; OpenAI models disrupt it most | DwtDct, DwtDctSvd and RivaGAN only. "SynthID was not tested" | Regeneration through a provider's own model. It breaks the scheme our DWT-DCT detector reads, and says nothing about production SynthID, which the same editors add to their output |
| Yao et al., [arXiv:2610.11290](https://arxiv.org/abs/2610.11290) (ProxyEraseAgent) | Ranks a pool of open decoders by calibrated response and attacks the strongest as proxy boundary estimators; 94.8% success over 11 systems against 79.5% for the best fixed Regen-VAE | Open schemes, TrustMark and VideoSeal among them. Not SynthID | Needs a decoder that responds to the mark. No public decoder responds to production SynthID, so it does not transfer to an oracle-only target |
| Bao et al., [arXiv:2609.08213](https://arxiv.org/abs/2609.08213) (DRIFT) | Deflects the diffusion trajectory. The adaptive variant searches a strength ladder per image for "the first verifier-rejected rung" from binary verifier feedback; 98-100% success on nine marks | In-generation diffusion marks (Tree-Ring family, Gaussian Shading and others). Not SynthID | The ladder stop is our [attack ladders](#attack-ladders-openai-oracle) run per image. Against a provider verifier that is the adaptive query loop its terms forbid, so it applies to local oracles only |
| Ye et al., [arXiv:2610.03166](https://arxiv.org/abs/2610.03166) (LiBRA) | White-box latent optimization with the key and decoder, steering decoding confidence toward chance from both sides so the mark is not left inverted but detectable; Stable Signature detection 1.00 to 0.03 | Released checkpoints of Stable Signature, the YU1 GAN fingerprint, HiDDeN and MBRS | Needs the decoder, so it is out of reach here. The inverted-mark point applies to any bit-flip attack |
| Aistov et al., [arXiv:2610.02010](https://arxiv.org/abs/2610.02010) (Latent Frequency Masking, ICDM 2026) | Replaces selected Fourier coefficients of the latent; [code](https://github.com/alvitrioliks/lfm) | Stable Signature, Tree-Ring, RingID, Gaussian Shading (resists), Maxsive, METR. Not SynthID | In-generation latent marks. Not the post-hoc encoder family Gowal describes |
| Li et al., [arXiv:2609.31693](https://arxiv.org/abs/2609.31693) (Disentangle and Drop) | Splits the image into a grayscale carrier and a residual branch, then suppresses the residual, with one shared operating setting | SSL, DwtDct, DwtDctSvd, RivaGAN, StegaStamp, Stable Signature, Tree-Ring | Open schemes only |
| Yang et al., [arXiv:2609.31722](https://arxiv.org/abs/2609.31722) (Push-Pull) | Key-free and decoder-free at deployment; average BER 0.3958 at 31.07 dB PSNR on a four-method sweep | Seven open methods. Not SynthID | A BER near 0.40 leaves much of the payload intact, as with SPFM-Net below |
| Ding et al., [arXiv:2609.34003](https://arxiv.org/abs/2609.34003) (DeMark, audio) | Query-free black box that suppresses the time-frequency artifacts the embedding leaves; average attack success 0.92 and 0.96 on two speech sets | AudioSeal, WavMark, Timbre, AudioMarkNet. Not audio SynthID | A candidate cohort for the pinned AudioSeal oracle in the [benchmark kernel](benchmark-kernel.md). No code located |
| Wen et al., [arXiv:2305.20030](https://arxiv.org/abs/2305.20030) (Tree-Ring) | In-generation Fourier pattern in initial noise | Not SynthID | Kill is DDIM inversion plus latent wipe, not a pixel scramble. Lin and Juarez [arXiv:2506.10502](https://arxiv.org/abs/2506.10502) (USENIX 2025) remove it from public knowledge |
| Fernandez et al., Stable Signature, ICCV 2023 | Fine-tuned VAE decoder | Not SynthID | Regeneration that replaces the decoder is in-family. Our foreign VAE at 22.3 dB did not kill OpenAI SynthID, which is the expected mismatch |
| Google to [The Verge, 2026-04-14](https://www.theverge.com/ai-artificial-intelligence/911579/google-synthid-ai-watermarking-system-reverse-engineered) | reverse-SynthID does not systematically remove SynthID | Google spokesperson on the spectral bypass | Agrees with our V4 audit and with Bypass: reconstruction or decoder confusion, not a payload delete |
Quiet pixel-only removal is not a published result on production
OpenAI or Google SynthID (still true in the 2026-09-27 literature sweep).
OpenAI or Google SynthID (still true in the 2026-10-17 literature sweep).
Against open encoders the fidelity band has moved: WmForger
([arXiv:2510.20468](https://arxiv.org/abs/2510.20468)) and Hide&Seek
([arXiv:2603.01067](https://arxiv.org/abs/2603.01067)) report roughly 30 dB,
+7 -1
View File
@@ -575,7 +575,13 @@ The SynthID decoder is proprietary and not released:
> -- Gowal et al., arXiv:2510.09263
There are no released payload-decoder weights or public algorithm. Google
provides verification in Gemini. The old limited SynthID Detector portal does
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`. 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
+10 -4
View File
@@ -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
@@ -262,8 +262,8 @@ and implemented by `scripts/provider_oracles.py`.
Proprietary watermark removal cannot be verified locally by design -- no public decoder
exists. Each vendor has its own oracle and it covers only that vendor's content:
the Content Provenance API first and `openai.com/verify` as an explicit fallback for
OpenAI, the Gemini app for Google, and Microsoft's
the Content Provenance API first and `openai.com/verify` for
OpenAI, the Gemini app and the SynthID Detector at `synthid.com` for Google, and Microsoft's
[Content Provenance Detection API](https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection)
for InvisMark. The Microsoft API reports `Watermark` and `C2PA` separately. It therefore
needs a pixel-identical metadata-stripped control: the control must lose `C2PA` while
@@ -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.
+14
View File
@@ -37,6 +37,12 @@ claim generator still falls back to the certificate issuer.
Exact claim-generator mappings also cover `Grok Imagine` as xAI Grok Imagine
and `Suno` as Suno. Both can carry a valid AI source claim without a separately
readable issuer, so issuer-only attribution leaves the platform blank.
Suno also [announced on 2026-08-06](https://suno.com/blog/building-the-future-of-music-responsibly)
that it would adopt "new audio watermarking and fingerprinting technology" in
the coming weeks. It did not name the technology
([TechCrunch](https://techcrunch.com/2026/08/06/amid-legal-battles-suno-says-it-will-start-watermarking-songs/)
asked whether it would be SynthID and got no answer), so there is no local
detector, and metadata stripping leaves any such mark in the audio.
Some signers expose the product in `signature_info` while retaining a generic
claim generator. xAI Grok Imagine is recognized from that signed identity even
@@ -128,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
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://glama.ai/mcp/schemas/server.json",
"maintainers": ["wiltodelta"]
}
+4 -1
View File
@@ -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.45.1"
version = "0.46.0"
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
readme = "README.md"
requires-python = ">=3.11,<3.15"
@@ -205,6 +205,9 @@ dev = [
"httpx>=0.28,<1",
# Development-only isolated browser automation for public provider oracles.
"playwright>=1.62,<2",
# The official MCP client, used only to test `remove-ai-watermarks mcp`
# against the reference implementation. The server itself is stdlib-only.
"mcp>=2.3,<3",
"pytest>=8.0.0",
"pytest-cov>=4.1.0",
"pytest-xdist>=3.5.0",
+4 -4
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import datetime
from datetime import date
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -10,15 +10,15 @@ if TYPE_CHECKING:
from pathlib import Path
def corpus_day(path: Path, corpus: Path) -> datetime.date | None:
def corpus_day(path: Path, corpus: Path) -> date | None:
"""Read the leading YYYY-MM-DD corpus segment, if present."""
try:
return datetime.date.fromisoformat(path.relative_to(corpus).parts[0])
return date.fromisoformat(path.relative_to(corpus).parts[0])
except (ValueError, IndexError):
return None
def corpus_files(corpus: Path, since: datetime.date | None, *, suffixes: Collection[str] | None = None) -> list[Path]:
def corpus_files(corpus: Path, since: date | None, *, suffixes: Collection[str] | None = None) -> list[Path]:
"""Return a deterministic recursive walk, optionally date- and suffix-bounded."""
if since is None:
candidates = corpus.rglob("*")
@@ -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. |
+1 -1
View File
@@ -121,7 +121,7 @@ def load_pinned_models() -> tuple[object, object]:
raw = convert_state_dict_for_scriptable_model(raw)
model = create(config, device="cpu") # type: ignore[call-arg]
load_state_dict(model, state_dict=raw)
model.eval()
model.train(False)
return model
generator = load(GENERATOR_CARD, "generator_base.pth", AudioSealWMConfig, create_generator)
+1 -1
View File
@@ -51,7 +51,7 @@ def export_onnx(checkpoint: Path, output: Path) -> None:
if output.exists():
raise SystemExit(f"refusing to overwrite existing output: {output}")
output.parent.mkdir(parents=True, exist_ok=True)
model = VisionEmbedding(load_headed_clip(checkpoint, torch.device("cpu"))).eval()
model = VisionEmbedding(load_headed_clip(checkpoint, torch.device("cpu"))).train(False)
export_options: dict[str, Any] = {}
if "dynamo" in inspect.signature(torch.onnx.export).parameters:
export_options["dynamo"] = False
+1 -1
View File
@@ -252,7 +252,7 @@ def _lpips_model() -> tuple[Any, Any]:
import torch
model = lpips.LPIPS(net="alex", verbose=False)
model.eval()
model.train(False)
return model, torch
+1 -1
View File
@@ -173,7 +173,7 @@ def load_model() -> object:
raise RuntimeError(
f"PixelSeal checkpoint/source mismatch: missing={loaded.missing_keys}, unexpected={loaded.unexpected_keys}"
)
model.eval()
model.train(False)
return model
+85 -16
View File
@@ -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)
@@ -288,19 +361,15 @@ def run_web_batch(
oracles.verify_batch(manifest_path)
manifest, results = oracles.load_batch(manifest_path)
surface = manifest["surface"]
if surface == "gemini-web":
raise ValueError("gemini-web requires the real Chrome workflow, not isolated Playwright")
if oracles.SURFACES[surface].automation == "real_browser":
raise ValueError(f"{surface} requires the real Chrome workflow, not isolated Playwright")
if surface not in PLAYWRIGHT_SURFACES:
raise ValueError(f"surface {surface} is not a Playwright Web oracle")
slot = manifest["slot"]
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)
+38 -11
View File
@@ -169,6 +169,26 @@ SURFACES: dict[str, OracleSurface] = {
account_slots=True,
network_slots=True,
),
"synthid-web": OracleSurface(
key="synthid-web",
provider_key="google",
provider="Google",
signal_family="synthid",
surface="SynthID Detector",
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 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=False,
network_slots=True,
),
"meta-web": OracleSurface(
key="meta-web",
provider_key="meta",
@@ -278,6 +298,11 @@ def ordered_surfaces() -> list[OracleSurface]:
return sorted(SURFACES.values(), key=_surface_order)
def _automation_keys(automation: str) -> frozenset[str]:
"""Return the surface keys that use one automation kind."""
return frozenset(key for key, surface in SURFACES.items() if surface.automation == automation)
def _inside(path: Path, parent: Path) -> bool:
"""Return whether resolved PATH is inside resolved PARENT."""
try:
@@ -364,6 +389,8 @@ def load_slots(path: Path) -> dict[str, ExecutionSlot]:
"api_key_env",
}
slots: dict[str, ExecutionSlot] = {}
real_browser = _automation_keys("real_browser")
playwright = _automation_keys("playwright")
for index, raw_slot in enumerate(slot_items):
if not isinstance(raw_slot, dict):
raise ValueError(f"slot {index} must be a JSON object")
@@ -407,19 +434,18 @@ def load_slots(path: Path) -> dict[str, ExecutionSlot]:
raise ValueError(f"surface {surface_key} requires an account_label")
if account_label is not None and not SURFACES[surface_key].account_slots:
raise ValueError(f"surface {surface_key} does not use account slots")
if surface_key == "gemini-web" and browser_profile is None:
raise ValueError("surface gemini-web requires a browser_profile")
if surface_key in real_browser and browser_profile is None:
raise ValueError(f"surface {surface_key} requires a browser_profile")
if surface_key == "gemini-web" and google_account_index is None:
raise ValueError("surface gemini-web requires a google_account_index")
if surface_key != "gemini-web" and google_account_index is not None:
raise ValueError("only gemini-web uses google_account_index")
if SURFACES[surface_key].automation == "api" and browser_profile is not None:
raise ValueError(f"API surface {surface_key} does not use a browser_profile")
if surface_key != "gemini-web" and browser_profile is not None:
raise ValueError("only gemini-web uses a real browser_profile")
if surface_key == "gemini-web" and proxy_url_env is not None:
raise ValueError("surface gemini-web does not use proxy_url_env")
if proxy_url_env is not None and surface_key not in {"openai-web", "microsoft-web", "meta-web"}:
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:
raise ValueError("surface openai-api requires an api_key_env")
@@ -858,10 +884,11 @@ def list_oracles(as_json: bool) -> None:
def plan(provider: str, as_json: bool) -> None:
"""Show the API-first surface order for one provider."""
definitions = provider_plan(provider)
has_api = any(definition.automation == "api" for definition in definitions)
rows = []
for index, definition in enumerate(definitions):
for definition in definitions:
row = definition.to_dict()
row["preference"] = "primary" if index == 0 else "fallback"
row["preference"] = "fallback" if has_api and definition.automation != "api" else "primary"
rows.append(row)
if as_json:
click.echo(json.dumps(rows, indent=2, sort_keys=True))
@@ -869,7 +896,7 @@ def plan(provider: str, as_json: bool) -> None:
for row in rows:
click.echo(f"{row['preference']}\t{row['key']}\t{row['automation']}\t{row['surface']}")
if len(rows) > 1:
click.echo("Web fallback requires a separate operator decision; failures do not trigger it automatically.")
click.echo("This command only prints the order. After a failure, run the next surface as its own command.")
@cli.command()
@@ -1011,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
View File
@@ -98,7 +98,7 @@ def _train_detector(emb: dict[str, np.ndarray], catalog: dict) -> tuple[nn.Modul
optimizer.zero_grad()
loss_fn(model(features).squeeze(-1), labels).backward()
optimizer.step()
model.eval()
model.train(False)
dev = np.stack(
[
emb[row["sha256"]]
@@ -161,7 +161,7 @@ def _train_provider(feat: dict[str, np.ndarray], catalog: dict) -> dict[str, nn.
optimizer.zero_grad()
_focal_loss(model(features).squeeze(-1), labels).backward()
optimizer.step()
heads[name] = model.eval()
heads[name] = model.train(False)
log.info("provider %s pos=%d", name, len(positive))
return heads
+1 -1
View File
@@ -127,7 +127,7 @@ def load_model() -> object:
import torch
model = torch.jit.load(str(checkpoint_path()), map_location="cpu")
model.eval()
model.train(False)
return model
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"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",
"repository": {
"url": "https://github.com/wiltodelta/remove-ai-watermarks",
"source": "github"
},
"websiteUrl": "https://github.com/wiltodelta/remove-ai-watermarks/blob/main/docs/mcp-server.md",
"packages": [
{
"registryType": "pypi",
"registryBaseUrl": "https://pypi.org",
"identifier": "remove-ai-watermarks",
"version": "0.46.0",
"runtimeHint": "uvx",
"transport": {
"type": "stdio"
},
"packageArguments": [
{
"type": "positional",
"value": "mcp"
}
]
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remove-ai-watermarks",
"displayName": "Remove AI Watermarks",
"version": "1.0.11",
"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",
+12 -6
View File
@@ -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.11"
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
@@ -138,13 +138,20 @@ user names a device, and never to claim a device the probe did not report.
Without `-o`, video commands write `<source>_clean` and keep the original.
Output extension must match the input container.
## MCP server
`remove-ai-watermarks mcp` (since CLI 0.46.0) serves `identify`, `remove`
and `batch` as MCP tools over stdin and stdout for MCP clients. It blocks
waiting for protocol messages, so do not run it from this skill; call the
commands above directly.
## Exit codes
| 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.
+1 -1
View File
@@ -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, 45, 0)
MIN_CLI_VERSION = (0, 46, 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.
+1 -1
View File
@@ -35,7 +35,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.45.1"
__version__ = "0.46.0"
__all__ = [
"BatchItemResult",
@@ -1,7 +1,7 @@
"""Generated C2PA soft-binding registry; do not edit by hand.
Source: https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/main/softbinding-algorithm-list.json
Revision: d36d1c1742b1753ca064bb3307abfd95db912a28
Revision: d5960248ab69c17dc194906d2ef7d11ad164be70
Source license: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)
Changes: validated, ordered, and reduced to fields used at runtime; descriptions
are shortened to their first sentence for display.
@@ -12,7 +12,7 @@ from __future__ import annotations
C2PA_SOFT_BINDING_SOURCE_URL = (
"https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/main/softbinding-algorithm-list.json"
)
C2PA_SOFT_BINDING_SOURCE_REVISION = "d36d1c1742b1753ca064bb3307abfd95db912a28"
C2PA_SOFT_BINDING_SOURCE_REVISION = "d5960248ab69c17dc194906d2ef7d11ad164be70"
C2PA_SOFT_BINDING_SOURCE_LICENSE = "CC BY 4.0"
C2PA_SOFT_BINDING_ROWS: tuple[
@@ -632,4 +632,15 @@ C2PA_SOFT_BINDING_ROWS: tuple[
(),
False,
),
(
54,
"be.immostory.videoseal.1",
"watermark",
("video",),
("video/mp4",),
"ImmoStory AI deployment of Meta FAIR's Video Seal 1.0 invisible video watermark",
"2026-10-12T00:00:00.000Z",
(),
False,
),
)
@@ -67,7 +67,7 @@ def load_headed_clip(checkpoint: Path, device: torch.device) -> HeadedCLIP:
model = HeadedCLIP(clip)
state = torch.load(checkpoint, map_location="cpu", weights_only=True)
model.load_state_dict(state, strict=True)
return model.to(device).eval()
return model.to(device).train(False)
def load_processor() -> Any:
+12 -2
View File
@@ -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()
+106 -19
View File
@@ -7,7 +7,9 @@ without re-encoding, the image path drops matching boxes and emits the rest
verbatim. The streaming MP4/MOV/M4A path instead preserves all offsets by
retyping matching boxes as ``free`` and blanking their payloads in place. The
codestream (``mdat`` for ISOBMFF, ``jxlc`` / ``jxlp`` for JPEG-XL) is untouched,
so pixel, video, and audio data is preserved bit-for-bit.
so pixel, video, and audio data is preserved bit-for-bit. The one exception is
Kling's SEI tag inside ``mdat``: its bytes are blanked in place, which no decoder
reads, so every decoded frame stays identical.
TC260-PG-20257A video metadata is nested instead:
``moov.udta.meta.keys/ilst``. Its detector seeks through those boxes without
@@ -43,8 +45,11 @@ from remove_ai_watermarks.metadata import (
C2PA_UUID,
IPTC_AI_FIELD_MARKERS,
IPTC_AI_MARKERS,
KLING_SEI_TAG,
LOCAL_GENERATION_METADATA_KEYS,
MAX_TC260_VALUE_BYTES,
app_generator_from_metadata,
app_provenance_from_metadata,
parse_tc260_aigc_json,
)
@@ -347,6 +352,55 @@ def _ai_metadata_regions(
return regions
def _app_metadata_regions(
stream: BinaryIO,
file_size: int,
) -> list[tuple[tuple[int, int] | None, int, int]]:
"""Locate keyed or keyless values carrying an app-export provenance object.
Dreamina writes its export JSON under ordinary keys (``LvMetaInfo`` and a
second copy under ``com.apple.quicktime.artwork``). The file-level scan reads
it from the raw head bytes, so removal must blank every item it would match.
"""
return [
(key_span, value_start, value_end)
for _name, key_span, value_start, value_end, value in _metadata_items(stream, file_size)
if app_provenance_from_metadata(value) or app_generator_from_metadata(value)
]
def _copy_finding_kling_sei(
source: BinaryIO,
target: BinaryIO,
mdat_ranges: list[tuple[int, int]],
) -> list[int]:
"""Copy ``source`` to ``target`` and return each Kling SEI tag offset in ``mdat``.
Detection reads the tag from the head window, but removal covers every copy in
the media data. The search rides on the copy, so the file is read once. Each
window carries one byte less than the tag from the previous chunk, so a copy
straddling a read boundary is found exactly once.
"""
keep = len(KLING_SEI_TAG) - 1
offsets: list[int] = []
tail = b""
pos = 0
while chunk := source.read(_STREAM_COPY_BYTES):
target.write(chunk)
if mdat_ranges:
window = tail + chunk
base = pos - len(tail)
found = window.find(KLING_SEI_TAG)
while found != -1:
start = base + found
if any(low <= start and start + len(KLING_SEI_TAG) <= high for low, high in mdat_ranges):
offsets.append(start)
found = window.find(KLING_SEI_TAG, found + 1)
tail = window[-keep:]
pos += len(chunk)
return offsets
def ai_metadata_tags(path: str | Path, *, strict: bool = False) -> dict[str, str]:
"""Read keyed AI-generation metadata from an ISOBMFF container."""
try:
@@ -601,15 +655,16 @@ def strip_isobmff_media_file(
The output retains every box size and byte offset. A top-level C2PA/JUMBF or
AI-label box is converted to a ``free`` box and its payload is zeroed; native
TC260 key/value spans are blanked in place. Keeping the original lengths is
required because removing a pre-``mdat`` box would invalidate absolute media
offsets in an existing sample table.
TC260, generation-key and app-export key/value spans, and the Kling SEI tag in
``mdat``, are blanked in place. Keeping the original lengths is required
because removing a pre-``mdat`` box would invalidate absolute media offsets in
an existing sample table.
The source is copied in bounded chunks to a sibling temporary file and
atomically published only after all patches succeed. A malformed top-level
walk is fail-safe: the input is copied unchanged.
Returns ``(provenance_boxes_blanked, native_tc260_keys_blanked)``.
Returns ``(provenance_boxes_blanked, native_tags_blanked)``.
"""
from pathlib import Path as _Path
@@ -627,12 +682,23 @@ def strip_isobmff_media_file(
)
tc260_regions = _tc260_aigc_regions(stream, file_size) if targets is not None else []
ai_metadata_regions = _ai_metadata_regions(stream, file_size) if targets is not None else []
app_regions = _app_metadata_regions(stream, file_size) if targets is not None else []
mdat_ranges = (
[
(payload, end)
for _start, end, box_type, payload in iter_file_boxes(stream, 0, file_size)
if box_type == b"mdat"
]
if targets is not None
else []
)
tc260_key_spans = {region[0] for region in tc260_regions if region[0] is not None}
ai_metadata_key_spans = {region[0] for region in ai_metadata_regions}
app_key_spans = {region[0] for region in app_regions if region[0] is not None}
with atomic_video_output(output_path) as temporary_path:
with source_path.open("rb") as source_stream, temporary_path.open("r+b") as temporary:
shutil.copyfileobj(source_stream, temporary, length=_STREAM_COPY_BYTES)
kling_sei_offsets = _copy_finding_kling_sei(source_stream, temporary, mdat_ranges)
if targets is not None:
for box_start, payload_start, box_end in targets:
temporary.seek(box_start + 4)
@@ -647,6 +713,15 @@ def strip_isobmff_media_file(
temporary.seek(key_span[0])
temporary.write(b"free" + b" " * (key_span[1] - key_span[0] - 4))
_overwrite_range(temporary, value_start, value_end, byte=b" ")
for key_span, value_start, value_end in app_regions:
if key_span is not None:
temporary.seek(key_span[0])
temporary.write(b"free" + b" " * (key_span[1] - key_span[0] - 4))
_overwrite_range(temporary, value_start, value_end, byte=b" ")
# Spaces keep the SEI message well-formed: its length fields stand,
# and a 0x20 run cannot form an emulation-prevention sequence.
for offset in kling_sei_offsets:
_overwrite_range(temporary, offset, offset + len(KLING_SEI_TAG), byte=b" ")
temporary.flush()
os.fsync(temporary.fileno())
shutil.copymode(source_path, temporary_path)
@@ -657,13 +732,21 @@ def strip_isobmff_media_file(
source_path,
)
return 0, 0
return len(targets), len(tc260_key_spans | ai_metadata_key_spans)
# Every copy of the Kling tag is one tag, however many samples repeat it.
return len(targets), len(tc260_key_spans | ai_metadata_key_spans | app_key_spans) + bool(kling_sei_offsets)
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)
def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
"""Return ``(cleaned_bytes, stripped_count)`` with AI-provenance boxes removed.
"""Return ``(cleaned_bytes, stripped_count)`` with AI-provenance boxes blanked.
Walks top-level boxes and drops:
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
@@ -671,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
@@ -688,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`
@@ -823,7 +823,7 @@ class TwoStageZImagePipeline:
torch_dtype=self._face_stage_dtype(),
**kwargs,
).to(self.device)
model.eval()
model.train(False)
self._sam_model = model
self._sam_processor = processor
return model, processor
@@ -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)
@@ -217,9 +217,12 @@ class WatermarkRemover:
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
from remove_ai_watermarks.metadata import strip_and_verify
remove_ai_metadata(output_path, output_path, keep_standard=True)
# Verified, so a marker that survives fails the run instead of shipping with it.
_, leftover = strip_and_verify(output_path, output_path, keep_standard=True)
if leftover:
raise RuntimeError(f"AI metadata survived the strip: {', '.join(sorted(leftover))}")
def remove_watermark(
self,
+20 -15
View File
@@ -132,33 +132,36 @@ 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_and_verify
metadata.remove_ai_metadata(out_path, out_path)
_, leftover = strip_and_verify(staged, staged)
if leftover:
raise MetadataStripIncomplete(set(leftover))
def remove_visible(
@@ -676,7 +679,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 +690,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)
+2 -2
View File
@@ -467,7 +467,7 @@ def _load_runtime(device: Any, backend: ClassifierBackend = "torch") -> _Runtime
probe = np.load(_weight_path(folder, PROBE_FILE))
detector = _build_detector()
detector.load_state_dict(torch.load(_weight_path(folder, DETECTOR_FILE), map_location="cpu", weights_only=True))
detector.to(device).eval()
detector.to(device).train(False)
packed = torch.load(_weight_path(folder, PROVIDER_FILE), map_location="cpu", weights_only=True)
heads = {}
for name in PROVIDER_LABELS:
@@ -476,7 +476,7 @@ def _load_runtime(device: Any, backend: ClassifierBackend = "torch") -> _Runtime
continue
head = _build_provider_head()
head.load_state_dict(packed[name])
heads[name] = head.to(device).eval()
heads[name] = head.to(device).train(False)
ridge_threshold = float(probe["thr_oi_1pct"])
mlp_threshold, provider_margin = _operating_point(folder)
log.info("loaded photo-classify freeze from %s", folder)
+99 -42
View File
@@ -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_and_verify
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_and_verify(staged, 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)")
@@ -915,6 +937,11 @@ def cmd_invisible(
source = _validate_image(source)
if output is None:
output = source.with_stem(source.stem + "_clean")
# The scrub gate reads an undecodable file as "no target", which would exit 2
# (nothing to remove) for a file this command never looked at.
if image_io.imread(source) is None:
console.print(f"Error: Failed to read image: {source}")
raise SystemExit(1)
# An explicit --vendor wins over detection (see the option help) and implies the
# scrub runs: naming the cohort asserts the pixel watermark is present, so the
@@ -932,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
@@ -1047,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)
@@ -1059,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()
@@ -1435,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}")
@@ -1483,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()
@@ -1529,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) ──
@@ -1819,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}")
@@ -1854,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,
@@ -1862,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,
)
@@ -1906,5 +1949,19 @@ def cmd_batch(
raise SystemExit(1)
# ── MCP server ──
@main.command("mcp")
def cmd_mcp() -> None:
"""Run a Model Context Protocol server on stdin/stdout.
Exposes identify, remove and batch as MCP tools for agent clients. Each tool
runs one command of this CLI, so results and exit codes match it. Start it as
`uvx remove-ai-watermarks mcp` from the client's server configuration.
"""
from remove_ai_watermarks.mcp_server import serve
serve()
if __name__ == "__main__":
main()
+17 -2
View File
@@ -311,6 +311,15 @@ class InvisibleEngine:
with Image.open(image_path) as opened:
image = ImageOps.exif_transpose(opened)
orig_size = image.size # (width, height)
# Diffusion only processes RGB. Keep alpha at the original display geometry
# (after EXIF, before any resize) and rejoin it after all RGB postprocessing.
# Palette and colour-key PNGs can carry transparency without an A band.
alpha = (
image.convert("RGBA").getchannel("A")
if output_path.suffix.lower() in image_io.ALPHA_FORMATS
and ("A" in image.getbands() or "transparency" in image.info)
else None
)
# Full-res original, kept for the adaptive-polish detail target (image is
# reassigned to the resized copy below; PIL resize returns a new object).
reference_pil = image
@@ -369,7 +378,7 @@ class InvisibleEngine:
# Route those outputs through the same final resize so --no-adaptive-polish does
# not silently change e.g. 1448x1086 into 1448x1080.
needs_restore = target is not None or any(dimension % 8 for dimension in orig_size)
if humanize > 0.0 or unsharp > 0.0 or adaptive_polish or needs_restore:
if humanize > 0.0 or unsharp > 0.0 or adaptive_polish or needs_restore or alpha is not None:
import cv2
import numpy as np
@@ -390,7 +399,13 @@ class InvisibleEngine:
progress=self._progress_callback,
)
if not image_io.imwrite(out_path, out_cv, display_tags_from=image_path, orientation_applied=True):
if not image_io.write_bgr_with_alpha(
out_path,
out_cv,
np.asarray(alpha) if alpha is not None else None,
display_tags_from=image_path,
orientation_applied=True,
):
raise OSError(f"failed to write postprocessed output: {out_path}")
return out_path
+302
View File
@@ -0,0 +1,302 @@
"""Model Context Protocol server over stdio, a thin wrapper around this package's CLI.
``remove-ai-watermarks mcp`` speaks newline-delimited JSON-RPC 2.0 on stdin and
stdout with the initialize handshake. Each tool call runs one CLI command in a
child process of the same interpreter, so a tool returns exactly what that command
would: its exit code, its output file and its text. Nothing but protocol messages
reaches stdout, and a crash inside a command cannot corrupt the stream.
The server needs no dependency beyond the standard library, so the bare package
runs it through ``uvx remove-ai-watermarks mcp``. Commands that need an optional
extra fail with the CLI's own install hint, returned as a tool error.
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
from typing import IO, Any, cast
from remove_ai_watermarks import __version__
from remove_ai_watermarks.video import VIDEO_EXTENSIONS
log = logging.getLogger(__name__)
# Newest first. A client asking for an unlisted version gets the newest, and
# decides for itself whether it can continue.
PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
INSTRUCTIONS = (
"Identify and remove AI provenance marks from images and video the user generated "
"or edited themselves: C2PA, IPTC, EXIF and TC260 metadata, visible AI-generator "
"labels, and supported invisible watermarks. Do not use it on stock, agency, "
"marketplace or other third-party marks that protect someone else's asset. A "
"result of 'unknown' or exit code 2 means no supported signal was found, not "
"that the file is clean. Paths are read on the machine running the server."
)
_MODES = ("metadata", "visible", "all")
TOOLS: list[dict[str, Any]] = [
{
"name": "identify",
"title": "Identify AI provenance",
"description": (
"Report which AI provenance signals an image or video carries: metadata "
"(C2PA, IPTC, EXIF, TC260), visible AI-generator labels, and supported local "
"invisible-watermark decoders. Read-only. Video is detected by file suffix."
),
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Image or video file to inspect."},
"check_visible": {
"type": "boolean",
"default": True,
"description": "Run the pixel and visible-mark detectors. False reads metadata only.",
},
},
"required": ["path"],
"additionalProperties": False,
},
"annotations": {"readOnlyHint": True, "openWorldHint": False},
},
{
"name": "remove",
"title": "Remove AI watermarks from one file",
"description": (
"Write a cleaned copy of an image or video the user generated or edited. "
"mode=metadata strips AI metadata without touching pixels or re-encoding video; "
"mode=visible also removes a known visible AI label; for an image, mode=all adds "
"the invisible-watermark scrub where the installed extras and hardware allow it. "
"The source is never overwritten; the default output is <name>_clean beside it."
),
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Image or video file to clean."},
"mode": {"type": "string", "enum": list(_MODES), "default": "metadata"},
"output": {"type": "string", "description": "Output file. Must differ from the source."},
},
"required": ["path"],
"additionalProperties": False,
},
"annotations": {"readOnlyHint": False, "destructiveHint": False, "openWorldHint": False},
},
{
"name": "batch",
"title": "Remove AI watermarks from a directory",
"description": (
"Clean every supported image (media=image) or video (media=video) in a directory "
"the user owns, writing results to a separate directory (default <dir>_clean). "
"Modes as for the remove tool."
),
"inputSchema": {
"type": "object",
"properties": {
"directory": {"type": "string", "description": "Directory of input files."},
"mode": {"type": "string", "enum": list(_MODES), "default": "metadata"},
"media": {"type": "string", "enum": ["image", "video"], "default": "image"},
"output_dir": {"type": "string", "description": "Output directory. Must differ from the input."},
},
"required": ["directory"],
"additionalProperties": False,
},
"annotations": {"readOnlyHint": False, "destructiveHint": False, "openWorldHint": False},
},
]
_TOOL_SCHEMAS = {tool["name"]: tool["inputSchema"] for tool in TOOLS}
Runner = Callable[[list[str]], subprocess.CompletedProcess[str]]
class ToolInputError(ValueError):
"""A tool call whose arguments the server refuses before running anything."""
def run_cli(args: list[str]) -> subprocess.CompletedProcess[str]:
"""Run one CLI command in a child process of this interpreter."""
env = {**os.environ, "COLUMNS": "120", "NO_COLOR": "1", "TERM": "dumb"}
command = [sys.executable, "-m", "remove_ai_watermarks.cli", *args]
log.info("Running CLI command: %s", command)
completed = subprocess.run( # noqa: S603 -- fixed interpreter, argument list, no shell
command,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
env=env,
check=False,
)
log.info("CLI command exited %d: %s", completed.returncode, command)
return completed
def _validated(tool: str, arguments: dict[str, Any]) -> dict[str, Any]:
"""Check arguments against the tool's own input schema and fill its defaults."""
schema = _TOOL_SCHEMAS[tool]
properties: dict[str, dict[str, Any]] = schema["properties"]
unknown = sorted(set(arguments) - set(properties))
if unknown:
raise ToolInputError(f"unknown argument(s): {', '.join(unknown)}")
values: dict[str, Any] = {}
for key, spec in properties.items():
if key not in arguments:
if key in schema["required"]:
raise ToolInputError(f"'{key}' is required")
if "default" in spec:
values[key] = spec["default"]
continue
value = arguments[key]
if spec["type"] == "boolean" and not isinstance(value, bool):
raise ToolInputError(f"'{key}' must be a boolean")
if spec["type"] == "string" and (not isinstance(value, str) or not value.strip()):
raise ToolInputError(f"'{key}' must be a non-empty string")
if "enum" in spec and value not in spec["enum"]:
raise ToolInputError(f"'{key}' must be one of {', '.join(spec['enum'])}")
values[key] = value
return values
def _is_video(path: str) -> bool:
return Path(path).suffix.lower() in VIDEO_EXTENSIONS
def _identify_command(values: dict[str, Any]) -> tuple[list[str], str | None]:
path = values["path"]
prefix = ["video", "identify"] if _is_video(path) else ["identify"]
return [*prefix, "--json", *([] if values["check_visible"] else ["--no-visible"]), "--", path], None
def _remove_command(values: dict[str, Any]) -> tuple[list[str], str | None]:
path, mode = values["path"], values["mode"]
source = Path(path)
output = values.get("output") or str(source.with_stem(source.stem + "_clean"))
if Path(output).resolve() == source.resolve():
raise ToolInputError("'output' must differ from the source; the server never overwrites an input")
command = ["metadata", "--remove"] if mode == "metadata" else [mode]
return [*(["video"] if _is_video(path) else []), *command, "-o", output, "--", path], output
def _batch_command(values: dict[str, Any]) -> tuple[list[str], str | None]:
directory = values["directory"]
resolved = Path(directory).resolve()
output_dir = values.get("output_dir") or str(resolved.with_name(resolved.name + "_clean"))
if Path(output_dir).resolve() == resolved:
raise ToolInputError("'output_dir' must differ from the input directory")
command = ["video", "batch"] if values["media"] == "video" else ["batch"]
return [*command, "--mode", values["mode"], "-o", output_dir, "--", directory], output_dir
_COMMANDS: dict[str, Callable[[dict[str, Any]], tuple[list[str], str | None]]] = {
"identify": _identify_command,
"remove": _remove_command,
"batch": _batch_command,
}
def call_tool(name: str, arguments: dict[str, Any], runner: Runner = run_cli) -> dict[str, Any]:
"""Run one tool and return an MCP ``tools/call`` result."""
try:
args, output = _COMMANDS[name](_validated(name, arguments))
except ToolInputError as exc:
return {"content": [{"type": "text", "text": f"Invalid arguments: {exc}"}], "isError": True}
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:]
structured: dict[str, Any] = {"command": ["remove-ai-watermarks", *args], "exit_code": code, "output": output}
if "--json" in args and code == 0:
with contextlib.suppress(json.JSONDecodeError):
structured["report"] = json.loads(text)
if "report" not in structured:
structured["stdout"] = text
if stderr:
structured["stderr"] = stderr
# 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, 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}
def _error(request_id: object, code: int, message: str) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
def _object(value: object) -> dict[str, Any] | None:
"""A decoded JSON object, or ``None`` for any other JSON value."""
return cast("dict[str, Any]", value) if isinstance(value, dict) else None
def handle(raw: object, runner: Runner = run_cli) -> dict[str, Any] | None:
"""Answer one JSON-RPC message; ``None`` for notifications and client responses."""
message = _object(raw)
if message is None or message.get("jsonrpc") != "2.0":
return _error(None, -32600, "Invalid Request")
method = message.get("method")
if "id" not in message:
return None # a notification or a response to nothing this server sent
request_id: object = message["id"]
if not isinstance(method, str):
return _error(request_id, -32600, "Invalid Request")
params = _object(message.get("params") or {})
if params is None:
return _error(request_id, -32602, "params must be an object")
if method == "initialize":
requested = params.get("protocolVersion")
result: dict[str, Any] = {
"protocolVersion": requested if requested in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0],
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "remove-ai-watermarks", "title": "Remove AI Watermarks", "version": __version__},
"instructions": INSTRUCTIONS,
}
elif method == "ping":
result = {}
elif method == "tools/list":
result = {"tools": TOOLS}
elif method == "tools/call":
name = params.get("name")
arguments = _object(params.get("arguments") or {})
if not isinstance(name, str) or name not in _COMMANDS:
return _error(request_id, -32602, f"Unknown tool: {name}")
if arguments is None:
return _error(request_id, -32602, "arguments must be an object")
result = call_tool(name, arguments, runner)
else:
return _error(request_id, -32601, f"Method not found: {method}")
return {"jsonrpc": "2.0", "id": request_id, "result": result}
def serve(stdin: IO[str] | None = None, stdout: IO[str] | None = None, runner: Runner = run_cli) -> None:
"""Serve newline-delimited JSON-RPC until stdin closes."""
source: IO[str] = sys.stdin if stdin is None else stdin
sink: IO[str] = sys.stdout if stdout is None else stdout
for line in source:
if not line.strip():
continue
try:
message = json.loads(line)
except json.JSONDecodeError:
response: dict[str, Any] | None = _error(None, -32700, "Parse error")
else:
response = handle(message, runner)
if response is not None:
sink.write(json.dumps(response, ensure_ascii=False) + "\n")
sink.flush()
+103 -4
View File
@@ -85,6 +85,11 @@ AI_KEYWORDS: tuple[str, ...] = (
# Reference: https://spec.c2pa.org/specifications/specifications/2.1/specs/C2PA_Specification.html
C2PA_UUID: bytes = bytes.fromhex("d8fec3d61b0e483c92975828877ec481")
# Kling's encoder writes an H.264 ``user_data_unregistered`` SEI message (payload
# type 5) whose payload is this UUID followed by ``kling-ai``. It lives in the coded
# stream inside ``mdat``, where no metadata box walk reaches it.
KLING_SEI_TAG: bytes = bytes.fromhex("91ca60614aee385486142d5f73f4ae2e") + b"kling-ai"
def c2pa_marker_in(data: bytes) -> bool:
"""True if ``data`` carries a real C2PA manifest marker, not just an
@@ -1430,6 +1435,10 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
if app_generator:
result.setdefault("app_aigc", f"App AIGC disclosure ({app_generator})")
# Kling's encoder tag, an H.264 SEI message in the first video sample.
if KLING_SEI_TAG in app_scan:
result.setdefault("kling_sei", "Kling AI encoder tag (H.264 SEI kling-ai)")
# xAI / Grok signature pair in EXIF or an equivalent container-native field.
if xai_signature(image_path):
result.setdefault("xai_signature", "xAI/Grok signature (UUID + Signature blob)")
@@ -1847,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,
@@ -1857,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
@@ -1869,6 +1966,8 @@ 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}")
out = remove_ai_metadata(source_path, output_path, keep_standard=keep_standard)
remaining = get_ai_metadata(out)
if not remaining:
+11 -4
View File
@@ -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
@@ -377,6 +382,8 @@ def _platform_from_video_metadata(markers: dict[str, str]) -> str | None:
return _tc260_video_platform(markers.get("aigc_producer", "")) or (
"China AIGC-labeled content (TC260 standard)"
)
if "kling_sei" in markers:
return _tc260_video_platform("kling")
return None
@@ -712,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
+1 -1
View File
@@ -135,7 +135,7 @@ def load_video_vae_runtime(
dtype = torch.float16 if resolved_device == "cuda" else torch.float32
log.info("Loading %s on %s", model, resolved_device)
vae = AutoencoderKL.from_pretrained(model, torch_dtype=dtype).to(resolved_device)
vae.eval()
vae.train(False)
vae.enable_slicing()
scaling_factor = float(vae.config.scaling_factor)
log.info("Latent scaling factor %.5f", scaling_factor)
+6 -4
View File
@@ -14,6 +14,7 @@ ROOT = Path(__file__).resolve().parents[1]
SKILL = ROOT / "skills" / "remove-ai-watermarks" / "SKILL.md"
PLUGIN = ROOT / "skills" / ".claude-plugin" / "plugin.json"
MARKETPLACE = ROOT / ".claude-plugin" / "marketplace.json"
CODEX_PLUGIN = ROOT / ".codex-plugin" / "plugin.json"
AGENT_DOC = ROOT / "docs" / "agent-skill.md"
INSTALL_DOC = ROOT / "docs" / "installation.md"
INSTALL_REFERENCE = ROOT / "skills" / "remove-ai-watermarks" / "references" / "install.md"
@@ -346,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),
@@ -541,15 +542,16 @@ def test_probe_minimum_version_is_a_release_that_exists() -> None:
assert current >= probe.MIN_CLI_VERSION, "the probe demands a version the project has not released"
def test_skill_version_agrees_across_all_three_manifests() -> None:
"""Three files carry this version, and a catalog reads a different one from each."""
def test_skill_version_agrees_across_every_manifest() -> None:
"""Four files carry this version, and a catalog reads a different one from each."""
frontmatter = _frontmatter(SKILL.read_text(encoding="utf-8"))
match = re.search(r'(?m)^\s+version:\s*"?([0-9][^"\n]*?)"?\s*$', frontmatter)
assert match is not None, "SKILL.md frontmatter no longer carries metadata.version"
plugin = json.loads(PLUGIN.read_text(encoding="utf-8"))
marketplace = json.loads(MARKETPLACE.read_text(encoding="utf-8"))
codex = json.loads(CODEX_PLUGIN.read_text(encoding="utf-8"))
assert match.group(1) == plugin["version"] == marketplace["metadata"]["version"]
assert match.group(1) == plugin["version"] == marketplace["metadata"]["version"] == codex["version"]
def test_a_bad_invocation_shares_the_no_signal_exit_code(tmp_path: Path) -> None:
+34 -11
View File
@@ -867,7 +867,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 +1309,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,9 +1320,32 @@ 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(
("name", "content"),
[
("empty.png", b""),
("notimage.jpg", b"plain text, not an image at all " * 20),
("truncated.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 40),
],
)
@pytest.mark.parametrize("force", [[], ["--force"]])
def test_invisible_rejects_unreadable_input_instead_of_reporting_no_signal(runner, tmp_path, name, content, force):
"""The scrub gate reads undecodable bytes as "no target", so without a decode
check `invisible` exited 2 -- the no-signal code -- on a file it never read,
while `all` and `batch` exited 1."""
bad = tmp_path / name
bad.write_bytes(content)
mock_cls, mock_engine = _mock_invisible_engine()
with (
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
):
result = runner.invoke(main, ["invisible", str(bad), "-o", str(tmp_path / "out.png"), *force])
assert result.exit_code == 1, result.output
assert "Failed to read image" in result.output
mock_engine.remove_watermark.assert_not_called()
+75
View File
@@ -0,0 +1,75 @@
"""The Codex plugin manifest, held to the checks a Codex plugin catalog runs on it."""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / ".codex-plugin" / "plugin.json"
CLAUDE_PLUGIN = ROOT / "skills" / ".claude-plugin" / "plugin.json"
def _manifest() -> dict:
return json.loads(MANIFEST.read_text(encoding="utf-8"))
def test_manifest_carries_every_field_the_catalog_validator_requires() -> None:
manifest = _manifest()
for field in ("name", "version", "description", "repository", "license"):
assert str(manifest.get(field, "")).strip(), field
interface = manifest["interface"]
for field in ("displayName", "shortDescription", "composerIcon"):
assert str(interface.get(field, "")).strip(), field
assert re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", manifest["name"])
assert re.fullmatch(r"\d+\.\d+\.\d+", manifest["version"])
assert re.fullmatch(r"#[0-9A-Fa-f]{6}", interface["brandColor"])
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", "version", "description", "repository", "license", "homepage"):
assert manifest[field] == claude[field], field
def test_every_declared_path_stays_inside_the_repository() -> None:
manifest = _manifest()
interface = manifest["interface"]
paths = [manifest["skills"], interface["composerIcon"], interface["logo"]]
for declared in paths:
assert declared.startswith("./"), declared
resolved = (ROOT / declared).resolve()
assert resolved.is_relative_to(ROOT), declared
assert resolved.exists(), declared
assert (ROOT / manifest["skills"] / "remove-ai-watermarks" / "SKILL.md").is_file()
def test_icon_is_a_small_self_contained_svg() -> None:
icon = ROOT / _manifest()["interface"]["composerIcon"]
content = icon.read_text(encoding="utf-8")
assert icon.suffix == ".svg"
assert icon.stat().st_size <= 50 * 1024
for forbidden in ("data:image/", "base64", "TODO", "PLACEHOLDER"):
assert forbidden not in content, forbidden
def test_no_tracked_code_matches_the_scanner_dynamic_execution_rule() -> None:
"""The catalog's scanner reports a high finding for any call spelled like
Python's built-in evaluator in its code files, and that rule cannot tell it from
the PyTorch module method of the same name. Its scan job is advisory, but the
finding still lands on the listing PR, so the code switches modules to inference
with the equivalent ``module.train(False)``."""
tracked = subprocess.run(
["git", "ls-files", "*.py", "*.js", "*.mjs", "*.cjs", "*.ts", "*.tsx", "*.jsx"], # noqa: S607
cwd=ROOT,
check=True,
capture_output=True,
text=True,
).stdout.split()
assert tracked
rule = re.compile(r"\beval\s*\(")
offenders = [name for name in tracked if rule.search((ROOT / name).read_text(encoding="utf-8", errors="ignore"))]
assert offenders == []
+311
View File
@@ -0,0 +1,311 @@
"""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")
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"]
+122
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from types import SimpleNamespace
import numpy as np
import pytest
from PIL import Image
from remove_ai_watermarks.invisible_engine import (
@@ -164,6 +166,126 @@ class TestNativeOutputSize:
assert Image.open(out).size == (24, 18)
class TestTransparency:
"""Exercise real image IO and postprocessing around an RGB-only model stand-in."""
@staticmethod
def _engine():
engine = object.__new__(InvisibleEngine)
engine._progress_callback = None
def remove_watermark(image_path, output_path, **_kwargs):
with Image.open(image_path) as image:
assert image.mode == "RGB"
width, height = image.size
# Model-side grid rounding and changed pixels, not a copy of the input.
Image.new("RGB", (width // 8 * 8, height // 8 * 8), (40, 120, 200)).save(output_path)
return output_path
engine._remover = SimpleNamespace(configured_profile="qwen-zimage", remove_watermark=remove_watermark)
return engine
@staticmethod
def _source(mode, size):
image = Image.new("RGB", size, (180, 90, 30))
alpha = np.resize(np.array([0, 64, 128, 255], dtype=np.uint8), (size[1], size[0]))
if mode == "P":
image = Image.fromarray(alpha).convert("P")
image.info["transparency"] = bytes(range(256))
elif mode == "RGB-key":
image.paste((0, 0, 0), (0, 0, size[0] // 2, size[1]))
image.info["transparency"] = (0, 0, 0)
else:
image = image.convert(mode)
image.putalpha(Image.fromarray(alpha))
return image
@pytest.mark.parametrize("mode", ["RGBA", "LA", "P", "RGB-key"])
@pytest.mark.parametrize(("size", "max_resolution"), [((32, 24), 0), ((35, 27), 0), ((32, 24), 16)])
def test_preserves_exact_alpha_after_model_and_size_restoration(self, tmp_path, mode, size, max_resolution):
source, output = tmp_path / "source.png", tmp_path / "output.png"
self._source(mode, size).save(source)
with Image.open(source) as image:
expected = np.asarray(image.convert("RGBA"))[:, :, 3].copy()
self._engine().remove_watermark(source, output, max_resolution=max_resolution, adaptive_polish=False)
with Image.open(output) as image:
assert image.mode == "RGBA"
assert image.size == size
actual = np.asarray(image)
np.testing.assert_array_equal(actual[:, :, 3], expected)
# Retain the model's RGB result rather than restoring the source image.
np.testing.assert_array_equal(actual[:, :, :3], np.broadcast_to([40, 120, 200], actual[:, :, :3].shape))
@pytest.mark.parametrize("orientation", [2, 6, 7])
def test_in_place_output_keeps_alpha_in_display_orientation(self, tmp_path, orientation):
from PIL import ImageOps
source = tmp_path / "source.png"
image = self._source("RGBA", (32, 24))
image.getexif()[274] = orientation
image.save(source)
with Image.open(source) as opened:
expected = np.asarray(ImageOps.exif_transpose(opened).getchannel("A")).copy()
self._engine().remove_watermark(source, adaptive_polish=False)
with Image.open(source) as result:
assert result.mode == "RGBA"
assert result.getexif().get(274, 1) == 1
np.testing.assert_array_equal(np.asarray(result.getchannel("A")), expected)
def test_webp_keeps_alpha_after_postprocessing(self, tmp_path):
source, output = tmp_path / "source.png", tmp_path / "output.webp"
image = self._source("RGBA", (32, 24))
image.save(source)
self._engine().remove_watermark(source, output, unsharp=0.5, adaptive_polish=True, humanize=1.0)
with Image.open(output) as result:
assert result.mode == "RGBA"
np.testing.assert_array_equal(np.asarray(result.getchannel("A")), np.asarray(image.getchannel("A")))
@pytest.mark.parametrize(("source_mode", "output_suffix"), [("RGB", ".png"), ("RGBA", ".jpg")])
def test_rgb_and_non_alpha_output_remain_rgb(self, tmp_path, source_mode, output_suffix):
source, output = tmp_path / "source.png", tmp_path / ("output" + output_suffix)
Image.new(source_mode, (32, 24)).save(source)
self._engine().remove_watermark(source, output, adaptive_polish=False)
with Image.open(output) as result:
assert result.mode == "RGB"
@pytest.mark.parametrize("command", ["invisible", "batch"])
def test_cli_preserves_alpha_through_the_real_engine(self, tmp_path, monkeypatch, command):
from click.testing import CliRunner
from remove_ai_watermarks import invisible_engine
from remove_ai_watermarks.cli import main
source = tmp_path / "source.png"
image = self._source("RGBA", (32, 24))
image.save(source)
engine = self._engine()
monkeypatch.setattr(invisible_engine, "InvisibleEngine", lambda **_kwargs: engine)
monkeypatch.setattr(invisible_engine, "is_available", lambda: True)
if command == "batch":
output_dir = tmp_path / "results"
args = ["batch", str(tmp_path), "--mode", "invisible", "-o", str(output_dir), "--force"]
output = output_dir / "source.png"
else:
output = tmp_path / "output.png"
args = ["invisible", str(source), "-o", str(output), "--force"]
result = CliRunner().invoke(main, args)
assert result.exit_code == 0, result.output
with Image.open(output) as processed:
assert processed.mode == "RGBA"
np.testing.assert_array_equal(np.asarray(processed.getchannel("A")), np.asarray(image.getchannel("A")))
class TestTargetSize:
"""Regression guard for the native-resolution decision (issues #10 / #15).
+272
View File
@@ -0,0 +1,272 @@
"""`remove-ai-watermarks mcp`: the protocol layer, the CLI mapping, and real clients."""
from __future__ import annotations
import io
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
from remove_ai_watermarks import mcp_server
from remove_ai_watermarks.metadata import KLING_SEI_TAG
ROOT = Path(__file__).resolve().parents[1]
KLING_MP4 = ROOT / "data/fixtures/provenance/higgsfield-kling-3-0-turbo.mp4"
SERVER = [sys.executable, "-m", "remove_ai_watermarks.cli", "mcp"]
class _Recorder:
"""Stands in for the CLI child process and records each argument list."""
def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = "") -> None:
self.calls: list[list[str]] = []
self._result = (returncode, stdout, stderr)
def __call__(self, args: list[str]) -> subprocess.CompletedProcess[str]:
self.calls.append(args)
returncode, stdout, stderr = self._result
return subprocess.CompletedProcess(args, returncode, stdout, stderr)
def _request(method: str, params: dict | None = None, request_id: int = 1) -> dict:
message: dict = {"jsonrpc": "2.0", "id": request_id, "method": method}
if params is not None:
message["params"] = params
return message
def _assert_the_cli_accepts(args: list[str]) -> None:
"""Every subcommand and flag a tool builds must exist in the real Click tree."""
import click
from remove_ai_watermarks.cli import main
command: click.Command = main
position = 0
while isinstance(command, click.Group):
command = command.commands[args[position]]
position += 1
options = {option for param in command.params for option in (*param.opts, *param.secondary_opts)}
flags = [arg for arg in args[position : args.index("--")] if arg.startswith("-")]
assert set(flags) <= options, (args, sorted(set(flags) - options))
@pytest.mark.parametrize(
("requested", "negotiated"),
[("2025-06-18", "2025-06-18"), ("2024-11-05", "2024-11-05"), ("2099-01-01", mcp_server.PROTOCOL_VERSIONS[0])],
)
def test_initialize_negotiates_a_supported_protocol_version(requested: str, negotiated: str) -> None:
response = mcp_server.handle(_request("initialize", {"protocolVersion": requested, "capabilities": {}}))
assert response is not None
assert response["result"]["protocolVersion"] == negotiated
assert response["result"]["capabilities"] == {"tools": {"listChanged": False}}
assert "third-party" in response["result"]["instructions"]
def test_notifications_get_no_reply_and_unknown_methods_get_method_not_found() -> None:
assert mcp_server.handle({"jsonrpc": "2.0", "method": "notifications/initialized"}) is None
# A modern client probes server/discover first and falls back to initialize on this error.
response = mcp_server.handle(_request("server/discover"))
assert response is not None
assert response["error"]["code"] == -32601
def test_tools_list_exposes_exactly_the_three_tools() -> None:
response = mcp_server.handle(_request("tools/list"))
assert response is not None
tools = response["result"]["tools"]
assert [tool["name"] for tool in tools] == ["identify", "remove", "batch"]
assert tools[0]["annotations"]["readOnlyHint"] is True
for tool in tools:
assert tool["inputSchema"]["type"] == "object"
assert tool["inputSchema"]["additionalProperties"] is False
@pytest.mark.parametrize(
("arguments", "expected"),
[
({"path": "a.png"}, ["identify", "--json", "--", "a.png"]),
({"path": "a.png", "check_visible": False}, ["identify", "--json", "--no-visible", "--", "a.png"]),
({"path": "clip.MP4"}, ["video", "identify", "--json", "--", "clip.MP4"]),
],
)
def test_identify_maps_to_the_cli(arguments: dict, expected: list[str]) -> None:
runner = _Recorder(stdout='{"is_ai_generated": true}')
result = mcp_server.call_tool("identify", arguments, runner)
assert runner.calls == [expected]
_assert_the_cli_accepts(expected)
assert result["isError"] is False
assert result["structuredContent"]["report"] == {"is_ai_generated": True}
@pytest.mark.parametrize(
("arguments", "expected"),
[
({"path": "d/a.png"}, ["metadata", "--remove", "-o", str(Path("d/a_clean.png")), "--", "d/a.png"]),
({"path": "a.jpg", "mode": "visible"}, ["visible", "-o", "a_clean.jpg", "--", "a.jpg"]),
({"path": "a.png", "mode": "all", "output": "o.png"}, ["all", "-o", "o.png", "--", "a.png"]),
({"path": "c.mov"}, ["video", "metadata", "--remove", "-o", "c_clean.mov", "--", "c.mov"]),
({"path": "c.mp4", "mode": "visible"}, ["video", "visible", "-o", "c_clean.mp4", "--", "c.mp4"]),
],
)
def test_remove_maps_to_the_cli_and_always_names_an_output(arguments: dict, expected: list[str]) -> None:
runner = _Recorder()
result = mcp_server.call_tool("remove", arguments, runner)
assert runner.calls == [expected]
_assert_the_cli_accepts(expected)
assert result["structuredContent"]["output"] == expected[expected.index("-o") + 1]
@pytest.mark.parametrize(
("arguments", "expected"),
[
({"directory": "in"}, ["batch", "--mode", "metadata", "-o", str(Path("in_clean").resolve()), "--", "in"]),
(
{"directory": "in", "media": "video", "mode": "visible", "output_dir": "out"},
["video", "batch", "--mode", "visible", "-o", "out", "--", "in"],
),
],
)
def test_batch_maps_to_the_cli(arguments: dict, expected: list[str]) -> None:
runner = _Recorder()
result = mcp_server.call_tool("batch", arguments, runner)
assert runner.calls == [expected]
assert result["structuredContent"]["output"] == expected[expected.index("-o") + 1]
_assert_the_cli_accepts(expected)
@pytest.mark.parametrize(
("tool", "arguments"),
[
("remove", {"path": "a.png", "output": "./a.png"}),
("remove", {"path": "a.png", "mode": "invisible"}),
("remove", {"path": ""}),
("remove", {}),
("identify", {"path": "a.png", "check_visible": "no"}),
("identify", {"path": "a.png", "shell": "rm -rf /"}),
("batch", {"directory": "in", "output_dir": "in"}),
],
)
def test_invalid_arguments_are_refused_before_anything_runs(tool: str, arguments: dict) -> None:
runner = _Recorder()
result = mcp_server.call_tool(tool, arguments, runner)
assert runner.calls == []
assert result["isError"] is True
@pytest.mark.parametrize(
("returncode", "stderr", "is_error"),
[
(0, "", False),
(2, "", False),
(1, "Error: boom", True),
# Click's own usage errors also exit 2; they are failures, not a no-signal verdict.
(2, "Usage: cli metadata [OPTIONS] SOURCE\nError: Invalid value for 'SOURCE'", True),
],
)
def test_only_failures_are_tool_errors(returncode: int, stderr: str, is_error: bool) -> None:
runner = _Recorder(returncode=returncode, stdout="table", stderr=stderr)
result = mcp_server.call_tool("remove", {"path": "a.png"}, runner)
assert result["isError"] is is_error
assert result["structuredContent"]["exit_code"] == returncode
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"]
def test_serve_answers_line_by_line_and_reports_parse_errors() -> None:
stdin = io.StringIO(
json.dumps(_request("ping", request_id=7))
+ "\n\nnot json\n"
+ json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"})
+ "\n"
)
stdout = io.StringIO()
mcp_server.serve(stdin, stdout, _Recorder())
replies = [json.loads(line) for line in stdout.getvalue().splitlines()]
assert replies == [
{"jsonrpc": "2.0", "id": 7, "result": {}},
{"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}},
]
def test_the_cli_subcommand_serves_real_commands_over_stdio(tmp_path: Path) -> None:
"""End to end through the installed CLI: every stdout line must be a protocol message."""
output = tmp_path / "clean.mp4"
messages = [
_request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}}, 1),
{"jsonrpc": "2.0", "method": "notifications/initialized"},
_request("tools/call", {"name": "identify", "arguments": {"path": str(KLING_MP4), "check_visible": False}}, 2),
_request("tools/call", {"name": "remove", "arguments": {"path": str(KLING_MP4), "output": str(output)}}, 3),
]
completed = subprocess.run( # noqa: S603
SERVER,
input="".join(json.dumps(message) + "\n" for message in messages),
capture_output=True,
text=True,
check=True,
timeout=300,
)
replies = {reply["id"]: reply for reply in map(json.loads, completed.stdout.splitlines())}
assert sorted(replies) == [1, 2, 3]
report = replies[2]["result"]["structuredContent"]["report"]
assert report["platform"] == "Kuaishou Kling AI"
assert "kling_sei" in report["metadata_markers"]
assert replies[3]["result"]["isError"] is False
assert KLING_SEI_TAG not in output.read_bytes()
def test_the_official_sdk_client_connects_and_calls_a_tool() -> None:
"""The reference client probes server/discover first, then falls back to initialize."""
anyio = pytest.importorskip("anyio")
mcp = pytest.importorskip("mcp")
async def session() -> tuple[list[str], dict]:
params = mcp.StdioServerParameters(command=SERVER[0], args=SERVER[1:], cwd=str(ROOT))
async with mcp.Client(params) as client:
tools = await client.list_tools()
result = await client.call_tool("identify", {"path": str(KLING_MP4), "check_visible": False})
return [tool.name for tool in tools.tools], result.structured_content
names, structured = anyio.run(session)
assert names == ["identify", "remove", "batch"]
assert structured["report"]["platform"] == "Kuaishou Kling AI"
def test_registry_manifest_matches_the_package_and_the_readme_marker() -> None:
"""The registry launches `uvx <identifier>==<version> <packageArguments>` and verifies
ownership by the README's `mcp-name:` marker, so each of those must agree."""
from click.testing import CliRunner
from remove_ai_watermarks import __version__
from remove_ai_watermarks.cli import main
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
(package,) = manifest["packages"]
readme = (ROOT / "README.md").read_text(encoding="utf-8")
assert manifest["version"] == package["version"] == __version__
assert package["identifier"] == "remove-ai-watermarks"
assert re.search(rf"mcp-name: {re.escape(manifest['name'])}(?=\s|-->|<)", readme)
args = [argument["value"] for argument in package["packageArguments"]]
assert CliRunner().invoke(main, [*args, "--help"]).exit_code == 0
+105 -14
View File
@@ -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
+2 -2
View File
@@ -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."""
+83 -16
View File
@@ -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,7 +444,7 @@ def test_run_web_batch_requires_explicit_upload_acknowledgement(
web.run_web_batch(manifest_path, acknowledge_uploads=False)
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,
) -> None:
@@ -405,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)
+105 -2
View File
@@ -23,6 +23,7 @@ def test_catalog_covers_every_project_vendor_oracle_surface() -> None:
"microsoft-web",
"openai-api",
"openai-web",
"synthid-web",
}
assert {"google", "meta", "microsoft", "openai"} == oracles.PROVIDER_KEYS
assert oracles.SURFACES["openai-api"].automation == "api"
@@ -34,6 +35,15 @@ def test_catalog_covers_every_project_vendor_oracle_surface() -> None:
"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."
)
synthid = oracles.SURFACES["synthid-web"]
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")
assert synthid.project_evidence == ()
assert synthid.prompt is None
assert "Google DeepMind, Nvidia, OpenAI, and Kakao" in synthid.clean_result_note
def test_default_slot_config_is_repository_local() -> None:
@@ -52,6 +62,7 @@ def test_list_json_exposes_scope_and_surface() -> None:
"meta-web",
"microsoft-web",
"openai-web",
"synthid-web",
]
assert next(row for row in payload if row["key"] == "meta-web")["signal_family"] == "content_seal"
@@ -59,7 +70,7 @@ def test_list_json_exposes_scope_and_surface() -> None:
@pytest.mark.parametrize(
("provider", "expected"),
[
("google", ["gemini-web"]),
("google", ["gemini-web", "synthid-web"]),
("meta", ["meta-web"]),
("microsoft", ["microsoft-api", "microsoft-web"]),
("openai", ["openai-api", "openai-web"]),
@@ -69,6 +80,17 @@ def test_provider_plan_prefers_api_before_web(provider: str, expected: list[str]
assert [surface.key for surface in oracles.provider_plan(provider)] == expected
def test_plan_json_keeps_every_google_web_surface_primary() -> None:
result = CliRunner().invoke(oracles.cli, ["plan", "google", "--json"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert [(row["key"], row["preference"], row["url"]) for row in payload] == [
("gemini-web", "primary", "https://gemini.google.com/"),
("synthid-web", "primary", "https://synthid.com/"),
]
def test_plan_json_marks_web_as_an_explicit_fallback_when_api_exists() -> None:
result = CliRunner().invoke(oracles.cli, ["plan", "openai", "--json"])
@@ -80,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(
@@ -119,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",
},
],
}
),
@@ -132,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"
@@ -171,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)
@@ -255,11 +327,42 @@ def test_slots_override_requires_a_named_slot(tmp_path: Path) -> None:
},
"only openai-api uses api_key_env",
),
(
{
"name": "synthid-with-profile",
"surface": "synthid-web",
"account_label": None,
"browser_profile": "Profile 2",
"network_label": "home",
},
"only a real-browser surface uses a browser_profile",
),
(
{
"name": "synthid-with-account-index",
"surface": "synthid-web",
"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(
tmp_path: Path,
slot: dict[str, str | None],
slot: dict[str, str | int | None],
message: str,
) -> None:
config = tmp_path / "slots.json"
+58
View File
@@ -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"}
+1 -1
View File
@@ -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):
+121 -5
View File
@@ -17,12 +17,13 @@ from click.testing import CliRunner
from PIL import Image, ImageDraw, ImageFont
from remove_ai_watermarks.cli import main
from remove_ai_watermarks.metadata import C2PA_UUID
from remove_ai_watermarks.metadata import C2PA_UUID, KLING_SEI_TAG
if TYPE_CHECKING:
from collections.abc import Callable
_PROVENANCE_FIXTURES = Path(__file__).resolve().parents[1] / "data/fixtures/provenance"
_MP4_FTYP = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom"
_VIDEO_PAYLOAD = b"synthetic-video-payload"
_TC260_AIGC = (
@@ -36,6 +37,17 @@ def _box(box_type: bytes, payload: bytes) -> bytes:
return (8 + len(payload)).to_bytes(4, "big") + box_type + payload
# The H.264 NAL exactly as Kling writes it into the first sample: a 4-byte length,
# an SEI header, payload type 5 (user_data_unregistered) of 25 bytes, the tag, a
# NUL and the RBSP trailing bits.
_KLING_SEI_NAL = b"\x00\x00\x00\x1d\x06\x05\x19" + KLING_SEI_TAG + b"\x00\x80"
def _video_with_kling_sei(path: Path) -> Path:
path.write_bytes(_MP4_FTYP + _box(b"mdat", _KLING_SEI_NAL + _VIDEO_PAYLOAD) + _box(b"moov", b""))
return path
def _video_with_c2pa(path: Path) -> Path:
manifest = C2PA_UUID + b"OpenAI trainedAlgorithmicMedia c2pa.watermarked.unbound"
path.write_bytes(_MP4_FTYP + _box(b"uuid", manifest) + _box(b"mdat", _VIDEO_PAYLOAD))
@@ -644,6 +656,108 @@ class TestVideoMetadataApi:
assert _VIDEO_PAYLOAD in output.read_bytes()
assert C2PA_UUID not in output.read_bytes()
@pytest.mark.parametrize(
"name",
sorted(path.name for path in _PROVENANCE_FIXTURES.glob("*.mp4")),
)
def test_no_flagged_metadata_value_survives_in_the_output_bytes(self, tmp_path: Path, name: str):
# The re-scan reads only the head window it was built for. Each flagged value
# is also searched for byte for byte, so an untouched copy the re-scan does not
# reach cannot survive while `remaining` reports clean.
from remove_ai_watermarks._internal.isobmff import _metadata_items
from remove_ai_watermarks.metadata import (
app_generator_from_metadata,
app_provenance_from_metadata,
parse_tc260_aigc_json,
)
from remove_ai_watermarks.video import remove_video_metadata
source = _PROVENANCE_FIXTURES / name
data = source.read_bytes()
flagged = [
value
for _key, _span, _start, _end, value in _metadata_items(io.BytesIO(data), len(data))
if app_provenance_from_metadata(value)
or app_generator_from_metadata(value)
or parse_tc260_aigc_json(value) is not None
]
result = remove_video_metadata(source, tmp_path / name)
assert bool(flagged) == bool({"aigc_label", "app_provenance"} & set(result.detected))
assert (KLING_SEI_TAG in data) == ("kling_sei" in result.detected)
assert result.remaining == {}
cleaned = result.output.read_bytes()
assert len(cleaned) == len(data)
assert C2PA_UUID not in cleaned
assert KLING_SEI_TAG not in cleaned
assert [value for value in flagged if value in cleaned] == []
def test_kling_sei_tag_alone_identifies_kling_and_is_blanked_in_place(self, tmp_path: Path):
from remove_ai_watermarks.video import identify_video, inspect_video_metadata, remove_video_metadata
source = _video_with_kling_sei(tmp_path / "source.mp4")
assert "kling_sei" in inspect_video_metadata(source).markers
report = identify_video(source, check_visible=False)
assert report.is_ai_generated is True
assert report.platform == "Kuaishou Kling AI"
result = remove_video_metadata(source, tmp_path / "clean.mp4")
cleaned = result.output.read_bytes()
assert result.remaining == {}
assert len(cleaned) == source.stat().st_size
assert KLING_SEI_TAG not in cleaned
# Only the tag bytes change: the NAL length, the SEI header and payload size,
# the trailing bits and the coded payload keep the stream parseable.
assert _KLING_SEI_NAL.replace(KLING_SEI_TAG, b" " * len(KLING_SEI_TAG)) + _VIDEO_PAYLOAD in cleaned
def test_kling_sei_tag_is_blanked_past_the_scan_window_and_across_read_chunks(self, tmp_path: Path):
from remove_ai_watermarks._internal.isobmff import _STREAM_COPY_BYTES
from remove_ai_watermarks.video import remove_video_metadata
# The first copy straddles the copy loop's first read boundary; the second
# sits past it and past the 1 MiB detection window.
header = len(_MP4_FTYP) + 8
lead = _STREAM_COPY_BYTES - header - len(_KLING_SEI_NAL) // 2
payload = b"\x11" * lead + _KLING_SEI_NAL + b"\x11" * 1024 + _KLING_SEI_NAL + _VIDEO_PAYLOAD
source = tmp_path / "source.mp4"
source.write_bytes(_MP4_FTYP + _box(b"mdat", payload) + _box(b"moov", b""))
tag_offset = source.read_bytes().index(KLING_SEI_TAG)
assert tag_offset < _STREAM_COPY_BYTES < tag_offset + len(KLING_SEI_TAG)
result = remove_video_metadata(source, tmp_path / "clean.mp4")
cleaned = result.output.read_bytes()
assert len(cleaned) == source.stat().st_size
assert cleaned.count(b" " * len(KLING_SEI_TAG)) == 2
assert KLING_SEI_TAG not in cleaned
@pytest.mark.parametrize("name", ["higgsfield-kling-3-0-turbo.mp4", "higgsfield-cinema-studio-v2.mp4"])
def test_blanking_the_kling_sei_tag_leaves_every_decoded_frame_unchanged(self, tmp_path: Path, name: str):
from remove_ai_watermarks.video import remove_video_metadata
ffmpeg, _ffprobe = _ffmpeg_test_tools()
source = _PROVENANCE_FIXTURES / name
assert KLING_SEI_TAG in source.read_bytes()
result = remove_video_metadata(source, tmp_path / name)
def frame_hashes(path: Path) -> list[str]:
completed = subprocess.run( # noqa: S603
[ffmpeg, "-v", "error", "-xerror", "-i", str(path), "-map", "0:v:0", "-f", "framemd5", "-"],
check=True,
capture_output=True,
)
assert completed.stderr == b""
return [line for line in completed.stdout.decode().splitlines() if not line.startswith("#")]
assert KLING_SEI_TAG not in result.output.read_bytes()
before = frame_hashes(source)
assert before
assert frame_hashes(result.output) == before
def test_default_output_preserves_source(self, tmp_path: Path):
from remove_ai_watermarks.video import remove_video_metadata
@@ -720,13 +834,15 @@ class TestVideoMetadataApi:
cleaned = output.read_bytes()
assert result.detected["aigc_label"].startswith("China AIGC label")
# The artwork item's Doubao product JSON is app-export provenance, a
# separate signal the TC260 blanker does not touch -- as on the real
# Doubao iOS export this fixture mirrors.
assert result.remaining == {"app_provenance": "App export provenance (ByteDance Doubao)"}
# The artwork item's Doubao product JSON is app-export provenance, which
# the stripper blanks beside the TC260 label, as on the real Doubao iOS
# export this fixture mirrors.
assert result.detected["app_provenance"] == "App export provenance (ByteDance Doubao)"
assert result.remaining == {}
assert len(cleaned) == source.stat().st_size
assert _VIDEO_PAYLOAD in cleaned
assert _TC260_AIGC not in cleaned
assert b'"product":"doubao"' not in cleaned
def test_inspects_native_tc260_in_quicktime_mdir_metadata_list(self, tmp_path: Path):
# The second Doubao iOS variant: a QuickTime metadata list under
Generated
+513 -233
View File
@@ -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]]
@@ -824,7 +825,7 @@ name = "cuda-bindings"
version = "13.4.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "cuda-pathfinder" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/3a/06569edfd5db020c1ff7c0361268a3eaa60ead0c0ccca460fef69d68022f/cuda_bindings-13.4.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df8b3767facca8acde216460df684dfc3826d519096c13adfd3030a55870dc79", size = 6494098, upload-time = "2026-09-23T02:21:50.472Z" },
@@ -857,48 +858,48 @@ wheels = [
[package.optional-dependencies]
cublas = [
{ name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cudart = [
{ name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cufft = [
{ name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cufile = [
{ name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cupti = [
{ name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
curand = [
{ name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cusolver = [
{ name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cusparse = [
{ name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
nvjitlink = [
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
nvrtc = [
{ name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
nvtx = [
{ name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
[[package]]
name = "diffsynth"
version = "2.1.8"
version = "2.1.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "accelerate" },
@@ -913,18 +914,17 @@ dependencies = [
{ name = "torchvision" },
{ name = "transformers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/75/60/ef2ccc2d1c5261827ba1663cc9da6fd8065ebdcdedd40c1daae7eeda9081/diffsynth-2.1.8.tar.gz", hash = "sha256:dfd589bdeb62fac8d5fc362f3690419359db79e19e630c26c892f8fabc772eff", size = 832784, upload-time = "2026-09-20T09:29:27.763Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/d4/afa9ff593b85f0977ec4b8b7c6fca8e76e6204ffbd77c37a80c56a421641/diffsynth-2.1.9.tar.gz", hash = "sha256:c5ac42765d780ab0429591007ebd8c1e6a5f7c1655597531f126be1964d331d2", size = 837407, upload-time = "2026-10-09T08:05:56.862Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/97/f82efc26c69e519cd914f18f5010183e6d0d667018f87e990dc427bc61b3/diffsynth-2.1.8-py3-none-any.whl", hash = "sha256:c314e1773581d1c91c43a5e37c2e371251b39939fe287603f95704fcbe0a276a", size = 924369, upload-time = "2026-09-20T09:29:25.93Z" },
{ url = "https://files.pythonhosted.org/packages/16/91/d394550f1622c69259c413ec7ead80f9e407373933571d605e084b917472/diffsynth-2.1.9-py3-none-any.whl", hash = "sha256:be98adf71f1d42ede52fc699330c23bc7ceaa5d72283aeb87cfc4a330869e5d0", size = 929921, upload-time = "2026-10-09T08:05:54.578Z" },
]
[[package]]
name = "diffusers"
version = "0.40.0"
version = "0.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "httpx" },
{ name = "huggingface-hub" },
{ name = "importlib-metadata" },
{ name = "numpy" },
@@ -933,9 +933,9 @@ dependencies = [
{ name = "requests" },
{ name = "safetensors" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/32/b9c6a198812e2a7d2c2828a850dba384306f9af285eac23e35e89d5b008a/diffusers-0.40.0.tar.gz", hash = "sha256:49f112ce52ff6d332ab68afec01f79a53e6489b42cb69104444c98b4a2f64af8", size = 4886420, upload-time = "2026-08-20T14:30:40.923Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a8/bc/34a91d366fe8e546637d702403e4d8cd5dbdf705a15fb3725d34a7ab3416/diffusers-0.41.0.tar.gz", hash = "sha256:69dcced7230b6bc0b70b5c6cfdff1aa62da487ab52ae74b642e46b1b9bfacc47", size = 5026281, upload-time = "2026-10-06T06:24:14.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/df/ffb593ebed2a068d2d6be44261283f39a6b809c0fcdfdcafbd448cbeec77/diffusers-0.40.0-py3-none-any.whl", hash = "sha256:5b5da7c3ddb62152fa4afc577f02e050af688c797375c34fb2d01006da3f3541", size = 5911654, upload-time = "2026-08-20T14:30:38.422Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c4/5bf8afd1ff3a8d99da7a18b1067ae0b4e2a037c38f3851890a989c511fd3/diffusers-0.41.0-py3-none-any.whl", hash = "sha256:ea8918b7dfd92ce793b6db689a551b8cd25d52c6c0fcd3ade0706a5fd2a25990", size = 6063887, upload-time = "2026-10-06T06:24:12.455Z" },
]
[[package]]
@@ -1221,8 +1221,8 @@ name = "httpcore2"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11", marker = "sys_platform != 'emscripten'" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" }
wheels = [
@@ -1476,6 +1476,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "lightning"
version = "2.6.6"
@@ -1595,6 +1622,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mcp"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx2" },
{ name = "jsonschema" },
{ name = "mcp-types" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" },
]
[[package]]
name = "mcp-types"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -1899,7 +1964,7 @@ name = "nvidia-cublas"
version = "13.1.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cuda-nvrtc" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
@@ -1938,7 +2003,7 @@ name = "nvidia-cudnn-cu13"
version = "9.24.0.43"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cublas" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/30/7c257e3d5cb4fecb147b93895c66e29c93f8e76d74b45bb418ff0587c4ec/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:a6812a554a1ff0413e9c52b84c26c050380649ab9615f9c16bded368ce9f421f", size = 650976863, upload-time = "2026-07-02T16:23:39.248Z" },
@@ -1950,7 +2015,7 @@ name = "nvidia-cufft"
version = "12.0.0.61"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
@@ -1980,9 +2045,9 @@ name = "nvidia-cusolver"
version = "12.0.4.66"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cusparse", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cublas" },
{ name = "nvidia-cusparse" },
{ name = "nvidia-nvjitlink" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
@@ -1994,7 +2059,7 @@ name = "nvidia-cusparse"
version = "12.6.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
@@ -2061,7 +2126,7 @@ wheels = [
[[package]]
name = "onnxruntime"
version = "1.30.0"
version = "1.31.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flatbuffers" },
@@ -2070,35 +2135,35 @@ dependencies = [
{ name = "protobuf" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/b6/e2fa6dfc8c3cd19639d452c3120fb0e02f5176b4e24ecabb24b5362be725/onnxruntime-1.30.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f36c87ec504f4f328e968ee322e4ba9682abe66280b3425625afcbed5333fed7", size = 21533037, upload-time = "2026-09-10T16:31:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/4f/1a/4d197d2676a866f6239cd2d06a95b6db40d47dc29d5bec9fb1493a13dd94/onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bac3aee7b40acd805659fd81a2acd6096312d5b0f014c97ddfe75fb4c1644627", size = 21332697, upload-time = "2026-09-10T16:31:08.118Z" },
{ url = "https://files.pythonhosted.org/packages/4f/2e/4c96278a99140d0307ccda6fd51e6e9d1ca7c9f6fdd1fa96ff375acfc713/onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fd54b314ea385bcecac69ab431f020ba503e3878dad4ebb645fec5a24b041242", size = 23561046, upload-time = "2026-09-10T16:31:11.494Z" },
{ url = "https://files.pythonhosted.org/packages/66/68/b8aaade8c1883f569228f4b57201cce922bb7d4af6f977d619520a733430/onnxruntime-1.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:0edd0145a6e3fce8a1276491dc784d615e3c58bcb952c9b4e5c876d5c6a12ad7", size = 14309136, upload-time = "2026-09-10T16:31:14.181Z" },
{ url = "https://files.pythonhosted.org/packages/96/f1/fbda6fb08ac4db6c5c6f7c3e128e4d8fb398b52fe2fc3282c45cadedd18b/onnxruntime-1.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:16506cfa5d218850f2b46e0edce1b952121f2f84f8e0074dafe15f4787f0c8fb", size = 14167408, upload-time = "2026-09-10T16:31:16.505Z" },
{ url = "https://files.pythonhosted.org/packages/31/6f/48169f2e62b405bff5053cbd1d73fb5ce41ef7ecd13bb3bfcc191e689b8a/onnxruntime-1.30.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:001ed726c9bd5e2bc92faade7d37d889e9606a350b7d5529f0227df2e3bb57fd", size = 21544867, upload-time = "2026-09-10T16:31:19.876Z" },
{ url = "https://files.pythonhosted.org/packages/16/bd/cbc5b8f91963689fdd622f463508c01d0aa95d3f944747b1e0b1eb2160b8/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6c32a000d5139a38ba9349030b0032e3331acb559d596b22738d9d2b343a2b83", size = 21345202, upload-time = "2026-09-10T16:31:23.361Z" },
{ url = "https://files.pythonhosted.org/packages/34/35/e7f862dbacbc99fadd9b14a614e49c99bf0f35fd9927a82f096e3de33531/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fa688e7891a6aa206636fe7372e27ee75fd17713289f6b4fc7b190e0a7de9328", size = 23585654, upload-time = "2026-09-10T16:31:26.65Z" },
{ url = "https://files.pythonhosted.org/packages/a6/13/0f1699f6de549c9324bc9112a2a85b14c517904cd11b562a654643b755a1/onnxruntime-1.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3501472571f1b1eee50e017851e7929f5ea37312d2d8c2494a19e8fc58b4a38", size = 14311470, upload-time = "2026-09-10T16:31:31.273Z" },
{ url = "https://files.pythonhosted.org/packages/a2/17/02b13e5f51461f0453b18ab854e2d0bc1b6ec353241b05a2ee6b79e35d87/onnxruntime-1.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc4c706f1935ebb62356e6a095b047859badd854482c40560888e95c328ed262", size = 14175072, upload-time = "2026-09-10T16:31:34.541Z" },
{ url = "https://files.pythonhosted.org/packages/f0/75/508454c5d01f31641dabc597fe559594c931a520a2673031179319d0afd8/onnxruntime-1.30.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:05e4fc41711d1f4abd19a9124b5be7a65a506cb2670a7f14b16162ef13c58134", size = 21544990, upload-time = "2026-09-10T16:31:37.685Z" },
{ url = "https://files.pythonhosted.org/packages/89/06/e603c71f43f4fe3fd156a053af79cbed6e27a2c649f0988a67d97fedd39f/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5327cf6aa15a02bad805fac8bd6882a62571e8b72f6f2938a8f37e6bd1966ce9", size = 21344996, upload-time = "2026-09-10T16:31:40.915Z" },
{ url = "https://files.pythonhosted.org/packages/f1/a1/ede48ab5dc54907a2999362777f541e132639fb06628ded1932058aa8a36/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f", size = 23585560, upload-time = "2026-09-10T16:31:44.941Z" },
{ url = "https://files.pythonhosted.org/packages/3c/dd/c57c529dbc6dd55eca24b12cfbeab1b6a690de72083824eca689085f55b0/onnxruntime-1.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:4b63041bd623a9a9ac5e353948436c6fa7f43edd12d6b4a4ebc340bca959ba93", size = 14311378, upload-time = "2026-09-10T16:31:48.034Z" },
{ url = "https://files.pythonhosted.org/packages/11/2f/ef00b45b911e7f2115273a24ed1b43e73c8c6c9ec17f9bfa13f8492581f8/onnxruntime-1.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:c389b6887fc95e0fcb80e89b156bc2cb18e662c29df55e9326fe64140e7d7b4f", size = 14175134, upload-time = "2026-09-10T16:31:50.799Z" },
{ url = "https://files.pythonhosted.org/packages/92/0a/284fd6fe701c9a8aff39dbc119c37582ca40e9cff1ea05425a7cc8606a02/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5224ba2b00284cb1c48b3edcd303c109245d66df9ec1b861858fd6de672a38e", size = 21349453, upload-time = "2026-09-10T16:31:53.898Z" },
{ url = "https://files.pythonhosted.org/packages/d6/72/4f4466f8fa1ec267a9ef5e2f3bd175c203d3da0facbdf4049dc93abbe91c/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3f9e002417f1e3bbb31ed43dafa4f22ca1b2b68832244fdee192fcaa1ae19bca", size = 23579837, upload-time = "2026-09-10T16:31:56.82Z" },
{ url = "https://files.pythonhosted.org/packages/6a/03/05c9a9234688757d2876ddecf80bba908561ee11debf125bc1a427ae6f48/onnxruntime-1.30.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8b6169c16a48429890d2f4a0c774ebf54dfe9066a998514aad0518a16d398547", size = 21545313, upload-time = "2026-09-10T16:31:59.654Z" },
{ url = "https://files.pythonhosted.org/packages/c6/bc/1069e58b24779ba9d2fd479db5ecb3a15a6f49b585107c898819c0789558/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d2184fddb6798136e7c478244391ca82443f5c757f59f15bb9e5ad2da5e03175", size = 21346830, upload-time = "2026-09-10T16:32:02.275Z" },
{ url = "https://files.pythonhosted.org/packages/f1/38/8138eed225c5bc6ddfc05879ecac7dacc63c34b9b6f99be72839c1f6dc49/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:8b611d24db2954545ce6bd9acd4670183cb368e4642450de7a9ab6474eb374ec", size = 23586333, upload-time = "2026-09-10T16:32:04.996Z" },
{ url = "https://files.pythonhosted.org/packages/4d/0a/748b86000fbf9518b5dfc5bdc1b924eeaca976203740fd9d30649e568ee6/onnxruntime-1.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bdd1752a8502ac1a7ccc6e878d16db6943df574d54ed7f01058a6806ae05be4", size = 14675319, upload-time = "2026-09-10T16:32:07.5Z" },
{ url = "https://files.pythonhosted.org/packages/2f/db/db8e4c0cf6f70f1311060560630fd21763648e01dfbba5195a2592c052ef/onnxruntime-1.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:83d543843cbd352cfa9996a6c7b92f8a480f114a18f95ff2a1acaf6921e85b6d", size = 14569360, upload-time = "2026-09-10T16:32:09.896Z" },
{ url = "https://files.pythonhosted.org/packages/24/0a/ec0a9d656e39b43887c378a5388b20c3b1e1ee43bded84580b0936466694/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:265de607ba6f9814264e1d5d413fa7069d70f48e3049b5e460b3b04bdfcef294", size = 21348433, upload-time = "2026-09-10T16:32:12.772Z" },
{ url = "https://files.pythonhosted.org/packages/91/f0/40f74b7c00077e1e25627067ed98a70df1fef5c0e21b82849190d312554e/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:67ad7f03433b6462c627d0f555dece80e6a26bc71e8542ced35cebd32142d1b7", size = 23579340, upload-time = "2026-09-10T16:32:15.532Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e7/61b2768393646bd12e31eeb71958193f4e02c98c4980cf9289d19bbb4a8f/onnxruntime-1.31.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cbf1a7f6470ddfe9dbc781966af8ce4a10e1858d75a93f93cc6b9367c9587870", size = 20871717, upload-time = "2026-10-09T04:18:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/e57025ab9c1eb83b6e686c92507fa6b7156d9d375e197a6c3a2afc05a1e2/onnxruntime-1.31.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:37c7dfe398550afdf9670a29315dbb88e49d8afc473ffaf1f410376efbb9c80a", size = 21413529, upload-time = "2026-10-09T04:18:06.493Z" },
{ url = "https://files.pythonhosted.org/packages/a6/72/6c57163b63b5343853d7f0619c4f424a6e53ee762d7263667ff004bfede1/onnxruntime-1.31.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d4092b78fc5bab77ce6522393098cdb2535423045ecdcff15cc0d022162d6b66", size = 23753636, upload-time = "2026-10-09T04:18:09.974Z" },
{ url = "https://files.pythonhosted.org/packages/37/de/6cab7e39917cc87728d2f00abe97c81fe86b29f9e1f758627864c28f0c21/onnxruntime-1.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:317608967b03807ed4661113b08293fac02a1db6496a6863a07d9f19232936ad", size = 14885750, upload-time = "2026-10-09T04:18:13.004Z" },
{ url = "https://files.pythonhosted.org/packages/1d/11/f335a124a1aadda99e5a2b618264606504bd9e3763b1b2486e6441cd65e5/onnxruntime-1.31.0-cp311-cp311-win_arm64.whl", hash = "sha256:e85c1632c0a8cf488bd8f1039f5320877b864c8f9ebd4122fb8bb909f83b7096", size = 14735138, upload-time = "2026-10-09T04:18:15.895Z" },
{ url = "https://files.pythonhosted.org/packages/b3/bd/2ac094311163b803e3626c3937461d6900934bd56cca7601f6150ff860c3/onnxruntime-1.31.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:aaab9b3af536b06ca27ab5e35e3d429c97457ce76cf298af103f687e8b9975c0", size = 20882054, upload-time = "2026-10-09T04:18:18.811Z" },
{ url = "https://files.pythonhosted.org/packages/53/1a/561b43ca1536d9e81d1785bb8a1a260a9e314ef6d04976ba0411c652bda1/onnxruntime-1.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:35758d7606d578ec5b9d65f6e8a1f488013194c3f6097038a3223cb26d35ef9a", size = 21420804, upload-time = "2026-10-09T04:18:21.729Z" },
{ url = "https://files.pythonhosted.org/packages/6c/44/1e9e762b95b7da0a8424913a1ed7c38cdaf88624a3c41ddba24ebac88bc9/onnxruntime-1.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5e129d6c56abd53e659cb70f00a108d6824086470ff99c2e47a82e5786563db3", size = 23760984, upload-time = "2026-10-09T04:18:24.61Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/b12cea136ccd7b03d924f46b8393faf7ceac21115c0c50e729faa248cf23/onnxruntime-1.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:09d56445c1753e66e0912de69d3f0184016ad9a191dcd6925bf5dd570d2bfbe5", size = 14888841, upload-time = "2026-10-09T04:18:27.62Z" },
{ url = "https://files.pythonhosted.org/packages/02/ad/37bbc51dcb5cd105c5b2fe98f122b23e90171c2719516964edc65bb1d4cc/onnxruntime-1.31.0-cp312-cp312-win_arm64.whl", hash = "sha256:5c54a0eb7b2b4eef3eb9dcfaf82f5ce880db07288dc309574f6657e9da5cc754", size = 14740604, upload-time = "2026-10-09T04:18:30.399Z" },
{ url = "https://files.pythonhosted.org/packages/e0/2b/117f94d73a3bac4276c285c47e384e1b3ea67b191aa4c7592df9d3f4a136/onnxruntime-1.31.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0ba02a44acb6203040354d9a1f160e3f37a43feac7bb05caa3e0ea545efed505", size = 20881803, upload-time = "2026-10-09T04:18:33.62Z" },
{ url = "https://files.pythonhosted.org/packages/8a/d0/3677fe93ec0fa3c637744aa4c3ae6ef89a93ee229cd3c5157820f267c7bd/onnxruntime-1.31.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ad663106f6eeff3d454f24a786450459d07f30e74863851104fc1b8b3f368127", size = 21420629, upload-time = "2026-10-09T04:18:36.731Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ac/67ebbaab4b3083f2a6b27ee6c4aa400c7f8d6c72b5499aac7e4cd6ba74f5/onnxruntime-1.31.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:37fd78cee5160c7a43a1730ccb3682ffd880af9c9e80385d625c0c2f8b125809", size = 23760708, upload-time = "2026-10-09T04:18:40.883Z" },
{ url = "https://files.pythonhosted.org/packages/c4/86/05ed2056f43b27aaf12ebc592ebd9037a26bed315958cf882f43425fd469/onnxruntime-1.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:73e0165d58ece068c2a8a1c477c90b38e5a8adbbd399fdfdfd4bd79cbc28ff8d", size = 14888306, upload-time = "2026-10-09T04:18:43.722Z" },
{ url = "https://files.pythonhosted.org/packages/c9/93/d33bae7b1a78780c4946ce03989c59a67d42d7015ad62d2098975fc5a580/onnxruntime-1.31.0-cp313-cp313-win_arm64.whl", hash = "sha256:e51d10d2e2e1e5bbf9b126a0cd9853d3e6c4e21424518dd50160b91471be33dc", size = 14740892, upload-time = "2026-10-09T04:18:46.338Z" },
{ url = "https://files.pythonhosted.org/packages/12/05/cf44f7642269b285aada4b662c4662b14ac63f6e03e129d939c4a956a0f5/onnxruntime-1.31.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e0e050bf9ec754950a6ba9830e4032f4004d972c6f38c5642fef26d44d894965", size = 21432644, upload-time = "2026-10-09T04:18:48.925Z" },
{ url = "https://files.pythonhosted.org/packages/b5/8e/673315b2dd2eb99b2f4774d7a5986fe00d933ebed17ee72c441f579226e6/onnxruntime-1.31.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:e93d7c5fad20afa697ac16f376fd0306ed180f9a376e86106cc0b7d84f53ef87", size = 23773868, upload-time = "2026-10-09T04:18:51.776Z" },
{ url = "https://files.pythonhosted.org/packages/9d/fb/b4c52e500c6f3d00dfc22fad4d7513524f3ea2100a24a077ee3b0daf552d/onnxruntime-1.31.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:278e0dc922ec69b05a28f59110d5421e2ec8b1d0dd46c6b10c063069a4051e72", size = 20883462, upload-time = "2026-10-09T04:18:54.978Z" },
{ url = "https://files.pythonhosted.org/packages/37/fb/8be04665b700cb6e874d944e9932bb3c3969d3f53e820f5c42bfd26565d0/onnxruntime-1.31.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:984c0a2c1ad6a41fbc101dc3949abe4a72254892d01a5e70d9b792711e0bfa54", size = 21421618, upload-time = "2026-10-09T04:18:58.1Z" },
{ url = "https://files.pythonhosted.org/packages/30/2e/5c6ec7e26a097e97ee70f2dee68b8ca4d9d26701f2f33c3f8ab585cb89fe/onnxruntime-1.31.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e4efa4a1a0bb0b5173c6a3292c181d518b8323f9d56e978635d0c09d38c94d1a", size = 23762993, upload-time = "2026-10-09T04:19:01.236Z" },
{ url = "https://files.pythonhosted.org/packages/6a/66/0bf4fdb9f58efa69cf4eddde24c72aebcc628d6ff1d67c9546145c6b9922/onnxruntime-1.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:83e3dbcf6abc6189c4bdf7d329c07ba1133c88172134c266d84b4409aa3b9dbf", size = 15268709, upload-time = "2026-10-09T04:19:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/af/99/75a36172c1ed1d74ac0e91c11d642548081e2c9c63f15ee796564619556f/onnxruntime-1.31.0-cp314-cp314-win_arm64.whl", hash = "sha256:d2d5ac22f896c810be2b2b171392bb908f80b6c9a7e2d592ddb7435c928044e1", size = 15153795, upload-time = "2026-10-09T04:19:06.609Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ec/23b7749edc7aad53bf4632de190399fda69a9195499426637ef1b02f06c6/onnxruntime-1.31.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d25cd65874b75fdf16149120a04d0cd4551f860a3c8e2ecec785a1903e41d8aa", size = 21432344, upload-time = "2026-10-09T04:19:09.646Z" },
{ url = "https://files.pythonhosted.org/packages/f2/76/155ab0b265e9ceade28a8dd3858fdfa509b039f78010042c875940e32e58/onnxruntime-1.31.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1ecc1450af28d2cf362990e188ccc81b51388f317f641ad973ab4301473200f2", size = 23772576, upload-time = "2026-10-09T04:19:12.731Z" },
]
[[package]]
name = "openai"
version = "3.24.0"
version = "3.29.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -2108,9 +2173,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/73/4f/e57670227cb7b61362d8f9bfba54d4e9f7bde799798c342782788bc12d6c/openai-3.24.0.tar.gz", hash = "sha256:1e7463f7d78773ab2ce4fe85710481aa5bd5ffefd54c8de4b067506cd2d42895", size = 1797218, upload-time = "2026-10-02T15:55:18.851Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/99/5f6206494a87edbaa67b35c5212a2cb5e6f414bee5efb0f0672c4704315a/openai-3.29.0.tar.gz", hash = "sha256:bc70a46d97b70ba64daedfcb3227f610a395f297ec68d39f735676190a92be94", size = 1828914, upload-time = "2026-10-11T17:39:41.895Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/01/32baec3bdb83d36da27281d7eeac2647241345cf8ff538bc21d1013ddeec/openai-3.24.0-py3-none-any.whl", hash = "sha256:b59703a3e3b5df78b269e90049d383a1dda58c90104cc4be59db21078db4b7a4", size = 2127166, upload-time = "2026-10-02T15:55:16.228Z" },
{ url = "https://files.pythonhosted.org/packages/ef/9a/91285d01a9293d0b3d86167f9c7bd13f08c960d3fa365782751ce9bcbfb1/openai-3.29.0-py3-none-any.whl", hash = "sha256:860d3bad424c5d38cd9ba0a0a2050d558b58ce027596a39f9ccbc0f51a2090b7", size = 2173181, upload-time = "2026-10-11T17:39:39.493Z" },
]
[[package]]
@@ -2168,6 +2233,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/88/763b967f7efd7226b82c9fae16d560cba049b1f0c036647e65c610fd636e/opencv_python_headless-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e", size = 43825962, upload-time = "2026-07-02T05:50:09.627Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.45.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
]
[[package]]
name = "opt-einsum"
version = "3.3.0"
@@ -2422,58 +2499,58 @@ wheels = [
[[package]]
name = "pillow-heif"
version = "1.8.0"
version = "1.8.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/4c/d5319a1f276c70528ff97893afc42a300ff28029e27ca8de89bb3b271680/pillow_heif-1.8.0.tar.gz", hash = "sha256:e47c27432c6fd3d66c22f0de9f27fd379383b646c947520bc485854ce72060d0", size = 17395353, upload-time = "2026-09-22T10:04:17.964Z" }
sdist = { url = "https://files.pythonhosted.org/packages/44/c1/82145984920ca055675af2c2795bd30da6f7461215c41f3c1eacb3d66353/pillow_heif-1.8.1.tar.gz", hash = "sha256:521ebffb8a181d56c3904e5a61f20903edee0d9d3275967b8fb345f866215c06", size = 17395786, upload-time = "2026-10-11T13:18:19.2Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/ea/89eebdb47b602766cdef5087821c7ace911f0e83b405f07c34faa46670b8/pillow_heif-1.8.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:f2234a6fea91bcdfecae2effd553307973a77628aa9914151650cf20f5fa5c87", size = 4781069, upload-time = "2026-09-22T10:02:29.876Z" },
{ url = "https://files.pythonhosted.org/packages/8d/54/a0a944b3c98fcc7c7b67d5b996699a2c5cc420cc102540855dd119473469/pillow_heif-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6e8705dc410bcb184cc15e75182ca51875e2466f5d0e203ee551475ed2776852", size = 4301120, upload-time = "2026-09-22T10:02:31.605Z" },
{ url = "https://files.pythonhosted.org/packages/13/29/52e82d2739506032c0603db5836062851e57d150338a77137d59b0e66e1e/pillow_heif-1.8.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffcd347a4903db4081a4416ea2ad9332e3d8db6eedd9df83ead5ea1a53d1e9a1", size = 6396229, upload-time = "2026-09-22T10:02:33.39Z" },
{ url = "https://files.pythonhosted.org/packages/b8/93/6a88ea7b16820d0387965783b9deacf77d04ef25a6b66fead711e091e962/pillow_heif-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:741115092cd6922d4ca5db801455617aaca2e52d17ec293852bd67357964c861", size = 5652683, upload-time = "2026-09-22T10:02:35.312Z" },
{ url = "https://files.pythonhosted.org/packages/0c/be/34635a594f403e0bfc390b46e521e04e6dc21b2cb22b5ce3a1cbf6f9fdf0/pillow_heif-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47c30f761a02af398da7568b480cccd0297f8a231cf333ae7d21c48d9e79d8e1", size = 7428674, upload-time = "2026-09-22T10:02:37.182Z" },
{ url = "https://files.pythonhosted.org/packages/59/0d/9e147be430ecb162561adceae54783b3b65178feb399cadb9a3a1e52478a/pillow_heif-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b771d311675f0093a0f09efd58b856fdf4a9358b0ae49049c09dc3c3957db3dd", size = 6687746, upload-time = "2026-09-22T10:02:39.363Z" },
{ url = "https://files.pythonhosted.org/packages/92/39/97b3810ece5f8835eeb27fae6692229f13e5374b7e4f1c22a42ad311effe/pillow_heif-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:5d4678f8c20773c6f41a8a940ac0fc869b8ec7d8b8aba82063fdee9259b81c2c", size = 6604322, upload-time = "2026-09-22T10:02:41.326Z" },
{ url = "https://files.pythonhosted.org/packages/a8/57/654c05e15e78ed1cdef49249f42528afcd69e0c59223c1e729d867f24273/pillow_heif-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:cf550adf3c3076fa046844e62a9a9d0a91e3178c041841bdfbe14d842c03094a", size = 3868144, upload-time = "2026-09-22T10:02:43.143Z" },
{ url = "https://files.pythonhosted.org/packages/b6/80/4b57a70a1aa4e24932bfa7e8e56842356ddc13ce32b6fe4729c1050b0d83/pillow_heif-1.8.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:e9bf19f4163f50e52e374ac04d9084756322ed7ef2ac756c2f3ca08cc43e5c1b", size = 4781341, upload-time = "2026-09-22T10:02:45.173Z" },
{ url = "https://files.pythonhosted.org/packages/91/5c/4e37a10f2ac2a1806b879f13c81f1fdc33ba5742d5b48e1b3808e9cdf21a/pillow_heif-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dbd020b4d8a6730f46fb0815efaca3eebbea36ddd030fb507aae081713179830", size = 4303432, upload-time = "2026-09-22T10:02:47.044Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ef/f8b2dbbd515301573c3020fea4571c73f47651d8dbbe4cf4b7407dc4f2e2/pillow_heif-1.8.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec6610ea28ee151458dde715346cbed90ef74feb6d7c354b318b4c4a560fdb1c", size = 6394460, upload-time = "2026-09-22T10:02:48.931Z" },
{ url = "https://files.pythonhosted.org/packages/84/ab/c99d29c5311df50074d0fee3ee80bae004b200b4e89b149a275ce4d504cf/pillow_heif-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9088a295f3c64f88211c4b1d845d883cf535e8919a72a17f7860abe8c991bf2", size = 5652099, upload-time = "2026-09-22T10:02:50.705Z" },
{ url = "https://files.pythonhosted.org/packages/7e/c0/c4bf60e58f9bda582e2ce6365de350901a01ed67c22f450eb61b1080d066/pillow_heif-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6116981fdccce5f43fabfd8fac884c0fff9fde9601d00887a70af1220b1a91b1", size = 7427104, upload-time = "2026-09-22T10:02:52.526Z" },
{ url = "https://files.pythonhosted.org/packages/27/4e/f6cb52ff29889ba743b5cf0fc65de68f191dbc3c2837ec755e310d43e9dd/pillow_heif-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8cd4043b07966fc464fb95df41f584d3e804c26115e118165fc57c8fc890cdd", size = 6686998, upload-time = "2026-09-22T10:02:54.11Z" },
{ url = "https://files.pythonhosted.org/packages/a9/5e/97c0e837b787316ae8020e6f17134aa24e22b16027440406d02f9be7f99f/pillow_heif-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:921784d39b5e3b9bcbfcf96ff05e78bc5a15ced5d171cf4e3987d06c04da71bf", size = 6604433, upload-time = "2026-09-22T10:02:55.855Z" },
{ url = "https://files.pythonhosted.org/packages/f6/54/7b01ef1b72046d7ba2246036fe954f86cb1011bec2b77e26c9bcc9722e4a/pillow_heif-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:80b8dbf31c92aaffc25b2a1a9e86722fa6d6085f3db0e6003ad5bc0fb11905e7", size = 3868206, upload-time = "2026-09-22T10:02:57.421Z" },
{ url = "https://files.pythonhosted.org/packages/63/9f/2c601980b4cacc1cb44dfb9f8db88c67dc14adb77d544186f9cde8ca70e5/pillow_heif-1.8.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:01aeb134dbd99a6b2cbadffd312693c29dff6413c98ac6000674100b827c09ae", size = 4781323, upload-time = "2026-09-22T10:02:59.006Z" },
{ url = "https://files.pythonhosted.org/packages/99/11/e1aa6d072d4778821d6cb81763dd1993527d4f2062be701360fe4d9f853c/pillow_heif-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec5ff22dac945f169d04b18b870f369e439370ba919c0f15ea3a37ac40048fb7", size = 4303427, upload-time = "2026-09-22T10:03:00.491Z" },
{ url = "https://files.pythonhosted.org/packages/11/7c/b5f71083cfbeae902bcf615062c51e8795aa7e1feeedea663521a510227e/pillow_heif-1.8.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34d2cae783350949fb204d29ce85e50d6059c4efbf310fe473585a78f2790add", size = 6394506, upload-time = "2026-09-22T10:03:02.252Z" },
{ url = "https://files.pythonhosted.org/packages/a6/bb/e48ff21447a2b213eaaad90f67e473c59396d33a8ed8c227b24deeebc31d/pillow_heif-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06db8d10f000438ba605fbd12f4dffed4b792afe9c923cf4c589d04025fcc6b4", size = 5652131, upload-time = "2026-09-22T10:03:04.143Z" },
{ url = "https://files.pythonhosted.org/packages/d8/3e/cf7104e89572eee71549f7898aefa28fd396922d41939d4af8bbcd11f388/pillow_heif-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6ca3739b5ad2cfb1b6a1cb631a7b21c4f6219aeb95c883be5b540e6d29d1ca4b", size = 7427115, upload-time = "2026-09-22T10:03:05.997Z" },
{ url = "https://files.pythonhosted.org/packages/d9/88/3abaf871d71d92e27ff59eb95f69669886108478fa955d4306664a67e451/pillow_heif-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f150de06a4387644df54fb3a6162264825b99cbabfd804ca3154fb867a35ab16", size = 6687026, upload-time = "2026-09-22T10:03:07.839Z" },
{ url = "https://files.pythonhosted.org/packages/71/77/fa70118bafb15584e49cba1af544d5be6756b5bfaca76ce25d42481bf4c2/pillow_heif-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:70161d9963702d94bbaa31b2e08e43f6fb3baffccfac8c9ec1d702f6ff620cde", size = 6604416, upload-time = "2026-09-22T10:03:09.443Z" },
{ url = "https://files.pythonhosted.org/packages/11/bd/a30b115ea07917a0b668ef315c7e54c7e34a966107869529f6253ac529fa/pillow_heif-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:18057aa9b02d2f47e7dbe8145d90b12754e2fd37679cfce983c135cd9e1bda24", size = 3868201, upload-time = "2026-09-22T10:03:11.124Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e9/3e992d1c4fb08fc265424797ef0c0457213fbf34249ff8fed5df3e28ecce/pillow_heif-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f3d4b3e6558d9ab5a23abdebe38af568e59345c6f6d5fb2c8b6e6276a53650b9", size = 4781307, upload-time = "2026-09-22T10:03:12.812Z" },
{ url = "https://files.pythonhosted.org/packages/6b/d3/aac4e3138c35d00350c22d5bbdd0d78c657ab06b5e87432df679b0ba13b3/pillow_heif-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d953c39095d98584b7c60696b4bf25be561ef43a9441c44349e322b8f8a3233e", size = 4303432, upload-time = "2026-09-22T10:03:14.755Z" },
{ url = "https://files.pythonhosted.org/packages/13/40/84dbb73ad887d0f53db3ed7434915cbc1826eec3199c187872f80ace141d/pillow_heif-1.8.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2900d250563dbc0a61e5803f9f1e60f52cdd85ea521c2c8047027e12cc7b5a7b", size = 6394653, upload-time = "2026-09-22T10:03:16.434Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/1d7077b80eb6767151a04f215856f0fdc3e666c24475fe3ecf6bc3cc4955/pillow_heif-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f9d5fc0f32f73bc916c0c7554353b72cc66b92edd00e1942d27a6b5119f283d", size = 5652225, upload-time = "2026-09-22T10:03:18.539Z" },
{ url = "https://files.pythonhosted.org/packages/51/c9/928fbc1a85641126d32bbef4339c834dfd719f8b1f0ec47477625b38b11a/pillow_heif-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a39ff1a09711e52a84401ec02fc297953fd180b285ac1631406311db50204bff", size = 7427281, upload-time = "2026-09-22T10:03:20.322Z" },
{ url = "https://files.pythonhosted.org/packages/75/06/9dbb1f3a9dccf08904abe0a493e0ff115f6bb26a649a047b78be70a7a16d/pillow_heif-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e92eafa786bd9f1b2a230088bddca6b30f1091ca8bc1434be0e5b63453587ad", size = 6687101, upload-time = "2026-09-22T10:03:22.172Z" },
{ url = "https://files.pythonhosted.org/packages/6e/a6/79861065ea4719817e0a4a3d41160393d41643378d1b5a939012aaec695e/pillow_heif-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:257c81a049f6d11b2289908fbeb9affbae9adba943fec1bb6ce0926083d5fad6", size = 6783029, upload-time = "2026-09-22T10:03:25.233Z" },
{ url = "https://files.pythonhosted.org/packages/76/75/f8c7be15103372268eed37f4750bd62ea2d6a2b8b2b51a585ff367128c28/pillow_heif-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:8410beea2767b9e37bf058f7bb810c35ee6bcbdefbce5e7249c6d935f03af200", size = 4075726, upload-time = "2026-09-22T10:03:27.161Z" },
{ url = "https://files.pythonhosted.org/packages/79/02/37aec3c09ec421c8d8685822ed660743751a47acd9d23c4fa47efcbb19d3/pillow_heif-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f448c9c46a28876a7908a4f70e79403d02b229e611dc64217a54ba5e86e5c7b3", size = 4782464, upload-time = "2026-09-22T10:03:28.796Z" },
{ url = "https://files.pythonhosted.org/packages/35/bb/aa1a4a9b38d798dde0fa368b15fe0b2687510d78b7c1280157737d428403/pillow_heif-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e46cae4b19f86f31264433db892ad048d6162d3989a842472f802d8494ffb333", size = 4304481, upload-time = "2026-09-22T10:03:30.32Z" },
{ url = "https://files.pythonhosted.org/packages/47/b9/39a9442c231792057f2e6dc1ed5a3c79e6a12c3dbef3a6bb6b892c0abe21/pillow_heif-1.8.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb137d199489f0ee2d91f2ba7f86a930b3aef5e3bd72da83e19ad5004c8eb0ee", size = 6401415, upload-time = "2026-09-22T10:03:31.939Z" },
{ url = "https://files.pythonhosted.org/packages/1f/50/c989dd1cf9b232b009126f40fa36f930d86101b04ee213f52aba1970abc0/pillow_heif-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e83a66adf542cb3a266e01bda6b258bdda5f3efbe5ddc084f179efcc9e10b2f9", size = 5658002, upload-time = "2026-09-22T10:03:33.661Z" },
{ url = "https://files.pythonhosted.org/packages/70/27/e42b37fb0ceb19bc1d499298c49bcbecf0037ca5f241410f811aefa49319/pillow_heif-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5111663af679fbda0066c0232fcff232f8e6263d256737bff38f88f3809963c0", size = 7433458, upload-time = "2026-09-22T10:03:35.259Z" },
{ url = "https://files.pythonhosted.org/packages/43/4b/f66299f8b5770e9295d19aad4ae16b0d783cbcd8ab4ce8e598d437f2777d/pillow_heif-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:344a427a173807f7b244305991f12a306d3a58c321fdb4e833d69158e9e52666", size = 6692660, upload-time = "2026-09-22T10:03:36.884Z" },
{ url = "https://files.pythonhosted.org/packages/dc/ca/37e30b0e1636508c251a349912b2e5b0ae11d91bc053dcbaa602547caff1/pillow_heif-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:45c012add5e9dc2d9b1671ba51bdb851971f069303b4e545b5971818c1ff0b0e", size = 6784356, upload-time = "2026-09-22T10:03:38.47Z" },
{ url = "https://files.pythonhosted.org/packages/da/f5/571482d6f8eee388e8f879d962fc3d2daeff847ff06aedbb4750c7a37404/pillow_heif-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:303aa4d336bb64cf3885910cb9014f31db8ba4f11f89d731fb9ba13930256e44", size = 4076205, upload-time = "2026-09-22T10:03:40.221Z" },
{ url = "https://files.pythonhosted.org/packages/e5/c0/27c7ac7c63bd9df8aaa88af6d2dd93d519dd678b132882750b130f279447/pillow_heif-1.8.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:862f827e9ce838a842f8180ee7a4d22674e643460592418c9b8b618ba43768be", size = 4768687, upload-time = "2026-09-22T10:04:09.222Z" },
{ url = "https://files.pythonhosted.org/packages/e3/10/7862ca75487ad3f7de4bba5e72031c051158c1c3e07f1a1711a82fdba175/pillow_heif-1.8.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:a816c28e37d15aea2196c11eb4683d243fb43a42caf02a34f546711e27b04a65", size = 4299993, upload-time = "2026-09-22T10:04:10.824Z" },
{ url = "https://files.pythonhosted.org/packages/cd/25/b94f02e8d70ba8af3ee54beeaa4985009aa04ffb7e9c2ff53d1fd4866483/pillow_heif-1.8.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9b7ac59dde01d100c1185e2f0348a467c259a0825edd829bd80d1f1303b12ed", size = 6343951, upload-time = "2026-09-22T10:04:12.418Z" },
{ url = "https://files.pythonhosted.org/packages/3d/c8/0b44106c76c5aa9bf11e142dfc83ca34771d9f5d7d6d7f56e83c21e3cf10/pillow_heif-1.8.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9795724a63ccff65d43bc108beb797ee4c7eb7f5f470be83836dd198a78059f7", size = 5597916, upload-time = "2026-09-22T10:04:14.336Z" },
{ url = "https://files.pythonhosted.org/packages/ad/bb/62c2472624989c029da8d5eae2a8098119c2aa423d6995a7d54cd2c2f539/pillow_heif-1.8.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fec91ca160e4f393e56a7fa1b63ee14ea6ded82d86bfe6d2ecb2b7bc04bb1af5", size = 6604603, upload-time = "2026-09-22T10:04:16.032Z" },
{ url = "https://files.pythonhosted.org/packages/85/4d/dd392467616bb618a168e3475268e12a9e6f7a709baede13c13d40de8ac3/pillow_heif-1.8.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:a36557e0959f680582b6de5046e84f61d6cde5f9db4cd60086dc3d4434e29816", size = 4815367, upload-time = "2026-10-11T11:16:24.519Z" },
{ url = "https://files.pythonhosted.org/packages/ac/17/4488241f4f348b08b48891ff06d624b72ad095ca0a3c09727f4ce8f7d609/pillow_heif-1.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:961a0298ede61a7eb559c095662c90a9e567984cfc006527b8b902034388c609", size = 4309206, upload-time = "2026-10-11T11:16:26.326Z" },
{ url = "https://files.pythonhosted.org/packages/23/2d/1f9b3a0795283c30586528b3eb1e810a087a3493b58e9c67931e7e179019/pillow_heif-1.8.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446b58aae154e4a084124d383317fed1cc869ae402d1acea91c377ad18da0a6b", size = 6420056, upload-time = "2026-10-11T11:16:28.121Z" },
{ url = "https://files.pythonhosted.org/packages/40/63/ad16ea9d8c3d3568b10de38896ba5787a3b84c1af8ec15d2c524ba19d940/pillow_heif-1.8.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a94f02ccb61042820e9fc60b2a427d85377c6017d27b7594d33f26b1c78918e5", size = 5709404, upload-time = "2026-10-11T11:16:29.793Z" },
{ url = "https://files.pythonhosted.org/packages/85/3f/54bf4f5421ef74e7ebb7a2b37428be16bc8b0681741114cfd04799009a84/pillow_heif-1.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72bd9d8c3f037ed3e4833dad5cfd3e45720a688b465a28df81c7586fb17c786b", size = 7454223, upload-time = "2026-10-11T11:16:31.667Z" },
{ url = "https://files.pythonhosted.org/packages/93/42/663e4cbeae8832ceb595daf4edc0c2506e9a7a223d5b157a98d6809dfd97/pillow_heif-1.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca20c0ce72d2884011b642ae57ad1305cfd0bf80c3c07ebdf140cf8e5dd7102", size = 6745450, upload-time = "2026-10-11T11:16:33.571Z" },
{ url = "https://files.pythonhosted.org/packages/92/a0/1b9febe5d16652972d5fb5c1463a610acc1906f0cf0f58f90fe1dc13f1fb/pillow_heif-1.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:9d9e1034a5d6a8ccea5a950545583d82c0c249bd68f8825bbc91436d652a170c", size = 6603993, upload-time = "2026-10-11T11:16:35.521Z" },
{ url = "https://files.pythonhosted.org/packages/a7/2a/73a7fe34d77bfb08360923ced0778968d49d854be38b09d8913b5d3e72fa/pillow_heif-1.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:950cbad44494253b539c10620a0b36e5e0ab4900f58038abc166b5e04cc2f9d2", size = 3872457, upload-time = "2026-10-11T11:16:37.651Z" },
{ url = "https://files.pythonhosted.org/packages/f9/21/276668287678aad18c8fff15146b4965067c477358dbd6250e4ee08d7ff6/pillow_heif-1.8.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:a8e7edf5d30cf10a3d062c28d4ff19baf7e4e0a3c20fb5e4e63d690d67b0bbd4", size = 4815635, upload-time = "2026-10-11T11:16:39.416Z" },
{ url = "https://files.pythonhosted.org/packages/16/a2/53ad321b6d202cd159be3914bccb0eabaa48fa7b4fc630feb31323eccb9d/pillow_heif-1.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1c60f323daf9df728858e469e0d95010727a32ee3e6c8e9658809a070fb93f69", size = 4311517, upload-time = "2026-10-11T11:16:41.16Z" },
{ url = "https://files.pythonhosted.org/packages/d9/36/a9f5728e5d5078e7b5d9dee041c3ffeb23ff24a4e9f13af4d2555d4e2018/pillow_heif-1.8.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a36caeeb3e3ce12a3492aa8ab52d08393601303fa9b8b1bb807bef32b1edb505", size = 6418283, upload-time = "2026-10-11T11:16:42.735Z" },
{ url = "https://files.pythonhosted.org/packages/19/77/d5508d73a2ec0d422b396dc5110e58fe8c928096b62cdf8cfdf9e29c9906/pillow_heif-1.8.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3811fa95ad29d6abd37a72c88c8c682dd1ff41d51fddf4899255328bfccbe358", size = 5708816, upload-time = "2026-10-11T11:16:44.436Z" },
{ url = "https://files.pythonhosted.org/packages/7b/e2/16fa61109f48848e18da28cecc70647af992c7d9acebd265c4fffc5f7e06/pillow_heif-1.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a719a475c761fe2834346a1e9f127b322bd14ed88f347360e82fd9766ff06a2", size = 7452650, upload-time = "2026-10-11T11:16:46.172Z" },
{ url = "https://files.pythonhosted.org/packages/9f/6f/a4800d1ad35d30e90266c4b5c5678c61ad6ae004190b30e910b05866044c/pillow_heif-1.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16c26d51ee36a0f6ab1b611d4f33539c48639b7f2020e474030641b018d15a73", size = 6744713, upload-time = "2026-10-11T11:16:47.881Z" },
{ url = "https://files.pythonhosted.org/packages/db/fd/2ff579be4694ac68cc73bfaafe1abc255bd658b678bfb3b33922784ddaf0/pillow_heif-1.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ce0ff957ad901a5a6bf8cd22ea26c4304bab7cf2f93d0a2f03046487e5711910", size = 6604108, upload-time = "2026-10-11T11:16:50.267Z" },
{ url = "https://files.pythonhosted.org/packages/1a/65/1edfab7623dd3370727cd65311a944004b27a03da20bcf92e4d98d7d4d98/pillow_heif-1.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:5decc7420988ed48d7e6f4b1440225897fc7c477ded77523d6f6a3b3d31c6683", size = 3872590, upload-time = "2026-10-11T11:16:51.876Z" },
{ url = "https://files.pythonhosted.org/packages/8a/3a/6d395d48eca2914c8cc9b38d589c3e2c61e33ca531e3a7514dd359be85fb/pillow_heif-1.8.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:05cc2b14203cdb9d0a1f44d47657fa2d2bf12f6fff8d2e2873c2a1d837198aa9", size = 4815623, upload-time = "2026-10-11T11:16:53.725Z" },
{ url = "https://files.pythonhosted.org/packages/29/96/4170d91441cbb3336dbe02155b57c0004b2516a40538f7aae8c0b8af497d/pillow_heif-1.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98c500475f3add0d2ac4a6686b925c22fd0cf05def1ce977fec8ec753dabd66a", size = 4311510, upload-time = "2026-10-11T11:16:55.452Z" },
{ url = "https://files.pythonhosted.org/packages/4e/32/42afbf4ab79ae8973a1210648e1a0a4a6dee35853223d7f534ffc2154545/pillow_heif-1.8.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ac80def387aaee029733c4292bab551b397128da5abd889fe13c0626a1cc1ce", size = 6418323, upload-time = "2026-10-11T11:16:57.45Z" },
{ url = "https://files.pythonhosted.org/packages/62/1e/32b8a70a253ac5c805e65b89c94ad404fbaf0af602499b1cf0f85fbf28f6/pillow_heif-1.8.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1f60ee05d1280f98c00a052829963e57790dce0ca8203828658b14f8c0cf7b", size = 5708847, upload-time = "2026-10-11T11:16:59.512Z" },
{ url = "https://files.pythonhosted.org/packages/0e/be/cf3f1fa1f2fd4d7cdcc54804e8b21b9141c641d92304dd609cc70fe5da8e/pillow_heif-1.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b45c673d53f4e147d784567b3581475fa98730f0da415aad6bf230d22eeda6ce", size = 7452665, upload-time = "2026-10-11T11:17:01.54Z" },
{ url = "https://files.pythonhosted.org/packages/d9/32/5f6895c1ac788658214f8e787017a740b5b3437f7d35411363b5c038431c/pillow_heif-1.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:74107d65386616a8165f90b2055b4b5265472c4f6bdf107895539c6408dc6180", size = 6744731, upload-time = "2026-10-11T11:17:03.399Z" },
{ url = "https://files.pythonhosted.org/packages/37/b5/42eda6f5a7894276592c2b499caad152b057f62b4e1dabab26d808cd0c71/pillow_heif-1.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:f2110c6f9ec02efecf52a979addaf5734770e55ca29705ce0c3f0e588db5e6b5", size = 6604096, upload-time = "2026-10-11T11:17:05.4Z" },
{ url = "https://files.pythonhosted.org/packages/dc/b7/083f29901b7cbb4f23bb431335f48d7d574f7982c7b5e82372d18130390c/pillow_heif-1.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b572832c06c7dfa5339ed592aea506b68b380a15f78308929d9af37c5aa9c2f", size = 3872589, upload-time = "2026-10-11T11:17:07.371Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b0/070e0d04126acf4d474a143f2f321c65be393ff07898a87a57e3cc649f74/pillow_heif-1.8.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4fc68f850786864725b27da222596da55f2563f8e2eb73ec365f69a0dbe4fe8f", size = 4815603, upload-time = "2026-10-11T11:17:09.078Z" },
{ url = "https://files.pythonhosted.org/packages/fd/40/8793c9b7570391f6693d31af032d32d4ea6909b3f48b219fbd22863c0d90/pillow_heif-1.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:88d842a8d917c8311c34e55c6f9e9bb30f5d6032e5be8b6f477c7966374fae0f", size = 4311516, upload-time = "2026-10-11T11:17:10.634Z" },
{ url = "https://files.pythonhosted.org/packages/e9/93/d339a7215abb0db8fb7edeb5ebd41cbdab7209d34e973bd24ed54e33a4d1/pillow_heif-1.8.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ba18074ad0bd4eb115544b902412c4526ff1a991a89f2951a04d7af40ba8e5a", size = 6418471, upload-time = "2026-10-11T11:17:12.643Z" },
{ url = "https://files.pythonhosted.org/packages/51/5a/0b3961c9a0bd7f54c65aa8cf06ac2ff806850d9d14fae78a3835148488b9/pillow_heif-1.8.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6045ef6f9bd7107713b95c8b1ac02418fee08f5b116a9e3cd1e11a5d95007f38", size = 5708943, upload-time = "2026-10-11T11:17:14.438Z" },
{ url = "https://files.pythonhosted.org/packages/bb/c0/0707295f509e66a2422448fe417a8c003310d78dc71859f875b817fb7323/pillow_heif-1.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68928b1c35bbb6dc3f0ada5c537b6448ec09ecd9cde04480555098d9b1838f88", size = 7452835, upload-time = "2026-10-11T11:17:16.208Z" },
{ url = "https://files.pythonhosted.org/packages/6d/2b/68eedb42a77ac57a7893a5407b1d0fd79293c1a559a66728e0abcb339ed5/pillow_heif-1.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:543aa8df3bdef47795fc9de5c870a935d35dddbc56e8011c2f36d1fb6862d563", size = 6744807, upload-time = "2026-10-11T11:17:18.22Z" },
{ url = "https://files.pythonhosted.org/packages/89/06/be02e0307ebb6772d94f6347729f979457669c6b868a83caaa8b736c5425/pillow_heif-1.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:c583f2c08aa08848e7b97f4b416f5dce9f485182fd55efd39edba10f092ee651", size = 6781849, upload-time = "2026-10-11T11:17:20.352Z" },
{ url = "https://files.pythonhosted.org/packages/09/2a/8eb282bc1c0d6701ca3cd9a8730428251a6982f496d628658807d5b63f40/pillow_heif-1.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:c59d5c311e202fd868279cbdbca8f4ba8ce5970a6264f3f1fc96799ab8d3f80e", size = 4084734, upload-time = "2026-10-11T11:17:22.093Z" },
{ url = "https://files.pythonhosted.org/packages/f1/09/cabbe6a6c09a7457df8b842245a03bb1bf4c1ac4619e7eeefc335ad3551f/pillow_heif-1.8.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc8f3b859611cb0397d79c91d4b0c27c4288026c381d6302b53c2b4da61aaee1", size = 4816756, upload-time = "2026-10-11T11:17:24.152Z" },
{ url = "https://files.pythonhosted.org/packages/2d/61/15d9343a0f72289cb9a10f09da1d7687d120fd02ee5f71d961b6e2027914/pillow_heif-1.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad8258511bffd62b5d55f8203cf06d01dfb257b6f900f1272d3bdae4b353d259", size = 4312563, upload-time = "2026-10-11T11:17:25.849Z" },
{ url = "https://files.pythonhosted.org/packages/b8/db/4ce0f37b77f7bb70b3e145ef1a49d246d08680aa49bfb35ed82950e503e6/pillow_heif-1.8.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0674a79dbcfe445b33aaf1eec69216832d179f715d10c786404ea2d9e32404e8", size = 6425235, upload-time = "2026-10-11T11:17:27.632Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f8/8c37988e87c31bc3f58af466f79183961624358f287f7a9f40e132d63d29/pillow_heif-1.8.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5f0f81b98fb175298aa5ea0b6da4a9651e497fa9cb145ceb5e4d493eb25d36a", size = 5714716, upload-time = "2026-10-11T11:17:29.363Z" },
{ url = "https://files.pythonhosted.org/packages/90/8d/4f5ba5d8a1e2d35d7827ac94b974e9851535d3c02f035e48f8637d42910f/pillow_heif-1.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6261359e4d9920b12d5c3a3cf7fb07cced2feb05816982ab3106364f8e1c8618", size = 7459010, upload-time = "2026-10-11T11:17:31.367Z" },
{ url = "https://files.pythonhosted.org/packages/22/7c/84456729f6c21fb6ff9b083600260ea53df194004d5ae03e5eaf58316538/pillow_heif-1.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dff0c92e1387ea5a24c1a40a90074a507a18645fabfb1479746d3340535ca047", size = 6750371, upload-time = "2026-10-11T11:17:33.633Z" },
{ url = "https://files.pythonhosted.org/packages/27/33/a5f6ffb9c0a58b2dec1c2d156153153af8af285d58d8717321f93a9b2f15/pillow_heif-1.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4de12a61358c419309457c296d735561e0c66ee88de6fd9392f1f41637174e29", size = 6783183, upload-time = "2026-10-11T11:17:36.401Z" },
{ url = "https://files.pythonhosted.org/packages/7d/1f/9e0dcbe9c34d161f7bf329b4d96ba576f741d35d82441e7d3ab919d8b881/pillow_heif-1.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0e3a55171379cda4f538ea15a1110d1c00d4bc532fb2c9083cd3bd355b6f1a48", size = 4085195, upload-time = "2026-10-11T11:17:38.132Z" },
{ url = "https://files.pythonhosted.org/packages/71/83/c85d945ea6676a06afb23ecb4f91829315f54a5ccd74c9e2f116f97f34bd/pillow_heif-1.8.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:15656f1b2d5260421210c48731332e8a30729381eef97d4d8b22df18382490de", size = 4802985, upload-time = "2026-10-11T13:18:08.513Z" },
{ url = "https://files.pythonhosted.org/packages/4c/7b/58f7c402ed71891a274698b5963690fe5a602ba62e6bb94906fd229863c9/pillow_heif-1.8.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:77ff9e899f094e06964aa1e52c9e80d089e699baf16b248d7fb898b2432a59d3", size = 4308083, upload-time = "2026-10-11T13:18:10.069Z" },
{ url = "https://files.pythonhosted.org/packages/ed/38/c47df37b9ccd731d9a9d7173dbe38a9ef7713dd8480c5c6504d3740961d9/pillow_heif-1.8.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:317c6317a5f22fb5cd5b651186b1669760e587ac8b3d55895c04355b0a4b56f4", size = 6367775, upload-time = "2026-10-11T13:18:11.696Z" },
{ url = "https://files.pythonhosted.org/packages/33/ad/67cde410707ef0d53717ddd92a305dfded755ac6f9eef1ea02c819612361/pillow_heif-1.8.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad4a201eebfb45f5c4217e62e835c27aed2788f9f252616a31346491060eec35", size = 5654632, upload-time = "2026-10-11T13:18:14.837Z" },
{ url = "https://files.pythonhosted.org/packages/c5/f9/ba8c637bbc8c3dc46f8a875efd910f8a072085e550c22b0faa7a3ffc161d/pillow_heif-1.8.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9307c857733908ea013cdc6fb08598440e6c3df0c48721b455a8b1dd137d14b5", size = 6604282, upload-time = "2026-10-11T13:18:17.227Z" },
]
[[package]]
@@ -2876,6 +2953,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pyjwt"
version = "2.15.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pypdfium2"
version = "5.13.0"
@@ -3072,6 +3163,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pytorch-lightning"
version = "2.6.6"
@@ -3103,7 +3203,7 @@ resolution-markers = [
"(python_full_version < '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
]
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.12'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/75/50581633d199812205ea8cdd0f6d52f12a624886b74bf1486335b67f01ff/pywavelets-1.9.0.tar.gz", hash = "sha256:148d12203377772bea452a59211d98649c8ee4a05eff019a9021853a36babdc8", size = 3938340, upload-time = "2025-08-04T16:20:04.978Z" }
wheels = [
@@ -3179,7 +3279,7 @@ resolution-markers = [
"(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
]
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.12'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/84/a9e80e33eb114e78bd2f1829a90d72d8d4d660109c721a8aa2f0d58d7592/pywavelets-1.10.0.tar.gz", hash = "sha256:f3cb8640225a8f3fdcbc3f6f3bd112c78927c9407cbfa65a5ff303990a6a3e32", size = 3948163, upload-time = "2026-09-07T17:15:15.637Z" }
wheels = [
@@ -3221,6 +3321,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/61/0375be8264b1b86b3182459bec94ac0d6ffba08c9b6a090840d4ba38d05b/pywavelets-1.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeb5073bb1859f7a911b275ab0fef1e41acede52fb1661331d74cb7f45ecbb4c", size = 4217922, upload-time = "2026-09-07T17:14:40.596Z" },
]
[[package]]
name = "pywin32"
version = "312"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" },
{ url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" },
{ url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" },
{ url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" },
{ url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" },
{ url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" },
{ url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" },
{ url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" },
{ url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" },
{ url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
{ url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
{ url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
@@ -3256,6 +3375,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "regex"
version = "2026.9.29"
@@ -3346,7 +3479,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.45.1"
version = "0.46.0"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },
@@ -3406,6 +3539,7 @@ dev = [
{ name = "httpx" },
{ name = "huggingface-hub" },
{ name = "invisible-watermark" },
{ name = "mcp" },
{ name = "numpy" },
{ name = "openai" },
{ name = "opencv-python-headless" },
@@ -3516,6 +3650,7 @@ requires-dist = [
{ name = "huggingface-hub", marker = "extra == 'source-classify'", specifier = ">=0.20.0,<2" },
{ name = "huggingface-hub", marker = "extra == 'text-draft'", specifier = ">=0.20.0,<2" },
{ name = "invisible-watermark", marker = "extra == 'dev'", specifier = ">=0.2.0" },
{ name = "mcp", marker = "extra == 'dev'", specifier = ">=2.3,<3" },
{ name = "numpy", marker = "python_full_version >= '3.13' and extra == 'pixels'", specifier = ">=2" },
{ name = "numpy", marker = "python_full_version < '3.13' and extra == 'pixels'", specifier = ">=1.24.0" },
{ name = "onnxruntime", marker = "extra == 'classify-onnx'", specifier = ">=1.24.0" },
@@ -3592,6 +3727,112 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "rpds-py"
version = "2026.9.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948, upload-time = "2026-10-04T16:32:36.469Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/6d/ddb8dfcf41892ba9c672d27988592c038d30db61708230313831b6780a1b/rpds_py-2026.9.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2", size = 344947, upload-time = "2026-10-04T16:28:55.207Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b2/632c3d034961149eff606e3e357547620a9ab37b849ae5815f67838753fd/rpds_py-2026.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044", size = 346770, upload-time = "2026-10-04T16:28:56.884Z" },
{ url = "https://files.pythonhosted.org/packages/6d/45/4df346410bb873d03ec60c3b891d1fceb8cabde1971d29844bbb625cc296/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa", size = 377795, upload-time = "2026-10-04T16:28:58.193Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b8/a70f302207e4f29f873c01a986fcac5def49b9c8224328cef1ad7da6b7a9/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52", size = 383137, upload-time = "2026-10-04T16:28:59.411Z" },
{ url = "https://files.pythonhosted.org/packages/13/e3/3aa28a48a01169d0e20728bf0e5ebbea3ae2774285b40853ae49b3ef332a/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f", size = 496789, upload-time = "2026-10-04T16:29:01.011Z" },
{ url = "https://files.pythonhosted.org/packages/3e/a7/414e8d088602d66ad6f47c65bf07fe5090992a8574cdd4571c1a83b647ed/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7", size = 395929, upload-time = "2026-10-04T16:29:02.465Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5c/aac3b960fd55d37b49f77c0eebbe043eaf36762ebd48e7b46951e59c1c05/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff", size = 375426, upload-time = "2026-10-04T16:29:03.944Z" },
{ url = "https://files.pythonhosted.org/packages/d3/80/77987a41c40be7202a78d9aed4a650bfb05792237cfd3b9e22638d990fe5/rpds_py-2026.9.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e", size = 381497, upload-time = "2026-10-04T16:29:05.308Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d0/640b02eb1ecd71a913c1178b6235287b31006266bca9a26862e80e497b54/rpds_py-2026.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888", size = 405740, upload-time = "2026-10-04T16:29:06.578Z" },
{ url = "https://files.pythonhosted.org/packages/3e/8c/d034195d45d215625a23f2ae85dbbdb86a49a93a51272061c97c80b9d316/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834", size = 555850, upload-time = "2026-10-04T16:29:08.115Z" },
{ url = "https://files.pythonhosted.org/packages/de/f8/532f1addfc86207bc3cf23749638bb0d457bce891595012ed8a08d0543b8/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391", size = 618661, upload-time = "2026-10-04T16:29:09.554Z" },
{ url = "https://files.pythonhosted.org/packages/50/5b/b0f2c9833a36a5b1f6ac5cb0e635fabacf713266f46ceefba2102da0bf51/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a", size = 583530, upload-time = "2026-10-04T16:29:11.072Z" },
{ url = "https://files.pythonhosted.org/packages/bf/20/464ed4b98d3c8c6a334f3b5e24d3886de04b05658a95fc40a37e0e6e4775/rpds_py-2026.9.1-cp311-cp311-win32.whl", hash = "sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1", size = 208125, upload-time = "2026-10-04T16:29:12.408Z" },
{ url = "https://files.pythonhosted.org/packages/df/c9/384a9b62f8cd89c8e3e609b8c9052c140e8c01d807261e0517c904b8bf29/rpds_py-2026.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f", size = 226045, upload-time = "2026-10-04T16:29:13.751Z" },
{ url = "https://files.pythonhosted.org/packages/4f/3b/0a691f0dcb2301fa9c736346da2d0d4991778373283301c5c12b8f952f3d/rpds_py-2026.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12", size = 222636, upload-time = "2026-10-04T16:29:14.989Z" },
{ url = "https://files.pythonhosted.org/packages/5d/34/a828586ea3329fbb50895b50e9cf98ca3d924a9f41a0b26d443bff1b3794/rpds_py-2026.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af", size = 346822, upload-time = "2026-10-04T16:29:16.258Z" },
{ url = "https://files.pythonhosted.org/packages/90/81/ac6a0d064982251856ce009c9e1dd51a34110b3c055aa7d1aad18b4899a3/rpds_py-2026.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b", size = 341380, upload-time = "2026-10-04T16:29:17.706Z" },
{ url = "https://files.pythonhosted.org/packages/42/ff/bf7d54f362748fd6a49277b9d6531c394074110fedf50ad791ec59133fbb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a", size = 373053, upload-time = "2026-10-04T16:29:19.063Z" },
{ url = "https://files.pythonhosted.org/packages/60/de/74b0ccdbbd28687b9b5fdb34c1cabed88352182facaced9d6f5486b8b9ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07", size = 377742, upload-time = "2026-10-04T16:29:20.543Z" },
{ url = "https://files.pythonhosted.org/packages/c2/6d/b979775a3057b2a5c26d75ecbff60a20a24ef081db6dd76836fca3f20247/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69", size = 487289, upload-time = "2026-10-04T16:29:21.958Z" },
{ url = "https://files.pythonhosted.org/packages/1d/5d/7c34734ce3ece943d9d6ee0122a74a21bbc75b47acb5b7053c83f6efb1ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d", size = 390710, upload-time = "2026-10-04T16:29:23.378Z" },
{ url = "https://files.pythonhosted.org/packages/1d/6d/b26eb1e75395925b3a142ed351cbed2ec8212f5c9d937aee3df32c701baa/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab", size = 371261, upload-time = "2026-10-04T16:29:25.1Z" },
{ url = "https://files.pythonhosted.org/packages/7c/98/b2fdfe10301a9e27af21e634337fbba7baac0bc17fe44619a17c26bba2cb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e", size = 376075, upload-time = "2026-10-04T16:29:26.748Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b1/c4b8d954e49c3c69cf8063c0c9e0919c99f3d2cecc46311606a1cbe835dc/rpds_py-2026.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237", size = 399358, upload-time = "2026-10-04T16:29:28.282Z" },
{ url = "https://files.pythonhosted.org/packages/b3/59/9559a7293c97dff0cbb293efffbd36644103883ecc27741af8ef90c84ca8/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3", size = 550267, upload-time = "2026-10-04T16:29:29.851Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/6dc60e49511de4c8b00e74f9c1d43aed4d27f712cdb1ade92f2c199cbf64/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc", size = 614018, upload-time = "2026-10-04T16:29:31.54Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bc/92a4ecf27301b886232a413360f6b348a81d55ffef654f2ebda5970ebc3d/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6", size = 578509, upload-time = "2026-10-04T16:29:33.01Z" },
{ url = "https://files.pythonhosted.org/packages/08/53/efb97f2e6589b7ab8394385a73870e0b2ab2a195281c117b21fd6d7d4855/rpds_py-2026.9.1-cp312-cp312-win32.whl", hash = "sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3", size = 205401, upload-time = "2026-10-04T16:29:34.451Z" },
{ url = "https://files.pythonhosted.org/packages/1c/84/1700cc748d0eaa747486e4e82882d2575224e6e9a3148df583058850c629/rpds_py-2026.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f", size = 222923, upload-time = "2026-10-04T16:29:35.709Z" },
{ url = "https://files.pythonhosted.org/packages/c3/89/0302215373c2f8b4408b0fdfee955368cc378a98ff59508d47a7e0dc2dbf/rpds_py-2026.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02", size = 218415, upload-time = "2026-10-04T16:29:37.263Z" },
{ url = "https://files.pythonhosted.org/packages/83/ea/ee88fd9e756ff93fb6b1182a47ec09504a242620e33ce1d20679efefe841/rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9", size = 346229, upload-time = "2026-10-04T16:29:38.82Z" },
{ url = "https://files.pythonhosted.org/packages/57/71/a097d6552f837500fc36e6b23d09cfb9890c3cc47531f9ca64e149799615/rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858", size = 340514, upload-time = "2026-10-04T16:29:40.405Z" },
{ url = "https://files.pythonhosted.org/packages/bd/b7/497e85768bf4e0d8ddbaa096a4cac31d1509251dee2728a8490aa367e0b5/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063", size = 372611, upload-time = "2026-10-04T16:29:41.778Z" },
{ url = "https://files.pythonhosted.org/packages/52/4b/74ab4108916250b6e198e0d3af05bc6835eb046315f22f7a0ceb49667c5a/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025", size = 377712, upload-time = "2026-10-04T16:29:43.242Z" },
{ url = "https://files.pythonhosted.org/packages/0c/8e/067e77d9d7b3cc793c9d909b7e97e7aadbbb1fb094093b6876902cc96d38/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5", size = 488106, upload-time = "2026-10-04T16:29:44.692Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b4/c5aae6c2dde269bf955f6b7d35065c655a57e47750d9668052ad67e74dda/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0", size = 390795, upload-time = "2026-10-04T16:29:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b", size = 370794, upload-time = "2026-10-04T16:29:47.603Z" },
{ url = "https://files.pythonhosted.org/packages/3d/fe/cd2a80e6d7b871937a60e935c5d507aa390d143f4ff3636f640b9733d5df/rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33", size = 375673, upload-time = "2026-10-04T16:29:49.223Z" },
{ url = "https://files.pythonhosted.org/packages/6c/18/7464a9953724e55a3b3206062fa0ffdeaa519584c6aabf65d3956d94f131/rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83", size = 398758, upload-time = "2026-10-04T16:29:50.601Z" },
{ url = "https://files.pythonhosted.org/packages/c0/86/1534b436700fd49ff411063b7c4d7e938adfabf90895b6cf1622d5a7d1f6/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c", size = 550370, upload-time = "2026-10-04T16:29:52.002Z" },
{ url = "https://files.pythonhosted.org/packages/57/1c/e1fa82a8a01e3c5820f3ba98a8b2673f642128eb368fa88871b01dd2c909/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36", size = 613106, upload-time = "2026-10-04T16:29:53.655Z" },
{ url = "https://files.pythonhosted.org/packages/29/55/b20b8c4c3dde8755bfcd5b08492a02d0cd2e26929aedf6199ca2a377d42a/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d", size = 578235, upload-time = "2026-10-04T16:29:55.157Z" },
{ url = "https://files.pythonhosted.org/packages/55/42/df3f7bbc3f7ab37a8a9db8d6c2ff2c985422899f1f7926afbf7ec3c0b8b4/rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c", size = 205293, upload-time = "2026-10-04T16:29:56.669Z" },
{ url = "https://files.pythonhosted.org/packages/31/9c/ba5a9569d719bfdd6ce863df4133ac6a1658cf1b07cc3534c31db729fbbc/rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9", size = 222409, upload-time = "2026-10-04T16:29:58.049Z" },
{ url = "https://files.pythonhosted.org/packages/35/72/f28ca566f6c23c35bbf7445f65eb0364577b25a026305995e24f78b83d94/rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f", size = 217681, upload-time = "2026-10-04T16:29:59.449Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f2/67b94be1532767803415c1c5a1fd88ea487643d74a673cec1ba140af77bb/rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b", size = 347178, upload-time = "2026-10-04T16:30:00.865Z" },
{ url = "https://files.pythonhosted.org/packages/04/37/b751de2b59b0197a1d92a5dd491de88e8a5e928c2e6562581974f1e85263/rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35", size = 341878, upload-time = "2026-10-04T16:30:02.564Z" },
{ url = "https://files.pythonhosted.org/packages/72/e2/5873bc4643c250db9e05d48dc0c93763d4aa68bc3b81164cb1af3b45b284/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0", size = 373963, upload-time = "2026-10-04T16:30:04.026Z" },
{ url = "https://files.pythonhosted.org/packages/51/03/5acf7632158247f3f6386ff0af3a1ee48167d575037e8d0920594b76d92b/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477", size = 377975, upload-time = "2026-10-04T16:30:05.555Z" },
{ url = "https://files.pythonhosted.org/packages/e6/00/63fda451b8bffa5808fc8bb311ee7c073b340974b09f273c7b2a145d3d62/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4", size = 490347, upload-time = "2026-10-04T16:30:07.156Z" },
{ url = "https://files.pythonhosted.org/packages/a1/ae/c093ffd070ba0fb02f76c565d06fecc65ad6e4afdbae78f7031076d3cdac/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef", size = 392962, upload-time = "2026-10-04T16:30:08.77Z" },
{ url = "https://files.pythonhosted.org/packages/22/9d/d08a1128ab199b2f0cf25bfeb0639bd05119fff4b7c47bec24ef9a8ec23f/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd", size = 371623, upload-time = "2026-10-04T16:30:10.501Z" },
{ url = "https://files.pythonhosted.org/packages/53/c7/4758ddcbb75609414bbccfcb11d612436f9b3ee821bd2f33f0f1604ee648/rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35", size = 376997, upload-time = "2026-10-04T16:30:12.124Z" },
{ url = "https://files.pythonhosted.org/packages/87/e4/947bd7f608ff60faf46dc9d389c3dffd0e3d767d78a0be19978448ef0ce7/rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86", size = 400169, upload-time = "2026-10-04T16:30:13.804Z" },
{ url = "https://files.pythonhosted.org/packages/4b/35/fe93e020a0543b5670472c18d7e6af3197c08da571240ce1965c84f85c0f/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e", size = 551615, upload-time = "2026-10-04T16:30:15.332Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4f/5d2a0136bb03b2a56a39dc6ff92d58a6e3e53a2e17079238b86228882f16/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785", size = 614655, upload-time = "2026-10-04T16:30:16.92Z" },
{ url = "https://files.pythonhosted.org/packages/09/1c/3f1025aaf70d9bf7272cc41f8b64ee76b48bf01726248138430e16f23b38/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118", size = 579602, upload-time = "2026-10-04T16:30:18.555Z" },
{ url = "https://files.pythonhosted.org/packages/53/0d/5c72e6204f76610608706da32b6b7e11ef7e10317558a7bb15a122e008dc/rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc", size = 206315, upload-time = "2026-10-04T16:30:20.05Z" },
{ url = "https://files.pythonhosted.org/packages/a4/0b/489d48abbcc7d70cf3fbf662d9d22abf1f4650761c0a9ae05260800800d3/rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c", size = 222852, upload-time = "2026-10-04T16:30:21.604Z" },
{ url = "https://files.pythonhosted.org/packages/91/16/bbb05a7e6a10cf79ba639be7f799d770ee15f64175cc61d081b218dd402a/rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31", size = 218709, upload-time = "2026-10-04T16:30:23.036Z" },
{ url = "https://files.pythonhosted.org/packages/22/ac/ac507a0a4ec478ca470440a09583db4be5259ba7670aeba0620822f1e57a/rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e", size = 349596, upload-time = "2026-10-04T16:30:24.558Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f2/817a46b658d5070f477f722c298ee9a24525b0e4017347964146ef5fdd0e/rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96", size = 343114, upload-time = "2026-10-04T16:30:26.048Z" },
{ url = "https://files.pythonhosted.org/packages/6c/42/6ade976b13ac1b4cb3bf2eb603f1be2fe74df19a29988d4c2b386be59d6f/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445", size = 374197, upload-time = "2026-10-04T16:30:27.699Z" },
{ url = "https://files.pythonhosted.org/packages/d9/70/77cdf1d3f1a07faabe936016ae623aec7981f73108a8fe7a203ed2e21998/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69", size = 378025, upload-time = "2026-10-04T16:30:29.451Z" },
{ url = "https://files.pythonhosted.org/packages/3f/6b/18a44a3beaa9b7931acb04af7bd9539836477c630a794452d4826d6185d4/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495", size = 491969, upload-time = "2026-10-04T16:30:30.995Z" },
{ url = "https://files.pythonhosted.org/packages/73/27/fb39cfd6bddaf741b024f813890374578ff8ac1f1adc473659c048b03b05/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c", size = 393905, upload-time = "2026-10-04T16:30:32.628Z" },
{ url = "https://files.pythonhosted.org/packages/ed/71/0fa7bb77b57af0d710273964180d11b503f62b8a5c358eb2d8c3f62feff6/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e", size = 374569, upload-time = "2026-10-04T16:30:34.257Z" },
{ url = "https://files.pythonhosted.org/packages/54/22/f41cfac269af3b449513ef1bc3d7f32fde52abbbd2d01c7e76b47743acd9/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346", size = 377482, upload-time = "2026-10-04T16:30:35.997Z" },
{ url = "https://files.pythonhosted.org/packages/b4/fc/312b49006e7f8f9ca5f96647577b8aa6f3df30519c46bd448f5c425af0b2/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299", size = 400040, upload-time = "2026-10-04T16:30:37.76Z" },
{ url = "https://files.pythonhosted.org/packages/cf/a6/18cca7a878dc7fa95165a83343fd4d7b65643fd22e54e47340a451121d5c/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531", size = 551849, upload-time = "2026-10-04T16:30:39.443Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6d/1f5685e20f39604691bdc3c05aaa6b8bd2f954e9e996477adf2376768e33/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb", size = 614842, upload-time = "2026-10-04T16:30:41.231Z" },
{ url = "https://files.pythonhosted.org/packages/c6/25/98652109fd9f7e10268dd4571aa52b81987001b806f37ef1a18260de714a/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa", size = 582606, upload-time = "2026-10-04T16:30:43.345Z" },
{ url = "https://files.pythonhosted.org/packages/19/03/11ca09099bab5f53373a80a334c424ec917c13d050760a59499d2af5171e/rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569", size = 203341, upload-time = "2026-10-04T16:30:44.954Z" },
{ url = "https://files.pythonhosted.org/packages/6f/8a/88909e3ffd9f47f5b58211473875d8c3c09079f0058c46fb72c55a702a26/rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e", size = 222843, upload-time = "2026-10-04T16:30:46.486Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ad/f0eba548e297987964ef8088403db875b333963bf8189f3f563b6936b20e/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30", size = 384112, upload-time = "2026-10-04T16:31:42.643Z" },
{ url = "https://files.pythonhosted.org/packages/33/89/891fa4d81f1eae7fe262ecd0aba2c51de94a116c7f3ccfb034fa75f4c537/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050", size = 389662, upload-time = "2026-10-04T16:31:44.642Z" },
{ url = "https://files.pythonhosted.org/packages/4f/46/912cf623b2263f9c9c2d04e8508fc38dbe89983faeba5fb36ca4558ae550/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb", size = 504637, upload-time = "2026-10-04T16:31:46.667Z" },
{ url = "https://files.pythonhosted.org/packages/9b/5c/7f3cfa7c5f01765c9f47c8398210ed0c1eb686c0e99c223cb1c6776d3fb9/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df", size = 402607, upload-time = "2026-10-04T16:31:49.068Z" },
{ url = "https://files.pythonhosted.org/packages/f0/76/2e915d4115ee453c33e6af17bc1df7e22ddc52974e178eeeb92a78c09983/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a", size = 382762, upload-time = "2026-10-04T16:31:51.24Z" },
{ url = "https://files.pythonhosted.org/packages/0d/e5/844382925d7b84a4074ad79fc2b9d05eb6231ea243b34f247e68e519b606/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608", size = 389563, upload-time = "2026-10-04T16:31:53.549Z" },
{ url = "https://files.pythonhosted.org/packages/81/82/d6e3951602dc2e24790ef9d299aa24bf5e2493df5043f2c6da00480cc447/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9", size = 416842, upload-time = "2026-10-04T16:31:55.76Z" },
{ url = "https://files.pythonhosted.org/packages/ea/1c/8d248e4ea671d525ab7daa0703ad81cc2175076fa0ccd7ef088facbd2b01/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c", size = 562434, upload-time = "2026-10-04T16:31:57.874Z" },
{ url = "https://files.pythonhosted.org/packages/ad/38/e7ac0db898120d15dea25e3661b1242a40cb3b23d1f4db86c15a01fe0ba1/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f", size = 627909, upload-time = "2026-10-04T16:31:59.977Z" },
{ url = "https://files.pythonhosted.org/packages/03/c8/37e555514932cb1ee36c75349187aa882a98dee5d5654c761699ed8a0d27/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e", size = 593658, upload-time = "2026-10-04T16:32:02.14Z" },
{ url = "https://files.pythonhosted.org/packages/12/5c/5f69a91add94a8a33acea68a2b034a464946a654ff7296af9d957ef0d6a5/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_10_12_x86_64.whl", hash = "sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32", size = 351769, upload-time = "2026-10-04T16:32:04.675Z" },
{ url = "https://files.pythonhosted.org/packages/41/22/35ea8bf3b2682b90ab687c74e4828418ff12298a5b0b0a0c033f61c86a30/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31", size = 351719, upload-time = "2026-10-04T16:32:06.834Z" },
{ url = "https://files.pythonhosted.org/packages/a3/90/a9ba81408369d34c1aca73319c15adc91ed45c1d21e9e8ee4832ffb7fe0b/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4", size = 381109, upload-time = "2026-10-04T16:32:08.994Z" },
{ url = "https://files.pythonhosted.org/packages/b3/44/5192a0bed94cec86bd2a5e1cc5a1bb8f7eec3aec907eacd2deb13e87e326/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759", size = 388222, upload-time = "2026-10-04T16:32:11.321Z" },
{ url = "https://files.pythonhosted.org/packages/92/cd/5356549711448f18b52f7a11ffbe90f1228774996fbf3c6cd1b18d22abaf/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb", size = 500384, upload-time = "2026-10-04T16:32:13.478Z" },
{ url = "https://files.pythonhosted.org/packages/7a/88/ddda9d28adfe33119c75b2e733d4ba7e326f41d7e1b1093951771768ca48/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82", size = 401661, upload-time = "2026-10-04T16:32:15.653Z" },
{ url = "https://files.pythonhosted.org/packages/32/c3/bb59ba16a6b4a57995d15b8c04c00f28df3b938c4dcdde48fbe0f73edb0c/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c", size = 380748, upload-time = "2026-10-04T16:32:17.727Z" },
{ url = "https://files.pythonhosted.org/packages/f5/b8/0580faa1c5a32ddc160130dd7ae54d2d007272a89a40a6b5db3e50f9f585/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14", size = 385731, upload-time = "2026-10-04T16:32:19.855Z" },
{ url = "https://files.pythonhosted.org/packages/bf/70/f73564642bbe3322c7eeef2c2f258cf040b71c41a430b532e59d04a1b838/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c", size = 412131, upload-time = "2026-10-04T16:32:22.285Z" },
{ url = "https://files.pythonhosted.org/packages/8c/5d/17fff2e1f8f68721a2afb5cb48f7e442c751bb6b4883157c5abaf618cd08/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68", size = 558193, upload-time = "2026-10-04T16:32:24.4Z" },
{ url = "https://files.pythonhosted.org/packages/2a/d0/869ab6fb08531f97aae4a780b4d61b4190d8aef48fe054ee28bcf7114466/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_i686.whl", hash = "sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da", size = 622448, upload-time = "2026-10-04T16:32:26.844Z" },
{ url = "https://files.pythonhosted.org/packages/ba/a5/9672e532fe02cd3b92c78cfd8743abca939d96853006c63835009a1ecb6f/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448", size = 591126, upload-time = "2026-10-04T16:32:29.274Z" },
{ url = "https://files.pythonhosted.org/packages/80/ef/3a9f8e4279c7920af561deed4cb7ebebed2794a8f608cf404d5eef14a105/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0", size = 357108, upload-time = "2026-10-04T16:32:31.641Z" },
{ url = "https://files.pythonhosted.org/packages/b7/55/4b2fa381a583760aea5c92841e4e928a358e1c6511b129219e9c2826a226/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37", size = 346877, upload-time = "2026-10-04T16:32:34.061Z" },
]
[[package]]
name = "ruamel-yaml"
version = "0.19.1"
@@ -3603,27 +3844,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.16.10"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" },
{ url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" },
{ url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" },
{ url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" },
{ url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" },
{ url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" },
{ url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" },
{ url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" },
{ url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" },
{ url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" },
{ url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" },
{ url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" },
{ url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" },
{ url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" },
{ url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" },
{ url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" },
{ url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" },
{ url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" },
{ url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" },
{ url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" },
{ url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" },
{ url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" },
{ url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" },
{ url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" },
{ url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" },
{ url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" },
{ url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" },
{ url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" },
{ url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" },
{ url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" },
]
[[package]]
@@ -3795,6 +4036,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sse-starlette"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" },
]
[[package]]
name = "starlette"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/0c/6efb252d091ecccd7d62048ae11f0ea35cd75a4fbaeea5e30f9c3bf91d10/starlette-1.8.0.tar.gz", hash = "sha256:1565dc0b35d5737a271ed1e0e04e949f4e81198799f216d2667b0a0fb9cf9522", size = 2730457, upload-time = "2026-10-13T07:54:39.53Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/b0/5742e4ac7af5eb58ec3470a537a49d7aa507e5539413e504b3a65ef50ba8/starlette-1.8.0-py3-none-any.whl", hash = "sha256:dfdd6b29c26483288088d990eee59631dedadd66ce20d203402a7ca8e3c4656f", size = 79612, upload-time = "2026-10-13T07:54:38.019Z" },
]
[[package]]
name = "sympy"
version = "1.14.0"
@@ -4201,6 +4468,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[[package]]
name = "uvicorn"
version = "0.54.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
]
[[package]]
name = "wcwidth"
version = "0.9.1"