mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
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:
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user