mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 15:36:01 +02:00
feat: complete product video watermark pipeline
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
+94
-19
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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] = []
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user