Resolve InvokeAI board media through their recorded subfolder - #378
Merged
Conversation
Board albums joined the bare filename InvokeAI reported to
<invokeai_root>/outputs/{images,videos}, which assumes the flat storage
layout. InvokeAI actually files each image and video into a subfolder
chosen by a server-side strategy (flat, type, date, hash) and records the
one it used on the row, so on a `type`-organized backend every video lives
at outputs/videos/general/<name> and nothing resolved — a board full of
videos indexed as empty, or failed with "none of the board videos were
found under .../outputs/videos".
The deprecated `/boards/{id}/image_names` and `/videos/names` endpoints
only return names, so the client now pages the DTO listings
(`GET /api/v1/{images,videos}/`) instead and reads `image_subfolder` /
`video_subfolder` off each row, returning paths relative to that media
type's outputs directory. A backend that predates the setting reports no
subfolder, which yields exactly the old flat path, so existing indexes see
no churn.
The subfolder is a server-supplied string that becomes a local filesystem
path, so absolute and upward-walking values are dropped rather than
followed out of the outputs directory.
Videos keep their "the router 404'd, so this listing is absent rather than
empty" signal, which is what stops an older InvokeAI from pruning
previously indexed videos; images treat a 404 as the real failure it is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLPwCcYrkpzwaTbk4FwjuB
Follow-ups from an adversarial review of the previous commit. The subfolder sanitizer normalized backslashes to "/" before checking the value, but tested is-absolute on the raw string — so "\etc" passed the check and then *became* "/etc", and joining an absolute path discards the outputs directory entirely. A malicious or compromised InvokeAI could name any readable file on the host and have PhotoMap read and embed it. The normalization was never needed: InvokeAI's own _validate_subfolder rejects backslashes outright and every strategy emits "/", so a backslash is a value InvokeAI would have refused to store, not a separator to translate. Names were checked with PurePosixPath, which treats "..\..\secret" as an ordinary filename — true on Linux, false on a Windows PhotoMap host. Both now go through one rule matching InvokeAI's grammar: relative, "/" separated, no empty or dot segments, no backslashes anywhere. Offset pagination is also not a snapshot. InvokeAI orders by (starred, created_at) and serves each page from its own query, so deleting or unstarring one row between pages shifts every later row down an offset and skips exactly one — and since the caller prunes indexed rows the listing does not mention, that silently deletes a file still on the board. Each board is now checked against the `total` its own listing reported, re-walked once if short, and only then failed: an aborted update leaves the previous index intact, which is recoverable, while pruning live rows is not. Rows added mid-walk only produce duplicates, which dedupe absorbs. The same check catches an intermediary that truncates pages, and the walk now stops once it has paged past the declared total, so a proxy that ignores `offset` costs a handful of requests instead of grinding through the whole page budget. Live: all 14103 images of the largest board resolve in 2.7s with no spurious re-read; 30/30 and 20/20 videos on the video boards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JLPwCcYrkpzwaTbk4FwjuB
test_board_media_in_subfolders_are_found asserted against str(Path(...)), which is backslash-separated on Windows, while the index stores path.resolve().as_posix(). The two never matched there, so the test failed on both Windows runners while passing on Linux and macOS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JLPwCcYrkpzwaTbk4FwjuB
lstein
force-pushed
the
lstein/fix/invokeai-media-subfolders
branch
from
August 21, 2026 02:34
b2886c3 to
de9c76c
Compare
lstein
enabled auto-merge (squash)
August 21, 2026 02:36
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.
The bug
Indexing an InvokeAI board containing videos found none of them on disk.
InvokeAI does not store board media flat under
outputs/images/outputs/videos. The subfolder strategy is a server-side setting (flat,type,date,hash), and the subfolder each file was actually written into is recorded per row at save time. On atype-organized backend every non-intermediate video lives atoutputs/videos/general/<name>.mp4.PhotoMap asked the
/boards/{id}/image_namesand/videos/namesendpoints, which return only names, and joined them straight onto<invokeai_root>/outputs/videos/<name>— sois_file()was false for every video on the board. Depending on the mix, the album either indexed as empty or failed with "None of the N board video(s) listed by InvokeAI were found under …/outputs/videos — check the InvokeAI root directory", which pointed at the wrong culprit.Images had the same bug and it was simply less visible: a long-lived
outputs/imagesstill holds a pile of legacy flat files, so old images resolved while newer ones (image_subfolder: "general") silently vanished.The fix
The client now pages the DTO listings
GET /api/v1/{images,videos}/instead of the two name endpoints (deprecated upstream anyway, in favour of the polymorphic gallery listing) and readsimage_subfolder/video_subfolderoff each row, returning paths relative to that media type's outputs directory.routers/index.pyjoins them.A backend predating the setting reports no subfolder, which yields exactly the old flat path — verified against real legacy rows, which report
''. Existing indexes see no churn, and nothing is re-keyed or re-embedded.Cost: roughly one request per 1000 rows and a fatter payload, since a whole DTO is fetched to read one field. Measured on the largest local board — 14,103 images in 2.7 s.
Hardening (second commit, from an adversarial review)
/before validating, but tested is-absolute on the raw string, so\etcpassed the check and then became/etc— and joining an absolute path discards the left-hand side entirely. The normalization was never needed: InvokeAI's own_validate_subfolderrejects backslashes outright and every strategy emits/. Names were checked withPurePosixPath, which treats..\..\secretas an ordinary filename (true on Linux, false on a Windows PhotoMap host). Both now go through one rule matching InvokeAI's own grammar: relative,/-separated, no empty or dot segments, no backslashes anywhere.(starred, created_at)and serves each page from its own query, so deleting or unstarring one row between pages shifts every later row down an offset and skips exactly one. Since an index update prunes every indexed row the listing does not mention, that silently deleted a file still on the board. Each board is now checked against thetotalits own listing reported, re-walked once if short, and only then failed — an aborted update leaves the previous index intact, which is recoverable; pruning live rows is not. Rows added mid-walk only produce duplicates, which the dedupe absorbs.offsetcosts a handful of requests rather than the whole page budget.Verification
test_board_media_in_subfolders_are_foundcovers both depths (type's one segment,date's three) end-to-end through indexing and asserts the stored paths, not basenames. It fails against the old flat join, so it is a genuine regression test.total, and rows added mid-walk.773 passed, ruff clean.🤖 Generated with Claude Code
https://claude.ai/code/session_01JLPwCcYrkpzwaTbk4FwjuB