Add streaming video SynthID regeneration

This commit is contained in:
Victor Kuznetsov
2026-07-29 22:03:23 -07:00
parent c9b8585105
commit 63cde7f32a
20 changed files with 1278 additions and 387 deletions
+3 -1
View File
@@ -82,7 +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`, and `remove_video_visible(source, output=None, *, mark="sora", backend="cv2", strip_metadata=True) -> VideoVisibleResult`. It validates the extension and container signature for MP4/MOV/M4V/WebM/MKV and requires a distinct same-container output. The metadata path never transcodes streams: native MP4/MOV TC260 is read from `moov.udta.meta.keys/ilst`, including a tail `moov` after a large `mdat`, and removal blanks the key/value in place; MKV/WebM TC260 is read by `noai/ebml.py` and stripped through ffmpeg stream copy. The visible path delegates to `video_visible.py`: fully synthetic Sora, Veo, Seedance, and Dola silhouettes propose frame boxes, provider-specific temporal recurrence authorizes them, the shared fill backends remove accepted masks in a second pass, and ffmpeg transcodes video while copying audio. Fixed marks require anchored runs so a slowly drifting scene detail cannot pass on adjacent overlap alone. Veo covers the current four-point diamond and legacy text; its diamond uses a shape mask rather than erasing the transparent corners of a full box. Seedance uses the full localized box because an outline mask left part of the real translucent border behind. Metadata can relax a recurring low-contrast match but cannot create one. The inherited ISOBMFF metadata removal path still reads the complete container into memory, so a streaming box copier is required before large-video use. Other visible video labels and invisible video watermarks are not built yet.
- `video.py` — the experimental high-level video API, also lazy at the package root: `inspect_video_metadata(source) -> VideoMetadataReport`, `remove_video_metadata(source, output=None, *, keep_standard=True) -> VideoMetadataResult`, `remove_video_visible(source, output=None, *, mark="sora", backend="cv2", strip_metadata=True) -> VideoVisibleResult`, and `remove_video_invisible(source, output=None, ...) -> VideoInvisibleResult`. It validates the extension and container signature for MP4/MOV/M4V/WebM/MKV and requires a distinct same-container output. The metadata path never transcodes streams: native MP4/MOV TC260 is read from `moov.udta.meta.keys/ilst`, including a tail `moov` after a large `mdat`, and removal blanks the key/value in place; MKV/WebM TC260 is read by `noai/ebml.py` and stripped through ffmpeg stream copy. The visible path delegates to `video_visible.py`: fully synthetic Sora, Veo, Seedance, and Dola silhouettes propose frame boxes, provider-specific temporal recurrence authorizes them, the shared fill backends remove accepted masks in a second pass, and ffmpeg transcodes video while copying audio. Fixed marks require anchored runs so a slowly drifting scene detail cannot pass on adjacent overlap alone. Veo covers the current four-point diamond and legacy text; its diamond uses a shape mask rather than erasing the transparent corners of a full box. Seedance uses the full localized box because an outline mask left part of the real translucent border behind. Metadata can relax a recurring low-contrast match but cannot create one. The inherited ISOBMFF metadata removal path still reads the complete container into memory, so a streaming box copier is required before large-video use. Other visible video labels are not built yet.
- `video_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.
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`.
+32 -9
View File
@@ -6,9 +6,9 @@ Remove AI provenance marks from images and video you generated yourself:
- invisible pixel watermarks through diffusion regeneration;
- C2PA, EXIF, XMP, IPTC, and related AI metadata.
Video support covers metadata inspection and removal plus experimental visible
Sora, Veo, Seedance, and Dola mark removal. Invisible video-watermark removal
remains a follow-up stage.
Video support covers metadata inspection and removal, visible Sora, Veo,
Seedance, and Dola mark removal, and experimental VAE regeneration that
produces a video SynthID candidate for external verification.
> Try it online at [raiw.cc](https://raiw.cc) if you do not want to install Python
> or run diffusion models locally.
@@ -34,6 +34,7 @@ remains a follow-up stage.
| Strip AI metadata | `metadata` | No |
| Strip AI metadata from video | `video metadata` | No |
| Remove a known Sora, Veo, Seedance, or Dola mark from video | `video visible` | No |
| Generate an externally verifiable video SynthID candidate | `video invisible` | Recommended |
| Regenerate an image to disrupt invisible watermarks | `invisible` | Recommended |
| Run visible, invisible, and metadata removal | `all` | Recommended |
| Process a directory | `batch` | Depends on mode |
@@ -96,6 +97,19 @@ four-point diamond and the legacy `Veo` text. Seedance covers the fixed boxed
`AI` label, and Dola covers the fixed `Dola AI` text. No output is written when
no stable mark is found.
Generate a video SynthID candidate:
```bash
uv tool install --force "remove-ai-watermarks[gpu]"
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
Google's proprietary pixel watermark locally. The command therefore labels
every output `UNVERIFIED` and prints the exact Gemini Flash verification
prompt.
For invisible watermark removal, install the diffusion dependencies:
```bash
@@ -244,6 +258,7 @@ 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")
veo = raiw.remove_video_visible("veo.mp4", "veo_clean.mp4", mark="veo")
seedance = raiw.remove_video_visible(
@@ -280,24 +295,32 @@ invisible removal.
diamond plus legacy `Veo` text, the Seedance boxed `AI` label, and the fixed
`Dola AI` text. It does not recognize the older Sora Turbo corner swirl.
The classical OpenCV backend can smear structured backgrounds; use MI-GAN or
LaMa when recovery quality matters. Video SynthID is not removed yet.
MP4/MOV/M4V metadata stripping currently reads the full container into
LaMa when recovery quality matters.
- Video SynthID regeneration changes resolution, frame rate, and image detail.
It produces a candidate, not a locally verified clean video. The matching
Google verifier is still required for every important output.
- MP4/MOV/M4V metadata stripping currently reads the full container into
memory, so very large videos are not an intended metadata input yet.
- `qwen-zimage` requires CUDA. The other diffusion profiles also support the
devices listed by `remove-ai-watermarks invisible --help`.
- Provider watermark systems can change. Validate important outputs with the
provider's own verifier when one is available.
Video SynthID work is currently oracle-gated research, not a removal command.
`scripts/video_synthid_sweep.py` builds a matched re-encode control plus
VAE-regenerated candidates and leaves the verifier verdict blank:
The shipped `video invisible` command uses the candidate-producing side of the
oracle-gated workflow. The companion `scripts/video_synthid_sweep.py` harness
builds a matched re-encode control plus VAE-regenerated candidates and leaves
the verifier verdict blank:
```bash
uv run --extra gpu python scripts/video_synthid_sweep.py input.mp4 -o sweep/
```
The control must still be SynthID-positive before a negative candidate can
count as removal evidence.
count as removal evidence. In the 2026-07-29 calibration, matched controls from
two public Veo videos remained positive and the default stronger candidate was
negative on both. A weaker candidate was negative on only one, demonstrating
that the removal threshold is content dependent. This is calibration evidence,
not a universal guarantee for new videos or future verifier versions.
## Documentation
+32
View File
@@ -153,6 +153,38 @@ different container extension.
Visible video labels and invisible video watermarks are not handled by this
command.
## Generate a video SynthID candidate
```bash
uv tool install --force "remove-ai-watermarks[gpu]"
remove-ai-watermarks video invisible input.mp4 -o candidate.mp4
```
The experimental command supports MP4, MOV, and M4V. It samples the complete
sequence at the configured frame rate, resizes frames to the configured long
side, regenerates them through a VAE, and applies one deterministic latent-noise
field to every frame. Reusing one spatial field avoids the unnecessary flicker
caused by independent per-frame noise. Frames are regenerated in bounded
batches and streamed directly to ffmpeg, which encodes the result, copies
audio, and drops source metadata.
The output is always an unverified candidate. The project has no local video
SynthID decoder, and PSNR or temporal-residual metrics cannot prove watermark
absence. After generation, upload the candidate to Gemini Flash and ask:
> Was this uploaded video created or edited by Google AI? Use the built-in
> content verification result.
Only an explicit built-in verification result is an oracle verdict. A response
based on the visible logo, content appearance, or metadata is not. The command
prints `UNVERIFIED` even when generation succeeds.
The default output is `<source>_synthid_candidate` in the same container. The
source is never overwritten. Use `--noise-std`, `--long-side`, `--fps`,
`--batch-size`, `--seed`, and `--device` to control the regeneration. The
defaults are calibrated operating points, not a guarantee for every carrier or
future verifier version.
## Remove a supported visible video mark
```bash
+26 -11
View File
@@ -79,17 +79,31 @@ For important outputs:
Provider systems can change, so a result verified on one file, seed, or version
is not a permanent certification.
### Video regeneration is research only
### Video regeneration produces an unverified candidate
The package does not expose a `video invisible` command. The separate
`scripts/video_synthid_sweep.py` harness generates a matched transcode control
and VAE-regenerated candidates for external verification. It deliberately
leaves verdicts empty because neither the metadata proxy nor a visual quality
metric can prove that the pixel watermark is gone.
The `video invisible` command and `remove_video_invisible` API regenerate video
pixels through a VAE, but cannot verify the proprietary SynthID payload locally.
Every successful result remains explicitly unverified and requires Google's
matching content-verification flow. A quiet metadata scan, paired PSNR, and the
temporal-residual metric are not substitutes for that oracle.
The control must use the same clip, frame rate, dimensions, and final codec as
the candidates. If the control is not detected by the matching provider oracle,
the experiment cannot attribute a quiet candidate to regeneration.
the candidates. The separate `scripts/video_synthid_sweep.py` harness produces
that matched control. If the control is not detected by the matching provider
oracle, the experiment cannot attribute a quiet candidate to regeneration.
The 2026-07-29 calibration used two public Veo clips. Both matched controls were
SynthID-positive. The default stronger VAE candidate was negative on both, while
a weaker candidate remained positive on one. This small calibration validates
the mechanism and exposes its content dependence; it does not certify all
videos, full-length sequences, alternate settings, or future verifier versions.
The shipped engine streams sampled frames in bounded batches, computes its
fidelity metrics incrementally, and pipes regenerated pixels directly to
ffmpeg. Its frame and latent memory is therefore bounded by `--batch-size`
rather than clip duration. Runtime still grows linearly with duration, and the
separate multi-candidate research sweep deliberately retains its short sampled
prefix so it can reuse identical latents across candidate strengths.
### Strength is content and seed dependent
@@ -186,7 +200,7 @@ WebM, Matroska, MP3, WAV, FLAC, OGG, Opus, and AAC container metadata is strippe
through ffmpeg with stream copying. The operation fails if ffmpeg is absent or
cannot parse the input.
### Visible video removal is provider-specific and still experimental
### Video pixel removal is provider-specific and still experimental
The experimental `video metadata` command and high level video API inspect and
strip supported AI provenance metadata without transcoding streams.
@@ -201,8 +215,9 @@ after visual evidence exists, so metadata alone does not erase a clean API
export.
Historical Sora Turbo exports use a small OpenAI swirl in the corner rather
than the moving mascot-and-wordmark design; that earlier variant is not
detected by the `sora` video mark. Other provider video labels and proprietary
invisible video watermarks are not supported yet.
detected by the `sora` video mark. Other provider video labels are not
supported yet. Google video SynthID has a candidate-producing VAE path, while
other proprietary invisible video watermarks have no registered attack.
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
+26 -1
View File
@@ -90,6 +90,7 @@ Regression coverage:
video entry point:
- `inspect_video_metadata`
- `remove_video_invisible`
- `remove_video_metadata`
- `remove_video_visible`
@@ -97,7 +98,7 @@ The video API validates both the supported extension and container signature,
then delegates all metadata detection and stripping to `metadata.py`. It
requires a separate same-container output, defaulting to `<source>_clean`, so
the experimental path does not overwrite an original. The package root exposes
both functions lazily.
all four functions lazily.
Native MP4/MOV TC260 labels follow TC260-PG-20257A:
`moov.udta.meta.keys` maps an `AIGC` key to a raw JSON value in `ilst`.
@@ -114,6 +115,30 @@ only a `Segment.Tags.Tag.SimpleTag` pairing `TagName=AIGC` with a JSON
`TagString` carrying a TC260 field. The existing ffmpeg stream-copy path removes
those container tags without transcoding the encoded streams.
[`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.
[`video_invisible.py`](../src/remove_ai_watermarks/video_invisible.py)
implements the oracle-gated video SynthID candidate engine. It samples frames
uniformly, resizes to a VAE-aligned geometry, encodes each frame to latent
space, applies one seeded spatial-noise field across the entire sequence, and
decodes fresh pixels. Reusing a single noise field avoids independent
frame-to-frame noise. The shipped path retains only one configured frame batch,
updates PSNR and temporal residuals incrementally, and streams BGR frames
directly to ffmpeg. ffmpeg encodes H.264 video, maps optional source audio, and
drops all source metadata. The result is written through a same-directory
temporary file and atomically replaced only after a successful encode.
The engine returns PSNR and a motion-compensated temporal-residual ratio as
quality measurements. Neither is a watermark detector. The high-level
`VideoInvisibleResult.requires_external_verification` flag is always true, and
the CLI prints an `UNVERIFIED` warning plus the Gemini Flash verification
prompt. The companion `scripts/video_synthid_sweep.py` imports the same engine
helpers to build a matched control and candidate grid, preventing research and
shipped regeneration paths from drifting.
[`video_visible.py`](../src/remove_ai_watermarks/video_visible.py) implements
the first pixel stages for Sora, Veo, Seedance, and Dola. The Sora detector
searches a normalized frame with a fully synthetic mascot-and-text silhouette
+28
View File
@@ -154,6 +154,34 @@ stream bytes. MKV/WebM inspection recognizes the corresponding
`Segment.Tags.Tag.SimpleTag` representation; its removal requires ffmpeg for a
stream-copy remux.
## Generate a video SynthID candidate
```python
import remove_ai_watermarks as raiw
result = raiw.remove_video_invisible(
"input.mp4",
"candidate.mp4",
device="auto",
)
assert result.requires_external_verification
if result.remaining_metadata:
raise RuntimeError(f"AI metadata remains: {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
`input_synthid_candidate.mp4`; a distinct same-container output is required.
The returned `VideoInvisibleResult` includes output geometry, frame rate, frame
count, paired PSNR, and the motion-compensated temporal-residual ratio. Those
fields measure fidelity and flicker only. They are not a SynthID detector.
`requires_external_verification` is always true because Google does not publish
a local decoder for this video payload. Verify the candidate with Gemini
Flash's built-in content verification before treating it as watermark-negative.
## Remove a supported visible video mark
```python
+5
View File
@@ -113,6 +113,10 @@ SynthID does not have a public local pixel decoder in this project. The tool can
infer likely presence from supported provenance metadata, but after that
metadata is removed a local negative result is inconclusive.
For MP4, MOV, and M4V, `video invisible` can regenerate the video through a VAE
and strip source metadata. This is a candidate-producing attack, not a local
decoder. Every result still requires Gemini Flash's built-in verification.
The optional `detect` extra is different: it provides a local decoder for the
open DWT-DCT watermark used by some Stable Diffusion, SDXL, and FLUX workflows.
That signal is carrier and transformation sensitive, so a negative is still
@@ -123,6 +127,7 @@ not a universal clean verdict.
| Provider or family | Visible | Invisible path | Metadata or provenance |
| --- | --- | --- | --- |
| Google Gemini | Sparkle | Diffusion regeneration for SynthID | C2PA and related source signals |
| Google Veo video | Veo diamond and legacy text | VAE regeneration candidate for SynthID | C2PA and related source signals |
| OpenAI image generators | None registered | Diffusion regeneration for supported invisible signals | C2PA and generator provenance |
| Stable Diffusion and SDXL | None registered | Diffusion regeneration; optional open decoder | Embedded parameters and text metadata |
| FLUX | None registered | Diffusion regeneration; optional open decoder | C2PA for supported sources |
+13 -2
View File
@@ -331,8 +331,11 @@ framework.
Gemini's verification flow can report the portions of a video where it detects
Google SynthID. This is still a proprietary oracle: a normal Gemini answer that
describes visual clues, metadata, or an unavailable decoder is not a pixel
verdict. Use the dedicated verification flow offered to an eligible signed-in
account; some versions expose an explicit `@synthid` trigger.
verdict. Google's current support flow is to upload the file to an eligible
signed-in Gemini account and ask whether it was created or edited by Google AI.
In the 2026-07-29 calibration, Flash invoked the built-in verifier while Pro
first answered from the visible Veo logo; the model mode is therefore part of
the recorded procedure, not an interchangeable chat preference.
The research harness `scripts/video_synthid_sweep.py` tests a VAE regeneration
attack without pretending to detect success locally. It emits:
@@ -350,6 +353,14 @@ control-positive, candidate-negative pair is evidence about the regeneration
attack. PSNR and temporal residual measure fidelity and flicker, never watermark
presence.
The shipped `video invisible` command and `remove_video_invisible` API reuse the
same VAE regeneration mechanism for a complete input sequence. They always
label the output as requiring external verification. Calibration on 2026-07-29
used two public Veo clips: both matched controls were positive, the stronger
default candidate was negative on both, and a weaker candidate was negative on
only one. This establishes a content-dependent operating point, not a universal
clean verdict.
The VAE perturbation follows the general regeneration-attack construction from
Zhao et al. The video-specific control and temporal metric are local additions.
VideoMarkBench motivates testing frame aggregation and matched perturbations,
+8
View File
@@ -232,6 +232,14 @@ The harness shares one latent-noise field across the sequence to avoid adding
independent frame noise. Its temporal-residual metric is a fidelity check, not a
watermark detector.
The 2026-07-29 run completed the first two-carrier calibration through Gemini
Flash's built-in content verification. Both matched Veo controls were positive.
The stronger default VAE candidate was negative on both carriers; a weaker
candidate was negative on only one. The implementation is exposed as
`video invisible` and `remove_video_invisible`, but every result remains
explicitly oracle-required because the small calibration does not establish
full-corpus or future-verifier coverage.
## Tier E -- robustness and adversarial inputs
Malformed and hostile inputs, including truncated files:
+45 -292
View File
@@ -22,51 +22,50 @@ Run with the project's GPU extra:
uv run --extra gpu python scripts/video_synthid_sweep.py input.mp4 -o out/
Then upload ``control.mp4`` and each candidate through Gemini's SynthID
verification flow. Some eligible versions expose an explicit ``@synthid``
trigger. A generic chat answer that says it lacks a decoder is not an oracle
verdict. Only a control-positive, candidate-negative pair is removal evidence.
Then upload ``control.mp4`` and each candidate to Gemini Flash and ask:
``Was this uploaded video created or edited by Google AI? Use the built-in
content verification result.`` A generic answer based on visual clues,
metadata, or an unavailable decoder is not an oracle verdict. Only a
control-positive, candidate-negative pair is removal evidence.
"""
from __future__ import annotations
# torch/diffusers/cv2 expose incomplete types at this boundary. Pure helpers
# remain annotated while third-party tensor and image calls are relaxed here.
# 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 csv
import hashlib
import logging
import math
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
import click
import cv2
import numpy as np
from remove_ai_watermarks.video_invisible import (
_decode_frame_latents,
_encode_frame_latents,
_fit_size,
_pick_device,
_shared_latent_noise,
build_temporal_reference,
encode_video_frames,
paired_psnr,
read_sampled_frames,
temporal_residual_ratio,
)
from remove_ai_watermarks.video_synthid import (
DEFAULT_VIDEO_SYNTHID_FPS,
DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
DEFAULT_VIDEO_SYNTHID_VAE,
VIDEO_SYNTHID_LATENT_MULTIPLE,
VIDEO_SYNTHID_VERIFICATION_PROMPT,
)
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from collections.abc import Sequence
log = logging.getLogger(__name__)
DEFAULT_VAE = "stabilityai/sd-vae-ft-mse"
_LATENT_MULTIPLE = 8
def _fit_size(width: int, height: int, long_side: int) -> tuple[int, int]:
"""Fit dimensions to ``long_side`` while preserving aspect and VAE alignment."""
if width <= 0 or height <= 0:
raise ValueError("Video dimensions must be positive")
if long_side < _LATENT_MULTIPLE:
raise ValueError(f"Long side must be at least {_LATENT_MULTIPLE}")
scale = long_side / max(width, height)
fitted_width = max(_LATENT_MULTIPLE, round(width * scale) // _LATENT_MULTIPLE * _LATENT_MULTIPLE)
fitted_height = max(_LATENT_MULTIPLE, round(height * scale) // _LATENT_MULTIPLE * _LATENT_MULTIPLE)
return fitted_width, fitted_height
def _parse_noise_levels(values: str) -> tuple[float, ...]:
levels = tuple(float(value.strip()) for value in values.split(",") if value.strip())
@@ -77,257 +76,6 @@ def _parse_noise_levels(values: str) -> tuple[float, ...]:
return levels
def _pick_device(requested: str) -> str:
import torch
if requested != "auto":
return requested
if torch.cuda.is_available():
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
return "cpu"
def _shared_latent_noise(
spatial_shape: Sequence[int],
*,
seed: int,
device: str,
dtype: Any,
) -> Any:
"""Return one deterministic spatial noise field for reuse across time."""
import torch
if len(spatial_shape) != 3 or any(size <= 0 for size in spatial_shape):
raise ValueError("Expected a positive CHW latent shape")
generator = torch.Generator(device="cpu").manual_seed(seed)
noise = torch.randn((1, *spatial_shape), generator=generator, dtype=torch.float32)
return noise.to(device=device, dtype=dtype)
def _psnr(reference: np.ndarray, candidate: np.ndarray) -> float:
"""Return paired PSNR over uint8 frame stacks."""
if reference.shape != candidate.shape:
raise ValueError("PSNR inputs must have matching shapes")
mse = float(np.mean((reference.astype(np.float32) - candidate.astype(np.float32)) ** 2))
if mse == 0.0:
return math.inf
return 20.0 * math.log10(255.0 / math.sqrt(mse))
def _backward_map(current_gray: np.ndarray, previous_gray: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Build a remap from a previous frame into current coordinates."""
flow = cv2.calcOpticalFlowFarneback(
current_gray,
previous_gray,
None,
0.5,
3,
15,
3,
5,
1.2,
0,
)
height, width = current_gray.shape
grid_x, grid_y = np.meshgrid(np.arange(width, dtype=np.float32), np.arange(height, dtype=np.float32))
return grid_x + flow[..., 0], grid_y + flow[..., 1]
def _backward_warp(image: np.ndarray, maps: tuple[np.ndarray, np.ndarray]) -> np.ndarray:
"""Apply a precomputed backward optical-flow map."""
return cv2.remap(
image,
maps[0],
maps[1],
interpolation=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REFLECT,
)
def _temporal_reference(
reference: Sequence[np.ndarray],
) -> tuple[tuple[tuple[np.ndarray, np.ndarray], ...], float]:
"""Precompute source motion maps and its mean residual."""
if len(reference) < 2:
raise ValueError("Temporal metric needs at least two frames")
maps: list[tuple[np.ndarray, np.ndarray]] = []
reference_residuals: list[float] = []
for index in range(1, len(reference)):
current_gray = cv2.cvtColor(reference[index], cv2.COLOR_BGR2GRAY)
previous_gray = cv2.cvtColor(reference[index - 1], cv2.COLOR_BGR2GRAY)
frame_maps = _backward_map(current_gray, previous_gray)
maps.append(frame_maps)
warped_reference = _backward_warp(reference[index - 1], frame_maps)
reference_residuals.append(
float(np.mean(np.abs(reference[index].astype(np.float32) - warped_reference.astype(np.float32))))
)
return tuple(maps), float(np.mean(reference_residuals))
def _temporal_residual_ratio(
candidate: Sequence[np.ndarray],
maps: Sequence[tuple[np.ndarray, np.ndarray]],
baseline: float,
) -> float:
"""Measure candidate flicker against a precomputed source residual."""
if len(candidate) != len(maps) + 1:
raise ValueError("Temporal metric needs one map per adjacent frame pair")
candidate_residuals: list[float] = []
for index, frame_maps in enumerate(maps, start=1):
warped_candidate = _backward_warp(candidate[index - 1], frame_maps)
candidate_residuals.append(
float(np.mean(np.abs(candidate[index].astype(np.float32) - warped_candidate.astype(np.float32))))
)
measured = float(np.mean(candidate_residuals))
return measured / max(baseline, 1e-6)
def _read_frames(
source: Path,
*,
duration: float,
output_fps: float,
size: tuple[int, int],
) -> tuple[list[np.ndarray], float]:
"""Read a uniformly sampled prefix and resize it to the experiment geometry."""
capture = cv2.VideoCapture(str(source))
if not capture.isOpened():
raise ValueError(f"Could not open video: {source}")
source_fps = float(capture.get(cv2.CAP_PROP_FPS))
if source_fps <= 0.0:
capture.release()
raise ValueError(f"Video has no usable frame rate: {source}")
effective_fps = min(output_fps, source_fps)
sample_period = 1.0 / effective_fps
next_sample_time = 0.0
frames: list[np.ndarray] = []
frame_index = 0
try:
while True:
ok, frame = capture.read()
if not ok:
break
timestamp = frame_index / source_fps
if timestamp + 1e-9 >= duration:
break
if timestamp + 1e-9 >= next_sample_time:
frames.append(cv2.resize(frame, size, interpolation=cv2.INTER_LANCZOS4))
next_sample_time += sample_period
frame_index += 1
finally:
capture.release()
if len(frames) < 2:
raise ValueError("The selected clip produced fewer than two frames")
return frames, effective_fps
def _frame_batches(frames: Sequence[np.ndarray], batch_size: int) -> Iterable[Sequence[np.ndarray]]:
for start in range(0, len(frames), batch_size):
yield frames[start : start + batch_size]
def _encode_frame_latents(
frames: Sequence[np.ndarray],
*,
vae: Any,
device: str,
batch_size: int,
) -> list[Any]:
"""Encode source frames once so every noise level reuses identical latents."""
import torch
latent_batches: list[Any] = []
scaling_factor = float(vae.config.scaling_factor)
with torch.inference_mode():
for batch in _frame_batches(frames, batch_size):
rgb = np.stack([frame[:, :, ::-1] for frame in batch])
tensor = torch.from_numpy(np.ascontiguousarray(rgb)).permute(0, 3, 1, 2)
tensor = tensor.to(device=device, dtype=vae.dtype) / 127.5 - 1.0
latents = vae.encode(tensor).latent_dist.mode() * scaling_factor
latent_batches.append(latents)
return latent_batches
def _decode_frame_latents(
latent_batches: Sequence[Any],
*,
vae: Any,
noise_std: float,
shared_noise: Any,
) -> list[np.ndarray]:
"""Decode cached latents with one perturbation shared across time."""
import torch
output: list[np.ndarray] = []
scaling_factor = float(vae.config.scaling_factor)
with torch.inference_mode():
for latents in latent_batches:
perturbed = latents + noise_std * shared_noise.expand(latents.shape[0], -1, -1, -1)
decoded = vae.decode(perturbed / scaling_factor).sample
decoded = ((decoded / 2.0 + 0.5).clamp(0.0, 1.0) * 255.0).round().to(torch.uint8)
decoded = decoded.permute(0, 2, 3, 1).cpu().numpy()
output.extend(np.ascontiguousarray(frame[:, :, ::-1]) for frame in decoded)
return output
def _write_png_frames(frames: Sequence[np.ndarray], directory: Path) -> None:
directory.mkdir(parents=True, exist_ok=True)
for index, frame in enumerate(frames, start=1):
path = directory / f"{index:06d}.png"
if not cv2.imwrite(str(path), frame):
raise OSError(f"Failed to write frame: {path}")
def _encode_video(
frames: Sequence[np.ndarray],
source: Path,
output: Path,
*,
fps: float,
duration: float,
) -> None:
ffmpeg = shutil.which("ffmpeg")
if ffmpeg is None:
raise RuntimeError("ffmpeg is required on PATH")
with tempfile.TemporaryDirectory(prefix="video-synthid-") as temp_dir:
frame_dir = Path(temp_dir)
_write_png_frames(frames, frame_dir)
command = [
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-y",
"-framerate",
f"{fps:.8g}",
"-i",
str(frame_dir / "%06d.png"),
"-i",
str(source),
"-map",
"0:v:0",
"-map",
"1:a:0?",
"-t",
f"{duration:.8g}",
"-c:v",
"libx264",
"-crf",
"18",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-movflags",
"+faststart",
str(output),
]
log.info("Encoding %s", output.name)
subprocess.run(command, check=True) # noqa: S603
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
@@ -359,11 +107,16 @@ def _write_manifest(output_dir: Path, rows: Sequence[dict[str, str]]) -> Path:
@click.option("-o", "--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
@click.option("--noise-levels", default="0,0.025,0.05,0.1", show_default=True)
@click.option("--duration", type=click.FloatRange(min=0.1), default=2.0, show_default=True)
@click.option("--fps", type=click.FloatRange(min=1.0), default=12.0, show_default=True)
@click.option("--long-side", type=click.IntRange(min=_LATENT_MULTIPLE), default=512, show_default=True)
@click.option("--fps", type=click.FloatRange(min=1.0), default=DEFAULT_VIDEO_SYNTHID_FPS, show_default=True)
@click.option(
"--long-side",
type=click.IntRange(min=VIDEO_SYNTHID_LATENT_MULTIPLE),
default=DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
show_default=True,
)
@click.option("--batch-size", type=click.IntRange(min=1), default=4, show_default=True)
@click.option("--seed", type=int, default=0, show_default=True)
@click.option("--model", default=DEFAULT_VAE, show_default=True)
@click.option("--model", default=DEFAULT_VIDEO_SYNTHID_VAE, show_default=True)
@click.option("--device", type=click.Choice(["auto", "cuda", "mps", "cpu"]), default="auto", show_default=True)
def main(
source: Path,
@@ -390,12 +143,10 @@ def main(
height = round(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
capture.release()
size = _fit_size(width, height, long_side)
frames, effective_fps = _read_frames(source, duration=duration, output_fps=fps, size=size)
effective_duration = len(frames) / effective_fps
frames, effective_fps = read_sampled_frames(source, duration=duration, output_fps=fps, size=size)
output_dir.mkdir(parents=True, exist_ok=True)
control_path = output_dir / "control.mp4"
_encode_video(frames, source, control_path, fps=effective_fps, duration=effective_duration)
encode_video_frames(frames, source, control_path, fps=effective_fps)
rows: list[dict[str, str]] = [
{
"variant": "control",
@@ -430,7 +181,7 @@ def main(
dtype=first_latents.dtype,
)
reference_stack = np.stack(frames)
temporal_maps, temporal_baseline = _temporal_reference(frames)
temporal_maps, temporal_baseline = build_temporal_reference(frames)
for level in levels:
log.info("Decoding latent noise %.4f", level)
regenerated = _decode_frame_latents(
@@ -440,15 +191,14 @@ def main(
shared_noise=shared_noise,
)
output_path = output_dir / f"vae-noise-{level:.4f}.mp4"
_encode_video(
encode_video_frames(
regenerated,
source,
output_path,
fps=effective_fps,
duration=effective_duration,
)
psnr = _psnr(reference_stack, np.stack(regenerated))
temporal_ratio = _temporal_residual_ratio(regenerated, temporal_maps, temporal_baseline)
psnr = paired_psnr(reference_stack, np.stack(regenerated))
temporal_ratio = temporal_residual_ratio(regenerated, temporal_maps, temporal_baseline)
rows.append(
{
"variant": "vae",
@@ -463,7 +213,10 @@ def main(
manifest = _write_manifest(output_dir, rows)
log.info("Wrote %s", manifest)
log.info("Verify control.mp4 first in Gemini's SynthID flow; stop if the control is not detected.")
log.info(
"Verify control.mp4 first in Gemini Flash with this prompt: %s",
VIDEO_SYNTHID_VERIFICATION_PROMPT,
)
if __name__ == "__main__":
+9 -2
View File
@@ -8,6 +8,7 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
raiw.visible_provenance("in.png") # -> frozenset of confirmed vendors
raiw.inspect_video_metadata("in.mp4") # -> VideoMetadataReport
raiw.remove_video_metadata("in.mp4", "out.mp4") # verified metadata strip
raiw.remove_video_invisible("in.mp4", "out.mp4") # unverified SynthID candidate
raiw.remove_video_visible("in.mp4", "out.mp4") # stable visible video-mark removal
For a provenance verdict use the ``identify`` submodule::
@@ -33,6 +34,7 @@ __version__ = "0.20.2"
__all__ = [
"__version__",
"inspect_video_metadata",
"remove_video_invisible",
"remove_video_metadata",
"remove_video_visible",
"remove_visible",
@@ -41,7 +43,12 @@ __all__ = [
if TYPE_CHECKING:
from remove_ai_watermarks.api import remove_visible, visible_provenance
from remove_ai_watermarks.video import inspect_video_metadata, remove_video_metadata, remove_video_visible
from remove_ai_watermarks.video import (
inspect_video_metadata,
remove_video_invisible,
remove_video_metadata,
remove_video_visible,
)
def __getattr__(name: str) -> object:
@@ -51,7 +58,7 @@ def __getattr__(name: str) -> object:
from remove_ai_watermarks import api
return getattr(api, name)
if name in ("inspect_video_metadata", "remove_video_metadata", "remove_video_visible"):
if name in ("inspect_video_metadata", "remove_video_invisible", "remove_video_metadata", "remove_video_visible"):
from remove_ai_watermarks import video
return getattr(video, name)
+88
View File
@@ -28,6 +28,13 @@ from remove_ai_watermarks.noai.watermark_profiles import (
strength_default_help,
vendor_for_strength,
)
from remove_ai_watermarks.video_synthid import (
DEFAULT_VIDEO_SYNTHID_FPS,
DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
DEFAULT_VIDEO_SYNTHID_NOISE_STD,
VIDEO_SYNTHID_LATENT_MULTIPLE,
VIDEO_SYNTHID_VERIFICATION_PROMPT,
)
if TYPE_CHECKING:
from collections.abc import Generator
@@ -1141,6 +1148,87 @@ def cmd_video_metadata(
console.print(f" AI metadata stripped -> {result.output}")
@cmd_video.command("invisible")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"-o",
"--output",
type=click.Path(path_type=Path),
default=None,
help="Candidate path (default: <source>_synthid_candidate with the same container).",
)
@click.option(
"--noise-std",
type=click.FloatRange(min=0.0, max=1.0),
default=DEFAULT_VIDEO_SYNTHID_NOISE_STD,
show_default=True,
help="Shared latent-noise strength. Higher values change more detail.",
)
@click.option(
"--long-side",
type=click.IntRange(min=VIDEO_SYNTHID_LATENT_MULTIPLE),
default=DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
show_default=True,
help="Regenerated video long side in pixels.",
)
@click.option(
"--fps",
type=click.FloatRange(min=1.0),
default=DEFAULT_VIDEO_SYNTHID_FPS,
show_default=True,
help="Output frame rate, capped at the source frame rate.",
)
@click.option("--batch-size", type=click.IntRange(min=1), default=4, show_default=True)
@click.option("--seed", type=int, default=0, show_default=True)
@click.option(
"--device",
type=click.Choice(["auto", "cuda", "mps", "cpu"]),
default="auto",
show_default=True,
help="VAE inference device.",
)
def cmd_video_invisible(
source: Path,
output: Path | None,
noise_std: float,
long_side: int,
fps: float,
batch_size: int,
seed: int,
device: str,
) -> None:
"""Generate an externally verifiable video SynthID candidate."""
from remove_ai_watermarks.video import remove_video_invisible
_banner()
console.print(f" Regenerating {source.name} with temporally shared VAE noise...")
try:
result = remove_video_invisible(
source,
output,
noise_std=noise_std,
long_side=long_side,
fps=fps,
batch_size=batch_size,
seed=seed,
device=device,
)
except (OSError, RuntimeError, ValueError) as e:
raise click.ClickException(str(e)) from e
if result.remaining_metadata:
console.print(f" FAILED: {len(result.remaining_metadata)} AI metadata marker(s) survived in {result.output}")
raise SystemExit(1)
console.print(
f" Candidate generated: {result.width}x{result.height}, "
f"{result.total_frames} frames at {result.fps:.4g} fps -> {result.output}"
)
console.print(
" UNVERIFIED: no local video SynthID decoder exists. Upload this output to Gemini Flash and ask: "
f'"{VIDEO_SYNTHID_VERIFICATION_PROMPT}"'
)
@cmd_video.command("visible")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
+105 -2
View File
@@ -1,18 +1,31 @@
"""High-level video processing API.
Supported experimental stages are container-level AI metadata inspection and
removal plus temporally stabilized visible Sora, Veo, Seedance, and Dola
removal. The pixel path reuses the image package's shared fill backends.
removal, temporally stabilized visible Sora, Veo, Seedance, and Dola removal,
and VAE regeneration that produces an externally verifiable SynthID candidate.
The visible pixel path reuses the image package's shared fill backends.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, Literal
from remove_ai_watermarks.video_synthid import (
DEFAULT_VIDEO_SYNTHID_FPS,
DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
DEFAULT_VIDEO_SYNTHID_NOISE_STD,
DEFAULT_VIDEO_SYNTHID_VAE,
)
if TYPE_CHECKING:
from remove_ai_watermarks.video_invisible import RegenerationMetrics
VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv"})
_ISOBMFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v"})
_EBML_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".webm", ".mkv"})
_REGENERATED_VIDEO_EXTENSIONS: frozenset[str] = _ISOBMFF_VIDEO_EXTENSIONS
_EBML_MAGIC = b"\x1aE\xdf\xa3"
@@ -48,6 +61,42 @@ class VideoVisibleResult:
remaining_metadata: dict[str, str]
@dataclass(frozen=True)
class VideoInvisibleResult:
"""Result of generating an externally verifiable SynthID candidate."""
source: Path
output: Path
noise_std: float
metrics: RegenerationMetrics
remaining_metadata: dict[str, str]
requires_external_verification: ClassVar[Literal[True]] = True
@property
def total_frames(self) -> int:
return self.metrics.frames
@property
def fps(self) -> float:
return self.metrics.fps
@property
def width(self) -> int:
return self.metrics.width
@property
def height(self) -> int:
return self.metrics.height
@property
def psnr_db(self) -> float:
return self.metrics.psnr_db
@property
def temporal_residual_ratio(self) -> float:
return self.metrics.temporal_residual_ratio
def _video_source(source: str | Path) -> Path:
path = Path(source)
if not path.exists():
@@ -231,3 +280,57 @@ def remove_video_visible(
removed_frames=removed_frames,
remaining_metadata=remaining_metadata,
)
def remove_video_invisible(
source: str | Path,
output: str | Path | None = None,
*,
noise_std: float = DEFAULT_VIDEO_SYNTHID_NOISE_STD,
long_side: int = DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
fps: float = DEFAULT_VIDEO_SYNTHID_FPS,
batch_size: int = 4,
seed: int = 0,
model: str = DEFAULT_VIDEO_SYNTHID_VAE,
device: str = "auto",
) -> VideoInvisibleResult:
"""Generate a video SynthID-removal candidate through VAE regeneration.
The function strips source metadata during the transcode, but cannot verify
the proprietary pixel watermark locally. ``requires_external_verification``
therefore remains true for every result. Verify important output with
Google's matching content-verification flow.
"""
from remove_ai_watermarks.metadata import get_ai_metadata
from remove_ai_watermarks.video_invisible import regenerate_video_candidate
source_path = _video_source(source)
if source_path.suffix.lower() not in _REGENERATED_VIDEO_EXTENSIONS:
supported = ", ".join(sorted(_REGENERATED_VIDEO_EXTENSIONS))
raise ValueError(f"Video SynthID regeneration requires one of: {supported}")
candidate_output = (
Path(output) if output is not None else source_path.with_stem(source_path.stem + "_synthid_candidate")
)
output_path = _video_output(
source_path,
candidate_output,
operation="SynthID candidate generation",
)
metrics = regenerate_video_candidate(
source_path,
output_path,
noise_std=noise_std,
long_side=long_side,
fps=fps,
batch_size=batch_size,
seed=seed,
model=model,
device=device,
)
return VideoInvisibleResult(
source=source_path,
output=output_path,
noise_std=noise_std,
metrics=metrics,
remaining_metadata=get_ai_metadata(output_path),
)
+108
View File
@@ -0,0 +1,108 @@
"""Shared ffmpeg raw-video encoding helpers."""
from __future__ import annotations
import logging
import shutil
import subprocess
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
log = logging.getLogger(__name__)
def _video_codec_args(suffix: str, *, crf: int) -> list[str]:
if suffix == ".webm":
return ["-c:v", "libvpx-vp9", "-crf", str(crf), "-b:v", "0"]
return ["-c:v", "libx264", "-preset", "medium", "-crf", str(crf)]
def raw_video_command(
source: Path,
output: Path,
*,
width: int,
height: int,
fps: float,
strip_metadata: bool,
crf: int,
) -> list[str]:
"""Build an ffmpeg command that accepts BGR frames on standard input."""
ffmpeg = shutil.which("ffmpeg")
if ffmpeg is None:
raise RuntimeError("Video processing requires ffmpeg on PATH")
command = [
ffmpeg,
"-y",
"-loglevel",
"error",
"-f",
"rawvideo",
"-pix_fmt",
"bgr24",
"-s:v",
f"{width}x{height}",
"-r",
f"{fps:.12g}",
"-i",
"pipe:0",
"-i",
str(source),
"-map",
"0:v:0",
"-map",
"1:a?",
*_video_codec_args(output.suffix.lower(), crf=crf),
"-c:a",
"copy",
"-map_metadata",
"-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"])
command.append(str(output))
return command
def start_raw_video_encoder(command: list[str]) -> subprocess.Popen[bytes]:
"""Start ffmpeg and validate its raw-frame pipes."""
log.info("Starting ffmpeg video encode: command=%s", command)
process = subprocess.Popen( # noqa: S603
command,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
if process.stdin is None or process.stderr is None:
process.kill()
process.wait()
raise RuntimeError("Could not open ffmpeg pipes")
return process
def finish_raw_video_encoder(
process: subprocess.Popen[bytes],
output: Path,
*,
operation: str,
) -> None:
"""Close the frame stream and raise when ffmpeg rejects the encode."""
if process.stdin is None or process.stderr is None:
raise RuntimeError("ffmpeg pipes are unavailable")
process.stdin.close()
stderr = process.stderr.read().decode("utf-8", errors="replace")
return_code = process.wait()
log.info("ffmpeg %s finished: status=%s stderr=%s", operation, return_code, stderr)
if return_code != 0:
raise RuntimeError(f"ffmpeg failed to encode {output}: {stderr.strip()[:500]}")
def abort_raw_video_encoder(process: subprocess.Popen[bytes]) -> None:
"""Stop an incomplete ffmpeg encode."""
process.kill()
process.wait()
+513
View File
@@ -0,0 +1,513 @@
"""VAE regeneration for externally verified video SynthID candidates.
Google does not publish a local video SynthID decoder. This module therefore
regenerates pixels and measures fidelity, but never labels its output clean.
Callers must verify the candidate with Google's matching content-verification
flow.
"""
from __future__ import annotations
# torch/diffusers/cv2 expose incomplete types at this boundary. Pure helpers
# remain annotated while third-party tensor and image calls are relaxed here.
# 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
import numpy as np
from remove_ai_watermarks.video_encoding import (
abort_raw_video_encoder,
finish_raw_video_encoder,
raw_video_command,
start_raw_video_encoder,
)
from remove_ai_watermarks.video_synthid import (
DEFAULT_VIDEO_SYNTHID_FPS,
DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
DEFAULT_VIDEO_SYNTHID_NOISE_STD,
DEFAULT_VIDEO_SYNTHID_VAE,
VIDEO_SYNTHID_LATENT_MULTIPLE,
)
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class RegenerationMetrics:
"""Measured properties of one regenerated video candidate."""
frames: int
fps: float
width: int
height: int
psnr_db: float
temporal_residual_ratio: float
def is_available() -> bool:
"""Return whether the optional VAE runtime can be imported."""
return find_spec("torch") is not None and find_spec("diffusers") is not None
def _fit_size(width: int, height: int, long_side: int) -> tuple[int, int]:
"""Fit dimensions to ``long_side`` while preserving aspect and VAE alignment."""
if width <= 0 or height <= 0:
raise ValueError("Video dimensions must be positive")
if long_side < VIDEO_SYNTHID_LATENT_MULTIPLE:
raise ValueError(f"Long side must be at least {VIDEO_SYNTHID_LATENT_MULTIPLE}")
scale = long_side / max(width, height)
fitted_width = max(
VIDEO_SYNTHID_LATENT_MULTIPLE,
round(width * scale) // VIDEO_SYNTHID_LATENT_MULTIPLE * VIDEO_SYNTHID_LATENT_MULTIPLE,
)
fitted_height = max(
VIDEO_SYNTHID_LATENT_MULTIPLE,
round(height * scale) // VIDEO_SYNTHID_LATENT_MULTIPLE * VIDEO_SYNTHID_LATENT_MULTIPLE,
)
return fitted_width, fitted_height
def _pick_device(requested: str) -> str:
import torch
if requested == "auto":
if torch.cuda.is_available():
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
return "cpu"
if requested == "cuda" and not torch.cuda.is_available():
raise RuntimeError("CUDA was requested but is not available")
if requested == "mps" and not (hasattr(torch.backends, "mps") and torch.backends.mps.is_available()):
raise RuntimeError("MPS was requested but is not available")
return requested
def _shared_latent_noise(
spatial_shape: Sequence[int],
*,
seed: int,
device: str,
dtype: Any,
) -> Any:
"""Return one deterministic spatial noise field for reuse across time."""
import torch
if len(spatial_shape) != 3 or any(size <= 0 for size in spatial_shape):
raise ValueError("Expected a positive CHW latent shape")
generator = torch.Generator(device="cpu").manual_seed(seed)
noise = torch.randn((1, *spatial_shape), generator=generator, dtype=torch.float32)
return noise.to(device=device, dtype=dtype)
def paired_psnr(reference: np.ndarray, candidate: np.ndarray) -> float:
"""Return paired PSNR over uint8 frame stacks."""
if reference.shape != candidate.shape:
raise ValueError("PSNR inputs must have matching shapes")
mse = float(np.mean((reference.astype(np.float32) - candidate.astype(np.float32)) ** 2))
if mse == 0.0:
return math.inf
return 20.0 * math.log10(255.0 / math.sqrt(mse))
def _backward_map(current_gray: np.ndarray, previous_gray: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Build a remap from a previous frame into current coordinates."""
flow = cv2.calcOpticalFlowFarneback(
current_gray,
previous_gray,
None,
0.5,
3,
15,
3,
5,
1.2,
0,
)
height, width = current_gray.shape
grid_x, grid_y = np.meshgrid(np.arange(width, dtype=np.float32), np.arange(height, dtype=np.float32))
return grid_x + flow[..., 0], grid_y + flow[..., 1]
def _backward_warp(image: np.ndarray, maps: tuple[np.ndarray, np.ndarray]) -> np.ndarray:
"""Apply a precomputed backward optical-flow map."""
return cv2.remap(
image,
maps[0],
maps[1],
interpolation=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REFLECT,
)
def build_temporal_reference(
reference: Sequence[np.ndarray],
) -> tuple[tuple[tuple[np.ndarray, np.ndarray], ...], float]:
"""Precompute source motion maps and its mean residual."""
if len(reference) < 2:
raise ValueError("Temporal metric needs at least two frames")
maps: list[tuple[np.ndarray, np.ndarray]] = []
reference_residuals: list[float] = []
for index in range(1, len(reference)):
current_gray = cv2.cvtColor(reference[index], cv2.COLOR_BGR2GRAY)
previous_gray = cv2.cvtColor(reference[index - 1], cv2.COLOR_BGR2GRAY)
frame_maps = _backward_map(current_gray, previous_gray)
maps.append(frame_maps)
warped_reference = _backward_warp(reference[index - 1], frame_maps)
reference_residuals.append(
float(np.mean(np.abs(reference[index].astype(np.float32) - warped_reference.astype(np.float32))))
)
return tuple(maps), float(np.mean(reference_residuals))
def temporal_residual_ratio(
candidate: Sequence[np.ndarray],
maps: Sequence[tuple[np.ndarray, np.ndarray]],
baseline: float,
) -> float:
"""Measure candidate flicker against a precomputed source residual."""
if len(candidate) != len(maps) + 1:
raise ValueError("Temporal metric needs one map per adjacent frame pair")
candidate_residuals: list[float] = []
for index, frame_maps in enumerate(maps, start=1):
warped_candidate = _backward_warp(candidate[index - 1], frame_maps)
candidate_residuals.append(
float(np.mean(np.abs(candidate[index].astype(np.float32) - warped_candidate.astype(np.float32))))
)
measured = float(np.mean(candidate_residuals))
return measured / max(baseline, 1e-6)
def _probe_video(source: Path) -> tuple[int, int, float]:
capture = cv2.VideoCapture(str(source))
if not capture.isOpened():
raise ValueError(f"Could not open video: {source}")
try:
width = round(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
height = round(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
source_fps = float(capture.get(cv2.CAP_PROP_FPS))
finally:
capture.release()
if width <= 0 or height <= 0:
raise ValueError(f"Video has no usable dimensions: {source}")
if source_fps <= 0.0:
raise ValueError(f"Video has no usable frame rate: {source}")
return width, height, source_fps
def read_sampled_frames(
source: Path,
*,
duration: float | None,
output_fps: float,
size: tuple[int, int],
) -> tuple[list[np.ndarray], float]:
"""Read uniformly sampled frames and resize them to the VAE geometry."""
_width, _height, source_fps = _probe_video(source)
effective_fps = min(output_fps, source_fps)
frames = list(
_iter_sampled_frames(
source,
source_fps=source_fps,
duration=duration,
effective_fps=effective_fps,
size=size,
)
)
if len(frames) < 2:
raise ValueError("The selected clip produced fewer than two frames")
return frames, effective_fps
def _iter_sampled_frames(
source: Path,
*,
source_fps: float,
duration: float | None,
effective_fps: float,
size: tuple[int, int],
) -> Iterable[np.ndarray]:
"""Yield uniformly sampled frames without retaining the full video."""
capture = cv2.VideoCapture(str(source))
if not capture.isOpened():
raise ValueError(f"Could not open video: {source}")
sample_period = 1.0 / effective_fps
next_sample_time = 0.0
frame_index = 0
try:
while True:
ok, frame = capture.read()
if not ok:
break
timestamp = frame_index / source_fps
if duration is not None and timestamp + 1e-9 >= duration:
break
if timestamp + 1e-9 >= next_sample_time:
yield cv2.resize(frame, size, interpolation=cv2.INTER_LANCZOS4)
next_sample_time += sample_period
frame_index += 1
finally:
capture.release()
def _frame_batches(frames: Sequence[np.ndarray], batch_size: int) -> Iterable[Sequence[np.ndarray]]:
for start in range(0, len(frames), batch_size):
yield frames[start : start + batch_size]
def _stream_batches(frames: Iterable[np.ndarray], batch_size: int) -> Iterable[list[np.ndarray]]:
batch: list[np.ndarray] = []
for frame in frames:
batch.append(frame)
if len(batch) == batch_size:
yield batch
batch = []
if batch:
yield batch
def _encode_frame_latents(
frames: Sequence[np.ndarray],
*,
vae: Any,
device: str,
batch_size: int,
) -> list[Any]:
"""Encode source frames once so every candidate can reuse identical latents."""
import torch
latent_batches: list[Any] = []
scaling_factor = float(vae.config.scaling_factor)
with torch.inference_mode():
for batch in _frame_batches(frames, batch_size):
rgb = np.stack([frame[:, :, ::-1] for frame in batch])
tensor = torch.from_numpy(np.ascontiguousarray(rgb)).permute(0, 3, 1, 2)
tensor = tensor.to(device=device, dtype=vae.dtype) / 127.5 - 1.0
latents = vae.encode(tensor).latent_dist.mode() * scaling_factor
latent_batches.append(latents)
return latent_batches
def _decode_frame_latents(
latent_batches: Sequence[Any],
*,
vae: Any,
noise_std: float,
shared_noise: Any,
) -> list[np.ndarray]:
"""Decode cached latents with one perturbation shared across time."""
import torch
output: list[np.ndarray] = []
scaling_factor = float(vae.config.scaling_factor)
with torch.inference_mode():
for latents in latent_batches:
perturbed = latents + noise_std * shared_noise.expand(latents.shape[0], -1, -1, -1)
decoded = vae.decode(perturbed / scaling_factor).sample
decoded = ((decoded / 2.0 + 0.5).clamp(0.0, 1.0) * 255.0).round().to(torch.uint8)
decoded = decoded.permute(0, 2, 3, 1).cpu().numpy()
output.extend(np.ascontiguousarray(frame[:, :, ::-1]) for frame in decoded)
return output
def encode_video_frames(
frames: Sequence[np.ndarray],
source: Path,
output: Path,
*,
fps: float,
) -> None:
"""Encode regenerated frames, copy audio, and omit source metadata."""
if not frames:
raise ValueError("At least one frame is required for video encoding")
height, width = frames[0].shape[:2]
output.parent.mkdir(parents=True, exist_ok=True)
process = start_raw_video_encoder(
raw_video_command(
source,
output,
width=width,
height=height,
fps=fps,
strip_metadata=True,
crf=18,
)
)
frame_pipe = process.stdin
if frame_pipe is None:
abort_raw_video_encoder(process)
raise RuntimeError("Could not open ffmpeg input pipe")
try:
for frame in frames:
if frame.shape[:2] != (height, width):
raise ValueError("Video frames must have matching dimensions")
frame_pipe.write(frame.tobytes())
finish_raw_video_encoder(process, output, operation="SynthID candidate encode")
except Exception:
if process.poll() is None:
abort_raw_video_encoder(process)
raise
def regenerate_video_candidate(
source: Path,
output: Path,
*,
noise_std: float = DEFAULT_VIDEO_SYNTHID_NOISE_STD,
long_side: int = DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
fps: float = DEFAULT_VIDEO_SYNTHID_FPS,
batch_size: int = 4,
seed: int = 0,
model: str = DEFAULT_VIDEO_SYNTHID_VAE,
device: str = "auto",
duration: float | None = None,
) -> RegenerationMetrics:
"""Regenerate video pixels and return fidelity metrics.
This function does not verify SynthID. Its output is an oracle candidate,
not a locally proven clean file.
"""
if not 0.0 <= noise_std <= 1.0:
raise ValueError("noise_std must be between 0 and 1")
if fps < 1.0:
raise ValueError("fps must be at least 1")
if batch_size < 1:
raise ValueError("batch_size must be at least 1")
if duration is not None and duration <= 0:
raise ValueError("duration must be positive")
if device not in {"auto", "cuda", "mps", "cpu"}:
raise ValueError("device must be auto, cuda, mps, or cpu")
if not is_available():
raise RuntimeError("Video SynthID regeneration requires the gpu extra")
import torch
from diffusers import AutoencoderKL
width, height, source_fps = _probe_video(source)
size = _fit_size(width, height, long_side)
effective_fps = min(fps, source_fps)
resolved_device = _pick_device(device)
dtype = torch.float16 if resolved_device == "cuda" else torch.float32
log.info("Loading %s on %s", model, resolved_device)
vae = AutoencoderKL.from_pretrained(model, torch_dtype=dtype).to(resolved_device)
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:
process = start_raw_video_encoder(
raw_video_command(
source,
temporary_output,
width=size[0],
height=size[1],
fps=effective_fps,
strip_metadata=True,
crf=18,
)
)
frame_pipe = process.stdin
if frame_pipe is None:
abort_raw_video_encoder(process)
raise RuntimeError("Could not open ffmpeg input pipe")
frame_count = 0
squared_error = 0.0
pixel_count = 0
temporal_baseline = 0.0
temporal_candidate = 0.0
previous_reference: np.ndarray | None = None
previous_candidate: np.ndarray | None = None
shared_noise: Any | None = None
try:
sampled_frames = _iter_sampled_frames(
source,
source_fps=source_fps,
duration=duration,
effective_fps=effective_fps,
size=size,
)
for frames in _stream_batches(sampled_frames, batch_size):
latent_batches = _encode_frame_latents(
frames,
vae=vae,
device=resolved_device,
batch_size=batch_size,
)
latents = latent_batches[0]
if shared_noise is None:
shared_noise = _shared_latent_noise(
latents.shape[1:],
seed=seed,
device=resolved_device,
dtype=latents.dtype,
)
regenerated = _decode_frame_latents(
latent_batches,
vae=vae,
noise_std=noise_std,
shared_noise=shared_noise,
)
for reference, candidate in zip(frames, regenerated, strict=True):
frame_pipe.write(candidate.tobytes())
difference = reference.astype(np.float32) - candidate.astype(np.float32)
squared_error += float(np.sum(difference * difference, dtype=np.float64))
pixel_count += reference.size
if previous_reference is not None and previous_candidate is not None:
current_gray = cv2.cvtColor(reference, cv2.COLOR_BGR2GRAY)
previous_gray = cv2.cvtColor(previous_reference, cv2.COLOR_BGR2GRAY)
frame_maps = _backward_map(current_gray, previous_gray)
warped_reference = _backward_warp(previous_reference, frame_maps)
warped_candidate = _backward_warp(previous_candidate, frame_maps)
temporal_baseline += float(
np.mean(np.abs(reference.astype(np.float32) - warped_reference.astype(np.float32)))
)
temporal_candidate += float(
np.mean(np.abs(candidate.astype(np.float32) - warped_candidate.astype(np.float32)))
)
previous_reference = reference
previous_candidate = candidate
frame_count += 1
if frame_count < 2:
raise ValueError("The selected clip produced fewer than two frames")
finish_raw_video_encoder(
process,
temporary_output,
operation="SynthID candidate encode",
)
except Exception:
if process.poll() is None:
abort_raw_video_encoder(process)
raise
mse = squared_error / pixel_count
metrics = RegenerationMetrics(
frames=frame_count,
fps=effective_fps,
width=size[0],
height=size[1],
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
+10
View File
@@ -0,0 +1,10 @@
"""Shared configuration for experimental video SynthID regeneration."""
DEFAULT_VIDEO_SYNTHID_VAE = "stabilityai/sd-vae-ft-mse"
DEFAULT_VIDEO_SYNTHID_NOISE_STD = 0.10
DEFAULT_VIDEO_SYNTHID_LONG_SIDE = 512
DEFAULT_VIDEO_SYNTHID_FPS = 12.0
VIDEO_SYNTHID_LATENT_MULTIPLE = 8
VIDEO_SYNTHID_VERIFICATION_PROMPT = (
"Was this uploaded video created or edited by Google AI? Use the built-in content verification result."
)
+30 -64
View File
@@ -22,8 +22,6 @@ because visible-mark removal changes pixels.
from __future__ import annotations
import logging
import shutil
import subprocess
from dataclasses import dataclass
from functools import lru_cache
from itertools import pairwise
@@ -33,6 +31,13 @@ import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from remove_ai_watermarks.video_encoding import (
abort_raw_video_encoder,
finish_raw_video_encoder,
raw_video_command,
start_raw_video_encoder,
)
if TYPE_CHECKING:
from pathlib import Path
@@ -729,12 +734,6 @@ def scan_dola_video(source: Path) -> VideoScan:
return _scan_video(source, detect_dola_frame)
def _ffmpeg_video_args(suffix: str) -> list[str]:
if suffix == ".webm":
return ["-c:v", "libvpx-vp9", "-crf", "18", "-b:v", "0"]
return ["-c:v", "libx264", "-preset", "medium", "-crf", "14"]
def _mask_for_region(
frame_bgr: NDArray[Any],
region: Region,
@@ -792,61 +791,29 @@ def encode_clean_video(
"""Decode again, fill accepted regions, and encode video while copying audio."""
from remove_ai_watermarks.watermark_registry import fill, resolve_backend
ffmpeg = shutil.which("ffmpeg")
if ffmpeg is None:
raise RuntimeError("Visible video removal requires ffmpeg on PATH")
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)
command = [
ffmpeg,
"-y",
"-loglevel",
"error",
"-f",
"rawvideo",
"-pix_fmt",
"bgr24",
"-s:v",
f"{scan.width}x{scan.height}",
"-r",
f"{scan.fps:.12g}",
"-i",
"pipe:0",
"-i",
str(source),
"-map",
"0:v:0",
"-map",
"1:a?",
*_ffmpeg_video_args(output.suffix.lower()),
"-c:a",
"copy",
"-map_metadata",
"-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"])
command.append(str(output))
log.info("Encoding visible-watermark removal with ffmpeg: command=%s", command)
process = subprocess.Popen( # noqa: S603
command,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
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,
)
)
if process.stdin is None or process.stderr is None:
process.kill()
raise RuntimeError("Could not open ffmpeg pipes")
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():
process.kill()
abort_raw_video_encoder(process)
raise RuntimeError(f"OpenCV could not reopen video for removal: {source}")
removed_frames = 0
@@ -868,20 +835,19 @@ def encode_clean_video(
backend=resolved_backend,
)
removed_frames += 1
process.stdin.write(frame.tobytes())
process.stdin.close()
stderr = process.stderr.read().decode("utf-8", errors="replace")
return_code = process.wait()
frame_pipe.write(frame.tobytes())
finish_raw_video_encoder(
process,
output,
operation="visible-watermark encode",
)
except Exception:
process.kill()
process.wait()
if process.poll() is None:
abort_raw_video_encoder(process)
raise
finally:
capture.release()
log.info("ffmpeg visible-watermark encode finished: status=%s stderr=%s", return_code, stderr)
if return_code != 0:
raise RuntimeError(f"ffmpeg failed to encode {output}: {stderr.strip()[:500]}")
return removed_frames
+123
View File
@@ -81,11 +81,33 @@ def _video_with_tc260_ebml(path: Path, *, value: bytes = _TC260_AIGC) -> Path:
return path
def _regeneration_metrics(
*,
frames: int = 24,
fps: float = 12.0,
width: int = 512,
height: int = 288,
psnr_db: float = 22.0,
temporal_residual_ratio: float = 1.2,
):
from remove_ai_watermarks.video_invisible import RegenerationMetrics
return RegenerationMetrics(
frames=frames,
fps=fps,
width=width,
height=height,
psnr_db=psnr_db,
temporal_residual_ratio=temporal_residual_ratio,
)
class TestVideoMetadataApi:
def test_top_level_api_is_lazy_exported(self):
import remove_ai_watermarks as raiw
assert raiw.inspect_video_metadata is not None
assert raiw.remove_video_invisible is not None
assert raiw.remove_video_metadata is not None
assert raiw.remove_video_visible is not None
@@ -264,6 +286,107 @@ class TestVideoMetadataCli:
assert "Unsupported video format" in result.output
class TestVideoInvisibleApi:
def test_generates_unverified_candidate_and_strips_metadata(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
from remove_ai_watermarks import video_invisible
from remove_ai_watermarks.video import remove_video_invisible
source = _video_with_c2pa(tmp_path / "source.mp4")
output = tmp_path / "candidate.mp4"
def fake_regenerate(_source: Path, target: Path, **_kwargs: object):
target.write_bytes(_MP4_FTYP + _box(b"mdat", _VIDEO_PAYLOAD))
return _regeneration_metrics()
monkeypatch.setattr(video_invisible, "regenerate_video_candidate", fake_regenerate)
result = remove_video_invisible(source, output)
assert result.output == output
assert result.requires_external_verification is True
assert result.total_frames == 24
assert result.remaining_metadata == {}
def test_default_output_is_named_as_candidate(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
from remove_ai_watermarks import video_invisible
from remove_ai_watermarks.video import remove_video_invisible
source = _video_with_c2pa(tmp_path / "source.mp4")
def fake_regenerate(_source: Path, target: Path, **_kwargs: object):
target.write_bytes(_MP4_FTYP + _box(b"mdat", _VIDEO_PAYLOAD))
return _regeneration_metrics(
frames=2,
fps=2.0,
width=16,
height=16,
psnr_db=20.0,
temporal_residual_ratio=1.0,
)
monkeypatch.setattr(video_invisible, "regenerate_video_candidate", fake_regenerate)
result = remove_video_invisible(source)
assert result.output == tmp_path / "source_synthid_candidate.mp4"
def test_rejects_webm_regeneration(self, tmp_path: Path):
from remove_ai_watermarks.video import remove_video_invisible
source = _video_with_tc260_ebml(tmp_path / "source.webm")
with pytest.raises(ValueError, match="requires one of"):
remove_video_invisible(source)
class TestVideoInvisibleCli:
def test_help_describes_external_verification(self):
runner = CliRunner()
result = runner.invoke(main, ["video", "invisible", "--help"])
assert result.exit_code == 0, result.output
assert "externally verifiable" in result.output
def test_reports_unverified_candidate(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
from remove_ai_watermarks import video
runner = CliRunner()
source = _video_with_c2pa(tmp_path / "source.mp4")
output = tmp_path / "candidate.mp4"
def fake_remove(_source: Path, target: Path, **_kwargs: object):
target.write_bytes(_MP4_FTYP + _box(b"mdat", _VIDEO_PAYLOAD))
return video.VideoInvisibleResult(
source=_source,
output=target,
noise_std=0.1,
metrics=_regeneration_metrics(),
remaining_metadata={},
)
monkeypatch.setattr(video, "remove_video_invisible", fake_remove)
result = runner.invoke(main, ["video", "invisible", str(source), "-o", str(output)])
assert result.exit_code == 0, result.output
assert "Candidate generated" in result.output
assert "UNVERIFIED" in result.output
assert "Gemini Flash" in result.output
class TestSoraFrameLocalization:
@staticmethod
def _sora_like_frame() -> tuple[np.ndarray, tuple[int, int, int, int]]:
+71
View File
@@ -0,0 +1,71 @@
"""Regression tests for the video SynthID candidate engine."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from remove_ai_watermarks import video_encoding, video_invisible
if TYPE_CHECKING:
from pathlib import Path
def test_availability_requires_both_optional_packages(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
video_invisible,
"find_spec",
lambda name: object() if name == "torch" else None,
)
assert video_invisible.is_available() is False
def test_regeneration_rejects_noise_outside_unit_interval(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="between 0 and 1"):
video_invisible.regenerate_video_candidate(
tmp_path / "source.mp4",
tmp_path / "candidate.mp4",
noise_std=1.01,
)
def test_encoder_command_discards_metadata_and_copies_audio(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
source = tmp_path / "source.mp4"
output = tmp_path / "candidate.mp4"
monkeypatch.setattr(video_encoding.shutil, "which", lambda _name: "/usr/bin/ffmpeg")
command = video_encoding.raw_video_command(
source,
output,
width=8,
height=8,
fps=2.0,
strip_metadata=True,
crf=18,
)
metadata_index = command.index("-map_metadata")
assert command[metadata_index + 1] == "-1"
audio_codec_index = command.index("-c:a")
assert command[audio_codec_index + 1] == "copy"
assert "pipe:0" in command
def test_stream_batches_consumes_only_one_batch_ahead() -> None:
consumed: list[int] = []
def values():
for value in range(5):
consumed.append(value)
yield value
batches = video_invisible._stream_batches(values(), 2)
assert next(iter(batches)) == [0, 1]
assert consumed == [0, 1]
+3 -3
View File
@@ -55,7 +55,7 @@ def test_shared_latent_noise_is_seeded(sweep: ModuleType) -> None:
def test_psnr_is_infinite_for_identical_frames(sweep: ModuleType) -> None:
frame = np.full((2, 8, 8, 3), 120, dtype=np.uint8)
assert sweep._psnr(frame, frame.copy()) == pytest.approx(float("inf"))
assert sweep.paired_psnr(frame, frame.copy()) == pytest.approx(float("inf"))
def test_temporal_residual_ratio_is_one_for_identical_sequences(sweep: ModuleType) -> None:
@@ -63,5 +63,5 @@ def test_temporal_residual_ratio_is_one_for_identical_sequences(sweep: ModuleTyp
second = first.copy()
second[:, 8:16] = 80
sequence = [first, second]
maps, baseline = sweep._temporal_reference(sequence)
assert sweep._temporal_residual_ratio([frame.copy() for frame in sequence], maps, baseline) == pytest.approx(1.0)
maps, baseline = sweep.build_temporal_reference(sequence)
assert sweep.temporal_residual_ratio([frame.copy() for frame in sequence], maps, baseline) == pytest.approx(1.0)