feat: server-side thumbnails (Pillow/ffmpeg), 3D cover-flow carousel, fix preview close on iOS

- New /ui/files/{pk}/thumb/ endpoint: Pillow resize for images, ffmpeg first-frame for videos, disk-cached with per-record lock
- Grid & carousel render server thumbnails via <img> — fixes blank video thumbs on iOS Safari
- Carousel upgraded to 3D cover-flow: perspective rotateY/scale/opacity driven by scroll position
- Preview <video> gets playsinline + webkit-playsinline — fixes close button unusable after iOS fullscreen playback
- thumbnail_url exposed in serializer, upload response, and client JSON
- 3 new tests: thumb 200/cache, non-media 404, missing-file 404
This commit is contained in:
OpenClaw Sub-agent
2026-08-01 23:22:57 +10:00
parent 301224ed18
commit 8cd92812f7
6 changed files with 220 additions and 16 deletions
+1
View File
@@ -13,4 +13,5 @@ urlpatterns = [
path('<uuid:pk>/delete/', file_views.FileDeleteView.as_view(), name='file-delete'),
path('<uuid:pk>/toggle-public/', file_views.FileTogglePublicView.as_view(), name='file-toggle-public'),
path('<uuid:pk>/set-expiry/', file_views.FileSetExpiryView.as_view(), name='file-set-expiry'),
path('<uuid:pk>/thumb/', file_views.FileThumbnailView.as_view(), name='file-thumbnail'),
]
+80
View File
@@ -3,8 +3,10 @@ import mimetypes
import os
import secrets
import logging
import threading
from pathlib import Path
from PIL import Image
from django.conf import settings
from django.db.models import F
from django.http import FileResponse, JsonResponse, Http404, HttpResponse
@@ -161,6 +163,83 @@ def _stream_file(file_path, mime_type, disposition, range_header=None):
# ── UI Views ──────────────────────────────────────────────────────────────────
THUMB_MAX_DIM = 480 # longest edge of generated thumbnails (JPEG)
_THUMB_LOCK = {} # per-record lock to avoid duplicate generation
def _generate_thumbnail(record):
"""Generate (and cache on disk) a thumbnail for an image or video FileUpload.
- Images: Pillow resize, longest edge capped at THUMB_MAX_DIM, JPEG quality 82.
- Videos: ffmpeg extracts the first frame, then Pillow downscales the same way.
Returns the thumbnail path on success, or None (no thumbnail possible / failed).
"""
if not (record.is_image or record.mime_type.startswith('video/')):
return None
if not os.path.exists(record.file_path):
return None
thumb = record.thumb_path
if os.path.exists(thumb):
return thumb
lock = _THUMB_LOCK.setdefault(str(record.pk), threading.Lock())
with lock:
# Re-check after acquiring the lock (another request may have generated it)
if os.path.exists(thumb):
return thumb
tmp = f'{thumb}.part'
try:
Path(thumb).parent.mkdir(parents=True, exist_ok=True)
if record.is_image:
with Image.open(record.file_path) as im:
im = im.convert('RGB')
im.thumbnail((THUMB_MAX_DIM, THUMB_MAX_DIM))
im.save(tmp, 'JPEG', quality=82, optimize=True)
else: # video → ffmpeg first frame
import subprocess
result = subprocess.run(
['ffmpeg', '-y', '-loglevel', 'error',
'-i', record.file_path,
'-frames:v', '1', '-vf', f"scale='min({THUMB_MAX_DIM},iw)':-2",
'-f', 'image2', tmp],
capture_output=True, timeout=60,
)
if result.returncode != 0 or not os.path.exists(tmp) or os.path.getsize(tmp) == 0:
logger.warning('ffmpeg thumbnail failed for %s: %s', record.pk,
result.stderr.decode(errors='ignore')[:200])
_cleanup_partial(tmp)
return None
# Downscale huge first frames to the same cap
with Image.open(tmp) as im:
im.thumbnail((THUMB_MAX_DIM, THUMB_MAX_DIM))
im.save(tmp, 'JPEG', quality=82, optimize=True)
os.replace(tmp, thumb)
return thumb
except Exception as exc:
logger.warning('thumbnail generation failed for %s: %s', record.pk, exc)
try:
_cleanup_partial(tmp)
except NameError:
pass
return None
class FileThumbnailView(View):
"""Serve a generated thumbnail for image/video FileUploads.
GET /ui/files/{uuid}/thumb/ → 200 image/jpeg (generated & cached on first hit)
Non-media types → 404 (the UI falls back to a type icon).
"""
def get(self, request, pk):
record = get_object_or_404(FileUpload, pk=pk)
thumb = _generate_thumbnail(record)
if not thumb or not os.path.exists(thumb):
raise Http404('No thumbnail available for this file')
return FileResponse(open(thumb, 'rb'), content_type='image/jpeg')
class FileListView(View):
def get(self, request):
files = FileUpload.objects.all()
@@ -197,6 +276,7 @@ class FileUploadView(View):
'created_at': record.created_at.isoformat(),
'download_url': record.download_url,
'is_image': record.is_image,
'thumbnail_url': record.thumbnail_url,
})
if is_ajax:
status_code = 200 if results else 500
+13
View File
@@ -467,6 +467,19 @@ class FileUpload(models.Model):
safe_name = re.sub(r'[^\w.\-]', '-', self.name)
return f'/ui/files/{self.pk}-{safe_name}'
@property
def thumb_path(self):
"""Disk path for the generated thumbnail (image resize / video first-frame)."""
folder = os.path.join(settings.FILE_UPLOADS_FOLDER, 'thumbs')
return os.path.join(folder, f'{self.stored_name}.jpg')
@property
def thumbnail_url(self):
"""URL of the server-generated thumbnail; empty for non-media types."""
if not (self.is_image or self.mime_type.startswith('video/')):
return ''
return f'/ui/files/{self.pk}/thumb/'
@property
def public_url(self):
import re as _re
+6
View File
@@ -129,6 +129,7 @@ class FileUploadSerializer(serializers.ModelSerializer):
formatted_size = serializers.SerializerMethodField()
public_url = serializers.SerializerMethodField()
is_expired = serializers.SerializerMethodField()
thumbnail_url = serializers.SerializerMethodField()
class Meta:
model = FileUpload
@@ -137,11 +138,13 @@ class FileUploadSerializer(serializers.ModelSerializer):
'is_public', 'public_token', 'public_url',
'expires_at', 'is_expired',
'download_count', 'created_at', 'updated_at',
'thumbnail_url',
]
read_only_fields = [
'id', 'mime_type', 'size', 'formatted_size',
'public_token', 'public_url', 'is_expired',
'download_count', 'created_at', 'updated_at',
'thumbnail_url',
]
def get_formatted_size(self, obj):
@@ -153,6 +156,9 @@ class FileUploadSerializer(serializers.ModelSerializer):
def get_is_expired(self, obj):
return obj.is_expired
def get_thumbnail_url(self, obj):
return obj.thumbnail_url
class BookmarkSerializer(serializers.ModelSerializer):
"""Full bookmark for read / single-create / update."""
+72 -16
View File
@@ -212,11 +212,11 @@
.grid-thumb { border-radius: 13px; }
}
/* ---------- Carousel view (iOS cover-flow style) ---------- */
.file-carousel { position: relative; }
/* ---------- Carousel view (3D cover-flow style) ---------- */
.file-carousel { position: relative; perspective: 1400px; }
.carousel-track {
display: flex; gap: 1.1rem; overflow-x: auto;
padding: 0.9rem 1.8rem 1.4rem; margin-bottom: -1.4rem;
padding: 1.6rem 1.8rem 1.8rem; margin-bottom: -1.4rem;
scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
@@ -227,9 +227,12 @@
background: #fff; border-radius: var(--apple-radius-lg);
border: 1px solid rgba(0, 0, 0, 0.05); box-shadow: var(--apple-shadow-card);
padding: 0.8rem; cursor: pointer; text-align: left;
transition: transform 0.3s var(--apple-ease), box-shadow 0.3s var(--apple-ease);
transform-style: preserve-3d;
transition: transform 0.25s var(--apple-ease), opacity 0.25s ease, box-shadow 0.25s ease;
will-change: transform;
}
.carousel-card:hover { transform: translateY(-4px) scale(1.02); box-shadow: var(--apple-shadow-lift); }
.carousel-card:hover { box-shadow: var(--apple-shadow-lift); }
.carousel-card.active { box-shadow: 0 18px 48px rgba(0, 0, 0, 0.16); }
.carousel-cover {
position: relative; width: 100%; aspect-ratio: 1 / 1; border-radius: 18px;
overflow: hidden; background: #f0f0f2;
@@ -501,7 +504,8 @@
"public_url": "{{ file.public_url|escapejs }}",
"expires_at": "{{ file.expires_at|date:'c' }}",
"download_url": "{{ file.download_url|escapejs }}",
"is_image": {{ file.is_image|lower }}
"is_image": {{ file.is_image|lower }},
"thumbnail_url": "{{ file.thumbnail_url|escapejs }}"
}{% if not forloop.last %},{% endif %}{% endfor %}]
</script>
@@ -616,12 +620,11 @@
:title="f.name" role="button" tabindex="0"
@keydown.enter.prevent="openPreview({name:f.name, mimeType:f.mime_type, url:f.download_url})">
<!-- Thumbnail: image / video first frame / type icon -->
<!-- Thumbnail: server-generated (Pillow/ffmpeg) for media, type icon otherwise -->
<div class="grid-thumb">
<img x-show="f.is_image" :src="f.download_url" :alt="f.name" loading="lazy" decoding="async">
<video x-show="isVideo(f)" :src="f.download_url" muted playsinline preload="metadata"
aria-hidden="true"></video>
<span class="grid-file-icon" x-show="!f.is_image && !isVideo(f)" aria-hidden="true">
<img x-show="hasThumb(f)" :src="f.thumbnail_url || f.download_url" :alt="f.name"
loading="lazy" decoding="async" @error="$el.style.display='none'">
<span class="grid-file-icon" x-show="!hasThumb(f)" aria-hidden="true">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
@@ -665,15 +668,19 @@
:title="f.name" role="button" tabindex="0"
@keydown.enter.prevent="openPreview({name:f.name, mimeType:f.mime_type, url:f.download_url})">
<div class="carousel-cover">
<img x-show="f.is_image" :src="f.download_url" :alt="f.name" loading="lazy" decoding="async">
<video x-show="isVideo(f)" :src="f.download_url" muted playsinline preload="metadata"
aria-hidden="true"></video>
<span class="carousel-file-icon" x-show="!f.is_image && !isVideo(f)" aria-hidden="true">
<img x-show="hasThumb(f)" :src="f.thumbnail_url || f.download_url" :alt="f.name"
loading="lazy" decoding="async" @error="$el.style.display='none'">
<span class="carousel-file-icon" x-show="!hasThumb(f)" aria-hidden="true">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</span>
<span class="thumb-badge" x-show="isVideo(f)" aria-hidden="true">
<svg fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5.14v13.72c0 .82.9 1.32 1.6.9l10.6-6.86a1.06 1.06 0 000-1.8L9.6 4.24a1.06 1.06 0 00-1.6.9z"/>
</svg>
</span>
</div>
<span class="carousel-name" x-text="f.name"></span>
<span class="carousel-meta" x-text="f.size"></span>
@@ -870,7 +877,7 @@
style="display:block; max-width:min(88vw,1200px); max-height:calc(92vh - 96px); width:auto; height:auto; object-fit:contain;">
<video x-show="previewModal.open && previewModal.mimeType.startsWith('video/')" x-cloak
controls
controls playsinline webkit-playsinline
style="display:block; max-width:min(88vw,1200px); max-height:calc(92vh - 96px); width:auto; height:auto;"
:src="previewModal.url"></video>
@@ -989,6 +996,22 @@ function fileManager() {
f.expires_at = e.detail.expires_at || null;
}
});
// 3D cover-flow: recompute card transforms while the track scrolls
this.$nextTick(() => {
const track = this.$refs.carouselTrack;
if (track) {
let raf = null;
track.addEventListener('scroll', () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = null;
this._applyCoverFlow();
});
}, { passive: true });
this._applyCoverFlow();
}
});
},
openPicker() {
@@ -1004,13 +1027,43 @@ function fileManager() {
this.$nextTick(() => {
const track = this.$refs.carouselTrack;
if (track && track.scrollTo) track.scrollTo({ left: 0, behavior: 'auto' });
this._applyCoverFlow();
});
}
},
// 3D cover-flow: rotate/scale/fade each card by its distance from the track centre
_applyCoverFlow() {
const track = this.$refs.carouselTrack;
if (!track) return;
const cards = Array.from(track.querySelectorAll('.carousel-card'));
if (!cards.length) return;
const centre = track.scrollLeft + track.clientWidth / 2;
const cardW = cards[0].offsetWidth;
const maxAngle = 42;
cards.forEach((card) => {
const cardCentre = card.offsetLeft + cardW / 2 - track.scrollLeft;
const dist = (cardCentre - track.clientWidth / 2) / track.clientWidth; // -0.5..0.5
const abs = Math.min(Math.abs(dist), 0.6);
const angle = dist * maxAngle * 2; // rotateY
const scale = 1 - abs * 0.22;
const opacity = 1 - abs * 0.55;
const z = Math.round(20 - abs * 20);
card.style.transform = `perspective(1400px) translateY(${abs * -10}px) rotateY(${angle.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
card.style.opacity = opacity.toFixed(3);
card.style.zIndex = z;
card.classList.toggle('active', abs < 0.08);
});
},
isVideo(f) {
const mime = (f.mime_type || '').toLowerCase();
return mime.startsWith('video/') || /\.(mp4|webm|mov|m4v)$/i.test(f.name || '');
},
hasThumb(f) {
// Media files get a server-generated thumbnail; everything else shows a type icon
const mime = (f.mime_type || '').toLowerCase();
return mime.startsWith('image/') || mime.startsWith('video/')
|| /\.(jpe?g|png|gif|webp|avif|svg|mp4|webm|mov|m4v)$/i.test(f.name || '');
},
scrollCarousel(dir) {
const track = this.$refs.carouselTrack;
if (!track) return;
@@ -1064,11 +1117,13 @@ function fileManager() {
expires_at: null,
download_url: rec.download_url || ('/ui/files/' + rec.id + '-' + encodeURIComponent(rec.name)),
is_image: (rec.mime_type || guessMime(file.name)).startsWith('image/'),
thumbnail_url: rec.thumbnail_url || '',
};
// Avoid inserting duplicate (key by id)
if (!this.visibleFiles.some(x => String(x.id) === String(newFile.id))) {
this.visibleFiles.unshift(newFile);
this.fileList.unshift({ name: newFile.name, mimeType: newFile.mime_type, url: newFile.download_url });
this.$nextTick(() => this._applyCoverFlow());
}
});
} catch (e) { /* ignore parse error */ }
@@ -1113,6 +1168,7 @@ function fileManager() {
const fidx = this.fileList.findIndex(f => f.url === (idx !== -1 ? 'placeholder' : ''));
// rebuild fileList to keep preview nav in sync
this.fileList = this.visibleFiles.map(f => ({ name: f.name, mimeType: f.mime_type, url: f.download_url }));
this.$nextTick(() => this._applyCoverFlow());
},
// ── Preview modal ────────────────────────────────────────────
+48
View File
@@ -2,6 +2,7 @@
Integration tests for the Files REST API (/api/files) and download endpoint.
"""
import os
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
@@ -17,6 +18,16 @@ def _png():
return SimpleUploadedFile("test.png", data, content_type="image/png")
def _real_png():
"""A fully decodable 1x1 PNG (Pillow must be able to open it)."""
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (8, 8), (200, 60, 60)).save(buf, format="PNG")
return SimpleUploadedFile("real.png", buf.getvalue(), content_type="image/png")
def _pdf():
data = (
b"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
@@ -204,3 +215,40 @@ class TestFilesAPI:
assert r.status_code == 200
assert r["Content-Type"] == "video/mp4"
assert (r["Content-Disposition"] or "").startswith("inline"), r["Content-Disposition"]
# ── Thumbnail endpoint ─────────────────────────────────────────────
def test_thumbnail_endpoint_generates_png_thumb(self, client, api_client):
"""Image uploads get a cached JPEG thumbnail at /ui/files/{pk}/thumb/."""
uploaded = api_client.post(
f"{self.BASE}", {"files": _real_png()}, format="multipart"
).json()[0]
pk = uploaded["id"]
assert uploaded["thumbnail_url"] == f"/ui/files/{pk}/thumb/"
r = client.get(f"/ui/files/{pk}/thumb/")
assert r.status_code == 200
assert r["Content-Type"] == "image/jpeg"
# Second hit is served from the disk cache (still 200)
assert client.get(f"/ui/files/{pk}/thumb/").status_code == 200
def test_thumbnail_endpoint_404_for_non_media(self, client, api_client):
"""PDFs/text have no thumbnail; the endpoint 404s and the UI falls back to an icon."""
uploaded = api_client.post(
f"{self.BASE}", {"files": _pdf()}, format="multipart"
).json()[0]
pk = uploaded["id"]
assert uploaded["thumbnail_url"] == ""
assert client.get(f"/ui/files/{pk}/thumb/").status_code == 404
def test_thumbnail_endpoint_404_for_missing_file(self, client, api_client):
"""A record whose file is gone from disk must 404, not 500."""
uploaded = api_client.post(
f"{self.BASE}", {"files": _png()}, format="multipart"
).json()[0]
pk = uploaded["id"]
from links.models import FileUpload
record = FileUpload.objects.get(pk=pk)
if os.path.exists(record.file_path):
os.remove(record.file_path)
assert client.get(f"/ui/files/{pk}/thumb/").status_code == 404