mirror of
https://github.com/ashim-hq/ashim
synced 2026-04-21 13:37:52 +00:00
fix: verbose errors, batch processing, multi-file support (#1)
- Eliminate [object Object] errors across all 20+ API routes - Global Fastify error handler with full stack traces - Image-to-PDF auth fix (Object.entries → headers.forEach) - OCR verbose fallbacks with engine reporting - Split multi-file with per-image subfolders in ZIP - Batch support for blur-faces, strip-metadata, edit-metadata, vectorize - Docker LOG_LEVEL=debug, PYTHONWARNINGS=default - 20 Playwright e2e tests pass against Docker container
This commit is contained in:
commit
8b87cf888c
40 changed files with 1371 additions and 163 deletions
|
|
@ -38,6 +38,18 @@ const app = Fastify({
|
|||
bodyLimit: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024,
|
||||
});
|
||||
|
||||
app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => {
|
||||
const statusCode = error.statusCode ?? 500;
|
||||
request.log.error(
|
||||
{ err: error, url: request.url, method: request.method },
|
||||
"Unhandled request error",
|
||||
);
|
||||
reply.status(statusCode).send({
|
||||
error: statusCode >= 500 ? "Internal server error" : error.message,
|
||||
details: error.stack ?? error.message,
|
||||
});
|
||||
});
|
||||
|
||||
// Plugins
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN
|
||||
|
|
|
|||
7
apps/api/src/lib/errors.ts
Normal file
7
apps/api/src/lib/errors.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { ZodIssue } from "zod";
|
||||
|
||||
export function formatZodErrors(issues: ZodIssue[]): string {
|
||||
return issues
|
||||
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
|
||||
.join("; ");
|
||||
}
|
||||
|
|
@ -13,6 +13,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 { formatZodErrors } from "../lib/errors.js";
|
||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../lib/filename.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
|
|
@ -88,12 +89,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
|||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: result.error.issues.map(
|
||||
(i: { path: (string | number)[]; message: string }) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
}),
|
||||
),
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
settings = result.data;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,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 { formatZodErrors } from "../lib/errors.js";
|
||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../lib/filename.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
|
|
@ -130,10 +131,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid pipeline definition",
|
||||
details: result.error.issues.map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
})),
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
pipeline = result.data;
|
||||
|
|
@ -431,10 +429,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid pipeline definition",
|
||||
details: result.error.issues.map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
})),
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
pipeline = result.data;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import sharp from "sharp";
|
|||
import type { z } from "zod";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { autoOrient } from "../lib/auto-orient.js";
|
||||
import { formatZodErrors } from "../lib/errors.js";
|
||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../lib/filename.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
|
|
@ -185,10 +186,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: result.error.issues.map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
})),
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
settings = result.data;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { basename, extname } from "node:path";
|
|||
import archiver from "archiver";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
pattern: z.string().min(1).max(200).default("image-{{index}}"),
|
||||
|
|
@ -53,7 +54,9 @@ export function registerBulkRename(app: FastifyInstance) {
|
|||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
|
|||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
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";
|
||||
|
|
@ -467,7 +468,9 @@ export function registerCollage(app: FastifyInstance) {
|
|||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
|
|||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { sanitizeFilename } from "../../lib/filename.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
|
@ -74,7 +75,9 @@ export function registerCompose(app: FastifyInstance) {
|
|||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { seamCarve } from "@ashim/ai";
|
|||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
|
@ -82,10 +83,7 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
|||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: result.error.issues.map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
})),
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
settings = result.data;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { basename, join } from "node:path";
|
|||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import {
|
||||
buildTagArgs,
|
||||
type EditMetadataSettings,
|
||||
|
|
@ -150,10 +151,7 @@ export function registerEditMetadata(app: FastifyInstance) {
|
|||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: result.error.issues.map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
})),
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
settings = result.data;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import PDFDocument from "pdfkit";
|
|||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
|
|
@ -62,7 +63,9 @@ export function registerImageToPdf(app: FastifyInstance) {
|
|||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { basename } from "node:path";
|
|||
import { extractText } from "@ashim/ai";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
import { updateSingleFileProgress } from "../progress.js";
|
||||
|
|
@ -66,7 +67,7 @@ export function registerOcr(app: FastifyInstance) {
|
|||
if (!result.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: result.error.issues });
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
|
|
@ -135,10 +136,18 @@ export function registerOcr(app: FastifyInstance) {
|
|||
});
|
||||
}
|
||||
|
||||
if (result.engine && result.engine !== tier) {
|
||||
request.log.warn(
|
||||
{ toolId: "ocr", requested: tier, actual: result.engine },
|
||||
`OCR engine fallback: requested ${tier} but used ${result.engine}`,
|
||||
);
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
filename,
|
||||
text: result.text,
|
||||
engine: result.engine,
|
||||
});
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../../lib/filename.js";
|
||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||
|
|
@ -117,10 +118,7 @@ export function registerOptimizeForWeb(app: FastifyInstance) {
|
|||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: result.error.issues.map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
})),
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
settings = result.data;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
|
||||
|
|
@ -275,15 +276,14 @@ export function registerPassportPhoto(app: FastifyInstance) {
|
|||
app.post(
|
||||
"/api/v1/tools/passport-photo/generate",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let parsed: z.infer<typeof generateSettingsSchema>;
|
||||
try {
|
||||
parsed = generateSettingsSchema.parse(request.body);
|
||||
} catch (err) {
|
||||
const parseResult = generateSettingsSchema.safeParse(request.body);
|
||||
if (!parseResult.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
details: formatZodErrors(parseResult.error.issues),
|
||||
});
|
||||
}
|
||||
const parsed = parseResult.data;
|
||||
|
||||
const {
|
||||
jobId,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { FastifyInstance } from "fastify";
|
|||
import * as mupdf from "mupdf";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { encodeHeic } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
|
|
@ -290,7 +291,9 @@ export function registerPdfToImage(app: FastifyInstance) {
|
|||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { join } from "node:path";
|
|||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import QRCode from "qrcode";
|
||||
import { z } from "zod";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
|
|
@ -37,10 +38,7 @@ export function registerQrGenerate(app: FastifyInstance) {
|
|||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: result.error.issues.map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
})),
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
|
|||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
|
|
@ -67,7 +68,9 @@ export function registerSplit(app: FastifyInstance) {
|
|||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
|
|||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
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";
|
||||
|
|
@ -91,7 +92,9 @@ export function registerStitch(app: FastifyInstance) {
|
|||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import PQueue from "p-queue";
|
|||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { env } from "../../config.js";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { sanitizeFilename } from "../../lib/filename.js";
|
||||
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
||||
import { isSvgBuffer, sanitizeSvg } from "../../lib/svg-sanitize.js";
|
||||
|
|
@ -147,7 +148,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
|||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
|
||||
details: formatZodErrors(result.error.issues),
|
||||
});
|
||||
}
|
||||
settings = result.data;
|
||||
|
|
@ -365,7 +366,9 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
|||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import potrace from "potrace";
|
|||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
|
|
@ -82,7 +83,9 @@ export function registerVectorize(app: FastifyInstance) {
|
|||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
|
|||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
|
|
@ -56,7 +57,9 @@ export function registerWatermarkImage(app: FastifyInstance) {
|
|||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -84,12 +84,24 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
|
|||
|
||||
export function BlurFacesSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("blur-faces");
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("blur-faces");
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, settings);
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
|
|
|||
|
|
@ -133,8 +133,16 @@ function LabeledInput({
|
|||
|
||||
export function EditMetadataSettings() {
|
||||
const { entries, selectedIndex, files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("edit-metadata");
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("edit-metadata");
|
||||
|
||||
const [form, setForm] = useState<FormFields>(EMPTY_FORM);
|
||||
const [initialForm, setInitialForm] = useState<FormFields>(EMPTY_FORM);
|
||||
|
|
@ -391,7 +399,11 @@ export function EditMetadataSettings() {
|
|||
settings.fieldsToRemove = Array.from(fieldsToRemove);
|
||||
}
|
||||
|
||||
processFiles(files, settings);
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -111,7 +111,13 @@ export function EraseObjectSettings({
|
|||
} else {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
setError(body.error || body.details || `Failed: ${xhr.status}`);
|
||||
setError(
|
||||
typeof body.error === "string"
|
||||
? body.error
|
||||
: typeof body.details === "string"
|
||||
? body.details
|
||||
: `Failed: ${xhr.status}`,
|
||||
);
|
||||
} catch {
|
||||
setError(`Processing failed: ${xhr.status}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,10 +220,9 @@ export function ImageToPdfSettings() {
|
|||
};
|
||||
|
||||
xhr.open("POST", "/api/v1/tools/image-to-pdf");
|
||||
const headers = formatHeaders();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
xhr.setRequestHeader(key, value as string);
|
||||
}
|
||||
formatHeaders().forEach((value, key) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
xhr.send(formData);
|
||||
}, [files, pageSize, orientation, margin, setProcessing, setError]);
|
||||
|
||||
|
|
|
|||
|
|
@ -442,7 +442,14 @@ export function PassportPhotoSettings() {
|
|||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new Error(body?.details || body?.error || `Analysis failed: ${response.status}`);
|
||||
const msg = body
|
||||
? typeof body.details === "string"
|
||||
? body.details
|
||||
: typeof body.error === "string"
|
||||
? body.error
|
||||
: `Analysis failed: ${response.status}`
|
||||
: `Analysis failed: ${response.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
|
@ -504,9 +511,14 @@ export function PassportPhotoSettings() {
|
|||
|
||||
if (!response.ok) {
|
||||
const errBody = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
errBody?.details || errBody?.error || `Generation failed: ${response.status}`,
|
||||
);
|
||||
const msg = errBody
|
||||
? typeof errBody.details === "string"
|
||||
? errBody.details
|
||||
: typeof errBody.error === "string"
|
||||
? errBody.error
|
||||
: `Generation failed: ${response.status}`
|
||||
: `Generation failed: ${response.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const result: GenerateResult = await response.json();
|
||||
|
|
|
|||
|
|
@ -92,8 +92,6 @@ export function SplitSettings() {
|
|||
setZipBlobUrl(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", files[0]);
|
||||
const effectiveGrid = getEffectiveGrid();
|
||||
const settings: Record<string, unknown> = {
|
||||
columns: effectiveGrid.columns,
|
||||
|
|
@ -107,41 +105,60 @@ export function SplitSettings() {
|
|||
if (LOSSY_FORMATS.has(outputFormat)) {
|
||||
settings.quality = quality;
|
||||
}
|
||||
formData.append("settings", JSON.stringify(settings));
|
||||
|
||||
const res = await fetch("/api/v1/tools/split", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
setZipBlobUrl(url);
|
||||
const settingsJson = JSON.stringify(settings);
|
||||
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = await JSZip.loadAsync(blob);
|
||||
const tileEntries: Array<{ row: number; col: number; blobUrl: string | null }> = [];
|
||||
const combinedZip = new JSZip();
|
||||
const previewTiles: Array<{ row: number; col: number; blobUrl: string | null }> = [];
|
||||
const multiFile = files.length > 1;
|
||||
|
||||
const fileNames = Object.keys(zip.files).filter((n) => !zip.files[n].dir);
|
||||
fileNames.sort();
|
||||
for (let fi = 0; fi < files.length; fi++) {
|
||||
const file = files[fi];
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("settings", settingsJson);
|
||||
|
||||
for (const name of fileNames) {
|
||||
const fileData = await zip.files[name].async("blob");
|
||||
const tileBlobUrl = URL.createObjectURL(fileData);
|
||||
const match = name.match(/_r(\d+)_c(\d+)/);
|
||||
const row = match ? Number.parseInt(match[1], 10) : 0;
|
||||
const col = match ? Number.parseInt(match[2], 10) : 0;
|
||||
tileEntries.push({ row, col, blobUrl: tileBlobUrl });
|
||||
const res = await fetch("/api/v1/tools/split", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Failed to split ${file.name}: ${text || res.status}`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const fileZip = await JSZip.loadAsync(blob);
|
||||
const baseName = file.name.replace(/\.[^.]+$/, "");
|
||||
const prefix = multiFile ? `${baseName}/` : "";
|
||||
|
||||
const fileNames = Object.keys(fileZip.files).filter((n) => !fileZip.files[n].dir);
|
||||
fileNames.sort();
|
||||
|
||||
for (const name of fileNames) {
|
||||
const data = await fileZip.files[name].async("uint8array");
|
||||
combinedZip.file(`${prefix}${name}`, data);
|
||||
|
||||
if (fi === 0) {
|
||||
const tileBlob = new Blob([data as BlobPart]);
|
||||
const tileBlobUrl = URL.createObjectURL(tileBlob);
|
||||
const match = name.match(/_r(\d+)_c(\d+)/);
|
||||
previewTiles.push({
|
||||
row: match ? Number.parseInt(match[1], 10) : 0,
|
||||
col: match ? Number.parseInt(match[2], 10) : 0,
|
||||
blobUrl: tileBlobUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tileEntries.sort((a, b) => a.row - b.row || a.col - b.col);
|
||||
const combinedBlob = await combinedZip.generateAsync({ type: "blob" });
|
||||
setZipBlobUrl(URL.createObjectURL(combinedBlob));
|
||||
|
||||
previewTiles.sort((a, b) => a.row - b.row || a.col - b.col);
|
||||
setTiles(
|
||||
tileEntries.map((t, i) => ({
|
||||
previewTiles.map((t, i) => ({
|
||||
row: t.row,
|
||||
col: t.col,
|
||||
label: `${i + 1}`,
|
||||
|
|
@ -173,7 +190,8 @@ export function SplitSettings() {
|
|||
if (!zipBlobUrl) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = zipBlobUrl;
|
||||
const baseName = files[0]?.name?.replace(/\.[^.]+$/, "") ?? "split";
|
||||
const baseName =
|
||||
files.length > 1 ? "split-batch" : (files[0]?.name?.replace(/\.[^.]+$/, "") ?? "split");
|
||||
a.download = `${baseName}-${grid.columns}x${grid.rows}.zip`;
|
||||
a.click();
|
||||
}, [zipBlobUrl, files, grid]);
|
||||
|
|
@ -381,12 +399,20 @@ export function SplitSettings() {
|
|||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Splitting..." : `Split into ${tileCount} Tiles`}
|
||||
{processing
|
||||
? "Splitting..."
|
||||
: files.length > 1
|
||||
? `Split ${files.length} Images (${tileCount} tiles each)`
|
||||
: `Split into ${tileCount} Tiles`}
|
||||
</button>
|
||||
|
||||
{hasTiles && (
|
||||
<div className="space-y-3 border-t border-border pt-3">
|
||||
<p className="text-xs font-medium text-foreground">{tiles.length} Tiles Generated</p>
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{files.length > 1
|
||||
? `${files.length} images split (${tiles.length} tiles each)`
|
||||
: `${tiles.length} Tiles Generated`}
|
||||
</p>
|
||||
<div
|
||||
className="grid gap-1"
|
||||
style={{ gridTemplateColumns: `repeat(${grid.columns}, 1fr)` }}
|
||||
|
|
|
|||
|
|
@ -197,8 +197,16 @@ export function StripMetadataControls({
|
|||
|
||||
export function StripMetadataSettings() {
|
||||
const { entries, selectedIndex, files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("strip-metadata");
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("strip-metadata");
|
||||
|
||||
const [stripSettings, setStripSettings] = useState<Record<string, unknown>>({
|
||||
stripAll: true,
|
||||
|
|
@ -267,7 +275,11 @@ export function StripMetadataSettings() {
|
|||
}, [currentFile, fileKey, metadataCache]);
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, stripSettings);
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, stripSettings);
|
||||
} else {
|
||||
processFiles(files, stripSettings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
|
|
|||
|
|
@ -115,41 +115,92 @@ export function VectorizeSettings() {
|
|||
setError(null);
|
||||
setDownloadUrl(null);
|
||||
|
||||
const settingsJson = JSON.stringify({
|
||||
colorMode,
|
||||
threshold,
|
||||
colorPrecision,
|
||||
layerDifference,
|
||||
filterSpeckle,
|
||||
pathMode,
|
||||
cornerThreshold,
|
||||
invert,
|
||||
});
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", files[0]);
|
||||
formData.append(
|
||||
"settings",
|
||||
JSON.stringify({
|
||||
colorMode,
|
||||
threshold,
|
||||
colorPrecision,
|
||||
layerDifference,
|
||||
filterSpeckle,
|
||||
pathMode,
|
||||
cornerThreshold,
|
||||
invert,
|
||||
}),
|
||||
);
|
||||
if (files.length === 1) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", files[0]);
|
||||
formData.append("settings", settingsJson);
|
||||
|
||||
const res = await fetch("/api/v1/tools/vectorize", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
const res = await fetch("/api/v1/tools/vectorize", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Failed: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
setJobId(result.jobId);
|
||||
setProcessedUrl(result.downloadUrl);
|
||||
setDownloadUrl(result.downloadUrl);
|
||||
setOriginalSize(result.originalSize);
|
||||
setProcessedSize(result.processedSize);
|
||||
setSizes(result.originalSize, result.processedSize);
|
||||
} else {
|
||||
const { updateEntry, setBatchZip } = useFileStore.getState();
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = new JSZip();
|
||||
let totalOriginal = 0;
|
||||
let totalProcessed = 0;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("settings", settingsJson);
|
||||
|
||||
const res = await fetch("/api/v1/tools/vectorize", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
updateEntry(i, {
|
||||
status: "failed",
|
||||
error: body.error || `Failed: ${res.status}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
totalOriginal += result.originalSize;
|
||||
totalProcessed += result.processedSize;
|
||||
|
||||
const svgRes = await fetch(result.downloadUrl, { headers: formatHeaders() });
|
||||
const svgBlob = await svgRes.blob();
|
||||
const svgName = file.name.replace(/\.[^.]+$/, ".svg");
|
||||
zip.file(svgName, svgBlob);
|
||||
|
||||
updateEntry(i, {
|
||||
processedUrl: result.downloadUrl,
|
||||
processedSize: result.processedSize,
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
setBatchZip(zipBlob, "vectorize-batch.zip");
|
||||
setOriginalSize(totalOriginal);
|
||||
setProcessedSize(totalProcessed);
|
||||
setSizes(totalOriginal, totalProcessed);
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
setJobId(result.jobId);
|
||||
setProcessedUrl(result.downloadUrl);
|
||||
setDownloadUrl(result.downloadUrl);
|
||||
setOriginalSize(result.originalSize);
|
||||
setProcessedSize(result.processedSize);
|
||||
setSizes(result.originalSize, result.processedSize);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Vectorization failed");
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { formatHeaders, parseApiError } from "@/lib/api";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import type { PipelineStep } from "@/stores/pipeline-store";
|
||||
|
|
@ -145,10 +145,7 @@ export function usePipelineProcessor() {
|
|||
} else {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
const msg = body.details
|
||||
? `${body.error}: ${body.details}`
|
||||
: body.error || `Processing failed: ${xhr.status}`;
|
||||
setError(msg);
|
||||
setError(parseApiError(body, xhr.status));
|
||||
} catch {
|
||||
setError(`Processing failed: ${xhr.status}`);
|
||||
}
|
||||
|
|
@ -273,9 +270,7 @@ export function usePipelineProcessor() {
|
|||
errorMsg += ` (${body.errors.length} files failed)`;
|
||||
}
|
||||
} else {
|
||||
errorMsg = body.details
|
||||
? `${body.error}: ${body.details}`
|
||||
: body.error || `Batch processing failed: ${response.status}`;
|
||||
errorMsg = parseApiError(body, response.status);
|
||||
}
|
||||
} catch {
|
||||
errorMsg = `Batch processing failed: ${response.status}`;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { PYTHON_SIDECAR_TOOLS } from "@ashim/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { formatHeaders, parseApiError } from "@/lib/api";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
|
|
@ -233,10 +233,7 @@ export function useToolProcessor(toolId: string) {
|
|||
} else {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
const msg = body.details
|
||||
? `${body.error}: ${body.details}`
|
||||
: body.error || `Processing failed: ${xhr.status}`;
|
||||
setError(msg);
|
||||
setError(parseApiError(body, xhr.status));
|
||||
} catch {
|
||||
setError(`Processing failed: ${xhr.status}`);
|
||||
}
|
||||
|
|
@ -357,9 +354,7 @@ export function useToolProcessor(toolId: string) {
|
|||
let errorMsg: string;
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
errorMsg = body.details
|
||||
? `${body.error}: ${body.details}`
|
||||
: body.error || `Batch processing failed: ${response.status}`;
|
||||
errorMsg = parseApiError(body, response.status);
|
||||
} catch {
|
||||
errorMsg = `Batch processing failed: ${response.status}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,26 @@
|
|||
const API_BASE = "/api";
|
||||
|
||||
export function parseApiError(body: Record<string, unknown>, fallbackStatus: number): string {
|
||||
const error = typeof body.error === "string" ? body.error : "";
|
||||
const details = body.details;
|
||||
if (!details) {
|
||||
return error || (body.message as string) || `Processing failed: ${fallbackStatus}`;
|
||||
}
|
||||
let detailsStr: string;
|
||||
if (typeof details === "string") {
|
||||
detailsStr = details;
|
||||
} else if (Array.isArray(details)) {
|
||||
detailsStr = details
|
||||
.map((d) =>
|
||||
typeof d === "string" ? d : (d as Record<string, unknown>)?.message || JSON.stringify(d),
|
||||
)
|
||||
.join("; ");
|
||||
} else {
|
||||
detailsStr = JSON.stringify(details);
|
||||
}
|
||||
return error ? `${error}: ${detailsStr}` : detailsStr;
|
||||
}
|
||||
|
||||
// ── Auth Headers ───────────────────────────────────────────────
|
||||
|
||||
function getToken(): string {
|
||||
|
|
|
|||
|
|
@ -291,14 +291,14 @@ ENV PORT=1349 \
|
|||
CONCURRENT_JOBS=3 \
|
||||
MAX_MEGAPIXELS=100 \
|
||||
RATE_LIMIT_PER_MIN=100 \
|
||||
LOG_LEVEL=info
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# NVIDIA Container Toolkit env vars (harmless on non-GPU systems)
|
||||
ENV NVIDIA_VISIBLE_DEVICES=all \
|
||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
# Suppress noisy ML library output in docker logs
|
||||
ENV PYTHONWARNINGS=ignore \
|
||||
ENV PYTHONWARNINGS=default \
|
||||
TF_CPP_MIN_LOG_LEVEL=3 \
|
||||
PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,435 @@
|
|||
# On-Demand AI Feature Downloads
|
||||
|
||||
**Date:** 2026-04-17
|
||||
**Status:** Approved
|
||||
**Goal:** Reduce Docker image from ~30 GB to ~5-6 GB (amd64) / ~2-3 GB (arm64) by making AI features downloadable post-install.
|
||||
|
||||
## Problem
|
||||
|
||||
The Docker image bundles all Python ML packages (~8-10 GB) and model weights (~5-8 GB) regardless of whether users need AI features. Users who only want basic image tools (resize, crop, convert) must pull ~30 GB.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **Single Docker image** — no lite/full variants
|
||||
- **Individual feature bundles** — users cherry-pick by feature name, not model name
|
||||
- **Admin-only downloads** — only admins can enable/disable AI features
|
||||
- **AI tools visible with badge** — uninstalled tools appear in grid with a download indicator
|
||||
- **Both tool-page and settings UI** — admins can download from the tool page or from a central management panel in settings
|
||||
|
||||
## Architecture
|
||||
|
||||
### Base Image Contents
|
||||
|
||||
The base image includes everything needed for non-AI tools plus the prerequisites for AI feature installation:
|
||||
|
||||
| Component | Rationale |
|
||||
|-----------|-----------|
|
||||
| Node.js 22 + pnpm + app source + frontend dist | Core application |
|
||||
| Sharp, imagemagick, tesseract-ocr, potrace, libheif, exiftool | Non-AI image processing |
|
||||
| caire binary | Content-aware resize |
|
||||
| Python 3 + pip + build-essential | Required for pip install at runtime |
|
||||
| numpy==1.26.4, Pillow, opencv-python-headless | Shared by all AI features, small (~300 MB) |
|
||||
| CUDA runtime (amd64 only, from nvidia/cuda base) | Required for GPU-accelerated AI |
|
||||
|
||||
**Estimated size:** ~5-6 GB (amd64), ~2-3 GB (arm64)
|
||||
|
||||
### Feature Bundles
|
||||
|
||||
Six user-facing bundles, named by what they enable (not by model names). **Each tool belongs to exactly one bundle — no partial functionality.** When a bundle is installed, all its tools work fully. When it's not installed, those tools are locked entirely.
|
||||
|
||||
| Feature Name | Python Packages | Models | Tools Fully Enabled | Est. Size |
|
||||
|---|---|---|---|---|
|
||||
| **Background Removal** | rembg, onnxruntime(-gpu), mediapipe | birefnet-general-lite, blaze_face, face_landmarker | remove-background, passport-photo | ~700 MB - 1 GB |
|
||||
| **Face Detection** | mediapipe | blaze_face, face_landmarker | blur-faces, red-eye-removal, smart-crop | ~200-300 MB |
|
||||
| **Object Eraser & Colorize** | onnxruntime(-gpu) | LaMa ONNX, DDColor ONNX, OpenCV colorize | erase-object, colorize | ~600-800 MB |
|
||||
| **Upscale & Enhance** | torch, torchvision, realesrgan, codeformer-pip (--no-deps), gfpgan, basicsr, lpips | RealESRGAN x4plus, GFPGANv1.3, CodeFormer (.pth), facexlib, SCUNet, NAFNet | upscale, enhance-faces, noise-removal | ~4-5 GB |
|
||||
| **Photo Restoration** | onnxruntime(-gpu), mediapipe | LaMa ONNX, DDColor ONNX, CodeFormer ONNX, blaze_face, face_landmarker, OpenCV colorize | restore-photo | ~800 MB - 1 GB |
|
||||
| **OCR** | paddlepaddle(-gpu), paddleocr | PP-OCRv5 (7 models), PaddleOCR-VL 1.5 | ocr | ~3-4 GB |
|
||||
|
||||
Notes:
|
||||
- `passport-photo` is in the Background Removal bundle because it primarily needs rembg; mediapipe (for face landmarks) is included in the same bundle so the tool works fully
|
||||
- `noise-removal` is in the Upscale & Enhance bundle because its quality/maximum tiers need PyTorch; all 4 tiers (including OpenCV-based quick/balanced) are locked until the bundle is installed
|
||||
- `ocr` is fully locked until the OCR bundle is installed, including the Tesseract-based fast tier — this keeps the UX clean even though Tesseract is pre-installed in the base image
|
||||
- `restore-photo` is its own bundle because it needs models from multiple domains (inpainting, face enhancement, colorization); all stages work when installed
|
||||
- Some packages appear in multiple bundles (e.g., mediapipe in Background Removal, Face Detection, and Photo Restoration; onnxruntime in Background Removal, Object Eraser, and Photo Restoration). The install script skips already-installed packages — pip handles this naturally
|
||||
- Some models appear in multiple bundles (e.g., blaze_face in both Background Removal and Face Detection). The install script skips already-downloaded model files
|
||||
|
||||
### Bundle Dependencies
|
||||
|
||||
```
|
||||
Background Removal ───── standalone
|
||||
Face Detection ────────── standalone
|
||||
Object Eraser & Colorize ── standalone
|
||||
Upscale & Enhance ─────── standalone
|
||||
Photo Restoration ─────── standalone
|
||||
OCR ───────────────────── standalone
|
||||
```
|
||||
|
||||
All bundles are independently installable. Shared packages (mediapipe, onnxruntime) and shared models (blaze_face, LaMa, etc.) are silently skipped if already present from another bundle.
|
||||
|
||||
### Single Venv Strategy
|
||||
|
||||
The current architecture uses a single venv at `/opt/venv` (set via `PYTHON_VENV_PATH`). The bridge (`bridge.ts`) constructs `${venvPath}/bin/python3` — it can only point to one interpreter. Having two venvs (base at `/opt/venv`, features at `/data/ai/venv/`) is fragile: C extensions and entry points reference their venv prefix, and `PYTHONPATH` hacks break in practice.
|
||||
|
||||
**Solution:** Use a single venv on the persistent volume at `/data/ai/venv/`.
|
||||
|
||||
- The Dockerfile creates `/opt/venv` with base packages (numpy, Pillow, opencv) as before
|
||||
- The entrypoint script bootstraps `/data/ai/venv/` on first run by copying `/opt/venv` into it (fast file copy, ~300 MB)
|
||||
- `PYTHON_VENV_PATH` is set to `/data/ai/venv/` so the bridge uses it
|
||||
- Feature installs add packages to this same venv
|
||||
- On container update, the entrypoint checks if base package versions changed and updates the venv accordingly (pip install from wheel cache)
|
||||
|
||||
This gives us one venv with all packages, living on a persistent volume, bootstrapped from the image's base packages.
|
||||
|
||||
### Persistent Storage
|
||||
|
||||
All AI data lives under `/data/ai/` on the existing Docker volume (no docker-compose changes):
|
||||
|
||||
```
|
||||
/data/ai/
|
||||
venv/ # Single Python virtual environment (bootstrapped from /opt/venv, extended by feature installs)
|
||||
models/ # Downloaded model weight files (same structure as /opt/models/)
|
||||
pip-cache/ # Wheel cache for fast re-installs after updates
|
||||
installed.json # Tracks installed bundles, versions, timestamps
|
||||
```
|
||||
|
||||
### Feature Manifest
|
||||
|
||||
A `feature-manifest.json` file is baked into each Docker image at build time. It is the single source of truth for what each bundle installs:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"imageVersion": "1.16.0",
|
||||
"pythonVersion": "3.12",
|
||||
"basePackages": ["numpy==1.26.4", "Pillow==11.1.0", "opencv-python-headless==4.10.0.84"],
|
||||
"bundles": {
|
||||
"background-removal": {
|
||||
"name": "Background Removal",
|
||||
"description": "Remove image backgrounds with AI",
|
||||
"packages": {
|
||||
"common": ["rembg==2.0.62"],
|
||||
"amd64": ["onnxruntime-gpu==1.20.1", "mediapipe==0.10.21"],
|
||||
"arm64": ["onnxruntime==1.20.1", "rembg[cpu]==2.0.62", "mediapipe==0.10.18"]
|
||||
},
|
||||
"pipFlags": {},
|
||||
"models": [
|
||||
{
|
||||
"id": "birefnet-general-lite",
|
||||
"downloadFn": "rembg_session",
|
||||
"args": ["birefnet-general-lite"]
|
||||
},
|
||||
{
|
||||
"id": "blaze-face-short-range",
|
||||
"url": "https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/latest/blaze_face_short_range.tflite",
|
||||
"path": "mediapipe/blaze_face_short_range.tflite",
|
||||
"minSize": 100000
|
||||
},
|
||||
{
|
||||
"id": "face-landmarker",
|
||||
"url": "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task",
|
||||
"path": "mediapipe/face_landmarker.task",
|
||||
"minSize": 5000000
|
||||
}
|
||||
],
|
||||
"enablesTools": ["remove-background", "passport-photo"]
|
||||
},
|
||||
"upscale-enhance": {
|
||||
"name": "Upscale & Enhance",
|
||||
"description": "AI upscaling, face enhancement, and noise removal",
|
||||
"packages": {
|
||||
"common": ["codeformer-pip==0.0.4", "lpips"],
|
||||
"amd64": [
|
||||
"torch torchvision --extra-index-url https://download.pytorch.org/whl/cu126",
|
||||
"realesrgan==0.3.0 --extra-index-url https://download.pytorch.org/whl/cu126"
|
||||
],
|
||||
"arm64": ["torch", "torchvision", "realesrgan==0.3.0"]
|
||||
},
|
||||
"pipFlags": {
|
||||
"codeformer-pip==0.0.4": "--no-deps"
|
||||
},
|
||||
"postInstall": ["pip install numpy==1.26.4"],
|
||||
"models": [
|
||||
{ "id": "realesrgan-x4plus", "url": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth", "path": "realesrgan/RealESRGAN_x4plus.pth", "minSize": 67000000 },
|
||||
{ "id": "gfpgan-v1.3", "url": "https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.3.pth", "path": "gfpgan/GFPGANv1.3.pth", "minSize": 332000000 },
|
||||
{ "id": "codeformer-pth", "url": "https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth", "path": "codeformer/codeformer.pth", "minSize": 375000000 },
|
||||
{ "id": "codeformer-onnx", "url": "hf://facefusion/models-3.0.0/codeformer.onnx", "path": "codeformer/codeformer.onnx", "minSize": 377000000 },
|
||||
{ "id": "facexlib-detection", "url": "https://github.com/xinntao/facexlib/releases/download/v0.1.0/detection_Resnet50_Final.pth", "path": "gfpgan/facelib/detection_Resnet50_Final.pth", "minSize": 104000000 },
|
||||
{ "id": "facexlib-parsing", "url": "https://github.com/xinntao/facexlib/releases/download/v0.2.2/parsing_parsenet.pth", "path": "gfpgan/facelib/parsing_parsenet.pth", "minSize": 85000000 },
|
||||
{ "id": "scunet", "url": "https://github.com/cszn/KAIR/releases/download/v1.0/scunet_color_real_psnr.pth", "path": "scunet/scunet_color_real_psnr.pth", "minSize": 4000000 },
|
||||
{ "id": "nafnet", "url": "hf://mikestealth/nafnet-models/NAFNet-SIDD-width64.pth", "path": "nafnet/NAFNet-SIDD-width64.pth", "minSize": 67000000 }
|
||||
],
|
||||
"enablesTools": ["upscale", "enhance-faces", "noise-removal"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Install Script
|
||||
|
||||
A Python script (`packages/ai/python/install_feature.py`) handles feature installation:
|
||||
|
||||
1. Reads the feature manifest from the image
|
||||
2. Detects architecture (amd64/arm64) and GPU availability
|
||||
3. Creates or reuses the venv at `/data/ai/venv/`
|
||||
4. Runs pip install with the correct packages, flags, and index URLs per platform
|
||||
5. Handles the numpy version conflict (--no-deps for codeformer, re-pin numpy)
|
||||
6. Downloads model weights with retry logic (ported from `download_models.py`)
|
||||
7. Updates `/data/ai/installed.json` with bundle status
|
||||
8. Reports progress to stdout as JSON lines (consumed by the Node bridge)
|
||||
|
||||
The script must be idempotent — running it twice for the same bundle is a no-op.
|
||||
|
||||
### Uninstall and Shared Package Strategy
|
||||
|
||||
Bundles share Python packages (e.g., onnxruntime in Background Removal, Object Eraser, and Photo Restoration). Naively pip-uninstalling a bundle's packages could break other installed bundles.
|
||||
|
||||
**v1 approach (simple):** Uninstall removes model files and updates `installed.json`. Orphaned pip packages stay in the venv — they use disk but don't cause issues. A "Clean up" button in the AI Features settings panel rebuilds the venv from scratch: creates a fresh venv, installs only packages needed by currently-installed bundles, removes the old venv.
|
||||
|
||||
**Future improvement:** Reference counting — track which bundles need which packages, only remove packages exclusively owned by the target bundle.
|
||||
|
||||
### Tool Route Registration for Uninstalled Features
|
||||
|
||||
Currently `registerToolRoutes()` either registers a route or doesn't (disabled tools get 404). For uninstalled AI features, we need routes that return a structured error instead of 404.
|
||||
|
||||
**Solution: Register ALL tool routes always, add a pre-processing guard.**
|
||||
|
||||
In `tool-factory.ts`, before calling `config.process()`, check feature installation status:
|
||||
|
||||
```typescript
|
||||
if (isAiTool(config.toolId) && !isFeatureInstalled(config.toolId)) {
|
||||
const bundle = getBundleForTool(config.toolId);
|
||||
return reply.status(501).send({
|
||||
error: "Feature not installed",
|
||||
code: "FEATURE_NOT_INSTALLED",
|
||||
feature: bundle.id,
|
||||
featureName: bundle.name,
|
||||
estimatedSize: bundle.estimatedSize,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
This also applies to `restore-photo.ts` (which uses its own route handler, not the factory) and the pipeline pre-validation in `pipeline.ts`.
|
||||
|
||||
**For batch processing:** If a batch job targets an uninstalled tool, return 501 before processing starts (same as the route guard). Don't silently skip files.
|
||||
|
||||
**For pipelines:** The pipeline pre-validation loop already checks tool availability. Extend it to also check feature installation. Return a 501 with the specific bundle needed.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
New routes — read endpoint is public (no `/admin/` prefix), mutation endpoints are admin-only:
|
||||
|
||||
```
|
||||
GET /api/v1/features
|
||||
Returns: list of all bundles with install status, sizes, enabled tools
|
||||
Auth: any authenticated user (read-only, needed by frontend for badges/tool page state)
|
||||
Response: {
|
||||
bundles: [{
|
||||
id: "background-removal",
|
||||
name: "Background Removal",
|
||||
description: "Remove image backgrounds with AI",
|
||||
status: "not_installed" | "installing" | "installed" | "error",
|
||||
installedVersion: "1.15.3" | null,
|
||||
estimatedSize: "500-700 MB",
|
||||
enablesTools: ["remove-background"],
|
||||
partialTools: ["passport-photo"],
|
||||
progress: { percent: 45, stage: "Downloading models..." } | null,
|
||||
error: "pip install failed: ..." | null,
|
||||
dependencies: [] | ["upscale-enhance"]
|
||||
}]
|
||||
}
|
||||
|
||||
POST /api/v1/admin/features/:bundleId/install
|
||||
Starts background installation of a feature bundle.
|
||||
Auth: admin only
|
||||
Response: { jobId: "uuid" }
|
||||
SSE progress at: GET /api/v1/jobs/:jobId/progress
|
||||
|
||||
POST /api/v1/admin/features/:bundleId/uninstall
|
||||
Removes a feature bundle (pip packages + models).
|
||||
Auth: admin only
|
||||
Response: { ok: true, freedSpace: "500 MB" }
|
||||
|
||||
GET /api/v1/admin/features/disk-usage
|
||||
Returns total disk usage of /data/ai/.
|
||||
Auth: admin only
|
||||
Response: { totalBytes: 5368709120, byBundle: { "background-removal": 734003200, ... } }
|
||||
```
|
||||
|
||||
### Background Job Mechanism
|
||||
|
||||
Feature installation runs as a background child process (not inline with the HTTP request):
|
||||
|
||||
1. `POST /admin/features/:bundleId/install` spawns the install script as a child process
|
||||
2. Progress is streamed via stderr JSON lines → captured by the Node process → pushed to SSE listeners
|
||||
3. The existing SSE infrastructure (`/api/v1/jobs/:jobId/progress`) is reused
|
||||
4. Job status is persisted to the `jobs` table for recovery on restart
|
||||
5. Only one install can run at a time (mutex). Concurrent install requests return 409 Conflict.
|
||||
|
||||
### Python Sidecar Changes
|
||||
|
||||
**dispatcher.py:**
|
||||
- On startup, read `/data/ai/installed.json` to know which features are available
|
||||
- Populate `available_modules` based on what's actually installed
|
||||
- When a script is requested for an uninstalled feature, return a structured error: `{"error": "feature_not_installed", "feature": "background-removal", "message": "Background Removal is not installed"}`
|
||||
- After a feature is installed, the dispatcher must be restarted (or sent a reload signal) to pick up new packages. The bridge handles this by killing and re-spawning the dispatcher.
|
||||
|
||||
**Python scripts:**
|
||||
- Convert hard module-level imports in `colorize.py` and `restore.py` to lazy imports inside functions
|
||||
- All scripts should check for their feature's models and return a clear "not installed" error if missing
|
||||
- The `sys.path` must include `/data/ai/venv/lib/python3.X/site-packages/` (set by the dispatcher on startup based on installed.json)
|
||||
|
||||
**Bridge (bridge.ts):**
|
||||
- Update `PYTHON_VENV_PATH` logic to prefer `/data/ai/venv/` when it exists
|
||||
- Add a `restartDispatcher()` function called after feature install completes
|
||||
- Handle the new `feature_not_installed` error type from the dispatcher
|
||||
|
||||
### Model Path Resolution
|
||||
|
||||
Currently models are at `/opt/models/`. With on-demand downloads, they'll be at `/data/ai/models/`. The resolution order:
|
||||
|
||||
1. `/opt/models/<model>` (Docker-baked, for backwards compatibility if someone builds a full image)
|
||||
2. `/data/ai/models/<model>` (on-demand download location)
|
||||
3. `~/.cache/ashim/<model>` (local dev fallback)
|
||||
|
||||
Environment variables (`U2NET_HOME`, etc.) are updated by the install script to point to `/data/ai/models/`.
|
||||
|
||||
### Dockerfile Changes
|
||||
|
||||
1. Remove all `pip install` commands for ML packages (lines 175-206)
|
||||
2. Remove `download_models.py` COPY and RUN (lines 219-231)
|
||||
3. Keep: Python 3 + pip + build-essential (do NOT purge build-essential)
|
||||
4. Keep: numpy, Pillow, opencv-python-headless install (lightweight shared deps)
|
||||
5. Add: COPY `feature-manifest.json` into the image
|
||||
6. Add: COPY `install_feature.py` into the image
|
||||
7. Update entrypoint to set up `/data/ai/` directory structure on first run
|
||||
8. Update env vars: `MODELS_PATH=/data/ai/models` as default, fallback to `/opt/models`
|
||||
|
||||
### Frontend: Tool Page (Uninstalled State)
|
||||
|
||||
When a user navigates to an AI tool that isn't installed:
|
||||
|
||||
**For admins:**
|
||||
- Show a card replacing the normal upload area:
|
||||
- Feature icon + name (e.g., "Background Removal")
|
||||
- "This feature requires an additional download (~500-700 MB)"
|
||||
- [Enable Feature] button
|
||||
- After clicking: progress bar with stage text, estimated time
|
||||
- On completion: page automatically transitions to the normal tool UI
|
||||
|
||||
**For non-admins:**
|
||||
- Show: "This feature is not enabled. Ask your administrator to enable it in Settings."
|
||||
|
||||
### Frontend: Tool Grid (Badge)
|
||||
|
||||
AI tools in the grid show a small download icon overlay when not installed. When installed, the icon disappears and the tool looks like any other tool.
|
||||
|
||||
Tools with partial dependencies (e.g., passport-photo needs 2 bundles) show the badge until ALL required bundles are installed.
|
||||
|
||||
### Frontend: Settings Panel
|
||||
|
||||
New "AI Features" section in the settings dialog (admin only):
|
||||
|
||||
- List of all 6 feature bundles as cards
|
||||
- Each card shows: name, description, status (installed/not installed/installing), disk usage
|
||||
- Install/Uninstall buttons per bundle
|
||||
- "Install All" button at the top
|
||||
- Total AI disk usage summary at the bottom
|
||||
- Progress bar during installation
|
||||
- Dependency warnings (e.g., "Advanced Noise Removal requires Upscale & Face Enhance")
|
||||
|
||||
### Container Update Flow
|
||||
|
||||
When a user does `docker pull` + restart:
|
||||
|
||||
1. **Pull:** Only app code layers changed → ~50-100 MB download
|
||||
2. **Startup:** Backend reads feature manifest from new image + installed.json from volume
|
||||
3. **Comparison:**
|
||||
- If bundle package versions unchanged → no action, instant startup
|
||||
- If a package version bumped → `pip install --upgrade` from wheel cache (seconds)
|
||||
- If a model URL/version changed → re-download that model only
|
||||
- If Python major version changed → rebuild venv from cached wheels (rare, ~2-5 min)
|
||||
4. **Dispatcher restart** if any packages changed
|
||||
|
||||
This check runs at startup, not blocking the HTTP server. AI features show "Updating..." status until the check completes.
|
||||
|
||||
### Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| No internet during install | Error with clear message: "Could not download packages. Check your internet connection." |
|
||||
| Partial install (interrupted) | On next install attempt, detect incomplete state and resume/retry |
|
||||
| Disk full | Error with disk usage info: "Not enough disk space. Need ~500 MB, only 200 MB available." |
|
||||
| pip install failure | Error with the pip output. Bundle marked as "error" status, admin can retry. |
|
||||
| Model download failure | Retry 3 times with exponential backoff. On final failure, mark bundle as partially installed (packages OK, models missing). |
|
||||
| Container update breaks venv | Version manifest comparison detects mismatch, triggers venv rebuild from wheel cache |
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
- **Unit tests:** Feature manifest parsing, version comparison logic, bundle dependency resolution
|
||||
- **Integration tests:** Install/uninstall API endpoints, status reporting, SSE progress
|
||||
- **E2E tests:** Admin enables a feature from settings, tool page transitions from "not installed" to working
|
||||
- **Docker build test:** Verify base image builds without ML packages, verify feature-manifest.json is present
|
||||
- **Install script test:** Run install script in a clean container, verify packages and models are correctly installed
|
||||
|
||||
### Migration Path
|
||||
|
||||
Since the new image is fundamentally different (no ML packages baked in), existing users upgrading from the full image will need to re-download their AI features. The Python ML packages are no longer in the system venv, so even if old model weights exist at `/opt/models/`, the features won't work without packages.
|
||||
|
||||
The first-run experience for upgrading users:
|
||||
|
||||
1. Detect this is an upgrade: no `/data/ai/installed.json` exists, but user data exists in `/data`
|
||||
2. Show a one-time banner in the UI: "We've reduced the image size from 30 GB to 5 GB! AI features are now downloaded on-demand. Visit Settings → AI Features to enable the ones you need."
|
||||
3. No automatic downloads — let the admin choose what to install
|
||||
4. Old model weights at `/opt/models/` are ignored (they won't exist in the new image anyway since that layer is removed)
|
||||
|
||||
### Frontend: Feature Status Propagation
|
||||
|
||||
The frontend needs to know which tools are installed for three purposes: tool grid badges, tool page state, and settings panel.
|
||||
|
||||
**Features store** (`apps/web/src/stores/features-store.ts`):
|
||||
- Zustand store fetched on app load (like `settings-store.ts`)
|
||||
- Calls `GET /api/v1/features` to get bundle statuses
|
||||
- Provides a derived mapping: `toolInstallStatus: Record<string, "installed" | "not_installed" | "installing" | "partial">` where "partial" means some but not all required bundles are installed (e.g., passport-photo with only Background Removal but not Face Detection)
|
||||
- Provides `isToolInstalled(toolId): boolean` and `getBundlesForTool(toolId): BundleInfo[]` helpers
|
||||
- Refreshes on install/uninstall completion
|
||||
|
||||
**Tool grid integration:**
|
||||
- `ToolCard` checks `isToolInstalled(tool.id)` from the features store
|
||||
- If not installed: show a download icon badge (similar to existing "Experimental" badge)
|
||||
- The tool remains clickable (not disabled) — clicking navigates to the tool page where the install prompt appears
|
||||
- `PYTHON_SIDECAR_TOOLS` constant is used to determine which tools are AI tools (only AI tools can be "not installed")
|
||||
|
||||
**Tool page integration:**
|
||||
- `ToolPage` component checks feature status after the tool lookup
|
||||
- If the user is admin and feature not installed: render `FeatureInstallPrompt` component instead of the normal tool UI
|
||||
- If the user is non-admin and feature not installed: render "This feature is not enabled. Contact your administrator."
|
||||
- The install prompt shows feature name, description, estimated size, and an "Enable" button
|
||||
- After clicking "Enable": show progress bar with SSE-streamed progress, auto-transition to normal tool UI on completion
|
||||
|
||||
### Development and Testing
|
||||
|
||||
All development and testing is done via Docker containers — the same environment users run. Build the image locally and run it with:
|
||||
|
||||
```bash
|
||||
docker run -d --name ashim -p 1349:1349 -v ashim-data:/data ghcr.io/ashim-hq/ashim:latest
|
||||
```
|
||||
|
||||
Auth can be disabled for development by passing `-e AUTH_ENABLED=false`.
|
||||
|
||||
### Scope Boundaries
|
||||
|
||||
**In scope:**
|
||||
- Dockerfile restructuring to remove ML packages and models
|
||||
- Feature manifest system
|
||||
- Install/uninstall API + background job
|
||||
- Python sidecar changes for dynamic feature detection
|
||||
- Frontend: tool page download prompt, grid badge, settings panel
|
||||
- Container update handling with version manifest
|
||||
|
||||
**Out of scope (future work):**
|
||||
- Additional rembg model variants as sub-downloads within Background Removal
|
||||
- Automatic feature recommendations based on usage
|
||||
- Download from private/custom model registries
|
||||
- Bandwidth throttling for downloads
|
||||
- Multiple venv support (e.g., different Python versions)
|
||||
|
|
@ -229,10 +229,13 @@ def main():
|
|||
emit_progress(10, "Detecting language")
|
||||
language = auto_detect_language(input_path)
|
||||
|
||||
engine_used = quality
|
||||
|
||||
# Route to engine based on quality tier
|
||||
if quality == "fast":
|
||||
try:
|
||||
text = run_tesseract(input_path, language, is_auto=was_auto)
|
||||
engine_used = "tesseract"
|
||||
except FileNotFoundError:
|
||||
print(json.dumps({"success": False, "error": "Tesseract is not installed"}))
|
||||
sys.exit(1)
|
||||
|
|
@ -240,39 +243,63 @@ def main():
|
|||
elif quality == "balanced":
|
||||
try:
|
||||
text = run_paddleocr_v5(input_path, language)
|
||||
except ImportError:
|
||||
print(json.dumps({"success": False, "error": "PaddleOCR is not installed"}))
|
||||
engine_used = "paddleocr-v5"
|
||||
except ImportError as e:
|
||||
print(json.dumps({"success": False, "error": f"PaddleOCR is not installed: {e}"}))
|
||||
sys.exit(1)
|
||||
except Exception:
|
||||
emit_progress(25, "Falling back")
|
||||
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"}))
|
||||
print(json.dumps({"success": False, "error": "OCR engines unavailable: PaddleOCR failed and Tesseract is not installed"}))
|
||||
sys.exit(1)
|
||||
|
||||
elif quality == "best":
|
||||
try:
|
||||
text = run_paddleocr_vl(input_path)
|
||||
except ImportError:
|
||||
emit_progress(20, "Falling back")
|
||||
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)
|
||||
except Exception:
|
||||
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)
|
||||
except Exception:
|
||||
emit_progress(20, "Falling back")
|
||||
engine_used = "tesseract (fallback from best)"
|
||||
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)
|
||||
except Exception:
|
||||
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)"
|
||||
|
||||
else:
|
||||
print(json.dumps({"success": False, "error": f"Unknown quality: {quality}"}))
|
||||
sys.exit(1)
|
||||
|
||||
emit_progress(95, "Done")
|
||||
print(json.dumps({"success": True, "text": text}))
|
||||
print(json.dumps({"success": True, "text": text, "engine": engine_used}))
|
||||
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e)}))
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface OcrOptions {
|
|||
|
||||
export interface OcrResult {
|
||||
text: string;
|
||||
engine?: string;
|
||||
}
|
||||
|
||||
export async function extractText(
|
||||
|
|
@ -42,5 +43,6 @@ export async function extractText(
|
|||
|
||||
return {
|
||||
text: result.text,
|
||||
engine: result.engine,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
37
playwright.docker.config.ts
Normal file
37
playwright.docker.config.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import path from "node:path";
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const authFile = path.join(__dirname, "test-results", ".auth", "docker-user.json");
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e-docker",
|
||||
timeout: 120_000,
|
||||
expect: {
|
||||
timeout: 30_000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [["html", { open: "never" }], ["list"]],
|
||||
use: {
|
||||
baseURL: "http://localhost:1349",
|
||||
screenshot: "only-on-failure",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /auth\.setup\.ts/,
|
||||
},
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
storageState: authFile,
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export { authFile };
|
||||
54
scripts/test-docker-fixes.sh
Executable file
54
scripts/test-docker-fixes.sh
Executable file
|
|
@ -0,0 +1,54 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Step 1: Stop existing container ==="
|
||||
docker stop ashim 2>/dev/null || true
|
||||
docker rm ashim 2>/dev/null || true
|
||||
|
||||
echo "=== Step 2: Start test container ==="
|
||||
docker run -d \
|
||||
--name ashim-test \
|
||||
-p 1349:1349 \
|
||||
-e AUTH_ENABLED=true \
|
||||
-e DEFAULT_USERNAME=admin \
|
||||
-e DEFAULT_PASSWORD=admin \
|
||||
-e LOG_LEVEL=debug \
|
||||
-e RATE_LIMIT_PER_MIN=50000 \
|
||||
-e SKIP_MUST_CHANGE_PASSWORD=true \
|
||||
ashim:test-fixes
|
||||
|
||||
echo "=== Step 3: Wait for container to be healthy ==="
|
||||
echo "Waiting for health check..."
|
||||
for i in $(seq 1 60); do
|
||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' ashim-test 2>/dev/null || echo "starting")
|
||||
if [ "$STATUS" = "healthy" ]; then
|
||||
echo "Container is healthy after ${i}0 seconds"
|
||||
break
|
||||
fi
|
||||
if [ "$STATUS" = "unhealthy" ]; then
|
||||
echo "Container is unhealthy!"
|
||||
docker logs ashim-test --tail 50
|
||||
exit 1
|
||||
fi
|
||||
echo " Status: $STATUS (${i}0s elapsed)"
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "=== Step 4: Run Playwright tests ==="
|
||||
npx playwright test --config playwright.docker.config.ts --reporter=list 2>&1
|
||||
|
||||
TEST_EXIT=$?
|
||||
|
||||
echo "=== Step 5: Check Docker logs for errors ==="
|
||||
echo "--- Container logs (last 50 lines) ---"
|
||||
docker logs ashim-test --tail 50
|
||||
|
||||
echo "=== Step 6: Cleanup ==="
|
||||
docker stop ashim-test
|
||||
docker rm ashim-test
|
||||
|
||||
# Restart original container
|
||||
echo "=== Restarting original ashim container ==="
|
||||
docker start ashim 2>/dev/null || true
|
||||
|
||||
exit $TEST_EXIT
|
||||
12
tests/e2e-docker/auth.setup.ts
Normal file
12
tests/e2e-docker/auth.setup.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { expect, test as setup } from "@playwright/test";
|
||||
import { authFile } from "../../playwright.docker.config";
|
||||
|
||||
setup("authenticate", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("admin");
|
||||
await page.getByLabel("Password").fill("admin");
|
||||
await page.getByRole("button", { name: /login/i }).click();
|
||||
await page.waitForURL("/", { timeout: 30_000 });
|
||||
await expect(page).toHaveURL("/");
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
461
tests/e2e-docker/fixes-verification.spec.ts
Normal file
461
tests/e2e-docker/fixes-verification.spec.ts
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
|
||||
const SAMPLES_DIR = path.join(process.env.HOME ?? "/Users/sidd", "Downloads", "sample");
|
||||
const FIXTURES_DIR = path.join(process.cwd(), "tests", "fixtures");
|
||||
|
||||
function getSampleImage(name: string): string {
|
||||
const p = path.join(SAMPLES_DIR, name);
|
||||
if (fs.existsSync(p)) return p;
|
||||
throw new Error(`Sample image not found: ${p}`);
|
||||
}
|
||||
|
||||
function getFixture(name: string): string {
|
||||
return path.join(FIXTURES_DIR, name);
|
||||
}
|
||||
|
||||
async function uploadFiles(page: Page, filePaths: string[]): Promise<void> {
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
const dropzone = page.locator("[class*='border-dashed']").first();
|
||||
await dropzone.click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(filePaths);
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
async function waitForProcessingDone(page: Page, timeoutMs = 120_000): Promise<void> {
|
||||
try {
|
||||
const spinner = page.locator("[class*='animate-spin']");
|
||||
if (await spinner.isVisible({ timeout: 3000 })) {
|
||||
await spinner.waitFor({ state: "hidden", timeout: timeoutMs });
|
||||
}
|
||||
} catch {
|
||||
// No spinner — processing may have been instant
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// ─── 1. Error messages should never show [object Object] ─────────────
|
||||
|
||||
test.describe("Error message formatting", () => {
|
||||
test("tool validation error shows readable message, not [object Object]", async ({ request }) => {
|
||||
// Send a malformed request directly to trigger a validation error
|
||||
const res = await request.post("/api/v1/tools/resize", {
|
||||
multipart: {
|
||||
file: {
|
||||
name: "test.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
buffer: Buffer.from("not-an-image"),
|
||||
},
|
||||
settings: JSON.stringify({ width: 0 }),
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBeDefined();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error).not.toContain("[object Object]");
|
||||
if (body.details) {
|
||||
expect(typeof body.details).toBe("string");
|
||||
expect(body.details).not.toContain("[object Object]");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. Image-to-PDF auth fix ────────────────────────────────────────
|
||||
|
||||
test.describe("Image-to-PDF", () => {
|
||||
test("single image converts to PDF without auth error", async ({ page }) => {
|
||||
await page.goto("/image-to-pdf");
|
||||
|
||||
// image-to-pdf has its own upload UI — use the Upload button
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Upload from computer").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(getFixture("test-100x100.jpg"));
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const processBtn = page.getByRole("button", { name: /create pdf/i });
|
||||
await expect(processBtn).toBeEnabled({ timeout: 15_000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page);
|
||||
|
||||
// Should NOT see "Authentication required"
|
||||
const errorEl = page.locator(".text-red-500, [class*='text-red']");
|
||||
if (await errorEl.isVisible({ timeout: 3000 })) {
|
||||
const text = await errorEl.textContent();
|
||||
expect(text).not.toContain("Authentication required");
|
||||
expect(text).not.toContain("[object Object]");
|
||||
}
|
||||
|
||||
// Should see a download link
|
||||
const downloadLink = page.locator("a[download], a[href*='download']");
|
||||
await expect(downloadLink).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("multiple images create multi-page PDF", async ({ page }) => {
|
||||
await page.goto("/image-to-pdf");
|
||||
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Upload from computer").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles([getFixture("test-100x100.jpg"), getFixture("test-200x150.png")]);
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const processBtn = page.getByRole("button", { name: /create pdf/i });
|
||||
await expect(processBtn).toBeEnabled({ timeout: 15_000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page);
|
||||
|
||||
const errorEl = page.locator(".text-red-500, [class*='text-red']");
|
||||
if (await errorEl.isVisible({ timeout: 3000 })) {
|
||||
const text = await errorEl.textContent();
|
||||
expect(text).not.toContain("Authentication required");
|
||||
}
|
||||
|
||||
const downloadLink = page.locator("a[download], a[href*='download']");
|
||||
await expect(downloadLink).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. Split tool multi-file ────────────────────────────────────────
|
||||
|
||||
test.describe("Split tool", () => {
|
||||
test("single image splits into tiles", async ({ page }) => {
|
||||
await page.goto("/split");
|
||||
await uploadFiles(page, [getFixture("test-200x150.png")]);
|
||||
|
||||
// Use 2x2 preset
|
||||
await page.getByRole("button", { name: "2x2" }).click();
|
||||
|
||||
const processBtn = page.getByTestId("split-submit");
|
||||
await expect(processBtn).toBeEnabled({ timeout: 5000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page, 30_000);
|
||||
|
||||
// Should see tile preview grid
|
||||
const tiles = page.locator("button[title^='Download tile']");
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 10_000 });
|
||||
const tileCount = await tiles.count();
|
||||
expect(tileCount).toBe(4);
|
||||
|
||||
// ZIP download button should appear
|
||||
await expect(page.getByRole("button", { name: /download all/i })).toBeVisible();
|
||||
|
||||
// No errors
|
||||
const error = page.locator(".text-red-500");
|
||||
expect(await error.isVisible({ timeout: 1000 }).catch(() => false)).toBe(false);
|
||||
});
|
||||
|
||||
test("multiple images all get split with subfolders in ZIP", async ({ page }) => {
|
||||
await page.goto("/split");
|
||||
await uploadFiles(page, [getFixture("test-100x100.jpg"), getFixture("test-200x150.png")]);
|
||||
|
||||
await page.getByRole("button", { name: "2x2" }).click();
|
||||
|
||||
const processBtn = page.getByTestId("split-submit");
|
||||
await expect(processBtn).toBeEnabled({ timeout: 5000 });
|
||||
|
||||
// Button should indicate multiple images
|
||||
const btnText = await processBtn.textContent();
|
||||
expect(btnText).toContain("2 Images");
|
||||
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page, 60_000);
|
||||
|
||||
// ZIP download should appear
|
||||
await expect(page.getByRole("button", { name: /download all/i })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Tile preview should show tiles from first image
|
||||
const tiles = page.locator("button[title^='Download tile']");
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Summary should mention both images
|
||||
const summary = page.locator("text=images split");
|
||||
if (await summary.isVisible({ timeout: 2000 })) {
|
||||
const text = await summary.textContent();
|
||||
expect(text).toContain("2");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. Batch processing (tools that had single-file bug) ────────────
|
||||
|
||||
test.describe("Batch processing fixes", () => {
|
||||
test("strip-metadata processes multiple files", async ({ page }) => {
|
||||
await page.goto("/strip-metadata");
|
||||
await uploadFiles(page, [getFixture("test-with-exif.jpg"), getFixture("test-100x100.jpg")]);
|
||||
|
||||
// There should be a thumbnail strip with 2 entries
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const processBtn = page.getByRole("button", { name: /strip|remove|process/i });
|
||||
await expect(processBtn).toBeEnabled({ timeout: 5000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page, 30_000);
|
||||
|
||||
// Should see ZIP download button for batch
|
||||
const zipBtn = page.locator(
|
||||
"button:has-text('ZIP'), a:has-text('ZIP'), button:has-text('Download All')",
|
||||
);
|
||||
await expect(zipBtn).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("blur-faces processes multiple files", async ({ page }) => {
|
||||
await page.goto("/blur-faces");
|
||||
|
||||
// Use sample portraits
|
||||
const portrait = path.join(
|
||||
SAMPLES_DIR,
|
||||
"free-photo-of-black-and-white-portrait-of-a-smiling-woman.jpeg",
|
||||
);
|
||||
if (!fs.existsSync(portrait)) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await uploadFiles(page, [portrait, getFixture("test-portrait.jpg")]);
|
||||
|
||||
const processBtn = page.getByRole("button", { name: /blur|process/i });
|
||||
await expect(processBtn).toBeEnabled({ timeout: 5000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page, 60_000);
|
||||
|
||||
// Should see batch ZIP
|
||||
const zipBtn = page.locator(
|
||||
"button:has-text('ZIP'), a:has-text('ZIP'), button:has-text('Download All')",
|
||||
);
|
||||
await expect(zipBtn).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("vectorize processes multiple files", async ({ page }) => {
|
||||
await page.goto("/vectorize");
|
||||
await uploadFiles(page, [getFixture("test-100x100.jpg"), getFixture("test-50x50.webp")]);
|
||||
|
||||
const processBtn = page.getByTestId("vectorize-submit");
|
||||
await expect(processBtn).toBeEnabled({ timeout: 5000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page, 30_000);
|
||||
|
||||
// Should see ZIP download for batch
|
||||
const zipBtn = page.locator(
|
||||
"button:has-text('ZIP'), a:has-text('ZIP'), button:has-text('Download All')",
|
||||
);
|
||||
await expect(zipBtn).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. Passport photo error handling ────────────────────────────────
|
||||
|
||||
test.describe("Passport photo", () => {
|
||||
test("error message is readable, not [object Object]", async ({ page }) => {
|
||||
await page.goto("/passport-photo");
|
||||
|
||||
// Upload a non-face image to trigger face detection failure
|
||||
await uploadFiles(page, [getFixture("test-100x100.jpg")]);
|
||||
|
||||
// Wait for auto-analyze to run and potentially fail
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Check for error message
|
||||
const errorEl = page.locator(".text-red-500, [class*='text-red'], [class*='error']");
|
||||
if (await errorEl.isVisible({ timeout: 10_000 })) {
|
||||
const text = await errorEl.textContent();
|
||||
expect(text).not.toContain("[object Object]");
|
||||
if (text && text.length > 0) {
|
||||
expect(text.length).toBeGreaterThan(3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("passport photo works with real portrait", async ({ page }) => {
|
||||
const portrait = path.join(
|
||||
SAMPLES_DIR,
|
||||
"free-photo-of-black-and-white-portrait-of-a-smiling-woman.jpeg",
|
||||
);
|
||||
if (!fs.existsSync(portrait)) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto("/passport-photo");
|
||||
await uploadFiles(page, [portrait]);
|
||||
|
||||
// Wait for face analysis (uses MediaPipe + rembg, can be slow on CPU)
|
||||
await page.waitForTimeout(5000);
|
||||
const analyzeSpinner = page.locator("[class*='animate-spin']").first();
|
||||
if (await analyzeSpinner.isVisible({ timeout: 3000 })) {
|
||||
await analyzeSpinner.waitFor({ state: "hidden", timeout: 180_000 });
|
||||
}
|
||||
|
||||
// Face detection should succeed — look for the Generate button
|
||||
const generateBtn = page.getByRole("button", { name: /generate|create/i });
|
||||
const analyzeError = page.locator("p.text-red-500");
|
||||
const gotButton = await generateBtn.isVisible({ timeout: 10_000 }).catch(() => false);
|
||||
const gotError = await analyzeError.isVisible({ timeout: 1000 }).catch(() => false);
|
||||
if (gotError) {
|
||||
const text = await analyzeError.textContent();
|
||||
expect(text).not.toContain("[object Object]");
|
||||
}
|
||||
expect(gotButton || gotError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 6. OCR modes ────────────────────────────────────────────────────
|
||||
|
||||
test.describe("OCR", () => {
|
||||
test("fast mode works and returns engine info", async ({ page }) => {
|
||||
await page.goto("/ocr");
|
||||
await uploadFiles(page, [getFixture("test-100x100.jpg")]);
|
||||
|
||||
// Select fast mode
|
||||
const fastBtn = page.getByRole("button", { name: /fast/i });
|
||||
if (await fastBtn.isVisible({ timeout: 2000 })) {
|
||||
await fastBtn.click();
|
||||
}
|
||||
|
||||
const processBtn = page.getByRole("button", { name: /extract|scan|process/i });
|
||||
await expect(processBtn).toBeEnabled({ timeout: 5000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page, 60_000);
|
||||
|
||||
// Should not show [object Object]
|
||||
const errorEl = page.locator(".text-red-500, [class*='text-red']");
|
||||
if (await errorEl.isVisible({ timeout: 3000 })) {
|
||||
const text = await errorEl.textContent();
|
||||
expect(text).not.toContain("[object Object]");
|
||||
}
|
||||
});
|
||||
|
||||
test("balanced mode works", async ({ page }) => {
|
||||
await page.goto("/ocr");
|
||||
await uploadFiles(page, [getFixture("test-100x100.jpg")]);
|
||||
|
||||
const balancedBtn = page.getByRole("button", { name: /balanced/i });
|
||||
if (await balancedBtn.isVisible({ timeout: 2000 })) {
|
||||
await balancedBtn.click();
|
||||
}
|
||||
|
||||
const processBtn = page.getByRole("button", { name: /extract|scan|process/i });
|
||||
await expect(processBtn).toBeEnabled({ timeout: 5000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page, 120_000);
|
||||
|
||||
const errorEl = page.locator(".text-red-500, [class*='text-red']");
|
||||
if (await errorEl.isVisible({ timeout: 5000 })) {
|
||||
const text = await errorEl.textContent();
|
||||
expect(text).not.toContain("[object Object]");
|
||||
}
|
||||
});
|
||||
|
||||
test("best mode works", async ({ page }) => {
|
||||
await page.goto("/ocr");
|
||||
await uploadFiles(page, [getFixture("test-100x100.jpg")]);
|
||||
|
||||
const bestBtn = page.getByRole("button", { name: /best/i });
|
||||
if (await bestBtn.isVisible({ timeout: 2000 })) {
|
||||
await bestBtn.click();
|
||||
}
|
||||
|
||||
const processBtn = page.getByRole("button", { name: /extract|scan|process/i });
|
||||
await expect(processBtn).toBeEnabled({ timeout: 5000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page, 120_000);
|
||||
|
||||
const errorEl = page.locator(".text-red-500, [class*='text-red']");
|
||||
if (await errorEl.isVisible({ timeout: 5000 })) {
|
||||
const text = await errorEl.textContent();
|
||||
expect(text).not.toContain("[object Object]");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 7. Common tools still work (regression check) ──────────────────
|
||||
|
||||
test.describe("Regression checks", () => {
|
||||
test("resize single image works", async ({ page }) => {
|
||||
await page.goto("/resize");
|
||||
await uploadFiles(page, [getFixture("test-200x150.png")]);
|
||||
|
||||
// Set explicit width so the button enables
|
||||
const widthInput = page.getByLabel("Width (px)");
|
||||
await widthInput.fill("100");
|
||||
|
||||
const processBtn = page.getByTestId("resize-submit");
|
||||
await expect(processBtn).toBeEnabled({ timeout: 15_000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page);
|
||||
|
||||
const error = page.locator(".text-red-500");
|
||||
expect(await error.isVisible({ timeout: 2000 }).catch(() => false)).toBe(false);
|
||||
});
|
||||
|
||||
test("compress single image works", async ({ page }) => {
|
||||
await page.goto("/compress");
|
||||
await uploadFiles(page, [getFixture("test-100x100.jpg")]);
|
||||
|
||||
const processBtn = page.getByRole("button", { name: /compress/i });
|
||||
await expect(processBtn).toBeEnabled({ timeout: 5000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page);
|
||||
|
||||
const error = page.locator(".text-red-500");
|
||||
expect(await error.isVisible({ timeout: 2000 }).catch(() => false)).toBe(false);
|
||||
});
|
||||
|
||||
test("resize batch processes multiple files", async ({ page }) => {
|
||||
await page.goto("/resize");
|
||||
await uploadFiles(page, [getFixture("test-100x100.jpg"), getFixture("test-200x150.png")]);
|
||||
|
||||
// Set explicit width so the button enables
|
||||
const widthInput = page.getByLabel("Width (px)");
|
||||
await widthInput.fill("50");
|
||||
|
||||
const processBtn = page.getByTestId("resize-submit");
|
||||
await expect(processBtn).toBeEnabled({ timeout: 15_000 });
|
||||
await processBtn.click();
|
||||
await waitForProcessingDone(page, 30_000);
|
||||
|
||||
// Batch should produce ZIP
|
||||
const zipBtn = page.locator(
|
||||
"button:has-text('ZIP'), a:has-text('ZIP'), button:has-text('Download All')",
|
||||
);
|
||||
await expect(zipBtn).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 8. Docker container health and logging ──────────────────────────
|
||||
|
||||
test.describe("Container health", () => {
|
||||
test("health endpoint returns healthy", async ({ request }) => {
|
||||
const res = await request.get("/api/v1/health");
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe("healthy");
|
||||
});
|
||||
|
||||
test("API returns structured errors, not HTML", async ({ request }) => {
|
||||
const res = await request.get("/api/v1/tools/nonexistent");
|
||||
const contentType = res.headers()["content-type"] ?? "";
|
||||
expect(contentType).toContain("application/json");
|
||||
});
|
||||
|
||||
test("error responses have string details, not objects", async ({ request }) => {
|
||||
const formData = new URLSearchParams();
|
||||
const res = await request.post("/api/v1/tools/resize", {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
data: formData.toString(),
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
const body = await res.json();
|
||||
if (body.details) {
|
||||
expect(typeof body.details).toBe("string");
|
||||
}
|
||||
if (body.error) {
|
||||
expect(typeof body.error).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue