fix: code review suggestions

yes used ai for this, all seems valid and noted though
This commit is contained in:
Maya 2026-03-20 13:55:47 +03:00
parent 8729584614
commit d3aeb9b696
14 changed files with 382 additions and 258 deletions

View File

@ -11,49 +11,49 @@
"@fontsource/lexend": "^5.2.11", "@fontsource/lexend": "^5.2.11",
"@fontsource/radio-canada-big": "^5.2.7", "@fontsource/radio-canada-big": "^5.2.7",
"@imagemagick/magick-wasm": "^0.0.37", "@imagemagick/magick-wasm": "^0.0.37",
"@mediabunny/ac3": "^1.35.1", "@mediabunny/ac3": "^1.40.0",
"@mediabunny/flac-encoder": "^1.37.0", "@mediabunny/flac-encoder": "^1.40.0",
"@mediabunny/mp3-encoder": "^1.35.1", "@mediabunny/mp3-encoder": "^1.40.0",
"@stripe/stripe-js": "^8.7.0", "@stripe/stripe-js": "^8.11.0",
"byte-data": "^19.0.1", "byte-data": "^19.0.1",
"client-zip": "^2.5.0", "client-zip": "^2.5.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"lucide-svelte": "^0.554.0", "lucide-svelte": "^0.554.0",
"mediabunny": "^1.37.0", "mediabunny": "^1.40.0",
"music-metadata": "^11.12.0", "music-metadata": "^11.12.3",
"overlayscrollbars": "^2.14.0", "overlayscrollbars": "^2.14.0",
"overlayscrollbars-svelte": "^0.5.5", "overlayscrollbars-svelte": "^0.5.5",
"p-queue": "^9.1.0", "p-queue": "^9.1.0",
"riff-file": "^1.0.3", "riff-file": "^1.0.3",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.2",
"svelte-stripe": "^1.4.0", "svelte-stripe": "^1.4.0",
"vert-wasm": "^0.0.2", "vert-wasm": "^0.0.2",
"vite-plugin-wasm": "^3.5.0", "vite-plugin-wasm": "^3.6.0",
}, },
"devDependencies": { "devDependencies": {
"@inlang/paraglide-js": "^2.11.0", "@inlang/paraglide-js": "^2.15.0",
"@poppanator/sveltekit-svg": "^5.0.1", "@poppanator/sveltekit-svg": "^5.0.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.52.0", "@sveltejs/kit": "^2.55.0",
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.1",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.27",
"css-select": "5.1.0", "css-select": "5.1.0",
"eslint": "^9.39.2", "eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^2.46.1", "eslint-plugin-svelte": "^2.46.1",
"globals": "^15.15.0", "globals": "^15.15.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1", "prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.6.14",
"sass": "^1.97.3", "sass": "^1.98.0",
"svelte": "^5.51.2", "svelte": "^5.54.0",
"svelte-check": "^4.4.0", "svelte-check": "^4.4.5",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.55.0", "typescript-eslint": "^8.57.1",
"vite": "^5.4.21", "vite": "^5.4.21",
"vite-plugin-top-level-await": "^1.6.0", "vite-plugin-top-level-await": "^1.6.0",
}, },

View File

@ -9,7 +9,7 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "prettier --check . && eslint ." "lint": "prettier --check .; p=$?; eslint .; e=$?; [ $p -eq 0 ] && [ $e -eq 0 ]"
}, },
"devDependencies": { "devDependencies": {
"@inlang/paraglide-js": "^2.15.0", "@inlang/paraglide-js": "^2.15.0",

View File

@ -13,6 +13,25 @@
type Props = DialogProps<VertdErrorDetailsProps>; type Props = DialogProps<VertdErrorDetailsProps>;
let { additional }: Props = $props(); let { additional }: Props = $props();
let errorBlobUrl = $state("");
$effect(() => {
if (!additional.errorMessage) {
errorBlobUrl = "";
return;
}
const nextUrl = URL.createObjectURL(
new Blob([additional.errorMessage], {
type: "text/plain",
}),
);
errorBlobUrl = nextUrl;
return () => {
URL.revokeObjectURL(nextUrl);
};
});
</script> </script>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@ -41,13 +60,7 @@
{@html sanitize(link( {@html sanitize(link(
["view_link"], ["view_link"],
m["convert.errors.vertd_details_error_message"](), m["convert.errors.vertd_details_error_message"](),
[ [errorBlobUrl || "#"],
URL.createObjectURL(
new Blob([additional.errorMessage], {
type: "text/plain",
}),
),
],
[true], [true],
["text-blue-500 font-normal"], ["text-blue-500 font-normal"],
))} ))}

View File

@ -90,10 +90,11 @@ export class FFmpegConverter extends Converter {
this.error = (msg) => error(["converters", this.name], msg); this.error = (msg) => error(["converters", this.name], msg);
this.log(`created converter`); this.log(`created converter`);
if (!browser) return; if (!browser) return;
try {
// this is just to cache the wasm and js for when we actually use it. we're not using this ffmpeg instance // this is just to cache the wasm and js for when we actually use it. we're not using this ffmpeg instance
this.ffmpeg = new FFmpeg(); this.ffmpeg = new FFmpeg();
(async () => { void (async () => {
try {
const baseURL = const baseURL =
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm"; "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm";
@ -105,15 +106,15 @@ export class FFmpegConverter extends Converter {
}); });
this.status = "ready"; this.status = "ready";
})(); } catch (err) {
} catch (err) { this.error(`Error loading ffmpeg: ${err}`);
this.error(`Error loading ffmpeg: ${err}`); this.status = "error";
this.status = "error"; ToastManager.add({
ToastManager.add({ type: "error",
type: "error", message: m["workers.errors.ffmpeg"](),
message: m["workers.errors.ffmpeg"](), });
}); }
} })();
} }
public async getAvailableSettings(): Promise<SettingDefinition[]> { public async getAvailableSettings(): Promise<SettingDefinition[]> {
@ -251,46 +252,42 @@ export class FFmpegConverter extends Converter {
ffmpeg.on("log", errorListener); ffmpeg.on("log", errorListener);
const buf = new Uint8Array(await input.file.arrayBuffer()); try {
await ffmpeg.writeFile("input", buf); const buf = new Uint8Array(await input.file.arrayBuffer());
this.log(`wrote ${input.name} to ffmpeg virtual fs`); await ffmpeg.writeFile("input", buf);
this.log(`wrote ${input.name} to ffmpeg virtual fs`);
const command = await this.buildConversionCommand( const command = await this.buildConversionCommand(
ffmpeg, ffmpeg,
input, input,
to, to,
conversionSettings, conversionSettings,
isAlac, isAlac,
); );
this.log(`FFmpeg command: ${command.join(" ")}`); this.log(`FFmpeg command: ${command.join(" ")}`);
await ffmpeg.exec(command); await ffmpeg.exec(command);
this.log("executed ffmpeg command"); this.log("executed ffmpeg command");
if (conversionError) { if (conversionError) throw new Error(conversionError);
const output = (await ffmpeg.readFile(
"output" + to,
)) as unknown as Uint8Array;
if (!output || output.length === 0)
throw new Error("empty file returned");
const outputFileName =
input.name.split(".").slice(0, -1).join(".") + to;
this.log(`read ${outputFileName} from ffmpeg virtual fs`);
const outBuf = new Uint8Array(output).buffer.slice(0);
return new VertFile(new File([outBuf], outputFileName), to);
} finally {
ffmpeg.off("log", errorListener); ffmpeg.off("log", errorListener);
this.activeConversions.delete(input.id);
ffmpeg.terminate(); ffmpeg.terminate();
throw new Error(conversionError);
} }
const output = (await ffmpeg.readFile(
"output" + to,
)) as unknown as Uint8Array;
if (!output || output.length === 0) {
ffmpeg.off("log", errorListener);
ffmpeg.terminate();
throw new Error("empty file returned");
}
const outputFileName =
input.name.split(".").slice(0, -1).join(".") + to;
this.log(`read ${outputFileName} from ffmpeg virtual fs`);
ffmpeg.off("log", errorListener);
ffmpeg.terminate();
const outBuf = new Uint8Array(output).buffer.slice(0);
return new VertFile(new File([outBuf], outputFileName), to);
} }
public async cancel(input: VertFile): Promise<void> { public async cancel(input: VertFile): Promise<void> {
@ -529,7 +526,7 @@ export class FFmpegConverter extends Converter {
// -map for each audio track // -map for each audio track
if (settings.tracks > 1) { if (settings.tracks > 1) {
for (let i = 0; i < settings.tracks; i++) { for (let i = 0; i < settings.tracks; i++) {
tracksArgs.push("-map", `0:a:${i - 1}`); tracksArgs.push("-map", `0:a:${i}`);
} }
} else { } else {
tracksArgs = ["-map", "0:a:0"]; // default to first audio track if not specified tracksArgs = ["-map", "0:a:0"]; // default to first audio track if not specified

View File

@ -49,69 +49,70 @@ export class PandocConverter extends Converter {
this.activeConversions.set(file.id, worker); this.activeConversions.set(file.id, worker);
const loadMsg: WorkerMessage = { try {
type: "load", const loadMsg: WorkerMessage = {
wasm: this.wasm, type: "load",
id: file.id, wasm: this.wasm,
}; id: file.id,
worker.postMessage(loadMsg); };
await waitForMessage(worker, "loaded"); worker.postMessage(loadMsg);
const convertMsg: WorkerMessage = { await waitForMessage(worker, "loaded");
type: "convert", const convertMsg: WorkerMessage = {
to, type: "convert",
input: {
file: file.file,
name: file.name,
from: file.from,
to, to,
}, input: {
id: file.id, file: file.file,
conversionSettings: "", // no settings for pandoc yet name: file.name,
}; from: file.from,
worker.postMessage(convertMsg); to,
const result = await waitForMessage(worker); },
if (result.type === "error") { id: file.id,
worker.terminate(); conversionSettings: "", // no settings for pandoc yet
// throw new Error(result.error); };
const error = result.error.toString(); worker.postMessage(convertMsg);
switch (result.errorKind) { const result = await waitForMessage(worker);
case "PandocUnknownReaderError": { if (result.type === "error") {
throw new Error( const error = result.error.toString();
`${file.from} is not a supported input format for documents.`, switch (result.errorKind) {
); case "PandocUnknownReaderError": {
}
case "PandocUnknownWriterError": {
throw new Error(
`${to} is not a supported output format for documents.`,
);
}
case "PandocParseError": {
if (error.includes("JSON missing pandoc-api-version")) {
throw new Error( throw new Error(
`This JSON file is not a pandoc-converted JSON file. It must be converted with pandoc / VERT to be converted again.`, `${file.from} is not a supported input format for documents.`,
); );
} }
}
// eslint-disable-next-line no-fallthrough case "PandocUnknownWriterError": {
default:
if (result.errorKind)
throw new Error( throw new Error(
`[${result.errorKind}] ${result.error}`, `${to} is not a supported output format for documents.`,
); );
else throw new Error(result.error); }
}
}
if (!to.startsWith(".")) to = `.${to}`; case "PandocParseError": {
this.activeConversions.delete(file.id); if (error.includes("JSON missing pandoc-api-version")) {
worker.terminate(); throw new Error(
return new VertFile( `This JSON file is not a pandoc-converted JSON file. It must be converted with pandoc / VERT to be converted again.`,
new File([result.output], file.name), );
result.isZip ? ".zip" : to, }
); }
// eslint-disable-next-line no-fallthrough
default:
if (result.errorKind)
throw new Error(
`[${result.errorKind}] ${result.error}`,
);
else throw new Error(result.error);
}
}
if (!to.startsWith(".")) to = `.${to}`;
return new VertFile(
new File([result.output], file.name),
result.isZip ? ".zip" : to,
);
} finally {
this.activeConversions.delete(file.id);
worker.terminate();
}
} }
public async cancel(input: VertFile): Promise<void> { public async cancel(input: VertFile): Promise<void> {

View File

@ -723,11 +723,42 @@ export class VertdConverter extends Converter {
const apiUrl = await VertdInstance.instance.url(); const apiUrl = await VertdInstance.instance.url();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let settled = false;
const protocol = apiUrl.startsWith("https") ? "wss:" : "ws:"; const protocol = apiUrl.startsWith("https") ? "wss:" : "ws:";
const ws = new WebSocket( const ws = new WebSocket(
`${protocol}//${apiUrl.replace("http://", "").replace("https://", "")}/api/ws`, `${protocol}//${apiUrl.replace("http://", "").replace("https://", "")}/api/ws`,
); );
const connectTimeout = setTimeout(() => {
if (settled) return;
settled = true;
this.activeConversions.delete(input.id);
ws.close();
reject(new Error("vertd websocket connection timeout"));
}, 30000);
const rejectConversion = (reason: unknown) => {
if (settled) return;
settled = true;
clearTimeout(connectTimeout);
this.activeConversions.delete(input.id);
if (
ws.readyState === WebSocket.CONNECTING ||
ws.readyState === WebSocket.OPEN
) {
ws.close();
}
reject(reason);
};
const resolveConversion = (value: VertFile) => {
if (settled) return;
settled = true;
clearTimeout(connectTimeout);
this.activeConversions.delete(input.id);
resolve(value);
};
this.activeConversions.set(input.id, { this.activeConversions.set(input.id, {
ws, ws,
jobId: uploadRes.id, jobId: uploadRes.id,
@ -735,6 +766,7 @@ export class VertdConverter extends Converter {
}); });
ws.onopen = () => { ws.onopen = () => {
clearTimeout(connectTimeout);
this.log( this.log(
`opened ws connection to vertd for file ${input.name}`, `opened ws connection to vertd for file ${input.name}`,
); );
@ -752,8 +784,32 @@ export class VertdConverter extends Converter {
this.log(`sent startJob message for file ${input.name}`); this.log(`sent startJob message for file ${input.name}`);
}; };
ws.onerror = () => {
this.error(`ws error for file ${input.name}`);
rejectConversion(new Error("vertd websocket error"));
};
ws.onclose = (event) => {
if (settled) return;
this.error(
`ws closed unexpectedly for file ${input.name} (code: ${event.code})`,
);
rejectConversion(new Error("vertd websocket closed unexpectedly"));
};
ws.onmessage = async (e) => { ws.onmessage = async (e) => {
const msg: VertdMessage = JSON.parse(e.data); let msg: VertdMessage;
try {
if (typeof e.data !== "string") {
rejectConversion(new Error("invalid websocket payload type"));
return;
}
msg = JSON.parse(e.data);
} catch {
rejectConversion(new Error("invalid websocket payload"));
return;
}
this.log(`received message ${msg.type} for file ${input.name}`); this.log(`received message ${msg.type} for file ${input.name}`);
switch (msg.type) { switch (msg.type) {
case "progressUpdate": { case "progressUpdate": {
@ -781,45 +837,48 @@ export class VertdConverter extends Converter {
case "jobFinished": { case "jobFinished": {
this.log(`job finished for file ${input.name}`); this.log(`job finished for file ${input.name}`);
ws.close(); ws.close();
this.activeConversions.delete(input.id);
const url = `${apiUrl}/api/download/${msg.data.jobId}/${uploadRes.auth}`;
this.log(`downloading from ${url}`);
// const res = await fetch(url).then((res) => res.blob());
const res = await downloadFile(url, input);
// confirm download to clean up on server
try { try {
await vertdFetch( const url = `${apiUrl}/api/download/${msg.data.jobId}/${uploadRes.auth}`;
`/api/confirm/${msg.data.jobId}/${uploadRes.auth}`, this.log(`downloading from ${url}`);
{ const res = await downloadFile(url, input);
method: "GET",
}, // confirm download to clean up on server
); try {
this.log( await vertdFetch(
`confirmed download for file ${input.name}`, `/api/confirm/${msg.data.jobId}/${uploadRes.auth}`,
{
method: "GET",
},
);
this.log(
`confirmed download for file ${input.name}`,
);
} catch (e) {
this.error(`failed to confirm download: ${e}`);
}
resolveConversion(
new VertFile(new File([res], input.name), to),
); );
} catch (e) { } catch (e) {
this.error(`failed to confirm download: ${e}`); if (hash) this.failure(hash);
rejectConversion(e);
} }
resolve(new VertFile(new File([res], input.name), to));
break; break;
} }
case "jobCancelled": { case "jobCancelled": {
this.log("job cancelled"); this.log("job cancelled");
ws.close(); ws.close();
this.activeConversions.delete(input.id); rejectConversion("Conversion cancelled");
reject("Conversion cancelled");
break; break;
} }
case "error": { case "error": {
this.error(`error: ${msg.data.message}`); this.error(`error: ${msg.data.message}`);
this.activeConversions.delete(input.id);
if (hash) this.failure(hash); if (hash) this.failure(hash);
reject({ rejectConversion({
component: VertdErrorComponent, component: VertdErrorComponent,
additional: { additional: {
jobId: uploadRes.id, jobId: uploadRes.id,
@ -829,6 +888,11 @@ export class VertdConverter extends Converter {
errorMessage: msg.data.message, errorMessage: msg.data.message,
}, },
}); });
break;
}
default: {
break;
} }
} }
}; };

View File

@ -1,6 +1,7 @@
import { PUB_VERTD_URL } from "$env/static/public"; import { PUB_VERTD_URL } from "$env/static/public";
import type { ConversionBitrate } from "$lib/converters/ffmpeg.svelte"; import type { ConversionBitrate } from "$lib/converters/ffmpeg.svelte";
import type { ConversionSpeed } from "$lib/converters/vertd.svelte"; import type { ConversionSpeed } from "$lib/converters/vertd.svelte";
import { readSettings } from "$lib/util/settings";
import { VertdInstance } from "./vertdSettings.svelte"; import { VertdInstance } from "./vertdSettings.svelte";
export { default as Appearance } from "./Appearance.svelte"; export { default as Appearance } from "./Appearance.svelte";
@ -62,9 +63,9 @@ export class Settings {
public load() { public load() {
try { try {
VertdInstance.instance.load(); VertdInstance.instance.load();
const ls = localStorage.getItem("settings"); const persisted = readSettings<ISettings>();
if (!ls) return; if (!Object.keys(persisted).length) return;
const settings: ISettings = JSON.parse(ls); const settings = persisted as ISettings;
const vertdBlockedHashes = new Map<string, Date[]>( const vertdBlockedHashes = new Map<string, Date[]>(
Object.entries( Object.entries(
settings.vertdBlockedHashes || settings.vertdBlockedHashes ||

View File

@ -11,6 +11,7 @@ import { m } from "$lib/paraglide/messages";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import { ToastManager } from "$lib/util/toast.svelte"; import { ToastManager } from "$lib/util/toast.svelte";
import { GB } from "$lib/util/consts"; import { GB } from "$lib/util/consts";
import { readSettings } from "$lib/util/settings";
class Files { class Files {
public files = $state<VertFile[]>([]); public files = $state<VertFile[]>([]);
@ -52,9 +53,7 @@ class Files {
public requiredConverters = $derived( public requiredConverters = $derived(
Array.from( Array.from(
new Set( new Set(
this.files.flatMap((file) => this.files.flatMap((file) => this.getRequiredConverters(file)),
this.getRequiredConverters(file),
),
), ),
), ),
); );
@ -93,6 +92,9 @@ class Files {
?.includes(file.from.toLowerCase()); ?.includes(file.from.toLowerCase());
try { try {
if (file.blobUrl?.startsWith("blob:"))
URL.revokeObjectURL(file.blobUrl);
if (isAudio) { if (isAudio) {
// try to get the thumbnail from the audio via music-metadata // try to get the thumbnail from the audio via music-metadata
const { common } = await parseBlob(file.file, { const { common } = await parseBlob(file.file, {
@ -136,55 +138,65 @@ class Files {
const mediaElement = isVideo const mediaElement = isVideo
? document.createElement("video") ? document.createElement("video")
: new Image(); : new Image();
mediaElement.src = URL.createObjectURL(file); const mediaUrl = URL.createObjectURL(file);
mediaElement.src = mediaUrl;
await new Promise((resolve, reject) => { try {
if (isVideo) { await new Promise((resolve, reject) => {
const video = mediaElement as HTMLVideoElement; if (isVideo) {
// seek to 10% of video time or 2 seconds in const video = mediaElement as HTMLVideoElement;
video.onloadeddata = () => { // seek to 10% of video time or 2 seconds in
const seekTime = Math.min(video.duration * 0.1, 2); video.onloadeddata = () => {
video.currentTime = seekTime; const seekTime = Math.min(video.duration * 0.1, 2);
}; video.currentTime = seekTime;
video.onseeked = resolve; };
video.onerror = reject; video.onseeked = resolve;
} else { video.onerror = reject;
(mediaElement as HTMLImageElement).onload = resolve; } else {
(mediaElement as HTMLImageElement).onerror = reject; (mediaElement as HTMLImageElement).onload = resolve;
(mediaElement as HTMLImageElement).onerror = reject;
}
});
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return undefined;
const width = isVideo
? (mediaElement as HTMLVideoElement).videoWidth
: (mediaElement as HTMLImageElement).width;
const height = isVideo
? (mediaElement as HTMLVideoElement).videoHeight
: (mediaElement as HTMLImageElement).height;
const scale = Math.max(maxSize / width, maxSize / height);
canvas.width = width * scale;
canvas.height = height * scale;
ctx.drawImage(mediaElement, 0, 0, canvas.width, canvas.height);
// check if completely transparent
const imageData = ctx.getImageData(
0,
0,
canvas.width,
canvas.height,
);
const isTransparent = Array.from(imageData.data).every(
(value, index) => {
return (index + 1) % 4 !== 0 || value === 0;
},
);
if (isTransparent) {
canvas.remove();
return undefined;
} }
});
const canvas = document.createElement("canvas"); const url = canvas.toDataURL();
const ctx = canvas.getContext("2d");
if (!ctx) return undefined;
const width = isVideo
? (mediaElement as HTMLVideoElement).videoWidth
: (mediaElement as HTMLImageElement).width;
const height = isVideo
? (mediaElement as HTMLVideoElement).videoHeight
: (mediaElement as HTMLImageElement).height;
const scale = Math.max(maxSize / width, maxSize / height);
canvas.width = width * scale;
canvas.height = height * scale;
ctx.drawImage(mediaElement, 0, 0, canvas.width, canvas.height);
// check if completely transparent
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const isTransparent = Array.from(imageData.data).every(
(value, index) => {
return (index + 1) % 4 !== 0 || value === 0;
},
);
if (isTransparent) {
canvas.remove(); canvas.remove();
return undefined; return url;
} finally {
URL.revokeObjectURL(mediaUrl);
} }
const url = canvas.toDataURL();
canvas.remove();
return url;
} }
private async _handleZipFile(file: File): Promise<void> { private async _handleZipFile(file: File): Promise<void> {
@ -435,7 +447,7 @@ class Files {
const blob = await downloadZip(dlFiles, "converted.zip").blob(); const blob = await downloadZip(dlFiles, "converted.zip").blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const settings = JSON.parse(localStorage.getItem("settings") ?? "{}"); const settings = readSettings<{ filenameFormat?: string }>();
const filenameFormat = settings.filenameFormat || "VERT_%name%"; const filenameFormat = settings.filenameFormat || "VERT_%name%";
const format = (name: string) => { const format = (name: string) => {
@ -603,7 +615,10 @@ export const getMaxArrayBufferSize = (): number => {
// lmao uh mobile devices definitely have a much lower limit and using binary search here // lmao uh mobile devices definitely have a much lower limit and using binary search here
// was causing crashes especially on iOS, so just return 2GB to be safe :p // was causing crashes especially on iOS, so just return 2GB to be safe :p
if (get(isMobile)) { if (get(isMobile)) {
log(["converters"], `mobile device likely detected, using 2GB fallback for max ArrayBuffer size`); log(
["converters"],
`mobile device likely detected, using 2GB fallback for max ArrayBuffer size`,
);
// don't save to localStorage, since it can always be a false positive or the user's browser window is simply just small // don't save to localStorage, since it can always be a false positive or the user's browser window is simply just small
return 2 * GB; return 2 * GB;
} }

View File

@ -11,6 +11,7 @@ import type {
SettingDefinition, SettingDefinition,
} from "./conversion-settings"; } from "./conversion-settings";
import { log } from "$lib/util/logger"; import { log } from "$lib/util/logger";
import { readSettings } from "$lib/util/settings";
const MAX_BLOB_SIZE_LIMIT = 2 * 1024 * 1024 * 1024; // 2GB const MAX_BLOB_SIZE_LIMIT = 2 * 1024 * 1024 * 1024; // 2GB
@ -121,7 +122,7 @@ export class VertFile {
constructor(file: File, to: string, blobUrl?: string) { constructor(file: File, to: string, blobUrl?: string) {
const ext = file.name.split(".").pop(); const ext = file.name.split(".").pop();
const newFile = new File( const newFile = new File(
[file.slice(0, file.size, file.type)], [file],
`${file.name.split(".").slice(0, -1).join(".")}.${ext?.toLowerCase()}`, `${file.name.split(".").slice(0, -1).join(".")}.${ext?.toLowerCase()}`,
); );
this.file = newFile; this.file = newFile;
@ -479,7 +480,7 @@ export class VertFile {
let to = this.result.to; let to = this.result.to;
if (!to.startsWith(".")) to = `.${to}`; if (!to.startsWith(".")) to = `.${to}`;
const settings = JSON.parse(localStorage.getItem("settings") ?? "{}"); const settings = readSettings<{ filenameFormat?: string }>();
const filenameFormat = settings.filenameFormat || "VERT_%name%"; const filenameFormat = settings.filenameFormat || "VERT_%name%";
const format = (name: string) => { const format = (name: string) => {
@ -528,7 +529,7 @@ export class VertFile {
setTimeout(() => { setTimeout(() => {
cache.delete(cacheKey); cache.delete(cacheKey);
}, 3000); }, 30000);
} else { } else {
blob = URL.createObjectURL( blob = URL.createObjectURL(
new Blob([await this.result.file.arrayBuffer()], { new Blob([await this.result.file.arrayBuffer()], {
@ -545,7 +546,9 @@ export class VertFile {
a.target = "_blank"; a.target = "_blank";
a.style.display = "none"; a.style.display = "none";
a.click(); a.click();
URL.revokeObjectURL(blob); setTimeout(() => {
URL.revokeObjectURL(blob);
}, 30000);
a.remove(); a.remove();
} }

23
src/lib/util/settings.ts Normal file
View File

@ -0,0 +1,23 @@
import { browser } from "$app/environment";
import { error } from "$lib/util/logger";
export function readSettings<T extends object = Record<string, unknown>>(): Partial<T> {
if (!browser) return {};
const raw = localStorage.getItem("settings");
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
localStorage.removeItem("settings");
return {};
}
return parsed as Partial<T>;
} catch (e) {
error(["settings", "error"], `failed to parse saved settings: ${e}`);
localStorage.removeItem("settings");
return {};
}
}

View File

@ -46,15 +46,20 @@ class ServiceWorkerManager {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const messageChannel = new MessageChannel(); const messageChannel = new MessageChannel();
let settled = false;
messageChannel.port1.onmessage = (event) => { const timeoutId = setTimeout(() => {
resolve(event.data); if (settled) return;
}; settled = true;
setTimeout(() => {
reject(new Error("Timeout waiting for cache info")); reject(new Error("Timeout waiting for cache info"));
}, 5000); }, 5000);
messageChannel.port1.onmessage = (event) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
resolve(event.data);
};
navigator.serviceWorker?.controller?.postMessage( navigator.serviceWorker?.controller?.postMessage(
{ type: "GET_CACHE_INFO" }, { type: "GET_CACHE_INFO" },
[messageChannel.port2], [messageChannel.port2],
@ -69,8 +74,17 @@ class ServiceWorkerManager {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const messageChannel = new MessageChannel(); const messageChannel = new MessageChannel();
let settled = false;
const timeoutId = setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error("Timeout waiting for cache clear"));
}, 10000);
messageChannel.port1.onmessage = (event) => { messageChannel.port1.onmessage = (event) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
if (event.data.success) { if (event.data.success) {
resolve(); resolve();
} else { } else {
@ -80,10 +94,6 @@ class ServiceWorkerManager {
} }
}; };
setTimeout(() => {
reject(new Error("Timeout waiting for cache clear"));
}, 10000);
navigator.serviceWorker?.controller?.postMessage( navigator.serviceWorker?.controller?.postMessage(
{ type: "CLEAR_CACHE" }, { type: "CLEAR_CACHE" },
[messageChannel.port2], [messageChannel.port2],

View File

@ -76,8 +76,15 @@
// Check if the data is already in sessionStorage // Check if the data is already in sessionStorage
const cachedContribs = sessionStorage.getItem("ghContribs"); const cachedContribs = sessionStorage.getItem("ghContribs");
if (cachedContribs) { if (cachedContribs) {
ghContribs = JSON.parse(cachedContribs); try {
return; const parsedContribs = JSON.parse(cachedContribs);
if (Array.isArray(parsedContribs)) {
ghContribs = parsedContribs;
return;
}
} catch {
sessionStorage.removeItem("ghContribs");
}
} }
// Fetch GitHub contributors // Fetch GitHub contributors
@ -104,30 +111,16 @@
!excludedNames.has(contrib.login), !excludedNames.has(contrib.login),
); );
// Fetch and cache avatar images as Base64 ghContribs = filteredContribs.map(
const fetchAvatar = async (url: string) => { (contrib: {
const res = await fetch(url); login: string;
const blob = await res.blob(); avatar_url: string;
return new Promise<string>((resolve, reject) => { html_url: string;
const reader = new FileReader(); }) => ({
reader.onloadend = () => resolve(reader.result as string); name: contrib.login,
reader.onerror = reject; avatar: contrib.avatar_url,
reader.readAsDataURL(blob); github: contrib.html_url,
}); }),
};
ghContribs = await Promise.all(
filteredContribs.map(
async (contrib: {
login: string;
avatar_url: string;
html_url: string;
}) => ({
name: contrib.login,
avatar: await fetchAvatar(contrib.avatar_url),
github: contrib.html_url,
}),
),
); );
// Cache the data in sessionStorage // Cache the data in sessionStorage

View File

@ -2,17 +2,22 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { error, log } from "$lib/util/logger"; import { error, log } from "$lib/util/logger";
import * as Settings from "$lib/sections/settings/index.svelte"; import * as Settings from "$lib/sections/settings/index.svelte";
import { PUB_PLAUSIBLE_URL } from "$env/static/public";
import { SettingsIcon } from "lucide-svelte"; import { SettingsIcon } from "lucide-svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { m } from "$lib/paraglide/messages"; import { m } from "$lib/paraglide/messages";
import { ToastManager } from "$lib/util/toast.svelte"; import { ToastManager } from "$lib/util/toast.svelte";
import { DISABLE_ALL_EXTERNAL_REQUESTS } from "$lib/util/consts"; import { DISABLE_ALL_EXTERNAL_REQUESTS } from "$lib/util/consts";
import { readSettings } from "$lib/util/settings";
let settings = $state(Settings.Settings.instance.settings); let settings = $state(Settings.Settings.instance.settings);
let isInitial = $state(true); let isInitial = $state(true);
const readSavedSettings = () => {
const parsed = readSettings<typeof settings>();
return Object.keys(parsed).length ? parsed : null;
};
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
if (isInitial) { if (isInitial) {
@ -20,12 +25,9 @@
return; return;
} }
const savedSettings = localStorage.getItem("settings"); const parsedSettings = readSavedSettings();
if (savedSettings) { if (parsedSettings && JSON.stringify(parsedSettings) === JSON.stringify(settings))
const parsedSettings = JSON.parse(savedSettings); return;
if (JSON.stringify(parsedSettings) === JSON.stringify(settings))
return;
}
try { try {
Settings.Settings.instance.settings = settings; Settings.Settings.instance.settings = settings;
@ -41,9 +43,8 @@
}); });
onMount(() => { onMount(() => {
const savedSettings = localStorage.getItem("settings"); const parsedSettings = readSavedSettings();
if (savedSettings) { if (parsedSettings) {
const parsedSettings = JSON.parse(savedSettings);
Settings.Settings.instance.settings = { Settings.Settings.instance.settings = {
...Settings.Settings.instance.settings, ...Settings.Settings.instance.settings,
...parsedSettings, ...parsedSettings,

View File

@ -126,8 +126,10 @@ self.addEventListener("fetch", (event) => {
self.addEventListener("message", (event) => { self.addEventListener("message", (event) => {
if (!event.data) return; if (!event.data) return;
const type = event.data.type; const type = event.data.type;
const port = event.ports?.[0];
if (type === "GET_CACHE_INFO") { if (type === "GET_CACHE_INFO") {
if (!port) return;
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then(async (cache) => { caches.open(CACHE_NAME).then(async (cache) => {
const keys = await cache.keys(); const keys = await cache.keys();
@ -159,7 +161,7 @@ self.addEventListener("message", (event) => {
} }
} }
event.ports[0].postMessage({ port.postMessage({
totalSize, totalSize,
fileCount: files.length, fileCount: files.length,
files, files,
@ -169,6 +171,7 @@ self.addEventListener("message", (event) => {
} }
if (type === "CLEAR_CACHE") { if (type === "CLEAR_CACHE") {
if (!port) return;
event.waitUntil( event.waitUntil(
caches caches
.delete(CACHE_NAME) .delete(CACHE_NAME)
@ -177,11 +180,11 @@ self.addEventListener("message", (event) => {
return caches.open(CACHE_NAME); return caches.open(CACHE_NAME);
}) })
.then(() => { .then(() => {
event.ports[0].postMessage({ success: true }); port.postMessage({ success: true });
}) })
.catch((err) => { .catch((err) => {
console.error("[SW] failed to clear cache:", err); console.error("[SW] failed to clear cache:", err);
event.ports[0].postMessage({ port.postMessage({
success: false, success: false,
error: err.message, error: err.message,
}); });