mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-18 23:37:14 +02:00
Compare commits
No files matched your search
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Agent skill and Claude Code plugin for the remove-ai-watermarks CLI.",
|
||||
"version": "1.0.11"
|
||||
"version": "1.0.13"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -29,9 +29,9 @@ 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 may use the finite set of configured account slots and surfaces
|
||||
after a refusal. Do not add new logins, IP rotation, network-route failover,
|
||||
unbounded retries, or cross-session result merging. Never commit the local
|
||||
slot file.
|
||||
Every manual result stays bound to the exact sanitized upload hash and preserves the
|
||||
provider response verbatim.
|
||||
@@ -39,11 +39,14 @@ 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. A request to use a provider covers every configured surface, including Web,
|
||||
without a second confirmation. An API refusal, quota response, transport error, or
|
||||
indeterminate verdict stays on that surface and never silently submits the file to Web.
|
||||
|
||||
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
|
||||
session. Gemini uses an explicit `/u/N/` account index. The SynthID Detector at
|
||||
`https://synthid.com/` (`synthid-web`) uses the same Chrome profile and does not take a
|
||||
`/u/N/` index; its terms forbid driving the page as an automated API. OpenAI, Microsoft, and Meta Web automation
|
||||
uses isolated Playwright contexts; it must never attach to or copy state from the user's
|
||||
real browser. Proxy credentials are read only through a slot's named environment variable
|
||||
and never enter a manifest or log. Dotenv interpolation may derive routed proxy URLs from
|
||||
@@ -79,6 +82,8 @@ The same rule applies to install hints: name the extra that actually makes the c
|
||||
|
||||
Run `bash maintain.sh` from the repository root. The authoritative type gate is scoped to `src/`; full-project Pyright can exhaust Node memory on the ML dependency graph.
|
||||
|
||||
The full gate outlasts the agent's 10-minute foreground shell cap, which kills it mid-suite. Run it detached under `nohup caffeinate -dimu` with its exit code written to a file and poll that file. To stop a stray run, kill its own PID, never `pkill -f pytest`, which also takes down other sessions' suites on the machine.
|
||||
|
||||
The security scanner's exit status is authoritative, including a failure after a success
|
||||
message. `tests/test_maintenance.py` checks clean, vulnerable, and broken scanner
|
||||
runs through the real shell entry point, and pins the CI `security` job to the
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: provider-oracles
|
||||
description: Run hash-bound Google, OpenAI, Microsoft, or Meta watermark-oracle checks for this repository. Use for provider verification, multi-account Gemini checks, isolated Playwright checks, or recording oracle evidence.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Provider oracles
|
||||
@@ -29,22 +31,24 @@ request permission for each account or file already covered by the task.
|
||||
Submit each frozen artifact at most once per slot in that pass. Preserve
|
||||
inconclusive results and disagreements between accounts. Stop when the bounded
|
||||
plan is complete or no authorized usable slot remains; report remaining rows.
|
||||
New logins, another provider, a new network route, or another pass after the plan
|
||||
is exhausted require authorization unless already requested. Account
|
||||
New logins, another provider, or a new network route require authorization.
|
||||
The oracles and slots already configured for the requested provider may be used
|
||||
again, including their Web surfaces, without a second confirmation. Account
|
||||
continuation does not authorize changing candidate pixels or erasing failures.
|
||||
|
||||
## Default surface priority
|
||||
|
||||
Prefer an official API adapter whenever that provider, media type, and configured
|
||||
slot support it. 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`.
|
||||
slot support it. For OpenAI and Microsoft, the default plans are API then Web.
|
||||
Google and Meta currently have only usable Web routes in this tooling. Google's
|
||||
routes are `gemini-web` and `synthid-web`. Inspect the order with `plan PROVIDER`.
|
||||
The same request may use every configured surface.
|
||||
|
||||
The priority is a selection policy, not automatic failover. An API refusal,
|
||||
quota response, transport error, or indeterminate verdict must be recorded and
|
||||
reported. Do not submit the same artifact to Web unless the operator separately
|
||||
authorizes that next surface.
|
||||
reported. The same request already covers every configured surface of that
|
||||
provider, including Web. Run Web as its own acknowledged check and keep its
|
||||
verdict separate. Do not replace the API result with the Web result.
|
||||
|
||||
## Route by surface
|
||||
|
||||
@@ -84,6 +88,27 @@ 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.
|
||||
|
||||
### SynthID Detector
|
||||
|
||||
Use the user's real authenticated Chrome. Do not use isolated Playwright: the
|
||||
portal's additional terms forbid using the page as an automated API, bulk
|
||||
verification, and quota evasion.
|
||||
|
||||
1. Validate a `synthid-web` slot. It names `browser_profile` and `account_label`.
|
||||
It does not use `google_account_index` or a proxy variable.
|
||||
2. Prepare an immutable batch outside the repository. Inspect images the same
|
||||
way as for Gemini, and do not submit a file that still shows a provider logo.
|
||||
3. Open `https://synthid.com/` in the named Chrome profile. Accept the terms
|
||||
dialog when it is shown. Do not start a new login. If the page requires
|
||||
sign-in and that profile has no session, leave the row unsubmitted.
|
||||
4. Attach one prepared upload through the page's own file control.
|
||||
5. Read the settled label. `Watermark detected` is `detected`. `Watermark not
|
||||
detected` is `not_detected`. Quota, sign-in, and unclear wording stay
|
||||
`refused` or `indeterminate`.
|
||||
6. Record the verbatim response. The result covers partner SynthID from Google
|
||||
DeepMind, Nvidia, OpenAI, and Kakao, and it does not name which partner. Do
|
||||
not record it as a Gemini-only or OpenAI-only verdict.
|
||||
|
||||
### OpenAI, Microsoft, and Meta Web
|
||||
|
||||
Use the isolated Playwright runner, never the user's real Chrome:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: signal-discovery
|
||||
description: Discover missing AI provenance and watermark coverage in the remove-ai-watermarks library from fresh or historical corpora, then validate detection and removal parity. Use for corpus gap mining, periodic signal audits, new-provider discovery, or deciding whether old retained data must be replayed. Do not use for ordinary per-file identification or removal.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Signal discovery
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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).
|
||||
@@ -36,10 +37,12 @@ The gate does not pass `--ignore-unfixed` to uv-secure, so a transitive CVE with
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
```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
|
||||
@@ -274,11 +283,8 @@ features, and development setup.
|
||||
|
||||
### Visible Gemini mark
|
||||
|
||||
| Before | After |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
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 +482,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 +510,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
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
+18
-2
@@ -26,6 +26,11 @@ SkillsMP indexes public GitHub `SKILL.md` files and does not need a separate
|
||||
upload. ClawHub and the Claude community marketplace are explicit publishes;
|
||||
see [Release and distribution](release-and-distribution.md#agent-skill).
|
||||
|
||||
Every maintainer skill under `.claude/skills/` carries `metadata.internal: true`.
|
||||
The skills CLI discovers every `SKILL.md` in that directory, so without the flag
|
||||
`npx skills add` offers it and skills.sh lists it beside the published skill.
|
||||
`tests/test_agent_skill.py` fails on a project skill that lacks it.
|
||||
|
||||
## Where the skill can actually be listed
|
||||
|
||||
Most skill catalogs are install registries, not discovery engines. A listing
|
||||
@@ -65,6 +70,17 @@ Do these in order after the skill is on the default branch.
|
||||
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`: READY, not submitted. 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`.
|
||||
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
|
||||
@@ -186,8 +202,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
|
||||
|
||||
+16
@@ -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,6 +691,16 @@ 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
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Read this reference for environment setup, dependency recovery, CI behavior, and
|
||||
- On an unreliable connection, sync `dev` plus only the required feature extras, such as `diffusion`, and run the checks directly instead of downloading every optional learned backend.
|
||||
- Run `uv` from the repository root or it may create a bare environment without the project dependencies.
|
||||
|
||||
`maintain.sh` starts with `uv sync --all-extras`, so a lean environment is replaced whenever the gate runs. Syncing back down can leave `opencv-python-headless` half-removed: `import cv2` then succeeds against an empty namespace while `cv2.INTER_LINEAR` is gone, which surfaces as a suite that stalls or reports hundreds of unrelated failures. Repair it with `uv sync --frozen --extra dev --reinstall-package opencv-python-headless` rather than re-reading the diff.
|
||||
`maintain.sh` starts with `uv sync --all-extras`, so a lean environment is replaced whenever the gate runs. Syncing back down can leave `opencv-python-headless` half-removed: `import cv2` then succeeds against an empty namespace while `cv2.INTER_LINEAR` is gone, which surfaces as a suite that stalls or reports hundreds of unrelated failures. Repair it with `uv sync --frozen --extra dev --reinstall-package opencv-python-headless` rather than re-reading the diff. A stale worktree environment can fail differently: `import cv2` raises `partially initialized module 'cv2' has no attribute 'gapi_wip_gst_GStreamerPipeline'`, because the lock installs `opencv-contrib-python` 4.x beside `opencv-python` 5.x into one `cv2` directory. Reinstalling the three OpenCV packages did not fix that on 2026-10-17; deleting the worktree's `.venv` and running `uv sync --frozen --all-extras` did.
|
||||
|
||||
Pyright is only meaningful with the extras its imports need. Scoped to `src/`, a lean environment reports `TrustMark`, `transformers` and `huggingface_hub` as unresolved, which reads as a type error and is not one; add `--extra trustmark --extra classify` before believing it.
|
||||
|
||||
|
||||
+2
-1
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -400,12 +417,20 @@ global Qwen stage is tiled; the face stage runs after tile blending.
|
||||
every profile, streaming weights instead of pinning them. It reduces CUDA memory
|
||||
pressure at the cost of speed.
|
||||
|
||||
It reaches the global stack of `qwen-zimage` only. That stack is each
|
||||
profile's own, and `chroma-zimage` and `sdxl-zimage` load theirs with a plain
|
||||
`.to(device)`. On a card below the face-stage residency floor, where the face
|
||||
stack was already offloading, the flag changes nothing at all for those two: the
|
||||
run behaves as if it were absent. The library says so on the way past, as a
|
||||
warning ahead of the model load.
|
||||
It reaches the global stack of `qwen-zimage` and `sdxl-zimage`. That stack is
|
||||
each profile's own, and `chroma-zimage` loads its stack with a plain `.to(device)`.
|
||||
On a card below the face-stage residency floor, where the face stack was already
|
||||
offloading, the flag changes nothing at all for that profile: the run behaves as
|
||||
if it were absent. The library says so on the way past, as a warning ahead of the
|
||||
model load.
|
||||
|
||||
`sdxl-zimage` streams its global stack without being asked on a card below
|
||||
`RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB` (12 GiB). Its UNet and Canny ControlNet run
|
||||
together on every step, so the stack does not fit an 8 GiB card even with
|
||||
model-level offload. A resident load there does not always fail loudly: under
|
||||
the Windows display driver, including WSL2, CUDA can spill the overflow into
|
||||
shared system memory, and the run goes on at a fraction of the speed instead of
|
||||
raising an out-of-memory error.
|
||||
|
||||
Residency is otherwise chosen from the card's total VRAM. On a card large enough
|
||||
to hold a stack, offloading is pure waste: DiffSynth drops weights to the meta
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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, which is unknown rather than clean.
|
||||
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).
|
||||
+109
-13
@@ -47,6 +47,9 @@ Important contracts:
|
||||
`EXIT_NO_VISIBLE_MARK`.
|
||||
- `invisible` writes no output when no supported local signal is found, unless
|
||||
`--force` is supplied.
|
||||
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`.
|
||||
- A quiet `metadata --check` and a successful `metadata --remove` (same for
|
||||
`video metadata`) end by repeating the `identify` limit: the pixel channel is
|
||||
@@ -236,10 +239,46 @@ 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.
|
||||
`stco`/`co64` offset, encoded stream byte apart from the Kling SEI tag below, and
|
||||
source-sized memory bound.
|
||||
Publication is atomic, and a malformed top-level walk is copied unchanged. A
|
||||
generic `AIGC` key whose value has no TC260 field is ignored.
|
||||
|
||||
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 +676,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.
|
||||
@@ -1501,6 +1544,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 +2208,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.
|
||||
@@ -2185,27 +2244,27 @@ Regression coverage:
|
||||
|
||||
### CPU offload
|
||||
|
||||
CPU offload is enabled only when requested. Nothing calls Diffusers'
|
||||
`enable_model_cpu_offload` any more -- that belonged to the deleted single-stage
|
||||
profiles. `--cpu-offload` sets both residency fields, and they do not travel the
|
||||
same distance: the face field is read by the shared base and so applies to every
|
||||
profile, while the global field is read by `QwenZImagePipeline` alone.
|
||||
`ChromaZImagePipeline` and `SdxlZImagePipeline` load their global stack with a
|
||||
plain `.to(device)` and never consult it.
|
||||
Nothing calls Diffusers' `enable_model_cpu_offload` any more -- that belonged to
|
||||
the deleted single-stage profiles. `--cpu-offload` sets both residency fields, and
|
||||
they do not travel the same distance: the face field is read by the shared base and
|
||||
so applies to every profile, while the global field is read by `QwenZImagePipeline`
|
||||
and `SdxlZImagePipeline`. `ChromaZImagePipeline` loads its global stack with a
|
||||
plain `.to(device)` and never consults it.
|
||||
|
||||
`GLOBAL_OFFLOAD_PROFILES` in `watermark_profiles.py` is the list, and
|
||||
`global_offload_supported` the question; `WatermarkRemover._warn_if_global_offload_unsupported`
|
||||
warns ahead of the model load rather than at construction, because `auto` picks
|
||||
its engine per-image. An explicit `auto` preload uses the qwen-zimage fallback and
|
||||
therefore keeps global offload active before that resolution. Below
|
||||
`RESIDENT_FACE_MODEL_MIN_VRAM_GIB` the flag is a no-op for the other two profiles --
|
||||
the face stack was offloading anyway and the global one does not ask -- which is
|
||||
what the warning exists to say before a download starts.
|
||||
`RESIDENT_FACE_MODEL_MIN_VRAM_GIB` the flag is a no-op for `chroma-zimage` -- the
|
||||
face stack was offloading anyway and the global one does not ask -- which is what
|
||||
the warning exists to say before a download starts.
|
||||
|
||||
Residency is otherwise chosen from the card's total VRAM, once per stack:
|
||||
`resolve_global_model_residency` gates the mandatory Qwen stack at
|
||||
`RESIDENT_GLOBAL_MODEL_MIN_VRAM_GIB` and `resolve_face_model_residency` gates the
|
||||
optional Z-Image stack at `RESIDENT_FACE_MODEL_MIN_VRAM_GIB`.
|
||||
`RESIDENT_GLOBAL_MODEL_MIN_VRAM_GIB`, `resolve_sdxl_global_residency` gates the
|
||||
SDXL stack at `RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB`, and `resolve_face_model_residency`
|
||||
gates the optional Z-Image stack at `RESIDENT_FACE_MODEL_MIN_VRAM_GIB`.
|
||||
|
||||
Below the global floor, `_qwen_vram_config` streams the stack from disk, which is
|
||||
what makes a 20B model runnable on a consumer card. At or above it, streaming is
|
||||
@@ -2222,6 +2281,43 @@ The resident config deliberately passes no `"disk"` value anywhere. DiffSynth la
|
||||
`disk_offload` once, from `offload_dtype`, so leaving the sentinel in place while
|
||||
pointing every device at CUDA would keep the meta-drop and re-read.
|
||||
|
||||
Sequential SDXL offload receives the current CUDA device index explicitly, so
|
||||
its hooks use the same GPU as the residency probes, input tensors, and seeded
|
||||
generators. Omitting the index makes Diffusers select GPU 0 even after
|
||||
`torch.cuda.set_device(1)`. `test_streaming_uses_the_current_cuda_device` in
|
||||
`test_cpu_offload.py` covers automatic and forced streaming on nonzero devices.
|
||||
|
||||
The SDXL stack (fp16 UNet, Canny ControlNet, both text encoders, the fp16-fix VAE)
|
||||
is ~8.8 GiB of weights, and the UNet and the ControlNet run together on every
|
||||
denoising step. Below the 12 GiB floor `_load_global` fuses the Lightning LoRA on
|
||||
the CPU and then calls `enable_sequential_cpu_offload`; the order matters, because
|
||||
the offload hooks stream their own copy of each weight and a later fuse would never
|
||||
reach it. Measured on an RTX 5060 Laptop (8 GiB, WSL2, 16 GiB system RAM) in
|
||||
October 2026, one 1672x941 global pass at strength 0.15:
|
||||
|
||||
| Placement | Warm pass | Peak VRAM | Peak system RAM |
|
||||
|---|---|---|---|
|
||||
| resident `.to(device)` (previous behaviour) | did not finish in 10 min | 7.9 GiB used, spilling | -- |
|
||||
| `enable_model_cpu_offload` | 237 s | 9.2 GiB reserved on an 8 GiB card | 12.4 GiB |
|
||||
| group offload, block level, CUDA stream | 92 s, or hung | 12.4 GiB reserved | 15.6 GiB |
|
||||
| group offload, leaf level, CUDA stream | 7.8 s, or hung | 5.6 GiB | 15.7 GiB |
|
||||
| group offload, leaf level, `low_cpu_mem_usage` | 21.4 s | 5.6 GiB | 9.4 GiB |
|
||||
| `enable_sequential_cpu_offload` | 12.8 s | 3-4 GiB | 8.5 GiB |
|
||||
|
||||
The resident and model-offload rows did not fail: under the Windows display driver
|
||||
CUDA falls back to shared system memory, so PyTorch reserved more than the card
|
||||
holds and the run crawled instead of raising. The streamed group-offload variants
|
||||
pin every weight in host memory up front; on a 16 GiB machine that left the run
|
||||
finishing or hanging depending on what else held RAM, which rules them out as a
|
||||
default. All completed placements produced bit-identical images.
|
||||
|
||||
Fusing on the CPU instead of on CUDA rounds 788 of the UNet's 3256 fused tensors
|
||||
differently, by at most one fp16 ulp (1.2e-4). On the pass above that moved the
|
||||
output by a mean of 0.16 levels (PSNR 54.3 dB against a CUDA-fused, equally
|
||||
streamed run). Cards at or above the floor keep the CUDA fuse and therefore the
|
||||
previous pixels; only a streamed run, automatic or forced with `--cpu-offload`,
|
||||
sees the difference.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_cpu_offload.py`](../tests/test_cpu_offload.py)
|
||||
|
||||
+64
-25
@@ -14,6 +14,7 @@ through the installed CLI or public Python API.
|
||||
| `openai-api` | OpenAI SynthID | Official Content Provenance API | Images |
|
||||
| `microsoft-api` | Microsoft InvisMark | Azure Content Provenance Detection API | Images |
|
||||
| `gemini-web` | Google SynthID | Signed-in Gemini web account | Images and videos |
|
||||
| `synthid-web` | Partner SynthID | SynthID Detector at synthid.com, real Chrome | None recorded |
|
||||
| `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,46 @@ 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`.
|
||||
|
||||
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.
|
||||
A request to use a provider covers every configured surface of that provider,
|
||||
including Web, without a second confirmation. API-first is still the order to
|
||||
try, not a lock on the other surfaces. An API refusal, quota response, transport
|
||||
failure, or indeterminate verdict is recorded as-is. It does not silently
|
||||
upload the same file to Web; run that surface as its own acknowledged check and
|
||||
keep the two verdicts separate.
|
||||
|
||||
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 using the page as an automated API, bulk verification, and quota evasion,
|
||||
so `run-web` does not drive it. Use the real Chrome workflow below. A
|
||||
`Watermark detected` result is partner SynthID, not a Gemini-only or OpenAI-only
|
||||
verdict.
|
||||
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 +182,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
|
||||
|
||||
@@ -193,6 +217,13 @@ default; `--slots PATH` explicitly overrides it.
|
||||
"google_account_index": 2,
|
||||
"network_label": "office"
|
||||
},
|
||||
{
|
||||
"name": "synthid-personal",
|
||||
"surface": "synthid-web",
|
||||
"account_label": "google-account-a",
|
||||
"browser_profile": "Your Chrome",
|
||||
"network_label": "home"
|
||||
},
|
||||
{
|
||||
"name": "openai-web-thordata-us",
|
||||
"surface": "openai-web",
|
||||
@@ -261,7 +292,9 @@ 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
|
||||
account, its real Chrome profile, and its nonnegative `/u/N/` account index. A
|
||||
SynthID Detector slot names the account and the same real Chrome profile, and it
|
||||
does not use `google_account_index`. An
|
||||
API slot must name its account or project and cannot name a browser profile. The
|
||||
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
|
||||
@@ -279,9 +312,10 @@ again for files or accounts already covered by the task.
|
||||
Submit each frozen artifact at most once per slot in that 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.
|
||||
unsubmitted rows. A new login, another provider, or a new network route needs
|
||||
authorization. The oracles and slots already configured for the requested
|
||||
provider may be used again, including their Web surfaces, without a second
|
||||
confirmation. Candidate pixels remain frozen throughout account continuation.
|
||||
|
||||
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:
|
||||
@@ -385,18 +419,23 @@ 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.
|
||||
Google is intentionally excluded from the standalone runner. Both `gemini-web`
|
||||
and `synthid-web` use the user's existing real Chrome profile, and the SynthID
|
||||
Detector terms forbid driving that page as an automated API. Invoke the project
|
||||
`provider-oracles` skill to prepare the batch and record the settled outcome.
|
||||
For Gemini, select the named profile and `/u/N/` account. For SynthID Detector,
|
||||
open `https://synthid.com/` in that profile. The portal has no `/u/N/` index.
|
||||
Accept the terms dialog when it is shown, attach one prepared upload, and read
|
||||
the explicit `Watermark detected` or `Watermark not detected` label. If the page
|
||||
requires sign-in and that profile has no session, record the row unsubmitted
|
||||
and do not start a new login.
|
||||
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
-1
@@ -575,7 +575,11 @@ 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`. 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 registered, because no publication-cleared export has been captured;
|
||||
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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
||||
"maintainers": ["wiltodelta"]
|
||||
}
|
||||
+4
-1
@@ -4,7 +4,7 @@
|
||||
# on it, including the ComfyUI node package. The console script below carries
|
||||
# the same weight, since users have it on PATH.
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.45.0"
|
||||
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",
|
||||
|
||||
@@ -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("*")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -288,8 +288,8 @@ 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"]
|
||||
|
||||
+35
-11
@@ -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="real_browser",
|
||||
url="https://synthid.com/",
|
||||
media_types=("image", "video", "audio"),
|
||||
project_evidence=(),
|
||||
prompt=None,
|
||||
clean_result_note=(
|
||||
"Record detected only for the explicit label Watermark detected, and not_detected only for "
|
||||
"Watermark not detected. The portal reports partner SynthID from Google DeepMind, Nvidia, "
|
||||
"OpenAI, and Kakao without naming which partner. A not_detected result is not proof of "
|
||||
"human authorship. Preserve quota, sign-in, and unclear wording."
|
||||
),
|
||||
account_slots=True,
|
||||
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,17 @@ 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")
|
||||
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,18 +883,17 @@ 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))
|
||||
return
|
||||
for row in rows:
|
||||
click.echo(f"{row['preference']}\t{row['key']}\t{row['automation']}\t{row['surface']}")
|
||||
if len(rows) > 1:
|
||||
click.echo("Web fallback requires a separate operator decision; failures do not trigger it automatically.")
|
||||
|
||||
|
||||
@cli.command()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -138,6 +138,13 @@ 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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -35,7 +35,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.45.0"
|
||||
__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:
|
||||
|
||||
@@ -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,7 +732,8 @@ 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 strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
|
||||
@@ -47,6 +47,12 @@ _LATENT_GRID = 8
|
||||
# The reference graph loads the Qwen LoRA at 0.8; carrying that number to a different
|
||||
# LoRA on a different architecture would be imitation, not parity.
|
||||
SDXL_STEPS = 4
|
||||
# Below this floor the global stack streams its weights from CPU layer by layer; at or
|
||||
# above it the stack stays resident. The fp16 UNet, Canny ControlNet, both text
|
||||
# encoders and the VAE are ~8.8 GiB of weights, and the UNet and the ControlNet run
|
||||
# together on every step, so model-level offload cannot get under an 8 GiB card.
|
||||
# Benchmark in docs/module-internals.md, "CPU offload".
|
||||
RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB = 12.0
|
||||
|
||||
|
||||
def sdxl_target_size(width: int, height: int) -> tuple[int, int]:
|
||||
@@ -54,6 +60,17 @@ def sdxl_target_size(width: int, height: int) -> tuple[int, int]:
|
||||
return _target_size(width, height, _LATENT_GRID)
|
||||
|
||||
|
||||
def resolve_sdxl_global_residency(
|
||||
requested: bool | None,
|
||||
*,
|
||||
total_memory_gib: float,
|
||||
) -> bool:
|
||||
"""Keep the SDXL global stack resident when explicitly requested or safely sized."""
|
||||
if requested is not None:
|
||||
return requested
|
||||
return total_memory_gib >= RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB
|
||||
|
||||
|
||||
@dataclass
|
||||
class SdxlZImagePipeline(TwoStageZImagePipeline):
|
||||
"""Lazy runtime for the SDXL global stage plus the inherited face stage."""
|
||||
@@ -64,6 +81,12 @@ class SdxlZImagePipeline(TwoStageZImagePipeline):
|
||||
super().__post_init__()
|
||||
self._sdxl_pipe: Any = None
|
||||
|
||||
def _keep_global_models_resident(self) -> bool:
|
||||
return resolve_sdxl_global_residency(
|
||||
self.keep_global_models_on_device,
|
||||
total_memory_gib=self._total_vram_gib(),
|
||||
)
|
||||
|
||||
def _load_global(self) -> Any:
|
||||
if self._sdxl_pipe is not None:
|
||||
return self._sdxl_pipe
|
||||
@@ -89,11 +112,19 @@ class SdxlZImagePipeline(TwoStageZImagePipeline):
|
||||
variant="fp16",
|
||||
add_watermarker=False,
|
||||
**token,
|
||||
).to(self.device)
|
||||
)
|
||||
resident = self._keep_global_models_resident()
|
||||
if resident:
|
||||
pipe = pipe.to(self.device)
|
||||
pipe.load_lora_weights(hf_hub_download(SDXL_LIGHTNING_MODEL_ID, SDXL_LIGHTNING_PATTERN, **token))
|
||||
pipe.fuse_lora()
|
||||
# SDXL-Lightning is distilled against trailing timestep spacing.
|
||||
pipe.scheduler = EulerDiscreteScheduler.from_config(pipe.scheduler.config, timestep_spacing="trailing")
|
||||
if not resident:
|
||||
# After the fuse: the offload hooks keep their own CPU copy of each weight
|
||||
# and stream that copy, so fusing later would never reach what runs.
|
||||
self._progress("Streaming the SDXL stack from CPU (card below the residency floor)...")
|
||||
pipe.enable_sequential_cpu_offload(device=self.device, gpu_id=torch.cuda.current_device())
|
||||
self._sdxl_pipe = pipe
|
||||
return pipe
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,13 +63,13 @@ PROFILE_ADAPTIVE_POLISH = {
|
||||
|
||||
# Which profiles let ``--cpu-offload`` reach their GLOBAL stack. The face stage
|
||||
# honours the flag everywhere, because residency lives on the shared base, but the
|
||||
# global stack is each profile's own: only qwen-zimage streams it, through
|
||||
# ``_qwen_vram_config``. chroma-zimage and sdxl-zimage load theirs with a plain
|
||||
# ``.to(device)``, so on a card below the face-stage residency floor the flag has
|
||||
# nothing left to change and the run behaves as if it were absent. Data rather than
|
||||
# a subclass attribute, because the caller that must warn cannot import a profile
|
||||
# module without pulling in Diffusers.
|
||||
GLOBAL_OFFLOAD_PROFILES = frozenset({QWEN_ZIMAGE_PROFILE})
|
||||
# global stack is each profile's own: qwen-zimage streams it through
|
||||
# ``_qwen_vram_config`` and sdxl-zimage through Diffusers sequential offload.
|
||||
# chroma-zimage loads its stack with a plain ``.to(device)``, so on a card below the
|
||||
# face-stage residency floor the flag has nothing left to change there and the run
|
||||
# behaves as if it were absent. Data rather than a subclass attribute, because the
|
||||
# caller that must warn cannot import a profile module without pulling in Diffusers.
|
||||
GLOBAL_OFFLOAD_PROFILES = frozenset({QWEN_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE})
|
||||
|
||||
SDXL_LIGHTNING_MODEL_ID = "ByteDance/SDXL-Lightning"
|
||||
SDXL_LIGHTNING_PATTERN = "sdxl_lightning_4step_lora.safetensors"
|
||||
|
||||
@@ -175,9 +175,9 @@ class WatermarkRemover:
|
||||
return
|
||||
logger.warning(
|
||||
"--cpu-offload does not reach the global stack of the '%s' pipeline: only "
|
||||
"qwen-zimage streams its global model between CUDA calls. The face stage "
|
||||
"still honours the flag, so below the face-stage residency floor this run "
|
||||
"behaves as if --cpu-offload were absent.",
|
||||
"qwen-zimage and sdxl-zimage stream their global model between CUDA calls. "
|
||||
"The face stage still honours the flag, so below the face-stage residency "
|
||||
"floor this run behaves as if --cpu-offload were absent.",
|
||||
self.model_profile,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -308,8 +308,9 @@ _cpu_offload_option = click.option(
|
||||
"Offload model components to CPU between CUDA calls instead of keeping the "
|
||||
"whole pipeline in VRAM, at the cost of speed. Forces the face stack to "
|
||||
"offload instead of using automatic residency, on every profile. It reaches "
|
||||
"the global stack of qwen-zimage only: chroma-zimage and sdxl-zimage keep "
|
||||
"theirs resident either way."
|
||||
"the global stack of qwen-zimage and sdxl-zimage, which also stream theirs "
|
||||
"automatically on a card too small to hold it; chroma-zimage keeps its "
|
||||
"global stack resident either way."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -914,6 +915,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
|
||||
@@ -1905,5 +1911,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()
|
||||
@@ -172,7 +172,8 @@ class InvisibleEngine:
|
||||
cpu_offload: Offload model components to CPU between CUDA calls instead
|
||||
of keeping the whole pipeline in VRAM, at the cost of speed. Forces
|
||||
the face stack to offload instead of using automatic residency, on
|
||||
every profile; reaches the global stack of qwen-zimage only.
|
||||
every profile; reaches the global stack of qwen-zimage and
|
||||
sdxl-zimage, not chroma-zimage.
|
||||
CUDA only.
|
||||
"""
|
||||
|
||||
@@ -310,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
|
||||
@@ -368,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
|
||||
|
||||
@@ -389,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
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""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}
|
||||
completed = runner(args)
|
||||
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, a result. Click also exits 2 on
|
||||
# a usage error, such as a missing file, and prints its usage line on stderr.
|
||||
no_signal = code == 2 and "Usage:" not in stderr
|
||||
is_error = code != 0 and not no_signal
|
||||
summary = f"exit code {code}"
|
||||
if no_signal:
|
||||
summary += " (no supported signal found; this is unknown, not proof the file is clean)"
|
||||
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()
|
||||
@@ -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)")
|
||||
|
||||
@@ -377,6 +377,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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -55,6 +56,16 @@ def test_skill_frontmatter_matches_agent_skills_spec() -> None:
|
||||
assert "remove-ai-watermarks" in description
|
||||
|
||||
|
||||
def test_project_skills_stay_out_of_public_skill_discovery() -> None:
|
||||
# `npx skills add` and skills.sh discover every SKILL.md under .claude/skills;
|
||||
# metadata.internal hides a maintainer-only skill from that listing.
|
||||
project_skills = sorted((ROOT / ".claude" / "skills").glob("*/SKILL.md"))
|
||||
assert project_skills
|
||||
for path in project_skills:
|
||||
frontmatter = _frontmatter(path.read_text(encoding="utf-8"))
|
||||
assert re.search(r"(?m)^metadata:\n internal: true$", frontmatter), path
|
||||
|
||||
|
||||
def test_plugin_and_marketplace_names_agree() -> None:
|
||||
plugin = json.loads(PLUGIN.read_text(encoding="utf-8"))
|
||||
marketplace = json.loads(MARKETPLACE.read_text(encoding="utf-8"))
|
||||
@@ -531,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:
|
||||
|
||||
@@ -1326,3 +1326,29 @@ def test_unreadable_input_exits_cleanly(runner, tmp_path, name, content, cmd):
|
||||
else:
|
||||
assert result.exit_code == 1, result.output # cannot remove a mark from unreadable input
|
||||
assert "Error" in result.output
|
||||
|
||||
|
||||
@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()
|
||||
@@ -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 == []
|
||||
+135
-11
@@ -1,20 +1,27 @@
|
||||
"""Unit tests for how far --cpu-offload travels, per profile.
|
||||
|
||||
The flag sets two residency fields. The face one is read by the shared base and
|
||||
reaches every profile; the global one is read by ``QwenZImagePipeline`` alone, so
|
||||
on ``chroma-zimage`` and ``sdxl-zimage`` it never touches the stack the user is
|
||||
trying to fit. ``WatermarkRemover`` says so before the model load.
|
||||
reaches every profile; the global one is read by ``QwenZImagePipeline`` and
|
||||
``SdxlZImagePipeline``, so on ``chroma-zimage`` it never touches the stack the user
|
||||
is trying to fit. ``WatermarkRemover`` says so before the model load.
|
||||
|
||||
Both halves are exercised with an uninitialized remover, so the core CI matrix
|
||||
needs no diffusion dependency, model download, or GPU.
|
||||
needs no diffusion dependency, model download, or GPU. The SDXL loader placement is
|
||||
driven through stubbed Diffusers constructors and skips without the diffusion extra.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import (
|
||||
RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB,
|
||||
resolve_sdxl_global_residency,
|
||||
)
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
AUTO_PROFILE,
|
||||
CHROMA_ZIMAGE_PROFILE,
|
||||
@@ -29,7 +36,7 @@ from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
||||
OFFLOAD_SUPPORT_CASES = (
|
||||
(QWEN_ZIMAGE_PROFILE, True),
|
||||
(CHROMA_ZIMAGE_PROFILE, False),
|
||||
(SDXL_ZIMAGE_PROFILE, False),
|
||||
(SDXL_ZIMAGE_PROFILE, True),
|
||||
# Auto preloads its concrete Qwen fallback before an image vendor is known.
|
||||
(AUTO_PROFILE, True),
|
||||
)
|
||||
@@ -69,18 +76,20 @@ class TestGlobalOffloadSupport:
|
||||
|
||||
|
||||
class TestUnsupportedProfileWarns:
|
||||
@pytest.mark.parametrize("profile", [CHROMA_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE])
|
||||
def test_it_names_the_profile_and_the_flag(self, profile: str, caplog: pytest.LogCaptureFixture) -> None:
|
||||
def test_it_names_the_profile_and_the_flag(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_remover(profile, cpu_offload=True)._warn_if_global_offload_unsupported()
|
||||
_remover(CHROMA_ZIMAGE_PROFILE, cpu_offload=True)._warn_if_global_offload_unsupported()
|
||||
assert len(caplog.records) == 1
|
||||
message = caplog.records[0].getMessage()
|
||||
assert "--cpu-offload" in message
|
||||
assert profile in message
|
||||
assert CHROMA_ZIMAGE_PROFILE in message
|
||||
|
||||
def test_qwen_zimage_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
@pytest.mark.parametrize("profile", [QWEN_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE])
|
||||
def test_a_profile_that_streams_its_global_stack_is_quiet(
|
||||
self, profile: str, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_remover(QWEN_ZIMAGE_PROFILE, cpu_offload=True)._warn_if_global_offload_unsupported()
|
||||
_remover(profile, cpu_offload=True)._warn_if_global_offload_unsupported()
|
||||
assert caplog.records == []
|
||||
|
||||
def test_a_run_that_did_not_ask_for_offload_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
@@ -132,3 +141,118 @@ class TestUnsupportedProfileWarns:
|
||||
with pytest.raises(ImportError):
|
||||
remover._load_qwen_zimage_pipeline()
|
||||
assert order == ["warned", "loaded"]
|
||||
|
||||
|
||||
class TestSdxlGlobalResidency:
|
||||
@pytest.mark.parametrize(
|
||||
("requested", "total_memory_gib", "expected"),
|
||||
[
|
||||
# Unset: the card decides, and an 8 GiB card cannot hold the stack.
|
||||
(None, 7.6, False),
|
||||
(None, RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB, True),
|
||||
(None, 23.5, True),
|
||||
# An unreadable card reports 0.0 and must stream rather than spill.
|
||||
(None, 0.0, False),
|
||||
# Explicit requests win in both directions.
|
||||
(False, 79.2, False),
|
||||
(True, 7.6, True),
|
||||
],
|
||||
)
|
||||
def test_request_wins_and_the_card_decides_otherwise(
|
||||
self, requested: bool | None, total_memory_gib: float, expected: bool
|
||||
) -> None:
|
||||
assert resolve_sdxl_global_residency(requested, total_memory_gib=total_memory_gib) is expected
|
||||
|
||||
|
||||
class _RecordingSdxlPipe:
|
||||
"""Stands in for the Diffusers SDXL pipeline and records placement calls in order."""
|
||||
|
||||
def __init__(self, calls: list[Any]) -> None:
|
||||
self.calls = calls
|
||||
self.scheduler = MagicMock()
|
||||
|
||||
def to(self, device: str) -> _RecordingSdxlPipe:
|
||||
self.calls.append(("to", device))
|
||||
return self
|
||||
|
||||
def load_lora_weights(self, _path: str) -> None:
|
||||
self.calls.append(("load_lora_weights",))
|
||||
|
||||
def fuse_lora(self) -> None:
|
||||
self.calls.append(("fuse_lora",))
|
||||
|
||||
def enable_sequential_cpu_offload(self, *, device: str, gpu_id: int | None = None) -> None:
|
||||
self.calls.append(("enable_sequential_cpu_offload", device, gpu_id))
|
||||
|
||||
|
||||
class TestSdxlGlobalPlacement:
|
||||
@staticmethod
|
||||
def _load(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
requested: bool | None,
|
||||
total_memory_gib: float,
|
||||
current_device: int = 0,
|
||||
) -> list[Any]:
|
||||
diffusers = pytest.importorskip("diffusers")
|
||||
torch = pytest.importorskip("torch")
|
||||
monkeypatch.setattr(torch.cuda, "current_device", lambda: current_device)
|
||||
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import SdxlZImagePipeline
|
||||
|
||||
calls: list[Any] = []
|
||||
monkeypatch.setattr(diffusers.ControlNetModel, "from_pretrained", lambda *a, **k: MagicMock())
|
||||
monkeypatch.setattr(diffusers.AutoencoderKL, "from_pretrained", lambda *a, **k: MagicMock())
|
||||
monkeypatch.setattr(
|
||||
diffusers.StableDiffusionXLControlNetImg2ImgPipeline,
|
||||
"from_pretrained",
|
||||
lambda *a, **k: _RecordingSdxlPipe(calls),
|
||||
)
|
||||
monkeypatch.setattr("huggingface_hub.hf_hub_download", lambda *a, **k: "lora.safetensors")
|
||||
monkeypatch.setattr(diffusers.EulerDiscreteScheduler, "from_config", lambda *a, **k: MagicMock())
|
||||
|
||||
pipeline = SdxlZImagePipeline(device="cuda", torch_dtype=None, keep_global_models_on_device=requested)
|
||||
monkeypatch.setattr(type(pipeline), "_require_cuda", lambda self: None)
|
||||
monkeypatch.setattr(type(pipeline), "_total_vram_gib", lambda self: total_memory_gib)
|
||||
pipeline._load_global()
|
||||
return calls
|
||||
|
||||
def test_a_small_card_streams_the_fused_stack_instead_of_moving_it(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = self._load(monkeypatch, requested=None, total_memory_gib=7.6)
|
||||
# The offload hooks stream their own copy of each weight, so they must follow the fuse.
|
||||
assert calls == [("load_lora_weights",), ("fuse_lora",), ("enable_sequential_cpu_offload", "cuda", 0)]
|
||||
|
||||
def test_a_large_card_keeps_the_stack_resident_as_before(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = self._load(monkeypatch, requested=None, total_memory_gib=23.5)
|
||||
assert calls == [("to", "cuda"), ("load_lora_weights",), ("fuse_lora",)]
|
||||
|
||||
def test_cpu_offload_streams_even_on_a_large_card(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = self._load(monkeypatch, requested=False, total_memory_gib=79.2)
|
||||
assert ("to", "cuda") not in calls
|
||||
assert calls[-1] == ("enable_sequential_cpu_offload", "cuda", 0)
|
||||
|
||||
@pytest.mark.parametrize("current_device", [1, 2])
|
||||
@pytest.mark.parametrize(("requested", "total_memory_gib"), [(None, 7.6), (False, 79.2)])
|
||||
def test_streaming_uses_the_current_cuda_device(
|
||||
self, monkeypatch: pytest.MonkeyPatch, current_device: int, requested: bool | None, total_memory_gib: float
|
||||
) -> None:
|
||||
calls = self._load(
|
||||
monkeypatch,
|
||||
requested=requested,
|
||||
total_memory_gib=total_memory_gib,
|
||||
current_device=current_device,
|
||||
)
|
||||
assert ("to", "cuda") not in calls
|
||||
assert calls[-1] == ("enable_sequential_cpu_offload", "cuda", current_device)
|
||||
|
||||
def test_cpu_offload_reaches_the_sdxl_stack(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from remove_ai_watermarks._internal import sdxl_zimage_pipeline
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class Recorder:
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(sdxl_zimage_pipeline, "SdxlZImagePipeline", Recorder)
|
||||
_remover(SDXL_ZIMAGE_PROFILE, cpu_offload=True)._load_qwen_zimage_pipeline()
|
||||
assert captured["keep_global_models_on_device"] is False
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 the file is clean" 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
|
||||
@@ -192,7 +192,7 @@ class TestNoReembeddedWatermark:
|
||||
# from_config would otherwise resolve the mock's config as a repo id.
|
||||
monkeypatch.setattr(diffusers.EulerDiscreteScheduler, "from_config", lambda *a, **k: MagicMock())
|
||||
|
||||
pipeline = SdxlZImagePipeline(device="cuda", torch_dtype=None)
|
||||
pipeline = SdxlZImagePipeline(device="cuda", torch_dtype=None, keep_global_models_on_device=True)
|
||||
monkeypatch.setattr(type(pipeline), "_require_cuda", lambda self: None)
|
||||
pipeline._load_global()
|
||||
|
||||
|
||||
@@ -392,12 +392,14 @@ def test_run_web_batch_requires_explicit_upload_acknowledgement(
|
||||
web.run_web_batch(manifest_path, acknowledge_uploads=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("surface", ["gemini-web", "synthid-web"])
|
||||
def test_run_web_batch_refuses_google_real_browser_workflow(
|
||||
tmp_path: Path,
|
||||
tmp_clean_png: Path,
|
||||
surface: str,
|
||||
) -> None:
|
||||
manifest_path = oracles.prepare_batch(
|
||||
"gemini-web",
|
||||
surface,
|
||||
[tmp_clean_png],
|
||||
output_dir=tmp_path / "oracle-batch",
|
||||
repository_root=oracles.REPOSITORY_ROOT,
|
||||
|
||||
@@ -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,14 @@ 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 == "real_browser"
|
||||
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 +61,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 +69,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 +79,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"]) for row in payload] == [
|
||||
("gemini-web", "primary"),
|
||||
("synthid-web", "primary"),
|
||||
]
|
||||
|
||||
|
||||
def test_plan_json_marks_web_as_an_explicit_fallback_when_api_exists() -> None:
|
||||
result = CliRunner().invoke(oracles.cli, ["plan", "openai", "--json"])
|
||||
|
||||
@@ -255,11 +276,32 @@ def test_slots_override_requires_a_named_slot(tmp_path: Path) -> None:
|
||||
},
|
||||
"only openai-api uses api_key_env",
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": "synthid-no-profile",
|
||||
"surface": "synthid-web",
|
||||
"account_label": "personal-a",
|
||||
"browser_profile": None,
|
||||
"network_label": "home",
|
||||
},
|
||||
"requires a browser_profile",
|
||||
),
|
||||
(
|
||||
{
|
||||
"name": "synthid-with-account-index",
|
||||
"surface": "synthid-web",
|
||||
"account_label": "personal-a",
|
||||
"browser_profile": "Profile 2",
|
||||
"google_account_index": 0,
|
||||
"network_label": "home",
|
||||
},
|
||||
"only gemini-web uses google_account_index",
|
||||
),
|
||||
],
|
||||
)
|
||||
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"
|
||||
|
||||
@@ -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"}
|
||||
+121
-5
@@ -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
|
||||
|
||||
@@ -898,7 +898,7 @@ nvtx = [
|
||||
|
||||
[[package]]
|
||||
name = "diffsynth"
|
||||
version = "2.1.8"
|
||||
version = "2.1.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "accelerate" },
|
||||
@@ -913,18 +913,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 +932,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]]
|
||||
@@ -1476,6 +1475,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 +1621,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"
|
||||
@@ -2061,7 +2125,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime"
|
||||
version = "1.30.0"
|
||||
version = "1.31.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flatbuffers" },
|
||||
@@ -2070,35 +2134,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.22.1"
|
||||
version = "3.29.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -2108,9 +2172,9 @@ dependencies = [
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/db/b135830095e2ae120ea04b0a53d3a1644eb65554385a8ef4c9cb4de3f5fd/openai-3.22.1.tar.gz", hash = "sha256:2575f961e4c1df8761f8eeb4d78e967de3f9b9f91b22f7120d60e393e970094d", size = 1751626, upload-time = "2026-09-30T00:25:55.548Z" }
|
||||
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/32/74/e3b77d1157c8b9ad815a87d5719545097e5edb06156869c1657fbb6b797b/openai-3.22.1-py3-none-any.whl", hash = "sha256:43ff3de5457a8beb4afa177d4e0ba6841cfadd5219ebc62297fde6c8a9d401a6", size = 2090965, upload-time = "2026-09-30T00:25:53.524Z" },
|
||||
{ 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 +2232,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 +2498,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 +2952,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"
|
||||
@@ -3065,11 +3155,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.3"
|
||||
version = "1.2.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
||||
{ 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]]
|
||||
@@ -3221,6 +3320,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 +3374,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 +3478,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.45.0"
|
||||
version = "0.46.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "c2pa-python" },
|
||||
@@ -3406,6 +3538,7 @@ dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "invisible-watermark" },
|
||||
{ name = "mcp" },
|
||||
{ name = "numpy" },
|
||||
{ name = "openai" },
|
||||
{ name = "opencv-python-headless" },
|
||||
@@ -3516,6 +3649,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 +3726,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 +3843,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.9"
|
||||
version = "0.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
|
||||
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/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
|
||||
{ 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 +4035,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 +4467,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", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "h11", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
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"
|
||||
|
||||
Reference in new issue
Block a user