mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 23:50:40 +02:00
Add Hailuo, Kling, AVI, and FLV video coverage
This commit is contained in:
@@ -26,9 +26,9 @@ Per-command exit-code semantics (the no-signal / GPU-missing skip branches), tes
|
||||
- `uv run remove-ai-watermarks identify <image>` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip both registered visible detectors and the optional open invisible-watermark decoder
|
||||
- `uv run remove-ai-watermarks metadata <image.png> --check` — inspect AI metadata (C2PA, EXIF, PNG chunks)
|
||||
- `uv run remove-ai-watermarks metadata <image.png> --remove -o <out.png>` — strip all AI metadata
|
||||
- `uv run remove-ai-watermarks video metadata <input.mp4> --check` — inspect AI metadata in MP4/MOV/M4V/WebM/MKV
|
||||
- `uv run remove-ai-watermarks video metadata <input.mp4> --check` — inspect AI metadata in MP4/MOV/M4V/WebM/MKV/AVI/FLV
|
||||
- `uv run remove-ai-watermarks video metadata <input.mp4> --remove -o <clean.mp4>` — strip verified video metadata without transcoding streams; the experimental video path requires a separate same-container output and defaults to `<source>_clean`
|
||||
- `uv run remove-ai-watermarks video visible <input.mp4> -o <clean.mp4>` — remove a temporally recurring Sora mark, or pass `--mark veo|seedance|dola` for the current Veo diamond and legacy `Veo` text, the Seedance boxed `AI`, or `Dola AI`. It scans the full sequence first, transcodes video through ffmpeg, copies audio, strips AI metadata by default, and writes no output when no stable mark is found. `cv2` is the fast default; `migan`/`lama` improve difficult backgrounds.
|
||||
- `uv run remove-ai-watermarks video visible <input.mp4> -o <clean.mp4>` — remove a temporally recurring Sora mark, or pass `--mark veo|seedance|dola|hailuo|kling` for the current Veo diamond and legacy `Veo` text, the Seedance boxed `AI`, `Dola AI`, the MINIMAX/Hailuo composite, or the versioned Kling label. It scans the full sequence first, transcodes video through ffmpeg, copies audio, strips AI metadata by default, and writes no output when no stable mark is found. `cv2` is the fast default; `migan`/`lama` improve difficult backgrounds.
|
||||
- `uv run remove-ai-watermarks batch <directory>` — process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the full `invisible` knob set above, plus `--backend` and `--sensitivity` for the visible localize -> fill pass. Applies the same no-signal skip per image; see the module doc. **Exit code:** non-zero when any image errored OR (mirroring single `all`) a `--mode invisible`/`all` image carried an invisible signal but the GPU extra was absent, so its SynthID scrub was skipped — it emits a loud warning and copies the input through (invisible mode) so the output dir stays complete; a wrapping service can then detect the incomplete run instead of trusting a silent exit 0.
|
||||
|
||||
## Test and lint
|
||||
@@ -82,7 +82,7 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal
|
||||
- `upscaler.py` — optional Real-ESRGAN pre-diffusion super-resolution for small inputs (extra `esrgan`, spandrel only). Manual opt-in; the default `--upscaler` stays `lanczos` and the engine always falls back to Lanczos on absence/error. ESRGAN can degrade faces and thin text.
|
||||
- `image_io.py` — centralizes Unicode-safe image IO, alpha preservation, content-based format sniffing, and HEIC/AVIF fallbacks. Callers must check `imwrite` success. No-op visible removal preserves original bytes when the output format is unchanged.
|
||||
- `api.py` — the high-level convenience API, re-exported lazily at the package top level via `__init__.__getattr__` (PEP 562, so `import remove_ai_watermarks` stays cheap): `remove_visible(source, output=None, *, sensitivity="auto", backend="auto", strip_metadata=True, write_noop=True) -> (result_bgr, [labels])` (source = path OR BGR ndarray; a PATH auto-reads metadata provenance and preserves alpha, an ARRAY does neither; `write_noop=True` writes a clean passthrough copy when nothing is removed, `False` leaves `output` untouched so a "no mark = produce nothing" caller like the CLI `visible` command does not clobber a pre-existing file there) and `visible_provenance(path) -> frozenset[str]` (the single metadata→vendor-keys mapper; `cli._visible_provenance` is a thin None-guarded wrapper over it). **`remove_visible` is the ONE path the CLI and library share** — `cli.cmd_visible`'s `--mark auto` branch delegates entirely to it (read → provenance → `remove_auto_marks` → write → `strip_metadata`), so there is no CLI-vs-library drift; `strip_metadata` defaults True to match `visible --strip-metadata`. This is where a library caller should start — NOT the engines directly (`GeminiEngine`/`TextMarkEngine` have no `remove_watermark` any more; removal is registry `remove_auto_marks`/`KnownMark.remove`; the old single-strongest `best_auto_mark` is gone — removal takes EVERY mark). `identify` is NOT top-level re-exported (it collides with the `identify` submodule); use `from remove_ai_watermarks.identify import identify`.
|
||||
- `video.py` — the experimental high-level video API, also lazy at the package root: `inspect_video_metadata(source) -> VideoMetadataReport`, `remove_video_metadata(source, output=None, *, keep_standard=True) -> VideoMetadataResult`, `remove_video_visible(source, output=None, *, mark="sora", backend="cv2", strip_metadata=True) -> VideoVisibleResult`, and `remove_video_invisible(source, output=None, ...) -> VideoInvisibleResult`. It validates the extension and container signature for MP4/MOV/M4V/WebM/MKV and requires a distinct same-container output. The metadata path never transcodes streams: native MP4/MOV TC260 is read from `moov.udta.meta.keys/ilst`, including a tail `moov` after a large `mdat`, and removal blanks the key/value in place; MKV/WebM TC260 is read by `noai/ebml.py` and stripped through ffmpeg stream copy. The visible path delegates to `video_visible.py`: fully synthetic Sora, Veo, Seedance, and Dola silhouettes propose frame boxes, provider-specific temporal recurrence authorizes them, the shared fill backends remove accepted masks in a second pass, and ffmpeg transcodes video while copying audio. Fixed marks require anchored runs so a slowly drifting scene detail cannot pass on adjacent overlap alone. Veo covers the current four-point diamond and legacy text; its diamond uses a shape mask rather than erasing the transparent corners of a full box. Seedance uses the full localized box because an outline mask left part of the real translucent border behind. Metadata can relax a recurring low-contrast match but cannot create one. The inherited ISOBMFF metadata removal path still reads the complete container into memory, so a streaming box copier is required before large-video use. Other visible video labels are not built yet.
|
||||
- `video.py` — the experimental high-level video API, also lazy at the package root: `inspect_video_metadata(source) -> VideoMetadataReport`, `remove_video_metadata(source, output=None, *, keep_standard=True) -> VideoMetadataResult`, `remove_video_visible(source, output=None, *, mark="sora", backend="cv2", strip_metadata=True) -> VideoVisibleResult`, and `remove_video_invisible(source, output=None, ...) -> VideoInvisibleResult`. It validates the extension and container signature for MP4/MOV/M4V/WebM/MKV/AVI/FLV and requires a distinct same-container output. The metadata path never transcodes streams: native MP4/MOV TC260 is read from `moov.udta.meta.keys/ilst`, including a tail `moov` after a large `mdat`, and removal blanks the key/value in place; MKV/WebM, AVI, and FLV TC260 are read by bounded container walkers that skip media payloads and stripped through ffmpeg stream copy. The visible path delegates to `video_visible.py`: fully synthetic Sora, Veo, Seedance, Dola, Hailuo, and Kling silhouettes propose frame boxes, provider-specific temporal recurrence authorizes them, the shared fill backends remove accepted masks in a second pass, and ffmpeg transcodes video while copying audio. Fixed marks require anchored runs so a slowly drifting scene detail cannot pass on adjacent overlap alone. Veo covers the current four-point diamond and legacy text; its diamond uses a shape mask rather than erasing the transparent corners of a full box. Seedance uses the full localized box because an outline mask left part of the real translucent border behind. Hailuo expands its matched core to the full composite, while Kling combines font and swirl candidates and requires a bright low-saturation edge label. Metadata can relax a recurring low-contrast match but cannot create one. The inherited ISOBMFF metadata removal path still reads the complete container into memory, so a streaming box copier is required before large-video use. Other visible video labels are not built yet.
|
||||
- `video_encoding.py` — the shared raw-BGR ffmpeg command and pipe lifecycle for both visible removal and invisible regeneration. It copies optional audio, controls metadata/chapter retention explicitly, and keeps container-specific codec arguments in one place.
|
||||
- `video_invisible.py` — the oracle-gated video SynthID candidate engine for MP4/MOV/M4V. It regenerates frames through `stabilityai/sd-vae-ft-mse` with one seeded latent-noise field shared across time, retains only one configured batch, updates PSNR and motion-compensated temporal residuals incrementally, streams pixels directly to ffmpeg, copies audio, strips metadata, and atomically publishes the completed output. Every result remains explicitly unverified because Google exposes no local decoder. The 2026-07-29 Gemini Flash calibration found the default `0.10` candidate negative on both control-positive public Veo clips, while `0.05` remained positive on one, so the operating point is content-dependent and requires the matching external oracle.
|
||||
|
||||
@@ -102,7 +102,7 @@ Compact list. Full measurements, incident history, and oracle-validation runs li
|
||||
- Pyright first run is slow (2-3 min) due to ML deps (torch/diffusers/transformers stubs); full-project `uv run pyright` can stall for many minutes — scope it to changed files.
|
||||
- A third-party PIL plugin autoload (e.g. an HEIF/AVIF plugin) can raise a non-OSError (`ModuleNotFoundError`), not `UnidentifiedImageError`, when opening a file. Code that opens user-supplied or unknown-format files should `except Exception`, not just `OSError`/`UnidentifiedImageError`.
|
||||
- rich was dropped: the CLI + analysis scripts print plain text (`click.echo` / the `scripts/_plain_console.py` shim). `rich` is NOT a dependency — importing it breaks the core+dev CI sync; new scripts must use the shim. No Unicode glyphs / colors / progress bars in CLI output by design.
|
||||
- HEIC/AVIF are decodable on BOTH paths now: the pixel/removal path via the `image_io.imread` Pillow fallback (+ core `pillow-heif`), and metadata detection via a plugin-free binary scan. C2PA removal in those containers (and MP4/MOV/M4V) is `noai/isobmff.py`; JPEG-XL stays metadata/strip-only (Pillow can't decode it without `pillow-jxl`, not a dep). Non-ISOBMFF audio/video (WebM/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. On the ISOBMFF path `remove_ai_metadata` routes to the container branch and never runs the JPEG `_scrub_ai_exif`, so `isobmff.blank_ai_exif_tokens` is the ONLY EXIF scrubber there and must stay in PARITY with it: it blanks **in place** (same-length space overwrite, piexif-validated so a coincidental II/MM run in pixels is ignored — no `iinf`/`iloc` surgery, mirrors `blank_ai_xmp_packets`) an AI-generator token in `Software`/`Make`/`Artist`/`ImageDescription`, the China TC260 `{"AIGC":{...}}` block in `ImageDescription`/`UserComment` (via `_is_aigc_exif_value`), AND the xAI/Grok `Signature:` + UUID-`Artist` pair — leaving camera/editor EXIF intact. Still NOT built: Resemble PerTh audio detection (no presence/confidence flag exists).
|
||||
- HEIC/AVIF are decodable on BOTH paths now: the pixel/removal path via the `image_io.imread` Pillow fallback (+ core `pillow-heif`), and metadata detection via a plugin-free binary scan. C2PA removal in those containers (and MP4/MOV/M4V) is `noai/isobmff.py`; JPEG-XL stays metadata/strip-only (Pillow can't decode it without `pillow-jxl`, not a dep). Non-ISOBMFF audio/video (WebM/MKV/AVI/FLV/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. On the ISOBMFF path `remove_ai_metadata` routes to the container branch and never runs the JPEG `_scrub_ai_exif`, so `isobmff.blank_ai_exif_tokens` is the ONLY EXIF scrubber there and must stay in PARITY with it: it blanks **in place** (same-length space overwrite, piexif-validated so a coincidental II/MM run in pixels is ignored — no `iinf`/`iloc` surgery, mirrors `blank_ai_xmp_packets`) an AI-generator token in `Software`/`Make`/`Artist`/`ImageDescription`, the China TC260 `{"AIGC":{...}}` block in `ImageDescription`/`UserComment` (via `_is_aigc_exif_value`), AND the xAI/Grok `Signature:` + UUID-`Artist` pair — leaving camera/editor EXIF intact. Still NOT built: Resemble PerTh audio detection (no presence/confidence flag exists).
|
||||
- **SynthID technical reference: `docs/synthid.md`** — primary-source-cited doc covering mechanism (post-hoc encoder/decoder pair, 136-bit payload at 512x512, pixel-space, model weights NOT modified), robustness numbers (arXiv:2510.09263: ~99.98% TPR@0.1%FPR across 30 transforms including JPEG/crop/resize/color/noise), removal attacks and forensic detectability (arXiv:2605.09203: all 6 attacks detectable at >98% TPR@1%FPR), detectability limits (no public decoder, metadata-proxy only), oracle scope, and adoption landscape. Read that doc first before adding notes here.
|
||||
- **SynthID detection is metadata-only.** No local pixel detector is possible by design (Google's decoder is proprietary, trusted-testers only); we read the C2PA companion proxy, which goes quiet once metadata is stripped — a quiet proxy is not proof the pixel watermark is gone. Each vendor has its OWN oracle and it detects only that vendor's content: the Gemini app "Verify with SynthID" for Google, `openai.com/verify` for OpenAI. **Validate the OpenAI arm FIRST** — `openai.com/verify` is more accessible (fewer per-check restrictions) and the strongest automation candidate (Playwright / Chrome MCP); the Gemini flow is more manual. Ordering/throughput choice, not a substitution (see `docs/synthid.md`). SynthID survives JPEG re-encode, so GitHub issue attachments remain valid pixel-watermark test subjects. Every spectral/phase detection approach evaluated (reverse-SynthID, our own probes) works only on controlled solid fills, never on real content.
|
||||
- **External AI-vs-real classifier models are out of scope** (decided 2026-05-24): per-generator, degrade off-distribution, and our own light SDXL pass would likely defeat them. Detection stays local + signal-based.
|
||||
|
||||
@@ -7,8 +7,8 @@ Remove AI provenance marks from images and video you generated yourself:
|
||||
- C2PA, EXIF, XMP, IPTC, and related AI metadata.
|
||||
|
||||
Video support covers metadata inspection and removal, visible Sora, Veo,
|
||||
Seedance, and Dola mark removal, and experimental VAE regeneration that
|
||||
produces a video SynthID candidate for external verification.
|
||||
Seedance, Dola, Hailuo, and Kling mark removal, and experimental VAE
|
||||
regeneration that produces a video SynthID candidate for external verification.
|
||||
|
||||
> Try it online at [raiw.cc](https://raiw.cc) if you do not want to install Python
|
||||
> or run diffusion models locally.
|
||||
@@ -33,7 +33,7 @@ produces a video SynthID candidate for external verification.
|
||||
| Erase a region you select | `erase` | No |
|
||||
| Strip AI metadata | `metadata` | No |
|
||||
| Strip AI metadata from video | `video metadata` | No |
|
||||
| Remove a known Sora, Veo, Seedance, or Dola mark from video | `video visible` | No |
|
||||
| Remove a registered visible AI mark from video | `video visible` | No |
|
||||
| Generate an externally verifiable video SynthID candidate | `video invisible` | Recommended |
|
||||
| Regenerate an image to disrupt invisible watermarks | `invisible` | Recommended |
|
||||
| Run visible, invisible, and metadata removal | `all` | Recommended |
|
||||
@@ -65,7 +65,8 @@ Strip metadata without running visible inpainting or diffusion:
|
||||
remove-ai-watermarks metadata image.png --remove -o clean.png
|
||||
```
|
||||
|
||||
Inspect or remove AI metadata from an MP4, MOV, M4V, WebM, or MKV file:
|
||||
Inspect or remove AI metadata from an MP4, MOV, M4V, WebM, MKV, AVI, or FLV
|
||||
file:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks video metadata input.mp4 --check
|
||||
@@ -77,7 +78,9 @@ omitted it writes `<source>_clean` and preserves the original. MP4 and MOV
|
||||
inspection includes the native TC260 `AIGC` tag in
|
||||
`moov.udta.meta.keys/ilst`, including a `moov` placed after the media payload.
|
||||
MKV and WebM inspection reads the normative
|
||||
`Segment.Tags.Tag.SimpleTag` placement.
|
||||
`Segment.Tags.Tag.SimpleTag` placement. AVI uses `LIST/INFO/AIGC`, while FLV
|
||||
uses `script.onMetaData.AIGC`. The non-ISOBMFF formats are remuxed with stream
|
||||
copy for removal.
|
||||
|
||||
Remove a supported visible video mark:
|
||||
|
||||
@@ -86,6 +89,8 @@ remove-ai-watermarks video visible input.mp4 -o clean.mp4
|
||||
remove-ai-watermarks video visible veo.mp4 --mark veo -o veo_clean.mp4
|
||||
remove-ai-watermarks video visible seedance.mp4 --mark seedance -o seedance_clean.mp4
|
||||
remove-ai-watermarks video visible dola.mp4 --mark dola -o dola_clean.mp4
|
||||
remove-ai-watermarks video visible hailuo.mp4 --mark hailuo -o hailuo_clean.mp4
|
||||
remove-ai-watermarks video visible kling.mp4 --mark kling -o kling_clean.mp4
|
||||
```
|
||||
|
||||
This path scans the complete sequence before changing pixels. It accepts only a
|
||||
@@ -94,8 +99,10 @@ same OpenCV, MI-GAN, or LaMa fill backends as image removal. Audio is copied
|
||||
without re-encoding; the video stream is transcoded because its pixels change.
|
||||
Sora covers the moving Sora 2 mascot and wordmark. Veo covers both the current
|
||||
four-point diamond and the legacy `Veo` text. Seedance covers the fixed boxed
|
||||
`AI` label, and Dola covers the fixed `Dola AI` text. No output is written when
|
||||
no stable mark is found.
|
||||
`AI` label, Dola covers the fixed `Dola AI` text, Hailuo covers the composite
|
||||
`MINIMAX | hailuo AI` label, and Kling covers the bottom-right `KLING AI`
|
||||
label with its version suffix. No output is written when no stable mark is
|
||||
found.
|
||||
|
||||
Generate a video SynthID candidate:
|
||||
|
||||
@@ -293,7 +300,8 @@ invisible removal.
|
||||
detail.
|
||||
- Visible video removal recognizes the moving Sora 2 wordmark, the current Veo
|
||||
diamond plus legacy `Veo` text, the Seedance boxed `AI` label, and the fixed
|
||||
`Dola AI` text. It does not recognize the older Sora Turbo corner swirl.
|
||||
Dola, Hailuo, and Kling 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.
|
||||
- Video SynthID regeneration changes resolution, frame rate, and image detail.
|
||||
|
||||
+24
-16
@@ -134,17 +134,20 @@ remove-ai-watermarks video metadata input.mp4 --check
|
||||
remove-ai-watermarks video metadata input.mp4 --remove -o clean.mp4
|
||||
```
|
||||
|
||||
Supported containers are MP4, MOV, M4V, WebM, and MKV. The operation delegates
|
||||
to the same verified metadata scanner and stripper as the generic `metadata`
|
||||
command, so detection and removal stay in parity. Video and audio streams are
|
||||
not transcoded. For MP4 and MOV, this includes the native TC260 `AIGC` key and
|
||||
JSON value stored in `moov.udta.meta.keys/ilst`. The inspector seeks past a
|
||||
large `mdat` to find a tail `moov`; removal blanks the key and value in place so
|
||||
box sizes and media offsets do not move.
|
||||
Supported containers are MP4, MOV, M4V, WebM, MKV, AVI, and FLV. The operation
|
||||
delegates to the same verified metadata scanner and stripper as the generic
|
||||
`metadata` command, so detection and removal stay in parity. Video and audio
|
||||
streams are not transcoded. For MP4 and MOV, this includes the native TC260
|
||||
`AIGC` key and JSON value stored in `moov.udta.meta.keys/ilst`. The inspector
|
||||
seeks past a large `mdat` to find a tail `moov`; removal blanks the key and
|
||||
value in place so box sizes and media offsets do not move.
|
||||
|
||||
For MKV and WebM, the inspector reads the native TC260
|
||||
`Segment.Tags.Tag.SimpleTag` entry. Removal uses ffmpeg stream copying to
|
||||
discard container tags and chapters without transcoding the streams.
|
||||
AVI uses the normative `LIST/INFO/AIGC` chunk, while FLV uses the
|
||||
`script.onMetaData.AIGC` AMF0 string. Their bounded readers skip media payloads,
|
||||
and removal also uses ffmpeg stream copying.
|
||||
|
||||
When `-o` is omitted, the command writes `<source>_clean` with the same
|
||||
extension. It never overwrites the source, and it rejects an output with a
|
||||
@@ -192,22 +195,27 @@ remove-ai-watermarks video visible input.mp4 -o clean.mp4
|
||||
remove-ai-watermarks video visible veo.mp4 --mark veo -o veo_clean.mp4
|
||||
remove-ai-watermarks video visible seedance.mp4 --mark seedance -o seedance_clean.mp4
|
||||
remove-ai-watermarks video visible dola.mp4 --mark dola -o dola_clean.mp4
|
||||
remove-ai-watermarks video visible hailuo.mp4 --mark hailuo -o hailuo_clean.mp4
|
||||
remove-ai-watermarks video visible kling.mp4 --mark kling -o kling_clean.mp4
|
||||
```
|
||||
|
||||
The experimental command supports the moving Sora mascot and wordmark, two Veo
|
||||
corner variants, the Seedance boxed `AI` label, and the `Dola AI` text label.
|
||||
Sora searches the whole frame at multiple scales. The other detectors search
|
||||
bounded bottom-right regions with separate synthetic silhouettes. Every mark
|
||||
corner variants, the Seedance boxed `AI` label, the `Dola AI` text label, the
|
||||
composite `MINIMAX | hailuo AI` label, and the bottom-right Kling label. Sora
|
||||
searches the whole frame at multiple scales. The other detectors search bounded
|
||||
lower-frame regions with separate synthetic silhouettes. Kling additionally
|
||||
requires its bright low-saturation label near the frame edge. Every mark
|
||||
requires a spatially recurring candidate across adjacent frames. Fixed marks
|
||||
must also remain anchored instead of drifting with a scene object. Matching
|
||||
provider provenance may relax the visual score, but metadata alone never
|
||||
creates a detection. Clean API exports therefore remain untouched.
|
||||
provider provenance may relax the visual score only for registered
|
||||
provenance-aware marks; metadata alone never creates a detection.
|
||||
|
||||
The video stream is transcoded and the original audio stream is copied.
|
||||
Supported input and output containers are MP4, MOV, M4V, WebM, and MKV; the
|
||||
output extension must match the input. The default `cv2` backend is fast but can
|
||||
smear structured backgrounds. Select `--backend migan` or `--backend lama` for
|
||||
a learned fill, or `--backend auto` to choose the best installed backend.
|
||||
Supported input and output containers are MP4, MOV, M4V, WebM, MKV, AVI, and
|
||||
FLV; the output extension must match the input. The default `cv2` backend is
|
||||
fast but can smear structured backgrounds. Select `--backend migan` or
|
||||
`--backend lama` for a learned fill, or `--backend auto` to choose the best
|
||||
installed backend.
|
||||
|
||||
AI metadata is stripped from the encoded output by default. Use
|
||||
`--keep-metadata` to retain mapped container metadata. When no temporally stable
|
||||
|
||||
+18
-10
@@ -207,17 +207,21 @@ strip supported AI provenance metadata without transcoding streams.
|
||||
|
||||
`video visible` and `remove_video_visible` additionally support the moving
|
||||
Sora 2 mascot and wordmark, the current Veo four-point diamond, the legacy
|
||||
`Veo` text, the Seedance boxed `AI` label, and the fixed `Dola AI` text.
|
||||
Detection requires a recurring visual candidate across adjacent frames.
|
||||
Seedance, Dola, and Veo candidates must remain anchored rather than drifting
|
||||
with a scene object. Provider provenance can recover low-contrast runs only
|
||||
after visual evidence exists, so metadata alone does not erase a clean API
|
||||
export.
|
||||
`Veo` text, the Seedance boxed `AI` label, the fixed `Dola AI` text, the Hailuo
|
||||
MINIMAX/Hailuo composite label, and the bottom-right Kling label with its
|
||||
version suffix. Detection requires a recurring visual candidate across
|
||||
adjacent frames. Fixed-mark candidates must remain anchored rather than
|
||||
drifting with a scene object. Kling also requires a bright low-saturation
|
||||
candidate near the expected frame edge. Provider provenance can recover
|
||||
low-contrast runs only after visual evidence exists for the marks that define a
|
||||
provenance prior, so metadata alone does not erase a clean API export.
|
||||
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. Other provider video labels are not
|
||||
supported yet. Google video SynthID has a candidate-producing VAE path, while
|
||||
other proprietary invisible video watermarks have no registered attack.
|
||||
detected by the `sora` video mark. Hailuo and Kling 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
|
||||
SynthID has a candidate-producing VAE path, while other proprietary invisible
|
||||
video watermarks have no registered attack.
|
||||
|
||||
Visible removal transcodes the video stream and copies audio. Its frame-local
|
||||
fill is not a motion-aware video inpainting model. OpenCV can leave a visible
|
||||
@@ -235,7 +239,11 @@ Native TC260 metadata in MP4/MOV is supported at its normative
|
||||
`moov.udta.meta.keys/ilst` placement, including non-faststart files whose
|
||||
`moov` follows a large media payload. MKV/WebM is supported at the normative
|
||||
`Segment.Tags.Tag.SimpleTag` placement and uses ffmpeg for stream-copy removal.
|
||||
The corresponding FLV and AVI native tags are not parsed yet.
|
||||
AVI is supported at `LIST/INFO/AIGC`, and FLV at
|
||||
`script.onMetaData.AIGC`; both use ffmpeg stream-copy removal. Stock ffmpeg can
|
||||
write and strip the FLV form, but writing the nonstandard AVI child for fixture
|
||||
generation requires dedicated muxer support, so the AVI reader is verified
|
||||
against an exact synthetic RIFF structure.
|
||||
|
||||
The current ISOBMFF stripper reads an MP4, MOV, or M4V container into memory
|
||||
before rewriting its metadata boxes. A streaming box copier is required before
|
||||
|
||||
+41
-21
@@ -115,6 +115,13 @@ only a `Segment.Tags.Tag.SimpleTag` pairing `TagName=AIGC` with a JSON
|
||||
`TagString` carrying a TC260 field. The existing ffmpeg stream-copy path removes
|
||||
those container tags without transcoding the encoded streams.
|
||||
|
||||
[`noai/riff.py`](../src/remove_ai_watermarks/noai/riff.py) and
|
||||
[`noai/flv.py`](../src/remove_ai_watermarks/noai/flv.py) implement the remaining
|
||||
normative TC260 video placements. The RIFF walker reads only AVI
|
||||
`LIST/INFO/AIGC` children. The FLV walker skips media tags and parses the AMF0
|
||||
`script.onMetaData.AIGC` string. Both require a recognized TC260 JSON field and
|
||||
use the verified ffmpeg stream-copy path for removal.
|
||||
|
||||
[`video_encoding.py`](../src/remove_ai_watermarks/video_encoding.py) owns the
|
||||
raw-BGR ffmpeg command and pipe lifecycle shared by visible removal and
|
||||
invisible regeneration. It centralizes container codecs, optional audio stream
|
||||
@@ -140,34 +147,44 @@ helpers to build a matched control and candidate grid, preventing research and
|
||||
shipped regeneration paths from drifting.
|
||||
|
||||
[`video_visible.py`](../src/remove_ai_watermarks/video_visible.py) implements
|
||||
the first pixel stages for Sora, Veo, Seedance, and Dola. 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. All fixed-mark searches are bounded to the bottom-right area and
|
||||
calibrated independently. A strong relocated Veo diamond may bypass the known
|
||||
layout anchors, but weak free-corner matches never enter the temporal arbiter.
|
||||
the first pixel stages for Sora, Veo, Seedance, Dola, Hailuo, and Kling. 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 uses a synthetic waveform,
|
||||
MINIMAX/Hailuo text, separator, and ring. Kling combines synthetic font
|
||||
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
|
||||
expected lower-frame area and calibrated independently. A strong relocated Veo
|
||||
diamond may bypass the known layout anchors, but weak free-corner matches never
|
||||
enter the temporal arbiter.
|
||||
|
||||
Every per-frame result is untrusted. The provider-specific stabilization
|
||||
wrappers share one recurrence implementation, while retaining separate visual
|
||||
floors and minimum-run policy. Provenance can relax a low-contrast run only
|
||||
after recurring visual evidence exists. Sora transition frames follow the
|
||||
nearest confirmed moving position only with Sora provenance. Veo, Seedance,
|
||||
and Dola additionally require candidates to remain anchored to the start of a
|
||||
run. This rejects slowly drifting scene details that still have high
|
||||
frame-to-frame overlap.
|
||||
Dola, Hailuo, and Kling additionally require candidates to remain anchored to
|
||||
the start of a run. This rejects slowly drifting scene details that still have
|
||||
high frame-to-frame overlap. Hailuo and Kling do not infer provenance from
|
||||
technical encoder tags; their confirmed public samples carried no provider
|
||||
metadata.
|
||||
|
||||
Removal runs in a second decode pass. Sora, legacy Veo text, Dola text, and the
|
||||
Seedance box use box masks. Seedance deliberately fills the complete localized
|
||||
box: a synthetic outline mask passed repeat detection but left part of the real
|
||||
translucent border visible during visual end-to-end review. The square Veo
|
||||
diamond uses a synthetic shape mask so transparent corners do not erase
|
||||
unrelated pixels. Every mask goes through the shared `watermark_registry.fill`
|
||||
backends. ffmpeg encodes the changed video stream and copies optional audio.
|
||||
The default OpenCV fill is the speed floor; structured backgrounds need MI-GAN
|
||||
or LaMa for better reconstruction. Invisible video stages must continue to
|
||||
reuse the image and metadata implementations rather than copying their logic.
|
||||
Removal runs in a second decode pass. Sora, legacy Veo text, Dola text,
|
||||
Seedance, Hailuo, and Kling use box masks. Seedance deliberately fills the
|
||||
complete localized box: a synthetic outline mask passed repeat detection but
|
||||
left part of the real translucent border visible during visual end-to-end
|
||||
review. Hailuo expands beyond the matched core to cover both provider icons.
|
||||
Kling expands around the wordmark or swirl to include the version and optional
|
||||
`PRO` suffix. The square Veo diamond uses a synthetic shape mask so transparent
|
||||
corners do not erase unrelated pixels. Every mask goes through the shared
|
||||
`watermark_registry.fill` backends. ffmpeg encodes the changed video stream and
|
||||
copies optional audio. The default OpenCV fill is the speed floor; structured
|
||||
backgrounds need MI-GAN or LaMa for better reconstruction. Invisible video
|
||||
stages must continue to reuse the image and metadata implementations rather
|
||||
than copying their logic.
|
||||
|
||||
The inherited ISOBMFF metadata path currently reads the complete container into
|
||||
memory; replacing that with a streaming box copier is a prerequisite for large
|
||||
@@ -205,6 +222,9 @@ Key contracts:
|
||||
`moov.udta.meta.keys/ilst` and blanked without changing box sizes.
|
||||
- Native MKV/WebM TC260 `AIGC` entries are read from
|
||||
`Segment.Tags.Tag.SimpleTag` and removed through the ffmpeg stream-copy path.
|
||||
- Native AVI and FLV TC260 entries are read from `LIST/INFO/AIGC` and
|
||||
`script.onMetaData.AIGC`, respectively, then removed through ffmpeg stream
|
||||
copying.
|
||||
- Supported non-ISOBMFF audio and video containers use ffmpeg stream copying.
|
||||
- The low-level remover is fail-safe and can copy an undecodable file through
|
||||
unchanged.
|
||||
|
||||
+21
-7
@@ -130,7 +130,8 @@ as proof that metadata was removed.
|
||||
|
||||
## Inspect and strip video metadata
|
||||
|
||||
The experimental high level video API supports MP4, MOV, M4V, WebM, and MKV:
|
||||
The experimental high level video API supports MP4, MOV, M4V, WebM, MKV, AVI,
|
||||
and FLV:
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
@@ -152,7 +153,9 @@ inspection recognizes the native TC260 `AIGC` entry in
|
||||
`moov.udta.meta.keys/ilst`; its removal preserves container size and encoded
|
||||
stream bytes. MKV/WebM inspection recognizes the corresponding
|
||||
`Segment.Tags.Tag.SimpleTag` representation; its removal requires ffmpeg for a
|
||||
stream-copy remux.
|
||||
stream-copy remux. AVI inspection reads `LIST/INFO/AIGC`, and FLV inspection
|
||||
reads `script.onMetaData.AIGC`; both use the same verified ffmpeg stream-copy
|
||||
removal path.
|
||||
|
||||
## Generate a video SynthID candidate
|
||||
|
||||
@@ -211,16 +214,27 @@ dola_result = raiw.remove_video_visible(
|
||||
"dola_clean.mp4",
|
||||
mark="dola",
|
||||
)
|
||||
hailuo_result = raiw.remove_video_visible(
|
||||
"hailuo.mp4",
|
||||
"hailuo_clean.mp4",
|
||||
mark="hailuo",
|
||||
)
|
||||
kling_result = raiw.remove_video_visible(
|
||||
"kling.mp4",
|
||||
"kling_clean.mp4",
|
||||
mark="kling",
|
||||
)
|
||||
```
|
||||
|
||||
`remove_video_visible` scans the complete video before writing output. It
|
||||
combines synthetic multi-scale visual matching with temporal consistency, so an
|
||||
isolated lookalike in one frame is not enough to authorize inpainting. The
|
||||
supported `mark` values are `sora`, `veo`, `seedance`, and `dola`. The Veo
|
||||
detector recognizes the current four-point diamond and the legacy `Veo` text.
|
||||
Seedance recognizes the boxed `AI` label, while Dola recognizes its compact
|
||||
text label. Each variant has an independent synthetic silhouette and calibrated
|
||||
temporal policy.
|
||||
supported `mark` values are `sora`, `veo`, `seedance`, `dola`, `hailuo`, and
|
||||
`kling`. The Veo detector recognizes the current four-point diamond and the
|
||||
legacy `Veo` text. Seedance recognizes the boxed `AI` label, Dola recognizes
|
||||
its compact text label, Hailuo recognizes the composite MINIMAX/Hailuo label,
|
||||
and Kling recognizes its bottom-right logo, wordmark, and version suffix. Each
|
||||
variant has an independent synthetic silhouette and calibrated temporal policy.
|
||||
|
||||
The returned `VideoVisibleResult` records the total, detected, and removed frame
|
||||
counts plus any AI metadata that survived the output encode. The function
|
||||
|
||||
@@ -37,6 +37,8 @@ when you can select the affected area yourself.
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `kling` | Kling swirl, `KLING AI`, version, and optional `PRO` suffix | Fixed bottom-right edge | Combines a synthetic logo rescue with font variants, an edge gate, a white-label gate, and anchored temporal recurrence. |
|
||||
|
||||
Use `video visible` for this registry. It is separate from the image `visible`
|
||||
command because selection is made over a sequence rather than one raster.
|
||||
@@ -61,8 +63,9 @@ The inspection and stripping code handles signals in these groups:
|
||||
- IPTC AI disclosure fields;
|
||||
- PNG text chunks and embedded generation parameters;
|
||||
- China TC260 AIGC labels in supported image placements and the normative
|
||||
MP4/MOV `moov.udta.meta.keys/ilst` and MKV/WebM
|
||||
`Segment.Tags.Tag.SimpleTag` placements;
|
||||
MP4/MOV `moov.udta.meta.keys/ilst`, MKV/WebM
|
||||
`Segment.Tags.Tag.SimpleTag`, AVI `LIST/INFO/AIGC`, and FLV
|
||||
`script.onMetaData.AIGC` placements;
|
||||
- xAI and Grok EXIF signature fields;
|
||||
- Samsung AI editing markers;
|
||||
- Hugging Face job metadata;
|
||||
@@ -87,8 +90,8 @@ Metadata inspection and removal additionally have container paths for:
|
||||
|
||||
- JPEG XL metadata;
|
||||
- MP4, MOV, M4V, and M4A;
|
||||
- WebM, MKV, MKA, MP3, WAV, FLAC, OGG, OGA, Opus, and AAC when ffmpeg is
|
||||
available.
|
||||
- WebM, MKV, MKA, AVI, FLV, MP3, WAV, FLAC, OGG, OGA, Opus, and AAC when
|
||||
ffmpeg is available.
|
||||
|
||||
JPEG image metadata stripping removes targeted metadata segments without
|
||||
re-encoding the entropy coded image scan. PNG and WebP removal preserves pixel
|
||||
@@ -135,7 +138,8 @@ not a universal clean verdict.
|
||||
| Midjourney | None registered | No registered pixel decoder | EXIF, XMP, and IPTC signals |
|
||||
| ByteDance generators | Doubao and Jimeng marks | No registered pixel decoder | TC260 AIGC and supported C2PA signals |
|
||||
| Qwen | Qwen mark | No registered pixel decoder | TC260 AIGC |
|
||||
| Kling | Kling mark | No registered pixel decoder | TC260 AIGC |
|
||||
| Kling | Kling image and video marks | No registered pixel decoder | TC260 AIGC |
|
||||
| Hailuo / MiniMax video | Hailuo composite video label | No registered pixel decoder | TC260 AIGC where present |
|
||||
| Baidu | Baidu mark | No registered pixel decoder | TC260 AIGC |
|
||||
| LibLibAI | LibLibAI mark | No registered pixel decoder | TC260 AIGC |
|
||||
| RunningHub | RunningHub mark | No registered pixel decoder | TC260 AIGC |
|
||||
|
||||
@@ -45,6 +45,12 @@ Native MKV/WebM is a sixth serialization. TC260-PG-20257A stores
|
||||
`Segment.Tags.Tag.SimpleTag`. The bounded EBML reader skips cluster payloads;
|
||||
the existing ffmpeg stream-copy path removes the tags without transcoding.
|
||||
|
||||
The same [TC260 video guide](https://www.tc260.org.cn/portal/article/303/4061772dcf684d8a96f395a4298e9e53)
|
||||
defines two more native serializations. AVI stores an `AIGC` child in
|
||||
`LIST/INFO`; FLV stores an AMF0 `AIGC` string under `script.onMetaData`. The
|
||||
bounded RIFF and FLV readers validate the JSON field set and skip media
|
||||
payloads. Removal remuxes either container through ffmpeg with stream copy.
|
||||
|
||||
- **HuggingFace-hosted job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** HuggingFace 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.
|
||||
- **No detectable signal on some downloads:** Recraft exports and some hosted
|
||||
FLUX surfaces can arrive without a supported local signal. Midjourney samples
|
||||
@@ -56,8 +62,9 @@ the existing ffmpeg stream-copy path removes the tags without transcoding.
|
||||
- **Built in the dated batch:** soft-binding vendor detection, IPTC Photo
|
||||
Metadata AI-disclosure fields, C2PA detection and stripping for supported
|
||||
ISOBMFF video, the optional Adobe TrustMark decoder, and temporally stabilized
|
||||
visible Sora, Veo, Seedance, and Dola removal. Other visible video logos and
|
||||
proprietary audio-watermark detection remain outside the package.
|
||||
visible Sora, Veo, Seedance, Dola, Hailuo, and Kling removal. Other visible
|
||||
video logos and proprietary audio-watermark detection remain outside the
|
||||
package.
|
||||
Metadata stripping for supported audio containers is a separate implemented
|
||||
path.
|
||||
|
||||
@@ -101,6 +108,21 @@ every frame, then requires an anchored temporal run. This extra anchor check
|
||||
was necessary because a moving clean scene detail could retain enough adjacent
|
||||
overlap to pass a recurrence-only gate.
|
||||
|
||||
**Hailuo and Kling use larger fixed composite labels.** Verified Hailuo exports
|
||||
carry a lower-edge waveform, `MINIMAX`, separator, Hailuo ring, and
|
||||
`hailuo AI` text. Verified Kling exports carry a bottom-right swirl,
|
||||
`KLING AI`, a changing version suffix, and sometimes `PRO`. The detectors use
|
||||
only synthetic primitives and fonts. Hailuo expands the matched core to cover
|
||||
the complete composite. Kling combines a version-independent text core with a
|
||||
synthetic ring rescue, then requires the recurring candidate to reach the
|
||||
expected frame edge and contain enough bright low-saturation pixels. Those
|
||||
extra gates were added after clean Luma and PixVerse scene details passed shape
|
||||
and temporal recurrence alone. The generic
|
||||
[WatermarkRemover-AI](https://github.com/D-Ogi/WatermarkRemover-AI) project
|
||||
instead uses Florence-2 to identify arbitrary watermarks before LaMa
|
||||
inpainting. That is broader, but it carries a much heavier model and a less
|
||||
auditable detection boundary than the provider-specific synthetic path here.
|
||||
|
||||
**The faint-visible-mark precision/recall wall is fundamental, not a heuristic artifact.** The visible-watermark-detection literature has moved to LEARNED segmentation / object-detection (WDNet WACV'21 arXiv:2012.07616; SLBR ACM MM'21, open code+weights; the PRCV'18 large-scale detector; Su et al. survey 2025), but three verified findings bound what a learned detector actually buys: (1) a claim that a confidence threshold "cleanly separates" true from false matches even with a learned CNN front-end was **REFUTED** in verification (arXiv:1705.08593) -- the precision/recall wall persists even with learned features. (2) Learned detectors need a LARGE, pattern-diverse labeled dataset trained on synthetic composites (PRCV'18: 60k images / 80 watermark classes; CLWD: 60k / 160 marks), and off-distribution degradation is a documented real axis (models trained on limited-pattern LVW transfer worse; diversity of training patterns drives generalization). (3) Inference is cheap (WDNet ~8 ms at 256x256) -- the cost is the data pipeline, not runtime. Net: a learned detector shifts the frontier but does NOT remove the wall; for a SINGLE mark the cheapest next step is a small patch classifier (real-sparkle vs false-positive) on top of the existing NCC localizer, not a full segmentation model. SLBR is a ready baseline. The current NCC + false-positive gate (core-ring brightness margin + gradient-NCC crispness + white-core saturation) is a sound operating point, and the residual miss is the information-theoretic wall the literature confirms.
|
||||
|
||||
**Visible-mark landscape beyond the registry.** Meta stamps a visible "Imagined with AI" mark (bottom-LEFT, a small symbol) on its OWN Meta AI / "Imagine" output; for third-party images it relies on C2PA / IPTC, not a visible mark. Samsung Galaxy AI additionally uses a **four-star icon** variant in a corner alongside the localized text wordmark `samsung_engine` calibrates (only the Italian text variant is covered) -- the icon is a distinct, uncovered variant. Every source agrees visible + metadata marks are trivially removable (crop / screenshot, ~2 s), which is the tool's premise.
|
||||
|
||||
@@ -1063,8 +1063,8 @@ def cmd_metadata(
|
||||
Strips EXIF AI tags, PNG text chunks, C2PA provenance manifests, and the
|
||||
China TC260 AIGC label. Beyond images (PNG/JPEG/WebP/AVIF/HEIF/JXL) it also
|
||||
strips provenance metadata from MP4/MOV/M4V/M4A containers and, via ffmpeg,
|
||||
from WebM/MP3/WAV/FLAC/OGG. The coded image, audio, and video data are left
|
||||
untouched.
|
||||
from WebM/MKV/AVI/FLV/MP3/WAV/FLAC/OGG. The coded image, audio, and video
|
||||
data are left untouched.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata, strip_and_verify
|
||||
|
||||
@@ -1240,7 +1240,7 @@ def cmd_video_invisible(
|
||||
)
|
||||
@click.option(
|
||||
"--mark",
|
||||
type=click.Choice(["sora", "veo", "seedance", "dola"]),
|
||||
type=click.Choice(["sora", "veo", "seedance", "dola", "hailuo", "kling"]),
|
||||
default="sora",
|
||||
help="Visible AI mark to remove.",
|
||||
)
|
||||
|
||||
@@ -10,10 +10,11 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -125,7 +126,7 @@ _ISOBMFF_EXTS: frozenset[str] = frozenset({".avif", ".heif", ".heic", ".jxl", ".
|
||||
# RIFF / Vorbis). remove_ai_metadata strips their container metadata losslessly
|
||||
# via ffmpeg (`-c copy`), so it needs ffmpeg on PATH for these.
|
||||
_FFMPEG_STRIP_EXTS: frozenset[str] = frozenset(
|
||||
{".webm", ".mkv", ".mka", ".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".aac"}
|
||||
{".webm", ".mkv", ".mka", ".avi", ".flv", ".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".aac"}
|
||||
)
|
||||
|
||||
# China's mandatory AI-content labeling (TC260, the national cybersecurity
|
||||
@@ -161,6 +162,22 @@ TC260_AIGC_FIELDS: frozenset[str] = frozenset(
|
||||
"ServiceUser",
|
||||
}
|
||||
)
|
||||
MAX_TC260_VALUE_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
def parse_tc260_aigc_json(value: bytes) -> dict[str, str] | None:
|
||||
"""Parse a bounded JSON object carrying at least one normative TC260 field."""
|
||||
if len(value) > MAX_TC260_VALUE_BYTES:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value.rstrip(b"\x00 ").decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
fields = {str(key): str(item) for key, item in cast("dict[object, object]", parsed).items()}
|
||||
return fields if TC260_AIGC_FIELDS & fields.keys() else None
|
||||
|
||||
|
||||
# HuggingFace-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with this
|
||||
# ``tEXt`` chunk key holding the job UUID. It marks the hosting job, not a
|
||||
@@ -365,6 +382,8 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
(as written by Doubao / ByteDance), read via PIL;
|
||||
- a native MP4/MOV ``AIGC`` key in ``moov.udta.meta.keys`` whose matching
|
||||
``ilst`` item carries the raw JSON object;
|
||||
- a native AVI ``LIST/INFO/AIGC`` chunk or FLV
|
||||
``script.onMetaData.AIGC`` string carrying the raw JSON object;
|
||||
- an XMP ``<TC260:AIGC>{...}</TC260:AIGC>`` block (HTML-entity encoded text),
|
||||
found by a container-agnostic raw-byte scan (PNG/JPEG/WebP alike); and
|
||||
- a raw-JSON ``{"AIGC":{...}}`` block with no namespace, as embedded in JPEG
|
||||
@@ -381,20 +400,17 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
namespaced XMP element is unambiguous, so any JSON object is accepted.
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
from typing import cast
|
||||
|
||||
def _parse(text: str, *, require_tc260_field: bool) -> dict[str, str] | None:
|
||||
if require_tc260_field:
|
||||
return parse_tc260_aigc_json(text.encode("utf-8"))
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
fields = {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()}
|
||||
if require_tc260_field and not (TC260_AIGC_FIELDS & fields.keys()):
|
||||
return None
|
||||
return fields
|
||||
return {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()}
|
||||
|
||||
# PNG tEXt chunk keyed "AIGC" with raw JSON (Doubao and other China gens).
|
||||
# The key is generic, so require a TC260 field to avoid a false positive.
|
||||
@@ -428,6 +444,22 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
if result := _parse(payload.decode("utf-8", "replace"), require_tc260_field=True):
|
||||
return result
|
||||
|
||||
# Native AVI and FLV TC260 metadata. Both readers walk their container
|
||||
# structures and skip media payloads instead of relying on a raw substring
|
||||
# that could collide inside compressed video.
|
||||
legacy_payloads: tuple[bytes, ...] = ()
|
||||
if image_path.suffix.lower() == ".avi":
|
||||
from remove_ai_watermarks.noai.riff import tc260_aigc_payloads as riff_tc260_aigc_payloads
|
||||
|
||||
legacy_payloads = riff_tc260_aigc_payloads(image_path)
|
||||
elif image_path.suffix.lower() == ".flv":
|
||||
from remove_ai_watermarks.noai.flv import tc260_aigc_payloads as flv_tc260_aigc_payloads
|
||||
|
||||
legacy_payloads = flv_tc260_aigc_payloads(image_path)
|
||||
for payload in legacy_payloads:
|
||||
if result := _parse(payload.decode("utf-8", "replace"), require_tc260_field=True):
|
||||
return result
|
||||
|
||||
# XMP TC260:AIGC, namespaced (unambiguous) in either serialization RDF allows:
|
||||
# an element <TC260:AIGC>{...}</TC260:AIGC> or an attribute TC260:AIGC="{...}"
|
||||
# (the attribute form is what PicWish writes). Both are HTML-entity encoded.
|
||||
@@ -1206,7 +1238,8 @@ def remove_ai_metadata(
|
||||
)
|
||||
return output_path
|
||||
|
||||
# Non-ISOBMFF audio/video (WebM/Matroska EBML, MP3 ID3, WAV/FLAC/OGG): the
|
||||
# Non-ISOBMFF audio/video (WebM/Matroska EBML, AVI/FLV, MP3 ID3,
|
||||
# WAV/FLAC/OGG): the
|
||||
# box walker can't reach these, so strip container metadata losslessly via
|
||||
# ffmpeg (-c copy -- codec data untouched, only tags/chapters dropped).
|
||||
if source_path.suffix.lower() in _FFMPEG_STRIP_EXTS:
|
||||
|
||||
@@ -8,14 +8,13 @@ their payloads.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, BinaryIO, cast
|
||||
from typing import TYPE_CHECKING, BinaryIO
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.metadata import TC260_AIGC_FIELDS
|
||||
from remove_ai_watermarks.metadata import MAX_TC260_VALUE_BYTES, parse_tc260_aigc_json
|
||||
|
||||
_EBML_MAGIC = b"\x1aE\xdf\xa3"
|
||||
_SEGMENT_ID = 0x18538067
|
||||
@@ -25,7 +24,6 @@ _SIMPLE_TAG_ID = 0x67C8
|
||||
_TAG_NAME_ID = 0x45A3
|
||||
_TAG_STRING_ID = 0x4487
|
||||
_MAX_TAG_NAME_BYTES = 256
|
||||
_MAX_TC260_VALUE_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
def _vint_length(first: int, *, maximum: int) -> int | None:
|
||||
@@ -111,17 +109,6 @@ def _read_bounded(
|
||||
return value if len(value) == size else None
|
||||
|
||||
|
||||
def _is_tc260_aigc_json(value: bytes) -> bool:
|
||||
try:
|
||||
parsed = json.loads(value.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return False
|
||||
if not isinstance(parsed, dict):
|
||||
return False
|
||||
fields = cast("dict[object, object]", parsed)
|
||||
return bool(TC260_AIGC_FIELDS & {str(key) for key in fields})
|
||||
|
||||
|
||||
def _simple_tag_payloads(
|
||||
stream: BinaryIO,
|
||||
start: int,
|
||||
@@ -133,12 +120,12 @@ def _simple_tag_payloads(
|
||||
if element_id == _TAG_NAME_ID:
|
||||
name = _read_bounded(stream, payload_start, element_end, _MAX_TAG_NAME_BYTES)
|
||||
elif element_id == _TAG_STRING_ID:
|
||||
value = _read_bounded(stream, payload_start, element_end, _MAX_TC260_VALUE_BYTES)
|
||||
value = _read_bounded(stream, payload_start, element_end, MAX_TC260_VALUE_BYTES)
|
||||
if value is not None:
|
||||
values.append(value)
|
||||
if name != b"AIGC":
|
||||
return ()
|
||||
return tuple(value for value in values if _is_tc260_aigc_json(value))
|
||||
return tuple(value for value in values if parse_tc260_aigc_json(value) is not None)
|
||||
|
||||
|
||||
def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]:
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Bounded FLV metadata reader for native TC260 AIGC labels.
|
||||
|
||||
TC260-PG-20257A stores the label in the ``onMetaData`` script tag as an AMF0
|
||||
property named ``AIGC`` whose string value is the normative JSON object. Media
|
||||
tag payloads are skipped without being loaded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.metadata import MAX_TC260_VALUE_BYTES, parse_tc260_aigc_json
|
||||
|
||||
_SCRIPT_TAG = 18
|
||||
_MAX_SCRIPT_BYTES = 4 * 1024 * 1024
|
||||
|
||||
|
||||
def _u24(value: bytes) -> int:
|
||||
return int.from_bytes(value, "big")
|
||||
|
||||
|
||||
def _amf0_string(data: bytes, position: int, *, long: bool = False) -> tuple[bytes, int] | None:
|
||||
length_size = 4 if long else 2
|
||||
if position + length_size > len(data):
|
||||
return None
|
||||
length = int.from_bytes(data[position : position + length_size], "big")
|
||||
start = position + length_size
|
||||
end = start + length
|
||||
if end > len(data):
|
||||
return None
|
||||
return data[start:end], end
|
||||
|
||||
|
||||
def _skip_amf0(data: bytes, position: int, depth: int = 0) -> int | None:
|
||||
if position >= len(data) or depth > 8:
|
||||
return None
|
||||
value_type = data[position]
|
||||
position += 1
|
||||
if value_type == 0:
|
||||
return position + 8 if position + 8 <= len(data) else None
|
||||
if value_type == 1:
|
||||
return position + 1 if position + 1 <= len(data) else None
|
||||
if value_type == 2:
|
||||
parsed = _amf0_string(data, position)
|
||||
return parsed[1] if parsed is not None else None
|
||||
if value_type in {5, 6}:
|
||||
return position
|
||||
if value_type == 7:
|
||||
return position + 2 if position + 2 <= len(data) else None
|
||||
if value_type == 11:
|
||||
return position + 10 if position + 10 <= len(data) else None
|
||||
if value_type == 12:
|
||||
parsed = _amf0_string(data, position, long=True)
|
||||
return parsed[1] if parsed is not None else None
|
||||
if value_type == 10:
|
||||
if position + 4 > len(data):
|
||||
return None
|
||||
count = int.from_bytes(data[position : position + 4], "big")
|
||||
position += 4
|
||||
for _ in range(count):
|
||||
next_position = _skip_amf0(data, position, depth + 1)
|
||||
if next_position is None:
|
||||
return None
|
||||
position = next_position
|
||||
return position
|
||||
if value_type in {3, 8}:
|
||||
if value_type == 8:
|
||||
if position + 4 > len(data):
|
||||
return None
|
||||
position += 4
|
||||
while position + 3 <= len(data):
|
||||
name_length = int.from_bytes(data[position : position + 2], "big")
|
||||
position += 2
|
||||
if name_length == 0 and data[position] == 9:
|
||||
return position + 1
|
||||
position += name_length
|
||||
if position > len(data):
|
||||
return None
|
||||
next_position = _skip_amf0(data, position, depth + 1)
|
||||
if next_position is None:
|
||||
return None
|
||||
position = next_position
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _script_payloads(data: bytes) -> tuple[bytes, ...]:
|
||||
first = _amf0_string(data, 1) if data[:1] == b"\x02" else None
|
||||
if first is None or first[0] != b"onMetaData":
|
||||
return ()
|
||||
position = first[1]
|
||||
if position >= len(data) or data[position] not in {3, 8}:
|
||||
return ()
|
||||
if data[position] == 8:
|
||||
position += 5
|
||||
else:
|
||||
position += 1
|
||||
found: list[bytes] = []
|
||||
while position + 3 <= len(data):
|
||||
name_length = int.from_bytes(data[position : position + 2], "big")
|
||||
position += 2
|
||||
if name_length == 0 and data[position] == 9:
|
||||
break
|
||||
name_end = position + name_length
|
||||
if name_end > len(data):
|
||||
break
|
||||
name = data[position:name_end]
|
||||
position = name_end
|
||||
if name == b"AIGC" and position < len(data) and data[position] in {2, 12}:
|
||||
long = data[position] == 12
|
||||
parsed = _amf0_string(data, position + 1, long=long)
|
||||
if parsed is None:
|
||||
break
|
||||
value, position = parsed
|
||||
if len(value) <= MAX_TC260_VALUE_BYTES and parse_tc260_aigc_json(value) is not None:
|
||||
found.append(value)
|
||||
continue
|
||||
next_position = _skip_amf0(data, position)
|
||||
if next_position is None:
|
||||
break
|
||||
position = next_position
|
||||
return tuple(found)
|
||||
|
||||
|
||||
def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]:
|
||||
"""Read validated TC260 values from FLV ``script.onMetaData.AIGC``."""
|
||||
found: list[bytes] = []
|
||||
try:
|
||||
with open(path, "rb") as stream:
|
||||
header = stream.read(9)
|
||||
if len(header) != 9 or header[:3] != b"FLV":
|
||||
return ()
|
||||
data_offset = int.from_bytes(header[5:9], "big")
|
||||
stream.seek(0, 2)
|
||||
file_size = stream.tell()
|
||||
position = data_offset + 4
|
||||
while position + 11 <= file_size:
|
||||
stream.seek(position)
|
||||
tag_header = stream.read(11)
|
||||
if len(tag_header) != 11:
|
||||
break
|
||||
tag_type = tag_header[0] & 0x1F
|
||||
data_size = _u24(tag_header[1:4])
|
||||
payload_start = position + 11
|
||||
payload_end = payload_start + data_size
|
||||
if payload_end + 4 > file_size:
|
||||
break
|
||||
if tag_type == _SCRIPT_TAG and data_size <= _MAX_SCRIPT_BYTES:
|
||||
payload = stream.read(data_size)
|
||||
if len(payload) == data_size:
|
||||
found.extend(_script_payloads(payload))
|
||||
if found:
|
||||
return tuple(found)
|
||||
position = payload_end + 4
|
||||
except OSError:
|
||||
return ()
|
||||
return tuple(found)
|
||||
@@ -23,11 +23,10 @@ Reference: ISO/IEC 14496-12 (ISOBMFF) and C2PA 2.1 spec §11.
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
from typing import TYPE_CHECKING, Any, BinaryIO, cast
|
||||
from typing import TYPE_CHECKING, Any, BinaryIO
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@@ -38,7 +37,8 @@ from remove_ai_watermarks.metadata import (
|
||||
C2PA_UUID,
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
TC260_AIGC_FIELDS,
|
||||
MAX_TC260_VALUE_BYTES,
|
||||
parse_tc260_aigc_json,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,14 +61,12 @@ _AI_LABEL_MARKERS: tuple[bytes, ...] = AIGC_MARKERS + IPTC_AI_MARKERS + IPTC_AI_
|
||||
# blanked in place (see ``blank_ai_xmp_packets``).
|
||||
_XMP_PACKET_RE = re.compile(rb"<\?xpacket begin=.*?<\?xpacket end=[^>]*?\?>", re.DOTALL)
|
||||
|
||||
|
||||
# TC260-PG-20257A stores an MP4/MOV label as an ``AIGC`` key in
|
||||
# ``moov.udta.meta.keys`` and its JSON value in the corresponding
|
||||
# ``moov.udta.meta.ilst`` item. The value is intentionally bounded before it is
|
||||
# read: the normative object is tiny, and a corrupt size must not allocate an
|
||||
# arbitrary amount of memory during an inspection.
|
||||
_MAX_TC260_VALUE_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
def _iter_top_level_boxes(data: bytes) -> Iterator[tuple[int, int, bytes, int]]:
|
||||
"""Yield ``(start, end, type, payload_offset)`` for each top-level box.
|
||||
|
||||
@@ -181,18 +179,6 @@ def _tc260_key_indices(
|
||||
return found
|
||||
|
||||
|
||||
def _is_tc260_aigc_json(value: bytes) -> bool:
|
||||
"""Require a JSON object carrying at least one normative TC260 field."""
|
||||
try:
|
||||
parsed = json.loads(value.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return False
|
||||
if not isinstance(parsed, dict):
|
||||
return False
|
||||
fields = cast("dict[object, object]", parsed)
|
||||
return bool(TC260_AIGC_FIELDS & {str(key) for key in fields})
|
||||
|
||||
|
||||
def _tc260_aigc_regions(
|
||||
stream: BinaryIO,
|
||||
file_size: int,
|
||||
@@ -249,11 +235,11 @@ def _tc260_aigc_regions(
|
||||
):
|
||||
value_start = data_payload + 8
|
||||
value_size = data_end - value_start
|
||||
if data_type != b"data" or value_size < 0 or value_size > _MAX_TC260_VALUE_BYTES:
|
||||
if data_type != b"data" or value_size < 0 or value_size > MAX_TC260_VALUE_BYTES:
|
||||
continue
|
||||
stream.seek(value_start)
|
||||
value = stream.read(value_size)
|
||||
if len(value) == value_size and _is_tc260_aigc_json(value):
|
||||
if len(value) == value_size and parse_tc260_aigc_json(value) is not None:
|
||||
regions.append((*key_span, value_start, data_end, value))
|
||||
return regions
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Bounded AVI metadata reader for native TC260 AIGC labels.
|
||||
|
||||
TC260-PG-20257A stores the label in an AVI ``LIST/INFO`` chunk whose child
|
||||
chunk ID is ``AIGC`` and whose value is the normative JSON object. The walker
|
||||
seeks over media chunks and reads only bounded metadata values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, BinaryIO
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.metadata import MAX_TC260_VALUE_BYTES, parse_tc260_aigc_json
|
||||
|
||||
|
||||
def _info_payloads(
|
||||
stream: BinaryIO,
|
||||
start: int,
|
||||
end: int,
|
||||
) -> tuple[bytes, ...]:
|
||||
found: list[bytes] = []
|
||||
position = start
|
||||
while position + 8 <= end:
|
||||
stream.seek(position)
|
||||
chunk_id = stream.read(4)
|
||||
size_raw = stream.read(4)
|
||||
if len(chunk_id) != 4 or len(size_raw) != 4:
|
||||
break
|
||||
size = int.from_bytes(size_raw, "little")
|
||||
payload_start = position + 8
|
||||
payload_end = payload_start + size
|
||||
if payload_end > end:
|
||||
break
|
||||
if chunk_id == b"AIGC" and size <= MAX_TC260_VALUE_BYTES:
|
||||
value = stream.read(size)
|
||||
if len(value) == size and parse_tc260_aigc_json(value) is not None:
|
||||
found.append(value.rstrip(b"\x00 "))
|
||||
position = payload_end + (size & 1)
|
||||
return tuple(found)
|
||||
|
||||
|
||||
def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]:
|
||||
"""Read validated TC260 values from an AVI ``LIST/INFO/AIGC`` chunk."""
|
||||
found: list[bytes] = []
|
||||
try:
|
||||
with open(path, "rb") as stream:
|
||||
header = stream.read(12)
|
||||
if len(header) != 12 or header[:4] != b"RIFF" or header[8:12] != b"AVI ":
|
||||
return ()
|
||||
stream.seek(0, 2)
|
||||
file_size = stream.tell()
|
||||
declared_end = min(8 + int.from_bytes(header[4:8], "little"), file_size)
|
||||
position = 12
|
||||
while position + 8 <= declared_end:
|
||||
stream.seek(position)
|
||||
chunk_id = stream.read(4)
|
||||
size_raw = stream.read(4)
|
||||
if len(chunk_id) != 4 or len(size_raw) != 4:
|
||||
break
|
||||
size = int.from_bytes(size_raw, "little")
|
||||
payload_start = position + 8
|
||||
payload_end = payload_start + size
|
||||
if payload_end > declared_end:
|
||||
break
|
||||
if chunk_id == b"LIST" and size >= 4:
|
||||
list_type = stream.read(4)
|
||||
if list_type == b"INFO":
|
||||
found.extend(_info_payloads(stream, payload_start + 4, payload_end))
|
||||
position = payload_end + (size & 1)
|
||||
except OSError:
|
||||
return ()
|
||||
return tuple(found)
|
||||
@@ -1,9 +1,10 @@
|
||||
"""High-level video processing API.
|
||||
|
||||
Supported experimental stages are container-level AI metadata inspection and
|
||||
removal, temporally stabilized visible Sora, Veo, Seedance, and Dola removal,
|
||||
and VAE regeneration that produces an externally verifiable SynthID candidate.
|
||||
The visible pixel path reuses the image package's shared fill backends.
|
||||
removal, temporally stabilized visible Sora, Veo, Seedance, Dola, Hailuo, and
|
||||
Kling removal, and VAE regeneration that produces an externally verifiable
|
||||
SynthID candidate. The visible pixel path reuses the image package's shared
|
||||
fill backends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,9 +23,11 @@ from remove_ai_watermarks.video_synthid import (
|
||||
if TYPE_CHECKING:
|
||||
from remove_ai_watermarks.video_invisible import RegenerationMetrics
|
||||
|
||||
VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv"})
|
||||
VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".flv"})
|
||||
_ISOBMFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v"})
|
||||
_EBML_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".webm", ".mkv"})
|
||||
_RIFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".avi"})
|
||||
_FLV_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".flv"})
|
||||
_REGENERATED_VIDEO_EXTENSIONS: frozenset[str] = _ISOBMFF_VIDEO_EXTENSIONS
|
||||
_EBML_MAGIC = b"\x1aE\xdf\xa3"
|
||||
|
||||
@@ -109,8 +112,11 @@ def _video_source(source: str | Path) -> Path:
|
||||
with path.open("rb") as stream:
|
||||
head = stream.read(12)
|
||||
suffix = path.suffix.lower()
|
||||
matches_container = (suffix in _ISOBMFF_VIDEO_EXTENSIONS and len(head) >= 8 and head[4:8] == b"ftyp") or (
|
||||
suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(_EBML_MAGIC)
|
||||
matches_container = (
|
||||
(suffix in _ISOBMFF_VIDEO_EXTENSIONS and len(head) >= 8 and head[4:8] == b"ftyp")
|
||||
or (suffix in _EBML_VIDEO_EXTENSIONS and head.startswith(_EBML_MAGIC))
|
||||
or (suffix in _RIFF_VIDEO_EXTENSIONS and len(head) >= 12 and head[:4] == b"RIFF" and head[8:12] == b"AVI ")
|
||||
or (suffix in _FLV_VIDEO_EXTENSIONS and head.startswith(b"FLV"))
|
||||
)
|
||||
if not matches_container:
|
||||
raise ValueError(f"Video content does not match its {suffix} extension: {path}")
|
||||
@@ -182,11 +188,11 @@ def remove_video_visible(
|
||||
) -> VideoVisibleResult:
|
||||
"""Remove a supported visible AI wordmark from a video.
|
||||
|
||||
Supported marks are ``sora``, ``veo``, ``seedance``, and ``dola``. The full
|
||||
sequence is scanned before pixels change, and only recurring candidates are
|
||||
accepted. Audio is copied without re-encoding; video is transcoded because
|
||||
the pixels change. When no stable mark is found, no output is written and
|
||||
``output`` in the result is ``None``.
|
||||
Supported marks are ``sora``, ``veo``, ``seedance``, ``dola``, ``hailuo``,
|
||||
and ``kling``. The full sequence is scanned before pixels change, and only
|
||||
recurring candidates are accepted. Audio is copied without re-encoding;
|
||||
video is transcoded because the pixels change. When no stable mark is found,
|
||||
no output is written and ``output`` in the result is ``None``.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata
|
||||
from remove_ai_watermarks.video_visible import (
|
||||
@@ -195,18 +201,22 @@ def remove_video_visible(
|
||||
has_sora_provenance,
|
||||
has_veo_provenance,
|
||||
scan_dola_video,
|
||||
scan_hailuo_video,
|
||||
scan_kling_video,
|
||||
scan_seedance_video,
|
||||
scan_sora_video,
|
||||
scan_veo_video,
|
||||
stabilize_dola_localizations,
|
||||
stabilize_hailuo_localizations,
|
||||
stabilize_kling_localizations,
|
||||
stabilize_seedance_localizations,
|
||||
stabilize_sora_localizations,
|
||||
stabilize_veo_localizations,
|
||||
)
|
||||
from remove_ai_watermarks.watermark_registry import resolve_backend
|
||||
|
||||
if mark not in {"sora", "veo", "seedance", "dola"}:
|
||||
raise ValueError("Unsupported visible video mark; expected sora, veo, seedance, or dola")
|
||||
if mark not in {"sora", "veo", "seedance", "dola", "hailuo", "kling"}:
|
||||
raise ValueError("Unsupported visible video mark; expected sora, veo, seedance, dola, hailuo, or kling")
|
||||
if backend not in {"auto", "cv2", "migan", "lama"}:
|
||||
raise ValueError("Unsupported fill backend; expected auto, cv2, migan, or lama")
|
||||
|
||||
@@ -237,7 +247,7 @@ def remove_video_visible(
|
||||
)
|
||||
padding_fraction = 0.0
|
||||
mask_style = "box"
|
||||
else:
|
||||
elif mark == "dola":
|
||||
scan = scan_dola_video(source_path)
|
||||
regions = stabilize_dola_localizations(
|
||||
scan.detections,
|
||||
@@ -245,6 +255,16 @@ def remove_video_visible(
|
||||
)
|
||||
padding_fraction = 0.20
|
||||
mask_style = "box"
|
||||
elif mark == "hailuo":
|
||||
scan = scan_hailuo_video(source_path)
|
||||
regions = stabilize_hailuo_localizations(scan.detections)
|
||||
padding_fraction = 0.12
|
||||
mask_style = "box"
|
||||
else:
|
||||
scan = scan_kling_video(source_path)
|
||||
regions = stabilize_kling_localizations(scan.detections)
|
||||
padding_fraction = 0.12
|
||||
mask_style = "box"
|
||||
detected_frames = sum(region is not None for region in regions)
|
||||
if detected_frames == 0:
|
||||
return VideoVisibleResult(
|
||||
|
||||
@@ -4,10 +4,11 @@ Supported marks use fully synthetic silhouettes made from geometric primitives,
|
||||
OpenCV's built-in font, and Pillow's bundled font. Sora detection searches the
|
||||
full frame because the wordmark moves. Veo detection covers both the current
|
||||
four-point diamond and legacy ``Veo`` text. Seedance detects the boxed ``AI``
|
||||
label, while Dola detects its compact text label. A single frame is never enough
|
||||
to authorize removal: the temporal arbiter requires the candidate to recur at
|
||||
the same location across adjacent frames. This keeps isolated lookalikes in
|
||||
clean videos from becoming removal masks.
|
||||
label, Dola detects its compact text label, Hailuo detects the composite
|
||||
MINIMAX/Hailuo label, and Kling detects its version-independent wordmark core.
|
||||
A single frame is never enough to authorize removal: the temporal arbiter
|
||||
requires the candidate to recur at the same location across adjacent frames.
|
||||
This keeps isolated lookalikes in clean videos from becoming removal masks.
|
||||
|
||||
Video pixels are decoded with OpenCV and encoded with the system ``ffmpeg``.
|
||||
Audio is stream-copied from the source. The video stream must be transcoded
|
||||
@@ -64,6 +65,11 @@ _SEEDANCE_STRONG_CONFIDENCE = 0.43
|
||||
_DOLA_PROVENANCE_WEAK_CONFIDENCE = 0.48
|
||||
_DOLA_STRICT_WEAK_CONFIDENCE = 0.50
|
||||
_DOLA_STRONG_CONFIDENCE = 0.52
|
||||
_HAILUO_WEAK_CONFIDENCE = 0.30
|
||||
_HAILUO_STRONG_CONFIDENCE = 0.34
|
||||
_KLING_WEAK_CONFIDENCE = 0.20
|
||||
_KLING_STRONG_CONFIDENCE = 0.24
|
||||
_KLING_MIN_WHITE_FRACTION = 0.02
|
||||
_MIN_STABLE_FRAMES = 5
|
||||
_MIN_VEO_STABLE_FRAMES = 12
|
||||
_MIN_FIXED_MARK_STABLE_FRAMES = 12
|
||||
@@ -76,6 +82,8 @@ _VEO_DIAMOND_PROFILES = (
|
||||
(44, 29, 40),
|
||||
)
|
||||
_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))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -108,6 +116,14 @@ def _scalable_default_font(size: int) -> ImageFont.ImageFont | ImageFont.FreeTyp
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _crop_nonzero(image: NDArray[Any]) -> NDArray[Any]:
|
||||
"""Crop a synthetic template to its nonzero footprint."""
|
||||
ys, xs = np.where(image > 0)
|
||||
if len(xs) == 0:
|
||||
return image
|
||||
return image[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _sora_templates() -> tuple[NDArray[Any], NDArray[Any]]:
|
||||
"""Return synthetic full-wordmark and mascot-only silhouettes.
|
||||
@@ -162,9 +178,7 @@ def _veo_templates() -> tuple[NDArray[Any], NDArray[Any]]:
|
||||
text_canvas = Image.new("L", (140, 60), 0)
|
||||
text_draw = ImageDraw.Draw(text_canvas)
|
||||
text_draw.text((2, 0), "Veo", font=_scalable_default_font(48), fill=255)
|
||||
text = np.asarray(text_canvas, dtype=np.uint8)
|
||||
ys, xs = np.where(text > 0)
|
||||
text = text[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
text = _crop_nonzero(np.asarray(text_canvas, dtype=np.uint8))
|
||||
return np.asarray(diamond_canvas, dtype=np.uint8), text
|
||||
|
||||
|
||||
@@ -201,8 +215,75 @@ def _dola_template() -> NDArray[Any]:
|
||||
3,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
ys, xs = np.where(canvas > 0)
|
||||
return canvas[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
return _crop_nonzero(canvas)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _hailuo_template() -> NDArray[Any]:
|
||||
"""Return a synthetic MINIMAX/Hailuo composite-label silhouette."""
|
||||
canvas = Image.new("L", (680, 112), 0)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
# Five symmetric waveform strokes approximate the provider-independent
|
||||
# geometry at the left edge without copying pixels from an export.
|
||||
waveform_heights = (42, 70, 96, 70, 42)
|
||||
center_y = 56
|
||||
for index, height in enumerate(waveform_heights):
|
||||
x = 10 + index * 13
|
||||
draw.rounded_rectangle(
|
||||
(x, center_y - height // 2, x + 5, center_y + height // 2),
|
||||
radius=2,
|
||||
fill=255,
|
||||
)
|
||||
|
||||
font = _scalable_default_font(58)
|
||||
draw.text((84, 18), "MINIMAX", font=font, fill=255, stroke_width=1, stroke_fill=255)
|
||||
draw.rectangle((342, 18, 347, 92), fill=255)
|
||||
|
||||
# The Hailuo symbol is a ring with a small offset highlight.
|
||||
draw.ellipse((370, 20, 446, 96), outline=255, width=12)
|
||||
draw.ellipse((397, 38, 434, 76), fill=255)
|
||||
draw.ellipse((397, 29, 420, 52), fill=0)
|
||||
draw.text((456, 18), "hailuo AI", font=font, fill=255, stroke_width=1, stroke_fill=255)
|
||||
return np.asarray(canvas, dtype=np.uint8)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _kling_templates() -> tuple[NDArray[Any], ...]:
|
||||
"""Return synthetic font variants for the Kling wordmark core."""
|
||||
canvas = Image.new("L", (430, 104), 0)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
draw.text(
|
||||
(2, 12),
|
||||
"KLING AI",
|
||||
font=_scalable_default_font(64),
|
||||
fill=255,
|
||||
stroke_width=1,
|
||||
stroke_fill=255,
|
||||
)
|
||||
templates = [_crop_nonzero(np.asarray(canvas, dtype=np.uint8))]
|
||||
for font in (cv2.FONT_HERSHEY_SIMPLEX, cv2.FONT_HERSHEY_DUPLEX):
|
||||
cv_template = np.zeros((100, 500), dtype=np.uint8)
|
||||
cv2.putText(
|
||||
cv_template,
|
||||
"KLING AI",
|
||||
(2, 72),
|
||||
font,
|
||||
2.2,
|
||||
255,
|
||||
3,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
templates.append(_crop_nonzero(cv_template))
|
||||
return tuple(templates)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _kling_logo_template() -> NDArray[Any]:
|
||||
"""Return a synthetic ring approximation of the Kling swirl."""
|
||||
template = np.zeros((100, 100), dtype=np.uint8)
|
||||
cv2.circle(template, (50, 50), 34, 255, 14, cv2.LINE_AA)
|
||||
return _crop_nonzero(template)
|
||||
|
||||
|
||||
def _top_hat(gray: NDArray[Any]) -> NDArray[Any]:
|
||||
@@ -338,21 +419,43 @@ def _restore_region(
|
||||
return source_x, source_y, source_width, source_height
|
||||
|
||||
|
||||
def _detect_fixed_bottom_right_mark(
|
||||
def _bounded_region(
|
||||
x: int,
|
||||
y: int,
|
||||
width: int,
|
||||
height: int,
|
||||
*,
|
||||
frame_width: int,
|
||||
frame_height: int,
|
||||
) -> Region:
|
||||
"""Clip an expanded region to the frame without changing its anchor."""
|
||||
bounded_x = max(0, x)
|
||||
bounded_y = max(0, y)
|
||||
return (
|
||||
bounded_x,
|
||||
bounded_y,
|
||||
min(frame_width - bounded_x, width + x - bounded_x),
|
||||
min(frame_height - bounded_y, height + y - bounded_y),
|
||||
)
|
||||
|
||||
|
||||
def _detect_fixed_mark(
|
||||
image_bgr: NDArray[Any],
|
||||
template: NDArray[Any],
|
||||
*,
|
||||
relative_heights: tuple[float, ...],
|
||||
search_origin: tuple[float, float],
|
||||
kernel_fraction: float,
|
||||
prefer_larger_within: float = 0.0,
|
||||
normalized: tuple[NDArray[Any], float] | None = None,
|
||||
frame_index: int,
|
||||
) -> FrameLocalization:
|
||||
"""Match one fixed bottom-right synthetic mark on a normalized frame."""
|
||||
"""Match one fixed synthetic mark inside a normalized-frame search region."""
|
||||
if image_bgr.size == 0:
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
|
||||
frame_height, frame_width = image_bgr.shape[:2]
|
||||
gray, scale = _normalized_gray(image_bgr)
|
||||
gray, scale = normalized if normalized is not None else _normalized_gray(image_bgr)
|
||||
normalized_height, normalized_width = gray.shape[:2]
|
||||
short_side = min(normalized_height, normalized_width)
|
||||
search_x = round(normalized_width * search_origin[0])
|
||||
@@ -363,8 +466,7 @@ def _detect_fixed_bottom_right_mark(
|
||||
normalized_width - search_x,
|
||||
normalized_height - search_y,
|
||||
)
|
||||
best_confidence = 0.0
|
||||
best_region: Region | None = None
|
||||
matches: list[tuple[float, Region]] = []
|
||||
for relative_height in relative_heights:
|
||||
template_height = max(6, round(short_side * relative_height))
|
||||
template_width = max(1, round(template.shape[1] * template_height / template.shape[0]))
|
||||
@@ -379,9 +481,21 @@ def _detect_fixed_bottom_right_mark(
|
||||
region=search_region,
|
||||
kernel_size=max(3, round(template_height * kernel_fraction) | 1),
|
||||
)
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_region = candidate
|
||||
if candidate is not None and confidence > 0:
|
||||
matches.append((confidence, candidate))
|
||||
if not matches:
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
best_confidence, best_region = max(matches, key=lambda match: match[0])
|
||||
if prefer_larger_within > 0:
|
||||
eligible = [
|
||||
(confidence, candidate)
|
||||
for confidence, candidate in matches
|
||||
if confidence >= best_confidence - prefer_larger_within
|
||||
]
|
||||
best_confidence, best_region = max(
|
||||
eligible,
|
||||
key=lambda match: (match[1][2] * match[1][3], match[0]),
|
||||
)
|
||||
|
||||
return FrameLocalization(
|
||||
frame_index,
|
||||
@@ -397,7 +511,7 @@ def _detect_fixed_bottom_right_mark(
|
||||
|
||||
def detect_seedance_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
"""Locate the strongest fixed Seedance boxed-AI candidate."""
|
||||
return _detect_fixed_bottom_right_mark(
|
||||
return _detect_fixed_mark(
|
||||
image_bgr,
|
||||
_seedance_template(),
|
||||
relative_heights=(0.065, 0.075, 0.085, 0.095, 0.105),
|
||||
@@ -409,7 +523,7 @@ def detect_seedance_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> F
|
||||
|
||||
def detect_dola_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
"""Locate the strongest fixed Dola AI text candidate."""
|
||||
return _detect_fixed_bottom_right_mark(
|
||||
return _detect_fixed_mark(
|
||||
image_bgr,
|
||||
_dola_template(),
|
||||
relative_heights=_DOLA_RELATIVE_HEIGHTS,
|
||||
@@ -419,6 +533,137 @@ def detect_dola_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> Frame
|
||||
)
|
||||
|
||||
|
||||
def detect_hailuo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
"""Locate the strongest fixed MINIMAX/Hailuo composite-label candidate."""
|
||||
detection = _detect_fixed_mark(
|
||||
image_bgr,
|
||||
_hailuo_template(),
|
||||
relative_heights=_HAILUO_RELATIVE_HEIGHTS,
|
||||
search_origin=(0.28, 0.76),
|
||||
kernel_fraction=0.18,
|
||||
frame_index=frame_index,
|
||||
)
|
||||
if detection.region is None:
|
||||
return detection
|
||||
frame_width = image_bgr.shape[1]
|
||||
x, y, width, height = detection.region
|
||||
horizontal_padding = round(height * 1.25)
|
||||
region = _bounded_region(
|
||||
x - horizontal_padding,
|
||||
y,
|
||||
width + horizontal_padding * 2,
|
||||
height,
|
||||
frame_width=frame_width,
|
||||
frame_height=image_bgr.shape[0],
|
||||
)
|
||||
return FrameLocalization(
|
||||
frame_index,
|
||||
detection.confidence,
|
||||
region,
|
||||
)
|
||||
|
||||
|
||||
def detect_kling_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
"""Locate the fixed Kling wordmark core and include its version suffix."""
|
||||
if image_bgr.size == 0:
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
frame_height, frame_width = image_bgr.shape[:2]
|
||||
normalized = _normalized_gray(image_bgr)
|
||||
expanded: list[FrameLocalization] = []
|
||||
for template in _kling_templates():
|
||||
detection = _detect_fixed_mark(
|
||||
image_bgr,
|
||||
template,
|
||||
relative_heights=_KLING_RELATIVE_HEIGHTS,
|
||||
search_origin=(0.64, 0.84),
|
||||
kernel_fraction=0.18,
|
||||
prefer_larger_within=0.06,
|
||||
normalized=normalized,
|
||||
frame_index=frame_index,
|
||||
)
|
||||
if detection.region is None:
|
||||
continue
|
||||
x, y, width, height = detection.region
|
||||
left_padding = round(height * 1.8)
|
||||
right_padding = round(height * 4.0)
|
||||
vertical_padding = round(height * 0.4)
|
||||
region = _bounded_region(
|
||||
x - left_padding,
|
||||
y - vertical_padding,
|
||||
width + left_padding + right_padding,
|
||||
height + vertical_padding * 2,
|
||||
frame_width=frame_width,
|
||||
frame_height=frame_height,
|
||||
)
|
||||
expanded.append(
|
||||
FrameLocalization(
|
||||
frame_index,
|
||||
detection.confidence,
|
||||
region,
|
||||
)
|
||||
)
|
||||
if not expanded:
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
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
|
||||
]
|
||||
font_candidate = None if not edge_candidates else max(edge_candidates, key=lambda candidate: candidate.confidence)
|
||||
|
||||
logo = _detect_fixed_mark(
|
||||
image_bgr,
|
||||
_kling_logo_template(),
|
||||
relative_heights=tuple(value / 1000 for value in range(20, 61, 3)),
|
||||
search_origin=(0.62, 0.90),
|
||||
kernel_fraction=0.18,
|
||||
prefer_larger_within=0.03,
|
||||
normalized=normalized,
|
||||
frame_index=frame_index,
|
||||
)
|
||||
logo_candidate: FrameLocalization | None = None
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
best = font_candidate or logo_candidate
|
||||
if (
|
||||
font_candidate is not None
|
||||
and font_candidate.region is not None
|
||||
and logo_candidate is not None
|
||||
and logo_x is not None
|
||||
):
|
||||
font_x, _, font_width, _ = font_candidate.region
|
||||
if logo_x <= font_x + round(font_width * 0.50):
|
||||
best = logo_candidate
|
||||
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:
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
return best
|
||||
|
||||
|
||||
def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
"""Locate the strongest current-diamond or legacy-text Veo candidate."""
|
||||
if image_bgr.size == 0:
|
||||
@@ -599,6 +844,38 @@ def stabilize_dola_localizations(
|
||||
)
|
||||
|
||||
|
||||
def stabilize_hailuo_localizations(
|
||||
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
|
||||
) -> list[Region | None]:
|
||||
"""Accept a recurring MINIMAX/Hailuo label at a fixed position."""
|
||||
return _stabilize_localizations(
|
||||
detections,
|
||||
provenance=False,
|
||||
weak_floor=_HAILUO_WEAK_CONFIDENCE,
|
||||
strong_floor=_HAILUO_STRONG_CONFIDENCE,
|
||||
transition_floor=0.28,
|
||||
min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES,
|
||||
cover_after_confirmation=True,
|
||||
anchor_iou=0.80,
|
||||
)
|
||||
|
||||
|
||||
def stabilize_kling_localizations(
|
||||
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
|
||||
) -> list[Region | None]:
|
||||
"""Accept a recurring versioned Kling label at a fixed position."""
|
||||
return _stabilize_localizations(
|
||||
detections,
|
||||
provenance=False,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def _stabilize_localizations(
|
||||
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
|
||||
*,
|
||||
@@ -734,6 +1011,16 @@ def scan_dola_video(source: Path) -> VideoScan:
|
||||
return _scan_video(source, detect_dola_frame)
|
||||
|
||||
|
||||
def scan_hailuo_video(source: Path) -> VideoScan:
|
||||
"""Decode a video once and collect one untrusted Hailuo candidate per frame."""
|
||||
return _scan_video(source, detect_hailuo_frame)
|
||||
|
||||
|
||||
def scan_kling_video(source: Path) -> VideoScan:
|
||||
"""Decode a video once and collect one untrusted Kling candidate per frame."""
|
||||
return _scan_video(source, detect_kling_frame)
|
||||
|
||||
|
||||
def _mask_for_region(
|
||||
frame_bgr: NDArray[Any],
|
||||
region: Region,
|
||||
|
||||
+231
-34
@@ -14,6 +14,7 @@ from remove_ai_watermarks.cli import main
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -81,6 +82,71 @@ def _video_with_tc260_ebml(path: Path, *, value: bytes = _TC260_AIGC) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def _riff_chunk(chunk_id: bytes, payload: bytes) -> bytes:
|
||||
return chunk_id + len(payload).to_bytes(4, "little") + payload + (b"\x00" if len(payload) & 1 else b"")
|
||||
|
||||
|
||||
def _video_with_tc260_avi(path: Path, *, value: bytes = _TC260_AIGC) -> Path:
|
||||
info = _riff_chunk(b"AIGC", value) + _riff_chunk(b"INAM", b"standard title\x00")
|
||||
body = b"AVI " + _riff_chunk(b"LIST", b"INFO" + info) + _riff_chunk(b"JUNK", _VIDEO_PAYLOAD)
|
||||
path.write_bytes(b"RIFF" + len(body).to_bytes(4, "little") + body)
|
||||
return path
|
||||
|
||||
|
||||
def _amf0_string(value: bytes) -> bytes:
|
||||
return b"\x02" + len(value).to_bytes(2, "big") + value
|
||||
|
||||
|
||||
def _video_with_tc260_flv(path: Path, *, value: bytes = _TC260_AIGC) -> Path:
|
||||
payload = (
|
||||
_amf0_string(b"onMetaData")
|
||||
+ b"\x08\x00\x00\x00\x02"
|
||||
+ len(b"AIGC").to_bytes(2, "big")
|
||||
+ b"AIGC"
|
||||
+ _amf0_string(value)
|
||||
+ len(b"duration").to_bytes(2, "big")
|
||||
+ b"duration"
|
||||
+ b"\x00"
|
||||
+ b"\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
+ b"\x00\x00\x09"
|
||||
)
|
||||
tag_header = b"\x12" + len(payload).to_bytes(3, "big") + b"\x00" * 7
|
||||
path.write_bytes(
|
||||
b"FLV\x01\x05\x00\x00\x00\x09"
|
||||
+ b"\x00\x00\x00\x00"
|
||||
+ tag_header
|
||||
+ payload
|
||||
+ (11 + len(payload)).to_bytes(4, "big")
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
_LEGACY_VIDEO_CASES = (
|
||||
(".avi", _video_with_tc260_avi),
|
||||
(".flv", _video_with_tc260_flv),
|
||||
)
|
||||
|
||||
|
||||
def _stamp_gray_mark(
|
||||
frame: np.ndarray,
|
||||
mark: Image.Image | np.ndarray,
|
||||
*,
|
||||
x: int,
|
||||
y: int,
|
||||
opacity: float,
|
||||
) -> None:
|
||||
"""Alpha-composite a grayscale synthetic mark onto a BGR test frame."""
|
||||
mark_array = np.asarray(mark, dtype=np.float32)
|
||||
height, width = mark_array.shape
|
||||
alpha = mark_array[:, :, None] / 255 * opacity
|
||||
crop = frame[y : y + height, x : x + width].astype(np.float32)
|
||||
frame[y : y + height, x : x + width] = np.clip(
|
||||
crop * (1 - alpha) + 255 * alpha,
|
||||
0,
|
||||
255,
|
||||
).astype(np.uint8)
|
||||
|
||||
|
||||
def _regeneration_metrics(
|
||||
*,
|
||||
frames: int = 24,
|
||||
@@ -224,6 +290,47 @@ class TestVideoMetadataApi:
|
||||
assert report.has_ai_metadata is False
|
||||
assert report.markers == {}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("suffix", "factory"),
|
||||
_LEGACY_VIDEO_CASES,
|
||||
)
|
||||
def test_inspects_native_tc260_legacy_video_metadata(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
suffix: str,
|
||||
factory: Callable[..., Path],
|
||||
):
|
||||
from remove_ai_watermarks.video import inspect_video_metadata
|
||||
|
||||
source = factory(tmp_path / f"source{suffix}")
|
||||
|
||||
report = inspect_video_metadata(source)
|
||||
|
||||
assert report.has_ai_metadata is True
|
||||
assert report.markers["aigc_label"].endswith("producer 00119144030008867405X210002")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("suffix", "factory"),
|
||||
_LEGACY_VIDEO_CASES,
|
||||
)
|
||||
def test_ignores_generic_legacy_video_aigc_tag(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
suffix: str,
|
||||
factory: Callable[..., Path],
|
||||
):
|
||||
from remove_ai_watermarks.video import inspect_video_metadata
|
||||
|
||||
source = factory(
|
||||
tmp_path / f"source{suffix}",
|
||||
value=b'{"description":"ordinary application metadata"}',
|
||||
)
|
||||
|
||||
report = inspect_video_metadata(source)
|
||||
|
||||
assert report.has_ai_metadata is False
|
||||
assert report.markers == {}
|
||||
|
||||
def test_rejects_image_input(self, tmp_clean_png: Path):
|
||||
from remove_ai_watermarks.video import inspect_video_metadata
|
||||
|
||||
@@ -404,9 +511,7 @@ class TestSoraFrameLocalization:
|
||||
draw.text((68, 1), "Sora", font=font, fill=255, stroke_width=1)
|
||||
mark_array = cv2.resize(np.asarray(mark), (124, 44), interpolation=cv2.INTER_AREA)
|
||||
x, y = 620, 398
|
||||
alpha = mark_array.astype(np.float32)[:, :, None] / 255 * 0.78
|
||||
crop = frame[y : y + 44, x : x + 124].astype(np.float32)
|
||||
frame[y : y + 44, x : x + 124] = np.clip(crop * (1 - alpha) + 255 * alpha, 0, 255).astype(np.uint8)
|
||||
_stamp_gray_mark(frame, mark_array, x=x, y=y, opacity=0.78)
|
||||
return frame, (x, y, 124, 44)
|
||||
|
||||
def test_localizes_independently_rendered_sora_like_mark(self):
|
||||
@@ -448,13 +553,7 @@ class TestVeoFrameLocalization:
|
||||
(round(size * 0.39), round(size * 0.38)),
|
||||
)
|
||||
ImageDraw.Draw(mark).polygon(points, fill=255)
|
||||
alpha = np.asarray(mark, dtype=np.float32)[:, :, None] / 255 * 0.72
|
||||
crop = frame[y : y + size, x : x + size].astype(np.float32)
|
||||
frame[y : y + size, x : x + size] = np.clip(
|
||||
crop * (1 - alpha) + 255 * alpha,
|
||||
0,
|
||||
255,
|
||||
).astype(np.uint8)
|
||||
_stamp_gray_mark(frame, mark, x=x, y=y, opacity=0.72)
|
||||
|
||||
detection = detect_veo_frame(frame)
|
||||
|
||||
@@ -478,13 +577,7 @@ class TestVeoFrameLocalization:
|
||||
mark_height, mark_width = mark_array.shape
|
||||
x = frame.shape[1] - mark_width - 20
|
||||
y = frame.shape[0] - mark_height - 18
|
||||
alpha = mark_array.astype(np.float32)[:, :, None] / 255 * 0.66
|
||||
crop = frame[y : y + mark_height, x : x + mark_width].astype(np.float32)
|
||||
frame[y : y + mark_height, x : x + mark_width] = np.clip(
|
||||
crop * (1 - alpha) + 255 * alpha,
|
||||
0,
|
||||
255,
|
||||
).astype(np.uint8)
|
||||
_stamp_gray_mark(frame, mark_array, x=x, y=y, opacity=0.66)
|
||||
|
||||
detection = detect_veo_frame(frame)
|
||||
|
||||
@@ -528,15 +621,8 @@ class TestByteDanceFrameLocalization:
|
||||
except TypeError:
|
||||
font = ImageFont.load_default()
|
||||
draw.text((18, 8), "AI", font=font, fill=255)
|
||||
mark_array = np.asarray(mark, dtype=np.float32)
|
||||
x, y = 1130, 620
|
||||
alpha = mark_array[:, :, None] / 255 * 0.65
|
||||
crop = frame[y : y + 60, x : x + 80].astype(np.float32)
|
||||
frame[y : y + 60, x : x + 80] = np.clip(
|
||||
crop * (1 - alpha) + 255 * alpha,
|
||||
0,
|
||||
255,
|
||||
).astype(np.uint8)
|
||||
_stamp_gray_mark(frame, mark, x=x, y=y, opacity=0.65)
|
||||
|
||||
detection = detect_seedance_frame(frame)
|
||||
|
||||
@@ -564,13 +650,7 @@ class TestByteDanceFrameLocalization:
|
||||
mark_height, mark_width = mark.shape
|
||||
x = frame.shape[1] - mark_width - 18
|
||||
y = frame.shape[0] - mark_height - 14
|
||||
alpha = mark.astype(np.float32)[:, :, None] / 255 * 0.75
|
||||
crop = frame[y : y + mark_height, x : x + mark_width].astype(np.float32)
|
||||
frame[y : y + mark_height, x : x + mark_width] = np.clip(
|
||||
crop * (1 - alpha) + 255 * alpha,
|
||||
0,
|
||||
255,
|
||||
).astype(np.uint8)
|
||||
_stamp_gray_mark(frame, mark, x=x, y=y, opacity=0.75)
|
||||
|
||||
detection = detect_dola_frame(frame)
|
||||
|
||||
@@ -594,6 +674,92 @@ class TestByteDanceFrameLocalization:
|
||||
assert mask[84, 104] == 0
|
||||
|
||||
|
||||
class TestAdditionalProviderFrameLocalization:
|
||||
def test_localizes_independently_rendered_hailuo_label(self):
|
||||
from remove_ai_watermarks.video_visible import _region_iou, detect_hailuo_frame
|
||||
|
||||
frame = np.full((720, 1280, 3), 32, dtype=np.uint8)
|
||||
mark = Image.new("L", (330, 54), 0)
|
||||
draw = ImageDraw.Draw(mark)
|
||||
try:
|
||||
font = ImageFont.load_default(size=28)
|
||||
except TypeError:
|
||||
font = ImageFont.load_default()
|
||||
for index, height in enumerate((20, 34, 46, 34, 20)):
|
||||
x = 4 + index * 6
|
||||
draw.rounded_rectangle((x, 27 - height // 2, x + 2, 27 + height // 2), radius=1, fill=255)
|
||||
draw.text((39, 8), "MINIMAX", font=font, fill=255)
|
||||
draw.rectangle((164, 7, 166, 47), fill=255)
|
||||
draw.ellipse((178, 8, 224, 50), outline=255, width=5)
|
||||
draw.text((228, 8), "hailuo AI", font=font, fill=255)
|
||||
x, y = 930, 650
|
||||
_stamp_gray_mark(frame, mark, x=x, y=y, opacity=0.75)
|
||||
|
||||
detection = detect_hailuo_frame(frame)
|
||||
|
||||
assert detection.region is not None
|
||||
assert detection.confidence >= 0.24
|
||||
assert _region_iou(detection.region, (x, y, 330, 54)) >= 0.45
|
||||
|
||||
def test_localizes_kling_core_and_covers_version_suffix(self):
|
||||
from remove_ai_watermarks.video_visible import detect_kling_frame
|
||||
|
||||
frame = np.full((720, 1280, 3), 28, dtype=np.uint8)
|
||||
mark = np.zeros((42, 245), dtype=np.uint8)
|
||||
cv2.ellipse(mark, (20, 21), (15, 15), 0, 20, 330, 255, 4, cv2.LINE_AA)
|
||||
cv2.putText(
|
||||
mark,
|
||||
"KLING AI 1.6",
|
||||
(43, 31),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.9,
|
||||
255,
|
||||
2,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
x, y = 1018, 664
|
||||
_stamp_gray_mark(frame, mark, x=x, y=y, opacity=0.72)
|
||||
|
||||
detection = detect_kling_frame(frame)
|
||||
glyph_ys, glyph_xs = np.where(mark > 0)
|
||||
glyph_box = (
|
||||
x + int(glyph_xs.min()),
|
||||
y + int(glyph_ys.min()),
|
||||
int(glyph_xs.max() - glyph_xs.min() + 1),
|
||||
int(glyph_ys.max() - glyph_ys.min() + 1),
|
||||
)
|
||||
|
||||
assert detection.region is not None
|
||||
assert detection.confidence >= 0.24
|
||||
detected_x, detected_y, detected_width, detected_height = detection.region
|
||||
glyph_x, glyph_y, glyph_width, glyph_height = glyph_box
|
||||
assert detected_x <= glyph_x
|
||||
assert detected_y <= glyph_y
|
||||
assert detected_x + detected_width >= glyph_x + glyph_width
|
||||
assert detected_y + detected_height >= glyph_y + glyph_height
|
||||
|
||||
def test_rejects_a_saturated_fixed_kling_shape(self):
|
||||
from remove_ai_watermarks.video_visible import detect_kling_frame
|
||||
|
||||
frame = np.full((720, 1280, 3), 24, dtype=np.uint8)
|
||||
cv2.circle(frame, (1040, 670), 15, (0, 220, 0), 5, cv2.LINE_AA)
|
||||
cv2.putText(
|
||||
frame,
|
||||
"KLING AI 1.6",
|
||||
(1065, 681),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.9,
|
||||
(0, 220, 0),
|
||||
2,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
|
||||
detection = detect_kling_frame(frame)
|
||||
|
||||
assert detection.confidence == 0.0
|
||||
assert detection.region is None
|
||||
|
||||
|
||||
class TestSoraTemporalArbiter:
|
||||
_BOX = (40, 60, 150, 54)
|
||||
|
||||
@@ -823,6 +989,35 @@ class TestByteDanceTemporalArbiter:
|
||||
assert not has_bytedance_video_provenance({"issuer": "BytePlus (ByteDance)"})
|
||||
|
||||
|
||||
class TestAdditionalProviderTemporalArbiter:
|
||||
_HAILUO_BOX = (930, 650, 330, 54)
|
||||
_KLING_BOX = (1018, 664, 245, 42)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stabilizer_name", "box", "weak_score", "strong_score"),
|
||||
[
|
||||
("stabilize_hailuo_localizations", _HAILUO_BOX, 0.31, 0.35),
|
||||
("stabilize_kling_localizations", _KLING_BOX, 0.21, 0.25),
|
||||
],
|
||||
)
|
||||
def test_requires_a_strong_anchored_twelve_frame_run(
|
||||
self,
|
||||
stabilizer_name: str,
|
||||
box: tuple[int, int, int, int],
|
||||
weak_score: float,
|
||||
strong_score: float,
|
||||
):
|
||||
from remove_ai_watermarks import video_visible
|
||||
from remove_ai_watermarks.video_visible import FrameLocalization
|
||||
|
||||
stabilize = getattr(video_visible, stabilizer_name)
|
||||
weak = [FrameLocalization(index, weak_score, box) for index in range(12)]
|
||||
strong = [FrameLocalization(index, strong_score, box) for index in range(12)]
|
||||
|
||||
assert stabilize(weak) == [None] * 12
|
||||
assert stabilize(strong) == [box] * 12
|
||||
|
||||
|
||||
class TestVideoVisibleApi:
|
||||
def test_removes_stable_sora_run_and_writes_output(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
from remove_ai_watermarks import video_visible
|
||||
@@ -932,9 +1127,11 @@ class TestVideoVisibleApi:
|
||||
[
|
||||
("seedance", "scan_seedance_video", "box"),
|
||||
("dola", "scan_dola_video", "box"),
|
||||
("hailuo", "scan_hailuo_video", "box"),
|
||||
("kling", "scan_kling_video", "box"),
|
||||
],
|
||||
)
|
||||
def test_dispatches_bytedance_detectors(
|
||||
def test_dispatches_fixed_mark_detectors(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -985,7 +1182,7 @@ class TestVideoVisibleCli:
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "temporally stable" in result.output
|
||||
assert "sora|veo|seedance|dola" in result.output
|
||||
assert "sora|veo|seedance|dola|hailuo|kling" in result.output
|
||||
|
||||
def test_reports_removed_frames(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
from remove_ai_watermarks import video
|
||||
|
||||
Reference in New Issue
Block a user