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
486 changes: 155 additions & 331 deletions photomap/backend/invokeai_client.py

Large diffs are not rendered by default.

31 changes: 10 additions & 21 deletions photomap/backend/routers/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -722,20 +722,12 @@ class BoardAlbumFiles(NamedTuple):
async def _resolve_board_album_files(album_config) -> BoardAlbumFiles:
"""Resolve an InvokeAI-board album's images and videos to local paths.

Fetches the selected boards' images *and* videos from the InvokeAI API
and joins the returned paths to ``<invokeai_root>/outputs/images`` and
``<invokeai_root>/outputs/videos`` respectively — the two are separate
resources on the InvokeAI side, listed by separate endpoints and stored
in separate directories, so both have to be asked for.

Those paths are *relative*, not bare filenames: InvokeAI files each
image and video into a subfolder chosen by a server-side strategy
(``flat``, ``type``, ``date``, ``hash``), recorded per row, so a board's
videos routinely live at ``outputs/videos/general/<name>`` rather than
``outputs/videos/<name>``. The client hands back whatever prefix the
server reported (empty for a flat backend), and this only joins it.

Files the API lists but that don't exist locally are skipped with a
Fetches the selected boards' image names *and* video names from the
InvokeAI API and maps them to ``<invokeai_root>/outputs/images/<name>``
and ``<invokeai_root>/outputs/videos/<name>`` respectively — the two are
separate resources on the InvokeAI side, listed by separate endpoints and
stored in separate directories, so both have to be asked for by name.
Names the API lists but that don't exist locally are skipped with a
warning; if *none* of them exist the InvokeAI root is almost certainly
wrong, which deserves a pointed error instead of a generic "no images
found". The error names the directory that actually came up empty, since
Expand All @@ -750,7 +742,7 @@ async def _resolve_board_album_files(album_config) -> BoardAlbumFiles:
still on the board.
"""
outputs = Path(album_config.invokeai_root).expanduser() / "outputs"
image_relpaths = await invokeai_client.fetch_board_image_relpaths(
image_names = await invokeai_client.fetch_board_image_names(
album_config.invokeai_url,
album_config.invokeai_board_ids,
album_config.invokeai_username,
Expand All @@ -759,18 +751,15 @@ async def _resolve_board_album_files(album_config) -> BoardAlbumFiles:
# ``api_available`` is False against an InvokeAI with no video API, so a
# board album on an older backend keeps indexing exactly as it did before
# — the caller only has to say so when it costs the album something.
(
video_relpaths,
video_api_available,
) = await invokeai_client.fetch_board_video_relpaths(
video_names, video_api_available = await invokeai_client.fetch_board_video_names(
album_config.invokeai_url,
album_config.invokeai_board_ids,
album_config.invokeai_username,
album_config.invokeai_password,
)

image_paths = [outputs / "images" / rel for rel in image_relpaths]
video_paths = [outputs / "videos" / rel for rel in video_relpaths]
image_paths = [outputs / "images" / name for name in image_names]
video_paths = [outputs / "videos" / name for name in video_names]
paths = image_paths + video_paths
existing = [p for p in paths if p.is_file()]
missing = len(paths) - len(existing)
Expand Down
11 changes: 0 additions & 11 deletions photomap/frontend/static/css/umap-floating-window.css
Original file line number Diff line number Diff line change
Expand Up @@ -268,17 +268,6 @@
display: none;
}

/* The Cluster Strength field holds something the album cannot be set to — a
half-typed number, or one below the floor the server would silently raise.
umap.js answers those by doing nothing, so without a mark a refused
keystroke and a saved one look the same. Set from JS rather than `:invalid`
so it never fires on a derived strength that simply exceeds the spinner's
display `max`, which is a real number the map is clustering with. */
#umapEpsSpinner.umap-eps-unusable {
border: 1px solid #ff8080;
outline: none;
}

#umapClickBehaviorContainer,
#umapMediaFilterContainer {
font-size: 0.85em;
Expand Down
9 changes: 7 additions & 2 deletions photomap/frontend/static/javascript/back-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// recent positions as thumbnails the user can jump to directly.

import { backStack } from "./back-stack.js";
import { visibleViewportBottom } from "./panel-anchor.js";
import { state } from "./state.js";

const FLYOUT_ROWS = 3;
Expand Down Expand Up @@ -68,13 +69,17 @@ function populateFlyout(flyout) {
function clampFlyoutPosition(flyout) {
const rect = flyout.getBoundingClientRect();
const margin = 6;
// Not window.innerHeight: on iPadOS the bottom of the layout viewport can be
// off the screen (see panel-anchor.js), and clamping to it puts the bottom
// row of thumbnails somewhere the user cannot see.
const bottom = visibleViewportBottom();
let left = flyout._anchorX;
let top = flyout._anchorY;
if (left + rect.width > window.innerWidth - margin) {
left = Math.max(margin, window.innerWidth - rect.width - margin);
}
if (top + rect.height > window.innerHeight - margin) {
top = Math.max(margin, window.innerHeight - rect.height - margin);
if (top + rect.height > bottom - margin) {
top = Math.max(margin, bottom - rect.height - margin);
}
flyout.style.left = `${left}px`;
flyout.style.top = `${top}px`;
Expand Down
5 changes: 4 additions & 1 deletion photomap/frontend/static/javascript/bookmarks.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { showDeleteConfirmModal } from "./control-panel.js";
import { createSimpleDirectoryPicker } from "./filetree.js";
import { deleteImages } from "./index.js";
import { showConfirmModal } from "./modal-utils.js";
import { visibleViewportBottom } from "./panel-anchor.js";
import { setSearchResults } from "./search.js";
import { slideState } from "./slide-state.js";
import { state } from "./state.js";
Expand Down Expand Up @@ -1006,7 +1007,9 @@ class BookmarkManager {
// Position after appending so we can measure
const menuHeight = menu.offsetHeight;
const menuWidth = menu.offsetWidth;
const windowHeight = window.innerHeight;
// The visible bottom edge, which on iPadOS is not always the layout
// viewport's bottom edge — see panel-anchor.js.
const windowHeight = visibleViewportBottom();
const windowWidth = window.innerWidth;

// Position above the click if would go off bottom
Expand Down
96 changes: 87 additions & 9 deletions photomap/frontend/static/javascript/control-panel.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// control-panel.js
// This file manages control panel button events (fullscreen, copy, delete)
import { deleteImage, getIndexMetadata } from "./index.js";
import { initializePanelAnchor, syncPanelAnchor } from "./panel-anchor.js";
import { getCurrentFilepath, getCurrentSlideIndex, slideState } from "./slide-state.js";
import { saveSettingsToLocalStorage, state } from "./state.js";
import { errorDetail, hideSpinner, showSpinner } from "./utils.js";
Expand All @@ -19,17 +20,48 @@ function cacheElements() {
};
}

// Toggle fullscreen mode
// Is the document (or anything in it) currently fullscreen?
//
// Which spelling of the Fullscreen API a browser exposes is not something this
// app gets to assume — reading only document.fullscreenElement can report "not
// fullscreen" while the app plainly is, and the panels then stay hidden with
// no way back. touch.js already checks all four; this now agrees with it
// rather than answering the same question differently.
function isDocumentFullscreen() {
return !!(
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.webkitCurrentFullScreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement
);
}

// Toggle fullscreen mode.
//
// The exit chain has to cover every spelling the state check above accepts, or
// the button becomes a one-way trip: enter succeeds, isDocumentFullscreen()
// then reports true through a prefixed property, and the exit call is missing.
// Note that neither legacy vendor spells it "exit" — Firefox cancels, and so
// did older WebKit.
function toggleFullscreen() {
const elem = document.documentElement;
if (!document.fullscreenElement) {
elem.requestFullscreen();
if (isDocumentFullscreen()) {
const exit =
document.exitFullscreen ||
document.webkitExitFullscreen ||
document.webkitCancelFullScreen ||
document.mozCancelFullScreen ||
document.msExitFullscreen;
exit?.call(document);
} else {
document.exitFullscreen();
const request =
elem.requestFullscreen || elem.webkitRequestFullscreen || elem.mozRequestFullScreen || elem.msRequestFullscreen;
request?.call(elem);
}
}

// Panel visibility follows document.fullscreenElement and nothing else.
// Panel visibility follows the current fullscreen state and nothing else.
//
// A <video controls> element has its own fullscreen button, which makes this
// fire with the video as the fullscreen element and again with none on the
Expand All @@ -45,15 +77,39 @@ function toggleFullscreen() {
// own fullscreen is entered and left in pairs, so the class ends up where it
// started, and while the modal is open its backdrop sits at z-index 99999 —
// so no intermediate state is ever visible to the user anyway.
function handleFullscreenChange() {
const isFullscreen = !!document.fullscreenElement;
//
// Because the class is derived from the current state rather than flipped,
// calling this more often than strictly necessary is always a no-op — which is
// what lets the extra resyncs below exist.
function syncPanelVisibility() {
const isFullscreen = isDocumentFullscreen();

// Toggle visibility of UI panels
[elements.controlPanel, elements.searchPanel, elements.scoreDisplay].forEach((panel) => {
if (panel) {
panel.classList.toggle("hidden-fullscreen", isFullscreen);
}
});

// Leaving fullscreen is the transition that strands the panels below the
// visible area on iPadOS; see panel-anchor.js. Entering is corrected too,
// since the layout viewport changes in both directions.
syncPanelAnchor();
}

// Neither the fullscreen state nor the viewport can be trusted to be settled
// at the moment the event fires. An exit event delivered while the document
// still names the outgoing fullscreen element reads as "still fullscreen" and
// latches the panels hidden in windowed mode, and iPadOS finishes resizing the
// viewport well after the event — the exit is animated. Both are unrecoverable
// if sampled once: visibility:hidden takes the fullscreen button out of hit
// testing, and a stranded panel is off the bottom of the screen, so in either
// case the user cannot reach the control that would undo it. Resampling over
// the following second costs nothing, because both syncs derive their result
// from the current state rather than toggling it.
function handleFullscreenChange() {
syncPanelVisibility();
[0, 250, 750].forEach((delay) => setTimeout(syncPanelVisibility, delay));
}

// Copy text to clipboard
Expand Down Expand Up @@ -226,14 +282,36 @@ function setupControlPanelEventListeners() {
elements.deleteCurrentFileBtn.addEventListener("click", handleDeleteCurrentFile);
}

// Fullscreen change event
document.addEventListener("fullscreenchange", handleFullscreenChange);
// Fullscreen change event. The prefixed spellings are for iPad browsers that
// only emit those; a browser emitting both just resyncs twice, which is a
// no-op.
["fullscreenchange", "webkitfullscreenchange", "mozfullscreenchange", "MSFullscreenChange"].forEach((eventName) => {
document.addEventListener(eventName, handleFullscreenChange);
});

// Self-healing resync. If a fullscreen transition is ever missed entirely —
// no event delivered, or every sample taken while the document still reports
// the outgoing element — the panels would otherwise stay hidden until a
// reload, with the button that would undo it out of hit testing. The visual
// viewport is included deliberately: on a stranded iPadOS exit the layout
// viewport does not change, so window.resize may never fire, while the
// visible area shrinking always does.
window.addEventListener("resize", syncPanelVisibility);
window.addEventListener("orientationchange", syncPanelVisibility);
window.visualViewport?.addEventListener("resize", syncPanelVisibility);
}

// Initialize control panel
export function initializeControlPanel() {
cacheElements();
setupControlPanelEventListeners();
// Every bottom-anchored element that a stranded layout viewport carries off
// the screen with it. The score display is not one: it hangs off the *top*
// of the viewport, and lifting it would push it off that edge instead.
// .curation-panel is bottom-anchored too but animates itself with a
// transform, which this would overwrite; it belongs to a separate mode and
// is left alone.
initializePanelAnchor([elements.controlPanel, elements.searchPanel, document.getElementById("textSearchPanel")]);
}

// Export for keyboard shortcuts
Expand Down
127 changes: 127 additions & 0 deletions photomap/frontend/static/javascript/panel-anchor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// panel-anchor.js
//
// Keeps the bottom-anchored panels inside the *visible* viewport.
//
// #controlPanel and #searchPanel are `position: fixed; bottom: 10px`, which
// resolves against the layout viewport. On iPadOS that is not always the part
// of the page you can see: leaving fullscreen shrinks the visible area as the
// browser chrome returns, and WebKit can keep the taller, fullscreen-sized
// layout viewport afterwards. The panels are then laid out ten pixels above a
// bottom edge that is off the tablet — the user watches them slide past the
// bottom of the screen on the way out of fullscreen and never sees them again,
// because nothing in the page ever re-lays them out. Rotating does not help and
// only a reload clears it.
//
// window.visualViewport reports the region that is actually on screen, so the
// distance between its bottom and the layout viewport's bottom is exactly how
// far the panels overshoot. Translating them back up by that much puts them
// where the CSS intended. When the two agree — every desktop browser, and iPad
// when it behaves — the offset is zero and this does nothing at all.

// The visible area shrinks by less than this for reasons that are not a
// stranded layout viewport: sub-pixel rounding, and the first fraction of a
// pinch before the scale guard below takes over. Browser chrome is far taller
// than this, so nothing real is filtered out.
const MIN_OVERSHOOT_PX = 24;

// A resample after the viewport has had time to settle. The iPadOS fullscreen
// exit is animated, so a sample taken mid-transition can be wrong, and the
// event that would correct it may already have fired.
const SETTLE_DELAY_MS = 300;

let anchored = [];
let appliedOvershoot = 0;
let settleTimer = null;

/** Is the software keyboard likely to be the reason the viewport shrank? */
function isTextEntryFocused() {
const active = document.activeElement;
if (!active) {
return false;
}
return active.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName);
}

/**
* How far the layout viewport's bottom edge currently sits below the visible
* one, as measured right now.
*/
function liveOvershoot() {
const viewport = window.visualViewport;
if (!viewport) {
return 0;
}
// Zoomed in, the visual viewport is a window onto the page and WebKit
// already treats fixed elements specially; correcting on top of that would
// drag the panels around under the user's fingers.
if (viewport.scale > 1.01) {
return 0;
}
const overshoot = document.documentElement.clientHeight - (viewport.offsetTop + viewport.height);
return Number.isFinite(overshoot) && overshoot >= MIN_OVERSHOOT_PX ? Math.round(overshoot) : 0;
}

/**
* The lowest client-coordinate y that is actually on screen.
*
* Anything positioning itself against the bottom of the window — a flyout
* clamped so it does not overflow, say — has the same problem the panels do
* and wants this instead of window.innerHeight.
*
* @returns {number} y coordinate of the visible bottom edge
*/
export function visibleViewportBottom() {
return document.documentElement.clientHeight - liveOvershoot();
}

/** Re-seat the registered panels against the current viewport. */
export function syncPanelAnchor() {
// The software keyboard shrinks the visual viewport exactly as a stranded
// layout viewport does, and on iPad it is by far the more common of the two:
// opening the text search dialog would otherwise fling both panels several
// hundred pixels up into the middle of the photo, on top of the dialog they
// belong under. There is nothing in the geometry to tell the two apart, so
// while a text field holds focus the last correction is held instead of
// recomputed.
const overshoot = isTextEntryFocused() ? appliedOvershoot : liveOvershoot();
appliedOvershoot = overshoot;

anchored.forEach((panel) => {
if (panel) {
panel.style.transform = overshoot ? `translateY(${-overshoot}px)` : "";
}
});
}

/** Resync once more after the viewport has settled, coalescing repeat calls. */
function scheduleSettleResync() {
syncPanelAnchor();
clearTimeout(settleTimer);
settleTimer = setTimeout(syncPanelAnchor, SETTLE_DELAY_MS);
}

/**
* Register the panels and start following the viewport.
*
* @param {Array<HTMLElement|null>} panels elements to keep on screen
*/
export function initializePanelAnchor(panels) {
anchored = panels.filter(Boolean);
appliedOvershoot = 0;

window.addEventListener("resize", scheduleSettleResync);
window.addEventListener("orientationchange", scheduleSettleResync);
if (window.visualViewport) {
// The visible area shrinking is the signal that fires most reliably on the
// platform this exists for: the layout viewport does not change on a
// stranded exit, so window.resize may never come.
window.visualViewport.addEventListener("resize", scheduleSettleResync);
window.visualViewport.addEventListener("scroll", scheduleSettleResync);
}
// Focus changes bracket the software keyboard, and the held correction has
// to be recomputed once it goes away again.
window.addEventListener("focusin", scheduleSettleResync);
window.addEventListener("focusout", scheduleSettleResync);

syncPanelAnchor();
}
Loading