feat: make all hardcoded limits configurable via env vars

- bodyLimit: conditional on MAX_UPLOAD_SIZE_MB (0 = 1GB practical max)
- rate limiting: disabled when RATE_LIMIT_PER_MIN=0
- shutdown timeout: 8s → 30s
- upload plugin: no fileSize/files cap when env=0
- session duration: configurable via SESSION_DURATION_HOURS (default 168h)
- login attempts: configurable via LOGIN_ATTEMPT_LIMIT
- batch/pipeline/svg-to-raster: skip guard when MAX_BATCH_SIZE=0
- pipeline steps: configurable via MAX_PIPELINE_STEPS (0 = unlimited)
- user-files: remove 200 hard cap
- stitch canvas: configurable via MAX_CANVAS_PIXELS (0 = unlimited)
- PDF pages: configurable via MAX_PDF_PAGES (0 = unlimited)
- SVG size: configurable via MAX_SVG_SIZE_MB (0 = unlimited)
- logo size: configurable via MAX_LOGO_SIZE_KB (default 2048)
- worker threads: auto-detect via resolveWorkerThreads (0 = auto)
- megapixels: skip validation when MAX_MEGAPIXELS=0
- seam carving: remove 1200px dimension cap
- concurrency: auto-detect via resolveConcurrency (0 = auto)
This commit is contained in:
ashim-hq
2026-04-20 21:50:17 +08:00
parent be254f9ca6
commit 6746989aa1
14 changed files with 57 additions and 81 deletions
+9 -8
View File
@@ -41,7 +41,7 @@ recoverInterruptedInstalls();
const app = Fastify({ const app = Fastify({
logger: { level: env.LOG_LEVEL }, logger: { level: env.LOG_LEVEL },
bodyLimit: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024, bodyLimit: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : 1073741824,
}); });
app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => { app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => {
@@ -79,12 +79,13 @@ app.addHook("onSend", async (_request, reply) => {
} }
}); });
await app.register(rateLimit, { if (env.RATE_LIMIT_PER_MIN > 0) {
max: env.RATE_LIMIT_PER_MIN, await app.register(rateLimit, {
timeWindow: "1 minute", max: env.RATE_LIMIT_PER_MIN,
// Only rate-limit API endpoints — static files and the SPA fallback must never be throttled timeWindow: "1 minute",
allowList: (request) => !request.url.startsWith("/api/"), allowList: (request) => !request.url.startsWith("/api/"),
}); });
}
// Multipart upload support // Multipart upload support
await registerUpload(app); await registerUpload(app);
@@ -195,7 +196,7 @@ try {
} }
// Graceful shutdown // Graceful shutdown
const SHUTDOWN_TIMEOUT_MS = 8000; const SHUTDOWN_TIMEOUT_MS = 30000;
let shuttingDown = false; let shuttingDown = false;
async function shutdown(signal: string) { async function shutdown(signal: string) {
if (shuttingDown) return; if (shuttingDown) return;
+1 -1
View File
@@ -90,7 +90,7 @@ export async function validateImageBuffer(
const height = metadata.height ?? 0; const height = metadata.height ?? 0;
const megapixels = (width * height) / 1_000_000; const megapixels = (width * height) / 1_000_000;
if (megapixels > env.MAX_MEGAPIXELS) { if (env.MAX_MEGAPIXELS > 0 && megapixels > env.MAX_MEGAPIXELS) {
return { return {
valid: false, valid: false,
reason: `Image exceeds maximum size: ${megapixels.toFixed(1)}MP (limit: ${env.MAX_MEGAPIXELS}MP)`, reason: `Image exceeds maximum size: ${megapixels.toFixed(1)}MP (limit: ${env.MAX_MEGAPIXELS}MP)`,
+4 -3
View File
@@ -1,12 +1,13 @@
const MAX_SVG_SIZE = 10 * 1024 * 1024; // 10MB import { env } from "../config.js";
/** /**
* Sanitize an SVG buffer to prevent XXE, SSRF, and script injection. * Sanitize an SVG buffer to prevent XXE, SSRF, and script injection.
* Throws if the SVG exceeds the maximum allowed size. * Throws if the SVG exceeds the maximum allowed size.
*/ */
export function sanitizeSvg(buffer: Buffer): Buffer { export function sanitizeSvg(buffer: Buffer): Buffer {
if (buffer.length > MAX_SVG_SIZE) { const maxSvgSize = env.MAX_SVG_SIZE_MB > 0 ? env.MAX_SVG_SIZE_MB * 1024 * 1024 : Infinity;
throw new Error(`SVG exceeds maximum size of ${MAX_SVG_SIZE / 1024 / 1024}MB`); if (buffer.length > maxSvgSize) {
throw new Error(`SVG exceeds maximum size of ${env.MAX_SVG_SIZE_MB}MB`);
} }
let svg = buffer.toString("utf-8"); let svg = buffer.toString("utf-8");
// Remove DOCTYPE (XXE prevention, including internal subsets) // Remove DOCTYPE (XXE prevention, including internal subsets)
+2 -3
View File
@@ -4,15 +4,14 @@
* Uses Piscina (backed by worker_threads) so Sharp operations don't block * Uses Piscina (backed by worker_threads) so Sharp operations don't block
* HTTP request handling, SSE streams, or health checks. * HTTP request handling, SSE streams, or health checks.
*/ */
import { availableParallelism } from "node:os";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import Piscina from "piscina"; import Piscina from "piscina";
import { loadEnv, resolveWorkerThreads } from "./env.js";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
// Size the pool: leave 1 thread for the event loop, min 1 worker const maxThreads = resolveWorkerThreads(loadEnv());
const maxThreads = Math.max(1, Math.min(availableParallelism() - 1, 4));
let pool: Piscina | null = null; let pool: Piscina | null = null;
+2 -5
View File
@@ -98,7 +98,7 @@ export function requireAdmin(request: FastifyRequest, reply: FastifyReply): Auth
// ── Session helpers ──────────────────────────────────────────────── // ── Session helpers ────────────────────────────────────────────────
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours const SESSION_DURATION_MS = env.SESSION_DURATION_HOURS * 60 * 60 * 1000;
function createSessionToken(): string { function createSessionToken(): string {
return randomUUID(); return randomUUID();
@@ -137,10 +137,7 @@ export async function ensureDefaultAdmin(): Promise<void> {
// ── Login attempt limit ────────────────────────────────────────── // ── Login attempt limit ──────────────────────────────────────────
const DEFAULT_LOGIN_ATTEMPT_LIMIT = 10;
function getLoginAttemptLimit(): number { function getLoginAttemptLimit(): number {
// Allow override via RATE_LIMIT_PER_MIN for test environments
if (env.RATE_LIMIT_PER_MIN > 1000) return env.RATE_LIMIT_PER_MIN; if (env.RATE_LIMIT_PER_MIN > 1000) return env.RATE_LIMIT_PER_MIN;
const row = db const row = db
.select() .select()
@@ -151,7 +148,7 @@ function getLoginAttemptLimit(): number {
const parsed = parseInt(row.value, 10); const parsed = parseInt(row.value, 10);
if (!Number.isNaN(parsed) && parsed > 0) return parsed; if (!Number.isNaN(parsed) && parsed > 0) return parsed;
} }
return DEFAULT_LOGIN_ATTEMPT_LIMIT; return env.LOGIN_ATTEMPT_LIMIT;
} }
// ── Auth routes ──────────────────────────────────────────────────── // ── Auth routes ────────────────────────────────────────────────────
+2 -2
View File
@@ -5,8 +5,8 @@ import { env } from "../config.js";
export async function registerUpload(app: FastifyInstance): Promise<void> { export async function registerUpload(app: FastifyInstance): Promise<void> {
await app.register(multipart, { await app.register(multipart, {
limits: { limits: {
fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024, fileSize: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : undefined,
files: env.MAX_BATCH_SIZE, files: env.MAX_BATCH_SIZE > 0 ? env.MAX_BATCH_SIZE : undefined,
}, },
}); });
} }
+3 -2
View File
@@ -15,6 +15,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import PQueue from "p-queue"; import PQueue from "p-queue";
import { env } from "../config.js"; import { env } from "../config.js";
import { autoOrient } from "../lib/auto-orient.js"; import { autoOrient } from "../lib/auto-orient.js";
import { resolveConcurrency } from "../lib/env.js";
import { formatZodErrors } from "../lib/errors.js"; import { formatZodErrors } from "../lib/errors.js";
import { isToolInstalled } from "../lib/feature-status.js"; import { isToolInstalled } from "../lib/feature-status.js";
import { validateImageBuffer } from "../lib/file-validation.js"; import { validateImageBuffer } from "../lib/file-validation.js";
@@ -90,7 +91,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
} }
// Enforce batch size limit // Enforce batch size limit
if (files.length > env.MAX_BATCH_SIZE) { if (env.MAX_BATCH_SIZE > 0 && files.length > env.MAX_BATCH_SIZE) {
return reply.status(400).send({ return reply.status(400).send({
error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`, error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`,
}); });
@@ -126,7 +127,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
updateJobProgress({ ...progress }); updateJobProgress({ ...progress });
// Use p-queue for concurrency control // Use p-queue for concurrency control
const queue = new PQueue({ concurrency: env.CONCURRENT_JOBS }); const queue = new PQueue({ concurrency: resolveConcurrency(env) });
// All processed buffers are held in memory until ZIP streaming begins. // All processed buffers are held in memory until ZIP streaming begins.
// Peak memory scales with files.length * avg output size. MAX_BATCH_SIZE bounds this. // Peak memory scales with files.length * avg output size. MAX_BATCH_SIZE bounds this.
+7 -5
View File
@@ -11,13 +11,14 @@ import { join } from "node:path";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp"; import sharp from "sharp";
import { env } from "../config.js";
import { db, schema } from "../db/index.js"; import { db, schema } from "../db/index.js";
import { ensureSharpCompat } from "../lib/heic-converter.js"; import { ensureSharpCompat } from "../lib/heic-converter.js";
import { requireAdmin } from "../plugins/auth.js"; import { requireAdmin } from "../plugins/auth.js";
const BRANDING_DIR = join(process.cwd(), "data", "branding"); const BRANDING_DIR = join(process.cwd(), "data", "branding");
const LOGO_PATH = join(BRANDING_DIR, "logo.png"); const LOGO_PATH = join(BRANDING_DIR, "logo.png");
const MAX_LOGO_SIZE = 500 * 1024; // 500 KB const maxLogoSize = env.MAX_LOGO_SIZE_KB * 1024;
function upsertSetting(key: string, value: string): void { function upsertSetting(key: string, value: string): void {
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get(); const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
@@ -51,10 +52,11 @@ export async function brandingRoutes(app: FastifyInstance): Promise<void> {
const buffer = await file.toBuffer(); const buffer = await file.toBuffer();
// Validate size // Validate size
if (buffer.length > MAX_LOGO_SIZE) { if (buffer.length > maxLogoSize) {
return reply return reply.status(400).send({
.status(400) error: `Logo must be ${env.MAX_LOGO_SIZE_KB}KB or smaller`,
.send({ error: "Logo must be 500KB or smaller", code: "VALIDATION_ERROR" }); code: "VALIDATION_ERROR",
});
} }
// Decode HEIC/HEIF if needed, then convert to PNG, resize to max 128x128 // Decode HEIC/HEIF if needed, then convert to PNG, resize to max 128x128
+9 -4
View File
@@ -18,6 +18,7 @@ import { z } from "zod";
import { env } from "../config.js"; import { env } from "../config.js";
import { db, schema } from "../db/index.js"; import { db, schema } from "../db/index.js";
import { autoOrient } from "../lib/auto-orient.js"; import { autoOrient } from "../lib/auto-orient.js";
import { resolveConcurrency } from "../lib/env.js";
import { formatZodErrors } from "../lib/errors.js"; import { formatZodErrors } from "../lib/errors.js";
import { isToolInstalled } from "../lib/feature-status.js"; import { isToolInstalled } from "../lib/feature-status.js";
import { validateImageBuffer } from "../lib/file-validation.js"; import { validateImageBuffer } from "../lib/file-validation.js";
@@ -39,7 +40,9 @@ const pipelineDefinitionSchema = z.object({
steps: z steps: z
.array(pipelineStepSchema) .array(pipelineStepSchema)
.min(1, "Pipeline must have at least one step") .min(1, "Pipeline must have at least one step")
.max(20, "Pipeline cannot exceed 20 steps"), .refine((steps) => env.MAX_PIPELINE_STEPS === 0 || steps.length <= env.MAX_PIPELINE_STEPS, {
message: "Pipeline exceeds maximum steps",
}),
}); });
/** Schema for saving a pipeline. */ /** Schema for saving a pipeline. */
@@ -49,7 +52,9 @@ const savePipelineSchema = z.object({
steps: z steps: z
.array(pipelineStepSchema) .array(pipelineStepSchema)
.min(1, "Pipeline must have at least one step") .min(1, "Pipeline must have at least one step")
.max(20, "Pipeline cannot exceed 20 steps"), .refine((steps) => env.MAX_PIPELINE_STEPS === 0 || steps.length <= env.MAX_PIPELINE_STEPS, {
message: "Pipeline exceeds maximum steps",
}),
}); });
export async function registerPipelineRoutes(app: FastifyInstance): Promise<void> { export async function registerPipelineRoutes(app: FastifyInstance): Promise<void> {
@@ -424,7 +429,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
} }
// Enforce batch size limit // Enforce batch size limit
if (files.length > env.MAX_BATCH_SIZE) { if (env.MAX_BATCH_SIZE > 0 && files.length > env.MAX_BATCH_SIZE) {
return reply.status(400).send({ return reply.status(400).send({
error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`, error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`,
}); });
@@ -499,7 +504,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
updateJobProgress({ ...progress }); updateJobProgress({ ...progress });
// ── Process files through the pipeline with concurrency control ── // ── Process files through the pipeline with concurrency control ──
const queue = new PQueue({ concurrency: env.CONCURRENT_JOBS }); const queue = new PQueue({ concurrency: resolveConcurrency(env) });
const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill( const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill(
null, null,
+2 -1
View File
@@ -7,6 +7,7 @@ import type { FastifyInstance } from "fastify";
import * as mupdf from "mupdf"; import * as mupdf from "mupdf";
import sharp from "sharp"; import sharp from "sharp";
import { z } from "zod"; import { z } from "zod";
import { env } from "../../config.js";
import { formatZodErrors } from "../../lib/errors.js"; import { formatZodErrors } from "../../lib/errors.js";
import { encodeHeic } from "../../lib/heic-converter.js"; import { encodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js"; import { createWorkspace } from "../../lib/workspace.js";
@@ -229,7 +230,7 @@ export function registerPdfToImage(app: FastifyInstance) {
return reply.status(400).send({ error: "Password-protected PDFs are not supported" }); return reply.status(400).send({ error: "Password-protected PDFs are not supported" });
} }
const pageCount = doc.countPages(); const pageCount = doc.countPages();
const maxPages = Math.min(pageCount, 200); const maxPages = env.MAX_PDF_PAGES > 0 ? Math.min(pageCount, env.MAX_PDF_PAGES) : pageCount;
const thumbnails: Array<{ const thumbnails: Array<{
page: number; page: number;
dataUrl: string; dataUrl: string;
+4 -4
View File
@@ -4,14 +4,13 @@ import { basename, join } from "node:path";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import sharp from "sharp"; import sharp from "sharp";
import { z } from "zod"; import { z } from "zod";
import { env } from "../../config.js";
import { autoOrient } from "../../lib/auto-orient.js"; import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js"; import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js"; import { validateImageBuffer } from "../../lib/file-validation.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js"; import { createWorkspace } from "../../lib/workspace.js";
const MAX_CANVAS_PIXELS = 100_000_000;
const settingsSchema = z.object({ const settingsSchema = z.object({
direction: z.enum(["horizontal", "vertical", "grid"]).default("horizontal"), direction: z.enum(["horizontal", "vertical", "grid"]).default("horizontal"),
gridColumns: z.number().int().min(2).max(100).default(2), gridColumns: z.number().int().min(2).max(100).default(2),
@@ -180,9 +179,10 @@ export function registerStitch(app: FastifyInstance) {
} }
} }
if (canvasWidth * canvasHeight > MAX_CANVAS_PIXELS) { const maxCanvasPixels = env.MAX_CANVAS_PIXELS > 0 ? env.MAX_CANVAS_PIXELS : Infinity;
if (canvasWidth * canvasHeight > maxCanvasPixels) {
return reply.status(422).send({ return reply.status(422).send({
error: `Canvas too large: ${canvasWidth}x${canvasHeight} (${Math.round((canvasWidth * canvasHeight) / 1_000_000)}MP exceeds 100MP limit)`, error: `Canvas too large: ${canvasWidth}x${canvasHeight} (${Math.round((canvasWidth * canvasHeight) / 1_000_000)}MP exceeds ${Math.round(maxCanvasPixels / 1_000_000)}MP limit)`,
}); });
} }
+3 -2
View File
@@ -7,6 +7,7 @@ import PQueue from "p-queue";
import sharp from "sharp"; import sharp from "sharp";
import { z } from "zod"; import { z } from "zod";
import { env } from "../../config.js"; import { env } from "../../config.js";
import { resolveConcurrency } from "../../lib/env.js";
import { formatZodErrors } from "../../lib/errors.js"; import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js"; import { sanitizeFilename } from "../../lib/filename.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js"; import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
@@ -135,7 +136,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
return reply.status(400).send({ error: "No SVG files provided" }); return reply.status(400).send({ error: "No SVG files provided" });
} }
if (files.length > env.MAX_BATCH_SIZE) { if (env.MAX_BATCH_SIZE > 0 && files.length > env.MAX_BATCH_SIZE) {
return reply.status(400).send({ return reply.status(400).send({
error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`, error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`,
}); });
@@ -157,7 +158,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
} }
const jobId = clientJobId || randomUUID(); const jobId = clientJobId || randomUUID();
const queue = new PQueue({ concurrency: env.CONCURRENT_JOBS }); const queue = new PQueue({ concurrency: resolveConcurrency(env) });
const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill( const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill(
null, null,
); );
+1 -1
View File
@@ -102,7 +102,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
const user = requireAuth(request, reply); const user = requireAuth(request, reply);
if (!user) return; if (!user) return;
const limit = Math.min(parseInt(request.query.limit ?? "50", 10) || 50, 200); const limit = parseInt(request.query.limit ?? "50", 10) || 50;
const offset = parseInt(request.query.offset ?? "0", 10) || 0; const offset = parseInt(request.query.offset ?? "0", 10) || 0;
const search = request.query.search?.trim(); const search = request.query.search?.trim();
+8 -40
View File
@@ -47,17 +47,11 @@ async function findCaire(): Promise<string> {
); );
} }
/** Max pixels on the longest edge before downscaling for caire. */
const MAX_CAIRE_DIMENSION = 1200;
/** /**
* Content-aware resize using caire (Go seam carving engine). * Content-aware resize using caire (Go seam carving engine).
* Supports both shrinking and enlarging via seam removal/insertion. * Supports both shrinking and enlarging via seam removal/insertion.
* * Processes at native resolution -- JPEG intermediate is used because
* Large images (>1200px longest edge) are downscaled first because * Go's JPEG decoder is significantly faster than PNG for large images.
* seam carving is O(width * height * seams) and becomes impractical
* on high-resolution inputs. JPEG intermediate is used because Go's
* JPEG decoder is significantly faster than PNG for large images.
*/ */
export async function seamCarve( export async function seamCarve(
inputBuffer: Buffer, inputBuffer: Buffer,
@@ -66,35 +60,18 @@ export async function seamCarve(
): Promise<SeamCarveResult> { ): Promise<SeamCarveResult> {
const cairePath = await findCaire(); const cairePath = await findCaire();
const id = randomUUID(); const id = randomUUID();
// Use JPEG for input (fast decode in Go) and PNG for output (lossless)
const inputPath = join(outputDir, `caire-in-${id}.jpg`); const inputPath = join(outputDir, `caire-in-${id}.jpg`);
const outputPath = join(outputDir, `caire-out-${id}.png`); const outputPath = join(outputDir, `caire-out-${id}.png`);
try { try {
// Downscale large images and convert to JPEG for fast caire processing
const meta = await sharp(inputBuffer).metadata(); const meta = await sharp(inputBuffer).metadata();
const origWidth = meta.width ?? 0; const width = meta.width ?? 0;
const origHeight = meta.height ?? 0; const height = meta.height ?? 0;
const longest = Math.max(origWidth, origHeight);
let width = origWidth; const processBuffer = await sharp(inputBuffer).jpeg({ quality: 95 }).toBuffer();
let height = origHeight;
if (longest > MAX_CAIRE_DIMENSION) {
const scale = MAX_CAIRE_DIMENSION / longest;
width = Math.round(origWidth * scale);
height = Math.round(origHeight * scale);
}
// Always output JPEG for caire input (Go decodes JPEG 3-5x faster than PNG)
const processBuffer = await sharp(inputBuffer)
.resize(width, height, { fit: "fill" })
.jpeg({ quality: 95 })
.toBuffer();
await writeFile(inputPath, processBuffer); await writeFile(inputPath, processBuffer);
// Build caire arguments
const args = ["-in", inputPath, "-out", outputPath, "-preview=false"]; const args = ["-in", inputPath, "-out", outputPath, "-preview=false"];
if (options.square) { if (options.square) {
@@ -102,19 +79,10 @@ export async function seamCarve(
args.push("-square", "-width", String(shortest), "-height", String(shortest)); args.push("-square", "-width", String(shortest), "-height", String(shortest));
} else { } else {
if (options.width) { if (options.width) {
// Scale user-specified dimensions proportionally if image was downscaled args.push("-width", String(options.width));
const targetW =
longest > MAX_CAIRE_DIMENSION
? Math.round(options.width * (MAX_CAIRE_DIMENSION / longest))
: options.width;
args.push("-width", String(targetW));
} }
if (options.height) { if (options.height) {
const targetH = args.push("-height", String(options.height));
longest > MAX_CAIRE_DIMENSION
? Math.round(options.height * (MAX_CAIRE_DIMENSION / longest))
: options.height;
args.push("-height", String(targetH));
} }
} }
@@ -122,7 +90,7 @@ export async function seamCarve(
if (options.blurRadius !== undefined) args.push("-blur", String(options.blurRadius)); if (options.blurRadius !== undefined) args.push("-blur", String(options.blurRadius));
if (options.sobelThreshold !== undefined) args.push("-sobel", String(options.sobelThreshold)); if (options.sobelThreshold !== undefined) args.push("-sobel", String(options.sobelThreshold));
const megapixels = (origWidth * origHeight) / 1_000_000; const megapixels = (width * height) / 1_000_000;
const timeoutMs = Math.max(120_000, megapixels * 10 * 1000); const timeoutMs = Math.max(120_000, megapixels * 10 * 1000);
await execFileAsync(cairePath, args, { timeout: timeoutMs }); await execFileAsync(cairePath, args, { timeout: timeoutMs });