diff --git a/.env.example b/.env.example index 2c05aaf..7b2641e 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,11 @@ # Fallback cache root when HF_HOME is unset (optional) # XDG_CACHE_HOME=/path/to/cache +# Gemini API key (optional; only for generating Google provider samples by hand) +# Paid tier: Nano Banana 2 Lite and Gemini Omni Flash have no free tier. +# Create and manage keys at: https://aistudio.google.com/apikey +# GEMINI_API_KEY= + # Named OpenAI keys for provider-oracle slots (optional; keep each value secret) # OPENAI_API_KEY_1= # OPENAI_API_KEY_2= diff --git a/CLAUDE.md b/CLAUDE.md index 3826182..bfe5d97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,7 @@ The project gives users control over provenance marks on content they generated - Add visible templates only for AI-generation labels. - Do not add stock, agency, or classifieds marks to `watermark_registry.py`. +- Do not add templates for regulator-defined disclosure icons (EU Code of Practice Annex 1). - Keep `erase --region` generic and user-directed; do not build an automatic stock-watermark remover on it. Full boundary and legal context: [`docs/legal-and-safety.md`](docs/legal-and-safety.md). diff --git a/README.md b/README.md index d3e04c6..fd4bbdd 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,12 @@ removal. [![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-db61a2?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/wiltodelta) [![skills.sh](https://skills.sh/b/wiltodelta/remove-ai-watermarks)](https://skills.sh/wiltodelta/remove-ai-watermarks) -> This project is for lawful use on content you own. It does not target stock -> agency previews or other watermarks that protect third party paid content. -> See [scope, safety, and legal notes](docs/legal-and-safety.md). +> This is a research project on AI provenance signals, for lawful use on content +> you own. It is provided as is, without warranty or liability, and each user is +> responsible for how they use it. It does not target stock agency previews or +> other watermarks that protect third party paid content. Some jurisdictions, +> including China, restrict removing AI labels or supplying tools that do. See +> [scope, safety, and legal notes](docs/legal-and-safety.md). ## Choose what you want to do @@ -490,7 +493,7 @@ invisible removal. detail. - Visible video removal recognizes the moving Sora 2 wordmark, the current Veo diamond plus legacy `Veo` text, the Seedance boxed `AI` label, and the fixed - Doubao, Dola, Hailuo AI, and Kling AI labels. It does not recognize the older Sora Turbo + Doubao, Dola, Hailuo AI, Vidu, and Kling AI labels. It does not recognize the older Sora Turbo corner swirl or unregistered layouts from those providers. The classical OpenCV backend can smear structured backgrounds; use MI-GAN or LaMa when recovery quality matters. diff --git a/data/README.md b/data/README.md index 82c4c37..c7d5b3b 100644 --- a/data/README.md +++ b/data/README.md @@ -10,6 +10,9 @@ data/ visible/ Per-mark example gallery: one synthetic canonical example per registered mark plus documented provider originals where available (see fixtures/visible/README.md) + captures/ + 2026-09/ Provider outputs from the maintainer's own accounts, stored as + served, indexed by manifest.csv (see its README) calibration/ / Minimal controlled inputs needed to rebuild detector assets synthid/ @@ -51,9 +54,15 @@ evaluation outputs outside the repository. `evaluations/engine-selection/` is the bounded exception for ORIGINAL study inputs: its manifest tracks a prompt-matched OpenAI/Meta content matrix, not generated model outputs or watermark-oracle evidence. -6. Keep third-party fixture license notices in `licenses/`, outside directories +6. Put provider captures that no test reads in `captures//`, with a + `manifest.csv` row per capture. A capture that is also a fixture is stored + once, in `fixtures/`, and its manifest row points there. Request a new + capture only for a service x vendor pair not yet captured, or for a + service's own model: another version of a known pair adds no provenance + evidence. +7. Keep third-party fixture license notices in `licenses/`, outside directories that tests enumerate as media inputs. -7. Runtime detector assets belong in `src/remove_ai_watermarks/assets/`. +8. Runtime detector assets belong in `src/remove_ai_watermarks/assets/`. Unregistered research candidates belong in `scripts/assets/visible-mark-candidates/` so they are not shipped in the wheel. diff --git a/data/captures/2026-09/README.md b/data/captures/2026-09/README.md new file mode 100644 index 0000000..cc53886 --- /dev/null +++ b/data/captures/2026-09/README.md @@ -0,0 +1,44 @@ +# Provider captures, September 2026 + +Original outputs downloaded from the maintainer's own accounts between +2026-09-23 and 2026-09-25 and cleared for publication by the maintainer. The +services are the Gemini API, xAI Grok, Vidu, Wan, Apple Image Playground, +YouTube (re-encodes of our own uploads), and the paid Higgsfield, Runway and +Dreamina plans. Every prompt is a neutral coffee-mug scene; Higgsfield +Genjutsu and the video-edit samples take our own Higgsfield outputs as input. + +Files are stored byte-for-byte as served. Nothing is re-encoded, because the +provenance under study (C2PA, TC260 labels, `hf-job-id` chunks) lives in the +container bytes. + +`manifest.csv` lists every capture once, including the ones that are already +test fixtures: those rows point at their `data/fixtures/` path instead of a +second copy (`stored` is `existing`). Columns: + +| Column | Meaning | +| --- | --- | +| `sha256` | Digest of the stored file | +| `path` | Canonical repository path | +| `stored` | `new` for files in this directory, `existing` for a fixture already tracked | +| `service` | Service the file was downloaded from | +| `source_name` | Local capture name, which names the model | +| `captured` | Capture date | +| `ai_from_metadata`, `platform` | `identify` (images) or `identify_video` (videos) verdict from metadata only, at capture time | + +What each service does to the upstream provenance, and the per-model +findings, are summarized in the live-captures table of +[`docs/watermarking-landscape.md`](../../../docs/watermarking-landscape.md). +The `google-photos/` files are the maintainer's own iPhone photos edited in +Google Photos on iOS (an Ask edit replacing a dog with a cat, an eraser edit, and +two other AI edits), published unmodified with their EXIF GPS position by the +maintainer's decision on 2026-09-25. Google's checker in Gemini found SynthID in +all four with the metadata stripped. The Apple Clean Up photo is not stored. +The `google-photos-web/` files are a negative control from the Google Photos web +editor (2026-09-26): one of the maintainer's photos, downloaded untouched and after +Enhance, Sky (Vivid) and a square crop. The web editor offers no generative tool and +writes no Content Credentials, so none of the four carries C2PA, IPTC or an AI +credit, and Google's checker in Gemini found no SynthID in any of them (three +explicit negatives; the crop drew a reply that did not name SynthID and is recorded +as indeterminate). + +- License: repository license; generated and explicitly cleared by the maintainer diff --git a/data/captures/2026-09/dreamina/gpt-image-2-5-flare.png b/data/captures/2026-09/dreamina/gpt-image-2-5-flare.png new file mode 100644 index 0000000..47fe506 Binary files /dev/null and b/data/captures/2026-09/dreamina/gpt-image-2-5-flare.png differ diff --git a/data/captures/2026-09/dreamina/gpt-image-2-5-sunburst.png b/data/captures/2026-09/dreamina/gpt-image-2-5-sunburst.png new file mode 100644 index 0000000..cc60181 Binary files /dev/null and b/data/captures/2026-09/dreamina/gpt-image-2-5-sunburst.png differ diff --git a/data/captures/2026-09/dreamina/gpt-image-2.png b/data/captures/2026-09/dreamina/gpt-image-2.png new file mode 100644 index 0000000..0038e3f Binary files /dev/null and b/data/captures/2026-09/dreamina/gpt-image-2.png differ diff --git a/data/captures/2026-09/dreamina/minimax-h3.mp4 b/data/captures/2026-09/dreamina/minimax-h3.mp4 new file mode 100644 index 0000000..7dae4e2 Binary files /dev/null and b/data/captures/2026-09/dreamina/minimax-h3.mp4 differ diff --git a/data/captures/2026-09/dreamina/nano-banana-pro.jpeg b/data/captures/2026-09/dreamina/nano-banana-pro.jpeg new file mode 100644 index 0000000..384d57c Binary files /dev/null and b/data/captures/2026-09/dreamina/nano-banana-pro.jpeg differ diff --git a/data/captures/2026-09/dreamina/nano-banana.jpeg b/data/captures/2026-09/dreamina/nano-banana.jpeg new file mode 100644 index 0000000..7f49852 Binary files /dev/null and b/data/captures/2026-09/dreamina/nano-banana.jpeg differ diff --git a/data/captures/2026-09/dreamina/seedance-1-0-fast.mp4 b/data/captures/2026-09/dreamina/seedance-1-0-fast.mp4 new file mode 100644 index 0000000..968d01c Binary files /dev/null and b/data/captures/2026-09/dreamina/seedance-1-0-fast.mp4 differ diff --git a/data/captures/2026-09/dreamina/seedance-1-0.mp4 b/data/captures/2026-09/dreamina/seedance-1-0.mp4 new file mode 100644 index 0000000..e97365f Binary files /dev/null and b/data/captures/2026-09/dreamina/seedance-1-0.mp4 differ diff --git a/data/captures/2026-09/dreamina/seedance-1-5-pro.mp4 b/data/captures/2026-09/dreamina/seedance-1-5-pro.mp4 new file mode 100644 index 0000000..6142b6b Binary files /dev/null and b/data/captures/2026-09/dreamina/seedance-1-5-pro.mp4 differ diff --git a/data/captures/2026-09/dreamina/seedance-2-0-fast.mp4 b/data/captures/2026-09/dreamina/seedance-2-0-fast.mp4 new file mode 100644 index 0000000..98bf641 Binary files /dev/null and b/data/captures/2026-09/dreamina/seedance-2-0-fast.mp4 differ diff --git a/data/captures/2026-09/dreamina/seedance-2-0.mp4 b/data/captures/2026-09/dreamina/seedance-2-0.mp4 new file mode 100644 index 0000000..e03bd85 Binary files /dev/null and b/data/captures/2026-09/dreamina/seedance-2-0.mp4 differ diff --git a/data/captures/2026-09/dreamina/seedance-2-5.mp4 b/data/captures/2026-09/dreamina/seedance-2-5.mp4 new file mode 100644 index 0000000..ecc3509 Binary files /dev/null and b/data/captures/2026-09/dreamina/seedance-2-5.mp4 differ diff --git a/data/captures/2026-09/dreamina/seedream-3-0.jpeg b/data/captures/2026-09/dreamina/seedream-3-0.jpeg new file mode 100644 index 0000000..916f101 Binary files /dev/null and b/data/captures/2026-09/dreamina/seedream-3-0.jpeg differ diff --git a/data/captures/2026-09/dreamina/seedream-3-1.jpeg b/data/captures/2026-09/dreamina/seedream-3-1.jpeg new file mode 100644 index 0000000..d38df76 Binary files /dev/null and b/data/captures/2026-09/dreamina/seedream-3-1.jpeg differ diff --git a/data/captures/2026-09/dreamina/seedream-4-0.jpeg b/data/captures/2026-09/dreamina/seedream-4-0.jpeg new file mode 100644 index 0000000..f8a5d72 Binary files /dev/null and b/data/captures/2026-09/dreamina/seedream-4-0.jpeg differ diff --git a/data/captures/2026-09/dreamina/seedream-4-1.jpeg b/data/captures/2026-09/dreamina/seedream-4-1.jpeg new file mode 100644 index 0000000..0929297 Binary files /dev/null and b/data/captures/2026-09/dreamina/seedream-4-1.jpeg differ diff --git a/data/captures/2026-09/dreamina/seedream-4-5.jpeg b/data/captures/2026-09/dreamina/seedream-4-5.jpeg new file mode 100644 index 0000000..4dbd6ac Binary files /dev/null and b/data/captures/2026-09/dreamina/seedream-4-5.jpeg differ diff --git a/data/captures/2026-09/dreamina/seedream-4-6.jpeg b/data/captures/2026-09/dreamina/seedream-4-6.jpeg new file mode 100644 index 0000000..12a8089 Binary files /dev/null and b/data/captures/2026-09/dreamina/seedream-4-6.jpeg differ diff --git a/data/captures/2026-09/dreamina/seedream-4-7.jpeg b/data/captures/2026-09/dreamina/seedream-4-7.jpeg new file mode 100644 index 0000000..0882918 Binary files /dev/null and b/data/captures/2026-09/dreamina/seedream-4-7.jpeg differ diff --git a/data/captures/2026-09/dreamina/seedream-5-0-lite.jpeg b/data/captures/2026-09/dreamina/seedream-5-0-lite.jpeg new file mode 100644 index 0000000..6c636fb Binary files /dev/null and b/data/captures/2026-09/dreamina/seedream-5-0-lite.jpeg differ diff --git a/data/captures/2026-09/dreamina/seedream-5-pro-2.jpeg b/data/captures/2026-09/dreamina/seedream-5-pro-2.jpeg new file mode 100644 index 0000000..41f8248 Binary files /dev/null and b/data/captures/2026-09/dreamina/seedream-5-pro-2.jpeg differ diff --git a/data/captures/2026-09/google-photos-web/forest-crop-square.jpg b/data/captures/2026-09/google-photos-web/forest-crop-square.jpg new file mode 100644 index 0000000..b788e49 Binary files /dev/null and b/data/captures/2026-09/google-photos-web/forest-crop-square.jpg differ diff --git a/data/captures/2026-09/google-photos-web/forest-enhance.jpg b/data/captures/2026-09/google-photos-web/forest-enhance.jpg new file mode 100644 index 0000000..f29e96e Binary files /dev/null and b/data/captures/2026-09/google-photos-web/forest-enhance.jpg differ diff --git a/data/captures/2026-09/google-photos-web/forest-original.jpg b/data/captures/2026-09/google-photos-web/forest-original.jpg new file mode 100644 index 0000000..91c59f4 Binary files /dev/null and b/data/captures/2026-09/google-photos-web/forest-original.jpg differ diff --git a/data/captures/2026-09/google-photos-web/forest-sky-vivid.jpg b/data/captures/2026-09/google-photos-web/forest-sky-vivid.jpg new file mode 100644 index 0000000..86fbe98 Binary files /dev/null and b/data/captures/2026-09/google-photos-web/forest-sky-vivid.jpg differ diff --git a/data/captures/2026-09/google-photos/ask-dog-to-cat.jpg b/data/captures/2026-09/google-photos/ask-dog-to-cat.jpg new file mode 100644 index 0000000..413d30c Binary files /dev/null and b/data/captures/2026-09/google-photos/ask-dog-to-cat.jpg differ diff --git a/data/captures/2026-09/google-photos/eraser-glasses.jpg b/data/captures/2026-09/google-photos/eraser-glasses.jpg new file mode 100644 index 0000000..1fd7f57 Binary files /dev/null and b/data/captures/2026-09/google-photos/eraser-glasses.jpg differ diff --git a/data/captures/2026-09/google-photos/gphotos-5.jpg b/data/captures/2026-09/google-photos/gphotos-5.jpg new file mode 100644 index 0000000..89cc0f2 Binary files /dev/null and b/data/captures/2026-09/google-photos/gphotos-5.jpg differ diff --git a/data/captures/2026-09/google-photos/gphotos-6.jpg b/data/captures/2026-09/google-photos/gphotos-6.jpg new file mode 100644 index 0000000..c8044a8 Binary files /dev/null and b/data/captures/2026-09/google-photos/gphotos-6.jpg differ diff --git a/data/captures/2026-09/grok/download-0.jpg b/data/captures/2026-09/grok/download-0.jpg new file mode 100644 index 0000000..58a3fa9 Binary files /dev/null and b/data/captures/2026-09/grok/download-0.jpg differ diff --git a/data/captures/2026-09/higgsfield/cinema-studio-3-0.mp4 b/data/captures/2026-09/higgsfield/cinema-studio-3-0.mp4 new file mode 100644 index 0000000..c27ce50 Binary files /dev/null and b/data/captures/2026-09/higgsfield/cinema-studio-3-0.mp4 differ diff --git a/data/captures/2026-09/higgsfield/edit-flux-3.mp4 b/data/captures/2026-09/higgsfield/edit-flux-3.mp4 new file mode 100644 index 0000000..bd3a8c0 Binary files /dev/null and b/data/captures/2026-09/higgsfield/edit-flux-3.mp4 differ diff --git a/data/captures/2026-09/higgsfield/edit-gemini-omni-1-1.mp4 b/data/captures/2026-09/higgsfield/edit-gemini-omni-1-1.mp4 new file mode 100644 index 0000000..b94b76f Binary files /dev/null and b/data/captures/2026-09/higgsfield/edit-gemini-omni-1-1.mp4 differ diff --git a/data/captures/2026-09/higgsfield/edit-kling-3-0-omni.mp4 b/data/captures/2026-09/higgsfield/edit-kling-3-0-omni.mp4 new file mode 100644 index 0000000..693666a Binary files /dev/null and b/data/captures/2026-09/higgsfield/edit-kling-3-0-omni.mp4 differ diff --git a/data/captures/2026-09/higgsfield/flux-2-flex.png b/data/captures/2026-09/higgsfield/flux-2-flex.png new file mode 100644 index 0000000..8901c44 Binary files /dev/null and b/data/captures/2026-09/higgsfield/flux-2-flex.png differ diff --git a/data/captures/2026-09/higgsfield/flux-2-max.png b/data/captures/2026-09/higgsfield/flux-2-max.png new file mode 100644 index 0000000..074533c Binary files /dev/null and b/data/captures/2026-09/higgsfield/flux-2-max.png differ diff --git a/data/captures/2026-09/higgsfield/flux-3-video.mp4 b/data/captures/2026-09/higgsfield/flux-3-video.mp4 new file mode 100644 index 0000000..9c22b38 Binary files /dev/null and b/data/captures/2026-09/higgsfield/flux-3-video.mp4 differ diff --git a/data/captures/2026-09/higgsfield/gemini-omni-flash-1-1.mp4 b/data/captures/2026-09/higgsfield/gemini-omni-flash-1-1.mp4 new file mode 100644 index 0000000..959e121 Binary files /dev/null and b/data/captures/2026-09/higgsfield/gemini-omni-flash-1-1.mp4 differ diff --git a/data/captures/2026-09/higgsfield/gemini-omni-flash.mp4 b/data/captures/2026-09/higgsfield/gemini-omni-flash.mp4 new file mode 100644 index 0000000..038a89f Binary files /dev/null and b/data/captures/2026-09/higgsfield/gemini-omni-flash.mp4 differ diff --git a/data/captures/2026-09/higgsfield/genjutsu-replace-object.mp4 b/data/captures/2026-09/higgsfield/genjutsu-replace-object.mp4 new file mode 100644 index 0000000..2d38041 Binary files /dev/null and b/data/captures/2026-09/higgsfield/genjutsu-replace-object.mp4 differ diff --git a/data/captures/2026-09/higgsfield/gpt-image-1-5.png b/data/captures/2026-09/higgsfield/gpt-image-1-5.png new file mode 100644 index 0000000..8a11b9b Binary files /dev/null and b/data/captures/2026-09/higgsfield/gpt-image-1-5.png differ diff --git a/data/captures/2026-09/higgsfield/gpt-image-2-5-flare.png b/data/captures/2026-09/higgsfield/gpt-image-2-5-flare.png new file mode 100644 index 0000000..530d7f6 Binary files /dev/null and b/data/captures/2026-09/higgsfield/gpt-image-2-5-flare.png differ diff --git a/data/captures/2026-09/higgsfield/gpt-image-2-5-sunburst.png b/data/captures/2026-09/higgsfield/gpt-image-2-5-sunburst.png new file mode 100644 index 0000000..d9e67b1 Binary files /dev/null and b/data/captures/2026-09/higgsfield/gpt-image-2-5-sunburst.png differ diff --git a/data/captures/2026-09/higgsfield/gpt-image-2.png b/data/captures/2026-09/higgsfield/gpt-image-2.png new file mode 100644 index 0000000..f482fec Binary files /dev/null and b/data/captures/2026-09/higgsfield/gpt-image-2.png differ diff --git a/data/captures/2026-09/higgsfield/grok-imagine-2-0.png b/data/captures/2026-09/higgsfield/grok-imagine-2-0.png new file mode 100644 index 0000000..3eaca70 Binary files /dev/null and b/data/captures/2026-09/higgsfield/grok-imagine-2-0.png differ diff --git a/data/captures/2026-09/higgsfield/grok-imagine.png b/data/captures/2026-09/higgsfield/grok-imagine.png new file mode 100644 index 0000000..24ff75e Binary files /dev/null and b/data/captures/2026-09/higgsfield/grok-imagine.png differ diff --git a/data/captures/2026-09/higgsfield/grok-video-1-5.mp4 b/data/captures/2026-09/higgsfield/grok-video-1-5.mp4 new file mode 100644 index 0000000..d4c84e7 Binary files /dev/null and b/data/captures/2026-09/higgsfield/grok-video-1-5.mp4 differ diff --git a/data/captures/2026-09/higgsfield/grok-video.mp4 b/data/captures/2026-09/higgsfield/grok-video.mp4 new file mode 100644 index 0000000..547db63 Binary files /dev/null and b/data/captures/2026-09/higgsfield/grok-video.mp4 differ diff --git a/data/captures/2026-09/higgsfield/kling-2-6.mp4 b/data/captures/2026-09/higgsfield/kling-2-6.mp4 new file mode 100644 index 0000000..60caebe Binary files /dev/null and b/data/captures/2026-09/higgsfield/kling-2-6.mp4 differ diff --git a/data/captures/2026-09/higgsfield/kling-3-0.mp4 b/data/captures/2026-09/higgsfield/kling-3-0.mp4 new file mode 100644 index 0000000..b79c990 Binary files /dev/null and b/data/captures/2026-09/higgsfield/kling-3-0.mp4 differ diff --git a/data/captures/2026-09/higgsfield/marketing-studio.mp4 b/data/captures/2026-09/higgsfield/marketing-studio.mp4 new file mode 100644 index 0000000..8beca28 Binary files /dev/null and b/data/captures/2026-09/higgsfield/marketing-studio.mp4 differ diff --git a/data/captures/2026-09/higgsfield/minimax-h3-max.mp4 b/data/captures/2026-09/higgsfield/minimax-h3-max.mp4 new file mode 100644 index 0000000..8b5b740 Binary files /dev/null and b/data/captures/2026-09/higgsfield/minimax-h3-max.mp4 differ diff --git a/data/captures/2026-09/higgsfield/nano-banana-2-lite.png b/data/captures/2026-09/higgsfield/nano-banana-2-lite.png new file mode 100644 index 0000000..2ab063e Binary files /dev/null and b/data/captures/2026-09/higgsfield/nano-banana-2-lite.png differ diff --git a/data/captures/2026-09/higgsfield/nano-banana-2.png b/data/captures/2026-09/higgsfield/nano-banana-2.png new file mode 100644 index 0000000..c0c22b2 Binary files /dev/null and b/data/captures/2026-09/higgsfield/nano-banana-2.png differ diff --git a/data/captures/2026-09/higgsfield/nano-banana-pro.png b/data/captures/2026-09/higgsfield/nano-banana-pro.png new file mode 100644 index 0000000..65e7279 Binary files /dev/null and b/data/captures/2026-09/higgsfield/nano-banana-pro.png differ diff --git a/data/captures/2026-09/higgsfield/nano-banana.png b/data/captures/2026-09/higgsfield/nano-banana.png new file mode 100644 index 0000000..0d82baa Binary files /dev/null and b/data/captures/2026-09/higgsfield/nano-banana.png differ diff --git a/data/captures/2026-09/higgsfield/recraft-v4-1-utility.png b/data/captures/2026-09/higgsfield/recraft-v4-1-utility.png new file mode 100644 index 0000000..0b2c6d0 Binary files /dev/null and b/data/captures/2026-09/higgsfield/recraft-v4-1-utility.png differ diff --git a/data/captures/2026-09/higgsfield/recraft-v4-1.png b/data/captures/2026-09/higgsfield/recraft-v4-1.png new file mode 100644 index 0000000..c64c04d Binary files /dev/null and b/data/captures/2026-09/higgsfield/recraft-v4-1.png differ diff --git a/data/captures/2026-09/higgsfield/seedance-1-5-pro.mp4 b/data/captures/2026-09/higgsfield/seedance-1-5-pro.mp4 new file mode 100644 index 0000000..c738ee6 Binary files /dev/null and b/data/captures/2026-09/higgsfield/seedance-1-5-pro.mp4 differ diff --git a/data/captures/2026-09/higgsfield/seedance-2-0.mp4 b/data/captures/2026-09/higgsfield/seedance-2-0.mp4 new file mode 100644 index 0000000..79fd20b Binary files /dev/null and b/data/captures/2026-09/higgsfield/seedance-2-0.mp4 differ diff --git a/data/captures/2026-09/higgsfield/seedance-2-5.mp4 b/data/captures/2026-09/higgsfield/seedance-2-5.mp4 new file mode 100644 index 0000000..ebd8c33 Binary files /dev/null and b/data/captures/2026-09/higgsfield/seedance-2-5.mp4 differ diff --git a/data/captures/2026-09/higgsfield/seedream-4-0.png b/data/captures/2026-09/higgsfield/seedream-4-0.png new file mode 100644 index 0000000..cc52053 Binary files /dev/null and b/data/captures/2026-09/higgsfield/seedream-4-0.png differ diff --git a/data/captures/2026-09/higgsfield/seedream-4-5.png b/data/captures/2026-09/higgsfield/seedream-4-5.png new file mode 100644 index 0000000..abd879b Binary files /dev/null and b/data/captures/2026-09/higgsfield/seedream-4-5.png differ diff --git a/data/captures/2026-09/higgsfield/seedream-5-flash.png b/data/captures/2026-09/higgsfield/seedream-5-flash.png new file mode 100644 index 0000000..51e39c6 Binary files /dev/null and b/data/captures/2026-09/higgsfield/seedream-5-flash.png differ diff --git a/data/captures/2026-09/higgsfield/seedream-5-lite.png b/data/captures/2026-09/higgsfield/seedream-5-lite.png new file mode 100644 index 0000000..a295e8a Binary files /dev/null and b/data/captures/2026-09/higgsfield/seedream-5-lite.png differ diff --git a/data/captures/2026-09/higgsfield/seedream-5-pro.png b/data/captures/2026-09/higgsfield/seedream-5-pro.png new file mode 100644 index 0000000..27658f4 Binary files /dev/null and b/data/captures/2026-09/higgsfield/seedream-5-pro.png differ diff --git a/data/captures/2026-09/higgsfield/soul-cinema.png b/data/captures/2026-09/higgsfield/soul-cinema.png new file mode 100644 index 0000000..34135f5 Binary files /dev/null and b/data/captures/2026-09/higgsfield/soul-cinema.png differ diff --git a/data/captures/2026-09/higgsfield/soul-v1.png b/data/captures/2026-09/higgsfield/soul-v1.png new file mode 100644 index 0000000..c2922d6 Binary files /dev/null and b/data/captures/2026-09/higgsfield/soul-v1.png differ diff --git a/data/captures/2026-09/higgsfield/soul2-image.png b/data/captures/2026-09/higgsfield/soul2-image.png new file mode 100644 index 0000000..d3c9299 Binary files /dev/null and b/data/captures/2026-09/higgsfield/soul2-image.png differ diff --git a/data/captures/2026-09/higgsfield/veo-3-1.mp4 b/data/captures/2026-09/higgsfield/veo-3-1.mp4 new file mode 100644 index 0000000..fb8f5a3 Binary files /dev/null and b/data/captures/2026-09/higgsfield/veo-3-1.mp4 differ diff --git a/data/captures/2026-09/higgsfield/wan-2-2.png b/data/captures/2026-09/higgsfield/wan-2-2.png new file mode 100644 index 0000000..19ba4fd Binary files /dev/null and b/data/captures/2026-09/higgsfield/wan-2-2.png differ diff --git a/data/captures/2026-09/higgsfield/wan-2-5.mp4 b/data/captures/2026-09/higgsfield/wan-2-5.mp4 new file mode 100644 index 0000000..141db6c Binary files /dev/null and b/data/captures/2026-09/higgsfield/wan-2-5.mp4 differ diff --git a/data/captures/2026-09/higgsfield/wan-2-6.mp4 b/data/captures/2026-09/higgsfield/wan-2-6.mp4 new file mode 100644 index 0000000..24eb5b6 Binary files /dev/null and b/data/captures/2026-09/higgsfield/wan-2-6.mp4 differ diff --git a/data/captures/2026-09/higgsfield/wan-2-7.mp4 b/data/captures/2026-09/higgsfield/wan-2-7.mp4 new file mode 100644 index 0000000..0dca031 Binary files /dev/null and b/data/captures/2026-09/higgsfield/wan-2-7.mp4 differ diff --git a/data/captures/2026-09/higgsfield/wan-3-0-prime.mp4 b/data/captures/2026-09/higgsfield/wan-3-0-prime.mp4 new file mode 100644 index 0000000..b3d28b4 Binary files /dev/null and b/data/captures/2026-09/higgsfield/wan-3-0-prime.mp4 differ diff --git a/data/captures/2026-09/higgsfield/wan-3-0.mp4 b/data/captures/2026-09/higgsfield/wan-3-0.mp4 new file mode 100644 index 0000000..acee12b Binary files /dev/null and b/data/captures/2026-09/higgsfield/wan-3-0.mp4 differ diff --git a/data/captures/2026-09/higgsfield/z-image.png b/data/captures/2026-09/higgsfield/z-image.png new file mode 100644 index 0000000..c8db263 Binary files /dev/null and b/data/captures/2026-09/higgsfield/z-image.png differ diff --git a/data/captures/2026-09/manifest.csv b/data/captures/2026-09/manifest.csv new file mode 100644 index 0000000..d58fbb9 --- /dev/null +++ b/data/captures/2026-09/manifest.csv @@ -0,0 +1,135 @@ +sha256,path,stored,service,source_name,captured,ai_from_metadata,platform +5ade61fc4cd869e2487745af61c7e23d008a3e9c2b5c440d2ffe16c2d3a32315,data/fixtures/provenance/apple-image-playground.png,existing,apple,image-playground-panda.png,2026-09-23,True,Apple Image Playground +f11595c6a9c0a72d4712d228354f589078d6940e529538cb009c799d8b8410d7,data/fixtures/provenance/google-gemini-omni-app.mp4,existing,gemini-omni,omni-wm-toggled.mp4,2026-09-23,True,Google (Gemini / Imagen) +cbbf831fb5d394436feb2f022d46e8933989f003acb9616e907791bca8a5dd6d,data/captures/2026-09/grok/download-0.jpg,new,grok,download-0.jpg,2026-09-23,True,xAI Grok Imagine +c6e3329009260cd230deaadd0cc7293269a94c4628010cd7eeb3d390b76cc0c2,data/fixtures/provenance/xai-grok-imagine-video.mp4,existing,grok,video-0.mp4,2026-09-23,True,xAI Grok Imagine +8e9f2a8bd190e5cfc662151aeef63cbc3372619678e2d37b3432b4fea113fb1c,data/fixtures/visible/vidu/provider-original.mp4,existing,vidu,vidu-free-q3.mp4,2026-09-23,True,ShengShu Vidu +583df0ec34d88653fc82c658728780f474a959eb246156649e5d5dde956fc474,data/fixtures/visible/wan/provider-original.png,existing,wan,wan-image-0.png,2026-09-23,True,China AIGC-labeled content (TC260 standard) +040e5c5fe2d7d82b467c54ce2558e4d5c0705e676f00464577f29c211bc7784f,data/captures/2026-09/dreamina/gpt-image-2-5-flare.png,new,dreamina,gpt-image-2-5-flare.png,2026-09-24,True,ByteDance Dreamina +acfc163bac69099e0d20ed26ec5f70537f21cac7b33ebb8fd7e03dfee287fa9a,data/captures/2026-09/dreamina/gpt-image-2-5-sunburst.png,new,dreamina,gpt-image-2-5-sunburst.png,2026-09-24,True,ByteDance Dreamina +047d62991a5adde4d246acf69d543b3f7a4c382e86523a57fbb0732bc7695a47,data/captures/2026-09/dreamina/gpt-image-2.png,new,dreamina,gpt-image-2.png,2026-09-24,True,ByteDance Dreamina +a48fc7da8b8bcc4519802e4db57942840aadf113d13c0fb5efc591a5b21617cc,data/captures/2026-09/dreamina/minimax-h3.mp4,new,dreamina,minimax-h3.mp4,2026-09-24,True,ByteDance Dreamina +885a47916a72c39ec2e5bea6a295215144979b26c811d7ca63a0473742a90a91,data/captures/2026-09/dreamina/nano-banana-pro.jpeg,new,dreamina,nano-banana-pro.jpeg,2026-09-24,True,ByteDance Dreamina +615fa80ca5bcca828f2c058a6021e53b73e196b36950446beed3f83d1a9fcb03,data/captures/2026-09/dreamina/nano-banana.jpeg,new,dreamina,nano-banana.jpeg,2026-09-24,True,ByteDance Dreamina +233a79d0d482bb964a67280b71e7999defb0587b64b6d0a84371dea61f670bc2,data/captures/2026-09/dreamina/seedance-1-0-fast.mp4,new,dreamina,seedance-1-0-fast.mp4,2026-09-24,True,ByteDance Dreamina +48df1fc4860c29cbe0cc5ebdd36526c1a75e1e8fffa745bc2798871701abaf94,data/captures/2026-09/dreamina/seedance-1-0.mp4,new,dreamina,seedance-1-0.mp4,2026-09-24,True,ByteDance Dreamina +561fc4ea7a536e415ef0dc2f466fc35925ad61159f1ea2856d7f525bc86a34ce,data/captures/2026-09/dreamina/seedance-1-5-pro.mp4,new,dreamina,seedance-1-5-pro.mp4,2026-09-24,True,ByteDance Dreamina +f58dd63abc2553c9b70d6cda8a068e874e59b5f3eb6d0f3db9ab08fefe9f3c3f,data/captures/2026-09/dreamina/seedance-2-0-fast.mp4,new,dreamina,seedance-2-0-fast.mp4,2026-09-24,True,ByteDance Dreamina +d2712d4804074ac08e28fc77ff1c1ff986e6c4bbaf3ae9737f743719e51a59de,data/fixtures/provenance/dreamina-seedance-2-mini.mp4,existing,dreamina,seedance-2-0-mini.mp4,2026-09-24,True,ByteDance Dreamina +375dde6314547b7b7c83a058a2e785c45b47ffee9198e81644e804d5769ba9a2,data/captures/2026-09/dreamina/seedance-2-0.mp4,new,dreamina,seedance-2-0.mp4,2026-09-24,True,ByteDance Dreamina +ff29e21fd43b79fb9ddb097c4164f02da8b3f56afd27f8fbff851a7763bc9d33,data/captures/2026-09/dreamina/seedance-2-5.mp4,new,dreamina,seedance-2-5.mp4,2026-09-24,True,ByteDance Dreamina +5d5a5e7c3f9434245eec4505cb6665fe57df2bf8da99a6f31302c8efacb4c9c8,data/captures/2026-09/dreamina/seedream-3-0.jpeg,new,dreamina,seedream-3-0.jpeg,2026-09-24,True,ByteDance Dreamina +a24e9f7c6ee07b60ac9af9f061746a39b305db89006eb92cca19f926bc8ee89e,data/captures/2026-09/dreamina/seedream-3-1.jpeg,new,dreamina,seedream-3-1.jpeg,2026-09-24,True,ByteDance Dreamina +ebe832110f0408a7b8cb65721213aec41bac70bfc580ef27d8c627639ceb7f2e,data/captures/2026-09/dreamina/seedream-4-0.jpeg,new,dreamina,seedream-4-0.jpeg,2026-09-24,True,ByteDance Dreamina +8ba87f6aa7e5b441a236ead9f325fe5cf74ec638bd359f2f87f91831bb3bdb4a,data/captures/2026-09/dreamina/seedream-4-1.jpeg,new,dreamina,seedream-4-1.jpeg,2026-09-24,True,ByteDance Dreamina +e2d14b3df27f22c53ca79f3372251ae04bf6607dab9ff86a16c365a667abc277,data/captures/2026-09/dreamina/seedream-4-5.jpeg,new,dreamina,seedream-4-5.jpeg,2026-09-24,True,ByteDance Dreamina +d54e8825d180fbdd6350c320b558015efb98f20839d3ce51ce9e939e59d6d870,data/captures/2026-09/dreamina/seedream-4-6.jpeg,new,dreamina,seedream-4-6.jpeg,2026-09-24,True,ByteDance Dreamina +25375e8f0485c1742e35d6f81da79fc64cb4029e4b4a36fb56e419a1646907f1,data/captures/2026-09/dreamina/seedream-4-7.jpeg,new,dreamina,seedream-4-7.jpeg,2026-09-24,True,ByteDance Dreamina +41ad0748d6894d40cde35f352082eb5608f3ee83216e120b7499afd0284703db,data/captures/2026-09/dreamina/seedream-5-0-lite.jpeg,new,dreamina,seedream-5-0-lite.jpeg,2026-09-24,True,ByteDance Dreamina +27ead86c611fbea25b5f50dfb120c1614f891f1ca07a707edfb1184f5d5109c5,data/captures/2026-09/dreamina/seedream-5-pro-2.jpeg,new,dreamina,seedream-5-pro-2.jpeg,2026-09-24,True,ByteDance Dreamina +2393cb470dc7c69de4c421f338aeb08192ed916515bc095a3afcd2ef4af89c90,data/fixtures/provenance/dreamina-seedream-5-pro.jpg,existing,dreamina,seedream-5-pro.jpeg,2026-09-24,True,ByteDance Dreamina +2e23ade5758aa6f42eb12261b22fd686e7f17b6a26414046c1afa1012c886e04,data/captures/2026-09/higgsfield/flux-2-flex.png,new,higgsfield,flux-2-flex.png,2026-09-24,True,Higgsfield (model not identified) +5d1f676ec1d3ab6684f75292d0c26bdaf2dc0cd93b3a4e657611d0fe34f6e880,data/captures/2026-09/higgsfield/flux-2-max.png,new,higgsfield,flux-2-max.png,2026-09-24,True,Higgsfield (model not identified) +4382b0c2a5f3d2b2c52f070ba9a12ffa259b4e0bad2446a65134cd700a06cb0a,data/fixtures/provenance/higgsfield-flux-2-pro.png,existing,higgsfield,flux-2-pro.png,2026-09-24,True,Higgsfield (model not identified) +e4eed8c3a5b1d99f3dce1ff16aa90ceb790a308c4ce623a9056cc711d4c5685f,data/captures/2026-09/higgsfield/gpt-image-1-5.png,new,higgsfield,gpt-image-1-5.png,2026-09-24,True,Higgsfield (model not identified) +197eb0d30a4967cdf19e68226e9fa9aa6723613a3feab1bd9553b8b4287dae61,data/captures/2026-09/higgsfield/gpt-image-2-5-flare.png,new,higgsfield,gpt-image-2-5-flare.png,2026-09-24,True,Higgsfield (model not identified) +3c20bc41a016117651142b4c3911237e6183ecfe2f53d97338b7beefc30cdea6,data/captures/2026-09/higgsfield/gpt-image-2-5-sunburst.png,new,higgsfield,gpt-image-2-5-sunburst.png,2026-09-24,True,Higgsfield (model not identified) +f079e02f2b79f5ddf7f06d29de9f7b0a9ae7746c9a1e340555a9998f91637e59,data/captures/2026-09/higgsfield/gpt-image-2.png,new,higgsfield,gpt-image-2.png,2026-09-24,True,Higgsfield (model not identified) +4cb06b2084f2f1907421fbab38ccc459ea2cb33859005e69c07c3ea524dc0f6d,data/fixtures/provenance/higgsfield-gpt-image.jpg,existing,higgsfield,gpt-image.jpeg,2026-09-24,True,OpenAI (ChatGPT / GPT Image / DALL·E / Sora) +eb47fb7ed862e1e828627457f4a438f63dd27aa0d2c7e492d62d31d2c7939feb,data/captures/2026-09/higgsfield/grok-imagine-2-0.png,new,higgsfield,grok-imagine-2-0.png,2026-09-24,True,Higgsfield (model not identified) +90d31906f629c4a99f60d115762fe003fc00dcdcabc895b2179e78e30407ef69,data/captures/2026-09/higgsfield/grok-imagine.png,new,higgsfield,grok-imagine.png,2026-09-24,True,Higgsfield (model not identified) +6ad75936db0bb172423564dd24d27bddb992917263555ecb5697cd2f354deeac,data/fixtures/provenance/higgsfield-kling-o1.png,existing,higgsfield,kling-o1.png,2026-09-24,True,China AIGC-labeled content (TC260 standard) +5365087f32d3bf8047375722835b3d8e75aa5d0f97afd5d59edfab5ae18f8b41,data/captures/2026-09/higgsfield/nano-banana-2-lite.png,new,higgsfield,nano-banana-2-lite.png,2026-09-24,True,Higgsfield (model not identified) +8511db22012b878b996c1993285943eebe87dfbbc3d7d262f112a6a8f36c70bf,data/captures/2026-09/higgsfield/nano-banana-2.png,new,higgsfield,nano-banana-2.png,2026-09-24,True,Higgsfield (model not identified) +dec96b39618905556a6d0592041d38aae0fb69e462b7dd4f3295242ff2b72543,data/captures/2026-09/higgsfield/nano-banana-pro.png,new,higgsfield,nano-banana-pro.png,2026-09-24,True,Higgsfield (model not identified) +c1e3ad5ace74231a0106827a26cef9e3caf1424977b073ccba19a13f1eb3be3f,data/captures/2026-09/higgsfield/nano-banana.png,new,higgsfield,nano-banana.png,2026-09-24,True,Higgsfield (model not identified) +776cba6484b751e729778253ba885f7042a9b53aa72d8315d27303533d2e3681,data/captures/2026-09/higgsfield/recraft-v4-1-utility.png,new,higgsfield,recraft-v4-1-utility.png,2026-09-24,True,Higgsfield (model not identified) +1e68c02f97f4f2af68c1096e2631def01e6dc1bf388eaad2eae27a93f67c730a,data/captures/2026-09/higgsfield/recraft-v4-1.png,new,higgsfield,recraft-v4-1.png,2026-09-24,True,Higgsfield (model not identified) +6153f35d8a7505162118f884ffc79f2f311a840251b799f8095fee06b4d615f8,data/captures/2026-09/higgsfield/seedream-4-0.png,new,higgsfield,seedream-4-0.png,2026-09-24,True,Higgsfield (model not identified) +30d53dbfc5bd80b0c4a7435f02955590408ad206fe41017d29ce82a8e13667c8,data/captures/2026-09/higgsfield/seedream-4-5.png,new,higgsfield,seedream-4-5.png,2026-09-24,True,Higgsfield (model not identified) +a86d007946c30c0fee139434d1cf6884e142ea11db3afb7911057e9415110d06,data/captures/2026-09/higgsfield/seedream-5-flash.png,new,higgsfield,seedream-5-flash.png,2026-09-24,True,Higgsfield (model not identified) +5ad760ca1ecf73fea60c78520f1903ba3fd6c2b5e4d45e963558a945c91e9007,data/captures/2026-09/higgsfield/seedream-5-lite.png,new,higgsfield,seedream-5-lite.png,2026-09-24,True,Higgsfield (model not identified) +2d64e99f5281fe10ad955429b26720dd2092bf7e943456b39f68564eb35c8e59,data/captures/2026-09/higgsfield/seedream-5-pro.png,new,higgsfield,seedream-5-pro.png,2026-09-24,True,Higgsfield (model not identified) +0d3a70c1382639241d8b55966b19965ad42824aad60663a20a5f5bc810101cc6,data/captures/2026-09/higgsfield/soul-cinema.png,new,higgsfield,soul-cinema.png,2026-09-24,, +fd5703240e9c045dbfb52c5fbef90802974f06aec716f482e8af74ef91288104,data/captures/2026-09/higgsfield/soul-v1.png,new,higgsfield,soul-v1.png,2026-09-24,, +91a2160b5a72f23776ac25207250359e7f86ade761b531247856a47488a50e63,data/captures/2026-09/higgsfield/soul2-image.png,new,higgsfield,soul2-image.png,2026-09-24,True,Higgsfield (model not identified) +f5f0b26198f8f1da107280b3c2c20c82897c3d522e27ba0acc4cf9eb8f74650a,data/captures/2026-09/higgsfield/flux-3-video.mp4,new,higgsfield,flux-3-video.mp4,2026-09-24,True,Black Forest Labs (FLUX) +967dcb6022e19f55639ff705a58e5252a62a24405f247dcffefcc0f610149c30,data/fixtures/provenance/higgsfield-happyhorse.mp4,existing,higgsfield,happyhorse.mp4,2026-09-24,True,"Alibaba Tongyi (Wan, HappyHorse)" +7011c92d52ec624da12600f24a4abb8d997f26cdea62d6f5a8d1940bc7ffcc9c,data/fixtures/provenance/higgsfield-kling-3-0-turbo.mp4,existing,higgsfield,kling-3-0-turbo.mp4,2026-09-24,True,Kuaishou Kling AI +5fe5b9d08b9fb41a4d3eea470d443eb5df520235300e65134090338701215a43,data/fixtures/provenance/higgsfield-minimax-h3.mp4,existing,higgsfield,minimax-h3.mp4,2026-09-24,True,MiniMax Hailuo AI +7d4ffa63755d605538c9a73059b4f6354a57e4f21100a92497aebc3c19dc3485,data/fixtures/provenance/higgsfield-veo-3-1-lite.mp4,existing,higgsfield,veo-3-1-lite.mp4,2026-09-24,True,Google (Gemini / Imagen) +07fcc98ddc21f80d2c4b918c8afa9298ddab4ae49fc6428c8e464a9b69c9db21,data/captures/2026-09/higgsfield/wan-2-5.mp4,new,higgsfield,wan-2-5.mp4,2026-09-24,True,"Alibaba Tongyi (Wan, HappyHorse)" +40a828d0360820019f3033c367283652df0a99c21150bafaddbc9a92437e743f,data/captures/2026-09/higgsfield/wan-2-2.png,new,higgsfield,wan-2-2.png,2026-09-24,, +c3563b5413fe3d54ccc3f60c834c32e02f3dab7a830262bf38b6e0a700a0ac16,data/captures/2026-09/higgsfield/z-image.png,new,higgsfield,z-image.png,2026-09-24,True,Higgsfield (model not identified) +1fd7cccffb25f18e74fa5ebc639672f1361db6137e6b9e848bc33fc1f583c0fe,data/fixtures/provenance/google-nano-banana.png,existing,nb-api,gemini-2.5-flash-image.png,2026-09-24,True,Google (Gemini / Imagen) +5fd14ce6d32f3806f0128f952eb30d36f7dad384a9eec728e5efbdb46ebca7db,data/fixtures/provenance/google-nano-banana-pro.jpg,existing,nb-api,gemini-3-pro-image.jpg,2026-09-24,True,Google (Gemini / Imagen) +977110ac48678627d907d635ac1b110b930044c350b669fb5f927a500e7d95ac,data/fixtures/provenance/google-nano-banana-2.jpg,existing,nb-api,gemini-3.1-flash-image.jpg,2026-09-24,True,Google (Gemini / Imagen) +df4f422b9e77d6b08d3e6d095299f0cdf6d48f6c770632bb042a6c7b09627334,data/fixtures/provenance/google-nano-banana-2-lite.jpg,existing,nb2-lite,nb2lite-1.jpg,2026-09-24,True,Google (Gemini / Imagen) +3d7dddd333ca6b576b3d7dc5220df42cbef3ea388bd4688f5671cdd796dcb805,data/captures/2026-09/nb2-lite/nb2lite-2.jpg,new,nb2-lite,nb2lite-2.jpg,2026-09-24,True,Google (Gemini / Imagen) +e04a0ed3976bdff9df7ffdc318296cadcca3ba6e7166c81247c27a09ba39fe5c,data/captures/2026-09/nb2-lite/nb2lite-3.jpg,new,nb2-lite,nb2lite-3.jpg,2026-09-24,True,Google (Gemini / Imagen) +fb034e94e4cbca9e0b56d82389d361fda0d6191f7e16f37ac6c55cd62a3621f7,data/fixtures/provenance/google-gemini-omni-flash-api.mp4,existing,omni-flash,omni-flash-1.mp4,2026-09-24,True,Google (Gemini / Imagen) +25b2dc2a3c147dd8f04457a6b85183edd6ee235abdfe92c162c41c25812b0d9a,data/captures/2026-09/runway/flux-2-klein.png,new,runway,flux-2-klein.png,2026-09-24,True,Black Forest Labs (FLUX) +e9b75d61adef6842c602767c4c0a5217de816927fd257ba1675e7e4a4dd043d0,data/captures/2026-09/runway/flux-2-max.jpg,new,runway,flux-2-max.jpg,2026-09-24,True,Black Forest Labs (FLUX) +12262d853760b4e6dab7a896b833331ee5af43cd6e9467b899b7de76c7361d8d,data/fixtures/provenance/runway-gen4-image.png,existing,runway,gen4-image-artifact.png,2026-09-24,True,Runway +98c0040b60aad1187c45df5fd4d6e8a11762950329b1a89183d6a58d5b947ad2,data/fixtures/provenance/runway-gen45-video.mp4,existing,runway,gen45-video-artifact.mp4,2026-09-24,True,Runway +eaec9e1c3a6c46c14d6ab8d12954b875e7482c8fde2a6d53ccd2a0b77c227a0b,data/captures/2026-09/runway/gpt-image-1-5.png,new,runway,gpt-image-1-5.png,2026-09-24,True,OpenAI (ChatGPT / GPT Image / DALL·E / Sora) +2905127c3caabf13906abe6abaef8096209ee9e6e961e899b112f82ebb6e0c4f,data/captures/2026-09/runway/gpt-image-1-mini.png,new,runway,gpt-image-1-mini.png,2026-09-24,True,OpenAI (ChatGPT / GPT Image / DALL·E / Sora) +22fb128833cb401a02b72a4ef5f23a577224a97950f296d03433224fb2249afd,data/captures/2026-09/runway/gpt-image-2-5-flare.png,new,runway,gpt-image-2-5-flare.png,2026-09-24,True,OpenAI (ChatGPT / GPT Image / DALL·E / Sora) +249b0d0f3fe3decc140a4ba234aee862d107b7a181bb47882f280cbddd325bfe,data/captures/2026-09/runway/gpt-image-2-5-sunburst.png,new,runway,gpt-image-2-5-sunburst.png,2026-09-24,True,OpenAI (ChatGPT / GPT Image / DALL·E / Sora) +8c0d4d0e739e559035a16bdc276fe8d517d4561736e56c0ddf8d1da46181dfbd,data/captures/2026-09/runway/gpt-image-2.png,new,runway,gpt-image-2.png,2026-09-24,True,OpenAI (ChatGPT / GPT Image / DALL·E / Sora) +9f7fae91c8faf9eb5554443d395dfd3b23204702b32feb19e1904ab3009d8c29,data/captures/2026-09/runway/grok-imagine-2-0-or-quality-a.jpg,new,runway,grok-imagine-2-0-or-quality-a.jpg,2026-09-24,True,xAI Grok Imagine +99c7873a4c3de880af9daeb5c0fa753232313d4e4689f0976bbd020d2b420465,data/captures/2026-09/runway/grok-imagine-2-0-or-quality-b.jpg,new,runway,grok-imagine-2-0-or-quality-b.jpg,2026-09-24,True,xAI Grok Imagine +001007bdeda172730028b2d54eb1b2acd90f6396800c10e2be2efe777faa1718,data/captures/2026-09/runway/grok-imagine.jpg,new,runway,grok-imagine.jpg,2026-09-24,True,xAI Grok Imagine +14e97f57d83f5438a13b89175eb995ec07f817432642466b1bdb8572f7573da2,data/captures/2026-09/runway/ideogram-4-0.png,new,runway,ideogram-4-0.png,2026-09-24,True,Ideogram +8a651b72eed17775e222c1a40b5bbea6e69715a27d6c8e8e0e3d4c5d0723909c,data/captures/2026-09/runway/ideogram-p-image.png,new,runway,ideogram-p-image.png,2026-09-24,True,Ideogram +3132a2e7198ecd3dead3042cebc6c9091264638bf63b40d5027ff058bd043547,data/captures/2026-09/runway/kling-o3-standard.mp4,new,runway,kling-o3-standard.mp4,2026-09-24,True,Kuaishou Kling AI +b95f4a9f2706fc4f3c8716f6daa016029e55f07177dfd2efd8fedbaf67094981,data/captures/2026-09/runway/ltx-2-5-fast.mp4,new,runway,ltx-2-5-fast.mp4,2026-09-24,, +b374c9d29c094a2fd1b0b6257c989a43d155ce62d9fdc3d445707978cd7dd1e5,data/captures/2026-09/runway/meta-muse-image.png,new,runway,meta-muse-image.png,2026-09-24,True, +fbc0313aea807e67d1bb904cac7b47c9163b1ac8435d4774d4b63e99ba1975df,data/captures/2026-09/runway/nano-banana-2-lite.jpg,new,runway,nano-banana-2-lite.jpg,2026-09-24,True,Google (Gemini / Imagen) +a32344cb19bbd6607733aa138b45b66e919f827cdfcdfc0d7da8de75906237a9,data/captures/2026-09/runway/nano-banana-2-via-runway.png,new,runway,nano-banana-2-via-runway.png,2026-09-24,True,Google (Gemini / Imagen) +d2b82a1907056f8f022308fdd16e701238da816a9c9c339d7fa54999af2cf12e,data/captures/2026-09/runway/nano-banana-pro.png,new,runway,nano-banana-pro.png,2026-09-24,True,Google (Gemini / Imagen) +077e552f16ab1de180e3487e85567f5e0565e1bd6e942dc1d585309a105fa19a,data/captures/2026-09/runway/nano-banana.png,new,runway,nano-banana.png,2026-09-24,True,Google (Gemini / Imagen) +88f0e48032dee30d1e6237962af74cd38991ab52c1424a4f8e12aadf4e1b9346,data/captures/2026-09/runway/seedance-2-mini.mp4,new,runway,seedance-2-mini.mp4,2026-09-24,True,BytePlus (ByteDance) +5df40eb93f1b0f4d802bd117fba736ecec9713f5f411c6449726690d94972560,data/captures/2026-09/runway/seedream-5-0.png,new,runway,seedream-5-0.png,2026-09-24,True,BytePlus (ByteDance) +8275bf3af0c8a3d4815f76a546f29172c31ddd65c7a153e1bd6d6421beb1eccc,data/captures/2026-09/runway/seedream-5-pro.png,new,runway,seedream-5-pro.png,2026-09-24,True,BytePlus (ByteDance) +9d8f9901115697ca4925f02e3b5c91920fb02579657268c6525c0cd9bcf33d63,data/captures/2026-09/youtube/yt-136-video.mp4,new,youtube,yt-136-video.mp4,2026-09-24,, +68c87b0697fb954f3533f96ec32875f6dec173c142fd4d4f2552c624c21f7c28,data/captures/2026-09/youtube/yt-398-av1.mp4,new,youtube,yt-398-av1.mp4,2026-09-24,, +aa2fde5c0444eda05f40db2a8974183284016876c9788f099dda9df5aac1714f,data/fixtures/provenance/youtube-reencode-gemini-omni-flash.mp4,existing,youtube,yt-studio-download.mp4,2026-09-24,True,Google (Gemini / Imagen) +236dd73a25a893b8f2c7bbdad8e1208341251c2b12e7b261c1fff65bf58277a5,data/fixtures/provenance/youtube-reencode-grok.mp4,existing,youtube,yt-studio-grok.mp4,2026-09-24,, +7c37874c72d289606e997434ccde73dba3aa89ecdc5adb3dfe64923933da5378,data/captures/2026-09/higgsfield/cinema-studio-3-0.mp4,new,higgsfield,cinema-studio-3-0.mp4,2026-09-25,True,BytePlus (ByteDance) +485449b69f1013a97970aabc5429327db1ff88559c81e3430c536fab108db7dc,data/fixtures/provenance/higgsfield-cinema-studio-v2.mp4,existing,higgsfield,cinema-studio-v2.mp4,2026-09-25,True,Kuaishou Kling AI +f7915d8e77eeb6feceff7169aa79c380330aa2f71a71b5eba426c4495cb8d696,data/captures/2026-09/higgsfield/edit-flux-3.mp4,new,higgsfield,edit-flux-3.mp4,2026-09-25,True,Black Forest Labs (FLUX) +fca47e6aea979b3fb0be01b5775c47f95acde66b4692957aae928a17ce86418d,data/captures/2026-09/higgsfield/edit-gemini-omni-1-1.mp4,new,higgsfield,edit-gemini-omni-1-1.mp4,2026-09-25,True,Google (Gemini / Imagen) +4f8aff9e6ef05c71a5028f2d3b5c63191b5e6932ebe39827068ae09cfe00e17b,data/captures/2026-09/higgsfield/gemini-omni-flash-1-1.mp4,new,higgsfield,gemini-omni-flash-1-1.mp4,2026-09-25,True,Google (Gemini / Imagen) +7c9a93140bffb50de173bca7cff1ecb7b2a75f611bf11bb26a54d903c043e5e3,data/captures/2026-09/higgsfield/gemini-omni-flash.mp4,new,higgsfield,gemini-omni-flash.mp4,2026-09-25,True,Google (Gemini / Imagen) +a3c2e8c506327ff0bdee078372be8d89fb913672f61efc66a4791771026ec0fc,data/fixtures/provenance/higgsfield-genjutsu-motion-control.mp4,existing,higgsfield,genjutsu-motion-control.mp4,2026-09-25,, +d3b7a9bbe03abdab2b6c0fb009f90a961cb021405ff652c02943e51e01c38ba0,data/captures/2026-09/higgsfield/genjutsu-replace-object.mp4,new,higgsfield,genjutsu-replace-object.mp4,2026-09-25,, +32ec5b3d70636c6295ff312dadb9fc018e8ff061c7ebe786000fcbbdc2161b6b,data/captures/2026-09/higgsfield/grok-video-1-5.mp4,new,higgsfield,grok-video-1-5.mp4,2026-09-25,True,xAI Grok Imagine +3811bca40850d8b4caebf3cc0f21b8c414f8b7d7c810f0a72b38b845174bb02e,data/captures/2026-09/higgsfield/grok-video.mp4,new,higgsfield,grok-video.mp4,2026-09-25,True,xAI Grok Imagine +67f4b6c49f6b93485fd47c457f64d3f721ec326ec57a9fb39488d6b9023528b7,data/fixtures/provenance/higgsfield-hailuo-2-3.mp4,existing,higgsfield,hailuo-2-3.mp4,2026-09-25,True,MiniMax Hailuo AI +96034f675008efcc09ca25c0739068f6e7301dde3ac67f4cfd14b82c5c7f9cae,data/captures/2026-09/higgsfield/kling-2-6.mp4,new,higgsfield,kling-2-6.mp4,2026-09-25,True,Kuaishou Kling AI +00a0ab7a2fe37f1c5b3208f9940c928932124015df7391dab4bfa5a9cc21af0c,data/captures/2026-09/higgsfield/kling-3-0.mp4,new,higgsfield,kling-3-0.mp4,2026-09-25,True,Kuaishou Kling AI +1f8fb86da8d71db7d2e6c4f87a6a2fd5cab69b7824406d7145a2756c2d358e38,data/captures/2026-09/higgsfield/marketing-studio.mp4,new,higgsfield,marketing-studio.mp4,2026-09-25,True,BytePlus (ByteDance) +bacf6dc7ab9b6dfe67488349ecced76b10221f3c73196a1a4bff163588e7270d,data/captures/2026-09/higgsfield/minimax-h3-max.mp4,new,higgsfield,minimax-h3-max.mp4,2026-09-25,True,MiniMax Hailuo AI +11b8e1890ac393a41de61f9a39633cefcad45b9e47f82d13a92906ac5f6ced45,data/captures/2026-09/higgsfield/seedance-1-5-pro.mp4,new,higgsfield,seedance-1-5-pro.mp4,2026-09-25,, +a65d7b57c528f6fa0e6b844d918d16cc6c2260038921d1c7d6d2e2bf60095a26,data/captures/2026-09/higgsfield/seedance-2-0.mp4,new,higgsfield,seedance-2-0.mp4,2026-09-25,True,BytePlus (ByteDance) +24857c4744ee9a7e4385d1f85df0acdd0aba05f1827210b7b97556d1a9d58841,data/captures/2026-09/higgsfield/seedance-2-5.mp4,new,higgsfield,seedance-2-5.mp4,2026-09-25,True,BytePlus (ByteDance) +d4aec466d7811ae5670f4db6308f5275d2773653089736849f85f96fe2b262be,data/captures/2026-09/higgsfield/veo-3-1.mp4,new,higgsfield,veo-3-1.mp4,2026-09-25,True,Google (Gemini / Imagen) +e78ed6fa92814a72a64b6a377bebfd5b65efeae149d49170c68744917e9246b3,data/captures/2026-09/higgsfield/wan-2-6.mp4,new,higgsfield,wan-2-6.mp4,2026-09-25,True,"Alibaba Tongyi (Wan, HappyHorse)" +f59ae93226bae3cc24598214ff623f99957e127838541b57acd55bd9cdb8e72c,data/captures/2026-09/higgsfield/wan-2-7.mp4,new,higgsfield,wan-2-7.mp4,2026-09-25,True,"Alibaba Tongyi (Wan, HappyHorse)" +bfb89e7729ef0e54260452f525ebdadd1237fac0902c5bda39edbcd7a48aa6cc,data/captures/2026-09/higgsfield/wan-3-0-prime.mp4,new,higgsfield,wan-3-0-prime.mp4,2026-09-25,True,"Alibaba Tongyi (Wan, HappyHorse)" +3e982e49d46445143f78a0b445642ce888a23571823e6db74e93c0c7ede3c9c1,data/captures/2026-09/higgsfield/wan-3-0.mp4,new,higgsfield,wan-3-0.mp4,2026-09-25,True,"Alibaba Tongyi (Wan, HappyHorse)" +7fc933d784dcb1ed4bef0f6d857fe79156c1ef1da9d437d750633217d0da23a8,data/captures/2026-09/google-photos/ask-dog-to-cat.jpg,new,google-photos,ask-dog-to-cat.jpg,2026-09-25,True,Google Photos (AI edit) +1c8726279b869bc435dd30bcb385d04203dd9af5570b0d2d393ff3f3e5162e5b,data/captures/2026-09/google-photos/eraser-glasses.jpg,new,google-photos,eraser-glasses.jpg,2026-09-25,True,Google Photos (AI edit) +1abca17d4649e458626864560e5d2db6397eb51d7bc8617f49bc70be0905522e,data/captures/2026-09/google-photos/gphotos-5.jpg,new,google-photos,gphotos-5.jpg,2026-09-24,True,Google Photos (AI edit) +56817730a46a84975a7ac6d9595d2f68a18676b49fd1be1c5230b517beafa9cc,data/captures/2026-09/google-photos/gphotos-6.jpg,new,google-photos,gphotos-6.jpg,2026-09-24,True,Google Photos (AI edit) +39f8141415ab51fd6070b6d2591132c81fffa6b44f76f2e5e84b38912b3becc1,data/captures/2026-09/runway/flux-3-video.mp4,new,runway,flux-3-video.mp4,2026-09-25,True,Black Forest Labs (FLUX) +81cd555906034c4aff6473047a7a898d14ce060dd4e6a3c6226902547446045b,data/captures/2026-09/runway/grok-imagine-1-5-video.mp4,new,runway,grok-imagine-1-5-video.mp4,2026-09-25,True,xAI Grok Imagine +c255d4a7168197e4ab6af162690d305f3e3f48037b1a5fd9e877ab782539e0fb,data/captures/2026-09/runway/gemini-omni-1-1-flash.mp4,new,runway,gemini-omni-1-1-flash.mp4,2026-09-25,True,Google (Gemini / Imagen) +a8711432008960149a16afd43704fa673f70826cf527d677aa0c35b7a18502d1,data/captures/2026-09/runway/happyhorse-1-0.mp4,new,runway,happyhorse-1-0.mp4,2026-09-25,True,Alibaba Tongyi (Wan, HappyHorse) +4c909ca68ecad62c9200727cae2bef5e80678a255b3fd781f7a43ca0b0824f46,data/captures/2026-09/runway/minimax-h3.mp4,new,runway,minimax-h3.mp4,2026-09-25,True,MiniMax Hailuo AI +c04468ef20081aecf16ee94fe81ddd419ddae36acb678e7e4f26def7a1f7bb72,data/captures/2026-09/google-photos-web/forest-original.jpg,new,google-photos-web,forest-original.jpg,2026-09-26,, +af0265e41edec3413683f86441a1ffa9c64dbcb7117070d7ccbd2e91c20fd327,data/captures/2026-09/google-photos-web/forest-enhance.jpg,new,google-photos-web,forest-enhance.jpg,2026-09-26,, +3f42e0877f67c4677f6f52be18146923fc5eeb471f9e0e27b9477d29dc1e6bbb,data/captures/2026-09/google-photos-web/forest-sky-vivid.jpg,new,google-photos-web,forest-sky-vivid.jpg,2026-09-26,, +c83f903117cc030edf78164430ce59ffa897cb3a9efe464cf257e55644a955fd,data/captures/2026-09/google-photos-web/forest-crop-square.jpg,new,google-photos-web,forest-crop-square.jpg,2026-09-26,, +e5612ea4bc06df48821d431d8d4758d3c73d1e74bd7b5b5dce921eaffdfbbe93,data/captures/2026-09/higgsfield/edit-kling-3-0-omni.mp4,new,higgsfield,edit-kling-3-0-omni.mp4,2026-09-26,True,Kuaishou Kling AI diff --git a/data/captures/2026-09/nb2-lite/nb2lite-2.jpg b/data/captures/2026-09/nb2-lite/nb2lite-2.jpg new file mode 100644 index 0000000..5db9972 Binary files /dev/null and b/data/captures/2026-09/nb2-lite/nb2lite-2.jpg differ diff --git a/data/captures/2026-09/nb2-lite/nb2lite-3.jpg b/data/captures/2026-09/nb2-lite/nb2lite-3.jpg new file mode 100644 index 0000000..2e4c0ef Binary files /dev/null and b/data/captures/2026-09/nb2-lite/nb2lite-3.jpg differ diff --git a/data/captures/2026-09/runway/flux-2-klein.png b/data/captures/2026-09/runway/flux-2-klein.png new file mode 100644 index 0000000..c91ecec Binary files /dev/null and b/data/captures/2026-09/runway/flux-2-klein.png differ diff --git a/data/captures/2026-09/runway/flux-2-max.jpg b/data/captures/2026-09/runway/flux-2-max.jpg new file mode 100644 index 0000000..3575a4b Binary files /dev/null and b/data/captures/2026-09/runway/flux-2-max.jpg differ diff --git a/data/captures/2026-09/runway/flux-3-video.mp4 b/data/captures/2026-09/runway/flux-3-video.mp4 new file mode 100644 index 0000000..20f610b Binary files /dev/null and b/data/captures/2026-09/runway/flux-3-video.mp4 differ diff --git a/data/captures/2026-09/runway/gemini-omni-1-1-flash.mp4 b/data/captures/2026-09/runway/gemini-omni-1-1-flash.mp4 new file mode 100644 index 0000000..b0a645d Binary files /dev/null and b/data/captures/2026-09/runway/gemini-omni-1-1-flash.mp4 differ diff --git a/data/captures/2026-09/runway/gpt-image-1-5.png b/data/captures/2026-09/runway/gpt-image-1-5.png new file mode 100644 index 0000000..e061385 Binary files /dev/null and b/data/captures/2026-09/runway/gpt-image-1-5.png differ diff --git a/data/captures/2026-09/runway/gpt-image-1-mini.png b/data/captures/2026-09/runway/gpt-image-1-mini.png new file mode 100644 index 0000000..1320ae4 Binary files /dev/null and b/data/captures/2026-09/runway/gpt-image-1-mini.png differ diff --git a/data/captures/2026-09/runway/gpt-image-2-5-flare.png b/data/captures/2026-09/runway/gpt-image-2-5-flare.png new file mode 100644 index 0000000..c6fa92d Binary files /dev/null and b/data/captures/2026-09/runway/gpt-image-2-5-flare.png differ diff --git a/data/captures/2026-09/runway/gpt-image-2-5-sunburst.png b/data/captures/2026-09/runway/gpt-image-2-5-sunburst.png new file mode 100644 index 0000000..7489965 Binary files /dev/null and b/data/captures/2026-09/runway/gpt-image-2-5-sunburst.png differ diff --git a/data/captures/2026-09/runway/gpt-image-2.png b/data/captures/2026-09/runway/gpt-image-2.png new file mode 100644 index 0000000..079dd24 Binary files /dev/null and b/data/captures/2026-09/runway/gpt-image-2.png differ diff --git a/data/captures/2026-09/runway/grok-imagine-1-5-video.mp4 b/data/captures/2026-09/runway/grok-imagine-1-5-video.mp4 new file mode 100644 index 0000000..748adfb Binary files /dev/null and b/data/captures/2026-09/runway/grok-imagine-1-5-video.mp4 differ diff --git a/data/captures/2026-09/runway/grok-imagine-2-0-or-quality-a.jpg b/data/captures/2026-09/runway/grok-imagine-2-0-or-quality-a.jpg new file mode 100644 index 0000000..1d00f91 Binary files /dev/null and b/data/captures/2026-09/runway/grok-imagine-2-0-or-quality-a.jpg differ diff --git a/data/captures/2026-09/runway/grok-imagine-2-0-or-quality-b.jpg b/data/captures/2026-09/runway/grok-imagine-2-0-or-quality-b.jpg new file mode 100644 index 0000000..c3740ba Binary files /dev/null and b/data/captures/2026-09/runway/grok-imagine-2-0-or-quality-b.jpg differ diff --git a/data/captures/2026-09/runway/grok-imagine.jpg b/data/captures/2026-09/runway/grok-imagine.jpg new file mode 100644 index 0000000..156e67e Binary files /dev/null and b/data/captures/2026-09/runway/grok-imagine.jpg differ diff --git a/data/captures/2026-09/runway/happyhorse-1-0.mp4 b/data/captures/2026-09/runway/happyhorse-1-0.mp4 new file mode 100644 index 0000000..d1b0bd6 Binary files /dev/null and b/data/captures/2026-09/runway/happyhorse-1-0.mp4 differ diff --git a/data/captures/2026-09/runway/ideogram-4-0.png b/data/captures/2026-09/runway/ideogram-4-0.png new file mode 100644 index 0000000..ba2ba77 Binary files /dev/null and b/data/captures/2026-09/runway/ideogram-4-0.png differ diff --git a/data/captures/2026-09/runway/ideogram-p-image.png b/data/captures/2026-09/runway/ideogram-p-image.png new file mode 100644 index 0000000..8f5d0f3 Binary files /dev/null and b/data/captures/2026-09/runway/ideogram-p-image.png differ diff --git a/data/captures/2026-09/runway/kling-o3-standard.mp4 b/data/captures/2026-09/runway/kling-o3-standard.mp4 new file mode 100644 index 0000000..01f1173 Binary files /dev/null and b/data/captures/2026-09/runway/kling-o3-standard.mp4 differ diff --git a/data/captures/2026-09/runway/ltx-2-5-fast.mp4 b/data/captures/2026-09/runway/ltx-2-5-fast.mp4 new file mode 100644 index 0000000..9d103e9 Binary files /dev/null and b/data/captures/2026-09/runway/ltx-2-5-fast.mp4 differ diff --git a/data/captures/2026-09/runway/meta-muse-image.png b/data/captures/2026-09/runway/meta-muse-image.png new file mode 100644 index 0000000..602efe0 Binary files /dev/null and b/data/captures/2026-09/runway/meta-muse-image.png differ diff --git a/data/captures/2026-09/runway/minimax-h3.mp4 b/data/captures/2026-09/runway/minimax-h3.mp4 new file mode 100644 index 0000000..da5f96d Binary files /dev/null and b/data/captures/2026-09/runway/minimax-h3.mp4 differ diff --git a/data/captures/2026-09/runway/nano-banana-2-lite.jpg b/data/captures/2026-09/runway/nano-banana-2-lite.jpg new file mode 100644 index 0000000..f8ace46 Binary files /dev/null and b/data/captures/2026-09/runway/nano-banana-2-lite.jpg differ diff --git a/data/captures/2026-09/runway/nano-banana-2-via-runway.png b/data/captures/2026-09/runway/nano-banana-2-via-runway.png new file mode 100644 index 0000000..b0b3bbd Binary files /dev/null and b/data/captures/2026-09/runway/nano-banana-2-via-runway.png differ diff --git a/data/captures/2026-09/runway/nano-banana-pro.png b/data/captures/2026-09/runway/nano-banana-pro.png new file mode 100644 index 0000000..170c3f9 Binary files /dev/null and b/data/captures/2026-09/runway/nano-banana-pro.png differ diff --git a/data/captures/2026-09/runway/nano-banana.png b/data/captures/2026-09/runway/nano-banana.png new file mode 100644 index 0000000..94dfb85 Binary files /dev/null and b/data/captures/2026-09/runway/nano-banana.png differ diff --git a/data/captures/2026-09/runway/seedance-2-mini.mp4 b/data/captures/2026-09/runway/seedance-2-mini.mp4 new file mode 100644 index 0000000..7e3fc10 Binary files /dev/null and b/data/captures/2026-09/runway/seedance-2-mini.mp4 differ diff --git a/data/captures/2026-09/runway/seedream-5-0.png b/data/captures/2026-09/runway/seedream-5-0.png new file mode 100644 index 0000000..6d550da Binary files /dev/null and b/data/captures/2026-09/runway/seedream-5-0.png differ diff --git a/data/captures/2026-09/runway/seedream-5-pro.png b/data/captures/2026-09/runway/seedream-5-pro.png new file mode 100644 index 0000000..38006e4 Binary files /dev/null and b/data/captures/2026-09/runway/seedream-5-pro.png differ diff --git a/data/captures/2026-09/youtube/yt-136-video.mp4 b/data/captures/2026-09/youtube/yt-136-video.mp4 new file mode 100644 index 0000000..16a686f Binary files /dev/null and b/data/captures/2026-09/youtube/yt-136-video.mp4 differ diff --git a/data/captures/2026-09/youtube/yt-398-av1.mp4 b/data/captures/2026-09/youtube/yt-398-av1.mp4 new file mode 100644 index 0000000..1846ea4 Binary files /dev/null and b/data/captures/2026-09/youtube/yt-398-av1.mp4 differ diff --git a/data/fixtures/README.md b/data/fixtures/README.md index 468182e..ba55f98 100644 --- a/data/fixtures/README.md +++ b/data/fixtures/README.md @@ -178,3 +178,163 @@ dimensions. `c2pa.Builder.from_json(...).sign_file(png, out, Signer.from_info( C2paSignerInfo(b"es256", leaf_pem + ca_pem, pkcs8_key, None)))`. - No third-party image and no Ideogram artifact contributes pixels or bytes. + +## Gemini API images and Gemini Omni video + +Original outputs generated by the maintainer and cleared for publication. The +prompts were neutral scenes (a red bicycle, a paper boat, a coffee cup). + +| File | Source | Size | SHA-256 | +| --- | --- | --- | --- | +| `google-nano-banana.png` | Gemini API `gemini-2.5-flash-image`, 2026-09-24 | 1024 x 1024 PNG | `1fd7cccffb25f18e74fa5ebc639672f1361db6137e6b9e848bc33fc1f583c0fe` | +| `google-nano-banana-2.jpg` | Gemini API `gemini-3.1-flash-image`, 2026-09-24 | 1408 x 768 JPEG | `977110ac48678627d907d635ac1b110b930044c350b669fb5f927a500e7d95ac` | +| `google-nano-banana-pro.jpg` | Gemini API `gemini-3-pro-image`, 2026-09-24 | 1408 x 768 JPEG | `5fd14ce6d32f3806f0128f952eb30d36f7dad384a9eec728e5efbdb46ebca7db` | +| `google-nano-banana-2-lite.jpg` | Gemini API `gemini-3.1-flash-lite-image`, 2026-09-24 | 1408 x 768 JPEG | `df4f422b9e77d6b08d3e6d095299f0cdf6d48f6c770632bb042a6c7b09627334` | +| `google-gemini-omni-flash-api.mp4` | Gemini API `gemini-omni-1.1-flash`, 2026-09-24 | 10 s, 1280 x 720 H.264 and AAC | `fb034e94e4cbca9e0b56d82389d361fda0d6191f7e16f37ac6c55cd62a3621f7` | +| `google-gemini-omni-app.mp4` | Gemini app, Omni video with the Media watermark setting on, 2026-09-23 | 10 s, 1280 x 720 H.264 and AAC | `f11595c6a9c0a72d4712d228354f589078d6940e529538cb009c799d8b8410d7` | + +Every file carries a valid Google C2PA manifest signed `Google Media Processing +Services` (claim generator `Google C2PA Core Generator Library`) with +`c2pa.created` ("Created by Google Generative AI.") and a SynthID +`c2pa.edited` action: "Applied imperceptible SynthID watermark." on the images, +"Added imperceptible SynthID watermark" on the videos. The API outputs have no +visible mark. The app video adds the Veo-style diamond on every frame and a +third action, "Added visible watermark". + +- Expected signal: C2PA AI-generated, platform `Google (Gemini / Imagen)`, + SynthID present by Google provenance; the app video reports visible mark + `veo` (Omni reuses the Veo diamond, so its platform reads `Google Veo`) +- License: repository license; generated and explicitly cleared by the + maintainer + +## Runway Gen-4 image and Gen-4.5 video + +Original task artifacts generated by the maintainer on a Runway Standard plan on +2026-09-24 and cleared for publication; neutral coffee-mug scenes. + +| File | Source | Size | SHA-256 | +| --- | --- | --- | --- | +| `runway-gen4-image.png` | Runway Gen-4 image, 16:9 1080p | 1920 x 1080 PNG | `12262d853760b4e6dab7a896b833331ee5af43cd6e9467b899b7de76c7361d8d` | +| `runway-gen45-video.mp4` | Runway Gen-4.5 text-to-video, 5 s | 1280 x 720 H.264, 24 fps | `98c0040b60aad1187c45df5fd4d6e8a11762950329b1a89183d6a58d5b947ad2` | + +Both carry a valid C2PA manifest from `c2pa-rs`, signed with issuer `RUNWAY AI, +INC.`, recording one `c2pa.created` action typed `trainedAlgorithmicMedia` with +software agent `Runway Image Generation` 4.0.0 or `Runway Video Generation` +4.5.0. Neither has a visible mark. + +- Expected signal: C2PA AI-generated, platform `Runway`, no SynthID claim +- License: repository license; generated and explicitly cleared by the + maintainer + +## Higgsfield and Dreamina downloads + +Original downloads generated by the maintainer on paid Higgsfield and Dreamina +plans on 2026-09-24 and cleared for publication; neutral coffee-mug scenes. +Higgsfield serves its own and third-party models, so each file tests what the +aggregator does to the upstream provenance. + +| File | Source | Size | SHA-256 | +| --- | --- | --- | --- | +| `higgsfield-flux-2-pro.png` | FLUX.2 Pro on Higgsfield | 768 x 1024 PNG | `4382b0c2a5f3d2b2c52f070ba9a12ffa259b4e0bad2446a65134cd700a06cb0a` | +| `higgsfield-gpt-image.jpg` | GPT Image on Higgsfield, JPEG download | 1024 x 1024 JPEG | `4cb06b2084f2f1907421fbab38ccc459ea2cb33859005e69c07c3ea524dc0f6d` | +| `higgsfield-kling-o1.png` | Kling O1 image on Higgsfield | 880 x 1168 PNG | `6ad75936db0bb172423564dd24d27bddb992917263555ecb5697cd2f354deeac` | +| `higgsfield-veo-3-1-lite.mp4` | Veo 3.1 Lite on Higgsfield, 8 s | 1280 x 720 H.264, 24 fps | `7d4ffa63755d605538c9a73059b4f6354a57e4f21100a92497aebc3c19dc3485` | +| `higgsfield-kling-3-0-turbo.mp4` | Kling 3.0 Turbo on Higgsfield, 5 s | 1280 x 720 H.264, 24 fps | `7011c92d52ec624da12600f24a4abb8d997f26cdea62d6f5a8d1940bc7ffcc9c` | +| `higgsfield-happyhorse.mp4` | HappyHorse on Higgsfield, 5 s | 1280 x 720 H.264, 24 fps | `967dcb6022e19f55639ff705a58e5252a62a24405f247dcffefcc0f610149c30` | +| `higgsfield-minimax-h3.mp4` | MiniMax Hailuo H3 on Higgsfield, 5 s | 2560 x 1440 H.264, 24 fps | `5fe5b9d08b9fb41a4d3eea470d443eb5df520235300e65134090338701215a43` | +| `higgsfield-cinema-studio-v2.mp4` | Cinema Studio Video v2 on Higgsfield, 3 s, 2026-09-25 | 1280 x 720 H.264, 24 fps | `485449b69f1013a97970aabc5429327db1ff88559c81e3430c536fab108db7dc` | +| `higgsfield-hailuo-2-3.mp4` | MiniMax Hailuo 2.3 on Higgsfield, 6 s, 2026-09-25 | 1366 x 768 H.264, 24 fps | `67f4b6c49f6b93485fd47c457f64d3f721ec326ec57a9fb39488d6b9023528b7` | +| `higgsfield-genjutsu-motion-control.mp4` | Higgsfield Genjutsu motion control on our Wan 2.5 clip and FLUX.2 image, 2026-09-25 | 842 x 486 H.264 | `a3c2e8c506327ff0bdee078372be8d89fb913672f61efc66a4791771026ec0fc` | +| `dreamina-seedream-5-pro.jpg` | Seedream 5.0 Pro on Dreamina | 2368 x 1776 JPEG | `2393cb470dc7c69de4c421f338aeb08192ed916515bc095a3afcd2ef4af89c90` | +| `dreamina-seedance-2-mini.mp4` | Seedance 2.0 Mini on Dreamina, 5 s | 1280 x 720 H.264, 60 fps | `d2712d4804074ac08e28fc77ff1c1ff986e6c4bbaf3ae9737f743719e51a59de` | + +The FLUX.2 Pro PNG keeps Black Forest Labs' C2PA manifest, but Higgsfield +inserted its `hf-job-id` text chunk after signing, so validation fails with +`assertion.dataHash.mismatch`. The GPT Image JPEG keeps a valid OpenAI manifest +and has no `hf-job-id`. The Kling O1 PNG carries a TC260 label with Kuaishou's +producer code plus `hf-job-id`. Both the GPT Image and the Kling O1 file score +0.53 as a Gemini sparkle on wood texture. The Veo clip keeps Google's C2PA with +its SynthID action, and the Kling detector matched its wood texture on every +frame. The three Chinese videos carry only an MP4 `AIGC` TC260 label, with the +producer written as the bare string `kling`, as Alibaba Tongyi Yunqi's code +(`001191330106MA2CFLDG4R10001`), or as the bare string `MiniMax`. Dreamina files +carry a ByteDance C2PA manifest whose active claim is a transcode, with the +`Dreamina/7.5.0` generator in the ingredient, and a visible boxed `AI` badge in +the top-left corner that the library does not detect. Higgsfield's own Cinema +Studio v2 carries Kling's TC260 label (producer `kling`); neither it nor the +Hailuo 2.3 clip has a visible mark, and both were false visible `kling` and +`hailuo` detections on wood grain before the swirl edge gate and the Hailuo +white-pixel gate. The Genjutsu clip has no provenance at all and was a drifting +wordmark match on wood grain until Kling runs had to stay anchored within 0.95 +IoU. + +- Expected signal: FLUX.2 Pro, AI at medium confidence with platform + `Higgsfield (model not identified)` and invalid C2PA; GPT Image, OpenAI; + Kling O1, the TC260 label; neither of those two reports a Gemini sparkle. + Veo, `Google (Gemini / Imagen)` with SynthID present and no Kling mark; + Kling, `Kuaishou Kling AI`; HappyHorse, `Alibaba Tongyi (Wan, HappyHorse)`; + MiniMax, `MiniMax Hailuo AI`; both Dreamina files, `ByteDance Dreamina`; + Cinema Studio v2 and Hailuo 2.3, no visible mark +- License: repository license; generated and explicitly cleared by the + maintainer + +## xAI Grok video and YouTube re-encodes + +Original outputs and platform round trips produced by the maintainer and +cleared for publication; neutral scenes (a coffee cup, a paper boat). + +| File | Source | Size | SHA-256 | +| --- | --- | --- | --- | +| `xai-grok-imagine-video.mp4` | Grok Imagine Quick Animate, SuperGrok, 2026-09-23 | 6 s, 448 x 672 | `c6e3329009260cd230deaadd0cc7293269a94c4628010cd7eeb3d390b76cc0c2` | +| `youtube-reencode-grok.mp4` | YouTube Studio download of the Grok video uploaded unlisted, 2026-09-24 | 6 s, 448 x 672 | `236dd73a25a893b8f2c7bbdad8e1208341251c2b12e7b261c1fff65bf58277a5` | +| `youtube-reencode-gemini-omni-flash.mp4` | YouTube Studio download of `google-gemini-omni-flash-api.mp4` uploaded unlisted, 2026-09-24 | 10 s, 1280 x 720 | `aa2fde5c0444eda05f40db2a8974183284016876c9788f099dda9df5aac1714f` | + +The Grok original carries a C2PA manifest with claim generator `Grok Imagine` +and a `trainedAlgorithmicMedia` source, signed by a self-signed ephemeral +certificate. YouTube re-signs both uploads with a valid Google LLC manifest +from `YouTube Video Processing Services` recording `c2pa.opened` and +`c2pa.transcoded`. The Omni ingredient keeps its SynthID action; the Grok +ingredient's self-signed manifest is dropped by the reader. + +- Expected signal: Grok original, AI with platform `xAI Grok Imagine` and no + SynthID; YouTube Grok re-encode, unknown with no platform and no SynthID; + YouTube Omni re-encode, AI with SynthID present by Google provenance +- License: repository license; generated and explicitly cleared by the + maintainer + +## Google Photos AI edit (iOS) + +`google-photos-ios-edit.jpg` is an original 3024 x 4032 iPhone 15 Pro Max photo +edited in Google Photos on iOS to remove an object, supplied by the maintainer +on 2026-09-23 and cleared for publication unmodified, including its EXIF GPS +position. The C2PA manifest is signed by Google LLC with certificate common name +`Google Photos` (claim generator `Google C2PA SDK for iOS`) and records +`c2pa.opened` and `c2pa.deleted`, the latter typed +`compositeWithTrainedAlgorithmicMedia`, with no action description and no +SynthID action. XMP adds `photoshop:Credit` `Edited with Google AI`. Nothing in +this file names the edit tool or SynthID; Google's checker found SynthID in all +four Photos AI edits under `data/captures/2026-09/google-photos/`, so the tool +reports it present. + +- SHA-256: `d32a9ddb07f6902262246ec380b04e1e7016ef40bbffe6e8d82f4dc48e84ba23` +- Expected signal: C2PA AI-enhanced, platform `Google Photos (AI edit)`, SynthID + present with the Google Photos measurement caveat +- License: repository license; captured and explicitly cleared by the + maintainer + +## Apple Image Playground + +`apple-image-playground.png` is an original 1024 x 1024 Display P3 PNG from +Image Playground, exported by the maintainer from their iPhone on 2026-09-23. +The file records no OS version; the device ran iOS 27.0 (operator's +Settings screen, 2026-09-23). Apple writes no C2PA manifest and no EXIF software field; the only +provenance is XMP `photoshop:Credit` `Apple Image Playground` beside +`Iptc4xmpExt:DigitalSourceType` `trainedAlgorithmicMedia`. There is no visible +mark. Apple announced SynthID for Image Playground in a later software update. +Only Apple's own verifier could show whether this file carries it; another +vendor's SynthID oracle is not evidence either way. + +- SHA-256: `5ade61fc4cd869e2487745af61c7e23d008a3e9c2b5c440d2ffe16c2d3a32315` +- Expected signal: IPTC digitalSourceType, platform `Apple Image Playground` +- License: repository license; generated and explicitly cleared by the + maintainer diff --git a/data/fixtures/provenance/apple-image-playground.png b/data/fixtures/provenance/apple-image-playground.png new file mode 100644 index 0000000..65a9998 Binary files /dev/null and b/data/fixtures/provenance/apple-image-playground.png differ diff --git a/data/fixtures/provenance/dreamina-seedance-2-mini.mp4 b/data/fixtures/provenance/dreamina-seedance-2-mini.mp4 new file mode 100644 index 0000000..6c36bec Binary files /dev/null and b/data/fixtures/provenance/dreamina-seedance-2-mini.mp4 differ diff --git a/data/fixtures/provenance/dreamina-seedream-5-pro.jpg b/data/fixtures/provenance/dreamina-seedream-5-pro.jpg new file mode 100644 index 0000000..fad804f Binary files /dev/null and b/data/fixtures/provenance/dreamina-seedream-5-pro.jpg differ diff --git a/data/fixtures/provenance/google-gemini-omni-app.mp4 b/data/fixtures/provenance/google-gemini-omni-app.mp4 new file mode 100644 index 0000000..b6a76c0 Binary files /dev/null and b/data/fixtures/provenance/google-gemini-omni-app.mp4 differ diff --git a/data/fixtures/provenance/google-gemini-omni-flash-api.mp4 b/data/fixtures/provenance/google-gemini-omni-flash-api.mp4 new file mode 100644 index 0000000..54099cf Binary files /dev/null and b/data/fixtures/provenance/google-gemini-omni-flash-api.mp4 differ diff --git a/data/fixtures/provenance/google-nano-banana-2-lite.jpg b/data/fixtures/provenance/google-nano-banana-2-lite.jpg new file mode 100644 index 0000000..67958e6 Binary files /dev/null and b/data/fixtures/provenance/google-nano-banana-2-lite.jpg differ diff --git a/data/fixtures/provenance/google-nano-banana-2.jpg b/data/fixtures/provenance/google-nano-banana-2.jpg new file mode 100644 index 0000000..268e362 Binary files /dev/null and b/data/fixtures/provenance/google-nano-banana-2.jpg differ diff --git a/data/fixtures/provenance/google-nano-banana-pro.jpg b/data/fixtures/provenance/google-nano-banana-pro.jpg new file mode 100644 index 0000000..ddf7c5f Binary files /dev/null and b/data/fixtures/provenance/google-nano-banana-pro.jpg differ diff --git a/data/fixtures/provenance/google-nano-banana.png b/data/fixtures/provenance/google-nano-banana.png new file mode 100644 index 0000000..fc693e1 Binary files /dev/null and b/data/fixtures/provenance/google-nano-banana.png differ diff --git a/data/fixtures/provenance/google-photos-ios-edit.jpg b/data/fixtures/provenance/google-photos-ios-edit.jpg new file mode 100644 index 0000000..e8f2530 Binary files /dev/null and b/data/fixtures/provenance/google-photos-ios-edit.jpg differ diff --git a/data/fixtures/provenance/higgsfield-cinema-studio-v2.mp4 b/data/fixtures/provenance/higgsfield-cinema-studio-v2.mp4 new file mode 100644 index 0000000..38bd1d2 Binary files /dev/null and b/data/fixtures/provenance/higgsfield-cinema-studio-v2.mp4 differ diff --git a/data/fixtures/provenance/higgsfield-flux-2-pro.png b/data/fixtures/provenance/higgsfield-flux-2-pro.png new file mode 100644 index 0000000..7cd50ff Binary files /dev/null and b/data/fixtures/provenance/higgsfield-flux-2-pro.png differ diff --git a/data/fixtures/provenance/higgsfield-genjutsu-motion-control.mp4 b/data/fixtures/provenance/higgsfield-genjutsu-motion-control.mp4 new file mode 100644 index 0000000..330aa0c Binary files /dev/null and b/data/fixtures/provenance/higgsfield-genjutsu-motion-control.mp4 differ diff --git a/data/fixtures/provenance/higgsfield-gpt-image.jpg b/data/fixtures/provenance/higgsfield-gpt-image.jpg new file mode 100644 index 0000000..4f23f46 Binary files /dev/null and b/data/fixtures/provenance/higgsfield-gpt-image.jpg differ diff --git a/data/fixtures/provenance/higgsfield-hailuo-2-3.mp4 b/data/fixtures/provenance/higgsfield-hailuo-2-3.mp4 new file mode 100644 index 0000000..e9300fc Binary files /dev/null and b/data/fixtures/provenance/higgsfield-hailuo-2-3.mp4 differ diff --git a/data/fixtures/provenance/higgsfield-happyhorse.mp4 b/data/fixtures/provenance/higgsfield-happyhorse.mp4 new file mode 100644 index 0000000..9b9758e Binary files /dev/null and b/data/fixtures/provenance/higgsfield-happyhorse.mp4 differ diff --git a/data/fixtures/provenance/higgsfield-kling-3-0-turbo.mp4 b/data/fixtures/provenance/higgsfield-kling-3-0-turbo.mp4 new file mode 100644 index 0000000..9d8b364 Binary files /dev/null and b/data/fixtures/provenance/higgsfield-kling-3-0-turbo.mp4 differ diff --git a/data/fixtures/provenance/higgsfield-kling-o1.png b/data/fixtures/provenance/higgsfield-kling-o1.png new file mode 100644 index 0000000..955cfa0 Binary files /dev/null and b/data/fixtures/provenance/higgsfield-kling-o1.png differ diff --git a/data/fixtures/provenance/higgsfield-minimax-h3.mp4 b/data/fixtures/provenance/higgsfield-minimax-h3.mp4 new file mode 100644 index 0000000..260592a Binary files /dev/null and b/data/fixtures/provenance/higgsfield-minimax-h3.mp4 differ diff --git a/data/fixtures/provenance/higgsfield-veo-3-1-lite.mp4 b/data/fixtures/provenance/higgsfield-veo-3-1-lite.mp4 new file mode 100644 index 0000000..b5f54b7 Binary files /dev/null and b/data/fixtures/provenance/higgsfield-veo-3-1-lite.mp4 differ diff --git a/data/fixtures/provenance/runway-gen4-image.png b/data/fixtures/provenance/runway-gen4-image.png new file mode 100644 index 0000000..6266ba0 Binary files /dev/null and b/data/fixtures/provenance/runway-gen4-image.png differ diff --git a/data/fixtures/provenance/runway-gen45-video.mp4 b/data/fixtures/provenance/runway-gen45-video.mp4 new file mode 100644 index 0000000..26b85c8 Binary files /dev/null and b/data/fixtures/provenance/runway-gen45-video.mp4 differ diff --git a/data/fixtures/provenance/xai-grok-imagine-video.mp4 b/data/fixtures/provenance/xai-grok-imagine-video.mp4 new file mode 100644 index 0000000..77b0fb9 Binary files /dev/null and b/data/fixtures/provenance/xai-grok-imagine-video.mp4 differ diff --git a/data/fixtures/provenance/youtube-reencode-gemini-omni-flash.mp4 b/data/fixtures/provenance/youtube-reencode-gemini-omni-flash.mp4 new file mode 100644 index 0000000..da68ce5 Binary files /dev/null and b/data/fixtures/provenance/youtube-reencode-gemini-omni-flash.mp4 differ diff --git a/data/fixtures/provenance/youtube-reencode-grok.mp4 b/data/fixtures/provenance/youtube-reencode-grok.mp4 new file mode 100644 index 0000000..794bc0d Binary files /dev/null and b/data/fixtures/provenance/youtube-reencode-grok.mp4 differ diff --git a/data/fixtures/visible/README.md b/data/fixtures/visible/README.md index 7447842..a6eff30 100644 --- a/data/fixtures/visible/README.md +++ b/data/fixtures/visible/README.md @@ -17,7 +17,7 @@ The examples demonstrate DETECTION geometry and house style, not vendor raster fidelity; real-world variants (fonts, opacities, sizes) are covered by the engines' recorded calibration evidence. -The Qwen, Kling, Yuanbao, Baidu, Jimeng, LiblibAI, Microsoft, Sora, Hailuo, and Veo +The Qwen, Kling, Yuanbao, Baidu, Jimeng, LiblibAI, Microsoft, Sora, Hailuo, Veo, Vidu, and Wan directories additionally contain real provider-marked sources. These files retain the source bytes and names used here are deliberately distinct from the generated canonical examples. The current LiblibAI output exercises the @@ -37,6 +37,8 @@ metadata-corroborated compact-pill path, not the historical wordmark detector: | `hailuo/provider-original.mp4` | Authenticated Hailuo AI MiniMax H3 generation, downloaded with `With Watermark` selected on 2026-09-03 | `6152609e0339c443af79e244440e1b1050f3c615a50f76bfaf6146584e68142f` | Hailuo, 124/124 stable frames | | `kling/provider-original.mp4` | Authenticated Kling AI VIDEO 3.0 generation, downloaded 2026-09-04 | `f3e8d6a765fca699036d68fde28eb16f7aed97b6702f06f318e61ae8d4e53a69` | Kling, 121/121 stable frames | | `veo/provider-original.mp4` | Authenticated Google Flow Veo 3.1 Fast generation with `Visible watermarking` enabled, downloaded 2026-09-03 | `e778194b545e34dafd2d5be58691031a070862890c41e6c527a652a114a7c7a4` | Veo, 192/192 stable frames | +| `vidu/provider-original.mp4` | Authenticated Vidu Q3 text-to-video generation on the free trial, downloaded 2026-09-23 | `8e9f2a8bd190e5cfc662151aeef63cbc3372619678e2d37b3432b4fea113fb1c` | Vidu, 193/193 stable frames | +| `wan/provider-original.png` | Authenticated Wan 2.7 Pro image generation on free daily credits, downloaded 2026-09-23 | `583df0ec34d88653fc82c658728780f474a959eb246156649e5d5dde956fc474` | Wan, confidence 0.66 | OpenNoMark describes its corpus as real-image examples and distributes the repository under CC BY-NC-SA 4.0. Its license text is reproduced in @@ -53,6 +55,16 @@ still-life prompt recorded in `../README.md`, carries the visible top-right AI-service output; the maintainer distributes this generated output under the repository license. +The Vidu file is the original 1920 x 1080 HEVC MP4, 8 seconds, of a neutral +coffee-cup scene generated on the maintainer's free Vidu account. It carries the +bottom-right `Vidu AI` logo and wordmark on every frame and a TC260 AIGC label in +the MP4 `AIGC` key naming ShengShu (USCC `91110108MACC4D63XF`) as producer. The +Wan file is the original 2048 x 2048 PNG of a neutral coffee-mug scene from the +maintainer's Wan account, with the bottom-right logo and `Wan` wordmark and an +XMP TC260 label naming Tongyi Yunqi (Hangzhou), USCC `91330106MA2CFLDG4R`. The +service reported `noWaterMark: true` for that file, yet the mark is present. The +maintainer distributes both outputs under the repository license. + The Sora frame is the original JPEG frame `images/train/images/failed_image_000016_frame_000150.jpg` from LLinked's `sora-watermark-dataset`. The dataset card describes its images as frames diff --git a/data/fixtures/visible/vidu/example.mp4 b/data/fixtures/visible/vidu/example.mp4 new file mode 100644 index 0000000..d53e7c1 Binary files /dev/null and b/data/fixtures/visible/vidu/example.mp4 differ diff --git a/data/fixtures/visible/vidu/provider-original.mp4 b/data/fixtures/visible/vidu/provider-original.mp4 new file mode 100644 index 0000000..407f236 Binary files /dev/null and b/data/fixtures/visible/vidu/provider-original.mp4 differ diff --git a/data/fixtures/visible/wan/example.png b/data/fixtures/visible/wan/example.png new file mode 100644 index 0000000..866c006 Binary files /dev/null and b/data/fixtures/visible/wan/example.png differ diff --git a/data/fixtures/visible/wan/provider-original.png b/data/fixtures/visible/wan/provider-original.png new file mode 100644 index 0000000..95c7110 Binary files /dev/null and b/data/fixtures/visible/wan/provider-original.png differ diff --git a/docs/c2pa-resolution-research.md b/docs/c2pa-resolution-research.md index 31e200b..4c48d74 100644 --- a/docs/c2pa-resolution-research.md +++ b/docs/c2pa-resolution-research.md @@ -12,7 +12,7 @@ API: a standard way of retrieving manifest stores from a resolution endpoint given **a soft binding value, a manifest identifier, or an asset** - three input modes that are exactly three privacy exposure levels. The algorithm registry (`c2pa-org/softbinding-algorithm-list`) carries the endpoint URIs in -`softBindingResolutionApis`. As of the packaged revision `c0218cd628c9`, +`softBindingResolutionApis`. As of the packaged revision `1b3ef637890e`, 8 of 53 entries publish them. ## Measured contracts diff --git a/docs/chroma1-engine-research.md b/docs/chroma1-engine-research.md index 5f7b3af..5c1c2a6 100644 --- a/docs/chroma1-engine-research.md +++ b/docs/chroma1-engine-research.md @@ -10,6 +10,12 @@ > [supported signals](supported-signals.md), [known limitations](known-limitations.md), > and [module internals](module-internals.md). Nothing here is shipped. +Correction 2026-09-23: the claim below is out of date. diffusers now ships +`Flux2KleinInpaintPipeline`, whose `strength` argument drives `get_timesteps` +and `scheduler.scale_noise` (checked against the installed 0.40.0 source); with an all-white mask it is partial-noise img2img, and FLUX.2 klein 4B +is Apache-2.0. A measured FLUX.2 klein global stage is a pending experiment, not +a shipped profile. + Cited experiment behind issue #88 ("Please integrate Flux 2"): FLUX.2 has no native strength img2img anywhere (BFL reference code or diffusers; its image inputs are reference conditioning and outputs start from pure noise), so the diff --git a/docs/cli.md b/docs/cli.md index 2c6e0ba..e304069 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -17,7 +17,7 @@ defaults. This page focuses on choosing the right command. | `classify` | `remove-ai-watermarks[classify]` | | Visible signals in `identify` | `remove-ai-watermarks[visible]` (`pixels` is the minimal runtime) | | Open DWT-DCT signals in `identify` | `remove-ai-watermarks[detect]` | -| Adobe TrustMark signals in `identify` on Python 3.11-3.12 | `remove-ai-watermarks[trustmark]` | +| Adobe TrustMark signals in `identify` | `remove-ai-watermarks[trustmark]` | | `visible` and `erase` with OpenCV | `remove-ai-watermarks[visible]` (`pixels` is the minimal runtime) | | `visible` or `erase` with MI-GAN | `remove-ai-watermarks[migan]` | | `visible` or `erase` with big-LaMa | `remove-ai-watermarks[lama]` | diff --git a/docs/external-benchmark-licensing.md b/docs/external-benchmark-licensing.md index e7a4bfb..c12ca8b 100644 --- a/docs/external-benchmark-licensing.md +++ b/docs/external-benchmark-licensing.md @@ -33,7 +33,9 @@ Sources verified: the Hugging Face dataset `Shilin-LU/W-Bench` at revision `c81f3924f0fb` and the VINE repository `Shilin-LU/VINE`. - **Dataset license: MIT**, declared on the dataset card. The benchmark is - legally usable. + legally usable as a benchmark, but the MIT tag does not relicense the + underlying COCO, Flickr and ShareGPT4V images, whose own terms still apply + to any redistribution. - **Composition (verified from the card):** 10,000 instances sourced from COCO, Flickr, ShareGPT4V and similar datasets, eleven watermarking methods, evaluated across four editing profiles - regeneration, global diff --git a/docs/index.md b/docs/index.md index fde1bc9..6511131 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,3 +59,4 @@ The current behavior is defined by the code, tests, README, and user guides. - [C2PA resolution research](c2pa-resolution-research.md) (measured resolver contracts, the privacy gradient, and the decision to never resolve over the network) - [External benchmark licensing](external-benchmark-licensing.md) (ETI license-blocked, W-Bench dataset MIT with non-commercial code, verified 2026-09-07) - [Chroma1 engine research](chroma1-engine-research.md) +- [Research sweep, September 2026](research-sweep-2026-09.md) (survey of every covered area, where each finding landed, and open items) diff --git a/docs/installation.md b/docs/installation.md index 34623e2..d0930f3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -126,7 +126,7 @@ application actually uses: | `visible` | Visible mark detection, OpenCV inpainting, and manual erasing | `pixels` | No | | `video` | Visible video identification/removal and timestamp preservation | `visible`, PyAV | No | | `detect` | Open DWT-DCT detection for Stable Diffusion, SDXL, and FLUX | `pixels`, PyWavelets | No | -| `trustmark` | Adobe TrustMark detection on Python 3.11-3.12 | trustmark | Yes | +| `trustmark` | Adobe TrustMark detection | trustmark | Yes | | `classify` | Metadata-free photo AI-versus-camera classifier plus gated provider | `pixels`, Torch, Transformers | Yes | | `classify-onnx` | Optional CPU-only ONNX vision runtime for `classify` | `classify`, ONNX Runtime | Yes | | `source-classify` | Abstaining OpenAI/Google/unknown source-export classifier, not SynthID detection | `pixels`, Hugging Face Hub | Model download, no Torch | @@ -164,10 +164,9 @@ flowchart LR explicitly with another feature when required. `text-draft` is excluded from `all` because it proposes unverified OCR annotations and is not a production removal path. `classify-onnx` is excluded from `all`; install it only for `backend="onnx"`. -The TrustMark branch remains available only on Python 3.11-3.12. -Its supported dependency floor includes releases requiring NumPy 1.x, which has -no CPython 3.13 or 3.14 wheels; newer TrustMark releases relaxing that requirement -do not by themselves widen this package's declared support range. The `all` bundle contains every production branch compatible with +The TrustMark branch requires `trustmark>=0.9.2`, the first release without a +NumPy 1.x pin, so it installs on every supported Python; its detector tests pass +on Python 3.13 with NumPy 2 (checked 2026-09-23). The `all` bundle contains every production branch compatible with the active Python and never includes `dev`. Examples: diff --git a/docs/known-limitations.md b/docs/known-limitations.md index bd0c72e..5672617 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -80,6 +80,17 @@ Known examples: requires LiblibAI metadata or the bottom-center wordmark and a flat footprint; `--sensitivity strict` leaves this corroboration-only component untouched. - Kling AI support covers the calibrated variants rather than every Kling AI label. +- Dreamina's boxed `AI` badge (top-left, images and video) is deliberately not + registered; see the EU disclosure-icon boundary in + [legal-and-safety.md](legal-and-safety.md) and the decision in + [research-sweep-2026-09.md](research-sweep-2026-09.md#decisions). +- The Gemini sparkle detector scores up to 0.67 on non-Gemini images (whiteboard + photos, UI screenshots, digital art, wood grain), while real Gemini sparkles + start near 0.55. `identify` drops a sparkle when trusted provenance names + another AI vendor, for example a valid non-Google C2PA claim or a registered + TC260 producer, and says so in a caveat. The visible removal path does not + read that provenance, so it can still fill a sparkle-shaped patch on such an + image; without provenance the false positive stays in both paths. Use `erase --region` when you can see and select an unsupported or missed mark. @@ -231,7 +242,11 @@ cleaned. Whether a given carrier's audio actually holds a mark the verifier reads has not been established -- that needs a provider verdict, which has not been obtained. Treat a clean local report on a clip with generated audio as unproven, not as a -guarantee, and check the audio separately when it matters. +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. The public video results make that boundary explicit without decoding audio: `visual_invisible_action` records whether video pixels were regenerated, while @@ -404,7 +419,18 @@ The contracts, the privacy gradient, and the reasoning are recorded in Screenshots, social platforms, and re-encoding can remove metadata while a pixel watermark remains. `identify` therefore reports unknown rather than -clean when no supported signal is found. +clean when no supported signal is found. Aggregators strip it too: Higgsfield +serves Google and xAI outputs without their C2PA, and its Soul v1, Soul Cinema +and Wan 2.2 downloads carry no signal at all (2026-09-24). + +### GIF, TIFF, and DNG metadata + +GIF metadata removal walks the file's blocks and drops comment and application +extensions (XMP, C2PA) while copying every frame, palette and LZW stream +verbatim; the looping extension and, with standard metadata kept, the ICC +profile survive. TIFF and DNG are refused with an error: the generic re-save +used to write PNG bytes under the original name and drop pages, depth and raw +data, and no lossless TIFF tag editor is implemented yet. ### JPEG XL is metadata only @@ -455,7 +481,25 @@ Historical Sora Turbo exports use a small OpenAI swirl in the corner rather than the moving mascot-and-wordmark design; that earlier variant is not detected by the `sora` video mark. Hailuo AI and Kling AI coverage is specific to the verified lower-edge layouts; a new provider layout needs a separate calibrated -silhouette. Other provider video labels are not supported yet. Google video +silhouette. Other provider video labels are not supported yet. The `vidu` +video mark and the `wan` image mark are each calibrated on one real export, so +their gates are provisional: other resolutions, layouts, or a Wan video label +may be missed. The Kling detector still scores the Vidu wordmark; `vidu` comes +first in the selection order. A C2PA manifest from another vendor that claims AI +generation vetoes any video mark, and a TC260 label naming a non-Kling producer +also vetoes a Kling match: the Kling detector scored 0.61 on wood texture in a +Veo 3.1 Lite clip, against 0.63 on a real Kling original, and the Sora detector +scored 0.61-0.67 on steam in a Gemini Omni clip. Without such provenance, those +false positives remain. Two wood-grain false positives whose provenance matched the mark are +closed by shape and color rather than provenance: a Kling swirl candidate must +reach the bottom-right edge like the wordmark, and a Hailuo candidate must hold +white label pixels (Higgsfield Cinema Studio v2 and Hailuo 2.3, 2026-09-25). +A Kling run must also keep its box within 0.95 IoU of the run's first frame, +because the per-frame Kling score does not separate texture from the label (a +Genjutsu clip's wood grain drifted a few pixels while a real overlay never +moves). +A label shown only on the opening frames, as China's labeling rules allow, is +filled for at most `min_stable_frames` beyond its last detected frame. Google video SynthID has an oracle-certified VAE removal path, while other proprietary invisible video watermarks have no registered attack. @@ -544,9 +588,8 @@ The `trustmark` extra adds Adobe TrustMark decoding. The implementation retains an additional JPEG re-encode gate and requires the binary payload and schema to remain identical because isolated decoder hits can otherwise be content noise. It accepts Variant P schemas 0-2. Variant Q requires a different model, and -schema 3 is rejected at the measured precision threshold. Its NumPy 1.x runtime -limits the extra to Python 3.11-3.12; the rest of the package remains supported -through Python 3.14. The current TrustMark dependency line resolves +schema 3 is rejected at the measured precision threshold. The extra requires +`trustmark>=0.9.2`, which supports NumPy 2 and Python 3.13-3.14. The current TrustMark dependency line resolves `lightning` and `pytorch-lightning` 2.6.6, which contain the published fixes for their checkpoint-loading advisories. The calibration history is in [module internals](module-internals.md#metadata-and-provenance). diff --git a/docs/legal-and-safety.md b/docs/legal-and-safety.md index 61e8b64..9c5cbed 100644 --- a/docs/legal-and-safety.md +++ b/docs/legal-and-safety.md @@ -1,20 +1,36 @@ # Scope, safety, and legal notes -This page explains the project's intended boundary. It is not legal advice. -Laws and platform rules change, so check the current rules that apply to your -location and use case. +This page explains the project's purpose and intended boundary. It is not legal +advice. Laws and platform rules change, so check the current rules that apply to +your location and use case. -## Intended scope +## Purpose -The project removes AI provenance marks that a platform adds to content the -user generated or edited themselves. Examples include: +This is a research project. It exists to study how AI provenance signals behave: +how robust visible labels, invisible watermarks, and C2PA or metadata +disclosures are, how they fail, and how reliably they can be identified. The +same code gives people control over provenance marks on content they generated +or edited themselves, and helps correct false AI labels. + +The project covers: - visible AI generation labels; - invisible provenance watermarks; - C2PA and metadata based AI disclosures. -The purpose is user control over the user's own output, false positive cleanup, -interoperability work, and watermark robustness research. +## No warranty and no liability + +The software is provided under the Apache License 2.0, "AS IS", without +warranties or conditions of any kind (section 7). The authors and contributors +are not liable for any damages arising from its use (section 8). They do not +monitor, control, or endorse what anyone does with the software or with its +outputs. Each user is solely responsible for determining whether a given use is +lawful where they are and where the content is published. + +A license disclaimer governs the relationship between the authors and users. +It does not override public law. Where a jurisdiction restricts removing AI +labels, or supplying tools that do so, that restriction applies regardless of +this notice; see the table below. ## Out of scope @@ -26,6 +42,10 @@ party's paid or copyrighted asset, including: - tiled overlays used to gate a purchase; - artist protection systems such as Nightshade or Glaze. +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. + The `erase` command is a generic region tool. Users are responsible for having the right to edit the selected content. @@ -38,6 +58,7 @@ Removing a local signal does not: - anonymize the generating account; - defeat every statistical AI detector; - guarantee that a provider's current verifier will reject the result; +- remove a platform's own disclosure duty or its independent AI detection; - make deceptive or unlawful use permissible. An original file may remain linked to an account or generation session in a @@ -45,10 +66,43 @@ provider's systems even after a local copy is changed. ## Legal context -Some jurisdictions and platforms require AI generated content to carry visible -or machine readable disclosures. Rules may apply to providers, publishers, -users, or a combination of them. Some laws also restrict removing or -suppressing provenance information. +Several jurisdictions now require AI generated content to carry visible or +machine readable disclosures. Most of these rules bind providers and platforms; +some also restrict removing the labels, and China also restricts supplying +tools for doing so. The table records the primary sources checked on 2026-09-23. It is a +starting point, not a complete or current survey. + +| Jurisdiction | Instrument and status | Who it binds | Removal provision | +| --- | --- | --- | --- | +| China | [Measures for Labeling AI-Generated Synthetic Content](https://www.cac.gov.cn/2025-03/14/c_1743654684782215.htm), in force since 2025-09-01; technical standard GB 45438-2025 | Service providers, platforms, app stores, users, and "any organization or individual" | Article 10: no organization or individual may maliciously delete, alter, forge, or conceal the required labels, or provide tools or services for others to do so. Article 9 lets a provider supply unlabeled output on a user's request under a user agreement, keeping logs. | +| China | [Deep Synthesis Provisions](https://www.cac.gov.cn/2022-12/11/c_1672221949354811.htm), in force since 2023-01-10 | Everyone | Article 18: no organization or individual may use technical means to delete, alter, or conceal deep synthesis labels. | +| China | CAC enforcement campaign against AI application abuses, [launched 2026-04-30](https://www.cac.gov.cn/2026-04/30/c_1779289298718765.htm) | Platforms, sellers, open-source communities | Names tutorials that teach label removal and non-compliant de-labeling tools and services as targets. | +| European Union | [AI Act Article 50](https://eur-lex.europa.eu/eli/reg/2024/1689/oj), applying from 2026-08-02; [Regulation (EU) 2026/1744](https://eur-lex.europa.eu/eli/reg/2026/1744/oj) gives systems already on the market until 2026-12-02 | Providers (machine-readable marking) and deployers (deepfake disclosure, except purely personal non-professional use) | None in the Act. | +| European Union | [Code of Practice on marking and labelling of AI-generated content](https://digital-strategy.ec.europa.eu/en/news/commission-publishes-code-practice-marking-and-labelling-ai-generated-content), final 2026-06-10, voluntary | Signatory providers and deployers | Measure 1.2: signatories prohibit intentional removal of or tampering with metadata markings in their terms, with an exception for legitimate processing such as security audits and research purposes, and will not promote tools whose purpose is to circumvent machine-readable markings. | +| United States, California | SB 942 AI Transparency Act as amended by [AB 853](https://leginfo.legislature.ca.gov/faces/billTextClient.xhtml?bill_id=202520260AB853); provider duties from 2026-08-02, platforms from 2027-01-01 | Large providers, their licensees, large platforms, hosting platforms | Providers must make disclosures difficult to remove; platforms may not knowingly strip provenance data. No duty on end users or third-party tools. | +| United States, federal | [17 U.S.C. 1202](https://www.law.cornell.edu/uscode/text/17/1202) (copyright management information) | Any person | Prohibits knowingly removing copyright management information when the person knows it will enable or conceal infringement. | +| India | [IT Rules amended by G.S.R. 120(E)](https://www.meity.gov.in/static/uploads/2026/02/550681ab908f8afb135b0ad42816a1c9.pdf), notified 2026-02-10, in force since 2026-02-20; a further draft amendment (April 2026) would require the label for the whole duration of a video | Intermediaries offering tools that create synthetically generated information; significant social media intermediaries must also collect and verify user declarations | Rule 3(3)(b): the intermediary shall not enable modification, suppression, or removal of the label or the permanent metadata and unique identifier. The [MeitY FAQ](https://www.meity.gov.in/static/uploads/2025/10/065b6deb585441b5ccdf8be42502a49c.pdf) adds that intermediaries should not offer "remove watermark" or "export without metadata" functions. | +| South Korea | [AI Framework Act](https://www.law.go.kr/법령/인공지능발전과신뢰기반조성등에관한기본법), in force since 2026-01-22 | AI business operators | Labeling duty for generative output; no removal provision found. | + +Provider terms of service can also restrict removal or misrepresentation of +origin independently of any statute; several major providers prohibit +presenting generated output as human-made. Platforms such as YouTube, TikTok, +and Meta require creators to disclose realistic AI content and may apply their +own labels from their own detection. A test upload on 2026-09-24 confirmed the YouTube side: a Gemini +video with Google C2PA got "How this was made: Made with AI, Info from Google +LLC" with the AI question left unanswered, while an xAI Grok video whose own +manifest was self-signed got no label; the public streams carried no C2PA, +and the owner's Studio download came back re-signed by YouTube. As of +September 2026, YouTube carries +forward C2PA 2.1 or later disclosures that the whole video was made with AI and +also labels content its own systems detect; TikTok reads C2PA Content +Credentials and adds its own invisible watermark to content made with its AI +tools; Meta labels from the C2PA and IPTC AI indicators; LinkedIn shows a C2PA +icon on signed media. Sources: [YouTube](https://support.google.com/youtube/answer/15447836), +[YouTube blog, 2026-05-27](https://blog.youtube/news-and-events/improving-ai-labels-viewers-creators/), +[TikTok, 2025-11-19](https://newsroom.tiktok.com/more-ways-to-spot-shape-and-understand-ai-content?lang=en), +[Meta](https://about.fb.com/news/2024/04/metas-approach-to-labeling-ai-generated-content-and-manipulated-media/), +[LinkedIn](https://www.linkedin.com/help/linkedin/answer/a6282984). Before removing a mark, consider: @@ -56,21 +110,19 @@ Before removing a mark, consider: 2. whether a disclosure is legally required where the content will be used; 3. whether removal would mislead a viewer about authorship or origin; 4. whether copyright management information is involved; -5. whether a platform's terms prohibit the change. - -The repository does not provide jurisdiction specific legal advice. The user is -responsible for checking current law and policy. +5. whether a platform's or provider's terms prohibit the change; +6. whether the jurisdiction restricts label removal or removal tools. ## Appropriate uses Examples that fit the project scope include: +- testing the robustness of watermarking and provenance systems; +- evaluating image processing pipelines in a controlled environment; - removing metadata that exposes an account identifier from your own file; - correcting an AI label applied to a human photograph after a limited edit; - publishing your own generated artwork under the disclosure rules that apply - to you; -- testing the robustness of watermarking and provenance systems; -- evaluating image processing pipelines in a controlled environment. + to you. ## Uses the project does not condone diff --git a/docs/module-internals.md b/docs/module-internals.md index 18a017b..431b2a6 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -187,7 +187,15 @@ functions lazily. `identify_video` runs the same stable-mark selection helper as `remove_video_visible`, so a provenance report cannot authorize a mark that the removal path would reject. It reports an empty local result as unknown rather -than clean. Identification skips the separate per-frame timestamp probe because +than clean. A C2PA manifest alone is not an AI signal: `_video_markers_claim_ai` +requires what the image path requires, an AI digital source type or disclosure, +another AI marker (SynthID, TC260, xAI signature), or an AI-generator identity +(a registered AI signer or an AI product as claim generator). Since YouTube +re-signs every upload as Google LLC with only `opened` and `transcoded`, the +earlier any-manifest rule read a re-encoded xAI Grok video as Google AI with +SynthID (measured 2026-09-24, fixture `youtube-reencode-grok.mp4`); on the +local video corpus the change also moved an Adobe Premiere Pro export and a +Claude-provided file from AI to unknown and nothing else. Identification skips the separate per-frame timestamp probe because it never encodes frames. `remove_video_all` is the predictable-output composition: visible removal plus verified metadata stripping by default, with a same-container passthrough when neither signal exists. The lossy invisible @@ -211,7 +219,10 @@ Native MP4/MOV TC260 labels follow TC260-PG-20257A: `moov.udta.meta.keys` maps an `AIGC` key to a raw JSON value in `ilst`. [`_internal/isobmff.py`](../src/remove_ai_watermarks/_internal/isobmff.py) walks those nested boxes by seeking, so detection reaches a tail `moov` without reading the -preceding `mdat`. Two Doubao iOS variants sit outside that normative placement +preceding `mdat`. The metadata record stores each such value behind its `AIGC` +key: the bare JSON gave the record's scan parser no anchor, so three Higgsfield +MP4s with a tail `moov` read as labeled from the file and unknown from their +record until the key was kept (2026-09-24). Two Doubao iOS variants sit outside that normative placement and are covered by the same walker (2026-08-17 corpus findings, both previously undetected): a QuickTime-form `meta` box as a *direct* `moov` child (no FullBox header, disambiguated by probing the child-box offset), and a QuickTime @@ -347,13 +358,17 @@ from the gate. The full-clip oracle floor is detected while `0.15` did not. [`video_visible.py`](../src/remove_ai_watermarks/video_visible.py) implements -the first pixel stages for Sora, Veo, Seedance, Doubao, Dola, Hailuo AI, and Kling AI. The +the first pixel stages for Sora, Veo, Seedance, Doubao, Dola, Hailuo AI, Vidu, and Kling AI. The Sora detector searches a normalized frame with a fully synthetic mascot-and-text silhouette at several scales. The Veo detector uses separate synthetic silhouettes for the current four-point diamond and legacy `Veo` text. Seedance uses a synthetic rounded boxed-`AI` silhouette, while Dola uses an OpenCV-font `Dola AI` silhouette. Hailuo AI uses a synthetic waveform, -MINIMAX/Hailuo AI text, separator, and ring. Kling AI combines synthetic font +MINIMAX/Hailuo AI text, separator, and ring. Vidu uses two outlined capsules +leaning into a V plus a font-rendered `Vidu AI`, sized from the measured 1080p +mark (232 x 50 px); its floors (0.52 weak, 0.58 strong) sit between the one real +clip (0.72 at 480-1080p, 0.60 at 360p) and the maximum over the 956 other local +videos (0.41), measured 2026-09-24. Kling AI combines synthetic font and capitalization variants with a ring approximation of its swirl; the logo path rescues wordmarks whose version or font differs, while the edge and white-label gates reject recurring scene texture. All fixed-mark searches are bounded to the @@ -366,7 +381,8 @@ normalized representations across all detectors, and caches resized synthetic template features for the fixed stream geometry. Provider confidence scales are not comparable: selection applies each provider's temporal arbiter and takes the first stable result in specificity order (`sora`, `veo`, `seedance`, -`doubao`, `dola`, `hailuo`, `kling`). An explicit mark uses the same scan path with one +`doubao`, `dola`, `hailuo`, `vidu`, `kling`). `vidu` precedes `kling` because the +Kling detector also scores the Vidu wordmark. An explicit mark uses the same scan path with one candidate. Removal also collects authoritative per-frame timestamps for the encoder, while identification omits that unused ffprobe pass. @@ -375,7 +391,8 @@ fill padding and mask style are one row in `VISIBLE_MARK_POLICIES`, and every ma enters the same `stabilize_localizations` entry point; the recurrence implementation underneath knows nothing about providers. That policy row also carries `accepts_provenance`, which forces `provenance=False` for Kling AI. -Hailuo AI accepts an explicit MiniMax TC260 producer. Doubao accepts its +Hailuo AI accepts an explicit MiniMax TC260 producer, and Vidu the ShengShu +USCC `91110108MACC4D63XF`. Doubao accepts its registry-listed TC260 producer codes, including their structured USCC form; an unrelated or absent producer does not confirm Doubao. Provenance can relax a low-contrast run only after recurring visual evidence exists. Sora transition frames follow the @@ -384,7 +401,38 @@ Dola, Hailuo AI, and Kling AI additionally require candidates to remain anchored start of a run. This rejects slowly drifting scene details that still have high frame-to-frame overlap. Technical encoder tags do not establish provider provenance. Hailuo AI confirmation requires its explicit producer marker, -while Kling AI remains visual-only. +while Kling AI remains visual-only. Provenance can still veto any video mark +(`contradicts_video_provenance`): a C2PA manifest that records +`trainedAlgorithmicMedia` under an issuer outside the mark's own vendor +(`_VIDEO_MARK_ISSUER_TOKENS`), and for Kling also a TC260 producer outside the +Kling row's codes (which include the bare `kling` Higgsfield writes). The C2PA +rule came from a Veo 3.1 Lite clip whose wood texture matched Kling on every +frame at 0.61, and was widened to every mark after a Gemini Omni 1.1 Flash clip +from Runway (2026-09-25) matched Sora on a steam wisp at 0.61-0.67 for five +frames, a stable run above the strict floor. A re-signing platform such as +YouTube records no source type and vetoes nothing. Provenance cannot veto a clip that really is Kling, so the swirl arm of +`detect_kling_frame` now passes the same `_reaches_kling_edge` test as the +wordmark arm: on Higgsfield's Cinema Studio v2 (a Kling model, TC260 producer +`kling`) the swirl matched wood grain at 0.52-0.56 with its box 11% short of the +right edge, and the run covered every frame. `detect_hailuo_frame` likewise +rejects a box with under `_HAILUO_MIN_WHITE_FRACTION` bright low-saturation +pixels: Hailuo 2.3 scored 0.33-0.35 on wood grain, above the real label's 0.31, +with no white pixel in the box (real label 1.00, synthetic example 0.15). +Neither gate stopped a Higgsfield Genjutsu clip whose wordmark arm matched wood +grain at the frame edge (0.22-0.32, white highlights included). Across 1008 +local videos the per-frame Kling score was no discriminator at all: the top +clip, an OpenArt export, scored 0.81 against the real overlay's 0.63, and 68 +inspected clips between 0.24 and 0.81 showed no Kling mark. What does separate +them is geometry over time: the real overlay's box is identical on every frame, +texture matches drift, so the Kling policy tightens `anchor_iou` from 0.80 to +0.95. + +A video TC260 label names its platform through `_tc260_video_platform`: the +Kling, Doubao, Qwen and Wan registry codes, the Vidu constant, and the bare +`MiniMax`. The labels name the producer organization, because the Tongyi Yunqi +code signs both Wan and HappyHorse. An unregistered producer keeps the generic +"China AIGC-labeled content (TC260 standard)". The image path keeps that generic +platform for every producer and records the manufacturer only for clash checks. Removal runs in a second decode pass. Sora, legacy Veo text, Dola text, Seedance, Hailuo AI, and Kling AI use box masks. Seedance deliberately fills the @@ -1430,11 +1478,23 @@ metadata uploads, not another synthetic guess. `_apply_false_positive_gate` logs `DEBUG` on the exact bypass path this incident hit, to build that corpus from production occurrences. +Texture reaches the trust gate too. A local sweep on 2026-09-24 found non-Gemini +images scoring up to 0.67 (whiteboard photos, UI screenshots, digital art) while +real Google-signed sparkles went as low as 0.55, and two Higgsfield downloads +scored 0.53 on wood grain. No threshold separates those, so `identify` passes +`_collect_visible_signals` the first non-Google AI vendor from the trusted +vendor claims (C2PA only when verified) and drops a sparkle on such an image +with `_SPARKLE_VETOED_CAVEAT`. Google provenance never vetoes. The removal +arbitration does not see these claims. + Regression coverage: - [`test_gemini_engine.py`](../tests/test_gemini_engine.py), including `TestDecorativeGlyphFalsePositive` pinning the confirmed false positive and its blocking counter-example. +- [`test_identify.py`](../tests/test_identify.py) + `test_non_google_provenance_vetoes_a_sparkle_on_texture` and + `test_google_provenance_keeps_the_sparkle`. ### Text mark engines @@ -1658,6 +1718,21 @@ actual vendor output -- treat this detector as weaker evidence than the brand-tuned engines above until it is recalibrated on real captures. +#### Wan logo and wordmark + +Registered 2026-09-24 from one real Wan 2.7 Pro export (2048 px square, neutral +scene, `data/fixtures/visible/wan/provider-original.png`). The mark measures +0.131 x 0.050 of the short side with ~0.005 margins; the synthetic silhouette is +`draw_wan` in `scripts/render_vendor_silhouettes.py` (a cut hexagon plus Avenir +Next Demi Bold `Wan`, parameters picked by best match on the real mark, 0.66). +NCC alone separates poorly: 3092 local images (every tracked fixture plus 3000 +sampled) reached 0.519. The mark hugs the corner, so `WanEngine._post_gate` +also requires the match to end within 0.015 of the short side from both edges; +with that anchor no negative scored 0.45 or above. The gate is NCC >= 0.55 plus +the anchor, strict only. The TC260 producer is Tongyi Yunqi (Hangzhou), USCC +`91330106MA2CFLDG4R`. Calibration stays provisional until a wider Wan cohort +exists. + #### OpenArt wordmark Registered 2026-09-21 for a confirmed production miss reported through raiw-app: @@ -2086,8 +2161,10 @@ recorded in runs the same two-stage recipe on a Chroma1-HD (`lodestones/Chroma1-HD`, Apache-2.0) global pass through diffusers' `ChromaImg2ImgPipeline`. It is the answer to issue #88's FLUX.2 request through the model that actually exposes -strength-controlled img2img in that family; the full research record, -including why FLUX.2 itself is not integrable this way, is +strength-controlled img2img in that family. diffusers has since added +`Flux2KleinInpaintPipeline` with a real strength path, so the earlier "FLUX.2 +is not integrable" conclusion is superseded pending a measured FLUX.2 klein +stage; the full research record is [`chroma1-engine-research.md`](chroma1-engine-research.md). The profile also implements the shared verified-text donor hook through its already diff --git a/docs/provider-oracles.md b/docs/provider-oracles.md index 38fb19c..52c0eaf 100644 --- a/docs/provider-oracles.md +++ b/docs/provider-oracles.md @@ -40,7 +40,8 @@ never silently triggers a Web upload. 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. Historical corpus rows may still name `synthid-portal`; that value +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. 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 diff --git a/docs/python-api.md b/docs/python-api.md index 71be52e..8dfe161 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -707,7 +707,7 @@ combines synthetic multi-scale visual matching with temporal consistency, so an isolated lookalike in one frame is not enough to authorize inpainting. `mark="auto"` is the default: it evaluates all providers in one decode pass and selects the first stable match in specificity order (`sora`, `veo`, `seedance`, -`doubao`, `dola`, `hailuo`, `kling`). Provider confidence values are calibrated +`doubao`, `dola`, `hailuo`, `vidu`, `kling`). Provider confidence values are calibrated independently and are not compared across detectors. Pass one of those explicit values to restrict the scan to a single provider. The Veo detector recognizes the current four-point diamond and the diff --git a/docs/research-sweep-2026-09.md b/docs/research-sweep-2026-09.md new file mode 100644 index 0000000..1208abb --- /dev/null +++ b/docs/research-sweep-2026-09.md @@ -0,0 +1,101 @@ +# Research sweep, September 2026 + +A survey of every area the library works in, run on 2026-09-22 and 2026-09-23 to +find what changed since the topic pages were written and what earlier research +missed. Nine areas were covered: Google SynthID, other vendors' invisible +watermarks, the watermark attack literature, visible AI labels, metadata and +provenance standards, video and audio, AI image classifiers, law and regulation, +and fill or regeneration models with competing tools. Claims were checked +against primary sources (specifications, vendor documentation, legal texts, +arXiv pages); live captures from the maintainer's accounts settled several +questions directly. + +This page is an index. Each finding lives in its canonical page. + +## Where the findings went + +| Area | Change | Page | +| --- | --- | --- | +| Law and regulation | Research purpose, no-liability notice, jurisdiction table (China, EU, India, US, South Korea), platform labeling behavior | [legal-and-safety.md](legal-and-safety.md) | +| SynthID adoption | 100B+ items, ElevenLabs and OpenAI audio (with OpenAI's verification API limits), Apple (announced), Vertex C2PA, Nano Banana 2 Lite and Gemini Omni Flash, optional visible mark, portal status | [synthid.md](synthid.md), [provider-oracles.md](provider-oracles.md) | +| Other vendors | Microsoft Foundry provenance for Azure-hosted Flux and MAI, Amazon Nova Reel, audio adopters | [watermarking-landscape.md](watermarking-landscape.md) | +| C2PA 2.3 and 2.4 | `c2pa.ai-disclosure` and ingredient `digitalSourceType` now read; parser claim corrected | [watermarking-landscape.md](watermarking-landscape.md) | +| Containers | GIF metadata stripped without re-encoding; TIFF and DNG refused; C2PA after large WAV and AVI data now scanned | [known-limitations.md](known-limitations.md) | +| TC260 in audio | WAV, MP3, OGG, Opus and FLAC label placements from the 2025-08 audio guide | [supported-signals.md](supported-signals.md) | +| Video labels | Sora discontinued; opening-only labels no longer filled over the whole clip; any video mark vetoed by another vendor's C2PA AI claim, Kling also by a non-Kling TC260 producer; TC260 video producers named | [supported-signals.md](supported-signals.md), [known-limitations.md](known-limitations.md) | +| Apple | Image Playground and Photos Clean Up credits attributed | [supported-signals.md](supported-signals.md) | +| New visible marks | `vidu` video mark and `wan` image mark, each calibrated on one real export | [supported-signals.md](supported-signals.md), [known-limitations.md](known-limitations.md) | +| Google Photos | AI edits reported as "Google Photos (AI edit)" rather than Gemini, with SynthID present: Google's checker found it in 4 of 4 Photos AI edits (Ask, eraser and two others) | [synthid.md](synthid.md), [supported-signals.md](supported-signals.md) | +| YouTube | Upload test: C2PA-driven "Made with AI" label, public streams without C2PA, Studio downloads re-signed by YouTube; re-encoded non-Google video no longer read as Google AI with SynthID | [supported-signals.md](supported-signals.md), [legal-and-safety.md](legal-and-safety.md), [module-internals.md](module-internals.md) | +| Audio | Generated audio from OpenAI, ElevenLabs and Microsoft passes through the video path unchanged | [known-limitations.md](known-limitations.md) | +| Attack literature | Fidelity band on open encoders now 30-45 dB; WmForger and WMCopier status | [synthid-removal-research.md](synthid-removal-research.md), [watermark-forgery-study.md](watermark-forgery-study.md) | +| Models | FLUX.2 klein has a strength-controlled pipeline in diffusers | [chroma1-engine-research.md](chroma1-engine-research.md) | +| Dependencies | TrustMark extra on every supported Python (`trustmark>=0.9.2`) | [installation.md](installation.md) | +| Live captures | Gemini Omni, Grok, Vidu, Wan, Apple, Google Photos, Nano Banana 2 Lite and Gemini Omni Flash through the API; Runway, Higgsfield and Dreamina | [watermarking-landscape.md](watermarking-landscape.md#live-captures-2026-09-23-and-2026-09-24) | + +## Open items + +- **Google Photos Reimagine manifests.** Magic Editor, and so Reimagine, is not + offered in Google Photos on iOS, so no Reimagine file is captured. The + question it was meant to answer is settled without one: Google's checker found + SynthID in all four Photos AI edits tested on 2026-09-25, none of which + records SynthID in its manifest. +- **Vidu and Wan marks on more samples.** Both are registered from one real + export each (`vidu` video, `wan` image); their gates are provisional until a + wider cohort is measured, and a Wan video label is not yet captured. +- **TC260 practice guides.** The audio placements from TC260-PG-202510A are + now read (the guide PDFs load in a browser from + `tc260.org.cn/tc260/sjzn/list.shtml`, not to scripted requests). The text-file + guide (TC260-PG-20258A) covers documents this image and video tool does not + process. The security-protection guide (TC260-PG-202511A) records a + `SecurityData` JSON object, a digital signature over the label and optionally + the content, in `ReservedCode1` and `ReservedCode2`. The library accepts those + fields as label evidence but does not verify the signature. +- **Experiments.** Chroma restoration after regeneration (from the NeurIPS 2024 + "Erasing the Invisible" winning solution, arXiv:2508.21072), FLUX.2 klein and + Z-Image-Turbo as global stages (with an Apple Silicon arm through mflux), + linear probes on newer frozen backbones for `classify`, Resemble Perth and + Meta PixelSeal as benchmark oracles, re-watermarking as a removal primitive + (arXiv:2605.16796, MIT code), RAVEN view-synthesis removal + (arXiv:2601.08832, no code released as of 2026-09-24), and Apache-2.0 video + inpainting (VACE, ROSE) against the per-frame visible video fill. + MiniMax-Remover (CC-BY-NC-4.0 weights) and ProPainter (S-Lab License, + non-commercial) are excluded on license. +- **New C2PA signers without a vendor row.** The + [C2PA conformance list](https://github.com/c2pa-org/conformance-public/blob/main/conforming-products/conforming-products-list.json) + names generator certificates `Amazon Bedrock`, `Getty Images + AI-Generated Image` and `AI-Modified Image` (and the iStock pair), `Jasper`, + `RefaceAI`, and vivo `vivo Albums` and `JOVI Albums`. It also lists `Google + Photos` for both Android and iOS, the signer the Photos rule keys on. A + real sample per vendor is still needed to confirm the manifest shape before + adding a row. Runway is done: its own models sign as `RUNWAY AI, INC.` + (measured 2026-09-24) and the row covers the conformance-list spelling too. +- **Chinese phone galleries.** Huawei's help pages say the Xiaoyi photo-edit AI + watermark can be switched off; Honor, OPPO and Xiaomi label behavior is not + documented in any primary source found. vivo's Album setting is already + covered. +- **Claims not supported by a primary source.** Kling embedding SynthID + (repeated by remover blogs; Kuaishou is not in Google's adopter list), + Hailuo pixel watermarks, and platforms stripping C2PA on upload. None is used + by the library. +- **Google Photos small edits (deferred).** The "SynthID present" rule for + Photos AI edits rests on four checked edits, none of them tiny. Still to + measure on the phone app: a tiny eraser edit, a large one, a small "Help me + edit" change, a restyle, and a crop-only control, each checked in Gemini. iPhone + Mirroring does not show the Photos editing toolbar, and the web editor has no + generative tool, so this needs edits made on the device. +- **Captures blocked by region or device.** Doubao, Jimeng, Samsung. Higgsfield, + Runway and Dreamina were captured on paid plans on 2026-09-24. + +## Decisions + +- **Dreamina `AI` badge: not registered (2026-09-26).** Dreamina stamps a small + boxed `AI` badge top-left on images and video, and calls it its visible AI + watermark. It stays unregistered for three reasons. A generic boxed `AI` is the + shape of the disclosure icon the EU Code of Practice defines, which this + project does not template, so a template would remove any such label. The same + generic shape would match other platforms' badges and plain `AI` text and + misattribute them to Dreamina. And it adds nothing to attribution: Dreamina + exports are identified by their C2PA (`ByteDance Media Transcode Service` + signer with a `Dreamina/7.5.0` ingredient), and Dreamina's own "Remove watermarks" setting + removes the badge. Revisit only if the badge becomes a distinctive brand mark. diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 244a493..f40e9ef 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -14,6 +14,7 @@ The `visible` command registers these mark keys: | `doubao` | `豆包AI生成` | Bottom right | Vendor specific text detector. | | `jimeng` | `★ 即梦AI` | Bottom right | Vendor specific text detector. | | `qwen` | `千问AI生成` or the three-lobe Qwen symbol | Bottom right | Separate strict templates cover the text and Qwen Create symbol variants. | +| `wan` | Wan logo and `Wan` wordmark | Bottom right, hugging the corner | Provisional: one real Wan 2.7 Pro export. Requires NCC >= 0.55 and a match that ends within 0.015 of the short side from both edges. | | `kling` | `可灵AI 3.0` or `KlingAI 3.0` | Bottom right | Separate calibrated silhouettes cover the older CJK and current IMAGE 3.0 Latin variants. | | `yuanbao` | `元宝` over `AI生成` | Bottom right | Standard two-line variant only. | | `samsung` | `✦ Contenuti generati dall'AI` | Bottom left | Calibrated for the Italian text variant. | @@ -48,13 +49,14 @@ pill detector; the generic pill shape does not attribute LiblibAI on its own. | Key | Mark | Motion | Important limit | | --- | --- | --- | --- | -| `sora` | Sora 2 mascot and wordmark | Moves among frame positions | Requires a temporally recurring visual match; the older Sora Turbo corner swirl is a different unsupported mark. | +| `sora` | Sora 2 mascot and wordmark | Moves among frame positions | Requires a temporally recurring visual match; the older Sora Turbo corner swirl is a different unsupported mark. OpenAI discontinued the Sora app on 2026-04-26 and the Sora API on 2026-09-24, so this covers existing exports only. | | `veo` | Current four-point diamond and legacy `Veo` text | Fixed bottom-right corner | Uses separate silhouettes and requires a recurring match; learned fill is preferable on structured backgrounds. | | `seedance` | Boxed `AI` label | Fixed bottom-right corner | Requires an anchored recurring match; the full localized box is filled because a thinner synthetic shape mask leaves the real translucent rim behind. | | `doubao` | `豆包AI生成` text run | Fixed bottom-right corner | Reuses the image engine's synthetic alpha as the template; a stable run of at least 12 frames at confidence >=0.35 is required. Without Doubao TC260 confirmation, the run also needs a strong anchor at >=0.55. | | `dola` | `Dola AI` text | Fixed bottom-right corner | Requires an anchored recurring match; ByteDance or BytePlus provenance can relax only an existing visual run. | | `hailuo` | `MINIMAX \| hailuo AI` composite label | Fixed lower edge | Uses a synthetic waveform, text, separator, and ring silhouette; the complete recurring label box is filled. A TC260 label naming MiniMax as producer can relax only an existing stable run. | -| `kling` | Kling AI swirl, `KLING AI` or `KlingAI`, version, and optional `PRO` suffix | Fixed bottom-right edge | Combines a synthetic logo rescue with font and capitalization variants, an edge gate, a white-label gate, and anchored temporal recurrence. | +| `vidu` | Vidu two-loop logo and `Vidu AI` wordmark | Fixed bottom-right corner | Provisional: one real Vidu Q3 export (0.72 per frame at 480-1080p, 0.60 at 360p; the 956 other local videos at most 0.41). Requires an anchored recurring run at >=0.52 with a strong frame at >=0.58. A ShengShu TC260 label confirms the vendor without lowering the bar. | +| `kling` | Kling AI swirl, `KLING AI` or `KlingAI`, version, and optional `PRO` suffix | Fixed bottom-right edge | Combines a synthetic logo rescue with font and capitalization variants, an edge gate, a white-label gate, and anchored temporal recurrence. It also scores the Vidu wordmark, so `vidu` is checked first, and a TC260 label naming a non-Kling producer vetoes the match. | `video identify`, `video visible`, and `video all` share this registry and the same temporal arbiter. It is separate from the image registry because selection @@ -93,12 +95,14 @@ The inspection and stripping code handles signals in these groups: - China TC260 AIGC labels in supported image placements and the normative MP4/MOV `moov.udta.meta.keys/ilst`, MKV/WebM `Segment.Tags.Tag.SimpleTag`, AVI `LIST/INFO/AIGC`, and FLV - `script.onMetaData.AIGC` placements; + `script.onMetaData.AIGC` placements, plus the audio placements of + TC260-PG-202510A: a RIFF `AIGC` chunk in WAV, an ID3v2 `TXXX` frame described + `AIGC` in MP3, and an `AIGC=` Vorbis comment in OGG, Opus and FLAC; - xAI and Grok signature-plus-UUID pairs in ordinary EXIF, ImageMagick PNG raw EXIF profiles, XMP description/creator fields, PNG Description/Author text, and IPTC Caption-Abstract/By-line fields; - Samsung AI editing markers; -- Hugging Face job metadata; +- Higgsfield job metadata (the `hf-job-id` PNG chunk); - open Stable Diffusion style DWT-DCT watermarks with the `detect` extra; - Adobe TrustMark Variant P schemas 0-2 with the `trustmark` extra. Variant Q needs a different model, while schema 3 is deliberately rejected because it @@ -176,7 +180,17 @@ and `scripts/synthid_runtime/`. The tool recognizes presence from supported provenance: Google AI C2PA under Google's all-media watermark policy, and current OpenAI C2PA carrying an -explicit `c2pa.watermarked.*` action. Legacy OpenAI C2PA without that action +explicit `c2pa.watermarked.*` action. Two Google signers are exceptions. +YouTube re-signs uploads as Google LLC (certificate `YouTube Video Processing +Services`, actions `opened` and `transcoded`, measured on Studio downloads), so +a YouTube-signed manifest establishes SynthID only through a SynthID action in +its chain, as a Gemini ingredient carries; with no AI claim in the chain, +`video identify` reports unknown. A Photos-signed AI edit is reported as +"Google Photos (AI edit)" with SynthID present, although the Photos manifest +records no SynthID action and Google documents the mark only for Reimagine: +Google's checker found SynthID in all four Photos AI edits tested on 2026-09-25 +(Ask, eraser and two other edits), and a caveat says the claim rests on that +measurement. Legacy OpenAI C2PA without that action does not assert SynthID. GPT Image 2.5 Flare and Sunburst were verified on 2026-09-15: both API outputs carried valid OpenAI C2PA with `c2pa.watermarked.unbound` and exercised the existing model-independent OpenAI @@ -270,11 +284,16 @@ not a universal clean verdict. | 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 | +| 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 | +| Wan (Alibaba) | Wan image mark | No registered pixel decoder | TC260 AIGC in XMP | | Hailuo AI / MiniMax video | Hailuo AI composite video label | No registered pixel decoder | TC260 AIGC where present | | Baidu | Baidu mark | No registered pixel decoder | TC260 AIGC | | LiblibAI | LiblibAI wordmark and compact pill | No registered pixel decoder | TC260 AIGC | | RunningHub | RunningHub mark | No registered pixel decoder | TC260 AIGC | | Samsung Galaxy AI | One locale specific mark | No registered pixel decoder | C2PA and Samsung markers | +| Google Photos AI edits | None | SynthID, reported present: Google's checker found it in 4 of 4 Photos AI edits (2026-09-25), though the manifest records no SynthID action | C2PA signed "Google Photos" with a `compositeWithTrainedAlgorithmicMedia` action, plus XMP `photoshop:Credit` `Edited with Google AI` | +| Apple Image Playground and Photos Clean Up | None | No registered pixel decoder; SynthID announced by Apple for a later update | XMP `photoshop:Credit` (`Apple Image Playground`, `Apple Photos Generative Edit: Clean Up`) plus IPTC digitalSourceType; no C2PA on measured 2026-09 output | For detector thresholds, measured limits, and incident history, see [module internals](module-internals.md) and diff --git a/docs/synthid-removal-research.md b/docs/synthid-removal-research.md index 1ae2c6a..3b4b09e 100644 --- a/docs/synthid-removal-research.md +++ b/docs/synthid-removal-research.md @@ -233,9 +233,14 @@ regeneration is already in [synthid.md](synthid.md#23-removal-attacks-and-forens | 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. Every paper that "beats" a watermark either -redraws the image, attacks an open encoder, or reports PSNR in the same -19-25 dB band as our 16-32 scramble. +OpenAI or Google SynthID (still true in the 2026-09-22 literature sweep). +Against open encoders the fidelity band has moved: re-watermarking +([arXiv:2605.16796](https://arxiv.org/abs/2605.16796)), 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, +and SPFM-Net and FDDWAN (arXiv:2607.27811, 2607.27800) claim 40-45 dB, but on +small images and with bit error rates that leave much of the payload intact. +None of these is a result on a production closed watermark. ## Product remainder for removal diff --git a/docs/synthid.md b/docs/synthid.md index 2054f2f..1bc056c 100644 --- a/docs/synthid.md +++ b/docs/synthid.md @@ -577,7 +577,9 @@ The SynthID decoder is proprietary and not released: There are no released payload-decoder weights or public algorithm. Google provides verification in Gemini. The old limited SynthID Detector portal does not work: on 2026-09-07, its official `labs.google/synthid` entry point -redirected a signed-in browser to a Google 404. OpenAI now documents a +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 +404 to a signed-in browser. OpenAI now documents a synchronous Content Provenance API whose image response contains separate C2PA and SynthID outcomes. That API is a remote, OpenAI-scoped verifier, not a local decoder. Its documentation also says not to use repeated queries to @@ -652,7 +654,53 @@ evidence, not proof of human authorship. We recognize SynthID indirectly from supported C2PA evidence; this is not a pixel watermark decode. Google states that all media generated by its tools is -watermarked, so Google AI C2PA establishes SynthID. OpenAI C2PA predates its +watermarked, so Google AI C2PA establishes SynthID. Google Photos AI edits +are included on measurement rather than documentation. Google documents SynthID +in Photos only for Reimagine +([blog.google, 2025-08-20](https://blog.google/products-and-platforms/products/photos/ai-photo-editing-google-photos/)), +and a Photos manifest (signer common name `Google Photos`) records actions such +as `c2pa.deleted` or `c2pa.edited` with no description naming the tool and no +SynthID action. Yet Google's own checker in Gemini, given the four Photos AI +edits in [`data/captures/2026-09/google-photos/`](../data/captures/2026-09/google-photos/) +with their metadata stripped, found SynthID in all four on 2026-09-25: an Ask +edit, an eraser edit and two other AI edits. The tool therefore reports SynthID +as present for a Photos-signed AI edit and adds a caveat that the claim rests on +that measurement, since Google notes a very small edit may not carry the mark. +The Google Photos web editor is outside that rule: it has no generative tool and +writes no Content Credentials, and Enhance, Sky and crop edits made there carried +no SynthID in the same checker (2026-09-26, +[`data/captures/2026-09/google-photos-web/`](../data/captures/2026-09/google-photos-web/)). + +What Google states, checked against each page on 2026-09-26; the step from these +statements to "every Photos AI edit" is this project's inference, not Google's: + +- Reimagine ([blog.google, 2025-02-06](https://blog.google/feed/synthid-reimagine-magic-editor/)): + Photos uses SynthID "to mark images edited with generative AI using Reimagine + in Magic Editor", and "edits made using Reimagine may be too small for SynthID + to label and detect". +- The Photos editor runs Nano Banana + ([blog.google, 2025-11-11](https://blog.google/products-and-platforms/products/photos/nano-banana-ai-templates-ask-photos/)): + "With Nano Banana in Photos' editor, you can now make all kinds of new + transformations to your images", reached through "Help me edit". The post does + not mention SynthID. +- The model marks its own output + ([Google Developers Blog](https://developers.googleblog.com/en/introducing-gemini-2-5-flash-image/)): + "All images created or edited with Gemini 2.5 Flash Image will include an + invisible SynthID digital watermark". +- Photos calls Magic Eraser generative and signs non-AI edits only in a chain that + already had credentials ([Photos Help](https://support.google.com/photos/answer/16496549)): + "Edited with AI tools" appears on edits "with generative AI like Magic Eraser + where new pixels are created", and an original without Content Credentials gets + them "when an AI edit is saved". No Google page states whether Magic Eraser + applies SynthID; the eraser capture above is the only evidence here. +- The checker's own caveat ([Gemini Apps Help](https://support.google.com/gemini/answer/16722517)): + "Likely too small an edit" (minor alterations might not carry a detectable + watermark). + +YouTube is the exception: it re-signs every upload with `YouTube Video +Processing Services`, so its manifest counts only when the chain carries a +SynthID action (measured 2026-09-24: a Gemini Omni Flash upload kept its +ingredient's SynthID action, an xAI Grok upload had none). OpenAI C2PA predates its SynthID rollout, but current manifests add the signed `c2pa.watermarked.unbound` action; OpenAI provenance establishes SynthID only when that action is present. Legacy OpenAI C2PA without it remains valid origin @@ -747,17 +795,40 @@ stand in for the Gemini oracle. --- -## 4. Adoption and current state (as of June 2026) +## 4. Adoption and current state (as of September 2026) ### 4.1 Google products -Google has watermarked **over 10 billion** images and video frames. The -deployment split by surface matters for our tool: +Google reports watermarking "over 100 billion images and videos and 60,000 years +of audio" ([Google, 2026-05-19](https://blog.google/innovation-and-ai/products/identifying-ai-generated-media-online/)). +The deployment split by surface matters for our tool: | Surface | SynthID pixel | C2PA metadata | Visible sparkle | |--------------------------------------|---------------|---------------|-----------------| | Gemini app (generated images) | YES | YES (Google) | YES | -| Gemini API / AI Studio / Nano Banana | YES | NO | YES | +| Gemini API / AI Studio / Nano Banana | YES | YES (Google) on all four image models, measured 2026-09-24: `gemini-2.5-flash-image`, `gemini-3.1-flash-image`, `gemini-3-pro-image`, `gemini-3.1-flash-lite-image` | NO on every measured API output | +| Vertex AI / Agent Platform models | YES | YES (Google, device "Google Media Processing Services") | Model-dependent | +| Gemini Omni video (Gemini app, Flow) | YES | YES (Google) | Optional Veo diamond | +| Gemini Omni Flash API (`gemini-omni-1.1-flash`) | YES, video with audio ([API docs](https://ai.google.dev/gemini-api/docs/omni)) | YES (Google), measured 2026-09-24; the docs do not mention it | NO on the measured output | + +The visible mark is optional for every consumer tier since 2026-08-14: the +Gemini "Media watermark" setting switches it off for images, Omni video and +Lyria music, except on work accounts and in India, South Korea and Vietnam +without Ultra. The C2PA actions record the visible mark as its own +`c2pa.edited` action ("Added visible watermark"), next to "Added imperceptible +SynthID watermark" (measured on an Omni capture, 2026-09-23). + +API captures on 2026-09-24 (one image each from Nano Banana, Nano Banana 2 and +Nano Banana Pro, three from Nano Banana 2 Lite, one Gemini Omni Flash video) +carried a valid Google C2PA manifest signed by `Google Media +Processing Services` with `c2pa.created` ("Created by Google Generative AI.") +and a SynthID `c2pa.edited` action, and no visible mark. The wording differs by +medium: the images say "Applied imperceptible SynthID watermark.", the video +"Added imperceptible SynthID watermark". The API video had no "Added visible +watermark" action. Its AAC audio track is covered by the same BMFF hash; whether +the audio carries its own SynthID payload is not visible locally. A Google manifest +without the visible-mark action therefore marks an export whose sparkle was +switched off, not a clean file. The Gemini API surface is a key blind spot: it embeds the pixel watermark and the visible sparkle but **no C2PA or IPTC at all**. Our `identify` returns @@ -795,6 +866,22 @@ floor to 0.15625 by the established full-spread rule. ### 4.3 Other vendors - **Kakao** (South Korea): SynthID adopter as of May 2026 (Google announcement) +- **ElevenLabs**: SynthID adopter for generated audio (same Google announcement). + ElevenLabs runs its own Audio Detector; Gemini's verifier recognizes only + Google content, so it is not an oracle for ElevenLabs audio. +- **OpenAI audio**: since 2026-07-31, audio generated through ChatGPT and the + OpenAI API carries SynthID, verifiable in OpenAI's public tool and API + ([OpenAI](https://openai.com/index/advancing-content-provenance/)). The API + is `POST /v1/content_provenance_checks`: one file up to 50 MiB, audio of 60 + seconds or less after decoding in MP3, Opus, AAC, FLAC, WAV or PCM; audio + results carry only a SynthID entry, with no C2PA + ([guide](https://developers.openai.com/api/docs/guides/content-provenance)). + The repository's OpenAI oracle adapter uploads PNG images only. +- **Apple** (announced, not shipped): Image Playground and Photos edits will get + SynthID "in a software update later this year" ([Apple, 2026-09-14](https://www.apple.com/newsroom/2026/09/major-updates-for-apples-software-platforms-are-now-available/)). + Measured iOS 27.0 output carries only an XMP credit and IPTC source type. + Each adopter has its own verifier; a Google verdict says nothing about another + vendor's payload. - **NVIDIA Cosmos**: SynthID for video (not still images; different pipeline) - **Meta AI**: does NOT use SynthID; Muse uses Content Seal and may retain a shared IPTC `digitalSourceType` companion tag that does not identify Meta by itself diff --git a/docs/watermark-forgery-study.md b/docs/watermark-forgery-study.md index ff657fc..6f8449f 100644 --- a/docs/watermark-forgery-study.md +++ b/docs/watermark-forgery-study.md @@ -109,7 +109,12 @@ Oracles and checkpoints are pinned as in the kernel document. ## Follow-ups -- A keyed-scheme perspective needs a scheme with actual key control; WmForger - and WMCopier diffusion-attack baselines remain the map's heavyweight item. +- A keyed-scheme perspective needs a scheme with actual key control. WmForger + ([arXiv:2510.20468](https://arxiv.org/abs/2510.20468), NeurIPS 2025) is a + gradient attack on an image preference model, not a diffusion attack, and its + code ships MIT-licensed in `facebookresearch/videoseal`, so it can run against + the pinned VideoSeal oracle and the local TrustMark decoder. WMCopier + ([arXiv:2503.22330](https://arxiv.org/abs/2503.22330)) has public code but no + license, so its outputs cannot be published here. - A general remover-trace detector would need features beyond one band ratio and validation against removals this project did not produce. diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index 40ece3e..1b0a09f 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -81,14 +81,14 @@ defines two more native serializations. AVI stores an `AIGC` child in bounded RIFF and FLV readers validate the JSON field set and skip media payloads. Removal remuxes either container through ffmpeg with stream copy. -- **Hugging Face-hosted job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** Hugging Face Jobs / Spaces can stamp generated PNGs with an `hf-job-id` tEXt chunk holding the job UUID. It marks the *hosting job*, not a model, so it lifts an Unknown verdict to a tentative AI via `hf_only` but never overrides a hard metadata signal. `_HF_JOB_CAVEAT` states the limit. Removal drops the chunk through the PNG metadata whitelist. +- **Higgsfield job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** Higgsfield writes an `hf-job-id` tEXt chunk holding its job UUID into the PNGs it serves, for its own and for hosted third-party models (23 of 26 PNG captures across 27 image models, 2026-09-24; Soul v1, Soul Cinema and Wan 2.2 arrived without it, and the one JPEG download has no PNG chunk to carry it). The public names still say Hugging Face, which was the earlier reading of the `hf-` prefix; they are kept for compatibility. The tag names no model, so it lifts an Unknown verdict to a tentative AI via `hf_only` (platform "Higgsfield (model not identified)") but never overrides a hard metadata signal. Higgsfield adds the chunk after signing, so an upstream C2PA manifest (OpenAI, Black Forest Labs, BytePlus, Recraft, fal.ai) fails with `assertion.dataHash.mismatch`; `_HF_JOB_CAVEAT` states both limits. Removal drops the chunk through the PNG metadata whitelist. - **No detectable signal on some downloads:** Recraft exports and some hosted FLUX surfaces can arrive without a supported local signal. Midjourney samples may carry IPTC metadata but no registered C2PA or pixel watermark. The open DWT-DCT decoder only applies when the producing pipeline actually ran its encoder and the carrier remains decodable. -- **Invisible but NOT locally detectable (proprietary, API/oracle only — same wall as SynthID):** Amazon Titan Image Generator + Nova Canvas (Bedrock `DetectGeneratedContent` API), Kakao (new SynthID image adopter, May 2026), NVIDIA Cosmos (SynthID video). No local detector possible; treat like SynthID. -- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We READ the soft-binding `alg`, preserve the structured assertion's signed `value`, and generate the display/type registry from the official C2PA JSON with `scripts/sync_c2pa_soft_bindings.py`. The packaged snapshot keeps runtime inspection offline and pins the exact upstream revision. Registration is name-only support: Adobe TrustMark remains the one locally decoded scheme (`trustmark_detector`) unless another compatible decoder is independently verified. Fingerprint entries remain re-linkability signals and are not mislabeled as pixel watermarks. +- **Invisible but NOT locally detectable (proprietary, API/oracle only — same wall as SynthID):** Amazon Titan Image Generator + Nova Canvas (Bedrock `DetectGeneratedContent` API; Amazon's Nova Reel service card also claims a watermark on every video), Kakao (new SynthID image adopter, May 2026), NVIDIA Cosmos (SynthID video), ElevenLabs and OpenAI audio (SynthID, 2026), Apple Image Playground and Photos edits (SynthID announced 2026-09-14, not shipped). Microsoft Foundry lists provenance (C2PA and/or invisible watermark; the page does not split them per model) for Azure OpenAI GPT Image, MAI-Image-2.5, Azure-hosted Black Forest Labs Flux (1.1-Pro, Kontext-pro, 2-flex, 2-Pro) and Azure audio models ([Microsoft Learn, 2026-09-17](https://learn.microsoft.com/en-us/azure/foundry/responsible-ai/content-safety/provenance-disclosure)). No local detector possible; treat like SynthID. +- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Correction 2026-09-23: 2.3 and 2.4 did change what a parser must read. 2.3 added OGG Vorbis and AVIX embedding; 2.4 added the `c2pa.ai-disclosure` assertion (section 18.28: `modelType`, `modelName`, `modelIdentifier`, `contentProfile.humanOversightLevel`), `digitalSourceType` on ingredient assertions, IFD0 embedding for single-IFD TIFF, and HTML and text embedding. The reader now handles `c2pa.ai-disclosure` and ingredient `digitalSourceType`; HTML and text embedding are out of scope for an image and video tool.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We READ the soft-binding `alg`, preserve the structured assertion's signed `value`, and generate the display/type registry from the official C2PA JSON with `scripts/sync_c2pa_soft_bindings.py`. The packaged snapshot keeps runtime inspection offline and pins the exact upstream revision. Registration is name-only support: Adobe TrustMark remains the one locally decoded scheme (`trustmark_detector`) unless another compatible decoder is independently verified. Fingerprint entries remain re-linkability signals and are not mislabeled as pixel watermarks. - **Microsoft Paint and Photos InvisMark (reverse-engineered 2026-08-20):** Paint receives a per-generation GUID from remote prompt moderation, embeds it into locally generated pixels, and records the same value in `c2pa.soft-binding` under `com.microsoft.invismark.1`. The C2PA soft-binding registry independently identifies that algorithm as Microsoft Responsible AI InvisMark for image and video. Paint's 144-bit writer framing does not match the public repository's 100-bit pretrained checkpoint interface, so compatibility is not assumed. The parser reports the signed identifier; there is no validated local pixel decoder. Microsoft's external Content Provenance Detection API is the removal oracle because it reports `Watermark` separately from `C2PA`; a metadata-stripped, pixel-identical control must remain watermark-positive before an output-negative result is attributed to pixel removal. The no-account web page for the same check is ; its clean outcome is rendered as `Inconclusive`, and that public provider oracle is the surface used for the measured removal certification. Sources: https://xusheng.dev/posts/reversing/mspaint_invisible_watermark/main/, https://github.com/c2pa-org/softbinding-algorithm-list/blob/main/softbinding-algorithm-list.json, and https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection - **Built in the dated batch:** soft-binding vendor detection, IPTC Photo Metadata AI-disclosure fields, C2PA detection and stripping for supported @@ -119,7 +119,7 @@ against primary sources before adding jurisdiction-specific claims. ## Visible AI-generation marks + detection methods (deep-research 2026-07-10, adversarially verified) -**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. So a Google-C2PA image with NO visible sparkle is expected (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, ask if it is AI-generated), which 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). +**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, ask if it is AI-generated), which 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). **Google Veo video marks use two incompatible visible designs.** Public raw clips verify the current four-point diamond and the legacy bottom-right `Veo` @@ -213,7 +213,7 @@ OpenRouter result above. **Visible-mark landscape beyond the registry.** Since Muse Image (2026-07) Meta's own generation output carries no visible mark, only the invisible proprietary Content Seal; the legacy visible "Imagined with AI" mark belongs to the pre-2026 Meta AI / "Imagine" pipeline and remains unregistered (string verified, position not). For third-party images Meta relies on C2PA / IPTC, not a visible mark. The Samsung detector covers only the measured Italian text wordmark; no separate icon-only variant has been verified. Visible overlays and embedded metadata can usually be removed without changing the underlying generation model, which is the tool's premise. -**Regulatory driver -- China GB 45438-2025 is the strongest VISIBLE-mark mandate.** The CAC / TC260 "Measures for Labeling AI-Generated Synthesized Content" (issued March 2025, **effective 2025-09-01**, technical standard **GB 45438-2025**, building on the TC260 Aug-2023 practice guide) MANDATE a **visible** label for AI images -- a visible textual mark whose height must be **>= 5% of the image's shortest side** -- plus the metadata (implicit) label. Several such CJK text marks are now registered; see [supported signals](supported-signals.md) for the current list. By contrast EU AI Act Article 50 mandates only the MACHINE-READABLE mark (enforceable 2026-08-02, grace to 2026-12-02); a visible label is proposed and modality-specific (visible for images) but is NOT a hard "fixed icon" mandate -- a claim that Art 50 requires a clearly-visible fixed icon for images was refuted in verification. Primary-source dates verified against the article/standard text, not search summaries. +**Regulatory driver -- China GB 45438-2025 is the strongest VISIBLE-mark mandate.** The CAC / TC260 "Measures for Labeling AI-Generated Synthesized Content" (issued March 2025, **effective 2025-09-01**, technical standard **GB 45438-2025**, building on the TC260 Aug-2023 practice guide) MANDATE a **visible** label for AI images -- a visible textual mark whose height must be **>= 5% of the image's shortest side** -- plus the metadata (implicit) label. Several such CJK text marks are now registered; see [supported signals](supported-signals.md) for the current list. By contrast EU AI Act Article 50(2) makes providers add a MACHINE-READABLE mark from 2026-08-02 (systems already on the market before that date have until 2026-12-02 under Regulation (EU) 2026/1744), and Article 50(4) makes deployers disclose deepfakes. The final Code of Practice (2026-06-10) defines an optional EU icon (Annex 1) for that deployer disclosure; no fixed icon is mandated by the Act. The same CAC Measures that mandate the Chinese label also prohibit, in Article 10, providing tools or services that remove it; see [legal notes](legal-and-safety.md). Primary-source dates verified against the article/standard text, not search summaries. ## Uncovered visible marks: implementation specs (deep-research 2026-07-18) @@ -229,7 +229,7 @@ not ground truth. - 字型应清晰可辨 (legible typeface -- no family named); - **文字高度不应低于画面最短边长度的 5%** -- glyph HEIGHT >= 5% of the frame's SHORTEST side, with a note defining the shortest side for non-rectangular images. -Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a compliant CN mark's glyphs are large (>= 51 px on a 1024² image), so a CN silhouette ladder can be anchored at ~5-10% of the short side instead of swept broadly, which should cut false fires; (2) every compliant string shares the tail `AI生成` / `AI合成`, so a shared suffix silhouette plus a per-vendor prefix may beat five independent templates. NOT established: whether 文字高度 means cap height, em box, or rendered bounding box (a ~1.3x spread in template scale). Sources: `https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf`, parent CAC measure `https://www.cac.gov.cn/2025-03/14/c_1743654684782215.htm` (the CAC text itself specifies no size or corner). +Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a compliant CN mark's glyphs are large (>= 51 px on a 1024² image), so a CN silhouette ladder can be anchored at ~5-10% of the short side instead of swept broadly, which should cut false fires; (2) every compliant string shares the tail `AI生成` / `AI合成`, so a shared suffix silhouette plus a per-vendor prefix may beat five independent templates. NOT established: whether 文字高度 means cap height, em box, or rendered bounding box (a ~1.3x spread in template scale). Sources: `https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf` (on 2026-09-23 this URL served the TC260 HTML home page to a scripted request, not the PDF; the official SAMR record is `https://openstd.samr.gov.cn/bzgk/std/newGbInfo?hcno=F32EA2A561F1886CD8D606513512D547`), parent CAC measure `https://www.cac.gov.cn/2025-03/14/c_1743654684782215.htm` (the CAC text itself specifies no size or corner). **Alibaba Cloud Qwen -- several surfaces differ (API tier VERIFIED (a)).** Model Studio docs state verbatim that the API adds a `Qwen-Image` watermark 在图像右下角 and 默认值为 false -- so API output is **unwatermarked by default**, and when enabled the documented mark is a LATIN wordmark, not CJK, and not GB-compliant in wording. The Chinese consumer app's `千问AI生成` (bottom-right) is (b) secondary only -- no Alibaba primary page states it. A cleared Qwen Create `Qwen-Image 2.0` output from 2026-09-03 instead carries the three-lobe Qwen symbol alone; that symbol and the CJK text are registered under one `qwen` key with separate templates. The documented API Latin wordmark remains unsupported, so absence of either registered shape is never evidence of a clean image. Source: `https://help.aliyun.com/zh/model-studio/qwen-image-api`. @@ -248,3 +248,38 @@ Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a comp **The one document that would settle ByteDance placement is BLOCKED.** Douyin's 《抖音关于人工智能生成内容标识的水印与元数据规范》 aims to give AI tools a unified watermark style and position, which would cover Doubao / Jimeng / 星绘 at once. Both mirrors return HTTP 403 to WebFetch; a secondary report (b, unconfirmed) says the watermark is `AI生成` + tool name + company name placed **top-left** -- which would explain the Jimeng pill's top-left position but contradicts the GB annex's bottom-right example. Worth one retry through Chrome MCP with a real browser session. **No vendor publishes typeface, color, opacity, plate, or margin for ANY of these marks.** The only font-adjacent requirement anywhere is GB's "legible typeface". So each synthetic silhouette's font must be calibrated against corpus positives exactly as the Jimeng pill was; candidate CJK families by platform convention (inference): HarmonyOS Sans / Source Han Sans / Noto Sans CJK SC for Android-origin apps, PingFang SC for iOS-origin. + +## Live captures (2026-09-23 and 2026-09-24) + +Outputs generated from the maintainer's own accounts and inspected locally. +Provider files stay outside the repository unless noted. Gemini API captures +read `GEMINI_API_KEY` from the gitignored `.env` (see `.env.example`); both +models used are paid-tier only. + +| Provider and tier | Visible mark | Provenance | Library result | +| --- | --- | --- | --- | +| Gemini Omni video, Media watermark on | Veo-style four-point diamond, about 48 px at 1280x720, about 96 px from the bottom-right corner | Google C2PA with separate "Added imperceptible SynthID watermark" and "Added visible watermark" actions | `veo` detected on every frame (committed fixture) | +| Grok Imagine image and video, paid tier | None | xAI C2PA and EXIF signature | Attributed to xAI Grok Imagine from metadata | +| Vidu Q3 text-to-video, free trial | `Vidu AI` logo and wordmark, white, semi-transparent, bottom-right, about 50 px tall at 1080p, every frame | MP4 `AIGC` key with a TC260 label; producer code differs from Kling's | Previously misattributed to Kling; now the `vidu` mark (193 of 193 frames, platform ShengShu Vidu); gallery provider original | +| Wan 2.7 Pro image, free credits | Three-part logo and `Wan` wordmark, white, semi-transparent, bottom-right, about 4% of the short side | XMP TC260 label; the service API reports `noWaterMark: true` for the same file | Now also the `wan` mark (confidence 0.66); the platform still reads from the TC260 label, as for Kling and Qwen; gallery provider original | +| Apple Image Playground (iOS 27.0) | None | XMP `photoshop:Credit` `Apple Image Playground` plus IPTC `trainedAlgorithmicMedia`; no C2PA | Attributed to Apple Image Playground (committed fixture) | +| Nano Banana, Nano Banana 2 and Nano Banana Pro, Gemini API (one image each; PNG for Nano Banana, 1408 x 768 JPEG for the others) | None | Same manifest as Nano Banana 2 Lite below | Google with SynthID present, correct (committed fixtures) | +| Nano Banana 2 Lite, Gemini API (3 images, 1408 x 768 JPEG) | None | Google C2PA signed `Google Media Processing Services`: `c2pa.created` plus "Applied imperceptible SynthID watermark." | Google with SynthID present, correct (one committed fixture) | +| Gemini Omni Flash, Gemini API (10 s, 1280 x 720, H.264 and AAC) | None; `veo` found on 0 of 240 frames | Google C2PA with "Added imperceptible SynthID watermark", no visible-watermark action | Google with SynthID present, correct (committed fixture) | +| YouTube, unlisted upload of the Gemini Omni Flash API video | Label "How this was made: Made with AI, Info from Google LLC" with the AI question unanswered | Public streams (H.264, AV1, AAC via yt-dlp): no C2PA. Studio download: re-signed by `YouTube Video Processing Services` (`opened`, `transcoded`), original manifest kept as ingredient with its SynthID action | Google with SynthID present, correct (committed fixture) | +| YouTube, unlisted upload of the xAI Grok video | No AI label seen | Studio download re-signed the same way; the Grok manifest is self-signed and dropped by the reader | Was Google AI with SynthID; now unknown (committed fixture) | +| Google Photos edit on iOS (object removal) | None | C2PA signed `Google Photos` (Google C2PA SDK for iOS), actions `c2pa.opened` and `c2pa.deleted` typed `compositeWithTrainedAlgorithmicMedia`, no SynthID action; XMP `photoshop:Credit` `Edited with Google AI` | Was reported as Gemini with SynthID present; now "Google Photos (AI edit)" with SynthID present. this file was not checked, but Google's checker found SynthID in four other Photos AI edits (Ask, eraser and two others, 2026-09-25) | +| Google Photos on iOS: Ask (dog replaced by a cat), eraser and two other AI edits, 2026-09-24 and 2026-09-25 (`data/captures/2026-09/google-photos/`) | None | Same manifest shape, no action description and no SynthID action; Magic Editor, and so Reimagine, is not offered on iOS | "Google Photos (AI edit)" with SynthID present; Google's checker in Gemini found SynthID in all four with the metadata stripped | +| Apple Photos Clean Up | None | XMP `photoshop:Credit` `Apple Photos Generative Edit: Clean Up` plus `compositeWithTrainedAlgorithmicMedia`; no C2PA | Attributed to Apple Photos Clean Up | +| Runway Gen-4 image and Gen-4.5 video (Standard plan, 2026-09-24) | None on either | C2PA signed `RUNWAY AI, INC.`, `c2pa.created` from `trainedAlgorithmicMedia` with software agent "Runway Image Generation" 4.0.0 or "Runway Video Generation" 4.5.0 | Was AI with an unknown signer and no platform; now Runway (committed fixtures) | +| Nano Banana 2 run inside Runway | None | The original Google manifest with its SynthID action; Runway does not re-sign third-party models | Google with SynthID present, correct | +| Other third-party models inside Runway (FLUX.2 klein and Max, GPT Image 1 mini, 1.5, 2, 2.5 Flare and Sunburst, Ideogram 4.0 and P-Image, Seedream 5.0 and 5.0 Pro, Nano Banana, Nano Banana 2 Lite and Pro, Grok Imagine image models, Meta Muse; LTX-2.5 Fast, Seedance 2.0 Mini and Kling O3 Standard video) | None | Vendor manifests passed through valid (xAI signs as organization `SpaceXAI`); Kling O3 carries a TC260 label with producer `kling`; Meta Muse has IPTC `trainedAlgorithmicMedia` only; LTX-2.5 has no provenance | Vendor attributed where provenance exists; Meta Muse AI with no platform; LTX-2.5 unknown | +| Runway video models from five more vendors (2026-09-25): FLUX 3, Grok Imagine 1.5, Gemini Omni 1.1 Flash, HappyHorse 1.0, MiniMax H3 | None | Vendor provenance passed through: C2PA from Black Forest Labs, `xAI Grok Imagine` and Google (with its SynthID action), TC260 producers `001191330106MA2CFLDG4R10001` (Alibaba) and `MiniMax` | Vendor attributed on all five. The Omni clip was first reported as OpenAI Sora from a five-frame match on steam; Google's C2PA now vetoes it | +| Higgsfield, 27 image models (Soul family, FLUX.2, GPT Image, Nano Banana, Seedream, Recraft, Grok Imagine, Kling O1, Wan 2.2, Z-Image) | None | `hf-job-id` PNG chunk on 23 of 26 PNGs, added after signing, so upstream C2PA (OpenAI, Black Forest Labs, BytePlus, Recraft, and fal.ai for Z-Image) fails `assertion.dataHash.mismatch`; Google and xAI manifests are stripped; Soul v1, Soul Cinema and Wan 2.2 carry nothing | Was reported as a Hugging Face job; now "Higgsfield (model not identified)" at medium confidence; the three bare files are unknown. Two files scored 0.53 as a Gemini sparkle on wood texture, now vetoed by their OpenAI or Kuaishou provenance (committed fixtures) | +| Higgsfield video (Veo 3.1 and 3.1 Lite, Gemini Omni Flash and 1.1, Kling 3.0, 3.0 Turbo and 2.6, Grok Video and 1.5, Seedance 2.5, 2.0 and 1.5 Pro, MiniMax H3, H3 Max and Hailuo 2.3, Wan 2.5, 2.6, 2.7, 3.0 and 3.0 Prime, HappyHorse, FLUX.3 Video, Cinema Studio v2 and 3.0) | None | Google keeps C2PA with SynthID, xAI and Black Forest Labs keep C2PA, Seedance 2.x signs BytePlus C2PA; Kling, MiniMax and Alibaba write an MP4 `AIGC` TC260 label naming the producer as `kling`, `MiniMax` or Tongyi Yunqi's code (MiniMax with a `SecurityData` field); Higgsfield's own Cinema Studio v2 is Kling and 3.0 is Seedance by their provenance; Seedance 1.5 Pro has none | All attributed except Seedance 1.5 Pro (unknown). Veo 3.1 Lite was read as Kling, Cinema Studio v2 as a visible Kling mark and Hailuo 2.3 as a visible Hailuo mark, all on wood grain; each is now rejected (committed fixtures) | +| Higgsfield own tools and video edits (Genjutsu motion control and object replacement, Marketing Studio; FLUX 3 Video Edit, Gemini Omni Flash 1.1 edit, Kling 3.0 Omni Edit, all on our own clips) | None | Genjutsu outputs carry no provenance; Marketing Studio signs BytePlus C2PA (Seedance); FLUX 3 edit records `compositeWithTrainedAlgorithmicMedia` with `opened` and `edited`; Gemini Omni edit keeps Google C2PA with SynthID; Kling Omni Edit writes a TC260 label with the bare producer `kling` and no C2PA (2026-09-26; it rejected a 360p source, and the Higgsfield connector passes the source as a reference image) | Genjutsu unknown; the others attributed. Genjutsu motion control was a visible Kling match on wood grain, now rejected by the Kling anchor requirement (committed fixture) | +| Dreamina, all 14 image models (Seedream 3.0 to 5.0 Pro, GPT Image 2 and 2.5, Nano Banana and Pro) and 8 video models (Seedance 1.0 to 2.5, MiniMax H3), paid plan | Boxed `AI` badge, top-left; not detected | ByteDance C2PA: active claim `ByteDance Media Transcode Service`, `Dreamina/7.5.0` in the ingredient; the OpenAI and Google manifests of hosted models are dropped | All read as ByteDance Dreamina, so the hosted model is not identifiable (committed fixtures) | + +Not captured, with the reason: Doubao (region block outside +China), Jimeng (requires a mainland account), Samsung (device-only), Grok free +tier (the test account is paid). diff --git a/pyproject.toml b/pyproject.toml index b0b92c3..e71c5f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,9 +141,9 @@ text-draft = [ # because it pulls torch and downloads model weights on first use. identify() # guards the import and skips the TrustMark signal when absent. trustmark = [ - # The supported TrustMark floor includes releases requiring NumPy <2, - # without CPython 3.13+ wheels. Keep the established Python support range. - "trustmark>=0.8.0; python_version < '3.13'", + # 0.9.2 (2026-09-08) dropped the NumPy <2 pin that kept earlier releases off + # CPython 3.13+; its 13 detector tests pass on 3.13 with NumPy 2.5.3. + "trustmark>=0.9.2", ] # Metadata-free photo classifier (Model 1 then gated Model 2). CPU torch is # enough. identify never imports this extra. Weights download on first use. diff --git a/scripts/provider_oracles.py b/scripts/provider_oracles.py index 4c998c1..e52e978 100644 --- a/scripts/provider_oracles.py +++ b/scripts/provider_oracles.py @@ -297,7 +297,9 @@ def _pixel_fingerprint(path: Path) -> tuple[str, int, int, str]: """Return a bounded-memory RGBA pixel hash, width, height, and format.""" with Image.open(path) as image: image.load() - image_format = image.format + # iPhone JPEGs are MPO: a JPEG primary image followed by an HDR gain map. + # PIL decodes the primary frame, which is what a provider checks. + image_format = "JPEG" if image.format == "MPO" else image.format if image_format not in {"JPEG", "PNG", "WEBP"}: raise ValueError(f"unsupported image format: {image_format or 'unknown'}") digest = hashlib.sha256() diff --git a/scripts/render_vendor_silhouettes.py b/scripts/render_vendor_silhouettes.py index 98394b7..93c7814 100644 --- a/scripts/render_vendor_silhouettes.py +++ b/scripts/render_vendor_silhouettes.py @@ -81,6 +81,9 @@ MARKS = { "chromastudio_alpha.png": "ChromaStudio.ai", "digenai_alpha.png": "DIGENAI", "gendo_alpha.png": "GendoAI", + # Alibaba Wan (通义万相) bottom-right logo plus "Wan" wordmark. Sentinel: + # drawn by draw_wan(), not font-rendered alone. + "wan_alpha.png": "Wan logo + Wan", # Jianying / 剪映, the China product in the CapCut family, stamps 剪映AI # bottom-right (the international CapCut pill sits top-left). "jianying_alpha.png": "剪映AI", @@ -300,10 +303,65 @@ def draw_samsung_en(width: int = 335) -> np.ndarray: return arr[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1] +def draw_wan(width: int = 335) -> np.ndarray: + """Alibaba Wan logo plus "Wan" wordmark, bottom-right, light-glyph class. + + Geometry measured on the one real Wan 2.7 Pro export (2048 px square, + 2026-09-23): logo 102 x 103 px, a 19 px gap, then "Wan" 147 px wide with a + 51 px cap height centered on the logo; 268 x 103 overall. The logo is + approximated as a hexagon with a central triangular hole and three pinwheel + cuts; the wordmark is Avenir Next Demi Bold, the nearest installed geometric sans. + Hole size, cut width and weight were chosen by the best match to the real mark + over a small grid (0.66 vs 0.60-0.65 for the neighbors). + """ + import math + + k = width / 268 + h = round(103 * k) + im = Image.new("L", (width, h), 0) + d = ImageDraw.Draw(im) + logo = round(102 * k) + cx, cy, radius = logo / 2, h / 2, logo / 2 + d.polygon( + [ + (cx + radius * math.cos(math.radians(60 * i)), cy + radius * 0.98 * math.sin(math.radians(60 * i))) + for i in range(6) + ], + fill=255, + ) + inner = [ + ( + cx + radius * 0.42 * math.cos(math.radians(90 + 120 * i)), + cy + radius * 0.42 * math.sin(math.radians(90 + 120 * i)), + ) + for i in range(3) + ] + d.polygon(inner, fill=0) + cut = max(2, round(logo * 0.05)) + for i, (px, py) in enumerate(inner): + angle = math.radians(90 + 120 * i + 90) + d.line( + [(px, py), (px + radius * 1.2 * math.cos(angle), py + radius * 1.2 * math.sin(angle))], fill=0, width=cut + ) + text = "Wan" + probe = ImageDraw.Draw(Image.new("L", (10, 10))) + font = _fit_font( + "/System/Library/Fonts/Avenir Next.ttc", + lambda f: probe.textbbox((0, 0), text, font=f)[3] - probe.textbbox((0, 0), text, font=f)[1] >= round(51 * k), + index=2, + ) + bb = probe.textbbox((0, 0), text, font=font) + d.text((round((102 + 19) * k) - bb[0], round(h / 2 - (bb[3] - bb[1]) / 2) - bb[1]), text, font=font, fill=255) + arr = np.array(im) + ys, xs = np.where(arr > 0) + return arr[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1] + + _CUSTOM_RENDERERS = { "catlogo_alpha.png": draw_catlogo, "microsoft_alpha.png": draw_msbadge, "samsung_en_alpha.png": draw_samsung_en, + "wan_alpha.png": draw_wan, } diff --git a/scripts/render_visible_examples.py b/scripts/render_visible_examples.py index 60c3ff5..05e7fc4 100644 --- a/scripts/render_visible_examples.py +++ b/scripts/render_visible_examples.py @@ -283,6 +283,14 @@ def _video_mark_frame(key: str, w: int, h: int) -> np.ndarray: tmpl, scale, x, y = templates["dola"], 0.036, int(w * 0.70), int(h * 0.88) elif key == "hailuo": tmpl, scale, x, y = templates["hailuo"], 0.052, int(w * 0.34), int(h * 0.82) + elif key == "vidu": + # Logo plus wordmark bottom-right, at the measured 1080p geometry: 4.6% of + # the short side tall, ~3% off the right and bottom edges. + tmpl = templates["vidu"] + th = max(8, round(short * 0.046)) + tw = max(1, round(tmpl.shape[1] * th / tmpl.shape[0])) + x, y = w - tw - round(short * 0.029), h - th - round(short * 0.033) + return _composite_light(base, cv2.resize(tmpl, (tw, th)).astype(np.float32) / 255.0, x, y, 250) elif key == "kling": # The FULL mark: swirl logo left of the text run, flush against the # bottom-right EDGE. The font arm is edge-gated (region must reach diff --git a/scripts/sidecar_regression.py b/scripts/sidecar_regression.py index 2fc7984..a64b456 100644 --- a/scripts/sidecar_regression.py +++ b/scripts/sidecar_regression.py @@ -74,7 +74,7 @@ _LEGACY_FAMILIES: tuple[tuple[str, tuple[str, ...]], ...] = ( ("samsung_genai", ("genaitype", "photoeditor_re_edit")), ("soft_binding", ("soft binding", "soft-binding", "invismark")), ("c2pa_cloud", ("cloud manifest",)), - ("hf_job", ("hf-job", "hugging face job")), + ("hf_job", ("hf-job", "higgsfield job", "hugging face job")), ("exif_generator", ("exif", "software tag", "png text")), ) diff --git a/skills/remove-ai-watermarks/SKILL.md b/skills/remove-ai-watermarks/SKILL.md index b2429b1..659c8ed 100644 --- a/skills/remove-ai-watermarks/SKILL.md +++ b/skills/remove-ai-watermarks/SKILL.md @@ -1,6 +1,6 @@ --- name: remove-ai-watermarks -description: "Removes and identifies AI watermarks and provenance marks in images and video the user generated or edited. Covers Gemini sparkles, SynthID, Microsoft InvisMark, Meta Content Seal, C2PA, EXIF/XMP/IPTC metadata, and Sora/Veo/Seedance/Doubao/Dola/Hailuo/Kling labels. Use when the user wants to strip or clean AI watermarks and generation labels, or asks whether a file was AI-generated or carries AI provenance, even if they do not name remove-ai-watermarks. Do not use for stock-agency, marketplace, classifieds, or other third-party paid-asset watermarks." +description: "Removes and identifies AI watermarks and provenance marks in images and video the user generated or edited. Covers Gemini sparkles, SynthID, Microsoft InvisMark, Meta Content Seal, C2PA, EXIF/XMP/IPTC metadata, and Sora/Veo/Seedance/Doubao/Dola/Hailuo/Vidu/Kling and Wan labels. Use when the user wants to strip or clean AI watermarks and generation labels, or asks whether a file was AI-generated or carries AI provenance, even if they do not name remove-ai-watermarks. Do not use for stock-agency, marketplace, classifieds, or other third-party paid-asset watermarks." 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: @@ -34,6 +34,12 @@ 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 lawful. If the user wants to present generated work as human-made, stop. +The project is research software provided without warranty; the user is +responsible for lawful use. Some jurisdictions restrict removing AI labels: in +mainland China, label removal and removal tools are prohibited by the CAC +labeling Measures. Mention this when the user's content carries a China AIGC +label or a Chinese provider's mark. + ## Workflow Copy this checklist and check items off: diff --git a/skills/remove-ai-watermarks/references/commands.md b/skills/remove-ai-watermarks/references/commands.md index b1f64f4..28849c4 100644 --- a/skills/remove-ai-watermarks/references/commands.md +++ b/skills/remove-ai-watermarks/references/commands.md @@ -20,7 +20,7 @@ remove-ai-watermarks visible image.png --mark gemini -o clean.png remove-ai-watermarks visible image.png --keep-metadata -o clean.png ``` -Image mark keys: `gemini`, `doubao`, `jimeng`, `qwen`, `kling`, `yuanbao`, +Image mark keys: `gemini`, `doubao`, `jimeng`, `qwen`, `wan`, `kling`, `yuanbao`, `samsung`, `runninghub`, `baidu`, `liblib`, `liblib_pill`, `microsoft`, `generic_ai_label`, `openart`, `jimeng_pill`. Default `--mark auto` removes every selected match. `microsoft` is the top-right AI @@ -125,7 +125,7 @@ remove-ai-watermarks video batch ./videos --mode all ``` Video mark keys: `sora`, `veo`, `seedance`, `doubao`, `dola`, `hailuo`, -`kling`. Default `--mark auto` picks the first temporally stable match in that +`vidu`, `kling`. Default `--mark auto` picks the first temporally stable match in that order. `video invisible` and `video all --invisible` accept MP4, MOV, and M4V only. diff --git a/skills/remove-ai-watermarks/references/install.md b/skills/remove-ai-watermarks/references/install.md index 83cc731..33f2863 100644 --- a/skills/remove-ai-watermarks/references/install.md +++ b/skills/remove-ai-watermarks/references/install.md @@ -67,7 +67,7 @@ ffmpeg is missing, or the user asked about Homebrew, pipx, or pip. | Open DWT-DCT detect | add `detect` | | Pixel classifier (`classify`) | add `classify` | | CPU ONNX photo classifier, library `backend="onnx"` only (since CLI 0.40.0) | add `classify-onnx` | -| Adobe TrustMark on Python 3.11-3.12 | add `trustmark` | +| Adobe TrustMark | add `trustmark` | | Every production extra on this Python | `all` | `all` the extra and `all` the command are different. The extra installs diff --git a/src/remove_ai_watermarks/_internal/c2pa.py b/src/remove_ai_watermarks/_internal/c2pa.py index 681c83b..81d8a8d 100644 --- a/src/remove_ai_watermarks/_internal/c2pa.py +++ b/src/remove_ai_watermarks/_internal/c2pa.py @@ -24,6 +24,7 @@ from remove_ai_watermarks._internal.constants import ( C2PA_SOFT_BINDING_REGISTRY, C2PA_SOFT_BINDINGS, PNG_SIGNATURE, + SYNTHID_EDIT_SIGNERS, C2paSoftBindingAlgorithm, ) @@ -426,6 +427,39 @@ def _claim_generator_from_store(store: dict[str, Any]) -> str | None: return None +# c2pa.ai-disclosure (C2PA 2.4, section 18.28) states that a trained model produced +# the asset. Its humanOversightLevel maps onto the existing source kinds per the +# spec's own table in 18.28.3: human_validated pairs with +# compositeWithTrainedAlgorithmicMedia, fully_autonomous and prompt_guided with +# trainedAlgorithmicMedia. It only decides the kind when no digitalSourceType did. +_AI_DISCLOSURE_ENHANCED_LEVELS = frozenset({"human_validated"}) + + +def _apply_ai_disclosures(disclosures: list[dict[object, object]], info: dict[str, Any]) -> str | None: + """Record ``c2pa.ai-disclosure`` fields and return the source kind they imply.""" + if not disclosures: + return None + models: list[str] = [] + oversight: list[str] = [] + for disclosure in disclosures: + for key in ("modelName", "modelIdentifier", "modelType"): + value = disclosure.get(key) + if isinstance(value, str) and value.isprintable() and len(value) <= 256: + models.append(value) + break + profile = disclosure.get("contentProfile") + if isinstance(profile, dict): + level = cast("dict[object, object]", profile).get("humanOversightLevel") + if isinstance(level, str): + oversight.append(level) + info["ai_disclosure"] = ", ".join(dict.fromkeys(models)) or "present" + if oversight: + info["human_oversight"] = ", ".join(dict.fromkeys(oversight)) + if oversight and all(level in _AI_DISCLOSURE_ENHANCED_LEVELS for level in oversight): + return "enhanced" + return "generated" + + def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: """Extract provenance only from the active manifest and its ingredient graph.""" chain = _manifest_chain(store) @@ -436,6 +470,7 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: issuers: list[str] = [] tools: list[str] = [] actions: list[str] = [] + ai_disclosures: list[dict[object, object]] = [] source_types: list[str] = [] soft_binding_algorithms: list[str] = [] soft_binding_values: list[str] = [] @@ -445,6 +480,7 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: # ImageGen" softwareAgent) is a service name, not that vendor's provenance. identity_strings: list[str] = [] claim_generator_asserts_ai = False + records_synthid_action = False def add_tool_matches(value: str, *, asserts_ai: bool = False) -> None: nonlocal claim_generator_asserts_ai @@ -463,6 +499,14 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: issuers.extend(_ordered_matches(value.encode(), C2PA_ISSUERS)) identity_strings.append(value) + ingredient_values = manifest.get("ingredients") + if isinstance(ingredient_values, list): + for ingredient_value in cast("list[object]", ingredient_values): + if isinstance(ingredient_value, dict): + ingredient_source = cast("dict[object, object]", ingredient_value).get("digitalSourceType") + if isinstance(ingredient_source, str): + source_types.append(ingredient_source) + direct_generator = manifest.get("claim_generator") if isinstance(direct_generator, str): identity_strings.append(direct_generator) @@ -500,6 +544,16 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: value = cast("dict[object, object]", block_value).get("value") if isinstance(value, str) and value.isprintable() and len(value) <= 256: soft_binding_values.append(value) + if isinstance(label, str) and label.startswith("c2pa.ingredient") and isinstance(data, dict): + # C2PA 2.4 lets an ingredient added without its own manifest carry + # digitalSourceType directly (spec 2.4, change log section 5.3.1). + ingredient_source = cast("dict[object, object]", data).get("digitalSourceType") + if isinstance(ingredient_source, str): + source_types.append(ingredient_source) + continue + if isinstance(label, str) and label.startswith("c2pa.ai-disclosure") and isinstance(data, dict): + ai_disclosures.append(cast("dict[object, object]", data)) + continue if not (isinstance(label, str) and label.startswith("c2pa.actions") and isinstance(data, dict)): continue action_values = cast("dict[object, object]", data).get("actions") @@ -512,6 +566,9 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: action_name = action.get("action") if isinstance(action_name, str): actions.append(C2PA_ACTIONS.get(action_name.encode(), action_name.removeprefix("c2pa."))) + description = action.get("description") + if isinstance(description, str) and "SynthID" in description: + records_synthid_action = True source_type = action.get("digitalSourceType") if isinstance(source_type, str): source_types.append(source_type) @@ -540,10 +597,13 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: ) enhanced = any("compositeWithTrainedAlgorithmicMedia" in value for value in source_types) procedural = any("algorithmicMedia" in value for value in source_types) + disclosure_kind = _apply_ai_disclosures(ai_disclosures, info) if generated: info.update(source_type="trainedAlgorithmicMedia (AI-generated)", ai_source_kind="generated") elif enhanced: info.update(source_type="compositeWithTrainedAlgorithmicMedia (AI-enhanced)", ai_source_kind="enhanced") + elif disclosure_kind is not None: + info.update(source_type=f"c2pa.ai-disclosure ({disclosure_kind})", ai_source_kind=disclosure_kind) elif procedural: info["source_type"] = "algorithmicMedia" @@ -563,7 +623,11 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: # registered fingerprint does not suppress an independently established # SynthID watermark; a watermark or unknown algorithm does. identity_bytes = json.dumps(identity_strings, ensure_ascii=False).encode() - synthid = synthid_evidence_vendors_in(identity_bytes, has_watermark_action=has_watermark_action) + synthid = synthid_evidence_vendors_in( + identity_bytes, + has_watermark_action=has_watermark_action, + records_synthid_action=records_synthid_action, + ) if synthid: info["synthid_vendors"] = synthid info["synthid_watermark"] = synthid_verdict(", ".join(synthid)) @@ -584,24 +648,39 @@ def synthid_verdict(vendors: str) -> str: return f"present according to {vendors} provenance" -def synthid_evidence_vendors_in(buffer: bytes, *, has_watermark_action: bool | None = None) -> list[str]: +def synthid_evidence_vendors_in( + buffer: bytes, + *, + has_watermark_action: bool | None = None, + records_synthid_action: bool | None = None, +) -> list[str]: """List issuers whose provenance establishes SynthID for this asset. Google applies SynthID to all media generated by its tools, so its AI C2PA - provenance is sufficient. OpenAI C2PA predates OpenAI's SynthID rollout; + provenance is sufficient, Google Photos AI edits included. YouTube, which + re-signs every upload, is the exception: a ``SYNTHID_EDIT_SIGNERS`` manifest + needs a recorded SynthID action. OpenAI C2PA predates OpenAI's SynthID rollout; current manifests distinguish the watermarked generation with the explicit ``c2pa.watermarked.*`` action. A legacy OpenAI issuer token alone therefore remains provenance evidence, but not SynthID evidence. """ if has_watermark_action is None: has_watermark_action = b"c2pa.watermarked" in buffer + if records_synthid_action is None: + records_synthid_action = b"SynthID" in buffer + # An edit signer without a recorded SynthID action leaves only the vendors whose + # SynthID rests on an explicit watermark action, never on identity alone. + identity_suffices = records_synthid_action or not any(signer in buffer for signer in SYNTHID_EDIT_SIGNERS) return sorted( { vendor.org for vendor in C2PA_AI_VENDORS if vendor.synthid and vendor.issuer in buffer - and (has_watermark_action or not vendor.synthid_requires_watermark_action) + and ( + (has_watermark_action and vendor.synthid_requires_watermark_action) + or (identity_suffices and not vendor.synthid_requires_watermark_action) + ) } ) @@ -652,6 +731,10 @@ def _populate_registry_fields(buffer: bytes, info: dict[str, Any]) -> bool: elif b"compositeWithTrainedAlgorithmicMedia" in buffer: info.update(source_type="compositeWithTrainedAlgorithmicMedia (AI-enhanced)", ai_source_kind="enhanced") ai_source = True + elif b"c2pa.ai-disclosure" in buffer: + kind = "enhanced" if b"human_validated" in buffer and b"fully_autonomous" not in buffer else "generated" + info.update(source_type=f"c2pa.ai-disclosure ({kind})", ai_source_kind=kind, ai_disclosure="present") + ai_source = True elif b"algorithmicMedia" in buffer: info["source_type"] = "algorithmicMedia" diff --git a/src/remove_ai_watermarks/_internal/constants.py b/src/remove_ai_watermarks/_internal/constants.py index 3d53914..b4cb35d 100644 --- a/src/remove_ai_watermarks/_internal/constants.py +++ b/src/remove_ai_watermarks/_internal/constants.py @@ -163,10 +163,32 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = ( None, signer_platform="ASUS Gallery (C2PA signer)", ), + # Certificate common name on Google Photos edits (Android and iOS). The + # manifest also names Google LLC, so an AI edit establishes Google SynthID + # (see ``SYNTHID_EDIT_SIGNERS``) and its platform label lives in ``identify``; an edited + # Gemini or OpenAI generation keeps its generator. + _vendor(b"Google Photos", "Google Photos", None, None), + # Runway signs only its own models (Gen-4 image, Gen-4.5 video, measured + # 2026-09-24: issuer "RUNWAY AI, INC.", software agent "Runway Image/Video + # Generation"); a third-party model run inside Runway keeps its vendor's own + # manifest (a Nano Banana 2 image came back with Google's). The C2PA + # conformance list spells the organization "Runway AI, Inc". + _vendor(b"RUNWAY AI, INC.", "Runway", "Runway", "Runway"), + _vendor(b"Runway AI, Inc", "Runway", "Runway", "Runway"), _vendor(b"Truepic", "Truepic", None, None), ) C2PA_ISSUERS = {vendor.issuer: vendor.org for vendor in C2PA_AI_VENDORS} +# Google signers that re-encode media rather than generate it, so their Google +# LLC identity alone says nothing about SynthID. YouTube re-signs every upload +# with "YouTube Video Processing Services" (opened and transcoded, measured +# 2026-09-24): an xAI Grok video came back reading as Google SynthID. A manifest +# from one of these signers establishes SynthID only when the chain records a +# SynthID action, as a Gemini ingredient does. Google Photos is deliberately +# absent: its manifests record no SynthID action, yet Google's checker found +# SynthID on all four Photos AI edits tested (Ask, eraser and two other edits, +# 2026-09-25), although Google documents the mark only for Reimagine. +SYNTHID_EDIT_SIGNERS: tuple[bytes, ...] = (b"YouTube",) C2PA_IDENTITY_AI_ORGS = frozenset(vendor.org for vendor in C2PA_AI_VENDORS if vendor.asserts_ai) C2PA_SIGNER_PLATFORM_BY_ORG = { vendor.org: vendor.signer_platform for vendor in C2PA_AI_VENDORS if vendor.signer_platform is not None @@ -293,6 +315,8 @@ AI_GENERATOR_TOKENS = frozenset( "lumalabs", "aphrodite ai", "apple photos clean up", + "apple photos generative edit", + "apple image playground", "fal-ai", } ) diff --git a/src/remove_ai_watermarks/assets/wan_alpha.png b/src/remove_ai_watermarks/assets/wan_alpha.png new file mode 100644 index 0000000..ae328a0 Binary files /dev/null and b/src/remove_ai_watermarks/assets/wan_alpha.png differ diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 89316e8..96cd343 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -98,6 +98,10 @@ _SCAN_BYTES = 1024 * 1024 # false positives when the sparkle is the only signal (e.g. an OpenAI image scored # 0.37 -- below threshold, correctly dropped). _SPARKLE_THRESHOLD = GEMINI_SPARKLE_TRUST_CONF +_SPARKLE_VETOED_CAVEAT = ( + "A Gemini-sparkle-like pattern scored {conf:.2f}, but provenance names {vendor} as the " + "generator, so it is not reported as a Gemini mark." +) # Issuer (C2PA signer) -> human-readable generating platform, derived from the # single C2PA_AI_VENDORS registry. Ordered: when a manifest names several issuers @@ -120,6 +124,12 @@ _SYNTHID_CAVEAT = ( "SynthID presence comes from supported provenance here; the pixel watermark is not locally " "decoded (proprietary decoder). Confirm via the Gemini app or openai.com/verify." ) +_GOOGLE_PHOTOS_EDIT = "Google Photos (AI edit)" +_GOOGLE_PHOTOS_SYNTHID_CAVEAT = ( + "SynthID on a Google Photos AI edit rests on measurement, not on the manifest: Google's checker " + "found it on every Photos AI edit tested (Ask, eraser and two other edits, 2026-09-25), but Google " + "notes a very small edit may not carry it. Confirm via the Gemini app." +) _C2PA_UNTRUSTED_CAVEAT = ( "The C2PA claim signature and asset binding validate, but no trust anchor list is configured here, " "so the signer identity was never checked against one; treat the named platform as a signed claim, " @@ -143,9 +153,10 @@ _INVISIBLE_WM_CAVEAT = ( "or resizing, so it confirms origin only on a pristine (un-re-encoded) file." ) _HF_JOB_CAVEAT = ( - "The hf-job-id tag marks a Hugging Face-hosted job (commonly diffusion " - "generation) but names neither the model nor the content type, so it is a " - "medium-confidence signal, not proof the pixels are AI-generated." + "The hf-job-id tag marks a Higgsfield generation job (Higgsfield writes it into most PNGs " + "it serves, for its own and hosted third-party models) but names no model, so it is a " + "medium-confidence signal. Higgsfield adds the tag after signing, which invalidates any " + "C2PA manifest the upstream model embedded." ) _C2PA_CLOUD_CAVEAT = ( "The embedded C2PA manifest is absent but an XMP provenance pointer to the " @@ -459,7 +470,7 @@ def evidence_from_metadata_record( if iptc_system: ai_metadata.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({iptc_system})") if hf_job: - ai_metadata.setdefault("huggingface_job", f"Hugging Face-hosted job ({hf_job})") + ai_metadata.setdefault("huggingface_job", f"Higgsfield job ({hf_job})") if samsung is not None: ai_metadata.setdefault("samsung_genai", f"Samsung Galaxy AI editing marker (genAIType={samsung})") @@ -844,11 +855,27 @@ _AI_VENDOR_TOKENS: tuple[tuple[str, str], ...] = ( ("fal-ai", "fal.ai"), ("bria", "Bria"), ("apple photos clean up", "Apple"), + ("apple photos generative edit", "Apple"), + ("apple image playground", "Apple"), ("luma ai", "Luma AI"), ("lumalabs", "Luma AI"), ) +# photoshop:Credit values Apple writes next to the IPTC digitalSourceType; first +# match wins, so specific credits precede the generic prefix. Measured on real +# output 2026-09-23: Image Playground writes "Apple Image Playground" with +# trainedAlgorithmicMedia; Photos Clean Up (device on iOS 27.0) writes +# "Apple Photos Generative Edit: Clean Up" with compositeWithTrainedAlgorithmicMedia. +# Neither carried C2PA. The Playground PNG records no OS version. +_APPLE_CREDIT_PLATFORMS = ( + (b"Apple Photos Generative Edit: Clean Up", "Apple Photos (Clean Up AI edit)"), + (b"Apple Photos Clean Up", "Apple Photos (Clean Up AI edit)"), + (b"Apple Photos Generative Edit", "Apple Photos (Generative Edit)"), + (b"Apple Image Playground", "Apple Image Playground"), +) + + def _vendor_of(text: str | None) -> str | None: """Normalize a platform/generator string to a coarse origin-vendor key, or None.""" if not text: @@ -1109,12 +1136,17 @@ def _collect_visible_signals( platform: str | None, decode: _SharedDecode, caveats: list[str], + *, + non_google_vendor: str | None = None, ) -> str | None: """Append every trusted visible-mark signal and return platform. All visible detectors share the one decoded BGR array held by ``decode`` (which the invisible detectors have usually already paid for). A decode failure preserves the detectors' historical fallback/no-op behavior. + + ``non_google_vendor`` is an AI origin that stamped provenance names; a sparkle + score on such an image is read as texture, not as a Gemini mark. """ image = decode.get_or_none() if image is None: @@ -1130,7 +1162,9 @@ def _collect_visible_signals( return platform sparkle_conf = _visible_sparkle(image_path, image=image) - if sparkle_conf is not None and sparkle_conf >= _SPARKLE_THRESHOLD: + if sparkle_conf is not None and sparkle_conf >= _SPARKLE_THRESHOLD and non_google_vendor: + caveats.append(_SPARKLE_VETOED_CAVEAT.format(conf=sparkle_conf, vendor=non_google_vendor)) + elif sparkle_conf is not None and sparkle_conf >= _SPARKLE_THRESHOLD: signals.append(Signal("visible_sparkle", f"NCC confidence {sparkle_conf:.2f}", "medium")) watermarks.append(f"Google Gemini visible watermark (sparkle; confidence {sparkle_conf:.2f})") if platform is None: @@ -1231,8 +1265,17 @@ def _identify_from_evidence( # Exact product generators are useful provenance even when the # signed operation is a non-AI edit (for example, CapCut). or _claim_generator_platform(generator) - or signer_label + # An AI product generator recorded under a generic re-signer beats the + # signer's own label: Dreamina exports carry a "Dreamina/7.5.0" + # ingredient under an active "ByteDance Media Transcode Service" + # claim (measured 2026-09-24). or (_claim_generator_platform(str(info.get("ai_tool"))) if c2pa_is_ai and info.get("ai_tool") else None) + or signer_label + or ( + _GOOGLE_PHOTOS_EDIT + if c2pa_is_ai and source_kind == "enhanced" and "Google Photos" in issuer_blob + else None + ) or _attribute_platform(issuers, is_ai=c2pa_is_ai) ) if has_c2pa and c2pa_usable @@ -1335,6 +1378,8 @@ def _identify_from_evidence( else f"SynthID watermark claimed by invalid C2PA credentials ({synthid})" ) caveats.append(_SYNTHID_CAVEAT) + if any("Google Photos" in issuer for issuer in issuers): + caveats.append(_GOOGLE_PHOTOS_SYNTHID_CAVEAT) if c2pa_usable and (v := _vendor_of(synthid)): ai_vendor_claims["synthid"] = v @@ -1383,11 +1428,11 @@ def _identify_from_evidence( signals.append(Signal("iptc", "digitalSourceType (Made with AI)", "high")) watermarks.append("IPTC digitalSourceType (Made with AI)") caveats.append(_IPTC_ONLY_CAVEAT) - # Apple Photos Clean Up carries an explicit product credit beside the shared - # IPTC value. Without such product-specific evidence the standard names no - # platform and must not imply a vendor watermark. - if platform is None and b"Apple Photos Clean Up" in head: - platform = "Apple Photos (Clean Up AI edit)" + # Apple Intelligence surfaces carry an explicit photoshop:Credit beside the + # shared IPTC value. Without such product-specific evidence the standard + # names no platform and must not imply a vendor watermark. + if platform is None: + platform = next((name for credit, name in _APPLE_CREDIT_PLATFORMS if credit in head), None) # ── IPTC 2025.1 AI-disclosure fields (Iptc4xmpExt:AISystemUsed etc.) ─ iptc_ai = any(m in head for m in IPTC_AI_FIELD_MARKERS) @@ -1448,17 +1493,19 @@ def _identify_from_evidence( platform = "xAI (Grok / Aurora)" ai_vendor_claims["xai"] = "xAI" - # ── Hugging Face-hosted job marker (hf-job-id PNG text chunk) ───── - # Marks the hosting job, not a model -- medium confidence (commonly diffusion - # output). Like the visible sparkle, it lifts an otherwise-Unknown verdict to - # a tentative AI, but never overrides a high-confidence metadata signal. + # ── Higgsfield job marker (hf-job-id PNG text chunk) ───────────── + # Marks the hosting job, not a model -- medium confidence. Earlier releases read + # it as Hugging Face; Higgsfield output measured on 2026-09-24 carries it on every + # PNG, and no Hugging Face source documents it (the public API keeps the old + # names). Like the visible sparkle, it lifts an otherwise-Unknown verdict to a + # tentative AI, but never overrides a high-confidence metadata signal. hf_job = evidence.huggingface_job if hf_job: - signals.append(Signal("hf_job", f"Hugging Face job {hf_job}", "medium")) - watermarks.append("Hugging Face-hosted job (hf-job-id)") + signals.append(Signal("hf_job", f"Higgsfield job {hf_job}", "medium")) + watermarks.append("Higgsfield job (hf-job-id)") caveats.append(_HF_JOB_CAVEAT) if platform is None: - platform = "Hugging Face-hosted job (model not identified)" + platform = "Higgsfield (model not identified)" # ── Samsung Galaxy AI editing marker (genAIType) ───────────────── # Galaxy AI tools stamp a proprietary genAIType in PhotoEditor_Re_Edit_Data. @@ -1521,7 +1568,16 @@ def _identify_from_evidence( ) if check_visible and pixel_path is not None: - platform = _collect_visible_signals(pixel_path, signals, watermarks, platform, decode, caveats) + # A trusted provenance claim by another vendor vetoes the sparkle: sparkle + # scores on real Gemini images reach down to 0.55 while wood grain, UI and + # whiteboard photos reach 0.67, so no threshold separates them. Two + # Higgsfield downloads (a valid OpenAI C2PA, a Kuaishou TC260 label) scored + # 0.53 on wood texture (2026-09-24). + trusted_claims = {k: v for k, v in ai_vendor_claims.items() if k != "c2pa" or c2pa_level == "verified"} + non_google = next((v for v in trusted_claims.values() if v != "Google"), None) + platform = _collect_visible_signals( + pixel_path, signals, watermarks, platform, decode, caveats, non_google_vendor=non_google + ) visible_only = any(s.name.startswith("visible_") for s in signals) and not ai_from_metadata hf_only = bool(hf_job) and not ai_from_metadata diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index 77ee7e6..e496b63 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -208,9 +208,9 @@ def parse_tc260_aigc_json(value: bytes) -> dict[str, str] | None: return fields if TC260_AIGC_FIELDS & fields.keys() else None -# Hugging Face-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with this -# ``tEXt`` chunk key holding the job UUID. It marks the hosting job, not a -# specific model -- a medium-confidence AI signal (commonly diffusion output). +# Higgsfield stamps every PNG it serves with this ``tEXt`` chunk key holding the +# job UUID (measured 2026-09-24; see ``_huggingface_job_impl``). It marks the +# hosting job, not a specific model -- a medium-confidence AI signal. _HF_JOB_KEY: str = "hf-job-id" STANDARD_METADATA_KEYS: frozenset[str] = frozenset( @@ -298,6 +298,12 @@ def _png_late_metadata(image_path: Path, window: int, *, strict: bool = False) - return bytes(out) +# RIFF forms whose metadata chunks may follow the media data. C2PA puts its manifest +# in the LAST top-level chunk of every RIFF file (spec 2.4 section A.3.7), so a WAV +# or AVI longer than the head window hides it without this walk. +_RIFF_LATE_SCAN_FORMS: frozenset[bytes] = frozenset({b"WEBP", b"WAVE", b"AVI "}) + + def _riff_late_metadata( image_path: Path, window: int, *, max_total: int = 4 * 1024 * 1024, strict: bool = False ) -> bytes: @@ -417,7 +423,7 @@ def _scan_head_impl(image_path: Path, size: int) -> bytes: # len(head) == size means the file is at least `size` bytes, so metadata # chunks may lie beyond the window; otherwise the whole PNG is in `head`. head += _png_late_metadata(image_path, size) - elif head[:4] == b"RIFF" and head[8:12] == b"WEBP" and len(head) == size: + elif head[:4] == b"RIFF" and head[8:12] in _RIFF_LATE_SCAN_FORMS and len(head) == size: head += _riff_late_metadata(image_path, size) head += _decoder_visible_text(image_path, head) return head @@ -524,7 +530,7 @@ def has_ai_metadata(image_path: Path) -> bool: # only the XMP form; the raw-JSON tEXt chunk needs the PIL-based parse). if aigc_label(image_path) is not None: return True - # Hugging Face-hosted job marker (hf-job-id PNG text chunk). + # Higgsfield job marker (hf-job-id PNG text chunk). if huggingface_job(image_path): return True # xAI / Grok signature + UUID pair, including container-native transcodes. @@ -575,9 +581,24 @@ def aigc_label_from_metadata(data: bytes, candidates: tuple[str, ...] = ()) -> d continue if result := _parse(text[brace:end], require_tc260_field=True): return result + # Audio placements from TC260-PG-202510A (2025-08): a RIFF chunk with id ``AIGC`` + # (WAV; 4-byte length before the JSON), an ID3v2 ``TXXX`` frame whose + # description is ``AIGC`` (MP3; NUL before the UTF-8 value), and a Vorbis + # comment ``AIGC=`` (OGG, Opus, FLAC). The JSON must still carry a TC260 field. + for match in _AUDIO_AIGC_PREFIX.finditer(text): + brace = match.end() + try: + _, end = json.JSONDecoder().raw_decode(text, brace) + except ValueError: + continue + if result := _parse(text[brace:end].encode("latin-1").decode("utf-8", "replace"), require_tc260_field=True): + return result return None +_AUDIO_AIGC_PREFIX = re.compile(r"AIGC(?:=|\x00|[\x00-\xff]{4})(?=\{)") + + def _aigc_label_impl(image_path: Path) -> dict[str, str] | None: """Parse a China TC260 AI-labeling block, if present. @@ -696,14 +717,16 @@ def c2pa_cloud_manifest(image_path: Path) -> str | None: def _huggingface_job_impl(image_path: Path) -> str | None: - """Return the Hugging Face job id if the image carries an ``hf-job-id`` PNG + """Return the Higgsfield job id if the image carries an ``hf-job-id`` PNG text chunk, else None. - Hugging Face-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with an - ``hf-job-id`` ``tEXt`` chunk holding the job's UUID. It identifies the - *hosting job*, not a specific model, and is most commonly seen on diffusion- - generation output -- a medium-confidence AI signal, not proof of AI pixels - on its own. + Higgsfield (higgsfield.ai) writes an ``hf-job-id`` ``tEXt`` chunk holding the + job UUID into the PNGs it serves, for its own Soul models and for hosted + third-party models alike (23 of 26 PNG captures across 27 image models, + 2026-09-24; Soul v1, Soul Cinema and Wan 2.2 arrived without it). The name is + kept from an earlier reading as Hugging Face: no Hugging Face source documents + the chunk. It identifies the hosting job, not a model -- a medium-confidence + AI signal, not proof of AI pixels on its own. """ try: from PIL import Image @@ -813,7 +836,8 @@ def synthid_source(image_path: Path, *, c2pa_info: dict[str, Any] | None = None) This is provenance-based, not a local pixel decode. Google states that all media generated by its tools carries SynthID, so Google AI C2PA establishes - the mark. OpenAI C2PA existed before OpenAI adopted SynthID, so OpenAI also + the mark, except for YouTube re-encodes, which need a recorded SynthID + action. OpenAI C2PA existed before OpenAI adopted SynthID, so OpenAI also requires the explicit ``c2pa.watermarked.*`` action used by current manifests. Adobe Firefly and Microsoft sign C2PA but do not use SynthID, so they return None. @@ -1411,9 +1435,9 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]: if system := iptc_ai_system(image_path): result.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({system})") - # Hugging Face-hosted job marker (hf-job-id PNG text chunk). + # Higgsfield job marker (hf-job-id PNG text chunk); key name kept for compatibility. if job := huggingface_job(image_path): - result.setdefault("huggingface_job", f"Hugging Face-hosted job ({job})") + result.setdefault("huggingface_job", f"Higgsfield job ({job})") # Samsung Galaxy AI editing marker (genAIType in PhotoEditor_Re_Edit_Data). if (genai := samsung_genai(image_path)) is not None: result.setdefault("samsung_genai", f"Samsung Galaxy AI editing marker (genAIType={genai})") @@ -1585,6 +1609,73 @@ def _png_bit_depth(data: bytes) -> int: return data[24] +# GIF application extensions that carry playback behaviour, not provenance. Every +# other application extension (XMP ``XMP DataXMP``, C2PA ``C2PA_GIF``, vendor blobs) +# and every comment extension is dropped by the GIF walker. +_GIF_PLAYBACK_APP_IDS: tuple[bytes, ...] = (b"NETSCAPE2.0", b"ANIMEXTS1.0") +_GIF_ICC_APP_ID = b"ICCRGBG1012" + + +def _strip_gif_metadata_lossless(source_path: Path, output_path: Path, keep_standard: bool) -> bool: + """Remove metadata extensions from a GIF by walking its blocks, frames untouched. + + Pillow has no GIF path that preserves every frame and palette bit for bit, and the + generic re-save below would write PNG bytes under a ``.gif`` name and keep only the + first frame (measured 2026-09-23 on a three-frame file). Image descriptors, local + colour tables and LZW data are copied verbatim; graphic-control and plain-text + extensions stay; application extensions survive only for looping and, when + ``keep_standard``, the ICC profile. Returns False when the bytes are not a walkable + GIF so the caller can fail safe. + """ + data = source_path.read_bytes() + if data[:6] not in (b"GIF87a", b"GIF89a") or len(data) < 13: + return False + out = bytearray() + try: + packed = data[10] + pos = 13 + (3 * (2 ** ((packed & 0x07) + 1)) if packed & 0x80 else 0) + out += data[:pos] + + def sub_blocks_end(start: int) -> int: + cursor = start + while True: + size = data[cursor] + cursor += 1 + size + if size == 0: + return cursor + + while pos < len(data): + introducer = data[pos] + if introducer == 0x3B: # trailer + out += data[pos : pos + 1] + break + if introducer == 0x2C: # image descriptor, optional local colour table, LZW data + local_packed = data[pos + 9] + table = 3 * (2 ** ((local_packed & 0x07) + 1)) if local_packed & 0x80 else 0 + end = sub_blocks_end(pos + 10 + table + 1) + out += data[pos:end] + pos = end + continue + if introducer != 0x21: + return False + label = data[pos + 1] + end = sub_blocks_end(pos + 2) + keep = label in (0xF9, 0x01) + if label == 0xFF and data[pos + 2] == 11: + app_id = bytes(data[pos + 3 : pos + 14]) + keep = app_id in _GIF_PLAYBACK_APP_IDS or (keep_standard and app_id == _GIF_ICC_APP_ID) + if keep: + out += data[pos:end] + pos = end + else: + return False + except IndexError: + return False + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(bytes(out)) + return True + + def _strip_png_metadata_lossless(source_path: Path, output_path: Path, keep_standard: bool) -> bool: """Remove AI metadata from a PNG by rewriting its chunk list, leaving IDAT alone. @@ -1895,6 +1986,22 @@ def remove_ai_metadata( # ends .jpg, breaking the "work with originals" invariant. true_fmt = _sniff_image_format(head) + if head[:6] in (b"GIF87a", b"GIF89a"): + if _strip_gif_metadata_lossless(source_path, output_path, keep_standard): + return output_path + logger.warning("Could not walk the blocks of GIF %s; copied through unchanged", source_path) + if output_path != source_path: + import shutil + + output_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source_path, output_path) + return output_path + + if head[:4] in (b"II*\x00", b"MM\x00*"): + # TIFF and DNG: re-saving through Pillow would write PNG bytes under the TIFF + # name and flatten pages, bit depth and raw data. Refuse rather than damage it. + raise ValueError(f"metadata removal does not support TIFF/DNG containers: {source_path.name}") + # JPEG: strip AI metadata at the byte level so the DCT scan (the pixels) is NOT # re-encoded. The PIL open+save path below is lossy for JPEG (a q95 re-encode that # would undo the quality-preserving writes of the removal pipelines); this keeps a diff --git a/src/remove_ai_watermarks/metadata_record.py b/src/remove_ai_watermarks/metadata_record.py index a0a41db..8839505 100644 --- a/src/remove_ai_watermarks/metadata_record.py +++ b/src/remove_ai_watermarks/metadata_record.py @@ -176,7 +176,10 @@ def _isobmff_regions(image_path: Path, head: bytes) -> bytes: out = bytearray(head[:HEAD_WINDOW]) out += scan_c2pa_region(image_path, strict=True) for payload in tc260_aigc_payloads(image_path, strict=True): - out += payload + # Keep the key with its value, as the RIFF regions keep the chunk id: with + # ``moov`` after ``mdat`` the header window holds no ``AIGC`` key, and the + # scan parser anchors on it (Higgsfield MP4 downloads, 2026-09-24). + out += b"AIGC" + payload return bytes(out) diff --git a/src/remove_ai_watermarks/video.py b/src/remove_ai_watermarks/video.py index 5a2ac62..b0be0ee 100644 --- a/src/remove_ai_watermarks/video.py +++ b/src/remove_ai_watermarks/video.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: from remove_ai_watermarks.video_visible import VideoScan VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".flv"}) -VIDEO_VISIBLE_MARKS = ("sora", "veo", "seedance", "doubao", "dola", "hailuo", "kling") +VIDEO_VISIBLE_MARKS = ("sora", "veo", "seedance", "doubao", "dola", "hailuo", "vidu", "kling") _ISOBMFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v"}) _EBML_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".webm", ".mkv"}) _RIFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".avi"}) @@ -221,6 +221,7 @@ _VISIBLE_PLATFORM = { "doubao": "ByteDance Doubao", "dola": "ByteDance Dola", "hailuo": "MiniMax Hailuo AI", + "vidu": "ShengShu Vidu", "kling": "Kuaishou Kling AI", } @@ -279,11 +280,13 @@ def _visible_removal_plan( """ from remove_ai_watermarks.video_visible import ( VISIBLE_MARK_POLICIES, + contradicts_video_provenance, has_bytedance_video_provenance, has_doubao_video_provenance, has_hailuo_video_provenance, has_sora_provenance, has_veo_provenance, + has_vidu_video_provenance, stabilize_localizations, ) @@ -294,8 +297,11 @@ def _visible_removal_plan( "doubao": has_doubao_video_provenance, "dola": has_bytedance_video_provenance, "hailuo": has_hailuo_video_provenance, + "vidu": has_vidu_video_provenance, }.get(selected_mark) policy = VISIBLE_MARK_POLICIES[selected_mark] + if contradicts_video_provenance(selected_mark, markers): + return [None] * len(selected_scan.detections), policy.padding_fraction, policy.mask_style regions = stabilize_localizations( selected_mark, selected_scan.detections, @@ -337,6 +343,54 @@ def _select_stable_visible_mark( return None +# C2PA fields that describe a manifest without asserting AI. A platform that +# re-signs every upload (YouTube, "opened" and "transcoded") leaves only these, +# and the signer's name alone is not an AI generator claim. +_C2PA_STRUCTURAL_MARKERS = frozenset( + { + "c2pa_manifest", + "claim_generator", + "c2pa_spec", + "issuer", + "actions", + "c2pa_validation_source", + "c2pa_validation_state", + "c2pa_integrity", + "c2pa_signature", + "c2pa_signer_trust", + "c2pa_signer_validity", + } +) + + +def _video_markers_claim_ai(markers: dict[str, str]) -> bool: + """Return whether the metadata asserts AI, mirroring the image path's C2PA rule. + + A marker outside the C2PA structure (a digital source type, SynthID, a TC260 + label, an xAI signature) asserts AI. A C2PA manifest with nothing else does + so only through an AI-generator identity: a registered AI signer or an AI + product named as the claim generator. + """ + from remove_ai_watermarks._internal.constants import ( + C2PA_AI_TOOLS, + C2PA_CLAIM_GENERATOR_PLATFORMS, + C2PA_IDENTITY_AI_ORGS, + ) + + source_type = markers.get("source_type", "") + if any(key not in _C2PA_STRUCTURAL_MARKERS and key != "source_type" for key in markers): + return True + if "trainedalgorithmicmedia" in source_type.casefold() or source_type.startswith("c2pa.ai-disclosure"): + return True + issuer = markers.get("issuer", "") + generator = markers.get("claim_generator", "") + return ( + any(org in issuer for org in C2PA_IDENTITY_AI_ORGS) + or any(token in generator.casefold() for token, _ in C2PA_CLAIM_GENERATOR_PLATFORMS) + or any(token.decode() in generator for token in C2PA_AI_TOOLS) + ) + + def _platform_from_video_metadata(markers: dict[str, str]) -> str | None: """Map supported C2PA-derived marker text to its generating platform.""" from remove_ai_watermarks._internal.constants import C2PA_AI_VENDORS @@ -347,6 +401,37 @@ def _platform_from_video_metadata(markers: dict[str, str]) -> str | None: for vendor in C2PA_AI_VENDORS: if vendor.platform is not None and vendor.needle is not None and vendor.needle.casefold() in marker_text: return vendor.platform + if "aigc_label" in markers: + return _tc260_video_platform(markers.get("aigc_producer", "")) or ( + "China AIGC-labeled content (TC260 standard)" + ) + return None + + +def _tc260_video_platform(producer: str) -> str | None: + """Name the organization a TC260 ``ContentProducer`` identifies, if registered. + + Codes come from the registry rows (plus the Vidu constant); the labels name + the producer, not one product, because one code can cover several models (the + Tongyi Yunqi code signs both Wan and HappyHorse video, measured 2026-09-24). + MiniMax writes its bare name instead of a USCC. + """ + from remove_ai_watermarks.video_visible import VIDU_TC260_PRODUCER_CODES, tc260_producer_in + from remove_ai_watermarks.watermark_registry import get_mark + + labels = { + "kling": _VISIBLE_PLATFORM["kling"], + "doubao": _VISIBLE_PLATFORM["doubao"], + "qwen": "Alibaba Cloud Qwen", + "wan": "Alibaba Tongyi (Wan, HappyHorse)", + } + for key, label in labels.items(): + if tc260_producer_in(producer, get_mark(key).tc260_producer_codes): + return label + if tc260_producer_in(producer, VIDU_TC260_PRODUCER_CODES): + return _VISIBLE_PLATFORM["vidu"] + if tc260_producer_in(producer, ("MiniMax",)): + return _VISIBLE_PLATFORM["hailuo"] return None @@ -396,11 +481,18 @@ def identify_video( selected_mark, _scan, regions, _padding, _mask_style = selected detected_frames = sum(region is not None for region in regions) - has_signal = bool(markers) or selected_mark is not None + claims_ai = _video_markers_claim_ai(markers) + has_signal = claims_ai or selected_mark is not None caveats = ["No public local decoder can verify proprietary pixel watermarks such as video SynthID."] if not check_visible: caveats.append("Visible video-mark detection was skipped.") - if not has_signal: + if not has_signal and markers: + caveats.append( + f"A C2PA manifest signed by {markers.get('issuer', 'an unregistered signer')} records " + f"{markers.get('actions', 'no actions')} and no AI claim, as a platform re-encode does " + "(YouTube re-signs every upload); that is unknown, not proof that the video is clean." + ) + elif not has_signal: caveats.append("No supported signal was found; absence is unknown, not proof that the video is clean.") return VideoProvenanceReport( source=source_path, @@ -410,6 +502,8 @@ def identify_video( _VISIBLE_PLATFORM.get(selected_mark) if selected_mark is not None else _platform_from_video_metadata(markers) + if claims_ai + else None ), visible_mark=selected_mark, visible_detected_frames=detected_frames, diff --git a/src/remove_ai_watermarks/video_visible.py b/src/remove_ai_watermarks/video_visible.py index 6304c40..74be192 100644 --- a/src/remove_ai_watermarks/video_visible.py +++ b/src/remove_ai_watermarks/video_visible.py @@ -50,6 +50,7 @@ from remove_ai_watermarks.video_encoding import ( from remove_ai_watermarks.video_temporal import stabilize_filled_frame if TYPE_CHECKING: + from collections.abc import Iterable from pathlib import Path from numpy.typing import NDArray @@ -80,6 +81,19 @@ _HAILUO_STRONG_CONFIDENCE = 0.34 _KLING_WEAK_CONFIDENCE = 0.20 _KLING_STRONG_CONFIDENCE = 0.24 _KLING_MIN_WHITE_FRACTION = 0.02 +# The Hailuo label is white. A Hailuo 2.3 clip served by Higgsfield (2026-09-25) +# scored 0.33-0.35 on wood grain, above the real label's 0.31, with no bright +# low-saturation pixel in the box; the real label and the synthetic example +# measure 1.00 and 0.15. +_HAILUO_MIN_WHITE_FRACTION = 0.02 +# Vidu (ShengShu) "Vidu AI" logo plus wordmark, bottom-right. Measured 2026-09-24 on +# the one real Vidu Q3 export (fixture visible/vidu/provider-original.mp4): 0.72-0.73 +# on every frame at 1080p, 720p and 480p re-encodes, 0.60 at 360p. The maximum over +# the 956 other videos of the local corpus and every tracked fixture (every 12th +# frame) was 0.41; none reached 0.52. One positive clip is a thin cohort: the +# floors sit between the two with margin on both sides. +_VIDU_WEAK_CONFIDENCE = 0.52 +_VIDU_STRONG_CONFIDENCE = 0.58 _MIN_STABLE_FRAMES = 5 _MIN_VEO_STABLE_FRAMES = 12 _MIN_FIXED_MARK_STABLE_FRAMES = 12 @@ -94,6 +108,10 @@ _VEO_DIAMOND_PROFILES = ( _DOLA_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(22, 41)) _HAILUO_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(28, 56, 3)) _KLING_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(24, 49, 3)) +_VIDU_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(34, 61, 3)) +# ShengShu (北京生数科技有限公司), Vidu's maker, as the USCC inside a TC260 +# ContentProducer. Verified against the Beijing municipal list of 2025-03-03. +VIDU_TC260_PRODUCER_CODES = ("91110108MACC4D63XF",) _HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"}) @@ -308,6 +326,45 @@ def _kling_logo_template() -> NDArray[Any]: return _crop_nonzero(template) +def _vidu_capsule(height: int, width: int, angle: float, stroke: int) -> NDArray[Any]: + canvas = Image.new("L", (200, 200), 0) + ImageDraw.Draw(canvas).rounded_rectangle( + (100 - width // 2, 100 - height // 2, 100 + width // 2, 100 + height // 2), + radius=width // 2, + outline=255, + width=stroke, + ) + rotation = cv2.getRotationMatrix2D((100, 100), angle, 1.0) + return _crop_nonzero(cv2.warpAffine(np.asarray(canvas, dtype=np.uint8), rotation, (200, 200))) + + +@lru_cache(maxsize=1) +def _vidu_template() -> NDArray[Any]: + """Return a synthetic Vidu logo plus "Vidu AI" wordmark silhouette. + + The logo is two outlined capsules leaning into a V; the text is font-rendered. + Proportions follow the measured 1080p mark (232 x 50 px: logo 65 x 50, text + 155 x 35 centered 12 px after it). No pixels come from an export. + """ + canvas = np.zeros((100, 464), dtype=np.uint8) + logo = np.zeros((100, 130), dtype=np.uint8) + left = _vidu_capsule(96, 40, 24, 11) + left = cv2.resize(left, (left.shape[1] * 100 // left.shape[0], 100)) + logo[:, : left.shape[1]] = np.maximum(logo[:, : left.shape[1]], left) + right = _vidu_capsule(96, 40, -24, 11) + right = cv2.resize(right, (right.shape[1] * 100 // right.shape[0], 100)) + offset = 130 - right.shape[1] + logo[:, offset:] = np.maximum(logo[:, offset:], right) + canvas[:, :130] = logo + text = Image.new("L", (600, 140), 0) + ImageDraw.Draw(text).text( + (4, 10), "Vidu AI", font=_scalable_default_font(96), fill=255, stroke_width=1, stroke_fill=255 + ) + word = cv2.resize(_crop_nonzero(np.asarray(text, dtype=np.uint8)), (310, 70), interpolation=cv2.INTER_AREA) + canvas[15:85, 154:464] = np.maximum(canvas[15:85, 154:464], word) + return canvas + + def _top_hat(gray: NDArray[Any]) -> NDArray[Any]: kernel = np.ones((7, 7), dtype=np.uint8) return cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel) @@ -348,6 +405,7 @@ def _template_sources() -> dict[str, NDArray[Any]]: "seedance": _seedance_template(), "dola": _dola_template(), "hailuo": _hailuo_template(), + "vidu": _vidu_template(), "kling-logo": _kling_logo_template(), # The Doubao video mark is the same "豆包AI生成" run the image engine # matches (its synthetic alpha asset), bottom-right on video frames. @@ -676,6 +734,22 @@ def detect_dola_frame( ) +def _white_fraction(roi: NDArray[Any]) -> float: + """Share of bright, low-saturation pixels, the color of the white video labels.""" + if roi.size == 0: + return 0.0 + if roi.ndim == 2: + return float(np.mean(roi >= 180)) + hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) + return float(np.mean((hsv[:, :, 1] <= 55) & (hsv[:, :, 2] >= 180))) + + +def _reaches_kling_edge(region: Region, frame_width: int, frame_height: int) -> bool: + """Whether a Kling candidate box reaches the bottom-right frame edge.""" + x, y, width, height = region + return x + width >= frame_width * 0.96 and y + height >= frame_height * 0.94 + + def detect_hailuo_frame( image_bgr: NDArray[Any], *, @@ -696,6 +770,8 @@ def detect_hailuo_frame( return detection frame_width = image_bgr.shape[1] x, y, width, height = detection.region + if _white_fraction(image_bgr[y : y + height, x : x + width]) < _HAILUO_MIN_WHITE_FRACTION: + return FrameLocalization(frame_index, 0.0, None) horizontal_padding = round(height * 1.25) region = _bounded_region( x - horizontal_padding, @@ -712,6 +788,24 @@ def detect_hailuo_frame( ) +def detect_vidu_frame( + image_bgr: NDArray[Any], + *, + frame_index: int = 0, + prepared: _PreparedFrame | None = None, +) -> FrameLocalization: + """Locate the strongest fixed Vidu logo-plus-wordmark candidate.""" + return _detect_fixed_mark( + image_bgr, + "vidu", + relative_heights=_VIDU_RELATIVE_HEIGHTS, + search_origin=(0.70, 0.84), + kernel_fraction=0.18, + normalized=None if prepared is None else (prepared.normalized_gray, prepared.normalized_scale), + frame_index=frame_index, + ) + + def detect_kling_frame( image_bgr: NDArray[Any], *, @@ -762,9 +856,7 @@ def detect_kling_frame( edge_candidates = [ candidate for candidate in expanded - if candidate.region is not None - and candidate.region[0] + candidate.region[2] >= frame_width * 0.96 - and candidate.region[1] + candidate.region[3] >= frame_height * 0.94 + if candidate.region is not None and _reaches_kling_edge(candidate.region, frame_width, frame_height) ] font_candidate = None if not edge_candidates else max(edge_candidates, key=lambda candidate: candidate.confidence) @@ -782,18 +874,19 @@ def detect_kling_frame( logo_x: int | None = None if logo.region is not None and logo.confidence >= 0.44: logo_x, logo_y, _, logo_height = logo.region - logo_candidate = FrameLocalization( - frame_index, - logo.confidence, - _bounded_region( - logo_x - round(logo_height * 0.2), - logo_y - round(logo_height * 0.25), - round(logo_height * 7.8), - round(logo_height * 1.5), - frame_width=frame_width, - frame_height=frame_height, - ), + logo_region = _bounded_region( + logo_x - round(logo_height * 0.2), + logo_y - round(logo_height * 0.25), + round(logo_height * 7.8), + round(logo_height * 1.5), + frame_width=frame_width, + frame_height=frame_height, ) + # The swirl arm must reach the same frame edge as the text arm: a swirl + # match on wood grain 11% short of the right edge covered every frame of + # a Higgsfield Cinema Studio clip (2026-09-25). + if _reaches_kling_edge(logo_region, frame_width, frame_height): + logo_candidate = FrameLocalization(frame_index, logo.confidence, logo_region) best = font_candidate or logo_candidate if ( @@ -808,13 +901,7 @@ def detect_kling_frame( if best is None or best.region is None: return FrameLocalization(frame_index, 0.0, None) x, y, width, height = best.region - roi = image_bgr[y : y + height, x : x + width] - if roi.ndim == 2: - white_fraction = float(np.mean(roi >= 180)) - else: - hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) - white_fraction = float(np.mean((hsv[:, :, 1] <= 55) & (hsv[:, :, 2] >= 180))) - if white_fraction < _KLING_MIN_WHITE_FRACTION: + if _white_fraction(image_bgr[y : y + height, x : x + width]) < _KLING_MIN_WHITE_FRACTION: return FrameLocalization(frame_index, 0.0, None) return best @@ -1044,13 +1131,31 @@ VISIBLE_MARK_POLICIES: dict[str, VisibleMarkPolicy] = { # a MiniMax TC260 label only drops the strong-frame requirement for an # already-stable run (see has_hailuo_video_provenance). ), + "vidu": VisibleMarkPolicy( + weak_floor=_VIDU_WEAK_CONFIDENCE, + strong_floor=_VIDU_STRONG_CONFIDENCE, + transition_floor=0.45, + min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES, + cover_after_confirmation=True, + anchor_iou=0.80, + padding_fraction=0.12, + mask_style="box", + # No provenance_weak_floor: a ShengShu TC260 label confirms the vendor + # (has_vidu_video_provenance) but does not lower the measured bar. + ), "kling": VisibleMarkPolicy( weak_floor=_KLING_WEAK_CONFIDENCE, strong_floor=_KLING_STRONG_CONFIDENCE, transition_floor=0.30, min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES, cover_after_confirmation=True, - anchor_iou=0.80, + # Kling's per-frame score does not separate texture from the label: over + # 1008 local videos the top clip, an OpenArt export with no Kling mark, + # scored 0.81 against 0.63 for the real overlay, and none of 68 inspected + # clips between 0.24 and 0.81 showed one (2026-09-25). The overlay's box + # is identical on every frame, while texture matches drift a few pixels, so + # a run must stay anchored far tighter than the shared 0.80. + anchor_iou=0.95, padding_fraction=0.12, mask_style="box", accepts_provenance=False, @@ -1145,10 +1250,18 @@ def _stabilize_localizations( # the generator name. Veo may also cover the sequence without provenance # after its longer, strong fixed-position run. Cover low-contrast transition # frames with the nearest confirmed position. + # + # The carry stops ``min_stable_frames`` after the last frame that still shows the + # mark. China's labeling rules (CAC Measures, Article 4) require a video label only + # on the opening frames; without this bound a label seen for 2 s had its box + # inpainted over the whole clip (measured 2026-09-23: 240 of 240 frames for a + # 48-frame label on every policy with cover_after_confirmation). No committed + # provider clip relied on a tail carry longer than zero frames. confirmed_positions = [position for position, region in enumerate(accepted) if region is not None] if (provenance or cover_after_confirmation) and confirmed_positions: confirmed_regions = [accepted[position] for position in confirmed_positions] carry_position = confirmed_positions[0] + last_supported = confirmed_positions[-1] for position, region in enumerate(accepted): if region is not None: carry_position = position @@ -1163,6 +1276,9 @@ def _stabilize_localizations( ) ): accepted[position] = raw.region + last_supported = max(last_supported, position) + continue + if position > last_supported + min_stable_frames: continue accepted[position] = accepted[carry_position] @@ -1252,6 +1368,7 @@ def scan_video_marks( detect_doubao_frame, detect_dola_frame, detect_hailuo_frame, + detect_vidu_frame, detect_kling_frame, ), strict=True, @@ -1534,13 +1651,65 @@ def has_bytedance_video_provenance(markers: dict[str, str]) -> bool: return ("bytedance" in identity or "byteplus" in identity) and "trainedalgorithmicmedia" in source_type +def tc260_producer_in(producer: str, codes: Iterable[str]) -> bool: + """Whether a TC260 ``ContentProducer`` names one of ``codes`` (USCC-normalized, casefolded).""" + from remove_ai_watermarks.metadata import uscc_of + + code = uscc_of(producer.strip()).casefold() + return bool(code) and code in {candidate.casefold() for candidate in codes} + + def has_doubao_video_provenance(markers: dict[str, str]) -> bool: """Whether the structural TC260 producer identifies registered Doubao provenance.""" - from remove_ai_watermarks.metadata import uscc_of from remove_ai_watermarks.watermark_registry import get_mark - producer = uscc_of(markers.get("aigc_producer", "").strip()).casefold() - return producer in {code.casefold() for code in get_mark("doubao").tc260_producer_codes} + return tc260_producer_in(markers.get("aigc_producer", ""), get_mark("doubao").tc260_producer_codes) + + +# Tokens of the C2PA issuer each mark's own vendor signs with, compared casefolded. +_VIDEO_MARK_ISSUER_TOKENS: dict[str, tuple[str, ...]] = { + "sora": ("openai",), + "veo": ("google",), + "seedance": ("bytedance", "byteplus"), + "doubao": ("bytedance", "byteplus"), + "dola": ("bytedance", "byteplus"), + "hailuo": ("minimax",), + "vidu": ("shengshu", "vidu"), + "kling": ("kling", "kuaishou"), +} + + +def contradicts_video_provenance(mark: str, markers: dict[str, str]) -> bool: + """Whether provenance names a producer other than the visible mark's vendor. + + Kling alone also reads the TC260 producer. The Kling detector's thresholds are + low (0.20/0.24) and it scores any white + Latin wordmark in the bottom-right corner: a real Vidu Q3 export (2026-09-23, + "Vidu AI" logo plus wordmark) scored 0.56 on the ring template and 0.37-0.40 on + the font templates, a stable 193/193-frame run. The same file's TC260 label + names Vidu's producer, so a present, non-Kling producer vetoes the visual match. + A missing label is not evidence either way and leaves the visual path alone. + + A C2PA manifest that itself claims AI generation by another vendor contradicts + any mark: a Veo 3.1 Lite clip with Google's C2PA (Higgsfield, 2026-09-24) scored + 0.61 for Kling on wood texture, against 0.63 on the real Kling original, a stable + 192-frame run; a Gemini Omni 1.1 Flash clip with Google's C2PA (Runway, + 2026-09-25) scored 0.61-0.67 for Sora on a steam wisp over five frames. A + re-signing platform (YouTube) records no AI source type, so it vetoes nothing. + """ + producer = markers.get("aigc_producer", "").strip() + if mark == "kling" and producer: + from remove_ai_watermarks.watermark_registry import get_mark + + return not tc260_producer_in(producer, get_mark("kling").tc260_producer_codes) + issuer = markers.get("issuer", "").casefold() + claims_ai = "trainedalgorithmicmedia" in markers.get("source_type", "").casefold() + return bool(issuer) and claims_ai and not any(token in issuer for token in _VIDEO_MARK_ISSUER_TOKENS[mark]) + + +def has_vidu_video_provenance(markers: dict[str, str]) -> bool: + """Whether a TC260 label names ShengShu, Vidu's maker, as the producer.""" + return tc260_producer_in(markers.get("aigc_producer", ""), VIDU_TC260_PRODUCER_CODES) def has_hailuo_video_provenance(markers: dict[str, str]) -> bool: diff --git a/src/remove_ai_watermarks/wan_engine.py b/src/remove_ai_watermarks/wan_engine.py new file mode 100644 index 0000000..e5dd663 --- /dev/null +++ b/src/remove_ai_watermarks/wan_engine.py @@ -0,0 +1,112 @@ +"""Alibaba Wan (通义万相) visible watermark detector/localizer. + +Wan image exports carry a white, semi-transparent logo plus "Wan" wordmark in the +bottom-right corner, alongside the TC260 label in XMP whose ContentProducer names +Tongyi Yunqi (Hangzhou) Information Technology (USCC 91330106MA2CFLDG4R, owned by +Alibaba Cloud and Tongyi Lab). The service API's ``noWaterMark: true`` flag did not remove it on +the measured export, so the flag is not evidence of a clean file. + +Detection matches the bundled synthetic silhouette (``assets/wan_alpha.png``, drawn +by ``scripts/render_vendor_silhouettes.py::draw_wan``, no export pixels) against the +corner; removal is the shared localize -> fill. + +**Calibration is PROVISIONAL.** One real positive exists (a 2048 px square Wan 2.7 +Pro export, 2026-09-23): the mark measures 0.131 x 0.050 of the short side with +~0.005 margins and scores 0.61-0.67 at 512-2048 px. NCC alone does not separate +it well: over 3092 local images (every tracked fixture plus 3000 sampled from the +local corpus, 2026-09-24) the maximum was 0.519. The mark hugs the corner, and +requiring the match to end within 0.015 of the short side from the right and +bottom edges leaves no negative at 0.45 or above. Both gates apply. Re-measure on +a real Wan cohort before any precision claim. +""" + +from __future__ import annotations + +import logging + +from remove_ai_watermarks._text_mark_engine import ( + TextMarkConfig, + TextMarkDetection, + TextMarkEngine, + TextMarkScan, +) + +logger = logging.getLogger(__name__) + +# Locate box as a fraction of the SHORT side: the mark plus slack for the ladder. +WM_WIDTH_FRAC = 0.17 +WM_HEIGHT_FRAC = 0.075 +MARGIN_RIGHT_FRAC = 0.002 +MARGIN_BOTTOM_FRAC = 0.002 + +MAX_SATURATION = 55 +LOGO_MIN_LUMA = 150 +TOPHAT_DELTA = 12 + +DETECT_MIN_COVERAGE = 0.04 # unused by the tophat front-end (kept for config parity) +DETECT_NCC_THRESHOLD = 0.55 # provisional; see the module docstring + +_ALPHA_WIDTH_FRAC = 268 / 2048 +_ALPHA_HEIGHT_FRAC = 103 / 2048 + +_CONFIG = TextMarkConfig( + name="Wan", + asset_name="wan_alpha.png", + corner="br", + margin_floor=4, + width_frac=WM_WIDTH_FRAC, + height_frac=WM_HEIGHT_FRAC, + margin_x_frac=MARGIN_RIGHT_FRAC, + margin_bottom_frac=MARGIN_BOTTOM_FRAC, + max_saturation=MAX_SATURATION, + logo_min_luma=LOGO_MIN_LUMA, + tophat_delta=TOPHAT_DELTA, + morph_open_size=5, + detect_min_coverage=DETECT_MIN_COVERAGE, + detect_ncc_threshold=DETECT_NCC_THRESHOLD, + detect_frontend="tophat", + scale_basis="short", + alpha_width_frac=_ALPHA_WIDTH_FRAC, + alpha_height_frac=_ALPHA_HEIGHT_FRAC, + min_gw=8, + # STRICT ONLY: one positive cannot calibrate a relaxed provenance band. + provenance_ncc_factor=1.0, +) + + +class WanEngine(TextMarkEngine): + """Detect/localize the bottom-right Wan logo plus wordmark (locate -> mask -> fill).""" + + # Match must end this close to the right and bottom edges (fraction of the short + # side). Measured positive: 0.005-0.008 at 512-2048 px. + _ANCHOR_MAX_RIGHT = 0.015 + _ANCHOR_MAX_BOTTOM = 0.015 + + def __init__(self) -> None: + super().__init__(_CONFIG) + + def _post_gate(self, det: TextMarkDetection, scan: TextMarkScan) -> TextMarkDetection: + """Demote a match that does not hug the bottom-right corner. + + A shared post-gate rather than a ``detect`` override, so the single-pass + perception path (``detect_both``) cannot skip it. + """ + if not det.detected or scan.loc is None: + return det + box = det.match_box + if box is None: + det.detected = False + return det + h, w = scan.frame + base = min(h, w) + right = (w - (scan.loc.x + box[2] + 1)) / base + bottom = (h - (scan.loc.y + box[3] + 1)) / base + if not (0 <= right <= self._ANCHOR_MAX_RIGHT and 0 <= bottom <= self._ANCHOR_MAX_BOTTOM): + logger.debug( + "Wan detect: score %.3f but match off-anchor (right=%.3f bottom=%.3f); demoting.", + det.confidence, + right, + bottom, + ) + det.detected = False + return det diff --git a/src/remove_ai_watermarks/watermark_registry.py b/src/remove_ai_watermarks/watermark_registry.py index 03d0548..ccf249a 100644 --- a/src/remove_ai_watermarks/watermark_registry.py +++ b/src/remove_ai_watermarks/watermark_registry.py @@ -457,6 +457,7 @@ _ENGINE_CLASS: dict[str, tuple[str, str]] = { "microsoft": ("microsoft_engine", "MicrosoftEngine"), "generic_ai_label": ("generic_ai_label_engine", "GenericAiLabelEngine"), "openart": ("openart_engine", "OpenArtEngine"), + "wan": ("wan_engine", "WanEngine"), } @@ -713,13 +714,25 @@ _REGISTRY: tuple[KnownMark, ...] = ( manufacturer="alibaba", tc260_producer_codes=("91440101MA9Y9T4H7A",), ), + _text_mark( + "wan", + "Wan logo and wordmark", + "bottom-right", + platform="Alibaba Wan (visible Wan mark detected)", + manufacturer="alibaba", + # Tongyi Yunqi (Hangzhou) Information Technology, owned by Alibaba Cloud and + # Tongyi Lab: the ContentProducer on the measured Wan export. + tc260_producer_codes=("91330106MA2CFLDG4R",), + ), _text_mark( "kling", "Kling AI 可灵AI / KlingAI 3.0 text", "bottom-right", platform="Kuaishou Kling AI (visible 可灵AI / KlingAI 3.0 mark detected)", manufacturer="kuaishou", - tc260_producer_codes=("91110108335469089C",), + # Kling 3.0 Turbo video served by Higgsfield (2026-09-24) names the + # producer by the bare string "kling" instead of a USCC. + tc260_producer_codes=("91110108335469089C", "kling"), ), _text_mark( "yuanbao", diff --git a/tests/test_captures_manifest.py b/tests/test_captures_manifest.py new file mode 100644 index 0000000..964d885 --- /dev/null +++ b/tests/test_captures_manifest.py @@ -0,0 +1,44 @@ +"""The provider-capture manifest and the files it indexes must agree.""" + +from __future__ import annotations + +import csv +import hashlib +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +CAPTURES = ROOT / "data" / "captures" +MANIFESTS = sorted(CAPTURES.glob("*/manifest.csv")) + + +def _rows(manifest: Path) -> list[dict[str, str]]: + with manifest.open(newline="") as handle: + return list(csv.DictReader(handle)) + + +@pytest.mark.parametrize("manifest", MANIFESTS, ids=lambda path: path.parent.name) +def test_every_row_points_at_a_file_with_its_digest(manifest: Path): + for row in _rows(manifest): + path = ROOT / row["path"] + assert path.is_file(), row["path"] + assert hashlib.sha256(path.read_bytes()).hexdigest() == row["sha256"], row["path"] + + +@pytest.mark.parametrize("manifest", MANIFESTS, ids=lambda path: path.parent.name) +def test_every_stored_capture_is_indexed_once(manifest: Path): + rows = _rows(manifest) + indexed = [row["path"] for row in rows] + assert len(indexed) == len(set(indexed)) + assert len({row["sha256"] for row in rows}) == len(rows) + on_disk = { + str(path.relative_to(ROOT)) + for path in manifest.parent.rglob("*") + if path.is_file() and path.name not in {"manifest.csv", "README.md"} + } + assert on_disk == {row["path"] for row in rows if row["stored"] == "new"} + + +def test_a_manifest_exists(): + assert MANIFESTS diff --git a/tests/test_identify.py b/tests/test_identify.py index 3f8e7a3..7f959b0 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -796,6 +796,116 @@ class TestIdentifyRealSamples: assert r.is_ai_generated is True assert any("IPTC" in w for w in r.watermarks) + @pytest.mark.parametrize( + "name", + [ + "google-nano-banana.png", + "google-nano-banana-2.jpg", + "google-nano-banana-pro.jpg", + "google-nano-banana-2-lite.jpg", + ], + ) + def test_gemini_api_image_carries_google_c2pa_and_synthid(self, name: str): + # Real Gemini API outputs (2026-09-24), one per image model: valid Google C2PA + # with a SynthID action and no visible sparkle. + r = identify(SAMPLES_DIR / name, check_visible=True, check_invisible=False) + assert r.is_ai_generated is True + assert r.ai_source_kind == "generated" + assert r.platform == "Google (Gemini / Imagen)" + assert r.c2pa_validation["state"] == "Valid" + assert "SynthID watermark (present according to Google LLC provenance)" in r.watermarks + assert not any("sparkle" in watermark.lower() for watermark in r.watermarks) + + def test_runway_gen4_image_is_attributed_to_runway(self): + # Real Runway Gen-4 image artifact (2026-09-24): C2PA signed "RUNWAY AI, INC.", + # c2pa.created with software agent "Runway Image Generation", no visible mark. + r = identify(SAMPLES_DIR / "runway-gen4-image.png", check_visible=True, check_invisible=False) + assert r.is_ai_generated is True + assert r.ai_source_kind == "generated" + assert r.platform == "Runway" + assert "C2PA Content Credentials (Runway)" in r.watermarks + assert not any("SynthID" in watermark for watermark in r.watermarks) + + def test_dreamina_export_is_attributed_through_its_transcode_signer(self): + # Real Dreamina Seedream 5.0 Pro image (2026-09-24): the active claim is + # "ByteDance Media Transcode Service", the "Dreamina/7.5.0" generator sits in + # the ingredient. The generic signer label used to win over the product. + r = identify(SAMPLES_DIR / "dreamina-seedream-5-pro.jpg", check_visible=False, check_invisible=False) + assert r.is_ai_generated is True + assert r.platform == "ByteDance Dreamina" + + @pytest.mark.parametrize("name", ["higgsfield-gpt-image.jpg", "higgsfield-kling-o1.png"]) + def test_non_google_provenance_vetoes_a_sparkle_on_texture(self, name: str): + # Higgsfield downloads (2026-09-24): a valid OpenAI C2PA and a Kuaishou TC260 + # label, each scoring 0.53 as a Gemini sparkle on wood texture. + r = identify(SAMPLES_DIR / name, check_visible=True, check_invisible=False) + assert r.is_ai_generated is True + assert not any("Gemini visible" in watermark for watermark in r.watermarks) + assert any("not reported as a Gemini mark" in caveat for caveat in r.caveats) + + def test_google_provenance_keeps_the_sparkle(self): + # The veto is for other vendors only: a Google-signed Gemini image keeps it. + path = Path(__file__).resolve().parents[1] / "data/calibration/gemini/gemini_black_2048.png" + r = identify(path, check_visible=True, check_invisible=False) + assert any("Gemini visible" in watermark for watermark in r.watermarks) + assert not any("not reported as a Gemini mark" in caveat for caveat in r.caveats) + + def test_higgsfield_job_breaks_the_upstream_manifest(self): + # FLUX.2 Pro via Higgsfield: Black Forest Labs signed the image, then + # Higgsfield inserted its hf-job-id chunk, so the data hash no longer matches. + r = identify(SAMPLES_DIR / "higgsfield-flux-2-pro.png", check_visible=False, check_invisible=False) + assert r.is_ai_generated is True + assert r.confidence == "medium" + assert r.platform == "Higgsfield (model not identified)" + assert "C2PA Content Credentials (invalid asset binding or signature)" in r.watermarks + assert "Higgsfield job (hf-job-id)" in r.watermarks + + @pytest.mark.parametrize( + "path", + [ + SAMPLES_DIR / "google-photos-ios-edit.jpg", + *( + SAMPLES_DIR.parents[1] / "captures" / "2026-09" / "google-photos" / name + for name in ("ask-dog-to-cat.jpg", "eraser-glasses.jpg", "gphotos-5.jpg", "gphotos-6.jpg") + ), + ], + ids=lambda path: path.name, + ) + def test_google_photos_ai_edit_reports_synthid(self, path: Path): + # Real Google Photos iOS AI edits (Ask, eraser and two others, 2026-09-23 to + # 2026-09-25): valid Google C2PA signed "Google Photos", no SynthID action. + # Google's checker found SynthID on all four captures. + r = identify(path, check_visible=False, check_invisible=False) + assert r.is_ai_generated is True + assert r.ai_source_kind == "enhanced" + assert r.platform == "Google Photos (AI edit)" + assert r.c2pa_validation["state"] == "Valid" + assert any("SynthID" in watermark for watermark in r.watermarks) + assert any("Google Photos AI edit" in caveat for caveat in r.caveats) + + def test_apple_image_playground_attributed(self): + # Real Image Playground export (2026-09-23): XMP photoshop:Credit plus the IPTC + # digitalSourceType, no C2PA. The credit names the platform; the IPTC + # value alone would leave it undetermined. + r = identify(SAMPLES_DIR / "apple-image-playground.png", check_visible=False, check_invisible=False) + assert r.is_ai_generated is True + assert r.platform == "Apple Image Playground" + assert not any("C2PA" in watermark for watermark in r.watermarks) + + def test_apple_generative_edit_clean_up_attributed(self, tmp_path: Path): + # Real Photos Clean Up credit, iPhone on iOS 27.0 (measured 2026-09-23). It does not + # contain the older "Apple Photos Clean Up" substring, so it needs its own + # entry or the platform falls back to undetermined. + p = tmp_path / "apple_generative_edit.jpg" + p.write_bytes( + b'\xff\xd8\xff\xe1\xff\xd9" + ) + r = identify(p, check_visible=False, check_invisible=False) + assert r.is_ai_generated is True + assert r.platform == "Apple Photos (Clean Up AI edit)" + assert r.ai_source_kind == "enhanced" + def test_apple_clean_up_attributed(self, tmp_path: Path): # Apple Photos Clean Up (Apple Intelligence object removal) marks the # AI edit via photoshop:Credit next to compositeWithTrainedAlgorithmicMedia @@ -1017,7 +1127,7 @@ class TestIdentifyAigcPngChunk: assert "doubao" in signal.detail -# ── Hugging Face-hosted job marker (medium confidence) ───────────── +# ── Higgsfield job marker, hf-job-id (medium confidence) ─────────── class TestIdentifyHuggingFaceJob: @@ -1039,7 +1149,7 @@ class TestIdentifyHuggingFaceJob: assert r.is_ai_generated is True assert r.confidence == "medium" assert r.platform is not None - assert "Hugging Face" in r.platform + assert r.platform == "Higgsfield (model not identified)" signal = next(s for s in r.signals if s.name == "hf_job") assert signal.confidence == "medium" @@ -2050,3 +2160,88 @@ class TestRegistryScansSkipTheCodedPixels: blob = b"\xff\xd8" + b"not really a jpeg, no valid marker chain here" * 4 assert _metadata_region(blob) == blob + + +class TestGooglePhotosSynthIdScope: + """A Google Photos AI edit reports SynthID without a recorded SynthID action. + + Google documents SynthID only for Reimagine (blog.google, 2025-08-20), but its + checker found the mark on all four Photos AI edits tested on 2026-09-25. + + The manifest shape mirrors a real Google Photos iOS object-removal edit captured + 2026-09-23: signer common name "Google Photos", actions opened + deleted, the + deleted action typed compositeWithTrainedAlgorithmicMedia, no SynthID action. + """ + + @staticmethod + def _report(tmp_path: Path, *, common_name: str, extra_actions: list[dict]): + store = { + "active_manifest": "edit", + "validation_results": { + "activeManifest": { + "success": [{"code": "assertion.dataHash.match"}, {"code": "claimSignature.validated"}], + "failure": [], + } + }, + "manifests": { + "edit": { + "claim_generator_info": [{"name": "Google C2PA SDK for iOS"}], + "signature_info": {"issuer": "Google LLC", "common_name": common_name}, + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + {"action": "c2pa.opened"}, + { + "action": "c2pa.deleted", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/" + "compositeWithTrainedAlgorithmicMedia", + }, + *extra_actions, + ] + }, + } + ], + } + }, + } + info = c2pa_info_from_manifest_store(store) + # The file's bytes carry the same manifest text the reader parsed. + scan = b"jumb c2pa " + json.dumps(store).encode() + evidence = ProvenanceEvidence( + path=tmp_path / "edit.jpg", + c2pa_info=info, + ai_metadata={}, + scan=scan, + iptc_ai_system=None, + aigc_label=None, + exif_generator=None, + xai_signature=False, + huggingface_job=None, + samsung_genai=None, + ) + return identify_from_evidence(evidence) + + def test_photos_edit_without_synthid_action_claims_synthid(self, tmp_path: Path): + report = self._report(tmp_path, common_name="Google Photos", extra_actions=[]) + + assert report.is_ai_generated is True + assert report.ai_source_kind == "enhanced" + assert report.platform == "Google Photos (AI edit)" + assert any("SynthID" in mark for mark in report.watermarks) + assert any("Google Photos AI edit" in caveat for caveat in report.caveats) + + def test_photos_edit_with_synthid_action_claims_synthid(self, tmp_path: Path): + synthid_action = {"action": "c2pa.edited", "description": "Added imperceptible SynthID watermark"} + + report = self._report(tmp_path, common_name="Google Photos", extra_actions=[synthid_action]) + + assert any("SynthID" in mark for mark in report.watermarks) + assert report.platform == "Google Photos (AI edit)" + + def test_other_google_signers_keep_synthid_without_the_action(self, tmp_path: Path): + report = self._report(tmp_path, common_name="Google Media Processing Services", extra_actions=[]) + + assert any("SynthID" in mark for mark in report.watermarks) + assert not any("Google Photos AI edit" in caveat for caveat in report.caveats) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 2738b34..fb8831c 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -23,6 +23,7 @@ from remove_ai_watermarks.metadata import ( iptc_ai_system, remove_ai_metadata, samsung_genai, + scan_head, strip_and_verify, synthid_source, xai_signature, @@ -1775,7 +1776,7 @@ class TestAIGCLabel: class TestHuggingFaceJob: - """Hugging Face-hosted job marker (``hf-job-id`` PNG text chunk).""" + """Higgsfield job marker (``hf-job-id`` PNG text chunk).""" def _hf_png(self, tmp_path: Path, job_id: str = "ec8380a6-2091-423a-b835-209420f99ee1") -> Path: p = tmp_path / "hfjob.png" @@ -2430,3 +2431,117 @@ class TestDisplayTagsSurviveTheStrip: with Image.open(out) as im: assert im.info.get("icc_profile") == source_icc assert im.getexif().get(0x0112) == self.ORIENT + + +class TestContainerRouting: + """Containers the PIL re-save used to corrupt or the head window used to miss.""" + + @staticmethod + def _gif_with_xmp(path: Path) -> None: + colors = [(200, 30, 30), (30, 200, 30), (30, 30, 200)] + frames = [Image.new("RGB", (16, 16), color).convert("P") for color in colors] + frames[0].save( + path, + save_all=True, + append_images=frames[1:], + duration=100, + loop=0, + comment=b"trainedAlgorithmicMedia", + ) + data = path.read_bytes() + # An XMP application extension ("XMP DataXMP") carrying an AI source type, + # inserted before the first image descriptor. + xmp = b"" + blocks = b"".join(bytes([len(xmp[i : i + 255])]) + xmp[i : i + 255] for i in range(0, len(xmp), 255)) + extension = b"\x21\xff\x0bXMP DataXMP" + blocks + b"\x00" + # Insert right after the header, screen descriptor and global colour table. + packed = data[10] + blocks_start = 13 + (3 * (2 ** ((packed & 0x07) + 1)) if packed & 0x80 else 0) + path.write_bytes(data[:blocks_start] + extension + data[blocks_start:]) + + def test_gif_strip_keeps_every_frame_and_the_gif_container(self, tmp_path: Path): + src = tmp_path / "anim.gif" + self._gif_with_xmp(src) + assert b"trainedAlgorithmicMedia" in src.read_bytes() + + out = tmp_path / "clean.gif" + remove_ai_metadata(src, out) + + cleaned = out.read_bytes() + assert cleaned[:6] == b"GIF89a" + assert b"trainedAlgorithmicMedia" not in cleaned + assert b"NETSCAPE2.0" in cleaned + with Image.open(src) as before, Image.open(out) as after: + assert after.format == "GIF" + assert after.n_frames == before.n_frames == 3 + for index in range(3): + before.seek(index) + after.seek(index) + assert after.convert("RGB").tobytes() == before.convert("RGB").tobytes() + + def test_tiff_strip_is_refused_instead_of_rewritten_as_png(self, tmp_path: Path): + src = tmp_path / "scan.tif" + Image.new("RGB", (8, 8)).save(src) + with pytest.raises(ValueError, match="TIFF/DNG"): + remove_ai_metadata(src, tmp_path / "clean.tif") + assert not (tmp_path / "clean.tif").exists() + + def test_c2pa_chunk_after_large_wav_data_reaches_the_head_scan(self, tmp_path: Path): + # C2PA puts a RIFF manifest in the last top-level chunk; audio longer than the + # head window pushed it out of every byte detector's view. + payload = b"c2pa trainedAlgorithmicMedia" + pcm = b"\x00" * (2 * 1024 * 1024) + fmt = struct.pack("<4sIHHIIHH", b"fmt ", 16, 1, 1, 8000, 16000, 2, 16) + body = fmt + b"data" + struct.pack(" dict: + manifest: dict = {"claim_generator": "some-editor/1.0", "assertions": assertions} + if ingredients is not None: + manifest["ingredients"] = ingredients + return {"active_manifest": "active", "manifests": {"active": manifest}} + + @pytest.mark.parametrize( + ("oversight", "kind"), + [("fully_autonomous", "generated"), ("prompt_guided", "generated"), ("human_validated", "enhanced")], + ) + def test_ai_disclosure_alone_asserts_ai_with_the_spec_kind(self, oversight: str, kind: str): + store = self._store( + [ + { + "label": "c2pa.ai-disclosure", + "data": { + "modelType": "diffusion", + "modelName": "Example Diffusion 3", + "contentProfile": {"humanOversightLevel": oversight}, + }, + } + ] + ) + + info = c2pa_info_from_manifest_store(store) + + assert info["ai_source_kind"] == kind + assert info["ai_disclosure"] == "Example Diffusion 3" + assert info["human_oversight"] == oversight + + def test_digital_source_type_still_decides_the_kind_over_disclosure(self): + store = self._store( + [ + { + "label": "c2pa.actions.v2", + "data": {"actions": [{"action": "c2pa.created", "digitalSourceType": "trainedAlgorithmicMedia"}]}, + }, + { + "label": "c2pa.ai-disclosure", + "data": {"modelType": "diffusion", "contentProfile": {"humanOversightLevel": "human_validated"}}, + }, + ] + ) + + assert c2pa_info_from_manifest_store(store)["ai_source_kind"] == "generated" + + def test_ingredient_digital_source_type_is_read(self): + store = self._store( + [], + ingredients=[ + { + "title": "background.png", + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia", + } + ], + ) + + assert c2pa_info_from_manifest_store(store)["ai_source_kind"] == "generated" + + def test_manifest_without_either_signal_stays_unclaimed(self): + assert "ai_source_kind" not in c2pa_info_from_manifest_store(self._store([])) + + +def test_byte_fallback_reads_ai_disclosure_label(): + from remove_ai_watermarks._internal.c2pa import _populate_registry_fields + + info: dict = {} + assert _populate_registry_fields(b"jumb c2pa c2pa.ai-disclosure modelType human_validated", info) is True + assert info["ai_source_kind"] == "enhanced" diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 43c485d..1380081 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -68,9 +68,10 @@ def test_openai_sdk_is_development_only(): assert "openai" not in _requirement_names("all") -def test_trustmark_is_limited_to_its_numpy_compatible_python_range(): - assert "trustmark" in _requirement_names("trustmark", python_version="3.12") - assert "trustmark" not in _requirement_names("trustmark", python_version="3.13") +def test_trustmark_covers_every_supported_python(): + # trustmark 0.9.2 dropped its NumPy <2 pin, so the extra is no longer capped at 3.12. + for python_version in ("3.12", "3.13", "3.14"): + assert "trustmark" in _requirement_names("trustmark", python_version=python_version) def test_extras_use_capability_names_without_legacy_aliases(): diff --git a/tests/test_platform.py b/tests/test_platform.py index 25bb661..f80e3dd 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -346,6 +346,41 @@ class TestVendorForStrength: ): assert vendor_for_strength(Path("x.png")) == expected + @pytest.mark.parametrize(("integrity", "expected"), [("valid", "google"), ("invalid", None)]) + def test_google_photos_ai_edit_keeps_the_google_cohort(self, integrity, expected): + """A Photos AI edit carries SynthID although its manifest records no SynthID action.""" + from remove_ai_watermarks._internal.c2pa import c2pa_info_from_manifest_store + from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength + + info = c2pa_info_from_manifest_store( + { + "active_manifest": "edit", + "manifests": { + "edit": { + "signature_info": {"issuer": "Google LLC", "common_name": "Google Photos"}, + "assertions": [ + { + "label": "c2pa.actions.v2", + "data": { + "actions": [ + { + "action": "c2pa.deleted", + "digitalSourceType": "compositeWithTrainedAlgorithmicMedia", + } + ] + }, + } + ], + } + }, + } + ) + assert info["synthid_vendors"] == ["Google LLC"] + info.update(c2pa_integrity=integrity, c2pa_signature="valid", c2pa_signer_validity="valid") + # The real synthid_source reads the manifest, so an invalid binding drops the cohort. + with patch("remove_ai_watermarks._internal.c2pa.extract_c2pa_info", return_value=info): + assert vendor_for_strength(Path("x.png")) == expected + def test_both_issuers_google_wins(self): # The more-robust watermark wins -> safer (higher) strength. from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength diff --git a/tests/test_provider_oracles.py b/tests/test_provider_oracles.py index a3dd40d..cf5cd68 100644 --- a/tests/test_provider_oracles.py +++ b/tests/test_provider_oracles.py @@ -313,6 +313,26 @@ def test_prepare_strips_image_metadata_without_changing_pixels( assert oracles.verify_batch(manifest_path)["complete"] is False +def test_prepare_accepts_an_iphone_mpo_jpeg(tmp_path: Path) -> None: + # iPhone and Google Photos iOS exports are MPO (JPEG plus an HDR gain map). + source = tmp_path / "iphone.jpg" + primary = Image.new("RGB", (64, 48), (200, 120, 40)) + gain_map = Image.new("RGB", (32, 24), (90, 90, 90)) + primary.save(source, "MPO", save_all=True, append_images=[gain_map]) + with Image.open(source) as image: + assert image.format == "MPO" + output_dir = tmp_path / "oracle-batch" + + manifest_path = oracles.prepare_batch( + "gemini-web", [source], output_dir=output_dir, repository_root=oracles.REPOSITORY_ROOT + ) + + row = json.loads(manifest_path.read_text(encoding="utf-8"))["rows"][0] + assert row["media_type"] == "image" + assert row["pixels_preserved"] is True + assert row["format"] == "jpeg" + + def test_prepare_keeps_non_image_bytes_exact(tmp_path: Path) -> None: source = tmp_path / "clip.mp4" source.write_bytes(b"synthetic video fixture") diff --git a/tests/test_video.py b/tests/test_video.py index 216ebb0..6d34968 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -7,6 +7,7 @@ import io import json import shutil import subprocess +from pathlib import Path from typing import TYPE_CHECKING import cv2 @@ -20,7 +21,6 @@ from remove_ai_watermarks.metadata import C2PA_UUID if TYPE_CHECKING: from collections.abc import Callable - from pathlib import Path _MP4_FTYP = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom" @@ -968,7 +968,7 @@ class TestVideoProvenanceApi: assert report.visible_mark == "sora" assert report.visible_detected_frames == 5 assert report.total_frames == 5 - assert tuple(VIDEO_VISIBLE_MARKS) == ("sora", "veo", "seedance", "doubao", "dola", "hailuo", "kling") + assert tuple(VIDEO_VISIBLE_MARKS) == ("sora", "veo", "seedance", "doubao", "dola", "hailuo", "vidu", "kling") def test_reports_unknown_instead_of_clean(self, tmp_path: Path): from remove_ai_watermarks.video import identify_video @@ -2122,6 +2122,120 @@ class TestAdditionalProviderTemporalArbiter: assert stabilize(weak) == [None] * 12 assert stabilize(strong) == [box] * 12 + # Producer codes as they appear on real exports: Vidu (measured on a Vidu Q3 + # export, 2026-09-23) and Kling (the registered image producer). + _VIDU_PRODUCER = "001191110108MACC4D63XF10306" + _KLING_PRODUCER = "001191110108335469089C10100" + + def test_non_kling_tc260_producer_vetoes_kling_video_mark(self): + # A real Vidu "Vidu AI" logo plus wordmark formed a stable 193-frame kling run + # and was reported as Kling. The TC260 producer contradicts it. + from remove_ai_watermarks.video import _visible_removal_plan + from remove_ai_watermarks.video_visible import FrameLocalization, VideoScan + + scan = VideoScan( + width=1920, + height=1080, + fps=24.0, + detections=tuple(FrameLocalization(index, 0.56, self._KLING_BOX) for index in range(12)), + ) + + vetoed, _, _ = _visible_removal_plan("kling", scan, {"aigc_producer": self._VIDU_PRODUCER}) + kept, _, _ = _visible_removal_plan("kling", scan, {"aigc_producer": self._KLING_PRODUCER}) + unlabeled, _, _ = _visible_removal_plan("kling", scan, {}) + + assert vetoed == [None] * 12 + assert kept == [self._KLING_BOX] * 12 + assert unlabeled == [self._KLING_BOX] * 12 + + def test_shengshu_tc260_label_confirms_vidu(self): + from remove_ai_watermarks.video_visible import has_vidu_video_provenance + + assert has_vidu_video_provenance({"aigc_producer": self._VIDU_PRODUCER}) + assert not has_vidu_video_provenance({"aigc_producer": self._KLING_PRODUCER}) + assert not has_vidu_video_provenance({}) + + @pytest.mark.parametrize( + ("producer", "platform"), + [ + # Producer values as measured on Higgsfield video downloads, 2026-09-24. + ("kling", "Kuaishou Kling AI"), + ("001191330106MA2CFLDG4R10001", "Alibaba Tongyi (Wan, HappyHorse)"), + ("MiniMax", "MiniMax Hailuo AI"), + ("91110108MACC4D63XF", "ShengShu Vidu"), + ("91110000000000000X", "China AIGC-labeled content (TC260 standard)"), + ], + ) + def test_tc260_producer_names_the_video_platform(self, producer: str, platform: str): + from remove_ai_watermarks.video import _platform_from_video_metadata + + markers = {"aigc_label": "China AIGC label (TC260)", "aigc_producer": producer} + assert _platform_from_video_metadata(markers) == platform + + def test_bare_kling_tc260_producer_keeps_the_kling_mark(self): + from remove_ai_watermarks.video_visible import contradicts_video_provenance + + assert not contradicts_video_provenance("kling", {"aigc_producer": "kling"}) + + def test_non_kling_c2pa_ai_claim_vetoes_kling_video_mark(self): + # Veo 3.1 Lite from Higgsfield carries Google's C2PA and scored above the + # Kling floor on wood texture on all 192 frames (2026-09-24). + from remove_ai_watermarks.video_visible import contradicts_video_provenance + + ai = "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + assert contradicts_video_provenance("kling", {"issuer": "Google LLC", "source_type": ai}) + assert not contradicts_video_provenance("kling", {"issuer": "Kuaishou", "source_type": ai}) + # A re-signing platform records no AI source type and vetoes nothing. + assert not contradicts_video_provenance("kling", {"issuer": "YouTube"}) + assert not contradicts_video_provenance("kling", {"source_type": ai}) + + @pytest.mark.parametrize( + ("mark", "own_issuer"), + [ + ("sora", "OpenAI"), + ("veo", "Google LLC"), + ("seedance", "BytePlus"), + ("doubao", "ByteDance"), + ("dola", "ByteDance"), + ("hailuo", "MiniMax"), + ("vidu", "ShengShu"), + ("kling", "Kuaishou"), + ], + ) + def test_another_vendor_c2pa_ai_claim_vetoes_every_video_mark(self, mark: str, own_issuer: str): + # Gemini Omni 1.1 Flash via Runway carries Google's C2PA and scored above the + # strict Sora floor on a steam wisp for five frames (2026-09-25). + from remove_ai_watermarks.video import VIDEO_VISIBLE_MARKS + from remove_ai_watermarks.video_visible import contradicts_video_provenance + + ai = "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" + foreign = "RUNWAY AI, INC." if mark != "sora" else "Google LLC" + assert mark in VIDEO_VISIBLE_MARKS + assert contradicts_video_provenance(mark, {"issuer": foreign, "source_type": ai}) + assert not contradicts_video_provenance(mark, {"issuer": own_issuer, "source_type": ai}) + assert not contradicts_video_provenance(mark, {"issuer": foreign}) + + @pytest.mark.parametrize("mark", ["veo", "seedance", "doubao", "dola", "hailuo", "vidu", "kling"]) + def test_opening_only_label_is_not_carried_over_the_clean_rest(self, mark: str): + # China's labeling rules require a video label only on the opening frames. A + # 2 s label at 24 fps used to be carried over every later, clean frame. + from remove_ai_watermarks.video_visible import ( + VISIBLE_MARK_POLICIES, + FrameLocalization, + stabilize_localizations, + ) + + policy = VISIBLE_MARK_POLICIES[mark] + score = max(policy.strong_floor, policy.weak_floor) + 0.05 + box = (1700, 980, 200, 60) + detections = [FrameLocalization(index, score, box) for index in range(48)] + detections += [FrameLocalization(index, 0.0, None) for index in range(48, 240)] + + accepted = stabilize_localizations(mark, detections) + + assert accepted[:48] == [box] * 48 + assert accepted[48 + policy.min_stable_frames + 1 :] == [None] * (240 - 48 - policy.min_stable_frames - 1) + def test_hailuo_provenance_accepts_sub_strong_stable_run(self): # A TC260 label naming MiniMax relaxes only the strong-frame requirement: # the entry bar stays the measured weak floor (0.30), so a stable run @@ -2197,18 +2311,19 @@ class TestVideoVisibleScan: "detect_seedance_frame", "detect_dola_frame", "detect_hailuo_frame", + "detect_vidu_frame", "detect_kling_frame", ): monkeypatch.setattr(video_visible, detector_name, fake_detector) scans = scan_video_marks( tmp_path / "synthetic.mp4", - ("sora", "veo", "seedance", "doubao", "dola", "hailuo", "kling"), + ("sora", "veo", "seedance", "doubao", "dola", "hailuo", "vidu", "kling"), ) - assert set(scans) == {"sora", "veo", "seedance", "doubao", "dola", "hailuo", "kling"} + assert set(scans) == {"sora", "veo", "seedance", "doubao", "dola", "hailuo", "vidu", "kling"} assert all(scan.timestamps == (0.25,) for scan in scans.values()) - assert len(prepared_ids) == 6 + assert len(prepared_ids) == 7 assert len(set(prepared_ids)) == 1 @@ -2694,7 +2809,7 @@ class TestVideoVisibleApi: ) def fake_scan(_source: Path, marks: tuple[str, ...]): - assert marks == ("sora", "veo", "seedance", "doubao", "dola", "hailuo", "kling") + assert marks == ("sora", "veo", "seedance", "doubao", "dola", "hailuo", "vidu", "kling") return { "sora": sora_scan, "veo": sora_scan, @@ -2914,7 +3029,7 @@ class TestVideoVisibleCli: assert result.exit_code == 0, result.output assert "temporally stable" in result.output - assert "auto|sora|veo|seedance|doubao|dola|hailuo|kling" in result.output + assert "auto|sora|veo|seedance|doubao|dola|hailuo|vidu|kling" in result.output assert "--temporal-consistency" in result.output def test_reports_removed_frames(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): @@ -2942,3 +3057,210 @@ class TestVideoVisibleCli: assert result.exit_code == 0, result.output assert "10/12 frames" in result.output assert "Audio watermark: UNVERIFIED" in result.output + + +class TestGeminiOmniRealSamples: + """Real Gemini Omni outputs: app export with the visible diamond, and API output without it.""" + + _DIR = Path(__file__).resolve().parents[1] / "data" / "fixtures" / "provenance" + + def test_app_export_carries_the_veo_diamond_and_google_synthid(self): + from remove_ai_watermarks.video import identify_video + + report = identify_video(self._DIR / "google-gemini-omni-app.mp4") + + assert report.visible_mark == "veo" + assert report.visible_detected_frames == report.total_frames == 240 + assert report.metadata_markers["c2pa_validation_state"] == "Valid" + assert report.metadata_markers["synthid_watermark"] == "present according to Google LLC provenance" + + def test_api_output_has_google_synthid_and_no_visible_mark(self): + from remove_ai_watermarks.video import identify_video + + report = identify_video(self._DIR / "google-gemini-omni-flash-api.mp4") + + assert report.visible_mark is None + assert report.visible_detected_frames == 0 + assert report.platform == "Google (Gemini / Imagen)" + assert report.metadata_markers["c2pa_validation_state"] == "Valid" + assert report.metadata_markers["synthid_watermark"] == "present according to Google LLC provenance" + + +class TestYouTubeReencodedSamples: + """YouTube re-signs every upload as Google LLC ("YouTube Video Processing Services"). + + Real Studio downloads (2026-09-24) of two uploads: an xAI Grok video, whose own + self-signed manifest the reader drops, and a Gemini Omni Flash video, whose + manifest survives as the ingredient with its SynthID action. + """ + + _DIR = Path(__file__).resolve().parents[1] / "data" / "fixtures" / "provenance" + + def test_reencoded_grok_is_not_read_as_google_ai(self): + from remove_ai_watermarks.video import identify_video + + report = identify_video(self._DIR / "youtube-reencode-grok.mp4", check_visible=False) + + assert report.is_ai_generated is None + assert report.confidence == "unknown" + assert report.platform is None + assert "synthid_watermark" not in report.metadata_markers + assert report.metadata_markers["actions"] == "opened, transcoded" + assert any("no AI claim" in caveat for caveat in report.caveats) + + def test_reencoded_gemini_keeps_its_ingredient_synthid(self): + from remove_ai_watermarks.video import identify_video + + report = identify_video(self._DIR / "youtube-reencode-gemini-omni-flash.mp4", check_visible=False) + + assert report.is_ai_generated is True + assert report.platform == "Google (Gemini / Imagen)" + assert report.metadata_markers["synthid_watermark"] == "present according to Google LLC provenance" + + def test_runway_gen45_video_is_attributed_to_runway(self): + from remove_ai_watermarks.video import identify_video + + report = identify_video(self._DIR / "runway-gen45-video.mp4", check_visible=False) + + assert report.is_ai_generated is True + assert report.platform == "Runway" + assert report.metadata_markers["issuer"] == "Runway" + assert "synthid_watermark" not in report.metadata_markers + + @pytest.mark.parametrize( + ("name", "platform"), + [ + ("higgsfield-kling-3-0-turbo.mp4", "Kuaishou Kling AI"), + ("higgsfield-happyhorse.mp4", "Alibaba Tongyi (Wan, HappyHorse)"), + ("higgsfield-minimax-h3.mp4", "MiniMax Hailuo AI"), + ("dreamina-seedance-2-mini.mp4", "ByteDance Dreamina"), + ], + ) + def test_aggregator_video_names_its_generator(self, name: str, platform: str): + from remove_ai_watermarks.video import identify_video + + report = identify_video(self._DIR / name, check_visible=False) + + assert report.is_ai_generated is True + assert report.platform == platform + + @staticmethod + def _frames(path: Path, indices: tuple[int, ...]): + import cv2 + + cap = cv2.VideoCapture(str(path)) + frames = {} + index = 0 + while len(frames) < len(indices): + ok, frame = cap.read() + if not ok: + break + if index in indices: + frames[index] = frame + index += 1 + cap.release() + assert sorted(frames) == sorted(indices) + return frames + + def test_kling_swirl_on_wood_short_of_the_edge_is_rejected(self): + # Higgsfield Cinema Studio v2 (2026-09-25): the swirl arm matched wood + # grain at 0.52-0.56 on these frames, 11% short of the right edge, and + # the run covered every frame. Its provenance is Kling, so no veto applies. + from remove_ai_watermarks.video_visible import _KLING_WEAK_CONFIDENCE, detect_kling_frame + + for index, frame in self._frames(self._DIR / "higgsfield-cinema-studio-v2.mp4", (0, 3, 4, 7)).items(): + assert detect_kling_frame(frame, frame_index=index).confidence < _KLING_WEAK_CONFIDENCE + + def test_hailuo_candidate_without_white_label_pixels_is_rejected(self): + # Hailuo 2.3 via Higgsfield (2026-09-25) scored 0.34-0.36 on wood grain, + # above the real label, on every frame; the box holds no white pixel. + from remove_ai_watermarks.video_visible import detect_hailuo_frame + + for index, frame in self._frames(self._DIR / "higgsfield-hailuo-2-3.mp4", (0, 1, 2, 3)).items(): + assert detect_hailuo_frame(frame, frame_index=index).region is None + + def test_drifting_kling_match_on_texture_is_not_a_run(self): + # Higgsfield Genjutsu motion control (2026-09-25): the wordmark arm matched + # wood grain at 0.22-0.32 on every frame, reaching the frame edge with white + # highlights, while its box drifted by a few pixels. A real Kling overlay + # keeps an identical box, so the run must stay anchored within 0.95 IoU. + from remove_ai_watermarks.video_visible import scan_video_marks, stabilize_localizations + + texture = scan_video_marks( + self._DIR / "higgsfield-genjutsu-motion-control.mp4", ("kling",), collect_timestamps=False + ) + real = scan_video_marks( + self._DIR.parent / "visible" / "kling" / "provider-original.mp4", ("kling",), collect_timestamps=False + ) + assert not any(stabilize_localizations("kling", texture["kling"].detections)) + assert all(stabilize_localizations("kling", real["kling"].detections)) + + def test_google_c2pa_vetoes_a_sora_match_on_steam(self): + # Gemini Omni 1.1 Flash via Runway (2026-09-25): a steam wisp scored 0.61-0.67 + # on the Sora template for five frames, a stable run above the strict floor, + # and the report named OpenAI Sora over the file's valid Google C2PA. + from remove_ai_watermarks.video import _visible_removal_plan, identify_video + from remove_ai_watermarks.video_visible import scan_video_marks, stabilize_localizations + + clip = self._DIR.parents[1] / "captures" / "2026-09" / "runway" / "gemini-omni-1-1-flash.mp4" + scan = scan_video_marks(clip, ("sora",), collect_timestamps=False)["sora"] + assert any(stabilize_localizations("sora", scan.detections)) + markers = identify_video(clip, check_visible=False).metadata_markers + assert not any(_visible_removal_plan("sora", scan, markers)[0]) + + def test_real_kling_and_hailuo_labels_still_pass_the_new_gates(self): + from remove_ai_watermarks.video_visible import ( + _HAILUO_WEAK_CONFIDENCE, + _KLING_WEAK_CONFIDENCE, + detect_hailuo_frame, + detect_kling_frame, + ) + + visible = self._DIR.parent / "visible" + kling = self._frames(visible / "kling" / "provider-original.mp4", (30,))[30] + hailuo = self._frames(visible / "hailuo" / "provider-original.mp4", (30,))[30] + assert detect_kling_frame(kling, frame_index=30).confidence >= _KLING_WEAK_CONFIDENCE + assert detect_hailuo_frame(hailuo, frame_index=30).confidence >= _HAILUO_WEAK_CONFIDENCE + + def test_veo_provenance_vetoes_a_kling_match(self): + # Veo 3.1 Lite via Higgsfield: the Kling detector matched wood texture on all + # 192 frames. Its Google C2PA must veto Kling; a real bare "kling" producer + # must not. + from remove_ai_watermarks.video import identify_video + from remove_ai_watermarks.video_visible import contradicts_video_provenance + + veo = identify_video(self._DIR / "higgsfield-veo-3-1-lite.mp4", check_visible=False) + kling = identify_video(self._DIR / "higgsfield-kling-3-0-turbo.mp4", check_visible=False) + + assert veo.platform == "Google (Gemini / Imagen)" + assert contradicts_video_provenance("kling", veo.metadata_markers) + assert not contradicts_video_provenance("kling", kling.metadata_markers) + + def test_original_grok_video_is_xai_without_synthid(self): + from remove_ai_watermarks.video import identify_video + + report = identify_video(self._DIR / "xai-grok-imagine-video.mp4", check_visible=False) + + assert report.is_ai_generated is True + assert report.platform == "xAI Grok Imagine" + assert "synthid_watermark" not in report.metadata_markers + + @pytest.mark.parametrize( + ("markers", "expected"), + [ + ({"c2pa_manifest": "store", "issuer": "Google LLC", "actions": "opened, transcoded"}, False), + ({"c2pa_manifest": "store", "issuer": "Google LLC", "source_type": "algorithmicMedia"}, False), + ({"c2pa_manifest": "store", "issuer": "Google LLC", "source_type": "trainedAlgorithmicMedia"}, True), + ( + {"c2pa_manifest": "store", "issuer": "TikTok", "source_type": "compositeWithTrainedAlgorithmicMedia"}, + True, + ), + ({"c2pa_manifest": "store", "issuer": "xAI Grok Imagine"}, True), + ({"c2pa_manifest": "store", "claim_generator": "Grok Imagine"}, True), + ({"c2pa_manifest": "store", "aigc_label": "China AIGC label (TC260)"}, True), + ], + ) + def test_only_an_ai_claim_makes_c2pa_markers_an_ai_signal(self, markers: dict[str, str], expected: bool): + from remove_ai_watermarks.video import _video_markers_claim_ai + + assert _video_markers_claim_ai(markers) is expected diff --git a/tests/test_visible_examples.py b/tests/test_visible_examples.py index b9498bf..6216b9f 100644 --- a/tests/test_visible_examples.py +++ b/tests/test_visible_examples.py @@ -32,6 +32,7 @@ _PROVIDER_ORIGINALS = { "kling": "provider-original-direct.png", "microsoft": "provider-original.png", "qwen": "provider-original.png", + "wan": "provider-original.png", "yuanbao": "provider-original.png", } _VIDEO_PROVIDER_ORIGINALS = { @@ -39,6 +40,7 @@ _VIDEO_PROVIDER_ORIGINALS = { "kling": "provider-original.mp4", "sora": "provider-original.mp4", "veo": "provider-original.mp4", + "vidu": "provider-original.mp4", } diff --git a/tests/test_visible_fill_quality.py b/tests/test_visible_fill_quality.py index 18188e1..0095c60 100644 --- a/tests/test_visible_fill_quality.py +++ b/tests/test_visible_fill_quality.py @@ -21,6 +21,7 @@ _ALTERNATE_SIZE = { "doubao": (1280, 960), "jimeng": (1280, 960), "qwen": (1280, 1280), + "wan": (1280, 1280), "kling": (1280, 960), "yuanbao": (1280, 960), "samsung": (2304, 1728), diff --git a/tests/test_watermark_registry.py b/tests/test_watermark_registry.py index 6dccf5f..311c7d6 100644 --- a/tests/test_watermark_registry.py +++ b/tests/test_watermark_registry.py @@ -19,6 +19,7 @@ class TestCatalog: "doubao", "jimeng", "qwen", + "wan", "kling", "yuanbao", "samsung", @@ -113,6 +114,7 @@ class TestScan: "doubao", "jimeng", "qwen", + "wan", "kling", "yuanbao", "samsung", diff --git a/uv.lock b/uv.lock index 9654540..17cb551 100644 --- a/uv.lock +++ b/uv.lock @@ -1066,7 +1066,7 @@ wheels = [ [package.optional-dependencies] http = [ - { name = "aiohttp", marker = "python_full_version < '3.13'" }, + { name = "aiohttp" }, ] [[package]] @@ -1461,15 +1461,15 @@ name = "lightning" version = "2.6.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fsspec", extra = ["http"], marker = "python_full_version < '3.13'" }, - { name = "lightning-utilities", marker = "python_full_version < '3.13'" }, - { name = "packaging", marker = "python_full_version < '3.13'" }, - { name = "pytorch-lightning", marker = "python_full_version < '3.13'" }, - { name = "pyyaml", marker = "python_full_version < '3.13'" }, - { name = "torch", marker = "python_full_version < '3.13'" }, - { name = "torchmetrics", marker = "python_full_version < '3.13'" }, - { name = "tqdm", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "fsspec", extra = ["http"] }, + { name = "lightning-utilities" }, + { name = "packaging" }, + { name = "pytorch-lightning" }, + { name = "pyyaml" }, + { name = "torch" }, + { name = "torchmetrics" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/39/b8/fbf1469d917c3ed3b22e00ab3a852fb9a5f85b9dc7f7060eedc3449098e7/lightning-2.6.6.tar.gz", hash = "sha256:1c6329e241f8a85f29fedead89c1ee138b36caa08de60c260c6a7eaee81c8295", size = 659902, upload-time = "2026-09-10T09:41:03.009Z" } wheels = [ @@ -1481,8 +1481,8 @@ name = "lightning-utilities" version = "0.15.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "packaging" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f1/45/7fa8f56b17dc0f0a41ec70dd307ecd6787254483549843bef4c30ab5adce/lightning_utilities-0.15.3.tar.gz", hash = "sha256:792ae0204c79f6859721ac7f386c237a33b0ed06ba775009cb894e010a842033", size = 33553, upload-time = "2026-02-22T14:48:53.348Z" } wheels = [ @@ -3179,14 +3179,14 @@ name = "pytorch-lightning" version = "2.6.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fsspec", extra = ["http"], marker = "python_full_version < '3.13'" }, - { name = "lightning-utilities", marker = "python_full_version < '3.13'" }, - { name = "packaging", marker = "python_full_version < '3.13'" }, - { name = "pyyaml", marker = "python_full_version < '3.13'" }, - { name = "torch", marker = "python_full_version < '3.13'" }, - { name = "torchmetrics", marker = "python_full_version < '3.13'" }, - { name = "tqdm", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "fsspec", extra = ["http"] }, + { name = "lightning-utilities" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "torch" }, + { name = "torchmetrics" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/97/e4/5025cb42311b4f8cc6d1b12cd81399ca433159f8e3831ab229c5a6c5177e/pytorch_lightning-2.6.6.tar.gz", hash = "sha256:52c52b20522dd3d3feb3bb3aae2dbe3f291e8e836d4111a74cfd72e9df018834", size = 662507, upload-time = "2026-09-10T09:41:16.684Z" } wheels = [ @@ -3480,7 +3480,7 @@ all = [ { name = "torch" }, { name = "torchvision" }, { name = "transformers" }, - { name = "trustmark", marker = "python_full_version < '3.13'" }, + { name = "trustmark" }, ] classify = [ { name = "huggingface-hub" }, @@ -3614,7 +3614,7 @@ text-restoration = [ { name = "transformers" }, ] trustmark = [ - { name = "trustmark", marker = "python_full_version < '3.13'" }, + { name = "trustmark" }, ] video = [ { name = "av" }, @@ -3691,7 +3691,7 @@ requires-dist = [ { name = "torchvision", marker = "extra == 'qwen-zimage'", specifier = ">=0.20.0" }, { name = "transformers", marker = "extra == 'classify'", specifier = ">=5,<6" }, { name = "transformers", marker = "extra == 'diffusion'", specifier = ">=5,<6" }, - { name = "trustmark", marker = "python_full_version < '3.13' and extra == 'trustmark'", specifier = ">=0.8.0" }, + { name = "trustmark", marker = "extra == 'trustmark'", specifier = ">=0.9.2" }, ] provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "text-restoration", "text-draft", "trustmark", "classify", "classify-onnx", "source-classify", "lama", "migan", "dev", "all"] @@ -4076,10 +4076,11 @@ name = "torchmetrics" version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "lightning-utilities", marker = "python_full_version < '3.13'" }, + { name = "lightning-utilities" }, { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "packaging", marker = "python_full_version < '3.13'" }, - { name = "torch", marker = "python_full_version < '3.13'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "packaging" }, + { name = "torch" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/34/39b8b749333db56c0585d7a11fa62a283c087bb1dfc897d69fb8cedbefb1/torchmetrics-1.9.0.tar.gz", hash = "sha256:a488609948600df52d3db4fcdab02e62aab2a85ef34da67037dc3e65b8512faa", size = 581765, upload-time = "2026-03-09T17:41:22.443Z" } wheels = [ @@ -4174,13 +4175,14 @@ name = "trustmark" version = "0.9.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "einops", marker = "python_full_version < '3.13'" }, - { name = "lightning", marker = "python_full_version < '3.13'" }, + { name = "einops" }, + { name = "lightning" }, { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "omegaconf", marker = "python_full_version < '3.13'" }, - { name = "six", marker = "python_full_version < '3.13'" }, - { name = "torch", marker = "python_full_version < '3.13'" }, - { name = "torchvision", marker = "python_full_version < '3.13'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "omegaconf" }, + { name = "six" }, + { name = "torch" }, + { name = "torchvision" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/41/32e84596af94e8696c55060d55eb9eed9d99722a673d12212a1c0c4ef1d2/trustmark-0.9.2.tar.gz", hash = "sha256:bcaa3bc38ead1c0558df81932672f34e306efaed31389da57dbed100e236b28a", size = 63780, upload-time = "2026-09-08T10:58:11.601Z" }