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
14 changes: 13 additions & 1 deletion photomap/backend/embeddings.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
capture_download_progress,
get_cached_encoder,
)
from .media_types import IMAGE_EXTENSIONS
from .media_types import IMAGE_EXTENSIONS, is_video
from .metadata_extraction import MetadataExtractor
from .metadata_formatting import format_metadata
from .metadata_modules import SlideSummary
Expand Down Expand Up @@ -1857,6 +1857,18 @@ def find_duplicate_clusters(self, similarity_threshold=0.995):
embeddings = data["embeddings"]
filenames = data["filenames"]

# Videos are excluded from duplicate detection. Their embedding
# describes one extracted frame, and opening frames are frequently a
# black slate or a title card — two unrelated clips would then sit at
# ~1.0 cosine and be reported as duplicates, which users act on by
# deleting. Their frames can also legitimately duplicate a photo.
keep = np.array([not is_video(Path(str(f))) for f in filenames], dtype=bool)
if not keep.all():
embeddings = embeddings[keep]
filenames = filenames[keep]
if len(embeddings) == 0:
return

# Normalize embeddings. ``_l2_normalize`` carries an epsilon guard so
# an all-zero row can't produce NaN here.
norm_embeddings = _l2_normalize(embeddings, axis=-1)
Expand Down
36 changes: 30 additions & 6 deletions photomap/backend/progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,10 @@ def __init__(self):
# folded into the ProgressInfo when the run completes (see
# ``complete_operation``). Kept separate from ``_progress`` because the
# per-phase ``start_operation`` calls recreate ProgressInfo and would
# otherwise wipe a warning recorded earlier in the same run.
self._completion_warnings: dict[str, str] = {}
# otherwise wipe a warning recorded earlier in the same run. Held as a
# list per album so several notices can accumulate and be compared
# exactly; joined with a space when handed to the poller.
self._completion_warnings: dict[str, list[str]] = {}
self._lock = threading.Lock()

def start_operation(self, album_key: str, total_images: int, operation_type: str):
Expand Down Expand Up @@ -199,13 +201,36 @@ def set_completion_warning(self, album_key: str, message: str | None) -> None:
and is folded in atomically by ``complete_operation``. A falsy
``message`` clears any pending notice so a clean re-run doesn't inherit
a stale one.

Replaces whatever was pending. Use :meth:`add_completion_warning` to
contribute an additional notice without discarding an existing one.
"""
with self._lock:
if message:
self._completion_warnings[album_key] = message
self._completion_warnings[album_key] = [message]
else:
self._completion_warnings.pop(album_key, None)

def add_completion_warning(self, album_key: str, message: str | None) -> None:
"""Append a notice, keeping any already pending for this album.

A run can now produce more than one: a board album may have images
missing on disk *and* videos that could not be decoded. This used to
be a single slot, so the second writer silently discarded the first.

Repeating an identical notice is ignored. Notices are held as a list so
that comparison is against whole notices: testing ``message not in
existing`` against the joined text made "2 videos could not be read."
a substring of "12 videos could not be read." and dropped it, which is
exactly the silent discard this method exists to prevent.
"""
if not message:
return
with self._lock:
pending = self._completion_warnings.setdefault(album_key, [])
if message not in pending:
pending.append(message)

def get_progress(self, album_key: str) -> ProgressInfo | None:
"""Get progress info for an album."""
with self._lock:
Expand Down Expand Up @@ -258,9 +283,8 @@ def complete_operation(
progress.images_processed = progress.total_images
# Fold in (and consume) any pending non-fatal notice so it
# lands atomically with the COMPLETED status the poller reads.
progress.warning_message = self._completion_warnings.pop(
album_key, None
)
pending = self._completion_warnings.pop(album_key, None)
progress.warning_message = " ".join(pending) if pending else None


# Global instance
Expand Down
13 changes: 12 additions & 1 deletion photomap/backend/routers/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .. import invokeai_client
from ..config import get_config_manager
from ..embeddings import LAST_UPDATED_FILENAME, Embeddings, peek_encoder_spec
from ..media_types import is_video
from ..progress import IndexingCancelled, progress_tracker
from .album import (
AlbumDep,
Expand Down Expand Up @@ -69,6 +70,12 @@ class EmbeddingsIndexMetadata(BaseModel):
filename_count: int
embeddings_path: str
last_modified: float
# Broken out so the album card can say "120 images, 4 videos" rather than
# leaving the user to wonder why the single count jumped. Derived from the
# filename suffixes, so indexes written before video support report
# image_count == filename_count and video_count == 0.
image_count: int = 0
video_count: int = 0


# Note: How check_album_lock is used in this file:
Expand Down Expand Up @@ -275,12 +282,16 @@ async def index_metadata(album_config: AlbumDep) -> EmbeddingsIndexMetadata:
marker = index_path.parent / LAST_UPDATED_FILENAME
if marker.exists():
last_modified = max(last_modified, marker.stat().st_mtime)
filename_count = len(Embeddings.open_cached_embeddings(index_path)["filenames"])
filenames = Embeddings.open_cached_embeddings(index_path)["filenames"]
filename_count = len(filenames)
video_count = sum(1 for f in filenames if is_video(Path(str(f))))

return EmbeddingsIndexMetadata(
filename_count=filename_count,
embeddings_path=str(index_path),
last_modified=last_modified,
image_count=filename_count - video_count,
video_count=video_count,
)


Expand Down
14 changes: 13 additions & 1 deletion photomap/backend/routers/invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
_request_with_auth_fallback,
_validate_invokeai_url,
)
from ..media_types import is_video
from ..metadata_modules.invoke.invoke_metadata_view import InvokeMetadataView
from ..metadata_modules.invokemetadata import GenerationMetadataAdapter
from .album import get_embeddings_for_album, require_no_lock
Expand Down Expand Up @@ -494,7 +495,18 @@ def _load_image_path(album_key: str, index: int) -> Path:
filenames = indexes["sorted_filenames"]
if index < 0 or index >= len(filenames):
raise HTTPException(status_code=404, detail="Index out of range")
return Path(str(filenames[index]))
path = Path(str(filenames[index]))
# The single choke point for both /recall and /use_ref_image, which upload
# the file to InvokeAI as a reference image. InvokeAI's upload endpoint
# takes images, so handing it an .mp4 would surface as an opaque 502
# carrying raw upstream text. The drawer already withholds the button for
# videos; this closes the API path behind it.
if is_video(path):
raise HTTPException(
status_code=400,
detail="InvokeAI actions are not available for video files.",
)
return path


def _build_recall_payload(raw_metadata: dict, include_seed: bool) -> dict:
Expand Down
67 changes: 64 additions & 3 deletions photomap/backend/routers/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@
_MAX_THUMB_SIZE = 2048
_MAX_THUMB_RADIUS = 512

# ``download_images_zip`` builds its archive in memory. Videos make it easy to
# ask for far more than fits, so cap the total selection size.
_MAX_ZIP_BYTES = 2_000_000_000


def _format_bytes(size: int) -> str:
"""Human-readable size for the download-limit message.

Falls back to MB below a gigabyte so a lowered ceiling doesn't render as
"over the 0 GB download limit".
"""
if size >= 1_000_000_000:
return f"{size / 1_000_000_000:.1f} GB"
if size >= 1_000_000:
return f"{size / 1_000_000:.0f} MB"
return f"{size} bytes"


# Response Models
class SearchResult(BaseModel):
Expand Down Expand Up @@ -104,8 +121,22 @@ async def search_with_text_and_image(
try:
# If image_data is provided, decode and save to temp file
if req.image_data:
image_bytes = base64.b64decode(req.image_data.split(",")[-1])
query_image_data = Image.open(BytesIO(image_bytes))
# A query blob that isn't a still image — a video file dropped on
# the search panel, say — used to surface as an opaque 500 from
# deep inside PIL. The encoder only takes stills.
try:
image_bytes = base64.b64decode(req.image_data.split(",")[-1])
query_image_data = Image.open(BytesIO(image_bytes))
# ``open`` only reads the header; without an explicit load the
# decode failure would surface later, from inside the encoder,
# as the 500 this guard is meant to replace.
query_image_data.load()
except Exception as e:
logger.info(f"Rejected an unreadable search query image: {e}")
raise HTTPException(
status_code=400,
detail="The query image could not be read. Search by image needs a still image.",
) from e

logger.info(
f"Search request: {req.min_search_score=}, {req.max_search_results=}"
Expand Down Expand Up @@ -531,6 +562,31 @@ async def download_images_zip(
"""
Download multiple images as a ZIP file.
"""
# The archive is assembled entirely in memory, which was fine for photos
# but is not for video: twenty bookmarked 200 MB clips would be several
# gigabytes resident. Refuse above a ceiling rather than exhausting the
# server.
# Applies the same access check as the loop below, so the total only counts
# files that would actually be written. Counting a rejected path could
# refuse a selection that zips to nothing.
total_bytes = 0
for index in req.indices:
try:
candidate = embeddings.get_image_path(index)
if validate_image_access(album_config, candidate) and candidate.is_file():
total_bytes += candidate.stat().st_size
except Exception:
continue
if total_bytes > _MAX_ZIP_BYTES:
raise HTTPException(
status_code=413,
detail=(
f"That selection is {_format_bytes(total_bytes)}, over the "
f"{_format_bytes(_MAX_ZIP_BYTES)} download limit. "
"Select fewer files, or copy them to a folder instead."
),
)

# Create ZIP file in memory
zip_buffer = BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
Expand All @@ -543,8 +599,13 @@ async def download_images_zip(
if not image_path.exists() or not image_path.is_file():
logger.warning(f"Image not found at index {index}")
continue
# Video containers hold already-compressed streams, so
# deflating them burns CPU for no gain. Store them instead.
compression = (
zipfile.ZIP_STORED if is_video(image_path) else zipfile.ZIP_DEFLATED
)
# Add file to ZIP with just the filename (not full path)
zip_file.write(image_path, image_path.name)
zip_file.write(image_path, image_path.name, compress_type=compression)
except Exception as e:
logger.warning(f"Error adding image at index {index} to ZIP: {e}")
continue
Expand Down
22 changes: 20 additions & 2 deletions photomap/frontend/static/javascript/bookmarks.js
Original file line number Diff line number Diff line change
Expand Up @@ -509,8 +509,26 @@ class BookmarkManager {

async downloadSingleImage(globalIndex) {
const data = await fetchJson(`retrieve_image/${encodeURIComponent(state.album)}/${globalIndex}`);
const imageUrl = data.image_url;
const filename = data.filename || `image_${globalIndex}.jpg`;
const isVideo = data.media_type === "video";
// For a video, download the playable file rather than its still frame.
const imageUrl = isVideo && data.video_url ? data.video_url : data.image_url;
// Derive the fallback extension from the real path — the old hardcoded
// .jpg would save a video under a name no player would open.
const fallbackExtension = data.filepath?.split(".").pop() || (isVideo ? "mp4" : "jpg");
const filename = data.filename || `image_${globalIndex}.${fallbackExtension}`;

if (isVideo) {
// Videos are far too large to buffer into a blob: a 200 MB clip would
// sit entirely in browser memory before the save dialog appeared. Point
// the download straight at the URL and let the browser stream it.
const a = document.createElement("a");
a.href = imageUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
return;
}

// Fetch the actual image (binary, not JSON — fetch directly)
const imageResponse = await fetch(imageUrl);
Expand Down
Loading