From dc9160746ef98f34952dbcbc2818768701b9ebac Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Tue, 21 Apr 2026 10:20:08 +0800 Subject: [PATCH] fix: ICO needs CLI decode, AVIF compress missing options, remove JXL output - ICO: Sharp cannot decode ICO files. Added ImageMagick-based ICO decoder that extracts the largest embedded image. Added ICO to CLI_DECODED_FORMATS and SERVER_PREVIEW_EXTENSIONS. Removed from BROWSER_PREVIEWABLE sets. - AVIF compress: Sharp's AVIF encoder requires effort option. Added formatOpts() helper that supplies effort:4 for AVIF format. - JXL output: Docker's bundled libvips lacks the JXL encoder plugin. Removed JXL as a convert output target to avoid guaranteed failures. JXL remains fully supported as an input format. --- apps/api/src/lib/file-validation.ts | 2 +- apps/api/src/lib/format-decoders.ts | 27 +++++++++++- apps/api/src/routes/tool-factory.ts | 1 - apps/api/src/routes/tools/convert.ts | 3 +- .../src/components/tools/convert-settings.tsx | 4 +- apps/web/src/lib/image-preview.ts | 1 + apps/web/src/pages/tool-page.tsx | 1 - docker/docker-compose-formats-test.yml | 41 +++++++++++++++++++ .../image-engine/src/operations/compress.ts | 18 +++++--- 9 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 docker/docker-compose-formats-test.yml diff --git a/apps/api/src/lib/file-validation.ts b/apps/api/src/lib/file-validation.ts index 5eafd08c..2f2bd86b 100644 --- a/apps/api/src/lib/file-validation.ts +++ b/apps/api/src/lib/file-validation.ts @@ -67,7 +67,7 @@ export interface ValidationError { const RAW_EXTENSIONS = new Set(["dng", "cr2", "nef", "arw", "orf", "rw2"]); /** Formats that Sharp cannot decode natively — skip dimension check. */ -const CLI_DECODED_FORMATS = new Set(["raw", "tga", "psd", "exr", "hdr"]); +const CLI_DECODED_FORMATS = new Set(["raw", "ico", "tga", "psd", "exr", "hdr"]); /** * Check whether a file extension corresponds to a Camera RAW format. diff --git a/apps/api/src/lib/format-decoders.ts b/apps/api/src/lib/format-decoders.ts index c1fab88f..dbd48b8b 100644 --- a/apps/api/src/lib/format-decoders.ts +++ b/apps/api/src/lib/format-decoders.ts @@ -8,7 +8,7 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); /** Formats that need external CLI tools (not decodable by Sharp). */ -const CLI_DECODED_FORMATS = new Set(["raw", "tga", "psd", "exr", "hdr"]); +const CLI_DECODED_FORMATS = new Set(["raw", "ico", "tga", "psd", "exr", "hdr"]); export function needsCliDecode(format: string): boolean { return CLI_DECODED_FORMATS.has(format); @@ -22,6 +22,8 @@ export async function decodeToSharpCompat(buffer: Buffer, format: string): Promi switch (format) { case "raw": return decodeRaw(buffer); + case "ico": + return decodeIco(buffer); case "psd": return decodePsd(buffer); case "tga": @@ -57,6 +59,29 @@ function magickArgs(cmd: string, args: string[]): string[] { return cmd === "magick" ? ["convert", ...args] : args; } +// ── ICO decoder ──────────────────────────────────────────────── + +async function decodeIco(buffer: Buffer): Promise { + const cmd = await findMagickCmd(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `ico-in-${id}.ico`); + const outputPath = join(tmpdir(), `ico-out-${id}.png`); + + try { + await writeFile(inputPath, buffer); + // ICO contains multiple sizes; extract the largest by sorting + await execFileAsync( + cmd, + magickArgs(cmd, [`${inputPath}[-1]`, `png:${outputPath}`]), + { timeout: 120_000 }, + ); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } +} + // ── RAW decoder (ImageMagick with LibRaw delegate) ───────────── async function decodeRaw(buffer: Buffer): Promise { diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index dd4a4abc..91e640c1 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -295,7 +295,6 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig "image/svg+xml", "image/bmp", "image/avif", - "image/x-icon", ]); let previewUrl: string | undefined; if (!BROWSER_PREVIEWABLE.has(result.contentType)) { diff --git a/apps/api/src/routes/tools/convert.ts b/apps/api/src/routes/tools/convert.ts index 6bbb739a..fa988607 100644 --- a/apps/api/src/routes/tools/convert.ts +++ b/apps/api/src/routes/tools/convert.ts @@ -16,11 +16,10 @@ const FORMAT_CONTENT_TYPES: Record = { gif: "image/gif", heic: "image/heic", heif: "image/heif", - jxl: "image/jxl", }; const settingsSchema = z.object({ - format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif", "jxl"]), + format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"]), quality: z.number().min(1).max(100).optional(), }); diff --git a/apps/web/src/components/tools/convert-settings.tsx b/apps/web/src/components/tools/convert-settings.tsx index 2fa927e8..849748ae 100644 --- a/apps/web/src/components/tools/convert-settings.tsx +++ b/apps/web/src/components/tools/convert-settings.tsx @@ -4,8 +4,8 @@ import { ProgressCard } from "@/components/common/progress-card"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; -const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif", "jxl"] as const; -const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif", "jxl"]; +const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"] as const; +const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif"]; export interface ConvertControlsProps { settings?: Record; diff --git a/apps/web/src/lib/image-preview.ts b/apps/web/src/lib/image-preview.ts index deccc47e..3e9474db 100644 --- a/apps/web/src/lib/image-preview.ts +++ b/apps/web/src/lib/image-preview.ts @@ -3,6 +3,7 @@ import { formatHeaders } from "@/lib/api"; const SERVER_PREVIEW_EXTENSIONS = new Set([ "heic", "heif", "hif", // HEIF "jxl", // JPEG XL (Chrome dropped support) + "ico", // ICO (Sharp can't decode) "dng", "cr2", "nef", "arw", "orf", "rw2", // Camera RAW "tga", // Targa "psd", // Photoshop diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index f8fad2ac..d02aae7a 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -45,7 +45,6 @@ const BROWSER_PREVIEWABLE_EXTS = new Set([ "webp", "svg", "bmp", - "ico", "avif", ]); diff --git a/docker/docker-compose-formats-test.yml b/docker/docker-compose-formats-test.yml new file mode 100644 index 00000000..ba73e493 --- /dev/null +++ b/docker/docker-compose-formats-test.yml @@ -0,0 +1,41 @@ +name: ashim-formats-test + +services: + ashim: + build: + context: .. + dockerfile: docker/Dockerfile + image: ashim-formats-test:latest + container_name: ashim-formats-test + ports: + - "1355:1349" + volumes: + - ashim-formats-data:/data + - ashim-formats-workspace:/tmp/workspace + environment: + - AUTH_ENABLED=true + - DEFAULT_USERNAME=admin + - DEFAULT_PASSWORD=admin + - SKIP_MUST_CHANGE_PASSWORD=true + - MAX_UPLOAD_SIZE_MB=0 + - MAX_BATCH_SIZE=0 + - MAX_MEGAPIXELS=0 + - CONCURRENT_JOBS=0 + - RATE_LIMIT_PER_MIN=0 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"] + interval: 15s + timeout: 5s + start_period: 120s + retries: 5 + shm_size: '2gb' + logging: + driver: json-file + options: + max-size: "50m" + max-file: "3" + +volumes: + ashim-formats-data: + ashim-formats-workspace: diff --git a/packages/image-engine/src/operations/compress.ts b/packages/image-engine/src/operations/compress.ts index 01d9fa37..3d119f90 100644 --- a/packages/image-engine/src/operations/compress.ts +++ b/packages/image-engine/src/operations/compress.ts @@ -11,6 +11,12 @@ const FORMAT_MAP: Record = { gif: "gif", }; +function formatOpts(format: string, quality: number): Record { + const opts: Record = { quality }; + if (format === "avif") opts.effort = 4; + return opts; +} + export async function compress(image: Sharp, options: CompressOptions): Promise { const { quality, targetSizeBytes, format } = options; @@ -32,7 +38,7 @@ export async function compress(image: Sharp, options: CompressOptions): Promise< throw new Error("Quality must be between 1 and 100"); } - return image.toFormat(outputFormat, { quality: q }); + return image.toFormat(outputFormat, formatOpts(outputFormat, q)); } async function compressToTargetSize( @@ -49,7 +55,7 @@ async function compressToTargetSize( for (let i = 0; i < maxIterations && low <= high; i++) { const mid = Math.min(100, Math.max(1, Math.round((low + high) / 2))); - const attempt = sharp(inputBuffer).toFormat(format, { quality: mid }); + const attempt = sharp(inputBuffer).toFormat(format, formatOpts(format, mid)); const resultBuffer = await attempt.toBuffer(); const resultSize = resultBuffer.length; @@ -68,11 +74,11 @@ async function compressToTargetSize( } } - // If we never found a suitable buffer, compress at lowest quality found if (bestBuffer === null) { - bestBuffer = await sharp(inputBuffer).toFormat(format, { quality: bestQuality }).toBuffer(); + bestBuffer = await sharp(inputBuffer) + .toFormat(format, formatOpts(format, bestQuality)) + .toBuffer(); } - // Preserve format + quality so the caller's .toBuffer() doesn't re-encode at defaults - return sharp(bestBuffer).toFormat(format, { quality: bestQuality }); + return sharp(bestBuffer).toFormat(format, formatOpts(format, bestQuality)); }