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:
+15
-5
@@ -101,9 +101,18 @@ Files now stage first and are resolved before entering the library.
|
||||
|
||||
## 4. Staff tools
|
||||
|
||||
- [ ] **Optimization modal** (image quality, GIF/APNG resolution+FPS+compression,
|
||||
video bitrate/presets/two-pass/hw-accel) with original vs processed preview
|
||||
and override
|
||||
- [x] **Optimization modal** (client-side, no server processing)
|
||||
- [x] Browser pipeline in a Web Worker: Mediabunny/WebCodecs for video
|
||||
(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**
|
||||
- [ ] CPU / RAM / GPU / disk usage
|
||||
- [ ] 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
|
||||
(`imagehash` server-side; no external hashing service).
|
||||
- Download progress, optimization jobs and the stats "Active Workers" count all
|
||||
want the same background-task/progress primitive — design it once.
|
||||
- Download progress, optimization and the stats "Active Workers" count share
|
||||
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
|
||||
talks to e621 directly for browsing, while the backend e621 client
|
||||
(`apps/library/e621.py`) handles metadata matching and batch scans.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+116
-2
@@ -1,20 +1,29 @@
|
||||
{
|
||||
"name": "vite-scaffold",
|
||||
"name": "j621-frontend",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vite-scaffold",
|
||||
"name": "j621-frontend",
|
||||
"version": "0.0.0",
|
||||
"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",
|
||||
"clsx": "^2.1.1",
|
||||
"gifenc": "^1.0.3",
|
||||
"gifuct-js": "^2.1.2",
|
||||
"lucide-react": "^1.47.0",
|
||||
"mediabunny": "^1.58.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"upng-js": "^2.1.0",
|
||||
"wasm-feature-detect": "^1.9.0",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -79,6 +88,36 @@
|
||||
"@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": {
|
||||
"version": "0.150.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
|
||||
@@ -1299,6 +1338,21 @@
|
||||
"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": {
|
||||
"version": "24.13.5",
|
||||
"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_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": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -1462,6 +1531,12 @@
|
||||
"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": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
@@ -1754,6 +1829,24 @@
|
||||
"@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": {
|
||||
"version": "3.3.19",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2055,6 +2154,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "8.3.0",
|
||||
"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": {
|
||||
"version": "5.0.15",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
|
||||
|
||||
@@ -10,13 +10,22 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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",
|
||||
"clsx": "^2.1.1",
|
||||
"gifenc": "^1.0.3",
|
||||
"gifuct-js": "^2.1.2",
|
||||
"lucide-react": "^1.47.0",
|
||||
"mediabunny": "^1.58.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"upng-js": "^2.1.0",
|
||||
"wasm-feature-detect": "^1.9.0",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button, EmptyState, Spinner, inputClass, linkButtonClass } from "@/comp
|
||||
import { TagChip } from "@/components/TagChip";
|
||||
import { DetailShell } from "@/features/detail/DetailShell";
|
||||
import { IqdbCard } from "@/features/library/IqdbCard";
|
||||
import { OptimizeModal } from "@/features/optimize/OptimizeModal";
|
||||
import { api, errorMessage } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { isTypingTarget } from "@/lib/dom";
|
||||
@@ -495,6 +496,7 @@ export function LibraryDetail({ jId }: { jId: string }) {
|
||||
const ordering = useUi((state) => state.ordering);
|
||||
const user = useAuth((state) => state.user);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [optimizeOpen, setOptimizeOpen] = useState(false);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["file", jId],
|
||||
@@ -584,6 +586,7 @@ export function LibraryDetail({ jId }: { jId: string }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailShell
|
||||
breadcrumb={
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
@@ -602,6 +605,15 @@ export function LibraryDetail({ jId }: { jId: string }) {
|
||||
<a href={downloadUrl} className={linkButtonClass}>
|
||||
<Download className="h-4 w-4" /> Download
|
||||
</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 ? (
|
||||
confirmDelete ? (
|
||||
<>
|
||||
@@ -728,5 +740,9 @@ export function LibraryDetail({ jId }: { jId: string }) {
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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)`,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user