mirror of https://github.com/VERT-sh/VERT.git
feat: encode multiple images for .ico
This commit is contained in:
parent
35ccc6f709
commit
ee81d0d88f
1
bun.lock
1
bun.lock
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "vert",
|
"name": "vert",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue