mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 23:50:40 +02:00
Add streaming video SynthID regeneration
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user