diff --git a/bun.lock b/bun.lock index 8a8d9e8..52f899b 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "vert", diff --git a/messages/en.json b/messages/en.json index ed37775..b28ee19 100644 --- a/messages/en.json +++ b/messages/en.json @@ -101,7 +101,8 @@ "quality": "Quality", "depth": "Color depth", "color_space": "Color space", - "transparency": "Transparency" + "transparency": "Transparency", + "single_size": "Single size image" }, "audio": { "bitrate": { diff --git a/src/lib/components/functional/FormatDropdown.svelte b/src/lib/components/functional/FormatDropdown.svelte index 6643aee..b3cd5e5 100644 --- a/src/lib/components/functional/FormatDropdown.svelte +++ b/src/lib/components/functional/FormatDropdown.svelte @@ -136,9 +136,7 @@ ); if (imageSequence) { - // this is to allow image sequence -> video on vertd (soon:tm:) - // though i feel like this could also be done with ffmpeg-wasm in the browser? or mediabunny? - // TODO: image sequence locally w/ ffmpeg-wasm or mediabunny + // TODO: image sequence -> video on vertd? cats.push("video"); } else { // handle special cases diff --git a/src/lib/components/functional/popups/VertdError.svelte b/src/lib/components/functional/popups/VertdError.svelte index bc5634c..0ddace4 100644 --- a/src/lib/components/functional/popups/VertdError.svelte +++ b/src/lib/components/functional/popups/VertdError.svelte @@ -63,6 +63,7 @@ const showDetails = () => { addDialog( m["convert.errors.vertd.details.view"](), + // eslint-disable-next-line @typescript-eslint/no-explicit-any VertdErrorDetails as any, [ { diff --git a/src/lib/converters/magick/magick.svelte.ts b/src/lib/converters/magick/magick.svelte.ts index 043aaef..0368b3a 100644 --- a/src/lib/converters/magick/magick.svelte.ts +++ b/src/lib/converters/magick/magick.svelte.ts @@ -32,8 +32,6 @@ export class MagickConverter extends Converter { new FormatInfo("avif", true, true), new FormatInfo("heic", true, false), // seems to be unreliable? HEIC/HEIF is very weird if it will actually work new FormatInfo("heif", true, false), - // TODO: .ico files can encode multiple images at various - // sizes, bitdepths, etc. we should support that in future new FormatInfo("ico", true, true), new FormatInfo("bmp", true, true), new FormatInfo("cur", true, true), @@ -125,6 +123,33 @@ export class MagickConverter extends Converter { ): Promise { // 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 settings: SettingDefinition[] = []; + + let supportsMetadata = true; + let supportsTransparency = true; + + const toIcon = input.to === ".ico"; + + // TODO: surely there's a better way to do this lol + switch (input.from) { + case ".jpg": + case ".jpeg": + case ".jfif": + supportsTransparency = false; + break; + } + + switch (input.to) { + case ".ico": + supportsMetadata = false; + break; + + case ".jpg": + case ".jpeg": + case ".jfif": + supportsTransparency = false; + break; + } const quality: SettingDefinition = { key: "quality", @@ -134,7 +159,33 @@ export class MagickConverter extends Converter { min: 0, max: 100, }; + settings.push(quality); + // TODO: surely there's a better way to do this as well + const iconResolutions = [ + { value: "16x16", label: "16x16" }, + { value: "32x32", label: "32x32" }, + { value: "48x48", label: "48x48" }, + { value: "64x64", label: "64x64" }, + { value: "128x128", label: "128x128" }, + { value: "256x256", label: "256x256" }, + { value: "512x512", label: "512x512" }, + ]; + const commonResolutions = [ + { + value: "custom", + label: m["convert.settings.common.custom"](), + }, + { value: "426x240", label: "426x240" }, + { value: "640x360", label: "640x360" }, + { value: "854x480", label: "854x480" }, + { value: "720x1280", label: "720x1280 (V)" }, + { value: "1280x720", label: "1280x720" }, + { value: "1080x1920", label: "1080x1920 (V)" }, + { value: "1920x1080", label: "1920x1080" }, + { value: "2160x3840", label: "2160x3840 (V)" }, + { value: "3840x2160", label: "3840x2160" }, + ]; const resolution: SettingDefinition = { key: "resolution", label: m["convert.settings.video.resolution.label"](), @@ -142,24 +193,21 @@ export class MagickConverter extends Converter { default: "auto", options: [ { value: "auto", label: m["convert.settings.common.auto"]() }, - { - value: "custom", - label: m["convert.settings.common.custom"](), - }, - { value: "426x240", label: "426x240" }, - { value: "640x360", label: "640x360" }, - { value: "854x480", label: "854x480" }, - { value: "720x1280", label: "720x1280 (V)" }, - { value: "1280x720", label: "1280x720" }, - { value: "1080x1920", label: "1080x1920 (V)" }, - { value: "1920x1080", label: "1920x1080" }, - { value: "2160x3840", label: "2160x3840 (V)" }, - { value: "3840x2160", label: "3840x2160" }, + ...(toIcon ? iconResolutions : commonResolutions), ], hasCustomInput: true, customInputKey: "customResolution", placeholder: m["convert.settings.video.resolution.placeholder"](), }; + settings.push(resolution); + + const singleSize: SettingDefinition = { + key: "singleSize", + label: m["convert.settings.image.single_size"](), + type: "boolean", + default: false, + }; + if (toIcon) settings.push(singleSize); const depth: SettingDefinition = { key: "depth", @@ -174,6 +222,7 @@ export class MagickConverter extends Converter { { value: "32", label: "32-bit" }, ], }; + settings.push(depth); const colorSpace: SettingDefinition = { key: "colorSpace", @@ -193,21 +242,15 @@ export class MagickConverter extends Converter { { value: "gray", label: "Grayscale" }, ], }; + settings.push(colorSpace); - // TODO: check other formats for transparency support - const fromJpeg = - input.from === ".jpg" || - input.from === ".jpeg" || - input.from === ".jfif"; - const toJpeg = - input.to === ".jpg" || input.to === ".jpeg" || input.to === ".jfif"; const transparency: SettingDefinition = { key: "transparency", label: m["convert.settings.image.transparency"](), type: "boolean", default: true, - disabled: fromJpeg || toJpeg, }; + if (supportsTransparency) settings.push(transparency); const metadata: SettingDefinition = { key: "metadata", @@ -215,10 +258,11 @@ export class MagickConverter extends Converter { type: "boolean", default: global.metadata ?? true, }; + if (supportsMetadata) settings.push(metadata); // resize, crop, rotate - prob want a ui - return [quality, depth, colorSpace, resolution, transparency, metadata]; + return settings; } public async getDefaultSettings( diff --git a/src/lib/converters/vertd/vertd.svelte.ts b/src/lib/converters/vertd/vertd.svelte.ts index 615a027..7c38574 100644 --- a/src/lib/converters/vertd/vertd.svelte.ts +++ b/src/lib/converters/vertd/vertd.svelte.ts @@ -340,9 +340,9 @@ const downloadFile = async (url: string, file: VertFile): Promise => { }; // prettier-ignore -export const videoFormats = ["mp4", "mkv", "webm", "avi", "wmv", "mov", "gif", "apng", "webp", "mts", "ts", "m2ts", "mpg", "mpeg", "flv", "f4v", "vob", "m4v", "3gp", "3g2", "mxf", "ogv", "rm", "rmvb", "h264", "divx", "swf", "amv", "asf", "nut"]; -const cantEncode = ["rm", "rmvb"]; -const cantDecode = [""]; +export const videoFormats: string[] = ["mp4", "mkv", "webm", "avi", "wmv", "mov", "gif", "apng", "webp", "mts", "ts", "m2ts", "mpg", "mpeg", "flv", "f4v", "vob", "m4v", "3gp", "3g2", "mxf", "ogv", "rm", "rmvb", "h264", "divx", "swf", "amv", "asf", "nut"]; +const cantEncode: string[] = ["rm", "rmvb"]; +const cantDecode: string[] = []; export class VertdConverter extends Converter { public name = "vertd"; diff --git a/src/lib/workers/magick.ts b/src/lib/workers/magick.ts index 5ee755b..669cb96 100644 --- a/src/lib/workers/magick.ts +++ b/src/lib/workers/magick.ts @@ -282,9 +282,10 @@ const magickConvert = async ( ) => { let fmt = to.slice(1).toUpperCase(); if (fmt === "JFIF") fmt = "JPEG"; + const singleSize = Boolean(conversionSettings?.singleSize); const resolution = conversionSettings.resolution as string; - if (resolution && resolution !== "auto") { + if (!singleSize && resolution && resolution !== "auto") { const actualResolution = resolution === "custom" ? (conversionSettings.customResolution as string) @@ -299,19 +300,57 @@ const magickConvert = async ( } } - // ICO size clamp to avoid WidthOrHeightExceedsLimit - if (fmt === "ICO") { - const max = 256; - const w = img.width; - const h = img.height; + if (fmt === "ICO") && !singleSize) { + const standardSizes = [16, 24, 32, 48, 64, 128, 256, 512]; - if (w > max || h > max) { - const scale = max / Math.max(w, h); - const newW = Math.max(1, Math.round(w * scale)); - const newH = Math.max(1, Math.round(h * scale)); - - img.resize(newW, newH); + let desired = 0; + if (resolution && resolution !== "auto") { + const actualResolution = + resolution === "custom" + ? (conversionSettings.customResolution as string) + : resolution; + const [wsel, hsel] = (actualResolution || "") + .split("x") + .map((d: string) => parseInt(d) || 0); + desired = Math.max(wsel || 0, hsel || 0); + } else { + desired = Math.max(img.width || 0, img.height || 0); } + + if (desired <= 0) desired = Math.max(...standardSizes); + if (desired > Math.max(...standardSizes)) desired = Math.max(...standardSizes); + + const sizes = standardSizes.filter((s) => s <= desired); + if (sizes.length === 0) sizes.push(Math.min(...standardSizes)); + + const sourcePng = await new Promise((resolve) => { + img.write(MagickFormat.Png, (o: Uint8Array) => resolve(structuredClone(o))); + }); + + console.log(`encoding sizes for ico: ${sizes.join(", ")}`); + + return await new Promise((resolve) => { + MagickImageCollection.use((collection) => { + for (const size of sizes) { + const variant = MagickImage.create( + sourcePng, + new MagickReadSettings({ format: MagickFormat.Png }), + ); + + const scale = size / Math.max(variant.width, variant.height); + const newW = Math.max(1, Math.round(variant.width * scale)); + const newH = Math.max(1, Math.round(variant.height * scale)); + variant.resize(newW, newH); + + collection.push(variant); + console.log(`added size ${size}x${size} to MagickImageCollection`); + } + + collection.write(fmt as unknown as MagickFormat, (o: Uint8Array) => { + resolve(structuredClone(o)); + }); + }); + }); } const result = await new Promise((resolve, reject) => {