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
@@ -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;