Read and display InvokeAI 7's video metadata - #400
Merged
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
InvokeAI 7 (commit
70329866b0) embeds a generated video's generation record in the.mp4itself, 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-Pythonmoov/udta/metabox walk. The bundledimageio-ffmpegships 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".photomap_videoprobe key, exactly as an image's record is stored. One renderer serves both, and every existing.npzrewrite path (delete, batch delete, path update) carries it for free.GenerationMetadata5gains 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 joinreference_images, so they become clickable thumbnails when they're in the same album.Three things worth reviewing hardest
1. The
metadata_versioncollision — this fixes InvokeAI 7 images too. PhotoMapAI's discriminator overGenerationMetadata2/3/5is 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 toinvoke_record_versionand our tag inferred in its place. Renaming our discriminator would have been the tidier fix, but ~15 existing test payloads passmetadata_version: 3/5explicitly.2.
/invokeai/recallhad no video guard, and this branch is what opened the hole. Onlyuse_ref_imageresolved the file path, which is where theis_video400 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.
Modelisextra="forbid", andModelIdentifierFieldhas a sixth field.submodel_typeis dropped from the record only while it is None, so declaring seven newModel-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_modelandqwen3_encoderstill carry the same pre-existing hazard — deliberately left alone rather than widening this PR.Deliberately not done
outputs/videos/sidecars/, which is not read./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.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
OverflowErroron a >308-digitfpsthat 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.mp4is 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