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 0000000..a0f5988 Binary files /dev/null and b/.github/images/bmc-button.png differ 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 0000000..1cfaaa2 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/image-placeholder.jpg b/static/image-placeholder.jpg new file mode 100644 index 0000000..a09c840 Binary files /dev/null and b/static/image-placeholder.jpg differ diff --git a/static/image-placeholder.jpg.pp3 b/static/image-placeholder.jpg.pp3 new file mode 100644 index 0000000..c59cd9f --- /dev/null +++ b/static/image-placeholder.jpg.pp3 @@ -0,0 +1,752 @@ +[Version] +AppVersion= +Version=349 + +[General] +Rank=0 +ColorLabel=0 +InTrash=false + +[Exposure] +Auto=false +Clip=0.02 +Compensation=0 +Brightness=0 +Contrast=0 +Saturation=0 +Black=0 +HighlightCompr=0 +HighlightComprThreshold=0 +ShadowCompr=50 +HistogramMatching=false +CurveFromHistogramMatching=false +ClampOOG=true +CurveMode=Standard +CurveMode2=Standard +Curve=0; +Curve2=0; + +[HLRecovery] +Enabled=false +Method=Blend +Hlbl=0 + +[Retinex] +Enabled=false +Str=20 +Scal=3 +Iter=1 +Grad=1 +Grads=1 +Gam=1.3 +Slope=3 +Median=false +Neigh=80 +Offs=0 +Vart=200 +Limd=8 +highl=4 +skal=3 +complexMethod=normal +RetinexMethod=high +mapMethod=none +viewMethod=none +Retinexcolorspace=Lab +Gammaretinex=none +CDCurve=0; +MAPCurve=0; +CDHCurve=0; +LHCurve=0; +Highlights=0 +HighlightTonalWidth=80 +Shadows=0 +ShadowTonalWidth=80 +Radius=40 +TransmissionCurve=1;0;0.5;0.34999999999999998;0.34999999999999998;0.59999999999999998;0.75;0.34999999999999998;0.34999999999999998;1;0.5;0.34999999999999998;0.34999999999999998; +GainTransmissionCurve=1;0;0.10000000000000001;0.34999999999999998;0;0.25;0.25;0.34999999999999998;0.34999999999999998;0.69999999999999996;0.25;0.34999999999999998;0.34999999999999998;1;0.10000000000000001;0;0; + +[Local Contrast] +Enabled=false +Radius=80 +Amount=0.20000000000000001 +Darkness=1 +Lightness=1 + +[Channel Mixer] +Enabled=false +Red=1000;0;0; +Green=0;1000;0; +Blue=0;0;1000; + +[Black & White] +Enabled=false +Method=Desaturation +Auto=false +ComplementaryColors=true +Setting=RGB-Rel +Filter=None +MixerRed=33 +MixerOrange=33 +MixerYellow=33 +MixerGreen=33 +MixerCyan=33 +MixerBlue=33 +MixerMagenta=33 +MixerPurple=33 +GammaRed=0 +GammaGreen=0 +GammaBlue=0 +Algorithm=SP +LuminanceCurve=0; +BeforeCurveMode=Standard +AfterCurveMode=Standard +BeforeCurve=0; +AfterCurve=0; + +[Luminance Curve] +Enabled=false +Brightness=0 +Contrast=0 +Chromaticity=0 +AvoidColorShift=false +RedAndSkinTonesProtection=0 +LCredsk=true +LCurve=0; +aCurve=0; +bCurve=0; +ccCurve=0; +chCurve=0; +lhCurve=0; +hhCurve=0; +LcCurve=0; +ClCurve=0; + +[Sharpening] +Enabled=false +Contrast=20 +Method=usm +Radius=0.5 +BlurRadius=0.20000000000000001 +Amount=200 +Threshold=20;80;2000;1200; +OnlyEdges=false +EdgedetectionRadius=1.8999999999999999 +EdgeTolerance=1800 +HalocontrolEnabled=false +HalocontrolAmount=85 +DeconvRadius=0.75 +DeconvAmount=100 +DeconvDamping=0 +DeconvIterations=30 + +[Vibrance] +Enabled=false +Pastels=0 +Saturated=0 +PSThreshold=0;75; +ProtectSkins=false +AvoidColorShift=true +PastSatTog=true +SkinTonesCurve=0; + +[SharpenEdge] +Enabled=false +Passes=2 +Strength=50 +ThreeChannels=false + +[SharpenMicro] +Enabled=false +Matrix=false +Strength=20 +Contrast=20 +Uniformity=5 + +[White Balance] +Enabled=true +Setting=Camera +Temperature=6473 +Green=0.96699999999999997 +Equal=1 +TemperatureBias=0 + +[Color appearance] +Enabled=false +Degree=90 +AutoDegree=true +Degreeout=90 +AutoDegreeout=true +Surround=Average +complex=normal +ModelCat=16 +CatCat=clas +Surrsrc=Average +AdaptLum=16 +Badpixsl=0 +Model=RawT +Illum=i50 +Algorithm=JC +J-Light=0 +Q-Bright=0 +C-Chroma=0 +S-Chroma=0 +M-Chroma=0 +J-Contrast=0 +Q-Contrast=0 +H-Hue=0 +RSTProtection=0 +AdaptScene=2000 +AutoAdapscen=true +YbScene=18 +Autoybscen=true +SurrSource=false +Gamut=true +Tempout=5003 +Autotempout=true +Greenout=1 +Tempsc=5003 +Greensc=1 +Ybout=18 +Datacie=false +Tonecie=false +Presetcat02=false +CurveMode=Lightness +CurveMode2=Brightness +CurveMode3=Chroma +Curve=0; +Curve2=0; +Curve3=0; + +[Impulse Denoising] +Enabled=false +Threshold=50 + +[Defringing] +Enabled=false +Radius=2 +Threshold=13 +HueCurve=1;0.16666666699999999;0;0.34999999999999998;0.34999999999999998;0.34699999999999998;0;0.34999999999999998;0.34999999999999998;0.51366742600000004;0;0.34999999999999998;0.34999999999999998;0.66894457100000004;0;0.34999999999999998;0.34999999999999998;0.82877752459999998;0.97835991;0.34999999999999998;0.34999999999999998;0.99088838270000001;0;0.34999999999999998;0.34999999999999998; + +[Dehaze] +Enabled=false +Strength=50 +ShowDepthMap=false +Depth=25 +Saturation=50 + +[Directional Pyramid Denoising] +Enabled=false +Enhance=false +Median=false +Luma=0 +Ldetail=0 +Chroma=15 +Method=Lab +LMethod=SLI +CMethod=MAN +C2Method=AUTO +SMethod=shal +MedMethod=soft +RGBMethod=soft +MethodMed=Lonly +Redchro=0 +Bluechro=0 +Gamma=1.7 +Passes=1 +LCurve=1;0.050000000000000003;0.14999999999999999;0.34999999999999998;0.34999999999999998;0.55000000000000004;0.040000000000000001;0.34999999999999998;0.34999999999999998; +CCCurve=1;0.050000000000000003;0.5;0.34999999999999998;0.34999999999999998;0.34999999999999998;0.050000000000000003;0.34999999999999998;0.34999999999999998; + +[EPD] +Enabled=false +Strength=0.5 +Gamma=1 +EdgeStopping=1.3999999999999999 +Scale=1 +ReweightingIterates=0 + +[FattalToneMapping] +Enabled=false +Threshold=30 +Amount=20 +Anchor=50 + +[Shadows & Highlights] +Enabled=false +Highlights=0 +HighlightTonalWidth=70 +Shadows=0 +ShadowTonalWidth=30 +Radius=40 +Lab=false + +[Crop] +Enabled=true +X=0 +Y=0 +W=1920 +H=1080 +FixedRatio=true +Ratio=As Image +Orientation=As Image +Guide=Frame + +[Coarse Transformation] +Rotate=0 +HorizontalFlip=false +VerticalFlip=false + +[Common Properties for Transformations] +Method=log +AutoFill=true + +[Rotation] +Degree=0 + +[Distortion] +Amount=0 + +[LensProfile] +LcMode=none +LCPFile= +UseDistortion=true +UseVignette=true +UseCA=false +LFCameraMake= +LFCameraModel= +LFLens= + +[Perspective] +Method=simple +Horizontal=0 +Vertical=0 +CameraCropFactor=0 +CameraFocalLength=0 +CameraPitch=0 +CameraRoll=0 +CameraShiftHorizontal=0 +CameraShiftVertical=0 +CameraYaw=0 +ProjectionShiftHorizontal=0 +ProjectionPitch=0 +ProjectionRotate=0 +ProjectionShiftVertical=0 +ProjectionYaw=0 +ControlLineValues= +ControlLineTypes= + +[Gradient] +Enabled=false +Degree=0 +Feather=25 +Strength=0.59999999999999998 +CenterX=0 +CenterY=0 + +[Locallab] +Enabled=false +Selspot=0 + +[PCVignette] +Enabled=false +Strength=0.59999999999999998 +Feather=50 +Roundness=50 + +[CACorrection] +Red=0 +Blue=0 + +[Vignetting Correction] +Amount=0 +Radius=50 +Strength=1 +CenterX=0 +CenterY=0 + +[Resize] +Enabled=false +Scale=1 +AppliesTo=Cropped area +Method=Lanczos +DataSpecified=3 +Width=900 +Height=900 +LongEdge=900 +ShortEdge=900 +AllowUpscaling=false + +[PostDemosaicSharpening] +Enabled=false +Contrast=10 +AutoContrast=true +AutoRadius=true +DeconvRadius=0.75 +DeconvRadiusOffset=0 +DeconvIterCheck=true +DeconvIterations=20 + +[PostResizeSharpening] +Enabled=false +Contrast=15 +Method=rld +Radius=0.5 +Amount=200 +Threshold=20;80;2000;1200; +OnlyEdges=false +EdgedetectionRadius=1.8999999999999999 +EdgeTolerance=1800 +HalocontrolEnabled=false +HalocontrolAmount=85 +DeconvRadius=0.45000000000000001 +DeconvAmount=100 +DeconvDamping=0 +DeconvIterations=100 + +[Color Management] +InputProfile=(embedded) +ToneCurve=false +ApplyLookTable=false +ApplyBaselineExposureOffset=true +ApplyHueSatMap=true +DCPIlluminant=0 +WorkingProfile=ProPhoto +WorkingTRC=none +Will=D50 +Wprim=def +WorkingTRCGamma=2.3999999999999999 +WorkingTRCSlope=12.92 +Redx=0.73470000000000002 +Redy=0.26529999999999998 +Grex=0.15959999999999999 +Grey=0.84040000000000004 +Blux=0.036600000000000001 +Bluy=0.0001 +LabGridcieALow=0.51763000000000003 +LabGridcieBLow=-0.33582000000000001 +LabGridcieAHigh=-0.75163000000000002 +LabGridcieBHigh=-0.81799999999999995 +LabGridcieGx=-0.69164000000000003 +LabGridcieGy=-0.70909 +LabGridcieWx=-0.18964 +LabGridcieWy=-0.16636000000000001 +Preser=0 +Fbw=false +OutputProfile=RTv4_sRGB +aIntent=Relative +OutputProfileIntent=Relative +OutputBPC=true + +[Wavelet] +Enabled=false +Strength=100 +Balance=0 +Sigmafin=1 +Sigmaton=1 +Sigmacol=1 +Sigmadir=1 +Rangeab=20 +Protab=0 +Iter=0 +MaxLev=7 +TilesMethod=full +complexMethod=normal +mixMethod=mix7 +sliMethod=sli +quaMethod=cons +DaubMethod=4_ +ChoiceLevMethod=all +BackMethod=grey +LevMethod=4 +DirMethod=all +CBgreenhigh=0 +CBgreenmed=0 +CBgreenlow=0 +CBbluehigh=0 +CBbluemed=0 +CBbluelow=0 +Ballum=7 +Sigm=1 +Levden=0 +Thrden=0 +Limden=0 +Balchrom=0 +Chromfine=0 +Chromcoarse=0 +MergeL=20 +MergeC=20 +Softrad=0 +Softradend=0 +Strend=50 +Detend=0 +Thrend=0 +Expcontrast=false +Expchroma=false +Expedge=false +expbl=false +Expresid=false +Expfinal=false +Exptoning=false +Expnoise=false +Expclari=false +LabGridALow=0 +LabGridBLow=0 +LabGridAHigh=0 +LabGridBHigh=0 +Contrast1=0 +Contrast2=0 +Contrast3=0 +Contrast4=0 +Contrast5=0 +Contrast6=0 +Contrast7=0 +Contrast8=0 +Contrast9=0 +Chroma1=0 +Chroma2=0 +Chroma3=0 +Chroma4=0 +Chroma5=0 +Chroma6=0 +Chroma7=0 +Chroma8=0 +Chroma9=0 +ContExtra=0 +HSMethod=with +HLRange=50;75;100;98; +SHRange=0;2;50;25; +Edgcont=0;10;75;40; +Level0noise=0;0; +Level1noise=0;0; +Level2noise=0;0; +Level3noise=0;0; +Leveldenoise=0;0; +Levelsigm=1;1; +ThresholdHighlight=4 +ThresholdShadow=5 +Edgedetect=90 +Edgedetectthr=20 +EdgedetectthrHi=0 +Edgesensi=60 +Edgeampli=10 +ThresholdChroma=5 +CHromaMethod=link +Medgreinf=less +Ushamethod=clari +CHSLromaMethod=SL +EDMethod=CU +NPMethod=none +BAMethod=none +TMMethod=cont +ChromaLink=0 +ContrastCurve=1;0;0.25;0.34999999999999998;0.34999999999999998;0.5;0.75;0.34999999999999998;0.34999999999999998;0.90000000000000002;0;0.34999999999999998;0.34999999999999998; +blcurve=1;0;0;0;0.34999999999999998;0.5;0;0.34999999999999998;0.34999999999999998;1;0;0.34999999999999998;0.34999999999999998; +Pastlev=0;2;30;20; +Satlev=30;45;130;100; +OpacityCurveRG=1;0;0.5;0.34999999999999998;0.34999999999999998;1;0.5;0.34999999999999998;0.34999999999999998; +OpacityCurveBY=1;0;0.5;0.34999999999999998;0.34999999999999998;1;0.5;0.34999999999999998;0.34999999999999998; +wavdenoise=1;0;1;0.34999999999999998;0.34999999999999998;0.5;1;0.34999999999999998;0.34999999999999998;1;1;0.34999999999999998;0.34999999999999998; +wavdenoiseh=1;0;1;0.34999999999999998;0.34999999999999998;0.5;1;0.34999999999999998;0.34999999999999998;1;1;0.34999999999999998;0.34999999999999998; +OpacityCurveW=1;0;0.34999999999999998;0.34999999999999998;0;0.34999999999999998;0.75;0.34999999999999998;0.34999999999999998;0.59999999999999998;0.75;0.34999999999999998;0.34999999999999998;1;0.34999999999999998;0;0; +OpacityCurveWL=1;0;0.5;0.34999999999999998;0.34999999999999998;1;0.5;0.34999999999999998;0.34999999999999998; +HHcurve=0; +Wavguidcurve=0; +Wavhuecurve=0; +CHcurve=0; +WavclCurve=0; +Median=false +Medianlev=false +Linkedg=false +CBenab=false +Lipst=false +Skinprotect=0 +chrwav=0 +bluwav=1 +Hueskin=-5;25;170;120; +Edgrad=15 +Edgeffect=1 +Edgval=0 +ThrEdg=10 +AvoidColorShift=false +Showmask=false +Oldsh=true +TMr=false +Sigma=1 +Offset=1 +Lowthr=40 +ResidualcontShadow=0 +ResidualcontHighlight=0 +ThresholdResidShadow=30 +ThresholdResidHighLight=70 +Residualradius=40 +Residualchroma=0 +Residualblur=0 +Residualblurc=0 +ResidualTM=0 +ResidualEDGS=1.3999999999999999 +ResidualSCALE=1 +Residualgamma=1 +HueRangeResidual=0 +HueRange=-260;-250;-130;-140; +Contrast=0 + +[Spot removal] +Enabled=false + +[Directional Pyramid Equalizer] +Enabled=false +Gamutlab=false +cbdlMethod=bef +Mult0=1 +Mult1=1 +Mult2=1 +Mult3=1 +Mult4=1 +Mult5=1 +Threshold=0.20000000000000001 +Skinprotect=0 +Hueskin=-5;25;170;120; + +[HSV Equalizer] +Enabled=false +HCurve=0; +SCurve=0; +VCurve=0; + +[SoftLight] +Enabled=false +Strength=30 + +[Film Simulation] +Enabled=false +ClutFilename= +Strength=100 + +[RGB Curves] +Enabled=false +LumaMode=false +rCurve=0; +gCurve=0; +bCurve=0; + +[ColorToning] +Enabled=false +Method=LabRegions +Lumamode=true +Twocolor=Std +Redlow=0 +Greenlow=0 +Bluelow=0 +Satlow=0 +Balance=0 +Sathigh=0 +Redmed=0 +Greenmed=0 +Bluemed=0 +Redhigh=0 +Greenhigh=0 +Bluehigh=0 +Autosat=true +OpacityCurve=1;0;0.29999999999999999;0.34999999999999998;0;0.25;0.80000000000000004;0.34999999999999998;0.34999999999999998;0.69999999999999996;0.80000000000000004;0.34999999999999998;0.34999999999999998;1;0.29999999999999999;0;0; +ColorCurve=1;0.050000000000000003;0.62;0.25;0.25;0.58499999999999996;0.11;0.25;0.25; +SatProtectionThreshold=30 +SaturatedOpacity=80 +Strength=50 +HighlightsColorSaturation=60;80; +ShadowsColorSaturation=80;208; +ClCurve=3;0;0;0.34999999999999998;0.65000000000000002;1;1; +Cl2Curve=3;0;0;0.34999999999999998;0.65000000000000002;1;1; +LabGridALow=0 +LabGridBLow=0 +LabGridAHigh=0 +LabGridBHigh=0 +LabRegionA_1=0 +LabRegionB_1=0 +LabRegionSaturation_1=0 +LabRegionSlope_1=1 +LabRegionOffset_1=0 +LabRegionPower_1=1 +LabRegionHueMask_1=1;0.16666666699999999;1;0.34999999999999998;0.34999999999999998;0.82877752459999998;1;0.34999999999999998;0.34999999999999998; +LabRegionChromaticityMask_1=1;0;1;0.34999999999999998;0.34999999999999998;1;1;0.34999999999999998;0.34999999999999998; +LabRegionLightnessMask_1=1;0;1;0.34999999999999998;0.34999999999999998;1;1;0.34999999999999998;0.34999999999999998; +LabRegionMaskBlur_1=0 +LabRegionChannel_1=-1 +LabRegionsShowMask=-1 + +[RAW] +DarkFrame=/szeva +DarkFrameAuto=false +FlatFieldFile=/szeva +FlatFieldAutoSelect=false +FlatFieldBlurRadius=32 +FlatFieldBlurType=Area Flatfield +FlatFieldAutoClipControl=false +FlatFieldClipControl=0 +CA=false +CAAvoidColourshift=true +CAAutoIterations=2 +CARed=0 +CABlue=0 +HotPixelFilter=false +DeadPixelFilter=false +HotDeadPixelThresh=100 +PreExposure=1 + +[RAW Bayer] +Method=amaze +Border=4 +ImageNum=1 +CcSteps=0 +PreBlack0=0 +PreBlack1=0 +PreBlack2=0 +PreBlack3=0 +PreTwoGreen=true +LineDenoise=0 +LineDenoiseDirection=3 +GreenEqThreshold=0 +DCBIterations=2 +DCBEnhance=true +LMMSEIterations=2 +DualDemosaicAutoContrast=true +DualDemosaicContrast=20 +PixelShiftMotionCorrectionMethod=1 +PixelShiftEperIso=0 +PixelShiftSigma=1 +PixelShiftShowMotion=false +PixelShiftShowMotionMaskOnly=false +pixelShiftHoleFill=true +pixelShiftAverage=false +pixelShiftMedian=false +pixelShiftGreen=true +pixelShiftBlur=true +pixelShiftSmoothFactor=0.69999999999999996 +pixelShiftEqualBright=false +pixelShiftEqualBrightChannel=false +pixelShiftNonGreenCross=true +pixelShiftDemosaicMethod=amaze +PDAFLinesFilter=false + +[RAW X-Trans] +Method=3-pass (best) +DualDemosaicAutoContrast=true +DualDemosaicContrast=20 +Border=7 +CcSteps=0 +PreBlackRed=0 +PreBlackGreen=0 +PreBlackBlue=0 + +[MetaData] +Mode=0 + +[Film Negative] +Enabled=false +RedRatio=1.3600000000000001 +GreenExponent=1.5 +BlueRatio=0.85999999999999999 +ColorSpace=1 +RefInput=0;0;0; +RefOutput=2739.948486328125;2740.07568359375;2739.714111328125; + +[RAW Preprocess WB] +Mode=1 diff --git a/static/logo.svg b/static/logo.svg new file mode 100644 index 0000000..ef2fbe4 --- /dev/null +++ b/static/logo.svg @@ -0,0 +1,6 @@ + + + + + + \ 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;