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)
# In dev, the API auto-starts on an internal port; you always access localhost:1349
# Server
PORT=1349
AUTH_ENABLED=true
DEFAULT_USERNAME=admin
DEFAULT_PASSWORD=admin
STORAGE_MODE=local
FILE_MAX_AGE_HOURS=24
CLEANUP_INTERVAL_MINUTES=30
MAX_UPLOAD_SIZE_MB=100
MAX_BATCH_SIZE=200
CONCURRENT_JOBS=3
MAX_MEGAPIXELS=100
RATE_LIMIT_PER_MIN=100
# Cleanup
FILE_MAX_AGE_HOURS=72
CLEANUP_INTERVAL_MINUTES=60
# Upload & Batch (0 = unlimited)
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
# SKIP_MUST_CHANGE_PASSWORD=false
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.
// busy_timeout must be set first so journal_mode = WAL can retry
// 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("synchronous = NORMAL");
sqlite.pragma("foreign_keys = ON");
+10 -8
View File
@@ -41,7 +41,8 @@ recoverInterruptedInstalls();
const app = Fastify({
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) => {
@@ -79,12 +80,13 @@ app.addHook("onSend", async (_request, reply) => {
}
});
await app.register(rateLimit, {
max: env.RATE_LIMIT_PER_MIN,
timeWindow: "1 minute",
// Only rate-limit API endpoints — static files and the SPA fallback must never be throttled
allowList: (request) => !request.url.startsWith("/api/"),
});
if (env.RATE_LIMIT_PER_MIN > 0) {
await app.register(rateLimit, {
max: env.RATE_LIMIT_PER_MIN,
timeWindow: "1 minute",
allowList: (request) => !request.url.startsWith("/api/"),
});
}
// Multipart upload support
await registerUpload(app);
@@ -195,7 +197,7 @@ try {
}
// Graceful shutdown
const SHUTDOWN_TIMEOUT_MS = 8000;
const SHUTDOWN_TIMEOUT_MS = 30000;
let shuttingDown = false;
async function shutdown(signal: string) {
if (shuttingDown) return;
+29 -8
View File
@@ -1,3 +1,4 @@
import { availableParallelism } from "node:os";
import { z } from "zod";
const envSchema = z.object({
@@ -13,13 +14,13 @@ const envSchema = z.object({
.default("false")
.transform((v) => v === "true"),
STORAGE_MODE: z.enum(["local", "s3"]).default("local"),
FILE_MAX_AGE_HOURS: z.coerce.number().default(24),
CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(30),
MAX_UPLOAD_SIZE_MB: z.coerce.number().default(100),
MAX_BATCH_SIZE: z.coerce.number().default(200),
CONCURRENT_JOBS: z.coerce.number().default(3),
MAX_MEGAPIXELS: z.coerce.number().default(100),
RATE_LIMIT_PER_MIN: z.coerce.number().default(100),
FILE_MAX_AGE_HOURS: z.coerce.number().default(72),
CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(60),
MAX_UPLOAD_SIZE_MB: z.coerce.number().default(0),
MAX_BATCH_SIZE: z.coerce.number().default(0),
CONCURRENT_JOBS: z.coerce.number().default(0),
MAX_MEGAPIXELS: z.coerce.number().default(0),
RATE_LIMIT_PER_MIN: z.coerce.number().default(0),
DB_PATH: z.string().default("./data/ashim.db"),
FILES_STORAGE_PATH: z.string().default("./data/files"),
WORKSPACE_PATH: z.string().default("./tmp/workspace"),
@@ -27,8 +28,18 @@ const envSchema = z.object({
DEFAULT_LOCALE: z.string().default("en"),
APP_NAME: z.string().default("ashim"),
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"),
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>;
@@ -36,3 +47,13 @@ export type Env = z.infer<typeof envSchema>;
export function loadEnv(): 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 {
await writeFile(tempPath, buffer);
const { stdout } = await execFileAsync(bin, ["-json", "-G", "-struct", "-n", tempPath], {
timeout: 30_000,
timeout: 60_000,
maxBuffer: 10 * 1024 * 1024,
});
@@ -126,7 +126,7 @@ export async function writeMetadata(
try {
await writeFile(tempPath, buffer);
await execFileAsync(bin, ["-overwrite_original", ...tags, tempPath], {
timeout: 30_000,
timeout: 60_000,
maxBuffer: 10 * 1024 * 1024,
});
return await readFile(tempPath);
+1 -1
View File
@@ -90,7 +90,7 @@ export async function validateImageBuffer(
const height = metadata.height ?? 0;
const megapixels = (width * height) / 1_000_000;
if (megapixels > env.MAX_MEGAPIXELS) {
if (env.MAX_MEGAPIXELS > 0 && megapixels > env.MAX_MEGAPIXELS) {
return {
valid: false,
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 {
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.
try {
@@ -94,7 +94,7 @@ export async function encodeHeic(buffer: Buffer, quality = 80): Promise<Buffer>
try {
await writeFile(inputPath, buffer);
await execFileAsync("heif-enc", ["-q", String(quality), "-o", outputPath, inputPath], {
timeout: 30_000,
timeout: 120_000,
});
return await readFile(outputPath);
} 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.
* Throws if the SVG exceeds the maximum allowed size.
*/
export function sanitizeSvg(buffer: Buffer): Buffer {
if (buffer.length > MAX_SVG_SIZE) {
throw new Error(`SVG exceeds maximum size of ${MAX_SVG_SIZE / 1024 / 1024}MB`);
const maxSvgSize = env.MAX_SVG_SIZE_MB > 0 ? env.MAX_SVG_SIZE_MB * 1024 * 1024 : Infinity;
if (buffer.length > maxSvgSize) {
throw new Error(`SVG exceeds maximum size of ${env.MAX_SVG_SIZE_MB}MB`);
}
let svg = buffer.toString("utf-8");
// 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
* HTTP request handling, SSE streams, or health checks.
*/
import { availableParallelism } from "node:os";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import Piscina from "piscina";
import { loadEnv, resolveWorkerThreads } from "./env.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
// Size the pool: leave 1 thread for the event loop, min 1 worker
const maxThreads = Math.max(1, Math.min(availableParallelism() - 1, 4));
const maxThreads = resolveWorkerThreads(loadEnv());
let pool: Piscina | null = null;
+2 -5
View File
@@ -98,7 +98,7 @@ export function requireAdmin(request: FastifyRequest, reply: FastifyReply): Auth
// ── 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 {
return randomUUID();
@@ -137,10 +137,7 @@ export async function ensureDefaultAdmin(): Promise<void> {
// ── Login attempt limit ──────────────────────────────────────────
const DEFAULT_LOGIN_ATTEMPT_LIMIT = 10;
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;
const row = db
.select()
@@ -151,7 +148,7 @@ function getLoginAttemptLimit(): number {
const parsed = parseInt(row.value, 10);
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
}
return DEFAULT_LOGIN_ATTEMPT_LIMIT;
return env.LOGIN_ATTEMPT_LIMIT;
}
// ── Auth routes ────────────────────────────────────────────────────
+2 -2
View File
@@ -5,8 +5,8 @@ import { env } from "../config.js";
export async function registerUpload(app: FastifyInstance): Promise<void> {
await app.register(multipart, {
limits: {
fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024,
files: env.MAX_BATCH_SIZE,
fileSize: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : undefined,
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 { env } from "../config.js";
import { autoOrient } from "../lib/auto-orient.js";
import { resolveConcurrency } from "../lib/env.js";
import { formatZodErrors } from "../lib/errors.js";
import { isToolInstalled } from "../lib/feature-status.js";
import { validateImageBuffer } from "../lib/file-validation.js";
@@ -90,7 +91,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
}
// 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({
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 });
// 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.
// 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 type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { ensureSharpCompat } from "../lib/heic-converter.js";
import { requireAdmin } from "../plugins/auth.js";
const BRANDING_DIR = join(process.cwd(), "data", "branding");
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 {
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();
// Validate size
if (buffer.length > MAX_LOGO_SIZE) {
return reply
.status(400)
.send({ error: "Logo must be 500KB or smaller", code: "VALIDATION_ERROR" });
if (buffer.length > maxLogoSize) {
return reply.status(400).send({
error: `Logo must be ${env.MAX_LOGO_SIZE_KB}KB or smaller`,
code: "VALIDATION_ERROR",
});
}
// 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 { db, schema } from "../db/index.js";
import { autoOrient } from "../lib/auto-orient.js";
import { resolveConcurrency } from "../lib/env.js";
import { formatZodErrors } from "../lib/errors.js";
import { isToolInstalled } from "../lib/feature-status.js";
import { validateImageBuffer } from "../lib/file-validation.js";
@@ -39,17 +40,21 @@ const pipelineDefinitionSchema = z.object({
steps: z
.array(pipelineStepSchema)
.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. */
const savePipelineSchema = z.object({
name: z.string().min(1, "Pipeline name is required").max(100),
description: z.string().max(500).optional(),
name: z.string().min(1, "Pipeline name is required").max(255),
description: z.string().max(2000).optional(),
steps: z
.array(pipelineStepSchema)
.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> {
@@ -424,7 +429,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
}
// 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({
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 });
// ── 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(
null,
+5 -1
View File
@@ -15,6 +15,7 @@ import { sanitizeFilename } from "../lib/filename.js";
import { decodeHeic } from "../lib/heic-converter.js";
import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js";
import { sanitizeSvg } from "../lib/svg-sanitize.js";
import { computeTimeout } from "../lib/timeout.js";
import { getWorkerPool } from "../lib/worker-pool.js";
import { createWorkspace } from "../lib/workspace.js";
@@ -227,8 +228,11 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
filename,
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, {
signal: AbortSignal.timeout(30_000),
signal: AbortSignal.timeout(timeoutMs),
});
result = {
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 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"),
padding: z.number().min(0).max(200).default(0),
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),
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),
shadowOffsetY: z.number().min(-50).max(50).default(5),
shadowColor: hexColor.default("#000000"),
+1 -1
View File
@@ -6,7 +6,7 @@ import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
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),
});
+3 -3
View File
@@ -321,15 +321,15 @@ const cellSchema = z.object({
imageIndex: z.number().int().min(0),
panX: 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"),
});
const settingsSchema = z.object({
templateId: z.string(),
cells: z.array(cellSchema).optional(),
gap: z.number().min(0).max(50).default(8),
cornerRadius: z.number().min(0).max(30).default(0),
gap: z.number().min(0).max(500).default(8),
cornerRadius: z.number().min(0).max(500).default(0),
backgroundColor: z.string().default("#FFFFFF"),
aspectRatio: z.string().default("free"),
outputFormat: z.enum(["png", "jpeg", "webp"]).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"),
// Resize
width: z.number().min(1).max(4096).optional(),
height: z.number().min(1).max(4096).optional(),
width: z.number().min(1).max(16384).optional(),
height: z.number().min(1).max(16384).optional(),
percentage: z.number().min(1).max(500).optional(),
// Optimize
+1 -1
View File
@@ -13,7 +13,7 @@ import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
pageSize: z.enum(["A4", "Letter", "A3", "A5"]).default("A4"),
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]> = {
+1 -1
View File
@@ -36,7 +36,7 @@ const generateSettingsSchema = z.object({
bgColor: z.string().default("#FFFFFF"),
printLayout: z.string().default("none"),
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(),
customHeightMm: z.number().optional(),
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 sharp from "sharp";
import { z } from "zod";
import { env } from "../../config.js";
import { formatZodErrors } from "../../lib/errors.js";
import { encodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -14,7 +15,7 @@ import { createWorkspace } from "../../lib/workspace.js";
// ── Settings schema ──────────────────────────────────────────────
const settingsSchema = z.object({
format: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif"]).default("png"),
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),
colorMode: z.enum(["color", "grayscale", "bw"]).default("color"),
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" });
}
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<{
page: number;
dataUrl: string;
+1 -1
View File
@@ -9,7 +9,7 @@ import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
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"),
foreground: z
.string()
+1 -1
View File
@@ -15,7 +15,7 @@ const settingsSchema = z.object({
y2: z.number().min(0).max(50).default(12),
y3: z.number().min(0).max(50).default(20),
// 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),
threshold: z.number().min(0).max(255).default(0),
// High-Pass
+4 -4
View File
@@ -9,8 +9,8 @@ import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
columns: z.number().min(1).max(20).default(3),
rows: z.number().min(1).max(20).default(3),
columns: z.number().min(1).max(100).default(3),
rows: z.number().min(1).max(100).default(3),
tileWidth: z.number().min(10).optional(),
tileHeight: z.number().min(10).optional(),
outputFormat: z.enum(["original", "png", "jpg", "webp"]).default("original"),
@@ -89,8 +89,8 @@ export function registerSplit(app: FastifyInstance) {
cols = Math.max(1, Math.ceil(fullW / settings.tileWidth));
rows = Math.max(1, Math.ceil(fullH / settings.tileHeight));
}
cols = Math.min(cols, 20);
rows = Math.min(rows, 20);
cols = Math.min(cols, 100);
rows = Math.min(rows, 100);
const cellW = Math.floor(fullW / cols);
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 sharp from "sharp";
import { z } from "zod";
import { env } from "../../config.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
const MAX_CANVAS_PIXELS = 100_000_000;
const settingsSchema = z.object({
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"),
alignment: z.enum(["start", "center", "end"]).default("center"),
gap: z.number().min(0).max(200).default(0),
border: z.number().min(0).max(50).default(0),
cornerRadius: z.number().min(0).max(50).default(0),
gap: z.number().min(0).max(1000).default(0),
border: z.number().min(0).max(500).default(0),
cornerRadius: z.number().min(0).max(500).default(0),
backgroundColor: z
.string()
.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({
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 { z } from "zod";
import { env } from "../../config.js";
import { resolveConcurrency } from "../../lib/env.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.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 settingsSchema = z.object({
width: z.number().min(1).max(16384).optional(),
height: z.number().min(1).max(16384).optional(),
dpi: z.number().min(36).max(1200).default(300),
width: z.number().min(1).max(65536).optional(),
height: z.number().min(1).max(65536).optional(),
dpi: z.number().min(36).max(2400).default(300),
quality: z.number().min(1).max(100).default(90),
backgroundColor: z
.string()
@@ -135,7 +136,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
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({
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 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(
null,
);
+3 -3
View File
@@ -14,9 +14,9 @@ import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
colorMode: z.enum(["bw", "color"]).default("bw"),
threshold: z.number().min(0).max(255).default(128),
colorPrecision: z.number().min(1).max(8).default(6),
layerDifference: z.number().min(1).max(64).default(6),
filterSpeckle: z.number().min(1).max(128).default(4),
colorPrecision: z.number().min(1).max(16).default(6),
layerDifference: z.number().min(1).max(128).default(6),
filterSpeckle: z.number().min(1).max(256).default(4),
pathMode: z.enum(["none", "polygon", "spline"]).default("spline"),
cornerThreshold: z.number().min(0).max(180).default(60),
invert: z.boolean().default(false),
+1 -1
View File
@@ -5,7 +5,7 @@ import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
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
.string()
.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);
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 search = request.query.search?.trim();
@@ -27,7 +27,7 @@ interface ImageViewerProps {
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;
export function ImageViewer({
@@ -72,7 +72,7 @@ function scanOneFile(
formData.append("settings", JSON.stringify({ tryHarder }));
const xhr = new XMLHttpRequest();
xhr.timeout = 60_000;
xhr.timeout = 300_000;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onUploadProgress((e.loaded / e.total) * 100);
@@ -349,8 +349,8 @@ function CollageCell({
if (first) memo = { panX: transform.panX, panY: transform.panY };
const rect = cellRef.current?.getBoundingClientRect();
if (!rect || !memo) return memo;
const panX = Math.max(-100, Math.min(100, memo.panX + (mx / rect.width) * 100));
const panY = Math.max(-100, Math.min(100, memo.panY + (my / rect.height) * 100));
const panX = Math.max(-200, Math.min(200, memo.panX + (mx / rect.width) * 100));
const panY = Math.max(-200, Math.min(200, memo.panY + (my / rect.height) * 100));
store.setCellTransform(cellIndex, { panX, panY });
return memo;
},
@@ -360,11 +360,11 @@ function CollageCell({
const bindPinch = usePinch(
({ offset: [scale] }) => {
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 });
},
{
scaleBounds: { min: 1, max: 3 },
scaleBounds: { min: 1, max: 10 },
from: () => [transform.zoom, 0],
},
);
@@ -378,7 +378,7 @@ function CollageCell({
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
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 });
};
el.addEventListener("wheel", handleWheel, { passive: false });
@@ -555,7 +555,7 @@ function CollageCell({
<input
type="range"
min="1"
max="3"
max="10"
step="0.1"
value={transform.zoom}
onChange={handleZoomSlider}
@@ -73,7 +73,7 @@ export function FaviconSettings() {
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
xhr.responseType = "blob";
xhr.timeout = 180_000;
xhr.timeout = 300_000;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
@@ -167,7 +167,7 @@ export function ImageToPdfSettings() {
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
xhr.timeout = 180_000;
xhr.timeout = 300_000;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
@@ -1062,8 +1062,8 @@ export function PassportPhotoPreview() {
if (!dragStartRef.current) return;
const dx = (e.clientX - dragStartRef.current.x) * 0.001;
const dy = (e.clientY - dragStartRef.current.y) * 0.001;
setAdjustX(Math.max(-0.15, Math.min(0.15, dragStartRef.current.ax - dx)));
setAdjustY(Math.max(-0.15, Math.min(0.15, dragStartRef.current.ay - dy)));
setAdjustX(Math.max(-0.3, Math.min(0.3, dragStartRef.current.ax - dx)));
setAdjustY(Math.max(-0.3, Math.min(0.3, dragStartRef.current.ay - dy)));
}
function handleMouseUp() {
@@ -1083,7 +1083,7 @@ export function PassportPhotoPreview() {
const handleWheel = useCallback(
(e: React.WheelEvent<HTMLCanvasElement>) => {
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],
);
@@ -1138,7 +1138,7 @@ export function PassportPhotoPreview() {
</span>
<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"
title="Zoom in"
>
+2 -2
View File
@@ -86,8 +86,8 @@ export function usePipelineProcessor() {
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
// Pipeline runs multiple steps sequentially, allow up to 3 minutes
xhr.timeout = 180_000;
// Pipeline runs multiple steps sequentially, allow up to 10 minutes
xhr.timeout = 600_000;
// Pipeline is always "medium" speed: upload = 0-40%, processing = 40-95%
const UPLOAD_WEIGHT = 40;
+2 -2
View File
@@ -153,8 +153,8 @@ export function useToolProcessor(toolId: string) {
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
// Timeout: 60s for fast tools, 3 min for medium (seam carving), 5 min for AI
xhr.timeout = isAiTool ? 300_000 : isMediumTool ? 180_000 : 60_000;
// Timeout: 2 min for fast tools, 5 min for medium (seam carving), 10 min for AI
xhr.timeout = isAiTool ? 600_000 : isMediumTool ? 300_000 : 120_000;
// For AI tools: upload = 0-15%, processing = 15-100% (SSE-driven)
// 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 });
try {
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 });
} catch (err) {
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 }),
setColumns: (columns) =>
set({ columns: Math.max(1, Math.min(20, columns)), tiles: [], zipBlobUrl: null }),
setRows: (rows) => set({ rows: Math.max(1, Math.min(20, rows)), tiles: [], zipBlobUrl: null }),
set({ columns: Math.max(1, Math.min(100, columns)), tiles: [], zipBlobUrl: null }),
setRows: (rows) => set({ rows: Math.max(1, Math.min(100, rows)), tiles: [], zipBlobUrl: null }),
setTileWidth: (tileWidth) =>
set({ tileWidth: Math.max(10, tileWidth), tiles: [], zipBlobUrl: null }),
setTileHeight: (tileHeight) =>
+18 -7
View File
@@ -228,13 +228,24 @@ ENV PORT=1349 \
DEFAULT_THEME=light \
DEFAULT_LOCALE=en \
APP_NAME="ashim" \
FILE_MAX_AGE_HOURS=24 \
CLEANUP_INTERVAL_MINUTES=30 \
MAX_UPLOAD_SIZE_MB=100 \
MAX_BATCH_SIZE=200 \
CONCURRENT_JOBS=3 \
MAX_MEGAPIXELS=100 \
RATE_LIMIT_PER_MIN=100 \
FILE_MAX_AGE_HOURS=72 \
CLEANUP_INTERVAL_MINUTES=60 \
MAX_UPLOAD_SIZE_MB=0 \
MAX_BATCH_SIZE=0 \
CONCURRENT_JOBS=0 \
MAX_MEGAPIXELS=0 \
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
# NVIDIA Container Toolkit env vars (harmless on non-GPU systems)
+13 -3
View File
@@ -21,7 +21,16 @@ services:
- DEFAULT_USERNAME=admin
- DEFAULT_PASSWORD=admin
- 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
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"]
@@ -29,11 +38,12 @@ services:
timeout: 5s
start_period: 60s
retries: 3
shm_size: '2gb'
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
max-size: "50m"
max-file: "5"
deploy:
resources:
reservations:
+13 -3
View File
@@ -22,7 +22,16 @@ services:
- DEFAULT_USERNAME=admin
- DEFAULT_PASSWORD=admin
- 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
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"]
@@ -30,11 +39,12 @@ services:
timeout: 5s
start_period: 60s
retries: 3
shm_size: '2gb'
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
max-size: "50m"
max-file: "5"
volumes:
ashim-data:
+18 -20
View File
@@ -50,7 +50,7 @@ def colorize_ddcolor(img_bgr, intensity):
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_shape = session.get_inputs()[0].shape
# Dynamic dims are strings ('w', 'h'), so default to 512 if not int
@@ -181,41 +181,39 @@ def main():
result_bgr = None
method = "unknown"
# Try DDColor first
if model_choice in ("auto", "ddcolor"):
try:
if os.path.exists(DDCOLOR_MODEL_PATH):
result_bgr, method = colorize_ddcolor(img_bgr, intensity)
elif model_choice == "ddcolor":
if not os.path.exists(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:
import traceback
print(f"[colorize] DDColor failed: {e}", file=sys.stderr, flush=True)
traceback.print_exc(file=sys.stderr)
if model_choice == "ddcolor":
# User explicitly requested ddcolor — fail, don't degrade
raise
result_bgr = None
print(json.dumps({
"success": False,
"error": (
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
if result_bgr is None and model_choice in ("auto", "opencv"):
elif model_choice == "opencv":
try:
if 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":
if not (os.path.exists(OPENCV_PROTO_PATH) and os.path.exists(OPENCV_MODEL_PATH)):
raise FileNotFoundError(f"OpenCV colorize models not found: {OPENCV_PROTO_PATH}")
result_bgr, method = colorize_opencv(img_bgr, intensity)
except Exception as e:
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)
if model_choice == "opencv":
raise
result_bgr = None
raise
if result_bgr is None:
else:
print(json.dumps({
"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)
+24 -6
View File
@@ -2,6 +2,25 @@
import sys
import json
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):
@@ -270,19 +289,18 @@ def main():
model_used = "codeformer"
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:
fidelity_weight = 1.0 - strength
enhanced = enhance_with_codeformer(img_array, fidelity_weight)
model_used = "codeformer"
except Exception as e:
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)
enhanced = enhance_with_gfpgan(img_array, only_center_face)
model_used = "gfpgan"
raise RuntimeError(
f"CodeFormer is not available: {e}. "
"Install the face-enhance feature or use model=gfpgan."
) from e
finally:
# 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) ───────────────────
def detect_with_solutions(img_array):
def detect_with_solutions(img_array, max_faces=1):
"""Use the legacy mp.solutions.face_mesh API."""
import mediapipe as mp
mp_face_mesh = mp.solutions.face_mesh
face_mesh = mp_face_mesh.FaceMesh(
static_image_mode=True,
max_num_faces=1,
max_num_faces=max_faces,
refine_landmarks=True,
min_detection_confidence=0.5,
)
@@ -99,7 +99,7 @@ def ensure_model():
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."""
import mediapipe as mp
@@ -108,7 +108,7 @@ def detect_with_tasks(img_path):
options = mp.tasks.vision.FaceLandmarkerOptions(
base_options=mp.tasks.BaseOptions(model_asset_path=model_path),
running_mode=mp.tasks.vision.RunningMode.IMAGE,
num_faces=1,
num_faces=max_faces,
min_face_detection_confidence=0.5,
output_face_blendshapes=False,
output_facial_transformation_matrixes=False,
@@ -133,6 +133,8 @@ def main():
output_path = sys.argv[2] # unused but kept for bridge.ts compatibility
settings = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {}
max_faces = settings.get("max_num_faces", 1)
try:
emit_progress(10, "Loading image")
from PIL import Image
@@ -146,16 +148,14 @@ def main():
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
try:
img_array = np.array(img)
emit_progress(30, "Detecting face landmarks")
landmarks_list = detect_with_solutions(img_array)
landmarks_list = detect_with_solutions(img_array, max_faces)
except AttributeError:
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:
print(json.dumps({
+25 -9
View File
@@ -1,10 +1,16 @@
"""Runtime GPU/CUDA detection utility."""
import functools
import json
import os
import subprocess
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)
def gpu_available():
"""Return True if a usable CUDA GPU is present at runtime."""
@@ -51,24 +57,34 @@ def gpu_available():
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():
return ["CUDAExecutionProvider", "CPUExecutionProvider"]
return ["CPUExecutionProvider"]
return (["CUDAExecutionProvider", "CPUExecutionProvider"], "cuda")
emit_info("No GPU detected, processing on CPU")
return (["CPUExecutionProvider"], "cpu")
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
device = "cpu"
if providers is None:
providers = onnx_providers()
providers, device = onnx_providers()
try:
return ort.InferenceSession(model_path, providers=providers)
session = ort.InferenceSession(model_path, providers=providers)
return session, device
except Exception as e:
if "CUDAExecutionProvider" in providers:
print(f"[gpu] CUDA EP init failed ({e}), falling back to CPU",
file=sys.stderr, flush=True)
return ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
emit_info(f"CUDA init failed ({e}), falling back to CPU")
session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
return session, "cpu"
raise
+1 -1
View File
@@ -111,7 +111,7 @@ def main():
model_path = _get_model_path()
from gpu import safe_onnx_session
session = safe_onnx_session(model_path)
session, _device = safe_onnx_session(model_path)
emit_progress(20, "Loading images")
img = Image.open(input_path).convert("RGB")
+28 -36
View File
@@ -256,19 +256,23 @@ def main():
text = run_paddleocr_v5(input_path, language)
engine_used = "paddleocr-v5"
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)
except Exception as e:
print(json.dumps({
"warning": f"PaddleOCR PP-OCRv5 failed ({type(e).__name__}: {e}), falling back to Tesseract"
}), file=sys.stderr, flush=True)
emit_progress(25, "PaddleOCR failed, falling back to Tesseract")
try:
text = run_tesseract(input_path, language, is_auto=was_auto)
engine_used = "tesseract (fallback from balanced)"
except FileNotFoundError:
print(json.dumps({"success": False, "error": "OCR engines unavailable: PaddleOCR failed and Tesseract is not installed"}))
sys.exit(1)
"success": False,
"error": (
f"PaddleOCR PP-OCRv5 failed: {type(e).__name__}: {e}. "
"Install the OCR feature or use quality=fast for Tesseract."
),
}))
sys.exit(1)
elif quality == "best":
try:
@@ -276,34 +280,22 @@ def main():
engine_used = "paddleocr-vl"
except ImportError as e:
print(json.dumps({
"warning": f"PaddleOCR-VL not available ({e}), trying PP-OCRv5"
}), file=sys.stderr, flush=True)
emit_progress(20, "VL model unavailable, trying PP-OCRv5")
try:
text = run_paddleocr_v5(input_path, language)
engine_used = "paddleocr-v5 (fallback from best)"
except Exception as e2:
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)"
"success": False,
"error": (
f"PaddleOCR-VL is not available: {e}. "
"Install the OCR feature or use quality=balanced for PP-OCRv5."
),
}))
sys.exit(1)
except Exception as e:
print(json.dumps({
"warning": f"PaddleOCR-VL failed ({type(e).__name__}: {e}), trying PP-OCRv5"
}), file=sys.stderr, flush=True)
emit_progress(20, "VL model failed, trying PP-OCRv5")
try:
text = run_paddleocr_v5(input_path, language)
engine_used = "paddleocr-v5 (fallback from best)"
except Exception as e2:
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)"
"success": False,
"error": (
f"PaddleOCR-VL failed: {type(e).__name__}: {e}. "
"Install the OCR feature or use quality=balanced for PP-OCRv5."
),
}))
sys.exit(1)
else:
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
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).
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]
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).
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
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."""
try:
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")
providers = onnx_providers()
providers, device = onnx_providers()
try:
session = new_session(model, providers=providers)
except Exception as e:
if "CUDAExecutionProvider" in providers:
print(f"[remove-bg] GPU session failed ({e}), falling back to CPU",
file=sys.stderr, flush=True)
from gpu import emit_info
emit_info(f"GPU session failed ({e}), falling back to CPU")
session = new_session(model, providers=["CPUExecutionProvider"])
device = "cpu"
else:
raise
@@ -92,7 +93,6 @@ def main():
with open(input_path, "rb") as f:
input_data = f.read()
# Try with alpha matting for better edges, fall back without
emit_progress(30, "Analyzing image")
try:
output_data = remove(
@@ -103,8 +103,9 @@ def main():
alpha_matting_background_threshold=10,
)
except Exception as e:
print(f"[remove-bg] Alpha matting failed ({e}), using standard removal", file=sys.stderr, flush=True)
output_data = remove(input_data, session=session)
raise RuntimeError(
f"Alpha matting failed: {e}. Try again without alpha matting or with a different model."
) from e
emit_progress(80, "Background removed")
@@ -115,7 +116,7 @@ def main():
with open(output_path, "wb") as f:
f.write(output_data)
result = json.dumps({"success": True, "model": model})
result = json.dumps({"success": True, "model": model, "device": device})
except ImportError as e:
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
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]
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
@@ -317,7 +317,7 @@ def enhance_faces(img_bgr, fidelity=0.7):
# Load CodeFormer model
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()]
result = img_bgr.copy()
@@ -329,8 +329,7 @@ def enhance_faces(img_bgr, fidelity=0.7):
w = face_box["w"]
h = face_box["h"]
# Skip very small faces (under 48px) - enhancement won't help
if w < 48 or h < 48:
if w < 24 or h < 24:
continue
# 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):
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_shape = session.get_inputs()[0].shape
model_size = (
@@ -545,6 +544,9 @@ def main():
scratch_sensitivity = "medium"
try:
from gpu import gpu_available
device = "cuda" if gpu_available() else "cpu"
emit_progress(5, "Opening image")
img_bgr = cv2.imread(input_path, cv2.IMREAD_COLOR)
if img_bgr is None:
@@ -633,6 +635,7 @@ def main():
"facesEnhanced": faces_found,
"isGrayscale": bw_detected,
"colorized": colorized,
"device": device,
"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 json
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):
@@ -126,32 +145,30 @@ def main():
result = Image.fromarray(output_array)
method = "realesrgan"
# Face enhancement with GFPGAN
if face_enhance:
emit_progress(82, "Enhancing faces")
try:
from gfpgan import GFPGANer
from gfpgan import GFPGANer
if os.path.exists(GFPGAN_MODEL_PATH):
face_enhancer = GFPGANer(
model_path=GFPGAN_MODEL_PATH,
upscale=scale,
arch="clean",
channel_multiplier=2,
bg_upsampler=upsampler,
)
_, _, face_output = face_enhancer.enhance(
img_array,
has_aligned=False,
only_center_face=False,
paste_back=True,
)
result = Image.fromarray(face_output)
emit_progress(88, "Face enhancement complete")
else:
emit_progress(88, "Face model not found, skipping")
except (ImportError, RuntimeError, OSError):
emit_progress(88, "Face enhancement unavailable, skipping")
if not os.path.exists(GFPGAN_MODEL_PATH):
raise FileNotFoundError(
f"GFPGAN model not found at {GFPGAN_MODEL_PATH}. "
"Install the upscale-enhance feature or disable faceEnhance."
)
face_enhancer = GFPGANer(
model_path=GFPGAN_MODEL_PATH,
upscale=scale,
arch="clean",
channel_multiplier=2,
bg_upsampler=upsampler,
)
_, _, face_output = face_enhancer.enhance(
img_array,
has_aligned=False,
only_center_face=False,
paste_back=True,
)
result = Image.fromarray(face_output)
emit_progress(88, "Face enhancement complete")
finally:
# Restore stdout after ALL AI processing
@@ -164,19 +181,23 @@ def main():
import traceback
print(f"[upscale] Real-ESRGAN failed: {e}", file=sys.stderr, flush=True)
traceback.print_exc(file=sys.stderr)
if model_choice == "realesrgan":
# User explicitly requested realesrgan — fail, don't degrade
raise RuntimeError(f"Real-ESRGAN unavailable: {e}") from e
result = None
print(json.dumps({
"success": False,
"error": (
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:
if model_choice not in ("auto", "lanczos"):
raise RuntimeError(f"Requested model '{model_choice}' is not available")
if result is None and model_choice == "lanczos":
emit_progress(50, "Upscaling with Lanczos")
result = img.resize(new_size, Image.LANCZOS)
method = "lanczos"
if result is None:
raise RuntimeError(f"Requested model '{model_choice}' is not available")
# Denoise
if denoise_strength > 0:
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 { tmpdir } from "node:os";
import { join } from "node:path";
import sharp from "sharp";
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
export interface RemoveBackgroundOptions {
@@ -21,8 +22,10 @@ export async function removeBackground(
await writeFile(inputPath, inputBuffer);
try {
// BiRefNet models need longer timeout (up to 10 min for first load)
const timeout = options.model?.startsWith("birefnet") ? 600000 : 300000;
const meta = await sharp(inputBuffer).metadata();
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(
"remove_bg.py",
[inputPath, outputPath, JSON.stringify(options)],
+14 -6
View File
@@ -218,7 +218,11 @@ function dispatcherRun(
if (!proc || !proc.stdin || !dispatcherReady) return null;
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) => {
const timer = setTimeout(() => {
@@ -278,7 +282,11 @@ function runPythonPerRequest(
} = {},
): Promise<{ stdout: string; stderr: string }> {
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) => {
const trySpawn = (pythonBin: string, isFallback: boolean) => {
@@ -390,14 +398,14 @@ export function runPythonWithProgress(
const dispatcherPromise = dispatcherRun(scriptName, args, options);
if (dispatcherPromise) {
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") {
console.warn(
`[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;
});
+5 -1
View File
@@ -31,9 +31,13 @@ export async function extractText(
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
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)], {
onProgress,
timeout: 600_000, // 10 min timeout for VLM on CPU
timeout,
});
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).
* Supports both shrinking and enlarging via seam removal/insertion.
*
* Large images (>1200px longest edge) are downscaled first because
* 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.
* Processes at native resolution -- JPEG intermediate is used because
* Go's JPEG decoder is significantly faster than PNG for large images.
*/
export async function seamCarve(
inputBuffer: Buffer,
@@ -66,35 +60,18 @@ export async function seamCarve(
): Promise<SeamCarveResult> {
const cairePath = await findCaire();
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 outputPath = join(outputDir, `caire-out-${id}.png`);
try {
// Downscale large images and convert to JPEG for fast caire processing
const meta = await sharp(inputBuffer).metadata();
const origWidth = meta.width ?? 0;
const origHeight = meta.height ?? 0;
const longest = Math.max(origWidth, origHeight);
const width = meta.width ?? 0;
const height = meta.height ?? 0;
let width = origWidth;
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();
const processBuffer = await sharp(inputBuffer).jpeg({ quality: 95 }).toBuffer();
await writeFile(inputPath, processBuffer);
// Build caire arguments
const args = ["-in", inputPath, "-out", outputPath, "-preview=false"];
if (options.square) {
@@ -102,19 +79,10 @@ export async function seamCarve(
args.push("-square", "-width", String(shortest), "-height", String(shortest));
} else {
if (options.width) {
// Scale user-specified dimensions proportionally if image was downscaled
const targetW =
longest > MAX_CAIRE_DIMENSION
? Math.round(options.width * (MAX_CAIRE_DIMENSION / longest))
: options.width;
args.push("-width", String(targetW));
args.push("-width", String(options.width));
}
if (options.height) {
const targetH =
longest > MAX_CAIRE_DIMENSION
? Math.round(options.height * (MAX_CAIRE_DIMENSION / longest))
: options.height;
args.push("-height", String(targetH));
args.push("-height", String(options.height));
}
}
@@ -122,7 +90,9 @@ export async function seamCarve(
if (options.blurRadius !== undefined) args.push("-blur", String(options.blurRadius));
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 outMeta = await sharp(buffer).metadata();
+18
View File
@@ -241,6 +241,24 @@ export const en = {
logoRequirements: "PNG, SVG, or JPEG. Max 500KB.",
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: {
login: "Login",