Harden automatic video watermark removal for product use

This commit is contained in:
Victor Kuznetsov
2026-07-30 18:36:04 -07:00
parent 07b678fa4c
commit 8975916b11
14 changed files with 737 additions and 232 deletions
+4 -4
View File
@@ -28,7 +28,7 @@ Per-command exit-code semantics (the no-signal / GPU-missing skip branches), tes
- `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/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|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 video visible <input.mp4> -o <clean.mp4>` — remove a temporally recurring Sora, Veo, Seedance, Dola, Hailuo, or Kling mark. `--mark auto` is the default: all providers share one decode pass, then the first stable result wins in specificity order; an explicit mark restricts the scan. It transcodes video through ffmpeg, copies the complete audio stream, strips AI metadata by default, publishes atomically, 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,9 +82,9 @@ 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/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.
- `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="auto", 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`: auto scans all six synthetic provider silhouettes in one decode pass and selects the first stable result in specificity order because provider confidence scales are not comparable. Provider-specific temporal recurrence authorizes frame boxes, the shared fill backends remove accepted masks in a second pass, and ffmpeg transcodes video while copying complete 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, atomic sibling-temporary publication, and pipe lifecycle for both visible removal and invisible regeneration. It copies complete optional audio without shortening a tail, 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 complete 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.
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`.
+11 -5
View File
@@ -96,13 +96,17 @@ 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
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.
without re-encoding and is allowed to reach its natural end; the video stream
is transcoded because its pixels change. The default `--mark auto` scans all
providers in one decode pass and selects the first stable match in the
specificity order shown below. Pass an explicit mark to restrict detection to
one provider.
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, 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.
label with its version suffix. A completed encode is published atomically. No
output is written when no stable mark is found.
Generate a video SynthID candidate:
@@ -112,7 +116,8 @@ remove-ai-watermarks video invisible input.mp4 -o candidate.mp4
```
This path regenerates the complete sequence with one latent-noise field shared
across time, copies audio, and strips source metadata. It cannot verify
across time, copies complete audio, strips source metadata, and publishes the
completed encode atomically. It cannot verify
Google's proprietary pixel watermark locally. The command therefore labels
every output `UNVERIFIED` and prints the exact Gemini Flash verification
prompt.
@@ -266,7 +271,8 @@ print(removed)
report = raiw.inspect_video_metadata("input.mp4")
cleaned = raiw.remove_video_metadata("input.mp4")
candidate = raiw.remove_video_invisible("input.mp4", "candidate.mp4")
visible = raiw.remove_video_visible("sora.mp4", "sora_clean.mp4")
visible = raiw.remove_video_visible("input.mp4", "clean.mp4")
print(visible.mark)
veo = raiw.remove_video_visible("veo.mp4", "veo_clean.mp4", mark="veo")
seedance = raiw.remove_video_visible(
"seedance.mp4",
+11 -2
View File
@@ -210,7 +210,15 @@ must also remain anchored instead of drifting with a scene object. Matching
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.
`--mark auto` is the default. It evaluates all providers in one decode pass and
selects the first stable match in specificity order: Sora, Veo, Seedance, Dola,
Hailuo, then Kling. Their confidence scores are independently calibrated and
are not compared across providers. Pass an explicit `--mark` to scan only that
provider.
The video stream is transcoded and the complete original audio stream is
copied without truncating an audio tail that extends beyond the final video
frame.
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
@@ -220,7 +228,8 @@ installed backend.
AI metadata is stripped from the encoded output by default. Use
`--keep-metadata` to retain mapped container metadata. When no temporally stable
mark is found, the command writes no output and exits with the no-visible-mark
status.
status. The final path is replaced atomically only after ffmpeg completes, so a
failed encode does not overwrite an existing result.
## Remove invisible watermarks
+18 -11
View File
@@ -215,6 +215,11 @@ 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.
The default auto-router evaluates all detectors in one decode pass but does not
rank their raw confidence values. Those scores are provider-specific and known
to cross-match in some layouts, so the router applies the independent temporal
policies and selects the first stable result in specificity order. Use an
explicit mark when the provider is already known.
Historical Sora Turbo exports use a small OpenAI swirl in the corner rather
than the moving mascot-and-wordmark design; that earlier variant is not
detected by the `sora` video mark. Hailuo and Kling coverage is specific to the
@@ -223,17 +228,19 @@ 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
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. 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.
Visible removal transcodes the video stream and copies the complete audio
stream without shortening an audio tail. Completed visible and invisible
encodes are published atomically, so an encode failure preserves an existing
output. Its frame-local 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. 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
+11 -1
View File
@@ -125,7 +125,9 @@ 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
copying, metadata/chapter policy, and encode-failure reporting.
copying, metadata/chapter policy, encode-failure reporting, and atomic
same-directory publication. Each mapped stream is allowed to reach its own end,
so a copied audio tail is not shortened to the raw-video input duration.
[`video_invisible.py`](../src/remove_ai_watermarks/video_invisible.py)
implements the oracle-gated video SynthID candidate engine. It samples frames
@@ -161,6 +163,14 @@ 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.
The default `auto` route decodes each frame once, shares its grayscale and
normalized representations across all detectors, and caches resized synthetic
template features for the fixed stream geometry. Provider confidence scales
are not comparable: selection applies each provider's temporal arbiter and
takes the first stable result in specificity order (`sora`, `veo`, `seedance`,
`dola`, `hailuo`, `kling`). An explicit mark uses the same scan path with one
candidate.
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
+20 -12
View File
@@ -174,8 +174,8 @@ if result.remaining_metadata:
`remove_video_invisible` supports MP4, MOV, and M4V. It regenerates the complete
video through a VAE in bounded batches, shares one seeded latent-noise field
across all frames, streams pixels to ffmpeg, copies audio, and strips source
metadata. The default output is
across all frames, streams pixels to ffmpeg, copies complete audio, strips
source metadata, and publishes atomically. The default output is
`input_synthid_candidate.mp4`; a distinct same-container output is required.
The returned `VideoInvisibleResult` includes output geometry, frame rate, frame
@@ -191,13 +191,15 @@ Flash's built-in content verification before treating it as watermark-negative.
import remove_ai_watermarks as raiw
result = raiw.remove_video_visible(
"sora.mp4",
"sora_clean.mp4",
"input.mp4",
"clean.mp4",
backend="cv2",
strip_metadata=True,
)
if result.output is None:
print("No temporally stable Sora mark was found")
print("No temporally stable supported mark was found")
else:
print(result.mark)
veo_result = raiw.remove_video_visible(
"veo.mp4",
@@ -228,18 +230,24 @@ kling_result = raiw.remove_video_visible(
`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`, `dola`, `hailuo`, and
`kling`. The Veo detector recognizes the current four-point diamond and the
isolated lookalike in one frame is not enough to authorize inpainting.
`mark="auto"` is the default: it evaluates all providers in one decode pass and
selects the first stable match in specificity order (`sora`, `veo`, `seedance`,
`dola`, `hailuo`, `kling`). Provider confidence values are calibrated
independently and are not compared across detectors. Pass one of those explicit
values to restrict the scan to a single provider. The Veo detector recognizes
the current four-point diamond and the
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
returns `output=None` and writes no file when no stable mark is selected. Video
pixels are transcoded through ffmpeg while the source audio stream is copied.
The returned `VideoVisibleResult` records the selected `mark`, the total,
detected, and removed frame counts, plus any AI metadata that survived the
output encode. The function returns `output=None` and writes no file when no
stable mark is selected. Video pixels are transcoded through ffmpeg while the
complete source audio stream is copied. A failed encode preserves any existing
output; only a completed result is published atomically.
## Remove invisible watermarks
+4 -1
View File
@@ -41,7 +41,10 @@ when you can select the affected area yourself.
| `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.
command because selection is made over a sequence rather than one raster. Its
default `auto` mode scans all six entries in one decode pass and selects the
first temporally stable match in table order; an explicit mark restricts the
scan to that row.
## Fill backends
+6 -4
View File
@@ -28,6 +28,7 @@ from remove_ai_watermarks.noai.watermark_profiles import (
strength_default_help,
vendor_for_strength,
)
from remove_ai_watermarks.video import VIDEO_VISIBLE_MARKS
from remove_ai_watermarks.video_synthid import (
DEFAULT_VIDEO_SYNTHID_FPS,
DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
@@ -1240,9 +1241,9 @@ def cmd_video_invisible(
)
@click.option(
"--mark",
type=click.Choice(["sora", "veo", "seedance", "dola", "hailuo", "kling"]),
default="sora",
help="Visible AI mark to remove.",
type=click.Choice(["auto", *VIDEO_VISIBLE_MARKS]),
default="auto",
help="Visible AI mark to remove. Auto scans every supported provider in one decode pass.",
)
@click.option(
"--backend",
@@ -1281,7 +1282,8 @@ def cmd_video_visible(
console.print(f" FAILED: {len(result.remaining_metadata)} AI metadata marker(s) survived in {result.output}")
raise SystemExit(1)
console.print(
f" Removed {mark} watermark from {result.removed_frames}/{result.total_frames} frames -> {result.output}"
f" Removed {result.mark} watermark from "
f"{result.removed_frames}/{result.total_frames} frames -> {result.output}"
)
+96 -54
View File
@@ -22,8 +22,10 @@ from remove_ai_watermarks.video_synthid import (
if TYPE_CHECKING:
from remove_ai_watermarks.video_invisible import RegenerationMetrics
from remove_ai_watermarks.video_visible import VideoScan
VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".flv"})
VIDEO_VISIBLE_MARKS = ("sora", "veo", "seedance", "dola", "hailuo", "kling")
_ISOBMFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v"})
_EBML_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".webm", ".mkv"})
_RIFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".avi"})
@@ -182,17 +184,20 @@ def remove_video_visible(
source: str | Path,
output: str | Path | None = None,
*,
mark: str = "sora",
mark: str = "auto",
backend: str = "cv2",
strip_metadata: bool = True,
) -> VideoVisibleResult:
"""Remove a supported visible AI wordmark from a video.
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``.
``mark="auto"`` scans every supported provider in one decode pass and selects
the first stable match in specificity order. Explicit marks are ``sora``,
``veo``, ``seedance``, ``dola``, ``hailuo``, and ``kling``. The full
sequence is scanned before pixels change, and only recurring candidates are
accepted. Complete audio is copied without re-encoding; video is transcoded
because the pixels change. Completed output is published atomically. 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 (
@@ -200,12 +205,7 @@ def remove_video_visible(
has_bytedance_video_provenance,
has_sora_provenance,
has_veo_provenance,
scan_dola_video,
scan_hailuo_video,
scan_kling_video,
scan_seedance_video,
scan_sora_video,
scan_veo_video,
scan_video_marks,
stabilize_dola_localizations,
stabilize_hailuo_localizations,
stabilize_kling_localizations,
@@ -215,56 +215,98 @@ def remove_video_visible(
)
from remove_ai_watermarks.watermark_registry import resolve_backend
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 mark not in {"auto", *VIDEO_VISIBLE_MARKS}:
raise ValueError("Unsupported visible video mark; expected auto, 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")
source_path = _video_source(source)
output_path = _video_output(source_path, output, operation="visible watermark removal")
markers = get_ai_metadata(source_path)
if mark == "sora":
scan = scan_sora_video(source_path)
regions = stabilize_sora_localizations(
scan.detections,
provenance=has_sora_provenance(markers),
def removal_plan(
selected_mark: str,
selected_scan: VideoScan,
) -> tuple[list[tuple[int, int, int, int] | None], float, Literal["box", "veo"]]:
if selected_mark == "sora":
return (
stabilize_sora_localizations(
selected_scan.detections,
provenance=has_sora_provenance(markers),
),
0.28,
"box",
)
if selected_mark == "veo":
return (
stabilize_veo_localizations(
selected_scan.detections,
provenance=has_veo_provenance(markers),
),
0.18,
"veo",
)
if selected_mark == "seedance":
return (
stabilize_seedance_localizations(
selected_scan.detections,
provenance=has_bytedance_video_provenance(markers),
),
0.0,
"box",
)
if selected_mark == "dola":
return (
stabilize_dola_localizations(
selected_scan.detections,
provenance=has_bytedance_video_provenance(markers),
),
0.20,
"box",
)
if selected_mark == "hailuo":
return stabilize_hailuo_localizations(selected_scan.detections), 0.12, "box"
return stabilize_kling_localizations(selected_scan.detections), 0.12, "box"
candidate_marks = VIDEO_VISIBLE_MARKS if mark == "auto" else (mark,)
scans = scan_video_marks(source_path, candidate_marks)
selected: (
tuple[
str,
VideoScan,
list[tuple[int, int, int, int] | None],
float,
Literal["box", "veo"],
]
| None
) = None
for candidate_mark in candidate_marks:
candidate_scan = scans[candidate_mark]
candidate_regions, candidate_padding, candidate_mask_style = removal_plan(
candidate_mark,
candidate_scan,
)
padding_fraction = 0.28
mask_style = "box"
elif mark == "veo":
scan = scan_veo_video(source_path)
regions = stabilize_veo_localizations(
scan.detections,
provenance=has_veo_provenance(markers),
if any(region is not None for region in candidate_regions):
selected = (
candidate_mark,
candidate_scan,
candidate_regions,
candidate_padding,
candidate_mask_style,
)
break
if selected is None:
scan = scans[candidate_marks[0]]
return VideoVisibleResult(
source=source_path,
output=None,
mark=mark,
total_frames=len(scan.detections),
detected_frames=0,
removed_frames=0,
remaining_metadata=markers if strip_metadata else {},
)
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"
elif mark == "dola":
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"
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"
mark, scan, regions, padding_fraction, mask_style = selected
detected_frames = sum(region is not None for region in regions)
if detected_frames == 0:
return VideoVisibleResult(
+23 -2
View File
@@ -3,16 +3,38 @@
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import tempfile
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
from collections.abc import Generator
log = logging.getLogger(__name__)
@contextmanager
def atomic_video_output(output: Path) -> Generator[Path]:
"""Yield a sibling temporary path and publish it only after success."""
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
prefix=f".{output.stem}-",
suffix=output.suffix,
dir=output.parent,
delete=False,
) as stream:
temporary_output = Path(stream.name)
try:
yield temporary_output
os.replace(temporary_output, output)
finally:
temporary_output.unlink(missing_ok=True)
def _video_codec_args(suffix: str, *, crf: int) -> list[str]:
if suffix == ".webm":
return ["-c:v", "libvpx-vp9", "-crf", str(crf), "-b:v", "0"]
@@ -61,7 +83,6 @@ def raw_video_command(
"-1" if strip_metadata else "1",
"-map_chapters",
"-1" if strip_metadata else "1",
"-shortest",
]
if output.suffix.lower() in {".mp4", ".mov", ".m4v"}:
command.extend(["-movflags", "+faststart"])
+4 -17
View File
@@ -13,11 +13,8 @@ from __future__ import annotations
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false
import logging
import math
import os
import tempfile
from dataclasses import dataclass
from importlib.util import find_spec
from pathlib import Path
from typing import TYPE_CHECKING, Any
import cv2
@@ -25,6 +22,7 @@ import numpy as np
from remove_ai_watermarks.video_encoding import (
abort_raw_video_encoder,
atomic_video_output,
finish_raw_video_encoder,
raw_video_command,
start_raw_video_encoder,
@@ -39,6 +37,7 @@ from remove_ai_watermarks.video_synthid import (
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from pathlib import Path
log = logging.getLogger(__name__)
@@ -405,15 +404,7 @@ def regenerate_video_candidate(
vae.eval()
vae.enable_slicing()
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
prefix=f".{output.stem}-",
suffix=output.suffix,
dir=output.parent,
delete=False,
) as stream:
temporary_output = Path(stream.name)
try:
with atomic_video_output(output) as temporary_output:
process = start_raw_video_encoder(
raw_video_command(
source,
@@ -499,7 +490,7 @@ def regenerate_video_candidate(
raise
mse = squared_error / pixel_count
metrics = RegenerationMetrics(
return RegenerationMetrics(
frames=frame_count,
fps=effective_fps,
width=size[0],
@@ -507,7 +498,3 @@ def regenerate_video_candidate(
psnr_db=math.inf if mse == 0.0 else 20.0 * math.log10(255.0 / math.sqrt(mse)),
temporal_residual_ratio=temporal_candidate / max(temporal_baseline, 1e-6),
)
os.replace(temporary_output, output)
finally:
temporary_output.unlink(missing_ok=True)
return metrics
+257 -106
View File
@@ -11,8 +11,8 @@ 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
because visible-mark removal changes pixels.
Complete audio is stream-copied from the source. The video stream must be
transcoded because visible-mark removal changes pixels.
"""
# cv2/numpy boundary: these packages do not expose usable types for many array
@@ -32,8 +32,10 @@ import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from remove_ai_watermarks.video import VIDEO_VISIBLE_MARKS
from remove_ai_watermarks.video_encoding import (
abort_raw_video_encoder,
atomic_video_output,
finish_raw_video_encoder,
raw_video_command,
start_raw_video_encoder,
@@ -105,6 +107,15 @@ class VideoScan:
detections: tuple[FrameLocalization, ...]
@dataclass(frozen=True)
class _PreparedFrame:
"""Frame representations shared by every detector in one scan pass."""
gray: NDArray[Any]
normalized_gray: NDArray[Any]
normalized_scale: float
def _scalable_default_font(size: int) -> ImageFont.ImageFont | ImageFont.FreeTypeFont:
"""Load Pillow's bundled scalable font, with a Pillow 10.0 fallback."""
try:
@@ -291,6 +302,43 @@ def _top_hat(gray: NDArray[Any]) -> NDArray[Any]:
return cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel)
@lru_cache(maxsize=1)
def _template_sources() -> dict[str, NDArray[Any]]:
"""Return every immutable synthetic template by detector-local key."""
sora_word, sora_icon = _sora_templates()
veo_diamond, veo_text = _veo_templates()
sources = {
"sora-word": sora_word,
"sora-icon": sora_icon,
"veo-diamond": veo_diamond,
"veo-text": veo_text,
"seedance": _seedance_template(),
"dola": _dola_template(),
"hailuo": _hailuo_template(),
"kling-logo": _kling_logo_template(),
}
sources.update({f"kling-{index}": template for index, template in enumerate(_kling_templates())})
return sources
@lru_cache(maxsize=512)
def _resized_template_feature(
template_key: str,
width: int,
height: int,
kernel_size: int,
) -> tuple[NDArray[Any], NDArray[Any]]:
"""Resize one template and cache its invariant top-hat representation."""
template = cv2.resize(
_template_sources()[template_key],
(width, height),
interpolation=cv2.INTER_AREA,
)
kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
feature = cv2.morphologyEx(template, cv2.MORPH_TOPHAT, kernel)
return template, feature
def _normalized_gray(image_bgr: NDArray[Any]) -> tuple[NDArray[Any], float]:
gray = image_bgr if image_bgr.ndim == 2 else cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
height, width = gray.shape[:2]
@@ -307,6 +355,13 @@ def _normalized_gray(image_bgr: NDArray[Any]) -> tuple[NDArray[Any], float]:
return gray, scale
def _prepare_frame(image_bgr: NDArray[Any]) -> _PreparedFrame:
"""Compute the shared grayscale representations for one decoded frame."""
gray = image_bgr if image_bgr.ndim == 2 else cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
normalized_gray, normalized_scale = _normalized_gray(gray)
return _PreparedFrame(gray, normalized_gray, normalized_scale)
def _expanded_region(
location: tuple[int, int],
template_width: int,
@@ -328,7 +383,12 @@ def _expanded_region(
return x, y, max(1, width), max(1, height)
def detect_sora_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
def detect_sora_frame(
image_bgr: NDArray[Any],
*,
frame_index: int = 0,
prepared: _PreparedFrame | None = None,
) -> FrameLocalization:
"""Locate the strongest synthetic Sora-wordmark match in one frame.
The returned candidate is intentionally untrusted. Call
@@ -339,7 +399,8 @@ def detect_sora_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> Frame
return FrameLocalization(frame_index, 0.0, None)
frame_height, frame_width = image_bgr.shape[:2]
gray, scale = _normalized_gray(image_bgr)
prepared = prepared or _prepare_frame(image_bgr)
gray, scale = prepared.normalized_gray, prepared.normalized_scale
normalized_height, normalized_width = gray.shape[:2]
feature = _top_hat(gray)
best_confidence = 0.0
@@ -347,17 +408,19 @@ def detect_sora_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> Frame
for template_index, base_template in enumerate(_sora_templates()):
icon_only = template_index == 1
template_key = "sora-icon" if icon_only else "sora-word"
for relative_height in _SORA_RELATIVE_HEIGHTS:
template_height = max(16, round(min(normalized_height, normalized_width) * relative_height))
template_width = max(1, round(base_template.shape[1] * template_height / base_template.shape[0]))
if template_height >= normalized_height or template_width >= normalized_width:
continue
template = cv2.resize(
base_template,
(template_width, template_height),
interpolation=cv2.INTER_AREA,
_, template_feature = _resized_template_feature(
template_key,
template_width,
template_height,
7,
)
scores = cv2.matchTemplate(feature, _top_hat(template), cv2.TM_CCOEFF_NORMED)
scores = cv2.matchTemplate(feature, template_feature, cv2.TM_CCOEFF_NORMED)
_, confidence, _, location = cv2.minMaxLoc(scores)
if confidence <= best_confidence:
continue
@@ -381,6 +444,7 @@ def _match_template(
*,
region: Region,
kernel_size: int,
template_feature: NDArray[Any] | None = None,
) -> tuple[float, Region | None]:
"""Match one synthetic silhouette inside a bounded frame region."""
x, y, width, height = region
@@ -390,7 +454,8 @@ def _match_template(
return 0.0, None
kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
feature = cv2.morphologyEx(roi, cv2.MORPH_TOPHAT, kernel)
template_feature = cv2.morphologyEx(template, cv2.MORPH_TOPHAT, kernel)
if template_feature is None:
template_feature = cv2.morphologyEx(template, cv2.MORPH_TOPHAT, kernel)
scores = cv2.matchTemplate(feature, template_feature, cv2.TM_CCOEFF_NORMED)
_, confidence, _, location = cv2.minMaxLoc(scores)
return float(confidence), (
@@ -441,7 +506,7 @@ def _bounded_region(
def _detect_fixed_mark(
image_bgr: NDArray[Any],
template: NDArray[Any],
template_key: str,
*,
relative_heights: tuple[float, ...],
search_origin: tuple[float, float],
@@ -467,19 +532,23 @@ def _detect_fixed_mark(
normalized_height - search_y,
)
matches: list[tuple[float, Region]] = []
base_template = _template_sources()[template_key]
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,
template_width = max(1, round(base_template.shape[1] * template_height / base_template.shape[0]))
kernel_size = max(3, round(template_height * kernel_fraction) | 1)
resized, template_feature = _resized_template_feature(
template_key,
template_width,
template_height,
kernel_size,
)
confidence, candidate = _match_template(
gray,
resized,
region=search_region,
kernel_size=max(3, round(template_height * kernel_fraction) | 1),
kernel_size=kernel_size,
template_feature=template_feature,
)
if candidate is not None and confidence > 0:
matches.append((confidence, candidate))
@@ -509,38 +578,56 @@ def _detect_fixed_mark(
)
def detect_seedance_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
def detect_seedance_frame(
image_bgr: NDArray[Any],
*,
frame_index: int = 0,
prepared: _PreparedFrame | None = None,
) -> FrameLocalization:
"""Locate the strongest fixed Seedance boxed-AI candidate."""
return _detect_fixed_mark(
image_bgr,
_seedance_template(),
"seedance",
relative_heights=(0.065, 0.075, 0.085, 0.095, 0.105),
search_origin=(0.68, 0.72),
kernel_fraction=0.12,
normalized=None if prepared is None else (prepared.normalized_gray, prepared.normalized_scale),
frame_index=frame_index,
)
def detect_dola_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
def detect_dola_frame(
image_bgr: NDArray[Any],
*,
frame_index: int = 0,
prepared: _PreparedFrame | None = None,
) -> FrameLocalization:
"""Locate the strongest fixed Dola AI text candidate."""
return _detect_fixed_mark(
image_bgr,
_dola_template(),
"dola",
relative_heights=_DOLA_RELATIVE_HEIGHTS,
search_origin=(0.65, 0.85),
kernel_fraction=0.50,
normalized=None if prepared is None else (prepared.normalized_gray, prepared.normalized_scale),
frame_index=frame_index,
)
def detect_hailuo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
def detect_hailuo_frame(
image_bgr: NDArray[Any],
*,
frame_index: int = 0,
prepared: _PreparedFrame | None = None,
) -> FrameLocalization:
"""Locate the strongest fixed MINIMAX/Hailuo composite-label candidate."""
detection = _detect_fixed_mark(
image_bgr,
_hailuo_template(),
"hailuo",
relative_heights=_HAILUO_RELATIVE_HEIGHTS,
search_origin=(0.28, 0.76),
kernel_fraction=0.18,
normalized=None if prepared is None else (prepared.normalized_gray, prepared.normalized_scale),
frame_index=frame_index,
)
if detection.region is None:
@@ -563,17 +650,23 @@ def detect_hailuo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> Fra
)
def detect_kling_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
def detect_kling_frame(
image_bgr: NDArray[Any],
*,
frame_index: int = 0,
prepared: _PreparedFrame | None = None,
) -> 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)
prepared = prepared or _prepare_frame(image_bgr)
normalized = prepared.normalized_gray, prepared.normalized_scale
expanded: list[FrameLocalization] = []
for template in _kling_templates():
for template_index, _template in enumerate(_kling_templates()):
detection = _detect_fixed_mark(
image_bgr,
template,
f"kling-{template_index}",
relative_heights=_KLING_RELATIVE_HEIGHTS,
search_origin=(0.64, 0.84),
kernel_fraction=0.18,
@@ -615,7 +708,7 @@ def detect_kling_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> Fram
logo = _detect_fixed_mark(
image_bgr,
_kling_logo_template(),
"kling-logo",
relative_heights=tuple(value / 1000 for value in range(20, 61, 3)),
search_origin=(0.62, 0.90),
kernel_fraction=0.18,
@@ -664,15 +757,20 @@ def detect_kling_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> Fram
return best
def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
def detect_veo_frame(
image_bgr: NDArray[Any],
*,
frame_index: int = 0,
prepared: _PreparedFrame | None = None,
) -> FrameLocalization:
"""Locate the strongest current-diamond or legacy-text Veo candidate."""
if image_bgr.size == 0:
return FrameLocalization(frame_index, 0.0, None)
frame_height, frame_width = image_bgr.shape[:2]
gray = image_bgr if image_bgr.ndim == 2 else cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
gray = (prepared or _prepare_frame(image_bgr)).gray
short_scale = min(frame_height, frame_width) / _VEO_REFERENCE_SHORT_SIDE
diamond_base, text_base = _veo_templates()
_, text_base = _veo_templates()
best_confidence = 0.0
best_region: Region | None = None
@@ -680,10 +778,12 @@ def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameL
for base_size, right_margin, bottom_margin in _VEO_DIAMOND_PROFILES:
diamond_size = max(16, round(base_size * short_scale))
diamond_sizes.add(diamond_size)
template = cv2.resize(
diamond_base,
(diamond_size, diamond_size),
interpolation=cv2.INTER_AREA,
kernel_size = max(3, round(7 * short_scale) | 1)
template, template_feature = _resized_template_feature(
"veo-diamond",
diamond_size,
diamond_size,
kernel_size,
)
expected_x = round(frame_width - (right_margin + base_size) * short_scale)
expected_y = round(frame_height - (bottom_margin + base_size) * short_scale)
@@ -696,7 +796,8 @@ def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameL
gray,
template,
region=(search_x, search_y, search_width, search_height),
kernel_size=max(3, round(7 * short_scale) | 1),
kernel_size=kernel_size,
template_feature=template_feature,
)
if confidence > best_confidence:
best_confidence = confidence
@@ -710,16 +811,19 @@ def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameL
corner_y = round(frame_height * 0.65)
corner_region = (corner_x, corner_y, frame_width - corner_x, frame_height - corner_y)
for diamond_size in diamond_sizes:
template = cv2.resize(
diamond_base,
(diamond_size, diamond_size),
interpolation=cv2.INTER_AREA,
kernel_size = max(3, round(7 * short_scale) | 1)
template, template_feature = _resized_template_feature(
"veo-diamond",
diamond_size,
diamond_size,
kernel_size,
)
confidence, candidate = _match_template(
gray,
template,
region=corner_region,
kernel_size=max(3, round(7 * short_scale) | 1),
kernel_size=kernel_size,
template_feature=template_feature,
)
if confidence >= 0.70 and confidence > best_confidence:
best_confidence = confidence
@@ -736,16 +840,19 @@ def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameL
text_heights = sorted({max(5, round(height * short_scale)) for height in range(8, 22)})
for text_height in text_heights:
text_width = max(1, round(text_base.shape[1] * text_height / text_base.shape[0]))
template = cv2.resize(
text_base,
(text_width, text_height),
interpolation=cv2.INTER_AREA,
kernel_size = max(3, round(3 * short_scale) | 1)
template, template_feature = _resized_template_feature(
"veo-text",
text_width,
text_height,
kernel_size,
)
confidence, candidate = _match_template(
gray,
template,
region=text_region,
kernel_size=max(3, round(3 * short_scale) | 1),
kernel_size=kernel_size,
template_feature=template_feature,
)
if confidence > best_confidence:
best_confidence = confidence
@@ -959,11 +1066,11 @@ def _stabilize_localizations(
return accepted
def _scan_video(
def _scan_video_detectors(
source: Path,
detector: Any,
) -> VideoScan:
"""Decode a video once and collect one untrusted candidate per frame."""
detectors: dict[str, Any],
) -> dict[str, VideoScan]:
"""Decode once and collect one untrusted candidate per detector and frame."""
capture = cv2.VideoCapture(str(source))
if not capture.isOpened():
raise RuntimeError(f"OpenCV could not decode video: {source}")
@@ -974,7 +1081,7 @@ def _scan_video(
capture.release()
raise RuntimeError(f"Video has invalid stream geometry or frame rate: {source}")
detections: list[FrameLocalization] = []
detections: dict[str, list[FrameLocalization]] = {mark: [] for mark in detectors}
frame_index = 0
while True:
ok, frame = capture.read()
@@ -983,12 +1090,56 @@ def _scan_video(
if frame.shape[:2] != (height, width):
capture.release()
raise RuntimeError(f"Video changes frame dimensions at frame {frame_index}: {source}")
detections.append(detector(frame, frame_index=frame_index))
prepared = _prepare_frame(frame)
for mark, detector in detectors.items():
detections[mark].append(
detector(
frame,
frame_index=frame_index,
prepared=prepared,
)
)
frame_index += 1
capture.release()
if not detections:
if frame_index == 0:
raise RuntimeError(f"Video contains no decodable frames: {source}")
return VideoScan(width, height, fps, tuple(detections))
return {mark: VideoScan(width, height, fps, tuple(mark_detections)) for mark, mark_detections in detections.items()}
def _scan_video(
source: Path,
detector: Any,
) -> VideoScan:
"""Decode a video once and collect one untrusted candidate per frame."""
return _scan_video_detectors(source, {"selected": detector})["selected"]
def scan_video_marks(
source: Path,
marks: tuple[str, ...] = VIDEO_VISIBLE_MARKS,
) -> dict[str, VideoScan]:
"""Decode once and collect candidates for every requested provider mark."""
detectors = dict(
zip(
VIDEO_VISIBLE_MARKS,
(
detect_sora_frame,
detect_veo_frame,
detect_seedance_frame,
detect_dola_frame,
detect_hailuo_frame,
detect_kling_frame,
),
strict=True,
)
)
unsupported = sorted(set(marks) - detectors.keys())
if unsupported:
raise ValueError(f"Unsupported visible video mark: {', '.join(unsupported)}")
return _scan_video_detectors(
source,
{mark: detectors[mark] for mark in marks},
)
def scan_sora_video(source: Path) -> VideoScan:
@@ -1075,65 +1226,65 @@ def encode_clean_video(
padding_fraction: float = 0.28,
mask_style: Literal["box", "veo"] = "box",
) -> int:
"""Decode again, fill accepted regions, and encode video while copying audio."""
"""Decode again, fill accepted regions, and atomically encode with complete audio."""
from remove_ai_watermarks.watermark_registry import fill, resolve_backend
if len(regions) != len(scan.detections):
raise ValueError("Temporal localization count does not match the scanned frame count")
output.parent.mkdir(parents=True, exist_ok=True)
process = start_raw_video_encoder(
raw_video_command(
source,
output,
width=scan.width,
height=scan.height,
fps=scan.fps,
strip_metadata=strip_metadata,
crf=14,
with atomic_video_output(output) as temporary_output:
process = start_raw_video_encoder(
raw_video_command(
source,
temporary_output,
width=scan.width,
height=scan.height,
fps=scan.fps,
strip_metadata=strip_metadata,
crf=14,
)
)
)
frame_pipe = process.stdin
if frame_pipe is None:
abort_raw_video_encoder(process)
raise RuntimeError("Could not open ffmpeg input pipe")
capture = cv2.VideoCapture(str(source))
if not capture.isOpened():
abort_raw_video_encoder(process)
raise RuntimeError(f"OpenCV could not reopen video for removal: {source}")
removed_frames = 0
resolved_backend: Literal["cv2", "migan", "lama"] = resolve_backend(backend)
try:
for frame_index, region in enumerate(regions):
ok, frame = capture.read()
if not ok:
raise RuntimeError(f"Video ended while reading frame {frame_index}: {source}")
if region is not None:
frame = fill(
frame,
_mask_for_region(
frame,
region,
padding_fraction=padding_fraction,
mask_style=mask_style,
),
backend=resolved_backend,
)
removed_frames += 1
frame_pipe.write(frame.tobytes())
finish_raw_video_encoder(
process,
output,
operation="visible-watermark encode",
)
except Exception:
if process.poll() is None:
frame_pipe = process.stdin
if frame_pipe is None:
abort_raw_video_encoder(process)
raise
finally:
capture.release()
raise RuntimeError("Could not open ffmpeg input pipe")
capture = cv2.VideoCapture(str(source))
if not capture.isOpened():
abort_raw_video_encoder(process)
raise RuntimeError(f"OpenCV could not reopen video for removal: {source}")
removed_frames = 0
resolved_backend: Literal["cv2", "migan", "lama"] = resolve_backend(backend)
try:
for frame_index, region in enumerate(regions):
ok, frame = capture.read()
if not ok:
raise RuntimeError(f"Video ended while reading frame {frame_index}: {source}")
if region is not None:
frame = fill(
frame,
_mask_for_region(
frame,
region,
padding_fraction=padding_fraction,
mask_style=mask_style,
),
backend=resolved_backend,
)
removed_frames += 1
frame_pipe.write(frame.tobytes())
finish_raw_video_encoder(
process,
temporary_output,
operation="visible-watermark encode",
)
except Exception:
if process.poll() is None:
abort_raw_video_encoder(process)
raise
finally:
capture.release()
return removed_frames
+271 -13
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import io
from typing import TYPE_CHECKING
import cv2
@@ -1018,7 +1019,249 @@ class TestAdditionalProviderTemporalArbiter:
assert stabilize(strong) == [box] * 12
class TestVideoVisibleScan:
def test_auto_prepares_each_frame_once_for_every_detector(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
from remove_ai_watermarks import video_visible
from remove_ai_watermarks.video_visible import FrameLocalization, scan_video_marks
frame = np.zeros((8, 12, 3), dtype=np.uint8)
class FakeCapture:
def __init__(self) -> None:
self._read = False
def isOpened(self) -> bool:
return True
def get(self, property_id: int) -> float:
return {
video_visible.cv2.CAP_PROP_FRAME_WIDTH: 12.0,
video_visible.cv2.CAP_PROP_FRAME_HEIGHT: 8.0,
video_visible.cv2.CAP_PROP_FPS: 24.0,
}[property_id]
def read(self) -> tuple[bool, np.ndarray | None]:
if self._read:
return False, None
self._read = True
return True, frame
def release(self) -> None:
pass
prepared_ids: list[int] = []
def fake_detector(
_frame: np.ndarray,
*,
frame_index: int,
prepared: object,
) -> FrameLocalization:
assert prepared is not None
prepared_ids.append(id(prepared))
return FrameLocalization(frame_index, 0.0, None)
monkeypatch.setattr(video_visible.cv2, "VideoCapture", lambda _path: FakeCapture())
for detector_name in (
"detect_sora_frame",
"detect_veo_frame",
"detect_seedance_frame",
"detect_dola_frame",
"detect_hailuo_frame",
"detect_kling_frame",
):
monkeypatch.setattr(video_visible, detector_name, fake_detector)
scans = scan_video_marks(
tmp_path / "synthetic.mp4",
("sora", "veo", "seedance", "dola", "hailuo", "kling"),
)
assert set(scans) == {"sora", "veo", "seedance", "dola", "hailuo", "kling"}
assert len(prepared_ids) == 6
assert len(set(prepared_ids)) == 1
class TestVideoVisibleEncoding:
@staticmethod
def _patch_single_frame_encode(
monkeypatch: pytest.MonkeyPatch,
*,
encoded_bytes: bytes,
fail: bool,
) -> tuple[object, list[Path]]:
from remove_ai_watermarks import video_visible, watermark_registry
frame = np.full((8, 8, 3), 32, dtype=np.uint8)
class FakeCapture:
def __init__(self) -> None:
self._read = False
def isOpened(self) -> bool:
return True
def read(self) -> tuple[bool, np.ndarray | None]:
if self._read:
return False, None
self._read = True
return True, frame.copy()
def release(self) -> None:
pass
class FakeProcess:
def __init__(self) -> None:
self.stdin = io.BytesIO()
def poll(self) -> int:
return 1
targets: list[Path] = []
def fake_command(_source: Path, target: Path, **_kwargs: object) -> list[str]:
targets.append(target)
return ["ffmpeg", str(target)]
def fake_finish(_process: object, target: Path, **_kwargs: object) -> None:
target.write_bytes(encoded_bytes)
if fail:
raise RuntimeError("synthetic encode failure")
process = FakeProcess()
monkeypatch.setattr(video_visible.cv2, "VideoCapture", lambda _path: FakeCapture())
monkeypatch.setattr(video_visible, "raw_video_command", fake_command)
monkeypatch.setattr(video_visible, "start_raw_video_encoder", lambda _command: process)
monkeypatch.setattr(video_visible, "finish_raw_video_encoder", fake_finish)
monkeypatch.setattr(watermark_registry, "resolve_backend", lambda _backend: "cv2")
return process, targets
def test_publishes_completed_encode_atomically(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
from remove_ai_watermarks.video_visible import FrameLocalization, VideoScan, encode_clean_video
source = tmp_path / "source.mp4"
source.write_bytes(b"source")
output = tmp_path / "clean.mp4"
scan = VideoScan(8, 8, 24.0, (FrameLocalization(0, 0.0, None),))
_process, targets = self._patch_single_frame_encode(
monkeypatch,
encoded_bytes=b"complete",
fail=False,
)
encode_clean_video(
source,
output,
scan,
[None],
backend="cv2",
strip_metadata=True,
)
assert output.read_bytes() == b"complete"
assert targets[0] != output
assert targets[0].suffix == output.suffix
assert not targets[0].exists()
def test_failed_encode_preserves_existing_output(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
from remove_ai_watermarks.video_visible import FrameLocalization, VideoScan, encode_clean_video
source = tmp_path / "source.mp4"
source.write_bytes(b"source")
output = tmp_path / "clean.mp4"
output.write_bytes(b"previous")
scan = VideoScan(8, 8, 24.0, (FrameLocalization(0, 0.0, None),))
_process, targets = self._patch_single_frame_encode(
monkeypatch,
encoded_bytes=b"partial",
fail=True,
)
with pytest.raises(RuntimeError, match="synthetic encode failure"):
encode_clean_video(
source,
output,
scan,
[None],
backend="cv2",
strip_metadata=True,
)
assert output.read_bytes() == b"previous"
assert not targets[0].exists()
class TestVideoVisibleApi:
def test_auto_prefers_specific_sora_run_over_hailuo_cross_match(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
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"
sora_box = (4, 4, 20, 8)
hailuo_box = (30, 40, 28, 12)
sora_scan = VideoScan(
width=64,
height=64,
fps=24.0,
detections=tuple(FrameLocalization(index, 0.66, sora_box) for index in range(12)),
)
hailuo_scan = VideoScan(
width=64,
height=64,
fps=24.0,
detections=tuple(FrameLocalization(index, 0.35, hailuo_box) for index in range(12)),
)
def fake_scan(_source: Path, marks: tuple[str, ...]):
assert marks == ("sora", "veo", "seedance", "dola", "hailuo", "kling")
return {
"sora": sora_scan,
"veo": sora_scan,
"seedance": sora_scan,
"dola": sora_scan,
"hailuo": hailuo_scan,
"kling": sora_scan,
}
monkeypatch.setattr(video_visible, "scan_video_marks", fake_scan)
def fake_encode(
_source: Path,
target: Path,
_scan: VideoScan,
regions: list[tuple[int, int, int, int] | None],
**kwargs: object,
) -> int:
assert regions == [sora_box] * 12
assert kwargs["padding_fraction"] == 0.28
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)
assert result.output == output
assert result.mark == "sora"
def test_removes_stable_sora_run_and_writes_output(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
from remove_ai_watermarks import video_visible
from remove_ai_watermarks.video import remove_video_visible
@@ -1033,7 +1276,11 @@ class TestVideoVisibleApi:
fps=24.0,
detections=tuple(FrameLocalization(index, 0.66, box) for index in range(5)),
)
monkeypatch.setattr(video_visible, "scan_sora_video", lambda _source: scan)
monkeypatch.setattr(
video_visible,
"scan_video_marks",
lambda _source, marks: {"sora": scan} if marks == ("sora",) else {},
)
def fake_encode(
_source: Path,
@@ -1048,7 +1295,7 @@ class TestVideoVisibleApi:
monkeypatch.setattr(video_visible, "encode_clean_video", fake_encode)
result = remove_video_visible(source, output)
result = remove_video_visible(source, output, mark="sora")
assert result.output == output
assert result.detected_frames == 5
@@ -1072,9 +1319,13 @@ class TestVideoVisibleApi:
FrameLocalization(2, 0.70, (1, 30, 20, 8)),
),
)
monkeypatch.setattr(video_visible, "scan_sora_video", lambda _source: scan)
monkeypatch.setattr(
video_visible,
"scan_video_marks",
lambda _source, marks: {"sora": scan} if marks == ("sora",) else {},
)
result = remove_video_visible(source, output)
result = remove_video_visible(source, output, mark="sora")
assert result.output is None
assert result.removed_frames == 0
@@ -1098,7 +1349,11 @@ class TestVideoVisibleApi:
fps=24.0,
detections=tuple(FrameLocalization(index, 0.60, box) for index in range(12)),
)
monkeypatch.setattr(video_visible, "scan_veo_video", lambda _source: scan)
monkeypatch.setattr(
video_visible,
"scan_video_marks",
lambda _source, marks: {"veo": scan} if marks == ("veo",) else {},
)
def fake_encode(
_source: Path,
@@ -1123,12 +1378,12 @@ class TestVideoVisibleApi:
assert result.removed_frames == 12
@pytest.mark.parametrize(
("mark", "scan_name", "mask_style"),
("mark", "mask_style"),
[
("seedance", "scan_seedance_video", "box"),
("dola", "scan_dola_video", "box"),
("hailuo", "scan_hailuo_video", "box"),
("kling", "scan_kling_video", "box"),
("seedance", "box"),
("dola", "box"),
("hailuo", "box"),
("kling", "box"),
],
)
def test_dispatches_fixed_mark_detectors(
@@ -1136,7 +1391,6 @@ class TestVideoVisibleApi:
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
mark: str,
scan_name: str,
mask_style: str,
):
from remove_ai_watermarks import video_visible
@@ -1152,7 +1406,11 @@ class TestVideoVisibleApi:
fps=24.0,
detections=tuple(FrameLocalization(index, 0.60, box) for index in range(12)),
)
monkeypatch.setattr(video_visible, scan_name, lambda _source: scan)
monkeypatch.setattr(
video_visible,
"scan_video_marks",
lambda _source, marks: {mark: scan} if marks == (mark,) else {},
)
def fake_encode(
_source: Path,
@@ -1182,7 +1440,7 @@ class TestVideoVisibleCli:
assert result.exit_code == 0, result.output
assert "temporally stable" in result.output
assert "sora|veo|seedance|dola|hailuo|kling" in result.output
assert "auto|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
+1
View File
@@ -55,6 +55,7 @@ def test_encoder_command_discards_metadata_and_copies_audio(
audio_codec_index = command.index("-c:a")
assert command[audio_codec_index + 1] == "copy"
assert "pipe:0" in command
assert "-shortest" not in command
def test_stream_batches_consumes_only_one_batch_ahead() -> None: