merge: resolve conflict with main branch in tool-registry.tsx

This commit is contained in:
SnapOtter
2026-05-08 18:55:28 +08:00
196 changed files with 36147 additions and 130 deletions
+118
View File
@@ -0,0 +1,118 @@
import sharp from "sharp";
import { parseHex } from "./constants.js";
interface SolidOpts {
type: "solid";
color: string;
width: number;
height: number;
}
interface GradientOpts {
type: "linear-gradient" | "radial-gradient";
stops: Array<{ color: string; position: number }>;
angle?: number;
width: number;
height: number;
}
interface TransparentOpts {
type: "transparent";
width: number;
height: number;
}
interface ImageOpts {
type: "image";
imageBuffer: Buffer;
width: number;
height: number;
}
export type BackgroundOpts = SolidOpts | GradientOpts | TransparentOpts | ImageOpts;
export async function generateBackground(opts: BackgroundOpts): Promise<Buffer> {
const { width, height } = opts;
if (opts.type === "solid") {
const c = parseHex(opts.color);
return sharp({
create: {
width,
height,
channels: 4,
background: { r: c.r, g: c.g, b: c.b, alpha: 1 },
},
})
.png()
.toBuffer();
}
if (opts.type === "transparent") {
return sharp({
create: {
width,
height,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.png()
.toBuffer();
}
if (opts.type === "image") {
return sharp(opts.imageBuffer)
.resize(width, height, { fit: "cover" })
.ensureAlpha()
.png()
.toBuffer();
}
// Gradient (linear or radial)
const stops = opts.stops
.map((s) => `<stop offset="${s.position}%" stop-color="${s.color}"/>`)
.join("\n ");
let gradientDef: string;
if (opts.type === "linear-gradient") {
const angle = opts.angle ?? 135;
const rad = ((angle - 90) * Math.PI) / 180;
const x1 = Math.round(50 - Math.cos(rad) * 50);
const y1 = Math.round(50 - Math.sin(rad) * 50);
const x2 = Math.round(50 + Math.cos(rad) * 50);
const y2 = Math.round(50 + Math.sin(rad) * 50);
gradientDef = `<linearGradient id="g" x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%">
${stops}
</linearGradient>`;
} else {
gradientDef = `<radialGradient id="g" cx="50%" cy="50%" r="70%">
${stops}
</radialGradient>`;
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<defs>${gradientDef}</defs>
<rect width="${width}" height="${height}" fill="url(#g)"/>
</svg>`;
return sharp(Buffer.from(svg)).png().toBuffer();
}
export function getDominantBackground(
opts: Pick<BackgroundOpts, "type"> & {
color?: string;
stops?: Array<{ color: string; position: number }>;
},
): { r: number; g: number; b: number; alpha: number } {
if (opts.type === "solid" && opts.color) {
const c = parseHex(opts.color);
return { ...c, alpha: 1 };
}
if ((opts.type === "linear-gradient" || opts.type === "radial-gradient") && opts.stops?.length) {
const last = opts.stops[opts.stops.length - 1];
const c = parseHex(last.color);
return { ...c, alpha: 1 };
}
return { r: 0, g: 0, b: 0, alpha: 0 };
}
+110
View File
@@ -0,0 +1,110 @@
import { z } from "zod";
export const SHADOW_PRESETS = {
none: { blur: 0, offsetX: 0, offsetY: 0, color: "#000000", opacity: 0 },
subtle: { blur: 20, offsetX: 0, offsetY: 4, color: "#000000", opacity: 20 },
medium: { blur: 40, offsetX: 0, offsetY: 10, color: "#000000", opacity: 35 },
dramatic: { blur: 80, offsetX: 0, offsetY: 20, color: "#000000", opacity: 50 },
} as const;
export const SOCIAL_PRESETS = {
none: null,
twitter: { width: 1600, height: 900 },
linkedin: { width: 1200, height: 627 },
"instagram-square": { width: 1080, height: 1080 },
"instagram-story": { width: 1080, height: 1920 },
facebook: { width: 1200, height: 630 },
producthunt: { width: 1270, height: 760 },
} as const;
export const DEVICE_FRAMES = new Set([
"iphone",
"iphone-dark",
"macbook",
"macbook-dark",
"ipad",
"ipad-dark",
]);
export const SVG_FRAMES = new Set([
"macos-light",
"macos-dark",
"windows-light",
"windows-dark",
"browser-light",
"browser-dark",
]);
export const gradientStopSchema = z.object({
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
position: z.number().min(0).max(100),
});
export const settingsSchema = z.object({
backgroundType: z
.enum(["solid", "linear-gradient", "radial-gradient", "image", "transparent"])
.default("linear-gradient"),
backgroundColor: z.string().default("#667eea"),
gradientStops: z
.array(gradientStopSchema)
.min(2)
.default([
{ color: "#667eea", position: 0 },
{ color: "#764ba2", position: 100 },
]),
gradientAngle: z.number().min(0).max(360).default(135),
padding: z.number().min(0).max(256).default(64),
borderRadius: z.number().min(0).max(64).default(12),
shadowPreset: z.enum(["none", "subtle", "medium", "dramatic", "custom"]).default("subtle"),
shadowBlur: z.number().min(0).max(100).default(20),
shadowOffsetX: z.number().min(-50).max(50).default(0),
shadowOffsetY: z.number().min(-50).max(50).default(10),
shadowColor: z.string().default("#000000"),
shadowOpacity: z.number().min(0).max(100).default(30),
frame: z
.enum([
"none",
"macos-light",
"macos-dark",
"windows-light",
"windows-dark",
"browser-light",
"browser-dark",
"iphone",
"iphone-dark",
"macbook",
"macbook-dark",
"ipad",
"ipad-dark",
])
.default("none"),
frameTitle: z.string().optional(),
socialPreset: z
.enum([
"none",
"twitter",
"linkedin",
"instagram-square",
"instagram-story",
"facebook",
"producthunt",
])
.default("none"),
watermarkText: z.string().optional(),
watermarkPosition: z
.enum(["top-left", "top-right", "bottom-left", "bottom-right", "center"])
.default("bottom-right"),
watermarkOpacity: z.number().min(0).max(100).default(50),
outputFormat: z.enum(["png", "jpeg", "webp"]).default("png"),
});
export type BeautifySettings = z.infer<typeof settingsSchema>;
export function parseHex(hex: string): { r: number; g: number; b: number } {
const h = hex.replace("#", "");
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
};
}
+252
View File
@@ -0,0 +1,252 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import { DEVICE_FRAMES, SVG_FRAMES } from "./constants.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const FRAMES_DIR = join(__dirname, "../../assets/frames");
interface DeviceFrameData {
png: Buffer;
meta: { screenX: number; screenY: number; screenW: number; screenH: number };
}
const frameCache = new Map<string, DeviceFrameData>();
function loadDeviceFrame(frameId: string): DeviceFrameData {
const cached = frameCache.get(frameId);
if (cached) return cached;
const pngBuf = readFileSync(join(FRAMES_DIR, `${frameId}.png`));
const meta = JSON.parse(readFileSync(join(FRAMES_DIR, `${frameId}.meta.json`), "utf-8"));
const data: DeviceFrameData = { png: pngBuf, meta };
frameCache.set(frameId, data);
return data;
}
async function renderDeviceFrame(imageBuffer: Buffer, frameId: string): Promise<Buffer> {
const { png: framePng, meta } = loadDeviceFrame(frameId);
const frameMeta = await sharp(framePng).metadata();
const frameW = frameMeta.width ?? 0;
const frameH = frameMeta.height ?? 0;
// Resize the screenshot to fit the screen rect
const resized = await sharp(imageBuffer)
.resize(meta.screenW, meta.screenH, { fit: "fill" })
.png()
.toBuffer();
// Composite: screenshot behind frame so the frame bezel overlays it
const result = await sharp({
create: {
width: frameW,
height: frameH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([
{ input: resized, left: meta.screenX, top: meta.screenY },
{ input: framePng, left: 0, top: 0 },
])
.png()
.toBuffer();
return result;
}
const WINDOW_TITLE_BAR_HEIGHT = 36;
const BROWSER_TITLE_BAR_HEIGHT = 72;
function escapeXml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function macosFrame(width: number, variant: "light" | "dark", title?: string): string {
const bg = variant === "light" ? "#e8e8e8" : "#3a3a3c";
const h = WINDOW_TITLE_BAR_HEIGHT;
const dotY = h / 2;
const dotR = 6;
const dotStart = 20;
const dotGap = 20;
const titleText = title
? `<text x="${width / 2}" y="${dotY + 1}" text-anchor="middle" font-family="sans-serif" font-size="13" fill="${variant === "light" ? "#4b4b4b" : "#d4d4d4"}">${escapeXml(title)}</text>`
: "";
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}">
<rect width="${width}" height="${h}" fill="${bg}"/>
<circle cx="${dotStart}" cy="${dotY}" r="${dotR}" fill="#ff5f57"/>
<circle cx="${dotStart + dotGap}" cy="${dotY}" r="${dotR}" fill="#febc2e"/>
<circle cx="${dotStart + dotGap * 2}" cy="${dotY}" r="${dotR}" fill="#28c840"/>
${titleText}
</svg>`;
}
function windowsFrame(width: number, variant: "light" | "dark", title?: string): string {
const bg = variant === "light" ? "#f0f0f0" : "#2b2b2b";
const h = WINDOW_TITLE_BAR_HEIGHT;
const iconColor = variant === "light" ? "#616161" : "#a0a0a0";
const closeHoverBg = variant === "light" ? "#e81123" : "#e81123";
const midY = h / 2;
const btnW = 46;
const btnH = h;
const closeX = width - btnW;
const maxX = closeX - btnW;
const minX = maxX - btnW;
const titleText = title
? `<text x="12" y="${midY + 1}" dominant-baseline="middle" font-family="sans-serif" font-size="12" fill="${variant === "light" ? "#1a1a1a" : "#d4d4d4"}">${escapeXml(title)}</text>`
: "";
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}">
<rect width="${width}" height="${h}" fill="${bg}"/>
${titleText}
<g>
<!-- Minimize -->
<line x1="${minX + 18}" y1="${midY}" x2="${minX + 28}" y2="${midY}" stroke="${iconColor}" stroke-width="1"/>
<!-- Maximize -->
<rect x="${maxX + 18}" y="${midY - 5}" width="10" height="10" fill="none" stroke="${iconColor}" stroke-width="1"/>
<!-- Close -->
<rect x="${closeX}" y="0" width="${btnW}" height="${btnH}" fill="${closeHoverBg}" opacity="0"/>
<line x1="${closeX + 18}" y1="${midY - 5}" x2="${closeX + 28}" y2="${midY + 5}" stroke="${iconColor}" stroke-width="1"/>
<line x1="${closeX + 28}" y1="${midY - 5}" x2="${closeX + 18}" y2="${midY + 5}" stroke="${iconColor}" stroke-width="1"/>
</g>
</svg>`;
}
function browserFrame(width: number, variant: "light" | "dark", title?: string): string {
const bg = variant === "light" ? "#f1f5f9" : "#1e293b";
const h = BROWSER_TITLE_BAR_HEIGHT;
const topH = 36;
const bottomH = h - topH;
const dotY = topH / 2;
const dotR = 6;
const dotStart = 20;
const dotGap = 20;
const tabBg = variant === "light" ? "#ffffff" : "#334155";
const tabTextColor = variant === "light" ? "#334155" : "#e2e8f0";
const tabText = title ? escapeXml(title) : "New Tab";
const tabWidth = Math.min(200, width - 120);
const tabX = dotStart + dotGap * 3 + 16;
const barBg = variant === "light" ? "#ffffff" : "#0f172a";
const barTextColor = variant === "light" ? "#64748b" : "#94a3b8";
const barPadX = 12;
const barY = topH + 6;
const barH = bottomH - 12;
const barRx = Math.min(barH / 2, 10);
const urlText = title ? escapeXml(title) : "";
// Lock icon path (small padlock in the URL bar)
const lockX = barPadX + 10;
const lockY = barY + barH / 2;
const lockColor = variant === "light" ? "#16a34a" : "#4ade80";
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}">
<!-- Top bar background -->
<rect width="${width}" height="${topH}" fill="${bg}"/>
<!-- Bottom bar background -->
<rect y="${topH}" width="${width}" height="${bottomH}" fill="${bg}"/>
<!-- Traffic lights -->
<circle cx="${dotStart}" cy="${dotY}" r="${dotR}" fill="#ff5f57"/>
<circle cx="${dotStart + dotGap}" cy="${dotY}" r="${dotR}" fill="#febc2e"/>
<circle cx="${dotStart + dotGap * 2}" cy="${dotY}" r="${dotR}" fill="#28c840"/>
<!-- Tab -->
<rect x="${tabX}" y="8" width="${tabWidth}" height="${topH - 8}" rx="8" ry="8" fill="${tabBg}"/>
<text x="${tabX + 12}" y="${dotY + 1}" dominant-baseline="middle" font-family="sans-serif" font-size="12" fill="${tabTextColor}">${tabText}</text>
<!-- Address bar -->
<rect x="${barPadX}" y="${barY}" width="${width - barPadX * 2}" height="${barH}" rx="${barRx}" ry="${barRx}" fill="${barBg}"/>
<!-- Lock icon -->
<g transform="translate(${lockX}, ${lockY})">
<rect x="-4" y="-2" width="8" height="6" rx="1" fill="${lockColor}"/>
<path d="M-3,-2 L-3,-4 A3,3 0 0,1 3,-4 L3,-2" fill="none" stroke="${lockColor}" stroke-width="1.5"/>
</g>
<!-- URL text -->
<text x="${lockX + 10}" y="${lockY + 1}" dominant-baseline="middle" font-family="sans-serif" font-size="12" fill="${barTextColor}">${urlText}</text>
<!-- Separator line -->
<line x1="0" y1="${h}" x2="${width}" y2="${h}" stroke="${variant === "light" ? "#e2e8f0" : "#334155"}" stroke-width="1"/>
</svg>`;
}
function generateSvgFrame(
width: number,
frame: string,
title?: string,
): { svg: string; height: number } {
switch (frame) {
case "macos-light":
return { svg: macosFrame(width, "light", title), height: WINDOW_TITLE_BAR_HEIGHT };
case "macos-dark":
return { svg: macosFrame(width, "dark", title), height: WINDOW_TITLE_BAR_HEIGHT };
case "windows-light":
return { svg: windowsFrame(width, "light", title), height: WINDOW_TITLE_BAR_HEIGHT };
case "windows-dark":
return { svg: windowsFrame(width, "dark", title), height: WINDOW_TITLE_BAR_HEIGHT };
case "browser-light":
return { svg: browserFrame(width, "light", title), height: BROWSER_TITLE_BAR_HEIGHT };
case "browser-dark":
return { svg: browserFrame(width, "dark", title), height: BROWSER_TITLE_BAR_HEIGHT };
default:
throw new Error(`Unknown SVG frame type: ${frame}`);
}
}
export async function renderFrame(
imageBuffer: Buffer,
frame: string,
title?: string,
): Promise<Buffer> {
if (frame === "none") {
return imageBuffer;
}
if (DEVICE_FRAMES.has(frame)) {
return renderDeviceFrame(imageBuffer, frame);
}
if (!SVG_FRAMES.has(frame)) {
return imageBuffer;
}
const meta = await sharp(imageBuffer).metadata();
const imgW = meta.width ?? 100;
const imgH = meta.height ?? 100;
const { svg, height: titleBarH } = generateSvgFrame(imgW, frame, title);
const titleBarBuf = await sharp(Buffer.from(svg)).resize(imgW, titleBarH).png().toBuffer();
const result = await sharp({
create: {
width: imgW,
height: titleBarH + imgH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([
{ input: titleBarBuf, left: 0, top: 0 },
{ input: imageBuffer, left: 0, top: titleBarH },
])
.png()
.toBuffer();
return result;
}
+82
View File
@@ -0,0 +1,82 @@
import sharp from "sharp";
import { parseHex } from "./constants.js";
interface ShadowOpts {
blur: number;
offsetX: number;
offsetY: number;
color: string;
opacity: number;
}
interface ShadowResult {
buffer: Buffer;
imgX: number;
imgY: number;
padLeft: number;
padTop: number;
}
export async function applyShadow(imageBuffer: Buffer, opts: ShadowOpts): Promise<ShadowResult> {
const buf = await sharp(imageBuffer).ensureAlpha().png().toBuffer();
const meta = await sharp(buf).metadata();
const bW = meta.width ?? 100;
const bH = meta.height ?? 100;
const sc = parseHex(opts.color);
const alpha = opts.opacity / 100;
const blur = opts.blur;
const spread = Math.ceil(blur * 2);
const ox = opts.offsetX;
const oy = opts.offsetY;
const shadowSilhouette = await sharp({
create: {
width: bW,
height: bH,
channels: 4,
background: { r: sc.r, g: sc.g, b: sc.b, alpha },
},
})
.composite([{ input: buf, blend: "dest-in" }])
.extend({
top: spread,
bottom: spread,
left: spread,
right: spread,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.blur(Math.max(blur, 0.3))
.png()
.toBuffer();
const padL = Math.max(0, spread - ox);
const padR = Math.max(0, spread + ox);
const padT = Math.max(0, spread - oy);
const padB = Math.max(0, spread + oy);
const canvasW = bW + padL + padR;
const canvasH = bH + padT + padB;
const imgX = padL;
const imgY = padT;
const shadX = Math.max(0, imgX + ox - spread);
const shadY = Math.max(0, imgY + oy - spread);
const result = await sharp({
create: {
width: canvasW,
height: canvasH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([
{ input: shadowSilhouette, left: shadX, top: shadY },
{ input: buf, left: imgX, top: imgY },
])
.png()
.toBuffer();
return { buffer: result, imgX, imgY, padLeft: padL, padTop: padT };
}
+126 -2
View File
@@ -20,6 +20,17 @@ const SUPPORTED_INPUT_FORMATS = new Set([
"psd",
"exr",
"hdr",
"jp2",
"qoi",
"eps",
"dds",
"cur",
"dpx",
"fits",
"ppm",
"pgm",
"pbm",
"pfm",
]);
interface MagicEntry {
@@ -38,6 +49,19 @@ const MAGIC_BYTES: MagicEntry[] = [
{ 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: "heif" }, // ftyp box; verified below
{ bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, format: "cr3" }, // ftyp box; verified below
// Fujifilm RAF: "FUJIFILMCCD-RAW" at offset 0
{
bytes: [
0x46, 0x55, 0x4a, 0x49, 0x46, 0x49, 0x4c, 0x4d, 0x43, 0x43, 0x44, 0x2d, 0x52, 0x41, 0x57,
],
offset: 0,
format: "raw",
},
// Sigma X3F: "FOVb" at offset 0
{ bytes: [0x46, 0x4f, 0x56, 0x62], offset: 0, format: "raw" },
// Minolta MRW: "\x00MRM" at offset 0
{ bytes: [0x00, 0x4d, 0x52, 0x4d], offset: 0, format: "raw" },
// JXL ISOBMFF container
{ bytes: [0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20], offset: 0, format: "jxl" },
// JXL raw codestream
@@ -49,6 +73,47 @@ const MAGIC_BYTES: MagicEntry[] = [
// OpenEXR
{ bytes: [0x76, 0x2f, 0x31, 0x01], offset: 0, format: "exr" },
// TGA has no reliable magic bytes — detected by extension only
// JPEG 2000 JP2 box signature (NOT ISOBMFF)
{
bytes: [0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a],
offset: 0,
format: "jp2",
},
// JPEG 2000 raw codestream (J2K/J2C)
{ bytes: [0xff, 0x4f, 0xff, 0x51], offset: 0, format: "jp2" },
// QOI: "qoif" at offset 0
{ bytes: [0x71, 0x6f, 0x69, 0x66], offset: 0, format: "qoi" },
// DDS: "DDS " at offset 0
{ bytes: [0x44, 0x44, 0x53, 0x20], offset: 0, format: "dds" },
// CUR: Windows cursor (ICO variant, byte 3 = 0x02 vs ICO's 0x01)
{ bytes: [0x00, 0x00, 0x02, 0x00], offset: 0, format: "cur" },
// DPX forward: "SDPX"
{ bytes: [0x53, 0x44, 0x50, 0x58], offset: 0, format: "dpx" },
// DPX reverse: "XPDS"
{ bytes: [0x58, 0x50, 0x44, 0x53], offset: 0, format: "dpx" },
// Cineon
{ bytes: [0x80, 0x2a, 0x5f, 0xd7], offset: 0, format: "dpx" },
// FITS: "SIMPLE" at offset 0
{ bytes: [0x53, 0x49, 0x4d, 0x50, 0x4c, 0x45], offset: 0, format: "fits" },
// EPS ASCII header: "%!PS-Adobe"
{
bytes: [0x25, 0x21, 0x50, 0x53, 0x2d, 0x41, 0x64, 0x6f, 0x62, 0x65],
offset: 0,
format: "eps",
},
// EPS binary (DOS EPS)
{ bytes: [0xc5, 0xd0, 0xd3, 0xc6], offset: 0, format: "eps" },
// Netpbm: P1-P7 headers (these MUST go AFTER the PNG entry to avoid false matches on 0x50)
{ bytes: [0x50, 0x31], offset: 0, format: "pbm" },
{ bytes: [0x50, 0x34], offset: 0, format: "pbm" },
{ bytes: [0x50, 0x32], offset: 0, format: "pgm" },
{ bytes: [0x50, 0x35], offset: 0, format: "pgm" },
{ bytes: [0x50, 0x33], offset: 0, format: "ppm" },
{ bytes: [0x50, 0x36], offset: 0, format: "ppm" },
{ bytes: [0x50, 0x37], offset: 0, format: "ppm" },
// PFM (Portable FloatMap)
{ bytes: [0x50, 0x46], offset: 0, format: "pfm" },
{ bytes: [0x50, 0x66], offset: 0, format: "pfm" },
];
export interface ValidationResult {
@@ -64,10 +129,50 @@ export interface ValidationError {
}
/** Camera RAW extensions that share TIFF magic bytes. */
const RAW_EXTENSIONS = new Set(["dng", "cr2", "nef", "arw", "orf", "rw2"]);
const RAW_EXTENSIONS = new Set([
"dng",
"cr2",
"cr3",
"nef",
"nrw",
"arw",
"orf",
"rw2",
"raf",
"pef",
"3fr",
"iiq",
"srw",
"x3f",
"rwl",
"gpr",
"fff",
"mrw",
"mef",
"kdc",
"dcr",
"erf",
"ptx",
]);
/** Formats that Sharp cannot decode natively — skip dimension check. */
const CLI_DECODED_FORMATS = new Set(["raw", "ico", "tga", "psd", "exr", "hdr", "bmp", "jxl"]);
const CLI_DECODED_FORMATS = new Set([
"raw",
"ico",
"tga",
"psd",
"exr",
"hdr",
"bmp",
"jxl",
"jp2",
"qoi",
"eps",
"dds",
"cur",
"dpx",
"fits",
]);
/**
* Check whether a file extension corresponds to a Camera RAW format.
@@ -121,6 +226,18 @@ export async function validateImageBuffer(
detectedFormat = "tga";
}
// SVGZ: gzip-compressed SVG, detected by extension + gzip magic
if (!detectedFormat && ext === "svgz") {
if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) {
detectedFormat = "svg";
}
}
// APNG: Sharp handles as PNG first frame. Accept .apng extension.
if (!detectedFormat && ext === "apng") {
detectedFormat = "png";
}
if (!detectedFormat) {
return { valid: false, reason: "Unrecognized image format" };
}
@@ -219,6 +336,13 @@ function detectMagicBytes(buffer: Buffer): string | null {
const brand = buffer.slice(8, 12).toString("ascii");
if (!["heic", "heix", "mif1", "msf1", "hevc", "hevx"].includes(brand)) continue;
}
// For ftyp, verify CR3 brand at bytes 8-11.
if (entry.format === "cr3") {
if (buffer.length < 12) continue;
const brand = buffer.slice(8, 12).toString("ascii");
if (brand !== "crx ") continue;
return "raw"; // CR3 is a RAW format, routed through decodeRaw()
}
return entry.format;
}
}
+248 -7
View File
@@ -4,11 +4,31 @@ 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", "ico", "tga", "psd", "exr", "hdr", "bmp", "jxl"]);
const CLI_DECODED_FORMATS = new Set([
"raw",
"ico",
"tga",
"psd",
"exr",
"hdr",
"bmp",
"jxl",
"jp2",
"qoi",
"eps",
"dds",
"cur",
"dpx",
"ppm",
"pgm",
"pbm",
"fits",
]);
export function needsCliDecode(format: string): boolean {
return CLI_DECODED_FORMATS.has(format);
@@ -17,11 +37,22 @@ export function needsCliDecode(format: string): boolean {
/**
* Main entry point - routes to the right decoder based on format.
* Returns a PNG buffer that Sharp can process downstream.
*
* @param buffer - The raw file buffer
* @param format - The detected format string (e.g. "raw", "psd", "ico")
* @param ext - Optional original file extension (e.g. "cr3", "nef").
* Passed to decodeRaw so the temp file uses the correct
* extension, which helps ExifTool and ImageMagick identify
* the RAW variant.
*/
export async function decodeToSharpCompat(buffer: Buffer, format: string): Promise<Buffer> {
export async function decodeToSharpCompat(
buffer: Buffer,
format: string,
ext?: string,
): Promise<Buffer> {
switch (format) {
case "raw":
return decodeRaw(buffer);
return decodeRaw(buffer, ext);
case "ico":
return decodeIco(buffer);
case "psd":
@@ -36,6 +67,24 @@ export async function decodeToSharpCompat(buffer: Buffer, format: string): Promi
return decodeBmp(buffer);
case "jxl":
return decodeJxl(buffer);
case "jp2":
return decodeJp2(buffer);
case "eps":
return decodeEps(buffer);
case "dds":
return decodeDds(buffer);
case "cur":
return decodeIco(buffer); // CUR is structurally identical to ICO
case "dpx":
return decodeDpx(buffer);
case "fits":
return decodeFits(buffer);
case "qoi":
return decodeQoi(buffer);
case "ppm":
case "pgm":
case "pbm":
return decodeNetpbm(buffer, format);
default:
return buffer;
}
@@ -84,16 +133,47 @@ async function decodeIco(buffer: Buffer): Promise<Buffer> {
}
}
// ── RAW decoder (ImageMagick with LibRaw delegate) ─────────────
// ── RAW decoder (ExifTool-first, ImageMagick fallback) ──────────
//
// Strategy: Many camera RAW files (CR2, CR3, NEF, ARW, etc.) embed a
// full-size JPEG preview. ExifTool can extract it near-instantly with
// `-b -JpgFromRaw`. This is faster and more reliable than ImageMagick's
// LibRaw delegate, which may not support newer formats like CR3.
//
// If ExifTool extraction fails (no embedded JPEG, or exiftool not
// installed), we fall back to ImageMagick + LibRaw.
async function decodeRaw(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
const id = randomUUID();
const inputPath = join(tmpdir(), `raw-in-${id}.dng`);
// Use the original extension so ExifTool / ImageMagick can identify the RAW variant.
const suffix = ext ? `.${ext.replace(/^\./, "")}` : ".dng";
const inputPath = join(tmpdir(), `raw-in-${id}${suffix}`);
const outputPath = join(tmpdir(), `raw-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
// Attempt 1: ExifTool embedded JPEG extraction (fast path)
try {
const { stdout } = await execFileAsync("exiftool", ["-b", "-JpgFromRaw", inputPath], {
encoding: "buffer",
maxBuffer: 50 * 1024 * 1024,
timeout: 30_000,
} as never);
// stdout is a Buffer when encoding is "buffer"
const jpegBuf = stdout as unknown as Buffer;
if (jpegBuf && jpegBuf.length > 1000) {
// Verify it starts with JPEG SOI marker
if (jpegBuf[0] === 0xff && jpegBuf[1] === 0xd8) {
return jpegBuf;
}
}
} catch {
// ExifTool not available or no embedded JPEG -- fall through
}
// Attempt 2: ImageMagick + LibRaw delegate (full decode)
const cmd = await findMagickCmd();
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", "-auto-orient", `png:${outputPath}`]),
@@ -242,3 +322,164 @@ async function decodeJxl(buffer: Buffer): Promise<Buffer> {
await rm(outputPath, { force: true }).catch(() => {});
}
}
// ── JPEG 2000 decoder (opj_decompress-first, ImageMagick fallback) ──
async function decodeJp2(buffer: Buffer): Promise<Buffer> {
const id = randomUUID();
const inputPath = join(tmpdir(), `jp2-in-${id}.jp2`);
const outputPath = join(tmpdir(), `jp2-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
try {
await execFileAsync("opj_decompress", ["-i", inputPath, "-o", outputPath], {
timeout: 60_000,
});
return await readFile(outputPath);
} catch {
// opj_decompress not available, fall back to ImageMagick
}
const cmd = await findMagickCmd();
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(() => {});
}
}
// ── EPS decoder (ImageMagick + Ghostscript delegate) ──
const MAX_EPS_SIZE = 50 * 1024 * 1024;
async function decodeEps(buffer: Buffer): Promise<Buffer> {
if (buffer.length > MAX_EPS_SIZE) {
throw new Error(
`EPS file too large (${(buffer.length / 1024 / 1024).toFixed(1)}MB, limit: 50MB)`,
);
}
const cmd = await findMagickCmd();
const id = randomUUID();
const inputPath = join(tmpdir(), `eps-in-${id}.eps`);
const outputPath = join(tmpdir(), `eps-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await execFileAsync(
cmd,
magickArgs(cmd, [
"-density",
"300",
"-define",
"gs:MaxBitmap=500000000",
inputPath,
"-colorspace",
"sRGB",
`png:${outputPath}`,
]),
{ timeout: 30_000 },
);
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
// ── DDS decoder ──
async function decodeDds(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
const id = randomUUID();
const inputPath = join(tmpdir(), `dds-in-${id}.dds`);
const outputPath = join(tmpdir(), `dds-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(() => {});
}
}
// ── DPX / Cineon decoder ──
async function decodeDpx(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
const id = randomUUID();
const inputPath = join(tmpdir(), `dpx-in-${id}.dpx`);
const outputPath = join(tmpdir(), `dpx-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(() => {});
}
}
// ── FITS decoder ──
async function decodeFits(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
const id = randomUUID();
const inputPath = join(tmpdir(), `fits-in-${id}.fits`);
const outputPath = join(tmpdir(), `fits-out-${id}.png`);
try {
await writeFile(inputPath, buffer);
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, "-normalize", "-colorspace", "sRGB", `png:${outputPath}`]),
{ timeout: 120_000 },
);
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
// ── QOI decoder ──
async function decodeQoi(buffer: Buffer): Promise<Buffer> {
const { qoiDecode } = await import("@snapotter/image-engine");
const { header, pixels } = qoiDecode(new Uint8Array(buffer));
return sharp(Buffer.from(pixels), {
raw: { width: header.width, height: header.height, channels: 4 },
})
.png()
.toBuffer();
}
// ── Netpbm (PPM/PGM/PBM) decoder ──
async function decodeNetpbm(buffer: Buffer, format: string): Promise<Buffer> {
try {
return await sharp(buffer).png().toBuffer();
} catch {
const cmd = await findMagickCmd();
const id = randomUUID();
const ext = format === "pgm" ? "pgm" : format === "pbm" ? "pbm" : "ppm";
const inputPath = join(tmpdir(), `netpbm-in-${id}.${ext}`);
const outputPath = join(tmpdir(), `netpbm-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(() => {});
}
}
}
+105
View File
@@ -0,0 +1,105 @@
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 { qoiEncode } from "@snapotter/image-engine";
import sharp from "sharp";
const execFileAsync = promisify(execFile);
let cachedMagickCmd: string | null = null;
async function findMagickCmd(): Promise<string> {
if (cachedMagickCmd) return cachedMagickCmd;
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.");
}
function magickArgs(cmd: string, args: string[]): string[] {
return cmd === "magick" ? ["convert", ...args] : args;
}
export async function encodeBmp(inputBuffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
const id = randomUUID();
const inputPath = join(tmpdir(), `bmp-enc-in-${id}.png`);
const outputPath = join(tmpdir(), `bmp-enc-out-${id}.bmp`);
try {
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
await writeFile(inputPath, pngBuffer);
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `bmp3:${outputPath}`]), {
timeout: 60_000,
});
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
export async function encodeIco(inputBuffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
const id = randomUUID();
const inputPath = join(tmpdir(), `ico-enc-in-${id}.png`);
const outputPath = join(tmpdir(), `ico-enc-out-${id}.ico`);
try {
const pngBuffer = await sharp(inputBuffer)
.resize(256, 256, { fit: "inside", withoutEnlargement: true })
.png()
.toBuffer();
await writeFile(inputPath, pngBuffer);
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `ico:${outputPath}`]), {
timeout: 60_000,
});
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
export async function encodeJp2(inputBuffer: Buffer, quality?: number): Promise<Buffer> {
const id = randomUUID();
const inputPath = join(tmpdir(), `jp2-enc-in-${id}.png`);
const outputPath = join(tmpdir(), `jp2-enc-out-${id}.jp2`);
try {
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
await writeFile(inputPath, pngBuffer);
try {
const rate = quality ? String(Math.max(1, Math.round(quality / 10))) : "5";
await execFileAsync("opj_compress", ["-i", inputPath, "-o", outputPath, "-r", rate], {
timeout: 60_000,
});
return await readFile(outputPath);
} catch {
/* fall back to ImageMagick */
}
const cmd = await findMagickCmd();
const q = quality ? ["-quality", String(quality)] : [];
await execFileAsync(cmd, magickArgs(cmd, [inputPath, ...q, `jp2:${outputPath}`]), {
timeout: 60_000,
});
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
export async function encodeQoi(inputBuffer: Buffer): Promise<Buffer> {
const { data, info } = await sharp(inputBuffer).ensureAlpha().raw().toBuffer({
resolveWithObject: true,
});
const encoded = qoiEncode(new Uint8Array(data), info.width, info.height, 4);
return Buffer.from(encoded);
}
+2 -2
View File
@@ -18,14 +18,14 @@ const FORMAT_MAP: Record<
tiff: { format: "tiff", extension: "tiff", contentType: "image/tiff" },
avif: { format: "avif", extension: "avif", contentType: "image/avif" },
heif: { format: "avif", extension: "avif", contentType: "image/avif" },
jxl: { format: "png", extension: "png", contentType: "image/png" },
jxl: { format: "jxl" as keyof sharp.FormatEnum, extension: "jxl", contentType: "image/jxl" },
};
const DEFAULT_QUALITY = 95;
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"]);
const PNG_FALLBACK_FORMATS = new Set(["svg", "raw", "tga", "psd", "exr", "hdr"]);
/**
* Detect the input image format and return matching output config.
+19
View File
@@ -1,3 +1,4 @@
import { gunzipSync } from "node:zlib";
import { env } from "../config.js";
/**
@@ -36,6 +37,24 @@ export function sanitizeSvg(buffer: Buffer): Buffer {
return Buffer.from(svg, "utf-8");
}
const MAX_SVGZ_DECOMPRESSED_SIZE = 50 * 1024 * 1024;
/**
* Decompress an SVGZ (gzip-compressed SVG) buffer.
* Returns the buffer unchanged if it is not gzip-compressed.
* Throws on decompression bomb or invalid SVG content.
*/
export function decompressSvgz(buffer: Buffer): Buffer {
if (buffer.length < 2 || buffer[0] !== 0x1f || buffer[1] !== 0x8b) {
return buffer;
}
const decompressed = gunzipSync(buffer, { maxOutputLength: MAX_SVGZ_DECOMPRESSED_SIZE });
if (!isSvgBuffer(decompressed)) {
throw new Error("SVGZ file does not contain valid SVG content after decompression");
}
return decompressed;
}
/**
* Check whether a buffer looks like SVG content.
* Examines the first 4KB for an <svg tag.