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
|
## 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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Generated
+116
-2
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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