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:
co-authored by
Claude Sonnet 5
parent
b301d40592
commit
27a82eead1
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]):
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user