Compare commits

...

24 commits

Author SHA1 Message Date
ashim-hq
15a63e57c3 chore: remove docs/superpowers from tracking (local-only) 2026-04-18 12:19:31 +08:00
ashim-hq
204fd14ad5 docs: update plan for recent Dockerfile, bridge, and Python changes
Remove unnecessary lazy import steps (base packages are always
installed). Update Dockerfile task for cuDNN base, node-bins stage,
pip cache mounts. Add parallel model downloads and NCCL conflict
handling to install script requirements.
2026-04-18 02:15:32 +08:00
ashim-hq
ae411f2d72 chore(release): 1.15.9 [skip ci] 2026-04-17 23:26:26 +08:00
ashim-hq
31424d4356 docs: add crash recovery and robustness to spec and plan
Atomic model downloads (.downloading suffix + rename), file-based
install lock (survives container restart), atomic JSON writes,
startup recovery sequence, frontend double-click prevention,
SSE fallback polling, disk space pre-checks.
2026-04-17 23:08:35 +08:00
Ashim
08a7ffe403 Enhance logging and error handling across tools; add full tool audit and Playwright tests
- Added model mismatch warnings in colorize, enhance-faces, and upscale routes.
- Improved error handling in colorize, enhance_faces, remove_bg, restore, and upscale scripts with detailed logging.
- Updated Dockerfile to align NCCL versions for compatibility.
- Introduced a new full tool audit script to test all tools for functionality and GPU usage.
- Created Playwright E2E tests for GPU-dependent tools to ensure proper functionality and performance.
2026-04-17 23:06:31 +08:00
ashim-hq
51f60a8269 Refactor code structure for improved readability and maintainability
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Docker Build Test (push) Has been cancelled
Deploy Docs to GitHub Pages / build (push) Has been cancelled
Deploy Docs to GitHub Pages / deploy (push) Has been cancelled
2026-04-17 17:07:31 +08:00
semantic-release-bot
95af281584 chore(release): 1.15.8 [skip ci]
## [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](536125ec9f))
2026-04-17 07:36:40 +00:00
ashim-hq
536125ec9f fix: copy Node.js from official image instead of apt-get install
Ubuntu mirrors (security.ubuntu.com) are frequently unreachable from
GitHub Actions runners, causing all amd64 Docker builds to fail.

Instead of installing Node.js via NodeSource apt repo (which requires
working Ubuntu mirrors for the initial apt-get update), copy the Node
binary and modules directly from the official node:22-bookworm image.

Also add retry with backoff to the system deps apt-get update.
2026-04-17 15:36:05 +08:00
semantic-release-bot
caa2160ef8 chore(release): 1.15.7 [skip ci]
## [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](3d6db5a32d))
2026-04-17 07:04:40 +00:00
ashim-hq
3d6db5a32d fix: add retry with backoff for apt-get update on CUDA base image
Ubuntu security mirrors can be unreachable from GitHub Actions runners.
Add a retry loop with exponential backoff (15s, 30s, 45s) around
apt-get update in the Node.js install step for the CUDA base image.
2026-04-17 15:04:03 +08:00
semantic-release-bot
23dae8d152 chore(release): 1.15.6 [skip ci]
## [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](79c4ed6a35))
2026-04-17 06:55:08 +00:00
ashim-hq
79c4ed6a35 perf: parallelize model downloads and switch to registry cache
- Parallelize all 14 model downloads using ThreadPoolExecutor (6 workers)
  Downloads were sequential (~30 min), now concurrent (~5-10 min)
- Switch Docker cache from type=gha to type=registry (GHCR)
  GHA cache has 10 GB limit causing blob eviction and corrupted builds
  Registry cache has no size limit and persists across runner instances
- Add pip download cache mounts to all pip install layers
  Prevents re-downloading packages when layers rebuild
2026-04-17 14:54:23 +08:00
semantic-release-bot
2fd0c00564 chore(release): 1.15.5 [skip ci]
## [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](8df18c56a6))
2026-04-17 06:41:05 +00:00
ashim-hq
d148229d03 chore: remove docs/superpowers from tracking
Already in .gitignore — this removes the file that was committed before
the ignore rule was added.
2026-04-17 14:38:50 +08:00
ashim-hq
94db94cb28 docs: align spec with recent error handling and testing changes
Reference new parseApiError, formatZodErrors, global error handler,
and playwright.docker.config.ts infrastructure. Remove stale
partialTools concept since every tool maps to exactly one bundle.
2026-04-17 14:35:36 +08:00
ashim-hq
8df18c56a6 fix: exclude e2e-docker tests from Vitest runner
The Playwright-based Docker e2e tests use test.describe() which is
incompatible with Vitest. Exclude tests/e2e-docker/ from Vitest's
test discovery, matching the existing tests/e2e/ exclusion.
2026-04-17 14:24:14 +08:00
semantic-release-bot
3dc98374e2 chore(release): 1.15.4 [skip ci]
## [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](32239600ae))
* verbose errors, batch processing, multi-file support ([#1](https://github.com/ashim-hq/ashim/issues/1)) ([8b87cf8](8b87cf888c))
2026-04-17 06:21:10 +00:00
ashim-hq
8b87cf888c fix: verbose errors, batch processing, multi-file support (#1)
- Eliminate [object Object] errors across all 20+ API routes
- Global Fastify error handler with full stack traces
- Image-to-PDF auth fix (Object.entries → headers.forEach)
- OCR verbose fallbacks with engine reporting
- Split multi-file with per-image subfolders in ZIP
- Batch support for blur-faces, strip-metadata, edit-metadata, vectorize
- Docker LOG_LEVEL=debug, PYTHONWARNINGS=default
- 20 Playwright e2e tests pass against Docker container
2026-04-17 14:19:57 +08:00
ashim-hq
32239600ae fix: verbose error handling, batch processing, and multi-file support
- Replace [object Object] errors with readable messages across all 20+ API
  routes by normalizing Zod validation errors to strings (formatZodErrors)
- Add parseApiError() on frontend to defensively handle any details type
- Add global Fastify error handler with full stack traces in logs
- Fix image-to-pdf auth: Object.entries(headers) → headers.forEach()
- Fix passport-photo: safeParse + formatZodErrors, safe error extraction
- Fix OCR silent fallbacks: log exception type/message when falling back,
  include actual engine used in API response and Docker logs
- Fix split tool: process all uploaded images, combine into ZIP with
  subfolders per image
- Fix batch support for blur-faces, strip-metadata, edit-metadata,
  vectorize: add processAllFiles branch for multi-file uploads
- Docker: LOG_LEVEL=debug, PYTHONWARNINGS=default for visibility
- Add Playwright e2e tests verifying all fixes against Docker container
2026-04-17 14:15:27 +08:00
ashim-hq
2e2dbbb8e0 docs: strict one-bundle-per-tool, lock full tools, docker-only testing
Revised bundles so every tool belongs to exactly one bundle with no
partial functionality. OCR and noise-removal fully locked until
their bundle is installed. passport-photo includes mediapipe in the
Background Removal bundle. restore-photo gets its own bundle.
Development/testing always via Docker container.
2026-04-17 13:01:14 +08:00
ashim-hq
959ece3a35 docs: improve on-demand AI features spec with architectural fixes
Address single-venv strategy (avoid two-venv fragility), shared
package uninstall via reference counting, tool route registration
for uninstalled features (501 instead of 404), multi-bundle tool
graceful degradation, frontend feature status propagation, and
local development compatibility.
2026-04-17 12:13:50 +08:00
ashim-hq
b9fbf9db67 docs: add design spec for on-demand AI feature downloads
Reduce Docker image from ~30GB to ~5-6GB by making AI features
downloadable post-install. Users cherry-pick feature bundles
(Background Removal, OCR, etc.) from the UI after pulling.
2026-04-17 11:31:35 +08:00
semantic-release-bot
b76b9a1878 chore(release): 1.15.3 [skip ci]
## [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](cec71632d0))
2026-04-16 19:46:27 +00:00
ashim-hq
cec71632d0 fix: retry apt-get update on transient mirror sync errors (Acquire::Retries=3) 2026-04-17 03:45:42 +08:00
69 changed files with 1679 additions and 256 deletions

View file

@ -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

View file

@ -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: |

View file

@ -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)

View file

@ -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>

View file

@ -1,6 +1,6 @@
{
"name": "@ashim/api",
"version": "1.15.2",
"version": "1.15.9",
"private": true,
"type": "module",
"scripts": {

View file

@ -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

View 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("; ");
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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 {

View file

@ -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 {

View file

@ -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)}`,

View file

@ -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 {

View file

@ -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;

View file

@ -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;

View file

@ -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)}`,

View file

@ -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 {

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -7,6 +7,7 @@ import type { FastifyInstance } from "fastify";
import * as mupdf from "mupdf";
import sharp from "sharp";
import { z } from "zod";
import { 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 {

View file

@ -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),
});
}

View file

@ -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");

View file

@ -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 {

View file

@ -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 {

View file

@ -7,6 +7,7 @@ import PQueue from "p-queue";
import sharp from "sharp";
import { z } from "zod";
import { env } from "../../config.js";
import { 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 {

View file

@ -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)}`,

View file

@ -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 {

View file

@ -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 {

View file

@ -1,6 +1,6 @@
{
"name": "@ashim/docs",
"version": "1.15.2",
"version": "1.15.9",
"private": true,
"scripts": {
"docs:dev": "vitepress dev .",

View file

@ -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

View file

@ -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;

View file

@ -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 (

View file

@ -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}`);
}

View file

@ -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]);

View file

@ -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();

View file

@ -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)` }}

View file

@ -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;

View file

@ -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 {

View file

@ -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}`;

View file

@ -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}`;
}

View file

@ -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 {

View file

@ -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

View file

@ -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.")

View file

@ -1,6 +1,6 @@
{
"name": "ashim",
"version": "1.15.2",
"version": "1.15.9",
"private": true,
"packageManager": "pnpm@9.15.4",
"scripts": {

View file

@ -1,6 +1,6 @@
{
"name": "@ashim/ai",
"version": "1.15.2",
"version": "1.15.9",
"private": true,
"type": "module",
"main": "./src/index.ts",

View file

@ -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:

View file

@ -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()

View file

@ -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")

View file

@ -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():

View file

@ -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)}))

View file

@ -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:

View file

@ -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

View file

@ -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"

View file

@ -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;

View file

@ -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,
};
}

View file

@ -1,6 +1,6 @@
{
"name": "@ashim/image-engine",
"version": "1.15.2",
"version": "1.15.9",
"private": true,
"type": "module",
"main": "./src/index.ts",

View file

@ -1,6 +1,6 @@
{
"name": "@ashim/shared",
"version": "1.15.2",
"version": "1.15.9",
"private": true,
"type": "module",
"main": "./src/index.ts",

View file

@ -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).

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

View 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 });
});

View 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");
}
});
});

View 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); });

View 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);
});

View file

@ -24,6 +24,7 @@ export default defineConfig({
},
exclude: [
"tests/e2e/**",
"tests/e2e-docker/**",
"**/node_modules/**",
"**/dist/**",
"**/.{idea,git,cache,output,temp}/**",