feat: add JXL to all tool format selectors, expand convert with BMP/ICO/JP2/QOI, add server-side editor export

This commit is contained in:
SnapOtter
2026-05-08 00:19:19 +08:00
parent 390b5adb8c
commit 80961c0187
28 changed files with 282 additions and 65 deletions
+5 -1
View File
@@ -333,7 +333,7 @@ const settingsSchema = z.object({
cornerRadius: z.number().min(0).max(500).default(0),
backgroundColor: z.string().default("#FFFFFF"),
aspectRatio: z.string().default("free"),
outputFormat: z.enum(["png", "jpeg", "webp", "avif"]).default("png"),
outputFormat: z.enum(["png", "jpeg", "webp", "avif", "jxl"]).default("png"),
quality: z.number().min(1).max(100).default(90),
});
@@ -642,6 +642,10 @@ export function registerCollage(app: FastifyInstance) {
pipeline = pipeline.avif({ quality: settings.quality, effort: 4 });
outputExt = "avif";
break;
case "jxl":
pipeline = pipeline.jxl({ quality: settings.quality });
outputExt = "jxl";
break;
default:
pipeline = pipeline.png();
outputExt = "png";
+42 -1
View File
@@ -3,6 +3,7 @@ import { convert } from "@snapotter/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { encodeBmp, encodeIco, encodeJp2, encodeQoi } from "../../lib/format-encoders.js";
import { encodeHeic } from "../../lib/heic-converter.js";
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
import { createToolRoute } from "../tool-factory.js";
@@ -16,10 +17,36 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
gif: "image/gif",
heic: "image/heic",
heif: "image/heif",
jxl: "image/jxl",
bmp: "image/bmp",
ico: "image/x-icon",
jp2: "image/jp2",
qoi: "image/x-qoi",
};
const CLI_ENCODERS: Record<string, (buf: Buffer, quality?: number) => Promise<Buffer>> = {
bmp: encodeBmp,
ico: encodeIco,
jp2: encodeJp2,
qoi: encodeQoi,
};
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",
"jxl",
"bmp",
"ico",
"jp2",
"qoi",
]),
quality: z.number().min(1).max(100).optional(),
});
@@ -28,6 +55,20 @@ export function registerConvert(app: FastifyInstance) {
toolId: "convert",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
// CLI-encoded formats bypass Sharp entirely
const cliEncoder = CLI_ENCODERS[settings.format];
if (cliEncoder) {
const outputBuffer = await cliEncoder(inputBuffer, settings.quality);
const ext = extname(filename);
const baseName = ext ? filename.slice(0, -ext.length) : filename;
const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
return {
buffer: outputBuffer,
filename: `${baseName}.${settings.format}`,
contentType,
};
}
const sharpOpts = isSvgBuffer(inputBuffer) ? { density: 300 } : undefined;
const image = sharp(inputBuffer, sharpOpts);
+6 -2
View File
@@ -26,13 +26,14 @@ const EXT_MAP: Record<string, string> = {
avif: "avif",
heic: "heic",
heif: "heif",
jxl: "jxl",
};
const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]);
const settingsSchema = z.object({
format: z
.enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif"])
.enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif", "jxl"])
.default("auto"),
quality: z.number().int().min(1).max(100).default(95),
});
@@ -203,7 +204,7 @@ export function registerEraseObject(app: FastifyInstance) {
);
// Convert to the requested output format using Sharp
const needsNodeConversion = ["heic", "heif", "avif"].includes(format);
const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
let outputBuffer: Buffer;
let finalFormat = format;
@@ -211,6 +212,9 @@ export function registerEraseObject(app: FastifyInstance) {
if (format === "heic" || format === "heif") {
outputBuffer = await encodeHeic(resultBuffer, quality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await sharp(resultBuffer).jxl({ quality }).toBuffer();
finalFormat = "jxl";
} else {
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
finalFormat = "avif";
+5 -1
View File
@@ -6,7 +6,7 @@ import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
outputFormat: z.enum(["original", "jpeg", "png", "webp", "avif"]).default("original"),
outputFormat: z.enum(["original", "jpeg", "png", "webp", "avif", "jxl"]).default("original"),
quality: z.number().int().min(1).max(100).default(80),
maxWidth: z.number().int().min(0).default(0),
maxHeight: z.number().int().min(0).default(0),
@@ -145,6 +145,10 @@ export function registerImageToBase64(app: FastifyInstance) {
outputBuffer = await pipeline.avif({ quality: opts.quality, effort: 4 }).toBuffer();
mimeType = "image/avif";
break;
case "jxl":
outputBuffer = await pipeline.jxl({ quality: opts.quality }).toBuffer();
mimeType = "image/jxl";
break;
default:
outputBuffer = await pipeline.toBuffer();
mimeType = detectMimeType(ext);
+2 -2
View File
@@ -21,7 +21,7 @@ const settingsSchema = z.object({
strength: z.union([z.number(), z.string()]).transform(Number).default(50),
detailPreservation: z.union([z.number(), z.string()]).transform(Number).default(50),
colorNoise: z.union([z.number(), z.string()]).transform(Number).default(30),
format: z.enum(["original", "png", "jpeg", "webp", "avif"]).default("original"),
format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"),
quality: z.union([z.number(), z.string()]).transform(Number).default(90),
});
@@ -198,7 +198,7 @@ export function registerNoiseRemoval(app: FastifyInstance) {
strength: z.union([z.number(), z.string()]).transform(Number).default(50),
detailPreservation: z.union([z.number(), z.string()]).transform(Number).default(50),
colorNoise: z.union([z.number(), z.string()]).transform(Number).default(30),
format: z.enum(["original", "png", "jpeg", "webp", "avif"]).default("original"),
format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"),
quality: z.union([z.number(), z.string()]).transform(Number).default(90),
}),
process: async (inputBuffer, settings, filename) => {
@@ -17,6 +17,7 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
jpeg: "image/jpeg",
avif: "image/avif",
png: "image/png",
jxl: "image/jxl",
};
const FORMAT_EXTENSIONS: Record<string, string> = {
@@ -24,10 +25,11 @@ const FORMAT_EXTENSIONS: Record<string, string> = {
jpeg: "jpg",
avif: "avif",
png: "png",
jxl: "jxl",
};
const settingsSchema = z.object({
format: z.enum(["webp", "jpeg", "avif", "png"]).default("webp"),
format: z.enum(["webp", "jpeg", "avif", "png", "jxl"]).default("webp"),
quality: z.number().min(1).max(100).default(80),
maxWidth: z.number().positive().optional(),
maxHeight: z.number().positive().optional(),
+6 -1
View File
@@ -14,7 +14,9 @@ import { createWorkspace } from "../../lib/workspace.js";
// ── Settings schema ──────────────────────────────────────────────
const settingsSchema = z.object({
format: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif"]).default("png"),
format: z
.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif", "jxl"])
.default("png"),
dpi: z.number().min(36).max(2400).default(150),
quality: z.number().min(1).max(100).default(85),
colorMode: z.enum(["color", "grayscale", "bw"]).default("color"),
@@ -86,6 +88,7 @@ const FORMAT_EXT: Record<string, string> = {
gif: ".gif",
heic: ".heic",
heif: ".heif",
jxl: ".jxl",
};
async function convertWithSharp(
@@ -114,6 +117,8 @@ async function convertWithSharp(
return s.tiff().toBuffer();
case "gif":
return s.gif().toBuffer();
case "jxl":
return s.jxl({ quality }).toBuffer();
case "heic":
case "heif": {
const pngBuf = await s.png().toBuffer();
+2 -1
View File
@@ -15,7 +15,7 @@ const settingsSchema = z.object({
rows: z.number().min(1).max(100).default(3),
tileWidth: z.number().min(10).optional(),
tileHeight: z.number().min(10).optional(),
outputFormat: z.enum(["original", "png", "jpg", "webp", "avif"]).default("original"),
outputFormat: z.enum(["original", "png", "jpg", "webp", "avif", "jxl"]).default("original"),
quality: z.number().min(1).max(100).default(90),
});
@@ -31,6 +31,7 @@ function resolveOutputFormat(
jpg: { sharpFormat: "jpeg", ext: ".jpg" },
webp: { sharpFormat: "webp", ext: ".webp" },
avif: { sharpFormat: "avif", ext: ".avif" },
jxl: { sharpFormat: "jxl", ext: ".jxl" },
};
return map[outputFormat] ?? { sharpFormat: null, ext: originalExt };
}
+5 -1
View File
@@ -24,7 +24,7 @@ const settingsSchema = z.object({
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default("#FFFFFF"),
format: z.enum(["png", "jpeg", "webp", "avif"]).default("png"),
format: z.enum(["png", "jpeg", "webp", "avif", "jxl"]).default("png"),
quality: z.number().min(1).max(100).default(90),
});
@@ -203,6 +203,8 @@ export function registerStitch(app: FastifyInstance) {
pipeline = pipeline.webp({ quality: settings.quality });
} else if (settings.format === "avif") {
pipeline = pipeline.avif({ quality: settings.quality, effort: 4 });
} else if (settings.format === "jxl") {
pipeline = pipeline.jxl({ quality: settings.quality });
} else {
pipeline = pipeline.png();
}
@@ -235,6 +237,8 @@ export function registerStitch(app: FastifyInstance) {
result = await sharp(result).webp({ quality: settings.quality }).toBuffer();
} else if (settings.format === "avif") {
result = await sharp(result).avif({ quality: settings.quality, effort: 4 }).toBuffer();
} else if (settings.format === "jxl") {
result = await sharp(result).jxl({ quality: settings.quality }).toBuffer();
}
}
+5 -1
View File
@@ -26,7 +26,7 @@ const settingsSchema = z.object({
.string()
.regex(/^#[0-9a-fA-F]{6,8}$/)
.default("#00000000"),
outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif"]).default("png"),
outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif", "jxl"]).default("png"),
});
interface ParsedSvgFile {
@@ -77,6 +77,10 @@ async function convertSvg(
buffer = await image.gif().toBuffer();
ext = "gif";
break;
case "jxl":
buffer = await image.jxl({ quality: settings.quality }).toBuffer();
ext = "jxl";
break;
case "heif": {
const pngBuffer = await image.png().toBuffer();
buffer = await encodeHeic(pngBuffer, settings.quality);
+5 -1
View File
@@ -158,7 +158,7 @@ export function registerUpscale(app: FastifyInstance) {
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const needsNodeConversion = ["heic", "heif", "avif"].includes(format);
const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
const pythonFormat = needsNodeConversion ? "png" : format;
const onProgress = (percent: number, stage: string) => {
@@ -185,6 +185,9 @@ export function registerUpscale(app: FastifyInstance) {
if (format === "heic" || format === "heif") {
outputBuffer = await encodeHeic(result.buffer, outputQuality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await sharp(result.buffer).jxl({ quality: outputQuality }).toBuffer();
finalFormat = "jxl";
} else if (format === "avif") {
outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer();
finalFormat = "avif";
@@ -201,6 +204,7 @@ export function registerUpscale(app: FastifyInstance) {
avif: "avif",
heic: "heic",
heif: "heif",
jxl: "jxl",
};
const ext = EXT_MAP[finalFormat] || "png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`;
@@ -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,21 +34,32 @@ 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 {
switch (format) {
case "png":
return "image/png";
case "jpeg":
return "image/jpeg";
case "webp":
return "image/webp";
}
const mimes: Record<ExportFormat, string> = {
png: "image/png",
jpeg: "image/jpeg",
webp: "image/webp",
avif: "image/avif",
tiff: "image/tiff",
gif: "image/gif",
jxl: "image/jxl",
};
return mimes[format];
}
export function ExportDialog({ onClose }: { onClose: () => void }) {
@@ -77,9 +88,14 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
const maxPreview = 200;
const scale = Math.min(maxPreview / canvasSize.width, maxPreview / canvasSize.height);
// For server-convert formats the Canvas API cannot produce a preview,
// so fall back to PNG for the thumbnail.
const fmtOpt = FORMAT_OPTIONS.find((o) => o.value === settings.format);
const previewMime = fmtOpt?.needsServerConvert ? "image/png" : getMimeType(settings.format);
const url = stage.toDataURL({
pixelRatio: scale,
mimeType: getMimeType(settings.format),
mimeType: previewMime,
quality: settings.quality / 100,
x: 0,
y: 0,
@@ -134,6 +150,69 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
if (!stage) return;
const pixelRatio = settings.width / canvasSize.width;
// Server-side convert for formats the Canvas API cannot produce
const formatOption = FORMAT_OPTIONS.find((o) => o.value === settings.format);
if (formatOption?.needsServerConvert) {
let stageCanvas: HTMLCanvasElement;
if (!settings.transparent || settings.format === "jpeg") {
const raw = stage.toCanvas({
pixelRatio,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
const exportCanvas = document.createElement("canvas");
exportCanvas.width = raw.width;
exportCanvas.height = raw.height;
const ctx = exportCanvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
ctx.drawImage(raw, 0, 0);
stageCanvas = exportCanvas;
} else {
stageCanvas = stage.toCanvas({
pixelRatio,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
}
stageCanvas.toBlob(async (blob) => {
if (!blob) return;
const formData = new FormData();
formData.append("file", blob, "export.png");
formData.append(
"settings",
JSON.stringify({ format: settings.format, quality: settings.quality }),
);
try {
const res = await fetch("/api/v1/tools/convert", {
method: "POST",
body: formData,
});
if (!res.ok) throw new Error("Server convert 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("Server-side export failed:", err);
}
}, "image/png");
return;
}
let dataUrl: string;
if (!settings.transparent || settings.format === "jpeg") {
@@ -306,7 +385,7 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
[onClose],
);
const supportsQuality = settings.format === "jpeg" || settings.format === "webp";
const supportsQuality = settings.format !== "png";
const supportsTransparency = settings.format !== "jpeg";
return (
@@ -26,6 +26,7 @@ const OUTPUT_FORMATS: { value: OutputFormat; label: string }[] = [
{ value: "jpeg", label: "JPEG" },
{ value: "webp", label: "WebP" },
{ value: "avif", label: "AVIF" },
{ value: "jxl", label: "JXL" },
];
const BG_PRESETS = [
@@ -4,8 +4,38 @@ 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"] as const;
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif"];
const OUTPUT_FORMATS = [
"jpg",
"png",
"webp",
"avif",
"tiff",
"gif",
"heic",
"heif",
"jxl",
"bmp",
"ico",
"jp2",
"qoi",
] as const;
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif", "jxl", "jp2"];
const FORMAT_LABELS: Record<string, string> = {
jpg: "JPG",
png: "PNG",
webp: "WebP",
avif: "AVIF",
tiff: "TIFF",
gif: "GIF",
heic: "HEIC",
heif: "HEIF",
jxl: "JXL",
bmp: "BMP",
ico: "ICO",
jp2: "JP2",
qoi: "QOI",
};
export interface ConvertControlsProps {
settings?: Record<string, unknown>;
@@ -54,7 +84,7 @@ export function ConvertControls({ settings: initialSettings, onChange }: Convert
>
{OUTPUT_FORMATS.map((f) => (
<option key={f} value={f}>
{f.toUpperCase()}
{FORMAT_LABELS[f] ?? f.toUpperCase()}
</option>
))}
</select>
@@ -6,8 +6,18 @@ import { generateId } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
import type { EraserCanvasRef } from "./eraser-canvas";
const OUTPUT_FORMATS = ["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif"] as const;
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif"];
const OUTPUT_FORMATS = [
"png",
"jpg",
"webp",
"avif",
"tiff",
"gif",
"heic",
"heif",
"jxl",
] as const;
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif", "jxl"];
interface EraseObjectSettingsProps {
eraserRef: React.RefObject<EraserCanvasRef | null>;
@@ -10,6 +10,7 @@ const OUTPUT_FORMATS = [
{ value: "png", label: "PNG" },
{ value: "webp", label: "WebP" },
{ value: "avif", label: "AVIF" },
{ value: "jxl", label: "JXL" },
] as const;
export function ImageToBase64Settings() {
@@ -69,7 +70,11 @@ export function ImageToBase64Settings() {
};
const hasFiles = files.length > 0;
const showQuality = outputFormat === "jpeg" || outputFormat === "webp" || outputFormat === "avif";
const showQuality =
outputFormat === "jpeg" ||
outputFormat === "webp" ||
outputFormat === "avif" ||
outputFormat === "jxl";
return (
<div className="space-y-4">
@@ -13,7 +13,7 @@ const TIERS: { id: Tier; label: string; desc: string }[] = [
{ id: "maximum", label: "Maximum", desc: "Best AI model, slowest" },
];
const LOSSY_FORMATS = new Set(["jpeg", "webp", "avif"]);
const LOSSY_FORMATS = new Set(["jpeg", "webp", "avif", "jxl"]);
export interface NoiseRemovalControlsProps {
settings?: Record<string, unknown>;
@@ -28,9 +28,9 @@ export function NoiseRemovalControls({
const [strength, setStrength] = useState(50);
const [detailPreservation, setDetailPreservation] = useState(50);
const [colorNoise, setColorNoise] = useState(30);
const [outputFormat, setOutputFormat] = useState<"original" | "png" | "jpeg" | "webp" | "avif">(
"original",
);
const [outputFormat, setOutputFormat] = useState<
"original" | "png" | "jpeg" | "webp" | "avif" | "jxl"
>("original");
const [quality, setQuality] = useState(90);
// One-time init from pipeline settings
@@ -44,7 +44,9 @@ export function NoiseRemovalControls({
setDetailPreservation(Number(initialSettings.detailPreservation));
if (initialSettings.colorNoise != null) setColorNoise(Number(initialSettings.colorNoise));
if (initialSettings.format != null)
setOutputFormat(initialSettings.format as "original" | "png" | "jpeg" | "webp" | "avif");
setOutputFormat(
initialSettings.format as "original" | "png" | "jpeg" | "webp" | "avif" | "jxl",
);
if (initialSettings.quality != null) setQuality(Number(initialSettings.quality));
}, [initialSettings]);
@@ -164,8 +166,8 @@ export function NoiseRemovalControls({
{/* Output format */}
<div>
<p className="text-xs text-muted-foreground mb-1">Output Format</p>
<div className="grid grid-cols-5 gap-1">
{(["original", "png", "jpeg", "webp", "avif"] as const).map((f) => (
<div className="grid grid-cols-3 gap-1">
{(["original", "png", "jpeg", "webp", "avif", "jxl"] as const).map((f) => (
<button
key={f}
type="button"
@@ -5,7 +5,7 @@ import { useToolProcessor } from "@/hooks/use-tool-processor";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
type WebFormat = "webp" | "jpeg" | "avif" | "png";
type WebFormat = "webp" | "jpeg" | "avif" | "png" | "jxl";
interface PreviewState {
loading: boolean;
@@ -19,6 +19,7 @@ const FORMAT_LABELS: Record<WebFormat, string> = {
jpeg: "JPEG",
avif: "AVIF",
png: "PNG",
jxl: "JXL",
};
function formatSize(bytes: number): string {
@@ -200,8 +201,8 @@ export function OptimizeForWebSettings() {
{/* Format selector */}
<div>
<p className="text-sm font-medium text-muted-foreground">Output Format</p>
<div className="grid grid-cols-4 gap-1 mt-1">
{(["webp", "jpeg", "avif", "png"] as const).map((f) => (
<div className="grid grid-cols-5 gap-1 mt-1">
{(["webp", "jpeg", "avif", "png", "jxl"] as const).map((f) => (
<button
key={f}
type="button"
@@ -11,6 +11,7 @@ const FORMAT_OPTIONS = [
{ value: "gif", label: "GIF" },
{ value: "heic", label: "HEIC" },
{ value: "heif", label: "HEIF" },
{ value: "jxl", label: "JXL" },
];
const DPI_PRESETS = [
@@ -33,7 +34,7 @@ const COLOR_MODE_OPTIONS = [
{ value: "bw", label: "B&W" },
] as const;
const LOSSY_FORMATS = ["jpg", "webp", "avif", "heic", "heif"];
const LOSSY_FORMATS = ["jpg", "webp", "avif", "heic", "heif", "jxl"];
export function PdfToImageSettings() {
const store = usePdfToImageStore();
@@ -4,7 +4,7 @@ import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
const LOSSY_FORMATS = new Set(["jpeg", "webp", "avif"]);
const LOSSY_FORMATS = new Set(["jpeg", "webp", "avif", "jxl"]);
export interface RedEyeRemovalControlsProps {
settings?: Record<string, unknown>;
@@ -17,9 +17,9 @@ export function RedEyeRemovalControls({
}: RedEyeRemovalControlsProps) {
const [sensitivity, setSensitivity] = useState(50);
const [strength, setStrength] = useState(70);
const [outputFormat, setOutputFormat] = useState<"original" | "png" | "jpeg" | "webp" | "avif">(
"original",
);
const [outputFormat, setOutputFormat] = useState<
"original" | "png" | "jpeg" | "webp" | "avif" | "jxl"
>("original");
const [quality, setQuality] = useState(90);
// One-time init from pipeline settings
@@ -30,7 +30,9 @@ export function RedEyeRemovalControls({
if (initialSettings.sensitivity != null) setSensitivity(Number(initialSettings.sensitivity));
if (initialSettings.strength != null) setStrength(Number(initialSettings.strength));
if (initialSettings.format != null)
setOutputFormat(initialSettings.format as "original" | "png" | "jpeg" | "webp" | "avif");
setOutputFormat(
initialSettings.format as "original" | "png" | "jpeg" | "webp" | "avif" | "jxl",
);
if (initialSettings.quality != null) setQuality(Number(initialSettings.quality));
}, [initialSettings]);
@@ -101,8 +103,8 @@ export function RedEyeRemovalControls({
{/* Output format */}
<div>
<p className="text-xs text-muted-foreground mb-1">Output Format</p>
<div className="grid grid-cols-5 gap-1">
{(["original", "png", "jpeg", "webp", "avif"] as const).map((f) => (
<div className="grid grid-cols-3 gap-1">
{(["original", "png", "jpeg", "webp", "avif", "jxl"] as const).map((f) => (
<button
key={f}
type="button"
@@ -29,9 +29,10 @@ const OUTPUT_FORMATS = [
{ value: "jpg", label: "JPG" },
{ value: "webp", label: "WebP" },
{ value: "avif", label: "AVIF" },
{ value: "jxl", label: "JXL" },
] as const;
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif"]);
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif", "jxl"]);
export function SplitSettings() {
const { files, processing: fileStoreProcessing } = useFileStore();
@@ -6,7 +6,7 @@ import { useFileStore } from "@/stores/file-store";
type Direction = "horizontal" | "vertical" | "grid";
type ResizeMode = "fit" | "original" | "stretch" | "crop";
type Alignment = "start" | "center" | "end";
type OutputFormat = "png" | "jpeg" | "webp" | "avif";
type OutputFormat = "png" | "jpeg" | "webp" | "avif" | "jxl";
export function StitchSettings() {
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
@@ -227,7 +227,7 @@ export function StitchSettings() {
<div>
<p className="text-xs text-muted-foreground">Format</p>
<div className="grid grid-cols-3 gap-1 mt-1">
{(["png", "jpeg", "webp", "avif"] as const).map((f) => (
{(["png", "jpeg", "webp", "avif", "jxl"] as const).map((f) => (
<button
type="button"
key={f}
@@ -240,7 +240,7 @@ export function StitchSettings() {
</div>
</div>
{(format === "jpeg" || format === "webp" || format === "avif") && (
{(format === "jpeg" || format === "webp" || format === "avif" || format === "jxl") && (
<div>
<div className="flex justify-between items-center">
<label htmlFor="stitch-quality" className="text-xs text-muted-foreground">
@@ -4,12 +4,12 @@ import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
type OutputFormat = "png" | "jpg" | "webp" | "avif" | "tiff" | "gif" | "heif";
type OutputFormat = "png" | "jpg" | "webp" | "avif" | "tiff" | "gif" | "heif" | "jxl";
type SizingMode = "scale" | "custom";
type BgMode = "transparent" | "color";
const FORMATS: OutputFormat[] = ["png", "jpg", "webp", "avif", "tiff", "gif", "heif"];
const LOSSY_FORMATS: OutputFormat[] = ["jpg", "webp", "avif", "heif"];
const FORMATS: OutputFormat[] = ["png", "jpg", "webp", "avif", "tiff", "gif", "heif", "jxl"];
const LOSSY_FORMATS: OutputFormat[] = ["jpg", "webp", "avif", "heif", "jxl"];
const NO_TRANSPARENCY_FORMATS: OutputFormat[] = ["jpg", "tiff"];
const SCALE_PRESETS = [0.5, 1, 2, 3, 4];
@@ -10,8 +10,18 @@ const MODEL_OPTIONS = [
{ value: "auto", label: "Balanced" },
{ value: "realesrgan", label: "Best" },
] as const;
const OUTPUT_FORMATS = ["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif"] as const;
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif"];
const OUTPUT_FORMATS = [
"png",
"jpg",
"webp",
"avif",
"tiff",
"gif",
"heic",
"heif",
"jxl",
] as const;
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif", "jxl"];
export interface UpscaleControlsProps {
settings?: Record<string, unknown>;
+1 -1
View File
@@ -3,7 +3,7 @@ import { COLLAGE_TEMPLATES, getDefaultTemplate } from "@/lib/collage-templates";
import { fetchDecodedPreview, needsServerPreview, revokePreviewUrl } from "@/lib/image-preview";
export type AspectRatio = "free" | "1:1" | "4:3" | "3:2" | "16:9" | "9:16" | "4:5";
export type OutputFormat = "png" | "jpeg" | "webp" | "avif";
export type OutputFormat = "png" | "jpeg" | "webp" | "avif" | "jxl";
export type Phase = "upload" | "editing" | "processing" | "result";
export interface CollageImage {
+2 -2
View File
@@ -18,7 +18,7 @@ interface SplitState {
rows: number;
tileWidth: number;
tileHeight: number;
outputFormat: "original" | "png" | "jpg" | "webp" | "avif";
outputFormat: "original" | "png" | "jpg" | "webp" | "avif" | "jxl";
quality: number;
// Image dimensions (set when image loads in the canvas)
@@ -36,7 +36,7 @@ interface SplitState {
setRows: (n: number) => void;
setTileWidth: (n: number) => void;
setTileHeight: (n: number) => void;
setOutputFormat: (f: "original" | "png" | "jpg" | "webp" | "avif") => void;
setOutputFormat: (f: "original" | "png" | "jpg" | "webp" | "avif" | "jxl") => void;
setQuality: (q: number) => void;
setImageDimensions: (d: { width: number; height: number } | null) => void;
setProcessing: (p: boolean) => void;
@@ -39,6 +39,8 @@ export async function optimizeForWeb(image: Sharp, options: OptimizeForWebOption
return pipeline.avif({ quality, effort: 4 });
case "png":
return pipeline.png({ compressionLevel: 9, palette: true });
case "jxl":
return pipeline.jxl({ quality, effort: 7 });
default:
throw new Error(`Unsupported format: ${format}`);
}
+1 -1
View File
@@ -187,7 +187,7 @@ export interface CorrectionParams {
}
export interface OptimizeForWebOptions {
format: "webp" | "jpeg" | "avif" | "png";
format: "webp" | "jpeg" | "avif" | "png" | "jxl";
quality: number;
maxWidth?: number;
maxHeight?: number;