Client-side optimization pipeline with apply-to-J-ID

Backend:
- POST /api/files/J-x/optimize/ applies a browser-processed file: replaces
  every copy (renaming when the extension changes), recomputes MD5, size
  and perceptual hashes, seeds guest visibility; 400 when identical,
  409 when the result matches another item, owner/staff only.

Frontend (no server-side processing by design):
- optimize.worker.ts + pipelines: Mediabunny/WebCodecs for video with a
  prefer-hardware hint and per-browser codec detection; MozJPEG/OxiPNG/
  libwebp (jSquash) for images; gifuct-js+gifenc and UPNG for GIF/APNG.
- OptimizeModal: per-file-type options, original vs processed previews
  with sizes/savings, progress bar with ETA, then Apply (overwrite).
- Optimize button on the library detail for the uploader/staff.
This commit is contained in:
2026-09-17 20:03:22 -05:00
parent 90e61bb333
commit bab9904fc8
14 changed files with 1721 additions and 8 deletions
+109
View File
@@ -1,4 +1,6 @@
import re
import shutil
import uuid
from pathlib import Path
from urllib.parse import urlparse
@@ -296,6 +298,113 @@ class MediaItemViewSet(
matching.unlink_item(item)
return Response(self.get_serializer(item).data)
@action(detail=True, methods=["post"], permission_classes=[CanUpload])
def optimize(self, request, pk=None):
"""Replace this item's file(s) with a client-processed version.
The browser does the actual compression (WebCodecs/WASM); this only
applies the result. The item keeps its J-ID while MD5, size and
perceptual hashes are recomputed, so it may become a duplicate again.
"""
item = self.get_object()
if not self._can_match(request, item):
return Response(
{"detail": "Only the uploader or staff can optimize this item."},
status=status.HTTP_403_FORBIDDEN,
)
upload = request.FILES.get("file")
if upload is None:
return Response(
{"detail": "A processed file is required."},
status=status.HTTP_400_BAD_REQUEST,
)
extension = Path(upload.name).suffix.lower()
if extension not in services.ALLOWED_EXTENSIONS:
return Response(
{"detail": f"Unsupported file type: {extension or 'unknown'}"},
status=status.HTTP_400_BAD_REQUEST,
)
locations = list(item.locations.all())
if not locations:
return Response(
{"detail": "This item has no file on disk."},
status=status.HTTP_400_BAD_REQUEST,
)
folder = Path(locations[0].path).parent
temp_path = folder / f".optimize-{uuid.uuid4().hex}{extension}"
try:
with open(temp_path, "wb") as handle:
for chunk in upload.chunks():
handle.write(chunk)
new_md5 = services.compute_md5(temp_path)
new_size = temp_path.stat().st_size
except OSError as exc:
temp_path.unlink(missing_ok=True)
return Response(
{"detail": f"Could not store the processed file: {exc}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if new_md5 == item.md5:
temp_path.unlink(missing_ok=True)
return Response(
{"detail": "The processed file is identical to the current one."},
status=status.HTTP_400_BAD_REQUEST,
)
clash = MediaItem.objects.filter(md5=new_md5).exclude(pk=item.pk).first()
if clash is not None:
temp_path.unlink(missing_ok=True)
return Response(
{
"detail": (
f"The processed file is an exact match (MD5) for "
f"J-{clash.id}; nothing was changed."
)
},
status=status.HTTP_409_CONFLICT,
)
new_name = f"J-{item.id}{extension}"
try:
for location in locations:
target = Path(location.path).parent / new_name
shutil.copyfile(temp_path, target)
if str(target) != location.path:
Path(location.path).unlink(missing_ok=True)
location.path = str(target)
try:
location.rel_path = str(
target.relative_to(settings.WATCHED_FOLDER)
)
except ValueError:
location.rel_path = target.name
location.mtime = target.stat().st_mtime
location.save(update_fields=["path", "rel_path", "mtime"])
except OSError as exc:
return Response(
{"detail": f"Could not replace the file(s): {exc}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
finally:
temp_path.unlink(missing_ok=True)
hashes = (
services.compute_visual_hashes(locations[0].path)
if extension in services.IMAGE_EXTENSIONS
else {}
)
item.md5 = new_md5
item.size = new_size
for field in services.HASH_FIELDS:
setattr(item, field, hashes.get(field, ""))
item.save(
update_fields=["md5", "size", *services.HASH_FIELDS, "updated_at"]
)
item = self.get_queryset().get(pk=item.pk)
return Response(self.get_serializer(item).data)
class DownloadTaskViewSet(
mixins.ListModelMixin,