From 3a02affae34dd303ac715ad0d1284b3fc6fcfc15 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Fri, 8 May 2026 21:06:29 +0800 Subject: [PATCH] feat: recover AVIF/TIFF/GIF/JXL/PSD export from orphaned commit Recovers expanded export format support from orphaned commit 80961c01 and PSD export from b07ecd5. The editor export dialog now supports 7 formats (PNG, JPEG, WebP client-side; AVIF, TIFF, GIF, JXL via server conversion). PSD export uses ImageMagick on the backend. --- apps/api/src/routes/tools/convert.ts | 53 ++++++++++-- .../editor/common/export-dialog.tsx | 83 ++++++++++++++----- 2 files changed, 109 insertions(+), 27 deletions(-) diff --git a/apps/api/src/routes/tools/convert.ts b/apps/api/src/routes/tools/convert.ts index d0dd7925..eb3ce284 100644 --- a/apps/api/src/routes/tools/convert.ts +++ b/apps/api/src/routes/tools/convert.ts @@ -1,4 +1,9 @@ -import { extname } from "node:path"; +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { extname, join } from "node:path"; +import { promisify } from "node:util"; import { convert } from "@snapotter/image-engine"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; @@ -7,6 +12,28 @@ import { encodeHeic } from "../../lib/heic-converter.js"; import { isSvgBuffer } from "../../lib/svg-sanitize.js"; import { createToolRoute } from "../tool-factory.js"; +const execFileAsync = promisify(execFile); + +let cachedMagickCmd: string | null = null; + +async function findMagickCmd(): Promise { + if (cachedMagickCmd) return cachedMagickCmd; + for (const cmd of ["magick", "convert"]) { + try { + await execFileAsync(cmd, ["--version"], { timeout: 5_000 }); + cachedMagickCmd = cmd; + return cmd; + } catch { + // try next + } + } + throw new Error("No ImageMagick found. Install imagemagick (provides convert/magick)."); +} + +function magickArgs(cmd: string, args: string[]): string[] { + return cmd === "magick" ? ["convert", ...args] : args; +} + const FORMAT_CONTENT_TYPES: Record = { jpg: "image/jpeg", png: "image/png", @@ -16,10 +43,11 @@ const FORMAT_CONTENT_TYPES: Record = { gif: "image/gif", heic: "image/heic", heif: "image/heif", + psd: "image/vnd.adobe.photoshop", }; const settingsSchema = z.object({ - format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"]), + format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif", "psd"]), quality: z.number().min(1).max(100).optional(), }); @@ -32,12 +60,27 @@ export function registerConvert(app: FastifyInstance) { const image = sharp(inputBuffer, sharpOpts); let buffer: Buffer; - if (settings.format === "heic" || settings.format === "heif") { - // Sharp cannot encode HEVC. Convert to PNG first, then use heif-enc. + if (settings.format === "psd") { + const pngBuffer = await image.png().toBuffer(); + const id = randomUUID(); + const inputPath = join(tmpdir(), `psd-enc-in-${id}.png`); + const outputPath = join(tmpdir(), `psd-enc-out-${id}.psd`); + try { + await writeFile(inputPath, pngBuffer); + const cmd = await findMagickCmd(); + await execFileAsync(cmd, magickArgs(cmd, [inputPath, `psd:${outputPath}`]), { + timeout: 120_000, + }); + buffer = await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } + } else if (settings.format === "heic" || settings.format === "heif") { const pngBuffer = await image.png().toBuffer(); buffer = await encodeHeic(pngBuffer, settings.quality); } else { - const result = await convert(image, settings); + const result = await convert(image, settings as Parameters[1]); buffer = await result.toBuffer(); } diff --git a/apps/web/src/components/editor/common/export-dialog.tsx b/apps/web/src/components/editor/common/export-dialog.tsx index 0f076f6c..5558d8f4 100644 --- a/apps/web/src/components/editor/common/export-dialog.tsx +++ b/apps/web/src/components/editor/common/export-dialog.tsx @@ -23,7 +23,7 @@ import type { Guide, } from "@/types/editor"; -type ExportFormat = "png" | "jpeg" | "webp"; +type ExportFormat = "png" | "jpeg" | "webp" | "avif" | "tiff" | "gif" | "jxl"; interface ExportSettings { format: ExportFormat; @@ -34,10 +34,19 @@ interface ExportSettings { transparent: boolean; } -const FORMAT_OPTIONS: { value: ExportFormat; label: string; supportsTransparency: boolean }[] = [ - { value: "png", label: "PNG", supportsTransparency: true }, - { value: "jpeg", label: "JPEG", supportsTransparency: false }, - { value: "webp", label: "WebP", supportsTransparency: true }, +const FORMAT_OPTIONS: { + value: ExportFormat; + label: string; + supportsTransparency: boolean; + needsServerConvert: boolean; +}[] = [ + { value: "png", label: "PNG", supportsTransparency: true, needsServerConvert: false }, + { value: "jpeg", label: "JPEG", supportsTransparency: false, needsServerConvert: false }, + { value: "webp", label: "WebP", supportsTransparency: true, needsServerConvert: false }, + { value: "avif", label: "AVIF", supportsTransparency: true, needsServerConvert: true }, + { value: "tiff", label: "TIFF", supportsTransparency: true, needsServerConvert: true }, + { value: "gif", label: "GIF", supportsTransparency: true, needsServerConvert: true }, + { value: "jxl", label: "JXL", supportsTransparency: true, needsServerConvert: true }, ]; function getMimeType(format: ExportFormat): string { @@ -48,6 +57,8 @@ function getMimeType(format: ExportFormat): string { return "image/jpeg"; case "webp": return "image/webp"; + default: + return "image/png"; } } @@ -169,23 +180,51 @@ export function ExportDialog({ onClose }: { onClose: () => void }) { }); } - // Convert data URL to blob for download - fetch(dataUrl) - .then((res) => res.blob()) - .then((blob) => { - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `export.${settings.format}`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - markClean(); - }) - .catch((err) => { - console.error("Export failed:", err); - }); + const formatOpt = FORMAT_OPTIONS.find((f) => f.value === settings.format); + if (formatOpt?.needsServerConvert) { + fetch(dataUrl) + .then((res) => res.blob()) + .then(async (pngBlob) => { + const formData = new FormData(); + formData.append("file", pngBlob, "export.png"); + formData.append( + "settings", + JSON.stringify({ format: settings.format, quality: settings.quality }), + ); + const res = await fetch("/api/v1/tools/convert", { method: "POST", body: formData }); + if (!res.ok) throw new Error("Server conversion failed"); + const json = await res.json(); + if (json.downloadUrl) { + const a = document.createElement("a"); + a.href = json.downloadUrl; + a.download = `export.${settings.format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + markClean(); + } + }) + .catch((err) => { + console.error("Export failed:", err); + }); + } else { + fetch(dataUrl) + .then((res) => res.blob()) + .then((blob) => { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `export.${settings.format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + markClean(); + }) + .catch((err) => { + console.error("Export failed:", err); + }); + } }, [settings, canvasSize, markClean]); // Issue #6: Copy to clipboard using Konva stage