mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
merge: resolve conflict with main branch in tool-registry.tsx
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import {
|
||||
type BackgroundOpts,
|
||||
generateBackground,
|
||||
getDominantBackground,
|
||||
} from "../../lib/beautify/backgrounds.js";
|
||||
import {
|
||||
type BeautifySettings,
|
||||
DEVICE_FRAMES,
|
||||
SHADOW_PRESETS,
|
||||
SOCIAL_PRESETS,
|
||||
settingsSchema,
|
||||
} from "../../lib/beautify/constants.js";
|
||||
import { renderFrame } from "../../lib/beautify/frames.js";
|
||||
import { applyShadow } from "../../lib/beautify/shadow.js";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { sanitizeFilename } from "../../lib/filename.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
import { registerToolProcessFn } from "../tool-factory.js";
|
||||
|
||||
const ALPHA_FORMATS = new Set(["png", "webp", "avif", "tiff"]);
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Resolve shadow preset to concrete values. */
|
||||
function resolveShadow(settings: BeautifySettings) {
|
||||
return settings.shadowPreset === "custom"
|
||||
? {
|
||||
blur: settings.shadowBlur,
|
||||
offsetX: settings.shadowOffsetX,
|
||||
offsetY: settings.shadowOffsetY,
|
||||
color: settings.shadowColor,
|
||||
opacity: settings.shadowOpacity,
|
||||
}
|
||||
: SHADOW_PRESETS[settings.shadowPreset];
|
||||
}
|
||||
|
||||
/** Check whether the output needs an alpha channel. */
|
||||
function needsAlphaOutput(settings: BeautifySettings): boolean {
|
||||
const shadow = resolveShadow(settings);
|
||||
const hasShadow = shadow.opacity > 0 && shadow.blur > 0;
|
||||
return (
|
||||
hasShadow ||
|
||||
settings.borderRadius > 0 ||
|
||||
settings.backgroundType === "transparent" ||
|
||||
settings.frame !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
/** Compute the output filename with the correct extension. */
|
||||
function resolveOutputFilename(filename: string, settings: BeautifySettings): string {
|
||||
const forcedPng = needsAlphaOutput(settings) && !ALPHA_FORMATS.has(settings.outputFormat);
|
||||
const ext = forcedPng ? ".png" : `.${settings.outputFormat}`;
|
||||
return filename.replace(/\.[^.]+$/, ext);
|
||||
}
|
||||
|
||||
export async function processBeautify(
|
||||
inputBuffer: Buffer,
|
||||
settings: BeautifySettings,
|
||||
_filename: string,
|
||||
bgImageBuffer?: Buffer,
|
||||
): Promise<Buffer> {
|
||||
// 1. Decode & Prepare
|
||||
let buf = await autoOrient(await ensureSharpCompat(inputBuffer));
|
||||
buf = await sharp(buf).ensureAlpha().png().toBuffer();
|
||||
|
||||
// 2. Apply Border Radius (skip for device frames that have their own bezels)
|
||||
const hasDeviceFrame = settings.frame !== "none" && DEVICE_FRAMES.has(settings.frame);
|
||||
if (settings.borderRadius > 0 && !hasDeviceFrame) {
|
||||
const meta = await sharp(buf).metadata();
|
||||
const w = meta.width ?? 100;
|
||||
const h = meta.height ?? 100;
|
||||
const r = Math.min(settings.borderRadius, w / 2, h / 2);
|
||||
|
||||
const mask = Buffer.from(
|
||||
`<svg width="${w}" height="${h}"><rect x="0" y="0" width="${w}" height="${h}" rx="${r}" ry="${r}" fill="white"/></svg>`,
|
||||
);
|
||||
buf = await sharp(buf)
|
||||
.composite([{ input: await sharp(mask).resize(w, h).toBuffer(), blend: "dest-in" }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// 3. Render Device/SVG Frame
|
||||
buf = await renderFrame(buf, settings.frame, settings.frameTitle);
|
||||
|
||||
// 4-5. Resolve & Apply Shadow
|
||||
const shadowOpts = resolveShadow(settings);
|
||||
const hasShadow = shadowOpts.opacity > 0 && shadowOpts.blur > 0;
|
||||
if (hasShadow) {
|
||||
const result = await applyShadow(buf, shadowOpts);
|
||||
buf = result.buffer;
|
||||
}
|
||||
|
||||
// 6. Generate Background
|
||||
const framedMeta = await sharp(buf).metadata();
|
||||
const framedW = framedMeta.width ?? 100;
|
||||
const framedH = framedMeta.height ?? 100;
|
||||
const padding = settings.padding;
|
||||
const canvasW = framedW + padding * 2;
|
||||
const canvasH = framedH + padding * 2;
|
||||
|
||||
let bgOpts: BackgroundOpts;
|
||||
if (settings.backgroundType === "image" && bgImageBuffer) {
|
||||
bgOpts = { type: "image", imageBuffer: bgImageBuffer, width: canvasW, height: canvasH };
|
||||
} else if (settings.backgroundType === "solid") {
|
||||
bgOpts = { type: "solid", color: settings.backgroundColor, width: canvasW, height: canvasH };
|
||||
} else if (
|
||||
settings.backgroundType === "linear-gradient" ||
|
||||
settings.backgroundType === "radial-gradient"
|
||||
) {
|
||||
bgOpts = {
|
||||
type: settings.backgroundType,
|
||||
stops: settings.gradientStops,
|
||||
angle: settings.gradientAngle,
|
||||
width: canvasW,
|
||||
height: canvasH,
|
||||
};
|
||||
} else {
|
||||
bgOpts = { type: "transparent", width: canvasW, height: canvasH };
|
||||
}
|
||||
|
||||
const background = await generateBackground(bgOpts);
|
||||
|
||||
// 7. Composite onto Background
|
||||
buf = await sharp(background)
|
||||
.composite([{ input: buf, left: padding, top: padding }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// 8. Apply Watermark
|
||||
if (settings.watermarkText) {
|
||||
const wmMeta = await sharp(buf).metadata();
|
||||
const wmW = wmMeta.width ?? canvasW;
|
||||
const wmH = wmMeta.height ?? canvasH;
|
||||
|
||||
const fontSize = Math.max(12, Math.round(Math.min(wmW, wmH) * 0.03));
|
||||
const alpha = settings.watermarkOpacity / 100;
|
||||
const escaped = escapeXml(settings.watermarkText);
|
||||
|
||||
let textX: number;
|
||||
let textY: number;
|
||||
let anchor: string;
|
||||
switch (settings.watermarkPosition) {
|
||||
case "top-left":
|
||||
textX = fontSize;
|
||||
textY = fontSize * 2;
|
||||
anchor = "start";
|
||||
break;
|
||||
case "top-right":
|
||||
textX = wmW - fontSize;
|
||||
textY = fontSize * 2;
|
||||
anchor = "end";
|
||||
break;
|
||||
case "bottom-left":
|
||||
textX = fontSize;
|
||||
textY = wmH - fontSize;
|
||||
anchor = "start";
|
||||
break;
|
||||
case "center":
|
||||
textX = wmW / 2;
|
||||
textY = wmH / 2;
|
||||
anchor = "middle";
|
||||
break;
|
||||
default:
|
||||
// bottom-right
|
||||
textX = wmW - fontSize;
|
||||
textY = wmH - fontSize;
|
||||
anchor = "end";
|
||||
break;
|
||||
}
|
||||
|
||||
const wmSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${wmW}" height="${wmH}">
|
||||
<text x="${textX}" y="${textY}" text-anchor="${anchor}" font-family="sans-serif" font-size="${fontSize}" fill="white" opacity="${alpha}">${escaped}</text>
|
||||
</svg>`;
|
||||
|
||||
buf = await sharp(buf)
|
||||
.composite([{ input: Buffer.from(wmSvg) }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// 9. Resize to Social Preset
|
||||
const preset = SOCIAL_PRESETS[settings.socialPreset];
|
||||
if (preset) {
|
||||
const dominant = getDominantBackground({
|
||||
type: settings.backgroundType,
|
||||
color: settings.backgroundColor,
|
||||
stops: settings.gradientStops,
|
||||
});
|
||||
buf = await sharp(buf)
|
||||
.resize(preset.width, preset.height, {
|
||||
fit: "contain",
|
||||
background: dominant,
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// 10. Encode Output
|
||||
if (needsAlphaOutput(settings) && !ALPHA_FORMATS.has(settings.outputFormat)) {
|
||||
return sharp(buf).png().toBuffer();
|
||||
}
|
||||
|
||||
switch (settings.outputFormat) {
|
||||
case "jpeg":
|
||||
return sharp(buf).flatten({ background: "#ffffff" }).jpeg({ quality: 90 }).toBuffer();
|
||||
case "webp":
|
||||
return sharp(buf).webp({ quality: 90 }).toBuffer();
|
||||
default:
|
||||
return sharp(buf).png().toBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerBeautify(app: FastifyInstance) {
|
||||
// Custom HTTP route (multi-file upload: main image + optional background image)
|
||||
app.post("/api/v1/tools/beautify", async (request, reply) => {
|
||||
let mainBuffer: Buffer | null = null;
|
||||
let bgImageBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let settingsRaw: string | null = null;
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buf = Buffer.concat(chunks);
|
||||
if (part.fieldname === "backgroundImage") {
|
||||
bgImageBuffer = buf;
|
||||
} else {
|
||||
mainBuffer = buf;
|
||||
filename = sanitizeFilename(part.filename ?? "image");
|
||||
}
|
||||
} else if (part.fieldname === "settings") {
|
||||
settingsRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
if (!mainBuffer || mainBuffer.length === 0) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
|
||||
let settings: BeautifySettings;
|
||||
try {
|
||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
try {
|
||||
const originalSize = mainBuffer.length;
|
||||
const outputBuf = await processBeautify(
|
||||
mainBuffer,
|
||||
settings,
|
||||
filename,
|
||||
bgImageBuffer ?? undefined,
|
||||
);
|
||||
const outFilename = resolveOutputFilename(filename, settings);
|
||||
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const outputPath = join(workspacePath, "output", outFilename);
|
||||
await writeFile(outputPath, outputBuf);
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`,
|
||||
originalSize,
|
||||
processedSize: outputBuf.length,
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Processing failed",
|
||||
details: err instanceof Error ? err.message : "Image processing failed",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Register for pipeline/batch support
|
||||
registerToolProcessFn({
|
||||
toolId: "beautify",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const s = settings as BeautifySettings;
|
||||
if (s.backgroundType === "image") {
|
||||
throw new Error("Image backgrounds are not supported in pipeline mode.");
|
||||
}
|
||||
const buffer = await processBeautify(inputBuffer, s, filename);
|
||||
const outFilename = resolveOutputFilename(filename, s);
|
||||
const forcedPng = needsAlphaOutput(s) && !ALPHA_FORMATS.has(s.outputFormat);
|
||||
const ext = forcedPng ? "png" : s.outputFormat;
|
||||
const contentType =
|
||||
ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
||||
return { buffer, filename: outFilename, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { colorBlindness } from "@snapotter/image-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
simulationType: z
|
||||
.enum([
|
||||
"protanopia",
|
||||
"deuteranopia",
|
||||
"tritanopia",
|
||||
"protanomaly",
|
||||
"deuteranomaly",
|
||||
"tritanomaly",
|
||||
"achromatopsia",
|
||||
"blueConeMonochromacy",
|
||||
])
|
||||
.default("deuteranomaly"),
|
||||
});
|
||||
|
||||
export function registerColorBlindness(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "color-blindness",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const image = sharp(inputBuffer);
|
||||
const result = await colorBlindness(image, { type: settings.simulationType });
|
||||
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const buffer = await result
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -3,11 +3,13 @@ import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { db, schema } from "../../db/index.js";
|
||||
import { registerBarcodeRead } from "./barcode-read.js";
|
||||
import { registerBeautify } from "./beautify.js";
|
||||
import { registerBlurFaces } from "./blur-faces.js";
|
||||
import { registerBorder } from "./border.js";
|
||||
import { registerBulkRename } from "./bulk-rename.js";
|
||||
import { registerCollage } from "./collage.js";
|
||||
import { registerColorAdjustments } from "./color-adjustments.js";
|
||||
import { registerColorBlindness } from "./color-blindness.js";
|
||||
import { registerColorPalette } from "./color-palette.js";
|
||||
import { registerColorize } from "./colorize.js";
|
||||
import { registerCompare } from "./compare.js";
|
||||
@@ -118,6 +120,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "stitch", register: registerStitch },
|
||||
{ id: "split", register: registerSplit },
|
||||
{ id: "border", register: registerBorder },
|
||||
{ id: "beautify", register: registerBeautify },
|
||||
|
||||
// Format & Conversion
|
||||
{ id: "svg-to-raster", register: registerSvgToRaster },
|
||||
@@ -133,6 +136,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
// Adjustments extra
|
||||
{ id: "replace-color", register: registerReplaceColor },
|
||||
{ id: "color-blindness", register: registerColorBlindness },
|
||||
|
||||
// AI Tools
|
||||
{ id: "remove-background", register: registerRemoveBackground },
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user