From 983643eb2aeee618558b1a7d3a173d4f5c962a9c Mon Sep 17 00:00:00 2001 From: Alexandre Negrel Date: Thu, 4 Jan 2024 09:38:08 +0100 Subject: [PATCH] initial commit --- .envrc | 1 + .github/images/bmc-button.png | Bin 0 -> 24374 bytes .gitignore | 13 + LICENSE | 21 + README.md | 16 + components/AlertList.tsx | 38 + components/Button.tsx | 11 + components/InformationCircleIcon.tsx | 21 + components/LeftArrowIcon.tsx | 23 + components/OutlinedButton.tsx | 11 + components/ProgressBar.tsx | 32 + components/RightArrowIcon.tsx | 23 + components/Spinner.tsx | 36 + components/UpArrowIcon.tsx | 23 + deno.json | 41 + deno.lock | 1616 ++++++++++++++++++++++++++ dev.ts | 8 + flake.lock | 61 + flake.nix | 22 + fresh.config.ts | 6 + fresh.gen.ts | 39 + islands/GoToTopButton.tsx | 31 + islands/Home.tsx | 9 + islands/ImagesCompressor.tsx | 137 +++ islands/ImagesGallery.tsx | 121 ++ islands/ImagesInput.tsx | 153 +++ islands/home/CompressingPage.tsx | 234 ++++ islands/home/SetupPage.tsx | 17 + lib/compress.ts | 84 ++ lib/format_bytes.ts | 8 + lib/rpc.ts | 108 ++ lib/signals.ts | 12 + lib/worker_pool.ts | 133 +++ main.ts | 13 + routes/_404.tsx | 27 + routes/_app.tsx | 19 + routes/_layout.tsx | 11 + routes/index.tsx | 14 + routes/static/[...name].tsx | 31 + signals/home.ts | 42 + signals/images.ts | 34 + static/favicon.ico | Bin 0 -> 22382 bytes static/image-placeholder.jpg | Bin 0 -> 35179 bytes static/image-placeholder.jpg.pp3 | 752 ++++++++++++ static/logo.svg | 6 + static/mod.mjs | 82 ++ static/styles.css | 7 + static/worker_script.js | 78 ++ tailwind.config.ts | 30 + 49 files changed, 4255 insertions(+) create mode 100644 .envrc create mode 100644 .github/images/bmc-button.png create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 components/AlertList.tsx create mode 100644 components/Button.tsx create mode 100644 components/InformationCircleIcon.tsx create mode 100644 components/LeftArrowIcon.tsx create mode 100644 components/OutlinedButton.tsx create mode 100644 components/ProgressBar.tsx create mode 100644 components/RightArrowIcon.tsx create mode 100644 components/Spinner.tsx create mode 100644 components/UpArrowIcon.tsx create mode 100644 deno.json create mode 100644 deno.lock create mode 100755 dev.ts create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 fresh.config.ts create mode 100644 fresh.gen.ts create mode 100644 islands/GoToTopButton.tsx create mode 100644 islands/Home.tsx create mode 100644 islands/ImagesCompressor.tsx create mode 100644 islands/ImagesGallery.tsx create mode 100644 islands/ImagesInput.tsx create mode 100644 islands/home/CompressingPage.tsx create mode 100644 islands/home/SetupPage.tsx create mode 100644 lib/compress.ts create mode 100644 lib/format_bytes.ts create mode 100644 lib/rpc.ts create mode 100644 lib/signals.ts create mode 100644 lib/worker_pool.ts create mode 100644 main.ts create mode 100644 routes/_404.tsx create mode 100644 routes/_app.tsx create mode 100644 routes/_layout.tsx create mode 100644 routes/index.tsx create mode 100644 routes/static/[...name].tsx create mode 100644 signals/home.ts create mode 100644 signals/images.ts create mode 100644 static/favicon.ico create mode 100644 static/image-placeholder.jpg create mode 100644 static/image-placeholder.jpg.pp3 create mode 100644 static/logo.svg create mode 100644 static/mod.mjs create mode 100644 static/styles.css create mode 100644 static/worker_script.js create mode 100644 tailwind.config.ts diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/images/bmc-button.png b/.github/images/bmc-button.png new file mode 100644 index 0000000000000000000000000000000000000000..a0f598810db3acb9e12b3a695f472c40e97ad9ff GIT binary patch literal 24374 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 I$tvvs02_|;O#lD@ literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..568954f --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# Fresh build directory +_fresh/ +# npm dependencies +node_modules/ + +.direnv/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1ba4a93 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Alexandre Negrel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec0e33e --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Fresh project + +Your new Fresh project is ready to go. You can follow the Fresh "Getting +Started" guide here: https://fresh.deno.dev/docs/getting-started + +### Usage + +Make sure to install Deno: https://deno.land/manual/getting_started/installation + +Then start the project: + +``` +deno task start +``` + +This will watch the project directory and restart as necessary. diff --git a/components/AlertList.tsx b/components/AlertList.tsx new file mode 100644 index 0000000..f50b60e --- /dev/null +++ b/components/AlertList.tsx @@ -0,0 +1,38 @@ +import { ComponentChildren } from "preact"; + +function AlertList( + { icon, title, list, className, children }: { + icon?: ComponentChildren; + title: string; + list: string[]; + className?: string; + children?: ComponentChildren; + }, +) { + return ( + + ); +} + +export default AlertList; diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..d4c0622 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,11 @@ +import { ComponentProps } from "preact"; + +function Button(props: ComponentProps<"button">) { + props.className = + "select-none flex nowrap items-center gap-2 cursor-pointer rounded-lg bg-gradient-to-tr from-gray-900 to-gray-800 dark:from-gray-100 dark:to-gray-500 text-center align-middle font-sans text-xs font-bold uppercase text-slate-50 dark:text-slate-950 shadow-md shadow-gray-900/10 transition-all hover:shadow-lg hover:shadow-gray-900/20 active:opacity-[0.85] disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none " + + (props.className ?? ""); + + return + + ); +} + +export default GoToTopButton; diff --git a/islands/Home.tsx b/islands/Home.tsx new file mode 100644 index 0000000..062d32d --- /dev/null +++ b/islands/Home.tsx @@ -0,0 +1,9 @@ +import * as home from "@/signals/home.ts"; +import CompressingPage from "@/islands/home/CompressingPage.tsx"; +import SetupPage from "@/islands/home/SetupPage.tsx"; + +function Home() { + return home.isIdle() ? : ; +} + +export default Home; diff --git a/islands/ImagesCompressor.tsx b/islands/ImagesCompressor.tsx new file mode 100644 index 0000000..1b3adcd --- /dev/null +++ b/islands/ImagesCompressor.tsx @@ -0,0 +1,137 @@ +import Button from "@/components/Button.tsx"; +import { getImages } from "@/signals/images.ts"; +import OutlinedButton from "@/components/OutlinedButton.tsx"; +import ImagesGallery from "@/islands/ImagesGallery.tsx"; +import { + getCompressionOptions, + resetCompressionOptions, + startCompressing, + updateCompressionOptions, +} from "@/signals/home.ts"; + +function IdleSection() { + const compressionOptions = getCompressionOptions(); + + const settingsInputs = [{ + name: "quantity", + label: "Quality (%)", + inputRange: { + min: 1, + max: 100, + value: compressionOptions.quality, + onInput: (value: number) => updateCompressionOptions({ quality: value }), + }, + inputNumber: { + min: 1, + max: 100, + value: compressionOptions.quality, + onChange: (value: number) => updateCompressionOptions({ quality: value }), + }, + }, { + name: "max-width", + label: "Max width (px)", + inputNumber: { + min: 1, + value: compressionOptions.maxWidth, + onChange: (value: number) => + updateCompressionOptions({ maxWidth: value }), + }, + }, { + name: "max-height", + label: "Max height (px)", + inputNumber: { + min: 1, + value: compressionOptions.maxHeight, + onChange: (value: number) => + updateCompressionOptions({ maxHeight: value }), + }, + }, { + name: "compressAsZip", + label: "Zip file", + inputCheckbox: { + checked: compressionOptions.zipFile, + onChange: (value: boolean) => + updateCompressionOptions({ zipFile: value }), + }, + }]; + + return ( +
+
+ Settings + {settingsInputs.map( + ({ name, label, inputRange, inputNumber, inputCheckbox }) => ( +
+ +
+ {inputRange && + ( + + inputRange.onInput(Number.parseInt( + (ev.target as HTMLInputElement).value, + ))} + className="flex-1" + /> + )} + {inputNumber && + ( + + inputNumber.onChange(Number.parseInt( + (ev.target as HTMLInputElement).value, + ))} + className={`px-2 rounded-md mr-1 ${ + inputRange ? "w-16" : "" + }`} + /> + )} + {inputCheckbox && ( + + inputCheckbox.onChange( + (ev.target as HTMLInputElement).checked, + )} + /> + )} +
+
+ ), + )} + + Reset + +
+ +
+ ); +} + +function ImagesCompressor() { + return ( + <> + + + + ); +} + +export default ImagesCompressor; diff --git a/islands/ImagesGallery.tsx b/islands/ImagesGallery.tsx new file mode 100644 index 0000000..9b42dcd --- /dev/null +++ b/islands/ImagesGallery.tsx @@ -0,0 +1,121 @@ +import { useSignal } from "@preact/signals"; +import { + getImages, + getSelectedImages, + isImageSelected, + setSelectedImage, +} from "@/signals/images.ts"; +import { trigger } from "@/lib/signals.ts"; +import Spinner from "@/components/Spinner.tsx"; + +function ImagesGallery() { + const loadedImages = useSignal<{ src: string; imageFile: File }[]>([]); + + const observerCallback = (entries: IntersectionObserverEntry[]) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + const images = getImages(); + + // Load up to 8 more images. + const imagesToLoadCount = Math.min( + loadedImages.value.length + 8, + images.length, + ); + + // Load images async. + for (let i = loadedImages.value.length; i < imagesToLoadCount; i++) { + const imageFile = images[i]; + // Use placeholder while image is loading. + loadedImages.value[i] = { + src: "/image-placeholder.jpg", + imageFile, + }; + imageFile.arrayBuffer().then((arrayBuffer) => { + const blob = new Blob([arrayBuffer]); + loadedImages.value[i].src = URL.createObjectURL(blob); + // Trigger rendering when image loaded. + trigger(loadedImages); + }); + } + + // Trigger rendering of placeholders. + trigger(loadedImages); + }); + }; + + // Infinite scrolling using intersection observer. + // We don't load all images at first as user can add thousands of it. + const intersectionObserverReady = useSignal(false); + const setupIntersectionObserver = ( + spinnerRef: HTMLDivElement | undefined, + ) => { + if (!spinnerRef) return; + if (intersectionObserverReady.value) return; + intersectionObserverReady.value = true; + + const options = { + root: null, + rootMargin: "0px", + threshold: 0.1, + }; + const observer = new IntersectionObserver(observerCallback, options); + + // Connect spinner. + observer.observe(spinnerRef); + }; + + if (getImages().length === 0) return <>; + + const hideSpinner = loadedImages.value.length >= getImages().length; + + return ( + <> +
+ + {getSelectedImages().length} image(s) selected. + +
+ {loadedImages.value.map(({ src, imageFile }, index) => ( +
+ setSelectedImage( + index, + !isImageSelected(index), + )} + > + {imageFile.name} +
+ + +
+
+ ))} +
+
+ +
+
+ + ); +} + +export default ImagesGallery; diff --git a/islands/ImagesInput.tsx b/islands/ImagesInput.tsx new file mode 100644 index 0000000..048044e --- /dev/null +++ b/islands/ImagesInput.tsx @@ -0,0 +1,153 @@ +import { batch, useSignal } from "@preact/signals"; +import { useRef } from "preact/hooks"; +import { addImages, clearImages } from "@/signals/images.ts"; +import AlertList from "@/components/AlertList.tsx"; +import InformationCircleIcon from "@/components/InformationCircleIcon.tsx"; +import Button from "@/components/Button.tsx"; +import OutlinedButton from "@/components/OutlinedButton.tsx"; + +function ImagesDrop({ acceptedFileType }: { acceptedFileType: string[] }) { + const alertList = useSignal([]); + const dragOver = useSignal(false); + + const filterNonAcceptedFiles = (...files: File[]) => + batch(() => { + const filtered = []; + for (const f of files) { + if (acceptedFileType.includes(f.type)) { + filtered.push(f); + } else { + alertList.value.push( + `${f.name} (${f.type === "" ? "unknown file type" : f.type})`, + ); + } + } + + return filtered; + }); + + const handleDrop = ( + ev: Event & { dataTransfer: DataTransfer }, + ) => { + ev.stopPropagation(); + ev.preventDefault(); + + // Drag over done. + dragOver.value = false; + + let files: File[] = []; + if (ev.dataTransfer.items) { + // Use DataTransferItemList interface to access the file(s) + files = [...ev.dataTransfer.items].reduce( + (acc, item) => { + if (item.kind !== "file") return acc; + acc.push(item.getAsFile() as File); + return acc; + }, + [], + ); + } else { + files = [...ev.dataTransfer.files]; + } + + addImages(...filterNonAcceptedFiles(...files)); + }; + + return ( + <> +
+
ev.preventDefault()} + onDragEnter={() => dragOver.value = true} + onDragLeave={() => dragOver.value = false} + // deno-lint-ignore no-explicit-any + onDrop={handleDrop as unknown as any} + > +
+ + + + + Drag your images here + +
+
+
+ {alertList.value.length > 0 && ( + } + title="Some files are unsupported and won't be compressed:" + list={alertList.value} + /> + )} + + ); +} + +function InputImages({ accept }: { accept: string[] }) { + const inputRef = useRef(); + + const handleClick = () => { + inputRef.current?.click(); + }; + + const handleChange = (ev: Event) => { + const target = ev.target as HTMLInputElement; + + if (target.files) addImages(...target.files); + }; + + return ( + + ); +} + +function ImagesInput() { + const acceptedFileType = [ + "image/png", + "image/jpg", + "image/jpeg", + "image/bmp", + "image/tiff", + "image/vnd.microsoft.icon", + ]; + + return ( + <> +
+ + + Clear files + +
+ + + ); +} + +export default ImagesInput; diff --git a/islands/home/CompressingPage.tsx b/islands/home/CompressingPage.tsx new file mode 100644 index 0000000..58abce3 --- /dev/null +++ b/islands/home/CompressingPage.tsx @@ -0,0 +1,234 @@ +import { compress, CompressionProgress } from "@/lib/compress.ts"; +import { useEffect } from "preact/hooks"; +import ProgressBar from "@/components/ProgressBar.tsx"; +import formatBytes from "@/lib/format_bytes.ts"; +import { batch, useComputed, useSignal } from "@preact/signals"; +import { getCompressionOptions } from "@/signals/home.ts"; +import { WorkerPool } from "@/lib/worker_pool.ts"; +import GoToTopButton from "@/islands/GoToTopButton.tsx"; +import { addImages, clearImages, getSelectedImages } from "@/signals/images.ts"; +import { trigger } from "@/lib/signals.ts"; +import AlertList from "@/components/AlertList.tsx"; +import InformationCircleIcon from "@/components/InformationCircleIcon.tsx"; +import OutlinedButton from "@/components/OutlinedButton.tsx"; + +function ProgressSection( + { percentage, progress, total }: { + percentage: number; + progress: CompressionProgress[]; + total: Omit; + }, +) { + const detailsOpen = useSignal(false); + + return ( +
+
+ +
+
+
+
+ Initial size +
+
+ {formatBytes(total.initialSize)} +
+
+
+
+ Compressed size +
+
+ {formatBytes(total.compressedSize)} +
+
+
+
+ Compression rate +
+
+ {Math.round( + (1 - (total.compressedSize / total.initialSize)) * 100, + )}% +
+
+
+
+ detailsOpen.value = (ev.target as HTMLDetailsElement).open} + open={detailsOpen} + > + Progress report + {detailsOpen && + ( +
+ + + + + + + + + + + {progress.reverse().map((p, index) => ( + + + + + + + ))} + +
+

+ File name +

+
+

+ Initial size +

+
+

+ Compressed size +

+
+

+ Compression rate +

+
+

+ {p.file.name} +

+
+

+ {formatBytes(p.initialSize)} +

+
+

+ {formatBytes(p.compressedSize)} +

+
+

+ {Math.round( + (1 - (p.compressedSize / p.initialSize)) * 100, + )}% +

+
+
+ )} +
+
+ ); +} + +function CompressingPage() { + const compressionProgress = useSignal([]); + const compressionProgressPercentage = useSignal(0); + const totalCompressionProgress = useSignal>( + { + initialSize: 1, + compressedSize: 1, // Prevent division by 0. + }, + ); + const compressionFileErrors = useSignal([]); + const alertList = useComputed(() => + compressionFileErrors.value.map((file) => file.name) + ); + + const effectCount = useSignal(0); + useEffect(() => { + const files = getSelectedImages(); + const compressionOptions = getCompressionOptions(); + + const pool = new WorkerPool({ + workerScript: new URL("/worker_script.js", window.location.origin), + maxTasksPerWorker: 8, + minWorker: 1, + maxWorker: compressionOptions.hardwareConcurrency, + }); + + (async () => { + let compressedImageCount = 0; + + const result = await compress( + pool, + compressionOptions, + files, + (progress: CompressionProgress) => { + totalCompressionProgress.value.initialSize += progress.initialSize; + totalCompressionProgress.value.compressedSize += + progress.compressedSize; + + compressionProgress.value.push(progress); + compressionProgressPercentage.value = Math.ceil( + (compressedImageCount++ / (files.length - 1)) * 100, + ); + + trigger(compressionProgress, totalCompressionProgress); + }, + ); + + // Handle failed compression + result.forEach((promise, index) => { + if (promise.status === "rejected") { + const f = files[index]; + compressionFileErrors.value.push(f); + } + }); + trigger(compressionFileErrors); + })(); + + return () => pool.terminate(); + }, [effectCount.value]); + + return ( +
+ + {alertList.value.length > 0 && ( + } + title="An error ocurred while compressing the following file(s):" + list={alertList.value} + > +
+ compressionFileErrors.value = []} + > + Ignore + + { + clearImages(); + addImages(...compressionFileErrors.value); + effectCount.value += 1; + }} + > + Retry + +
+
+ )} +
+ +
+
+ ); +} + +export default CompressingPage; diff --git a/islands/home/SetupPage.tsx b/islands/home/SetupPage.tsx new file mode 100644 index 0000000..9af7cc2 --- /dev/null +++ b/islands/home/SetupPage.tsx @@ -0,0 +1,17 @@ +import GoToTopButton from "@/islands/GoToTopButton.tsx"; +import ImagesInput from "@/islands/ImagesInput.tsx"; +import ImagesCompressor from "@/islands/ImagesCompressor.tsx"; + +function SetupPage() { + return ( +
+ +
+ +
+ +
+ ); +} + +export default SetupPage; diff --git a/lib/compress.ts b/lib/compress.ts new file mode 100644 index 0000000..631b1e2 --- /dev/null +++ b/lib/compress.ts @@ -0,0 +1,84 @@ +import { BlobWriter, Uint8ArrayReader, ZipWriter } from "@zip.js/zip.js"; +import { WorkerPool } from "@/lib/worker_pool.ts"; + +export interface CompressionOptions { + quality: number; + maxWidth: number; + maxHeight: number; + zipFile: boolean; + hardwareConcurrency: number; +} + +interface WorkerCompressOptions { + quality: number; + maxWidth: number; + maxHeight: number; +} + +export interface CompressionProgress { + initialSize: number; + compressedSize: number; + file: File; +} + +export async function compress( + pool: WorkerPool, + options: CompressionOptions, + imageFiles: File[], + progressCallback: (progress: CompressionProgress) => void, +): Promise[]> { + const promises: Promise[] = []; + + const zipFileWriter = new BlobWriter(); + const zipWriter = new ZipWriter(zipFileWriter); + + for (const file of imageFiles) { + const p = (async () => { + const initialSize = file.size; + + const compressedImg = await pool.remoteProcedureCall< + [File, WorkerCompressOptions], + ArrayBuffer + >({ + name: "compress", + args: [file, options], + }, { timeout: 30_000 }); + + // Report progress. + progressCallback({ + initialSize, + compressedSize: compressedImg.byteLength, + file, + }); + + // Add compressed image to zip. + if (options.zipFile) { + const reader = new Uint8ArrayReader(new Uint8Array(compressedImg)); + return zipWriter.add(file.name, reader) as unknown as Promise; + } else { + // Download images directly. + const blob = new Blob([compressedImg], { type: file.type }); + downloadBlob(blob, file.name); + } + })(); + + promises.push(p); + } + + const results = await Promise.allSettled(promises); + if (options.zipFile) { + const blob = await zipWriter.close(); + downloadBlob(blob, "bulkcompressphotos.zip"); + } + + pool.terminate(); + + return results; +} + +function downloadBlob(blob: Blob, filename: string) { + const anchor = document.createElement("a"); + anchor.href = window.URL.createObjectURL(blob); + anchor.download = filename; + anchor.click(); +} diff --git a/lib/format_bytes.ts b/lib/format_bytes.ts new file mode 100644 index 0000000..9d1aef6 --- /dev/null +++ b/lib/format_bytes.ts @@ -0,0 +1,8 @@ +export default function formatBytes(bytes: number) { + if (bytes == 0) return "0 Bytes"; + const k = 1024, + dm = 2, + sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"], + i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; +} diff --git a/lib/rpc.ts b/lib/rpc.ts new file mode 100644 index 0000000..c3e0547 --- /dev/null +++ b/lib/rpc.ts @@ -0,0 +1,108 @@ +export interface RPC { + id: number; + name: string; + args: A[]; +} + +export type RpcResult = { + id: number; + result: R; +} | { + id: number; + error: string; +}; + +export interface RpcOptions { + timeout: number; + transfer: Transferable[]; +} + +const defaultRpcOptions: RpcOptions = { + timeout: 300000, + transfer: [], +}; + +let globalMsgId = 0; + +export type ResponseHandler = (_: RpcResult) => void; + +export class RpcWorker { + private readonly worker: Worker; + // deno-lint-ignore no-explicit-any + private readonly responseHandlers = new Map>(); + + constructor(specifier: string | URL, options?: WorkerOptions) { + this.worker = new Worker(specifier, options); + this.worker.onmessage = this.onResponse.bind(this); + this.worker.onmessageerror = (ev) => { + console.error(ev); + }; + this.worker.onerror = (ev) => { + throw new Error(ev.message); + }; + } + + terminate(): void { + this.worker.terminate(); + } + + private onResponse(event: MessageEvent>): void { + const responseId = event.data.id; + const responseHandler = this.responseHandlers.get(responseId); + + if (responseHandler === undefined) { + throw new Error( + `received unexpected response for rpc ${responseId}, no handler registered`, + ); + } + + responseHandler(event.data); + } + + async remoteProcedureCall( + rpc: { name: string; args: A[] }, + options: Partial = {}, + ): Promise { + const { timeout, transfer } = { + ...defaultRpcOptions, + ...options, + }; + + const msgId = globalMsgId++; + + return await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + // eslint-disable-next-line prefer-promise-reject-errors + reject(`rpc ${msgId} (${rpc.name}) timed out`); + }, timeout); + + this.addResponseHandler(msgId, (data: RpcResult) => { + // Clear timeout and response handler. + clearTimeout(timeoutId); + this.removeResponseHandler(msgId); + + console.debug(`rpc ${data.id} returned ${JSON.stringify(data)}`); + + if ("error" in data) { + reject(data.error); + return; + } + + resolve(data.result); + }); + + console.debug(`rpc ${msgId} called ${JSON.stringify(rpc)}`); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.worker.postMessage({ id: msgId, ...rpc }, transfer); + }); + } + + // deno-lint-ignore no-explicit-any + private addResponseHandler(id: number, handler: ResponseHandler): void { + this.responseHandlers.set(id, handler); + } + + private removeResponseHandler(id: number): void { + this.responseHandlers.delete(id); + } +} diff --git a/lib/signals.ts b/lib/signals.ts new file mode 100644 index 0000000..02ec54c --- /dev/null +++ b/lib/signals.ts @@ -0,0 +1,12 @@ +import { batch } from "@preact/signals"; + +// deno-lint-ignore no-explicit-any +export function trigger(...signals: { value: any }[]) { + batch(() => { + for (const s of signals) { + const old = s.value; + s.value = null; + s.value = old; + } + }); +} diff --git a/lib/worker_pool.ts b/lib/worker_pool.ts new file mode 100644 index 0000000..9f8b937 --- /dev/null +++ b/lib/worker_pool.ts @@ -0,0 +1,133 @@ +import { type RpcOptions, RpcWorker } from "@/lib/rpc.ts"; + +export interface WorkerPoolOptions { + workerScript: URL; + minWorker: number; + maxWorker: number; + maxTasksPerWorker: number; +} + +const defaultWorkPoolOptions: WorkerPoolOptions = { + workerScript: new URL("/worker_script.ts", import.meta.url), + minWorker: navigator.hardwareConcurrency ?? 1, + maxWorker: navigator.hardwareConcurrency ?? 4, + maxTasksPerWorker: 8, +}; + +export class WorkerPool { + private readonly workers: RpcWorker[] = []; + // deno-lint-ignore no-explicit-any + private readonly runningTasks: Array>> = []; + private readonly taskQueue: Array< + // deno-lint-ignore no-explicit-any + [(_: [RpcWorker, number]) => void, (_: any) => void] + > = []; + + private readonly options: WorkerPoolOptions; + + constructor(options: Partial = {}) { + this.options = { + ...defaultWorkPoolOptions, + ...options, + }; + } + + async remoteProcedureCall( + rpc: { name: string; args: A }, + options?: Partial, + ): Promise { + let worker = this.workers[0]; + let workerIndex = 0; + + // Find a worker. + if (this.workers.length < this.options.minWorker) { + [worker, workerIndex] = this.createWorker(); + } else { + let workerIndexWithLessTask = -1; + let workerMinTask = Number.MAX_SAFE_INTEGER; + for (let i = 0; i < this.workers.length; i++) { + if (this.runningTasks[i].size < workerMinTask) { + workerMinTask = this.runningTasks[i].size; + workerIndexWithLessTask = i; + } + } + + // All workers are full + if (workerMinTask >= this.options.maxTasksPerWorker) { + if (this.workers.length < this.options.maxWorker) { + [worker, workerIndex] = this.createWorker(); + this.runningTasks.push(new Set()); + } else { + // Wait for a new worker to be free. + console.debug( + "worker pool exhausted, waiting for a task to complete", + ); + [worker, workerIndex] = await new Promise((resolve, reject) => { + this.taskQueue.push([resolve, reject]); + }); + } + } else { + worker = this.workers[workerIndexWithLessTask]; + workerIndex = workerIndexWithLessTask; + } + } + + // Do RPC. + const promise = worker.remoteProcedureCall(rpc, options); + this.runningTasks[workerIndex].add(promise); + const result = await promise; + this.runningTasks[workerIndex].delete(promise); + + // If task in queue, resume it. + const startNextTask = this.taskQueue.shift(); + if (startNextTask !== undefined) { + startNextTask[0]([worker, workerIndex]); + } + + return result; + } + + async forEachWorkerRemoteProcedureCall( + rpc: { name: string; args: A[] }, + options?: Partial, + ): Promise | undefined>>> { + const promises = []; + for (const w of this.workers) { + promises.push(w.remoteProcedureCall(rpc, options)); + } + + return await Promise.allSettled(promises); + } + + // Reject task in waiting queue and terminate workers. + terminate(): void { + while (this.taskQueue.length > 0) { + // Reject task in queue. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.taskQueue.pop()![1]("worker terminate"); + } + + for (const w of this.workers) { + w.terminate(); + } + } + + private createWorker(): [RpcWorker, number] { + console.debug("spawning a new worker"); + const worker = new RpcWorker(this.options.workerScript, { + type: "module", + }); + + const index = this.workers.length; + + void worker.remoteProcedureCall({ + name: "setupWorker", + args: [this.workers.length], + }); + + this.runningTasks.push(new Set()); + this.workers.push(worker); + + return [worker, index]; + } +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..675f529 --- /dev/null +++ b/main.ts @@ -0,0 +1,13 @@ +/// +/// +/// +/// +/// + +import "$std/dotenv/load.ts"; + +import { start } from "$fresh/server.ts"; +import manifest from "./fresh.gen.ts"; +import config from "./fresh.config.ts"; + +await start(manifest, config); diff --git a/routes/_404.tsx b/routes/_404.tsx new file mode 100644 index 0000000..c63ae2e --- /dev/null +++ b/routes/_404.tsx @@ -0,0 +1,27 @@ +import { Head } from "$fresh/runtime.ts"; + +export default function Error404() { + return ( + <> + + 404 - Page not found + + + + ); +} diff --git a/routes/_app.tsx b/routes/_app.tsx new file mode 100644 index 0000000..c6fa37d --- /dev/null +++ b/routes/_app.tsx @@ -0,0 +1,19 @@ +import { type PageProps } from "$fresh/server.ts"; + +export default function App({ Component }: PageProps) { + return ( + + + + + Bulk compress photos + + + + + + + + + ); +} diff --git a/routes/_layout.tsx b/routes/_layout.tsx new file mode 100644 index 0000000..faa442f --- /dev/null +++ b/routes/_layout.tsx @@ -0,0 +1,11 @@ +import { PageProps } from "$fresh/server.ts"; + +function Layout({ Component }: PageProps) { + return ( + <> + + + ); +} + +export default Layout; diff --git a/routes/index.tsx b/routes/index.tsx new file mode 100644 index 0000000..431cae6 --- /dev/null +++ b/routes/index.tsx @@ -0,0 +1,14 @@ +import HomeIsland from "@/islands/Home.tsx"; + +export default function Home() { + return ( +
+
+

+ Bulk photos compressor +

+
+ +
+ ); +} diff --git a/routes/static/[...name].tsx b/routes/static/[...name].tsx new file mode 100644 index 0000000..8f66448 --- /dev/null +++ b/routes/static/[...name].tsx @@ -0,0 +1,31 @@ +import { Handlers } from "$fresh/server.ts"; +import { extname } from "$std/path/extname.ts"; + +const contentTypes = new Map([ + [".html", "text/plain"], + [".ts", "application/typescript"], + [".js", "application/javascript"], + [".tsx", "text/tsx"], + [".jsx", "text/jsx"], + [".json", "application/json"], + [".wasm", "application/wasm"], +]); + +export const handler: Handlers = { + async GET(req, _ctx) { + console.log("static"); + const reqUrl = new URL(req.url); + const filepath = "./static" + reqUrl.pathname.slice("/static".length); + + const headers = new Headers(); + + const contentType = contentTypes.get(extname(filepath)); + headers.set("Content-Type", contentType ?? "text/plain"); + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements + headers.set("Cross-Origin-Embedder-Policy", "require-corp"); + headers.set("Cross-Origin-Opener-Policy", "same-origin"); + + const file = await Deno.open(filepath); + return new Response(file.readable, { headers }); + }, +}; diff --git a/signals/home.ts b/signals/home.ts new file mode 100644 index 0000000..c732fad --- /dev/null +++ b/signals/home.ts @@ -0,0 +1,42 @@ +import { signal } from "@preact/signals"; +import { CompressionOptions } from "@/lib/compress.ts"; + +const isCompressing = signal(false); + +export function isIdle() { + return !isCompressing.value; +} + +export function startCompressing() { + isCompressing.value = true; +} + +export function stopCompressing() { + isCompressing.value = false; +} + +const defaultCompressionOptions: CompressionOptions = { + quality: 75, + maxWidth: 4096, + maxHeight: 4096, + zipFile: true, + hardwareConcurrency: (navigator.hardwareConcurrency ?? 8) / 4, +}; + +const compressionOptions = signal({ + ...defaultCompressionOptions, +}); + +export function resetCompressionOptions() { + compressionOptions.value = { ...defaultCompressionOptions }; +} + +export function getCompressionOptions() { + return { ...compressionOptions.value }; +} + +export function updateCompressionOptions( + updateCmd: Partial, +) { + compressionOptions.value = { ...compressionOptions.value, ...updateCmd }; +} diff --git a/signals/images.ts b/signals/images.ts new file mode 100644 index 0000000..2f25b2e --- /dev/null +++ b/signals/images.ts @@ -0,0 +1,34 @@ +import { signal } from "@preact/signals"; +import { trigger } from "@/lib/signals.ts"; + +const images = signal([]); + +export function addImages(...files: File[]) { + images.value.push(...files); + trigger(images); +} + +export function clearImages() { + images.value = images.value.slice(0, 0); + unselectedImages.value = {}; + trigger(images, unselectedImages); +} + +export function getImages() { + return images.value; +} + +const unselectedImages = signal>({}); + +export function getSelectedImages() { + return images.value.filter((_, index) => isImageSelected(index)); +} + +export function setSelectedImage(index: number, selected: boolean) { + unselectedImages.value[index] = !selected; + trigger(unselectedImages); +} + +export function isImageSelected(index: number) { + return unselectedImages.value[index] !== true; +} diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1cfaaa2193b0f210107a559f7421569f57a25388 GIT binary patch literal 22382 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 bck@HGWY%2idMme??%p8ZW3z(%VE+9-Ofn0d literal 0 HcmV?d00001 diff --git a/static/image-placeholder.jpg b/static/image-placeholder.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a09c840dadf544b3e4e763aeb6b89f8171269bf2 GIT binary patch literal 35179 zcmeI(do)!09suy)n3u;$WGYf73Mm}#2xACwPsby#(&KQ7Mo)%>TBAA?6;YlkB6(9$ zLh9o55*ekA^dfmylJQ99aYl)Iy7!NJ&RTcfw7z@Rcjh;H_UyI4zx`SJyMMp6HouAA zf~MP8T3aFv1|f{#h4?(Q%-q3(=4k6+Nb%aJ=Dl;17iE{G`VSNpe+GTK7scAfieh7D zVMW=cr?qGa#mC=$E8Q=UVocH373Duc=17=8AQJGxL?Tf{L|Bw0Elv^>Bgsh3kdU4$ zD?e|ptehNWfx0q9VTq!goQnRUB|m8D=;+8R8yGLuGE&#p(Hh$cMnpt}Bu1JoEc{NIqgAW1yt(}Knqj1U%wClG~2M8yOfT$+xA zFj%Y*4vWX*aDuJ(3Vug8Nxam2O&Vc_y*p9CM_OxN^ci7AvkR4Ehlaid+8)09MMP)L znk^%{P)S*3k*bcap8iq;L-XYpmR2jQZB{utIlH*7Uh~VwO`cwx>D~-K|6KvQ1A`9y z8X9)+PT5eg6l zRuYGwuSt-i*%RG;W+-Uw6P7lMK69Z`L{ZzJkL=;wAUbn_PV2(=W2t@0?7vHF|KG}N zBC*N5nh^<$5u7}%B%-2$B$c!fQ3&V*FaQR?02lxRU;qq&0WbgtzyKHk17H9QfB`T7 z2EYIq00UqE41fVJ00zJS7ytuc01SWuFaQR?02lxRU;qq&0WbgtzyKHk17H9QfB`T7 z2EYIq_-+hjd6k7drbVB(H8oHQQA#WNsc72nNHgOPXL-$sd4a}=-mp65i9NGkIepYy zW8~EUCey-`xy^!)j7~?r&AQ%soJBZAPUK#i#pw^CUQXkq)3&Cve$HM)jT_i^y8b6S zN!?;M9NvZfDI#~rUi_UZO>yp{mWMe=B`pGC3iZL(NX78v0Xypg5BmDRBayz=oKQcP zl=q$bqX$P^bWQQ(>SBkB17BMIp)h83#JY!{-5#Mmm`XGX zmu-LM7<7D}y;^(GWn6a(Bce0&O3ceuikGdsmSOB!#MKra^nkpJc~W}zSJn>cmYCwD z?M?BHjePV#^tLrqAX7F41W^?^-&m(uO(UwW2#E0K@(@R1E-rA4S9 zh$+(tLSpqF*eAKwoZO^>I5y9ygO7Idk!nX4_7_V&a?%*By0lp1OY7gW$_YwPs`_yk zg(pMyP3kJhaNV?m5`o$`=LW3V!!l6KXj2Zu8aX*xc~+&*=pw8{CQUO-4^7Ls;ZIIH z*Zo#3@Bzuh#AzVk@KfPI_2+7Uv>ctT8> zKA66qc`NxvuYCL3hkhlK#deOY{>N(Ro}l3TEBQ3T<_=OKoMs;5F*$_|r!R3ohlE7qn8MX3Baa=e-S67hXJkNk%#ZF#hQ zo_wtx(RiXJ<{3V{Pi?E^qif6zwAtQ58bM5%KEU8RIe~S&R-RLx%Rd77XdZ)kg&E6m z#9T;c=EQSb*Tlw;H%^S7R?nXJYYqvD)MXM0kyRn8JptDkBbq7CvR_4Z6m_MA$V(TJ zau;}!rZLu$^J~_b&E2iIzoN3h``o3{;MKn#zTI`ViA7v#p|!|ib8MS|gVv$}v}$3r zd-$n)WJ~(sfvo3y(>AC{^fvN5Lv-`i<9gLv-1k*$WVjZ1-fz$MlA2~^IWMhjxqmd7 z)$Y1c7WqSg{oZdD{&Inhq&o;7iIGE8ZPV$`pXKKn^t`Q#3hGQuY$}p+O^yj}jMtZ7 zXhx6r5#8TMGJP8m@laJ&%b7@5ZtY5^G0|l zFlWXO?8^bd=jRjkt@ZwIkd-%FEd>9O~`7e!O8PB!w0vW9oJHWv!o+ zy(^o0|1>dsFxN1nm$5^pWp6(kna#iLq>%+r$VYRs852uH*H0qK$dRhW(2jk6D*5p|yAxFt7RWbm&%H*)A!wu=9S`T=EDWhF1BGtlA z{&8FiTQJ6XVHx%2uI)$%^If{B9GA>udahyCYxB`wS!$6QwNo&ObL}W%47kv^iFCm% zdp&pH|G$3%$>(Z;l + + + + + \ No newline at end of file diff --git a/static/mod.mjs b/static/mod.mjs new file mode 100644 index 0000000..531b175 --- /dev/null +++ b/static/mod.mjs @@ -0,0 +1,82 @@ +// import Vips from '/static/vendor/vips-es6.js'; +// +// console.time("vips") +// const vips = await Vips(); +// console.timeEnd("vips") +// +// console.log(vips) +// +// window.compress = async (imageFile, quality) => { +// let image = vips.Image.newFromBuffer(await imageFile.arrayBuffer()) +// console.log(imageFile.type) +// +// let result = null +// switch (imageFile.type) { +// case "image/png": +// result = image.writeToBuffer('.png', { +// Q: 1, +// compression: 9, +// filter: "sub", +// palette: true, +// bitdepth: 8, +// interlace: false, +// }) +// break +// +// case "image/jpg", "image/jpeg": +// default: +// result = image.writeToBuffer('.jpg', { Q: quality }) +// } +// +// image.delete() +// return result +// } + +// import { compress as photonCompress, PhotonImage } from "/vendor/photon.js"; +// import { compress, Image } from "/vendor/img_compress.js"; +// +// window.compress = async (imageFile, quality) => { +// const array = new Uint8Array(await imageFile.arrayBuffer()); +// let image = Image.new_from_byteslice(array); +// +// image = compress(image, quality); +// +// const result = image.get_bytes(quality); +// // image.free(); +// +// return result; +// }; + +// window.compress = async (imageFile, quality) => { +// const array = new Uint8Array(await imageFile.arrayBuffer()); +// let image = PhotonImage.new_from_byteslice(array); +// +// image = photonCompress(image, quality); +// +// const result = image.get_bytes(quality); +// image.free(); +// +// return result; +// }; + +const offscreenCanvas = new OffscreenCanvas(4096, 4096); + +window.compress = async (imageFile, quality) => { + const ctx = offscreenCanvas.getContext('2d') + + ctx.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height) + + const bitmap = await createImageBitmap(imageFile) + offscreenCanvas.width = bitmap.width + offscreenCanvas.height = bitmap.height + ctx.drawImage(bitmap, 0, 0); + + const blob = await offscreenCanvas.convertToBlob({ type: imageFile.type, quality: quality / 100 }) + return blob.arrayBuffer() + + // return new Promise((resolve, reject) => { + // offscreenCanvas.toBlob(async (blob) => { + // resolve(await blob.arrayBuffer()) + // }, imageFile.type, quality / 100) + // }) +} diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..2412057 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + box-sizing: border-box; +} diff --git a/static/worker_script.js b/static/worker_script.js new file mode 100644 index 0000000..71b0554 --- /dev/null +++ b/static/worker_script.js @@ -0,0 +1,78 @@ +function workerProcedureHandler( + procedures, + postMessage, +) { + return async (event) => { + console.debug( + `rpc ${event.data.id} received: ${JSON.stringify(event.data)}`, + ); + + try { + const procedure = procedures[event.data.name]; + if (typeof procedure !== "function") { + throw new Error(`procedure "${event.data.name}" doesn't exist`); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const result = await procedure(...event.data.args); + + console.debug(`rpc ${event.data.id} done: ${JSON.stringify(result)}`); + + postMessage( + { + id: event.data.id, + result, + }, + [], + ); + } catch (err) { + console.error(`rpc ${event.data.id} error: ${err}`); + + postMessage( + { + id: event.data.id, + error: err, + }, + [], + ); + } + }; +} + +self.onmessage = workerProcedureHandler({ + setupWorker(workedId) { + console.debug("worker", workedId, "setup"); + }, + async compress(imageFile, { quality, maxWidth, maxHeight }) { + const bitmap = await createImageBitmap(imageFile); + let drawWidth = bitmap.width; + let drawHeight = bitmap.height; + + // Resize image if overflow. + const xOverflow = drawWidth - maxWidth; + const yOverflow = drawHeight - maxHeight; + if (xOverflow > 0 || yOverflow > 0) { + const resizeOnX = xOverflow > yOverflow; + if (resizeOnX) { + drawHeight = Math.round(drawHeight * (maxWidth / drawWidth)); + drawWidth = maxWidth; + } else { + drawWidth = Math.round(drawWidth * (maxHeight / drawHeight)); + drawHeight = maxHeight; + } + } + + const offscreenCanvas = new OffscreenCanvas(drawWidth, drawHeight); + const ctx = offscreenCanvas.getContext("2d"); + + ctx.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height); + ctx.drawImage(bitmap, 0, 0, drawWidth, drawHeight); + + const blob = await offscreenCanvas.convertToBlob({ + type: imageFile.type, + quality: quality / 100, + }); + + return blob.arrayBuffer(); + }, +}, self.postMessage.bind(self)); diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..5ea4cdf --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,30 @@ +import { type Config } from "tailwindcss"; + +export default { + content: [ + "{routes,islands,components}/**/*.{ts,tsx}", + ], + theme: { + extend: { + fontFamily: { + sans: [ + "Inter var", + "ui-sans-serif", + "system-ui", + "-apple-system", + "BlinkMacSystemFont", + '"Segoe UI"', + "Roboto", + '"Helvetica Neue"', + "Arial", + '"Noto Sans"', + "sans-serif", + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + '"Segoe UI Symbol"', + '"Noto Color Emoji"', + ], + }, + }, + }, +} satisfies Config;