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 <img>/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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-22 18:05:26 +03:00
co-authored by Claude Sonnet 5
parent b301d40592
commit 27a82eead1
5 changed files with 225 additions and 42 deletions
+92
View File
@@ -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 "<id>.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
+3
View File
@@ -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 }),
+16 -42
View File
@@ -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
// <img> would work now that the source is already small, but this still draws it to a
// canvas: a <canvas> 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 <img>
// 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 <canvas>
// 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 <video> immediately. A live
// <video> per bin item held its network connection open (Chrome stalls media fetches
// but keeps the socket) — with ~6 videos in the bin the per-origin connection pool
// was exhausted and every editor API call queued behind the thumbnails.
const vid = document.createElement("video");
vid.muted = true;
vid.preload = "metadata";
vid.playsInline = true;
vid.src = url;
const release = () => { try { vid.removeAttribute("src"); vid.load(); } catch (_) {} };
vid.onerror = release;
vid.onloadeddata = () => {
if (thumb.isConnected && !thumb.querySelector("canvas")) {
const { w, h } = _thumbScale(vid.videoWidth || 320, vid.videoHeight || 180, THUMB_MAX_DIM);
const canvas = document.createElement("canvas");
canvas.className = "media-vid-thumb";
canvas.width = w;
canvas.height = h;
try {
canvas.getContext("2d").drawImage(vid, 0, 0, w, h);
ph.remove();
thumb.append(canvas);
thumbCache.set(m.id, { url, node: canvas });
} catch (_) {}
}
release();
};
return;
}
if (m.kind === "audio") {
thumb.append(el("span", "media-icon media-aud-ph", "♪"));
return;
+10
View File
@@ -1764,6 +1764,16 @@ if web is not None and PromptServer is not None:
raise web.HTTPNotFound()
return web.Response(body=p.read_bytes(), content_type=media.content_type(req.match_info["mid"]))
@routes.get(UI_PREFIX + "/api/media/{mid}/thumb")
async def _media_thumb(req):
p = media.thumb_path_for(req.match_info["mid"])
if p is None:
raise web.HTTPNotFound()
return web.Response(
body=p.read_bytes(), content_type="image/jpeg",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
@routes.delete(UI_PREFIX + "/api/media/{mid}")
async def _media_delete(req):
if not media.delete(req.match_info["mid"]):
+104
View File
@@ -0,0 +1,104 @@
"""media.thumb_path_for: cached, downscaled thumbnails instead of serving the full original
file for every media-bin grid render (see mediabrowser.js's own client-side history of the
same problem, fixed alongside this on the frontend side)."""
import shutil
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from movie_editor.backend import config, media # noqa: E402
pytest.importorskip("PIL", reason="Pillow is a real dependency of this project")
from PIL import Image # noqa: E402
FFMPEG = shutil.which("ffmpeg")
@pytest.fixture
def media_dir(tmp_path, monkeypatch):
monkeypatch.setattr(config, "MEDIA_DIR", tmp_path)
monkeypatch.setattr(media, "_load_index", lambda: media._items)
monkeypatch.setattr(media, "_save_index", lambda items: media._items.__setitem__(slice(None), items))
media._items = []
return tmp_path
def _add_image(media_dir, name="big.png", size=(1200, 600)):
Image.new("RGB", size, (200, 80, 40)).save(media_dir / name)
mid = "img" + name.replace(".", "")
media._items.append({"id": mid, "name": name, "filename": name, "kind": "image",
"size": 0, "added": 0})
return mid
def test_generates_a_downscaled_jpeg_for_an_oversized_image(media_dir):
mid = _add_image(media_dir, "big.png", (1200, 600))
out = media.thumb_path_for(mid)
assert out is not None and out.is_file()
assert out.suffix == ".jpg"
with Image.open(out) as im:
assert max(im.size) <= media.THUMB_MAX_DIM
assert im.size[0] / im.size[1] == pytest.approx(1200 / 600, rel=0.02)
def test_a_small_image_is_not_upscaled(media_dir):
mid = _add_image(media_dir, "small.png", (64, 32))
out = media.thumb_path_for(mid)
with Image.open(out) as im:
assert im.size == (64, 32)
def test_second_call_reuses_the_cached_file_instead_of_regenerating(media_dir, monkeypatch):
mid = _add_image(media_dir)
first = media.thumb_path_for(mid)
first_mtime = first.stat().st_mtime
monkeypatch.setattr(media, "_make_image_thumb",
lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not regenerate")))
second = media.thumb_path_for(mid)
assert second == first
assert second.stat().st_mtime == first_mtime
def test_audio_and_other_kinds_have_no_thumbnail(media_dir):
media._items.append({"id": "a1", "name": "x.mp3", "filename": "x.mp3", "kind": "audio",
"size": 0, "added": 0})
(media_dir / "x.mp3").write_bytes(b"\0")
assert media.thumb_path_for("a1") is None
def test_missing_source_file_returns_none(media_dir):
media._items.append({"id": "gone", "name": "gone.png", "filename": "gone.png",
"kind": "image", "size": 0, "added": 0})
assert media.thumb_path_for("gone") is None
def test_unknown_id_returns_none(media_dir):
assert media.thumb_path_for("nope") is None
def test_delete_removes_the_cached_thumbnail_too(media_dir):
mid = _add_image(media_dir)
out = media.thumb_path_for(mid)
assert out.is_file()
assert media.delete(mid)
assert not out.is_file()
@pytest.mark.skipif(not FFMPEG, reason="needs a real ffmpeg on PATH")
def test_generates_a_frame_grab_for_video(media_dir, tmp_path):
import subprocess
src = media_dir / "clip.mp4"
subprocess.run(
[FFMPEG, "-y", "-f", "lavfi", "-i", "color=c=blue:s=640x360:d=1", "-frames:v", "3", str(src)],
capture_output=True, check=True,
)
media._items.append({"id": "vid1", "name": "clip.mp4", "filename": "clip.mp4",
"kind": "video", "size": 0, "added": 0})
out = media.thumb_path_for("vid1")
assert out is not None and out.is_file()
with Image.open(out) as im:
assert max(im.size) <= media.THUMB_MAX_DIM
assert im.size[0] / im.size[1] == pytest.approx(640 / 360, rel=0.05)