From 55edaad4b451dddc62d6d2755465b09be9baf71d Mon Sep 17 00:00:00 2001 From: Maya Date: Sat, 13 Sep 2025 20:19:11 +0300 Subject: [PATCH] feat: better worker cancellation & types conversions should now *actually* stop and terminate when removed, instead of continuing to run in the background until finished. most notably, magick has been reworked to run a new worker for each conversion to follow ffmpeg and pandoc (& to allow individual cancellations) also fix uh, a lot of stuff relating to messages not following WorkerMessage type & types in general. i'm braindead right now but everything still works somehow, vertd is next. this took forever. --- messages/en.json | 1 + src/lib/converters/converter.svelte.ts | 9 + src/lib/converters/ffmpeg.svelte.ts | 27 ++- src/lib/converters/magick.svelte.ts | 219 ++++++++++++++++--------- src/lib/converters/pandoc.svelte.ts | 58 ++++++- src/lib/types/conversion-worker.ts | 22 ++- src/lib/types/file.svelte.ts | 45 ++++- src/lib/workers/magick.ts | 90 ++++++---- src/lib/workers/pandoc.ts | 10 +- src/routes/+layout.svelte | 4 +- src/routes/convert/+page.svelte | 6 +- 11 files changed, 356 insertions(+), 135 deletions(-) diff --git a/messages/en.json b/messages/en.json index bb7cca1..e43a34b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -209,6 +209,7 @@ "workers": { "errors": { "general": "Error converting {file}: {message}", + "cancel": "Error canceling conversion for {file}: {message}", "magick": "Error in Magick worker, image conversion may not work as expected.", "ffmpeg": "Error loading ffmpeg, some features may not work.", "no_audio": "No audio stream found.", diff --git a/src/lib/converters/converter.svelte.ts b/src/lib/converters/converter.svelte.ts index fcc2f6f..edde8fd 100644 --- a/src/lib/converters/converter.svelte.ts +++ b/src/lib/converters/converter.svelte.ts @@ -73,6 +73,15 @@ export class Converter { throw new Error("Not implemented"); } + /** + * Cancel the active conversion of a file. + * @param input The input file. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async cancel(input: VertFile): Promise { + throw new Error("Not implemented"); + } + public async valid(): Promise { return true; } diff --git a/src/lib/converters/ffmpeg.svelte.ts b/src/lib/converters/ffmpeg.svelte.ts index c80b0e1..1896210 100644 --- a/src/lib/converters/ffmpeg.svelte.ts +++ b/src/lib/converters/ffmpeg.svelte.ts @@ -38,6 +38,8 @@ export class FFmpegConverter extends Converter { public name = "ffmpeg"; public ready = $state(false); + private activeConversions = new Map(); + public supportedFormats = [ new FormatInfo("mp3", true, true), new FormatInfo("wav", true, true), @@ -109,6 +111,8 @@ export class FFmpegConverter extends Converter { let conversionError: string | null = null; const ffmpeg = await this.setupFFmpeg(input); + this.activeConversions.set(input.id, ffmpeg); + // listen for errors during conversion const errorListener = (l: { message: string }) => { const msg = l.message; @@ -117,7 +121,9 @@ export class FFmpegConverter extends Converter { msg.includes("is not supported") ) { const rate = Settings.instance.settings.ffmpegCustomSampleRate; - conversionError = m["workers.errors.invalid_rate"]({ rate }); + conversionError = m["workers.errors.invalid_rate"]({ + rate, + }); } else if (msg.includes("Stream map '0:a:0' matches no streams.")) { conversionError = m["workers.errors.no_audio"](); } else if ( @@ -181,6 +187,25 @@ export class FFmpegConverter extends Converter { return new VertFile(new File([outBuf], outputFileName), to); } + public async cancel(input: VertFile): Promise { + const ffmpeg = this.activeConversions.get(input.id); + if (!ffmpeg) { + log( + ["converters", this.name], + `No active conversion found for file ${input.name}`, + ); + return; + } + + log( + ["converters", this.name], + `Cancelling conversion for file ${input.name}`, + ); + + ffmpeg.terminate(); + this.activeConversions.delete(input.id); + } + private async setupFFmpeg(input: VertFile): Promise { const ffmpeg = new FFmpeg(); diff --git a/src/lib/converters/magick.svelte.ts b/src/lib/converters/magick.svelte.ts index 3846b1b..d8a6585 100644 --- a/src/lib/converters/magick.svelte.ts +++ b/src/lib/converters/magick.svelte.ts @@ -2,22 +2,19 @@ import { browser } from "$app/environment"; import { error, log } from "$lib/logger"; import { m } from "$lib/paraglide/messages"; import { addToast } from "$lib/store/ToastProvider"; -import type { OmitBetterStrict, WorkerMessage } from "$lib/types"; -import { VertFile } from "$lib/types"; +import { VertFile, type WorkerMessage } from "$lib/types"; import MagickWorker from "$lib/workers/magick?worker&url"; import { Converter, FormatInfo } from "./converter.svelte"; import { imageFormats } from "./magick-automated"; import { Settings } from "$lib/sections/settings/index.svelte"; +import magickWasm from "@imagemagick/magick-wasm/magick.wasm?url"; export class MagickConverter extends Converter { - private worker: Worker = browser - ? new Worker(MagickWorker, { - type: "module", - }) - : null!; - private id = 0; public name = "imagemagick"; public ready = $state(false); + public wasm: ArrayBuffer = null!; + + private activeConversions = new Map(); public supportedFormats = [ // manually tested formats @@ -86,23 +83,29 @@ export class MagickConverter extends Converter { super(); log(["converters", this.name], `created converter`); if (!browser) return; + this.initializeWasm(); + } - this.status = "downloading"; - - log(["converters", this.name], `loading worker @ ${MagickWorker}`); - this.worker.onmessage = (e) => { - const message: WorkerMessage = e.data; - log(["converters", this.name], `received message ${message.type}`); - if (message.type === "loaded") { - this.status = "ready"; - } else if (message.type === "error") { - error( - ["converters", this.name], - `error in worker: ${message.error}`, + private async initializeWasm() { + try { + this.status = "downloading"; + const response = await fetch(magickWasm); + if (!response.ok) { + throw new Error( + `Failed to fetch WASM: ${response.status} ${response.statusText}`, ); - addToast("error", m["workers.errors.magick"]()); } - }; + + this.wasm = await response.arrayBuffer(); + this.status = "ready"; + } catch (err) { + this.status = "error"; + error( + ["converters", this.name], + `Failed to load ImageMagick WASM: ${err}`, + ); + addToast("error", m["workers.errors.magick"]()); + } } public async convert( @@ -140,67 +143,137 @@ export class MagickConverter extends Converter { } } - // every other format handled by magick worker - const keepMetadata: boolean = - Settings.instance.settings.metadata ?? true; - log(["converters", this.name], `keep metadata: ${keepMetadata}`); - const msg = { - type: "convert", - input: { - file: input.file, - name: input.name, - to: input.to, - from: input.from, - }, - to, - compression, - keepMetadata, - } as WorkerMessage; - const res = await this.sendMessage(msg); + const worker = new Worker(MagickWorker, { + type: "module", + }); + this.activeConversions.set(input.id, worker); - if (res.type === "finished") { - log(["converters", this.name], `converted ${input.name} to ${to}`); - return new VertFile( - new File([res.output as unknown as BlobPart], input.name), - res.zip ? ".zip" : to, - ); + try { + await Promise.race([ + this.waitForMessage(worker, "ready"), + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + "Worker ready timeout after 5 seconds", + ), + ), + 5000, + ), + ), + ]); + + const loadMsg: WorkerMessage = { + type: "load", + wasm: this.wasm, + id: input.id, + }; + worker.postMessage(loadMsg); + + await Promise.race([ + this.waitForMessage(worker, "loaded"), + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + "Worker initialization timeout after 30 seconds", + ), + ), + 30000, + ), + ), + ]); + + // every other format handled by magick worker + const keepMetadata: boolean = + Settings.instance.settings.metadata ?? true; + log(["converters", this.name], `keep metadata: ${keepMetadata}`); + const convertMsg: WorkerMessage = { + type: "convert", + id: input.id, + input: { + file: input.file, + name: input.name, + from: input.from, + to: input.to, + }, + to, + compression, + keepMetadata, + }; + worker.postMessage(convertMsg); + + const res = await this.waitForMessage(worker); + if (res.type === "finished") { + log( + ["converters", this.name], + `converted ${input.name} to ${to}`, + ); + return new VertFile( + new File([res.output as unknown as BlobPart], input.name), + res.zip ? ".zip" : to, + ); + } + + if (res.type === "error") { + throw new Error(res.error); + } + + throw new Error("Unknown message type"); + } finally { + this.activeConversions.delete(input.id); + worker.terminate(); } - - if (res.type === "error") { - throw new Error(res.error); - } - - throw new Error("Unknown message type"); } - private sendMessage( - message: OmitBetterStrict, - ): Promise> { - const id = this.id++; - let resolved = false; - return new Promise((resolve) => { + public async cancel(input: VertFile): Promise { + const worker = this.activeConversions.get(input.id); + if (!worker) { + log( + ["converters", this.name], + `No active conversion found for file ${input.name}`, + ); + return; + } + + log( + ["converters", this.name], + `Cancelling conversion for file ${input.name}`, + ); + + worker.terminate(); + this.activeConversions.delete(input.id); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private waitForMessage(worker: Worker, type?: string): Promise { + return new Promise((resolve, reject) => { const onMessage = (e: MessageEvent) => { - if (e.data.id === id) { - this.worker.removeEventListener("message", onMessage); + if (type && e.data.type === type) { + worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); resolve(e.data); - resolved = true; + } else if (!type) { + worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); + resolve(e.data); + } else if (e.data.type === "error") { + worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); + reject(new Error(e.data.error)); } }; - setTimeout(() => { - if (!resolved) { - this.worker.removeEventListener("message", onMessage); - throw new Error("Timeout"); - } - }, 60000); + const onError = (e: ErrorEvent) => { + worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); + reject(new Error(`Worker error: ${e.message}`)); + }; - this.worker.addEventListener("message", onMessage); - const msg = { ...message, id, worker: null }; - try { - this.worker.postMessage(msg); - } catch (e) { - error(["converters", this.name], e); - } + worker.addEventListener("message", onMessage); + worker.addEventListener("error", onError); }); } diff --git a/src/lib/converters/pandoc.svelte.ts b/src/lib/converters/pandoc.svelte.ts index b4c9a7b..c5fbd3c 100644 --- a/src/lib/converters/pandoc.svelte.ts +++ b/src/lib/converters/pandoc.svelte.ts @@ -1,14 +1,17 @@ -import { VertFile } from "$lib/types"; +import { VertFile, type WorkerMessage } from "$lib/types"; import { Converter, FormatInfo } from "./converter.svelte"; import { browser } from "$app/environment"; import PandocWorker from "$lib/workers/pandoc?worker&url"; import { addToast } from "$lib/store/ToastProvider"; +import { log } from "$lib/logger"; export class PandocConverter extends Converter { public name = "pandoc"; public ready = $state(false); public wasm: ArrayBuffer = null!; + private activeConversions = new Map(); + constructor() { super(); if (!browser) return; @@ -27,17 +30,33 @@ export class PandocConverter extends Converter { })(); } - public async convert(input: VertFile, to: string): Promise { + public async convert(file: VertFile, to: string): Promise { const worker = new Worker(PandocWorker, { type: "module", }); - worker.postMessage({ type: "load", wasm: this.wasm }); + + this.activeConversions.set(file.id, worker); + + const loadMsg: WorkerMessage = { + type: "load", + wasm: this.wasm, + id: file.id, + }; + worker.postMessage(loadMsg); await waitForMessage(worker, "loaded"); - worker.postMessage({ + const convertMsg: WorkerMessage = { type: "convert", to, - file: input.file, - }); + input: { + file: file.file, + name: file.name, + from: file.from, + to, + }, + compression: null, + id: file.id, + }; + worker.postMessage(convertMsg); const result = await waitForMessage(worker); if (result.type === "error") { worker.terminate(); @@ -46,7 +65,7 @@ export class PandocConverter extends Converter { switch (result.errorKind) { case "PandocUnknownReaderError": { throw new Error( - `${input.from} is not a supported input format for documents.`, + `${file.from} is not a supported input format for documents.`, ); } @@ -73,14 +92,35 @@ export class PandocConverter extends Converter { else throw new Error(result.error); } } - worker.terminate(); + if (!to.startsWith(".")) to = `.${to}`; + this.activeConversions.delete(file.id); + worker.terminate(); return new VertFile( - new File([result.output], input.name), + new File([result.output], file.name), result.isZip ? ".zip" : to, ); } + public async cancel(input: VertFile): Promise { + const worker = this.activeConversions.get(input.id); + if (!worker) { + log( + ["converters", this.name], + `No active conversion found for file ${input.name}`, + ); + return; + } + + log( + ["converters", this.name], + `Cancelling conversion for file ${input.name}`, + ); + + worker.terminate(); + this.activeConversions.delete(input.id); + } + public supportedFormats = [ new FormatInfo("docx", true, true), new FormatInfo("doc", true, true), diff --git a/src/lib/types/conversion-worker.ts b/src/lib/types/conversion-worker.ts index 3a9d923..3576960 100644 --- a/src/lib/types/conversion-worker.ts +++ b/src/lib/types/conversion-worker.ts @@ -2,7 +2,12 @@ import { VertFile } from "./file.svelte"; interface ConvertMessage { type: "convert"; - input: VertFile; + input: { + file: File; + name: string; + from: string; + to: string; + } | VertFile; to: string; compression: number | null; keepMetadata?: boolean; @@ -10,14 +15,23 @@ interface ConvertMessage { interface FinishedMessage { type: "finished"; - output: ArrayBufferLike; + output: ArrayBufferLike | Uint8Array; zip?: boolean; } +interface LoadMessage { + type: "load"; + wasm: ArrayBuffer; +} + interface LoadedMessage { type: "loaded"; } +interface ReadyMessage { + type: "ready"; +} + interface ErrorMessage { type: "error"; error: string; @@ -26,8 +40,10 @@ interface ErrorMessage { export type WorkerMessage = ( | ConvertMessage | FinishedMessage + | LoadMessage | LoadedMessage + | ReadyMessage | ErrorMessage ) & { - id: number; + id: string; // unused? rn just using file id, probably meant to be incrementing w/ every message posted? }; diff --git a/src/lib/types/file.svelte.ts b/src/lib/types/file.svelte.ts index 339f0c6..cdca535 100644 --- a/src/lib/types/file.svelte.ts +++ b/src/lib/types/file.svelte.ts @@ -25,6 +25,8 @@ export class VertFile { public processing = $state(false); + public cancelled = $state(false); + public converters: Converter[] = []; public findConverters(supportedFormats: string[] = [this.from]) { @@ -84,26 +86,51 @@ export class VertFile { this.result = null; this.progress = 0; this.processing = true; + this.cancelled = false; let res; try { res = await converter.convert(this, this.to, ...args); this.result = res; } catch (err) { - const castedErr = err as Error; - error(["files"], castedErr.message); - addToast( - "error", - m["workers.errors.general"]({ - file: this.file.name, - message: castedErr.message || castedErr, - }), - ); + if (!this.cancelled) { + const castedErr = err as Error; + error(["files"], castedErr.message); + addToast( + "error", + m["workers.errors.general"]({ + file: this.file.name, + message: castedErr.message || castedErr, + }), + ); + } this.result = null; } this.processing = false; return res; } + public async cancel() { + if (!this.processing) return; + const converter = this.findConverter(); + if (!converter) throw new Error("No converter found"); + this.cancelled = true; + try { + await converter.cancel(this); + this.processing = false; + this.result = null; + } catch (err) { + const castedErr = err as Error; + error(["files"], castedErr.message); + addToast( + "error", + m["workers.errors.cancel"]({ + file: this.file.name, + message: castedErr.message || castedErr, + }), + ); + } + } + public async download() { if (!this.result) throw new Error("No result found"); diff --git a/src/lib/workers/magick.ts b/src/lib/workers/magick.ts index 0c2186d..4ed26bc 100644 --- a/src/lib/workers/magick.ts +++ b/src/lib/workers/magick.ts @@ -7,38 +7,58 @@ import { type IMagickImage, } from "@imagemagick/magick-wasm"; import { makeZip } from "client-zip"; -import wasm from "@imagemagick/magick-wasm/magick.wasm?url"; import { parseAni } from "$lib/parse/ani"; import { parseIcns } from "vert-wasm"; +import type { WorkerMessage } from "$lib/types"; -const magickPromise = initializeImageMagick(new URL(wasm, import.meta.url)); +let magickInitialized = false; -magickPromise - .then(() => { - postMessage({ type: "loaded" }); - }) - .catch((error) => { - postMessage({ type: "error", error }); - }); +self.postMessage({ type: "ready", id: "0" }); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const handleMessage = async (message: any): Promise => { +const handleMessage = async ( + message: WorkerMessage, +): Promise> => { switch (message.type) { + case "load": { + try { + if (!message.wasm || !(message.wasm instanceof ArrayBuffer)) { + throw new Error( + `Invalid WASM data: ${typeof message.wasm}`, + ); + } + + const wasmBytes = new Uint8Array(message.wasm); + + await initializeImageMagick(wasmBytes); + magickInitialized = true; + return { type: "loaded" }; + } catch (error) { + return { + type: "error", + error: `error loading magick-wasm: ${(error as Error).message}`, + }; + } + } case "convert": { - const compression: number | undefined = message.compression; + if (!magickInitialized) { + return { type: "error", error: "magick-wasm not initialized" }; + } + + const compression: number | undefined = + message.compression ?? undefined; const keepMetadata: boolean = message.keepMetadata ?? true; if (!message.to.startsWith(".")) message.to = `.${message.to}`; message.to = message.to.toLowerCase(); if (message.to === ".jfif") message.to = ".jpeg"; - if (message.input.from === ".jfif") message.input.from = ".jpeg"; - if (message.input.from === ".fit") message.input.from = ".fits"; + + let from = message.input.from; + if (from === ".jfif") from = ".jpeg"; + if (from === ".fit") from = ".fits"; const buffer = await message.input.file.arrayBuffer(); - // only wait when we need to - await magickPromise; // special ico handling to split them all into separate images - if (message.input.from === ".ico") { + if (from === ".ico") { const imgs = MagickImageCollection.create(); while (true) { @@ -98,7 +118,7 @@ const handleMessage = async (message: any): Promise => { output: zipBytes, zip: true, }; - } else if (message.input.from === ".ani") { + } else if (from === ".ani") { console.log("Parsing ANI file"); try { const parsedAni = parseAni(new Uint8Array(buffer)); @@ -136,7 +156,7 @@ const handleMessage = async (message: any): Promise => { } catch (e) { console.error(e); } - } else if (message.input.from === ".icns") { + } else if (from === ".icns") { const icns: Uint8Array[] = parseIcns(new Uint8Array(buffer)); if (typeof icns === "string") { return { @@ -187,6 +207,7 @@ const handleMessage = async (message: any): Promise => { "images.zip", ); const zipBytes = await readToEnd(zip.getReader()); + return { type: "finished", output: zipBytes, @@ -197,8 +218,7 @@ const handleMessage = async (message: any): Promise => { // build frames of animated formats (webp/gif) // APNG does not work on magick-wasm since it needs ffmpeg built-in (not in magick-wasm) - handle in ffmpeg if ( - (message.input.from === ".webp" || - message.input.from === ".gif") && + (from === ".webp" || from === ".gif") && (message.to === ".gif" || message.to === ".webp") ) { const collection = MagickImageCollection.create( @@ -214,6 +234,7 @@ const handleMessage = async (message: any): Promise => { }); }); collection.dispose(); + return { type: "finished", output: result, @@ -223,9 +244,7 @@ const handleMessage = async (message: any): Promise => { const img = MagickImage.create( new Uint8Array(buffer), new MagickReadSettings({ - format: message.input.from - .slice(1) - .toUpperCase() as MagickFormat, + format: from.slice(1).toUpperCase() as MagickFormat, }), ); @@ -241,6 +260,11 @@ const handleMessage = async (message: any): Promise => { output: converted, }; } + default: + return { + type: "error", + error: `Unknown message type: ${message.type}`, + }; } }; @@ -269,14 +293,18 @@ const magickConvert = async ( let fmt = to.slice(1).toUpperCase(); if (fmt === "JFIF") fmt = "JPEG"; - const result = await new Promise((resolve) => { - // magick-wasm automatically clamps (https://github.com/dlemstra/magick-wasm/blob/76fc6f2b0c0497d2ddc251bbf6174b4dc92ac3ea/src/magick-image.ts#L2480) - if (compression) img.quality = compression; - if (!keepMetadata) img.strip(); + const result = await new Promise((resolve, reject) => { + try { + // magick-wasm automatically clamps (https://github.com/dlemstra/magick-wasm/blob/76fc6f2b0c0497d2ddc251bbf6174b4dc92ac3ea/src/magick-image.ts#L2480) + if (compression) img.quality = compression; + if (!keepMetadata) img.strip(); - img.write(fmt as unknown as MagickFormat, (o: Uint8Array) => { - resolve(structuredClone(o)); - }); + img.write(fmt as unknown as MagickFormat, (o: Uint8Array) => { + resolve(structuredClone(o)); + }); + } catch (error) { + reject(error); + } }); return result; diff --git a/src/lib/workers/pandoc.ts b/src/lib/workers/pandoc.ts index 0377252..17772a5 100644 --- a/src/lib/workers/pandoc.ts +++ b/src/lib/workers/pandoc.ts @@ -1,3 +1,4 @@ +import type { WorkerMessage } from "$lib/types"; import * as wasiShim from "@bjorn3/browser_wasi_shim"; import * as zip from "client-zip"; @@ -37,18 +38,19 @@ type Format = | ".markdown"; // eslint-disable-next-line @typescript-eslint/no-explicit-any -const handleMessage = async (message: any): Promise => { +const handleMessage = async (message: WorkerMessage): Promise => { switch (message.type) { case "load": { wasm = message.wasm; - postMessage({ type: "loaded" }); + postMessage({ type: "loaded", id: "0" }); break; } case "convert": { try { - // eslint-disable-next-line prefer-const - let { to, file }: { to: Format; file: File } = message; + const { to: ext, input } = message; + const file = input.file as File; + const to = ext as Format; if (to === ".rtf") { throw new Error( "Converting into RTF is currently not supported.", diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b98c056..ad75bbf 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -63,7 +63,7 @@ const clipboardData = e.clipboardData; if (!clipboardData || !clipboardData.files.length) return; e.preventDefault(); - + if (page.url.pathname !== "/jpegify/") { const oldLength = files.files.length; files.add(clipboardData.files); @@ -73,8 +73,6 @@ } }; - - onMount(() => { initAnimStores(); diff --git a/src/routes/convert/+page.svelte b/src/routes/convert/+page.svelte index 296ee30..4af46a9 100644 --- a/src/routes/convert/+page.svelte +++ b/src/routes/convert/+page.svelte @@ -184,8 +184,10 @@