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.
This commit is contained in:
Maya 2025-09-13 20:19:11 +03:00
parent 9467e58d3b
commit 55edaad4b4
No known key found for this signature in database
11 changed files with 356 additions and 135 deletions

View File

@ -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.",

View File

@ -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<void> {
throw new Error("Not implemented");
}
public async valid(): Promise<boolean> {
return true;
}

View File

@ -38,6 +38,8 @@ export class FFmpegConverter extends Converter {
public name = "ffmpeg";
public ready = $state(false);
private activeConversions = new Map<string, FFmpeg>();
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<void> {
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<FFmpeg> {
const ffmpeg = new FFmpeg();

View File

@ -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<string, Worker>();
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<WorkerMessage, "id">,
): Promise<OmitBetterStrict<WorkerMessage, "id">> {
const id = this.id++;
let resolved = false;
return new Promise((resolve) => {
public async cancel(input: VertFile): Promise<void> {
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<any> {
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);
});
}

View File

@ -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<string, Worker>();
constructor() {
super();
if (!browser) return;
@ -27,17 +30,33 @@ export class PandocConverter extends Converter {
})();
}
public async convert(input: VertFile, to: string): Promise<VertFile> {
public async convert(file: VertFile, to: string): Promise<VertFile> {
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<void> {
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),

View File

@ -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?
};

View File

@ -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");

View File

@ -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<any> => {
const handleMessage = async (
message: WorkerMessage,
): Promise<Partial<WorkerMessage>> => {
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<any> => {
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<any> => {
} 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<any> => {
"images.zip",
);
const zipBytes = await readToEnd(zip.getReader());
return {
type: "finished",
output: zipBytes,
@ -197,8 +218,7 @@ const handleMessage = async (message: any): Promise<any> => {
// 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<any> => {
});
});
collection.dispose();
return {
type: "finished",
output: result,
@ -223,9 +244,7 @@ const handleMessage = async (message: any): Promise<any> => {
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<any> => {
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<Uint8Array>((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<Uint8Array>((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;

View File

@ -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<any> => {
const handleMessage = async (message: WorkerMessage): Promise<any> => {
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.",

View File

@ -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();

View File

@ -184,8 +184,10 @@
</div>
<button
class="flex-shrink-0 w-8 rounded-full hover:bg-panel-alt h-full flex items-center justify-center"
onclick={() =>
(files.files = files.files.filter((_, i) => i !== index))}
onclick={async () => {
await file.cancel();
files.files = files.files.filter((_, i) => i !== index);
}}
>
<XIcon size="24" class="text-muted" />
</button>