fix(metadata): strip bare AIGC in APP11 and AIGC in a standard PNG text chunk

Full-corpus strip audit surfaced 24 china_aigc survivors on real uploads:
- 19 JPEG carried the bare AIGC{...} blob in APP11 (0xEB). That marker's branch
  in _jpeg_app_carries_ai only tested for a C2PA/JUMBF manifest and RETURNED, so
  a bare AIGC there slipped past the generic AIGC check. The specific
  C2PA(APP11)/XMP(APP1)/IPTC(APP13) checks now fall through to the generic
  _is_aigc_exif_value drop, which runs for every APP marker they did not claim.
- PNG carried the {"AIGC":{...}} block in a STANDARD text chunk (Description).
  _is_ai_key keeps that key, so removal now also drops a text value carrying an
  AIGC block (_is_aigc_exif_value broadened to accept str; wired into the PNG
  re-save value filter), in parity with aigc_label's detection.

Verified on the corpus: decodable china_aigc survivors 27 -> 0; the 3 remaining
are truncated files the strip fail-safe (v0.15.1) copies through by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-07-13 12:27:05 +03:00
co-authored by Claude Opus 4.8
parent 72f6734b45
commit 2bdaa09e5f
3 changed files with 77 additions and 25 deletions
+38 -3
View File
@@ -1067,16 +1067,19 @@ class TestAIGCLabel:
assert aigc_label(out) is None
assert not has_ai_metadata(out)
def _aigc_bare_jpeg(self, tmp_path: Path, producer: str = "00119144030008867405X210002") -> Path:
def _aigc_bare_jpeg(
self, tmp_path: Path, producer: str = "00119144030008867405X210002", marker: bytes = b"\xff\xe9"
) -> Path:
"""Some China-served generators glue the TC260 label straight to its JSON
as a bare ``AIGC{...}`` blob inside a JPEG APP segment (no ``"AIGC":``
key wrapper, no PNG chunk, no namespaced XMP) -- seen near the JFIF
header on real 2026-06 downloads."""
header on real 2026-06 downloads. ``marker`` selects the APP segment
(default APP9; the real corpus also uses APP11)."""
p = tmp_path / "aigc_bare.jpg"
Image.new("RGB", (32, 32)).save(p)
raw = p.read_bytes()
blob = b'AIGC{"Label":"1","ContentProducer":"' + producer.encode() + b'","ProduceID":"8F995586"}'
segment = b"\xff\xe9" + (len(blob) + 2).to_bytes(2, "big") + blob # APP9
segment = marker + (len(blob) + 2).to_bytes(2, "big") + blob
p.write_bytes(raw[:2] + segment + raw[2:]) # splice after SOI
return p
@@ -1103,6 +1106,38 @@ class TestAIGCLabel:
assert aigc_label(out) is None
assert not has_ai_metadata(out)
def test_remove_strips_bare_aigc_in_app11(self, tmp_path: Path):
"""Regression (real corpus, 19/27 survivors): the bare ``AIGC{...}`` blob lives
in APP11 (0xEB) on many China gens. That marker's branch in _jpeg_app_carries_ai
only checked for a C2PA/JUMBF manifest and RETURNED, so the AIGC blob slipped past
the generic check -> survived the strip. The specific checks must fall through to
the generic AIGC check."""
from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata
src = self._aigc_bare_jpeg(tmp_path, marker=b"\xff\xeb") # APP11
assert aigc_label(src) is not None
out = tmp_path / "clean.jpg"
remove_ai_metadata(src, out)
assert aigc_label(out) is None
assert not has_ai_metadata(out)
def test_remove_strips_aigc_in_png_text_chunk(self, tmp_path: Path):
"""Regression (real corpus, 2 survivors): the TC260 ``{"AIGC":{...}}`` block in a
STANDARD PNG text chunk (Description) -- _is_ai_key keeps that key, so removal
must also drop it on the VALUE carrying an AIGC block."""
from PIL.PngImagePlugin import PngInfo
from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata
p = tmp_path / "aigc_desc.png"
info = PngInfo()
info.add_text("Description", '{"AIGC":{"Label":"1","ContentProducer":"00119144030008867405X210002"}}')
Image.new("RGB", (32, 32)).save(p, pnginfo=info)
assert aigc_label(p) is not None
out = tmp_path / "clean.png"
remove_ai_metadata(p, out)
assert aigc_label(out) is None
def test_bare_aigc_without_tc260_field_ignored(self, tmp_path: Path):
"""A bare ``AIGC{...}`` blob with no TC260 field must not false-positive."""
from remove_ai_watermarks.metadata import aigc_label