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