feat(playback): transcode HEVC .mov to H.264 MP4 on first hit

iPhone .mov files are HEVC Main 10 with codec_tag hvc1. Safari decodes
that fine; Chrome and Firefox refuse 10-bit HEVC entirely, which the
browser surfaces as "playback is not supported" against the existing
/original endpoint. Confirmed against the user's
26-05-01 13-13-26 0525.mov: codec_name=hevc, profile=Main 10,
audio=aac/48kHz.

New endpoint /photos/{id}/playback handles this transparently:
- check the on-disk cache at /data/video-cache/{id}.mp4 first; serve
  if newer than the source
- passthrough h264 in mp4/m4v/webm containers (ffprobe to confirm)
- otherwise transcode src -> H.264 8-bit MP4 with libx264 fast/CRF 23,
  audio re-encoded to AAC because the iPhone 16 ships APAC audio that
  no browser can decode; +faststart for progressive load
- atomic publish via tmp + os.replace so a failed run never leaves a
  half-written cache entry
- HTTP Range support so <video> can seek the result

The .mov container is excluded from the passthrough fast path because
Chrome/Firefox refuse to play even h264-in-mov reliably, so .mov always
goes through the cache (transcode-or-remux). /original is refactored
to share the new _serve_file_with_range helper.

Frontend getVideoSrc swaps from /original to /playback. /original
stays for downloads and any non-<video> fetches.

First-hit cost is ~9s wall for a 13s 1080p HEVC clip on this box
(software libx264, 4 cores). Long videos are still sync-in-request
because the browser's <video> can't deal with a 202 response; if that
becomes painful, lift the transcode into a celery task with a polling
endpoint.
This commit is contained in:
Claudio
2026-05-11 22:40:05 +02:00
parent 09c12ea35b
commit 1b6ff45726
3 changed files with 204 additions and 7 deletions

View File

@@ -36,5 +36,8 @@ export function getPreviewFullResSrc(photo: Photo): string {
}
export function getVideoSrc(photo: Photo): string {
return photosApi.getOriginalUrl(photo.id)
// /playback transcodes HEVC (iPhone .mov) to H.264 on first hit and
// caches it. /original would just hand the browser raw HEVC, which
// Chrome and Firefox refuse to decode.
return photosApi.getPlaybackUrl(photo.id)
}

View File

@@ -310,6 +310,16 @@ export const photos = {
return `${API_BASE_URL}/photos/${photoId}/proxy${qs}`
},
/** Browser-playable video URL. Backend passthroughs h264/mp4 sources and
* transcodes everything else (notably iPhone HEVC .mov) to H.264 MP4
* on first hit, cached thereafter. Use this for any <video src> in the
* UI; /original stays for downloads. */
getPlaybackUrl: (photoId: string) => {
const token = localStorage.getItem('access_token')
const qs = token ? `?token=${encodeURIComponent(token)}` : ''
return `${API_BASE_URL}/photos/${photoId}/playback${qs}`
},
/** "On this day" memories — photos taken on this date in previous years. */
memories: async (): Promise<MemoriesResponse> => {
const response = await api.get('/photos/memories')