Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/user-guide/invokeai-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ present on the select board(s) will be displayed, and deleting an
image within PhotoMapAI will trigger a deletion event in InvokeAI so
that the gallery and albumm remain in sync.

Videos on the selected board(s) are indexed too, alongside the
images. As in any other album, a video is represented by a still frame
taken shortly after its start, so it takes part in search and
clustering just like a photo, and clicking its play button opens it in
the embedded player. Deleting a video routes through InvokeAI as well,
so the gallery stays in sync the same way it does for images.

!!! warning
PhotoMapAI does not automatically watch the InvokeAI gallery for new
or deleted files. Whenever you have generated a batch of images you want to
Expand Down
49 changes: 38 additions & 11 deletions photomap/backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,17 +143,28 @@ class Album(BaseModel):
def _derive_board_album_fields(cls, data: Any) -> Any:
"""Fill in `image_paths` and `index` for InvokeAI-board albums.

Board albums have no user-chosen image directory: their images live
under `<invokeai_root>/outputs/images` and their index in the user
data directory. Both must be derived *before* field validation
because `image_paths` has `min_length=1` and `index` is required.
Board albums have no user-chosen image directory: their media live
under `<invokeai_root>/outputs/images` and `<invokeai_root>/outputs/videos`,
and their index in the user data directory. Both must be derived
*before* field validation because `image_paths` has `min_length=1`
and `index` is required.

The paths are recomputed on every construction rather than only when
absent. They are derived, not user-chosen, so whatever was persisted
must never win over the current root — and albums written before
video support stored only the images directory, which is how those
pick up the videos directory with no migration step. (`image_paths`
is what gates file access and relative-path resolution, so a board
video would otherwise be indexed but refused by `/videos/…`.)
"""
if not isinstance(data, dict) or data.get("source_type") != "invokeai_board":
return data
root = data.get("invokeai_root")
if root and not data.get("image_paths"):
if root:
outputs = Path(root).expanduser() / "outputs"
data["image_paths"] = [
str(Path(root).expanduser() / "outputs" / "images")
str(outputs / "images"),
str(outputs / "videos"),
]
if not data.get("index") and data.get("key"):
data["index"] = default_board_index_path(str(data["key"])).as_posix()
Expand Down Expand Up @@ -183,13 +194,29 @@ def _validate_board_fields(self) -> "Album":

@field_validator("image_paths")
@classmethod
def expand_and_validate_image_paths(cls, v: list[str]) -> list[str]:
"""Expand ~ and warn if image paths do not exist."""
expanded = [str(Path(path).expanduser()) for path in v]
for path in expanded:
def expand_image_paths(cls, v: list[str]) -> list[str]:
"""Expand ``~`` in image paths."""
return [str(Path(path).expanduser()) for path in v]

@model_validator(mode="after")
def _warn_about_missing_image_paths(self) -> "Album":
"""Warn about configured directories that are not on disk.

Runs after the model is built rather than as a field validator so it
can see ``source_type``: a board album's ``outputs/videos`` is derived
from the InvokeAI root, and InvokeAI does not create that directory
until it first writes a video. Warning about it would fire on every
config load for a perfectly correct setup, so a board album's derived
directories are exempt — for those, a wrong root surfaces as the
pointed "none of the board files were found" error at index time,
which is a better diagnosis than this warning could give.
"""
if self.source_type == "invokeai_board":
return self
for path in self.image_paths:
if not Path(path).exists():
logger.warning(f"Image path does not exist: {path}")
return expanded
return self

@field_validator("index")
@classmethod
Expand Down
195 changes: 194 additions & 1 deletion photomap/backend/invokeai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
running InvokeAI instance: URL validation, the JWT token cache with its
single-user/multi-user fallback logic, and thin wrappers around the
InvokeAI REST endpoints PhotoMap consumes (version probe, board listing,
board image names, image deletion).
board image and video names, image and video deletion).

Images and videos are distinct resources on the InvokeAI side — separate
routers, separate listing shapes, separate output directories — so each has
its own wrapper here rather than one parameterized by media type.

It deliberately lives outside ``routers/`` so that non-router code (the
indexing pipeline, curation) can use it without importing a FastAPI router
Expand All @@ -23,6 +27,7 @@
import logging
import time
from collections.abc import Awaitable, Callable
from typing import NamedTuple
from urllib.parse import urlsplit

import httpx
Expand Down Expand Up @@ -346,6 +351,121 @@ async def _do(
return list(dict.fromkeys(all_names))


class BoardVideoNames(NamedTuple):
"""What :func:`fetch_board_video_names` learned about a board's videos.

``api_available`` is False when the backend answered 404 for the video
router. ``names`` may still be non-empty in that case (earlier boards in
the same call succeeded); what the flag says is that the listing is
incomplete, so an empty or short list must not be read as "these videos
were removed from the board".
"""

names: list[str]
api_available: bool


async def fetch_board_video_names(
base_url: str,
board_ids: list[str],
username: str | None,
password: str | None,
) -> BoardVideoNames:
"""Return the video names belonging to ``board_ids``, deduplicated.

Videos are a separate resource from images in InvokeAI, with their own
router: this calls ``GET /api/v1/videos/names?board_id=...`` once per
board (the board is a *query* parameter here, unlike the images
endpoint's path parameter) and reads the ``video_names`` array out of the
returned ``VideoNamesResult`` object. Returned names include their
extension (``{uuid}.mp4``) and resolve under
``<invokeai_root>/outputs/videos``.

The same ``is_intermediate``/``categories`` filters as
:func:`fetch_board_image_names` are applied, and they matter just as much
here: a Wan pipeline writes its intermediate clips to the board too, so
an unfiltered listing returns videos the InvokeAI gallery itself hides.

A backend predating video support has no ``/api/v1/videos`` router at
all and answers **404**, which must not fail the whole index run — board
albums have to keep indexing their images against an older InvokeAI.
That case is reported as ``api_available=False`` rather than as a bare
empty list, because a 404 is *not* unambiguous: InvokeAI's own
``get_video_names`` calls ``assert_board_read_access``, which answers 404
"Board not found" for a non-admin caller whose board id no longer
resolves, and a reverse proxy in front of InvokeAI can route
``/api/v1/images`` while 404ing ``/api/v1/videos``. An empty listing and
an absent listing are different facts to the caller: the first means the
board has no videos, the second means we do not know what it has, and
only the caller can decide whether dropping previously indexed videos is
warranted.

Names already collected from earlier boards are kept when a later board
404s, for the same reason: they were fetched successfully and are not
made wrong by a subsequent failure.
"""
filter_params = {
"is_intermediate": "false",
"categories": ["general", "user"],
}
names_url = f"{base_url.rstrip('/')}/api/v1/videos/names"
all_names: list[str] = []
try:
async with httpx.AsyncClient(timeout=_BOARD_FETCH_TIMEOUT) as client:
for board_id in board_ids:
params = {**filter_params, "board_id": board_id}

async def _do(
headers: dict[str, str], params: dict = params
) -> httpx.Response:
return await client.get(names_url, params=params, headers=headers)

response = await _request_with_auth_fallback(
base_url, username, password, _do
)
if response.status_code == 404:
logger.info(
"InvokeAI backend at %s did not answer the video-names "
"endpoint for board %r (no video API, or the board is "
"not readable); board videos were not listed.",
base_url,
board_id,
)
return BoardVideoNames(list(dict.fromkeys(all_names)), False)
if response.status_code >= 400:
raise HTTPException(
status_code=502,
detail=(
f"InvokeAI backend returned {response.status_code} for "
f"videos on board {board_id!r}: {response.text[:200]}"
),
)
try:
payload = response.json()
except ValueError as exc:
raise HTTPException(
status_code=502,
detail=f"Video-names endpoint for board {board_id!r} did not return JSON",
) from exc
names = payload.get("video_names") if isinstance(payload, dict) else None
if not isinstance(names, list):
raise HTTPException(
status_code=502,
detail=f"Video-names endpoint for board {board_id!r} returned an unexpected shape",
)
all_names.extend(str(name) for name in names)
except httpx.RequestError as exc:
logger.warning("InvokeAI video-names request failed: %s", exc)
raise HTTPException(
status_code=502,
detail=f"Could not reach InvokeAI backend at {base_url}: {exc}",
) from exc

# A video belongs to one board, but overlapping selections (a board plus
# "none") must not index the same file twice — dedupe preserving order.
return BoardVideoNames(list(dict.fromkeys(all_names)), True)


async def delete_image(
base_url: str,
image_name: str,
Expand Down Expand Up @@ -389,3 +509,76 @@ async def _do(headers: dict[str, str]) -> httpx.Response:
f"{response.text[:200]}"
),
)


async def delete_video(
base_url: str,
video_name: str,
username: str | None,
password: str | None,
) -> None:
"""Delete ``video_name`` on the InvokeAI backend.

The video counterpart of :func:`delete_image`, and a separate call rather
than a different path handed to that one: videos live behind their own
router and answer with a ``DeleteVideosResult`` body instead of the
images route's ``DeleteImagesResult``.

Today's single-video route reports failure as a 500 and always sends an
empty ``failed_videos``; a populated one comes from the batch
``POST /videos/delete``. The list is still checked, because it is the
one way this endpoint can report a failure *with* HTTP 200, and taking
that for success would drop the row from the local index while the file
stayed in InvokeAI — the video would silently reappear on the next
re-index.

As with images, a 404 means InvokeAI no longer knows the video: log and
return so the caller can still drop it locally.
"""
url = f"{base_url.rstrip('/')}/api/v1/videos/i/{video_name}"
try:
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client:

async def _do(headers: dict[str, str]) -> httpx.Response:
return await client.delete(url, headers=headers)

response = await _request_with_auth_fallback(
base_url, username, password, _do
)
except httpx.RequestError as exc:
logger.warning("InvokeAI video delete request failed: %s", exc)
raise HTTPException(
status_code=502,
detail=f"Could not reach InvokeAI backend at {base_url}: {exc}",
) from exc

if response.status_code == 404:
logger.warning(
"InvokeAI no longer has video %s; removing from index anyway",
video_name,
)
return
if response.status_code >= 400:
raise HTTPException(
status_code=502,
detail=(
f"InvokeAI video delete returned {response.status_code}: "
f"{response.text[:200]}"
),
)

try:
payload = response.json()
except ValueError:
payload = None
# Guarded on the shape, not just on the parse: a proxy (or a future
# response model) can answer 200 with a JSON array or string, and
# ``.get`` on one of those would escape as an AttributeError *after*
# InvokeAI had already deleted the video — leaving the row in the local
# index pointing at a file that is gone.
failed = payload.get("failed_videos") if isinstance(payload, dict) else None
if isinstance(failed, list) and video_name in failed:
raise HTTPException(
status_code=502,
detail=f"InvokeAI reported that {video_name} could not be deleted",
)
Loading