mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add support for JXL, Camera RAW, ICO, TGA, PSD, EXR, HDR image formats
Extends the platform to handle 7 new image format families alongside the existing AVIF support gap-fill. Uses the established HEIC decoder pattern (CLI decode → PNG → Sharp) for formats Sharp can't handle natively: Camera RAW via dcraw_emu/LibRaw, PSD/TGA/EXR/HDR via ImageMagick. JXL and ICO are Sharp-native. Adds server-side preview for non-browser-displayable formats and JXL as a new convert output target. All 27 validateImageBuffer callers updated with filename for extension-based format detection.
This commit is contained in:
@@ -15,6 +15,20 @@ const SAFE_STORAGE_EXTENSIONS = new Set([
|
|||||||
".avif",
|
".avif",
|
||||||
".svg",
|
".svg",
|
||||||
".pdf",
|
".pdf",
|
||||||
|
".heic",
|
||||||
|
".heif",
|
||||||
|
".jxl",
|
||||||
|
".ico",
|
||||||
|
".dng",
|
||||||
|
".cr2",
|
||||||
|
".nef",
|
||||||
|
".arw",
|
||||||
|
".orf",
|
||||||
|
".rw2",
|
||||||
|
".tga",
|
||||||
|
".psd",
|
||||||
|
".exr",
|
||||||
|
".hdr",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let storageReady = false;
|
let storageReady = false;
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ const SUPPORTED_INPUT_FORMATS = new Set([
|
|||||||
"avif",
|
"avif",
|
||||||
"heif",
|
"heif",
|
||||||
"svg",
|
"svg",
|
||||||
|
"jxl",
|
||||||
|
"ico",
|
||||||
|
"raw",
|
||||||
|
"tga",
|
||||||
|
"psd",
|
||||||
|
"exr",
|
||||||
|
"hdr",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
interface MagicEntry {
|
interface MagicEntry {
|
||||||
@@ -31,6 +38,17 @@ const MAGIC_BYTES: MagicEntry[] = [
|
|||||||
{ bytes: [0x4d, 0x4d, 0x00, 0x2a], offset: 0, format: "tiff" },
|
{ bytes: [0x4d, 0x4d, 0x00, 0x2a], offset: 0, format: "tiff" },
|
||||||
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "avif" }, // ftyp box; verified below
|
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "avif" }, // ftyp box; verified below
|
||||||
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "heif" }, // ftyp box; verified below
|
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "heif" }, // ftyp box; verified below
|
||||||
|
// JXL ISOBMFF container
|
||||||
|
{ bytes: [0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20], offset: 0, format: "jxl" },
|
||||||
|
// JXL raw codestream
|
||||||
|
{ bytes: [0xff, 0x0a], offset: 0, format: "jxl" },
|
||||||
|
// ICO
|
||||||
|
{ bytes: [0x00, 0x00, 0x01, 0x00], offset: 0, format: "ico" },
|
||||||
|
// PSD ("8BPS")
|
||||||
|
{ bytes: [0x38, 0x42, 0x50, 0x53], offset: 0, format: "psd" },
|
||||||
|
// OpenEXR
|
||||||
|
{ bytes: [0x76, 0x2f, 0x31, 0x01], offset: 0, format: "exr" },
|
||||||
|
// TGA has no reliable magic bytes — detected by extension only
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface ValidationResult {
|
export interface ValidationResult {
|
||||||
@@ -45,6 +63,19 @@ export interface ValidationError {
|
|||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Camera RAW extensions that share TIFF magic bytes. */
|
||||||
|
const RAW_EXTENSIONS = new Set(["dng", "cr2", "nef", "arw", "orf", "rw2"]);
|
||||||
|
|
||||||
|
/** Formats that Sharp cannot decode natively — skip dimension check. */
|
||||||
|
const CLI_DECODED_FORMATS = new Set(["raw", "tga", "psd", "exr", "hdr"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a file extension corresponds to a Camera RAW format.
|
||||||
|
*/
|
||||||
|
export function isRawExtension(ext: string): boolean {
|
||||||
|
return RAW_EXTENSIONS.has(ext.toLowerCase().replace(/^\./, ""));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate an uploaded image buffer.
|
* Validate an uploaded image buffer.
|
||||||
*
|
*
|
||||||
@@ -53,9 +84,14 @@ export interface ValidationError {
|
|||||||
* 2. Magic bytes match a known image format
|
* 2. Magic bytes match a known image format
|
||||||
* 3. Format is in the supported input formats list
|
* 3. Format is in the supported input formats list
|
||||||
* 4. Image dimensions do not exceed MAX_MEGAPIXELS
|
* 4. Image dimensions do not exceed MAX_MEGAPIXELS
|
||||||
|
*
|
||||||
|
* @param buffer - The image file buffer
|
||||||
|
* @param filename - Optional original filename, used for extension-based
|
||||||
|
* format detection (Camera RAW, TGA)
|
||||||
*/
|
*/
|
||||||
export async function validateImageBuffer(
|
export async function validateImageBuffer(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
|
filename?: string,
|
||||||
): Promise<ValidationResult | ValidationError> {
|
): Promise<ValidationResult | ValidationError> {
|
||||||
// 1. Empty / null-byte check
|
// 1. Empty / null-byte check
|
||||||
if (!buffer || buffer.length === 0) {
|
if (!buffer || buffer.length === 0) {
|
||||||
@@ -68,8 +104,23 @@ export async function validateImageBuffer(
|
|||||||
return { valid: false, reason: "File contains no image data" };
|
return { valid: false, reason: "File contains no image data" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Format detection (magic bytes for raster, text check for SVG)
|
// Extract extension from filename for extension-based detection
|
||||||
const detectedFormat = detectMagicBytes(buffer) || (isSvgBuffer(buffer) ? "svg" : null);
|
const ext = filename ? (filename.split(".").pop()?.toLowerCase() ?? "") : "";
|
||||||
|
|
||||||
|
// 2. Format detection (magic bytes for raster, text check for SVG, text check for HDR)
|
||||||
|
let detectedFormat =
|
||||||
|
detectMagicBytes(buffer) || (isSvgBuffer(buffer) ? "svg" : null) || detectHdrText(buffer);
|
||||||
|
|
||||||
|
// RAW formats share TIFF magic bytes — differentiate by extension
|
||||||
|
if (detectedFormat === "tiff" && ext && isRawExtension(ext)) {
|
||||||
|
detectedFormat = "raw";
|
||||||
|
}
|
||||||
|
|
||||||
|
// TGA has no magic bytes — detect by extension only
|
||||||
|
if (!detectedFormat && ext === "tga") {
|
||||||
|
detectedFormat = "tga";
|
||||||
|
}
|
||||||
|
|
||||||
if (!detectedFormat) {
|
if (!detectedFormat) {
|
||||||
return { valid: false, reason: "Unrecognized image format" };
|
return { valid: false, reason: "Unrecognized image format" };
|
||||||
}
|
}
|
||||||
@@ -83,6 +134,12 @@ export async function validateImageBuffer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Dimensions check via sharp metadata
|
// 4. Dimensions check via sharp metadata
|
||||||
|
// For formats Sharp can't decode natively, skip the dimension check.
|
||||||
|
// The actual decoding happens later in the tool pipeline.
|
||||||
|
if (CLI_DECODED_FORMATS.has(detectedFormat)) {
|
||||||
|
return { valid: true, format: detectedFormat, width: 0, height: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sharpOpts = detectedFormat === "svg" ? { density: 72 } : undefined;
|
const sharpOpts = detectedFormat === "svg" ? { density: 72 } : undefined;
|
||||||
const metadata = await sharp(buffer, sharpOpts).metadata();
|
const metadata = await sharp(buffer, sharpOpts).metadata();
|
||||||
@@ -99,7 +156,9 @@ export async function validateImageBuffer(
|
|||||||
|
|
||||||
return { valid: true, format: detectedFormat, width, height };
|
return { valid: true, format: detectedFormat, width, height };
|
||||||
} catch {
|
} catch {
|
||||||
return { valid: false, reason: "Failed to read image metadata" };
|
// Sharp failed but we already confirmed valid magic bytes / extension.
|
||||||
|
// This can happen for JXL, ICO, or other formats Sharp partially supports.
|
||||||
|
return { valid: true, format: detectedFormat, width: 0, height: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,3 +227,16 @@ function detectMagicBytes(buffer: Buffer): string | null {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect Radiance HDR format by text header.
|
||||||
|
* HDR files start with "#?RADIANCE" or "#?RGBE".
|
||||||
|
*/
|
||||||
|
function detectHdrText(buffer: Buffer): string | null {
|
||||||
|
if (buffer.length < 10) return null;
|
||||||
|
const header = buffer.slice(0, 11).toString("ascii");
|
||||||
|
if (header.startsWith("#?RADIANCE") || header.startsWith("#?RGBE")) {
|
||||||
|
return "hdr";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/** Formats that need external CLI tools (not decodable by Sharp). */
|
||||||
|
const CLI_DECODED_FORMATS = new Set(["raw", "tga", "psd", "exr", "hdr"]);
|
||||||
|
|
||||||
|
export function needsCliDecode(format: string): boolean {
|
||||||
|
return CLI_DECODED_FORMATS.has(format);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point - routes to the right decoder based on format.
|
||||||
|
* Returns a PNG buffer that Sharp can process downstream.
|
||||||
|
*/
|
||||||
|
export async function decodeToSharpCompat(buffer: Buffer, format: string): Promise<Buffer> {
|
||||||
|
switch (format) {
|
||||||
|
case "raw":
|
||||||
|
return decodeRaw(buffer);
|
||||||
|
case "psd":
|
||||||
|
return decodePsd(buffer);
|
||||||
|
case "tga":
|
||||||
|
return decodeTga(buffer);
|
||||||
|
case "exr":
|
||||||
|
return decodeExr(buffer);
|
||||||
|
case "hdr":
|
||||||
|
return decodeHdr(buffer);
|
||||||
|
default:
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RAW decoder (dcraw_emu / dcraw) ─────────────────────────────
|
||||||
|
|
||||||
|
let cachedRawCmd: string | null = null;
|
||||||
|
|
||||||
|
async function findRawCmd(): Promise<string> {
|
||||||
|
if (cachedRawCmd) return cachedRawCmd;
|
||||||
|
for (const cmd of ["dcraw_emu", "dcraw"]) {
|
||||||
|
try {
|
||||||
|
await execFileAsync(cmd, [], { timeout: 5_000 });
|
||||||
|
cachedRawCmd = cmd;
|
||||||
|
return cmd;
|
||||||
|
} catch {
|
||||||
|
// dcraw_emu / dcraw exit non-zero with no args but that's fine -
|
||||||
|
// if the binary exists the exec won't throw ENOENT
|
||||||
|
if (cachedRawCmd === null) {
|
||||||
|
// Check if the error was ENOENT (not found) vs normal exit code
|
||||||
|
try {
|
||||||
|
await execFileAsync("which", [cmd], { timeout: 5_000 });
|
||||||
|
cachedRawCmd = cmd;
|
||||||
|
return cmd;
|
||||||
|
} catch {
|
||||||
|
// not found, try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("No RAW decoder found. Install libraw-dev (provides dcraw_emu) or dcraw.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode Camera RAW buffer to PNG via dcraw_emu.
|
||||||
|
* dcraw_emu -T produces a TIFF file alongside the input (same name, .tiff extension).
|
||||||
|
* We then convert TIFF to PNG via Sharp for consistent downstream handling.
|
||||||
|
*/
|
||||||
|
async function decodeRaw(buffer: Buffer): Promise<Buffer> {
|
||||||
|
const cmd = await findRawCmd();
|
||||||
|
const id = randomUUID();
|
||||||
|
const inputPath = join(tmpdir(), `raw-in-${id}.dng`);
|
||||||
|
const tiffPath = join(tmpdir(), `raw-in-${id}.tiff`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(inputPath, buffer);
|
||||||
|
// -T = output TIFF, -w = use camera white balance, -W = disable auto-brightness
|
||||||
|
await execFileAsync(cmd, ["-T", "-w", "-W", inputPath], { timeout: 120_000 });
|
||||||
|
const tiffBuffer = await readFile(tiffPath);
|
||||||
|
return await sharp(tiffBuffer).png().toBuffer();
|
||||||
|
} finally {
|
||||||
|
await rm(inputPath, { force: true }).catch(() => {});
|
||||||
|
await rm(tiffPath, { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ImageMagick decoders (PSD, TGA, EXR, HDR) ──────────────────
|
||||||
|
|
||||||
|
let cachedMagickCmd: string | null = null;
|
||||||
|
|
||||||
|
async function findMagickCmd(): Promise<string> {
|
||||||
|
if (cachedMagickCmd) return cachedMagickCmd;
|
||||||
|
// ImageMagick 7 uses `magick`, v6 uses `convert`
|
||||||
|
for (const cmd of ["magick", "convert"]) {
|
||||||
|
try {
|
||||||
|
await execFileAsync(cmd, ["--version"], { timeout: 5_000 });
|
||||||
|
cachedMagickCmd = cmd;
|
||||||
|
return cmd;
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("No ImageMagick found. Install imagemagick (provides convert/magick).");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the ImageMagick command args. For ImageMagick 7 (`magick`),
|
||||||
|
* the subcommand `convert` must be prepended.
|
||||||
|
*/
|
||||||
|
function magickArgs(cmd: string, args: string[]): string[] {
|
||||||
|
return cmd === "magick" ? ["convert", ...args] : args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode PSD to PNG. Uses [0] to read only the flattened composite layer.
|
||||||
|
*/
|
||||||
|
async function decodePsd(buffer: Buffer): Promise<Buffer> {
|
||||||
|
const cmd = await findMagickCmd();
|
||||||
|
const id = randomUUID();
|
||||||
|
const inputPath = join(tmpdir(), `psd-in-${id}.psd`);
|
||||||
|
const outputPath = join(tmpdir(), `psd-out-${id}.png`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(inputPath, buffer);
|
||||||
|
await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[0]`, `png:${outputPath}`]), {
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
return await readFile(outputPath);
|
||||||
|
} finally {
|
||||||
|
await rm(inputPath, { force: true }).catch(() => {});
|
||||||
|
await rm(outputPath, { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode TGA to PNG.
|
||||||
|
*/
|
||||||
|
async function decodeTga(buffer: Buffer): Promise<Buffer> {
|
||||||
|
const cmd = await findMagickCmd();
|
||||||
|
const id = randomUUID();
|
||||||
|
const inputPath = join(tmpdir(), `tga-in-${id}.tga`);
|
||||||
|
const outputPath = join(tmpdir(), `tga-out-${id}.png`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(inputPath, buffer);
|
||||||
|
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
return await readFile(outputPath);
|
||||||
|
} finally {
|
||||||
|
await rm(inputPath, { force: true }).catch(() => {});
|
||||||
|
await rm(outputPath, { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode EXR to PNG. Colorspace conversion from linear to sRGB is needed
|
||||||
|
* because EXR files are typically stored in linear light.
|
||||||
|
*/
|
||||||
|
async function decodeExr(buffer: Buffer): Promise<Buffer> {
|
||||||
|
const cmd = await findMagickCmd();
|
||||||
|
const id = randomUUID();
|
||||||
|
const inputPath = join(tmpdir(), `exr-in-${id}.exr`);
|
||||||
|
const outputPath = join(tmpdir(), `exr-out-${id}.png`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(inputPath, buffer);
|
||||||
|
await execFileAsync(
|
||||||
|
cmd,
|
||||||
|
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]),
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
);
|
||||||
|
return await readFile(outputPath);
|
||||||
|
} finally {
|
||||||
|
await rm(inputPath, { force: true }).catch(() => {});
|
||||||
|
await rm(outputPath, { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode Radiance HDR to PNG. Same colorspace handling as EXR.
|
||||||
|
*/
|
||||||
|
async function decodeHdr(buffer: Buffer): Promise<Buffer> {
|
||||||
|
const cmd = await findMagickCmd();
|
||||||
|
const id = randomUUID();
|
||||||
|
const inputPath = join(tmpdir(), `hdr-in-${id}.hdr`);
|
||||||
|
const outputPath = join(tmpdir(), `hdr-out-${id}.png`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(inputPath, buffer);
|
||||||
|
await execFileAsync(
|
||||||
|
cmd,
|
||||||
|
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]),
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
);
|
||||||
|
return await readFile(outputPath);
|
||||||
|
} finally {
|
||||||
|
await rm(inputPath, { force: true }).catch(() => {});
|
||||||
|
await rm(outputPath, { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,15 +18,19 @@ const FORMAT_MAP: Record<
|
|||||||
tiff: { format: "tiff", extension: "tiff", contentType: "image/tiff" },
|
tiff: { format: "tiff", extension: "tiff", contentType: "image/tiff" },
|
||||||
avif: { format: "avif", extension: "avif", contentType: "image/avif" },
|
avif: { format: "avif", extension: "avif", contentType: "image/avif" },
|
||||||
heif: { format: "avif", extension: "avif", contentType: "image/avif" },
|
heif: { format: "avif", extension: "avif", contentType: "image/avif" },
|
||||||
|
jxl: { format: "png", extension: "png", contentType: "image/png" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_QUALITY = 95;
|
const DEFAULT_QUALITY = 95;
|
||||||
const PNG_FALLBACK = FORMAT_MAP.png;
|
const PNG_FALLBACK = FORMAT_MAP.png;
|
||||||
|
|
||||||
|
/** Formats that have no Sharp output encoder — fall back to PNG. */
|
||||||
|
const PNG_FALLBACK_FORMATS = new Set(["svg", "bmp", "raw", "tga", "psd", "exr", "hdr", "ico"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect the input image format and return matching output config.
|
* Detect the input image format and return matching output config.
|
||||||
* Falls back to PNG for undetectable or unsupported output formats
|
* Falls back to PNG for undetectable or unsupported output formats
|
||||||
* (SVG, BMP, raw camera formats like CR2/NEF).
|
* (SVG, BMP, Camera RAW, TGA, PSD, EXR, HDR, ICO).
|
||||||
*/
|
*/
|
||||||
export async function resolveOutputFormat(
|
export async function resolveOutputFormat(
|
||||||
inputBuffer: Buffer,
|
inputBuffer: Buffer,
|
||||||
@@ -41,6 +45,11 @@ export async function resolveOutputFormat(
|
|||||||
// format detection failed
|
// format detection failed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force PNG fallback for formats without a Sharp output encoder
|
||||||
|
if (detected && PNG_FALLBACK_FORMATS.has(detected)) {
|
||||||
|
detected = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const mapped = detected ? FORMAT_MAP[detected] : undefined;
|
const mapped = detected ? FORMAT_MAP[detected] : undefined;
|
||||||
const config = mapped ?? PNG_FALLBACK;
|
const config = mapped ?? PNG_FALLBACK;
|
||||||
const quality = qualityOverride ?? DEFAULT_QUALITY;
|
const quality = qualityOverride ?? DEFAULT_QUALITY;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { formatZodErrors } from "../lib/errors.js";
|
|||||||
import { isToolInstalled } from "../lib/feature-status.js";
|
import { isToolInstalled } from "../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||||
import { decodeHeic } from "../lib/heic-converter.js";
|
import { decodeHeic } from "../lib/heic-converter.js";
|
||||||
import { type JobProgress, updateJobProgress } from "./progress.js";
|
import { type JobProgress, updateJobProgress } from "./progress.js";
|
||||||
import { getToolConfig } from "./tool-factory.js";
|
import { getToolConfig } from "./tool-factory.js";
|
||||||
@@ -144,7 +145,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
updateJobProgress({ ...progress });
|
updateJobProgress({ ...progress });
|
||||||
|
|
||||||
// Validate the image
|
// Validate the image
|
||||||
const validation = await validateImageBuffer(file.buffer);
|
const validation = await validateImageBuffer(file.buffer, file.filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
progress.failedFiles++;
|
progress.failedFiles++;
|
||||||
progress.errors.push({
|
progress.errors.push({
|
||||||
@@ -167,6 +168,11 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const ext = processFilename.match(/\.[^.]+$/)?.[0];
|
const ext = processFilename.match(/\.[^.]+$/)?.[0];
|
||||||
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
|
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
|
||||||
}
|
}
|
||||||
|
if (!skipPreprocess && needsCliDecode(validation.format)) {
|
||||||
|
processBuffer = await decodeToSharpCompat(processBuffer, validation.format);
|
||||||
|
const ext = processFilename.match(/\.[^.]+$/)?.[0];
|
||||||
|
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
|
||||||
|
}
|
||||||
if (!skipPreprocess) {
|
if (!skipPreprocess) {
|
||||||
processBuffer = await autoOrient(processBuffer);
|
processBuffer = await autoOrient(processBuffer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||||
import { decodeHeic } from "../lib/heic-converter.js";
|
import { decodeHeic } from "../lib/heic-converter.js";
|
||||||
import { createWorkspace, getWorkspacePath } from "../lib/workspace.js";
|
import { createWorkspace, getWorkspacePath } from "../lib/workspace.js";
|
||||||
|
|
||||||
@@ -49,8 +50,8 @@ export async function fileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
// Skip empty parts (e.g. empty file field)
|
// Skip empty parts (e.g. empty file field)
|
||||||
if (buffer.length === 0) continue;
|
if (buffer.length === 0) continue;
|
||||||
|
|
||||||
// Validate the image
|
// Validate the image (pass filename for extension-based format detection)
|
||||||
const validation = await validateImageBuffer(buffer);
|
const validation = await validateImageBuffer(buffer, part.filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: `Invalid file "${part.filename}": ${validation.reason}`,
|
error: `Invalid file "${part.filename}": ${validation.reason}`,
|
||||||
@@ -132,7 +133,7 @@ export async function fileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
let buffer = await data.toBuffer();
|
let buffer = await data.toBuffer();
|
||||||
|
|
||||||
const validation = await validateImageBuffer(buffer);
|
const validation = await validateImageBuffer(buffer, data.filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: validation.reason });
|
return reply.status(400).send({ error: validation.reason });
|
||||||
}
|
}
|
||||||
@@ -146,6 +147,17 @@ export async function fileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, PSD, TGA, EXR, HDR) via external tools
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
try {
|
||||||
|
buffer = await decodeToSharpCompat(buffer, validation.format);
|
||||||
|
} catch {
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: `Failed to decode ${validation.format.toUpperCase()} file`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const webp = await sharp(buffer)
|
const webp = await sharp(buffer)
|
||||||
.resize(1200, 1200, { fit: "inside", withoutEnlargement: true })
|
.resize(1200, 1200, { fit: "inside", withoutEnlargement: true })
|
||||||
.webp({ quality: 80 })
|
.webp({ quality: 80 })
|
||||||
@@ -170,6 +182,19 @@ function getContentType(ext: string): string {
|
|||||||
zip: "application/zip",
|
zip: "application/zip",
|
||||||
ico: "image/x-icon",
|
ico: "image/x-icon",
|
||||||
json: "application/json",
|
json: "application/json",
|
||||||
|
jxl: "image/jxl",
|
||||||
|
dng: "image/x-adobe-dng",
|
||||||
|
cr2: "image/x-canon-cr2",
|
||||||
|
nef: "image/x-nikon-nef",
|
||||||
|
arw: "image/x-sony-arw",
|
||||||
|
orf: "image/x-olympus-orf",
|
||||||
|
rw2: "image/x-panasonic-rw2",
|
||||||
|
tga: "image/x-tga",
|
||||||
|
psd: "image/vnd.adobe.photoshop",
|
||||||
|
exr: "image/x-exr",
|
||||||
|
hdr: "image/vnd.radiance",
|
||||||
|
heic: "image/heic",
|
||||||
|
heif: "image/heif",
|
||||||
};
|
};
|
||||||
return map[ext] ?? "application/octet-stream";
|
return map[ext] ?? "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { formatZodErrors } from "../lib/errors.js";
|
|||||||
import { isToolInstalled } from "../lib/feature-status.js";
|
import { isToolInstalled } from "../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||||
import { decodeHeic } from "../lib/heic-converter.js";
|
import { decodeHeic } from "../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../lib/workspace.js";
|
import { createWorkspace } from "../lib/workspace.js";
|
||||||
import { requireAuth } from "../plugins/auth.js";
|
import { requireAuth } from "../plugins/auth.js";
|
||||||
@@ -101,7 +102,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate the initial image
|
// Validate the initial image
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: `Invalid image: ${validation.reason}`,
|
error: `Invalid image: ${validation.reason}`,
|
||||||
@@ -123,6 +124,20 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
try {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||||
|
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: `Failed to decode ${validation.format} file`,
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize EXIF orientation before passing to pipeline steps
|
// Normalize EXIF orientation before passing to pipeline steps
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
|
||||||
@@ -517,7 +532,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
updateJobProgress({ ...progress });
|
updateJobProgress({ ...progress });
|
||||||
|
|
||||||
// Validate the image
|
// Validate the image
|
||||||
const validation = await validateImageBuffer(file.buffer);
|
const validation = await validateImageBuffer(file.buffer, file.filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
progress.failedFiles++;
|
progress.failedFiles++;
|
||||||
progress.errors.push({
|
progress.errors.push({
|
||||||
@@ -540,6 +555,13 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
if (ext) currentFilename = `${currentFilename.slice(0, -ext.length)}.png`;
|
if (ext) currentFilename = `${currentFilename.slice(0, -ext.length)}.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
currentBuffer = await decodeToSharpCompat(currentBuffer, validation.format);
|
||||||
|
const ext = currentFilename.match(/\.[^.]+$/)?.[0];
|
||||||
|
if (ext) currentFilename = `${currentFilename.slice(0, -ext.length)}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize EXIF orientation
|
// Normalize EXIF orientation
|
||||||
currentBuffer = await autoOrient(currentBuffer);
|
currentBuffer = await autoOrient(currentBuffer);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { formatZodErrors } from "../lib/errors.js";
|
|||||||
import { isToolInstalled } from "../lib/feature-status.js";
|
import { isToolInstalled } from "../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||||
import { decodeHeic } from "../lib/heic-converter.js";
|
import { decodeHeic } from "../lib/heic-converter.js";
|
||||||
import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js";
|
import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js";
|
||||||
import { sanitizeSvg } from "../lib/svg-sanitize.js";
|
import { sanitizeSvg } from "../lib/svg-sanitize.js";
|
||||||
@@ -147,7 +148,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate the uploaded image
|
// Validate the uploaded image
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -169,6 +170,21 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, PSD, TGA, EXR, HDR) via external tools.
|
||||||
|
// The decoded buffer is PNG, so update the filename extension to match.
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
try {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||||
|
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: `Failed to decode ${validation.format.toUpperCase()} file`,
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sanitize SVG input to prevent XXE, SSRF, and script injection
|
// Sanitize SVG input to prevent XXE, SSRF, and script injection
|
||||||
const isSvg = validation.format === "svg";
|
const isSvg = validation.format === "svg";
|
||||||
if (isSvg) {
|
if (isSvg) {
|
||||||
@@ -279,6 +295,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
"image/svg+xml",
|
"image/svg+xml",
|
||||||
"image/bmp",
|
"image/bmp",
|
||||||
"image/avif",
|
"image/avif",
|
||||||
|
"image/x-icon",
|
||||||
]);
|
]);
|
||||||
let previewUrl: string | undefined;
|
let previewUrl: string | undefined;
|
||||||
if (!BROWSER_PREVIEWABLE.has(result.contentType)) {
|
if (!BROWSER_PREVIEWABLE.has(result.contentType)) {
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function registerBarcodeRead(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Validate ---
|
// --- Validate ---
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: `Invalid image: ${validation.reason}`,
|
error: `Invalid image: ${validation.reason}`,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { z } from "zod";
|
|||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
import { decodeHeic, ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { decodeHeic, ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
import { updateSingleFileProgress } from "../progress.js";
|
import { updateSingleFileProgress } from "../progress.js";
|
||||||
@@ -60,7 +61,7 @@ export function registerBlurFaces(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -72,6 +73,11 @@ export function registerBlurFaces(app: FastifyInstance) {
|
|||||||
fileBuffer = await decodeHeic(fileBuffer);
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
}
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
{
|
{
|
||||||
toolId: "blur-faces",
|
toolId: "blur-faces",
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ export function registerCollage(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Validate all files and decode HEIC/HEIF
|
// Validate all files and decode HEIC/HEIF
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const validation = await validateImageBuffer(file.buffer);
|
const validation = await validateImageBuffer(file.buffer, file.filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply
|
return reply
|
||||||
.status(400)
|
.status(400)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { z } from "zod";
|
|||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
@@ -66,7 +67,7 @@ export function registerColorize(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -86,6 +87,11 @@ export function registerColorize(app: FastifyInstance) {
|
|||||||
fileBuffer = await decodeHeic(fileBuffer);
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-orient to fix EXIF rotation
|
// Auto-orient to fix EXIF rotation
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
|||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
@@ -56,7 +57,7 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -75,6 +76,20 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
try {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||||
|
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: `Failed to decode ${validation.format} file`,
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate settings
|
// Validate settings
|
||||||
let settings: Settings;
|
let settings: Settings;
|
||||||
try {
|
try {
|
||||||
@@ -162,6 +177,11 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
|||||||
if (["heic", "heif", "hif"].includes(ext)) {
|
if (["heic", "heif", "hif"].includes(ext)) {
|
||||||
buf = await decodeHeic(buf);
|
buf = await decodeHeic(buf);
|
||||||
}
|
}
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR) for pipeline/batch mode
|
||||||
|
const cliCheck = await validateImageBuffer(inputBuffer, filename);
|
||||||
|
if (cliCheck.valid && needsCliDecode(cliCheck.format)) {
|
||||||
|
buf = await decodeToSharpCompat(inputBuffer, cliCheck.format);
|
||||||
|
}
|
||||||
const orientedBuffer = await autoOrient(buf);
|
const orientedBuffer = await autoOrient(buf);
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
const workspacePath = await createWorkspace(jobId);
|
const workspacePath = await createWorkspace(jobId);
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
|
|||||||
gif: "image/gif",
|
gif: "image/gif",
|
||||||
heic: "image/heic",
|
heic: "image/heic",
|
||||||
heif: "image/heif",
|
heif: "image/heif",
|
||||||
|
jxl: "image/jxl",
|
||||||
};
|
};
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
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"]),
|
||||||
quality: z.number().min(1).max(100).optional(),
|
quality: z.number().min(1).max(100).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export function registerEditMetadata(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { z } from "zod";
|
|||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
import { updateSingleFileProgress } from "../progress.js";
|
import { updateSingleFileProgress } from "../progress.js";
|
||||||
@@ -61,7 +62,7 @@ export function registerEnhanceFaces(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -82,6 +83,11 @@ export function registerEnhanceFaces(app: FastifyInstance) {
|
|||||||
fileBuffer = await decodeHeic(fileBuffer);
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-orient to fix EXIF rotation before face detection
|
// Auto-orient to fix EXIF rotation before face detection
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import sharp from "sharp";
|
|||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
import { updateSingleFileProgress } from "../progress.js";
|
import { updateSingleFileProgress } from "../progress.js";
|
||||||
@@ -90,11 +91,11 @@ export function registerEraseObject(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageValidation = await validateImageBuffer(imageBuffer);
|
const imageValidation = await validateImageBuffer(imageBuffer, filename);
|
||||||
if (!imageValidation.valid) {
|
if (!imageValidation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` });
|
||||||
}
|
}
|
||||||
const maskValidation = await validateImageBuffer(maskBuffer);
|
const maskValidation = await validateImageBuffer(maskBuffer, "mask.png");
|
||||||
if (!maskValidation.valid) {
|
if (!maskValidation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
|
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -115,6 +116,11 @@ export function registerEraseObject(app: FastifyInstance) {
|
|||||||
imageBuffer = await decodeHeic(imageBuffer);
|
imageBuffer = await decodeHeic(imageBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(imageValidation.format)) {
|
||||||
|
imageBuffer = await decodeToSharpCompat(imageBuffer, imageValidation.format);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-orient to fix EXIF rotation
|
// Auto-orient to fix EXIF rotation
|
||||||
imageBuffer = await autoOrient(imageBuffer);
|
imageBuffer = await autoOrient(imageBuffer);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import sharp from "sharp";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||||
import { createToolRoute } from "../tool-factory.js";
|
import { createToolRoute } from "../tool-factory.js";
|
||||||
@@ -60,6 +61,7 @@ export function registerImageEnhancement(app: FastifyInstance) {
|
|||||||
"/api/v1/tools/image-enhancement/analyze",
|
"/api/v1/tools/image-enhancement/analyze",
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
let fileBuffer: Buffer | null = null;
|
let fileBuffer: Buffer | null = null;
|
||||||
|
let filename = "image";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
@@ -70,6 +72,7 @@ export function registerImageEnhancement(app: FastifyInstance) {
|
|||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
}
|
}
|
||||||
fileBuffer = Buffer.concat(chunks);
|
fileBuffer = Buffer.concat(chunks);
|
||||||
|
filename = part.filename ?? "image";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +87,7 @@ export function registerImageEnhancement(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -100,6 +103,18 @@ export function registerImageEnhancement(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
try {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: `Failed to decode ${validation.format} file`,
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
const analysis = await analyzeImage(fileBuffer);
|
const analysis = await analyzeImage(fileBuffer);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { z } from "zod";
|
|||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
import { updateSingleFileProgress } from "../progress.js";
|
import { updateSingleFileProgress } from "../progress.js";
|
||||||
@@ -72,7 +73,7 @@ export function registerNoiseRemoval(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -89,6 +90,11 @@ export function registerNoiseRemoval(app: FastifyInstance) {
|
|||||||
fileBuffer = await decodeHeic(fileBuffer);
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-orient to fix EXIF rotation before processing
|
// Auto-orient to fix EXIF rotation before processing
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function registerOcr(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
|
|||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { sanitizeSvg } from "../../lib/svg-sanitize.js";
|
import { sanitizeSvg } from "../../lib/svg-sanitize.js";
|
||||||
import { createToolRoute } from "../tool-factory.js";
|
import { createToolRoute } from "../tool-factory.js";
|
||||||
@@ -83,7 +84,7 @@ export function registerOptimizeForWeb(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -100,6 +101,18 @@ export function registerOptimizeForWeb(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
try {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: `Failed to decode ${validation.format} file`,
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sanitize SVG
|
// Sanitize SVG
|
||||||
if (validation.format === "svg") {
|
if (validation.format === "svg") {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
|
|||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
|
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
|
||||||
import { updateSingleFileProgress } from "../progress.js";
|
import { updateSingleFileProgress } from "../progress.js";
|
||||||
@@ -164,7 +165,7 @@ export function registerPassportPhoto(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -177,6 +178,13 @@ export function registerPassportPhoto(app: FastifyInstance) {
|
|||||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||||
|
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-orient to fix EXIF rotation
|
// Auto-orient to fix EXIF rotation
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { z } from "zod";
|
|||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
import { decodeHeic, ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { decodeHeic, ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
import { updateSingleFileProgress } from "../progress.js";
|
import { updateSingleFileProgress } from "../progress.js";
|
||||||
@@ -62,7 +63,7 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -74,6 +75,11 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
|
|||||||
fileBuffer = await decodeHeic(fileBuffer);
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
}
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
{
|
{
|
||||||
toolId: "red-eye-removal",
|
toolId: "red-eye-removal",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
|
|||||||
import { applyEffects } from "../../lib/bg-effects.js";
|
import { applyEffects } from "../../lib/bg-effects.js";
|
||||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
|
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
|
||||||
import { updateSingleFileProgress } from "../progress.js";
|
import { updateSingleFileProgress } from "../progress.js";
|
||||||
@@ -85,7 +86,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -100,6 +101,13 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
|||||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||||
|
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-orient to fix EXIF rotation
|
// Auto-orient to fix EXIF rotation
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
|
||||||
@@ -177,6 +185,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
|||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
let settingsRaw: string | null = null;
|
let settingsRaw: string | null = null;
|
||||||
let bgImageBuffer: Buffer | null = null;
|
let bgImageBuffer: Buffer | null = null;
|
||||||
|
let bgFilename = "background";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
@@ -185,6 +194,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
|||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of part.file) chunks.push(chunk);
|
for await (const chunk of part.file) chunks.push(chunk);
|
||||||
bgImageBuffer = Buffer.concat(chunks);
|
bgImageBuffer = Buffer.concat(chunks);
|
||||||
|
bgFilename = part.filename ?? "background";
|
||||||
} else if (part.type === "field" && part.fieldname === "settings") {
|
} else if (part.type === "field" && part.fieldname === "settings") {
|
||||||
settingsRaw = part.value as string;
|
settingsRaw = part.value as string;
|
||||||
}
|
}
|
||||||
@@ -221,10 +231,13 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Decode HEIC/HEIF background image if needed
|
// Decode HEIC/HEIF background image if needed
|
||||||
if (bgImageBuffer) {
|
if (bgImageBuffer) {
|
||||||
const bgValidation = await validateImageBuffer(bgImageBuffer);
|
const bgValidation = await validateImageBuffer(bgImageBuffer, bgFilename);
|
||||||
if (bgValidation.valid && bgValidation.format === "heif") {
|
if (bgValidation.valid && bgValidation.format === "heif") {
|
||||||
bgImageBuffer = await decodeHeic(bgImageBuffer);
|
bgImageBuffer = await decodeHeic(bgImageBuffer);
|
||||||
}
|
}
|
||||||
|
if (bgValidation.valid && needsCliDecode(bgValidation.format)) {
|
||||||
|
bgImageBuffer = await decodeToSharpCompat(bgImageBuffer, bgValidation.format);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply effects using cached mask + original
|
// Apply effects using cached mask + original
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { z } from "zod";
|
|||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
@@ -75,7 +76,7 @@ export function registerRestorePhoto(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -93,6 +94,11 @@ export function registerRestorePhoto(app: FastifyInstance) {
|
|||||||
fileBuffer = await decodeHeic(fileBuffer);
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-orient to fix EXIF rotation
|
// Auto-orient to fix EXIF rotation
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function registerStitch(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const validation = await validateImageBuffer(file.buffer);
|
const validation = await validateImageBuffer(file.buffer, file.filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply
|
return reply
|
||||||
.status(400)
|
.status(400)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { z } from "zod";
|
|||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
import { updateSingleFileProgress } from "../progress.js";
|
import { updateSingleFileProgress } from "../progress.js";
|
||||||
@@ -64,7 +65,7 @@ export function registerUpscale(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
@@ -87,6 +88,11 @@ export function registerUpscale(app: FastifyInstance) {
|
|||||||
fileBuffer = await decodeHeic(fileBuffer);
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-orient to fix EXIF rotation before upscaling
|
// Auto-orient to fix EXIF rotation before upscaling
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
if (buffer.length === 0) continue;
|
if (buffer.length === 0) continue;
|
||||||
|
|
||||||
// Validate image
|
// Validate image
|
||||||
const validation = await validateImageBuffer(buffer);
|
const validation = await validateImageBuffer(buffer, part.filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: `Invalid file "${part.filename}": ${validation.reason}`,
|
error: `Invalid file "${part.filename}": ${validation.reason}`,
|
||||||
@@ -503,7 +503,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate the image
|
// Validate the image
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: `Invalid file: ${validation.reason}`,
|
error: `Invalid file: ${validation.reason}`,
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ interface DropzoneProps {
|
|||||||
currentFiles?: File[];
|
currentFiles?: File[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browsers may not map .heic/.heif to image/* in file pickers.
|
// Browsers may not map certain formats (HEIC, JXL, RAW, etc.) to image/* in file pickers.
|
||||||
// Append explicit extensions so they are selectable.
|
// Append explicit extensions so they are selectable.
|
||||||
function expandAccept(accept?: string): string | undefined {
|
function expandAccept(accept?: string): string | undefined {
|
||||||
if (!accept?.includes("image/*")) return accept;
|
if (!accept?.includes("image/*")) return accept;
|
||||||
return `${accept},.heic,.heif,.hif`;
|
return `${accept},.heic,.heif,.hif,.jxl,.ico,.dng,.cr2,.nef,.arw,.orf,.rw2,.tga,.psd,.exr,.hdr`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }: DropzoneProps) {
|
export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }: DropzoneProps) {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function FileUploadArea() {
|
|||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,.heic,.heif,.hif"
|
accept="image/*,.heic,.heif,.hif,.jxl,.dng,.cr2,.nef,.arw,.orf,.rw2,.tga,.psd,.exr,.hdr"
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { ProgressCard } from "@/components/common/progress-card";
|
|||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"] as const;
|
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif", "jxl"] as const;
|
||||||
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif"];
|
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif", "jxl"];
|
||||||
|
|
||||||
export interface ConvertControlsProps {
|
export interface ConvertControlsProps {
|
||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
|
||||||
const HEIF_EXTENSIONS = new Set(["heic", "heif", "hif"]);
|
const SERVER_PREVIEW_EXTENSIONS = new Set([
|
||||||
|
"heic", "heif", "hif", // HEIF
|
||||||
|
"jxl", // JPEG XL (Chrome dropped support)
|
||||||
|
"dng", "cr2", "nef", "arw", "orf", "rw2", // Camera RAW
|
||||||
|
"tga", // Targa
|
||||||
|
"psd", // Photoshop
|
||||||
|
"exr", // OpenEXR
|
||||||
|
"hdr", // Radiance HDR
|
||||||
|
]);
|
||||||
|
|
||||||
export function needsServerPreview(file: File): boolean {
|
export function needsServerPreview(file: File): boolean {
|
||||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||||
return HEIF_EXTENSIONS.has(ext);
|
return SERVER_PREVIEW_EXTENSIONS.has(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDecodedPreview(file: File): Promise<string | null> {
|
export async function fetchDecodedPreview(file: File): Promise<string | null> {
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ export function ToolPage() {
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.multiple = true;
|
input.multiple = true;
|
||||||
input.accept = "image/*,.heic,.heif,.hif";
|
input.accept = "image/*,.heic,.heif,.hif,.jxl,.dng,.cr2,.nef,.arw,.orf,.rw2,.tga,.psd,.exr,.hdr";
|
||||||
input.onchange = (e) => {
|
input.onchange = (e) => {
|
||||||
const newFiles = Array.from((e.target as HTMLInputElement).files || []);
|
const newFiles = Array.from((e.target as HTMLInputElement).files || []);
|
||||||
if (newFiles.length > 0) addFiles(newFiles);
|
if (newFiles.length > 0) addFiles(newFiles);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libheif-examples \
|
libheif-examples \
|
||||||
libimage-exiftool-perl \
|
libimage-exiftool-perl \
|
||||||
|
imagemagick \
|
||||||
|
libraw-dev \
|
||||||
&& if apt-cache show libheif-plugin-x265 >/dev/null 2>&1; then \
|
&& if apt-cache show libheif-plugin-x265 >/dev/null 2>&1; then \
|
||||||
apt-get install -y --no-install-recommends libheif-plugin-x265; \
|
apt-get install -y --no-install-recommends libheif-plugin-x265; \
|
||||||
fi \
|
fi \
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const FORMAT_MAP: Record<string, string> = {
|
|||||||
heif: "avif",
|
heif: "avif",
|
||||||
tiff: "tiff",
|
tiff: "tiff",
|
||||||
gif: "gif",
|
gif: "gif",
|
||||||
|
jxl: "jxl",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ const MAGIC_BYTES: Array<{ bytes: number[]; offset: number; format: string }> =
|
|||||||
{ bytes: [0x49, 0x49, 0x2a, 0x00], offset: 0, format: "tiff" }, // Little-endian TIFF
|
{ bytes: [0x49, 0x49, 0x2a, 0x00], offset: 0, format: "tiff" }, // Little-endian TIFF
|
||||||
{ bytes: [0x4d, 0x4d, 0x00, 0x2a], offset: 0, format: "tiff" }, // Big-endian TIFF
|
{ bytes: [0x4d, 0x4d, 0x00, 0x2a], offset: 0, format: "tiff" }, // Big-endian TIFF
|
||||||
{ bytes: [0x42, 0x4d], offset: 0, format: "bmp" },
|
{ bytes: [0x42, 0x4d], offset: 0, format: "bmp" },
|
||||||
|
// AVIF (ftyp box at offset 4, brand verified in detectByMagicBytes)
|
||||||
|
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "avif" },
|
||||||
|
// JXL ISOBMFF container
|
||||||
|
{ bytes: [0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20], offset: 0, format: "jxl" },
|
||||||
|
// JXL raw codestream
|
||||||
|
{ bytes: [0xff, 0x0a], offset: 0, format: "jxl" },
|
||||||
|
// ICO
|
||||||
|
{ bytes: [0x00, 0x00, 0x01, 0x00], offset: 0, format: "ico" },
|
||||||
|
// PSD ("8BPS")
|
||||||
|
{ bytes: [0x38, 0x42, 0x50, 0x53], offset: 0, format: "psd" },
|
||||||
|
// OpenEXR
|
||||||
|
{ bytes: [0x76, 0x2f, 0x31, 0x01], offset: 0, format: "exr" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +61,12 @@ function detectByMagicBytes(buffer: Buffer): string {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// For ftyp, verify AVIF brand at bytes 8-11
|
||||||
|
if (entry.format === "avif") {
|
||||||
|
if (buffer.length < 12) continue;
|
||||||
|
const brand = buffer.slice(8, 12).toString("ascii");
|
||||||
|
if (brand !== "avif" && brand !== "avis") continue;
|
||||||
|
}
|
||||||
return entry.format;
|
return entry.format;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const FORMAT_MAP: Record<string, string> = {
|
|||||||
avif: "avif",
|
avif: "avif",
|
||||||
tiff: "tiff",
|
tiff: "tiff",
|
||||||
gif: "gif",
|
gif: "gif",
|
||||||
|
jxl: "jxl" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function convert(image: Sharp, options: ConvertOptions): Promise<Sharp> {
|
export async function convert(image: Sharp, options: ConvertOptions): Promise<Sharp> {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface OperationResult {
|
|||||||
info: ImageInfo;
|
info: ImageInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OutputFormat = "jpg" | "png" | "webp" | "avif" | "tiff" | "gif" | "heic" | "heif";
|
export type OutputFormat = "jpg" | "png" | "webp" | "avif" | "tiff" | "gif" | "heic" | "heif" | "jxl";
|
||||||
|
|
||||||
export interface ResizeOptions {
|
export interface ResizeOptions {
|
||||||
width?: number;
|
width?: number;
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ const EXT_TO_MIME: Record<string, string> = {
|
|||||||
ico: "image/x-icon",
|
ico: "image/x-icon",
|
||||||
heif: "image/heif",
|
heif: "image/heif",
|
||||||
heic: "image/heic",
|
heic: "image/heic",
|
||||||
|
jxl: "image/jxl",
|
||||||
|
dng: "image/x-adobe-dng",
|
||||||
|
cr2: "image/x-canon-cr2",
|
||||||
|
nef: "image/x-nikon-nef",
|
||||||
|
arw: "image/x-sony-arw",
|
||||||
|
orf: "image/x-olympus-orf",
|
||||||
|
rw2: "image/x-panasonic-rw2",
|
||||||
|
tga: "image/x-tga",
|
||||||
|
psd: "image/vnd.adobe.photoshop",
|
||||||
|
exr: "image/x-exr",
|
||||||
|
hdr: "image/vnd.radiance",
|
||||||
};
|
};
|
||||||
|
|
||||||
const MIME_TO_EXT: Record<string, string> = {
|
const MIME_TO_EXT: Record<string, string> = {
|
||||||
@@ -26,6 +37,17 @@ const MIME_TO_EXT: Record<string, string> = {
|
|||||||
"image/x-icon": "ico",
|
"image/x-icon": "ico",
|
||||||
"image/heif": "heif",
|
"image/heif": "heif",
|
||||||
"image/heic": "heic",
|
"image/heic": "heic",
|
||||||
|
"image/jxl": "jxl",
|
||||||
|
"image/x-adobe-dng": "dng",
|
||||||
|
"image/x-canon-cr2": "cr2",
|
||||||
|
"image/x-nikon-nef": "nef",
|
||||||
|
"image/x-sony-arw": "arw",
|
||||||
|
"image/x-olympus-orf": "orf",
|
||||||
|
"image/x-panasonic-rw2": "rw2",
|
||||||
|
"image/x-tga": "tga",
|
||||||
|
"image/vnd.adobe.photoshop": "psd",
|
||||||
|
"image/x-exr": "exr",
|
||||||
|
"image/vnd.radiance": "hdr",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user