From 41b6fd06821b68c2cfc1b24c9b909225ac9d052a Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Wed, 29 Jul 2026 17:56:52 -0700 Subject: [PATCH] Add Seedance and Dola video removal --- CLAUDE.md | 4 +- README.md | 27 ++- docs/cli.md | 15 +- docs/known-limitations.md | 22 ++- docs/module-internals.md | 40 ++-- docs/python-api.md | 20 +- docs/supported-signals.md | 2 + docs/watermarking-landscape.md | 15 +- src/remove_ai_watermarks/__init__.py | 2 +- src/remove_ai_watermarks/cli.py | 2 +- src/remove_ai_watermarks/video.py | 41 ++++- src/remove_ai_watermarks/video_visible.py | 215 +++++++++++++++++++++- tests/test_video.py | 191 ++++++++++++++++++- 13 files changed, 529 insertions(+), 67 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 01b7bea..f8cc4dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Per-command exit-code semantics (the no-signal / GPU-missing skip branches), tes - `uv run remove-ai-watermarks metadata --remove -o ` — strip all AI metadata - `uv run remove-ai-watermarks video metadata --check` — inspect AI metadata in MP4/MOV/M4V/WebM/MKV - `uv run remove-ai-watermarks video metadata --remove -o ` — strip verified video metadata without transcoding streams; the experimental video path requires a separate same-container output and defaults to `_clean` -- `uv run remove-ai-watermarks video visible -o ` — remove a temporally recurring Sora mark, or pass `--mark veo` for the current Veo diamond and legacy `Veo` text. 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 -o ` — 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 batch ` — process every supported image in a directory (output defaults to `_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`, and `remove_video_visible(source, output=None, *, mark="sora", backend="cv2", strip_metadata=True) -> VideoVisibleResult`. 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 and Veo 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. 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. 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 and invisible video watermarks 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`, and `remove_video_visible(source, output=None, *, mark="sora", backend="cv2", strip_metadata=True) -> VideoVisibleResult`. 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 and invisible video watermarks are not built yet. For the Doubao alpha-distillation history (why content-image reverse-alpha distillation fails by physics and controlled captures were required), see `docs/research-doubao-distillation.md`. diff --git a/README.md b/README.md index cde4b17..013830e 100644 --- a/README.md +++ b/README.md @@ -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 plus experimental visible -Sora-wordmark removal. Invisible video-watermark removal remains a follow-up -stage. +Sora, Veo, Seedance, and Dola mark removal. Invisible video-watermark removal +remains a follow-up stage. > 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 @@ stage. | Erase a region you select | `erase` | No | | Strip AI metadata | `metadata` | No | | Strip AI metadata from video | `video metadata` | No | -| Remove a known Sora or Veo mark from video | `video visible` | No | +| Remove a known Sora, Veo, Seedance, or Dola mark from video | `video visible` | No | | Regenerate an image to disrupt invisible watermarks | `invisible` | Recommended | | Run visible, invisible, and metadata removal | `all` | Recommended | | Process a directory | `batch` | Depends on mode | @@ -78,11 +78,13 @@ inspection includes the native TC260 `AIGC` tag in MKV and WebM inspection reads the normative `Segment.Tags.Tag.SimpleTag` placement. -Remove a moving Sora wordmark or a Veo corner mark: +Remove a supported visible video mark: ```bash 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 ``` This path scans the complete sequence before changing pixels. It accepts only a @@ -90,8 +92,9 @@ mark that repeats at a stable position across adjacent frames, then reuses the 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 in the bottom-right corner. No -output is written when no stable mark is found. +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. For invisible watermark removal, install the diffusion dependencies: @@ -243,6 +246,12 @@ report = raiw.inspect_video_metadata("input.mp4") cleaned = raiw.remove_video_metadata("input.mp4") visible = raiw.remove_video_visible("sora.mp4", "sora_clean.mp4") veo = raiw.remove_video_visible("veo.mp4", "veo_clean.mp4", mark="veo") +seedance = raiw.remove_video_visible( + "seedance.mp4", + "seedance_clean.mp4", + mark="seedance", +) +dola = raiw.remove_video_visible("dola.mp4", "dola_clean.mp4", mark="dola") ``` The high level API accepts a file path or a BGR NumPy array. For path inputs it @@ -267,9 +276,9 @@ invisible removal. and selected fill backend. - Invisible removal changes the whole image and may alter faces, text, or fine detail. -- Visible video removal recognizes the moving Sora 2 wordmark and the current - Veo diamond plus legacy `Veo` text. It does not recognize the older Sora Turbo - corner swirl. +- 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. The classical OpenCV backend can smear structured backgrounds; use MI-GAN or LaMa when recovery quality matters. Video SynthID is not removed yet. MP4/MOV/M4V metadata stripping currently reads the full container into diff --git a/docs/cli.md b/docs/cli.md index c8ba68a..293082d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -153,18 +153,21 @@ different container extension. Visible video labels and invisible video watermarks are not handled by this command. -## Remove a visible Sora or Veo video mark +## Remove a supported visible video mark ```bash 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 ``` -The experimental command supports the moving Sora mascot and wordmark plus two -Veo corner variants: the current four-point diamond and the legacy `Veo` text. -Sora searches the whole frame at multiple scales. Veo searches the -bottom-right corner using separate synthetic silhouettes for the two variants. -Both require a spatially recurring candidate across adjacent frames. Matching +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 +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. diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 35b4a93..627f7dc 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -174,16 +174,19 @@ WebM, Matroska, MP3, WAV, FLAC, OGG, Opus, and AAC container metadata is strippe through ffmpeg with stream copying. The operation fails if ffmpeg is absent or cannot parse the input. -### Visible video removal supports Sora and Veo and is still experimental +### Visible video removal is provider-specific and still experimental The experimental `video metadata` command and high level video API inspect and 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, and the legacy -`Veo` text. Detection requires a recurring visual candidate across adjacent -frames. Provider provenance can recover low-contrast runs only after visual -evidence exists, so metadata alone does not erase a clean API export. +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. 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 and proprietary @@ -194,9 +197,12 @@ fill is not a motion-aware video inpainting model. OpenCV can leave a visible smear where the mark overlaps a hard edge or structured texture, and the smear can vary over time. MI-GAN and LaMa improve individual frames but do not guarantee temporal coherence. The Veo diamond uses a shape mask to limit damage -outside the symbol, but OpenCV may still soften texture inside it. The current -encoder also emits a constant-frame-rate output at the decoded stream rate, so -variable-frame-rate preservation is not yet guaranteed. +outside the symbol. Seedance fills the full localized box because a synthetic +outline mask left part of the real translucent border visible in an end-to-end +check. OpenCV may therefore soften texture inside that small box; use MI-GAN or +LaMa when reconstruction quality matters. The current encoder also emits a +constant-frame-rate output at the decoded stream rate, so variable-frame-rate +preservation is not yet guaranteed. Native TC260 metadata in MP4/MOV is supported at its normative `moov.udta.meta.keys/ilst` placement, including non-faststart files whose diff --git a/docs/module-internals.md b/docs/module-internals.md index cb4dba6..8713b7f 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -115,32 +115,34 @@ only a `Segment.Tags.Tag.SimpleTag` pairing `TagName=AIGC` with a JSON those container tags without transcoding the encoded streams. [`video_visible.py`](../src/remove_ai_watermarks/video_visible.py) implements -the first pixel stages for Sora and Veo. 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, with bounded bottom-right searches calibrated -independently from Sora. A strong relocated-diamond match may bypass the known +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. -This prevents recurring scene details in clean API exports from being promoted -to a watermark. 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. A confirmed Veo -run can cover low-contrast frames at its fixed position. This separation keeps -clean API exports from being modified merely because metadata names the same -generator. +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. -Removal runs in a second decode pass. Sora and legacy Veo text use padded box -masks. The square Veo diamond uses a synthetic shape mask so transparent box -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, 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. The inherited ISOBMFF metadata path currently reads the complete container into memory; replacing that with a streaming box copier is a prerequisite for large diff --git a/docs/python-api.md b/docs/python-api.md index 24a4fbb..632fc29 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -154,7 +154,7 @@ stream bytes. MKV/WebM inspection recognizes the corresponding `Segment.Tags.Tag.SimpleTag` representation; its removal requires ffmpeg for a stream-copy remux. -## Remove a visible Sora or Veo video mark +## Remove a supported visible video mark ```python import remove_ai_watermarks as raiw @@ -173,14 +173,26 @@ veo_result = raiw.remove_video_visible( "veo_clean.mp4", mark="veo", ) +seedance_result = raiw.remove_video_visible( + "seedance.mp4", + "seedance_clean.mp4", + mark="seedance", +) +dola_result = raiw.remove_video_visible( + "dola.mp4", + "dola_clean.mp4", + mark="dola", +) ``` `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` and `veo`. The Veo detector recognizes the -current four-point diamond and the legacy `Veo` text with separate synthetic -silhouettes. +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. The returned `VideoVisibleResult` records the total, detected, and removed frame counts plus any AI metadata that survived the output encode. The function diff --git a/docs/supported-signals.md b/docs/supported-signals.md index ba19ef5..28ed2d7 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -35,6 +35,8 @@ when you can select the affected area yourself. | --- | --- | --- | --- | | `sora` | Sora 2 mascot and wordmark | Moves among frame positions | Requires a temporally recurring visual match; the older Sora Turbo corner swirl is a different unsupported mark. | | `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. | 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. diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index d350f7c..9e736c2 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -56,8 +56,8 @@ 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 and Veo removal. Other visible video logos and proprietary - audio-watermark detection remain outside the package. + visible Sora, Veo, Seedance, and Dola 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. @@ -90,6 +90,17 @@ pixels or alpha maps from that project: it uses two synthetic silhouettes, known-layout searches plus a strong relocated-diamond fallback, and a separate temporal arbiter calibrated against raw watermarked clips and clean API exports. +**ByteDance video surfaces use distinct visible labels.** Public Seedance +showcase clips contain a fixed rounded box with `AI`, while the Dola sample in +[issue #16](https://github.com/wiltodelta/remove-ai-watermarks/issues/16) uses +fixed `Dola AI` text. The independent +[Seedance remover](https://github.com/SamurAIGPT/seedance-2.0-watermark-remover) +estimates a static corner from a temporal mean frame and edge density. Our +implementation instead matches provider-specific synthetic silhouettes on +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. + **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. diff --git a/src/remove_ai_watermarks/__init__.py b/src/remove_ai_watermarks/__init__.py index 8ebbbcd..9e6f545 100644 --- a/src/remove_ai_watermarks/__init__.py +++ b/src/remove_ai_watermarks/__init__.py @@ -8,7 +8,7 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap):: raiw.visible_provenance("in.png") # -> frozenset of confirmed vendors raiw.inspect_video_metadata("in.mp4") # -> VideoMetadataReport raiw.remove_video_metadata("in.mp4", "out.mp4") # verified metadata strip - raiw.remove_video_visible("in.mp4", "out.mp4") # stable Sora or Veo mark removal + raiw.remove_video_visible("in.mp4", "out.mp4") # stable visible video-mark removal For a provenance verdict use the ``identify`` submodule:: diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index 02f0236..671a80b 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -1152,7 +1152,7 @@ def cmd_video_metadata( ) @click.option( "--mark", - type=click.Choice(["sora", "veo"]), + type=click.Choice(["sora", "veo", "seedance", "dola"]), default="sora", help="Visible AI mark to remove.", ) diff --git a/src/remove_ai_watermarks/video.py b/src/remove_ai_watermarks/video.py index 1fcb364..2928bf5 100644 --- a/src/remove_ai_watermarks/video.py +++ b/src/remove_ai_watermarks/video.py @@ -1,8 +1,8 @@ """High-level video processing API. Supported experimental stages are container-level AI metadata inspection and -removal plus temporally stabilized visible Sora and Veo removal. The pixel path -reuses the image package's shared fill backends. +removal plus temporally stabilized visible Sora, Veo, Seedance, and Dola +removal. The pixel path reuses the image package's shared fill backends. """ from __future__ import annotations @@ -133,26 +133,31 @@ def remove_video_visible( ) -> VideoVisibleResult: """Remove a supported visible AI wordmark from a video. - Supported marks are ``sora`` and ``veo``. 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``, 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``. """ from remove_ai_watermarks.metadata import get_ai_metadata from remove_ai_watermarks.video_visible import ( encode_clean_video, + has_bytedance_video_provenance, has_sora_provenance, has_veo_provenance, + scan_dola_video, + scan_seedance_video, scan_sora_video, scan_veo_video, + stabilize_dola_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"}: - raise ValueError("Unsupported visible video mark; expected 'sora' or 'veo'") + if mark not in {"sora", "veo", "seedance", "dola"}: + raise ValueError("Unsupported visible video mark; expected sora, veo, seedance, or dola") if backend not in {"auto", "cv2", "migan", "lama"}: raise ValueError("Unsupported fill backend; expected auto, cv2, migan, or lama") @@ -167,7 +172,7 @@ def remove_video_visible( ) padding_fraction = 0.28 mask_style = "box" - else: + elif mark == "veo": scan = scan_veo_video(source_path) regions = stabilize_veo_localizations( scan.detections, @@ -175,6 +180,22 @@ def remove_video_visible( ) padding_fraction = 0.18 mask_style = "veo" + elif mark == "seedance": + scan = scan_seedance_video(source_path) + regions = stabilize_seedance_localizations( + scan.detections, + provenance=has_bytedance_video_provenance(markers), + ) + padding_fraction = 0.0 + mask_style = "box" + else: + scan = scan_dola_video(source_path) + regions = stabilize_dola_localizations( + scan.detections, + provenance=has_bytedance_video_provenance(markers), + ) + padding_fraction = 0.20 + mask_style = "box" detected_frames = sum(region is not None for region in regions) if detected_frames == 0: return VideoVisibleResult( diff --git a/src/remove_ai_watermarks/video_visible.py b/src/remove_ai_watermarks/video_visible.py index 7b15ca0..28ce876 100644 --- a/src/remove_ai_watermarks/video_visible.py +++ b/src/remove_ai_watermarks/video_visible.py @@ -1,9 +1,10 @@ """Visible AI-watermark localization and removal for video. -Supported marks use fully synthetic silhouettes made from geometric primitives -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 the -legacy ``Veo`` text in the bottom-right corner. A single frame is never enough +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. @@ -53,8 +54,14 @@ _SORA_STRONG_CONFIDENCE = 0.65 _VEO_PROVENANCE_WEAK_CONFIDENCE = 0.45 _VEO_STRICT_WEAK_CONFIDENCE = 0.50 _VEO_STRONG_CONFIDENCE = 0.55 +_SEEDANCE_WEAK_CONFIDENCE = 0.38 +_SEEDANCE_STRONG_CONFIDENCE = 0.43 +_DOLA_PROVENANCE_WEAK_CONFIDENCE = 0.48 +_DOLA_STRICT_WEAK_CONFIDENCE = 0.50 +_DOLA_STRONG_CONFIDENCE = 0.52 _MIN_STABLE_FRAMES = 5 _MIN_VEO_STABLE_FRAMES = 12 +_MIN_FIXED_MARK_STABLE_FRAMES = 12 _MAX_STABLE_GAP = 2 _STABLE_IOU = 0.55 _VEO_REFERENCE_SHORT_SIDE = 720 @@ -63,6 +70,7 @@ _VEO_DIAMOND_PROFILES = ( (48, 72, 72), (44, 29, 40), ) +_DOLA_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(22, 41)) @dataclass(frozen=True) @@ -155,6 +163,43 @@ def _veo_templates() -> tuple[NDArray[Any], NDArray[Any]]: return np.asarray(diamond_canvas, dtype=np.uint8), text +@lru_cache(maxsize=1) +def _seedance_template() -> NDArray[Any]: + """Return a synthetic boxed-AI silhouette for Seedance exports.""" + canvas = Image.new("L", (160, 120), 0) + draw = ImageDraw.Draw(canvas) + draw.rounded_rectangle((8, 8, 142, 105), radius=28, outline=255, width=7) + draw.text( + (35, 17), + "AI", + font=_scalable_default_font(70), + fill=255, + stroke_width=1, + stroke_fill=255, + ) + draw.rounded_rectangle((142, 94, 157, 109), radius=3, outline=255, width=2) + draw.text((145, 94), "AI", font=_scalable_default_font(8), fill=255) + return np.asarray(canvas, dtype=np.uint8) + + +@lru_cache(maxsize=1) +def _dola_template() -> NDArray[Any]: + """Return a synthetic Dola AI text silhouette using OpenCV's font.""" + canvas = np.zeros((100, 400), dtype=np.uint8) + cv2.putText( + canvas, + "Dola AI", + (2, 72), + cv2.FONT_HERSHEY_DUPLEX, + 2.2, + 255, + 3, + cv2.LINE_AA, + ) + ys, xs = np.where(canvas > 0) + return canvas[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1] + + def _top_hat(gray: NDArray[Any]) -> NDArray[Any]: kernel = np.ones((7, 7), dtype=np.uint8) return cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel) @@ -270,6 +315,105 @@ def _match_template( ) +def _restore_region( + region: Region | None, + *, + scale: float, + frame_width: int, + frame_height: int, +) -> Region | None: + """Map a localization from normalized pixels back to the source frame.""" + if region is None: + return None + x, y, width, height = region + source_x = round(x / scale) + source_y = round(y / scale) + source_width = min(frame_width - source_x, max(1, round(width / scale))) + source_height = min(frame_height - source_y, max(1, round(height / scale))) + return source_x, source_y, source_width, source_height + + +def _detect_fixed_bottom_right_mark( + image_bgr: NDArray[Any], + template: NDArray[Any], + *, + relative_heights: tuple[float, ...], + search_origin: tuple[float, float], + kernel_fraction: float, + frame_index: int, +) -> FrameLocalization: + """Match one fixed bottom-right synthetic mark on a normalized frame.""" + 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) + normalized_height, normalized_width = gray.shape[:2] + short_side = min(normalized_height, normalized_width) + search_x = round(normalized_width * search_origin[0]) + search_y = round(normalized_height * search_origin[1]) + search_region = ( + search_x, + search_y, + normalized_width - search_x, + normalized_height - search_y, + ) + best_confidence = 0.0 + best_region: Region | None = None + 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])) + resized = cv2.resize( + template, + (template_width, template_height), + interpolation=cv2.INTER_AREA, + ) + confidence, candidate = _match_template( + gray, + resized, + region=search_region, + kernel_size=max(3, round(template_height * kernel_fraction) | 1), + ) + if confidence > best_confidence: + best_confidence = confidence + best_region = candidate + + return FrameLocalization( + frame_index, + best_confidence, + _restore_region( + best_region, + scale=scale, + frame_width=frame_width, + frame_height=frame_height, + ), + ) + + +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( + image_bgr, + _seedance_template(), + relative_heights=(0.065, 0.075, 0.085, 0.095, 0.105), + search_origin=(0.68, 0.72), + kernel_fraction=0.12, + frame_index=frame_index, + ) + + +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( + image_bgr, + _dola_template(), + relative_heights=_DOLA_RELATIVE_HEIGHTS, + search_origin=(0.65, 0.85), + kernel_fraction=0.50, + frame_index=frame_index, + ) + + 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: @@ -413,6 +557,43 @@ def stabilize_veo_localizations( ) +def stabilize_seedance_localizations( + detections: tuple[FrameLocalization, ...] | list[FrameLocalization], + *, + provenance: bool, +) -> list[Region | None]: + """Accept a recurring Seedance boxed-AI mark at a fixed position.""" + return _stabilize_localizations( + detections, + provenance=provenance, + weak_floor=_SEEDANCE_WEAK_CONFIDENCE, + strong_floor=_SEEDANCE_STRONG_CONFIDENCE, + transition_floor=0.30, + min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES, + cover_after_confirmation=True, + anchor_iou=0.80, + ) + + +def stabilize_dola_localizations( + detections: tuple[FrameLocalization, ...] | list[FrameLocalization], + *, + provenance: bool, +) -> list[Region | None]: + """Accept a recurring Dola AI text mark at a fixed position.""" + weak_floor = _DOLA_PROVENANCE_WEAK_CONFIDENCE if provenance else _DOLA_STRICT_WEAK_CONFIDENCE + return _stabilize_localizations( + detections, + provenance=provenance, + weak_floor=weak_floor, + strong_floor=_DOLA_STRONG_CONFIDENCE, + transition_floor=0.40, + min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES, + cover_after_confirmation=True, + anchor_iou=0.80, + ) + + def _stabilize_localizations( detections: tuple[FrameLocalization, ...] | list[FrameLocalization], *, @@ -422,6 +603,7 @@ def _stabilize_localizations( transition_floor: float, min_stable_frames: int, cover_after_confirmation: bool, + anchor_iou: float | None = None, ) -> list[Region | None]: """Apply the shared recurrence policy to provider-specific candidates.""" accepted: list[Region | None] = [None] * len(detections) @@ -433,11 +615,14 @@ def _stabilize_localizations( continue if current: previous = detections[current[-1]] + anchor = detections[current[0]] frame_gap = detection.frame_index - previous.frame_index if ( previous.region is None + or anchor.region is None or frame_gap > _MAX_STABLE_GAP + 1 or _region_iou(previous.region, detection.region) < _STABLE_IOU + or (anchor_iou is not None and _region_iou(anchor.region, detection.region) < anchor_iou) ): runs.append(current) current = [] @@ -534,6 +719,16 @@ def scan_veo_video(source: Path) -> VideoScan: return _scan_video(source, detect_veo_frame) +def scan_seedance_video(source: Path) -> VideoScan: + """Decode a video once and collect one untrusted Seedance candidate per frame.""" + return _scan_video(source, detect_seedance_frame) + + +def scan_dola_video(source: Path) -> VideoScan: + """Decode a video once and collect one untrusted Dola candidate per frame.""" + return _scan_video(source, detect_dola_frame) + + def _ffmpeg_video_args(suffix: str) -> list[str]: if suffix == ".webm": return ["-c:v", "libvpx-vp9", "-crf", "18", "-b:v", "0"] @@ -704,3 +899,15 @@ def has_veo_provenance(markers: dict[str, str]) -> bool: ) ).lower() return "google" in identity and "trainedalgorithmicmedia" in markers.get("source_type", "").lower() + + +def has_bytedance_video_provenance(markers: dict[str, str]) -> bool: + """Whether container provenance names ByteDance or BytePlus AI video.""" + identity = " ".join( + ( + markers.get("claim_generator", ""), + markers.get("issuer", ""), + ) + ).lower() + source_type = markers.get("source_type", "").lower() + return ("bytedance" in identity or "byteplus" in identity) and "trainedalgorithmicmedia" in source_type diff --git a/tests/test_video.py b/tests/test_video.py index ebde4be..7b0d73e 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -392,6 +392,85 @@ class TestVeoFrameLocalization: assert mask[67, 67] == 0 +class TestByteDanceFrameLocalization: + def test_localizes_independently_rendered_seedance_box(self): + from remove_ai_watermarks.video_visible import _region_iou, detect_seedance_frame + + frame = np.full((720, 1280, 3), 30, dtype=np.uint8) + mark = Image.new("L", (80, 60), 0) + draw = ImageDraw.Draw(mark) + draw.rounded_rectangle((2, 2, 70, 53), radius=14, outline=255, width=4) + try: + font = ImageFont.load_default(size=35) + 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) + + detection = detect_seedance_frame(frame) + + assert detection.region is not None + assert detection.confidence >= 0.43 + assert _region_iou(detection.region, (x, y, 80, 60)) >= 0.75 + + def test_localizes_independently_rendered_dola_text(self): + from remove_ai_watermarks.video_visible import _region_iou, detect_dola_frame + + frame = np.full((720, 1280, 3), 35, dtype=np.uint8) + mark = np.zeros((40, 150), dtype=np.uint8) + cv2.putText( + mark, + "Dola AI", + (2, 28), + cv2.FONT_HERSHEY_SIMPLEX, + 0.9, + 255, + 2, + cv2.LINE_AA, + ) + ys, xs = np.where(mark > 0) + mark = mark[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1] + 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) + + detection = detect_dola_frame(frame) + + assert detection.region is not None + assert detection.confidence >= 0.52 + assert _region_iou(detection.region, (x, y, mark_width, mark_height)) >= 0.75 + + def test_seedance_box_mask_covers_the_full_localized_mark(self): + from remove_ai_watermarks.video_visible import _mask_for_region + + mask = _mask_for_region( + np.zeros((120, 160, 3), dtype=np.uint8), + (20, 20, 80, 60), + padding_fraction=0.0, + mask_style="box", + ) + + assert mask[15, 15] == 0 + assert mask[16, 16] == 255 + assert mask[83, 103] == 255 + assert mask[84, 104] == 0 + + class TestSoraTemporalArbiter: _BOX = (40, 60, 150, 54) @@ -562,6 +641,65 @@ class TestVeoTemporalArbiter: assert stabilize_veo_localizations(detections, provenance=False) == [None] * 12 +class TestByteDanceTemporalArbiter: + _SEEDANCE_BOX = (1110, 610, 90, 66) + _DOLA_BOX = (1160, 680, 96, 22) + + def test_seedance_requires_twelve_recurring_frames(self): + from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_seedance_localizations + + detections = [FrameLocalization(index, 0.50, self._SEEDANCE_BOX) for index in range(11)] + + assert stabilize_seedance_localizations(detections, provenance=False) == [None] * 11 + + def test_seedance_strong_run_covers_low_contrast_frames(self): + from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_seedance_localizations + + detections = [FrameLocalization(index, 0.45, self._SEEDANCE_BOX) for index in range(12)] + detections.extend(FrameLocalization(index, 0.20, (200, 100, 80, 60)) for index in range(12, 15)) + + assert stabilize_seedance_localizations(detections, provenance=False) == [self._SEEDANCE_BOX] * 15 + + def test_seedance_rejects_a_slowly_drifting_scene_detail(self): + from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_seedance_localizations + + detections = [FrameLocalization(index, 0.46, (1110 - index * 3, 610, 90, 66)) for index in range(14)] + + assert stabilize_seedance_localizations(detections, provenance=False) == [None] * 14 + + def test_dola_requires_twelve_recurring_frames(self): + from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_dola_localizations + + detections = [FrameLocalization(index, 0.60, self._DOLA_BOX) for index in range(11)] + + assert stabilize_dola_localizations(detections, provenance=True) == [None] * 11 + + def test_dola_provenance_accepts_recurring_low_contrast_text(self): + from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_dola_localizations + + detections = [FrameLocalization(index, 0.49, self._DOLA_BOX) for index in range(12)] + + assert stabilize_dola_localizations(detections, provenance=True) == [self._DOLA_BOX] * 12 + + def test_dola_without_provenance_needs_a_strong_frame(self): + from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_dola_localizations + + detections = [FrameLocalization(index, 0.51, self._DOLA_BOX) for index in range(12)] + + assert stabilize_dola_localizations(detections, provenance=False) == [None] * 12 + + def test_bytedance_provenance_requires_ai_source_type(self): + from remove_ai_watermarks.video_visible import has_bytedance_video_provenance + + assert has_bytedance_video_provenance( + { + "issuer": "BytePlus (ByteDance)", + "source_type": "trainedAlgorithmicMedia (AI-generated)", + } + ) + assert not has_bytedance_video_provenance({"issuer": "BytePlus (ByteDance)"}) + + 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 @@ -666,6 +804,57 @@ class TestVideoVisibleApi: assert result.detected_frames == 12 assert result.removed_frames == 12 + @pytest.mark.parametrize( + ("mark", "scan_name", "mask_style"), + [ + ("seedance", "scan_seedance_video", "box"), + ("dola", "scan_dola_video", "box"), + ], + ) + def test_dispatches_bytedance_detectors( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mark: str, + scan_name: str, + mask_style: str, + ): + from remove_ai_watermarks import video_visible + from remove_ai_watermarks.video import remove_video_visible + from remove_ai_watermarks.video_visible import FrameLocalization, VideoScan + + source = _video_with_c2pa(tmp_path / "source.mp4") + output = tmp_path / "clean.mp4" + box = (40, 40, 20, 12) + scan = VideoScan( + width=64, + height=64, + fps=24.0, + detections=tuple(FrameLocalization(index, 0.60, box) for index in range(12)), + ) + monkeypatch.setattr(video_visible, scan_name, lambda _source: scan) + + def fake_encode( + _source: Path, + target: Path, + _scan: VideoScan, + regions: list[tuple[int, int, int, int] | None], + **kwargs: object, + ) -> int: + assert regions == [box] * 12 + assert kwargs["mask_style"] == mask_style + target.write_bytes(_MP4_FTYP + _box(b"mdat", _VIDEO_PAYLOAD)) + return 12 + + monkeypatch.setattr(video_visible, "encode_clean_video", fake_encode) + + result = remove_video_visible(source, output, mark=mark) + + assert result.output == output + assert result.mark == mark + assert result.detected_frames == 12 + assert result.removed_frames == 12 + class TestVideoVisibleCli: def test_help(self): @@ -673,7 +862,7 @@ class TestVideoVisibleCli: assert result.exit_code == 0, result.output assert "temporally stable" in result.output - assert "sora|veo" in result.output + assert "sora|veo|seedance|dola" in result.output def test_reports_removed_frames(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): from remove_ai_watermarks import video