From 27a82eead1de5f3a41ec2346ec6981c6e717beea Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:05:26 +0300 Subject: [PATCH] feat: server-side media thumbnails, cached, instead of the full original file The media bin's grid always requested the FULL original file for a thumbnail (an /video-frame decode of the whole thing, only cropped down visually via CSS/canvas afterward). A 4K photo or a multi-MB clip got fully downloaded and decoded just to show a 96px square, every render. Added GET /api/media/{mid}/thumb: generates a small JPEG on first request (Pillow resize for images, one ffmpeg frame-grab for video), caches it under media/thumbnails/, and serves that instead. Deleting a media item removes its cached thumbnail too. mediabrowser.js now loads from this endpoint for both image and video kinds -- the same canvas-drawing path as before, just fed a file that's already small instead of the original. Measured live: a 1.57MB test clip's cached thumbnail is 1.6KB. Co-Authored-By: Claude Sonnet 5 --- movie_editor/backend/media.py | 92 +++++++++++++++++++++++ movie_editor/frontend/api.js | 3 + movie_editor/frontend/mediabrowser.js | 58 ++++---------- movie_editor/server.py | 10 +++ tests/test_media_thumb.py | 104 ++++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 42 deletions(-) create mode 100644 tests/test_media_thumb.py diff --git a/movie_editor/backend/media.py b/movie_editor/backend/media.py index e289602b..af4dbb60 100644 --- a/movie_editor/backend/media.py +++ b/movie_editor/backend/media.py @@ -113,6 +113,94 @@ def content_type(mid: str) -> str: return mimetypes.guess_type(it["filename"])[0] or "application/octet-stream" +THUMB_MAX_DIM = 320 # long edge, px -- matches the frontend's own thumbnail cap (mediabrowser.js) + + +def _thumbs_dir() -> Path: + d = config.MEDIA_DIR / "thumbnails" + d.mkdir(parents=True, exist_ok=True) + return d + + +def thumb_path_for(mid: str) -> Optional[Path]: + """A small cached JPEG for this media item's grid thumbnail, generating it on first + request. None for audio/other (nothing to draw) or a missing/unreadable source. + + The full media file was always what the bin's thumbnail grid requested — the frontend + downscaled it AFTER downloading it in full, so a bin of real photos/video re-downloaded + every original file just to show a 96px square. This is the actual size reduction: a + resized derivative cached once on disk, so later requests (a page reload, scrolling a + thumbnail back into view) transfer kilobytes instead of megabytes. + """ + it = get(mid) + if not it or it.get("kind") not in ("image", "video"): + return None + src = path_for(mid) + if src is None: + return None + out = _thumbs_dir() / f"{mid}.jpg" + try: + if out.is_file() and out.stat().st_mtime >= src.stat().st_mtime: + return out + except OSError: + pass + try: + if it["kind"] == "image": + _make_image_thumb(src, out) + else: + _make_video_thumb(src, out) + return out if out.is_file() else None + except Exception: + return None + + +def _tmp_thumb_path(out: Path) -> Path: + # Unique per attempt, not a shared ".tmp" -- two requests for the same not-yet-cached + # id (a quick double-render before the first request's own cache write lands) would + # otherwise both write the same temp file and could interleave into a corrupt image that + # then gets renamed into place and served as if valid. + return out.with_name(f"{out.stem}.{uuid.uuid4().hex[:8]}.tmp") + + +def _make_image_thumb(src: Path, out: Path) -> None: + from PIL import Image + img = Image.open(src) + img.thumbnail((THUMB_MAX_DIM, THUMB_MAX_DIM)) # in place, preserves aspect, never upscales + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") + tmp = _tmp_thumb_path(out) + try: + img.save(tmp, "JPEG", quality=82) + tmp.replace(out) + finally: + tmp.unlink(missing_ok=True) + + +def _make_video_thumb(src: Path, out: Path) -> None: + import shutil + import subprocess + ff = shutil.which("ffmpeg") + if not ff: + raise RuntimeError("ffmpeg not found on PATH") + tmp = _tmp_thumb_path(out) + # force_original_aspect_ratio=decrease: shrink to fit inside THUMB_MAX_DIM square, + # preserving aspect, never upscaling a source already smaller than the cap. + scale = f"scale='min({THUMB_MAX_DIM},iw)':'min({THUMB_MAX_DIM},ih)':force_original_aspect_ratio=decrease" + try: + # -f mjpeg forces the output format explicitly -- the tmp filename's own extension + # (.tmp, not .jpg) gives ffmpeg nothing to infer a container from otherwise, and it + # refuses to guess. + proc = subprocess.run( + [ff, "-y", "-i", str(src), "-vframes", "1", "-vf", scale, "-f", "mjpeg", str(tmp)], + capture_output=True, text=True, + ) + if proc.returncode != 0 or not tmp.is_file(): + raise RuntimeError((proc.stderr or "ffmpeg failed")[-500:]) + tmp.replace(out) + finally: + tmp.unlink(missing_ok=True) + + def delete(mid: str) -> bool: items = _load_index() keep, removed = [], None @@ -127,5 +215,9 @@ def delete(mid: str) -> bool: (config.MEDIA_DIR / removed["filename"]).unlink(missing_ok=True) except OSError: pass + try: + (_thumbs_dir() / f"{mid}.jpg").unlink(missing_ok=True) + except OSError: + pass _save_index(keep) return True diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index 57211505..f77e845a 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -106,6 +106,9 @@ // media bin listMedia: () => j("GET", API("/media")), mediaUrl: (id) => API(`/media/${encodeURIComponent(id)}`), + // A small server-cached JPEG (generated on first request) instead of the full original + // file — for grid thumbnails only, never for actual playback/preview/export. + mediaThumbUrl: (id) => API(`/media/${encodeURIComponent(id)}/thumb`), deleteMedia: (id) => j("DELETE", API(`/media/${encodeURIComponent(id)}`)), renameMedia: (id, name) => j("PATCH", API(`/media/${encodeURIComponent(id)}`), { name }), importClipToMediaBin: (clip, name) => j("POST", API("/media/import-clip"), { clip, name: name || null }), diff --git a/movie_editor/frontend/mediabrowser.js b/movie_editor/frontend/mediabrowser.js index d97a85c2..c394d5f2 100644 --- a/movie_editor/frontend/mediabrowser.js +++ b/movie_editor/frontend/mediabrowser.js @@ -107,17 +107,20 @@ } function _appendMediaThumb(thumb, m) { - const url = API.mediaUrl(m.id); - const cached = thumbCache.get(m.id); - if (cached && cached.url === url) { thumb.append(cached.node); return; } - if (m.kind === "image") { - const ph = el("span", "media-icon", "◆"); + if (m.kind === "image" || m.kind === "video") { + // The server now generates and caches a small JPEG per media id (an ffmpeg frame-grab + // for video, a Pillow resize for images) instead of this loading the FULL original + // file just to show a 56-96px square -- was true even for a 4K photo or clip. A plain + // would work now that the source is already small, but this still draws it to a + // canvas: a isn't natively draggable, so the card's own drag-and-drop handling + // just works, instead of needing img.draggable=false to stop the browser's own image + // drag from hijacking it. + const url = API.mediaThumbUrl(m.id); + const cached = thumbCache.get(m.id); + if (cached && cached.url === url) { thumb.append(cached.node); return; } + const ph = el("span", "media-icon" + (m.kind === "video" ? " media-vid-ph" : ""), + m.kind === "video" ? "▶" : "◆"); thumb.append(ph); - // Loaded off-DOM and drawn down to a small canvas instead of appending the - // itself -- the img would hold its full-resolution decoded bitmap alive in memory for - // as long as the card exists, just to be shown at 56-96px via CSS. The canvas also - // sidesteps the old drag hijack this used to need img.draggable=false for: a - // isn't natively draggable, so the card's own drag handling just works. const img = new Image(); img.onload = () => { if (!thumb.isConnected) return; @@ -132,41 +135,12 @@ thumbCache.set(m.id, { url, node: canvas }); } catch (_) {} }; + // On failure (no ffmpeg, corrupt source, unsupported codec) the placeholder icon + // above just stays put -- no fallback to the full original file: that was the whole + // cost this endpoint exists to avoid, and a broken thumbnail is not worth paying it. img.src = url; return; } - if (m.kind === "video") { - const ph = el("span", "media-icon media-vid-ph", "▶"); - thumb.append(ph); - // Snapshot the first frame to a canvas and release the