Merge pull request #80 from ashim-hq/feat/unlimited-by-default

feat: Unlimited by Default — remove all artificial limits
This commit is contained in:
Ashim
2026-04-21 00:05:41 +08:00
committed by GitHub
59 changed files with 469 additions and 305 deletions
+30 -9
View File
@@ -1,17 +1,38 @@
# Server port (used in production / Docker) # Server
# In dev, the API auto-starts on an internal port; you always access localhost:1349
PORT=1349 PORT=1349
AUTH_ENABLED=true AUTH_ENABLED=true
DEFAULT_USERNAME=admin DEFAULT_USERNAME=admin
DEFAULT_PASSWORD=admin DEFAULT_PASSWORD=admin
STORAGE_MODE=local STORAGE_MODE=local
FILE_MAX_AGE_HOURS=24
CLEANUP_INTERVAL_MINUTES=30 # Cleanup
MAX_UPLOAD_SIZE_MB=100 FILE_MAX_AGE_HOURS=72
MAX_BATCH_SIZE=200 CLEANUP_INTERVAL_MINUTES=60
CONCURRENT_JOBS=3
MAX_MEGAPIXELS=100 # Upload & Batch (0 = unlimited)
RATE_LIMIT_PER_MIN=100 MAX_UPLOAD_SIZE_MB=0
MAX_BATCH_SIZE=0
CONCURRENT_JOBS=0
MAX_MEGAPIXELS=0
# Rate limiting (0 = disabled)
RATE_LIMIT_PER_MIN=0
# Users (0 = unlimited)
MAX_USERS=0
# Processing (0 = auto/unlimited)
MAX_WORKER_THREADS=0
PROCESSING_TIMEOUT_S=0
MAX_PIPELINE_STEPS=0
MAX_CANVAS_PIXELS=0
MAX_SVG_SIZE_MB=0
MAX_LOGO_SIZE_KB=2048
MAX_SPLIT_GRID=100
MAX_PDF_PAGES=0
SESSION_DURATION_HOURS=168
LOGIN_ATTEMPT_LIMIT=10
# Set to true in CI/dev to skip the forced password-change on the default admin # Set to true in CI/dev to skip the forced password-change on the default admin
# SKIP_MUST_CHANGE_PASSWORD=false # SKIP_MUST_CHANGE_PASSWORD=false
DB_PATH=./data/ashim.db DB_PATH=./data/ashim.db
+1 -1
View File
@@ -13,7 +13,7 @@ const sqlite: DatabaseType = new Database(env.DB_PATH);
// Critical SQLite pragmas for reliability. // Critical SQLite pragmas for reliability.
// busy_timeout must be set first so journal_mode = WAL can retry // busy_timeout must be set first so journal_mode = WAL can retry
// if another connection holds the lock (e.g. parallel test files). // if another connection holds the lock (e.g. parallel test files).
sqlite.pragma("busy_timeout = 5000"); sqlite.pragma("busy_timeout = 10000");
sqlite.pragma("journal_mode = WAL"); sqlite.pragma("journal_mode = WAL");
sqlite.pragma("synchronous = NORMAL"); sqlite.pragma("synchronous = NORMAL");
sqlite.pragma("foreign_keys = ON"); sqlite.pragma("foreign_keys = ON");
+10 -8
View File
@@ -41,7 +41,8 @@ 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,
maxParamLength: 500,
}); });
app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => { app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => {
@@ -79,12 +80,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 +197,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;
+29 -8
View File
@@ -1,3 +1,4 @@
import { availableParallelism } from "node:os";
import { z } from "zod"; import { z } from "zod";
const envSchema = z.object({ const envSchema = z.object({
@@ -13,13 +14,13 @@ const envSchema = z.object({
.default("false") .default("false")
.transform((v) => v === "true"), .transform((v) => v === "true"),
STORAGE_MODE: z.enum(["local", "s3"]).default("local"), STORAGE_MODE: z.enum(["local", "s3"]).default("local"),
FILE_MAX_AGE_HOURS: z.coerce.number().default(24), FILE_MAX_AGE_HOURS: z.coerce.number().default(72),
CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(30), CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(60),
MAX_UPLOAD_SIZE_MB: z.coerce.number().default(100), MAX_UPLOAD_SIZE_MB: z.coerce.number().default(0),
MAX_BATCH_SIZE: z.coerce.number().default(200), MAX_BATCH_SIZE: z.coerce.number().default(0),
CONCURRENT_JOBS: z.coerce.number().default(3), CONCURRENT_JOBS: z.coerce.number().default(0),
MAX_MEGAPIXELS: z.coerce.number().default(100), MAX_MEGAPIXELS: z.coerce.number().default(0),
RATE_LIMIT_PER_MIN: z.coerce.number().default(100), RATE_LIMIT_PER_MIN: z.coerce.number().default(0),
DB_PATH: z.string().default("./data/ashim.db"), DB_PATH: z.string().default("./data/ashim.db"),
FILES_STORAGE_PATH: z.string().default("./data/files"), FILES_STORAGE_PATH: z.string().default("./data/files"),
WORKSPACE_PATH: z.string().default("./tmp/workspace"), WORKSPACE_PATH: z.string().default("./tmp/workspace"),
@@ -27,8 +28,18 @@ const envSchema = z.object({
DEFAULT_LOCALE: z.string().default("en"), DEFAULT_LOCALE: z.string().default("en"),
APP_NAME: z.string().default("ashim"), APP_NAME: z.string().default("ashim"),
CORS_ORIGIN: z.string().default(""), CORS_ORIGIN: z.string().default(""),
MAX_USERS: z.coerce.number().default(5), MAX_USERS: z.coerce.number().default(0),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
MAX_WORKER_THREADS: z.coerce.number().default(0),
PROCESSING_TIMEOUT_S: z.coerce.number().default(0),
MAX_PIPELINE_STEPS: z.coerce.number().default(0),
MAX_CANVAS_PIXELS: z.coerce.number().default(0),
MAX_SVG_SIZE_MB: z.coerce.number().default(0),
MAX_LOGO_SIZE_KB: z.coerce.number().default(2048),
MAX_SPLIT_GRID: z.coerce.number().default(100),
MAX_PDF_PAGES: z.coerce.number().default(0),
SESSION_DURATION_HOURS: z.coerce.number().default(168),
LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(10),
}); });
export type Env = z.infer<typeof envSchema>; export type Env = z.infer<typeof envSchema>;
@@ -36,3 +47,13 @@ export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env { export function loadEnv(): Env {
return envSchema.parse(process.env); return envSchema.parse(process.env);
} }
export function resolveConcurrency(env: Env): number {
if (env.CONCURRENT_JOBS > 0) return env.CONCURRENT_JOBS;
return Math.max(2, availableParallelism() - 1);
}
export function resolveWorkerThreads(env: Env): number {
if (env.MAX_WORKER_THREADS > 0) return env.MAX_WORKER_THREADS;
return Math.max(2, availableParallelism() - 1);
}
+2 -2
View File
@@ -51,7 +51,7 @@ export async function inspectMetadata(buffer: Buffer, filename: string): Promise
try { try {
await writeFile(tempPath, buffer); await writeFile(tempPath, buffer);
const { stdout } = await execFileAsync(bin, ["-json", "-G", "-struct", "-n", tempPath], { const { stdout } = await execFileAsync(bin, ["-json", "-G", "-struct", "-n", tempPath], {
timeout: 30_000, timeout: 60_000,
maxBuffer: 10 * 1024 * 1024, maxBuffer: 10 * 1024 * 1024,
}); });
@@ -126,7 +126,7 @@ export async function writeMetadata(
try { try {
await writeFile(tempPath, buffer); await writeFile(tempPath, buffer);
await execFileAsync(bin, ["-overwrite_original", ...tags, tempPath], { await execFileAsync(bin, ["-overwrite_original", ...tags, tempPath], {
timeout: 30_000, timeout: 60_000,
maxBuffer: 10 * 1024 * 1024, maxBuffer: 10 * 1024 * 1024,
}); });
return await readFile(tempPath); return await readFile(tempPath);
+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)`,
+2 -2
View File
@@ -45,7 +45,7 @@ export async function decodeHeic(buffer: Buffer): Promise<Buffer> {
try { try {
await writeFile(inputPath, buffer); await writeFile(inputPath, buffer);
await execFileAsync(cmd, [inputPath, outputPath], { timeout: 30_000 }); await execFileAsync(cmd, [inputPath, outputPath], { timeout: 120_000 });
// Single-image HEIF: exact filename. Multi-image: -1 suffix on first image. // Single-image HEIF: exact filename. Multi-image: -1 suffix on first image.
try { try {
@@ -94,7 +94,7 @@ export async function encodeHeic(buffer: Buffer, quality = 80): Promise<Buffer>
try { try {
await writeFile(inputPath, buffer); await writeFile(inputPath, buffer);
await execFileAsync("heif-enc", ["-q", String(quality), "-o", outputPath, inputPath], { await execFileAsync("heif-enc", ["-q", String(quality), "-o", outputPath, inputPath], {
timeout: 30_000, timeout: 120_000,
}); });
return await readFile(outputPath); return await readFile(outputPath);
} finally { } finally {
+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)
+26
View File
@@ -0,0 +1,26 @@
import { env } from "../config.js";
type ToolCategory = "sharp" | "ai_cpu" | "ai_gpu" | "external" | "python";
const TIMEOUT_RATES: Record<ToolCategory, number> = {
sharp: 2,
ai_cpu: 30,
ai_gpu: 5,
external: 10,
python: 15,
};
export function computeTimeout(megapixels: number, category: ToolCategory, fileCount = 1): number {
if (env.PROCESSING_TIMEOUT_S > 0) {
return env.PROCESSING_TIMEOUT_S * 1000;
}
const perFile = Math.max(60_000, megapixels * TIMEOUT_RATES[category] * 1000);
return perFile * fileCount;
}
export function computeExternalToolTimeout(megapixels: number): number {
if (env.PROCESSING_TIMEOUT_S > 0) {
return env.PROCESSING_TIMEOUT_S * 1000;
}
return Math.max(60_000, megapixels * TIMEOUT_RATES.external * 1000);
}
+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
+11 -6
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,17 +40,21 @@ 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. */
const savePipelineSchema = z.object({ const savePipelineSchema = z.object({
name: z.string().min(1, "Pipeline name is required").max(100), name: z.string().min(1, "Pipeline name is required").max(255),
description: z.string().max(500).optional(), description: z.string().max(2000).optional(),
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,
+5 -1
View File
@@ -15,6 +15,7 @@ import { sanitizeFilename } from "../lib/filename.js";
import { decodeHeic } from "../lib/heic-converter.js"; import { decodeHeic } from "../lib/heic-converter.js";
import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js"; import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js";
import { sanitizeSvg } from "../lib/svg-sanitize.js"; import { sanitizeSvg } from "../lib/svg-sanitize.js";
import { computeTimeout } from "../lib/timeout.js";
import { getWorkerPool } from "../lib/worker-pool.js"; import { getWorkerPool } from "../lib/worker-pool.js";
import { createWorkspace } from "../lib/workspace.js"; import { createWorkspace } from "../lib/workspace.js";
@@ -227,8 +228,11 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
filename, filename,
inputFormat: validation.format, inputFormat: validation.format,
}; };
const meta = await sharp(fileBuffer).metadata();
const megapixels = ((meta.width ?? 0) * (meta.height ?? 0)) / 1_000_000;
const timeoutMs = computeTimeout(megapixels, "sharp");
const workerResult: WorkerOutput = await pool.run(workerInput, { const workerResult: WorkerOutput = await pool.run(workerInput, {
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(timeoutMs),
}); });
result = { result = {
buffer: Buffer.from(workerResult.buffer), buffer: Buffer.from(workerResult.buffer),
+3 -3
View File
@@ -6,13 +6,13 @@ import { createToolRoute } from "../tool-factory.js";
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/); const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/);
const settingsSchema = z.object({ const settingsSchema = z.object({
borderWidth: z.number().min(0).max(200).default(10), borderWidth: z.number().min(0).max(2000).default(10),
borderColor: hexColor.default("#000000"), borderColor: hexColor.default("#000000"),
padding: z.number().min(0).max(200).default(0), padding: z.number().min(0).max(200).default(0),
paddingColor: hexColor.default("#FFFFFF"), paddingColor: hexColor.default("#FFFFFF"),
cornerRadius: z.number().min(0).max(500).default(0), cornerRadius: z.number().min(0).max(2000).default(0),
shadow: z.boolean().default(false), shadow: z.boolean().default(false),
shadowBlur: z.number().min(1).max(50).default(15), shadowBlur: z.number().min(1).max(200).default(15),
shadowOffsetX: z.number().min(-50).max(50).default(0), shadowOffsetX: z.number().min(-50).max(50).default(0),
shadowOffsetY: z.number().min(-50).max(50).default(5), shadowOffsetY: z.number().min(-50).max(50).default(5),
shadowColor: hexColor.default("#000000"), shadowColor: hexColor.default("#000000"),
+1 -1
View File
@@ -6,7 +6,7 @@ import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js"; import { formatZodErrors } from "../../lib/errors.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
pattern: z.string().min(1).max(200).default("image-{{index}}"), pattern: z.string().min(1).max(1000).default("image-{{index}}"),
startIndex: z.number().min(0).default(1), startIndex: z.number().min(0).default(1),
}); });
+3 -3
View File
@@ -321,15 +321,15 @@ const cellSchema = z.object({
imageIndex: z.number().int().min(0), imageIndex: z.number().int().min(0),
panX: z.number().min(-100).max(100).default(0), panX: z.number().min(-100).max(100).default(0),
panY: z.number().min(-100).max(100).default(0), panY: z.number().min(-100).max(100).default(0),
zoom: z.number().min(1).max(3).default(1), zoom: z.number().min(1).max(10).default(1),
objectFit: z.enum(["cover", "contain"]).default("cover"), objectFit: z.enum(["cover", "contain"]).default("cover"),
}); });
const settingsSchema = z.object({ const settingsSchema = z.object({
templateId: z.string(), templateId: z.string(),
cells: z.array(cellSchema).optional(), cells: z.array(cellSchema).optional(),
gap: z.number().min(0).max(50).default(8), gap: z.number().min(0).max(500).default(8),
cornerRadius: z.number().min(0).max(30).default(0), cornerRadius: z.number().min(0).max(500).default(0),
backgroundColor: z.string().default("#FFFFFF"), backgroundColor: z.string().default("#FFFFFF"),
aspectRatio: z.string().default("free"), aspectRatio: z.string().default("free"),
outputFormat: z.enum(["png", "jpeg", "webp"]).default("png"), outputFormat: z.enum(["png", "jpeg", "webp"]).default("png"),
+2 -2
View File
@@ -65,8 +65,8 @@ const settingsSchema = z.object({
mode: z.enum(["resize", "optimize", "speed", "reverse", "extract", "rotate"]).default("resize"), mode: z.enum(["resize", "optimize", "speed", "reverse", "extract", "rotate"]).default("resize"),
// Resize // Resize
width: z.number().min(1).max(4096).optional(), width: z.number().min(1).max(16384).optional(),
height: z.number().min(1).max(4096).optional(), height: z.number().min(1).max(16384).optional(),
percentage: z.number().min(1).max(500).optional(), percentage: z.number().min(1).max(500).optional(),
// Optimize // Optimize
+1 -1
View File
@@ -13,7 +13,7 @@ import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
pageSize: z.enum(["A4", "Letter", "A3", "A5"]).default("A4"), pageSize: z.enum(["A4", "Letter", "A3", "A5"]).default("A4"),
orientation: z.enum(["portrait", "landscape"]).default("portrait"), orientation: z.enum(["portrait", "landscape"]).default("portrait"),
margin: z.number().min(0).max(100).default(20), margin: z.number().min(0).max(500).default(20),
}); });
const PAGE_SIZES: Record<string, [number, number]> = { const PAGE_SIZES: Record<string, [number, number]> = {
+1 -1
View File
@@ -36,7 +36,7 @@ const generateSettingsSchema = z.object({
bgColor: z.string().default("#FFFFFF"), bgColor: z.string().default("#FFFFFF"),
printLayout: z.string().default("none"), printLayout: z.string().default("none"),
maxFileSizeKb: z.number().default(0), maxFileSizeKb: z.number().default(0),
dpi: z.number().min(72).max(600).default(300), dpi: z.number().min(72).max(1200).default(300),
customWidthMm: z.number().optional(), customWidthMm: z.number().optional(),
customHeightMm: z.number().optional(), customHeightMm: z.number().optional(),
zoom: z.number().min(0.5).max(3).default(1), zoom: z.number().min(0.5).max(3).default(1),
+3 -2
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";
@@ -14,7 +15,7 @@ import { createWorkspace } from "../../lib/workspace.js";
// ── Settings schema ────────────────────────────────────────────── // ── Settings schema ──────────────────────────────────────────────
const settingsSchema = z.object({ 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"]).default("png"),
dpi: z.number().min(36).max(1200).default(150), dpi: z.number().min(36).max(2400).default(150),
quality: z.number().min(1).max(100).default(85), quality: z.number().min(1).max(100).default(85),
colorMode: z.enum(["color", "grayscale", "bw"]).default("color"), colorMode: z.enum(["color", "grayscale", "bw"]).default("color"),
pages: z.string().default("all"), pages: z.string().default("all"),
@@ -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;
+1 -1
View File
@@ -9,7 +9,7 @@ import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
text: z.string().min(1).max(2000), text: z.string().min(1).max(2000),
size: z.number().min(100).max(2000).default(400), size: z.number().min(100).max(10000).default(400),
errorCorrection: z.enum(["L", "M", "Q", "H"]).default("M"), errorCorrection: z.enum(["L", "M", "Q", "H"]).default("M"),
foreground: z foreground: z
.string() .string()
+1 -1
View File
@@ -15,7 +15,7 @@ const settingsSchema = z.object({
y2: z.number().min(0).max(50).default(12), y2: z.number().min(0).max(50).default(12),
y3: z.number().min(0).max(50).default(20), y3: z.number().min(0).max(50).default(20),
// Unsharp Mask // Unsharp Mask
amount: z.number().min(0).max(500).default(100), amount: z.number().min(0).max(1000).default(100),
radius: z.number().min(0.1).max(5).default(1.0), radius: z.number().min(0.1).max(5).default(1.0),
threshold: z.number().min(0).max(255).default(0), threshold: z.number().min(0).max(255).default(0),
// High-Pass // High-Pass
+4 -4
View File
@@ -9,8 +9,8 @@ import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
columns: z.number().min(1).max(20).default(3), columns: z.number().min(1).max(100).default(3),
rows: z.number().min(1).max(20).default(3), rows: z.number().min(1).max(100).default(3),
tileWidth: z.number().min(10).optional(), tileWidth: z.number().min(10).optional(),
tileHeight: z.number().min(10).optional(), tileHeight: z.number().min(10).optional(),
outputFormat: z.enum(["original", "png", "jpg", "webp"]).default("original"), outputFormat: z.enum(["original", "png", "jpg", "webp"]).default("original"),
@@ -89,8 +89,8 @@ export function registerSplit(app: FastifyInstance) {
cols = Math.max(1, Math.ceil(fullW / settings.tileWidth)); cols = Math.max(1, Math.ceil(fullW / settings.tileWidth));
rows = Math.max(1, Math.ceil(fullH / settings.tileHeight)); rows = Math.max(1, Math.ceil(fullH / settings.tileHeight));
} }
cols = Math.min(cols, 20); cols = Math.min(cols, 100);
rows = Math.min(rows, 20); rows = Math.min(rows, 100);
const cellW = Math.floor(fullW / cols); const cellW = Math.floor(fullW / cols);
const cellH = Math.floor(fullH / rows); const cellH = Math.floor(fullH / rows);
+8 -8
View File
@@ -4,22 +4,21 @@ 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(10).default(2), gridColumns: z.number().int().min(2).max(100).default(2),
resizeMode: z.enum(["fit", "original", "stretch", "crop"]).default("fit"), resizeMode: z.enum(["fit", "original", "stretch", "crop"]).default("fit"),
alignment: z.enum(["start", "center", "end"]).default("center"), alignment: z.enum(["start", "center", "end"]).default("center"),
gap: z.number().min(0).max(200).default(0), gap: z.number().min(0).max(1000).default(0),
border: z.number().min(0).max(50).default(0), border: z.number().min(0).max(500).default(0),
cornerRadius: z.number().min(0).max(50).default(0), cornerRadius: z.number().min(0).max(500).default(0),
backgroundColor: z backgroundColor: z
.string() .string()
.regex(/^#[0-9a-fA-F]{6}$/) .regex(/^#[0-9a-fA-F]{6}$/)
@@ -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)`,
}); });
} }
+6 -5
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";
@@ -17,9 +18,9 @@ import { updateJobProgress } from "../progress.js";
const NON_PREVIEWABLE = new Set(["tiff", "heif"]); const NON_PREVIEWABLE = new Set(["tiff", "heif"]);
const settingsSchema = z.object({ const settingsSchema = z.object({
width: z.number().min(1).max(16384).optional(), width: z.number().min(1).max(65536).optional(),
height: z.number().min(1).max(16384).optional(), height: z.number().min(1).max(65536).optional(),
dpi: z.number().min(36).max(1200).default(300), dpi: z.number().min(36).max(2400).default(300),
quality: z.number().min(1).max(100).default(90), quality: z.number().min(1).max(100).default(90),
backgroundColor: z backgroundColor: z
.string() .string()
@@ -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,
); );
+3 -3
View File
@@ -14,9 +14,9 @@ import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
colorMode: z.enum(["bw", "color"]).default("bw"), colorMode: z.enum(["bw", "color"]).default("bw"),
threshold: z.number().min(0).max(255).default(128), threshold: z.number().min(0).max(255).default(128),
colorPrecision: z.number().min(1).max(8).default(6), colorPrecision: z.number().min(1).max(16).default(6),
layerDifference: z.number().min(1).max(64).default(6), layerDifference: z.number().min(1).max(128).default(6),
filterSpeckle: z.number().min(1).max(128).default(4), filterSpeckle: z.number().min(1).max(256).default(4),
pathMode: z.enum(["none", "polygon", "spline"]).default("spline"), pathMode: z.enum(["none", "polygon", "spline"]).default("spline"),
cornerThreshold: z.number().min(0).max(180).default(60), cornerThreshold: z.number().min(0).max(180).default(60),
invert: z.boolean().default(false), invert: z.boolean().default(false),
+1 -1
View File
@@ -5,7 +5,7 @@ import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
text: z.string().min(1).max(500), text: z.string().min(1).max(500),
fontSize: z.number().min(8).max(200).default(48), fontSize: z.number().min(8).max(1000).default(48),
color: z color: z
.string() .string()
.regex(/^#[0-9a-fA-F]{6}$/) .regex(/^#[0-9a-fA-F]{6}$/)
+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();
@@ -27,7 +27,7 @@ interface ImageViewerProps {
imageWrapperStyle?: React.CSSProperties; imageWrapperStyle?: React.CSSProperties;
} }
const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200, 300]; const ZOOM_STEPS = [10, 25, 50, 75, 100, 150, 200, 300, 500, 1000];
const DEFAULT_ZOOM = 100; const DEFAULT_ZOOM = 100;
export function ImageViewer({ export function ImageViewer({
@@ -72,7 +72,7 @@ function scanOneFile(
formData.append("settings", JSON.stringify({ tryHarder })); formData.append("settings", JSON.stringify({ tryHarder }));
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.timeout = 60_000; xhr.timeout = 300_000;
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onUploadProgress((e.loaded / e.total) * 100); if (e.lengthComputable) onUploadProgress((e.loaded / e.total) * 100);
@@ -349,8 +349,8 @@ function CollageCell({
if (first) memo = { panX: transform.panX, panY: transform.panY }; if (first) memo = { panX: transform.panX, panY: transform.panY };
const rect = cellRef.current?.getBoundingClientRect(); const rect = cellRef.current?.getBoundingClientRect();
if (!rect || !memo) return memo; if (!rect || !memo) return memo;
const panX = Math.max(-100, Math.min(100, memo.panX + (mx / rect.width) * 100)); const panX = Math.max(-200, Math.min(200, memo.panX + (mx / rect.width) * 100));
const panY = Math.max(-100, Math.min(100, memo.panY + (my / rect.height) * 100)); const panY = Math.max(-200, Math.min(200, memo.panY + (my / rect.height) * 100));
store.setCellTransform(cellIndex, { panX, panY }); store.setCellTransform(cellIndex, { panX, panY });
return memo; return memo;
}, },
@@ -360,11 +360,11 @@ function CollageCell({
const bindPinch = usePinch( const bindPinch = usePinch(
({ offset: [scale] }) => { ({ offset: [scale] }) => {
if (!image || !isSelected) return; if (!image || !isSelected) return;
const zoom = Math.max(1, Math.min(3, scale)); const zoom = Math.max(1, Math.min(10, scale));
store.setCellTransform(cellIndex, { zoom }); store.setCellTransform(cellIndex, { zoom });
}, },
{ {
scaleBounds: { min: 1, max: 3 }, scaleBounds: { min: 1, max: 10 },
from: () => [transform.zoom, 0], from: () => [transform.zoom, 0],
}, },
); );
@@ -378,7 +378,7 @@ function CollageCell({
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
e.preventDefault(); e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1; const delta = e.deltaY > 0 ? -0.1 : 0.1;
const zoom = Math.max(1, Math.min(3, zoomRef.current + delta)); const zoom = Math.max(1, Math.min(10, zoomRef.current + delta));
store.setCellTransform(cellIndex, { zoom }); store.setCellTransform(cellIndex, { zoom });
}; };
el.addEventListener("wheel", handleWheel, { passive: false }); el.addEventListener("wheel", handleWheel, { passive: false });
@@ -555,7 +555,7 @@ function CollageCell({
<input <input
type="range" type="range"
min="1" min="1"
max="3" max="10"
step="0.1" step="0.1"
value={transform.zoom} value={transform.zoom}
onChange={handleZoomSlider} onChange={handleZoomSlider}
@@ -73,7 +73,7 @@ export function FaviconSettings() {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhrRef.current = xhr; xhrRef.current = xhr;
xhr.responseType = "blob"; xhr.responseType = "blob";
xhr.timeout = 180_000; xhr.timeout = 300_000;
xhr.upload.onprogress = (event) => { xhr.upload.onprogress = (event) => {
if (event.lengthComputable) { if (event.lengthComputable) {
@@ -167,7 +167,7 @@ export function ImageToPdfSettings() {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhrRef.current = xhr; xhrRef.current = xhr;
xhr.timeout = 180_000; xhr.timeout = 300_000;
xhr.upload.onprogress = (event) => { xhr.upload.onprogress = (event) => {
if (event.lengthComputable) { if (event.lengthComputable) {
@@ -1062,8 +1062,8 @@ export function PassportPhotoPreview() {
if (!dragStartRef.current) return; if (!dragStartRef.current) return;
const dx = (e.clientX - dragStartRef.current.x) * 0.001; const dx = (e.clientX - dragStartRef.current.x) * 0.001;
const dy = (e.clientY - dragStartRef.current.y) * 0.001; const dy = (e.clientY - dragStartRef.current.y) * 0.001;
setAdjustX(Math.max(-0.15, Math.min(0.15, dragStartRef.current.ax - dx))); setAdjustX(Math.max(-0.3, Math.min(0.3, dragStartRef.current.ax - dx)));
setAdjustY(Math.max(-0.15, Math.min(0.15, dragStartRef.current.ay - dy))); setAdjustY(Math.max(-0.3, Math.min(0.3, dragStartRef.current.ay - dy)));
} }
function handleMouseUp() { function handleMouseUp() {
@@ -1083,7 +1083,7 @@ export function PassportPhotoPreview() {
const handleWheel = useCallback( const handleWheel = useCallback(
(e: React.WheelEvent<HTMLCanvasElement>) => { (e: React.WheelEvent<HTMLCanvasElement>) => {
e.preventDefault(); e.preventDefault();
setZoom(Math.max(0.5, Math.min(3, zoom + (e.deltaY > 0 ? -0.1 : 0.1)))); setZoom(Math.max(0.5, Math.min(5, zoom + (e.deltaY > 0 ? -0.1 : 0.1))));
}, },
[zoom, setZoom], [zoom, setZoom],
); );
@@ -1138,7 +1138,7 @@ export function PassportPhotoPreview() {
</span> </span>
<button <button
type="button" type="button"
onClick={() => setZoom(Math.min(3, zoom + 0.25))} onClick={() => setZoom(Math.min(5, zoom + 0.25))}
className="p-1.5 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" className="p-1.5 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Zoom in" title="Zoom in"
> >
+2 -2
View File
@@ -86,8 +86,8 @@ export function usePipelineProcessor() {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhrRef.current = xhr; xhrRef.current = xhr;
// Pipeline runs multiple steps sequentially, allow up to 3 minutes // Pipeline runs multiple steps sequentially, allow up to 10 minutes
xhr.timeout = 180_000; xhr.timeout = 600_000;
// Pipeline is always "medium" speed: upload = 0-40%, processing = 40-95% // Pipeline is always "medium" speed: upload = 0-40%, processing = 40-95%
const UPLOAD_WEIGHT = 40; const UPLOAD_WEIGHT = 40;
+2 -2
View File
@@ -153,8 +153,8 @@ export function useToolProcessor(toolId: string) {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhrRef.current = xhr; xhrRef.current = xhr;
// Timeout: 60s for fast tools, 3 min for medium (seam carving), 5 min for AI // Timeout: 2 min for fast tools, 5 min for medium (seam carving), 10 min for AI
xhr.timeout = isAiTool ? 300_000 : isMediumTool ? 180_000 : 60_000; xhr.timeout = isAiTool ? 600_000 : isMediumTool ? 300_000 : 120_000;
// For AI tools: upload = 0-15%, processing = 15-100% (SSE-driven) // For AI tools: upload = 0-15%, processing = 15-100% (SSE-driven)
// For medium tools: upload = 0-40%, processing = 40-95% (gradual fill) // For medium tools: upload = 0-40%, processing = 40-95% (gradual fill)
+1 -1
View File
@@ -35,7 +35,7 @@ export const useFilesPageStore = create<FilesPageState>((set, get) => ({
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const { searchQuery } = get(); const { searchQuery } = get();
const result = await apiListFiles({ search: searchQuery || undefined, limit: 100 }); const result = await apiListFiles({ search: searchQuery || undefined, limit: 200 });
set({ files: result.files, total: result.total, loading: false }); set({ files: result.files, total: result.total, loading: false });
} catch (err) { } catch (err) {
set({ error: err instanceof Error ? err.message : "Failed to load files", loading: false }); set({ error: err instanceof Error ? err.message : "Failed to load files", loading: false });
+2 -2
View File
@@ -68,8 +68,8 @@ export const useSplitStore = create<SplitState>((set, get) => ({
setMode: (mode) => set({ mode, tiles: [], zipBlobUrl: null, error: null }), setMode: (mode) => set({ mode, tiles: [], zipBlobUrl: null, error: null }),
setColumns: (columns) => setColumns: (columns) =>
set({ columns: Math.max(1, Math.min(20, columns)), tiles: [], zipBlobUrl: null }), set({ columns: Math.max(1, Math.min(100, columns)), tiles: [], zipBlobUrl: null }),
setRows: (rows) => set({ rows: Math.max(1, Math.min(20, rows)), tiles: [], zipBlobUrl: null }), setRows: (rows) => set({ rows: Math.max(1, Math.min(100, rows)), tiles: [], zipBlobUrl: null }),
setTileWidth: (tileWidth) => setTileWidth: (tileWidth) =>
set({ tileWidth: Math.max(10, tileWidth), tiles: [], zipBlobUrl: null }), set({ tileWidth: Math.max(10, tileWidth), tiles: [], zipBlobUrl: null }),
setTileHeight: (tileHeight) => setTileHeight: (tileHeight) =>
+18 -7
View File
@@ -228,13 +228,24 @@ ENV PORT=1349 \
DEFAULT_THEME=light \ DEFAULT_THEME=light \
DEFAULT_LOCALE=en \ DEFAULT_LOCALE=en \
APP_NAME="ashim" \ APP_NAME="ashim" \
FILE_MAX_AGE_HOURS=24 \ FILE_MAX_AGE_HOURS=72 \
CLEANUP_INTERVAL_MINUTES=30 \ CLEANUP_INTERVAL_MINUTES=60 \
MAX_UPLOAD_SIZE_MB=100 \ MAX_UPLOAD_SIZE_MB=0 \
MAX_BATCH_SIZE=200 \ MAX_BATCH_SIZE=0 \
CONCURRENT_JOBS=3 \ CONCURRENT_JOBS=0 \
MAX_MEGAPIXELS=100 \ MAX_MEGAPIXELS=0 \
RATE_LIMIT_PER_MIN=100 \ RATE_LIMIT_PER_MIN=0 \
MAX_USERS=0 \
MAX_WORKER_THREADS=0 \
PROCESSING_TIMEOUT_S=0 \
MAX_PIPELINE_STEPS=0 \
MAX_CANVAS_PIXELS=0 \
MAX_SVG_SIZE_MB=0 \
MAX_LOGO_SIZE_KB=2048 \
MAX_SPLIT_GRID=100 \
MAX_PDF_PAGES=0 \
SESSION_DURATION_HOURS=168 \
LOGIN_ATTEMPT_LIMIT=10 \
LOG_LEVEL=debug LOG_LEVEL=debug
# NVIDIA Container Toolkit env vars (harmless on non-GPU systems) # NVIDIA Container Toolkit env vars (harmless on non-GPU systems)
+13 -3
View File
@@ -21,7 +21,16 @@ services:
- DEFAULT_USERNAME=admin - DEFAULT_USERNAME=admin
- DEFAULT_PASSWORD=admin - DEFAULT_PASSWORD=admin
- SKIP_MUST_CHANGE_PASSWORD=${SKIP_MUST_CHANGE_PASSWORD:-false} - SKIP_MUST_CHANGE_PASSWORD=${SKIP_MUST_CHANGE_PASSWORD:-false}
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-50000} - MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_SIZE_MB:-0}
- MAX_BATCH_SIZE=${MAX_BATCH_SIZE:-0}
- MAX_MEGAPIXELS=${MAX_MEGAPIXELS:-0}
- CONCURRENT_JOBS=${CONCURRENT_JOBS:-0}
- MAX_WORKER_THREADS=${MAX_WORKER_THREADS:-0}
- PROCESSING_TIMEOUT_S=${PROCESSING_TIMEOUT_S:-0}
- MAX_PIPELINE_STEPS=${MAX_PIPELINE_STEPS:-0}
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-0}
- MAX_USERS=${MAX_USERS:-0}
- SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168}
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"] test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"]
@@ -29,11 +38,12 @@ services:
timeout: 5s timeout: 5s
start_period: 60s start_period: 60s
retries: 3 retries: 3
shm_size: '2gb'
logging: logging:
driver: json-file driver: json-file
options: options:
max-size: "10m" max-size: "50m"
max-file: "3" max-file: "5"
deploy: deploy:
resources: resources:
reservations: reservations:
+13 -3
View File
@@ -22,7 +22,16 @@ services:
- DEFAULT_USERNAME=admin - DEFAULT_USERNAME=admin
- DEFAULT_PASSWORD=admin - DEFAULT_PASSWORD=admin
- SKIP_MUST_CHANGE_PASSWORD=${SKIP_MUST_CHANGE_PASSWORD:-false} - SKIP_MUST_CHANGE_PASSWORD=${SKIP_MUST_CHANGE_PASSWORD:-false}
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-50000} - MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_SIZE_MB:-0}
- MAX_BATCH_SIZE=${MAX_BATCH_SIZE:-0}
- MAX_MEGAPIXELS=${MAX_MEGAPIXELS:-0}
- CONCURRENT_JOBS=${CONCURRENT_JOBS:-0}
- MAX_WORKER_THREADS=${MAX_WORKER_THREADS:-0}
- PROCESSING_TIMEOUT_S=${PROCESSING_TIMEOUT_S:-0}
- MAX_PIPELINE_STEPS=${MAX_PIPELINE_STEPS:-0}
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-0}
- MAX_USERS=${MAX_USERS:-0}
- SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168}
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"] test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"]
@@ -30,11 +39,12 @@ services:
timeout: 5s timeout: 5s
start_period: 60s start_period: 60s
retries: 3 retries: 3
shm_size: '2gb'
logging: logging:
driver: json-file driver: json-file
options: options:
max-size: "10m" max-size: "50m"
max-file: "3" max-file: "5"
volumes: volumes:
ashim-data: ashim-data:
+18 -20
View File
@@ -50,7 +50,7 @@ def colorize_ddcolor(img_bgr, intensity):
emit_progress(15, "Loading DDColor model") emit_progress(15, "Loading DDColor model")
session = safe_onnx_session(DDCOLOR_MODEL_PATH) session, _device = safe_onnx_session(DDCOLOR_MODEL_PATH)
input_name = session.get_inputs()[0].name input_name = session.get_inputs()[0].name
input_shape = session.get_inputs()[0].shape input_shape = session.get_inputs()[0].shape
# Dynamic dims are strings ('w', 'h'), so default to 512 if not int # Dynamic dims are strings ('w', 'h'), so default to 512 if not int
@@ -181,41 +181,39 @@ def main():
result_bgr = None result_bgr = None
method = "unknown" method = "unknown"
# Try DDColor first
if model_choice in ("auto", "ddcolor"): if model_choice in ("auto", "ddcolor"):
try: try:
if os.path.exists(DDCOLOR_MODEL_PATH): if not os.path.exists(DDCOLOR_MODEL_PATH):
result_bgr, method = colorize_ddcolor(img_bgr, intensity)
elif model_choice == "ddcolor":
raise FileNotFoundError(f"DDColor model not found: {DDCOLOR_MODEL_PATH}") raise FileNotFoundError(f"DDColor model not found: {DDCOLOR_MODEL_PATH}")
result_bgr, method = colorize_ddcolor(img_bgr, intensity)
except Exception as e: except Exception as e:
import traceback import traceback
print(f"[colorize] DDColor failed: {e}", file=sys.stderr, flush=True) print(f"[colorize] DDColor failed: {e}", file=sys.stderr, flush=True)
traceback.print_exc(file=sys.stderr) traceback.print_exc(file=sys.stderr)
if model_choice == "ddcolor": print(json.dumps({
# User explicitly requested ddcolor — fail, don't degrade "success": False,
raise "error": (
result_bgr = None f"DDColor is not available: {e}. "
"Install the colorize feature or use model=opencv for basic colorization."
),
}))
sys.exit(1)
# Try OpenCV fallback only in auto mode elif model_choice == "opencv":
if result_bgr is None and model_choice in ("auto", "opencv"):
try: try:
if os.path.exists(OPENCV_PROTO_PATH) and os.path.exists(OPENCV_MODEL_PATH): if not (os.path.exists(OPENCV_PROTO_PATH) and os.path.exists(OPENCV_MODEL_PATH)):
result_bgr, method = colorize_opencv(img_bgr, intensity)
elif model_choice == "opencv":
raise FileNotFoundError(f"OpenCV colorize models not found: {OPENCV_PROTO_PATH}") raise FileNotFoundError(f"OpenCV colorize models not found: {OPENCV_PROTO_PATH}")
result_bgr, method = colorize_opencv(img_bgr, intensity)
except Exception as e: except Exception as e:
import traceback import traceback
print(f"[colorize] OpenCV fallback failed: {e}", file=sys.stderr, flush=True) print(f"[colorize] OpenCV failed: {e}", file=sys.stderr, flush=True)
traceback.print_exc(file=sys.stderr) traceback.print_exc(file=sys.stderr)
if model_choice == "opencv": raise
raise
result_bgr = None
if result_bgr is None: else:
print(json.dumps({ print(json.dumps({
"success": False, "success": False,
"error": "No colorization model available. Install DDColor or OpenCV models.", "error": f"Unknown model '{model_choice}'. Use 'auto', 'ddcolor', or 'opencv'.",
})) }))
sys.exit(1) sys.exit(1)
+24 -6
View File
@@ -2,6 +2,25 @@
import sys import sys
import json import json
import os import os
import types
# basicsr imports torchvision.transforms.functional_tensor which was removed
# in torchvision >= 0.17. This shim must exist before basicsr is imported.
try:
import torchvision.transforms.functional_tensor # noqa: F401
except (ImportError, ModuleNotFoundError):
try:
import torchvision.transforms.functional as _F
import torchvision.transforms
_shim = types.ModuleType("torchvision.transforms.functional_tensor")
for _attr in dir(_F):
if not _attr.startswith("_"):
setattr(_shim, _attr, getattr(_F, _attr))
sys.modules["torchvision.transforms.functional_tensor"] = _shim
torchvision.transforms.functional_tensor = _shim
except ImportError:
pass
def emit_progress(percent, stage): def emit_progress(percent, stage):
@@ -270,19 +289,18 @@ def main():
model_used = "codeformer" model_used = "codeformer"
elif model_choice == "auto": elif model_choice == "auto":
# Try CodeFormer first, fall back to GFPGAN.
# Catch broad Exception because codeformer-pip can fail in
# unexpected ways (AttributeError, TypeError, etc.)
try: try:
fidelity_weight = 1.0 - strength fidelity_weight = 1.0 - strength
enhanced = enhance_with_codeformer(img_array, fidelity_weight) enhanced = enhance_with_codeformer(img_array, fidelity_weight)
model_used = "codeformer" model_used = "codeformer"
except Exception as e: except Exception as e:
import traceback import traceback
print(f"[enhance-faces] CodeFormer failed, falling back to GFPGAN: {e}", file=sys.stderr, flush=True) print(f"[enhance-faces] CodeFormer failed: {e}", file=sys.stderr, flush=True)
traceback.print_exc(file=sys.stderr) traceback.print_exc(file=sys.stderr)
enhanced = enhance_with_gfpgan(img_array, only_center_face) raise RuntimeError(
model_used = "gfpgan" f"CodeFormer is not available: {e}. "
"Install the face-enhance feature or use model=gfpgan."
) from e
finally: finally:
# Restore stdout after ALL AI processing # Restore stdout after ALL AI processing
+8 -8
View File
@@ -54,14 +54,14 @@ def extract_key_points(lms):
# ── Old API: mp.solutions (mediapipe < 0.10.30) ─────────────────── # ── Old API: mp.solutions (mediapipe < 0.10.30) ───────────────────
def detect_with_solutions(img_array): def detect_with_solutions(img_array, max_faces=1):
"""Use the legacy mp.solutions.face_mesh API.""" """Use the legacy mp.solutions.face_mesh API."""
import mediapipe as mp import mediapipe as mp
mp_face_mesh = mp.solutions.face_mesh mp_face_mesh = mp.solutions.face_mesh
face_mesh = mp_face_mesh.FaceMesh( face_mesh = mp_face_mesh.FaceMesh(
static_image_mode=True, static_image_mode=True,
max_num_faces=1, max_num_faces=max_faces,
refine_landmarks=True, refine_landmarks=True,
min_detection_confidence=0.5, min_detection_confidence=0.5,
) )
@@ -99,7 +99,7 @@ def ensure_model():
return MODEL_PATH return MODEL_PATH
def detect_with_tasks(img_path): def detect_with_tasks(img_path, max_faces=1):
"""Use the new mp.tasks.vision.FaceLandmarker API.""" """Use the new mp.tasks.vision.FaceLandmarker API."""
import mediapipe as mp import mediapipe as mp
@@ -108,7 +108,7 @@ def detect_with_tasks(img_path):
options = mp.tasks.vision.FaceLandmarkerOptions( options = mp.tasks.vision.FaceLandmarkerOptions(
base_options=mp.tasks.BaseOptions(model_asset_path=model_path), base_options=mp.tasks.BaseOptions(model_asset_path=model_path),
running_mode=mp.tasks.vision.RunningMode.IMAGE, running_mode=mp.tasks.vision.RunningMode.IMAGE,
num_faces=1, num_faces=max_faces,
min_face_detection_confidence=0.5, min_face_detection_confidence=0.5,
output_face_blendshapes=False, output_face_blendshapes=False,
output_facial_transformation_matrixes=False, output_facial_transformation_matrixes=False,
@@ -133,6 +133,8 @@ def main():
output_path = sys.argv[2] # unused but kept for bridge.ts compatibility output_path = sys.argv[2] # unused but kept for bridge.ts compatibility
settings = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {} settings = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {}
max_faces = settings.get("max_num_faces", 1)
try: try:
emit_progress(10, "Loading image") emit_progress(10, "Loading image")
from PIL import Image from PIL import Image
@@ -146,16 +148,14 @@ def main():
emit_progress(20, "Initializing face mesh") emit_progress(20, "Initializing face mesh")
# Try the legacy solutions API first (Docker / older mediapipe),
# fall back to the tasks API (newer mediapipe versions).
landmarks_list = None landmarks_list = None
try: try:
img_array = np.array(img) img_array = np.array(img)
emit_progress(30, "Detecting face landmarks") emit_progress(30, "Detecting face landmarks")
landmarks_list = detect_with_solutions(img_array) landmarks_list = detect_with_solutions(img_array, max_faces)
except AttributeError: except AttributeError:
emit_progress(30, "Detecting face landmarks") emit_progress(30, "Detecting face landmarks")
landmarks_list = detect_with_tasks(input_path) landmarks_list = detect_with_tasks(input_path, max_faces)
if landmarks_list is None: if landmarks_list is None:
print(json.dumps({ print(json.dumps({
+25 -9
View File
@@ -1,10 +1,16 @@
"""Runtime GPU/CUDA detection utility.""" """Runtime GPU/CUDA detection utility."""
import functools import functools
import json
import os import os
import subprocess import subprocess
import sys import sys
def emit_info(msg):
"""Emit an informational JSON message to stderr for the bridge to capture."""
print(json.dumps({"info": msg}), file=sys.stderr, flush=True)
@functools.lru_cache(maxsize=1) @functools.lru_cache(maxsize=1)
def gpu_available(): def gpu_available():
"""Return True if a usable CUDA GPU is present at runtime.""" """Return True if a usable CUDA GPU is present at runtime."""
@@ -51,24 +57,34 @@ def gpu_available():
def onnx_providers(): def onnx_providers():
"""Return ONNX Runtime execution providers in priority order.""" """Return (providers, device) tuple.
providers: ONNX Runtime execution providers in priority order.
device: "cuda" or "cpu" reflects which hardware will actually be used.
"""
if gpu_available(): if gpu_available():
return ["CUDAExecutionProvider", "CPUExecutionProvider"] return (["CUDAExecutionProvider", "CPUExecutionProvider"], "cuda")
return ["CPUExecutionProvider"] emit_info("No GPU detected, processing on CPU")
return (["CPUExecutionProvider"], "cpu")
def safe_onnx_session(model_path, providers=None): def safe_onnx_session(model_path, providers=None):
"""Create an ONNX Runtime InferenceSession with graceful CUDA EP fallback.""" """Create an ONNX Runtime InferenceSession with graceful CUDA EP fallback.
Returns (session, device) where device is "cuda" or "cpu".
"""
import onnxruntime as ort import onnxruntime as ort
device = "cpu"
if providers is None: if providers is None:
providers = onnx_providers() providers, device = onnx_providers()
try: try:
return ort.InferenceSession(model_path, providers=providers) session = ort.InferenceSession(model_path, providers=providers)
return session, device
except Exception as e: except Exception as e:
if "CUDAExecutionProvider" in providers: if "CUDAExecutionProvider" in providers:
print(f"[gpu] CUDA EP init failed ({e}), falling back to CPU", emit_info(f"CUDA init failed ({e}), falling back to CPU")
file=sys.stderr, flush=True) session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
return ort.InferenceSession(model_path, providers=["CPUExecutionProvider"]) return session, "cpu"
raise raise
+1 -1
View File
@@ -111,7 +111,7 @@ def main():
model_path = _get_model_path() model_path = _get_model_path()
from gpu import safe_onnx_session from gpu import safe_onnx_session
session = safe_onnx_session(model_path) session, _device = safe_onnx_session(model_path)
emit_progress(20, "Loading images") emit_progress(20, "Loading images")
img = Image.open(input_path).convert("RGB") img = Image.open(input_path).convert("RGB")
+28 -36
View File
@@ -256,19 +256,23 @@ def main():
text = run_paddleocr_v5(input_path, language) text = run_paddleocr_v5(input_path, language)
engine_used = "paddleocr-v5" engine_used = "paddleocr-v5"
except ImportError as e: except ImportError as e:
print(json.dumps({"success": False, "error": f"PaddleOCR is not installed: {e}"})) print(json.dumps({
"success": False,
"error": (
f"PaddleOCR is not installed: {e}. "
"Install the OCR feature or use quality=fast for Tesseract."
),
}))
sys.exit(1) sys.exit(1)
except Exception as e: except Exception as e:
print(json.dumps({ print(json.dumps({
"warning": f"PaddleOCR PP-OCRv5 failed ({type(e).__name__}: {e}), falling back to Tesseract" "success": False,
}), file=sys.stderr, flush=True) "error": (
emit_progress(25, "PaddleOCR failed, falling back to Tesseract") f"PaddleOCR PP-OCRv5 failed: {type(e).__name__}: {e}. "
try: "Install the OCR feature or use quality=fast for Tesseract."
text = run_tesseract(input_path, language, is_auto=was_auto) ),
engine_used = "tesseract (fallback from balanced)" }))
except FileNotFoundError: sys.exit(1)
print(json.dumps({"success": False, "error": "OCR engines unavailable: PaddleOCR failed and Tesseract is not installed"}))
sys.exit(1)
elif quality == "best": elif quality == "best":
try: try:
@@ -276,34 +280,22 @@ def main():
engine_used = "paddleocr-vl" engine_used = "paddleocr-vl"
except ImportError as e: except ImportError as e:
print(json.dumps({ print(json.dumps({
"warning": f"PaddleOCR-VL not available ({e}), trying PP-OCRv5" "success": False,
}), file=sys.stderr, flush=True) "error": (
emit_progress(20, "VL model unavailable, trying PP-OCRv5") f"PaddleOCR-VL is not available: {e}. "
try: "Install the OCR feature or use quality=balanced for PP-OCRv5."
text = run_paddleocr_v5(input_path, language) ),
engine_used = "paddleocr-v5 (fallback from best)" }))
except Exception as e2: sys.exit(1)
print(json.dumps({
"warning": f"PP-OCRv5 also failed ({type(e2).__name__}: {e2}), falling back to Tesseract"
}), file=sys.stderr, flush=True)
emit_progress(25, "PP-OCRv5 failed, falling back to Tesseract")
text = run_tesseract(input_path, language, is_auto=was_auto)
engine_used = "tesseract (fallback from best)"
except Exception as e: except Exception as e:
print(json.dumps({ print(json.dumps({
"warning": f"PaddleOCR-VL failed ({type(e).__name__}: {e}), trying PP-OCRv5" "success": False,
}), file=sys.stderr, flush=True) "error": (
emit_progress(20, "VL model failed, trying PP-OCRv5") f"PaddleOCR-VL failed: {type(e).__name__}: {e}. "
try: "Install the OCR feature or use quality=balanced for PP-OCRv5."
text = run_paddleocr_v5(input_path, language) ),
engine_used = "paddleocr-v5 (fallback from best)" }))
except Exception as e2: sys.exit(1)
print(json.dumps({
"warning": f"PP-OCRv5 also failed ({type(e2).__name__}: {e2}), falling back to Tesseract"
}), file=sys.stderr, flush=True)
emit_progress(25, "PP-OCRv5 failed, falling back to Tesseract")
text = run_tesseract(input_path, language, is_auto=was_auto)
engine_used = "tesseract (fallback from best)"
else: else:
print(json.dumps({"success": False, "error": f"Unknown quality: {quality}"})) print(json.dumps({"success": False, "error": f"Unknown quality: {quality}"}))
+3 -3
View File
@@ -32,7 +32,7 @@ def _ensure_face_mesh_model():
return _LOCAL_MODEL_PATH return _LOCAL_MODEL_PATH
def _mesh_with_solutions(img_array, max_faces=10, min_confidence=0.5): def _mesh_with_solutions(img_array, max_faces=50, min_confidence=0.5):
"""FaceMesh using legacy mp.solutions API (mediapipe < 0.10.30). """FaceMesh using legacy mp.solutions API (mediapipe < 0.10.30).
Returns list of landmark lists. Each landmark has .x, .y attributes. Returns list of landmark lists. Each landmark has .x, .y attributes.
@@ -54,7 +54,7 @@ def _mesh_with_solutions(img_array, max_faces=10, min_confidence=0.5):
return [face.landmark for face in results.multi_face_landmarks] return [face.landmark for face in results.multi_face_landmarks]
def _mesh_with_tasks(img_array, max_faces=10, min_confidence=0.5): def _mesh_with_tasks(img_array, max_faces=50, min_confidence=0.5):
"""FaceMesh using new mp.tasks API (mediapipe >= 0.10.30). """FaceMesh using new mp.tasks API (mediapipe >= 0.10.30).
Returns list of landmark lists. Each landmark has .x, .y attributes. Returns list of landmark lists. Each landmark has .x, .y attributes.
@@ -79,7 +79,7 @@ def _mesh_with_tasks(img_array, max_faces=10, min_confidence=0.5):
return result.face_landmarks return result.face_landmarks
def _detect_face_mesh(img_array, max_faces=10, min_confidence=0.5): def _detect_face_mesh(img_array, max_faces=50, min_confidence=0.5):
"""Detect face mesh, trying legacy API first then falling back to tasks API.""" """Detect face mesh, trying legacy API first then falling back to tasks API."""
try: try:
return _mesh_with_solutions(img_array, max_faces, min_confidence) return _mesh_with_solutions(img_array, max_faces, min_confidence)
+8 -7
View File
@@ -76,14 +76,15 @@ def main():
emit_progress(10, "Loading model") emit_progress(10, "Loading model")
providers = onnx_providers() providers, device = onnx_providers()
try: try:
session = new_session(model, providers=providers) session = new_session(model, providers=providers)
except Exception as e: except Exception as e:
if "CUDAExecutionProvider" in providers: if "CUDAExecutionProvider" in providers:
print(f"[remove-bg] GPU session failed ({e}), falling back to CPU", from gpu import emit_info
file=sys.stderr, flush=True) emit_info(f"GPU session failed ({e}), falling back to CPU")
session = new_session(model, providers=["CPUExecutionProvider"]) session = new_session(model, providers=["CPUExecutionProvider"])
device = "cpu"
else: else:
raise raise
@@ -92,7 +93,6 @@ def main():
with open(input_path, "rb") as f: with open(input_path, "rb") as f:
input_data = f.read() input_data = f.read()
# Try with alpha matting for better edges, fall back without
emit_progress(30, "Analyzing image") emit_progress(30, "Analyzing image")
try: try:
output_data = remove( output_data = remove(
@@ -103,8 +103,9 @@ def main():
alpha_matting_background_threshold=10, alpha_matting_background_threshold=10,
) )
except Exception as e: except Exception as e:
print(f"[remove-bg] Alpha matting failed ({e}), using standard removal", file=sys.stderr, flush=True) raise RuntimeError(
output_data = remove(input_data, session=session) f"Alpha matting failed: {e}. Try again without alpha matting or with a different model."
) from e
emit_progress(80, "Background removed") emit_progress(80, "Background removed")
@@ -115,7 +116,7 @@ def main():
with open(output_path, "wb") as f: with open(output_path, "wb") as f:
f.write(output_data) f.write(output_data)
result = json.dumps({"success": True, "model": model}) result = json.dumps({"success": True, "model": model, "device": device})
except ImportError as e: except ImportError as e:
print(f"[remove-bg] Import failed: {e}", file=sys.stderr, flush=True) print(f"[remove-bg] Import failed: {e}", file=sys.stderr, flush=True)
+8 -5
View File
@@ -155,7 +155,7 @@ def inpaint_damage(img_bgr, mask):
from gpu import safe_onnx_session from gpu import safe_onnx_session
model_path = _get_lama_path() model_path = _get_lama_path()
session = safe_onnx_session(model_path) session, _device = safe_onnx_session(model_path)
orig_h, orig_w = img_bgr.shape[:2] orig_h, orig_w = img_bgr.shape[:2]
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
@@ -317,7 +317,7 @@ def enhance_faces(img_bgr, fidelity=0.7):
# Load CodeFormer model # Load CodeFormer model
model_path = _get_codeformer_path() model_path = _get_codeformer_path()
session = safe_onnx_session(model_path) session, _device = safe_onnx_session(model_path)
input_names = [inp.name for inp in session.get_inputs()] input_names = [inp.name for inp in session.get_inputs()]
result = img_bgr.copy() result = img_bgr.copy()
@@ -329,8 +329,7 @@ def enhance_faces(img_bgr, fidelity=0.7):
w = face_box["w"] w = face_box["w"]
h = face_box["h"] h = face_box["h"]
# Skip very small faces (under 48px) - enhancement won't help if w < 24 or h < 24:
if w < 48 or h < 48:
continue continue
# Expand bounding box by ~80% for hair, forehead, chin # Expand bounding box by ~80% for hair, forehead, chin
@@ -473,7 +472,7 @@ def colorize_bw(img_bgr, intensity=0.85):
if not os.path.exists(DDCOLOR_MODEL_PATH): if not os.path.exists(DDCOLOR_MODEL_PATH):
return img_bgr, False return img_bgr, False
session = safe_onnx_session(DDCOLOR_MODEL_PATH) session, _device = safe_onnx_session(DDCOLOR_MODEL_PATH)
input_name = session.get_inputs()[0].name input_name = session.get_inputs()[0].name
input_shape = session.get_inputs()[0].shape input_shape = session.get_inputs()[0].shape
model_size = ( model_size = (
@@ -545,6 +544,9 @@ def main():
scratch_sensitivity = "medium" scratch_sensitivity = "medium"
try: try:
from gpu import gpu_available
device = "cuda" if gpu_available() else "cpu"
emit_progress(5, "Opening image") emit_progress(5, "Opening image")
img_bgr = cv2.imread(input_path, cv2.IMREAD_COLOR) img_bgr = cv2.imread(input_path, cv2.IMREAD_COLOR)
if img_bgr is None: if img_bgr is None:
@@ -633,6 +635,7 @@ def main():
"facesEnhanced": faces_found, "facesEnhanced": faces_found,
"isGrayscale": bw_detected, "isGrayscale": bw_detected,
"colorized": colorized, "colorized": colorized,
"device": device,
"output_path": output_path, "output_path": output_path,
})) }))
+53 -32
View File
@@ -1,7 +1,26 @@
"""Image upscaling with Real-ESRGAN fallback to Lanczos.""" """Image upscaling with Real-ESRGAN."""
import sys import sys
import json import json
import os import os
import types
# basicsr imports torchvision.transforms.functional_tensor which was removed
# in torchvision >= 0.17. This shim must exist before basicsr is imported.
try:
import torchvision.transforms.functional_tensor # noqa: F401
except (ImportError, ModuleNotFoundError):
try:
import torchvision.transforms.functional as _F
import torchvision.transforms
_shim = types.ModuleType("torchvision.transforms.functional_tensor")
for _attr in dir(_F):
if not _attr.startswith("_"):
setattr(_shim, _attr, getattr(_F, _attr))
sys.modules["torchvision.transforms.functional_tensor"] = _shim
torchvision.transforms.functional_tensor = _shim
except ImportError:
pass
def emit_progress(percent, stage): def emit_progress(percent, stage):
@@ -126,32 +145,30 @@ def main():
result = Image.fromarray(output_array) result = Image.fromarray(output_array)
method = "realesrgan" method = "realesrgan"
# Face enhancement with GFPGAN
if face_enhance: if face_enhance:
emit_progress(82, "Enhancing faces") emit_progress(82, "Enhancing faces")
try: from gfpgan import GFPGANer
from gfpgan import GFPGANer
if os.path.exists(GFPGAN_MODEL_PATH): if not os.path.exists(GFPGAN_MODEL_PATH):
face_enhancer = GFPGANer( raise FileNotFoundError(
model_path=GFPGAN_MODEL_PATH, f"GFPGAN model not found at {GFPGAN_MODEL_PATH}. "
upscale=scale, "Install the upscale-enhance feature or disable faceEnhance."
arch="clean", )
channel_multiplier=2, face_enhancer = GFPGANer(
bg_upsampler=upsampler, model_path=GFPGAN_MODEL_PATH,
) upscale=scale,
_, _, face_output = face_enhancer.enhance( arch="clean",
img_array, channel_multiplier=2,
has_aligned=False, bg_upsampler=upsampler,
only_center_face=False, )
paste_back=True, _, _, face_output = face_enhancer.enhance(
) img_array,
result = Image.fromarray(face_output) has_aligned=False,
emit_progress(88, "Face enhancement complete") only_center_face=False,
else: paste_back=True,
emit_progress(88, "Face model not found, skipping") )
except (ImportError, RuntimeError, OSError): result = Image.fromarray(face_output)
emit_progress(88, "Face enhancement unavailable, skipping") emit_progress(88, "Face enhancement complete")
finally: finally:
# Restore stdout after ALL AI processing # Restore stdout after ALL AI processing
@@ -164,19 +181,23 @@ def main():
import traceback import traceback
print(f"[upscale] Real-ESRGAN failed: {e}", file=sys.stderr, flush=True) print(f"[upscale] Real-ESRGAN failed: {e}", file=sys.stderr, flush=True)
traceback.print_exc(file=sys.stderr) traceback.print_exc(file=sys.stderr)
if model_choice == "realesrgan": print(json.dumps({
# User explicitly requested realesrgan — fail, don't degrade "success": False,
raise RuntimeError(f"Real-ESRGAN unavailable: {e}") from e "error": (
result = None f"Real-ESRGAN is not available: {e}. "
"Install the upscale-enhance feature or use model=lanczos for basic upscaling."
),
}))
sys.exit(1)
# Lanczos path: used when explicitly requested or as auto fallback if result is None and model_choice == "lanczos":
if result is None:
if model_choice not in ("auto", "lanczos"):
raise RuntimeError(f"Requested model '{model_choice}' is not available")
emit_progress(50, "Upscaling with Lanczos") emit_progress(50, "Upscaling with Lanczos")
result = img.resize(new_size, Image.LANCZOS) result = img.resize(new_size, Image.LANCZOS)
method = "lanczos" method = "lanczos"
if result is None:
raise RuntimeError(f"Requested model '{model_choice}' is not available")
# Denoise # Denoise
if denoise_strength > 0: if denoise_strength > 0:
emit_progress(90, "Reducing noise") emit_progress(90, "Reducing noise")
+5 -2
View File
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { readFile, unlink, writeFile } from "node:fs/promises"; import { readFile, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import sharp from "sharp";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js"; import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
export interface RemoveBackgroundOptions { export interface RemoveBackgroundOptions {
@@ -21,8 +22,10 @@ export async function removeBackground(
await writeFile(inputPath, inputBuffer); await writeFile(inputPath, inputBuffer);
try { try {
// BiRefNet models need longer timeout (up to 10 min for first load) const meta = await sharp(inputBuffer).metadata();
const timeout = options.model?.startsWith("birefnet") ? 600000 : 300000; const megapixels = ((meta.width ?? 0) * (meta.height ?? 0)) / 1_000_000;
const baseTimeout = options.model?.startsWith("birefnet") ? 600000 : 300000;
const timeout = Math.max(baseTimeout, megapixels * 30 * 1000);
const { stdout } = await runPythonWithProgress( const { stdout } = await runPythonWithProgress(
"remove_bg.py", "remove_bg.py",
[inputPath, outputPath, JSON.stringify(options)], [inputPath, outputPath, JSON.stringify(options)],
+14 -6
View File
@@ -218,7 +218,11 @@ function dispatcherRun(
if (!proc || !proc.stdin || !dispatcherReady) return null; if (!proc || !proc.stdin || !dispatcherReady) return null;
const id = randomUUID(); const id = randomUUID();
const timeout = options.timeout ?? 300000; const timeout =
options.timeout ??
(process.env.PROCESSING_TIMEOUT_S && parseInt(process.env.PROCESSING_TIMEOUT_S, 10) > 0
? parseInt(process.env.PROCESSING_TIMEOUT_S, 10) * 1000
: 600000);
return new Promise((resolvePromise, rejectPromise) => { return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -278,7 +282,11 @@ function runPythonPerRequest(
} = {}, } = {},
): Promise<{ stdout: string; stderr: string }> { ): Promise<{ stdout: string; stderr: string }> {
const scriptPath = resolve(PYTHON_DIR, scriptName); const scriptPath = resolve(PYTHON_DIR, scriptName);
const timeout = options.timeout ?? 300000; const timeout =
options.timeout ??
(process.env.PROCESSING_TIMEOUT_S && parseInt(process.env.PROCESSING_TIMEOUT_S, 10) > 0
? parseInt(process.env.PROCESSING_TIMEOUT_S, 10) * 1000
: 600000);
return new Promise((resolvePromise, rejectPromise) => { return new Promise((resolvePromise, rejectPromise) => {
const trySpawn = (pythonBin: string, isFallback: boolean) => { const trySpawn = (pythonBin: string, isFallback: boolean) => {
@@ -390,14 +398,14 @@ export function runPythonWithProgress(
const dispatcherPromise = dispatcherRun(scriptName, args, options); const dispatcherPromise = dispatcherRun(scriptName, args, options);
if (dispatcherPromise) { if (dispatcherPromise) {
return dispatcherPromise.catch((err: Error) => { return dispatcherPromise.catch((err: Error) => {
// Dispatcher crashed mid-request (e.g. OOM when loading a large model).
// Retry in an isolated per-request process which starts clean and has
// more available memory than the warm dispatcher.
if (err.message === "Python dispatcher exited unexpectedly") { if (err.message === "Python dispatcher exited unexpectedly") {
console.warn( console.warn(
`[bridge] Dispatcher crashed during ${scriptName}, retrying with per-request process`, `[bridge] Dispatcher crashed during ${scriptName}, retrying with per-request process`,
); );
return runPythonPerRequest(scriptName, args, options); return runPythonPerRequest(scriptName, args, options).then((result) => ({
...result,
stderr: `${result.stderr}\n[bridge] retried after dispatcher crash`,
}));
} }
throw err; throw err;
}); });
+5 -1
View File
@@ -31,9 +31,13 @@ export async function extractText(
const pngBuffer = await sharp(inputBuffer).png().toBuffer(); const pngBuffer = await sharp(inputBuffer).png().toBuffer();
await writeFile(inputPath, pngBuffer); await writeFile(inputPath, pngBuffer);
const meta = await sharp(inputBuffer).metadata();
const megapixels = ((meta.width ?? 0) * (meta.height ?? 0)) / 1_000_000;
const timeout = Math.max(600_000, megapixels * 30 * 1000);
const { stdout } = await runPythonWithProgress("ocr.py", [inputPath, JSON.stringify(options)], { const { stdout } = await runPythonWithProgress("ocr.py", [inputPath, JSON.stringify(options)], {
onProgress, onProgress,
timeout: 600_000, // 10 min timeout for VLM on CPU timeout,
}); });
const result = parseStdoutJson(stdout); const result = parseStdoutJson(stdout);
+10 -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,9 @@ 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));
await execFileAsync(cairePath, args, { timeout: 120_000 }); const megapixels = (width * height) / 1_000_000;
const timeoutMs = Math.max(120_000, megapixels * 10 * 1000);
await execFileAsync(cairePath, args, { timeout: timeoutMs });
const buffer = await readFile(outputPath); const buffer = await readFile(outputPath);
const outMeta = await sharp(buffer).metadata(); const outMeta = await sharp(buffer).metadata();
+18
View File
@@ -241,6 +241,24 @@ export const en = {
logoRequirements: "PNG, SVG, or JPEG. Max 500KB.", logoRequirements: "PNG, SVG, or JPEG. Max 500KB.",
dragDrop: "Drag and drop or click to upload", dragDrop: "Drag and drop or click to upload",
}, },
limitsAndResources: "Limits & Resources",
maxFileSize: "Max File Size",
maxBatchSize: "Max Batch Size",
concurrentJobs: "Concurrent Jobs",
workerThreads: "Worker Threads",
maxPipelineSteps: "Max Pipeline Steps",
processingTimeout: "Processing Timeout",
rateLimitPerMin: "Rate Limit (req/min)",
unlimited: "Unlimited",
auto: "Auto",
disabled: "Disabled",
envOverride: "Set by environment variable",
maxCanvasPixels: "Max Canvas Pixels",
maxSvgSize: "Max SVG Size",
maxLogoSize: "Max Logo Size",
maxSplitGrid: "Max Split Grid",
maxPdfPages: "Max PDF Pages",
sessionDuration: "Session Duration",
}, },
auth: { auth: {
login: "Login", login: "Login",