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
+15 -5
View File
@@ -101,9 +101,18 @@ Files now stage first and are resolved before entering the library.
## 4. Staff tools ## 4. Staff tools
- [ ] **Optimization modal** (image quality, GIF/APNG resolution+FPS+compression, - [x] **Optimization modal** (client-side, no server processing)
video bitrate/presets/two-pass/hw-accel) with original vs processed preview - [x] Browser pipeline in a Web Worker: Mediabunny/WebCodecs for video
and override (hardware-accelerated where available), jSquash (MozJPEG/OxiPNG/libwebp)
for images, gifenc/gifuct-js + UPNG for GIF/APNG
- [x] Options per file type: image quality/resolution; animation
scale/fps/palette; video quality/resolution/codec/container/hardware
preference (codec support detected per browser)
- [x] Original vs processed previews with sizes and savings, progress bar
with ETA and a "Processing the file…" state
- [x] Apply overwrites the same J-ID: `POST /api/files/J-x/optimize/` replaces
the file(s) and recomputes MD5/size/perceptual hashes (409 when the
result matches another item, 400 when it is identical)
- [ ] **Stats dashboard** - [ ] **Stats dashboard**
- [ ] CPU / RAM / GPU / disk usage - [ ] CPU / RAM / GPU / disk usage
- [ ] Active jobs list (this is what makes the footer "Active Workers" real) - [ ] Active jobs list (this is what makes the footer "Active Workers" real)
@@ -139,8 +148,9 @@ Files now stage first and are resolved before entering the library.
- Duplicates and upload visual-similarity share the perceptual hashing layer - Duplicates and upload visual-similarity share the perceptual hashing layer
(`imagehash` server-side; no external hashing service). (`imagehash` server-side; no external hashing service).
- Download progress, optimization jobs and the stats "Active Workers" count all - Download progress, optimization and the stats "Active Workers" count share
want the same background-task/progress primitive — design it once. the same task style; optimization itself runs in the browser (the home
server is too weak for ffmpeg) and only the result is uploaded.
- IQDB and e621 matching depend on e621 credentials being configured; the SPA - IQDB and e621 matching depend on e621 credentials being configured; the SPA
talks to e621 directly for browsing, while the backend e621 client talks to e621 directly for browsing, while the backend e621 client
(`apps/library/e621.py`) handles metadata matching and batch scans. (`apps/library/e621.py`) handles metadata matching and batch scans.
+109
View File
@@ -1,4 +1,6 @@
import re import re
import shutil
import uuid
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -296,6 +298,113 @@ class MediaItemViewSet(
matching.unlink_item(item) matching.unlink_item(item)
return Response(self.get_serializer(item).data) 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( class DownloadTaskViewSet(
mixins.ListModelMixin, mixins.ListModelMixin,
+116 -2
View File
@@ -1,20 +1,29 @@
{ {
"name": "vite-scaffold", "name": "j621-frontend",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vite-scaffold", "name": "j621-frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@jsquash/jpeg": "^1.6.0",
"@jsquash/oxipng": "^2.3.0",
"@jsquash/resize": "^2.1.1",
"@jsquash/webp": "^1.5.0",
"@tanstack/react-query": "^5.103.1", "@tanstack/react-query": "^5.103.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"gifenc": "^1.0.3",
"gifuct-js": "^2.1.2",
"lucide-react": "^1.47.0", "lucide-react": "^1.47.0",
"mediabunny": "^1.58.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router-dom": "^7.18.4", "react-router-dom": "^7.18.4",
"tailwind-merge": "^3.7.0", "tailwind-merge": "^3.7.0",
"upng-js": "^2.1.0",
"wasm-feature-detect": "^1.9.0",
"zustand": "^5.0.15" "zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
@@ -79,6 +88,36 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@jsquash/jpeg": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jsquash/jpeg/-/jpeg-1.6.0.tgz",
"integrity": "sha512-zwN46Awh1VM6gXlIcALwb5WzqK5H2e6+Awcs1QP8AvS8ohsK/sbE4esvmH4jhlhW7+CgiUUww66vg0aTnlSIMA==",
"license": "Apache-2.0"
},
"node_modules/@jsquash/oxipng": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@jsquash/oxipng/-/oxipng-2.3.0.tgz",
"integrity": "sha512-aQ8wiEp6ztlTMXc+RMt/CG8crU3mEHDU+h+JYkIi6ctMhlh8+Ltj5XwQFfBuyzKYrp8NxaFW80Dp824bqjr+zA==",
"license": "Apache-2.0",
"dependencies": {
"wasm-feature-detect": "^1.2.11"
}
},
"node_modules/@jsquash/resize": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@jsquash/resize/-/resize-2.1.1.tgz",
"integrity": "sha512-0R5UL1ZLHUT+carjVikcE1QfA+kfNQ2YamYyGVRmhfh4zttU5EY3bQBGxPIPtY2xIAw1P4Kgyxm2xrceRw1r2w==",
"license": "Apache-2.0"
},
"node_modules/@jsquash/webp": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jsquash/webp/-/webp-1.5.0.tgz",
"integrity": "sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw==",
"license": "Apache-2.0",
"dependencies": {
"wasm-feature-detect": "^1.2.11"
}
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.150.0", "version": "0.150.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
@@ -1299,6 +1338,21 @@
"react": "^18 || ^19" "react": "^18 || ^19"
} }
}, },
"node_modules/@types/dom-mediacapture-transform": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz",
"integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==",
"license": "MIT",
"dependencies": {
"@types/dom-webcodecs": "*"
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz",
"integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.5", "version": "24.13.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz",
@@ -1445,6 +1499,21 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/gifenc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
"license": "MIT"
},
"node_modules/gifuct-js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
"license": "MIT",
"dependencies": {
"js-binary-schema-parser": "^2.0.3"
}
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1462,6 +1531,12 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-binary-schema-parser": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
"license": "MIT"
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -1754,6 +1829,24 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/mediabunny": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.58.0.tgz",
"integrity": "sha512-0jW6vSDVqWwNe3+WBryrMzC2mUBfk6E5KQLGXLdKVGzclceXXIsbayEkAsNDCuoTcoMiVI62sPDnkkFDxiMjGw==",
"license": "MPL-2.0",
"workspaces": [
".",
"packages/*"
],
"dependencies": {
"@types/dom-mediacapture-transform": "^0.1.11",
"@types/dom-webcodecs": "0.1.13"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/Vanilagy"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.19", "version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
@@ -1822,6 +1915,12 @@
} }
} }
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2055,6 +2154,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/upng-js": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/upng-js/-/upng-js-2.1.0.tgz",
"integrity": "sha512-d3xzZzpMP64YkjP5pr8gNyvBt7dLk/uGI67EctzDuVp4lCZyVMo0aJO6l/VDlgbInJYDY6cnClLoBp29eKWI6g==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.5"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
@@ -2133,6 +2241,12 @@
} }
} }
}, },
"node_modules/wasm-feature-detect": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz",
"integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==",
"license": "Apache-2.0"
},
"node_modules/zustand": { "node_modules/zustand": {
"version": "5.0.15", "version": "5.0.15",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
+9
View File
@@ -10,13 +10,22 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@jsquash/jpeg": "^1.6.0",
"@jsquash/oxipng": "^2.3.0",
"@jsquash/resize": "^2.1.1",
"@jsquash/webp": "^1.5.0",
"@tanstack/react-query": "^5.103.1", "@tanstack/react-query": "^5.103.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"gifenc": "^1.0.3",
"gifuct-js": "^2.1.2",
"lucide-react": "^1.47.0", "lucide-react": "^1.47.0",
"mediabunny": "^1.58.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router-dom": "^7.18.4", "react-router-dom": "^7.18.4",
"tailwind-merge": "^3.7.0", "tailwind-merge": "^3.7.0",
"upng-js": "^2.1.0",
"wasm-feature-detect": "^1.9.0",
"zustand": "^5.0.15" "zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Download, Trash2 } from "lucide-react"; import { ArrowLeft, Download, Trash2, Wand2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
@@ -7,6 +7,7 @@ import { Button, EmptyState, Spinner, inputClass, linkButtonClass } from "@/comp
import { TagChip } from "@/components/TagChip"; import { TagChip } from "@/components/TagChip";
import { DetailShell } from "@/features/detail/DetailShell"; import { DetailShell } from "@/features/detail/DetailShell";
import { IqdbCard } from "@/features/library/IqdbCard"; import { IqdbCard } from "@/features/library/IqdbCard";
import { OptimizeModal } from "@/features/optimize/OptimizeModal";
import { api, errorMessage } from "@/lib/api"; import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { isTypingTarget } from "@/lib/dom"; import { isTypingTarget } from "@/lib/dom";
@@ -495,6 +496,7 @@ export function LibraryDetail({ jId }: { jId: string }) {
const ordering = useUi((state) => state.ordering); const ordering = useUi((state) => state.ordering);
const user = useAuth((state) => state.user); const user = useAuth((state) => state.user);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [optimizeOpen, setOptimizeOpen] = useState(false);
const query = useQuery({ const query = useQuery({
queryKey: ["file", jId], queryKey: ["file", jId],
@@ -584,6 +586,7 @@ export function LibraryDetail({ jId }: { jId: string }) {
); );
return ( return (
<>
<DetailShell <DetailShell
breadcrumb={ breadcrumb={
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
@@ -602,6 +605,15 @@ export function LibraryDetail({ jId }: { jId: string }) {
<a href={downloadUrl} className={linkButtonClass}> <a href={downloadUrl} className={linkButtonClass}>
<Download className="h-4 w-4" /> Download <Download className="h-4 w-4" /> Download
</a> </a>
{canEdit ? (
<Button
variant="secondary"
onClick={() => setOptimizeOpen(true)}
title="Compress this file in your browser"
>
<Wand2 className="h-4 w-4" /> Optimize
</Button>
) : null}
{canDelete ? ( {canDelete ? (
confirmDelete ? ( confirmDelete ? (
<> <>
@@ -728,5 +740,9 @@ export function LibraryDetail({ jId }: { jId: string }) {
} }
footer={<E621MetadataCard item={item} />} footer={<E621MetadataCard item={item} />}
/> />
{optimizeOpen ? (
<OptimizeModal item={item} onClose={() => setOptimizeOpen(false)} />
) : null}
</>
); );
} }
@@ -0,0 +1,622 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { X } from "lucide-react";
import { useState, type ReactNode } from "react";
import { Button, Spinner, inputClass } from "@/components/ui";
import { errorMessage, uploadFile } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format";
import type { MediaItem } from "@/lib/types";
import { detectVideoCodecs } from "./detect";
import {
DEFAULT_OPTIONS,
kindForExtension,
type OptimizeKind,
type OptimizeOptions,
type VideoQualityLevel,
} from "./types";
import { useOptimizer } from "./useOptimizer";
const QUALITY_LEVELS: { value: VideoQualityLevel; label: string }[] = [
{ value: "very-high", label: "Visually lossless" },
{ value: "high", label: "High (recommended)" },
{ value: "medium", label: "Balanced" },
{ value: "low", label: "Small" },
{ value: "very-low", label: "Smallest" },
];
const DIMENSION_CHOICES: { value: number | null; label: string }[] = [
{ value: null, label: "Keep original" },
{ value: 2560, label: "Max 2560 px" },
{ value: 1920, label: "Max 1920 px" },
{ value: 1280, label: "Max 1280 px" },
{ value: 800, label: "Max 800 px" },
];
const VIDEO_HEIGHTS: { value: number | null; label: string }[] = [
{ value: null, label: "Keep original" },
{ value: 2160, label: "2160p" },
{ value: 1440, label: "1440p" },
{ value: 1080, label: "1080p" },
{ value: 720, label: "720p" },
{ value: 480, label: "480p" },
{ value: 360, label: "360p" },
];
function Field({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium uppercase tracking-wide text-ctp-overlay1">
{label}
</span>
{children}
</label>
);
}
function formatEta(ms: number): string {
const seconds = Math.max(1, Math.round(ms / 1000));
if (seconds < 60) return `${seconds}s left`;
const minutes = Math.round(seconds / 60);
return `${minutes} min left`;
}
function PreviewPanel({
title,
url,
isVideo,
size,
dimensions,
note,
state,
}: {
title: string;
url: string | null;
isVideo: boolean;
size: number | null;
dimensions: { width: number | null; height: number | null } | null;
note?: string | null;
state: "original" | "result" | "empty";
}) {
return (
<section className="flex flex-col overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base">
<div className="flex items-center justify-between border-b border-ctp-surface0 px-3 py-1.5">
<span className="text-xs font-semibold text-ctp-subtext1">{title}</span>
{size !== null ? (
<span className="font-mono text-[11px] text-ctp-overlay0">
{formatBytes(size)}
</span>
) : null}
</div>
<div className="flex h-56 items-center justify-center overflow-hidden bg-ctp-mantle">
{url && state !== "empty" ? (
isVideo ? (
<video
src={url}
controls
loop
muted
preload="metadata"
className="max-h-56 w-full object-contain"
/>
) : (
<img
src={url}
alt={title}
className="max-h-56 w-full object-contain"
/>
)
) : (
<span className="px-4 text-center text-xs text-ctp-overlay0">
{state === "empty"
? "Process the file to see the result here"
: "No preview"}
</span>
)}
</div>
<div className="px-3 py-2 text-[11px] text-ctp-overlay0">
{dimensions?.width && dimensions?.height
? `${dimensions.width}×${dimensions.height}`
: "…"}
{note ? ` · ${note}` : ""}
</div>
</section>
);
}
export function OptimizeModal({
item,
onClose,
}: {
item: MediaItem;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const kind: OptimizeKind = kindForExtension(item.extension) ?? "image";
const [options, setOptions] = useState<OptimizeOptions>({
...DEFAULT_OPTIONS,
maxDimension: null,
});
const { status, progress, stage, etaMs, error, result, run, reset } =
useOptimizer();
const [uploading, setUploading] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
const [applyError, setApplyError] = useState<string | null>(null);
const [applied, setApplied] = useState(false);
const codecQuery = useQuery({
queryKey: ["optimize-codecs"],
queryFn: detectVideoCodecs,
enabled: kind === "video",
staleTime: Infinity,
retry: 0,
});
const applyMutation = useMutation({
mutationFn: async () => {
if (!result) throw new Error("Nothing to apply yet.");
const extension =
kind === "video" && options.container !== "keep"
? options.container
: item.extension;
const file = new File([result.blob], `${item.j_id}.${extension}`, {
type: result.blob.type,
});
setUploading(true);
setUploadPercent(0);
try {
return await uploadFile<MediaItem>(
file,
{},
setUploadPercent,
`/api/files/${item.j_id}/optimize/`,
);
} finally {
setUploading(false);
}
},
onSuccess: () => {
setApplied(true);
setApplyError(null);
void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
void queryClient.invalidateQueries({ queryKey: ["storage"] });
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
},
onError: (err) => setApplyError(errorMessage(err)),
});
const originalSize = item.size;
const resultSize = result?.blob.size ?? null;
const savedPercent =
resultSize !== null && originalSize > 0
? Math.round((1 - resultSize / originalSize) * 100)
: null;
const isVideo = kind === "video";
const hardwareAvailable =
codecQuery.data?.some((entry) => entry.supported && entry.hardware) ??
false;
function process() {
setApplied(false);
setApplyError(null);
run({
url: item.raw_url,
filename: item.filename,
kind,
options,
});
}
return (
<div
className="fixed inset-0 z-[70] flex items-start justify-center overflow-y-auto bg-ctp-crust/70 px-4 py-8 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-4xl overflow-hidden rounded-lg border border-ctp-surface1 bg-ctp-surface0 shadow-[0_4px_12px_rgba(17,17,27,0.6)]"
onClick={(event) => event.stopPropagation()}
>
<header className="flex items-start justify-between gap-3 border-b border-ctp-surface1 px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-ctp-text">
Optimize{" "}
<span className="font-mono text-xs text-ctp-overlay0">
{item.j_id} · {item.extension} · {formatBytes(originalSize)}
</span>
</h2>
<p className="mt-0.5 text-[11px] text-ctp-overlay0">
Processed in your browser (WebCodecs/WASM) — the server only
stores the result. Applying overwrites the file and recomputes
its hashes.
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-ctp-overlay0 transition hover:bg-ctp-surface1 hover:text-ctp-text"
title="Close"
>
<X className="h-4 w-4" />
</button>
</header>
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,1fr)_280px]">
<div className="flex min-w-0 flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
<PreviewPanel
title="Original"
url={item.raw_url}
isVideo={isVideo}
size={originalSize}
dimensions={{ width: null, height: null }}
state="original"
/>
<PreviewPanel
title="Processed"
url={result?.url ?? null}
isVideo={isVideo}
size={resultSize}
dimensions={
result
? { width: result.width, height: result.height }
: null
}
note={result?.note ?? null}
state={result ? "result" : "empty"}
/>
</div>
{status === "running" ? (
<div className="rounded-md border border-ctp-surface1 bg-ctp-base p-3">
<div className="flex items-center gap-3">
<Spinner className="h-4 w-4" />
<span className="text-sm text-ctp-subtext1">
{stage || "Processing the file…"}
</span>
<span className="ml-auto font-mono text-xs text-ctp-overlay0">
{Math.round(progress * 100)}%
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-ctp-surface0">
<div
className="h-full bg-ctp-mauve transition-all"
style={{ width: `${Math.round(progress * 100)}%` }}
/>
</div>
{etaMs !== null ? (
<p className="mt-1 text-[11px] text-ctp-overlay0">
about {formatEta(etaMs)}
</p>
) : null}
</div>
) : null}
{status === "error" && error ? (
<p className="rounded-md border border-ctp-red/40 bg-ctp-red/10 px-3 py-2 text-xs text-ctp-red">
{error}
</p>
) : null}
{result && savedPercent !== null ? (
<p
className={cn(
"rounded-md border px-3 py-2 text-xs",
savedPercent > 0
? "border-ctp-green/40 bg-ctp-green/10 text-ctp-green"
: "border-ctp-peach/40 bg-ctp-peach/10 text-ctp-peach",
)}
>
{formatBytes(originalSize)} → {formatBytes(result.blob.size)}{" "}
{savedPercent > 0
? `(−${savedPercent}%)`
: `(+${Math.abs(savedPercent)}% — larger than the original)`}
</p>
) : null}
{uploading ? (
<div className="flex items-center gap-3">
<div className="h-1.5 w-40 overflow-hidden rounded-full bg-ctp-surface0">
<div
className="h-full bg-ctp-blue transition-all"
style={{ width: `${uploadPercent}%` }}
/>
</div>
<span className="font-mono text-xs text-ctp-overlay0">
uploading… {uploadPercent}%
</span>
</div>
) : null}
{applyError ? (
<p className="rounded-md border border-ctp-red/40 bg-ctp-red/10 px-3 py-2 text-xs text-ctp-red">
{applyError}
</p>
) : null}
{applied ? (
<p className="rounded-md border border-ctp-green/40 bg-ctp-green/10 px-3 py-2 text-xs text-ctp-green">
Applied — the item now stores the processed file.
</p>
) : null}
</div>
<aside className="flex flex-col gap-3">
{kind === "image" ? (
<>
{item.extension !== "png" ? (
<Field label={`Quality — ${options.quality}`}>
<input
type="range"
min={10}
max={100}
value={options.quality}
onChange={(event) =>
setOptions((current) => ({
...current,
quality: Number(event.target.value),
}))
}
className="w-full accent-ctp-mauve"
/>
</Field>
) : (
<Field label={`PNG optimization — level ${options.pngLevel}`}>
<input
type="range"
min={0}
max={6}
value={options.pngLevel}
onChange={(event) =>
setOptions((current) => ({
...current,
pngLevel: Number(event.target.value),
}))
}
className="w-full accent-ctp-mauve"
/>
</Field>
)}
<Field label="Resolution">
<select
className={inputClass}
value={options.maxDimension ?? ""}
onChange={(event) =>
setOptions((current) => ({
...current,
maxDimension: event.target.value
? Number(event.target.value)
: null,
}))
}
>
{DIMENSION_CHOICES.map((choice) => (
<option
key={choice.value ?? "keep"}
value={choice.value ?? ""}
>
{choice.label}
</option>
))}
</select>
</Field>
</>
) : null}
{kind === "animation" ? (
<>
<Field label="Scale">
<select
className={inputClass}
value={options.scale}
onChange={(event) =>
setOptions((current) => ({
...current,
scale: Number(event.target.value),
}))
}
>
<option value={1}>100%</option>
<option value={0.75}>75%</option>
<option value={0.5}>50%</option>
<option value={0.33}>33%</option>
</select>
</Field>
<Field label="Frame rate">
<select
className={inputClass}
value={options.fps ?? ""}
onChange={(event) =>
setOptions((current) => ({
...current,
fps: event.target.value
? Number(event.target.value)
: null,
}))
}
>
<option value="">Keep original</option>
<option value={30}>30 fps</option>
<option value={20}>20 fps</option>
<option value={15}>15 fps</option>
<option value={12}>12 fps</option>
<option value={10}>10 fps</option>
</select>
</Field>
<Field label={`Colors — ${options.colors}`}>
<select
className={inputClass}
value={options.colors}
onChange={(event) =>
setOptions((current) => ({
...current,
colors: Number(event.target.value),
}))
}
>
<option value={256}>256</option>
<option value={192}>192</option>
<option value={128}>128</option>
<option value={96}>96</option>
<option value={64}>64</option>
<option value={32}>32</option>
</select>
</Field>
</>
) : null}
{kind === "video" ? (
<>
<Field label="Quality">
<select
className={inputClass}
value={options.qualityLevel}
onChange={(event) =>
setOptions((current) => ({
...current,
qualityLevel: event.target.value as VideoQualityLevel,
}))
}
>
{QUALITY_LEVELS.map((level) => (
<option key={level.value} value={level.value}>
{level.label}
</option>
))}
</select>
</Field>
<Field label="Resolution">
<select
className={inputClass}
value={options.height ?? ""}
onChange={(event) =>
setOptions((current) => ({
...current,
height: event.target.value
? Number(event.target.value)
: null,
}))
}
>
{VIDEO_HEIGHTS.map((choice) => (
<option
key={choice.value ?? "keep"}
value={choice.value ?? ""}
>
{choice.label}
</option>
))}
</select>
</Field>
<Field label="Codec">
<select
className={inputClass}
value={options.codec}
onChange={(event) =>
setOptions((current) => ({
...current,
codec: event.target.value as OptimizeOptions["codec"],
}))
}
>
<option value="auto">Auto (best available)</option>
{(codecQuery.data ?? [])
.filter((entry) => entry.supported)
.map((entry) => (
<option key={entry.codec} value={entry.codec}>
{entry.codec.toUpperCase()}
{entry.hardware ? " (hardware)" : ""}
</option>
))}
</select>
</Field>
<Field label="Container">
<select
className={inputClass}
value={options.container}
onChange={(event) =>
setOptions((current) => ({
...current,
container: event.target
.value as OptimizeOptions["container"],
}))
}
>
<option value="keep">Keep ({item.extension})</option>
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
</Field>
<label className="flex items-start gap-2 text-xs text-ctp-subtext0">
<input
type="checkbox"
checked={options.preferHardware}
onChange={(event) =>
setOptions((current) => ({
...current,
preferHardware: event.target.checked,
}))
}
className="mt-0.5 accent-ctp-mauve"
/>
Prefer hardware encoding
</label>
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
{codecQuery.isPending
? "Checking the encoders this browser offers…"
: hardwareAvailable
? "Hardware-accelerated encoders are available on this machine."
: "No hardware encoder detected — encoding will use the CPU."}
</p>
</>
) : null}
<p className="mt-auto text-[11px] leading-relaxed text-ctp-overlay0">
Two-pass encoding and arbitrary FFmpeg filters are not available
in the browser; the result is muxed by WebCodecs.
</p>
</aside>
</div>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t border-ctp-surface1 px-4 py-3">
<span className="font-mono text-[11px] text-ctp-overlay0">
{result && savedPercent !== null
? `${savedPercent > 0 ? "−" : "+"}${Math.abs(savedPercent)}%`
: status === "running"
? "processing…"
: "not processed yet"}
</span>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
onClick={reset}
disabled={status === "running" || (!result && !error)}
>
Reset
</Button>
<Button onClick={process} disabled={status === "running"}>
{status === "done" ? "Process again" : "Process"}
</Button>
<Button
variant="secondary"
onClick={() => applyMutation.mutate()}
disabled={
!result || applied || uploading || status === "running"
}
>
Apply (overwrite {item.j_id})
</Button>
</div>
</footer>
</div>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
import { applyPalette, GIFEncoder, quantize } from "gifenc";
import { decompressFrames, parseGIF } from "gifuct-js";
import UPNG from "upng-js";
import {
extensionOf,
type OptimizeOptions,
type OptimizeResult,
type ProgressReporter,
} from "./types";
interface AnimationFrame {
imageData: ImageData;
delayMs: number;
}
function context2d(canvas: OffscreenCanvas): OffscreenCanvasRenderingContext2D {
const context = canvas.getContext("2d");
if (!context) {
throw new Error("This browser does not support OffscreenCanvas.");
}
return context;
}
function scaleFrame(
imageData: ImageData,
width: number,
height: number,
): ImageData {
if (imageData.width === width && imageData.height === height) {
return imageData;
}
const source = new OffscreenCanvas(imageData.width, imageData.height);
context2d(source).putImageData(imageData, 0, 0);
const target = new OffscreenCanvas(width, height);
const targetContext = context2d(target);
targetContext.imageSmoothingEnabled = true;
targetContext.imageSmoothingQuality = "high";
targetContext.drawImage(source, 0, 0, width, height);
return targetContext.getImageData(0, 0, width, height);
}
/** Keep the duration but sample the timeline at no more than `fps`. */
function resampleFrames(
frames: AnimationFrame[],
fps: number | null,
): AnimationFrame[] {
if (!fps || frames.length <= 1) return frames;
const step = 1000 / fps;
const totalMs = frames.reduce(
(sum, frame) => sum + Math.max(frame.delayMs, 10),
0,
);
const out: AnimationFrame[] = [];
let cursor = 0;
let elapsed = 0;
for (let time = 0; time < totalMs; time += step) {
while (
cursor < frames.length - 1 &&
elapsed + frames[cursor].delayMs <= time
) {
elapsed += frames[cursor].delayMs;
cursor += 1;
}
out.push({ imageData: frames[cursor].imageData, delayMs: step });
}
return out.length > 0 ? out : frames;
}
async function decodeGif(
blob: Blob,
): Promise<{ frames: AnimationFrame[]; width: number; height: number }> {
const parsed = parseGIF(await blob.arrayBuffer());
const decoded = decompressFrames(parsed, true);
if (decoded.length === 0) {
throw new Error("No frames found in this GIF.");
}
const width = parsed.lsd.width;
const height = parsed.lsd.height;
const canvas = new OffscreenCanvas(width, height);
const context = context2d(canvas);
const frames: AnimationFrame[] = [];
let restoreNeeded = false;
for (const frame of decoded) {
if (restoreNeeded) {
context.clearRect(0, 0, width, height);
restoreNeeded = false;
}
const patch = new ImageData(
new Uint8ClampedArray(frame.patch),
frame.dims.width,
frame.dims.height,
);
context.putImageData(patch, frame.dims.left, frame.dims.top);
frames.push({
imageData: context.getImageData(0, 0, width, height),
delayMs: frame.delay || 100,
});
if (frame.disposalType === 2) restoreNeeded = true;
}
return { frames, width, height };
}
async function decodeApng(
blob: Blob,
): Promise<{ frames: AnimationFrame[]; width: number; height: number }> {
const image = UPNG.decode(await blob.arrayBuffer());
const raw = UPNG.toRGBA8(image);
const frames = raw.map((frame, index) => ({
imageData: new ImageData(
new Uint8ClampedArray(frame),
image.width,
image.height,
),
delayMs: image.frames[index]?.delay || 100,
}));
if (frames.length === 0) {
throw new Error("No frames found in this APNG.");
}
return { frames, width: image.width, height: image.height };
}
function encodeGif(
frames: AnimationFrame[],
width: number,
height: number,
colors: number,
report: ProgressReporter,
): Blob {
const encoder = GIFEncoder();
frames.forEach((frame, index) => {
const palette = quantize(frame.imageData.data, colors, {
format: "rgb444",
});
const indexed = applyPalette(frame.imageData.data, palette, "rgb444");
encoder.writeFrame(indexed, width, height, {
palette,
delay: Math.max(20, Math.round(frame.delayMs)),
repeat: 0,
first: index === 0,
});
if (index % 8 === 0) {
report(0.85 + 0.1 * (index / frames.length), "Encoding the GIF…");
}
});
encoder.finish();
const bytes = encoder.bytesView();
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return new Blob([copy.buffer], { type: "image/gif" });
}
function encodeApng(
frames: AnimationFrame[],
width: number,
height: number,
colors: number,
): Blob {
const buffers = frames.map(
(frame) => frame.imageData.data.buffer as ArrayBuffer,
);
const delays = frames.map((frame) => Math.max(20, Math.round(frame.delayMs)));
const encoded = UPNG.encode(buffers, width, height, colors, delays);
return new Blob([encoded], { type: "image/apng" });
}
/** Re-encode GIF/APNG with a smaller palette and (optionally) fewer frames. */
export async function optimizeAnimation(
blob: Blob,
filename: string,
options: OptimizeOptions,
report: ProgressReporter,
): Promise<OptimizeResult> {
const extension = extensionOf(filename);
const isApng = extension === "apng";
report(0.05, isApng ? "Decoding the APNG…" : "Decoding the GIF…");
const { frames, width, height } = isApng
? await decodeApng(blob)
: await decodeGif(blob);
const targetWidth = Math.max(1, Math.round(width * options.scale));
const targetHeight = Math.max(1, Math.round(height * options.scale));
// Keep the frame count sane for long animations.
let fps = options.fps;
const totalMs = frames.reduce(
(sum, frame) => sum + Math.max(frame.delayMs, 10),
0,
);
if (fps) {
const maxFps = (600 * 1000) / Math.max(totalMs, 1);
fps = Math.min(fps, Math.max(1, maxFps));
}
const resampled = resampleFrames(frames, fps);
report(0.3, "Scaling frames…");
const scaled = resampled.map((frame, index) => {
const imageData = scaleFrame(
frame.imageData,
targetWidth,
targetHeight,
);
if (index % 8 === 0) {
report(0.3 + 0.5 * (index / Math.max(resampled.length, 1)), "Scaling frames…");
}
return { imageData, delayMs: frame.delayMs };
});
report(0.85, isApng ? "Encoding the APNG…" : "Encoding the GIF…");
const output = isApng
? encodeApng(scaled, targetWidth, targetHeight, options.colors)
: encodeGif(scaled, targetWidth, targetHeight, options.colors, report);
return {
blob: output,
width: targetWidth,
height: targetHeight,
note: `${scaled.length} frame(s)`,
};
}
+32
View File
@@ -0,0 +1,32 @@
import { canEncodeVideo, getEncodableVideoCodecs, type VideoCodec } from "mediabunny";
export interface VideoCodecSupport {
codec: VideoCodec;
supported: boolean;
hardware: boolean;
}
const VIDEO_CODEC_CANDIDATES: VideoCodec[] = ["avc", "hevc", "vp9", "av1"];
/** Which codecs this browser can encode, and whether hardware is preferred. */
export async function detectVideoCodecs(): Promise<VideoCodecSupport[]> {
const supported = new Set(await getEncodableVideoCodecs());
const results: VideoCodecSupport[] = [];
for (const codec of VIDEO_CODEC_CANDIDATES) {
const encodable = supported.has(codec);
let hardware = false;
if (encodable) {
try {
hardware = await canEncodeVideo(codec, {
width: 1920,
height: 1080,
hardwareAcceleration: "prefer-hardware",
});
} catch {
hardware = false;
}
}
results.push({ codec, supported: encodable, hardware });
}
return results;
}
+123
View File
@@ -0,0 +1,123 @@
import encodeJpeg, { init as initJpeg } from "@jsquash/jpeg/encode.js";
import encodeWebp, { init as initWebp } from "@jsquash/webp/encode.js";
import {
default as oxipng,
init as initOxipng,
} from "@jsquash/oxipng/optimise.js";
import oxipngMtWasmUrl from "@jsquash/oxipng/codec/pkg-parallel/squoosh_oxipng_bg.wasm?url";
import oxipngStWasmUrl from "@jsquash/oxipng/codec/pkg/squoosh_oxipng_bg.wasm?url";
import jpegWasmUrl from "@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm?url";
import webpSimdWasmUrl from "@jsquash/webp/codec/enc/webp_enc_simd.wasm?url";
import webpWasmUrl from "@jsquash/webp/codec/enc/webp_enc.wasm?url";
import { threads } from "wasm-feature-detect";
import {
extensionOf,
type OptimizeOptions,
type OptimizeResult,
type ProgressReporter,
} from "./types";
/**
* wasm-bindgen resolves its own wasm relative to the module, which bundlers
* rewrite; hand it the asset URL that matches the threaded/single-threaded
* build it is about to pick. This pipeline only ever runs inside the
* optimization worker, so the threading branch mirrors jSquash's own check
* (worker context + multiple cores + shared-memory support).
*/
let oxipngReady: Promise<unknown> | null = null;
async function ensureOxipng(): Promise<void> {
if (!oxipngReady) {
const cores = globalThis.navigator?.hardwareConcurrency ?? 1;
const useThreads = cores > 1 && (await threads());
oxipngReady = initOxipng({
module_or_path: useThreads ? oxipngMtWasmUrl : oxipngStWasmUrl,
});
}
await oxipngReady;
}
/** Copy into a fresh ArrayBuffer so shared wasm memory never reaches Blob. */
function toArrayBuffer(value: ArrayBuffer | Uint8Array): ArrayBuffer {
const view = value instanceof Uint8Array ? value : new Uint8Array(value);
const copy = new Uint8Array(view.byteLength);
copy.set(view);
return copy.buffer;
}
/** Decode with the browser, scale on a canvas, re-encode with jSquash codecs. */
export async function optimizeImage(
blob: Blob,
filename: string,
options: OptimizeOptions,
report: ProgressReporter,
): Promise<OptimizeResult> {
const extension = extensionOf(filename);
report(0.05, "Decoding the image…");
const bitmap = await createImageBitmap(blob);
const sourceWidth = bitmap.width;
const sourceHeight = bitmap.height;
let width = sourceWidth;
let height = sourceHeight;
if (options.maxDimension && Math.max(width, height) > options.maxDimension) {
const factor = options.maxDimension / Math.max(width, height);
width = Math.max(1, Math.round(width * factor));
height = Math.max(1, Math.round(height * factor));
}
const resized = width !== sourceWidth || height !== sourceHeight;
let imageData: ImageData | null = null;
if (resized || extension !== "png") {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d");
if (!context) {
throw new Error("This browser does not support OffscreenCanvas.");
}
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.drawImage(bitmap, 0, 0, width, height);
imageData = context.getImageData(0, 0, width, height);
}
bitmap.close();
report(0.4, "Encoding…");
let buffer: ArrayBuffer;
let type: string;
if (extension === "png") {
report(0.35, "Optimizing the PNG…");
await ensureOxipng();
buffer =
imageData !== null
? toArrayBuffer(await oxipng(imageData, { level: options.pngLevel }))
: toArrayBuffer(
await oxipng(await blob.arrayBuffer(), {
level: options.pngLevel,
}),
);
type = "image/png";
} else if (extension === "webp") {
await initWebp({
locateFile: (path: string) =>
path.includes("simd") ? webpSimdWasmUrl : webpWasmUrl,
});
buffer = await encodeWebp(imageData as ImageData, {
quality: options.quality,
});
type = "image/webp";
} else {
await initJpeg({ locateFile: () => jpegWasmUrl });
buffer = await encodeJpeg(imageData as ImageData, {
quality: options.quality,
});
type = "image/jpeg";
}
report(0.95, "Packaging…");
return {
blob: new Blob([buffer], { type }),
width,
height,
};
}
+78
View File
@@ -0,0 +1,78 @@
declare module "gifenc" {
export interface GifWriteFrameOptions {
palette?: number[][];
delay?: number;
repeat?: number;
transparent?: boolean;
transparentIndex?: number;
dispose?: number;
first?: boolean;
}
export interface GifEncoderInstance {
writeFrame(
index: Uint8Array,
width: number,
height: number,
options?: GifWriteFrameOptions,
): void;
finish(): void;
bytesView(): Uint8Array;
bytes(): Uint8Array;
reset(): void;
}
export function GIFEncoder(options?: {
auto?: boolean;
initialCapacity?: number;
}): GifEncoderInstance;
export function quantize(
rgba: Uint8Array | Uint8ClampedArray,
maxColors: number,
options?: {
format?: "rgb565" | "rgb444" | "rgba4444";
oneBitAlpha?: boolean | number;
clearAlpha?: boolean;
},
): number[][];
export function applyPalette(
rgba: Uint8Array | Uint8ClampedArray,
palette: number[][],
format?: "rgb565" | "rgb444" | "rgba4444",
): Uint8Array;
}
declare module "upng-js" {
export interface UPNGFrame {
rect: { x: number; y: number; width: number; height: number };
delay: number;
dispose: number;
blend: number;
}
export interface UPNGImage {
width: number;
height: number;
depth: number;
ctype: number;
frames: UPNGFrame[];
data: Uint8Array;
tabs: Record<string, unknown>;
}
const UPNG: {
decode(buffer: ArrayBuffer | Uint8Array): UPNGImage;
toRGBA8(image: UPNGImage): ArrayBuffer[];
encode(
frames: (ArrayBuffer | Uint8Array)[],
width: number,
height: number,
colorCount: number,
delays?: number[],
): ArrayBuffer;
};
export default UPNG;
}
@@ -0,0 +1,49 @@
import { optimizeAnimation } from "./animation";
import { optimizeImage } from "./image";
import type { OptimizeRequest, OptimizerMessage } from "./types";
import { optimizeVideo } from "./video";
interface WorkerScope {
postMessage(message: OptimizerMessage): void;
onmessage: ((event: MessageEvent<OptimizeRequest>) => void) | null;
}
const scope = self as unknown as WorkerScope;
scope.onmessage = async (event: MessageEvent<OptimizeRequest>) => {
const { url, filename, kind, options } = event.data;
const report = (progress: number, stage: string) =>
scope.postMessage({ type: "progress", progress, stage });
try {
report(0.01, "Downloading the original…");
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Could not load the file (HTTP ${response.status}).`);
}
const blob = await response.blob();
const result =
kind === "video"
? await optimizeVideo(blob, filename, options, report)
: kind === "animation"
? await optimizeAnimation(blob, filename, options, report)
: await optimizeImage(blob, filename, options, report);
scope.postMessage({
type: "done",
blob: result.blob,
width: result.width,
height: result.height,
note: result.note,
});
} catch (error) {
scope.postMessage({
type: "error",
message:
error instanceof Error
? error.message
: "The file could not be processed.",
});
}
};
+81
View File
@@ -0,0 +1,81 @@
/** Shared types for the client-side optimization pipeline. */
export type OptimizeKind = "image" | "animation" | "video";
export type VideoQualityLevel =
| "very-high"
| "high"
| "medium"
| "low"
| "very-low";
export interface OptimizeOptions {
// Images (jpg / png / webp)
quality: number; // 1-100 for lossy formats
pngLevel: number; // 0-6, higher = smaller/slower
maxDimension: number | null; // longest edge in px, null keeps the size
// Animations (gif / apng)
scale: number; // 1 keeps the resolution
fps: number | null; // frame-rate cap
colors: number; // palette size
// Video (mp4 / webm)
codec: "auto" | "avc" | "hevc" | "vp9" | "av1";
qualityLevel: VideoQualityLevel;
container: "keep" | "mp4" | "webm";
height: number | null; // target height, null keeps the size
preferHardware: boolean;
}
export const DEFAULT_OPTIONS: OptimizeOptions = {
quality: 82,
pngLevel: 2,
maxDimension: null,
scale: 1,
fps: null,
colors: 192,
codec: "auto",
qualityLevel: "high",
container: "keep",
height: null,
preferHardware: true,
};
export interface OptimizeRequest {
url: string;
filename: string;
kind: OptimizeKind;
options: OptimizeOptions;
}
export interface OptimizeResult {
blob: Blob;
width: number | null;
height: number | null;
note?: string;
}
export type OptimizerMessage =
| { type: "progress"; progress: number; stage: string }
| {
type: "done";
blob: Blob;
width: number | null;
height: number | null;
note?: string;
}
| { type: "error"; message: string };
export type ProgressReporter = (progress: number, stage: string) => void;
export function extensionOf(filename: string): string {
const index = filename.lastIndexOf(".");
return index === -1 ? "" : filename.slice(index + 1).toLowerCase();
}
export function kindForExtension(extension: string): OptimizeKind | null {
const ext = extension.toLowerCase();
if (ext === "gif" || ext === "apng") return "animation";
if (ext === "mp4" || ext === "webm") return "video";
if (["jpg", "jpeg", "png", "webp"].includes(ext)) return "image";
return null;
}
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type {
OptimizeRequest,
OptimizeResult,
OptimizerMessage,
} from "./types";
export interface OptimizerState {
status: "idle" | "running" | "done" | "error";
progress: number;
stage: string;
etaMs: number | null;
error: string | null;
result: (OptimizeResult & { url: string }) | null;
}
const INITIAL: OptimizerState = {
status: "idle",
progress: 0,
stage: "",
etaMs: null,
error: null,
result: null,
};
/** Runs the optimization worker and tracks progress/result in React state. */
export function useOptimizer() {
const workerRef = useRef<Worker | null>(null);
const [state, setState] = useState<OptimizerState>(INITIAL);
const stopWorker = useCallback(() => {
workerRef.current?.terminate();
workerRef.current = null;
}, []);
useEffect(() => stopWorker, [stopWorker]);
const reset = useCallback(() => {
stopWorker();
setState((current) => {
if (current.result) URL.revokeObjectURL(current.result.url);
return INITIAL;
});
}, [stopWorker]);
const run = useCallback(
(request: OptimizeRequest) => {
stopWorker();
setState((current) => {
if (current.result) URL.revokeObjectURL(current.result.url);
return {
status: "running",
progress: 0,
stage: "Starting…",
etaMs: null,
error: null,
result: null,
};
});
const worker = new Worker(new URL("./optimize.worker.ts", import.meta.url), {
type: "module",
});
workerRef.current = worker;
const startedAt = performance.now();
worker.onmessage = (event: MessageEvent<OptimizerMessage>) => {
const message = event.data;
if (message.type === "progress") {
const elapsed = performance.now() - startedAt;
const etaMs =
message.progress > 0.05
? (elapsed * (1 - message.progress)) / message.progress
: null;
setState((current) => ({
...current,
status: "running",
progress: message.progress,
stage: message.stage,
etaMs,
}));
return;
}
if (message.type === "done") {
const url = URL.createObjectURL(message.blob);
setState({
status: "done",
progress: 1,
stage: "",
etaMs: null,
error: null,
result: {
blob: message.blob,
url,
width: message.width,
height: message.height,
note: message.note,
},
});
worker.terminate();
workerRef.current = null;
return;
}
setState({
status: "error",
progress: 0,
stage: "",
etaMs: null,
error: message.message,
result: null,
});
};
worker.onerror = (event) => {
setState({
status: "error",
progress: 0,
stage: "",
etaMs: null,
error: event.message || "The processing worker failed.",
result: null,
});
};
worker.postMessage(request);
},
[stopWorker],
);
return { ...state, run, reset };
}
+119
View File
@@ -0,0 +1,119 @@
import {
ALL_FORMATS,
BlobSource,
BufferTarget,
Conversion,
Input,
Mp4OutputFormat,
Output,
Quality,
WebMOutputFormat,
getFirstEncodableVideoCodec,
type VideoCodec,
} from "mediabunny";
import {
extensionOf,
type OptimizeOptions,
type OptimizeResult,
type ProgressReporter,
} from "./types";
const CODEC_CANDIDATES: Record<"mp4" | "webm", VideoCodec[]> = {
mp4: ["avc", "hevc"],
webm: ["vp9", "av1"],
};
function even(value: number): number {
return Math.max(2, value - (value % 2));
}
/** Transcode with WebCodecs (hardware-accelerated where available). */
export async function optimizeVideo(
blob: Blob,
filename: string,
options: OptimizeOptions,
report: ProgressReporter,
): Promise<OptimizeResult> {
const extension = extensionOf(filename);
report(0.03, "Reading the video…");
const input = new Input({
source: new BlobSource(blob),
formats: ALL_FORMATS,
});
const track = await input.getPrimaryVideoTrack();
if (!track) {
throw new Error("No video track found in this file.");
}
const sourceWidth = await track.getDisplayWidth();
const sourceHeight = await track.getDisplayHeight();
const container: "mp4" | "webm" =
options.container === "keep"
? extension === "webm"
? "webm"
: "mp4"
: options.container;
const format =
container === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
const target = new BufferTarget();
const output = new Output({ format, target });
const quality = new Quality(options.qualityLevel);
let codec: VideoCodec | undefined =
options.codec === "auto" ? undefined : options.codec;
if (!codec) {
const candidate = await getFirstEncodableVideoCodec(
CODEC_CANDIDATES[container],
{ width: sourceWidth, height: sourceHeight, quality },
);
if (!candidate) {
throw new Error(
`This browser cannot encode ${container.toUpperCase()} video.`,
);
}
codec = candidate;
}
const outputHeight = options.height ?? sourceHeight;
const outputWidth = Math.round(sourceWidth * (outputHeight / sourceHeight));
report(0.06, "Preparing the encoder…");
const conversion = await Conversion.init({
input,
output,
video: {
codec,
quality,
height: even(outputHeight),
fit: "contain",
hardwareAcceleration: options.preferHardware
? "prefer-hardware"
: "no-preference",
forceTranscode: true,
},
});
if (!conversion.isValid) {
throw new Error(
"This conversion is not supported here — try another codec or container.",
);
}
conversion.onProgress = (progress: number) => {
report(0.08 + Math.min(Math.max(progress, 0), 1) * 0.85, "Encoding…");
};
await conversion.execute();
const buffer = target.buffer;
if (!buffer) {
throw new Error("Encoding produced no output.");
}
report(0.97, "Packaging…");
return {
blob: new Blob([buffer], {
type: container === "webm" ? "video/webm" : "video/mp4",
}),
width: even(outputWidth),
height: even(outputHeight),
note: `${codec} · ${container}`,
};
}