Skip to content

feat: play/duration/fps badge on video stills - #358

Merged
lstein merged 6 commits into
masterfrom
lstein/feature/video-badges
Aug 17, 2026
Merged

feat: play/duration/fps badge on video stills#358
lstein merged 6 commits into
masterfrom
lstein/feature/video-badges

Conversation

@lstein

@lstein lstein commented Aug 15, 2026

Copy link
Copy Markdown
Owner

PR 4 of 8 in the video-support stack. Stacked on #357.

Draws a play button with duration and frame rate over a video's still, in both the swiper and the grid. Clicking dispatches a videoPlayRequested window event — the player that consumes it is PR 5, so nothing opens yet. That seam keeps video-badge.js free of any dependency on the player, and it imports nothing at all, which is why its 44 unit tests need no module mocking.

The poster stays a real <img>

Load-bearing, not cosmetic. Three existing call sites reach for it:

  • search-ui.js — "search by this image" reads slide.querySelector("img")?.src
  • curation.js — skips slides with no img (frequency highlighting)
  • grid-view.js — updates alt text the same way

Keeping an <img> means all three work unchanged. It also means "search by this image" on a video searches by the exact frame that was embedded, which is the semantically right answer.

Where the badge is attached

Swiper: inside addSlideByIndex, at slide construction. Slides are also built by prependSlide, _doResetAllSlides and seekToSlideIndex — a badge added by a later sweep would be missing on all of them. (bookmarks.js badges grid slides only, so there was no existing precedent for a swiper-slide badge to follow.)

On touch devices it is appended to the slide itself, deliberately outside .swiper-zoom-container, or Swiper's zoom module scales the badge along with the image on a pinch. There's a test for that.

Grid: in updateSlideWithMetadata — tiles paint as placeholders first, so that's the earliest point the media type is known. applyVideoOverlay is idempotent because tiles get upgraded in place. (Note makeSlideHTML is dead code — only the placeholder path is live.)

Gesture collisions

The badge swallows mousedown, dblclick, touchstart and touchend as well as click. Three separate things would otherwise fire underneath it:

  1. attachDoubleTapHandler flips to grid view behind the freshly-opened player
  2. the grid's inline onclick="handleGridSlideClick(i)" re-selects the tile
  3. touch.js handleTouchEnd toggles the slideshow on any tap in fullscreen

Unplayable containers

.avi/.mkv/.wmv get a muted badge and an explanatory title, but still a working one — the video is indexed, searchable and mapped either way, and the player (PR 5) explains and offers a download. The playable flag is a styling hint only; actual playability depends on the codec inside, so the player always attempts playback and reacts to the element's own error event.

Positioned bottom-left, clear of the bookmark star (top-right) and the score pill.

Tests: 44 badge unit tests, 7 swiper integration tests (append, prepend, touch path, no-badge-for-images), 2 grid tests. Frontend 479 passed / 34 suites; backend 576 passed; eslint + prettier clean.

🤖 Generated with Claude Code

@lstein lstein mentioned this pull request Aug 15, 2026
@lstein
lstein force-pushed the lstein/feature/serve-video-media branch from 779960e to af92621 Compare August 17, 2026 00:33
@lstein
lstein force-pushed the lstein/feature/video-badges branch from 683cf6b to 4aa8cf5 Compare August 17, 2026 00:33
@lstein
lstein force-pushed the lstein/feature/serve-video-media branch from af92621 to 3c96dc2 Compare August 17, 2026 01:56
@lstein
lstein force-pushed the lstein/feature/video-badges branch 2 times, most recently from 0c8ec09 to 2888a99 Compare August 17, 2026 02:53
lstein and others added 2 commits August 16, 2026 22:55
Adds the endpoints and slide payload a video needs, ahead of the directory
walk actually collecting any. Still no behavior change for existing albums:
SlideSummary's new fields default to the pre-video values, so an image
payload is byte-identical to what it was.

/videos/{album_key}/{path} is a separate route rather than a widened /images/
allowlist. SUPPORTED_EXTENSIONS guards serve_image against the
add_album(image_paths=["/etc"]) -> GET /images/<key>/passwd arbitrary-file-read
chain, and widening it to admit videos would have loosened that guard as a
side effect. Two routes, two allowlists; a test asserts the split holds in
both directions, and test_image_type_guard.py is unchanged.

The route returns a FileResponse specifically, because Starlette implements
HTTP Range on it and that is what lets the <video> scrubber seek. Nothing in
PhotoMapAI implements Range itself and no test anywhere covered it, so
rewriting this route as a StreamingResponse (as the HEIC path does) would
silently break seeking with no other symptom. There are now explicit 206 and
Content-Range assertions.

serve_thumbnail resolves a video to its cached still before opening it, which
is what lets the grid, UMAP hover popups, landmark overlays, the back flyout
and the reference-thumbnail strip display videos with no changes of their own
— they are all already index-based. /video_frame/ serves the full-size still
for the slideshow poster. Both go through VideoFrameCache.ensure, so a wiped
cache regenerates rather than leaving broken images.

create_slide_url points image_url at the still and puts the playable bytes in
a new video_url field, so consumers that just want a picture keep working.

The drawer gets a Video panel (duration, fps, resolution, codec, container)
and keeps the EXIF panel underneath — phone videos routinely carry a creation
date and GPS worth showing. Videos never get the "Use as Ref Image" button:
it uploads the file to InvokeAI as a reference image, and handing it an .mkv
is a live bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adversarial review of this PR found 15 issues. The worst were not in the
serving logic itself but in what it does to the process around it.

VideoFrameCache.ensure() can spawn ffmpeg and wait up to two full timeouts,
and it was being called directly from `async def` handlers — which FastAPI
runs on the event loop, not in its threadpool. With uvicorn started as a
single worker, one slow or unreadable video froze every request in the
process. Both call sites now go through asyncio.to_thread, the convention
already used for the indexer and cluster labels. Measured before/after with a
real ASGI client: an unrelated request that previously waited behind a 3s
extraction now waits 0.002s.

Four independent crash paths, all reachable from parsing ffmpeg's stderr and
all surfacing as a 500 on /retrieve_image — a blank slideshow, not a missing
field:

- format_fps/format_duration put round() outside their guard, so inf raised
  OverflowError and nan raised ValueError. inf is reachable because the
  banner's numeric patterns match arbitrarily long digit runs and
  float("9" * 400) is inf with no exception.
- _resolution screened falsy values but not unparseable ones, so a width of
  "1920.0" raised out of int().
- A non-dict under the reserved key — an older or hand-edited index holding a
  JSON string — made the formatter's .get raise AttributeError.
- Even with all of those fixed, a non-finite float on the response model still
  500s, because Starlette serializes with allow_nan=False. That is a separate
  site from the formatters and needed its own fix at the boundary.

The probe dict is now sanitized once: non-dict rejected, non-finite values
dropped, and the result copied — it previously aliased the dict owned by the
lru_cached npz view, so the response model shared mutable state with the
process-wide index cache.

The drawer panel was wrapped in `video-metadata`, which no stylesheet targets.
metadata-drawer.css styles `.exif-metadata table`, and since the indexer
writes only the video dict this panel is normally the sole panel, so nothing
else pulled the styling in — every video's drawer rendered a borderless,
unpadded table. It now carries the class the drawer actually styles.

serve_thumbnail resolved the still before checking whether the derived PNG was
already cached, so every repaint of a grid of N videos paid the expensive path
N times, and a transient extraction failure 404'd even when a good thumbnail
was on disk. Reordered.

A failed extraction now degrades to a placeholder tile rather than a 404. All
five img.src callers set no onerror handler, so a 404 painted bare
broken-image glyphs across the grid, UMAP hover popups and landmark overlays
at once — and on any platform with no ffmpeg binary that is every video.

URLs are percent-encoded. "beach #2.mp4" made "#2.mp4" a fragment, so the
server saw "videos/<key>/beach " and 404'd; "?" started a query string and a
literal "%" read as a broken escape. html.escape on the drawer link is a
different encoding and does not help.

A NUL byte in the path made Path.resolve() raise ValueError inside
validate_image_access, escaping as a 500 with a traceback instead of the
403/404 the route is designed to return.

Cache headers: /videos/ gets an explicit lifetime, because FileResponse emits
ETag and Last-Modified but implements no conditional handling (only
StaticFiles does), so a revalidation re-transferred the whole clip.
/video_frame/ is no-cache, because it is keyed by index and an index
designates a different file once a delete or reindex reorders the album.

In video_cache: the failure record introduced by the previous review was
permanent and not album-qualified, so one transient ffmpeg failure blanked a
video's tile and poster for the life of the process, across every album
holding that file. It now expires and is scoped like the lock.

Test hygiene: the tests wrote into — and clear()'d from — the developer's real
~/.cache/photomap/video_frames. conftest now redirects the cache root per
test. test_thumbnail_endpoint_still_works_for_images was gated on ffmpeg
despite requesting the .jpeg, so the only guard that the video branch did not
break image thumbnailing skipped silently wherever no binary is installed. And
the fixture's add_album assert sat outside its try, so a setup failure leaked
the album into every later test.

Tests: 50 new. Backend 626 passed, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lstein
lstein force-pushed the lstein/feature/serve-video-media branch from 3d4e637 to c33e03c Compare August 17, 2026 02:58
lstein and others added 2 commits August 16, 2026 22:58
Draws a play button with the duration and frame rate over a video's still, in
both the swiper and the grid. Clicking it dispatches a videoPlayRequested
window event; the player that consumes it lands in the next PR, so nothing
opens yet.

The poster underneath stays a real <img>, which is load-bearing rather than
cosmetic. search-ui.js ("search by this image") reads
slide.querySelector("img")?.src, curation.js skips slides without an img, and
grid-view.js updates alt text the same way — keeping an <img> means all three
work unchanged, and "search by this image" then searches by the very frame
that was embedded.

In the swiper the badge is applied inside addSlideByIndex rather than by a
later sweep: slides are also built by prependSlide, _doResetAllSlides and
seekToSlideIndex, and a separate pass would miss all of those. On touch
devices it is appended to the slide itself, deliberately outside
.swiper-zoom-container, or Swiper's zoom module would scale the badge along
with the image on a pinch. In the grid it goes in updateSlideWithMetadata,
the point at which a placeholder tile learns its media type.

The badge swallows mousedown, dblclick, touchstart and touchend as well as
click, because three separate gestures would otherwise fire underneath it:
attachDoubleTapHandler flips to grid view, the grid's inline onclick
re-selects the tile, and touch.js toggles the slideshow on any tap while in
fullscreen.

Containers browsers generally cannot play are styled muted and carry an
explanatory title, but still get a working badge — the video is indexed,
searchable and mapped either way, and the player explains and offers a
download.

Tests: 44 unit tests for the badge module (which imports nothing, so it needs
no mocking), 7 swiper integration tests covering append, prepend and the
touch path, and 2 grid tests. Frontend 479 passed, backend 576 passed, eslint
and prettier clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported from smoke testing: in the swiper view the play button and fps
label were unreachable — the control panel sat on top of them.

Cause: the badge was bottom-left at z-index 100, and #controlPanel is
position:fixed bottom:10px left:20px at z-index 4000. Directly underneath, in
both position and stacking order. This is also why it worked in the grid and
not the swiper: a swiper slide fills the viewport, so the slide's bottom-left
corner IS the screen's bottom-left, whereas a grid tile is small and rarely
sits in that corner.

Every corner is spoken for — score display top-left, bookmark star top-right,
control and search panels along the bottom — so the badge moves to the centre
of the picture rather than to another corner. The poster is object-fit:
contain, which centres it within the slide, so the centre of the slide is also
the centre of the image, including for a portrait video letterboxed on a
landscape screen. The play button and the duration/fps label are stacked
vertically and centred together.

To keep the centred badge from creating a wide dead zone for the
double-click-to-grid gesture, the button itself is pointer-events:none with
the icon and label opting back in — so only the glyph and the pill take
clicks, not the whole bounding box.

Grid tiles get a smaller icon, and tiles under 140px drop the label entirely.
That threshold is applied in JS from the tile's own inline width rather than
in CSS: tile size varies continuously (200 * gridThumbSizeFactor, clamped
75-300), so no fixed selector could match it, and the previous attempt only
caught three exact widths. Swiper slides carry no inline width and are
therefore never compact.

Tests: 3 new for the compact threshold. Frontend 482 passed, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lstein
lstein force-pushed the lstein/feature/video-badges branch from 2888a99 to ad612e5 Compare August 17, 2026 02:58
Base automatically changed from lstein/feature/serve-video-media to master August 17, 2026 03:06
lstein and others added 2 commits August 16, 2026 23:37
Adversarial review of this PR found that the tests written to protect the
compact badge could not observe it — which is how dead CSS shipped. That is
the root of most of what follows.

The .video-badge--compact rules never applied to the icon. Verified in
headless Chrome: the compact icon computed to 42px, not 30px, because
`#gridViewSwiperWrapper .video-badge-icon` is specificity (1,0,1) and beats
`.video-badge--compact .video-badge-icon` at (0,2,0). The label correctly hid,
so the badge stayed full-size exactly where it most needed to shrink. The
compact rules are now scoped under the same wrapper. Now verified at 30px.

The grid tests could not have caught it: their fixture assigned innerHTML
directly to `.swiper.grid-mode`, so the tile never sat inside
#gridViewSwiperWrapper — the scope for every grid rule this PR adds — and
carried no inline width, which is the only input isCompactSlide reads. Both
tests would have passed with the compact path deleted outright. The fixtures
now mirror a real tile, and three tests cover the paths they could not reach.

applyVideoOverlay was idempotent but not reconciling: it returned early if any
badge was present. A tile's DOM node can outlive the item it showed, because
the grid fetches metadata in a staggered background loop and a response can
land after the album changed — so a tile that became an image kept offering to
play a photo, and a tile that became a different video showed the previous
clip's duration and played the previous clip's URL. It now reconciles against
the payload's identity.

aria-label suppressed the very text the badge exists for. It REPLACES element
contents in the accessible-name computation, and the icon is aria-hidden, so
a screen reader announced "Play clip.mp4" and never the duration or frame
rate. The unplayable branch was worse — a filename-less generic string, so
every unplayable tile in a grid announced identically. The name is now built
explicitly from both parts.

The badge was the only tabbable button in the app. setupAccessibility() takes
every button out of the tab order, but runs once at init over buttons that
exist then; a dynamically created badge missed it. Tabbing to one inside a
slide triggers Swiper's slide-to-focused-element, and a focused button loses
Space to the global slideshow shortcut, which preventDefaults it. Now
tabIndex -1, matching the app, and blurred after click for the same reason the
radio controls are.

The unplayable state was not perceptibly muted. Measured over a mid-grey
frame, the "dimmed" disc was actually slightly brighter than the playable one,
leaving a dashed 1.4-unit stroke as the only real signal — about 3px of dash
at the grid's icon size. It is now amber with a slash that scales with the
icon.

The label rendered in the UA form-control font: a <button> does not inherit
the page font and this app has no global button reset, so on Windows every
label used Segoe UI except the one in the middle of the picture. Verified now
inheriting.

Also: src and alt are assigned as properties rather than interpolated into
slide markup. A filename needs only a double quote to escape the attribute —
`evil" onerror="…` renders as a live event handler. Pre-existing, but this PR
edits that exact block.

And the touch test restored a process-wide singleton only on its success path,
so one failure would have cascaded into unrelated tests through the
.swiper-zoom-container branch. Moved to afterEach, restoring the captured
original rather than a hardcoded false.

Tests: 79 across the three badge files, up from 53. Frontend 492 passed,
backend 626 passed, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lstein
lstein enabled auto-merge (rebase) August 17, 2026 03:41
auto-merge was automatically disabled August 17, 2026 03:49

Rebase failed

@lstein
lstein merged commit 5a8df57 into master Aug 17, 2026
10 checks passed
@lstein
lstein deleted the lstein/feature/video-badges branch August 17, 2026 03:51
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