Play any video: on-demand transcoding, and a lightbox video player - #385
Open
lstein wants to merge 4 commits into
Open
Play any video: on-demand transcoding, and a lightbox video player#385lstein wants to merge 4 commits into
lstein wants to merge 4 commits into
Conversation
…play Two problems with video support, fixed together because the second one needs a place in the first one's UI to report itself. **The player was a dialog around a picture.** `.modal-content` is a #222 panel with a 2em pad and a rounded border; wrapping that around a black `<video>` — itself a bordered rectangle — reads as a frame inside a frame, and the title row above it added a second band of chrome. It is now a lightbox, modelled on InvokeAI 7's preview stage: a media card that shrink-wraps the video at whatever aspect ratio the clip has, sitting straight on the backdrop, with the filename as a caption below and the close button floating over the corner. It also sat ~40px low on iPad. `.modal-overlay` is `position: fixed; height: 100vh`, and after a fullscreen exit iPadOS keeps the taller fullscreen-sized layout viewport — so both resolve against a viewport that is partly off the screen and the flex centring puts the player below the middle of it. The modal now consumes `--visible-viewport-height`, the variable panel-anchor.js already publishes for exactly this and that swiper.css already uses for the photo itself. **Most of a real video collection will not play in a browser.** DivX/XviD `.avi`, MPEG-2 `.vob` and `.mpg` off DVDs, `.wmv`, HEVC and 10-bit H.264 in `.mkv` — `<video>` shows a black rectangle for all of them, and the player's only answer was a download link. `video_transcode.py` converts them to one H.264/AAC MP4 in the per-user cache directory, which the existing FileResponse path then serves with Range support, so the result is fully seekable and instant on every replay. Notes on the shape of it: * **Remux before re-encode.** Much of that collection is packaged wrongly rather than encoded wrongly: H.264/AAC in an `.mkv` only needs its streams copied into an MP4, which takes seconds where a real encode takes minutes. The two streams are decided independently, so the common H.264-plus-AC-3 case re-encodes only the audio. * **Every unknown re-encodes.** An unparsed pixel format or codec means we cannot *prove* a copy would play, and the cost of guessing wrong is the exact black rectangle this removes. Note `yuv420p10le` contains `yuv420p`: a substring test would call 10-bit copy-safe. * **A file, not a pipe.** A StreamingResponse off ffmpeg's stdout starts faster but has no Range support, so the scrubber cannot seek, and every replay pays again. * **Jobs live only while somebody is watching.** Conversions run one at a time, so opening several unplayable clips in a row would otherwise queue them all. The player's progress poll doubles as the liveness signal and a job nobody has polled for 45s is killed — closing the player is what that looks like from here. * The cache is bounded by bytes (these are whole movies, not the stills next door) and swept LRU after each conversion; deleting an album drops its conversions along with its frames. Triggered automatically: a known-bad container skips the black-rectangle-then-error round trip, and anything else is attempted first and converted on the `<video>` element's own error — which is what catches the cases no container list predicts, like HEVC in Firefox. The shared mixed image/video album fixture moved to `fixtures.py`, where conftest already picks fixtures up, so the new suite does not have to import it from another test module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwzC2csXxZkd9HsD8A2t7h
Three fresh-context reviewers attacked the previous commit. Confirmed findings, fixed here; each has a regression test. **Copy safety could be defeated by the file itself.** The rule is that an unknown stream re-encodes, never copies — but two pieces of file-controlled text are printed on ffmpeg's *own* report lines, outside the `Metadata:` blocks that were being stripped. A stream's language tag is printed inline before the `: Video:`/`: Audio:` delimiter, so a tag of `x): Video: h264 (High), yuv420p` makes an audio line match the video pattern; and the input path is echoed in the `Input #0 … from '…':` header, so a filename containing a newline forges whole report lines. Both degraded *toward* copy, which is the dangerous direction: a copied HEVC 10-bit stream is exactly the black rectangle this feature removes, cached and reported ready. `StreamProbe` now carries `trusted`, cleared when a stream line holds more than one kind delimiter or when the path contains a newline, and `plan_for` re-encodes everything from an untrusted probe. **Every 16:9 NTSC DVD rip failed.** `scale=iw*sar:ih` truncates, and 720*32/27 is 853.33 — libx264 refuses an odd width in yuv420p outright, so the single most common shape the filter exists for died with "width not divisible by 2" on every retry. Now rounds both axes to even and sets `setsar=1`. **Wrong-movie collision.** The transcode cache no longer reuses `VideoFrameCache.key_for`, which casefolds the path: on a case-sensitive filesystem `clip.mp4` and `Clip.mp4` with matching mtimes (routine for anything unpacked from one archive) collided. For a still that is the wrong thumbnail; here it played the wrong film and reported it ready without converting it. **Conversions outlived what they belonged to.** Deleting an album while one was in flight let it finish and `mkdir` the just-removed directory back, orphaning a whole movie under a key nothing would ever clear again; deleting a *video* left a full decodable copy of it in `~/.cache`, which is the retention the still-cache was written to avoid. Added `forget_album` (cancel, then clear), `TranscodeCache.discard`/`prune` wired into the per-image delete and the index-save sweep, and a pre-publish cancellation check so a conversion that succeeds after cancellation is still discarded. **Lifecycle and accounting:** * Everything between `Popen` and the read loop's `try/finally` was unprotected — thread exhaustion at `watcher.start()` left ffmpeg running unwatched and unreaped with a multi-gigabyte `.tmp` the sweeper could not even see (it counts `.mp4`). Now unwound, and the sweeper reclaims aged temps. * The pool's threads are non-daemon and `concurrent.futures` joins them at exit, so stopping the server hung for up to ~47s. An `atexit` hook now cancels and kills first. * `_forget_stale_jobs` could not reach queued or running entries and was skipped entirely on a cache hit, so a source whose mtime kept moving (a file still being copied in) queued a job per second forever. * Unknown duration took the *tightest* deadline, killing every `.m2v` longer than ten minutes of encoding; it now takes the loosest, with the stall detector still covering wedged jobs. * An audio-only `.mkv`/`.mov`/`.ogg` failed with ffmpeg's "Error opening output files: Invalid argument"; it now says it has no video track. * `_stderr_tail` reported ffmpeg's last line, which is always the generic muxer post-mortem, hiding the actual cause on the line above. * `TranscodeCache.get` stamps the entry, and the sweeper spares anything touched recently — the film being watched was otherwise the *first* thing evicted, 404ing its own next Range request. **Player:** the video's `94vw` cap exceeded the frame's own max-width below 800px of viewport, so `overflow: hidden` sliced ~24px off the right of every video on a phone; and the frame's unconditional `min-width` put up to 160px of black on one side of a portrait clip on a landscape screen. Caps now agree, the mins are gone (a `<video>` brings its own 300x150 floor) and the frame centres. Also: a transient network error no longer tears the clip down and starts a pointless re-encode (the `MediaError` code is consulted); one dropped poll no longer abandons a conversion, since the poll is also the liveness signal; focus no longer lands on `<body>` when no conversion is available; reopening with no URL releases the previous clip instead of leaving it playing unstoppably behind the panel; and an indeterminate progress bar no longer publishes `aria-valuenow="0"`. Dismissed after checking in a real browser: a report that a stale `error` from a dismissed clip could destroy the next one. The HTML media element load algorithm removes pending media element tasks, so replacing `src` discards the queued event — verified in Chromium (no event fired, while the control case fired code 4). The proof had manually dispatched the event in jsdom, which bypasses that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwzC2csXxZkd9HsD8A2t7h
The amber, dashed, slashed badge variant for containers no browser decodes described a real limitation right up until the previous commit removed it: clicking one used to reach a dead player offering a download. It now opens the player, converts the file and plays it, exactly like any other video — so the variant was advertising a restriction that no longer exists, and doing it most loudly on the files that now work hardest. `playable` stays in the badge's payload. The player still reads it to skip a direct attempt it knows will fail and go straight to converting; it just no longer changes how the badge looks or what it says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwzC2csXxZkd9HsD8A2t7h
There was no way to save the item you are looking at without bookmarking it first, opening the bookmark menu, downloading, and unbookmarking again. The download itself already existed and already handled video correctly — `downloadSingleImage` picks `video_url` over the still frame, derives the extension from the real path, and streams through an `<a download>` rather than buffering a 200 MB clip into browser memory. Only a way to reach it was missing, so this adds a trigger rather than an implementation: the logic moves to a shared `download.js` that the bookmark menu and the new button both call. The button sits next to Delete because it is the same shape of action, and it resolves its target through `getCurrentSlideIndex()` — the same view-agnostic primitive Delete uses — so one button serves both the swiper and the grid with no per-view code. Nothing is drawn over the picture. It is deliberately *outside* the `album_locked` guard that wraps Delete: a lock protects an album's files from being changed, and saving a copy changes nothing. Videos always download as the **original** file, never a converted copy. The conversion exists so the browser can decode a container it otherwise cannot; it is a lossy re-encode in the general case, and it is not what is in the user's library. Adding the button did not widen the panel — it is a fixed 416px with seven buttons or eight, measured against master. (That width already overflows a 390px viewport on master; unchanged here, and left alone.) Also fixes two Windows-only failures in the previous commit's tests, both test bugs rather than product ones: one compared an ffmpeg argument against a hardcoded POSIX path, and the case-collision test asserted a premise that only holds on a case-sensitive filesystem — on Windows the two names are one file, so sharing a cache entry is the correct answer there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwzC2csXxZkd9HsD8A2t7h
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two video problems, fixed together because the second one needs somewhere in the first one's UI to report itself.
1. The player was a dialog wrapped around a picture
.modal-contentis a#222panel with 2em of padding and its own rounded border. Wrapping that around a black<video>— itself a bordered rectangle — read as a frame inside a frame, and the title row above it added a second band of chrome.It is now a lightbox modelled on InvokeAI 7's preview stage: a bordered, rounded, shadowed media card that shrink-wraps the video at whatever aspect ratio the clip has, sitting directly on the backdrop, with the filename as a caption below and the close button floating over the corner.
It also sat ~40px low on iPad.
.modal-overlayisposition: fixed; height: 100vh, and after a fullscreen exit iPadOS keeps the taller fullscreen-sized layout viewport — so both resolve against a viewport that is partly off-screen and the flex centring lands below the middle of it. The modal now consumes--visible-viewport-height, the variablepanel-anchor.jsalready publishes for exactly this and thatswiper.cssalready uses for the photo itself. Verified on the device.Note that every
.modal-overlayhas this bug; only the video player was reported, so only it was changed. The fix may as well move up intomodal-base.cssthe next time a mispositioned dialog is reported on iPad.2. Most of a real video collection would not play
DivX/XviD
.avi, MPEG-2.voband.mpgoff DVDs,.wmv, HEVC and 10-bit H.264 in.mkv—<video>shows a black rectangle for all of them, and the player's only answer was a download link. Newphotomap/backend/video_transcode.pyconverts them to one H.264/AAC MP4 in the per-user cache directory, which the existingFileResponsepath then serves with Range support — so the result is fully seekable and instant on every replay.Two new endpoints, both behind the same album-access guard as
/videos/(extracted into a shared_resolve_album_video, byte-identical to the checks it replaced):POST /prepare_video/{album}/{path}— idempotent; starts the conversion, reports progress, and doubles as the poll.GET /transcoded_video/{album}/{path}— serves the result.Design notes worth knowing:
.mkvonly needs its streams copied into an MP4, seconds rather than minutes. H.264 + AC-3 copies the video and re-encodes only the audio.StreamingResponseoff ffmpeg's stdout starts faster but has no Range support, so the scrubber cannot seek, and every replay pays again.Triggered automatically: a known-bad container skips the black-rectangle-then-error round trip, anything else is attempted first and converted on the
<video>element's own error — which is what catches the cases no container list predicts, like HEVC in Firefox.3. One play badge for every video
The amber, dashed, slashed badge variant described a real limitation right up until this branch removed it. It now advertises a restriction that no longer exists, most loudly on the files that now work hardest, so it is gone.
Adversarial review
Three fresh-context reviewers attacked the first commit; 17 confirmed findings are fixed in
836bdc1, each with a regression test. The ones worth calling out:Metadata:blocks being stripped: a stream's language tag (inline, before the: Video:delimiter) and the input path (echoed in the header — filenames may contain newlines). Both forge report lines, and both degrade toward copy.StreamProbenow carries atrustedflag.scale=iw*sar:ihtruncates; 720×32/27 = 853.33, and libx264 refuses an odd width in yuv420p. Now rounds both axes and setssetsar=1.VideoFrameCache.key_forinherited its casefold, soclip.mp4/Clip.mp4with equal mtimes collided — a wrong thumbnail there, the wrong film here.Popenleaking an unwatched ffmpeg and an invisible multi-GB.tmp; non-daemon pool threads hanging shutdown ~47s; the sweeper evicting the film currently being watched.One reported finding was dismissed after checking in a real browser: a stale
errorfrom a dismissed clip cannot destroy the next one, because the HTML media-element load algorithm removes pending tasks whensrcchanges. The proof had dispatched the event manually in jsdom, which bypasses that.Testing
.avi(full re-encode), H.264/AAC.mkv(remux fast path), anamorphic 720×480 SAR 32:27 DVD, audio-only container, unreadable file.🤖 Generated with Claude Code
https://claude.ai/code/session_01GwzC2csXxZkd9HsD8A2t7h