mirror of
https://github.com/ashim-hq/ashim
synced 2026-04-21 13:37:52 +00:00
Compare commits
24 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15a63e57c3 | ||
|
|
204fd14ad5 | ||
|
|
ae411f2d72 | ||
|
|
31424d4356 | ||
|
|
08a7ffe403 | ||
|
|
51f60a8269 | ||
|
|
95af281584 | ||
|
|
536125ec9f | ||
|
|
caa2160ef8 | ||
|
|
3d6db5a32d | ||
|
|
23dae8d152 | ||
|
|
79c4ed6a35 | ||
|
|
2fd0c00564 | ||
|
|
d148229d03 | ||
|
|
94db94cb28 | ||
|
|
8df18c56a6 | ||
|
|
3dc98374e2 | ||
|
|
8b87cf888c | ||
|
|
32239600ae | ||
|
|
2e2dbbb8e0 | ||
|
|
959ece3a35 | ||
|
|
b9fbf9db67 | ||
|
|
b76b9a1878 | ||
|
|
cec71632d0 |
69 changed files with 1679 additions and 256 deletions
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
|
|
@ -99,6 +99,13 @@ jobs:
|
|||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR (for registry cache)
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
|
|
@ -106,5 +113,5 @@ jobs:
|
|||
push: false
|
||||
tags: ashim:ci
|
||||
build-args: SKIP_MODEL_DOWNLOADS=true
|
||||
cache-from: type=gha,scope=unified
|
||||
cache-to: type=gha,mode=max,scope=unified
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:cache-linux-amd64
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache-ci,mode=max
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -121,8 +121,8 @@ jobs:
|
|||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=ashimhq/ashim,ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:cache-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache-${{ env.PLATFORM_PAIR }},mode=max
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
|
|
|
|||
43
CHANGELOG.md
43
CHANGELOG.md
|
|
@ -1,3 +1,46 @@
|
|||
## [1.15.8](https://github.com/ashim-hq/ashim/compare/v1.15.7...v1.15.8) (2026-04-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* copy Node.js from official image instead of apt-get install ([536125e](https://github.com/ashim-hq/ashim/commit/536125ec9fbc5758bdd3e8f0f4c44253de2231c7))
|
||||
|
||||
## [1.15.7](https://github.com/ashim-hq/ashim/compare/v1.15.6...v1.15.7) (2026-04-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add retry with backoff for apt-get update on CUDA base image ([3d6db5a](https://github.com/ashim-hq/ashim/commit/3d6db5a32d3f9292d5476e9bd80c1f06624fa316))
|
||||
|
||||
## [1.15.6](https://github.com/ashim-hq/ashim/compare/v1.15.5...v1.15.6) (2026-04-17)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* parallelize model downloads and switch to registry cache ([79c4ed6](https://github.com/ashim-hq/ashim/commit/79c4ed6a359b008dedeb154a55e2764ee0e3d9fa))
|
||||
|
||||
## [1.15.5](https://github.com/ashim-hq/ashim/compare/v1.15.4...v1.15.5) (2026-04-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* exclude e2e-docker tests from Vitest runner ([8df18c5](https://github.com/ashim-hq/ashim/commit/8df18c56a6c69e2a188cf0e7c97e692cd4c0e7ec))
|
||||
|
||||
## [1.15.4](https://github.com/ashim-hq/ashim/compare/v1.15.3...v1.15.4) (2026-04-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* verbose error handling, batch processing, and multi-file support ([3223960](https://github.com/ashim-hq/ashim/commit/32239600ae6ce30628e77e61a805ca0a167b5068))
|
||||
* verbose errors, batch processing, multi-file support ([#1](https://github.com/ashim-hq/ashim/issues/1)) ([8b87cf8](https://github.com/ashim-hq/ashim/commit/8b87cf888c6194e2af427a180607d7c53a1d15b9))
|
||||
|
||||
## [1.15.3](https://github.com/ashim-hq/ashim/compare/v1.15.2...v1.15.3) (2026-04-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* retry apt-get update on transient mirror sync errors (Acquire::Retries=3) ([cec7163](https://github.com/ashim-hq/ashim/commit/cec71632d0c868c3a413b813ff15baccc8fa8cdd))
|
||||
|
||||
## [1.15.2](https://github.com/ashim-hq/ashim/compare/v1.15.1...v1.15.2) (2026-04-16)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
<p align="center">
|
||||
<img src="apps/web/public/logo-192.png" width="80" alt="ashim logo">
|
||||
<img src="social-preview.png" width="800" alt="ashim — A Self Hosted Image Manipulator">
|
||||
</p>
|
||||
|
||||
<h1 align="center">ashim</h1>
|
||||
|
||||
<p align="center">A Self Hosted Image Manipulator</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hub.docker.com/r/ashimhq/ashim"><img src="https://img.shields.io/docker/v/ashimhq/ashim?label=Docker%20Hub&logo=docker" alt="Docker Hub"></a>
|
||||
<a href="https://github.com/orgs/ashim-hq/packages/container/package/ashim"><img src="https://img.shields.io/badge/GHCR-ghcr.io%2Fashim--hq%2Fashim-blue?logo=github" alt="GHCR"></a>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@ashim/api",
|
||||
"version": "1.15.2",
|
||||
"version": "1.15.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -142,6 +142,13 @@ export function registerColorize(app: FastifyInstance) {
|
|||
});
|
||||
}
|
||||
|
||||
if (model !== "auto" && result.method !== model) {
|
||||
request.log.warn(
|
||||
{ toolId: "colorize", requested: model, actual: result.method },
|
||||
`Colorize model mismatch: requested ${model} but used ${result.method}`,
|
||||
);
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -122,6 +122,13 @@ export function registerEnhanceFaces(app: FastifyInstance) {
|
|||
});
|
||||
}
|
||||
|
||||
if (model !== "auto" && result.model !== model) {
|
||||
request.log.warn(
|
||||
{ toolId: "enhance-faces", requested: model, actual: result.model },
|
||||
`Face enhance model mismatch: requested ${model} but used ${result.model}`,
|
||||
);
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
|||
originalSize: fileBuffer.length,
|
||||
processedSize: transparentResult.length,
|
||||
filename,
|
||||
model: settings.model,
|
||||
});
|
||||
} catch (err) {
|
||||
request.log.error({ err, toolId: "remove-background" }, "Background removal failed");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -166,6 +166,13 @@ export function registerUpscale(app: FastifyInstance) {
|
|||
});
|
||||
}
|
||||
|
||||
if (model !== "auto" && result.method !== model) {
|
||||
request.log.warn(
|
||||
{ toolId: "upscale", requested: model, actual: result.method },
|
||||
`Upscale model mismatch: requested ${model} but used ${result.method}`,
|
||||
);
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@ashim/docs",
|
||||
"version": "1.15.2",
|
||||
"version": "1.15.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docs:dev": "vitepress dev .",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@ashim/web",
|
||||
"version": "1.15.2",
|
||||
"version": "1.15.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 226 KiB |
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,11 @@ RUN set -e; \
|
|||
# Stage 3: Platform-specific base images
|
||||
# ============================================
|
||||
FROM node:22-bookworm AS base-linux-arm64
|
||||
FROM nvidia/cuda:12.6.3-runtime-ubuntu24.04 AS base-linux-amd64
|
||||
FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu24.04 AS base-linux-amd64
|
||||
|
||||
# Node.js donor: provides Node binaries for the CUDA amd64 image without
|
||||
# relying on NodeSource apt repos or Ubuntu mirrors (which are flaky on CI).
|
||||
FROM node:22-bookworm AS node-bins
|
||||
|
||||
# ============================================
|
||||
# Stage 4: Production runtime
|
||||
|
|
@ -125,24 +129,21 @@ ARG SKIP_MODEL_DOWNLOADS=false
|
|||
# binary without downloading it on each container start.
|
||||
ENV COREPACK_HOME=/usr/local/share/corepack
|
||||
|
||||
# Install Node.js on amd64 (CUDA base has no Node; arm64 base already has it)
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates gnupg && \
|
||||
mkdir -p /etc/apt/keyrings && \
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | \
|
||||
gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
|
||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" > \
|
||||
/etc/apt/sources.list.d/nodesource.list && \
|
||||
apt-get update && apt-get install -y nodejs && \
|
||||
rm -rf /var/lib/apt/lists/* \
|
||||
; fi
|
||||
# Install Node.js on amd64 by copying from the official node image.
|
||||
# This avoids flaky Ubuntu/NodeSource apt mirrors that frequently fail on CI.
|
||||
COPY --from=node-bins /usr/local/bin/node /usr/local/bin/
|
||||
COPY --from=node-bins /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack && \
|
||||
ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate && \
|
||||
chmod -R a+rX /usr/local/share/corepack
|
||||
|
||||
# System dependencies (all platforms)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Retry apt-get update with backoff — Ubuntu mirrors can be flaky on CI runners
|
||||
RUN for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $((i * 15)); done && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
tini \
|
||||
imagemagick \
|
||||
libraw-dev \
|
||||
|
|
@ -164,7 +165,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
COPY --from=caire-builder /tmp/caire /usr/local/bin/caire
|
||||
|
||||
# Python venv - Layer 1: Base packages (rarely change, ~3 GB)
|
||||
RUN python3 -m venv /opt/venv && \
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install --upgrade pip && \
|
||||
/opt/venv/bin/pip install \
|
||||
Pillow==11.1.0 \
|
||||
|
|
@ -172,14 +174,16 @@ RUN python3 -m venv /opt/venv && \
|
|||
opencv-python-headless==4.10.0.84
|
||||
|
||||
# Platform-conditional ONNX runtime
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
/opt/venv/bin/pip install onnxruntime-gpu==1.20.1 \
|
||||
; else \
|
||||
/opt/venv/bin/pip install onnxruntime==1.20.1 \
|
||||
; fi
|
||||
|
||||
# Python venv - Layer 2: Tool packages (change occasionally, ~2 GB)
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
/opt/venv/bin/pip install rembg==2.0.62 && \
|
||||
/opt/venv/bin/pip install realesrgan==0.3.0 \
|
||||
--extra-index-url https://download.pytorch.org/whl/cu126 && \
|
||||
|
|
@ -193,17 +197,32 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
|||
; fi
|
||||
|
||||
# mediapipe 0.10.21 only has amd64 wheels; arm64 maxes out at 0.10.18
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
/opt/venv/bin/pip install mediapipe==0.10.21 \
|
||||
; else \
|
||||
/opt/venv/bin/pip install mediapipe==0.10.18 \
|
||||
; fi
|
||||
|
||||
# CodeFormer face enhancement (install with --no-deps to avoid numpy 2.x conflict)
|
||||
RUN /opt/venv/bin/pip install --no-deps codeformer-pip==0.0.4 lpips
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
/opt/venv/bin/pip install --no-deps codeformer-pip==0.0.4 lpips
|
||||
|
||||
# Re-pin numpy to 1.26.4 in case any transitive dep upgraded it
|
||||
RUN /opt/venv/bin/pip install numpy==1.26.4
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
/opt/venv/bin/pip install numpy==1.26.4
|
||||
|
||||
# Re-align NCCL to match torch's requirement. paddlepaddle-gpu pins an older
|
||||
# nvidia-nccl-cu12 which silently downgrades the version installed by torch,
|
||||
# causing "undefined symbol: ncclCommShrink" at import time. NCCL is ABI-
|
||||
# backwards-compatible, so the newer version satisfies both packages.
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
/opt/venv/bin/pip install $(/opt/venv/bin/python3 -c \
|
||||
"from importlib.metadata import requires; \
|
||||
print([r.split(';')[0].strip() for r in requires('torch') \
|
||||
if 'nccl' in r][0])") \
|
||||
; fi
|
||||
|
||||
# Pin rembg model storage to a fixed path so models downloaded at build time
|
||||
# (as root) are found at runtime (as the non-root ashim user, home=/app).
|
||||
|
|
@ -220,7 +239,7 @@ COPY docker/download_models.py /tmp/download_models.py
|
|||
RUN if [ "$SKIP_MODEL_DOWNLOADS" = "true" ]; then \
|
||||
echo "Skipping model downloads (CI build)"; \
|
||||
else \
|
||||
/opt/venv/bin/python3 /tmp/download_models.py; \
|
||||
CUDA_VISIBLE_DEVICES="" /opt/venv/bin/python3 /tmp/download_models.py; \
|
||||
fi && rm -f /tmp/download_models.py && \
|
||||
# Symlink PaddleX model dir into both possible HOME locations so models are
|
||||
# found regardless of whether HOME=/root (build/root context) or HOME=/app
|
||||
|
|
@ -291,14 +310,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
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,37 @@ os.environ["PADDLE_DEVICE"] = "cpu"
|
|||
os.environ["FLAGS_use_cuda"] = "0"
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
||||
|
||||
# Prevent ONNX Runtime from loading the CUDA Execution Provider at build time.
|
||||
# The cudnn-runtime base image includes cuDNN, which makes ONNX try to init
|
||||
# the CUDA EP. Without a GPU driver (only available at container runtime),
|
||||
# this segfaults. Temporarily renaming the provider .so is the most reliable
|
||||
# way to prevent this — env vars alone are not enough.
|
||||
def _hide_cuda_provider():
|
||||
"""Rename ONNX CUDA provider .so to prevent load at build time."""
|
||||
try:
|
||||
import onnxruntime as _ort
|
||||
ep_dir = os.path.join(os.path.dirname(_ort.__file__), "capi")
|
||||
for name in ("libonnxruntime_providers_cuda.so", "libonnxruntime_providers_tensorrt.so"):
|
||||
src = os.path.join(ep_dir, name)
|
||||
if os.path.exists(src):
|
||||
os.rename(src, src + ".build_hide")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
def _restore_cuda_provider():
|
||||
"""Restore hidden ONNX CUDA provider .so after build-time downloads."""
|
||||
try:
|
||||
import onnxruntime as _ort
|
||||
ep_dir = os.path.join(os.path.dirname(_ort.__file__), "capi")
|
||||
for name in ("libonnxruntime_providers_cuda.so", "libonnxruntime_providers_tensorrt.so"):
|
||||
bak = os.path.join(ep_dir, name + ".build_hide")
|
||||
if os.path.exists(bak):
|
||||
os.rename(bak, os.path.join(ep_dir, name))
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
_hide_cuda_provider()
|
||||
|
||||
LAMA_MODEL_DIR = "/opt/models/lama"
|
||||
LAMA_MODEL_URL = "https://huggingface.co/Carve/LaMa-ONNX/resolve/main/lama_fp32.onnx"
|
||||
LAMA_MODEL_PATH = os.path.join(LAMA_MODEL_DIR, "lama_fp32.onnx")
|
||||
|
|
@ -638,23 +669,55 @@ def smoke_test():
|
|||
|
||||
|
||||
def main():
|
||||
print("Pre-downloading all ML models...\n")
|
||||
download_lama_model()
|
||||
download_rembg_models()
|
||||
download_realesrgan_model()
|
||||
download_gfpgan_model()
|
||||
download_codeformer_model()
|
||||
download_ddcolor_model()
|
||||
download_codeformer_onnx_model()
|
||||
download_paddleocr_models()
|
||||
download_paddleocr_vl_model()
|
||||
download_scunet_model()
|
||||
download_nafnet_model()
|
||||
download_facexlib_models()
|
||||
download_opencv_colorize_models()
|
||||
download_mediapipe_task_models()
|
||||
import concurrent.futures
|
||||
import threading
|
||||
|
||||
print("Pre-downloading all ML models (parallel)...\n")
|
||||
print_lock = threading.Lock()
|
||||
|
||||
# All download functions are independent (separate dirs, separate CDNs).
|
||||
# Run them in parallel to cut download time from ~30 min to ~5-10 min.
|
||||
download_fns = [
|
||||
download_lama_model,
|
||||
download_rembg_models,
|
||||
download_realesrgan_model,
|
||||
download_gfpgan_model,
|
||||
download_codeformer_model,
|
||||
download_ddcolor_model,
|
||||
download_codeformer_onnx_model,
|
||||
download_paddleocr_models,
|
||||
download_paddleocr_vl_model,
|
||||
download_scunet_model,
|
||||
download_nafnet_model,
|
||||
download_facexlib_models,
|
||||
download_opencv_colorize_models,
|
||||
download_mediapipe_task_models,
|
||||
]
|
||||
|
||||
# 6 workers balances parallelism with CDN rate limits
|
||||
errors = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
|
||||
future_to_name = {
|
||||
pool.submit(fn): fn.__name__ for fn in download_fns
|
||||
}
|
||||
for future in concurrent.futures.as_completed(future_to_name):
|
||||
name = future_to_name[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
errors.append((name, e))
|
||||
print(f"\n*** {name} FAILED: {e}\n")
|
||||
|
||||
if errors:
|
||||
print(f"\n{len(errors)} download(s) failed:")
|
||||
for name, e in errors:
|
||||
print(f" {name}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print("\nAll downloads complete. Running verification...\n")
|
||||
verify_mediapipe()
|
||||
smoke_test()
|
||||
_restore_cuda_provider()
|
||||
print("All models downloaded and verified.")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ashim",
|
||||
"version": "1.15.2",
|
||||
"version": "1.15.9",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@ashim/ai",
|
||||
"version": "1.15.2",
|
||||
"version": "1.15.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
|
|
|||
|
|
@ -188,18 +188,29 @@ def main():
|
|||
if os.path.exists(DDCOLOR_MODEL_PATH):
|
||||
result_bgr, method = colorize_ddcolor(img_bgr, intensity)
|
||||
elif model_choice == "ddcolor":
|
||||
emit_progress(10, "DDColor model not found, using fallback")
|
||||
raise FileNotFoundError(f"DDColor model not found: {DDCOLOR_MODEL_PATH}")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[colorize] DDColor failed: {e}", file=sys.stderr, flush=True)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
if model_choice == "ddcolor":
|
||||
emit_progress(10, f"DDColor failed: {str(e)[:50]}")
|
||||
# User explicitly requested ddcolor — fail, don't degrade
|
||||
raise
|
||||
result_bgr = None
|
||||
|
||||
# Try OpenCV fallback
|
||||
# Try OpenCV fallback only in auto mode
|
||||
if result_bgr is None and model_choice in ("auto", "opencv"):
|
||||
try:
|
||||
if os.path.exists(OPENCV_PROTO_PATH) and os.path.exists(OPENCV_MODEL_PATH):
|
||||
result_bgr, method = colorize_opencv(img_bgr, intensity)
|
||||
except Exception:
|
||||
elif model_choice == "opencv":
|
||||
raise FileNotFoundError(f"OpenCV colorize models not found: {OPENCV_PROTO_PATH}")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[colorize] OpenCV fallback failed: {e}", file=sys.stderr, flush=True)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
if model_choice == "opencv":
|
||||
raise
|
||||
result_bgr = None
|
||||
|
||||
if result_bgr is None:
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ available_modules = {}
|
|||
def _try_import(name, import_fn):
|
||||
try:
|
||||
available_modules[name] = import_fn()
|
||||
except ImportError:
|
||||
pass
|
||||
except ImportError as e:
|
||||
print(f"[dispatcher] Module '{name}' not available: {e}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
_try_import("PIL", lambda: __import__("PIL"))
|
||||
|
|
@ -94,6 +94,8 @@ def _run_script_main(script_name, args):
|
|||
except SystemExit as e:
|
||||
exit_code = e.code if isinstance(e.code, int) else 1
|
||||
except Exception as e:
|
||||
# Log full traceback to stderr for diagnostics
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
# Write error to the captured stdout
|
||||
sys.stdout.write(json.dumps({"success": False, "error": str(e)}) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
|
@ -129,9 +131,10 @@ def main():
|
|||
try:
|
||||
from gpu import gpu_available
|
||||
gpu = gpu_available()
|
||||
except ImportError:
|
||||
pass
|
||||
except ImportError as e:
|
||||
print(f"[dispatcher] GPU detection failed: {e}", file=sys.stderr, flush=True)
|
||||
print(json.dumps({"ready": True, "gpu": gpu}), file=sys.stderr, flush=True)
|
||||
print(f"[dispatcher] Ready. GPU: {gpu}. Modules: {list(available_modules.keys())}", file=sys.stderr, flush=True)
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ except (ImportError, ModuleNotFoundError):
|
|||
_shim = types.ModuleType("torchvision.transforms.functional_tensor")
|
||||
_shim.rgb_to_grayscale = _F.rgb_to_grayscale
|
||||
sys.modules["torchvision.transforms.functional_tensor"] = _shim
|
||||
except ImportError:
|
||||
pass # torchvision not installed at all
|
||||
except ImportError as e:
|
||||
print(f"[enhance-faces] torchvision shim failed: {e}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def emit_progress(percent, stage):
|
||||
|
|
@ -196,6 +196,9 @@ def enhance_with_codeformer(img_array, fidelity_weight):
|
|||
finally:
|
||||
torch.cuda.is_available = _orig_cuda_check
|
||||
|
||||
if restored_bgr is None:
|
||||
raise RuntimeError("CodeFormer returned no result (face detection may have failed)")
|
||||
|
||||
restored_rgb = restored_bgr[:, :, ::-1].copy()
|
||||
return restored_rgb
|
||||
|
||||
|
|
@ -258,7 +261,9 @@ def main():
|
|||
# progress and init messages to stdout which would corrupt
|
||||
# our JSON result.
|
||||
stdout_fd = os.dup(1)
|
||||
sys.stdout.flush() # Flush before redirect to avoid mixing buffers
|
||||
os.dup2(2, 1)
|
||||
sys.stdout = os.fdopen(1, "w", closefd=False) # Rebind sys.stdout to new fd 1
|
||||
|
||||
enhanced = None
|
||||
model_used = None
|
||||
|
|
@ -281,14 +286,19 @@ def main():
|
|||
fidelity_weight = 1.0 - strength
|
||||
enhanced = enhance_with_codeformer(img_array, fidelity_weight)
|
||||
model_used = "codeformer"
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[enhance-faces] CodeFormer failed, falling back to GFPGAN: {e}", file=sys.stderr, flush=True)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
enhanced = enhance_with_gfpgan(img_array, only_center_face)
|
||||
model_used = "gfpgan"
|
||||
|
||||
finally:
|
||||
# Restore stdout after ALL AI processing
|
||||
sys.stdout.flush()
|
||||
os.dup2(stdout_fd, 1)
|
||||
os.close(stdout_fd)
|
||||
sys.stdout = sys.__stdout__ # Restore Python-level stdout
|
||||
|
||||
if enhanced is None:
|
||||
raise RuntimeError("Face enhancement failed: no model available")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""Runtime GPU/CUDA detection utility."""
|
||||
import ctypes
|
||||
import functools
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
|
|
@ -11,16 +13,36 @@ def gpu_available():
|
|||
if override is not None and override.lower() in ("0", "false", "no"):
|
||||
return False
|
||||
|
||||
# Use torch.cuda as the source of truth. It actually probes
|
||||
# the hardware. onnxruntime's get_available_providers() only
|
||||
# reports compiled-in backends, not whether a GPU exists.
|
||||
# Use torch.cuda as the source of truth when available. It actually
|
||||
# probes the hardware. Fall back to onnxruntime provider detection
|
||||
# when torch is not installed (e.g. CPU-only images without PyTorch).
|
||||
try:
|
||||
import torch
|
||||
return torch.cuda.is_available()
|
||||
except ImportError:
|
||||
pass
|
||||
avail = torch.cuda.is_available()
|
||||
if avail:
|
||||
name = torch.cuda.get_device_name(0)
|
||||
print(f"[gpu] CUDA available via torch: {name}", file=sys.stderr, flush=True)
|
||||
else:
|
||||
print("[gpu] torch loaded but CUDA not available", file=sys.stderr, flush=True)
|
||||
return avail
|
||||
except ImportError as e:
|
||||
print(f"[gpu] torch not importable: {e}", file=sys.stderr, flush=True)
|
||||
|
||||
return False
|
||||
# Fallback: check if onnxruntime's CUDA provider can actually load.
|
||||
# get_available_providers() only reports *compiled-in* backends, not whether
|
||||
# the required libraries (cuDNN, etc.) are present at runtime. We verify
|
||||
# by trying to load the provider shared library — this transitively checks
|
||||
# that cuDNN is installed.
|
||||
try:
|
||||
import onnxruntime as _ort
|
||||
if "CUDAExecutionProvider" not in _ort.get_available_providers():
|
||||
return False
|
||||
ep_dir = os.path.dirname(_ort.__file__)
|
||||
ctypes.CDLL(os.path.join(ep_dir, "capi", "libonnxruntime_providers_cuda.so"))
|
||||
return True
|
||||
except (ImportError, OSError) as e:
|
||||
print(f"[gpu] ONNX CUDA provider not functional: {e}", file=sys.stderr, flush=True)
|
||||
return False
|
||||
|
||||
|
||||
def onnx_providers():
|
||||
|
|
|
|||
|
|
@ -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)}))
|
||||
|
|
|
|||
|
|
@ -93,7 +93,8 @@ def main():
|
|||
alpha_matting_foreground_threshold=240,
|
||||
alpha_matting_background_threshold=10,
|
||||
)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
print(f"[remove-bg] Alpha matting failed ({e}), using standard removal", file=sys.stderr, flush=True)
|
||||
output_data = remove(input_data, session=session)
|
||||
|
||||
emit_progress(80, "Background removed")
|
||||
|
|
@ -107,11 +108,12 @@ def main():
|
|||
|
||||
result = json.dumps({"success": True, "model": model})
|
||||
|
||||
except ImportError:
|
||||
except ImportError as e:
|
||||
print(f"[remove-bg] Import failed: {e}", file=sys.stderr, flush=True)
|
||||
result = json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "rembg is not installed. Install with: pip install rembg[cpu]",
|
||||
"error": f"rembg import failed: {e}",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -368,7 +368,8 @@ def enhance_faces(img_bgr, fidelity=0.7):
|
|||
# Run inference
|
||||
try:
|
||||
output = session.run(None, model_inputs)[0][0] # (3, 512, 512)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
print(f"[restore] CodeFormer inference failed for face {i}: {e}", file=sys.stderr, flush=True)
|
||||
continue
|
||||
|
||||
# Postprocess: [-1, 1] -> [0, 255], RGB -> BGR
|
||||
|
|
@ -478,8 +479,8 @@ def colorize_bw(img_bgr, intensity=0.85):
|
|||
from gpu import gpu_available
|
||||
if gpu_available():
|
||||
providers.insert(0, "CUDAExecutionProvider")
|
||||
except ImportError:
|
||||
pass
|
||||
except ImportError as e:
|
||||
print(f"[restore] GPU detection unavailable: {e}", file=sys.stderr, flush=True)
|
||||
|
||||
session = ort.InferenceSession(DDCOLOR_MODEL_PATH, providers=providers)
|
||||
input_name = session.get_inputs()[0].name
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ except (ImportError, ModuleNotFoundError):
|
|||
_shim = types.ModuleType("torchvision.transforms.functional_tensor")
|
||||
_shim.rgb_to_grayscale = _F.rgb_to_grayscale
|
||||
sys.modules["torchvision.transforms.functional_tensor"] = _shim
|
||||
except ImportError:
|
||||
pass # torchvision not installed at all, Real-ESRGAN unavailable
|
||||
except ImportError as e:
|
||||
print(f"[upscale] torchvision shim failed: {e}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def emit_progress(percent, stage):
|
||||
|
|
@ -167,14 +167,19 @@ def main():
|
|||
os.dup2(stdout_fd, 1)
|
||||
os.close(stdout_fd)
|
||||
|
||||
except (ImportError, FileNotFoundError, RuntimeError, OSError):
|
||||
# RealESRGAN unavailable or failed
|
||||
except (ImportError, FileNotFoundError, RuntimeError, OSError) as e:
|
||||
import traceback
|
||||
print(f"[upscale] Real-ESRGAN failed: {e}", file=sys.stderr, flush=True)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
if model_choice == "realesrgan":
|
||||
emit_progress(15, "AI model not available, using fast resize")
|
||||
# User explicitly requested realesrgan — fail, don't degrade
|
||||
raise RuntimeError(f"Real-ESRGAN unavailable: {e}") from e
|
||||
result = None
|
||||
|
||||
# Fall back to Lanczos
|
||||
# Lanczos path: used when explicitly requested or as auto fallback
|
||||
if result is None:
|
||||
if model_choice not in ("auto", "lanczos"):
|
||||
raise RuntimeError(f"Requested model '{model_choice}' is not available")
|
||||
emit_progress(50, "Upscaling with Lanczos")
|
||||
result = img.resize(new_size, Image.LANCZOS)
|
||||
method = "lanczos"
|
||||
|
|
|
|||
|
|
@ -32,11 +32,26 @@ function extractPythonError(error: unknown): string {
|
|||
if (trimmed && !trimmed.startsWith("Traceback")) {
|
||||
return trimmed;
|
||||
}
|
||||
// Extract the last meaningful line from a Python Traceback
|
||||
if (trimmed) {
|
||||
const lines = trimmed
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const lastLine = lines[lines.length - 1];
|
||||
if (lastLine && lastLine !== "Traceback (most recent call last):") {
|
||||
return lastLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (execError.message) return execError.message;
|
||||
// Return empty string for {stdout, stderr} objects with no useful content
|
||||
// so the caller's fallback message (e.g. exit code) kicks in.
|
||||
return "";
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +100,9 @@ function startDispatcher(): ChildProcess | null {
|
|||
if (parsed.ready === true) {
|
||||
dispatcherReady = true;
|
||||
dispatcherGpuAvailable = parsed.gpu === true;
|
||||
console.log(
|
||||
`[bridge] Python dispatcher ready (GPU: ${parsed.gpu === true})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +115,11 @@ function startDispatcher(): ChildProcess | null {
|
|||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON - collect as error output for pending requests
|
||||
// Not JSON - forward diagnostic messages to Node.js logger,
|
||||
// collect the rest as error output for pending requests.
|
||||
if (trimmed.startsWith("[")) {
|
||||
console.log(`[python] ${trimmed}`);
|
||||
}
|
||||
for (const req of pendingRequests.values()) {
|
||||
req.stderrLines.push(trimmed);
|
||||
}
|
||||
|
|
@ -121,14 +143,17 @@ function startDispatcher(): ChildProcess | null {
|
|||
if (pending) {
|
||||
pendingRequests.delete(reqId);
|
||||
if (response.exitCode !== 0) {
|
||||
pending.reject(
|
||||
new Error(
|
||||
extractPythonError({
|
||||
stdout: response.stdout,
|
||||
stderr: pending.stderrLines.join("\n"),
|
||||
}) || `Python script exited with code ${response.exitCode}`,
|
||||
),
|
||||
);
|
||||
const errText =
|
||||
extractPythonError({
|
||||
stdout: response.stdout,
|
||||
stderr: pending.stderrLines.join("\n"),
|
||||
}) ||
|
||||
(response.exitCode === 137
|
||||
? "Process killed (out of memory) — try a lighter model or smaller image"
|
||||
: response.exitCode === 139
|
||||
? "Process crashed (segmentation fault)"
|
||||
: `Python script exited with code ${response.exitCode}`);
|
||||
pending.reject(new Error(errText));
|
||||
} else {
|
||||
pending.resolve({
|
||||
stdout: response.stdout || "",
|
||||
|
|
@ -143,6 +168,7 @@ function startDispatcher(): ChildProcess | null {
|
|||
});
|
||||
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
console.error(`[bridge] Dispatcher error: ${err.message} (code: ${err.code})`);
|
||||
if (err.code === "ENOENT") {
|
||||
// Venv python not found - mark as failed, will fall back to per-request
|
||||
dispatcherFailed = true;
|
||||
|
|
@ -307,7 +333,7 @@ function runPythonPerRequest(
|
|||
}
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
|
||||
if (stderrBuffer.trim()) {
|
||||
|
|
@ -322,7 +348,16 @@ function runPythonPerRequest(
|
|||
const stderr = stderrLines.join("\n");
|
||||
|
||||
if (code !== 0) {
|
||||
// When the process was killed by a signal, use a clear message
|
||||
// instead of surfacing unrelated stderr (e.g. CUDA warnings).
|
||||
const signalMsg =
|
||||
signal === "SIGKILL" || code === 137
|
||||
? "Process killed (out of memory) — try a lighter model or smaller image"
|
||||
: signal === "SIGSEGV" || code === 139
|
||||
? "Process crashed (segmentation fault)"
|
||||
: null;
|
||||
const errorText =
|
||||
signalMsg ||
|
||||
extractPythonError({ stdout: stdout.trim(), stderr }) ||
|
||||
`Python script exited with code ${code}`;
|
||||
rejectPromise(new Error(errorText));
|
||||
|
|
@ -361,6 +396,9 @@ export function runPythonWithProgress(
|
|||
// Retry in an isolated per-request process which starts clean and has
|
||||
// more available memory than the warm dispatcher.
|
||||
if (err.message === "Python dispatcher exited unexpectedly") {
|
||||
console.warn(
|
||||
`[bridge] Dispatcher crashed during ${scriptName}, retrying with per-request process`,
|
||||
);
|
||||
return runPythonPerRequest(scriptName, args, options);
|
||||
}
|
||||
throw err;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@ashim/image-engine",
|
||||
"version": "1.15.2",
|
||||
"version": "1.15.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@ashim/shared",
|
||||
"version": "1.15.2",
|
||||
"version": "1.15.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
|
|
|||
|
|
@ -1180,7 +1180,7 @@ export const PRINT_LAYOUTS: PrintLayout[] = [
|
|||
{ id: "none", label: "None", width: 0, height: 0 },
|
||||
];
|
||||
|
||||
export const APP_VERSION = "1.15.2";
|
||||
export const APP_VERSION = "1.15.9";
|
||||
|
||||
/**
|
||||
* Tool IDs that require the Python sidecar (AI/ML tools).
|
||||
|
|
|
|||
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
|
||||
BIN
social-preview.png
Normal file
BIN
social-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
248
tests/e2e-docker/full-tool-audit.mjs
Normal file
248
tests/e2e-docker/full-tool-audit.mjs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* FULL TOOL AUDIT — Tests every tool in ashim on Windows/amd64.
|
||||
* Verifies: (1) tool works, (2) GPU tools use GPU not CPU fallback,
|
||||
* (3) no unexpected model/method downgrades.
|
||||
*/
|
||||
const BASE = "http://localhost:1349";
|
||||
const USERNAME = "admin";
|
||||
const PASSWORD = "qFIJS2KcQ0NuUfZ0";
|
||||
const IMG = "C:/Users/siddh/Downloads/passport-photo-sample-correct.webp";
|
||||
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const results = [];
|
||||
let token = "";
|
||||
|
||||
function log(tool, status, detail = "") {
|
||||
const icon = status === "PASS" ? "\u2713" : status === "FAIL" ? "\u2717" : "-";
|
||||
const line = `${icon} [${status}] ${tool}${detail ? " -- " + detail : ""}`;
|
||||
console.log(line);
|
||||
results.push({ tool, status, detail });
|
||||
}
|
||||
|
||||
async function callTool(path, settings, filename = "test.webp") {
|
||||
const imageBuffer = readFileSync(IMG);
|
||||
const imageBlob = new Blob([imageBuffer], { type: "image/webp" });
|
||||
const formData = new FormData();
|
||||
formData.append("file", new File([imageBlob], filename, { type: "image/webp" }));
|
||||
formData.append("settings", JSON.stringify(settings));
|
||||
const res = await fetch(`${BASE}/api/v1/tools/${path}`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status} (non-JSON)` }));
|
||||
return { status: res.status, ok: res.ok, body };
|
||||
}
|
||||
|
||||
async function test(name, path, settings, checks = {}) {
|
||||
try {
|
||||
const { status, ok, body } = await callTool(path, settings);
|
||||
if (!ok || body.error) {
|
||||
log(name, "FAIL", body.details || body.error || `HTTP ${status}`);
|
||||
return;
|
||||
}
|
||||
// Check expected model/method
|
||||
if (checks.expectKey && checks.expectValue) {
|
||||
const actual = body[checks.expectKey];
|
||||
if (actual !== checks.expectValue) {
|
||||
log(name, "FAIL", `Expected ${checks.expectKey}=${checks.expectValue} but got ${actual} (FALLBACK DETECTED)`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Build detail string
|
||||
const parts = [];
|
||||
for (const k of ["method", "model", "engine", "format", "width", "height", "facesDetected", "steps"]) {
|
||||
if (body[k] !== undefined) {
|
||||
const v = Array.isArray(body[k]) ? JSON.stringify(body[k]) : body[k];
|
||||
parts.push(`${k}=${v}`);
|
||||
}
|
||||
}
|
||||
log(name, "PASS", parts.join(", "));
|
||||
} catch (err) {
|
||||
log(name, "FAIL", err.message.slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=============================================================");
|
||||
console.log(" ASHIM FULL TOOL AUDIT — Windows amd64 + RTX 4070");
|
||||
console.log("=============================================================\n");
|
||||
|
||||
// Login
|
||||
const loginRes = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: USERNAME, password: PASSWORD }),
|
||||
});
|
||||
const loginBody = await loginRes.json();
|
||||
token = loginBody.token;
|
||||
console.log("Authenticated.\n");
|
||||
|
||||
// Check GPU status
|
||||
const healthRes = await fetch(`${BASE}/api/v1/admin/health`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const health = await healthRes.json();
|
||||
console.log(`GPU detected: ${health.ai?.gpu}`);
|
||||
console.log(`Version: ${health.version}\n`);
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// SECTION 1: GPU/AI TOOLS — verify correct model, no fallbacks
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
console.log("--- GPU/AI TOOLS (must use GPU, no CPU fallback) ---\n");
|
||||
|
||||
// Background removal — all models
|
||||
await test("Remove BG (birefnet-general-lite)", "remove-background", { model: "birefnet-general-lite" }, { expectKey: "model", expectValue: "birefnet-general-lite" });
|
||||
await test("Remove BG (birefnet-portrait)", "remove-background", { model: "birefnet-portrait" }, { expectKey: "model", expectValue: "birefnet-portrait" });
|
||||
await test("Remove BG (birefnet-general)", "remove-background", { model: "birefnet-general" }, { expectKey: "model", expectValue: "birefnet-general" });
|
||||
await test("Remove BG (u2net)", "remove-background", { model: "u2net" }, { expectKey: "model", expectValue: "u2net" });
|
||||
await test("Remove BG (bria-rmbg)", "remove-background", { model: "bria-rmbg" }, { expectKey: "model", expectValue: "bria-rmbg" });
|
||||
await test("Remove BG (isnet-general-use)", "remove-background", { model: "isnet-general-use" }, { expectKey: "model", expectValue: "isnet-general-use" });
|
||||
await test("Remove BG (birefnet-matting/Ultra)", "remove-background", { model: "birefnet-matting" }, { expectKey: "model", expectValue: "birefnet-matting" });
|
||||
|
||||
// Upscale
|
||||
await test("Upscale (realesrgan 2x)", "upscale", { scale: 2, model: "realesrgan" }, { expectKey: "method", expectValue: "realesrgan" });
|
||||
await test("Upscale (realesrgan 4x)", "upscale", { scale: 4, model: "realesrgan" }, { expectKey: "method", expectValue: "realesrgan" });
|
||||
await test("Upscale (lanczos 2x)", "upscale", { scale: 2, model: "lanczos" });
|
||||
await test("Upscale (auto)", "upscale", { scale: 2, model: "auto" });
|
||||
|
||||
// Face enhancement
|
||||
await test("Face Enhance (gfpgan)", "enhance-faces", { model: "gfpgan" }, { expectKey: "model", expectValue: "gfpgan" });
|
||||
await test("Face Enhance (codeformer)", "enhance-faces", { model: "codeformer" });
|
||||
await test("Face Enhance (auto)", "enhance-faces", { model: "auto" });
|
||||
|
||||
// Colorize
|
||||
await test("Colorize (ddcolor)", "colorize", { model: "ddcolor" }, { expectKey: "method", expectValue: "ddcolor" });
|
||||
await test("Colorize (auto)", "colorize", { model: "auto" });
|
||||
|
||||
// Noise removal — all tiers
|
||||
await test("Noise Removal (quick)", "noise-removal", { tier: "quick" });
|
||||
await test("Noise Removal (balanced)", "noise-removal", { tier: "balanced" });
|
||||
await test("Noise Removal (quality/SCUNet)", "noise-removal", { tier: "quality" });
|
||||
await test("Noise Removal (maximum/NAFNet)", "noise-removal", { tier: "maximum" });
|
||||
|
||||
// Photo restoration
|
||||
await test("Photo Restoration", "restore-photo", {});
|
||||
|
||||
// OCR
|
||||
await test("OCR (tesseract)", "ocr", { engine: "tesseract" }, { expectKey: "engine", expectValue: "tesseract" });
|
||||
await test("OCR (paddleocr)", "ocr", { engine: "paddleocr" }, { expectKey: "engine", expectValue: "paddleocr-v5" });
|
||||
|
||||
// Face operations (MediaPipe)
|
||||
await test("Face Blur", "blur-faces", { intensity: 50 });
|
||||
await test("Red-Eye Removal", "red-eye-removal", {});
|
||||
|
||||
// Content-aware resize (caire binary)
|
||||
await test("Content-Aware Resize", "content-aware-resize", { width: 800, height: 800 });
|
||||
|
||||
// Erase object (LaMa inpainting — needs mask, likely fails without one)
|
||||
// Skipping as it needs a mask input
|
||||
|
||||
// Smart crop
|
||||
await test("Smart Crop", "smart-crop", { width: 400, height: 400 });
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// SECTION 2: IMAGE PROCESSING TOOLS (Sharp-based, CPU)
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
console.log("\n--- IMAGE PROCESSING TOOLS (Sharp-based) ---\n");
|
||||
|
||||
await test("Resize", "resize", { width: 512, height: 512, fit: "cover" });
|
||||
await test("Crop", "crop", { left: 100, top: 100, width: 500, height: 500 });
|
||||
await test("Rotate (90)", "rotate", { angle: 90 });
|
||||
await test("Rotate (45 + fill)", "rotate", { angle: 45, background: "#ffffff" });
|
||||
await test("Compress (webp q50)", "compress", { quality: 50 });
|
||||
await test("Convert (to PNG)", "convert", { format: "png" });
|
||||
await test("Convert (to JPEG)", "convert", { format: "jpg", quality: 85 });
|
||||
await test("Image Enhancement (auto)", "image-enhancement", { preset: "auto" });
|
||||
// color-adjustments is part of image-enhancement, not a separate tool
|
||||
await test("Image Enhancement (vivid)", "image-enhancement", { preset: "vivid" });
|
||||
await test("Sharpening", "sharpening", { sigma: 1.5, amount: 1.0 });
|
||||
await test("Border", "border", { size: 20, color: "#ff0000" });
|
||||
await test("Replace Color", "replace-color", { targetColor: "#ffffff", replacementColor: "#000000", tolerance: 30 });
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// SECTION 3: UTILITY TOOLS
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
console.log("\n--- UTILITY TOOLS ---\n");
|
||||
|
||||
await test("Info (metadata)", "info", {});
|
||||
await test("Strip Metadata", "strip-metadata", {});
|
||||
await test("Image to Base64", "image-to-base64", {});
|
||||
await test("Optimize for Web", "optimize-for-web", { maxWidth: 1920, quality: 80 });
|
||||
// Favicon returns binary ICO, not JSON — test via status code only
|
||||
try {
|
||||
const imageBuffer = readFileSync(IMG);
|
||||
const imageBlob = new Blob([imageBuffer], { type: "image/webp" });
|
||||
const formData = new FormData();
|
||||
formData.append("file", new File([imageBlob], "test.webp", { type: "image/webp" }));
|
||||
formData.append("settings", JSON.stringify({}));
|
||||
const res = await fetch(`${BASE}/api/v1/tools/favicon`, {
|
||||
method: "POST", headers: { Authorization: `Bearer ${token}` }, body: formData,
|
||||
});
|
||||
log("Favicon", res.ok ? "PASS" : "FAIL", `HTTP ${res.status}, ${res.headers.get('content-type')}`);
|
||||
} catch (e) { log("Favicon", "FAIL", e.message.slice(0, 100)); }
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// SECTION 4: MULTI-IMAGE / SPECIAL TOOLS (may need special input)
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
console.log("\n--- SPECIAL TOOLS (may need specific inputs) ---\n");
|
||||
|
||||
await test("QR Generate", "qr-generate", { text: "https://ashim.app", size: 512, format: "png" });
|
||||
await test("Text Overlay", "text-overlay", { text: "TEST", fontSize: 48, color: "#ff0000", position: "center" });
|
||||
await test("Vectorize", "vectorize", {});
|
||||
await test("SVG to Raster", "svg-to-raster", {}); // Will fail - needs SVG input
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// SUMMARY
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
console.log("\n=============================================================");
|
||||
console.log(" SUMMARY");
|
||||
console.log("=============================================================\n");
|
||||
|
||||
const passed = results.filter(r => r.status === "PASS");
|
||||
const failed = results.filter(r => r.status === "FAIL");
|
||||
|
||||
console.log(`PASSED: ${passed.length}`);
|
||||
console.log(`FAILED: ${failed.length}`);
|
||||
console.log(`TOTAL: ${results.length}\n`);
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.log("FAILURES:");
|
||||
for (const r of failed) {
|
||||
// Truncate long error messages
|
||||
const detail = r.detail.length > 150 ? r.detail.slice(0, 150) + "..." : r.detail;
|
||||
console.log(` \u2717 ${r.tool}: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check GPU usage in docker logs
|
||||
console.log("\n--- GPU USAGE CHECK ---\n");
|
||||
const { execSync } = await import("child_process");
|
||||
const logs = execSync('docker logs ashim 2>&1', { encoding: 'utf-8', maxBuffer: 1024 * 1024 });
|
||||
const gpuLines = logs.split('\n').filter(l =>
|
||||
l.includes('[gpu]') || l.includes('[bridge]') || l.includes('[dispatcher]') ||
|
||||
l.includes('GPU') || l.includes('CUDA') || l.includes('CUDAExecution')
|
||||
);
|
||||
for (const line of gpuLines.slice(0, 15)) {
|
||||
console.log(" " + line.trim().slice(0, 120));
|
||||
}
|
||||
|
||||
// Check for any fallback warnings
|
||||
console.log("\n--- FALLBACK/MISMATCH WARNINGS ---\n");
|
||||
const warnLines = logs.split('\n').filter(l =>
|
||||
l.includes('mismatch') || l.includes('fallback') || l.includes('Falling back') ||
|
||||
l.includes('degraded') || l.includes('lanczos') && l.includes('warn')
|
||||
);
|
||||
if (warnLines.length === 0) {
|
||||
console.log(" None detected - no silent fallbacks occurred.");
|
||||
} else {
|
||||
for (const line of warnLines) {
|
||||
console.log(" WARNING: " + line.trim().slice(0, 150));
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(failed.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error("Fatal:", err); process.exit(1); });
|
||||
159
tests/e2e-docker/playwright-gpu-test.mjs
Normal file
159
tests/e2e-docker/playwright-gpu-test.mjs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Playwright E2E test for all GPU-dependent tools on Windows/amd64.
|
||||
* Uses the API directly (multipart upload) with browser auth context.
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = "http://localhost:1349";
|
||||
const USERNAME = "admin";
|
||||
const PASSWORD = "qFIJS2KcQ0NuUfZ0";
|
||||
const TEST_IMAGE = "C:/Users/siddh/Downloads/passport-photo-sample-correct.webp";
|
||||
|
||||
const results = [];
|
||||
|
||||
function log(tool, status, detail = "") {
|
||||
const icon = status === "PASS" ? "\u2713" : status === "FAIL" ? "\u2717" : "!";
|
||||
console.log(`${icon} ${tool}: ${status}${detail ? " - " + detail : ""}`);
|
||||
results.push({ tool, status, detail });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=== Ashim GPU Tools E2E Test ===\n");
|
||||
|
||||
// Login via API
|
||||
const loginRes = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: USERNAME, password: PASSWORD }),
|
||||
});
|
||||
const { token } = await loginRes.json();
|
||||
console.log("Logged in.\n");
|
||||
|
||||
const { readFileSync } = await import("fs");
|
||||
const imageBuffer = readFileSync(TEST_IMAGE);
|
||||
const imageBlob = new Blob([imageBuffer], { type: "image/webp" });
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: "Remove Background",
|
||||
path: "remove-background",
|
||||
settings: { model: "birefnet-general-lite" },
|
||||
resultKey: "model",
|
||||
},
|
||||
{
|
||||
name: "Remove Background (portrait)",
|
||||
path: "remove-background",
|
||||
settings: { model: "birefnet-portrait" },
|
||||
resultKey: "model",
|
||||
},
|
||||
{
|
||||
name: "Upscale (realesrgan)",
|
||||
path: "upscale",
|
||||
settings: { scale: 2, model: "realesrgan" },
|
||||
resultKey: "method",
|
||||
},
|
||||
{
|
||||
name: "Face Enhancement (gfpgan)",
|
||||
path: "enhance-faces",
|
||||
settings: { model: "gfpgan" },
|
||||
resultKey: "model",
|
||||
},
|
||||
{
|
||||
name: "Face Enhancement (codeformer)",
|
||||
path: "enhance-faces",
|
||||
settings: { model: "codeformer" },
|
||||
resultKey: "model",
|
||||
},
|
||||
{
|
||||
name: "Colorize",
|
||||
path: "colorize",
|
||||
settings: { model: "auto" },
|
||||
resultKey: "method",
|
||||
},
|
||||
{
|
||||
name: "Noise Removal (quality/SCUNet)",
|
||||
path: "noise-removal",
|
||||
settings: { tier: "quality" },
|
||||
resultKey: null,
|
||||
},
|
||||
{
|
||||
name: "Photo Restoration",
|
||||
path: "restore-photo",
|
||||
settings: {},
|
||||
resultKey: "steps",
|
||||
},
|
||||
{
|
||||
name: "Face Blur",
|
||||
path: "blur-faces",
|
||||
settings: { intensity: 50 },
|
||||
resultKey: "facesDetected",
|
||||
},
|
||||
{
|
||||
name: "Red-Eye Removal",
|
||||
path: "red-eye-removal",
|
||||
settings: {},
|
||||
resultKey: "facesDetected",
|
||||
},
|
||||
{
|
||||
name: "OCR (tesseract)",
|
||||
path: "ocr",
|
||||
settings: { engine: "tesseract" },
|
||||
resultKey: "engine",
|
||||
},
|
||||
{
|
||||
name: "OCR (paddleocr)",
|
||||
path: "ocr",
|
||||
settings: { engine: "paddleocr" },
|
||||
resultKey: "engine",
|
||||
},
|
||||
];
|
||||
|
||||
for (const tool of tools) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", new File([imageBlob], "test.webp", { type: "image/webp" }));
|
||||
formData.append("settings", JSON.stringify(tool.settings));
|
||||
|
||||
const res = await fetch(`${BASE}/api/v1/tools/${tool.path}`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
|
||||
if (res.ok && !body.error) {
|
||||
const val = tool.resultKey ? body[tool.resultKey] : "ok";
|
||||
const detail = Array.isArray(val)
|
||||
? JSON.stringify(val)
|
||||
: val !== undefined
|
||||
? `${tool.resultKey}=${val}`
|
||||
: "";
|
||||
log(tool.name, "PASS", detail);
|
||||
} else {
|
||||
log(tool.name, "FAIL", body.details || body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log(tool.name, "FAIL", err.message.slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log("\n=== Summary ===");
|
||||
const passed = results.filter((r) => r.status === "PASS").length;
|
||||
const failed = results.filter((r) => r.status === "FAIL").length;
|
||||
console.log(`Passed: ${passed} Failed: ${failed} Total: ${results.length}`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log("\nFailed tests:");
|
||||
for (const r of results.filter((r) => r.status === "FAIL")) {
|
||||
console.log(` x ${r.tool}: ${r.detail}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -24,6 +24,7 @@ export default defineConfig({
|
|||
},
|
||||
exclude: [
|
||||
"tests/e2e/**",
|
||||
"tests/e2e-docker/**",
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/.{idea,git,cache,output,temp}/**",
|
||||
|
|
|
|||
Loading…
Reference in a new issue