photo-metadata
Embeds photo IPTC/EXIF/XMP metadata, caption, credit, alt text, license, AI-source label, GPS stripping, and C2PA credentials.
Install
npx skills add https://github.com/jamditis/claude-skills-journalism/tree/master/journalism-core/skills/photo-metadata
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jamditis-claude-skills-journalism@llmmart
git clone https://github.com/jamditis/claude-skills-journalism.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jamditis/claude-skills-journalism collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Photo metadata
Overview
Metadata embedded in an image file travels with the file. Photo CMSs (Photo Mechanic, Lightroom, Capture One) and wire intake systems read a photo's caption, credit, and rights from its IPTC and XMP blocks, not from a separate document or the email it arrived in. If the caption, credit, alt text, and license are not inside the file, they are gone the moment the photo is downloaded, forwarded, or re-uploaded.
One exiftool pass writes the EXIF, IPTC, and XMP layers together and leaves every other tag (camera settings, shot time) untouched. Modern software reads XMP first, legacy IPTC-IIM second, EXIF only for date and GPS, so write XMP everywhere and add IIM as a compatibility copy on JPEG/TIFF (HEIC, AVIF, and WebP have no IIM slot at all; see reference.md).
Two things changed since this workflow was "caption, credit, copyright." First, how an image was made now belongs in the metadata: the IPTC Digital Source Type field labels a camera photo versus an AI-generated one, and platforms (Meta, Google) and the EU AI Act increasingly read it. Second, cryptographic provenance (C2PA / "Content Credentials") is arriving on wire images and cameras, a signed layer exiftool can read but not write. Both are covered below.
A capable model already knows the field names. The hard part is not the mechanics, it is the judgment below. Lead with that.
When to use
- Prepping press photos for a wire so partner newsrooms can search, credit, and republish them
- Adding required photographer attribution and a reuse license before publishing or sharing
- Labeling how an image was made, a straight photo, an AI-generated illustration, an AI-edited composite
- Batch-tagging a shoot (a folder of images)
- Making images accessible (embedded alt text) and rights-clear (copyright or Creative Commons)
- Reading and sanity-checking the C2PA Content Credentials on an image that arrived from an agency
When not to use: editing pixels (this is metadata only); writing alt text for an HTML <img> (use accessibility-compliance); preserving web pages as evidence (use web-archiving); signing a Content Credential (exiftool can't, use c2patool, below).
The discipline (what agents get wrong)
These are the failures a capable agent makes anyway. They matter more than any tag name.
- Caption only what is visible. Describe what the frame shows, not what you were told. Do not infer events, intent, identities, relationships, or legal status you cannot see. "Demonstrators gather to protest a court ruling" is a claim about facts not in the frame; "A crowd holds signs outside a courthouse" is the photo.
- Label people from visible evidence. Name an agency or role only from a visible marking, a labeled vest, a uniform, a badge, a patch. Otherwise write "officers in tactical gear," "a man in a blue shirt." Never assert someone's immigration or legal status (no "detainee," no "undocumented") unless it is unambiguous in the frame.
- Always write alt text, it is not the caption. Write both: a short screen-reader description in
XMP-iptcCore:AltTextAccessibilityand the publishable caption inIPTC:Caption-Abstract. IPTC keeps these deliberately distinct, the caption states facts and is shown on the page; the alt text is read aloud by a screen reader, so do not just copy one into the other. Agents routinely write the caption and skip the alt text. - Label how the image was made, and never lie about it. If an image is AI-generated or AI-edited, say so in
XMP-iptcExt:DigitalSourceType; if it is a straight photo,digitalCapturestates that plainly. Do the honest thing and label it; do the diligent thing and, on an inbound file, never strip an existing Digital Source Type or C2PA credential, that erases a disclosure someone made on purpose. - Strip GPS when the location could endanger someone. A protester, a source, an abuse survivor, a minor, embedded coordinates can reveal a home or a safe house. Remove GPS from the published derivative (
-gps:all=) while keeping the caption and credit; keep a full-GPS archival master only where location is editorial evidence. GPS is the single highest-risk tag in the file. - Keep structured fields neutral. Editorial framing or a contested label belongs in
Headline, never inCity,Caption-Abstract, or the location fields. Partner newsrooms apply their own language; clean structured fields let them. - Verify the round-trip from source. Read the metadata back from the written file, not from your buffer. After any upload or transfer, re-read it from the destination, a 200 response proves the bytes were accepted, not that the metadata survived. Most social platforms re-encode on upload and strip IPTC, XMP, GPS, and C2PA (see
reference.md), so "I embedded it" is not "it arrived."
Quick reference, the fields that carry the weight
| Role | IPTC (IIM) | XMP | EXIF |
|---|---|---|---|
| Photographer | By-line |
dc:Creator |
Artist |
| Credit | Credit (org, max 32 chars) |
photoshop:Credit (full name / org) |
- |
| Caption | Caption-Abstract |
dc:Description |
ImageDescription |
| Alt text (short) | - | iptcCore:AltTextAccessibility |
- |
| Extended description | - | iptcCore:ExtDescrAccessibility (complex images; not the caption) |
- |
| How it was made | - | iptcExt:DigitalSourceType (full CV URI) |
- |
| Keywords | Keywords (repeatable) |
dc:Subject |
- |
| Copyright | CopyrightNotice |
dc:Rights |
Copyright |
| License (CC) | - | xmpRights:Marked/WebStatement/UsageTerms, cc:License (legacy) |
- |
| License / discovery | - | xmpRights:WebStatement, plus:LicensorName/LicensorURL (Google) |
- |
| Headline | Headline |
photoshop:Headline |
- |
| Location | Sub-location/City/Province-State/Country-* |
iptcCore:Location, photoshop:City/State/Country |
- |
| Date | DateCreated |
photoshop:DateCreated |
DateTimeOriginal (source of truth) |
Digital Source Type values (fully AI → trainedAlgorithmicMedia, AI-edited → compositeWithTrainedAlgorithmicMedia, straight photo → digitalCapture), the full IPTC controlled vocabulary, the IPTC-IIM byte limits, the PLUS/Google-licensing and Creative Commons field sets, the C2PA tooling, and the AP caption recipe: see reference.md.
One pass that writes all three layers
CAPTION="A crowd holds signs outside the Mercer County Courthouse, Friday, June 19, 2026, in Trenton, N.J. (Dana Rivera/Example News Collective)"
ALT="A crowd of people holding handmade signs stands on the steps of a stone courthouse."
# how the image was made, a full IPTC CV URI (see reference.md for all values)
DST="http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"
exiftool -codedcharacterset=utf8 -overwrite_original -P \
-EXIF:Artist="Dana Rivera" -XMP-dc:Creator="Dana Rivera" -IPTC:By-line="Dana Rivera" \
-IPTC:Credit="Example News Collective" -XMP-photoshop:Credit="Dana Rivera / Example News Collective" \
-IPTC:Caption-Abstract="$CAPTION" -XMP-dc:Description="$CAPTION" -EXIF:ImageDescription="$CAPTION" \
-XMP-iptcCore:AltTextAccessibility="$ALT" \
-XMP-iptcExt:DigitalSourceType="$DST" \
-IPTC:Keywords="protest" -IPTC:Keywords+="Trenton" \
-XMP-dc:Subject="protest" -XMP-dc:Subject+="Trenton" \
-EXIF:Copyright="(c) 2026 Example News Collective. Licensed CC BY 4.0." \
-IPTC:CopyrightNotice="(c) 2026 Example News Collective. CC BY 4.0." \
-XMP-dc:Rights="(c) 2026 Example News Collective. Licensed CC BY 4.0." \
-XMP-xmpRights:Marked=True \
-XMP-xmpRights:WebStatement="https://creativecommons.org/licenses/by/4.0/" \
-XMP-xmpRights:UsageTerms="Licensed CC BY 4.0. Credit: Dana Rivera / Example News Collective." \
-XMP-cc:License="https://creativecommons.org/licenses/by/4.0/" \
-XMP-cc:AttributionName="Dana Rivera / Example News Collective" \
-IPTC:City="Trenton" -IPTC:Province-State="New Jersey" \
-IPTC:Country-PrimaryLocationName="United States" -IPTC:Country-PrimaryLocationCode="USA" \
"-IPTC:DateCreated<EXIF:DateTimeOriginal" "-IPTC:TimeCreated<EXIF:DateTimeOriginal" \
"-XMP-photoshop:DateCreated<EXIF:DateTimeOriginal" \
photo.jpg
-P preserves the file's modification time; drop it if you want the write to touch the timestamp. Extended accessibility descriptions for complex images (charts, infographics) go in XMP-iptcCore:ExtDescrAccessibility, a separate field from the caption, added only when the alt text plus surrounding text can't convey the image.
Then verify from the file (the step agents skip):
exiftool -G1 -s -IPTC:By-line -IPTC:Caption-Abstract -XMP-iptcCore:AltTextAccessibility \
-XMP-iptcExt:DigitalSourceType -XMP-cc:License -IPTC:Keywords photo.jpg
Label how an image was made (AI and synthetic)
XMP-iptcExt:DigitalSourceType records origin from the IPTC controlled vocabulary. The value is a full URI, exiftool does not validate it, so a bare word or a typo is silently accepted and useless. The three every newsroom needs:
BASE="http://cv.iptc.org/newscodes/digitalsourcetype"
# a straight camera photo, worth stating even for real news images
exiftool -XMP-iptcExt:DigitalSourceType="$BASE/digitalCapture" photo.jpg
# fully AI-generated (a trained model produced the whole image)
exiftool -XMP-iptcExt:DigitalSourceType="$BASE/trainedAlgorithmicMedia" ai.jpg
# a real photo edited with generative AI (inpaint / outpaint / generative fill)
exiftool -XMP-iptcExt:DigitalSourceType="$BASE/compositeWithTrainedAlgorithmicMedia" edited.jpg
Meta and Google read this field to auto-label AI content, and the EU AI Act's machine-readable-disclosure duty (Article 50, enforcement from August 2026) is pushing it from nice-to-have toward required. IPTC 2025.1 adds companion fields, AISystemUsed, AISystemVersionUsed, AIPromptInformation, AIPromptWriterName (exiftool ≥ 13.40). Full vocabulary and the retired terms to avoid: reference.md.
Content Credentials (C2PA): provenance exiftool can read but not sign
A Content Credential is a cryptographically signed C2PA manifest bound to the pixels, who made the image, in what tool, and whether AI was involved, increasingly shipped by cameras (Leica M11-P, Nikon Z6III, Sony Alpha) and agencies (AFP, AP, BBC pilots). It is a different layer from IPTC/XMP and answers a different question: not "what does the file claim" but "who signed this, and has it changed since."
exiftool reads it and cannot write or verify it:
exiftool -G1 -a -jumbf:all incoming.jpg # report the C2PA/JUMBF manifest (no signature check)
That shows the manifest as data, it does not validate the signature or the signer. For a real check, drop the file into verify.contentauthenticity.org and confirm the signer is the agency you expect. To create a credential, use Adobe/CAI tooling, c2patool (brew install c2patool) or pip install c2pa-python, not exiftool.
Writing metadata to a signed file breaks its credential. A C2PA hard binding hashes the asset, and that hash covers the embedded metadata, so any exiftool write, caption, credit, GPS strip, even the tagging in this skill, leaves the manifest present but invalid. "Never strip the credential" is necessary but not sufficient. On an inbound signed file, either leave the original untouched and do your metadata work on a derivative you will re-sign with c2patool, or accept that the embedded credential no longer validates and say so. Do not embed metadata into a signed original and treat its credential as still good.
Two more cautions worth stating to any newsroom: a valid credential proves a signature and a chain, not that the scene is real (a camera will happily sign a photo of a screen), and most social platforms strip the manifest on upload, so on-platform provenance often survives only via "durable" watermark/fingerprint recovery. See reference.md.
Strip GPS for a publish-safe derivative
Remove location without touching the caption, credit, copyright, or source type:
exiftool -gps:all= "-xmp:GPS*=" -overwrite_original photo.jpg
exiftool -a -G1 -gps:all "-xmp:GPS*" photo.jpg # verify, this must print nothing
Use the -xmp:GPS*= wildcard, not just the three main coordinates: destination and image-direction fields (GPSDestLatitude, GPSImgDirection) are also a location and would otherwise survive. Keep the full-GPS file as a locked archival master where coordinates are editorial evidence (geolocation, verification). Publish the stripped copy. embed.py --strip-gps does this for a whole folder after tagging.
Licensing that shows up in search (Google Images)
To earn the Google Images "Licensable" badge and a working "Get this image" link, set the web statement of rights (the trigger) and the PLUS licensor fields:
exiftool -XMP-xmpRights:Marked=True \
-XMP-xmpRights:WebStatement="https://example.org/license/photo123" \
-XMP-plus:LicensorName="Example News" -XMP-plus:LicensorURL="https://example.org/buy/photo123" \
photo.jpg
The web statement is xmpRights:WebStatement, not dc:Rights, a common and costly mix-up. A Creative Commons license routes through the same WebStatement field with the CC deed URL. Details and the full PLUS field set: reference.md.
Batch tagging a folder
For a shoot, drive exiftool from a manifest instead of one command per file. embed.py in this directory takes a folder plus a JSON manifest (constant credit, license, licensor, and Digital Source Type fields, then per-image alt text, caption, extended description, keywords, and an optional per-image source-type override), writes tagged copies, reads each one back to confirm the metadata landed, and, with --strip-gps, removes GPS from the copies. It accepts a Digital Source Type shorthand (digitalCapture) or a full URI and refuses anything else rather than embedding a broken value. Run python3 embed.py --help.
Common mistakes (from baseline testing)
| Mistake | Fix |
|---|---|
| Wrote a caption, no alt text | Always write AltTextAccessibility too, they are different fields |
Copied the caption into the alt text (or ExtDescrAccessibility) |
IPTC keeps these distinct; write a real screen-reader sentence, keep ExtDescr for complex images only |
By-line/Credit/City silently truncated |
Those IIM fields cap at 32 chars; put the full credit in XMP-photoshop:Credit |
| Caption states things not in the frame | Describe only what is visible; move unseeable context out |
| AI-generated image left unlabeled | Set DigitalSourceType to trainedAlgorithmicMedia (or the right composite value) |
DigitalSourceType set to a bare word |
The value must be the full http://cv.iptc.org/... URI; exiftool won't validate it |
| Stripped an inbound file's Digital Source Type or C2PA | Never erase a disclosure, preserve provenance on files you receive |
| Published with GPS still embedded | Strip with -gps:all= when location could endanger a subject or source |
WebStatement put in dc:Rights |
The Google/licensing web statement is xmpRights:WebStatement |
Editorial label in City or caption |
Put framing in Headline; keep structured fields neutral |
| Assumed the upload kept the metadata | Re-read from the destination; most social platforms strip IPTC/XMP/GPS/C2PA |
| Keywords as one comma-joined string | Write repeatable Keywords records (and a dc:Subject list) |
| Set a CC license note in plain text only | Add xmpRights:WebStatement (CC deed URL) + xmpRights:Marked |
Real-world impact
Embedded metadata is what lets a partner newsroom find a photo, credit it correctly, and republish it under a clear license without ever contacting the photographer. It is also, now, where an image says whether a human or a model made it, and where a signed Content Credential travels. Strip it, and the same photo is an orphaned file, no credit, no license, no provenance.
Files (claude-skills-journalism)
-
agents
-
openai.yaml 142 B
interface: display_name: "Photo metadata" short_description: "Embeds photo IPTC/EXIF/XMP metadata, caption, credit, alt text, license…"
-
-
embed.py 22.5 KB
#!/usr/bin/env python3 """Batch-embed IPTC/EXIF/XMP metadata into a folder of photos with exiftool. Reads a JSON manifest of constant fields (byline, credit, license, location) plus per-image alt text, caption, and keywords; writes tagged copies (originals are left untouched by default); then reads each file back to confirm the metadata landed. No network and no credentials: this operates only on local files. Requires exiftool on PATH (https://exiftool.org). See reference.md for the field map and byte limits. Manifest shape: { "constants": { "by_line": "Dana Rivera", "creator": "Dana Rivera", "credit": "Example News", // org only, max 32 chars "credit_full": "Dana Rivera / Example News", // full credit, XMP (no limit) "copyright": "(c) 2026 Example News. CC BY 4.0.", "license_url": "https://creativecommons.org/licenses/by/4.0/", "web_statement": "https://example.org/license/123", // license-details page (Google) "licensor_name": "Example News", // "Get this image on ..." (Google) "licensor_url": "https://example.org/buy/123", "attribution_name": "Dana Rivera / Example News", "attribution_url": "https://example.org", "usage_terms": "Licensed CC BY 4.0. Credit: Dana Rivera/Example News.", "headline": "Editorial framing goes here, not in the caption", "digital_source_type": "digitalCapture", // how the image was made (see below) "sub_location": "Courthouse steps", "city": "Trenton", "state": "New Jersey", "country": "United States", "country_code": "USA" }, "images": { "test-001.jpg": { "alt": "A crowd holding signs stands on courthouse steps.", "caption": "A crowd holds signs ... (Dana Rivera/Example News)", "ext_description": "Longer screen-reader description for a complex image.", "keywords": ["protest", "courthouse", "Trenton"], "digital_source_type": "trainedAlgorithmicMedia" // per-image override of the constant } } } All keys are optional; only fields that are present get written, and the read-back check only requires the fields a given image actually asked for. `digital_source_type` records how the image was made, using the IPTC "Digital Source Type" controlled vocabulary. Pass a known shorthand (below) or a full CV URI; anything else is warned and skipped rather than written as a broken value. A per-image value overrides the constant. Newsroom-relevant values: digitalCapture straight photo from a camera (the news default) computationalCapture in-camera stack, e.g. HDR / night mode (non-AI) humanEdits human retouch/toning with non-generative tools algorithmicallyEnhanced sharpen / denoise, no content change compositeCapture composite whose elements are all real captures compositeSynthetic composite with at least one generative-AI element compositeWithTrainedAlgorithmicMedia real media edited with generative AI (inpaint/outpaint) trainedAlgorithmicMedia fully AI-generated (a trained model) algorithmicMedia pure algorithm, no training data (e.g. a fractal) Usage: python3 embed.py --dir ./photos --manifest manifest.json python3 embed.py --dir ./photos --manifest manifest.json --out ./tagged python3 embed.py --dir ./photos --manifest manifest.json --in-place python3 embed.py --dir ./photos --manifest manifest.json --strip-gps """ import argparse import json import shutil import subprocess import sys from pathlib import Path # constant manifest key -> list of exiftool tags it writes to. # license_url is listed before web_statement so that if both are set, the more specific # web_statement wins the shared WebStatement tag (build_args dedups by tag, last wins). CONST_TAGS = { "by_line": ["-IPTC:By-line=", "-XMP-dc:Creator=", "-EXIF:Artist="], "creator": ["-XMP-dc:Creator=", "-EXIF:Artist="], "credit": ["-IPTC:Credit="], "credit_full": ["-XMP-photoshop:Credit="], "copyright": ["-IPTC:CopyrightNotice=", "-XMP-dc:Rights=", "-EXIF:Copyright="], "license_url": ["-XMP-cc:License=", "-XMP-xmpRights:WebStatement="], "web_statement": ["-XMP-xmpRights:WebStatement="], "licensor_name": ["-XMP-plus:LicensorName="], "licensor_url": ["-XMP-plus:LicensorURL="], "attribution_name": ["-XMP-cc:AttributionName="], "attribution_url": ["-XMP-cc:AttributionURL="], "usage_terms": ["-XMP-xmpRights:UsageTerms=", "-IPTC:SpecialInstructions="], "headline": ["-IPTC:Headline=", "-XMP-photoshop:Headline="], "sub_location": ["-IPTC:Sub-location=", "-XMP-iptcCore:Location="], "city": ["-IPTC:City=", "-XMP-photoshop:City="], "state": ["-IPTC:Province-State=", "-XMP-photoshop:State="], "country": ["-IPTC:Country-PrimaryLocationName=", "-XMP-photoshop:Country="], "country_code": ["-IPTC:Country-PrimaryLocationCode=", "-XMP-iptcCore:CountryCode="], } # The caption is the publishable cutline. It is NOT the accessibility extended # description (XMP-iptcCore:ExtDescrAccessibility), IPTC keeps those distinct, so the # caption is not routed there; use the per-image "ext_description" field for that. CAPTION_TAGS = [ "-IPTC:Caption-Abstract=", "-XMP-dc:Description=", "-EXIF:ImageDescription=", ] # IPTC "Digital Source Type" controlled vocabulary (http://cv.iptc.org/newscodes/ # digitalsourcetype/). exiftool writes DigitalSourceType as an unvalidated string, so a # typo is silently accepted, we expand a known shorthand to the full URI, pass a full # URL through, and refuse anything else. Retired terms (minorHumanEdits, digitalArt, # softwareImage) are intentionally excluded. See reference.md for the full vocabulary. DST_BASE = "http://cv.iptc.org/newscodes/digitalsourcetype/" DST_IDS = { "digitalCapture", "computationalCapture", "negativeFilm", "positiveFilm", "print", "humanEdits", "compositeWithTrainedAlgorithmicMedia", "algorithmicallyEnhanced", "digitalCreation", "dataDrivenMedia", "trainedAlgorithmicMedia", "algorithmicMedia", "screenCapture", "virtualRecording", "composite", "compositeCapture", "compositeSynthetic", } # IPTC-IIM byte caps for constant fields; exiftool truncates silently without -m. # One entry per byte-capped key build_args() writes, so the warning set matches the # write set. (country_code is the 3-byte ISO 3166 alpha-3 code.) BYTE_LIMITS = { "by_line": 32, "credit": 32, "city": 32, "state": 32, "sub_location": 32, "country": 64, "country_code": 3, "headline": 256, "copyright": 128, "usage_terms": 256, } CAPTION_LIMIT = 2000 # IPTC:Caption-Abstract KEYWORD_LIMIT = 64 # IPTC:Keywords, per record # verify(): the canonical read-back tag per constant field, with its `-G1` JSON key. # We confirm PRESENCE (the tag is non-empty), not equality, byte-capped IIM fields # are expected to truncate (warned up front), so an equality check would false-fail. # Prefer an uncapped XMP layer where one exists. EXIF tags are skipped here because # `-G1` names them by IFD (IFD0:Artist), not by the "EXIF" group. VERIFY_TAGS = { "by_line": ("-IPTC:By-line", "IPTC:By-line"), "creator": ("-XMP-dc:Creator", "XMP-dc:Creator"), "credit": ("-IPTC:Credit", "IPTC:Credit"), "credit_full": ("-XMP-photoshop:Credit", "XMP-photoshop:Credit"), "copyright": ("-XMP-dc:Rights", "XMP-dc:Rights"), "license_url": ("-XMP-cc:License", "XMP-cc:License"), "web_statement": ("-XMP-xmpRights:WebStatement", "XMP-xmpRights:WebStatement"), "licensor_name": ("-XMP-plus:LicensorName", "XMP-plus:LicensorName"), "licensor_url": ("-XMP-plus:LicensorURL", "XMP-plus:LicensorURL"), "attribution_name": ("-XMP-cc:AttributionName", "XMP-cc:AttributionName"), "attribution_url": ("-XMP-cc:AttributionURL", "XMP-cc:AttributionURL"), "usage_terms": ("-XMP-xmpRights:UsageTerms", "XMP-xmpRights:UsageTerms"), "headline": ("-XMP-photoshop:Headline", "XMP-photoshop:Headline"), "sub_location": ("-XMP-iptcCore:Location", "XMP-iptcCore:Location"), "city": ("-XMP-photoshop:City", "XMP-photoshop:City"), "state": ("-XMP-photoshop:State", "XMP-photoshop:State"), "country": ("-XMP-photoshop:Country", "XMP-photoshop:Country"), "country_code": ("-XMP-iptcCore:CountryCode", "XMP-iptcCore:CountryCode"), } def effective_dst(constants, per_image): """The digital_source_type in force for one image: per-image overrides constant.""" return per_image.get("digital_source_type") or constants.get("digital_source_type") def resolve_dst(value): """Return the canonical Digital Source Type CV URI for a value, or None. Accepts a known CV shorthand (`digitalCapture`) or a full IPTC CV URI in either the `http://` or `https://` form; a full URI is reduced to its trailing ID and validated against the vocabulary, so a non-IPTC URL or a typo'd ID (e.g. `.../trainedAlgorithmicMedi`) returns None and the caller warns and skips it rather than embedding a broken value. The result is always the canonical `http://` form regardless of how it was written. """ if not value: return None v = str(value).strip() for scheme in (DST_BASE, DST_BASE.replace("http://", "https://", 1)): if v.startswith(scheme): v = v[len(scheme):] # reduce a full CV URI to its bare ID break if v in DST_IDS: return DST_BASE + v return None def build_args(constants, per_image, dst_uri=None): """Return the list of exiftool tag arguments for one image. `dst_uri` is the already-resolved Digital Source Type URI (or None); it is resolved by the caller so the same value drives both the write and the read-back check. """ args = ["-codedcharacterset=utf8"] # Collect constant tags into an ordered map keyed by exiftool tag, so overlapping # manifest keys (by_line and creator both touch dc:Creator/Artist) assign each # tag once instead of emitting a duplicate value. Iterate CONST_TAGS for a stable # order; a later key (creator) overrides an earlier one (by_line) for shared tags. const_args = {} for key in CONST_TAGS: value = constants.get(key) if value is None or value == "": continue for tag in CONST_TAGS[key]: const_args[tag] = f"{tag}{value}" args += list(const_args.values()) caption = per_image.get("caption") if caption: for tag in CAPTION_TAGS: args.append(f"{tag}{caption}") alt = per_image.get("alt") if alt: args.append(f"-XMP-iptcCore:AltTextAccessibility={alt}") # Extended accessibility description, a longer screen-reader text for a complex # image (a chart, an infographic). Distinct from both the alt text and the caption. ext = per_image.get("ext_description") if ext: args.append(f"-XMP-iptcCore:ExtDescrAccessibility={ext}") if dst_uri: args.append(f"-XMP-iptcExt:DigitalSourceType={dst_uri}") keywords = per_image.get("keywords") or [] if keywords: # clear then add, so re-running is idempotent rather than appending duplicates args += ["-IPTC:Keywords=", "-XMP-dc:Subject="] for kw in keywords: args += [f"-IPTC:Keywords+={kw}", f"-XMP-dc:Subject+={kw}"] if constants.get("license_url"): args.append("-XMP-xmpRights:Marked=True") # copy the real shot date/time from the camera rather than inventing one. IPTC # splits date and time, so copy both; XMP photoshop:DateCreated holds the full stamp. args += [ "-IPTC:DateCreated<EXIF:DateTimeOriginal", "-IPTC:TimeCreated<EXIF:DateTimeOriginal", "-XMP-photoshop:DateCreated<EXIF:DateTimeOriginal", ] return args def _too_long(value, limit): return value is not None and len(str(value).encode("utf-8")) > limit def warn_byte_limits(constants): """Warn about constant fields that exceed their IPTC-IIM byte cap.""" for key, limit in BYTE_LIMITS.items(): value = constants.get(key) if _too_long(value, limit): n = len(str(value).encode("utf-8")) print(f" warning: '{key}' is {n} bytes (IPTC limit {limit}); " f"it will be truncated in the IPTC layer", file=sys.stderr) def warn_image_byte_limits(name, per_image): """Warn about per-image caption/keywords that exceed their IPTC-IIM byte cap.""" if _too_long(per_image.get("caption"), CAPTION_LIMIT): n = len(str(per_image["caption"]).encode("utf-8")) print(f" warning: {name} caption is {n} bytes (IPTC Caption-Abstract limit " f"{CAPTION_LIMIT}); it will be truncated in the IPTC layer", file=sys.stderr) for kw in per_image.get("keywords") or []: if _too_long(kw, KEYWORD_LIMIT): n = len(str(kw).encode("utf-8")) print(f" warning: {name} keyword '{kw}' is {n} bytes (IPTC Keywords limit " f"{KEYWORD_LIMIT}); it will be truncated in the IPTC layer", file=sys.stderr) def verify(path, const_keys, per_image, dst_uri=None): """Read every requested field back from the written file. Returns (ok, problems). `const_keys` are the constant manifest keys that were actually written; this image's caption/alt/ext_description/keywords and its resolved Digital Source Type are checked too. Presence (the tag is non-empty), not equality, so a deliberately truncated IIM field is not a false failure, but a silently dropped or skipped tag (e.g. a non-writable spelling) does fail. """ # Each check is (label, [json_keys]); it passes if ANY of the keys is non-empty. # by_line/caption/keywords list both the IIM tag and its XMP twin, because # HEIC/AVIF/WebP have no IIM slot, there exiftool writes only the XMP copy, so a # valid write would false-fail if we demanded the IIM tag. On JPEG both are present. read_args, checks = [], [] for key in const_keys: if key == "by_line": read_args += ["-IPTC:By-line", "-XMP-dc:Creator"] checks.append(("by_line", ["IPTC:By-line", "XMP-dc:Creator"])) elif key in VERIFY_TAGS: arg, json_key = VERIFY_TAGS[key] read_args.append(arg) checks.append((key, [json_key])) if per_image.get("caption"): read_args += ["-IPTC:Caption-Abstract", "-XMP-dc:Description"] checks.append(("caption", ["IPTC:Caption-Abstract", "XMP-dc:Description"])) if per_image.get("alt"): read_args.append("-XMP-iptcCore:AltTextAccessibility") checks.append(("alt", ["XMP-iptcCore:AltTextAccessibility"])) if per_image.get("ext_description"): read_args.append("-XMP-iptcCore:ExtDescrAccessibility") checks.append(("ext_description", ["XMP-iptcCore:ExtDescrAccessibility"])) if dst_uri: read_args.append("-XMP-iptcExt:DigitalSourceType") checks.append(("digital_source_type", ["XMP-iptcExt:DigitalSourceType"])) want_keywords = per_image.get("keywords") or [] if want_keywords: read_args += ["-IPTC:Keywords", "-XMP-dc:Subject"] # The date copy is always attempted, so confirm it. Under -G1, DateTimeOriginal is # grouped as ExifIFD. (A file with no shot date is fine, there is nothing to copy.) read_args += ["-EXIF:DateTimeOriginal", "-IPTC:DateCreated", "-IPTC:TimeCreated", "-XMP-photoshop:DateCreated"] out = subprocess.run( ["exiftool", "-G1", "-j", *read_args, "--", str(path)], capture_output=True, text=True, ) if out.returncode != 0 or not out.stdout.strip(): return False, ["could not read file back"] data = json.loads(out.stdout)[0] problems = [f"missing {label}" for label, json_keys in checks if not any(data.get(k) for k in json_keys)] if want_keywords: got = [] for k in ("IPTC:Keywords", "XMP-dc:Subject"): # union of both layers v = data.get(k) got += [v] if isinstance(v, str) else (v or []) missing = [k for k in want_keywords if k not in got] if missing: problems.append("missing keywords: " + ", ".join(missing)) if data.get("ExifIFD:DateTimeOriginal"): # XMP-photoshop:DateCreated is the format-agnostic proof the copy ran. The IPTC # date/time split only exists where there is an IIM block; require both there # (the bug this guards), but do not demand them on a no-IIM format. if not data.get("XMP-photoshop:DateCreated"): problems.append("shot date present but XMP-photoshop:DateCreated not copied") if data.get("IPTC:DateCreated") or data.get("IPTC:TimeCreated"): for json_key in ("IPTC:DateCreated", "IPTC:TimeCreated"): if not data.get(json_key): problems.append(f"shot date present but {json_key} not copied") return (not problems), problems def strip_gps(path): """Remove GPS coordinates from a file, keeping every editorial tag. Returns True on success. Clears the whole EXIF GPS IFD (`-gps:all=`) and the entire XMP GPS set (`-xmp:GPS*=`), the wildcard catches destination and image-direction fields (`GPSDestLatitude`, `GPSImgDirection`, …), not just the three main coordinates, so a publish-safe derivative cannot leak a location through a field left behind. Other metadata, caption, credit, copyright, Digital Source Type, is untouched. """ out = subprocess.run( ["exiftool", "-m", "-overwrite_original", "-gps:all=", "-xmp:GPS*=", "--", str(path)], capture_output=True, text=True, ) return out.returncode == 0 def main(): ap = argparse.ArgumentParser( description="Batch-embed wire metadata into photos with exiftool.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__) ap.add_argument("--dir", required=True, help="folder of source images") ap.add_argument("--manifest", required=True, help="JSON manifest path") ap.add_argument("--out", help="output folder (default: <dir>/tagged)") ap.add_argument("--in-place", action="store_true", help="overwrite originals instead of writing copies") ap.add_argument("--strip-gps", action="store_true", help="remove GPS from each tagged file (keeps editorial metadata) " ", for a publish-safe derivative that won't leak a location") args = ap.parse_args() # --in-place overwrites originals; refuse to pair it with an explicit --out so a # stray flag can never turn a safe copy into a destructive overwrite. if args.in_place and args.out: ap.error("--in-place and --out are mutually exclusive") if not shutil.which("exiftool"): sys.exit("error: exiftool not found on PATH (https://exiftool.org)") src = Path(args.dir) if not src.is_dir(): sys.exit(f"error: --dir is not a folder: {src}") src_resolved = src.resolve() try: manifest = json.loads(Path(args.manifest).read_text()) except (OSError, json.JSONDecodeError) as exc: sys.exit(f"error: cannot read manifest: {exc}") constants = manifest.get("constants", {}) images = manifest.get("images", {}) if not isinstance(constants, dict) or not isinstance(images, dict): sys.exit("error: manifest 'constants' and 'images' must be objects") if not images: sys.exit("error: manifest has no images") warn_byte_limits(constants) out_dir = src if args.in_place else Path(args.out) if args.out else src / "tagged" if not args.in_place: out_dir.mkdir(parents=True, exist_ok=True) ok = failed = missing = 0 for name, per_image in images.items(): # Reject manifest keys that escape the source folder (e.g. "../secret.jpg"). # In --in-place mode this would otherwise let a manifest rewrite arbitrary files. try: (src / name).resolve().relative_to(src_resolved) except (ValueError, OSError): print(f" rejected: {name} (path escapes {src})", file=sys.stderr) failed += 1 continue source = src / name if not source.is_file(): print(f" missing: {name} (not in {src})", file=sys.stderr) missing += 1 continue warn_image_byte_limits(name, per_image) # Resolve the Digital Source Type once (per-image overrides constant); a value # that is neither a known shorthand nor a URL is warned and skipped, not written. dst_raw = effective_dst(constants, per_image) dst_uri = resolve_dst(dst_raw) if dst_raw and dst_uri is None: print(f" warning: {name} digital_source_type '{dst_raw}' is not a known " f"shorthand or URL; skipped (see reference.md)", file=sys.stderr) target = source if args.in_place else out_dir / name if not args.in_place: target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, target) # `--` ends option parsing so a filename beginning with "-" (e.g. "-foo.jpg" # reached via "--dir . --in-place") is treated as a path, not an exiftool option. cmd = ["exiftool", "-m", "-overwrite_original"] cmd += build_args(constants, per_image, dst_uri) cmd += ["--", str(target)] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: print(f" failed: {name}: {result.stderr.strip()}", file=sys.stderr) failed += 1 continue # Optional publish-safe step: drop GPS after embedding, keeping editorial tags. if args.strip_gps and not strip_gps(target): print(f" failed: {name}: could not strip GPS", file=sys.stderr) failed += 1 continue # Verify every constant field that was actually written, plus this image's # caption/alt/ext_description/keywords and its Digital Source Type, so a silently # dropped or skipped tag fails the run. const_keys = [k for k, v in constants.items() if k in VERIFY_TAGS and v not in (None, "")] good, problems = verify(target, const_keys, per_image, dst_uri) if good: ok += 1 else: failed += 1 print(f" verify failed: {name}: {', '.join(problems)}", file=sys.stderr) where = "in place" if args.in_place else str(out_dir) print(f"done: {ok} tagged ({where}), {failed} failed, {missing} missing") sys.exit(1 if (failed or missing) else 0) if __name__ == "__main__": main() -
reference.md 19.7 KB
# Photo metadata, full reference Detail behind `SKILL.md`. Load when you need the exact tag, a byte limit, the Digital Source Type vocabulary, the licensing/Creative Commons field set, the C2PA tooling, or the caption recipe. This is the full hand-write reference for `exiftool`. `embed.py` in this directory automates the subset of these fields exposed in its JSON manifest (see `SKILL.md`); the rest, `By-lineTitle`, `Source`, `ObjectName`, the AI-system fields, and any other tag without a manifest key, you write by hand. Current as of the **IPTC Photo Metadata Standard 2025.1** (Nov 2025; Core schema 1.5, Extension schema 1.9) and **exiftool 13.5x** (mid-2026). The AI-system fields (below) need exiftool ≥ 13.40; everything else here works on older builds. Standards move, re-check `iptc.org/standards/photo-metadata/` and `exiftool.org` when precision matters. ## Why three metadata layers, and which one wins A JPEG can carry the same fact in three places. Write all three; `exiftool` keeps them consistent. Modern software **reads XMP first**, IIM second, EXIF only for date/GPS, but legacy wire intake still parses IIM, so on JPEG/TIFF you write both. - **XMP**, the modern XML layer (Adobe, accessibility, licensing, Creative Commons, the AI/Digital-Source-Type and region fields). No length limits, always UTF-8. The canonical store; every field below has an XMP form. - **IPTC-IIM**, the legacy newsroom block. Photo Mechanic and wire intake still read it, so write it as a compatibility copy, but it is byte-limited (below), not UTF-8 by default (needs `-codedcharacterset=utf8`), and **not defined at all in HEIC/AVIF/WebP/PNG** (see the format table). On those formats, write XMP + EXIF only. - **EXIF**, written by the camera (shot time, model, exposure). Survives tools that strip everything else. Holds `Artist`, `Copyright`, `ImageDescription`, and the authoritative `DateTimeOriginal`. To write all three consistently in one shot without listing each tag, exiftool's **MWG composite tags** apply the Metadata Working Group reconciliation rules: `-MWG:Description=`, `-MWG:Creator=`, `-MWG:Copyright=`, `-MWG:Keywords=`, `-MWG:City=` etc. each update the IIM, XMP, and EXIF copies together. Handy for scripts; the explicit per-layer tags below give you finer control. ## Field map | Role | IPTC (IIM) | XMP | EXIF | |------|-----------|-----|------| | Photographer | `IPTC:By-line` | `XMP-dc:Creator` | `EXIF:Artist` | | Photographer title | `IPTC:By-lineTitle` | `XMP-photoshop:AuthorsPosition` | - | | Credit (org / full) | `IPTC:Credit` (org, max 32) | `XMP-photoshop:Credit` (full name / org) | - | | Source | `IPTC:Source` | `XMP-photoshop:Source` | - | | Caption / description | `IPTC:Caption-Abstract` | `XMP-dc:Description` | `EXIF:ImageDescription` | | Alt text (accessibility) | - | `XMP-iptcCore:AltTextAccessibility` | - | | Extended description (accessibility) | - | `XMP-iptcCore:ExtDescrAccessibility` | - | | Digital source type (AI / origin) | - | `XMP-iptcExt:DigitalSourceType` | - | | AI system used | - | `XMP-iptcExt:AISystemUsed`, `AISystemVersionUsed` | - | | AI prompt | - | `XMP-iptcExt:AIPromptInformation`, `AIPromptWriterName` | - | | Headline | `IPTC:Headline` | `XMP-photoshop:Headline` | - | | Title / object name | `IPTC:ObjectName` | `XMP-dc:Title` | - | | Keywords | `IPTC:Keywords` (repeatable) | `XMP-dc:Subject` (list) | - | | Copyright notice | `IPTC:CopyrightNotice` | `XMP-dc:Rights` | `EXIF:Copyright` | | Rights marked | - | `XMP-xmpRights:Marked` | - | | Usage terms | `IPTC:SpecialInstructions` | `XMP-xmpRights:UsageTerms` | - | | Web statement of rights | - | `XMP-xmpRights:WebStatement` | - | | Licensor (Google "Get this image") | - | `XMP-plus:LicensorName`, `XMP-plus:LicensorURL` | - | | License URL (CC, legacy) | - | `XMP-cc:License` | - | | Attribution name | - | `XMP-cc:AttributionName` | - | | Sub-location | `IPTC:Sub-location` | `XMP-iptcCore:Location` | - | | City | `IPTC:City` | `XMP-photoshop:City` | - | | State / province | `IPTC:Province-State` | `XMP-photoshop:State` | - | | Country | `IPTC:Country-PrimaryLocationName` | `XMP-photoshop:Country` | - | | Country code | `IPTC:Country-PrimaryLocationCode` | `XMP-iptcCore:CountryCode` | - | | Date created | `IPTC:DateCreated` (+ `TimeCreated`) | `XMP-photoshop:DateCreated` | `EXIF:DateTimeOriginal` | Copy the date from the camera rather than typing it: ```bash exiftool "-IPTC:DateCreated<EXIF:DateTimeOriginal" "-IPTC:TimeCreated<EXIF:DateTimeOriginal" "-XMP-photoshop:DateCreated<EXIF:DateTimeOriginal" photo.jpg ``` ## IPTC-IIM byte limits IIM fields are byte-capped; exiftool truncates silently unless you pass `-m` (and even then it warns). XMP has no limit, so put the short form in IPTC and the full form in XMP. | Field | Max bytes | |-------|-----------| | `By-line` | 32 | | `By-lineTitle` | 32 | | `Credit` | 32 | | `Source` | 32 | | `City` | 32 | | `Province-State` | 32 | | `Sub-location` | 32 | | `Country-PrimaryLocationName` | 64 | | `Country-PrimaryLocationCode` | 3 | | `ObjectName` (title) | 64 | | `Headline` | 256 | | `SpecialInstructions` | 256 | | `CopyrightNotice` | 128 | | `Caption-Abstract` | 2000 | | `Keywords` (per record) | 64 | The trap: a credit like `Jane Smith / Center for Cooperative Media` is 40+ characters. In `IPTC:Credit` it gets cut. Put the organization alone in `IPTC:Credit` (under 32) and the full `name / org` in `XMP-photoshop:Credit`. Use `-codedcharacterset=utf8` so accented names and curly quotes survive in the IPTC layer. ## Digital Source Type, how the image was made `XMP-iptcExt:DigitalSourceType` holds a **full URI** from the IPTC "Digital Source Type" NewsCodes controlled vocabulary. exiftool stores it as an unvalidated string, it will not expand a shorthand or catch a typo, so you write the whole URI: `http://cv.iptc.org/newscodes/digitalsourcetype/<id>` (the `http://` form is canonical; `https://` also resolves). | `<id>` | Meaning | |--------|---------| | `digitalCapture` | Straight capture from a digital camera, the news default, worth stating explicitly | | `computationalCapture` | In-camera multi-frame merge (HDR, night mode); **non-generative** | | `negativeFilm` / `positiveFilm` / `print` | Scanned from film negative / transparency / print | | `humanEdits` | Human retouch/toning with **non-generative** tools | | `algorithmicallyEnhanced` | Sharpening, denoise, no content change | | `digitalCreation` | Human-made with non-generative software (replaced `digitalArt`) | | `dataDrivenMedia` | Visual representation of data (a rendered dataset) | | `compositeCapture` | Composite whose elements are **all** real captures | | `composite` | Composite of elements, any of which may or may not be AI | | `compositeSynthetic` | Composite with **at least one** generative-AI element | | `compositeWithTrainedAlgorithmicMedia` | Existing media **edited with generative AI** (inpaint / outpaint / generative fill) | | `trainedAlgorithmicMedia` | **Fully AI-generated** by a model trained on captured content | | `algorithmicMedia` | Pure algorithm, **no** training data (a fractal, a procedural render) | | `screenCapture` | Capture of a screen | | `virtualRecording` | Recording of a virtual event (may mix capture and generative AI) | The three to know cold: fully AI → `trainedAlgorithmicMedia`; a real photo AI-edited → `compositeWithTrainedAlgorithmicMedia`; a real capture with an AI element dropped in → `compositeSynthetic`. **Retired terms, do not write** (they still resolve when reading legacy files): `minorHumanEdits` (→ `humanEdits`), `digitalArt` (→ `digitalCreation`), `softwareImage` (dropped as too vague). Who acts on it: **Meta** (Facebook/Instagram/Threads) and **Google** read `DigitalSourceType` (and C2PA carrying the same value) to apply AI-content labels; generators like OpenAI, Adobe Firefly, and Google's image tools write it at creation. The same vocabulary appears inside C2PA manifests, so a value written here matches the signed one. The EU AI Act (Article 50) makes machine-readable AI disclosure a legal duty with enforcement from **August 2026**. ### AI system and prompt fields (IPTC 2025.1, exiftool ≥ 13.40) For AI-generated or AI-assisted images, four companion fields record the tool and prompt: ```bash exiftool \ -XMP-iptcExt:DigitalSourceType="http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" \ -XMP-iptcExt:AISystemUsed="Adobe Firefly" \ -XMP-iptcExt:AISystemVersionUsed="Image 4" \ -XMP-iptcExt:AIPromptInformation="a wide shot of an empty newsroom at dawn" \ -XMP-iptcExt:AIPromptWriterName="Jane Smith" \ illustration.jpg ``` `AIPromptWriterName` is the person who wrote the prompt, explicitly *not* an author/creator claim over the image. ## Licensing metadata (Creative Commons and commercial) Plain-text "CC BY 4.0" in a copyright note is not machine-readable. The **XMP Rights** namespace is what every tool, and Google, understand, use it for both Creative Commons and commercial licensing: ```bash exiftool \ -XMP-xmpRights:Marked=True \ -XMP-xmpRights:WebStatement="https://creativecommons.org/licenses/by/4.0/" \ -XMP-xmpRights:UsageTerms="Licensed CC BY 4.0. Required credit: Jane Smith/Example News." \ -XMP-cc:License="https://creativecommons.org/licenses/by/4.0/" \ -XMP-cc:AttributionName="Jane Smith / Example News" \ -XMP-cc:AttributionURL="https://example.org" \ photo.jpg ``` - `XMP-xmpRights:Marked`, `True` = rights reserved / licensed; `False` = public domain. Set it either way; leaving it unset is a missing signal. - `XMP-xmpRights:WebStatement`, the URL of the rights/license page (or the CC deed URL). **This is the field Google keys on**, and the one people wrongly put in `dc:Rights`. - `XMP-cc:License` and the other `cc:` fields still work but are effectively legacy: there is **no maintained Creative Commons XMP spec**, so prefer `xmpRights:WebStatement` (with the CC deed URL) as the primary machine-readable signal and add `cc:License` only for older CC-aware readers. CC deed URLs: `by/4.0`, `by-sa/4.0`, `by-nc/4.0`, `by-nc-sa/4.0`, `by-nd/4.0`, `by-nc-nd/4.0`, or `publicdomain/zero/1.0/` for CC0. ### Google Images "Licensable" badge and PLUS licensor fields Google Images shows a "Licensable" badge and a "Get this image on …" link when the file carries a web statement plus a licensor. Confirmed active in 2025–2026. ```bash exiftool \ -XMP-xmpRights:Marked=True \ -XMP-xmpRights:WebStatement="https://example.org/license/photo123" \ -XMP-plus:LicensorName="Example Photo Agency" \ -XMP-plus:LicensorURL="https://example.org/buy/photo123" \ -XMP-dc:Creator="Jane Doe" -IPTC:Credit="Example Photo Agency" \ -XMP-dc:Rights="© 2026 Example Photo Agency" -IPTC:CopyrightNotice="© 2026 Example Photo Agency" \ photo123.jpg ``` - `WebStatement` is **required** to trigger the badge; `LicensorURL` powers the purchase link. - The PLUS namespace (`XMP-plus:`, from the Picture Licensing Universal System) has more: `LicensorName`/`LicensorURL`/`LicensorID`/`LicensorEmail`, and the supplier and owner structures `ImageSupplierName`/`ImageSupplierID`, `ImageCreatorName`, `CopyrightOwnerName`. The flattened tag writes the first list element, which is what publishers need. - On-page **schema.org `ImageObject`** structured data (`license`, `acquireLicensePage`, `creator`, `creditText`, `copyrightNotice`) is the alternative signal; if the embedded and on-page data disagree, **Google uses the structured data**. ## AP-style caption recipe A wire caption should stand on its own: scene, place, date, credit. One reliable shape: ``` <what is visible>, <weekday>, <Month D, YYYY>, in <City>, <State abbr>. (<Photographer>/<Organization>) ``` Example: ``` A crowd holds signs outside the county courthouse, Friday, June 19, 2026, in Trenton, N.J. (Dana Rivera/Example News Collective) ``` Notes: - AP abbreviates most state names in datelines (`N.J.`, `Calif.`, `Pa.`); the period in `N.J.` is the sentence terminator, do not add a second one. - Present tense for what the photo shows. - Verify the weekday against the date; do not guess it. - The credit goes in parentheses at the end, photographer first, then the organization. ## Caption, alt text, and extended description, three distinct fields IPTC keeps these separate on purpose: the caption states facts and is shown on the page; the alt text is hidden in the HTML and read aloud by a screen reader. **Do not copy one into the other.** - **Caption** (`IPTC:Caption-Abstract` / `XMP-dc:Description`): the publishable wire caption, scene, place, date, credit. Shown as a visible cutline. - **Alt text** (`XMP-iptcCore:AltTextAccessibility`): one short sentence for a screen-reader user, the single most important thing in the frame. Keep it short (a target of ~250 characters; software flags longer). No date, credit, or place (those are announced elsewhere), and no keyword stuffing, IPTC is explicit that alt text is not for SEO. - **Extended description** (`XMP-iptcCore:ExtDescrAccessibility`): a longer accessible description for a **complex** image (a chart, an infographic, a map), used only when the alt text plus surrounding page text can't convey it. It should **not** repeat the alt text, and it is **not** the caption. Most news photos don't need one. Both accessibility fields arrived in IPTC 2021.1 and are `lang-alt` (language-tagged), so they can carry translations. ## Labeling people, examples | Visible in frame | Write | Do not write | |------------------|-------|--------------| | Vest reads "POLICE ICE" | "an ICE officer" | "an agent" (too vague) or a name you are guessing | | Generic camo, no insignia | "officers in tactical gear" | "ICE agents" (not shown) | | Person in facility uniform behind a barrier | "a person in a facility uniform" | "a detainee" (a legal-status claim) | | Person at a podium with a name placard | read the placard | a name from memory | Describe expressions and actions only when clearly visible. Do not infer emotion, motive, or relationship. ## Content Credentials (C2PA), the signed provenance layer C2PA ("Content Credentials," the "Cr" pin) is a cryptographically signed manifest bound to the pixels by a content hash, stored in a **JUMBF** box (in JPEG, the APP11 segment). It is separate from IPTC/XMP and answers a different question: *who signed this asset, in what tool, with what edits and AI involvement, and has it changed since*. A C2PA manifest can also carry a signed copy of the IPTC metadata as a `stds.iptc.photo-metadata` assertion, and it uses the **same** Digital Source Type vocabulary as above. **exiftool reads it, cannot write it, and can delete it:** ```bash exiftool -G1 -a -jumbf:all incoming.jpg # report the manifest as data (NO signature check) exiftool -jumbf:all= -overwrite_original file.jpg # strip the credential (know that it's this easy) ``` exiftool shows the manifest contents but does **not** validate the signature, the trust chain, or the hash binding, so it tells you a credential is *present and says X*, not that it is *valid*. For a real verification, use: - **verify.contentauthenticity.org**, drag in a file; it validates the signature and shows the signer, edits, and ingredients. The human-facing check. - **c2patool** (`brew install c2patool`) or **`pip install c2pa-python`** (Python ≥ 3.10), read, validate, and **sign/add** manifests. Signing uses an external signer so private keys never pass through the tool. These are the CAI/Adobe reference tools; exiftool is not a substitute. Cautions to state plainly to any newsroom: - **Editing metadata invalidates the credential.** The hard binding hashes the asset, metadata included, so any `exiftool` write to a signed file, including the tagging and GPS-stripping in this skill, leaves the manifest embedded but no longer valid. Preserving the JUMBF box is not enough. Keep the signed original untouched and do metadata work on a derivative you re-sign with `c2patool`, or state that the embedded credential no longer validates. Do not tag a signed original and call its credential good. - **A valid credential proves a signature and a chain, not truth.** A camera will sign a photo of a screen; a manifest can be forged with a mis-issued cert; certificate revocation checking is optional in the spec and validators have disagreed in practice (e.g., a revoked Nikon signing cert in late 2025). Verify the *signer identity*, not merely that a credential exists. - **Adoption is emerging, not universal.** Cameras (Leica M11-P, Nikon Z6III via firmware + Nikon's authenticity service, Sony Alpha via Camera Verify, Canon) and agencies (AFP, AP, BBC pilots) are shipping it, but claims that wire services "require" signed credentials on all images are overstated, treat provenance as a growing practice. - **Durability.** Because platforms strip the embedded manifest, "Durable Content Credentials" add an invisible watermark (Digimarc, in the C2PA spec since 2.1) and a content fingerprint so a stripped credential can be recovered from a manifest store. Don't count on the embedded manifest alone surviving a trip through social media. ## Location privacy, stripping GPS GPS is the highest-risk tag in a news file. Strip it from the published derivative while keeping the editorial metadata: ```bash # EXIF GPS IFD + the whole XMP GPS set; leaves caption/credit/copyright/source-type intact exiftool -gps:all= "-xmp:GPS*=" -overwrite_original photo.jpg exiftool -a -G1 -gps:all "-xmp:GPS*" photo.jpg # verify: must print nothing ``` Clear the whole XMP GPS set with the `-xmp:GPS*=` wildcard, not just `GPSLatitude`/`GPSLongitude`/`GPSAltitude`: exiftool's EXIF→XMP GPS mapping can also populate `GPSDestLatitude`/`GPSDestLongitude` and `GPSImgDirection`, and a destination coordinate left behind still leaks a location. The `*` is quoted so the shell passes it to exiftool rather than globbing it. Keep a full-GPS archival master, locked internally, where coordinates are editorial evidence (geolocation, verification, accountability). Publish the stripped copy. `embed.py --strip-gps` applies this to every file in a tagged folder. Note that a blunt `exiftool -all=` also removes the ICC color profile (colors shift), the EXIF orientation (image may display rotated), and any C2PA credential, for a targeted scrub prefer `-gps:all=`; for a full strip that keeps color, add `--icc_profile:all=` back: `exiftool -all= --icc_profile:all= -overwrite_original photo.jpg`. ## Formats: where IIM, XMP, and EXIF live | Format | EXIF | XMP | IPTC-IIM | Write editorial metadata as | |--------|:----:|:---:|:--------:|-----------------------------| | JPEG | ✓ | ✓ | ✓ | IIM **and** XMP (+ EXIF) | | TIFF / DNG | ✓ | ✓ | ✓ | IIM **and** XMP (+ EXIF) | | HEIC / HEIF (iPhone) | ✓ | ✓ | **✗** | **XMP + EXIF only** (no IIM slot) | | AVIF | ✓ | ✓ | **✗** | **XMP + EXIF only** | | WebP | ✓ | ✓ | **✗** | **XMP + EXIF only** (confirm downstream reads WebP XMP) | | PNG | ✓ (eXIf) | ✓ | limited | **XMP + EXIF**; IIM not standard | exiftool reads and writes all of these. On the no-IIM formats, an `-IPTC:*` write is silently dropped (exiftool writes only the XMP copy), so target the `XMP-*` equivalents. `embed.py` writes both blocks and, on read-back, accepts the IIM tag *or* its XMP twin for byline, caption, and keywords: a valid XMP-only write on HEIC/AVIF/WebP passes rather than false-failing, while a tag that landed in neither layer still fails. ## Social platforms strip metadata Most platforms re-encode on upload and strip EXIF, GPS, IPTC, XMP, **and** C2PA from the copy other users download: Instagram, X/Twitter, Facebook, LinkedIn, TikTok, Reddit, Threads, Bluesky. A few preserve everything, **iMessage** and WhatsApp/Telegram *document/file* mode keep full metadata **including GPS** (a leak vector to warn about), while photo-hosting sites (Flickr, SmugMug) preserve and display IPTC/EXIF. The takeaway for the round-trip discipline in `SKILL.md`: after publishing to a platform, assume the embedded metadata and any Content Credential are gone unless you have verified otherwise on that platform. -
SKILL.md 15.9 KB
--- name: photo-metadata description: Embeds photo IPTC/EXIF/XMP metadata, caption, credit, alt text, license, AI-source label, GPS stripping, and C2PA credentials. --- # Photo metadata ## Overview Metadata embedded in an image file travels with the file. Photo CMSs (Photo Mechanic, Lightroom, Capture One) and wire intake systems read a photo's caption, credit, and rights from its IPTC and XMP blocks, not from a separate document or the email it arrived in. If the caption, credit, alt text, and license are not *inside* the file, they are gone the moment the photo is downloaded, forwarded, or re-uploaded. One `exiftool` pass writes the EXIF, IPTC, and XMP layers together and leaves every other tag (camera settings, shot time) untouched. Modern software reads **XMP first**, legacy IPTC-IIM second, EXIF only for date and GPS, so write XMP everywhere and add IIM as a compatibility copy on JPEG/TIFF (HEIC, AVIF, and WebP have no IIM slot at all; see `reference.md`). Two things changed since this workflow was "caption, credit, copyright." First, **how an image was made now belongs in the metadata**: the IPTC *Digital Source Type* field labels a camera photo versus an AI-generated one, and platforms (Meta, Google) and the EU AI Act increasingly read it. Second, **cryptographic provenance (C2PA / "Content Credentials")** is arriving on wire images and cameras, a signed layer `exiftool` can *read* but not write. Both are covered below. **A capable model already knows the field names.** The hard part is not the mechanics, it is the judgment below. Lead with that. ## When to use - Prepping press photos for a wire so partner newsrooms can search, credit, and republish them - Adding required photographer attribution and a reuse license before publishing or sharing - Labeling how an image was made, a straight photo, an AI-generated illustration, an AI-edited composite - Batch-tagging a shoot (a folder of images) - Making images accessible (embedded alt text) and rights-clear (copyright or Creative Commons) - Reading and sanity-checking the C2PA Content Credentials on an image that arrived from an agency **When not to use:** editing pixels (this is metadata only); writing alt text for an HTML `<img>` (use `accessibility-compliance`); preserving web pages as evidence (use `web-archiving`); *signing* a Content Credential (exiftool can't, use `c2patool`, below). ## The discipline (what agents get wrong) These are the failures a capable agent makes anyway. They matter more than any tag name. 1. **Caption only what is visible.** Describe what the frame shows, not what you were told. Do not infer events, intent, identities, relationships, or legal status you cannot see. "Demonstrators gather to protest a court ruling" is a claim about facts not in the frame; "A crowd holds signs outside a courthouse" is the photo. 2. **Label people from visible evidence.** Name an agency or role only from a visible marking, a labeled vest, a uniform, a badge, a patch. Otherwise write "officers in tactical gear," "a man in a blue shirt." Never assert someone's immigration or legal status (no "detainee," no "undocumented") unless it is unambiguous in the frame. 3. **Always write alt text, it is not the caption.** Write both: a short screen-reader description in `XMP-iptcCore:AltTextAccessibility` and the publishable caption in `IPTC:Caption-Abstract`. IPTC keeps these deliberately distinct, the caption states facts and is shown on the page; the alt text is read aloud by a screen reader, so do not just copy one into the other. Agents routinely write the caption and skip the alt text. 4. **Label how the image was made, and never lie about it.** If an image is AI-generated or AI-edited, say so in `XMP-iptcExt:DigitalSourceType`; if it is a straight photo, `digitalCapture` states that plainly. Do the honest thing and label it; do the diligent thing and, on an *inbound* file, **never strip an existing Digital Source Type or C2PA credential**, that erases a disclosure someone made on purpose. 5. **Strip GPS when the location could endanger someone.** A protester, a source, an abuse survivor, a minor, embedded coordinates can reveal a home or a safe house. Remove GPS from the published derivative (`-gps:all=`) while keeping the caption and credit; keep a full-GPS archival master only where location is editorial evidence. GPS is the single highest-risk tag in the file. 6. **Keep structured fields neutral.** Editorial framing or a contested label belongs in `Headline`, never in `City`, `Caption-Abstract`, or the location fields. Partner newsrooms apply their own language; clean structured fields let them. 7. **Verify the round-trip from source.** Read the metadata back *from the written file*, not from your buffer. After any upload or transfer, re-read it *from the destination*, a 200 response proves the bytes were accepted, not that the metadata survived. Most social platforms re-encode on upload and strip IPTC, XMP, GPS, *and* C2PA (see `reference.md`), so "I embedded it" is not "it arrived." ## Quick reference, the fields that carry the weight | Role | IPTC (IIM) | XMP | EXIF | |------|-----------|-----|------| | Photographer | `By-line` | `dc:Creator` | `Artist` | | Credit | `Credit` (org, max 32 chars) | `photoshop:Credit` (full name / org) | - | | Caption | `Caption-Abstract` | `dc:Description` | `ImageDescription` | | Alt text (short) | - | `iptcCore:AltTextAccessibility` | - | | Extended description | - | `iptcCore:ExtDescrAccessibility` (complex images; not the caption) | - | | How it was made | - | `iptcExt:DigitalSourceType` (full CV URI) | - | | Keywords | `Keywords` (repeatable) | `dc:Subject` | - | | Copyright | `CopyrightNotice` | `dc:Rights` | `Copyright` | | License (CC) | - | `xmpRights:Marked`/`WebStatement`/`UsageTerms`, `cc:License` (legacy) | - | | License / discovery | - | `xmpRights:WebStatement`, `plus:LicensorName`/`LicensorURL` (Google) | - | | Headline | `Headline` | `photoshop:Headline` | - | | Location | `Sub-location`/`City`/`Province-State`/`Country-*` | `iptcCore:Location`, `photoshop:City`/`State`/`Country` | - | | Date | `DateCreated` | `photoshop:DateCreated` | `DateTimeOriginal` (source of truth) | Digital Source Type values (fully AI → `trainedAlgorithmicMedia`, AI-edited → `compositeWithTrainedAlgorithmicMedia`, straight photo → `digitalCapture`), the full IPTC controlled vocabulary, the IPTC-IIM byte limits, the PLUS/Google-licensing and Creative Commons field sets, the C2PA tooling, and the AP caption recipe: see `reference.md`. ## One pass that writes all three layers ```bash CAPTION="A crowd holds signs outside the Mercer County Courthouse, Friday, June 19, 2026, in Trenton, N.J. (Dana Rivera/Example News Collective)" ALT="A crowd of people holding handmade signs stands on the steps of a stone courthouse." # how the image was made, a full IPTC CV URI (see reference.md for all values) DST="http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture" exiftool -codedcharacterset=utf8 -overwrite_original -P \ -EXIF:Artist="Dana Rivera" -XMP-dc:Creator="Dana Rivera" -IPTC:By-line="Dana Rivera" \ -IPTC:Credit="Example News Collective" -XMP-photoshop:Credit="Dana Rivera / Example News Collective" \ -IPTC:Caption-Abstract="$CAPTION" -XMP-dc:Description="$CAPTION" -EXIF:ImageDescription="$CAPTION" \ -XMP-iptcCore:AltTextAccessibility="$ALT" \ -XMP-iptcExt:DigitalSourceType="$DST" \ -IPTC:Keywords="protest" -IPTC:Keywords+="Trenton" \ -XMP-dc:Subject="protest" -XMP-dc:Subject+="Trenton" \ -EXIF:Copyright="(c) 2026 Example News Collective. Licensed CC BY 4.0." \ -IPTC:CopyrightNotice="(c) 2026 Example News Collective. CC BY 4.0." \ -XMP-dc:Rights="(c) 2026 Example News Collective. Licensed CC BY 4.0." \ -XMP-xmpRights:Marked=True \ -XMP-xmpRights:WebStatement="https://creativecommons.org/licenses/by/4.0/" \ -XMP-xmpRights:UsageTerms="Licensed CC BY 4.0. Credit: Dana Rivera / Example News Collective." \ -XMP-cc:License="https://creativecommons.org/licenses/by/4.0/" \ -XMP-cc:AttributionName="Dana Rivera / Example News Collective" \ -IPTC:City="Trenton" -IPTC:Province-State="New Jersey" \ -IPTC:Country-PrimaryLocationName="United States" -IPTC:Country-PrimaryLocationCode="USA" \ "-IPTC:DateCreated<EXIF:DateTimeOriginal" "-IPTC:TimeCreated<EXIF:DateTimeOriginal" \ "-XMP-photoshop:DateCreated<EXIF:DateTimeOriginal" \ photo.jpg ``` `-P` preserves the file's modification time; drop it if you want the write to touch the timestamp. Extended accessibility descriptions for complex images (charts, infographics) go in `XMP-iptcCore:ExtDescrAccessibility`, a *separate* field from the caption, added only when the alt text plus surrounding text can't convey the image. Then **verify from the file** (the step agents skip): ```bash exiftool -G1 -s -IPTC:By-line -IPTC:Caption-Abstract -XMP-iptcCore:AltTextAccessibility \ -XMP-iptcExt:DigitalSourceType -XMP-cc:License -IPTC:Keywords photo.jpg ``` ## Label how an image was made (AI and synthetic) `XMP-iptcExt:DigitalSourceType` records origin from the IPTC controlled vocabulary. The value is a **full URI**, `exiftool` does not validate it, so a bare word or a typo is silently accepted and useless. The three every newsroom needs: ```bash BASE="http://cv.iptc.org/newscodes/digitalsourcetype" # a straight camera photo, worth stating even for real news images exiftool -XMP-iptcExt:DigitalSourceType="$BASE/digitalCapture" photo.jpg # fully AI-generated (a trained model produced the whole image) exiftool -XMP-iptcExt:DigitalSourceType="$BASE/trainedAlgorithmicMedia" ai.jpg # a real photo edited with generative AI (inpaint / outpaint / generative fill) exiftool -XMP-iptcExt:DigitalSourceType="$BASE/compositeWithTrainedAlgorithmicMedia" edited.jpg ``` Meta and Google read this field to auto-label AI content, and the EU AI Act's machine-readable-disclosure duty (Article 50, enforcement from August 2026) is pushing it from nice-to-have toward required. IPTC 2025.1 adds companion fields, `AISystemUsed`, `AISystemVersionUsed`, `AIPromptInformation`, `AIPromptWriterName` (exiftool ≥ 13.40). Full vocabulary and the retired terms to avoid: `reference.md`. ## Content Credentials (C2PA): provenance exiftool can read but not sign A **Content Credential** is a cryptographically signed C2PA manifest bound to the pixels, who made the image, in what tool, and whether AI was involved, increasingly shipped by cameras (Leica M11-P, Nikon Z6III, Sony Alpha) and agencies (AFP, AP, BBC pilots). It is a different layer from IPTC/XMP and answers a different question: not "what does the file claim" but "who signed this, and has it changed since." `exiftool` **reads** it and **cannot write or verify** it: ```bash exiftool -G1 -a -jumbf:all incoming.jpg # report the C2PA/JUMBF manifest (no signature check) ``` That shows the manifest as *data*, it does not validate the signature or the signer. For a real check, drop the file into **verify.contentauthenticity.org** and confirm the signer is the agency you expect. To *create* a credential, use Adobe/CAI tooling, `c2patool` (`brew install c2patool`) or `pip install c2pa-python`, not exiftool. **Writing metadata to a signed file breaks its credential.** A C2PA hard binding hashes the asset, and that hash covers the embedded metadata, so any `exiftool` write, caption, credit, GPS strip, even the tagging in this skill, leaves the manifest present but *invalid*. "Never strip the credential" is necessary but not sufficient. On an inbound signed file, either leave the original untouched and do your metadata work on a **derivative you will re-sign** with `c2patool`, or accept that the embedded credential no longer validates and say so. Do not embed metadata into a signed original and treat its credential as still good. Two more cautions worth stating to any newsroom: a valid credential proves a signature and a chain, **not** that the scene is real (a camera will happily sign a photo of a screen), and most social platforms strip the manifest on upload, so on-platform provenance often survives only via "durable" watermark/fingerprint recovery. See `reference.md`. ## Strip GPS for a publish-safe derivative Remove location without touching the caption, credit, copyright, or source type: ```bash exiftool -gps:all= "-xmp:GPS*=" -overwrite_original photo.jpg exiftool -a -G1 -gps:all "-xmp:GPS*" photo.jpg # verify, this must print nothing ``` Use the `-xmp:GPS*=` wildcard, not just the three main coordinates: destination and image-direction fields (`GPSDestLatitude`, `GPSImgDirection`) are also a location and would otherwise survive. Keep the full-GPS file as a locked archival master where coordinates are editorial evidence (geolocation, verification). Publish the stripped copy. `embed.py --strip-gps` does this for a whole folder after tagging. ## Licensing that shows up in search (Google Images) To earn the Google Images "Licensable" badge and a working "Get this image" link, set the web statement of rights (the trigger) and the PLUS licensor fields: ```bash exiftool -XMP-xmpRights:Marked=True \ -XMP-xmpRights:WebStatement="https://example.org/license/photo123" \ -XMP-plus:LicensorName="Example News" -XMP-plus:LicensorURL="https://example.org/buy/photo123" \ photo.jpg ``` The web statement is `xmpRights:WebStatement`, **not** `dc:Rights`, a common and costly mix-up. A Creative Commons license routes through the same `WebStatement` field with the CC deed URL. Details and the full PLUS field set: `reference.md`. ## Batch tagging a folder For a shoot, drive `exiftool` from a manifest instead of one command per file. `embed.py` in this directory takes a folder plus a JSON manifest (constant credit, license, licensor, and Digital Source Type fields, then per-image alt text, caption, extended description, keywords, and an optional per-image source-type override), writes tagged copies, reads each one back to confirm the metadata landed, and, with `--strip-gps`, removes GPS from the copies. It accepts a Digital Source Type shorthand (`digitalCapture`) or a full URI and refuses anything else rather than embedding a broken value. Run `python3 embed.py --help`. ## Common mistakes (from baseline testing) | Mistake | Fix | |---------|-----| | Wrote a caption, no alt text | Always write `AltTextAccessibility` too, they are different fields | | Copied the caption into the alt text (or `ExtDescrAccessibility`) | IPTC keeps these distinct; write a real screen-reader sentence, keep `ExtDescr` for complex images only | | `By-line`/`Credit`/`City` silently truncated | Those IIM fields cap at 32 chars; put the full credit in `XMP-photoshop:Credit` | | Caption states things not in the frame | Describe only what is visible; move unseeable context out | | AI-generated image left unlabeled | Set `DigitalSourceType` to `trainedAlgorithmicMedia` (or the right composite value) | | `DigitalSourceType` set to a bare word | The value must be the full `http://cv.iptc.org/...` URI; exiftool won't validate it | | Stripped an inbound file's Digital Source Type or C2PA | Never erase a disclosure, preserve provenance on files you receive | | Published with GPS still embedded | Strip with `-gps:all=` when location could endanger a subject or source | | `WebStatement` put in `dc:Rights` | The Google/licensing web statement is `xmpRights:WebStatement` | | Editorial label in `City` or caption | Put framing in `Headline`; keep structured fields neutral | | Assumed the upload kept the metadata | Re-read from the destination; most social platforms strip IPTC/XMP/GPS/C2PA | | Keywords as one comma-joined string | Write repeatable `Keywords` records (and a `dc:Subject` list) | | Set a CC license note in plain text only | Add `xmpRights:WebStatement` (CC deed URL) + `xmpRights:Marked` | ## Real-world impact Embedded metadata is what lets a partner newsroom find a photo, credit it correctly, and republish it under a clear license without ever contacting the photographer. It is also, now, where an image says whether a human or a model made it, and where a signed Content Credential travels. Strip it, and the same photo is an orphaned file, no credit, no license, no provenance. -
test_embed.py 23.8 KB
#!/usr/bin/env python3 """Black-box tests for embed.py, run with `python3 -m unittest` from this dir. Needs exiftool on PATH and Pillow (for generating fixture JPEGs). Each test runs embed.py as a CLI against a throwaway temp folder, then reads the result back with exiftool, so the tests exercise the real write/verify path rather than internals. """ import json import os import shutil import subprocess import sys import tempfile import unittest from pathlib import Path HERE = Path(__file__).resolve().parent EMBED = HERE / "embed.py" def have(cmd): return shutil.which(cmd) is not None def make_jpeg(path, size=(48, 32)): from PIL import Image Image.new("RGB", size, (200, 60, 60)).save(path, "JPEG") def read_back(path): out = subprocess.run( ["exiftool", "-j", "-IPTC:By-line", "-IPTC:Caption-Abstract", "-XMP-iptcCore:AltTextAccessibility", "-IPTC:Keywords", path], capture_output=True, text=True, ) return json.loads(out.stdout)[0] if out.returncode == 0 and out.stdout.strip() else {} def run_embed(*args, cwd=None): return subprocess.run([sys.executable, str(EMBED), *args], capture_output=True, text=True, cwd=cwd) @unittest.skipUnless(have("exiftool"), "exiftool not installed") class EmbedCLITests(unittest.TestCase): def setUp(self): self.tmp = Path(tempfile.mkdtemp()) self.src = self.tmp / "photos" self.src.mkdir() make_jpeg(self.src / "a.jpg") self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) def write_manifest(self, manifest): p = self.tmp / "manifest.json" p.write_text(json.dumps(manifest)) return p def test_minimal_write_and_verify(self): m = self.write_manifest({ "constants": {"by_line": "Dana Rivera", "credit_full": "Dana Rivera / Example"}, "images": {"a.jpg": {"caption": "A red rectangle.", "alt": "A solid red rectangle.", "keywords": ["test", "fixture"]}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, r.stderr) data = read_back(str(self.src / "tagged" / "a.jpg")) self.assertEqual(data.get("By-line"), "Dana Rivera") self.assertEqual(data.get("AltTextAccessibility"), "A solid red rectangle.") def test_optional_fields_only_alt_is_success(self): # Manifest sets only alt text, no byline, no caption. The write succeeds, # so the run must report success (verify must not demand fields nobody asked for). m = self.write_manifest({ "constants": {}, "images": {"a.jpg": {"alt": "A solid red rectangle."}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") data = read_back(str(self.src / "tagged" / "a.jpg")) self.assertEqual(data.get("AltTextAccessibility"), "A solid red rectangle.") def test_creator_only_manifest_is_success(self): # `creator` without `by_line` writes Creator/Artist but no IPTC By-line; the # run must still succeed (verify the field that was actually written). m = self.write_manifest({ "constants": {"creator": "Dana Rivera"}, "images": {"a.jpg": {"caption": "A red rectangle."}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") def test_in_place_and_out_are_mutually_exclusive(self): m = self.write_manifest({"constants": {}, "images": {"a.jpg": {"alt": "x"}}}) r = run_embed("--dir", str(self.src), "--manifest", str(m), "--in-place", "--out", str(self.tmp / "out")) self.assertNotEqual(r.returncode, 0) self.assertIn("mutually exclusive", (r.stderr + r.stdout).lower()) def test_manifest_path_traversal_is_rejected(self): # A valid JPEG sits OUTSIDE the source folder; a manifest name escapes to it. outside = self.tmp / "outside.jpg" make_jpeg(outside) m = self.write_manifest({ "constants": {"by_line": "Mallory"}, "images": {"../outside.jpg": {"caption": "should not be written"}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m), "--in-place") self.assertNotEqual(r.returncode, 0) # the file outside the folder must be untouched self.assertEqual(read_back(str(outside)).get("By-line"), None) def test_exif_datetime_copies_to_iptc_date_and_time(self): # A real EXIF shot time must land in BOTH IPTC DateCreated and TimeCreated, # not just the date (IPTC splits the two fields). subprocess.run(["exiftool", "-overwrite_original", "-EXIF:DateTimeOriginal=2026:06:19 14:30:00", "--", str(self.src / "a.jpg")], capture_output=True, text=True) m = self.write_manifest({"constants": {"by_line": "Dana"}, "images": {"a.jpg": {"caption": "A red rectangle."}}}) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, r.stderr) out = subprocess.run(["exiftool", "-G1", "-j", "-IPTC:DateCreated", "-IPTC:TimeCreated", "-XMP-photoshop:DateCreated", "--", str(self.src / "tagged" / "a.jpg")], capture_output=True, text=True) data = json.loads(out.stdout)[0] self.assertIn("2026:06:19", data.get("IPTC:DateCreated", "")) # date self.assertIn("14:30:00", data.get("IPTC:TimeCreated", "")) # time self.assertTrue(data.get("XMP-photoshop:DateCreated")) # XMP date def test_country_code_lands_in_both_iptc_and_xmp(self): # country_code must write the IPTC code AND a *writable* XMP tag. The # XMP-iptcExt spelling is not writable and was silently dropped under -m. m = self.write_manifest({ "constants": {"by_line": "Dana", "country_code": "USA"}, "images": {"a.jpg": {"caption": "A red rectangle."}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, r.stderr) out = subprocess.run( ["exiftool", "-G1", "-j", "-IPTC:Country-PrimaryLocationCode", "-XMP-iptcCore:CountryCode", "--", str(self.src / "tagged" / "a.jpg")], capture_output=True, text=True) data = json.loads(out.stdout)[0] self.assertEqual(data.get("IPTC:Country-PrimaryLocationCode"), "USA") self.assertEqual(data.get("XMP-iptcCore:CountryCode"), "USA") def test_full_constant_set_all_fields_land(self): # Every constant field must be written with a writable tag and survive the # round-trip. This also audits CONST_TAGS for non-writable tags (the class # the country_code bug belonged to). Values stay under the IIM byte caps. consts = { "by_line": "Dana Rivera", "creator": "Dana Rivera", "credit": "Example News", "credit_full": "Dana Rivera / Example News", "copyright": "(c) 2026 Example News. CC BY 4.0.", "license_url": "https://creativecommons.org/licenses/by/4.0/", "attribution_name": "Dana Rivera / Example News", "attribution_url": "https://example.org", "usage_terms": "Licensed CC BY 4.0.", "headline": "Editorial framing here", "sub_location": "Courthouse steps", "city": "Trenton", "state": "New Jersey", "country": "United States", "country_code": "USA", } m = self.write_manifest({"constants": consts, "images": {"a.jpg": {"caption": "A red rectangle."}}}) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") tagged = str(self.src / "tagged" / "a.jpg") g1_keys = [ "IPTC:By-line", "XMP-dc:Creator", "IPTC:Credit", "XMP-photoshop:Credit", "IPTC:CopyrightNotice", "XMP-dc:Rights", "XMP-cc:License", "XMP-xmpRights:WebStatement", "XMP-cc:AttributionName", "XMP-cc:AttributionURL", "XMP-xmpRights:UsageTerms", "IPTC:SpecialInstructions", "IPTC:Headline", "XMP-photoshop:Headline", "IPTC:Sub-location", "XMP-iptcCore:Location", "IPTC:City", "XMP-photoshop:City", "IPTC:Province-State", "XMP-photoshop:State", "IPTC:Country-PrimaryLocationName", "XMP-photoshop:Country", "IPTC:Country-PrimaryLocationCode", "XMP-iptcCore:CountryCode", ] out = subprocess.run(["exiftool", "-G1", "-j", *[f"-{k}" for k in g1_keys], "--", tagged], capture_output=True, text=True) data = json.loads(out.stdout)[0] for key in g1_keys: self.assertTrue(data.get(key), f"missing {key}: {data}") # by_line and creator both touch dc:Creator; it must not be written twice. self.assertEqual(data.get("XMP-dc:Creator"), "Dana Rivera") # EXIF tags use IFD group names under -G1, so read them plainly. exif = subprocess.run(["exiftool", "-s3", "-EXIF:Artist", "-EXIF:Copyright", "--", tagged], capture_output=True, text=True) self.assertIn("Dana Rivera", exif.stdout) self.assertIn("Example News", exif.stdout) def test_overlong_byte_limited_fields_warn(self): # Every byte-capped field build_args() writes must warn when overlong, so the # warning set matches the write set (quoted keys disambiguate country vs code). m = self.write_manifest({ "constants": {"headline": "H" * 300, "copyright": "C" * 200, "usage_terms": "U" * 300, "sub_location": "S" * 40, "country": "C" * 70, "country_code": "ABCD"}, "images": {"a.jpg": {"alt": "x"}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) warn = r.stderr + r.stdout for key in ("'headline'", "'copyright'", "'usage_terms'", "'sub_location'", "'country'", "'country_code'"): self.assertIn(key, warn) def test_dash_prefixed_filename_is_handled(self): # With "--dir . --in-place" the path handed to exiftool is "-dash.jpg", which # exiftool would parse as an option unless the command ends options with "--". make_jpeg(self.src / "-dash.jpg") m = self.write_manifest({ "constants": {"by_line": "Dana"}, "images": {"-dash.jpg": {"caption": "A red rectangle."}}, }) r = run_embed("--dir", ".", "--manifest", str(m), "--in-place", cwd=str(self.src)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") self.assertEqual(read_back(str(self.src / "-dash.jpg")).get("By-line"), "Dana") def test_digital_source_type_shorthand_expands_to_full_uri(self): # A newsroom shorthand must be written as the full IPTC CV URI, exiftool does # not validate DigitalSourceType, so the tool is responsible for the URI. m = self.write_manifest({ "constants": {"digital_source_type": "digitalCapture"}, "images": {"a.jpg": {"caption": "A red rectangle."}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") out = subprocess.run(["exiftool", "-s3", "-XMP-iptcExt:DigitalSourceType", "--", str(self.src / "tagged" / "a.jpg")], capture_output=True, text=True) self.assertEqual( out.stdout.strip(), "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture") def test_digital_source_type_ai_value_and_per_image_override(self): # A per-image value overrides the constant, so a folder can mix a real photo # with an AI illustration and label each honestly. make_jpeg(self.src / "ai.jpg") m = self.write_manifest({ "constants": {"digital_source_type": "digitalCapture"}, "images": { "a.jpg": {"caption": "A real photo."}, "ai.jpg": {"caption": "An AI illustration.", "digital_source_type": "trainedAlgorithmicMedia"}, }, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") def dst(name): out = subprocess.run(["exiftool", "-s3", "-XMP-iptcExt:DigitalSourceType", "--", str(self.src / "tagged" / name)], capture_output=True, text=True) return out.stdout.strip() self.assertTrue(dst("a.jpg").endswith("/digitalCapture")) self.assertTrue(dst("ai.jpg").endswith("/trainedAlgorithmicMedia")) def test_digital_source_type_full_uri_normalizes_to_http(self): # An https CV URI is accepted and normalized to the canonical http form. m = self.write_manifest({ "constants": {}, "images": {"a.jpg": {"caption": "x", "digital_source_type": "https://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic"}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") out = subprocess.run(["exiftool", "-s3", "-XMP-iptcExt:DigitalSourceType", "--", str(self.src / "tagged" / "a.jpg")], capture_output=True, text=True) self.assertEqual( out.stdout.strip(), "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic") def test_digital_source_type_typo_and_non_iptc_urls_are_rejected(self): # A typo'd CV id or a non-IPTC URL must be warned and skipped, not written as # broken provenance that a non-empty read-back would wave through. for bad in ("https://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedi", "https://example.com/ai"): make_jpeg(self.src / "b.jpg") m = self.write_manifest({ "constants": {}, "images": {"b.jpg": {"caption": "x", "digital_source_type": bad}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") self.assertIn("digital_source_type", (r.stderr + r.stdout)) out = subprocess.run(["exiftool", "-s3", "-XMP-iptcExt:DigitalSourceType", "--", str(self.src / "tagged" / "b.jpg")], capture_output=True, text=True) self.assertEqual(out.stdout.strip(), "", f"wrote broken value for {bad!r}") def test_strip_gps_clears_destination_coordinates_too(self): # A publish-safe strip must clear the whole XMP GPS set, not just the three # main coordinates: GPSDest* is also a location and must not survive. subprocess.run(["exiftool", "-overwrite_original", "-GPSLatitude=40.7", "-GPSLatitudeRef=N", "-GPSLongitude=74.0", "-GPSLongitudeRef=W", "-XMP-exif:GPSDestLatitude=41.0", "-XMP-exif:GPSDestLongitude=75.0", "--", str(self.src / "a.jpg")], capture_output=True, text=True) m = self.write_manifest({"constants": {"by_line": "Dana"}, "images": {"a.jpg": {"caption": "x"}}}) r = run_embed("--dir", str(self.src), "--manifest", str(m), "--strip-gps") self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") gps = subprocess.run(["exiftool", "-a", "-G1", "-gps:all", "-XMP-exif:all", "--", str(self.src / "tagged" / "a.jpg")], capture_output=True, text=True) self.assertNotIn("GPS", gps.stdout, f"GPS survived strip: {gps.stdout}") def test_webp_no_iim_format_verifies_via_xmp(self): # HEIC/AVIF/WebP have no IIM slot: exiftool writes only XMP, so verify must not # false-fail a valid XMP-only write by demanding the IPTC read-back tags. from PIL import Image Image.new("RGB", (48, 32), (20, 80, 20)).save(self.src / "a.webp", "WEBP") m = self.write_manifest({ "constants": {"by_line": "Dana Rivera", "digital_source_type": "digitalCapture"}, "images": {"a.webp": {"caption": "A green frame.", "alt": "A solid green rectangle.", "keywords": ["test", "webp"]}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") out = subprocess.run( ["exiftool", "-G1", "-j", "-XMP-dc:Creator", "-XMP-dc:Description", "-XMP-dc:Subject", "-XMP-iptcExt:DigitalSourceType", "--", str(self.src / "tagged" / "a.webp")], capture_output=True, text=True) data = json.loads(out.stdout)[0] self.assertEqual(data.get("XMP-dc:Creator"), "Dana Rivera") self.assertEqual(data.get("XMP-dc:Description"), "A green frame.") def test_unknown_digital_source_type_is_warned_and_skipped(self): # A typo must not be embedded as a broken value: warn, skip the tag, still # succeed on the fields that did write. m = self.write_manifest({ "constants": {}, "images": {"a.jpg": {"caption": "x", "digital_source_type": "camera-photo"}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") self.assertIn("digital_source_type", (r.stderr + r.stdout)) out = subprocess.run(["exiftool", "-s3", "-XMP-iptcExt:DigitalSourceType", "--", str(self.src / "tagged" / "a.jpg")], capture_output=True, text=True) self.assertEqual(out.stdout.strip(), "") def test_google_licensing_fields_land(self): # WebStatement (the Licensable trigger) + PLUS Licensor name/URL must round-trip. m = self.write_manifest({ "constants": { "web_statement": "https://example.org/license/123", "licensor_name": "Example Agency", "licensor_url": "https://example.org/buy/123", }, "images": {"a.jpg": {"caption": "x"}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") out = subprocess.run( ["exiftool", "-G1", "-j", "-XMP-xmpRights:WebStatement", "-XMP-plus:LicensorName", "-XMP-plus:LicensorURL", "--", str(self.src / "tagged" / "a.jpg")], capture_output=True, text=True) data = json.loads(out.stdout)[0] self.assertEqual(data.get("XMP-xmpRights:WebStatement"), "https://example.org/license/123") self.assertEqual(data.get("XMP-plus:LicensorName"), "Example Agency") self.assertEqual(data.get("XMP-plus:LicensorURL"), "https://example.org/buy/123") def test_web_statement_overrides_license_url_web_statement(self): # When both license_url and web_statement are set, the more specific # web_statement wins the shared WebStatement tag (Google reads the license page). m = self.write_manifest({ "constants": { "license_url": "https://creativecommons.org/licenses/by/4.0/", "web_statement": "https://example.org/license/123", }, "images": {"a.jpg": {"caption": "x"}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") out = subprocess.run(["exiftool", "-s3", "-XMP-xmpRights:WebStatement", "-XMP-cc:License", "--", str(self.src / "tagged" / "a.jpg")], capture_output=True, text=True) lines = out.stdout.strip().splitlines() self.assertEqual(lines[0].strip(), "https://example.org/license/123") # WebStatement self.assertEqual(lines[1].strip(), "https://creativecommons.org/licenses/by/4.0/") # cc:License def test_ext_description_is_distinct_from_caption(self): # The caption must NOT be routed into the accessibility extended description; # ext_description is its own field (IPTC keeps them distinct). m = self.write_manifest({ "constants": {}, "images": {"a.jpg": { "caption": "A visible cutline about the scene.", "ext_description": "A long screen-reader description of the chart.", }}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") out = subprocess.run( ["exiftool", "-G1", "-j", "-XMP-iptcCore:ExtDescrAccessibility", "-IPTC:Caption-Abstract", "--", str(self.src / "tagged" / "a.jpg")], capture_output=True, text=True) data = json.loads(out.stdout)[0] self.assertEqual(data.get("XMP-iptcCore:ExtDescrAccessibility"), "A long screen-reader description of the chart.") self.assertEqual(data.get("IPTC:Caption-Abstract"), "A visible cutline about the scene.") def test_caption_alone_does_not_write_ext_description(self): # A caption with no ext_description leaves ExtDescrAccessibility empty. m = self.write_manifest({ "constants": {}, "images": {"a.jpg": {"caption": "Just a caption."}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m)) self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") out = subprocess.run(["exiftool", "-s3", "-XMP-iptcCore:ExtDescrAccessibility", "--", str(self.src / "tagged" / "a.jpg")], capture_output=True, text=True) self.assertEqual(out.stdout.strip(), "") def test_strip_gps_removes_location_but_keeps_editorial_metadata(self): # A publish-safe derivative: GPS gone, caption/credit/source-type intact. subprocess.run(["exiftool", "-overwrite_original", "-GPSLatitude=40.7", "-GPSLatitudeRef=N", "-GPSLongitude=74.0", "-GPSLongitudeRef=W", "--", str(self.src / "a.jpg")], capture_output=True, text=True) m = self.write_manifest({ "constants": {"by_line": "Dana", "digital_source_type": "digitalCapture"}, "images": {"a.jpg": {"caption": "A red rectangle."}}, }) r = run_embed("--dir", str(self.src), "--manifest", str(m), "--strip-gps") self.assertEqual(r.returncode, 0, f"stdout={r.stdout} stderr={r.stderr}") tagged = str(self.src / "tagged" / "a.jpg") gps = subprocess.run(["exiftool", "-s3", "-GPSLatitude", "-GPSLongitude", "--", tagged], capture_output=True, text=True) self.assertEqual(gps.stdout.strip(), "") # GPS removed keep = subprocess.run(["exiftool", "-s3", "-IPTC:By-line", "-XMP-iptcExt:DigitalSourceType", "--", tagged], capture_output=True, text=True) self.assertIn("Dana", keep.stdout) self.assertIn("digitalsourcetype/digitalCapture", keep.stdout) if __name__ == "__main__": unittest.main(verbosity=2)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.