mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user