mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge pull request #80 from ashim-hq/feat/unlimited-by-default
feat: Unlimited by Default — remove all artificial limits
This commit is contained in:
+30
-9
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]> = {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}$/)
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}"}))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user