diff --git a/ROADMAP.md b/ROADMAP.md index 061df7f..896bee2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/backend/apps/library/views.py b/backend/apps/library/views.py index 70c4101..a9e9b9c 100644 --- a/backend/apps/library/views.py +++ b/backend/apps/library/views.py @@ -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, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 50af4db..d65912b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index c0262c6..57ab2c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/features/library/LibraryDetail.tsx b/frontend/src/features/library/LibraryDetail.tsx index 82d816f..b5f8efa 100644 --- a/frontend/src/features/library/LibraryDetail.tsx +++ b/frontend/src/features/library/LibraryDetail.tsx @@ -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 ( + <> @@ -602,6 +605,15 @@ export function LibraryDetail({ jId }: { jId: string }) { Download + {canEdit ? ( + + ) : null} {canDelete ? ( confirmDelete ? ( <> @@ -728,5 +740,9 @@ export function LibraryDetail({ jId }: { jId: string }) { } footer={} /> + {optimizeOpen ? ( + setOptimizeOpen(false)} /> + ) : null} + ); } diff --git a/frontend/src/features/optimize/OptimizeModal.tsx b/frontend/src/features/optimize/OptimizeModal.tsx new file mode 100644 index 0000000..0d38881 --- /dev/null +++ b/frontend/src/features/optimize/OptimizeModal.tsx @@ -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 ( + + ); +} + +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 ( +
+
+ {title} + {size !== null ? ( + + {formatBytes(size)} + + ) : null} +
+
+ {url && state !== "empty" ? ( + isVideo ? ( +
+
+ {dimensions?.width && dimensions?.height + ? `${dimensions.width}×${dimensions.height}` + : "…"} + {note ? ` · ${note}` : ""} +
+
+ ); +} + +export function OptimizeModal({ + item, + onClose, +}: { + item: MediaItem; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const kind: OptimizeKind = kindForExtension(item.extension) ?? "image"; + const [options, setOptions] = useState({ + ...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(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( + 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 ( +
+
event.stopPropagation()} + > +
+
+

+ Optimize{" "} + + {item.j_id} · {item.extension} · {formatBytes(originalSize)} + +

+

+ Processed in your browser (WebCodecs/WASM) — the server only + stores the result. Applying overwrites the file and recomputes + its hashes. +

+
+ +
+ +
+
+
+ + +
+ + {status === "running" ? ( +
+
+ + + {stage || "Processing the file…"} + + + {Math.round(progress * 100)}% + +
+
+
+
+ {etaMs !== null ? ( +

+ about {formatEta(etaMs)} +

+ ) : null} +
+ ) : null} + + {status === "error" && error ? ( +

+ {error} +

+ ) : null} + + {result && savedPercent !== null ? ( +

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)`} +

+ ) : null} + + {uploading ? ( +
+
+
+
+ + uploading… {uploadPercent}% + +
+ ) : null} + + {applyError ? ( +

+ {applyError} +

+ ) : null} + + {applied ? ( +

+ Applied — the item now stores the processed file. +

+ ) : null} +
+ + +
+ +
+ + {result && savedPercent !== null + ? `${savedPercent > 0 ? "−" : "+"}${Math.abs(savedPercent)}%` + : status === "running" + ? "processing…" + : "not processed yet"} + +
+ + + +
+
+
+
+ ); +} diff --git a/frontend/src/features/optimize/animation.ts b/frontend/src/features/optimize/animation.ts new file mode 100644 index 0000000..8f78811 --- /dev/null +++ b/frontend/src/features/optimize/animation.ts @@ -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 { + 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)`, + }; +} diff --git a/frontend/src/features/optimize/detect.ts b/frontend/src/features/optimize/detect.ts new file mode 100644 index 0000000..edc53cd --- /dev/null +++ b/frontend/src/features/optimize/detect.ts @@ -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 { + 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; +} diff --git a/frontend/src/features/optimize/image.ts b/frontend/src/features/optimize/image.ts new file mode 100644 index 0000000..395b18f --- /dev/null +++ b/frontend/src/features/optimize/image.ts @@ -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 | null = null; +async function ensureOxipng(): Promise { + 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 { + 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, + }; +} diff --git a/frontend/src/features/optimize/optimize-libs.d.ts b/frontend/src/features/optimize/optimize-libs.d.ts new file mode 100644 index 0000000..9fe8d84 --- /dev/null +++ b/frontend/src/features/optimize/optimize-libs.d.ts @@ -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; + } + + 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; +} diff --git a/frontend/src/features/optimize/optimize.worker.ts b/frontend/src/features/optimize/optimize.worker.ts new file mode 100644 index 0000000..37922ab --- /dev/null +++ b/frontend/src/features/optimize/optimize.worker.ts @@ -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) => void) | null; +} + +const scope = self as unknown as WorkerScope; + +scope.onmessage = async (event: MessageEvent) => { + 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.", + }); + } +}; diff --git a/frontend/src/features/optimize/types.ts b/frontend/src/features/optimize/types.ts new file mode 100644 index 0000000..2e8efdf --- /dev/null +++ b/frontend/src/features/optimize/types.ts @@ -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; +} diff --git a/frontend/src/features/optimize/useOptimizer.ts b/frontend/src/features/optimize/useOptimizer.ts new file mode 100644 index 0000000..861f8f4 --- /dev/null +++ b/frontend/src/features/optimize/useOptimizer.ts @@ -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(null); + const [state, setState] = useState(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) => { + 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 }; +} diff --git a/frontend/src/features/optimize/video.ts b/frontend/src/features/optimize/video.ts new file mode 100644 index 0000000..478ad1d --- /dev/null +++ b/frontend/src/features/optimize/video.ts @@ -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 { + 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}`, + }; +}