Skip to content

Read and display InvokeAI 7's video metadata - #400

Merged
lstein merged 3 commits into
masterfrom
lstein/feature/invokeai-video-metadata
Sep 20, 2026
Merged

lstein merged 3 commits into
masterfrom
lstein/feature/invokeai-video-metadata

Conversation

@lstein

@lstein lstein commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

InvokeAI 7 (commit 70329866b0) embeds a generated video's generation record in the .mp4 itself, as QuickTime keyed metadata under the same three keys a generated PNG carries as text chunks. PhotoMapAI now reads the record at index time and renders it in the metadata drawer beneath the still-frame probe panel.

What's here

  • photomap/backend/mp4_metadata.py — a pure-Python moov/udta/meta box walk. The bundled imageio-ffmpeg ships no ffprobe, and walking by box sizes costs a handful of seeks whatever the file's size: mdat, which is all of the video data, is stepped over by its declared size and never read. That matters because this runs once per video over collections that are often on a network mount. Every malformed, truncated or non-MP4 file reads as "no tags".
  • Storage — the record goes flat into the per-file metadata dict, beside the reserved photomap_video probe key, exactly as an image's record is stored. One renderer serves both, and every existing .npz rewrite path (delete, batch delete, path update) carries it for free.
  • Display — GenerationMetadata5 gains the documented video profile (frames, fps, keyframes, source clip and trim range, the Wan and MiniMax H3 auxiliary models, Ref2VA references) and the view exposes it as display-ready tuples. Keyframes and the source clip join reference_images, so they become clickable thumbnails when they're in the same album.

Three things worth reviewing hardest

1. The metadata_version collision — this fixes InvokeAI 7 images too. PhotoMapAI's discriminator over GenerationMetadata2/3/5 is a synthesised int under that name; InvokeAI 7 now stamps its own record version there as a semver string. Left alone, every image and video that release produces fails the discriminated union and degrades to the formatter's flat scalar table. A non-integer value is now moved aside to invoke_record_version and our tag inferred in its place. Renaming our discriminator would have been the tidier fix, but ~15 existing test payloads pass metadata_version: 3/5 explicitly.

2. /invokeai/recall had no video guard, and this branch is what opened the hole. Only use_ref_image resolved the file path, which is where the is_video 400 lives. A video's record is a perfectly good v5 record, so the endpoint would forward a video generation's prompt, model, seed and cfg to InvokeAI's image recall endpoint. Before videos carried a record the request failed only by accident — the metadata dict held nothing parseable. Now guarded at the path and again at the record.

3. Model is extra="forbid", and ModelIdentifierField has a sixth field. submodel_type is dropped from the record only while it is None, so declaring seven new Model-typed fields converted "unknown extra, parses fine" into "the whole union fails and the drawer loses the prompt, model, LoRAs and reference images too". The new fields use a lenient subclass. vae, upscale_model and qwen3_encoder still carry the same pre-existing hazard — deliberately left alone rather than widening this PR.

Deliberately not done

  • Pre-7 videos. Earlier releases kept the record in a JSON sidecar under outputs/videos/sidecars/, which is not read.
  • Recall / Remix for videos — blocked upstream, not deferred here. InvokeAI's /api/v1/recall/{queue_id} does not accept video parameters yet; it would have to be adjusted on the InvokeAI side before PhotoMapAI has anything to send. Until then the image payload would land in the wrong tab, which is what the guard above refuses — so that guard is the intended end state for this PR, not a stub.
  • Videos already indexed keep the metadata they were indexed with, and an update only re-reads files whose mtime moved, so they need a re-index. Nearly moot in practice — only videos generated after the InvokeAI commit above carry a record at all.

Testing

1083 backend + 890 frontend tests pass; ruff, eslint and prettier clean. The second commit is six defects an adversarial fresh-context review of the first found, each reproduced before being fixed and each with a regression test — including an OverflowError on a >308-digit fps that surfaced as a 500 on /retrieve_image (a blank slide) and, via the now-declared field, reached images as well as videos.

tests/backend/test_media/invoke_video.mp4 is tagged by real ffmpeg the way InvokeAI tags its output, with a prompt containing =, ;, # and a line break so the escaped ffmetadata round trip is covered. The box walk is otherwise tested against hand-built hostile files, since the interesting inputs are the ones no writer produces.

Verified in the running app against real InvokeAI 7 output.

🤖 Generated with Claude Code

lstein and others added 3 commits September 20, 2026 09:41
InvokeAI 7 embeds a generated video's generation record in the MP4 itself,
as QuickTime keyed metadata under the same three keys a generated PNG
carries as text chunks. PhotoMapAI now reads the record at index time and
renders it in the metadata drawer beneath the still-frame probe panel.

- mp4_metadata.py: a pure-Python ``moov/udta/meta`` box walk. The bundled
  imageio-ffmpeg ships no ffprobe, and a walk by box sizes costs a handful
  of seeks whatever the file's size — ``mdat`` is stepped over, never read.
  Every malformed, truncated or non-MP4 file reads as "no tags".
- The record is stored flat in the per-image metadata dict beside the
  reserved probe key, exactly as an image's record is stored, so the drawer
  renders both with one renderer that knows nothing about videos.
- GenerationMetadata5 gains the documented video profile (frames, fps,
  keyframes, source clip and trim, the Wan and MiniMax H3 auxiliary models,
  Ref2VA references), and the view exposes it as display-ready tuples.
  Keyframes and the source clip join ``reference_images``, so they become
  clickable thumbnails when they are in the same album.
- Fixes a collision the same release introduced: InvokeAI 7 stamps its
  record version under ``metadata_version``, which is already our own
  schema discriminator. Left alone, every image *and* video that release
  produces fails the discriminated union and degrades to a flat scalar
  table. A non-integer value is now moved aside to ``invoke_record_version``
  and our tag inferred in its place.

No recall buttons for videos: Send/Append upload the file as a reference
image, which an .mp4 is not, and Recall/Remix post an image-generation
payload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…a read

Six confirmed defects, each reproduced before fixing:

- /invokeai/recall forwarded a *video* generation's prompt, model, seed and
  cfg to InvokeAI's image recall endpoint. The drawer withholds the buttons,
  but the endpoint is public and only `use_ref_image` resolved the path,
  where the is_video guard lives. Before videos carried a record the request
  failed by accident — the metadata dict held nothing parseable — so adding
  the record is what opened it. Now guarded at the path and again at the
  record, which is what `is_video_generation` was for.
- `f"{fps:g}"` raises OverflowError on a >308-digit integer, which is
  neither TypeError nor ValueError and nothing on the path catches: a 500 on
  /retrieve_image, i.e. a blank slide. Reachable from any attacker-supplied
  file, and via the now-declared `fps` field it reached images too.
- The seven auxiliary model fields inherited Model's extra="forbid", so a
  ModelIdentifierField carrying submodel_type failed the whole union and
  collapsed the drawer to a scalar table. They use a lenient subclass now.
- The pre-1.0 Wan spellings were pydantic aliases, which let the legacy key
  win over the canonical one and left the canonical one in model_extra,
  tripping the drift warning by the name of a declared field. Folded in a
  before-validator instead, canonical wins.
- read_mp4_tags gave up on the first moov even when a later one held the
  tags, and its child walk was bounded in bytes but not in count.
- looks_like_invoke_metadata and _infer_metadata_version disagreed on the
  video fingerprints, so a record the adapter could place was never routed
  to it. One shared marker list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lstein
lstein merged commit 6eb82ff into master Sep 20, 2026
10 checks passed
@lstein
lstein deleted the lstein/feature/invokeai-video-metadata branch September 20, 2026 15:29
lstein added a commit that referenced this pull request Sep 20, 2026
The note shipped in #400 said an update "only re-reads files whose
modification time has changed", which implies Update Index might pick up
newly-readable metadata. It never will: _get_new_and_missing_images is a
set difference on paths, and modification_times is stored and sorted but
never consulted to decide re-processing, so a file already in the index is
not re-read whatever its mtime.

Confirmed on a real album: 363 indexed videos, 4 carrying a record, 89 more
recovered only after rebuilding.

Points at the Rebuild Index button from #402 rather than telling people to
delete the index file by hand, and links to that button's own section,
which covers what a rebuild costs and what it leaves alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lstein added a commit that referenced this pull request Sep 20, 2026
* feat(video): read the JSON sidecar for videos generated before InvokeAI 7

Pre-7 releases kept a generated video's record in a sidecar under
{outputs}/videos/sidecars/, mirroring the video's own subfolder, rather than
inside the MP4. InvokeAI 7 still writes one when the embedding remux fails,
so this is a live fallback and not only a legacy path.

The extractor now reads the MP4's keyed metadata first and the sidecar
second, which is InvokeAI's own order and means a v7 video costs no sidecar
lookup at all.

Finding the sidecar is the interesting part: PhotoMapAI indexes absolute
paths and never learns where an `outputs` directory begins, so each ancestor
of the video is tried as the videos root, nearest first, mirroring the
video's relative path under `sidecars/`. Bounded, and required to carry an
`invokeai_metadata` key, so an unrelated directory called `sidecars` cannot
be mistaken for InvokeAI's.

Measured against a real install (668 videos, 662 sidecars): every sidecar
resolved at ancestor depth 1, 132 carried a record and 530 carried a null
one (a workflow but no parameters), 3 videos had the record embedded, and
the whole scan cost 1 ms per video including the MP4 walk. All 132 recovered
records parse, are recognised as video generations, and render a panel with
no undeclared fields — which also validates the v5 video profile against
real data rather than only against the synthetic fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(video): close the gaps an adversarial review found in the sidecar reader

Six defects, each reproduced first, and the fixes mutation-tested after.

- RecursionError escaped the reader. json.loads raises it on deeply nested
  JSON; it is a RuntimeError, so neither `except OSError` nor
  `except ValueError` caught it, and ~120 KB of brackets is three orders of
  magnitude under the size cap. The damage was not a lost record but a lost
  *video*: _load_video catches it, returns None, and the file is recorded as
  bad and left out of the album — exactly what the docstring promises cannot
  happen. Both json.loads sites were exposed; they now share a helper.
- The `invokeai_metadata` key gate was weak and untested: it handed back
  whatever object another tool stored under that name, and removing the gate
  entirely left all 25 tests passing. The record must now also satisfy
  looks_like_invoke_metadata — the same test the drawer routes on, and one
  all 132 records in the reference install pass.
- MAX_SUBFOLDER_DEPTH cut from 3 to 1. The bound had no real test (the old
  one imported the constant it was checking, so it passed at 1 and at 9) and
  no evidence: 661 of 661 real sidecars resolve at depth 1. The extra levels
  only bought reach *outside* the configured album — at depth 3, a path at
  the filesystem root. That also removes the `..` escape the review found,
  which needed depth 2.
- The reader resolves the video path first, so a symlinked video finds the
  sidecar beside its target. sidecar_candidates stays pure and now documents
  that it expects a resolved path.
- Size cap 32 MiB -> 8 MiB, matching mp4_metadata.MAX_TAG_BYTES, so the same
  record is not rejected from one source and accepted from the other.
- UnicodeDecodeError was caught (it is a ValueError) but logged as a JSON
  error; split so the message names the right stage.

Also pinned the empty-embedded-record fall-through, which the `or` cannot
distinguish from "no tag", in both a test and the docstring.

Re-measured against the reference install after the fixes: the same 135
records recovered from 668 videos, at 0.2 ms per video.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: an existing album needs a full re-index, not an Update Index

The note shipped in #400 said an update "only re-reads files whose
modification time has changed", which implies Update Index might pick up
newly-readable metadata. It never will: _get_new_and_missing_images is a
set difference on paths, and modification_times is stored and sorted but
never consulted to decide re-processing, so a file already in the index is
not re-read whatever its mtime.

Confirmed on a real album: 363 indexed videos, 4 carrying a record, 89 more
recovered only after rebuilding.

Points at the Rebuild Index button from #402 rather than telling people to
delete the index file by hand, and links to that button's own section,
which covers what a rebuild costs and what it leaves alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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