| name | caption-images |
|---|---|
| description | Extract embedded JPEGs from RAW files, visually examine them, and write headlines, captions, and keywords into XMP sidecar files. Reads job context from an optional first-argument job file (`.md`/`.txt`) |
Caption the image files provided and write the results into their XMP sidecar files.
Arguments: $ARGUMENTS
If the first argument is a .md or .txt file, it is a job description; all remaining arguments are image files. Otherwise every argument is an image file.
The workflow has three phases: a single bulk pre-flight to triage every file, per-file processing only for files that need it, and a final report. Use absolute paths throughout — cd does not persist across Bash tool calls.
If the first argument is a Markdown or plain-text file, treat it as a job description: read it for style guide, venue, event, and any narrative context. The remaining arguments are the image files. Set JOBNAME to the basename of the job file without its extension; the final summary will be written to <JOBNAME>-report.md alongside the job file.
If no job file is supplied, skip the report-file output.
Before extracting any preview JPEGs (the expensive step), one bulk read tells you what each file needs:
exiftool -json \
-DateTimeOriginal -GPSLatitude -GPSLongitude \
-ImageDescription -Caption-Abstract -Headline \
-Subject -Keywords \
<all source files>For each source file, also check for an XMP sidecar at {stem}.xmp (e.g. IMG_0001.xmp next to IMG_0001.NEF) and read its XMP:Description, XMP:Headline, XMP:Subject, IPTC:Caption-Abstract, IPTC:Headline, IPTC:Keywords. The union of source + sidecar is the file's current state.
A "real" caption or headline is non-empty and not a placeholder (e.g. not "untitled", not boilerplate like "OLYMPUS DIGITAL CAMERA"). Existing keywords are evaluated differently: see "Keyword merge" below.
For DNGs, PhotoLab writes metadata directly into the DNG's embedded XMP (the file we leave untouched in Step 2.4), so the source check above is the primary signal. For proprietary RAWs (NEF, ARW, ORF, etc.) the sidecar is the primary signal.
Bucket each file by what's already present. Caption presence is the load-bearing dimension: if a real caption exists, no vision is needed because headline and keyword proposals can be derived from it; if it doesn't, vision is required. Every file gets a keyword pass — the bucket determines whether that pass is vision-driven or caption-derived.
| caption | headline | bucket | action |
|---|---|---|---|
| yes | yes | keywords-only | propose keywords from caption text; merge new ones in (no preview extraction, no vision) |
| yes | no | text-derive | derive headline from caption; propose keywords from caption text; merge new ones in |
| no | yes | caption-only | extract preview, examine, write a new caption; propose keywords from the image; preserve headline |
| no | no | full | extract preview, examine, write headline + caption; propose keywords from the image |
Keywords are always proposed and merged into whatever already exists (+=, never replace). Existing keyword lists are preserved in full; new proposals are added.
The dedup pass happens at write time, not triage time. When deciding whether to add a proposed keyword, compare it against the existing list and drop near-duplicates by judgement — same term differing only in case, plurals/singulars of the same noun, alternate spellings of the same proper noun, or substrings/supersets where the existing entry already covers the proposal. Camera-injected boilerplate (model strings, "DCIM", maker-name tags) does not count as an existing keyword for this comparison; it can be ignored, since the merged result will still contain it but our additions remain valuable.
If after dedup a file has no genuinely new keywords to add, omit the keyword arguments at write time (and, for the keywords-only bucket specifically, this means the file has nothing to write at all — see Step 2.4).
Treat the whole batch as one venue/event. Camera body differences are not a venue signal — photographers swap bodies within a job. If some files have GPS and others don't, propagate vicinity across files in close temporal proximity: a GPS-less image bracketed in time by GPS-tagged ones is at the same venue.
For the keywords-only and text-derive buckets, no vision is required:
- For text-derive, distill the existing caption into a 20–40 glyph headline (typically the first clause; strip trailing full stops). If the caption is already shorter than 40 glyphs, use it verbatim.
- For both buckets, propose keywords from the caption text plus any per-file metadata and job context to hand (camera/lens/exposure are not keywords; venue, event, and named subjects are). See Step 2.3 for keyword conventions, and "Keyword merge" in Phase 1 for the dedup rule.
Then jump to Step 2.4.
For the caption-only and full buckets, run Steps 2.1–2.5 in order.
Derive a temp path from the source filename so concurrent processing is safe and cleanup is unambiguous:
STEM=$(basename "$FILE")
PREVIEW="/tmp/caption_${STEM%.*}.jpg"Run the following in order, stopping as soon as one produces output larger than 512 bytes:
exiftool -b -JpgFromRaw "$FILE" > "$PREVIEW" 2>/dev/null && wc -c "$PREVIEW"
exiftool -b -PreviewImage "$FILE" > "$PREVIEW" 2>/dev/null && wc -c "$PREVIEW"
exiftool -b -OtherImage "$FILE" > "$PREVIEW" 2>/dev/null && wc -c "$PREVIEW"
exiftool -b -ThumbnailImage "$FILE" > "$PREVIEW" 2>/dev/null && wc -c "$PREVIEW"If the source file is already a JPEG or TIFF, copy it directly:
cp "$FILE" "$PREVIEW"If nothing works, report the failure, skip this file, and continue.
If the bulk read in Phase 1 didn't cover camera/lens/exposure that you need for caption purposes:
exiftool -json -Make -Model -LensModel -FocalLength -FNumber -ExposureTime -ISO "$FILE"Read the JPEG at $PREVIEW. Produce only the fields the bucket calls for:
- full bucket: Headline, Caption, proposed Keywords.
- caption-only bucket: Caption, proposed Keywords. Preserve the existing headline.
Output rules per field:
-
Headline — 20–40 glyphs, leads with the subject (vehicle name + mark, person, scene). No trailing full stop. Title Case is mandatory.
-
Caption — per the job description or CLAUDE.md style guide. Default: factual, two sentences max, no editorialising.
-
Keywords — content-specific terms only. Aim for as many as the image specifically supports, typically 3–10. Include:
- Specific named subjects with their full marks/variants ("Lamborghini Murciélago LMP", "Tiger I Ausf E", "Spitfire Mk IX", "Sherman Firefly").
- Specific named venues / events ("Goodwood FOS", "Bovington Tankfest", "Duxford Battle of Britain Air Show", "Saint-Lô").
- Specific named units, teams, or people if reliably identifiable from the job context — never guess.
- Specific era / conflict if discernible ("WWII", "Cold War", "Group B").
Do not include generic categories ("car", "tank", "aircraft", "auto show", "event", "vehicle", "military").
Preserve the natural casing of proper nouns. Descriptive terms in title case for visual consistency with proper nouns (e.g. "Tank Museum Interior", not "Tank Museum interior". "Churchill Birth Room", not "Churchill Birth Room". "Airshow", not "airshow". "Bed of Roses", not "bed of roses"). Avoid trivial duplication (don't include both "Spitfire" and "Spitfires"). Do not include camera/lens/exposure data as keywords.
Universal text rules (apply to headline, caption, and keywords):
- British English spelling throughout (e.g. "manoeuvre", "armour", "centre", "tyres") — even if the style guide is silent on it.
- Glyphs: stay within the Latin-1 (ISO-8859-1) repertoire — no emojis or other non-Latin-1 glyphs. Math symbols and accented Latin letters within Latin-1 are fine (so "Königstiger" and "Saint-Lô" are OK, but the em dash and curly quotes are not). This is a glyph constraint on the text content, not a directive about file encoding.
Build a single comma-and-space separated $NEW_KEYWORDS string of just the new (post-dedup) proposals (e.g. "Lamborghini Murciélago LMP, Goodwood FOS, Hill Climb"). The -sep ", " flag tells exiftool to split it into list elements; the += operator appends to the existing XMP:Subject / IPTC:Keywords lists rather than replacing them.
If an XMP sidecar already exists:
exiftool \
-sep ", " \
-XMP:Description="$CAPTION" \
-XMP:Headline="$HEADLINE" \
-XMP:Subject+="$NEW_KEYWORDS" \
-IPTC:Caption-Abstract="$CAPTION" \
-IPTC:Headline="$HEADLINE" \
-IPTC:Keywords+="$NEW_KEYWORDS" \
-overwrite_original \
"$XMP_FILE"If no XMP sidecar exists, create one as {stem}.xmp alongside the source file:
exiftool \
-sep ", " \
-XMP:Description="$CAPTION" \
-XMP:Headline="$HEADLINE" \
-XMP:Subject+="$NEW_KEYWORDS" \
-IPTC:Caption-Abstract="$CAPTION" \
-IPTC:Headline="$HEADLINE" \
-IPTC:Keywords+="$NEW_KEYWORDS" \
-o "${FILE%.*}.xmp" \
"$FILE"-o copies any existing embedded XMP from the RAW into the new sidecar, so += then appends to whatever keywords came along with it. Either way, the source file is never modified.
Omit any field that is being preserved or has nothing new to write:
- caption-only bucket: omit the headline arguments (
-XMP:Headline,-IPTC:Headline). - text-derive bucket: omit the caption arguments (
-XMP:Description,-IPTC:Caption-Abstract). - keywords-only bucket: omit caption and headline arguments — only the keyword args remain.
- any bucket where the dedup pass leaves no new keywords: omit the keyword arguments (
-XMP:Subject+=,-IPTC:Keywords+=). For the keywords-only bucket, this means there is nothing to write — skip the file entirely and report it as[SKIP].
Caution when batching writes in a single shell block: use a distinct variable name per file (
CAP_8147,HL_8147,NEWKW_8147, etc.) rather than reusing$CAPTION/$HEADLINE/$NEW_KEYWORDS. A copy-paste slip in a long block can otherwise overwrite an earlier file's metadata with a later one's text. The error is silent — exiftool reports success either way.
Delete the temp JPEG if you extracted one:
rm -f "$PREVIEW"Do this before moving to the next file so temps don't accumulate if the batch is interrupted.
Print a single line per file as you go. Glyphs are ASCII so logs survive any encoding. Keywords are reported as the list of newly added terms (with a + prefix to make the merge semantics obvious); existing keywords are preserved silently and not echoed.
[OK] DSC_001.NEF -> DSC_001.xmp [patched|created] (full|caption-only|text-derive|keywords-only)
headline: "Short headline written"
caption: "First sentence of the caption written."
keywords: +Lamborghini Murcielago LMP, +Goodwood FOS, +hill climb
For preserved fields, mark them as (kept):
[OK] DSC_004.NEF -> DSC_004.xmp patched (caption-only)
headline: (kept)
caption: "First sentence of the new caption."
keywords: +Tiger I Ausf E, +Bovington Tankfest
[SKIP] DSC_002.NEF no new keywords to add (caption + headline already present)
[FAIL] DSC_003.NEF no embedded JPEG found
After all files, if a job file was supplied, write a Markdown summary to <JOBNAME>-report.md alongside the job file, with:
- Counts by bucket: full / caption-only / text-derive / keywords-only / skipped / failed.
- One block per file: status, file name, headline, first sentence of caption, newly added keywords (or skip/fail reason).
- Use
-overwrite_originalon all exiftool write operations to prevent.xmp_originalbackup files from accumulating in the photo directory. - Never modify the RAW source file itself. All writes go to the
.xmpsidecar only. - Temp files are named
/tmp/caption_{stem}.jpgand removed immediately after each file is processed. If the batch is interrupted, any stranded files can be cleared withrm -f /tmp/caption_*.jpg. - This skill is tuned for a DxO PhotoLab + Capture One pipeline: PhotoLab writes XMP inside DNG files (which natively carry embedded XMP) and to
{stem}.xmpsidecars for proprietary RAWs; Capture One reads both. Only the{stem}.xmpsidecar form is used here — not the legacy<file>.xmpform. XMP:Descriptionis the primary description field.IPTC:Caption-Abstractis written alongside it for legacy IPTC IIM consumers.XMP:HeadlineandIPTC:Headlineare the corresponding short-title fields.XMP:Subjectis the primary keyword field (it's abagof strings);IPTC:Keywordsis written alongside it for legacy IIM consumers. Capture One reads both and merges them into its keyword library.- If the argument list contains a glob that the shell has already expanded, process each resolved path normally. If it looks like an unexpanded glob (contains
*), expand it with bash:ls $GLOB. - Always use absolute paths in exiftool commands. Each Bash tool call starts in the harness's working directory —
cddoes not persist across calls. Either use absolute paths throughout, or setD=/absolute/dironce per call and reference$D/file.