From 76ff9cc704c141a6a0ae377afa01b86a3ccb70ff Mon Sep 17 00:00:00 2001 From: Maya Date: Tue, 2 Sep 2025 16:53:33 +0800 Subject: [PATCH] feat: conversion quality fixes #56 --- src/lib/converters/ffmpeg.svelte.ts | 25 +++++++++---- src/lib/converters/magick.svelte.ts | 7 +++- src/lib/sections/settings/Conversion.svelte | 1 + src/lib/workers/magick.ts | 41 +++++++++++++++++---- 4 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/lib/converters/ffmpeg.svelte.ts b/src/lib/converters/ffmpeg.svelte.ts index b830814..a36290e 100644 --- a/src/lib/converters/ffmpeg.svelte.ts +++ b/src/lib/converters/ffmpeg.svelte.ts @@ -5,6 +5,7 @@ import { browser } from "$app/environment"; import { error, log } from "$lib/logger"; import { addToast } from "$lib/store/ToastProvider"; import { m } from "$lib/paraglide/messages"; +import { Settings } from "$lib/sections/settings/index.svelte"; // TODO: differentiate in UI? (not native formats) const videoFormats = [ @@ -203,15 +204,23 @@ export class FFmpegConverter extends Converter { const outputFormat = to.slice(1); const lossless = ["flac", "alac", "wav"]; - let audioBitrateArgs: string[]; - if ( - lossless.includes(inputFormat) && - !lossless.includes(outputFormat) - ) { - audioBitrateArgs = ["-b:a", "320k"]; + const userSetting = Settings.instance.settings.ffmpegQuality; + log(["converters", this.name], `using user setting for audio bitrate: ${userSetting}`); + let audioBitrateArgs: string[] = []; + + if (userSetting !== "auto") { + // user's setting + audioBitrateArgs = ["-b:a", `${userSetting}k`]; } else { - const inputBitrate = await this.detectAudioBitrate(ffmpeg); - audioBitrateArgs = inputBitrate ? ["-b:a", `${inputBitrate}k`] : []; + // detect bitrate of original file and use + if (lossless.includes(inputFormat) && !lossless.includes(outputFormat)) { + audioBitrateArgs = ["-b:a", "320k"]; + log(["converters", this.name], `using default audio bitrate: 320k`); + } else { + const inputBitrate = await this.detectAudioBitrate(ffmpeg); + audioBitrateArgs = inputBitrate ? ["-b:a", `${inputBitrate}k`] : []; + log(["converters", this.name], `using detected audio bitrate: ${inputBitrate}k`); + } } // video to audio diff --git a/src/lib/converters/magick.svelte.ts b/src/lib/converters/magick.svelte.ts index 1648415..fbdfd9c 100644 --- a/src/lib/converters/magick.svelte.ts +++ b/src/lib/converters/magick.svelte.ts @@ -7,6 +7,7 @@ import { VertFile } from "$lib/types"; import MagickWorker from "$lib/workers/magick?worker&url"; import { Converter, FormatInfo } from "./converter.svelte"; import { imageFormats } from "./magick-automated"; +import { Settings } from "$lib/sections/settings/index.svelte"; export class MagickConverter extends Converter { private worker: Worker = browser @@ -108,7 +109,11 @@ export class MagickConverter extends Converter { // eslint-disable-next-line @typescript-eslint/no-explicit-any ...args: any[] ): Promise { - const compression: number | undefined = args.at(0); + let compression: number | undefined = args.at(0); + if (compression == null) { + compression = Settings.instance.settings.magickQuality; + 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 diff --git a/src/lib/sections/settings/Conversion.svelte b/src/lib/sections/settings/Conversion.svelte index cc15705..4c387e7 100644 --- a/src/lib/sections/settings/Conversion.svelte +++ b/src/lib/sections/settings/Conversion.svelte @@ -62,6 +62,7 @@ type="number" min={1} max={100} + extension={"%"} />
diff --git a/src/lib/workers/magick.ts b/src/lib/workers/magick.ts index b67a7a0..bb673f7 100644 --- a/src/lib/workers/magick.ts +++ b/src/lib/workers/magick.ts @@ -26,6 +26,7 @@ magickPromise const handleMessage = async (message: any): Promise => { switch (message.type) { case "convert": { + const compression: number | undefined = message.compression; if (!message.to.startsWith(".")) message.to = `.${message.to}`; message.to = message.to.toLowerCase(); if (message.to === ".jfif") message.to = ".jpeg"; @@ -66,7 +67,7 @@ const handleMessage = async (message: any): Promise => { const convertedImgs: Uint8Array[] = []; await Promise.all( imgs.map(async (img, i) => { - const output = await magickConvert(img, message.to); + const output = await magickConvert(img, message.to, compression); convertedImgs[i] = output; }), ); @@ -74,7 +75,10 @@ const handleMessage = async (message: any): Promise => { const zip = makeZip( convertedImgs.map( (img, i) => - new File([img], `image${i}.${message.to.slice(1)}`), + new File( + [new Uint8Array(img)], + `image${i}.${message.to.slice(1)}`, + ), ), "images.zip", ); @@ -104,9 +108,13 @@ const handleMessage = async (message: any): Promise => { }), ), message.to, + compression ); files.push( - new File([blob], `image${i}${message.to}`), + new File( + [new Uint8Array(blob)], + `image${i}${message.to}`, + ), ); }), ); @@ -150,6 +158,7 @@ const handleMessage = async (message: any): Promise => { const converted = await magickConvert( img, message.to, + compression ); outputs.push(converted); break; @@ -163,7 +172,10 @@ const handleMessage = async (message: any): Promise => { const zip = makeZip( outputs.map( (img, i) => - new File([img], `image${i}.${message.to.slice(1)}`), + new File( + [new Uint8Array(img)], + `image${i}.${message.to.slice(1)}`, + ), ), "images.zip", ); @@ -210,7 +222,7 @@ const handleMessage = async (message: any): Promise => { }), ); - const converted = await magickConvert(img, message.to); + const converted = await magickConvert(img, message.to, compression); return { type: "finished", @@ -228,7 +240,10 @@ const readToEnd = async (reader: ReadableStreamDefaultReader) => { if (value) chunks.push(value); done = d; } - const blob = new Blob(chunks, { type: "application/zip" }); + const blob = new Blob( + chunks.map((chunk) => new Uint8Array(chunk)), + { type: "application/zip" }, + ); const arrayBuffer = await blob.arrayBuffer(); return new Uint8Array(arrayBuffer); }; @@ -272,8 +287,12 @@ const magickToBlob = async (img: IMagickImage): Promise => { return; } + if (!data) { + reject(new Error("Pixel data is null")); + return; + } const imageData = new ImageData( - new Uint8ClampedArray(data?.buffer || new ArrayBuffer(0)), + new Uint8ClampedArray(data), img.width, img.height, ); @@ -288,7 +307,11 @@ const magickToBlob = async (img: IMagickImage): Promise => { ); }; -const magickConvert = async (img: IMagickImage, to: string) => { +const magickConvert = async ( + img: IMagickImage, + to: string, + compression?: number, +) => { const intermediary = await magickToBlob(img); const buf = new Uint8Array(await intermediary.arrayBuffer()); let fmt = to.slice(1).toUpperCase(); @@ -296,6 +319,8 @@ const magickConvert = async (img: IMagickImage, to: string) => { const result = await new Promise((resolve) => { ImageMagick.read(buf, MagickFormat.Png, (image) => { + // magick-wasm automatically clamps (https://github.com/dlemstra/magick-wasm/blob/76fc6f2b0c0497d2ddc251bbf6174b4dc92ac3ea/src/magick-image.ts#L2480) + if (compression) image.quality = compression; image.write(fmt as unknown as MagickFormat, (o) => { resolve(structuredClone(o)); });