From 9b656513f178bc06031c322fdc71680915fa7834 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Sat, 8 Aug 2026 19:34:40 -0700 Subject: [PATCH] Improve C2PA attribution and ByteDance metadata handling --- docs/module-internals.md | 10 ++ docs/supported-signals.md | 5 +- docs/watermarking-landscape.md | 7 + .../_internal/constants.py | 9 ++ src/remove_ai_watermarks/identify.py | 21 ++- src/remove_ai_watermarks/metadata.py | 96 ++++++++++- src/remove_ai_watermarks/metadata_record.py | 9 +- tests/test_identify.py | 32 ++++ tests/test_metadata.py | 153 ++++++++++++++++++ tests/test_metadata_record.py | 19 +++ 10 files changed, 354 insertions(+), 7 deletions(-) diff --git a/docs/module-internals.md b/docs/module-internals.md index a3d5645..4ceb242 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -367,6 +367,11 @@ for partial and synthetic fixtures that the official reader rejects. Vendor attribution comes from the registry in [`_internal/constants.py`](../src/remove_ai_watermarks/_internal/constants.py). Derived issuer and platform maps should not be maintained separately. +For an AI C2PA claim, a recognized product in `claim_generator` takes precedence +over the certificate issuer: an application can sign through an upstream model +provider without becoming that provider's product. Only exact product mappings +receive this precedence; an unknown claim generator still falls back to issuer +attribution. ### Metadata scanning and stripping @@ -387,6 +392,11 @@ Key contracts: "the credentials could not be read", and the second silently downgrades a verdict. - JPEG stripping walks metadata segments and preserves the entropy-coded image scan. +- Exact app-export JSON disclosures in EXIF `ImageDescription` or `UserComment` + share one parser. Known AI-product provenance is removable without by itself + asserting that the pixels were generated; explicit `aigc_info` discriminator + values and Dreamina `exportType=generation` do assert AI origin. Ordinary + Aweme, retouch, and `lv` editor exports are preserved. - ISOBMFF containers use [`_internal/isobmff.py`](../src/remove_ai_watermarks/_internal/isobmff.py). - Native MP4/MOV TC260 `AIGC` entries are read from diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 88d18f8..752fb40 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -67,6 +67,9 @@ The inspection and stripping code handles signals in these groups: - C2PA Content Credentials and supported cloud manifest references; - EXIF and XMP generator fields; +- exact app-export provenance and AIGC disclosures from supported + ByteDance-family products, with product-only provenance excluded from the + generated-image verdict; - IPTC AI disclosure fields; - PNG text chunks and embedded generation parameters; - China TC260 AIGC labels in supported image placements and the normative @@ -153,7 +156,7 @@ not a universal clean verdict. | FLUX | None registered | Diffusion regeneration; optional open decoder | C2PA for supported sources | | Adobe Firefly | None registered | No proprietary local decoder | C2PA; optional TrustMark decoder | | Midjourney | None registered | No registered pixel decoder | EXIF, XMP, and IPTC signals | -| ByteDance generators | Doubao and Jimeng marks | No registered pixel decoder | TC260 AIGC and supported C2PA signals | +| ByteDance generators | Doubao and Jimeng marks | No registered pixel decoder | TC260 AIGC, supported C2PA, and exact app-export AIGC disclosures | | Qwen | Qwen mark | No registered pixel decoder | TC260 AIGC | | Kling | Kling image and video marks | No registered pixel decoder | TC260 AIGC | | Hailuo / MiniMax video | Hailuo composite video label | No registered pixel decoder | TC260 AIGC where present | diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index 586ba57..86ff339 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -22,8 +22,15 @@ icon-only variants need separate calibrated silhouettes. **Black Forest Labs (FLUX)** API output signs C2PA: `claim_generator_info "Black Forest Labs API"` + a `c2pa.ai_generated_content` assertion + `trainedAlgorithmicMedia` (issuer `b"Black Forest Labs"` added to `C2PA_ISSUERS`, platform "Black Forest Labs (FLUX)"). +Some applications sign C2PA through an upstream model or infrastructure +provider. For AI claims, exact product mappings in `claim_generator` therefore +take precedence over issuer attribution. Supported mappings include Higgsfield +AI, Topaz Labs Image API, and TikTok Ad Creative Toolbox; an unknown claim +generator still falls back to the certificate issuer. + **ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the same content as **"Byteplus Pte. Ltd."**. The bare `volcengine` needle missed it, so BytePlus output was mis-attributed to "Adobe Firefly" through an incidental "Adobe XMP" toolkit string. Issuer `b"Byteplus"` now maps directly to "BytePlus (ByteDance)". ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **no `trainedAlgorithmicMedia`**. Issuer `b"Dreamina"` maps to "ByteDance (Dreamina)" with **`asserts_ai=True`**. Registering the broader **issuer** `b"Bytedance Pte"` was deliberately avoided because that same entity also signs non-AI CapCut edits; keying on the `Dreamina` generator token is precise. - **EXIF/XMP/PNG-text generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal). Additional verified generator stamps include **NovelAI** (`Software`, `Source`, and `Title` PNG text chunks), **Reve** (`Software` or XMP `CreatorTool` = `reve.com`), and **Aphrodite AI** (`Make` or `Software` = `Aphrodite AI`). +- **App-export provenance and AIGC JSON:** supported ByteDance-family exports can place a JSON object in EXIF `ImageDescription` or `UserComment`, independently of C2PA or TC260. Exact `product` values for Doubao, Xinghui, and Dreamina are removable product provenance, but do not alone prove that the pixels were generated. Dreamina additionally requires `exportType=generation` for that verdict. A nested Aweme `aigc_type=1` or private ByteDance `aigc_label_type=1` / `2` is an AIGC disclosure; `0` is inconclusive and can occur on a Dreamina generation export. Plain Aweme, retouch, and `lv` exports are preserved. The lower-case private field is deliberately not interpreted as the normative TC260 `Label`, whose values `1` / `2` / `3` mean generated / possibly generated / suspected generated under [GB 45438-2025](https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf). - **xAI / Grok — its own EXIF signature scheme, NOT C2PA (DETECTED by `metadata.xai_signature`, built 2026-05-26).** Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC** — only EXIF `Artist` = a UUID and EXIF `ImageDescription` = `Signature: ` (a crypto signature, unverifiable locally without xAI's public key). This empirically kills the earlier unverified "xAI signs C2PA as xAI" lead — xAI is not even a C2PA member. `exif_generator` misses it (neither field holds an `AI_GENERATOR_TOKENS` token), so a dedicated detector `xai_signature(path)` matches the pair (`ImageDescription ~ ^Signature: [A-Za-z0-9+/=]{64,}` AND UUID `Artist`); wired into `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify` (signal `xai_signature`, platform "xAI (Grok / Aurora)"). diff --git a/src/remove_ai_watermarks/_internal/constants.py b/src/remove_ai_watermarks/_internal/constants.py index c55ed23..01c3482 100644 --- a/src/remove_ai_watermarks/_internal/constants.py +++ b/src/remove_ai_watermarks/_internal/constants.py @@ -103,6 +103,15 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = ( C2PA_ISSUERS = {vendor.issuer: vendor.org for vendor in C2PA_AI_VENDORS} C2PA_IDENTITY_AI_ORGS = frozenset(vendor.org for vendor in C2PA_AI_VENDORS if vendor.asserts_ai) +# Product-specific claim generators can sign through a different upstream issuer. +# Keep this attribution beside the issuer registry so every C2PA consumer has one +# canonical source rather than maintaining a derived product map in identify.py. +C2PA_CLAIM_GENERATOR_PLATFORMS: tuple[tuple[str, str], ...] = ( + ("higgsfield ai", "Higgsfield AI"), + ("topaz labs image api", "Topaz Labs"), + ("tiktok ad creative toolbox", "TikTok Ad Creative Toolbox"), +) + C2PA_AI_TOOLS = { token.encode(): label for token, label in ( diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index f2cf6df..d765028 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -37,6 +37,7 @@ from remove_ai_watermarks._internal.c2pa import ( from remove_ai_watermarks._internal.constants import ( C2PA_AI_TOOLS, C2PA_AI_VENDORS, + C2PA_CLAIM_GENERATOR_PLATFORMS, C2PA_IDENTITY_AI_ORGS, C2PA_ISSUERS, ) @@ -260,6 +261,7 @@ def _external_exif_generator(pairs: list[tuple[str, Any]], scan: bytes) -> str | "title", "description", "creatortool", + "usercomment", } candidates = [ _external_text(value) @@ -691,6 +693,14 @@ def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None return None +def _claim_generator_platform(generator: str | None) -> str | None: + """Resolve a distinctive C2PA claim generator to its user-facing product.""" + if not generator: + return None + lowered = generator.lower() + return next((platform for token, platform in C2PA_CLAIM_GENERATOR_PLATFORMS if token in lowered), None) + + # Coarse origin-vendor normalization for integrity-clash detection. Two signals # that resolve to the SAME key are consistent (a C2PA "Google (Gemini)" issuer # and Google SynthID provenance, or Adobe Firefly + its Adobe TrustMark soft @@ -1103,7 +1113,16 @@ def _identify_from_evidence( # ASUS Gallery), with the issuer byte-scan only as fallback. The issuer scan # alone mis-attributed real samples (Leica->Truepic timestamp authority, # Nikon->Adobe namespace, Pixel->Google Gemini) -- the token scans fix that. - platform = (camera_label or signer_label or _attribute_platform(issuers, is_ai=c2pa_is_ai)) if has_c2pa else None + platform = ( + ( + camera_label + or signer_label + or (_claim_generator_platform(generator) if c2pa_is_ai else None) + or _attribute_platform(issuers, is_ai=c2pa_is_ai) + ) + if has_c2pa + else None + ) if has_c2pa: detail = ", ".join(filter(None, [", ".join(issuers), generator, info.get("source_type")])) signals.append(Signal("c2pa", detail or "C2PA manifest present", "high")) diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index 23ff68f..50f146e 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -238,7 +238,8 @@ def _is_ai_value(value: str) -> bool: from remove_ai_watermarks._internal.constants import AI_GENERATOR_TOKENS value_lower = value.lower() - return any(token in value_lower for token in AI_GENERATOR_TOKENS) + provenance, generator = _app_metadata_evidence(value) + return provenance is not None or generator is not None or any(token in value_lower for token in AI_GENERATOR_TOKENS) def _png_late_metadata(image_path: Path, window: int) -> bytes: @@ -477,6 +478,9 @@ def has_ai_metadata(image_path: Path) -> bool: for key in img.info: if isinstance(key, str) and _is_ai_key(key): return True + exif_bytes = img.info.get("exif") + if exif_bytes and any(_app_metadata_evidence(exif_bytes)): + return True except Exception as exc: logger.debug("PIL could not open %s for metadata scan: %s", image_path, exc) @@ -500,6 +504,8 @@ def has_ai_metadata(image_path: Path) -> bool: # IPTC 2025.1 AI-disclosure XMP properties (their presence flags AI content). if any(marker in data for marker in IPTC_AI_FIELD_MARKERS): return True + if any(_app_metadata_evidence(data)): + return True # China TC260 AIGC label as a PNG text chunk (the byte scan above catches # only the XMP form; the raw-JSON tEXt chunk needs the PIL-based parse). if aigc_label(image_path) is not None: @@ -830,16 +836,79 @@ def generator_from_metadata(candidates: Iterable[str], scan: bytes = b"") -> str """Return a known AI generator from collected EXIF, PNG, or XMP values.""" from remove_ai_watermarks._internal.constants import AI_GENERATOR_TOKENS + if app_generator := app_generator_from_metadata(scan): + return app_generator + creator_tools = ( match.group(1).decode("latin1", "replace") for match in re.finditer(rb"CreatorTool[>\"'=\s]{1,4}([^<\"']{1,80})", scan) ) for value in itertools.chain(candidates, creator_tools): + if app_generator := app_generator_from_metadata(value): + return app_generator if any(token in value.lower() for token in AI_GENERATOR_TOKENS): return value.strip() return None +_APP_PROVENANCE_PRODUCTS: dict[str, str] = { + "doubao": "ByteDance Doubao", + "xinghui": "ByteDance Xinghui", + "dreamina": "ByteDance Dreamina", + "dreamina_oversea": "ByteDance Dreamina", +} +_APP_PRODUCT_RE = re.compile(r'"product"\s*:\s*"([a-z0-9_]+)"', re.IGNORECASE) +_APP_EXPORT_TYPE_RE = re.compile(r'"exportType"\s*:\s*"([a-z0-9_]+)"', re.IGNORECASE) +_APP_AIGC_LABEL_RE = re.compile(r'"aigc_label_type"\s*:\s*[12](?=\s*[,}])', re.IGNORECASE) +_APP_AIGC_TYPE_RE = re.compile(r'"aigc_type"\s*:\s*1(?=\s*[,}])', re.IGNORECASE) + + +def _normalized_app_metadata(value: str | bytes) -> str: + text = value.decode("latin-1", "ignore") if isinstance(value, bytes) else value + return text.replace('\\"', '"') + + +def _app_metadata_evidence(value: str | bytes) -> tuple[str | None, str | None]: + """Return removable product provenance and stronger AI-origin evidence.""" + normalized = _normalized_app_metadata(value) + products = tuple(match.group(1).lower() for match in _APP_PRODUCT_RE.finditer(normalized)) + provenance = next( + (_APP_PROVENANCE_PRODUCTS[product] for product in products if product in _APP_PROVENANCE_PRODUCTS), None + ) + export_types = {match.group(1).lower() for match in _APP_EXPORT_TYPE_RE.finditer(normalized)} + + generator = None + if set(products).intersection({"dreamina", "dreamina_oversea"}) and "generation" in export_types: + generator = "ByteDance Dreamina" + elif '"aigc_info"' in normalized.lower(): + if "aweme" in products and _APP_AIGC_TYPE_RE.search(normalized): + generator = "ByteDance Aweme AI" + elif _APP_AIGC_LABEL_RE.search(normalized): + generator = "Embedded app AIGC disclosure" + return provenance, generator + + +def app_provenance_from_metadata(value: str | bytes) -> str | None: + """Return exact AI-product provenance that is safe to scrub. + + An exporting product does not by itself prove that the pixels were generated. + That stronger interpretation stays in :func:`app_generator_from_metadata`. + """ + return _app_metadata_evidence(value)[0] + + +def app_generator_from_metadata(value: str | bytes) -> str | None: + """Identify exact app-export AI disclosures embedded in JSON-shaped metadata. + + ByteDance-family apps write a second provenance object beside C2PA or TC260. + Its keys are ordinary EXIF fields, so generic key matching misses it and a + metadata-preserving JPEG scrub keeps it. Only explicit AIGC discriminators or + a Dreamina generation export assert AI origin. Product provenance alone remains + removable without changing the image's origin verdict. + """ + return _app_metadata_evidence(value)[1] + + def exif_generator(image_path: Path) -> str | None: """Return an AI-generator name from the EXIF ``Software`` / XMP ``CreatorTool`` field (or a PNG text chunk), if it matches a known generator (see @@ -870,7 +939,9 @@ def exif_generator(image_path: Path) -> str | None: if isinstance(value, str) and value: candidates.append(value) if exif_bytes: - tags = piexif.load(exif_bytes).get("0th", {}) + loaded = piexif.load(exif_bytes) + tags = loaded.get("0th", {}) + exif_tags: dict[int, Any] = loaded.get("Exif") or {} # Make catches camera-style tags AI tools reuse (Ideogram writes # Make="Ideogram AI"); real cameras put "Apple"/"Canon" there, which # carry no AI token, so this stays low-false-positive. @@ -883,6 +954,9 @@ def exif_generator(image_path: Path) -> str | None: value = tags.get(tag) if isinstance(value, bytes): candidates.append(value.decode("latin1", "replace")) + user_comment = exif_tags.get(piexif.ExifIFD.UserComment) + if isinstance(user_comment, bytes): + candidates.append(user_comment.decode("latin1", "replace")) except Exception as exc: # unopenable format / malformed EXIF logger.debug("EXIF generator read failed for %s: %s", image_path, exc) @@ -1009,6 +1083,15 @@ def _ai_exif_targets(loaded: dict[str, Any]) -> list[tuple[str, int, bytes, str] add("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription") if _is_aigc_exif_value(ifde.get(piexif.ExifIFD.UserComment)): add("Exif", ifde, piexif.ExifIFD.UserComment, "UserComment") + # (d) ByteDance-family app JSON. Exact AI-product provenance is removable even + # when it does not by itself assert generated pixels. Ordinary Aweme/retouch/lv + # exports remain untouched. + for ifd_key, ifd, tag, name in ( + ("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription"), + ("Exif", ifde, piexif.ExifIFD.UserComment, "UserComment"), + ): + if any(_app_metadata_evidence(ifd.get(tag, b""))): + add(ifd_key, ifd, tag, name) return targets @@ -1087,6 +1170,13 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]: producer = aigc.get("ContentProducer", "") result["aigc_label"] = f"China AIGC label (TC260){f'; producer {producer}' if producer else ''}" + app_scan = scan_head(image_path) + app_provenance, app_generator = _app_metadata_evidence(app_scan) + if app_provenance: + result.setdefault("app_provenance", f"App export provenance ({app_provenance})") + if app_generator: + result.setdefault("app_aigc", f"App AIGC disclosure ({app_generator})") + # xAI / Grok EXIF signature scheme (its only provenance signal). if xai_signature(image_path): result.setdefault("xai_signature", "xAI/Grok EXIF signature (Artist UUID + Signature blob)") @@ -1181,7 +1271,7 @@ def _jpeg_app_carries_ai(marker: int, payload: bytes) -> bool: # (detection<->removal parity). Skip APP1-EXIF (0xE1 ``Exif``): its camera tags are # scrubbed tag-by-tag via piexif, not dropped wholesale. if not (marker == 0xE1 and payload.startswith(b"Exif")): - return _is_aigc_exif_value(payload) + return _is_aigc_exif_value(payload) or any(_app_metadata_evidence(payload)) return False diff --git a/src/remove_ai_watermarks/metadata_record.py b/src/remove_ai_watermarks/metadata_record.py index ade323b..f289ce2 100644 --- a/src/remove_ai_watermarks/metadata_record.py +++ b/src/remove_ai_watermarks/metadata_record.py @@ -302,12 +302,14 @@ def _exif_pairs(info: dict[str, Any]) -> dict[str, str]: try: import piexif - tags = piexif.load(exif_bytes).get("0th", {}) + loaded = piexif.load(exif_bytes) + tags = loaded.get("0th", {}) + exif_tags = loaded.get("Exif", {}) except Exception as exc: # malformed EXIF logger.debug("EXIF parse failed: %s", exc) return {} - return { + pairs = { name: text for name, tag in ( ("Software", piexif.ImageIFD.Software), @@ -317,6 +319,9 @@ def _exif_pairs(info: dict[str, Any]) -> dict[str, str]: ) if (text := exif_text(tags, tag)) } + if text := exif_text(exif_tags, piexif.ExifIFD.UserComment): + pairs["UserComment"] = text + return pairs def _pil_info(info: dict[str, Any]) -> dict[str, str]: diff --git a/tests/test_identify.py b/tests/test_identify.py index 7df8b7a..02b894f 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -313,6 +313,38 @@ class TestIdentifyNonPng: assert r.is_ai_generated is True assert "ByteDance" in (r.platform or "") + @pytest.mark.parametrize( + ("claim_generator", "platform"), + [ + ("Higgsfield AI", "Higgsfield AI"), + ("Topaz Labs Image API", "Topaz Labs"), + ("TIKTOK AD Creative Toolbox", "TikTok Ad Creative Toolbox"), + ], + ) + def test_claim_generator_wins_over_upstream_issuer( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, claim_generator: str, platform: str + ): + path = tmp_path / "generated.png" + from PIL import Image + + Image.new("RGB", (32, 32)).save(path) + monkeypatch.setattr( + "remove_ai_watermarks.identify.extract_c2pa_info", + lambda _path: { + "has_c2pa": True, + "issuer": "OpenAI", + "claim_generator": claim_generator, + "source_type": "trainedAlgorithmicMedia (AI-generated)", + "ai_source_kind": "generated", + }, + ) + + report = identify(path, check_visible=False, check_invisible=False) + + assert report.is_ai_generated is True + assert report.platform == platform + assert claim_generator in next(s.detail for s in report.signals if s.name == "c2pa") + def test_dreamina_attributed_without_source_type(self, tmp_path: Path): # Dreamina (ByteDance's international Jimeng brand) signs C2PA as # "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and NO diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 00dec60..bd369ff 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -12,6 +12,7 @@ import pytest from PIL import Image from PIL.PngImagePlugin import PngInfo +from remove_ai_watermarks.identify import identify from remove_ai_watermarks.metadata import ( C2PA_UUID, _is_ai_key, @@ -951,6 +952,158 @@ class TestXaiSignature: class TestRemoveAiExif: """remove_ai_metadata scrubs AI-provenance EXIF tags but keeps genuine EXIF.""" + @staticmethod + def _app_aigc_jpeg(tmp_path: Path, name: str, payload: dict) -> Path: + import json + + exif = piexif.dump( + { + "0th": { + piexif.ImageIFD.ImageDescription: json.dumps(payload, separators=(",", ":")).encode(), + piexif.ImageIFD.Make: b"Canon", + }, + "Exif": {}, + "GPS": {}, + "1st": {}, + } + ) + path = tmp_path / name + Image.new("RGB", (64, 64)).save(path, exif=exif) + return path + + @pytest.mark.parametrize( + ("payload", "generator"), + [ + ( + { + "data": { + "product": "dreamina_oversea", + "source_type": "dreamina_oversea", + "exportType": "generation", + } + }, + "Dreamina", + ), + ( + { + "data": { + "product": "aweme", + "aigc_info": { + "aigc_type": 1, + "is_sticker_aigc": 0, + }, + } + }, + "Aweme", + ), + ({"data": {"aigc_info": {"aigc_label_type": 1}}}, "AIGC disclosure"), + ({"data": {"aigc_info": {"aigc_label_type": 2}}}, "AIGC disclosure"), + ], + ) + def test_embedded_app_aigc_detected_and_stripped(self, tmp_path: Path, payload: dict, generator: str): + src = self._app_aigc_jpeg(tmp_path, "app-aigc.jpg", payload) + + assert generator in (exif_generator(src) or "") + assert has_ai_metadata(src) is True + assert "app_aigc" in get_ai_metadata(src) + + out = tmp_path / "clean.jpg" + _, remaining = strip_and_verify(src, out) + + assert remaining == {} + assert exif_generator(out) is None + kept = piexif.load(Image.open(out).info["exif"])["0th"] + assert kept.get(piexif.ImageIFD.ImageDescription) is None + assert kept.get(piexif.ImageIFD.Make) == b"Canon" + + @pytest.mark.parametrize( + "payload", + [ + {"data": {"product": "aweme"}}, + {"data": {"product": "retouch"}}, + {"data": {"product": "lv", "exportType": "image_export"}}, + {"data": {"aigc_info": {"aigc_label_type": 0}}}, + {"data": {"aigc_info": {"aigc_label_type": 9}}}, + {"data": {"aigc_info": {"aigc_label_type": 10}}}, + {"data": {"product": "aweme", "aigc_info": {"aigc_type": 0}}}, + {"data": {"product": "aweme", "aigc_info": {"aigc_type": 2}}}, + {"data": {"product": "aweme", "aigc_info": {"aigc_type": 10}}}, + ], + ) + def test_neighboring_app_metadata_is_preserved(self, tmp_path: Path, payload: dict): + src = self._app_aigc_jpeg(tmp_path, "ordinary-app.jpg", payload) + + assert exif_generator(src) is None + assert has_ai_metadata(src) is False + assert get_ai_metadata(src) == {} + + out = tmp_path / "kept.jpg" + remove_ai_metadata(src, out) + + kept = piexif.load(Image.open(out).info["exif"])["0th"] + assert kept.get(piexif.ImageIFD.ImageDescription) is not None + assert kept.get(piexif.ImageIFD.Make) == b"Canon" + + @pytest.mark.parametrize("product", ["doubao", "xinghui", "dreamina", "dreamina_oversea"]) + def test_ai_product_provenance_is_stripped_without_asserting_generation(self, tmp_path: Path, product: str): + src = self._app_aigc_jpeg(tmp_path, "app-provenance.jpg", {"data": {"product": product}}) + + assert exif_generator(src) is None + assert has_ai_metadata(src) is True + assert "app_provenance" in get_ai_metadata(src) + assert identify(src, check_visible=False, check_invisible=False).is_ai_generated is None + + out = tmp_path / "clean-provenance.jpg" + _, remaining = strip_and_verify(src, out) + + assert remaining == {} + kept = piexif.load(Image.open(out).info["exif"])["0th"] + assert kept.get(piexif.ImageIFD.ImageDescription) is None + assert kept.get(piexif.ImageIFD.Make) == b"Canon" + + @pytest.mark.parametrize("product", ["dreamina", "dreamina_oversea"]) + def test_dreamina_requires_generation_export_for_ai_verdict(self, tmp_path: Path, product: str): + generated = self._app_aigc_jpeg( + tmp_path, + "dreamina-generation.jpg", + {"data": {"product": product, "exportType": "generation"}}, + ) + edited = self._app_aigc_jpeg( + tmp_path, + "dreamina-edit.jpg", + {"data": {"product": product, "exportType": "image_export"}}, + ) + + assert "Dreamina" in (exif_generator(generated) or "") + assert identify(generated, check_visible=False, check_invisible=False).is_ai_generated is True + assert exif_generator(edited) is None + assert has_ai_metadata(edited) is True + + def test_nested_app_aigc_user_comment_is_detected_and_stripped(self, tmp_path: Path): + import json + + payload = {"data": {"aigc_info": json.dumps({"aigc_label_type": 2})}} + exif = piexif.dump( + { + "0th": {piexif.ImageIFD.Make: b"Canon"}, + "Exif": {piexif.ExifIFD.UserComment: json.dumps(payload, separators=(",", ":")).encode()}, + "GPS": {}, + "1st": {}, + } + ) + src = tmp_path / "app-aigc-user-comment.jpg" + Image.new("RGB", (64, 64)).save(src, exif=exif) + + assert has_ai_metadata(src) is True + + out = tmp_path / "clean-user-comment.jpg" + _, remaining = strip_and_verify(src, out) + + assert remaining == {} + kept = piexif.load(Image.open(out).info["exif"]) + assert kept["Exif"].get(piexif.ExifIFD.UserComment) is None + assert kept["0th"].get(piexif.ImageIFD.Make) == b"Canon" + def test_grok_signature_stripped_on_jpeg_output(self, tmp_path: Path): src = _grok_jpeg(tmp_path) assert xai_signature(src) is True diff --git a/tests/test_metadata_record.py b/tests/test_metadata_record.py index f4b7315..88819b8 100644 --- a/tests/test_metadata_record.py +++ b/tests/test_metadata_record.py @@ -89,6 +89,25 @@ class TestRecordReproducesTheFileVerdict: class TestPlacementsTheRecordCouldDrop: + def test_app_aigc_user_comment_survives(self, tmp_path: Path): + """The file and record paths must both inspect EXIF UserComment.""" + import piexif + + path = tmp_path / "app-aigc.jpg" + payload = json.dumps({"data": {"aigc_info": {"aigc_label_type": 1}}}, separators=(",", ":")) + exif = piexif.dump( + { + "0th": {piexif.ImageIFD.Make: b"Canon"}, + "Exif": {piexif.ExifIFD.UserComment: payload.encode()}, + "GPS": {}, + "1st": {}, + } + ) + Image.fromarray(np.zeros((64, 64, 3), dtype=np.uint8)).save(path, "JPEG", exif=exif) + + assert identify(path, check_visible=False, check_invisible=False).is_ai_generated is True + _assert_same_verdict(path) + def test_a_trailer_after_eoi_survives(self, tmp_path: Path): """Samsung Galaxy AI appends its marker past the JPEG EOI, and the value it is gated on can sit further back still. A record that stopped at the last