feat: complete product video watermark pipeline

This commit is contained in:
Victor Kuznetsov
2026-07-31 10:42:39 -07:00
parent 8975916b11
commit 6315b58628
33 changed files with 3793 additions and 515 deletions
+17
View File
@@ -44,3 +44,20 @@ jobs:
run: uv sync --frozen --extra dev
- name: Run tests
run: uv run pytest -q
video-e2e:
name: video full-clip end-to-end
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with:
python-version: "3.12"
- name: Install ffmpeg
run: sudo apt-get update && sudo apt-get install --yes ffmpeg
- name: Sync dev environment
run: uv sync --frozen --extra dev
- name: Run full-clip video test
run: >-
uv run pytest -q
tests/test_video.py::TestVideoVisibleFullClip
+9 -5
View File
@@ -26,9 +26,12 @@ Per-command exit-code semantics (the no-signal / GPU-missing skip branches), tes
- `uv run remove-ai-watermarks identify <image>` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip both registered visible detectors and the optional open invisible-watermark decoder
- `uv run remove-ai-watermarks metadata <image.png> --check` — inspect AI metadata (C2PA, EXIF, PNG chunks)
- `uv run remove-ai-watermarks metadata <image.png> --remove -o <out.png>` — strip all AI metadata
- `uv run remove-ai-watermarks video identify <input.mp4>` — locally verifiable video provenance report: supported metadata plus the same temporally stable visible-mark arbiter used by removal; `--json` for machine output and `--no-visible` for metadata only. A negative result is unknown, never clean.
- `uv run remove-ai-watermarks video all <input.mp4> -o <clean.mp4>` — product-oriented video pipeline: remove a stable registered visible mark when present, always strip verified AI metadata, and write a same-container passthrough when neither signal exists. Lossy SynthID removal is excluded unless `--invisible` is explicit; that option uses the oracle-certified video profile.
- `uv run remove-ai-watermarks video metadata <input.mp4> --check` — inspect AI metadata in MP4/MOV/M4V/WebM/MKV/AVI/FLV
- `uv run remove-ai-watermarks video metadata <input.mp4> --remove -o <clean.mp4>` — strip verified video metadata without transcoding streams; the experimental video path requires a separate same-container output and defaults to `<source>_clean`
- `uv run remove-ai-watermarks video visible <input.mp4> -o <clean.mp4>` — remove a temporally recurring Sora, Veo, Seedance, Dola, Hailuo, or Kling mark. `--mark auto` is the default: all providers share one decode pass, then the first stable result wins in specificity order; an explicit mark restricts the scan. It transcodes video through ffmpeg, copies the complete audio stream, strips AI metadata by default, publishes atomically, and writes no output when no stable mark is found. `cv2` is the fast default; `migan`/`lama` improve difficult backgrounds.
- `uv run remove-ai-watermarks video metadata <input.mp4> --remove -o <clean.mp4>` — strip verified video metadata without transcoding streams; the video path requires a separate same-container output and defaults to `<source>_clean`
- `uv run remove-ai-watermarks video visible <input.mp4> -o <clean.mp4>` — remove a temporally recurring Sora, Veo, Seedance, Dola, Hailuo, or Kling mark. `--mark auto` is the default: all providers share one decode pass, then the first stable result wins in specificity order; an explicit mark restricts the scan. It transcodes video through ffmpeg, copies the complete audio stream, strips AI metadata by default, publishes atomically, and writes no output when no stable mark is found. `cv2` is the fast default; `migan` is the practical learned CPU tier; `lama` is an offline quality tier. `--temporal-consistency` (default) motion-aligns adjacent accepted fills with mask-coverage and scene-context gates; `--no-temporal-consistency` is the frame-local baseline. HDR/high-bit inputs are rejected before encoding rather than silently reduced to 8-bit SDR.
- `uv run remove-ai-watermarks video batch <directory> --mode all` — sequential video directory processing with per-file results and non-zero exit when any file fails. Modes are `all|visible|metadata`; visible no-ops are byte-copied so successful output sets stay complete. `--invisible` is explicit and valid only in `all` mode.
- `uv run remove-ai-watermarks batch <directory>` — process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the full `invisible` knob set above, plus `--backend` and `--sensitivity` for the visible localize -> fill pass. Applies the same no-signal skip per image; see the module doc. **Exit code:** non-zero when any image errored OR (mirroring single `all`) a `--mode invisible`/`all` image carried an invisible signal but the GPU extra was absent, so its SynthID scrub was skipped — it emits a loud warning and copies the input through (invisible mode) so the output dir stays complete; a wrapping service can then detect the incomplete run instead of trusting a silent exit 0.
## Test and lint
@@ -82,9 +85,10 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal
- `upscaler.py` — optional Real-ESRGAN pre-diffusion super-resolution for small inputs (extra `esrgan`, spandrel only). Manual opt-in; the default `--upscaler` stays `lanczos` and the engine always falls back to Lanczos on absence/error. ESRGAN can degrade faces and thin text.
- `image_io.py` — centralizes Unicode-safe image IO, alpha preservation, content-based format sniffing, and HEIC/AVIF fallbacks. Callers must check `imwrite` success. No-op visible removal preserves original bytes when the output format is unchanged.
- `api.py` — the high-level convenience API, re-exported lazily at the package top level via `__init__.__getattr__` (PEP 562, so `import remove_ai_watermarks` stays cheap): `remove_visible(source, output=None, *, sensitivity="auto", backend="auto", strip_metadata=True, write_noop=True) -> (result_bgr, [labels])` (source = path OR BGR ndarray; a PATH auto-reads metadata provenance and preserves alpha, an ARRAY does neither; `write_noop=True` writes a clean passthrough copy when nothing is removed, `False` leaves `output` untouched so a "no mark = produce nothing" caller like the CLI `visible` command does not clobber a pre-existing file there) and `visible_provenance(path) -> frozenset[str]` (the single metadata→vendor-keys mapper; `cli._visible_provenance` is a thin None-guarded wrapper over it). **`remove_visible` is the ONE path the CLI and library share** — `cli.cmd_visible`'s `--mark auto` branch delegates entirely to it (read → provenance → `remove_auto_marks` → write → `strip_metadata`), so there is no CLI-vs-library drift; `strip_metadata` defaults True to match `visible --strip-metadata`. This is where a library caller should start — NOT the engines directly (`GeminiEngine`/`TextMarkEngine` have no `remove_watermark` any more; removal is registry `remove_auto_marks`/`KnownMark.remove`; the old single-strongest `best_auto_mark` is gone — removal takes EVERY mark). `identify` is NOT top-level re-exported (it collides with the `identify` submodule); use `from remove_ai_watermarks.identify import identify`.
- `video.py` — the experimental high-level video API, also lazy at the package root: `inspect_video_metadata(source) -> VideoMetadataReport`, `remove_video_metadata(source, output=None, *, keep_standard=True) -> VideoMetadataResult`, `remove_video_visible(source, output=None, *, mark="auto", backend="cv2", strip_metadata=True) -> VideoVisibleResult`, and `remove_video_invisible(source, output=None, ...) -> VideoInvisibleResult`. It validates the extension and container signature for MP4/MOV/M4V/WebM/MKV/AVI/FLV and requires a distinct same-container output. The metadata path never transcodes streams: native MP4/MOV TC260 is read from `moov.udta.meta.keys/ilst`, including a tail `moov` after a large `mdat`, and removal blanks the key/value in place; MKV/WebM, AVI, and FLV TC260 are read by bounded container walkers that skip media payloads and stripped through ffmpeg stream copy. The visible path delegates to `video_visible.py`: auto scans all six synthetic provider silhouettes in one decode pass and selects the first stable result in specificity order because provider confidence scales are not comparable. Provider-specific temporal recurrence authorizes frame boxes, the shared fill backends remove accepted masks in a second pass, and ffmpeg transcodes video while copying complete audio. Fixed marks require anchored runs so a slowly drifting scene detail cannot pass on adjacent overlap alone. Veo covers the current four-point diamond and legacy text; its diamond uses a shape mask rather than erasing the transparent corners of a full box. Seedance uses the full localized box because an outline mask left part of the real translucent border behind. Hailuo expands its matched core to the full composite, while Kling combines font and swirl candidates and requires a bright low-saturation edge label. Metadata can relax a recurring low-contrast match but cannot create one. The inherited ISOBMFF metadata removal path still reads the complete container into memory, so a streaming box copier is required before large-video use. Other visible video labels are not built yet.
- `video_encoding.py` — the shared raw-BGR ffmpeg command, atomic sibling-temporary publication, and pipe lifecycle for both visible removal and invisible regeneration. It copies complete optional audio without shortening a tail, controls metadata/chapter retention explicitly, and keeps container-specific codec arguments in one place.
- `video_invisible.py` — the oracle-gated video SynthID candidate engine for MP4/MOV/M4V. It regenerates frames through `stabilityai/sd-vae-ft-mse` with one seeded latent-noise field shared across time, retains only one configured batch, updates PSNR and motion-compensated temporal residuals incrementally, streams pixels directly to ffmpeg, copies complete audio, strips metadata, and atomically publishes the completed output. Every result remains explicitly unverified because Google exposes no local decoder. The 2026-07-29 Gemini Flash calibration found the default `0.10` candidate negative on both control-positive public Veo clips, while `0.05` remained positive on one, so the operating point is content-dependent and requires the matching external oracle.
- `video.py` — the high-level video API, also lazy at the package root: `identify_video`, `inspect_video_metadata`, `remove_video_all`, `remove_video_batch`, `remove_video_metadata`, `remove_video_visible`, and `remove_video_invisible`. `identify_video` and visible removal share one stable-mark selector; identification omits the unused per-frame timestamp probe. `remove_video_all` defaults to the locally verifiable visible + metadata stages and always writes a same-container output; lossy invisible removal is an explicit opt-in through the oracle-certified profile. Batch processing is sequential, returns every per-file error, byte-copies visible no-ops so successful output sets stay complete, and reuses one loaded VAE across an opt-in invisible batch. The API validates the extension and container signature for MP4/MOV/M4V/WebM/MKV/AVI/FLV and requires a distinct same-container output. The metadata path never transcodes streams: native MP4/MOV TC260 is read from `moov.udta.meta.keys/ilst`, including a tail `moov` after a large `mdat`; MP4/MOV/M4V removal stream-copies in bounded chunks, preserves every box size/offset, blanks supported provenance in place, and publishes atomically. MKV/WebM, AVI, and FLV TC260 are read by bounded container walkers that skip media payloads and stripped through ffmpeg stream copy. The visible path delegates to `video_visible.py`: auto scans all six synthetic provider silhouettes in one decode pass and selects the first stable result in specificity order because provider confidence scales are not comparable. Provider-specific temporal recurrence authorizes frame boxes, the shared fill backends remove accepted masks in a second pass, guarded optical-flow blending reduces frame-to-frame fill variance, and ffmpeg transcodes video while copying complete audio. Fixed marks require anchored runs so a slowly drifting scene detail cannot pass on adjacent overlap alone. Veo covers the current four-point diamond and legacy text; its diamond uses a shape mask rather than erasing the transparent corners of a full box. Seedance uses the full localized box because an outline mask left part of the real translucent border behind. Hailuo expands its matched core to the full composite, while Kling combines font and swirl candidates and requires a bright low-saturation edge label. Metadata can relax a recurring low-contrast match but cannot create one. Other visible video labels are not built yet.
- `video_encoding.py` — the shared ffmpeg command, atomic sibling-temporary publication, and pipe lifecycle for visible removal and invisible regeneration. It copies complete optional audio without shortening a tail, controls metadata/chapter retention explicitly, and keeps container-specific codec arguments in one place. `probe_video_encode_profile` preserves supported 8-bit chroma sampling, recognized color tags, encoder time base, MP4/MOV track timescale, source pixel format/depth, and the source video start PTS; HDR/high-bit inputs are rejected before encoding. `probe_video_timestamps` reads authoritative display PTS with ffprobe and falls back to count-matched OpenCV timestamps only on probe failure. Plain CFR stays on the cheap raw-BGR pipe, while VFR or non-zero-start frames are lazily packetized through PyAV as timestamped rawvideo/NUT and passed to system ffmpeg with `-fps_mode passthrough`. `-copyts` retains a non-zero video start and the corresponding copied-audio offset.
- `video_temporal.py` — shared optical-flow maps, temporal residual metrics, and guarded visible-fill stabilization. `stabilize_filled_frame` works on a bounded mask crop, requires warped prior-mask coverage and a matching unmasked context ring, and changes only safely covered pixels; scene cuts and disjoint marks keep the independent current fill.
- `video_invisible.py` — the oracle-certified video SynthID removal engine for MP4/MOV/M4V. It regenerates frames through `stabilityai/sd-vae-ft-mse` with one seeded latent-noise field shared across time, retains only one configured batch, updates PSNR and motion-compensated temporal residuals incrementally, streams pixels directly to ffmpeg, copies complete audio, strips metadata, and atomically publishes the completed output. The 2026-07-29 two-clip Gemini calibration remains valid: both matched controls were positive in the built-in SynthID verifier, the stronger candidate was negative on both carriers, and a weaker candidate was negative on one. The 2026-07-30 `UNAVAILABLE` follow-ups asked ordinary Gemini to reinterpret an already returned verifier result and were not detector reruns. The 2026-07-31 full-clip check used a public eight-second Veo carrier: the source and `noise_std=0.10` output were detected, while `0.15` was not, so `0.15` is the certified default. Google exposes no local decoder, so per-file verification remains an optional audit rather than a product status. Canonical hashes and verdicts: `data/evaluations/video-synthid-oracle.csv`.
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`.
+69 -28
View File
@@ -6,9 +6,10 @@ 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, visible Sora, Veo,
Seedance, Dola, Hailuo, and Kling mark removal, and experimental VAE
regeneration that produces a video SynthID candidate for external verification.
Video support covers provenance identification, complete visible-plus-metadata
cleaning, directory batches, visible Sora, Veo, Seedance, Dola, Hailuo, and
Kling mark removal, and oracle-certified VAE regeneration for video SynthID
removal.
> Try it online at [raiw.cc](https://raiw.cc) if you do not want to install Python
> or run diffusion models locally.
@@ -32,9 +33,12 @@ regeneration that produces a video SynthID candidate for external verification.
| Remove known visible AI marks | `visible` | No |
| Erase a region you select | `erase` | No |
| Strip AI metadata | `metadata` | No |
| Identify supported video provenance | `video identify` | No |
| Remove visible marks and AI metadata from video | `video all` | No |
| Strip AI metadata from video | `video metadata` | No |
| Remove a registered visible AI mark from video | `video visible` | No |
| Generate an externally verifiable video SynthID candidate | `video invisible` | Recommended |
| Process a directory of videos | `video batch` | Depends on mode |
| Remove video SynthID with the certified VAE profile | `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 |
@@ -82,6 +86,25 @@ MKV and WebM inspection reads the normative
uses `script.onMetaData.AIGC`. The non-ISOBMFF formats are remuxed with stream
copy for removal.
Use the product-oriented video path to identify or clean a file:
```bash
remove-ai-watermarks video identify input.mp4
remove-ai-watermarks video all input.mp4 -o clean.mp4
```
`video all` removes a stable registered visible mark when present and always
strips verified AI metadata. If neither signal is found, it still writes a
same-container passthrough, so application callers get one predictable output
contract. Proprietary invisible-video removal is excluded by default.
`--invisible` opts into the lossy, oracle-certified video SynthID profile.
Process a directory with the same contract:
```bash
remove-ai-watermarks video batch ./videos --mode all
```
Remove a supported visible video mark:
```bash
@@ -97,30 +120,41 @@ This path scans the complete sequence before changing pixels. It accepts only a
mark that repeats at a stable position across adjacent frames, then reuses the
same OpenCV, MI-GAN, or LaMa fill backends as image removal. Audio is copied
without re-encoding and is allowed to reach its natural end; the video stream
is transcoded because its pixels change. The default `--mark auto` scans all
providers in one decode pass and selects the first stable match in the
specificity order shown below. Pass an explicit mark to restrict detection to
one provider.
is transcoded because its pixels change. By default, a guarded optical-flow
pass motion-aligns the preceding accepted fill and blends it only when the
nearby source context agrees; use `--no-temporal-consistency` to disable it.
The encoder preserves supported
8-bit source chroma sampling, color tags, and MP4/MOV track timescale instead
of relying on ffmpeg's implicit raw-BGR defaults. Variable frame intervals are
preserved through a timestamped in-memory NUT bridge instead of being flattened
to the average frame rate. Non-zero source start timestamps are retained
together with the copied audio offset. The default `--mark auto`
scans all providers in one decode pass and selects the first stable match in
the specificity order shown below. Pass an explicit mark to restrict detection
to one provider.
Sora covers the moving Sora 2 mascot and wordmark. Veo covers both the current
four-point diamond and the legacy `Veo` text. Seedance covers the fixed boxed
`AI` label, Dola covers the fixed `Dola AI` text, Hailuo covers the composite
`MINIMAX | hailuo AI` label, and Kling covers the bottom-right `KLING AI`
label with its version suffix. A completed encode is published atomically. No
output is written when no stable mark is found.
HDR, PQ/HLG, and greater-than-8-bit inputs are rejected before encoding rather
than silently reduced through OpenCV's 8-bit BGR boundary.
Generate a video SynthID candidate:
Remove video SynthID:
```bash
uv tool install --force "remove-ai-watermarks[gpu]"
remove-ai-watermarks video invisible input.mp4 -o candidate.mp4
remove-ai-watermarks video invisible input.mp4 -o clean.mp4
```
This path regenerates the complete sequence with one latent-noise field shared
across time, copies complete audio, strips source metadata, and publishes the
completed encode atomically. It cannot verify
Google's proprietary pixel watermark locally. The command therefore labels
every output `UNVERIFIED` and prints the exact Gemini Flash verification
prompt.
completed encode atomically. The default `noise_std=0.15` profile passed both
the two-carrier calibration and a complete public eight-second Veo oracle
check. Google does not publish a local decoder, so a fresh provider check
remains useful for unusually important files or after provider changes, but it
is not a product result state.
For invisible watermark removal, install the diffusion dependencies:
@@ -268,9 +302,12 @@ import remove_ai_watermarks as raiw
result, removed = raiw.remove_visible("watermarked.png", "clean.png")
print(removed)
provenance = raiw.identify_video("input.mp4")
report = raiw.inspect_video_metadata("input.mp4")
complete = raiw.remove_video_all("input.mp4", "clean.mp4")
batch = raiw.remove_video_batch("videos", "videos_clean")
cleaned = raiw.remove_video_metadata("input.mp4")
candidate = raiw.remove_video_invisible("input.mp4", "candidate.mp4")
synthid_cleaned = raiw.remove_video_invisible("input.mp4", "synthid_clean.mp4")
visible = raiw.remove_video_visible("input.mp4", "clean.mp4")
print(visible.mark)
veo = raiw.remove_video_visible("veo.mp4", "veo_clean.mp4", mark="veo")
@@ -311,30 +348,34 @@ invisible removal.
The classical OpenCV backend can smear structured backgrounds; use MI-GAN or
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.
The shipped profile is oracle-certified, but no public local decoder can
certify an arbitrary output at runtime. Recheck unusually important outputs
after provider changes.
- `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.
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:
The shipped `video invisible` command uses the certified `noise_std=0.15`
profile. The companion `scripts/video_synthid_sweep.py` research 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. 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.
count as removal evidence. In the 2026-07-29 two-clip calibration, both matched
controls were positive in Gemini's built-in SynthID verifier; the stronger
candidate was negative on both carriers, while a weaker candidate was
negative on one. A later adversarial follow-up that asked ordinary Gemini to
reinterpret the pixel result returned `UNAVAILABLE`; that follow-up was not a
verifier rerun and does not invalidate the built-in verdicts. A 2026-07-31
full-clip check on a public eight-second Veo sample found `0.10` still detected
and `0.15` not detected, so `0.15` is now the certified default. The
reproducible hashes and verdicts live in
`data/evaluations/video-synthid-oracle.csv`.
## Documentation
+2
View File
@@ -15,6 +15,8 @@ data/
Reusable full-pipeline evaluation selection
evaluations/
fidelity/ Evaluation instructions and hand-verified ground truth
video-synthid-oracle.csv
Reproducible full-clip Gemini SynthID verdicts
```
## Storage rules
@@ -0,0 +1,3 @@
date,source_url,source_sha256,duration_seconds,source_verdict,noise_std,long_side,fps,seed,output_sha256,output_verdict,psnr_db,temporal_residual_ratio
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,8,detected_all_frames,0.10,512,12,0,079165105d4c56e1612091987c08c2627049423025f74c0d4e245fb47c2ff0e3,detected,26.2932,1.0072
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,8,detected_all_frames,0.15,512,12,0,1c4046bcfdead138353b4e2a73339ba227bb5e544878d80c5bc6cd8427c7b00e,not_detected,25.3911,1.0578
1 date source_url source_sha256 duration_seconds source_verdict noise_std long_side fps seed output_sha256 output_verdict psnr_db temporal_residual_ratio
2 2026-07-31 https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4 79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d 8 detected_all_frames 0.10 512 12 0 079165105d4c56e1612091987c08c2627049423025f74c0d4e245fb47c2ff0e3 detected 26.2932 1.0072
3 2026-07-31 https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4 79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d 8 detected_all_frames 0.15 512 12 0 1c4046bcfdead138353b4e2a73339ba227bb5e544878d80c5bc6cd8427c7b00e not_detected 25.3911 1.0578
+94 -19
View File
@@ -125,9 +125,57 @@ The command also supports the audio and video containers listed in
[supported signals](supported-signals.md). ffmpeg must be available for the
non-ISOBMFF audio and video path.
## Identify and clean video
Inspect every locally supported video signal:
```bash
remove-ai-watermarks video identify input.mp4
remove-ai-watermarks video identify input.mp4 --json
remove-ai-watermarks video identify input.mp4 --no-visible
```
The default scans the complete clip for stable registered visible marks and
inspects supported metadata. A result with no signals is reported as unknown,
not clean, because proprietary pixel watermarks have no public local decoder.
`--no-visible` performs metadata-only inspection.
Use the complete locally verifiable cleaning path:
```bash
remove-ai-watermarks video all input.mp4 -o clean.mp4
```
It removes a stable supported visible mark when found and always strips
verified AI metadata. When neither signal is found, it writes a same-container
passthrough instead of returning a missing output. The source is never
overwritten.
Invisible regeneration is deliberately opt-in:
```bash
remove-ai-watermarks video all input.mp4 -o clean.mp4 --invisible
```
That option is supported only for MP4, MOV, and M4V. It is lossy and uses the
same oracle-certified profile as `video invisible`.
Process all supported files in a top-level directory:
```bash
remove-ai-watermarks video batch ./videos --mode all
remove-ai-watermarks video batch ./videos --mode visible
remove-ai-watermarks video batch ./videos --mode metadata
```
The batch runs sequentially, preserves successful outputs when another file
fails, and exits nonzero if any item failed. Visible no-op files are copied
byte-for-byte so the output directory remains complete. `--invisible` is
available only with `--mode all`.
## Strip AI metadata from video
The experimental video namespace starts with metadata inspection and removal:
Metadata inspection and removal are also available as an isolated operation:
```bash
remove-ai-watermarks video metadata input.mp4 --check
@@ -139,8 +187,11 @@ delegates to the same verified metadata scanner and stripper as the generic
`metadata` command, so detection and removal stay in parity. Video and audio
streams are not transcoded. For MP4 and MOV, this includes the native TC260
`AIGC` key and JSON value stored in `moov.udta.meta.keys/ilst`. The inspector
seeks past a large `mdat` to find a tail `moov`; removal blanks the key and
value in place so box sizes and media offsets do not move.
seeks past a large `mdat` to find a tail `moov`. Removal stream-copies the
container in bounded chunks, converts supported top-level provenance boxes to
same-size `free` boxes, and blanks the TC260 key/value in place. Box sizes,
media offsets, and encoded stream bytes do not move; the result is atomically
published only after the complete copy succeeds.
For MKV and WebM, the inspector reads the native TC260
`Segment.Tags.Tag.SimpleTag` entry. Removal uses ffmpeg stream copying to
@@ -156,14 +207,14 @@ different container extension.
Visible video labels and invisible video watermarks are not handled by this
command.
## Generate a video SynthID candidate
## Remove video SynthID
```bash
uv tool install --force "remove-ai-watermarks[gpu]"
remove-ai-watermarks video invisible input.mp4 -o candidate.mp4
remove-ai-watermarks video invisible input.mp4 -o clean.mp4
```
The experimental command supports MP4, MOV, and M4V. It samples the complete
The 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
@@ -171,22 +222,28 @@ 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:
The default `noise_std=0.15` profile is oracle-certified. The project has no
local video SynthID decoder, so an optional per-file recheck is still useful
for unusually important files or after provider changes. In a new Gemini chat,
upload the original first, invoke the built-in verifier with `@synthid`, and ask:
> Was this uploaded video created or edited by Google AI? Use the built-in
> content verification result.
> For the video attached to this message, was it created or edited by Google
> AI? Use the built-in SynthID 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 source must be positive. Then upload the processed result in a separate new
chat and repeat the same built-in check. Only a source-positive, output-negative
pair is a fresh per-file verification. Do not ask an adversarial follow-up that tells the
chat model to ignore the verifier and reason about raw pixels: that is ordinary
Gemini reasoning, not a second oracle check.
The default output is `<source>_synthid_candidate` in the same container. The
The default output is `<source>_clean` 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.
default noise level is `0.15`. It cleared both carriers in the 2026-07-29
short-clip calibration and the complete public eight-second Veo carrier in the
2026-07-31 full-clip check; `0.10` remained detected on that complete clip. This
calibration certifies the shipped operating point; the paired check above is an
optional runtime audit, not a separate result state.
## Remove a supported visible video mark
@@ -199,7 +256,7 @@ remove-ai-watermarks video visible hailuo.mp4 --mark hailuo -o hailuo_clean.mp4
remove-ai-watermarks video visible kling.mp4 --mark kling -o kling_clean.mp4
```
The experimental command supports the moving Sora mascot and wordmark, two Veo
The command supports the moving Sora mascot and wordmark, two Veo
corner variants, the Seedance boxed `AI` label, the `Dola AI` text label, the
composite `MINIMAX | hailuo AI` label, and the bottom-right Kling label. Sora
searches the whole frame at multiple scales. The other detectors search bounded
@@ -218,13 +275,31 @@ provider.
The video stream is transcoded and the complete original audio stream is
copied without truncating an audio tail that extends beyond the final video
frame.
frame. The encoder probes the source stream and preserves supported 8-bit
chroma sampling, color range/matrix/transfer/primaries tags, and MP4/MOV track
timescale. For a variable-frame-rate source, decoded PTS are carried through a
timestamped in-memory NUT bridge so the output retains the source frame
intervals instead of flattening them to a constant rate. A non-zero source
start PTS and the copied audio start offset are preserved as well.
Supported input and output containers are MP4, MOV, M4V, WebM, MKV, AVI, and
FLV; the output extension must match the input. The default `cv2` backend is
fast but can smear structured backgrounds. Select `--backend migan` or
`--backend lama` for a learned fill, or `--backend auto` to choose the best
installed backend.
`--temporal-consistency` is enabled by default. It motion-aligns the preceding
accepted fill, requires overlapping removal masks and matching source context,
and blends only the safely covered pixels. Scene cuts, disjoint moving marks,
or a poor motion match keep the independent current-frame fill. Use
`--no-temporal-consistency` for an exact frame-local baseline.
The pixel path is intentionally limited to SDR 8-bit video. A high-bit-depth,
PQ, or HLG source is rejected before ffmpeg starts, preserving any existing
output instead of silently downconverting it through OpenCV's 8-bit boundary.
On CPU, MI-GAN is the practical learned tier. LaMa remains an explicit offline
quality option because full-sequence inference is too slow and memory-heavy
for an online worker.
AI metadata is stripped from the encoded output by default. Use
`--keep-metadata` to retain mapped container metadata. When no temporally stable
mark is found, the command writes no output and exits with the no-visible-mark
+63 -21
View File
@@ -79,24 +79,36 @@ For important outputs:
Provider systems can change, so a result verified on one file, seed, or version
is not a permanent certification.
### Video regeneration produces an unverified candidate
### Video SynthID removal is lossy and content-dependent
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.
pixels through a VAE. The shipped `noise_std=0.15` profile is oracle-certified,
but Google does not publish a local decoder for arbitrary runtime outputs. A
quiet metadata scan, paired PSNR, and the temporal-residual metric are fidelity
measurements, not independent SynthID verdicts.
The control must use the same clip, frame rate, dimensions, and final codec as
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 2026-07-29 two-clip calibration used Gemini's built-in content verifier:
both matched controls were SynthID-positive, the stronger candidate was
negative on both carriers, and a weaker candidate was negative on one. A
2026-07-30 adversarial follow-up incorrectly asked the ordinary chat model to
reinterpret the verifier while excluding every other input; its `UNAVAILABLE`
answer was not another detector run and does not invalidate the original
built-in results. The calibrated default remains content-dependent; a fresh
source-positive, output-negative pair is an optional audit for unusually
important files or after provider changes.
The 2026-07-31 full-clip check used the public eight-second Veo off-road sample.
The source was detected across the full clip, the complete product path at
`noise_std=0.10` remained detected, and `0.15` returned no SynthID detection.
The default was raised to `0.15`. At 512 px / 12 fps, the accepted candidate
measured 25.39 dB paired PSNR and a 1.058 motion-compensated temporal-residual
ratio. This is one carrier, not a universal guarantee; hashes and exact verdicts
are tracked in `data/evaluations/video-synthid-oracle.csv`.
The shipped engine streams sampled frames in bounded batches, computes its
fidelity metrics incrementally, and pipes regenerated pixels directly to
@@ -225,22 +237,48 @@ than the moving mascot-and-wordmark design; that earlier variant is not
detected by the `sora` video mark. Hailuo and Kling coverage is specific to the
verified lower-edge layouts; a new provider layout needs a separate calibrated
silhouette. Other provider video labels are not supported yet. Google video
SynthID has a candidate-producing VAE path, while other proprietary invisible
video watermarks have no registered attack.
SynthID has an oracle-certified VAE removal path, while other proprietary
invisible video watermarks have no registered attack.
Visible removal transcodes the video stream and copies the complete audio
stream without shortening an audio tail. Completed visible and invisible
encodes are published atomically, so an encode failure preserves an existing
output. Its frame-local fill is not a motion-aware video inpainting model.
OpenCV can leave a visible smear where the mark overlaps a hard edge or
structured texture, and the smear can vary over time. MI-GAN and LaMa improve
individual frames but do not guarantee temporal coherence. The Veo diamond
output. Visible removal now applies a guarded motion-compensated blend after
the per-frame fill. It uses adjacent optical flow only when the warped prior
mask covers the current mask and a source-context ring agrees; scene cuts and
disjoint marks keep the independent fill. This reduces measured paired
temporal error, but it is not a generative video-inpainting model and cannot
recover structure that no frame exposes. OpenCV can still leave a visible
smear where the mark overlaps a hard edge or structured texture. MI-GAN
improves difficult individual frames and is the practical learned CPU tier.
LaMa remains an offline quality option: a full real sequence confirmed its
multi-GB memory use and CPU throughput unsuitable for an online worker. The Veo diamond
uses a shape mask to limit damage outside the symbol. Seedance fills the full
localized box because a synthetic outline mask left part of the real
translucent border visible in an end-to-end check. OpenCV may therefore soften
texture inside that small box; use MI-GAN or LaMa when reconstruction quality
matters. The current encoder also emits a constant-frame-rate output at the
decoded stream rate, so variable-frame-rate preservation is not yet guaranteed.
matters. Relative variable-frame timestamps are preserved through a timestamped
NUT bridge. A non-zero absolute video start PTS and the corresponding copied
audio offset are preserved through ffmpeg timestamp passthrough.
The encoder is source-aware for common 8-bit inputs: it probes and preserves
supported chroma sampling, recognized color metadata, encoder time base, and
MP4/MOV track timescale instead of accepting ffmpeg's implicit `yuv444p`
raw-BGR output. OpenCV still decodes through 8-bit BGR, so HDR/high-bit-depth
inputs are rejected before encoding rather than silently falling back to
`yuv420p`. The synthetic
Sora/OpenCV full-clip CI gate covers complete removal, untouched-region PSNR,
frame count, frame rate, duration, copied-audio identity, source stream
properties, paired temporal deltas against an independently encoded
frame-local baseline inside the filled region, and metadata
stripping through real ffmpeg. A second synthetic VFR clip verifies all display
timestamps to the source time-base tick, including a non-zero source start,
and checks both video and audio stream offsets. A separate constant-rate case
guards the non-zero-start routing independently of VFR detection. A local
full-sequence audit over all six providers passed both OpenCV and MI-GAN for
complete-frame removal, quiet second detection, stream starts, duration, and
copied audio. A full LaMa sequence passed the same checks but established that
the backend belongs in the offline tier on CPU. These bounded local checks are
still not universal evidence for every provider layout or source.
Native TC260 metadata in MP4/MOV is supported at its normative
`moov.udta.meta.keys/ilst` placement, including non-faststart files whose
@@ -252,9 +290,13 @@ write and strip the FLV form, but writing the nonstandard AVI child for fixture
generation requires dedicated muxer support, so the AVI reader is verified
against an exact synthetic RIFF structure.
The current ISOBMFF stripper reads an MP4, MOV, or M4V container into memory
before rewriting its metadata boxes. A streaming box copier is required before
the experimental command is appropriate for very large video files.
MP4/MOV/M4V metadata removal now stream-copies the container in bounded chunks,
keeps every box size and media offset fixed, and publishes atomically. The
large-`mdat` regression rejects a full-source `read_bytes()` call and verifies
that the encoded payload is byte-identical. HEIF/AVIF/JPEG-XL image metadata
still uses the in-memory path because their XMP/EXIF items may live inside
`mdat`/`idat` and require bounded format-item parsing before that path can
stream safely.
### Metadata transformation is fail safe
+78 -25
View File
@@ -86,10 +86,13 @@ Regression coverage:
- [`test_api.py`](../tests/test_api.py)
- [`test_image_io.py`](../tests/test_image_io.py)
[`video.py`](../src/remove_ai_watermarks/video.py) provides the experimental
[`video.py`](../src/remove_ai_watermarks/video.py) provides the high-level
video entry point:
- `identify_video`
- `inspect_video_metadata`
- `remove_video_all`
- `remove_video_batch`
- `remove_video_invisible`
- `remove_video_metadata`
- `remove_video_visible`
@@ -97,17 +100,35 @@ video entry point:
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
all four functions lazily.
the product path does not overwrite an original. The package root exposes all
functions lazily.
`identify_video` runs the same stable-mark selection helper as
`remove_video_visible`, so a provenance report cannot authorize a mark that the
removal path would reject. It reports an empty local result as unknown rather
than clean. Identification skips the separate per-frame timestamp probe because
it never encodes frames. `remove_video_all` is the predictable-output
composition: visible removal plus verified metadata stripping by default, with
a same-container passthrough when neither signal exists. The lossy invisible
removal stage is an explicit opt-in through the oracle-certified profile.
`remove_video_batch` applies those contracts sequentially across a top-level
directory, returns every per-file failure, and byte-copies visible no-ops so a
successful output set has no silent holes. An invisible batch loads one VAE
runtime and reuses it across every compatible file; a failed model load is
reported per file without retrying the same multi-GB initialization.
Native MP4/MOV TC260 labels follow TC260-PG-20257A:
`moov.udta.meta.keys` maps an `AIGC` key to a raw JSON value in `ilst`.
[`noai/isobmff.py`](../src/remove_ai_watermarks/noai/isobmff.py) walks those
nested boxes by seeking, so detection reaches a tail `moov` without reading the
preceding `mdat`. Removal changes the four-byte key to `free` and blanks only
the validated JSON value with same-length spaces. This preserves every box
size, `stco`/`co64` offset, and encoded stream byte. A generic `AIGC` key whose
value has no TC260 field is ignored.
preceding `mdat`. The MP4/MOV/M4V/M4A removal path first validates the top-level
box walk, then copies the source to a sibling temporary file in bounded chunks.
Supported C2PA/JUMBF/AI-label boxes become same-size `free` boxes with blank
payloads; TC260 removal changes the four-byte key to `free` and blanks only the
validated JSON value with same-length spaces. This preserves every box size,
`stco`/`co64` offset, encoded stream byte, and source-sized memory bound.
Publication is atomic, and a malformed top-level walk is copied unchanged. A
generic `AIGC` key whose value has no TC260 field is ignored.
[`noai/ebml.py`](../src/remove_ai_watermarks/noai/ebml.py) provides the
corresponding bounded Matroska/WebM reader. It seeks over clusters and accepts
@@ -123,14 +144,40 @@ normative TC260 video placements. The RIFF walker reads only AVI
use the verified ffmpeg stream-copy path for removal.
[`video_encoding.py`](../src/remove_ai_watermarks/video_encoding.py) owns the
raw-BGR ffmpeg command and pipe lifecycle shared by visible removal and
invisible regeneration. It centralizes container codecs, optional audio stream
copying, metadata/chapter policy, encode-failure reporting, and atomic
same-directory publication. Each mapped stream is allowed to reach its own end,
so a copied audio tail is not shortened to the raw-video input duration.
ffmpeg command and pipe lifecycle shared by visible removal and invisible
regeneration. It centralizes container codecs, optional audio stream copying,
metadata/chapter policy, encode-failure reporting, and atomic same-directory
publication. Each mapped stream is allowed to reach its own end, so a copied
audio tail is not shortened to the frame-input duration.
`probe_video_encode_profile` reads the first source video stream with ffprobe
and preserves the supported properties that survive the 8-bit BGR boundary:
`yuv420p`/`yuv422p`/`yuv444p` chroma sampling, recognized color tags, encoder
time base, MP4/MOV track timescale, source pixel format, and component depth.
HDR transfer functions and component depths above 8 bits are rejected before
encoding so the OpenCV boundary cannot silently reduce them to SDR 8-bit.
`probe_video_timestamps` reads authoritative per-frame display PTS through
ffprobe. OpenCV timestamps are only a count-matched fallback when ffprobe is
unavailable or fails; this avoids decoder anomalies such as one spurious
negative first-frame timestamp turning a CFR clip into false VFR. A uniform sequence keeps the
cheap raw-BGR pipe unless the source starts at a non-zero PTS. A variable or
offset sequence is packetized by the lazy PyAV bridge as rawvideo in an
in-memory NUT stream with explicit PTS. System ffmpeg reads that stream with
`-fps_mode passthrough`; `-copyts` additionally retains a non-zero video start
and the corresponding copied-audio offset. No temporary frame sequence or
second video encoder is introduced.
[`video_temporal.py`](../src/remove_ai_watermarks/video_temporal.py) owns the
shared optical-flow maps and temporal residual metric. Visible removal uses
`stabilize_filled_frame` after the selected image backend: it works on a
bounded crop around adjacent masks, backward-warps the prior cleaned frame,
requires high warped-mask coverage, and gates blending on an unmasked
source-context ring. Only covered current-mask pixels change. Scene cuts,
disjoint marks, and poor motion matches therefore retain the independent
current-frame fill. The same module supplies the motion-compensated metric used
by the invisible-video sweep.
[`video_invisible.py`](../src/remove_ai_watermarks/video_invisible.py)
implements the oracle-gated video SynthID candidate engine. It samples frames
implements the oracle-certified video SynthID removal 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
@@ -141,12 +188,13 @@ 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.
quality measurements. Neither is a watermark detector. The high-level result
reports completed removal without a separate verification-status flag. 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. The full-clip oracle floor is
`noise_std=0.15`: on the public eight-second Veo carrier, `0.10` remained
detected while `0.15` did not.
[`video_visible.py`](../src/remove_ai_watermarks/video_visible.py) implements
the first pixel stages for Sora, Veo, Seedance, Dola, Hailuo, and Kling. The
@@ -169,7 +217,8 @@ template features for the fixed stream geometry. Provider confidence scales
are not comparable: selection applies each provider's temporal arbiter and
takes the first stable result in specificity order (`sora`, `veo`, `seedance`,
`dola`, `hailuo`, `kling`). An explicit mark uses the same scan path with one
candidate.
candidate. Removal also collects authoritative per-frame timestamps for the
encoder, while identification omits that unused ffprobe pass.
Every per-frame result is untrusted. The provider-specific stabilization
wrappers share one recurrence implementation, while retaining separate visual
@@ -196,13 +245,17 @@ backgrounds need MI-GAN or LaMa for better reconstruction. Invisible video
stages must continue to reuse the image and metadata implementations rather
than copying their logic.
The inherited ISOBMFF metadata path currently reads the complete container into
memory; replacing that with a streaming box copier is a prerequisite for large
video inputs.
Regression coverage:
- [`test_video.py`](../tests/test_video.py)
- [`test_video.py`](../tests/test_video.py), including a real ffmpeg full-clip
Sora/OpenCV path that generates a synthetic marked MP4 with AAC audio and
C2PA provenance, runs both `remove_video_visible` and the composed
`remove_video_all` API without mocks, and verifies complete removal, frame
count, frame rate, duration, untouched-region PSNR, paired temporal deltas
inside the filled region, byte-identical copied audio packets, source stream
properties, metadata stripping, and a large-`mdat` metadata case that rejects
any full-source `read_bytes()` call. CI installs ffmpeg explicitly for this
test so the integration gate cannot silently skip.
## Metadata and provenance
+81 -12
View File
@@ -128,10 +128,62 @@ path preserves the pixels but drops standard metadata. Treat a nonempty
undecodable input through unchanged, so its return alone must not be presented
as proof that metadata was removed.
## Identify and clean video
The high level video API supports MP4, MOV, M4V, WebM, MKV, AVI, and FLV:
```python
import remove_ai_watermarks as raiw
report = raiw.identify_video("input.mp4")
print(report.is_ai_generated)
print(report.platform)
print(report.visible_mark)
print(report.metadata_markers)
```
`identify_video` uses the same full-clip temporal arbiter as visible removal.
It reports a recurring registered mark and supported AI metadata as positive
signals. When neither is present, `is_ai_generated` is `None`, never `False`.
The absence of a public local video SynthID decoder is included in `caveats`.
Pass `check_visible=False` for a bounded metadata-only inspection.
For normal product integration, use the complete locally verifiable pipeline:
```python
result = raiw.remove_video_all("input.mp4", "clean.mp4")
if result.remaining_metadata:
raise RuntimeError(f"AI metadata remains: {result.remaining_metadata}")
```
The default removes one stable supported visible provider mark when present,
always strips verified AI metadata, and writes a same-container output even
when neither signal is found. This gives callers one predictable output path.
It does not run lossy invisible regeneration by default.
`include_invisible=True` explicitly adds VAE regeneration for MP4, MOV, or M4V.
`VideoAllResult.invisible_removed` reports whether the oracle-certified SynthID
stage ran.
Process a top-level directory sequentially:
```python
batch = raiw.remove_video_batch("videos", "videos_clean", mode="all")
if batch.failed:
for item in batch.items:
if item.error:
print(item.source, item.error)
```
Batch modes are `all`, `visible`, and `metadata`. Successful visible no-ops are
copied byte-for-byte, keeping the output directory complete. Per-file failures
are returned in `VideoBatchItem.error`; they do not discard successful outputs.
The invisible stage is available only as an explicit opt-in in `all` mode and
reuses one loaded VAE runtime across the batch.
## Inspect and strip video metadata
The experimental high level video API supports MP4, MOV, M4V, WebM, MKV, AVI,
and FLV:
Metadata inspection and removal use the same supported video containers:
```python
import remove_ai_watermarks as raiw
@@ -151,23 +203,23 @@ The returned `VideoMetadataResult` records the source, output, metadata detected
before removal, and any markers remaining after the verified strip. MP4/MOV
inspection recognizes the native TC260 `AIGC` entry in
`moov.udta.meta.keys/ilst`; its removal preserves container size and encoded
stream bytes. MKV/WebM inspection recognizes the corresponding
stream bytes. MP4/MOV/M4V are copied in bounded chunks, so a large `mdat` is not
loaded into memory; publication is atomic. MKV/WebM inspection recognizes the corresponding
`Segment.Tags.Tag.SimpleTag` representation; its removal requires ffmpeg for a
stream-copy remux. AVI inspection reads `LIST/INFO/AIGC`, and FLV inspection
reads `script.onMetaData.AIGC`; both use the same verified ffmpeg stream-copy
removal path.
## Generate a video SynthID candidate
## Remove video SynthID
```python
import remove_ai_watermarks as raiw
result = raiw.remove_video_invisible(
"input.mp4",
"candidate.mp4",
"clean.mp4",
device="auto",
)
assert result.requires_external_verification
if result.remaining_metadata:
raise RuntimeError(f"AI metadata remains: {result.remaining_metadata}")
```
@@ -176,14 +228,19 @@ if result.remaining_metadata:
video through a VAE in bounded batches, shares one seeded latent-noise field
across all frames, streams pixels to ffmpeg, copies complete audio, strips
source metadata, and publishes atomically. The default output is
`input_synthid_candidate.mp4`; a distinct same-container output is required.
`input_clean.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.
The default `noise_std=0.15` is the current full-clip oracle floor; `0.10`
remained detected on the public eight-second Veo calibration carrier.
The default profile is oracle-certified. Google does not publish a local
decoder for this video payload, so a fresh source-positive, output-negative
pair from Gemini's built-in SynthID verifier remains an optional per-file audit.
A response inferred from a visible logo or metadata is not such a verdict, and
an adversarial follow-up asking ordinary Gemini to reinterpret the verifier is
not a second oracle run.
## Remove a supported visible video mark
@@ -195,6 +252,7 @@ result = raiw.remove_video_visible(
"clean.mp4",
backend="cv2",
strip_metadata=True,
temporal_consistency=True,
)
if result.output is None:
print("No temporally stable supported mark was found")
@@ -241,13 +299,24 @@ legacy `Veo` text. Seedance recognizes the boxed `AI` label, Dola recognizes
its compact text label, Hailuo recognizes the composite MINIMAX/Hailuo label,
and Kling recognizes its bottom-right logo, wordmark, and version suffix. Each
variant has an independent synthetic silhouette and calibrated temporal policy.
After each accepted frame is filled, `temporal_consistency=True` motion-aligns
the preceding accepted fill and blends it only when the warped prior mask
covers the current mask and a surrounding source-context ring agrees. Scene
cuts and disjoint masks keep the independent current fill. Pass
`temporal_consistency=False` for the frame-local baseline.
The returned `VideoVisibleResult` records the selected `mark`, the total,
detected, and removed frame counts, plus any AI metadata that survived the
output encode. The function returns `output=None` and writes no file when no
stable mark is selected. Video pixels are transcoded through ffmpeg while the
complete source audio stream is copied. A failed encode preserves any existing
output; only a completed result is published atomically.
complete source audio stream is copied. The encoder preserves supported 8-bit
source chroma sampling, color tags, MP4/MOV track timescale, and relative
variable-frame timestamps. It also retains a non-zero source start PTS and the
copied audio offset. A failed encode preserves any existing output; only a
completed result is published atomically.
SDR 8-bit video is the supported pixel contract. High-bit-depth, PQ, and HLG
sources raise `RuntimeError` before encoding instead of being silently reduced
to 8-bit SDR.
## Remove invisible watermarks
+3 -1
View File
@@ -57,7 +57,9 @@ The conda job uses the published artifact rather than a locally built archive
as the hash source and commits the resulting recipe change to `main`. Runtime
dependency mapping remains review-controlled: keep it aligned with the core
dependencies in `pyproject.toml`, and document any conda-forge package that is
unavailable and must be omitted.
unavailable and must be omitted. PyPI's version-split PyAV dependency maps to
`av >=16` in conda: the solver selects the Python-3.10-compatible build or the
current line according to the environment.
## Source distribution boundary
+19 -11
View File
@@ -40,19 +40,23 @@ when you can select the affected area yourself.
| `hailuo` | `MINIMAX | hailuo AI` composite label | Fixed lower edge | Uses a synthetic waveform, text, separator, and ring silhouette; the complete recurring label box is filled. |
| `kling` | Kling swirl, `KLING AI`, version, and optional `PRO` suffix | Fixed bottom-right edge | Combines a synthetic logo rescue with font variants, an edge gate, a white-label gate, and anchored temporal recurrence. |
Use `video visible` for this registry. It is separate from the image `visible`
command because selection is made over a sequence rather than one raster. Its
default `auto` mode scans all six entries in one decode pass and selects the
first temporally stable match in table order; an explicit mark restricts the
scan to that row.
`video identify`, `video visible`, and `video all` share this registry and the
same temporal arbiter. It is separate from the image registry because selection
is made over a sequence rather than one raster. The default `auto` mode scans
all six entries in one decode pass and selects the first temporally stable
match in table order; an explicit mark restricts the scan to that row.
Accepted fills are motion-aligned across adjacent frames by default. The prior
fill contributes only where its warped mask covers the current removal mask and
nearby source context agrees. Scene cuts or disjoint marks retain the
independent frame fill.
## Fill backends
| Backend | Install | Behavior |
| --- | --- | --- |
| `cv2` | Core package | Classical OpenCV inpainting |
| `migan` | `remove-ai-watermarks[migan]` | MI-GAN through ONNX Runtime |
| `lama` | `remove-ai-watermarks[lama]` | big-LaMa through ONNX Runtime |
| `migan` | `remove-ai-watermarks[migan]` | MI-GAN through ONNX Runtime; practical learned CPU video tier |
| `lama` | `remove-ai-watermarks[lama]` | big-LaMa through ONNX Runtime; offline video quality tier |
| `auto` | Depends on installed extras | Selects LaMa, then MI-GAN, then OpenCV |
The learned backends download model files on first use.
@@ -119,9 +123,13 @@ 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.
For MP4, MOV, and M4V, `video invisible` or the explicit
`video all --invisible` option can regenerate the video through a VAE and strip
source metadata. The shipped profile is oracle-certified, but it is not a local
decoder. A fresh source-positive, output-negative pair from Gemini's built-in
SynthID verifier is an optional per-file audit. A normal Gemini answer may instead
infer from a visible logo or metadata; asking it to reinterpret a completed
verifier result is not a second oracle run.
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.
@@ -133,7 +141,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 |
| Google Veo video | Veo diamond and legacy text | Oracle-certified VAE removal 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 |
+25 -14
View File
@@ -328,14 +328,12 @@ framework.
### 3.4 Video verification and attack harness
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. 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.
Gemini's built-in verification flow reports whether and where it detects Google
SynthID in a video. This remains a proprietary oracle: invoke `@synthid`, use
the supported content-verification question, and keep every file in a separate
new chat. A normal Gemini answer that discusses visual clues or metadata is not
an oracle verdict. Nor is an adversarial follow-up that asks the chat model to
ignore and reinterpret a completed verifier result.
The research harness `scripts/video_synthid_sweep.py` tests a VAE regeneration
attack without pretending to detect success locally. It emits:
@@ -354,12 +352,25 @@ 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.
same VAE regeneration mechanism for a complete input sequence. The shipped
default is oracle-certified and does not expose a separate verification-status
flag. In the 2026-07-29
two-carrier calibration, both matched controls were positive in the built-in
verifier; the stronger candidate was negative on both, while a weaker
candidate was negative on one. A 2026-07-30 `UNAVAILABLE` response came from an
ordinary-model follow-up that asked Gemini to reinterpret the already returned
verdict and therefore did not invalidate it. The default is a calibrated,
content-dependent operating point. A per-file provider check remains an
optional audit after provider changes or for unusually important files.
The 2026-07-31 full-clip calibration used Google's public eight-second Veo
off-road sample through the complete product command. The original was detected
across 00:00-00:07, the `noise_std=0.10` output remained detected, and the
`0.15` output was not detected. The positive `0.10` result proves that the
surrounding 512 px / 12 fps / H.264 path did not create the negative result by
itself. `0.15` is therefore the shipped default. The tracked manifest
`data/evaluations/video-synthid-oracle.csv` records the public source URL,
hashes, fidelity metrics, and verdicts without committing generated videos.
The VAE perturbation follows the general regeneration-attack construction from
Zhao et al. The video-specific control and temporal metric are local additions.
+61 -11
View File
@@ -223,22 +223,35 @@ final video codec around the actual attack. `scripts/video_synthid_sweep.py`
therefore emits `control.mp4` from the same selected frames and encoder settings
as every VAE candidate.
Verify the control first. Continue only when the provider oracle still detects
SynthID in it. A generic Gemini response that discusses visual clues, metadata,
or says the chat model lacks a decoder is not an oracle result. Record only the
explicit SynthID verification verdict in the generated CSV.
Verify the control first in a new Gemini chat by invoking `@synthid` and using
the supported built-in content-verification question. Continue only when the
provider oracle still detects SynthID in it. Verify each candidate in its own
new chat. A generic response that discusses visual clues or metadata is not an
oracle result. Do not ask the chat model to reinterpret or second-guess the
built-in verdict; that follow-up is ordinary model reasoning. Record only the
explicit built-in SynthID verification verdict in the generated CSV.
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.
The 2026-07-29 two-carrier calibration produced genuine built-in verifier
results: both matched controls were positive, the stronger candidate
was negative on both carriers, and a weaker candidate was negative on one. On
2026-07-30, adversarial follow-up prompts returned `UNAVAILABLE` after asking
ordinary Gemini to ignore and reinterpret the detector result. Those follow-ups
were mistakenly treated as a stricter oracle; they were not detector reruns.
The implementation remains exposed as `video invisible` and
`remove_video_invisible`. Its default is a calibrated operating point, while
each important result still requires a fresh control-positive,
candidate-negative pair.
The 2026-07-31 full-clip check added a public eight-second Veo carrier. The
source and the complete `0.10` product output were both detected, proving the
surrounding resize / frame-rate / codec path had not silenced the oracle. The
complete `0.15` product output was not detected, so `0.15` became the default.
The tracked source and output hashes, fidelity metrics, and verdicts are in
`data/evaluations/video-synthid-oracle.csv`; generated media remains untracked.
## Tier E -- robustness and adversarial inputs
@@ -301,6 +314,43 @@ separately, outside this repo.
Run `scripts/real_examples_e2e.py` against representative local inputs before releases that affect image handling. The script must read from `.local-eval/`, write only untracked temporary output, and report behavior without exposing dataset provenance or aggregate private measurements.
The cheap video seam is covered automatically by
`TestVideoVisibleFullClip::test_removes_complete_clip_and_preserves_sequence_and_audio`.
It constructs a full synthetic Sora-like MP4 with AAC audio and C2PA
provenance, then exercises detection, temporal arbitration, OpenCV fill, real
ffmpeg encoding, metadata stripping, audio stream copy, and atomic publication
through the public API. A separately encoded clean control supplies the paired
frame-to-frame deltas inside the filled region, so the gate detects temporal
flicker rather than treating all output motion as an error. The default
motion-compensated fill must score strictly below a separately encoded
frame-local opt-out on the median paired error, while its high-percentile error
cannot regress. The dedicated Linux CI job installs ffmpeg explicitly. Keep real-provider and learned-backend
sequence evaluation local because those inputs or model downloads do not
belong in the core matrix.
The local real-provider audit runs one complete clip for every registered video
mark. OpenCV and MI-GAN must remove every accepted frame, leave the second-pass
detector quiet, preserve source stream starts and duration, and copy AAC
packets exactly when present. Run one complete LaMa clip to verify wiring and
resource tier; its CPU throughput makes a six-provider online matrix
counterproductive. Store generated outputs and the detailed CSV only under
`.local-eval/`.
The same full-clip gate runs the public metadata-only path against its real MP4
and verifies unchanged file size, decoded frames, stream properties, and AAC
packets. A separate synthetic large-`mdat` test rejects full-source
`read_bytes()`, hashes the copied media payload, and mutation-checks both C2PA
and TC260 survival.
A companion full-clip VFR case alternates three frame durations, runs the
public visible-removal API, and compares every output display timestamp to the
source within one source time-base tick. Its source starts at a non-zero PTS,
so the test also verifies retained video/audio stream offsets, container
duration, and copied AAC identity. Mutations that disable the timestamped NUT
bridge or reset its start PTS must fail this gate.
A separate constant-rate clip with the same non-zero start guards the
start-offset routing without relying on the VFR branch.
## Standing gap
None of this is in `maintain.sh`, and it should not all be -- the sweeps take hours. But
+9 -4
View File
@@ -31,6 +31,10 @@ requirements:
- py-opencv >=4.8.0
- click >=8.0.0
- python-dotenv >=1.0.0
# PyAV packetizes processed VFR frames with explicit PTS before system
# ffmpeg encodes them. Conda's Python constraint selects the compatible
# 16.x line on Python 3.10 and the current line on newer interpreters.
- av >=16
# c2pa-python is a core PyPI dependency but is not packaged on conda-forge.
# The guarded import falls back to the built-in C2PA byte scanner when it is
# absent. Add it here once a c2pa-python feedstock exists.
@@ -53,10 +57,11 @@ about:
summary: Remove visible and invisible AI watermarks from images
description: |
Detect and remove registered visible AI-provenance marks and strip
AI-provenance metadata (C2PA, EXIF, IPTC, and PNG text chunks) from images.
The core package covers the identify, metadata, visible, and erase command
surface. Optional pip extras add SynthID diffusion removal and additional
invisible-watermark detectors.
AI-provenance metadata (C2PA, EXIF, IPTC, and PNG text chunks) from images
and supported video containers. The core package covers the identify,
metadata, visible, erase, and experimental video command surface. Optional
pip extras add SynthID diffusion removal and additional invisible-watermark
detectors.
license: Apache-2.0
license_file: LICENSE
repository: https://github.com/wiltodelta/remove-ai-watermarks
+6
View File
@@ -64,6 +64,12 @@ dependencies = [
# import is light (no torch/numpy) so it fits the dependency-light identify
# host. Prebuilt wheels cover the full CI matrix (linux/macos/windows).
"c2pa-python>=0.35.0",
# Timestamped NUT bridge for processed video frames. Rawvideo pipes carry no
# per-frame PTS, so this is what lets the existing system-ffmpeg encoder
# preserve VFR timing without buffering a frame sequence on disk. PyAV 18
# requires Python 3.11; the 16.x wheel line still covers Python 3.10.
"av>=16,<17; python_version < '3.11'",
"av>=18,<19; python_version >= '3.11'",
]
[project.optional-dependencies]
+8 -7
View File
@@ -22,11 +22,12 @@ 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 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.
Then upload ``control.mp4`` and each candidate in separate Gemini chats, invoke
the built-in SynthID verifier (``@synthid``), and use the question printed by
the script. Do not follow the verdict with an adversarial prompt asking the chat
model to reinterpret the detector: that switches back to ordinary reasoning.
Only a control-positive, candidate-negative pair from the built-in verifier is
removal evidence.
"""
from __future__ import annotations
@@ -105,7 +106,7 @@ def _write_manifest(output_dir: Path, rows: Sequence[dict[str, str]]) -> Path:
@click.command()
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=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("--noise-levels", default="0,0.05,0.1,0.15", 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=DEFAULT_VIDEO_SYNTHID_FPS, show_default=True)
@click.option(
@@ -214,7 +215,7 @@ def main(
manifest = _write_manifest(output_dir, rows)
log.info("Wrote %s", manifest)
log.info(
"Verify control.mp4 first in Gemini Flash with this prompt: %s",
"Verify control.mp4 first with Gemini's built-in SynthID verifier and this question: %s",
VIDEO_SYNTHID_VERIFICATION_PROMPT,
)
+19 -2
View File
@@ -6,9 +6,12 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
raiw.remove_visible("in.png", "out.png") # clean a file (provenance auto)
result, removed = raiw.remove_visible(bgr_array) # array -> array
raiw.visible_provenance("in.png") # -> frozenset of confirmed vendors
raiw.identify_video("in.mp4") # -> VideoProvenanceReport
raiw.inspect_video_metadata("in.mp4") # -> VideoMetadataReport
raiw.remove_video_all("in.mp4", "out.mp4") # visible + verified metadata
raiw.remove_video_batch("videos", "videos_clean") # complete per-file results
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_invisible("in.mp4", "out.mp4") # oracle-certified SynthID removal
raiw.remove_video_visible("in.mp4", "out.mp4") # stable visible video-mark removal
For a provenance verdict use the ``identify`` submodule::
@@ -33,7 +36,10 @@ __version__ = "0.20.2"
__all__ = [
"__version__",
"identify_video",
"inspect_video_metadata",
"remove_video_all",
"remove_video_batch",
"remove_video_invisible",
"remove_video_metadata",
"remove_video_visible",
@@ -44,7 +50,10 @@ __all__ = [
if TYPE_CHECKING:
from remove_ai_watermarks.api import remove_visible, visible_provenance
from remove_ai_watermarks.video import (
identify_video,
inspect_video_metadata,
remove_video_all,
remove_video_batch,
remove_video_invisible,
remove_video_metadata,
remove_video_visible,
@@ -58,7 +67,15 @@ def __getattr__(name: str) -> object:
from remove_ai_watermarks import api
return getattr(api, name)
if name in ("inspect_video_metadata", "remove_video_invisible", "remove_video_metadata", "remove_video_visible"):
if name in (
"identify_video",
"inspect_video_metadata",
"remove_video_all",
"remove_video_batch",
"remove_video_invisible",
"remove_video_metadata",
"remove_video_visible",
):
from remove_ai_watermarks import video
return getattr(video, name)
+256 -52
View File
@@ -4,7 +4,8 @@ Provides commands for:
- Visible watermark removal (Gemini sparkle) - works offline, fast
- Invisible watermark removal (SynthID etc.) - requires GPU/diffusion models
- AI metadata stripping - lightweight, no ML deps needed
- Experimental video metadata and visible-wordmark removal
- Video identification, visible-wordmark removal, and metadata stripping
- Oracle-certified video SynthID removal
"""
from __future__ import annotations
@@ -34,7 +35,6 @@ from remove_ai_watermarks.video_synthid import (
DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
DEFAULT_VIDEO_SYNTHID_NOISE_STD,
VIDEO_SYNTHID_LATENT_MULTIPLE,
VIDEO_SYNTHID_VERIFICATION_PROMPT,
)
if TYPE_CHECKING:
@@ -1097,12 +1097,107 @@ def cmd_metadata(
console.print(f" AI metadata stripped -> {out}")
# ── Experimental video pipeline ──
# ── Video pipeline ──
def _video_visible_options(f: Any) -> Any:
"""Apply the shared visible-video detector and fill options."""
f = click.option(
"--temporal-consistency/--no-temporal-consistency",
default=True,
help="Motion-align adjacent accepted fills to reduce frame-to-frame flicker.",
)(f)
f = click.option(
"--backend",
type=click.Choice(["auto", "cv2", "migan", "lama"]),
default="cv2",
help="Per-frame visible-fill backend.",
)(f)
return click.option(
"--mark",
type=click.Choice(["auto", *VIDEO_VISIBLE_MARKS]),
default="auto",
help="Visible AI mark to remove. Auto scans every supported provider in one decode pass.",
)(f)
def _video_invisible_options(f: Any) -> Any:
"""Apply the shared invisible-video removal options."""
f = click.option(
"--device",
type=click.Choice(["auto", "cuda", "mps", "cpu"]),
default="auto",
show_default=True,
help="VAE inference device.",
)(f)
f = click.option("--seed", type=int, default=0, show_default=True)(f)
f = click.option("--batch-size", type=click.IntRange(min=1), default=4, show_default=True)(f)
f = 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.",
)(f)
f = 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.",
)(f)
return 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.",
)(f)
@main.group("video")
def cmd_video() -> None:
"""Process AI watermarks in video files."""
@cmd_video.command("identify")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--no-visible", is_flag=True, help="Skip visible-mark detection; inspect metadata only.")
@click.option("--json", "as_json", is_flag=True, help="Emit the report as JSON.")
def cmd_video_identify(source: Path, no_visible: bool, as_json: bool) -> None:
"""Identify supported provenance and visible AI marks in video."""
from dataclasses import asdict
from remove_ai_watermarks.video import identify_video
try:
report = identify_video(source, check_visible=not no_visible)
except (OSError, RuntimeError, ValueError) as e:
raise click.ClickException(str(e)) from e
if as_json:
click.echo(json.dumps(asdict(report), default=str, indent=2))
return
_banner()
verdict = "AI-generated" if report.is_ai_generated else "unknown"
console.print(f" Verdict: {verdict} (confidence: {report.confidence})")
console.print(f" Platform: {report.platform or 'undetermined'}")
if report.visible_mark is not None:
console.print(
f" Visible mark: {report.visible_mark} "
f"({report.visible_detected_frames}/{report.total_frames} stable frames)"
)
else:
console.print(" Visible mark: none found" if not no_visible else " Visible mark: not checked")
if report.metadata_markers:
console.print(f" AI metadata markers: {', '.join(sorted(report.metadata_markers))}")
else:
console.print(" AI metadata markers: none found")
if report.caveats:
console.print(" Caveats:")
for caveat in report.caveats:
console.print(f" - {caveat}")
@cmd_video.command("metadata")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--check", is_flag=True, help="Check for AI metadata (don't modify).")
@@ -1156,38 +1251,9 @@ def cmd_video_metadata(
"--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.",
help="Output path (default: <source>_clean with the same container).",
)
@_video_invisible_options
def cmd_video_invisible(
source: Path,
output: Path | None,
@@ -1198,7 +1264,7 @@ def cmd_video_invisible(
seed: int,
device: str,
) -> None:
"""Generate an externally verifiable video SynthID candidate."""
"""Remove video SynthID with the oracle-certified VAE profile."""
from remove_ai_watermarks.video import remove_video_invisible
_banner()
@@ -1221,13 +1287,9 @@ def cmd_video_invisible(
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" SynthID removal complete: {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")
@@ -1239,24 +1301,14 @@ def cmd_video_invisible(
default=None,
help="Output path (default: <source>_clean with the same container).",
)
@click.option(
"--mark",
type=click.Choice(["auto", *VIDEO_VISIBLE_MARKS]),
default="auto",
help="Visible AI mark to remove. Auto scans every supported provider in one decode pass.",
)
@click.option(
"--backend",
type=click.Choice(["auto", "cv2", "migan", "lama"]),
default="cv2",
help="Per-frame fill backend. cv2 is the fast default; learned backends improve difficult backgrounds.",
)
@_video_visible_options
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from the transcoded output.")
def cmd_video_visible(
source: Path,
output: Path | None,
mark: str,
backend: str,
temporal_consistency: bool,
strip_metadata: bool,
) -> None:
"""Remove a temporally stable visible AI wordmark from video."""
@@ -1271,6 +1323,7 @@ def cmd_video_visible(
mark=mark,
backend=backend,
strip_metadata=strip_metadata,
temporal_consistency=temporal_consistency,
)
except (OSError, RuntimeError, ValueError) as e:
raise click.ClickException(str(e)) from e
@@ -1287,6 +1340,157 @@ def cmd_video_visible(
)
@cmd_video.command("all")
@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="Output path (default: <source>_clean with the same container).",
)
@_video_visible_options
@click.option(
"--invisible/--no-invisible",
default=False,
help="Opt into oracle-certified lossy video SynthID removal.",
)
@_video_invisible_options
def cmd_video_all(
source: Path,
output: Path | None,
mark: str,
backend: str,
temporal_consistency: bool,
invisible: bool,
noise_std: float,
long_side: int,
fps: float,
batch_size: int,
seed: int,
device: str,
) -> None:
"""Remove stable visible marks and AI metadata from video."""
from remove_ai_watermarks.video import remove_video_all
_banner()
stages = "visible marks + SynthID + verified AI metadata" if invisible else "visible marks + verified AI metadata"
console.print(f" Cleaning {source.name}: {stages}...")
try:
result = remove_video_all(
source,
output,
mark=mark,
backend=backend,
temporal_consistency=temporal_consistency,
include_invisible=invisible,
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)
if result.visible_mark is None:
detail = "" if result.invisible_removed else "; pixels preserved"
console.print(f" Visible mark: none found{detail}")
else:
console.print(
f" Visible mark: removed {result.visible_mark} from "
f"{result.visible_removed_frames}/{result.total_frames} frames"
)
console.print(f" AI metadata: stripped -> {result.output}")
if result.invisible_removed:
console.print(" SynthID: removed with the oracle-certified VAE profile")
@cmd_video.command("batch")
@click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.option(
"-o",
"--output-dir",
type=click.Path(path_type=Path),
default=None,
help="Output directory (default: <directory>_clean).",
)
@click.option(
"--mode",
type=click.Choice(["all", "visible", "metadata"]),
default="all",
show_default=True,
help="Video processing mode.",
)
@_video_visible_options
@click.option(
"--invisible/--no-invisible",
default=False,
help="Opt into oracle-certified lossy SynthID removal in all mode.",
)
@_video_invisible_options
def cmd_video_batch(
directory: Path,
output_dir: Path | None,
mode: str,
mark: str,
backend: str,
temporal_consistency: bool,
invisible: bool,
noise_std: float,
long_side: int,
fps: float,
batch_size: int,
seed: int,
device: str,
) -> None:
"""Process every supported video in a directory."""
from remove_ai_watermarks.video import remove_video_batch
_banner()
console.print(f" Processing video directory {directory} in {mode} mode...")
try:
result = remove_video_batch(
directory,
output_dir,
mode=mode, # type: ignore[arg-type]
mark=mark,
backend=backend,
temporal_consistency=temporal_consistency,
include_invisible=invisible,
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
for item in result.items:
if item.error is not None:
console.print(f" FAILED {item.source.name}: {item.error}")
elif item.changed:
detail = f" ({item.visible_mark})" if item.visible_mark is not None else ""
console.print(f" Processed {item.source.name}{detail} -> {item.output}")
elif item.mode == "visible":
console.print(f" Copied {item.source.name} byte-for-byte -> {item.output}")
else:
console.print(f" Completed {item.source.name}; no supported signal found -> {item.output}")
console.print(
f" Batch complete: {result.processed} processed, {result.failed} failed -> {result.output_directory}"
)
if result.invisible_removed:
console.print(f" SynthID: removed from {result.invisible_removed} file(s)")
if result.failed:
raise SystemExit(1)
# ── Provenance identification ──
@main.command("identify")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
+15 -2
View File
@@ -121,6 +121,7 @@ IPTC_AI_FIELD_MARKERS: tuple[bytes, ...] = (
# the container level (image, video, audio -- all ISOBMFF). A content sniff
# (``ftyp``) is also accepted, so this is a fast-path hint, not the sole gate.
_ISOBMFF_EXTS: frozenset[str] = frozenset({".avif", ".heif", ".heic", ".jxl", ".mp4", ".mov", ".m4v", ".m4a"})
_STREAMING_ISOBMFF_EXTS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".m4a"})
# Non-ISOBMFF audio/video the ISOBMFF box walker can't reach (EBML / framed /
# RIFF / Vorbis). remove_ai_metadata strips their container metadata losslessly
@@ -1202,18 +1203,30 @@ def remove_ai_metadata(
# strip C2PA + AI-label boxes at the container level without re-encoding.
# Avoids needing PIL plugins (pillow-heif / pillow-jxl) and preserves the
# codestream bit-for-bit. MP4/MOV/M4A are ISOBMFF too, so the same top-level
# uuid/jumb box walker applies. Route by suffix OR by an ``ftyp`` content
# sniff, so a correctly-shaped container is handled whatever its extension.
# uuid/jumb box walker applies. Known media suffixes take the bounded,
# offset-preserving streaming path; images retain the in-memory item scrub
# needed for XMP/EXIF inside mdat/idat. Route the remaining formats by suffix
# OR by an ``ftyp`` content sniff.
from remove_ai_watermarks.noai.isobmff import (
blank_ai_exif_tokens,
blank_ai_xmp_packets,
blank_tc260_aigc_tags,
is_isobmff,
strip_c2pa_boxes,
strip_isobmff_media_file,
)
with open(source_path, "rb") as f:
head = f.read(12)
if source_path.suffix.lower() in _STREAMING_ISOBMFF_EXTS and is_isobmff(head):
stripped, tc260_blanked = strip_isobmff_media_file(source_path, output_path)
logger.info(
"Stream-blanked %d AI-provenance box(es) and %d native TC260 tag(s) → %s",
stripped,
tc260_blanked,
output_path,
)
return output_path
if source_path.suffix.lower() in _ISOBMFF_EXTS or is_isobmff(head):
data = source_path.read_bytes()
# Top-level uuid/jumb boxes (C2PA + AI-label XMP), then the meta-box items
+150 -4
View File
@@ -3,10 +3,11 @@
The ISO Base Media File Format wraps content in nested ``[size:4][type:4][...]``
boxes. C2PA stores its manifest in a top-level ``uuid`` box keyed by the
C2PA UUID; JPEG-XL uses a ``jumb`` box (JUMBF) instead. To strip provenance
without re-encoding the image, we walk the top-level box list, drop boxes that
carry C2PA, and emit the rest verbatim. The codestream (``mdat`` for ISOBMFF,
``jxlc`` / ``jxlp`` for JPEG-XL) is untouched, so pixel data is preserved
bit-for-bit.
without re-encoding, the image path drops matching boxes and emits the rest
verbatim. The streaming MP4/MOV/M4A path instead preserves all offsets by
retyping matching boxes as ``free`` and blanking their payloads in place. The
codestream (``mdat`` for ISOBMFF, ``jxlc`` / ``jxlp`` for JPEG-XL) is untouched,
so pixel, video, and audio data is preserved bit-for-bit.
TC260-PG-20257A video metadata is nested instead:
``moov.udta.meta.keys/ilst``. Its detector seeks through those boxes without
@@ -24,7 +25,9 @@ from __future__ import annotations
import io
import logging
import os
import re
import shutil
import struct
from typing import TYPE_CHECKING, Any, BinaryIO
@@ -60,6 +63,8 @@ _AI_LABEL_MARKERS: tuple[bytes, ...] = AIGC_MARKERS + IPTC_AI_MARKERS + IPTC_AI_
# out of reach of the top-level box stripper, so an AI-label packet there is
# blanked in place (see ``blank_ai_xmp_packets``).
_XMP_PACKET_RE = re.compile(rb"<\?xpacket begin=.*?<\?xpacket end=[^>]*?\?>", re.DOTALL)
_STREAM_COPY_BYTES = 1024 * 1024
_STREAM_SCAN_BYTES = 4 * 1024 * 1024
# TC260-PG-20257A stores an MP4/MOV label as an ``AIGC`` key in
@@ -337,6 +342,147 @@ def scan_c2pa_region(path: str | Path, *, max_total: int = 4 * 1024 * 1024) -> b
return bytes(collected)
def _payload_has_ai_label(
stream: BinaryIO,
start: int,
end: int,
*,
max_scan: int,
) -> bool:
"""Scan a bounded prefix of one metadata payload for an AI-label marker."""
longest_marker = max(len(marker) for marker in _AI_LABEL_MARKERS)
remaining = min(end - start, max_scan)
overlap = b""
stream.seek(start)
while remaining > 0:
chunk = stream.read(min(_STREAM_COPY_BYTES, remaining))
if not chunk:
return False
searchable = overlap + chunk
if any(marker in searchable for marker in _AI_LABEL_MARKERS):
return True
overlap = searchable[-(longest_marker - 1) :]
remaining -= len(chunk)
return False
def _streaming_provenance_boxes(
stream: BinaryIO,
file_size: int,
*,
max_scan: int,
) -> list[tuple[int, int, int]] | None:
"""Return top-level provenance boxes, or ``None`` for a malformed walk.
Each result is ``(box_start, payload_start, box_end)``. The walk reads only
headers and bounded metadata prefixes, seeking over ``mdat`` payloads.
"""
stream.seek(0)
if not is_isobmff(stream.read(8)):
return None
targets: list[tuple[int, int, int]] = []
pos = 0
while pos < file_size:
header = _read_box_header(stream, pos, file_size)
if header is None:
return None
box_end, box_type, payload_off = header
if box_type == b"uuid":
stream.seek(payload_off)
is_c2pa = payload_off + 16 <= box_end and stream.read(16) == C2PA_UUID
has_ai_label = not is_c2pa and _payload_has_ai_label(
stream,
payload_off,
box_end,
max_scan=max_scan,
)
if is_c2pa or has_ai_label:
targets.append((pos, payload_off, box_end))
elif box_type == b"jumb":
targets.append((pos, payload_off, box_end))
pos = box_end
return targets
def _overwrite_range(
stream: BinaryIO,
start: int,
end: int,
*,
byte: bytes,
) -> None:
"""Overwrite one byte range with bounded allocations."""
stream.seek(start)
remaining = end - start
block = byte * min(_STREAM_COPY_BYTES, max(remaining, 1))
while remaining > 0:
size = min(len(block), remaining)
stream.write(block[:size])
remaining -= size
def strip_isobmff_media_file(
source: str | Path,
output: str | Path,
*,
max_box_scan: int = _STREAM_SCAN_BYTES,
) -> tuple[int, int]:
"""Stream-copy an MP4/MOV/M4A while removing supported AI metadata.
The output retains every box size and byte offset. A top-level C2PA/JUMBF or
AI-label box is converted to a ``free`` box and its payload is zeroed; native
TC260 key/value spans are blanked in place. Keeping the original lengths is
required because removing a pre-``mdat`` box would invalidate absolute media
offsets in an existing sample table.
The source is copied in bounded chunks to a sibling temporary file and
atomically published only after all patches succeed. A malformed top-level
walk is fail-safe: the input is copied unchanged.
Returns ``(provenance_boxes_blanked, native_tc260_keys_blanked)``.
"""
from pathlib import Path as _Path
from remove_ai_watermarks.video_encoding import atomic_video_output
source_path = _Path(source)
output_path = _Path(output)
with source_path.open("rb") as stream:
stream.seek(0, 2)
file_size = stream.tell()
targets = _streaming_provenance_boxes(
stream,
file_size,
max_scan=max_box_scan,
)
tc260_regions = _tc260_aigc_regions(stream, file_size) if targets is not None else []
tc260_key_spans = {(region[0], region[1]) for region in tc260_regions}
with atomic_video_output(output_path) as temporary_path:
with source_path.open("rb") as source_stream, temporary_path.open("r+b") as temporary:
shutil.copyfileobj(source_stream, temporary, length=_STREAM_COPY_BYTES)
if targets is not None:
for box_start, payload_start, box_end in targets:
temporary.seek(box_start + 4)
temporary.write(b"free")
_overwrite_range(temporary, payload_start, box_end, byte=b"\x00")
for key_start, _key_end, value_start, value_end, _value in tc260_regions:
temporary.seek(key_start)
temporary.write(b"free")
_overwrite_range(temporary, value_start, value_end, byte=b" ")
temporary.flush()
os.fsync(temporary.fileno())
shutil.copymode(source_path, temporary_path)
if targets is None:
logger.warning(
"ISOBMFF box walk failed for %s; copied input unchanged to avoid corrupting media offsets",
source_path,
)
return 0, 0
return len(targets), len(tc260_key_spans)
def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
"""Return ``(cleaned_bytes, stripped_count)`` with AI-provenance boxes removed.
+523 -120
View File
@@ -1,17 +1,17 @@
"""High-level video processing API.
Supported experimental stages are container-level AI metadata inspection and
The product path covers provenance identification, container-level AI metadata
removal, temporally stabilized visible Sora, Veo, Seedance, Dola, Hailuo, and
Kling removal, and VAE regeneration that produces an externally verifiable
SynthID candidate. The visible pixel path reuses the image package's shared
fill backends.
Kling removal, and an oracle-certified opt-in VAE profile for video SynthID.
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 tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, Literal
from remove_ai_watermarks.video_synthid import (
DEFAULT_VIDEO_SYNTHID_FPS,
@@ -21,7 +21,7 @@ from remove_ai_watermarks.video_synthid import (
)
if TYPE_CHECKING:
from remove_ai_watermarks.video_invisible import RegenerationMetrics
from remove_ai_watermarks.video_invisible import RegenerationMetrics, VideoVaeRuntime
from remove_ai_watermarks.video_visible import VideoScan
VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".flv"})
@@ -53,6 +53,22 @@ class VideoMetadataResult:
remaining: dict[str, str]
@dataclass(frozen=True)
class VideoProvenanceReport:
"""Locally verifiable provenance signals found in one video."""
source: Path
is_ai_generated: Literal[True] | None
confidence: Literal["high", "unknown"]
platform: str | None
visible_mark: str | None
visible_detected_frames: int
total_frames: int | None
has_ai_metadata: bool
metadata_markers: dict[str, str]
caveats: tuple[str, ...]
@dataclass(frozen=True)
class VideoVisibleResult:
"""Result of visible AI-watermark removal from a video."""
@@ -68,14 +84,13 @@ class VideoVisibleResult:
@dataclass(frozen=True)
class VideoInvisibleResult:
"""Result of generating an externally verifiable SynthID candidate."""
"""Result of removing video SynthID through the oracle-certified VAE profile."""
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:
@@ -102,6 +117,65 @@ class VideoInvisibleResult:
return self.metrics.temporal_residual_ratio
@dataclass(frozen=True)
class VideoAllResult:
"""Result of the complete video-cleaning pipeline."""
source: Path
output: Path
visible_mark: str | None
total_frames: int
visible_detected_frames: int
visible_removed_frames: int
detected_metadata: dict[str, str]
remaining_metadata: dict[str, str]
invisible_removed: bool
@dataclass(frozen=True)
class VideoBatchItem:
"""Outcome for one source in a video batch."""
source: Path
output: Path | None
mode: Literal["all", "visible", "metadata"]
changed: bool
visible_mark: str | None
invisible_removed: bool
error: str | None = None
@dataclass(frozen=True)
class VideoBatchResult:
"""Aggregate outcome for a sequential video batch."""
directory: Path
output_directory: Path
items: tuple[VideoBatchItem, ...]
@property
def processed(self) -> int:
return sum(item.error is None for item in self.items)
@property
def failed(self) -> int:
return sum(item.error is not None for item in self.items)
@property
def invisible_removed(self) -> int:
return sum(item.invisible_removed for item in self.items)
_VISIBLE_PLATFORM = {
"sora": "OpenAI Sora",
"veo": "Google Veo",
"seedance": "ByteDance Seedance",
"dola": "ByteDance Dola",
"hailuo": "MiniMax Hailuo",
"kling": "Kuaishou Kling",
}
def _video_source(source: str | Path) -> Path:
path = Path(source)
if not path.exists():
@@ -142,15 +216,176 @@ def _video_output(
return path
def _visible_removal_plan(
selected_mark: str,
selected_scan: VideoScan,
markers: dict[str, str],
) -> tuple[list[tuple[int, int, int, int] | None], float, Literal["box", "veo"]]:
"""Resolve one provider's stable frame regions and fill geometry."""
from remove_ai_watermarks.video_visible import (
has_bytedance_video_provenance,
has_sora_provenance,
has_veo_provenance,
stabilize_dola_localizations,
stabilize_hailuo_localizations,
stabilize_kling_localizations,
stabilize_seedance_localizations,
stabilize_sora_localizations,
stabilize_veo_localizations,
)
if selected_mark == "sora":
return (
stabilize_sora_localizations(
selected_scan.detections,
provenance=has_sora_provenance(markers),
),
0.28,
"box",
)
if selected_mark == "veo":
return (
stabilize_veo_localizations(
selected_scan.detections,
provenance=has_veo_provenance(markers),
),
0.18,
"veo",
)
if selected_mark == "seedance":
return (
stabilize_seedance_localizations(
selected_scan.detections,
provenance=has_bytedance_video_provenance(markers),
),
0.0,
"box",
)
if selected_mark == "dola":
return (
stabilize_dola_localizations(
selected_scan.detections,
provenance=has_bytedance_video_provenance(markers),
),
0.20,
"box",
)
if selected_mark == "hailuo":
return stabilize_hailuo_localizations(selected_scan.detections), 0.12, "box"
return stabilize_kling_localizations(selected_scan.detections), 0.12, "box"
def _select_stable_visible_mark(
scans: dict[str, VideoScan],
markers: dict[str, str],
candidate_marks: tuple[str, ...],
) -> (
tuple[
str,
VideoScan,
list[tuple[int, int, int, int] | None],
float,
Literal["box", "veo"],
]
| None
):
"""Select the first stable provider result in the public specificity order."""
for candidate_mark in candidate_marks:
candidate_scan = scans[candidate_mark]
candidate_regions, candidate_padding, candidate_mask_style = _visible_removal_plan(
candidate_mark,
candidate_scan,
markers,
)
if any(region is not None for region in candidate_regions):
return (
candidate_mark,
candidate_scan,
candidate_regions,
candidate_padding,
candidate_mask_style,
)
return None
def _platform_from_video_metadata(markers: dict[str, str]) -> str | None:
"""Map supported C2PA-derived marker text to its generating platform."""
from remove_ai_watermarks.noai.constants import C2PA_AI_VENDORS
marker_text = "\n".join(markers.values()).casefold()
if not marker_text:
return None
for vendor in C2PA_AI_VENDORS:
if vendor.platform is not None and vendor.needle is not None and vendor.needle.casefold() in marker_text:
return vendor.platform
return None
def inspect_video_metadata(source: str | Path) -> VideoMetadataReport:
"""Inspect supported AI-provenance metadata in a video container."""
from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata
from remove_ai_watermarks.metadata import get_ai_metadata
source_path = _video_source(source)
markers = get_ai_metadata(source_path)
return VideoMetadataReport(
source=source_path,
has_ai_metadata=has_ai_metadata(source_path),
markers=get_ai_metadata(source_path),
has_ai_metadata=bool(markers),
markers=markers,
)
def identify_video(
source: str | Path,
*,
check_visible: bool = True,
) -> VideoProvenanceReport:
"""Identify locally readable AI provenance and stable visible video marks.
A negative result is reported as unknown, never clean. Proprietary pixel
watermarks such as video SynthID have no public local decoder.
"""
from remove_ai_watermarks.metadata import get_ai_metadata
from remove_ai_watermarks.video_visible import scan_video_marks
source_path = _video_source(source)
markers = get_ai_metadata(source_path)
selected_mark: str | None = None
detected_frames = 0
total_frames: int | None = None
if check_visible:
scans = scan_video_marks(
source_path,
VIDEO_VISIBLE_MARKS,
collect_timestamps=False,
)
total_frames = len(scans[VIDEO_VISIBLE_MARKS[0]].detections)
selected = _select_stable_visible_mark(scans, markers, VIDEO_VISIBLE_MARKS)
if selected is not None:
selected_mark, _scan, regions, _padding, _mask_style = selected
detected_frames = sum(region is not None for region in regions)
has_signal = bool(markers) or selected_mark is not None
caveats = ["No public local decoder can verify proprietary pixel watermarks such as video SynthID."]
if not check_visible:
caveats.append("Visible video-mark detection was skipped.")
if not has_signal:
caveats.append("No supported signal was found; absence is unknown, not proof that the video is clean.")
return VideoProvenanceReport(
source=source_path,
is_ai_generated=True if has_signal else None,
confidence="high" if has_signal else "unknown",
platform=(
_VISIBLE_PLATFORM.get(selected_mark)
if selected_mark is not None
else _platform_from_video_metadata(markers)
),
visible_mark=selected_mark,
visible_detected_frames=detected_frames,
total_frames=total_frames,
has_ai_metadata=bool(markers),
metadata_markers=markers,
caveats=tuple(caveats),
)
@@ -159,18 +394,18 @@ def remove_video_metadata(
output: str | Path | None = None,
*,
keep_standard: bool = True,
_detected_metadata: dict[str, str] | None = None,
) -> VideoMetadataResult:
"""Remove AI metadata without transcoding video or audio streams.
The default output is ``<source_stem>_clean<source_suffix>``. A separate
output is required so an experimental video operation never overwrites the
original file.
output is required so the operation never overwrites the original file.
"""
from remove_ai_watermarks.metadata import get_ai_metadata, strip_and_verify
source_path = _video_source(source)
output_path = _video_output(source_path, output)
detected = get_ai_metadata(source_path)
detected = get_ai_metadata(source_path) if _detected_metadata is None else _detected_metadata
written, remaining = strip_and_verify(source_path, output_path, keep_standard=keep_standard)
return VideoMetadataResult(
source=source_path,
@@ -187,6 +422,8 @@ def remove_video_visible(
mark: str = "auto",
backend: str = "cv2",
strip_metadata: bool = True,
temporal_consistency: bool = True,
_metadata_markers: dict[str, str] | None = None,
) -> VideoVisibleResult:
"""Remove a supported visible AI wordmark from a video.
@@ -195,24 +432,14 @@ def remove_video_visible(
``veo``, ``seedance``, ``dola``, ``hailuo``, and ``kling``. The full
sequence is scanned before pixels change, and only recurring candidates are
accepted. Complete audio is copied without re-encoding; video is transcoded
because the pixels change. Completed output is published atomically. When
no stable mark is found, no output is written and ``output`` in the result
is ``None``.
because the pixels change. ``temporal_consistency=True`` motion-aligns a
safely covered prior fill after each image-backend pass; scene cuts and
disjoint masks keep the independent current fill. Completed output is
published atomically. When no stable mark is found, no output is written
and ``output`` in the result is ``None``.
"""
from remove_ai_watermarks.metadata import get_ai_metadata
from remove_ai_watermarks.video_visible import (
encode_clean_video,
has_bytedance_video_provenance,
has_sora_provenance,
has_veo_provenance,
scan_video_marks,
stabilize_dola_localizations,
stabilize_hailuo_localizations,
stabilize_kling_localizations,
stabilize_seedance_localizations,
stabilize_sora_localizations,
stabilize_veo_localizations,
)
from remove_ai_watermarks.video_visible import encode_clean_video, scan_video_marks
from remove_ai_watermarks.watermark_registry import resolve_backend
if mark not in {"auto", *VIDEO_VISIBLE_MARKS}:
@@ -222,79 +449,11 @@ def remove_video_visible(
source_path = _video_source(source)
output_path = _video_output(source_path, output, operation="visible watermark removal")
markers = get_ai_metadata(source_path)
def removal_plan(
selected_mark: str,
selected_scan: VideoScan,
) -> tuple[list[tuple[int, int, int, int] | None], float, Literal["box", "veo"]]:
if selected_mark == "sora":
return (
stabilize_sora_localizations(
selected_scan.detections,
provenance=has_sora_provenance(markers),
),
0.28,
"box",
)
if selected_mark == "veo":
return (
stabilize_veo_localizations(
selected_scan.detections,
provenance=has_veo_provenance(markers),
),
0.18,
"veo",
)
if selected_mark == "seedance":
return (
stabilize_seedance_localizations(
selected_scan.detections,
provenance=has_bytedance_video_provenance(markers),
),
0.0,
"box",
)
if selected_mark == "dola":
return (
stabilize_dola_localizations(
selected_scan.detections,
provenance=has_bytedance_video_provenance(markers),
),
0.20,
"box",
)
if selected_mark == "hailuo":
return stabilize_hailuo_localizations(selected_scan.detections), 0.12, "box"
return stabilize_kling_localizations(selected_scan.detections), 0.12, "box"
markers = get_ai_metadata(source_path) if _metadata_markers is None else _metadata_markers
candidate_marks = VIDEO_VISIBLE_MARKS if mark == "auto" else (mark,)
scans = scan_video_marks(source_path, candidate_marks)
selected: (
tuple[
str,
VideoScan,
list[tuple[int, int, int, int] | None],
float,
Literal["box", "veo"],
]
| None
) = None
for candidate_mark in candidate_marks:
candidate_scan = scans[candidate_mark]
candidate_regions, candidate_padding, candidate_mask_style = removal_plan(
candidate_mark,
candidate_scan,
)
if any(region is not None for region in candidate_regions):
selected = (
candidate_mark,
candidate_scan,
candidate_regions,
candidate_padding,
candidate_mask_style,
)
break
selected = _select_stable_visible_mark(scans, markers, candidate_marks)
if selected is None:
scan = scans[candidate_marks[0]]
return VideoVisibleResult(
@@ -308,16 +467,6 @@ def remove_video_visible(
)
mark, scan, regions, padding_fraction, mask_style = selected
detected_frames = sum(region is not None for region in regions)
if detected_frames == 0:
return VideoVisibleResult(
source=source_path,
output=None,
mark=mark,
total_frames=len(scan.detections),
detected_frames=0,
removed_frames=0,
remaining_metadata=markers if strip_metadata else {},
)
# Validate optional model availability before ffmpeg creates or overwrites
# the requested output.
@@ -331,6 +480,7 @@ def remove_video_visible(
strip_metadata=strip_metadata,
padding_fraction=padding_fraction,
mask_style=mask_style,
temporal_consistency=temporal_consistency,
)
remaining_metadata = get_ai_metadata(output_path) if strip_metadata else {}
return VideoVisibleResult(
@@ -344,6 +494,260 @@ def remove_video_visible(
)
def remove_video_all(
source: str | Path,
output: str | Path | None = None,
*,
mark: str = "auto",
backend: str = "cv2",
temporal_consistency: bool = True,
include_invisible: bool = False,
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",
_invisible_runtime: VideoVaeRuntime | None = None,
) -> VideoAllResult:
"""Run the complete video cleaning pipeline.
The default path removes a stable visible provider mark when present and
always strips verified AI metadata. It writes a same-container passthrough
when neither signal is present, giving product callers one predictable
output contract. ``include_invisible=True`` additionally runs lossy VAE
regeneration with the oracle-certified default profile.
"""
from remove_ai_watermarks.metadata import get_ai_metadata
source_path = _video_source(source)
output_path = _video_output(source_path, output, operation="complete cleaning")
if include_invisible and 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}")
detected_metadata = get_ai_metadata(source_path)
with TemporaryDirectory(prefix=f".{source_path.stem}-video-all-", dir=source_path.parent) as temp_dir:
visible_output = Path(temp_dir) / f"visible{source_path.suffix}" if include_invisible else output_path
visible_result = remove_video_visible(
source_path,
visible_output,
mark=mark,
backend=backend,
strip_metadata=True,
temporal_consistency=temporal_consistency,
_metadata_markers=detected_metadata,
)
current_source = visible_result.output or source_path
if include_invisible:
invisible_result = remove_video_invisible(
current_source,
output_path,
noise_std=noise_std,
long_side=long_side,
fps=fps,
batch_size=batch_size,
seed=seed,
model=model,
device=device,
_runtime=_invisible_runtime,
)
remaining_metadata = invisible_result.remaining_metadata
elif visible_result.output is None:
metadata_result = remove_video_metadata(
source_path,
output_path,
_detected_metadata=detected_metadata,
)
remaining_metadata = metadata_result.remaining
else:
remaining_metadata = visible_result.remaining_metadata
return VideoAllResult(
source=source_path,
output=output_path,
visible_mark=visible_result.mark if visible_result.output is not None else None,
total_frames=visible_result.total_frames,
visible_detected_frames=visible_result.detected_frames,
visible_removed_frames=visible_result.removed_frames,
detected_metadata=detected_metadata,
remaining_metadata=remaining_metadata,
invisible_removed=include_invisible,
)
def remove_video_batch(
directory: str | Path,
output_directory: str | Path | None = None,
*,
mode: Literal["all", "visible", "metadata"] = "all",
mark: str = "auto",
backend: str = "cv2",
temporal_consistency: bool = True,
include_invisible: bool = False,
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",
) -> VideoBatchResult:
"""Process every supported video in one directory.
Files are processed sequentially so model and ffmpeg resource use stays
bounded. Per-file failures are returned in ``items`` and do not discard
successful outputs. Visible-only no-op files are copied byte-for-byte so the
output directory remains complete.
"""
import shutil
from remove_ai_watermarks.video_encoding import atomic_video_output
directory_path = Path(directory)
if not directory_path.exists():
raise FileNotFoundError(f"Video directory does not exist: {directory_path}")
if not directory_path.is_dir():
raise ValueError(f"Video batch source must be a directory: {directory_path}")
if mode not in {"all", "visible", "metadata"}:
raise ValueError("Unsupported video batch mode; expected all, visible, or metadata")
if mark not in {"auto", *VIDEO_VISIBLE_MARKS}:
raise ValueError("Unsupported visible video mark; expected auto, sora, veo, seedance, dola, hailuo, or kling")
if backend not in {"auto", "cv2", "migan", "lama"}:
raise ValueError("Unsupported fill backend; expected auto, cv2, migan, or lama")
if include_invisible and mode != "all":
raise ValueError("The invisible video stage is available only in all mode")
output_path = (
Path(output_directory)
if output_directory is not None
else directory_path.parent / f"{directory_path.name}_clean"
)
if output_path.resolve() == directory_path.resolve():
raise ValueError("Video batch output directory must differ from the source directory")
output_path.mkdir(parents=True, exist_ok=True)
sources = tuple(
path
for path in sorted(directory_path.iterdir(), key=lambda candidate: candidate.name.lower())
if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS
)
items: list[VideoBatchItem] = []
invisible_runtime: VideoVaeRuntime | None = None
invisible_runtime_error: str | None = None
for source_path in sources:
item_output = output_path / source_path.name
try:
if mode == "all":
if (
include_invisible
and invisible_runtime is None
and source_path.suffix.lower() in _REGENERATED_VIDEO_EXTENSIONS
):
# Validate the container before paying the multi-GB model
# load, then retain one runtime for the complete batch.
_video_source(source_path)
if invisible_runtime_error is not None:
raise RuntimeError(invisible_runtime_error)
from remove_ai_watermarks.video_invisible import load_video_vae_runtime
try:
invisible_runtime = load_video_vae_runtime(model=model, device=device)
except Exception as exc:
invisible_runtime_error = str(exc)
raise
all_result = remove_video_all(
source_path,
item_output,
mark=mark,
backend=backend,
temporal_consistency=temporal_consistency,
include_invisible=include_invisible,
noise_std=noise_std,
long_side=long_side,
fps=fps,
batch_size=batch_size,
seed=seed,
model=model,
device=device,
_invisible_runtime=invisible_runtime,
)
if all_result.remaining_metadata:
raise RuntimeError(
f"{len(all_result.remaining_metadata)} AI metadata marker(s) survived the complete pipeline"
)
items.append(
VideoBatchItem(
source=source_path,
output=all_result.output,
mode=mode,
changed=bool(
all_result.visible_mark or all_result.detected_metadata or all_result.invisible_removed
),
visible_mark=all_result.visible_mark,
invisible_removed=all_result.invisible_removed,
)
)
elif mode == "visible":
visible_result = remove_video_visible(
source_path,
item_output,
mark=mark,
backend=backend,
strip_metadata=False,
temporal_consistency=temporal_consistency,
)
if visible_result.output is None:
with atomic_video_output(item_output) as temporary_output:
shutil.copyfile(source_path, temporary_output)
items.append(
VideoBatchItem(
source=source_path,
output=item_output,
mode=mode,
changed=visible_result.output is not None,
visible_mark=visible_result.mark if visible_result.output is not None else None,
invisible_removed=False,
)
)
else:
metadata_result = remove_video_metadata(source_path, item_output)
if metadata_result.remaining:
raise RuntimeError(
f"{len(metadata_result.remaining)} AI metadata marker(s) survived metadata removal"
)
items.append(
VideoBatchItem(
source=source_path,
output=metadata_result.output,
mode=mode,
changed=bool(metadata_result.detected),
visible_mark=None,
invisible_removed=False,
)
)
except Exception as exc:
items.append(
VideoBatchItem(
source=source_path,
output=None,
mode=mode,
changed=False,
visible_mark=None,
invisible_removed=False,
error=str(exc),
)
)
return VideoBatchResult(
directory=directory_path,
output_directory=output_path,
items=tuple(items),
)
def remove_video_invisible(
source: str | Path,
output: str | Path | None = None,
@@ -355,13 +759,13 @@ def remove_video_invisible(
seed: int = 0,
model: str = DEFAULT_VIDEO_SYNTHID_VAE,
device: str = "auto",
_runtime: VideoVaeRuntime | None = None,
) -> VideoInvisibleResult:
"""Generate a video SynthID-removal candidate through VAE regeneration.
"""Remove video SynthID through the oracle-certified VAE profile.
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.
The function also strips source metadata during the transcode. The default
profile is provider-oracle certified; important outputs may still be
rechecked with Google's verifier when the caller needs a per-file verdict.
"""
from remove_ai_watermarks.metadata import get_ai_metadata
from remove_ai_watermarks.video_invisible import regenerate_video_candidate
@@ -370,13 +774,11 @@ def remove_video_invisible(
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")
)
clean_output = Path(output) if output is not None else source_path.with_stem(source_path.stem + "_clean")
output_path = _video_output(
source_path,
candidate_output,
operation="SynthID candidate generation",
clean_output,
operation="SynthID removal",
)
metrics = regenerate_video_candidate(
source_path,
@@ -388,6 +790,7 @@ def remove_video_invisible(
seed=seed,
model=model,
device=device,
runtime=_runtime,
)
return VideoInvisibleResult(
source=source_path,
+264 -16
View File
@@ -1,21 +1,221 @@
"""Shared ffmpeg raw-video encoding helpers."""
"""Shared ffmpeg frame-encoding helpers."""
from __future__ import annotations
import json
import logging
import math
import os
import shutil
import subprocess
import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from fractions import Fraction
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Generator
from collections.abc import Generator, Sequence
log = logging.getLogger(__name__)
_PIXEL_FORMATS = frozenset({"yuv420p", "yuv422p", "yuv444p"})
_PIXEL_FORMAT_ALIASES = {
"yuvj420p": "yuv420p",
"yuvj422p": "yuv422p",
"yuvj444p": "yuv444p",
}
_COLOR_RANGES = frozenset({"tv", "pc"})
_COLOR_SPACES = frozenset({"bt709", "fcc", "bt470bg", "smpte170m", "smpte240m"})
_COLOR_TRANSFERS = frozenset(
{
"bt709",
"gamma22",
"gamma28",
"smpte170m",
"smpte240m",
"linear",
"log",
"log_sqrt",
"iec61966-2-4",
"bt1361e",
"iec61966-2-1",
"bt2020-10",
"bt2020-12",
"smpte2084",
"smpte428",
"arib-std-b67",
}
)
_COLOR_PRIMARIES = frozenset(
{
"bt709",
"bt470m",
"bt470bg",
"smpte170m",
"smpte240m",
"film",
"bt2020",
"smpte428",
"smpte431",
"smpte432",
"jedec-p22",
"ebu3213",
}
)
@dataclass(frozen=True)
class VideoEncodeProfile:
"""Source video properties that the raw-frame encoder can preserve."""
pixel_format: str = "yuv420p"
color_range: str | None = None
color_space: str | None = None
color_transfer: str | None = None
color_primaries: str | None = None
time_base: str | None = None
start_pts: int | None = None
source_pixel_format: str | None = None
component_depth: int | None = None
def _known_value(value: object, allowed: frozenset[str]) -> str | None:
"""Return a supported ffmpeg enum value, otherwise omit it."""
return value if isinstance(value, str) and value in allowed else None
def _time_base(value: object) -> str | None:
"""Normalize a positive ffprobe time base."""
if not isinstance(value, str):
return None
try:
fraction = Fraction(value)
except (ValueError, ZeroDivisionError):
return None
if fraction <= 0:
return None
return f"{fraction.numerator}/{fraction.denominator}"
def _pixel_component_depth(pixel_format: object, raw_bits: object) -> int | None:
"""Return the largest component depth reported by ffprobe or PyAV."""
depths: list[int] = []
if isinstance(raw_bits, str) and raw_bits.isdigit():
depths.append(int(raw_bits))
if isinstance(pixel_format, str):
try:
import av
depths.extend(component.bits for component in av.VideoFormat(pixel_format).components)
except (ImportError, ValueError):
pass
return max(depths) if depths else None
def _run_ffprobe(
source: Path,
arguments: Sequence[str],
*,
purpose: str,
output_format: str,
) -> str | None:
"""Run one ffprobe query with shared logging and failure handling."""
ffprobe = shutil.which("ffprobe")
if ffprobe is None:
log.warning("ffprobe is unavailable; cannot inspect %s", purpose)
return None
command = [ffprobe, "-v", "error", *arguments, "-of", output_format, str(source)]
result = subprocess.run( # noqa: S603
command,
capture_output=True,
check=False,
text=True,
)
log.info(
"ffprobe %s: command=%s status=%s stdout=%s stderr=%s",
purpose,
command,
result.returncode,
result.stdout,
result.stderr,
)
if result.returncode != 0:
log.warning("ffprobe could not inspect %s for %s", purpose, source)
return None
return result.stdout
def probe_video_encode_profile(source: Path) -> VideoEncodeProfile:
"""Read source properties that survive the package's 8-bit BGR boundary."""
raw_profile = _run_ffprobe(
source,
(
"-select_streams",
"v:0",
"-show_entries",
"stream=pix_fmt,bits_per_raw_sample,color_range,color_space,color_transfer,color_primaries,time_base,start_pts",
),
purpose="video profile",
output_format="json",
)
if raw_profile is None:
return VideoEncodeProfile()
try:
payload = json.loads(raw_profile)
streams = payload.get("streams", [])
stream = streams[0]
except (AttributeError, IndexError, TypeError, json.JSONDecodeError):
log.warning("ffprobe returned no usable video profile for %s; using yuv420p", source)
return VideoEncodeProfile()
raw_pixel_format = stream.get("pix_fmt")
pixel_format = _PIXEL_FORMAT_ALIASES.get(raw_pixel_format, raw_pixel_format)
if pixel_format not in _PIXEL_FORMATS:
pixel_format = "yuv420p"
time_base = _time_base(stream.get("time_base"))
raw_start_pts = stream.get("start_pts")
start_pts = raw_start_pts if isinstance(raw_start_pts, int) and time_base is not None else None
return VideoEncodeProfile(
pixel_format=pixel_format,
color_range=_known_value(stream.get("color_range"), _COLOR_RANGES),
color_space=_known_value(stream.get("color_space"), _COLOR_SPACES),
color_transfer=_known_value(stream.get("color_transfer"), _COLOR_TRANSFERS),
color_primaries=_known_value(stream.get("color_primaries"), _COLOR_PRIMARIES),
time_base=time_base,
start_pts=start_pts,
source_pixel_format=raw_pixel_format if isinstance(raw_pixel_format, str) else None,
component_depth=_pixel_component_depth(raw_pixel_format, stream.get("bits_per_raw_sample")),
)
def probe_video_timestamps(source: Path) -> tuple[float, ...]:
"""Read authoritative display timestamps for the first video stream."""
raw_timestamps = _run_ffprobe(
source,
(
"-select_streams",
"v:0",
"-show_frames",
"-show_entries",
"frame=best_effort_timestamp_time",
),
purpose="video timestamps",
output_format="csv=p=0",
)
if raw_timestamps is None:
return ()
try:
timestamps = tuple(float(line) for line in raw_timestamps.splitlines() if line)
except ValueError:
log.warning("ffprobe returned unusable frame timestamps for %s", source)
return ()
if not timestamps or not all(math.isfinite(timestamp) for timestamp in timestamps):
log.warning("ffprobe returned no finite frame timestamps for %s", source)
return ()
return timestamps
@contextmanager
def atomic_video_output(output: Path) -> Generator[Path]:
@@ -35,10 +235,38 @@ def atomic_video_output(output: Path) -> Generator[Path]:
temporary_output.unlink(missing_ok=True)
def _video_codec_args(suffix: str, *, crf: int) -> list[str]:
def _video_codec_args(suffix: str, *, crf: int, profile: VideoEncodeProfile) -> 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)]
args = ["-c:v", "libx264", "-preset", "medium", "-crf", str(crf)]
x264_params: list[str] = []
if profile.color_primaries == "bt709":
x264_params.append("colorprim=bt709")
if profile.color_transfer == "bt709":
x264_params.append("transfer=bt709")
if profile.color_space == "bt709":
x264_params.append("colormatrix=bt709")
if profile.color_range is not None:
x264_params.append(f"range={'full' if profile.color_range == 'pc' else 'limited'}")
if x264_params:
args.extend(["-x264-params", ":".join(x264_params)])
return args
def _profile_args(profile: VideoEncodeProfile) -> list[str]:
"""Build generic output options for source properties ffmpeg understands."""
args = ["-pix_fmt", profile.pixel_format]
for option, value in (
("-color_range", profile.color_range),
("-colorspace", profile.color_space),
("-color_trc", profile.color_transfer),
("-color_primaries", profile.color_primaries),
):
if value is not None:
args.extend([option, value])
if profile.time_base is not None:
args.extend(["-enc_time_base:v", profile.time_base])
return args
def raw_video_command(
@@ -50,33 +278,45 @@ def raw_video_command(
fps: float,
strip_metadata: bool,
crf: int,
profile: VideoEncodeProfile,
timestamped_input: bool = False,
copy_input_timestamps: bool = False,
) -> list[str]:
"""Build an ffmpeg command that accepts BGR frames on standard input."""
"""Build a source-aware ffmpeg command for BGR frames on standard input."""
ffmpeg = shutil.which("ffmpeg")
if ffmpeg is None:
raise RuntimeError("Video processing requires ffmpeg on PATH")
frame_input = (
["-f", "nut", "-i", "pipe:0"]
if timestamped_input
else [
"-f",
"rawvideo",
"-pix_fmt",
"bgr24",
"-s:v",
f"{width}x{height}",
"-r",
f"{fps:.12g}",
"-i",
"pipe:0",
]
)
command = [
ffmpeg,
"-y",
"-loglevel",
"error",
"-f",
"rawvideo",
"-pix_fmt",
"bgr24",
"-s:v",
f"{width}x{height}",
"-r",
f"{fps:.12g}",
"-i",
"pipe:0",
*(["-copyts"] if copy_input_timestamps else []),
*frame_input,
"-i",
str(source),
"-map",
"0:v:0",
"-map",
"1:a?",
*_video_codec_args(output.suffix.lower(), crf=crf),
*_video_codec_args(output.suffix.lower(), crf=crf, profile=profile),
*_profile_args(profile),
"-c:a",
"copy",
"-map_metadata",
@@ -84,7 +324,15 @@ def raw_video_command(
"-map_chapters",
"-1" if strip_metadata else "1",
]
if timestamped_input:
command.extend(["-fps_mode", "passthrough"])
if copy_input_timestamps:
command.extend(["-avoid_negative_ts", "disabled"])
if output.suffix.lower() in {".mp4", ".mov", ".m4v"}:
if profile.time_base is not None:
numerator, denominator = (int(part) for part in profile.time_base.split("/", 1))
if numerator == 1:
command.extend(["-video_track_timescale", str(denominator)])
command.extend(["-movflags", "+faststart"])
command.append(str(output))
return command
+83 -106
View File
@@ -1,9 +1,8 @@
"""VAE regeneration for externally verified video SynthID candidates.
"""Oracle-certified VAE regeneration for video SynthID removal.
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.
Google does not publish a local video SynthID decoder. The default profile is
therefore certified against Google's matching content-verification flow and
also reports local fidelity metrics.
"""
from __future__ import annotations
@@ -24,6 +23,7 @@ from remove_ai_watermarks.video_encoding import (
abort_raw_video_encoder,
atomic_video_output,
finish_raw_video_encoder,
probe_video_encode_profile,
raw_video_command,
start_raw_video_encoder,
)
@@ -34,6 +34,12 @@ from remove_ai_watermarks.video_synthid import (
DEFAULT_VIDEO_SYNTHID_VAE,
VIDEO_SYNTHID_LATENT_MULTIPLE,
)
from remove_ai_watermarks.video_temporal import (
_backward_map,
_motion_residual,
build_temporal_reference,
temporal_residual_ratio,
)
if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
@@ -41,10 +47,12 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
__all__ = ["build_temporal_reference", "temporal_residual_ratio"]
@dataclass(frozen=True)
class RegenerationMetrics:
"""Measured properties of one regenerated video candidate."""
"""Measured properties of one regenerated video."""
frames: int
fps: float
@@ -54,6 +62,16 @@ class RegenerationMetrics:
temporal_residual_ratio: float
@dataclass(frozen=True)
class VideoVaeRuntime:
"""Loaded VAE state reusable across multiple video regenerations."""
model: str
requested_device: str
resolved_device: str
vae: Any
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
@@ -93,6 +111,34 @@ def _pick_device(requested: str) -> str:
return requested
def load_video_vae_runtime(
*,
model: str = DEFAULT_VIDEO_SYNTHID_VAE,
device: str = "auto",
) -> VideoVaeRuntime:
"""Load one reusable video VAE runtime."""
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
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()
return VideoVaeRuntime(
model=model,
requested_device=device,
resolved_device=resolved_device,
vae=vae,
)
def _shared_latent_noise(
spatial_shape: Sequence[int],
*,
@@ -120,74 +166,6 @@ def paired_psnr(reference: np.ndarray, candidate: np.ndarray) -> float:
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():
@@ -341,6 +319,7 @@ def encode_video_frames(
fps=fps,
strip_metadata=True,
crf=18,
profile=probe_video_encode_profile(source),
)
)
frame_pipe = process.stdin
@@ -352,7 +331,7 @@ def encode_video_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")
finish_raw_video_encoder(process, output, operation="SynthID removal encode")
except Exception:
if process.poll() is None:
abort_raw_video_encoder(process)
@@ -371,11 +350,13 @@ def regenerate_video_candidate(
model: str = DEFAULT_VIDEO_SYNTHID_VAE,
device: str = "auto",
duration: float | None = None,
runtime: VideoVaeRuntime | 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.
The default profile is certified against the provider oracle. This function
does not perform a per-file SynthID decode because Google exposes no local
decoder.
"""
if not 0.0 <= noise_std <= 1.0:
raise ValueError("noise_std must be between 0 and 1")
@@ -387,22 +368,16 @@ def regenerate_video_candidate(
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()
if runtime is None:
runtime = load_video_vae_runtime(model=model, device=device)
elif runtime.model != model or runtime.requested_device != device:
raise ValueError("The supplied video VAE runtime does not match the requested model and device")
resolved_device = runtime.resolved_device
vae = runtime.vae
with atomic_video_output(output) as temporary_output:
process = start_raw_video_encoder(
@@ -414,6 +389,7 @@ def regenerate_video_candidate(
fps=effective_fps,
strip_metadata=True,
crf=18,
profile=probe_video_encode_profile(source),
)
)
frame_pipe = process.stdin
@@ -425,8 +401,9 @@ def regenerate_video_candidate(
pixel_count = 0
temporal_baseline = 0.0
temporal_candidate = 0.0
previous_reference: np.ndarray | None = None
previous_candidate: np.ndarray | None = None
previous_gray: np.ndarray | None = None
previous_reference_f32: np.ndarray | None = None
previous_candidate_f32: np.ndarray | None = None
shared_noise: Any | None = None
try:
sampled_frames = _iter_sampled_frames(
@@ -459,30 +436,30 @@ def regenerate_video_candidate(
)
for reference, candidate in zip(frames, regenerated, strict=True):
frame_pipe.write(candidate.tobytes())
difference = reference.astype(np.float32) - candidate.astype(np.float32)
reference_f32 = reference.astype(np.float32)
candidate_f32 = candidate.astype(np.float32)
difference = reference_f32 - candidate_f32
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)
current_gray = cv2.cvtColor(reference, cv2.COLOR_BGR2GRAY)
if (
previous_gray is not None
and previous_reference_f32 is not None
and previous_candidate_f32 is not None
):
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
temporal_baseline += _motion_residual(reference_f32, previous_reference_f32, frame_maps)
temporal_candidate += _motion_residual(candidate_f32, previous_candidate_f32, frame_maps)
previous_gray = current_gray
previous_reference_f32 = reference_f32
previous_candidate_f32 = candidate_f32
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",
operation="SynthID removal encode",
)
except Exception:
if process.poll() is None:
+3 -2
View File
@@ -1,10 +1,11 @@
"""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_NOISE_STD = 0.15
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."
"For the video attached to this message, was it created or edited by "
"Google AI? Use the built-in SynthID content verification result."
)
+189
View File
@@ -0,0 +1,189 @@
"""Pure motion-compensated helpers shared by video pipelines."""
from __future__ import annotations
# OpenCV exposes incomplete types for optical-flow and remap operations.
# Public signatures remain annotated while this third-party boundary is relaxed.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportMissingTypeStubs=false, reportCallIssue=false, reportArgumentType=false
from typing import TYPE_CHECKING, Any
import cv2
import numpy as np
if TYPE_CHECKING:
from collections.abc import Sequence
from numpy.typing import NDArray
def _backward_map(
current_gray: NDArray[Any],
previous_gray: NDArray[Any],
) -> tuple[NDArray[Any], NDArray[Any]]:
"""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
flow[..., 0] += np.arange(width, dtype=np.float32)[None, :]
flow[..., 1] += np.arange(height, dtype=np.float32)[:, None]
return flow[..., 0], flow[..., 1]
def _backward_warp(
image: NDArray[Any],
maps: tuple[NDArray[Any], NDArray[Any]],
*,
interpolation: int = cv2.INTER_LINEAR,
) -> NDArray[Any]:
"""Apply a precomputed backward optical-flow map."""
return cv2.remap(
image,
maps[0],
maps[1],
interpolation=interpolation,
borderMode=cv2.BORDER_REFLECT,
)
def _motion_residual(
current: NDArray[Any],
previous: NDArray[Any],
maps: tuple[NDArray[Any], NDArray[Any]],
) -> float:
"""Return mean absolute residual after warping the previous frame."""
current_f32 = np.asarray(current, dtype=np.float32)
previous_f32 = np.asarray(previous, dtype=np.float32)
warped_previous = _backward_warp(previous_f32, maps)
return float(np.mean(np.abs(current_f32 - warped_previous)))
def build_temporal_reference(
reference: Sequence[NDArray[Any]],
) -> tuple[tuple[tuple[NDArray[Any], NDArray[Any]], ...], 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[NDArray[Any], NDArray[Any]]] = []
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)
reference_residuals.append(_motion_residual(reference[index], reference[index - 1], frame_maps))
return tuple(maps), float(np.mean(reference_residuals))
def temporal_residual_ratio(
candidate: Sequence[NDArray[Any]],
maps: Sequence[tuple[NDArray[Any], NDArray[Any]]],
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):
candidate_residuals.append(_motion_residual(candidate[index], candidate[index - 1], frame_maps))
measured = float(np.mean(candidate_residuals))
return measured / max(baseline, 1e-6)
def stabilize_filled_frame(
previous_source: NDArray[Any],
previous_cleaned: NDArray[Any],
previous_mask: NDArray[Any],
current_source: NDArray[Any],
current_cleaned: NDArray[Any],
current_mask: NDArray[Any],
*,
blend: float = 0.5,
max_context_residual: float = 12.0,
copy: bool = True,
) -> NDArray[Any]:
"""Blend a motion-aligned prior fill when nearby source pixels agree.
The prior contributes only where its warped removal mask covers the current
mask. A context ring outside both masks gates the blend, so scene cuts or
non-rigid local changes keep the independent current-frame fill.
"""
if not 0.0 <= blend <= 1.0:
raise ValueError("Temporal blend must be between 0 and 1")
if max_context_residual <= 0.0:
raise ValueError("Context residual threshold must be positive")
if (
previous_source.shape != current_source.shape
or previous_cleaned.shape != current_cleaned.shape
or previous_source.shape != previous_cleaned.shape
or previous_mask.shape != current_mask.shape
or previous_mask.shape != current_source.shape[:2]
):
raise ValueError("Temporal fill inputs must share frame and mask geometry")
union = (previous_mask > 0) | (current_mask > 0)
ys, xs = np.where(union)
if len(xs) == 0:
return current_cleaned
height, width = current_mask.shape
mask_width = int(xs.max() - xs.min() + 1)
mask_height = int(ys.max() - ys.min() + 1)
padding = max(24, round(max(mask_width, mask_height) * 0.75))
x0 = max(0, int(xs.min()) - padding)
y0 = max(0, int(ys.min()) - padding)
x1 = min(width, int(xs.max()) + padding + 1)
y1 = min(height, int(ys.max()) + padding + 1)
previous_source_crop = previous_source[y0:y1, x0:x1]
current_source_crop = current_source[y0:y1, x0:x1]
current_cleaned_crop = current_cleaned[y0:y1, x0:x1]
previous_cleaned_crop = previous_cleaned[y0:y1, x0:x1]
previous_mask_crop = previous_mask[y0:y1, x0:x1]
current_mask_crop = current_mask[y0:y1, x0:x1]
maps = _backward_map(
cv2.cvtColor(current_source_crop, cv2.COLOR_BGR2GRAY),
cv2.cvtColor(previous_source_crop, cv2.COLOR_BGR2GRAY),
)
warped_previous_source = _backward_warp(previous_source_crop, maps)
warped_previous_cleaned = _backward_warp(previous_cleaned_crop, maps)
warped_previous_mask = _backward_warp(
previous_mask_crop,
maps,
interpolation=cv2.INTER_NEAREST,
)
current_hole = current_mask_crop > 0
if not np.any(current_hole):
return current_cleaned
covered = current_hole & (warped_previous_mask > 0)
if float(np.mean(covered[current_hole])) < 0.85:
return current_cleaned
occupied = current_hole | (warped_previous_mask > 0)
dilation = max(7, round(max(mask_width, mask_height) * 0.25))
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dilation | 1, dilation | 1))
context = cv2.dilate(occupied.astype(np.uint8), kernel).astype(bool) & ~occupied
if np.count_nonzero(context) < 64:
return current_cleaned
residual = np.abs(current_source_crop.astype(np.float32) - warped_previous_source.astype(np.float32))
context_residual = float(np.mean(residual[context]))
if context_residual > max_context_residual:
return current_cleaned
effective_blend = blend * (1.0 - context_residual / max_context_residual)
blended = (1.0 - effective_blend) * current_cleaned_crop[covered].astype(
np.float32
) + effective_blend * warped_previous_cleaned[covered].astype(np.float32)
result = current_cleaned.copy() if copy else current_cleaned
result_crop = result[y0:y1, x0:x1]
result_crop[covered] = np.clip(blended, 0, 255).astype(np.uint8)
return result
+171 -15
View File
@@ -11,8 +11,10 @@ requires the candidate to recur at the same location across adjacent frames.
This keeps isolated lookalikes in clean videos from becoming removal masks.
Video pixels are decoded with OpenCV and encoded with the system ``ffmpeg``.
Complete audio is stream-copied from the source. The video stream must be
transcoded because visible-mark removal changes pixels.
Variable frame timestamps cross the pipe in a PyAV-muxed NUT stream; uniform
inputs retain the cheaper raw-BGR pipe. Complete audio is stream-copied from
the source. The video stream must be transcoded because visible-mark removal
changes pixels.
"""
# cv2/numpy boundary: these packages do not expose usable types for many array
@@ -23,7 +25,9 @@ transcoded because visible-mark removal changes pixels.
from __future__ import annotations
import logging
from dataclasses import dataclass
from contextlib import suppress
from dataclasses import dataclass, replace
from fractions import Fraction
from functools import lru_cache
from itertools import pairwise
from typing import TYPE_CHECKING, Any, Literal
@@ -37,9 +41,12 @@ from remove_ai_watermarks.video_encoding import (
abort_raw_video_encoder,
atomic_video_output,
finish_raw_video_encoder,
probe_video_encode_profile,
probe_video_timestamps,
raw_video_command,
start_raw_video_encoder,
)
from remove_ai_watermarks.video_temporal import stabilize_filled_frame
if TYPE_CHECKING:
from pathlib import Path
@@ -86,6 +93,7 @@ _VEO_DIAMOND_PROFILES = (
_DOLA_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(22, 41))
_HAILUO_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(28, 56, 3))
_KLING_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(24, 49, 3))
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
@dataclass(frozen=True)
@@ -105,6 +113,7 @@ class VideoScan:
height: int
fps: float
detections: tuple[FrameLocalization, ...]
timestamps: tuple[float, ...] = ()
@dataclass(frozen=True)
@@ -1069,6 +1078,8 @@ def _stabilize_localizations(
def _scan_video_detectors(
source: Path,
detectors: dict[str, Any],
*,
collect_timestamps: bool = True,
) -> dict[str, VideoScan]:
"""Decode once and collect one untrusted candidate per detector and frame."""
capture = cv2.VideoCapture(str(source))
@@ -1082,11 +1093,14 @@ def _scan_video_detectors(
raise RuntimeError(f"Video has invalid stream geometry or frame rate: {source}")
detections: dict[str, list[FrameLocalization]] = {mark: [] for mark in detectors}
timestamps: list[float] = []
frame_index = 0
while True:
ok, frame = capture.read()
if not ok:
break
if collect_timestamps:
timestamps.append(float(capture.get(cv2.CAP_PROP_POS_MSEC)) / 1000)
if frame.shape[:2] != (height, width):
capture.release()
raise RuntimeError(f"Video changes frame dimensions at frame {frame_index}: {source}")
@@ -1103,7 +1117,24 @@ def _scan_video_detectors(
capture.release()
if frame_index == 0:
raise RuntimeError(f"Video contains no decodable frames: {source}")
return {mark: VideoScan(width, height, fps, tuple(mark_detections)) for mark, mark_detections in detections.items()}
shared_timestamps: tuple[float, ...] = ()
if collect_timestamps:
probed_timestamps = probe_video_timestamps(source)
if len(probed_timestamps) == frame_index:
shared_timestamps = probed_timestamps
else:
if probed_timestamps:
log.warning(
"ffprobe/OpenCV frame-count mismatch for %s: timestamps=%s decoded=%s; using decoder timestamps",
source,
len(probed_timestamps),
frame_index,
)
shared_timestamps = tuple(timestamps)
return {
mark: VideoScan(width, height, fps, tuple(mark_detections), shared_timestamps)
for mark, mark_detections in detections.items()
}
def _scan_video(
@@ -1117,8 +1148,14 @@ def _scan_video(
def scan_video_marks(
source: Path,
marks: tuple[str, ...] = VIDEO_VISIBLE_MARKS,
*,
collect_timestamps: bool = True,
) -> dict[str, VideoScan]:
"""Decode once and collect candidates for every requested provider mark."""
"""Decode once and collect candidates for every requested provider mark.
Timestamp probing is optional because identification never encodes frames.
Removal keeps it enabled so variable and non-zero-start timing is preserved.
"""
detectors = dict(
zip(
VIDEO_VISIBLE_MARKS,
@@ -1139,6 +1176,7 @@ def scan_video_marks(
return _scan_video_detectors(
source,
{mark: detectors[mark] for mark in marks},
collect_timestamps=collect_timestamps,
)
@@ -1215,6 +1253,66 @@ def _mask_for_region(
return mask
def _timestamp_time_base(profile_time_base: str | None) -> Fraction:
"""Use the source time base when known, with a microsecond fallback."""
return Fraction(profile_time_base) if profile_time_base is not None else Fraction(1, 1_000_000)
def _timestamps_are_variable(scan: VideoScan, *, time_base: Fraction) -> bool:
"""Whether OpenCV exposed valid timestamps with non-uniform frame intervals."""
if len(scan.timestamps) != len(scan.detections) or len(scan.timestamps) < 3:
return False
ticks = tuple(round(timestamp / float(time_base)) for timestamp in scan.timestamps)
intervals = tuple(current - previous for previous, current in pairwise(ticks))
return all(interval > 0 for interval in intervals) and len(set(intervals)) > 1
class _TimestampedNutWriter:
"""Mux BGR frames with explicit PTS into ffmpeg's standard-input pipe."""
def __init__(
self,
pipe: Any,
*,
width: int,
height: int,
time_base: Fraction,
start_pts: int = 0,
) -> None:
import av
self._av = av
self._time_base = time_base
self._start_pts = start_pts
self._origin: float | None = None
self._container = av.open(pipe, mode="w", format="nut")
self._stream = self._container.add_stream("rawvideo", rate=None)
self._stream.width = width
self._stream.height = height
self._stream.pix_fmt = "bgr24"
self._stream.time_base = time_base
self._stream.codec_context.time_base = time_base
def write(self, frame_bgr: NDArray[Any], timestamp: float) -> None:
"""Mux one contiguous BGR frame at its source timeline timestamp."""
if self._origin is None:
self._origin = timestamp
frame = self._av.VideoFrame.from_ndarray(
np.ascontiguousarray(frame_bgr),
format="bgr24",
)
frame.pts = self._start_pts + round((timestamp - self._origin) / float(self._time_base))
frame.time_base = self._time_base
for packet in self._stream.encode(frame):
self._container.mux(packet)
def close(self) -> None:
"""Flush the rawvideo encoder and NUT container without closing ffmpeg stdin."""
for packet in self._stream.encode():
self._container.mux(packet)
self._container.close()
def encode_clean_video(
source: Path,
output: Path,
@@ -1225,6 +1323,7 @@ def encode_clean_video(
strip_metadata: bool,
padding_fraction: float = 0.28,
mask_style: Literal["box", "veo"] = "box",
temporal_consistency: bool = True,
) -> int:
"""Decode again, fill accepted regions, and atomically encode with complete audio."""
from remove_ai_watermarks.watermark_registry import fill, resolve_backend
@@ -1233,6 +1332,22 @@ def encode_clean_video(
raise ValueError("Temporal localization count does not match the scanned frame count")
with atomic_video_output(output) as temporary_output:
profile = probe_video_encode_profile(source)
if (profile.component_depth or 0) > 8 or profile.color_transfer in _HDR_TRANSFERS:
source_format = profile.source_pixel_format or "unknown pixel format"
raise RuntimeError(
"Visible video removal currently supports SDR 8-bit input only; "
f"refusing to silently reduce {source_format} / "
f"{profile.color_transfer or 'unknown transfer'} to 8-bit SDR"
)
time_base = _timestamp_time_base(profile.time_base)
preserve_start_offset = profile.start_pts not in (None, 0)
timestamped_input = preserve_start_offset or _timestamps_are_variable(scan, time_base=time_base)
if timestamped_input and profile.time_base is None:
profile = replace(
profile,
time_base=f"{time_base.numerator}/{time_base.denominator}",
)
process = start_raw_video_encoder(
raw_video_command(
source,
@@ -1242,6 +1357,9 @@ def encode_clean_video(
fps=scan.fps,
strip_metadata=strip_metadata,
crf=14,
profile=profile,
timestamped_input=timestamped_input,
copy_input_timestamps=preserve_start_offset,
)
)
frame_pipe = process.stdin
@@ -1255,31 +1373,69 @@ def encode_clean_video(
raise RuntimeError(f"OpenCV could not reopen video for removal: {source}")
removed_frames = 0
resolved_backend: Literal["cv2", "migan", "lama"] = resolve_backend(backend)
timestamped_writer: _TimestampedNutWriter | None = None
previous_source: NDArray[Any] | None = None
previous_cleaned: NDArray[Any] | None = None
previous_mask: NDArray[Any] | None = None
try:
resolved_backend: Literal["cv2", "migan", "lama"] = resolve_backend(backend)
if timestamped_input:
timestamped_writer = _TimestampedNutWriter(
frame_pipe,
width=scan.width,
height=scan.height,
time_base=time_base,
start_pts=profile.start_pts or 0,
)
for frame_index, region in enumerate(regions):
ok, frame = capture.read()
if not ok:
raise RuntimeError(f"Video ended while reading frame {frame_index}: {source}")
source_frame = frame
mask: NDArray[Any] | None = None
if region is not None:
frame = fill(
mask = _mask_for_region(
frame,
_mask_for_region(
frame,
region,
padding_fraction=padding_fraction,
mask_style=mask_style,
),
backend=resolved_backend,
region,
padding_fraction=padding_fraction,
mask_style=mask_style,
)
frame = fill(frame, mask, backend=resolved_backend)
if (
temporal_consistency
and previous_source is not None
and previous_cleaned is not None
and previous_mask is not None
):
frame = stabilize_filled_frame(
previous_source,
previous_cleaned,
previous_mask,
source_frame,
frame,
mask,
copy=False,
)
removed_frames += 1
frame_pipe.write(frame.tobytes())
if timestamped_writer is None:
frame_pipe.write(frame.tobytes())
else:
timestamped_writer.write(frame, scan.timestamps[frame_index])
previous_source = source_frame
previous_cleaned = frame
previous_mask = mask
if timestamped_writer is not None:
timestamped_writer.close()
timestamped_writer = None
finish_raw_video_encoder(
process,
temporary_output,
operation="visible-watermark encode",
)
except Exception:
if timestamped_writer is not None:
with suppress(Exception):
timestamped_writer.close()
if process.poll() is None:
abort_raw_video_encoder(process)
raise
+3 -3
View File
@@ -1436,7 +1436,7 @@ def _box(box_type: bytes, payload: bytes) -> bytes:
class TestVideoC2pa:
"""C2PA in MP4 (ISOBMFF) -- detect + strip, reusing the image box walker."""
"""C2PA in MP4 (ISOBMFF) -- detect + offset-preserving stream blank."""
def test_detects_c2pa_in_mp4(self, tmp_path: Path):
from remove_ai_watermarks.metadata import C2PA_UUID
@@ -1454,7 +1454,7 @@ class TestVideoC2pa:
src.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
out = tmp_path / "out.mp4"
remove_ai_metadata(src, out)
assert out.read_bytes() == _MP4_FTYP + _MP4_MDAT
assert out.read_bytes() == _MP4_FTYP + _box(b"free", b"\x00" * 24) + _MP4_MDAT
assert has_ai_metadata(out) is False
@@ -1628,7 +1628,7 @@ class TestIsobmffMetadataRemoval:
src.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
out = tmp_path / "clean.m4a"
remove_ai_metadata(src, out)
assert out.read_bytes() == _MP4_FTYP + _MP4_MDAT
assert out.read_bytes() == _MP4_FTYP + _box(b"free", b"\x00" * 24) + _MP4_MDAT
def test_content_sniff_routes_unknown_suffix(self, tmp_path: Path):
# An ISOBMFF file with a non-standard extension is still box-stripped.
+38
View File
@@ -452,6 +452,44 @@ class TestISOBMFF:
assert blanked == 0
assert out == FTYP + b"\x00\x00\x00\x0cmdat" + b"pixels!!"
def test_streaming_malformed_walk_copies_input_unchanged(self, tmp_path: Path):
from remove_ai_watermarks.noai.isobmff import strip_isobmff_media_file
source = tmp_path / "malformed.mp4"
output = tmp_path / "clean.mp4"
malformed = FTYP + struct.pack(">I", 999) + b"uuid" + b"short"
source.write_bytes(malformed)
stripped, tc260_blanked = strip_isobmff_media_file(source, output)
assert (stripped, tc260_blanked) == (0, 0)
assert output.read_bytes() == malformed
def test_streaming_failure_does_not_publish_partial_output(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
):
from remove_ai_watermarks import metadata
from remove_ai_watermarks.noai import isobmff
source = tmp_path / "source.mp4"
output = tmp_path / "clean.mp4"
uuid_box = struct.pack(">I", 24) + b"uuid" + metadata.C2PA_UUID
source.write_bytes(FTYP + uuid_box)
output.write_bytes(b"previous output")
def fail_patch(*_args: object, **_kwargs: object) -> None:
raise OSError("synthetic patch failure")
monkeypatch.setattr(isobmff, "_overwrite_range", fail_patch)
with pytest.raises(OSError, match="synthetic patch failure"):
isobmff.strip_isobmff_media_file(source, output)
assert output.read_bytes() == b"previous output"
assert not list(tmp_path.glob(".clean-*"))
class TestIterTopLevelBoxes:
"""The box walker's three size encodings and its underflow/overflow guards."""
+8
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from pathlib import Path
import pytest
from scripts.sync_conda_recipe import update_recipe
@@ -39,3 +41,9 @@ def test_update_recipe_rejects_ambiguous_recipe() -> None:
with pytest.raises(ValueError, match="exactly one"):
update_recipe(duplicate, version="2.0.0", sha256=_NEW_SHA)
def test_repository_recipe_includes_timestamp_bridge() -> None:
recipe = Path("packaging/conda/recipe.yaml").read_text()
assert " - av >=16\n" in recipe
+1182 -34
View File
File diff suppressed because it is too large Load Diff
+121 -1
View File
@@ -1,12 +1,14 @@
"""Regression tests for the video SynthID candidate engine."""
"""Regression tests for the video SynthID removal engine."""
from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING
import pytest
from remove_ai_watermarks import video_encoding, video_invisible
from remove_ai_watermarks.video_synthid import DEFAULT_VIDEO_SYNTHID_NOISE_STD
if TYPE_CHECKING:
from pathlib import Path
@@ -39,6 +41,14 @@ def test_encoder_command_discards_metadata_and_copies_audio(
output = tmp_path / "candidate.mp4"
monkeypatch.setattr(video_encoding.shutil, "which", lambda _name: "/usr/bin/ffmpeg")
profile = video_encoding.VideoEncodeProfile(
pixel_format="yuv420p",
color_range="tv",
color_space="bt709",
color_transfer="bt709",
color_primaries="bt709",
time_base="1/90000",
)
command = video_encoding.raw_video_command(
source,
@@ -48,16 +58,126 @@ def test_encoder_command_discards_metadata_and_copies_audio(
fps=2.0,
strip_metadata=True,
crf=18,
profile=profile,
)
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"
output_pixel_format_index = command.index("-pix_fmt", command.index("-c:v"))
assert command[output_pixel_format_index + 1] == "yuv420p"
assert command[command.index("-color_range") + 1] == "tv"
assert command[command.index("-colorspace") + 1] == "bt709"
assert command[command.index("-color_trc") + 1] == "bt709"
assert command[command.index("-color_primaries") + 1] == "bt709"
assert command[command.index("-enc_time_base:v") + 1] == "1/90000"
assert command[command.index("-video_track_timescale") + 1] == "90000"
assert command[command.index("-x264-params") + 1] == (
"colorprim=bt709:transfer=bt709:colormatrix=bt709:range=limited"
)
assert "pipe:0" in command
assert "-shortest" not in command
def test_timestamped_encoder_reads_nut_and_passes_pts_through(
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=24.0,
strip_metadata=True,
crf=18,
profile=video_encoding.VideoEncodeProfile(time_base="1/90000"),
timestamped_input=True,
copy_input_timestamps=True,
)
assert "-copyts" in command
assert command[command.index("-f") : command.index("-f") + 4] == ["-f", "nut", "-i", "pipe:0"]
assert command[command.index("-fps_mode") + 1] == "passthrough"
assert command[command.index("-avoid_negative_ts") + 1] == "disabled"
assert command[command.index("-enc_time_base:v") + 1] == "1/90000"
def test_probe_encode_profile_preserves_supported_8_bit_properties(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
source = tmp_path / "source.mp4"
source.write_bytes(b"video")
monkeypatch.setattr(video_encoding.shutil, "which", lambda _name: "/usr/bin/ffprobe")
monkeypatch.setattr(
video_encoding.subprocess,
"run",
lambda *_args, **_kwargs: SimpleNamespace(
returncode=0,
stdout=(
'{"streams":[{"pix_fmt":"yuvj422p","color_range":"tv",'
'"color_space":"bt709","color_transfer":"bt709",'
'"color_primaries":"bt709","time_base":"2/180000",'
'"start_pts":180000,"bits_per_raw_sample":"8"}]}'
),
stderr="",
),
)
profile = video_encoding.probe_video_encode_profile(source)
assert profile == video_encoding.VideoEncodeProfile(
pixel_format="yuv422p",
color_range="tv",
color_space="bt709",
color_transfer="bt709",
color_primaries="bt709",
time_base="1/90000",
start_pts=180000,
source_pixel_format="yuvj422p",
component_depth=8,
)
def test_probe_encode_profile_uses_compatible_defaults_without_ffprobe(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(video_encoding.shutil, "which", lambda _name: None)
assert video_encoding.probe_video_encode_profile(tmp_path / "source.mp4") == (video_encoding.VideoEncodeProfile())
def test_probe_video_timestamps_uses_best_effort_pts(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
source = tmp_path / "source.mp4"
source.write_bytes(b"video")
monkeypatch.setattr(video_encoding.shutil, "which", lambda _name: "/usr/bin/ffprobe")
monkeypatch.setattr(
video_encoding.subprocess,
"run",
lambda *_args, **_kwargs: SimpleNamespace(
returncode=0,
stdout="0.000000\n0.041667\n",
stderr="",
),
)
assert video_encoding.probe_video_timestamps(source) == (0.0, 0.041667)
def test_default_noise_matches_full_clip_oracle_floor() -> None:
assert DEFAULT_VIDEO_SYNTHID_NOISE_STD == 0.15
def test_stream_batches_consumes_only_one_batch_ahead() -> None:
consumed: list[int] = []
+112
View File
@@ -0,0 +1,112 @@
"""Regression tests for motion-compensated visible-video fill."""
from __future__ import annotations
import cv2
import numpy as np
from remove_ai_watermarks.video_temporal import stabilize_filled_frame
def _translated_pair() -> tuple[np.ndarray, np.ndarray]:
rng = np.random.default_rng(7)
previous = rng.integers(0, 256, (96, 128, 3), dtype=np.uint8)
previous = cv2.GaussianBlur(previous, (5, 5), 0)
current = cv2.warpAffine(
previous,
np.float32(((1, 0, 2), (0, 1, 1))),
(128, 96),
borderMode=cv2.BORDER_REFLECT,
)
return previous, current
def test_motion_aligned_prior_reduces_independent_fill_error() -> None:
previous, current = _translated_pair()
mask = np.zeros(current.shape[:2], dtype=np.uint8)
mask[30:66, 45:85] = 255
rng = np.random.default_rng(11)
current_fill = current.copy()
noise = rng.normal(0, 18, (36, 40, 3))
current_fill[30:66, 45:85] = np.clip(
current_fill[30:66, 45:85].astype(np.float32) + noise,
0,
255,
).astype(np.uint8)
stabilized = stabilize_filled_frame(
previous,
previous,
mask,
current,
current_fill,
mask,
)
hole = mask > 0
before = float(np.mean((current_fill[hole].astype(np.float32) - current[hole]) ** 2))
after = float(np.mean((stabilized[hole].astype(np.float32) - current[hole]) ** 2))
assert after < before * 0.5
assert np.array_equal(stabilized[~hole], current_fill[~hole])
def test_owned_fill_can_be_stabilized_without_full_frame_copy() -> None:
previous, current = _translated_pair()
mask = np.zeros(current.shape[:2], dtype=np.uint8)
mask[30:66, 45:85] = 255
current_fill = current.copy()
current_fill[mask > 0] = 127
stabilized = stabilize_filled_frame(
previous,
previous,
mask,
current,
current_fill,
mask,
copy=False,
)
assert stabilized is current_fill
def test_scene_cut_keeps_independent_current_fill() -> None:
previous, _current = _translated_pair()
rng = np.random.default_rng(13)
current = rng.integers(0, 256, previous.shape, dtype=np.uint8)
mask = np.zeros(current.shape[:2], dtype=np.uint8)
mask[30:66, 45:85] = 255
current_fill = current.copy()
current_fill[mask > 0] = 0
stabilized = stabilize_filled_frame(
previous,
previous,
mask,
current,
current_fill,
mask,
)
assert np.array_equal(stabilized, current_fill)
def test_disjoint_prior_mask_cannot_reintroduce_old_mark() -> None:
previous, current = _translated_pair()
previous_mask = np.zeros(current.shape[:2], dtype=np.uint8)
previous_mask[8:24, 8:24] = 255
current_mask = np.zeros(current.shape[:2], dtype=np.uint8)
current_mask[60:76, 96:112] = 255
current_fill = current.copy()
current_fill[current_mask > 0] = 127
stabilized = stabilize_filled_frame(
previous,
previous,
previous_mask,
current,
current_fill,
current_mask,
)
assert np.array_equal(stabilized, current_fill)
Generated
+109
View File
@@ -255,6 +255,111 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "av"
version = "16.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11' and sys_platform == 'darwin'",
"python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
sdist = { url = "https://files.pythonhosted.org/packages/78/cd/3a83ffbc3cc25b39721d174487fb0d51a76582f4a1703f98e46170ce83d4/av-16.1.0.tar.gz", hash = "sha256:a094b4fd87a3721dacf02794d3d2c82b8d712c85b9534437e82a8a978c175ffd", size = 4285203, upload-time = "2026-01-11T07:31:33.772Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/51/2217a9249409d2e88e16e3f16f7c0def9fd3e7ffc4238b2ec211f9935bdb/av-16.1.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:2395748b0c34fe3a150a1721e4f3d4487b939520991b13e7b36f8926b3b12295", size = 26942590, upload-time = "2026-01-09T20:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/bf/cd/a7070f4febc76a327c38808e01e2ff6b94531fe0b321af54ea3915165338/av-16.1.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:72d7ac832710a158eeb7a93242370aa024a7646516291c562ee7f14a7ea881fd", size = 21507910, upload-time = "2026-01-09T20:18:02.309Z" },
{ url = "https://files.pythonhosted.org/packages/ae/30/ec812418cd9b297f0238fe20eb0747d8a8b68d82c5f73c56fe519a274143/av-16.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6cbac833092e66b6b0ac4d81ab077970b8ca874951e9c3974d41d922aaa653ed", size = 38738309, upload-time = "2026-01-09T20:18:04.701Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b8/6c5795bf1f05f45c5261f8bce6154e0e5e86b158a6676650ddd77c28805e/av-16.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:eb990672d97c18f99c02f31c8d5750236f770ffe354b5a52c5f4d16c5e65f619", size = 40293006, upload-time = "2026-01-09T20:18:07.238Z" },
{ url = "https://files.pythonhosted.org/packages/a7/44/5e183bcb9333fc3372ee6e683be8b0c9b515a506894b2d32ff465430c074/av-16.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05ad70933ac3b8ef896a820ea64b33b6cca91a5fac5259cb9ba7fa010435be15", size = 40123516, upload-time = "2026-01-09T20:18:09.955Z" },
{ url = "https://files.pythonhosted.org/packages/12/1d/b5346d582a3c3d958b4d26a2cc63ce607233582d956121eb20d2bbe55c2e/av-16.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d831a1062a3c47520bf99de6ec682bd1d64a40dfa958e5457bb613c5270e7ce3", size = 41463289, upload-time = "2026-01-09T20:18:12.459Z" },
{ url = "https://files.pythonhosted.org/packages/fa/31/acc946c0545f72b8d0d74584cb2a0ade9b7dfe2190af3ef9aa52a2e3c0b1/av-16.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:358ab910fef3c5a806c55176f2b27e5663b33c4d0a692dafeb049c6ed71f8aff", size = 31754959, upload-time = "2026-01-09T20:18:14.718Z" },
{ url = "https://files.pythonhosted.org/packages/48/d0/b71b65d1b36520dcb8291a2307d98b7fc12329a45614a303ff92ada4d723/av-16.1.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:e88ad64ee9d2b9c4c5d891f16c22ae78e725188b8926eb88187538d9dd0b232f", size = 26927747, upload-time = "2026-01-09T20:18:16.976Z" },
{ url = "https://files.pythonhosted.org/packages/2f/79/720a5a6ccdee06eafa211b945b0a450e3a0b8fc3d12922f0f3c454d870d2/av-16.1.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cb296073fa6935724de72593800ba86ae49ed48af03960a4aee34f8a611f442b", size = 21492232, upload-time = "2026-01-09T20:18:19.266Z" },
{ url = "https://files.pythonhosted.org/packages/8e/4f/a1ba8d922f2f6d1a3d52419463ef26dd6c4d43ee364164a71b424b5ae204/av-16.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:720edd4d25aa73723c1532bb0597806d7b9af5ee34fc02358782c358cfe2f879", size = 39291737, upload-time = "2026-01-09T20:18:21.513Z" },
{ url = "https://files.pythonhosted.org/packages/1a/31/fc62b9fe8738d2693e18d99f040b219e26e8df894c10d065f27c6b4f07e3/av-16.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c7f2bc703d0df260a1fdf4de4253c7f5500ca9fc57772ea241b0cb241bcf972e", size = 40846822, upload-time = "2026-01-09T20:18:24.275Z" },
{ url = "https://files.pythonhosted.org/packages/53/10/ab446583dbce730000e8e6beec6ec3c2753e628c7f78f334a35cad0317f4/av-16.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d69c393809babada7d54964d56099e4b30a3e1f8b5736ca5e27bd7be0e0f3c83", size = 40675604, upload-time = "2026-01-09T20:18:26.866Z" },
{ url = "https://files.pythonhosted.org/packages/31/d7/1003be685277005f6d63fd9e64904ee222fe1f7a0ea70af313468bb597db/av-16.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:441892be28582356d53f282873c5a951592daaf71642c7f20165e3ddcb0b4c63", size = 42015955, upload-time = "2026-01-09T20:18:29.461Z" },
{ url = "https://files.pythonhosted.org/packages/2f/4a/fa2a38ee9306bf4579f556f94ecbc757520652eb91294d2a99c7cf7623b9/av-16.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:273a3e32de64819e4a1cd96341824299fe06f70c46f2288b5dc4173944f0fd62", size = 31750339, upload-time = "2026-01-09T20:18:32.249Z" },
{ url = "https://files.pythonhosted.org/packages/9c/84/2535f55edcd426cebec02eb37b811b1b0c163f26b8d3f53b059e2ec32665/av-16.1.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:640f57b93f927fba8689f6966c956737ee95388a91bd0b8c8b5e0481f73513d6", size = 26945785, upload-time = "2026-01-09T20:18:34.486Z" },
{ url = "https://files.pythonhosted.org/packages/b6/17/ffb940c9e490bf42e86db4db1ff426ee1559cd355a69609ec1efe4d3a9eb/av-16.1.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ae3fb658eec00852ebd7412fdc141f17f3ddce8afee2d2e1cf366263ad2a3b35", size = 21481147, upload-time = "2026-01-09T20:18:36.716Z" },
{ url = "https://files.pythonhosted.org/packages/15/c1/e0d58003d2d83c3921887d5c8c9b8f5f7de9b58dc2194356a2656a45cfdc/av-16.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:27ee558d9c02a142eebcbe55578a6d817fedfde42ff5676275504e16d07a7f86", size = 39517197, upload-time = "2026-01-11T09:57:31.937Z" },
{ url = "https://files.pythonhosted.org/packages/32/77/787797b43475d1b90626af76f80bfb0c12cfec5e11eafcfc4151b8c80218/av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2", size = 41174337, upload-time = "2026-01-11T09:57:35.792Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ac/d90df7f1e3b97fc5554cf45076df5045f1e0a6adf13899e10121229b826c/av-16.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8cf065f9d438e1921dc31fc7aa045790b58aee71736897866420d80b5450f62a", size = 40817720, upload-time = "2026-01-11T09:57:39.039Z" },
{ url = "https://files.pythonhosted.org/packages/80/6f/13c3a35f9dbcebafd03fe0c4cbd075d71ac8968ec849a3cfce406c35a9d2/av-16.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a345877a9d3cc0f08e2bc4ec163ee83176864b92587afb9d08dff50f37a9a829", size = 42267396, upload-time = "2026-01-11T09:57:42.115Z" },
{ url = "https://files.pythonhosted.org/packages/c8/b9/275df9607f7fb44317ccb1d4be74827185c0d410f52b6e2cd770fe209118/av-16.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:f49243b1d27c91cd8c66fdba90a674e344eb8eb917264f36117bf2b6879118fd", size = 31752045, upload-time = "2026-01-11T09:57:45.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/2a/63797a4dde34283dd8054219fcb29294ba1c25d68ba8c8c8a6ae53c62c45/av-16.1.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:ce2a1b3d8bf619f6c47a9f28cfa7518ff75ddd516c234a4ee351037b05e6a587", size = 26916715, upload-time = "2026-01-11T09:57:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/d2/c4/0b49cf730d0ae8cda925402f18ae814aef351f5772d14da72dd87ff66448/av-16.1.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:408dbe6a2573ca58a855eb8cd854112b33ea598651902c36709f5f84c991ed8e", size = 21452167, upload-time = "2026-01-11T09:57:50.606Z" },
{ url = "https://files.pythonhosted.org/packages/51/23/408806503e8d5d840975aad5699b153aaa21eb6de41ade75248a79b7a37f/av-16.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:57f657f86652a160a8a01887aaab82282f9e629abf94c780bbdbb01595d6f0f7", size = 39215659, upload-time = "2026-01-11T09:57:53.757Z" },
{ url = "https://files.pythonhosted.org/packages/c4/19/a8528d5bba592b3903f44c28dab9cc653c95fcf7393f382d2751a1d1523e/av-16.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:adbad2b355c2ee4552cac59762809d791bda90586d134a33c6f13727fb86cb3a", size = 40874970, upload-time = "2026-01-11T09:57:56.802Z" },
{ url = "https://files.pythonhosted.org/packages/e8/24/2dbcdf0e929ad56b7df078e514e7bd4ca0d45cba798aff3c8caac097d2f7/av-16.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f42e1a68ec2aebd21f7eb6895be69efa6aa27eec1670536876399725bbda4b99", size = 40530345, upload-time = "2026-01-11T09:58:00.421Z" },
{ url = "https://files.pythonhosted.org/packages/54/27/ae91b41207f34e99602d1c72ab6ffd9c51d7c67e3fbcd4e3a6c0e54f882c/av-16.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58fe47aeaef0f100c40ec8a5de9abbd37f118d3ca03829a1009cf288e9aef67c", size = 41972163, upload-time = "2026-01-11T09:58:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/fc/7a/22158fb923b2a9a00dfab0e96ef2e8a1763a94dd89e666a5858412383d46/av-16.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:565093ebc93b2f4b76782589564869dadfa83af5b852edebedd8fee746457d06", size = 31729230, upload-time = "2026-01-11T09:58:07.254Z" },
{ url = "https://files.pythonhosted.org/packages/7f/f1/878f8687d801d6c4565d57ebec08449c46f75126ebca8e0fed6986599627/av-16.1.0-cp313-cp313t-macosx_11_0_x86_64.whl", hash = "sha256:574081a24edb98343fd9f473e21ae155bf61443d4ec9d7708987fa597d6b04b2", size = 27008769, upload-time = "2026-01-11T09:58:10.266Z" },
{ url = "https://files.pythonhosted.org/packages/30/f1/bd4ce8c8b5cbf1d43e27048e436cbc9de628d48ede088a1d0a993768eb86/av-16.1.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:9ab00ea29c25ebf2ea1d1e928d7babb3532d562481c5d96c0829212b70756ad0", size = 21590588, upload-time = "2026-01-11T09:58:12.629Z" },
{ url = "https://files.pythonhosted.org/packages/1d/dd/c81f6f9209201ff0b5d5bed6da6c6e641eef52d8fbc930d738c3f4f6f75d/av-16.1.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a84a91188c1071f238a9523fd42dbe567fb2e2607b22b779851b2ce0eac1b560", size = 40638029, upload-time = "2026-01-11T09:58:15.399Z" },
{ url = "https://files.pythonhosted.org/packages/15/4d/07edff82b78d0459a6e807e01cd280d3180ce832efc1543de80d77676722/av-16.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c2cd0de4dd022a7225ff224fde8e7971496d700be41c50adaaa26c07bb50bf97", size = 41970776, upload-time = "2026-01-11T09:58:19.075Z" },
{ url = "https://files.pythonhosted.org/packages/da/9d/1f48b354b82fa135d388477cd1b11b81bdd4384bd6a42a60808e2ec2d66b/av-16.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0816143530624a5a93bc5494f8c6eeaf77549b9366709c2ac8566c1e9bff6df5", size = 41764751, upload-time = "2026-01-11T09:58:22.788Z" },
{ url = "https://files.pythonhosted.org/packages/2f/c7/a509801e98db35ec552dd79da7bdbcff7104044bfeb4c7d196c1ce121593/av-16.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e3a28053af29644696d0c007e897d19b1197585834660a54773e12a40b16974c", size = 43034355, upload-time = "2026-01-11T09:58:26.125Z" },
{ url = "https://files.pythonhosted.org/packages/36/8b/e5f530d9e8f640da5f5c5f681a424c65f9dd171c871cd255d8a861785a6e/av-16.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e3e67144a202b95ed299d165232533989390a9ea3119d37eccec697dc6dbb0c", size = 31947047, upload-time = "2026-01-11T09:58:31.867Z" },
{ url = "https://files.pythonhosted.org/packages/df/18/8812221108c27d19f7e5f486a82c827923061edf55f906824ee0fcaadf50/av-16.1.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:39a634d8e5a87e78ea80772774bfd20c0721f0d633837ff185f36c9d14ffede4", size = 26916179, upload-time = "2026-01-11T09:58:36.506Z" },
{ url = "https://files.pythonhosted.org/packages/38/ef/49d128a9ddce42a2766fe2b6595bd9c49e067ad8937a560f7838a541464e/av-16.1.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:0ba32fb9e9300948a7fa9f8a3fc686e6f7f77599a665c71eb2118fdfd2c743f9", size = 21460168, upload-time = "2026-01-11T09:58:39.231Z" },
{ url = "https://files.pythonhosted.org/packages/e6/a9/b310d390844656fa74eeb8c2750e98030877c75b97551a23a77d3f982741/av-16.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ca04d17815182d34ce3edc53cbda78a4f36e956c0fd73e3bab249872a831c4d7", size = 39210194, upload-time = "2026-01-11T09:58:42.138Z" },
{ url = "https://files.pythonhosted.org/packages/0c/7b/e65aae179929d0f173af6e474ad1489b5b5ad4c968a62c42758d619e54cf/av-16.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ee0e8de2e124a9ef53c955fe2add6ee7c56cc8fd83318265549e44057db77142", size = 40811675, upload-time = "2026-01-11T09:58:45.871Z" },
{ url = "https://files.pythonhosted.org/packages/54/3f/5d7edefd26b6a5187d6fac0f5065ee286109934f3dea607ef05e53f05b31/av-16.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22bf77a2f658827043a1e184b479c3bf25c4c43ab32353677df2d119f080e28f", size = 40543942, upload-time = "2026-01-11T09:58:49.759Z" },
{ url = "https://files.pythonhosted.org/packages/1b/24/f8b17897b67be0900a211142f5646a99d896168f54d57c81f3e018853796/av-16.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2dd419d262e6a71cab206d80bbf28e0a10d0f227b671cdf5e854c028faa2d043", size = 41924336, upload-time = "2026-01-11T09:58:53.344Z" },
{ url = "https://files.pythonhosted.org/packages/1c/cf/d32bc6bbbcf60b65f6510c54690ed3ae1c4ca5d9fafbce835b6056858686/av-16.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:53585986fd431cd436f290fba662cfb44d9494fbc2949a183de00acc5b33fa88", size = 31735077, upload-time = "2026-01-11T09:58:56.684Z" },
{ url = "https://files.pythonhosted.org/packages/53/f4/9b63dc70af8636399bd933e9df4f3025a0294609510239782c1b746fc796/av-16.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:76f5ed8495cf41e1209a5775d3699dc63fdc1740b94a095e2485f13586593205", size = 27014423, upload-time = "2026-01-11T09:58:59.703Z" },
{ url = "https://files.pythonhosted.org/packages/d1/da/787a07a0d6ed35a0888d7e5cfb8c2ffa202f38b7ad2c657299fac08eb046/av-16.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:8d55397190f12a1a3ae7538be58c356cceb2bf50df1b33523817587748ce89e5", size = 21595536, upload-time = "2026-01-11T09:59:02.508Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f4/9a7d8651a611be6e7e3ab7b30bb43779899c8cac5f7293b9fb634c44a3f3/av-16.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:9d51d9037437218261b4bbf9df78a95e216f83d7774fbfe8d289230b5b2e28e2", size = 40642490, upload-time = "2026-01-11T09:59:05.842Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e4/eb79bc538a94b4ff93cd4237d00939cba797579f3272490dd0144c165a21/av-16.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0ce07a89c15644407f49d942111ca046e323bbab0a9078ff43ee57c9b4a50dad", size = 41976905, upload-time = "2026-01-11T09:59:09.169Z" },
{ url = "https://files.pythonhosted.org/packages/5e/f5/f6db0dd86b70167a4d55ee0d9d9640983c570d25504f2bde42599f38241e/av-16.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cac0c074892ea97113b53556ff41c99562db7b9f09f098adac1f08318c2acad5", size = 41770481, upload-time = "2026-01-11T09:59:12.74Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/33651d658e45e16ab7671ea5fcf3d20980ea7983234f4d8d0c63c65581a5/av-16.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7dec3dcbc35a187ce450f65a2e0dda820d5a9e6553eea8344a1459af11c98649", size = 43036824, upload-time = "2026-01-11T09:59:16.507Z" },
{ url = "https://files.pythonhosted.org/packages/83/41/7f13361db54d7e02f11552575c0384dadaf0918138f4eaa82ea03a9f9580/av-16.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6f90dc082ff2068ddbe77618400b44d698d25d9c4edac57459e250c16b33d700", size = 31948164, upload-time = "2026-01-11T09:59:19.501Z" },
]
[[package]]
name = "av"
version = "18.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'darwin'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'darwin'",
"python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
"(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"(python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
"python_full_version == '3.11.*' and sys_platform == 'darwin'",
"python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version == '3.11.*' and sys_platform == 'win32'",
"python_full_version == '3.11.*' and sys_platform == 'emscripten'",
"(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba", size = 4340222, upload-time = "2026-07-02T06:37:58.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25", size = 22499354, upload-time = "2026-07-02T06:36:58.751Z" },
{ url = "https://files.pythonhosted.org/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484", size = 18175248, upload-time = "2026-07-02T06:37:01.741Z" },
{ url = "https://files.pythonhosted.org/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0", size = 33387843, upload-time = "2026-07-02T06:37:05.097Z" },
{ url = "https://files.pythonhosted.org/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017", size = 35536910, upload-time = "2026-07-02T06:37:08.806Z" },
{ url = "https://files.pythonhosted.org/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f", size = 38984619, upload-time = "2026-07-02T06:37:11.861Z" },
{ url = "https://files.pythonhosted.org/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1", size = 34451176, upload-time = "2026-07-02T06:37:15.154Z" },
{ url = "https://files.pythonhosted.org/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44", size = 36619869, upload-time = "2026-07-02T06:37:18.495Z" },
{ url = "https://files.pythonhosted.org/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629", size = 27556236, upload-time = "2026-07-02T06:37:21.388Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6", size = 20221133, upload-time = "2026-07-02T06:37:23.88Z" },
{ url = "https://files.pythonhosted.org/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5", size = 22740741, upload-time = "2026-07-02T06:37:26.659Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8", size = 18384189, upload-time = "2026-07-02T06:37:29.518Z" },
{ url = "https://files.pythonhosted.org/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e", size = 36749881, upload-time = "2026-07-02T06:37:33.096Z" },
{ url = "https://files.pythonhosted.org/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe", size = 38645927, upload-time = "2026-07-02T06:37:37.086Z" },
{ url = "https://files.pythonhosted.org/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b", size = 40454783, upload-time = "2026-07-02T06:37:40.904Z" },
{ url = "https://files.pythonhosted.org/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f", size = 37573117, upload-time = "2026-07-02T06:37:44.856Z" },
{ url = "https://files.pythonhosted.org/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b", size = 39669026, upload-time = "2026-07-02T06:37:48.761Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b", size = 28448336, upload-time = "2026-07-02T06:37:52.477Z" },
{ url = "https://files.pythonhosted.org/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c", size = 21377289, upload-time = "2026-07-02T06:37:55.935Z" },
]
[[package]]
name = "c2pa-python"
version = "0.37.1"
@@ -3190,6 +3295,8 @@ name = "remove-ai-watermarks"
version = "0.20.2"
source = { editable = "." }
dependencies = [
{ name = "av", version = "16.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "av", version = "18.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "c2pa-python" },
{ name = "click" },
{ name = "numpy" },
@@ -3272,6 +3379,8 @@ trustmark = [
[package.metadata]
requires-dist = [
{ name = "accelerate", marker = "extra == 'gpu'", specifier = ">=0.25.0" },
{ name = "av", marker = "python_full_version < '3.11'", specifier = ">=16,<17" },
{ name = "av", marker = "python_full_version >= '3.11'", specifier = ">=18,<19" },
{ name = "c2pa-python", specifier = ">=0.35.0" },
{ name = "click", specifier = ">=8.0.0" },
{ name = "diffsynth", marker = "extra == 'qwen-zimage'", specifier = ">=2.0.17,<3" },