Skip to content

Index InvokeAI board videos alongside board images - #369

Merged
lstein merged 3 commits into
masterfrom
lstein/feature/invokeai-board-videos
Aug 19, 2026
Merged

Index InvokeAI board videos alongside board images#369
lstein merged 3 commits into
masterfrom
lstein/feature/invokeai-board-videos

Conversation

@lstein

@lstein lstein commented Aug 18, 2026

Copy link
Copy Markdown
Owner

An InvokeAI board holds videos next to its images and its gallery shows both, so a board-backed album now indexes both. This removes the board-video restriction added in #361, which skipped them explicitly pending a verified delete path.

What changed

InvokeAI client

  • fetch_board_video_names() lists a board's videos. Videos are a separate resource over there: their own router, the board passed as a query parameter rather than a path segment, and a VideoNamesResult object instead of the bare array the images endpoint returns. The same is_intermediate=false + categories=general,user filtering as images applies, and it matters — a Wan pipeline writes its intermediate clips to the board too (a real board listed 52 unfiltered vs 48 filtered).
  • A backend predating the video API 404s the whole router, which reads as "no videos here" rather than a failed index run, so board albums on an older InvokeAI keep indexing their images.
  • delete_video() routes deletions to the video endpoint. It also checks failed_videos, because that endpoint can report a failure inside a 200 — accepting it would drop the local index row while the video stayed on the board, and the video would reappear on the next re-index.

Board albums

  • Both output directories are derived now (outputs/images and outputs/videos). image_paths is what gates file access and relative-path resolution, so a board video would otherwise index but be refused at playback. The paths are recomputed on every construction, which is how albums written before this pick up the videos directory with no migration step.
  • Deletions dispatch on suffix: sending a video name to the images endpoint just 404s, which the client reads as "already gone" and swallows.
  • The missing-on-disk discrepancy warning and the "board is empty" error now count both media types.

Verification

Backend 679 passed, frontend 578 passed, ruff clean.

Verified end-to-end against a live InvokeAI: 48 videos + 2 images resolved off a real board, through the auth fallback, with frame extraction and probe facts intact.

Note on the merge commit

Master's video work (#358#362) landed while this was open. embeddings.py took master's side wholesale — its video indexing is a superset of what this branch had grown independently — and _resolve_board_album_files kept this branch's. Details are in the merge commit message. Since PRs here are squash-merged, the merge commit disappears on landing.

🤖 Generated with Claude Code

lstein and others added 2 commits August 17, 2026 22:07
An InvokeAI board holds videos next to its images and its gallery shows
both, so a board-backed album now indexes both.

* ``fetch_board_video_names`` lists a board's videos. Videos are a
  separate resource over there: their own router, the board passed as a
  query parameter rather than a path segment, and a ``VideoNamesResult``
  object instead of a bare array. A backend predating the video API 404s
  the whole router, which reads as "no videos here" so those albums keep
  indexing their images.
* ``delete_video`` routes video deletions to the video endpoint. It also
  checks ``failed_videos``, because that endpoint can report a failure
  inside a 200 — accepting it would drop the local index row while the
  video stayed on the board.
* Board albums derive both output directories now. ``image_paths`` is
  what gates file access and relative-path resolution, so a board video
  would otherwise index but be refused at playback. The paths are
  recomputed on every construction, which is how albums written before
  this pick up the videos directory with no migration.
* The indexing pipeline itself learned videos: a video is loaded as the
  still frame ffmpeg extracts near its start and then flows through the
  encoder exactly like a photo, with the probe facts riding along in the
  per-image metadata. The scan gate skips the pixel probe for videos,
  which has no header to read and would otherwise reject every clip
  small enough to reach it.

Collecting videos is opt-in per album (``Embeddings.index_videos``) and
only board albums set it: turning it on for a directory album re-scans it
for a whole new media type and makes indexing depend on ffmpeg, which is
a separate call to make.

Verified end-to-end against a live InvokeAI: 48 videos + 2 images
resolved off a real board, with frame extraction and probe facts intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Master grew its own video support while this branch was open (#358#362:
badge, modal player, guards, indexing, semantic-map filter). Conflict
resolution:

* ``embeddings.py`` — took master's wholesale. Its video indexing is a
  superset of this branch's: same still-frame-as-the-image approach, plus
  priming the per-album frame cache at index time, invalidating
  scan-reject entries written while videos still went through the pixel
  gate, and pruning stale stills. ``Embeddings.index_videos`` and
  ``scan_extensions`` are gone with it — master collects videos for every
  album, so there is nothing left to opt into.
* ``_resolve_board_album_files`` — kept this branch's. Master had added a
  filter that skipped board videos explicitly, because board deletion
  routed through ``delete_image`` and had not been verified against them.
  That is precisely what this branch fixes: videos are deleted through
  ``delete_video``, verified against a live InvokeAI.
* Both ``Embeddings(...)`` call sites take master's ``album_key=``.

Also dropped the docs note claiming videos were board-album-only, which
master's directory-album indexing makes false.
An adversarial pass over #369 turned up four defects in the board-video
work and three smaller ones. All are fixed here.

* ``delete_video`` read ``failed_videos`` off the response body guarded
  only against a parse failure, so a 200 carrying a JSON array or string
  raised ``AttributeError`` — surfacing as a 500 *after* InvokeAI had
  already deleted the video, leaving the row in the local index pointing
  at a file that is gone. Guarded on the shape now, as its sibling
  ``fetch_board_video_names`` already was.

* Deleting a single board video never discarded its extracted still. The
  non-board branch and the batch endpoint both do; the board branch
  returned first. Inert before this feature, since a board album could
  not hold a video.

* ``fetch_board_video_names`` reported "no video API" and a board with no
  videos as the same empty list, and the index update prunes rows for
  every file the resolver does not return — so an unreachable video
  router silently dropped every indexed board video and reported a clean
  success. It now returns ``BoardVideoNames(names, api_available)``, and
  a run that loses rows to an unanswered listing says so. The docstring
  claimed a 404 could only mean a missing router; it cannot. InvokeAI's
  ``get_video_names`` calls ``assert_board_read_access``, which answers
  404 "Board not found" to a non-admin caller, and a proxy can route
  ``/api/v1/images`` while 404ing ``/api/v1/videos``. Names already
  collected from earlier boards are kept when a later board 404s.

* ``_delete_board_media`` justified its suffix dispatch with a 404 that
  does not happen: InvokeAI's image-delete route wraps its lookup in a
  bare ``except`` and answers 200 with an empty ``deleted_images``, so a
  mis-dispatch would read as success rather than as "already gone". The
  dispatch is right; the reasoning was not.

* Deriving ``<root>/outputs/videos`` made the "Image path does not exist"
  warning fire on every config load for any InvokeAI that has never
  produced a video — which is most of them, on a correct configuration.
  The warning moved to a model validator that can see ``source_type`` and
  exempts a board album's derived directories; a wrong root still
  surfaces at index time, with a better diagnosis than this could give.

* A board holding only videos, none of them on disk, blamed ``outputs``
  as a whole. The error names the directory that actually came up empty,
  and an empty album after an unanswered video listing no longer asserts
  "contains no videos" — that is precisely what could not be checked.

Tests: the missing-video test was gated on ffmpeg while never touching a
video file, which left the two-directory accounting with no coverage
anywhere ffmpeg is absent. ``min_image_bytes: 0`` went out of the board
fixture (the bundled images clear the default gate, and videos bypass it
outright). New: a board video served end-to-end through ``/videos/`` and
``/video_frame/``, the frame-cache discard on delete, both outage shapes
including the partial listing that must *not* claim videos were dropped,
the videos-only "none found" error, the legacy single-path album gaining
the videos directory, and a 200 delete body that is not an object.

Backend 691 passed, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lstein
lstein enabled auto-merge (squash) August 19, 2026 01:37
@lstein

lstein commented Aug 19, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review

Three fresh-context agents attacked this branch on separate lanes (config path derivation, the InvokeAI client + delete dispatch, indexing pipeline + test honesty). Every claim below was verified against both this code and the InvokeAI checkout it talks to. Baseline reproduced clean at a826b8e: 679 backend tests passed.

Everything found is fixed in 87061d6.

Confirmed defects

1. delete_video crashed on a 200 whose body is not a JSON objectinvokeai_client.py

response.json().get("failed_videos") was guarded only against ValueError. A 200 carrying a JSON array/string/null raised AttributeError, surfacing as a 500 after InvokeAI had already deleted the video — leaving the index row pointing at a file that is gone, which is the exact inconsistency the failed_videos check exists to prevent. Its sibling fetch_board_video_names already guarded the same hazard with isinstance(payload, dict).

2. Single-delete of a board video orphaned its extracted stillrouters/index.py

The board branch returned before _discard_cached_frame; the non-board branch and the batch endpoint both call it. Inert before this PR, because a board album could not hold a video.

3. A falsely-empty video listing silently pruned already-indexed videosinvokeai_client.pyrouters/index.pyembeddings.py

fetch_board_video_names returned [] on 404, so _resolve_board_album_files reported missing == 0, update_index_async classified every previously-indexed board video as missing, _filter_missing_images dropped the rows and _prune_video_frame_cache deleted their stills — and the run reported completed with no warning. Note the asymmetry: every other status raises 502 and preserves the index; only the destructive case was silent. Reachable via a reverse proxy that routes /api/v1/images but not /api/v1/videos, or an InvokeAI downgrade.

4. "A 404 cannot mean anything else here" is falseinvokeai_client.py

InvokeAI's get_video_names calls assert_board_read_access, which raises 404 "Board not found" for a non-admin caller (invokeai/app/api/routers/_access.py:81-84). What actually saved the code was an undocumented ordering coupling — _resolve_board_album_files fetches image names first, and the boards router 404s an unknown board for all users, so the run already 502s before the video call. Separately, the return [] sat inside the per-board loop, discarding names already collected from earlier boards.

5. _delete_board_media's stated rationale was wrongrouters/index.py

It claimed a video name sent to the images endpoint "just returns 404". InvokeAI's DELETE /images/i/{name} wraps its lookup in a bare except Exception: pass and returns 200 with an empty deleted_images (invokeai/app/api/routers/images.py:213-226) — a mis-dispatch would read as success, not as a swallowed 404. The dispatch is right; the reasoning was not.

Lower severity

  • Permanent false warning: the derived <root>/outputs/videos made Image path does not exist fire on every Album construction for any InvokeAI that has never produced a video — i.e. most installs, on a correct configuration.
  • Misleading fatal error: a board holding only videos, none on disk, hit missing and not existing → 502 blaming outputs as a whole, when only the videos directory came up empty. Pre-PR that album completed with "contain no images".
  • Test gaps: test_missing_board_video_is_skipped_not_fatal was marked @requires_ffmpeg but never touches a video file — and it was the only coverage of the new two-directory missing arithmetic, so on ffmpeg-less platforms that accounting had none. min_image_bytes: 0 in the board fixture was unnecessary (the bundled images clear the 8192 default; videos bypass the gate outright) and its comment was inaccurate. Nothing covered the legacy single-path album gaining the videos directory, nor a board video actually serving through /videos/… — the entire stated justification for the image_paths change.

Attacks that failed

Suffix mis-dispatch (InvokeAI names are {uuid}.png/{uuid}.mp4 unconditionally and video upload accepts .mp4 only, so #361's is_video filter was defensive — removing it is safe); images↔videos name collisions (disjoint suffix allowlists on the serving routes, thumbnail keys hash the extension); config round-trip idempotence and ~/symlink/relative-root handling; the late-binding closure over params in the per-board loop (correctly pinned); batch-delete consistency (per-item try/except, rows dropped only after a successful remote delete, single npz rewrite); auth-fallback loops or token poisoning; videos being rejected by the scan gate; progress-tracker stuck states on 502; and every downstream path from #358#362 (umap media filter, duplicate detection, zip/curation export, the recall guard) behaving identically for board and directory videos.

What changed in 87061d6

fetch_board_video_names now returns BoardVideoNames(names, api_available), so "this board has no videos" and "we could not find out" are different facts to the caller; a run that loses rows to an unanswered listing says so, counting only the videos actually being pruned rather than every indexed video. Plus the shape guard in delete_video, the frame-cache discard on board delete, the two corrected rationales, the warning exemption for derived board directories, a per-kind "none found" error, and seven new tests (end-to-end video serving, frame-cache discard, both outage shapes including the partial listing that must not claim videos were dropped, the videos-only error, the legacy path migration, and a non-object delete body).

The fixes went through two further adversarial rounds. The first found three real defects in them — the drop count included videos being kept, the empty-album error still asserted "no videos" during an outage, and the count primed a path-keyed lru_cache immediately before a possible unlink(). All three are fixed and mutation-verified (each new test fails with its production hunk reverted); the second round found nothing new.

Backend 691 passed, ruff clean.

Out of scope

Filed #371 for a pre-existing bug this widens: bookmarks.js's "Move to folder → Add Folder" POSTs a partial update_album/ payload, and update_album defaults source_type to directory — silently demoting a board album, after which deletions stop routing through InvokeAI and the next index scans outputs/images and now outputs/videos as plain directories.

Two other pre-existing items left alone: _request_with_auth_fallback treats a genuine 403 as "backend went single-user" (now more reachable, since the video endpoints have real 403 sources), and delete_image never inspects deleted_images — adding that check would break the legitimate "already gone" path, so it needs its own design.

🤖 Generated with Claude Code

@lstein
lstein merged commit 4ee135d into master Aug 19, 2026
9 checks passed
@lstein
lstein deleted the lstein/feature/invokeai-board-videos branch August 19, 2026 01:46
lstein added a commit that referenced this pull request Aug 20, 2026
Rebase fallout, not a behaviour change. #369 gave board albums an
``outputs/videos`` directory alongside ``outputs/images``, so "the paths
this root derives" is now both. The test echoed back only the images
directory, which is genuinely not what the album derives, and the guard was
right to refuse it with a 400.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lstein added a commit that referenced this pull request Aug 20, 2026
* fix: treat an album update as a patch, not a replacement (#371)

``POST /update_album/`` rebuilt the album from the request payload alone,
so every field the payload left out was reset to its model default. Real
callers send partial payloads — the bookmark menu sends five keys, the
search dialog sends the album plus three overrides — and the worst of
what that reset was ``source_type``: an InvokeAI-board album came back as
a directory album with every ``invokeai_*`` field cleared. After that the
album's own indexing walked InvokeAI's output directories as if they were
ordinary folders, and deletions stopped routing through InvokeAI's API,
leaving dangling rows in its database. Reproducing it took one bookmark
and the "Move to folder → Yes, Add Folder" prompt.

Now a key the payload does not carry keeps its stored value, and falsy
values that real edits send (``min_image_bytes: 0``,
``use_query_optimization: false``, an emptied description) still win.
Null is treated as absent, because ``create_album`` turns a None into the
model *default* rather than into a cleared field — honoring it would swap
an album's encoder for the host default and silently invalidate its
index. The one field that genuinely needs clearing, ``invokeai_username``
(InvokeAI leaving multi-user mode), goes through a helper that keys on
presence instead.

Two things the patch rule would otherwise have broken, and one it
exposed:

* Board albums pass ``image_paths=None`` so the model re-derives them.
  Carrying the stored list over would suppress that derivation and pin
  the album to its old root the moment the user edits the root — the
  re-index would write under the new root while the access gate still
  pointed at the old one, 404ing every image.
* ``min_search_score`` is left to the model to re-resolve when the
  encoder *family* changes. The floors differ by an order of magnitude
  (0.005 for SigLIP, 0.2 for CLIP), so carrying one across makes the new
  encoder look like it returns nothing — but swapping one CLIP model for
  another must not discard a score the user tuned by hand. Both the model
  and the router now read the rule from one ``default_min_search_score``.
* An update whose ``source_type`` disagrees with the stored album is
  refused: the edit form branches on the stored kind and never offers to
  switch, so a disagreement is a partial payload being read as a
  replacement.

A board album's directories cannot be changed or added to, and saying so
beats accepting the request and quietly deriving something else — the
request is refused with a 400 unless it merely echoes the album's paths
(either the stored list or the one the same request derives, both of
which callers legitimately send while a root edit is in flight). An
``HTTPException`` raised inside the handler is re-raised rather than
wrapped, so that 400 (and the 404 for an unknown key) survives as itself.

Frontend: the bookmark menu no longer offers to add a destination folder
to a board album, since the backend has to refuse it, and its update
payload carries only the keys it is changing.

Tests: eleven new cases covering the demotion, the refused folder and
source-type changes, root changes moving the derived paths, an explicit
null neither clearing a tuning field nor surviving as one, the username
clear, the encoder-family re-resolve and the same-family preservation, a
blank index, and an unresolvable path being refused rather than crashing.
Board-album tests also stopped deleting from the developer's real user
data directory: ``default_board_index_path`` resolves against
``platformdirs.user_data_dir``, which nothing isolated, so a test album
keyed like a real one removed the real index on cleanup.

Backend 680 passed, frontend 581 passed, ruff and prettier clean.

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

* fix: let the InvokeAI password be cleared, not only replaced

A blank password means "I did not touch this" — the edit form never sees
the stored one — so there was no way to say "forget it". A backend that
leaves multi-user mode left a credential in config.yaml that nothing but
a text editor could remove, and ``has_invokeai_password`` kept reporting
it to the form.

``invokeai_password: null`` now clears it, while ``""`` and an omitted
key still keep what is stored. No existing caller can send that null by
accident: ``_album_public_dict`` carries ``has_invokeai_password`` and
never the password itself, so the search-settings persister's round trip
cannot reach it, and the bookmark menu sends three keys. The only source
is a new *Forget saved password* checkbox in the edit form, offered only
when there is one to forget and reset every time the form opens; a
password typed in the same edit wins over it.

The checkbox needs its own ``[hidden]`` rule: it lives in a ``<label>``
inside a ``.form-group``, and that selector sets ``display: block``,
which as an author rule beats the UA stylesheet's ``[hidden]`` — the
element would have stayed on screen for albums with no stored password.
Same shape as the existing ``.video-player-*[hidden]`` rules.

Clearing (or changing) a credential also invalidates the cached JWT. The
token is keyed on ``(base_url, username)``, neither of which has to
change when a password does, so a forgotten password would otherwise
keep working from cache until the token expired — up to a day later.

Tests: the three-way backend distinction (absent / blank / null), and on
the frontend the payload matrix including "typed wins over the box", the
row's visibility under the real CSS rule (asserting computed style, since
the attribute alone reported hidden while the box was visible), and the
reset on re-open.

Backend 681 passed, frontend 587 passed, ruff and prettier clean.

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

* test: a board album's derived paths are a pair, not just images

Rebase fallout, not a behaviour change. #369 gave board albums an
``outputs/videos`` directory alongside ``outputs/images``, so "the paths
this root derives" is now both. The test echoed back only the images
directory, which is genuinely not what the album derives, and the guard was
right to refuse it with a 400.

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

* fix: close four gaps in the update-as-patch rules

Adversarial review of this branch. The first is a regression the patch
semantics introduced; the rest are older, but this endpoint's new contract
is what makes them contradictions.

**A null min_search_score kept a stale floor across an encoder change.**
The re-resolve was gated on `"min_search_score" not in album_data` while the
value came from `kept`, which treats a null as absent — so the two halves
disagreed. An explicit null both suppressed the re-resolve and fell through
to the stored number, leaving a CLIP floor of 0.35 on a SigLIP album whose
similarities sit around 0.05-0.15, i.e. a search that silently returns
nothing. Gating on `is None` makes both halves agree. Before this branch the
same payload re-resolved correctly, so this one is new.

**`name` was not patchable.** It was read straight out of the payload while
the docstring promised that any omitted key keeps its stored value, so
`{"key": "a", "min_search_score": 0.3}` raised a KeyError that surfaced as a
500 whose detail was the word "name".

**A missing album answered 404 or 500 depending on the payload.** A complete
one reached the failed write and got its 404; a partial one died building a
half-formed Album first. With patch semantics there is nothing to patch
either way, so the key is checked up front.

**The token cache was invalidated before the write, not after.** The cache
key is (url, username) and does not include the password, so any request
that read the album in between — an index scan, a board delete — logged in
with the old password and re-cached a token that outlived the change by up
to a day. That is precisely what the invalidation was added to prevent.

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

* fix: reload search settings after an edit; accept an echoed board path list

Two remaining findings from the adversarial review of this branch.

**A stale score could be persisted back over a re-resolved one.** Changing
an album's encoder family re-resolves min_search_score server-side, but the
album manager never told state about it, so `state.minSearchScore` kept the
old family's floor. The next nudge of any search setting then POSTed that
floor back — 0.2 on a SigLIP album, whose similarities sit around 0.05-0.15,
so search silently returns nothing.

The stomp predates this branch; what is new is that it sticks. Every album
edit used to re-resolve the score from the encoder, so a bad value was
corrected on the next save; now that an update keeps the fields its payload
carries, it survives. `saveAlbumChanges` reloads the active album's settings
before anything can write them back.

**A board album refused a path list it had just handed out.** The guard
compared normalized lists, so a caller echoing the album's own directories
in a different order got a 400, and so did one echoing a snapshot taken
before board albums gained their `outputs/videos` directory — one path where
the album now has two. Neither is asking for a change. It compares sets and
accepts a subset now, which is the same tolerance the guard already extends
to a caller whose snapshot straddles a root edit. A directory the album does
not derive is still refused out loud.

Three sibling suites mock state.js and had to learn the new export.

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