Skip to content

Resolve InvokeAI board media through their recorded subfolder - #378

Merged
lstein merged 3 commits into
masterfrom
lstein/fix/invokeai-media-subfolders
Aug 21, 2026
Merged

Resolve InvokeAI board media through their recorded subfolder#378
lstein merged 3 commits into
masterfrom
lstein/fix/invokeai-media-subfolders

Conversation

@lstein

@lstein lstein commented Aug 20, 2026

Copy link
Copy Markdown
Owner

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 a type-organized backend every non-intermediate video lives at outputs/videos/general/<name>.mp4.

PhotoMap asked the /boards/{id}/image_names and /videos/names endpoints, which return only names, and joined them straight onto <invokeai_root>/outputs/videos/<name> — so is_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/images still 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 reads image_subfolder / video_subfolder off each row, returning paths relative to that media type's outputs directory. routers/index.py joins 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)

  • The subfolder sanitizer could be walked out of the outputs directory. It normalized backslashes to / before validating, but tested is-absolute on the raw string, so \etc passed 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_subfolder rejects backslashes outright and every strategy emits /. 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 own grammar: relative, /-separated, no empty or dot segments, no backslashes anywhere.
  • Offset pagination is 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. 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 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; pruning live rows is not. Rows added mid-walk only produce duplicates, which the dedupe absorbs.
  • The same check catches an intermediary that truncates pages, and the walk stops once it has paged past the declared total, so a proxy that ignores offset costs a handful of requests rather than the whole page budget.

Verification

  • Live against InvokeAI 6.14.0-alpha: 14103/14103 images on the largest board (15 pages, 2.7 s, no spurious re-read), 30/30 and 20/20 videos on the video boards, 250/250 on the uncategorized bucket across a mix of flat and subfoldered rows. Paging re-checked live at a forced page size of 100.
  • New test_board_media_in_subfolders_are_found covers 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.
  • Escape cases are parameterized over every backslash, absolute, traversal, and empty-segment spelling, asserted through the joined path.
  • New tests for the short-listing re-read, the fail-rather-than-prune path, the offset-ignoring server, a payload with no total, and rows added mid-walk.
  • 773 passed, ruff clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JLPwCcYrkpzwaTbk4FwjuB

lstein and others added 3 commits August 20, 2026 22:30
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
lstein force-pushed the lstein/fix/invokeai-media-subfolders branch from b2886c3 to de9c76c Compare August 21, 2026 02:34
@lstein
lstein enabled auto-merge (squash) August 21, 2026 02:36
@lstein
lstein merged commit bbf76c7 into master Aug 21, 2026
9 checks passed
@lstein
lstein deleted the lstein/fix/invokeai-media-subfolders branch August 21, 2026 02:44
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