mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
merge: resolve conflict with main branch in tool-registry.tsx
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Generates device frame PNG assets + meta.json files from SVG templates.
|
||||
*
|
||||
* Run: cd apps/api && npx tsx scripts/generate-frames.ts
|
||||
*/
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import sharp from "sharp";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const FRAMES_DIR = join(__dirname, "../src/assets/frames");
|
||||
|
||||
interface FrameDef {
|
||||
id: string;
|
||||
svg: string;
|
||||
meta: { screenX: number; screenY: number; screenW: number; screenH: number };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// iPhone frames
|
||||
// ---------------------------------------------------------------------------
|
||||
function iphoneSvg(variant: "light" | "dark"): string {
|
||||
const body = variant === "light" ? "#c0c0c0" : "#2a2a2a";
|
||||
const border = variant === "light" ? "#333333" : "#111111";
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="430" height="880">
|
||||
<!-- Body -->
|
||||
<rect x="0" y="0" width="430" height="880" rx="50" ry="50" fill="${body}"/>
|
||||
<!-- Screen border -->
|
||||
<rect x="15" y="75" width="400" height="730" rx="5" ry="5" fill="${border}"/>
|
||||
<!-- Screen (transparent) -->
|
||||
<rect x="20" y="80" width="390" height="720" rx="3" ry="3" fill="transparent"/>
|
||||
<!-- Notch -->
|
||||
<rect x="165" y="10" width="100" height="30" rx="15" ry="15" fill="${border}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const IPHONE_META = { screenX: 20, screenY: 80, screenW: 390, screenH: 720 };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MacBook frames
|
||||
// ---------------------------------------------------------------------------
|
||||
function macbookSvg(variant: "light" | "dark"): string {
|
||||
const bezel = variant === "light" ? "#d4d4d8" : "#3f3f46";
|
||||
const base = variant === "light" ? "#e4e4e7" : "#27272a";
|
||||
const cameraDot = variant === "light" ? "#a1a1aa" : "#52525b";
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="780">
|
||||
<!-- Screen bezel -->
|
||||
<rect x="50" y="0" width="1100" height="690" rx="12" ry="12" fill="${bezel}"/>
|
||||
<!-- Screen (transparent) -->
|
||||
<rect x="100" y="30" width="1000" height="625" rx="4" ry="4" fill="transparent"/>
|
||||
<!-- Camera dot -->
|
||||
<circle cx="600" cy="15" r="4" fill="${cameraDot}"/>
|
||||
<!-- Base / hinge -->
|
||||
<rect x="0" y="690" width="1200" height="20" rx="4" ry="4" fill="${bezel}"/>
|
||||
<!-- Laptop base -->
|
||||
<path d="M80,710 L1120,710 L1200,780 L0,780 Z" fill="${base}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const MACBOOK_META = { screenX: 100, screenY: 30, screenW: 1000, screenH: 625 };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// iPad frames
|
||||
// ---------------------------------------------------------------------------
|
||||
function ipadSvg(variant: "light" | "dark"): string {
|
||||
const body = variant === "light" ? "#d4d4d8" : "#3f3f46";
|
||||
const cameraDot = variant === "light" ? "#a1a1aa" : "#52525b";
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="540" height="720">
|
||||
<!-- Body -->
|
||||
<rect x="0" y="0" width="540" height="720" rx="20" ry="20" fill="${body}"/>
|
||||
<!-- Screen (transparent) -->
|
||||
<rect x="25" y="25" width="490" height="670" rx="4" ry="4" fill="transparent"/>
|
||||
<!-- Camera dot (top center) -->
|
||||
<circle cx="270" cy="12" r="4" fill="${cameraDot}"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const IPAD_META = { screenX: 25, screenY: 25, screenW: 490, screenH: 670 };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate all frames
|
||||
// ---------------------------------------------------------------------------
|
||||
const frames: FrameDef[] = [
|
||||
{ id: "iphone", svg: iphoneSvg("light"), meta: IPHONE_META },
|
||||
{ id: "iphone-dark", svg: iphoneSvg("dark"), meta: IPHONE_META },
|
||||
{ id: "macbook", svg: macbookSvg("light"), meta: MACBOOK_META },
|
||||
{ id: "macbook-dark", svg: macbookSvg("dark"), meta: MACBOOK_META },
|
||||
{ id: "ipad", svg: ipadSvg("light"), meta: IPAD_META },
|
||||
{ id: "ipad-dark", svg: ipadSvg("dark"), meta: IPAD_META },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
if (!existsSync(FRAMES_DIR)) {
|
||||
mkdirSync(FRAMES_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
for (const frame of frames) {
|
||||
const pngPath = join(FRAMES_DIR, `${frame.id}.png`);
|
||||
const metaPath = join(FRAMES_DIR, `${frame.id}.meta.json`);
|
||||
|
||||
const pngBuf = await sharp(Buffer.from(frame.svg)).png().toBuffer();
|
||||
writeFileSync(pngPath, pngBuf);
|
||||
writeFileSync(metaPath, JSON.stringify(frame.meta, null, 2) + "\n");
|
||||
|
||||
console.log(`Generated ${frame.id}.png + ${frame.id}.meta.json`);
|
||||
}
|
||||
|
||||
console.log(`\nAll frames written to ${FRAMES_DIR}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"screenX": 25,
|
||||
"screenY": 25,
|
||||
"screenW": 490,
|
||||
"screenH": 670
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"screenX": 25,
|
||||
"screenY": 25,
|
||||
"screenW": 490,
|
||||
"screenH": 670
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"screenX": 20,
|
||||
"screenY": 80,
|
||||
"screenW": 390,
|
||||
"screenH": 720
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"screenX": 20,
|
||||
"screenY": 80,
|
||||
"screenW": 390,
|
||||
"screenH": 720
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"screenX": 100,
|
||||
"screenY": 30,
|
||||
"screenW": 1000,
|
||||
"screenH": 625
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"screenX": 100,
|
||||
"screenY": 30,
|
||||
"screenW": 1000,
|
||||
"screenH": 625
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+116
-1
@@ -3,7 +3,7 @@ info:
|
||||
title: SnapOtter API
|
||||
version: 1.15.9
|
||||
description: |
|
||||
REST API for SnapOtter, a self-hosted image processing platform with 48 tools.
|
||||
REST API for SnapOtter, a self-hosted image processing platform with 50 tools.
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -610,6 +610,77 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UnauthorizedError"
|
||||
|
||||
/api/v1/tools/beautify:
|
||||
post:
|
||||
tags: [Tools]
|
||||
summary: Beautify screenshot
|
||||
description: |
|
||||
Add gradient backgrounds, device frames, shadows, watermarks, and social
|
||||
media sizing to screenshots. Supports solid, linear-gradient, radial-gradient,
|
||||
image, and transparent backgrounds. Includes macOS, Windows, browser, iPhone,
|
||||
MacBook, and iPad device frames. Social media presets for Twitter, LinkedIn,
|
||||
Instagram, Facebook, and Product Hunt.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: Screenshot or image file to beautify
|
||||
backgroundImage:
|
||||
type: string
|
||||
format: binary
|
||||
description: Optional image for image backgrounds (used when backgroundType is "image")
|
||||
settings:
|
||||
type: string
|
||||
description: |
|
||||
JSON string with options:
|
||||
- `backgroundType` (string, default "linear-gradient") -- One of: solid, linear-gradient, radial-gradient, image, transparent
|
||||
- `backgroundColor` (hex string, default "#667eea") -- Background color (for solid backgrounds)
|
||||
- `gradientStops` (array, default [{color:"#667eea",position:0},{color:"#764ba2",position:100}]) -- Array of {color, position} objects for gradient backgrounds
|
||||
- `gradientAngle` (number 0-360, default 135) -- Gradient angle in degrees
|
||||
- `padding` (number 0-256, default 64) -- Space between screenshot and canvas edge in pixels
|
||||
- `borderRadius` (number 0-64, default 12) -- Corner rounding radius for the screenshot
|
||||
- `shadowPreset` (string, default "subtle") -- One of: none, subtle, medium, dramatic, custom
|
||||
- `shadowBlur` (number 0-100, default 20) -- Shadow blur radius (used with custom shadow preset)
|
||||
- `shadowOffsetX` (number -50 to 50, default 0) -- Shadow horizontal offset
|
||||
- `shadowOffsetY` (number -50 to 50, default 10) -- Shadow vertical offset
|
||||
- `shadowColor` (hex string, default "#000000") -- Shadow color
|
||||
- `shadowOpacity` (number 0-100, default 30) -- Shadow opacity percentage
|
||||
- `frame` (string, default "none") -- One of: none, macos-light, macos-dark, windows-light, windows-dark, browser-light, browser-dark, iphone, iphone-dark, macbook, macbook-dark, ipad, ipad-dark
|
||||
- `frameTitle` (string, optional) -- Title text shown in window title bars
|
||||
- `socialPreset` (string, default "none") -- One of: none, twitter, linkedin, instagram-square, instagram-story, facebook, producthunt
|
||||
- `watermarkText` (string, optional) -- Watermark text to overlay
|
||||
- `watermarkPosition` (string, default "bottom-right") -- One of: top-left, top-right, bottom-left, bottom-right, center
|
||||
- `watermarkOpacity` (number 0-100, default 50) -- Watermark opacity percentage
|
||||
- `outputFormat` (string, default "png") -- One of: png, jpeg, webp
|
||||
responses:
|
||||
"200":
|
||||
description: Beautified image
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ToolResponse"
|
||||
"400":
|
||||
description: Invalid input
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"401":
|
||||
description: Authentication required
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UnauthorizedError"
|
||||
|
||||
/api/v1/tools/adjust-colors:
|
||||
post:
|
||||
tags: [Tools]
|
||||
@@ -2053,6 +2124,50 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UnauthorizedError"
|
||||
|
||||
/api/v1/tools/color-blindness:
|
||||
post:
|
||||
tags: [Tools]
|
||||
summary: Color blindness simulation
|
||||
description: Simulate how an image appears to people with various types of color vision deficiency.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: Image file to process
|
||||
settings:
|
||||
type: string
|
||||
description: |
|
||||
JSON string with options:
|
||||
- `simulationType` (enum, default "deuteranomaly") -- Type of color vision deficiency to simulate. One of: protanopia, deuteranopia, tritanopia, protanomaly, deuteranomaly, tritanomaly, achromatopsia, blueConeMonochromacy
|
||||
responses:
|
||||
"200":
|
||||
description: Processed image
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ToolResponse"
|
||||
"400":
|
||||
description: Invalid input
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"401":
|
||||
description: Authentication required
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UnauthorizedError"
|
||||
|
||||
/api/v1/tools/gif-tools:
|
||||
post:
|
||||
tags: [Tools]
|
||||
|
||||
@@ -39,7 +39,7 @@ function generateLlmsTxt(spec: OpenAPISpec): string {
|
||||
lines.push(`# ${spec.info.title}`);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"> Self-hosted image processing API with 48 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
|
||||
"> Self-hosted image processing API with 50 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("## Docs");
|
||||
|
||||
@@ -16,7 +16,7 @@ import { sanitizeFilename } from "../lib/filename.js";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js";
|
||||
import { sanitizeSvg } from "../lib/svg-sanitize.js";
|
||||
import { decompressSvgz, sanitizeSvg } from "../lib/svg-sanitize.js";
|
||||
import { computeTimeout } from "../lib/timeout.js";
|
||||
import { getWorkerPool } from "../lib/worker-pool.js";
|
||||
import { createWorkspace } from "../lib/workspace.js";
|
||||
@@ -192,9 +192,12 @@ 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.
|
||||
// Pass the original file extension so RAW decoder can use the correct
|
||||
// temp file suffix (e.g. .cr3, .nef) for format identification.
|
||||
if (needsCliDecode(validation.format)) {
|
||||
try {
|
||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||
const fileExt = filename.split(".").pop()?.toLowerCase();
|
||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||
} catch (err) {
|
||||
@@ -209,6 +212,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
const isSvg = validation.format === "svg";
|
||||
if (isSvg) {
|
||||
try {
|
||||
fileBuffer = decompressSvgz(fileBuffer);
|
||||
fileBuffer = sanitizeSvg(fileBuffer);
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
@@ -314,6 +318,18 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
"image/bmp": ".bmp",
|
||||
"image/heic": ".heic",
|
||||
"image/heif": ".heif",
|
||||
"image/jxl": ".jxl",
|
||||
"image/x-icon": ".ico",
|
||||
"image/vnd.adobe.photoshop": ".psd",
|
||||
"image/x-exr": ".exr",
|
||||
"image/vnd.radiance": ".hdr",
|
||||
"image/x-targa": ".tga",
|
||||
"image/jp2": ".jp2",
|
||||
"image/qoi": ".qoi",
|
||||
"application/postscript": ".eps",
|
||||
"image/vnd.ms-dds": ".dds",
|
||||
"image/x-dpx": ".dpx",
|
||||
"image/fits": ".fits",
|
||||
};
|
||||
const expectedExt = CONTENT_TYPE_TO_EXT[result.contentType];
|
||||
if (expectedExt) {
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import {
|
||||
type BackgroundOpts,
|
||||
generateBackground,
|
||||
getDominantBackground,
|
||||
} from "../../lib/beautify/backgrounds.js";
|
||||
import {
|
||||
type BeautifySettings,
|
||||
DEVICE_FRAMES,
|
||||
SHADOW_PRESETS,
|
||||
SOCIAL_PRESETS,
|
||||
settingsSchema,
|
||||
} from "../../lib/beautify/constants.js";
|
||||
import { renderFrame } from "../../lib/beautify/frames.js";
|
||||
import { applyShadow } from "../../lib/beautify/shadow.js";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { sanitizeFilename } from "../../lib/filename.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
import { registerToolProcessFn } from "../tool-factory.js";
|
||||
|
||||
const ALPHA_FORMATS = new Set(["png", "webp", "avif", "tiff"]);
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Resolve shadow preset to concrete values. */
|
||||
function resolveShadow(settings: BeautifySettings) {
|
||||
return settings.shadowPreset === "custom"
|
||||
? {
|
||||
blur: settings.shadowBlur,
|
||||
offsetX: settings.shadowOffsetX,
|
||||
offsetY: settings.shadowOffsetY,
|
||||
color: settings.shadowColor,
|
||||
opacity: settings.shadowOpacity,
|
||||
}
|
||||
: SHADOW_PRESETS[settings.shadowPreset];
|
||||
}
|
||||
|
||||
/** Check whether the output needs an alpha channel. */
|
||||
function needsAlphaOutput(settings: BeautifySettings): boolean {
|
||||
const shadow = resolveShadow(settings);
|
||||
const hasShadow = shadow.opacity > 0 && shadow.blur > 0;
|
||||
return (
|
||||
hasShadow ||
|
||||
settings.borderRadius > 0 ||
|
||||
settings.backgroundType === "transparent" ||
|
||||
settings.frame !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
/** Compute the output filename with the correct extension. */
|
||||
function resolveOutputFilename(filename: string, settings: BeautifySettings): string {
|
||||
const forcedPng = needsAlphaOutput(settings) && !ALPHA_FORMATS.has(settings.outputFormat);
|
||||
const ext = forcedPng ? ".png" : `.${settings.outputFormat}`;
|
||||
return filename.replace(/\.[^.]+$/, ext);
|
||||
}
|
||||
|
||||
export async function processBeautify(
|
||||
inputBuffer: Buffer,
|
||||
settings: BeautifySettings,
|
||||
_filename: string,
|
||||
bgImageBuffer?: Buffer,
|
||||
): Promise<Buffer> {
|
||||
// 1. Decode & Prepare
|
||||
let buf = await autoOrient(await ensureSharpCompat(inputBuffer));
|
||||
buf = await sharp(buf).ensureAlpha().png().toBuffer();
|
||||
|
||||
// 2. Apply Border Radius (skip for device frames that have their own bezels)
|
||||
const hasDeviceFrame = settings.frame !== "none" && DEVICE_FRAMES.has(settings.frame);
|
||||
if (settings.borderRadius > 0 && !hasDeviceFrame) {
|
||||
const meta = await sharp(buf).metadata();
|
||||
const w = meta.width ?? 100;
|
||||
const h = meta.height ?? 100;
|
||||
const r = Math.min(settings.borderRadius, w / 2, h / 2);
|
||||
|
||||
const mask = Buffer.from(
|
||||
`<svg width="${w}" height="${h}"><rect x="0" y="0" width="${w}" height="${h}" rx="${r}" ry="${r}" fill="white"/></svg>`,
|
||||
);
|
||||
buf = await sharp(buf)
|
||||
.composite([{ input: await sharp(mask).resize(w, h).toBuffer(), blend: "dest-in" }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// 3. Render Device/SVG Frame
|
||||
buf = await renderFrame(buf, settings.frame, settings.frameTitle);
|
||||
|
||||
// 4-5. Resolve & Apply Shadow
|
||||
const shadowOpts = resolveShadow(settings);
|
||||
const hasShadow = shadowOpts.opacity > 0 && shadowOpts.blur > 0;
|
||||
if (hasShadow) {
|
||||
const result = await applyShadow(buf, shadowOpts);
|
||||
buf = result.buffer;
|
||||
}
|
||||
|
||||
// 6. Generate Background
|
||||
const framedMeta = await sharp(buf).metadata();
|
||||
const framedW = framedMeta.width ?? 100;
|
||||
const framedH = framedMeta.height ?? 100;
|
||||
const padding = settings.padding;
|
||||
const canvasW = framedW + padding * 2;
|
||||
const canvasH = framedH + padding * 2;
|
||||
|
||||
let bgOpts: BackgroundOpts;
|
||||
if (settings.backgroundType === "image" && bgImageBuffer) {
|
||||
bgOpts = { type: "image", imageBuffer: bgImageBuffer, width: canvasW, height: canvasH };
|
||||
} else if (settings.backgroundType === "solid") {
|
||||
bgOpts = { type: "solid", color: settings.backgroundColor, width: canvasW, height: canvasH };
|
||||
} else if (
|
||||
settings.backgroundType === "linear-gradient" ||
|
||||
settings.backgroundType === "radial-gradient"
|
||||
) {
|
||||
bgOpts = {
|
||||
type: settings.backgroundType,
|
||||
stops: settings.gradientStops,
|
||||
angle: settings.gradientAngle,
|
||||
width: canvasW,
|
||||
height: canvasH,
|
||||
};
|
||||
} else {
|
||||
bgOpts = { type: "transparent", width: canvasW, height: canvasH };
|
||||
}
|
||||
|
||||
const background = await generateBackground(bgOpts);
|
||||
|
||||
// 7. Composite onto Background
|
||||
buf = await sharp(background)
|
||||
.composite([{ input: buf, left: padding, top: padding }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// 8. Apply Watermark
|
||||
if (settings.watermarkText) {
|
||||
const wmMeta = await sharp(buf).metadata();
|
||||
const wmW = wmMeta.width ?? canvasW;
|
||||
const wmH = wmMeta.height ?? canvasH;
|
||||
|
||||
const fontSize = Math.max(12, Math.round(Math.min(wmW, wmH) * 0.03));
|
||||
const alpha = settings.watermarkOpacity / 100;
|
||||
const escaped = escapeXml(settings.watermarkText);
|
||||
|
||||
let textX: number;
|
||||
let textY: number;
|
||||
let anchor: string;
|
||||
switch (settings.watermarkPosition) {
|
||||
case "top-left":
|
||||
textX = fontSize;
|
||||
textY = fontSize * 2;
|
||||
anchor = "start";
|
||||
break;
|
||||
case "top-right":
|
||||
textX = wmW - fontSize;
|
||||
textY = fontSize * 2;
|
||||
anchor = "end";
|
||||
break;
|
||||
case "bottom-left":
|
||||
textX = fontSize;
|
||||
textY = wmH - fontSize;
|
||||
anchor = "start";
|
||||
break;
|
||||
case "center":
|
||||
textX = wmW / 2;
|
||||
textY = wmH / 2;
|
||||
anchor = "middle";
|
||||
break;
|
||||
default:
|
||||
// bottom-right
|
||||
textX = wmW - fontSize;
|
||||
textY = wmH - fontSize;
|
||||
anchor = "end";
|
||||
break;
|
||||
}
|
||||
|
||||
const wmSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${wmW}" height="${wmH}">
|
||||
<text x="${textX}" y="${textY}" text-anchor="${anchor}" font-family="sans-serif" font-size="${fontSize}" fill="white" opacity="${alpha}">${escaped}</text>
|
||||
</svg>`;
|
||||
|
||||
buf = await sharp(buf)
|
||||
.composite([{ input: Buffer.from(wmSvg) }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// 9. Resize to Social Preset
|
||||
const preset = SOCIAL_PRESETS[settings.socialPreset];
|
||||
if (preset) {
|
||||
const dominant = getDominantBackground({
|
||||
type: settings.backgroundType,
|
||||
color: settings.backgroundColor,
|
||||
stops: settings.gradientStops,
|
||||
});
|
||||
buf = await sharp(buf)
|
||||
.resize(preset.width, preset.height, {
|
||||
fit: "contain",
|
||||
background: dominant,
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// 10. Encode Output
|
||||
if (needsAlphaOutput(settings) && !ALPHA_FORMATS.has(settings.outputFormat)) {
|
||||
return sharp(buf).png().toBuffer();
|
||||
}
|
||||
|
||||
switch (settings.outputFormat) {
|
||||
case "jpeg":
|
||||
return sharp(buf).flatten({ background: "#ffffff" }).jpeg({ quality: 90 }).toBuffer();
|
||||
case "webp":
|
||||
return sharp(buf).webp({ quality: 90 }).toBuffer();
|
||||
default:
|
||||
return sharp(buf).png().toBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerBeautify(app: FastifyInstance) {
|
||||
// Custom HTTP route (multi-file upload: main image + optional background image)
|
||||
app.post("/api/v1/tools/beautify", async (request, reply) => {
|
||||
let mainBuffer: Buffer | null = null;
|
||||
let bgImageBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let settingsRaw: string | null = null;
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buf = Buffer.concat(chunks);
|
||||
if (part.fieldname === "backgroundImage") {
|
||||
bgImageBuffer = buf;
|
||||
} else {
|
||||
mainBuffer = buf;
|
||||
filename = sanitizeFilename(part.filename ?? "image");
|
||||
}
|
||||
} else if (part.fieldname === "settings") {
|
||||
settingsRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
if (!mainBuffer || mainBuffer.length === 0) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
|
||||
let settings: BeautifySettings;
|
||||
try {
|
||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
try {
|
||||
const originalSize = mainBuffer.length;
|
||||
const outputBuf = await processBeautify(
|
||||
mainBuffer,
|
||||
settings,
|
||||
filename,
|
||||
bgImageBuffer ?? undefined,
|
||||
);
|
||||
const outFilename = resolveOutputFilename(filename, settings);
|
||||
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const outputPath = join(workspacePath, "output", outFilename);
|
||||
await writeFile(outputPath, outputBuf);
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`,
|
||||
originalSize,
|
||||
processedSize: outputBuf.length,
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Processing failed",
|
||||
details: err instanceof Error ? err.message : "Image processing failed",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Register for pipeline/batch support
|
||||
registerToolProcessFn({
|
||||
toolId: "beautify",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const s = settings as BeautifySettings;
|
||||
if (s.backgroundType === "image") {
|
||||
throw new Error("Image backgrounds are not supported in pipeline mode.");
|
||||
}
|
||||
const buffer = await processBeautify(inputBuffer, s, filename);
|
||||
const outFilename = resolveOutputFilename(filename, s);
|
||||
const forcedPng = needsAlphaOutput(s) && !ALPHA_FORMATS.has(s.outputFormat);
|
||||
const ext = forcedPng ? "png" : s.outputFormat;
|
||||
const contentType =
|
||||
ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
||||
return { buffer, filename: outFilename, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -333,7 +333,7 @@ const settingsSchema = z.object({
|
||||
cornerRadius: z.number().min(0).max(500).default(0),
|
||||
backgroundColor: z.string().default("#FFFFFF"),
|
||||
aspectRatio: z.string().default("free"),
|
||||
outputFormat: z.enum(["png", "jpeg", "webp", "avif"]).default("png"),
|
||||
outputFormat: z.enum(["png", "jpeg", "webp", "avif", "jxl"]).default("png"),
|
||||
quality: z.number().min(1).max(100).default(90),
|
||||
});
|
||||
|
||||
@@ -642,6 +642,10 @@ export function registerCollage(app: FastifyInstance) {
|
||||
pipeline = pipeline.avif({ quality: settings.quality, effort: 4 });
|
||||
outputExt = "avif";
|
||||
break;
|
||||
case "jxl":
|
||||
pipeline = pipeline.jxl({ quality: settings.quality });
|
||||
outputExt = "jxl";
|
||||
break;
|
||||
default:
|
||||
pipeline = pipeline.png();
|
||||
outputExt = "png";
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { colorBlindness } from "@snapotter/image-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
simulationType: z
|
||||
.enum([
|
||||
"protanopia",
|
||||
"deuteranopia",
|
||||
"tritanopia",
|
||||
"protanomaly",
|
||||
"deuteranomaly",
|
||||
"tritanomaly",
|
||||
"achromatopsia",
|
||||
"blueConeMonochromacy",
|
||||
])
|
||||
.default("deuteranomaly"),
|
||||
});
|
||||
|
||||
export function registerColorBlindness(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "color-blindness",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const image = sharp(inputBuffer);
|
||||
const result = await colorBlindness(image, { type: settings.simulationType });
|
||||
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const buffer = await result
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { convert } from "@snapotter/image-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { encodeBmp, encodeIco, encodeJp2, encodeQoi } from "../../lib/format-encoders.js";
|
||||
import { encodeHeic } from "../../lib/heic-converter.js";
|
||||
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
@@ -16,10 +17,36 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
|
||||
gif: "image/gif",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
jxl: "image/jxl",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
jp2: "image/jp2",
|
||||
qoi: "image/x-qoi",
|
||||
};
|
||||
|
||||
const CLI_ENCODERS: Record<string, (buf: Buffer, quality?: number) => Promise<Buffer>> = {
|
||||
bmp: encodeBmp,
|
||||
ico: encodeIco,
|
||||
jp2: encodeJp2,
|
||||
qoi: encodeQoi,
|
||||
};
|
||||
|
||||
const settingsSchema = z.object({
|
||||
format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"]),
|
||||
format: z.enum([
|
||||
"jpg",
|
||||
"png",
|
||||
"webp",
|
||||
"avif",
|
||||
"tiff",
|
||||
"gif",
|
||||
"heic",
|
||||
"heif",
|
||||
"jxl",
|
||||
"bmp",
|
||||
"ico",
|
||||
"jp2",
|
||||
"qoi",
|
||||
]),
|
||||
quality: z.number().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
@@ -28,6 +55,20 @@ export function registerConvert(app: FastifyInstance) {
|
||||
toolId: "convert",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
// CLI-encoded formats bypass Sharp entirely
|
||||
const cliEncoder = CLI_ENCODERS[settings.format];
|
||||
if (cliEncoder) {
|
||||
const outputBuffer = await cliEncoder(inputBuffer, settings.quality);
|
||||
const ext = extname(filename);
|
||||
const baseName = ext ? filename.slice(0, -ext.length) : filename;
|
||||
const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
|
||||
return {
|
||||
buffer: outputBuffer,
|
||||
filename: `${baseName}.${settings.format}`,
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
|
||||
const sharpOpts = isSvgBuffer(inputBuffer) ? { density: 300 } : undefined;
|
||||
const image = sharp(inputBuffer, sharpOpts);
|
||||
|
||||
|
||||
@@ -26,13 +26,14 @@ const EXT_MAP: Record<string, string> = {
|
||||
avif: "avif",
|
||||
heic: "heic",
|
||||
heif: "heif",
|
||||
jxl: "jxl",
|
||||
};
|
||||
|
||||
const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]);
|
||||
|
||||
const settingsSchema = z.object({
|
||||
format: z
|
||||
.enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif"])
|
||||
.enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif", "jxl"])
|
||||
.default("auto"),
|
||||
quality: z.number().int().min(1).max(100).default(95),
|
||||
});
|
||||
@@ -203,7 +204,7 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
);
|
||||
|
||||
// Convert to the requested output format using Sharp
|
||||
const needsNodeConversion = ["heic", "heif", "avif"].includes(format);
|
||||
const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
|
||||
let outputBuffer: Buffer;
|
||||
let finalFormat = format;
|
||||
|
||||
@@ -211,6 +212,9 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
if (format === "heic" || format === "heif") {
|
||||
outputBuffer = await encodeHeic(resultBuffer, quality);
|
||||
finalFormat = format;
|
||||
} else if (format === "jxl") {
|
||||
outputBuffer = await sharp(resultBuffer).jxl({ quality }).toBuffer();
|
||||
finalFormat = "jxl";
|
||||
} else {
|
||||
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
|
||||
finalFormat = "avif";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
outputFormat: z.enum(["original", "jpeg", "png", "webp", "avif"]).default("original"),
|
||||
outputFormat: z.enum(["original", "jpeg", "png", "webp", "avif", "jxl"]).default("original"),
|
||||
quality: z.number().int().min(1).max(100).default(80),
|
||||
maxWidth: z.number().int().min(0).default(0),
|
||||
maxHeight: z.number().int().min(0).default(0),
|
||||
@@ -145,6 +145,10 @@ export function registerImageToBase64(app: FastifyInstance) {
|
||||
outputBuffer = await pipeline.avif({ quality: opts.quality, effort: 4 }).toBuffer();
|
||||
mimeType = "image/avif";
|
||||
break;
|
||||
case "jxl":
|
||||
outputBuffer = await pipeline.jxl({ quality: opts.quality }).toBuffer();
|
||||
mimeType = "image/jxl";
|
||||
break;
|
||||
default:
|
||||
outputBuffer = await pipeline.toBuffer();
|
||||
mimeType = detectMimeType(ext);
|
||||
|
||||
@@ -3,11 +3,13 @@ import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { db, schema } from "../../db/index.js";
|
||||
import { registerBarcodeRead } from "./barcode-read.js";
|
||||
import { registerBeautify } from "./beautify.js";
|
||||
import { registerBlurFaces } from "./blur-faces.js";
|
||||
import { registerBorder } from "./border.js";
|
||||
import { registerBulkRename } from "./bulk-rename.js";
|
||||
import { registerCollage } from "./collage.js";
|
||||
import { registerColorAdjustments } from "./color-adjustments.js";
|
||||
import { registerColorBlindness } from "./color-blindness.js";
|
||||
import { registerColorPalette } from "./color-palette.js";
|
||||
import { registerColorize } from "./colorize.js";
|
||||
import { registerCompare } from "./compare.js";
|
||||
@@ -118,6 +120,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "stitch", register: registerStitch },
|
||||
{ id: "split", register: registerSplit },
|
||||
{ id: "border", register: registerBorder },
|
||||
{ id: "beautify", register: registerBeautify },
|
||||
|
||||
// Format & Conversion
|
||||
{ id: "svg-to-raster", register: registerSvgToRaster },
|
||||
@@ -133,6 +136,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
// Adjustments extra
|
||||
{ id: "replace-color", register: registerReplaceColor },
|
||||
{ id: "color-blindness", register: registerColorBlindness },
|
||||
|
||||
// AI Tools
|
||||
{ id: "remove-background", register: registerRemoveBackground },
|
||||
|
||||
@@ -21,7 +21,7 @@ const settingsSchema = z.object({
|
||||
strength: z.union([z.number(), z.string()]).transform(Number).default(50),
|
||||
detailPreservation: z.union([z.number(), z.string()]).transform(Number).default(50),
|
||||
colorNoise: z.union([z.number(), z.string()]).transform(Number).default(30),
|
||||
format: z.enum(["original", "png", "jpeg", "webp", "avif"]).default("original"),
|
||||
format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"),
|
||||
quality: z.union([z.number(), z.string()]).transform(Number).default(90),
|
||||
});
|
||||
|
||||
@@ -198,7 +198,7 @@ export function registerNoiseRemoval(app: FastifyInstance) {
|
||||
strength: z.union([z.number(), z.string()]).transform(Number).default(50),
|
||||
detailPreservation: z.union([z.number(), z.string()]).transform(Number).default(50),
|
||||
colorNoise: z.union([z.number(), z.string()]).transform(Number).default(30),
|
||||
format: z.enum(["original", "png", "jpeg", "webp", "avif"]).default("original"),
|
||||
format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"),
|
||||
quality: z.union([z.number(), z.string()]).transform(Number).default(90),
|
||||
}),
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
|
||||
jpeg: "image/jpeg",
|
||||
avif: "image/avif",
|
||||
png: "image/png",
|
||||
jxl: "image/jxl",
|
||||
};
|
||||
|
||||
const FORMAT_EXTENSIONS: Record<string, string> = {
|
||||
@@ -24,10 +25,11 @@ const FORMAT_EXTENSIONS: Record<string, string> = {
|
||||
jpeg: "jpg",
|
||||
avif: "avif",
|
||||
png: "png",
|
||||
jxl: "jxl",
|
||||
};
|
||||
|
||||
const settingsSchema = z.object({
|
||||
format: z.enum(["webp", "jpeg", "avif", "png"]).default("webp"),
|
||||
format: z.enum(["webp", "jpeg", "avif", "png", "jxl"]).default("webp"),
|
||||
quality: z.number().min(1).max(100).default(80),
|
||||
maxWidth: z.number().positive().optional(),
|
||||
maxHeight: z.number().positive().optional(),
|
||||
|
||||
@@ -14,7 +14,9 @@ import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
// ── Settings schema ──────────────────────────────────────────────
|
||||
const settingsSchema = z.object({
|
||||
format: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif"]).default("png"),
|
||||
format: z
|
||||
.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif", "jxl"])
|
||||
.default("png"),
|
||||
dpi: z.number().min(36).max(2400).default(150),
|
||||
quality: z.number().min(1).max(100).default(85),
|
||||
colorMode: z.enum(["color", "grayscale", "bw"]).default("color"),
|
||||
@@ -86,6 +88,7 @@ const FORMAT_EXT: Record<string, string> = {
|
||||
gif: ".gif",
|
||||
heic: ".heic",
|
||||
heif: ".heif",
|
||||
jxl: ".jxl",
|
||||
};
|
||||
|
||||
async function convertWithSharp(
|
||||
@@ -114,6 +117,8 @@ async function convertWithSharp(
|
||||
return s.tiff().toBuffer();
|
||||
case "gif":
|
||||
return s.gif().toBuffer();
|
||||
case "jxl":
|
||||
return s.jxl({ quality }).toBuffer();
|
||||
case "heic":
|
||||
case "heif": {
|
||||
const pngBuf = await s.png().toBuffer();
|
||||
|
||||
@@ -15,7 +15,7 @@ const settingsSchema = z.object({
|
||||
rows: z.number().min(1).max(100).default(3),
|
||||
tileWidth: z.number().min(10).optional(),
|
||||
tileHeight: z.number().min(10).optional(),
|
||||
outputFormat: z.enum(["original", "png", "jpg", "webp", "avif"]).default("original"),
|
||||
outputFormat: z.enum(["original", "png", "jpg", "webp", "avif", "jxl"]).default("original"),
|
||||
quality: z.number().min(1).max(100).default(90),
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ function resolveOutputFormat(
|
||||
jpg: { sharpFormat: "jpeg", ext: ".jpg" },
|
||||
webp: { sharpFormat: "webp", ext: ".webp" },
|
||||
avif: { sharpFormat: "avif", ext: ".avif" },
|
||||
jxl: { sharpFormat: "jxl", ext: ".jxl" },
|
||||
};
|
||||
return map[outputFormat] ?? { sharpFormat: null, ext: originalExt };
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const settingsSchema = z.object({
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.default("#FFFFFF"),
|
||||
format: z.enum(["png", "jpeg", "webp", "avif"]).default("png"),
|
||||
format: z.enum(["png", "jpeg", "webp", "avif", "jxl"]).default("png"),
|
||||
quality: z.number().min(1).max(100).default(90),
|
||||
});
|
||||
|
||||
@@ -203,6 +203,8 @@ export function registerStitch(app: FastifyInstance) {
|
||||
pipeline = pipeline.webp({ quality: settings.quality });
|
||||
} else if (settings.format === "avif") {
|
||||
pipeline = pipeline.avif({ quality: settings.quality, effort: 4 });
|
||||
} else if (settings.format === "jxl") {
|
||||
pipeline = pipeline.jxl({ quality: settings.quality });
|
||||
} else {
|
||||
pipeline = pipeline.png();
|
||||
}
|
||||
@@ -235,6 +237,8 @@ export function registerStitch(app: FastifyInstance) {
|
||||
result = await sharp(result).webp({ quality: settings.quality }).toBuffer();
|
||||
} else if (settings.format === "avif") {
|
||||
result = await sharp(result).avif({ quality: settings.quality, effort: 4 }).toBuffer();
|
||||
} else if (settings.format === "jxl") {
|
||||
result = await sharp(result).jxl({ quality: settings.quality }).toBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const settingsSchema = z.object({
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6,8}$/)
|
||||
.default("#00000000"),
|
||||
outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif"]).default("png"),
|
||||
outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif", "jxl"]).default("png"),
|
||||
});
|
||||
|
||||
interface ParsedSvgFile {
|
||||
@@ -77,6 +77,10 @@ async function convertSvg(
|
||||
buffer = await image.gif().toBuffer();
|
||||
ext = "gif";
|
||||
break;
|
||||
case "jxl":
|
||||
buffer = await image.jxl({ quality: settings.quality }).toBuffer();
|
||||
ext = "jxl";
|
||||
break;
|
||||
case "heif": {
|
||||
const pngBuffer = await image.png().toBuffer();
|
||||
buffer = await encodeHeic(pngBuffer, settings.quality);
|
||||
|
||||
@@ -158,7 +158,7 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
// The result will be delivered via the SSE progress channel.
|
||||
reply.status(202).send({ jobId: progressJobId, async: true });
|
||||
|
||||
const needsNodeConversion = ["heic", "heif", "avif"].includes(format);
|
||||
const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
|
||||
const pythonFormat = needsNodeConversion ? "png" : format;
|
||||
|
||||
const onProgress = (percent: number, stage: string) => {
|
||||
@@ -185,6 +185,9 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
if (format === "heic" || format === "heif") {
|
||||
outputBuffer = await encodeHeic(result.buffer, outputQuality);
|
||||
finalFormat = format;
|
||||
} else if (format === "jxl") {
|
||||
outputBuffer = await sharp(result.buffer).jxl({ quality: outputQuality }).toBuffer();
|
||||
finalFormat = "jxl";
|
||||
} else if (format === "avif") {
|
||||
outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer();
|
||||
finalFormat = "avif";
|
||||
@@ -201,6 +204,7 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
avif: "avif",
|
||||
heic: "heic",
|
||||
heif: "heif",
|
||||
jxl: "jxl",
|
||||
};
|
||||
const ext = EXT_MAP[finalFormat] || "png";
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`;
|
||||
|
||||
Reference in New Issue
Block a user