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", "settings": "Settings",
"title": "File conversion 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.", "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": { "image": {
"quality": "Quality", "quality": "Quality",
"depth": "Color depth", "depth": "Color depth",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -422,7 +422,7 @@ export class VertdConverter extends Converter {
return defaults; 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); if (to.startsWith(".")) to = to.slice(1);
let fileUpload = input; let fileUpload = input;
@ -440,7 +440,7 @@ export class VertdConverter extends Converter {
fileUpload = await magickConverter.convert( fileUpload = await magickConverter.convert(
input, input,
".gif", ".gif",
input.conversionSettings, settings,
100, 100,
); );
this.log(`successfully converted webp to gif`); this.log(`successfully converted webp to gif`);

View File

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

View File

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

View File

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

View File

@ -1,15 +1,20 @@
import { import {
ColorSpace,
initializeImageMagick, initializeImageMagick,
MagickColor,
MagickFormat, MagickFormat,
MagickImage, MagickImage,
MagickImageCollection, MagickImageCollection,
MagickReadSettings, MagickReadSettings,
AlphaAction,
type IMagickImage, type IMagickImage,
} from "@imagemagick/magick-wasm"; } from "@imagemagick/magick-wasm";
import { makeZip } from "client-zip"; import { makeZip } from "client-zip";
import { parseAni } from "$lib/util/parse/ani"; import { parseAni } from "$lib/util/parse/ani";
import { parseIcns } from "vert-wasm"; import { parseIcns } from "vert-wasm";
import type { WorkerMessage } from "$lib/types"; import type { WorkerMessage } from "$lib/types";
import type { ConversionSettings } from "$lib/types/conversion-settings";
import { log } from "$lib/util/logger";
let magickInitialized = false; let magickInitialized = false;
@ -44,9 +49,6 @@ const handleMessage = async (
return { type: "error", error: "magick-wasm not initialized" }; 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}`; 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";
@ -55,6 +57,10 @@ const handleMessage = async (
if (from === ".jfif") from = ".jpeg"; if (from === ".jfif") from = ".jpeg";
if (from === ".fit") from = ".fits"; 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(); const buffer = await message.input.file.arrayBuffer();
// special ico handling to split them all into separate images // special ico handling to split them all into separate images
@ -90,8 +96,7 @@ const handleMessage = async (
const output = await magickConvert( const output = await magickConvert(
img, img,
message.to, message.to,
keepMetadata, conversionSettings,
compression,
); );
convertedImgs[i] = output; convertedImgs[i] = output;
}), }),
@ -133,8 +138,7 @@ const handleMessage = async (
}), }),
), ),
message.to, message.to,
keepMetadata, conversionSettings,
compression,
); );
files.push( files.push(
new File( new File(
@ -184,8 +188,7 @@ const handleMessage = async (
const converted = await magickConvert( const converted = await magickConvert(
img, img,
message.to, message.to,
keepMetadata, conversionSettings,
compression,
); );
outputs.push(converted); outputs.push(converted);
break; break;
@ -251,8 +254,7 @@ const handleMessage = async (
const converted = await magickConvert( const converted = await magickConvert(
img, img,
message.to, message.to,
keepMetadata, conversionSettings,
compression,
); );
return { return {
@ -287,8 +289,7 @@ const readToEnd = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
const magickConvert = async ( const magickConvert = async (
img: IMagickImage, img: IMagickImage,
to: string, to: string,
keepMetadata: boolean, conversionSettings: ConversionSettings,
compression?: number,
) => { ) => {
let fmt = to.slice(1).toUpperCase(); let fmt = to.slice(1).toUpperCase();
if (fmt === "JFIF") fmt = "JPEG"; if (fmt === "JFIF") fmt = "JPEG";
@ -310,10 +311,56 @@ const magickConvert = async (
const result = await new Promise<Uint8Array>((resolve, reject) => { const result = await new Promise<Uint8Array>((resolve, reject) => {
try { try {
// magick-wasm automatically clamps (https://github.com/dlemstra/magick-wasm/blob/76fc6f2b0c0497d2ddc251bbf6174b4dc92ac3ea/src/magick-image.ts#L2480) // quality, depth, colorSpace, transparency, metadata
if (compression) img.quality = compression; const quality = conversionSettings.quality as number;
if (!keepMetadata) img.strip(); 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) => { img.write(fmt as unknown as MagickFormat, (o: Uint8Array) => {
resolve(structuredClone(o)); 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 = [ 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.js",
"https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm/ffmpeg-core.wasm", "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm/ffmpeg-core.wasm",
]; ];