feat(metadata): blank AI-generator tokens in AVIF/HEIF Exif meta-box items

Closes a documented coverage gap (P2#9): an AI Software/Make/Artist/ImageDescription
token in an EXIF item (its TIFF bytes live in mdat/idat) survived remove_ai_metadata
because the top-level box stripper and (absent pillow-heif) the PIL EXIF reader can't
reach it. New isobmff.blank_ai_exif_tokens finds EXIF TIFF blocks by their II/MM
byte-order header, validates each with piexif (a coincidental II/MM run in pixels
won't parse as a TIFF IFD, so it's ignored), and overwrites any AI_GENERATOR_TOKENS-
bearing value with same-length spaces -- so box sizes and iloc offsets stay valid and
the coded image is untouched (mirrors blank_ai_xmp_packets; no iinf/iloc surgery, no
exiftool dep). Camera/editor EXIF without an AI token is preserved. Wired into
remove_ai_metadata's ISOBMFF path. Covers the realistic AI-generator-token case; xAI-
signature-in-meta-box-EXIF (Grok is JPEG-only) stays out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-06-19 10:43:35 -07:00
co-authored by Claude Opus 4.8
parent 3f5d6a0af1
commit d5845a72f3
6 changed files with 141 additions and 12 deletions
+19
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import shutil
import struct
import subprocess
from pathlib import Path
@@ -125,6 +126,24 @@ class TestHasAiMetadata:
assert not has_ai_metadata(out)
def test_remove_ai_metadata_blanks_exif_token_item_in_avif(self, tmp_path: Path):
"""End-to-end: ``remove_ai_metadata`` blanks an AI-generator EXIF token
stored as a meta-box Exif item (bytes in mdat) without re-encoding."""
from remove_ai_watermarks.metadata import remove_ai_metadata
ftyp = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avifmif1"
blob = piexif.dump({"0th": {piexif.ImageIFD.Software: b"Midjourney", piexif.ImageIFD.Make: b"NIKON"}})
mdat = struct.pack(">I", 8 + len(blob)) + b"mdat" + blob
src = tmp_path / "in.avif"
src.write_bytes(ftyp + mdat)
out = tmp_path / "out.avif"
remove_ai_metadata(src, out)
cleaned = out.read_bytes()
assert len(cleaned) == len(ftyp + mdat) # in place, no re-encode
assert b"Midjourney" not in cleaned # AI token gone
assert b"NIKON" in cleaned # camera tag preserved
def test_detects_iptc_trained_algorithmic_media_marker(self, tmp_path: Path):
"""Some pipelines embed only the IPTC AI marker in XMP, no C2PA manifest."""
path = tmp_path / "fake.jpg"