mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 15:36:01 +02:00
Harden automatic video watermark removal for product use
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user