feat: encode multiple images for .ico

This commit is contained in:
Maya 2026-05-27 11:18:00 +03:00
parent 35ccc6f709
commit ee81d0d88f
No known key found for this signature in database
7 changed files with 127 additions and 43 deletions

View File

@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "vert", "name": "vert",

View File

@ -101,7 +101,8 @@
"quality": "Quality", "quality": "Quality",
"depth": "Color depth", "depth": "Color depth",
"color_space": "Color space", "color_space": "Color space",
"transparency": "Transparency" "transparency": "Transparency",
"single_size": "Single size image"
}, },
"audio": { "audio": {
"bitrate": { "bitrate": {

View File

@ -136,9 +136,7 @@
); );
if (imageSequence) { if (imageSequence) {
// this is to allow image sequence -> video on vertd (soon:tm:) // TODO: image sequence -> video on vertd?
// 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
cats.push("video"); cats.push("video");
} else { } else {
// handle special cases // handle special cases

View File

@ -63,6 +63,7 @@
const showDetails = () => { const showDetails = () => {
addDialog( addDialog(
m["convert.errors.vertd.details.view"](), m["convert.errors.vertd.details.view"](),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
VertdErrorDetails as any, VertdErrorDetails as any,
[ [
{ {

View File

@ -32,8 +32,6 @@ export class MagickConverter extends Converter {
new FormatInfo("avif", true, true), 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("heic", true, false), // seems to be unreliable? HEIC/HEIF is very weird if it will actually work
new FormatInfo("heif", true, false), 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("ico", true, true),
new FormatInfo("bmp", true, true), new FormatInfo("bmp", true, true),
new FormatInfo("cur", true, true), new FormatInfo("cur", true, true),
@ -125,6 +123,33 @@ export class MagickConverter extends Converter {
): Promise<SettingDefinition[]> { ): 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 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 = { const quality: SettingDefinition = {
key: "quality", key: "quality",
@ -134,7 +159,33 @@ export class MagickConverter extends Converter {
min: 0, min: 0,
max: 100, 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 = { const resolution: SettingDefinition = {
key: "resolution", key: "resolution",
label: m["convert.settings.video.resolution.label"](), label: m["convert.settings.video.resolution.label"](),
@ -142,24 +193,21 @@ export class MagickConverter extends Converter {
default: "auto", default: "auto",
options: [ options: [
{ value: "auto", label: m["convert.settings.common.auto"]() }, { value: "auto", label: m["convert.settings.common.auto"]() },
{ ...(toIcon ? iconResolutions : 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" },
], ],
hasCustomInput: true, hasCustomInput: true,
customInputKey: "customResolution", customInputKey: "customResolution",
placeholder: m["convert.settings.video.resolution.placeholder"](), 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 = { const depth: SettingDefinition = {
key: "depth", key: "depth",
@ -174,6 +222,7 @@ export class MagickConverter extends Converter {
{ value: "32", label: "32-bit" }, { value: "32", label: "32-bit" },
], ],
}; };
settings.push(depth);
const colorSpace: SettingDefinition = { const colorSpace: SettingDefinition = {
key: "colorSpace", key: "colorSpace",
@ -193,21 +242,15 @@ export class MagickConverter extends Converter {
{ value: "gray", label: "Grayscale" }, { 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 = { const transparency: SettingDefinition = {
key: "transparency", key: "transparency",
label: m["convert.settings.image.transparency"](), label: m["convert.settings.image.transparency"](),
type: "boolean", type: "boolean",
default: true, default: true,
disabled: fromJpeg || toJpeg,
}; };
if (supportsTransparency) settings.push(transparency);
const metadata: SettingDefinition = { const metadata: SettingDefinition = {
key: "metadata", key: "metadata",
@ -215,10 +258,11 @@ export class MagickConverter extends Converter {
type: "boolean", type: "boolean",
default: global.metadata ?? true, default: global.metadata ?? true,
}; };
if (supportsMetadata) settings.push(metadata);
// resize, crop, rotate - prob want a ui // resize, crop, rotate - prob want a ui
return [quality, depth, colorSpace, resolution, transparency, metadata]; return settings;
} }
public async getDefaultSettings( public async getDefaultSettings(

View File

@ -340,9 +340,9 @@ const downloadFile = async (url: string, file: VertFile): Promise<Blob> => {
}; };
// prettier-ignore // 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"]; 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 = ["rm", "rmvb"]; const cantEncode: string[] = ["rm", "rmvb"];
const cantDecode = [""]; const cantDecode: string[] = [];
export class VertdConverter extends Converter { export class VertdConverter extends Converter {
public name = "vertd"; public name = "vertd";

View File

@ -282,9 +282,10 @@ const magickConvert = async (
) => { ) => {
let fmt = to.slice(1).toUpperCase(); let fmt = to.slice(1).toUpperCase();
if (fmt === "JFIF") fmt = "JPEG"; if (fmt === "JFIF") fmt = "JPEG";
const singleSize = Boolean(conversionSettings?.singleSize);
const resolution = conversionSettings.resolution as string; const resolution = conversionSettings.resolution as string;
if (resolution && resolution !== "auto") { if (!singleSize && resolution && resolution !== "auto") {
const actualResolution = const actualResolution =
resolution === "custom" resolution === "custom"
? (conversionSettings.customResolution as string) ? (conversionSettings.customResolution as string)
@ -299,19 +300,57 @@ const magickConvert = async (
} }
} }
// ICO size clamp to avoid WidthOrHeightExceedsLimit if (fmt === "ICO") && !singleSize) {
if (fmt === "ICO") { const standardSizes = [16, 24, 32, 48, 64, 128, 256, 512];
const max = 256;
const w = img.width;
const h = img.height;
if (w > max || h > max) { let desired = 0;
const scale = max / Math.max(w, h); if (resolution && resolution !== "auto") {
const newW = Math.max(1, Math.round(w * scale)); const actualResolution =
const newH = Math.max(1, Math.round(h * scale)); resolution === "custom"
? (conversionSettings.customResolution as string)
img.resize(newW, newH); : 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<Uint8Array>((resolve) => {
img.write(MagickFormat.Png, (o: Uint8Array) => resolve(structuredClone(o)));
});
console.log(`encoding sizes for ico: ${sizes.join(", ")}`);
return await new Promise<Uint8Array>((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<Uint8Array>((resolve, reject) => { const result = await new Promise<Uint8Array>((resolve, reject) => {