feat: imagemagick settings logic

this took a bit lol, restructured the conversion workers a little bit - i prob broke vertd and ffmpeg audio for now
This commit is contained in:
Maya 2026-02-15 21:06:08 +03:00
parent 33cd998ab8
commit 7fcbdcd73a
No known key found for this signature in database
12 changed files with 126 additions and 56 deletions

View File

@ -85,6 +85,7 @@
"settings": "Settings",
"title": "File conversion settings",
"description": "Change the conversion settings for <b>{filename}</b>, which is using <b>{converter}</b>. These settings may not be available for all formats.",
"none": "No settings available for this format.",
"image": {
"quality": "Quality",
"depth": "Color depth",

View File

@ -7,8 +7,11 @@
type Props = DialogType;
let { id, title, message, buttons, type, ...rest }: Props = $props();
let additional = $derived("additional" in rest ? rest.additional : undefined);
let props: Props = $props();
// svelte-ignore state_referenced_locally
const { id, title, message, buttons, type } = props;
// svelte-ignore state_referenced_locally
const additional = "additional" in props ? props.additional : undefined;
const colors = {
success: "purple",
@ -53,7 +56,9 @@
</div>
<div class="flex flex-col gap-1 w-full">
{#if typeof message === "string"}
<p class="text-sm font-normal text-muted whitespace-pre-wrap">{message}</p>
<p class="text-sm font-normal text-muted whitespace-pre-wrap">
{message}
</p>
{:else}
{@const MessageComponent = message}
<div class="text-sm font-normal text-muted">

View File

@ -6,6 +6,8 @@
import { m } from "$lib/paraglide/messages";
import type { VertFile } from "$lib/types";
import { sanitize } from "$lib/store/index.svelte";
import { log } from "$lib/util/logger";
import { type ConversionSettings } from "$lib/types/conversion-settings";
type Props = {
file: VertFile | null;
@ -14,21 +16,32 @@
let { file, onclose }: Props = $props();
let settings = $state<Record<string, any>>({});
let settings = $state<ConversionSettings>({});
const handleSettingChange = (key: string, value: any) => {
if (!file) return;
settings[key] = value;
console.log(
`Changed settings for ${file.name}: ${JSON.stringify(settings, null, 2)}`,
);
};
const applySettings = () => {
const applySettings = async () => {
onclose?.();
if (!file) return;
file.conversionSettings = { ...file.conversionSettings, ...settings };
console.log(
const converter = file.findConverter();
if (!converter) {
log(
["settings", "modal"],
`No converter found for ${file.name}, cannot apply settings`,
);
return;
}
// apply defaults, then existing settings, then new settings on top
file.conversionSettings = {
...(await converter.getDefaultSettings()),
...file.conversionSettings,
...settings,
};
log(
["settings", "modal"],
`Applied settings for ${file.name}: ${JSON.stringify(file.conversionSettings, null, 2)}`,
);
};
@ -58,7 +71,8 @@
<p class="text-base">
{@html sanitize(
m["convert.settings.description"]({
converter: file.findConverter()?.name,
converter:
file.findConverter()?.name || "unknown",
filename: file.name,
}),
)}

View File

@ -12,12 +12,18 @@
import type { ToastProps } from "$lib/util/toast.svelte";
import type { SvelteComponent } from "svelte";
import clsx from "clsx";
import type { Toast as ToastType } from "$lib/util/toast.svelte";
const { id, type, message, durations, ...rest }: ToastProps = $props();
const props: {
toast: ToastType<unknown>;
} = $props();
const additional = $derived(
"additional" in rest ? rest.additional : undefined,
);
// svelte-ignore state_referenced_locally
const { id, type, message, durations } = props.toast;
// svelte-ignore state_referenced_locally
const additional =
"additional" in props.toast ? props.toast.additional : {};
const colors = {
success: "purple",

View File

@ -93,7 +93,7 @@ export class Converter {
public async convert(
input: VertFile,
to: string,
settings?: ConversionSettings,
settings: ConversionSettings,
...args: any[]
): Promise<VertFile> {
throw new Error("Not implemented");

View File

@ -118,12 +118,13 @@ export class MagickConverter extends Converter {
public async getAvailableSettings(): Promise<SettingDefinition[]> {
// images - quality/compression/quantize/interlace/depth-DPI, resize, crop, rotate, flip/flop, autoOrient?, color space/bit depth, transparency settings
const global = Settings.instance.settings;
const quality: SettingDefinition = {
key: "quality",
label: m["convert.settings.image.quality"](),
type: "number",
default: 100,
default: global.magickQuality ?? 100,
min: 0,
max: 100,
};
@ -152,6 +153,7 @@ export class MagickConverter extends Converter {
// what are these even lmao
{ value: "auto", label: "Auto" },
{ value: "srgb", label: "sRGB" },
{ value: "cmyk", label: "CMYK" },
{ value: "adobe98", label: "Adobe RGB" },
{ value: "prophoto", label: "ProPhoto RGB" },
{ value: "displayp3", label: "Display P3" },
@ -174,7 +176,7 @@ export class MagickConverter extends Converter {
key: "metadata",
label: m["convert.settings.common.metadata"](),
type: "boolean",
default: true,
default: global.metadata ?? true,
};
// resize, crop, rotate - prob want a ui
@ -194,17 +196,10 @@ export class MagickConverter extends Converter {
public async convert(
input: VertFile,
to: string,
settings: ConversionSettings,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...args: any[]
): Promise<VertFile> {
let compression: number | undefined = args.at(0);
if (!compression) {
compression = Settings.instance.settings.magickQuality ?? 100;
log(
["converters", this.name],
`using user setting for quality: ${compression}%`,
);
}
log(["converters", this.name], `converting ${input.name} to ${to}`);
// handle converting from SVG manually because magick-wasm doesn't support it
@ -216,7 +211,7 @@ export class MagickConverter extends Converter {
input.to,
);
if (to === ".png") return pngFile; // if target is png, return it directly
return await this.convert(pngFile, to, ...args); // otherwise, recursively convert png to user's target format
return await this.convert(pngFile, to, settings, ...args); // otherwise, recursively convert png to user's target format
} catch (err) {
error(
["converters", this.name],
@ -270,9 +265,11 @@ 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 conversionSettings = JSON.stringify(
Object.keys(settings).length > 0
? settings // user-provided settings
: await this.getDefaultSettings(), // use defaults if not provided
);
const convertMsg: WorkerMessage = {
type: "convert",
id: input.id,
@ -283,8 +280,7 @@ export class MagickConverter extends Converter {
to: input.to,
},
to,
compression,
keepMetadata,
conversionSettings,
};
worker.postMessage(convertMsg);

View File

@ -422,7 +422,7 @@ export class VertdConverter extends Converter {
return defaults;
}
public async convert(input: VertFile, to: string): Promise<VertFile> {
public async convert(input: VertFile, to: string, settings: ConversionSettings): Promise<VertFile> {
if (to.startsWith(".")) to = to.slice(1);
let fileUpload = input;
@ -440,7 +440,7 @@ export class VertdConverter extends Converter {
fileUpload = await magickConverter.convert(
input,
".gif",
input.conversionSettings,
settings,
100,
);
this.log(`successfully converted webp to gif`);

View File

@ -10,7 +10,7 @@ export interface SettingDefinition {
min?: number;
max?: number;
step?: number;
options?: Array<{ value: string; label: string }>; // for select types
options?: Array<{ value: any; label: string }>; // for select types
description?: string;
}

View File

@ -9,8 +9,7 @@ interface ConvertMessage {
to: string;
} | VertFile;
to: string;
compression: number | null;
keepMetadata?: boolean;
conversionSettings: string; // JSON stringified ConversionSettings
}
interface FinishedMessage {

View File

@ -188,6 +188,7 @@ export class VertFile {
const converted = await converter.convert(
tempVFile,
this.to,
this.conversionSettings,
);
let outputExt = this.to;
@ -209,6 +210,7 @@ export class VertFile {
const converted = await converter.convert(
tempVFile,
this.to,
this.conversionSettings,
);
let outputExt = this.to;

View File

@ -1,15 +1,20 @@
import {
ColorSpace,
initializeImageMagick,
MagickColor,
MagickFormat,
MagickImage,
MagickImageCollection,
MagickReadSettings,
AlphaAction,
type IMagickImage,
} from "@imagemagick/magick-wasm";
import { makeZip } from "client-zip";
import { parseAni } from "$lib/util/parse/ani";
import { parseIcns } from "vert-wasm";
import type { WorkerMessage } from "$lib/types";
import type { ConversionSettings } from "$lib/types/conversion-settings";
import { log } from "$lib/util/logger";
let magickInitialized = false;
@ -44,9 +49,6 @@ const handleMessage = async (
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";
@ -55,6 +57,10 @@ const handleMessage = async (
if (from === ".jfif") from = ".jpeg";
if (from === ".fit") from = ".fits";
console.log(JSON.stringify(message, null, 2));
const conversionSettings = JSON.parse(
message.conversionSettings || "{}",
) as ConversionSettings;
const buffer = await message.input.file.arrayBuffer();
// special ico handling to split them all into separate images
@ -90,8 +96,7 @@ const handleMessage = async (
const output = await magickConvert(
img,
message.to,
keepMetadata,
compression,
conversionSettings,
);
convertedImgs[i] = output;
}),
@ -133,8 +138,7 @@ const handleMessage = async (
}),
),
message.to,
keepMetadata,
compression,
conversionSettings,
);
files.push(
new File(
@ -184,8 +188,7 @@ const handleMessage = async (
const converted = await magickConvert(
img,
message.to,
keepMetadata,
compression,
conversionSettings,
);
outputs.push(converted);
break;
@ -251,8 +254,7 @@ const handleMessage = async (
const converted = await magickConvert(
img,
message.to,
keepMetadata,
compression,
conversionSettings,
);
return {
@ -287,8 +289,7 @@ const readToEnd = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
const magickConvert = async (
img: IMagickImage,
to: string,
keepMetadata: boolean,
compression?: number,
conversionSettings: ConversionSettings,
) => {
let fmt = to.slice(1).toUpperCase();
if (fmt === "JFIF") fmt = "JPEG";
@ -310,10 +311,56 @@ const magickConvert = async (
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();
// quality, depth, colorSpace, transparency, metadata
const quality = conversionSettings.quality as number;
const bitDepth = conversionSettings.depth as number;
const colorSpace = conversionSettings.colorSpace as string;
const transparency = conversionSettings.transparency as boolean;
const metadata = conversionSettings.metadata as boolean;
if (quality) img.quality = quality;
if (bitDepth) img.depth = bitDepth;
if (!metadata) img.strip();
if (colorSpace) {
switch (colorSpace) {
case "srgb":
img.colorSpace = ColorSpace.sRGB;
break;
case "cmyk":
img.colorSpace = ColorSpace.CMYK;
break;
case "adobe98":
img.colorSpace = ColorSpace.Adobe98;
break;
case "prophoto":
img.colorSpace = ColorSpace.ProPhoto;
break;
case "displayp3":
img.colorSpace = ColorSpace.DisplayP3;
break;
case "xyz":
img.colorSpace = ColorSpace.XYZ;
break;
case "lab":
img.colorSpace = ColorSpace.Lab;
break;
case "gray":
img.colorSpace = ColorSpace.Gray;
break;
// auto is default so do nothing
}
}
if (!transparency) {
img.backgroundColor = new MagickColor(0, 0, 0, 255); // TODO: probably make it an option to set the bg colour
img.alpha(AlphaAction.Remove);
}
log(
["workers", "imagemagick"],
`Converting to ${fmt} with settings: ${JSON.stringify(conversionSettings)}`,
);
// magick-wasm automatically clamps (https://github.com/dlemstra/magick-wasm/blob/76fc6f2b0c0497d2ddc251bbf6174b4dc92ac3ea/src/magick-image.ts#L2480)
img.write(fmt as unknown as MagickFormat, (o: Uint8Array) => {
resolve(structuredClone(o));
});

View File

@ -1,7 +1,7 @@
const CACHE_NAME = "vert-wasm-cache-v2"; // updated when workers update
const CACHE_NAME = "vert-wasm-cache-v3"; // updated when workers update
const WASM_FILES = [
"/pandoc.wasm",
"/pandoc.wasm", // from https://github.com/haskell-wasm/pandoc-wasm
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm/ffmpeg-core.js",
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm/ffmpeg-core.wasm",
];