Skip to content

Add XMP sidecar output with keyword pass and language support - #3

Closed
jdmasa wants to merge 8 commits into
oderwat:mainfrom
jdmasa:xmp-output
Closed

jdmasa wants to merge 8 commits into
oderwat:mainfrom
jdmasa:xmp-output

Conversation

@jdmasa

@jdmasa jdmasa commented Sep 17, 2026

Copy link
Copy Markdown

Hi @oderwat ,

I found your tool very useful, but I needed some extra features. Instead of reinventing the wheel, I took the liberty to fork your project and add those features on top: XMP output instead of .txt, language support and file format validation with magic-byte.

Take a look and if you are fine with it, just accept the PR.

Thank you for your work,

Regards,

José

jdmasa and others added 8 commits September 17, 2026 15:59
Adds --xmp, which writes an XMP sidecar next to the image instead of a
.txt caption, holding the caption as dc:description and model generated
tags as dc:subject.

The sidecar keeps the full image name (image.jpg.xmp), which is what
exiftool, digiKam and Lightroom expect. Keywords come from a second pass
of the same vision model over the same image, so no extra model has to be
configured; --keyword-model overrides it, --keyword-prompt and
--keyword-system tune it, --max-keywords caps the list and --no-keywords
skips it entirely. The keyword pass ignores --force-one-sentence, whose
stop token would truncate a keyword list.

Models answer keywords in whatever shape they please, so ParseKeywords
accepts comma lists, bullet lists, numbered lists and JSON arrays, strips
markdown decoration and lead-ins, and dedupes case insensitively. Both
fields are XML escaped, and dc:subject is left out when no keywords
survive parsing.

The skip-existing check now looks at whichever file would be written, so
.txt and .xmp runs of the same directory stay independent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The keyword pass re-encodes and re-prefills the image, which roughly
doubles the time per image. --single-pass instead asks for both fields in
one request, using two labelled lines:

  DESCRIPTION: A fluffy orange cat sitting on a sunny wooden deck.
  KEYWORDS: cat, outdoor, sunny, deck

Measured on qwen2.5vl:3b with a warm model, this cut 22.2s per image down
to 9.9s.

The labels are searched for anywhere in the reply, so a model that answers
on one line is handled as well as one that uses two, and CAPTION:, TAGS:,
markdown decoration and bullet lists are accepted since small models
drift. When no keyword label turns up at all, the whole reply is kept as
the description and a warning is logged, so a malformed answer never costs
the caption as well.

It stays opt-in because smaller vision models write a weaker caption when
the same turn also has to produce tags. --force-one-sentence is refused in
this mode, as its stop token would cut the answer before the keywords, and
so is --keyword-model, which has nothing to act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--language takes a name ("Spanish") or a BCP 47 code ("es", "es-ES") and
appends the instruction to whichever prompt is in use, so it applies to
.txt captions, both XMP modes and a custom --prompt alike. English stays
the default and adds no instruction at all.

In a sidecar the caption is written twice, as x-default for readers that
ignore languages and again tagged with the language itself, which is what
the XMP spec asks for and what exiftool reports as XMP-dc:Description-es.
dc:subject stays untagged, as XMP defines it as an unordered bag of plain
text.

--single-pass asks the model to keep its two labels in English, but models
translate them regardless, so the parser now also accepts the Spanish,
French, German, Italian, Portuguese and Catalan forms.

A language outside the table is still passed to the model by name, so an
unlisted one keeps working for the caption; only the xml:lang tag is
dropped, and a warning says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A JPEG XL file named .jpg makes Ollama answer "Failed to load image or
audio file", and log.Fatalf then aborted the whole run over one bad file.
Phones and photo managers leave exactly such files behind, so a large
library is likely to hold some.

Every file is now identified by its header before a request is spent on
it. JPEG and PNG are sent; JPEG XL, HEIC, AVIF, WebP, GIF, BMP, TIFF and
SVG are recognised by name so the message says what the file really is,
and anything unrecognised is reported as not a JPEG or PNG. Either way the
file is skipped and the walk continues. --no-format-check turns this off
for a backend that accepts more than the two.

The check sits after the skip-existing test, so an image that already has
a caption still costs nothing. The OpenAI path now derives its media type
from the content as well, which fixes a PNG named .jpg being announced as
image/jpeg.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifteen table driven tests over the parts that have to cope with whatever
a model or a photo library hands them:

  xmp_test.go       keyword shapes, the single pass labels, XML escaping,
                    the language entries of dc:description
  language_test.go  names, codes, regions and unknown languages, and that
                    English adds no instruction at all
  format_test.go    the magic bytes of eight formats, the near misses
                    (RIFF that is not WebP, ftyp that is not HEIC) and
                    short, empty and missing files
  main_test.go      sidecar naming, and two regressions worth pinning:
                    struct tag escapes reaching the model literally, and
                    --force-one-sentence leaking into a keyword list

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release workflow went straight from the tag to the cross compiled
binaries, so nothing checked the code first. A test job now runs go vet
and go test once, and the build matrix waits on it, which stops a broken
parser from reaching a release artifact.

It runs as its own job rather than as a step in the matrix, so the suite
runs once instead of four times, and it runs natively because the matrix
sets GOOS and GOARCH only on its build step.

The workflow still only triggers on v* tags and workflow_dispatch, so
this gates releases rather than every push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release workflow only gates a tagged release, so a broken parser
could sit on a branch until tag time. This runs the same two checks on
every branch push and pull request.

The trigger lists branches only, which excludes tag pushes, so a v* tag
runs the release workflow rather than both. In-flight runs of a branch
are cancelled when it is pushed again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jdmasa jdmasa closed this Sep 17, 2026
@jdmasa
jdmasa deleted the xmp-output branch September 17, 2026 14:46
@jdmasa
jdmasa restored the xmp-output branch September 17, 2026 14:53
@jdmasa
jdmasa deleted the xmp-output branch September 18, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant