mirror of https://github.com/VERT-sh/VERT.git
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:
parent
9467e58d3b
commit
55edaad4b4
|
|
@ -209,6 +209,7 @@
|
||||||
"workers": {
|
"workers": {
|
||||||
"errors": {
|
"errors": {
|
||||||
"general": "Error converting {file}: {message}",
|
"general": "Error converting {file}: {message}",
|
||||||
|
"cancel": "Error canceling conversion for {file}: {message}",
|
||||||
"magick": "Error in Magick worker, image conversion may not work as expected.",
|
"magick": "Error in Magick worker, image conversion may not work as expected.",
|
||||||
"ffmpeg": "Error loading ffmpeg, some features may not work.",
|
"ffmpeg": "Error loading ffmpeg, some features may not work.",
|
||||||
"no_audio": "No audio stream found.",
|
"no_audio": "No audio stream found.",
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,15 @@ export class Converter {
|
||||||
throw new Error("Not implemented");
|
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> {
|
public async valid(): Promise<boolean> {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export class FFmpegConverter extends Converter {
|
||||||
public name = "ffmpeg";
|
public name = "ffmpeg";
|
||||||
public ready = $state(false);
|
public ready = $state(false);
|
||||||
|
|
||||||
|
private activeConversions = new Map<string, FFmpeg>();
|
||||||
|
|
||||||
public supportedFormats = [
|
public supportedFormats = [
|
||||||
new FormatInfo("mp3", true, true),
|
new FormatInfo("mp3", true, true),
|
||||||
new FormatInfo("wav", true, true),
|
new FormatInfo("wav", true, true),
|
||||||
|
|
@ -109,6 +111,8 @@ export class FFmpegConverter extends Converter {
|
||||||
let conversionError: string | null = null;
|
let conversionError: string | null = null;
|
||||||
const ffmpeg = await this.setupFFmpeg(input);
|
const ffmpeg = await this.setupFFmpeg(input);
|
||||||
|
|
||||||
|
this.activeConversions.set(input.id, ffmpeg);
|
||||||
|
|
||||||
// listen for errors during conversion
|
// listen for errors during conversion
|
||||||
const errorListener = (l: { message: string }) => {
|
const errorListener = (l: { message: string }) => {
|
||||||
const msg = l.message;
|
const msg = l.message;
|
||||||
|
|
@ -117,7 +121,9 @@ export class FFmpegConverter extends Converter {
|
||||||
msg.includes("is not supported")
|
msg.includes("is not supported")
|
||||||
) {
|
) {
|
||||||
const rate = Settings.instance.settings.ffmpegCustomSampleRate;
|
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.")) {
|
} else if (msg.includes("Stream map '0:a:0' matches no streams.")) {
|
||||||
conversionError = m["workers.errors.no_audio"]();
|
conversionError = m["workers.errors.no_audio"]();
|
||||||
} else if (
|
} else if (
|
||||||
|
|
@ -181,6 +187,25 @@ export class FFmpegConverter extends Converter {
|
||||||
return new VertFile(new File([outBuf], outputFileName), to);
|
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> {
|
private async setupFFmpeg(input: VertFile): Promise<FFmpeg> {
|
||||||
const ffmpeg = new FFmpeg();
|
const ffmpeg = new FFmpeg();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,19 @@ import { browser } from "$app/environment";
|
||||||
import { error, log } from "$lib/logger";
|
import { error, log } from "$lib/logger";
|
||||||
import { m } from "$lib/paraglide/messages";
|
import { m } from "$lib/paraglide/messages";
|
||||||
import { addToast } from "$lib/store/ToastProvider";
|
import { addToast } from "$lib/store/ToastProvider";
|
||||||
import type { OmitBetterStrict, WorkerMessage } from "$lib/types";
|
import { VertFile, type WorkerMessage } from "$lib/types";
|
||||||
import { VertFile } from "$lib/types";
|
|
||||||
import MagickWorker from "$lib/workers/magick?worker&url";
|
import MagickWorker from "$lib/workers/magick?worker&url";
|
||||||
import { Converter, FormatInfo } from "./converter.svelte";
|
import { Converter, FormatInfo } from "./converter.svelte";
|
||||||
import { imageFormats } from "./magick-automated";
|
import { imageFormats } from "./magick-automated";
|
||||||
import { Settings } from "$lib/sections/settings/index.svelte";
|
import { Settings } from "$lib/sections/settings/index.svelte";
|
||||||
|
import magickWasm from "@imagemagick/magick-wasm/magick.wasm?url";
|
||||||
|
|
||||||
export class MagickConverter extends Converter {
|
export class MagickConverter extends Converter {
|
||||||
private worker: Worker = browser
|
|
||||||
? new Worker(MagickWorker, {
|
|
||||||
type: "module",
|
|
||||||
})
|
|
||||||
: null!;
|
|
||||||
private id = 0;
|
|
||||||
public name = "imagemagick";
|
public name = "imagemagick";
|
||||||
public ready = $state(false);
|
public ready = $state(false);
|
||||||
|
public wasm: ArrayBuffer = null!;
|
||||||
|
|
||||||
|
private activeConversions = new Map<string, Worker>();
|
||||||
|
|
||||||
public supportedFormats = [
|
public supportedFormats = [
|
||||||
// manually tested formats
|
// manually tested formats
|
||||||
|
|
@ -86,23 +83,29 @@ export class MagickConverter extends Converter {
|
||||||
super();
|
super();
|
||||||
log(["converters", this.name], `created converter`);
|
log(["converters", this.name], `created converter`);
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
this.initializeWasm();
|
||||||
|
}
|
||||||
|
|
||||||
this.status = "downloading";
|
private async initializeWasm() {
|
||||||
|
try {
|
||||||
log(["converters", this.name], `loading worker @ ${MagickWorker}`);
|
this.status = "downloading";
|
||||||
this.worker.onmessage = (e) => {
|
const response = await fetch(magickWasm);
|
||||||
const message: WorkerMessage = e.data;
|
if (!response.ok) {
|
||||||
log(["converters", this.name], `received message ${message.type}`);
|
throw new Error(
|
||||||
if (message.type === "loaded") {
|
`Failed to fetch WASM: ${response.status} ${response.statusText}`,
|
||||||
this.status = "ready";
|
|
||||||
} else if (message.type === "error") {
|
|
||||||
error(
|
|
||||||
["converters", this.name],
|
|
||||||
`error in worker: ${message.error}`,
|
|
||||||
);
|
);
|
||||||
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(
|
public async convert(
|
||||||
|
|
@ -140,67 +143,137 @@ export class MagickConverter extends Converter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// every other format handled by magick worker
|
const worker = new Worker(MagickWorker, {
|
||||||
const keepMetadata: boolean =
|
type: "module",
|
||||||
Settings.instance.settings.metadata ?? true;
|
});
|
||||||
log(["converters", this.name], `keep metadata: ${keepMetadata}`);
|
this.activeConversions.set(input.id, worker);
|
||||||
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);
|
|
||||||
|
|
||||||
if (res.type === "finished") {
|
try {
|
||||||
log(["converters", this.name], `converted ${input.name} to ${to}`);
|
await Promise.race([
|
||||||
return new VertFile(
|
this.waitForMessage(worker, "ready"),
|
||||||
new File([res.output as unknown as BlobPart], input.name),
|
new Promise((_, reject) =>
|
||||||
res.zip ? ".zip" : to,
|
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(
|
public async cancel(input: VertFile): Promise<void> {
|
||||||
message: OmitBetterStrict<WorkerMessage, "id">,
|
const worker = this.activeConversions.get(input.id);
|
||||||
): Promise<OmitBetterStrict<WorkerMessage, "id">> {
|
if (!worker) {
|
||||||
const id = this.id++;
|
log(
|
||||||
let resolved = false;
|
["converters", this.name],
|
||||||
return new Promise((resolve) => {
|
`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) => {
|
const onMessage = (e: MessageEvent) => {
|
||||||
if (e.data.id === id) {
|
if (type && e.data.type === type) {
|
||||||
this.worker.removeEventListener("message", onMessage);
|
worker.removeEventListener("message", onMessage);
|
||||||
|
worker.removeEventListener("error", onError);
|
||||||
resolve(e.data);
|
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(() => {
|
const onError = (e: ErrorEvent) => {
|
||||||
if (!resolved) {
|
worker.removeEventListener("message", onMessage);
|
||||||
this.worker.removeEventListener("message", onMessage);
|
worker.removeEventListener("error", onError);
|
||||||
throw new Error("Timeout");
|
reject(new Error(`Worker error: ${e.message}`));
|
||||||
}
|
};
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
this.worker.addEventListener("message", onMessage);
|
worker.addEventListener("message", onMessage);
|
||||||
const msg = { ...message, id, worker: null };
|
worker.addEventListener("error", onError);
|
||||||
try {
|
|
||||||
this.worker.postMessage(msg);
|
|
||||||
} catch (e) {
|
|
||||||
error(["converters", this.name], e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { VertFile } from "$lib/types";
|
import { VertFile, type WorkerMessage } from "$lib/types";
|
||||||
import { Converter, FormatInfo } from "./converter.svelte";
|
import { Converter, FormatInfo } from "./converter.svelte";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import PandocWorker from "$lib/workers/pandoc?worker&url";
|
import PandocWorker from "$lib/workers/pandoc?worker&url";
|
||||||
import { addToast } from "$lib/store/ToastProvider";
|
import { addToast } from "$lib/store/ToastProvider";
|
||||||
|
import { log } from "$lib/logger";
|
||||||
|
|
||||||
export class PandocConverter extends Converter {
|
export class PandocConverter extends Converter {
|
||||||
public name = "pandoc";
|
public name = "pandoc";
|
||||||
public ready = $state(false);
|
public ready = $state(false);
|
||||||
public wasm: ArrayBuffer = null!;
|
public wasm: ArrayBuffer = null!;
|
||||||
|
|
||||||
|
private activeConversions = new Map<string, Worker>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
if (!browser) return;
|
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, {
|
const worker = new Worker(PandocWorker, {
|
||||||
type: "module",
|
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");
|
await waitForMessage(worker, "loaded");
|
||||||
worker.postMessage({
|
const convertMsg: WorkerMessage = {
|
||||||
type: "convert",
|
type: "convert",
|
||||||
to,
|
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);
|
const result = await waitForMessage(worker);
|
||||||
if (result.type === "error") {
|
if (result.type === "error") {
|
||||||
worker.terminate();
|
worker.terminate();
|
||||||
|
|
@ -46,7 +65,7 @@ export class PandocConverter extends Converter {
|
||||||
switch (result.errorKind) {
|
switch (result.errorKind) {
|
||||||
case "PandocUnknownReaderError": {
|
case "PandocUnknownReaderError": {
|
||||||
throw new Error(
|
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);
|
else throw new Error(result.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
worker.terminate();
|
|
||||||
if (!to.startsWith(".")) to = `.${to}`;
|
if (!to.startsWith(".")) to = `.${to}`;
|
||||||
|
this.activeConversions.delete(file.id);
|
||||||
|
worker.terminate();
|
||||||
return new VertFile(
|
return new VertFile(
|
||||||
new File([result.output], input.name),
|
new File([result.output], file.name),
|
||||||
result.isZip ? ".zip" : to,
|
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 = [
|
public supportedFormats = [
|
||||||
new FormatInfo("docx", true, true),
|
new FormatInfo("docx", true, true),
|
||||||
new FormatInfo("doc", true, true),
|
new FormatInfo("doc", true, true),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@ import { VertFile } from "./file.svelte";
|
||||||
|
|
||||||
interface ConvertMessage {
|
interface ConvertMessage {
|
||||||
type: "convert";
|
type: "convert";
|
||||||
input: VertFile;
|
input: {
|
||||||
|
file: File;
|
||||||
|
name: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
} | VertFile;
|
||||||
to: string;
|
to: string;
|
||||||
compression: number | null;
|
compression: number | null;
|
||||||
keepMetadata?: boolean;
|
keepMetadata?: boolean;
|
||||||
|
|
@ -10,14 +15,23 @@ interface ConvertMessage {
|
||||||
|
|
||||||
interface FinishedMessage {
|
interface FinishedMessage {
|
||||||
type: "finished";
|
type: "finished";
|
||||||
output: ArrayBufferLike;
|
output: ArrayBufferLike | Uint8Array;
|
||||||
zip?: boolean;
|
zip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LoadMessage {
|
||||||
|
type: "load";
|
||||||
|
wasm: ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
interface LoadedMessage {
|
interface LoadedMessage {
|
||||||
type: "loaded";
|
type: "loaded";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReadyMessage {
|
||||||
|
type: "ready";
|
||||||
|
}
|
||||||
|
|
||||||
interface ErrorMessage {
|
interface ErrorMessage {
|
||||||
type: "error";
|
type: "error";
|
||||||
error: string;
|
error: string;
|
||||||
|
|
@ -26,8 +40,10 @@ interface ErrorMessage {
|
||||||
export type WorkerMessage = (
|
export type WorkerMessage = (
|
||||||
| ConvertMessage
|
| ConvertMessage
|
||||||
| FinishedMessage
|
| FinishedMessage
|
||||||
|
| LoadMessage
|
||||||
| LoadedMessage
|
| LoadedMessage
|
||||||
|
| ReadyMessage
|
||||||
| ErrorMessage
|
| ErrorMessage
|
||||||
) & {
|
) & {
|
||||||
id: number;
|
id: string; // unused? rn just using file id, probably meant to be incrementing w/ every message posted?
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export class VertFile {
|
||||||
|
|
||||||
public processing = $state(false);
|
public processing = $state(false);
|
||||||
|
|
||||||
|
public cancelled = $state(false);
|
||||||
|
|
||||||
public converters: Converter[] = [];
|
public converters: Converter[] = [];
|
||||||
|
|
||||||
public findConverters(supportedFormats: string[] = [this.from]) {
|
public findConverters(supportedFormats: string[] = [this.from]) {
|
||||||
|
|
@ -84,26 +86,51 @@ export class VertFile {
|
||||||
this.result = null;
|
this.result = null;
|
||||||
this.progress = 0;
|
this.progress = 0;
|
||||||
this.processing = true;
|
this.processing = true;
|
||||||
|
this.cancelled = false;
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await converter.convert(this, this.to, ...args);
|
res = await converter.convert(this, this.to, ...args);
|
||||||
this.result = res;
|
this.result = res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const castedErr = err as Error;
|
if (!this.cancelled) {
|
||||||
error(["files"], castedErr.message);
|
const castedErr = err as Error;
|
||||||
addToast(
|
error(["files"], castedErr.message);
|
||||||
"error",
|
addToast(
|
||||||
m["workers.errors.general"]({
|
"error",
|
||||||
file: this.file.name,
|
m["workers.errors.general"]({
|
||||||
message: castedErr.message || castedErr,
|
file: this.file.name,
|
||||||
}),
|
message: castedErr.message || castedErr,
|
||||||
);
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
this.result = null;
|
this.result = null;
|
||||||
}
|
}
|
||||||
this.processing = false;
|
this.processing = false;
|
||||||
return res;
|
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() {
|
public async download() {
|
||||||
if (!this.result) throw new Error("No result found");
|
if (!this.result) throw new Error("No result found");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,38 +7,58 @@ import {
|
||||||
type IMagickImage,
|
type IMagickImage,
|
||||||
} from "@imagemagick/magick-wasm";
|
} from "@imagemagick/magick-wasm";
|
||||||
import { makeZip } from "client-zip";
|
import { makeZip } from "client-zip";
|
||||||
import wasm from "@imagemagick/magick-wasm/magick.wasm?url";
|
|
||||||
import { parseAni } from "$lib/parse/ani";
|
import { parseAni } from "$lib/parse/ani";
|
||||||
import { parseIcns } from "vert-wasm";
|
import { parseIcns } from "vert-wasm";
|
||||||
|
import type { WorkerMessage } from "$lib/types";
|
||||||
|
|
||||||
const magickPromise = initializeImageMagick(new URL(wasm, import.meta.url));
|
let magickInitialized = false;
|
||||||
|
|
||||||
magickPromise
|
self.postMessage({ type: "ready", id: "0" });
|
||||||
.then(() => {
|
|
||||||
postMessage({ type: "loaded" });
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
postMessage({ type: "error", error });
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const handleMessage = async (
|
||||||
const handleMessage = async (message: any): Promise<any> => {
|
message: WorkerMessage,
|
||||||
|
): Promise<Partial<WorkerMessage>> => {
|
||||||
switch (message.type) {
|
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": {
|
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;
|
const keepMetadata: boolean = message.keepMetadata ?? true;
|
||||||
if (!message.to.startsWith(".")) message.to = `.${message.to}`;
|
if (!message.to.startsWith(".")) message.to = `.${message.to}`;
|
||||||
message.to = message.to.toLowerCase();
|
message.to = message.to.toLowerCase();
|
||||||
if (message.to === ".jfif") message.to = ".jpeg";
|
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();
|
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
|
// special ico handling to split them all into separate images
|
||||||
if (message.input.from === ".ico") {
|
if (from === ".ico") {
|
||||||
const imgs = MagickImageCollection.create();
|
const imgs = MagickImageCollection.create();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -98,7 +118,7 @@ const handleMessage = async (message: any): Promise<any> => {
|
||||||
output: zipBytes,
|
output: zipBytes,
|
||||||
zip: true,
|
zip: true,
|
||||||
};
|
};
|
||||||
} else if (message.input.from === ".ani") {
|
} else if (from === ".ani") {
|
||||||
console.log("Parsing ANI file");
|
console.log("Parsing ANI file");
|
||||||
try {
|
try {
|
||||||
const parsedAni = parseAni(new Uint8Array(buffer));
|
const parsedAni = parseAni(new Uint8Array(buffer));
|
||||||
|
|
@ -136,7 +156,7 @@ const handleMessage = async (message: any): Promise<any> => {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
} else if (message.input.from === ".icns") {
|
} else if (from === ".icns") {
|
||||||
const icns: Uint8Array[] = parseIcns(new Uint8Array(buffer));
|
const icns: Uint8Array[] = parseIcns(new Uint8Array(buffer));
|
||||||
if (typeof icns === "string") {
|
if (typeof icns === "string") {
|
||||||
return {
|
return {
|
||||||
|
|
@ -187,6 +207,7 @@ const handleMessage = async (message: any): Promise<any> => {
|
||||||
"images.zip",
|
"images.zip",
|
||||||
);
|
);
|
||||||
const zipBytes = await readToEnd(zip.getReader());
|
const zipBytes = await readToEnd(zip.getReader());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "finished",
|
type: "finished",
|
||||||
output: zipBytes,
|
output: zipBytes,
|
||||||
|
|
@ -197,8 +218,7 @@ const handleMessage = async (message: any): Promise<any> => {
|
||||||
// build frames of animated formats (webp/gif)
|
// 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
|
// APNG does not work on magick-wasm since it needs ffmpeg built-in (not in magick-wasm) - handle in ffmpeg
|
||||||
if (
|
if (
|
||||||
(message.input.from === ".webp" ||
|
(from === ".webp" || from === ".gif") &&
|
||||||
message.input.from === ".gif") &&
|
|
||||||
(message.to === ".gif" || message.to === ".webp")
|
(message.to === ".gif" || message.to === ".webp")
|
||||||
) {
|
) {
|
||||||
const collection = MagickImageCollection.create(
|
const collection = MagickImageCollection.create(
|
||||||
|
|
@ -214,6 +234,7 @@ const handleMessage = async (message: any): Promise<any> => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
collection.dispose();
|
collection.dispose();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "finished",
|
type: "finished",
|
||||||
output: result,
|
output: result,
|
||||||
|
|
@ -223,9 +244,7 @@ const handleMessage = async (message: any): Promise<any> => {
|
||||||
const img = MagickImage.create(
|
const img = MagickImage.create(
|
||||||
new Uint8Array(buffer),
|
new Uint8Array(buffer),
|
||||||
new MagickReadSettings({
|
new MagickReadSettings({
|
||||||
format: message.input.from
|
format: from.slice(1).toUpperCase() as MagickFormat,
|
||||||
.slice(1)
|
|
||||||
.toUpperCase() as MagickFormat,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -241,6 +260,11 @@ const handleMessage = async (message: any): Promise<any> => {
|
||||||
output: converted,
|
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();
|
let fmt = to.slice(1).toUpperCase();
|
||||||
if (fmt === "JFIF") fmt = "JPEG";
|
if (fmt === "JFIF") fmt = "JPEG";
|
||||||
|
|
||||||
const result = await new Promise<Uint8Array>((resolve) => {
|
const result = await new Promise<Uint8Array>((resolve, reject) => {
|
||||||
// magick-wasm automatically clamps (https://github.com/dlemstra/magick-wasm/blob/76fc6f2b0c0497d2ddc251bbf6174b4dc92ac3ea/src/magick-image.ts#L2480)
|
try {
|
||||||
if (compression) img.quality = compression;
|
// magick-wasm automatically clamps (https://github.com/dlemstra/magick-wasm/blob/76fc6f2b0c0497d2ddc251bbf6174b4dc92ac3ea/src/magick-image.ts#L2480)
|
||||||
if (!keepMetadata) img.strip();
|
if (compression) img.quality = compression;
|
||||||
|
if (!keepMetadata) img.strip();
|
||||||
|
|
||||||
img.write(fmt as unknown as MagickFormat, (o: Uint8Array) => {
|
img.write(fmt as unknown as MagickFormat, (o: Uint8Array) => {
|
||||||
resolve(structuredClone(o));
|
resolve(structuredClone(o));
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { WorkerMessage } from "$lib/types";
|
||||||
import * as wasiShim from "@bjorn3/browser_wasi_shim";
|
import * as wasiShim from "@bjorn3/browser_wasi_shim";
|
||||||
import * as zip from "client-zip";
|
import * as zip from "client-zip";
|
||||||
|
|
||||||
|
|
@ -37,18 +38,19 @@ type Format =
|
||||||
| ".markdown";
|
| ".markdown";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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) {
|
switch (message.type) {
|
||||||
case "load": {
|
case "load": {
|
||||||
wasm = message.wasm;
|
wasm = message.wasm;
|
||||||
postMessage({ type: "loaded" });
|
postMessage({ type: "loaded", id: "0" });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "convert": {
|
case "convert": {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line prefer-const
|
const { to: ext, input } = message;
|
||||||
let { to, file }: { to: Format; file: File } = message;
|
const file = input.file as File;
|
||||||
|
const to = ext as Format;
|
||||||
if (to === ".rtf") {
|
if (to === ".rtf") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Converting into RTF is currently not supported.",
|
"Converting into RTF is currently not supported.",
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
const clipboardData = e.clipboardData;
|
const clipboardData = e.clipboardData;
|
||||||
if (!clipboardData || !clipboardData.files.length) return;
|
if (!clipboardData || !clipboardData.files.length) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (page.url.pathname !== "/jpegify/") {
|
if (page.url.pathname !== "/jpegify/") {
|
||||||
const oldLength = files.files.length;
|
const oldLength = files.files.length;
|
||||||
files.add(clipboardData.files);
|
files.add(clipboardData.files);
|
||||||
|
|
@ -73,8 +73,6 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
initAnimStores();
|
initAnimStores();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,8 +184,10 @@
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="flex-shrink-0 w-8 rounded-full hover:bg-panel-alt h-full flex items-center justify-center"
|
class="flex-shrink-0 w-8 rounded-full hover:bg-panel-alt h-full flex items-center justify-center"
|
||||||
onclick={() =>
|
onclick={async () => {
|
||||||
(files.files = files.files.filter((_, i) => i !== index))}
|
await file.cancel();
|
||||||
|
files.files = files.files.filter((_, i) => i !== index);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<XIcon size="24" class="text-muted" />
|
<XIcon size="24" class="text-muted" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue